Files
AI-VideoAssistant/web/pages/LLMLibrary.tsx
2026-02-12 19:23:30 +08:00

467 lines
19 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, Select, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge, LibraryPageShell, TableStatusRow, LibraryActionCell } 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 (
<LibraryPageShell
title="模型接入"
primaryAction={(
<Button onClick={() => setIsAddModalOpen(true)} className="shadow-[0_0_15px_rgba(6,182,212,0.4)]">
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
filterBar={(
<>
<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
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
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 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>
<LibraryActionCell
previewAction={(
<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>
)}
editAction={(
<Button variant="ghost" size="icon" onClick={() => setEditingModel(model)} title="编辑模型">
<Pencil className="h-4 w-4" />
</Button>
)}
deleteAction={(
<Button variant="ghost" size="icon" onClick={() => handleDelete(model.id)} className="text-muted-foreground hover:text-destructive transition-colors" title="删除模型">
<Trash2 className="h-4 w-4" />
</Button>
)}
/>
</TableRow>
))}
{!isLoading && filteredModels.length === 0 && <TableStatusRow colSpan={7} text="暂无模型数据" />}
{isLoading && <TableStatusRow colSpan={7} text="加载中..." />}
</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}
/>
</LibraryPageShell>
);
};
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="h-10 border border-white/10 appearance-none cursor-pointer"
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>
);
};