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,