diff --git a/web/README.md b/web/README.md index a64d1ba..e900716 100644 --- a/web/README.md +++ b/web/README.md @@ -16,5 +16,6 @@ View your app in AI Studio: https://ai.studio/apps/drive/1Cg9WH_2bOQEHVVj-lSN5l2 1. Install dependencies: `npm install` 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key -3. Run the app: +3. Optional: set `VITE_API_BASE_URL` (for backend API, default `http://localhost:8000/api`) +4. Run the app: `npm run dev` diff --git a/web/package-lock.json b/web/package-lock.json index f0f5943..a43031f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ai视频助手", + "name": "ai视频助手2", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ai视频助手", + "name": "ai视频助手2", "version": "0.0.0", "dependencies": { "@google/genai": "^1.39.0", diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index a7cd5ed..caed9fa 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -2,9 +2,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { Plus, Search, Play, Copy, Trash2, Edit2, Mic, MessageSquare, Save, Video, PhoneOff, Camera, ArrowLeftRight, Send, Phone, MoreHorizontal, Rocket, AlertTriangle, PhoneCall, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Wrench, Globe, Terminal, X, ClipboardCheck, Sparkles, Volume2, Timer, ChevronDown, Link as LinkIcon, Database, Server, Zap, ExternalLink, Key, BrainCircuit, Ear, Book, Filter } from 'lucide-react'; import { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI'; -import { mockAssistants, mockKnowledgeBases, mockVoices, mockLLMModels, mockASRModels } from '../services/mockData'; -import { Assistant, TabValue } from '../types'; +import { mockLLMModels, mockASRModels } from '../services/mockData'; +import { Assistant, KnowledgeBase, TabValue, Voice } from '../types'; import { GoogleGenAI } from "@google/genai"; +import { createAssistant, deleteAssistant, fetchAssistants, fetchKnowledgeBases, fetchVoices, updateAssistant as updateAssistantApi } from '../services/backendApi'; interface ToolItem { id: string; @@ -16,7 +17,9 @@ interface ToolItem { } export const AssistantsPage: React.FC = () => { - const [assistants, setAssistants] = useState(mockAssistants); + const [assistants, setAssistants] = useState([]); + const [voices, setVoices] = useState([]); + const [knowledgeBases, setKnowledgeBases] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [selectedId, setSelectedId] = useState(null); const [activeTab, setActiveTab] = useState(TabValue.GLOBAL); @@ -40,6 +43,7 @@ export const AssistantsPage: React.FC = () => { const [deleteId, setDeleteId] = useState(null); const [copySuccess, setCopySuccess] = useState(false); const [saveLoading, setSaveLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const selectedAssistant = assistants.find(a => a.id === selectedId) || null; @@ -47,39 +51,69 @@ export const AssistantsPage: React.FC = () => { a.name.toLowerCase().includes(searchTerm.toLowerCase()) ); - const handleCreate = () => { - const newId = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); - const newAssistant: Assistant = { - id: newId, + useEffect(() => { + const loadInitialData = async () => { + setIsLoading(true); + try { + const [assistantList, voiceList, kbList] = await Promise.all([ + fetchAssistants(), + fetchVoices(), + fetchKnowledgeBases(), + ]); + setAssistants(assistantList); + setVoices(voiceList); + setKnowledgeBases(kbList); + if (assistantList.length > 0) { + setSelectedId(assistantList[0].id); + } + } catch (error) { + console.error(error); + alert('加载助手数据失败,请检查后端服务是否启动。'); + } finally { + setIsLoading(false); + } + }; + + loadInitialData(); + }, []); + + const handleCreate = async () => { + const newAssistantPayload: Partial = { name: 'New Assistant', - callCount: 0, opener: '', prompt: '', knowledgeBaseId: '', language: 'zh', - voice: mockVoices[0]?.id || '', + voice: voices[0]?.id || '', speed: 1, hotwords: [], tools: [], interruptionSensitivity: 500, configMode: 'platform', - llmModelId: '', - asrModelId: '', - embeddingModelId: '', - rerankModelId: '', }; - setAssistants([...assistants, newAssistant]); - setSelectedId(newId); - setActiveTab(TabValue.GLOBAL); + try { + const created = await createAssistant(newAssistantPayload); + setAssistants((prev) => [created, ...prev]); + setSelectedId(created.id); + setActiveTab(TabValue.GLOBAL); + } catch (error) { + console.error(error); + alert('创建助手失败。'); + } }; - const handleSave = () => { + const handleSave = async () => { + if (!selectedAssistant) return; setSaveLoading(true); - // Simulate API call - setTimeout(() => { + try { + const updated = await updateAssistantApi(selectedAssistant.id, selectedAssistant); + setAssistants((prev) => prev.map((item) => (item.id === updated.id ? { ...item, ...updated } : item))); + } catch (error) { + console.error(error); + alert('保存失败,请稍后重试。'); + } finally { setSaveLoading(false); - // In a real app, logic to persist selectedAssistant would go here - }, 800); + } }; const handleCopyId = (id: string, text?: string) => { @@ -88,11 +122,18 @@ export const AssistantsPage: React.FC = () => { setTimeout(() => setCopySuccess(false), 2000); }; - const handleCopy = (e: React.MouseEvent, assistant: Assistant) => { + const handleCopy = async (e: React.MouseEvent, assistant: Assistant) => { e.stopPropagation(); - const newId = Math.floor(Math.random() * 1000000).toString().padStart(6, '0'); - const newAssistant = { ...assistant, id: newId, name: `${assistant.name} (Copy)` }; - setAssistants([...assistants, newAssistant]); + try { + const copied = await createAssistant({ + ...assistant, + name: `${assistant.name} (Copy)`, + }); + setAssistants((prev) => [copied, ...prev]); + } catch (error) { + console.error(error); + alert('复制助手失败。'); + } }; const handleDeleteClick = (e: React.MouseEvent, id: string) => { @@ -100,11 +141,17 @@ export const AssistantsPage: React.FC = () => { setDeleteId(id); }; - const confirmDelete = () => { + const confirmDelete = async () => { if (deleteId) { - setAssistants(prev => prev.filter(a => a.id !== deleteId)); - if (selectedId === deleteId) setSelectedId(null); - setDeleteId(null); + try { + await deleteAssistant(deleteId); + setAssistants(prev => prev.filter(a => a.id !== deleteId)); + if (selectedId === deleteId) setSelectedId(null); + setDeleteId(null); + } catch (error) { + console.error(error); + alert('删除失败,请稍后重试。'); + } } }; @@ -223,7 +270,7 @@ export const AssistantsPage: React.FC = () => {
- {filteredAssistants.map(assistant => ( + {!isLoading && filteredAssistants.map(assistant => (
setSelectedId(assistant.id)} @@ -268,11 +315,16 @@ export const AssistantsPage: React.FC = () => {
))} - {filteredAssistants.length === 0 && ( + {!isLoading && filteredAssistants.length === 0 && (
未找到小助手
)} + {isLoading && ( +
+ 加载中... +
+ )} @@ -545,7 +597,7 @@ export const AssistantsPage: React.FC = () => { onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)} > - {mockKnowledgeBases.map(kb => ( + {knowledgeBases.map(kb => ( ))} @@ -593,7 +645,7 @@ export const AssistantsPage: React.FC = () => { onChange={(e) => updateAssistant('voice', e.target.value)} > - {mockVoices.map(voice => ( + {voices.map(voice => ( diff --git a/web/pages/AutoTest.tsx b/web/pages/AutoTest.tsx index 3dfd694..71af013 100644 --- a/web/pages/AutoTest.tsx +++ b/web/pages/AutoTest.tsx @@ -1,18 +1,32 @@ -import React, { useState } from 'react'; +import React, { useEffect, 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'; +import { mockAutoTestAssistants } from '../services/mockData'; +import { Assistant, AutoTestAssistant, TestType, TestMethod } from '../types'; +import { fetchAssistants } from '../services/backendApi'; export const AutoTestPage: React.FC = () => { const [testAssistants, setTestAssistants] = useState(mockAutoTestAssistants); + const [assistants, setAssistants] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [selectedId, setSelectedId] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const [copySuccess, setCopySuccess] = useState(false); + useEffect(() => { + const loadAssistants = async () => { + try { + const list = await fetchAssistants(); + setAssistants(list); + } catch (error) { + console.error(error); + } + }; + loadAssistants(); + }, []); + const filteredTests = testAssistants.filter(t => t.name.toLowerCase().includes(searchTerm.toLowerCase()) ); @@ -26,7 +40,7 @@ export const AutoTestPage: React.FC = () => { name: '新测试任务', type: TestType.FIXED, method: TestMethod.TEXT, - targetAssistantId: mockAssistants[0]?.id || '', + targetAssistantId: assistants[0]?.id || '', fixedWorkflowSteps: [], intelligentPrompt: '', createdAt: new Date().toISOString().split('T')[0], @@ -176,7 +190,7 @@ export const AutoTestPage: React.FC = () => { value={selectedTest.targetAssistantId} onChange={(e) => updateTest('targetAssistantId', e.target.value)} > - {mockAssistants.map(a => ( + {assistants.map(a => ( ))} diff --git a/web/pages/Dashboard.tsx b/web/pages/Dashboard.tsx index f91c2f2..ac6e44c 100644 --- a/web/pages/Dashboard.tsx +++ b/web/pages/Dashboard.tsx @@ -1,14 +1,17 @@ -import React, { useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Phone, CheckCircle, Clock, UserCheck, Activity, Filter, ChevronDown, BarChart3, HelpCircle, Mail, Sparkles, ArrowDown, Bot, Zap, Rocket, LineChart, Layers, Fingerprint, Network, MonitorPlay, Plus } from 'lucide-react'; import { Card, Button } from '../components/UI'; -import { mockAssistants, getDashboardStats } from '../services/mockData'; +import { getDashboardStats } from '../services/mockData'; +import { Assistant } from '../types'; +import { fetchAssistants } from '../services/backendApi'; export const DashboardPage: React.FC = () => { const navigate = useNavigate(); const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week'); const [selectedAssistantId, setSelectedAssistantId] = useState('all'); + const [assistants, setAssistants] = useState([]); const workflowRef = useRef(null); const aboutRef = useRef(null); @@ -17,6 +20,18 @@ export const DashboardPage: React.FC = () => { return getDashboardStats(timeRange, selectedAssistantId); }, [timeRange, selectedAssistantId]); + useEffect(() => { + const loadAssistants = async () => { + try { + const list = await fetchAssistants(); + setAssistants(list); + } catch (error) { + console.error(error); + } + }; + loadAssistants(); + }, []); + const scrollToNext = (ref: React.RefObject) => { ref.current?.scrollIntoView({ behavior: 'smooth' }); }; @@ -83,7 +98,7 @@ export const DashboardPage: React.FC = () => { onChange={(e) => setSelectedAssistantId(e.target.value)} > - {mockAssistants.map(a => ( + {assistants.map(a => ( ))} diff --git a/web/pages/History.tsx b/web/pages/History.tsx index 3bb306f..a6b9e1f 100644 --- a/web/pages/History.tsx +++ b/web/pages/History.tsx @@ -1,18 +1,36 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Download, Search, Calendar, Filter, MessageSquare, Mic, Video, Eye, X, Play, User, Bot, Clock } from 'lucide-react'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge, Drawer } from '../components/UI'; -import { mockCallLogs } from '../services/mockData'; import { CallLog, InteractionType } from '../types'; +import { fetchHistory, fetchHistoryDetail } from '../services/backendApi'; export const HistoryPage: React.FC = () => { - const [logs] = useState(mockCallLogs); + const [logs, setLogs] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all'); const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all'); const [typeFilter, setTypeFilter] = useState<'all' | InteractionType>('all'); - + const [isLoading, setIsLoading] = useState(true); const [selectedLog, setSelectedLog] = useState(null); + const [isDetailLoading, setIsDetailLoading] = useState(false); + + useEffect(() => { + const loadHistory = async () => { + setIsLoading(true); + try { + const list = await fetchHistory(); + setLogs(list); + } catch (error) { + console.error(error); + alert('加载历史记录失败,请检查后端服务。'); + } finally { + setIsLoading(false); + } + }; + + loadHistory(); + }, []); const filteredLogs = logs.filter(log => { const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase()); @@ -34,7 +52,7 @@ export const HistoryPage: React.FC = () => { log.startTime, log.duration ].join(',')); - const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...headers.join(',')].join('\n'); + const csvContent = "data:text/csv;charset=utf-8," + [headers.join(','), ...rows].join('\n'); const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); link.setAttribute("href", encodedUri); @@ -44,6 +62,20 @@ export const HistoryPage: React.FC = () => { document.body.removeChild(link); }; + const openDetail = async (log: CallLog) => { + setSelectedLog(log); + setIsDetailLoading(true); + try { + const detail = await fetchHistoryDetail(log.id, log); + setSelectedLog(detail); + } catch (error) { + console.error(error); + alert('加载通话详情失败。'); + } finally { + setIsDetailLoading(false); + } + }; + return (
@@ -118,8 +150,8 @@ export const HistoryPage: React.FC = () => { - {filteredLogs.map(log => ( - setSelectedLog(log)}> + {!isLoading && filteredLogs.map(log => ( + openDetail(log)}> #{log.id} {log.agentName} @@ -144,11 +176,16 @@ export const HistoryPage: React.FC = () => { {log.duration} ))} - {filteredLogs.length === 0 && ( + {!isLoading && filteredLogs.length === 0 && ( 暂无记录 )} + {isLoading && ( + + 加载中... + + )}
@@ -174,7 +211,10 @@ export const HistoryPage: React.FC = () => {
- {(selectedLog.details && selectedLog.details.length > 0) ? ( + {isDetailLoading && ( +
详情加载中...
+ )} + {!isDetailLoading && (selectedLog.details && selectedLog.details.length > 0) ? ( selectedLog.details.map((detail, index) => (
@@ -225,12 +265,12 @@ export const HistoryPage: React.FC = () => {
)) - ) : ( + ) : !isDetailLoading ? (

暂无对话记录

- )} + ) : null}
diff --git a/web/pages/KnowledgeBase.tsx b/web/pages/KnowledgeBase.tsx index 81f3803..74f2bd4 100644 --- a/web/pages/KnowledgeBase.tsx +++ b/web/pages/KnowledgeBase.tsx @@ -1,21 +1,43 @@ -import React, { useState, useRef } from 'react'; +import React, { useEffect, useState, useRef } from '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 { mockKnowledgeBases } from '../services/mockData'; import { KnowledgeBase } from '../types'; +import { createKnowledgeBase, deleteKnowledgeDocument, fetchKnowledgeBases, uploadKnowledgeDocument } from '../services/backendApi'; export const KnowledgeBasePage: React.FC = () => { const [view, setView] = useState<'list' | 'detail'>('list'); const [selectedKb, setSelectedKb] = useState(null); const [searchTerm, setSearchTerm] = useState(''); - const [kbs, setKbs] = useState(mockKnowledgeBases); + const [kbs, setKbs] = useState([]); const [isUploadOpen, setIsUploadOpen] = useState(false); const [isCreateKbOpen, setIsCreateKbOpen] = useState(false); const [newKbName, setNewKbName] = useState(''); + const [isLoading, setIsLoading] = useState(true); const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase())); + const refreshKnowledgeBases = async () => { + setIsLoading(true); + try { + const list = await fetchKnowledgeBases(); + setKbs(list); + if (selectedKb) { + const nextSelected = list.find((item) => item.id === selectedKb.id) || null; + setSelectedKb(nextSelected); + } + } catch (error) { + console.error(error); + alert('加载知识库失败,请检查后端服务。'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + refreshKnowledgeBases(); + }, []); + const handleSelect = (kb: KnowledgeBase) => { setSelectedKb(kb); setView('detail'); @@ -25,20 +47,17 @@ export const KnowledgeBasePage: React.FC = () => { setIsUploadOpen(true); }; - const handleCreateKb = () => { + const handleCreateKb = async () => { 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(''); + try { + await createKnowledgeBase(newKbName.trim()); + await refreshKnowledgeBases(); + setIsCreateKbOpen(false); + setNewKbName(''); + } catch (error) { + console.error(error); + alert('新建知识库失败。'); + } }; if (view === 'detail' && selectedKb) { @@ -48,8 +67,22 @@ export const KnowledgeBasePage: React.FC = () => { kb={selectedKb} onBack={() => setView('list')} onImport={handleImportClick} + onDeleteDocument={async (docId) => { + try { + await deleteKnowledgeDocument(selectedKb.id, docId); + await refreshKnowledgeBases(); + } catch (error) { + console.error(error); + alert('删除文档失败。'); + } + }} + /> + setIsUploadOpen(false)} + onUploaded={refreshKnowledgeBases} /> - setIsUploadOpen(false)} /> ); } @@ -103,6 +136,12 @@ export const KnowledgeBasePage: React.FC = () => { 新建知识库 + {!isLoading && filteredKbs.length === 0 && ( +
暂无知识库
+ )} + {isLoading && ( +
加载中...
+ )} {/* New Knowledge Base Dialog */} @@ -141,7 +180,8 @@ const KnowledgeBaseDetail: React.FC<{ kb: KnowledgeBase; onBack: () => void; onImport: () => void; -}> = ({ kb, onBack, onImport }) => { + onDeleteDocument: (docId: string) => void; +}> = ({ kb, onBack, onImport, onDeleteDocument }) => { const [docSearch, setDocSearch] = useState(''); const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase())); @@ -192,7 +232,14 @@ const KnowledgeBaseDetail: React.FC<{ {doc.size} {doc.uploadDate} - + )) : ( @@ -207,7 +254,7 @@ const KnowledgeBaseDetail: React.FC<{ ); }; -const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose }) => { +const UploadModal: React.FC<{ kbId: string; isOpen: boolean; onClose: () => void; onUploaded: () => Promise }> = ({ kbId, isOpen, onClose, onUploaded }) => { const [dragActive, setDragActive] = useState(false); const [files, setFiles] = useState([]); const inputRef = useRef(null); @@ -242,6 +289,19 @@ const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpe setFiles(prev => prev.filter((_, i) => i !== idx)); }; + const handleUpload = async () => { + if (files.length === 0) return; + try { + await Promise.all(files.map((file) => uploadKnowledgeDocument(kbId, file))); + await onUploaded(); + onClose(); + setFiles([]); + } catch (error) { + console.error(error); + alert('上传失败,请稍后重试。'); + } + }; + return ( void }> = ({ isOpe footer={ <> - + } > diff --git a/web/pages/VoiceLibrary.tsx b/web/pages/VoiceLibrary.tsx index cebf889..3131ab7 100644 --- a/web/pages/VoiceLibrary.tsx +++ b/web/pages/VoiceLibrary.tsx @@ -1,12 +1,13 @@ -import React, { useState, useRef } from 'react'; +import React, { useEffect, useState, useRef } from '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 { mockVoices } from '../services/mockData'; import { Voice } from '../types'; +import { fetchVoices } from '../services/backendApi'; export const VoiceLibraryPage: React.FC = () => { - const [voices, setVoices] = useState(mockVoices); + const [voices, setVoices] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动'>('all'); const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all'); @@ -15,6 +16,24 @@ export const VoiceLibraryPage: React.FC = () => { const [playingVoiceId, setPlayingVoiceId] = useState(null); const [isCloneModalOpen, setIsCloneModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadVoices = async () => { + setIsLoading(true); + try { + const list = await fetchVoices(); + setVoices(list.length > 0 ? list : mockVoices); + } catch (error) { + console.error(error); + setVoices(mockVoices); + } finally { + setIsLoading(false); + } + }; + + loadVoices(); + }, []); const filteredVoices = voices.filter(voice => { const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase()); @@ -116,7 +135,7 @@ export const VoiceLibraryPage: React.FC = () => { - {filteredVoices.map(voice => ( + {!isLoading && filteredVoices.map(voice => (
@@ -144,11 +163,16 @@ export const VoiceLibraryPage: React.FC = () => { ))} - {filteredVoices.length === 0 && ( + {!isLoading && filteredVoices.length === 0 && ( 暂无声音数据 )} + {isLoading && ( + + 加载中... + + )}
diff --git a/web/pages/WorkflowEditor.tsx b/web/pages/WorkflowEditor.tsx index b3990a5..184c80d 100644 --- a/web/pages/WorkflowEditor.tsx +++ b/web/pages/WorkflowEditor.tsx @@ -3,9 +3,56 @@ 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 { Assistant, WorkflowNode, WorkflowEdge, Workflow } from '../types'; import { DebugDrawer } from './Assistants'; +import { createWorkflow, fetchAssistants, fetchWorkflowById, updateWorkflow } from '../services/backendApi'; + +const getTemplateNodes = (templateType: string | null): WorkflowNode[] => { + 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: '你好!' } + } + ]; +}; export const WorkflowEditorPage: React.FC = () => { const navigate = useNavigate(); @@ -15,58 +62,11 @@ export const WorkflowEditorPage: React.FC = () => { // 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(() => { - 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(existingWf?.edges || []); + const [name, setName] = useState(templateName || '新工作流'); + const [nodes, setNodes] = useState(() => getTemplateNodes(templateType)); + const [edges, setEdges] = useState([]); + const [createdAt, setCreatedAt] = useState(''); + const [assistants, setAssistants] = useState([]); const [selectedNodeName, setSelectedNodeName] = useState(null); const [isAddMenuOpen, setIsAddMenuOpen] = useState(false); @@ -141,6 +141,36 @@ export const WorkflowEditorPage: React.FC = () => { }; }, [draggingNodeName, isPanning, zoom]); + useEffect(() => { + const loadData = async () => { + try { + const assistantList = await fetchAssistants(); + setAssistants(assistantList); + } catch (error) { + console.error(error); + } + }; + loadData(); + }, []); + + useEffect(() => { + if (!id) return; + const loadWorkflow = async () => { + try { + const workflow = await fetchWorkflowById(id); + setName(workflow.name); + setNodes(workflow.nodes); + setEdges(workflow.edges); + setCreatedAt(workflow.createdAt); + } catch (error) { + console.error(error); + alert('加载工作流失败。'); + } + }; + + loadWorkflow(); + }, [id]); + const addNode = (type: WorkflowNode['type']) => { const newNode: WorkflowNode = { name: `${type}_${Date.now()}`, @@ -158,26 +188,29 @@ export const WorkflowEditorPage: React.FC = () => { setNodes(prev => prev.map(n => n.name === selectedNodeName ? { ...n, [field]: value } : n)); }; - const handleSave = () => { + const handleSave = async () => { const now = new Date().toISOString().replace('T', ' ').substring(0, 16); - const updatedWorkflow: Workflow = { - id: id || `wf_${Date.now()}`, + const workflowPayload: Partial = { name, nodeCount: nodes.length, - createdAt: existingWf?.createdAt || now, + createdAt: 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); + try { + if (id) { + await updateWorkflow(id, workflowPayload); + } else { + await createWorkflow(workflowPayload); + } + alert('保存成功!工作流已同步至列表。'); + navigate('/workflows'); + } catch (error) { + console.error(error); + alert('保存失败,请稍后重试。'); } - alert('保存成功!工作流已同步至列表。'); - navigate('/workflows'); }; return ( @@ -385,7 +418,18 @@ export const WorkflowEditorPage: React.FC = () => { setIsDebugOpen(false)} - assistant={mockAssistants[0]} + assistant={assistants[0] || { + id: 'debug', + name: 'Debug Assistant', + callCount: 0, + opener: 'Hello!', + prompt: '', + knowledgeBaseId: '', + language: 'zh', + voice: '', + speed: 1, + hotwords: [], + }} /> ); diff --git a/web/pages/Workflows.tsx b/web/pages/Workflows.tsx index 467cbbf..765bdfd 100644 --- a/web/pages/Workflows.tsx +++ b/web/pages/Workflows.tsx @@ -1,17 +1,19 @@ -import React, { useState, useRef } from 'react'; +import React, { useEffect, 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'; +import { Workflow } from '../types'; +import { deleteWorkflow, fetchWorkflows } from '../services/backendApi'; export const WorkflowsPage: React.FC = () => { const navigate = useNavigate(); - const [workflows, setWorkflows] = useState(mockWorkflows); + const [workflows, setWorkflows] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [isUploadOpen, setIsUploadOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false); const [activeMenu, setActiveMenu] = useState(null); + const [isLoading, setIsLoading] = useState(true); const [newWfName, setNewWfName] = useState(''); const [selectedTemplate, setSelectedTemplate] = useState<'blank' | 'lead'>('blank'); @@ -20,6 +22,23 @@ export const WorkflowsPage: React.FC = () => { wf.name.toLowerCase().includes(searchTerm.toLowerCase()) ); + useEffect(() => { + const loadWorkflows = async () => { + setIsLoading(true); + try { + const list = await fetchWorkflows(); + setWorkflows(list); + } catch (error) { + console.error(error); + alert('加载工作流失败,请检查后端服务。'); + } finally { + setIsLoading(false); + } + }; + + loadWorkflows(); + }, []); + const handleCreateWorkflow = () => { if (!newWfName.trim()) { alert('请输入工作流名称'); @@ -29,10 +48,16 @@ export const WorkflowsPage: React.FC = () => { navigate(`/workflows/new?name=${encodeURIComponent(newWfName)}&template=${selectedTemplate}`); }; - const handleDeleteWorkflow = (id: string) => { + const handleDeleteWorkflow = async (id: string) => { if (confirm('确定要删除这个工作流吗?')) { - setWorkflows(prev => prev.filter(w => w.id !== id)); - setActiveMenu(null); + try { + await deleteWorkflow(id); + setWorkflows(prev => prev.filter(w => w.id !== id)); + setActiveMenu(null); + } catch (error) { + console.error(error); + alert('删除工作流失败。'); + } } }; @@ -83,7 +108,7 @@ export const WorkflowsPage: React.FC = () => {
- {filteredWorkflows.map(wf => ( + {!isLoading && filteredWorkflows.map(wf => (