Files
ai-videoassistant-frontend/pages/Assistants.tsx

882 lines
49 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, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle, PhoneCall, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Wrench, Globe, Terminal, X } from 'lucide-react';
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
import { Assistant, TabValue } from '../types';
import { GoogleGenAI } from "@google/genai";
interface ToolItem {
id: string;
name: string;
icon: React.ReactNode;
desc: string;
category: 'system' | 'query';
isCustom?: boolean;
}
export const AssistantsPage: React.FC = () => {
const [assistants, setAssistants] = useState<Assistant[]>(mockAssistants);
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('');
// Custom Tools State
const [customTools, setCustomTools] = useState<ToolItem[]>([]);
const [hiddenToolIds, setHiddenToolIds] = useState<string[]>([]); // Track deleted/hidden base tools
const [isAddToolModalOpen, setIsAddToolModalOpen] = useState(false);
const [addingToCategory, setAddingToCategory] = useState<'system' | 'query'>('system');
// New Tool Form State
const [newToolName, setNewToolName] = useState('');
const [newToolDesc, setNewToolDesc] = useState('');
// State for delete confirmation dialog
const [deleteId, setDeleteId] = useState<string | null>(null);
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
const filteredAssistants = assistants.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleCreate = () => {
const newId = Date.now().toString();
const newAssistant: Assistant = {
id: newId,
name: 'New Assistant',
callCount: 0,
opener: '',
prompt: '',
knowledgeBaseId: '',
language: 'zh',
voice: 'default',
speed: 1,
hotwords: [],
tools: []
};
setAssistants([...assistants, newAssistant]);
setSelectedId(newId);
};
const handleCopy = (e: React.MouseEvent, assistant: Assistant) => {
e.stopPropagation();
const newAssistant = { ...assistant, id: Date.now().toString(), name: `${assistant.name} (Copy)` };
setAssistants([...assistants, newAssistant]);
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeleteId(id);
};
const confirmDelete = () => {
if (deleteId) {
setAssistants(prev => prev.filter(a => a.id !== deleteId));
if (selectedId === deleteId) setSelectedId(null);
setDeleteId(null);
}
};
const updateAssistant = (field: keyof Assistant, value: any) => {
if (!selectedId) return;
setAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
};
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 deleteTool = (e: React.MouseEvent, toolId: string) => {
e.stopPropagation();
// 1. Remove from assistant configurations if enabled
setAssistants(prev => prev.map(a => ({
...a,
tools: a.tools?.filter(id => id !== toolId) || []
})));
// 2. Remove from tool list
if (customTools.some(t => t.id === toolId)) {
setCustomTools(prev => prev.filter(t => t.id !== toolId));
} else {
setHiddenToolIds(prev => [...prev, toolId]);
}
};
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 handleAddCustomTool = () => {
if (!newToolName.trim()) return;
const newTool: ToolItem = {
id: `custom_${Date.now()}`,
name: newToolName,
desc: newToolDesc,
category: addingToCategory,
icon: addingToCategory === 'system' ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />,
isCustom: true
};
setCustomTools([...customTools, newTool]);
setIsAddToolModalOpen(false);
setNewToolName('');
setNewToolDesc('');
};
const openAddToolModal = (e: React.MouseEvent, cat: 'system' | 'query') => {
e.stopPropagation();
setAddingToCategory(cat);
setIsAddToolModalOpen(true);
};
// Define tools available
const baseSystemTools: ToolItem[] = [
{ id: 'cam_open', name: '打开相机', icon: <Camera className="w-4 h-4" />, desc: '允许 AI 开启摄像头流', category: 'system' },
{ id: 'cam_close', name: '关闭相机', icon: <CameraOff className="w-4 h-4" />, desc: '允许 AI 停止摄像头流', category: 'system' },
{ id: 'take_photo', name: '拍照', icon: <Image className="w-4 h-4" />, desc: 'AI 触发单张拍摄', category: 'system' },
{ id: 'burst_3', name: '连拍三张', icon: <Images className="w-4 h-4" />, desc: 'AI 触发快速连拍', category: 'system' },
];
const baseQueryTools: ToolItem[] = [
{ id: 'q_weather', name: '天气查询', icon: <CloudSun className="w-4 h-4" />, desc: '查询实时及未来天气', category: 'query' },
{ id: 'q_calendar', name: '日历查询', icon: <Calendar className="w-4 h-4" />, desc: '查询日程及节假日信息', category: 'query' },
{ id: 'q_stock', name: '股价查询', icon: <TrendingUp className="w-4 h-4" />, desc: '查询股票实时行情', category: 'query' },
{ id: 'q_exchange', name: '汇率查询', icon: <Coins className="w-4 h-4" />, desc: '查询多国货币汇率', category: 'query' },
];
const systemTools = [...baseSystemTools, ...customTools.filter(t => t.category === 'system')].filter(t => !hiddenToolIds.includes(t.id));
const queryTools = [...baseQueryTools, ...customTools.filter(t => t.category === 'query')].filter(t => !hiddenToolIds.includes(t.id));
return (
<div className="flex h-[calc(100vh-6rem)] gap-6 animate-in fade-in">
{/* LEFT COLUMN: List */}
<div className="w-80 flex flex-col gap-4 shrink-0">
<div className="flex items-center justify-between px-1">
<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">
{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 justify-between items-start mb-2">
<span className={`font-semibold truncate pr-6 ${selectedId === assistant.id ? 'text-primary' : 'text-foreground'}`}>
{assistant.name}
</span>
</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>
{/* Hover Actions */}
<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">
<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>
))}
{filteredAssistants.length === 0 && (
<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-4">
{/* Row 1: Name and Actions */}
<div className="flex items-end justify-between gap-4">
<div className="flex-1">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase mb-2 block ml-1">ASSISTANT NAME</label>
<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">
<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
onClick={() => alert("发布成功!")}
className="shadow-[0_0_20px_rgba(6,182,212,0.3)]"
>
<Rocket className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* Row 2: Tabs */}
<div className="flex bg-white/5 p-1 rounded-lg w-fit">
<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>
</div>
</div>
{/* Content Scroll Area */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
{activeTab === TabValue.GLOBAL && (
<div className="space-y-6">
<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 className="space-y-2">
<label className="text-sm font-medium text-white"></label>
<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"
value={selectedAssistant.knowledgeBaseId}
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
>
<option value="">使</option>
{mockKnowledgeBases.map(kb => (
<option key={kb.id} value={kb.id}>{kb.name}</option>
))}
</select>
</div>
</div>
)}
{activeTab === TabValue.VOICE && (
<div className="space-y-8">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-white"> (Language)</label>
<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"
value={selectedAssistant.language}
onChange={(e) => updateAssistant('language', e.target.value)}
>
<option value="zh"> (Chinese)</option>
<option value="en"> (English)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white"> (Voice)</label>
<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"
value={selectedAssistant.voice}
onChange={(e) => updateAssistant('voice', e.target.value)}
>
<option value="default"> (Default)</option>
<option value="alloy">Alloy</option>
<option value="echo">Echo</option>
<option value="fable">Fable</option>
<option value="onyx">Onyx</option>
<option value="nova">Nova</option>
<option value="shimmer">Shimmer</option>
</select>
</div>
</div>
<div className="space-y-4 p-4 rounded-xl border border-white/5 bg-white/[0.02]">
<div className="flex justify-between items-center">
<label className="text-sm font-medium text-white"> (Speed)</label>
<span className="text-sm font-mono text-primary bg-primary/10 px-2 py-0.5 rounded">{selectedAssistant.speed}x</span>
</div>
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={selectedAssistant.speed}
onChange={(e) => updateAssistant('speed', parseFloat(e.target.value))}
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>0.5x (Slow)</span>
<span>1.0x (Normal)</span>
<span>2.0x (Fast)</span>
</div>
</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}></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"></span>
)}
{selectedAssistant.hotwords.map((word, idx) => (
<Badge key={idx} variant="outline">
{word}
<button onClick={() => removeHotword(word)} className="ml-2 hover:text-destructive transition-colors">×</button>
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
)}
{activeTab === TabValue.TOOLS && (
<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>
<button
onClick={(e) => openAddToolModal(e, 'system')}
className="p-1 rounded-full bg-primary/10 text-primary hover:bg-primary/20 transition-colors shadow-sm"
>
<Plus className="w-3.5 h-3.5" />
</button>
</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'}`}>
{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.desc}</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => deleteTool(e, tool.id)}
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>
<button
onClick={(e) => openAddToolModal(e, 'query')}
className="p-1 rounded-full bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors shadow-sm"
>
<Plus className="w-3.5 h-3.5" />
</button>
</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'}`}>
{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.desc}</p>
</div>
{/* Delete Button */}
<button
onClick={(e) => deleteTool(e, tool.id)}
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>AI </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>
{selectedAssistant && (
<DebugDrawer
isOpen={debugOpen}
onClose={() => setDebugOpen(false)}
assistant={selectedAssistant}
/>
)}
{/* Add Custom Tool Modal */}
<Dialog
isOpen={isAddToolModalOpen}
onClose={() => setIsAddToolModalOpen(false)}
title={addingToCategory === 'system' ? '添加自定义系统指令' : '添加自定义信息查询'}
footer={
<>
<Button variant="ghost" onClick={() => setIsAddToolModalOpen(false)}></Button>
<Button onClick={handleAddCustomTool}></Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input
value={newToolName}
onChange={e => setNewToolName(e.target.value)}
placeholder="例如: 智能家居控制"
autoFocus
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> ( AI )</label>
<textarea
className="flex min-h-[100px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
value={newToolDesc}
onChange={e => setNewToolDesc(e.target.value)}
placeholder="描述该工具的功能,以及 AI 应该在什么情况下调用它..."
/>
</div>
<div className="p-3 bg-primary/5 border border-primary/20 rounded-lg text-[10px] text-muted-foreground flex items-start gap-2">
<Wrench className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />
<p> AI these </p>
</div>
</div>
</Dialog>
{/* 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>
);
// --- Debug Drawer Component ---
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);
const streamRef = useRef<MediaStream | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [selectedCamera, setSelectedCamera] = useState<string>('');
const [selectedMic, setSelectedMic] = useState<string>('');
const [isSwapped, setIsSwapped] = useState(false);
// Initialize
useEffect(() => {
if (isOpen) {
if (mode === 'text') {
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
} else {
setMessages([]);
setCallStatus('idle');
}
} else {
setMode('text');
stopMedia();
setIsSwapped(false);
setCallStatus('idle');
}
}, [isOpen, assistant, mode]);
// Auto-scroll logic
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, mode]);
// Fetch Devices
useEffect(() => {
if (isOpen && mode === 'video') {
const getDevices = async () => {
try {
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);
}
};
getDevices();
}
}, [isOpen, mode]);
const stopMedia = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
};
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 = () => {
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;
const userMsg = inputText;
setMessages(prev => [...prev, { role: 'user', text: userMsg }]);
setInputText('');
setIsLoading(true);
try {
if (process.env.API_KEY) {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const chat = ai.chats.create({
model: "gemini-3-flash-preview",
config: { systemInstruction: assistant.prompt },
history: messages.map(m => ({ role: m.role, parts: [{ text: m.text }] }))
});
const result = await chat.sendMessage({ message: userMsg });
setMessages(prev => [...prev, { role: 'model', text: result.text || '' }]);
} 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." }]);
} finally {
setIsLoading(false);
}
};
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>}
{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>
);
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>
);
return (
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`}>
<div className="flex flex-col h-full">
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
{(['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 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>
<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 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>
</Drawer>
);
};