Files
AI-VideoAssistant/web/pages/LLMLibrary.tsx
2026-02-12 18:44:55 +08:00

469 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>('OpenAI Compatible');
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 = 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));
};
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="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></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 === 'rerank'}
title={model.type === 'rerank' ? '暂不支持 rerank 预览' : (model.type === 'embedding' ? '预览 embedding 向量' : '预览模型')}
>
<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>
</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 isEmbeddingModel = model?.type === 'embedding';
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={isEmbeddingModel ? 'embedding 预览无需 system prompt可留空' : '可选系统提示词'}
disabled={isEmbeddingModel}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-widest block">{isEmbeddingModel ? 'Input Text' : '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={isEmbeddingModel ? '输入需要生成向量的文本' : '输入用户消息'}
/>
</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'))} disabled={isEmbeddingModel} />
</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))} disabled={isEmbeddingModel} />
</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>
);
};