Update web page config

This commit is contained in:
Xin Wang
2026-02-06 23:09:24 +08:00
parent c6f4ecd911
commit dc3130d387
11 changed files with 1028 additions and 41 deletions

235
web/pages/ASRLibrary.tsx Normal file
View File

@@ -0,0 +1,235 @@
import React, { useState } from 'react';
import { Search, Filter, Plus, Trash2, Key, Server, Ear, Globe, Languages } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
import { mockASRModels } from '../services/mockData';
import { ASRModel } from '../types';
export const ASRLibraryPage: React.FC = () => {
const [models, setModels] = useState<ASRModel[]>(mockASRModels);
const [searchTerm, setSearchTerm] = useState('');
const [vendorFilter, setVendorFilter] = useState<string>('all');
const [langFilter, setLangFilter] = useState<string>('all');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
// Form State
const [newModel, setNewModel] = useState<Partial<ASRModel>>({
vendor: 'OpenAI Compatible',
language: 'zh'
});
const filteredModels = models.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesVendor = vendorFilter === 'all' || m.vendor === vendorFilter;
const matchesLang = langFilter === 'all' || m.language === langFilter || (langFilter !== 'all' && m.language === 'Multi-lingual');
return matchesSearch && matchesVendor && matchesLang;
});
const handleAddModel = () => {
if (!newModel.name || !newModel.baseUrl || !newModel.apiKey) {
alert("请填写完整信息");
return;
}
const model: ASRModel = {
id: `asr_${Date.now()}`,
name: newModel.name,
vendor: newModel.vendor as 'OpenAI Compatible',
language: newModel.language || 'zh',
baseUrl: newModel.baseUrl,
apiKey: newModel.apiKey
};
setModels([model, ...models]);
setIsAddModalOpen(false);
setNewModel({ vendor: 'OpenAI Compatible', language: 'zh', name: '', baseUrl: '', apiKey: '' });
};
const handleDeleteModel = (id: string) => {
if (confirm('确认删除该语音识别模型吗?')) {
setModels(prev => prev.filter(m => m.id !== id));
}
};
const maskApiKey = (key: string) => {
if (!key || key.length < 8) return '********';
return `${key.substring(0, 3)}****${key.substring(key.length - 4)}`;
};
return (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
<Button onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative col-span-1 md:col-span-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索模型名称..."
className="pl-9 border-0 bg-white/5"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={vendorFilter}
onChange={(e) => setVendorFilter(e.target.value)}
>
<option value="all"></option>
<option value="OpenAI Compatible">OpenAI Compatible</option>
</select>
</div>
<div className="flex items-center space-x-2">
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={langFilter}
onChange={(e) => setLangFilter(e.target.value)}
>
<option value="all"></option>
<option value="zh"> (Chinese)</option>
<option value="en"> (English)</option>
<option value="Multi-lingual"> (Multi-lingual)</option>
</select>
</div>
</div>
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
<table className="w-full text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Base URL</TableHead>
<TableHead>API Key</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<tbody>
{filteredModels.map(model => (
<TableRow key={model.id}>
<TableCell className="font-medium text-white flex items-center">
<Ear className="w-4 h-4 mr-2 text-primary" />
{model.name}
</TableCell>
<TableCell>
<Badge variant="outline">{model.vendor}</Badge>
</TableCell>
<TableCell>
<Badge variant="default" className="bg-purple-500/10 text-purple-400 border-purple-500/20">
{model.language}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{model.baseUrl}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{maskApiKey(model.apiKey)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteModel(model.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
{filteredModels.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
<Dialog
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
title="添加语音识别模型"
footer={
<>
<Button variant="ghost" onClick={() => setIsAddModalOpen(false)}></Button>
<Button onClick={handleAddModel}></Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Interface Type)</label>
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
value={newModel.vendor}
onChange={e => setNewModel({...newModel, vendor: e.target.value as any})}
>
<option value="OpenAI Compatible">OpenAI Compatible</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Language)</label>
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
{(['zh', 'en', 'Multi-lingual'] as const).map(l => (
<button
key={l}
onClick={() => setNewModel({...newModel, language: l})}
className={`flex-1 flex items-center justify-center py-1.5 text-xs font-bold rounded-md transition-all ${newModel.language === l ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
{l === 'zh' && <span className="mr-1">🇨🇳</span>}
{l === 'en' && <span className="mr-1">🇺🇸</span>}
{l === 'Multi-lingual' && <Globe className="w-3 h-3 mr-1.5" />}
{l === 'zh' ? '中文' : l === 'en' ? '英文' : '多语言'}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Model Name)</label>
<Input
value={newModel.name}
onChange={e => setNewModel({...newModel, name: e.target.value})}
placeholder="例如: whisper-1, funasr"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center">
<Server className="w-3 h-3 mr-1.5" /> Base URL
</label>
<Input
value={newModel.baseUrl}
onChange={e => setNewModel({...newModel, baseUrl: e.target.value})}
placeholder="https://api.openai.com/v1"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center">
<Key className="w-3 h-3 mr-1.5" /> API Key
</label>
<Input
type="password"
value={newModel.apiKey}
onChange={e => setNewModel({...newModel, apiKey: e.target.value})}
placeholder="sk-..."
className="font-mono text-xs"
/>
</div>
</div>
</Dialog>
</div>
);
};

View File

@@ -140,8 +140,8 @@ export const DashboardPage: React.FC = () => {
</Card>
</div>
{/* 6. Platform Feature Intro - Moved to Bottom, Full Width */}
<div className="w-full bg-white/[0.02] border border-white/5 rounded-2xl p-6 animate-in slide-in-from-bottom-4 duration-700 shadow-2xl relative overflow-hidden group pb-10 mb-10">
{/* 6. Platform Feature Intro - Updated Background */}
<div className="w-full bg-card/30 backdrop-blur-sm border border-white/5 rounded-2xl p-6 animate-in slide-in-from-bottom-4 duration-700 shadow-2xl relative overflow-hidden group pb-10 mb-10">
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 blur-[100px] -mr-32 -mt-32 rounded-full pointer-events-none group-hover:bg-primary/10 transition-colors"></div>
<div className="relative z-10">

View File

@@ -1,29 +1,35 @@
import React, { useState } from 'react';
import { Download, Search, Calendar, Filter } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Badge } from '../components/UI';
import { Download, Search, Calendar, Filter, MessageSquare, Mic, Video, Eye, X, Play } 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';
export const HistoryPage: React.FC = () => {
const [logs] = useState(mockCallLogs);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'connected' | 'missed'>('all');
const [sourceFilter, setSourceFilter] = useState<'all' | 'debug' | 'external'>('all');
const [typeFilter, setTypeFilter] = useState<'all' | InteractionType>('all');
const [selectedLog, setSelectedLog] = useState<CallLog | null>(null);
const filteredLogs = logs.filter(log => {
const matchesSearch = log.agentName.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || log.status === statusFilter;
const matchesSource = sourceFilter === 'all' || log.source === sourceFilter;
return matchesSearch && matchesStatus && matchesSource;
const matchesType = typeFilter === 'all' || log.type === typeFilter;
return matchesSearch && matchesStatus && matchesSource && matchesType;
});
const handleExport = () => {
// Generate CSV content
const headers = ['ID', 'Agent', 'Source', 'Status', 'Start Time', 'Duration'];
const headers = ['ID', 'Agent', 'Source', 'Type', 'Status', 'Start Time', 'Duration'];
const rows = filteredLogs.map(log => [
log.id,
log.agentName,
log.source,
log.source,
log.type,
log.status,
log.startTime,
log.duration
@@ -47,7 +53,7 @@ export const HistoryPage: React.FC = () => {
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
@@ -69,6 +75,18 @@ export const HistoryPage: React.FC = () => {
<option value="external"> (External)</option>
</select>
</div>
<div className="flex items-center space-x-2">
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as any)}
>
<option value="all"></option>
<option value="text"> (Text)</option>
<option value="audio"> (Audio)</option>
<option value="video"> (Video)</option>
</select>
</div>
<div className="flex items-center space-x-2">
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card"
@@ -92,6 +110,7 @@ export const HistoryPage: React.FC = () => {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
@@ -100,9 +119,19 @@ export const HistoryPage: React.FC = () => {
</TableHeader>
<tbody>
{filteredLogs.map(log => (
<TableRow key={log.id}>
<TableCell className="font-mono text-xs text-muted-foreground">#{log.id}</TableCell>
<TableCell className="font-medium text-white">{log.agentName}</TableCell>
<TableRow key={log.id} className="cursor-pointer hover:bg-white/5 group" onClick={() => setSelectedLog(log)}>
<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">
{log.agentName}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{log.type === 'text' && <MessageSquare className="w-3.5 h-3.5 text-blue-400" />}
{log.type === 'audio' && <Mic className="w-3.5 h-3.5 text-orange-400" />}
{log.type === 'video' && <Video className="w-3.5 h-3.5 text-green-400" />}
<span className="capitalize text-xs">{log.type}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{log.source === 'debug' ? '调试' : '外部'}</Badge>
</TableCell>
@@ -117,12 +146,101 @@ export const HistoryPage: React.FC = () => {
))}
{filteredLogs.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-6 text-muted-foreground"></TableCell>
<TableCell colSpan={7} className="text-center py-6 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
{selectedLog && (
<Drawer
isOpen={!!selectedLog}
onClose={() => setSelectedLog(null)}
title="历史记录详情"
>
<div className="flex flex-col h-full overflow-hidden">
<div className="shrink-0 mb-4 p-4 bg-white/5 rounded-xl border border-white/10 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-lg text-white">{selectedLog.agentName}</h3>
<Badge variant="outline" className="uppercase">{selectedLog.type} Record</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-xs text-muted-foreground">
<div>ID: <span className="font-mono text-white/70">#{selectedLog.id}</span></div>
<div>: <span className="text-white/70">{selectedLog.startTime}</span></div>
<div>: <span className="text-white/70">{selectedLog.duration}</span></div>
<div>: <span className={selectedLog.status === 'connected' ? 'text-green-400' : 'text-yellow-400'}>{selectedLog.status}</span></div>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-4 pr-1 custom-scrollbar pb-6">
{(selectedLog.details && selectedLog.details.length > 0) ? (
selectedLog.details.map((detail, index) => (
<div key={index} className={`flex flex-col gap-1 ${detail.role === 'user' ? 'items-end' : 'items-start'}`}>
<div className={`max-w-[85%] rounded-2xl p-4 shadow-sm border ${
detail.role === 'user'
? 'bg-primary/10 border-primary/20 rounded-tr-none'
: 'bg-card border-white/10 rounded-tl-none'
}`}>
<div className="flex items-center gap-2 mb-2 opacity-70">
<span className="text-[10px] font-bold uppercase tracking-wider text-primary">
{detail.role === 'user' ? 'User' : 'AI Assistant'}
</span>
<span className="text-[10px] text-muted-foreground">{detail.timestamp}</span>
</div>
{/* Video Frames */}
{selectedLog.type === 'video' && detail.role === 'user' && detail.imageUrls && detail.imageUrls.length > 0 && (
<div className="flex gap-2 overflow-x-auto mb-3 pb-2">
{detail.imageUrls.map((url, i) => (
<div key={i} className="relative h-20 w-32 rounded-lg overflow-hidden border border-white/10 bg-black/50 shrink-0 group">
<img src={url} alt={`Frame ${i}`} className="h-full w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Eye className="w-5 h-5 text-white" />
</div>
</div>
))}
</div>
)}
{/* Content / Transcript */}
<div className="text-sm leading-relaxed text-white/90">
{selectedLog.type !== 'text' && (
<div className="flex items-center gap-2 mb-1.5">
<div className={`p-1.5 rounded-full ${detail.role === 'user' ? 'bg-primary/20' : 'bg-white/10'}`}>
{selectedLog.type === 'audio' ? <Mic size={12} /> : <Video size={12} />}
</div>
<span className="text-[10px] uppercase font-mono text-muted-foreground">Transcript</span>
</div>
)}
{detail.content}
</div>
{/* Audio Player Placeholder for Audio/Video types */}
{selectedLog.type !== 'text' && (
<div className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-black/20 border border-white/5">
<button className="w-6 h-6 rounded-full bg-white/10 hover:bg-primary hover:text-white flex items-center justify-center transition-colors">
<Play size={10} className="ml-0.5" />
</button>
<div className="h-1 flex-1 bg-white/10 rounded-full overflow-hidden">
<div className="h-full w-1/3 bg-primary/50"></div>
</div>
<span className="text-[10px] font-mono text-muted-foreground">00:05</span>
</div>
)}
</div>
</div>
))
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50 space-y-2">
<MessageSquare className="w-10 h-10" />
<p className="text-sm"></p>
</div>
)}
</div>
</div>
</Drawer>
)}
</div>
);
};

248
web/pages/LLMLibrary.tsx Normal file
View File

@@ -0,0 +1,248 @@
import React, { useState } from 'react';
import { Search, Filter, Plus, BrainCircuit, Trash2, Key, Settings2, Server, Thermometer } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
import { mockLLMModels } from '../services/mockData';
import { LLMModel } from '../types';
export const LLMLibraryPage: React.FC = () => {
const [models, setModels] = useState<LLMModel[]>(mockLLMModels);
const [searchTerm, setSearchTerm] = useState('');
const [vendorFilter, setVendorFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
// Form State
const [newModel, setNewModel] = useState<Partial<LLMModel>>({
vendor: 'OpenAI Compatible',
type: 'text',
temperature: 0.7
});
const filteredModels = models.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesVendor = vendorFilter === 'all' || m.vendor === vendorFilter;
const matchesType = typeFilter === 'all' || m.type === typeFilter;
return matchesSearch && matchesVendor && matchesType;
});
const handleAddModel = () => {
if (!newModel.name || !newModel.baseUrl || !newModel.apiKey) {
alert("请填写完整信息");
return;
}
const model: LLMModel = {
id: `m_${Date.now()}`,
name: newModel.name,
vendor: newModel.vendor as string,
type: newModel.type as 'text' | 'embedding' | 'rerank',
baseUrl: newModel.baseUrl,
apiKey: newModel.apiKey,
temperature: newModel.type === 'text' ? newModel.temperature : undefined
};
setModels([model, ...models]);
setIsAddModalOpen(false);
setNewModel({ vendor: 'OpenAI Compatible', type: 'text', temperature: 0.7, name: '', baseUrl: '', apiKey: '' });
};
const handleDeleteModel = (id: string) => {
if (confirm('确认删除该模型配置吗?')) {
setModels(prev => prev.filter(m => m.id !== id));
}
};
return (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
<Button onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative col-span-1 md:col-span-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索模型名称..."
className="pl-9 border-0 bg-white/5"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={vendorFilter}
onChange={(e) => setVendorFilter(e.target.value)}
>
<option value="all"></option>
<option value="OpenAI Compatible">OpenAI Compatible</option>
</select>
</div>
<div className="flex items-center space-x-2">
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all"></option>
<option value="text"> (Text)</option>
<option value="embedding"> (Embedding)</option>
<option value="rerank"> (Rerank)</option>
</select>
</div>
</div>
<div className="rounded-md border border-white/5 bg-card/40 backdrop-blur-md overflow-hidden">
<table className="w-full text-sm">
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Base URL</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<tbody>
{filteredModels.map(model => (
<TableRow key={model.id}>
<TableCell className="font-medium text-white flex items-center">
<BrainCircuit className="w-4 h-4 mr-2 text-primary" />
{model.name}
</TableCell>
<TableCell>
<Badge variant="outline">{model.vendor}</Badge>
</TableCell>
<TableCell>
<Badge variant={model.type === 'text' ? 'default' : 'outline'} className={model.type !== 'text' ? 'text-blue-400 border-blue-400/20 bg-blue-400/5' : ''}>
{model.type.toUpperCase()}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{model.baseUrl}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteModel(model.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
{filteredModels.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground"></TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
<Dialog
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
title="添加大模型"
footer={
<>
<Button variant="ghost" onClick={() => setIsAddModalOpen(false)}></Button>
<Button onClick={handleAddModel}></Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Vendor)</label>
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
value={newModel.vendor}
onChange={e => setNewModel({...newModel, vendor: e.target.value})}
>
<option value="OpenAI Compatible">OpenAI Compatible</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Type)</label>
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
{(['text', 'embedding', 'rerank'] as const).map(t => (
<button
key={t}
onClick={() => setNewModel({...newModel, type: t})}
className={`flex-1 flex items-center justify-center py-1.5 text-xs font-bold rounded-md transition-all ${newModel.type === t ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
{t === 'text' && <Settings2 className="w-3 h-3 mr-1.5" />}
{t === 'embedding' && <BrainCircuit className="w-3 h-3 mr-1.5" />}
{t === 'rerank' && <Filter className="w-3 h-3 mr-1.5" />}
{t === 'text' ? '文本' : t === 'embedding' ? '嵌入' : '重排'}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Model Name)</label>
<Input
value={newModel.name}
onChange={e => setNewModel({...newModel, name: e.target.value})}
placeholder="例如: gpt-4o, deepseek-chat"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center">
<Server className="w-3 h-3 mr-1.5" /> Base URL
</label>
<Input
value={newModel.baseUrl}
onChange={e => setNewModel({...newModel, baseUrl: e.target.value})}
placeholder="https://api.openai.com/v1"
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center">
<Key className="w-3 h-3 mr-1.5" /> API Key
</label>
<Input
type="password"
value={newModel.apiKey}
onChange={e => setNewModel({...newModel, apiKey: e.target.value})}
placeholder="sk-..."
className="font-mono text-xs"
/>
</div>
{newModel.type === 'text' && (
<div className="space-y-3 pt-2">
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block flex items-center">
<Thermometer className="w-3 h-3 mr-1.5" /> (Temperature)
</label>
<span className="text-[10px] font-mono text-primary bg-primary/10 px-1.5 py-0.5 rounded">{newModel.temperature}</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={newModel.temperature}
onChange={(e) => setNewModel({...newModel, temperature: parseFloat(e.target.value)})}
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
)}
</div>
</Dialog>
</div>
);
};

191
web/pages/ToolLibrary.tsx Normal file
View File

@@ -0,0 +1,191 @@
import React, { useState } from 'react';
import { Search, Filter, Plus, Wrench, Terminal, Globe, Camera, CameraOff, Image, Images, CloudSun, Calendar, TrendingUp, Coins, Trash2, Edit2, X, Box } from 'lucide-react';
import { Button, Input, Badge, Dialog } from '../components/UI';
import { mockTools } from '../services/mockData';
import { Tool } from '../types';
// Map icon strings to React Nodes
const iconMap: Record<string, React.ReactNode> = {
Camera: <Camera className="w-5 h-5" />,
CameraOff: <CameraOff className="w-5 h-5" />,
Image: <Image className="w-5 h-5" />,
Images: <Images className="w-5 h-5" />,
CloudSun: <CloudSun className="w-5 h-5" />,
Calendar: <Calendar className="w-5 h-5" />,
TrendingUp: <TrendingUp className="w-5 h-5" />,
Coins: <Coins className="w-5 h-5" />,
Terminal: <Terminal className="w-5 h-5" />,
Globe: <Globe className="w-5 h-5" />,
Wrench: <Wrench className="w-5 h-5" />,
};
export const ToolLibraryPage: React.FC = () => {
const [tools, setTools] = useState<Tool[]>(mockTools);
const [searchTerm, setSearchTerm] = useState('');
const [categoryFilter, setCategoryFilter] = useState<'all' | 'system' | 'query'>('all');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
// New Tool Form
const [newToolName, setNewToolName] = useState('');
const [newToolDesc, setNewToolDesc] = useState('');
const [newToolCategory, setNewToolCategory] = useState<'system' | 'query'>('system');
const filteredTools = tools.filter(tool => {
const matchesSearch = tool.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = categoryFilter === 'all' || tool.category === categoryFilter;
return matchesSearch && matchesCategory;
});
const handleAddTool = () => {
if (!newToolName.trim()) return;
const newTool: Tool = {
id: `custom_${Date.now()}`,
name: newToolName,
description: newToolDesc,
category: newToolCategory,
icon: newToolCategory === 'system' ? 'Terminal' : 'Globe',
isCustom: true
};
setTools([...tools, newTool]);
setIsAddModalOpen(false);
setNewToolName('');
setNewToolDesc('');
};
const handleDeleteTool = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (confirm('确认删除该工具吗?')) {
setTools(prev => prev.filter(t => t.id !== id));
}
};
return (
<div className="space-y-6 animate-in fade-in py-4 pb-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight text-white"></h1>
<Button onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 bg-card/50 p-4 rounded-lg border border-white/5 shadow-sm">
<div className="relative col-span-1 md:col-span-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索工具名称..."
className="pl-9 border-0 bg-white/5"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
className="flex h-9 w-full rounded-md border-0 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as any)}
>
<option value="all"></option>
<option value="system"> (System)</option>
<option value="query"> (Query)</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTools.map(tool => (
<div
key={tool.id}
className={`p-5 rounded-xl border transition-all relative group flex items-start space-x-4 bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10 hover:shadow-lg`}
>
<div className={`p-3 rounded-lg shrink-0 transition-colors ${tool.category === 'system' ? 'bg-primary/10 text-primary' : 'bg-blue-500/10 text-blue-400'}`}>
{iconMap[tool.icon] || <Box className="w-5 h-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-base font-bold text-white">{tool.name}</span>
{tool.isCustom && <Badge variant="outline" className="text-[9px] h-4 px-1">CUSTOM</Badge>}
</div>
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className={`text-[10px] border-0 px-0 ${tool.category === 'system' ? 'text-primary' : 'text-blue-400'}`}>
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
</Badge>
<span className="text-[10px] text-muted-foreground font-mono opacity-50">ID: {tool.id}</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
</div>
{tool.isCustom && (
<div className="absolute top-3 right-3 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleDeleteTool(e, tool.id)}
className="p-1.5 rounded-md hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</div>
))}
{filteredTools.length === 0 && (
<div className="col-span-full py-12 flex flex-col items-center justify-center text-muted-foreground opacity-50">
<Wrench className="w-12 h-12 mb-4 stroke-1" />
<p></p>
</div>
)}
</div>
<Dialog
isOpen={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
title="添加自定义工具"
footer={
<>
<Button variant="ghost" onClick={() => setIsAddModalOpen(false)}></Button>
<Button onClick={handleAddTool}></Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<div className="flex bg-white/5 p-1 rounded-lg border border-white/10">
<button
onClick={() => setNewToolCategory('system')}
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${newToolCategory === 'system' ? 'bg-primary text-primary-foreground shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
<Terminal className="w-3.5 h-3.5 mr-2" />
</button>
<button
onClick={() => setNewToolCategory('query')}
className={`flex-1 flex items-center justify-center py-2 text-xs font-bold rounded-md transition-all ${newToolCategory === 'query' ? 'bg-blue-500 text-white shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
<Globe className="w-3.5 h-3.5 mr-2" />
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"></label>
<Input
value={newToolName}
onChange={e => setNewToolName(e.target.value)}
placeholder="例如: 智能家居控制"
autoFocus
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> ( AI )</label>
<textarea
className="flex min-h-[100px] w-full rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
value={newToolDesc}
onChange={e => setNewToolDesc(e.target.value)}
placeholder="描述该工具的功能,以及 AI 应该在什么情况下调用它..."
/>
</div>
</div>
</Dialog>
</div>
);
};