yt-chat-components 1.2.2 → 1.2.4

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 (42) hide show
  1. package/.idea/sonarlint/issuestore/7/0/7030d0b2f71b999ff89a343de08c414af32fc93a +0 -0
  2. package/.idea/sonarlint/issuestore/8/e/8ec9a00bfd09b3190ac6b22251dbb1aa95a0579d +0 -0
  3. package/.idea/sonarlint/issuestore/9/c/9cfff9a6d27bd6c255aa751213163c7901fb8ce7 +0 -0
  4. package/.idea/sonarlint/issuestore/index.pb +7 -0
  5. package/build/asset-manifest.json +20 -0
  6. package/build/index.html +1 -0
  7. package/build/static/css/main.8ee59d98.css +2 -0
  8. package/build/static/css/main.8ee59d98.css.map +1 -0
  9. package/build/static/js/main.371ede49.js +3 -0
  10. package/build/static/js/main.371ede49.js.LICENSE.txt +181 -0
  11. package/build/static/js/main.371ede49.js.map +1 -0
  12. package/build/static/media/ai_call_working.a63afffab31dc8264d05.gif +0 -0
  13. package/build/static/media/aiavatar.74bafa995cce4c01b804.png +0 -0
  14. package/build/static/media/history-list-empty.1eb65b1550aef4e8c8a4.png +0 -0
  15. package/build/static/media/icon_history_headerbg.50747e81d01257f55346.png +0 -0
  16. package/build/static/media/moreAi.285e66289f838072060c.png +0 -0
  17. package/build/static/media/moreBg.9fc998472925cecd89f2.png +0 -0
  18. package/build/static/media/phone.19bc6f0d2e9eae4863ae.png +0 -0
  19. package/package.json +80 -78
  20. package/public/index.html +108 -108
  21. package/src/YtChatView/chatWidget/chatWindow/callInterface/index.module.css +50 -50
  22. package/src/YtChatView/chatWidget/chatWindow/callInterface/index.tsx +549 -549
  23. package/src/YtChatView/chatWidget/chatWindow/callInterface/style.ts +44 -44
  24. package/src/YtChatView/chatWidget/chatWindow/chatMessage/index.tsx +501 -501
  25. package/src/YtChatView/chatWidget/chatWindow/chatPlaceholder/index.tsx +23 -23
  26. package/src/YtChatView/chatWidget/chatWindow/controllers/index.ts +249 -249
  27. package/src/YtChatView/chatWidget/chatWindow/index.module.css +196 -196
  28. package/src/YtChatView/chatWidget/chatWindow/index.tsx +1182 -1186
  29. package/src/YtChatView/chatWidget/chatWindow/types/chatWidget/index.ts +50 -50
  30. package/src/YtChatView/chatWidget/index.tsx +2598 -2596
  31. package/src/YtChatView/logoBtn/index.css +3 -3
  32. package/src/YtChatView/logoBtn/index.jsx +103 -103
  33. package/src/YtChatView/logoSplitBtn/index.css +3 -3
  34. package/src/YtChatView/logoSplitBtn/index.jsx +105 -105
  35. package/src/YtChatView/mobileChat/index.jsx +945 -945
  36. package/src/YtChatView/mobileChat/index.module.css +253 -253
  37. package/src/YtChatView/previewDialog/index.jsx +601 -601
  38. package/src/YtChatView/previewDialog/index.module.css +253 -253
  39. package/src/chatWidget/chatWindow/index.tsx +426 -426
  40. package/src/chatWidget/index.tsx +2195 -2195
  41. package/src/index.tsx +11 -127
  42. package/webpack.config.js +50 -50
@@ -1,550 +1,550 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { Button, Avatar, message } from 'antd';
3
- import './index.module.css';
4
- import { callStyle } from './style';
5
- import { ChatMessageType } from '../../chatWindow/types/chatWidget';
6
- import Recorder from 'recorder-core'
7
- import 'recorder-core/src/engine/mp3'
8
- import 'recorder-core/src/engine/mp3-engine'
9
- // import 'recorder-core/src/extensions/waveview'
10
- import ai_call_thinking from '../../../../assets/aicenter/ai_call_thinking.png'
11
- import ai_call_working from '../../../../assets/aicenter/ai_call_working.gif'
12
-
13
-
14
- interface CallInterfaceProps {
15
- onHangup: () => void;
16
- contactName?: string;
17
- contactAvatar?: string;
18
- api_key: string;
19
- flowId: string;
20
- sessionId: React.MutableRefObject<string>;
21
- hostUrl: string;
22
- addMessage: (message: ChatMessageType) => void;
23
- userInfo: {
24
- code: string;
25
- };
26
- }
27
-
28
- const CallInterface: React.FC<CallInterfaceProps> = ({
29
- onHangup,
30
- contactName = '',
31
- contactAvatar,
32
- api_key,
33
- flowId,
34
- sessionId,
35
- hostUrl,
36
- addMessage,
37
- userInfo,
38
- }) => {
39
- const [callDuration, setCallDuration] = useState(0);
40
- const [callStatus, setCallStatus] = useState<'connecting' | 'connected'>('connecting');
41
- const [workStatus, setWorkStatus] = useState<'正在聆听' | '正在思考' | '正在回复'>('正在聆听');
42
-
43
- // 添加VAD相关状态
44
- const isSpeakingRef = useRef<boolean>(false);
45
- const SILENCE_THRESHOLD = 35; // 静音阈值,可根据环境调整
46
- const SPEECH_DELAY = 1500; // 停止说话多久后认为一段话结束(毫秒)
47
- // 发送信息后持有这个id,当id不为null放弃处理后续说话
48
- const msgIdRef = useRef<string | null>(null);
49
-
50
- // WebRTC相关状态和引用
51
- const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
52
- const localStreamRef = useRef<MediaStream | null>(null);
53
- const localAudioRef = useRef<HTMLAudioElement>(null);
54
- const remoteStreamRef = useRef<MediaStream | null>(null);
55
- const remoteAudioRef = useRef<HTMLAudioElement>(null);
56
- let responseAudio: any = null;
57
- // let wave: any = null;
58
- const wsRef = useRef<WebSocket | null>(null);
59
-
60
- // VAD相关引用
61
- const recorderRef = useRef<any | null>(null);
62
- const speechTimeoutRef = useRef<NodeJS.Timeout | null>(null);
63
- const vadProcessorRef = useRef<ScriptProcessorNode | null>(null);
64
- const audioContextRef = useRef<AudioContext | null>(null);
65
- const [isSpeakingNow,setIsSpeakingNow] = useState(false);
66
-
67
-
68
- // 添加统一的WebSocket消息发送方法
69
- const sendWebSocketMessage = (type: string, data: any) => {
70
- if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
71
- wsRef.current.send(JSON.stringify({type, data}));
72
- } else {
73
- console.error(`WebSocket未连接,无法发送${type}消息`);
74
- }
75
- };
76
-
77
- const handleAudioData = (audioData: string) => {
78
- setWorkStatus('正在回复');
79
-
80
- // 将base64音频转换为Blob
81
- const byteCharacters = atob(audioData);
82
- const byteNumbers = new Array(byteCharacters.length);
83
- for (let i = 0; i < byteCharacters.length; i++) {
84
- byteNumbers[i] = byteCharacters.charCodeAt(i);
85
- }
86
- const byteArray = new Uint8Array(byteNumbers);
87
- const audioBlob = new Blob([byteArray], { type: 'audio/webm' });
88
-
89
- // 创建音频URL并播放
90
- const audioUrl = URL.createObjectURL(audioBlob);
91
- const audio = new Audio(audioUrl);
92
- responseAudio = audio;
93
-
94
- // 监听用户打断
95
- audio.onplay = () => {
96
- // 设置打断检测,3秒后才启动
97
- const interruptionCheck = setInterval(() => {
98
- if (isSpeakingRef.current) {
99
- // 用户开始说话,打断播放
100
- // console.log('用户打断播放');
101
- audio.pause();
102
- URL.revokeObjectURL(audioUrl);
103
- clearInterval(interruptionCheck);
104
- setWorkStatus('正在聆听');
105
- }
106
- }, 3000);
107
-
108
- // 播放结束时清除检测
109
- audio.onended = () => {
110
- clearInterval(interruptionCheck);
111
- URL.revokeObjectURL(audioUrl);
112
- setWorkStatus('正在聆听');
113
- };
114
- };
115
-
116
- // 开始播放
117
- audio.play().catch(err => {
118
- console.error('播放音频失败:', err);
119
- setWorkStatus('正在聆听');
120
- });
121
- };
122
-
123
- const initWebRTC = async () => {
124
- try {
125
- const peerConnection = new RTCPeerConnection({
126
- iceServers: [
127
- // 替换为国内可用的STUN服务器
128
- { urls: 'stun:stun.qq.com:3478' },
129
- { urls: 'stun:stun.miwifi.com:3478' },
130
- // 备用服务器
131
- { urls: 'stun:stun.voip.eutelia.it:3478' },
132
- { urls: 'stun:openrelay.metered.ca:80' }
133
- ]
134
- });
135
- peerConnectionRef.current = peerConnection;
136
- const {code} = userInfo;
137
- const opeartorId = code || sessionId;
138
- // 创建WebSocket连接
139
- console.log('开始连接WebSocket');
140
- const ws = new WebSocket(`${hostUrl.replace('http', 'ws')}/api/v1/ws/webrtc?flow_id=${flowId}&session_id=${sessionId}&api_key=${api_key}&operator_id=${opeartorId}`);
141
- wsRef.current = ws;
142
-
143
- // 添加连接超时处理
144
- const connectionTimeout = setTimeout(() => {
145
- if (ws.readyState === WebSocket.CONNECTING) {
146
- console.error('WebSocket连接超时');
147
- message.error('连接超时,请重试');
148
- ws.close();
149
- }
150
- }, 60000); // 60秒超时
151
-
152
- // 创建和发送offer的函数
153
- const createAndSendOffer = async (_peerConnection: RTCPeerConnection) => {
154
- try {
155
- const offer = await _peerConnection.createOffer();
156
- await _peerConnection.setLocalDescription(offer);
157
-
158
- sendWebSocketMessage('offer', { offer });
159
- } catch (error) {
160
- console.error('创建offer失败:', error);
161
- }
162
- };
163
-
164
- ws.onopen = () => {
165
- console.log('WebSocket连接已建立');
166
- clearTimeout(connectionTimeout); // 清除超时定时器
167
-
168
- // 只有在WebSocket连接成功后才创建和发送offer
169
- if (peerConnectionRef.current) {
170
- createAndSendOffer(peerConnectionRef.current);
171
- }
172
- };
173
-
174
- const localStream = await navigator.mediaDevices.getUserMedia({
175
- audio: {
176
- echoCancellation: true, // 启用回声消除
177
- noiseSuppression: true, // 启用噪声抑制
178
- autoGainControl: true // 启用自动增益控制
179
- },
180
- video: false
181
- });
182
- localStreamRef.current = localStream;
183
-
184
- // if (localAudioRef.current) {
185
- // localAudioRef.current.srcObject = localStream;
186
- // }
187
-
188
- // 设置VAD(语音活动检测)
189
- const audioContext = new AudioContext();
190
- audioContextRef.current = audioContext;
191
- const analyser = audioContext.createAnalyser();
192
- analyser.fftSize = 256;
193
- const bufferLength = analyser.frequencyBinCount;
194
- const dataArray = new Uint8Array(bufferLength);
195
-
196
- const source = audioContext.createMediaStreamSource(localStream);
197
- source.connect(analyser);
198
-
199
- // 创建音频处理器用于VAD
200
- const vadProcessor = audioContext.createScriptProcessor(2048, 1, 1);
201
- vadProcessorRef.current = vadProcessor;
202
-
203
- vadProcessor.onaudioprocess = (e) => {
204
- // 正在听对方说话 or 思考,不处理
205
- if (workStatus === '正在回复'){
206
- console.log("--------------------- 正在回复")
207
- return
208
- }else if(msgIdRef.current !== null){
209
- console.log("--------------------- 正在思考")
210
- return;
211
- }
212
-
213
- analyser.getByteFrequencyData(dataArray);
214
-
215
- // 计算音量平均值
216
- let sum = 0;
217
- for (let i = 0; i < bufferLength; i++) {
218
- sum += dataArray[i];
219
- }
220
- const average = sum / bufferLength;
221
-
222
- // 检测是否有语音
223
- const isSpeakingNow = average > SILENCE_THRESHOLD;
224
-
225
- if (isSpeakingNow && !isSpeakingRef.current) {
226
- setIsSpeakingNow(true)
227
- // 开始说话
228
- isSpeakingRef.current = true;
229
-
230
- // 开始录制
231
- if (!recorderRef.current && localStreamRef.current) {
232
- console.log('准备录制');
233
- const recorder = new Recorder({
234
- type:"mp3",
235
- sampleRate:16000,
236
- bitRate:16,
237
- sourceStream:localStreamRef.current,
238
- runningContext:audioContextRef.current,
239
- onProcess: (buffers: any, powerLevel: any, bufferDuration: any, bufferSampleRate: any, newBufferIdx: any, asyncEnd: any) => {
240
- //录音实时回调,大约1秒调用12次本回调,buffers为开始到现在的所有录音pcm数据块(16位小端LE)
241
- //可利用extensions/sonic.js插件实时变速变调,此插件计算量巨大,onProcess需要返回true开启异步模式
242
- //可实时上传(发送)数据,配合Recorder.SampleData方法,将buffers中的新数据连续的转换成pcm上传,或使用mock方法将新数据连续的转码成其他格式上传,可以参考文档里面的:Demo片段列表 -> 实时转码并上传-通用版;基于本功能可以做到:实时转发数据、实时保存数据、实时语音识别(ASR)等
243
-
244
- //可实时绘制波形(extensions目录内的waveview.js、wavesurfer.view.js、frequency.histogram.view.js插件功能)
245
- // if(workStatus === '正在聆听'){
246
- // wave && wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
247
- // }
248
- }
249
- });
250
- recorderRef.current = recorder;
251
- recorder.open(() => {
252
- // if(Recorder.WaveView){
253
- // wave = Recorder.WaveView({elem: ".recwave"});
254
- // }
255
- });
256
- recorder.start();
257
- }
258
-
259
- // 清除之前的超时
260
- if (speechTimeoutRef.current) {
261
- clearTimeout(speechTimeoutRef.current);
262
- speechTimeoutRef.current = null;
263
- }
264
- }
265
- else if (!isSpeakingNow && isSpeakingRef.current) {
266
- // 可能停止说话,设置超时
267
- if (!speechTimeoutRef.current) {
268
- speechTimeoutRef.current = setTimeout(() => {
269
- // 确认停止说话
270
- isSpeakingRef.current = false;
271
-
272
- // 停止录制
273
- if (recorderRef.current) {
274
- recorderRef.current.stop((blob: Blob, duration:any, mime:any) => {
275
- console.log('onstop', duration, mime);
276
- // 将音频转换为base64
277
- const reader = new FileReader();
278
- reader.readAsDataURL(blob);
279
- reader.onloadend = () => {
280
- const base64Audio = reader.result as string;
281
- // 发送完整的语音片段到服务器
282
- if (wsRef.current) {
283
- msgIdRef.current = new Date().getTime() + ""
284
- sendWebSocketMessage('complete-audio', {
285
- audioData: base64Audio.split(',')[1], // 移除data URL前缀
286
- userMessage: true,
287
- audioFormat:"mp3",
288
- sampleRate:16000,
289
- msgId: msgIdRef.current,
290
- });
291
- console.log('发送完毕');
292
-
293
- // 添加用户消息到聊天记录
294
- // addMessage({
295
- // message: "[语音消息]",
296
- // isSend: true,
297
- // timestamp: new Date().toISOString()
298
- // });
299
-
300
- setWorkStatus('正在思考');
301
- setIsSpeakingNow(false)
302
- }
303
- };
304
-
305
- // wave = null
306
- recorderRef.current = null;
307
- },(msg: any)=> {
308
- console.log("录音失败:"+msg);
309
- // wave = null
310
- recorderRef.current.close();//可以通过stop方法的第3个参数来自动调用close
311
- recorderRef.current=null;
312
- });
313
- }
314
-
315
- speechTimeoutRef.current = null;
316
- }, SPEECH_DELAY);
317
- }
318
- }
319
- else if (isSpeakingNow && isSpeakingRef.current) {
320
- console.log('继续说话');
321
- // 继续说话,重置超时
322
- if (speechTimeoutRef.current) {
323
- clearTimeout(speechTimeoutRef.current);
324
- speechTimeoutRef.current = null;
325
- }
326
- }
327
- };
328
-
329
- // 连接VAD处理器
330
- source.connect(vadProcessor);
331
- vadProcessor.connect(audioContext.destination);
332
-
333
- // 修改WebSocket消息处理
334
- ws.onmessage = async (event) => {
335
- const message = JSON.parse(event.data);
336
- console.log('收到消息', message);
337
-
338
- if (message.type === 'offer') {
339
- await handleOffer(message);
340
- } else if (message.type === 'answer') {
341
- await handleAnswer(message);
342
- } else if (message.type === 'ice-candidate') {
343
- await handleIceCandidate(message);
344
- } else if (message.type === 'connected') {
345
- setCallStatus('connected');
346
- if (message.audioData) {
347
- handleAudioData(message.audioData);
348
- }
349
- } else if (message.type === 'complete-audio') {
350
- // 处理从后端返回的完整音频响应
351
- msgIdRef.current = null
352
- if (message.audioData) {
353
- handleAudioData(message.audioData);
354
- }else {
355
- setWorkStatus('正在聆听');
356
- }
357
- }
358
- };
359
-
360
- // 添加本地音频轨道到对等连接
361
- localStream.getTracks().forEach(track => {
362
- peerConnection.addTrack(track, localStream);
363
- });
364
-
365
- // 处理远程流
366
- peerConnection.ontrack = (event) => {
367
- remoteStreamRef.current = event.streams[0];
368
- if (remoteAudioRef.current) {
369
- remoteAudioRef.current.srcObject = event.streams[0];
370
- }
371
- };
372
-
373
- // 处理ICE候选
374
- peerConnection.onicecandidate = (event) => {
375
- if (event.candidate && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
376
- sendWebSocketMessage('ice-candidate', {candidate: event.candidate})
377
- }
378
- };
379
- } catch (error) {
380
- console.error('初始化WebRTC失败:', error);
381
- message.error('无法访问麦克风,请检查权限设置');
382
- }
383
- };
384
-
385
- const clear = () => {
386
- console.log('CallInterface组件卸载');
387
-
388
- if (responseAudio !== null){
389
- responseAudio.pause();
390
- responseAudio = null;
391
- }
392
-
393
- // 关闭WebSocket连接
394
- if (wsRef.current) {
395
- wsRef.current.close();
396
- }
397
-
398
- // 关闭对等连接
399
- if (peerConnectionRef.current) {
400
- peerConnectionRef.current.close();
401
- }
402
-
403
- // 停止所有媒体轨道
404
- if (localStreamRef.current) {
405
- localStreamRef.current.getTracks().forEach(track => track.stop());
406
- }
407
-
408
- // 清理VAD相关资源
409
- if (vadProcessorRef.current && audioContextRef.current) {
410
- vadProcessorRef.current.disconnect();
411
- }
412
-
413
- if (audioContextRef.current) {
414
- audioContextRef.current.close();
415
- }
416
-
417
- // 清理超时
418
- if (speechTimeoutRef.current) {
419
- clearTimeout(speechTimeoutRef.current);
420
- }
421
- }
422
-
423
- // 初始化WebRTC连接
424
- useEffect(() => {
425
- initWebRTC().then();
426
- // 清理函数
427
- return clear
428
- }, []);
429
-
430
- // 处理收到的offer
431
- const handleOffer = async (message: any) => {
432
- if (peerConnectionRef.current) {
433
- await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(message.offer));
434
- const answer = await peerConnectionRef.current.createAnswer();
435
- await peerConnectionRef.current.setLocalDescription(answer);
436
-
437
- if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
438
- sendWebSocketMessage('answer', {answer})
439
- } else {
440
- console.error('WebSocket未连接,无法发送answer');
441
- }
442
- }
443
- };
444
-
445
- // 处理收到的answer
446
- const handleAnswer = async (message: any) => {
447
- if (peerConnectionRef.current) {
448
- await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(message.answer));
449
- }
450
- };
451
-
452
- // 处理收到的ICE候选
453
- const handleIceCandidate = async (message: any) => {
454
- console.log('收到ICE候选', message);
455
- if (peerConnectionRef.current) {
456
- await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(message.candidate));
457
- }
458
- };
459
-
460
- // 计时器
461
- useEffect(() => {
462
- let timer: NodeJS.Timeout;
463
-
464
- if (callStatus === 'connected') {
465
- timer = setInterval(() => {
466
- setCallDuration(prev => prev + 1);
467
- }, 1000);
468
- }
469
-
470
- return () => {
471
- if (timer) clearInterval(timer);
472
- };
473
- }, [callStatus]);
474
-
475
- // 格式化通话时间
476
- const formatTime = (seconds: number) => {
477
- const mins = Math.floor(seconds / 60);
478
- const secs = seconds % 60;
479
- return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
480
- };
481
-
482
- const handleHangup = () => {
483
- // 发送挂断信号
484
- if (wsRef.current) {
485
- sendWebSocketMessage('hangup', {});
486
- }
487
-
488
- // 停止所有媒体轨道
489
- if (localStreamRef.current) {
490
- localStreamRef.current.getTracks().forEach(track => track.stop());
491
- }
492
-
493
- onHangup();
494
- };
495
-
496
- return (
497
- <div className="call-interface">
498
- <style dangerouslySetInnerHTML={{ __html: callStyle }}></style>
499
- <div className="call-header">
500
- <div className="contact-info">
501
- <Avatar
502
- size={80}
503
- src={contactAvatar}
504
- style={{ backgroundColor: '#1890ff' }}
505
- >
506
- {!contactAvatar && contactName.charAt(0)}
507
- </Avatar>
508
- <h2>{contactName}</h2>
509
- <p className="call-status">
510
- {callStatus === 'connecting' ? '正在连接...' : formatTime(callDuration)}
511
- </p>
512
- {
513
- callStatus === 'connected' &&
514
- <div style={{display: 'flex', flexDirection:'column', justifyContent: 'center', alignItems: 'center'}}>
515
- {/*<div style={{width: 120, height: 50, position: 'relative'}}>*/}
516
- {/* {*/}
517
- {/* <div className='recwave' style={{width: 120, height: 50, display: workStatus === '正在聆听' ? 'block' : 'none'}}></div>*/}
518
- {/* }*/}
519
- {/*</div>*/}
520
- <div className='recwave' style={{width: 120, height: 65}}>{
521
- (isSpeakingNow && workStatus === '正在聆听')|| workStatus === '正在回复' ? <img src={ai_call_working}/>:<img src={ai_call_thinking}/>
522
- }</div>
523
- <p className="call-status">
524
- {workStatus}
525
- </p>
526
- </div>
527
-
528
- }
529
- </div>
530
- </div>
531
-
532
- {/* 音频元素 */}
533
- <audio ref={localAudioRef} autoPlay muted style={{ display: 'none' }} />
534
- <audio ref={remoteAudioRef} autoPlay style={{ display: 'none' }} />
535
-
536
- <div className="call-controls">
537
- <Button
538
- type="primary"
539
- danger
540
- shape="circle"
541
- size="large"
542
- onClick={handleHangup}
543
- icon={<span className="hangup-icon">✕</span>}
544
- />
545
- </div>
546
- </div>
547
- );
548
- };
549
-
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Button, Avatar, message } from 'antd';
3
+ import './index.module.css';
4
+ import { callStyle } from './style';
5
+ import { ChatMessageType } from '../../chatWindow/types/chatWidget';
6
+ import Recorder from 'recorder-core'
7
+ import 'recorder-core/src/engine/mp3'
8
+ import 'recorder-core/src/engine/mp3-engine'
9
+ // import 'recorder-core/src/extensions/waveview'
10
+ import ai_call_thinking from '../../../../assets/aicenter/ai_call_thinking.png'
11
+ import ai_call_working from '../../../../assets/aicenter/ai_call_working.gif'
12
+
13
+
14
+ interface CallInterfaceProps {
15
+ onHangup: () => void;
16
+ contactName?: string;
17
+ contactAvatar?: string;
18
+ api_key: string;
19
+ flowId: string;
20
+ sessionId: React.MutableRefObject<string>;
21
+ hostUrl: string;
22
+ addMessage: (message: ChatMessageType) => void;
23
+ userInfo: {
24
+ code: string;
25
+ };
26
+ }
27
+
28
+ const CallInterface: React.FC<CallInterfaceProps> = ({
29
+ onHangup,
30
+ contactName = '',
31
+ contactAvatar,
32
+ api_key,
33
+ flowId,
34
+ sessionId,
35
+ hostUrl,
36
+ addMessage,
37
+ userInfo,
38
+ }) => {
39
+ const [callDuration, setCallDuration] = useState(0);
40
+ const [callStatus, setCallStatus] = useState<'connecting' | 'connected'>('connecting');
41
+ const [workStatus, setWorkStatus] = useState<'正在聆听' | '正在思考' | '正在回复'>('正在聆听');
42
+
43
+ // 添加VAD相关状态
44
+ const isSpeakingRef = useRef<boolean>(false);
45
+ const SILENCE_THRESHOLD = 35; // 静音阈值,可根据环境调整
46
+ const SPEECH_DELAY = 1500; // 停止说话多久后认为一段话结束(毫秒)
47
+ // 发送信息后持有这个id,当id不为null放弃处理后续说话
48
+ const msgIdRef = useRef<string | null>(null);
49
+
50
+ // WebRTC相关状态和引用
51
+ const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
52
+ const localStreamRef = useRef<MediaStream | null>(null);
53
+ const localAudioRef = useRef<HTMLAudioElement>(null);
54
+ const remoteStreamRef = useRef<MediaStream | null>(null);
55
+ const remoteAudioRef = useRef<HTMLAudioElement>(null);
56
+ let responseAudio: any = null;
57
+ // let wave: any = null;
58
+ const wsRef = useRef<WebSocket | null>(null);
59
+
60
+ // VAD相关引用
61
+ const recorderRef = useRef<any | null>(null);
62
+ const speechTimeoutRef = useRef<NodeJS.Timeout | null>(null);
63
+ const vadProcessorRef = useRef<ScriptProcessorNode | null>(null);
64
+ const audioContextRef = useRef<AudioContext | null>(null);
65
+ const [isSpeakingNow,setIsSpeakingNow] = useState(false);
66
+
67
+
68
+ // 添加统一的WebSocket消息发送方法
69
+ const sendWebSocketMessage = (type: string, data: any) => {
70
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
71
+ wsRef.current.send(JSON.stringify({type, data}));
72
+ } else {
73
+ console.error(`WebSocket未连接,无法发送${type}消息`);
74
+ }
75
+ };
76
+
77
+ const handleAudioData = (audioData: string) => {
78
+ setWorkStatus('正在回复');
79
+
80
+ // 将base64音频转换为Blob
81
+ const byteCharacters = atob(audioData);
82
+ const byteNumbers = new Array(byteCharacters.length);
83
+ for (let i = 0; i < byteCharacters.length; i++) {
84
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
85
+ }
86
+ const byteArray = new Uint8Array(byteNumbers);
87
+ const audioBlob = new Blob([byteArray], { type: 'audio/webm' });
88
+
89
+ // 创建音频URL并播放
90
+ const audioUrl = URL.createObjectURL(audioBlob);
91
+ const audio = new Audio(audioUrl);
92
+ responseAudio = audio;
93
+
94
+ // 监听用户打断
95
+ audio.onplay = () => {
96
+ // 设置打断检测,3秒后才启动
97
+ const interruptionCheck = setInterval(() => {
98
+ if (isSpeakingRef.current) {
99
+ // 用户开始说话,打断播放
100
+ // console.log('用户打断播放');
101
+ audio.pause();
102
+ URL.revokeObjectURL(audioUrl);
103
+ clearInterval(interruptionCheck);
104
+ setWorkStatus('正在聆听');
105
+ }
106
+ }, 3000);
107
+
108
+ // 播放结束时清除检测
109
+ audio.onended = () => {
110
+ clearInterval(interruptionCheck);
111
+ URL.revokeObjectURL(audioUrl);
112
+ setWorkStatus('正在聆听');
113
+ };
114
+ };
115
+
116
+ // 开始播放
117
+ audio.play().catch(err => {
118
+ console.error('播放音频失败:', err);
119
+ setWorkStatus('正在聆听');
120
+ });
121
+ };
122
+
123
+ const initWebRTC = async () => {
124
+ try {
125
+ const peerConnection = new RTCPeerConnection({
126
+ iceServers: [
127
+ // 替换为国内可用的STUN服务器
128
+ { urls: 'stun:stun.qq.com:3478' },
129
+ { urls: 'stun:stun.miwifi.com:3478' },
130
+ // 备用服务器
131
+ { urls: 'stun:stun.voip.eutelia.it:3478' },
132
+ { urls: 'stun:openrelay.metered.ca:80' }
133
+ ]
134
+ });
135
+ peerConnectionRef.current = peerConnection;
136
+ const {code} = userInfo;
137
+ const opeartorId = code || sessionId;
138
+ // 创建WebSocket连接
139
+ console.log('开始连接WebSocket');
140
+ const ws = new WebSocket(`${hostUrl.replace('http', 'ws')}/api/v1/ws/webrtc?flow_id=${flowId}&session_id=${sessionId}&api_key=${api_key}&operator_id=${opeartorId}`);
141
+ wsRef.current = ws;
142
+
143
+ // 添加连接超时处理
144
+ const connectionTimeout = setTimeout(() => {
145
+ if (ws.readyState === WebSocket.CONNECTING) {
146
+ console.error('WebSocket连接超时');
147
+ message.error('连接超时,请重试');
148
+ ws.close();
149
+ }
150
+ }, 60000); // 60秒超时
151
+
152
+ // 创建和发送offer的函数
153
+ const createAndSendOffer = async (_peerConnection: RTCPeerConnection) => {
154
+ try {
155
+ const offer = await _peerConnection.createOffer();
156
+ await _peerConnection.setLocalDescription(offer);
157
+
158
+ sendWebSocketMessage('offer', { offer });
159
+ } catch (error) {
160
+ console.error('创建offer失败:', error);
161
+ }
162
+ };
163
+
164
+ ws.onopen = () => {
165
+ console.log('WebSocket连接已建立');
166
+ clearTimeout(connectionTimeout); // 清除超时定时器
167
+
168
+ // 只有在WebSocket连接成功后才创建和发送offer
169
+ if (peerConnectionRef.current) {
170
+ createAndSendOffer(peerConnectionRef.current);
171
+ }
172
+ };
173
+
174
+ const localStream = await navigator.mediaDevices.getUserMedia({
175
+ audio: {
176
+ echoCancellation: true, // 启用回声消除
177
+ noiseSuppression: true, // 启用噪声抑制
178
+ autoGainControl: true // 启用自动增益控制
179
+ },
180
+ video: false
181
+ });
182
+ localStreamRef.current = localStream;
183
+
184
+ // if (localAudioRef.current) {
185
+ // localAudioRef.current.srcObject = localStream;
186
+ // }
187
+
188
+ // 设置VAD(语音活动检测)
189
+ const audioContext = new AudioContext();
190
+ audioContextRef.current = audioContext;
191
+ const analyser = audioContext.createAnalyser();
192
+ analyser.fftSize = 256;
193
+ const bufferLength = analyser.frequencyBinCount;
194
+ const dataArray = new Uint8Array(bufferLength);
195
+
196
+ const source = audioContext.createMediaStreamSource(localStream);
197
+ source.connect(analyser);
198
+
199
+ // 创建音频处理器用于VAD
200
+ const vadProcessor = audioContext.createScriptProcessor(2048, 1, 1);
201
+ vadProcessorRef.current = vadProcessor;
202
+
203
+ vadProcessor.onaudioprocess = (e) => {
204
+ // 正在听对方说话 or 思考,不处理
205
+ if (workStatus === '正在回复'){
206
+ console.log("--------------------- 正在回复")
207
+ return
208
+ }else if(msgIdRef.current !== null){
209
+ console.log("--------------------- 正在思考")
210
+ return;
211
+ }
212
+
213
+ analyser.getByteFrequencyData(dataArray);
214
+
215
+ // 计算音量平均值
216
+ let sum = 0;
217
+ for (let i = 0; i < bufferLength; i++) {
218
+ sum += dataArray[i];
219
+ }
220
+ const average = sum / bufferLength;
221
+
222
+ // 检测是否有语音
223
+ const isSpeakingNow = average > SILENCE_THRESHOLD;
224
+
225
+ if (isSpeakingNow && !isSpeakingRef.current) {
226
+ setIsSpeakingNow(true)
227
+ // 开始说话
228
+ isSpeakingRef.current = true;
229
+
230
+ // 开始录制
231
+ if (!recorderRef.current && localStreamRef.current) {
232
+ console.log('准备录制');
233
+ const recorder = new Recorder({
234
+ type:"mp3",
235
+ sampleRate:16000,
236
+ bitRate:16,
237
+ sourceStream:localStreamRef.current,
238
+ runningContext:audioContextRef.current,
239
+ onProcess: (buffers: any, powerLevel: any, bufferDuration: any, bufferSampleRate: any, newBufferIdx: any, asyncEnd: any) => {
240
+ //录音实时回调,大约1秒调用12次本回调,buffers为开始到现在的所有录音pcm数据块(16位小端LE)
241
+ //可利用extensions/sonic.js插件实时变速变调,此插件计算量巨大,onProcess需要返回true开启异步模式
242
+ //可实时上传(发送)数据,配合Recorder.SampleData方法,将buffers中的新数据连续的转换成pcm上传,或使用mock方法将新数据连续的转码成其他格式上传,可以参考文档里面的:Demo片段列表 -> 实时转码并上传-通用版;基于本功能可以做到:实时转发数据、实时保存数据、实时语音识别(ASR)等
243
+
244
+ //可实时绘制波形(extensions目录内的waveview.js、wavesurfer.view.js、frequency.histogram.view.js插件功能)
245
+ // if(workStatus === '正在聆听'){
246
+ // wave && wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
247
+ // }
248
+ }
249
+ });
250
+ recorderRef.current = recorder;
251
+ recorder.open(() => {
252
+ // if(Recorder.WaveView){
253
+ // wave = Recorder.WaveView({elem: ".recwave"});
254
+ // }
255
+ });
256
+ recorder.start();
257
+ }
258
+
259
+ // 清除之前的超时
260
+ if (speechTimeoutRef.current) {
261
+ clearTimeout(speechTimeoutRef.current);
262
+ speechTimeoutRef.current = null;
263
+ }
264
+ }
265
+ else if (!isSpeakingNow && isSpeakingRef.current) {
266
+ // 可能停止说话,设置超时
267
+ if (!speechTimeoutRef.current) {
268
+ speechTimeoutRef.current = setTimeout(() => {
269
+ // 确认停止说话
270
+ isSpeakingRef.current = false;
271
+
272
+ // 停止录制
273
+ if (recorderRef.current) {
274
+ recorderRef.current.stop((blob: Blob, duration:any, mime:any) => {
275
+ console.log('onstop', duration, mime);
276
+ // 将音频转换为base64
277
+ const reader = new FileReader();
278
+ reader.readAsDataURL(blob);
279
+ reader.onloadend = () => {
280
+ const base64Audio = reader.result as string;
281
+ // 发送完整的语音片段到服务器
282
+ if (wsRef.current) {
283
+ msgIdRef.current = new Date().getTime() + ""
284
+ sendWebSocketMessage('complete-audio', {
285
+ audioData: base64Audio.split(',')[1], // 移除data URL前缀
286
+ userMessage: true,
287
+ audioFormat:"mp3",
288
+ sampleRate:16000,
289
+ msgId: msgIdRef.current,
290
+ });
291
+ console.log('发送完毕');
292
+
293
+ // 添加用户消息到聊天记录
294
+ // addMessage({
295
+ // message: "[语音消息]",
296
+ // isSend: true,
297
+ // timestamp: new Date().toISOString()
298
+ // });
299
+
300
+ setWorkStatus('正在思考');
301
+ setIsSpeakingNow(false)
302
+ }
303
+ };
304
+
305
+ // wave = null
306
+ recorderRef.current = null;
307
+ },(msg: any)=> {
308
+ console.log("录音失败:"+msg);
309
+ // wave = null
310
+ recorderRef.current.close();//可以通过stop方法的第3个参数来自动调用close
311
+ recorderRef.current=null;
312
+ });
313
+ }
314
+
315
+ speechTimeoutRef.current = null;
316
+ }, SPEECH_DELAY);
317
+ }
318
+ }
319
+ else if (isSpeakingNow && isSpeakingRef.current) {
320
+ console.log('继续说话');
321
+ // 继续说话,重置超时
322
+ if (speechTimeoutRef.current) {
323
+ clearTimeout(speechTimeoutRef.current);
324
+ speechTimeoutRef.current = null;
325
+ }
326
+ }
327
+ };
328
+
329
+ // 连接VAD处理器
330
+ source.connect(vadProcessor);
331
+ vadProcessor.connect(audioContext.destination);
332
+
333
+ // 修改WebSocket消息处理
334
+ ws.onmessage = async (event) => {
335
+ const message = JSON.parse(event.data);
336
+ console.log('收到消息', message);
337
+
338
+ if (message.type === 'offer') {
339
+ await handleOffer(message);
340
+ } else if (message.type === 'answer') {
341
+ await handleAnswer(message);
342
+ } else if (message.type === 'ice-candidate') {
343
+ await handleIceCandidate(message);
344
+ } else if (message.type === 'connected') {
345
+ setCallStatus('connected');
346
+ if (message.audioData) {
347
+ handleAudioData(message.audioData);
348
+ }
349
+ } else if (message.type === 'complete-audio') {
350
+ // 处理从后端返回的完整音频响应
351
+ msgIdRef.current = null
352
+ if (message.audioData) {
353
+ handleAudioData(message.audioData);
354
+ }else {
355
+ setWorkStatus('正在聆听');
356
+ }
357
+ }
358
+ };
359
+
360
+ // 添加本地音频轨道到对等连接
361
+ localStream.getTracks().forEach(track => {
362
+ peerConnection.addTrack(track, localStream);
363
+ });
364
+
365
+ // 处理远程流
366
+ peerConnection.ontrack = (event) => {
367
+ remoteStreamRef.current = event.streams[0];
368
+ if (remoteAudioRef.current) {
369
+ remoteAudioRef.current.srcObject = event.streams[0];
370
+ }
371
+ };
372
+
373
+ // 处理ICE候选
374
+ peerConnection.onicecandidate = (event) => {
375
+ if (event.candidate && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
376
+ sendWebSocketMessage('ice-candidate', {candidate: event.candidate})
377
+ }
378
+ };
379
+ } catch (error) {
380
+ console.error('初始化WebRTC失败:', error);
381
+ message.error('无法访问麦克风,请检查权限设置');
382
+ }
383
+ };
384
+
385
+ const clear = () => {
386
+ console.log('CallInterface组件卸载');
387
+
388
+ if (responseAudio !== null){
389
+ responseAudio.pause();
390
+ responseAudio = null;
391
+ }
392
+
393
+ // 关闭WebSocket连接
394
+ if (wsRef.current) {
395
+ wsRef.current.close();
396
+ }
397
+
398
+ // 关闭对等连接
399
+ if (peerConnectionRef.current) {
400
+ peerConnectionRef.current.close();
401
+ }
402
+
403
+ // 停止所有媒体轨道
404
+ if (localStreamRef.current) {
405
+ localStreamRef.current.getTracks().forEach(track => track.stop());
406
+ }
407
+
408
+ // 清理VAD相关资源
409
+ if (vadProcessorRef.current && audioContextRef.current) {
410
+ vadProcessorRef.current.disconnect();
411
+ }
412
+
413
+ if (audioContextRef.current) {
414
+ audioContextRef.current.close();
415
+ }
416
+
417
+ // 清理超时
418
+ if (speechTimeoutRef.current) {
419
+ clearTimeout(speechTimeoutRef.current);
420
+ }
421
+ }
422
+
423
+ // 初始化WebRTC连接
424
+ useEffect(() => {
425
+ initWebRTC().then();
426
+ // 清理函数
427
+ return clear
428
+ }, []);
429
+
430
+ // 处理收到的offer
431
+ const handleOffer = async (message: any) => {
432
+ if (peerConnectionRef.current) {
433
+ await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(message.offer));
434
+ const answer = await peerConnectionRef.current.createAnswer();
435
+ await peerConnectionRef.current.setLocalDescription(answer);
436
+
437
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
438
+ sendWebSocketMessage('answer', {answer})
439
+ } else {
440
+ console.error('WebSocket未连接,无法发送answer');
441
+ }
442
+ }
443
+ };
444
+
445
+ // 处理收到的answer
446
+ const handleAnswer = async (message: any) => {
447
+ if (peerConnectionRef.current) {
448
+ await peerConnectionRef.current.setRemoteDescription(new RTCSessionDescription(message.answer));
449
+ }
450
+ };
451
+
452
+ // 处理收到的ICE候选
453
+ const handleIceCandidate = async (message: any) => {
454
+ console.log('收到ICE候选', message);
455
+ if (peerConnectionRef.current) {
456
+ await peerConnectionRef.current.addIceCandidate(new RTCIceCandidate(message.candidate));
457
+ }
458
+ };
459
+
460
+ // 计时器
461
+ useEffect(() => {
462
+ let timer: NodeJS.Timeout;
463
+
464
+ if (callStatus === 'connected') {
465
+ timer = setInterval(() => {
466
+ setCallDuration(prev => prev + 1);
467
+ }, 1000);
468
+ }
469
+
470
+ return () => {
471
+ if (timer) clearInterval(timer);
472
+ };
473
+ }, [callStatus]);
474
+
475
+ // 格式化通话时间
476
+ const formatTime = (seconds: number) => {
477
+ const mins = Math.floor(seconds / 60);
478
+ const secs = seconds % 60;
479
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
480
+ };
481
+
482
+ const handleHangup = () => {
483
+ // 发送挂断信号
484
+ if (wsRef.current) {
485
+ sendWebSocketMessage('hangup', {});
486
+ }
487
+
488
+ // 停止所有媒体轨道
489
+ if (localStreamRef.current) {
490
+ localStreamRef.current.getTracks().forEach(track => track.stop());
491
+ }
492
+
493
+ onHangup();
494
+ };
495
+
496
+ return (
497
+ <div className="call-interface">
498
+ <style dangerouslySetInnerHTML={{ __html: callStyle }}></style>
499
+ <div className="call-header">
500
+ <div className="contact-info">
501
+ <Avatar
502
+ size={80}
503
+ src={contactAvatar}
504
+ style={{ backgroundColor: '#1890ff' }}
505
+ >
506
+ {!contactAvatar && contactName.charAt(0)}
507
+ </Avatar>
508
+ <h2>{contactName}</h2>
509
+ <p className="call-status">
510
+ {callStatus === 'connecting' ? '正在连接...' : formatTime(callDuration)}
511
+ </p>
512
+ {
513
+ callStatus === 'connected' &&
514
+ <div style={{display: 'flex', flexDirection:'column', justifyContent: 'center', alignItems: 'center'}}>
515
+ {/*<div style={{width: 120, height: 50, position: 'relative'}}>*/}
516
+ {/* {*/}
517
+ {/* <div className='recwave' style={{width: 120, height: 50, display: workStatus === '正在聆听' ? 'block' : 'none'}}></div>*/}
518
+ {/* }*/}
519
+ {/*</div>*/}
520
+ <div className='recwave' style={{width: 120, height: 65}}>{
521
+ (isSpeakingNow && workStatus === '正在聆听')|| workStatus === '正在回复' ? <img src={ai_call_working}/>:<img src={ai_call_thinking}/>
522
+ }</div>
523
+ <p className="call-status">
524
+ {workStatus}
525
+ </p>
526
+ </div>
527
+
528
+ }
529
+ </div>
530
+ </div>
531
+
532
+ {/* 音频元素 */}
533
+ <audio ref={localAudioRef} autoPlay muted style={{ display: 'none' }} />
534
+ <audio ref={remoteAudioRef} autoPlay style={{ display: 'none' }} />
535
+
536
+ <div className="call-controls">
537
+ <Button
538
+ type="primary"
539
+ danger
540
+ shape="circle"
541
+ size="large"
542
+ onClick={handleHangup}
543
+ icon={<span className="hangup-icon">✕</span>}
544
+ />
545
+ </div>
546
+ </div>
547
+ );
548
+ };
549
+
550
550
  export default CallInterface;