dvc890 commited on
Commit
dac3e2f
·
verified ·
1 Parent(s): 4859284

Upload 58 files

Browse files
Files changed (1) hide show
  1. components/LiveAssistant.tsx +162 -16
components/LiveAssistant.tsx CHANGED
@@ -1,6 +1,6 @@
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
- import { Mic, X, Power, Loader2, Bot, Volume2, Radio, Activity, RefreshCw, ChevronDown } from 'lucide-react';
4
  import { api } from '../services/api';
5
 
6
  // --- Audio Types & Helpers ---
@@ -23,7 +23,16 @@ export const LiveAssistant: React.FC = () => {
23
  const [transcript, setTranscript] = useState('');
24
  const [volumeLevel, setVolumeLevel] = useState(0);
25
 
26
- const audioContextRef = useRef<AudioContext | null>(null);
 
 
 
 
 
 
 
 
 
27
  const mediaStreamRef = useRef<MediaStream | null>(null);
28
  const processorRef = useRef<ScriptProcessorNode | null>(null);
29
  const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
@@ -33,16 +42,75 @@ export const LiveAssistant: React.FC = () => {
33
  const nextPlayTimeRef = useRef<number>(0);
34
  const analyserRef = useRef<AnalyserNode | null>(null);
35
  const volumeIntervalRef = useRef<any>(null);
 
 
 
36
 
37
  useEffect(() => {
38
  if (!isOpen) {
39
  handleDisconnect();
40
  }
 
 
 
 
 
 
 
 
 
 
41
  return () => {
42
  handleDisconnect();
43
  };
44
  }, [isOpen]);
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  useEffect(() => {
47
  if (status === 'DISCONNECTED') {
48
  setVolumeLevel(0);
@@ -59,7 +127,48 @@ export const LiveAssistant: React.FC = () => {
59
  return () => clearInterval(volumeIntervalRef.current);
60
  }, [status]);
61
 
62
- const initAudioContext = () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  if (!audioContextRef.current) {
64
  // @ts-ignore
65
  const AudioCtor = window.AudioContext || window.webkitAudioContext;
@@ -88,7 +197,7 @@ export const LiveAssistant: React.FC = () => {
88
  setTranscript('正在连接服务器...');
89
 
90
  try {
91
- initAudioContext();
92
 
93
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
94
  // Append User Info to ensure backend creates a distinct session context
@@ -186,18 +295,30 @@ export const LiveAssistant: React.FC = () => {
186
  setStatus('CONNECTED');
187
  }
188
 
 
 
189
  const stream = await navigator.mediaDevices.getUserMedia({ audio: {
190
  sampleRate: INPUT_SAMPLE_RATE,
191
  channelCount: 1,
192
- echoCancellation: true
 
 
193
  }});
194
  mediaStreamRef.current = stream;
195
 
196
- const ctx = new (window.AudioContext || (window as any).webkitAudioContext)({ sampleRate: INPUT_SAMPLE_RATE });
 
 
 
 
 
197
  const source = ctx.createMediaStreamSource(stream);
198
  const processor = ctx.createScriptProcessor(4096, 1, 1);
199
 
200
  processor.onaudioprocess = (e) => {
 
 
 
201
  const inputData = e.inputBuffer.getChannelData(0);
202
  const l = inputData.length;
203
  const int16Data = new Int16Array(l);
@@ -231,14 +352,18 @@ export const LiveAssistant: React.FC = () => {
231
 
232
  } catch (e) {
233
  console.error(e);
 
234
  setTranscript('无法访问麦克风');
235
  }
236
  };
237
 
238
  const stopRecording = () => {
239
- if (status !== 'LISTENING') return;
 
240
 
 
241
  if (processorRef.current) {
 
242
  processorRef.current.disconnect();
243
  processorRef.current = null;
244
  }
@@ -250,6 +375,11 @@ export const LiveAssistant: React.FC = () => {
250
  mediaStreamRef.current.getTracks().forEach(t => t.stop());
251
  mediaStreamRef.current = null;
252
  }
 
 
 
 
 
253
 
254
  setStatus('THINKING');
255
  setTranscript('思考中...');
@@ -273,26 +403,41 @@ export const LiveAssistant: React.FC = () => {
273
  if (!api.auth.getCurrentUser()) return null;
274
 
275
  return (
276
- <div className="fixed bottom-6 right-6 z-[9999]">
 
 
 
 
277
  {!isOpen && (
278
- <button
279
- onClick={() => setIsOpen(true)}
280
- className="w-14 h-14 rounded-full bg-gradient-to-br from-indigo-600 to-purple-600 text-white shadow-2xl flex items-center justify-center hover:scale-110 transition-transform cursor-pointer border-2 border-white/20 animate-in zoom-in"
 
281
  >
282
- <Bot size={28} />
283
- </button>
 
 
 
 
 
284
  )}
285
 
286
  {isOpen && (
287
  <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]">
288
- <div className="bg-slate-800/50 p-4 flex justify-between items-center text-white shrink-0 backdrop-blur-md">
 
 
 
 
 
289
  <div className="flex items-center gap-2">
290
  <div className={`w-2 h-2 rounded-full ${status === 'DISCONNECTED' ? 'bg-red-500' : 'bg-green-500 animate-pulse'}`}></div>
291
  <span className="font-bold text-sm">AI 实时通话 (代理模式)</span>
292
  </div>
293
  <div className="flex gap-2">
294
- <button onClick={handleDisconnect} title="重置" className="hover:bg-white/10 p-1.5 rounded-full text-gray-400 hover:text-white transition-colors"><RefreshCw size={16}/></button>
295
- <button onClick={() => setIsOpen(false)} title="最小化" className="hover:bg-white/10 p-1.5 rounded-full text-gray-400 hover:text-white transition-colors"><ChevronDown size={20}/></button>
296
  </div>
297
  </div>
298
 
@@ -359,6 +504,7 @@ export const LiveAssistant: React.FC = () => {
359
  <button
360
  onMouseDown={startRecording}
361
  onMouseUp={stopRecording}
 
362
  onTouchStart={(e) => { e.preventDefault(); startRecording(); }}
363
  onTouchEnd={(e) => { e.preventDefault(); stopRecording(); }}
364
  className={`w-20 h-20 rounded-full flex items-center justify-center shadow-lg transition-all transform ${
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
+ import { Mic, X, Power, Loader2, Bot, Volume2, Radio, Activity, RefreshCw, ChevronDown, Move } from 'lucide-react';
4
  import { api } from '../services/api';
5
 
6
  // --- Audio Types & Helpers ---
 
23
  const [transcript, setTranscript] = useState('');
24
  const [volumeLevel, setVolumeLevel] = useState(0);
25
 
26
+ // Dragging State
27
+ const [position, setPosition] = useState<{x: number, y: number} | null>(null);
28
+ const containerRef = useRef<HTMLDivElement>(null);
29
+ const dragRef = useRef({ isDragging: false, startX: 0, startY: 0, initialLeft: 0, initialTop: 0 });
30
+ const hasMovedRef = useRef(false);
31
+
32
+ // Refs
33
+ const audioContextRef = useRef<AudioContext | null>(null); // Output Context
34
+ const inputAudioContextRef = useRef<AudioContext | null>(null); // Input Context (New)
35
+
36
  const mediaStreamRef = useRef<MediaStream | null>(null);
37
  const processorRef = useRef<ScriptProcessorNode | null>(null);
38
  const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
 
42
  const nextPlayTimeRef = useRef<number>(0);
43
  const analyserRef = useRef<AnalyserNode | null>(null);
44
  const volumeIntervalRef = useRef<any>(null);
45
+
46
+ // Track recording state for the processor callback to avoid stale closures or race conditions
47
+ const isRecordingRef = useRef(false);
48
 
49
  useEffect(() => {
50
  if (!isOpen) {
51
  handleDisconnect();
52
  }
53
+ // When opening/closing, ensure we stay within bounds if position exists
54
+ if (position && containerRef.current) {
55
+ const { innerWidth, innerHeight } = window;
56
+ const rect = containerRef.current.getBoundingClientRect();
57
+ const newX = Math.min(Math.max(0, position.x), innerWidth - rect.width);
58
+ const newY = Math.min(Math.max(0, position.y), innerHeight - rect.height);
59
+ if (newX !== position.x || newY !== position.y) {
60
+ setPosition({ x: newX, y: newY });
61
+ }
62
+ }
63
  return () => {
64
  handleDisconnect();
65
  };
66
  }, [isOpen]);
67
 
68
+ useEffect(() => {
69
+ const handleMove = (e: MouseEvent | TouchEvent) => {
70
+ if (!dragRef.current.isDragging) return;
71
+
72
+ const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX;
73
+ const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY;
74
+
75
+ const deltaX = clientX - dragRef.current.startX;
76
+ const deltaY = clientY - dragRef.current.startY;
77
+
78
+ if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
79
+ hasMovedRef.current = true;
80
+ }
81
+
82
+ let newX = dragRef.current.initialLeft + deltaX;
83
+ let newY = dragRef.current.initialTop + deltaY;
84
+
85
+ // Bounds check
86
+ if (containerRef.current) {
87
+ const rect = containerRef.current.getBoundingClientRect();
88
+ const { innerWidth, innerHeight } = window;
89
+ newX = Math.min(Math.max(0, newX), innerWidth - rect.width);
90
+ newY = Math.min(Math.max(0, newY), innerHeight - rect.height);
91
+ }
92
+
93
+ setPosition({ x: newX, y: newY });
94
+ };
95
+
96
+ const handleUp = () => {
97
+ dragRef.current.isDragging = false;
98
+ document.body.style.userSelect = '';
99
+ };
100
+
101
+ window.addEventListener('mousemove', handleMove);
102
+ window.addEventListener('mouseup', handleUp);
103
+ window.addEventListener('touchmove', handleMove, { passive: false });
104
+ window.addEventListener('touchend', handleUp);
105
+
106
+ return () => {
107
+ window.removeEventListener('mousemove', handleMove);
108
+ window.removeEventListener('mouseup', handleUp);
109
+ window.removeEventListener('touchmove', handleMove);
110
+ window.removeEventListener('touchend', handleUp);
111
+ };
112
+ }, []);
113
+
114
  useEffect(() => {
115
  if (status === 'DISCONNECTED') {
116
  setVolumeLevel(0);
 
127
  return () => clearInterval(volumeIntervalRef.current);
128
  }, [status]);
129
 
130
+ const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => {
131
+ if (!containerRef.current) return;
132
+
133
+ // Prevent default to stop scrolling on mobile while dragging
134
+ // e.preventDefault(); // Note: might block click if not careful, handled by checking dragging state
135
+
136
+ const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX;
137
+ const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY;
138
+
139
+ const rect = containerRef.current.getBoundingClientRect();
140
+
141
+ // If position is null (initial state), set it to current computed position
142
+ if (!position) {
143
+ setPosition({ x: rect.left, y: rect.top });
144
+ dragRef.current = {
145
+ isDragging: true,
146
+ startX: clientX,
147
+ startY: clientY,
148
+ initialLeft: rect.left,
149
+ initialTop: rect.top
150
+ };
151
+ } else {
152
+ dragRef.current = {
153
+ isDragging: true,
154
+ startX: clientX,
155
+ startY: clientY,
156
+ initialLeft: position.x,
157
+ initialTop: position.y
158
+ };
159
+ }
160
+
161
+ hasMovedRef.current = false;
162
+ document.body.style.userSelect = 'none';
163
+ };
164
+
165
+ const handleToggleOpen = () => {
166
+ if (!hasMovedRef.current) {
167
+ setIsOpen(prev => !prev);
168
+ }
169
+ };
170
+
171
+ const initOutputAudioContext = () => {
172
  if (!audioContextRef.current) {
173
  // @ts-ignore
174
  const AudioCtor = window.AudioContext || window.webkitAudioContext;
 
197
  setTranscript('正在连接服务器...');
198
 
199
  try {
200
+ initOutputAudioContext();
201
 
202
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
203
  // Append User Info to ensure backend creates a distinct session context
 
295
  setStatus('CONNECTED');
296
  }
297
 
298
+ isRecordingRef.current = true; // Mark as recording
299
+
300
  const stream = await navigator.mediaDevices.getUserMedia({ audio: {
301
  sampleRate: INPUT_SAMPLE_RATE,
302
  channelCount: 1,
303
+ echoCancellation: true,
304
+ autoGainControl: true,
305
+ noiseSuppression: true
306
  }});
307
  mediaStreamRef.current = stream;
308
 
309
+ // Use a new context for input to ensure 16k rate if browser supports specific ctx rate
310
+ // @ts-ignore
311
+ const AudioCtor = window.AudioContext || window.webkitAudioContext;
312
+ const ctx = new AudioCtor({ sampleRate: INPUT_SAMPLE_RATE });
313
+ inputAudioContextRef.current = ctx;
314
+
315
  const source = ctx.createMediaStreamSource(stream);
316
  const processor = ctx.createScriptProcessor(4096, 1, 1);
317
 
318
  processor.onaudioprocess = (e) => {
319
+ // Crucial check: If we decided to stop recording, do not process even if event fires
320
+ if (!isRecordingRef.current) return;
321
+
322
  const inputData = e.inputBuffer.getChannelData(0);
323
  const l = inputData.length;
324
  const int16Data = new Int16Array(l);
 
352
 
353
  } catch (e) {
354
  console.error(e);
355
+ isRecordingRef.current = false;
356
  setTranscript('无法访问麦克风');
357
  }
358
  };
359
 
360
  const stopRecording = () => {
361
+ if (!isRecordingRef.current) return;
362
+ isRecordingRef.current = false; // Stop processing loop immediately
363
 
364
+ // Cleanup Mic Processing
365
  if (processorRef.current) {
366
+ processorRef.current.onaudioprocess = null; // Detach handler
367
  processorRef.current.disconnect();
368
  processorRef.current = null;
369
  }
 
375
  mediaStreamRef.current.getTracks().forEach(t => t.stop());
376
  mediaStreamRef.current = null;
377
  }
378
+ // Force close input context to ensure hardware releases
379
+ if (inputAudioContextRef.current) {
380
+ inputAudioContextRef.current.close().catch(()=>{});
381
+ inputAudioContextRef.current = null;
382
+ }
383
 
384
  setStatus('THINKING');
385
  setTranscript('思考中...');
 
403
  if (!api.auth.getCurrentUser()) return null;
404
 
405
  return (
406
+ <div
407
+ ref={containerRef}
408
+ className={`fixed z-[9999] touch-none ${position ? '' : 'bottom-6 right-6'}`}
409
+ style={position ? { left: position.x, top: position.y } : undefined}
410
+ >
411
  {!isOpen && (
412
+ <div
413
+ className="cursor-move"
414
+ onMouseDown={handleDragStart}
415
+ onTouchStart={handleDragStart}
416
  >
417
+ <button
418
+ onClick={handleToggleOpen}
419
+ className="w-14 h-14 rounded-full bg-gradient-to-br from-indigo-600 to-purple-600 text-white shadow-2xl flex items-center justify-center hover:scale-110 transition-transform cursor-pointer border-2 border-white/20 animate-in zoom-in"
420
+ >
421
+ <Bot size={28} />
422
+ </button>
423
+ </div>
424
  )}
425
 
426
  {isOpen && (
427
  <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]">
428
+ {/* Draggable Header */}
429
+ <div
430
+ className="bg-slate-800/50 p-4 flex justify-between items-center text-white shrink-0 backdrop-blur-md cursor-move select-none"
431
+ onMouseDown={handleDragStart}
432
+ onTouchStart={handleDragStart}
433
+ >
434
  <div className="flex items-center gap-2">
435
  <div className={`w-2 h-2 rounded-full ${status === 'DISCONNECTED' ? 'bg-red-500' : 'bg-green-500 animate-pulse'}`}></div>
436
  <span className="font-bold text-sm">AI 实时通话 (代理模式)</span>
437
  </div>
438
  <div className="flex gap-2">
439
+ <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>
440
+ <button onClick={() => setIsOpen(false)} 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>
441
  </div>
442
  </div>
443
 
 
504
  <button
505
  onMouseDown={startRecording}
506
  onMouseUp={stopRecording}
507
+ onMouseLeave={stopRecording}
508
  onTouchStart={(e) => { e.preventDefault(); startRecording(); }}
509
  onTouchEnd={(e) => { e.preventDefault(); stopRecording(); }}
510
  className={`w-20 h-20 rounded-full flex items-center justify-center shadow-lg transition-all transform ${