Update workflow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle, PhoneCall } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
||||
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
|
||||
import { Assistant, TabValue } from '../types';
|
||||
@@ -385,12 +385,12 @@ const BotIcon = ({className}: {className?: string}) => (
|
||||
);
|
||||
|
||||
// --- Debug Drawer Component ---
|
||||
|
||||
const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: Assistant }> = ({ isOpen, onClose, assistant }) => {
|
||||
export const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: Assistant }> = ({ isOpen, onClose, assistant }) => {
|
||||
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
|
||||
const [messages, setMessages] = useState<{role: 'user' | 'model', text: string}[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle');
|
||||
|
||||
// Media State
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -400,19 +400,24 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedCamera, setSelectedCamera] = useState<string>('');
|
||||
const [selectedMic, setSelectedMic] = useState<string>('');
|
||||
const [isSwapped, setIsSwapped] = useState(false); // False: AI is Big, Local is Small. True: Local is Big, AI is Small.
|
||||
const [isSwapped, setIsSwapped] = useState(false);
|
||||
|
||||
// Initialize with opener
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||
if (mode === 'text') {
|
||||
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||
} else {
|
||||
setMessages([]);
|
||||
setCallStatus('idle');
|
||||
}
|
||||
} else {
|
||||
// Reset and stop media when closed
|
||||
setMode('text');
|
||||
stopMedia();
|
||||
setIsSwapped(false);
|
||||
setCallStatus('idle');
|
||||
}
|
||||
}, [isOpen, assistant]);
|
||||
}, [isOpen, assistant, mode]);
|
||||
|
||||
// Auto-scroll logic
|
||||
useEffect(() => {
|
||||
@@ -429,13 +434,10 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
const dev = await navigator.mediaDevices.enumerateDevices();
|
||||
setDevices(dev);
|
||||
|
||||
const cams = dev.filter(d => d.kind === 'videoinput');
|
||||
const mics = dev.filter(d => d.kind === 'audioinput');
|
||||
|
||||
if (cams.length > 0 && !selectedCamera) setSelectedCamera(cams[0].deviceId);
|
||||
if (mics.length > 0 && !selectedMic) setSelectedMic(mics[0].deviceId);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error enumerating devices", e);
|
||||
}
|
||||
@@ -444,7 +446,6 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
}
|
||||
}, [isOpen, mode]);
|
||||
|
||||
// Handle Video/Media stream
|
||||
const stopMedia = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
@@ -454,7 +455,7 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
|
||||
useEffect(() => {
|
||||
const handleStream = async () => {
|
||||
if (isOpen && mode === 'video') {
|
||||
if (isOpen && mode === 'video' && callStatus === 'active') {
|
||||
try {
|
||||
stopMedia();
|
||||
const constraints = {
|
||||
@@ -469,14 +470,28 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
} catch (err) {
|
||||
console.error("Failed to access camera/mic:", err);
|
||||
}
|
||||
} else {
|
||||
} else if (callStatus !== 'active') {
|
||||
stopMedia();
|
||||
}
|
||||
};
|
||||
|
||||
handleStream();
|
||||
return () => stopMedia();
|
||||
}, [mode, isOpen, selectedCamera, selectedMic]);
|
||||
}, [mode, isOpen, selectedCamera, selectedMic, callStatus]);
|
||||
|
||||
const handleCall = () => {
|
||||
setCallStatus('calling');
|
||||
setTimeout(() => {
|
||||
setCallStatus('active');
|
||||
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleHangup = () => {
|
||||
stopMedia();
|
||||
setCallStatus('idle');
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputText.trim()) return;
|
||||
@@ -509,12 +524,6 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
stopMedia();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Reusable Messages List Component
|
||||
const TranscriptionLog = () => (
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
|
||||
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4">暂无转写记录</div>}
|
||||
@@ -530,171 +539,113 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper to render the Local Video Element
|
||||
const renderLocalVideo = (isSmall: boolean) => (
|
||||
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover transform scale-x-[-1]"
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">
|
||||
Me
|
||||
</div>
|
||||
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover transform scale-x-[-1]" />
|
||||
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">Me</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper to render the "Remote" AI Video Element (Simulated)
|
||||
const renderRemoteVideo = (isSmall: boolean) => (
|
||||
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
|
||||
<div className={`absolute rounded-full bg-primary/40 animate-ping ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}></div>
|
||||
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
|
||||
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
|
||||
</div>
|
||||
</div>
|
||||
{!isSmall && (
|
||||
<div className="mt-4 font-mono text-primary animate-pulse text-sm">
|
||||
{assistant.name}
|
||||
</div>
|
||||
)}
|
||||
{!isSmall && <div className="mt-4 font-mono text-primary animate-pulse text-sm">{assistant.name}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} onClose={handleClose} title={`调试: ${assistant.name}`}>
|
||||
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'text' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('text')}
|
||||
>
|
||||
<MessageSquare className="inline w-4 h-4 mr-1"/> 文本
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'voice' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('voice')}
|
||||
>
|
||||
<Mic className="inline w-4 h-4 mr-1"/> 语音
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'video' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('video')}
|
||||
>
|
||||
<Video className="inline w-4 h-4 mr-1"/> 视频
|
||||
</button>
|
||||
{(['text', 'voice', 'video'] as const).map(m => (
|
||||
<button key={m} className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === m ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`} onClick={() => setMode(m)}>
|
||||
{m === 'text' && <MessageSquare className="inline w-4 h-4 mr-1"/>}
|
||||
{m === 'voice' && <Mic className="inline w-4 h-4 mr-1"/>}
|
||||
{m === 'video' && <Video className="inline w-4 h-4 mr-1"/>}
|
||||
{m === 'text' ? '文本' : m === 'voice' ? '语音' : '视频'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4 gap-2">
|
||||
{mode === 'text' && <TranscriptionLog />}
|
||||
|
||||
{mode === 'voice' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Visualizer Area */}
|
||||
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/20 pointer-events-none"></div>
|
||||
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
|
||||
<Mic className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm relative z-10">通话中...</p>
|
||||
</div>
|
||||
{/* Transcript */}
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1">实时转写 / Live Transcription</h4>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'video' && (
|
||||
<div className="flex flex-col h-full space-y-2">
|
||||
{/* Video Area (Top) */}
|
||||
<div className="h-3/5 shrink-0 flex flex-col gap-2">
|
||||
{/* Device Settings Bar */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
||||
value={selectedCamera}
|
||||
onChange={(e) => setSelectedCamera(e.target.value)}
|
||||
>
|
||||
{devices.filter(d => d.kind === 'videoinput').map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Camera...`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
||||
value={selectedMic}
|
||||
onChange={(e) => setSelectedMic(e.target.value)}
|
||||
>
|
||||
{devices.filter(d => d.kind === 'audioinput').map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Mic...`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Container (PiP) */}
|
||||
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
|
||||
{/* Main Window */}
|
||||
<div className="absolute inset-0">
|
||||
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
|
||||
</div>
|
||||
{/* Small Window */}
|
||||
<div className="absolute bottom-2 right-2 w-24 h-36 z-10 transition-all duration-300">
|
||||
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
|
||||
</div>
|
||||
{/* Swap Button */}
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full h-7 w-7 bg-black/50 hover:bg-primary/80 backdrop-blur text-white border border-white/20"
|
||||
onClick={() => setIsSwapped(!isSwapped)}
|
||||
title="切换窗口"
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4">
|
||||
{mode === 'text' ? (
|
||||
<TranscriptionLog />
|
||||
) : callStatus === 'idle' ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-6 border border-white/5 rounded-xl bg-black/20 animate-in fade-in zoom-in-95">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
|
||||
<div className="relative h-24 w-24 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
|
||||
{mode === 'voice' ? <Mic className="h-10 w-10 text-muted-foreground" /> : <Video className="h-10 w-10 text-muted-foreground" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript Area (Bottom) */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1">实时转写 / Live Transcription</h4>
|
||||
<TranscriptionLog />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-bold text-white mb-1">准备就绪</h3>
|
||||
<p className="text-xs text-muted-foreground">点击下方按钮开启人机交互测试</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions - Unified Input */}
|
||||
<div className="shrink-0 space-y-2">
|
||||
{(mode === 'voice' || mode === 'video') && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" size="sm" className="w-full shadow-red-500/20 shadow-lg" onClick={handleClose}>
|
||||
<PhoneOff className="mr-2 h-3 w-3" /> 挂断通话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder={mode === 'text' ? "输入消息..." : "输入文字模拟语音/Input to simulate speech..."}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="icon" onClick={handleSend} disabled={isLoading}>
|
||||
<Send className="h-4 w-4" />
|
||||
<Button onClick={handleCall} className="w-48 h-12 rounded-full bg-green-500 hover:bg-green-600 shadow-[0_0_20px_rgba(34,197,94,0.4)] text-base font-bold">
|
||||
<PhoneCall className="mr-2 h-5 w-5" /> 发起呼叫
|
||||
</Button>
|
||||
</div>
|
||||
) : callStatus === 'calling' ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-6">
|
||||
<div className="h-24 w-24 rounded-full bg-primary/20 flex items-center justify-center animate-bounce">
|
||||
<PhoneCall className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-primary font-mono text-sm tracking-widest animate-pulse">CALLING...</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">正在连接 AI 服务</p>
|
||||
</div>
|
||||
<Button onClick={handleHangup} variant="destructive" className="rounded-full h-10 px-8">取消</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0 space-y-2">
|
||||
{mode === 'voice' ? (
|
||||
<div className="flex flex-col h-full animate-in fade-in">
|
||||
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
|
||||
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
|
||||
<Mic className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm relative z-10">通话中...</p>
|
||||
</div>
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1 uppercase tracking-tight">转写日志</h4>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full space-y-2 animate-in fade-in">
|
||||
<div className="h-3/5 shrink-0 flex flex-col gap-2">
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<select className="flex-1 text-xs bg-white/5 border border-white/10 rounded px-2 py-1 text-foreground" value={selectedCamera} onChange={e => setSelectedCamera(e.target.value)}>
|
||||
{devices.filter(d => d.kind === 'videoinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Camera'}</option>)}
|
||||
</select>
|
||||
<select className="flex-1 text-xs bg-white/5 border border-white/10 rounded px-2 py-1 text-foreground" value={selectedMic} onChange={e => setSelectedMic(e.target.value)}>
|
||||
{devices.filter(d => d.kind === 'audioinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Mic'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
|
||||
<div className="absolute inset-0">{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}</div>
|
||||
<div className="absolute bottom-2 right-2 w-24 h-36 z-10">{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}</div>
|
||||
<button className="absolute top-2 right-2 z-20 h-8 w-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center text-white border border-white/10 hover:bg-primary/80" onClick={() => setIsSwapped(!isSwapped)}><ArrowLeftRight className="h-3.5 w-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
)}
|
||||
<Button variant="destructive" size="sm" className="w-full h-10 font-bold" onClick={handleHangup}>
|
||||
<PhoneOff className="mr-2 h-4 w-4" /> 挂断通话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-2">
|
||||
<div className="flex space-x-2">
|
||||
<Input value={inputText} onChange={e => setInputText(e.target.value)} placeholder={mode === 'text' ? "输入消息..." : "输入文本模拟交互..."} onKeyDown={e => e.key === 'Enter' && handleSend()} disabled={isLoading || (mode !== 'text' && callStatus !== 'active')} className="flex-1" />
|
||||
<Button size="icon" onClick={handleSend} disabled={isLoading || (mode !== 'text' && callStatus !== 'active')}><Send className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user