mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
@@ -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>
|
||||
|
||||
14
frontend/src/components/llm/LLMProviders.tsx
Normal file
14
frontend/src/components/llm/LLMProviders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
frontend/src/components/llm/ModelCombobox.tsx
Normal file
153
frontend/src/components/llm/ModelCombobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/llm/ProviderDialog.tsx
Normal file
171
frontend/src/components/llm/ProviderDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
frontend/src/components/llm/ProviderList.tsx
Normal file
266
frontend/src/components/llm/ProviderList.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
216
frontend/src/components/llm/RoleAssignment.tsx
Normal file
216
frontend/src/components/llm/RoleAssignment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/llm/TestResultDialog.tsx
Normal file
89
frontend/src/components/llm/TestResultDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
frontend/src/services/llmProviderService.ts
Normal file
86
frontend/src/services/llmProviderService.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user