本文为作者原创首发于掘金现同步发布到 CSDN。内容整理自AI Mind项目的真实开发过程。GitHubhttps://github.com/HWYD/ai-mind对应代码版本v0.0.12AI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目项目从本地大模型聊天起步逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。如果这篇文章或 AI Mind 项目对你有所帮助也欢迎到 GitHub 给项目点个 Star⭐这会是对我继续整理后续版本复盘很大的鼓励。普通 textarea 只能承载自然语言但 AI 应用里的输入往往还需要表达任务意图和上下文引用。本文复盘 AI Mind 中如何使用 Tiptap 实现一个轻量 AI 输入层支持 / command、 resource、inline chip 和结构化 ComposerPayload。用户输入一段自然语言前端把它发给后端后端拿到消息数组模型开始生成回答。这个链路简单、直接也足够支撑最早期的问答场景。但当项目里慢慢出现 Skill任务能力层、Tool可执行工具、Resource可读取上下文、Prompt可注入模型的提示模板、MCP 能力通过 MCP 接入的外部能力之后我开始遇到一个更具体的问题用户输入不再只是一段自然语言。它开始同时表达三件事这一轮我想做什么这一轮我要引用哪个上下文这一轮真正的自然语言问题是什么普通textarea能承载第三件事却很难稳定承载前两件事。这也是 AI Mind 在v0.0.12里升级输入层的原因。先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime SkeletonAI 原生运行时骨架不是一次性做完的 AI 产品。它从本地聊天闭环开始逐步长出结构化流式协议、工具调用、多工具运行时、Skill 运行时、MCP 接入、能力表面以及后续会开始推进的 Agent / 数据层能力。到本版本之前项目里已经有了reader-skill阅读类 Skill负责承接文档读取、项目上下文和 MCP 能力消费、utility-skill工具类 Skill负责计算、时间、单位换算等稳定工具任务、本地 / 远程 MCP Tool、Resource、Prompt以及前端的执行事实展示。为了让第一次读到这篇文章的读者更容易跟上这里先轻量对齐几个概念/command表达“我想做什么”例如选择“总结文档”。resource表达“我想引用什么”例如显式引用docs://README.md。命令标签 / 资源标签Command Chip / Resource Chip是在输入框里可见、可删除的结构化标签。结构化请求ComposerPayload是发送给后端的稳定输入结构包含自然语言、命令意图和资源引用。Skill 是运行时最终命中的内部承接模块例如reader-skill承接阅读和文档类任务utility-skill承接计算、时间等工具类任务。这几个概念之间的关系也要先讲清楚用户通过/command和resource表达输入侧的能力倾向但不是直接指定 Skill也不是直接执行 Tool。真正由哪个 Skill 承接、是否读取资源、是否绑定工具仍然由运行时根据上下文做最终判断。所以这一版我没有直接进入 Agent也没有把输入框做成复杂的富文本编辑器而是先补了一个更基础的输入层Composer V1。如果把这次升级压成一句话这版不是把textarea换成 Tiptap而是让一次 AI 请求从“一段字符串”变成“自然语言 任务意图 上下文引用”的结构化输入。普通textarea和 Composer V1 的差异大概是这样普通 textarea 用户只能输入自然语言 Composer V1 自然语言 /命令 资源1. 为什么我不再满足于 textarea在早期聊天场景里输入框的职责很单纯收集用户自然语言。比如帮我总结一下这个项目。这个问题本身已经包含了任务目标但它没有明确说明“总结哪个上下文”。如果系统里只有一段固定上下文问题不大但当项目开始支持 Resource可读取上下文、Prompt提示模板、MCP 服务外部能力服务和 Skill 路由之后靠自然语言猜上下文会越来越不稳。用户可能想表达的是[总结文档] docs://README.md 这份文档的核心结构是什么这里面其实有三层信息[总结文档]是任务意图docs://README.md是上下文引用这份文档的核心结构是什么才是自然语言问题如果这些都塞进一段字符串里后端只能继续靠解析文本或者靠模型自己猜。这在工程上会带来几个问题前端不知道这轮输入到底携带了哪些结构化语义。后端不知道用户引用的资源是明确选择的还是自然语言里随口提到的。运行时很难区分“用户想总结文档”和“用户只是问到 summary 这个词”。下一阶段开始进入 Agent 时输入层仍然是一团不稳定的文本。所以 Composer V1 解决的不是“输入框更好看”这个问题而是让输入层先有能力表达结构。2. 为什么选择 Tiptap但不把它当富文本编辑器用这次我选择 Tiptap不是因为想做一个 Markdown 编辑器。恰好相反我在这个版本里刻意避免把输入框做成富文本产品形态。Tiptap 在这里是增强输入框不是富文本编辑器。AI ComposerAI 输入层的职责不是排版文章而是表达本轮 AI 请求的输入语义。它需要的是这些能力在光标位置插入一个整体可选中的标签用/触发命令菜单用触发资源菜单删除标签时同步清空对应结构化信息发送时从编辑器文档树里提取结构化请求输入#、-、**bold**时保持普通文本不自动变成富文本结构如果继续用textarea最麻烦的不是输入文本而是“输入框里有一块不是普通文本、但又要像内容一样存在”的标签。Tiptap 的价值在这里就很明确它能让我把命令标签Command Chip和资源标签Resource Chip做成内联原子节点inline atom node。也就是说它们在编辑器里像一个整体对象一样存在可以被光标跳过、选中、删除同时又能在发送时被序列化成稳定的结构化信息。本版实际代码里composer-editor.tsxTiptap 输入层负责编辑器初始化、Enter 行为、/与菜单触发使用了 Tiptap但关闭了大部分富文本能力。StarterKit只是被裁剪后的基础输入能力不承担 Markdown 富文本编辑器角色。从实现流程看它大概分成 6 步初始化 Tiptap 编辑器 - 关闭大部分富文本能力只保留基础输入 - 注册 commandChip / resourceChip 两类内联节点 - 用 Suggestion 监听 / 和 - 选择菜单项后插入内联标签 - 发送时序列化成 plainText / command / references这里最关键的不是“用了 Tiptap”而是把编辑器能力拆成了两层前端输入体验由 Tiptap 承接前后端协议仍然由结构化请求承接。这样后端不用理解 Tiptap 的文档树也不会被某个前端编辑器绑定死。所以这版我一直把它当“增强版输入框”来用它能插入标签、处理光标和菜单、生成结构化请求但不负责把用户输入渲染成富文本文章。这也是我给自己设的边界不做工具栏不做图片粘贴不做表格不做标题、列表、加粗的自动格式化不做 Markdown 双向转换不让后端依赖 Tiptap 原始 JSON一句话总结我用的是 Tiptap 的编辑器基础能力不是它的富文本产品形态。3. Composer V1 的最小形态文本、命令标签、资源标签Composer V1 的输入模型很小只保留三块plainText用户自然语言 命令标签Command Chip用户想做什么 资源标签Resource Chip用户引用什么上下文本版没有做复杂选择器也没有做完整命令执行系统。我给它收了几个明确边界只允许一个命令标签只允许一个资源标签选择新的命令会替换旧命令选择新的资源会替换旧资源删除标签后同步清空结构化信息不做搜索不做文件树不做完整资源选择器Resource Picker不做 Skill / Tool / Prompt 菜单最终前后端约定的结构是ComposerPayloadComposer 发送给后端的结构化请求exporttypeChatComposerCommandNamecheck|summary|tasklistexportinterfaceChatComposerCommand{label:stringname:ChatComposerCommandName}exportinterfaceChatComposerReference{id:stringlabel:stringserverId?:stringsource:local|remotetype:resourceuri:string}exportinterfaceChatComposerPayload{command?:ChatComposerCommand plainText:stringreferences?:ChatComposerReference[]}这里有一个小细节虽然本版 UI 只允许一个资源标签但结构化请求里仍然使用references数组。这不是为了提前做多资源选择而是为了让接口语义更自然。v0.0.12只写入 0 或 1 个资源引用后续如果真的进入受控多引用不需要再把单数结构改成数组结构。它也不会替代原来的消息数组。ComposerPayload在本版里只是结构化提示信息让运行时更清楚这一轮输入里有哪些意图和引用。Composer V1 先解决“结构化表达”不解决“所有资源都能浏览和选择”。4./命令菜单Command 是意图不是执行按钮/命令菜单是这版最容易被误解的地方。很多产品里的 slash command斜杠命令像一个“立即执行按钮”选了某个命令就马上触发对应动作。但我在这一版没有这么做。Composer V1 里的 Command 只是任务意图标签。第一版固定 3 个命令/summary总结文档/tasklist生成任务清单/check检查文档一致性选择命令之后输入框里会插入一个命令标签。但它不会立即调用 Prompt也不会立即调用 Tool。比如用户输入[生成任务清单] 帮我整理一下 v0.0.12 后续工作这里的[生成任务清单]只告诉后端本轮用户更偏向“任务清单”场景。它不是一个远程 Prompt 的执行按钮。这个边界很重要。因为一旦把命令做成执行入口输入层就会开始承担工作流workflow多步骤编排的职责选命令、找资源、调 Prompt、调 Tool、处理结果。这样看起来很快但会让 Composer 提前变成一个小型 Agent。本版我只让命令进入结构化请求真正消费由后端运行时Runtime决定而且消费范围非常窄。5.资源菜单让上下文引用变成显式结构菜单解决的是另一个问题用户到底引用了什么上下文过去用户可能会写帮我看看 README。这个问题对人来说能懂但对运行时不够稳定。它到底是根目录README.md还是 docs 里的README.md是本地文档还是远程 MCP 资源模型能不能读读哪个服务这些都不能靠一句自然语言长期稳定地猜。所以本版把本地文档 Resource可读取上下文收敛成docs://...docs://README.md docs://architecture/runtime-boundary.md docs://architecture/stream-core.md docs://architecture/capability-skill-surface.md同时保留一个远程资源project://latest-context这背后对应了一个更大的边界收口本地 Resource 不再是“项目任意文件读取”而是只允许读取docs/**/*.md里的项目知识文档并统一使用docs://...这样的资源地址。这部分不是本文主线只需要理解成resource的安全背景用户可以显式引用项目文档但不会把源码目录、配置文件和任意本地文件都暴露给模型。资源标签的意义不是“前端提前读取资源”而是把“本轮显式引用了哪个资源”写进结构化请求。真正读取资源的动作仍然由运行时完成。6. 内联标签让结构化输入成为消息内容的一部分最终在输入框里我希望用户看到的是[总结文档] docs://README.md 帮我总结核心结构也就是命令、资源和自然语言共同构成本轮输入。所以实现里composer-chip-nodes.ts命令 / 资源内联节点定义负责把标签注册成 Tiptap inline atom node把两类标签都放进了编辑器文档树exportconstCOMMAND_CHIP_NODE_NAMEcommandChipexportconstRESOURCE_CHIP_NODE_NAMEresourceChipexportconstCommandChipNodeNode.create({name:COMMAND_CHIP_NODE_NAME,group:inline,inline:true,atom:true,selectable:true,addNodeView(){returnReactNodeViewRenderer(InlineComposerChipNodeView)},})exportconstResourceChipNodeNode.create({name:RESOURCE_CHIP_NODE_NAME,group:inline,inline:true,atom:true,selectable:true,addNodeView(){returnReactNodeViewRenderer(InlineComposerChipNodeView)},})这里的关键是inline atom selectable。它让标签既是输入内容的一部分又不会被拆成普通文本。用户可以选中它、删除它序列化时也能准确识别它。发送后的用户消息气泡也会保留这层语义。项目里用displaySegments消息展示片段用来还原文字、命令标签和资源标签的位置记录本轮用户消息的可读展示结构避免发送之后只能看到一段被压平的 plain text。这部分不是为了炫 UI而是为了让结构化输入真正成为消息内容的一部分。7. 结构化请求不要把 Tiptap JSON 直接丢给后端用了 Tiptap 之后很自然会出现一个诱惑既然编辑器有 JSON那直接把editor.getJSON()发给后端不就好了我没有这么做。原因很简单Tiptap JSON 是前端编辑器实现细节不应该成为 AI 运行时的输入协议。后端真正需要的只有三件事plainText用户自然语言command本轮任务意图references本轮显式引用的上下文资源所以项目里专门有composer-serialization.tsComposer 序列化层负责从 Tiptap 文档树提取 plainText、command、references 和 displaySegments来做这件事。核心逻辑是标签不进入plainText而是进入结构化信息。functiongetInlineTextFromContent(content:JSONContent[]|undefined):string{return(content??[]).map(node{if(node.typetext){returnnode.text??}if(node.typehardBreak){return\n}if(node.typeCOMMAND_CHIP_NODE_NAME||node.typeRESOURCE_CHIP_NODE_NAME){return}returngetInlineTextFromContent(node.content)}).join()}exportfunctionserializeComposerPayload(editor:Editor):ComposerPayload{consteditorJSONeditor.getJSON()constmetadataextractComposerMetadata(editorJSON)return{plainText:getPlainTextFromContent(editorJSON),...(metadata.command?{command:metadata.command}:{}),...(metadata.references.length0?{references:metadata.references}:{}),}}这段代码解决的问题很明确输入框里看到的 chip不应该被混进用户自然语言。比如[总结文档] docs://README.md 帮我总结核心结构发送给后端时应该变成{plainText:帮我总结核心结构,command:{name:summary,label:总结文档},references:[{type:resource,id:docs-readme,label:docs/README.md,source:local,uri:docs://README.md}]}而不是把[总结文档] docs://README.md一起塞进plainText。这里还有一个很小但很真实的兼容点如果用户只选择了标签没有输入自然语言旧的消息链路仍然需要一段非空文本。所以composer-submission.tsComposer 提交兼容层负责判断“只有标签、没有文字”的输入是否有效并生成兼容文本会在这种情况下用可读标签生成一段兼容文本。这样既不破坏旧消息结构也不丢掉 Composer 的结构化语义。8. Composer 运行时只做很窄的消费闭环有了结构化请求之后下一步很容易做重。比如/tasklist - 自动调用 tasklist-draft Prompt /check - 自动调用 check_doc_consistency Tool /summary - 自动选择某个 Resource 再总结这些听起来都合理但它们已经开始接近工作流。本版我没有这么做。composer-context.tsComposer 运行时消费层负责把命令意图和资源引用转成受控上下文注入只做了几条固定、很窄的逻辑exportfunctionresolveComposerContextInvocation(request:ChatRequest):ComposerContextInvocation|null{constcommandrequest.composer?.commandconstcommandNamecommand?.nameconstreferencegetPrimaryComposerReference(request)constuserGoalgetLastUserMessageText(request)if(commandNamesummaryisDocsResourceReference(reference)){return{kind:docs-summary,reference,userGoal,}}if(isDocsResourceReference(reference)){return{command,kind:docs-resource,reference,userGoal,}}if(isLatestContextReference(reference)){return{command,kind:remote-resource,reference,userGoal,}}if(command){return{command,kind:command-hint,userGoal,}}returnnull}这段逻辑体现了本版的边界。不同输入形态在本版里的行为大概是这样输入形态本版行为/summary docs://...读取 docs resource并使用local-file-summary生成文档摘要上下文docs://...读取 docs resource作为本轮回答上下文project://latest-context读取远程 context作为本轮回答上下文/tasklist只作为任务清单意图提示不自动调用远程 Prompt/check只作为一致性检查意图提示不自动调用远程 Tool也就是说/summary docs://...是本版唯一的自动摘要闭环但不是唯一的 Composer 消费路径。这个差异很关键。如果我把/tasklist和/check也做成立即执行就会从 Composer V1 滑向命令执行系统Command Execution System。那不是本版目标。Composer V1 不是工作流 V1。它只是先让用户输入变得结构化让运行时在少量固定场景里安全消费这些结构。9. 几个让输入框从“演示可用”变成“日常可用”的细节这次看起来是在做输入框但真正花时间的地方并不只是 UI。Enter 和 Shift Enter聊天输入里Enter 通常表示发送Shift Enter 表示换行。但到了 Tiptap 里Enter 同时可能被编辑器、Suggestion 菜单和输入法占用。最终实现里菜单打开时 Enter 交给菜单选择项菜单未打开、且不是组合输入状态时Enter 才会触发发送。中文输入法 composition中文输入法期间用户按 Enter 很可能只是确认候选词。所以发送逻辑必须检查event.isComposing和view.composing。如果不做这一步中文用户很容易在打字过程中误发送。这是一个非常小的判断但它直接决定输入框能不能日常使用。Markdown 字符保持纯文本AI 输入框不是 Markdown 编辑器。所以本版明确要求# 标题 - item **bold**这些输入都保持普通文本不自动变标题、列表或加粗。前端回答仍然可以渲染 Markdown但用户输入框不承担文章排版职责。/和菜单不能撑开输入框/和菜单用的是 TiptapSuggestion ReactRenderer tippy。可以简单理解为Tiptap 负责识别触发字符React 负责渲染菜单tippy 负责把菜单浮在光标附近。它必须是浮层而不是普通 DOM 流里的列表。不然菜单一打开底部输入框高度就会被撑开消息列表也会跟着抖动。触发字符也要收窄/不是任何时候都应该触发命令菜单。比如路径、URL、普通文本里都可能出现/。所以实现里会检查触发字符前面是否为空或空白避免把普通路径误识别成命令。的规则也类似只是为了适配中文输入它允许前一个字符是中文字符。这些细节不复杂但如果没有它们输入框会很“演示可用”但不太像日常可用。10. 用户看不到的另一半工具运行时也要回到能力驱动上面这些都是用户能看到的输入层变化。但本版本还有一个用户不太感知、对运行时却很重要的变化Tool Runtime工具运行时不再从allowedTools绑定工具而是从capabilitySelectors解析本轮可用工具。旧方式里Skill 上会有一组allowedTools。它简单直接但到了v0.0.11之后项目已经有了 Capability Surface能力表面用来统一描述 Tool / Resource / Prompt。如果继续保留allowedToolsSkill 里一套工具声明、能力模型里又一套选择规则后续会越来越难解释。本版迁移后的链路是SkillDefinition.capabilitySelectors - 能力目录Capability Catalog - 解析 tool 类型能力 - 生成本轮可用工具表 - 映射成模型可见工具 - 绑定给模型 - 进入工具运行时对应实现放在tool-binding.ts能力驱动的工具绑定层负责根据 Skill 选择范围解析本轮可用工具表里。关键点不是“换了一个字段名”而是把工具绑定收回到同一条能力链路里只有capabilityType tool的能力能进入模型工具绑定。Resource / Prompt 不会被错误塞进工具运行时。普通聊天没有命中 Skill 时不默认暴露所有工具。同一轮出现工具名冲突时直接失败收口不自动改名。这一层收口之后模型绑定、工具调用校验、工具执行和前端展示都消费同一份“本轮可用工具表”。这对后续 Agent 很重要因为 Agent 不应该面对一组散落在 Skill、工具注册表、MCP 适配器里的工具来源。11. 远程 MCP Tool 标准化不要让 check_doc_consistency 变成特殊分支v0.0.11已经接入了远程 MCP 服务project-assistant-service远程 MCP mock 服务当前用于验证远程 Resource / Prompt / Tool 最小闭环。其中有一个远程 Toolcheck_doc_consistency它可以用来检查文档口径一致性。如果只是为了跑通一个演示最容易的做法是写死if(toolNamecheck_doc_consistency){// special logic}但这会让远程 Tool 永远停留在特殊分支里。本版把它改成标准工具运行时可消费对象远程 MCP tools/list - RemoteMcpToolAdapter - ChatToolDefinition - 工具运行时 - mcpClientManager.callTool(...)实现放在remote-mcp-tool-adapter.ts远程 MCP Tool 适配层负责把tools/list返回的工具信息转成项目内部标准ChatToolDefinition里。这部分的核心只有一个check_doc_consistency只是第一个样本不应该成为写死分支。它应该通过RemoteMcpToolAdapter进入标准 Tool Runtime再由capabilitySelectors决定本轮是否暴露给模型。这样一来远程 MCP Tool 和本地工具走同一条工具运行时Resource / Prompt 也不会被错误混进工具绑定。这部分用户不一定直接感知但它让输入层、能力层和执行层开始对齐。12. 这版刻意没有做什么这次升级很容易继续往下扩但我刻意收住了。本版不做Agent工作流动态规划器RAG / chunking / indexing完整资源选择器Resource Picker多资源选择文件树浏览Markdown 富文本编辑器/tasklist自动调用远程 Prompt/check自动调用远程 Tool工具市场多服务发现Prompt / Resource tool 化这些不是不重要而是不应该在 Composer V1 里一起做。输入层刚开始结构化时最重要的是边界稳定。如果这一版同时把输入、资源选择、命令执行、工具规划和 Agent 都揉在一起短期看起来功能更多长期反而很难继续演进。所以我更愿意先把这三个问题讲清楚用户输入如何表达意图和上下文运行时何时消费这些结构化信息Tool 绑定如何回到能力驱动13. 一个小插曲正在思考的动效优化除了 Composer 主线这版还有一个很小的前端细节正在思考的文字动效。这个点不是核心架构但它会明显影响用户对“系统正在工作”的感知。一开始我是在 GPT 的界面里看到这个效果第一反应是这个“正在思考”的扫光反馈还挺酷。后面豆包也更新了类似特效我就更想把这个细节也补到 AI Mind 里。所以本版本除了输入层升级我也顺手实现了一个类似的“正在思考”文字动效。它不是为了堆炫技而是让等待过程不那么干尤其是在深度思考开启、模型还没吐出正文时界面能给用户一个更自然的状态反馈。代码上我没有做复杂动画组件只保留一个很小的ThinkingTextexport function ThinkingText({ text 正在思考, className }: ThinkingTextProps) { return ( span>.thinkingText{color:var(--thinking-text-base);background-image:linear-gradient(100deg,var(--thinking-text-base)0%,var(--thinking-text-base)42%,var(--thinking-text-sheen)50%,var(--thinking-text-base)58%,var(--thinking-text-base)100%);background-size:260% 100%;background-clip:text;-webkit-text-fill-color:transparent;animation:thinking-text-shimmer 1.8s ease-in-out infinite;}这块我比较在意两点它只是“系统正在思考”的轻提示不抢回答内容的注意力。它支持prefers-reduced-motion用户关闭动画偏好时会退回普通文字。这个小插曲和 Composer 的主线其实是同一件事的两面输入层让用户更清楚地表达“我要什么”反馈层让用户更清楚地感知“系统正在做什么”。14. 这版真正改变了什么如果只看 UI这版像是把textarea换成了 Tiptap 输入框。但从运行时角度看它改的是三条更底层的链路。第一输入层变了。用户输入不再只是自然语言字符串而是可以携带命令标签和资源标签。输入框开始表达“我要做什么”和“我要引用什么”。第二请求结构变了。前后端不再把所有东西都塞进 text而是把自然语言、任务意图、资源引用拆成plainText / command / references。Tiptap JSON 留在前端运行时只消费稳定的结构化请求。第三工具绑定变了。工具运行时不再依赖旧的allowedTools双轨声明而是通过capabilitySelectors - 能力目录 - 本轮可用工具表解析本轮可用工具。远程 MCP Tool 也进入了标准工具运行时。对我来说v0.0.12的意义不是“做了一个更漂亮的输入框”而是让 AI 应用的输入层第一次具备了结构化语义。它为下一阶段开始进入受控单 Agent 留下了更稳的前提textarea - 结构化请求 - 能力驱动的运行时 - 受控单 Agent Preview项目地址 GitHubhttps://github.com/HWYD/ai-mind如果这篇文章或者 AI Mind 项目对你有所帮助也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。
Tiptap 实现 AI 输入框:支持 / 命令、@ 引用和结构化请求
发布时间:2026/6/6 1:45:08
本文为作者原创首发于掘金现同步发布到 CSDN。内容整理自AI Mind项目的真实开发过程。GitHubhttps://github.com/HWYD/ai-mind对应代码版本v0.0.12AI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目项目从本地大模型聊天起步逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。如果这篇文章或 AI Mind 项目对你有所帮助也欢迎到 GitHub 给项目点个 Star⭐这会是对我继续整理后续版本复盘很大的鼓励。普通 textarea 只能承载自然语言但 AI 应用里的输入往往还需要表达任务意图和上下文引用。本文复盘 AI Mind 中如何使用 Tiptap 实现一个轻量 AI 输入层支持 / command、 resource、inline chip 和结构化 ComposerPayload。用户输入一段自然语言前端把它发给后端后端拿到消息数组模型开始生成回答。这个链路简单、直接也足够支撑最早期的问答场景。但当项目里慢慢出现 Skill任务能力层、Tool可执行工具、Resource可读取上下文、Prompt可注入模型的提示模板、MCP 能力通过 MCP 接入的外部能力之后我开始遇到一个更具体的问题用户输入不再只是一段自然语言。它开始同时表达三件事这一轮我想做什么这一轮我要引用哪个上下文这一轮真正的自然语言问题是什么普通textarea能承载第三件事却很难稳定承载前两件事。这也是 AI Mind 在v0.0.12里升级输入层的原因。先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime SkeletonAI 原生运行时骨架不是一次性做完的 AI 产品。它从本地聊天闭环开始逐步长出结构化流式协议、工具调用、多工具运行时、Skill 运行时、MCP 接入、能力表面以及后续会开始推进的 Agent / 数据层能力。到本版本之前项目里已经有了reader-skill阅读类 Skill负责承接文档读取、项目上下文和 MCP 能力消费、utility-skill工具类 Skill负责计算、时间、单位换算等稳定工具任务、本地 / 远程 MCP Tool、Resource、Prompt以及前端的执行事实展示。为了让第一次读到这篇文章的读者更容易跟上这里先轻量对齐几个概念/command表达“我想做什么”例如选择“总结文档”。resource表达“我想引用什么”例如显式引用docs://README.md。命令标签 / 资源标签Command Chip / Resource Chip是在输入框里可见、可删除的结构化标签。结构化请求ComposerPayload是发送给后端的稳定输入结构包含自然语言、命令意图和资源引用。Skill 是运行时最终命中的内部承接模块例如reader-skill承接阅读和文档类任务utility-skill承接计算、时间等工具类任务。这几个概念之间的关系也要先讲清楚用户通过/command和resource表达输入侧的能力倾向但不是直接指定 Skill也不是直接执行 Tool。真正由哪个 Skill 承接、是否读取资源、是否绑定工具仍然由运行时根据上下文做最终判断。所以这一版我没有直接进入 Agent也没有把输入框做成复杂的富文本编辑器而是先补了一个更基础的输入层Composer V1。如果把这次升级压成一句话这版不是把textarea换成 Tiptap而是让一次 AI 请求从“一段字符串”变成“自然语言 任务意图 上下文引用”的结构化输入。普通textarea和 Composer V1 的差异大概是这样普通 textarea 用户只能输入自然语言 Composer V1 自然语言 /命令 资源1. 为什么我不再满足于 textarea在早期聊天场景里输入框的职责很单纯收集用户自然语言。比如帮我总结一下这个项目。这个问题本身已经包含了任务目标但它没有明确说明“总结哪个上下文”。如果系统里只有一段固定上下文问题不大但当项目开始支持 Resource可读取上下文、Prompt提示模板、MCP 服务外部能力服务和 Skill 路由之后靠自然语言猜上下文会越来越不稳。用户可能想表达的是[总结文档] docs://README.md 这份文档的核心结构是什么这里面其实有三层信息[总结文档]是任务意图docs://README.md是上下文引用这份文档的核心结构是什么才是自然语言问题如果这些都塞进一段字符串里后端只能继续靠解析文本或者靠模型自己猜。这在工程上会带来几个问题前端不知道这轮输入到底携带了哪些结构化语义。后端不知道用户引用的资源是明确选择的还是自然语言里随口提到的。运行时很难区分“用户想总结文档”和“用户只是问到 summary 这个词”。下一阶段开始进入 Agent 时输入层仍然是一团不稳定的文本。所以 Composer V1 解决的不是“输入框更好看”这个问题而是让输入层先有能力表达结构。2. 为什么选择 Tiptap但不把它当富文本编辑器用这次我选择 Tiptap不是因为想做一个 Markdown 编辑器。恰好相反我在这个版本里刻意避免把输入框做成富文本产品形态。Tiptap 在这里是增强输入框不是富文本编辑器。AI ComposerAI 输入层的职责不是排版文章而是表达本轮 AI 请求的输入语义。它需要的是这些能力在光标位置插入一个整体可选中的标签用/触发命令菜单用触发资源菜单删除标签时同步清空对应结构化信息发送时从编辑器文档树里提取结构化请求输入#、-、**bold**时保持普通文本不自动变成富文本结构如果继续用textarea最麻烦的不是输入文本而是“输入框里有一块不是普通文本、但又要像内容一样存在”的标签。Tiptap 的价值在这里就很明确它能让我把命令标签Command Chip和资源标签Resource Chip做成内联原子节点inline atom node。也就是说它们在编辑器里像一个整体对象一样存在可以被光标跳过、选中、删除同时又能在发送时被序列化成稳定的结构化信息。本版实际代码里composer-editor.tsxTiptap 输入层负责编辑器初始化、Enter 行为、/与菜单触发使用了 Tiptap但关闭了大部分富文本能力。StarterKit只是被裁剪后的基础输入能力不承担 Markdown 富文本编辑器角色。从实现流程看它大概分成 6 步初始化 Tiptap 编辑器 - 关闭大部分富文本能力只保留基础输入 - 注册 commandChip / resourceChip 两类内联节点 - 用 Suggestion 监听 / 和 - 选择菜单项后插入内联标签 - 发送时序列化成 plainText / command / references这里最关键的不是“用了 Tiptap”而是把编辑器能力拆成了两层前端输入体验由 Tiptap 承接前后端协议仍然由结构化请求承接。这样后端不用理解 Tiptap 的文档树也不会被某个前端编辑器绑定死。所以这版我一直把它当“增强版输入框”来用它能插入标签、处理光标和菜单、生成结构化请求但不负责把用户输入渲染成富文本文章。这也是我给自己设的边界不做工具栏不做图片粘贴不做表格不做标题、列表、加粗的自动格式化不做 Markdown 双向转换不让后端依赖 Tiptap 原始 JSON一句话总结我用的是 Tiptap 的编辑器基础能力不是它的富文本产品形态。3. Composer V1 的最小形态文本、命令标签、资源标签Composer V1 的输入模型很小只保留三块plainText用户自然语言 命令标签Command Chip用户想做什么 资源标签Resource Chip用户引用什么上下文本版没有做复杂选择器也没有做完整命令执行系统。我给它收了几个明确边界只允许一个命令标签只允许一个资源标签选择新的命令会替换旧命令选择新的资源会替换旧资源删除标签后同步清空结构化信息不做搜索不做文件树不做完整资源选择器Resource Picker不做 Skill / Tool / Prompt 菜单最终前后端约定的结构是ComposerPayloadComposer 发送给后端的结构化请求exporttypeChatComposerCommandNamecheck|summary|tasklistexportinterfaceChatComposerCommand{label:stringname:ChatComposerCommandName}exportinterfaceChatComposerReference{id:stringlabel:stringserverId?:stringsource:local|remotetype:resourceuri:string}exportinterfaceChatComposerPayload{command?:ChatComposerCommand plainText:stringreferences?:ChatComposerReference[]}这里有一个小细节虽然本版 UI 只允许一个资源标签但结构化请求里仍然使用references数组。这不是为了提前做多资源选择而是为了让接口语义更自然。v0.0.12只写入 0 或 1 个资源引用后续如果真的进入受控多引用不需要再把单数结构改成数组结构。它也不会替代原来的消息数组。ComposerPayload在本版里只是结构化提示信息让运行时更清楚这一轮输入里有哪些意图和引用。Composer V1 先解决“结构化表达”不解决“所有资源都能浏览和选择”。4./命令菜单Command 是意图不是执行按钮/命令菜单是这版最容易被误解的地方。很多产品里的 slash command斜杠命令像一个“立即执行按钮”选了某个命令就马上触发对应动作。但我在这一版没有这么做。Composer V1 里的 Command 只是任务意图标签。第一版固定 3 个命令/summary总结文档/tasklist生成任务清单/check检查文档一致性选择命令之后输入框里会插入一个命令标签。但它不会立即调用 Prompt也不会立即调用 Tool。比如用户输入[生成任务清单] 帮我整理一下 v0.0.12 后续工作这里的[生成任务清单]只告诉后端本轮用户更偏向“任务清单”场景。它不是一个远程 Prompt 的执行按钮。这个边界很重要。因为一旦把命令做成执行入口输入层就会开始承担工作流workflow多步骤编排的职责选命令、找资源、调 Prompt、调 Tool、处理结果。这样看起来很快但会让 Composer 提前变成一个小型 Agent。本版我只让命令进入结构化请求真正消费由后端运行时Runtime决定而且消费范围非常窄。5.资源菜单让上下文引用变成显式结构菜单解决的是另一个问题用户到底引用了什么上下文过去用户可能会写帮我看看 README。这个问题对人来说能懂但对运行时不够稳定。它到底是根目录README.md还是 docs 里的README.md是本地文档还是远程 MCP 资源模型能不能读读哪个服务这些都不能靠一句自然语言长期稳定地猜。所以本版把本地文档 Resource可读取上下文收敛成docs://...docs://README.md docs://architecture/runtime-boundary.md docs://architecture/stream-core.md docs://architecture/capability-skill-surface.md同时保留一个远程资源project://latest-context这背后对应了一个更大的边界收口本地 Resource 不再是“项目任意文件读取”而是只允许读取docs/**/*.md里的项目知识文档并统一使用docs://...这样的资源地址。这部分不是本文主线只需要理解成resource的安全背景用户可以显式引用项目文档但不会把源码目录、配置文件和任意本地文件都暴露给模型。资源标签的意义不是“前端提前读取资源”而是把“本轮显式引用了哪个资源”写进结构化请求。真正读取资源的动作仍然由运行时完成。6. 内联标签让结构化输入成为消息内容的一部分最终在输入框里我希望用户看到的是[总结文档] docs://README.md 帮我总结核心结构也就是命令、资源和自然语言共同构成本轮输入。所以实现里composer-chip-nodes.ts命令 / 资源内联节点定义负责把标签注册成 Tiptap inline atom node把两类标签都放进了编辑器文档树exportconstCOMMAND_CHIP_NODE_NAMEcommandChipexportconstRESOURCE_CHIP_NODE_NAMEresourceChipexportconstCommandChipNodeNode.create({name:COMMAND_CHIP_NODE_NAME,group:inline,inline:true,atom:true,selectable:true,addNodeView(){returnReactNodeViewRenderer(InlineComposerChipNodeView)},})exportconstResourceChipNodeNode.create({name:RESOURCE_CHIP_NODE_NAME,group:inline,inline:true,atom:true,selectable:true,addNodeView(){returnReactNodeViewRenderer(InlineComposerChipNodeView)},})这里的关键是inline atom selectable。它让标签既是输入内容的一部分又不会被拆成普通文本。用户可以选中它、删除它序列化时也能准确识别它。发送后的用户消息气泡也会保留这层语义。项目里用displaySegments消息展示片段用来还原文字、命令标签和资源标签的位置记录本轮用户消息的可读展示结构避免发送之后只能看到一段被压平的 plain text。这部分不是为了炫 UI而是为了让结构化输入真正成为消息内容的一部分。7. 结构化请求不要把 Tiptap JSON 直接丢给后端用了 Tiptap 之后很自然会出现一个诱惑既然编辑器有 JSON那直接把editor.getJSON()发给后端不就好了我没有这么做。原因很简单Tiptap JSON 是前端编辑器实现细节不应该成为 AI 运行时的输入协议。后端真正需要的只有三件事plainText用户自然语言command本轮任务意图references本轮显式引用的上下文资源所以项目里专门有composer-serialization.tsComposer 序列化层负责从 Tiptap 文档树提取 plainText、command、references 和 displaySegments来做这件事。核心逻辑是标签不进入plainText而是进入结构化信息。functiongetInlineTextFromContent(content:JSONContent[]|undefined):string{return(content??[]).map(node{if(node.typetext){returnnode.text??}if(node.typehardBreak){return\n}if(node.typeCOMMAND_CHIP_NODE_NAME||node.typeRESOURCE_CHIP_NODE_NAME){return}returngetInlineTextFromContent(node.content)}).join()}exportfunctionserializeComposerPayload(editor:Editor):ComposerPayload{consteditorJSONeditor.getJSON()constmetadataextractComposerMetadata(editorJSON)return{plainText:getPlainTextFromContent(editorJSON),...(metadata.command?{command:metadata.command}:{}),...(metadata.references.length0?{references:metadata.references}:{}),}}这段代码解决的问题很明确输入框里看到的 chip不应该被混进用户自然语言。比如[总结文档] docs://README.md 帮我总结核心结构发送给后端时应该变成{plainText:帮我总结核心结构,command:{name:summary,label:总结文档},references:[{type:resource,id:docs-readme,label:docs/README.md,source:local,uri:docs://README.md}]}而不是把[总结文档] docs://README.md一起塞进plainText。这里还有一个很小但很真实的兼容点如果用户只选择了标签没有输入自然语言旧的消息链路仍然需要一段非空文本。所以composer-submission.tsComposer 提交兼容层负责判断“只有标签、没有文字”的输入是否有效并生成兼容文本会在这种情况下用可读标签生成一段兼容文本。这样既不破坏旧消息结构也不丢掉 Composer 的结构化语义。8. Composer 运行时只做很窄的消费闭环有了结构化请求之后下一步很容易做重。比如/tasklist - 自动调用 tasklist-draft Prompt /check - 自动调用 check_doc_consistency Tool /summary - 自动选择某个 Resource 再总结这些听起来都合理但它们已经开始接近工作流。本版我没有这么做。composer-context.tsComposer 运行时消费层负责把命令意图和资源引用转成受控上下文注入只做了几条固定、很窄的逻辑exportfunctionresolveComposerContextInvocation(request:ChatRequest):ComposerContextInvocation|null{constcommandrequest.composer?.commandconstcommandNamecommand?.nameconstreferencegetPrimaryComposerReference(request)constuserGoalgetLastUserMessageText(request)if(commandNamesummaryisDocsResourceReference(reference)){return{kind:docs-summary,reference,userGoal,}}if(isDocsResourceReference(reference)){return{command,kind:docs-resource,reference,userGoal,}}if(isLatestContextReference(reference)){return{command,kind:remote-resource,reference,userGoal,}}if(command){return{command,kind:command-hint,userGoal,}}returnnull}这段逻辑体现了本版的边界。不同输入形态在本版里的行为大概是这样输入形态本版行为/summary docs://...读取 docs resource并使用local-file-summary生成文档摘要上下文docs://...读取 docs resource作为本轮回答上下文project://latest-context读取远程 context作为本轮回答上下文/tasklist只作为任务清单意图提示不自动调用远程 Prompt/check只作为一致性检查意图提示不自动调用远程 Tool也就是说/summary docs://...是本版唯一的自动摘要闭环但不是唯一的 Composer 消费路径。这个差异很关键。如果我把/tasklist和/check也做成立即执行就会从 Composer V1 滑向命令执行系统Command Execution System。那不是本版目标。Composer V1 不是工作流 V1。它只是先让用户输入变得结构化让运行时在少量固定场景里安全消费这些结构。9. 几个让输入框从“演示可用”变成“日常可用”的细节这次看起来是在做输入框但真正花时间的地方并不只是 UI。Enter 和 Shift Enter聊天输入里Enter 通常表示发送Shift Enter 表示换行。但到了 Tiptap 里Enter 同时可能被编辑器、Suggestion 菜单和输入法占用。最终实现里菜单打开时 Enter 交给菜单选择项菜单未打开、且不是组合输入状态时Enter 才会触发发送。中文输入法 composition中文输入法期间用户按 Enter 很可能只是确认候选词。所以发送逻辑必须检查event.isComposing和view.composing。如果不做这一步中文用户很容易在打字过程中误发送。这是一个非常小的判断但它直接决定输入框能不能日常使用。Markdown 字符保持纯文本AI 输入框不是 Markdown 编辑器。所以本版明确要求# 标题 - item **bold**这些输入都保持普通文本不自动变标题、列表或加粗。前端回答仍然可以渲染 Markdown但用户输入框不承担文章排版职责。/和菜单不能撑开输入框/和菜单用的是 TiptapSuggestion ReactRenderer tippy。可以简单理解为Tiptap 负责识别触发字符React 负责渲染菜单tippy 负责把菜单浮在光标附近。它必须是浮层而不是普通 DOM 流里的列表。不然菜单一打开底部输入框高度就会被撑开消息列表也会跟着抖动。触发字符也要收窄/不是任何时候都应该触发命令菜单。比如路径、URL、普通文本里都可能出现/。所以实现里会检查触发字符前面是否为空或空白避免把普通路径误识别成命令。的规则也类似只是为了适配中文输入它允许前一个字符是中文字符。这些细节不复杂但如果没有它们输入框会很“演示可用”但不太像日常可用。10. 用户看不到的另一半工具运行时也要回到能力驱动上面这些都是用户能看到的输入层变化。但本版本还有一个用户不太感知、对运行时却很重要的变化Tool Runtime工具运行时不再从allowedTools绑定工具而是从capabilitySelectors解析本轮可用工具。旧方式里Skill 上会有一组allowedTools。它简单直接但到了v0.0.11之后项目已经有了 Capability Surface能力表面用来统一描述 Tool / Resource / Prompt。如果继续保留allowedToolsSkill 里一套工具声明、能力模型里又一套选择规则后续会越来越难解释。本版迁移后的链路是SkillDefinition.capabilitySelectors - 能力目录Capability Catalog - 解析 tool 类型能力 - 生成本轮可用工具表 - 映射成模型可见工具 - 绑定给模型 - 进入工具运行时对应实现放在tool-binding.ts能力驱动的工具绑定层负责根据 Skill 选择范围解析本轮可用工具表里。关键点不是“换了一个字段名”而是把工具绑定收回到同一条能力链路里只有capabilityType tool的能力能进入模型工具绑定。Resource / Prompt 不会被错误塞进工具运行时。普通聊天没有命中 Skill 时不默认暴露所有工具。同一轮出现工具名冲突时直接失败收口不自动改名。这一层收口之后模型绑定、工具调用校验、工具执行和前端展示都消费同一份“本轮可用工具表”。这对后续 Agent 很重要因为 Agent 不应该面对一组散落在 Skill、工具注册表、MCP 适配器里的工具来源。11. 远程 MCP Tool 标准化不要让 check_doc_consistency 变成特殊分支v0.0.11已经接入了远程 MCP 服务project-assistant-service远程 MCP mock 服务当前用于验证远程 Resource / Prompt / Tool 最小闭环。其中有一个远程 Toolcheck_doc_consistency它可以用来检查文档口径一致性。如果只是为了跑通一个演示最容易的做法是写死if(toolNamecheck_doc_consistency){// special logic}但这会让远程 Tool 永远停留在特殊分支里。本版把它改成标准工具运行时可消费对象远程 MCP tools/list - RemoteMcpToolAdapter - ChatToolDefinition - 工具运行时 - mcpClientManager.callTool(...)实现放在remote-mcp-tool-adapter.ts远程 MCP Tool 适配层负责把tools/list返回的工具信息转成项目内部标准ChatToolDefinition里。这部分的核心只有一个check_doc_consistency只是第一个样本不应该成为写死分支。它应该通过RemoteMcpToolAdapter进入标准 Tool Runtime再由capabilitySelectors决定本轮是否暴露给模型。这样一来远程 MCP Tool 和本地工具走同一条工具运行时Resource / Prompt 也不会被错误混进工具绑定。这部分用户不一定直接感知但它让输入层、能力层和执行层开始对齐。12. 这版刻意没有做什么这次升级很容易继续往下扩但我刻意收住了。本版不做Agent工作流动态规划器RAG / chunking / indexing完整资源选择器Resource Picker多资源选择文件树浏览Markdown 富文本编辑器/tasklist自动调用远程 Prompt/check自动调用远程 Tool工具市场多服务发现Prompt / Resource tool 化这些不是不重要而是不应该在 Composer V1 里一起做。输入层刚开始结构化时最重要的是边界稳定。如果这一版同时把输入、资源选择、命令执行、工具规划和 Agent 都揉在一起短期看起来功能更多长期反而很难继续演进。所以我更愿意先把这三个问题讲清楚用户输入如何表达意图和上下文运行时何时消费这些结构化信息Tool 绑定如何回到能力驱动13. 一个小插曲正在思考的动效优化除了 Composer 主线这版还有一个很小的前端细节正在思考的文字动效。这个点不是核心架构但它会明显影响用户对“系统正在工作”的感知。一开始我是在 GPT 的界面里看到这个效果第一反应是这个“正在思考”的扫光反馈还挺酷。后面豆包也更新了类似特效我就更想把这个细节也补到 AI Mind 里。所以本版本除了输入层升级我也顺手实现了一个类似的“正在思考”文字动效。它不是为了堆炫技而是让等待过程不那么干尤其是在深度思考开启、模型还没吐出正文时界面能给用户一个更自然的状态反馈。代码上我没有做复杂动画组件只保留一个很小的ThinkingTextexport function ThinkingText({ text 正在思考, className }: ThinkingTextProps) { return ( span>.thinkingText{color:var(--thinking-text-base);background-image:linear-gradient(100deg,var(--thinking-text-base)0%,var(--thinking-text-base)42%,var(--thinking-text-sheen)50%,var(--thinking-text-base)58%,var(--thinking-text-base)100%);background-size:260% 100%;background-clip:text;-webkit-text-fill-color:transparent;animation:thinking-text-shimmer 1.8s ease-in-out infinite;}这块我比较在意两点它只是“系统正在思考”的轻提示不抢回答内容的注意力。它支持prefers-reduced-motion用户关闭动画偏好时会退回普通文字。这个小插曲和 Composer 的主线其实是同一件事的两面输入层让用户更清楚地表达“我要什么”反馈层让用户更清楚地感知“系统正在做什么”。14. 这版真正改变了什么如果只看 UI这版像是把textarea换成了 Tiptap 输入框。但从运行时角度看它改的是三条更底层的链路。第一输入层变了。用户输入不再只是自然语言字符串而是可以携带命令标签和资源标签。输入框开始表达“我要做什么”和“我要引用什么”。第二请求结构变了。前后端不再把所有东西都塞进 text而是把自然语言、任务意图、资源引用拆成plainText / command / references。Tiptap JSON 留在前端运行时只消费稳定的结构化请求。第三工具绑定变了。工具运行时不再依赖旧的allowedTools双轨声明而是通过capabilitySelectors - 能力目录 - 本轮可用工具表解析本轮可用工具。远程 MCP Tool 也进入了标准工具运行时。对我来说v0.0.12的意义不是“做了一个更漂亮的输入框”而是让 AI 应用的输入层第一次具备了结构化语义。它为下一阶段开始进入受控单 Agent 留下了更稳的前提textarea - 结构化请求 - 能力驱动的运行时 - 受控单 Agent Preview项目地址 GitHubhttps://github.com/HWYD/ai-mind如果这篇文章或者 AI Mind 项目对你有所帮助也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。