1234 lines
74 KiB
TypeScript
1234 lines
74 KiB
TypeScript
|
||
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, BrainCircuit, Ear, Book, Filter } from 'lucide-react';
|
||
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
||
import { mockAssistants, mockKnowledgeBases, mockVoices, mockLLMModels, mockASRModels } 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',
|
||
llmModelId: '',
|
||
asrModelId: '',
|
||
embeddingModelId: '',
|
||
rerankModelId: '',
|
||
};
|
||
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.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>
|
||
{mockLLMModels.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>
|
||
{mockLLMModels.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>
|
||
{mockLLMModels.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>
|
||
{mockKnowledgeBases.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>
|
||
{mockASRModels.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>
|
||
{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>
|
||
);
|
||
};
|