从 textarea 到 AI 输入框:用 Tiptap 实现 / 命令、@ 引用和结构化请求

本文对应项目版本:v0.0.12

在一个 AI 应用刚开始做聊天功能时,textarea 往往已经够用了。

用户输入一段自然语言,前端把它发给后端,后端拿到消息数组,模型开始生成回答。这个链路简单、直接,也足够支撑最早期的问答场景。

但当项目里慢慢出现 Skill(任务能力层)、Tool(可执行工具)、Resource(可读取上下文)、Prompt(可注入模型的提示模板)、MCP 能力(通过 MCP 接入的外部能力)之后,我开始遇到一个更具体的问题:用户输入不再只是一段自然语言。

它开始同时表达三件事:

  • 这一轮我想做什么
  • 这一轮我要引用哪个上下文
  • 这一轮真正的自然语言问题是什么

普通 textarea 能承载第三件事,却很难稳定承载前两件事。

这也是 AI Mind 在 v0.0.12 里升级输入层的原因。

先简单介绍一下项目背景。AI Mind 是一个按版本持续演进的 AI Native Runtime Skeleton(AI 原生运行时骨架),不是一次性做完的 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 的差异大概是这样:

text 复制代码
普通 textarea:
用户只能输入自然语言

Composer V1:
自然语言 + /命令 + @资源

1. 为什么我不再满足于 textarea

在早期聊天场景里,输入框的职责很单纯:收集用户自然语言。

比如:

text 复制代码
帮我总结一下这个项目。

这个问题本身已经包含了任务目标,但它没有明确说明"总结哪个上下文"。如果系统里只有一段固定上下文,问题不大;但当项目开始支持 Resource(可读取上下文)、Prompt(提示模板)、MCP 服务(外部能力服务)和 Skill 路由之后,靠自然语言猜上下文会越来越不稳。

用户可能想表达的是:

text 复制代码
[总结文档] @docs://README.md 这份文档的核心结构是什么?

这里面其实有三层信息:

  • [总结文档] 是任务意图
  • @docs://README.md 是上下文引用
  • 这份文档的核心结构是什么? 才是自然语言问题

如果这些都塞进一段字符串里,后端只能继续靠解析文本或者靠模型自己猜。

这在工程上会带来几个问题:

  • 前端不知道这轮输入到底携带了哪些结构化语义。
  • 后端不知道用户引用的资源是明确选择的,还是自然语言里随口提到的。
  • 运行时很难区分"用户想总结文档"和"用户只是问到 summary 这个词"。
  • 下一阶段开始进入 Agent 时,输入层仍然是一团不稳定的文本。

所以 Composer V1 解决的不是"输入框更好看"这个问题,而是让输入层先有能力表达结构。


2. 为什么选择 Tiptap,但不把它当富文本编辑器用

这次我选择 Tiptap,不是因为想做一个 Markdown 编辑器。

恰好相反,我在这个版本里刻意避免把输入框做成富文本产品形态。

Tiptap 在这里是增强输入框,不是富文本编辑器。

AI Composer(AI 输入层)的职责不是排版文章,而是表达本轮 AI 请求的输入语义。它需要的是这些能力:

  • 在光标位置插入一个整体可选中的标签
  • / 触发命令菜单
  • @ 触发资源菜单
  • 删除标签时同步清空对应结构化信息
  • 发送时从编辑器文档树里提取结构化请求
  • 输入 #-**bold** 时保持普通文本,不自动变成富文本结构

如果继续用 textarea,最麻烦的不是输入文本,而是"输入框里有一块不是普通文本、但又要像内容一样存在"的标签。

Tiptap 的价值在这里就很明确:它能让我把命令标签(Command Chip)和资源标签(Resource Chip)做成内联原子节点(inline atom node)。也就是说,它们在编辑器里像一个整体对象一样存在,可以被光标跳过、选中、删除,同时又能在发送时被序列化成稳定的结构化信息。

本版实际代码里,composer-editor.tsx(Tiptap 输入层,负责编辑器初始化、Enter 行为、/@ 菜单触发)使用了 Tiptap,但关闭了大部分富文本能力。StarterKit 只是被裁剪后的基础输入能力,不承担 Markdown 富文本编辑器角色。

从实现流程看,它大概分成 6 步:

text 复制代码
初始化 Tiptap 编辑器
-> 关闭大部分富文本能力,只保留基础输入
-> 注册 commandChip / resourceChip 两类内联节点
-> 用 Suggestion 监听 / 和 @
-> 选择菜单项后插入内联标签
-> 发送时序列化成 plainText / command / references

这里最关键的不是"用了 Tiptap",而是把编辑器能力拆成了两层:前端输入体验由 Tiptap 承接,前后端协议仍然由结构化请求承接。这样后端不用理解 Tiptap 的文档树,也不会被某个前端编辑器绑定死。

所以这版我一直把它当"增强版输入框"来用:它能插入标签、处理光标和菜单、生成结构化请求,但不负责把用户输入渲染成富文本文章。

这也是我给自己设的边界:

  • 不做工具栏
  • 不做图片粘贴
  • 不做表格
  • 不做标题、列表、加粗的自动格式化
  • 不做 Markdown 双向转换
  • 不让后端依赖 Tiptap 原始 JSON

一句话总结:

我用的是 Tiptap 的编辑器基础能力,不是它的富文本产品形态。


3. Composer V1 的最小形态:文本、命令标签、资源标签

Composer V1 的输入模型很小,只保留三块:

text 复制代码
plainText:用户自然语言
命令标签(Command Chip):用户想做什么
资源标签(Resource Chip):用户引用什么上下文

本版没有做复杂选择器,也没有做完整命令执行系统。

我给它收了几个明确边界:

  • 只允许一个命令标签
  • 只允许一个资源标签
  • 选择新的命令会替换旧命令
  • 选择新的资源会替换旧资源
  • 删除标签后同步清空结构化信息
  • 不做搜索
  • 不做文件树
  • 不做完整资源选择器(Resource Picker)
  • 不做 Skill / Tool / Prompt 菜单

最终前后端约定的结构是 ComposerPayload(Composer 发送给后端的结构化请求):

ts 复制代码
export type ChatComposerCommandName = 'check' | 'summary' | 'tasklist'

export interface ChatComposerCommand {
    label: string
    name: ChatComposerCommandName
}

export interface ChatComposerReference {
    id: string
    label: string
    serverId?: string
    source: 'local' | 'remote'
    type: 'resource'
    uri: string
}

export interface ChatComposerPayload {
    command?: ChatComposerCommand
    plainText: string
    references?: ChatComposerReference[]
}

这里有一个小细节:虽然本版 UI 只允许一个资源标签,但结构化请求里仍然使用 references 数组。

这不是为了提前做多资源选择,而是为了让接口语义更自然。v0.0.12 只写入 0 或 1 个资源引用,后续如果真的进入受控多引用,不需要再把单数结构改成数组结构。

它也不会替代原来的消息数组。ComposerPayload 在本版里只是结构化提示信息,让运行时更清楚这一轮输入里有哪些意图和引用。

Composer V1 先解决"结构化表达",不解决"所有资源都能浏览和选择"。


4. / 命令菜单:Command 是意图,不是执行按钮

/ 命令菜单是这版最容易被误解的地方。

很多产品里的 slash command(斜杠命令)像一个"立即执行按钮":选了某个命令,就马上触发对应动作。但我在这一版没有这么做。

Composer V1 里的 Command 只是任务意图标签

第一版固定 3 个命令:

  • /summary:总结文档
  • /tasklist:生成任务清单
  • /check:检查文档一致性

选择命令之后,输入框里会插入一个命令标签。但它不会立即调用 Prompt,也不会立即调用 Tool。

比如用户输入:

text 复制代码
[生成任务清单] 帮我整理一下 v0.0.12 后续工作

这里的 [生成任务清单] 只告诉后端:本轮用户更偏向"任务清单"场景。它不是一个远程 Prompt 的执行按钮。

这个边界很重要。

因为一旦把命令做成执行入口,输入层就会开始承担工作流(workflow,多步骤编排)的职责:选命令、找资源、调 Prompt、调 Tool、处理结果。这样看起来很快,但会让 Composer 提前变成一个小型 Agent。

本版我只让命令进入结构化请求,真正消费由后端运行时(Runtime)决定,而且消费范围非常窄。


5. @ 资源菜单:让上下文引用变成显式结构

@ 菜单解决的是另一个问题:用户到底引用了什么上下文?

过去用户可能会写:

text 复制代码
帮我看看 README。

这个问题对人来说能懂,但对运行时不够稳定。

它到底是根目录 README.md?还是 docs 里的 README.md?是本地文档?还是远程 MCP 资源?模型能不能读?读哪个服务?这些都不能靠一句自然语言长期稳定地猜。

所以本版把本地文档 Resource(可读取上下文)收敛成 docs://...

text 复制代码
@docs://README.md
@docs://architecture/runtime-boundary.md
@docs://architecture/stream-core.md
@docs://architecture/capability-skill-surface.md

同时保留一个远程资源:

text 复制代码
@project://latest-context

这背后对应了一个更大的边界收口:本地 Resource 不再是"项目任意文件读取",而是只允许读取 docs/**/*.md 里的项目知识文档,并统一使用 docs://... 这样的资源地址。

这部分不是本文主线,只需要理解成 @resource 的安全背景:用户可以显式引用项目文档,但不会把源码目录、配置文件和任意本地文件都暴露给模型。

资源标签的意义不是"前端提前读取资源",而是把"本轮显式引用了哪个资源"写进结构化请求。真正读取资源的动作仍然由运行时完成。


6. 内联标签:让结构化输入成为消息内容的一部分

最终在输入框里,我希望用户看到的是:

text 复制代码
[总结文档] @docs://README.md 帮我总结核心结构

也就是命令、资源和自然语言共同构成本轮输入。

所以实现里,composer-chip-nodes.ts(命令 / 资源内联节点定义,负责把标签注册成 Tiptap inline atom node)把两类标签都放进了编辑器文档树:

ts 复制代码
export const COMMAND_CHIP_NODE_NAME = 'commandChip'
export const RESOURCE_CHIP_NODE_NAME = 'resourceChip'

export const CommandChipNode = Node.create({
    name: COMMAND_CHIP_NODE_NAME,
    group: 'inline',
    inline: true,
    atom: true,
    selectable: true,

    addNodeView() {
        return ReactNodeViewRenderer(InlineComposerChipNodeView)
    },
})

export const ResourceChipNode = Node.create({
    name: RESOURCE_CHIP_NODE_NAME,
    group: 'inline',
    inline: true,
    atom: true,
    selectable: true,

    addNodeView() {
        return ReactNodeViewRenderer(InlineComposerChipNodeView)
    },
})

这里的关键是 inline + atom + selectable

它让标签既是输入内容的一部分,又不会被拆成普通文本。用户可以选中它、删除它,序列化时也能准确识别它。

发送后的用户消息气泡也会保留这层语义。项目里用 displaySegments(消息展示片段,用来还原文字、命令标签和资源标签的位置)记录本轮用户消息的可读展示结构,避免发送之后只能看到一段被压平的 plain text。

这部分不是为了炫 UI,而是为了让结构化输入真正成为消息内容的一部分。


7. 结构化请求:不要把 Tiptap JSON 直接丢给后端

用了 Tiptap 之后,很自然会出现一个诱惑:既然编辑器有 JSON,那直接把 editor.getJSON() 发给后端不就好了?

我没有这么做。

原因很简单:Tiptap JSON 是前端编辑器实现细节,不应该成为 AI 运行时的输入协议。

后端真正需要的只有三件事:

  • plainText:用户自然语言
  • command:本轮任务意图
  • references:本轮显式引用的上下文资源

所以项目里专门有 composer-serialization.ts(Composer 序列化层,负责从 Tiptap 文档树提取 plainText、command、references 和 displaySegments)来做这件事。

核心逻辑是:标签不进入 plainText,而是进入结构化信息。

ts 复制代码
function getInlineTextFromContent(content: JSONContent[] | undefined): string {
    return (content ?? [])
        .map(node => {
            if (node.type === 'text') {
                return node.text ?? ''
            }

            if (node.type === 'hardBreak') {
                return '\n'
            }

            if (node.type === COMMAND_CHIP_NODE_NAME || node.type === RESOURCE_CHIP_NODE_NAME) {
                return ''
            }

            return getInlineTextFromContent(node.content)
        })
        .join('')
}

export function serializeComposerPayload(editor: Editor): ComposerPayload {
    const editorJSON = editor.getJSON()
    const metadata = extractComposerMetadata(editorJSON)

    return {
        plainText: getPlainTextFromContent(editorJSON),
        ...(metadata.command ? { command: metadata.command } : {}),
        ...(metadata.references.length > 0 ? { references: metadata.references } : {}),
    }
}

这段代码解决的问题很明确:输入框里看到的 chip,不应该被混进用户自然语言。

比如:

text 复制代码
[总结文档] @docs://README.md 帮我总结核心结构

发送给后端时应该变成:

ts 复制代码
{
  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.ts(Composer 提交兼容层,负责判断"只有标签、没有文字"的输入是否有效,并生成兼容文本)会在这种情况下用可读标签生成一段兼容文本。这样既不破坏旧消息结构,也不丢掉 Composer 的结构化语义。


8. Composer 运行时:只做很窄的消费闭环

有了结构化请求之后,下一步很容易做重。

比如:

text 复制代码
/tasklist -> 自动调用 tasklist-draft Prompt
/check -> 自动调用 check_doc_consistency Tool
/summary -> 自动选择某个 Resource 再总结

这些听起来都合理,但它们已经开始接近工作流。

本版我没有这么做。

composer-context.ts(Composer 运行时消费层,负责把命令意图和资源引用转成受控上下文注入)只做了几条固定、很窄的逻辑:

ts 复制代码
export function resolveComposerContextInvocation(request: ChatRequest): ComposerContextInvocation | null {
    const command = request.composer?.command
    const commandName = command?.name
    const reference = getPrimaryComposerReference(request)
    const userGoal = getLastUserMessageText(request)

    if (commandName === 'summary' && isDocsResourceReference(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,
        }
    }

    return null
}

这段逻辑体现了本版的边界。不同输入形态在本版里的行为大概是这样:

输入形态 本版行为
/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.isComposingview.composing。如果不做这一步,中文用户很容易在打字过程中误发送。

这是一个非常小的判断,但它直接决定输入框能不能日常使用。

Markdown 字符保持纯文本

AI 输入框不是 Markdown 编辑器。

所以本版明确要求:

text 复制代码
# 标题
- item
**bold**

这些输入都保持普通文本,不自动变标题、列表或加粗。

前端回答仍然可以渲染 Markdown,但用户输入框不承担文章排版职责。

/@ 菜单不能撑开输入框

/@ 菜单用的是 Tiptap Suggestion + ReactRenderer + tippy。可以简单理解为:Tiptap 负责识别触发字符,React 负责渲染菜单,tippy 负责把菜单浮在光标附近。

它必须是浮层,而不是普通 DOM 流里的列表。不然菜单一打开,底部输入框高度就会被撑开,消息列表也会跟着抖动。

触发字符也要收窄

/ 不是任何时候都应该触发命令菜单。

比如路径、URL、普通文本里都可能出现 /。所以实现里会检查触发字符前面是否为空或空白,避免把普通路径误识别成命令。

@ 的规则也类似,只是为了适配中文输入,它允许前一个字符是中文字符。

这些细节不复杂,但如果没有它们,输入框会很"演示可用",但不太像日常可用。


10. 用户看不到的另一半:工具运行时也要回到能力驱动

上面这些都是用户能看到的输入层变化。

但本版本还有一个用户不太感知、对运行时却很重要的变化:Tool Runtime(工具运行时)不再从 allowedTools 绑定工具,而是从 capabilitySelectors 解析本轮可用工具。

旧方式里,Skill 上会有一组 allowedTools。它简单直接,但到了 v0.0.11 之后,项目已经有了 Capability Surface(能力表面,用来统一描述 Tool / Resource / Prompt)。如果继续保留 allowedTools,Skill 里一套工具声明、能力模型里又一套选择规则,后续会越来越难解释。

本版迁移后的链路是:

text 复制代码
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 最小闭环)。

其中有一个远程 Tool:

text 复制代码
check_doc_consistency

它可以用来检查文档口径一致性。

如果只是为了跑通一个演示,最容易的做法是写死:

ts 复制代码
if (toolName === 'check_doc_consistency') {
    // special logic
}

但这会让远程 Tool 永远停留在特殊分支里。

本版把它改成标准工具运行时可消费对象:

text 复制代码
远程 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 里。

所以本版本除了输入层升级,我也顺手实现了一个类似的"正在思考"文字动效。它不是为了堆炫技,而是让等待过程不那么干,尤其是在深度思考开启、模型还没吐出正文时,界面能给用户一个更自然的状态反馈。

代码上我没有做复杂动画组件,只保留一个很小的 ThinkingText

tsx 复制代码
export function ThinkingText({ text = '正在思考', className }: ThinkingTextProps) {
    return (
        <span data-slot="thinking-text" className={cn(styles.thinkingText, className)}>
            {text}
        </span>
    )
}

真正的动效放在 CSS 里,用文字背景渐变做一个从右到左的扫光:

css 复制代码
.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 留下了更稳的前提:

text 复制代码
textarea
-> 结构化请求
-> 能力驱动的运行时
-> 受控单 Agent Preview

项目地址:

github.com/HWYD/ai-min...

如果这个系列对大家有帮助,可以顺手点个 Star 支持一下。下一阶段我会开始推进受控单 Agent Preview:不是一上来做完全自由规划,而是在这版结构化输入、受控资源引用和能力驱动工具绑定的基础上,让 Agent 先在明确边界里跑起来。

相关推荐
kyriewen2 小时前
程序员连夜带团队跑路,省了23万:这AI太贵,真的用不起了
前端·javascript·openai
kyriewen3 小时前
你写的代码没有测试,就像出门不锁门——Jest + Testing Library 从入门到不慌
前端·单元测试·jest
yuzhiboyouye4 小时前
web前端英语面试
前端·面试·状态模式
canonical_entropy5 小时前
下一代低代码渲染框架 nop-chaos-flux 的设计原则
前端·低代码·前端框架
东方小月5 小时前
5分钟搞懂Harness Engineering(驾驭工程):从提示词到AI Agent的进化之路
前端·后端·架构
我叫黑大帅5 小时前
为什么需要 @types/react?解决“无法找到模块 react 的声明文件”报错
前端·javascript·面试
之歆5 小时前
DAY_21JavaScript 深度解析:数组(Array)与函数(Function)(一)
前端·javascript
XinZong6 小时前
【AI社交】基于OpenClaw自研轻量化AI社交平台实战
前端
Le_ee6 小时前
ctfweb:php/php短标签/.haccess+图片马/XXE
开发语言·前端·php