
1. 项目概述为什么输入框需要“AI化”在内容创作和编辑领域我们正处在一个奇妙的转折点。过去一个输入框无论是富文本编辑器还是代码编辑器的核心功能是“接收”和“格式化”用户输入。用户是唯一的内容生产者编辑器则是被动的容器。但现在随着大语言模型LLM能力的爆发这种单向关系正在被重构。输入框不再仅仅是内容的终点更应成为智能交互的起点——一个能理解上下文、提供建议、辅助创作甚至直接生成内容的“超级入口”。想象一下你在写一篇技术博客卡在了某个概念的描述上。传统做法是切出编辑器打开搜索引擎或另一个AI工具查询、复制、再粘贴回来。这个过程打断了心流割裂了体验。而“AI超级入口”的愿景是在当前的编辑光标处直接唤起一个智能助手它基于你已写的内容理解你的意图提供续写、改写、翻译、总结或代码补全等能力并将结果无缝插入。这不仅仅是添加一个“AI按钮”而是将AI能力深度编织进编辑器的交互流与数据模型中。要实现这个愿景选择一个强大、灵活且可深度定制的编辑器框架是基石。这正是ProseMirror大显身手的地方。它不是一个开箱即用的“成品”编辑器像TinyMCE或Quill而是一个用于构建富文本编辑器的工具包Toolkit。它提供了严谨的文档模型Schema、事务Transaction系统、状态State管理和插件Plugin架构。这意味着我们可以从底层精确控制编辑器的每一个行为包括如何与AI服务交互、如何将AI生成的内容安全地插入文档、如何管理AI操作的历史记录撤销/重做等。因此这个项目的核心就是利用ProseMirror构建一个富文本编辑器并为其注入AI能力打造一个从用户触发、到AI服务调用、再到结果渲染与集成的全流程实战案例。我们将深入ProseMirror的肌理而不仅仅是调用它的API。2. 核心架构设计在ProseMirror的宇宙中为AI安家将AI能力接入编辑器不是简单地绑个事件监听器然后调接口那么简单。我们需要一个清晰、健壮且与ProseMirror哲学契合的架构。核心思路是将AI交互视为一种特殊的编辑器“状态”和“事务”。2.1 状态State扩展为AI预留空间ProseMirror的核心是它的EditorState其中包含了文档doc、选区selection和所有插件状态。我们要做的第一件事就是扩展这个状态让它能表示“当前是否正处于AI交互中”。一种常见的做法是创建一个自定义的Plugin并在其state属性中管理AI相关的状态。例如我们可以定义这样一个状态接口interface AIAssistantState { // AI交互是否激活 isActive: boolean; // 当前AI请求的类型continue | rewrite | translate | code | null activeMode: string | null; // 触发AI请求时的原始文本选区或上下文 context: { from: number; to: number; text: string } | null; // AI生成的结果用于预览 pendingContent: string | null; // 错误信息 error: string | null; }然后我们创建一个ProseMirror插件来托管这个状态。这个插件的state初始化、应用和序列化方法将确保我们的AI状态能随着编辑器状态一起变化、保存在协同编辑场景中尤为重要和恢复。注意这里的关键是AI状态是编辑器状态的一部分。这意味着你可以通过editor.state来获取当前AI的激活情况并且任何对AI状态的操作如激活、取消、设置结果都必须通过创建和分发一个Transaction来完成从而保证状态变化的可预测性和可追溯性。2.2 事务Transaction驱动让AI操作可撤销在ProseMirror中任何对文档或状态的修改都通过Transaction进行。AI生成内容的插入也必须遵循这个规则。我们不能直接用document.execCommand或直接操作DOM。我们的流程将是用户通过快捷键或菜单触发AI命令如“续写”。命令处理函数创建一个Transaction将上述AI状态中的isActive设为true并记录触发上下文。派发dispatch这个事务编辑器状态更新UI可以据此显示一个“正在思考...”的加载状态。在后台发起异步的AI API调用如调用OpenAI、Claude或本地模型。收到AI响应后再创建一个新的事务。这个事务要做两件事将AI状态重置isActive: false等。使用tr.replaceWith或tr.insert等方法将AI生成的内容作为一个或多个ProseMirror节点Node或片段Fragment插入到文档的指定位置。派发这个最终的事务。为什么必须这么做因为只有这样这次“AI插入”操作才会被记录到ProseMirror的撤销栈Undo Stack中。用户按下CtrlZ时可以完美地撤销AI生成的内容回到触发前的状态。这是构建专业编辑体验的基石。2.3 插件Plugin作为组织单元我们将把不同的AI功能模块化每个模块可能对应一个或多个ProseMirror插件AI状态管理插件如上所述负责维护AI的全局状态。AI命令插件注册编辑器命令如aiContinue,aiRewriteSelection。这些命令封装了创建事务、调用服务、处理响应的逻辑。AI UI插件可选但重要负责根据AI状态渲染UI。例如当isActive为真时在选区附近显示一个浮动加载指示器当有pendingContent时显示一个预览面板让用户确认后再插入。快捷键插件将快捷键如CtrlShift.绑定到上述AI命令。这种插件化的设计使得功能易于启用、禁用和组合也符合ProseMirror生态的惯例。3. 实战构建一个“智能续写”功能让我们以最经典的“智能续写”功能为例走一遍全流程。假设用户选中了一段文字或者光标停留在一个段落末尾希望AI基于此继续创作。3.1 步骤一定义文档结构Schema与AI节点首先我们需要考虑AI生成的内容如何表示。最简单的情况是AI生成的就是普通的段落、标题等现有节点。但有时我们可能希望给AI生成的内容打上标记例如显示一个淡背景色或允许用户一键接受/拒绝。我们可以在Schema中定义一种“AI占位符”节点或标记Mark。import { Schema } from prosemirror-model; const mySchema new Schema({ nodes: { // ... 其他标准节点定义如 doc, paragraph, text, heading aiPlaceholder: { content: inline*, // 占位符内可以包含行内内容 inline: true, atom: true, // 作为一个不可分割的原子单位 attrs: { id: { default: null }, // 唯一ID用于后续查找替换 status: { default: pending } // pending | generated | error }, toDOM(node) { // 渲染为带特定属性的span方便CSS样式化 return [span, { class: ai-placeholder status-${node.attrs.status}, data-ai-id: node.attrs.id }, 0]; }, parseDOM: [{ tag: span.ai-placeholder, getAttrs(dom) { return { id: dom.getAttribute(data-ai-id), status: dom.getAttribute(data-status) || pending }; } }] } }, marks: { /* ... 已有的标记如 strong, em */ } });这样在等待AI响应时我们可以先插入一个statuspending的aiPlaceholder节点显示一个加载动画。收到响应后再用实际内容替换这个占位符节点并将其status改为generated。3.2 步骤二创建AI状态管理插件import { Plugin, PluginKey } from prosemirror-state; interface AIState { activeRequestId: string | null; // ... 其他状态 } const aiPluginKey new PluginKey(ai); const aiStatePlugin new Plugin({ key: aiPluginKey, state: { init() { return { activeRequestId: null }; }, apply(tr, prevState) { // 从事务的meta信息中获取对AI状态的更新指令 const meta tr.getMeta(aiPluginKey); if (meta) { return { ...prevState, ...meta }; } return prevState; } }, // 可以在这里定义一些视图相关的副作用比如根据状态显示/隐藏UI view(editorView) { // ... 创建和管理浮动UI元素的逻辑 return { update(view, prevState) { const aiState aiPluginKey.getState(view.state); const prevAIState aiPluginKey.getState(prevState); // 如果状态变化更新UI if (aiState.activeRequestId ! prevAIState.activeRequestId) { // 显示或隐藏加载指示器 } }, destroy() { /* 清理UI */ } }; } });3.3 步骤三实现“续写”命令这是最核心的部分它串联了状态变更、异步请求和文档修改。import { Command } from prosemirror-state; import { v4 as uuidv4 } from uuid; // 用于生成唯一ID // 模拟一个AI服务调用 async function callAIContinuationAPI(contextText: string): Promisestring { // 这里替换成你真实的API调用例如 fetch(/api/ai/continue, {...}) await new Promise(resolve setTimeout(resolve, 1000)); // 模拟延迟 return 这是基于“${contextText}”由AI生成的续写内容。; } export const aiContinueCommand: Command (state, dispatch, view) { if (!view || !dispatch) return false; const { from, to, empty } state.selection; // 获取光标前或选中区域的文本作为上下文 const context state.doc.textBetween(Math.max(0, from - 500), to, ); // 取最多500字符上下文 // 1. 生成唯一请求ID const requestId uuidv4(); // 2. 创建并派发一个事务激活AI状态并插入占位符 const tr state.tr; // 2.1 更新AI插件状态 tr.setMeta(aiPluginKey, { activeRequestId: requestId }); // 2.2 在光标位置插入一个AI占位符节点 const aiPlaceholderNode state.schema.nodes.aiPlaceholder.create({ id: requestId, status: pending }); tr.insert(from, aiPlaceholderNode); // 在选区起始位置插入 dispatch(tr); // 3. 发起异步AI请求 callAIContinuationAPI(context).then(generatedText { // 请求完成再次获取当前编辑器状态状态可能已经变化 const currentState view.state; // 通过请求ID找到对应的占位符节点在文档中的位置 let placeholderPos: number | null null; currentState.doc.descendants((node, pos) { if (node.type.name aiPlaceholder node.attrs.id requestId) { placeholderPos pos; return false; // 找到即停止遍历 } }); if (placeholderPos ! null) { // 4. 创建新事务来替换占位符并更新状态 const finalTr currentState.tr; // 4.1 移除占位符节点 finalTr.delete(placeholderPos, placeholderPos 1); // 4.2 插入AI生成的内容这里简单插入一个段落 const generatedNode currentState.schema.nodes.paragraph.create( null, currentState.schema.text(generatedText) ); finalTr.insert(placeholderPos, generatedNode); // 4.3 重置AI插件状态 finalTr.setMeta(aiPluginKey, { activeRequestId: null }); // 5. 派发最终事务 view.dispatch(finalTr); } }).catch(error { // 错误处理将占位符状态标记为错误或替换为错误信息 const currentState view.state; let placeholderPos: number | null null; currentState.doc.descendants((node, pos) { if (node.type.name aiPlaceholder node.attrs.id requestId) { placeholderPos pos; return false; } }); if (placeholderPos ! null) { const errorTr currentState.tr; // 将占位符节点的状态属性改为error errorTr.setNodeMarkup(placeholderPos, undefined, { id: requestId, status: error }); // 也可以选择插入一个错误文本节点 // errorTr.replaceWith(placeholderPos, placeholderPos1, state.schema.text([AI生成失败: ${error.message}])); errorTr.setMeta(aiPluginKey, { activeRequestId: null }); view.dispatch(errorTr); } }); return true; // 命令已处理 };3.4 步骤四集成到编辑器并绑定快捷键最后我们将所有插件组合起来创建编辑器视图并绑定快捷键。import { EditorState } from prosemirror-state; import { EditorView } from prosemirror-view; import { keymap } from prosemirror-keymap; import { baseKeymap } from prosemirror-commands; import { schema } from ./my-schema; // 你的schema import { aiStatePlugin, aiContinueCommand } from ./ai-plugins; // 1. 创建包含AI插件的状态 const state EditorState.create({ schema, plugins: [ aiStatePlugin, // 其他必要插件如历史记录插件 // history(), keymap({ // 绑定 CtrlShift. (或 CmdShift. on Mac) 到续写命令 Ctrl-Shift-.: aiContinueCommand, Mod-Shift-.: aiContinueCommand, // 保留基础快捷键 ...baseKeymap }) ] }); // 2. 挂载到DOM const view new EditorView(document.querySelector(#editor), { state });至此一个具备基础AI续写功能的ProseMirror编辑器就搭建完成了。用户选中文字或放置光标按下CtrlShift.编辑器会插入一个临时占位符调用AI服务并在返回后替换为生成的内容整个过程支持撤销/重做。4. 进阶优化与问题排查基础功能跑通后我们会面临更多工程化和体验上的挑战。4.1 性能与用户体验优化防抖与取消用户可能连续快速触发AI命令。我们需要对命令进行防抖处理并确保旧的、未完成的请求可以被取消避免结果插入错乱。实操技巧在AI状态中保存AbortController实例。发起新请求前调用上一个请求的abort()。在请求的catch块中检查error.name AbortError来忽略被取消的请求错误。流式输出Streaming对于生成长文本的模型等待全部生成完毕再插入体验不佳。理想情况是像ChatGPT一样逐字输出。实现思路这更复杂。我们需要在收到第一个数据块时就用一个“流动文本”节点替换掉占位符。随后每次收到新数据块都通过一个事务向这个“流动文本”节点的末尾追加内容。这需要精细的节点定位和事务管理。上下文管理给AI的上下文并非越多越好。需要智能截取例如只发送当前段落、前几个段落或整个章节。这需要根据你的文档结构和业务逻辑来设计。4.2 常见问题与排查技巧问题现象可能原因排查步骤与解决方案AI生成的内容插入后撤销Undo一次就全没了而不是逐步撤销。AI内容的插入被ProseMirror视为一个原子操作。可能是在一个事务中完成了“删除占位符”和“插入新内容”。确保aiPlaceholder节点的atom属性设置为true。检查你的替换逻辑是否在一个事务中完成。可以尝试将“标记为完成”和“插入内容”分成两个连续的事务但这可能影响用户体验。更常见的做法是接受这种原子性因为AI生成本身就是一个逻辑单元。快捷键无效。1. 快捷键绑定被其他插件覆盖。2. 命令函数返回了false。3. 编辑器视图未获得焦点。1. 使用console.log在命令函数开头打印确认是否被触发。2. 检查命令函数的返回值确保在适当条件下返回true。3. 确保绑定快捷键时Mod-键在Mac上被正确映射为Cmd。可以使用keymap插件的调试工具。在协同编辑场景下AI插入的内容导致冲突或状态同步错误。AI事务的meta信息或自定义节点属性未被协同编辑框架如y-prosemirror正确序列化和同步。1. 确保自定义的AI插件状态和节点属性都在Schema和插件中正确定义了toJSON和fromJSON方法。2. 协同编辑通常只同步文档内容和基本选区自定义的插件状态可能需要通过** Awareness**感知通道来同步这是一个高级话题需要结合具体协同库处理。AI占位符节点在HTML输出中残留。在导出或提交内容时没有过滤掉aiPlaceholder这类临时节点。在序列化文档为HTML或纯文本之前遍历文档树过滤掉type.name aiPlaceholder的节点。或者在Schema定义中为其设置toDOM方法在导出时返回空。4.3 扩展更多AI功能基于上述框架扩展其他功能就变得有章可循改写选中文本命令获取选中文本state.selection.content()将其作为AI指令如“让这段话更简洁”的输入的一部分生成结果后使用tr.replaceSelectionWith来替换原选区。翻译与改写类似但需要指定源语言和目标语言参数。可以在触发时弹出一个小的浮动面板让用户选择语言。代码补全/解释这需要识别光标是否在代码块中。可以通过state.selection.$from向上查找父节点判断是否为代码块节点。如果是则将整个代码块或当前行的内容作为上下文发送给专精代码的模型如Codex并将结果插入到光标后或替换当前行。5. 工程化与生产环境考量当项目从Demo走向生产环境我们需要考虑更多。1. 插件配置化不应将AI服务的API密钥、端点等硬编码在命令函数中。应该通过插件的props或单独的配置对象注入。例如在创建编辑器时传入一个aiConfig对象。new EditorView(element, { state, // 通过props传递配置 props: { aiConfig: { apiKey: process.env.AI_API_KEY, endpoint: https://api.your-ai-service.com/v1/complete, defaultModel: gpt-4 } } });然后在插件或命令中通过view.props.aiConfig访问。2. 错误处理与重试网络请求可能失败。除了基本的catch应实现指数退避重试机制并向用户提供清晰的非阻塞错误提示例如在占位符处显示“生成失败点击重试”。3. 成本与限流AI API调用通常按Token收费。需要在服务端或前端对用户的调用频率和上下文长度进行限制防止滥用。可以在触发命令前先检查配额。4. 可访问性A11y为AI激活状态、加载指示器和生成的内容添加适当的ARIA属性确保屏幕阅读器用户能感知到状态变化。例如当插入占位符时用aria-live区域播报“AI正在生成内容”。5. 测试ProseMirror的插件和命令是纯函数给定输入状态和事务产生输出状态这非常利于单元测试。可以为每个AI命令编写测试模拟不同的选区状态验证产生的事务是否正确。对于与AI服务的集成则需要进行Mock测试。将输入框变为AI的“超级入口”本质上是将智能能力从外部工具“拉”到创作现场。ProseMirror以其卓越的设计为我们提供了实现这一愿景的完美手术刀。它要求我们深入理解其状态、事务、插件体系但回报是一个高度可控、体验无缝、能力强大的智能编辑环境。这个过程就像在精心设计的钟表机芯里加入一个智能发条——每一步操作都必须精准、协同最终才能让整个系统流畅地运转起来无声却强大地赋能于每一个创作瞬间。