Skip to content

Commit 4881329

Browse files
authored
feat(bubble): add unified bubble event channel (#362)
1 parent 045d26b commit 4881329

17 files changed

Lines changed: 306 additions & 69 deletions

File tree

docs/demos/bubble/list-custom-group.vue

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ const groupByTime = (msgs: BubbleMessage[]): BubbleMessageGroup[] => {
9696
role: message.role || 'assistant',
9797
messages: [message],
9898
messageIndexes: [index],
99-
startIndex: index,
10099
})
101100
} else {
102101
lastGroup.messages.push(message)
@@ -123,7 +122,6 @@ const groupByTurn = (msgs: BubbleMessage[]): BubbleMessageGroup[] => {
123122
role,
124123
messages: [message],
125124
messageIndexes: [index],
126-
startIndex: index,
127125
}
128126
groups.push(currentGroup)
129127
} else if (currentGroup) {
@@ -136,7 +134,6 @@ const groupByTurn = (msgs: BubbleMessage[]): BubbleMessageGroup[] => {
136134
role,
137135
messages: [message],
138136
messageIndexes: [index],
139-
startIndex: index,
140137
}
141138
groups.push(fallbackGroup)
142139
currentGroup = fallbackGroup

docs/demos/bubble/state-change.vue

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,127 @@
11
<template>
2-
<div style="display: flex; flex-direction: column; gap: 16px">
3-
<div>
4-
<label>
5-
<input type="checkbox" v-model="messageState.expanded" />
6-
展开消息
7-
</label>
8-
</div>
2+
<tr-bubble-provider :content-renderer-matches="contentRendererMatches">
3+
<div style="display: flex; flex-direction: column; gap: 16px">
4+
<tr-bubble
5+
:content="messageContent"
6+
:avatar="aiAvatar"
7+
:state="messageState"
8+
@bubble-event="handleBubbleEvent"
9+
@state-change="handleStateChange"
10+
></tr-bubble>
911

10-
<tr-bubble
11-
content="这是一条可以交互的消息"
12-
:avatar="aiAvatar"
13-
:state="messageState"
14-
@state-change="handleStateChange"
15-
>
16-
<template #content-footer>
17-
<div v-if="messageState.expanded" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #eee">
18-
<button @click="toggleLike" style="padding: 4px 8px; font-size: 12px">
19-
{{ messageState.liked ? '取消点赞' : '点赞' }}
20-
</button>
12+
<div style="font-size: 12px; color: #666">
13+
<div style="display: flex; align-items: center; gap: 8px">
14+
<span>外部收到的事件:</span>
15+
<button type="button" style="padding: 2px 8px; font-size: 12px" @click="resetEventLogs">重置日志</button>
2116
</div>
22-
</template>
23-
</tr-bubble>
24-
</div>
17+
<pre style="margin: 8px 0 0; padding: 8px; background: #f5f5f5; border-radius: 6px">{{ bubbleEventLog }}</pre>
18+
<pre style="margin: 8px 0 0; padding: 8px; background: #f5f5f5; border-radius: 6px">{{ stateChangeLog }}</pre>
19+
</div>
20+
</div>
21+
</tr-bubble-provider>
2522
</template>
2623

2724
<script setup lang="ts">
28-
import { TrBubble } from '@opentiny/tiny-robot'
25+
import {
26+
BubbleRendererMatchPriority,
27+
type BubbleEvent,
28+
type BubbleContentRendererMatch,
29+
type BubbleContentRendererProps,
30+
TrBubble,
31+
TrBubbleProvider,
32+
useBubbleEventFn,
33+
useBubbleStateChangeFn,
34+
useMessageContent,
35+
} from '@opentiny/tiny-robot'
2936
import { IconAi } from '@opentiny/tiny-robot-svgs'
30-
import { h, ref } from 'vue'
37+
import { computed, defineComponent, h, markRaw, ref } from 'vue'
3138
3239
const aiAvatar = h(IconAi, { style: { fontSize: '32px' } })
3340
41+
const messageContent = [{ type: 'state-demo', text: '这条消息的状态由自定义 renderer 修改。' }]
3442
const messageState = ref<Record<string, unknown>>({
3543
expanded: false,
3644
liked: false,
3745
})
46+
const bubbleEventLog = ref('bubble-event 尚未触发')
47+
const stateChangeLog = ref('state-change 尚未触发')
48+
49+
const StateDemoRenderer = defineComponent({
50+
props: {
51+
message: {
52+
type: Object,
53+
required: true,
54+
},
55+
contentIndex: Number,
56+
},
57+
setup(props: BubbleContentRendererProps) {
58+
const { content } = useMessageContent(props)
59+
const emitBubbleEvent = useBubbleEventFn()
60+
const handleStateChange = useBubbleStateChangeFn()
61+
62+
const expanded = computed(() => Boolean(props.message.state?.expanded))
63+
const liked = computed(() => Boolean(props.message.state?.liked))
64+
65+
const toggleExpanded = () => {
66+
handleStateChange('expanded', !expanded.value)
67+
}
68+
69+
const toggleLiked = () => {
70+
handleStateChange('liked', !liked.value)
71+
}
72+
73+
const sendCustomEvent = () => {
74+
emitBubbleEvent({
75+
name: 'demo:apply-to-input',
76+
payload: {
77+
text: content.value?.text || '',
78+
},
79+
})
80+
}
81+
82+
const button = (text: string, onClick: () => void) => h('button', { type: 'button', onClick }, text)
83+
84+
return () => {
85+
const detailText = liked.value ? '详情已展开,当前已点赞。' : '详情已展开,当前未点赞。'
86+
87+
return h('div', { style: 'display: flex; flex-direction: column; gap: 8px' }, [
88+
h('div', content.value?.text || ''),
89+
h('div', { style: 'display: flex; gap: 8px' }, [
90+
button(expanded.value ? '收起详情' : '展开详情', toggleExpanded),
91+
button(liked.value ? '取消点赞' : '点赞', toggleLiked),
92+
button('发送普通事件', sendCustomEvent),
93+
]),
94+
expanded.value
95+
? h(
96+
'div',
97+
{ style: 'padding: 8px; background: #f5f5f5; border-radius: 6px; color: #666; font-size: 12px' },
98+
detailText,
99+
)
100+
: null,
101+
])
102+
}
103+
},
104+
})
38105
39-
const handleStateChange = (payload: { key: string; value: unknown }) => {
106+
const contentRendererMatches: BubbleContentRendererMatch[] = [
107+
{
108+
find: (_message, content) => content.type === 'state-demo',
109+
renderer: markRaw(StateDemoRenderer),
110+
priority: BubbleRendererMatchPriority.CONTENT,
111+
},
112+
]
113+
114+
const handleStateChange = (payload: { key: string; value: unknown; contentIndex: number }) => {
40115
messageState.value[payload.key] = payload.value
116+
stateChangeLog.value = `state-change\n${JSON.stringify(payload, null, 2)}`
117+
}
118+
119+
const handleBubbleEvent = (payload: BubbleEvent & { messageIndex: number; contentIndex: number }) => {
120+
bubbleEventLog.value = `bubble-event\n${JSON.stringify(payload, null, 2)}`
41121
}
42122
43-
const toggleLike = () => {
44-
messageState.value.liked = !messageState.value.liked
45-
handleStateChange({ key: 'liked', value: messageState.value.liked })
123+
const resetEventLogs = () => {
124+
bubbleEventLog.value = 'bubble-event 尚未触发'
125+
stateChangeLog.value = 'state-change 尚未触发'
46126
}
47127
</script>

docs/src/components/bubble.md

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,49 @@ defineProps<BubbleBoxRendererProps>()
335335
- 在 Content 渲染器中可使用 `useMessageContent(props)` 获取当前 `content``contentText`,以正确处理 `contentIndex` 与数组内容
336336
- 多根节点或复合渲染器应使用 `inheritAttrs: false`,并显式决定 `$attrs` 绑定到哪个节点;不要把同一份 attributes 复制到多个兄弟节点上,避免重复 `id`、ARIA 或测试选择器
337337

338-
### 状态管理
338+
### 状态和事件管理
339339

340-
Bubble 组件支持通过 `state` 属性存储 UI 相关的数据,并通过 `state-change` 事件来更新状态。这对于实现交互功能(如展开/收起、点赞等)非常有用。
340+
如果你希望在 Bubble 内部(例如自定义 Content 渲染器中)向外通知交互行为,可以使用 `useBubbleEventFn()` 触发 `bubble-event`。事件会从当前渲染器逐层透传到外层的 `Bubble` / `BubbleList`,业务侧可以统一监听并处理。
341+
342+
Bubble 也支持通过 `state` 属性存储 UI 相关的数据,例如展开状态、点赞状态等。这些状态不会写入消息内容本身,适合放置只影响渲染表现的交互数据。
343+
344+
Bubble 内部统一通过 `bubble-event` 抛出渲染器交互事件。状态变化本身也是一种特定事件,事件名为 `state:update`;对于常见的 UI 状态更新场景,可以使用 `useBubbleStateChangeFn()` 这个便捷 API,它会自动触发 `name``state:update``bubble-event`
345+
346+
```ts
347+
const handleStateChange = useBubbleStateChangeFn()
348+
349+
handleStateChange('expanded', true)
350+
```
351+
352+
这等价于发出:
353+
354+
```ts
355+
const emitBubbleEvent = useBubbleEventFn()
356+
357+
emitBubbleEvent({
358+
name: 'state:update',
359+
payload: { key: 'expanded', value: true },
360+
})
361+
```
362+
363+
外层 `Bubble` / `BubbleList` 会收到 `bubble-event`;当事件名为 `state:update` 时,还会额外触发 `state-change` 这个便捷事件,业务侧可以直接在事件回调中把新的 `key` / `value` 同步回消息的 `state`
364+
365+
如果渲染器需要抛出不直接修改 UI 状态的普通交互事件,可以使用 `useBubbleEventFn()`
366+
367+
```ts
368+
const emitBubbleEvent = useBubbleEventFn()
369+
370+
emitBubbleEvent({
371+
name: 'demo:apply-to-input',
372+
payload: { text: '...' },
373+
})
374+
```
375+
376+
组件内置的部分渲染器也会使用同一事件机制触发状态更新,例如 Reasoning 渲染器的展开/收起、Tool 渲染器的详情展开/收起。
341377

342378
<demo vue="../../demos/bubble/state-change.vue" />
343379

344-
> **注意**消息的 `state` 属性用于存储 UI 相关的数据,不会影响消息内容。可以通过 `state-change` 事件来更新状态
380+
> **注意**`state-change` 是针对 `bubble-event``state:update` 提供的便捷事件,只负责通知外部更新 UI 状态。若状态没有同步回传给消息的 `state` 属性,渲染器下一次渲染时不会保留该状态
345381
346382
## Props
347383

@@ -405,6 +441,7 @@ Bubble 组件支持通过 `state` 属性存储 UI 相关的数据,并通过 `s
405441
| 事件名 | 参数类型 | 说明 |
406442
| -------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
407443
| `state-change` | `{ key: string; value: unknown; messageIndex: number; contentIndex: number }` | 当消息状态改变时触发。`key` 为状态键名,`value` 为状态值,`messageIndex` 为消息索引,`contentIndex` 为内容索引 |
444+
| `bubble-event` | `BubbleEvent & { messageIndex: number; contentIndex: number }` | Bubble 内部交互事件。状态更新会以 `name: 'state:update'` 触发,并额外派发 `state-change` 便捷事件 |
408445

409446
## Slots
410447

@@ -559,7 +596,11 @@ type BubbleMessageGroup = {
559596
role: string
560597
messages: BubbleMessage[]
561598
messageIndexes: number[]
562-
startIndex: number
599+
/**
600+
* @deprecated 自定义分组中的消息可能不连续,使用 startIndex + 局部索引推导全局索引可能出错。
601+
* 请以 messageIndexes 作为局部索引到全局索引的映射依据。
602+
*/
603+
startIndex?: number
563604
}
564605
```
565606

docs/src/migration/bubble-migration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ outline: [1, 3]
2121
- 0.4.x `BubbleList` 默认会把消息**按策略分组**(同角色连续/分割角色/自定义函数)
2222
- 插槽从“单条 bubble”切换为“分组 messages / messageIndexes”语义
2323
- **能力增强**
24-
- 新增 `state` + `state-change`(存储 UI 状态且不污染原始消息)
24+
- 新增 `bubble-event`(Bubble 内部渲染器向外抛出的通用事件)
25+
- 新增 `state` + `state-change`(存储 UI 状态且不污染原始消息;`state-change` 是针对 `bubble-event``state:update` 提供的便捷事件)
2526
- 新增 `contentResolver` / `contentRenderMode`(支持从任意字段解析内容、支持数组内容 split 渲染)
2627
- 新增内置 renderers:`Image / Markdown / Loading / Reasoning / Tool / Tools / ToolRole ...`
2728

packages/components/src/bubble/Bubble.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useCopyCleanup,
1212
} from './composables'
1313
import { BUBBLE_LIST_CONTEXT_KEY } from './constants'
14-
import type { BubbleMessage, BubbleMessageGroup, BubbleProps, BubbleSlots } from './index.type'
14+
import type { BubbleEvent, BubbleMessage, BubbleMessageGroup, BubbleProps, BubbleSlots } from './index.type'
1515
1616
const props = withDefaults(defineProps<BubbleProps>(), {
1717
placement: 'start',
@@ -26,6 +26,7 @@ const contentResolver = useContentResolver(() => props.contentResolver)
2626
2727
const emit = defineEmits<{
2828
(e: 'state-change', payload: { key: string; value: unknown; messageIndex: number; contentIndex: number }): void
29+
(e: 'bubble-event', payload: BubbleEvent & { messageIndex: number; contentIndex: number }): void
2930
}>()
3031
3132
// Provide bubble store if not already provided
@@ -112,6 +113,7 @@ if (!isInBubbleList) {
112113
:message="messages.at(0)!"
113114
:content-index="index"
114115
@state-change="emit('state-change', { ...$event, messageIndex: 0 })"
116+
@bubble-event="emit('bubble-event', { ...$event, messageIndex: 0 })"
115117
></BubbleContentWrapper>
116118
<slot name="content-footer" :messages="messages" :role="props.role" :content-index="index"></slot>
117119
</BubbleBoxWrapper>
@@ -125,6 +127,7 @@ if (!isInBubbleList) {
125127
:message="message"
126128
:content-index="contentIndex"
127129
@state-change="emit('state-change', { ...$event, messageIndex: msgIndex })"
130+
@bubble-event="emit('bubble-event', { ...$event, messageIndex: msgIndex })"
128131
></BubbleContentWrapper>
129132
</template>
130133
<slot name="content-footer" :messages="messages" :role="props.role"></slot>

packages/components/src/bubble/BubbleContentWrapper.vue

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import { computed } from 'vue'
3-
import { setupBubbleStateChangeFn, useBubbleContentRenderer } from './composables'
4-
import type { BubbleContentRendererProps } from './index.type'
3+
import { setupBubbleEventFn, useBubbleContentRenderer } from './composables'
4+
import type { BubbleContentRendererProps, BubbleEvent } from './index.type'
55
66
const props = defineProps<BubbleContentRendererProps>()
77
@@ -14,17 +14,36 @@ const componentProps = computed(() => ({
1414
1515
const emit = defineEmits<{
1616
(e: 'state-change', payload: { key: string; value: unknown; contentIndex: number }): void
17+
(e: 'bubble-event', payload: BubbleEvent & { contentIndex: number }): void
1718
}>()
1819
19-
const handleStateChange = (key: string, value: unknown) => {
20-
emit('state-change', {
21-
key,
22-
value,
20+
const handleBubbleEvent = (event: BubbleEvent) => {
21+
emit('bubble-event', {
22+
...event,
2323
contentIndex: props.contentIndex,
2424
})
25+
26+
if (event.name === 'state:update') {
27+
const payload = event.payload
28+
if (
29+
!payload ||
30+
typeof payload !== 'object' ||
31+
!('key' in payload) ||
32+
typeof payload.key !== 'string' ||
33+
!('value' in payload)
34+
) {
35+
return
36+
}
37+
38+
emit('state-change', {
39+
key: payload.key,
40+
value: payload.value,
41+
contentIndex: props.contentIndex,
42+
})
43+
}
2544
}
2645
27-
setupBubbleStateChangeFn(handleStateChange)
46+
setupBubbleEventFn(handleBubbleEvent)
2847
</script>
2948

3049
<template>

packages/components/src/bubble/BubbleItem.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup lang="ts">
22
import Bubble from './Bubble.vue'
33
import { setupBubbleMessageGroup } from './composables'
4-
import type { BubbleMessageGroup, BubbleProps, BubbleRoleConfig, BubbleSlots } from './index.type'
4+
import type { BubbleEvent, BubbleMessageGroup, BubbleProps, BubbleRoleConfig, BubbleSlots } from './index.type'
55
66
const props = defineProps<{
77
messageGroup: BubbleMessageGroup
@@ -14,6 +14,7 @@ defineSlots<BubbleSlots>()
1414
1515
const emit = defineEmits<{
1616
(e: 'state-change', payload: { key: string; value: unknown; messageIndex: number; contentIndex: number }): void
17+
(e: 'bubble-event', payload: BubbleEvent & { messageIndex: number; contentIndex: number }): void
1718
}>()
1819
1920
// Provide messages for each BubbleItem instance
@@ -27,6 +28,7 @@ setupBubbleMessageGroup(() => props.messageGroup)
2728
:content-render-mode="contentRenderMode"
2829
:content-resolver="contentResolver"
2930
@state-change="emit('state-change', $event)"
31+
@bubble-event="emit('bubble-event', $event)"
3032
>
3133
<template #prefix="slotProps">
3234
<slot name="prefix" v-bind="slotProps"></slot>

0 commit comments

Comments
 (0)