新增: 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
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "u-ppt 主窗口默认能力:IPC 命令调用 + AI 流式事件订阅",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-unlisten"
]
}
+4 -1
View File
@@ -205,8 +205,11 @@ pub async fn ai_proxy_stream(
.map_err(|e| format!("请求失败: {}", e))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
return Ok(text);
// 错误体带状态码前缀,前端按「接口返回 N」格式提示
let _ = app.emit("ai-error", &text);
return Err(format!("__HTTP_{}__{}", status, text));
}
// 流式读取 SSE
+102 -86
View File
@@ -3,74 +3,14 @@
发送/停止/生成整套/润色本页/流式渲染/操作应用
===================================================================== -->
<script setup lang="ts">
import { ref, nextTick, computed, watch } from 'vue'
import { ref, nextTick, computed, watch, onUnmounted } from 'vue'
import type { ChatMessage, AiOp, Slide } from '../../core/types'
import { store } from '../../core/store'
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
import { elementTypes } from '../../core/sample'
import { renderMd } from '../../core/markdown'
import OutlinePanel from './OutlinePanel.vue'
/* ---------- 消息内容渲染:提取 JSON 代码块并格式化 ---------- */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** 尝试把 JSON 字符串格式化(缩进),失败则返回原文 */
function tryFormatJson(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
}
/** 对格式化后的 JSON 做轻量语法着色(不引依赖,纯正则) */
function highlightJson(code: string): string {
return escapeHtml(code)
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
}
interface RenderedPart {
type: 'text' | 'code'
html: string
lang?: string
}
/** 把消息内容拆分为文本段 + JSON 代码块段 */
function renderMessageContent(content: string): RenderedPart[] {
if (!content) return []
const parts: RenderedPart[] = []
// 匹配 ```json ... ``` 或 ``` ... ``` 代码块
const codeBlockRe = /```(\w*)\n?([\s\S]*?)```/g
let lastIdx = 0
let m: RegExpExecArray | null
while ((m = codeBlockRe.exec(content)) !== null) {
// 代码块前的文本
if (m.index > lastIdx) {
const text = content.slice(lastIdx, m.index)
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
}
const lang = m[1] || ''
const code = m[2]
const trimmed = code.trim()
// JSON 代码块 → 格式化 + 着色
if (!lang || lang === 'json') {
parts.push({ type: 'code', lang: 'json', html: highlightJson(tryFormatJson(trimmed)) })
} else {
parts.push({ type: 'code', lang, html: escapeHtml(trimmed) })
}
lastIdx = m.index + m[0].length
}
// 尾部文本
if (lastIdx < content.length) {
const text = content.slice(lastIdx)
if (text.trim()) parts.push({ type: 'text', html: escapeHtml(text) })
}
// 没有代码块 → 返回整个为文本段
if (!parts.length) parts.push({ type: 'text', html: escapeHtml(content) })
return parts
}
const emit = defineEmits<{
(e: 'busy-change', busy: boolean): void
(e: 'toast', msg: string): void
@@ -131,6 +71,8 @@ function persistChat() {
function setBusy(b: boolean) {
busy.value = b
emit('busy-change', b)
// 停止/完成后焦点回输入框,继续对话无需重新点击
if (!b) nextTick(() => inputEl.value?.focus())
}
function scrollBottom() {
@@ -159,10 +101,20 @@ function addPersisted(role: ChatMessage['role'], text: string) {
scrollBottom()
}
/** 在跑的流式气泡集(组件卸载时统一停 rAF) */
const activeStreams = new Set<StreamCtrl>()
/** 创建一条流式气泡(返回控制器对象) */
interface StreamCtrl {
msg: RenderMsg
started: boolean
/** 打字机缓冲:网络 delta 先入队,rAF 匀速释放渲染(参考 devflow 流式体验) */
buffer: string
shown: string
rafId: number | null
flushPending: boolean
/** 冲刷完成回调(持久化等收尾动作) */
settled?: (() => void) | null
}
function streamBubble(placeholder?: string): StreamCtrl {
const msg: RenderMsg = {
@@ -173,27 +125,74 @@ function streamBubble(placeholder?: string): StreamCtrl {
}
renderMsgs.value.push(msg)
scrollBottom()
return { msg, started: !placeholder }
const ctrl: StreamCtrl = { msg, started: !placeholder, buffer: '', shown: '', rafId: null, flushPending: false }
activeStreams.add(ctrl)
return ctrl
}
/** rAF 匀速释放缓冲:每帧最多放 CHARS_PER_FRAME 字符,网络抖动时也不突兀蹦字 */
const CHARS_PER_FRAME = 4
function pumpTypewriter(s: StreamCtrl) {
if (s.rafId != null) return
s.rafId = requestAnimationFrame(() => {
s.rafId = null
if (!s.buffer.length) {
// 缓冲排空:流已结束则收尾,否则待下一批 delta
if (s.flushPending) {
s.msg.streaming = false
s.flushPending = false
s.settled?.()
s.settled = null
}
return
}
// 每帧固定放 4 字符 ≈ 240 字符/秒,网络抖动时也保持匀速观感
const take = Math.min(s.buffer.length, CHARS_PER_FRAME)
s.shown += s.buffer.slice(0, take)
s.buffer = s.buffer.slice(take)
s.msg.content = s.shown
scrollBottom()
if (s.buffer.length || s.flushPending) pumpTypewriter(s)
})
}
function streamOnVisible(s: StreamCtrl) {
return (t: string) => {
if (!s.started) { s.msg.content = ''; s.started = true }
s.msg.content += t
scrollBottom()
if (!s.started) { s.msg.content = ''; s.shown = ''; s.started = true }
s.buffer += t
pumpTypewriter(s)
}
}
function streamDone(s: StreamCtrl) {
/** 流结束:标记待冲刷(缓冲剩余字符继续匀速放完再收尾),可选收尾回调 */
function streamFlush(s: StreamCtrl, onSettled?: () => void) {
s.settled = onSettled || null
s.flushPending = true
if (!s.buffer.length) {
// 缓冲已空:立即收尾
s.msg.streaming = false
if (!s.msg.content) s.msg.content = ''
s.flushPending = false
s.settled?.()
s.settled = null
return
}
pumpTypewriter(s)
}
function streamDone(s: StreamCtrl, onSettled?: () => void) {
streamFlush(s, onSettled)
}
function streamSetText(s: StreamCtrl, txt: string) {
// 外部直接设置最终文本(跳过打字机,用于错误/提示)
if (s.rafId != null) { cancelAnimationFrame(s.rafId); s.rafId = null }
s.buffer = ''
s.shown = txt
s.msg.content = txt
s.msg.streaming = false
s.flushPending = false
}
function streamError(s: StreamCtrl, msg: string) {
streamSetText(s, '⚠ ' + msg)
s.msg.error = true
s.msg.content = '⚠ ' + msg
s.msg.streaming = false
}
function streamTag(s: StreamCtrl, txt: string) {
if (txt) s.msg.tag = txt
@@ -215,6 +214,11 @@ function applyOp(op: AiOp, lockedIdx: number): string {
let t = op.target != null ? op.target : lockedIdx
t = Math.max(0, Math.min(t, store.getCount() - 1))
store.replaceSlide(t, slides[0])
// 目标页与发送时页不同:跳转过去让用户直接看到改动
if (t !== lockedIdx) {
store.setCurrentIndex(t)
return '已更新第 ' + (t + 1) + ' 页并跳转'
}
return '已更新第 ' + (t + 1) + ' 页'
}
return ''
@@ -230,10 +234,7 @@ function persistStream(s: StreamCtrl) {
function onSend() {
if (busy.value) return
const text = inputText.value.trim()
if (!text) {
if (!isConfigured()) { emit('open-settings'); return }
return
}
if (!text) return
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
inputText.value = ''
runChat(text)
@@ -241,7 +242,11 @@ function onSend() {
async function runChat(input: string) {
addPersisted('user', input)
const history = chatLog.value.slice(-10).map(m => ({ role: m.role, content: m.content }))
// history 发送前剥离系统追加的 [✓ ...] 标记行,避免污染 AI 上下文
const history = chatLog.value.slice(-10).map(m => ({
role: m.role,
content: m.content.replace(/\n?\[✓[^\]]*\]$/g, '')
}))
const idx0 = store.getCurrentIndex()
const stream = streamBubble()
@@ -256,18 +261,24 @@ async function runChat(input: string) {
signal: abortCtrl.signal,
selectedElement: selectedEl.value
})
streamDone(stream)
if (!r.reply) {
streamSetText(stream, '(已完成)')
}
// op 应用与 tag 立即执行;持久化等打字机冲刷完成(内容已齐)再落
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
const applied = applyOp(r.op, idx0)
streamTag(stream, applied)
}
streamDone(stream, () => {
if (!stream.msg.content) streamSetText(stream, '(已完成)')
persistStream(stream)
})
} catch (e: any) {
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
else streamError(stream, e?.message || String(e))
if (e?.name === 'AbortError') {
// 停止:放弃缓冲中未显示的部分之外,把已显示内容立即固化
if (stream.rafId != null) { cancelAnimationFrame(stream.rafId); stream.rafId = null }
stream.msg.streaming = false
stream.msg.content = stream.shown ? stream.shown + '\n(已停止)' : '(已停止)'
} else {
streamError(stream, e?.message || String(e))
}
persistStream(stream)
} finally {
setBusy(false); abortCtrl = null
@@ -392,6 +403,14 @@ defineExpose({ isBusy, focus })
/* ---------- 初始化:从持久化记录重建 ---------- */
rebuildFromChatLog()
/* 组件卸载:停掉所有在跑的打字机 rAF */
onUnmounted(() => {
for (const s of activeStreams) {
if (s.rafId != null) cancelAnimationFrame(s.rafId)
}
activeStreams.clear()
})
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
watch(currentChatId, () => {
chatLog.value = loadChat()
@@ -427,10 +446,8 @@ watch(currentChatId, () => {
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
<div class="bubble" :class="{ error: m.error }">
<span v-if="m.tag" class="diff-tag"> {{ m.tag }}</span>
<template v-for="(p, pi) in renderMessageContent(m.content)" :key="pi">
<pre v-if="p.type === 'code'" class="msg-code" :data-lang="p.lang"><code v-html="p.html"></code></pre>
<span v-else class="stream-text" v-html="p.html"></span>
</template>
<!-- Markdown 整段渲染含代码块/列表/加粗等 DOMPurify 消毒 -->
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
<span v-if="m.streaming" class="cursor"></span>
</div>
</div>
@@ -443,7 +460,6 @@ watch(currentChatId, () => {
id="chatInput"
rows="3"
placeholder="输入指令,回车发送(Shift+Enter 换行)"
:disabled="busy"
@keydown="onInputKeydown"
></textarea>
<div class="btns">
+153 -22
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(/\/+$/, '')
}
async function postJSON(url: string, headers: Record<string, string>, body: unknown, signal?: AbortSignal): Promise<Response> {
/**
* 桌面流式请求: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 {
return await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal })
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)
@@ -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,23 +338,24 @@ 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 })
return {
/** 喂一个网络 chunk(SSE 帧文本,可跨界) */
push(chunk: string) {
sseBuf += chunk.replace(/\r/g, '')
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()
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)
}
}
// 末帧残留 data: 行补解析
},
/** 流结束:解析残留行并汇总 */
finish(): { json: any; reply: string; op: any } {
const tail = sseBuf.trim()
if (tail.indexOf('data:') === 0) {
const tp = tail.slice(5).trim()
@@ -267,7 +365,6 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
}
}
if (!opts.jsonMode && !sepMode && pending) emit(pending)
if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null }
const parts = full.split(SEP)
return {
@@ -275,6 +372,8 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
reply: (parts[0] || '').trim(),
op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null
}
}
}
}
function tryParse(s: string): any {
@@ -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> {
+57 -30
View File
@@ -1,44 +1,71 @@
/* =====================================================================
* bridge.ts — Tauri IPC 安全调用桥接
*
* 安全检测 __TAURI__ 环境,动态 import @tauri-apps/api/core。
* Web 模式下返回 mock,组件无需关心环境。
* bridge.ts — Tauri IPC 桥接AI 代理专用)
* 桌面版(Tauri WebView)直连 AI 网关受 CORS 限制,走 Rust 侧 reqwest 代理;
* Web 版返回 null,调用方回退浏览器 fetch(智谱等支持 CORS 的网关直连可用)
* ===================================================================== */
/** 动态获取 invoke 函数 */
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
/** 是否运行在 Tauri 桌面环境 */
export function isTauri(): boolean {
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
}
async function ensureInvoke() {
if (_invoke) return true
if (typeof window === 'undefined' || !('__TAURI__' in window)) return false
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
let _invokeTried = false
/** 懒加载 Tauri invoke(动态 importWeb 版不会打进包) */
async function getInvoke() {
if (_invokeTried) return _invoke
_invokeTried = true
if (!isTauri()) return null
try {
const mod = await import('@tauri-apps/api/core')
_invoke = mod.invoke
return true
} catch {
return false
}
} catch { _invoke = null }
return _invoke
}
/**
* 安全调用 Tauri IPC 命令。
* Web 模式下返回 undefined(调用方需处理)。
*/
export async function invoke<T = unknown>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const ok = await ensureInvoke()
if (!ok || !_invoke) return undefined
return _invoke(cmd, args) as Promise<T>
export interface ProxyResp {
status: number
body: string
error?: string
}
/** 注册 Tauri 事件监听(用于 Rust → 前端推送) */
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
if (typeof window === 'undefined' || !('__TAURI__' in window)) {
return () => {} // 空注销函数
}
/** 非流式代理:Rust 侧 fetch,返回 {status, body};非桌面环境返回 null */
export async function aiProxy(url: string, apiKey: string, body: string): Promise<ProxyResp | null> {
const invoke = await getInvoke()
if (!invoke) return null
try {
const { listen } = await import('@tauri-apps/api/event')
return listen(event, (e: { payload: T }) => handler(e.payload))
} catch {
return () => {}
const r = await invoke('ai_proxy', { url, apiKey, body }) as ProxyResp
return r
} catch (e: any) {
return { status: 0, body: '', error: String(e?.message || e) }
}
}
/** 流式代理:Rust 侧 SSE,原始 chunk(含 "data: {...}" 帧)通过 onChunk 推送;
* SSE 解析由 ai.ts 的 createSSESink 统一处理(与浏览器路径共用)。非桌面返回 null */
export async function aiProxyStream(
url: string, apiKey: string, body: string,
onChunk: (raw: string) => void
): Promise<ProxyResp | null> {
const invoke = await getInvoke()
if (!invoke) return null
try {
const eventMod = await import('@tauri-apps/api/event')
const unlisten = await eventMod.listen<string>('ai-delta', (ev) => {
onChunk(ev.payload || '')
})
try {
const full = await invoke('ai_proxy_stream', { url, apiKey, body }) as unknown as string
return { status: 200, body: full }
} finally {
unlisten()
}
} catch (e: any) {
// Rust 侧错误协议:__HTTP_<status>__<body>
const raw = String(e?.message || e)
const m = raw.match(/^__HTTP_(\d+)__/)
if (m) return { status: Number(m[1]), body: raw.slice(m[0].length) }
return { status: 0, body: '', error: raw }
}
}
+66
View File
@@ -0,0 +1,66 @@
/* =====================================================================
* markdown.ts — AI 消息 Markdown 渲染(参考 devflow useMarkdown 设计)
* - marked 静态导入(u-ppt 包体敏感度低,无需动态加载)
* - DOMPurify.sanitize 防 XSS,可安全 v-html
* - 历史消息整段缓存(文本不变命中跳过 parse+sanitize
* - JSON 代码块轻量着色(保留原有正则方案,不引 hljs 控包体)
* ===================================================================== */
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({ gfm: true, breaks: true })
/* ---------- JSON 轻量着色(与原 AiPanel 实现一致) ---------- */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function tryFormatJson(raw: string): string {
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch (e) { return raw }
}
/** JSON 代码着色(纯正则,无依赖) */
function highlightJson(code: string): string {
return escapeHtml(code)
.replace(/("(?:\\.|[^"\\])*"\s*:)/g, '<span class="jk">$1</span>')
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span class="js">$1</span>')
.replace(/:\s*(-?\d+\.?\d*)/g, ': <span class="jn">$1</span>')
.replace(/:\s*(true|false|null)/g, ': <span class="jb">$1</span>')
}
/* ---------- code rendererjson 格式化+着色,其余转义 ---------- */
const renderer = new marked.Renderer()
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
const language = (lang || '').toLowerCase()
if (!language || language === 'json') {
return `<pre class="msg-code" data-lang="json"><code>${highlightJson(tryFormatJson(text.trim()))}</code></pre>`
}
return `<pre class="msg-code" data-lang="${escapeHtml(language)}"><code>${escapeHtml(text)}</code></pre>`
}
marked.use({ renderer })
/* ---------- 缓存 + 主入口 ---------- */
const _mdCache = new Map<string, string>()
const MD_CACHE_LIMIT = 300
/** 渲染 Markdown → 消毒 HTML(可安全 v-html)。失败降级纯文本转义。 */
export function renderMd(text: string): string {
const hit = _mdCache.get(text)
if (hit != null) return hit
let html: string
try {
html = DOMPurify.sanitize(marked.parse(text) as string, {
ADD_ATTR: ['data-lang'],
})
} catch (e) {
html = '<p>' + escapeHtml(text).replace(/\n/g, '<br>') + '</p>'
}
if (_mdCache.size >= MD_CACHE_LIMIT) {
// 简单淘汰:清掉最早的键(Map 保持插入序)
const firstKey = _mdCache.keys().next().value
if (firstKey !== undefined) _mdCache.delete(firstKey)
}
_mdCache.set(text, html)
return html
}
+35 -4
View File
@@ -73,6 +73,31 @@
.msg .bubble .stream-text { white-space: pre-wrap; }
/* Markdown 正文排版(marked 渲染后的气泡内容) */
.msg .bubble .md-body > *:first-child { margin-top: 0; }
.msg .bubble .md-body > *:last-child { margin-bottom: 0; }
.msg .bubble .md-body p { margin: 6px 0; }
.msg .bubble .md-body h1, .msg .bubble .md-body h2, .msg .bubble .md-body h3,
.msg .bubble .md-body h4 { margin: 10px 0 6px; font-size: 14px; font-weight: 600; }
.msg .bubble .md-body h1 { font-size: 16px; }
.msg .bubble .md-body h2 { font-size: 15px; }
.msg .bubble .md-body ul, .msg .bubble .md-body ol { margin: 6px 0; padding-left: 1.4em; }
.msg .bubble .md-body li { margin: 3px 0; }
.msg .bubble .md-body strong { font-weight: 600; }
.msg .bubble .md-body code {
font-family: 'Consolas','Monaco','Courier New',monospace;
font-size: 12px; padding: 1px 5px; border-radius: 4px;
background: rgba(15,23,42,.08);
}
.msg.user .md-body code { background: rgba(255,255,255,.2); }
.msg .bubble .md-body blockquote {
margin: 6px 0; padding: 2px 10px; border-left: 3px solid var(--ui-border);
color: var(--ui-muted);
}
.msg .bubble .md-body table { border-collapse: collapse; margin: 6px 0; font-size: 12px; }
.msg .bubble .md-body th, .msg .bubble .md-body td { border: 1px solid var(--ui-border); padding: 4px 8px; }
.msg .bubble .md-body hr { border: none; border-top: 1px solid var(--ui-border); margin: 8px 0; }
/* 消息中的代码块 */
.msg .msg-code {
margin: 6px 0; padding: 10px 12px;
@@ -89,12 +114,18 @@
.msg .msg-code .jn { color: #d97706; } /* number */
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
/* 流式打字机光标(参考 devflow:细竖线 + 柔和步进闪烁) */
.cursor {
display: inline-block; width: 7px; height: 1em;
background: currentColor; vertical-align: -2px; margin-left: 2px;
animation: blink 1s steps(2, start) infinite;
display: inline-block;
width: 2px; height: 14px;
background: var(--ui-primary, #4f46e5);
margin-left: 2px; vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes blink { 50% { opacity: 0; } }
/* 输入栏 */
.chat-input-bar {
+13 -3
View File
@@ -6,9 +6,19 @@ export default defineConfig(({ mode }) => ({
build: {
chunkSizeWarningLimit: 800,
},
// npm run devmode=web)→ 浏览器模式,端口 8080
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
// npm run tauri:dev → 默认 mode,端口 5173
server: mode === 'web'
? { host: '127.0.0.1', port: 8080, open: true }
: { host: '127.0.0.1', port: 5173, strictPort: true },
? {
host: '127.0.0.1', port: 8080, open: true,
// 与 tauri dev 共存时防止 watch Cargo 产物(EBUSY 崩 watcher
watch: { ignored: ['**/src-tauri/target/**'] },
}
: {
host: '127.0.0.1', port: 5173, strictPort: true,
// 不 watch src-tauri/targetCargo 编译期间写 dll 会触发 EBUSY 崩掉 watcher
watch: { ignored: ['**/src-tauri/target/**'] },
},
// 桌面模式 HMR 由 tauri-cli 管理,无需自动打开浏览器
clearScreen: false,
}))