diff --git a/frontend/src/app/config/components/AiConfigTab.tsx b/frontend/src/app/config/components/AiConfigTab.tsx new file mode 100644 index 0000000..36c19c6 --- /dev/null +++ b/frontend/src/app/config/components/AiConfigTab.tsx @@ -0,0 +1,487 @@ +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 new file mode 100644 index 0000000..68cca80 --- /dev/null +++ b/frontend/src/app/config/components/AnalysisConfigTab.tsx @@ -0,0 +1,213 @@ +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 { 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 { ModelSelector } from './ModelSelector'; + +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(); + + 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; + + return ( +
+
+
+

{config.name || moduleId}

+

ID: {moduleId}

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