新增: 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
+104 -88
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) {
s.msg.streaming = false
if (!s.msg.content) s.msg.content = ''
/** 流结束:标记待冲刷(缓冲剩余字符继续匀速放完再收尾),可选收尾回调 */
function streamFlush(s: StreamCtrl, onSettled?: () => void) {
s.settled = onSettled || null
s.flushPending = true
if (!s.buffer.length) {
// 缓冲已空:立即收尾
s.msg.streaming = false
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)
}
persistStream(stream)
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">