/* ===================================================================== * 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' import { isTauri, aiProxy, aiProxyStream } from './bridge' const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记 const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', '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 图片 | video 视频 | 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|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubble,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' + ' - video:content 填视频 URL\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() /** 图片/超长 content 脱敏:base64 会撑爆上下文,替换为占位说明 */ const sanitizeEl = (el: SlideElement): SlideElement => { if (el.type === 'image' || el.type === 'video') { const src = el.content || '' const label = el.type === 'video' ? '视频' : '图片' const desc = src.startsWith('data:') ? `[${label} base64 ${Math.round(src.length / 1024)}KB]` : (src ? `[${label}URL: ${src.slice(0, 80)}${src.length > 80 ? '…' : ''}]` : `[空${label}]`) return { ...el, content: desc } } if (el.content && el.content.length > 2000) { return { ...el, content: el.content.slice(0, 2000) + `…[截断,共${el.content.length}字符]` } } return el } 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 ? ' ← 当前页' : '')) }) const cur = store.currentSlide.value const curSanitized = { ...cur, elements: cur.elements.map(sanitizeEl) } lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(curSanitized)) // 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它 if (selectedEl) { const typeLabel = selectedEl.type const preview = (selectedEl.content || '').replace(/\n/g, ' ').slice(0, 100) lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)') lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"') lines.push('完整JSON: ' + JSON.stringify(sanitizeEl(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(/\/+$/, '') } /** * 桌面流式请求:Rust ai_proxy_stream 转发 SSE 原始 chunk, * 事件桥逐 chunk 喂 SSE 解析器(与浏览器路径共用 createSSESink,保留打字机效果与 SEP 协议)。 * 返回 null 表示桌面桥不可用(回退浏览器 fetch 路径)。 */ async function desktopStream( url: string, apiKey: string, body: Record, extractDelta: (obj: any) => string | null, opts: StreamOpts ): Promise<{ json: any; reply: string; op: any } | null> { const sink = createSSESink(extractDelta, opts) const full = await aiProxyStream(url, apiKey, JSON.stringify(body), (chunk) => sink.push(chunk)) if (!full) return null if (full.status === 0 && full.error) throw new Error('桌面代理请求失败:' + full.error) // 非 200:按 consumeStream 相同格式报错(含智谱错误码翻译) if (full.status >= 400) { let msg = '接口返回 ' + full.status const hint = ERROR_HINTS_BY_STATUS[full.status] try { const err = JSON.parse(full.body).error if (err) { msg += ' [' + err.code + '] ' + (err.message || '') if (err.code && ERROR_HINTS[err.code]) msg += '\n\n💡 ' + ERROR_HINTS[err.code] } else msg += ' ' + (full.body || '').slice(0, 200) } catch { msg += ' ' + (full.body || '').slice(0, 200) } if (hint && !msg.includes('💡')) msg += '\n\n💡 ' + hint throw new Error(msg) } return sink.finish() } /** * 统一 POST 入口: * - 桌面(Tauri)→ Rust 侧 reqwest 代理(无 CORS) * - Web → 浏览器 fetch(支持 CORS 的网关如智谱可直连) * 桌面流式请求的 SSE 由 bridge 内部转为 onVisible 增量回调 */ async function postJSON(url: string, headers: Record, body: unknown, signal?: AbortSignal): Promise { const bodyStr = JSON.stringify(body) // 桌面代理路径:headers 中取鉴权(Authorization Bearer 或 x-api-key) if (isTauri()) { const apiKey = headers['Authorization']?.replace(/^Bearer\s+/i, '') || headers['x-api-key'] || '' const proxied = await aiProxy(url, apiKey, bodyStr) if (proxied) { if (proxied.status === 0 && proxied.error) throw new Error('桌面代理请求失败:' + proxied.error) return new Response(proxied.body, { status: proxied.status }) } } try { return await fetch(url, { method: 'POST', headers, body: bodyStr, 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' } // 桌面流式:Rust 事件桥推送 SSE 增量(保留打字机效果),完成后一次性解析 if (isTauri() && opts.onVisible) { const extract = (obj: any) => { const ch = obj.choices && obj.choices[0] return (ch && ch.delta && ch.delta.content) || null } const r = await desktopStream(url, cfg.key, body, extract, opts) if (r) return r } 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') // 桌面流式(同 OpenAI 路径) if (isTauri() && opts.onVisible) { const extract = (obj: any) => { if (obj.type === 'content_block_delta' && obj.delta) return obj.delta.text || null return null } const r = await desktopStream(url, cfg.key, body, extract, opts) if (r) return r } 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 消费(浏览器 fetch 流) 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') const sseSink = createSSESink(extractDelta, opts) for (;;) { const chunk = await reader.read() if (chunk.done) break sseSink.push(dec.decode(chunk.value, { stream: true })) } return sseSink.finish() } /** * SSE 解析核心:数据源无关(fetch 流 / Tauri 事件流通用)。 * push() 喂原始 chunk(可能含多行/半行),finish() 返回与 consumeStream 相同结构。 */ function createSSESink(extractDelta: (obj: any) => string | null, opts: StreamOpts) { 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) } return { /** 喂一个网络 chunk(SSE 帧文本,可跨界) */ push(chunk: string) { sseBuf += chunk.replace(/\r/g, '') const lines = sseBuf.split('\n') sseBuf = lines.pop() || '' for (const line of lines) { const l = line.trim() if (!l || l.indexOf('data:') !== 0) continue const payload = l.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) } }, /** 流结束:解析残留行并汇总 */ finish(): { json: any; reply: string; op: any } { 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 (out.autoplay != null) out.autoplay = !!out.autoplay if (out.loop != null) out.loop = !!out.loop if (out.muted != null) out.muted = !!out.muted 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) } } /** SEP 分隔标记(对话回复与 JSON 操作的分隔),导出给 relay 等 Transport 复用 */ export const CHAT_SEP = SEP /** * 解析 chat 协议回复文本:SEP 前为自然语言回复,SEP 后为 JSON 操作。 * 供 AiPanel(SSE 路径)与 AgentPanel(relay 路径)共用,避免复制粘贴。 */ export function parseChatReply(text: string): { reply: string; op: AiOp | null } { const parts = text.split(SEP) return { reply: (parts[0] || '').trim(), op: parts.length > 1 ? normalizeOp(tryParse(parts.slice(1).join(SEP))) : null } } /** * 组装 Agent 请求 prompt:用户指令 + chat 协议要求 + deck 上下文。 * 大 deck(序列化 >700KB)降级为「当前页完整 JSON + 全deck大纲摘要」,防超 1MiB 帧上限。 */ export function buildAgentPrompt(input: string, selectedElement?: SlideElement | null, history?: { role: string; content: string }[]): string { const SYS_AGENT = SYS_BASE + '\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' + '回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' + 'JSON 操作格式:{"action":"add_page|update_page|create_all|answer|outline|gen_page","slides":[...],"target":页码(从1开始,可选)}\n' + '- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' + '- outline:用户要求「拟定大纲/先出大纲」时返回,格式 {"action":"outline","title":"整份标题","items":[...]};\n' + ' items 结构:{ "kind":"cover|toc|content|quote|end", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选补充指令" };\n' + ' kind 取值:cover(封面)/toc(目录)/content(内容)/quote(金句)/end(结尾),封面与结尾各 1 页,目录可选;\n' + ' 页数遵循用户指令(指定具体页数照办;未指定/「自动」则按主题信息量在 5-12 页裁量:概念介绍 5-7、常规 7-9、复杂多维度 9-12);hint 给后续生成幻灯片的补充指令。\n' + '- gen_page:用户给出大纲条目要求生成该页时返回,格式 {"action":"gen_page","slides":[单个slide对象]}。\n' + '没有改动时不要输出分隔标记。不要使用 markdown 代码块。' const deck = store.getDeck() let body: string const full = JSON.stringify(deck) if (full.length > 700 * 1024) { // 超大 deck 降级:大纲摘要 + 当前页完整 JSON body = deckContext(store.getCurrentIndex(), null) } else { body = '完整 deck JSON:\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页' } // 选中元素上下文:与 direct 通道对话行为对齐 if (selectedElement) { const typeLabel = selectedElement.type const preview = (selectedElement.content || '').replace(/\n/g, ' ').slice(0, 100) body += '\n\n【用户当前选中的元素】(第' + (store.getCurrentIndex() + 1) + '页)' body += '\n类型: ' + typeLabel + ',内容预览: "' + preview + '"' body += '\n用户接下来的指令默认针对此元素,除非明确说整页/整套。' } // 多轮对话历史(不含本次输入);单条截断防长回复撑爆 1MiB 帧上限 let hist = '' if (history && history.length) { hist = '\n\n对话历史:\n' + history .map(m => (m.role === 'user' ? '用户: ' : '助手: ') + m.content.slice(0, 1500)) .join('\n') } return SYS_AGENT + '\n\n' + hist + '\n\n' + body + '\n\n用户指令:' + input } /** 大纲条目规范化(outline() 与 normalizeOp 的 outline 分支共用) */ function normOutlineItems(arr: any[]): OutlineItem[] { return (Array.isArray(arr) ? arr : []).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 })) } 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 // outline:agent 返回整份大纲,条目规范化(复用 outline() 的逻辑) if (action === 'outline') { const items = normOutlineItems(json.items) if (!items.length) action = 'answer' else return { action, slides, target: null, note: json.note || '', outline: { title: String(json.title || '').slice(0, 80) || '未命名大纲', topic: '', items } } } // gen_page:按大纲条目生成的单页,空则降级 answer if (action === 'gen_page' && !slides.length) action = 'answer' 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-12 页间裁量,' + '概念介绍 5-7 页、常规主题 7-9 页、多维度复杂主题 9-12 页),每页给出:页类型 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 | 'auto'; signal?: AbortSignal }): Promise { const count = opts.count || 'auto' const countHint = count === 'auto' ? '页数自动裁量(结合主题信息量在 5-12 页之间取舍)' : '请拟定约 ' + count + ' 页' const messages: Message[] = [ { role: 'system', content: SYS_OUTLINE }, { role: 'user', content: '主题:' + opts.topic + '\n' + countHint + '的大纲(含封面与结尾),中文。' } ] const r = await streamChat(messages, { jsonMode: true, signal: opts.signal }) if (!r.json) throw new Error('AI 未返回有效大纲,请重试。') const items = normOutlineItems(r.json.items) 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 // 图像模型默认值跟随服务商:智谱用 cogview,其余走 OpenAI 兼容默认 const imgModel = cfg.imgModel || (imgBase.includes('bigmodel.cn') ? 'cogview-3-plus' : 'dall-e-3') if (!imgKey) throw new Error('未配置 API Key,无法生成图片。') const url = imgBase + '/images/generations' const body: Record = { model: imgModel, prompt: opts.prompt, n: 1, size: '1024x1024' } // 智谱 CogView 不支持 response_format 参数(会报错),仅对 OpenAI 兼容网关传 b64_json if (!imgBase.includes('bigmodel.cn')) body.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('图片生成返回无图像数据。') // 返回的是临时 URL 而非 base64:链接会过期导致日后裂图,先抓回本地存 data URL if (/^https?:/i.test(dataUrl)) { try { const imgResp = await fetch(dataUrl, { signal: opts.signal }) if (imgResp.ok) { const blob = await imgResp.blob() const b64 = await new Promise((resolve, reject) => { const r = new FileReader() r.onload = () => resolve(r.result as string) r.onerror = () => reject(r.error) r.readAsDataURL(blob) }) return { url: b64, revisedPrompt: item.revised_prompt } } } catch (e: any) { if (e?.name === 'AbortError') throw e // 抓取失败(常见为 CORS)——退回原 URL,仍可显示但有过期风险 } } return { url: dataUrl, revisedPrompt: item.revised_prompt } } /** 判断是否配置了图像生成能力(当前以主 Key 是否存在为信号) */ export function isImageConfigured(): boolean { const cfg = store.getCfg() return !!(cfg.imgKey || cfg.key) } /** 检查写入图片后是否超 localStorage 上限(约 5MB 字符),返回错误文案或 null */ export function checkImageQuota(dataUrl: string): string | null { const QUOTA_CHARS = 4_500_000 let deckChars = 0 try { deckChars = JSON.stringify(store.getDeck()).length } catch (e) { /* ignore */ } if (deckChars + dataUrl.length > QUOTA_CHARS) { return '图片写入后将超本地存储上限(约 5MB),已取消。请删除部分旧图,或导出 JSON 备份后清理文库' } return null } /* ---------- 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 }