Frontend start to use backend CRUD api
This commit is contained in:
@@ -16,5 +16,6 @@ View your app in AI Studio: https://ai.studio/apps/drive/1Cg9WH_2bOQEHVVj-lSN5l2
|
|||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
`npm install`
|
`npm install`
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
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`
|
`npm run dev`
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "ai视频助手",
|
"name": "ai视频助手2",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ai视频助手",
|
"name": "ai视频助手2",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.39.0",
|
"@google/genai": "^1.39.0",
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
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, 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 { 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 { Button, Input, Card, Badge, Drawer, Dialog } from '../components/UI';
|
||||||
import { mockAssistants, mockKnowledgeBases, mockVoices, mockLLMModels, mockASRModels } from '../services/mockData';
|
import { mockLLMModels, mockASRModels } from '../services/mockData';
|
||||||
import { Assistant, TabValue } from '../types';
|
import { Assistant, KnowledgeBase, TabValue, Voice } from '../types';
|
||||||
import { GoogleGenAI } from "@google/genai";
|
import { GoogleGenAI } from "@google/genai";
|
||||||
|
import { createAssistant, deleteAssistant, fetchAssistants, fetchKnowledgeBases, fetchVoices, updateAssistant as updateAssistantApi } from '../services/backendApi';
|
||||||
|
|
||||||
interface ToolItem {
|
interface ToolItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,7 +17,9 @@ interface ToolItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantsPage: React.FC = () => {
|
export const AssistantsPage: React.FC = () => {
|
||||||
const [assistants, setAssistants] = useState<Assistant[]>(mockAssistants);
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||||
|
const [voices, setVoices] = useState<Voice[]>([]);
|
||||||
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<TabValue>(TabValue.GLOBAL);
|
const [activeTab, setActiveTab] = useState<TabValue>(TabValue.GLOBAL);
|
||||||
@@ -40,6 +43,7 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
|
const selectedAssistant = assistants.find(a => a.id === selectedId) || null;
|
||||||
|
|
||||||
@@ -47,39 +51,69 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
a.name.toLowerCase().includes(searchTerm.toLowerCase())
|
a.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = () => {
|
useEffect(() => {
|
||||||
const newId = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
|
const loadInitialData = async () => {
|
||||||
const newAssistant: Assistant = {
|
setIsLoading(true);
|
||||||
id: newId,
|
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<Assistant> = {
|
||||||
name: 'New Assistant',
|
name: 'New Assistant',
|
||||||
callCount: 0,
|
|
||||||
opener: '',
|
opener: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
knowledgeBaseId: '',
|
knowledgeBaseId: '',
|
||||||
language: 'zh',
|
language: 'zh',
|
||||||
voice: mockVoices[0]?.id || '',
|
voice: voices[0]?.id || '',
|
||||||
speed: 1,
|
speed: 1,
|
||||||
hotwords: [],
|
hotwords: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
interruptionSensitivity: 500,
|
interruptionSensitivity: 500,
|
||||||
configMode: 'platform',
|
configMode: 'platform',
|
||||||
llmModelId: '',
|
|
||||||
asrModelId: '',
|
|
||||||
embeddingModelId: '',
|
|
||||||
rerankModelId: '',
|
|
||||||
};
|
};
|
||||||
setAssistants([...assistants, newAssistant]);
|
try {
|
||||||
setSelectedId(newId);
|
const created = await createAssistant(newAssistantPayload);
|
||||||
|
setAssistants((prev) => [created, ...prev]);
|
||||||
|
setSelectedId(created.id);
|
||||||
setActiveTab(TabValue.GLOBAL);
|
setActiveTab(TabValue.GLOBAL);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('创建助手失败。');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
|
if (!selectedAssistant) return;
|
||||||
setSaveLoading(true);
|
setSaveLoading(true);
|
||||||
// Simulate API call
|
try {
|
||||||
setTimeout(() => {
|
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);
|
setSaveLoading(false);
|
||||||
// In a real app, logic to persist selectedAssistant would go here
|
}
|
||||||
}, 800);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyId = (id: string, text?: string) => {
|
const handleCopyId = (id: string, text?: string) => {
|
||||||
@@ -88,11 +122,18 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
setTimeout(() => setCopySuccess(false), 2000);
|
setTimeout(() => setCopySuccess(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent, assistant: Assistant) => {
|
const handleCopy = async (e: React.MouseEvent, assistant: Assistant) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newId = Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
|
try {
|
||||||
const newAssistant = { ...assistant, id: newId, name: `${assistant.name} (Copy)` };
|
const copied = await createAssistant({
|
||||||
setAssistants([...assistants, newAssistant]);
|
...assistant,
|
||||||
|
name: `${assistant.name} (Copy)`,
|
||||||
|
});
|
||||||
|
setAssistants((prev) => [copied, ...prev]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('复制助手失败。');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
|
||||||
@@ -100,11 +141,17 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
setDeleteId(id);
|
setDeleteId(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = async () => {
|
||||||
if (deleteId) {
|
if (deleteId) {
|
||||||
|
try {
|
||||||
|
await deleteAssistant(deleteId);
|
||||||
setAssistants(prev => prev.filter(a => a.id !== deleteId));
|
setAssistants(prev => prev.filter(a => a.id !== deleteId));
|
||||||
if (selectedId === deleteId) setSelectedId(null);
|
if (selectedId === deleteId) setSelectedId(null);
|
||||||
setDeleteId(null);
|
setDeleteId(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('删除失败,请稍后重试。');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,7 +270,7 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||||
{filteredAssistants.map(assistant => (
|
{!isLoading && filteredAssistants.map(assistant => (
|
||||||
<div
|
<div
|
||||||
key={assistant.id}
|
key={assistant.id}
|
||||||
onClick={() => setSelectedId(assistant.id)}
|
onClick={() => setSelectedId(assistant.id)}
|
||||||
@@ -268,11 +315,16 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredAssistants.length === 0 && (
|
{!isLoading && filteredAssistants.length === 0 && (
|
||||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||||
未找到小助手
|
未找到小助手
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -545,7 +597,7 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
|
onChange={(e) => updateAssistant('knowledgeBaseId', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">如果不选择,则使用通用大模型知识</option>
|
<option value="">如果不选择,则使用通用大模型知识</option>
|
||||||
{mockKnowledgeBases.map(kb => (
|
{knowledgeBases.map(kb => (
|
||||||
<option key={kb.id} value={kb.id}>{kb.name}</option>
|
<option key={kb.id} value={kb.id}>{kb.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -593,7 +645,7 @@ export const AssistantsPage: React.FC = () => {
|
|||||||
onChange={(e) => updateAssistant('voice', e.target.value)}
|
onChange={(e) => updateAssistant('voice', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="" disabled>请选择声音库中的声音...</option>
|
<option value="" disabled>请选择声音库中的声音...</option>
|
||||||
{mockVoices.map(voice => (
|
{voices.map(voice => (
|
||||||
<option key={voice.id} value={voice.id}>
|
<option key={voice.id} value={voice.id}>
|
||||||
{voice.name} ({voice.vendor} - {voice.gender === 'Male' ? '男' : '女'})
|
{voice.name} ({voice.vendor} - {voice.gender === 'Male' ? '男' : '女'})
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -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 { Plus, Search, Play, Copy, Trash2, Zap, MessageSquare, Mic, AlertTriangle, ClipboardCheck, X } from 'lucide-react';
|
||||||
import { Button, Input, Card, Badge, Dialog } from '../components/UI';
|
import { Button, Input, Card, Badge, Dialog } from '../components/UI';
|
||||||
import { mockAutoTestAssistants, mockAssistants } from '../services/mockData';
|
import { mockAutoTestAssistants } from '../services/mockData';
|
||||||
import { AutoTestAssistant, TestType, TestMethod } from '../types';
|
import { Assistant, AutoTestAssistant, TestType, TestMethod } from '../types';
|
||||||
|
import { fetchAssistants } from '../services/backendApi';
|
||||||
|
|
||||||
export const AutoTestPage: React.FC = () => {
|
export const AutoTestPage: React.FC = () => {
|
||||||
const [testAssistants, setTestAssistants] = useState<AutoTestAssistant[]>(mockAutoTestAssistants);
|
const [testAssistants, setTestAssistants] = useState<AutoTestAssistant[]>(mockAutoTestAssistants);
|
||||||
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [copySuccess, setCopySuccess] = useState(false);
|
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 =>
|
const filteredTests = testAssistants.filter(t =>
|
||||||
t.name.toLowerCase().includes(searchTerm.toLowerCase())
|
t.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -26,7 +40,7 @@ export const AutoTestPage: React.FC = () => {
|
|||||||
name: '新测试任务',
|
name: '新测试任务',
|
||||||
type: TestType.FIXED,
|
type: TestType.FIXED,
|
||||||
method: TestMethod.TEXT,
|
method: TestMethod.TEXT,
|
||||||
targetAssistantId: mockAssistants[0]?.id || '',
|
targetAssistantId: assistants[0]?.id || '',
|
||||||
fixedWorkflowSteps: [],
|
fixedWorkflowSteps: [],
|
||||||
intelligentPrompt: '',
|
intelligentPrompt: '',
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
createdAt: new Date().toISOString().split('T')[0],
|
||||||
@@ -176,7 +190,7 @@ export const AutoTestPage: React.FC = () => {
|
|||||||
value={selectedTest.targetAssistantId}
|
value={selectedTest.targetAssistantId}
|
||||||
onChange={(e) => updateTest('targetAssistantId', e.target.value)}
|
onChange={(e) => updateTest('targetAssistantId', e.target.value)}
|
||||||
>
|
>
|
||||||
{mockAssistants.map(a => (
|
{assistants.map(a => (
|
||||||
<option key={a.id} value={a.id}>{a.name}</option>
|
<option key={a.id} value={a.id}>{a.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -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 { 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 { 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 { 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 = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week');
|
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week');
|
||||||
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('all');
|
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('all');
|
||||||
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||||
|
|
||||||
const workflowRef = useRef<HTMLDivElement>(null);
|
const workflowRef = useRef<HTMLDivElement>(null);
|
||||||
const aboutRef = useRef<HTMLDivElement>(null);
|
const aboutRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -17,6 +20,18 @@ export const DashboardPage: React.FC = () => {
|
|||||||
return getDashboardStats(timeRange, selectedAssistantId);
|
return getDashboardStats(timeRange, selectedAssistantId);
|
||||||
}, [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<HTMLDivElement>) => {
|
const scrollToNext = (ref: React.RefObject<HTMLDivElement>) => {
|
||||||
ref.current?.scrollIntoView({ behavior: 'smooth' });
|
ref.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
@@ -83,7 +98,7 @@ export const DashboardPage: React.FC = () => {
|
|||||||
onChange={(e) => setSelectedAssistantId(e.target.value)}
|
onChange={(e) => setSelectedAssistantId(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all" className="bg-background">全平台概览</option>
|
<option value="all" className="bg-background">全平台概览</option>
|
||||||
{mockAssistants.map(a => (
|
{assistants.map(a => (
|
||||||
<option key={a.id} value={a.id} className="bg-background">{a.name}</option>
|
<option key={a.id} value={a.id} className="bg-background">{a.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -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 { 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 { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge, Drawer } from '../components/UI';
|
||||||
import { mockCallLogs } from '../services/mockData';
|
|
||||||
import { CallLog, InteractionType } from '../types';
|
import { CallLog, InteractionType } from '../types';
|
||||||
|
import { fetchHistory, fetchHistoryDetail } from '../services/backendApi';
|
||||||
|
|
||||||
export const HistoryPage: React.FC = () => {
|
export const HistoryPage: React.FC = () => {
|
||||||
const [logs] = useState(mockCallLogs);
|
const [logs, setLogs] = useState<CallLog[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
|
||||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
|
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
|
||||||
const [typeFilter, setTypeFilter] = useState<'all' | InteractionType>('all');
|
const [typeFilter, setTypeFilter] = useState<'all' | InteractionType>('all');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [selectedLog, setSelectedLog] = useState<CallLog | null>(null);
|
const [selectedLog, setSelectedLog] = useState<CallLog | null>(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 filteredLogs = logs.filter(log => {
|
||||||
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
|
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
@@ -34,7 +52,7 @@ export const HistoryPage: React.FC = () => {
|
|||||||
log.startTime,
|
log.startTime,
|
||||||
log.duration
|
log.duration
|
||||||
].join(','));
|
].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 encodedUri = encodeURI(csvContent);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.setAttribute("href", encodedUri);
|
link.setAttribute("href", encodedUri);
|
||||||
@@ -44,6 +62,20 @@ export const HistoryPage: React.FC = () => {
|
|||||||
document.body.removeChild(link);
|
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 (
|
return (
|
||||||
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
<div className="space-y-6 animate-in fade-in py-4 pb-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -118,8 +150,8 @@ export const HistoryPage: React.FC = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredLogs.map(log => (
|
{!isLoading && filteredLogs.map(log => (
|
||||||
<TableRow key={log.id} className="cursor-pointer hover:bg-white/5 group" onClick={() => setSelectedLog(log)}>
|
<TableRow key={log.id} className="cursor-pointer hover:bg-white/5 group" onClick={() => openDetail(log)}>
|
||||||
<TableCell className="font-mono text-xs text-muted-foreground group-hover:text-primary transition-colors">#{log.id}</TableCell>
|
<TableCell className="font-mono text-xs text-muted-foreground group-hover:text-primary transition-colors">#{log.id}</TableCell>
|
||||||
<TableCell className="font-medium text-white group-hover:text-primary transition-colors flex items-center gap-2">
|
<TableCell className="font-medium text-white group-hover:text-primary transition-colors flex items-center gap-2">
|
||||||
{log.agentName}
|
{log.agentName}
|
||||||
@@ -144,11 +176,16 @@ export const HistoryPage: React.FC = () => {
|
|||||||
<TableCell className="text-muted-foreground">{log.duration}</TableCell>
|
<TableCell className="text-muted-foreground">{log.duration}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{filteredLogs.length === 0 && (
|
{!isLoading && filteredLogs.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center py-6 text-muted-foreground">暂无记录</TableCell>
|
<TableCell colSpan={7} className="text-center py-6 text-muted-foreground">暂无记录</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-6 text-muted-foreground">加载中...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +211,10 @@ export const HistoryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 pr-2 custom-scrollbar pb-6 px-1">
|
<div className="flex-1 overflow-y-auto space-y-6 pr-2 custom-scrollbar pb-6 px-1">
|
||||||
{(selectedLog.details && selectedLog.details.length > 0) ? (
|
{isDetailLoading && (
|
||||||
|
<div className="text-sm text-muted-foreground text-center py-8">详情加载中...</div>
|
||||||
|
)}
|
||||||
|
{!isDetailLoading && (selectedLog.details && selectedLog.details.length > 0) ? (
|
||||||
selectedLog.details.map((detail, index) => (
|
selectedLog.details.map((detail, index) => (
|
||||||
<div key={index} className={`flex gap-3 ${detail.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
|
<div key={index} className={`flex gap-3 ${detail.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 border border-white/10 ${detail.role === 'user' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-muted-foreground'}`}>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 border border-white/10 ${detail.role === 'user' ? 'bg-primary/20 text-primary' : 'bg-white/5 text-muted-foreground'}`}>
|
||||||
@@ -225,12 +265,12 @@ export const HistoryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : !isDetailLoading ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-40 space-y-3">
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-40 space-y-3">
|
||||||
<MessageSquare className="w-12 h-12 stroke-1" />
|
<MessageSquare className="w-12 h-12 stroke-1" />
|
||||||
<p className="text-sm font-medium">暂无对话记录</p>
|
<p className="text-sm font-medium">暂无对话记录</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -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 { Search, Plus, FileText, Upload, ArrowLeft, CloudUpload, File as FileIcon, X } from 'lucide-react';
|
||||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
|
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Card, Dialog } from '../components/UI';
|
||||||
import { mockKnowledgeBases } from '../services/mockData';
|
|
||||||
import { KnowledgeBase } from '../types';
|
import { KnowledgeBase } from '../types';
|
||||||
|
import { createKnowledgeBase, deleteKnowledgeDocument, fetchKnowledgeBases, uploadKnowledgeDocument } from '../services/backendApi';
|
||||||
|
|
||||||
export const KnowledgeBasePage: React.FC = () => {
|
export const KnowledgeBasePage: React.FC = () => {
|
||||||
const [view, setView] = useState<'list' | 'detail'>('list');
|
const [view, setView] = useState<'list' | 'detail'>('list');
|
||||||
const [selectedKb, setSelectedKb] = useState<KnowledgeBase | null>(null);
|
const [selectedKb, setSelectedKb] = useState<KnowledgeBase | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [kbs, setKbs] = useState(mockKnowledgeBases);
|
const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
|
||||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||||
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false);
|
const [isCreateKbOpen, setIsCreateKbOpen] = useState(false);
|
||||||
const [newKbName, setNewKbName] = useState('');
|
const [newKbName, setNewKbName] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const filteredKbs = kbs.filter(kb => kb.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
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) => {
|
const handleSelect = (kb: KnowledgeBase) => {
|
||||||
setSelectedKb(kb);
|
setSelectedKb(kb);
|
||||||
setView('detail');
|
setView('detail');
|
||||||
@@ -25,20 +47,17 @@ export const KnowledgeBasePage: React.FC = () => {
|
|||||||
setIsUploadOpen(true);
|
setIsUploadOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateKb = () => {
|
const handleCreateKb = async () => {
|
||||||
if (!newKbName.trim()) return;
|
if (!newKbName.trim()) return;
|
||||||
|
try {
|
||||||
const newKb: KnowledgeBase = {
|
await createKnowledgeBase(newKbName.trim());
|
||||||
id: `kb_${Date.now()}`,
|
await refreshKnowledgeBases();
|
||||||
name: newKbName.trim(),
|
|
||||||
creator: 'Admin User',
|
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
|
||||||
documents: []
|
|
||||||
};
|
|
||||||
|
|
||||||
setKbs([newKb, ...kbs]);
|
|
||||||
setIsCreateKbOpen(false);
|
setIsCreateKbOpen(false);
|
||||||
setNewKbName('');
|
setNewKbName('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('新建知识库失败。');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (view === 'detail' && selectedKb) {
|
if (view === 'detail' && selectedKb) {
|
||||||
@@ -48,8 +67,22 @@ export const KnowledgeBasePage: React.FC = () => {
|
|||||||
kb={selectedKb}
|
kb={selectedKb}
|
||||||
onBack={() => setView('list')}
|
onBack={() => setView('list')}
|
||||||
onImport={handleImportClick}
|
onImport={handleImportClick}
|
||||||
|
onDeleteDocument={async (docId) => {
|
||||||
|
try {
|
||||||
|
await deleteKnowledgeDocument(selectedKb.id, docId);
|
||||||
|
await refreshKnowledgeBases();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('删除文档失败。');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<UploadModal
|
||||||
|
kbId={selectedKb.id}
|
||||||
|
isOpen={isUploadOpen}
|
||||||
|
onClose={() => setIsUploadOpen(false)}
|
||||||
|
onUploaded={refreshKnowledgeBases}
|
||||||
/>
|
/>
|
||||||
<UploadModal isOpen={isUploadOpen} onClose={() => setIsUploadOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,6 +136,12 @@ export const KnowledgeBasePage: React.FC = () => {
|
|||||||
<Plus className="h-8 w-8 mb-2 opacity-50" />
|
<Plus className="h-8 w-8 mb-2 opacity-50" />
|
||||||
<span>新建知识库</span>
|
<span>新建知识库</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!isLoading && filteredKbs.length === 0 && (
|
||||||
|
<div className="col-span-full text-center text-muted-foreground py-8">暂无知识库</div>
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="col-span-full text-center text-muted-foreground py-8">加载中...</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Knowledge Base Dialog */}
|
{/* New Knowledge Base Dialog */}
|
||||||
@@ -141,7 +180,8 @@ const KnowledgeBaseDetail: React.FC<{
|
|||||||
kb: KnowledgeBase;
|
kb: KnowledgeBase;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onImport: () => void;
|
onImport: () => void;
|
||||||
}> = ({ kb, onBack, onImport }) => {
|
onDeleteDocument: (docId: string) => void;
|
||||||
|
}> = ({ kb, onBack, onImport, onDeleteDocument }) => {
|
||||||
const [docSearch, setDocSearch] = useState('');
|
const [docSearch, setDocSearch] = useState('');
|
||||||
const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase()));
|
const filteredDocs = kb.documents.filter(d => d.name.toLowerCase().includes(docSearch.toLowerCase()));
|
||||||
|
|
||||||
@@ -192,7 +232,14 @@ const KnowledgeBaseDetail: React.FC<{
|
|||||||
<TableCell className="text-muted-foreground">{doc.size}</TableCell>
|
<TableCell className="text-muted-foreground">{doc.size}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
|
<TableCell className="text-muted-foreground">{doc.uploadDate}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive/80">删除</Button>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive/80"
|
||||||
|
onClick={() => onDeleteDocument(doc.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)) : (
|
)) : (
|
||||||
@@ -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<void> }> = ({ kbId, isOpen, onClose, onUploaded }) => {
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -242,6 +289,19 @@ const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpe
|
|||||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -250,7 +310,7 @@ const UploadModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpe
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||||
<Button onClick={() => { alert('Upload Started!'); onClose(); setFiles([]); }}>确认上传</Button>
|
<Button onClick={handleUpload}>确认上传</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { Search, Mic2, Play, Pause, Upload, X, Filter, Plus, Volume2, Sparkles, Wand2, ChevronDown } from 'lucide-react';
|
||||||
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
|
||||||
import { mockVoices } from '../services/mockData';
|
import { mockVoices } from '../services/mockData';
|
||||||
import { Voice } from '../types';
|
import { Voice } from '../types';
|
||||||
|
import { fetchVoices } from '../services/backendApi';
|
||||||
|
|
||||||
export const VoiceLibraryPage: React.FC = () => {
|
export const VoiceLibraryPage: React.FC = () => {
|
||||||
const [voices, setVoices] = useState<Voice[]>(mockVoices);
|
const [voices, setVoices] = useState<Voice[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动'>('all');
|
const [vendorFilter, setVendorFilter] = useState<'all' | 'Ali' | 'Volcano' | 'Minimax' | '硅基流动'>('all');
|
||||||
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
|
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
|
||||||
@@ -15,6 +16,24 @@ export const VoiceLibraryPage: React.FC = () => {
|
|||||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||||
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [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 filteredVoices = voices.filter(voice => {
|
||||||
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
|
const matchesSearch = voice.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
@@ -116,7 +135,7 @@ export const VoiceLibraryPage: React.FC = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredVoices.map(voice => (
|
{!isLoading && filteredVoices.map(voice => (
|
||||||
<TableRow key={voice.id}>
|
<TableRow key={voice.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -144,11 +163,16 @@ export const VoiceLibraryPage: React.FC = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{filteredVoices.length === 0 && (
|
{!isLoading && filteredVoices.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">暂无声音数据</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-6 text-muted-foreground">加载中...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,24 +3,11 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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 { ArrowLeft, Play, Save, Rocket, Plus, Bot, UserCheck, Wrench, Ban, Zap, X, Copy, MousePointer2 } from 'lucide-react';
|
||||||
import { Button, Input, Badge } from '../components/UI';
|
import { Button, Input, Badge } from '../components/UI';
|
||||||
import { mockAssistants, mockKnowledgeBases, mockWorkflows } from '../services/mockData';
|
import { Assistant, WorkflowNode, WorkflowEdge, Workflow } from '../types';
|
||||||
import { WorkflowNode, WorkflowEdge, Workflow } from '../types';
|
|
||||||
import { DebugDrawer } from './Assistants';
|
import { DebugDrawer } from './Assistants';
|
||||||
|
import { createWorkflow, fetchAssistants, fetchWorkflowById, updateWorkflow } from '../services/backendApi';
|
||||||
|
|
||||||
export const WorkflowEditorPage: React.FC = () => {
|
const getTemplateNodes = (templateType: string | null): WorkflowNode[] => {
|
||||||
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') {
|
if (templateType === 'lead') {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -65,8 +52,21 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
messagePlan: { firstMessage: '你好!' }
|
messagePlan: { firstMessage: '你好!' }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
const [edges, setEdges] = useState<WorkflowEdge[]>(existingWf?.edges || []);
|
|
||||||
|
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');
|
||||||
|
const [name, setName] = useState(templateName || '新工作流');
|
||||||
|
const [nodes, setNodes] = useState<WorkflowNode[]>(() => getTemplateNodes(templateType));
|
||||||
|
const [edges, setEdges] = useState<WorkflowEdge[]>([]);
|
||||||
|
const [createdAt, setCreatedAt] = useState('');
|
||||||
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||||
|
|
||||||
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
|
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(null);
|
||||||
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
|
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
|
||||||
@@ -141,6 +141,36 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [draggingNodeName, isPanning, zoom]);
|
}, [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 addNode = (type: WorkflowNode['type']) => {
|
||||||
const newNode: WorkflowNode = {
|
const newNode: WorkflowNode = {
|
||||||
name: `${type}_${Date.now()}`,
|
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));
|
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 now = new Date().toISOString().replace('T', ' ').substring(0, 16);
|
||||||
const updatedWorkflow: Workflow = {
|
const workflowPayload: Partial<Workflow> = {
|
||||||
id: id || `wf_${Date.now()}`,
|
|
||||||
name,
|
name,
|
||||||
nodeCount: nodes.length,
|
nodeCount: nodes.length,
|
||||||
createdAt: existingWf?.createdAt || now,
|
createdAt: createdAt || now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
if (id) {
|
if (id) {
|
||||||
const idx = mockWorkflows.findIndex(w => w.id === id);
|
await updateWorkflow(id, workflowPayload);
|
||||||
if (idx !== -1) mockWorkflows[idx] = updatedWorkflow;
|
|
||||||
} else {
|
} else {
|
||||||
mockWorkflows.push(updatedWorkflow);
|
await createWorkflow(workflowPayload);
|
||||||
}
|
}
|
||||||
alert('保存成功!工作流已同步至列表。');
|
alert('保存成功!工作流已同步至列表。');
|
||||||
navigate('/workflows');
|
navigate('/workflows');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('保存失败,请稍后重试。');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -385,7 +418,18 @@ export const WorkflowEditorPage: React.FC = () => {
|
|||||||
<DebugDrawer
|
<DebugDrawer
|
||||||
isOpen={isDebugOpen}
|
isOpen={isDebugOpen}
|
||||||
onClose={() => setIsDebugOpen(false)}
|
onClose={() => 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: [],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { 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 { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Card } from '../components/UI';
|
||||||
import { mockWorkflows } from '../services/mockData';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Workflow } from '../types';
|
||||||
|
import { deleteWorkflow, fetchWorkflows } from '../services/backendApi';
|
||||||
|
|
||||||
export const WorkflowsPage: React.FC = () => {
|
export const WorkflowsPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [workflows, setWorkflows] = useState(mockWorkflows);
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
const [activeMenu, setActiveMenu] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const [newWfName, setNewWfName] = useState('');
|
const [newWfName, setNewWfName] = useState('');
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<'blank' | 'lead'>('blank');
|
const [selectedTemplate, setSelectedTemplate] = useState<'blank' | 'lead'>('blank');
|
||||||
@@ -20,6 +22,23 @@ export const WorkflowsPage: React.FC = () => {
|
|||||||
wf.name.toLowerCase().includes(searchTerm.toLowerCase())
|
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 = () => {
|
const handleCreateWorkflow = () => {
|
||||||
if (!newWfName.trim()) {
|
if (!newWfName.trim()) {
|
||||||
alert('请输入工作流名称');
|
alert('请输入工作流名称');
|
||||||
@@ -29,10 +48,16 @@ export const WorkflowsPage: React.FC = () => {
|
|||||||
navigate(`/workflows/new?name=${encodeURIComponent(newWfName)}&template=${selectedTemplate}`);
|
navigate(`/workflows/new?name=${encodeURIComponent(newWfName)}&template=${selectedTemplate}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWorkflow = (id: string) => {
|
const handleDeleteWorkflow = async (id: string) => {
|
||||||
if (confirm('确定要删除这个工作流吗?')) {
|
if (confirm('确定要删除这个工作流吗?')) {
|
||||||
|
try {
|
||||||
|
await deleteWorkflow(id);
|
||||||
setWorkflows(prev => prev.filter(w => w.id !== id));
|
setWorkflows(prev => prev.filter(w => w.id !== id));
|
||||||
setActiveMenu(null);
|
setActiveMenu(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('删除工作流失败。');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +108,7 @@ export const WorkflowsPage: React.FC = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredWorkflows.map(wf => (
|
{!isLoading && filteredWorkflows.map(wf => (
|
||||||
<TableRow key={wf.id} className="group">
|
<TableRow key={wf.id} className="group">
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<button
|
<button
|
||||||
@@ -125,11 +150,16 @@ export const WorkflowsPage: React.FC = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{filteredWorkflows.length === 0 && (
|
{!isLoading && filteredWorkflows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">暂无工作流数据</TableCell>
|
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">暂无工作流数据</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">加载中...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
58
web/services/apiClient.ts
Normal file
58
web/services/apiClient.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const DEFAULT_API_BASE_URL = 'http://localhost:8000/api';
|
||||||
|
|
||||||
|
const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const getApiBaseUrl = (): string => {
|
||||||
|
const configured = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL;
|
||||||
|
return trimTrailingSlash(configured);
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
body?: unknown;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiRequest = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
|
||||||
|
const url = `${getApiBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Request failed: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData?.detail) {
|
||||||
|
message = typeof errorData.detail === 'string' ? errorData.detail : message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parsing errors.
|
||||||
|
}
|
||||||
|
throw new ApiError(message, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
};
|
||||||
276
web/services/backendApi.ts
Normal file
276
web/services/backendApi.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { Assistant, CallLog, InteractionDetail, KnowledgeBase, KnowledgeDocument, Voice, Workflow, WorkflowEdge, WorkflowNode } from '../types';
|
||||||
|
import { apiRequest } from './apiClient';
|
||||||
|
|
||||||
|
type AnyRecord = Record<string, any>;
|
||||||
|
|
||||||
|
const readField = <T>(obj: AnyRecord, keys: string[], fallback: T): T => {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] !== undefined && obj[key] !== null) {
|
||||||
|
return obj[key] as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDateLabel = (value: string): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.includes('T') ? value.replace('T', ' ').slice(0, 16) : value.slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds?: number | null): string => {
|
||||||
|
if (!seconds || seconds <= 0) return '0s';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (m === 0) return `${s}s`;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapAssistant = (raw: AnyRecord): Assistant => ({
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
callCount: Number(readField(raw, ['callCount', 'call_count'], 0)),
|
||||||
|
opener: readField(raw, ['opener'], ''),
|
||||||
|
prompt: readField(raw, ['prompt'], ''),
|
||||||
|
knowledgeBaseId: readField(raw, ['knowledgeBaseId', 'knowledge_base_id'], ''),
|
||||||
|
language: readField(raw, ['language'], 'zh') as 'zh' | 'en',
|
||||||
|
voice: readField(raw, ['voice'], ''),
|
||||||
|
speed: Number(readField(raw, ['speed'], 1)),
|
||||||
|
hotwords: readField(raw, ['hotwords'], []),
|
||||||
|
tools: readField(raw, ['tools'], []),
|
||||||
|
interruptionSensitivity: Number(readField(raw, ['interruptionSensitivity', 'interruption_sensitivity'], 500)),
|
||||||
|
configMode: readField(raw, ['configMode', 'config_mode'], 'platform') as 'platform' | 'dify' | 'fastgpt' | 'none',
|
||||||
|
apiUrl: readField(raw, ['apiUrl', 'api_url'], ''),
|
||||||
|
apiKey: readField(raw, ['apiKey', 'api_key'], ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapVoice = (raw: AnyRecord): Voice => ({
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
vendor: readField(raw, ['vendor'], ''),
|
||||||
|
gender: readField(raw, ['gender'], ''),
|
||||||
|
language: readField(raw, ['language'], ''),
|
||||||
|
description: readField(raw, ['description'], ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapWorkflowNode = (raw: AnyRecord): WorkflowNode => ({
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
type: readField(raw, ['type'], 'conversation') as 'conversation' | 'tool' | 'human' | 'end',
|
||||||
|
isStart: readField(raw, ['isStart', 'is_start'], undefined),
|
||||||
|
metadata: readField(raw, ['metadata'], { position: { x: 200, y: 200 } }),
|
||||||
|
prompt: readField(raw, ['prompt'], ''),
|
||||||
|
messagePlan: readField(raw, ['messagePlan', 'message_plan'], undefined),
|
||||||
|
variableExtractionPlan: readField(raw, ['variableExtractionPlan', 'variable_extraction_plan'], undefined),
|
||||||
|
tool: readField(raw, ['tool'], undefined),
|
||||||
|
globalNodePlan: readField(raw, ['globalNodePlan', 'global_node_plan'], undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapWorkflowEdge = (raw: AnyRecord): WorkflowEdge => ({
|
||||||
|
from: readField(raw, ['from', 'from_'], ''),
|
||||||
|
to: readField(raw, ['to'], ''),
|
||||||
|
label: readField(raw, ['label'], undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapWorkflow = (raw: AnyRecord): Workflow => ({
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
nodeCount: Number(readField(raw, ['nodeCount', 'node_count'], Array.isArray(raw.nodes) ? raw.nodes.length : 0)),
|
||||||
|
createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')),
|
||||||
|
updatedAt: normalizeDateLabel(readField(raw, ['updatedAt', 'updated_at'], '')),
|
||||||
|
nodes: readField(raw, ['nodes'], []).map((node: AnyRecord) => mapWorkflowNode(node)),
|
||||||
|
edges: readField(raw, ['edges'], []).map((edge: AnyRecord) => mapWorkflowEdge(edge)),
|
||||||
|
globalPrompt: readField(raw, ['globalPrompt', 'global_prompt'], ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapKnowledgeDocument = (raw: AnyRecord): KnowledgeDocument => ({
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
size: readField(raw, ['size'], ''),
|
||||||
|
uploadDate: normalizeDateLabel(readField(raw, ['uploadDate', 'upload_date', 'created_at'], '')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapKnowledgeBase = (raw: AnyRecord): KnowledgeBase => ({
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
name: readField(raw, ['name'], ''),
|
||||||
|
creator: 'Admin',
|
||||||
|
createdAt: normalizeDateLabel(readField(raw, ['createdAt', 'created_at'], '')),
|
||||||
|
documents: readField(raw, ['documents'], []).map((doc: AnyRecord) => mapKnowledgeDocument(doc)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toHistoryRow = (raw: AnyRecord, assistantNameMap: Map<string, string>): CallLog => {
|
||||||
|
const assistantId = readField(raw, ['assistant_id', 'assistantId'], '');
|
||||||
|
const startTime = normalizeDateLabel(readField(raw, ['started_at', 'startTime'], ''));
|
||||||
|
const type = readField(raw, ['type'], 'text');
|
||||||
|
return {
|
||||||
|
id: String(readField(raw, ['id'], '')),
|
||||||
|
source: readField(raw, ['source'], 'debug') as 'debug' | 'external',
|
||||||
|
status: readField(raw, ['status'], 'connected') as 'connected' | 'missed',
|
||||||
|
startTime,
|
||||||
|
duration: formatDuration(readField(raw, ['duration_seconds', 'durationSeconds'], 0)),
|
||||||
|
agentName: assistantNameMap.get(String(assistantId)) || String(assistantId || 'Unknown Assistant'),
|
||||||
|
type: type === 'audio' || type === 'video' ? type : 'text',
|
||||||
|
details: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toHistoryDetails = (raw: AnyRecord): InteractionDetail[] => {
|
||||||
|
const transcripts = readField(raw, ['transcripts'], []);
|
||||||
|
return transcripts.map((t: AnyRecord) => ({
|
||||||
|
role: readField(t, ['speaker'], 'human') === 'human' ? 'user' : 'assistant',
|
||||||
|
content: readField(t, ['content'], ''),
|
||||||
|
timestamp: `${Math.floor(Number(readField(t, ['startMs', 'start_ms'], 0)) / 1000)}s`,
|
||||||
|
audioUrl: readField(t, ['audioUrl', 'audio_url'], undefined),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAssistants = async (): Promise<Assistant[]> => {
|
||||||
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/assistants');
|
||||||
|
const list = Array.isArray(response) ? response : (response.list || []);
|
||||||
|
return list.map((item) => mapAssistant(item));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAssistant = async (data: Partial<Assistant>): Promise<Assistant> => {
|
||||||
|
const payload = {
|
||||||
|
name: data.name || 'New Assistant',
|
||||||
|
opener: data.opener || '',
|
||||||
|
prompt: data.prompt || '',
|
||||||
|
knowledgeBaseId: data.knowledgeBaseId || '',
|
||||||
|
language: data.language || 'zh',
|
||||||
|
voice: data.voice || '',
|
||||||
|
speed: data.speed ?? 1,
|
||||||
|
hotwords: data.hotwords || [],
|
||||||
|
tools: data.tools || [],
|
||||||
|
interruptionSensitivity: data.interruptionSensitivity ?? 500,
|
||||||
|
configMode: data.configMode || 'platform',
|
||||||
|
apiUrl: data.apiUrl || '',
|
||||||
|
apiKey: data.apiKey || '',
|
||||||
|
};
|
||||||
|
const response = await apiRequest<AnyRecord>('/assistants', { method: 'POST', body: payload });
|
||||||
|
return mapAssistant(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAssistant = async (id: string, data: Partial<Assistant>): Promise<Assistant> => {
|
||||||
|
const payload = {
|
||||||
|
name: data.name,
|
||||||
|
opener: data.opener,
|
||||||
|
prompt: data.prompt,
|
||||||
|
knowledgeBaseId: data.knowledgeBaseId,
|
||||||
|
language: data.language,
|
||||||
|
voice: data.voice,
|
||||||
|
speed: data.speed,
|
||||||
|
hotwords: data.hotwords,
|
||||||
|
tools: data.tools,
|
||||||
|
interruptionSensitivity: data.interruptionSensitivity,
|
||||||
|
configMode: data.configMode,
|
||||||
|
apiUrl: data.apiUrl,
|
||||||
|
apiKey: data.apiKey,
|
||||||
|
};
|
||||||
|
const response = await apiRequest<AnyRecord>(`/assistants/${id}`, { method: 'PUT', body: payload });
|
||||||
|
return mapAssistant(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAssistant = async (id: string): Promise<void> => {
|
||||||
|
await apiRequest(`/assistants/${id}`, { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchVoices = async (): Promise<Voice[]> => {
|
||||||
|
const response = await apiRequest<AnyRecord[]>('/voices');
|
||||||
|
return response.map((item) => mapVoice(item));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchWorkflows = async (): Promise<Workflow[]> => {
|
||||||
|
const response = await apiRequest<AnyRecord[]>('/workflows');
|
||||||
|
return response.map((item) => mapWorkflow(item));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchWorkflowById = async (id: string): Promise<Workflow> => {
|
||||||
|
const list = await fetchWorkflows();
|
||||||
|
const workflow = list.find((item) => item.id === id);
|
||||||
|
if (!workflow) {
|
||||||
|
throw new Error('Workflow not found');
|
||||||
|
}
|
||||||
|
return workflow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWorkflow = async (data: Partial<Workflow>): Promise<Workflow> => {
|
||||||
|
const payload = {
|
||||||
|
name: data.name || '新工作流',
|
||||||
|
nodeCount: data.nodeCount ?? data.nodes?.length ?? 0,
|
||||||
|
createdAt: data.createdAt || '',
|
||||||
|
updatedAt: data.updatedAt || '',
|
||||||
|
globalPrompt: data.globalPrompt || '',
|
||||||
|
nodes: data.nodes || [],
|
||||||
|
edges: data.edges || [],
|
||||||
|
};
|
||||||
|
const response = await apiRequest<AnyRecord>('/workflows', { method: 'POST', body: payload });
|
||||||
|
return mapWorkflow(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateWorkflow = async (id: string, data: Partial<Workflow>): Promise<Workflow> => {
|
||||||
|
const payload = {
|
||||||
|
name: data.name,
|
||||||
|
nodeCount: data.nodeCount ?? data.nodes?.length,
|
||||||
|
nodes: data.nodes,
|
||||||
|
edges: data.edges,
|
||||||
|
globalPrompt: data.globalPrompt,
|
||||||
|
};
|
||||||
|
const response = await apiRequest<AnyRecord>(`/workflows/${id}`, { method: 'PUT', body: payload });
|
||||||
|
return mapWorkflow(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteWorkflow = async (id: string): Promise<void> => {
|
||||||
|
await apiRequest(`/workflows/${id}`, { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchKnowledgeBases = async (): Promise<KnowledgeBase[]> => {
|
||||||
|
const response = await apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/knowledge/bases');
|
||||||
|
const list = Array.isArray(response) ? response : (response.list || []);
|
||||||
|
return list.map((item) => mapKnowledgeBase(item));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createKnowledgeBase = async (name: string): Promise<KnowledgeBase> => {
|
||||||
|
const payload = { name, description: '', embeddingModel: 'text-embedding-3-small', chunkSize: 500, chunkOverlap: 50 };
|
||||||
|
const response = await apiRequest<AnyRecord>('/knowledge/bases', { method: 'POST', body: payload });
|
||||||
|
return mapKnowledgeBase(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteKnowledgeBase = async (kbId: string): Promise<void> => {
|
||||||
|
await apiRequest(`/knowledge/bases/${kbId}`, { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadKnowledgeDocument = async (kbId: string, file: File): Promise<void> => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
name: file.name,
|
||||||
|
size: `${(file.size / 1024).toFixed(1)} KB`,
|
||||||
|
file_type: file.type || 'txt',
|
||||||
|
});
|
||||||
|
await apiRequest(`/knowledge/bases/${kbId}/documents?${params.toString()}`, { method: 'POST' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteKnowledgeDocument = async (kbId: string, docId: string): Promise<void> => {
|
||||||
|
await apiRequest(`/knowledge/bases/${kbId}/documents/${docId}`, { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchHistory = async (): Promise<CallLog[]> => {
|
||||||
|
const [historyResp, assistantsResp] = await Promise.all([
|
||||||
|
apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/history'),
|
||||||
|
apiRequest<{ list?: AnyRecord[] } | AnyRecord[]>('/assistants'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assistantList = Array.isArray(assistantsResp) ? assistantsResp : (assistantsResp.list || []);
|
||||||
|
const assistantNameMap = new Map<string, string>(assistantList.map((a: AnyRecord) => [String(readField(a, ['id'], '')), readField(a, ['name'], '')]));
|
||||||
|
const historyList = Array.isArray(historyResp) ? historyResp : (historyResp.list || []);
|
||||||
|
return historyList.map((item) => toHistoryRow(item, assistantNameMap));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchHistoryDetail = async (id: string, base: CallLog): Promise<CallLog> => {
|
||||||
|
const response = await apiRequest<AnyRecord>(`/history/${id}`);
|
||||||
|
const details = toHistoryDetails(response);
|
||||||
|
const duration = formatDuration(readField(response, ['duration_seconds', 'durationSeconds'], 0));
|
||||||
|
const startTime = normalizeDateLabel(readField(response, ['started_at'], base.startTime));
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
startTime,
|
||||||
|
duration,
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user