把输入框变成 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 核心设计如下:

typescript 复制代码
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: Record<string, 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 就是定义了如何转到 HTML,parseDOM 代表什么样的 HTML 片段解析成这个节点。

再简单看下hardBreakNode,它说白了,就是一个br标签。

yaml 复制代码
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),@ 后弹窗的样式与交互也集中在渲染层完成。

php 复制代码
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)。

python 复制代码
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、标签引用、变量插入这类富文本能力,这篇实现里最值得借鉴。

这也是这次 @文档 实践最核心的收获:

把问题讲清楚、把边界拆清楚,编辑器能力才有机会长期演进。

相关推荐
lunzi_08261 小时前
《图解HTTP》--第5章-与HTTP协作的Web服务器
服务器·前端·http
李剑一1 小时前
面试第一关!面试官:讲一下事件循环机制,宏&微任务,还有渲染时机
前端·面试
shuoshuohaohao1 小时前
《CSS》
前端·css
码农大坚果1 小时前
智能体开发实战02|Harness工程入门
人工智能·agent
西部荒野子1 小时前
Zustand 状态管理规范:别让轻量状态变成隐形通知风暴
前端·javascript
之歆1 小时前
Day03_ES6 深度解析与实战应用:运算符、Symbol、Class、集合与迭代协议
前端·ecmascript·es6
LienJack1 小时前
Context Manager 新范式:Agent 的注意力操作系统
agent
Carson带你学Android1 小时前
Kotlin放大招!官方 Skills 直接喂出「专家级」代码
android·前端·kotlin