TinyRobot Sender 从 v0.3 到 v0.4 经历了一次重大架构升级——从自研编辑器切换到基于 Tiptap 的输入架构。这次升级不仅仅是底层引擎的替换,更是一次全面的可插拔化重构:扩展体系、按钮组件化、插槽体系、兼容层设计,每一个决策背后都有深思熟虑的技术权衡。

本文将深入拆解 Sender 的核心设计原理,揭示那些在 API 文档背后看不见的架构决策。
v0.3 的 Sender 是一个功能耦合的组件——模板、提及、联想、语音、上传、主题全部内置于组件 props 中。这种设计在初期快速迭代中是合理的,但随着功能增长,问题逐渐显现:
buttonGroup、speech、suggestions 等 props 堆叠,配置项越来越多v0.4 的升级策略是解耦:将内置于 props 的功能拆分为独立模块,通过 extensions 和插槽体系组合。
复制代码v0.3 架构:
Sender props → templateData / suggestions / allowSpeech / allowFiles / buttonGroup / themev0.4 架构:
Sender props → extensions (Template/Mention/Suggestion) + slots (VoiceButton/UploadButton) + ThemeProvider
Sender 的 extensions prop 类型直接来自 Tiptap:
复制代码import type { Extension } from '@tiptap/core'interface SenderProps {
extensions?: Extension[] // 默认为空数组
}
Tiptap 的 Extension 是其插件体系的核心概念,每个 Extension 可以:
Sender 的 Template、Mention、Suggestion 三个扩展本质上就是 Tiptap Extension 的实例化:
block 和 select 两种自定义 Nodemention Node,以特殊节点形式插入编辑器每个扩展提供两种集成方式:
复制代码// 便捷函数
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"的困惑。
v0.3 的 Sender 使用自研的 textarea 编辑器,功能有限(仅支持纯文本输入)。v0.4 选择 Tiptap 作为底层编辑引擎,基于以下考量:
ProseMirror 的文档是一个树形结构,每个节点(Node)都有类型、属性和内容。Sender 编辑器中的文档结构:
复制代码doc
└── paragraph
├── text("请帮我分析 ")
├── mention({ label: "张三", value: "用户ID" })
└── text(" 的周报")
这种结构化文档模型是 submit 事件返回 StructuredData 的底层支撑——遍历文档节点即可提取所有特殊节点的信息。
复制代码type StructuredData = TemplateItem[] | MentionStructuredItem[]
StructuredData 采用联合类型设计,根据启用的扩展类型返回不同的结构。submit 事件的双参数设计(text + data?)遵循一个原则:
这种双参数设计避免了"要么只有纯文本,要么必须解析结构"的单选困境,让开发者根据业务需求灵活选择。
Sender 的状态管理围绕两个核心属性展开:
复制代码interface SenderProps {
loading?: boolean // 默认 false
}
loading 状态的 UI 联动:
stopText(默认"停止响应")loading 自动禁用 复制代码// Events
interface SenderEvents {
cancel: () => void // v0.4 新增
}
cancel 事件的设计逻辑:用户点击停止按钮 → 触发 cancel 事件 → 开发者中止 AI 响应请求(如 abortRequest())→ 设置 loading = false。
复制代码<template>
<tr-sender :loading="isLoading" @submit="handleSubmit" @cancel="handleCancel" />
</template><script setup>
const isLoading = ref(false)const handleSubmit = async (text: string) => {
isLoading.value = true
try {
await sendMessage(text)
} finally {
isLoading.value = false
}
}const handleCancel = () => {
abortRequest()
isLoading.value = false
}
</script>
disabled 状态与 loading 的区别:disabled 是"不可用",loading 是"正在处理"。两者都会禁用编辑器,但 disabled 不显示停止按钮。
复制代码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()
}
}
}
这种"智能切换"避免了用户在单行和多行之间手动选择的困扰——单行模式适合简短输入,当输入变长时自然过渡为多行。
复制代码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'])可自定义选中联想项的按键,支持同时绑定多个键。
复制代码// 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 中。这种设计的问题:
ButtonGroupConfig 类型定义v0.4 的策略是拆分:
defaultActions prop 配置 复制代码// v0.4 的基础按钮配置
interface DefaultActions {
submit?: { disabled?, tooltip?, tooltipPlacement? }
clear?: { disabled?, tooltip?, tooltipPlacement? }
}// 增强按钮是独立组件
import { VoiceButton, UploadButton } from '@opentiny/tiny-robot'
这种组件化设计的优势:
VoiceButton 和 UploadButton 不是 Sender 的子组件,而是平级的独立组件。它们:
复制代码<!-- VoiceButton 独立使用 -->
<tr-voice-button
:speech-config="{ lang: 'zh-CN' }"
@speech-final="handleResult"
/><!-- UploadButton 独立使用 -->
<tr-upload-button accept="image/*" @select="handleFiles" />
这种"平级组件 + 插槽组合"的模式,是 Vue 组件设计中的一种高级模式——组件之间不是父子关系,而是协作关系。
SenderCompat 是为 v0.3 用户提供的过渡组件,它保留了 v0.3 的大部分 API,内部实现则委托给 v0.4 Sender:
复制代码v0.3 API → SenderCompat(适配层)→ v0.4 Sender(核心实现)
适配层的核心职责:
setTemplateData())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 的架构,可以提炼出几个核心设计原则:
这些原则不仅适用于 Sender,也可以作为 Vue 组件库设计的参考范式。
TinyRobot 官网:tiny-robot.opentiny.design
GitHub 仓库:github.com/opentiny/ti…