Files
AI-VideoAssistant/web/pages/Assistants.tsx

2121 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { Plus, Search, Play, Copy, Trash2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, Rocket, AlertTriangle, PhoneCall, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Wrench, Globe, Terminal, X, ClipboardCheck, Sparkles, Volume2, Timer, ChevronDown, Database, Server, Zap, ExternalLink, Key, BrainCircuit, Ear, Book, Filter } from 'lucide-react';
import { Button, Input, Badge, Drawer, Dialog } from '../components/UI';
import { ASRModel, Assistant, KnowledgeBase, LLMModel, TabValue, Tool, Voice } from '../types';
import { createAssistant, deleteAssistant, fetchASRModels, fetchAssistants, fetchKnowledgeBases, fetchLLMModels, fetchTools, fetchVoices, updateAssistant as updateAssistantApi } from '../services/backendApi';
const isSiliconflowVendor = (vendor?: string) => {
const normalized = String(vendor || '').trim().toLowerCase();
return normalized === 'siliconflow' || normalized === '硅基流动';
};
const SILICONFLOW_DEFAULT_MODEL = 'FunAudioLLM/CosyVoice2-0.5B';
const buildSiliconflowVoiceKey = (voiceId: string, model?: string) => {
const id = String(voiceId || '').trim();
if (!id) return '';
if (id.includes(':')) return id;
return `${model || SILICONFLOW_DEFAULT_MODEL}:${id}`;
};
const resolveRuntimeTtsVoice = (selectedVoiceId: string, voice: Voice) => {
const explicitKey = String(voice.voiceKey || '').trim();
if (!isSiliconflowVendor(voice.vendor)) {
return explicitKey || selectedVoiceId;
}
if (voice.isSystem) {
const canonical = buildSiliconflowVoiceKey(selectedVoiceId, voice.model);
if (!explicitKey) return canonical;
const explicitSuffix = explicitKey.includes(':') ? explicitKey.split(':').pop() : explicitKey;
if (explicitSuffix && explicitSuffix !== selectedVoiceId) return canonical;
}
return explicitKey || buildSiliconflowVoiceKey(selectedVoiceId, voice.model);
};
const renderToolIcon = (icon: string) => {
const className = 'w-4 h-4';
const map: Record<string, React.ReactNode> = {
Camera: <Camera className={className} />,
CameraOff: <CameraOff className={className} />,
Image: <Image className={className} />,
Images: <Images className={className} />,
CloudSun: <CloudSun className={className} />,
Calendar: <Calendar className={className} />,
TrendingUp: <TrendingUp className={className} />,
Coins: <Coins className={className} />,
Terminal: <Terminal className={className} />,
Globe: <Globe className={className} />,
Wrench: <Wrench className={className} />,
};
return map[icon] || <Wrench className={className} />;
};
export const AssistantsPage: React.FC = () => {
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [voices, setVoices] = useState<Voice[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [asrModels, setAsrModels] = useState<ASRModel[]>([]);
const [tools, setTools] = useState<Tool[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabValue>(TabValue.GLOBAL);
const [debugOpen, setDebugOpen] = useState(false);
const [hotwordInput, setHotwordInput] = useState('');
// Publish Modal State
const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
const [publishTab, setPublishTab] = useState<'web' | 'api'>('web');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
const filteredAssistants = assistants.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
);
useEffect(() => {
const loadInitialData = async () => {
setIsLoading(true);
try {
const [assistantList, voiceList, kbList, llmList, asrList, toolList] = await Promise.all([
fetchAssistants(),
fetchVoices(),
fetchKnowledgeBases(),
fetchLLMModels(),
fetchASRModels(),
fetchTools(),
]);
setAssistants(assistantList);
setVoices(voiceList);
setKnowledgeBases(kbList);
setLlmModels(llmList);
setAsrModels(asrList);
setTools(toolList);
if (assistantList.length > 0) {
setSelectedId(assistantList[0].id);
}
} catch (error) {
console.error(error);
alert('加载助手数据失败,请检查后端服务是否启动。');
} finally {
setIsLoading(false);
}
};
loadInitialData();
}, []);
const handleCreate = async () => {
const newAssistantPayload: Partial<Assistant> = {
name: 'New Assistant',
opener: '',
prompt: '',
knowledgeBaseId: '',
language: 'zh',
voice: voices[0]?.id || '',
speed: 1,
hotwords: [],
tools: [],
interruptionSensitivity: 500,
configMode: 'platform',
};
try {
const created = await createAssistant(newAssistantPayload);
setAssistants((prev) => [created, ...prev]);
setSelectedId(created.id);
setActiveTab(TabValue.GLOBAL);
} catch (error) {
console.error(error);
alert('创建助手失败。');
}
};
const handleSave = async () => {
if (!selectedAssistant) return;
setSaveLoading(true);
try {
const updated = await updateAssistantApi(selectedAssistant.id, selectedAssistant);
setAssistants((prev) => prev.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
} catch (error) {
console.error(error);
alert('保存失败,请稍后重试。');
} finally {
setSaveLoading(false);
}
};
const handleCopyId = (id: string, text?: string) => {
navigator.clipboard.writeText(text || id);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
};
const handleCopy = async (e: React.MouseEvent, assistant: Assistant) => {
e.stopPropagation();
try {
const copied = await createAssistant({
...assistant,
name: `${assistant.name} (Copy)`,
});
setAssistants((prev) => [copied, ...prev]);
} catch (error) {
console.error(error);
alert('复制助手失败。');
}
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeleteId(id);
};
const confirmDelete = async () => {
if (deleteId) {
try {
await deleteAssistant(deleteId);
setAssistants(prev => prev.filter(a => a.id !== deleteId));
if (selectedId === deleteId) setSelectedId(null);
setDeleteId(null);
} catch (error) {
console.error(error);
alert('删除失败,请稍后重试。');
}
}
};
const updateAssistant = (field: keyof Assistant, value: any) => {
if (!selectedId) return;
setAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
if (field === 'configMode') {
if (value === 'platform') {
setActiveTab(TabValue.GLOBAL);
} else if (value === 'dify' || value === 'fastgpt') {
setActiveTab(TabValue.LINK);
}
}
};
const toggleTool = (toolId: string) => {
if (!selectedAssistant) return;
const currentTools = selectedAssistant.tools || [];
const newTools = currentTools.includes(toolId)
? currentTools.filter(id => id !== toolId)
: [...currentTools, toolId];
updateAssistant('tools', newTools);
};
const removeImportedTool = (e: React.MouseEvent, tool: Tool) => {
e.stopPropagation();
if (!selectedAssistant) return;
updateAssistant('tools', (selectedAssistant.tools || []).filter((id) => id !== tool.id));
};
const addHotword = () => {
if (hotwordInput.trim() && selectedAssistant) {
updateAssistant('hotwords', [...selectedAssistant.hotwords, hotwordInput.trim()]);
setHotwordInput('');
}
};
const removeHotword = (word: string) => {
if (selectedAssistant) {
updateAssistant('hotwords', selectedAssistant.hotwords.filter(w => w !== word));
}
};
const systemTools = tools.filter((t) => t.enabled !== false && t.category === 'system');
const queryTools = tools.filter((t) => t.enabled !== false && t.category === 'query');
const isExternalConfig = selectedAssistant?.configMode === 'dify' || selectedAssistant?.configMode === 'fastgpt';
const isNoneConfig = selectedAssistant?.configMode === 'none' || !selectedAssistant?.configMode;
return (
<div className="flex h-[calc(100vh-8rem)] gap-6 animate-in fade-in py-4">
{/* LEFT COLUMN: List */}
<div className="w-80 flex flex-col gap-4 shrink-0">
<div className="flex items-center justify-between px-1 text-white">
<h2 className="text-xl font-bold tracking-tight text-white"></h2>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索..."
className="pl-9 bg-card/50 border-white/5"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button size="icon" onClick={handleCreate} title="新建小助手">
<Plus className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
{!isLoading && filteredAssistants.map(assistant => (
<div
key={assistant.id}
onClick={() => setSelectedId(assistant.id)}
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
selectedId === assistant.id
? 'bg-primary/10 border-primary/40 shadow-[0_0_15px_rgba(6,182,212,0.15)]'
: 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'
}`}
>
<div className="flex flex-col gap-1.5 mb-2 pr-16 overflow-hidden">
<span className={`font-semibold truncate ${selectedId === assistant.id ? 'text-primary' : 'text-foreground'}`}>
{assistant.name}
</span>
{assistant.configMode && assistant.configMode !== 'none' && (
<div className="flex">
<Badge
variant="outline"
className={`text-[9px] uppercase tracking-tighter shrink-0 opacity-70 border-white/10 ${
assistant.configMode === 'platform' ? 'text-cyan-400 bg-cyan-400/5' :
assistant.configMode === 'dify' ? 'text-blue-400 bg-blue-400/5' :
'text-purple-400 bg-purple-400/5'
}`}
>
{assistant.configMode === 'platform' ? '内置' : assistant.configMode}
</Badge>
</div>
)}
</div>
<div className="flex items-center text-xs text-muted-foreground">
<Phone className="h-3 w-3 mr-1.5 opacity-70" />
<span>{assistant.callCount} </span>
</div>
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5 shadow-lg border border-white/5">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, assistant)} title="复制">
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, assistant.id)} title="删除">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
{!isLoading && filteredAssistants.length === 0 && (
<div className="text-center py-10 text-muted-foreground text-sm">
</div>
)}
{isLoading && (
<div className="text-center py-10 text-muted-foreground text-sm">
...
</div>
)}
</div>
</div>
{/* RIGHT COLUMN: Config Panel */}
<div className="flex-1 bg-card/20 backdrop-blur-sm border border-white/5 rounded-2xl overflow-hidden flex flex-col relative shadow-xl">
{selectedAssistant ? (
<>
{/* Header Area */}
<div className="p-6 border-b border-white/5 bg-white/[0.02] space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1"></label>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-white/5 border border-white/10 group/id transition-all hover:bg-white/10">
<span className="text-[10px] font-mono text-muted-foreground/60 select-all tracking-tight">UUID: {selectedAssistant.id}</span>
<button
onClick={() => handleCopyId(selectedAssistant.id)}
className="text-muted-foreground hover:text-primary transition-colors flex items-center"
title="复制ID"
>
{copySuccess ? <ClipboardCheck className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</div>
<Input
value={selectedAssistant.name}
onChange={(e) => updateAssistant('name', e.target.value)}
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
/>
</div>
<div className="flex items-center space-x-2 pt-6">
<Button
variant="secondary"
onClick={() => setDebugOpen(true)}
className="border border-primary/20 hover:border-primary/50 text-primary hover:text-primary hover:bg-primary/10 shadow-[0_0_15px_rgba(6,182,212,0.1)]"
>
<Play className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={saveLoading}
className="border border-white/10 hover:border-primary/50 text-foreground"
>
<Save className={`mr-2 h-4 w-4 ${saveLoading ? 'animate-pulse' : ''}`} /> {saveLoading ? '正在保存...' : '保存'}
</Button>
<Button
onClick={() => setIsPublishModalOpen(true)}
className="shadow-[0_0_20px_rgba(6,182,212,0.3)]"
>
<Rocket className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1"></label>
<div className="relative group w-full">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
value={selectedAssistant.configMode || 'none'}
onChange={(e) => updateAssistant('configMode', e.target.value as any)}
>
<option value="none"></option>
<option value="platform"></option>
<option value="dify">Dify </option>
<option value="fastgpt">FastGPT </option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
</div>
{!isNoneConfig && (
<div className="flex bg-white/5 p-1 rounded-lg w-fit border border-white/5 animate-in fade-in slide-in-from-top-1">
{selectedAssistant.configMode === 'platform' ? (
<>
<button
onClick={() => setActiveTab(TabValue.GLOBAL)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.GLOBAL ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.VOICE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.TOOLS)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.TOOLS ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.KNOWLEDGE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.KNOWLEDGE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
</>
) : (
<>
<button
onClick={() => setActiveTab(TabValue.LINK)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.LINK ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => setActiveTab(TabValue.VOICE)}
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
</>
)}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
{isNoneConfig ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-40 animate-in fade-in">
<AlertTriangle className="w-12 h-12 mb-4" />
<p className="text-sm font-medium"></p>
</div>
) : (
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
{activeTab === TabValue.LINK && isExternalConfig && (
<div className="space-y-6 animate-in fade-in">
<div className="p-4 rounded-xl bg-primary/5 border border-primary/20 flex items-start gap-3 mb-4">
<Database className="w-5 h-5 text-primary shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-bold text-white mb-1">
{selectedAssistant.configMode === 'dify' ? 'Dify' : 'FastGPT'}
</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
</p>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Globe className="w-4 h-4 mr-2 text-primary"/> (API URL)
</label>
<Input
value={selectedAssistant.apiUrl || ''}
onChange={(e) => updateAssistant('apiUrl', e.target.value)}
placeholder={selectedAssistant.configMode === 'dify' ? "https://api.dify.ai/v1" : "https://api.fastgpt.in/api/v1"}
className="bg-white/5 border-white/10 focus:border-primary/50 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Terminal className="w-4 h-4 mr-2 text-primary"/> (API KEY)
</label>
<Input
type="password"
value={selectedAssistant.apiKey || ''}
onChange={(e) => updateAssistant('apiKey', e.target.value)}
placeholder="请输入应用 API 密钥..."
className="bg-white/5 border-white/10 focus:border-primary/50 font-mono text-xs"
/>
</div>
</div>
)}
{activeTab === TabValue.GLOBAL && selectedAssistant.configMode === 'platform' && (
<div className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<BrainCircuit className="w-4 h-4 mr-2 text-primary"/> (LLM Model)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.llmModelId || ''}
onChange={(e) => updateAssistant('llmModelId', e.target.value)}
>
<option value="">使</option>
{llmModels.filter(m => m.type === 'text').map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.vendor})</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<MessageSquare className="w-4 h-4 mr-2 text-primary"/> (Opener)
</label>
<Input
value={selectedAssistant.opener}
onChange={(e) => updateAssistant('opener', e.target.value)}
placeholder="例如您好我是您的专属AI助手..."
className="bg-white/5 border-white/10 focus:border-primary/50"
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<BotIcon className="w-4 h-4 mr-2 text-primary"/> (Prompt)
</label>
<textarea
className="flex min-h-[200px] w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 resize-y text-white"
value={selectedAssistant.prompt}
onChange={(e) => updateAssistant('prompt', e.target.value)}
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
/>
</div>
</div>
)}
{activeTab === TabValue.KNOWLEDGE && selectedAssistant.configMode === 'platform' && (
<div className="space-y-8 animate-in fade-in">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<BrainCircuit className="w-4 h-4 mr-2 text-primary"/> (Embedding Model)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.embeddingModelId || ''}
onChange={(e) => updateAssistant('embeddingModelId', e.target.value)}
>
<option value="">使</option>
{llmModels.filter(m => m.type === 'embedding').map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.vendor})</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Filter className="w-4 h-4 mr-2 text-primary"/> (Rerank Model)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.rerankModelId || ''}
onChange={(e) => updateAssistant('rerankModelId', e.target.value)}
>
<option value="">使</option>
{llmModels.filter(m => m.type === 'rerank').map(model => (
<option key={model.id} value={model.id}>{model.name} ({model.vendor})</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Book className="w-4 h-4 mr-2 text-primary"/> (Knowledge Base)
</label>
<div className="relative group">
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.knowledgeBaseId}
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
>
<option value="">使</option>
{knowledgeBases.map(kb => (
<option key={kb.id} value={kb.id}>{kb.name}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
)}
{activeTab === TabValue.VOICE && !isNoneConfig && (
<div className="space-y-8">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Ear className="w-4 h-4 mr-2 text-primary"/> (ASR Model)
</label>
<div className="relative group">
<select
className="flex h-12 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-1 text-sm shadow-sm transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.asrModelId || ''}
onChange={(e) => updateAssistant('asrModelId', e.target.value)}
>
<option value="">使</option>
{asrModels.map(model => (
<option key={model.id} value={model.id}>
{model.name} ({model.vendor})
</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center">
<Volume2 className="w-4 h-4 mr-2 text-primary"/> (From Voice Library)
</label>
<div className="relative group">
<select
className="flex h-12 w-full rounded-xl border border-white/10 bg-white/5 px-4 py-1 text-sm shadow-sm transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground appearance-none cursor-pointer"
value={selectedAssistant.voice}
onChange={(e) => updateAssistant('voice', e.target.value)}
>
<option value="" disabled>...</option>
{voices.map(voice => (
<option key={voice.id} value={voice.id}>
{voice.name} ({voice.vendor} - {voice.gender === 'Male' ? '男' : '女'})
</option>
))}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none group-hover:text-primary transition-colors" />
</div>
<p className="text-xs text-muted-foreground flex items-center mt-1">
<Sparkles className="w-3 h-3 mr-1 text-primary opacity-70" />
</p>
</div>
<div className="space-y-4 pt-2">
<div className="flex justify-between items-center mb-1">
<label className="text-sm font-medium text-white flex items-center">
<Timer className="w-4 h-4 mr-2 text-primary"/> (Interruption Sensitivity)
</label>
<div className="flex items-center gap-2">
<div className="relative">
<Input
type="number"
value={selectedAssistant.interruptionSensitivity || 500}
onChange={(e) => updateAssistant('interruptionSensitivity', parseInt(e.target.value) || 0)}
className="w-20 h-8 text-right pr-7 text-xs font-mono bg-black/40 border-white/5"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground font-mono">ms</span>
</div>
</div>
</div>
<div className="flex items-center gap-6">
<input
type="range"
min="0"
max="2000"
step="50"
value={selectedAssistant.interruptionSensitivity || 500}
onChange={(e) => updateAssistant('interruptionSensitivity', parseInt(e.target.value))}
className="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground font-mono uppercase tracking-widest px-0.5 opacity-50">
<span>0ms (Extreme)</span>
<span>1000ms</span>
<span>2000ms (Lazy)</span>
</div>
<p className="text-xs text-muted-foreground pt-1 italic opacity-60">
* AI
</p>
</div>
<div className="space-y-3">
<label className="text-sm font-medium text-white flex items-center">
<Mic className="w-4 h-4 mr-2 text-primary"/> ASR (Hotwords)
</label>
<div className="flex space-x-2">
<Input
value={hotwordInput}
onChange={(e) => setHotwordInput(e.target.value)}
placeholder="输入专有名词或高频词汇..."
onKeyDown={(e) => e.key === 'Enter' && addHotword()}
className="bg-white/5 border-white/10"
/>
<Button variant="secondary" onClick={addHotword} className="px-10 whitespace-nowrap"></Button>
</div>
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-lg border border-dashed border-white/10">
{selectedAssistant.hotwords.length === 0 && (
<span className="text-xs text-muted-foreground py-1 px-1"></span>
)}
{selectedAssistant.hotwords.map((word, idx) => (
<Badge key={idx} variant="outline" className="py-1">
{word}
<button onClick={() => removeHotword(word)} className="ml-2 hover:text-destructive transition-colors text-lg leading-none">×</button>
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
)}
{activeTab === TabValue.TOOLS && selectedAssistant.configMode === 'platform' && (
<div className="space-y-8 animate-in fade-in">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black flex items-center text-primary uppercase tracking-[0.2em]">
<Wrench className="w-3.5 h-3.5 mr-2" />
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{systemTools.map(tool => (
<div
key={tool.id}
onClick={() => toggleTool(tool.id)}
className={`p-4 rounded-xl border transition-all cursor-pointer group relative flex items-start space-x-3 ${selectedAssistant.tools?.includes(tool.id) ? 'bg-primary/10 border-primary/40 shadow-[0_0_15px_rgba(6,182,212,0.1)]' : 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'}`}
>
<div className={`p-2 rounded-lg shrink-0 transition-colors ${selectedAssistant.tools?.includes(tool.id) ? 'bg-primary text-primary-foreground' : 'bg-white/5 text-muted-foreground'}`}>
{renderToolIcon(tool.icon)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-bold text-white">{tool.name}</span>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all ${selectedAssistant.tools?.includes(tool.id) ? 'border-primary bg-primary' : 'border-white/10'}`}>
{selectedAssistant.tools?.includes(tool.id) && <div className="w-1.5 h-1.5 bg-white rounded-full"></div>}
</div>
</div>
<p className="text-[10px] text-muted-foreground line-clamp-1 opacity-70">{tool.description}</p>
</div>
{selectedAssistant.tools?.includes(tool.id) && (
<button
onClick={(e) => removeImportedTool(e, tool)}
className="absolute -top-1 -right-1 p-0.5 rounded-full bg-destructive text-white opacity-0 group-hover:opacity-100 transition-opacity hover:scale-110 shadow-lg z-10"
title="从当前小助手移除"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-[10px] font-black flex items-center text-blue-400 uppercase tracking-[0.2em]">
<TrendingUp className="w-3.5 h-3.5 mr-2" />
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{queryTools.map(tool => (
<div
key={tool.id}
onClick={() => toggleTool(tool.id)}
className={`p-4 rounded-xl border transition-all cursor-pointer group relative flex items-start space-x-3 ${selectedAssistant.tools?.includes(tool.id) ? 'bg-blue-500/10 border-blue-500/40 shadow-[0_0_15px_rgba(59,130,246,0.1)]' : 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'}`}
>
<div className={`p-2 rounded-lg shrink-0 transition-colors ${selectedAssistant.tools?.includes(tool.id) ? 'bg-blue-500 text-white' : 'bg-white/5 text-muted-foreground'}`}>
{renderToolIcon(tool.icon)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-bold text-white">{tool.name}</span>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all ${selectedAssistant.tools?.includes(tool.id) ? 'border-blue-500 bg-blue-500' : 'border-white/10'}`}>
{selectedAssistant.tools?.includes(tool.id) && <div className="w-1.5 h-1.5 bg-white rounded-full"></div>}
</div>
</div>
<p className="text-[10px] text-muted-foreground line-clamp-1 opacity-70">{tool.description}</p>
</div>
{selectedAssistant.tools?.includes(tool.id) && (
<button
onClick={(e) => removeImportedTool(e, tool)}
className="absolute -top-1 -right-1 p-0.5 rounded-full bg-destructive text-white opacity-0 group-hover:opacity-100 transition-opacity hover:scale-110 shadow-lg z-10"
title="从当前小助手移除"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
</div>
<div className="p-4 bg-white/5 border border-white/5 rounded-xl text-[10px] text-muted-foreground flex items-center gap-3">
<Rocket className="w-4 h-4 text-primary shrink-0" />
<span></span>
</div>
</div>
)}
</div>
)}
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<BotIcon className="h-8 w-8 opacity-50" />
</div>
<p className="text-lg font-medium"></p>
<p className="text-sm opacity-60"></p>
</div>
)}
</div>
{/* Publish Modal */}
<Dialog
isOpen={isPublishModalOpen}
onClose={() => setIsPublishModalOpen(false)}
title="发布小助手"
footer={
<Button onClick={() => setIsPublishModalOpen(false)}></Button>
}
>
<div className="space-y-6">
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
<button
onClick={() => setPublishTab('web')}
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${publishTab === 'web' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
<ExternalLink className="w-3.5 h-3.5 mr-2" />
</button>
<button
onClick={() => setPublishTab('api')}
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${publishTab === 'api' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
<Server className="w-3.5 h-3.5 mr-2" /> API
</button>
</div>
{publishTab === 'web' ? (
<div className="space-y-4 animate-in fade-in slide-in-from-top-1">
<div className="p-4 rounded-xl bg-primary/5 border border-primary/20 space-y-3">
<div className="flex items-center gap-2 text-primary">
<Zap className="w-4 h-4" />
<h4 className="text-sm font-bold"></h4>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
</p>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase">访</label>
<div className="flex gap-2">
<Input
readOnly
value={`https://ai-video.com/share/${selectedAssistant?.id}`}
className="bg-white/5 border-white/10 font-mono text-[11px] text-primary"
/>
<Button variant="secondary" size="icon" onClick={() => handleCopyId('', `https://ai-video.com/share/${selectedAssistant?.id}`)}>
{copySuccess ? <ClipboardCheck className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
) : (
<div className="space-y-5 animate-in fade-in slide-in-from-top-1">
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase">API Endpoint (v1)</label>
<div className="flex gap-2">
<Input
readOnly
value={`https://api.ai-video.com/v1/call/${selectedAssistant?.id}`}
className="bg-white/5 border-white/10 font-mono text-[11px]"
/>
<Button variant="ghost" size="icon" onClick={() => handleCopyId('', `https://api.ai-video.com/v1/call/${selectedAssistant?.id}`)}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase">Secret API KEY</label>
<Badge variant="outline" className="text-[8px] opacity-50">PRIVATE</Badge>
</div>
<div className="flex gap-2">
<Input
readOnly
type="password"
value="sk-ai-video-78x29jKkL1M90vX..."
className="bg-white/5 border-white/10 font-mono text-[11px]"
/>
<Button variant="ghost" size="icon" onClick={() => handleCopyId('', "sk-ai-video-78x29jKkL1M90vX...")}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground italic flex items-center gap-1.5 px-1 mt-2">
<Key className="w-3 h-3" /> API Key
</p>
</div>
</div>
)}
</div>
</Dialog>
{selectedAssistant && (
<DebugDrawer
isOpen={debugOpen}
onClose={() => setDebugOpen(false)}
assistant={selectedAssistant}
voices={voices}
llmModels={llmModels}
asrModels={asrModels}
/>
)}
{/* Delete Confirmation Dialog */}
<Dialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
title="确认删除"
footer={
<>
<Button variant="ghost" onClick={() => setDeleteId(null)}></Button>
<Button variant="destructive" onClick={confirmDelete}></Button>
</>
}
>
<div className="flex items-center space-x-4">
<div className="p-3 bg-destructive/10 rounded-full">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<div>
<p className="text-sm text-foreground text-white">
</p>
{deleteId && (
<p className="text-xs text-muted-foreground mt-1">
: {assistants.find(a => a.id === deleteId)?.name}
</p>
)}
</div>
</div>
</Dialog>
</div>
);
};
// Icon helper
const BotIcon = ({className}: {className?: string}) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
);
// Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping)
const TranscriptionLog: React.FC<{
scrollRef: React.RefObject<HTMLDivElement | null>;
messages: { role: 'user' | 'model'; text: string }[];
isLoading: boolean;
className?: string;
}> = ({ scrollRef, messages, isLoading, className = '' }) => (
<div ref={scrollRef} className={`overflow-y-auto overflow-x-hidden space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0 custom-scrollbar ${className}`}>
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4"></div>}
{messages.map((m, i) => (
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${m.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-card border border-white/10 shadow-sm text-foreground'}`}>
<span className="text-[10px] opacity-70 block mb-0.5 uppercase tracking-wider">{m.role === 'user' ? 'Me' : 'AI'}</span>
{m.text}
</div>
</div>
))}
{isLoading && <div className="text-xs text-muted-foreground ml-2 animate-pulse">Thinking...</div>}
</div>
);
// --- Debug Drawer Component ---
export const DebugDrawer: React.FC<{
isOpen: boolean;
onClose: () => void;
assistant: Assistant;
voices: Voice[];
llmModels: LLMModel[];
asrModels: ASRModel[];
}> = ({ isOpen, onClose, assistant, voices, llmModels, asrModels }) => {
const TARGET_SAMPLE_RATE = 16000;
const downsampleTo16k = (input: Float32Array, inputSampleRate: number): Float32Array => {
if (inputSampleRate === TARGET_SAMPLE_RATE) return input;
if (inputSampleRate < TARGET_SAMPLE_RATE) return input;
const ratio = inputSampleRate / TARGET_SAMPLE_RATE;
const outputLength = Math.max(1, Math.round(input.length / ratio));
const output = new Float32Array(outputLength);
let offsetInput = 0;
for (let i = 0; i < outputLength; i += 1) {
const nextOffsetInput = Math.min(input.length, Math.round((i + 1) * ratio));
let accum = 0;
let count = 0;
for (let j = offsetInput; j < nextOffsetInput; j += 1) {
accum += input[j];
count += 1;
}
output[i] = count > 0 ? accum / count : input[Math.min(offsetInput, input.length - 1)] || 0;
offsetInput = nextOffsetInput;
}
return output;
};
const float32ToPcm16 = (input: Float32Array): Int16Array => {
const output = new Int16Array(input.length);
for (let i = 0; i < input.length; i += 1) {
const s = Math.max(-1, Math.min(1, input[i]));
output[i] = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
}
return output;
};
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');
const [textSessionStarted, setTextSessionStarted] = useState(false);
const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected');
const [wsError, setWsError] = useState('');
const [resolvedConfigOpen, setResolvedConfigOpen] = useState(false);
const [resolvedConfigView, setResolvedConfigView] = useState<string>('');
const [captureConfigOpen, setCaptureConfigOpen] = useState(false);
const [captureConfigView, setCaptureConfigView] = useState<string>('');
const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false);
const [wsUrl, setWsUrl] = useState<string>(() => {
const fromStorage = localStorage.getItem('debug_ws_url');
if (fromStorage) return fromStorage;
const defaultHost = window.location.hostname || 'localhost';
return `ws://${defaultHost}:8000/ws`;
});
// Media State
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const wsReadyRef = useRef(false);
const pendingResolveRef = useRef<(() => void) | null>(null);
const pendingRejectRef = useRef<((e: Error) => void) | null>(null);
const assistantDraftIndexRef = useRef<number | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const playbackTimeRef = useRef<number>(0);
const activeAudioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const queuedAudioBuffersRef = useRef<AudioBuffer[]>([]);
const queuedAudioDurationRef = useRef<number>(0);
const PLAYBACK_INITIAL_BUFFER_SECONDS = 0.25;
const PLAYBACK_MAX_AHEAD_SECONDS = 0.8;
const PLAYBACK_SCHEDULE_LEAD_SECONDS = 0.04;
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedCamera, setSelectedCamera] = useState<string>('');
const [selectedMic, setSelectedMic] = useState<string>('');
const [isSwapped, setIsSwapped] = useState(false);
const [textTtsEnabled, setTextTtsEnabled] = useState(true);
const [aecEnabled, setAecEnabled] = useState<boolean>(() => localStorage.getItem('debug_audio_aec') !== '0');
const [nsEnabled, setNsEnabled] = useState<boolean>(() => localStorage.getItem('debug_audio_ns') !== '0');
const [agcEnabled, setAgcEnabled] = useState<boolean>(() => localStorage.getItem('debug_audio_agc') !== '0');
const micAudioCtxRef = useRef<AudioContext | null>(null);
const micSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const micProcessorRef = useRef<ScriptProcessorNode | null>(null);
const micGainRef = useRef<GainNode | null>(null);
const userDraftIndexRef = useRef<number | null>(null);
const lastUserFinalRef = useRef<string>('');
// Initialize
useEffect(() => {
if (isOpen) {
if (mode === 'text') {
setMessages([]);
setTextSessionStarted(false);
} else {
setMessages([]);
setCallStatus('idle');
}
} else {
setMode('text');
stopVoiceCapture();
stopMedia();
closeWs();
if (audioCtxRef.current) {
void audioCtxRef.current.close();
audioCtxRef.current = null;
}
setSettingsDrawerOpen(false);
setIsSwapped(false);
setCallStatus('idle');
}
}, [isOpen, assistant, mode]);
useEffect(() => {
localStorage.setItem('debug_ws_url', wsUrl);
}, [wsUrl]);
useEffect(() => {
localStorage.setItem('debug_audio_aec', aecEnabled ? '1' : '0');
}, [aecEnabled]);
useEffect(() => {
localStorage.setItem('debug_audio_ns', nsEnabled ? '1' : '0');
}, [nsEnabled]);
useEffect(() => {
localStorage.setItem('debug_audio_agc', agcEnabled ? '1' : '0');
}, [agcEnabled]);
// Auto-scroll logic
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, mode]);
// Fetch Devices
useEffect(() => {
if (isOpen && (mode === 'video' || mode === 'voice')) {
const getDevices = async () => {
let permissionStream: MediaStream | null = null;
try {
permissionStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: mode === 'video' });
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);
} finally {
permissionStream?.getTracks().forEach((track) => track.stop());
}
};
getDevices();
}
}, [isOpen, mode]);
const stopMedia = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
const stopVoiceCapture = () => {
if (micProcessorRef.current) {
micProcessorRef.current.onaudioprocess = null;
try {
micProcessorRef.current.disconnect();
} catch {
// no-op
}
micProcessorRef.current = null;
}
if (micSourceRef.current) {
try {
micSourceRef.current.disconnect();
} catch {
// no-op
}
micSourceRef.current = null;
}
if (micGainRef.current) {
try {
micGainRef.current.disconnect();
} catch {
// no-op
}
micGainRef.current = null;
}
if (micAudioCtxRef.current) {
void micAudioCtxRef.current.close();
micAudioCtxRef.current = null;
}
setCaptureConfigView('');
stopMedia();
};
const buildMicConstraints = (): MediaTrackConstraints => ({
deviceId: selectedMic ? { exact: selectedMic } : undefined,
echoCancellation: aecEnabled,
noiseSuppression: nsEnabled,
autoGainControl: agcEnabled,
channelCount: 1,
sampleRate: TARGET_SAMPLE_RATE,
});
const startVoiceCapture = async () => {
stopVoiceCapture();
const requestedConstraints = buildMicConstraints();
const stream = await navigator.mediaDevices.getUserMedia({
audio: requestedConstraints,
video: false,
});
streamRef.current = stream;
const track = stream.getAudioTracks()[0];
if (track) {
console.log('Voice capture settings', track.getSettings());
setCaptureConfigView(
JSON.stringify(
{
requested: requestedConstraints,
applied: track.getSettings(),
capabilities: typeof track.getCapabilities === 'function' ? track.getCapabilities() : undefined,
},
null,
2
)
);
}
const ctx = new AudioContext();
if (ctx.state === 'suspended') {
await ctx.resume();
}
micAudioCtxRef.current = ctx;
const source = ctx.createMediaStreamSource(stream);
const processor = ctx.createScriptProcessor(4096, 1, 1);
const silentGain = ctx.createGain();
silentGain.gain.value = 0;
source.connect(processor);
processor.connect(silentGain);
silentGain.connect(ctx.destination);
processor.onaudioprocess = (event) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !wsReadyRef.current) return;
const inChannel = event.inputBuffer.getChannelData(0);
const downsampled = downsampleTo16k(inChannel, event.inputBuffer.sampleRate);
const pcm16 = float32ToPcm16(downsampled);
wsRef.current.send(pcm16.buffer);
};
micSourceRef.current = source;
micProcessorRef.current = processor;
micGainRef.current = silentGain;
};
const ensureAudioContext = async () => {
if (!audioCtxRef.current) {
audioCtxRef.current = new AudioContext();
playbackTimeRef.current = audioCtxRef.current.currentTime;
}
if (audioCtxRef.current.state === 'suspended') {
await audioCtxRef.current.resume();
}
return audioCtxRef.current;
};
const clearPlaybackQueue = () => {
const ctx = audioCtxRef.current;
const now = ctx ? ctx.currentTime : 0;
playbackTimeRef.current = now;
queuedAudioBuffersRef.current = [];
queuedAudioDurationRef.current = 0;
};
const stopPlaybackImmediately = () => {
activeAudioSourcesRef.current.forEach((source) => {
try {
source.stop();
} catch {
// no-op
}
source.disconnect();
});
activeAudioSourcesRef.current.clear();
clearPlaybackQueue();
};
const scheduleQueuedPlayback = (ctx: AudioContext) => {
const queue = queuedAudioBuffersRef.current;
if (queue.length === 0) return;
const now = ctx.currentTime;
if (playbackTimeRef.current < now) {
playbackTimeRef.current = now;
}
const hasActivePlayback = activeAudioSourcesRef.current.size > 0;
const minBufferSeconds = hasActivePlayback
? 0
: PLAYBACK_INITIAL_BUFFER_SECONDS;
if (queuedAudioDurationRef.current < minBufferSeconds) {
return;
}
while (queue.length > 0 && (playbackTimeRef.current - now) < PLAYBACK_MAX_AHEAD_SECONDS) {
const audioBuffer = queue.shift();
if (!audioBuffer) break;
queuedAudioDurationRef.current = Math.max(0, queuedAudioDurationRef.current - audioBuffer.duration);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
activeAudioSourcesRef.current.add(source);
source.onended = () => {
activeAudioSourcesRef.current.delete(source);
try {
source.disconnect();
} catch {
// no-op
}
scheduleQueuedPlayback(ctx);
};
const startAt = Math.max(ctx.currentTime + PLAYBACK_SCHEDULE_LEAD_SECONDS, playbackTimeRef.current);
source.start(startAt);
playbackTimeRef.current = startAt + audioBuffer.duration;
}
};
const playPcm16Chunk = async (pcmBuffer: ArrayBuffer) => {
if (!textTtsEnabled) return;
const ctx = await ensureAudioContext();
const int16 = new Int16Array(pcmBuffer);
if (int16.length === 0) return;
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i += 1) {
float32[i] = int16[i] / 32768;
}
const sampleRate = 16000;
const audioBuffer = ctx.createBuffer(1, float32.length, sampleRate);
audioBuffer.copyToChannel(float32, 0);
queuedAudioBuffersRef.current.push(audioBuffer);
queuedAudioDurationRef.current += audioBuffer.duration;
scheduleQueuedPlayback(ctx);
};
useEffect(() => {
const handleStream = async () => {
if (isOpen && mode === 'video' && callStatus === 'active') {
try {
stopMedia();
const constraints = {
video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true,
audio: selectedMic ? { deviceId: { exact: selectedMic } } : true
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("Failed to access camera/mic:", err);
}
} else if (callStatus !== 'active') {
stopMedia();
}
};
handleStream();
return () => stopMedia();
}, [mode, isOpen, selectedCamera, selectedMic, callStatus]);
const handleCall = () => {
if (mode !== 'voice') {
setCallStatus('calling');
setTimeout(() => {
setCallStatus('active');
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
}, 1500);
return;
}
const launchVoice = async () => {
try {
setCallStatus('calling');
setMessages([]);
lastUserFinalRef.current = '';
setWsError('');
closeWs();
if (textTtsEnabled) await ensureAudioContext();
await ensureWsSession();
await startVoiceCapture();
setCallStatus('active');
setMessages([{ role: 'model', text: assistant.opener || 'Hello!' }]);
} catch (e) {
console.error(e);
stopVoiceCapture();
setCallStatus('idle');
setWsStatus('error');
setWsError((e as Error)?.message || 'Failed to start voice call');
}
};
void launchVoice();
};
const handleHangup = () => {
stopVoiceCapture();
stopMedia();
closeWs();
setCallStatus('idle');
setMessages([]);
lastUserFinalRef.current = '';
setIsLoading(false);
};
const handleSend = async () => {
if (!inputText.trim()) return;
const userMsg = inputText;
assistantDraftIndexRef.current = null;
setMessages(prev => [...prev, { role: 'user', text: userMsg }]);
setInputText('');
setIsLoading(true);
try {
if (mode === 'text') {
if (textTtsEnabled) await ensureAudioContext();
await ensureWsSession();
// Interrupt any in-flight response/audio before sending new user utterance.
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'response.cancel', graceful: false }));
}
stopPlaybackImmediately();
wsRef.current?.send(JSON.stringify({ type: 'input.text', text: userMsg }));
} else if (mode === 'voice') {
await ensureWsSession();
wsRef.current?.send(JSON.stringify({ type: 'input.text', text: userMsg }));
} else {
setTimeout(() => {
setMessages(prev => [...prev, { role: 'model', text: `[Mock Response]: Received "${userMsg}"` }]);
setIsLoading(false);
}, 1000);
}
} catch (e) {
console.error(e);
setMessages(prev => [...prev, { role: 'model', text: "Error: Failed to connect to AI service." }]);
setIsLoading(false);
} finally {
if (mode !== 'text') setIsLoading(false);
}
};
const handleTextLaunch = async () => {
try {
setWsError('');
// Start every text debug run as a fresh session transcript.
setMessages([]);
lastUserFinalRef.current = '';
assistantDraftIndexRef.current = null;
// Force a fresh WS session so updated assistant runtime config
// (voice/model/provider/speed) is applied on session.start.
closeWs();
if (textTtsEnabled) await ensureAudioContext();
await ensureWsSession();
setTextSessionStarted(true);
setMessages([{ role: 'model', text: assistant.opener || 'Hello!' }]);
} catch (e) {
console.error(e);
setWsStatus('error');
setWsError((e as Error)?.message || 'Failed to connect');
setTextSessionStarted(false);
}
};
const buildLocalResolvedRuntime = () => {
const warnings: string[] = [];
const services: Record<string, any> = {};
const isExternalLlm = assistant.configMode === 'dify' || assistant.configMode === 'fastgpt';
if (isExternalLlm) {
services.llm = {
provider: 'openai',
model: '',
apiKey: assistant.apiKey || '',
baseUrl: assistant.apiUrl || '',
};
if (!assistant.apiUrl) warnings.push(`External LLM API URL is empty for mode: ${assistant.configMode}`);
if (!assistant.apiKey) warnings.push(`External LLM API key is empty for mode: ${assistant.configMode}`);
} else if (assistant.llmModelId) {
const llm = llmModels.find((item) => item.id === assistant.llmModelId);
if (llm) {
services.llm = {
provider: 'openai',
model: llm.modelName || llm.name,
apiKey: llm.apiKey,
baseUrl: llm.baseUrl,
};
} else {
warnings.push(`LLM model not found in loaded list: ${assistant.llmModelId}`);
}
} else {
// Keep empty object to indicate engine should use default provider model.
services.llm = {};
}
if (assistant.asrModelId) {
const asr = asrModels.find((item) => item.id === assistant.asrModelId);
if (asr) {
const asrProvider = isSiliconflowVendor(asr.vendor) ? 'siliconflow' : 'buffered';
services.asr = {
provider: asrProvider,
model: asr.modelName || asr.name,
apiKey: asrProvider === 'siliconflow' ? asr.apiKey : null,
};
} else {
warnings.push(`ASR model not found in loaded list: ${assistant.asrModelId}`);
}
}
if (assistant.voice) {
const voice = voices.find((item) => item.id === assistant.voice);
if (voice) {
const ttsProvider = isSiliconflowVendor(voice.vendor) ? 'siliconflow' : 'edge';
services.tts = {
provider: ttsProvider,
model: voice.model,
apiKey: ttsProvider === 'siliconflow' ? voice.apiKey : null,
voice: resolveRuntimeTtsVoice(assistant.voice, voice),
speed: assistant.speed || voice.speed || 1.0,
};
} else {
services.tts = {
voice: assistant.voice,
speed: assistant.speed || 1.0,
};
warnings.push(`Voice resource not found in loaded list: ${assistant.voice}`);
}
}
const localResolved = {
assistantId: assistant.id,
warnings,
sessionStartMetadata: {
systemPrompt: assistant.prompt || '',
greeting: assistant.opener || '',
services,
history: {
assistantId: assistant.id,
userId: 1,
source: 'debug',
},
},
};
return localResolved;
};
const fetchRuntimeMetadata = async (): Promise<Record<string, any>> => {
const localResolved = buildLocalResolvedRuntime();
setResolvedConfigView(JSON.stringify(localResolved, null, 2));
return localResolved.sessionStartMetadata;
};
const closeWs = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'session.stop', reason: 'debug_drawer_closed' }));
}
wsRef.current?.close();
wsRef.current = null;
wsReadyRef.current = false;
pendingResolveRef.current = null;
pendingRejectRef.current = null;
assistantDraftIndexRef.current = null;
userDraftIndexRef.current = null;
lastUserFinalRef.current = '';
setTextSessionStarted(false);
stopPlaybackImmediately();
if (isOpen) setWsStatus('disconnected');
};
const ensureWsSession = async () => {
if (wsRef.current && wsReadyRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return;
}
if (wsRef.current && wsRef.current.readyState === WebSocket.CONNECTING) {
await new Promise<void>((resolve, reject) => {
pendingResolveRef.current = resolve;
pendingRejectRef.current = reject;
});
return;
}
const metadata = await fetchRuntimeMetadata();
setWsStatus('connecting');
setWsError('');
await new Promise<void>((resolve, reject) => {
pendingResolveRef.current = resolve;
pendingRejectRef.current = reject;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'hello', version: 'v1' }));
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
void playPcm16Chunk(event.data);
return;
}
if (event.data instanceof Blob) {
void event.data.arrayBuffer().then((buf) => playPcm16Chunk(buf));
return;
}
if (typeof event.data !== 'string') return;
let payload: any;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
const type = payload?.type;
if (type === 'hello.ack') {
ws.send(
JSON.stringify({
type: 'session.start',
audio: { encoding: 'pcm_s16le', sample_rate_hz: 16000, channels: 1 },
metadata,
})
);
return;
}
if (type === 'output.audio.start') {
// New utterance audio starts: cancel old queued/playing audio to avoid overlap.
stopPlaybackImmediately();
return;
}
if (type === 'response.interrupted') {
assistantDraftIndexRef.current = null;
setIsLoading(false);
stopPlaybackImmediately();
return;
}
if (type === 'session.started') {
wsReadyRef.current = true;
setWsStatus('ready');
pendingResolveRef.current?.();
pendingResolveRef.current = null;
pendingRejectRef.current = null;
return;
}
if (type === 'input.speech_started') {
setIsLoading(true);
return;
}
if (type === 'input.speech_stopped') {
setIsLoading(false);
return;
}
if (type === 'transcript.delta') {
const delta = String(payload.text || '');
if (!delta) return;
setMessages((prev) => {
const idx = userDraftIndexRef.current;
if (idx === null || !prev[idx] || prev[idx].role !== 'user') {
const next = [...prev, { role: 'user' as const, text: delta }];
userDraftIndexRef.current = next.length - 1;
return next;
}
const next = [...prev];
// ASR interim is typically the latest partial text, not a true text delta.
next[idx] = { ...next[idx], text: delta };
return next;
});
return;
}
if (type === 'transcript.final') {
const finalText = String(payload.text || '');
if (!finalText) {
userDraftIndexRef.current = null;
return;
}
if (lastUserFinalRef.current === finalText) {
userDraftIndexRef.current = null;
return;
}
setMessages((prev) => {
const idx = userDraftIndexRef.current;
userDraftIndexRef.current = null;
if (idx !== null && prev[idx] && prev[idx].role === 'user') {
const next = [...prev];
next[idx] = { ...next[idx], text: finalText || next[idx].text };
lastUserFinalRef.current = finalText;
return next;
}
const last = prev[prev.length - 1];
if (last?.role === 'user') {
if (last.text === finalText) {
lastUserFinalRef.current = finalText;
return prev;
}
if (finalText.startsWith(last.text) || last.text.startsWith(finalText)) {
const next = [...prev];
next[next.length - 1] = { ...last, text: finalText };
lastUserFinalRef.current = finalText;
return next;
}
}
lastUserFinalRef.current = finalText;
return [...prev, { role: 'user', text: finalText }];
});
return;
}
if (type === 'assistant.response.delta') {
const delta = String(payload.text || '');
if (!delta) return;
setMessages((prev) => {
const idx = assistantDraftIndexRef.current;
if (idx === null || !prev[idx] || prev[idx].role !== 'model') {
const last = prev[prev.length - 1];
if (last?.role === 'model' && last.text === delta) {
return prev;
}
const next = [...prev, { role: 'model' as const, text: delta }];
assistantDraftIndexRef.current = next.length - 1;
return next;
}
const next = [...prev];
next[idx] = { ...next[idx], text: next[idx].text + delta };
return next;
});
return;
}
if (type === 'assistant.response.final') {
const finalText = String(payload.text || '');
setMessages((prev) => {
const idx = assistantDraftIndexRef.current;
assistantDraftIndexRef.current = null;
if (idx !== null && prev[idx] && prev[idx].role === 'model') {
const next = [...prev];
next[idx] = { ...next[idx], text: finalText || next[idx].text };
return next;
}
if (!finalText) return prev;
const last = prev[prev.length - 1];
if (last?.role === 'model') {
if (last.text === finalText) return prev;
if (finalText.startsWith(last.text) || last.text.startsWith(finalText)) {
const next = [...prev];
next[next.length - 1] = { ...last, text: finalText };
return next;
}
}
return [...prev, { role: 'model', text: finalText }];
});
setIsLoading(false);
return;
}
if (type === 'error') {
const message = String(payload.message || 'Unknown error');
setWsStatus('error');
setWsError(message);
setIsLoading(false);
const err = new Error(message);
pendingRejectRef.current?.(err);
pendingResolveRef.current = null;
pendingRejectRef.current = null;
}
};
ws.onerror = () => {
const err = new Error('WebSocket connection error');
setWsStatus('error');
setWsError(err.message);
setIsLoading(false);
pendingRejectRef.current?.(err);
pendingResolveRef.current = null;
pendingRejectRef.current = null;
};
ws.onclose = () => {
wsReadyRef.current = false;
setTextSessionStarted(false);
userDraftIndexRef.current = null;
stopPlaybackImmediately();
if (wsStatus !== 'error') setWsStatus('disconnected');
};
});
};
useEffect(() => {
if (!isOpen) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
// If core TTS-related settings changed while drawer stays open,
// reset the active WS session so the next launch uses new metadata.
closeWs();
}, [isOpen, assistant.id, assistant.voice, assistant.speed]);
useEffect(() => {
if (!textTtsEnabled) {
stopPlaybackImmediately();
}
}, [textTtsEnabled]);
useEffect(() => {
if (!isOpen || mode !== 'voice' || callStatus !== 'active') return;
const restartCapture = async () => {
try {
await startVoiceCapture();
} catch (e) {
console.error('Failed to restart voice capture with new 3A settings', e);
}
};
void restartCapture();
}, [aecEnabled, nsEnabled, agcEnabled, selectedMic, mode, callStatus, isOpen]);
useEffect(() => {
if (!isOpen) return;
const localResolved = buildLocalResolvedRuntime();
setResolvedConfigView(JSON.stringify(localResolved, null, 2));
}, [isOpen, assistant, voices, llmModels, asrModels]);
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>
</div>
);
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 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>}
</div>
);
const settingsPanel = (
<div className="h-full border-r border-white/10 bg-background/95 backdrop-blur-md shadow-2xl p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-black tracking-[0.15em] uppercase text-muted-foreground"></h3>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => setSettingsDrawerOpen(false)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase block">WebSocket Endpoint</label>
<Input value={wsUrl} onChange={(e) => setWsUrl(e.target.value)} placeholder="ws://localhost:8000/ws" />
</div>
<div className="flex items-center justify-between gap-2">
<Badge variant="outline" className="text-xs">WS: {wsStatus}</Badge>
<label className="inline-flex items-center gap-1 text-xs text-muted-foreground px-2 py-1 rounded border border-white/10">
<input
type="checkbox"
checked={textTtsEnabled}
onChange={(e) => setTextTtsEnabled(e.target.checked)}
className="accent-primary"
/>
TTS
</label>
</div>
<div className="rounded-md border border-white/10 bg-black/20 p-2 space-y-2">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground">Audio 3A</p>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={aecEnabled} onChange={(e) => setAecEnabled(e.target.checked)} className="accent-primary" />
Echo Cancellation (AEC)
</label>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={nsEnabled} onChange={(e) => setNsEnabled(e.target.checked)} className="accent-primary" />
Noise Suppression (NS)
</label>
<label className="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input type="checkbox" checked={agcEnabled} onChange={(e) => setAgcEnabled(e.target.checked)} className="accent-primary" />
Auto Gain Control (AGC)
</label>
</div>
<div className="rounded-md border border-white/10 bg-black/30">
<button
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground flex items-center justify-between"
onClick={() => setCaptureConfigOpen((v) => !v)}
>
<span>Capture Config Echo</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${captureConfigOpen ? 'rotate-180' : ''}`} />
</button>
{captureConfigOpen && (
<pre className="px-3 pb-3 text-[11px] leading-5 text-cyan-100/90 whitespace-pre-wrap break-all max-h-64 overflow-auto">
{captureConfigView || 'Voice call not started yet.'}
</pre>
)}
</div>
{wsError && <p className="text-xs text-red-400">{wsError}</p>}
<div className="rounded-md border border-white/10 bg-black/30">
<button
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground flex items-center justify-between"
onClick={() => setResolvedConfigOpen((v) => !v)}
>
<span>Resolved Runtime Config</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${resolvedConfigOpen ? 'rotate-180' : ''}`} />
</button>
{resolvedConfigOpen && (
<pre className="px-3 pb-3 text-[11px] leading-5 text-cyan-100/90 whitespace-pre-wrap break-all max-h-64 overflow-auto">
{resolvedConfigView || 'Connect to load resolved config...'}
</pre>
)}
</div>
</div>
</div>
);
return (
<>
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`}>
<div className="relative flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex items-center gap-2 mb-4 shrink-0">
<div className="flex-1 flex justify-center bg-white/5 p-1 rounded-lg">
{(['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>
</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${mode === 'text' && textSessionStarted ? 'mb-0' : 'mb-4'}`}>
{mode === 'text' ? (
textSessionStarted ? (
<div className="flex-1 min-h-0 overflow-hidden animate-in fade-in flex flex-col">
<div className="h-[68vh] min-h-[420px] max-h-[68vh] w-full flex flex-col min-h-0 overflow-hidden">
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 h-full" />
</div>
</div>
) : wsStatus === 'connecting' ? (
<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">
<MessageSquare className="h-10 w-10 text-primary" />
</div>
<div className="text-center">
<p className="text-primary font-mono text-sm tracking-widest animate-pulse">CONNECTING...</p>
<p className="text-xs text-muted-foreground mt-2"></p>
</div>
<Button onClick={closeWs} variant="destructive" className="rounded-full h-10 px-8"></Button>
</div>
) : (
<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 px-6">
<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">
<MessageSquare className="h-10 w-10 text-muted-foreground" />
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-bold text-white mb-1"></h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<Button
onClick={handleTextLaunch}
disabled={wsStatus === 'connecting'}
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 === '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>
<div className="text-center">
<h3 className="text-lg font-bold text-white mb-1"></h3>
<p className="text-xs text-muted-foreground"></p>
</div>
<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 min-h-0 animate-in fade-in">
<div className="mb-2">
<select
className="w-full 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="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 shrink-0"></h4>
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 overflow-y-auto" />
</div>
) : (
<div className="flex flex-col h-full min-h-0 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 scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 overflow-y-auto" />
</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={mode === 'text' && textSessionStarted ? 'shrink-0 mt-3 px-1 mb-3' : 'shrink-0 space-y-2 mt-2 px-1 mb-3'}>
<div className="w-full flex items-center gap-2 min-w-0">
{mode === 'text' && textSessionStarted && (
<Button
variant="destructive"
size="sm"
className="h-9 px-3 font-bold shrink-0 whitespace-nowrap"
onClick={closeWs}
>
<PhoneOff className="h-4 w-4" />
<span className="ml-1.5"></span>
</Button>
)}
<Input
value={inputText}
onChange={e => setInputText(e.target.value)}
placeholder={mode === 'text' && !textSessionStarted ? "请先发起呼叫后输入消息..." : (mode === 'text' ? "输入消息..." : "输入文本模拟交互...")}
onKeyDown={e => e.key === 'Enter' && handleSend()}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
className="flex-1 min-w-0"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleSend}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</Drawer>
{isOpen && (
<div className="fixed inset-y-0 z-[51] right-[min(100vw,32rem)]">
<button
className={`absolute inset-y-0 w-10 border-r border-white/10 bg-background/90 backdrop-blur-md text-muted-foreground hover:text-foreground hover:bg-background/95 transition-[right,color,background-color] duration-300 flex flex-col items-center justify-center gap-2 ${settingsDrawerOpen ? 'right-[min(78vw,28rem)]' : 'right-0'}`}
onClick={() => setSettingsDrawerOpen((v) => !v)}
title={settingsDrawerOpen ? '收起调试设置' : '展开调试设置'}
>
<span className={`text-[10px] font-mono tracking-tight ${settingsDrawerOpen ? 'text-primary animate-pulse' : 'opacity-90 animate-pulse'}`}>
{settingsDrawerOpen ? '>>' : '<<'}
</span>
<Wrench className="h-4 w-4" />
<span className="text-[10px] tracking-widest [writing-mode:vertical-rl]"></span>
</button>
<div className="absolute inset-y-0 right-0 w-[78vw] max-w-md overflow-hidden pointer-events-none">
<div className={`h-full transition-transform duration-300 ease-out will-change-transform ${settingsDrawerOpen ? 'translate-x-0 pointer-events-auto' : 'translate-x-full pointer-events-none'}`}>
{settingsPanel}
</div>
</div>
</div>
)}
</>
);
};