Files
u-ppt/src/core/ai.ts
T

853 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
* 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<string, string> = {
'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<number, string> = {
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' +
'元素类型 typetitle 标题 | 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/quotecontent 为文字,list 用 \\n 分多行\n' +
' - statcontent 为大数字(如 "65%")style.label 为说明\n' +
' - cardcontent 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
' - shapestyle.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubblestyle.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
' - chartcontent 为 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' +
' - tablecontent 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
' - codecontent 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
' - formulacontent 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
' - imagecontent 留空\n' +
' - videocontent 填视频 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<string, unknown>,
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<string, string>, body: unknown, signal?: AbortSignal): Promise<Response> {
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<typeof store.getCfg>) {
const url = apiUrl(cfg) + '/chat/completions'
const body: Record<string, unknown> = { 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<typeof store.getCfg>) {
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<string, unknown> = {
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 操作。
* 供 AiPanelSSE 路径)与 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-deepkind=end 用 g-primarykind=content 数据页用 stat+chart。'
const SYS_THEME =
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
'要求:\n' +
'- 主色饱和度适中,不刺眼;强调色与主色形成对比但不冲突。\n' +
'- bg 与 panel 接近白色,差异轻微。\n' +
'- text 深于 primarymuted 为中性灰。\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<Outline> {
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<Slide> {
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<ImageGenResult> {
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<string, unknown> = {
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<string>((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<ThemeSuggestion> {
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 }