新增: AI 助手 Markdown 渲染+打字机流式+桌面 Rust 代理修复 CORS
This commit is contained in:
+172
-41
@@ -10,6 +10,7 @@ import type { AiOp, ChartItem, Deck, Slide, SlideElement, ElementStyle, RichLine
|
||||
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', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
|
||||
@@ -103,20 +104,36 @@ const SYS_CHAT =
|
||||
* ============================================================ */
|
||||
function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string {
|
||||
const deck: Deck = store.getDeck()
|
||||
/** 图片/超长 content 脱敏:base64 会撑爆上下文,替换为占位说明 */
|
||||
const sanitizeEl = (el: SlideElement): SlideElement => {
|
||||
if (el.type === 'image') {
|
||||
const src = el.content || ''
|
||||
const desc = src.startsWith('data:')
|
||||
? `[图片 base64 ${Math.round(src.length / 1024)}KB]`
|
||||
: (src ? `[图片URL: ${src.slice(0, 80)}${src.length > 80 ? '…' : ''}]` : '[空图片]')
|
||||
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 ? ' ← 当前页' : ''))
|
||||
})
|
||||
lines.push('\n当前页(第' + (currentIdx + 1) + '页)完整JSON:\n' + JSON.stringify(store.currentSlide.value))
|
||||
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 || '').slice(0, 100)
|
||||
const preview = (selectedEl.content || '').replace(/\n/g, ' ').slice(0, 100)
|
||||
lines.push('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)')
|
||||
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
||||
lines.push('完整JSON: ' + JSON.stringify(selectedEl))
|
||||
lines.push('完整JSON: ' + JSON.stringify(sanitizeEl(selectedEl)))
|
||||
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
||||
}
|
||||
return lines.join('\n')
|
||||
@@ -145,9 +162,55 @@ 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: JSON.stringify(body), signal })
|
||||
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)
|
||||
@@ -159,6 +222,16 @@ async function runOpenAI(messages: Message[], opts: StreamOpts, cfg: ReturnType<
|
||||
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) => {
|
||||
@@ -184,6 +257,16 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
|
||||
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,
|
||||
@@ -197,7 +280,7 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// 通用 SSE 消费
|
||||
// 通用 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) {}
|
||||
@@ -215,6 +298,20 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
|
||||
}
|
||||
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 = ''
|
||||
@@ -241,39 +338,41 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
|
||||
}
|
||||
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
|
||||
/** 喂一个网络 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,16 +654,18 @@ export async function generateImage(opts: { prompt: string; signal?: AbortSignal
|
||||
const cfg = store.getCfg()
|
||||
const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '')
|
||||
const imgKey = cfg.imgKey || cfg.key
|
||||
const imgModel = cfg.imgModel || 'dall-e-3'
|
||||
// 图像模型默认值跟随服务商:智谱用 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 = {
|
||||
const body: Record<string, unknown> = {
|
||||
model: imgModel,
|
||||
prompt: opts.prompt,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json'
|
||||
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, {
|
||||
@@ -588,6 +689,25 @@ export async function generateImage(opts: { prompt: string; signal?: AbortSignal
|
||||
? '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 }
|
||||
}
|
||||
|
||||
@@ -597,6 +717,17 @@ export function isImageConfigured(): boolean {
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user