系列 :《从零构建跨端 AI 对话系统》
前置 :第 1、2 篇的项目骨架和 SSE 接入
目标:构建一个生产级消息气泡组件,支持 Markdown、代码块高亮、公式混排、状态机、长消息折叠、消息操作栏
一、一个气泡到底要处理多少东西?
把 AI 返回的一条消息拆开看:
你好!这是一道关于**勾股定理**的题目。
根据公式 $a^2 + b^2 = c^2$,代入数据:
$$c = \sqrt{3^2 + 4^2} = \sqrt{25} = 5$$
用 Python 验证一下:
```python
import math
c = math.sqrt(3**2 + 4**2)
print(f"斜边长度: {c}") # 输出: 5.0
``` ← 注意这里反引号是内容的一部分
所以答案是 **5**。
这一条消息里包含:
- 纯文本 + Markdown 粗体
- 行内公式 (
$...$) - 块级公式 (
$$...$$) - 代码块 (带语言标识的 ```````python```` )
- 混排在一起
气泡组件必须正确处理所有这些,而且是在流式输入过程中。
二、消息状态机
在写渲染逻辑之前,先定义清楚一条消息的生命周期:
┌─────────┐
│ loading │ 用户刚发送,AI 还没回第一个字
└────┬─────┘
│ 收到第一个 chunk
▼
┌───────────┐
│ streaming │ AI 正在输出,内容持续增长
└─────┬──────┘
│ ┌──────────┐
├─ 正常结束 ────────→ │ done │ 输出完毕
│ └──────────┘
│ ┌──────────┐
├─ 网络错误 ────────→ │ error │ 出错了
│ └──────────┘
│ ┌──────────┐
└─ 用户中断 ────────→ │ aborted │ 手动停止
└──────────┘
每个状态对应不同的 UI 表现:
| 状态 | 内容区 | 操作栏 | 光标 |
|---|---|---|---|
loading |
加载动画(三个跳动的点) | 无 | 无 |
streaming |
实时内容 + Markdown 渲染 | 「停止」按钮 | 闪烁光标 |
done |
完整内容 + 公式/代码高亮 | 复制 / 重新生成 / 点赞 | 无 |
error |
错误信息 | 重试按钮 | 无 |
aborted |
已生成的部分内容 | 继续生成 / 复制 | 无 |
三、安装依赖
bash
npm install marked highlight.js dompurify
| 库 | 用途 |
|---|---|
marked |
Markdown → HTML |
highlight.js |
代码块语法高亮 |
dompurify |
防 XSS,净化 HTML |
四、Markdown 渲染器:流式安全版
AI 流式输出 Markdown 时,会出现未闭合标签,比如:
流式过程中的内容: "这是 **加粗文" ← 只有开始的 ** 没有结束的 **
marked 解析结果: "这是 **加粗文" ← marked 不闭合就原样输出,还行
但如果是: "这是 <b>加粗文" ← HTML 标签未闭合就危险了
更严重的是代码块:
流式中: "```python\nprint("hello" ← 反引号未闭合
marked: <code>python\nprint("hello" ← 整个后续内容都被吃进代码块
我们需要一个流式安全的 Markdown 渲染器。
src/utils/markdownRenderer.js
js
import { marked } from 'marked'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'
/**
* 配置 marked
*/
const renderer = new marked.Renderer()
// 代码块:添加语言标签和复制按钮占位
renderer.code = function ({ text, lang }) {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'
const highlighted = hljs.highlight(text, { language }).value
return `<div class="code-block">
<div class="code-header">
<span class="code-lang">${language}</span>
<button class="code-copy-btn" data-code="${encodeURIComponent(text)}">复制</button>
</div>
<pre><code class="hljs language-${language}">${highlighted}</code></pre>
</div>`
}
// 链接:新窗口打开
renderer.link = function ({ href, title, text }) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer" title="${title || ''}">${text}</a>`
}
marked.setOptions({
renderer,
breaks: true, // 换行符转 <br>
gfm: true, // GitHub Flavored Markdown
pedantic: false,
})
/**
* 流式安全的 Markdown 渲染
*
* 核心问题:流式输入中可能有未闭合的标记
* 策略:检测并临时闭合未完成的代码块和公式
*
* @param {string} raw - 原始 Markdown 文本
* @param {boolean} isStreaming - 是否还在流式输入中
* @returns {string} 安全的 HTML
*/
export function renderMarkdown(raw, isStreaming = false) {
if (!raw) return ''
let text = raw
if (isStreaming) {
text = patchUnclosed(text)
}
// 1. 保护公式不被 marked 处理
const { text: protectedText, formulas } = protectFormulas(text)
// 2. marked 渲染 Markdown
let html = marked.parse(protectedText)
// 3. 还原公式占位符
html = restoreFormulas(html, formulas)
// 4. DOMPurify 防 XSS
html = DOMPurify.sanitize(html, {
ADD_TAGS: ['mjx-container', 'mjx-math', 'mjx-mrow', 'mjx-mi', 'mjx-mo',
'mjx-mn', 'mjx-msup', 'mjx-msqrt', 'mjx-mfrac', 'mjx-munder',
'mjx-mover', 'mjx-mtable', 'mjx-mtr', 'mjx-mtd'],
ADD_ATTR: ['class', 'style', 'data-code'],
})
return html
}
/**
* 修补未闭合的 Markdown 标记
* 仅在流式输出中使用
*/
function patchUnclosed(text) {
// 1. 检测未闭合的代码块 ```
const codeBlockCount = (text.match(/```/g) || []).length
if (codeBlockCount % 2 !== 0) {
// 奇数个 ```说明有未闭合的代码块
// 追加一个闭合标记,让 marked 正确解析
text += '\n```'
}
// 2. 检测未闭合的行内代码 `
// 简单处理:不修补,marked 对单个 ` 的处理还算合理
// 3. 未闭合的粗体/斜体在流式中影响不大,marked 会原样输出
return text
}
/**
* 保护公式:将 $...$ 和 $$...$$ 替换为占位符
* 避免 marked 把公式中的 _ 当做斜体、* 当做粗体
*/
function protectFormulas(text) {
const formulas = []
let index = 0
// 先处理块级公式 $$...$$
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (match) => {
const placeholder = `%%FORMULA_${index}%%`
formulas[index] = match
index++
return placeholder
})
// 再处理行内公式 $...$
// 注意:不能匹配 \$(转义的美元符号)
text = text.replace(/(?<!\\)\$([^\$\n]+?)\$/g, (match) => {
const placeholder = `%%FORMULA_${index}%%`
formulas[index] = match
index++
return placeholder
})
// 处理 \(...\) 和 \[...\]
text = text.replace(/\\\([\s\S]*?\\\)/g, (match) => {
const placeholder = `%%FORMULA_${index}%%`
formulas[index] = match
index++
return placeholder
})
text = text.replace(/\\\[[\s\S]*?\\\]/g, (match) => {
const placeholder = `%%FORMULA_${index}%%`
formulas[index] = match
index++
return placeholder
})
return { text, formulas }
}
/**
* 还原公式占位符
*/
function restoreFormulas(html, formulas) {
for (let i = 0; i < formulas.length; i++) {
html = html.replace(`%%FORMULA_${i}%%`, formulas[i])
}
return html
}
为什么要「保护公式」?
Markdown 和 LaTeX 的语法有冲突:
markdown
公式: $a_1 + b_2$
marked 的理解: $a<em>1 + b</em>2$
↑ _ 被当成斜体了!
保护后: %%FORMULA_0%% ← marked 看不到公式
marked 输出: %%FORMULA_0%%
还原后: $a_1 + b_2$ ← 安全地保留给 MathJax 处理
五、消息气泡组件
src/components/MessageBubble.vue
vue
<template>
<div class="bubble-row" :class="[`bubble-row--${msg.role}`]">
<!-- 头像 -->
<div class="avatar" :class="[`avatar--${msg.role}`]">
{{ msg.role === 'ai' ? '🤖' : '👤' }}
</div>
<!-- 气泡主体 -->
<div class="bubble" :class="[`bubble--${msg.role}`, `bubble--${msg.status}`]">
<!-- ======= loading 状态 ======= -->
<div v-if="msg.status === 'loading'" class="loading-dots">
<span></span><span></span><span></span>
</div>
<!-- ======= 内容区 ======= -->
<div v-else class="bubble-body">
<!-- AI 消息:Markdown 渲染 -->
<div
v-if="msg.role === 'ai'"
ref="contentRef"
class="bubble-content markdown-body"
@click="onContentClick"
>
<!-- 冻结区:MathJax 渲染后的内容,Vue 不碰 -->
<span ref="frozenRef"></span>
<!-- 流式区:未处理的尾部 -->
<span v-html="pendingHtml"></span>
<!-- 流式光标 -->
<span v-if="msg.streaming" class="cursor">|</span>
</div>
<!-- 用户消息:纯文本 -->
<div v-else class="bubble-content user-content">
{{ msg.content }}
</div>
<!-- 错误状态 -->
<div v-if="msg.status === 'error'" class="bubble-error">
<span>⚠️ {{ msg.error?.message || '发送失败' }}</span>
<button @click="$emit('retry', msg)">重试</button>
</div>
<!-- 中断状态 -->
<div v-if="msg.status === 'aborted'" class="bubble-aborted">
<span>⏸️ 已停止生成</span>
</div>
</div>
<!-- ======= 操作栏 ======= -->
<MessageActions
v-if="msg.status === 'done' || msg.status === 'aborted'"
:msg="msg"
:isMobile="isMobile"
@copy="copyContent"
@regenerate="$emit('regenerate', msg)"
@thumbUp="$emit('thumbUp', msg)"
@thumbDown="$emit('thumbDown', msg)"
/>
<!-- Token 用量(可选显示) -->
<div v-if="msg.usage && msg.status === 'done'" class="token-usage">
{{ msg.usage.total_tokens }} tokens
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue'
import { renderMarkdown } from '../utils/markdownRenderer'
import MessageActions from './MessageActions.vue'
const props = defineProps({
msg: { type: Object, required: true },
isMobile: { type: Boolean, default: false },
})
defineEmits(['retry', 'regenerate', 'thumbUp', 'thumbDown'])
// ---- 双区域渲染相关 ----
const contentRef = ref(null)
const frozenRef = ref(null)
const pendingHtml = ref('')
let processedIndex = 0
let freezeTimer = null
// ---- 流式内容 watch ----
watch(
() => props.msg.content,
(newVal) => {
if (!newVal || props.msg.role !== 'ai') return
if (props.msg.done) return
const tail = newVal.substring(processedIndex)
const safeLen = findSafeSplit(tail)
if (safeLen > 0 && safeLen < tail.length) {
// 有完整公式/代码块可以冻结
doFreeze(newVal, processedIndex + safeLen)
} else if (safeLen === tail.length) {
// 全部安全,节流冻结
pendingHtml.value = renderMarkdown(tail, true)
scheduleFreeze()
} else {
// 有未闭合标记,留在流式区
pendingHtml.value = renderMarkdown(tail, true)
}
}
)
// ---- 流式结束 ----
watch(
() => props.msg.done,
(val) => {
if (val && props.msg.role === 'ai') {
clearTimeout(freezeTimer)
pendingHtml.value = ''
nextTick(() => {
const remaining = (props.msg.content || '').substring(processedIndex)
if (remaining) {
appendToFrozen(remaining, true)
} else if (frozenRef.value) {
typesetElement(frozenRef.value)
}
processedIndex = props.msg.content?.length || 0
})
}
}
)
// ---- 历史消息(mounted 时已完成) ----
onMounted(() => {
if (props.msg.role === 'ai' && props.msg.done && props.msg.content) {
nextTick(() => {
if (frozenRef.value) {
frozenRef.value.innerHTML = renderMarkdown(props.msg.content, false)
processedIndex = props.msg.content.length
typesetElement(frozenRef.value)
}
})
}
})
// ---- 冻结方法 ----
function appendToFrozen(rawText, shouldTypeset = false) {
if (!frozenRef.value) return
const span = document.createElement('span')
span.innerHTML = renderMarkdown(rawText, false)
frozenRef.value.appendChild(span)
if (shouldTypeset || hasFormula(rawText)) {
typesetElement(span)
}
}
function doFreeze(fullContent, upTo) {
const toFreeze = fullContent.substring(processedIndex, upTo)
processedIndex = upTo
const tail = fullContent.substring(upTo)
pendingHtml.value = renderMarkdown(tail, true)
appendToFrozen(toFreeze)
}
function scheduleFreeze() {
clearTimeout(freezeTimer)
freezeTimer = setTimeout(() => {
const content = props.msg.content || ''
const tail = content.substring(processedIndex)
if (!tail) return
const safeLen = findSafeSplit(tail)
if (safeLen > 0) {
doFreeze(content, processedIndex + safeLen)
}
}, 500)
}
// ---- MathJax ----
function typesetElement(el) {
if (!window.MathJax || !el) return
try {
window.MathJax.typesetPromise([el]).catch(() => {})
} catch {}
}
function hasFormula(text) {
return /\$|\\\(|\\\[/.test(text)
}
// ---- 公式边界检测(同第 4 篇详解) ----
function findSafeSplit(text) {
let i = 0, lastSafe = 0
while (i < text.length) {
if (text.startsWith('$$', i)) {
const end = text.indexOf('$$', i + 2)
if (end === -1) return lastSafe
i = end + 2; lastSafe = i
} else if (text[i] === '$' && (i === 0 || text[i - 1] !== '\\')) {
const end = text.indexOf('$', i + 1)
if (end === -1) return lastSafe
i = end + 1; lastSafe = i
} else if (text.startsWith('\\(', i)) {
const end = text.indexOf('\\)', i + 2)
if (end === -1) return lastSafe
i = end + 2; lastSafe = i
} else if (text.startsWith('\\[', i)) {
const end = text.indexOf('\\]', i + 2)
if (end === -1) return lastSafe
i = end + 2; lastSafe = i
} else if (text.startsWith('```', i)) {
const end = text.indexOf('```', i + 3)
if (end === -1) return lastSafe
i = end + 3; lastSafe = i
} else {
i++; lastSafe = i
}
}
return lastSafe
}
// ---- 代码块复制(事件委托) ----
function onContentClick(e) {
const btn = e.target.closest('.code-copy-btn')
if (!btn) return
const code = decodeURIComponent(btn.dataset.code || '')
copyToClipboard(code)
btn.textContent = '已复制 ✓'
setTimeout(() => { btn.textContent = '复制' }, 2000)
}
// ---- 复制 ----
function copyContent() {
copyToClipboard(props.msg.content)
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text)
} catch {
// 降级:textarea 复制法(兼容移动端 WebView)
const ta = document.createElement('textarea')
ta.value = text
ta.style.cssText = 'position:fixed;left:-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
}
</script>
<style scoped>
/* ====== 行布局 ====== */
.bubble-row {
display: flex;
gap: 10px;
padding: 16px;
align-items: flex-start;
}
.bubble-row--user {
flex-direction: row-reverse;
}
/* ====== 头像 ====== */
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.avatar--ai { background: #e0f2fe; }
.avatar--user { background: #dcfce7; }
/* ====== 气泡 ====== */
.bubble {
max-width: 75%;
border-radius: 12px;
position: relative;
}
.bubble--ai {
background: #f8f9fa;
border: 1px solid #e5e7eb;
padding: 12px 16px;
border-bottom-left-radius: 4px;
}
.bubble--user {
background: #3b82f6;
color: white;
padding: 10px 14px;
border-bottom-right-radius: 4px;
}
.bubble--error {
border-color: #fca5a5;
background: #fef2f2;
}
/* ====== Markdown 内容样式 ====== */
.markdown-body {
font-size: 14px;
line-height: 1.7;
word-break: break-word;
}
.markdown-body :deep(p) {
margin: 0.5em 0;
}
.markdown-body :deep(p:first-child) {
margin-top: 0;
}
.markdown-body :deep(p:last-child) {
margin-bottom: 0;
}
.markdown-body :deep(strong) {
font-weight: 600;
}
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
padding-left: 1.5em;
margin: 0.5em 0;
}
.markdown-body :deep(li) {
margin: 0.25em 0;
}
.markdown-body :deep(blockquote) {
border-left: 3px solid #d1d5db;
padding-left: 12px;
margin: 0.5em 0;
color: #6b7280;
}
.markdown-body :deep(a) {
color: #2563eb;
text-decoration: none;
}
.markdown-body :deep(a:hover) {
text-decoration: underline;
}
.markdown-body :deep(hr) {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1em 0;
}
.markdown-body :deep(table) {
border-collapse: collapse;
margin: 0.5em 0;
font-size: 13px;
width: 100%;
overflow-x: auto;
display: block;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
border: 1px solid #d1d5db;
padding: 6px 12px;
text-align: left;
}
.markdown-body :deep(th) {
background: #f3f4f6;
font-weight: 600;
}
/* ====== 代码块 ====== */
.markdown-body :deep(.code-block) {
margin: 0.75em 0;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.markdown-body :deep(.code-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background: #f3f4f6;
border-bottom: 1px solid #e5e7eb;
font-size: 12px;
}
.markdown-body :deep(.code-lang) {
color: #6b7280;
font-weight: 500;
}
.markdown-body :deep(.code-copy-btn) {
background: none;
border: 1px solid #d1d5db;
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
color: #374151;
transition: all 0.15s;
}
.markdown-body :deep(.code-copy-btn:hover) {
background: #e5e7eb;
}
.markdown-body :deep(pre) {
margin: 0;
padding: 12px;
overflow-x: auto;
background: #fafafa;
}
.markdown-body :deep(code) {
font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
}
/* 行内代码 */
.markdown-body :deep(:not(pre) > code) {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
color: #d63384;
}
/* ====== 用户消息 ====== */
.user-content {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
/* ====== 光标 ====== */
.cursor {
display: inline-block;
margin-left: 2px;
font-weight: 400;
animation: blink 0.7s infinite;
}
@keyframes blink {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
/* ====== Loading 动画 ====== */
.loading-dots {
display: flex;
gap: 4px;
padding: 8px 0;
}
.loading-dots span {
width: 8px;
height: 8px;
background: #9ca3af;
border-radius: 50%;
animation: dotBounce 1.4s infinite both;
}
.loading-dots span:nth-child(2) { animation-delay: 0.16s; }
.loading-dots span:nth-child(3) { animation-delay: 0.32s; }
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ====== 错误 / 中断 ====== */
.bubble-error,
.bubble-aborted {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.bubble-error { color: #dc2626; }
.bubble-aborted { color: #6b7280; }
.bubble-error button {
background: none;
border: 1px solid #dc2626;
color: #dc2626;
border-radius: 4px;
padding: 2px 10px;
cursor: pointer;
font-size: 12px;
}
/* ====== Token 用量 ====== */
.token-usage {
font-size: 11px;
color: #9ca3af;
margin-top: 6px;
text-align: right;
}
</style>
六、消息操作栏组件
PC 端 hover 显示,移动端长按显示。
src/components/MessageActions.vue
vue
<template>
<div
class="msg-actions"
:class="{ 'msg-actions--visible': visible }"
v-if="msg.role === 'ai'"
>
<button
v-for="action in actions"
:key="action.key"
class="action-btn"
:title="action.label"
@click="$emit(action.key, msg)"
>
{{ action.icon }} <span class="action-label">{{ action.label }}</span>
</button>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
const props = defineProps({
msg: { type: Object, required: true },
isMobile: { type: Boolean, default: false },
})
defineEmits(['copy', 'regenerate', 'thumbUp', 'thumbDown'])
const visible = ref(false)
let longPressTimer = null
const actions = computed(() => {
const list = [
{ key: 'copy', icon: '📋', label: '复制' },
{ key: 'regenerate', icon: '🔄', label: '重新生成' },
{ key: 'thumbUp', icon: '👍', label: '有用' },
{ key: 'thumbDown', icon: '👎', label: '无用' },
]
return list
})
onMounted(() => {
const bubble = getBubbleEl()
if (!bubble) return
if (props.isMobile) {
// 移动端:长按显示
bubble.addEventListener('touchstart', onTouchStart, { passive: true })
bubble.addEventListener('touchend', onTouchEnd)
bubble.addEventListener('touchmove', onTouchEnd)
} else {
// PC 端:hover 显示
bubble.addEventListener('mouseenter', () => { visible.value = true })
bubble.addEventListener('mouseleave', () => { visible.value = false })
}
})
onBeforeUnmount(() => {
clearTimeout(longPressTimer)
})
function getBubbleEl() {
// 向上找到 .bubble 元素
let el = document.querySelector(`[data-msg-id="${props.msg.id}"]`)
return el
}
function onTouchStart() {
longPressTimer = setTimeout(() => {
visible.value = true
}, 500)
}
function onTouchEnd() {
clearTimeout(longPressTimer)
// 3 秒后自动隐藏
if (visible.value) {
setTimeout(() => { visible.value = false }, 3000)
}
}
</script>
<style scoped>
.msg-actions {
display: flex;
gap: 2px;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #f3f4f6;
opacity: 0;
transition: opacity 0.15s;
}
.msg-actions--visible {
opacity: 1;
}
/* PC 端 hover 父元素时显示 */
:global(.bubble:hover) .msg-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
background: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #6b7280;
transition: all 0.15s;
}
.action-btn:hover {
background: #f3f4f6;
color: #374151;
}
.action-label {
font-size: 12px;
}
/* 移动端隐藏文字标签,只显示图标 */
@media (max-width: 767px) {
.action-label {
display: none;
}
.action-btn {
font-size: 16px;
padding: 6px 10px;
}
}
</style>
七、长消息折叠
AI 有时候会输出非常长的内容,需要提供折叠功能。
src/composables/useCollapse.js
js
import { ref, onMounted, nextTick, watch } from 'vue'
/**
* 长内容折叠
*
* @param {Object} options
* @param {import('vue').Ref} options.contentRef - 内容元素 ref
* @param {number} options.maxHeight - 折叠高度(px),默认 300
* @param {import('vue').Ref<boolean>} options.done - 消息是否完成
*/
export function useCollapse({ contentRef, maxHeight = 300, done }) {
const isExpanded = ref(false)
const needsCollapse = ref(false)
const contentHeight = ref(0)
function checkHeight() {
if (!contentRef.value) return
const el = contentRef.value
contentHeight.value = el.scrollHeight
needsCollapse.value = el.scrollHeight > maxHeight
}
// 内容完成后检测高度
watch(done, (val) => {
if (val) {
nextTick(() => {
// 等 MathJax 渲染完再检测
setTimeout(checkHeight, 500)
})
}
})
onMounted(() => {
if (done?.value) {
nextTick(checkHeight)
}
})
function toggle() {
isExpanded.value = !isExpanded.value
}
return {
isExpanded,
needsCollapse,
toggle,
collapseStyle: () => {
if (!needsCollapse.value || isExpanded.value) return {}
return {
maxHeight: maxHeight + 'px',
overflow: 'hidden',
}
},
}
}
在 MessageBubble.vue 中使用:
vue
<!-- 在 bubble-body 上加折叠 -->
<div class="bubble-body" :style="collapseStyle()">
<!-- ... 内容 ... -->
</div>
<!-- 折叠按钮 -->
<button
v-if="needsCollapse"
class="collapse-btn"
@click="toggle"
>
{{ isExpanded ? '收起 ▲' : '展开全文 ▼' }}
</button>
js
// script setup 中
import { useCollapse } from '../composables/useCollapse'
import { toRef } from 'vue'
const { isExpanded, needsCollapse, toggle, collapseStyle } = useCollapse({
contentRef,
maxHeight: 300,
done: toRef(() => props.msg.done),
})
css
/* 折叠过渡 */
.bubble-body {
transition: max-height 0.3s ease;
position: relative;
}
/* 折叠时底部渐隐 */
.bubble-body[style*="max-height"]::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(transparent, #f8f9fa);
pointer-events: none;
}
.collapse-btn {
display: block;
width: 100%;
padding: 6px;
border: none;
background: none;
color: #3b82f6;
font-size: 13px;
cursor: pointer;
text-align: center;
}
.collapse-btn:hover {
text-decoration: underline;
}
八、highlight.js 样式引入
在 main.js 中引入代码高亮主题:
js
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
// 代码高亮主题(选一个你喜欢的)
import 'highlight.js/styles/github.css'
// 其他可选:
// import 'highlight.js/styles/atom-one-dark.css'
// import 'highlight.js/styles/vs2015.css'
createApp(App).mount('#app')
九、完整渲染流程图
原始内容 (流式拼接中)
│
├─ 角色 = user?
│ └── 直接显示纯文本,不做任何处理
│
└─ 角色 = ai
│
▼
findSafeSplit(tail) ← 检测公式 $ $$ \( \[ 和代码块 ```的闭合
│
├── 安全部分 → appendToFrozen()
│ │
│ ├── renderMarkdown(text, false)
│ │ │
│ │ ├── protectFormulas() ← $..$ → 占位符
│ │ ├── marked.parse() ← Markdown → HTML
│ │ ├── restoreFormulas() ← 占位符 → $..$
│ │ └── DOMPurify.sanitize()← 防 XSS
│ │
│ ├── frozenRef.appendChild(span)
│ │
│ └── hasFormula? → MathJax.typesetPromise([span])
│
└── 不安全尾部 → pendingHtml = renderMarkdown(tail, true)
│
└── patchUnclosed() ← 临时闭合 ```等
│
└── v-html 更新(Vue 管理)
─── msg.done = true ───
剩余内容全部 → appendToFrozen(remaining, true)
MathJax 最终 typeset
十、本篇核心要点
| 要点 | 实现 |
|---|---|
| 状态机 | loading → streaming → done / error / aborted,每个状态独立 UI |
| Markdown 渲染 | marked + 公式保护 + 代码高亮 + DOMPurify |
| 流式安全 | patchUnclosed() 临时闭合代码块,findSafeSplit() 增加 ```检测 |
| 公式不闪烁 | 双区域渲染:ref="frozen" + v-html="pending" |
| 代码块复制 | renderer.code 自定义 + 事件委托 + clipboard API 降级 |
| 操作栏适配 | PC hover 显示,移动端长按显示 |
| 长消息折叠 | scrollHeight 检测 + max-height 过渡 + 渐隐遮罩 |
| XSS 防护 | DOMPurify 白名单放行 MathJax 标签 |
下一篇预告
第 4 篇:数学公式渲染 ------ 双区域渲染解决流式 + MathJax 的冲突
深入讲解 [findSafeSplit]状态机算法、MathJax 3 的初始化与配置、typesetPromise vs typeset 的区别、KaTeX vs MathJax 选型、以及 Vue 2 / Vue 3 / React 三套实现。