深挖底层:TinyRobot Bubble消息气泡组件核心技术原理

15 阅读8分钟

深挖底层:TinyRobot Bubble 消息气泡组件核心技术原理

前面几篇文章,我们从 Kit 工具包入门、基础组件搭建、多轮对话分组、内容解析器到渲染器架构,逐步掌握了 TinyRobot Bubble 的完整使用体系。现在,让我们深入底层,剖析 Bubble 的核心技术原理——它是如何从数据模型到 DOM 渲染完成整个链路的?


一、消息数据模型设计

1.1 BubbleMessage 泛型设计

BubbleMessage 采用泛型设计,允许自定义 content 和 state 的类型:

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 会被"统一化":

string  → [{ type: 'text', text: 原字符串 }]
Array   → 保持原样,每项已是 ChatMessageContentItem

统一化的好处是:渲染器始终处理的是数组结构,无需额外判断 content 是 string 还是 array。

1.3 消息字段与渲染器的映射

字段对应渲染器优先级
loading: trueLoading 渲染器P_LOADING (-1)
content[].type === 'image_url'Image 渲染器P_CONTENT (10)
role === 'tool'ToolRole 渲染器P_ROLE (20)
tool_callsTool/Tools 渲染器P_NORMAL (0)
reasoning_contentReasoning 渲染器P_NORMAL (0)
其他Text 渲染器(fallback)-

优先级设计确保:加载状态永远最先被检查(-1),因为一条消息不可能同时是"加载中"和"有内容"。然后是内容类型判断(10),最后是角色判断(20)——因为角色是更宏观的分类。


二、分组算法原理

2.1 divider 策略算法

divider 策略的核心逻辑是"按分割角色划界":

输入:[user1, ai1, ai2, user2, ai3, tool1]

dividerRole = 'user' 时:
  组1: [user1, ai1, ai2]  — user1 是分割线,后续消息直到下一个 user
  组2: [user2, ai3, tool1] — user2 是分割线,后续消息直到结束

算法伪代码:

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 策略更简单——连续相同角色合并:

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 匹配流程详解

完整的匹配流程如下:

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 函数

find: (
  messages: BubbleMessage[],          // 当前组的所有消息
  content: ChatMessageContentItem | undefined,  // split 模式时的当前内容项
  contentIndex: number | undefined,   // split 模式时的内容索引
) => boolean

contentcontentIndex 仅在 split 模式有值——因为 split 模式下每条内容项是独立的 box,需要知道"当前这个 box 是哪条内容"。

Content 渲染器的 find 函数

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) 工具函数可以正确处理这个联动:

const { content, contentText } = useMessageContent(props)
// content: 根据 contentIndex 自动选择正确的内容项
// contentText: 内容的文本摘要

四、contentResolver 在渲染链路中的位置

4.1 完整链路图

原始消息 → contentResolver → ChatMessageContent
  → 统一化 → ChatMessageContentItem[]
  → 按内容项遍历 → find 函数匹配 → 渲染器执行

contentResolver 是第一步——它从消息的原始数据中提取内容。默认提取 message.content,自定义则可以从任意字段取值。

4.2 Resolver 返回值的影响

  • 返回 undefined → 消息被认为没有内容,不渲染
  • 返回 string → 统一化为 [{ type: 'text', text: string }]
  • 返回 ChatMessageContentItem[] → 直接使用数组

五、状态管理的原理

5.1 state 与 content 的隔离

Bubble 的核心设计原则是:UI 状态和消息内容严格隔离

content: AI 返回的文本内容 → 不可修改(它是 API 数据)
state: UI 交互状态 → 可自由修改(展开/收起、选中/未选中等)

这种隔离确保了:

  1. 数据一致性:API 返回的数据不被 UI 操作污染
  2. 请求干净性:发送给 API 的消息只包含必要字段
  3. 状态可恢复:刷新页面后,content 仍然正确

5.2 requestMessageFieldsExclude

useMessage 在向 API 发送请求时,默认排除 statemetadataloading 等纯 UI 字段:

const defaultExcludeFields = ['state', 'metadata', 'loading']

你也可以自定义:

useMessage({
  responseProvider,
  requestMessageFieldsExclude: ['state', 'metadata', 'loading', 'customUIField'],
})

5.3 state-change 事件机制

当渲染器或用户操作修改了 state,Bubble 触发 state-change 事件:

emit('state-change', {
  key: 'toolCall',
  value: newToolCallState,
  messageIndex: 0,
  contentIndex: 0,
})

父组件监听这个事件,更新响应式数据,再通过 props 传回——形成单向数据流:

state-change → 父组件更新 ref → 传入新 state → Bubble 重新渲染

六、autoScroll 的智能判断机制

6.1 判断是否接近底部

autoScroll 不是"有新消息就滚",而是判断用户是否正在"看底部":

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 参数:

scrollToBottom(behavior?: ScrollBehavior): Promise<void>
// behavior: 'smooth' | 'auto' | 'instant'

如果未启用 autoScroll,调用此方法不会有实际滚动效果(因为容器可能没有设置滚动行为)。


七、渲染性能优化

7.1 markRaw 包装渲染器

自定义渲染器必须用 markRaw 包装:

const renderer = markRaw(CustomContentRenderer)

原因:渲染器是静态组件定义,不应该被 Vue 的响应式系统追踪。如果被响应式处理,每次 props 变化都会触发不必要的重新代理,导致性能损耗。

7.2 content 的响应式设计

Bubble 的 content 属性是响应式的——修改 content 即可更新渲染。这天然适配流式场景:

// 流式更新:逐字追加
streamContent.value += newChar

Vue 的响应式系统会精确追踪变化,只更新必要的 DOM,无需手动优化。

7.3 分组算法的缓存

BubbleList 的分组计算只在 messages 数组变化时触发,不会在 content 内部更新时重新分组。这确保了流式输出时不会频繁重算分组结构。


八、从 v0.3 到 v0.4 的架构演进

v0.4 是 Bubble 的一次重大重构:

方面v0.3v0.4
渲染器固定组件可插拔渲染器 + 匹配规则
消息结构自定义格式OpenAI 风格(tool_calls、reasoning_content)
分组简单角色分组divider / consecutive / 自定义函数
状态content 内嵌 UI 数据state 独立存储 UI 状态
配置组件级配置三层梯度配置(Prop → Provider → Default)

这些变化让 Bubble 从一个"UI 组件"进化为一个"渲染引擎",具备了支撑复杂 AI 对话场景的架构能力。


九、总结

深入底层后,Bubble 的核心技术原理可以概括为:

  1. 泛型数据模型 — BubbleMessage 的泛型设计为类型安全提供保障
  2. 统一化处理 — 所有 content 最终转为数组,渲染器始终处理统一结构
  3. 优先级匹配引擎 — 优先级从 -1 到 20,确保关键状态(loading)始终最先被检查
  4. 状态隔离原则 — UI 状态在 state,消息内容在 content,互不干扰
  5. 智能滚动判断 — 用户意图优先,自动滚动不打断阅读体验
  6. 三层配置梯度 — Prop / Provider / Default,灵活性与简洁性兼顾

掌握了这些底层原理,你不仅能用好 Bubble,更能根据业务需求精准扩展它——从自定义渲染器到自定义分组策略,从多模态内容到工具调用场景,Bubble 的架构为你提供了完备的扩展空间。


TinyRobot 官网https://opentiny/tiny-robot GitHub 仓库github.com/opentiny/ti…