Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions docs/demos/bubble/list-array-content.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<template>
<div style="display: flex; flex-direction: column; gap: 16px">
<p style="font-size: 12px; color: #666; margin: 0">
当 message.role === 'user' 且 content 为数组时,该消息会被单独分组(密封),后续消息不会合并到该组。
满足「contentRenderMode 为 split 且组内只有 1 条消息」时,数组 content 的每一项会单独渲染为一个 box; 否则在同一
box 内渲染。下例中第一个气泡满足该条件(单条消息 + 数组 content + split),故出现多个 box。
</p>
<tr-bubble-list :messages="messages" :role-configs="roles"></tr-bubble-list>
<tr-bubble-list :messages="messages" :role-configs="roles" content-render-mode="split"></tr-bubble-list>
</div>
</template>

Expand All @@ -15,24 +16,24 @@ import { h } from 'vue'
const aiAvatar = h(IconAi, { style: { fontSize: '32px' } })
const userAvatar = h(IconUser, { style: { fontSize: '32px' } })

// 第一个气泡:单条消息 + content 为数组,且 contentRenderMode="split" → 每项单独一个 box
// 第二、三个气泡:单条消息 + content 为字符串 → 各一个 box
const messages: BubbleListProps['messages'] = [
{
role: 'user',
// role 为 user 且 content 为数组时,会被单独分组(密封)
content: [
{ type: 'text', text: '第一部分' },
{ type: 'text', text: '第二部分' },
{ type: 'text', text: '数组第一项' },
{ type: 'text', text: '数组第二项' },
{ type: 'text', text: '数组第三项' },
],
},
{
role: 'ai',
// 上一条为 user+数组(密封),所以这条单独成组
content: '第二条消息(单独成组)',
content: '单条消息,字符串 content,一个 box',
},
{
role: 'ai',
// 与上一条角色相同且上一条非密封,合并到同一组
content: '第三条消息(与第二条合并)',
role: 'user',
content: '单条消息,字符串 content,一个 box',
},
]

Expand Down
9 changes: 5 additions & 4 deletions docs/demos/bubble/list-consecutive.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<div>
<p><strong>divider 分组策略(对比)</strong></p>
<p style="font-size: 12px; color: #666; margin-bottom: 8px">
按分割角色分组,连续的分割角色在一组,其他消息在另一组
按分割角色分组(每条分割角色消息单独成组,其他消息在两个分割角色之间合并为一组)
</p>
<tr-bubble-list :messages="messages" :role-configs="roles" group-strategy="divider"></tr-bubble-list>
</div>
Expand Down Expand Up @@ -42,30 +42,31 @@ const systemAvatar = h(
'S',
)

// consecutive:连续相同角色合并为一组;divider:每条分割角色单独成组,其他消息在两分割角色之间合并为一组
const messages: BubbleListProps['messages'] = [
{
role: 'user',
content: '第一条用户消息',
},
{
role: 'user',
content: '第二条用户消息(连续,会被合并)',
content: '第二条用户消息',
},
{
role: 'ai',
content: 'AI 回复第一条',
},
{
role: 'ai',
content: 'AI 回复第二条(连续,会被合并)',
content: 'AI 回复第二条',
},
{
role: 'system',
content: '系统通知:这是一条系统消息',
},
{
role: 'system',
content: '系统通知:这是另一条系统消息(连续,会被合并)',
content: '系统通知:另一条系统消息',
},
{
role: 'user',
Expand Down
130 changes: 112 additions & 18 deletions docs/demos/bubble/list-custom-group.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
<template>
<div style="display: flex; flex-direction: column; gap: 16px">
<p style="font-size: 12px; color: #666; margin: 0">
自定义分组函数:按时间间隔分组,时间间隔超过 5 秒的消息分为不同组。
通过自定义分组函数控制 BubbleList 的展示逻辑:
<br />
- 「按时间间隔分组」:时间间隔超过 5 秒则开启新分组
<br />
- 「按对话轮次分组」:每一轮 user 提问及其后续 ai/system 回复视为一组
</p>

<div style="display: flex; gap: 8px; margin: 8px 0">
<button
type="button"
style="padding: 4px 8px; font-size: 12px"
:style="activeMode === 'time' ? activeButtonStyle : inactiveButtonStyle"
@click="activeMode = 'time'"
>
按时间间隔分组
</button>
<button
type="button"
style="padding: 4px 8px; font-size: 12px"
:style="activeMode === 'turn' ? activeButtonStyle : inactiveButtonStyle"
@click="activeMode = 'turn'"
>
按对话轮次分组
</button>
</div>

<tr-bubble-list :messages="messages" :role-configs="roles" :group-strategy="customGroupStrategy"></tr-bubble-list>
</div>
</template>
Expand All @@ -16,18 +40,22 @@ import {
TrBubbleList,
} from '@opentiny/tiny-robot'
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
import { h } from 'vue'
import { h, ref } from 'vue'

const aiAvatar = h(IconAi, { style: { fontSize: '32px' } })
const userAvatar = h(IconUser, { style: { fontSize: '32px' } })

// 示例消息,包含时间戳
const messages: (BubbleListProps['messages'][0] & { timestamp?: number })[] = [
{ role: 'user', content: '第一条消息', timestamp: 1000 },
{ role: 'user', content: '第二条消息(1秒后,同一组)', timestamp: 2000 },
{ role: 'ai', content: 'AI 回复', timestamp: 3000 },
{ role: 'user', content: '第三条消息(10秒后,新组)', timestamp: 14000 },
{ role: 'user', content: '第四条消息(1秒后,同一组)', timestamp: 15000 },
// 示例消息,包含时间戳,方便进行时间分组演示
type MessageWithTimestamp = BubbleListProps['messages'][0] & { timestamp?: number }

const messages: MessageWithTimestamp[] = [
{ role: 'user', content: '用户:第一次提问(t=0s)', timestamp: 0 },
{ role: 'ai', content: 'AI:第一次回答(t=1s,同一轮对话)', timestamp: 1000 },
{ role: 'system', content: 'System:提示信息(t=2s,同一轮对话)', timestamp: 2000 },
{ role: 'user', content: '用户:第二次提问(t=10s,新一轮对话)', timestamp: 10000 },
{ role: 'ai', content: 'AI:第二次回答(t=11s,同一轮对话)', timestamp: 11000 },
{ role: 'user', content: '用户:第三次提问(t=25s,新一轮对话)', timestamp: 25000 },
{ role: 'ai', content: 'AI:第三次回答(t=35s,时间间隔较大)', timestamp: 35000 },
]

const roles: Record<string, BubbleRoleConfig> = {
Expand All @@ -39,25 +67,30 @@ const roles: Record<string, BubbleRoleConfig> = {
placement: 'end',
avatar: userAvatar,
},
system: {
placement: 'start',
},
}

// 自定义分组函数:按时间间隔分组(超过 5 秒分为不同组)
const customGroupStrategy = (msgs: BubbleMessage[], _dividerRole?: string): BubbleMessageGroup[] => {
// 当前分组模式:'time' | 'turn'
const activeMode = ref<'time' | 'turn'>('time')

// 按时间间隔分组:相邻消息时间差超过 5 秒则开启新分组
const groupByTime = (msgs: BubbleMessage[]): BubbleMessageGroup[] => {
const groups: BubbleMessageGroup[] = []
const TIME_THRESHOLD = 5000

for (const [index, message] of msgs.entries()) {
const msgWithTimestamp = message as (typeof messages)[0]
const msgWithTimestamp = message as MessageWithTimestamp
const lastGroup = groups[groups.length - 1]

if (
!lastGroup ||
(msgWithTimestamp.timestamp &&
lastGroup.messages.length > 0 &&
(lastGroup.messages[lastGroup.messages.length - 1] as (typeof messages)[0]).timestamp &&
msgWithTimestamp.timestamp -
((lastGroup.messages[lastGroup.messages.length - 1] as (typeof messages)[0]).timestamp || 0) >
TIME_THRESHOLD)
!msgWithTimestamp.timestamp ||
!(lastGroup.messages[lastGroup.messages.length - 1] as MessageWithTimestamp).timestamp ||
msgWithTimestamp.timestamp -
((lastGroup.messages[lastGroup.messages.length - 1] as MessageWithTimestamp).timestamp || 0) >
TIME_THRESHOLD
) {
groups.push({
role: message.role || 'assistant',
Expand All @@ -73,6 +106,67 @@ const customGroupStrategy = (msgs: BubbleMessage[], _dividerRole?: string): Bubb

return groups
}

// 按对话轮次分组:
// - 以 user 消息作为一轮对话的开始
// - 将后续的 ai/system 消息归入同一组,直到下一条 user 出现
const groupByTurn = (msgs: BubbleMessage[]): BubbleMessageGroup[] => {
const groups: BubbleMessageGroup[] = []
let currentGroup: BubbleMessageGroup | null = null

msgs.forEach((message, index) => {
const role = message.role || 'assistant'

if (role === 'user') {
// 遇到新的 user,开启新一轮对话
currentGroup = {
role,
messages: [message],
messageIndexes: [index],
startIndex: index,
}
groups.push(currentGroup)
} else if (currentGroup) {
// 将 ai/system 等回复归入当前轮次
currentGroup.messages.push(message)
currentGroup.messageIndexes.push(index)
} else {
// 没有 user 作为起点时,单独成组兜底
const fallbackGroup: BubbleMessageGroup = {
role,
messages: [message],
messageIndexes: [index],
startIndex: index,
}
groups.push(fallbackGroup)
currentGroup = fallbackGroup
}
})

return groups
}

// 统一对外暴露的分组函数,根据 activeMode 切换具体实现
const customGroupStrategy = (msgs: BubbleMessage[]): BubbleMessageGroup[] => {
if (activeMode.value === 'turn') {
return groupByTurn(msgs)
}
return groupByTime(msgs)
}

const activeButtonStyle: Record<string, string> = {
backgroundColor: '#409eff',
color: '#fff',
border: '1px solid #409eff',
borderRadius: '4px',
}

const inactiveButtonStyle: Record<string, string> = {
backgroundColor: '#fff',
color: '#666',
border: '1px solid #ddd',
borderRadius: '4px',
}
</script>

<style scoped>
Expand Down
18 changes: 9 additions & 9 deletions docs/src/components/bubble.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Bubble 组件支持渲染图片内容。当 `content` 为数组且包含 `type:

### 分组策略

BubbleList 支持多种分组策略
BubbleList 支持多种分组策略。分组时,连续的 `hidden` 消息会归为同一组。

**连续分组(consecutive)**

Expand All @@ -138,16 +138,16 @@ BubbleList 支持多种分组策略:

<demo vue="../../demos/bubble/list-custom-group.vue" />

**数组内容的分组**
**数组内容的展示**

当 `message.role === 'user'` 且 `content` 为数组时,该消息会被单独作为一个独立分组(密封),后续的消息(即使角色相同)也不会被添加到这个分组中。
当消息的 `content` 为数组时,每一项的渲染方式由 `contentRenderMode` 与**当前组的消息条数**共同决定:

<demo vue="../../demos/bubble/list-array-content.vue" />
- 若 `contentRenderMode` 为 `'split'` **且** 当前组仅包含 1 条消息,则数组的每一项会单独渲染为一个 box。
- 若不满足上述条件(例如为 `'single'` 模式,或组内有多条消息),则不会按数组项拆成多个 box,所有内容在同一 box 内渲染。

> **注意**:分组策略的特殊处理规则:
>
> - 当 `message.role === 'user'` 且 `content` 为数组时,该消息会被单独作为一个独立分组(密封),后续的消息(即使角色相同)也不会被添加到这个分组中
> - `hidden` 消息的分组规则:连续的 `hidden` 消息可以同一组
下方示例中,第一个气泡为单条消息且 `content` 为数组、`contentRenderMode="split"`,因此出现多个 box;其余气泡为单条消息且 `content` 为字符串,或组内有多条消息,因此每个气泡一个 box。

<demo vue="../../demos/bubble/list-array-content.vue" />

### 隐藏角色

Expand Down Expand Up @@ -379,7 +379,7 @@ Bubble 组件支持通过 `state` 属性存储 UI 相关的数据,并通过 `s
| 属性 | 类型 | 默认值 | 说明 |
| ------------------- | ------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `messages` | `BubbleMessage[]` | - | **必填**,消息数组 |
| `groupStrategy` | `'consecutive' \| 'divider' \| BubbleGroupFunction` | `'divider'` | 分组策略:<br/>- `'consecutive'`: 连续相同角色的消息合并为一组<br/>- `'divider'`: 按分割角色分组(连续的分割角色在一组,其他消息在另一组)<br/>- 自定义函数: `(messages, dividerRole?) => BubbleMessageGroup[]` |
| `groupStrategy` | `'consecutive' \| 'divider' \| BubbleGroupFunction` | `'divider'` | 分组策略:<br/>- `'consecutive'`: 连续相同角色的消息合并为一组<br/>- `'divider'`: 按分割角色分组(每条分割角色消息单独成组,其他消息在两个分割角色之间合并为一组)<br/>- 自定义函数: `(messages, dividerRole?) => BubbleMessageGroup[]` |
| `dividerRole` | `string` | `'user'` | `'divider'` 策略的分割角色,具有此角色的消息将作为分割线 |
| `fallbackRole` | `string` | `'assistant'` | 当消息没有角色或角色为空时,使用此角色 |
| `roleConfigs` | `Record<string, BubbleRoleConfig>` | - | 每个角色的默认配置项(头像、位置、形状等) |
Expand Down
Loading
Loading