Files
AI-VideoAssistant/web/pages/LLMLibrary.tsx
2026-02-08 23:55:40 +08:00

468 lines
20 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { Search, Filter, Plus, BrainCircuit, Trash2, Key, Settings2, Server, Thermometer, Pencil, Play } from 'lucide-react';
import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI';
import { LLMModel } from '../types';
import { createLLMModel, deleteLLMModel, fetchLLMModels, previewLLMModel, updateLLMModel } from '../services/backendApi';
const maskApiKey = (key?: string) => {
if (!key) return '********';
if (key.length < 8) return '********';
return `${key.slice(0, 3)}****${key.slice(-4)}`;
};
export const LLMLibraryPage: React.FC = () => {
const [models, setModels] = useState<LLMModel[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [vendorFilter, setVendorFilter] = useState<string>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingModel, setEditingModel] = useState<LLMModel | null>(null);
const [previewingModel, setPreviewingModel] = useState<LLMModel | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const load = async () => {
setIsLoading(true);
try {
setModels(await fetchLLMModels());
} catch (error) {
console.error(error);
setModels([]);
} finally {
setIsLoading(false);
}
};
load();
}, []);
const filteredModels = models.filter((m) => {
const q = searchTerm.toLowerCase();
const matchesSearch =
m.name.toLowerCase().includes(q) ||
(m.modelName || '').toLowerCase().includes(q) ||
(m.baseUrl || '').toLowerCase().includes(q);
const matchesVendor = vendorFilter === 'all' || m.vendor === vendorFilter;
const matchesType = typeFilter === 'all' || m.type === typeFilter;
return matchesSearch && matchesVendor && matchesType;
});
const handleCreate = async (data: Partial<LLMModel>) => {
const created = await createLLMModel(data);
setModels((prev) => [created, ...prev]);
setIsAddModalOpen(false);
};
const handleUpdate = async (id: string, data: Partial<LLMModel>) => {
const updated = await updateLLMModel(id, data);
setModels((prev) => prev.map((item) => (item.id === id ? updated : item)));
setEditingModel(null);
};
const handleDelete = async (id: string) => {
if (!confirm('确认删除该模型配置吗?')) return;
await deleteLLMModel(id);
setModels((prev) => prev.filter((item) => item.id !== id));
};
const vendorOptions = Array.from(new Set(models.map((m) => m.vendor).filter(Boolean)));
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="搜索模型名称/Model Name..."
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>
{vendorOptions.map((vendor) => (
<option key={vendor} value={vendor}>{vendor}</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></TableHead>
<TableHead>Base URL</TableHead>
<TableHead>API Key</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<tbody>
{!isLoading && 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.modelName || '-'}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground max-w-[240px] truncate">{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={() => setPreviewingModel(model)} disabled={model.type !== 'text'} title={model.type !== 'text' ? '仅 text 模型可预览' : '预览模型'}>
<Play className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setEditingModel(model)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(model.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
{!isLoading && filteredModels.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground"></TableCell>
</TableRow>
)}
{isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">...</TableCell>
</TableRow>
)}
</tbody>
</table>
</div>
<LLMModelModal isOpen={isAddModalOpen} onClose={() => setIsAddModalOpen(false)} onSubmit={handleCreate} />
<LLMModelModal
isOpen={!!editingModel}
onClose={() => setEditingModel(null)}
onSubmit={(data) => handleUpdate(editingModel!.id, data)}
initialModel={editingModel || undefined}
/>
<LLMPreviewModal
isOpen={!!previewingModel}
onClose={() => setPreviewingModel(null)}
model={previewingModel}
/>
</div>
);
};
const LLMModelModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSubmit: (model: Partial<LLMModel>) => Promise<void>;
initialModel?: LLMModel;
}> = ({ isOpen, onClose, onSubmit, initialModel }) => {
const [name, setName] = useState('');
const [vendor, setVendor] = useState('OpenAI Compatible');
const [type, setType] = useState<'text' | 'embedding' | 'rerank'>('text');
const [modelName, setModelName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [temperature, setTemperature] = useState(0.7);
const [contextLength, setContextLength] = useState(8192);
const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
if (initialModel) {
setName(initialModel.name || '');
setVendor(initialModel.vendor || 'OpenAI Compatible');
setType(initialModel.type || 'text');
setModelName(initialModel.modelName || '');
setBaseUrl(initialModel.baseUrl || '');
setApiKey(initialModel.apiKey || '');
setTemperature(initialModel.temperature ?? 0.7);
setContextLength(initialModel.contextLength ?? 8192);
setEnabled(initialModel.enabled ?? true);
return;
}
setName('');
setVendor('OpenAI Compatible');
setType('text');
setModelName('');
setBaseUrl('');
setApiKey('');
setTemperature(0.7);
setContextLength(8192);
setEnabled(true);
}, [initialModel, isOpen]);
const handleSubmit = async () => {
if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
alert('请填写完整信息');
return;
}
try {
setSaving(true);
await onSubmit({
name: name.trim(),
vendor: vendor.trim(),
type,
modelName: modelName.trim() || undefined,
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
temperature: type === 'text' ? temperature : undefined,
contextLength: contextLength > 0 ? contextLength : undefined,
enabled,
});
} catch (error: any) {
alert(error?.message || '保存失败');
} finally {
setSaving(false);
}
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={initialModel ? '编辑大模型' : '添加大模型'}
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={saving}>{saving ? '保存中...' : (initialModel ? '保存修改' : '确认添加')}</Button>
</>
}
>
<div className="space-y-4 max-h-[75vh] overflow-y-auto px-1 custom-scrollbar">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (Vendor)</label>
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-foreground appearance-none cursor-pointer [&>option]:bg-card"
value={vendor}
onChange={(e) => setVendor(e.target.value)}
>
<option value="OpenAI Compatible">OpenAI Compatible</option>
<option value="OpenAI">OpenAI</option>
<option value="SiliconFlow">SiliconFlow</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={() => setType(t)}
className={`flex-1 flex items-center justify-center py-1.5 text-xs font-bold rounded-md transition-all ${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"> (Display Name)</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="例如: GPT4o-Prod" />
</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={modelName} onChange={(e) => setModelName(e.target.value)} placeholder="例如: gpt-4o-mini" />
</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={baseUrl} onChange={(e) => setBaseUrl(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={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..." className="font-mono text-xs" />
</div>
{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">{temperature.toFixed(1)}</span>
</div>
<input
type="range"
min="0"
max="2"
step="0.1"
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value))}
className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
)}
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block"> (context_length)</label>
<Input type="number" min={1} value={contextLength} onChange={(e) => setContextLength(parseInt(e.target.value || '0', 10))} />
</div>
<label className="flex items-center space-x-2 text-xs text-muted-foreground">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span></span>
</label>
</div>
</Dialog>
);
};
const LLMPreviewModal: React.FC<{
isOpen: boolean;
onClose: () => void;
model: LLMModel | null;
}> = ({ isOpen, onClose, model }) => {
const [systemPrompt, setSystemPrompt] = useState('You are a concise helpful assistant.');
const [message, setMessage] = useState('Hello, please introduce yourself in one sentence.');
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(256);
const [reply, setReply] = useState('');
const [latency, setLatency] = useState<number | null>(null);
const [usage, setUsage] = useState<Record<string, number> | null>(null);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
if (!isOpen) return;
setReply('');
setLatency(null);
setUsage(null);
setTemperature(model?.temperature ?? 0.7);
}, [isOpen, model]);
const runPreview = async () => {
if (!model?.id) return;
if (!message.trim()) {
alert('请输入测试消息');
return;
}
try {
setIsRunning(true);
const result = await previewLLMModel(model.id, {
message,
system_prompt: systemPrompt || undefined,
max_tokens: maxTokens,
temperature,
});
setReply(result.reply || result.error || '无返回内容');
setLatency(result.latency_ms ?? null);
setUsage(result.usage || null);
} catch (error: any) {
alert(error?.message || '预览失败');
} finally {
setIsRunning(false);
}
};
return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={`预览模型: ${model?.name || ''}`}
footer={
<>
<Button variant="ghost" onClick={onClose}></Button>
<Button onClick={runPreview} disabled={isRunning}>{isRunning ? '请求中...' : '开始预览'}</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">System Prompt</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="flex min-h-[70px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
placeholder="可选系统提示词"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">User Message</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="flex min-h-[90px] w-full rounded-md border-0 bg-white/5 px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 text-white"
placeholder="输入用户消息"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Temperature</label>
<Input type="number" min={0} max={2} step={0.1} value={temperature} onChange={(e) => setTemperature(parseFloat(e.target.value || '0'))} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">Max Tokens</label>
<Input type="number" min={1} value={maxTokens} onChange={(e) => setMaxTokens(parseInt(e.target.value || '1', 10))} />
</div>
</div>
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3 space-y-2">
<div className="flex items-center justify-between text-xs text-primary">
<span></span>
<span>{latency !== null ? `Latency: ${latency}ms` : ''}</span>
</div>
<textarea
readOnly
value={reply}
className="flex min-h-[140px] w-full rounded-md border-0 bg-black/20 px-3 py-2 text-sm shadow-sm text-white"
placeholder="回复会显示在这里"
/>
{usage && (
<div className="text-xs text-muted-foreground font-mono">
prompt: {usage.prompt_tokens ?? '-'} | completion: {usage.completion_tokens ?? '-'} | total: {usage.total_tokens ?? '-'}
</div>
)}
</div>
</div>
</Dialog>
);
};