byt-lingxiao-ai 0.1.6 → 0.1.8
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/ChatWindow.vue +331 -16
- package/components/assets/byt.mp3 +0 -0
- package/components/assets/entering.png +0 -0
- package/components/assets/speaking.png +0 -0
- package/components/assets/waiting.png +0 -0
- package/components/index.js +3 -1
- package/dist/img/entering.42f05909.png +0 -0
- package/dist/img/speaking.3ce8b666.png +0 -0
- package/dist/img/waiting.ac21d76e.png +0 -0
- package/dist/index.common.js +1882 -47
- package/dist/index.common.js.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.umd.js +1882 -47
- package/dist/index.umd.js.map +1 -1
- package/dist/index.umd.min.js +1 -1
- package/dist/index.umd.min.js.map +1 -1
- package/dist/media/byt.59a033e9.mp3 +0 -0
- package/package.json +3 -2
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="chat">
|
|
3
|
-
|
|
3
|
+
<!-- 隐藏的音频播放器 -->
|
|
4
|
+
<audio
|
|
5
|
+
ref="audioPlayer"
|
|
6
|
+
class="hidden-audio"
|
|
7
|
+
@timeupdate="onTimeUpdate"
|
|
8
|
+
@ended="onAudioEnded"
|
|
9
|
+
>
|
|
10
|
+
<source :src="audioSrc" type="audio/mp3">
|
|
11
|
+
您的浏览器不支持音频元素。
|
|
12
|
+
</audio>
|
|
13
|
+
<div v-if="robotStatus !== 'leaving'" :class="['chat-robot', robotStatus]" ></div>
|
|
14
|
+
<div v-else class="chat-ai" @click="toggleWindow">
|
|
4
15
|
<div class="chat-ai-avater"></div>
|
|
5
16
|
<div class="chat-ai-text">凌霄AI</div>
|
|
6
17
|
</div>
|
|
@@ -63,7 +74,7 @@
|
|
|
63
74
|
@keydown="handleKeyDown"
|
|
64
75
|
></el-input>
|
|
65
76
|
<div class="chat-window-bar">
|
|
66
|
-
<div class="chat-window-send">
|
|
77
|
+
<div class="chat-window-send" @click="handleSend">
|
|
67
78
|
<svg
|
|
68
79
|
xmlns="http://www.w3.org/2000/svg"
|
|
69
80
|
width="20"
|
|
@@ -100,22 +111,22 @@
|
|
|
100
111
|
</div>
|
|
101
112
|
</template>
|
|
102
113
|
<script>
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
const SAMPLE_RATE = 16000;
|
|
115
|
+
const FRAME_SIZE = 512;
|
|
116
|
+
import audioSrc from './assets/byt.mp3'
|
|
105
117
|
export default {
|
|
106
118
|
name: 'ChatWindow',
|
|
107
119
|
props: {
|
|
108
120
|
appendToBody: {
|
|
109
121
|
type: Boolean,
|
|
110
122
|
default: true,
|
|
111
|
-
}
|
|
123
|
+
}
|
|
112
124
|
},
|
|
113
125
|
data() {
|
|
114
126
|
return {
|
|
115
|
-
|
|
116
|
-
inputMessage: '',
|
|
117
|
-
visible: false,
|
|
118
|
-
// ... existing code ...
|
|
127
|
+
audioSrc, // 音频URL
|
|
128
|
+
inputMessage: '', // 输入消息
|
|
129
|
+
visible: false, // 窗口是否可见
|
|
119
130
|
messages: [
|
|
120
131
|
{
|
|
121
132
|
id: 1,
|
|
@@ -145,27 +156,297 @@ export default {
|
|
|
145
156
|
time: '',
|
|
146
157
|
content: '你好,欢迎来到凌霄大模型AI对话。',
|
|
147
158
|
},
|
|
148
|
-
],
|
|
159
|
+
], // 消息列表
|
|
160
|
+
isRecording: false, // 正在录制
|
|
161
|
+
isMicAvailable: false, // 麦克风是否可用
|
|
162
|
+
mediaRecorder: null, // 媒体录制器
|
|
163
|
+
ws: null, // WebSocket连接
|
|
164
|
+
// wsUrl: 'wss://mes.shnfonline.top:8316/ai_model/ws/voice-stream', // WebSocket URL
|
|
165
|
+
wsUrl: 'ws://192.168.8.9:9999/ai_model/ws/voice-stream', // WebSocket URL
|
|
166
|
+
isConnected: false, // WebSocket是否已连接
|
|
167
|
+
audioContext: null, // 音频上下文
|
|
168
|
+
microphone: null, // 麦克风输入节点
|
|
169
|
+
processor: null, // 音频处理节点
|
|
170
|
+
robotStatus: 'leaving', // 机器人状态 waiting, speaking, leaving, entering
|
|
171
|
+
audioBuffer: new Float32Array(0) // 音频缓冲区
|
|
149
172
|
}
|
|
150
173
|
},
|
|
151
174
|
mounted() {
|
|
152
|
-
this.
|
|
175
|
+
this.initWebSocket()
|
|
153
176
|
|
|
154
177
|
// 处理append-to-body逻辑
|
|
155
178
|
if (this.appendToBody) {
|
|
156
179
|
this.appendToBodyHandler()
|
|
157
180
|
}
|
|
158
181
|
},
|
|
182
|
+
unmounted() {
|
|
183
|
+
this.closeWebSocket()
|
|
184
|
+
this.stopRecording()
|
|
185
|
+
},
|
|
159
186
|
beforeDestroy() {
|
|
160
187
|
// 组件销毁前,如果元素被移动到body中,需要移除
|
|
161
188
|
if (this.appendToBody && this.$el.parentElement === document.body) {
|
|
162
189
|
document.body.removeChild(this.$el)
|
|
163
190
|
}
|
|
191
|
+
this.closeWebSocket()
|
|
192
|
+
this.stopRecording()
|
|
164
193
|
},
|
|
165
194
|
methods: {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
195
|
+
// 处理音频播放结束事件
|
|
196
|
+
onAudioEnded() {
|
|
197
|
+
this.robotStatus = 'leaving'
|
|
198
|
+
},
|
|
199
|
+
// 处理音频播放时间更新事件
|
|
200
|
+
onTimeUpdate() {
|
|
201
|
+
const audio = this.$refs.audioPlayer
|
|
202
|
+
const currentTime = audio.currentTime
|
|
203
|
+
|
|
204
|
+
if (!this.jumpedTimePoints) {
|
|
205
|
+
this.jumpedTimePoints = new Set()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const timeJumpPoints = [
|
|
209
|
+
{ time: 30, url: '', title: '生产管理' },
|
|
210
|
+
{ time: 50, url: '', title: '生产信息总览' },
|
|
211
|
+
{ time: 60, url: '', title: '能源管理' },
|
|
212
|
+
{ time: 70, url: '', title: '设备管理' }
|
|
213
|
+
]
|
|
214
|
+
// 检查当前时间是否达到跳转点
|
|
215
|
+
timeJumpPoints.forEach(point => {
|
|
216
|
+
// 使用一定的误差范围,确保不会因为播放进度的微小差异而错过跳转点
|
|
217
|
+
if (currentTime >= point.time && currentTime < point.time + 0.5 && !this.jumpedTimePoints.has(point.time)) {
|
|
218
|
+
this.jumpedTimePoints.add(point.time)
|
|
219
|
+
console.log(`到达时间点 ${point.time} 秒,跳转到 ${point.title}`)
|
|
220
|
+
this.$appOptions.store.dispatch('tags/addTagview', {
|
|
221
|
+
path: point.url,
|
|
222
|
+
name: point.title,
|
|
223
|
+
meta: {
|
|
224
|
+
title: point.title
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
this.$appOptions.router.push({
|
|
228
|
+
path: point.url
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
console.log('当前播放时间:', currentTime)
|
|
234
|
+
},
|
|
235
|
+
play() {
|
|
236
|
+
this.robotStatus = 'speaking'
|
|
237
|
+
this.$refs.audioPlayer.play()
|
|
238
|
+
},
|
|
239
|
+
pause() {
|
|
240
|
+
this.robotStatus = 'waiting'
|
|
241
|
+
this.$refs.audioPlayer.pause()
|
|
242
|
+
},
|
|
243
|
+
stop() {
|
|
244
|
+
this.robotStatus = 'leaving'
|
|
245
|
+
this.$refs.audioPlayer.pause()
|
|
246
|
+
this.$refs.audioPlayer.currentTime = 0
|
|
247
|
+
},
|
|
248
|
+
initWebSocket() {
|
|
249
|
+
try {
|
|
250
|
+
this.ws = new WebSocket(this.wsUrl)
|
|
251
|
+
this.ws.binaryType = 'arraybuffer'
|
|
252
|
+
|
|
253
|
+
this.ws.onopen = async () => {
|
|
254
|
+
console.log('连接成功')
|
|
255
|
+
this.isConnected = true
|
|
256
|
+
this.initAudio()
|
|
257
|
+
}
|
|
258
|
+
this.ws.onmessage = (event) => {
|
|
259
|
+
try {
|
|
260
|
+
console.log("收到原始消息:", event.data);
|
|
261
|
+
// 二进制数据直接返回
|
|
262
|
+
if (event.data instanceof ArrayBuffer) {
|
|
263
|
+
console.log("收到二进制音频数据");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// 解析JSON数据
|
|
267
|
+
const data = JSON.parse(event.data);
|
|
268
|
+
console.log("解析后的数据:", data);
|
|
269
|
+
|
|
270
|
+
if (data.type === 'config'){
|
|
271
|
+
console.log('配置信息:', data);
|
|
272
|
+
} else if (data.code === 0) {
|
|
273
|
+
if (data.data.type === 'detection') {
|
|
274
|
+
console.log('检测到唤醒词...');
|
|
275
|
+
} else if (data.data.type === 'Collecting') {
|
|
276
|
+
console.log('状态: 采集中...');
|
|
277
|
+
} else if (data.data.type === 'command') {
|
|
278
|
+
// 根据指令改变机器人的状态
|
|
279
|
+
console.log('状态: 处理中...')
|
|
280
|
+
this.analyzeAudioCommand(data.data.category)
|
|
281
|
+
} else {
|
|
282
|
+
console.log('状态: 其他...')
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
console.error("服务器返回错误:", data.msg);
|
|
286
|
+
}
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error("消息解析错误:", error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
this.ws.onclose = () => {
|
|
292
|
+
console.log('连接关闭')
|
|
293
|
+
this.isConnected = false
|
|
294
|
+
if (this.isRecording) {
|
|
295
|
+
this.stopRecording()
|
|
296
|
+
}
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
console.log('尝试重新建立连接')
|
|
299
|
+
if (!this.isConnected) {
|
|
300
|
+
// 尝试重连
|
|
301
|
+
this.initWebSocket()
|
|
302
|
+
}
|
|
303
|
+
}, 3000)
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error('WebSocket连接失败:', error);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
closeWebSocket() {
|
|
310
|
+
if (this.ws) {
|
|
311
|
+
this.ws.close()
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
async initAudio() {
|
|
315
|
+
if (this.isRecording) return;
|
|
316
|
+
try {
|
|
317
|
+
this.isMicAvailable = true;
|
|
318
|
+
// 2. 获取麦克风权限
|
|
319
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
320
|
+
audio: {
|
|
321
|
+
sampleRate: SAMPLE_RATE, // 请求指定采样率
|
|
322
|
+
channelCount: 1, // 单声道
|
|
323
|
+
noiseSuppression: true,
|
|
324
|
+
echoCancellation: true
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// 3. 创建音频处理环境
|
|
329
|
+
this.audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
|
330
|
+
const actualSampleRate = this.audioContext.sampleRate;
|
|
331
|
+
this.microphone = this.audioContext.createMediaStreamSource(stream);
|
|
332
|
+
|
|
333
|
+
// 4. 创建音频处理器
|
|
334
|
+
this.processor = this.audioContext.createScriptProcessor(FRAME_SIZE, 1, 1);
|
|
335
|
+
this.processor.onaudioprocess = this.processAudio;
|
|
336
|
+
// 连接处理链
|
|
337
|
+
this.microphone.connect(this.processor);
|
|
338
|
+
this.processor.connect(this.audioContext.destination);
|
|
339
|
+
|
|
340
|
+
this.isRecording = true;
|
|
341
|
+
|
|
342
|
+
console.log(`状态: 录音中 (采样率: ${actualSampleRate}Hz)`)
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error("音频初始化失败:", error);
|
|
345
|
+
this.isRecording = false
|
|
346
|
+
this.isMicAvailable = false
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
async handleSend() {
|
|
350
|
+
if (!this.inputMessage.trim()) {
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
const message = this.inputMessage.trim();
|
|
354
|
+
// 发送消息
|
|
355
|
+
this.messages.push({
|
|
356
|
+
id: this.messages.length + 1,
|
|
357
|
+
type: 'user',
|
|
358
|
+
sender: '用户',
|
|
359
|
+
time: new Date().toLocaleTimeString(),
|
|
360
|
+
content: this.inputMessage,
|
|
361
|
+
})
|
|
362
|
+
this.inputMessage = ''
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const token = `Bearer 24ab99b4-4b59-42a0-84df-1d73a96e70cd`
|
|
366
|
+
const response = await fetch('/bytserver/api-model/chat/stream', {
|
|
367
|
+
timeout: 30000,
|
|
368
|
+
method: 'POST',
|
|
369
|
+
headers: {
|
|
370
|
+
'Content-Type': 'application/json' ,
|
|
371
|
+
'Authorization': token,
|
|
372
|
+
},
|
|
373
|
+
body: JSON.stringify({ content: message })
|
|
374
|
+
});
|
|
375
|
+
if (!response.ok) {
|
|
376
|
+
throw new Error(`${response.status}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.log('响应状态:', response.status);
|
|
380
|
+
// 解析响应流
|
|
381
|
+
this.parseResponseStream(response.body)
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error('发送消息失败:', error)
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
// 分析音频命令
|
|
387
|
+
analyzeAudioCommand(command) {
|
|
388
|
+
console.log('分析音频命令:', command)
|
|
389
|
+
// 解析到开始导览,执行机器人进入动画
|
|
390
|
+
if (command === 'C5') {
|
|
391
|
+
this.robotStatus = 'entering'
|
|
392
|
+
setTimeout(() => {
|
|
393
|
+
this.robotStatus = 'speaking'
|
|
394
|
+
this.play()
|
|
395
|
+
}, 3000)
|
|
396
|
+
}
|
|
397
|
+
// 继续导览
|
|
398
|
+
else if (command === 'C8') {
|
|
399
|
+
this.robotStatus = 'speaking'
|
|
400
|
+
this.play()
|
|
401
|
+
}
|
|
402
|
+
// 解析到暂停导览,执行机器人暂停动画
|
|
403
|
+
else if (command === 'C7') {
|
|
404
|
+
this.robotStatus = 'waiting'
|
|
405
|
+
this.pause()
|
|
406
|
+
}
|
|
407
|
+
// 解析到结束导览,执行机器人离开动画
|
|
408
|
+
else if (command === 'C6') {
|
|
409
|
+
this.robotStatus = 'leaving'
|
|
410
|
+
this.stop()
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
// 解析响应流
|
|
414
|
+
parseResponseStream(body) {
|
|
415
|
+
console.log(body)
|
|
416
|
+
},
|
|
417
|
+
processAudio(event) {
|
|
418
|
+
if (!this.isRecording) return;
|
|
419
|
+
// 5. 获取音频数据并处理
|
|
420
|
+
const inputData = event.inputBuffer.getChannelData(0);
|
|
421
|
+
|
|
422
|
+
// 累积音频数据
|
|
423
|
+
const tempBuffer = new Float32Array(this.audioBuffer.length + inputData.length);
|
|
424
|
+
tempBuffer.set(this.audioBuffer, 0);
|
|
425
|
+
tempBuffer.set(inputData, this.audioBuffer.length);
|
|
426
|
+
this.audioBuffer = tempBuffer;
|
|
427
|
+
|
|
428
|
+
// 当累积足够一帧时发送
|
|
429
|
+
while (this.audioBuffer.length >= FRAME_SIZE) {
|
|
430
|
+
const frame = this.audioBuffer.slice(0, FRAME_SIZE);
|
|
431
|
+
this.audioBuffer = this.audioBuffer.slice(FRAME_SIZE);
|
|
432
|
+
|
|
433
|
+
// 转换为16位PCM
|
|
434
|
+
const pcmData = this.floatTo16BitPCM(frame);
|
|
435
|
+
|
|
436
|
+
// 通过WebSocket发送
|
|
437
|
+
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
|
438
|
+
this.ws.send(pcmData);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
floatTo16BitPCM(input) {
|
|
443
|
+
const output = new Int16Array(input.length);
|
|
444
|
+
for (let i = 0; i < input.length; i++) {
|
|
445
|
+
const s = Math.max(-1, Math.min(1, input[i]));
|
|
446
|
+
output[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
|
447
|
+
}
|
|
448
|
+
return output.buffer;
|
|
449
|
+
},
|
|
169
450
|
toggleWindow() {
|
|
170
451
|
this.visible = !this.visible
|
|
171
452
|
},
|
|
@@ -189,6 +470,18 @@ export default {
|
|
|
189
470
|
this.handleSend()
|
|
190
471
|
}
|
|
191
472
|
},
|
|
473
|
+
stopRecording() {
|
|
474
|
+
if (!this.isRecording) return;
|
|
475
|
+
if (this.microphone) this.microphone.disconnect();
|
|
476
|
+
if (this.processor) this.processor.disconnect();
|
|
477
|
+
if (this.analyser) this.analyser.disconnect();
|
|
478
|
+
if (this.audioContext) {
|
|
479
|
+
this.audioContext.close().catch(e => {
|
|
480
|
+
console.error("关闭音频上下文失败:", e);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
this.isRecording = false;
|
|
484
|
+
},
|
|
192
485
|
// 添加到body的处理函数
|
|
193
486
|
appendToBodyHandler() {
|
|
194
487
|
// 确保DOM已经渲染完成
|
|
@@ -216,6 +509,12 @@ export default {
|
|
|
216
509
|
}
|
|
217
510
|
</script>
|
|
218
511
|
<style scoped>
|
|
512
|
+
.hidden-audio {
|
|
513
|
+
position: absolute;
|
|
514
|
+
left: -9999px;
|
|
515
|
+
opacity: 0;
|
|
516
|
+
pointer-events: none;
|
|
517
|
+
}
|
|
219
518
|
.chat-overlay {
|
|
220
519
|
position: fixed;
|
|
221
520
|
top: 0;
|
|
@@ -268,6 +567,22 @@ export default {
|
|
|
268
567
|
cursor: pointer;
|
|
269
568
|
user-select: none;
|
|
270
569
|
}
|
|
570
|
+
.chat-robot {
|
|
571
|
+
width: 150px;
|
|
572
|
+
height: 200px;
|
|
573
|
+
}
|
|
574
|
+
.chat-robot.entering {
|
|
575
|
+
background-image: url('./assets/entering.png');
|
|
576
|
+
background-size: cover;
|
|
577
|
+
}
|
|
578
|
+
.chat-robot.waiting {
|
|
579
|
+
background-image: url('./assets/waiting.png');
|
|
580
|
+
background-size: cover;
|
|
581
|
+
}
|
|
582
|
+
.chat-robot.speaking {
|
|
583
|
+
background-image: url('./assets/speaking.png');
|
|
584
|
+
background-size: cover;
|
|
585
|
+
}
|
|
271
586
|
.chat-ai-avater {
|
|
272
587
|
border-radius: 67px;
|
|
273
588
|
border: 1px solid #0f66e4;
|
|
@@ -333,8 +648,8 @@ export default {
|
|
|
333
648
|
background: #fff;
|
|
334
649
|
box-shadow: 0 2px 20px 0 rgba(0, 0, 0, 0.16);
|
|
335
650
|
position: absolute;
|
|
336
|
-
bottom:
|
|
337
|
-
right:
|
|
651
|
+
bottom: 20px;
|
|
652
|
+
right: 60px;
|
|
338
653
|
overflow: hidden;
|
|
339
654
|
display: flex;
|
|
340
655
|
flex-direction: column;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/components/index.js
CHANGED
|
@@ -4,10 +4,12 @@ const components = {
|
|
|
4
4
|
ChatWindow,
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
-
const install = function(Vue) {
|
|
7
|
+
const install = function(Vue, options = {}) {
|
|
8
8
|
if (install.installed) return;
|
|
9
9
|
install.installed = true;
|
|
10
10
|
|
|
11
|
+
Vue.prototype.$appOptions = options;
|
|
12
|
+
|
|
11
13
|
Object.keys(components).forEach(name => {
|
|
12
14
|
Vue.component(name, components[name]);
|
|
13
15
|
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|