import crypto from 'node:crypto'; export const ASSET_PLUGIN_CATALOG = [ { id: 'asset-search', label: '素材检索', description: '从已授权图库检索素材;不改变现有页面默认生成流程。', providers: ['pexels', 'unsplash'], supportsLlm: true, }, { id: 'asset-generate', label: '图片生成', description: '异步生成页面所需图片;关闭或失败时必须降级为无图页面。', providers: ['image_make', 'flux-schnell', 'comfyui'], supportsLlm: true, }, { id: 'asset-transform', label: '图片处理', description: '压缩、裁剪和格式转换等确定性处理。', providers: ['sharp'], supportsLlm: false, }, { id: 'asset-analyze', label: '图片理解', description: '对图片进行描述、结构分析或辅助可访问性标注。', providers: ['qwen25-vl'], supportsLlm: true, }, ]; export const IMAGE_MAKE_PURPOSE_CATALOG = [ { 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)); 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) { const id = String(value ?? '').trim().toLowerCase(); return pluginById.has(id) ? id : null; } function normalizeProvider(plugin, value) { const provider = String(value ?? '').trim().toLowerCase(); return plugin?.providers.includes(provider) ? provider : null; } function toBoolean(value, defaultValue = false) { if (value === undefined || value === null) return defaultValue; 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); const config = { pluginId: row.plugin_id, label: plugin?.label ?? row.plugin_id, description: plugin?.description ?? '', providers: plugin?.providers ?? [], supportsLlm: Boolean(plugin?.supportsLlm), enabled: Boolean(row.enabled), provider: row.provider, llmProviderKeyId: row.llm_provider_key_id ?? null, llmProviderName: providerKey?.name ?? null, llmProviderId: providerKey?.providerId ?? 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; } /** * Configuration-only control plane for optional asset plugins. * * This service deliberately does not invoke a model, external API, queue, or * storage. Callers must opt in to it, which keeps the current chat and page * generation path unchanged until a later adapter explicitly uses a plugin. */ export function createAssetGatewayConfigService(pool, { llmProviderService = null } = {}) { async function getGlobalRow() { const [rows] = await pool.query( 'SELECT * FROM h5_asset_gateway_config WHERE config_key = ? LIMIT 1', ['default'], ); return rows[0] ?? null; } async function listRows() { const [rows] = await pool.query('SELECT * FROM h5_asset_plugin_configs ORDER BY plugin_id'); return rows; } async function getRow(pluginId) { const [rows] = await pool.query( 'SELECT * FROM h5_asset_plugin_configs WHERE plugin_id = ? LIMIT 1', [pluginId], ); return rows[0] ?? null; } async function listKeyMap() { if (!llmProviderService?.listKeys) return new Map(); const keys = await llmProviderService.listKeys(); return new Map(keys.map((key) => [key.id, key])); } async function ensureLlmBinding({ plugin, keyId, model, enabled }) { if (!plugin.supportsLlm && (keyId || model)) { return { ok: false, message: `${plugin.label}不支持 LLM 绑定` }; } if (!enabled || (!keyId && !model)) return { ok: true }; if (!keyId || !model) return { ok: false, message: 'LLM Provider 与模型必须同时选择' }; if (!llmProviderService?.listKeys) return { ok: false, message: 'LLM Provider 服务未启用' }; const key = (await llmProviderService.listKeys()).find((item) => item.id === keyId); if (!key || key.status !== 'active') return { ok: false, message: 'LLM Provider 不存在或已禁用' }; if (!key.models.includes(model)) return { ok: false, message: '模型不在该 Provider 支持列表中' }; return { ok: true }; } return { catalog: ASSET_PLUGIN_CATALOG, async getConfig() { const [globalRow, rows, keyMap] = await Promise.all([getGlobalRow(), listRows(), listKeyMap()]); const byPlugin = new Map(rows.map((row) => [row.plugin_id, row])); return { enabled: Boolean(globalRow?.enabled), updatedAt: Number(globalRow?.updated_at ?? 0) || null, plugins: ASSET_PLUGIN_CATALOG.map((plugin) => { const row = byPlugin.get(plugin.id); return row ? rowToPluginConfig(row, keyMap.get(row.llm_provider_key_id) ?? null) : { pluginId: plugin.id, label: plugin.label, description: plugin.description, providers: plugin.providers, supportsLlm: plugin.supportsLlm, enabled: false, provider: null, llmProviderKeyId: null, llmProviderName: null, llmProviderId: null, llmModel: null, updatedAt: null, ...(plugin.id === 'asset-generate' ? { purposes: { ...disabledImageMakePurposes }, purposeCatalog: IMAGE_MAKE_PURPOSE_CATALOG, } : {}), }; }), }; }, async updateGlobalConfig(payload = {}, { updatedBy = null } = {}) { const enabled = toBoolean(payload.enabled); const now = Date.now(); const existing = await getGlobalRow(); if (existing) { await pool.query( 'UPDATE h5_asset_gateway_config SET enabled = ?, updated_by = ?, updated_at = ? WHERE config_key = ?', [enabled ? 1 : 0, updatedBy, now, 'default'], ); } else { await pool.query( 'INSERT INTO h5_asset_gateway_config (config_key, enabled, updated_by, updated_at) VALUES (?, ?, ?, ?)', ['default', enabled ? 1 : 0, updatedBy, now], ); } return this.getConfig(); }, async updatePluginConfig(rawPluginId, payload = {}, { updatedBy = null } = {}) { const pluginId = normalizePluginId(rawPluginId); if (!pluginId) return { ok: false, message: '不支持的资产插件' }; const plugin = pluginById.get(pluginId); const enabled = toBoolean(payload.enabled); const provider = normalizeProvider(plugin, payload.provider); if (enabled && !provider) return { ok: false, message: '请选择该插件支持的 Provider' }; 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 = ?, config_json = ?, updated_by = ?, updated_at = ? WHERE plugin_id = ?`, [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, 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() }; }, async resolvePlugin(rawPluginId) { const pluginId = normalizePluginId(rawPluginId); if (!pluginId) return { ok: false, code: 'unsupported_plugin', message: '不支持的资产插件' }; const global = await getGlobalRow(); if (!global?.enabled) { return { ok: false, code: 'gateway_disabled', message: '资产能力未启用,可安全降级到原有流程' }; } const row = await getRow(pluginId); if (!row?.enabled) { return { ok: false, code: 'plugin_disabled', message: '资产插件未启用,可安全降级到原有流程' }; } 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: '该图片用途未启用,可安全降级到原有流程' }; } 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: purposeConfig?.presetId, strategy: purposeConfig?.strategy ?? 'generate', sourcePurpose: purposeConfig?.sourcePurpose ?? null, plugin: resolved.plugin, }; }, }; }