新增: AI 助手 Markdown 渲染+打字机流式+桌面 Rust 代理修复 CORS
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -205,8 +205,11 @@ pub async fn ai_proxy_stream(
|
|||||||
.map_err(|e| format!("请求失败: {}", e))?;
|
.map_err(|e| format!("请求失败: {}", e))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status().as_u16();
|
||||||
let text = resp.text().await.unwrap_or_default();
|
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
|
// 流式读取 SSE
|
||||||
|
|||||||
+102
-86
@@ -3,74 +3,14 @@
|
|||||||
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
发送/停止/生成整套/润色本页/流式渲染/操作应用
|
||||||
===================================================================== -->
|
===================================================================== -->
|
||||||
<script setup lang="ts">
|
<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 type { ChatMessage, AiOp, Slide } from '../../core/types'
|
||||||
import { store } from '../../core/store'
|
import { store } from '../../core/store'
|
||||||
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
import { generate, polish, chat, beautifyPage, isConfigured } from '../../core/ai'
|
||||||
import { elementTypes } from '../../core/sample'
|
import { elementTypes } from '../../core/sample'
|
||||||
|
import { renderMd } from '../../core/markdown'
|
||||||
import OutlinePanel from './OutlinePanel.vue'
|
import OutlinePanel from './OutlinePanel.vue'
|
||||||
|
|
||||||
/* ---------- 消息内容渲染:提取 JSON 代码块并格式化 ---------- */
|
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 尝试把 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<{
|
const emit = defineEmits<{
|
||||||
(e: 'busy-change', busy: boolean): void
|
(e: 'busy-change', busy: boolean): void
|
||||||
(e: 'toast', msg: string): void
|
(e: 'toast', msg: string): void
|
||||||
@@ -131,6 +71,8 @@ function persistChat() {
|
|||||||
function setBusy(b: boolean) {
|
function setBusy(b: boolean) {
|
||||||
busy.value = b
|
busy.value = b
|
||||||
emit('busy-change', b)
|
emit('busy-change', b)
|
||||||
|
// 停止/完成后焦点回输入框,继续对话无需重新点击
|
||||||
|
if (!b) nextTick(() => inputEl.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollBottom() {
|
function scrollBottom() {
|
||||||
@@ -159,10 +101,20 @@ function addPersisted(role: ChatMessage['role'], text: string) {
|
|||||||
scrollBottom()
|
scrollBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在跑的流式气泡集(组件卸载时统一停 rAF) */
|
||||||
|
const activeStreams = new Set<StreamCtrl>()
|
||||||
|
|
||||||
/** 创建一条流式气泡(返回控制器对象) */
|
/** 创建一条流式气泡(返回控制器对象) */
|
||||||
interface StreamCtrl {
|
interface StreamCtrl {
|
||||||
msg: RenderMsg
|
msg: RenderMsg
|
||||||
started: boolean
|
started: boolean
|
||||||
|
/** 打字机缓冲:网络 delta 先入队,rAF 匀速释放渲染(参考 devflow 流式体验) */
|
||||||
|
buffer: string
|
||||||
|
shown: string
|
||||||
|
rafId: number | null
|
||||||
|
flushPending: boolean
|
||||||
|
/** 冲刷完成回调(持久化等收尾动作) */
|
||||||
|
settled?: (() => void) | null
|
||||||
}
|
}
|
||||||
function streamBubble(placeholder?: string): StreamCtrl {
|
function streamBubble(placeholder?: string): StreamCtrl {
|
||||||
const msg: RenderMsg = {
|
const msg: RenderMsg = {
|
||||||
@@ -173,27 +125,74 @@ function streamBubble(placeholder?: string): StreamCtrl {
|
|||||||
}
|
}
|
||||||
renderMsgs.value.push(msg)
|
renderMsgs.value.push(msg)
|
||||||
scrollBottom()
|
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) {
|
function streamOnVisible(s: StreamCtrl) {
|
||||||
return (t: string) => {
|
return (t: string) => {
|
||||||
if (!s.started) { s.msg.content = ''; s.started = true }
|
if (!s.started) { s.msg.content = ''; s.shown = ''; s.started = true }
|
||||||
s.msg.content += t
|
s.buffer += t
|
||||||
scrollBottom()
|
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
|
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) {
|
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.content = txt
|
||||||
s.msg.streaming = false
|
s.msg.streaming = false
|
||||||
|
s.flushPending = false
|
||||||
}
|
}
|
||||||
function streamError(s: StreamCtrl, msg: string) {
|
function streamError(s: StreamCtrl, msg: string) {
|
||||||
|
streamSetText(s, '⚠ ' + msg)
|
||||||
s.msg.error = true
|
s.msg.error = true
|
||||||
s.msg.content = '⚠ ' + msg
|
|
||||||
s.msg.streaming = false
|
|
||||||
}
|
}
|
||||||
function streamTag(s: StreamCtrl, txt: string) {
|
function streamTag(s: StreamCtrl, txt: string) {
|
||||||
if (txt) s.msg.tag = txt
|
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
|
let t = op.target != null ? op.target : lockedIdx
|
||||||
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
t = Math.max(0, Math.min(t, store.getCount() - 1))
|
||||||
store.replaceSlide(t, slides[0])
|
store.replaceSlide(t, slides[0])
|
||||||
|
// 目标页与发送时页不同:跳转过去让用户直接看到改动
|
||||||
|
if (t !== lockedIdx) {
|
||||||
|
store.setCurrentIndex(t)
|
||||||
|
return '已更新第 ' + (t + 1) + ' 页并跳转'
|
||||||
|
}
|
||||||
return '已更新第 ' + (t + 1) + ' 页'
|
return '已更新第 ' + (t + 1) + ' 页'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
@@ -230,10 +234,7 @@ function persistStream(s: StreamCtrl) {
|
|||||||
function onSend() {
|
function onSend() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
if (!text) {
|
if (!text) return
|
||||||
if (!isConfigured()) { emit('open-settings'); return }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
if (!isConfigured()) { toast('请先配置 API Key'); emit('open-settings'); return }
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
runChat(text)
|
runChat(text)
|
||||||
@@ -241,7 +242,11 @@ function onSend() {
|
|||||||
|
|
||||||
async function runChat(input: string) {
|
async function runChat(input: string) {
|
||||||
addPersisted('user', input)
|
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 idx0 = store.getCurrentIndex()
|
||||||
const stream = streamBubble()
|
const stream = streamBubble()
|
||||||
|
|
||||||
@@ -256,18 +261,24 @@ async function runChat(input: string) {
|
|||||||
signal: abortCtrl.signal,
|
signal: abortCtrl.signal,
|
||||||
selectedElement: selectedEl.value
|
selectedElement: selectedEl.value
|
||||||
})
|
})
|
||||||
streamDone(stream)
|
// op 应用与 tag 立即执行;持久化等打字机冲刷完成(内容已齐)再落
|
||||||
if (!r.reply) {
|
|
||||||
streamSetText(stream, '(已完成)')
|
|
||||||
}
|
|
||||||
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
if (r.op && r.op.action !== 'answer' && r.op.slides.length) {
|
||||||
const applied = applyOp(r.op, idx0)
|
const applied = applyOp(r.op, idx0)
|
||||||
streamTag(stream, applied)
|
streamTag(stream, applied)
|
||||||
}
|
}
|
||||||
|
streamDone(stream, () => {
|
||||||
|
if (!stream.msg.content) streamSetText(stream, '(已完成)')
|
||||||
persistStream(stream)
|
persistStream(stream)
|
||||||
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === 'AbortError') streamSetText(stream, '(已停止)')
|
if (e?.name === 'AbortError') {
|
||||||
else streamError(stream, e?.message || String(e))
|
// 停止:放弃缓冲中未显示的部分之外,把已显示内容立即固化
|
||||||
|
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)
|
persistStream(stream)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false); abortCtrl = null
|
setBusy(false); abortCtrl = null
|
||||||
@@ -392,6 +403,14 @@ defineExpose({ isBusy, focus })
|
|||||||
/* ---------- 初始化:从持久化记录重建 ---------- */
|
/* ---------- 初始化:从持久化记录重建 ---------- */
|
||||||
rebuildFromChatLog()
|
rebuildFromChatLog()
|
||||||
|
|
||||||
|
/* 组件卸载:停掉所有在跑的打字机 rAF */
|
||||||
|
onUnmounted(() => {
|
||||||
|
for (const s of activeStreams) {
|
||||||
|
if (s.rafId != null) cancelAnimationFrame(s.rafId)
|
||||||
|
}
|
||||||
|
activeStreams.clear()
|
||||||
|
})
|
||||||
|
|
||||||
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
/* ---------- 会话切换:chatId 变化时重新加载对话 ---------- */
|
||||||
watch(currentChatId, () => {
|
watch(currentChatId, () => {
|
||||||
chatLog.value = loadChat()
|
chatLog.value = loadChat()
|
||||||
@@ -427,10 +446,8 @@ watch(currentChatId, () => {
|
|||||||
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
<div v-for="m in renderMsgs" :key="m.key" class="msg" :class="m.role">
|
||||||
<div class="bubble" :class="{ error: m.error }">
|
<div class="bubble" :class="{ error: m.error }">
|
||||||
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
<span v-if="m.tag" class="diff-tag">✓ {{ m.tag }}</span>
|
||||||
<template v-for="(p, pi) in renderMessageContent(m.content)" :key="pi">
|
<!-- Markdown 整段渲染(含代码块/列表/加粗等,经 DOMPurify 消毒) -->
|
||||||
<pre v-if="p.type === 'code'" class="msg-code" :data-lang="p.lang"><code v-html="p.html"></code></pre>
|
<div v-if="m.content" class="md-body" v-html="renderMd(m.content)"></div>
|
||||||
<span v-else class="stream-text" v-html="p.html"></span>
|
|
||||||
</template>
|
|
||||||
<span v-if="m.streaming" class="cursor"></span>
|
<span v-if="m.streaming" class="cursor"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,7 +460,6 @@ watch(currentChatId, () => {
|
|||||||
id="chatInput"
|
id="chatInput"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
placeholder="输入指令,回车发送(Shift+Enter 换行)"
|
||||||
:disabled="busy"
|
|
||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
|
|||||||
+153
-22
@@ -10,6 +10,7 @@ import type { AiOp, ChartItem, Deck, Slide, SlideElement, ElementStyle, RichLine
|
|||||||
import { elementTypes, uid } from './sample'
|
import { elementTypes, uid } from './sample'
|
||||||
import { store } from './store'
|
import { store } from './store'
|
||||||
import { normSegments } from './richtext'
|
import { normSegments } from './richtext'
|
||||||
|
import { isTauri, aiProxy, aiProxyStream } from './bridge'
|
||||||
|
|
||||||
const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记
|
const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记
|
||||||
const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
|
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 {
|
function deckContext(currentIdx: number, selectedEl?: SlideElement | null): string {
|
||||||
const deck: Deck = store.getDeck()
|
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 + ' 页。']
|
const lines = ['当前主题: ' + deck.theme + ',共 ' + deck.slides.length + ' 页。']
|
||||||
deck.slides.forEach((s, i) => {
|
deck.slides.forEach((s, i) => {
|
||||||
const types = s.elements.map(e => e.type).join('/')
|
const types = s.elements.map(e => e.type).join('/')
|
||||||
const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : ''
|
const head = (s.elements[0] && s.elements[0].type === 'title') ? (' 标题:"' + (s.elements[0].content || '') + '"') : ''
|
||||||
lines.push('第' + (i + 1) + '页 [' + types + ']' + head + (i === currentIdx ? ' ← 当前页' : ''))
|
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 知道用户正在编辑哪个元素,对话直接围绕它
|
// 选中元素上下文:让 AI 知道用户正在编辑哪个元素,对话直接围绕它
|
||||||
if (selectedEl) {
|
if (selectedEl) {
|
||||||
const typeLabel = selectedEl.type
|
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('\n【用户当前选中的元素】(第' + (currentIdx + 1) + '页)')
|
||||||
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
lines.push('类型: ' + typeLabel + ',内容预览: "' + preview + '"')
|
||||||
lines.push('完整JSON: ' + JSON.stringify(selectedEl))
|
lines.push('完整JSON: ' + JSON.stringify(sanitizeEl(selectedEl)))
|
||||||
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
lines.push('用户接下来的对话默认针对此元素,除非明确说整页/整套。')
|
||||||
}
|
}
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
@@ -145,9 +162,55 @@ function apiUrl(cfg: { proxy: string; base: string }): string {
|
|||||||
return (cfg.proxy || cfg.base || '').replace(/\/+$/, '')
|
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 {
|
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) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError') throw e
|
if (e.name === 'AbortError') throw e
|
||||||
throw new Error('请求失败(可能是 CORS 跨域拦截)。可在设置中配置"代理 URL"。\n' + e.message)
|
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 url = apiUrl(cfg) + '/chat/completions'
|
||||||
const body: Record<string, unknown> = { model: cfg.model || 'glm-4.6', messages, stream: true, temperature: 0.75 }
|
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' }
|
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,
|
const resp = await postJSON(url,
|
||||||
{ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }, body, opts.signal)
|
{ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.key }, body, opts.signal)
|
||||||
return consumeStream(resp, (obj: any) => {
|
return consumeStream(resp, (obj: any) => {
|
||||||
@@ -184,6 +257,16 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
|
|||||||
temperature: 0.75
|
temperature: 0.75
|
||||||
}
|
}
|
||||||
if (sysParts.length) body.system = sysParts.join('\n\n')
|
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 = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-api-key': cfg.key,
|
'x-api-key': cfg.key,
|
||||||
@@ -197,7 +280,7 @@ async function runAnthropic(messages: Message[], opts: StreamOpts, cfg: ReturnTy
|
|||||||
}, opts)
|
}, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用 SSE 消费
|
// 通用 SSE 消费(浏览器 fetch 流)
|
||||||
async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
|
async function consumeStream(resp: Response, extractDelta: (obj: any) => string | null, opts: StreamOpts): Promise<{ json: any; reply: string; op: any }> {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
let t = ''; try { t = await resp.text() } catch (e) {}
|
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 reader = resp.body!.getReader()
|
||||||
const dec = new TextDecoder('utf-8')
|
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 sseBuf = ''
|
||||||
let full = ''
|
let full = ''
|
||||||
let pending = ''
|
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) }
|
function emit(text: string) { if (opts.onVisible) opts.onVisible(text) }
|
||||||
|
|
||||||
for (;;) {
|
return {
|
||||||
const chunk = await reader.read()
|
/** 喂一个网络 chunk(SSE 帧文本,可跨界) */
|
||||||
if (chunk.done) break
|
push(chunk: string) {
|
||||||
sseBuf += dec.decode(chunk.value, { stream: true })
|
sseBuf += chunk.replace(/\r/g, '')
|
||||||
const lines = sseBuf.split('\n')
|
const lines = sseBuf.split('\n')
|
||||||
sseBuf = lines.pop()!
|
sseBuf = lines.pop() || ''
|
||||||
for (let k = 0; k < lines.length; k++) {
|
for (const line of lines) {
|
||||||
const line = lines[k].trim()
|
const l = line.trim()
|
||||||
if (!line || line.indexOf('data:') !== 0) continue
|
if (!l || l.indexOf('data:') !== 0) continue
|
||||||
const payload = line.slice(5).trim()
|
const payload = l.slice(5).trim()
|
||||||
if (!payload || payload === '[DONE]') continue
|
if (!payload || payload === '[DONE]') continue
|
||||||
let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue }
|
let obj: any; try { obj = JSON.parse(payload) } catch (e) { continue }
|
||||||
const delta = extractDelta(obj)
|
const delta = extractDelta(obj)
|
||||||
if (delta != null) feed(delta)
|
if (delta != null) feed(delta)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
// 末帧残留 data: 行补解析
|
/** 流结束:解析残留行并汇总 */
|
||||||
|
finish(): { json: any; reply: string; op: any } {
|
||||||
const tail = sseBuf.trim()
|
const tail = sseBuf.trim()
|
||||||
if (tail.indexOf('data:') === 0) {
|
if (tail.indexOf('data:') === 0) {
|
||||||
const tp = tail.slice(5).trim()
|
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 && !sepMode && pending) emit(pending)
|
||||||
|
|
||||||
if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null }
|
if (opts.jsonMode) return { json: tryParse(full), reply: '', op: null }
|
||||||
const parts = full.split(SEP)
|
const parts = full.split(SEP)
|
||||||
return {
|
return {
|
||||||
@@ -275,6 +372,8 @@ async function consumeStream(resp: Response, extractDelta: (obj: any) => string
|
|||||||
reply: (parts[0] || '').trim(),
|
reply: (parts[0] || '').trim(),
|
||||||
op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null
|
op: parts.length > 1 ? tryParse(parts.slice(1).join(SEP)) : null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryParse(s: string): any {
|
function tryParse(s: string): any {
|
||||||
@@ -555,16 +654,18 @@ export async function generateImage(opts: { prompt: string; signal?: AbortSignal
|
|||||||
const cfg = store.getCfg()
|
const cfg = store.getCfg()
|
||||||
const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '')
|
const imgBase = (cfg.imgBase || cfg.base || '').replace(/\/+$/, '')
|
||||||
const imgKey = cfg.imgKey || cfg.key
|
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,无法生成图片。')
|
if (!imgKey) throw new Error('未配置 API Key,无法生成图片。')
|
||||||
const url = imgBase + '/images/generations'
|
const url = imgBase + '/images/generations'
|
||||||
const body = {
|
const body: Record<string, unknown> = {
|
||||||
model: imgModel,
|
model: imgModel,
|
||||||
prompt: opts.prompt,
|
prompt: opts.prompt,
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024'
|
||||||
response_format: 'b64_json'
|
|
||||||
}
|
}
|
||||||
|
// 智谱 CogView 不支持 response_format 参数(会报错),仅对 OpenAI 兼容网关传 b64_json
|
||||||
|
if (!imgBase.includes('bigmodel.cn')) body.response_format = 'b64_json'
|
||||||
let resp: Response
|
let resp: Response
|
||||||
try {
|
try {
|
||||||
resp = await fetch(url, {
|
resp = await fetch(url, {
|
||||||
@@ -588,6 +689,25 @@ export async function generateImage(opts: { prompt: string; signal?: AbortSignal
|
|||||||
? 'data:image/png;base64,' + item.b64_json
|
? 'data:image/png;base64,' + item.b64_json
|
||||||
: (item.url || '')
|
: (item.url || '')
|
||||||
if (!dataUrl) throw new Error('图片生成返回无图像数据。')
|
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 }
|
return { url: dataUrl, revisedPrompt: item.revised_prompt }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,6 +717,17 @@ export function isImageConfigured(): boolean {
|
|||||||
return !!(cfg.imgKey || cfg.key)
|
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 主题/配色建议 ---------- */
|
/* ---------- 4. AI 主题/配色建议 ---------- */
|
||||||
|
|
||||||
export async function suggestTheme(opts: { topic: string; signal?: AbortSignal }): Promise<ThemeSuggestion> {
|
export async function suggestTheme(opts: { topic: string; signal?: AbortSignal }): Promise<ThemeSuggestion> {
|
||||||
|
|||||||
+57
-30
@@ -1,44 +1,71 @@
|
|||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
* bridge.ts — Tauri IPC 安全调用桥接
|
* bridge.ts — Tauri IPC 桥接(AI 代理专用)
|
||||||
*
|
* 桌面版(Tauri WebView)直连 AI 网关受 CORS 限制,走 Rust 侧 reqwest 代理;
|
||||||
* 安全检测 __TAURI__ 环境,动态 import @tauri-apps/api/core。
|
* Web 版返回 null,调用方回退浏览器 fetch(智谱等支持 CORS 的网关直连可用)
|
||||||
* Web 模式下返回 mock,组件无需关心环境。
|
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
|
|
||||||
/** 动态获取 invoke 函数 */
|
/** 是否运行在 Tauri 桌面环境 */
|
||||||
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
|
export function isTauri(): boolean {
|
||||||
|
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureInvoke() {
|
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null
|
||||||
if (_invoke) return true
|
let _invokeTried = false
|
||||||
if (typeof window === 'undefined' || !('__TAURI__' in window)) return false
|
|
||||||
|
/** 懒加载 Tauri invoke(动态 import,Web 版不会打进包) */
|
||||||
|
async function getInvoke() {
|
||||||
|
if (_invokeTried) return _invoke
|
||||||
|
_invokeTried = true
|
||||||
|
if (!isTauri()) return null
|
||||||
try {
|
try {
|
||||||
const mod = await import('@tauri-apps/api/core')
|
const mod = await import('@tauri-apps/api/core')
|
||||||
_invoke = mod.invoke
|
_invoke = mod.invoke
|
||||||
return true
|
} catch { _invoke = null }
|
||||||
} catch {
|
return _invoke
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface ProxyResp {
|
||||||
* 安全调用 Tauri IPC 命令。
|
status: number
|
||||||
* Web 模式下返回 undefined(调用方需处理)。
|
body: string
|
||||||
*/
|
error?: string
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册 Tauri 事件监听(用于 Rust → 前端推送) */
|
/** 非流式代理:Rust 侧 fetch,返回 {status, body};非桌面环境返回 null */
|
||||||
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<() => void> {
|
export async function aiProxy(url: string, apiKey: string, body: string): Promise<ProxyResp | null> {
|
||||||
if (typeof window === 'undefined' || !('__TAURI__' in window)) {
|
const invoke = await getInvoke()
|
||||||
return () => {} // 空注销函数
|
if (!invoke) return null
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { listen } = await import('@tauri-apps/api/event')
|
const r = await invoke('ai_proxy', { url, apiKey, body }) as ProxyResp
|
||||||
return listen(event, (e: { payload: T }) => handler(e.payload))
|
return r
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
return () => {}
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 renderer:json 格式化+着色,其余转义 ---------- */
|
||||||
|
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
@@ -73,6 +73,31 @@
|
|||||||
|
|
||||||
.msg .bubble .stream-text { white-space: pre-wrap; }
|
.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 {
|
.msg .msg-code {
|
||||||
margin: 6px 0; padding: 10px 12px;
|
margin: 6px 0; padding: 10px 12px;
|
||||||
@@ -89,12 +114,18 @@
|
|||||||
.msg .msg-code .jn { color: #d97706; } /* number */
|
.msg .msg-code .jn { color: #d97706; } /* number */
|
||||||
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
|
.msg .msg-code .jb { color: #e11d48; font-weight: 600; } /* boolean/null */
|
||||||
|
|
||||||
|
/* 流式打字机光标(参考 devflow:细竖线 + 柔和步进闪烁) */
|
||||||
.cursor {
|
.cursor {
|
||||||
display: inline-block; width: 7px; height: 1em;
|
display: inline-block;
|
||||||
background: currentColor; vertical-align: -2px; margin-left: 2px;
|
width: 2px; height: 14px;
|
||||||
animation: blink 1s steps(2, start) infinite;
|
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 {
|
.chat-input-bar {
|
||||||
|
|||||||
+13
-3
@@ -6,9 +6,19 @@ export default defineConfig(({ mode }) => ({
|
|||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 800,
|
chunkSizeWarningLimit: 800,
|
||||||
},
|
},
|
||||||
// npm run dev(mode=web)→ 浏览器模式,端口 8080
|
// npm run dev:web(mode=web)→ 浏览器模式,端口 8080
|
||||||
// npm run tauri:dev → 默认 mode,端口 5173
|
// npm run tauri:dev → 默认 mode,端口 5173
|
||||||
server: mode === 'web'
|
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/target:Cargo 编译期间写 dll 会触发 EBUSY 崩掉 watcher
|
||||||
|
watch: { ignored: ['**/src-tauri/target/**'] },
|
||||||
|
},
|
||||||
|
// 桌面模式 HMR 由 tauri-cli 管理,无需自动打开浏览器
|
||||||
|
clearScreen: false,
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user