853 lines
43 KiB
TypeScript
853 lines
43 KiB
TypeScript
/* =====================================================================
|
||
* 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' +
|
||
'元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | video 视频 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
|
||
'元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' +
|
||
' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' +
|
||
' - stat:content 为大数字(如 "65%"),style.label 为说明\n' +
|
||
' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
|
||
' - shape:style.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubble,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
|
||
' - chart:content 为 JSON,两种格式:\n' +
|
||
' 单系列:[{"label":"","value":数字}, ...]\n' +
|
||
' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' +
|
||
' style.max=最大值,style.chartType=bar|line|area|pie|doughnut|radar|hbar|progress(默认 bar)\n' +
|
||
' style.legend=true 显示图例,style.grid=true 显示网格线,style.stack=true 堆叠(多系列柱状)\n' +
|
||
' 图表类型选择建议:柱状(bar)对比离散类型;折线(line)显示趋势;面积(area)累计趋势;\n' +
|
||
' 饼图(pie)占比构成;环形(doughnut)轻量占比;雷达(radar)多维度对比;条形(hbar)长标签对比;进度(progress)完成率。\n' +
|
||
' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
|
||
' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
|
||
' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
|
||
' - image:content 留空\n' +
|
||
' - video:content 填视频 URL\n\n' +
|
||
'可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' +
|
||
' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' +
|
||
' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' +
|
||
' 仅在需要行内混合样式时用 segments;纯文本继续用 content 即可。segments 与 content 二选一。\n\n' +
|
||
'style 公共字段:fontSize 数字、color 颜色键、align left|center|right、bold 布尔、italic 布尔、label/labelColor/labelSize、radius\n' +
|
||
'颜色 color/fill/accent:一律用主题键 primary|accent|text|muted(系统按背景深浅自动反相,深底文字自动变白,不要手填 #ffffff)。\n\n' +
|
||
'入场动画 style.anim(重要,给主要元素都加上,演示时逐个入场):\n' +
|
||
' 可选:fade-up(默认上浮) | fade | scale | zoom | slide-l | slide-r | pop(弹现) | rotate(旋转) | bounce(弹跳) | flip(翻转) | blur(聚焦)\n' +
|
||
' 建议:标题/金句用 pop 或 scale;正文/列表用 fade-up;卡片用 fade-up 或 slide-l/slide-r;装饰形状用 scale。\n\n' +
|
||
'版式规范:\n' +
|
||
'- 画布逻辑 1280×720。封面标题 y≈30 h≈18;内容标题 y≈8-10。\n' +
|
||
'- 字号:title 44-66、text 22-28、list 24-30、stat 数字 64-80、quote 40-52。\n' +
|
||
'- 一页一个观点,留白充足,列表不超过 5 条。\n' +
|
||
'- 现代版式:多用 card 分组;封面/金句/结尾用 g-primary;目录用 3-4 张卡片网格;数据页 stat+chart。\n' +
|
||
'- emoji 极克制:默认不给 card.icon,除非确有助于理解,多数卡片留空。\n\n' +
|
||
'内容准则(重要——避免「AI 味」,写得像该领域的真人):\n' +
|
||
'- 标题写具体事实而非口号:「华东 Q3 增长 23%」而非「业绩腾飞」;「同屏字+口述记忆降 50%」而非「效率革命」。\n' +
|
||
'- 正文要有实质:具体数字、案例、步骤、来源;少用「赋能/助力/打造/引领/开启/一站式」这类空词。\n' +
|
||
'- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」「让 X 更简单」「不止是 X,更是 Y」。\n' +
|
||
'- 列表每条是可执行/可验证的要点,不是三词排比(避免「清晰、简洁、高效」式口号)。\n' +
|
||
'- 有判断、有观点,像从业者写的;数据尽量给来源(人名/书名/研究)。\n' +
|
||
'- 不要使用 markdown 代码块,直接输出 JSON。'
|
||
|
||
const SYS_GENERATE =
|
||
SYS_BASE +
|
||
'\n任务:根据用户主题生成一套完整演示(封面、目录、3-5 页内容、结尾,共 6-8 页)。\n' +
|
||
'要求:每个主要元素都加 style.anim;封面与结尾用 g-primary;至少 2 张 card;数据页用 stat+chart。\n' +
|
||
'严格输出:{"action":"create_all","slides":[...]}'
|
||
|
||
const SYS_POLISH =
|
||
SYS_BASE +
|
||
'\n任务:按指令优化给定的“单页”,保持主题一致、布局合理,并为各元素补充 style.anim。\n' +
|
||
'严格输出:{"action":"update_page","slides":[ 优化后的单页 ],"note":"一句话说明改动"}'
|
||
|
||
const SYS_CHAT =
|
||
SYS_BASE +
|
||
'\n任务:与用户对话式编辑当前演示。先用中文流式回复用户(解释你将做什么),' +
|
||
'如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' +
|
||
'JSON 操作格式:{"action":"add_page|update_page|create_all|answer","slides":[...],"target":页码(从1开始,可选)}\n' +
|
||
'- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' +
|
||
'没有改动时不要输出分隔标记,仅回复即可。'
|
||
|
||
/* ============================================================
|
||
* 上下文摘要
|
||
* ============================================================ */
|
||
function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string {
|
||
const deck: Deck = store.getDeck()
|
||
/** 图片/超长 content 脱敏:base64 会撑爆上下文,替换为占位说明 */
|
||
const sanitizeEl = (el: SlideElement): SlideElement => {
|
||
if (el.type === 'image' || el.type === 'video') {
|
||
const src = el.content || ''
|
||
const label = el.type === 'video' ? '视频' : '图片'
|
||
const desc = src.startsWith('data:')
|
||
? `[${label} base64 ${Math.round(src.length / 1024)}KB]`
|
||
: (src ? `[${label}URL: ${src.slice(0, 80)}${src.length > 80 ? '…' : ''}]` : `[空${label}]`)
|
||
return { ...el, content: desc }
|
||
}
|
||
if (el.content && el.content.length > 2000) {
|
||
return { ...el, content: el.content.slice(0, 2000) + `…[截断,共${el.content.length}字符]` }
|
||
}
|
||
return el
|
||
}
|
||
const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。']
|
||
deck.slides.forEach((s, i) => {
|
||
const types = s.elements.map(e => e.type).join('/')
|
||
const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : ''
|
||
lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : ''))
|
||
})
|
||
const cur = store.currentSlide.value
|
||
const curSanitized = { ...cur, elements: cur.elements.map(sanitizeEl) }
|
||
lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(curSanitized))
|
||
// 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它
|
||
if (selectedEl) {
|
||
const typeLabel = selectedEl.type
|
||
const preview = (selectedEl.content || '').replace(/\n/g, ' ').slice(0, 100)
|
||
lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)')
|
||
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
||
lines.push('完整JSON: ' + JSON.stringify(sanitizeEl(selectedEl)))
|
||
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
||
}
|
||
return lines.join('\n')
|
||
}
|
||
|
||
/* ============================================================
|
||
* 底层:流式请求
|
||
* ============================================================ */
|
||
interface StreamOpts {
|
||
jsonMode?: boolean
|
||
onVisible?: (text: string) => void
|
||
signal?: AbortSignal
|
||
}
|
||
|
||
interface Message { role: 'system' | 'user' | 'assistant'; content: string }
|
||
|
||
async function streamChat(messages: Message[], opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
|
||
const cfg = store.getCfg()
|
||
const isLocal = /localhost|127\.0\.0\.1/i.test(cfg.base || '')
|
||
if (!cfg.key && !isLocal) throw new Error('未配置 API Key,请点击右上角 ⚙ 填写。')
|
||
if (cfg.protocol === 'anthropic') return runAnthropic(messages, opts, cfg)
|
||
return runOpenAI(messages, opts, cfg)
|
||
}
|
||
|
||
function apiUrl(cfg: { proxy: string; base: string }): string {
|
||
return (cfg.proxy || cfg.base || '').replace(/\/+$/, '')
|
||
}
|
||
|
||
/**
|
||
* 桌面流式请求:Rust ai_proxy_stream 转发 SSE 原始 chunk,
|
||
* 事件桥逐 chunk 喂 SSE 解析器(与浏览器路径共用 createSSESink,保留打字机效果与 SEP 协议)。
|
||
* 返回 null 表示桌面桥不可用(回退浏览器 fetch 路径)。
|
||
*/
|
||
async function desktopStream(
|
||
url: string, apiKey: string, body: Record<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 操作。
|
||
* 供 AiPanel(SSE 路径)与 AgentPanel(relay 路径)共用,避免复制粘贴。
|
||
*/
|
||
export function parseChatReply(text: string): { reply: string; op: AiOp | null } {
|
||
const parts = text.split(SEP)
|
||
return {
|
||
reply: (parts[0] || '').trim(),
|
||
op: parts.length > 1 ? normalizeOp(tryParse(parts.slice(1).join(SEP))) : null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 组装 Agent 请求 prompt:用户指令 + chat 协议要求 + deck 上下文。
|
||
* 大 deck(序列化 >700KB)降级为「当前页完整 JSON + 全deck大纲摘要」,防超 1MiB 帧上限。
|
||
*/
|
||
export function buildAgentPrompt(input: string, selectedElement?: SlideElement | null, history?: { role: string; content: string }[]): string {
|
||
const SYS_AGENT =
|
||
SYS_BASE +
|
||
'\n任务:你是通过中继接入的远程 Agent。根据用户指令编辑当前演示。\n' +
|
||
'回复格式:先用中文说明你将做什么,如需修改 PPT,在回复最后另起一行输出分隔标记 ' + SEP + ',紧随其后输出 JSON 操作。\n' +
|
||
'JSON 操作格式:{"action":"add_page|update_page|create_all|answer|outline|gen_page","slides":[...],"target":页码(从1开始,可选)}\n' +
|
||
'- add_page:在 target 页后插入新页;- update_page:替换 target 页;- create_all:整体替换;- answer:仅回答不改稿。\n' +
|
||
'- outline:用户要求「拟定大纲/先出大纲」时返回,格式 {"action":"outline","title":"整份标题","items":[...]};\n' +
|
||
' items 结构:{ "kind":"cover|toc|content|quote|end", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选补充指令" };\n' +
|
||
' kind 取值:cover(封面)/toc(目录)/content(内容)/quote(金句)/end(结尾),封面与结尾各 1 页,目录可选;\n' +
|
||
' 页数遵循用户指令(指定具体页数照办;未指定/「自动」则按主题信息量在 5-12 页裁量:概念介绍 5-7、常规 7-9、复杂多维度 9-12);hint 给后续生成幻灯片的补充指令。\n' +
|
||
'- gen_page:用户给出大纲条目要求生成该页时返回,格式 {"action":"gen_page","slides":[单个slide对象]}。\n' +
|
||
'没有改动时不要输出分隔标记。不要使用 markdown 代码块。'
|
||
const deck = store.getDeck()
|
||
let body: string
|
||
const full = JSON.stringify(deck)
|
||
if (full.length > 700 * 1024) {
|
||
// 超大 deck 降级:大纲摘要 + 当前页完整 JSON
|
||
body = deckContext(store.getCurrentIndex(), null)
|
||
} else {
|
||
body = '完整 deck JSON:\n' + full + '\n当前页码:第 ' + (store.getCurrentIndex() + 1) + ' 页'
|
||
}
|
||
// 选中元素上下文:与 direct 通道对话行为对齐
|
||
if (selectedElement) {
|
||
const typeLabel = selectedElement.type
|
||
const preview = (selectedElement.content || '').replace(/\n/g, ' ').slice(0, 100)
|
||
body += '\n\n【用户当前选中的元素】(第' + (store.getCurrentIndex() + 1) + '页)'
|
||
body += '\n类型: ' + typeLabel + ',内容预览: "' + preview + '"'
|
||
body += '\n用户接下来的指令默认针对此元素,除非明确说整页/整套。'
|
||
}
|
||
// 多轮对话历史(不含本次输入);单条截断防长回复撑爆 1MiB 帧上限
|
||
let hist = ''
|
||
if (history && history.length) {
|
||
hist = '\n\n对话历史:\n' + history
|
||
.map(m => (m.role === 'user' ? '用户: ' : '助手: ') + m.content.slice(0, 1500))
|
||
.join('\n')
|
||
}
|
||
return SYS_AGENT + '\n\n' + hist + '\n\n' + body + '\n\n用户指令:' + input
|
||
}
|
||
|
||
/** 大纲条目规范化(outline() 与 normalizeOp 的 outline 分支共用) */
|
||
function normOutlineItems(arr: any[]): OutlineItem[] {
|
||
return (Array.isArray(arr) ? arr : []).map((it: any, i: number) => ({
|
||
id: 'ol-' + Date.now() + '-' + i,
|
||
kind: ['cover', 'toc', 'content', 'quote', 'end'].includes(it.kind) ? it.kind : 'content',
|
||
title: String(it.title || '未命名').slice(0, 80),
|
||
points: Array.isArray(it.points) ? it.points.map((p: any) => String(p).slice(0, 200)).filter(Boolean).slice(0, 6) : [],
|
||
hint: it.hint ? String(it.hint).slice(0, 120) : undefined,
|
||
done: false
|
||
}))
|
||
}
|
||
|
||
function normalizeOp(json: any): AiOp | null {
|
||
if (!json) return null
|
||
let action: AiOp['action'] = json.action || 'answer'
|
||
const slides = normSlides(json.slides)
|
||
const target = json.target != null ? (Number(json.target) - 1) : null
|
||
// outline:agent 返回整份大纲,条目规范化(复用 outline() 的逻辑)
|
||
if (action === 'outline') {
|
||
const items = normOutlineItems(json.items)
|
||
if (!items.length) action = 'answer'
|
||
else return { action, slides, target: null, note: json.note || '', outline: { title: String(json.title || '').slice(0, 80) || '未命名大纲', topic: '', items } }
|
||
}
|
||
// gen_page:按大纲条目生成的单页,空则降级 answer
|
||
if (action === 'gen_page' && !slides.length) action = 'answer'
|
||
if (action === 'update_page' && slides.length) action = 'update_page'
|
||
if (action === 'add_page' && slides.length) action = 'add_page'
|
||
if ((action === 'update_page' || action === 'add_page') && !slides.length) action = 'answer'
|
||
return { action, slides, target: isNaN(target as number) ? null : target, note: json.note || '' }
|
||
}
|
||
|
||
export function isConfigured(): boolean { return !!(store.getCfg().key) }
|
||
|
||
/* ============================================================
|
||
* AI 深化能力:大纲 / 逐页生成 / 配图 / 主题建议 / 一键美化
|
||
* ============================================================ */
|
||
|
||
import type { Outline, OutlineItem, ThemeSuggestion, ImageGenResult } from './types'
|
||
|
||
/* ---------- Prompt 模板 ---------- */
|
||
|
||
const SYS_OUTLINE =
|
||
'你是「u-ppt」的演示策划助手。用户给你一个主题,你先制定大纲,不要直接写完整幻灯片。\n\n' +
|
||
'大纲要素:先拟定一个具体、有信息量的整份标题(不要「关于 X 的分享」这类空泛标题);\n' +
|
||
'然后拆成页(页数遵循用户指令:指定了具体页数就照办;「自动」则按主题的信息量在 5-12 页间裁量,' +
|
||
'概念介绍 5-7 页、常规主题 7-9 页、多维度复杂主题 9-12 页),每页给出:页类型 kind、标题 title、3-5 条要点 points、可选 hint。\n' +
|
||
'kind 取值:cover(封面) / toc(目录) / content(内容) / quote(金句) / end(结尾)。\n' +
|
||
'封面与结尾各 1 页,目录可选。\n' +
|
||
'hint 用来给后续生成幻灯片的 AI 补充指令,例如「数据页:用 stat+chart」「对比页:双 card 并置」「引言页:深色背景金句」。\n\n' +
|
||
'内容准则:\n' +
|
||
'- 标题写具体事实而非口号(「华东 Q3 增长 23%」而非「业绩腾飞」)。\n' +
|
||
'- 要点每条是可验证的论点或具体数字,不要三词排比。\n' +
|
||
'- 有判断、有观点,像该领域从业者写的;数据尽量给来源。\n' +
|
||
'- 禁用万能金句:「少即是多」「一图胜千言」「未来已来」。\n\n' +
|
||
'严格输出 JSON:\n' +
|
||
'{ "title": "整份标题", "items": [ { "kind":"cover", "title":"页标题", "points":["要点 1","要点 2"], "hint":"可选" } ] }\n' +
|
||
'不要使用 markdown 代码块,直接输出 JSON。'
|
||
|
||
const SYS_GEN_PAGE =
|
||
SYS_BASE +
|
||
'\n任务:根据大纲中的一条,生成「一页」幻灯片。严格遵循要点与 hint,不要偏离主题。\n' +
|
||
'严格输出:{ "background":"...", "elements":[ ... ] }(单个 slide 对象,不要数组)。\n' +
|
||
'kind=cover 用 g-primary 背景;kind=quote 用 g-deep;kind=end 用 g-primary;kind=content 数据页用 stat+chart。'
|
||
|
||
const SYS_THEME =
|
||
'你是配色设计师。根据主题关键词,推荐一套现代、专业的 6 色配色(primary 主色、accent 强调色、bg 背景、panel 面板、text 正文、muted 次要文字)。\n\n' +
|
||
'要求:\n' +
|
||
'- 主色饱和度适中,不刺眼;强调色与主色形成对比但不冲突。\n' +
|
||
'- bg 与 panel 接近白色,差异轻微。\n' +
|
||
'- text 深于 primary,muted 为中性灰。\n' +
|
||
'- 所有颜色用 #hex 格式。\n\n' +
|
||
'可选背景推荐:bg/panel(浅底)/primary/accent(深底)/g-primary(主→强调渐变)/g-deep(深色渐变)。\n' +
|
||
'内容页优先 bg/panel;封面/结尾优先 g-primary;金句页优先 g-deep。\n\n' +
|
||
'严格输出 JSON:\n' +
|
||
'{ "name":"主题名","primary":"#...","accent":"#...","bg":"#...","panel":"#...","text":"#...","muted":"#...","recommendBg":"g-primary","reason":"一句话设计理由" }\n' +
|
||
'不要使用 markdown 代码块。'
|
||
|
||
const SYS_BEAUTIFY =
|
||
SYS_BASE +
|
||
'\n任务:优化给定页面的「排版」(位置、尺寸、字号、对齐、装饰元素),保持文字内容不变。\n' +
|
||
'如果布局合理则微调;如果有明显问题(元素重叠、字号失衡、留白不足)则重排。\n' +
|
||
'严格输出:{ "background":"原背景或更合适的键", "elements":[ ... ], "note":"一句话说明" }\n' +
|
||
'不要改动文字内容本身,只优化视觉。'
|
||
|
||
/* ---------- 1. 大纲生成 ---------- */
|
||
|
||
export async function outline(opts: { topic: string; count?: number | 'auto'; signal?: AbortSignal }): Promise<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 }
|