import { Agent, fetch as undiciFetch } from 'undici'; import { jsonrepair } from 'jsonrepair'; import { decryptSecret, normalizeApiUrl, resolveChatCompletionsUrl, } from './llm-providers.mjs'; import { normalizeCoverMetaSuggestion, parseMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, }); function resolveEncryptionKey(explicitKey) { const raw = explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; return raw; } function parseJsonObject(text) { const source = String(text ?? '').trim(); if (!source) return null; try { return JSON.parse(source); } catch { try { return JSON.parse(jsonrepair(source)); } catch { return null; } } } function extractJsonObject(text) { const direct = parseJsonObject(text); if (direct && typeof direct === 'object') return direct; const source = String(text ?? '').trim(); const start = source.indexOf('{'); const end = source.lastIndexOf('}'); if (start < 0 || end <= start) { throw Object.assign(new Error('AI 未返回有效 JSON'), { code: 'cover_ai_invalid_output' }); } const raw = source.slice(start, end + 1); try { return JSON.parse(jsonrepair(raw)); } catch (error) { throw Object.assign(new Error('AI 封面 JSON 解析失败'), { code: 'cover_ai_invalid_output', cause: error instanceof Error ? error.message : String(error), }); } } function buildCoverPrompt({ title, summary, html, instruction, currentCover }) { const excerpt = String(html ?? '') .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 1200); return [ '你是 MindSpace 信息流封面设计助手。请根据页面内容生成 mindspace-cover 元数据。', '只返回一个 JSON 对象,不要 Markdown,不要解释。', '字段要求:', '- tag: 2-4 个汉字分类,如 旅行/美食/活动/报告', '- emoji: 1 个与主题高度相关的 emoji', '- accent: 主色 hex,如 #eeb04e', '- accent2: 辅色 hex', '- subtitle: 12-28 字副标题,用于封面底部', '- cover: 可选,若页面已有 hero 图路径可保留,否则留空字符串', '', `页面标题:${title || '未命名页面'}`, `页面摘要:${summary || '无'}`, `当前 cover 元数据:${JSON.stringify(currentCover ?? {})}`, `页面正文摘录:${excerpt || '无'}`, instruction ? `用户补充要求:${instruction}` : '', ] .filter(Boolean) .join('\n'); } export async function suggestCoverMetaWithAi( pool, { title, summary, html, instruction, encryptionKey }, ) { const [rows] = await pool.query( `SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = 'active' LIMIT 1`, ); const row = rows[0]; if (!row) { throw Object.assign(new Error('请先在管理后台配置并启用 LLM'), { code: 'llm_not_configured' }); } let apiKey; try { apiKey = decryptSecret( { ciphertext: row.api_key_ciphertext, iv: row.api_key_iv, tag: row.api_key_tag, }, resolveEncryptionKey(encryptionKey), ); } catch { throw Object.assign(new Error('LLM 密钥解密失败,请重新配置'), { code: 'llm_not_configured' }); } const apiUrl = normalizeApiUrl(row.api_url); const url = resolveChatCompletionsUrl(apiUrl); if (!url) { throw Object.assign(new Error('LLM API 地址无效'), { code: 'llm_not_configured' }); } const prompt = buildCoverPrompt({ title, summary, html, instruction, currentCover: parseMindspaceCoverMeta(html), }); let upstream; try { upstream = await undiciFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: row.default_model, messages: [{ role: 'user', content: prompt }], stream: false, ...(row.relay_provider ? { provider: row.relay_provider } : {}), }), dispatcher: url.startsWith('https://') ? insecureDispatcher : undefined, }); } catch (error) { throw Object.assign( new Error(`AI 封面生成失败:${error instanceof Error ? error.message : '网络错误'}`), { code: 'cover_ai_failed' }, ); } const text = await upstream.text().catch(() => ''); if (!upstream.ok) { throw Object.assign(new Error(`AI 封面生成失败:${text.slice(0, 240) || upstream.status}`), { code: 'cover_ai_failed', }); } let data; try { data = JSON.parse(text); } catch { throw Object.assign(new Error('AI 响应不是 JSON'), { code: 'cover_ai_failed' }); } const reply = data?.choices?.[0]?.message?.content ?? data?.message?.content ?? data?.output ?? text; const parsed = normalizeCoverMetaSuggestion(extractJsonObject(reply)); if (!parsed.tag && !parsed.emoji && !parsed.accent) { throw Object.assign(new Error('AI 未生成有效封面参数'), { code: 'cover_ai_invalid_output' }); } return parsed; }