第 3 篇:消息气泡组件 —— 远比你想的复杂

系列 :《从零构建跨端 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 三套实现。

相关推荐
一晌小贪欢1 小时前
Python在物联网(IoT)中的应用:从边缘计算到云端数据处理
开发语言·人工智能·python·物联网·边缘计算
你的冰西瓜2 小时前
C++中的priority_queue容器详解
开发语言·c++·stl
H Corey2 小时前
Java字符串操作全解析
java·开发语言·学习·intellij-idea
1314lay_10072 小时前
color: var(--el-color-success); CSS里面使用函数
前端·css
墨染青竹梦悠然2 小时前
基于SpringBoot + vue的农产品销售系统(华夏鲜仓)
vue.js·spring boot·python·django·毕业设计·毕设
爱上妖精的尾巴2 小时前
8-7 WPS JS宏 正则表达式 元字符应用-提取连续数字
javascript·wps·jsa
brucelee1862 小时前
Java 开发AWS Lambda 实战指南(SAM CLI + IntelliJ)
java·开发语言
xlq223222 小时前
16.环境变量与地址空间
前端·chrome