6ee6fd64dd
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
5.2 KiB
JavaScript
174 lines
5.2 KiB
JavaScript
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(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/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;
|
|
}
|