From 75aa5e4addcb00fd088c2b92db041f9ff3e54122 Mon Sep 17 00:00:00 2001 From: "Lv, Qi" Date: Wed, 19 Nov 2025 06:51:50 +0800 Subject: [PATCH] refactor(frontend): split large config page into sub-components --- .../src/app/config/components/AIConfigTab.tsx | 640 ++++++++++++++++++ .../src/app/config/components/AiConfigTab.tsx | 487 ------------- .../config/components/AnalysisConfigTab.tsx | 550 +++++++++------ .../components/DataSourcesConfigTab.tsx | 159 +++++ .../app/config/components/DataSourcesTab.tsx | 178 ----- .../app/config/components/ModelSelector.tsx | 5 +- .../app/config/components/SystemConfigTab.tsx | 193 +++--- frontend/src/app/config/page.tsx | 154 ++++- frontend/src/app/config/utils.ts | 139 ++++ 9 files changed, 1564 insertions(+), 941 deletions(-) create mode 100644 frontend/src/app/config/components/AIConfigTab.tsx delete mode 100644 frontend/src/app/config/components/AiConfigTab.tsx create mode 100644 frontend/src/app/config/components/DataSourcesConfigTab.tsx delete mode 100644 frontend/src/app/config/components/DataSourcesTab.tsx create mode 100644 frontend/src/app/config/utils.ts diff --git a/frontend/src/app/config/components/AIConfigTab.tsx b/frontend/src/app/config/components/AIConfigTab.tsx new file mode 100644 index 0000000..5d3d2b5 --- /dev/null +++ b/frontend/src/app/config/components/AIConfigTab.tsx @@ -0,0 +1,640 @@ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Spinner } from "@/components/ui/spinner"; +import { Separator } from "@/components/ui/separator"; +import { useLlmProviders, updateLlmProviders, discoverProviderModels } from '@/hooks/useApi'; +import type { LlmProvidersConfig, LlmModel } from '@/types'; + +export function AIConfigTab({ + newProviderBaseUrl, + setNewProviderBaseUrl +}: { + newProviderBaseUrl: string; + setNewProviderBaseUrl: (url: string) => void; +}) { + const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders(); + + const [localLlmProviders, setLocalLlmProviders] = useState({}); + const [isSavingLlm, setIsSavingLlm] = useState(false); + const [llmSaveMessage, setLlmSaveMessage] = useState(''); + + // New Provider Form State + const [newProviderId, setNewProviderId] = useState(''); + // newProviderBaseUrl is passed as prop + const [newProviderApiKey, setNewProviderApiKey] = useState(''); + + // Provider Management State + const [pendingApiKeys, setPendingApiKeys] = useState>({}); + const [editingApiKey, setEditingApiKey] = useState>({}); + const [discoverMessages, setDiscoverMessages] = useState>({}); + const [modelPickerOpen, setModelPickerOpen] = useState>({}); + const [candidateModels, setCandidateModels] = useState>({}); + const [modelSearch, setModelSearch] = useState>({}); + const [selectedCandidates, setSelectedCandidates] = useState>>({}); + + // New Model Input State + const [newModelInputs, setNewModelInputs] = useState>({}); + const [newModelNameInputs, setNewModelNameInputs] = useState>({}); + const [newModelMenuOpen, setNewModelMenuOpen] = useState>({}); + const [newModelHighlightIndex, setNewModelHighlightIndex] = useState>({}); + + // Refs for Auto-save + const hasInitializedLlmRef = useRef(false); + const autoSaveTimerRef = useRef | null>(null); + const savingStartedAtRef = useRef(0); + const llmDirtyRef = useRef(false); + const latestServerPayloadRef = useRef(''); + const lastSavedPayloadRef = useRef(''); + + const markLlmDirty = useCallback(() => { + llmDirtyRef.current = true; + }, []); + + const normalizeProviders = useCallback((obj: LlmProvidersConfig) => { + const cloned: LlmProvidersConfig = JSON.parse(JSON.stringify(obj || {})); + Object.keys(cloned).forEach(pid => { + if (!cloned[pid].name || cloned[pid].name.trim().length === 0) { + cloned[pid].name = pid; + } + }); + return cloned; + }, []); + + const buildMergedLlmPayload = useCallback(() => { + const merged: LlmProvidersConfig = normalizeProviders(localLlmProviders || {}); + // 待更新的 API Key 覆盖 + Object.entries(pendingApiKeys || {}).forEach(([pid, key]) => { + if (merged[pid]) merged[pid].api_key = key; + }); + return merged; + }, [localLlmProviders, pendingApiKeys, normalizeProviders]); + + const flushSaveLlmImmediate = useCallback(async () => { + const payload = buildMergedLlmPayload(); + const payloadStr = JSON.stringify(payload); + if (payloadStr === latestServerPayloadRef.current || payloadStr === lastSavedPayloadRef.current) { + return; + } + savingStartedAtRef.current = Date.now(); + setIsSavingLlm(true); + setLlmSaveMessage('自动保存中...'); + try { + const updated = await updateLlmProviders(payload); + await mutateLlmProviders(updated, false); + lastSavedPayloadRef.current = payloadStr; + llmDirtyRef.current = false; + setPendingApiKeys({}); + setEditingApiKey({}); + setLlmSaveMessage('已自动保存'); + } catch (e: any) { + setLlmSaveMessage(`保存失败: ${e?.message || '未知错误'}`); + } finally { + const elapsed = Date.now() - (savingStartedAtRef.current || 0); + const minMs = 1000; + const waitMs = elapsed >= minMs ? 0 : (minMs - elapsed); + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + setIsSavingLlm(false); + setTimeout(() => setLlmSaveMessage(''), 3000); + } + }, [buildMergedLlmPayload, mutateLlmProviders, setPendingApiKeys, setEditingApiKey]); + + // Initialize from Server Data + useEffect(() => { + if (llmProviders) { + setLocalLlmProviders(llmProviders); + const normalized = normalizeProviders(llmProviders); + latestServerPayloadRef.current = JSON.stringify(normalized); + llmDirtyRef.current = false; + } + }, [llmProviders, normalizeProviders]); + + // Auto-save Effect + useEffect(() => { + if (!hasInitializedLlmRef.current) { + hasInitializedLlmRef.current = true; + return; + } + if (!llmDirtyRef.current) { + return; + } + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + autoSaveTimerRef.current = setTimeout(() => { + void flushSaveLlmImmediate(); + }, 500); + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + } + }; + }, [localLlmProviders, pendingApiKeys, flushSaveLlmImmediate]); + + return ( + + +
+ AI Provider 管理 +
+ {isSavingLlm && ( + <> + + 自动保存中... + + )} + {!isSavingLlm && llmSaveMessage && ( + + {llmSaveMessage} + + )} +
+
+ 管理多个 Provider(API Key、Base URL)及其模型清单 +
+ + {/* 新增 Provider */} +
+
+
+ + setNewProviderId(e.target.value)} placeholder="例如: openai_official" /> +
+
+ + setNewProviderBaseUrl(e.target.value)} placeholder="例如: https://api.openai.com/v1" /> +
+
+ + setNewProviderApiKey(e.target.value)} placeholder="输入新Provider的API Key" /> +
+
+
+ +
+
+ + + + {/* Provider 列表 */} +
+ {Object.entries(localLlmProviders || {}).map(([providerId, provider]) => { + const message = discoverMessages[providerId]; + const candidates = candidateModels[providerId] || []; + const query = (modelSearch[providerId] || '').trim().toLowerCase(); + const filteredCandidates = candidates.filter(id => id.toLowerCase().includes(query)); + const selectedMap = selectedCandidates[providerId] || {}; + return ( +
+
+
+
+

{providerId}

+ Base on ID + + {isSavingLlm ? ( + + + 正在保存… + + ) : null} + +
+
{provider.api_base_url}
+
+
+ + + +
+
+ + {message && ( +
{message}
+ )} + + {modelPickerOpen[providerId] && ( +
+
+
+ + setModelSearch(prev => ({ ...prev, [providerId]: e.target.value }))} + placeholder="输入关键字过滤(前缀/包含均可)" + /> +
+
+ + +
+
+ +
+
+ +
+ {filteredCandidates.length === 0 ? ( +
无匹配的候选模型
+ ) : ( + filteredCandidates.map(id => { + const checked = !!selectedMap[id]; + return ( +
+
{id}
+ { + setSelectedCandidates(prev => { + const cur = { ...(prev[providerId] || {}) }; + if (v) cur[id] = true; else delete cur[id]; + return { ...prev, [providerId]: cur }; + }); + }} + /> +
+ ); + }) + )} +
+
+
+ )} + +
+
+ + { + markLlmDirty(); + setLocalLlmProviders(prev => ({ + ...prev, + [providerId]: { ...prev[providerId], api_base_url: e.target.value }, + })); + }} + /> +
+
+ + {!editingApiKey[providerId] ? ( +
+ + {provider.api_key ? '已配置' : '未配置'} + + +
+ ) : ( +
+ { + markLlmDirty(); + setPendingApiKeys(prev => ({ ...prev, [providerId]: e.target.value })); + }} + /> + +
+ )} +
+
+ +
+ +
+ {(provider.models || []).map((m) => ( +
+ { + const v = e.target.value; + markLlmDirty(); + setLocalLlmProviders(prev => ({ + ...prev, + [providerId]: { + ...prev[providerId], + models: (prev[providerId].models || []).map(mm => + mm.model_id === m.model_id ? { ...mm, model_id: v } : mm + ), + }, + })); + }} + /> + { + const v = e.target.value; + markLlmDirty(); + setLocalLlmProviders(prev => ({ + ...prev, + [providerId]: { + ...prev[providerId], + models: (prev[providerId].models || []).map(mm => + mm.model_id === m.model_id ? { ...mm, name: v } : mm + ), + }, + })); + }} + placeholder="可选别名" + /> +
+
+ { + markLlmDirty(); + setLocalLlmProviders(prev => ({ + ...prev, + [providerId]: { + ...prev[providerId], + models: (prev[providerId].models || []).map(mm => + mm.model_id === m.model_id ? { ...mm, is_active: !!checked } : mm + ), + }, + })); + }} + /> + +
+ +
+
+ ))} +
+
+
+
+ { + const v = e.target.value; + setNewModelInputs(prev => ({ ...prev, [providerId]: v })); + setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true })); + setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 })); + }} + onFocus={() => { + setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true })); + }} + onKeyDown={(e) => { + const typedRaw = (newModelInputs[providerId] || ''); + const typed = typedRaw.trim().toLowerCase(); + const existing = new Set((provider.models || []).map(m => m.model_id)); + const list = (candidateModels[providerId] || []) + .filter(id => id.toLowerCase().includes(typed)) + .filter(id => !existing.has(id)) + .slice(0, 50); + const hi = newModelHighlightIndex[providerId] ?? 0; + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (list.length > 0) { + const next = Math.min(hi + 1, list.length - 1); + setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next })); + } + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (list.length > 0) { + const next = Math.max(hi - 1, 0); + setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next })); + } + return; + } + if (e.key === 'Escape') { + setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false })); + return; + } + if (e.key === 'Enter') { + const chosen = list.length > 0 && (newModelMenuOpen[providerId] ?? false) ? list[hi] : (newModelInputs[providerId] || '').trim(); + const id = (chosen || '').trim(); + if (!id) return; + const existsSet = new Set((provider.models || []).map(m => m.model_id)); + if (existsSet.has(id)) return; + markLlmDirty(); + setLocalLlmProviders(prev => ({ + ...prev, + [providerId]: { + ...prev[providerId], + models: [ + ...(prev[providerId].models || []), + { model_id: id, name: (newModelNameInputs[providerId] || '') || null, is_active: true }, + ], + }, + })); + setNewModelInputs(prev => ({ ...prev, [providerId]: '' })); + setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' })); + setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false })); + setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 })); + } + }} + /> + {(() => { + const typed = (newModelInputs[providerId] || '').trim().toLowerCase(); + const existing = new Set((provider.models || []).map(m => m.model_id)); + const list = (candidateModels[providerId] || []) + .filter(id => id.toLowerCase().includes(typed)) + .filter(id => !existing.has(id)) + .slice(0, 50); + const open = !!newModelMenuOpen[providerId]; + if (!typed || list.length === 0 || !open) return null; + const hi = newModelHighlightIndex[providerId] ?? 0; + return ( +
+ {list.map((id, idx) => ( +
{ + setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: idx })); + }} + onMouseDown={(ev) => { + // 防止失焦导致菜单关闭 + ev.preventDefault(); + }} + onClick={() => { + setNewModelInputs(prev => ({ ...prev, [providerId]: id })); + }} + > + {id} +
+ ))} +
+ ); + })()} +
+ { + const v = e.target.value; + setNewModelNameInputs(prev => ({ ...prev, [providerId]: v })); + }} + /> +
+ +
+
+
+ 若无候选,请先点击上方“刷新候选模型”加载后再输入筛选。 +
+
+
+
+ ); + })} +
+
+
+ ); +} + diff --git a/frontend/src/app/config/components/AiConfigTab.tsx b/frontend/src/app/config/components/AiConfigTab.tsx deleted file mode 100644 index 36c19c6..0000000 --- a/frontend/src/app/config/components/AiConfigTab.tsx +++ /dev/null @@ -1,487 +0,0 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Separator } from "@/components/ui/separator"; -import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Spinner } from "@/components/ui/spinner"; -import { useAiConfig } from '../hooks/useAiConfig'; -import { LlmModel } from '@/types'; - -export function AiConfigTab() { - const { - localLlmProviders, setLocalLlmProviders, - isSavingLlm, llmSaveMessage, setLlmSaveMessage, - newProviderId, setNewProviderId, - newProviderBaseUrl, setNewProviderBaseUrl, - newProviderApiKey, setNewProviderApiKey, - pendingApiKeys, setPendingApiKeys, - editingApiKey, setEditingApiKey, - discoverMessages, - modelPickerOpen, setModelPickerOpen, - candidateModels, setCandidateModels, - modelSearch, setModelSearch, - selectedCandidates, setSelectedCandidates, - newModelInputs, setNewModelInputs, - newModelNameInputs, setNewModelNameInputs, - newModelMenuOpen, setNewModelMenuOpen, - newModelHighlightIndex, setNewModelHighlightIndex, - markLlmDirty, - handleAddProvider, - handleDiscoverModels, - flushSaveLlmImmediate, - llmDirtyRef - } = useAiConfig(); - - return ( - - -
- AI Provider 管理 -
- {isSavingLlm && ( - <> - - 自动保存中... - - )} - {!isSavingLlm && llmSaveMessage && ( - - {llmSaveMessage} - - )} -
-
- 管理多个 Provider(API Key、Base URL)及其模型清单 -
- - {/* 新增 Provider */} -
-
-
- - setNewProviderId(e.target.value)} placeholder="例如: openai_official" /> -
-
- - setNewProviderBaseUrl(e.target.value)} placeholder="例如: https://api.openai.com/v1" /> -
-
- - setNewProviderApiKey(e.target.value)} placeholder="输入新Provider的API Key" /> -
-
-
- -
-
- - - - {/* Provider 列表 */} -
- {Object.entries(localLlmProviders || {}).map(([providerId, provider]) => { - const message = discoverMessages[providerId]; - const candidates = candidateModels[providerId] || []; - const query = (modelSearch[providerId] || '').trim().toLowerCase(); - const filteredCandidates = candidates.filter(id => id.toLowerCase().includes(query)); - const selectedMap = selectedCandidates[providerId] || {}; - return ( -
-
-
-
-

{providerId}

- Base on ID - - {isSavingLlm ? ( - - - 正在保存… - - ) : null} - -
-
{provider.api_base_url}
-
-
- - - -
-
- - {message && ( -
{message}
- )} - - {modelPickerOpen[providerId] && ( -
-
-
- - setModelSearch(prev => ({ ...prev, [providerId]: e.target.value }))} - placeholder="输入关键字过滤(前缀/包含均可)" - /> -
-
- - -
-
- -
-
- -
- {filteredCandidates.length === 0 ? ( -
无匹配的候选模型
- ) : ( - filteredCandidates.map(id => { - const checked = !!selectedMap[id]; - return ( -
-
{id}
- { - setSelectedCandidates(prev => { - const cur = { ...(prev[providerId] || {}) }; - if (v) cur[id] = true; else delete cur[id]; - return { ...prev, [providerId]: cur }; - }); - }} - /> -
- ); - }) - )} -
-
-
- )} - -
-
- - { - markLlmDirty(); - setLocalLlmProviders(prev => ({ - ...prev, - [providerId]: { ...prev[providerId], api_base_url: e.target.value }, - })); - }} - /> -
-
- - {!editingApiKey[providerId] ? ( -
- - {provider.api_key ? '已配置' : '未配置'} - - -
- ) : ( -
- { - markLlmDirty(); - setPendingApiKeys(prev => ({ ...prev, [providerId]: e.target.value })); - }} - /> - -
- )} -
-
- -
- -
- {(provider.models || []).map((m) => ( -
- { - const v = e.target.value; - markLlmDirty(); - setLocalLlmProviders(prev => ({ - ...prev, - [providerId]: { - ...prev[providerId], - models: (prev[providerId].models || []).map(mm => - mm.model_id === m.model_id ? { ...mm, model_id: v } : mm - ), - }, - })); - }} - /> - { - const v = e.target.value; - markLlmDirty(); - setLocalLlmProviders(prev => ({ - ...prev, - [providerId]: { - ...prev[providerId], - models: (prev[providerId].models || []).map(mm => - mm.model_id === m.model_id ? { ...mm, name: v } : mm - ), - }, - })); - }} - placeholder="可选别名" - /> -
-
- { - markLlmDirty(); - setLocalLlmProviders(prev => ({ - ...prev, - [providerId]: { - ...prev[providerId], - models: (prev[providerId].models || []).map(mm => - mm.model_id === m.model_id ? { ...mm, is_active: !!checked } : mm - ), - }, - })); - }} - /> - -
- -
-
- ))} -
-
-
-
- { - const v = e.target.value; - setNewModelInputs(prev => ({ ...prev, [providerId]: v })); - setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true })); - setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 })); - }} - onFocus={() => { - setNewModelMenuOpen(prev => ({ ...prev, [providerId]: true })); - }} - onKeyDown={(e) => { - const typedRaw = (newModelInputs[providerId] || ''); - const typed = typedRaw.trim().toLowerCase(); - const existing = new Set((provider.models || []).map(m => m.model_id)); - const list = (candidateModels[providerId] || []) - .filter(id => id.toLowerCase().includes(typed)) - .filter(id => !existing.has(id)) - .slice(0, 50); - const hi = newModelHighlightIndex[providerId] ?? 0; - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (list.length > 0) { - const next = Math.min(hi + 1, list.length - 1); - setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next })); - } - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - if (list.length > 0) { - const next = Math.max(hi - 1, 0); - setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: next })); - } - return; - } - if (e.key === 'Escape') { - setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false })); - return; - } - if (e.key === 'Enter') { - const chosen = list.length > 0 && (newModelMenuOpen[providerId] ?? false) ? list[hi] : (newModelInputs[providerId] || '').trim(); - const id = (chosen || '').trim(); - if (!id) return; - const existsSet = new Set((provider.models || []).map(m => m.model_id)); - if (existsSet.has(id)) return; - markLlmDirty(); - setLocalLlmProviders(prev => ({ - ...prev, - [providerId]: { - ...prev[providerId], - models: [ - ...(prev[providerId].models || []), - { model_id: id, name: (newModelNameInputs[providerId] || '') || null, is_active: true }, - ], - }, - })); - setNewModelInputs(prev => ({ ...prev, [providerId]: '' })); - setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' })); - setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false })); - setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 })); - } - }} - /> - {(() => { - const typed = (newModelInputs[providerId] || '').trim().toLowerCase(); - const existing = new Set((provider.models || []).map(m => m.model_id)); - const list = (candidateModels[providerId] || []) - .filter(id => id.toLowerCase().includes(typed)) - .filter(id => !existing.has(id)) - .slice(0, 50); - const open = !!newModelMenuOpen[providerId]; - if (!typed || list.length === 0 || !open) return null; - const hi = newModelHighlightIndex[providerId] ?? 0; - return ( -
- {list.map((id, idx) => ( -
{ - setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: idx })); - }} - onMouseDown={(ev) => { - // 防止失焦导致菜单关闭 - ev.preventDefault(); - }} - onClick={() => { - setNewModelInputs(prev => ({ ...prev, [providerId]: id })); - }} - > - {id} -
- ))} -
- ); - })()} -
- { - const v = e.target.value; - setNewModelNameInputs(prev => ({ ...prev, [providerId]: v })); - }} - /> -
- -
-
-
- 若无候选,请先点击上方“刷新候选模型”加载后再输入筛选。 -
-
-
-
- ); - })} -
-
-
- ); -} - diff --git a/frontend/src/app/config/components/AnalysisConfigTab.tsx b/frontend/src/app/config/components/AnalysisConfigTab.tsx index 68cca80..9384252 100644 --- a/frontend/src/app/config/components/AnalysisConfigTab.tsx +++ b/frontend/src/app/config/components/AnalysisConfigTab.tsx @@ -1,213 +1,383 @@ + +import { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; -import { useAnalysisConfig } from '../hooks/useAnalysisConfig'; +import { Separator } from "@/components/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; import { ModelSelector } from './ModelSelector'; +import { useAnalysisTemplateSets, updateAnalysisTemplateSets, useLlmProviders } from '@/hooks/useApi'; +import type { AnalysisTemplateSets, LlmModel } from '@/types'; export function AnalysisConfigTab() { - const { - localTemplateSets, - selectedTemplateId, - setSelectedTemplateId, - allModels, - isSavingAnalysis, - analysisSaveMessage, - setAnalysisSaveMessage, - - newTemplateId, setNewTemplateId, - newTemplateName, setNewTemplateName, - isCreatingTemplate, setIsCreatingTemplate, - handleAddTemplate, - handleDeleteTemplate, - - isCreatingModule, setIsCreatingModule, - newModuleId, setNewModuleId, - newModuleName, setNewModuleName, - handleAddNewModule, - handleDeleteModule, - - handleAnalysisChange, - handleSaveAnalysis, - updateAnalysisDependencies - } = useAnalysisConfig(); + // LLM Providers for Model Selector + const { data: llmProviders } = useLlmProviders(); + const allModels = useMemo(() => { + if (!llmProviders) return []; + const models: { providerId: string; providerName: string; model: LlmModel }[] = []; + Object.entries(llmProviders).forEach(([pId, provider]) => { + provider.models.forEach(m => { + if (m.is_active) { + models.push({ + providerId: pId, + providerName: provider.name || pId, + model: m + }); + } + }); + }); + return models; + }, [llmProviders]); - return ( -
- - - 分析模板与模块配置 - 管理不同的分析模板集,并为每个模板集内的模块配置模型、提示词和依赖关系。 - - + // Analysis Config State + const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets(); + const [localTemplateSets, setLocalTemplateSets] = useState({}); + const [selectedTemplateId, setSelectedTemplateId] = useState(null); - {/* --- Level 1: Template Set Management --- */} -
-
- - - - -
+ const [isSavingAnalysis, setIsSavingAnalysis] = useState(false); + const [analysisSaveMessage, setAnalysisSaveMessage] = useState(''); - {isCreatingTemplate && ( -
-

新建分析模板集

-
- setNewTemplateId(e.target.value.replace(/\s/g, ''))} - /> - setNewTemplateName(e.target.value)} - /> -
-
- - -
-
- )} + // State for creating/editing templates and modules + const [newTemplateId, setNewTemplateId] = useState(''); + const [newTemplateName, setNewTemplateName] = useState(''); + const [isCreatingTemplate, setIsCreatingTemplate] = useState(false); + + const [isCreatingModule, setIsCreatingModule] = useState(false); + const [newModuleId, setNewModuleId] = useState(''); + const [newModuleName, setNewModuleName] = useState(''); + + // Initialize local state + useEffect(() => { + if (!initialAnalysisTemplateSets) return; + setLocalTemplateSets(initialAnalysisTemplateSets); + // Only set if null (first load), to avoid resetting user selection on revalidation + setSelectedTemplateId(prev => prev ?? (Object.keys(initialAnalysisTemplateSets)[0] || null)); + }, [initialAnalysisTemplateSets]); + + const handleAnalysisChange = (moduleId: string, field: string, value: any) => { + if (!selectedTemplateId) return; + setLocalTemplateSets(prev => ({ + ...prev, + [selectedTemplateId]: { + ...prev[selectedTemplateId], + modules: { + ...prev[selectedTemplateId].modules, + [moduleId]: { + ...prev[selectedTemplateId].modules[moduleId], + [field]: value, + }, + }, + }, + })); + }; + + const handleSaveAnalysis = async () => { + setIsSavingAnalysis(true); + setAnalysisSaveMessage('保存中...'); + try { + const updated = await updateAnalysisTemplateSets(localTemplateSets); + await mutateAnalysisTemplateSets(updated, false); + setAnalysisSaveMessage('分析配置保存成功!'); + } catch (e: any) { + setAnalysisSaveMessage(`保存失败: ${e.message}`); + } finally { + setIsSavingAnalysis(false); + setTimeout(() => setAnalysisSaveMessage(''), 5000); + } + }; + + const updateAnalysisDependencies = (moduleId: string, dependency: string, checked: boolean) => { + if (!selectedTemplateId) return; + setLocalTemplateSets(prev => { + const currentModule = prev[selectedTemplateId].modules[moduleId]; + const currentDeps = currentModule.dependencies || []; + const newDeps = checked + ? [...currentDeps, dependency] + : currentDeps.filter(d => d !== dependency); + + return { + ...prev, + [selectedTemplateId]: { + ...prev[selectedTemplateId], + modules: { + ...prev[selectedTemplateId].modules, + [moduleId]: { + ...currentModule, + dependencies: [...new Set(newDeps)], + }, + }, + }, + }; + }); + }; + + const handleAddTemplate = () => { + if (!newTemplateId || !newTemplateName) { + setAnalysisSaveMessage('模板 ID 和名称不能为空'); + return; + } + if (localTemplateSets[newTemplateId]) { + setAnalysisSaveMessage('模板 ID 已存在'); + return; + } + const newSet: AnalysisTemplateSets = { + ...localTemplateSets, + [newTemplateId]: { + name: newTemplateName, + modules: {}, + }, + }; + setLocalTemplateSets(newSet); + setSelectedTemplateId(newTemplateId); + setNewTemplateId(''); + setNewTemplateName(''); + setIsCreatingTemplate(false); + // 新建后立即持久化 + (async () => { + setIsSavingAnalysis(true); + setAnalysisSaveMessage('保存中...'); + try { + const updated = await updateAnalysisTemplateSets(newSet); + await mutateAnalysisTemplateSets(updated, false); + setAnalysisSaveMessage('分析配置保存成功!'); + } catch (e: any) { + setAnalysisSaveMessage(`保存失败: ${e?.message || '未知错误'}`); + } finally { + setIsSavingAnalysis(false); + setTimeout(() => setAnalysisSaveMessage(''), 5000); + } + })(); + }; + + const handleDeleteTemplate = () => { + if (!selectedTemplateId || !window.confirm(`确定要删除模板 "${localTemplateSets[selectedTemplateId].name}" 吗?`)) { + return; + } + const newSets = { ...localTemplateSets }; + delete newSets[selectedTemplateId]; + setLocalTemplateSets(newSets); + const firstKey = Object.keys(newSets)[0] || null; + setSelectedTemplateId(firstKey); + }; + + const handleAddNewModule = () => { + if (!selectedTemplateId || !newModuleId || !newModuleName) { + setAnalysisSaveMessage('模块 ID 和名称不能为空'); + return; + } + if (localTemplateSets[selectedTemplateId].modules[newModuleId]) { + setAnalysisSaveMessage('模块 ID 已存在'); + return; + } + setLocalTemplateSets(prev => ({ + ...prev, + [selectedTemplateId]: { + ...prev[selectedTemplateId], + modules: { + ...prev[selectedTemplateId].modules, + [newModuleId]: { + name: newModuleName, + provider_id: '', + model_id: '', + prompt_template: '', + dependencies: [], + } + } + } + })); + setNewModuleId(''); + setNewModuleName(''); + setIsCreatingModule(false); + }; + + const handleDeleteModule = (moduleId: string) => { + if (!selectedTemplateId) return; + setLocalTemplateSets(prev => { + const newModules = { ...prev[selectedTemplateId].modules }; + delete newModules[moduleId]; + return { + ...prev, + [selectedTemplateId]: { + ...prev[selectedTemplateId], + modules: newModules, + }, + }; + }); + }; + + return ( + + + 分析模板与模块配置 + 管理不同的分析模板集,并为每个模板集内的模块配置模型、提示词和依赖关系。 + + + + {/* --- Level 1: Template Set Management --- */} +
+
+ + + + +
+ + {isCreatingTemplate && ( +
+

新建分析模板集

+
+ setNewTemplateId(e.target.value.replace(/\s/g, ''))} + /> + setNewTemplateName(e.target.value)} + />
+
+ + +
+
+ )} +
- + - {/* --- Level 2: Module Management (within selected template) --- */} - {selectedTemplateId && localTemplateSets[selectedTemplateId] ? ( -
- {Object.entries(localTemplateSets[selectedTemplateId].modules).map(([moduleId, config]) => { - const allModulesInSet = localTemplateSets[selectedTemplateId]!.modules; + {/* --- Level 2: Module Management (within selected template) --- */} + {selectedTemplateId && localTemplateSets[selectedTemplateId] ? ( +
+ {Object.entries(localTemplateSets[selectedTemplateId].modules).map(([moduleId, config]) => { + const allModulesInSet = localTemplateSets[selectedTemplateId].modules; - return ( -
-
-
-

{config.name || moduleId}

-

ID: {moduleId}

-
- -
- -
- - { - handleAnalysisChange(moduleId, 'provider_id', pId); - handleAnalysisChange(moduleId, 'model_id', mId); - }} - allModels={allModels} - /> -
- -
- -