Files
memind/mindspace-cover-ai.mjs
John 6ee6fd64dd Add MindSpace page live edit, chat skills, and H5 deploy tooling.
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>
2026-06-15 22:09:38 -07:00

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;
}