Compare commits

..

3 Commits

Author SHA1 Message Date
Xin Wang
b608c395c7 Better UX 2026-02-04 18:36:40 +08:00
Xin Wang
47207dab19 边栏缩略图,小助手添加工具配置,添加自动测试大类 2026-02-04 16:35:44 +08:00
Xin Wang
75914cf2e6 Update workflow 2026-02-02 10:10:24 +08:00
15 changed files with 2516 additions and 465 deletions

73
App.tsx
View File

@@ -1,75 +1,100 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { HashRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom'; import { HashRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import { Bot, Phone, Book, User, LayoutDashboard, Cpu, Mic2, Video } from 'lucide-react'; import { Bot, Phone, Book, User, LayoutDashboard, Mic2, Video, GitBranch, Zap, PanelLeftClose, PanelLeftOpen, History as HistoryIcon } from 'lucide-react';
import { AssistantsPage } from './pages/Assistants'; import { AssistantsPage } from './pages/Assistants';
import { KnowledgeBasePage } from './pages/KnowledgeBase'; import { KnowledgeBasePage } from './pages/KnowledgeBase';
import { CallLogsPage } from './pages/CallLogs'; import { HistoryPage } from './pages/History';
import { ProfilePage } from './pages/Profile'; import { ProfilePage } from './pages/Profile';
import { DashboardPage } from './pages/Dashboard'; import { DashboardPage } from './pages/Dashboard';
import { VoiceLibraryPage } from './pages/VoiceLibrary'; import { VoiceLibraryPage } from './pages/VoiceLibrary';
import { WorkflowsPage } from './pages/Workflows';
import { WorkflowEditorPage } from './pages/WorkflowEditor';
import { AutoTestPage } from './pages/AutoTest';
const SidebarItem: React.FC<{ to: string; icon: React.ReactNode; label: string; active: boolean }> = ({ to, icon, label, active }) => ( const SidebarItem: React.FC<{ to: string; icon: React.ReactNode; label: string; active: boolean; isCollapsed: boolean }> = ({ to, icon, label, active, isCollapsed }) => (
<Link <Link
to={to} to={to}
className={`flex items-center space-x-3 px-4 py-3 rounded-md transition-all duration-200 ${active ? 'bg-primary/20 text-primary border-r-2 border-primary' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}`} title={isCollapsed ? label : undefined}
className={`flex items-center space-x-3 px-4 py-3 rounded-md transition-all duration-300 ${active ? 'bg-primary/20 text-primary border-r-2 border-primary' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'} ${isCollapsed ? 'justify-center space-x-0 px-2' : ''}`}
> >
{icon} <div className="shrink-0">{icon}</div>
<span className="font-medium text-sm">{label}</span> {!isCollapsed && <span className="font-medium text-sm animate-in fade-in duration-300 whitespace-nowrap overflow-hidden">{label}</span>}
</Link> </Link>
); );
const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation(); const location = useLocation();
const [isCollapsed, setIsCollapsed] = useState(false);
const navItems = [ const navItems = [
{ path: '/', label: '首页', icon: <LayoutDashboard className="h-5 w-5" /> }, { path: '/', label: '首页', icon: <LayoutDashboard className="h-5 w-5" /> },
{ path: '/assistants', label: '小助手', icon: <Bot className="h-5 w-5" /> }, { path: '/assistants', label: '小助手', icon: <Bot className="h-5 w-5" /> },
{ path: '/voices', label: '声音库', icon: <Mic2 className="h-5 w-5" /> }, { path: '/voices', label: '声音库', icon: <Mic2 className="h-5 w-5" /> },
{ path: '/call-logs', label: '视频通话记录', icon: <Phone className="h-5 w-5" /> }, { path: '/history', label: '历史记录', icon: <HistoryIcon className="h-5 w-5" /> },
{ path: '/knowledge', label: '知识库', icon: <Book className="h-5 w-5" /> }, { path: '/knowledge', label: '知识库', icon: <Book className="h-5 w-5" /> },
{ path: '/workflows', label: '工作流', icon: <GitBranch className="h-5 w-5" /> },
{ path: '/auto-test', label: '测试助手', icon: <Zap className="h-5 w-5" /> },
{ path: '/profile', label: '个人中心', icon: <User className="h-5 w-5" /> }, { path: '/profile', label: '个人中心', icon: <User className="h-5 w-5" /> },
]; ];
return ( return (
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Sidebar with Glass effect */} {/* Sidebar with Glass effect and collapse logic */}
<aside className="w-64 border-r border-border/40 bg-card/30 backdrop-blur-md hidden md:flex flex-col"> <aside
<div className="p-6 flex items-center space-x-3 border-b border-border/40 overflow-hidden"> className={`border-r border-border/40 bg-card/30 backdrop-blur-md hidden md:flex flex-col transition-all duration-300 ease-in-out relative group ${isCollapsed ? 'w-20' : 'w-64'}`}
{/* Cool Logo */} >
<div className={`p-6 flex items-center border-b border-border/40 overflow-hidden transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'space-x-3'}`}>
<div className="h-10 w-10 shrink-0 bg-gradient-to-br from-cyan-400 to-blue-600 rounded-xl flex items-center justify-center shadow-[0_0_20px_rgba(6,182,212,0.5)] border border-white/10"> <div className="h-10 w-10 shrink-0 bg-gradient-to-br from-cyan-400 to-blue-600 rounded-xl flex items-center justify-center shadow-[0_0_20px_rgba(6,182,212,0.5)] border border-white/10">
<Video className="h-6 w-6 text-white drop-shadow-md" /> <Video className="h-6 w-6 text-white drop-shadow-md" />
</div> </div>
{/* No Wrap Title */} {!isCollapsed && (
<span className="text-lg font-bold tracking-wide whitespace-nowrap bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80"> <span className="text-lg font-bold tracking-wide whitespace-nowrap bg-clip-text text-transparent bg-gradient-to-r from-white to-white/80 animate-in slide-in-from-left-2">
AI VideoAssistant AI视频助手
</span> </span>
)}
</div> </div>
<nav className="flex-1 p-4 space-y-2">
<nav className="flex-1 p-4 space-y-2 overflow-y-auto overflow-x-hidden custom-scrollbar">
{navItems.map(item => ( {navItems.map(item => (
<SidebarItem <SidebarItem
key={item.path} key={item.path}
to={item.path} to={item.path}
icon={item.icon} icon={item.icon}
label={item.label} label={item.label}
isCollapsed={isCollapsed}
active={item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path)} active={item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path)}
/> />
))} ))}
</nav> </nav>
<div className="p-4 border-t border-border/40 text-xs text-muted-foreground text-center font-mono">
SYSTEM v2.0 {/* Footer with Version and Collapse Button */}
<div className={`p-4 border-t border-border/40 flex items-center transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isCollapsed && (
<span className="text-[10px] text-muted-foreground font-mono opacity-60 animate-in fade-in">
SYSTEM v1.0
</span>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
title={isCollapsed ? "展开边栏" : "收起边栏"}
className={`p-1.5 rounded-md hover:bg-white/5 text-muted-foreground hover:text-primary transition-all ${isCollapsed ? '' : 'ml-2'}`}
>
{isCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button>
</div> </div>
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden relative"> <main className="flex-1 flex flex-col overflow-hidden relative bg-background">
<header className="h-16 border-b border-border/40 flex items-center px-6 bg-card/30 backdrop-blur-md md:hidden space-x-3"> <header className="h-16 border-b border-border/40 flex items-center px-6 bg-card/30 backdrop-blur-md md:hidden space-x-3">
<div className="h-8 w-8 bg-gradient-to-br from-cyan-400 to-blue-600 rounded-lg flex items-center justify-center shadow-lg"> <div className="h-8 w-8 bg-gradient-to-br from-cyan-400 to-blue-600 rounded-lg flex items-center justify-center shadow-lg">
<Video className="h-5 w-5 text-white" /> <Video className="h-5 w-5 text-white" />
</div> </div>
<span className="font-bold text-lg whitespace-nowrap">AI VideoAssistant</span> <span className="font-bold text-lg whitespace-nowrap text-white">AI视频助手</span>
</header> </header>
<div className="flex-1 overflow-auto p-6 md:p-8"> <div className="flex-1 overflow-auto p-2 md:p-4 transition-all duration-300">
{children} {children}
</div> </div>
</main> </main>
@@ -86,7 +111,11 @@ const App: React.FC = () => {
<Route path="/assistants" element={<AssistantsPage />} /> <Route path="/assistants" element={<AssistantsPage />} />
<Route path="/voices" element={<VoiceLibraryPage />} /> <Route path="/voices" element={<VoiceLibraryPage />} />
<Route path="/knowledge" element={<KnowledgeBasePage />} /> <Route path="/knowledge" element={<KnowledgeBasePage />} />
<Route path="/call-logs" element={<CallLogsPage />} /> <Route path="/history" element={<HistoryPage />} />
<Route path="/workflows" element={<WorkflowsPage />} />
<Route path="/workflows/new" element={<WorkflowEditorPage />} />
<Route path="/workflows/edit/:id" element={<WorkflowEditorPage />} />
<Route path="/auto-test" element={<AutoTestPage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
</Routes> </Routes>
</AppLayout> </AppLayout>

View File

@@ -1,9 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI VideoAssistant</title> <title>AI视频助手</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {

View File

@@ -1,5 +1,6 @@
{ {
"name": "AI VideoAssistant", "name": "AI视频助手",
"description": "A minimalist AI Assistant management system featuring agent configuration, knowledge base management, call logs, and a debugging suite.", "description": "A minimalist AI Assistant management system featuring agent configuration, knowledge base management, call logs, and a debugging suite.",
"requestFramePermissions": [ "requestFramePermissions": [
"microphone", "microphone",

View File

@@ -1,5 +1,5 @@
{ {
"name": "ai-videoassistant", "name": "ai视频助手",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",

File diff suppressed because it is too large Load Diff

314
pages/AutoTest.tsx Normal file
View File

@@ -0,0 +1,314 @@
import React, { useState } from 'react';
import { Plus, Search, Play, Copy, Trash2, Zap, MessageSquare, Mic, AlertTriangle, ClipboardCheck, X } from 'lucide-react';
import { Button, Input, Card, Badge, Dialog } from '../components/UI';
import { mockAutoTestAssistants, mockAssistants } from '../services/mockData';
import { AutoTestAssistant, TestType, TestMethod } from '../types';
export const AutoTestPage: React.FC = () => {
const [testAssistants, setTestAssistants] = useState<AutoTestAssistant[]>(mockAutoTestAssistants);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const filteredTests = testAssistants.filter(t =>
t.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedTest = testAssistants.find(t => t.id === selectedId) || null;
const handleCreate = () => {
const newId = crypto.randomUUID();
const newTest: AutoTestAssistant = {
id: newId,
name: '新测试任务',
type: TestType.FIXED,
method: TestMethod.TEXT,
targetAssistantId: mockAssistants[0]?.id || '',
fixedWorkflowSteps: [],
intelligentPrompt: '',
createdAt: new Date().toISOString().split('T')[0],
};
setTestAssistants([newTest, ...testAssistants]);
setSelectedId(newId);
};
const handleCopy = (e: React.MouseEvent, test: AutoTestAssistant) => {
e.stopPropagation();
const newId = crypto.randomUUID();
const newTest: AutoTestAssistant = {
...test,
id: newId,
name: `${test.name} (复制)`,
createdAt: new Date().toISOString().split('T')[0],
};
setTestAssistants([newTest, ...testAssistants]);
setSelectedId(newId);
};
const updateTest = (field: keyof AutoTestAssistant, value: any) => {
if (!selectedId) return;
setTestAssistants(prev => prev.map(t => t.id === selectedId ? { ...t, [field]: value } : t));
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeleteId(id);
setIsDeleteModalOpen(true);
};
const confirmDelete = () => {
if (deleteId) {
setTestAssistants(prev => prev.filter(t => t.id !== deleteId));
if (selectedId === deleteId) setSelectedId(null);
setIsDeleteModalOpen(false);
setDeleteId(null);
}
};
const handleCopyId = (id: string) => {
navigator.clipboard.writeText(id);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
};
return (
<div className="flex h-[calc(100vh-8rem)] gap-6 animate-in fade-in py-4">
{/* Left List */}
<div className="w-80 flex flex-col gap-4 shrink-0">
<h2 className="text-xl font-bold tracking-tight text-white"></h2>
<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">
{filteredTests.map(test => (
<div
key={test.id}
onClick={() => setSelectedId(test.id)}
className={`group relative flex flex-col p-4 rounded-xl border transition-all cursor-pointer ${
selectedId === test.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 === test.id ? 'text-primary' : 'text-foreground'}`}>
{test.name}
</span>
<div className="flex">
<Badge
variant="outline"
className={`text-[9px] uppercase tracking-tighter shrink-0 opacity-70 border-white/10 ${
test.type === TestType.FIXED ? 'text-blue-400 bg-blue-400/5' : 'text-purple-400 bg-purple-400/5'
}`}
>
{test.type === TestType.FIXED ? '固定' : '智能'}
</Badge>
</div>
</div>
<div className="text-[10px] text-muted-foreground font-mono opacity-60">
: {test.createdAt}
</div>
{/* Hover Actions Toolbar */}
<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, test)} 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, test.id)} title="删除测试任务">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
{filteredTests.length === 0 && (
<div className="text-center py-10 text-muted-foreground text-sm">
</div>
)}
</div>
</div>
{/* Right 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">
{selectedTest ? (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-6 border-b border-white/5 bg-white/[0.02]">
<div className="flex items-end justify-between gap-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1"></label>
</div>
<Input
value={selectedTest.name}
onChange={(e) => updateTest('name', e.target.value)}
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
/>
</div>
<Button onClick={() => alert("开始自动化测试脚本生成...")}>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
<div className="max-w-3xl mx-auto space-y-8 animate-in slide-in-from-bottom-2">
{/* Basic Config */}
<div className="grid grid-cols-2 gap-6">
<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 text-foreground focus:ring-1 focus:ring-primary/50 [&>option]:bg-card"
value={selectedTest.targetAssistantId}
onChange={(e) => updateTest('targetAssistantId', e.target.value)}
>
{mockAssistants.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white"></label>
<div className="flex bg-white/5 p-1 rounded-lg">
<button
onClick={() => updateTest('method', TestMethod.TEXT)}
className={`flex-1 flex items-center justify-center py-1.5 text-xs rounded-md transition-all ${selectedTest.method === TestMethod.TEXT ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-white/5'}`}
>
<MessageSquare className="w-3 h-3 mr-1.5" />
</button>
<button
onClick={() => updateTest('method', TestMethod.AUDIO)}
className={`flex-1 flex items-center justify-center py-1.5 text-xs rounded-md transition-all ${selectedTest.method === TestMethod.AUDIO ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-white/5'}`}
>
<Mic className="w-3 h-3 mr-1.5" />
</button>
</div>
</div>
</div>
{/* Test Logic Type */}
<div className="space-y-4">
<label className="text-sm font-medium text-white"></label>
<div className="grid grid-cols-2 gap-4">
<Card
className={`p-4 cursor-pointer transition-all border-2 ${selectedTest.type === TestType.FIXED ? 'border-primary bg-primary/5' : 'border-white/5'}`}
onClick={() => updateTest('type', TestType.FIXED)}
>
<div className="flex items-center space-x-3 mb-2">
<Badge variant={selectedTest.type === TestType.FIXED ? 'default' : 'outline'}></Badge>
</div>
<p className="text-[10px] text-muted-foreground text-white/70"></p>
</Card>
<Card
className={`p-4 cursor-pointer transition-all border-2 ${selectedTest.type === TestType.INTELLIGENT ? 'border-primary bg-primary/5' : 'border-white/5'}`}
onClick={() => updateTest('type', TestType.INTELLIGENT)}
>
<div className="flex items-center space-x-3 mb-2">
<Badge variant={selectedTest.type === TestType.INTELLIGENT ? 'default' : 'outline'}></Badge>
</div>
<p className="text-[10px] text-muted-foreground text-white/70"> AI </p>
</Card>
</div>
</div>
{/* Content Config */}
{selectedTest.type === TestType.FIXED ? (
<div className="space-y-4">
<label className="text-sm font-medium text-white"></label>
<div className="space-y-2">
{selectedTest.fixedWorkflowSteps.map((step, idx) => (
<div key={idx} className="flex gap-2 animate-in slide-in-from-left-2" style={{ animationDelay: `${idx * 50}ms` }}>
<div className="h-9 w-9 shrink-0 flex items-center justify-center bg-white/5 rounded-lg text-xs font-mono text-muted-foreground">{idx + 1}</div>
<Input
value={step}
onChange={(e) => {
const newSteps = [...selectedTest.fixedWorkflowSteps];
newSteps[idx] = e.target.value;
updateTest('fixedWorkflowSteps', newSteps);
}}
placeholder="输入用户消息..."
/>
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => {
updateTest('fixedWorkflowSteps', selectedTest.fixedWorkflowSteps.filter((_, i) => i !== idx));
}}>
<X size={14} className="text-destructive" />
</Button>
</div>
))}
<Button variant="outline" className="w-full border-dashed" onClick={() => {
updateTest('fixedWorkflowSteps', [...selectedTest.fixedWorkflowSteps, '']);
}}>
<Plus size={14} className="mr-2" />
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<label className="text-sm font-medium text-white"> (Intelligent Prompt)</label>
<textarea
className="w-full h-48 bg-white/5 border border-white/10 rounded-xl p-4 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 text-white placeholder:text-muted-foreground/30 leading-relaxed"
value={selectedTest.intelligentPrompt}
onChange={(e) => updateTest('intelligentPrompt', e.target.value)}
placeholder="描述扮演的角色背景、性格特点以及本次测试需要达成的目标..."
/>
<div className="p-3 bg-primary/5 border border-primary/20 rounded-lg text-[10px] text-muted-foreground flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />
<p>使 AI </p>
</div>
</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">
<Zap className="h-8 w-8 opacity-50" />
</div>
<p className="text-lg font-medium"></p>
<p className="text-sm opacity-60"></p>
</div>
)}
</div>
{/* Delete Confirmation */}
<Dialog
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
title="确认删除"
footer={
<>
<Button variant="ghost" onClick={() => setIsDeleteModalOpen(false)}></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-white"></p>
<p className="text-xs text-muted-foreground mt-1">
: {testAssistants.find(t => t.id === deleteId)?.name}
</p>
</div>
</div>
</Dialog>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Download, Search, Calendar, Filter } from 'lucide-react'; import { Download, Search, Calendar, Filter } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI';
@@ -27,7 +28,7 @@ export const CallLogsPage: React.FC = () => {
log.startTime, log.startTime,
log.duration log.duration
].join(',')); ].join(','));
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...rows].join('\n'); const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...headers.join(',')].join('\n');
const encodedUri = encodeURI(csvContent); const encodedUri = encodeURI(csvContent);
const link = document.createElement("a"); const link = document.createElement("a");
link.setAttribute("href", encodedUri); link.setAttribute("href", encodedUri);
@@ -38,7 +39,7 @@ export const CallLogsPage: React.FC = () => {
}; };
return ( return (
<div className="space-y-6 animate-in fade-in"> <div className="space-y-6 animate-in fade-in py-2 pb-10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1> <h1 className="text-2xl font-bold tracking-tight"></h1>
<Button variant="outline" onClick={handleExport}> <Button variant="outline" onClick={handleExport}>

View File

@@ -1,5 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Phone, CheckCircle, Clock, UserCheck, Activity, Filter } from 'lucide-react'; import { Phone, CheckCircle, Clock, UserCheck, Activity, Filter, ChevronDown, BarChart3, HelpCircle, Mail, Sparkles, Terminal, Box, Zap, ShieldCheck } from 'lucide-react';
import { Card, Button } from '../components/UI'; import { Card, Button } from '../components/UI';
import { mockAssistants, getDashboardStats } from '../services/mockData'; import { mockAssistants, getDashboardStats } from '../services/mockData';
@@ -12,78 +13,125 @@ export const DashboardPage: React.FC = () => {
}, [timeRange, selectedAssistantId]); }, [timeRange, selectedAssistantId]);
return ( return (
<div className="space-y-6 animate-in fade-in"> <div className="min-h-full flex flex-col animate-in fade-in py-1">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="w-full max-w-[1600px] mx-auto space-y-4 px-2 lg:px-6">
<h1 className="text-2xl font-bold tracking-tight text-foreground"></h1>
{/* Filters */} {/* 1. Utility Row (Top Navigation Actions) */}
<div className="flex flex-col sm:flex-row items-center gap-3 bg-card/40 backdrop-blur-md p-2 rounded-lg border border-border/50"> <div className="flex justify-end items-center gap-2 border-b border-white/[0.03] pb-2">
<div className="flex items-center px-2"> <Button variant="ghost" size="sm" className="h-7 px-2.5 text-[10px] font-bold border border-white/5 hover:bg-primary/10 hover:text-primary transition-all text-white/70">
<Filter className="h-4 w-4 text-primary mr-2" /> <HelpCircle className="w-3 h-3 mr-1.5 opacity-70" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2.5 text-[10px] font-bold border border-white/5 hover:bg-primary/10 hover:text-primary transition-all text-white/70">
<Mail className="w-3 h-3 mr-1.5 opacity-70" />
</Button>
</div>
{/* 2. Welcome Header */}
<div className="flex flex-col space-y-4 text-center md:text-left pt-1">
<div className="space-y-0.5">
<h1 className="text-2xl font-bold tracking-tight text-white">
, <span className="text-primary">Admin User</span>
</h1>
<p className="text-muted-foreground flex items-center justify-center md:justify-start text-[11px]">
<span className="flex items-center ml-2 text-green-400 text-[10px] font-mono bg-green-400/10 px-1.5 py-0.5 rounded-full border border-green-400/20">
<span className="w-1 h-1 rounded-full bg-green-400 animate-pulse mr-1"></span>
HEALTHY
</span>
</p>
</div>
</div>
{/* 3. Section Header: Title + Filters */}
<div className="flex flex-col md:flex-row items-center justify-between border-b border-white/5 pb-3 pt-2 gap-3">
<div className="flex items-center space-x-2.5">
<div className="p-1 bg-primary/10 rounded-lg">
<BarChart3 className="h-3.5 w-3.5 text-primary" />
</div>
<div className="flex flex-col">
<h2 className="text-sm font-bold text-white tracking-wide leading-none"></h2>
<span className="text-[8px] font-mono text-muted-foreground uppercase tracking-[0.2em] opacity-40 mt-1">Metrics Overview</span>
</div>
</div>
<div className="flex items-center gap-1.5 bg-black/20 p-0.5 rounded-lg border border-white/5 shadow-inner scale-95 origin-right">
<div className="relative group min-w-[130px]">
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground group-focus-within:text-primary transition-colors">
<Filter className="h-2.5 w-2.5" />
</div>
<select <select
className="bg-transparent text-sm font-medium focus:outline-none text-foreground [&>option]:bg-background" className="w-full bg-transparent border-0 rounded-lg pl-7 pr-6 py-1 text-[10px] font-bold focus:outline-none appearance-none cursor-pointer transition-all text-white/80"
value={selectedAssistantId} value={selectedAssistantId}
onChange={(e) => setSelectedAssistantId(e.target.value)} onChange={(e) => setSelectedAssistantId(e.target.value)}
> >
<option value="all"></option> <option value="all" className="bg-background"></option>
{mockAssistants.map(a => ( {mockAssistants.map(a => (
<option key={a.id} value={a.id}>{a.name}</option> <option key={a.id} value={a.id} className="bg-background">{a.name}</option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 h-2.5 w-2.5 text-muted-foreground pointer-events-none" />
</div> </div>
<div className="h-4 w-px bg-border/50 hidden sm:block"></div>
<div className="flex bg-muted/50 rounded-md p-1"> <div className="h-3 w-px bg-white/10 mx-0.5"></div>
<div className="flex gap-0.5">
{(['week', 'month', 'year'] as const).map((r) => ( {(['week', 'month', 'year'] as const).map((r) => (
<button <button
key={r} key={r}
onClick={() => setTimeRange(r)} onClick={() => setTimeRange(r)}
className={`px-3 py-1 text-xs font-medium rounded-sm transition-all ${timeRange === r ? 'bg-primary text-primary-foreground shadow-[0_0_10px_rgba(6,182,212,0.3)]' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`} className={`px-2.5 py-0.5 text-[9px] font-black uppercase tracking-tight rounded transition-all ${timeRange === r ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
> >
{r === 'week' ? '近一周' : r === 'month' ? '近一个月' : '近一年'} {r === 'week' ? '周' : r === 'month' ? '月' : '年'}
</button> </button>
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* Metrics Grid */} {/* 4. Metrics Grid (Cards) */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
title="通话数量" title="通话数量"
value={stats.totalCalls.toString()} value={stats.totalCalls.toString()}
icon={<Phone className="h-4 w-4 text-primary" />} icon={<Phone className="h-3.5 w-3.5 text-primary" />}
trend="+12.5% 较上期" trend="+12.5% UP"
/> />
<StatCard <StatCard
title="接通率" title="接通率"
value={`${stats.answerRate}%`} value={`${stats.answerRate}%`}
icon={<CheckCircle className="h-4 w-4 text-green-400" />} icon={<CheckCircle className="h-3.5 w-3.5 text-green-400" />}
trend="+2.1% 较上期" trend="+2.1% UP"
/> />
<StatCard <StatCard
title="平均通话时长" title="平均时长"
value={stats.avgDuration} value={stats.avgDuration}
icon={<Clock className="h-4 w-4 text-blue-400" />} icon={<Clock className="h-3.5 w-3.5 text-blue-400" />}
trend="-0.5% 较上期" trend="-0.5% LOW"
/> />
<StatCard <StatCard
title="转人工数" title="转人工数"
value={stats.humanTransferCount.toString()} value={stats.humanTransferCount.toString()}
icon={<UserCheck className="h-4 w-4 text-purple-400" />} icon={<UserCheck className="h-3.5 w-3.5 text-purple-400" />}
trend="+5% 较上期" trend="+5% STABLE"
/> />
</div> </div>
{/* Charts Section */} {/* 5. Charts Section */}
<div className="grid gap-4 md:grid-cols-1"> <div className="pt-1">
<Card className="p-6 border-primary/20 bg-card/30"> <Card className="p-5 border-white/5 bg-card/20 backdrop-blur-sm overflow-hidden shadow-xl">
<div className="flex items-center justify-between mb-6"> <div className="flex flex-col md:flex-row items-center justify-between mb-6 gap-3">
<div className="space-y-1"> <div className="space-y-0.5 text-center md:text-left">
<h3 className="text-lg font-medium leading-none flex items-center"> <h3 className="text-base font-bold leading-none flex items-center justify-center md:justify-start text-white">
<Activity className="h-5 w-5 text-primary mr-2" /> <Activity className="h-4 w-4 text-primary mr-2" />
(Performance Insight)
</h3> </h3>
<p className="text-sm text-muted-foreground"></p> <p className="text-[10px] text-muted-foreground font-mono opacity-40">REAL-TIME DATA PROCESSING PIPELINE ENABLED</p>
</div>
<div className="flex items-center space-x-2">
<div className="h-6 w-6 rounded-full border border-primary/20 flex items-center justify-center animate-spin-slow">
<div className="h-1 w-1 rounded-full bg-primary shadow-[0_0_8px_rgba(6,182,212,0.8)]"></div>
</div>
<span className="text-[9px] font-mono text-primary animate-pulse tracking-widest uppercase">Streaming</span>
</div> </div>
</div> </div>
<div className="h-[300px] w-full"> <div className="h-[300px] w-full">
@@ -91,6 +139,69 @@ export const DashboardPage: React.FC = () => {
</div> </div>
</Card> </Card>
</div> </div>
{/* 6. Platform Feature Intro - Moved to Bottom, Full Width */}
<div className="w-full bg-white/[0.02] border border-white/5 rounded-2xl p-6 animate-in slide-in-from-bottom-4 duration-700 shadow-2xl relative overflow-hidden group pb-10 mb-10">
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 blur-[100px] -mr-32 -mt-32 rounded-full pointer-events-none group-hover:bg-primary/10 transition-colors"></div>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<div className="p-1.5 bg-primary/20 rounded-lg">
<Sparkles className="w-4 h-4 text-primary" />
</div>
<h3 className="text-sm font-bold text-white tracking-wide"></h3>
</div>
<div className="mb-6">
<p className="text-sm text-white/80 leading-relaxed font-medium">
AI视频助手是一个领先的多模态智能体管理平台 AI 🚀
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2 group/item">
<div className="flex items-center gap-2">
<span className="text-lg">🤖</span>
<h4 className="text-xs font-bold text-primary uppercase tracking-wider"></h4>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
7x24h
</p>
</div>
<div className="space-y-2 group/item">
<div className="flex items-center gap-2">
<span className="text-lg">📚</span>
<h4 className="text-xs font-bold text-primary uppercase tracking-wider"></h4>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
RAG PDF/DOCX
</p>
</div>
<div className="space-y-2 group/item">
<div className="flex items-center gap-2">
<span className="text-lg">🎙</span>
<h4 className="text-xs font-bold text-primary uppercase tracking-wider"></h4>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
TTS
</p>
</div>
<div className="space-y-2 group/item">
<div className="flex items-center gap-2">
<span className="text-lg">🛡</span>
<h4 className="text-xs font-bold text-primary uppercase tracking-wider"></h4>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed group-hover:text-white/70 transition-colors">
AI
</p>
</div>
</div>
</div>
</div>
</div>
</div> </div>
); );
}; };
@@ -98,14 +209,20 @@ export const DashboardPage: React.FC = () => {
// --- Sub Components --- // --- Sub Components ---
const StatCard: React.FC<{ title: string; value: string; icon: React.ReactNode; trend?: string }> = ({ title, value, icon, trend }) => ( const StatCard: React.FC<{ title: string; value: string; icon: React.ReactNode; trend?: string }> = ({ title, value, icon, trend }) => (
<Card className="p-6 border-border/40 hover:border-primary/50 transition-colors"> <Card className="p-4 border-white/5 bg-card/30 hover:border-primary/40 hover:bg-card/50 transition-all duration-300 group flex flex-col justify-between min-h-[110px] shadow-lg">
<div className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex flex-row items-center justify-between space-y-0">
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3> <h3 className="text-[9px] font-mono font-bold text-muted-foreground uppercase tracking-[0.15em]">{title}</h3>
<div className="p-1.5 bg-white/5 rounded-lg group-hover:bg-primary/20 transition-all group-hover:scale-110">
{icon} {icon}
</div> </div>
<div className="content-end"> </div>
<div className="text-2xl font-bold tracking-tight text-foreground">{value}</div> <div className="mt-2">
{trend && <p className="text-xs text-muted-foreground mt-1">{trend}</p>} <div className="text-2xl font-black tracking-tight text-white group-hover:text-primary transition-colors">{value}</div>
{trend && (
<p className={`text-[8px] font-bold font-mono mt-1 flex items-center ${trend.includes('+') ? 'text-green-400' : 'text-red-400'}`}>
<span className="bg-white/5 px-1 rounded-sm mr-1 opacity-70">{trend}</span>
</p>
)}
</div> </div>
</Card> </Card>
); );
@@ -113,9 +230,9 @@ const StatCard: React.FC<{ title: string; value: string; icon: React.ReactNode;
const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> = ({ data }) => { const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> = ({ data }) => {
if (!data || data.length === 0) return null; if (!data || data.length === 0) return null;
const height = 250; const height = 300;
const width = 1000; const width = 1400;
const padding = 20; const padding = 30;
const maxValue = Math.max(...data.map(d => d.value)) * 1.2; const maxValue = Math.max(...data.map(d => d.value)) * 1.2;
const points = data.map((d, i) => { const points = data.map((d, i) => {
@@ -131,9 +248,16 @@ const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> =
return ( return (
<div className="w-full h-full relative"> <div className="w-full h-full relative">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full overflow-visible" preserveAspectRatio="none"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full overflow-visible" preserveAspectRatio="none">
{/* Tech Grid Lines */} {/* Grid Lines */}
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="hsl(var(--border))" strokeWidth="1" /> <line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="rgba(255,255,255,0.03)" strokeWidth="1" />
<line x1={padding} y1={padding} x2={width - padding} y2={padding} stroke="hsl(var(--border))" strokeWidth="1" strokeDasharray="4 4" opacity="0.3" /> {[0.25, 0.5, 0.75].map(v => (
<line
key={v}
x1={padding} y1={height - padding - ((height - padding * 2) * v)}
x2={width - padding} y2={height - padding - ((height - padding * 2) * v)}
stroke="rgba(255,255,255,0.02)" strokeWidth="1" strokeDasharray="8 6"
/>
))}
{/* Area Fill Gradient */} {/* Area Fill Gradient */}
<defs> <defs>
@@ -141,9 +265,8 @@ const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> =
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" /> <stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" /> <stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
</linearGradient> </linearGradient>
{/* Glow Filter */}
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%"> <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="coloredBlur" /> <feGaussianBlur stdDeviation="5" result="coloredBlur" />
<feMerge> <feMerge>
<feMergeNode in="coloredBlur" /> <feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" /> <feMergeNode in="SourceGraphic" />
@@ -151,10 +274,8 @@ const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> =
</filter> </filter>
</defs> </defs>
{/* Fill Area */}
<polygon points={fillPath} fill="url(#chartGradient)" /> <polygon points={fillPath} fill="url(#chartGradient)" />
{/* Main Line with Glow */}
<polyline <polyline
points={points} points={points}
fill="none" fill="none"
@@ -163,22 +284,23 @@ const SimpleAreaChart: React.FC<{ data: { label: string, value: number }[] }> =
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
filter="url(#glow)" filter="url(#glow)"
className="drop-shadow-sm"
/> />
{/* Data Points */} {data.length < 32 && data.map((d, i) => {
{data.length < 20 && data.map((d, i) => {
const x = (i / (data.length - 1)) * (width - padding * 2) + padding; const x = (i / (data.length - 1)) * (width - padding * 2) + padding;
const y = height - (d.value / maxValue) * (height - padding * 2) - padding; const y = height - (d.value / maxValue) * (height - padding * 2) - padding;
return ( return (
<circle key={i} cx={x} cy={y} r="4" fill="hsl(var(--background))" stroke="hsl(var(--primary))" strokeWidth="2" /> <g key={i} className="group/dot">
<circle cx={x} cy={y} r="3.5" fill="hsl(var(--background))" stroke="hsl(var(--primary))" strokeWidth="2" className="transition-all duration-300 group-hover/dot:r-5 group-hover/dot:stroke-white" />
<circle cx={x} cy={y} r="10" fill="hsl(var(--primary))" fillOpacity="0" className="cursor-pointer" />
</g>
); );
})} })}
</svg> </svg>
{/* X-Axis Labels */} {/* X-Axis Labels */}
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-[2%] text-xs text-muted-foreground pointer-events-none font-mono"> <div className="absolute bottom-0 left-0 right-0 flex justify-between px-[3%] text-[8px] text-muted-foreground pointer-events-none font-mono opacity-30 mt-2">
{data.filter((_, i) => i % Math.ceil(data.length / 6) === 0).map((d, i) => ( {data.filter((_, i) => i % Math.ceil(data.length / 7) === 0).map((d, i) => (
<span key={i}>{d.label}</span> <span key={i}>{d.label}</span>
))} ))}
</div> </div>

128
pages/History.tsx Normal file
View File

@@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { Download, Search, Calendar, Filter } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI';
import { mockCallLogs } from '../services/mockData';
export const HistoryPage: React.FC = () => {
const [logs] = useState(mockCallLogs);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
const filteredLogs = logs.filter(log => {
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || log.status === statusFilter;
const matchesSource = sourceFilter === 'all' || log.source === sourceFilter;
return matchesSearch && matchesStatus && matchesSource;
});
const handleExport = () => {
// Generate CSV content
const headers = ['ID', 'Agent', 'Source', 'Status', 'Start Time', 'Duration'];
const rows = filteredLogs.map(log => [
log.id,
log.agentName,
log.source,
log.status,
log.startTime,
log.duration
].join(','));
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...headers.join(',')].join('\n');
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "history_logs.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
<Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索代理小助手..."
className="pl-9 border-0 bg-white/5"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
className="flex h-9 w-full rounded-md border-0 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"
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value as any)}
>
<option value="all"></option>
<option value="debug"> (Debug)</option>
<option value="external"> (External)</option>
</select>
</div>
<div className="flex items-center space-x-2">
<select
className="flex h-9 w-full rounded-md border-0 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"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
>
<option value="all"></option>
<option value="connected"></option>
<option value="missed"></option>
</select>
</div>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="date" className="pl-9 border-0 bg-white/5" />
</div>
</div>
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
<table className="w-full text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<tbody>
{filteredLogs.map(log => (
<TableRow key={log.id}>
<TableCell className="font-mono text-xs text-muted-foreground">#{log.id}</TableCell>
<TableCell className="font-medium text-white">{log.agentName}</TableCell>
<TableCell>
<Badge variant="outline">{log.source === 'debug' ? '调试' : '外部'}</Badge>
</TableCell>
<TableCell>
<Badge variant={log.status === 'connected' ? 'success' : 'warning'}>
{log.status === 'connected' ? '已接通' : '未接通'}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{log.startTime}</TableCell>
<TableCell className="text-muted-foreground">{log.duration}</TableCell>
</TableRow>
))}
{filteredLogs.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react'; import { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
@@ -10,6 +11,8 @@ export const KnowledgeBasePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [kbs, setKbs] = useState(mockKnowledgeBases); const [kbs, setKbs] = useState(mockKnowledgeBases);
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false);
const [newKbName, setNewKbName] = useState('');
const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase())); const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
@@ -22,34 +25,53 @@ export const KnowledgeBasePage: React.FC = () => {
setIsUploadOpen(true); setIsUploadOpen(true);
}; };
const handleCreateKb = () => {
if (!newKbName.trim()) return;
const newKb: KnowledgeBase = {
id: `kb_${Date.now()}`,
name: newKbName.trim(),
creator: 'Admin User',
createdAt: new Date().toISOString().split('T')[0],
documents: []
};
setKbs([newKb, ...kbs]);
setIsCreateKbOpen(false);
setNewKbName('');
};
if (view === 'detail' && selectedKb) { if (view === 'detail' && selectedKb) {
return ( return (
<> <div className="py-4 pb-10">
<KnowledgeBaseDetail <KnowledgeBaseDetail
kb={selectedKb} kb={selectedKb}
onBack={() => setView('list')} onBack={() => setView('list')}
onImport={handleImportClick} onImport={handleImportClick}
/> />
<UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} /> <UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
</> </div>
); );
} }
return ( return (
<div className="space-y-6 animate-in fade-in"> <div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1> <h1 className="text-2xl font-bold tracking-tight text-white"></h1>
</div> </div>
<div className="flex items-center space-x-2 bg-card/50 p-2 rounded-lg border border-white/5 shadow-sm w-full md:w-1/3"> {/* Search Bar - Layout aligned with History Page and width filled */}
<Search className="h-4 w-4 text-muted-foreground ml-2" /> <div className="bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative w-full">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="搜索知识库名称..." placeholder="搜索知识库名称..."
className="border-0 shadow-none bg-transparent focus-visible:ring-0" className="pl-9 border-0 bg-white/5 w-full"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredKbs.map(kb => ( {filteredKbs.map(kb => (
@@ -63,7 +85,7 @@ export const KnowledgeBasePage: React.FC = () => {
<FileText className="h-6 w-6" /> <FileText className="h-6 w-6" />
</div> </div>
</div> </div>
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">{kb.name}</h3> <h3 className="text-lg font-semibold group-hover:text-primary transition-colors text-white">{kb.name}</h3>
<div className="mt-4 space-y-1 text-sm text-muted-foreground"> <div className="mt-4 space-y-1 text-sm text-muted-foreground">
<p>: {kb.documents.length}</p> <p>: {kb.documents.length}</p>
<p>: {kb.creator}</p> <p>: {kb.creator}</p>
@@ -73,12 +95,44 @@ export const KnowledgeBasePage: React.FC = () => {
</Card> </Card>
))} ))}
{/* Add New Placeholer */} {/* Add New Placeholder */}
<div className="border border-dashed border-white/10 rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-white/5 hover:border-primary/30 transition-all cursor-pointer min-h-[200px]"> <div
onClick={() => setIsCreateKbOpen(true)}
className="border border-dashed border-white/10 rounded-xl p-6 flex flex-col items-center justify-center text-muted-foreground hover:bg-white/5 hover:border-primary/30 transition-all cursor-pointer min-h-[200px]"
>
<Plus className="h-8 w-8 mb-2 opacity-50" /> <Plus className="h-8 w-8 mb-2 opacity-50" />
<span></span> <span></span>
</div> </div>
</div> </div>
{/* New Knowledge Base Dialog */}
<Dialog
isOpen={isCreateKbOpen}
onClose={() => setIsCreateKbOpen(false)}
title="新建知识库"
footer={
<>
<Button variant="ghost" onClick={() => setIsCreateKbOpen(false)}></Button>
<Button onClick={handleCreateKb} disabled={!newKbName.trim()}></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={newKbName}
onChange={(e) => setNewKbName(e.target.value)}
placeholder="请输入知识库名称..."
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateKb()}
/>
</div>
<p className="text-xs text-muted-foreground">
AI
</p>
</div>
</Dialog>
</div> </div>
); );
}; };
@@ -99,7 +153,7 @@ const KnowledgeBaseDetail: React.FC<{
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-bold">{kb.name}</h1> <h1 className="text-2xl font-bold text-white">{kb.name}</h1>
<p className="text-sm text-muted-foreground"> {kb.createdAt} · by {kb.creator}</p> <p className="text-sm text-muted-foreground"> {kb.createdAt} · by {kb.creator}</p>
</div> </div>
</div> </div>
@@ -110,7 +164,7 @@ const KnowledgeBaseDetail: React.FC<{
<Card className="overflow-hidden border-white/5"> <Card className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-white/5"> <div className="p-4 border-b border-white/5 flex justify-between items-center bg-white/5">
<h3 className="font-medium"></h3> <h3 className="font-medium text-white"></h3>
<div className="w-64"> <div className="w-64">
<Input <Input
placeholder="搜索文档..." placeholder="搜索文档..."
@@ -132,21 +186,18 @@ const KnowledgeBaseDetail: React.FC<{
<tbody> <tbody>
{filteredDocs.length > 0 ? filteredDocs.map(doc => ( {filteredDocs.length > 0 ? filteredDocs.map(doc => (
<TableRow key={doc.id}> <TableRow key={doc.id}>
<TableCell className="font-medium flex items-center"> <TableCell className="font-medium flex items-center text-white">
<FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name} <FileText className="h-4 w-4 mr-2 text-primary"/> {doc.name}
</TableCell> </TableCell>
<TableCell>{doc.size}</TableCell> <TableCell className="text-muted-foreground">{doc.size}</TableCell>
<TableCell>{doc.uploadDate}</TableCell> <TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80"></Button> <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80"></Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) : ( )) : (
<TableRow> <TableRow>
<TableCell className="text-center py-8 text-muted-foreground"></TableCell> <TableCell colSpan={4} className="text-center py-8 text-muted-foreground"></TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</tbody> </tbody>
@@ -176,7 +227,6 @@ const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpe
e.stopPropagation(); e.stopPropagation();
setDragActive(false); setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) { if (e.dataTransfer.files && e.dataTransfer.files[0]) {
// Add new files to existing state
setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]); setFiles(prev => [...prev, ...Array.from(e.dataTransfer.files)]);
} }
}; };
@@ -224,7 +274,7 @@ const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpe
<p className="text-sm text-muted-foreground text-center"> <p className="text-sm text-muted-foreground text-center">
<span className="font-semibold text-primary"></span> <span className="font-semibold text-primary"></span>
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> PDF, DOCX, TXT (Max 10MB)</p> <p className="text-xs text-muted-foreground mt-1 text-white/50"> PDF, DOCX, TXT (Max 10MB)</p>
</div> </div>
{files.length > 0 && ( {files.length > 0 && (
@@ -233,10 +283,10 @@ const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpe
<div key={idx} className="flex items-center justify-between p-2 rounded-md bg-white/5 border border-white/5"> <div key={idx} className="flex items-center justify-between p-2 rounded-md bg-white/5 border border-white/5">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<FileIcon className="h-4 w-4 text-primary shrink-0" /> <FileIcon className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm truncate max-w-[200px]">{file.name}</span> <span className="text-sm truncate max-w-[200px] text-white">{file.name}</span>
<span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span> <span className="text-xs text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
</div> </div>
<button onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive"> <button onClick={() => removeFile(idx)} className="text-muted-foreground hover:text-destructive transition-colors">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
</div> </div>

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Search, Mic2, Play, Pause, Upload, X, Filter } from 'lucide-react'; import { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
import { mockVoices } from '../services/mockData'; import { mockVoices } from '../services/mockData';
import { Voice } from '../types'; import { Voice } from '../types';
@@ -8,12 +8,13 @@ import { Voice } from '../types';
export const VoiceLibraryPage: React.FC = () => { export const VoiceLibraryPage: React.FC = () => {
const [voices, setVoices] = useState<Voice[]>(mockVoices); const [voices, setVoices] = useState<Voice[]>(mockVoices);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax'>('all'); const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动'>('all');
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all'); const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all'); const [langFilter, setLangFilter] = useState<'all' | 'zh' | 'en'>('all');
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null); const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false); const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const filteredVoices = voices.filter(voice => { const filteredVoices = voices.filter(voice => {
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase()); const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
@@ -28,26 +29,31 @@ export const VoiceLibraryPage: React.FC = () => {
setPlayingVoiceId(null); setPlayingVoiceId(null);
} else { } else {
setPlayingVoiceId(id); setPlayingVoiceId(id);
// Mock auto-stop after 3 seconds
setTimeout(() => { setTimeout(() => {
setPlayingVoiceId((current) => current === id ? null : current); setPlayingVoiceId((current) => current === id ? null : current);
}, 3000); }, 3000);
} }
}; };
const handleCloneSuccess = (newVoice: Voice) => { const handleAddSuccess = (newVoice: Voice) => {
setVoices([newVoice, ...voices]); setVoices([newVoice, ...voices]);
setIsAddModalOpen(false);
setIsCloneModalOpen(false); setIsCloneModalOpen(false);
}; };
return ( return (
<div className="space-y-6 animate-in fade-in"> <div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight"></h1> <h1 className="text-2xl font-bold tracking-tight text-white"></h1>
<Button onClick={() => setIsCloneModalOpen(true)}> <div className="flex space-x-3">
<Button variant="primary" onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button variant="primary" onClick={() => setIsCloneModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Mic2 className="mr-2 h-4 w-4" /> <Mic2 className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div>
{/* Filter Bar */} {/* Filter Bar */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
@@ -63,11 +69,12 @@ export const VoiceLibraryPage: React.FC = () => {
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" /> <Filter className="h-4 w-4 text-muted-foreground" />
<select <select
className="flex h-9 w-full rounded-md border-0 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" className="flex h-9 w-full rounded-md border-0 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={vendorFilter} value={vendorFilter}
onChange={(e) => setVendorFilter(e.target.value as any)} onChange={(e) => setVendorFilter(e.target.value as any)}
> >
<option value="all"></option> <option value="all"></option>
<option value="硅基流动"> (SiliconFlow)</option>
<option value="Ali"> (Ali)</option> <option value="Ali"> (Ali)</option>
<option value="Volcano"> (Volcano)</option> <option value="Volcano"> (Volcano)</option>
<option value="Minimax">Minimax</option> <option value="Minimax">Minimax</option>
@@ -75,7 +82,7 @@ export const VoiceLibraryPage: React.FC = () => {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<select <select
className="flex h-9 w-full rounded-md border-0 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" className="flex h-9 w-full rounded-md border-0 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={genderFilter} value={genderFilter}
onChange={(e) => setGenderFilter(e.target.value as any)} onChange={(e) => setGenderFilter(e.target.value as any)}
> >
@@ -86,7 +93,7 @@ export const VoiceLibraryPage: React.FC = () => {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<select <select
className="flex h-9 w-full rounded-md border-0 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" className="flex h-9 w-full rounded-md border-0 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={langFilter} value={langFilter}
onChange={(e) => setLangFilter(e.target.value as any)} onChange={(e) => setLangFilter(e.target.value as any)}
> >
@@ -97,7 +104,7 @@ export const VoiceLibraryPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md"> <div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -113,15 +120,18 @@ export const VoiceLibraryPage: React.FC = () => {
<TableRow key={voice.id}> <TableRow key={voice.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex flex-col"> <div className="flex flex-col">
<span>{voice.name}</span> <span className="flex items-center text-white">
{voice.vendor === '硅基流动' && <Sparkles className="w-3 h-3 text-primary mr-1.5" />}
{voice.name}
</span>
{voice.description && <span className="text-xs text-muted-foreground">{voice.description}</span>} {voice.description && <span className="text-xs text-muted-foreground">{voice.description}</span>}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline">{voice.vendor}</Badge> <Badge variant={voice.vendor === '硅基流动' ? 'default' : 'outline'}>{voice.vendor}</Badge>
</TableCell> </TableCell>
<TableCell>{voice.gender === 'Male' ? '男' : '女'}</TableCell> <TableCell className="text-muted-foreground">{voice.gender === 'Male' ? '男' : '女'}</TableCell>
<TableCell>{voice.language === 'zh' ? '中文' : 'English'}</TableCell> <TableCell className="text-muted-foreground">{voice.language === 'zh' ? '中文' : 'English'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
@@ -136,26 +146,230 @@ export const VoiceLibraryPage: React.FC = () => {
))} ))}
{filteredVoices.length === 0 && ( {filteredVoices.length === 0 && (
<TableRow> <TableRow>
<TableCell className="text-center py-6 text-muted-foreground"></TableCell> <TableCell colSpan={5} className="text-center py-6 text-muted-foreground"></TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
<TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
<AddVoiceModal
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
onSuccess={handleAddSuccess}
/>
<CloneVoiceModal <CloneVoiceModal
isOpen={isCloneModalOpen} isOpen={isCloneModalOpen}
onClose={() => setIsCloneModalOpen(false)} onClose={() => setIsCloneModalOpen(false)}
onSuccess={handleCloneSuccess} onSuccess={handleAddSuccess}
/> />
</div> </div>
); );
}; };
// --- Unified Add Voice Modal ---
const AddVoiceModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSuccess: (voice: Voice) => void;
}> = ({ isOpen, onClose, onSuccess }) => {
const [vendor, setVendor] = useState<'硅基流动' | 'Ali' | 'Volcano' | 'Minimax'>('硅基流动');
const [name, setName] = useState('');
const [sfModel, setSfModel] = useState('fishaudio/fish-speech-1.5');
const [sfVoiceId, setSfVoiceId] = useState('fishaudio:amy');
const [sfSpeed, setSfSpeed] = useState(1);
const [sfGain, setSfGain] = useState(0);
const [model, setModel] = useState('');
const [voiceKey, setVoiceKey] = useState('');
const [gender, setGender] = useState('Female');
const [language, setLanguage] = useState('zh');
const [description, setDescription] = useState('');
const [testInput, setTestInput] = useState('你好,正在测试语音合成效果。');
const [isAuditioning, setIsAuditioning] = useState(false);
const handleAudition = () => {
if (!testInput.trim()) return;
setIsAuditioning(true);
setTimeout(() => setIsAuditioning(false), 2000);
};
const handleSubmit = () => {
if (!name) { alert("请填写声音显示名称"); return; }
let newVoice: Voice = {
id: `${vendor === '硅基流动' ? 'sf' : 'gen'}-${Date.now()}`,
name: name,
vendor: vendor,
gender: gender,
language: language,
description: description || (vendor === '硅基流动' ? `Model: ${sfModel}` : `Model: ${model}`)
};
onSuccess(newVoice);
setName('');
setVendor('硅基流动');
setDescription('');
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title="添加声音"
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={handleSubmit} className="bg-primary hover:bg-primary/90"></Button>
</>
}
>
<div className="space-y-4 max-h-[75vh] overflow-y-auto px-1 custom-scrollbar">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Vendor)</label>
<div className="relative">
<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 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
value={vendor}
onChange={(e) => setVendor(e.target.value as any)}
>
<option value="硅基流动"> (SiliconFlow)</option>
<option value="Ali"> (Ali)</option>
<option value="Volcano"> (Volcano)</option>
<option value="Minimax">Minimax</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
</div>
<div className="h-px bg-white/5"></div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="例如: 客服小美" />
</div>
{vendor === '硅基流动' ? (
<div className="space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Model)</label>
<select
className="flex h-9 w-full rounded-md border-0 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 text-foreground [&>option]:bg-card"
value={sfModel}
onChange={e => setSfModel(e.target.value)}
>
<option value="fishaudio/fish-speech-1.5">fishaudio/fish-speech-1.5</option>
<option value="fishaudio/fish-speech-1.4">fishaudio/fish-speech-1.4</option>
<option value="ByteDance/SA-Speech">ByteDance/SA-Speech</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> ID (Voice)</label>
<Input value={sfVoiceId} onChange={e => setSfVoiceId(e.target.value)} placeholder="fishaudio:amy" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Speed)</label>
<div className="flex items-center space-x-2">
<input type="range" min="0.5" max="2" step="0.1" value={sfSpeed} onChange={e => setSfSpeed(parseFloat(e.target.value))} className="flex-1 accent-primary" />
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfSpeed}x</span>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Gain)</label>
<div className="flex items-center space-x-2">
<input type="range" min="-10" max="10" step="1" value={sfGain} onChange={e => setSfGain(parseInt(e.target.value))} className="flex-1 accent-primary" />
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{sfGain}dB</span>
</div>
</div>
</div>
</div>
) : (
<div className="space-y-4 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input value={model} onChange={e => setModel(e.target.value)} placeholder="API Model Key" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input value={voiceKey} onChange={e => setVoiceKey(e.target.value)} placeholder="Voice Key" />
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<select
className="flex h-9 w-full rounded-md border-0 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 text-foreground [&>option]:bg-card"
value={gender}
onChange={e => setGender(e.target.value)}
>
<option value="Female"> (Female)</option>
<option value="Male"> (Male)</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<select
className="flex h-9 w-full rounded-md border-0 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 text-foreground [&>option]:bg-card"
value={language}
onChange={e => setLanguage(e.target.value)}
>
<option value="zh"> (Chinese)</option>
<option value="en"> (English)</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<textarea
className="flex min-h-[60px] w-full rounded-md border-0 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={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="记录该声音的特点..."
/>
</div>
<div className="p-4 rounded-xl border border-primary/20 bg-primary/5 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-[10px] font-black text-primary flex items-center tracking-widest uppercase">
<Volume2 className="w-3.5 h-3.5 mr-1.5" /> (Preview)
</h4>
{vendor === '硅基流动' && <Badge variant="outline" className="text-[8px] border-primary/20 text-primary/70">SiliconFlow Audio API</Badge>}
</div>
<div className="flex gap-2">
<Input
value={testInput}
onChange={e => setTestInput(e.target.value)}
placeholder="输入测试文本..."
className="text-xs bg-black/20"
/>
<Button
variant="primary"
size="sm"
onClick={handleAudition}
disabled={isAuditioning}
className="shrink-0 h-9"
>
{isAuditioning ? <Pause className="h-3.5 w-3.5 animate-pulse" /> : <Play className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
</div>
</Dialog>
);
};
const CloneVoiceModal: React.FC<{ const CloneVoiceModal: React.FC<{
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -178,19 +392,16 @@ const CloneVoiceModal: React.FC<{
return; return;
} }
// Mock creation
const newVoice: Voice = { const newVoice: Voice = {
id: `v-${Date.now()}`, id: `v-${Date.now()}`,
name: name, name: name,
vendor: 'Volcano', // Default for cloned voices vendor: 'Volcano',
gender: 'Female', // Mock default gender: 'Female',
language: 'zh', language: 'zh',
description: description || 'User cloned voice' description: description || 'User cloned voice'
}; };
onSuccess(newVoice); onSuccess(newVoice);
// Reset
setName(''); setName('');
setDescription(''); setDescription('');
setFile(null); setFile(null);
@@ -210,7 +421,7 @@ const CloneVoiceModal: React.FC<{
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium"></label> <label className="text-sm font-medium text-white"></label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@@ -219,7 +430,7 @@ const CloneVoiceModal: React.FC<{
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium"> ()</label> <label className="text-sm font-medium text-white"> ()</label>
<div <div
className="flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed border-white/10 bg-white/5 hover:bg-white/10 transition-colors cursor-pointer" className="flex flex-col items-center justify-center w-full h-32 rounded-lg border-2 border-dashed border-white/10 bg-white/5 hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
@@ -246,9 +457,9 @@ const CloneVoiceModal: React.FC<{
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium"></label> <label className="text-sm font-medium text-white"></label>
<textarea <textarea
className="flex min-h-[80px] w-full rounded-md border-0 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" className="flex min-h-[80px] w-full rounded-md border-0 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={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="描述声音特点(如:年轻、沉稳..." placeholder="描述声音特点(如:年轻、沉稳..."

402
pages/WorkflowEditor.tsx Normal file
View File

@@ -0,0 +1,402 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Play, Save, Rocket, Plus, Bot, UserCheck, Wrench, Ban, Zap, X, Copy, MousePointer2 } from 'lucide-react';
import { Button, Input, Badge } from '../components/UI';
import { mockAssistants, mockKnowledgeBases, mockWorkflows } from '../services/mockData';
import { WorkflowNode, WorkflowEdge, Workflow } from '../types';
import { DebugDrawer } from './Assistants';
export const WorkflowEditorPage: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams();
const [searchParams] = useSearchParams();
// Template data for new workflows
const templateName = searchParams.get('name');
const templateType = searchParams.get('template');
// Find initial workflow or create a new one
const existingWf = mockWorkflows.find(w => w.id === id);
const [name, setName] = useState(templateName || existingWf?.name || '新工作流');
const [nodes, setNodes] = useState<WorkflowNode[]>(() => {
if (existingWf) return existingWf.nodes;
if (templateType === 'lead') {
return [
{
name: 'introduction',
type: 'conversation',
isStart: true,
metadata: { position: { x: 100, y: 100 } },
prompt: "You are Morgan from GrowthPartners. Start with: 'Hello, this is Morgan from GrowthPartners. We help businesses improve their operational efficiency through custom software solutions. Do you have a few minutes to chat about how we might be able to help your business?'",
messagePlan: { firstMessage: "Hello, this is Morgan from GrowthPartners. Do you have a few minutes to chat?" }
},
{
name: 'need_discovery',
type: 'conversation',
metadata: { position: { x: 450, y: 250 } },
prompt: "Conduct need discovery by asking about business challenges...",
variableExtractionPlan: {
output: [
{ title: 'industry', type: 'string', description: 'user industry' },
{ title: 'pain_points', type: 'string', description: 'main challenges' }
]
}
},
{
name: 'hangup_node',
type: 'end',
metadata: { position: { x: 450, y: 550 } },
tool: {
type: 'endCall',
function: { name: 'hangup', parameters: {} },
messages: [{ type: 'request-start', content: 'Thank you for your time!', blocking: true }]
}
}
];
}
return [
{
name: 'start_node',
type: 'conversation',
isStart: true,
metadata: { position: { x: 200, y: 200 } },
prompt: '欢迎对话系统...',
messagePlan: { firstMessage: '你好!' }
}
];
});
const [edges, setEdges] = useState<WorkflowEdge[]>(existingWf?.edges || []);
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
const [isDebugOpen, setIsDebugOpen] = useState(false);
const [zoom, setZoom] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const [draggingNodeName, setDraggingNodeName] = useState<string | null>(null);
const [isPanning, setIsPanning] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const panStart = useRef({ x: 0, y: 0 });
const selectedNode = nodes.find(n => n.name === selectedNodeName);
// Scroll Zoom handler
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const zoomSpeed = 0.001;
const newZoom = Math.min(Math.max(zoom - e.deltaY * zoomSpeed, 0.2), 3);
setZoom(newZoom);
};
// Middle mouse click handler for panning
const handleMouseDown = (e: React.MouseEvent) => {
// Button 1 is middle mouse
if (e.button === 1) {
e.preventDefault();
setIsPanning(true);
panStart.current = { x: e.clientX - panOffset.x, y: e.clientY - panOffset.y };
}
};
const handleNodeMouseDown = (e: React.MouseEvent, nodeName: string) => {
if (e.button !== 0) return; // Only left click for nodes
e.stopPropagation();
setSelectedNodeName(nodeName);
setDraggingNodeName(nodeName);
const node = nodes.find(n => n.name === nodeName);
if (node) {
dragOffset.current = {
x: e.clientX - node.metadata.position.x * zoom,
y: e.clientY - node.metadata.position.y * zoom
};
}
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (draggingNodeName) {
setNodes(prev => prev.map(n =>
n.name === draggingNodeName
? { ...n, metadata: { ...n.metadata, position: { x: (e.clientX - dragOffset.current.x) / zoom, y: (e.clientY - dragOffset.current.y) / zoom } } }
: n
));
} else if (isPanning) {
setPanOffset({
x: e.clientX - panStart.current.x,
y: e.clientY - panStart.current.y
});
}
};
const handleMouseUp = (e: MouseEvent) => {
setDraggingNodeName(null);
setIsPanning(false);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [draggingNodeName, isPanning, zoom]);
const addNode = (type: WorkflowNode['type']) => {
const newNode: WorkflowNode = {
name: `${type}_${Date.now()}`,
type,
metadata: { position: { x: (300 - panOffset.x) / zoom, y: (300 - panOffset.y) / zoom } },
prompt: type === 'conversation' ? '输入该节点的 Prompt...' : '',
tool: type === 'end' ? { type: 'endCall', function: { name: 'hangup', parameters: {} } } : undefined
};
setNodes([...nodes, newNode]);
setIsAddMenuOpen(false);
};
const updateNodeData = (field: string, value: any) => {
if (!selectedNodeName) return;
setNodes(prev => prev.map(n => n.name === selectedNodeName ? { ...n, [field]: value } : n));
};
const handleSave = () => {
const now = new Date().toISOString().replace('T', ' ').substring(0, 16);
const updatedWorkflow: Workflow = {
id: id || `wf_${Date.now()}`,
name,
nodeCount: nodes.length,
createdAt: existingWf?.createdAt || now,
updatedAt: now,
nodes,
edges,
};
if (id) {
const idx = mockWorkflows.findIndex(w => w.id === id);
if (idx !== -1) mockWorkflows[idx] = updatedWorkflow;
} else {
mockWorkflows.push(updatedWorkflow);
}
alert('保存成功!工作流已同步至列表。');
navigate('/workflows');
};
return (
<div className="flex flex-col h-[calc(100vh-6rem)] relative bg-card/10 border border-white/5 rounded-2xl overflow-hidden animate-in fade-in duration-300">
{/* Editor Header */}
<header className="h-14 border-b border-white/5 bg-white/[0.02] flex items-center justify-between px-4 shrink-0">
<div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/workflows')} className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="h-4 w-px bg-white/10"></div>
<Input
value={name}
onChange={e => setName(e.target.value)}
className="bg-transparent border-transparent focus:bg-white/5 font-bold text-sm h-8 w-48 shadow-none text-white"
/>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={() => setIsDebugOpen(true)}>
<Play className="mr-1.5 h-3.5 w-3.5" />
</Button>
<Button variant="secondary" size="sm" onClick={handleSave}>
<Save className="mr-1.5 h-3.5 w-3.5" />
</Button>
<Button size="sm" onClick={() => alert('发布成功!')}>
<Rocket className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</header>
{/* Canvas Area */}
<div
className="flex-1 relative bg-[radial-gradient(rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:24px_24px] overflow-hidden select-none cursor-crosshair"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
>
{/* Floating Controls */}
<div className="absolute top-4 left-4 z-10 flex flex-col items-start gap-2">
<Button
className="rounded-xl shadow-lg h-10 px-5 bg-primary text-primary-foreground font-semibold hover:bg-primary/90"
onClick={() => setIsAddMenuOpen(!isAddMenuOpen)}
>
<Plus className="mr-2 h-4 w-4" /> {isAddMenuOpen ? '取消' : '添加节点'}
</Button>
{isAddMenuOpen && (
<div className="bg-background/90 backdrop-blur-xl border border-white/10 rounded-xl p-1.5 w-48 shadow-2xl animate-in slide-in-from-top-2">
<button onClick={() => addNode('conversation')} className="flex items-center w-full p-2.5 hover:bg-primary/10 rounded-lg transition-colors text-sm text-left text-white">
<Bot className="w-4 h-4 mr-3 text-primary" /> <span></span>
</button>
<button onClick={() => addNode('tool')} className="flex items-center w-full p-2.5 hover:bg-purple-400/10 rounded-lg transition-colors text-sm text-left text-white">
<Wrench className="w-4 h-4 mr-3 text-purple-400" /> <span></span>
</button>
<button onClick={() => addNode('human')} className="flex items-center w-full p-2.5 hover:bg-orange-400/10 rounded-lg transition-colors text-sm text-left text-white">
<UserCheck className="w-4 h-4 mr-3 text-orange-400" /> <span></span>
</button>
<button onClick={() => addNode('end')} className="flex items-center w-full p-2.5 hover:bg-destructive/10 rounded-lg transition-colors text-sm text-left text-destructive">
<Ban className="w-4 h-4 mr-3" /> <span></span>
</button>
</div>
)}
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-[10px] text-muted-foreground font-mono">
<span>{Math.round(zoom * 100)}%</span>
<span className="opacity-30">|</span>
<span>POS: {Math.round(panOffset.x)}, {Math.round(panOffset.y)}</span>
</div>
</div>
{/* Scalable Container */}
<div
className="absolute inset-0"
style={{
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
transformOrigin: '0 0'
}}
>
{nodes.map(node => (
<div
key={node.name}
onMouseDown={(e) => handleNodeMouseDown(e, node.name)}
style={{ left: node.metadata.position.x, top: node.metadata.position.y }}
className={`absolute w-56 p-4 rounded-xl border bg-card/70 backdrop-blur-sm cursor-grab active:cursor-grabbing group transition-shadow ${selectedNodeName === node.name ? 'border-primary shadow-[0_0_30px_rgba(6,182,212,0.3)]' : 'border-white/10 hover:border-white/30'}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2 overflow-hidden">
<NodeIcon type={node.type} />
<span className="font-semibold text-[10px] truncate uppercase tracking-tight text-white">{node.name}</span>
</div>
{node.isStart && <Badge variant="default" className="scale-75 origin-right">START</Badge>}
</div>
<div className="text-[10px] text-muted-foreground line-clamp-3 italic opacity-60 leading-relaxed">
{node.prompt || (node.tool ? `Action: ${node.tool.type}` : '待配置逻辑...')}
</div>
{/* Port simulation */}
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-primary rounded-full border-2 border-background opacity-0 group-hover:opacity-100 cursor-crosshair transition-opacity shadow-[0_0_5px_rgba(6,182,212,0.5)]"></div>
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-muted rounded-full border-2 border-background opacity-0 group-hover:opacity-100 cursor-crosshair transition-opacity"></div>
</div>
))}
</div>
{/* Bottom Right: Minimap */}
<div className="absolute bottom-6 right-6 w-48 h-32 bg-black/60 border border-white/10 rounded-2xl shadow-2xl pointer-events-none overflow-hidden flex flex-col backdrop-blur-md">
<div className="p-2 border-b border-white/5 bg-white/5 text-[9px] text-muted-foreground font-bold tracking-[0.2em] text-center uppercase">Minimap</div>
<div className="flex-1 relative p-3 opacity-60">
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-full h-full">
{nodes.map((n, i) => {
// Simple scaling for minimap
const mx = (n.metadata.position.x / 10) % 100;
const my = (n.metadata.position.y / 10) % 100;
return (
<div
key={i}
className={`absolute w-3 h-2 rounded-sm ${n.type === 'conversation' ? 'bg-primary' : n.type === 'end' ? 'bg-destructive' : 'bg-white/40'}`}
style={{ left: `${20 + mx}%`, top: `${20 + my}%` }}
></div>
);
})}
</div>
</div>
{/* Viewport visualization */}
<div
className="absolute border border-primary/40 bg-primary/5 rounded shadow-sm"
style={{ width: '40%', height: '40%', left: '30%', top: '30%' }}
/>
</div>
</div>
{/* Navigator Guide */}
<div className="absolute bottom-4 left-4 text-[10px] text-muted-foreground/30 font-mono flex items-center gap-2">
<MousePointer2 className="w-3 h-3" /> |
</div>
</div>
{/* Right Settings Panel */}
{selectedNode && (
<div className="absolute right-0 top-14 bottom-0 w-80 border-l border-white/10 bg-background/95 backdrop-blur-2xl flex flex-col animate-in slide-in-from-right duration-300 z-20 shadow-[-10px_0_30px_rgba(0,0,0,0.5)]">
<div className="p-4 border-b border-white/5 flex items-center justify-between bg-white/5">
<h3 className="text-sm font-bold uppercase tracking-widest text-primary"></h3>
<Button variant="ghost" size="icon" onClick={() => setSelectedNodeName(null)} className="h-8 w-8">
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest"> (Node Name)</label>
<Input
value={selectedNode.name}
onChange={e => updateNodeData('name', e.target.value)}
className="h-8 text-xs font-mono text-white bg-white/10"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest"></label>
<Badge variant="outline" className="w-fit">{selectedNode.type.toUpperCase()}</Badge>
</div>
{selectedNode.type === 'conversation' && (
<>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">Prompt ()</label>
<textarea
className="w-full h-48 bg-white/5 border border-white/10 rounded-lg p-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 text-white placeholder:text-muted-foreground/30 leading-relaxed"
value={selectedNode.prompt || ''}
onChange={e => updateNodeData('prompt', e.target.value)}
placeholder="输入该节点的业务规则、语气要求和回复逻辑..."
/>
</div>
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground uppercase font-mono tracking-widest">First Message ()</label>
<Input
value={selectedNode.messagePlan?.firstMessage || ''}
onChange={e => updateNodeData('messagePlan', { ...selectedNode.messagePlan, firstMessage: e.target.value })}
className="h-8 text-xs text-white"
placeholder="进入该节点时 AI 主动发起的消息..."
/>
</div>
</>
)}
{selectedNode.type === 'end' && (
<div className="p-4 rounded-xl border border-destructive/20 bg-destructive/5 text-xs text-destructive/80 space-y-2">
<p className="font-bold"></p>
<p></p>
</div>
)}
<div className="pt-4 border-t border-white/5">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={selectedNode.isStart}
onChange={e => updateNodeData('isStart', e.target.checked)}
className="w-4 h-4 rounded accent-primary border-white/10 bg-white/5"
/>
<span className="text-[10px] text-muted-foreground group-hover:text-primary transition-colors uppercase font-mono tracking-widest"> (Start Node)</span>
</label>
</div>
</div>
</div>
)}
{/* Debug Side Drawer */}
<DebugDrawer
isOpen={isDebugOpen}
onClose={() => setIsDebugOpen(false)}
assistant={mockAssistants[0]}
/>
</div>
);
};
const NodeIcon = ({ type }: { type: WorkflowNode['type'] }) => {
switch (type) {
case 'conversation': return <Bot className="h-4 w-4 text-primary" />;
case 'human': return <UserCheck className="h-4 w-4 text-orange-400" />;
case 'tool': return <Wrench className="h-4 w-4 text-purple-400" />;
case 'end': return <Ban className="h-4 w-4 text-destructive" />;
default: return <MousePointer2 className="h-4 w-4" />;
}
};

232
pages/Workflows.tsx Normal file
View File

@@ -0,0 +1,232 @@
import React, { useState, useRef } from 'react';
import { Search, Plus, Upload, MoreHorizontal, Code, Edit2, Copy, Trash2, Calendar, CloudUpload, File as FileIcon, X, Layout, FilePlus } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Card } from '../components/UI';
import { mockWorkflows } from '../services/mockData';
import { useNavigate } from 'react-router-dom';
export const WorkflowsPage: React.FC = () => {
const navigate = useNavigate();
const [workflows, setWorkflows] = useState(mockWorkflows);
const [searchTerm, setSearchTerm] = useState('');
const [isUploadOpen, setIsUploadOpen] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const [newWfName, setNewWfName] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<'blank' | 'lead'>('blank');
const filteredWorkflows = workflows.filter(wf =>
wf.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleCreateWorkflow = () => {
if (!newWfName.trim()) {
alert('请输入工作流名称');
return;
}
setIsCreateOpen(false);
navigate(`/workflows/new?name=${encodeURIComponent(newWfName)}&template=${selectedTemplate}`);
};
const handleDeleteWorkflow = (id: string) => {
if (confirm('确定要删除这个工作流吗?')) {
setWorkflows(prev => prev.filter(w => w.id !== id));
setActiveMenu(null);
}
};
return (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
<div className="flex space-x-3">
<Button variant="outline" onClick={() => setIsUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" /> JSON
</Button>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<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 border-0 bg-white/5"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2 bg-white/5 rounded-md px-3 border border-white/10 group focus-within:border-primary/50 transition-colors">
<Calendar className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
<select className="bg-transparent text-sm h-9 focus:outline-none border-none text-foreground cursor-pointer [&>option]:bg-background text-white">
<option value="all"></option>
<option value="today"></option>
<option value="week"></option>
<option value="month"></option>
</select>
</div>
</div>
<div className="rounded-xl border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
<table className="w-full text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<tbody>
{filteredWorkflows.map(wf => (
<TableRow key={wf.id} className="group">
<TableCell className="font-medium">
<button
onClick={() => navigate(`/workflows/edit/${wf.id}`)}
className="hover:text-primary transition-colors cursor-pointer text-left font-semibold text-white"
>
{wf.name}
</button>
</TableCell>
<TableCell className="text-muted-foreground">{wf.nodeCount} </TableCell>
<TableCell className="text-muted-foreground">{wf.createdAt}</TableCell>
<TableCell className="text-muted-foreground">{wf.updatedAt}</TableCell>
<TableCell className="text-right relative">
<Button
variant="ghost"
size="icon"
onClick={() => setActiveMenu(activeMenu === wf.id ? null : wf.id)}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
{activeMenu === wf.id && (
<div className="absolute right-0 top-12 z-50 w-48 bg-background border border-white/10 rounded-lg shadow-xl py-1 animate-in zoom-in-95">
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-white" onClick={() => { alert('JSON copied!'); setActiveMenu(null); }}>
<Code className="w-3.5 h-3.5 mr-2 opacity-70" /> JSON
</button>
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-white" onClick={() => navigate(`/workflows/edit/${wf.id}`)}>
<Edit2 className="w-3.5 h-3.5 mr-2 opacity-70" />
</button>
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-white" onClick={() => setActiveMenu(null)}>
<Copy className="w-3.5 h-3.5 mr-2 opacity-70" />
</button>
<div className="h-px bg-white/10 my-1" />
<button className="flex items-center w-full px-4 py-2 text-xs hover:bg-white/5 text-left text-destructive" onClick={() => handleDeleteWorkflow(wf.id)}>
<Trash2 className="w-3.5 h-3.5 mr-2 opacity-70" />
</button>
</div>
)}
</TableCell>
</TableRow>
))}
{filteredWorkflows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
<UploadJsonModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
<Dialog
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
title="创建新工作流"
footer={
<>
<Button variant="ghost" onClick={() => setIsCreateOpen(false)}></Button>
<Button onClick={handleCreateWorkflow}></Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-white"></label>
<Input
value={newWfName}
onChange={e => setNewWfName(e.target.value)}
placeholder="例如: Lead Qualification Agent"
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white"></label>
<div className="grid grid-cols-2 gap-3">
<div
onClick={() => setSelectedTemplate('blank')}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center text-center space-y-2 ${selectedTemplate === 'blank' ? 'border-primary bg-primary/10' : 'border-white/5 bg-white/5 hover:bg-white/10'}`}
>
<FilePlus className={`w-8 h-8 ${selectedTemplate === 'blank' ? 'text-primary' : 'text-muted-foreground'}`} />
<div>
<div className="text-sm font-bold text-white"></div>
<div className="text-[10px] text-muted-foreground"></div>
</div>
</div>
<div
onClick={() => setSelectedTemplate('lead')}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all flex flex-col items-center text-center space-y-2 ${selectedTemplate === 'lead' ? 'border-primary bg-primary/10' : 'border-white/5 bg-white/5 hover:bg-white/10'}`}
>
<Layout className={`w-8 h-8 ${selectedTemplate === 'lead' ? 'text-primary' : 'text-muted-foreground'}`} />
<div>
<div className="text-sm font-bold text-white"></div>
<div className="text-[10px] text-muted-foreground"> Lead </div>
</div>
</div>
</div>
</div>
</div>
</Dialog>
</div>
);
};
const UploadJsonModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => {
const [dragActive, setDragActive] = useState(false);
const [file, setFile] = useState<File | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(e.type === "dragenter" || e.type === "dragover");
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files?.[0]) setFile(e.dataTransfer.files[0]);
};
return (
<Dialog
isOpen={isOpen} onClose={onClose} title="上传工作流 JSON"
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={() => { alert('Import Success!'); onClose(); }}></Button>
</>
}
>
<div
className={`relative flex flex-col items-center justify-center w-full h-48 rounded-lg border-2 border-dashed transition-all cursor-pointer ${dragActive ? "border-primary bg-primary/10" : "border-white/10 bg-white/5 hover:bg-white/10"}`}
onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
>
<input ref={inputRef} type="file" className="hidden" accept=".json" onChange={e => e.target.files?.[0] && setFile(e.target.files[0])} />
<CloudUpload className={`h-10 w-10 mb-3 ${dragActive ? 'text-primary' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground text-center">
{file ? <span className="text-primary font-medium">{file.name}</span> : <span className="text-white/70"><span className="font-semibold text-primary"></span> JSON </span>}
</p>
<p className="text-xs text-muted-foreground mt-1 text-white/40"> .json </p>
</div>
</Dialog>
);
};

View File

@@ -1,5 +1,13 @@
import { Assistant, CallLog, KnowledgeBase, Voice } from '../types'; import { Assistant, CallLog, KnowledgeBase, Voice, Workflow, AutoTestAssistant, TestType, TestMethod } from '../types';
export const mockVoices: Voice[] = [
{ id: 'v1', name: 'Xiaoyun', vendor: 'Ali', gender: 'Female', language: 'zh', description: 'Gentle and professional.' },
{ id: 'v2', name: 'Kevin', vendor: 'Volcano', gender: 'Male', language: 'en', description: 'Deep and authoritative.' },
{ id: 'v3', name: 'Abby', vendor: 'Minimax', gender: 'Female', language: 'en', description: 'Cheerful and lively.' },
{ id: 'v4', name: 'Guang', vendor: 'Ali', gender: 'Male', language: 'zh', description: 'Standard newscast style.' },
{ id: 'v5', name: 'Doubao', vendor: 'Volcano', gender: 'Female', language: 'zh', description: 'Cute and young.' },
];
export const mockAssistants: Assistant[] = [ export const mockAssistants: Assistant[] = [
{ {
@@ -10,9 +18,10 @@ export const mockAssistants: Assistant[] = [
prompt: 'You are a helpful customer service agent.', prompt: 'You are a helpful customer service agent.',
knowledgeBaseId: 'kb1', knowledgeBaseId: 'kb1',
language: 'en', language: 'en',
voice: 'alloy', voice: 'v3',
speed: 1.0, speed: 1.0,
hotwords: ['refund', 'order'], hotwords: ['refund', 'order'],
interruptionSensitivity: 500,
}, },
{ {
id: '2', id: '2',
@@ -22,9 +31,54 @@ export const mockAssistants: Assistant[] = [
prompt: 'You are an energetic sales representative.', prompt: 'You are an energetic sales representative.',
knowledgeBaseId: 'kb2', knowledgeBaseId: 'kb2',
language: 'zh', language: 'zh',
voice: 'echo', voice: 'v1',
speed: 1.1, speed: 1.1,
hotwords: ['price', 'discount'], hotwords: ['price', 'discount'],
interruptionSensitivity: 300,
},
];
export let mockWorkflows: Workflow[] = [
{
id: 'wf1',
name: 'Lead Qualification Agent',
nodeCount: 11,
createdAt: '2024-03-01 10:00',
updatedAt: '2024-03-05 14:30',
nodes: [
{
name: "introduction",
type: "conversation",
isStart: true,
metadata: { position: { x: 100, y: 100 } },
prompt: "You are Morgan from GrowthPartners. Start with: 'Hello, this is Morgan from GrowthPartners. We help businesses improve their operational efficiency through custom software solutions. Do you have a few minutes to chat about how we might be able to help your business?' Use a friendly, consultative tone.",
messagePlan: { firstMessage: "Hello, this is Morgan from GrowthPartners. Do you have a few minutes to chat about how we might be able to help your business?" }
},
{
name: "need_discovery",
type: "conversation",
metadata: { position: { x: 400, y: 150 } },
prompt: "Conduct need discovery by asking about: 1) Their business and industry, 2) Current systems/processes they use, 3) Biggest challenges with current approach...",
variableExtractionPlan: {
output: [
{ type: "string", title: "industry", description: "the user's industry or business type" },
{ type: "string", title: "company_size", description: "approximate number of employees" }
]
}
}
],
edges: [
{ from: "introduction", to: "need_discovery" }
]
},
{
id: 'wf2',
name: '售后退款流程',
nodeCount: 5,
createdAt: '2024-03-01 10:00',
updatedAt: '2024-03-05 14:30',
nodes: [],
edges: []
}, },
]; ];
@@ -77,16 +131,29 @@ export const mockCallLogs: CallLog[] = [
}, },
]; ];
export const mockVoices: Voice[] = [ export const mockAutoTestAssistants: AutoTestAssistant[] = [
{ id: 'v1', name: 'Xiaoyun', vendor: 'Ali', gender: 'Female', language: 'zh', description: 'Gentle and professional.' }, {
{ id: 'v2', name: 'Kevin', vendor: 'Volcano', gender: 'Male', language: 'en', description: 'Deep and authoritative.' }, id: 'at1',
{ id: 'v3', name: 'Abby', vendor: 'Minimax', gender: 'Female', language: 'en', description: 'Cheerful and lively.' }, name: '退款流程压力测试',
{ id: 'v4', name: 'Guang', vendor: 'Ali', gender: 'Male', language: 'zh', description: 'Standard newscast style.' }, type: TestType.FIXED,
{ id: 'v5', name: 'Doubao', vendor: 'Volcano', gender: 'Female', language: 'zh', description: 'Cute and young.' }, method: TestMethod.TEXT,
targetAssistantId: '1',
fixedWorkflowSteps: ['你好,我要退款', '订单号是123456', '谢谢'],
intelligentPrompt: '',
createdAt: '2024-03-10 09:00'
},
{
id: 'at2',
name: '愤怒的客户模拟',
type: TestType.INTELLIGENT,
method: TestMethod.AUDIO,
targetAssistantId: '1',
fixedWorkflowSteps: [],
intelligentPrompt: '你是一个非常愤怒的客户,因为订单延迟了一周。你需要表达你的不满,并要求立即解决。你的语气必须很冲,不接受简单的道歉。',
createdAt: '2024-03-11 14:20'
}
]; ];
// --- Dashboard Mock Data Helpers ---
export interface DashboardStats { export interface DashboardStats {
totalCalls: number; totalCalls: number;
answerRate: number; answerRate: number;
@@ -96,14 +163,11 @@ export interface DashboardStats {
} }
export const getDashboardStats = (timeRange: 'week' | 'month' | 'year', assistantId: string): DashboardStats => { export const getDashboardStats = (timeRange: 'week' | 'month' | 'year', assistantId: string): DashboardStats => {
// Simulate data variation based on inputs
const multiplier = assistantId === 'all' ? 1 : (assistantId === '1' ? 0.6 : 0.4); const multiplier = assistantId === 'all' ? 1 : (assistantId === '1' ? 0.6 : 0.4);
const rangeMultiplier = timeRange === 'week' ? 1 : (timeRange === 'month' ? 4 : 52); const rangeMultiplier = timeRange === 'week' ? 1 : (timeRange === 'month' ? 4 : 52);
const baseCalls = Math.floor(100 * rangeMultiplier * multiplier); const baseCalls = Math.floor(100 * rangeMultiplier * multiplier);
const transfers = Math.floor(baseCalls * 0.15); // 15% transfer rate const transfers = Math.floor(baseCalls * 0.15);
// Generate Trend Data
let points = 7; let points = 7;
if (timeRange === 'month') points = 30; if (timeRange === 'month') points = 30;
if (timeRange === 'year') points = 12; if (timeRange === 'year') points = 12;
@@ -122,7 +186,7 @@ export const getDashboardStats = (timeRange: 'week' | 'month' | 'year', assistan
return { return {
totalCalls: baseCalls, totalCalls: baseCalls,
answerRate: 85 + Math.floor(Math.random() * 10), // 85-95% answerRate: 85 + Math.floor(Math.random() * 10),
avgDuration: `${Math.floor(2 + Math.random() * 3)}m ${Math.floor(Math.random() * 60)}s`, avgDuration: `${Math.floor(2 + Math.random() * 3)}m ${Math.floor(Math.random() * 60)}s`,
humanTransferCount: transfers, humanTransferCount: transfers,
trend trend

100
types.ts
View File

@@ -7,9 +7,23 @@ export interface Assistant {
prompt: string; prompt: string;
knowledgeBaseId: string; knowledgeBaseId: string;
language: 'zh' | 'en'; language: 'zh' | 'en';
voice: string; voice: string; // This will now store the ID of the voice from Voice Library
speed: number; speed: number;
hotwords: string[]; hotwords: string[];
tools?: string[]; // IDs of enabled tools
interruptionSensitivity?: number; // In ms
configMode?: 'platform' | 'dify' | 'fastgpt' | 'none';
apiUrl?: string;
apiKey?: string;
}
export interface Voice {
id: string;
name: string;
vendor: string;
gender: string;
language: string;
description: string;
} }
export interface KnowledgeBase { export interface KnowledgeBase {
@@ -36,24 +50,80 @@ export interface CallLog {
agentName: string; agentName: string;
} }
export interface UserProfile { export interface Workflow {
username: string;
avatarUrl: string;
email: string;
language: 'zh' | 'en';
}
export interface Voice {
id: string; id: string;
name: string; name: string;
vendor: 'Ali' | 'Volcano' | 'Minimax'; nodeCount: number;
gender: 'Male' | 'Female'; createdAt: string;
language: 'zh' | 'en'; updatedAt: string;
description?: string; nodes: WorkflowNode[];
previewUrl?: string; // Mock url edges: WorkflowEdge[];
globalPrompt?: string;
}
export interface WorkflowNode {
name: string;
type: 'conversation' | 'tool' | 'human' | 'end';
isStart?: boolean;
metadata: {
position: { x: number; y: number };
};
prompt?: string;
messagePlan?: {
firstMessage?: string;
};
variableExtractionPlan?: {
output: Array<{
type: string;
title: string;
description: string;
}>;
};
tool?: {
type: string;
function: {
name: string;
parameters: any;
};
destinations?: any[];
messages?: any[];
};
globalNodePlan?: {
enabled: boolean;
enterCondition: string;
};
}
export interface WorkflowEdge {
from: string;
to: string;
label?: string;
} }
export enum TabValue { export enum TabValue {
GLOBAL = 'global', GLOBAL = 'global',
VOICE = 'voice' VOICE = 'voice',
TOOLS = 'tools',
LINK = 'link'
}
export enum TestType {
FIXED = 'fixed',
INTELLIGENT = 'intelligent'
}
export enum TestMethod {
TEXT = 'text',
AUDIO = 'audio'
}
export interface AutoTestAssistant {
id: string;
name: string;
type: TestType;
method: TestMethod;
targetAssistantId: string;
fixedWorkflowSteps: string[];
intelligentPrompt: string;
createdAt: string;
} }