From 4959535b5e291d8b3dbcc0321da0fc9711ee2395 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 19 Jul 2026 18:34:50 +0800 Subject: [PATCH 1/8] feat: integrate image_make with MindSpace assets --- .env.example | 7 + asset-gateway.mjs | 97 +++++++++- asset-gateway.test.mjs | 36 +++- db.mjs | 6 + docs/image-make-integration.md | 64 +++++++ image-make-client.mjs | 197 +++++++++++++++++++++ image-make-client.test.mjs | 86 +++++++++ mindspace-image-generation-routes.mjs | 47 +++++ mindspace-image-generation-routes.test.mjs | 68 +++++++ mindspace-image-generation.mjs | 79 +++++++++ mindspace-image-generation.test.mjs | 70 ++++++++ package.json | 2 +- schema.sql | 1 + server.mjs | 22 +++ 14 files changed, 768 insertions(+), 14 deletions(-) create mode 100644 docs/image-make-integration.md create mode 100644 image-make-client.mjs create mode 100644 image-make-client.test.mjs create mode 100644 mindspace-image-generation-routes.mjs create mode 100644 mindspace-image-generation-routes.test.mjs create mode 100644 mindspace-image-generation.mjs create mode 100644 mindspace-image-generation.test.mjs diff --git a/.env.example b/.env.example index 6dd413b..6771e91 100644 --- a/.env.example +++ b/.env.example @@ -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 生产 必须区分) diff --git a/asset-gateway.mjs b/asset-gateway.mjs index 6f9be71..ef96a0b 100644 --- a/asset-gateway.mjs +++ b/asset-gateway.mjs @@ -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, + }; + }, }; } diff --git a/asset-gateway.test.mjs b/asset-gateway.test.mjs index ae372e7..1cc6866 100644 --- a/asset-gateway.test.mjs +++ b/asset-gateway.test.mjs @@ -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: '该图片用途未启用,可安全降级到原有流程', + }); +}); diff --git a/db.mjs b/db.mjs index a6f3c52..e4ff41c 100644 --- a/db.mjs +++ b/db.mjs @@ -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. */ diff --git a/docs/image-make-integration.md b/docs/image-make-integration.md new file mode 100644 index 0000000..164c0e9 --- /dev/null +++ b/docs/image-make-integration.md @@ -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 图片,不要求回滚数据库字段,也不改变既有页面内容。 diff --git a/image-make-client.mjs b/image-make-client.mjs new file mode 100644 index 0000000..81fcda5 --- /dev/null +++ b/image-make-client.mjs @@ -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, + }); +} diff --git a/image-make-client.test.mjs b/image-make-client.test.mjs new file mode 100644 index 0000000..a85755f --- /dev/null +++ b/image-make-client.test.mjs @@ -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); +}); diff --git a/mindspace-image-generation-routes.mjs b/mindspace-image-generation-routes.mjs new file mode 100644 index 0000000..b41d2fb --- /dev/null +++ b/mindspace-image-generation-routes.mjs @@ -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 }); + }); +} diff --git a/mindspace-image-generation-routes.test.mjs b/mindspace-image-generation-routes.test.mjs new file mode 100644 index 0000000..de348c6 --- /dev/null +++ b/mindspace-image-generation-routes.test.mjs @@ -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(); } +}); diff --git a/mindspace-image-generation.mjs b/mindspace-image-generation.mjs new file mode 100644 index 0000000..4ee37b9 --- /dev/null +++ b/mindspace-image-generation.mjs @@ -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 }; +} diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs new file mode 100644 index 0000000..0ddd621 --- /dev/null +++ b/mindspace-image-generation.test.mjs @@ -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: '图片生成暂不可用,已保留原有流程', + }); +}); diff --git a/package.json b/package.json index bef5ab2..4437c92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/schema.sql b/schema.sql index c86f99d..7144cf7 100644 --- a/schema.sql +++ b/schema.sql @@ -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, diff --git a/server.mjs b/server.mjs index 99e1f9b..7f91b6a 100644 --- a/server.mjs +++ b/server.mjs @@ -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, From 32e697b69868ca81eeac79533612eb1b2c74a748 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 19 Jul 2026 18:39:46 +0800 Subject: [PATCH 2/8] feat: expose image generation to page agents --- .env.example | 2 + capabilities.mjs | 18 +++++- capabilities.test.mjs | 11 +++- docs/image-make-integration.md | 6 ++ mindspace-image-generation-routes.mjs | 36 ++++++++++-- mindspace-image-generation-routes.test.mjs | 30 +++++++++- mindspace-sandbox-mcp.mjs | 66 ++++++++++++++++++++++ mindspace-sandbox-mcp.test.mjs | 56 ++++++++++++++++++ server.mjs | 2 + user-auth.mjs | 3 + 10 files changed, 220 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 6771e91..f36cfc7 100644 --- a/.env.example +++ b/.env.example @@ -259,6 +259,8 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # IMAGE_MAKE_GENERATION_TIMEOUT_MS=600000 # IMAGE_MAKE_POLL_INTERVAL_MS=1000 # IMAGE_MAKE_MAX_RESULT_BYTES=20971520 +# Sandbox MCP 调用 Portal 内部生图入口;容器内通常使用 host.docker.internal。 +# MINDSPACE_AGENT_API_BASE_URL=http://127.0.0.1:8081/api # --------------------------------------------------------------------------- # Memind runtime profile(本地 vs 生产 必须区分) diff --git a/capabilities.mjs b/capabilities.mjs index 7f7af94..187a91f 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -288,7 +288,15 @@ export function sandboxDeveloperTools(capabilities) { export function sandboxMcpTools(capabilities) { const tools = []; if (capabilities.static_publish) { - tools.push('read_file', 'write_file', 'edit_file', 'create_dir', 'generate_docx', 'generate_long_image'); + tools.push( + 'read_file', + 'write_file', + 'edit_file', + 'create_dir', + 'generate_image', + 'generate_docx', + 'generate_long_image', + ); if (capabilities.shell || capabilities.code_browse) tools.push('list_dir'); } if (capabilities.private_data_space) { @@ -344,6 +352,14 @@ function sandboxMcpEnvs(sandboxMcp, mcpTools) { if (sandboxMcp.workspaceRoot || localRoot) envs.MINDSPACE_WORKSPACE_ROOT = sandboxMcp.workspaceRoot || localRoot; if (sandboxMcp.workspaceRef) envs.MINDSPACE_WORKSPACE_REF = sandboxMcp.workspaceRef; if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId; + if (mcpTools.includes('generate_image')) { + if (sandboxMcp.agentApiBaseUrl) { + envs.MINDSPACE_AGENT_API_BASE_URL = sandboxMcp.agentApiBaseUrl; + } + if (sandboxMcp.internalAgentSecret) { + envs.MINDSPACE_INTERNAL_AGENT_SECRET = sandboxMcp.internalAgentSecret; + } + } if (mcpTools.includes('private_data_info')) { const userDataPgUrl = resolveSandboxMcpUserDataPgUrl({ portalUrl: sandboxMcp.userDataPgUrl ?? process.env.MINDSPACE_USERDATA_PG_URL, diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 78f4943..84bf9cd 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -256,6 +256,7 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => { 'write_file', 'edit_file', 'create_dir', + 'generate_image', 'generate_docx', 'generate_long_image', 'private_data_info', @@ -328,7 +329,12 @@ test('private_data_space alone exposes private data tools through sandbox MCP', test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of developer', () => { const caps = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; - const sandboxMcp = { serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', sandboxRoot: '/opt/h5/MindSpace/abc123' }; + const sandboxMcp = { + serverPath: '/opt/h5/mindspace-sandbox-mcp.mjs', + sandboxRoot: '/opt/h5/MindSpace/abc123', + agentApiBaseUrl: 'http://host.docker.internal:8081/api', + internalAgentSecret: 'internal-secret', + }; const policy = buildAgentExtensionPolicy(caps, { sandboxMcp }); const sandboxExt = policy.extensionOverrides.find((ext) => ext.name === 'sandbox-fs'); @@ -337,10 +343,13 @@ test('static_publish with sandboxMcp uses stdio sandbox-fs extension instead of assert.equal(sandboxExt.cmd, process.execPath); assert.equal(sandboxExt.envs.SANDBOX_ROOT, '/opt/h5/MindSpace/abc123'); assert.equal(sandboxExt.args[1], '/opt/h5/MindSpace/abc123'); // also passed as argv[2] + assert.equal(sandboxExt.envs.MINDSPACE_AGENT_API_BASE_URL, 'http://host.docker.internal:8081/api'); + assert.equal(sandboxExt.envs.MINDSPACE_INTERNAL_AGENT_SECRET, 'internal-secret'); assert.ok(sandboxExt.available_tools.includes('write_file')); assert.ok(sandboxExt.available_tools.includes('read_file')); assert.ok(sandboxExt.available_tools.includes('generate_docx')); assert.ok(sandboxExt.available_tools.includes('generate_long_image')); + assert.ok(sandboxExt.available_tools.includes('generate_image')); // built-in developer extension should only remain for read_image (image_read: true by default) const developer = policy.extensionOverrides.find((ext) => ext.name === 'developer'); diff --git a/docs/image-make-integration.md b/docs/image-make-integration.md index 164c0e9..0a1c0d4 100644 --- a/docs/image-make-integration.md +++ b/docs/image-make-integration.md @@ -58,6 +58,12 @@ IMAGE_MAKE_MAX_RESULT_BYTES=20971520 Token 只进入 Memind 运行环境,不在 `memind_adm` 页面或数据库中保存、展示。 +页面 Agent 通过 sandbox MCP 的 `generate_image` 工具调用 Portal 内部入口。需要额外配置 +`MINDSPACE_AGENT_API_BASE_URL`;MCP 使用现有 `MINDSPACE_INTERNAL_AGENT_SECRET` 鉴权,成功结果 +直接返回 MindSpace `publicUrl` 与 `workspaceRelativePath`。工具始终受同一后台总开关、Provider +开关和用途开关约束。容器部署时 base URL 应使用容器可访问的 Portal 地址,不能使用容器自身的 +`127.0.0.1`。 + ## 回滚 在 `memind_adm` 关闭图片生成插件或资产能力总开关即可立即停止新调用。该回滚不删除已经入库 diff --git a/mindspace-image-generation-routes.mjs b/mindspace-image-generation-routes.mjs index b41d2fb..f25a5fe 100644 --- a/mindspace-image-generation-routes.mjs +++ b/mindspace-image-generation-routes.mjs @@ -1,8 +1,6 @@ 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?.(); +async function handleGenerate(req, res, { service, userId, consumerPrefix }) { if (!service) { return res.status(503).json({ ok: false, @@ -26,11 +24,11 @@ export function attachMindSpaceImageGenerationRoutes(router, { getService } = {} return res.status(400).json({ message: '幂等键不能超过 128 字符' }); } const result = await service.generate({ - userId: req.currentUser.id, + userId, purpose, prompt, negativePrompt: req.body?.negative_prompt, - consumerRef: `mindspace:${req.requestId ?? 'request'}:${purpose}`, + consumerRef: `${consumerPrefix}:${req.requestId ?? 'request'}:${purpose}`, idempotencyKey, }); if (!result.ok) { @@ -43,5 +41,31 @@ export function attachMindSpaceImageGenerationRoutes(router, { getService } = {} return res.status(unavailable ? 503 : 409).json(result); } return res.status(201).json({ data: result }); - }); +} + +export function attachMindSpaceImageGenerationRoutes( + router, + { getService, requireInternal = null } = {}, +) { + router.post('/mindspace/v1/images/generate', async (req, res) => { + const service = getService?.(); + return handleGenerate(req, res, { + service, + userId: req.currentUser.id, + consumerPrefix: 'mindspace', + }); + }); + + if (requireInternal) { + router.post('/agent/mindspace_image_generate', async (req, res) => { + if (!requireInternal(req, res)) return; + const userId = String(req.body?.user_id ?? req.body?.userId ?? '').trim(); + if (!userId) return res.status(400).json({ message: '缺少 user_id' }); + return handleGenerate(req, res, { + service: getService?.(), + userId, + consumerPrefix: 'mindspace-agent', + }); + }); + } } diff --git a/mindspace-image-generation-routes.test.mjs b/mindspace-image-generation-routes.test.mjs index de348c6..83271c9 100644 --- a/mindspace-image-generation-routes.test.mjs +++ b/mindspace-image-generation-routes.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import express from 'express'; import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs'; -async function start(service) { +async function start(service, requireInternal = null) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -12,7 +12,7 @@ async function start(service) { req.requestId = 'req-1'; next(); }); - attachMindSpaceImageGenerationRoutes(app, { getService: () => service }); + attachMindSpaceImageGenerationRoutes(app, { getService: () => service, requireInternal }); const server = app.listen(0, '127.0.0.1'); await once(server, 'listening'); return { @@ -66,3 +66,29 @@ test('image generation route exposes safe fallback without changing existing flo }); } finally { await fixture.close(); } }); + +test('internal image generation route requires its injected secret guard and explicit user', async () => { + const calls = []; + const fixture = await start({ + async generate(input) { calls.push(input); return { ok: true, asset: { id: 'asset-1' } }; }, + }, (req, res) => { + if (req.get('authorization') === 'Bearer internal-secret') return true; + res.status(401).json({ message: 'invalid secret' }); + return false; + }); + try { + const denied = await fetch(`${fixture.url}/agent/mindspace_image_generate`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ user_id: 'user-2', purpose: 'hero', prompt: 'x' }), + }); + assert.equal(denied.status, 401); + const allowed = await fetch(`${fixture.url}/agent/mindspace_image_generate`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer internal-secret' }, + body: JSON.stringify({ user_id: 'user-2', purpose: 'hero', prompt: 'x' }), + }); + assert.equal(allowed.status, 201); + assert.equal(calls[0].userId, 'user-2'); + assert.equal(calls[0].consumerRef, 'mindspace-agent:req-1:hero'); + } finally { await fixture.close(); } +}); diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index f49753c..9a02ea5 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -40,6 +40,8 @@ const PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * const PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5000); const PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200); const PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim(); +const AGENT_API_BASE_URL = process.env.MINDSPACE_AGENT_API_BASE_URL?.trim(); +const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET?.trim(); const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim(); const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null; @@ -121,6 +123,39 @@ function runGenerateDocxScript({ outputPath, title, sections }) { return { bytes: size, stdout: String(stdout ?? '').trim() }; } +async function generateMindSpaceImage({ purpose, prompt, negativePrompt, idempotencyKey }) { + if (!AGENT_API_BASE_URL || !INTERNAL_AGENT_SECRET || !PRIVATE_DATA_USER_ID) { + throw new Error('generate_image: Memind 内部生图入口未配置'); + } + const endpoint = new URL('agent/mindspace_image_generate', `${AGENT_API_BASE_URL.replace(/\/+$/, '')}/`); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${INTERNAL_AGENT_SECRET}`, + 'content-type': 'application/json', + ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}), + }, + body: JSON.stringify({ + user_id: PRIVATE_DATA_USER_ID, + purpose, + prompt, + negative_prompt: negativePrompt, + }), + signal: AbortSignal.timeout(Number(process.env.IMAGE_MAKE_GENERATION_TIMEOUT_MS ?? 600_000)), + }); + let body = null; + try { + body = await response.json(); + } catch { + // Safe error below intentionally excludes the response body. + } + if (!response.ok && !body?.fallback) { + throw new Error(`generate_image: Memind 返回 ${response.status}`); + } + return body?.data ?? body ?? { ok: false, fallback: true, code: 'empty_response' }; +} + const ALL_TOOLS = [ { name: 'read_file', @@ -195,6 +230,25 @@ const ALL_TOOLS = [ required: ['html_path'], }, }, + { + name: 'generate_image', + description: + '通过平台 image_make 服务生成页面图片并直接写入当前用户 MindSpace。仅在后台开关开启时生效;失败时继续原页面流程。返回 canonical publicUrl 与 workspaceRelativePath。', + inputSchema: { + type: 'object', + properties: { + purpose: { + type: 'string', + enum: ['inline_image', 'hero', 'card_cover', 'feed_cover'], + description: '图片用途', + }, + prompt: { type: 'string', description: '图片描述,画面内不要包含文字、水印或二维码' }, + negative_prompt: { type: 'string', description: '可选负面描述' }, + idempotency_key: { type: 'string', description: '可选幂等键;同一页面同一用途应复用' }, + }, + required: ['purpose', 'prompt'], + }, + }, { name: 'generate_docx', description: @@ -534,6 +588,18 @@ async function callTool(name, args) { fs.mkdirSync(abs, { recursive: true }); return [{ type: 'text', text: `已创建目录 ${args.path}` }]; } + case 'generate_image': { + const purpose = String(args.purpose ?? '').trim(); + const prompt = String(args.prompt ?? '').trim(); + if (!prompt) throw new Error('generate_image: prompt 不能为空'); + const result = await generateMindSpaceImage({ + purpose, + prompt, + negativePrompt: String(args.negative_prompt ?? '').trim(), + idempotencyKey: String(args.idempotency_key ?? '').trim(), + }); + return [{ type: 'text', text: JSON.stringify(result, null, 2) }]; + } case 'generate_long_image': { const htmlPath = String(args.html_path ?? args.path ?? '').trim(); if (!htmlPath.toLowerCase().endsWith('.html')) { diff --git a/mindspace-sandbox-mcp.test.mjs b/mindspace-sandbox-mcp.test.mjs index 6bcb917..8116318 100644 --- a/mindspace-sandbox-mcp.test.mjs +++ b/mindspace-sandbox-mcp.test.mjs @@ -4,6 +4,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import http from 'node:http'; import { syncPublicDocxDownloads } from './mindspace-public-finish-sync.mjs'; function copyDocxGenerateSkill(root) { @@ -85,6 +87,60 @@ function startSandbox(root, envOverrides = {}) { return { child, request }; } +test('generate_image calls the authenticated Memind internal route and returns MindSpace asset data', async (t) => { + const requests = []; + const api = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + requests.push({ url: req.url, authorization: req.headers.authorization, body: JSON.parse(body) }); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + data: { + ok: true, + purpose: 'hero', + asset: { + id: 'asset-1', + publicUrl: 'https://m.example/image.webp', + workspaceRelativePath: 'public/images/2026-07-19/image.webp', + }, + }, + })); + }); + }); + api.listen(0, '127.0.0.1'); + await once(api, 'listening'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-generate-image-')); + const sandbox = startSandbox(root, { + ALLOWED_TOOLS: 'generate_image', + PRIVATE_DATA_USER_ID: 'user-1', + MINDSPACE_AGENT_API_BASE_URL: `http://127.0.0.1:${api.address().port}/api`, + MINDSPACE_INTERNAL_AGENT_SECRET: 'internal-secret', + }); + t.after(() => sandbox.child.kill()); + t.after(() => api.close()); + + await sandbox.request('initialize'); + const called = await sandbox.request('tools/call', { + name: 'generate_image', + arguments: { purpose: 'hero', prompt: 'calm reading room', idempotency_key: 'imgreq-1' }, + }); + assert.equal(called.result.isError, false); + const result = JSON.parse(called.result.content[0].text); + assert.equal(result.asset.id, 'asset-1'); + assert.deepEqual(requests, [{ + url: '/api/agent/mindspace_image_generate', + authorization: 'Bearer internal-secret', + body: { + user_id: 'user-1', + purpose: 'hero', + prompt: 'calm reading room', + negative_prompt: '', + }, + }]); +}); + test('sandbox MCP rejects browser storage in generated page code', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-storage-ban-')); const server = startSandbox(root, { ALLOWED_TOOLS: 'write_file,edit_file' }); diff --git a/server.mjs b/server.mjs index 7f91b6a..735b03d 100644 --- a/server.mjs +++ b/server.mjs @@ -2084,6 +2084,7 @@ api.use(async (req, res, next) => { if (req.path === '/agent/mindspace_page_patch') return next(); if (req.path === '/agent/mindspace_asset_delete') return next(); if (req.path === '/agent/mindspace_asset_download') return next(); + if (req.path === '/agent/mindspace_image_generate') return next(); if (req.path === '/config/blocked-words') return next(); if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); @@ -2127,6 +2128,7 @@ api.use(async (req, res, next) => { attachAsrRoutes(api, { sendError, sendData }); attachMindSpaceImageGenerationRoutes(api, { getService: () => mindSpaceImageGeneration, + requireInternal: requireInternalAgentSecret, }); attachPageDataRoutes(api, { sendError, diff --git a/user-auth.mjs b/user-auth.mjs index f47ccb5..c533b3a 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -1908,6 +1908,9 @@ export function createUserAuth(pool, options = {}) { userDataMcpPgUrl: env.MINDSPACE_USERDATA_MCP_PG_URL, userDataPgHostGateway: env.MINDSPACE_USERDATA_MCP_PG_HOST, userDataAutoProvision: env.MINDSPACE_USERDATA_AUTO_PROVISION, + agentApiBaseUrl: env.MINDSPACE_AGENT_API_BASE_URL, + internalAgentSecret: + env.MINDSPACE_INTERNAL_AGENT_SECRET ?? env.TKMIND_SERVER__SECRET_KEY, }; } catch (err) { console.warn('[getAgentSessionPolicy] sandbox MCP setup failed, falling back:', err?.message); From a0043e95c163bb6ccacdb7ad298969a36bc4aece Mon Sep 17 00:00:00 2001 From: john Date: Sun, 19 Jul 2026 19:09:26 +0800 Subject: [PATCH 3/8] fix: complete image_make asset delivery --- db.mjs | 2 +- image-make-client.mjs | 3 ++ image-make-client.test.mjs | 45 ++++++++++++++++++++++ mindspace-image-generation-routes.mjs | 2 +- mindspace-image-generation-routes.test.mjs | 10 +++-- mindspace-image-generation.test.mjs | 8 ++++ schema.sql | 2 +- 7 files changed, 66 insertions(+), 6 deletions(-) diff --git a/db.mjs b/db.mjs index e4ff41c..c060316 100644 --- a/db.mjs +++ b/db.mjs @@ -237,7 +237,7 @@ export async function migrateSchema(pool) { await pool.query( `ALTER TABLE h5_assets - MODIFY source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace') + MODIFY source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace', 'image_make') NOT NULL DEFAULT 'upload'`, ); diff --git a/image-make-client.mjs b/image-make-client.mjs index 81fcda5..318c659 100644 --- a/image-make-client.mjs +++ b/image-make-client.mjs @@ -123,6 +123,9 @@ export function createImageMakeClient({ const deadline = Date.now() + overallTimeout; let job = created; + if (!ACTIVE_STATUSES.has(String(job?.status ?? '')) && !job?.result && created?.status_url) { + job = await safeJson(await request(created.status_url)); + } while (ACTIVE_STATUSES.has(String(job?.status ?? ''))) { if (Date.now() >= deadline) { throw createClientError('IMAGE_MAKE_TIMEOUT', '图片生成超时', { jobId }); diff --git a/image-make-client.test.mjs b/image-make-client.test.mjs index a85755f..7a78590 100644 --- a/image-make-client.test.mjs +++ b/image-make-client.test.mjs @@ -80,6 +80,51 @@ test('image_make client fails closed on checksum mismatch', async () => { ); }); +test('image_make client resolves a completed idempotent reuse through status_url', async () => { + const image = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBPreused')]); + const sha256 = crypto.createHash('sha256').update(image).digest('hex'); + const calls = []; + const client = createImageMakeClient({ + baseUrl: 'http://127.0.0.1:8080', token: 'test-token', sleep: async () => {}, + fetchImpl: async (url) => { + const path = new URL(url).pathname; + calls.push(path); + if (path === '/v1/generation-jobs') { + return Response.json({ + job_id: 'job-reused', + status: 'succeeded', + status_url: '/v1/generation-jobs/job-reused', + }); + } + if (path === '/v1/generation-jobs/job-reused') { + return Response.json({ + job_id: 'job-reused', + status: 'succeeded', + result: { + download_url: '/v1/generation-jobs/job-reused/result', + sha256, + }, + }); + } + if (path.endsWith('/result')) { + return new Response(image, { headers: { 'content-type': 'image/webp' } }); + } + throw new Error(`Unexpected request ${path}`); + }, + }); + const result = await client.generateImage({ + prompt: 'reuse me', + presetId: 'memind_dark_hero', + idempotencyKey: 'imgreq-reused', + }); + assert.equal(result.jobId, 'job-reused'); + assert.deepEqual(calls, [ + '/v1/generation-jobs', + '/v1/generation-jobs/job-reused', + '/v1/generation-jobs/job-reused/result', + ]); +}); + 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); diff --git a/mindspace-image-generation-routes.mjs b/mindspace-image-generation-routes.mjs index f25a5fe..51d2af4 100644 --- a/mindspace-image-generation-routes.mjs +++ b/mindspace-image-generation-routes.mjs @@ -28,7 +28,7 @@ async function handleGenerate(req, res, { service, userId, consumerPrefix }) { purpose, prompt, negativePrompt: req.body?.negative_prompt, - consumerRef: `${consumerPrefix}:${req.requestId ?? 'request'}:${purpose}`, + consumerRef: `${consumerPrefix}:${idempotencyKey || req.requestId || 'request'}:${purpose}`, idempotencyKey, }); if (!result.ok) { diff --git a/mindspace-image-generation-routes.test.mjs b/mindspace-image-generation-routes.test.mjs index 83271c9..6003159 100644 --- a/mindspace-image-generation-routes.test.mjs +++ b/mindspace-image-generation-routes.test.mjs @@ -42,7 +42,7 @@ test('image generation route passes authenticated user and idempotency key to th purpose: 'hero', prompt: 'calm library', negativePrompt: undefined, - consumerRef: 'mindspace:req-1:hero', + consumerRef: 'mindspace:imgreq-1:hero', idempotencyKey: 'imgreq-1', }]); } finally { await fixture.close(); } @@ -84,11 +84,15 @@ test('internal image generation route requires its injected secret guard and exp assert.equal(denied.status, 401); const allowed = await fetch(`${fixture.url}/agent/mindspace_image_generate`, { method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer internal-secret' }, + headers: { + 'content-type': 'application/json', + authorization: 'Bearer internal-secret', + 'idempotency-key': 'page-hero-1', + }, body: JSON.stringify({ user_id: 'user-2', purpose: 'hero', prompt: 'x' }), }); assert.equal(allowed.status, 201); assert.equal(calls[0].userId, 'user-2'); - assert.equal(calls[0].consumerRef, 'mindspace-agent:req-1:hero'); + assert.equal(calls[0].consumerRef, 'mindspace-agent:page-hero-1:hero'); } finally { await fixture.close(); } }); diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs index 0ddd621..db6864a 100644 --- a/mindspace-image-generation.test.mjs +++ b/mindspace-image-generation.test.mjs @@ -1,7 +1,15 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import test from 'node:test'; import { createMindSpaceImageGenerationService } from './mindspace-image-generation.mjs'; +test('MindSpace asset schema accepts image_make as a source type', () => { + const schemaSql = fs.readFileSync(new URL('./schema.sql', import.meta.url), 'utf8'); + const dbSource = fs.readFileSync(new URL('./db.mjs', import.meta.url), 'utf8'); + assert.match(schemaSql, /source_type ENUM\([^\n]*'image_make'/); + assert.match(dbSource, /MODIFY source_type ENUM\([^\n]*'image_make'/); +}); + test('MindSpace image generation stays inert while the admin purpose is disabled', async () => { const service = createMindSpaceImageGenerationService({ configService: { diff --git a/schema.sql b/schema.sql index 7144cf7..22e7d5b 100644 --- a/schema.sql +++ b/schema.sql @@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS h5_assets ( risk_level ENUM('none', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'none', visibility ENUM('private', 'internal', 'public_candidate') NOT NULL DEFAULT 'private', status ENUM('uploaded', 'processing', 'ready', 'quarantined', 'archived', 'deleted') NOT NULL DEFAULT 'ready', - source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace') NOT NULL DEFAULT 'upload', + source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace', 'image_make') NOT NULL DEFAULT 'upload', created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, deleted_at BIGINT NULL, From 8438eec08cb371774035e5da4381a5e6b0bbd31d Mon Sep 17 00:00:00 2001 From: john Date: Mon, 20 Jul 2026 07:37:43 +0800 Subject: [PATCH 4/8] feat: add guarded image generation controls --- agent-run-gateway.mjs | 47 +++++++++++++-- agent-run-gateway.test.mjs | 28 ++++++++- asset-gateway.mjs | 21 +++++-- asset-gateway.test.mjs | 22 +++++++ chat-intent-router.mjs | 92 +++++++++++++++++++++++++++-- chat-intent-router.test.mjs | 48 +++++++++++++++ image-make-client.mjs | 4 +- mindspace-sandbox-mcp.mjs | 4 +- session-reply-wait.mjs | 79 +++++++++++++++++++++++++ session-reply-wait.test.mjs | 42 +++++++++++++ skills/static-page-publish/SKILL.md | 11 ++++ src/components/ChatPanel.tsx | 32 +++++++++- src/components/ChatView.tsx | 1 + src/components/SpaceChatPanel.tsx | 2 + src/hooks/usePageEditSubChat.ts | 12 +++- src/hooks/useTKMindChat.ts | 3 + src/index.css | 31 ++++++++++ src/utils/imageGeneration.ts | 13 ++++ user-publish.mjs | 13 +++- 19 files changed, 482 insertions(+), 23 deletions(-) create mode 100644 src/utils/imageGeneration.ts diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 7ceea2c..0c59c47 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -66,6 +66,33 @@ function extractRunMessageText(row) { .join('\n'); } +function resolveRequiredImageGeneration(row, routing) { + const message = parseDbJsonColumn(row?.user_message_json, {}) ?? {}; + const metadata = message.metadata ?? {}; + const runMetadata = metadata[RUN_METADATA_KEY] ?? metadata.agentRun ?? {}; + const requestedMode = String( + runMetadata.imageGenerationMode ?? runMetadata.image_generation_mode ?? metadata.imageGenerationMode ?? '', + ).trim().toLowerCase(); + if (requestedMode === 'required') return true; + if (requestedMode === 'disabled') return false; + return routing?.imageGeneration?.mode === 'required'; +} + +export function assertRequiredImageGenerationCompleted(row, routing, toolEvidence) { + if (!resolveRequiredImageGeneration(row, routing)) return; + if (toolEvidence?.generateImage?.succeeded === true) return; + if (toolEvidence?.generateImage?.called !== true) { + const error = new Error('本轮明确要求生成图片,但 Agent 没有实际调用 generate_image;历史回复不能作为本轮工具证据'); + error.code = 'IMAGE_GENERATION_REQUIRED_NOT_CALLED'; + error.retryable = false; + throw error; + } + const error = new Error('本轮明确要求生成图片,但未获得 image_make 的有效 PNG/JPEG/WebP 产物,不能标记成功'); + error.code = 'IMAGE_GENERATION_REQUIRED_MISSING'; + error.retryable = false; + throw error; +} + function positiveInteger(value, fallback) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return fallback; @@ -780,6 +807,7 @@ export function createAgentRunGateway({ }); } await invalidatePortalDirectChatSnapshot(sessionId); + let toolEvidence = null; const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true) && runOptions.toolMode === 'chat' && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; @@ -799,7 +827,10 @@ export function createAgentRunGateway({ await appendEvent(runId, 'session_finished', { sessionId, tokenState: finish.tokenState ?? null, + toolCalls: finish.toolEvidence?.calls ?? [], + generateImage: finish.toolEvidence?.generateImage ?? null, }); + toolEvidence = finish.toolEvidence ?? null; } catch (err) { if (await recoverRunFromDeliverables({ runId, @@ -823,10 +854,16 @@ export function createAgentRunGateway({ }, ); } - return { sessionId, routing }; + return { sessionId, routing, toolEvidence }; } - async function finalizeSuccessfulRun(runId, row, sessionId, { routing = null } = {}) { + async function finalizeSuccessfulRun( + runId, + row, + sessionId, + { routing = null, toolEvidence = null } = {}, + ) { + assertRequiredImageGenerationCompleted(row, routing, toolEvidence); let deliveryResult = null; if (typeof syncUserPagesOnSuccess === 'function') { deliveryResult = await syncUserPagesOnSuccess({ @@ -933,12 +970,12 @@ export function createAgentRunGateway({ const stopHeartbeat = startRunHeartbeat(runId, { attempt: nextAttempt }); try { - const { sessionId, routing } = await runWithTimeout(runId, () => executeRun(row, runId)); - await finalizeSuccessfulRun(runId, row, sessionId, { routing }); + const execution = await runWithTimeout(runId, () => executeRun(row, runId)); + await finalizeSuccessfulRun(runId, row, execution.sessionId, execution); } catch (err) { const latest = await getRunById(runId); const recoverySessionId = latest?.agent_session_id ?? row.agent_session_id ?? null; - if (await recoverRunFromDeliverables({ + if (err?.code !== 'IMAGE_GENERATION_REQUIRED_MISSING' && await recoverRunFromDeliverables({ runId, userId: row.user_id, sessionId: recoverySessionId, diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 2df0068..3213fca 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -4,7 +4,33 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { createAgentRunGateway } from './agent-run-gateway.mjs'; +import { + assertRequiredImageGenerationCompleted, + createAgentRunGateway, +} from './agent-run-gateway.mjs'; + +test('required image generation cannot succeed without a verified raster image_make result', () => { + const row = { + user_message_json: JSON.stringify({ + metadata: { memindRun: { imageGenerationMode: 'required' } }, + }), + }; + assert.throws( + () => assertRequiredImageGenerationCompleted(row, null, { + generateImage: { called: false, succeeded: false }, + }), + (error) => error?.code === 'IMAGE_GENERATION_REQUIRED_NOT_CALLED', + ); + assert.throws( + () => assertRequiredImageGenerationCompleted(row, null, { + generateImage: { called: true, succeeded: false }, + }), + (error) => error?.code === 'IMAGE_GENERATION_REQUIRED_MISSING', + ); + assert.doesNotThrow(() => assertRequiredImageGenerationCompleted(row, null, { + generateImage: { called: true, succeeded: true, jobId: 'job-1', mimeType: 'image/webp' }, + })); +}); function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } = {}) { const runs = new Map(); diff --git a/asset-gateway.mjs b/asset-gateway.mjs index ef96a0b..f469bba 100644 --- a/asset-gateway.mjs +++ b/asset-gateway.mjs @@ -32,10 +32,10 @@ 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' }, + { id: 'inline_image', label: '页面正文图片', presetId: 'memind_square_illustration', strategy: 'generate', sourcePurpose: null }, + { id: 'hero', label: '页面头图', presetId: 'memind_dark_hero', strategy: 'generate', sourcePurpose: null }, + { id: 'card_cover', label: '卡片封面图', presetId: 'memind_card_cover', strategy: 'derive', sourcePurpose: 'hero' }, + { id: 'feed_cover', label: '信息流缩略图', presetId: 'memind_feed_cover_source', strategy: 'derive', sourcePurpose: 'hero' }, ]; const imageMakePurposeIds = new Set(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => purpose.id)); @@ -281,10 +281,21 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul if (!resolved.plugin.purposes?.[purpose]) { return { ok: false, code: 'purpose_disabled', message: '该图片用途未启用,可安全降级到原有流程' }; } + const purposeConfig = IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose); + if (purposeConfig?.strategy === 'derive' && resolved.plugin.purposes?.hero) { + return { + ok: false, + code: 'reuse_hero_required', + message: '页面头图已启用:请只生成一次 hero,并从该主图派生卡片封面和信息流缩略图', + sourcePurpose: 'hero', + }; + } return { ok: true, purpose, - presetId: IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose)?.presetId, + presetId: purposeConfig?.presetId, + strategy: purposeConfig?.strategy ?? 'generate', + sourcePurpose: purposeConfig?.sourcePurpose ?? null, plugin: resolved.plugin, }; }, diff --git a/asset-gateway.test.mjs b/asset-gateway.test.mjs index 1cc6866..bbc2fad 100644 --- a/asset-gateway.test.mjs +++ b/asset-gateway.test.mjs @@ -120,6 +120,12 @@ test('image_make purposes are disabled by default and resolved independently', a feed_cover: true, }); assert.equal((await service.resolveImageGenerationPurpose('hero')).ok, true); + assert.deepEqual(await service.resolveImageGenerationPurpose('feed_cover'), { + ok: false, + code: 'reuse_hero_required', + message: '页面头图已启用:请只生成一次 hero,并从该主图派生卡片封面和信息流缩略图', + sourcePurpose: 'hero', + }); assert.equal(plugin.llmProviderKeyId, null); assert.equal(plugin.llmModel, null); assert.deepEqual(await service.resolveImageGenerationPurpose('card_cover'), { @@ -128,3 +134,19 @@ test('image_make purposes are disabled by default and resolved independently', a message: '该图片用途未启用,可安全降级到原有流程', }); }); + +test('derived cover purpose can generate one source when hero output is disabled', async () => { + const service = createAssetGatewayConfigService(createPool(), { llmProviderService }); + await service.updateGlobalConfig({ enabled: true }, { updatedBy: 'admin-1' }); + await service.updatePluginConfig('asset-generate', { + enabled: true, + provider: 'image_make', + purposes: { hero: false, card_cover: true }, + }, { updatedBy: 'admin-1' }); + + const resolved = await service.resolveImageGenerationPurpose('card_cover'); + assert.equal(resolved.ok, true); + assert.equal(resolved.strategy, 'derive'); + assert.equal(resolved.sourcePurpose, 'hero'); + assert.equal(resolved.presetId, 'memind_card_cover'); +}); diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 0332b8a..093d349 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -84,6 +84,83 @@ const OBVIOUS_DIRECT_CHAT_PATTERNS = [ /(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u, ]; +export const IMAGE_GENERATION_MODE = { + AUTO: 'auto', + REQUIRED: 'required', + DISABLED: 'disabled', +}; + +const IMAGE_GENERATION_REQUIRED_PATTERNS = [ + /(?:生成|制作|创建|画|绘制|配|添加|使用).{0,16}(?:AI\s*)?(?:图片|图像|背景图|封面图|插画|照片|主图|配图)/iu, + /(?:图片|图像|背景图|封面图|插画|照片|主图|配图).{0,16}(?:生成|制作|创建|画|绘制|作为|用作)/iu, + /(?:全屏|页面|网页|卡片|信息流).{0,12}(?:背景|封面|主图).{0,12}(?:图片|图像|照片)/iu, + /(?:AI配图|AI图片|真图|真实图片|生图)/iu, +]; + +const IMAGE_GENERATION_DISABLED_PATTERNS = [ + /(?:不要|无需|不需要|禁止|关闭|取消).{0,12}(?:AI\s*)?(?:生图|图片生成|生成图片|配图)/iu, + /(?:纯文字|只要文字|不要图片|不要配图|不用图片|不用配图)/iu, +]; + +const VISUAL_PAGE_IMAGE_HINT_PATTERNS = [ + /(?:精美|沉浸式|视觉化|视觉效果|全屏背景|大唐盛景|海报风|摄影风)/u, + /(?:旅行|美食|活动|品牌|产品|促销|诗词|唐诗).{0,16}(?:页面|网页|H5|h5|封面|背景)/u, +]; + +export function normalizeImageGenerationMode(value) { + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === IMAGE_GENERATION_MODE.REQUIRED) return IMAGE_GENERATION_MODE.REQUIRED; + if (normalized === IMAGE_GENERATION_MODE.DISABLED) return IMAGE_GENERATION_MODE.DISABLED; + return IMAGE_GENERATION_MODE.AUTO; +} + +export function resolveImageGenerationDecision({ text, requestedMode = IMAGE_GENERATION_MODE.AUTO } = {}) { + const normalizedText = String(text ?? '').trim(); + const normalizedMode = normalizeImageGenerationMode(requestedMode); + if (normalizedMode === IMAGE_GENERATION_MODE.REQUIRED) { + return { mode: IMAGE_GENERATION_MODE.REQUIRED, source: 'user_override', reason: '用户在输入区强制开启 AI 配图' }; + } + if (normalizedMode === IMAGE_GENERATION_MODE.DISABLED) { + return { mode: IMAGE_GENERATION_MODE.DISABLED, source: 'user_override', reason: '用户在输入区关闭 AI 配图' }; + } + if (IMAGE_GENERATION_DISABLED_PATTERNS.some((pattern) => pattern.test(normalizedText))) { + return { mode: IMAGE_GENERATION_MODE.DISABLED, source: 'intent', reason: '用户明确要求不生成图片' }; + } + if (IMAGE_GENERATION_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalizedText))) { + return { mode: IMAGE_GENERATION_MODE.REQUIRED, source: 'intent', reason: '检测到明确的图片生成需求' }; + } + if ( + isPageGenerationIntent(normalizedText) + && VISUAL_PAGE_IMAGE_HINT_PATTERNS.some((pattern) => pattern.test(normalizedText)) + ) { + return { mode: IMAGE_GENERATION_MODE.REQUIRED, source: 'intent', reason: '视觉类页面需要真实主图和缩略图源图' }; + } + return { mode: IMAGE_GENERATION_MODE.AUTO, source: 'default', reason: '未检测到必须生成或禁止生成图片的要求' }; +} + +function requestedImageGenerationMode(userMessage) { + const metadata = userMessage?.metadata ?? {}; + const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; + return normalizeImageGenerationMode( + runMetadata.imageGenerationMode ?? runMetadata.image_generation_mode ?? metadata.imageGenerationMode, + ); +} + +function buildImageGenerationInstruction(decision) { + if (decision?.mode === IMAGE_GENERATION_MODE.REQUIRED) { + return [ + '图片策略:强制生成。必须先调用 sandbox-fs__generate_image,并使用返回的 workspaceRelativePath/publicUrl 作为页面真实主图或背景图,再生成 HTML 与缩略图。', + '性能规则:页面头图、卡片封面和信息流缩略图只能共享一张主图。优先只调用一次 purpose=hero;hero 成功后必须复用该资产并裁剪派生,禁止再调用 card_cover 或 feed_cover。只有 hero 用途被后台关闭、且确实只需要对应封面时,才允许改用一个 card_cover 或 feed_cover 请求。正文插图 inline_image 仅在后台开启且正文确有需要时单独生成。', + '本轮证据要求:必须在当前这一次执行中发起新的 generate_image 工具请求;历史对话中的成功或失败都不能作为本轮结果。禁止在没有新 toolRequest/toolResponse 的情况下复述“已尝试”“Provider 未启用”或其他旧结论。', + '完成条件:返回结果必须 ok=true,包含 jobId,且产物为 PNG/JPEG/WebP;不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生图。生图失败时必须明确报告失败,不得静默降级后宣称完成。', + ].join('\n'); + } + if (decision?.mode === IMAGE_GENERATION_MODE.DISABLED) { + return '图片策略:关闭。本轮禁止调用 generate_image;如需页面视觉,只能使用现有合法资源或纯 CSS,并如实说明未生成新图片。'; + } + return '图片策略:自动。仅在用户需求确实需要新主图、背景图、正文配图或封面时调用 generate_image;不要为了装饰无条件生图。'; +} + /** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */ const REALTIME_INFO_AGENT_PATTERNS = [ /(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u, @@ -719,6 +796,7 @@ export function buildAgentOrchestrationAgentText({ `路由判定:${classification.reason}`, classification.agentBrief ? `执行要点:${classification.agentBrief}` : '', classification.suggestedSkill ? `建议 skill:${classification.suggestedSkill}` : '', + buildImageGenerationInstruction(classification.imageGeneration), skillPrompt, memoryLines.length ? [ @@ -1114,8 +1192,7 @@ export function createChatIntentRouter(options = {}) { if (!classification) return classification; const base = { ...classification }; delete base.decision; - return finalizeRouterClassification( - coercePageGenerationSkill( + const coerced = coercePageGenerationSkill( coercePageDataSkill( coerceRealtimeWebSkill(base, text, { grantedSkills }), text, @@ -1123,9 +1200,14 @@ export function createChatIntentRouter(options = {}) { ), text, { grantedSkills }, - ), - decisionContext, - ); + ); + return finalizeRouterClassification({ + ...coerced, + imageGeneration: resolveImageGenerationDecision({ + text, + requestedMode: requestedImageGenerationMode(userMessage), + }), + }, decisionContext); }; const ruleResult = classifyWithRules({ text, diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index a98a87f..2cb1068 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -23,10 +23,58 @@ import { isRealtimeInfoQuestion, isRichChannelPageIntent, isRichPublishableContentIntent, + IMAGE_GENERATION_MODE, + resolveImageGenerationDecision, coerceRealtimeWebSkill, resolveChatIntentRouterPolicy, } from './chat-intent-router.mjs'; +test('image generation intent requires a real image for explicit background requests', () => { + const decision = resolveImageGenerationDecision({ + text: '帮我生成一首唐诗页面,背景是大唐盛景图片,页面要精美', + }); + assert.equal(decision.mode, IMAGE_GENERATION_MODE.REQUIRED); + assert.equal(decision.source, 'intent'); +}); + +test('image generation user override can force or disable image generation', () => { + assert.equal(resolveImageGenerationDecision({ + text: '做一个纯文字页面', + requestedMode: 'required', + }).mode, IMAGE_GENERATION_MODE.REQUIRED); + assert.equal(resolveImageGenerationDecision({ + text: '生成一个带背景图的页面', + requestedMode: 'disabled', + }).mode, IMAGE_GENERATION_MODE.DISABLED); +}); + +test('router injects a fail-closed generate_image contract for required visual pages', async () => { + const router = createChatIntentRouter(); + const text = '帮我生成一个精美唐诗页面,背景是大唐盛景图片'; + const userMessage = { + role: 'user', + content: [{ type: 'text', text }], + metadata: { + displayText: text, + memindRun: { imageGenerationMode: 'auto' }, + }, + }; + const classification = await router.classify({ + userMessage, + grantedSkills: ['static-page-publish'], + }); + assert.equal(classification.imageGeneration.mode, IMAGE_GENERATION_MODE.REQUIRED); + const enriched = router.applyAgentOrchestration(userMessage, classification, { + grantedSkills: ['static-page-publish'], + }); + assert.match(enriched.content[0].text, /sandbox-fs__generate_image/); + assert.match(enriched.content[0].text, /当前这一次执行中发起新的 generate_image/); + assert.match(enriched.content[0].text, /历史对话中的成功或失败都不能作为本轮结果/); + assert.match(enriched.content[0].text, /hero 成功后必须复用该资产/); + assert.match(enriched.content[0].text, /禁止再调用 card_cover 或 feed_cover/); + assert.match(enriched.content[0].text, /不得用 SVG/); +}); + test('classifyWithRules routes greetings to direct chat', () => { const result = classifyWithRules({ text: '你好', diff --git a/image-make-client.mjs b/image-make-client.mjs index 318c659..bf5af30 100644 --- a/image-make-client.mjs +++ b/image-make-client.mjs @@ -52,7 +52,7 @@ export function createImageMakeClient({ token, fetchImpl = globalThis.fetch, requestTimeoutMs = 15_000, - generationTimeoutMs = 10 * 60_000, + generationTimeoutMs = 15 * 60_000, pollIntervalMs = 1_000, maxResultBytes = DEFAULT_MAX_RESULT_BYTES, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), @@ -62,7 +62,7 @@ export function createImageMakeClient({ 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 overallTimeout = positiveInteger(generationTimeoutMs, 15 * 60_000); const pollingDelay = positiveInteger(pollIntervalMs, 1_000); const resultLimit = positiveInteger(maxResultBytes, DEFAULT_MAX_RESULT_BYTES); diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 9a02ea5..7ce7dc9 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -142,7 +142,7 @@ async function generateMindSpaceImage({ purpose, prompt, negativePrompt, idempot prompt, negative_prompt: negativePrompt, }), - signal: AbortSignal.timeout(Number(process.env.IMAGE_MAKE_GENERATION_TIMEOUT_MS ?? 600_000)), + signal: AbortSignal.timeout(Number(process.env.IMAGE_MAKE_GENERATION_TIMEOUT_MS ?? 900_000)), }); let body = null; try { @@ -233,7 +233,7 @@ const ALL_TOOLS = [ { name: 'generate_image', description: - '通过平台 image_make 服务生成页面图片并直接写入当前用户 MindSpace。仅在后台开关开启时生效;失败时继续原页面流程。返回 canonical publicUrl 与 workspaceRelativePath。', + '通过平台 image_make 服务生成页面图片并直接写入当前用户 MindSpace。页面头图、卡片封面和信息流缩略图必须共享一次生成结果:优先只调用一次 hero,再从该图派生其他尺寸;禁止在 hero 成功后继续调用 card_cover/feed_cover。正文插图 inline_image 仅按需单独生成。仅在后台对应开关开启时生效,返回 canonical publicUrl 与 workspaceRelativePath。', inputSchema: { type: 'object', properties: { diff --git a/session-reply-wait.mjs b/session-reply-wait.mjs index 25622f0..c9b007f 100644 --- a/session-reply-wait.mjs +++ b/session-reply-wait.mjs @@ -24,6 +24,82 @@ export function eventMatchesRequest(event, requestId) { return !routingId || routingId === requestId; } +function messageContentItems(event) { + const candidates = [ + event?.message?.content, + event?.data?.message?.content, + event?.content, + ]; + return candidates.find((content) => Array.isArray(content)) ?? []; +} + +function parseGenerateImageResult(toolResult) { + const content = toolResult?.value?.content; + if (!Array.isArray(content)) return null; + for (const item of content) { + if (item?.type !== 'text' || !String(item.text ?? '').trim()) continue; + try { + const result = JSON.parse(String(item.text)); + const mimeType = String(result?.source?.mimeType ?? result?.asset?.mimeType ?? '').toLowerCase(); + if ( + result?.ok === true + && String(result?.jobId ?? '').trim() + && /^image\/(?:png|jpeg|webp)$/.test(mimeType) + ) { + return { + jobId: String(result.jobId), + mimeType, + assetId: String(result?.asset?.id ?? '').trim() || null, + publicUrl: String(result?.asset?.publicUrl ?? '').trim() || null, + workspaceRelativePath: String(result?.asset?.workspaceRelativePath ?? '').trim() || null, + }; + } + } catch { + // Ignore non-JSON tool text; it cannot prove a successful image_make delivery. + } + } + return null; +} + +function createToolEvidenceCollector() { + const requestNames = new Map(); + const calls = new Set(); + const successfulCalls = new Set(); + let generateImage = { called: false, succeeded: false }; + + return { + observe(event) { + for (const item of messageContentItems(event)) { + if (item?.type === 'toolRequest') { + const name = String(item?.toolCall?.value?.name ?? '').trim(); + if (!name) continue; + calls.add(name); + if (item.id) requestNames.set(String(item.id), name); + if (name.endsWith('generate_image')) generateImage = { called: true, succeeded: false }; + continue; + } + if (item?.type !== 'toolResponse') continue; + const name = requestNames.get(String(item.id ?? '')) ?? ''; + const result = item.toolResult ?? {}; + const succeeded = result.status === 'success' && result?.value?.isError !== true; + if (name && succeeded) successfulCalls.add(name); + if (!name.endsWith('generate_image')) continue; + const generated = succeeded ? parseGenerateImageResult(result) : null; + generateImage = generated + ? { called: true, succeeded: true, ...generated } + : { called: true, succeeded: false }; + } + }, + snapshot() { + return { + calls: [...calls], + successfulCalls: [...successfulCalls], + generateImage, + }; + }, + }; +} + export async function consumeSessionEventsUntilFinish( body, { @@ -40,6 +116,7 @@ export async function consumeSessionEventsUntilFinish( const reader = Readable.fromWeb(body); const decoder = new TextDecoder(); + const toolEvidence = createToolEvidenceCollector(); let buffer = ''; const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 1); @@ -60,6 +137,7 @@ export async function consumeSessionEventsUntilFinish( const event = parseSessionStreamEvent(trimmed); if (!event) continue; if (!eventMatchesRequest(event, requestId)) continue; + toolEvidence.observe(event); onEvent?.(event); if (event.type === 'Error') { const err = new Error(String(event.error ?? 'session reply failed')); @@ -71,6 +149,7 @@ export async function consumeSessionEventsUntilFinish( return { finishEvent: event, tokenState: event.token_state ?? null, + toolEvidence: toolEvidence.snapshot(), }; } } diff --git a/session-reply-wait.test.mjs b/session-reply-wait.test.mjs index c40e4d1..9b0d7e6 100644 --- a/session-reply-wait.test.mjs +++ b/session-reply-wait.test.mjs @@ -33,3 +33,45 @@ test('consumeSessionEventsUntilFinish resolves on Finish', async () => { assert.equal(result.finishEvent.type, 'Finish'); assert.equal(result.tokenState.totalTokens, 12); }); + +test('consumeSessionEventsUntilFinish records a successful raster generate_image result', async () => { + const toolResult = JSON.stringify({ + ok: true, + jobId: 'job-1', + source: { mimeType: 'image/webp' }, + asset: { + id: 'asset-1', + publicUrl: '/MindSpace/user/public/images/hero.webp', + workspaceRelativePath: 'public/images/hero.webp', + }, + }); + const frames = [ + `data: ${JSON.stringify({ + type: 'Message', request_id: 'req-1', message: { + content: [{ + type: 'toolRequest', id: 'call-1', + toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } }, + }], + }, + })}\n\n`, + `data: ${JSON.stringify({ + type: 'Message', request_id: 'req-1', message: { + content: [{ + type: 'toolResponse', id: 'call-1', + toolResult: { status: 'success', value: { content: [{ type: 'text', text: toolResult }], isError: false } }, + }], + }, + })}\n\n`, + 'data: {"type":"Finish","request_id":"req-1"}\n\n', + ]; + const stream = new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame)); + controller.close(); + }, + }); + const result = await consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 }); + assert.equal(result.toolEvidence.generateImage.called, true); + assert.equal(result.toolEvidence.generateImage.succeeded, true); + assert.equal(result.toolEvidence.generateImage.jobId, 'job-1'); +}); diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index aebce39..b75d20e 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -28,6 +28,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 11. **纯静态页(无 Page Data 问卷/后台)禁止调用 `private_data_bind_workspace_page`**;`write_file` / `edit_file` 落盘 `public/*.html` 后,直接按下方「回复格式」交付 **MindSpace 路径**(`/MindSpace/<用户ID>/public/...`),无需 bind、无需口令 12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接 13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill` → `page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。 +14. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先调用 `sandbox-fs__generate_image`;不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。 详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 @@ -41,6 +42,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 6. 若用户明确要求 Word/docx 下载,必须用 `generate_docx`(sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘 7. 若用户明确要求长图下载,必须用 `long-image-download` 生成 `public/<同名>.long.png`,并确认文件存在 +## AI 图片生成(按意图与用户选择) + +- **强制生成**:页面主图优先只调用一次 `generate_image(purpose=hero)`;卡片封面和信息流缩略图必须复用这张主图裁剪派生,hero 成功后禁止再调用 `card_cover` / `feed_cover` +- 只有后台关闭 `hero`、且任务确实只需要某一种封面时,才允许改用一次 `card_cover` 或 `feed_cover`;正文配图 `inline_image` 仅在后台开启且正文确有需要时单独生成 +- 成功结果必须满足 `ok=true`、包含 `jobId`,且 `source.mimeType` 为 `image/png`、`image/jpeg` 或 `image/webp` +- HTML 的 ``、背景图及 `mindspace-cover.cover` 必须引用本次返回的 `asset.publicUrl` 或对应工作区相对路径;页面主图默认同时作为缩略图源图 +- **自动**:只在用户需求确实需要新主图、背景图、正文配图或封面时调用 +- **关闭**:禁止调用 `generate_image` +- 强制生成失败时必须如实报告,禁止静默改用 SVG/旧图后宣称图片生成完成 + ## 按需伴生下载文件 - 默认不生成伴生文件;只有用户明确要求下载附件时才生成 diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index ac805c2..30b9389 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,10 +1,15 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react'; -import { BrainCircuit, Database } from 'lucide-react'; +import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; import { getMessageSaveActions } from '../utils/messageSave'; import { getDisplayText } from '../utils/message'; +import { + IMAGE_GENERATION_MODE_LABELS, + nextImageGenerationMode, + type ImageGenerationMode, +} from '../utils/imageGeneration'; import { downloadChatMessageDocx, } from '../api/client'; @@ -186,6 +191,7 @@ export function ChatPanel({ messageId?: string; forceDeepReasoning?: boolean; pgRequired?: boolean; + imageGenerationMode?: ImageGenerationMode; selectedChatSkill?: string; fileAttachments?: ChatFileAttachment[]; }, @@ -219,6 +225,7 @@ export function ChatPanel({ const [voiceNotice, setVoiceNotice] = useState(null); const [forceDeepReasoning, setForceDeepReasoning] = useState(false); const [pgRequired, setPgRequired] = useState(false); + const [imageGenerationMode, setImageGenerationMode] = useState('auto'); const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null); const [pendingImages, setPendingImages] = useState([]); const [pendingFiles, setPendingFiles] = useState([]); @@ -745,6 +752,7 @@ export function ChatPanel({ messageId: outgoingMessageId, forceDeepReasoning, pgRequired, + imageGenerationMode, selectedChatSkill, fileAttachments: fileAttachmentsToSend, }); @@ -774,6 +782,7 @@ export function ChatPanel({ }, [ forceDeepReasoning, pgRequired, + imageGenerationMode, input, onSubmit, onUploadFile, @@ -1229,6 +1238,27 @@ export function ChatPanel({ 需要长期使用的数据,放进专属空间 )} +