Initial commit
This commit is contained in:
703
pages/Assistants.tsx
Normal file
703
pages/Assistants.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
||||
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
|
||||
import { Assistant, TabValue } from '../types';
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
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('');
|
||||
|
||||
// State for delete confirmation dialog
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
|
||||
|
||||
const filteredAssistants = assistants.filter(a =>
|
||||
a.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
const newId = Date.now().toString();
|
||||
const newAssistant: Assistant = {
|
||||
id: newId,
|
||||
name: 'New Assistant',
|
||||
callCount: 0,
|
||||
opener: '',
|
||||
prompt: '',
|
||||
knowledgeBaseId: '',
|
||||
language: 'zh',
|
||||
voice: 'default',
|
||||
speed: 1,
|
||||
hotwords: []
|
||||
};
|
||||
setAssistants([...assistants, newAssistant]);
|
||||
setSelectedId(newId);
|
||||
};
|
||||
|
||||
const handleCopy = (e: React.MouseEvent, assistant: Assistant) => {
|
||||
e.stopPropagation();
|
||||
const newAssistant = { ...assistant, id: Date.now().toString(), name: `${assistant.name} (Copy)` };
|
||||
setAssistants([...assistants, newAssistant]);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
setAssistants(prev => prev.filter(a => a.id !== deleteId));
|
||||
if (selectedId === deleteId) setSelectedId(null);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAssistant = (field: keyof Assistant, value: any) => {
|
||||
if (!selectedId) return;
|
||||
setAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
|
||||
};
|
||||
|
||||
const 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));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-6rem)] gap-6 animate-in fade-in">
|
||||
{/* LEFT COLUMN: List */}
|
||||
<div className="w-80 flex flex-col gap-4 shrink-0">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-xl font-bold tracking-tight">小助手列表</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
className="pl-9 bg-card/50 border-white/5"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="icon" onClick={handleCreate} title="新建小助手">
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{filteredAssistants.map(assistant => (
|
||||
<div
|
||||
key={assistant.id}
|
||||
onClick={() => setSelectedId(assistant.id)}
|
||||
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
|
||||
selectedId === assistant.id
|
||||
? 'bg-primary/10 border-primary/40 shadow-[0_0_15px_rgba(6,182,212,0.15)]'
|
||||
: 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className={`font-semibold truncate pr-6 ${selectedId === assistant.id ? 'text-primary' : 'text-foreground'}`}>
|
||||
{assistant.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Phone className="h-3 w-3 mr-1.5 opacity-70" />
|
||||
<span>{assistant.callCount} 次通话</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Actions */}
|
||||
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, assistant)} title="复制">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, assistant.id)} title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredAssistants.length === 0 && (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
未找到小助手
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN: Config Panel */}
|
||||
<div className="flex-1 bg-card/20 backdrop-blur-sm border border-white/5 rounded-2xl overflow-hidden flex flex-col relative shadow-xl">
|
||||
{selectedAssistant ? (
|
||||
<>
|
||||
{/* Header Area */}
|
||||
<div className="p-6 border-b border-white/5 bg-white/[0.02] space-y-4">
|
||||
{/* Row 1: Name and Actions - Aligned with items-end */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground font-mono mb-2 block ml-1">ASSISTANT NAME</label>
|
||||
<Input
|
||||
value={selectedAssistant.name}
|
||||
onChange={(e) => updateAssistant('name', e.target.value)}
|
||||
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDebugOpen(true)}
|
||||
className="border border-primary/20 hover:border-primary/50 text-primary hover:text-primary hover:bg-primary/10 shadow-[0_0_15px_rgba(6,182,212,0.1)]"
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" /> 调试
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => alert("发布成功!")}
|
||||
className="shadow-[0_0_20px_rgba(6,182,212,0.3)]"
|
||||
>
|
||||
<Rocket className="mr-2 h-4 w-4" /> 发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Tabs */}
|
||||
<div className="flex bg-white/5 p-1 rounded-lg w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab(TabValue.GLOBAL)}
|
||||
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.GLOBAL ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
全局配置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab(TabValue.VOICE)}
|
||||
className={`px-6 py-1.5 text-sm font-medium rounded-md transition-all ${activeTab === TabValue.VOICE ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
|
||||
>
|
||||
语音配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Scroll Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
|
||||
{activeTab === TabValue.GLOBAL ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground 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-foreground 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"
|
||||
value={selectedAssistant.prompt}
|
||||
onChange={(e) => updateAssistant('prompt', e.target.value)}
|
||||
placeholder="设定小助手的人设、语气、行为规范以及业务逻辑..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">知识库绑定</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>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">语言 (Language)</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={selectedAssistant.language}
|
||||
onChange={(e) => updateAssistant('language', e.target.value)}
|
||||
>
|
||||
<option value="zh">中文 (Chinese)</option>
|
||||
<option value="en">英文 (English)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">音色 (Voice)</label>
|
||||
<select
|
||||
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
|
||||
value={selectedAssistant.voice}
|
||||
onChange={(e) => updateAssistant('voice', e.target.value)}
|
||||
>
|
||||
<option value="default">默认 (Default)</option>
|
||||
<option value="alloy">Alloy</option>
|
||||
<option value="echo">Echo</option>
|
||||
<option value="fable">Fable</option>
|
||||
<option value="onyx">Onyx</option>
|
||||
<option value="nova">Nova</option>
|
||||
<option value="shimmer">Shimmer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 rounded-xl border border-white/5 bg-white/[0.02]">
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-sm font-medium text-foreground">语速 (Speed)</label>
|
||||
<span className="text-sm font-mono text-primary bg-primary/10 px-2 py-0.5 rounded">{selectedAssistant.speed}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={selectedAssistant.speed}
|
||||
onChange={(e) => updateAssistant('speed', parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0.5x (Slow)</span>
|
||||
<span>1.0x (Normal)</span>
|
||||
<span>2.0x (Fast)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-foreground flex items-center">
|
||||
<Mic className="w-4 h-4 mr-2 text-primary"/> ASR 热词优化 (Hotwords)
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={hotwordInput}
|
||||
onChange={(e) => setHotwordInput(e.target.value)}
|
||||
placeholder="输入专有名词或高频词汇..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && addHotword()}
|
||||
className="bg-white/5 border-white/10"
|
||||
/>
|
||||
<Button variant="secondary" onClick={addHotword}>添加</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 min-h-[40px] p-2 rounded-lg border border-dashed border-white/10">
|
||||
{selectedAssistant.hotwords.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground py-1">暂无热词</span>
|
||||
)}
|
||||
{selectedAssistant.hotwords.map((word, idx) => (
|
||||
<Badge key={idx} variant="outline">
|
||||
{word}
|
||||
<button onClick={() => removeHotword(word)} className="ml-2 hover:text-destructive transition-colors">×</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">添加热词可以提高语音识别特定词汇的准确率。</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||
<BotIcon className="h-8 w-8 opacity-50" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">请选择一个小助手</p>
|
||||
<p className="text-sm opacity-60">从左侧列表选择或创建一个新的小助手以开始配置</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedAssistant && (
|
||||
<DebugDrawer
|
||||
isOpen={debugOpen}
|
||||
onClose={() => setDebugOpen(false)}
|
||||
assistant={selectedAssistant}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
您确定要删除此小助手吗?此操作无法撤销。
|
||||
</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 ---
|
||||
|
||||
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);
|
||||
|
||||
// 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); // False: AI is Big, Local is Small. True: Local is Big, AI is Small.
|
||||
|
||||
// Initialize with opener
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||
} else {
|
||||
// Reset and stop media when closed
|
||||
setMode('text');
|
||||
stopMedia();
|
||||
setIsSwapped(false);
|
||||
}
|
||||
}, [isOpen, assistant]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Handle Video/Media stream
|
||||
const stopMedia = () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleStream = async () => {
|
||||
if (isOpen && mode === 'video') {
|
||||
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 {
|
||||
stopMedia();
|
||||
}
|
||||
};
|
||||
|
||||
handleStream();
|
||||
return () => stopMedia();
|
||||
}, [mode, isOpen, selectedCamera, selectedMic]);
|
||||
|
||||
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 handleClose = () => {
|
||||
stopMedia();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Reusable Messages List Component
|
||||
const TranscriptionLog = () => (
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
|
||||
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4">暂无转写记录</div>}
|
||||
{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>
|
||||
);
|
||||
|
||||
// Helper to render the Local Video Element
|
||||
const renderLocalVideo = (isSmall: boolean) => (
|
||||
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover transform scale-x-[-1]"
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">
|
||||
Me
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper to render the "Remote" AI Video Element (Simulated)
|
||||
const renderRemoteVideo = (isSmall: boolean) => (
|
||||
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
|
||||
<div className={`absolute rounded-full bg-primary/40 animate-ping ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}></div>
|
||||
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
|
||||
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
|
||||
</div>
|
||||
</div>
|
||||
{!isSmall && (
|
||||
<div className="mt-4 font-mono text-primary animate-pulse text-sm">
|
||||
{assistant.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer isOpen={isOpen} onClose={handleClose} title={`调试: ${assistant.name}`}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'text' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('text')}
|
||||
>
|
||||
<MessageSquare className="inline w-4 h-4 mr-1"/> 文本
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'voice' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('voice')}
|
||||
>
|
||||
<Mic className="inline w-4 h-4 mr-1"/> 语音
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'video' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
||||
onClick={() => setMode('video')}
|
||||
>
|
||||
<Video className="inline w-4 h-4 mr-1"/> 视频
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4 gap-2">
|
||||
{mode === 'text' && <TranscriptionLog />}
|
||||
|
||||
{mode === 'voice' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Visualizer Area */}
|
||||
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/20 pointer-events-none"></div>
|
||||
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
|
||||
<Mic className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm relative z-10">通话中...</p>
|
||||
</div>
|
||||
{/* Transcript */}
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1">实时转写 / Live Transcription</h4>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'video' && (
|
||||
<div className="flex flex-col h-full space-y-2">
|
||||
{/* Video Area (Top) */}
|
||||
<div className="h-3/5 shrink-0 flex flex-col gap-2">
|
||||
{/* Device Settings Bar */}
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
||||
value={selectedCamera}
|
||||
onChange={(e) => setSelectedCamera(e.target.value)}
|
||||
>
|
||||
{devices.filter(d => d.kind === 'videoinput').map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Camera...`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
||||
value={selectedMic}
|
||||
onChange={(e) => setSelectedMic(e.target.value)}
|
||||
>
|
||||
{devices.filter(d => d.kind === 'audioinput').map(d => (
|
||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Mic...`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Container (PiP) */}
|
||||
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
|
||||
{/* Main Window */}
|
||||
<div className="absolute inset-0">
|
||||
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
|
||||
</div>
|
||||
{/* Small Window */}
|
||||
<div className="absolute bottom-2 right-2 w-24 h-36 z-10 transition-all duration-300">
|
||||
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
|
||||
</div>
|
||||
{/* Swap Button */}
|
||||
<div className="absolute top-2 right-2 z-20">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="rounded-full h-7 w-7 bg-black/50 hover:bg-primary/80 backdrop-blur text-white border border-white/20"
|
||||
onClick={() => setIsSwapped(!isSwapped)}
|
||||
title="切换窗口"
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript Area (Bottom) */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1">实时转写 / Live Transcription</h4>
|
||||
<TranscriptionLog />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions - Unified Input */}
|
||||
<div className="shrink-0 space-y-2">
|
||||
{(mode === 'voice' || mode === 'video') && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="destructive" size="sm" className="w-full shadow-red-500/20 shadow-lg" onClick={handleClose}>
|
||||
<PhoneOff className="mr-2 h-3 w-3" /> 挂断通话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder={mode === 'text' ? "输入消息..." : "输入文字模拟语音/Input to simulate speech..."}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="icon" onClick={handleSend} disabled={isLoading}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user