/* ===================================================================== * ai.ts — AI 内容创作引擎 * - OpenAI 兼容 /chat/completions * - Anthropic 兼容 /v1/messages * - 流式 SSE 解析、AbortController 中断 * - 三类调用:generate / polish / chat * 由 ai.js 迁移:逻辑几乎不变,仅改为 TS + ESM * ===================================================================== */ import type { AiOp, ChartItem, Deck, Slide, SlideElement, ElementStyle, RichLine } from './types' import { elementTypes, uid } from './sample' import { store } from './store' import { normSegments } from './richtext' const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记 const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const /* 智谱/通用错误码 → 可操作提示 */ const ERROR_HINTS: Record = { '1113': '该 Key 账户按量计费余额不足或无资源包。智谱 Coding Plan 套餐额度仅限官方工具(Claude Code 等)使用,网页应用需走按量计费——请到 bigmodel.cn 充值后重试。', '1309': 'GLM Coding Plan 套餐已到期,需续订后使用。', '1211': '模型名错误,请在设置里检查模型名(如 glm-4.6)。', '1234': '模型名错误,请检查模型名拼写。', '1123': 'API Key 无效或已过期,请检查设置中的 Key。', '1303': '该 Key 无对应模型权限,请更换模型或 Key。' } const ERROR_HINTS_BY_STATUS: Record = { 401: '鉴权失败:API Key 无效或格式错误,请检查设置。', 403: '无权限或来源被拒:Key 无该模型权限,或调用来源受限。', 404: '接口路径错误:请检查 Base URL 与所选协议是否匹配。', 429: '限流或额度不足,请稍后重试或检查账户额度。' } /* ============================================================ * Prompt 模板 * ============================================================ */ const SYS_BASE = '你是「u-ppt」的内容创作助手,擅长把主题变成结构清晰、视觉现代、带入场动效的中文演示稿。\n' + '输出必须严格遵循下面的数据模型,坐标用百分比(0-100),字号为数字。\n\n' + '幻灯片模型:\n' + '{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' + '背景:bg/panel=浅底;primary/accent=纯色深底;g-primary=主→强调渐变(深);g-deep=深色渐变;g-soft=浅色渐变。\n\n' + '元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' + '元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' + ' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' + ' - stat:content 为大数字(如 "65%"),style.label 为说明\n' + ' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' + ' - shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' + ' - chart:content 为 JSON,两种格式:\n' + ' 单系列:[{"label":"","value":数字}, ...]\n' + ' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' + ' style.max=最大值,style.chartType=bar|line|area|pie|doughnut|radar|hbar|progress(默认 bar)\n' + ' style.legend=true 显示图例,style.grid=true 显示网格线,style.stack=true 堆叠(多系列柱状)\n' + ' 图表类型选择建议:柱状(bar)对比离散类型;折线(line)显示趋势;面积(area)累计趋势;\n' + ' 饼图(pie)占比构成;环形(doughnut)轻量占比;雷达(radar)多维度对比;条形(hbar)长标签对比;进度(progress)完成率。\n' + ' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' + ' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' + ' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' + ' - image:content 留空\n\n' + '可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' + ' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' + ' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' + ' 仅在需要行内混合样式时用 segments;纯文本继续用 content 即可。segments 与 content 二选一。\n\n' + 'style 公共字段:fontSize 数字、color 颜色键、align left|center|right、bold 布尔、italic 布尔、label/labelColor/labelSize、radius\n' + '颜色 color/fill/accent:一律用主题键 primary|accent|text|muted(系统按背景深浅自动反相,深底文字自动变白,不要手填 #ffffff)。\n\n' + '入场动画 style.anim(重要,给主要元素都加上,演示时逐个入场):\n' + ' 可选:fade-up(默认上浮) | fade | scale | zoom | slide-l | slide-r | pop(弹现) | rotate(旋转) | bounce(弹跳) | flip(翻转) | blur(聚焦)\n' + ' 建议:标题/金句用 pop 或 scale;正文/列表用 fade-up;卡片用 fade-up 或 slide-l/slide-r;装饰形状用 scale。\n\n' + '版式规范:\n' + '- 画布逻辑 1280×720。封面标题 y≈30 h≈18;内容标题 y≈8-10。\n' + '- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' + '- 一页一个观点,留白充足,列表不超过 5 条。\n' + '- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' + '- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' + '内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' + '- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' + '- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。\n' + '- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」「让 X 更简单」「不止是 X,更是 Y」。\n' + '- 列表每条是可执行/可验证的要点,不是三词排比(避免「清晰、简洁、高效」式口号)。\n' + '- 有判断、有观点,像从业者写的;数据尽量给来源(人名/书名/研究)。\n' + '- 不要使用 markdown 代码块,直接输出 JSON。' const SYS_GENERATE = SYS_BASE + '\n任务:根据用户主题生成一套完整演示(封面、目录、3-5 页内容、结尾,共 6-8 页)。\n' + '要求:每个主要元素都加 style.anim;封面与结尾用 g-primary;至少 2 张 card;数据页用 stat+chart。\n' + '严格输出:{"action":"create_all","slides":[...]}' const SYS_POLISH = SYS_BASE + '\n任务:按指令优化给定的“单页”,保持主题一致、布局合理,并为各元素补充 style.anim。\n' + '严格输出:{"action":"update_page","slides":[ 优化后的单页 ],"note":"一句话说明改动"}' const SYS_CHAT = SYS_BASE + '\n任务:与用户对话式编辑当前演示。先用中文流式回复用户(解释你将做什么),' + '如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' + 'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' + '- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' + '没有改动时不要输出分隔标记,仅回复即可。' /* ============================================================ * 上下文摘要 * ============================================================ */ function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string { const deck: Deck = store.getDeck() const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。'] deck.slides.forEach((s, i) => { const types = s.elements.map(e => e.type).join('/') const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : '' lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : '')) }) lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(store.currentSlide.value)) // 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它 if (selectedEl) { const typeLabel = selectedEl.type const preview = (selectedEl.content || '').slice(0, 100) lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)') lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"') lines.push('完整JSON: ' + JSON.stringify(selectedEl)) lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。') } return lines.join('\n') } /* ============================================================ * 底层:流式请求 * ============================================================ */ interface StreamOpts { jsonMode?: boolean onVisible?: (text: string) => void signal?: AbortSignal } interface Message { role: 'system' | 'user' | 'assistant'; content: string } async function streamChat(messages: Message[], opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> { const cfg = store.getCfg() const isLocal = /localhost|127\.0\.0\.1/i.test(cfg.base || '') if (!cfg.key && !isLocal) throw new Error('未配置 API Key,请点击右上角 ⚙ 填写。') if (cfg.protocol === 'anthropic') return runAnthropic(messages, opts, cfg) return runOpenAI(messages, opts, cfg) } function apiUrl(cfg: { proxy: string; base: string }): string { return (cfg.proxy || cfg.base || '').replace(/\/+$/, '') } async function postJSON(url: string, headers: Record, body: unknown, signal?: AbortSignal): Promise { try { return await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal }) } catch (e: any) { if (e.name === 'AbortError') throw e throw new Error('请求失败(可能是 CORS 跨域拦截)。可在设置中配置"代理 URL"。\n' + e.message) } } // OpenAI 兼容 async function runOpenAI(messages: Message[], opts: StreamOpts, cfg: ReturnType) { const url = apiUrl(cfg) + '/chat/completions' const body: Record = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 } if (opts.jsonMode) body.response_format = { type: 'json_object' } const resp = await postJSON(url, { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }, body, opts.signal) return consumeStream(resp, (obj: any) => { const ch = obj.choices && obj.choices[0] return ch && ch.delta && ch.delta.content }, opts) } // Anthropic 兼容 async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnType) { const url = apiUrl(cfg) + '/v1/messages' const sysParts: string[] = [] const msgs: Message[] = [] messages.forEach(m => { if (m.role === 'system') { if (m.content) sysParts.push(m.content) } else msgs.push({ role: m.role, content: m.content }) }) const body: Record = { model: cfg.model || 'glm-4.6', messages: msgs, max_tokens: opts.jsonMode ? 8192 : 4096, stream: true, temperature: 0.75 } if (sysParts.length) body.system = sysParts.join('\n\n') const headers = { 'Content-Type': 'application/json', 'x-api-key': cfg.key, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' } const resp = await postJSON(url, headers, body, opts.signal) return consumeStream(resp, (obj: any) => { if (obj.type === 'content_block_delta' && obj.delta) return obj.delta.text || '' return null }, opts) } // 通用 SSE 消费 async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> { if (!resp.ok) { let t = ''; try { t = await resp.text() } catch (e) {} let msg = '接口返回 ' + resp.status let hint = ERROR_HINTS_BY_STATUS[resp.status] try { const err = JSON.parse(t).error if (err) { msg += ' [' + err.code + '] ' + (err.message || '') if (err.code && ERROR_HINTS[err.code]) hint = ERROR_HINTS[err.code] } else { msg += ' ' + (t || '').slice(0, 200) } } catch (e) { msg += ' ' + (t || '').slice(0, 200) } if (hint) msg += '\n\n💡 ' + hint throw new Error(msg) } const reader = resp.body!.getReader() const dec = new TextDecoder('utf-8') let sseBuf = '' let full = '' let pending = '' let sepMode = false function feed(delta: string | null) { if (delta == null) return full += delta if (opts.jsonMode || sepMode) return pending += delta const i = pending.indexOf(SEP) if (i >= 0) { emit(pending.slice(0, i)) sepMode = true pending = '' return } const keep = SEP.length - 1 if (pending.length > keep) { const safe = pending.slice(0, pending.length - keep) emit(safe) pending = pending.slice(safe.length) } } function emit(text: string) { if (opts.onVisible) opts.onVisible(text) } for (;;) { const chunk = await reader.read() if (chunk.done) break sseBuf += dec.decode(chunk.value, { stream: true }) const lines = sseBuf.split('\n') sseBuf = lines.pop()! for (let k = 0; k < lines.length; k++) { const line = lines[k].trim() if (!line || line.indexOf('data:') !== 0) continue const payload = line.slice(5).trim() if (!payload || payload === '[DONE]') continue let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue } const delta = extractDelta(obj) if (delta != null) feed(delta) } } // 末帧残留 data: 行补解析 const tail = sseBuf.trim() if (tail.indexOf('data:') === 0) { const tp = tail.slice(5).trim() if (tp && tp !== '[DONE]') { let to: any; try { to = JSON.parse(tp) } catch (e) { to = null } if (to) { const td = extractDelta(to); if (td != null) feed(td) } } } if (!opts.jsonMode && !sepMode && pending) emit(pending) if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null } const parts = full.split(SEP) return { json: null, reply: (parts[0] || '').trim(), op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null } } function tryParse(s: string): any { if (!s) return null s = String(s).replace(/```json/gi, '').replace(/```/g, '').trim() const i = s.indexOf('{'), j = s.lastIndexOf('}') if (i < 0 || j < 0) return null const candidate = s.slice(i, j + 1) try { return JSON.parse(candidate) } catch (e) { try { return JSON.parse(candidate.replace(/,(\s*[}\]])/g, '$1')) } catch (e2) { return null } } } /* ============================================================ * 数据规范化(AI 输出 → 可入库) * ============================================================ */ function validColor(v: string): string | undefined { if (!v) return v if (v.charAt(0) === '#') return /^#[0-9a-f]{3,8}$/i.test(v) ? v : undefined return ['primary', 'accent', 'text', 'muted'].indexOf(v) >= 0 ? v : undefined } function normStyle(st: any): ElementStyle { st = st || {} const out: any = { ...st } if (out.fontSize != null) out.fontSize = Number(out.fontSize) || 24 if (out.bold != null) out.bold = !!out.bold if (out.italic != null) out.italic = !!out.italic if (out.labelSize != null) out.labelSize = Number(out.labelSize) || 16 if (out.radius != null) out.radius = Number(out.radius) || 0 if (out.max != null) out.max = Number(out.max) || 0 // 图表子类型校验 const VALID_CHART_TYPES = ['bar', 'line', 'pie', 'area', 'doughnut', 'radar', 'hbar', 'progress'] if (out.chartType != null && !VALID_CHART_TYPES.includes(out.chartType)) delete out.chartType if (out.legend != null) out.legend = !!out.legend if (out.stack != null) out.stack = !!out.stack if (out.grid != null) out.grid = !!out.grid if (out.header != null) out.header = !!out.header if (out.inline != null) out.inline = !!out.inline if (typeof out.lang === 'string' && out.lang.length > 16) out.lang = out.lang.slice(0, 16) ;['color', 'fill', 'accent', 'labelColor'].forEach(k => { if (validColor(out[k]) === undefined && out[k] != null) delete out[k] }) if (typeof out.icon === 'string' && out.icon.length > 8) out.icon = out.icon.slice(0, 8) return out } function normElement(e: any): SlideElement | null { if (!e || typeof e !== 'object') return null const type = (VALID_TYPES as readonly string[]).indexOf(e.type) >= 0 ? e.type : 'text' const out: SlideElement = { id: e.id || uid('el'), type: type as SlideElement['type'], x: clampNum(e.x, 0, 100, 10), y: clampNum(e.y, 0, 100, 10), w: clampNum(e.w, 3, 100, 60), h: clampNum(e.h, 3, 100, 15), content: (e.content == null ? '' : String(e.content)), style: normStyle(e.style || {}) } // 可选 segments(结构化富文本) if (e.segments) { const segs = normSegments(e.segments) if (segs) (out as any).segments = segs } return out } const VALID_BGS = ['bg', 'panel', 'primary', 'accent', 'g-primary', 'g-deep', 'g-soft'] function normSlide(s: any): Slide | null { if (!s || typeof s !== 'object') return null const bg = VALID_BGS.indexOf(s.background) >= 0 ? s.background : (typeof s.background === 'string' && s.background.charAt(0) === '#' ? s.background : 'bg') const els = (Array.isArray(s.elements) ? s.elements : []).map(normElement).filter(Boolean) as SlideElement[] return { id: uid('s'), background: bg as Slide['background'], elements: els } } function normSlides(arr: any[]): Slide[] { return (Array.isArray(arr) ? arr : []).map(normSlide).filter((s): s is Slide => s !== null) } function clampNum(v: number, lo: number, hi: number, dflt: number): number { const n = Number(v) if (isNaN(n)) return dflt return Math.max(lo, Math.min(hi, n)) } /* ============================================================ * 高层 API * ============================================================ */ /** 生成整套 */ export async function generate(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise<{ action: 'create_all'; slides: Slide[] }> { const count = opts.count || 7 const messages: Message[] = [ { role: 'system', content: SYS_GENERATE }, { role: 'user', content: '主题:' + opts.topic + '\n请生成约 ' + count + ' 页(含封面与结尾),中文内容。' } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。') const slides = normSlides(r.json.slides || r.json) if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或换一个主题。') return { action: 'create_all', slides } } /** 文档内容 → AI 分析后生成幻灯片 */ export async function generateFromDocument(opts: { text: string filename?: string signal?: AbortSignal }): Promise<{ action: 'create_all'; slides: Slide[] }> { const SYS_DOC = SYS_BASE + '\n任务:分析下方提供的文档内容,将其提炼为 5-10 页专业演示稿(含封面 1 页 + 内容 3-7 页 + 结尾 1 页)。\n' + '要求:\n' + '- 提取文档中的核心观点、关键数据、重要结论,不要遗漏重要信息\n' + '- 每页只有一个主题,标题具体、有信息量,如「Q3 营收增长 23%」而非「业绩回顾」\n' + '- 正文有实质内容,包含具体数字、案例、来源;避免空泛口号\n' + '- 合理运用 card(对比/分组)、stat(突出数据)、chart(趋势/分布)等元素\n' + '- 每个主要元素都加 style.anim 入场动画\n' + '- 封面用 g-primary 渐变,结尾可用 g-primary 或 g-deep,内容页用 bg/panel 浅底\n' + '严格输出 JSON:{"action":"create_all","slides":[...]}' const messages: Message[] = [ { role: 'system', content: SYS_DOC }, { role: 'user', content: '文档' + (opts.filename ? '(' + opts.filename + ')' : '') + '内容如下:\n\n' + opts.text } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。') const slides = normSlides(r.json.slides || r.json) if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或检查文档内容。') return { action: 'create_all', slides } } /** 润色单页 */ export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> { const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼' const messages: Message[] = [ { role: 'system', content: SYS_POLISH }, { role: 'user', content: '指令:' + instruction + '\n当前页JSON:\n' + JSON.stringify(opts.slide) } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 输出无法解析,请重试。') const arr = r.json.slides || (r.json.slide ? [r.json.slide] : (r.json.elements ? [r.json] : [])) const slides = normSlides(arr) if (!slides.length) throw new Error('AI 未返回有效页面。') return { action: 'update_page', slide: slides[0], note: r.json.note || '' } } /** 对话式编辑 */ export async function chat(opts: { history: Message[] input: string onVisible?: (text: string) => void signal?: AbortSignal /** 当前选中的元素(可选,注入上下文让对话围绕它) */ selectedElement?: SlideElement | null }): Promise<{ reply: string; op: AiOp | null }> { const messages: Message[] = [{ role: 'system', content: SYS_CHAT + '\n\n' + deckContext(store.getCurrentIndex(), opts.selectedElement || null) }] ;(opts.history || []).forEach(m => messages.push(m)) messages.push({ role: 'user', content: opts.input }) const r = await streamChat(messages, { jsonMode: false, onVisible: opts.onVisible, signal: opts.signal }) return { reply: r.reply, op: normalizeOp(r.op) } } function normalizeOp(json: any): AiOp | null { if (!json) return null let action: AiOp['action'] = json.action || 'answer' const slides = normSlides(json.slides) const target = json.target != null ? (Number(json.target) - 1) : null if (action === 'update_page' && slides.length) action = 'update_page' if (action === 'add_page' && slides.length) action = 'add_page' if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer' return { action, slides, target: isNaN(target as number) ? null : target, note: json.note || '' } } export function isConfigured(): boolean { return !!(store.getCfg().key) } /* ============================================================ * AI 深化能力:大纲 / 逐页生成 / 配图 / 主题建议 / 一键美化 * ============================================================ */ import type { Outline, OutlineItem, ThemeSuggestion, ImageGenResult } from './types' /* ---------- Prompt 模板 ---------- */ const SYS_OUTLINE = '你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' + '大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\n' + '然后拆成 5-8 页,每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' + 'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' + '封面与结尾各 1 页,目录可选。\n' + 'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' + '内容准则:\n' + '- 标题写具体事实而非口号(「华东 Q3 增长 23%」而非「业绩腾飞」)。\n' + '- 要点每条是可验证的论点或具体数字,不要三词排比。\n' + '- 有判断、有观点,像该领域从业者写的;数据尽量给来源。\n' + '- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」。\n\n' + '严格输出 JSON:\n' + '{ "title": "整份标题", "items": [ { "kind":"cover", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选" } ] }\n' + '不要使用 markdown 代码块,直接输出 JSON。' const SYS_GEN_PAGE = SYS_BASE + '\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' + '严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' + 'kind=cover 用 g-primary 背景;kind=quote 用 g-deep;kind=end 用 g-primary;kind=content 数据页用 stat+chart。' const SYS_THEME = '你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' + '要求:\n' + '- 主色饱和度适中,不刺眼;强调色与主色形成对比但不冲突。\n' + '- bg 与 panel 接近白色,差异轻微。\n' + '- text 深于 primary,muted 为中性灰。\n' + '- 所有颜色用 #hex 格式。\n\n' + '可选背景推荐:bg/panel(浅底)/primary/accent(深底)/g-primary(主→强调渐变)/g-deep(深色渐变)。\n' + '内容页优先 bg/panel;封面/结尾优先 g-primary;金句页优先 g-deep。\n\n' + '严格输出 JSON:\n' + '{ "name":"主题名","primary":"#...","accent":"#...","bg":"#...","panel":"#...","text":"#...","muted":"#...","recommendBg":"g-primary","reason":"一句话设计理由" }\n' + '不要使用 markdown 代码块。' const SYS_BEAUTIFY = SYS_BASE + '\n任务:优化给定页面的「排版」(位置、尺寸、字号、对齐、装饰元素),保持文字内容不变。\n' + '如果布局合理则微调;如果有明显问题(元素重叠、字号失衡、留白不足)则重排。\n' + '严格输出:{ "background":"原背景或更合适的键", "elements":[ ... ], "note":"一句话说明" }\n' + '不要改动文字内容本身,只优化视觉。' /* ---------- 1. 大纲生成 ---------- */ export async function outline(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise { const count = opts.count || 7 const messages: Message[] = [ { role: 'system', content: SYS_OUTLINE }, { role: 'user', content: '主题:' + opts.topic + '\n请拟定约 ' + count + ' 页的大纲(含封面与结尾),中文。' } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 未返回有效大纲,请重试。') const items: OutlineItem[] = (Array.isArray(r.json.items) ? r.json.items : []).map((it: any, i: number) => ({ id: 'ol-' + Date.now() + '-' + i, kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content', title: String(it.title || '未命名').slice(0, 80), points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [], hint: it.hint ? String(it.hint).slice(0, 120) : undefined, done: false })) if (!items.length) throw new Error('大纲为空,请重试或换一个主题。') return { title: String(r.json.title || opts.topic).slice(0, 80), topic: opts.topic, items } } /* ---------- 2. 按大纲条目生成单页 ---------- */ export async function generatePage(opts: { item: OutlineItem; index: number; total: number; signal?: AbortSignal }): Promise { const user = '大纲第 ' + (opts.index + 1) + '/' + opts.total + ' 页:\n' + '类型:' + opts.item.kind + '\n' + '标题:' + opts.item.title + '\n' + '要点:' + opts.item.points.map((p, i) => (i + 1) + '. ' + p).join('\n') + (opts.item.hint ? '\n补充指令:' + opts.item.hint : '') const messages: Message[] = [ { role: 'system', content: SYS_GEN_PAGE }, { role: 'user', content: user } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 未返回有效页面。') const slides = normSlides([r.json]) if (!slides.length) throw new Error('AI 输出无法解析为页面。') return slides[0] } /* ---------- 3. AI 配图 ---------- */ /** 图像生成——当前支持 DALL-E 协议(OpenAI /images/generations)与通用返回 URL 的网关 */ export async function generateImage(opts: { prompt: string; signal?: AbortSignal }): Promise { const cfg = store.getCfg() const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '') const imgKey = cfg.imgKey || cfg.key const imgModel = cfg.imgModel || 'dall-e-3' if (!imgKey) throw new Error('未配置 API Key,无法生成图片。') const url = imgBase + '/images/generations' const body = { model: imgModel, prompt: opts.prompt, n: 1, size: '1024x1024', response_format: 'b64_json' } let resp: Response try { resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + imgKey }, body: JSON.stringify(body), signal: opts.signal }) } catch (e: any) { if (e.name === 'AbortError') throw e throw new Error('图片生成请求失败(可能是 CORS)。\n' + e.message) } if (!resp.ok) { let t = ''; try { t = await resp.text() } catch (e) {} throw new Error('图片生成失败 ' + resp.status + ':' + t.slice(0, 200)) } const json = await resp.json() const item = json.data && json.data[0] if (!item) throw new Error('图片生成返回为空。') const dataUrl = item.b64_json ? 'data:image/png;base64,' + item.b64_json : (item.url || '') if (!dataUrl) throw new Error('图片生成返回无图像数据。') return { url: dataUrl, revisedPrompt: item.revised_prompt } } /** 判断是否配置了图像生成能力(当前以主 Key 是否存在为信号) */ export function isImageConfigured(): boolean { const cfg = store.getCfg() return !!(cfg.imgKey || cfg.key) } /* ---------- 4. AI 主题/配色建议 ---------- */ export async function suggestTheme(opts: { topic: string; signal?: AbortSignal }): Promise { const messages: Message[] = [ { role: 'system', content: SYS_THEME }, { role: 'user', content: '主题关键词:' + opts.topic + '\n请推荐一套配色,并说明设计理由(一句话)。' } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 未返回有效配色方案。') const j = r.json // 基本校验,非法 hex 回退中性灰 const safeHex = (v: string, dflt: string) => /^#[0-9a-f]{6}$/i.test(v) ? v : dflt return { name: String(j.name || '自定义').slice(0, 20), primary: safeHex(j.primary, '#4f46e5'), accent: safeHex(j.accent, '#06b6d4'), bg: safeHex(j.bg, '#ffffff'), panel: safeHex(j.panel, '#f8fafc'), text: safeHex(j.text, '#1e293b'), muted: safeHex(j.muted, '#64748b'), recommendBg: j.recommendBg || 'g-primary', reason: j.reason ? String(j.reason).slice(0, 200) : undefined } } /* ---------- 5. 一键美化单页(排版优化,内容不变) ---------- */ export async function beautifyPage(opts: { slide: Slide; signal?: AbortSignal }): Promise<{ slide: Slide; note: string }> { const messages: Message[] = [ { role: 'system', content: SYS_BEAUTIFY }, { role: 'user', content: '当前页 JSON:\n' + JSON.stringify(opts.slide) } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 未返回有效页面。') const slides = normSlides([r.json]) if (!slides.length) throw new Error('AI 输出无法解析。') return { slide: slides[0], note: r.json.note || '' } } /** 导出 ChartItem 类型供组件用 */ export type { ChartItem, Outline, OutlineItem, ThemeSuggestion, ImageGenResult }