From ef8c0295a57437d648817f968fd1a15e66c0b3a8 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Wed, 11 Feb 2026 16:30:00 +0800 Subject: [PATCH 1/2] refactor(bubble-list): simplify grouping logic - Remove special-case grouping for array content and rely on role/divider/hidden rules only - Make divider strategy always create single-message groups for divider role and merge messages between dividers - Clarify docs and demos to reflect the simplified grouping behavior and array content display --- docs/demos/bubble/list-array-content.vue | 21 +++---- docs/demos/bubble/list-consecutive.vue | 9 +-- docs/src/components/bubble.md | 18 +++--- packages/components/src/bubble/BubbleList.vue | 56 +++++-------------- packages/components/src/bubble/index.type.ts | 5 +- 5 files changed, 41 insertions(+), 68 deletions(-) diff --git a/docs/demos/bubble/list-array-content.vue b/docs/demos/bubble/list-array-content.vue index 429905201..50e295d86 100644 --- a/docs/demos/bubble/list-array-content.vue +++ b/docs/demos/bubble/list-array-content.vue @@ -1,9 +1,10 @@ @@ -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', }, ] diff --git a/docs/demos/bubble/list-consecutive.vue b/docs/demos/bubble/list-consecutive.vue index efab1c65d..3d001de19 100644 --- a/docs/demos/bubble/list-consecutive.vue +++ b/docs/demos/bubble/list-consecutive.vue @@ -9,7 +9,7 @@

divider 分组策略(对比)

- 按分割角色分组,连续的分割角色在一组,其他消息在另一组 + 按分割角色分组(每条分割角色消息单独成组,其他消息在两个分割角色之间合并为一组)

@@ -42,6 +42,7 @@ const systemAvatar = h( 'S', ) +// consecutive:连续相同角色合并为一组;divider:每条分割角色单独成组,其他消息在两分割角色之间合并为一组 const messages: BubbleListProps['messages'] = [ { role: 'user', @@ -49,7 +50,7 @@ const messages: BubbleListProps['messages'] = [ }, { role: 'user', - content: '第二条用户消息(连续,会被合并)', + content: '第二条用户消息', }, { role: 'ai', @@ -57,7 +58,7 @@ const messages: BubbleListProps['messages'] = [ }, { role: 'ai', - content: 'AI 回复第二条(连续,会被合并)', + content: 'AI 回复第二条', }, { role: 'system', @@ -65,7 +66,7 @@ const messages: BubbleListProps['messages'] = [ }, { role: 'system', - content: '系统通知:这是另一条系统消息(连续,会被合并)', + content: '系统通知:另一条系统消息', }, { role: 'user', diff --git a/docs/src/components/bubble.md b/docs/src/components/bubble.md index decaf087c..5515967f8 100644 --- a/docs/src/components/bubble.md +++ b/docs/src/components/bubble.md @@ -124,7 +124,7 @@ Bubble 组件支持渲染图片内容。当 `content` 为数组且包含 `type: ### 分组策略 -BubbleList 支持多种分组策略: +BubbleList 支持多种分组策略。分组时,连续的 `hidden` 消息会归为同一组。 **连续分组(consecutive)** @@ -138,16 +138,16 @@ BubbleList 支持多种分组策略: -**数组内容的分组** +**数组内容的展示** -当 `message.role === 'user'` 且 `content` 为数组时,该消息会被单独作为一个独立分组(密封),后续的消息(即使角色相同)也不会被添加到这个分组中。 +当消息的 `content` 为数组时,每一项的渲染方式由 `contentRenderMode` 与**当前组的消息条数**共同决定: - +- 若 `contentRenderMode` 为 `'split'` **且** 当前组仅包含 1 条消息,则数组的每一项会单独渲染为一个 box。 +- 若不满足上述条件(例如为 `'single'` 模式,或组内有多条消息),则不会按数组项拆成多个 box,所有内容在同一 box 内渲染。 -> **注意**:分组策略的特殊处理规则: -> -> - 当 `message.role === 'user'` 且 `content` 为数组时,该消息会被单独作为一个独立分组(密封),后续的消息(即使角色相同)也不会被添加到这个分组中 -> - `hidden` 消息的分组规则:连续的 `hidden` 消息可以同一组 +下方示例中,第一个气泡为单条消息且 `content` 为数组、`contentRenderMode="split"`,因此出现多个 box;其余气泡为单条消息且 `content` 为字符串,或组内有多条消息,因此每个气泡一个 box。 + + ### 隐藏角色 @@ -379,7 +379,7 @@ Bubble 组件支持通过 `state` 属性存储 UI 相关的数据,并通过 `s | 属性 | 类型 | 默认值 | 说明 | | ------------------- | ------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `messages` | `BubbleMessage[]` | - | **必填**,消息数组 | -| `groupStrategy` | `'consecutive' \| 'divider' \| BubbleGroupFunction` | `'divider'` | 分组策略:
- `'consecutive'`: 连续相同角色的消息合并为一组
- `'divider'`: 按分割角色分组(连续的分割角色在一组,其他消息在另一组)
- 自定义函数: `(messages, dividerRole?) => BubbleMessageGroup[]` | +| `groupStrategy` | `'consecutive' \| 'divider' \| BubbleGroupFunction` | `'divider'` | 分组策略:
- `'consecutive'`: 连续相同角色的消息合并为一组
- `'divider'`: 按分割角色分组(每条分割角色消息单独成组,其他消息在两个分割角色之间合并为一组)
- 自定义函数: `(messages, dividerRole?) => BubbleMessageGroup[]` | | `dividerRole` | `string` | `'user'` | `'divider'` 策略的分割角色,具有此角色的消息将作为分割线 | | `fallbackRole` | `string` | `'assistant'` | 当消息没有角色或角色为空时,使用此角色 | | `roleConfigs` | `Record` | - | 每个角色的默认配置项(头像、位置、形状等) | diff --git a/packages/components/src/bubble/BubbleList.vue b/packages/components/src/bubble/BubbleList.vue index 63302fa47..f921b78d0 100644 --- a/packages/components/src/bubble/BubbleList.vue +++ b/packages/components/src/bubble/BubbleList.vue @@ -2,7 +2,7 @@ import { computed, nextTick, provide, ref, watch } from 'vue' import { useAutoScroll } from '../shared/composables' import BubbleItem from './BubbleItem.vue' -import { setupBubbleStore, useContentResolver, useCopyCleanup } from './composables' +import { setupBubbleStore, useCopyCleanup } from './composables' import { BUBBLE_LIST_CONTEXT_KEY } from './constants' import type { BubbleListProps, BubbleListSlots, BubbleMessage, BubbleMessageGroup } from './index.type' @@ -25,8 +25,6 @@ setupBubbleStore() // 提供 bubble list 上下文,标识 Bubble 组件在 BubbleList 下 provide(BUBBLE_LIST_CONTEXT_KEY, true) -const contentResolver = useContentResolver(() => props.contentResolver) - /** * 判断一个 role 是否是 hidden */ @@ -66,37 +64,23 @@ useCopyCleanup(listRef) /** * 按角色分组 - * 连续相同角色的消息会被合并到一组 - * 如果消息的 content 是数组,则该消息单独作为一组,且后续消息不能添加到这个组 - * hidden 的消息需要单独分组,连续的 hidden 可以同一组 + * - 连续相同角色的消息会被合并到一组 + * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 */ const groupByRole = (messages: BubbleMessage[]): BubbleMessageGroup[] => { const groups: BubbleMessageGroup[] = [] - let isLastGroupSealed = false let isLastGroupHidden = false for (const [index, message] of messages.entries()) { const lastGroup = groups[groups.length - 1] - const isArrayContent = message.role === 'user' && Array.isArray(contentResolver(message)) const messageRole = message.role || '' const isMessageHidden = isRoleHidden(message.role) - // 如果 content 是数组,则单独作为一组 - if (isArrayContent) { - groups.push({ - role: messageRole, - messages: [message], - messageIndexes: [index], - startIndex: index, - }) - isLastGroupSealed = true - } - // 如果上一组未被密封,且满足以下条件之一则添加到该组: + // 满足以下条件之一则添加到上一组: // 1. 连续的 hidden 消息(不管角色是否相同) // 2. 角色相同且 hidden 状态相同 - else if ( + if ( lastGroup && - !isLastGroupSealed && ((isLastGroupHidden && isMessageHidden) || (lastGroup.role === messageRole && isLastGroupHidden === isMessageHidden)) ) { @@ -110,7 +94,6 @@ const groupByRole = (messages: BubbleMessage[]): BubbleMessageGroup[] => { messageIndexes: [index], startIndex: index, }) - isLastGroupSealed = false } // 创建新组后统一更新 hidden 状态 isLastGroupHidden = isMessageHidden @@ -121,41 +104,31 @@ const groupByRole = (messages: BubbleMessage[]): BubbleMessageGroup[] => { /** * 按分割角色分组 - * - 连续的分割角色消息会被分到一组 + * - 分割角色消息每条单独分组 * - 非分割角色消息会被分到一组,直到遇到下一个分割角色消息 - * - 如果消息的 content 是数组,则该消息单独作为一组,且后续消息不能添加到这个组 * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 */ const groupByDivider = (messages: BubbleMessage[], dividerRole: string): BubbleMessageGroup[] => { const groups: BubbleMessageGroup[] = [] - let isLastGroupSealed = false let isLastGroupHidden = false for (const [index, message] of messages.entries()) { const lastGroup = groups[groups.length - 1] const isDivider = message.role === dividerRole - const isArrayContent = message.role === 'user' && Array.isArray(contentResolver(message)) const messageRole = message.role || '' const isMessageHidden = isRoleHidden(message.role) - // 如果 content 是数组,则单独作为一组 - if (isArrayContent) { - groups.push({ - role: messageRole, - messages: [message], - messageIndexes: [index], - startIndex: index, - }) - isLastGroupSealed = true - } - // 如果上一组未被密封,且满足以下条件之一则添加到该组: + // 满足以下条件之一则添加到上一组: // 1. 连续的 hidden 消息(不管分割/非分割类型是否相同) // 2. 分割/非分割类型相同且 hidden 状态相同 - else if ( + if ( lastGroup && - !isLastGroupSealed && - ((isLastGroupHidden && isMessageHidden) || - ((lastGroup.role === dividerRole) === isDivider && isLastGroupHidden === isMessageHidden)) + // divider 消息(分割角色)永远不与任何组进行合并 + !isDivider && + // divider 组(分割角色)不允许被追加消息,确保 divider 组永远只有 1 条 message + lastGroup.role !== dividerRole && + // hidden / 非 hidden 分组隔离 + isLastGroupHidden === isMessageHidden ) { lastGroup.messages.push(message) lastGroup.messageIndexes.push(index) @@ -167,7 +140,6 @@ const groupByDivider = (messages: BubbleMessage[], dividerRole: string): BubbleM messageIndexes: [index], startIndex: index, }) - isLastGroupSealed = false } // 创建新组后统一更新 hidden 状态 isLastGroupHidden = isMessageHidden diff --git a/packages/components/src/bubble/index.type.ts b/packages/components/src/bubble/index.type.ts index 79461866d..f0edc8276 100644 --- a/packages/components/src/bubble/index.type.ts +++ b/packages/components/src/bubble/index.type.ts @@ -132,12 +132,11 @@ export interface BubbleListProps { /** * 分组策略: * - 'consecutive': 连续相同角色的消息合并为一组 - * - 'divider': 按分割角色分组(连续的分割角色在一组,其他消息在另一组) + * - 'divider': 按分割角色分组(每条分割角色消息单独成组,其他消息在两个分割角色之间合并为一组) * - 自定义函数: (messages, dividerRole) => BubbleMessageGroup[] * * 特殊情况: - * - 当 message 的 content 为数组时,且 message.role === 'user',该 message 会被单独作为一个独立分组 - * - 该独立分组会被"密封",后续的消息(即使角色相同)也不会被添加到这个分组中 + * - hidden 的消息需要单独分组,连续的 hidden 可以同一组 */ groupStrategy?: 'consecutive' | 'divider' | BubbleGroupFunction /** From 4a76135027a0687a5f0222f80b69b75844d17860 Mon Sep 17 00:00:00 2001 From: gene9831 Date: Wed, 11 Feb 2026 16:52:23 +0800 Subject: [PATCH 2/2] feat(bubble-list): enhance grouping options with time and turn modes - Introduced a custom grouping strategy allowing users to switch between grouping messages by time intervals or conversation turns. - Updated demo to reflect new grouping options and improved message examples for clarity. - Added buttons for users to select their preferred grouping mode. --- docs/demos/bubble/list-custom-group.vue | 130 ++++++++++++++++++++---- 1 file changed, 112 insertions(+), 18 deletions(-) diff --git a/docs/demos/bubble/list-custom-group.vue b/docs/demos/bubble/list-custom-group.vue index 830aadb3d..af8c21f9b 100644 --- a/docs/demos/bubble/list-custom-group.vue +++ b/docs/demos/bubble/list-custom-group.vue @@ -1,8 +1,32 @@ @@ -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 = { @@ -39,25 +67,30 @@ const roles: Record = { 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', @@ -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 = { + backgroundColor: '#409eff', + color: '#fff', + border: '1px solid #409eff', + borderRadius: '4px', +} + +const inactiveButtonStyle: Record = { + backgroundColor: '#fff', + color: '#666', + border: '1px solid #ddd', + borderRadius: '4px', +}