Files
AI-VideoAssistant/web/pages/Assistants.tsx
Xin Wang d96ffdeda4 Add web
2026-02-06 20:43:35 +08:00

1130 lines
65 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, ClipboardCheck, Sparkles, Volume2, Timer, ChevronDown, Link as LinkIcon, Database, Server, Zap, ExternalLink, Key } from 'lucide-react';
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
import { mockAssistants, mockKnowledgeBases, mockVoices } 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('');
// Publish Modal State
const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
const [publishTab, setPublishTab] = useState<'web' | 'api'>('web');
// Custom Tools State
const [customTools, setCustomTools] = useState<ToolItem[]>([]);
const [hiddenToolIds, setHiddenToolIds] = useState<string[]>([]);
const [isAddToolModalOpen, setIsAddToolModalOpen] = useState(false);
const [addingToCategory, setAddingToCategory] = useState<'system' | 'query'>('system');
// New Tool Form State
const [newToolName, setNewToolName] = useState('');
const [newToolDesc, setNewToolDesc] = useState('');
const [deleteId, setDeleteId] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const [saveLoading, setSaveLoading] = useState(false);
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
const filteredAssistants = assistants.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleCreate = () => {
const newId = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
const newAssistant: Assistant = {
id: newId,
name: 'New Assistant',
callCount: 0,
opener: '',
prompt: '',
knowledgeBaseId: '',
language: 'zh',
voice: mockVoices[0]?.id || '',
speed: 1,
hotwords: [],
tools: [],
interruptionSensitivity: 500,
configMode: 'platform',
};
setAssistants([...assistants, newAssistant]);
setSelectedId(newId);
setActiveTab(TabValue.GLOBAL);
};
const handleSave = () => {
setSaveLoading(true);
// Simulate API call
setTimeout(() => {
setSaveLoading(false);
// In a real app, logic to persist selectedAssistant would go here
}, 800);
};
const handleCopyId = (id: string, text?: string) => {
navigator.clipboard.writeText(text || id);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
};
const handleCopy = (e: React.MouseEvent, assistant: Assistant) => {
e.stopPropagation();
const newId = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
const newAssistant = { ...assistant, id: newId, 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));
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 deleteTool = (e: React.MouseEvent, toolId: string) => {
e.stopPropagation();
setAssistants(prev => prev.map(a => ({
...a,
tools: a.tools?.filter(id => id !== toolId) || []
})));
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);
};
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));
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">
{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>
))}
{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-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.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">
<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 && !isNoneConfig && (
<div className="space-y-8">
<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>
{mockVoices.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>
<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>
<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>
<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>
{/* 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}
/>
)}
{/* 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> and 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>
);
};