@foundbyte/uni-agent 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/bubble-list/index.scss +254 -0
- package/components/bubble-list/index.vue +505 -0
- package/components/bubble-list/types.ts +110 -0
- package/components/bubble-list/use-typewriter.ts +193 -0
- package/components/bubble-wrapper/index.scss +34 -0
- package/components/bubble-wrapper/index.vue +97 -0
- package/components/history/index.scss +87 -0
- package/components/history/index.vue +118 -0
- package/components/history/types.ts +16 -0
- package/components/index.ts +4 -0
- package/components/prompts/index.scss +30 -0
- package/components/prompts/index.vue +46 -0
- package/components/prompts/types.ts +11 -0
- package/components/sender/image-picker.ts +100 -0
- package/components/sender/index.scss +288 -0
- package/components/sender/index.vue +374 -0
- package/components/sender/types.ts +56 -0
- package/composables/index.ts +6 -0
- package/composables/use-inner-audio/audio-manager.ts +22 -0
- package/composables/use-inner-audio/enums.ts +15 -0
- package/composables/use-inner-audio/types.ts +8 -0
- package/composables/use-inner-audio/use-inner-audio.ts +149 -0
- package/composables/use-prefix.ts +54 -0
- package/composables/use-recorder/h5-recorder-manager.ts +492 -0
- package/composables/use-recorder/native-recorder-manager.ts +181 -0
- package/composables/use-recorder/recorder-manager.ts +40 -0
- package/composables/use-recorder/types.ts +73 -0
- package/composables/use-recorder/use-recorder.ts +205 -0
- package/configs/index.ts +5 -0
- package/index.ts +3 -0
- package/package.json +90 -0
- package/styles/_base.scss +8 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<scroll-view
|
|
3
|
+
:class="p('list')"
|
|
4
|
+
scroll-y
|
|
5
|
+
:scroll-top="scrollTop"
|
|
6
|
+
:scroll-with-animation="true"
|
|
7
|
+
:scroll-into-view="scrollIntoView"
|
|
8
|
+
enhanced
|
|
9
|
+
:bounces="false"
|
|
10
|
+
:throttle="false"
|
|
11
|
+
:style="rootStyle"
|
|
12
|
+
@scroll="onScroll"
|
|
13
|
+
@scrolltolower="$emit('reach-bottom')"
|
|
14
|
+
>
|
|
15
|
+
<view :class="p('list-content')">
|
|
16
|
+
<!-- 列表顶部插槽 -->
|
|
17
|
+
<slot name="before" />
|
|
18
|
+
|
|
19
|
+
<view
|
|
20
|
+
v-for="(message, index) in messages"
|
|
21
|
+
:id="`bubble-${message.id}`"
|
|
22
|
+
:key="message.id || index"
|
|
23
|
+
:class="[
|
|
24
|
+
p('item'),
|
|
25
|
+
p('item', getPlacement(message)),
|
|
26
|
+
{ [p('item', 'last')]: index === messages.length - 1 },
|
|
27
|
+
getAnimationClass(message),
|
|
28
|
+
]"
|
|
29
|
+
:style="getAnimationStyle(message)"
|
|
30
|
+
>
|
|
31
|
+
<!-- 头像 - 左侧(对方) -->
|
|
32
|
+
<view
|
|
33
|
+
v-if="getPlacement(message) === BubblePlacement.Start && isShowAvatar(message)"
|
|
34
|
+
:class="[p('avatar'), p('avatar', 'left')]"
|
|
35
|
+
>
|
|
36
|
+
<slot name="avatar-left" :message="message" :index="index">
|
|
37
|
+
<image
|
|
38
|
+
v-if="getAvatar(message)"
|
|
39
|
+
:class="p(['avatar', 'image'])"
|
|
40
|
+
:src="getAvatar(message)"
|
|
41
|
+
mode="aspectFill"
|
|
42
|
+
/>
|
|
43
|
+
<view v-else :class="[p(['avatar', 'default']), p(['avatar', 'default'], 'ai')]">
|
|
44
|
+
<text :class="p(['avatar', 'icon'])"> 🤖 </text>
|
|
45
|
+
</view>
|
|
46
|
+
</slot>
|
|
47
|
+
</view>
|
|
48
|
+
|
|
49
|
+
<!-- 消息内容区域 -->
|
|
50
|
+
<view :class="[p('content'), p('content', getPlacement(message))]">
|
|
51
|
+
<!-- 角色名称 -->
|
|
52
|
+
<view v-if="getRoleName(message)" :class="p('role-name')">
|
|
53
|
+
{{ getRoleName(message) }}
|
|
54
|
+
</view>
|
|
55
|
+
|
|
56
|
+
<!-- 消息气泡 -->
|
|
57
|
+
<view
|
|
58
|
+
:class="[
|
|
59
|
+
p('message'),
|
|
60
|
+
p('message', getPlacement(message)),
|
|
61
|
+
p('message', message.role),
|
|
62
|
+
{ [p('message', 'loading')]: message.loading },
|
|
63
|
+
]"
|
|
64
|
+
>
|
|
65
|
+
<!-- 加载状态 -->
|
|
66
|
+
<view v-if="message.loading" :class="p('loading')">
|
|
67
|
+
<view :class="p(['loading', 'dot'])" />
|
|
68
|
+
<view :class="p(['loading', 'dot'])" />
|
|
69
|
+
<view :class="p(['loading', 'dot'])" />
|
|
70
|
+
</view>
|
|
71
|
+
|
|
72
|
+
<slot v-if="!message.loading" name="content" :message="message" :index="index"></slot>
|
|
73
|
+
|
|
74
|
+
<!-- 内容底部插槽 -->
|
|
75
|
+
<slot name="content-footer" :message="message" :index="index" />
|
|
76
|
+
</view>
|
|
77
|
+
|
|
78
|
+
<!-- 消息底部(时间等) -->
|
|
79
|
+
<view v-if="message.time || $slots['message-footer']" :class="p('message-footer')">
|
|
80
|
+
<!-- <text v-if="message.time" :class="p('time')">
|
|
81
|
+
{{ message.time }}
|
|
82
|
+
</text> -->
|
|
83
|
+
<slot name="message-footer" :message="message" :index="index" />
|
|
84
|
+
</view>
|
|
85
|
+
</view>
|
|
86
|
+
|
|
87
|
+
<!-- 头像 - 右侧(自己) -->
|
|
88
|
+
<view
|
|
89
|
+
v-if="getPlacement(message) === BubblePlacement.End && isShowAvatar(message)"
|
|
90
|
+
:class="[p('avatar'), p('avatar', 'right')]"
|
|
91
|
+
>
|
|
92
|
+
<slot name="avatar-right" :message="message" :index="index">
|
|
93
|
+
<image
|
|
94
|
+
v-if="getAvatar(message)"
|
|
95
|
+
:class="p(['avatar', 'image'])"
|
|
96
|
+
:src="getAvatar(message)"
|
|
97
|
+
mode="aspectFill"
|
|
98
|
+
/>
|
|
99
|
+
<view v-else :class="[p(['avatar', 'default']), p(['avatar', 'default'], 'user')]">
|
|
100
|
+
<text :class="p(['avatar', 'icon'])"> 👤 </text>
|
|
101
|
+
</view>
|
|
102
|
+
</slot>
|
|
103
|
+
</view>
|
|
104
|
+
</view>
|
|
105
|
+
|
|
106
|
+
<!-- 列表底部插槽 -->
|
|
107
|
+
<slot name="after" />
|
|
108
|
+
|
|
109
|
+
<!-- 底部锚点,用于滚动到底部 -->
|
|
110
|
+
<view :id="listBottomAnchorId" :class="p('list-bottom-anchor')" />
|
|
111
|
+
</view>
|
|
112
|
+
</scroll-view>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<script setup lang="ts">
|
|
116
|
+
// eslint-disable-next-line no-restricted-imports
|
|
117
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
118
|
+
import { parse as parseRichText } from 'marked'
|
|
119
|
+
import {
|
|
120
|
+
type BubbleListProps,
|
|
121
|
+
type BubbleMessage,
|
|
122
|
+
BubblePlacement,
|
|
123
|
+
MessageAnimation,
|
|
124
|
+
type MessageAnimationConfig,
|
|
125
|
+
MessageRole,
|
|
126
|
+
} from './types'
|
|
127
|
+
import { useTypewriterManager } from './use-typewriter'
|
|
128
|
+
import { usePrefix } from '../../composables'
|
|
129
|
+
|
|
130
|
+
defineOptions({
|
|
131
|
+
options: {
|
|
132
|
+
styleIsolation: 'shared',
|
|
133
|
+
virtualHost: true,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const props = withDefaults(defineProps<BubbleListProps>(), {
|
|
138
|
+
messages: () => [],
|
|
139
|
+
roleConfigs: () => ({}),
|
|
140
|
+
defaultPlacement: 'start',
|
|
141
|
+
autoScroll: true,
|
|
142
|
+
defaultTypingSpeed: 30,
|
|
143
|
+
showRole: true,
|
|
144
|
+
showAvatar: true,
|
|
145
|
+
animation: () => ({ type: MessageAnimation.SlideLeft }),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const emit = defineEmits<{
|
|
149
|
+
(e: 'scroll', event: ScrollEvent): void
|
|
150
|
+
(e: 'reach-top'): void
|
|
151
|
+
(e: 'reach-bottom'): void
|
|
152
|
+
}>()
|
|
153
|
+
|
|
154
|
+
const p = usePrefix('bubble')
|
|
155
|
+
|
|
156
|
+
const listBottomAnchorId = `anchor-${Math.random().toString(32).substring(2)}`
|
|
157
|
+
|
|
158
|
+
type ScrollEvent = {
|
|
159
|
+
detail: {
|
|
160
|
+
deltaX: number
|
|
161
|
+
deltaY: number
|
|
162
|
+
scrollHeight: number
|
|
163
|
+
scrollLeft: number
|
|
164
|
+
scrollTop: number
|
|
165
|
+
scrollWidth: number
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 滚动位置控制
|
|
170
|
+
const scrollTop = ref(0)
|
|
171
|
+
const scrollIntoView = ref('')
|
|
172
|
+
|
|
173
|
+
// 需要触发动画的消息 ID 集合
|
|
174
|
+
const pendingAnimationIds = ref<Set<string>>(new Set())
|
|
175
|
+
// 已经准备好初始状态、可以触发动画的消息 ID 集合
|
|
176
|
+
const readyToAnimateIds = ref<Set<string>>(new Set())
|
|
177
|
+
|
|
178
|
+
// 打字机效果管理
|
|
179
|
+
const typewriterManager = useTypewriterManager()
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 根节点样式 - 注入默认动画 CSS 变量
|
|
183
|
+
*/
|
|
184
|
+
const rootStyle = computed(() => {
|
|
185
|
+
const style: Record<string, string> = {}
|
|
186
|
+
const globalAnimation = props.animation
|
|
187
|
+
|
|
188
|
+
style['--animation-duration'] = `${globalAnimation.duration || 300}ms`
|
|
189
|
+
style['--animation-delay'] = `${globalAnimation.delay || 0}ms`
|
|
190
|
+
|
|
191
|
+
return style
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 获取消息的打字机状态
|
|
196
|
+
*/
|
|
197
|
+
function getMessageTypewriter(message: BubbleMessage) {
|
|
198
|
+
// 从 props.messages 中动态查找消息,确保响应式更新
|
|
199
|
+
const messageRef = computed(() => {
|
|
200
|
+
return props.messages.find((m) => m.id === message.id) || message
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// 只有 AI 消息且开启了 typing 才启用打字机效果
|
|
204
|
+
const enabled = computed(
|
|
205
|
+
() => messageRef.value.role === MessageRole.Ai && !!messageRef.value.typing,
|
|
206
|
+
)
|
|
207
|
+
const content = computed(() => messageRef.value.content || '')
|
|
208
|
+
|
|
209
|
+
// 用于控制滚动频率的节流
|
|
210
|
+
// let scrollThrottleTimer: ReturnType<typeof setTimeout> | null = null
|
|
211
|
+
|
|
212
|
+
return typewriterManager.getOrCreate(message.id, content, enabled, {
|
|
213
|
+
speed: message.typingSpeed || props.defaultTypingSpeed,
|
|
214
|
+
autoStart: true,
|
|
215
|
+
onUpdate: (index, len) => {
|
|
216
|
+
// 打字时自动滚动(如果不是用户主动滚动)
|
|
217
|
+
if (props.autoScroll) {
|
|
218
|
+
const lastMessage = props.messages[props.messages.length - 1]
|
|
219
|
+
if (lastMessage?.id === message.id) {
|
|
220
|
+
scrollToBottom()
|
|
221
|
+
if (index === len) {
|
|
222
|
+
setTimeout(scrollToBottom, 500)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function getMessageContent(message: BubbleMessage, isRichText = false): string {
|
|
231
|
+
const { displayText, isComplete } = getMessageTypewriter(message)
|
|
232
|
+
let content = displayText.value
|
|
233
|
+
if (isComplete.value || message.role !== MessageRole.Ai || !message.typing) {
|
|
234
|
+
content = message.content || ''
|
|
235
|
+
}
|
|
236
|
+
return isRichText ? (parseRichText(content) as string) : content
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 监听消息变化,自动滚动到底部
|
|
240
|
+
watch(
|
|
241
|
+
() => props.messages.length,
|
|
242
|
+
(newLen, oldLen) => {
|
|
243
|
+
if (newLen > (oldLen || 0) && props.autoScroll) {
|
|
244
|
+
scrollToBottom()
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{ flush: 'post' },
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// 监听最后一条消息的 loading 状态变化
|
|
251
|
+
watch(
|
|
252
|
+
() => props.messages[props.messages.length - 1]?.loading,
|
|
253
|
+
(loading) => {
|
|
254
|
+
if (props.autoScroll && loading) {
|
|
255
|
+
scrollToBottom()
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
// 监听最后一条 AI 消息的内容变化(流式输出时实时滚动)
|
|
261
|
+
let contentWatchStop: (() => void) | null = null
|
|
262
|
+
|
|
263
|
+
watch(
|
|
264
|
+
() => props.messages[props.messages.length - 1],
|
|
265
|
+
(lastMessage) => {
|
|
266
|
+
// 清理旧的监听
|
|
267
|
+
if (contentWatchStop) {
|
|
268
|
+
contentWatchStop()
|
|
269
|
+
contentWatchStop = null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 如果是 AI 消息且有内容变化,监听其 content
|
|
273
|
+
if (lastMessage?.role === MessageRole.Ai && lastMessage?.content !== undefined) {
|
|
274
|
+
contentWatchStop = watch(
|
|
275
|
+
() => lastMessage.content,
|
|
276
|
+
() => {
|
|
277
|
+
if (props.autoScroll) {
|
|
278
|
+
scrollToBottom()
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
{ flush: 'post' },
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
{ immediate: true },
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
function getPlacement(message: BubbleMessage) {
|
|
289
|
+
const config = props.roleConfigs[message.role]
|
|
290
|
+
return (
|
|
291
|
+
config?.placement ||
|
|
292
|
+
(message.role === MessageRole.User ? BubblePlacement.End : BubblePlacement.Start)
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 获取消息的动画配置
|
|
298
|
+
* 优先级:消息自身的 animation > 全局 animation 配置
|
|
299
|
+
*/
|
|
300
|
+
function getMessageAnimation(message: BubbleMessage): MessageAnimationConfig {
|
|
301
|
+
return (
|
|
302
|
+
message.animation ||
|
|
303
|
+
props.animation || {
|
|
304
|
+
type: MessageAnimation.SlideLeft,
|
|
305
|
+
duration: 300,
|
|
306
|
+
delay: 0,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 获取动画类名
|
|
313
|
+
* 根据消息位置和动画类型返回对应的动画类
|
|
314
|
+
* 只有在 readyToAnimateIds 中时才返回动画类(确保初始状态已设置)
|
|
315
|
+
*/
|
|
316
|
+
function getAnimationClass(message: BubbleMessage): string {
|
|
317
|
+
const animation = getMessageAnimation(message)
|
|
318
|
+
|
|
319
|
+
// 如果指定了自定义类名,直接返回
|
|
320
|
+
if (animation.customClass) {
|
|
321
|
+
return animation.customClass
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 根据动画类型返回对应类名
|
|
325
|
+
const type = animation.type || MessageAnimation.SlideLeft
|
|
326
|
+
|
|
327
|
+
// 如果是无动画,返回空字符串
|
|
328
|
+
if (type === MessageAnimation.None) {
|
|
329
|
+
return ''
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 只有当消息准备好触发动画时才添加动画类
|
|
333
|
+
if (!readyToAnimateIds.value.has(message.id)) {
|
|
334
|
+
return ''
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 根据消息位置和动画类型生成类名
|
|
338
|
+
const placement = getPlacement(message)
|
|
339
|
+
const baseClass = p('animate')
|
|
340
|
+
|
|
341
|
+
// 处理 SlideLeft 和 SlideRight,需要根据位置决定方向
|
|
342
|
+
if (type === MessageAnimation.SlideLeft) {
|
|
343
|
+
// 左侧消息从左滑入,右侧消息从右滑入
|
|
344
|
+
return `${baseClass}--${placement === BubblePlacement.End ? 'slide-right' : 'slide-left'}`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (type === MessageAnimation.SlideRight) {
|
|
348
|
+
// 左侧消息从右滑入,右侧消息从左滑入(反向)
|
|
349
|
+
return `${baseClass}--${placement === BubblePlacement.End ? 'slide-left' : 'slide-right'}`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 其他动画类型直接使用
|
|
353
|
+
return `${baseClass}--${type}`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 获取动画样式
|
|
358
|
+
* 设置初始状态和 transition 相关的 CSS 变量
|
|
359
|
+
*/
|
|
360
|
+
function getAnimationStyle(message: BubbleMessage): Record<string, string> {
|
|
361
|
+
const animation = getMessageAnimation(message)
|
|
362
|
+
const style: Record<string, string> = {}
|
|
363
|
+
|
|
364
|
+
// 获取全局默认值
|
|
365
|
+
const globalDuration = props.animation?.duration ?? 300
|
|
366
|
+
const globalDelay = props.animation?.delay ?? 0
|
|
367
|
+
|
|
368
|
+
// 只在与全局默认值不同时才设置 CSS 变量
|
|
369
|
+
if (animation.duration !== undefined && animation.duration !== globalDuration) {
|
|
370
|
+
style['--animation-duration'] = `${animation.duration}ms`
|
|
371
|
+
}
|
|
372
|
+
if (animation.delay !== undefined && animation.delay !== globalDelay) {
|
|
373
|
+
style['--animation-delay'] = `${animation.delay}ms`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const type = animation.type || MessageAnimation.SlideLeft
|
|
377
|
+
|
|
378
|
+
// 无动画类型,直接返回
|
|
379
|
+
if (type === MessageAnimation.None) {
|
|
380
|
+
return style
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 只有当消息是新添加的(在 pendingAnimationIds 中但不在 readyToAnimateIds 中)
|
|
384
|
+
// 才设置初始状态(opacity: 0, transform: ...)
|
|
385
|
+
if (!pendingAnimationIds.value.has(message.id) || readyToAnimateIds.value.has(message.id)) {
|
|
386
|
+
return style
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 根据动画类型设置初始状态
|
|
390
|
+
switch (type) {
|
|
391
|
+
case MessageAnimation.SlideLeft:
|
|
392
|
+
// 左侧消息从左滑入,右侧消息从右滑入
|
|
393
|
+
if (getPlacement(message) === BubblePlacement.End) {
|
|
394
|
+
style.transform = 'translateX(60rpx)'
|
|
395
|
+
} else {
|
|
396
|
+
style.transform = 'translateX(-60rpx)'
|
|
397
|
+
}
|
|
398
|
+
style.opacity = '0'
|
|
399
|
+
break
|
|
400
|
+
case MessageAnimation.SlideRight:
|
|
401
|
+
// 左侧消息从右滑入,右侧消息从左滑入(反向)
|
|
402
|
+
if (getPlacement(message) === BubblePlacement.End) {
|
|
403
|
+
style.transform = 'translateX(-60rpx)'
|
|
404
|
+
} else {
|
|
405
|
+
style.transform = 'translateX(60rpx)'
|
|
406
|
+
}
|
|
407
|
+
style.opacity = '0'
|
|
408
|
+
break
|
|
409
|
+
case MessageAnimation.Scale:
|
|
410
|
+
case MessageAnimation.Bounce:
|
|
411
|
+
style.transform = 'scale(0.8)'
|
|
412
|
+
style.opacity = '0'
|
|
413
|
+
break
|
|
414
|
+
case MessageAnimation.Fade:
|
|
415
|
+
default:
|
|
416
|
+
style.opacity = '0'
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return style
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isShowRole(message: BubbleMessage): boolean {
|
|
424
|
+
const config = props.roleConfigs[message.role]
|
|
425
|
+
// 优先使用角色配置,如果没有则使用全局配置
|
|
426
|
+
if (config?.showRole !== undefined) return config.showRole
|
|
427
|
+
return props.showRole
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isShowAvatar(message: BubbleMessage): boolean {
|
|
431
|
+
const config = props.roleConfigs[message.role]
|
|
432
|
+
// 优先使用角色配置,如果没有则使用全局配置
|
|
433
|
+
if (config?.showAvatar !== undefined) return config.showAvatar
|
|
434
|
+
return props.showAvatar
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function getRoleName(message: BubbleMessage): string {
|
|
438
|
+
if (!isShowRole(message)) return ''
|
|
439
|
+
return message.roleName || props.roleConfigs[message.role]?.name || ''
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function getAvatar(message: BubbleMessage): string {
|
|
443
|
+
if (!isShowAvatar(message)) return ''
|
|
444
|
+
return message.avatar || props.roleConfigs[message.role]?.avatar || ''
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function isRichContent(content?: string): content is string {
|
|
448
|
+
if (!content) return false
|
|
449
|
+
return /<[^>]+>/.test(content) || /[*#`\-_!]/.test(content)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const scrollToBottom = () => {
|
|
453
|
+
// 使用底部锚点进行滚动,更可靠
|
|
454
|
+
scrollIntoView.value = ''
|
|
455
|
+
nextTick(() => {
|
|
456
|
+
scrollIntoView.value = listBottomAnchorId
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function onScroll(event: ScrollEvent) {
|
|
461
|
+
emit('scroll', event)
|
|
462
|
+
if (event.detail.scrollTop <= 10) emit('reach-top')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 监听消息列表变化,为正在打字的AI消息添加滚动监听
|
|
466
|
+
watch(
|
|
467
|
+
() => props.messages.map((m) => m.id),
|
|
468
|
+
(newIds, oldIds) => {
|
|
469
|
+
// 清理已删除消息的打字机状态
|
|
470
|
+
if (oldIds) {
|
|
471
|
+
oldIds.forEach((id) => {
|
|
472
|
+
if (!newIds.includes(id)) {
|
|
473
|
+
typewriterManager.remove(id)
|
|
474
|
+
pendingAnimationIds.value.delete(id)
|
|
475
|
+
readyToAnimateIds.value.delete(id)
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 为新添加的消息触发动画
|
|
481
|
+
const newMessageIds = newIds.filter((id) => !oldIds?.includes(id))
|
|
482
|
+
if (newMessageIds.length > 0) {
|
|
483
|
+
// 第一步:立即添加到 pendingAnimationIds,让 getAnimationStyle 设置初始状态(opacity: 0)
|
|
484
|
+
newMessageIds.forEach((id) => pendingAnimationIds.value.add(id))
|
|
485
|
+
|
|
486
|
+
// 第二步:在下一帧添加到 readyToAnimateIds,让 getAnimationClass 添加动画类,触发 transition
|
|
487
|
+
nextTick(() => {
|
|
488
|
+
newMessageIds.forEach((id) => readyToAnimateIds.value.add(id))
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
{ immediate: true },
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
// 暴露方法
|
|
496
|
+
defineExpose({
|
|
497
|
+
isRichContent,
|
|
498
|
+
getMessageContent,
|
|
499
|
+
scrollToBottom,
|
|
500
|
+
})
|
|
501
|
+
</script>
|
|
502
|
+
|
|
503
|
+
<style lang="scss">
|
|
504
|
+
@import './index.scss';
|
|
505
|
+
</style>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** 消息角色类型 */
|
|
2
|
+
export enum MessageRole {
|
|
3
|
+
Ai = 'ai',
|
|
4
|
+
User = 'user',
|
|
5
|
+
System = 'system',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** 气泡位置 */
|
|
9
|
+
export enum BubblePlacement {
|
|
10
|
+
Start = 'start',
|
|
11
|
+
End = 'end',
|
|
12
|
+
Both = 'both',
|
|
13
|
+
Center = 'center',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 消息动画类型 */
|
|
17
|
+
export enum MessageAnimation {
|
|
18
|
+
/** 无动画 */
|
|
19
|
+
None = 'none',
|
|
20
|
+
/** 淡入 */
|
|
21
|
+
Fade = 'fade',
|
|
22
|
+
/** 从左滑入 */
|
|
23
|
+
SlideLeft = 'slide-left',
|
|
24
|
+
/** 从右滑入 */
|
|
25
|
+
SlideRight = 'slide-right',
|
|
26
|
+
/** 缩放进入 */
|
|
27
|
+
Scale = 'scale',
|
|
28
|
+
/** 弹跳进入 */
|
|
29
|
+
Bounce = 'bounce',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 消息动画配置 */
|
|
33
|
+
export interface MessageAnimationConfig {
|
|
34
|
+
/** 动画类型 */
|
|
35
|
+
type?: MessageAnimation
|
|
36
|
+
/** 动画持续时间(毫秒),默认 300ms */
|
|
37
|
+
duration?: number
|
|
38
|
+
/** 动画延迟(毫秒),默认 0ms */
|
|
39
|
+
delay?: number
|
|
40
|
+
/** 自定义动画类名(会覆盖 type) */
|
|
41
|
+
customClass?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 单条消息 */
|
|
45
|
+
export interface BubbleMessage {
|
|
46
|
+
/** 消息唯一标识 */
|
|
47
|
+
id: string
|
|
48
|
+
/** 消息角色 */
|
|
49
|
+
role: string
|
|
50
|
+
/** 消息内容 */
|
|
51
|
+
content?: string
|
|
52
|
+
/** 是否加载中 */
|
|
53
|
+
loading?: boolean
|
|
54
|
+
/** 是否开启打字机效果(仅AI消息有效) */
|
|
55
|
+
typing?: boolean
|
|
56
|
+
/** 打字速度(毫秒/字符),默认 30ms */
|
|
57
|
+
typingSpeed?: number
|
|
58
|
+
/** 发送时间 */
|
|
59
|
+
time?: string
|
|
60
|
+
/** 音频URL */
|
|
61
|
+
audioUrl?: string
|
|
62
|
+
/** 是否正在播放音频 */
|
|
63
|
+
isPlaying?: boolean
|
|
64
|
+
/** 动画配置(优先级高于全局配置) */
|
|
65
|
+
animation?: MessageAnimationConfig
|
|
66
|
+
/** 自定义数据 */
|
|
67
|
+
[key: string]: any
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 角色配置 */
|
|
71
|
+
export interface BubbleRoleConfig {
|
|
72
|
+
/** 角色名称 */
|
|
73
|
+
name?: string
|
|
74
|
+
/** 头像地址 */
|
|
75
|
+
avatar?: string
|
|
76
|
+
/** 气泡位置:start-左侧(对方), end-右侧(自己), both-两侧,center-水平居中 */
|
|
77
|
+
placement?: 'start' | 'end' | 'both' | 'center'
|
|
78
|
+
/** 气泡背景色 */
|
|
79
|
+
bubbleColor?: string
|
|
80
|
+
/** 气泡文字颜色 */
|
|
81
|
+
textColor?: string
|
|
82
|
+
/** 自定义渲染器 */
|
|
83
|
+
contentRenderer?: (content: string) => string
|
|
84
|
+
/** 是否显示角色名称 */
|
|
85
|
+
showRole?: boolean
|
|
86
|
+
/** 是否显示头像 */
|
|
87
|
+
showAvatar?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 组件 Props */
|
|
91
|
+
export interface BubbleListProps {
|
|
92
|
+
/** 消息列表 */
|
|
93
|
+
messages?: BubbleMessage[]
|
|
94
|
+
/** 角色配置 */
|
|
95
|
+
roleConfigs?: Record<string, BubbleRoleConfig>
|
|
96
|
+
/** 默认气泡位置 */
|
|
97
|
+
defaultPlacement?: 'start' | 'end'
|
|
98
|
+
/** 是否自动滚动到底部 */
|
|
99
|
+
autoScroll?: boolean
|
|
100
|
+
/** 是否开启虚拟列表(暂未实现) */
|
|
101
|
+
virtualList?: boolean
|
|
102
|
+
/** 默认打字速度(毫秒/字符) */
|
|
103
|
+
defaultTypingSpeed?: number
|
|
104
|
+
/** 是否显示角色名称(全局配置,可被 roleConfigs 中的 showRole 覆盖) */
|
|
105
|
+
showRole?: boolean
|
|
106
|
+
/** 是否显示头像(全局配置,可被 roleConfigs 中的 showAvatar 覆盖) */
|
|
107
|
+
showAvatar?: boolean
|
|
108
|
+
/** 全局动画配置(可被单条消息的 animation 配置覆盖) */
|
|
109
|
+
animation?: MessageAnimationConfig
|
|
110
|
+
}
|