298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
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' };
|
|
|
|
// 大模型统一在「统一模型中心」配置,资产插件不再单独绑定 LLM。
|
|
const keyId = null;
|
|
const model = null;
|
|
|
|
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,
|
|
};
|
|
},
|
|
};
|
|
}
|