在先前我们我们实现了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 中。
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-it
的Token
解析器稍微复杂一些,其不直接嵌套而是使用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)
的意思是,假设a
和b
都是从相同的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
增量富文本解析算法。