@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.
Files changed (32) hide show
  1. package/components/bubble-list/index.scss +254 -0
  2. package/components/bubble-list/index.vue +505 -0
  3. package/components/bubble-list/types.ts +110 -0
  4. package/components/bubble-list/use-typewriter.ts +193 -0
  5. package/components/bubble-wrapper/index.scss +34 -0
  6. package/components/bubble-wrapper/index.vue +97 -0
  7. package/components/history/index.scss +87 -0
  8. package/components/history/index.vue +118 -0
  9. package/components/history/types.ts +16 -0
  10. package/components/index.ts +4 -0
  11. package/components/prompts/index.scss +30 -0
  12. package/components/prompts/index.vue +46 -0
  13. package/components/prompts/types.ts +11 -0
  14. package/components/sender/image-picker.ts +100 -0
  15. package/components/sender/index.scss +288 -0
  16. package/components/sender/index.vue +374 -0
  17. package/components/sender/types.ts +56 -0
  18. package/composables/index.ts +6 -0
  19. package/composables/use-inner-audio/audio-manager.ts +22 -0
  20. package/composables/use-inner-audio/enums.ts +15 -0
  21. package/composables/use-inner-audio/types.ts +8 -0
  22. package/composables/use-inner-audio/use-inner-audio.ts +149 -0
  23. package/composables/use-prefix.ts +54 -0
  24. package/composables/use-recorder/h5-recorder-manager.ts +492 -0
  25. package/composables/use-recorder/native-recorder-manager.ts +181 -0
  26. package/composables/use-recorder/recorder-manager.ts +40 -0
  27. package/composables/use-recorder/types.ts +73 -0
  28. package/composables/use-recorder/use-recorder.ts +205 -0
  29. package/configs/index.ts +5 -0
  30. package/index.ts +3 -0
  31. package/package.json +90 -0
  32. package/styles/_base.scss +8 -0
@@ -0,0 +1,193 @@
1
+ // eslint-disable-next-line no-restricted-imports
2
+ import { ref, type Ref, watch } from 'vue'
3
+
4
+ export interface TypewriterOptions {
5
+ /** 打字速度(毫秒/字符),默认 30ms */
6
+ speed?: number
7
+ /** 是否自动开始 */
8
+ autoStart?: boolean
9
+ /** 内容更新时的回调(用于自动滚动) */
10
+ onUpdate?: (currentIndex: number, totalLength: number) => void
11
+ }
12
+
13
+ export interface TypewriterResult {
14
+ /** 当前显示的文本 */
15
+ displayText: Ref<string>
16
+ /** 是否正在打字中 */
17
+ isTyping: Ref<boolean>
18
+ /** 是否已完成 */
19
+ isComplete: Ref<boolean>
20
+ /** 开始打字 */
21
+ start: () => void
22
+ /** 跳过动画,直接显示全部 */
23
+ skip: () => void
24
+ /** 重置状态 */
25
+ reset: () => void
26
+ }
27
+
28
+ /**
29
+ * 打字机效果 composable
30
+ * @param fullText 完整文本内容
31
+ * @param enabled 是否启用打字机效果
32
+ * @param options 配置选项
33
+ */
34
+ export const useTypewriter = (
35
+ fullText: Ref<string>,
36
+ enabled: Ref<boolean>,
37
+ options: TypewriterOptions = {},
38
+ ) => {
39
+ const { speed = 30, autoStart = true, onUpdate } = options
40
+
41
+ const displayText = ref('')
42
+ const isTyping = ref(false)
43
+ const isComplete = ref(false)
44
+ let currentIndex = 0
45
+ let timer: ReturnType<typeof setTimeout> | null = null
46
+
47
+ const clearTimer = () => {
48
+ if (timer) {
49
+ clearTimeout(timer)
50
+ timer = null
51
+ }
52
+ }
53
+
54
+ const typeNextChar = () => {
55
+ const text = fullText.value || ''
56
+
57
+ if (currentIndex < text.length) {
58
+ // 处理 Markdown 代码块,确保代码块标签完整显示
59
+ const nextChar = text[currentIndex]
60
+ displayText.value += nextChar
61
+ currentIndex++
62
+
63
+ // 检查是否在代码块标记内,如果是则加速显示整个标记
64
+ const isInCodeBlockMarker =
65
+ /```\w*$/.test(text.slice(0, currentIndex)) || /^\s*```/.test(text.slice(currentIndex - 3))
66
+
67
+ // 如果在代码块标记附近,加速显示
68
+ const actualSpeed = isInCodeBlockMarker ? 10 : speed
69
+
70
+ // 触发更新回调(用于自动滚动)
71
+ onUpdate?.(currentIndex, text.length)
72
+
73
+ timer = setTimeout(typeNextChar, actualSpeed)
74
+ } else {
75
+ isTyping.value = false
76
+ isComplete.value = true
77
+ }
78
+ }
79
+
80
+ const start = () => {
81
+ if (!enabled.value) {
82
+ displayText.value = fullText.value || ''
83
+ isComplete.value = true
84
+ return
85
+ }
86
+
87
+ clearTimer()
88
+ displayText.value = ''
89
+ currentIndex = 0
90
+ isTyping.value = true
91
+ isComplete.value = false
92
+ typeNextChar()
93
+ }
94
+
95
+ const skip = () => {
96
+ clearTimer()
97
+ displayText.value = fullText.value || ''
98
+ isTyping.value = false
99
+ isComplete.value = true
100
+ currentIndex = displayText.value.length
101
+ }
102
+
103
+ const reset = () => {
104
+ clearTimer()
105
+ displayText.value = ''
106
+ currentIndex = 0
107
+ isTyping.value = false
108
+ isComplete.value = false
109
+ }
110
+
111
+ // 监听 fullText 变化,重新开始打字
112
+ watch(
113
+ () => fullText.value,
114
+ (newText, oldText) => {
115
+ if (!enabled.value) {
116
+ displayText.value = newText || ''
117
+ isComplete.value = true
118
+ return
119
+ }
120
+
121
+ // 如果是追加内容(流式响应),继续打字
122
+ if (isTyping.value && newText && oldText && newText.startsWith(oldText)) {
123
+ // 继续打字,不需要重置
124
+ return
125
+ }
126
+
127
+ // 否则重新开始
128
+ reset()
129
+ if (autoStart && newText) {
130
+ start()
131
+ }
132
+ },
133
+ { immediate: true },
134
+ )
135
+
136
+ // 监听 enabled 变化
137
+ watch(
138
+ () => enabled.value,
139
+ (enabled) => {
140
+ if (!enabled) {
141
+ skip()
142
+ }
143
+ },
144
+ )
145
+
146
+ return {
147
+ displayText,
148
+ isTyping,
149
+ isComplete,
150
+ start,
151
+ skip,
152
+ reset,
153
+ }
154
+ }
155
+
156
+ /**
157
+ * 管理多个消息的打字机状态
158
+ */
159
+ export const useTypewriterManager = () => {
160
+ const typingStates = new Map<string, TypewriterResult>()
161
+
162
+ const getOrCreate = (
163
+ messageId: string,
164
+ fullText: Ref<string>,
165
+ enabled: Ref<boolean>,
166
+ options?: TypewriterOptions,
167
+ ): TypewriterResult => {
168
+ if (!typingStates.has(messageId)) {
169
+ const state = useTypewriter(fullText, enabled, options)
170
+ typingStates.set(messageId, state)
171
+ }
172
+ return typingStates.get(messageId)!
173
+ }
174
+
175
+ const remove = (messageId: string) => {
176
+ const state = typingStates.get(messageId)
177
+ if (state) {
178
+ state.reset()
179
+ typingStates.delete(messageId)
180
+ }
181
+ }
182
+
183
+ const clear = () => {
184
+ typingStates.forEach((state) => state.reset())
185
+ typingStates.clear()
186
+ }
187
+
188
+ return {
189
+ getOrCreate,
190
+ remove,
191
+ clear,
192
+ }
193
+ }
@@ -0,0 +1,34 @@
1
+ @use '../../styles/base' as base;
2
+
3
+ .#{base.$prefix}-bubble {
4
+ &__wrapper {
5
+ padding: 32rpx;
6
+ border-radius: 24rpx;
7
+ background-color: #ffffff;
8
+ color: rgba(0, 0, 0, 0.85);
9
+ border-top-left-radius: 0;
10
+ box-shadow: 1px 4rpx 24rpx 0 rgba(0, 0, 0, 0.05);
11
+
12
+ &--dark {
13
+ background-color: #f6f7f8;
14
+ color: rgba(0, 0, 0, 0.85);
15
+ border-top-left-radius: 24rpx;
16
+ border-top-right-radius: 0;
17
+ padding: 32rpx;
18
+ box-shadow: none;
19
+ }
20
+
21
+ &-tool {
22
+ display: flex;
23
+ gap: 24rpx;
24
+ border-top: 1px solid #f6f7f8;
25
+ margin-top: 32rpx;
26
+ padding-top: 24rpx;
27
+
28
+ &-icon {
29
+ width: 48rpx;
30
+ height: 48rpx;
31
+ }
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,97 @@
1
+ <template>
2
+ <view :class="[p('wrapper'), { [p('wrapper', 'dark')]: !showTool }]">
3
+ <view style="white-space: pre-line">
4
+ <slot></slot>
5
+ </view>
6
+ <view v-if="showTool" :class="p(['wrapper', 'tool'])">
7
+ <image
8
+ :src="`${staticPrefix}/icon-msg-${audioIcon}.png`"
9
+ :class="p(['wrapper', 'tool', 'icon'])"
10
+ @click="onPlay"
11
+ />
12
+ <image
13
+ :src="`${staticPrefix}/icon-msg-copy.png`"
14
+ :class="p(['wrapper', 'tool', 'icon'])"
15
+ @click="onCopy"
16
+ />
17
+ </view>
18
+ </view>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { staticPrefix } from '../../configs'
23
+ import { EAudioPlayState, useInnerAudio, usePrefix } from '../../composables'
24
+ import type { BubbleMessage } from '../bubble-list/types'
25
+
26
+ defineOptions({
27
+ options: {
28
+ styleIsolation: 'shared',
29
+ virtualHost: true,
30
+ },
31
+ })
32
+
33
+ const props = defineProps<{
34
+ showTool?: boolean
35
+ message: BubbleMessage
36
+ }>()
37
+
38
+ const emit = defineEmits<{
39
+ /** 音频播放事件 */
40
+ (e: 'audio-play'): void
41
+ /** 音频暂停事件 */
42
+ (e: 'audio-pause'): void
43
+ /** 音频停止事件 */
44
+ (e: 'audio-stop'): void
45
+ /** 文本转语音事件 */
46
+ (e: 'text2audio', text: string): Promise<string> | void
47
+ }>()
48
+
49
+ const p = usePrefix('bubble')
50
+
51
+ const { state, isPlaying, playingId, load, play, pause } = useInnerAudio()
52
+
53
+ const audioIcon = computed(() => {
54
+ return isPlaying.value && playingId.value === props.message.id ? 'voice-pause' : 'voice'
55
+ })
56
+
57
+ async function onPlay() {
58
+ const { audioUrl, id, content } = props.message
59
+ if (!audioUrl) {
60
+ if (content) {
61
+ emit('text2audio', content)
62
+ }
63
+ return
64
+ }
65
+ if (id !== playingId.value) {
66
+ load(id, audioUrl, true)
67
+ return
68
+ }
69
+ switch (state.value) {
70
+ case EAudioPlayState.Playing:
71
+ pause()
72
+ break
73
+ case EAudioPlayState.Paused:
74
+ case EAudioPlayState.Ended:
75
+ play()
76
+ break
77
+ default:
78
+ load(id, audioUrl, true)
79
+ }
80
+ }
81
+
82
+ function onCopy() {
83
+ uni.setClipboardData({
84
+ data: props.message.content || '',
85
+ success() {
86
+ uni.showToast({
87
+ title: '复制成功',
88
+ icon: 'success',
89
+ })
90
+ },
91
+ })
92
+ }
93
+ </script>
94
+
95
+ <style lang="scss">
96
+ @import './index.scss';
97
+ </style>
@@ -0,0 +1,87 @@
1
+ @use '../../styles/base' as base;
2
+
3
+ .#{base.$prefix}-history {
4
+ display: flex;
5
+ flex-direction: column;
6
+
7
+ &__head {
8
+ font-weight: 600;
9
+ }
10
+
11
+ &__title {
12
+ display: flex;
13
+ justify-content: space-between;
14
+ padding: 40rpx;
15
+
16
+ &-text {
17
+ height: 40rpx;
18
+ font-size: 28rpx;
19
+ color: rgba(0, 0, 0, 0.85);
20
+ font-weight: 600;
21
+ }
22
+ }
23
+
24
+ &__edit {
25
+ height: 40rpx;
26
+ font-size: 20rpx;
27
+ color: rgba(0, 0, 0, 0.45);
28
+ font-weight: 400;
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 24rpx;
32
+
33
+ &-switch {
34
+ width: 32rpx;
35
+ height: 32rpx;
36
+ }
37
+ }
38
+
39
+ &__body {
40
+ flex: 1;
41
+ overflow: auto;
42
+ &-inner {
43
+ display: flex;
44
+ flex-direction: column;
45
+ // gap: 24rpx;
46
+ padding: 40rpx;
47
+ padding-top: 0;
48
+ }
49
+ }
50
+
51
+ &__group {
52
+ & + & {
53
+ margin-top: 40rpx;
54
+ }
55
+
56
+ &-title {
57
+ line-height: 28rpx;
58
+ font-size: 20rpx;
59
+ color: rgba(0, 0, 0, 0.65);
60
+ font-weight: 400;
61
+ padding-left: 8rpx;
62
+ }
63
+ }
64
+
65
+ &__item {
66
+ display: flex;
67
+ gap: 24rpx;
68
+ align-items: center;
69
+ margin-top: 24rpx;
70
+
71
+ &-title {
72
+ flex: 1;
73
+ overflow: hidden;
74
+ white-space: nowrap;
75
+ text-overflow: ellipsis;
76
+ line-height: 40rpx;
77
+ font-size: 28rpx;
78
+ color: #000000;
79
+ font-weight: 400;
80
+ }
81
+
82
+ &-clear {
83
+ width: 32rpx;
84
+ height: 32rpx;
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <view :class="p()">
3
+ <view :class="p('head')">
4
+ <slot name="title-before"></slot>
5
+ <view :class="p('title')">
6
+ <view :class="p(['title', 'text'])">对话记录</view>
7
+ <view v-if="isEditing" :class="p('edit')">
8
+ <view :class="p(['edit', 'clear'])" @click="$emit('deleteAll')">清空</view>
9
+ <view>|</view>
10
+ <view @click="endEdit">完成</view>
11
+ </view>
12
+ <image
13
+ v-else
14
+ :class="p(['edit', 'switch'])"
15
+ :src="`${staticPrefix}/icon-del.png`"
16
+ @click="isEditing = true"
17
+ />
18
+ </view>
19
+ </view>
20
+ <scroll-view :class="p('body')" scroll-y @scrolltolower="$emit('reachBottom')">
21
+ <view :class="p('body-inner')">
22
+ <!-- 分组 -->
23
+ <view v-for="group in groups" :key="group.title" :class="p('group')">
24
+ <view :class="p(['group', 'title'])">{{ group.title }}</view>
25
+ <!-- 会话列表 -->
26
+ <view
27
+ v-for="session in group.sessions"
28
+ :key="session.id"
29
+ :class="p('item')"
30
+ @click="$emit('itemClick', session)"
31
+ >
32
+ <view :class="p(['item', 'title'])">{{ session.label }}</view>
33
+ <image
34
+ v-if="isEditing"
35
+ :src="`${staticPrefix}/icon-del.png`"
36
+ :class="p(['item', 'clear'])"
37
+ @click.stop="$emit('delete', session)"
38
+ />
39
+ </view>
40
+ </view>
41
+ <view
42
+ style="
43
+ text-align: center;
44
+ margin-top: 40rpx;
45
+ font-size: 24rpx;
46
+ color: rgba(0, 0, 0, 0.45);
47
+ "
48
+ >{{ hasMore ? '加载中…' : '没有更多了~' }}</view
49
+ >
50
+ </view>
51
+ </scroll-view>
52
+ </view>
53
+ </template>
54
+
55
+ <script setup lang="ts">
56
+ // 会话历史
57
+ // eslint-disable-next-line no-restricted-imports
58
+ import { computed, ref } from 'vue'
59
+ import { staticPrefix } from '../../configs'
60
+ import { usePrefix } from '../../composables'
61
+ import type { HistoryProps, Session } from './types'
62
+
63
+ defineOptions({
64
+ options: {
65
+ styleIsolation: 'shared',
66
+ virtualHost: true,
67
+ },
68
+ })
69
+
70
+ const props = withDefaults(defineProps<HistoryProps>(), {
71
+ sessions: () => [],
72
+ })
73
+
74
+ defineEmits<{
75
+ itemClick: [session: Session]
76
+ delete: [session: Session]
77
+ deleteAll: []
78
+ reachBottom: []
79
+ }>()
80
+
81
+ const p = usePrefix('history')
82
+
83
+ const isEditing = ref(false)
84
+
85
+ const groups = computed<
86
+ {
87
+ title: string
88
+ sessions: Session[]
89
+ }[]
90
+ >(() => {
91
+ const groupMap = new Map<string, Session[]>()
92
+
93
+ for (const session of props.sessions) {
94
+ const date = session.date
95
+ if (!groupMap.has(date)) {
96
+ groupMap.set(date, [])
97
+ }
98
+ groupMap.get(date)!.push(session)
99
+ }
100
+
101
+ return Array.from(groupMap.entries()).map(([title, sessions]) => ({
102
+ title,
103
+ sessions,
104
+ }))
105
+ })
106
+
107
+ function endEdit() {
108
+ isEditing.value = false
109
+ }
110
+
111
+ defineExpose({
112
+ endEdit,
113
+ })
114
+ </script>
115
+
116
+ <style lang="scss">
117
+ @import './index.scss';
118
+ </style>
@@ -0,0 +1,16 @@
1
+ export interface Session {
2
+ id: string
3
+ /** 日常 */
4
+ date: string
5
+ /** 显示文本(首条会话内容?) */
6
+ label: string
7
+ }
8
+
9
+ export interface HistoryProps {
10
+ /** 数据加载中 */
11
+ loading?: boolean
12
+ /** 是否有更多数据 */
13
+ hasMore?: boolean
14
+ /** 会话列表 */
15
+ sessions?: Session[]
16
+ }
@@ -0,0 +1,4 @@
1
+ export * from './bubble-list/types'
2
+ export * from './history/types'
3
+ export * from './prompts/types'
4
+ export * from './sender/types'
@@ -0,0 +1,30 @@
1
+ @use '../../styles/base' as base;
2
+
3
+ .#{base.$prefix}-prompts {
4
+ display: flex;
5
+ gap: 12rpx;
6
+
7
+ &__item {
8
+ background-color: #fff;
9
+ padding: 15rpx 20rpx;
10
+ line-height: 34rpx;
11
+ box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.05);
12
+ border-radius: 32rpx;
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 12rpx;
16
+
17
+ &--disabled {
18
+ opacity: 0.6;
19
+ }
20
+
21
+ &:active {
22
+ background-color: #f0f0f0;
23
+ }
24
+ }
25
+
26
+ &__item-icon {
27
+ width: 32rpx;
28
+ height: 32rpx;
29
+ }
30
+ }
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <view :class="p()">
3
+ <view
4
+ v-for="(item, index) in items"
5
+ :key="item.id || index"
6
+ :class="[p('item'), { [p('item', 'disabled')]: item.disabled }]"
7
+ @click="onClick(item)"
8
+ >
9
+ <image v-if="item.icon" :src="item.icon" :class="p(['item', 'icon'])" mode="aspectFit" />
10
+ <text>{{ item.label }}</text>
11
+ </view>
12
+ </view>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ // 提示集
17
+ import { usePrefix } from '../../composables'
18
+ import type { PromptItem, PromptsProps } from './types'
19
+
20
+ defineOptions({
21
+ options: {
22
+ styleIsolation: 'shared',
23
+ virtualHost: true,
24
+ },
25
+ })
26
+
27
+ withDefaults(defineProps<PromptsProps>(), {
28
+ items: () => [],
29
+ })
30
+
31
+ const emit = defineEmits<{
32
+ itemClick: [item: PromptItem]
33
+ }>()
34
+
35
+ const p = usePrefix('prompts')
36
+
37
+ function onClick(item: PromptItem) {
38
+ if (!item.disabled) {
39
+ emit('itemClick', item)
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <style lang="scss">
45
+ @import './index.scss';
46
+ </style>
@@ -0,0 +1,11 @@
1
+ export interface PromptItem {
2
+ icon?: string
3
+ label: string
4
+ id?: string
5
+ disabled?: boolean
6
+ }
7
+
8
+ export interface PromptsProps {
9
+ /** 提示集合 */
10
+ items?: PromptItem[]
11
+ }