新增: AI 助手 Markdown 渲染+打字机流式+桌面 Rust 代理修复 CORS
This commit is contained in:
+104
-88
@@ -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, '&').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<{
|
||||
(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">
|
||||
|
||||
Reference in New Issue
Block a user