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

641 lines
32 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'
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<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 图片 | 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|trianglestyle.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\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<string, string>, body: unknown, signal?: AbortSignal): Promise<Response> {
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<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' }
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')
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-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; signal?: AbortSignal }): Promise<Outline> {
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<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
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<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 }