新增: 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:
+611
@@ -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' +
|
||||
'元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
|
||||
'元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' +
|
||||
' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' +
|
||||
' - stat:content 为大数字(如 "65%"),style.label 为说明\n' +
|
||||
' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
|
||||
' - shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
|
||||
' - chart:content 为 JSON,两种格式:\n' +
|
||||
' 单系列:[{"label":"","value":数字}, ...]\n' +
|
||||
' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' +
|
||||
' style.max=最大值,style.chartType=bar|line|area|pie|doughnut|radar|hbar|progress(默认 bar)\n' +
|
||||
' style.legend=true 显示图例,style.grid=true 显示网格线,style.stack=true 堆叠(多系列柱状)\n' +
|
||||
' 图表类型选择建议:柱状(bar)对比离散类型;折线(line)显示趋势;面积(area)累计趋势;\n' +
|
||||
' 饼图(pie)占比构成;环形(doughnut)轻量占比;雷达(radar)多维度对比;条形(hbar)长标签对比;进度(progress)完成率。\n' +
|
||||
' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
|
||||
' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
|
||||
' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
|
||||
' - image:content 留空\n\n' +
|
||||
'可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' +
|
||||
' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' +
|
||||
' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' +
|
||||
' 仅在需要行内混合样式时用 segments;纯文本继续用 content 即可。segments 与 content 二选一。\n\n' +
|
||||
'style 公共字段:fontSize 数字、color 颜色键、align left|center|right、bold 布尔、italic 布尔、label/labelColor/labelSize、radius\n' +
|
||||
'颜色 color/fill/accent:一律用主题键 primary|accent|text|muted(系统按背景深浅自动反相,深底文字自动变白,不要手填 #ffffff)。\n\n' +
|
||||
'入场动画 style.anim(重要,给主要元素都加上,演示时逐个入场):\n' +
|
||||
' 可选:fade-up(默认上浮) | fade | scale | zoom | slide-l | slide-r | pop(弹现) | rotate(旋转) | bounce(弹跳) | flip(翻转) | blur(聚焦)\n' +
|
||||
' 建议:标题/金句用 pop 或 scale;正文/列表用 fade-up;卡片用 fade-up 或 slide-l/slide-r;装饰形状用 scale。\n\n' +
|
||||
'版式规范:\n' +
|
||||
'- 画布逻辑 1280×720。封面标题 y≈30 h≈18;内容标题 y≈8-10。\n' +
|
||||
'- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' +
|
||||
'- 一页一个观点,留白充足,列表不超过 5 条。\n' +
|
||||
'- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' +
|
||||
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
|
||||
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
|
||||
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
|
||||
'- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。\n' +
|
||||
'- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」「让 X 更简单」「不止是 X,更是 Y」。\n' +
|
||||
'- 列表每条是可执行/可验证的要点,不是三词排比(避免「清晰、简洁、高效」式口号)。\n' +
|
||||
'- 有判断、有观点,像从业者写的;数据尽量给来源(人名/书名/研究)。\n' +
|
||||
'- 不要使用 markdown 代码块,直接输出 JSON。'
|
||||
|
||||
const SYS_GENERATE =
|
||||
SYS_BASE +
|
||||
'\n任务:根据用户主题生成一套完整演示(封面、目录、3-5 页内容、结尾,共 6-8 页)。\n' +
|
||||
'要求:每个主要元素都加 style.anim;封面与结尾用 g-primary;至少 2 张 card;数据页用 stat+chart。\n' +
|
||||
'严格输出:{"action":"create_all","slides":[...]}'
|
||||
|
||||
const SYS_POLISH =
|
||||
SYS_BASE +
|
||||
'\n任务:按指令优化给定的“单页”,保持主题一致、布局合理,并为各元素补充 style.anim。\n' +
|
||||
'严格输出:{"action":"update_page","slides":[ 优化后的单页 ],"note":"一句话说明改动"}'
|
||||
|
||||
const SYS_CHAT =
|
||||
SYS_BASE +
|
||||
'\n任务:与用户对话式编辑当前演示。先用中文流式回复用户(解释你将做什么),' +
|
||||
'如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' +
|
||||
'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' +
|
||||
'- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' +
|
||||
'没有改动时不要输出分隔标记,仅回复即可。'
|
||||
|
||||
/* ============================================================
|
||||
* 上下文摘要
|
||||
* ============================================================ */
|
||||
function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string {
|
||||
const deck: Deck = store.getDeck()
|
||||
const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。']
|
||||
deck.slides.forEach((s, i) => {
|
||||
const types = s.elements.map(e => e.type).join('/')
|
||||
const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : ''
|
||||
lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : ''))
|
||||
})
|
||||
lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(store.currentSlide.value))
|
||||
// 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它
|
||||
if (selectedEl) {
|
||||
const typeLabel = selectedEl.type
|
||||
const preview = (selectedEl.content || '').slice(0, 100)
|
||||
lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)')
|
||||
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
||||
lines.push('完整JSON: ' + JSON.stringify(selectedEl))
|
||||
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 底层:流式请求
|
||||
* ============================================================ */
|
||||
interface StreamOpts {
|
||||
jsonMode?: boolean
|
||||
onVisible?: (text: string) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface Message { role: 'system' | 'user' | 'assistant'; content: string }
|
||||
|
||||
async function streamChat(messages: Message[], opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
|
||||
const cfg = store.getCfg()
|
||||
const isLocal = /localhost|127\.0\.0\.1/i.test(cfg.base || '')
|
||||
if (!cfg.key && !isLocal) throw new Error('未配置 API Key,请点击右上角 ⚙ 填写。')
|
||||
if (cfg.protocol === 'anthropic') return runAnthropic(messages, opts, cfg)
|
||||
return runOpenAI(messages, opts, cfg)
|
||||
}
|
||||
|
||||
function apiUrl(cfg: { proxy: string; base: string }): string {
|
||||
return (cfg.proxy || cfg.base || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function postJSON(url: string, headers: Record<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-deep;kind=end 用 g-primary;kind=content 数据页用 stat+chart。'
|
||||
|
||||
const SYS_THEME =
|
||||
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
|
||||
'要求:\n' +
|
||||
'- 主色饱和度适中,不刺眼;强调色与主色形成对比但不冲突。\n' +
|
||||
'- bg 与 panel 接近白色,差异轻微。\n' +
|
||||
'- text 深于 primary,muted 为中性灰。\n' +
|
||||
'- 所有颜色用 #hex 格式。\n\n' +
|
||||
'可选背景推荐:bg/panel(浅底)/primary/accent(深底)/g-primary(主→强调渐变)/g-deep(深色渐变)。\n' +
|
||||
'内容页优先 bg/panel;封面/结尾优先 g-primary;金句页优先 g-deep。\n\n' +
|
||||
'严格输出 JSON:\n' +
|
||||
'{ "name":"主题名","primary":"#...","accent":"#...","bg":"#...","panel":"#...","text":"#...","muted":"#...","recommendBg":"g-primary","reason":"一句话设计理由" }\n' +
|
||||
'不要使用 markdown 代码块。'
|
||||
|
||||
const SYS_BEAUTIFY =
|
||||
SYS_BASE +
|
||||
'\n任务:优化给定页面的「排版」(位置、尺寸、字号、对齐、装饰元素),保持文字内容不变。\n' +
|
||||
'如果布局合理则微调;如果有明显问题(元素重叠、字号失衡、留白不足)则重排。\n' +
|
||||
'严格输出:{ "background":"原背景或更合适的键", "elements":[ ... ], "note":"一句话说明" }\n' +
|
||||
'不要改动文字内容本身,只优化视觉。'
|
||||
|
||||
/* ---------- 1. 大纲生成 ---------- */
|
||||
|
||||
export async function outline(opts: { topic: string; count?: number; signal?: AbortSignal }): Promise<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 }
|
||||
Reference in New Issue
Block a user