作者:桃酥
- 摘要:为了让用户在智能体输入框中编排复杂任务、复用已有能力,实现了一个「文字 + 卡片」混排的指令编辑器。本文从需求分析和技术选型出发,介绍整体架构、核心实现、实际踩坑,以及这套方案的适用范围与后续改进。


为什么做:让用户在输入框里组合已有能力
在我们做的智能体功能中,用户不仅需要提出一个问题,还需要组织更复杂的执行要求:先做什么,满足什么条件后再做什么,以及过程中要用到哪些已有能力。
这些要求涉及两类内容。自然语言负责描述任务、条件和前后衔接;已经定义好的指令和动作,则是可以被引用、复用的能力。我们希望用户在同一个输入框内,把它们组合起来交给 Agent。
以一个示意场景为例:
每天早上 指令:查询报表 ,如果超过阈值,就 动作:发送通知。
这里的方括号代表卡片。"每天早上""如果超过阈值"是用户自由编写的文字;"查询报表""发送通知"则引用已有的指令或动作,不需要用户在每次编写任务时重新描述其全部内容。

因此,要解决的不是普通文本输入,也不是文章排版,而是让自然语言和结构化引用在同一个编辑区域中混排。文字可以自由修改,卡片可以插入、替换和调整位置,最终共同组成一段执行要求。
编辑器负责这段内容的编写与表示;Agent 如何理解条件、调度能力和执行任务,不在本文的讨论范围内。
如何分析:从混排需求到技术选型
1.先确定编辑器需要处理什么
从编辑行为看,两类内容的区别不是"是否自定义",而是"如何修改":普通文字按字符编辑,已有卡片则需要作为一个整体操作。即使支持创建自定义指令,创建完成后,它仍然是一张完整的卡片。
这带来了几项具体要求:
-
混合输入
文字与卡片处于同一条编辑流中,可以在卡片前后继续打字、换行。
-
卡片操作
通过菜单插入、点击替换、拖拽排序;删除时不能把卡片拆成半截。
-
内容转换
界面展示卡片,保存时输出约定的文本标记,再次打开能够还原。
-
输入体验
菜单不能抢走正常输入,中文输入法、光标和选区需要协调处理。
判断方案是否合适,不能只看"能不能画出一个标签",还要看标签参与编辑后,这些行为能否一起成立。
2.比较几条实现路线
我调研了 textarea 叠加标签、React Quill、CodeMirror,以及直接使用 contentEditable 的方案。下面的取舍针对本次需求,并不代表其他方案无法实现类似交互。


最终,我们选择基于 React 和 contentEditable 实现一个专用编辑器:普通文字保留原生编辑能力,卡片渲染为不可直接编辑的节点,内部维护文本与 HTML 的转换,再补上菜单、键盘和拖拽交互。
这个选择的收益是可以直接控制混排 DOM 和交互细节,代价是必须承担编辑行为的维护成本。contentEditable 提供的是可编辑的容器,不是一套已经处理好所有边界的编辑器。
如何实现:整体架构、核心机制与实际踩坑
1.整体架构:对外传递内容,对内管理状态与 DOM
先看整体职责和数据流。下图按逻辑职责组织,不代表这些能力都已经拆成独立模块;转换模块和光标 Hook 的进一步抽取,放在后续改进中。
bash
flowchart TB
Parent["业务父组件"]
subgraph Editor["指令编辑器"]
Input["内容入口:初始标记文本"]
Output["对外通知:内容变化、失焦"]
subgraph Interaction["交互与状态"]
Events["输入 / 菜单选择 / 键盘 / 拖拽"]
UI["React state:菜单、搜索词、编辑模式、拖拽展示"]
Flags["ref:创建中标志、当前拖拽卡片等过程信息"]
end
subgraph Common["通用处理职责"]
Convert["内容转换:标记文本 ↔ HTML"]
Position["位置处理:文本位置、DOM 位置、屏幕坐标"]
Operations["节点操作:插入、替换、删除、换行、移动"]
Normalize["DOM 规范化:异常结构转回约定结构"]
end
DOM["contentEditable DOM:文字 + 卡片 + 换行"]
end
Parent -->|传入初始内容| Input
Input --> Convert
Convert -->|渲染| DOM
DOM -->|读取内容| Convert
Convert -->|标记文本| Output
DOM -->|失焦| Output
Output --> Parent
DOM -->|编辑事件| Events
Events --> UI
Events --> Flags
Events --> Position
Events --> Operations
Position -->|插入位置与落点| Operations
Position -->|菜单坐标| UI
Operations --> DOM
Events -->|发现异常结构| Normalize
Normalize -->|复用双向转换| Convert
对外,组件保持简单。 父组件传入初始内容,监听内容变化和失焦,不需要自己处理卡片节点、光标位置或浏览器生成的 HTML。
对内,UI 状态与编辑 DOM 分开管理。 菜单显隐、位置、搜索词和编辑模式等影响界面的信息放在 state 中;内容转换、光标定位和节点修改则直接操作编辑器 DOM。
"是否正在创建新指令""当前拖着哪张卡片"这类过程信息用 ref 保存,便于事件回调读取最新值。是否使用 ref 的依据是这份信息是否需要驱动渲染,而不是用它替代所有状态管理。
通用能力主要集中在转换、定位和节点操作上:初始化、粘贴和规范化都要用到内容转换;菜单、插入和拖拽都需要位置处理。把这些职责放在一起看,可以避免每个交互各自维护一套内容规则。
2.核心实现:让混排内容能够连续编辑
2.1.同一份内容,两种表示
界面上,卡片是带图标的标签;保存时,卡片对应一段特殊标记文本。当前有指令(Command)和动作(Action)两类,例如:
bash
每天早上 <|command_查询报表|>,如果超过阈值,就 <|action_发送通知|>。
编辑器再次打开时,把标记还原成卡片;内容输出时,再把卡片还原成标记。这样,展示结构与存储表示不必相同,但需要遵守同一套转换规则。

文本转 HTML 分为三步:
-
先用占位符保护卡片标记,再转义普通文本中的 HTML 字符,避免把用户文本当成 HTML 执行,也避免卡片语法被一起转义。
-
还原卡片标记,并转换为带图标的标签节点。普通卡片设置
contenteditable="false",同时支持点击和拖拽。 -
将换行符转换成编辑区域中的换行节点。
HTML 转文本 时则反过来:卡片节点恢复为 <|command_xxx|> 或 <|action_xxx|>,换行恢复为 \n,清理用于定位光标的零宽字符,并处理转换产生的多余末尾空行。
粘贴复用这套转换:只读取剪贴板中的纯文本,再转换成编辑器可接受的 HTML 插入,避免把外部页面的样式和复杂结构一起带进来。
2.2.卡片与菜单:文字可编辑,引用整体操作
卡片有三种状态:

菜单提供两个入口:输入 / 时,在光标位置弹出插入菜单;点击已有卡片时,在卡片旁弹出替换菜单。
菜单内支持搜索、指令与动作分组、方向键选择和回车确认。按 + 可以创建新指令,此时插入的是一张临时可编辑卡片。

这里需要同时处理两种位置:一是编辑内容中要替换或插入的位置,二是菜单应该显示的屏幕坐标。两者不能混用。
触发斜杠菜单时,先获取光标前的文本、定位最后一个 /,再找到它对应的 DOM 位置。实现中使用 TreeWalker 遍历文本节点、累计字符长度,将文本位置映射到具体节点及偏移,然后结合光标位置计算菜单坐标。
2.3.键盘与换行:同一个按键,要区分编辑状态
Backspace 可能删除一个字符、一整张卡片或一个换行;Enter 在普通输入中表示换行,在创建新指令时则表示确认。如果正在创建的卡片内容为空,再按 Backspace,就删除这张临时卡片。
换行时,编辑器插入换行节点。如果后面没有内容,就补一个零宽字符作为光标锚点,让用户能够在新行继续输入。这个字符只服务于编辑过程,输出文本时会清理掉。
2.4.中文输入法:先完成选字,再更新搜索
中文输入不是一次性得到最终文字。拼音组合期间会触发多次 input,如果每次都更新搜索,菜单就会跟着尚未确认的内容变化,甚至因为暂时搜不到结果而关闭。
我通过组合输入的开始和结束事件区分这两个阶段:组合期间暂停搜索词更新,组合结束后再使用确认的文字更新搜索。
此外,搜索无结果时不会立即关闭菜单,而是延迟约 1 秒再判断。这是对暂时无结果状态的缓冲,不能替代对输入法组合态的判断。

2.5.DOM 规范化:复用转换,但不在每次输入时重建
浏览器可能生成额外标签,创建指令的过程也可能留下临时可编辑结构。编辑器需要把这些结构收敛回约定的"文字、卡片、换行"。
处理方式是复用双向转换:先将当前 DOM 转成文本,再由文本生成标准 HTML。但不会每次输入都重建编辑区域,只在发现异常结构时进行规范化,减少对正在进行的编辑操作的干扰。
创建新指令期间需要跳过规范化,因为此时的可编辑卡片还处在临时状态,直接按普通卡片规则重建会破坏创建过程。
2.6.拖拽:把鼠标落点变成 DOM 插入点
拖拽开始后,卡片以半透明状态显示。放下时,先将鼠标坐标转换为 DOM 中的插入位置,再克隆卡片插入、补充空格,最后删除原位置的卡片。
其中,落点计算需要适配不同浏览器。原实现对 Chrome/Safari 与 Firefox 分别处理,后续的节点插入和删除流程则遵循同一套规则。
3.实际踩坑:问题往往出在两种行为交接的地方
单独展示文字或卡片并不复杂。难点集中在它们交接的位置,以及输入、菜单、拖拽等事件相互影响的时候。

下面把具体问题和处理方式放在一起,补充前面实现机制背后的原因。

还有两个容易被忽略的时序与回退问题。
菜单尚未选中,编辑器先失焦。 点击菜单时,编辑器的失焦逻辑可能先关闭菜单,导致点击无法完成。当前实现将失焦回调延迟约 200ms,让菜单点击有机会先处理。这个时间值是本次实现的处理方式,不是所有编辑器都适用的固定规则。
局部 DOM 操作失败。 插入节点失败时,回退到"修改文本后重新渲染"的路径。这条路径用于兜底,不作为每次输入的默认方式。
这些问题说明,只完成插入和展示还不够。要让混排编辑器真正可用,必须继续处理删除、换行、输入法和事件顺序,尤其是卡片与普通文字相邻时的行为。
适用范围:什么问题值得用这套方案
这套方案适合内容类型有限、结构化引用明确、交互需要定制的编辑场景。

这些场景可以复用设计思路,但仍需按业务定义自己的标记格式、卡片状态和交互规则。

以下情况则不适合直接沿用:
-
只需要纯文本输入
textarea就能满足,不必承担自定义 DOM、光标和选区的维护成本。 -
需要完整富文本排版
当需求包含表格、复杂样式和文档结构时,更适合评估 Slate、Quill 等成熟方案,而不是在当前实现上不断追加能力。
-
需要多人实时协同
当前方案没有覆盖协同编辑,也没有 OT/CRDT 等相应机制,不能直接当作协同编辑器使用。
选择它的前提,是愿意为了特定的混排交互承担编辑细节的维护,而不是认为直接使用 contentEditable 总比现成编辑器简单。
后续改进:先补验证,再整理通用能力
后续打磨主要围绕两件事:让已有行为更容易验证,让重复使用的处理逻辑更容易维护。下面都是计划中的改进,不作为已经完成的能力。
1.补齐内容转换测试
优先验证文字、卡片标记和换行的双向转换,覆盖特殊字符、连续卡片、空行以及零宽字符清理。重点检查合法内容经过"文本 → HTML → 文本"后,业务文字与卡片引用是否按约定保留。
2.补齐真实编辑交互测试
为删除、拖拽、菜单选择和新建指令补集成测试,并覆盖中文输入与不同浏览器。位置计算中不依赖 DOM 的部分可以单独测试;光标矩形、选区和拖拽落点等依赖浏览器的行为,需要在真实浏览器环境中验证。
测试应重点覆盖组合场景,例如卡片紧邻换行时删除、输入法确认后选择菜单、创建指令过程中失焦。单个操作正常,不代表这些操作连续发生时仍然正确。
3.进一步抽取转换与光标处理
将文本与 HTML 的转换逻辑拆成独立模块,把光标和选区操作整理为 Hook 或工具层,让菜单、拖拽和键盘处理复用相同的位置规则。抽取的目标是减少重复实现,并让这些规则能够被单独验证。
回到最初的需求,我们希望用户在一个输入框里,用自然语言组织任务,用卡片复用已有能力。围绕这个目标,选择了 contentEditable,完成了混排表示和编辑交互,也承担了浏览器原生编辑行为带来的复杂性。
这次实践最值得复用的,不只是卡片的展示方式,而是让文字、结构化引用、光标和输入事件在同一套规则下协同工作的方法。它决定了这个输入框是只能演示,还是能够承接连续、真实的编辑操作。



