feat(ui): add LLM provider management frontend

Add complete Web UI for LLM provider configuration: provider list with
enable/disable toggles, add/edit dialog, connection testing with result
display, role assignment cards, and model combobox with API/recommended/custom
tags. All labels in Chinese. Add description prop to SelectItem for
Radix Select rendering fix. Register route and nav link.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
jeffusion
2026-03-05 00:32:30 +08:00
committed by 路遥知码力
parent 984cf734fe
commit c45cb34a35
10 changed files with 1007 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ import { LoginPage } from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import { RepositoryManager } from './components/RepositoryManager';
import { ConfigManager } from './components/ConfigManager';
import { LLMProviders } from './components/llm/LLMProviders';
import { Toaster } from "@/components/ui/sonner"
function AuthGuard({ children }: { children: React.ReactNode }) {
@@ -46,6 +47,7 @@ function App() {
<Route index element={<Navigate to="/repos" replace />} />
<Route path="repos" element={<RepositoryManager />} />
<Route path="config" element={<ConfigManager />} />
<Route path="llm" element={<LLMProviders />} />
<Route path="*" element={<Navigate to="/repos" replace />} />
</Route>
</Routes>

View File

@@ -0,0 +1,14 @@
import { ProviderList } from './ProviderList';
import { RoleAssignment } from './RoleAssignment';
export function LLMProviders() {
return (
<div className="min-h-screen pb-12">
<div className="max-w-5xl mx-auto space-y-8 mt-6 px-4 md:px-6 lg:px-8">
<ProviderList />
<RoleAssignment />
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState, useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { useQuery } from '@tanstack/react-query';
import { fetchModels, MODEL_SUGGESTIONS } from '@/services/llmProviderService';
import type { ProviderType } from '@/services/llmProviderService';
interface ModelComboboxProps {
providerId?: string | null;
providerType?: ProviderType;
value: string;
onChange: (model: string) => void;
disabled?: boolean;
placeholder?: string;
className?: string;
}
export function ModelCombobox({
providerId,
providerType,
value,
onChange,
disabled,
placeholder = '选择或输入模型...',
className = '',
}: ModelComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const wrapperRef = useRef<HTMLDivElement>(null);
// Sync external value
useEffect(() => {
setInputValue(value);
}, [value]);
const { data: fetchedModels = [], isLoading } = useQuery({
queryKey: ['llm-models', providerId, providerType],
queryFn: () => {
if (providerId) return fetchModels(providerId);
return Promise.resolve([]);
},
enabled: !!providerId,
staleTime: 5 * 60 * 1000,
});
// Build tagged model list: API > suggestions > custom input
const useApiFetched = fetchedModels.length > 0;
const suggestionModels = providerType ? MODEL_SUGGESTIONS[providerType] || [] : [];
type TaggedModel = { name: string; tag: 'API' | '推荐' | '自定义' };
const trimmedInput = inputValue.trim().toLowerCase();
const buildTaggedList = (): TaggedModel[] => {
const result: TaggedModel[] = [];
const seen = new Set<string>();
// API models first
if (useApiFetched) {
for (const m of fetchedModels) {
if (m.toLowerCase().includes(trimmedInput)) {
result.push({ name: m, tag: 'API' });
seen.add(m.toLowerCase());
}
}
}
// Suggestion models (only show when no API results, or as supplement)
if (!useApiFetched) {
for (const m of suggestionModels) {
if (!seen.has(m.toLowerCase()) && m.toLowerCase().includes(trimmedInput)) {
result.push({ name: m, tag: '推荐' });
seen.add(m.toLowerCase());
}
}
}
// Custom input option when no exact match
if (inputValue.trim().length > 0 && !seen.has(trimmedInput)) {
result.push({ name: inputValue.trim(), tag: '自定义' });
}
return result;
};
const taggedModels = buildTaggedList();
const TAG_STYLES: Record<string, string> = {
'API': 'bg-emerald-500/15 text-emerald-400',
'推荐': 'bg-blue-500/15 text-blue-400',
'自定义': 'bg-amber-500/15 text-amber-400',
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
onChange(newValue);
setIsOpen(true);
};
const handleSelect = (model: string) => {
setInputValue(model);
onChange(model);
setIsOpen(false);
};
return (
<div className={`relative ${className}`} ref={wrapperRef}>
<div className="relative">
<Input
value={inputValue}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
disabled={disabled}
placeholder={placeholder}
autoComplete="off"
className="bg-zinc-900 border-white/10 text-white w-full pr-10"
/>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
</div>
{isOpen && !disabled && taggedModels.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-zinc-900 border border-white/10 rounded-lg shadow-xl">
<div className="py-1">
{taggedModels.map((item, idx) => (
<div
key={`${item.tag}-${item.name}-${idx}`}
className="px-3 py-2 text-sm text-zinc-200 hover:bg-white/10 cursor-pointer transition-colors flex items-center justify-between gap-2"
onClick={() => handleSelect(item.name)}
>
<span className="truncate">{item.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 ${TAG_STYLES[item.tag]}`}>{item.tag}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { createProvider, updateProvider, setApiKey } from '@/services/llmProviderService';
import type { ProviderDto, ProviderType } from '@/services/llmProviderService';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ModelCombobox } from './ModelCombobox';
interface ProviderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
provider?: ProviderDto;
}
const TYPE_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
{ value: 'openai_compatible', label: 'OpenAI 兼容', description: '兼容 OpenAI 接口的第三方服务' },
{ value: 'openai_responses', label: 'OpenAI Responses', description: 'OpenAI 官方 Responses API' },
{ value: 'anthropic', label: 'Anthropic', description: 'Anthropic Messages API' },
{ value: 'gemini', label: 'Gemini', description: 'Google Gemini API' },
];
export function ProviderDialog({ open, onOpenChange, provider }: ProviderDialogProps) {
if (!open) return null;
// Inner component mounts fresh each time dialog opens,
// so useState initializers read directly from provider props.
return <ProviderDialogInner onOpenChange={onOpenChange} provider={provider} />;
}
function ProviderDialogInner({ onOpenChange, provider }: Omit<ProviderDialogProps, 'open'>) {
const queryClient = useQueryClient();
const isEdit = !!provider;
const [name, setName] = useState(provider?.name ?? '');
const [type, setType] = useState<ProviderType>(provider?.type ?? 'openai_compatible');
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '');
const [defaultModel, setDefaultModel] = useState(provider?.defaultModel ?? '');
const [apiKey, setApiKeyInput] = useState('');
const saveMutation = useMutation({
mutationFn: async () => {
let savedProvider: ProviderDto;
const payload: Partial<ProviderDto> & { apiKey?: string } = {
name,
type,
baseUrl: baseUrl || null,
defaultModel,
};
if (!isEdit) {
if (apiKey) payload.apiKey = apiKey;
savedProvider = await createProvider(payload);
} else {
savedProvider = await updateProvider(provider.id, {
name,
type,
baseUrl: baseUrl || null,
defaultModel,
});
if (apiKey) {
await setApiKey(provider.id, apiKey);
}
}
return savedProvider;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
toast.success(isEdit ? '提供商已更新' : '提供商已创建');
onOpenChange(false);
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
}
});
const isBaseUrlRequired = type === 'openai_compatible';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name) return toast.error('请输入名称');
if (!defaultModel) return toast.error('请输入默认模型');
if (isBaseUrlRequired && !baseUrl) return toast.error('该类型必须填写 Base URL');
if (!isEdit && !apiKey) return toast.error('创建提供商时必须提供 API Key');
saveMutation.mutate();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md bg-zinc-950 border border-white/10 rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-bold text-zinc-100">{isEdit ? '编辑提供商' : '添加提供商'}</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 flex-1 overflow-y-auto space-y-5">
<div className="space-y-2">
<Label htmlFor="name"> <span className="text-red-500">*</span></Label>
<Input id="name" value={name} onChange={e => setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-zinc-900 border-white/10 text-white" />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={type} onValueChange={(v) => setType(v as ProviderType)}>
<SelectTrigger className="bg-zinc-900 border-white/10 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-white/10 text-white">
{TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value} description={opt.description}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="baseUrl">Base URL {isBaseUrlRequired ? <span className="text-red-500">*</span> : <span className="text-zinc-500">()</span>}</Label>
<Input
id="baseUrl"
value={baseUrl}
onChange={e => setBaseUrl(e.target.value)}
placeholder={isBaseUrlRequired ? "https://api.openai.com/v1" : "留空以使用默认地址"}
autoComplete="off"
className="bg-zinc-900 border-white/10 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultModel"> <span className="text-red-500">*</span></Label>
<ModelCombobox
providerId={provider?.hasKey ? provider.id : null}
providerType={type}
value={defaultModel}
onChange={setDefaultModel}
placeholder="如: gpt-4o"
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key {!isEdit && <span className="text-red-500">*</span>}</Label>
<Input
id="apiKey"
type="password"
value={apiKey}
onChange={e => setApiKeyInput(e.target.value)}
placeholder={isEdit && provider?.hasKey ? '•••••••• (输入以覆盖)' : 'sk-...'}
autoComplete="off"
className="bg-zinc-900 border-white/10 text-white"
/>
</div>
</form>
<div className="px-6 py-4 border-t border-white/10 flex justify-end gap-3 bg-zinc-900/50">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="border-white/10 text-zinc-300 hover:text-white hover:bg-zinc-800">
</Button>
<Button type="submit" onClick={handleSubmit} disabled={saveMutation.isPending} className="bg-primary text-primary-foreground hover:bg-primary/90">
{saveMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow
} from '@/components/ui/table';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { Edit2, Trash2, Play, Plus, Activity } from 'lucide-react';
import {
fetchProviders, updateProvider, deleteProvider, testProvider, fetchRoles
} from '@/services/llmProviderService';
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
import { ProviderDialog } from './ProviderDialog';
import { TestResultDialog } from './TestResultDialog';
const TYPE_LABELS: Record<string, string> = {
openai_compatible: 'OpenAI 兼容',
openai_responses: 'OpenAI Responses',
anthropic: 'Anthropic',
gemini: 'Gemini',
};
const TYPE_COLORS: Record<string, string> = {
openai_compatible: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
openai_responses: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
anthropic: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
gemini: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
};
export function ProviderList() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<ProviderDto | undefined>(undefined);
const [testingId, setTestingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [testProviderName, setTestProviderName] = useState<string>('');
const { data: providers = [], isLoading } = useQuery({
queryKey: ['llm-providers'],
queryFn: fetchProviders,
});
const { data: roles = [] } = useQuery({
queryKey: ['llm-roles'],
queryFn: fetchRoles,
});
const toggleMutation = useMutation({
mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => {
return updateProvider(id, { isEnabled });
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['llm-providers'] });
const previousProviders = queryClient.getQueryData<ProviderDto[]>(['llm-providers']);
queryClient.setQueryData<ProviderDto[]>(['llm-providers'], old =>
old?.map(p => p.id === variables.id ? { ...p, isEnabled: variables.isEnabled } : p)
);
return { previousProviders };
},
onError: (_err, _variables, context) => {
queryClient.setQueryData(['llm-providers'], context?.previousProviders);
toast.error('切换状态失败');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
},
});
const deleteMutation = useMutation({
mutationFn: deleteProvider,
onSuccess: () => {
toast.success('已删除提供商');
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error(`删除失败: ${err?.response?.data?.error || err.message}`);
}
});
const handleToggle = (provider: ProviderDto) => {
toggleMutation.mutate({ id: provider.id, isEnabled: !provider.isEnabled });
};
const handleDelete = (provider: ProviderDto) => {
const boundRoles = roles.filter(r => r.providerId === provider.id);
if (boundRoles.length > 0) {
const roleNames = boundRoles.map(r => r.role).join(', ');
if (!window.confirm(`警告:该提供商已绑定到以下角色 (${roleNames})。\n删除后这些角色将失去提供商配置\n确定要删除吗`)) {
return;
}
} else {
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
return;
}
}
deleteMutation.mutate(provider.id);
};
const handleTest = async (provider: ProviderDto) => {
try {
setTestingId(provider.id);
const result = await testProvider(provider.id);
setTestResult(result);
setTestProviderName(provider.name);
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error('测试请求失败', {
description: err?.response?.data?.error || err.message
});
} finally {
setTestingId(null);
}
};
const openAdd = () => {
setEditingProvider(undefined);
setDialogOpen(true);
};
const openEdit = (provider: ProviderDto) => {
setEditingProvider(provider);
setDialogOpen(true);
};
return (
<>
<Card className="glass-panel border-white/10 shadow-xl overflow-hidden group">
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center border border-primary/20 tech-glow group-hover:bg-primary/20 transition-all duration-300">
<Activity className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
</CardTitle>
<CardDescription className="text-zinc-400">
LLM API 访
</CardDescription>
</div>
</div>
<Button onClick={openAdd} className="bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_15px_rgba(20,184,166,0.3)] transition-all">
<Plus className="w-4 h-4 mr-2" />
</Button>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-zinc-900/50">
<TableRow className="border-white/5 hover:bg-transparent">
<TableHead className="text-zinc-400 font-medium h-12"></TableHead>
<TableHead className="text-zinc-400 font-medium h-12"></TableHead>
<TableHead className="text-zinc-400 font-medium h-12"></TableHead>
<TableHead className="text-zinc-400 font-medium h-12 text-center"></TableHead>
<TableHead className="text-zinc-400 font-medium h-12 text-center"></TableHead>
<TableHead className="text-zinc-400 font-medium h-12 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow className="border-white/5 hover:bg-zinc-900/30">
<TableCell colSpan={6} className="h-24 text-center text-zinc-500">
<div className="flex items-center justify-center gap-2">
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
...
</div>
</TableCell>
</TableRow>
) : providers.length === 0 ? (
<TableRow className="border-white/5 hover:bg-zinc-900/30">
<TableCell colSpan={6} className="h-24 text-center text-zinc-500">
</TableCell>
</TableRow>
) : (
providers.map(provider => (
<TableRow key={provider.id} className="border-white/5 hover:bg-zinc-900/30 transition-colors">
<TableCell className="font-medium text-zinc-200">
{provider.name}
</TableCell>
<TableCell>
<Badge variant="outline" className={`font-normal ${TYPE_COLORS[provider.type] || 'text-zinc-400'}`}>
{TYPE_LABELS[provider.type] || provider.type}
</Badge>
</TableCell>
<TableCell className="text-zinc-300">
<code className="bg-white/5 px-1.5 py-0.5 rounded text-xs text-primary/80">
{provider.defaultModel}
</code>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1.5" title={provider.hasKey ? '已配置 API Key' : '未配置 API Key'}>
<span className={`w-2 h-2 rounded-full ${provider.hasKey ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-zinc-600'}`} />
<span className="text-xs text-zinc-400">{provider.hasKey ? '就绪' : '无 Key'}</span>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
checked={provider.isEnabled}
onCheckedChange={() => handleToggle(provider)}
className="data-[state=checked]:bg-primary"
/>
</TableCell>
<TableCell className="text-right space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleTest(provider)}
disabled={testingId === provider.id || !provider.hasKey}
className="h-8 w-8 text-zinc-400 hover:text-primary hover:bg-primary/10 transition-colors"
title="测试连接"
>
{testingId === provider.id ? (
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(provider)}
className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(provider)}
className="h-8 w-8 text-zinc-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<ProviderDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
provider={editingProvider}
/>
<TestResultDialog
open={!!testResult}
onOpenChange={(open) => !open && setTestResult(null)}
result={testResult}
providerName={testProviderName}
/>
</>
);
}

View File

@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { Save, ShieldCheck } from 'lucide-react';
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
import { ModelCombobox } from './ModelCombobox';
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
legacy: { label: 'Legacy 审查', desc: '基础的单次代码审查模式,速度快但分析较浅' },
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
};
const ROLES = ['legacy', 'planner', 'specialist', 'judge', 'embedding'];
interface RoleState {
providerId: string | null;
model: string;
}
export function RoleAssignment() {
const queryClient = useQueryClient();
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
const { data: providers = [] } = useQuery({
queryKey: ['llm-providers'],
queryFn: fetchProviders,
});
const { data: roles = [], isLoading } = useQuery({
queryKey: ['llm-roles'],
queryFn: fetchRoles,
});
useEffect(() => {
if (roles.length > 0) {
const initial: Record<string, RoleState> = {};
roles.forEach(role => {
initial[role.role] = {
providerId: role.providerId,
model: role.model || '',
};
});
// Fill missing roles
ROLES.forEach(r => {
if (!initial[r]) {
initial[r] = { providerId: null, model: '' };
}
});
setRoleStates(initial);
} else if (!isLoading) {
const initial: Record<string, RoleState> = {};
ROLES.forEach(r => {
initial[r] = { providerId: null, model: '' };
});
setRoleStates(initial);
}
}, [roles, isLoading]);
const saveMutation = useMutation({
mutationFn: async ({ role, providerId, model }: { role: string; providerId: string | null; model: string | null }) => {
return setRole(role, providerId, model);
},
onSuccess: (data) => {
toast.success(`${ROLE_LABELS[data.role]?.label || data.role} 角色配置已保存`);
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
}
});
const handleProviderChange = (role: string, providerId: string) => {
const provider = providers.find(p => p.id === providerId);
setRoleStates(prev => ({
...prev,
[role]: {
providerId,
model: provider?.defaultModel || ''
}
}));
};
const handleModelChange = (role: string, model: string) => {
setRoleStates(prev => ({
...prev,
[role]: { ...prev[role], model }
}));
};
const handleSave = (role: string) => {
const state = roleStates[role];
if (!state.providerId) {
return toast.error('请选择提供商');
}
if (!state.model) {
return toast.error('请输入模型名称');
}
saveMutation.mutate({
role,
providerId: state.providerId,
model: state.model,
});
};
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
return (
<Card className="glass-panel border-white/10 shadow-xl overflow-hidden group">
<CardHeader className="flex flex-row items-center justify-between pb-4 space-y-0 border-b border-white/5 bg-zinc-950/30">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center border border-amber-500/20 group-hover:bg-amber-500/20 transition-all duration-300">
<ShieldCheck className="h-5 w-5 text-amber-400" />
</div>
<div className="space-y-1">
<CardTitle className="text-xl font-bold text-zinc-100 tracking-tight">
</CardTitle>
<CardDescription className="text-zinc-400">
AI
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6 bg-zinc-950/20">
{isLoading ? (
<div className="h-32 flex items-center justify-center text-zinc-500 gap-2">
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
...
</div>
) : (
<div className="divide-y divide-white/5">
{ROLES.map(role => {
const state = roleStates[role] || { providerId: null, model: '' };
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
(roles.find(r => r.role === role)?.model || '') !== state.model;
return (
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-zinc-900/30 transition-colors rounded-lg">
<div className="w-full md:w-1/3 space-y-1.5">
<Label className="text-base font-semibold text-zinc-100">
{ROLE_LABELS[role]?.label || role}
</Label>
<p className="text-sm text-zinc-400 leading-relaxed">
{ROLE_LABELS[role]?.desc}
</p>
</div>
<div className="w-full md:w-2/3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex-1 w-full space-y-1">
<Label className="text-xs text-zinc-400"></Label>
<Select
value={state.providerId || ''}
onValueChange={(v) => handleProviderChange(role, v)}
>
<SelectTrigger className="bg-zinc-900/50 border-white/10 text-white">
<SelectValue placeholder="选择提供商" />
</SelectTrigger>
<SelectContent className="bg-zinc-950 border-white/10 text-white">
{enabledProviders.map(p => (
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-zinc-900 focus:text-primary">
{p.name}
</SelectItem>
))}
{enabledProviders.length === 0 && (
<div className="px-2 py-3 text-xs text-red-400 text-center border-t border-white/5">
</div>
)}
</SelectContent>
</Select>
</div>
<div className="flex-1 w-full space-y-1">
<Label className="text-xs text-zinc-400">使</Label>
<ModelCombobox
providerId={state.providerId}
providerType={providers.find(p => p.id === state.providerId)?.type}
value={state.model}
onChange={(model) => handleModelChange(role, model)}
placeholder="选择或输入模型..."
disabled={!state.providerId}
className="w-full"
/>
</div>
<div className="pt-5 flex-shrink-0">
<Button
size="sm"
onClick={() => handleSave(role)}
disabled={!isDirty || saveMutation.isPending}
variant={isDirty ? 'default' : 'secondary'}
className={`transition-all ${isDirty ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30 hover:bg-amber-500/30' : 'bg-white/5 text-zinc-500 border border-transparent'}`}
>
<Save className="w-4 h-4 mr-1.5" />
{isDirty ? '保存更改' : '已保存'}
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,89 @@
import { Button } from '@/components/ui/button';
import type { TestResult } from '@/services/llmProviderService';
import { CheckCircle2, XCircle } from 'lucide-react';
interface TestResultDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
result: TestResult | null;
providerName: string;
}
export function TestResultDialog({ open, onOpenChange, result, providerName }: TestResultDialogProps) {
if (!open || !result) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-panel w-full max-w-md bg-zinc-950 border border-white/10 rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-bold text-zinc-100"> - {providerName}</h2>
</div>
<div className="p-6 flex-1 overflow-y-auto space-y-5">
{result.success ? (
<div className="space-y-4">
<div className="flex items-center gap-3 text-green-500">
<CheckCircle2 className="w-8 h-8" />
<span className="text-lg font-medium"></span>
</div>
<div className="space-y-2 text-sm text-zinc-300">
{result.latencyMs !== undefined && (
<div className="flex justify-between border-b border-white/5 pb-2">
<span className="text-zinc-500">:</span>
<span>{result.latencyMs} ms</span>
</div>
)}
{result.model && (
<div className="flex justify-between border-b border-white/5 pb-2">
<span className="text-zinc-500">:</span>
<span className="font-mono">{result.model}</span>
</div>
)}
{result.message && (
<div className="space-y-2 pt-2">
<span className="text-zinc-500">AI :</span>
<div className="bg-zinc-900 border border-white/10 rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
{result.message}
</div>
</div>
)}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 text-red-500">
<XCircle className="w-8 h-8" />
<span className="text-lg font-medium"></span>
</div>
<div className="space-y-2 text-sm text-zinc-300">
{result.latencyMs !== undefined && (
<div className="flex justify-between border-b border-white/5 pb-2">
<span className="text-zinc-500">:</span>
<span>{result.latencyMs} ms</span>
</div>
)}
<div className="space-y-2 pt-2">
<span className="text-zinc-500">:</span>
<div className="bg-zinc-900/50 border border-red-500/20 text-red-400 rounded-md p-3 max-h-48 overflow-y-auto whitespace-pre-wrap font-mono text-xs">
{result.error || result.message || '未知错误'}
</div>
</div>
</div>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-white/10 flex justify-end bg-zinc-900/50">
<Button type="button" onClick={() => onOpenChange(false)} className="bg-primary text-primary-foreground hover:bg-primary/90">
</Button>
</div>
</div>
</div>
);
}

View File

@@ -125,8 +125,9 @@ function SelectLabel({
function SelectItem({
className,
children,
description,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
}: React.ComponentProps<typeof SelectPrimitive.Item> & { description?: string }) {
return (
<SelectPrimitive.Item
data-slot="select-item"
@@ -141,7 +142,10 @@ function SelectItem({
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && <span className="text-xs text-muted-foreground">{description}</span>}
</div>
</SelectPrimitive.Item>
)
}

View File

@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, Cpu } from 'lucide-react';
const navItems = [
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
{ path: '/config', label: '配置管理', icon: Sliders },
{ path: '/llm', label: 'LLM 配置', icon: Cpu },
] as const;
export default function DashboardPage() {
@@ -25,6 +26,7 @@ export default function DashboardPage() {
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
const isConfigPage = location.pathname.startsWith('/config');
const isLLMPage = location.pathname.startsWith('/llm');
return (
<div className="flex h-screen w-full overflow-hidden bg-background">
@@ -159,7 +161,7 @@ export default function DashboardPage() {
<main className="flex-1 overflow-y-auto relative">
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03] -z-10"></div>
<div className={`mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 ${isConfigPage ? '' : 'p-4 md:p-6 lg:p-8'}`}>
<div className={`mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isLLMPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
<Outlet />
</div>
</main>

View File

@@ -0,0 +1,86 @@
import api from '@/lib/api';
export type ProviderType = 'openai_compatible' | 'openai_responses' | 'anthropic' | 'gemini';
export interface ProviderDto {
id: string;
name: string;
type: ProviderType;
baseUrl: string | null;
defaultModel: string;
isEnabled: boolean;
hasKey: boolean;
extraConfig: Record<string, unknown>;
createdAt: string;
updatedAt?: string;
}
export interface RoleAssignmentDto {
role: string;
providerId: string | null;
providerName: string | null;
providerType: string | null;
model: string | null;
}
export interface TestResult {
success: boolean;
latencyMs?: number;
model?: string;
message?: string;
error?: string;
}
export const MODEL_SUGGESTIONS: Record<ProviderType, string[]> = {
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
};
export const fetchProviders = async (): Promise<ProviderDto[]> => {
const response = await api.get<ProviderDto[]>('/llm/providers');
return response.data;
};
export const createProvider = async (data: Partial<ProviderDto> & { apiKey?: string }): Promise<ProviderDto> => {
const response = await api.post<ProviderDto>('/llm/providers', data);
return response.data;
};
export const updateProvider = async (id: string, data: Partial<ProviderDto>): Promise<ProviderDto> => {
const response = await api.put<ProviderDto>(`/llm/providers/${id}`, data);
return response.data;
};
export const deleteProvider = async (id: string): Promise<void> => {
await api.delete(`/llm/providers/${id}`);
};
export const setApiKey = async (id: string, apiKey: string): Promise<void> => {
await api.put(`/llm/providers/${id}/key`, { apiKey });
};
export const deleteApiKey = async (id: string): Promise<void> => {
await api.delete(`/llm/providers/${id}/key`);
};
export const fetchRoles = async (): Promise<RoleAssignmentDto[]> => {
const response = await api.get<RoleAssignmentDto[]>('/llm/roles');
return response.data;
};
export const setRole = async (role: string, providerId: string | null, model: string | null): Promise<RoleAssignmentDto> => {
const response = await api.put<RoleAssignmentDto>(`/llm/roles/${role}`, { providerId, model });
return response.data;
};
export const testProvider = async (id: string): Promise<TestResult> => {
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
return response.data;
};
export const fetchModels = async (id: string): Promise<string[]> => {
const response = await api.get<{ models: string[] }>(`/llm/providers/${id}/models`);
return response.data.models;
};