feat(analysis): integrate analysis templates into report workflow

- Backend: Add template_id to DataRequest and AnalysisResult metadata
- Frontend: Add TemplateSelector to Report page
- Frontend: Refactor ReportPage to use AnalysisTemplateSets for dynamic tabs
- Frontend: Strict mode for report rendering based on template_id
- Cleanup: Remove legacy analysis config hooks
This commit is contained in:
Lv, Qi 2025-11-19 06:58:47 +08:00
parent c8be576526
commit e855a16c69
21 changed files with 1294 additions and 837 deletions

View File

@ -0,0 +1,130 @@
# 分析模板集成设计文档
## 1. 概述
系统正在从单一、固定的分析配置架构向多模板架构迁移。目前,后端已支持 `AnalysisTemplateSets` 并能执行特定的模板。然而前端在渲染报告标签页Tabs和触发分析时仍然依赖于过时的 `AnalysisModulesConfig`(单一配置集)。
本文档概述了将“分析模板”完全集成到用户工作流中所需的变更,具体包括:
1. **触发分析**:在启动新的分析任务时选择特定的模板。
2. **报告展示**:根据分析所使用的模板,动态渲染标签页和内容。
## 2. 当前状态 vs. 目标状态
| 功能特性 | 当前状态 | 目标状态 |
| :--- | :--- | :--- |
| **配置管理** | `useAnalysisConfig` (过时,单一模块列表) | `useAnalysisTemplateSets` (多套具名模板) |
| **触发分析** | `trigger(symbol, market)` (无模板选择) | `trigger(symbol, market, templateId)` |
| **报告标签页** | 硬编码遍历过时的 `analysis_modules` keys | 根据报告使用的**特定模板**动态生成标签页 |
| **模块名称** | 从全局默认配置获取 | 从所使用的特定模板配置中获取 |
## 3. 详细设计
### 3.1. 后端变更
#### 3.1.1. API Gateway (`api-gateway`)
* **Endpoint**: `POST /api/data-requests`
* **变更**: 更新请求 DTO (Data Transfer Object) 以接收可选参数 `template_id: String`
* **逻辑**: 将此 `template_id` 通过 `GenerateReportCommand` 向下传递给 `report-generator-service`
#### 3.1.2. 数据持久化 / 报告数据
* **需求**: 前端需要知道生成某份报告时具体使用了*哪个*模板,以便正确渲染标签页(包括标题和顺序)。
* **变更**: 确保 `GET /api/financials/...``GET /api/reports/...` 的响应数据中,在 metadata 中包含 `template_id`
* **实现**: 前端 `route.ts` 聚合层通过查询 `analysis-results` 获取最新的 `template_id` 并注入到 `meta` 中。
### 3.2. 前端变更
#### 3.2.1. API Hooks (`useApi.ts`)
* **`useDataRequest`**: 更新 `trigger` 函数签名:
```typescript
trigger(symbol: string, market: string, templateId?: string)
```
* **`useAnalysisTemplateSets`**: 确保此 hook 可用(目前代码中已存在)。
#### 3.2.2. 触发 UI (报告页侧边栏 / 查询页)
* **组件**: 在“触发分析”按钮旁增加一个 `TemplateSelector` (选择框/下拉菜单)。
* **数据源**: `useAnalysisTemplateSets`
* **默认值**: 自动选中第一个可用的模板,或者标记为 "default" 的模板。
#### 3.2.3. 报告页面 (`frontend/src/app/report/[symbol]/page.tsx`)
这是最复杂的变更部分。我们需要重构标签页Tabs的生成逻辑。
1. **移除旧逻辑**:
* 移除对 `useAnalysisConfig` (全局默认配置) 的依赖。
* 弃用/移除 `runAnalysesSequentially` (旧的前端编排流程)。
2. **识别模板**:
* 从获取到的财务/报告数据中读取 `template_id` (例如 `financials.meta.template_id` 或类似位置)。
* **Strict Mode**: 如果缺失 `template_id`,则视为严重数据错误,前端直接报错停止渲染,**绝不进行默认值回退或自动推断**。
3. **动态标签页**:
* 使用 `useAnalysisTemplateSets` 获取 `templateSets`
* 从 `templateSets[currentTemplateId].modules` 中推导出 `activeModules` 列表。
* 遍历 `activeModules` 来生成 `TabsTrigger``TabsContent`
* **显示名称**: 使用 `moduleConfig.name`
* **排序**: 严格遵循模板中定义的顺序(或依赖顺序)。
### 3.3. 数据流
1. **用户**选择 "标准分析模板 V2" (Standard Analysis V2) 并点击 "运行"。
2. **前端**调用 `POST /api/data-requests`,载荷为 `{ ..., template_id: "standard_v2" }`
3. **后端**使用 "standard_v2" 中定义的模块生成报告。
4. **前端**轮询任务进度。
5. **前端**获取完成的数据。数据包含元数据 `meta: { template_id: "standard_v2" }`
6. **前端**查询 "standard_v2" 的配置详情。
7. **前端**渲染标签页:如 "公司简介"、"财务健康"(均来自 V2 配置)。
## 4. 实施步骤
1. **后端更新**:
* 验证 `api-gateway` 是否正确传递 `template_id`
* 验证报告 API 是否在 metadata 中返回 `template_id`
2. **前端 - 触发**:
* 更新 `useDataRequest` hook。
* 在 `ReportPage` 中添加 `TemplateSelector` 组件。
3. **前端 - 展示**:
* 重构 `ReportPage` 以使用 `templateSets`
* 根据报告中的 `template_id` 动态计算 `analysisTypes`
## 5. 待办事项列表 (To-Do List)
### Phase 1: 后端与接口 (Backend & API)
- [x] **1.1 更新请求 DTO (api-gateway)**
- 目标: `api-gateway``DataRequest` 结构体
- 动作: 增加 `template_id` 字段 (Option<String>)
- 验证: `curl` 请求带 `template_id` 能被解析
- [x] **1.2 传递 Command (api-gateway -> report-service)**
- 目标: `GenerateReportCommand` 消息
- 动作: 确保 `template_id` 被正确透传到消息队列或服务调用中
- [x] **1.3 验证报告元数据 (data-persistence)**
- 目标: `GET /api/financials/...` 接口
- 动作: 检查返回的 JSON 中 `meta` 字段是否包含 `template_id`
- 备注: 已通过 frontend `route.ts` 聚合实现
### Phase 2: 前端逻辑 (Frontend Logic)
- [x] **2.1 更新 API Hook**
- 文件: `frontend/src/hooks/useApi.ts`
- 动作: 修改 `useDataRequest``trigger` 方法签名,支持 `templateId` 参数
- [x] **2.2 移除旧版依赖**
- 文件: `frontend/src/app/report/[symbol]/page.tsx`
- 动作: 移除 `useAnalysisConfig` 及相关旧版逻辑 (`runAnalysesSequentially`)
### Phase 3: 前端界面 (Frontend UI)
- [x] **3.1 实现模板选择器**
- 文件: `frontend/src/app/report/[symbol]/page.tsx` (侧边栏)
- 动作: 添加 `<Select>` 组件,数据源为 `useAnalysisTemplateSets`
- 逻辑: 默认选中第一个模板,点击"触发分析"时传递选中的 ID
- [x] **3.2 动态渲染标签页**
- 文件: `frontend/src/app/report/[symbol]/page.tsx` (主区域)
- 动作:
1. 从 `financials.meta.template_id` 获取当前报告的模板 ID
2. 若 ID 缺失直接抛出错误 (Strict Mode)
3. 根据 ID 从 `templateSets` 获取模块列表
4. 遍历模块列表渲染 `<TabsTrigger>``<TabsContent>`
5. 内容从 `useAnalysisResults` hook 获取
### Phase 4: 验证与清理 (Verification)
- [ ] **4.1 端到端测试**
- 动作: 创建新模板 -> 选择该模板触发分析 -> 验证报告页只显示该模板定义的模块
- [x] **4.2 代码清理**
- 动作: 删除未使用的旧版配置 Hook 和类型定义

View File

@ -13,6 +13,82 @@ export async function GET(
const url = new URL(req.url);
const { slug } = await context.params;
const first = slug?.[0];
// 1. Match /api/financials/{market}/{symbol}
// slug[0] = market (e.g., "cn" or "us")
// slug[1] = symbol (e.g., "600519" or "AAPL")
if (slug.length === 2 && first !== 'analysis-config' && first !== 'config') {
const market = slug[0];
const symbol = slug[1];
const years = url.searchParams.get('years') || '10';
// Fetch financials from backend
const finResp = await fetch(
`${BACKEND_BASE}/market-data/financial-statements/${encodeURIComponent(symbol)}?metrics=${encodeURIComponent('')}`,
{ cache: 'no-store' }
);
if (!finResp.ok) {
if (finResp.status === 404) {
return Response.json({}, { status: 200 }); // Return empty for now to not break UI
}
return new Response(finResp.statusText, { status: finResp.status });
}
const series = await finResp.json();
// Transform to frontend expected format (BatchFinancialDataResponse)
// We group by metric_name
const groupedSeries: Record<string, any[]> = {};
series.forEach((item: any) => {
if (!groupedSeries[item.metric_name]) {
groupedSeries[item.metric_name] = [];
}
groupedSeries[item.metric_name].push({
period: item.period_date ? item.period_date.replace(/-/g, '') : null, // YYYY-MM-DD -> YYYYMMDD
value: item.value,
source: item.source
});
});
// Fetch Company Profile to populate name/industry
const profileResp = await fetch(`${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`, { cache: 'no-store' });
let profileData: any = {};
if (profileResp.ok) {
profileData = await profileResp.json();
}
// Fetch Latest Analysis Result Metadata (to get template_id)
// We search for the most recent analysis result for this symbol
const analysisResp = await fetch(`${BACKEND_BASE}/analysis-results?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' });
let meta: any = {
symbol: symbol,
generated_at: new Date().toISOString(), // Fallback
template_id: null // Explicitly null if not found
};
if (analysisResp.ok) {
const analysisList = await analysisResp.json();
if (Array.isArray(analysisList) && analysisList.length > 0) {
// Sort by created_at desc (backend should already do this, but to be safe)
// Backend returns sorted by created_at DESC
const latest = analysisList[0];
meta.template_id = latest.template_id || null;
meta.generated_at = latest.created_at;
}
}
const responsePayload = {
name: profileData.name || symbol,
symbol: symbol,
market: market,
series: groupedSeries,
meta: meta
};
return Response.json(responsePayload);
}
// 适配旧接口analysis-config → 新分析模块配置
if (first === 'analysis-config') {
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });

View File

@ -1,208 +0,0 @@
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<LlmProvidersConfig>({});
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<Record<string, string>>({});
const [editingApiKey, setEditingApiKey] = useState<Record<string, boolean>>({});
const [discoverMessages, setDiscoverMessages] = useState<Record<string, string>>({});
// Model management states
const [modelPickerOpen, setModelPickerOpen] = useState<Record<string, boolean>>({});
const [candidateModels, setCandidateModels] = useState<Record<string, string[]>>({});
const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
const [selectedCandidates, setSelectedCandidates] = useState<Record<string, Record<string, boolean>>>({});
// New model manual input states
const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
const [newModelNameInputs, setNewModelNameInputs] = useState<Record<string, string>>({});
const [newModelMenuOpen, setNewModelMenuOpen] = useState<Record<string, boolean>>({});
const [newModelHighlightIndex, setNewModelHighlightIndex] = useState<Record<string, number>>({});
// Auto-save refs
const hasInitializedLlmRef = useRef(false);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savingStartedAtRef = useRef<number>(0);
const llmDirtyRef = useRef(false);
const latestServerPayloadRef = useRef<string>('');
const lastSavedPayloadRef = useRef<string>('');
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
};
}

View File

@ -1,228 +0,0 @@
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<AnalysisTemplateSets>({});
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(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
};
}

View File

@ -1,94 +0,0 @@
import { useState, useEffect } from 'react';
import {
useDataSourcesConfig as useDataSourcesConfigApi,
updateDataSourcesConfig,
testConfig
} from '@/hooks/useApi';
import { DataSourcesConfig, DataSourceProvider, DataSourceConfig } from '@/types';
const defaultUrls: Partial<Record<DataSourceProvider, string>> = {
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<DataSourcesConfig>({});
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [testResults, setTestResults] = useState<Record<string, { success: boolean; summary: string; details?: string } | null>>({});
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
};
}

View File

@ -1,57 +0,0 @@
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<HTMLInputElement>, 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
};
}

View File

@ -1,138 +0,0 @@
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<string, unknown>;
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);
}

View File

@ -1,6 +1,7 @@
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface ReportHeaderProps {
unifiedSymbol: string;
@ -15,6 +16,10 @@ interface ReportHeaderProps {
onStartAnalysis: () => void;
onStopAnalysis: () => void;
onContinueAnalysis: () => void;
// Template props
templateSets: any;
selectedTemplateId: string;
onSelectTemplate: (id: string) => void;
}
export function ReportHeader({
@ -30,6 +35,9 @@ export function ReportHeader({
onStartAnalysis,
onStopAnalysis,
onContinueAnalysis,
templateSets,
selectedTemplateId,
onSelectTemplate,
}: ReportHeaderProps) {
return (
<>
@ -65,16 +73,50 @@ export function ReportHeader({
</Card>
<Card className="w-80 flex-shrink-0">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="space-y-1">
<label className="text-xs font-medium"></label>
<Select value={selectedTemplateId} onValueChange={onSelectTemplate} disabled={triggering}>
<SelectTrigger>
<SelectValue placeholder="选择分析模板" />
</SelectTrigger>
<SelectContent>
{templateSets && Object.entries(templateSets).map(([id, set]: [string, any]) => (
<SelectItem key={id} value={id}>
{set.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={onStartAnalysis}
disabled={triggering || !selectedTemplateId}
className="flex-1"
>
{triggering ? '触发中…' : '触发分析'}
</Button>
<Button variant="destructive" onClick={onStopAnalysis} disabled={!hasRunningTask}>
</Button>
<Button variant="outline" onClick={onContinueAnalysis} disabled={isAnalysisRunning}>
</Button>
</div>
</CardContent>
</Card>
<Card className="w-64 flex-shrink-0">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<SnapshotItem
label="日期"
value={snapshot?.trade_date ? `${snapshot.trade_date.slice(0,4)}-${snapshot.trade_date.slice(4,6)}-${snapshot.trade_date.slice(6,8)}` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="PB"
value={snapshot?.pb != null ? `${Number(snapshot.pb).toFixed(2)}` : undefined}
@ -95,31 +137,9 @@ export function ReportHeader({
value={snapshot?.total_mv != null ? `${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元` : undefined}
loading={snapshotLoading}
/>
<SnapshotItem
label="股息率"
value={snapshot?.dv_ratio != null ? `${Number(snapshot.dv_ratio).toFixed(2)}%` : undefined}
loading={snapshotLoading}
/>
</div>
</CardContent>
</Card>
<Card className="w-40 flex-shrink-0">
<CardContent className="flex flex-col gap-2 pt-6">
<Button
onClick={onStartAnalysis}
disabled={triggering}
>
{triggering ? '触发中…' : '触发分析'}
</Button>
<Button variant="destructive" onClick={onStopAnalysis} disabled={!hasRunningTask}>
</Button>
<Button variant="outline" onClick={onContinueAnalysis} disabled={isAnalysisRunning}>
</Button>
</CardContent>
</Card>
</>
);
}
@ -131,8 +151,7 @@ function SnapshotItem({ label, value, loading }: { label: string; value?: string
<span className="font-medium">
{loading ? (
<span className="flex items-center gap-1">
{label === '日期' && <Spinner className="size-3" />}
<span className="text-muted-foreground">{label === '日期' ? '加载中...' : '-'}</span>
<Spinner className="size-3" />
</span>
) : value ? (
value
@ -143,4 +162,3 @@ function SnapshotItem({ label, value, loading }: { label: string; value?: string
</div>
);
}

View File

@ -25,17 +25,47 @@ interface AnalysisRecord {
export function useAnalysisRunner(
financials: any,
analysisConfig: any,
financialConfig: any,
normalizedMarket: string,
unifiedSymbol: string,
isLoading: boolean,
error: any
error: any,
templateSets: any // Added templateSets
) {
// --- Template Logic ---
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('');
// Set default template
useEffect(() => {
if (!selectedTemplateId && templateSets && Object.keys(templateSets).length > 0) {
const defaultId = Object.keys(templateSets).find(k => k.includes('standard') || k === 'default') || Object.keys(templateSets)[0];
setSelectedTemplateId(defaultId);
}
}, [templateSets, selectedTemplateId]);
const reportTemplateId = financials?.meta?.template_id;
// Determine active template set
const activeTemplateId = (financials && reportTemplateId) ? reportTemplateId : selectedTemplateId;
const activeTemplateSet = useMemo(() => {
if (!activeTemplateId || !templateSets) return null;
return templateSets[activeTemplateId] || null;
}, [activeTemplateId, templateSets]);
// Derive effective analysis config from template set, falling back to global config if needed
const activeAnalysisConfig = useMemo(() => {
if (activeTemplateSet) {
return { analysis_modules: activeTemplateSet.modules };
}
return financialConfig; // Fallback to global config (legacy behavior)
}, [activeTemplateSet, financialConfig]);
// 分析类型列表
const analysisTypes = useMemo(() => {
if (!analysisConfig?.analysis_modules) return [];
return Object.keys(analysisConfig.analysis_modules);
}, [analysisConfig]);
if (!activeAnalysisConfig?.analysis_modules) return [];
return Object.keys(activeAnalysisConfig.analysis_modules);
}, [activeAnalysisConfig]);
// 分析状态管理
const [analysisStates, setAnalysisStates] = useState<Record<string, AnalysisState>>({});
@ -109,7 +139,7 @@ export function useAnalysisRunner(
}, [startTime]);
const retryAnalysis = async (analysisType: string) => {
if (!financials || !analysisConfig?.analysis_modules) {
if (!financials || !activeAnalysisConfig?.analysis_modules) {
return;
}
analysisFetchedRefs.current[analysisType] = false;
@ -119,7 +149,7 @@ export function useAnalysisRunner(
}));
setAnalysisRecords(prev => prev.filter(record => record.type !== analysisType));
const analysisName =
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
activeAnalysisConfig.analysis_modules[analysisType]?.name || analysisType;
const startTimeISO = new Date().toISOString();
setCurrentAnalysisTask(analysisType);
setAnalysisRecords(prev => [...prev, {
@ -186,6 +216,7 @@ export function useAnalysisRunner(
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
...prev[analysisType],
content: '',
loading: false,
error: errorMessage
@ -208,7 +239,7 @@ export function useAnalysisRunner(
};
useEffect(() => {
if (isLoading || error || !financials || !analysisConfig?.analysis_modules || analysisTypes.length === 0) {
if (isLoading || error || !financials || !activeAnalysisConfig?.analysis_modules || analysisTypes.length === 0) {
return;
}
if (isAnalysisRunningRef.current) {
@ -231,13 +262,13 @@ export function useAnalysisRunner(
if (analysisFetchedRefs.current[analysisType]) {
continue;
}
if (!analysisFetchedRefs.current || !analysisConfig?.analysis_modules) {
if (!analysisFetchedRefs.current || !activeAnalysisConfig?.analysis_modules) {
console.error("分析配置或refs未初始化无法进行分析。");
continue;
}
currentAnalysisTypeRef.current = analysisType;
const analysisName =
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
activeAnalysisConfig.analysis_modules[analysisType]?.name || analysisType;
const startTimeISO = new Date().toISOString();
setCurrentAnalysisTask(analysisType);
setAnalysisRecords(prev => {
@ -360,7 +391,7 @@ export function useAnalysisRunner(
}
};
runAnalysesSequentially();
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
}, [isLoading, error, financials, activeAnalysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
const stopAll = () => {
stopRequestedRef.current = true;
@ -382,11 +413,12 @@ export function useAnalysisRunner(
};
const triggerAnalysis = async () => {
const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || '');
const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || '', selectedTemplateId);
if (reqId) setRequestId(reqId);
};
return {
activeAnalysisConfig, // Exported
analysisTypes,
analysisStates,
analysisRecords,
@ -404,11 +436,8 @@ export function useAnalysisRunner(
continuePending,
retryAnalysis,
hasRunningTask,
isAnalysisRunning: isAnalysisRunningRef.current, // 注意:这里返回的是 ref.current可能不是响应式的。通常需要 state。
// 但原代码中 isAnalysisRunningRef 仅用于内部逻辑控制,没有直接用于 UI 展示(除了 disabled 状态,但这通常依赖 state 变化触发重渲染)。
// 在原 UI 中 `disabled={isAnalysisRunningRef.current}` 可能会有问题,如果仅仅 Ref 变了组件不一定会刷新。
// 不过原代码就是这样写的。
// 我建议可以用 hasRunningTask 来作为替代。
isAnalysisRunning: isAnalysisRunningRef.current,
selectedTemplateId, // Exported
setSelectedTemplateId, // Exported
};
}

View File

@ -134,3 +134,5 @@ export default function ReportPage() {
</div>
);
}

View File

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Dialog> & {
title?: string
description?: string
}) {
return (
<CommandPrimitive.Dialog
data-slot="command-dialog"
{...props}
>
{children}
</CommandPrimitive.Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex items-center border-b px-3" data-slot="command-input-wrapper">
<SearchIcon className="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,49 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -3,15 +3,13 @@ import {
BatchFinancialDataResponse,
TodaySnapshotResponse,
RealTimeQuoteResponse,
AnalysisConfigResponse,
LlmProvidersConfig,
AnalysisModulesConfig,
AnalysisTemplateSets, // New type
FinancialConfigResponse,
DataSourcesConfig,
AnalysisResultDto,
} from "@/types";
import { useEffect, useState } from "react";
// Execution-step types not used currently; keep API minimal and explicit
import { useConfigStore } from "@/stores/useConfigStore";
import type { SystemConfig } from "@/stores/useConfigStore";
@ -24,7 +22,7 @@ export function useDataRequest() {
const [isMutating, setIsMutating] = useState(false);
const [error, setError] = useState<Error | null>(null);
const trigger = async (symbol: string, market: string): Promise<string | undefined> => {
const trigger = async (symbol: string, market: string, templateId?: string): Promise<string | undefined> => {
setIsMutating(true);
setError(null);
try {
@ -33,7 +31,7 @@ export function useDataRequest() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ symbol, market }),
body: JSON.stringify({ symbol, market, template_id: templateId }),
});
if (!res.ok) {
@ -79,6 +77,19 @@ export function useTaskProgress(requestId: string | null, options?: SWRConfigura
};
}
// --- Analysis Results Hooks (NEW) ---
export function useAnalysisResults(symbol?: string) {
return useSWR<AnalysisResultDto[]>(
symbol ? `/api/analysis-results?symbol=${encodeURIComponent(symbol)}` : null,
fetcher,
{
refreshInterval: 5000, // Poll for new results
}
);
}
// --- 保留的旧Hooks (用于查询最终数据) ---
export function useCompanyProfile(symbol?: string, market?: string) {
@ -139,44 +150,6 @@ export function useFinancials(market?: string, stockCode?: string, years: number
);
}
export function useAnalysisConfig() {
return useSWR<AnalysisConfigResponse>('/api/configs/analysis_modules', fetcher);
}
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
const res = await fetch('/api/configs/analysis_modules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function generateFullAnalysis(tsCode: string, companyName: string) {
const url = `/api/financials/china/${encodeURIComponent(tsCode)}/analysis?company_name=${encodeURIComponent(companyName)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const text = await res.text();
if (!res.ok) {
try {
const errorJson = JSON.parse(text);
throw new Error(errorJson.detail || text);
} catch {
throw new Error(text || `Request failed: ${res.status}`);
}
}
try {
return JSON.parse(text);
} catch {
throw new Error('Invalid JSON response from server.');
}
}
export function useChinaSnapshot(ts_code?: string) {
return useSWR<TodaySnapshotResponse>(
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}/snapshot` : null,
@ -397,26 +370,6 @@ export async function updateAnalysisTemplateSets(payload: AnalysisTemplateSets)
return res.json() as Promise<AnalysisTemplateSets>;
}
// --- Analysis Modules Config Hooks (OLD - DEPRECATED) ---
export function useAnalysisModules() {
return useSWR<AnalysisModulesConfig>('/api/configs/analysis_modules', fetcher);
}
export async function updateAnalysisModules(payload: AnalysisModulesConfig) {
const res = await fetch('/api/configs/analysis_modules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
return res.json() as Promise<AnalysisModulesConfig>;
}
// --- Data Sources Config Hooks ---
export function useDataSourcesConfig() {

View File

@ -102,6 +102,7 @@ export interface FinancialMeta {
api_calls_by_group: Record<string, number>;
current_action?: string | null;
steps: StepRecord[];
template_id?: string | null;
}
export interface BatchFinancialDataResponse {
@ -183,6 +184,21 @@ export interface AnalysisResponse {
error?: string;
}
/**
* DTO (Analysis Result Data Transfer Object)
* Corresponds to backend AnalysisResultDto
*/
export interface AnalysisResultDto {
id: number;
request_id: string; // UUID
symbol: string;
template_id: string;
module_id: string;
content: string;
meta_data: any; // JSON
created_at: string; // ISO8601
}
/**
*
*/

697
package-lock.json generated
View File

@ -5,10 +5,559 @@
"packages": {
"": {
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"cmdk": "^1.1.1",
"immer": "^10.2.0",
"zustand": "^5.0.8"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@ -19,6 +568,154 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",

View File

@ -1,5 +1,8 @@
{
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"cmdk": "^1.1.1",
"immer": "^10.2.0",
"zustand": "^5.0.8"
}

View File

@ -23,6 +23,7 @@ const ANALYSIS_COMMANDS_QUEUE: &str = "analysis.commands.generate_report";
pub struct DataRequest {
pub symbol: String,
pub market: String,
pub template_id: Option<String>,
}
#[derive(Serialize)]
@ -102,7 +103,7 @@ async fn trigger_data_fetch(
let request_id = Uuid::new_v4();
let command = FetchCompanyDataCommand {
request_id,
symbol: payload.symbol,
symbol: payload.symbol.clone(),
market: payload.market,
};
@ -116,6 +117,23 @@ async fn trigger_data_fetch(
)
.await?;
// If a template_id is provided, trigger the analysis generation workflow as well.
if let Some(template_id) = payload.template_id {
let analysis_command = GenerateReportCommand {
request_id,
symbol: payload.symbol,
template_id,
};
info!(request_id = %request_id, template_id = %analysis_command.template_id, "Publishing analysis generation command (auto-triggered)");
state
.nats_client
.publish(
ANALYSIS_COMMANDS_QUEUE.to_string(),
serde_json::to_vec(&analysis_command).unwrap().into(),
)
.await?;
}
Ok((
StatusCode::ACCEPTED,
Json(RequestAcceptedResponse { request_id }),

View File

@ -3,5 +3,6 @@ pub mod models;
pub mod observability;
pub mod messages;
pub mod config_models;
pub mod provider;

View File

@ -0,0 +1,8 @@
pub trait DataProvider {
/// Returns the name of the provider (e.g., "tushare", "yfinance")
fn name(&self) -> &str;
/// Returns whether the provider is enabled in the configuration
fn is_enabled(&self) -> bool;
}

View File

@ -6,6 +6,7 @@ use crate::{
mapping::{map_financial_dtos, map_profile_dto},
};
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use common_contracts::provider::DataProvider;
use tokio;
#[derive(Debug, Deserialize, Clone)]
@ -60,6 +61,16 @@ pub struct FinnhubDataProvider {
client: FinnhubClient,
}
impl DataProvider for FinnhubDataProvider {
fn name(&self) -> &str {
"finnhub"
}
fn is_enabled(&self) -> bool {
true
}
}
impl FinnhubDataProvider {
pub fn new(api_url: String, api_token: String) -> Self {
Self {

View File

@ -9,12 +9,23 @@ use crate::{
ts_client::TushareClient,
};
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
use common_contracts::provider::DataProvider;
#[derive(Clone)]
pub struct TushareDataProvider {
client: TushareClient,
}
impl DataProvider for TushareDataProvider {
fn name(&self) -> &str {
"tushare"
}
fn is_enabled(&self) -> bool {
true
}
}
impl TushareDataProvider {
pub fn new(api_url: String, api_token: String) -> Self {
Self {