新增: u-ppt Vue 3 在线演示工具初始版本

- 核心架构: Vue 3 + Vite + TypeScript,零运行时依赖(仅 Vue)
- 编辑器: 幻灯片增删排序、元素拖拽八向缩放、双击编辑、12 种元素类型
  (标题/正文/列表/数据/金句/图片/形状/图表/卡片/表格/代码/公式)
- 图表: 8 种 SVG 自绘(柱状/条形/折线/面积/饼图/环形/雷达/进度)
- 富文本: 结构化 segments(加粗/斜体/颜色/高亮/上下标/代码/链接)
- AI 能力: 对话编辑、生成整套、润色本页、大纲→逐页生成、一键美化、AI 配图
- 会话绑定: 每份 PPT 独立会话,切换 PPT 自动切换对话历史
- 模板系统: 7 个内置版式 + 用户自存模板
- 演示模式: 全屏播放、键盘/鼠标/滚轮导航、入场动画
- 持久化: localStorage 存 deck/文库/会话/模板/配置
- 支持多服务商: 智谱/DeepSeek/通义/Kimi/豆包/OpenAI/Anthropic/Gemini/Groq/Ollama
This commit is contained in:
lxy
2026-07-12 13:12:09 +08:00
commit cfeabf2d37
35 changed files with 7919 additions and 0 deletions
+611
View File
@@ -0,0 +1,611 @@
/* =====================================================================
* 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 }
}
/** 润色单页 */
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 }