From c45cb34a358393a0b6e0523fd282ee3d49fbff82 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 5 Mar 2026 00:32:30 +0800 Subject: [PATCH] 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) --- frontend/src/App.tsx | 2 + frontend/src/components/llm/LLMProviders.tsx | 14 + frontend/src/components/llm/ModelCombobox.tsx | 153 ++++++++++ .../src/components/llm/ProviderDialog.tsx | 171 +++++++++++ frontend/src/components/llm/ProviderList.tsx | 266 ++++++++++++++++++ .../src/components/llm/RoleAssignment.tsx | 216 ++++++++++++++ .../src/components/llm/TestResultDialog.tsx | 89 ++++++ frontend/src/components/ui/select.tsx | 8 +- frontend/src/pages/DashboardPage.tsx | 6 +- frontend/src/services/llmProviderService.ts | 86 ++++++ 10 files changed, 1007 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/llm/LLMProviders.tsx create mode 100644 frontend/src/components/llm/ModelCombobox.tsx create mode 100644 frontend/src/components/llm/ProviderDialog.tsx create mode 100644 frontend/src/components/llm/ProviderList.tsx create mode 100644 frontend/src/components/llm/RoleAssignment.tsx create mode 100644 frontend/src/components/llm/TestResultDialog.tsx create mode 100644 frontend/src/services/llmProviderService.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b92f966..ad4bed1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/llm/LLMProviders.tsx b/frontend/src/components/llm/LLMProviders.tsx new file mode 100644 index 0000000..312e963 --- /dev/null +++ b/frontend/src/components/llm/LLMProviders.tsx @@ -0,0 +1,14 @@ + +import { ProviderList } from './ProviderList'; +import { RoleAssignment } from './RoleAssignment'; + +export function LLMProviders() { + return ( +
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/llm/ModelCombobox.tsx b/frontend/src/components/llm/ModelCombobox.tsx new file mode 100644 index 0000000..6c89370 --- /dev/null +++ b/frontend/src/components/llm/ModelCombobox.tsx @@ -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(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(); + + // 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 = { + '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) => { + const newValue = e.target.value; + setInputValue(newValue); + onChange(newValue); + setIsOpen(true); + }; + + const handleSelect = (model: string) => { + setInputValue(model); + onChange(model); + setIsOpen(false); + }; + + return ( +
+
+ setIsOpen(true)} + disabled={disabled} + placeholder={placeholder} + autoComplete="off" + className="bg-zinc-900 border-white/10 text-white w-full pr-10" + /> + {isLoading && ( +
+
+
+ )} +
+ + {isOpen && !disabled && taggedModels.length > 0 && ( +
+
+ {taggedModels.map((item, idx) => ( +
handleSelect(item.name)} + > + {item.name} + {item.tag} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/llm/ProviderDialog.tsx b/frontend/src/components/llm/ProviderDialog.tsx new file mode 100644 index 0000000..b576824 --- /dev/null +++ b/frontend/src/components/llm/ProviderDialog.tsx @@ -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 ; +} + +function ProviderDialogInner({ onOpenChange, provider }: Omit) { + const queryClient = useQueryClient(); + const isEdit = !!provider; + + const [name, setName] = useState(provider?.name ?? ''); + const [type, setType] = useState(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 & { 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 ( +
+
+
+

{isEdit ? '编辑提供商' : '添加提供商'}

+
+ +
+
+ + setName(e.target.value)} placeholder="如: DeepSeek" autoComplete="off" className="bg-zinc-900 border-white/10 text-white" /> +
+ +
+ + +
+ +
+ + setBaseUrl(e.target.value)} + placeholder={isBaseUrlRequired ? "https://api.openai.com/v1" : "留空以使用默认地址"} + autoComplete="off" + className="bg-zinc-900 border-white/10 text-white" + /> +
+ +
+ + +
+ +
+ + setApiKeyInput(e.target.value)} + placeholder={isEdit && provider?.hasKey ? '•••••••• (输入以覆盖)' : 'sk-...'} + autoComplete="off" + className="bg-zinc-900 border-white/10 text-white" + /> +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/llm/ProviderList.tsx b/frontend/src/components/llm/ProviderList.tsx new file mode 100644 index 0000000..2105490 --- /dev/null +++ b/frontend/src/components/llm/ProviderList.tsx @@ -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 = { + openai_compatible: 'OpenAI 兼容', + openai_responses: 'OpenAI Responses', + anthropic: 'Anthropic', + gemini: 'Gemini', +}; + +const TYPE_COLORS: Record = { + 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(undefined); + const [testingId, setTestingId] = useState(null); + const [testResult, setTestResult] = useState(null); + const [testProviderName, setTestProviderName] = useState(''); + + 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(['llm-providers']); + queryClient.setQueryData(['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 ( + <> + + +
+
+ +
+
+ + 模型提供商 + + + 管理连接的 LLM API 服务及其访问密钥 + +
+
+ +
+ + + + + + 名称 + 类型 + 默认模型 + 状态 + 启用 + 操作 + + + + {isLoading ? ( + + +
+
+ 加载中... +
+ + + ) : providers.length === 0 ? ( + + + 暂无提供商配置,请点击右上角添加。 + + + ) : ( + providers.map(provider => ( + + + {provider.name} + + + + {TYPE_LABELS[provider.type] || provider.type} + + + + + {provider.defaultModel} + + + +
+ + {provider.hasKey ? '就绪' : '无 Key'} +
+
+ + handleToggle(provider)} + className="data-[state=checked]:bg-primary" + /> + + + + + + +
+ )) + )} + +
+
+
+ + + + !open && setTestResult(null)} + result={testResult} + providerName={testProviderName} + /> + + ); +} diff --git a/frontend/src/components/llm/RoleAssignment.tsx b/frontend/src/components/llm/RoleAssignment.tsx new file mode 100644 index 0000000..8abe3e2 --- /dev/null +++ b/frontend/src/components/llm/RoleAssignment.tsx @@ -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 = { + 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>({}); + + 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 = {}; + 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 = {}; + 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 ( + + +
+
+ +
+
+ + 角色分配 + + + 为 AI 审查系统的不同角色指定提供商和模型 + +
+
+
+ + + {isLoading ? ( +
+
+ 加载角色配置... +
+ ) : ( +
+ {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 ( +
+
+ +

+ {ROLE_LABELS[role]?.desc} +

+
+ +
+
+ + +
+ +
+ + p.id === state.providerId)?.type} + value={state.model} + onChange={(model) => handleModelChange(role, model)} + placeholder="选择或输入模型..." + disabled={!state.providerId} + className="w-full" + /> +
+ +
+ +
+
+
+ ); + })} +
+ )} + + + ); +} diff --git a/frontend/src/components/llm/TestResultDialog.tsx b/frontend/src/components/llm/TestResultDialog.tsx new file mode 100644 index 0000000..4606037 --- /dev/null +++ b/frontend/src/components/llm/TestResultDialog.tsx @@ -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 ( +
+
+
+

测试结果 - {providerName}

+
+ +
+ {result.success ? ( +
+
+ + 连接成功 +
+ +
+ {result.latencyMs !== undefined && ( +
+ 延迟: + {result.latencyMs} ms +
+ )} + + {result.model && ( +
+ 模型: + {result.model} +
+ )} + + {result.message && ( +
+ AI 响应: +
+ {result.message} +
+
+ )} +
+
+ ) : ( +
+
+ + 测试失败 +
+ +
+ {result.latencyMs !== undefined && ( +
+ 延迟: + {result.latencyMs} ms +
+ )} + +
+ 错误: +
+ {result.error || result.message || '未知错误'} +
+
+
+
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index ec23c07..91d39d2 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -125,8 +125,9 @@ function SelectLabel({ function SelectItem({ className, children, + description, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { description?: string }) { return ( - {children} +
+ {children} + {description && {description}} +
) } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index fe7fadc..fac2254 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 (
@@ -159,7 +161,7 @@ export default function DashboardPage() {
-
+
diff --git a/frontend/src/services/llmProviderService.ts b/frontend/src/services/llmProviderService.ts new file mode 100644 index 0000000..4b7118e --- /dev/null +++ b/frontend/src/services/llmProviderService.ts @@ -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; + 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 = { + 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 => { + const response = await api.get('/llm/providers'); + return response.data; +}; + +export const createProvider = async (data: Partial & { apiKey?: string }): Promise => { + const response = await api.post('/llm/providers', data); + return response.data; +}; + +export const updateProvider = async (id: string, data: Partial): Promise => { + const response = await api.put(`/llm/providers/${id}`, data); + return response.data; +}; + +export const deleteProvider = async (id: string): Promise => { + await api.delete(`/llm/providers/${id}`); +}; + +export const setApiKey = async (id: string, apiKey: string): Promise => { + await api.put(`/llm/providers/${id}/key`, { apiKey }); +}; + +export const deleteApiKey = async (id: string): Promise => { + await api.delete(`/llm/providers/${id}/key`); +}; + +export const fetchRoles = async (): Promise => { + const response = await api.get('/llm/roles'); + return response.data; +}; + +export const setRole = async (role: string, providerId: string | null, model: string | null): Promise => { + const response = await api.put(`/llm/roles/${role}`, { providerId, model }); + return response.data; +}; + +export const testProvider = async (id: string): Promise => { + const response = await api.post(`/llm/providers/${id}/test`); + return response.data; +}; + +export const fetchModels = async (id: string): Promise => { + const response = await api.get<{ models: string[] }>(`/llm/providers/${id}/models`); + return response.data.models; +};