dvc890 commited on
Commit
0ae61be
·
verified ·
1 Parent(s): 38975da

Update components/LiveAssistant.tsx

Browse files
Files changed (1) hide show
  1. components/LiveAssistant.tsx +43 -54
components/LiveAssistant.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
- import { Mic, Power, Loader2, Bot, Volume2, Radio, RefreshCw, ChevronDown, Square, StopCircle } from 'lucide-react';
4
  import { api } from '../services/api';
5
 
6
  // --- Audio Types & Helpers ---
@@ -17,7 +17,7 @@ function base64ToUint8Array(base64: string) {
17
  return bytes;
18
  }
19
 
20
- // Simple downsampling algorithm (e.g. 48000 -> 16000)
21
  function downsampleBuffer(buffer: Float32Array, inputRate: number, outputRate: number) {
22
  if (outputRate === inputRate) {
23
  return buffer;
@@ -179,7 +179,6 @@ export const LiveAssistant: React.FC = () => {
179
 
180
  const handleMinimize = () => {
181
  setIsOpen(false);
182
- // Restore previous button position if it exists
183
  if (prevButtonPos.current) {
184
  setPosition(prevButtonPos.current);
185
  }
@@ -205,10 +204,11 @@ export const LiveAssistant: React.FC = () => {
205
  if (!user) return;
206
 
207
  setStatus('CONNECTING');
208
- setTranscript('正在连接服务器...');
209
 
210
  try {
211
  initOutputAudioContext();
 
212
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
213
  const wsUrl = `${protocol}//${window.location.host}/ws/live?userId=${user._id}&username=${encodeURIComponent(user.username)}`;
214
 
@@ -216,10 +216,13 @@ export const LiveAssistant: React.FC = () => {
216
  const ws = new WebSocket(wsUrl);
217
  wsRef.current = ws;
218
 
219
- ws.onopen = () => {
220
  console.log('WS Open');
221
  setStatus('CONNECTED');
222
- setTranscript('连接成功,点击麦克风开始说话');
 
 
 
223
  };
224
 
225
  ws.onmessage = async (event) => {
@@ -238,14 +241,14 @@ export const LiveAssistant: React.FC = () => {
238
 
239
  ws.onerror = (e) => {
240
  console.error('WS Error', e);
241
- setTranscript('连接服务器失败');
242
  handleDisconnect();
243
  };
244
 
245
  } catch (e) {
246
  console.error("Connect failed", e);
247
  setStatus('DISCONNECTED');
248
- setTranscript('连接失败');
249
  }
250
  };
251
 
@@ -279,7 +282,7 @@ export const LiveAssistant: React.FC = () => {
279
 
280
  source.onended = () => {
281
  if (ctx.currentTime >= nextPlayTimeRef.current - 0.1) {
282
- setStatus('CONNECTED');
283
  }
284
  };
285
  }
@@ -296,17 +299,8 @@ export const LiveAssistant: React.FC = () => {
296
  }
297
  };
298
 
299
- const toggleRecording = async () => {
300
- if (status === 'LISTENING') {
301
- stopRecording();
302
- } else {
303
- startRecording();
304
- }
305
- };
306
-
307
  const startRecording = async () => {
308
- // Allow interrupting Speaker to talk
309
- if (status !== 'CONNECTED' && status !== 'SPEAKING') return;
310
 
311
  try {
312
  isRecordingRef.current = true;
@@ -338,7 +332,7 @@ export const LiveAssistant: React.FC = () => {
338
  const source = ctx.createMediaStreamSource(stream);
339
  const processor = ctx.createScriptProcessor(4096, 1, 1);
340
 
341
- // Mute gain to prevent feedback loop
342
  const muteGain = ctx.createGain();
343
  muteGain.gain.value = 0;
344
 
@@ -346,14 +340,14 @@ export const LiveAssistant: React.FC = () => {
346
  processor.connect(muteGain);
347
  muteGain.connect(ctx.destination);
348
 
349
- const contextSampleRate = ctx.sampleRate; // e.g. 48000 or 44100
350
 
351
  processor.onaudioprocess = (e) => {
352
  if (!isRecordingRef.current) return;
353
 
354
  const inputData = e.inputBuffer.getChannelData(0);
355
 
356
- // 3. DOWNSAMPLE if necessary (Force to 16000 for backend compatibility)
357
  const downsampledData = downsampleBuffer(inputData, contextSampleRate, TARGET_SAMPLE_RATE);
358
 
359
  // 4. Convert to PCM16
@@ -385,19 +379,18 @@ export const LiveAssistant: React.FC = () => {
385
  processorRef.current = processor;
386
 
387
  setStatus('LISTENING');
388
- setTranscript('正在聆听...');
389
 
390
  } catch (e) {
391
  console.error(e);
392
  isRecordingRef.current = false;
393
- setTranscript('无法访问麦克风');
394
  }
395
  };
396
 
397
  const stopRecording = () => {
398
  isRecordingRef.current = false;
399
 
400
- // Cleanup Mic Processing
401
  if (processorRef.current) {
402
  processorRef.current.disconnect();
403
  processorRef.current = null;
@@ -414,11 +407,6 @@ export const LiveAssistant: React.FC = () => {
414
  inputAudioContextRef.current.close().catch(()=>{});
415
  inputAudioContextRef.current = null;
416
  }
417
-
418
- if (status === 'LISTENING') {
419
- setStatus('THINKING');
420
- setTranscript('思考中...');
421
- }
422
  };
423
 
424
  const handleDisconnect = () => {
@@ -461,7 +449,7 @@ export const LiveAssistant: React.FC = () => {
461
 
462
  {isOpen && (
463
  <div className="bg-slate-900 w-80 md:w-96 rounded-3xl shadow-2xl border border-slate-700 overflow-hidden flex flex-col animate-in slide-in-from-bottom-5 fade-in duration-300 h-[500px]">
464
- {/* Draggable Header */}
465
  <div
466
  className="bg-slate-800/50 p-4 flex justify-between items-center text-white shrink-0 backdrop-blur-md cursor-move select-none"
467
  onMouseDown={handleDragStart}
@@ -469,55 +457,62 @@ export const LiveAssistant: React.FC = () => {
469
  >
470
  <div className="flex items-center gap-2">
471
  <div className={`w-2 h-2 rounded-full ${status === 'DISCONNECTED' ? 'bg-red-500' : 'bg-green-500 animate-pulse'}`}></div>
472
- <span className="font-bold text-sm">AI 实时通话 (Toggle模式)</span>
473
  </div>
474
  <div className="flex gap-2">
475
- <button onClick={handleDisconnect} title="重置" className="hover:bg-white/10 p-1.5 rounded-full text-gray-400 hover:text-white transition-colors cursor-pointer" onMouseDown={e=>e.stopPropagation()}><RefreshCw size={16}/></button>
 
 
476
  <button onClick={handleMinimize} title="最小化" className="hover:bg-white/10 p-1.5 rounded-full text-gray-400 hover:text-white transition-colors cursor-pointer" onMouseDown={e=>e.stopPropagation()}><ChevronDown size={20}/></button>
477
  </div>
478
  </div>
479
 
 
480
  <div className="flex-1 flex flex-col items-center justify-center p-6 relative">
481
  <div className={`relative w-40 h-40 flex items-center justify-center transition-all duration-500 ${status === 'LISTENING' ? 'scale-110' : 'scale-100'}`}>
 
482
  <div
483
  className={`absolute inset-0 rounded-full blur-2xl transition-all duration-300 ${
484
  status === 'SPEAKING' ? 'bg-blue-500/40' :
485
- status === 'LISTENING' ? 'bg-red-500/40' :
486
  status === 'THINKING' ? 'bg-purple-500/40' : 'bg-gray-500/10'
487
  }`}
488
  style={{ opacity: 0.5 + (volumeLevel / 200) }}
489
  ></div>
 
490
  <div
491
  className={`absolute inset-0 rounded-full border-2 border-white/10 transition-all duration-100`}
492
  style={{ transform: `scale(${1 + volumeLevel/100})` }}
493
  ></div>
 
494
  <div
495
  className={`absolute inset-0 rounded-full border border-white/20 transition-all duration-100 delay-75`}
496
  style={{ transform: `scale(${1 + volumeLevel/150})` }}
497
  ></div>
 
498
  <div className={`z-10 w-24 h-24 rounded-full flex items-center justify-center text-white shadow-xl transition-colors duration-500 ${
499
  status === 'SPEAKING' ? 'bg-blue-600' :
500
- status === 'LISTENING' ? 'bg-red-500' :
501
  status === 'THINKING' ? 'bg-purple-600' :
502
  status === 'CONNECTED' ? 'bg-slate-700' : 'bg-slate-800'
503
  }`}>
504
  {status === 'SPEAKING' ? <Volume2 size={40} className="animate-pulse"/> :
505
  status === 'LISTENING' ? <Mic size={40} className="animate-pulse"/> :
506
  status === 'THINKING' ? <Loader2 size={40} className="animate-spin"/> :
507
- status === 'CONNECTED' ? <Radio size={40}/> : <Power size={40}/>}
508
  </div>
509
  </div>
510
 
511
  <div className="mt-8 text-center px-4 w-full">
512
  <p className={`text-sm font-bold uppercase tracking-wider mb-2 ${
513
  status === 'SPEAKING' ? 'text-blue-400' :
514
- status === 'LISTENING' ? 'text-red-400' :
515
  status === 'THINKING' ? 'text-purple-400' : 'text-gray-500'
516
  }`}>
517
  {status === 'DISCONNECTED' ? '未连接' :
518
- status === 'CONNECTING' ? '连接中...' :
519
- status === 'CONNECTED' ? '准备就绪' :
520
- status === 'LISTENING' ? '正在录音...' :
521
  status === 'THINKING' ? '思考中...' : '正在说话'}
522
  </p>
523
  <p className="text-white text-lg font-medium leading-relaxed min-h-[3rem] line-clamp-3 transition-all">
@@ -526,32 +521,26 @@ export const LiveAssistant: React.FC = () => {
526
  </div>
527
  </div>
528
 
 
529
  <div className="p-6 pb-8 bg-slate-800/50 backdrop-blur-md border-t border-slate-700 flex justify-center">
530
  {status === 'DISCONNECTED' ? (
531
  <button
532
  onClick={handleConnect}
533
- className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-2xl font-bold flex items-center justify-center gap-2 transition-all hover:scale-[1.02] active:scale-95"
534
  >
535
- <Power size={20}/> 开启 AI 语音
536
  </button>
537
  ) : (
538
  <div className="flex items-center gap-4 w-full justify-center">
539
  <div className="relative group">
540
  <button
541
- onClick={toggleRecording}
542
- className={`w-20 h-20 rounded-full flex items-center justify-center shadow-lg transition-all transform ${
543
- status === 'LISTENING' ? 'bg-red-500 hover:bg-red-600 scale-110 ring-4 ring-red-500/30' :
544
- 'bg-white text-slate-900 hover:bg-gray-100 hover:scale-105'
545
- }`}
546
  >
547
- {status === 'LISTENING' ? (
548
- <Square size={28} fill="white" className="text-white" />
549
- ) : (
550
- <Mic size={32} fill="currentColor" />
551
- )}
552
  </button>
553
- <div className="absolute -bottom-8 left-1/2 -translate-x-1/2 text-xs text-gray-400 whitespace-nowrap opacity-80 mt-2">
554
- {status === 'LISTENING' ? '点击停止' : '点击说话'}
555
  </div>
556
  </div>
557
  </div>
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
+ import { Mic, Loader2, Bot, Volume2, Radio, RefreshCw, ChevronDown, Phone, PhoneOff } from 'lucide-react';
4
  import { api } from '../services/api';
5
 
6
  // --- Audio Types & Helpers ---
 
17
  return bytes;
18
  }
19
 
20
+ // Downsampling: Force input to 16kHz for backend compatibility
21
  function downsampleBuffer(buffer: Float32Array, inputRate: number, outputRate: number) {
22
  if (outputRate === inputRate) {
23
  return buffer;
 
179
 
180
  const handleMinimize = () => {
181
  setIsOpen(false);
 
182
  if (prevButtonPos.current) {
183
  setPosition(prevButtonPos.current);
184
  }
 
204
  if (!user) return;
205
 
206
  setStatus('CONNECTING');
207
+ setTranscript('正在呼叫 AI 助理...');
208
 
209
  try {
210
  initOutputAudioContext();
211
+
212
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
213
  const wsUrl = `${protocol}//${window.location.host}/ws/live?userId=${user._id}&username=${encodeURIComponent(user.username)}`;
214
 
 
216
  const ws = new WebSocket(wsUrl);
217
  wsRef.current = ws;
218
 
219
+ ws.onopen = async () => {
220
  console.log('WS Open');
221
  setStatus('CONNECTED');
222
+ setTranscript('通话已接通');
223
+
224
+ // Automatically start recording once connected (simulate phone call behavior)
225
+ await startRecording();
226
  };
227
 
228
  ws.onmessage = async (event) => {
 
241
 
242
  ws.onerror = (e) => {
243
  console.error('WS Error', e);
244
+ setTranscript('连接中断');
245
  handleDisconnect();
246
  };
247
 
248
  } catch (e) {
249
  console.error("Connect failed", e);
250
  setStatus('DISCONNECTED');
251
+ setTranscript('呼叫失败');
252
  }
253
  };
254
 
 
282
 
283
  source.onended = () => {
284
  if (ctx.currentTime >= nextPlayTimeRef.current - 0.1) {
285
+ setStatus('LISTENING');
286
  }
287
  };
288
  }
 
299
  }
300
  };
301
 
 
 
 
 
 
 
 
 
302
  const startRecording = async () => {
303
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
 
304
 
305
  try {
306
  isRecordingRef.current = true;
 
332
  const source = ctx.createMediaStreamSource(stream);
333
  const processor = ctx.createScriptProcessor(4096, 1, 1);
334
 
335
+ // Mute gain
336
  const muteGain = ctx.createGain();
337
  muteGain.gain.value = 0;
338
 
 
340
  processor.connect(muteGain);
341
  muteGain.connect(ctx.destination);
342
 
343
+ const contextSampleRate = ctx.sampleRate;
344
 
345
  processor.onaudioprocess = (e) => {
346
  if (!isRecordingRef.current) return;
347
 
348
  const inputData = e.inputBuffer.getChannelData(0);
349
 
350
+ // 3. Downsample to 16000Hz for API compatibility
351
  const downsampledData = downsampleBuffer(inputData, contextSampleRate, TARGET_SAMPLE_RATE);
352
 
353
  // 4. Convert to PCM16
 
379
  processorRef.current = processor;
380
 
381
  setStatus('LISTENING');
382
+ // Don't set transcript here, keep "Connected" message until AI speaks or user status changes
383
 
384
  } catch (e) {
385
  console.error(e);
386
  isRecordingRef.current = false;
387
+ setTranscript('麦克风访问失败');
388
  }
389
  };
390
 
391
  const stopRecording = () => {
392
  isRecordingRef.current = false;
393
 
 
394
  if (processorRef.current) {
395
  processorRef.current.disconnect();
396
  processorRef.current = null;
 
407
  inputAudioContextRef.current.close().catch(()=>{});
408
  inputAudioContextRef.current = null;
409
  }
 
 
 
 
 
410
  };
411
 
412
  const handleDisconnect = () => {
 
449
 
450
  {isOpen && (
451
  <div className="bg-slate-900 w-80 md:w-96 rounded-3xl shadow-2xl border border-slate-700 overflow-hidden flex flex-col animate-in slide-in-from-bottom-5 fade-in duration-300 h-[500px]">
452
+ {/* Header */}
453
  <div
454
  className="bg-slate-800/50 p-4 flex justify-between items-center text-white shrink-0 backdrop-blur-md cursor-move select-none"
455
  onMouseDown={handleDragStart}
 
457
  >
458
  <div className="flex items-center gap-2">
459
  <div className={`w-2 h-2 rounded-full ${status === 'DISCONNECTED' ? 'bg-red-500' : 'bg-green-500 animate-pulse'}`}></div>
460
+ <span className="font-bold text-sm">AI 实时通话</span>
461
  </div>
462
  <div className="flex gap-2">
463
+ {status === 'DISCONNECTED' && (
464
+ <button onClick={handleDisconnect} title="重置" className="hover:bg-white/10 p-1.5 rounded-full text-gray-400 hover:text-white transition-colors cursor-pointer" onMouseDown={e=>e.stopPropagation()}><RefreshCw size={16}/></button>
465
+ )}
466
  <button onClick={handleMinimize} title="最小化" className="hover:bg-white/10 p-1.5 rounded-full text-gray-400 hover:text-white transition-colors cursor-pointer" onMouseDown={e=>e.stopPropagation()}><ChevronDown size={20}/></button>
467
  </div>
468
  </div>
469
 
470
+ {/* Main Visual */}
471
  <div className="flex-1 flex flex-col items-center justify-center p-6 relative">
472
  <div className={`relative w-40 h-40 flex items-center justify-center transition-all duration-500 ${status === 'LISTENING' ? 'scale-110' : 'scale-100'}`}>
473
+ {/* Pulse Effect */}
474
  <div
475
  className={`absolute inset-0 rounded-full blur-2xl transition-all duration-300 ${
476
  status === 'SPEAKING' ? 'bg-blue-500/40' :
477
+ status === 'LISTENING' ? 'bg-green-500/40' :
478
  status === 'THINKING' ? 'bg-purple-500/40' : 'bg-gray-500/10'
479
  }`}
480
  style={{ opacity: 0.5 + (volumeLevel / 200) }}
481
  ></div>
482
+ {/* Ripple 1 */}
483
  <div
484
  className={`absolute inset-0 rounded-full border-2 border-white/10 transition-all duration-100`}
485
  style={{ transform: `scale(${1 + volumeLevel/100})` }}
486
  ></div>
487
+ {/* Ripple 2 */}
488
  <div
489
  className={`absolute inset-0 rounded-full border border-white/20 transition-all duration-100 delay-75`}
490
  style={{ transform: `scale(${1 + volumeLevel/150})` }}
491
  ></div>
492
+ {/* Center Icon */}
493
  <div className={`z-10 w-24 h-24 rounded-full flex items-center justify-center text-white shadow-xl transition-colors duration-500 ${
494
  status === 'SPEAKING' ? 'bg-blue-600' :
495
+ status === 'LISTENING' ? 'bg-green-600' :
496
  status === 'THINKING' ? 'bg-purple-600' :
497
  status === 'CONNECTED' ? 'bg-slate-700' : 'bg-slate-800'
498
  }`}>
499
  {status === 'SPEAKING' ? <Volume2 size={40} className="animate-pulse"/> :
500
  status === 'LISTENING' ? <Mic size={40} className="animate-pulse"/> :
501
  status === 'THINKING' ? <Loader2 size={40} className="animate-spin"/> :
502
+ status === 'CONNECTED' ? <Radio size={40}/> : <Phone size={40}/>}
503
  </div>
504
  </div>
505
 
506
  <div className="mt-8 text-center px-4 w-full">
507
  <p className={`text-sm font-bold uppercase tracking-wider mb-2 ${
508
  status === 'SPEAKING' ? 'text-blue-400' :
509
+ status === 'LISTENING' ? 'text-green-400' :
510
  status === 'THINKING' ? 'text-purple-400' : 'text-gray-500'
511
  }`}>
512
  {status === 'DISCONNECTED' ? '未连接' :
513
+ status === 'CONNECTING' ? '呼叫中...' :
514
+ status === 'CONNECTED' ? '通话建立' :
515
+ status === 'LISTENING' ? '正在聆听...' :
516
  status === 'THINKING' ? '思考中...' : '正在说话'}
517
  </p>
518
  <p className="text-white text-lg font-medium leading-relaxed min-h-[3rem] line-clamp-3 transition-all">
 
521
  </div>
522
  </div>
523
 
524
+ {/* Controls */}
525
  <div className="p-6 pb-8 bg-slate-800/50 backdrop-blur-md border-t border-slate-700 flex justify-center">
526
  {status === 'DISCONNECTED' ? (
527
  <button
528
  onClick={handleConnect}
529
+ className="w-full py-4 bg-green-500 hover:bg-green-600 text-white rounded-2xl font-bold flex items-center justify-center gap-2 transition-all hover:scale-[1.02] active:scale-95 shadow-lg shadow-green-500/30"
530
  >
531
+ <Phone size={24} fill="currentColor" /> 呼叫 AI 助理
532
  </button>
533
  ) : (
534
  <div className="flex items-center gap-4 w-full justify-center">
535
  <div className="relative group">
536
  <button
537
+ onClick={handleDisconnect}
538
+ className="w-20 h-20 rounded-full flex items-center justify-center shadow-2xl transition-all transform bg-red-500 hover:bg-red-600 text-white scale-100 hover:scale-110 active:scale-95 ring-4 ring-red-100"
 
 
 
539
  >
540
+ <PhoneOff size={32} />
 
 
 
 
541
  </button>
542
+ <div className="absolute -bottom-8 left-1/2 -translate-x-1/2 text-xs text-gray-400 whitespace-nowrap opacity-80 mt-2 font-bold">
543
+ 挂断
544
  </div>
545
  </div>
546
  </div>