AI 聊天与流式输出
当你在做聊天界面、token 流式输出、SSE 响应预览,或者任何“用户正在看着内容持续变化”的 Markdown 场景时,就走这条路径。
如果你的页面其实是静态文章、文档站或低频更新页面,请回到 使用与流式渲染,优先使用更简单的 content 路径。
1. 先选最小安装组合
| 需求 | 安装包 | 适合场景 |
|---|---|---|
| 纯文本或轻量聊天界面 | markstream-vue | 基础 Markdown、列表、链接、引用 |
| 轻量代码高亮 | markstream-vue stream-markdown | SSR 友好的聊天记录、较小 bundle |
| 更强的代码交互 | markstream-vue stream-diffs | 复制、预览、语法高亮和 File/Diff surface |
| 聊天内容里有图表或公式 | markstream-vue mermaid katex | Mermaid 图表和 KaTeX 公式 |
只安装你预期回复里真的会出现的能力,对聊天界面的收益通常很大。
2. 推荐的数据流
对于高频 token 流,使用 MarkdownRender 内置的 smooth pacing。
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { ref } from 'vue'
const streamedText = ref('')
const final = ref(false)
</script>
<template>
<MarkdownRender
custom-id="chat"
:content="streamedText"
:final="final"
:max-live-nodes="0"
:batch-rendering="true"
:render-batch-size="16"
:render-batch-delay="8"
:render-batch-budget-ms="4"
:fade="false"
:typewriter="true"
/>
</template>这样做的好处:
- Incoming chunk 可能是突发式的,但可见输出可以保持平稳。
- Backlog-aware pacing 在积压文本增多时会自动加速。
- 最终解析会等到可见内容追上后再触发,避免流结束时的不稳定状态。
custom-id="chat"给了你一个安全的作用域,用来定制聊天界面样式或替换单个节点。- 默认
smooth-streaming="auto"已经在typewriter开启或max-live-nodes <= 0时自动启用 smooth pacing。只有在需要首屏内容也从空白开始 pacing 时才用:smooth-streaming="true"——这会跳过 mounted 门控,在 SSR 场景下可能导致 hydration 不匹配或空白闪烁。
如果某个渲染面需要原始 chunk 节奏,可以用 :smooth-streaming="false" 关闭。如果你已经在 worker/store 中自行解析并需要 AST 控制,可以继续用 nodes + final。
3. 这几个渲染配置通常最稳
- 长聊天记录优先保留默认虚拟化;只有在你有明确性能测量时再去调
maxLiveNodes。 - 如果代码块很多,但增强代码 surface 对当前聊天界面太重,可以先用
renderCodeBlocksAsPre降级。 - 重型 peers 先别全装。聊天类页面最容易从“不默认带 Mermaid、KaTeX、
stream-diffs”里拿到体积收益。 - 如果你关闭虚拟化(
:max-live-nodes="0"),那 Props 与选项 里的 batching 相关配置就会更重要。
4. 常见升级路径
直播聊天自动滚到底,但不要每个 token 都写滚动
如果聊天窗口要跟随最新 assistant 输出,滚动写入要用 requestAnimationFrame 合并,而且只在用户本来就接近底部时才跟随。不要在每个 chunk 上调用 scrollIntoView({ behavior: 'smooth' });流式输出时这会制造一堆互相打断的滚动动画。
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
final: boolean
}
const messages = ref<ChatMessage[]>([])
const scrollRoot = ref<HTMLElement | null>(null)
const contentRoot = ref<HTMLElement | null>(null)
const bottomPinned = ref(true)
const BOTTOM_THRESHOLD_PX = 64
let scrollFrame = 0
let resizeObserver: ResizeObserver | undefined
const latestOutputSignal = computed(() => {
const latest = messages.value[messages.value.length - 1]
return latest
? `${messages.value.length}:${latest.id}:${latest.content.length}:${latest.final}`
: '0'
})
function isNearBottom(element: HTMLElement) {
return element.scrollHeight - element.clientHeight - element.scrollTop <= BOTTOM_THRESHOLD_PX
}
function updateBottomPinned() {
const root = scrollRoot.value
bottomPinned.value = !root || isNearBottom(root)
}
function scrollToBottomNow() {
const root = scrollRoot.value
if (!root)
return
root.scrollTo({
top: root.scrollHeight,
behavior: 'auto',
})
}
function scheduleScrollToBottom() {
if (!bottomPinned.value || scrollFrame)
return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = 0
if (!bottomPinned.value)
return
scrollToBottomNow()
})
}
watch(
latestOutputSignal,
async () => {
await nextTick()
scheduleScrollToBottom()
},
{ flush: 'post' },
)
onMounted(() => {
updateBottomPinned()
resizeObserver = new ResizeObserver(scheduleScrollToBottom)
if (contentRoot.value)
resizeObserver.observe(contentRoot.value)
})
onBeforeUnmount(() => {
if (scrollFrame)
cancelAnimationFrame(scrollFrame)
resizeObserver?.disconnect()
})
</script>
<template>
<div ref="scrollRoot" class="chat-scroll" @scroll.passive="updateBottomPinned">
<div ref="contentRoot" class="chat-list">
<article
v-for="message in messages"
:key="message.id"
class="chat-message"
:class="`chat-message--${message.role}`"
>
<MarkdownRender
v-if="message.role === 'assistant'"
custom-id="chat"
mode="chat"
:content="message.content"
:final="message.final"
:smooth-streaming="message.final ? false : 'auto'"
:fade="message.final"
:typewriter="!message.final"
v-bind="message.final ? {} : { maxLiveNodes: 0 }"
/>
<p v-else class="user-text">
{{ message.content }}
</p>
</article>
</div>
</div>
</template>
<style scoped>
.chat-scroll {
height: min(70vh, 720px);
overflow: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.chat-list {
display: flex;
min-height: 100%;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.chat-message {
max-width: min(720px, 88%);
}
.chat-message--user {
align-self: flex-end;
}
.chat-message--assistant {
align-self: flex-start;
}
</style>关键点是:
latestOutputSignal只关注最后一条消息,避免长聊天记录在每个 token 上跑 deep watch。nextTick()等当前 Vue render pass 完成后再读取scrollHeight;后续 smooth-streaming 帧的高度变化由ResizeObserver继续处理。requestAnimationFrame把多次 chunk 更新合并成每帧最多一次滚动写入。ResizeObserver处理图片、KaTeX、代码块、字体等异步高度变化;前提是用户仍然贴底。bottomPinned会在用户向上翻历史时关闭自动跟随,等用户回到底部附近再恢复。- 如果挂载时已经有完整历史消息,要单独决定首帧跳到最新消息,还是恢复已保存的滚动位置。
长的混合 timeline 优先用内置虚拟列表。默认 stick-to-bottom="auto" 是同一类产品行为:贴底时跟随,用户手动翻历史时不抢滚动。
<MarkstreamVirtualTimeline
:items="timelineItems"
:thread-key="activeThreadId"
stick-to-bottom="auto"
/>更好的代码块
- 想要更轻的文档风格:用
MarkdownCodeBlockNode,配stream-markdown - 想要更强的预览 / diff / 交互:用
CodeBlockNode,配stream-diffs
具体差异看 渲染器与节点组件。
thinking 这类可信标签
使用 custom-html-tags + setCustomComponents('chat', mapping),让自定义标签只作用在聊天区域。
详见 自定义标签与高级组件。
只在一个消息区域里做覆盖
通过 setCustomComponents('chat', { image: ChatImageNode }) 注册,再配合 custom-id="chat" 渲染。
详见 覆盖内置组件。
5. CSS 与 SSR 检查清单
- 先引入 reset,再使用
@import 'markstream-vue/index.css' layer(components); - 只有启用数学公式时,才额外导入
katex/dist/katex.min.css - SSR 场景下,把 Mermaid、D2、增强代码 runtime 这类浏览器专属依赖放到 client-only 边界之后
- 如果样式串到别的区域,所有聊天界面的定制都收口到
[data-custom-id="chat"]
页面效果不对时,先从这里开始排: 故障排除
6. 手动使用 composable 搭配 nodes
如果你自己在 worker、store 或自定义 AST 管线中解析 nodes,MarkdownRender 内置的 smooth streaming 不会启用——它只作用于 content 路径。你可以直接使用 useSmoothMarkdownStream,在解析前对原始文本做 pacing。
import { getMarkdown, parseMarkdownToStructure, useSmoothMarkdownStream } from 'markstream-vue'
import { ref, watch } from 'vue'
const stream = useSmoothMarkdownStream()
// 从事件源喂入新 chunk
eventSource.onmessage = (event) => {
stream.enqueue(event.data)
}
eventSource.addEventListener('done', () => {
stream.finish()
})
// 只解析可见部分;最终解析等 caughtUp 后再触发
declare const messageId: string
const md = getMarkdown(`chat-${messageId}`)
const nodes = ref([])
watch([stream.visible, stream.final], () => {
nodes.value = parseMarkdownToStructure(stream.visible.value, md, {
final: stream.final.value,
})
})该 composable 返回响应式 ref:visible、source、caughtUp 和 final。用 visible 渲染,等 caughtUp 为 true 后再认为流结束。
7. 流式输出 vs 恢复历史消息 —— 运行时切换 props
在聊天界面中,同一个 MarkdownRender 实例通常需要处理两种截然不同的模式:
- 流式输出:模型正在实时生成 token —
content逐步增长,final为false。 - 恢复历史消息:从缓存或存储中加载已完成的消息 — 完整的 Markdown 字符串一次性可用。
这两种模式需要不同的 smooth-streaming 和 fade 组合:
流式输出(token 实时到达)
<MarkdownRender
:content="streamedText"
:final="false"
smooth-streaming="auto"
:fade="false"
:typewriter="true"
:max-live-nodes="0"
/>smooth-streaming="auto"对可见输出进行 pacing,使突发式 chunk 平稳呈现。它已经在内容层实现了"文本逐步出现"的效果。fade=false,因为 280 ms 的 opacity 动画与高频 smooth-streaming 更新冲突——每个小批量内容都会打断上一帧的 fade,导致闪烁而非平滑淡入。typewriter=true在流末尾添加闪烁光标。max-live-nodes=0关闭虚拟化,启用流式场景下的增量/分批渲染。
恢复历史消息(完整 Markdown 一次性加载)
<MarkdownRender
:content="historyText"
:final="true"
:smooth-streaming="false"
:fade="true"
:typewriter="false"
/>smooth-streaming=false,因为内容已经完整——pacing 会人为地拖慢一条用户希望立即看到的消息。fade=true为每个段落和节点提供优雅的 opacity 入场动画(280 ms),这在内容只到达一次(而非每帧到达)时效果很好。typewriter=false——已完成的消息不需要光标。final=true告知解析器这是完整文档,不会将末尾分隔符留在 loading 状态。
在一个组件中动态切换
典型模式是:一个 MarkdownRender 先以流式模式运行,当响应完成时切换到历史消息模式:
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { computed, ref } from 'vue'
const content = ref('')
const final = ref(false)
const isStreaming = computed(() => !final.value)
</script>
<template>
<MarkdownRender
custom-id="chat"
:content="content"
:final="final"
:smooth-streaming="isStreaming ? 'auto' : false"
:fade="!isStreaming"
:typewriter="isStreaming"
v-bind="isStreaming ? { maxLiveNodes: 0 } : {}"
/>
</template>当流结束时,设置 final.value = true。渲染器会从 smooth pacing + 无 fade 切换到无 pacing + fade,但不会 remount 未变化的内容,从而避免完成瞬间闪烁;fade=true 会作用于之后新挂载的完整历史消息,或一次性到达的完整内容。
静态 / SSR 快照(无动画)
<MarkdownRender
:content="staticText"
:final="true"
:smooth-streaming="false"
:fade="false"
/>零动画——适合服务端渲染输出、打印或 PDF 管线。
8. 什么时候不该走这条路径
- 更新频率不高、页面基本静态时,用
content更简单 - 如果服务端或别的层已经接管 Markdown 解析,就直接用预解析后的
nodes - 如果当前问题主要是 SSR / runtime 边界,而不是流式输出本身,优先看对应框架文档