流萤简历是我做的 AI 简历与求职资料工作台。这是专栏第 4 篇,从 AI 练习的原文、解答和拓展出发,聊文档表示、稳定块 ID 与编辑器生命周期。
产品特写:面经整理完,还能继续写
在流萤的 AI 练习里,一份面经可以保留原文,生成逐题解答与拓展,并继续当成文档修改。标题、列表、表格、公式、代码与 Mermaid 都能留在内容里,目录也能按页面嵌套组织。资料从"一次性回答"变成了可以反复加工的对象。

截图中的图表是文档里的内容块。它既要让人看到图,也要让 AI 读到源码;标题要供目录使用,正文要供来源核对使用。等 CLI 也加入编辑以后,还得回答一个问题:AI 说要修改"这一块",究竟靠什么定位?
一个字符串当然能存很多东西。困难在于,每个消费者对"同一篇文档"的要求不同,而且其中有些表示只能单向派生,不能再无损写回。
先确定谁是权威表示
权威表示指一份数据发生分歧时,系统认定应该以哪一份恢复完整内容。当前资料的富文本保存在 sourceDocument,是 HTML;content 保留供 AI、搜索和来源依据检查使用的文字。解答与拓展分别有 answerDocument、extensionDocument,不与原文混成一个字段。
这里的 HTML 是编辑器支持的结构化富文本,而不是任意网页都能原样执行的承诺。表格、列表和节点属性需要被保存,危险标签与协议在外部输入边界处理;HTML 代码块作为源码另外进入隔离预览。

图里的文字投影是一条有损通道。表格的边框、合并单元格、节点 ID 和图表配色,无法从普通文字可靠还原。因此,AI 读到 text 后直接把它当成完整文档写回,会让资料逐渐退化成纯文本。
这是一个很常见的产品风险:读取接口为了模型方便给了 text,调用方就把方便阅读的视图误当成权威输入。流萤的文档协议同时返回 html、text 和 blocks,调用方应根据操作选择正确材料,复杂文档修改优先保留已有 HTML 与块身份。
为什么不只存 Markdown
Markdown 对人和 AI 都友好,适合作为输入格式。标题、普通列表、代码围栏写起来直接,也方便在对话和终端里传递。但当文档包含列宽、合并单元格、提示块、资源卡片、折叠和图表视图偏好时,普通 Markdown 无法表达所有编辑器状态。
如果把这些状态放在另一份 JSON,Markdown 又单独存正文,就会开始维护两个可编辑真值:改表格时谁更新 Markdown,AI 改 Markdown 时又怎样保留原 JSON?每次转换都可能丢掉某些信息。
当前实现让 Markdown、HTML 和 text 都能作为外部输入,经过转换与校验后进入支持的 HTML 模型。浏览器编辑继续沿编辑器的节点结构工作,保存时写 HTML。这样并不是宣布 HTML 永远优于其他格式,而是为现有编辑器确定一条明确的持久化路径。
代价同样存在。HTML 是完整文档字段,两个客户端同时改同一字段时,当前合并不会提供字符级协作。服务端也需要维护允许的标签与属性,不能只因为格式叫 HTML 就接受任意内容。这些都是选择带来的后续责任。
页面 ID 与块 ID,要分开理解
页面 UUID 用于永久地址和页面归属,块 ID 用于文档内部的修改目标。一个页面可以改标题、移动目录,链接仍然指向它;一个段落可以在前面插入新段落后继续被定位,块 ID 也应该保持。
如果用数组下标定位块,AI 读取"第 4 段"之后,用户在第 2 段前加一段,原目标就变成第 5 段。版本检查能拒绝旧版本,但重新读取后,如果仍然依赖偶然位置,模型就需要重新猜"哪段才是原来的目标"。
当前协议给顶层块附 data-flowing-id。顶层块可以是段落、标题、列表、表格或代码块,身份属于整个结构,而不是列表里的每一个字符。CLI 读取时的 blocks 包含 ID 和完整块 HTML,patch 必须提交一个完整顶层块。

虚线表示位置可以改变,但原表格的身份不因此重建。块级寻址减少"定位哪一块"的歧义,revision 则判断"是否仍基于同一版文档"。两者一起用,不能互相替代。
一个稳定块 ID 也不授权访问。读者知道某段 ID,仍然需要通过页面范围与角色检查。否则把随机 UUID 当成权限系统,只是把访问规则寄托在"别人猜不到"上。
旧文档没有 ID,怎样平滑接进来
给历史数据统一写入随机 ID 看起来简单,却可能让"读取一个页面"变成写操作。它会触发版本递增、无意义历史和协作冲突,还让首次打开就出现保存提示。
服务端 htmlBlocks() 先检查已有属性。缺少、无效或重复的块 ID,会根据 seed、块位置和完整块内容派生稳定摘要。对同一份旧 HTML 重复读取,在内容没变时得到同样的块身份;真正编辑后,身份再进入持久化内容。
这个方案有明确前提:旧文档尚未持久化 ID 时,插入或修改块可能改变派生结果。因此派生身份帮助兼容读取,不等于历史内容已经具备永不变化的实体标识。调用方仍需要尊重版本,遇到目标变化重新读取。
浏览器侧 CliIdentity 扩展在文档实际变化后扫描顶层节点,给没有 ID 或出现重复 ID 的节点补新值,已有有效 ID 保留。事务标记 addToHistory: false,避免用户撤销一次输入时还要额外撤销内部身份维护。
复制和追加又不同。复制出来的是新的内容实体,应生成新的块身份,不能让两个副本共享同一个可寻址 ID。当前 CLI 在复制和追加场景显式重新生成身份,patch 则保留被替换目标的 ID。
patch 为什么要求一个完整块
patchHtml() 先解析当前 HTML,按 ID 找到目标,再解析替换内容。如果替换结果不是一个完整顶层块,就拒绝。接受"半个表格标签"再字符串拼接,短期可能凑出想要的样子,后续编辑器重新解析时却会重排或丢弃节点。
这一点对表格尤其重要。一个表格块包含行、列和单元格关系,修改必须形成合法结构;把某段原始字符串替换成另一个片段,不知道命中的是文字、属性还是嵌套节点。

图里还没更新数据库。块级改写只是构造候选的一步,最终仍要经过修改集版本检查与统一保存。解析成功证明结构可接受,不证明用户已经授权,也不证明候选所依据的远端状态仍然有效。
下面摘自 patchHtml(),省略前面的定位与候选校验。真正替换时延续目标 ID,再把顶层块序列组回文档:
ts
element.attrs = element.attrs.filter(a => a.name !== "data-flowing-id");
element.attrs.push({ name: "data-flowing-id", value: blockId });
current.blocks[index] = { id: blockId, html: serializeOuter(element) };
return current.blocks.map(block => block.html).join("");
这里替换的是解析后的一整个块,不是在原始 HTML 里按文字做任意 replace()。也正因为 ID 延续,后续读取仍然能识别这是原目标的新内容。
当目标块不存在,服务返回 BLOCK_CONFLICT,让调用方重新读取。一个"自动帮你改到最接近段落"的模糊匹配,可能把方便变成误改;这里保留确定性更合适。
正文变化与格式变化,不应该产生同样的副作用
AI 解答依赖原文。用户把"面试问了缓存击穿"改成"面试问了数据库索引",旧分析继续挂在下面,会造成内容依据错位。但用户只是给同一段加粗,没必要立刻清空解答。
savePracticeLibrary() 根据新旧 content 是否一致保留或清空 analysis。只改富文本格式且文字不变时保留已有分析;正文文字变化则使结构化分析失效。这把内容语义变化与显示变化分开了。
原文、解答和拓展也有各自的编辑字段。用户在解答里补充自己的理解,不应当反过来改掉原文引用。它们在界面里可以切换查看,在数据上仍保持清楚来源。
兼容旧客户端也要按同一原则处理。旧请求没有 sourceDocument,且文字没变,可以保留原来的富文本;如果文字确实变了却没有新富文本,再保留旧 HTML 就会让两个字段打架。服务端需要在这一边界确保它们不会代表两个不同正文。
文字投影也要理解特殊内容
编辑器的 practiceSourceText() 没有简单调用一次全局 textContent。HTML 代码块投影成 [HTML 内容块],完整源码仍在富文本里;Mermaid 保留代码围栏和源码;数学节点把公式表达带进文字。
原因在产品里很具体。一段交互 HTML 可能有大量 CSS 和脚本,如果统统塞进普通正文计数与模型材料,会把学习资料主体淹没。图表源码却往往承载知识结构,保留它对 AI 理解有意义。不同节点对文字消费者的价值不同。
不过这里还需要留意跨入口一致性:浏览器投影器和 CLI 的 HTML 文字提取器由不同实现承担,对特殊节点不应仅凭字段同名就假定所有细节一致。本文能确认的是各自代码的现有处理,后续若统一搜索或 AI 依据语义,应专门做同一文档的跨入口投影测试。
这种提醒不是要求在所有调用方加校验,而是指出真正的职责边界:投影规则应有清楚定义和负责方,消费者不该自己猜一次。这也是文档系统长期演进时容易被低估的一层协议。
保存成功,不应该重置光标和撤销栈
富文本编辑器内部还有选区、输入法组合态和撤销历史。它不只是一个接收字符串的表单。如果每次父组件回传 HTML 都调用 setContent,即使内容看起来一样,也可能重建文档、移动光标或污染撤销栈。
当前 PracticeDocumentEditor 用 ref 记录最新回调和自己刚发出的 HTML。父级回显等于自己发出的值时跳过重置;实际外部文档发生变化时,再按需要设置内容。页面与视图切换的编辑器身份也要明确,避免撤销回到另一页内容。
这能解释一个反直觉的现象:服务器和数据库完全正确,用户仍然会觉得"保存把我刚才写的东西弄乱了"。问题可能发生在响应如何进入编辑器,而不是保存 API。
首次打开旧资料时,Markdown 可以作为兼容输入展示,但打开动作本身不应制造一次无意义保存。只有实际用户编辑之后,才同时持久化文字和富文本。这样历史数据的升级跟随真实修改,阅读不会扰动版本。
外部 HTML 校验,为什么应该放在输入边界
模型、CLI 文件和导入内容都属于外部输入。renderContent() 校验格式和容量,再把 Markdown、text 或 HTML 转成支持的文档。validateHtml() 使用解析器检查标签、属性、链接协议、结构深度和节点数量,并规范链接的打开属性。
这些检查分别防止可识别的故障:危险协议执行、未经支持的结构进入编辑器、超深嵌套耗尽处理资源、非源码区的脚本标签执行。它们集中在接纳文档的位置,业务内部不必在每个成功响应里重新判断每个节点字段。
限制也要按单位描述。原文富文本文档上限、HTML 导入字节上限和普通正文文字上限是不同概念,不能因为常量都与"长度"有关就混用。转义后的 HTML 可能比原文件大,容量配置需要考虑这个关系。
怎样验证这套表示没有把内容磨平
用一份同时包含表格、提示块、公式与 Mermaid 的占位资料,经过浏览器编辑、保存、CLI 读取和块修改,再回到网页看。目标不只是文字还在,还包括表格结构、块 ID 和特殊节点属性是否连续。
单纯打开旧文档时,观察是否没有新增写请求;连续输入后等待保存,观察光标和撤销是否仍然自然;复制文档后确认新块 ID 与来源分离。再改一次纯格式和一次正文,分别检查分析保留与失效。
你可以到流萤 AI 练习整理一份占位面经,再把解答补成自己的学习笔记。人能顺畅编辑、AI 能准确寻址,需要在表示与身份上先形成约定。下一篇继续看,当网页、共享编辑和 CLI 都能写同一份资料,数据怎样在一个事务里保持一致。