byt-lingxiao-ai 0.3.28 → 0.3.30

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.
@@ -0,0 +1,60 @@
1
+ <template>
2
+ <div v-if="currentSubtitle" class="subtitle-container">
3
+ <div class="subtitle-text">
4
+ <div class="subtitle-left"></div>
5
+ {{ currentSubtitle }}
6
+ <div class="subtitle-right"></div>
7
+ </div>
8
+ </div>
9
+ </template>
10
+ <script>
11
+ export default {
12
+ props: {
13
+ currentSubtitle: {
14
+ type: String,
15
+ default: ''
16
+ }
17
+ }
18
+ }
19
+ </script>
20
+ <style scoped>
21
+ .subtitle-container {
22
+ position: fixed;
23
+ left: 0;
24
+ bottom: 10px;
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ width: 100%;
29
+ }
30
+ .subtitle-left,
31
+ .subtitle-right {
32
+ width: 25px;
33
+ height: 16px;
34
+ background-size: cover;
35
+ background-position: center;
36
+ background-repeat: no-repeat;
37
+ }
38
+ .subtitle-left {
39
+ background-image: url('./assets/right.png');
40
+ }
41
+ .subtitle-right {
42
+ background-image: url('./assets/left.png');
43
+ }
44
+ .subtitle-text {
45
+ color: #FFF;
46
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.40);
47
+ font-family: "Alibaba PuHuiTi";
48
+ font-size: 24px;
49
+ font-style: normal;
50
+ font-weight: 700;
51
+ line-height: 20px;
52
+ border-radius: 8px;
53
+ border: 1px solid #FFF;
54
+ background: rgba(1, 51, 120, 0.40);
55
+ padding: 15px 20px;
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 40px;
59
+ }
60
+ </style>
@@ -6,13 +6,13 @@
6
6
  <!-- 隐藏的音频播放器 -->
7
7
  <audio
8
8
  ref="audioPlayer"
9
+ :src="audioSrc"
9
10
  class="hidden-audio"
11
+ controls
12
+ preload="auto"
10
13
  @timeupdate="onTimeUpdate"
11
14
  @ended="onAudioEnded"
12
- >
13
- <source :src="audioSrc" type="audio/mpeg">
14
- 您的浏览器不支持音频元素。
15
- </audio>
15
+ />
16
16
 
17
17
  <!-- 机器人动画 -->
18
18
  <ChatRobot
@@ -24,7 +24,7 @@
24
24
  <ChatAvatar
25
25
  v-else
26
26
  :status="avaterStatus"
27
- @mousedown="startDrag"
27
+ @mousedown="startDrag($event)"
28
28
  />
29
29
 
30
30
  <!-- 聊天窗口 -->
@@ -40,6 +40,11 @@
40
40
  @thinking-click="handleThinkingClick"
41
41
  @overlay-click="handleOverlayClick"
42
42
  />
43
+ <!-- 字幕显示区域 -->
44
+ <AudioSubtitle
45
+ ref="audioSubtitle"
46
+ :current-subtitle="currentSubtitle"
47
+ />
43
48
  </div>
44
49
  </template>
45
50
 
@@ -47,22 +52,35 @@
47
52
  import ChatRobot from './ChatRobot.vue'
48
53
  import ChatAvatar from './ChatAvatar.vue'
49
54
  import ChatWindowDialog from './ChatWindowDialog.vue'
55
+ import AudioSubtitle from './AudioSubtitle.vue'
50
56
  import audioMixin from './mixins/audioMixin'
51
57
  import webSocketMixin from './mixins/webSocketMixin'
52
58
  import messageMixin from './mixins/messageMixin'
53
- import { AUDIO_URL, TIME_JUMP_POINTS_URL } from './config/index.js'
59
+ import { AUDIO_URL, COLLECTION, AREA_BOARD, ENTERPRISE_APPLICATION, REGIONAL_APPLICATION, TIME_JUMP_POINTS_URL, SUBTITLE_POINTS_URL } from './config/index.js'
54
60
  import generateUuid from './utils/Uuid.js'
61
+ import { getCookie } from './utils/Cookie'
62
+ import { BLACKLIST } from './config/blacklist.js'
55
63
 
56
64
  const SAMPLE_RATE = 16000;
57
65
  const FRAME_SIZE = 512;
58
- const startTime = null
66
+ const ROBOT_STATUS = {
67
+ ENTERING: 'entering', // 进入中
68
+ SPEAKING: 'speaking', // 说话中
69
+ WAITING: 'waiting', // 等待中
70
+ LEAVING: 'leaving' // 离开中
71
+ }
72
+ const AVATAR_STATUS = {
73
+ NORMAL: 'normal', // 正常
74
+ THINKING: 'thinking' // 思考中
75
+ }
59
76
 
60
77
  export default {
61
78
  name: 'ChatWindow',
62
79
  components: {
63
80
  ChatRobot,
64
81
  ChatAvatar,
65
- ChatWindowDialog
82
+ ChatWindowDialog,
83
+ AudioSubtitle
66
84
  },
67
85
  mixins: [audioMixin, webSocketMixin, messageMixin],
68
86
  props: {
@@ -73,30 +91,37 @@ export default {
73
91
  },
74
92
  data() {
75
93
  return {
76
- chatId: generateUuid(),
77
- audioSrc: AUDIO_URL,
78
- inputMessage: '',
79
- visible: false,
80
- messages: [],
81
- messageLoading: true,
82
- robotStatus: 'leaving',
83
- avaterStatus: 'normal',
84
- currentMessage: null,
85
- thinkStatus: true,
86
- jumpedTimePoints: new Set(),
87
- SAMPLE_RATE,
88
- FRAME_SIZE,
89
- startTime, // 检查性能使用
94
+ lastTimeUpdate: null, // 上次更新时间
95
+ chatId: generateUuid(), // 唯一标识当前聊天会话
96
+ audioSrc: AUDIO_URL, // 音频文件URL
97
+ inputMessage: '', // 当前输入的消息
98
+ visible: false, // 是否显示聊天窗口
99
+ messages: [], // 聊天消息数组
100
+ messageLoading: true, // 是否正在加载消息
101
+ robotStatus: 'leaving', // 机器人状态 entering: 进入中, waiting: 等待中, speaking: 说话中, leaving: 离开中
102
+ avaterStatus: 'normal', // 头像状态 normal: 正常, thinking: 思考中
103
+ currentMessage: null, // 当前正在处理的消息
104
+ thinkStatus: true, // 是否思考中
105
+ jumpedTimePoints: new Set(), // 已跳转的时间点集合
106
+ SAMPLE_RATE, // 采样率 16000Hz
107
+ FRAME_SIZE, // 帧大小 512
90
108
  dragThreshold: 5, // 拖拽阈值
91
- isDragging: false,
92
- dragStartX: 0,
93
- dragStartY: 0,
94
- currentX: 10,
95
- currentY: 20,
96
- initialX: 10,
97
- initialY: 20,
98
- hasMoved: false,
99
- timeJumpPoints: []
109
+ isDragging: false, // 是否正在拖拽
110
+ dragStartX: 0, // 拖拽开始时的X坐标
111
+ dragStartY: 0, // 拖拽开始时的Y坐标
112
+ currentX: 10, // 当前X坐标
113
+ currentY: 20, // 当前Y坐标
114
+ initialX: 10, // 初始X坐标
115
+ initialY: 20, // 初始Y坐标
116
+ hasMoved: false, // 是否已移动
117
+ timeJumpPoints: null, // 跳转
118
+ subTitlePoints: null, // 字幕
119
+ currentJumpPoints: [], // 当前跳转点数组
120
+ currentSubtitles: [], // 当前字幕数组
121
+ currentSubtitle: '', // 当前字幕,
122
+ jumpIndex: 0, // 当前跳转索引
123
+ subtitleIndex: 0, // 当前字幕索引
124
+ isTourRunning: false, // 是否正在导览
100
125
  }
101
126
  },
102
127
  computed: {
@@ -114,13 +139,16 @@ export default {
114
139
  };
115
140
  }
116
141
  },
117
- mounted() {
142
+ async mounted() {
118
143
  this.initWebSocket()
119
144
  if (this.appendToBody) {
120
145
  this.appendToBodyHandler()
121
146
  }
122
147
 
123
- this.fetchTimeJumpPoints()
148
+ await Promise.all([
149
+ this.fetchTimeJumpPoints(),
150
+ this.fetchSubTitlePoints()
151
+ ])
124
152
 
125
153
  this.$nextTick(() => {
126
154
  const chatEl = this.$el;
@@ -141,17 +169,30 @@ export default {
141
169
  document.removeEventListener('mouseup', this.stopDrag)
142
170
  },
143
171
  methods: {
172
+ initGuide() {
173
+ this.jumpIndex = 0
174
+ this.subtitleIndex = 0
175
+
176
+ this.jumpedTimePoints.clear()
177
+
178
+ this.setRobotStatus(ROBOT_STATUS.LEAVING)
179
+ this.setAvatarStatus(AVATAR_STATUS.NORMAL)
180
+ },
181
+ setRobotStatus(status) {
182
+ this.robotStatus = status || ROBOT_STATUS.LEAVING
183
+ },
184
+ setAvatarStatus(status) {
185
+ this.avaterStatus = status || AVATAR_STATUS.NORMAL
186
+ },
144
187
  toggleWindow() {
145
- if (this.avaterStatus === 'thinking') return;
146
188
  this.visible = !this.visible
147
189
 
148
190
  if (this.visible) {
149
191
  this.currentX = this.initialX
150
192
  this.currentY = this.initialY
151
193
  }
152
- },
153
- startDrag() {
154
- console.log('startDrag')
194
+ },
195
+ startDrag(event) {
155
196
  if (this.robotStatus !== 'leaving' && this.visible) return;
156
197
 
157
198
  this.isDragging = true;
@@ -208,9 +249,18 @@ export default {
208
249
  document.removeEventListener('mousemove', this.onDrag);
209
250
  document.removeEventListener('mouseup', this.stopDrag);
210
251
  if (!this.hasMoved) {
211
- this.toggleWindow()
252
+ this.handleClick()
212
253
  }
213
254
  },
255
+ handleClick() {
256
+ if (this.avaterStatus === 'thinking') return
257
+ if (this.isInBlacklist()) return
258
+ this.toggleWindow()
259
+ },
260
+ isInBlacklist() {
261
+ const tenantId = getCookie('bonyear-tenantId')
262
+ return tenantId && BLACKLIST.includes(tenantId)
263
+ },
214
264
  handleThinkingClick() {
215
265
  this.thinkStatus = !this.thinkStatus
216
266
  },
@@ -226,49 +276,292 @@ export default {
226
276
  },
227
277
  async fetchTimeJumpPoints() {
228
278
  try {
229
- const res = await fetch(TIME_JUMP_POINTS_URL)
279
+ const res = await fetch(TIME_JUMP_POINTS_URL + '?timestamp=' + Date.now())
230
280
  const data = await res.json()
231
- this.timeJumpPoints = Array.isArray(data) ? data : []
232
- console.log('时间跳转点加载完成:', this.timeJumpPoints)
281
+ this.timeJumpPoints = data
233
282
  } catch (err) {
234
283
  console.error('获取时间跳转点失败:', err)
235
- this.timeJumpPoints = []
284
+ this.timeJumpPoints = null
285
+ }
286
+ },
287
+ async fetchSubTitlePoints() {
288
+ try {
289
+ const res = await fetch(SUBTITLE_POINTS_URL + '?timestamp=' + Date.now())
290
+ const data = await res.json()
291
+ this.subTitlePoints = data
292
+ } catch (err) {
293
+ console.error('获取字幕跳转点失败')
294
+ this.subTitlePoints = null
295
+ }
296
+ },
297
+ normalizeCommand(cmd = '') {
298
+ return cmd
299
+ .trim() // 去掉 \n \r 空格
300
+ .replace(/[\u200B-\u200D]/g,'') // 去零宽字符
301
+ .replace(/[。!?,、,.!?]/g, '') // 去标点
302
+ },
303
+ // 分析音频语音指令
304
+ analyzeVoiceCommand(name) {
305
+ console.log('===== analyzeVoiceCommand =====')
306
+
307
+ console.log('typeof name:', typeof name)
308
+ console.log('name === "结束导览":', name === '结束导览')
309
+ console.log('Object.prototype.toString:', Object.prototype.toString.call(name))
310
+
311
+ const normalized = this.normalizeCommand(name)
312
+ const commandMap = {
313
+ '开始导览': this.startTheTour,
314
+ '开始区域看板导览': this.startAreaBoardTour,
315
+ '开始企业应用导览': this.startEnterpriseApplicationTour,
316
+ '开始区域应用导览': this.startRegionalApplicationTour,
317
+ '暂停导览': this.pauseTheTour,
318
+ '继续导览': this.resumeTheTour,
319
+ '结束导览': this.offTheTour,
320
+ '返回首页': this.returnToHome,
321
+ '未知指令': this.offTheTour,
322
+ }
323
+
324
+ const handler = commandMap[normalized]
325
+
326
+ if (!handler) {
327
+ console.warn('未定义的指令:', name)
328
+ return
329
+ }
330
+
331
+ handler()
332
+ },
333
+ // 启动导览
334
+ async onTheTour() {
335
+ if (this.isTourRunning) return
336
+
337
+ this.isTourRunning = true
338
+
339
+ const audio = this.$refs.audioPlayer
340
+ if (!audio) return
341
+
342
+ this.setRobotStatus(ROBOT_STATUS.ENTERING)
343
+ await new Promise(resolve => setTimeout(resolve, 3000))
344
+
345
+ this.setRobotStatus(ROBOT_STATUS.SPEAKING)
346
+ try {
347
+ await audio.play()
348
+ } catch (e) {
349
+ console.error('播放音频失败:', e)
350
+ } finally {
351
+ this.isTourRunning = false
352
+ }
353
+ },
354
+ // 暂停导览
355
+ pauseTheTour() {
356
+ this.pause()
357
+ this.setRobotStatus(ROBOT_STATUS.WAITING)
358
+ },
359
+ // 继续导览
360
+ resumeTheTour() {
361
+ this.play()
362
+ this.setRobotStatus(ROBOT_STATUS.SPEAKING)
363
+ },
364
+ // 结束导览
365
+ offTheTour() {
366
+ this.stop()
367
+ this.currentSubtitle = ''
368
+ this.setRobotStatus(ROBOT_STATUS.LEAVING)
369
+ this.setAvatarStatus(AVATAR_STATUS.NORMAL)
370
+ },
371
+ // 返回首页
372
+ returnToHome() {
373
+ this.setRobotStatus(ROBOT_STATUS.LEAVING)
374
+ this.setAvatarStatus(AVATAR_STATUS.NORMAL)
375
+ this.$appOptions.router.push({ path: '/' })
376
+ },
377
+ initJumpPoints(name) {
378
+ // 初始化跳转点
379
+ if (name) {
380
+ this.currentJumpPoints = this.timeJumpPoints[name] || []
381
+ console.log('currentJumpPoints:', this.currentJumpPoints)
382
+ } else {
383
+ this.currentJumpPoints = this.timeJumpPoints
384
+ }
385
+ },
386
+ initSubtitles(name) {
387
+ console.log('name:', name)
388
+ if (name) {
389
+ this.currentSubtitles = this.subTitlePoints[name] || []
390
+ } else {
391
+ this.currentSubtitles = this.subTitlePoints
392
+ }
393
+ },
394
+ // 开始导览
395
+ async startTheTour() {
396
+ await this.setAudio(COLLECTION) // 重置音频源
397
+
398
+ // 重置指针
399
+ this.initGuide()
400
+ // 重置跳转点指针
401
+ this.initJumpPoints('开始导览')
402
+ // 重置字幕指针
403
+ this.initSubtitles('开始导览')
404
+
405
+
406
+ await this.onTheTour()
407
+ },
408
+ // 开始区域看板导览
409
+ async startAreaBoardTour() {
410
+ // 重置audio
411
+ await this.setAudio(AREA_BOARD)
412
+
413
+ // 重置指针
414
+ this.initGuide()
415
+ // 重置跳转点指针
416
+ this.initJumpPoints('开始区域看板导览')
417
+ // 重置字幕指针
418
+ this.initSubtitles('开始区域看板导览')
419
+
420
+ await this.onTheTour()
421
+ },
422
+ // 开始企业应用导览
423
+ async startEnterpriseApplicationTour() {
424
+ await this.setAudio(ENTERPRISE_APPLICATION)
425
+
426
+ // 重置指针
427
+ this.initGuide()
428
+ // 重置跳转点指针
429
+ this.initJumpPoints('开始企业应用导览')
430
+ // 重置字幕指针
431
+ this.initSubtitles('开始企业应用导览')
432
+
433
+ await this.onTheTour()
434
+ },
435
+ // 开始区域应用导览
436
+ async startRegionalApplicationTour() {
437
+ await this.setAudio(REGIONAL_APPLICATION)
438
+
439
+ // 重置指针
440
+ this.initGuide()
441
+ // 重置跳转点指针
442
+ this.initJumpPoints('开始区域应用导览')
443
+ // 重置字幕指针
444
+ this.initSubtitles('开始区域应用导览')
445
+
446
+ await this.onTheTour()
447
+ },
448
+ // 分析音频命令
449
+ analyzeAudioCommand(command) {
450
+ console.log('分析音频命令:', command);
451
+
452
+ if (command === 'C5') {
453
+ this.setRobotStatus(ROBOT_STATUS.ENTERING);
454
+ setTimeout(() => {
455
+ this.setRobotStatus(ROBOT_STATUS.SPEAKING);
456
+ this.play();
457
+ }, 3000);
458
+ }
459
+
460
+ if (command === 'C8') {
461
+ this.setRobotStatus(ROBOT_STATUS.SPEAKING);
462
+ this.play();
463
+ }
464
+
465
+ if (command === 'C7') {
466
+ this.setRobotStatus(ROBOT_STATUS.WAITING);
467
+ this.pause();
468
+ }
469
+
470
+ if (command === 'C6') {
471
+ this.setRobotStatus(ROBOT_STATUS.LEAVING);
472
+ this.stop();
473
+ }
474
+ },
475
+ // 处理点击跳转时间
476
+ handleJumpPoint(currentTime) {
477
+ console.log('jumpIndex:', this.jumpIndex)
478
+ const point = this.currentJumpPoints[this.jumpIndex]
479
+
480
+ if (!point) return
481
+ console.log('currentTime:', currentTime)
482
+ console.log('point.time:', point.time)
483
+
484
+ if (currentTime >= point.time && !this.jumpedTimePoints.has(point.time)) {
485
+
486
+ this.jumpedTimePoints.add(point.time)
487
+ this.jumpIndex++
488
+
489
+ this.$appOptions.store.dispatch('tags/addTagview', {
490
+ path: point.url,
491
+ fullPath: point.url,
492
+ label: point.title,
493
+ name: point.title,
494
+ meta: { title: point.title },
495
+ query: {},
496
+ params: {}
497
+ })
498
+
499
+ this.$appOptions.router.push({ path: point.url })
500
+ }
501
+ },
502
+ // 处理播放字幕
503
+ handlePlaySubtitles(currentTime){
504
+ const subtitle = this.currentSubtitles[this.subtitleIndex]
505
+
506
+ if (!subtitle) return
507
+
508
+ if (currentTime < subtitle.start) return
509
+
510
+ // 正在播放当前字幕
511
+ if (currentTime >= subtitle.start && currentTime <= subtitle.end) {
512
+ if (this.currentSubtitle !== subtitle.text) {
513
+ this.currentSubtitle = subtitle.text
514
+ }
515
+ return
516
+ }
517
+
518
+ // 超过当前字幕,推进游标
519
+ if (currentTime > subtitle.end) {
520
+ this.subtitleIndex++
521
+ this.currentSubtitle = ''
236
522
  }
237
523
  },
524
+ async setAudio(src) {
525
+ return new Promise((resolve, reject) => {
526
+ const audio = this.$refs.audioPlayer
527
+
528
+ if (!audio) return reject('未找到音频元素')
529
+
530
+ audio.pause()
531
+ audio.currentTime = 0
532
+
533
+ // ✅ 用响应式数据
534
+ this.audioSrc = src
535
+
536
+ this.$nextTick(() => {
537
+ audio.load()
538
+
539
+ const onCanPlay = () => {
540
+ audio.removeEventListener('canplay', onCanPlay)
541
+ resolve()
542
+ }
543
+
544
+ audio.addEventListener('canplay', onCanPlay)
545
+ })
546
+ })
547
+ },
238
548
  // 音频时间更新处理
239
549
  onTimeUpdate() {
550
+ const now = performance.now()
551
+
552
+ if (this.lastTimeUpdate && now - this.lastTimeUpdate < 200) return
553
+ this.lastTimeUpdate = now
554
+
240
555
  const audio = this.$refs.audioPlayer
241
556
  const currentTime = audio.currentTime
242
557
 
243
- if (!this.timeJumpPoints.length) return
244
-
245
- this.timeJumpPoints.forEach(point => {
246
- if (
247
- currentTime >= point.time &&
248
- currentTime < point.time + 1 &&
249
- !this.jumpedTimePoints.has(point.time)
250
- ) {
251
- console.log('触发跳转:', point.url)
252
-
253
- this.jumpedTimePoints.add(point.time)
254
-
255
- this.$appOptions.store.dispatch('tags/addTagview', {
256
- path: point.url,
257
- fullPath: point.url,
258
- label: point.title,
259
- name: point.title,
260
- meta: { title: point.title },
261
- query: {},
262
- params: {}
263
- })
264
-
265
- this.$appOptions.router.push({ path: point.url })
266
- }
267
- })
558
+ this.handleJumpPoint(currentTime)
559
+ this.handlePlaySubtitles(currentTime)
268
560
  },
269
561
  onAudioEnded() {
270
- this.robotStatus = 'leaving'
271
- this.avaterStatus = 'normal'
562
+ this.currentSubtitle = ''
563
+ this.setRobotStatus(ROBOT_STATUS.LEAVING)
564
+ this.setAvatarStatus(AVATAR_STATUS.NORMAL)
272
565
  this.jumpedTimePoints.clear()
273
566
  }
274
567
  }
@@ -282,7 +575,6 @@ export default {
282
575
  opacity: 0;
283
576
  pointer-events: none;
284
577
  }
285
-
286
578
  .chat {
287
579
  position: fixed;
288
580
  bottom: 20px;
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ export const BLACKLIST = [
2
+ '81010400', // 南方水泥
3
+ ]
@@ -1,6 +1,15 @@
1
- const baseUrl = window.location.host;
1
+ // const baseUrl = window.location.host;
2
2
 
3
3
  export const API_URL = `/lingxiao-byt/api/v1/mcp/ask`; // 对话
4
- export const WS_URL = `ws://${baseUrl}/audio/ws/`; // 语音
5
- export const AUDIO_URL = '/minio/lingxiaoai/byt.mp3'; // 导览
4
+ // export const WS_URL = `ws://${baseUrl}/audio/ws/`; // 语音
5
+ export const WS_URL = `ws://220.189.237.146:8312/audio/ws/`; // 测试语音
6
+
7
+ export const AUDIO_URL = '/minio/lingxiaoai/byt.mp3'; // 导览 铜梁
8
+
9
+ export const COLLECTION = '/minio/lingxiaoai/collection.mp3'
10
+ export const AREA_BOARD = '/minio/lingxiaoai/area_board.mp3'; // 区域看板
11
+ export const ENTERPRISE_APPLICATION = '/minio/lingxiaoai/enterprise_application.mp3'; // 企业应用
12
+ export const REGIONAL_APPLICATION = '/minio/lingxiaoai/regional_application.mp3'; // 区域应用
13
+
6
14
  export const TIME_JUMP_POINTS_URL = '/minio/lingxiaoai/timeJumpPoints.json'; // 语音url跳转节点
15
+ export const SUBTITLE_POINTS_URL = '/minio/lingxiaoai/subTitlePoints.json'; // 字幕跳转节点