深挖底层:TinyRobot Bubble 消息气泡组件核心技术原理
前面几篇文章,我们从 Kit 工具包入门、基础组件搭建、多轮对话分组、内容解析器到渲染器架构,逐步掌握了 TinyRobot Bubble 的完整使用体系。现在,让我们深入底层,剖析 Bubble 的核心技术原理------它是如何从数据模型到 DOM 渲染完成整个链路的?
一、消息数据模型设计
1.1 BubbleMessage 泛型设计
BubbleMessage 采用泛型设计,允许自定义 content 和 state 的类型:
typescript
interface BubbleMessage<
T extends ChatMessageContent = ChatMessageContent,
S extends Record<string, unknown> = Record<string, unknown>,
> {
role?: string
content?: T
reasoning_content?: string
tool_calls?: ToolCall[]
tool_call_id?: string
name?: string
id?: string
loading?: boolean
state?: S
}
泛型的意义:
T参数让 content 可以是自定义的结构化数据(不只是 string 和 ChatMessageContentItem\[\])S参数让 state 可以有明确的类型定义,而非模糊的 Record
这使得 Bubble 在保持通用性的同时,为具体业务提供类型安全保障。
1.2 ChatMessageContent 的统一化
内部处理流程中,所有 content 会被"统一化":
go
string → [{ type: 'text', text: 原字符串 }]
Array → 保持原样,每项已是 ChatMessageContentItem
统一化的好处是:渲染器始终处理的是数组结构,无需额外判断 content 是 string 还是 array。
1.3 消息字段与渲染器的映射
| 字段 | 对应渲染器 | 优先级 |
|---|---|---|
loading: true |
Loading 渲染器 | P_LOADING (-1) |
content[].type === 'image_url' |
Image 渲染器 | P_CONTENT (10) |
role === 'tool' |
ToolRole 渲染器 | P_ROLE (20) |
tool_calls |
Tool/Tools 渲染器 | P_NORMAL (0) |
reasoning_content |
Reasoning 渲染器 | P_NORMAL (0) |
| 其他 | Text 渲染器(fallback) | - |
优先级设计确保:加载状态永远最先被检查(-1),因为一条消息不可能同时是"加载中"和"有内容"。然后是内容类型判断(10),最后是角色判断(20)------因为角色是更宏观的分类。
二、分组算法原理
2.1 divider 策略算法
divider 策略的核心逻辑是"按分割角色划界":
ini
输入:[user1, ai1, ai2, user2, ai3, tool1]
dividerRole = 'user' 时:
组1: [user1, ai1, ai2] --- user1 是分割线,后续消息直到下一个 user
组2: [user2, ai3, tool1] --- user2 是分割线,后续消息直到结束
算法伪代码:
typescript
function dividerGrouping(messages: BubbleMessage[], dividerRole: string): BubbleMessageGroup[] {
const groups: BubbleMessageGroup[] = []
for (const msg of messages) {
if (msg.role === dividerRole) {
// 分割角色消息:开始新组,此消息单独成组
groups.push({
role: msg.role,
messages: [msg],
messageIndexes: [currentIndex],
startIndex: currentIndex,
})
} else {
// 非分割角色消息:加入上一个分割组
groups[groups.length - 1]?.messages.push(msg)
}
}
return groups
}
2.2 consecutive 策略算法
consecutive 策略更简单------连续相同角色合并:
typescript
function consecutiveGrouping(messages: BubbleMessage[]): BubbleMessageGroup[] {
const groups: BubbleMessageGroup[] = []
let currentRole = null
for (const msg of messages) {
if (msg.role !== currentRole) {
// 角色切换,开始新组
groups.push({ role: msg.role, messages: [msg], ... })
currentRole = msg.role
} else {
// 角色相同,加入当前组
groups[groups.length - 1].messages.push(msg)
}
}
return groups
}
2.3 hidden 消息的特殊处理
分组时,连续的 hidden 消息会归为同一组,不管它们的 role 是否相同。这确保了隐藏消息不会破坏可见消息的分组结构。
三、渲染器匹配引擎
3.1 匹配流程详解
完整的匹配流程如下:
vbnet
Step 1: 收集所有匹配规则
- Provider 级别的 boxRendererMatches / contentRendererMatches
- 内置默认规则
Step 2: 按 priority 排序(值越小越优先)
Step 3: 依次执行 find 函数
- Box: find(messages, content, contentIndex) => boolean
- Content: find(message, content, contentIndex) => boolean
Step 4: 第一个返回 true 的规则 → 使用其 renderer
Step 5: 没有匹配 → 使用 fallback 渲染器
3.2 find 函数的参数设计
Box 渲染器的 find 函数:
typescript
find: (
messages: BubbleMessage[], // 当前组的所有消息
content: ChatMessageContentItem | undefined, // split 模式时的当前内容项
contentIndex: number | undefined, // split 模式时的内容索引
) => boolean
content 和 contentIndex 仅在 split 模式有值------因为 split 模式下每条内容项是独立的 box,需要知道"当前这个 box 是哪条内容"。
Content 渲染器的 find 函数:
typescript
find: (
message: BubbleMessage, // 当前消息
content: ChatMessageContentItem, // 统一化后的内容项
contentIndex: number, // 内容索引
) => boolean
content 是经 contentResolver 解析并统一化后的内容项。如果是 string,会被转为 { type: 'text', text: string }。
3.3 contentIndex 的联动机制
contentIndex 在不同模式下有不同的含义:
- single 模式:contentIndex 始终为 0 或 undefined
- split 模式:contentIndex 对应数组中每一项的索引
在 Content 渲染器中,使用 useMessageContent(props) 工具函数可以正确处理这个联动:
typescript
const { content, contentText } = useMessageContent(props)
// content: 根据 contentIndex 自动选择正确的内容项
// contentText: 内容的文本摘要
四、contentResolver 在渲染链路中的位置
4.1 完整链路图
arduino
原始消息 → contentResolver → ChatMessageContent
→ 统一化 → ChatMessageContentItem[]
→ 按内容项遍历 → find 函数匹配 → 渲染器执行
contentResolver 是第一步------它从消息的原始数据中提取内容。默认提取 message.content,自定义则可以从任意字段取值。
4.2 Resolver 返回值的影响
- 返回
undefined→ 消息被认为没有内容,不渲染 - 返回
string→ 统一化为[{ type: 'text', text: string }] - 返回
ChatMessageContentItem[]→ 直接使用数组
五、状态管理的原理
5.1 state 与 content 的隔离
Bubble 的核心设计原则是:UI 状态和消息内容严格隔离。
less
content: AI 返回的文本内容 → 不可修改(它是 API 数据)
state: UI 交互状态 → 可自由修改(展开/收起、选中/未选中等)
这种隔离确保了:
- 数据一致性:API 返回的数据不被 UI 操作污染
- 请求干净性:发送给 API 的消息只包含必要字段
- 状态可恢复:刷新页面后,content 仍然正确
5.2 requestMessageFieldsExclude
useMessage 在向 API 发送请求时,默认排除 state、metadata、loading 等纯 UI 字段:
typescript
const defaultExcludeFields = ['state', 'metadata', 'loading']
你也可以自定义:
typescript
useMessage({
responseProvider,
requestMessageFieldsExclude: ['state', 'metadata', 'loading', 'customUIField'],
})
5.3 state-change 事件机制
当渲染器或用户操作修改了 state,Bubble 触发 state-change 事件:
typescript
emit('state-change', {
key: 'toolCall',
value: newToolCallState,
messageIndex: 0,
contentIndex: 0,
})
父组件监听这个事件,更新响应式数据,再通过 props 传回------形成单向数据流:
perl
state-change → 父组件更新 ref → 传入新 state → Bubble 重新渲染
六、autoScroll 的智能判断机制
6.1 判断是否接近底部
autoScroll 不是"有新消息就滚",而是判断用户是否正在"看底部":
typescript
function isNearBottom(container: HTMLElement): boolean {
const threshold = 50 // px
return container.scrollHeight - container.scrollTop - container.clientHeight < threshold
}
只有当用户接近底部时,新消息才触发自动滚动------这避免了用户正在阅读历史消息时被强制跳转到底部的糟糕体验。
6.2 用户消息的优先滚动
关键设计:当最后一条消息是 role: 'user' 时,使用 smooth 滚动到底部,不检查是否接近底部。
原因:用户发送消息后,理应立即看到自己的消息和 AI 的回复。这是一种"用户意图优先"的设计。
6.3 scrollToBottom 方法
BubbleList 暴露的 scrollToBottom 方法接受 ScrollBehavior 参数:
typescript
scrollToBottom(behavior?: ScrollBehavior): Promise<void>
// behavior: 'smooth' | 'auto' | 'instant'
如果未启用 autoScroll,调用此方法不会有实际滚动效果(因为容器可能没有设置滚动行为)。
七、渲染性能优化
7.1 markRaw 包装渲染器
自定义渲染器必须用 markRaw 包装:
typescript
const renderer = markRaw(CustomContentRenderer)
原因:渲染器是静态组件定义,不应该被 Vue 的响应式系统追踪。如果被响应式处理,每次 props 变化都会触发不必要的重新代理,导致性能损耗。
7.2 content 的响应式设计
Bubble 的 content 属性是响应式的------修改 content 即可更新渲染。这天然适配流式场景:
typescript
// 流式更新:逐字追加
streamContent.value += newChar
Vue 的响应式系统会精确追踪变化,只更新必要的 DOM,无需手动优化。
7.3 分组算法的缓存
BubbleList 的分组计算只在 messages 数组变化时触发,不会在 content 内部更新时重新分组。这确保了流式输出时不会频繁重算分组结构。
八、从 v0.3 到 v0.4 的架构演进
v0.4 是 Bubble 的一次重大重构:
| 方面 | v0.3 | v0.4 |
|---|---|---|
| 渲染器 | 固定组件 | 可插拔渲染器 + 匹配规则 |
| 消息结构 | 自定义格式 | OpenAI 风格(tool_calls、reasoning_content) |
| 分组 | 简单角色分组 | divider / consecutive / 自定义函数 |
| 状态 | content 内嵌 UI 数据 | state 独立存储 UI 状态 |
| 配置 | 组件级配置 | 三层梯度配置(Prop → Provider → Default) |
这些变化让 Bubble 从一个"UI 组件"进化为一个"渲染引擎",具备了支撑复杂 AI 对话场景的架构能力。
九、总结
深入底层后,Bubble 的核心技术原理可以概括为:
- 泛型数据模型 --- BubbleMessage 的泛型设计为类型安全提供保障
- 统一化处理 --- 所有 content 最终转为数组,渲染器始终处理统一结构
- 优先级匹配引擎 --- 优先级从 -1 到 20,确保关键状态(loading)始终最先被检查
- 状态隔离原则 --- UI 状态在 state,消息内容在 content,互不干扰
- 智能滚动判断 --- 用户意图优先,自动滚动不打断阅读体验
- 三层配置梯度 --- Prop / Provider / Default,灵活性与简洁性兼顾
掌握了这些底层原理,你不仅能用好 Bubble,更能根据业务需求精准扩展它------从自定义渲染器到自定义分组策略,从多模态内容到工具调用场景,Bubble 的架构为你提供了完备的扩展空间。
TinyRobot 官网 :https://opentiny/tiny-robot GitHub 仓库 :github.com/opentiny/ti...