新增: AI 助手 Markdown 渲染+打字机流式+桌面 Rust 代理修复 CORS

This commit is contained in:
lxy
2026-08-24 02:13:15 +08:00
parent 7f08f654d9
commit db3b2c24b7
8 changed files with 463 additions and 167 deletions
+172 -41
View File
@@ -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> {