feat: integrate image_make with MindSpace assets
This commit is contained in:
@@ -252,6 +252,13 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
|
||||
# MINDSPACE_AGENT_JOBS_ENABLED=true
|
||||
# MINDSPACE_INTERNAL_AGENT_SECRET=local-dev-secret
|
||||
# MINDSPACE_FREE_PUBLIC_PAGE_LIMIT=10
|
||||
# image_make 独立生图服务。只有 URL、Token 与 memind_adm 资产能力开关同时配置后才会调用。
|
||||
# IMAGE_MAKE_BASE_URL=http://127.0.0.1:8080
|
||||
# IMAGE_MAKE_TOKEN=dev-token
|
||||
# IMAGE_MAKE_REQUEST_TIMEOUT_MS=15000
|
||||
# IMAGE_MAKE_GENERATION_TIMEOUT_MS=600000
|
||||
# IMAGE_MAKE_POLL_INTERVAL_MS=1000
|
||||
# IMAGE_MAKE_MAX_RESULT_BYTES=20971520
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memind runtime profile(本地 vs 生产 必须区分)
|
||||
|
||||
+88
-9
@@ -12,7 +12,7 @@ export const ASSET_PLUGIN_CATALOG = [
|
||||
id: 'asset-generate',
|
||||
label: '图片生成',
|
||||
description: '异步生成页面所需图片;关闭或失败时必须降级为无图页面。',
|
||||
providers: ['flux-schnell', 'comfyui'],
|
||||
providers: ['image_make', 'flux-schnell', 'comfyui'],
|
||||
supportsLlm: true,
|
||||
},
|
||||
{
|
||||
@@ -31,6 +31,18 @@ export const ASSET_PLUGIN_CATALOG = [
|
||||
},
|
||||
];
|
||||
|
||||
export const IMAGE_MAKE_PURPOSE_CATALOG = [
|
||||
{ id: 'inline_image', label: '页面正文图片', presetId: 'memind_square_illustration' },
|
||||
{ id: 'hero', label: '页面头图', presetId: 'memind_dark_hero' },
|
||||
{ id: 'card_cover', label: '卡片封面', presetId: 'memind_card_cover' },
|
||||
{ id: 'feed_cover', label: '信息流与缩略图源图', presetId: 'memind_feed_cover_source' },
|
||||
];
|
||||
|
||||
const imageMakePurposeIds = new Set(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => purpose.id));
|
||||
const disabledImageMakePurposes = Object.freeze(
|
||||
Object.fromEntries(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => [purpose.id, false])),
|
||||
);
|
||||
|
||||
const pluginById = new Map(ASSET_PLUGIN_CATALOG.map((plugin) => [plugin.id, plugin]));
|
||||
|
||||
function normalizePluginId(value) {
|
||||
@@ -48,9 +60,34 @@ function toBoolean(value, defaultValue = false) {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function parseJsonObject(value) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object' && !Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(String(value));
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageMakePurposes(value, fallback = disabledImageMakePurposes) {
|
||||
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
return Object.fromEntries(
|
||||
IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => [
|
||||
purpose.id,
|
||||
toBoolean(raw[purpose.id], Boolean(fallback?.[purpose.id])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function rowImageMakePurposes(row) {
|
||||
return normalizeImageMakePurposes(parseJsonObject(row?.config_json)?.purposes);
|
||||
}
|
||||
|
||||
function rowToPluginConfig(row, providerKey = null) {
|
||||
const plugin = pluginById.get(row.plugin_id);
|
||||
return {
|
||||
const config = {
|
||||
pluginId: row.plugin_id,
|
||||
label: plugin?.label ?? row.plugin_id,
|
||||
description: plugin?.description ?? '',
|
||||
@@ -64,6 +101,11 @@ function rowToPluginConfig(row, providerKey = null) {
|
||||
llmModel: row.llm_model ?? null,
|
||||
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||
};
|
||||
if (row.plugin_id === 'asset-generate') {
|
||||
config.purposes = rowImageMakePurposes(row);
|
||||
config.purposeCatalog = IMAGE_MAKE_PURPOSE_CATALOG;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +182,12 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
|
||||
llmProviderId: null,
|
||||
llmModel: null,
|
||||
updatedAt: null,
|
||||
...(plugin.id === 'asset-generate'
|
||||
? {
|
||||
purposes: { ...disabledImageMakePurposes },
|
||||
purposeCatalog: IMAGE_MAKE_PURPOSE_CATALOG,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -171,26 +219,36 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
|
||||
const provider = normalizeProvider(plugin, payload.provider);
|
||||
if (enabled && !provider) return { ok: false, message: '请选择该插件支持的 Provider' };
|
||||
|
||||
const keyId = String(payload.llmProviderKeyId ?? payload.keyId ?? '').trim() || null;
|
||||
const model = String(payload.llmModel ?? payload.model ?? '').trim() || null;
|
||||
const usesModelCenter = !(pluginId === 'asset-generate' && provider === 'image_make');
|
||||
const keyId = usesModelCenter
|
||||
? String(payload.llmProviderKeyId ?? payload.keyId ?? '').trim() || null
|
||||
: null;
|
||||
const model = usesModelCenter
|
||||
? String(payload.llmModel ?? payload.model ?? '').trim() || null
|
||||
: null;
|
||||
const binding = await ensureLlmBinding({ plugin, keyId, model, enabled });
|
||||
if (!binding.ok) return binding;
|
||||
|
||||
const existing = await getRow(pluginId);
|
||||
const now = Date.now();
|
||||
const purposeFallback = rowImageMakePurposes(existing);
|
||||
const purposes = pluginId === 'asset-generate'
|
||||
? normalizeImageMakePurposes(payload.purposes, purposeFallback)
|
||||
: null;
|
||||
const configJson = purposes ? JSON.stringify({ purposes }) : null;
|
||||
if (existing) {
|
||||
await pool.query(
|
||||
`UPDATE h5_asset_plugin_configs
|
||||
SET enabled = ?, provider = ?, llm_provider_key_id = ?, llm_model = ?, updated_by = ?, updated_at = ?
|
||||
SET enabled = ?, provider = ?, llm_provider_key_id = ?, llm_model = ?, config_json = ?, updated_by = ?, updated_at = ?
|
||||
WHERE plugin_id = ?`,
|
||||
[enabled ? 1 : 0, provider, keyId, model, updatedBy, now, pluginId],
|
||||
[enabled ? 1 : 0, provider, keyId, model, configJson, updatedBy, now, pluginId],
|
||||
);
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO h5_asset_plugin_configs
|
||||
(id, plugin_id, enabled, provider, llm_provider_key_id, llm_model, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[crypto.randomUUID(), pluginId, enabled ? 1 : 0, provider, keyId, model, updatedBy, now, now],
|
||||
(id, plugin_id, enabled, provider, llm_provider_key_id, llm_model, config_json, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[crypto.randomUUID(), pluginId, enabled ? 1 : 0, provider, keyId, model, configJson, updatedBy, now, now],
|
||||
);
|
||||
}
|
||||
return { ok: true, config: await this.getConfig() };
|
||||
@@ -209,5 +267,26 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
|
||||
}
|
||||
return { ok: true, plugin: rowToPluginConfig(row) };
|
||||
},
|
||||
|
||||
async resolveImageGenerationPurpose(rawPurpose) {
|
||||
const purpose = String(rawPurpose ?? '').trim().toLowerCase();
|
||||
if (!imageMakePurposeIds.has(purpose)) {
|
||||
return { ok: false, code: 'unsupported_purpose', message: '不支持的图片生成用途' };
|
||||
}
|
||||
const resolved = await this.resolvePlugin('asset-generate');
|
||||
if (!resolved.ok) return resolved;
|
||||
if (resolved.plugin.provider !== 'image_make') {
|
||||
return { ok: false, code: 'provider_not_image_make', message: 'image_make Provider 未启用' };
|
||||
}
|
||||
if (!resolved.plugin.purposes?.[purpose]) {
|
||||
return { ok: false, code: 'purpose_disabled', message: '该图片用途未启用,可安全降级到原有流程' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
purpose,
|
||||
presetId: IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose)?.presetId,
|
||||
plugin: resolved.plugin,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+32
-4
@@ -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: '该图片用途未启用,可安全降级到原有流程',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 图片,不要求回滚数据库字段,也不改变既有页面内容。
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const PURPOSES = new Set(['inline_image', 'hero', 'card_cover', 'feed_cover']);
|
||||
|
||||
export function attachMindSpaceImageGenerationRoutes(router, { getService } = {}) {
|
||||
router.post('/mindspace/v1/images/generate', async (req, res) => {
|
||||
const service = getService?.();
|
||||
if (!service) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
fallback: true,
|
||||
code: 'runtime_unavailable',
|
||||
message: '图片生成服务未配置',
|
||||
});
|
||||
}
|
||||
const purpose = String(req.body?.purpose ?? '').trim().toLowerCase();
|
||||
const prompt = String(req.body?.prompt ?? '').trim();
|
||||
if (!PURPOSES.has(purpose)) {
|
||||
return res.status(400).json({ message: '不支持的图片生成用途' });
|
||||
}
|
||||
if (!prompt || prompt.length > 1600) {
|
||||
return res.status(400).json({ message: '图片描述不能为空且不能超过 1600 字符' });
|
||||
}
|
||||
const headerIdempotencyKey = String(req.get('idempotency-key') ?? '').trim();
|
||||
const bodyIdempotencyKey = String(req.body?.idempotency_key ?? '').trim();
|
||||
const idempotencyKey = headerIdempotencyKey || bodyIdempotencyKey || undefined;
|
||||
if (idempotencyKey && idempotencyKey.length > 128) {
|
||||
return res.status(400).json({ message: '幂等键不能超过 128 字符' });
|
||||
}
|
||||
const result = await service.generate({
|
||||
userId: req.currentUser.id,
|
||||
purpose,
|
||||
prompt,
|
||||
negativePrompt: req.body?.negative_prompt,
|
||||
consumerRef: `mindspace:${req.requestId ?? 'request'}:${purpose}`,
|
||||
idempotencyKey,
|
||||
});
|
||||
if (!result.ok) {
|
||||
const unavailable = new Set([
|
||||
'runtime_unavailable',
|
||||
'image_make_unavailable',
|
||||
'IMAGE_MAKE_HTTP_ERROR',
|
||||
'IMAGE_MAKE_TIMEOUT',
|
||||
]).has(result.code);
|
||||
return res.status(unavailable ? 503 : 409).json(result);
|
||||
}
|
||||
return res.status(201).json({ data: result });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import express from 'express';
|
||||
import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs';
|
||||
|
||||
async function start(service) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.currentUser = { id: 'user-1' };
|
||||
req.requestId = 'req-1';
|
||||
next();
|
||||
});
|
||||
attachMindSpaceImageGenerationRoutes(app, { getService: () => service });
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}`,
|
||||
async close() { server.close(); await once(server, 'close'); },
|
||||
};
|
||||
}
|
||||
|
||||
test('image generation route passes authenticated user and idempotency key to the service', async () => {
|
||||
const calls = [];
|
||||
const fixture = await start({
|
||||
async generate(input) {
|
||||
calls.push(input);
|
||||
return { ok: true, asset: { id: 'asset-1', publicUrl: '/image.webp' } };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${fixture.url}/mindspace/v1/images/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'idempotency-key': 'imgreq-1' },
|
||||
body: JSON.stringify({ purpose: 'hero', prompt: 'calm library' }),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal((await response.json()).data.asset.id, 'asset-1');
|
||||
assert.deepEqual(calls, [{
|
||||
userId: 'user-1',
|
||||
purpose: 'hero',
|
||||
prompt: 'calm library',
|
||||
negativePrompt: undefined,
|
||||
consumerRef: 'mindspace:req-1:hero',
|
||||
idempotencyKey: 'imgreq-1',
|
||||
}]);
|
||||
} finally { await fixture.close(); }
|
||||
});
|
||||
|
||||
test('image generation route exposes safe fallback without changing existing flows', async () => {
|
||||
const fixture = await start({
|
||||
async generate() {
|
||||
return { ok: false, fallback: true, code: 'purpose_disabled', message: 'disabled' };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${fixture.url}/mindspace/v1/images/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ purpose: 'feed_cover', prompt: 'calm library' }),
|
||||
});
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: false, fallback: true, code: 'purpose_disabled', message: 'disabled',
|
||||
});
|
||||
} finally { await fixture.close(); }
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createMindSpaceImageGenerationService } from './mindspace-image-generation.mjs';
|
||||
|
||||
test('MindSpace image generation stays inert while the admin purpose is disabled', async () => {
|
||||
const service = createMindSpaceImageGenerationService({
|
||||
configService: {
|
||||
async resolveImageGenerationPurpose() {
|
||||
return { ok: false, code: 'purpose_disabled', message: 'disabled' };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'x' }), {
|
||||
ok: false,
|
||||
code: 'purpose_disabled',
|
||||
message: 'disabled',
|
||||
fallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('MindSpace image generation stores the result before acknowledging temporary output', async () => {
|
||||
const order = [];
|
||||
const service = createMindSpaceImageGenerationService({
|
||||
configService: {
|
||||
async resolveImageGenerationPurpose() {
|
||||
return { ok: true, presetId: 'memind_dark_hero' };
|
||||
},
|
||||
},
|
||||
imageMakeClient: {
|
||||
async generateImage(input) {
|
||||
order.push(['generate', input.presetId]);
|
||||
return {
|
||||
jobId: 'job-1', buffer: Buffer.from('image'), mimeType: 'image/webp',
|
||||
sha256: 'abc', width: 512, height: 512,
|
||||
};
|
||||
},
|
||||
async acknowledge(jobId, assetId) { order.push(['ack', jobId, assetId]); },
|
||||
},
|
||||
assetService: {
|
||||
async createChatAsset(userId, input) {
|
||||
order.push(['store', userId, input.sourceType, input.categoryCode]);
|
||||
return { id: 'asset-1', publicUrl: 'https://example.test/image.webp' };
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'x' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.asset.id, 'asset-1');
|
||||
assert.deepEqual(order, [
|
||||
['generate', 'memind_dark_hero'],
|
||||
['store', 'user-1', 'image_make', 'public'],
|
||||
['ack', 'job-1', 'asset-1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('MindSpace image generation converts provider failures into an explicit fallback result', async () => {
|
||||
const service = createMindSpaceImageGenerationService({
|
||||
configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'x' }; } },
|
||||
assetService: { async createChatAsset() { throw new Error('must not run'); } },
|
||||
imageMakeClient: { async generateImage() { throw Object.assign(new Error('offline'), { code: 'IMAGE_MAKE_HTTP_ERROR' }); } },
|
||||
logger: { warn() {} },
|
||||
});
|
||||
const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'x' });
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
fallback: true,
|
||||
code: 'IMAGE_MAKE_HTTP_ERROR',
|
||||
message: '图片生成暂不可用,已保留原有流程',
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -638,6 +638,7 @@ CREATE TABLE IF NOT EXISTS h5_asset_plugin_configs (
|
||||
provider VARCHAR(64) NULL,
|
||||
llm_provider_key_id CHAR(36) NULL,
|
||||
llm_model VARCHAR(128) NULL,
|
||||
config_json JSON NULL,
|
||||
updated_by CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
|
||||
+22
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user