尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Plate AI 预览快照重构:`tf.ai.*` 生命周期 Transform 的设计与实践

发布时间:2026/9/15 18:42:03

资讯中心
01
ARTICLE

Plate AI 预览快照重构:`tf.ai.*` 生命周期 Transform 的设计与实践

Plate AI 预览快照重构:`tf.ai.*` 生命周期 Transform 的设计与实践
Plate AI 预览快照重构tf.ai.*生命周期 Transform 的设计与实践【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate导读本文围绕 Plate 仓库中 docs/plans/2026-03-26-ai-preview-tf-api-refactor.md 记录的「AI Previewtf.aiRefactor」展开剖析一次典型的插件级 API 面重构将散落各处的 AI 预览底层快照操作收敛为BaseAIPlugin暴露的tf.ai.*生命周期变换同时保留「预览块不进历史、接受时提交一个全新批次、AI Undo 恢复流式输出前的文档值」的行为边界。读完本文你将掌握 AI 预览begin/cancel/accept/discard/has五个生命周期原语的职责划分、WeakMap快照存储与批处理withMerging/withNewBatch/withoutSaving的组合原理以及插入模式调用方如何从底层 helper 迁移到高层tf.ai.*接口。一、重构背景低层快照 Helper 的职责错位在重构之前AI 预览相关的存储与操作散落在packages/ai/src/lib/transforms/aiStreamSnapshot.ts中以WeakMap按编辑器实例保存快照。从该文件源码可见其数据结构type AIPreviewState { originalBlocks: Value; // 预览开始前的文档块回滚切片 selectionBefore: TRange | null; // 预览开始前的选区 }; const AI_STREAM_SNAPSHOT new WeakMapSlateEditor, AIPreviewState();WeakMap以编辑器对象为键意味着快照生命周期天然跟随编辑器实例编辑器被 GC 回收时快照也随之释放不会造成内存泄漏。文件同时导出beginAIPreview、hasAIPreview、cancelAIPreview、discardAIPreview、acceptAIPreview五个顶层函数并导出标记预览块的常量AI_PREVIEW_KEY aiPreview。从源码与调用点搜索调用点清单可以确认重构前这套低层 helper 被以下位置直接使用acceptAIChat插入模式接受预览undoAIAI Undo预览激活时回滚resetAIChat重置会话编辑器 AI kitwithAIChat等流式集成测试aiStreamSnapshot.spec.ts。问题在于这些低层函数暴露的是「怎么做」的实现细节而调用方关心的只是「生命周期事件」。同时AIPlugin已经承载了 AI 相关的编辑器变更语义insertNodes、removeMarks、removeNodes、undo见packages/ai/src/lib/BaseAIPlugin.ts预览生命周期本应归属同一宿主却游离在外形成职责错位。重构目标文档 Goal可归纳为四点用tf.ai.*生命周期变换替换低层 AI 预览快照 helper 面保持全文档快照策略为内部实现细节不对外暴露迁移插入模式预览调用方到新接口保持「预览不进历史、接受提交单个新批次、AI Undo 恢复流前值」的现有行为。二、目标形态BaseAIPlugin上的tf.ai.*变换重构的核心产物在 packages/ai/src/lib/BaseAIPlugin.tsexport const BaseAIPlugin createTSlatePluginBaseAIPluginConfig({ key: KEYS.ai, node: { isDecoration: false, isLeaf: true }, }) .extendTransforms(({ editor }) getAITransforms(editor)) .extendEditorTransformsBaseAIPluginConfig[transforms](({ editor }) ({ ai: getAITransforms(editor), }));getAITransforms将低层函数通过bindFirst绑定编辑器后注册为两组入口const getAITransforms (editor: SlateEditor) ({ acceptPreview: bindFirst(acceptAIPreview, editor), beginPreview: bindFirst(beginAIPreview, editor), cancelPreview: bindFirst(cancelAIPreview, editor), discardPreview: bindFirst(discardAIPreview, editor), hasPreview: bindFirst(hasAIPreview, editor), insertNodes: bindFirst(insertAINodes, editor), removeMarks: bindFirst(removeAIMarks, editor), removeNodes: bindFirst(removeAINodes, editor), undo: bindFirst(undoAI, editor), });这里展示了 Plate 插件系统的两种扩展机制.extendTransforms(...)注册到编辑器的通用 transforms 表适合库内部与类型收窄场景.extendEditorTransforms(...)注册editor.tf.ai.*命名空间形成对外最干净的公表面。BaseAIPluginConfig通过PluginConfigai, {}, {}, { ai: {...} }的第四个泛型参数声明了 transforms 类型其中五个预览原语带有 JSDoc 注释acceptPreview将激活的预览作为一个全新的可撤销批次提交beginPreview捕获回滚切片与选区用于 AI 预览cancelPreview恢复回滚点并清除激活的预览状态discardPreview仅清除预览簿记不恢复内容hasPreview报告是否存在 AI 预览回滚点。两条访问路径文档 Findings 明确指出对外最干净的公表面是tf.ai.*而库内部与类型更严格的调用点仍可通过editor.getTransforms(BaseAIPlugin).ai访问同一组变换。两条路径指向同一份getAITransforms结果因此不存在行为分叉只是类型宽度不同// 对外公表面泛型编辑器类型更宽 editor.tf.ai.beginPreview({ originalBlocks }); // 库内部、类型更窄的调用点 editor.getTransforms(BaseAIPlugin).ai.beginPreview({ originalBlocks });getTransforms(BaseAIPlugin)返回的ai对象拥有OmitFirst处理后的精确函数签名第一个editor参数已被绑定在严格的泛型编辑器上下文中类型推断更可靠。三、快照存储与预览范围判定内部实现重构保留了全文档快照策略但将其封闭为内部实现。aiStreamSnapshot.ts中有三个值得展开的内部机制。3.1 预览范围扫描预览块通过AI_PREVIEW_KEY值为aiPreview标记getAIPreviewRange顺序扫描editor.children返回三种结果type PreviewRange | { kind: invalid | none } | { kind: range; start: number; end: number };range预览块构成连续区间[start, end]invalid预览块被非预览块分隔成多段说明文档结构已被破坏此时拒绝任何提交/取消操作none当前不存在预览块。3.2 预览块的剥离cloneAcceptedPreviewBlocks在接受预览时深拷贝区间内的块并递归剥离两类标记元素级AI_PREVIEW_KEYaiPreview属性文本级KEYS.ai对应的类型属性文本上的ai: true标记由insertAINodes写入。剥离后的块才是进入历史、真正落盘的「干净」内容。3.3 锚点与选区恢复removeAIPreviewAnchor通过editor.tf.removeNodes移除KEYS.aiChat类型的锚点节点type: aiChatrestoreAIPreviewSelection快照选区非空则tf.select(cloneDeep(selection))恢复为空则tf.deselect()。四、五个生命周期原语的职责与批处理语义acceptAIPreview与cancelAIPreview是批处理语义最重的两个函数也是「预览不进历史」边界的关键。4.1 begin / has / discardexport const beginAIPreview (editor, { originalBlocks [] } {}) { if (getAIPreview(editor)) return false; // 已有快照则拒绝重复捕获 AI_STREAM_SNAPSHOT.set(editor, { originalBlocks: cloneDeep(originalBlocks), selectionBefore: cloneDeep(editor.selection), }); return true; };beginPreview幂等已存在快照时返回false防止嵌套捕获覆盖回滚点hasPreview仅查询WeakMapdiscardPreview只清簿记、不碰文档适用于「内容已经由其他路径处理只需释放快照」的场景例如 resetAIChat.ts 中undo false的分支。4.2 cancelPreview无痕回滚export const cancelAIPreview (editor) { const preview getAIPreview(editor); if (!preview) return false; const range getAIPreviewRange(editor); if (range.kind invalid) return false; editor.tf.withoutSaving(() { if (range.kind range) { replacePreviewRange(editor, range, preview.originalBlocks); } removeAIPreviewAnchor(editor); restoreAIPreviewSelection(editor, preview.selectionBefore); }); clearAIPreview(editor); return true; };关键点是editor.tf.withoutSaving(...)整个回滚删除预览区间、插入 originalBlocks、移除锚点、恢复选区都不写入历史。因此取消预览对 undo 栈完全透明用户按 Ctrl/CmdZ 不会看到中间态。4.3 acceptPreview一次全新批次if (range.kind range) { const acceptedBlocks cloneAcceptedPreviewBlocks(editor, range); editor.tf.withoutSaving(() { replacePreviewRange(editor, range, preview.originalBlocks); removeAIPreviewAnchor(editor); restoreAIPreviewSelection(editor, preview.selectionBefore); }); editor.tf.withNewBatch(() { if (preview.originalBlocks.length 0) { for (let index preview.originalBlocks.length - 1; index 0; index--) { editor.tf.removeNodes({ at: [range.start index] }); } } if (acceptedBlocks.length 0) { editor.tf.insertNodes(acceptedBlocks, { at: [range.start] }); } }); const lastBatch editor.history?.undos.at(-1); if (lastBatch) { lastBatch.selectionBefore cloneDeep(preview.selectionBefore); } }接受分两阶段无痕阶段withoutSaving先用originalBlocks还原现场再移除锚点、恢复选区——此时历史里没有产生任何条目提交阶段withNewBatch删除回滚切片、插入剥离后的预览块作为一个全新的、原子化的可撤销批次入栈。最后手动改写该批次条目的selectionBefore为快照选区保证撤销这次接受后光标回到预览开始前的位置。这正是文档所述「accept commits one fresh batch」与「AI undo restores the pre-stream value」在实现层的落地。配套的withAIBatchpackages/ai/src/lib/transforms/withAIBatch.ts为普通 AI 变更提供「合并或分裂 打ai标记」的能力export const withAIBatch (editor, fn, { split } {}) { if (split) { editor.tf.withNewBatch(fn); // 分裂为独立批次 } else { editor.tf.withMerging(fn); // 与相邻批次合并 } const lastBatch editor.history?.undos.at(-1) as AIBatch | undefined; if (lastBatch) lastBatch.ai true; // 标记为 AI 批次 };AIBatch History[undos][number] { ai?: boolean }这个ai标记正是undoAI判定「本批次是否属于 AI 操作」的依据。五、undoAI与 AI 批次的撤销语义packages/ai/src/lib/transforms/undoAI.ts 是预览生命周期与撤销系统协作的枢纽export const undoAI (editor: SlateEditor) { if (hasAIPreview(editor) cancelAIPreview(editor)) return; // 预览激活 → 取消预览 const hasAINodeOrAISuggestion editor.api.some({ at: [], match: (n) !!(n as any).ai }) || editor.api.some({ at: [], match: (n) !!n[getTransientSuggestionKey()] }); if ((editor.history.undos.at(-1) as any)?.ai hasAINodeOrAISuggestion) { editor.undo(); editor.history.redos.pop(); // 阻止 AI 批次进入重做栈 return; } if (hasAINodeOrAISuggestion) { cancelAIPreview(editor); // 兜底清理 } };三条分支对应三种状态预览激活直接cancelAIPreview无痕回滚到流式输出前不触碰历史栈顶是 AI 批次且文档存在 AI 节点/瞬时建议执行editor.undo()后redos.pop()——撤销 AI 批次但不允许重做避免用户重做出一段已被替换的 AI 输出文档残留 AI 标记但无 AI 批次兜底调用cancelAIPreview清理。其余 AI 节点写入/移除辅助insertAINodes自动加ai: true并定位到选区末尾与removeAIMarks用unsetNodes清除KEYS.ai标记则保持原有职责不变。六、插入模式调用方迁移callsite 迁移文档 Checklist 第 4 项「Migrate AI chat preview callsites off direct snapshot helpers」的落点集中在插入模式mode insert。以 acceptAIChat.ts 为例export const acceptAIChat (editor: PlateEditor) { const mode editor.getOption(AIChatPlugin, mode); if (mode insert) { const ai editor.getTransforms(BaseAIPlugin).ai; const api editor.getApiAIChatPluginConfig({ key: KEYS.ai }); const focusPoint getAcceptedInsertFocusPoint(editor); if (!ai.acceptPreview()) { // 无预览可接受时兜底清理预览标记与锚点 withAIBatch(editor, () { editor.tf.unsetNodes(AI_PREVIEW_KEY, { at: [], match: (node) ElementApi.isElement(node) !!(node as any)[AI_PREVIEW_KEY], }); ai.removeMarks(); editor.getTransforms(AIChatPlugin).aiChat.removeAnchor(); }); } api.aiChat.hide(); editor.tf.focus(); if (focusPoint) { editor.tf.select({ anchor: focusPoint, focus: focusPoint }); } } if (mode chat) { withAIBatch(editor, () { acceptAISuggestions(editor); }); editor.getApi(AIChatPlugin).aiChat.hide(); } };迁移后的调用点不再直接 import 快照函数而是统一走editor.getTransforms(BaseAIPlugin).ai。acceptPreview返回false时没有激活预览插入模式回退到兜底清理unsetNodes清掉残留AI_PREVIEW_KEY、ai.removeMarks()清文本标记、aiChat.removeAnchor()移除锚点再用withAIBatch包成一个带ai标记的批次。调用点迁移一览文件迁移后的用法语义acceptAIChat.tsai.acceptPreview()插入模式提交预览undoAI.tshasAIPreview/cancelAIPreview预览激活时撤销即取消resetAIChat.tsai.undo()/ai.discardPreview()重置会话默认走 undoundo: false时仅释放快照AIChatPlugin.tsai.undo()聊天相关快捷键/清理路径submitAIChat.tsai.undo()提交前的旧预览回滚insertBelowAIChat.tsai.undo()下方插入模式切换时回滚withAIChat.tsai句柄统一获取编辑器 AI kit 的入口收敛七、行为契约的测试验证packages/ai/src/lib/transforms/aiStreamSnapshot.spec.ts 使用bun:test与 mock 编辑器createEditor手工实现了tf.insertNodes/tf.removeNodes/tf.withNewBatch/tf.withoutSaving等从行为层锁定了五个契约捕获幂等beginPreview后再次beginPreview返回falsecancelPreview后文档与选区完整恢复为初始值且tf.setValue未被调用证明恢复是通过增量操作而非整体 setValue 完成空安全无预览时hasPreviewfalse、cancel/discard/accept均返回falsediscard 不动内容discardPreview后文档保持预览态、选区保持原样仅清快照空选区恢复快照选区为null时cancelPreview调用tf.deselect选区恢复为null单批次提交acceptPreview恰好调用一次withNewBatch提交后文档为「剥离标记的预览块 未触碰块」且history.undos.at(-1).selectionBefore等于初始选区。最后一个用例还通过真实createSlateEditor挂载BaseParagraphPlugin BaseAIPlugin验证了完整链路ai.beginPreview→withoutSaving插入预览块与锚点历史长度为 0→ai.acceptPreview历史长度变为 1→editor.undo()后文档与选区均回到初始值。这套用例正是文档 Checklist「Add or update tests for the new preview lifecycle contract」的产物可作为任何 fork 或上层封装回归测试的参照。八、验证结论与遗留债务文档 Findings 最后一条交代了验证边界packages/ai包级验证platejs/ai通过过滤后的apps/www类型检查仍存在与本次重构无关的工作区导出/类型失败需先执行根目录pnpm build该债务不在本次重构范围内。也就是说重构本身没有引入新的包内类型或行为问题apps/www的剩余失败源自工作区其他包的导出面问题属于重构之外的既有债务。九、模式总结从本次重构可复用的三条原则生命周期归属宿主插件凡与某插件强绑定的编辑语义预览、撤销、批处理标记应随插件注册为tf.*变换避免调用方依赖模块级低层函数。BaseAIPlugin同时提供editor.tf.ai.*对外与editor.getTransforms(BaseAIPlugin).ai内部精确类型双入口是「窄公表面 宽内部可达」的典型布局。用批处理原语划分历史边界withoutSaving回滚无痕、withMerging/withNewBatch合并或分裂批次、手动改写selectionBefore三者组合才能精确控制「预览不污染历史、接受只产生一个批次、撤销落点正确」。行为契约先行aiStreamSnapshot.spec.ts用 mock 编辑器把每个生命周期语义固化为可断言的契约重构后调用方迁移见第六节表格没有改变任何行为靠的就是测试对行为边界的锁定。十、延伸阅读预览快照实现packages/ai/src/lib/transforms/aiStreamSnapshot.ts插件变换注册packages/ai/src/lib/BaseAIPlugin.ts生命周期契约测试packages/ai/src/lib/transforms/aiStreamSnapshot.spec.ts批处理标记工具packages/ai/src/lib/transforms/withAIBatch.tsAI 节点插入与标记清理insertAINodes.ts、removeAIMarks.ts插入模式调用方acceptAIChat.ts、resetAIChat.ts【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。