把输入框变成 AI 的“超级入口”(ProseMirror 全流程实战)
作者vivo 互联网项目团队- Ding Junjie最近在做知识库问答输入框的 文档 能力表面上是“输入 后选一个文档”的小需求实操后发现核心难点在于编辑器稳定性。本文按真实心路历程展开先讲最直觉的 DOM 方案与踩坑再讲为什么转向 ProseMirror并给出 文档 的落地实现。1分钟看图掌握核心要点一、背景最近要去做知识库问答输入框里有一个很关键的能力文档我认为能力对于知识库agent来说就好似厨师的调味盘。它允许用户在和AI协作的时候的自由组织意图和上下文这样烹饪出来的食材才是顶尖的。所以我们今天聊聊 对于 知识库场景下的agent下 文档 能力 的 mention是如何实现的。第一次拆需求时我的构思很直接在 contenteditable 容器里监听输入识别光标前的 query弹出候选并插入一个不可编辑的引用节点起初我刻意没有上 ProseMirror。因为当时判断 文档 是个轻量交互不想一开始就引入复杂抽象增加团队心智负担。这条路径本身可行甚至很快就能做出可用版本。但进入深水区后我很快就走进了一段弯路当编辑器里开始出现“文本 原子节点”混排时复杂度会从“能不能插进去”转移到“能不能一直稳定”。最先暴露出来的坑是光标位置在嵌套节点里很难稳定恢复输入法IME组合输入期间改 DOM容易打断候选或错位用 innerHTML 纠正结构会污染撤销重做栈临时交互态高亮、弹窗锚点混进文档后很难维护也是在这里我才决定把方案切到 ProseMirror。这次踩坑之后我才真正理解 ProseMirror 的价值。二、ProseMirror 的整体架构方案切到 ProseMirror 后它很快就成了更合适的底座。ProseMirror 围绕不可变文档doc与事务Transaction构建配合编辑器状态EditorState与视图层EditorView并通过 Schema、Plugin、NodeView、Decoration 等扩展点协同工作处理跨浏览器的 contenteditable/选区/IME 输入与光标映射。简单过完ProseMirror的整体架构之后我们一起看下在这个 能力(下图)用了哪些 ProseMirror 的能力。先从 schema 说起这是定义编辑器由哪些元素组成以及 ProseMirror 的 node 和实际 DOM 的互相转换规则。如上图输入框内需要三件东西首先 最基础的是 textNode 文本其次需要 docrefNode(到的文档)最后也是非常容易忽略的就是 hardBreakNode(换行能力)。我们的输入框应该由这三个部分组成。故此schema 核心设计如下import { Schema, NodeSpec } from prosemirror-model // 原子行内引用节点docref const docrefNode: NodeSpec { inline: true, group: inline, atom: true, selectable: true, attrs: { id: { default: }, label: { default: }, mtype: { default: doc }, }, toDOM(node) { const { id, label, mtype } node.attrs const attrs: Recordstring, string { type: mtype } if (id) attrs.id id return [mention, attrs, label] }, parseDOM: [{ tag: mention, getAttrs(dom) { const el dom as HTMLElement const type (el.getAttribute(type) || ).toLowerCase() const id el.getAttribute(id) || const label el.textContent || if (type no-access) { return { id: , label, mtype: no-access } } return { id, label, mtype: doc } }, }], } // hardBreakNode实现 略其中 给docrefNode的 attrs 定义的三个字段id 代表这个 的文档的唯一id这样可以知道文档内容在哪里可以查label 就是展示文本(一般就是文档标题)type是为了向后兼容未来会 更多的东西需要有不同的样式包括 VAPD 的需求、任务等。toDOM 就是定义了如何转到 HTMLparseDOM 代表什么样的 HTML 片段解析成这个节点。再简单看下hardBreakNode它说白了就是一个br标签。const hardBreakNode: NodeSpec { inline: true, group: inline, selectable: false, parseDOM: [{ tag: br }], toDOM: () [br], }三、交互逻辑前文用 schema 定义了三类节点——text、docref、hard_break。在输入阶段还会出现一种“活跃查询”态 后的即时查询与高亮但它不属于文档结构应该作为渲染层的临时状态处理。如下图可以很明确我们还需要监听用户输入 把“编辑 → 渲染 → 视图”这一整条链路串起来处理中间的匹配、定位、弹窗等复杂逻辑。先把流程说清楚再看实现触发行首或空格后输入 进入活跃态计算匹配范围与查询字符串。显示给匹配范围加 查询高亮块用它来显示占位与高亮同时需要精准定位弹窗方便选择。确认选择候选后以一次事务插入 docref 节点并将光标放到其后。让我们从 createMentionPlugin 开始 看看如何实现核心能力由 Suggestion(由ProseMirror的Plugin实现) 提供匹配/装饰createMentionPlugin 作为组合层对接弹窗渲染SuggestRenderer 后弹窗的样式与交互也集中在渲染层完成。exportconst createMentionPlugin (opts {}) Suggestion({ pluginKey: DocMentionPluginKey, char: , allowedPrefixes: [ ], // 行首或空格后触发传 null 则放开前缀限制 allowSpaces: false, allowToIncludeChar: false, decorationClass: pm-mention-query, decorationContent: 输入文档名称, render: () createDocSuggestRenderer({ getItems: opts.getItems, onSelect: opts.onSelect })(), })其实没什么核心都在Suggestion和createDocSuggestRenderer里Suggestion完全基于ProseMirror实现我们后面就聊Suggestion但在聊Suggestion之前我们先把页面上明晃晃的一个样式(查询高亮块)聊清楚下图是“查询高亮块”的关键实现它是临时状态最终会被选中的 mention 替换因此不写入文档(schema)更合理。编辑器支持撤销/重做我们也不希望把“搜索中的中间态”压进历史栈。为此 ProseMirror 提供了 Decoration——专为这类场景设计只在渲染层出现用于显示与定位不影响文档结构参考 decorations。return DecorationSet.create(state.doc, [ Decoration.inline(range.from, range.to, { nodeName: span, class: isEmpty ? pm-mention-query is-empty : pm-mention-query, // 以 data-decoration-id 将装饰节点与插件状态绑定便于精准定位弹窗 data-decoration-id: decorationId, data-decoration-content: 输入文档名称, }), ])接下来我们聊聊 Suggestion, 在这儿之前我们先提前了解一下ProseMirror的另一个机制Plugin就像Webpack的插件机制一样ProseMirror也有个Plugin可以把它当作“观察者 小状态机”来理解它主要在每次事务 apply 里做各种处理。Suggestion 的实现按“触发 → 显示 → 确认”的顺序说明清楚触发监听每个事务在 state.apply 中调用 findSuggestionMatch根据 char/allowedPrefixes/allowSpaces 在光标前匹配触发串得到 range/query/text。显示弹窗弹窗渲染交给 createDoc-SuggestRenderer它返回 { onBefore-Start, onStart, onBeforeUpdate, on-Update, onExit, onKeyDown }。on-Start/onUpdate 接收的 props.clientRect() 用于定位。确认插入弹窗内部点击候选会触发 select(item)经由 createDoc-SuggestRenderer 的 onSelect(item) 抛出外层 createMentionPlugin 的 onSelect 接住并调用 insertDocRef(attrs)最终把 docref 原子节点插入文档并将光标移到其后。从弹窗回写 docref 的路径渲染器内部点击候选时触发 select(item)外部通过 onSelect(item) 接住并插入。本文实现中createMentionPlugin 传入的 onSelect 会调用组件的 insertDocRef({ id, label, subtitle })createMentionPlugin({ getItems: async q /* 拉取 { id,label,subtitle }[] */, onSelect: item { handleSelectSuggestion({ id: String(item.id), label: item.label, subtitle: item.subtitle }) } }) function handleSelectSuggestion(attrs: DocRefAttrs){ insertDocRef(attrs) // 创建 docref 原子节点 Hair Space并将光标置于其后 }至此完工。其他细节可以评论交流~四、结语回到全文真正要解决的是让 文档 在真实输入场景下稳定可用光标不乱跳、输入法不中断、撤销重做可预期、异步检索不串结果。这次方案的完成度可以概括为“主链路闭环 关键稳定性收敛”从触发、匹配、渲染、选择到插入已经打通文档内容与临时态被拆到不同层核心交互在事务边界内可控。它并不意味着所有问题都被一次性解决但至少把复杂度从“经验修补”推进到了“结构化治理”。如果你也在做 mention、标签引用、变量插入这类富文本能力这篇实现里最值得借鉴。这也是这次 文档 实践最核心的收获把问题讲清楚、把边界拆清楚编辑器能力才有机会长期演进。