在富文本编辑器中实现 Markdown 流式增量解析算法

在先前我们我们实现了SSE流式输出的实现,以及基于向量检索的RAG服务,这些实现都可以算作是AI Infra的范畴。这里我们再来聊一下在SSE流式输出的基础上,将Markdown解析和富文本编辑器的渲染结合起来,实现编辑器的增量解析算法,同样属于文档场景下的Infra建设。

概述

SSE流式输出的场景下,LLMs模型会逐步输出Markdown文本,在基本场景下我们只需要实现DOM的渲染即可。然而,在富文本编辑器的场景下,这件事就变得复杂了起来,因为编辑器通常都是自行维护一套数据结构,并不可以直接接受DOM结构,再叠加性能问题就需要考虑到下面几点:

  • 流式: 流式意味着我们需要处理不完整的Markdown文本,在不完整的情况下语法会出现问题,因此需要支持已渲染内容的重建。
  • 增量: 增量意味着我们不需要每次都进行全量渲染,已经稳定的内容实际上不需要再次解析,而是只需要在已有内容的基础上进行增量更新。
  • 富文本解析: 富文本解析意味着我们需要完整地对应到Md的解析情况,无论是流式处理还是增量解析都需要在编辑器结构基础上对等实现。

当然,即使是在基本场景下的DOM渲染,也会涉及到很多边界情况的处理,例如若是每次都是SSE输出Md时都进行全量渲染的性能问题、以及类似图片节点重渲染可能存在的重新加载问题等。因此,这里的增量解析算法流程,即使是对于基本场景也是很值得参考的。

并且,还有更复杂的场景,例如在SSE流式输出的过程中,用户总是会存在临时的不完整的Md文本,在完整输出Md文本之前,就会存在不符合规范的Md解析,或者是错误地匹配到另一种Md语法的情况。这些情况都需要额外的算法处理,实现一些额外的语法修复方案。

ini 复制代码
<img src="http://
<img src="http://example.com/
<img src="http://example.com/image.png" alt=

此外,诸如渲染的语法扩展、自定义组件等等,也会涉及到额外的语法处理,关于自定义解析渲染的实现可以参考react-markdown。这其实是是一个很庞大的管道实现,同样也会是很好的自定义处理AST并渲染的案例,remark也是非常丰富的生态系统,给予了关于Md解析的各种实现。

rust 复制代码
markdown -> remark + mdast -> remark plugins + mdast -> remark-rehype + hast -> rehype plugins + hast-> components + ->react elements

不过在这里我们并不展开讨论这些自定义语法解析的内容,而是主要聚焦在基本的Md语法解析和增量渲染上,但是在解析的过程中我们还是会涉及到针对语法错误匹配的相关问题处理。文中的相关实现可以参考 BlockKit 以及 StreamDelta 中。

%%{init: {"theme": "neutral" } }%% graph LR 文本 --> |片段| 词法解析 --> |Tokens| 游标指针 --> DC[Delta 变更] 编辑器 --> DF[Delta 片段] --> R[Retain 指针] --> D[Diff Delta] --> 应用 DC --> D

Markdown增量解析

首先我们来实现Markdown的流式增量解析,能够实现增量解析的重要的基础是,Md输出的后续内容格式通常不会影响先前的格式,即我们可以归档已经稳定的内容。因此我们可以设计一套数据处理方案,在解析的过程中需要遵循的整体大原则:

  • 非全量解析Markdown, 基于流渐进式分割结构处理数据。
  • 基于Lexer解析的结构, 双指针绑定Delta的增量变更。

词法解析

首先我们需要一个词法解析器将Md解析成Token流,或者是需要一个语法解析器将Md解析成AST。由于我们的数据结构是扁平化的,标准词法解析的扁平Token流对我们的二次解析并非难事,而若是完全嵌套结构的数据结构,语法解析生成AST的解析方案对则可能更加方便。

当前的主流解析器有比较多的相关实现,marked提供了lexer方法以及marked.Lexer作为解析器,remark作为unified庞大生态系统的一部分,也提供了mdast-util-from-markdown独立的解析器,markdown-it同样也提供了parse方法以及parseInline作为解析器。

在这里我们选择了marked作为解析器,主要是由于marked比较简单且易于理解。remark系列的生态系统过于庞大,但是作为标准的AST解析是非常不错的选择。markdown-itToken解析器稍微复杂一些,其不直接嵌套而是使用heading_open等类型标签进行数据处理。

从名字上也可以看出marked提供的lexer是偏向于词法解析的,但是其也并非完全纯粹的词法解析器。因为输出的结构也是存在嵌套存在嵌套的结构,当然其也并非比较标准的AST结构,这里更像是偏向Token流的混合结构实现。一段Md文本的解析结构如下所示:

js 复制代码
// marked.lexer("> use marked")
{
    "type": "blockquote",
    "raw": "> use marked",
    "tokens": [
        {
            "type": "paragraph",
            "raw": "use marked",
            "text": "use marked",
            "tokens": [{ "type": "text", "raw": "use marked", "text": "use marked", "escaped": false }]
        }
    ],
    "text": "use marked"
}

归档索引

那么在解析的过程中,我们需要维护几个变量来持有当前解析的状态。content维护了当前正在解析的片段,接受流式的数据需要不断拿到新片段来组装,indexIds以索引值作为稳定的键用来映射id值,archiveLexerIndex则维护了已经归档的主级Tokens节点索引值。

js 复制代码
/** 正在解析的内容 */
protected content: string;
/** 索引幂等的记录 */
public indexIds: O.Map<O.Map<string>>;
/** 归档的主级节点索引值 */
public archiveLexerIndex: number;

当执行追加内容时,我们直接将content与本次追加的text内容合并解析,然后借助marked词法解析就可以得到Token Tree,在调度的主框架中,我们只解析主级节点。只解析主级节点可以尽可能简化我们需要处理的数据,而子级节点可以再建立二级扩展索引处理。

js 复制代码
const tree = marked.lexer(this.content);
const archiveLexerIndex = this.archiveLexerIndex;
// 只迭代 root 的首层子节点
for (let i = 0; i < tree.length; i++) {
  // ...
}

接着我们需要将归档的位置处理一下,归档就意味着我们认为该Token将不会再处理了,因此持有的索引index就自增。而block.raw就是该Token就是解析的原始内容,这也是使用marked的简单之处,否则还需要自行根据索引解析一下,文本归档的部分我们直接移除即可。

js 复制代码
/**
 * 归档部分内容
 * @returns archived 字符长度
 */
public archive(block: Token) {
  this.archiveLexerIndex++;
  const len = block.raw.length;
  this.content = this.content.slice(len);
  return len;
}

在处理好归档索引之后,我们就可以在循环中具体处理这归档的部分。这里的策略非常简单,如果循环时某个节点存在前个节点,就可以归档上一个节点了。当然这里的归档并没有那么理想,特别是存在列表、表格等节点时,这里就需要特殊地处理,我们后续会讨论这个问题。

而后续就是解析当前的Token了,这部分是需要适配编辑器本身数据结构的实现。在当前编辑器中,我们认为Token作为主级节点存在,然而其本身并不能够完整对应到编辑器行的状态,例如list节点会存在嵌套的list_item节点,而我们的结构是纯扁平化的行结构,因此需要独立适配处理。

js 复制代码
const prev = tree[i - 1];
const child = tree[i];
// 首层子节点存在第二级时,归档上一个节点
// 此外诸如表格等节点可以正则匹配来避免过早归档
if (prev && child) {
  this.archive(prev);
}
const section = parseLexerToken(child, {
  depth: 0,
  mc: this,
  parent: null,
  index: archiveLexerIndex + i,
});

Token转换到Delta片段的过程我们以heading行格式以及bold行内格式为例,简单地说明一下解析过程。对于行节点而言,执行顺序很重要,需要先递归地处理所有行内节点,然后再执行行节点的处理,而无论是行节点还是行内,都是封装好的原地修改的方式来修改属性值。

js 复制代码
switch (token.type) {
  case "heading": {
    const tokens = token.tokens || [];
    const delta = parseChildTokens(tokens, { ...options, depth: depth + 1, parent: token });
    applyLineMarks(delta, { heading: "h" + token.depth });
    return delta;
  }
  case "strong": {
    const tokens = token.tokens || [];
    const delta = parseChildTokens(tokens, { ...options, depth: depth, parent: token });
    applyMarks(delta, { bold: "true" });
    return delta;
  }
}

此外,如果需要再继续解析HTML节点的话,则需要引入parse5/htmlparse2等独立解析HTML片段,不过parse5的解析结果会严格处理DOM的嵌套结构,因此用htmlparse2来处理HTML片段更合适。当然还有一种常见的解析方案,将所有数据处理为HTML,再行解析HTML-AST

语法修复

在上述的实现中,我们已经能够实现针对于Markdown部分的增量解析了。虽然这个策略多数情况下是没有什么问题的,但是在流式输出的过程中,会出现两个问题,一是流式输入过程中会存在临态节点,会导致错误的归档,例如缩进的列表,二是错误的语法匹配,例如无序列表缩进时的-解析为标题。

因此我们需要处理这些Case,当然这里目标主要是针对于语法的错误匹配修复,而并非补全不完整的语法。首先我们来看一下列表的缩进问题,若是直接使用上述的策略,那么在下面这个例子中,就会导致第一行1节点被归档,然后导致1.1节点没有缩进格式,因为此时不存在嵌套的list token

md 复制代码
- 无序列表项1
   - 无序列表项1.1

先来分析一下这个问题所在,流式的输出过程中,1.1行会有一个临态 也就是缩进的前置三个空格的状态,而其在输出到-字符之前,这个Token的解析格式是下面的内容。则按照上述策略,先前的Token存在且当前的Token存在,则前置的Token进行归档。

js 复制代码
[{ type: "list", raw: "1. xxx", ordered: true, start: 1, loose: false, items: [ /* ... */ ]},
{  type:"space", raw:"\n   \n" }]

因此在归档之后,就仅剩下1.1的节点需要解析了,而由于前一行的1节点已经被归档,那么此时1.1就是新的list以及list_item节点了。因为不存在嵌套节点,所以其虽然能够正常解析内容,但是就不存在缩进的格式了。

js 复制代码
{ type:"list", raw:"   -\n", ordered: false, start: "", loose: false, items: [ /* ... */ ]}

因此,针对这个问题,我们需要对解析的Token进行一些额外的处理。在上述的这个Case中,我们可以认为是由于space节点的存在,导致了前一个list节点被过早归档了,因此我们可以认为space节点是临时状态,不应该导致前一个节点的归档。

js 复制代码
export const normalizeTokenTree = (tree: Token[]) => {
  const copied = [...tree];
  if (!copied.length)  return copied;
  const last = copied[copied.length - 1];
  // 若是需要等待后续的数据处理, 就移除最后一个节点
  // Case1: 出现 space 节点可能会存在等待输入的情况, 例如上述的 list
  // 1. xxx
  //    [前方三个空格会出现 space 导致归档]
  if (last.type === "space") {
    copied.pop();
  }
  return copied;
};

而针对于第二个问题,同样会表现的很明显,由于格式的错误匹配会导致样式,而这个状态同样也是临态,因此在继续解析的过程中,会匹配到正确的结构,在这个过程中就会导致明显的样式突变,在输出的过程中非常明显。例如下面的这个例子中,本应该认为是无序列表的-,却被错误地解析为标题。

md 复制代码
- xxx
   - 
js 复制代码
({
  type: "list",
  items: [
    {
      type: "list_item",
      tokens: [ { type: "heading", tokens: [ /* ...*/ ] } ],
    },
  ],
});

实际上这个格式并没有什么问题,因为这本身就是规范中的格式,只不过通过---设置标题的格式在平时并不常用。我们自然也可以去查阅一下规范中的格式定义,在Setext headings部分中可以看到相关的定义:

The setext heading underline can be preceded by up to three spaces of indentation, and may have trailing spaces or tabs - Example 86

md 复制代码
Foo
   ----

A list item can contain a heading - Example 300

md 复制代码
- # Foo
- Bar
  ---
  baz

因此,处理这个问题的方法也很简单,即我们避免这个临时状态的出现,这也是我们处理这两个问题所要遵循的原则。因此我们可以通过正则来匹配这个临态,若是匹配成功则将该行移除掉,等待后续的正确格式出现。

js 复制代码
export const normalizeFragment = (md: string) => {
  // Case 1: 在缩进的无序列表尾部出现单个 - 时, 需要避免被解析为标题
  // - xxx
  //    -
  const lines = md.split("\n");
  const lastLine = lines[lines.length - 1];
  if (lastLine && /^[ ]{2,}-[ \n]?$/.test(lastLine)) { // $
    lines.pop();
  }
  return lines.join("\n");
};

编辑器流式渲染

Markdown的增量解析实现之后,我们还需要将解析的结果映射到编辑器本身的数据结构中,这里的主体流程则需要配合Md解析流程的实现。同样的,我们也遵循上述Md解析的流程,同样是实现归档索引,以及当前数据的解析数据重建等方法。

流式解析

在流式解析的过程中同样需要几个变量,首先是正在解析的Token Delta,这部分是需要保证独立顶级的Token节点,这也是匹配Md解析的主级节点。其次是已经归档的Delta长度,与Md解析不同的是其索引为Token数组索引,此时是Delta的文本长度索引。

js 复制代码
/** 归档的索引 */
public archiveIndex: number;
/** 正在处理的 token-delta */
public current: Delta | null;

在追加内容时,我们需要将最新的Token应用到编辑器上,但是由于编辑器此时已经应用了编辑器的内容,编辑器应用的是变更内容,因此则需要计算出已经应用的内容和目标的内容差异,才可以直接将其应用到编辑器本身。

js 复制代码
/**
 * 追加 delta
 * @returns 变更的差异
 */
public compose(delta: Delta) {
  const copied = new Delta(cloneOps(delta.ops));
  if (!this.current) {
    this.current = copied;
    return delta;
  }
  // 这里也可以避免 diff, 直接构造删除原始内容再添加新内容即可
  // 由于本身会归档内容, 无论是比较差异还是删除/新增都不会太耗费性能
  const diff = this.current.diff(copied);
  this.current = copied;
  return diff;
}

归档的实现则比较简单,主要是将当前的Delta归档掉,并且更新归档的索引。由于此处理论上仅会存在insert操作,因此可以直接调用length方法来得到此时的Delta长度,实际上这里还是应该检查一下操作类型。

js 复制代码
/**
 * 归档部分内容
 * @returns archived 的 delta 长度
 */
public archive() {
  if (this.current) {
    // 此处理论上只有 insert, 因此无需考虑 delete 的指针问题
    const len = this.current.length();
    this.archiveIndex = this.archiveIndex + len;
    this.current = null;
    return len;
  }
  return 0;
}

接下来我们就需要将Delta的组合流程结合到Md的解析流程当中了。首先我们需要取得当前已经归档的索引,这部分需要转换为retain以对齐索引。然后在循环归档的时候,需要处理继续处理索引信息,主要是因为后续的处理是merge diff, 此时的长度并非是dc的长度。

而最后的compose方法则是将剩余解析的Token转换的Delta进行合并,最终返回的delta就是本次追加内容的变更差异,也就是先前提到的diff方法。最后的merge方法则是将变更合并先前的retain指针,以此来保证索引的正确。

js 复制代码
// 因为 delta 首个值是 retain, 这里同样需要对齐其长度表达
let archiveLength = this.dc.archiveIndex;
for (let i = 0; i < tree.length; i++) {
  if (prev && child) {
    this.archive(prev);
    archiveLength = archiveLength + this.dc.archive();
    const deltaLength = getDeltaPointerPosition(delta);
    // 若归档长度大于当前 delta 长度, 则需要移动指针
    if (archiveLength - deltaLength > 0) {
      delta.push({ retain: archiveLength - deltaLength });
    }
  }
  // ...
  const diff = this.dc.compose(section);
  delta.merge(diff);
}

实际上这部分实现是非常需要测试来保证稳定性的,特别是在不断处理各种Case中,需要避免之前测试过的内容解析出现问题,因此维持一个测试集是非常有必要的,在单元测试的过程中我们主要专注于下面几种类型的输入以及输出:

  • 首先是完整的Md文本输入,这部分主要测试的是将所有的Token解析的正确性。
  • 其次就是文本内容的流式输入,这部分测试就可以完全使用单个字符的流渲染输出即可。
  • 还有就是随机的字符流式输入,这种情况下不容易维持稳定的单元测试的输出,此时需要测试最终的输出。

稳定键值

虽然此时我们的编辑器当前并未实现块级的结构嵌套,但是块级结构通常是不可避免的,例如代码块、表格等结构的实现。而无论是单纯的块结构嵌套,还是Blocks模式下的结构实现,通常都是存在id值来标识唯一的块级结构。

那么在流式输出的过程中,就很容易出现id值被重建的问题,特别是在表格种复杂的结构中,在仅实现主级节点解析归档的情况下,每个单元格都可能会被重新解析而生成新的id值。因此我们需要在解析的过程中,维持一个id值的映射表,就比较重要,也可以避免重复的渲染带来性能问题。

因此维持id映射表就需要实现一个稳定的键值,若是没有稳定的键值,那么在解析的过程中就无法确定上次解析的id和当前解析的id应该是一致的。那么在这里我们使用index以及depth的组合值作为索引,以此来映射复杂结构的id值,这也就是之前的indexIds作用。

js 复制代码
const key = `depth:${depth}-index:${index}`;
const id = (mc.indexIds[key] && mc.indexIds[key].id) || generateId();
const delta = parseChildTokens(tokens, { ...options, depth: depth + 1, parent: token });
const block = new Block({ id, delta });
// ...

编辑模式

通常来说,我们一般是不需要在流式输出的过程中允许用户进行编辑的,因此只需要在流式输出的过程中将readonly设置为true,等到流式输出完成之后再将readonly设置为false即可。然而,若是用户需要在流式输出的过程中进行编辑,这个问题就变得复杂起来。

回到编辑模式这个问题本身,由于存在多种输入模式可能导致的数据冲突,解决这个问题通常都是使用OT算法来解决的。若是编辑器结构是能够支持CRDT数据模式的话,这个问题应该会更加简单,毕竟理论上而言其处理的编辑位置是相对的,而我们需要处理索引冲突本质是由于绝对位置引起的。

OT的实现中最重要的就是transform方法,我们可以先看看transform所代表的意义。如果是在协同中的话,b'=a.t(b)的意思是,假设ab都是从相同的draft分支出来的,那么b'就是假设a已经应用了,此时b需要在a的基础上变换出b'才能直接应用。

而我们也可以换种理解方式,即transform解决了a操作对b操作造成的影响。那么类似于History模块中undoable的实现,transform同样可以来解决单机客户端Opa以及Opb操作带来的影响,那么自然也可以解决流式输出以及用户输入本身相互的数据影响。

由于我们已经记录了archiveLength,在archiveLength内的文档内容变更我们认为是已经稳定的结构,而archiveLength外的文档内容我们认为是临时的状态。因此这里的OT则变得简单了很多,我们需要做的计算拆分为了两部分:

  • 对于archiveLength内的变更,对于insert操作我们就将索引值相加,而对于delete操作我们就将索引值相减。
  • 对于archiveLength外的变更,我们则需要根据索引构造一个retain操作,然后将用户变更的changes组合到当前的Delta中。

总结

此外,由于编辑器数据结构通常都是各自维护的一套模式,因此在这里我们更偏向于业务代码实现,而并非通用的解析模式,在不同的业务场景下都需要额外的适配。不过在Md解析的流程上抽象出来更底层的实现是比较通用的模式,这部分确实是可以提供通用算法出来的。

实际上到这里,还需要考虑一个问题,若是不需要实现流式输出时编辑,我们也完全可以实现一套流式输出的纯HTML渲染模式,等待流式输出完成之后再替换为编辑器。这当然是个可行的方案,并且还可以避免很多复杂的实现,只不过这样实现成本就转移到了需要额外做一套纯渲染的样式,以保证用户体验。

在本文中我们实现了Md的词法解析,在此基础上处理了增量的Token归档以及增量处理,在Md整个流程的基础上结合了,编辑器数据结构本身的增量渲染。并且还处理了具体的语法匹配问题,以及编辑器细节id索引和编辑模式。在这些内容的基础上,实现了流式Markdown增量富文本解析算法。

每日一题

参考

相关推荐
堆栈future5 小时前
AI工作流自动生成公众号图文并发布到wemark
llm·aigc·markdown
coding随想5 小时前
指尖的魔法:触摸事件(Touch Events)如何让网页在你掌心起舞?
前端
一只毛驴5 小时前
视频播放器的编解码
前端·javascript
Chaoran5 小时前
浏览器加载外部资源性能优化
前端·javascript·面试
子兮曰6 小时前
别再手动处理API数据了!这个BFF数据转换层让你的开发效率提升300%
前端·javascript·api
知识分享小能手6 小时前
React学习教程,从入门到精通, React 入门指南:创建 React 应用程序的语法知识点(7)
前端·javascript·vue.js·学习·react.js·前端框架·anti-design-vue
golang学习记6 小时前
从0死磕全栈第2天:Vite + React 配置全解析,让你的开发效率飞起来
前端
liusheng6 小时前
fly.js 对 DELETE 请求无法传入 body 的问题
前端·javascript
前端_小_小白6 小时前
前端程序员修炼手册:从像素仔到体验守护者
前端·javascript