feat: integrate image_make with MindSpace assets

This commit is contained in:
john
2026-07-19 18:34:50 +08:00
parent 2ce1527edd
commit 4959535b5e
14 changed files with 768 additions and 14 deletions
+7
View File
@@ -252,6 +252,13 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
# MINDSPACE_AGENT_JOBS_ENABLED=true
# MINDSPACE_INTERNAL_AGENT_SECRET=local-dev-secret
# MINDSPACE_FREE_PUBLIC_PAGE_LIMIT=10
# image_make 独立生图服务。只有 URL、Token 与 memind_adm 资产能力开关同时配置后才会调用。
# IMAGE_MAKE_BASE_URL=http://127.0.0.1:8080
# IMAGE_MAKE_TOKEN=dev-token
# IMAGE_MAKE_REQUEST_TIMEOUT_MS=15000
# IMAGE_MAKE_GENERATION_TIMEOUT_MS=600000
# IMAGE_MAKE_POLL_INTERVAL_MS=1000
# IMAGE_MAKE_MAX_RESULT_BYTES=20971520
# ---------------------------------------------------------------------------
# Memind runtime profile(本地 vs 生产 必须区分)
+88 -9
View File
@@ -12,7 +12,7 @@ export const ASSET_PLUGIN_CATALOG = [
id: 'asset-generate',
label: '图片生成',
description: '异步生成页面所需图片;关闭或失败时必须降级为无图页面。',
providers: ['flux-schnell', 'comfyui'],
providers: ['image_make', 'flux-schnell', 'comfyui'],
supportsLlm: true,
},
{
@@ -31,6 +31,18 @@ export const ASSET_PLUGIN_CATALOG = [
},
];
export const IMAGE_MAKE_PURPOSE_CATALOG = [
{ id: 'inline_image', label: '页面正文图片', presetId: 'memind_square_illustration' },
{ id: 'hero', label: '页面头图', presetId: 'memind_dark_hero' },
{ id: 'card_cover', label: '卡片封面', presetId: 'memind_card_cover' },
{ id: 'feed_cover', label: '信息流与缩略图源图', presetId: 'memind_feed_cover_source' },
];
const imageMakePurposeIds = new Set(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => purpose.id));
const disabledImageMakePurposes = Object.freeze(
Object.fromEntries(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => [purpose.id, false])),
);
const pluginById = new Map(ASSET_PLUGIN_CATALOG.map((plugin) => [plugin.id, plugin]));
function normalizePluginId(value) {
@@ -48,9 +60,34 @@ function toBoolean(value, defaultValue = false) {
return Boolean(value);
}
function parseJsonObject(value) {
if (!value) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
try {
const parsed = JSON.parse(String(value));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function normalizeImageMakePurposes(value, fallback = disabledImageMakePurposes) {
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return Object.fromEntries(
IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => [
purpose.id,
toBoolean(raw[purpose.id], Boolean(fallback?.[purpose.id])),
]),
);
}
function rowImageMakePurposes(row) {
return normalizeImageMakePurposes(parseJsonObject(row?.config_json)?.purposes);
}
function rowToPluginConfig(row, providerKey = null) {
const plugin = pluginById.get(row.plugin_id);
return {
const config = {
pluginId: row.plugin_id,
label: plugin?.label ?? row.plugin_id,
description: plugin?.description ?? '',
@@ -64,6 +101,11 @@ function rowToPluginConfig(row, providerKey = null) {
llmModel: row.llm_model ?? null,
updatedAt: Number(row.updated_at ?? 0) || null,
};
if (row.plugin_id === 'asset-generate') {
config.purposes = rowImageMakePurposes(row);
config.purposeCatalog = IMAGE_MAKE_PURPOSE_CATALOG;
}
return config;
}
/**
@@ -140,6 +182,12 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
llmProviderId: null,
llmModel: null,
updatedAt: null,
...(plugin.id === 'asset-generate'
? {
purposes: { ...disabledImageMakePurposes },
purposeCatalog: IMAGE_MAKE_PURPOSE_CATALOG,
}
: {}),
};
}),
};
@@ -171,26 +219,36 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
const provider = normalizeProvider(plugin, payload.provider);
if (enabled && !provider) return { ok: false, message: '请选择该插件支持的 Provider' };
const keyId = String(payload.llmProviderKeyId ?? payload.keyId ?? '').trim() || null;
const model = String(payload.llmModel ?? payload.model ?? '').trim() || null;
const usesModelCenter = !(pluginId === 'asset-generate' && provider === 'image_make');
const keyId = usesModelCenter
? String(payload.llmProviderKeyId ?? payload.keyId ?? '').trim() || null
: null;
const model = usesModelCenter
? String(payload.llmModel ?? payload.model ?? '').trim() || null
: null;
const binding = await ensureLlmBinding({ plugin, keyId, model, enabled });
if (!binding.ok) return binding;
const existing = await getRow(pluginId);
const now = Date.now();
const purposeFallback = rowImageMakePurposes(existing);
const purposes = pluginId === 'asset-generate'
? normalizeImageMakePurposes(payload.purposes, purposeFallback)
: null;
const configJson = purposes ? JSON.stringify({ purposes }) : null;
if (existing) {
await pool.query(
`UPDATE h5_asset_plugin_configs
SET enabled = ?, provider = ?, llm_provider_key_id = ?, llm_model = ?, updated_by = ?, updated_at = ?
SET enabled = ?, provider = ?, llm_provider_key_id = ?, llm_model = ?, config_json = ?, updated_by = ?, updated_at = ?
WHERE plugin_id = ?`,
[enabled ? 1 : 0, provider, keyId, model, updatedBy, now, pluginId],
[enabled ? 1 : 0, provider, keyId, model, configJson, updatedBy, now, pluginId],
);
} else {
await pool.query(
`INSERT INTO h5_asset_plugin_configs
(id, plugin_id, enabled, provider, llm_provider_key_id, llm_model, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[crypto.randomUUID(), pluginId, enabled ? 1 : 0, provider, keyId, model, updatedBy, now, now],
(id, plugin_id, enabled, provider, llm_provider_key_id, llm_model, config_json, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[crypto.randomUUID(), pluginId, enabled ? 1 : 0, provider, keyId, model, configJson, updatedBy, now, now],
);
}
return { ok: true, config: await this.getConfig() };
@@ -209,5 +267,26 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
}
return { ok: true, plugin: rowToPluginConfig(row) };
},
async resolveImageGenerationPurpose(rawPurpose) {
const purpose = String(rawPurpose ?? '').trim().toLowerCase();
if (!imageMakePurposeIds.has(purpose)) {
return { ok: false, code: 'unsupported_purpose', message: '不支持的图片生成用途' };
}
const resolved = await this.resolvePlugin('asset-generate');
if (!resolved.ok) return resolved;
if (resolved.plugin.provider !== 'image_make') {
return { ok: false, code: 'provider_not_image_make', message: 'image_make Provider 未启用' };
}
if (!resolved.plugin.purposes?.[purpose]) {
return { ok: false, code: 'purpose_disabled', message: '该图片用途未启用,可安全降级到原有流程' };
}
return {
ok: true,
purpose,
presetId: IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose)?.presetId,
plugin: resolved.plugin,
};
},
};
}
+32 -4
View File
@@ -26,16 +26,17 @@ function createPool() {
if (compact.startsWith('INSERT INTO h5_asset_plugin_configs')) {
plugins.set(params[1], {
id: params[0], plugin_id: params[1], enabled: params[2], provider: params[3],
llm_provider_key_id: params[4], llm_model: params[5], updated_by: params[6],
created_at: params[7], updated_at: params[8],
llm_provider_key_id: params[4], llm_model: params[5], config_json: params[6],
updated_by: params[7], created_at: params[8], updated_at: params[9],
});
return [{}];
}
if (compact.startsWith('UPDATE h5_asset_plugin_configs')) {
const pluginId = params[6];
const pluginId = params[7];
plugins.set(pluginId, {
...plugins.get(pluginId), enabled: params[0], provider: params[1],
llm_provider_key_id: params[2], llm_model: params[3], updated_by: params[4], updated_at: params[5],
llm_provider_key_id: params[2], llm_model: params[3], config_json: params[4],
updated_by: params[5], updated_at: params[6],
});
return [{}];
}
@@ -100,3 +101,30 @@ test('asset plugins reject unknown providers and unsupported LLM bindings', asyn
assert.equal(unsupportedLlm.ok, false);
assert.match(unsupportedLlm.message, /不支持 LLM/);
});
test('image_make purposes are disabled by default and resolved independently', async () => {
const service = createAssetGatewayConfigService(createPool(), { llmProviderService });
await service.updateGlobalConfig({ enabled: true }, { updatedBy: 'admin-1' });
const result = await service.updatePluginConfig('asset-generate', {
enabled: true,
provider: 'image_make',
purposes: { hero: true, feed_cover: true },
}, { updatedBy: 'admin-1' });
assert.equal(result.ok, true);
const plugin = result.config.plugins.find((item) => item.pluginId === 'asset-generate');
assert.deepEqual(plugin.purposes, {
inline_image: false,
hero: true,
card_cover: false,
feed_cover: true,
});
assert.equal((await service.resolveImageGenerationPurpose('hero')).ok, true);
assert.equal(plugin.llmProviderKeyId, null);
assert.equal(plugin.llmModel, null);
assert.deepEqual(await service.resolveImageGenerationPurpose('card_cover'), {
ok: false,
code: 'purpose_disabled',
message: '该图片用途未启用,可安全降级到原有流程',
});
});
+6
View File
@@ -114,6 +114,7 @@ export async function ensureAssetGatewaySchema(pool) {
provider VARCHAR(64) NULL,
llm_provider_key_id CHAR(36) NULL,
llm_model VARCHAR(128) NULL,
config_json JSON NULL,
updated_by CHAR(36) NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
@@ -123,6 +124,11 @@ export async function ensureAssetGatewaySchema(pool) {
REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
if (!(await columnExists(pool, 'h5_asset_plugin_configs', 'config_json'))) {
await pool.query(
'ALTER TABLE h5_asset_plugin_configs ADD COLUMN config_json JSON NULL AFTER llm_model',
);
}
}
/** Additive only: does not read, move, or mutate user page/data records. */
+64
View File
@@ -0,0 +1,64 @@
# image_make 隔离接入
## 边界
`image_make` 是独立服务。Memind 只通过 HTTP API 调用,下载结果并校验后写入 MindSpace;
`image_make` 不连接 Memind 数据库,也不能直接写 MindSpace。开关关闭或请求失败时,现有页面、
封面与缩略图流程保持不变。
## 后台开关
开关位于 `memind_adm` 的「资产能力」页面,按以下顺序生效:
1. 资产能力总开关开启;
2. 图片生成插件开启,Provider 选择 `image_make`
3. 单独开启需要的用途:页面正文图片、页面头图、卡片封面、信息流与缩略图源图;
4. Memind 运行环境同时配置 `IMAGE_MAKE_BASE_URL``IMAGE_MAKE_TOKEN`
所有开关默认关闭。信息流缩略图仍由 MindSpace 从 `feed_cover` canonical 源资产派生,禁止为了
缩略图再次调用生图模型。
## Memind API
登录用户可显式调用:
```http
POST /api/mindspace/v1/images/generate
Idempotency-Key: imgreq_xxx
Content-Type: application/json
{
"purpose": "hero",
"prompt": ""
}
```
`purpose` 可选值:`inline_image``hero``card_cover``feed_cover`
成功时服务先完成以下步骤,再返回 `201`
1. 创建并轮询 image_make 任务;
2. 下载图片并校验 MIME、文件头、大小与 SHA-256
3. 通过现有 `createChatAsset` 写入用户 MindSpace 公共候选资产;
4. 获得 canonical `publicUrl` 后 best-effort acknowledge image_make 临时结果。
开关关闭返回带 `fallback: true``409`;服务未配置或暂不可用返回带 `fallback: true`
`503`。调用方必须继续原有页面或默认缩略图流程。
## 本地配置
```bash
IMAGE_MAKE_BASE_URL=http://127.0.0.1:8080
IMAGE_MAKE_TOKEN=dev-token
IMAGE_MAKE_REQUEST_TIMEOUT_MS=15000
IMAGE_MAKE_GENERATION_TIMEOUT_MS=600000
IMAGE_MAKE_POLL_INTERVAL_MS=1000
IMAGE_MAKE_MAX_RESULT_BYTES=20971520
```
Token 只进入 Memind 运行环境,不在 `memind_adm` 页面或数据库中保存、展示。
## 回滚
`memind_adm` 关闭图片生成插件或资产能力总开关即可立即停止新调用。该回滚不删除已经入库
的 MindSpace 图片,不要求回滚数据库字段,也不改变既有页面内容。
+197
View File
@@ -0,0 +1,197 @@
import crypto from 'node:crypto';
const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const ACTIVE_STATUSES = new Set(['queued', 'submitted', 'running']);
const DEFAULT_MAX_RESULT_BYTES = 20 * 1024 * 1024;
function positiveInteger(value, fallback) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
}
function normalizeBaseUrl(value) {
const url = new URL(String(value ?? '').trim());
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('image_make base URL is invalid');
}
url.pathname = url.pathname.replace(/\/+$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}
function imageMagicMatches(buffer, mimeType) {
if (mimeType === 'image/png') {
return buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
}
if (mimeType === 'image/jpeg') {
return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
}
if (mimeType === 'image/webp') {
return buffer.length >= 12
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP';
}
return false;
}
async function safeJson(response) {
try {
return await response.json();
} catch {
return null;
}
}
function createClientError(code, message, details = {}) {
return Object.assign(new Error(message), { code, ...details });
}
export function createImageMakeClient({
baseUrl,
token,
fetchImpl = globalThis.fetch,
requestTimeoutMs = 15_000,
generationTimeoutMs = 10 * 60_000,
pollIntervalMs = 1_000,
maxResultBytes = DEFAULT_MAX_RESULT_BYTES,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
} = {}) {
if (typeof fetchImpl !== 'function') throw new Error('image_make fetch implementation is required');
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const bearerToken = String(token ?? '').trim();
if (!bearerToken) throw new Error('image_make token is required');
const perRequestTimeout = positiveInteger(requestTimeoutMs, 15_000);
const overallTimeout = positiveInteger(generationTimeoutMs, 10 * 60_000);
const pollingDelay = positiveInteger(pollIntervalMs, 1_000);
const resultLimit = positiveInteger(maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
async function request(pathname, init = {}, expectedStatuses = [200]) {
const response = await fetchImpl(new URL(pathname, `${normalizedBaseUrl}/`), {
...init,
headers: {
accept: 'application/json',
authorization: `Bearer ${bearerToken}`,
...(init.body ? { 'content-type': 'application/json' } : {}),
...(init.headers ?? {}),
},
signal: AbortSignal.timeout(perRequestTimeout),
});
if (!expectedStatuses.includes(response.status)) {
const body = await safeJson(response);
throw createClientError(
'IMAGE_MAKE_HTTP_ERROR',
body?.error_message_safe ?? body?.message ?? `image_make request failed (${response.status})`,
{ status: response.status },
);
}
return response;
}
async function generateImage({
prompt,
negativePrompt = '',
presetId,
presetVersion = 1,
width,
height,
consumerRef,
idempotencyKey = `imgreq_${crypto.randomUUID()}`,
} = {}) {
const normalizedPrompt = String(prompt ?? '').trim();
if (!normalizedPrompt) throw createClientError('INVALID_PROMPT', '图片描述不能为空');
if (!presetId) throw createClientError('INVALID_PRESET', '图片 preset 不能为空');
const createResponse = await request('/v1/generation-jobs', {
method: 'POST',
headers: { 'idempotency-key': idempotencyKey },
body: JSON.stringify({
idempotency_key: idempotencyKey,
mode: 'text_to_image',
prompt: normalizedPrompt,
negative_prompt: String(negativePrompt ?? '').trim(),
preset: { id: presetId, version: presetVersion },
...(width && height ? { width, height } : {}),
count: 1,
output_format: 'webp',
consumer_ref: String(consumerRef ?? '').trim() || undefined,
}),
}, [200, 201]);
const created = await safeJson(createResponse);
const jobId = String(created?.job_id ?? '').trim();
if (!jobId) throw createClientError('IMAGE_MAKE_INVALID_RESPONSE', 'image_make 未返回任务 ID');
const deadline = Date.now() + overallTimeout;
let job = created;
while (ACTIVE_STATUSES.has(String(job?.status ?? ''))) {
if (Date.now() >= deadline) {
throw createClientError('IMAGE_MAKE_TIMEOUT', '图片生成超时', { jobId });
}
await sleep(Math.min(pollingDelay, Math.max(1, deadline - Date.now())));
job = await safeJson(await request(`/v1/generation-jobs/${encodeURIComponent(jobId)}`));
}
if (job?.status !== 'succeeded' || !job?.result?.download_url) {
throw createClientError(
'IMAGE_MAKE_JOB_FAILED',
job?.error_message_safe ?? '图片生成失败',
{ jobId, jobStatus: job?.status ?? 'unknown' },
);
}
const download = await request(job.result.download_url, {
headers: { accept: 'image/png,image/jpeg,image/webp' },
});
const mimeType = String(download.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
if (!SUPPORTED_IMAGE_TYPES.has(mimeType)) {
throw createClientError('IMAGE_MAKE_INVALID_RESULT', 'image_make 返回了不支持的图片格式', { jobId });
}
const declaredSize = Number(download.headers.get('content-length') ?? 0);
if (declaredSize > resultLimit) {
throw createClientError('IMAGE_MAKE_RESULT_TOO_LARGE', 'image_make 图片超过大小限制', { jobId });
}
const buffer = Buffer.from(await download.arrayBuffer());
if (!buffer.length || buffer.length > resultLimit || !imageMagicMatches(buffer, mimeType)) {
throw createClientError('IMAGE_MAKE_INVALID_RESULT', 'image_make 图片内容校验失败', { jobId });
}
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
const expectedHashes = [job.result.sha256, download.headers.get('x-artifact-sha256')]
.map((value) => String(value ?? '').trim().toLowerCase())
.filter(Boolean);
if (expectedHashes.some((value) => value !== sha256)) {
throw createClientError('IMAGE_MAKE_CHECKSUM_MISMATCH', 'image_make 图片摘要校验失败', { jobId });
}
return {
jobId,
buffer,
mimeType,
sha256,
width: Number(job.result.width ?? 0) || null,
height: Number(job.result.height ?? 0) || null,
};
}
async function acknowledge(jobId, consumerAssetRef) {
await request(`/v1/generation-jobs/${encodeURIComponent(jobId)}/acknowledge`, {
method: 'POST',
body: JSON.stringify({ consumer_asset_ref: consumerAssetRef }),
});
return { ok: true };
}
return { baseUrl: normalizedBaseUrl, generateImage, acknowledge };
}
export function createImageMakeClientFromEnv(env = process.env, options = {}) {
const baseUrl = String(env.IMAGE_MAKE_BASE_URL ?? '').trim();
const token = String(env.IMAGE_MAKE_TOKEN ?? '').trim();
if (!baseUrl || !token) return null;
return createImageMakeClient({
baseUrl,
token,
requestTimeoutMs: env.IMAGE_MAKE_REQUEST_TIMEOUT_MS,
generationTimeoutMs: env.IMAGE_MAKE_GENERATION_TIMEOUT_MS,
pollIntervalMs: env.IMAGE_MAKE_POLL_INTERVAL_MS,
maxResultBytes: env.IMAGE_MAKE_MAX_RESULT_BYTES,
...options,
});
}
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import test from 'node:test';
import { createImageMakeClient, createImageMakeClientFromEnv } from './image-make-client.mjs';
test('image_make client creates, polls, validates, downloads and acknowledges a job', async () => {
const image = Buffer.concat([
Buffer.from('RIFF'),
Buffer.alloc(4),
Buffer.from('WEBP'),
Buffer.from('test-image'),
]);
const sha256 = crypto.createHash('sha256').update(image).digest('hex');
const calls = [];
const fetchImpl = async (url, init) => {
const path = new URL(url).pathname;
calls.push({ path, init });
if (path === '/v1/generation-jobs') {
return Response.json({ job_id: 'job-1', status: 'queued' }, { status: 201 });
}
if (path === '/v1/generation-jobs/job-1' && init.method !== 'POST') {
return Response.json({
job_id: 'job-1',
status: 'succeeded',
result: {
download_url: '/v1/generation-jobs/job-1/result',
sha256,
width: 512,
height: 512,
},
});
}
if (path.endsWith('/result')) {
return new Response(image, {
headers: { 'content-type': 'image/webp', 'x-artifact-sha256': sha256 },
});
}
if (path.endsWith('/acknowledge')) return Response.json({ job_id: 'job-1' });
throw new Error(`Unexpected request ${path}`);
};
const client = createImageMakeClient({
baseUrl: 'http://127.0.0.1:8080',
token: 'test-token',
fetchImpl,
sleep: async () => {},
});
const result = await client.generateImage({
prompt: 'calm reading room',
presetId: 'memind_square_illustration',
idempotencyKey: 'imgreq-test',
});
assert.equal(result.jobId, 'job-1');
assert.equal(result.sha256, sha256);
assert.deepEqual(result.buffer, image);
await client.acknowledge(result.jobId, 'asset-1');
assert.deepEqual(calls.map((call) => call.path), [
'/v1/generation-jobs',
'/v1/generation-jobs/job-1',
'/v1/generation-jobs/job-1/result',
'/v1/generation-jobs/job-1/acknowledge',
]);
assert.equal(calls[0].init.headers.authorization, 'Bearer test-token');
assert.equal(calls[0].init.headers['idempotency-key'], 'imgreq-test');
});
test('image_make client fails closed on checksum mismatch', async () => {
const image = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPbad')]);
const client = createImageMakeClient({
baseUrl: 'http://127.0.0.1:8080', token: 'test-token', sleep: async () => {},
fetchImpl: async (url) => {
const path = new URL(url).pathname;
if (path === '/v1/generation-jobs') return Response.json({ job_id: 'job-1', status: 'running' }, { status: 201 });
if (path.endsWith('/result')) return new Response(image, { headers: { 'content-type': 'image/webp' } });
return Response.json({ job_id: 'job-1', status: 'succeeded', result: { download_url: '/v1/generation-jobs/job-1/result', sha256: '0'.repeat(64) } });
},
});
await assert.rejects(
client.generateImage({ prompt: 'x', presetId: 'memind_square_illustration' }),
{ code: 'IMAGE_MAKE_CHECKSUM_MISMATCH' },
);
});
test('image_make env client stays disabled until URL and token are both configured', () => {
assert.equal(createImageMakeClientFromEnv({}), null);
assert.equal(createImageMakeClientFromEnv({ IMAGE_MAKE_BASE_URL: 'http://127.0.0.1:8080' }), null);
});
+47
View File
@@ -0,0 +1,47 @@
const PURPOSES = new Set(['inline_image', 'hero', 'card_cover', 'feed_cover']);
export function attachMindSpaceImageGenerationRoutes(router, { getService } = {}) {
router.post('/mindspace/v1/images/generate', async (req, res) => {
const service = getService?.();
if (!service) {
return res.status(503).json({
ok: false,
fallback: true,
code: 'runtime_unavailable',
message: '图片生成服务未配置',
});
}
const purpose = String(req.body?.purpose ?? '').trim().toLowerCase();
const prompt = String(req.body?.prompt ?? '').trim();
if (!PURPOSES.has(purpose)) {
return res.status(400).json({ message: '不支持的图片生成用途' });
}
if (!prompt || prompt.length > 1600) {
return res.status(400).json({ message: '图片描述不能为空且不能超过 1600 字符' });
}
const headerIdempotencyKey = String(req.get('idempotency-key') ?? '').trim();
const bodyIdempotencyKey = String(req.body?.idempotency_key ?? '').trim();
const idempotencyKey = headerIdempotencyKey || bodyIdempotencyKey || undefined;
if (idempotencyKey && idempotencyKey.length > 128) {
return res.status(400).json({ message: '幂等键不能超过 128 字符' });
}
const result = await service.generate({
userId: req.currentUser.id,
purpose,
prompt,
negativePrompt: req.body?.negative_prompt,
consumerRef: `mindspace:${req.requestId ?? 'request'}:${purpose}`,
idempotencyKey,
});
if (!result.ok) {
const unavailable = new Set([
'runtime_unavailable',
'image_make_unavailable',
'IMAGE_MAKE_HTTP_ERROR',
'IMAGE_MAKE_TIMEOUT',
]).has(result.code);
return res.status(unavailable ? 503 : 409).json(result);
}
return res.status(201).json({ data: result });
});
}
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import test from 'node:test';
import express from 'express';
import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs';
async function start(service) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.currentUser = { id: 'user-1' };
req.requestId = 'req-1';
next();
});
attachMindSpaceImageGenerationRoutes(app, { getService: () => service });
const server = app.listen(0, '127.0.0.1');
await once(server, 'listening');
return {
url: `http://127.0.0.1:${server.address().port}`,
async close() { server.close(); await once(server, 'close'); },
};
}
test('image generation route passes authenticated user and idempotency key to the service', async () => {
const calls = [];
const fixture = await start({
async generate(input) {
calls.push(input);
return { ok: true, asset: { id: 'asset-1', publicUrl: '/image.webp' } };
},
});
try {
const response = await fetch(`${fixture.url}/mindspace/v1/images/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'idempotency-key': 'imgreq-1' },
body: JSON.stringify({ purpose: 'hero', prompt: 'calm library' }),
});
assert.equal(response.status, 201);
assert.equal((await response.json()).data.asset.id, 'asset-1');
assert.deepEqual(calls, [{
userId: 'user-1',
purpose: 'hero',
prompt: 'calm library',
negativePrompt: undefined,
consumerRef: 'mindspace:req-1:hero',
idempotencyKey: 'imgreq-1',
}]);
} finally { await fixture.close(); }
});
test('image generation route exposes safe fallback without changing existing flows', async () => {
const fixture = await start({
async generate() {
return { ok: false, fallback: true, code: 'purpose_disabled', message: 'disabled' };
},
});
try {
const response = await fetch(`${fixture.url}/mindspace/v1/images/generate`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ purpose: 'feed_cover', prompt: 'calm library' }),
});
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), {
ok: false, fallback: true, code: 'purpose_disabled', message: 'disabled',
});
} finally { await fixture.close(); }
});
+79
View File
@@ -0,0 +1,79 @@
import crypto from 'node:crypto';
const PURPOSES = {
inline_image: { presetId: 'memind_square_illustration', filename: 'inline-image.webp' },
hero: { presetId: 'memind_dark_hero', filename: 'hero.webp' },
card_cover: { presetId: 'memind_card_cover', filename: 'card-cover.webp' },
feed_cover: { presetId: 'memind_feed_cover_source', filename: 'feed-cover.webp' },
};
export function createMindSpaceImageGenerationService({
configService,
assetService,
imageMakeClient,
logger = console,
} = {}) {
async function generate({
userId,
purpose,
prompt,
negativePrompt = '',
consumerRef = '',
idempotencyKey,
} = {}) {
const spec = PURPOSES[purpose];
if (!spec) {
return { ok: false, fallback: true, code: 'unsupported_purpose', message: '不支持的图片生成用途' };
}
if (!configService?.resolveImageGenerationPurpose) {
return { ok: false, fallback: true, code: 'gateway_unavailable', message: '图片生成能力未启用' };
}
const gate = await configService.resolveImageGenerationPurpose(purpose);
if (!gate.ok) return { ...gate, fallback: true };
if (!imageMakeClient || !assetService?.createChatAsset) {
return { ok: false, fallback: true, code: 'runtime_unavailable', message: '图片生成服务未配置' };
}
try {
const generated = await imageMakeClient.generateImage({
prompt,
negativePrompt,
presetId: gate.presetId ?? spec.presetId,
consumerRef,
idempotencyKey: idempotencyKey || `imgreq_${crypto.randomUUID()}`,
});
const asset = await assetService.createChatAsset(userId, {
categoryCode: 'public',
buffer: generated.buffer,
filename: spec.filename,
displayName: spec.filename,
sourceType: 'image_make',
});
await imageMakeClient.acknowledge(generated.jobId, asset.id).catch((error) => {
logger.warn?.('[image_make] acknowledge failed; TTL cleanup will apply:', error?.message ?? error);
});
return {
ok: true,
purpose,
jobId: generated.jobId,
asset,
source: {
mimeType: generated.mimeType,
width: generated.width,
height: generated.height,
sha256: generated.sha256,
},
};
} catch (error) {
logger.warn?.('[image_make] generation failed; existing flow remains active:', error?.message ?? error);
return {
ok: false,
fallback: true,
code: error?.code ?? 'image_make_unavailable',
message: '图片生成暂不可用,已保留原有流程',
};
}
}
return { purposes: Object.keys(PURPOSES), generate };
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMindSpaceImageGenerationService } from './mindspace-image-generation.mjs';
test('MindSpace image generation stays inert while the admin purpose is disabled', async () => {
const service = createMindSpaceImageGenerationService({
configService: {
async resolveImageGenerationPurpose() {
return { ok: false, code: 'purpose_disabled', message: 'disabled' };
},
},
});
assert.deepEqual(await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'x' }), {
ok: false,
code: 'purpose_disabled',
message: 'disabled',
fallback: true,
});
});
test('MindSpace image generation stores the result before acknowledging temporary output', async () => {
const order = [];
const service = createMindSpaceImageGenerationService({
configService: {
async resolveImageGenerationPurpose() {
return { ok: true, presetId: 'memind_dark_hero' };
},
},
imageMakeClient: {
async generateImage(input) {
order.push(['generate', input.presetId]);
return {
jobId: 'job-1', buffer: Buffer.from('image'), mimeType: 'image/webp',
sha256: 'abc', width: 512, height: 512,
};
},
async acknowledge(jobId, assetId) { order.push(['ack', jobId, assetId]); },
},
assetService: {
async createChatAsset(userId, input) {
order.push(['store', userId, input.sourceType, input.categoryCode]);
return { id: 'asset-1', publicUrl: 'https://example.test/image.webp' };
},
},
});
const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'x' });
assert.equal(result.ok, true);
assert.equal(result.asset.id, 'asset-1');
assert.deepEqual(order, [
['generate', 'memind_dark_hero'],
['store', 'user-1', 'image_make', 'public'],
['ack', 'job-1', 'asset-1'],
]);
});
test('MindSpace image generation converts provider failures into an explicit fallback result', async () => {
const service = createMindSpaceImageGenerationService({
configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'x' }; } },
assetService: { async createChatAsset() { throw new Error('must not run'); } },
imageMakeClient: { async generateImage() { throw Object.assign(new Error('offline'), { code: 'IMAGE_MAKE_HTTP_ERROR' }); } },
logger: { warn() {} },
});
const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'x' });
assert.deepEqual(result, {
ok: false,
fallback: true,
code: 'IMAGE_MAKE_HTTP_ERROR',
message: '图片生成暂不可用,已保留原有流程',
});
});
+1 -1
View File
@@ -58,7 +58,7 @@
"test:scenario": "node scripts/run-scenario-test.mjs",
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
+1
View File
@@ -638,6 +638,7 @@ CREATE TABLE IF NOT EXISTS h5_asset_plugin_configs (
provider VARCHAR(64) NULL,
llm_provider_key_id CHAR(36) NULL,
llm_model VARCHAR(128) NULL,
config_json JSON NULL,
updated_by CHAR(36) NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
+22
View File
@@ -16,6 +16,10 @@ import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { createToolGateway } from './tool-gateway.mjs';
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
import { createImageMakeClientFromEnv } from './image-make-client.mjs';
import { createMindSpaceImageGenerationService } from './mindspace-image-generation.mjs';
import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
import {
createAgentRunEventsHandler,
@@ -362,6 +366,8 @@ function endSessionPageDelivery(sessionId) {
}
let chatIntentRouter = null;
let toolGateway = null;
let assetGatewayConfigService = null;
let mindSpaceImageGeneration = null;
let directChatService = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
@@ -663,6 +669,19 @@ async function bootstrapUserAuth() {
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
});
assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
let imageMakeClient = null;
try {
imageMakeClient = createImageMakeClientFromEnv(process.env);
} catch (error) {
console.warn('[image_make] invalid local configuration; integration stays disabled:', error?.message ?? error);
}
mindSpaceImageGeneration = createMindSpaceImageGenerationService({
configService: assetGatewayConfigService,
assetService: mindSpaceAssets,
imageMakeClient,
logger: console,
});
wordFilterService = createWordFilterService(pool);
void llmProviderService
.ensureBootstrapRelay()
@@ -2106,6 +2125,9 @@ api.use(async (req, res, next) => {
});
attachAsrRoutes(api, { sendError, sendData });
attachMindSpaceImageGenerationRoutes(api, {
getService: () => mindSpaceImageGeneration,
});
attachPageDataRoutes(api, {
sendError,
sendData,