@opentiny/vue-docs 3.24.1 → 3.24.3
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/demos/pc/menus.js +5 -0
- package/demos/pc/webdoc/changelog.md +52 -119
- package/demos/pc/webdoc/mcp-en.md +101 -0
- package/demos/pc/webdoc/mcp.md +101 -0
- package/package.json +8 -9
- package/src/App.vue +2 -2
- package/src/components/MessageCard.vue +117 -0
- package/src/composable/utils.ts +4 -4
- package/src/router.js +6 -0
- package/src/views/comprehensive/Demo.vue +211 -211
- package/src/views/comprehensive/index.vue +380 -391
- package/src/views/remoter/index.vue +63 -0
- package/src/views/remoter/sound.vue +349 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// 导入 CryptoJS 库
|
|
3
|
+
import CryptoJS from 'crypto-js'
|
|
4
|
+
import { reactive } from 'vue'
|
|
5
|
+
import { TinyButton, TinyDrawer } from '@opentiny/vue'
|
|
6
|
+
import RobotChat from '../../components/tiny-robot-chat.vue'
|
|
7
|
+
import Sound from './sound.vue'
|
|
8
|
+
import { globalConversation } from '../../composable/utils'
|
|
9
|
+
|
|
10
|
+
// 加密密钥,需要和生成二维码的页面保持一致
|
|
11
|
+
const secretKey = 'secret-session-id'
|
|
12
|
+
// 获取 URL 参数
|
|
13
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
14
|
+
const encryptedId = urlParams.get('id')
|
|
15
|
+
const state = reactive({ isShow: true, showChat: false, showSound: false })
|
|
16
|
+
|
|
17
|
+
if (encryptedId) {
|
|
18
|
+
// 解密 id
|
|
19
|
+
const bytes = CryptoJS.AES.decrypt(encryptedId, secretKey)
|
|
20
|
+
const originalText = bytes.toString(CryptoJS.enc.Utf8)
|
|
21
|
+
// 存储解密后的 id 到 window.sessionId
|
|
22
|
+
globalConversation.sessionId = originalText
|
|
23
|
+
}
|
|
24
|
+
const showMoter = (type) => {
|
|
25
|
+
state.isShow = false
|
|
26
|
+
state[type] = true
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div>
|
|
32
|
+
<RobotChat v-if="state.showChat" />
|
|
33
|
+
<Sound v-if="state.showSound" />
|
|
34
|
+
<tiny-drawer
|
|
35
|
+
v-if="state.isShow"
|
|
36
|
+
title="请选择控制器"
|
|
37
|
+
placement="bottom"
|
|
38
|
+
:mask-closable="false"
|
|
39
|
+
:show-close="false"
|
|
40
|
+
v-model:visible="state.isShow"
|
|
41
|
+
height="400px"
|
|
42
|
+
>
|
|
43
|
+
<div class="link-box">
|
|
44
|
+
<tiny-button @click="showMoter('showSound')" type="info" size="large">语音控制器</tiny-button>
|
|
45
|
+
|
|
46
|
+
<tiny-button @click="showMoter('showChat')" type="info" size="large">综合控制器</tiny-button>
|
|
47
|
+
</div>
|
|
48
|
+
</tiny-drawer>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped lang="less">
|
|
53
|
+
.link-box {
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
justify-content: center;
|
|
57
|
+
align-items: center;
|
|
58
|
+
|
|
59
|
+
.tiny-button {
|
|
60
|
+
margin-bottom: 20px;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="sound-container">
|
|
3
|
+
<div class="messages-container" ref="messagesContainer">
|
|
4
|
+
<template v-if="messages && messages.length > 0">
|
|
5
|
+
<message-card
|
|
6
|
+
v-for="(msg, index) in messages"
|
|
7
|
+
:key="index"
|
|
8
|
+
:role="msg.role === 'system' ? 'assistant' : msg.role"
|
|
9
|
+
:message="msg.content"
|
|
10
|
+
:timestamp="messageTimestamps[index]"
|
|
11
|
+
/>
|
|
12
|
+
</template>
|
|
13
|
+
<div v-else class="empty-message">
|
|
14
|
+
<p>暂无对话记录</p>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="sound-box">
|
|
18
|
+
<div class="recording-status" v-show="isTalk">
|
|
19
|
+
<div class="wave-animation"></div>
|
|
20
|
+
<span>{{ recordingTime }}s</span>
|
|
21
|
+
</div>
|
|
22
|
+
<tiny-button
|
|
23
|
+
@touchstart.prevent="handleStart"
|
|
24
|
+
@touchend.prevent="handleEnd"
|
|
25
|
+
@touchcancel.prevent="handleEnd"
|
|
26
|
+
:type="isTalk ? 'danger' : 'info'"
|
|
27
|
+
class="talk-button"
|
|
28
|
+
size="large"
|
|
29
|
+
:reset-time="0"
|
|
30
|
+
:disabled="!isSupported"
|
|
31
|
+
>
|
|
32
|
+
{{ buttonText }}
|
|
33
|
+
</tiny-button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
import { ref, computed, onUnmounted, watch, nextTick } from 'vue'
|
|
40
|
+
import { TinyButton, TinyNotify } from '@opentiny/vue'
|
|
41
|
+
import { useTinyRobot } from '../../composable/useTinyRobot'
|
|
42
|
+
import MessageCard from '../../components/MessageCard.vue'
|
|
43
|
+
|
|
44
|
+
// 类型定义
|
|
45
|
+
interface SpeechRecognitionResult {
|
|
46
|
+
[index: number]: {
|
|
47
|
+
transcript: string
|
|
48
|
+
confidence: number
|
|
49
|
+
}
|
|
50
|
+
isFinal?: boolean
|
|
51
|
+
length: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SpeechRecognitionResultList {
|
|
55
|
+
[index: number]: SpeechRecognitionResult
|
|
56
|
+
length: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SpeechRecognitionEvent extends Event {
|
|
60
|
+
results: SpeechRecognitionResultList
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SpeechRecognitionErrorEvent extends Event {
|
|
64
|
+
error: string
|
|
65
|
+
message: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 获取SpeechRecognition构造函数
|
|
69
|
+
const getSpeechRecognition = () => {
|
|
70
|
+
const SpeechRecognition =
|
|
71
|
+
(window as any).SpeechRecognition ||
|
|
72
|
+
(window as any).webkitSpeechRecognition ||
|
|
73
|
+
(window as any).mozSpeechRecognition ||
|
|
74
|
+
(window as any).msSpeechRecognition
|
|
75
|
+
return SpeechRecognition
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 状态管理
|
|
79
|
+
const isTalk = ref(false)
|
|
80
|
+
const isSupported = ref(false)
|
|
81
|
+
const recordingTime = ref(0)
|
|
82
|
+
const maxRecordingTime = 60
|
|
83
|
+
let recognition: any = null
|
|
84
|
+
let recordingTimer: number | null = null
|
|
85
|
+
const messagesContainer = ref<HTMLElement | null>(null)
|
|
86
|
+
const messageTimestamps = ref<number[]>([])
|
|
87
|
+
const { sendMessage, messages } = useTinyRobot()
|
|
88
|
+
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
|
89
|
+
|
|
90
|
+
// 计算属性
|
|
91
|
+
const buttonText = computed(() => {
|
|
92
|
+
if (!isSupported.value) return '当前浏览器不支持语音识别'
|
|
93
|
+
if (isTalk.value) return `录音中 ${recordingTime.value}s`
|
|
94
|
+
return '长按说话'
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// 监听消息变化,更新时间戳和自动滚动
|
|
98
|
+
watch(
|
|
99
|
+
() => messages.value,
|
|
100
|
+
async (newMessages) => {
|
|
101
|
+
if (!newMessages) return
|
|
102
|
+
|
|
103
|
+
// 更新时间戳
|
|
104
|
+
if (messageTimestamps.value.length < newMessages.length) {
|
|
105
|
+
const timestamp = Date.now()
|
|
106
|
+
messageTimestamps.value = newMessages.map((_, index) => messageTimestamps.value[index] || timestamp)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 自动滚动到底部
|
|
110
|
+
await nextTick()
|
|
111
|
+
if (messagesContainer.value) {
|
|
112
|
+
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{ deep: true }
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// 初始化语音识别
|
|
119
|
+
const initSpeechRecognition = () => {
|
|
120
|
+
const SpeechRecognition = getSpeechRecognition()
|
|
121
|
+
|
|
122
|
+
if (SpeechRecognition) {
|
|
123
|
+
isSupported.value = true
|
|
124
|
+
recognition = new SpeechRecognition()
|
|
125
|
+
recognition.lang = 'zh-CN'
|
|
126
|
+
recognition.continuous = false
|
|
127
|
+
recognition.interimResults = false
|
|
128
|
+
recognition.maxAlternatives = 1
|
|
129
|
+
|
|
130
|
+
// Safari 需要特殊处理
|
|
131
|
+
if (isSafari) {
|
|
132
|
+
recognition.interimResults = true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let finalTranscript = ''
|
|
136
|
+
|
|
137
|
+
recognition.addEventListener('result', (event: SpeechRecognitionEvent) => {
|
|
138
|
+
const transcript = event.results[0][0].transcript
|
|
139
|
+
if (transcript.trim()) {
|
|
140
|
+
finalTranscript = transcript
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
recognition.addEventListener('end', () => {
|
|
145
|
+
stopRecording()
|
|
146
|
+
// 确保在录音结束时发送消息
|
|
147
|
+
if (finalTranscript.trim()) {
|
|
148
|
+
sendMessage(finalTranscript)
|
|
149
|
+
finalTranscript = ''
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
recognition.addEventListener('error', (event: SpeechRecognitionErrorEvent) => {
|
|
154
|
+
handleRecognitionError(event)
|
|
155
|
+
finalTranscript = ''
|
|
156
|
+
})
|
|
157
|
+
} else {
|
|
158
|
+
isSupported.value = false
|
|
159
|
+
TinyNotify({
|
|
160
|
+
type: 'error',
|
|
161
|
+
title: '提示',
|
|
162
|
+
message: '您的浏览器不支持语音识别,请使用Chrome、Safari或Edge浏览器',
|
|
163
|
+
position: 'top-right',
|
|
164
|
+
duration: 5000
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 错误处理
|
|
170
|
+
const handleRecognitionError = (event: SpeechRecognitionErrorEvent) => {
|
|
171
|
+
stopRecording()
|
|
172
|
+
let errorMessage = '语音识别出错'
|
|
173
|
+
|
|
174
|
+
switch (event.error) {
|
|
175
|
+
case 'not-allowed':
|
|
176
|
+
errorMessage = '请允许浏览器使用麦克风'
|
|
177
|
+
break
|
|
178
|
+
case 'no-speech':
|
|
179
|
+
errorMessage = '未检测到语音,请重试'
|
|
180
|
+
break
|
|
181
|
+
case 'network':
|
|
182
|
+
errorMessage = '网络连接出错,请检查网络后重试'
|
|
183
|
+
break
|
|
184
|
+
case 'aborted':
|
|
185
|
+
return // 用户主动取消,不显示错误
|
|
186
|
+
default:
|
|
187
|
+
errorMessage = `语音识别失败: ${event.message || '未知错误'}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
TinyNotify({
|
|
191
|
+
type: 'error',
|
|
192
|
+
title: '语音识别出错',
|
|
193
|
+
message: errorMessage,
|
|
194
|
+
position: 'top-right',
|
|
195
|
+
duration: 3000
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 开始录音
|
|
200
|
+
const handleStart = async (event: TouchEvent) => {
|
|
201
|
+
event.preventDefault()
|
|
202
|
+
event.stopPropagation()
|
|
203
|
+
|
|
204
|
+
if (!isSupported.value) {
|
|
205
|
+
TinyNotify({
|
|
206
|
+
type: 'error',
|
|
207
|
+
title: '提示',
|
|
208
|
+
message: '当前浏览器不支持语音识别',
|
|
209
|
+
position: 'top-right',
|
|
210
|
+
duration: 3000
|
|
211
|
+
})
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isTalk.value) return
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
recognition.start()
|
|
219
|
+
isTalk.value = true
|
|
220
|
+
recordingTime.value = 0
|
|
221
|
+
startRecordingTimer()
|
|
222
|
+
} catch (error) {
|
|
223
|
+
handleRecognitionError({
|
|
224
|
+
error: 'start_error',
|
|
225
|
+
message: '启动语音识别失败,请重试'
|
|
226
|
+
} as SpeechRecognitionErrorEvent)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 结束录音
|
|
231
|
+
const handleEnd = () => {
|
|
232
|
+
if (!isTalk.value) return
|
|
233
|
+
try {
|
|
234
|
+
recognition.stop()
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.error('停止录音失败:', error)
|
|
237
|
+
}
|
|
238
|
+
stopRecording()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 开始计时
|
|
242
|
+
const startRecordingTimer = () => {
|
|
243
|
+
recordingTimer = window.setInterval(() => {
|
|
244
|
+
recordingTime.value++
|
|
245
|
+
if (recordingTime.value >= maxRecordingTime) {
|
|
246
|
+
handleEnd()
|
|
247
|
+
TinyNotify({
|
|
248
|
+
type: 'warning',
|
|
249
|
+
title: '提示',
|
|
250
|
+
message: '已达到最大录音时长',
|
|
251
|
+
position: 'top-right',
|
|
252
|
+
duration: 3000
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}, 1000)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 停止录音
|
|
259
|
+
const stopRecording = () => {
|
|
260
|
+
isTalk.value = false
|
|
261
|
+
if (recordingTimer) {
|
|
262
|
+
clearInterval(recordingTimer)
|
|
263
|
+
recordingTimer = null
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 初始化
|
|
268
|
+
initSpeechRecognition()
|
|
269
|
+
|
|
270
|
+
// 组件卸载时清理
|
|
271
|
+
onUnmounted(() => {
|
|
272
|
+
if (recordingTimer) {
|
|
273
|
+
clearInterval(recordingTimer)
|
|
274
|
+
}
|
|
275
|
+
if (recognition) {
|
|
276
|
+
try {
|
|
277
|
+
recognition.abort()
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error('清理语音识别失败:', error)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
</script>
|
|
284
|
+
|
|
285
|
+
<style scoped lang="less">
|
|
286
|
+
.sound-container {
|
|
287
|
+
display: flex;
|
|
288
|
+
flex-direction: column;
|
|
289
|
+
height: 100vh;
|
|
290
|
+
position: relative;
|
|
291
|
+
|
|
292
|
+
.messages-container {
|
|
293
|
+
flex: 1;
|
|
294
|
+
overflow-y: auto;
|
|
295
|
+
padding: 16px;
|
|
296
|
+
margin-bottom: 120px;
|
|
297
|
+
|
|
298
|
+
.empty-message {
|
|
299
|
+
text-align: center;
|
|
300
|
+
color: #999;
|
|
301
|
+
padding: 20px;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.sound-box {
|
|
306
|
+
position: fixed;
|
|
307
|
+
bottom: 0;
|
|
308
|
+
left: 0;
|
|
309
|
+
right: 0;
|
|
310
|
+
margin: 10px;
|
|
311
|
+
background: white;
|
|
312
|
+
padding: 10px;
|
|
313
|
+
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
|
314
|
+
|
|
315
|
+
.recording-status {
|
|
316
|
+
text-align: center;
|
|
317
|
+
margin-bottom: 10px;
|
|
318
|
+
|
|
319
|
+
.wave-animation {
|
|
320
|
+
width: 100px;
|
|
321
|
+
height: 20px;
|
|
322
|
+
margin: 0 auto 5px;
|
|
323
|
+
background: linear-gradient(90deg, #ff4d4f 25%, #ff7875 50%, #ff4d4f 75%);
|
|
324
|
+
background-size: 200% 100%;
|
|
325
|
+
animation: wave 2s linear infinite;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.talk-button {
|
|
330
|
+
width: 100%;
|
|
331
|
+
margin: 20px 0;
|
|
332
|
+
transition: all 0.3s;
|
|
333
|
+
|
|
334
|
+
&:active {
|
|
335
|
+
transform: scale(0.98);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@keyframes wave {
|
|
342
|
+
0% {
|
|
343
|
+
background-position: 100% 50%;
|
|
344
|
+
}
|
|
345
|
+
100% {
|
|
346
|
+
background-position: 0% 50%;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
</style>
|