拆解内核:深入分析 TinyRobot 输入区组件设计与实现原理
TinyRobot Sender 从 v0.3 到 v0.4 经历了一次大手术——从自研编辑器切到基于 Tiptap 的输入架构。这不光是引擎替换,更是一套可插拔化的重构:扩展体系、按钮组件化、插槽体系、兼容层设计……每个决策背后都有技术上的深思熟虑。

本文将深度剖析 Sender 输入区组件的核心设计机理,逐一揭示隐藏在 API 文档之下的关键架构决策,帮助开发者全面理解组件设计思路。
从 v0.3 到 v0.4:架构升级全景
v0.3 的 Sender 是个功能耦合的组件——模板、提及、联想、语音、上传、主题全都塞在 props 里。这种设计在早期快速迭代时还行,但随着功能增多,问题就藏不住了:
- 包体积膨胀:不需要的功能也得打包带进来
- 配置复杂:
buttonGroup、speech、suggestions这些 props 堆在一起,配置项越来越多 - 扩展困难:想加新功能就得动核心代码
- 维护成本高:所有功能耦合在一起,改一个地方可能牵连全局
v0.4 的升级思路很清晰:解耦——把内置在 props 里的功能拆成独立模块,通过 extensions 和插槽体系组装起来。
v0.3 架构:
Sender props → templateData / suggestions / allowSpeech / allowFiles / buttonGroup / theme
v0.4 架构:
Sender props → extensions (Template/Mention/Suggestion) + slots (VoiceButton/UploadButton) + ThemeProvider
可插拔扩展体系设计
Extension 类型从哪来?
Sender 的 extensions prop 类型直接继承自 Tiptap:
import type { Extension } from '@tiptap/core'
interface SenderProps {
extensions?: Extension[] // 默认为空数组
}
Tiptap 的 Extension 是它的插件核心,每个 Extension 可以:
- 定义新的 Node(文档节点类型)
- 定义新的 Mark(文本标记样式)
- 注册新的 Command(编辑器命令)
- 添加键盘快捷键
- 扩展编辑器状态
Sender 里的 Template、Mention、Suggestion 三个扩展,本质上就是 Tiptap Extension 的实例:
- Template 扩展定义了
block和select两种自定义 Node - Mention 扩展定义了
mentionNode,以特殊节点形式插入编辑器 - Suggestion 扩展通过 Tiptap 的 Plugin 系统实现弹出联想列表
便捷函数 vs 标准配置:设计上的权衡
每个扩展都提供了两种集成方式:
// 便捷函数
TrSender.mention(mentions, '@')
TrSender.template(templateData)
TrSender.suggestion(suggestions)
// 标准配置
TrSender.Mention.configure({ items: mentions, char: '@', allowSpaces: false })
TrSender.Template.configure({ items: templateData })
TrSender.Suggestion.configure({ items: suggestions, filterFn: customFilter })
这里有个设计细节值得停下来想想:
便捷函数本质上就是标准配置的参数简化版。它的目的很简单——降低门槛,一行代码就能跑起来。但便捷函数隐藏了部分配置项(比如 allowSpaces、onSelect、popupWidth),而这些在复杂场景里是必须的。
两者的实现关系:
// 便捷函数内部实现(伪代码)
TrSender.mention = (items, char = '@') => {
return TrSender.Mention.configure({ items, char })
}
TrSender.suggestion = (items, options?) => {
return TrSender.Suggestion.configure({ items, ...options })
}
这种"简洁版 + 完整版"的双轨设计在组件库里并不少见(比如 Ant Design 的 Form.create() vs Form.useForm())。但需要谨慎维护——两条 API 路径的差异必须在文档里说清楚,否则用户容易困惑:"为什么便捷函数不能配置 X?"
编辑器引擎:Tiptap 与 ProseMirror
为什么选 Tiptap?
v0.3 的 Sender 用自研的 textarea 编辑器,功能有限,只能纯文本输入。v0.4 选 Tiptap 作为底层引擎,主要是这么几方面考虑:
- ProseMirror 架构:Tiptap 基于 ProseMirror,这是个成熟的富文本编辑框架,有完整的文档模型、事务系统和插件体系
- 插件体系:ProseMirror 的插件系统天然适合可扩展设计,Sender 的三大扩展就是靠它实现的
- 可扩展节点:ProseMirror 支持自定义 Node 和 Mark,Template 的 block/select 节点、Mention 的 mention 节点都是自定义节点
- 社区生态:Tiptap 有丰富的社区插件(表格、代码块、协作编辑等),未来可以按需引入
ProseMirror 文档模型长什么样?
ProseMirror 的文档是个树形结构,每个节点(Node)都有类型、属性和内容。Sender 编辑器里的文档结构大概长这样:
doc
└── paragraph
├── text("请帮我分析 ")
├── mention({ label: "张三", value: "用户ID" })
└── text(" 的周报")
这种结构化文档模型是 submit 事件返回 StructuredData 的底气——遍历文档节点就能提取所有特殊节点的信息。
结构化数据设计
type StructuredData = TemplateItem[] | MentionStructuredItem[]
StructuredData 用了联合类型,根据启用的扩展类型返回不同结构。submit 事件的双参数设计(text + data?)遵循一个原则:
- text 参数:纯文本内容,适用于简单场景(比如直接发给 AI API)
- data 参数:结构化数据,适用于复杂场景(比如提取提及对象、自定义拼接格式)
这种双参数设计避免了"要么只有纯文本,要么必须解析结构"的两难困境,开发者可以根据业务需求灵活选择。
状态管理:loading/disabled 与 UI 联动
Sender 的状态管理围绕两个核心属性展开:
loading 状态
interface SenderProps {
loading?: boolean // 默认 false
}
loading 状态会联动 UI:
- 提交按钮变身为停止按钮:显示停止图标和
stopText(默认"停止响应") - 编辑器进入禁用态:阻止用户继续输入
- 功能按钮自动禁用:VoiceButton、UploadButton 等通过插槽作用域的
loading自动失效
cancel 事件设计
// Events
interface SenderEvents {
cancel: () => void // v0.4 新增
}
cancel 事件的设计逻辑很直白:用户点击停止按钮 → 触发 cancel 事件 → 开发者中止 AI 响应请求(比如 abortRequest())→ 设置 loading = false。
disabled 状态
disabled 和 loading 的区别很微妙:disabled 表示"不可用",loading 表示"正在处理"。两者都会禁用编辑器,但 disabled 不会显示停止按钮。
输入模式切换:single/multiple
模式定义
type InputMode = 'single' | 'multiple'
interface SenderProps {
mode?: InputMode // 默认 'single'
}
单行模式自动切换多行——这个设计很聪明
这是 Sender 最精巧的交互之一。在单行模式下:
- 当输入内容超出输入框宽度时,自动切换为多行模式
- 当
submitType="enter"时,按Ctrl+Enter或Shift+Enter也会自动切到多行模式并换行
实现的原理也简单:编辑器监听内容变化,当检测到内容高度超过单行阈值时,内部状态从 single 切为 multiple,同时调整编辑器高度和布局。
// 内部实现(伪代码)
watch(contentHeight, (height) => {
if (mode === 'single' && height > singleLineThreshold) {
internalMode.value = 'multiple'
}
})
// 换行快捷键触发
if (mode === 'single' && submitType === 'enter') {
// Ctrl+Enter / Shift+Enter → 自动切换多行 + 换行
handleKeyDown(event) {
if (isLineBreakShortcut(event)) {
switchToMultipleMode()
insertNewLine()
}
}
}
这种"智能切换"避免了用户手动在单行和多行之间切换的麻烦——单行模式适合简短输入,输入变长后自然过渡为多行。
提交与快捷键系统
submitType 的三种模式
type SubmitType = 'enter' | 'ctrlEnter' | 'shiftEnter'
interface SenderProps {
submitType?: SubmitType // 默认 'enter'
}
| submitType | 提交快捷键 | 换行快捷键 |
|---|---|---|
enter | Enter | Ctrl+Enter / Shift+Enter |
ctrlEnter | Ctrl+Enter | Enter |
shiftEnter | Shift+Enter | Enter |
单行模式下的特殊行为
单行模式下,Enter 键的行为取决于 submitType:
submitType="enter":Enter 提交(单行模式不需要换行)submitType="ctrlEnter":Ctrl+Enter 提交,Enter 无效果(单行模式下 Enter 不换行)submitType="shiftEnter":Shift+Enter 提交
当用户按下换行快捷键时,会触发模式切换:从 single 切到 multiple,然后插入换行。
快捷键参考表
| 快捷键 | 功能 | 适用条件 |
|---|---|---|
| Enter | 提交 / 换行 | submitType="enter" |
| Ctrl+Enter | 提交 / 换行 | submitType="ctrlEnter" / submitType="enter" |
| Shift+Enter | 提交 / 换行 | submitType="shiftEnter" / submitType="enter" |
| Tab | 选中联想项 | 联想开启时 |
| Esc | 关闭联想 | 联想开启时 |
| ↑ / ↓ | 导航联想项 | 联想开启时 |
activeSuggestionKeys(默认 ['Enter'])可以自定义选中联想项的按键,支持同时绑定多个键。
按钮组件化:从 buttonGroup 到 defaultActions + 插槽
v0.3 的 buttonGroup 设计:当时是怎么做的?
// v0.3 的按钮配置(已移除)
interface ButtonGroupConfig {
submit?: { disabled, tooltip, icon }
clear?: { disabled, tooltip, icon }
voice?: { disabled, tooltip, icon, speechConfig }
file?: { disabled, tooltip, icon, accept, multiple }
}
v0.3 把所有按钮配置塞进一个 buttonGroup prop。这种设计的问题:
- 职责不清:submit/clear 是基础按钮,voice/file 是增强按钮,混在一起
- 扩展困难:要新增按钮类型必须改
ButtonGroupConfig类型定义 - 类型膨胀:按钮种类一多,类型定义就越来越庞大
v0.4 的思路:组件化拆分
v0.4 的方法很直接:拆。
- 基础按钮(Submit、Clear)→
defaultActionsprop 配置 - 增强按钮(Voice、Upload)→ 独立组件 + 插槽添加
- 自定义按钮 → 直接在插槽里放任意按钮
// v0.4 的基础按钮配置
interface DefaultActions {
submit?: { disabled?, tooltip?, tooltipPlacement? }
clear?: { disabled?, tooltip?, tooltipPlacement? }
}
// 增强按钮是独立组件
import { VoiceButton, UploadButton } from '@opentiny/tiny-robot'
这种组件化设计的优势:
- 独立演进:VoiceButton 可以加新功能,不影响 Sender 核心
- 按需引入:不需要语音功能就不引入 VoiceButton
- 自由组合:任何按钮可以放在任何插槽位置
- 类型安全:每个组件有独立的类型定义
VoiceButton/UploadButton 的独立设计
VoiceButton 和 UploadButton 不是 Sender 的子组件,而是平级的独立组件。它们:
- 有独立的 Props、Events、Methods
- 可以独立使用(不依赖 Sender)
- 通过插槽与 Sender 组合
这种"平级组件 + 插槽组合"的模式,是 Vue 组件设计里的一种高级玩法——组件之间不是父子关系,而是协作关系。
SenderCompat:兼容层设计哲学
薄适配层
SenderCompat 是为 v0.3 用户准备的过渡组件,它保留了 v0.3 的大部分 API,内部实现则委托给 v0.4 Sender:
v0.3 API → SenderCompat(适配层)→ v0.4 Sender(核心实现)
适配层的核心职责:
- Props 转换:把 v0.3 的 props 转成 v0.4 的格式
- 事件映射:把 v0.3 的事件映射到 v0.4 的对应事件
- 方法兼容:保留 v0.3 的方法签名(比如
setTemplateData())
性能损耗 < 10%
SenderCompat 的性能损耗主要来自 Props 转换和事件映射的计算。因为适配层非常薄(只是数据格式转换),实际损耗 < 10%,甚至比 v0.3 的自研实现还有性能提升(得益于 Tiptap 的优化)。
迁移路径
方案 A:快速迁移(推荐)
v0.3 Sender → SenderCompat(改导入,小调整)
方案 B:完全升级(目标)
SenderCompat → v0.4 Sender(使用新 API)
SenderCompat 是过渡组件,会在未来版本(比如 v1.0.0)中废弃。但它的存在让 v0.3 用户可以渐进式迁移,不需要一次性重写所有代码。
核心设计原则总结
回头看 Sender v0.4 的架构,可以提炼出几个核心设计原则:
- 可插拔优于内置:通过 extensions 和插槽,功能按需组合,而不是全塞进去
- 组件化优于配置化:VoiceButton/UploadButton 是独立组件,而不是 buttonGroup 里的配置项
- 双轨 API:便捷函数覆盖 80% 的简单场景,标准配置覆盖 20% 的复杂场景
- 结构化输出:submit 事件同时提供纯文本和结构化数据,开发者按需选择
- 智能默认:单行自动切换多行、模板自动聚焦首个字段、删除提及保留触发字符——这些"零配置即好用"的细节,是 Sender 用户体验的基石
这些原则不仅适用于 Sender,也可以作为 Vue 组件库设计的一个参考范式。
