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 */}
+
+
+
+
+
+
+
+
+
+ {/* 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}
+ />
+
+
+
+
+
+
+
+
+
+ {Object.keys(allModulesInSet)
+ .filter(id => id !== moduleId)
+ .map(depId => (
+
+ updateAnalysisDependencies(moduleId, depId, !!checked)}
+ />
+
+
+ ))}
+
+
+
+ );
+ })}
+
+ {isCreatingModule && (
+
+
在 "{localTemplateSets[selectedTemplateId].name}" 中新增分析模块
+
+
+
+
+
+
+ )}
+
+
+
+
+ {analysisSaveMessage && (
+
+ {analysisSaveMessage}
+
+ )}
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
diff --git a/frontend/src/app/config/components/DataSourcesTab.tsx b/frontend/src/app/config/components/DataSourcesTab.tsx
new file mode 100644
index 0000000..13c010f
--- /dev/null
+++ b/frontend/src/app/config/components/DataSourcesTab.tsx
@@ -0,0 +1,178 @@
+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 { useDataSourcesConfigLogic } from '../hooks/useDataSourcesConfigLogic';
+import { formatDetailsToYaml } from '../utils/yaml-helper';
+import { DataSourceProvider, DataSourceConfig } from '@/types';
+
+export function DataSourcesTab() {
+ const {
+ localDataSources,
+ setLocalDataSources,
+ dsLoading,
+ dsError,
+ saving,
+ saveMessage,
+ testResults,
+ handleSave,
+ handleReset,
+ handleTest,
+ defaultUrls
+ } = useDataSourcesConfigLogic();
+
+ const handleTestTushare = () => {
+ const cfg = localDataSources['tushare'];
+ handleTest('tushare', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
+ };
+
+ const handleTestFinnhub = () => {
+ const cfg = localDataSources['finnhub'];
+ handleTest('finnhub', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
+ };
+
+ const handleTestAlphaVantage = () => {
+ const cfg = localDataSources['alphavantage'];
+ handleTest('alphavantage', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
+ };
+
+ return (
+
+
+
+ 数据源配置
+ 外部数据源 API 设置
+
+
+ {dsLoading && 加载数据源配置...
}
+ {dsError && 数据源配置加载失败: {String(dsError)}
}
+ {!dsLoading && !dsError && (
+
+ {(['tushare','finnhub','alphavantage','yfinance'] as DataSourceProvider[]).map((providerKey) => {
+ const item: DataSourceConfig = localDataSources[providerKey] || {
+ provider: providerKey,
+ api_key: '',
+ api_url: '',
+ enabled: false,
+ };
+ return (
+
+
+
+
+
+ {providerKey === 'tushare' && '中国市场数据源'}
+ {providerKey === 'finnhub' && '全球市场数据源'}
+ {providerKey === 'alphavantage' && '全球市场数据源(MCP桥接)'}
+ {providerKey === 'yfinance' && '雅虎财经数据源'}
+
+
+
+ {
+ setLocalDataSources((prev) => ({
+ ...prev,
+ [providerKey]: { ...item, enabled: !!checked, provider: providerKey },
+ }));
+ }}
+ />
+
+
+
+
+
+ {providerKey === 'tushare' && (
+
+ )}
+ {providerKey === 'finnhub' && (
+
+ )}
+ {providerKey === 'alphavantage' && (
+
+ )}
+
+ {testResults[providerKey] ? (() => {
+ const r = testResults[providerKey]!;
+ if (r.success) {
+ return (
+
+ {r.summary}
+
+ );
+ }
+ return (
+
+
测试失败
+ {r.details ? (
+
+ 查看详细错误(YAML)
+
+{formatDetailsToYaml(r.details)}
+
+
+ ) : null}
+
+ );
+ })() : null}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {saveMessage && (
+
+ {saveMessage}
+
+ )}
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/app/config/components/ModelSelector.tsx b/frontend/src/app/config/components/ModelSelector.tsx
new file mode 100644
index 0000000..b6ff0a2
--- /dev/null
+++ b/frontend/src/app/config/components/ModelSelector.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { useState } from 'react';
+import { Button } from "@/components/ui/button";
+import { Check, ChevronsUpDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import type { LlmModel } from '@/types';
+
+export interface ModelSelectorProps {
+ value: { providerId: string; modelId: string };
+ onChange: (providerId: string, modelId: string) => void;
+ allModels: { providerId: string; providerName: string; model: LlmModel }[];
+}
+
+export function ModelSelector({ value, onChange, allModels }: ModelSelectorProps) {
+ const [open, setOpen] = useState(false);
+
+ const selectedModel = allModels.find(
+ (m) => m.providerId === value.providerId && m.model.model_id === value.modelId
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ 未找到模型。
+
+ {allModels.map((item) => (
+ {
+ onChange(item.providerId, item.model.model_id);
+ setOpen(false);
+ }}
+ >
+
+ {item.model.name || item.model.model_id}
+
+ ({item.providerName})
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/src/app/config/components/SystemConfigTab.tsx b/frontend/src/app/config/components/SystemConfigTab.tsx
new file mode 100644
index 0000000..b6aa51b
--- /dev/null
+++ b/frontend/src/app/config/components/SystemConfigTab.tsx
@@ -0,0 +1,87 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { useSystemConfig } from '../hooks/useSystemConfig';
+
+interface SystemConfigTabProps {
+ onImportSuccess?: (config: any) => void;
+}
+
+export function SystemConfigTab({ onImportSuccess }: SystemConfigTabProps) {
+ const { config, saveMessage, handleExportConfig, handleImportConfig } = useSystemConfig();
+
+ return (
+
+
+
+ 系统信息
+ 当前系统状态和配置概览
+
+
+
+
+
+
+ {config?.new_api?.api_key ? '已配置' : '未配置'}
+
+
+
+
+
+ {config?.data_sources?.tushare?.api_key ? '已配置' : '未配置'}
+
+
+
+
+
+ {config?.data_sources?.finnhub?.api_key ? '已配置' : '未配置'}
+
+
+
+
+
+
+
+
+ 配置管理
+ 导入、导出和备份配置
+
+
+
+
+
+ handleImportConfig(e, onImportSuccess)}
+ className="hidden"
+ id="import-config"
+ />
+
+
+
+
+
• 导出配置将下载当前所有配置的备份文件
+
• 导入配置将加载备份文件中的设置(不包含敏感信息)
+
• 建议定期备份配置以防数据丢失
+
+ {saveMessage && (
+
+ {saveMessage}
+
+ )}
+
+
+
+ );
+}
+
diff --git a/frontend/src/app/config/hooks/useAiConfig.ts b/frontend/src/app/config/hooks/useAiConfig.ts
new file mode 100644
index 0000000..61eee35
--- /dev/null
+++ b/frontend/src/app/config/hooks/useAiConfig.ts
@@ -0,0 +1,208 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import {
+ useLlmProviders,
+ updateLlmProviders,
+ discoverProviderModels
+} from '@/hooks/useApi';
+import { LlmProvidersConfig, LlmModel } from '@/types';
+
+export function useAiConfig() {
+ const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders();
+ const [localLlmProviders, setLocalLlmProviders] = useState({});
+
+ const [isSavingLlm, setIsSavingLlm] = useState(false);
+ const [llmSaveMessage, setLlmSaveMessage] = useState('');
+
+ // New Provider Form
+ const [newProviderId, setNewProviderId] = useState('');
+ const [newProviderBaseUrl, setNewProviderBaseUrl] = useState('');
+ const [newProviderApiKey, setNewProviderApiKey] = useState('');
+
+ // Provider states
+ const [pendingApiKeys, setPendingApiKeys] = useState>({});
+ const [editingApiKey, setEditingApiKey] = useState>({});
+ const [discoverMessages, setDiscoverMessages] = useState>({});
+
+ // Model management states
+ const [modelPickerOpen, setModelPickerOpen] = useState>({});
+ const [candidateModels, setCandidateModels] = useState>({});
+ const [modelSearch, setModelSearch] = useState>({});
+ const [selectedCandidates, setSelectedCandidates] = useState>>({});
+
+ // New model manual input states
+ const [newModelInputs, setNewModelInputs] = useState>({});
+ const [newModelNameInputs, setNewModelNameInputs] = useState>({});
+ const [newModelMenuOpen, setNewModelMenuOpen] = useState>({});
+ const [newModelHighlightIndex, setNewModelHighlightIndex] = useState>({});
+
+ // Auto-save refs
+ 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]);
+
+ // Sync with server data initially
+ 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]);
+
+ // Actions
+ const handleAddProvider = () => {
+ if (!newProviderId || !newProviderBaseUrl || !newProviderApiKey) {
+ setLlmSaveMessage('请完整填写 Provider 信息');
+ setTimeout(() => setLlmSaveMessage(''), 5000);
+ return;
+ }
+ if (localLlmProviders[newProviderId]) {
+ setLlmSaveMessage('Provider ID 已存在');
+ setTimeout(() => setLlmSaveMessage(''), 5000);
+ return;
+ }
+ markLlmDirty();
+ setLocalLlmProviders(prev => ({
+ ...prev,
+ [newProviderId]: {
+ name: newProviderId,
+ api_base_url: newProviderBaseUrl,
+ api_key: newProviderApiKey,
+ models: [],
+ },
+ }));
+ setNewProviderId('');
+ setNewProviderBaseUrl('');
+ setNewProviderApiKey('');
+ };
+
+ const handleDiscoverModels = async (providerId: string) => {
+ try {
+ // 刷新前强制保存,确保后端使用最新配置
+ if (llmDirtyRef.current) {
+ await flushSaveLlmImmediate();
+ }
+ const resp = await discoverProviderModels(providerId);
+ const data = Array.isArray(resp?.data) ? resp.data : null;
+ if (!data) {
+ setDiscoverMessages(prev => ({ ...prev, [providerId]: '候选加载失败:不支持的响应结构' }));
+ setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
+ return;
+ }
+ const discovered: string[] = data
+ .map((x: any) => (typeof x?.id === 'string' ? x.id : null))
+ .filter((x: any) => typeof x === 'string');
+ setCandidateModels(prev => ({ ...prev, [providerId]: discovered }));
+ setDiscoverMessages(prev => ({ ...prev, [providerId]: `已加载候选模型 ${discovered.length} 个` }));
+ setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
+ } catch (e: any) {
+ setDiscoverMessages(prev => ({ ...prev, [providerId]: `加载失败:${e?.message || '未知错误'}` }));
+ setTimeout(() => setDiscoverMessages(prev => ({ ...prev, [providerId]: '' })), 5000);
+ }
+ };
+
+ return {
+ 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
+ };
+}
+
diff --git a/frontend/src/app/config/hooks/useAnalysisConfig.ts b/frontend/src/app/config/hooks/useAnalysisConfig.ts
new file mode 100644
index 0000000..837ef9d
--- /dev/null
+++ b/frontend/src/app/config/hooks/useAnalysisConfig.ts
@@ -0,0 +1,228 @@
+import { useState, useEffect, useMemo } from 'react';
+import {
+ useAnalysisTemplateSets,
+ updateAnalysisTemplateSets,
+ useLlmProviders
+} from '@/hooks/useApi';
+import { AnalysisTemplateSets, LlmModel } from '@/types';
+
+export function useAnalysisConfig() {
+ const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets();
+ const { data: llmProviders } = useLlmProviders();
+
+ const [localTemplateSets, setLocalTemplateSets] = useState({});
+ const [selectedTemplateId, setSelectedTemplateId] = useState(null);
+
+ const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
+ const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
+
+ 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('');
+
+ 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]);
+
+ useEffect(() => {
+ if (!initialAnalysisTemplateSets) return;
+ setLocalTemplateSets(initialAnalysisTemplateSets);
+ // 仅在未选择时,从后端数据中选择第一个模板;避免覆盖本地新增的选择与状态
+ 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 = async () => {
+ 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);
+
+ // 新建后立即持久化
+ 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 || !localTemplateSets[selectedTemplateId]) return;
+ if (!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 {
+ localTemplateSets,
+ selectedTemplateId,
+ setSelectedTemplateId,
+ allModels,
+ isSavingAnalysis,
+ analysisSaveMessage,
+ setAnalysisSaveMessage,
+ // Template creation
+ newTemplateId, setNewTemplateId,
+ newTemplateName, setNewTemplateName,
+ isCreatingTemplate, setIsCreatingTemplate,
+ handleAddTemplate,
+ handleDeleteTemplate,
+ // Module creation
+ isCreatingModule, setIsCreatingModule,
+ newModuleId, setNewModuleId,
+ newModuleName, setNewModuleName,
+ handleAddNewModule,
+ handleDeleteModule,
+ // Actions
+ handleAnalysisChange,
+ handleSaveAnalysis,
+ updateAnalysisDependencies
+ };
+}
+
diff --git a/frontend/src/app/config/hooks/useDataSourcesConfigLogic.ts b/frontend/src/app/config/hooks/useDataSourcesConfigLogic.ts
new file mode 100644
index 0000000..736acb8
--- /dev/null
+++ b/frontend/src/app/config/hooks/useDataSourcesConfigLogic.ts
@@ -0,0 +1,94 @@
+import { useState, useEffect } from 'react';
+import {
+ useDataSourcesConfig as useDataSourcesConfigApi,
+ updateDataSourcesConfig,
+ testConfig
+} from '@/hooks/useApi';
+import { DataSourcesConfig, DataSourceProvider, DataSourceConfig } from '@/types';
+
+const defaultUrls: Partial> = {
+ tushare: 'http://api.tushare.pro',
+ finnhub: 'https://finnhub.io/api/v1',
+ alphavantage: 'https://mcp.alphavantage.co/mcp',
+};
+
+export function useDataSourcesConfigLogic() {
+ const { data: initialDataSources, error: dsError, isLoading: dsLoading, mutate: mutateDataSources } = useDataSourcesConfigApi();
+ const [localDataSources, setLocalDataSources] = useState({});
+ const [saving, setSaving] = useState(false);
+ const [saveMessage, setSaveMessage] = useState('');
+ const [testResults, setTestResults] = useState>({});
+
+ useEffect(() => {
+ if (initialDataSources) {
+ setLocalDataSources(initialDataSources);
+ }
+ }, [initialDataSources]);
+
+ const handleSave = async () => {
+ setSaving(true);
+ setSaveMessage('保存中...');
+
+ try {
+ if (initialDataSources) {
+ const finalDataSources = JSON.parse(JSON.stringify(localDataSources));
+ for (const key in finalDataSources) {
+ const providerKey = key as DataSourceProvider;
+ const source = finalDataSources[providerKey];
+ if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) {
+ source.api_url = defaultUrls[providerKey];
+ }
+ }
+
+ await updateDataSourcesConfig(finalDataSources);
+ await mutateDataSources(finalDataSources, false);
+ setLocalDataSources(finalDataSources);
+ }
+ setSaveMessage('保存成功!');
+ } catch (e: any) {
+ setSaveMessage(`保存失败: ${e.message}`);
+ } finally {
+ setSaving(false);
+ setTimeout(() => setSaveMessage(''), 5000);
+ }
+ };
+
+ const handleReset = () => {
+ if (initialDataSources) setLocalDataSources(initialDataSources);
+ setTestResults({});
+ setSaveMessage('');
+ };
+
+ const handleTest = async (type: string, data: any) => {
+ try {
+ const result = await testConfig(type, data);
+ const success = !!result?.success;
+ const summary = typeof result?.message === 'string' && result.message.trim().length > 0
+ ? result.message
+ : (success ? '测试成功' : '测试失败');
+ setTestResults(prev => ({ ...prev, [type]: { success, summary } }));
+ } catch (e: any) {
+ const summary: string = (e && typeof e === 'object' && 'summary' in e) ? String(e.summary) : (e?.message || '未知错误');
+ const details: string | undefined = (e && typeof e === 'object' && 'details' in e) ? (e.details ? String(e.details) : undefined) : undefined;
+ setTestResults(prev => ({
+ ...prev,
+ [type]: { success: false, summary, details }
+ }));
+ }
+ };
+
+ return {
+ localDataSources,
+ setLocalDataSources,
+ dsLoading,
+ dsError,
+ saving,
+ saveMessage,
+ testResults,
+ handleSave,
+ handleReset,
+ handleTest,
+ defaultUrls
+ };
+}
+
diff --git a/frontend/src/app/config/hooks/useSystemConfig.ts b/frontend/src/app/config/hooks/useSystemConfig.ts
new file mode 100644
index 0000000..fffe4c4
--- /dev/null
+++ b/frontend/src/app/config/hooks/useSystemConfig.ts
@@ -0,0 +1,57 @@
+import { useState } from 'react';
+import { useConfigStore } from '@/stores/useConfigStore';
+
+export function useSystemConfig() {
+ const { config } = useConfigStore();
+ const [saveMessage, setSaveMessage] = useState('');
+
+ const handleExportConfig = () => {
+ if (!config) return;
+
+ const configToExport = {
+ new_api: config.new_api,
+ data_sources: config.data_sources,
+ export_time: new Date().toISOString(),
+ version: "1.0"
+ };
+
+ const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleImportConfig = (event: React.ChangeEvent, onImportSuccess?: (config: any) => void) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const importedConfig = JSON.parse(e.target?.result as string);
+
+ if (onImportSuccess) {
+ onImportSuccess(importedConfig);
+ }
+
+ setSaveMessage('配置导入成功,请检查并保存');
+ } catch (error) {
+ setSaveMessage('配置文件格式错误,导入失败');
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ return {
+ config,
+ saveMessage,
+ handleExportConfig,
+ handleImportConfig
+ };
+}
+
diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx
index 1bb12e8..56bbeaf 100644
--- a/frontend/src/app/config/page.tsx
+++ b/frontend/src/app/config/page.tsx
@@ -1,618 +1,16 @@
'use client';
-import { useState, useEffect, useRef, useCallback } from 'react';
-import {
- useConfig, testConfig,
- useAnalysisModules, updateAnalysisModules, useLlmProviders, updateLlmProviders, discoverProviderModels, discoverProviderModelsPreview
-} from '@/hooks/useApi';
+import { useConfig } from '@/hooks/useApi';
import { useConfigStore } from '@/stores/useConfigStore';
-import type { SystemConfig } from '@/stores/useConfigStore';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Spinner } from "@/components/ui/spinner";
-// Types are imported from '@/types'
-import type {
- AnalysisModulesConfig, DataSourcesConfig, DataSourceConfig, DataSourceProvider, LlmProvidersConfig, LlmModel,
- AnalysisTemplateSets, AnalysisTemplateSet, AnalysisModuleConfig
-} from '@/types';
-import { useDataSourcesConfig, updateDataSourcesConfig, useAnalysisTemplateSets, updateAnalysisTemplateSets } from '@/hooks/useApi';
-
-// ---- Helpers: pretty print nested JSON as YAML-like (braceless, unquoted) ----
-const MAX_REPARSE_DEPTH = 2;
-function tryParseJson(input: string): unknown {
- try {
- return JSON.parse(input);
- } catch {
- return input;
- }
-}
-function parsePossiblyNestedJson(raw: string): unknown {
- let current: unknown = raw;
- for (let i = 0; i < MAX_REPARSE_DEPTH; i++) {
- if (typeof current === 'string') {
- const trimmed = current.trim();
- if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
- current = tryParseJson(trimmed);
- continue;
- }
- }
- break;
- }
- return current;
-}
-function indentLines(text: string, indent: string): string {
- return text.split('\n').map((line) => indent + line).join('\n');
-}
-function looksLikeStructuredText(s: string): boolean {
- const t = s.trim();
- // 具有成对的大括号,或常见的 Rust/reqwest 错误标识,判定为可结构化的调试输出
- return (t.includes('{') && t.includes('}')) || t.includes('DynamicTransportError') || t.includes('reqwest::Error');
-}
-function prettyFormatByBraces(input: string): string {
- let out = '';
- let indentLevel = 0;
- const indentUnit = ' ';
- let inString = false;
- let stringQuote = '';
- for (let i = 0; i < input.length; i++) {
- const ch = input[i]!;
- const prev = i > 0 ? input[i - 1]! : '';
- if ((ch === '"' || ch === "'") && prev !== '\\') {
- if (!inString) {
- inString = true;
- stringQuote = ch;
- } else if (stringQuote === ch) {
- inString = false;
- }
- out += ch;
- continue;
- }
- if (inString) {
- out += ch;
- continue;
- }
- if (ch === '{' || ch === '[' || ch === '(') {
- indentLevel += 1;
- out += ch + '\n' + indentUnit.repeat(indentLevel);
- continue;
- }
- if (ch === '}' || ch === ']' || ch === ')') {
- indentLevel = Math.max(0, indentLevel - 1);
- out += '\n' + indentUnit.repeat(indentLevel) + ch;
- continue;
- }
- if (ch === ',') {
- out += ch + '\n' + indentUnit.repeat(indentLevel);
- continue;
- }
- if (ch === ':') {
- out += ch + ' ';
- continue;
- }
- out += ch;
- }
- return out;
-}
-function toPseudoYaml(value: unknown, indent: string = ''): string {
- const nextIndent = indent + ' ';
- if (value === null || value === undefined) {
- return `${indent}null`;
- }
- const t = typeof value;
- if (t === 'string' || t === 'number' || t === 'boolean') {
- const s = String(value);
- const shouldPretty = looksLikeStructuredText(s) || s.includes('\n');
- if (shouldPretty) {
- const pretty = looksLikeStructuredText(s) ? prettyFormatByBraces(s) : s;
- return `${indent}|-\n${indentLines(pretty, nextIndent)}`;
- }
- return `${indent}${s}`;
- }
- if (Array.isArray(value)) {
- if (value.length === 0) return `${indent}[]`;
- return value.map((item) => {
- const rendered = toPseudoYaml(item, nextIndent);
- const lines = rendered.split('\n');
- if (lines.length === 1) {
- return `${indent}- ${lines[0].trimStart()}`;
- }
- return `${indent}- ${lines[0].trimStart()}\n${lines.slice(1).map((l) => indent + ' ' + l.trimStart()).join('\n')}`;
- }).join('\n');
- }
- if (typeof value === 'object') {
- const obj = value as Record;
- const keys = Object.keys(obj);
- if (keys.length === 0) return `${indent}{}`;
- return keys.map((k) => {
- const rendered = toPseudoYaml(obj[k], nextIndent);
- const lines = rendered.split('\n');
- if (lines.length === 1) {
- return `${indent}${k}: ${lines[0].trimStart()}`;
- }
- return `${indent}${k}:\n${lines.map((l) => l).join('\n')}`;
- }).join('\n');
- }
- // fallback
- return `${indent}${String(value)}`;
-}
-function formatDetailsToYaml(details: string): string {
- const parsed = parsePossiblyNestedJson(details);
- if (typeof parsed === 'string') {
- // 尝试再次解析(有些场景内层 message 也是 JSON 串)
- const again = parsePossiblyNestedJson(parsed);
- if (typeof again === 'string') {
- return toPseudoYaml(again);
- }
- return toPseudoYaml(again);
- }
- return toPseudoYaml(parsed);
-}
-
-const defaultUrls: Partial> = {
- tushare: 'http://api.tushare.pro',
- finnhub: 'https://finnhub.io/api/v1',
- alphavantage: 'https://mcp.alphavantage.co/mcp',
-};
+import { AiConfigTab } from './components/AiConfigTab';
+import { DataSourcesTab } from './components/DataSourcesTab';
+import { AnalysisConfigTab } from './components/AnalysisConfigTab';
+import { SystemConfigTab } from './components/SystemConfigTab';
export default function ConfigPage() {
- // 从 Zustand store 获取全局状态
- const { config, loading, error, setConfig } = useConfigStore();
- // 使用 SWR hook 加载初始配置
+ const { config, loading, error } = useConfigStore();
useConfig();
-
- // 加载分析配置(统一使用 initialAnalysisModules)
- // const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisModules();
- // LLM Providers(用于模型列表与保存)
- const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders();
-
- // 本地表单状态
- // 数据源本地状态
- const { data: initialDataSources, error: dsError, isLoading: dsLoading, mutate: mutateDataSources } = useDataSourcesConfig();
- const [localDataSources, setLocalDataSources] = useState({});
-
- // 分析配置的本地状态
- const [localAnalysisModules, setLocalAnalysisModules] = useState({});
- // 分析配置保存状态(状态定义在下方统一维护)
-
- // 测试结果状态
- const [testResults, setTestResults] = useState>({});
-
- // 保存状态
- const [saving, setSaving] = useState(false);
- const [saveMessage, setSaveMessage] = useState('');
-
- // --- New State for Analysis Modules ---
- const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets();
- const [localTemplateSets, setLocalTemplateSets] = useState({});
- const [selectedTemplateId, setSelectedTemplateId] = useState(null);
-
- const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
- const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
-
- // 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('');
-
- // --- State for LLM Providers Management ---
- const [localLlmProviders, setLocalLlmProviders] = useState({});
- const [isSavingLlm, setIsSavingLlm] = useState(false);
- const [llmSaveMessage, setLlmSaveMessage] = useState('');
- const [newProviderId, setNewProviderId] = useState('');
- const [newProviderBaseUrl, setNewProviderBaseUrl] = useState('');
- const [newProviderApiKey, setNewProviderApiKey] = useState('');
- 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>>({});
- const hasInitializedLlmRef = useRef(false);
- const autoSaveTimerRef = useRef | null>(null);
- const [newModelInputs, setNewModelInputs] = useState>({});
- const [newModelNameInputs, setNewModelNameInputs] = useState>({});
- const [newModelMenuOpen, setNewModelMenuOpen] = useState>({});
- const [newModelHighlightIndex, setNewModelHighlightIndex] = useState>({});
- 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]);
-
- // 对 LLM Providers 的任何修改自动保存(去抖)
- 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]);
-
- useEffect(() => {
- if (!initialAnalysisTemplateSets) return;
- setLocalTemplateSets(initialAnalysisTemplateSets);
- // 仅在未选择时,从后端数据中选择第一个模板;避免覆盖本地新增的选择与状态
- setSelectedTemplateId(prev => prev ?? (Object.keys(initialAnalysisTemplateSets)[0] || null));
- }, [initialAnalysisTemplateSets]);
-
- useEffect(() => {
- if (initialDataSources) {
- setLocalDataSources(initialDataSources);
- }
- }, [initialDataSources]);
-
- useEffect(() => {
- if (llmProviders) {
- setLocalLlmProviders(llmProviders);
- const normalized = normalizeProviders(llmProviders);
- latestServerPayloadRef.current = JSON.stringify(normalized);
- llmDirtyRef.current = false;
- }
- }, [llmProviders, normalizeProviders]);
-
- 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);
- }
- };
-
- // 初始化分析配置的本地状态(已在 initialAnalysisModules 的 Effect 中处理)
-
- // 更新分析配置中的某个字段
- const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => {
- setLocalAnalysisModules(prev => ({
- ...prev,
- [type]: {
- ...prev[type],
- [field]: value
- }
- }));
- };
-
- // 更新分析模块的依赖
- 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)],
- },
- },
- },
- };
- });
- };
-
- // --- Handlers for templates and modules ---
-
- 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);
- // 新建后立即持久化,避免刷新/切 tab 导致本地新增被覆盖且无网络请求记录
- (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);
- // Select the first available template or null
- 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,
- },
- };
- });
- };
-
- // 旧版保存逻辑已移除,统一使用 handleSaveAnalysis
-
- const handleSave = async () => {
- setSaving(true);
- setSaveMessage('保存中...');
-
- try {
- if (initialDataSources) {
- // Create a deep copy to avoid mutating the local state directly
- const finalDataSources = JSON.parse(JSON.stringify(localDataSources));
- for (const key in finalDataSources) {
- const providerKey = key as DataSourceProvider;
- const source = finalDataSources[providerKey];
- // If the URL is empty or null, and there is a default URL, use it
- if (source && (source.api_url === null || source.api_url.trim() === '') && defaultUrls[providerKey]) {
- source.api_url = defaultUrls[providerKey];
- }
- }
-
- await updateDataSourcesConfig(finalDataSources);
- // After saving, mutate the local SWR cache with the final data
- // and also update the component's local state to reflect the change.
- await mutateDataSources(finalDataSources, false);
- setLocalDataSources(finalDataSources);
- }
- setSaveMessage('保存成功!');
- } catch (e: any) {
- setSaveMessage(`保存失败: ${e.message}`);
- } finally {
- setSaving(false);
- setTimeout(() => setSaveMessage(''), 5000);
- }
- };
-
- const handleTest = async (type: string, data: any) => {
- try {
- const result = await testConfig(type, data);
- const success = !!result?.success;
- const summary = typeof result?.message === 'string' && result.message.trim().length > 0
- ? result.message
- : (success ? '测试成功' : '测试失败');
- setTestResults(prev => ({ ...prev, [type]: { success, summary } }));
- } catch (e: any) {
- // 结构化错误对象:{ summary, details? }
- const summary: string = (e && typeof e === 'object' && 'summary' in e) ? String(e.summary) : (e?.message || '未知错误');
- const details: string | undefined = (e && typeof e === 'object' && 'details' in e) ? (e.details ? String(e.details) : undefined) : undefined;
- setTestResults(prev => ({
- ...prev,
- [type]: { success: false, summary, details }
- }));
- }
- };
-
- const handleTestTushare = () => {
- const cfg = localDataSources['tushare'];
- handleTest('tushare', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
- };
-
- const handleTestFinnhub = () => {
- const cfg = localDataSources['finnhub'];
- handleTest('finnhub', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
- };
-
- const handleTestAlphaVantage = () => {
- const cfg = localDataSources['alphavantage'];
- handleTest('alphavantage', { api_key: cfg?.api_key, api_url: cfg?.api_url, enabled: cfg?.enabled });
- };
-
- const handleReset = () => {
- if (initialDataSources) setLocalDataSources(initialDataSources);
- setTestResults({});
- setSaveMessage('');
- };
-
- const handleExportConfig = () => {
- if (!config) return;
-
- const configToExport = {
- new_api: config.new_api,
- data_sources: config.data_sources,
- export_time: new Date().toISOString(),
- version: "1.0"
- };
-
- const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- };
-
- const handleImportConfig = (event: React.ChangeEvent) => {
- const file = event.target.files?.[0];
- if (!file) return;
-
- const reader = new FileReader();
- reader.onload = (e) => {
- try {
- const importedConfig = JSON.parse(e.target?.result as string);
-
- // 验证导入的配置格式
- if (importedConfig.new_api?.base_url) {
- setNewProviderBaseUrl(importedConfig.new_api.base_url);
- }
-
- setSaveMessage('配置导入成功,请检查并保存');
- } catch (error) {
- setSaveMessage('配置文件格式错误,导入失败');
- }
- };
- reader.readAsText(file);
- };
if (loading) return (
@@ -650,906 +48,21 @@ export default function ConfigPage() {
-
-
-
-
AI Provider 管理
-
- {isSavingLlm && (
- <>
-
- 自动保存中...
- >
- )}
- {!isSavingLlm && llmSaveMessage && (
-
- {llmSaveMessage}
-
- )}
-
-
- 管理多个 Provider(API Key、Base URL)及其模型清单
-
-
- {/* 新增 Provider */}
-
-
-
-
-
-
-
-
-
- {/* 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 }));
- }}
- />
-
-
-
-
-
- 若无候选,请先点击上方“刷新候选模型”加载后再输入筛选。
-
-
-
-
- );
- })}
-
-
-
+
-
-
- 数据源配置
- 外部数据源 API 设置
-
-
- {dsLoading && 加载数据源配置...
}
- {dsError && 数据源配置加载失败: {String(dsError)}
}
- {!dsLoading && !dsError && (
-
- {(['tushare','finnhub','alphavantage','yfinance'] as DataSourceProvider[]).map((providerKey) => {
- const item: DataSourceConfig = localDataSources[providerKey] || {
- provider: providerKey,
- api_key: '',
- api_url: '',
- enabled: false,
- };
- return (
-
-
-
-
-
- {providerKey === 'tushare' && '中国市场数据源'}
- {providerKey === 'finnhub' && '全球市场数据源'}
- {providerKey === 'alphavantage' && '全球市场数据源(MCP桥接)'}
- {providerKey === 'yfinance' && '雅虎财经数据源'}
-
-
-
- {
- setLocalDataSources((prev) => ({
- ...prev,
- [providerKey]: { ...item, enabled: !!checked, provider: providerKey },
- }));
- }}
- />
-
-
-
-
-
- {providerKey === 'tushare' && (
-
- )}
- {providerKey === 'finnhub' && (
-
- )}
- {providerKey === 'alphavantage' && (
-
- )}
-
- {testResults[providerKey] ? (() => {
- const r = testResults[providerKey]!;
- if (r.success) {
- return (
-
- {r.summary}
-
- );
- }
- return (
-
-
测试失败
- {r.details ? (
-
- 查看详细错误(YAML)
-
-{formatDetailsToYaml(r.details)}
-
-
- ) : null}
-
- );
- })() : null}
-
- );
- })}
-
- )}
-
-
+
-
-
- 分析模板与模块配置
- 管理不同的分析模板集,并为每个模板集内的模块配置模型、提示词和依赖关系。
-
-
-
- {/* --- 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 availableModels = llmProviders?.[config.provider_id]?.models.filter(m => m.is_active) || [];
- const allModulesInSet = localTemplateSets[selectedTemplateId].modules;
-
- return (
-
-
-
-
{config.name || moduleId}
-
ID: {moduleId}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {Object.keys(allModulesInSet)
- .filter(id => id !== moduleId)
- .map(depId => (
-
- updateAnalysisDependencies(moduleId, depId, !!checked)}
- />
-
-
- ))}
-
-
-
- );
- })}
-
- {isCreatingModule && (
-
-
在 "{localTemplateSets[selectedTemplateId].name}" 中新增分析模块
-
-
-
-
-
-
- )}
-
-
-
-
- {analysisSaveMessage && (
-
- {analysisSaveMessage}
-
- )}
-
-
- ) : (
-
- )}
-
-
+
-
-
- 系统信息
- 当前系统状态和配置概览
-
-
-
-
-
-
- {config?.new_api?.api_key ? '已配置' : '未配置'}
-
-
-
-
-
- {config?.data_sources?.tushare?.api_key ? '已配置' : '未配置'}
-
-
-
-
-
- {config?.data_sources?.finnhub?.api_key ? '已配置' : '未配置'}
-
-
-
-
-
-
-
-
- 配置管理
- 导入、导出和备份配置
-
-
-
-
-
-
-
-
-
-
-
• 导出配置将下载当前所有配置的备份文件
-
• 导入配置将加载备份文件中的设置(不包含敏感信息)
-
• 建议定期备份配置以防数据丢失
-
-
-
+
-
-
-
-
-
- {saveMessage && (
-
- {saveMessage}
-
- )}
-
-
- 最后更新: {new Date().toLocaleString()}
-
-
);
}
diff --git a/frontend/src/app/config/utils/yaml-helper.ts b/frontend/src/app/config/utils/yaml-helper.ts
new file mode 100644
index 0000000..6d275ba
--- /dev/null
+++ b/frontend/src/app/config/utils/yaml-helper.ts
@@ -0,0 +1,138 @@
+
+const MAX_REPARSE_DEPTH = 2;
+
+function tryParseJson(input: string): unknown {
+ try {
+ return JSON.parse(input);
+ } catch {
+ return input;
+ }
+}
+
+function parsePossiblyNestedJson(raw: string): unknown {
+ let current: unknown = raw;
+ for (let i = 0; i < MAX_REPARSE_DEPTH; i++) {
+ if (typeof current === 'string') {
+ const trimmed = current.trim();
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
+ current = tryParseJson(trimmed);
+ continue;
+ }
+ }
+ break;
+ }
+ return current;
+}
+
+function indentLines(text: string, indent: string): string {
+ return text.split('\n').map((line) => indent + line).join('\n');
+}
+
+function looksLikeStructuredText(s: string): boolean {
+ const t = s.trim();
+ // 具有成对的大括号,或常见的 Rust/reqwest 错误标识,判定为可结构化的调试输出
+ return (t.includes('{') && t.includes('}')) || t.includes('DynamicTransportError') || t.includes('reqwest::Error');
+}
+
+function prettyFormatByBraces(input: string): string {
+ let out = '';
+ let indentLevel = 0;
+ const indentUnit = ' ';
+ let inString = false;
+ let stringQuote = '';
+ for (let i = 0; i < input.length; i++) {
+ const ch = input[i]!;
+ const prev = i > 0 ? input[i - 1]! : '';
+ if ((ch === '"' || ch === "'") && prev !== '\\') {
+ if (!inString) {
+ inString = true;
+ stringQuote = ch;
+ } else if (stringQuote === ch) {
+ inString = false;
+ }
+ out += ch;
+ continue;
+ }
+ if (inString) {
+ out += ch;
+ continue;
+ }
+ if (ch === '{' || ch === '[' || ch === '(') {
+ indentLevel += 1;
+ out += ch + '\n' + indentUnit.repeat(indentLevel);
+ continue;
+ }
+ if (ch === '}' || ch === ']' || ch === ')') {
+ indentLevel = Math.max(0, indentLevel - 1);
+ out += '\n' + indentUnit.repeat(indentLevel) + ch;
+ continue;
+ }
+ if (ch === ',') {
+ out += ch + '\n' + indentUnit.repeat(indentLevel);
+ continue;
+ }
+ if (ch === ':') {
+ out += ch + ' ';
+ continue;
+ }
+ out += ch;
+ }
+ return out;
+}
+
+function toPseudoYaml(value: unknown, indent: string = ''): string {
+ const nextIndent = indent + ' ';
+ if (value === null || value === undefined) {
+ return `${indent}null`;
+ }
+ const t = typeof value;
+ if (t === 'string' || t === 'number' || t === 'boolean') {
+ const s = String(value);
+ const shouldPretty = looksLikeStructuredText(s) || s.includes('\n');
+ if (shouldPretty) {
+ const pretty = looksLikeStructuredText(s) ? prettyFormatByBraces(s) : s;
+ return `${indent}|-\n${indentLines(pretty, nextIndent)}`;
+ }
+ return `${indent}${s}`;
+ }
+ if (Array.isArray(value)) {
+ if (value.length === 0) return `${indent}[]`;
+ return value.map((item) => {
+ const rendered = toPseudoYaml(item, nextIndent);
+ const lines = rendered.split('\n');
+ if (lines.length === 1) {
+ return `${indent}- ${lines[0].trimStart()}`;
+ }
+ return `${indent}- ${lines[0].trimStart()}\n${lines.slice(1).map((l) => indent + ' ' + l.trimStart()).join('\n')}`;
+ }).join('\n');
+ }
+ if (typeof value === 'object') {
+ const obj = value as Record;
+ const keys = Object.keys(obj);
+ if (keys.length === 0) return `${indent}{}`;
+ return keys.map((k) => {
+ const rendered = toPseudoYaml(obj[k], nextIndent);
+ const lines = rendered.split('\n');
+ if (lines.length === 1) {
+ return `${indent}${k}: ${lines[0].trimStart()}`;
+ }
+ return `${indent}${k}:\n${lines.map((l) => l).join('\n')}`;
+ }).join('\n');
+ }
+ // fallback
+ return `${indent}${String(value)}`;
+}
+
+export function formatDetailsToYaml(details: string): string {
+ const parsed = parsePossiblyNestedJson(details);
+ if (typeof parsed === 'string') {
+ // 尝试再次解析(有些场景内层 message 也是 JSON 串)
+ const again = parsePossiblyNestedJson(parsed);
+ if (typeof again === 'string') {
+ return toPseudoYaml(again);
+ }
+ return toPseudoYaml(again);
+ }
+ return toPseudoYaml(parsed);
+}
+