Skip to content
seren-sparkPublic

About

专为 AI 流式输出设计的 Vue 3 Markdown 渲染器,支持增量解析、不完整 Markdown 容错与稳定的 VNode 渲染。

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

md-stream-plus

面向 AI 对话与 token streaming 的 Vue 3 Markdown 渲染器

稳定提交已经闭合的内容,保守处理仍在生成的尾部,并用唯一的 Unified 链路生成 Vue VNode。

Vue 3.5 TypeScript 5.9 Unified 11 Node 20.19+ Vitest 37 tests Playwright 5 visual baselines

特性 · 快速开始 · 工作原理 · 控制器 API · 插件与自定义渲染 · 测试

Important

当前仓库是可运行的源码工程和完整实现验证,尚未配置 npm 发布与稳定包导出。下面的导入示例以本仓库的 Vite @ 别名为准。

为什么需要 md-stream-plus?

普通 Markdown 渲染器通常在每个新 token 到达时重新解析整篇文档。对于长回答,这不仅产生重复工作,还可能让未闭合的强调、链接、代码和列表在不同结构之间反复跳动。

md-stream-plus 将问题拆成三个互不越权的层:

层 只负责决定 不负责
SourceEngine / Incremark 思路 哪一段源文本需要重新处理、稳定边界在哪里 Markdown AST 语义
StreamingNormalizer / Markstream-vue 思路 pending 尾部以什么临时形态交给 parser 修改原始文本、提交稳定块
Unified + VNode renderer / XMarkdown 思路 MDAST/HAST 如何生成,以及 HAST 如何转成 Vue VNode 流式边界与源文本生命周期

这意味着:已证明稳定的块可以保持身份与缓存;不完整的尾部仍然可读;最终结果继续由标准 Markdown 语义链路负责。

核心特性

  • ⚡ 真正的增量源文本:piece table、可编辑行索引、稳定边界 checkpoint 和持久 slice ID。
  • 🌊 流式中间态容错:只追加 synthetic suffix,不删除、不替换、不污染 raw source。
  • ✍️ 支持源文本编辑:replaceText() 使用 UTF-16 exclusive range,setText() 自动计算最小 replacement。
  • 🧠 全局语义正确性:definition、heading slug 和 footnote 按文档顺序建立索引并细粒度失效。
  • 🧩 分区式全局编译:需要全局 AST 的 transform 仍可按 source owner 拆回稳定渲染区。
  • 🦶 增量脚注:维护引用编号、重复引用 backref、ARIA 属性和独立 footnote section。
  • 🧵 大文档 Worker:浏览器中默认超过 64 KiB 自动迁移;revision/generation 阻止旧结果覆盖新状态。
  • 🔒 安全 HTML 策略:默认显示为文本;开启后走 rehype-raw,并默认经过 rehype-sanitize。
  • 🧮 常用 Markdown 扩展:GFM、KaTeX 数学公式、directive container、任务列表、表格和脚注。
  • 🎨 纯 Vue VNode 渲染:不依赖 v-html,支持组件 alias、slot alias 和自定义属性。
  • 🧪 可观测、可回归:trace 记录边界、缓存、失效、patch、Worker、partition 和阶段耗时。

能力一览

能力 状态 说明
CommonMark 基础语法 ✅ 标题、段落、引用、列表、链接、图片、代码等
GFM ✅ 表格、任务列表、删除线;singleTilde: false
行内与块级数学公式 ✅ remark-math + KaTeX,支持 $...$ / $$...$$
Directive container ✅ :::tip 与兼容的 ::: tip,统一输出安全 div
Footnote ✅ 增量编号、重复引用、backref、ARIA 和全局 section
Raw HTML ✅ 默认关闭;开启后解析,默认清理危险内容
Definition 晚到达 ✅ 后到 definition 可激活已提交 reference
Heading 全局去重 ✅ 重复标题生成稳定、唯一的文档级 slug
Source replacement ✅ UTF-16 range replacement 与最小化 setText
Web Worker ✅ 自动阈值、显式开关、旧 ticket 隔离
自定义 Vue 组件 ✅ heading、code、list、container 等 alias
Shiki / Mermaid ⏳ 当前未内置,可通过自定义 code renderer 扩展
npm package / SSR Worker ⏳ 当前不在实现范围内

工作原理

flowchart LR
  A["文本 chunk / UTF-8 bytes / replacement"] --> B["Piece table + editable line index"]
  B --> C["Boundary detector"]
  C --> D["Committed slices"]
  C --> E["唯一 Pending slice"]
  E --> F["Suffix-only normalizer"]
  D --> G["MDAST cache"]
  F --> G
  G --> H["Global semantics"]
  H --> I["HAST cache / partitioner"]
  I --> J["Pure HAST → Vue VNode"]
  H --> K["Footnote global fragment"]
  K --> J
Loading

1. SourceEngine 只处理文本与边界

  • offset 统一使用 JavaScript UTF-16 code unit,range end 为 exclusive。
  • replacement 从受影响 slice 的前驱回退扫描,处理段落合并、Setext、表格和 CRLF 边界变化。
  • reconciliation 按内容与 context signature 复用 slice ID;仅位移的块不会失去 RenderBlock/DOM 身份。
  • 每次 mutation 发布有序、无重叠的 committed slices,以及至多一个 pending slice。

2. Normalizer 只追加临时后缀

核心不变量:

safeText === rawText + syntheticSuffix

高置信状态下可以临时闭合 code span、$ / $$、link destination/title,以及符合 CommonMark flanking 和 multiple-of-three 规则的强调分隔符。对于 fence、HTML、container、\[ 或无法证明安全的歧义状态,选择 literal/deferred fallback。

3. Unified 是唯一正式语义链路

remark-parse → remark-gfm / remark-math / remark-directive → remark-rehype → rehype-katex → rehype-sanitize 负责 MDAST/HAST;递归 renderer 只读 HAST 并纯函数地生成 VNode。

ParseCache 和 HastCache 分离保存 slice-relative position。全局 transform 完成后,HAST partitioner 根据 position、祖先与 ownership hook 将顶层节点归还给 source owner;归属不明确时才降级为 document-global。

快速开始

环境要求

  • Node.js >= 20.19.0
  • npm(项目包含 package-lock.json)
  • 现代浏览器;Worker 仅在浏览器环境启用

获取并运行 Playground

git clone https://github.com/seren-spark/md-stream.git
cd md-stream
npm install
npm run dev

Playground 支持:

  • 逐字符、逐词、逐行和确定性随机分块;
  • 播放、暂停、单步、完成解析与重置;
  • 可编辑完整 Markdown,并观察 source replacement;
  • HTML 安全模式与 Worker 自动迁移;
  • slice、pending、raw source、cache、partition 和 compile trace 面板。

md-stream-plus Playground 渲染预览

30 秒接入流式输出

下面示例适用于仓库内部或直接复用源码的项目:

<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import { createStreamingMarkdownController } from '@/index'
import StreamingMarkdown from '@/vue/StreamingMarkdown.vue'

const controller = createStreamingMarkdownController({
  documentId: 'assistant-message-1',
  scheduler: 'microtask',
  allowHtml: false,
  worker: 'auto',
})

async function consume(response: Response) {
  const reader = response.body?.getReader()
  if (!reader) return

  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    await controller.appendBytes(value)
  }

  await controller.finalize()
}

onBeforeUnmount(() => controller.dispose())
</script>

<template>
  <StreamingMarkdown :controller="controller" />
</template>

appendBytes() 内部使用流式 TextDecoder,可以正确处理跨网络 chunk 的 UTF-8 多字节字符。

使用文本 chunk

await controller.appendText('# Hello\n\nStreaming **mark')
await controller.appendText('down**.')
await controller.finalize()

编辑已经存在的文档

浏览器 textarea 的 selectionStart / selectionEnd 本身就是 UTF-16 offset,可以直接传入:

await controller.replaceText(selectionStart, selectionEnd, replacement)

// 自动通过最长公共前后缀转换为最小 replacement
await controller.setText(nextMarkdown)

仅改变文档头部时,后方未改变的 slice 会尽量保持 ID、MDAST/HAST 缓存和 DOM key。

Vue 渲染与定制

自定义代码块组件

<script setup lang="ts">
import CodeBlock from './CodeBlock.vue'

const components = {
  'block-code': CodeBlock,
}
</script>

<template>
  <StreamingMarkdown
    :controller="controller"
    :components="components"
  />
</template>

Alias

Alias 匹配节点 额外 props
heading h1—h6 level
inline-code 段落内 code language、content、inline
block-code pre > code language、content、loading、sourceRange
list ul / ol ordered、depth
list-item li ordered、depth、index
container 任意 directive container 原始安全属性
container:tip 指定名称的 container 原始安全属性
HTML tag name 对应 HAST element 原始安全属性

Markdown 不能直接指定任意 Vue 组件。Directive container 始终先转换成 div,再由宿主通过受控 alias 映射组件。

自定义属性

const customAttrs = {
  heading: { class: 'article-heading' },
  a: (_node, props) => ({
    class: ['article-link', props.class],
    'data-track': 'markdown-link',
  }),
}
<StreamingMarkdown
  :controller="controller"
  :custom-attrs="customAttrs"
/>

renderer 不修改缓存 HAST;属性转换和 custom attrs 合并都会创建新对象。

控制器 API

创建选项

interface StreamingMarkdownControllerOptions {
  documentId?: string
  allowHtml?: boolean
  sanitize?: boolean
  plugins?: StreamingMarkdownPlugin[]
  scheduler?: 'immediate' | 'microtask' | 'animation-frame' | 'manual'
  worker?: 'auto' | boolean
  workerThreshold?: number
  workerBatchMs?: number
}
选项 默认值 说明
documentId 'document' slice ID 命名空间
allowHtml false 是否将 raw HTML 作为结构解析
sanitize true 是否运行 HAST sanitize;不可信内容不要关闭
plugins [] Unified、boundary、semantics 与 ownership 扩展
scheduler 'microtask' mutation 调度与 append 合并策略
worker 'auto' 自动、强制或禁用 Worker
workerThreshold 64 * 1024 自动迁移阈值,单位为 UTF-16 code unit
workerBatchMs 16 Worker append 批处理等待时间

方法

方法 返回值 说明
appendText(chunk) Promise<void> 追加字符串 chunk
appendBytes(chunk) Promise<void> 追加 UTF-8 bytes,支持跨 chunk 解码
replaceText(start, end, text) Promise<void> 替换 UTF-16 [start, end) 范围
setText(text) Promise<void> 将全文转换为最小 replacement
flush() Promise<void> 立即排空 mutation scheduler
finalize() Promise<void> flush decoder/scheduler,再执行 strict 全文 parse/run
reset() void 清空文档、缓存、Worker session 和 finalized 状态
dispose() void 释放订阅与 Worker;controller 不可再次写入
getSnapshot() RenderStateSnapshot 获取当前不可变渲染快照
subscribe(listener) () => void 订阅快照,注册时立即收到当前值

生命周期约束

  • 同一个 reset() 周期内,appendBytes() 不能与 appendText() / 编辑 API 混用。
  • replaceText() 的 range 必须是合法整数、end exclusive,且不能超出当前 raw source。
  • finalize() 后禁止继续写入;需要新一轮内容时先调用 reset()。
  • dispose() 后 controller 永久不可写。
  • finalize() 是 correctness oracle:完整 raw source 会经过 strict parse/run,并原子替换 provisional 结果。

Snapshot

interface RenderStateSnapshot {
  blocks: readonly RenderBlock[]
  pending: PendingRenderRegion | null
  global: readonly HastFragment[]
  sourceRevision: number
  structureRevision: number
  semanticRevision: number
  final: boolean
  rawSource: string
  committedSlices: readonly CommittedSlice[]
  trace: AppendTrace | null
}

trace 包含 boundary evidence、normalizer 决策、MDAST/HAST cache hit、语义失效、patch 数量、piece 数量、重扫范围、partition owner、执行线程与各阶段耗时。

Worker 策略

环境 / 配置 执行位置
浏览器、小于阈值、worker: 'auto' 主线程
浏览器、达到阈值、无自定义运行时插件 自动迁移到 Worker
SSR 或没有 Worker 全局对象 主线程
worker: false 始终主线程
worker: true 且 Worker 不可用 构造 controller 时明确报错
worker: 'auto' 且存在自定义插件 留在主线程并写入 diagnostic
worker: true 且存在自定义插件 构造 controller 时明确报错

迁移时会传递完整 raw source 并在 Worker 重建缓存。reset/dispose 通过 generation 隔离旧 session,编译结果通过 source/structure/semantic revision ticket 防止竞态回写。

插件与自定义渲染

interface StreamingMarkdownPlugin {
  name: string
  remark?: Pluggable
  rehype?: Pluggable
  boundary?: BoundaryExtension
  semantics?: SemanticExtension
  scope: 'block-local' | 'symbol-aware' | 'document-global'
  requiresGlobalParse?: boolean
  async: boolean
  introducesBlockSyntax?: boolean
  ownership?: HastOwnershipExtension
}
  • remark / rehype:进入唯一的 Unified pipeline。
  • boundary:告诉 SourceEngine 新块语法如何影响稳定边界。
  • semantics:声明跨 slice 的语义 revision。
  • scope:声明插件是块局部、符号感知还是文档全局。
  • requiresGlobalParse:强制使用完整 raw-source MDAST。
  • ownership:认领 transform 新建且没有可靠 position 的顶层 HAST。

Warning

声明 introducesBlockSyntax: true 却没有提供 boundary extension 会直接报错。否则 SourceEngine 无法证明提交边界安全。

安全模型

  1. allowHtml: false 时 raw HTML 作为文本显示。
  2. allowHtml: true 时执行 remark-rehype(allowDangerousHtml) → rehype-raw。
  3. sanitize 默认开启,schema 显式允许 KaTeX/MathML、GFM task list、footnote 和 container 所需属性。
  4. URL 协议限制为 http、https、mailto、tel 等安全集合;VNode renderer 再拒绝 javascript:、vbscript: 与 data:。
  5. 外部 HTTP(S) 链接默认补充 target="_blank" 和 rel="noopener noreferrer"。
  6. HAST 到 VNode 采用纯函数递归,不通过 v-html 注入字符串。

只有在 Markdown 来源完全可信时才考虑 sanitize: false。

V1 → V2

阶段 主要能力
V1 append-only SourceEngine、稳定边界、pending suffix、Unified 编译、definition 依赖、全局 heading slug、footnote fallback、双缓存、Vue renderer 和 strict finalize
V2 piece table、source replacement、持久 slice ID、增量 footnote、全局 HAST 分区、完整 link/delimiter scanner、Worker 与编辑型 Playground

项目结构

src/
├─ source/          piece table、行索引、边界检测与 SourcePatch
├─ streaming/       pending tail scanner、normalizer 与 source map
├─ compiler/        MDAST/HAST、双缓存、全局语义与 partition
├─ renderer/        纯 HAST → Vue VNode renderer
├─ orchestration/   controller、scheduler 与 RenderStore
├─ worker/          Worker client、protocol 与 worker core
├─ vue/             StreamingMarkdown、RenderBlock、PendingRegion
├─ plugins/         内置 GFM/math/directive 与插件类型
├─ App.vue          交互式 Playground
└─ styles.css       Playground 与 Markdown 展示样式

tests/
├─ source/          piece table、line index、boundary 与随机编辑
├─ streaming/       suffix-only normalizer 与中间态
├─ compiler/        HAST partition 与 ownership
├─ renderer/        deep-freeze、属性转换和 URL 安全
├─ integration/     definition、footnote、Worker、finalize 与 differential
└─ visual/          Playwright 固定视口截图回归

更完整的设计、边界状态和一致性约束见 流式渲染实现方案。

测试与质量门禁

# TypeScript / Vue SFC 类型检查
npm run typecheck

# ESLint
npm run lint

# Vitest + fast-check
npm test

# Playwright 截图回归
npm run test:visual

# 类型检查 + Vite production build
npm run build

当前基线:

  • 8 个 Vitest 文件,37 个测试;
  • 5 个 Chromium 视觉用例;
  • 覆盖 append、replacement、footnote、partition、Worker、HTML 安全与 strict finalize;
  • finalize(stream) 与 one-shot strict pipeline 进行规范化语义对比。

首次执行视觉测试前安装 Chromium:

npx playwright install chromium

更新确认后的视觉基线:

npm run test:visual:update

当前边界与路线图

以下能力尚未包含在当前版本:

  • npm package、正式 exports、版本发布与变更日志流程
  • 内置 Shiki 代码高亮与 Mermaid 图表
  • SSR Worker
  • 可传输第三方插件模块协议
  • 独立 benchmark suite 与 CI 性能预算

贡献

欢迎通过 Issues 提交问题或建议。涉及 parser、boundary、normalizer、renderer 的改动请同时补充对应层测试;涉及 Playground 的视觉调整请更新 Playwright screenshot,并确保以下命令全部通过:

npm run typecheck
npm run lint
npm test
npm run test:visual
npm run build

思路来源与第三方许可

项目没有将三套上游 parser 作为运行时依赖,而是按职责选择性改写扫描、增量维护与 renderer 思路:

完整说明见 THIRD_PARTY_NOTICES.md。


让流式 Markdown 在“还没写完”时可读,在“已经写完”后正确。

About

专为 AI 流式输出设计的 Vue 3 Markdown 渲染器,支持增量解析、不完整 Markdown 容错与稳定的 VNode 渲染。

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages