Skip to content

Commit 8e20fce

Browse files
committed
feat(bubble): add provider-level box and content attributes
1 parent 6a8bf29 commit 8e20fce

9 files changed

Lines changed: 255 additions & 17 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<template>
2+
<div class="demo">
3+
<p class="desc">
4+
外层气泡容器是 Box,里面的每一段内容是 Content。点击下方任意 Box 或 Content,可查看该 DOM 节点上的真实 data-*
5+
属性。
6+
</p>
7+
8+
<div class="preview" @click="handleInspect">
9+
<tr-bubble-provider :box-attributes="boxAttributes" :content-attributes="contentAttributes">
10+
<tr-bubble-list :messages="messages" :role-configs="roleConfigs" content-render-mode="split"></tr-bubble-list>
11+
</tr-bubble-provider>
12+
</div>
13+
14+
<pre class="output">{{ output }}</pre>
15+
</div>
16+
</template>
17+
18+
<script setup lang="ts">
19+
import type {
20+
BubbleBoxAttributesConfig,
21+
BubbleContentAttributesConfig,
22+
BubbleMessage,
23+
BubbleRoleConfig,
24+
} from '@opentiny/tiny-robot'
25+
import { TrBubbleList, TrBubbleProvider } from '@opentiny/tiny-robot'
26+
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
27+
import { h, ref } from 'vue'
28+
29+
const messages: BubbleMessage[] = [
30+
{ role: 'user', content: '请总结今天会议。' },
31+
{
32+
role: 'assistant',
33+
content: [
34+
{ type: 'text', text: '重点一:支持 BubbleProvider 统一注入 attributes。' },
35+
{ type: 'text', text: '重点二:支持按消息上下文动态生成 attributes。' },
36+
],
37+
},
38+
]
39+
40+
const roleConfigs: Record<string, BubbleRoleConfig> = {
41+
assistant: {
42+
avatar: h(IconAi, { style: { fontSize: '28px' } }),
43+
},
44+
user: {
45+
avatar: h(IconUser, { style: { fontSize: '28px' } }),
46+
placement: 'end',
47+
},
48+
}
49+
50+
const boxAttributes: BubbleBoxAttributesConfig = (messages, content, contentIndex) => ({
51+
'data-demo-kind': 'box',
52+
'data-role': messages[0]?.role || 'unknown',
53+
'data-message-count': messages.length,
54+
'data-content-type': content?.type || 'unknown',
55+
'data-content-index': contentIndex ?? 'unknown',
56+
})
57+
58+
const contentAttributes: BubbleContentAttributesConfig = (message, content, contentIndex) => ({
59+
'data-demo-kind': 'content',
60+
'data-role': message.role || 'unknown',
61+
'data-content-type': content.type,
62+
'data-content-index': contentIndex,
63+
})
64+
65+
const output = ref('点击预览区域中的节点后,这里会显示该节点上的 data-* 属性。')
66+
67+
const handleInspect = (event: MouseEvent) => {
68+
const target = event.target as HTMLElement | null
69+
const element = target?.closest('[data-demo-kind]') as HTMLElement | null
70+
71+
if (!element) {
72+
return
73+
}
74+
75+
const dataAttributes = Object.fromEntries(
76+
element
77+
.getAttributeNames()
78+
.filter((name) => name.startsWith('data-') && !name.startsWith('data-v-'))
79+
.map((name) => [name, element.getAttribute(name)]),
80+
)
81+
82+
output.value = JSON.stringify(dataAttributes, null, 2)
83+
}
84+
</script>
85+
86+
<style scoped>
87+
.demo {
88+
display: flex;
89+
flex-direction: column;
90+
gap: 12px;
91+
}
92+
93+
.desc {
94+
margin: 0;
95+
font-size: 12px;
96+
color: #666;
97+
}
98+
99+
.preview {
100+
padding: 12px;
101+
border: 1px solid var(--vp-c-divider, #ddd);
102+
background: var(--vp-c-bg-soft, #f6f6f7);
103+
}
104+
105+
.preview :deep([data-demo-kind]) {
106+
cursor: pointer;
107+
}
108+
109+
.preview :deep([data-demo-kind='box']) {
110+
outline: 1px dashed #f59e0b;
111+
}
112+
113+
.preview :deep([data-demo-kind='content']) {
114+
outline: 1px solid #60a5fa;
115+
}
116+
117+
.output {
118+
margin: 0;
119+
padding: 12px;
120+
font-size: 12px;
121+
line-height: 1.5;
122+
color: var(--vp-c-text-1, #213547);
123+
background: var(--vp-c-bg-soft, #f5f5f5);
124+
border: 1px solid var(--vp-c-divider, #ddd);
125+
white-space: pre-wrap;
126+
word-break: break-word;
127+
}
128+
</style>

docs/src/components/bubble.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
outline: [1, 3]
2+
outline: [1, 4]
33
---
44

55
# Bubble 气泡组件
@@ -210,6 +210,20 @@ Bubble 组件采用渲染器架构,支持灵活的内容渲染和自定义扩
210210

211211
<demo vue="../../demos/bubble/provider-renderer.vue" />
212212

213+
#### 通过 BubbleProvider 统一注入 attributes
214+
215+
除了配置渲染器,`BubbleProvider` 还支持通过 `box-attributes``content-attributes` 为 Box / Content 统一注入 attributes。
216+
217+
- `box-attributes` 的作用域是一个 Box,对应参数为 `(messages, content, contentIndex)`
218+
- `content-attributes` 的作用域是单个 Content,对应参数为 `(message, content, contentIndex)`
219+
- 两个属性都支持传入静态对象,或返回 attributes 的函数
220+
221+
适合用于统一添加 `data-*` 标记、埋点字段、测试选择器等通用属性,而不需要依赖所有消息都匹配某个自定义渲染器。
222+
223+
<demo vue="../../demos/bubble/provider-attributes.vue" />
224+
225+
> `BubbleProvider` 注入的 attributes 会在对应的 Box / Content 上统一生效;如果某个匹配规则本身也配置了 `attributes`,会在 Provider attributes 的基础上继续合并。
226+
213227
#### 渲染器匹配优先级
214228

215229
匹配规则可以使用 `priority` 属性来设置优先级,值越小优先级越高。系统提供了以下优先级常量:
@@ -401,6 +415,8 @@ Bubble 组件支持通过 `state` 属性存储 UI 相关的数据,并通过 `s
401415
| ------------------------- | --------------------------------------- | ------ | ---------------------------------------------------------- |
402416
| `boxRendererMatches` | `BubbleBoxRendererMatch[]` | - | Box 渲染器匹配规则数组 |
403417
| `contentRendererMatches` | `BubbleContentRendererMatch[]` | - | 内容渲染器匹配规则数组 |
418+
| `boxAttributes` | `BubbleBoxAttributesConfig` | - | 统一注入到 Box 的 attributes,支持静态对象或 resolver 函数 |
419+
| `contentAttributes` | `BubbleContentAttributesConfig` | - | 统一注入到 Content 的 attributes,支持静态对象或 resolver 函数 |
404420
| `fallbackBoxRenderer` | `Component<BubbleBoxRendererProps>` | - | 默认 box 渲染器(当无法匹配到合适的渲染器时使用) |
405421
| `fallbackContentRenderer` | `Component<BubbleContentRendererProps>` | - | 默认内容渲染器(当无法匹配到合适的渲染器时使用) |
406422
| `store` | `Record<string, unknown>` | - | 全局状态存储,用于在 BubbleList 和 Bubble 组件之间共享数据 |

packages/components/src/bubble/BubbleBoxWrapper.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ const renderer = useBubbleBoxRenderer(() => props.messages, props.contentIndex)
1515
<template>
1616
<component
1717
:is="renderer.renderer"
18+
v-bind="renderer.attributes"
1819
:data-role="props.role"
1920
:data-placement="props.placement"
2021
:data-shape="props.shape"
21-
v-bind="renderer.attributes"
2222
>
2323
<slot />
2424
</component>

packages/components/src/bubble/BubbleContentWrapper.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
<script setup lang="ts">
2+
import { computed } from 'vue'
23
import { setupBubbleStateChangeFn, useBubbleContentRenderer } from './composables'
34
import type { BubbleContentRendererProps } from './index.type'
45
56
const props = defineProps<BubbleContentRendererProps>()
67
78
const renderer = useBubbleContentRenderer(() => props.message, props.contentIndex)
9+
const componentProps = computed(() => ({
10+
...renderer.value.attributes,
11+
message: props.message,
12+
contentIndex: props.contentIndex,
13+
}))
814
915
const emit = defineEmits<{
1016
(e: 'state-change', payload: { key: string; value: unknown; contentIndex: number }): void
@@ -22,5 +28,5 @@ setupBubbleStateChangeFn(handleStateChange)
2228
</script>
2329

2430
<template>
25-
<component :is="renderer" v-bind="props"></component>
31+
<component :is="renderer.renderer" v-bind="componentProps"></component>
2632
</template>

packages/components/src/bubble/BubbleProvider.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,16 @@ const fallbackContentRenderer = computed(() => {
3333
return props.fallbackContentRenderer || defaultFallbackContentRenderer
3434
})
3535
36-
setupBubbleBoxRenderer({ boxRendererMatches, fallbackBoxRenderer })
37-
setupBubbleContentRenderer({ contentRendererMatches, fallbackContentRenderer })
36+
setupBubbleBoxRenderer({
37+
boxRendererMatches,
38+
boxAttributes: () => props.boxAttributes,
39+
fallbackBoxRenderer,
40+
})
41+
setupBubbleContentRenderer({
42+
contentRendererMatches,
43+
contentAttributes: () => props.contentAttributes,
44+
fallbackContentRenderer,
45+
})
3846
</script>
3947

4048
<template>

packages/components/src/bubble/composables/useBubbleBoxRenderer.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import type { Component, ComputedRef, MaybeRefOrGetter } from 'vue'
22
import { computed, inject, provide, toValue } from 'vue'
33
import {
4+
BUBBLE_BOX_ATTRIBUTES_KEY,
45
BUBBLE_BOX_FALLBACK_RENDERER_KEY,
56
BUBBLE_BOX_PROP_FALLBACK_RENDERER_KEY,
67
BUBBLE_BOX_RENDERER_MATCHES_KEY,
78
} from '../constants'
8-
import type { BubbleBoxRendererMatch, BubbleMessage } from '../index.type'
9+
import type { BubbleAttributes, BubbleBoxAttributesConfig, BubbleBoxRendererMatch, BubbleMessage } from '../index.type'
910
import { defaultBoxRendererMatches, defaultFallbackBoxRenderer } from '../renderers/defaultRenderers'
1011
import { useContentResolver } from './useContentResolver'
1112

1213
export function setupBubbleBoxRenderer(renderers: {
1314
boxRendererMatches?: MaybeRefOrGetter<Array<BubbleBoxRendererMatch>>
15+
boxAttributes?: MaybeRefOrGetter<BubbleBoxAttributesConfig | undefined>
1416
fallbackBoxRenderer?: MaybeRefOrGetter<Component>
1517
}): void {
16-
const { boxRendererMatches, fallbackBoxRenderer } = renderers
18+
const { boxRendererMatches, boxAttributes, fallbackBoxRenderer } = renderers
1719
if (boxRendererMatches) {
1820
provide(BUBBLE_BOX_RENDERER_MATCHES_KEY, boxRendererMatches)
1921
}
22+
if (boxAttributes) {
23+
provide(BUBBLE_BOX_ATTRIBUTES_KEY, boxAttributes)
24+
}
2025
if (fallbackBoxRenderer) {
2126
provide(BUBBLE_BOX_FALLBACK_RENDERER_KEY, fallbackBoxRenderer)
2227
}
@@ -40,9 +45,10 @@ export function useBubbleBoxRenderer(
4045
contentIndex?: number,
4146
): ComputedRef<{
4247
renderer: Component
43-
attributes?: Record<string, string>
48+
attributes?: BubbleAttributes
4449
}> {
4550
const boxRendererMatches = inject(BUBBLE_BOX_RENDERER_MATCHES_KEY, defaultBoxRendererMatches)
51+
const boxAttributes = inject(BUBBLE_BOX_ATTRIBUTES_KEY, undefined)
4652
const fallbackBoxRenderer = inject(BUBBLE_BOX_FALLBACK_RENDERER_KEY, undefined)
4753
const propFallbackBoxRenderer = inject(BUBBLE_BOX_PROP_FALLBACK_RENDERER_KEY, undefined)
4854
const contentResolver = useContentResolver()
@@ -71,19 +77,30 @@ export function useBubbleBoxRenderer(
7177
const msgs = toValue(messages)
7278

7379
const { content, index } = getContentAndIndex(msgs)
80+
const resolvedBoxAttributes = (() => {
81+
const attrs = toValue(boxAttributes)
82+
if (!attrs) {
83+
return undefined
84+
}
85+
return typeof attrs === 'function' ? attrs(msgs, content, index) : attrs
86+
})()
7487

7588
const match = toValue(boxRendererMatches).find((match) => match.find(msgs, content, index))
7689

7790
if (match) {
7891
return {
7992
renderer: match.renderer,
80-
attributes: match.attributes,
93+
attributes: {
94+
...resolvedBoxAttributes,
95+
...match.attributes,
96+
},
8197
}
8298
}
8399

84100
// Priority: prop-level > provider-level > default
85101
return {
86102
renderer: toValue(propFallbackBoxRenderer) || toValue(fallbackBoxRenderer) || defaultFallbackBoxRenderer,
103+
attributes: resolvedBoxAttributes,
87104
}
88105
})
89106
}

packages/components/src/bubble/composables/useBubbleContentRenderer.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
11
import type { Component, ComputedRef, MaybeRefOrGetter } from 'vue'
22
import { computed, inject, provide, toValue } from 'vue'
33
import {
4+
BUBBLE_CONTENT_ATTRIBUTES_KEY,
45
BUBBLE_CONTENT_FALLBACK_RENDERER_KEY,
56
BUBBLE_CONTENT_PROP_FALLBACK_RENDERER_KEY,
67
BUBBLE_CONTENT_RENDERER_MATCHES_KEY,
78
} from '../constants'
8-
import type { BubbleContentRendererMatch, BubbleMessage, ChatMessageContentItem } from '../index.type'
9+
import type {
10+
BubbleAttributes,
11+
BubbleContentAttributesConfig,
12+
BubbleContentRendererMatch,
13+
BubbleMessage,
14+
ChatMessageContentItem,
15+
} from '../index.type'
916
import { defaultContentRendererMatches, defaultFallbackContentRenderer } from '../renderers/defaultRenderers'
1017
import { useContentResolver } from './useContentResolver'
1118

1219
export function setupBubbleContentRenderer(renderers: {
1320
contentRendererMatches?: MaybeRefOrGetter<Array<BubbleContentRendererMatch>>
21+
contentAttributes?: MaybeRefOrGetter<BubbleContentAttributesConfig | undefined>
1422
fallbackContentRenderer?: MaybeRefOrGetter<Component>
1523
}): void {
16-
const { contentRendererMatches, fallbackContentRenderer } = renderers
24+
const { contentRendererMatches, contentAttributes, fallbackContentRenderer } = renderers
1725
if (contentRendererMatches) {
1826
provide(BUBBLE_CONTENT_RENDERER_MATCHES_KEY, contentRendererMatches)
1927
}
28+
if (contentAttributes) {
29+
provide(BUBBLE_CONTENT_ATTRIBUTES_KEY, contentAttributes)
30+
}
2031
if (fallbackContentRenderer) {
2132
provide(BUBBLE_CONTENT_FALLBACK_RENDERER_KEY, fallbackContentRenderer)
2233
}
@@ -38,8 +49,12 @@ export function setupBubblePropContentRenderer(renderers: {
3849
export function useBubbleContentRenderer(
3950
message: MaybeRefOrGetter<BubbleMessage>,
4051
contentIndex: number,
41-
): ComputedRef<Component> {
52+
): ComputedRef<{
53+
renderer: Component
54+
attributes?: BubbleAttributes
55+
}> {
4256
const contentRendererMatches = inject(BUBBLE_CONTENT_RENDERER_MATCHES_KEY, defaultContentRendererMatches)
57+
const contentAttributes = inject(BUBBLE_CONTENT_ATTRIBUTES_KEY, undefined)
4358
const fallbackContentRenderer = inject(BUBBLE_CONTENT_FALLBACK_RENDERER_KEY, undefined)
4459
const propFallbackContentRenderer = inject(BUBBLE_CONTENT_PROP_FALLBACK_RENDERER_KEY, undefined)
4560
const contentResolver = useContentResolver()
@@ -50,12 +65,29 @@ export function useBubbleContentRenderer(
5065
const content = Array.isArray(resolvedContent)
5166
? (resolvedContent.at(contentIndex ?? 0) as ChatMessageContentItem)
5267
: { type: 'text', text: resolvedContent || '' }
68+
const resolvedProviderAttributes = (() => {
69+
const attrs = toValue(contentAttributes)
70+
if (!attrs) {
71+
return undefined
72+
}
73+
return typeof attrs === 'function' ? attrs(msg, content, contentIndex) : attrs
74+
})()
5375
const match = toValue(contentRendererMatches).find((match) => match.find(msg, content, contentIndex))
5476
if (match) {
55-
return match.renderer
77+
return {
78+
renderer: match.renderer,
79+
attributes: {
80+
...resolvedProviderAttributes,
81+
...match.attributes,
82+
},
83+
}
5684
}
5785

5886
// Priority: prop-level > provider-level > default
59-
return toValue(propFallbackContentRenderer) || toValue(fallbackContentRenderer) || defaultFallbackContentRenderer
87+
return {
88+
renderer:
89+
toValue(propFallbackContentRenderer) || toValue(fallbackContentRenderer) || defaultFallbackContentRenderer,
90+
attributes: resolvedProviderAttributes,
91+
}
6092
})
6193
}

0 commit comments

Comments
 (0)