Spaces:
Sleeping
Sleeping
Update components/LiveAssistant.tsx
Browse files- 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,
|
| 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 |
-
//
|
| 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('
|
| 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 |
-
|
| 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
|
| 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;
|
| 350 |
|
| 351 |
processor.onaudioprocess = (e) => {
|
| 352 |
if (!isRecordingRef.current) return;
|
| 353 |
|
| 354 |
const inputData = e.inputBuffer.getChannelData(0);
|
| 355 |
|
| 356 |
-
// 3.
|
| 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 |
-
|
| 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 |
-
{/*
|
| 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
|
| 473 |
</div>
|
| 474 |
<div className="flex gap-2">
|
| 475 |
-
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 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}/> : <
|
| 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-
|
| 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-
|
| 534 |
>
|
| 535 |
-
<
|
| 536 |
</button>
|
| 537 |
) : (
|
| 538 |
<div className="flex items-center gap-4 w-full justify-center">
|
| 539 |
<div className="relative group">
|
| 540 |
<button
|
| 541 |
-
onClick={
|
| 542 |
-
className=
|
| 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 |
-
{
|
| 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 |
-
|
| 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>
|