diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ad4bed1..5c3715e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +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 { ReviewConfigPage } from './components/ReviewConfigPage'; import { Toaster } from "@/components/ui/sonner" function AuthGuard({ children }: { children: React.ReactNode }) { @@ -47,7 +47,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/ConfigGroupCard.tsx b/frontend/src/components/ConfigGroupCard.tsx index 574fcc9..a002709 100644 --- a/frontend/src/components/ConfigGroupCard.tsx +++ b/frontend/src/components/ConfigGroupCard.tsx @@ -1,5 +1,6 @@ -import type { ConfigGroupDto } from '@/services/configService'; +import React from 'react'; +import type { ConfigGroupDto, ConfigFieldDto } from '@/services/configService'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { ConfigFieldInput } from './ConfigFieldInput'; @@ -24,6 +25,8 @@ interface ConfigGroupCardProps { onFieldChange: (envKey: string, value: any) => void; onReset: (keys: string[]) => void; isResetting: boolean; + /** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */ + renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined; } export function ConfigGroupCard({ @@ -32,6 +35,7 @@ export function ConfigGroupCard({ onFieldChange, onReset, isResetting, + renderField, }: ConfigGroupCardProps) { const hasOverride = group.fields.some((f) => f.source === 'db'); @@ -79,14 +83,18 @@ export function ConfigGroupCard({ )} - {group.fields.map((field) => ( - onFieldChange(field.envKey, val)} - /> - ))} + {group.fields.map((field) => { + const custom = renderField?.(field, localConfig[field.envKey], (val) => onFieldChange(field.envKey, val)); + if (custom !== undefined) return {custom}; + return ( + onFieldChange(field.envKey, val)} + /> + ); + })} ); diff --git a/frontend/src/components/ConfigManager.tsx b/frontend/src/components/ConfigManager.tsx index e9db4ec..4b743df 100644 --- a/frontend/src/components/ConfigManager.tsx +++ b/frontend/src/components/ConfigManager.tsx @@ -8,6 +8,9 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Save, AlertCircle, RotateCcw } from 'lucide-react'; import { toast } from 'sonner'; +/** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */ +const SYSTEM_GROUPS = new Set(['gitea', 'feishu', 'security']); + export function ConfigManager() { const queryClient = useQueryClient(); const [localConfig, setLocalConfig] = useState>({}); @@ -22,7 +25,7 @@ export function ConfigManager() { useEffect(() => { if (data) { const initialState: Record = {}; - data.groups.forEach((group) => { + data.groups.filter((g) => SYSTEM_GROUPS.has(g.key)).forEach((group) => { group.fields.forEach((field) => { if (field.sensitive && field.hasValue) { initialState[field.envKey] = '••••••••'; @@ -96,6 +99,7 @@ export function ConfigManager() { const handleResetAll = () => { if (!data) return; const allOverrideKeys = data.groups + .filter((g) => SYSTEM_GROUPS.has(g.key)) .flatMap((g) => g.fields) .filter((f) => f.source === 'db') .map((f) => f.envKey); @@ -105,7 +109,9 @@ export function ConfigManager() { } }; - const hasOverrides = data?.groups.some((g) => + const visibleGroups = data?.groups.filter((g) => SYSTEM_GROUPS.has(g.key)); + + const hasOverrides = visibleGroups?.some((g) => g.fields.some((f) => f.source === 'db') ) ?? false; @@ -165,7 +171,7 @@ export function ConfigManager() {
- {data?.groups.map((group) => ( + {visibleGroups?.map((group) => ( { + if (f.envKey === ENGINE_FIELD) return false; // rendered separately + switch (engine) { + case 'legacy': + return LEGACY_AGENT_FIELDS.has(f.envKey); + case 'agent': + return LEGACY_AGENT_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey); + case 'codex': + return CODEX_FIELDS.has(f.envKey); + default: + return false; + } + }); +} + +// --------------------------------------------------------------------------- +// Engine selector badges +// --------------------------------------------------------------------------- + +const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [ + { value: 'legacy', label: 'Legacy', description: '传统单次 LLM 审查' }, + { value: 'agent', label: 'Agent', description: '多代理编排深度审查' }, + { value: 'codex', label: 'Codex', description: 'Codex CLI 审查' }, +]; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ReviewConfigPage() { + const queryClient = useQueryClient(); + const [localConfig, setLocalConfig] = useState>({}); + const [hasChanges, setHasChanges] = useState(false); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['config'], + queryFn: fetchConfig, + }); + + // Derived: current engine mode + const engine: EngineMode = useMemo(() => { + const val = localConfig[ENGINE_FIELD]; + if (val === 'agent' || val === 'codex') return val; + return 'legacy'; + }, [localConfig]); + + // Derived: review group and memory group from fetched data + const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]); + const memoryGroup = useMemo(() => data?.groups.find((g) => g.key === 'memory'), [data]); + + // Initialize local config from ALL groups (so save works for review + memory fields) + useEffect(() => { + if (data) { + const initialState: Record = {}; + data.groups + .filter((g) => g.key === 'review' || g.key === 'memory') + .forEach((group) => { + group.fields.forEach((field) => { + if (field.sensitive && field.hasValue) { + initialState[field.envKey] = '••••••••'; + } else if (field.type === 'boolean') { + initialState[field.envKey] = field.value === 'true' || field.value === true; + } else { + initialState[field.envKey] = field.value ?? ''; + } + }); + }); + setLocalConfig(initialState); + setHasChanges(false); + } + }, [data]); + + const saveMutation = useMutation({ + mutationFn: (configData: Record) => updateConfig(configData), + onSuccess: () => { + toast.success('审查配置已保存'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + setHasChanges(false); + }, + onError: (err: Error) => { + toast.error(`保存失败: ${err.message}`); + }, + }); + + const resetMutation = useMutation({ + mutationFn: (keys: string[]) => resetConfig(keys), + onSuccess: () => { + toast.success('配置已重置'); + queryClient.invalidateQueries({ queryKey: ['config'] }); + }, + onError: (err: Error) => { + toast.error(`重置失败: ${err.message}`); + }, + }); + + const handleFieldChange = (envKey: string, value: any) => { + setLocalConfig((prev) => ({ ...prev, [envKey]: value })); + setHasChanges(true); + }; + + const handleSave = () => { + const payload: Record = {}; + for (const [key, val] of Object.entries(localConfig)) { + if (typeof val === 'boolean') { + payload[key] = val ? 'true' : 'false'; + } else { + payload[key] = val === undefined || val === null ? '' : String(val); + } + } + saveMutation.mutate(payload); + }; + + const handleResetGroup = (keys: string[]) => { + if (confirm('确定要重置这些配置到默认值吗?这将立即生效并重载关联设置。')) { + resetMutation.mutate(keys); + } + }; + + const handleResetAll = () => { + const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[]; + const allOverrideKeys = groups + .flatMap((g) => g.fields) + .filter((f) => f.source === 'db') + .map((f) => f.envKey); + if (allOverrideKeys.length === 0) return; + if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) { + resetMutation.mutate(allOverrideKeys); + } + }; + + // Derived: visible fields for the current engine + const visibleReviewFields = useMemo( + () => (reviewGroup ? getVisibleFields(engine, reviewGroup.fields) : []), + [engine, reviewGroup] + ); + + const hasOverrides = useMemo(() => { + const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[]; + return groups.some((g) => g.fields.some((f) => f.source === 'db')); + }, [reviewGroup, memoryGroup]); + + // -- Render states -- + + if (isLoading) { + return ( +
+
+ + +
+ + +
+ ); + } + + if (isError) { + return ( +
+ +
加载配置失败: {error.message}
+
+ ); + } + + // Build a synthetic group for the visible review fields + const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup + ? { + ...reviewGroup, + label: engine === 'codex' ? 'Codex 审查设置' : engine === 'agent' ? 'Agent 审查设置' : 'Legacy 审查设置', + description: + engine === 'codex' + ? 'Codex CLI 审查引擎配置' + : engine === 'agent' + ? '多代理编排审查引擎配置' + : '传统单次 LLM 审查引擎配置', + fields: visibleReviewFields, + } + : null; + + /** Custom field renderer: CODEX_MODEL uses ModelCombobox for tokenlens suggestions. */ + const renderReviewField = engine === 'codex' + ? (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => { + if (field.envKey !== CODEX_MODEL_FIELD) return undefined; + // Replicate ConfigFieldInput layout with ModelCombobox as the input control + const sourceBadge = field.source === 'db' + ? 已配置 + : 默认值; + return ( +
+
+
+
+ + {sourceBadge} +
+
{field.description}
+
+ + {field.envKey} + +
+
+
+ +
+
+
+ ); + } + : undefined; + + return ( +
+ {/* Sticky action bar */} +
+
+ + +
+
+ +
+ {/* Engine Selector Card */} + + +
+
+ +
+
+ 审查引擎 + 选择代码审查引擎模式 +
+
+
+ +
+ {ENGINE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + {/* Engine-specific review config fields */} + {syntheticReviewGroup && syntheticReviewGroup.fields.length > 0 && ( + + )} + + {/* Memory group — agent mode only */} + {engine === 'agent' && memoryGroup && ( + + )} + + {/* LLM Provider config — legacy & agent only */} + {engine !== 'codex' && ( + <> + + + + )} +
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index fac2254..9223012 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,12 +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, Cpu } from 'lucide-react'; +import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch } from 'lucide-react'; const navItems = [ { path: '/repos', label: '仓库管理', icon: FolderGit2 }, - { path: '/config', label: '配置管理', icon: Sliders }, - { path: '/llm', label: 'LLM 配置', icon: Cpu }, + { path: '/config', label: '系统配置', icon: Sliders }, + { path: '/review-config', label: '审查配置', icon: FileSearch }, ] as const; export default function DashboardPage() { @@ -26,7 +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'); + const isReviewConfigPage = location.pathname.startsWith('/review-config'); return (
@@ -161,7 +161,7 @@ export default function DashboardPage() {
-
+