Update workflow
This commit is contained in:
17
App.tsx
17
App.tsx
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { HashRouter as Router, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom';
|
import React from 'react';
|
||||||
import { Bot, Phone, Book, User, LayoutDashboard, Cpu, Mic2, Video } from 'lucide-react';
|
import { HashRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Bot, Phone, Book, User, LayoutDashboard, Mic2, Video, GitBranch } from 'lucide-react';
|
||||||
|
|
||||||
import { AssistantsPage } from './pages/Assistants';
|
import { AssistantsPage } from './pages/Assistants';
|
||||||
import { KnowledgeBasePage } from './pages/KnowledgeBase';
|
import { KnowledgeBasePage } from './pages/KnowledgeBase';
|
||||||
@@ -8,6 +9,8 @@ import { CallLogsPage } from './pages/CallLogs';
|
|||||||
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';
|
||||||
|
|
||||||
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 }> = ({ to, icon, label, active }) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -28,6 +31,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
{ 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: '/call-logs', label: '视频通话记录', icon: <Phone 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: '/profile', label: '个人中心', icon: <User className="h-5 w-5" /> },
|
{ path: '/profile', label: '个人中心', icon: <User className="h-5 w-5" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -36,11 +40,9 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
{/* Sidebar with Glass effect */}
|
{/* Sidebar with Glass effect */}
|
||||||
<aside className="w-64 border-r border-border/40 bg-card/30 backdrop-blur-md hidden md:flex flex-col">
|
<aside className="w-64 border-r border-border/40 bg-card/30 backdrop-blur-md hidden md:flex flex-col">
|
||||||
<div className="p-6 flex items-center space-x-3 border-b border-border/40 overflow-hidden">
|
<div className="p-6 flex items-center space-x-3 border-b border-border/40 overflow-hidden">
|
||||||
{/* Cool Logo */}
|
|
||||||
<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 */}
|
|
||||||
<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">
|
||||||
AI VideoAssistant
|
AI VideoAssistant
|
||||||
</span>
|
</span>
|
||||||
@@ -69,7 +71,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-lg whitespace-nowrap">AI VideoAssistant</span>
|
<span className="font-bold text-lg whitespace-nowrap">AI VideoAssistant</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 overflow-auto p-6 md:p-8">
|
<div className="flex-1 overflow-auto p-4 md:p-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -87,6 +89,9 @@ const App: React.FC = () => {
|
|||||||
<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="/call-logs" element={<CallLogsPage />} />
|
||||||
|
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||||
|
<Route path="/workflows/new" element={<WorkflowEditorPage />} />
|
||||||
|
<Route path="/workflows/edit/:id" element={<WorkflowEditorPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle } from 'lucide-react';
|
import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle, PhoneCall } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
||||||
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
|
import { mockAssistants, mockKnowledgeBases } from '../services/mockData';
|
||||||
import { Assistant, TabValue } from '../types';
|
import { Assistant, TabValue } from '../types';
|
||||||
@@ -385,12 +385,12 @@ const BotIcon = ({className}: {className?: string}) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// --- Debug Drawer Component ---
|
// --- Debug Drawer Component ---
|
||||||
|
export const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: Assistant }> = ({ isOpen, onClose, assistant }) => {
|
||||||
const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: Assistant }> = ({ isOpen, onClose, assistant }) => {
|
|
||||||
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
|
const [mode, setMode] = useState<'text' | 'voice' | 'video'>('text');
|
||||||
const [messages, setMessages] = useState<{role: 'user' | 'model', text: string}[]>([]);
|
const [messages, setMessages] = useState<{role: 'user' | 'model', text: string}[]>([]);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle');
|
||||||
|
|
||||||
// Media State
|
// Media State
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
@@ -400,19 +400,24 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||||
const [selectedCamera, setSelectedCamera] = useState<string>('');
|
const [selectedCamera, setSelectedCamera] = useState<string>('');
|
||||||
const [selectedMic, setSelectedMic] = useState<string>('');
|
const [selectedMic, setSelectedMic] = useState<string>('');
|
||||||
const [isSwapped, setIsSwapped] = useState(false); // False: AI is Big, Local is Small. True: Local is Big, AI is Small.
|
const [isSwapped, setIsSwapped] = useState(false);
|
||||||
|
|
||||||
// Initialize with opener
|
// Initialize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
if (mode === 'text') {
|
||||||
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||||
} else {
|
} else {
|
||||||
// Reset and stop media when closed
|
setMessages([]);
|
||||||
|
setCallStatus('idle');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
setMode('text');
|
setMode('text');
|
||||||
stopMedia();
|
stopMedia();
|
||||||
setIsSwapped(false);
|
setIsSwapped(false);
|
||||||
|
setCallStatus('idle');
|
||||||
}
|
}
|
||||||
}, [isOpen, assistant]);
|
}, [isOpen, assistant, mode]);
|
||||||
|
|
||||||
// Auto-scroll logic
|
// Auto-scroll logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -429,13 +434,10 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||||
const dev = await navigator.mediaDevices.enumerateDevices();
|
const dev = await navigator.mediaDevices.enumerateDevices();
|
||||||
setDevices(dev);
|
setDevices(dev);
|
||||||
|
|
||||||
const cams = dev.filter(d => d.kind === 'videoinput');
|
const cams = dev.filter(d => d.kind === 'videoinput');
|
||||||
const mics = dev.filter(d => d.kind === 'audioinput');
|
const mics = dev.filter(d => d.kind === 'audioinput');
|
||||||
|
|
||||||
if (cams.length > 0 && !selectedCamera) setSelectedCamera(cams[0].deviceId);
|
if (cams.length > 0 && !selectedCamera) setSelectedCamera(cams[0].deviceId);
|
||||||
if (mics.length > 0 && !selectedMic) setSelectedMic(mics[0].deviceId);
|
if (mics.length > 0 && !selectedMic) setSelectedMic(mics[0].deviceId);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error enumerating devices", e);
|
console.error("Error enumerating devices", e);
|
||||||
}
|
}
|
||||||
@@ -444,7 +446,6 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
}
|
}
|
||||||
}, [isOpen, mode]);
|
}, [isOpen, mode]);
|
||||||
|
|
||||||
// Handle Video/Media stream
|
|
||||||
const stopMedia = () => {
|
const stopMedia = () => {
|
||||||
if (streamRef.current) {
|
if (streamRef.current) {
|
||||||
streamRef.current.getTracks().forEach(track => track.stop());
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
@@ -454,7 +455,7 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStream = async () => {
|
const handleStream = async () => {
|
||||||
if (isOpen && mode === 'video') {
|
if (isOpen && mode === 'video' && callStatus === 'active') {
|
||||||
try {
|
try {
|
||||||
stopMedia();
|
stopMedia();
|
||||||
const constraints = {
|
const constraints = {
|
||||||
@@ -469,14 +470,28 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to access camera/mic:", err);
|
console.error("Failed to access camera/mic:", err);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (callStatus !== 'active') {
|
||||||
stopMedia();
|
stopMedia();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleStream();
|
handleStream();
|
||||||
return () => stopMedia();
|
return () => stopMedia();
|
||||||
}, [mode, isOpen, selectedCamera, selectedMic]);
|
}, [mode, isOpen, selectedCamera, selectedMic, callStatus]);
|
||||||
|
|
||||||
|
const handleCall = () => {
|
||||||
|
setCallStatus('calling');
|
||||||
|
setTimeout(() => {
|
||||||
|
setCallStatus('active');
|
||||||
|
setMessages([{ role: 'model', text: assistant.opener || "Hello!" }]);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHangup = () => {
|
||||||
|
stopMedia();
|
||||||
|
setCallStatus('idle');
|
||||||
|
setMessages([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!inputText.trim()) return;
|
if (!inputText.trim()) return;
|
||||||
@@ -509,12 +524,6 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
stopMedia();
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reusable Messages List Component
|
|
||||||
const TranscriptionLog = () => (
|
const TranscriptionLog = () => (
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 p-2 border border-white/5 rounded-md bg-black/20 min-h-0">
|
||||||
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4">暂无转写记录</div>}
|
{messages.length === 0 && <div className="text-center text-muted-foreground text-xs py-4">暂无转写记录</div>}
|
||||||
@@ -530,171 +539,113 @@ const DebugDrawer: React.FC<{ isOpen: boolean; onClose: () => void; assistant: A
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to render the Local Video Element
|
|
||||||
const renderLocalVideo = (isSmall: boolean) => (
|
const renderLocalVideo = (isSmall: boolean) => (
|
||||||
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
<div className={`relative w-full h-full bg-black overflow-hidden ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||||
<video
|
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover transform scale-x-[-1]" />
|
||||||
ref={videoRef}
|
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">Me</div>
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
className="w-full h-full object-cover transform scale-x-[-1]"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-2 left-2 bg-black/50 px-2 py-0.5 rounded text-[10px] text-white/80">
|
|
||||||
Me
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper to render the "Remote" AI Video Element (Simulated)
|
|
||||||
const renderRemoteVideo = (isSmall: boolean) => (
|
const renderRemoteVideo = (isSmall: boolean) => (
|
||||||
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
<div className={`relative w-full h-full bg-slate-900 overflow-hidden flex flex-col items-center justify-center ${isSmall ? 'rounded-lg border border-white/20 shadow-lg' : ''}`}>
|
||||||
<div className="relative flex items-center justify-center">
|
<div className="relative flex items-center justify-center">
|
||||||
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
|
<div className={`rounded-full bg-primary/20 animate-pulse ${isSmall ? 'w-16 h-16' : 'w-32 h-32'}`}></div>
|
||||||
<div className={`absolute rounded-full bg-primary/40 animate-ping ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}></div>
|
|
||||||
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
|
<div className={`absolute rounded-full bg-primary flex items-center justify-center shadow-[0_0_30px_hsl(var(--primary))] ${isSmall ? 'w-12 h-12' : 'w-24 h-24'}`}>
|
||||||
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
|
<Video className={`${isSmall ? 'w-6 h-6' : 'w-10 h-10'} text-primary-foreground`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isSmall && (
|
{!isSmall && <div className="mt-4 font-mono text-primary animate-pulse text-sm">{assistant.name}</div>}
|
||||||
<div className="mt-4 font-mono text-primary animate-pulse text-sm">
|
|
||||||
{assistant.name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer isOpen={isOpen} onClose={handleClose} title={`调试: ${assistant.name}`}>
|
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`}>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Mode Toggle */}
|
|
||||||
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
|
<div className="flex justify-center mb-4 bg-white/5 p-1 rounded-lg shrink-0">
|
||||||
<button
|
{(['text', 'voice', 'video'] as const).map(m => (
|
||||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'text' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
<button key={m} className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === m ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`} onClick={() => setMode(m)}>
|
||||||
onClick={() => setMode('text')}
|
{m === 'text' && <MessageSquare className="inline w-4 h-4 mr-1"/>}
|
||||||
>
|
{m === 'voice' && <Mic className="inline w-4 h-4 mr-1"/>}
|
||||||
<MessageSquare className="inline w-4 h-4 mr-1"/> 文本
|
{m === 'video' && <Video className="inline w-4 h-4 mr-1"/>}
|
||||||
</button>
|
{m === 'text' ? '文本' : m === 'voice' ? '语音' : '视频'}
|
||||||
<button
|
|
||||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'voice' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
|
||||||
onClick={() => setMode('voice')}
|
|
||||||
>
|
|
||||||
<Mic className="inline w-4 h-4 mr-1"/> 语音
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`flex-1 py-1 text-sm rounded-md transition-all ${mode === 'video' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground hover:bg-white/5'}`}
|
|
||||||
onClick={() => setMode('video')}
|
|
||||||
>
|
|
||||||
<Video className="inline w-4 h-4 mr-1"/> 视频
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Area */}
|
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4">
|
||||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0 mb-4 gap-2">
|
{mode === 'text' ? (
|
||||||
{mode === 'text' && <TranscriptionLog />}
|
<TranscriptionLog />
|
||||||
|
) : callStatus === 'idle' ? (
|
||||||
{mode === 'voice' && (
|
<div className="flex-1 flex flex-col items-center justify-center space-y-6 border border-white/5 rounded-xl bg-black/20 animate-in fade-in zoom-in-95">
|
||||||
<div className="flex flex-col h-full">
|
<div className="relative">
|
||||||
{/* Visualizer Area */}
|
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
|
||||||
|
<div className="relative h-24 w-24 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
|
||||||
|
{mode === 'voice' ? <Mic className="h-10 w-10 text-muted-foreground" /> : <Video className="h-10 w-10 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-1">准备就绪</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">点击下方按钮开启人机交互测试</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleCall} className="w-48 h-12 rounded-full bg-green-500 hover:bg-green-600 shadow-[0_0_20px_rgba(34,197,94,0.4)] text-base font-bold">
|
||||||
|
<PhoneCall className="mr-2 h-5 w-5" /> 发起呼叫
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : callStatus === 'calling' ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center space-y-6">
|
||||||
|
<div className="h-24 w-24 rounded-full bg-primary/20 flex items-center justify-center animate-bounce">
|
||||||
|
<PhoneCall className="h-10 w-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-primary font-mono text-sm tracking-widest animate-pulse">CALLING...</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">正在连接 AI 服务</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleHangup} variant="destructive" className="rounded-full h-10 px-8">取消</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 space-y-2">
|
||||||
|
{mode === 'voice' ? (
|
||||||
|
<div className="flex flex-col h-full animate-in fade-in">
|
||||||
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
|
<div className="h-1/3 min-h-[150px] shrink-0 border border-white/5 rounded-md bg-black/20 flex flex-col items-center justify-center text-muted-foreground space-y-4 mb-2 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/20 pointer-events-none"></div>
|
|
||||||
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
|
<div className="h-24 w-24 rounded-full bg-primary/10 flex items-center justify-center animate-pulse relative z-10">
|
||||||
<Mic className="h-10 w-10 text-primary" />
|
<Mic className="h-10 w-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm relative z-10">通话中...</p>
|
<p className="text-sm relative z-10">通话中...</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Transcript */}
|
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1 uppercase tracking-tight">转写日志</h4>
|
||||||
<h4 className="text-xs font-medium text-muted-foreground px-1">实时转写 / Live Transcription</h4>
|
|
||||||
<TranscriptionLog />
|
<TranscriptionLog />
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<div className="flex flex-col h-full space-y-2 animate-in fade-in">
|
||||||
{mode === 'video' && (
|
|
||||||
<div className="flex flex-col h-full space-y-2">
|
|
||||||
{/* Video Area (Top) */}
|
|
||||||
<div className="h-3/5 shrink-0 flex flex-col gap-2">
|
<div className="h-3/5 shrink-0 flex flex-col gap-2">
|
||||||
{/* Device Settings Bar */}
|
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
<div className="flex-1">
|
<select className="flex-1 text-xs bg-white/5 border border-white/10 rounded px-2 py-1 text-foreground" value={selectedCamera} onChange={e => setSelectedCamera(e.target.value)}>
|
||||||
<select
|
{devices.filter(d => d.kind === 'videoinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Camera'}</option>)}
|
||||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
</select>
|
||||||
value={selectedCamera}
|
<select className="flex-1 text-xs bg-white/5 border border-white/10 rounded px-2 py-1 text-foreground" value={selectedMic} onChange={e => setSelectedMic(e.target.value)}>
|
||||||
onChange={(e) => setSelectedCamera(e.target.value)}
|
{devices.filter(d => d.kind === 'audioinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Mic'}</option>)}
|
||||||
>
|
|
||||||
{devices.filter(d => d.kind === 'videoinput').map(d => (
|
|
||||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Camera...`}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<select
|
|
||||||
className="w-full text-xs bg-white/5 border border-white/10 rounded px-2 py-1.5 focus:outline-none focus:border-primary/50 text-foreground"
|
|
||||||
value={selectedMic}
|
|
||||||
onChange={(e) => setSelectedMic(e.target.value)}
|
|
||||||
>
|
|
||||||
{devices.filter(d => d.kind === 'audioinput').map(d => (
|
|
||||||
<option key={d.deviceId} value={d.deviceId}>{d.label || `Mic...`}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video Container (PiP) */}
|
|
||||||
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
|
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
|
||||||
{/* Main Window */}
|
<div className="absolute inset-0">{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}</div>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute bottom-2 right-2 w-24 h-36 z-10">{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}</div>
|
||||||
{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}
|
<button className="absolute top-2 right-2 z-20 h-8 w-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center text-white border border-white/10 hover:bg-primary/80" onClick={() => setIsSwapped(!isSwapped)}><ArrowLeftRight className="h-3.5 w-3.5" /></button>
|
||||||
</div>
|
|
||||||
{/* Small Window */}
|
|
||||||
<div className="absolute bottom-2 right-2 w-24 h-36 z-10 transition-all duration-300">
|
|
||||||
{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}
|
|
||||||
</div>
|
|
||||||
{/* Swap Button */}
|
|
||||||
<div className="absolute top-2 right-2 z-20">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full h-7 w-7 bg-black/50 hover:bg-primary/80 backdrop-blur text-white border border-white/20"
|
|
||||||
onClick={() => setIsSwapped(!isSwapped)}
|
|
||||||
title="切换窗口"
|
|
||||||
>
|
|
||||||
<ArrowLeftRight className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transcript Area (Bottom) */}
|
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
|
||||||
<h4 className="text-xs font-medium text-muted-foreground px-1 mb-1">实时转写 / Live Transcription</h4>
|
|
||||||
<TranscriptionLog />
|
<TranscriptionLog />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<Button variant="destructive" size="sm" className="w-full h-10 font-bold" onClick={handleHangup}>
|
||||||
|
<PhoneOff className="mr-2 h-4 w-4" /> 挂断通话
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions - Unified Input */}
|
|
||||||
<div className="shrink-0 space-y-2">
|
<div className="shrink-0 space-y-2">
|
||||||
{(mode === 'voice' || mode === 'video') && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="destructive" size="sm" className="w-full shadow-red-500/20 shadow-lg" onClick={handleClose}>
|
|
||||||
<PhoneOff className="mr-2 h-3 w-3" /> 挂断通话
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Input
|
<Input value={inputText} onChange={e => setInputText(e.target.value)} placeholder={mode === 'text' ? "输入消息..." : "输入文本模拟交互..."} onKeyDown={e => e.key === 'Enter' && handleSend()} disabled={isLoading || (mode !== 'text' && callStatus !== 'active')} className="flex-1" />
|
||||||
value={inputText}
|
<Button size="icon" onClick={handleSend} disabled={isLoading || (mode !== 'text' && callStatus !== 'active')}><Send className="h-4 w-4" /></Button>
|
||||||
onChange={(e) => setInputText(e.target.value)}
|
|
||||||
placeholder={mode === 'text' ? "输入消息..." : "输入文字模拟语音/Input to simulate speech..."}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button size="icon" onClick={handleSend} disabled={isLoading}>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 } 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,100 +13,155 @@ 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 justify-center 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-5xl mx-auto space-y-4 px-2">
|
||||||
<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">
|
||||||
<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">
|
||||||
|
<Mail className="w-3 h-3 mr-1.5 opacity-70" /> 联系方式
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Welcome Row */}
|
||||||
|
<div className="flex flex-col space-y-0.5 text-center md:text-left pt-1">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 3. Section Header: Title + Filters aligned perfectly */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between border-b border-white/5 pb-3 pt-1 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>
|
||||||
|
|
||||||
|
{/* Filters Group (Aligned Right) */}
|
||||||
|
<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">
|
||||||
|
{/* Assistant Selector */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Time Range Selector */}
|
||||||
|
<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-[250px] w-full">
|
||||||
<SimpleAreaChart data={stats.trend} />
|
<SimpleAreaChart data={stats.trend} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 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>
|
||||||
);
|
);
|
||||||
@@ -131,9 +187,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 +204,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 +213,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 +223,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>
|
||||||
|
|||||||
402
pages/WorkflowEditor.tsx
Normal file
402
pages/WorkflowEditor.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
235
pages/Workflows.tsx
Normal file
235
pages/Workflows.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// New Workflow State
|
||||||
|
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 to the editor with the template name and type as query params
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<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>{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" 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" 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" 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)} />
|
||||||
|
|
||||||
|
{/* Create Workflow Modal */}
|
||||||
|
<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><span className="font-semibold text-primary">点击上传</span> 或将 JSON 文件拖拽到此处</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">仅支持 .json 格式的工作流配置文件</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Assistant, CallLog, KnowledgeBase, Voice } from '../types';
|
import { Assistant, CallLog, KnowledgeBase, Voice, Workflow } from '../types';
|
||||||
|
|
||||||
export const mockAssistants: Assistant[] = [
|
export const mockAssistants: Assistant[] = [
|
||||||
{
|
{
|
||||||
@@ -28,6 +28,50 @@ export const mockAssistants: Assistant[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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: []
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const mockKnowledgeBases: KnowledgeBase[] = [
|
export const mockKnowledgeBases: KnowledgeBase[] = [
|
||||||
{
|
{
|
||||||
id: 'kb1',
|
id: 'kb1',
|
||||||
@@ -85,8 +129,6 @@ export const mockVoices: Voice[] = [
|
|||||||
{ id: 'v5', name: 'Doubao', vendor: 'Volcano', gender: 'Female', language: 'zh', description: 'Cute and young.' },
|
{ id: 'v5', name: 'Doubao', vendor: 'Volcano', gender: 'Female', language: 'zh', description: 'Cute and young.' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Dashboard Mock Data Helpers ---
|
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
totalCalls: number;
|
totalCalls: number;
|
||||||
answerRate: number;
|
answerRate: number;
|
||||||
@@ -96,14 +138,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 +161,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
|
||||||
|
|||||||
68
types.ts
68
types.ts
@@ -12,6 +12,15 @@ export interface Assistant {
|
|||||||
hotwords: string[];
|
hotwords: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Voice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vendor: string;
|
||||||
|
gender: string;
|
||||||
|
language: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KnowledgeBase {
|
export interface KnowledgeBase {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,21 +45,54 @@ 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user