Compare commits
7 Commits
68ae2656a7
...
d28f3c5266
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d28f3c5266 | ||
|
|
e855a16c69 | ||
|
|
c8be576526 | ||
|
|
75aa5e4add | ||
|
|
4fef6bf35b | ||
|
|
e699cda81e | ||
|
|
75378e7aae |
@ -83,6 +83,7 @@ services:
|
|||||||
SERVER_PORT: 4000
|
SERVER_PORT: 4000
|
||||||
NATS_ADDR: nats://nats:4222
|
NATS_ADDR: nats://nats:4222
|
||||||
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
REPORT_GENERATOR_SERVICE_URL: http://report-generator-service:8004
|
||||||
# provider_services via explicit JSON for deterministic parsing
|
# provider_services via explicit JSON for deterministic parsing
|
||||||
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]'
|
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]'
|
||||||
RUST_LOG: info,axum=info
|
RUST_LOG: info,axum=info
|
||||||
@ -94,6 +95,7 @@ services:
|
|||||||
- tushare-provider-service
|
- tushare-provider-service
|
||||||
- finnhub-provider-service
|
- finnhub-provider-service
|
||||||
- yfinance-provider-service
|
- yfinance-provider-service
|
||||||
|
- report-generator-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -186,6 +188,9 @@ services:
|
|||||||
- data-persistence-service
|
- data-persistence-service
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 8.8.4.4
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8003/health >/dev/null || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8003/health >/dev/null || exit 1"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@ -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 和类型定义
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
# 分析流程优化与数据缓存机制修复
|
||||||
|
|
||||||
|
## 1. 问题背景 (Problem Statement)
|
||||||
|
|
||||||
|
根据系统日志分析与代码排查,当前系统存在以下关键问题:
|
||||||
|
|
||||||
|
1. **数据源重复请求 (Missing Cache Logic)**:
|
||||||
|
* `yfinance-provider-service` (及其他数据服务) 在接收到任务指令时,未检查本地数据库是否存在有效数据,而是直接向外部 API 发起请求。
|
||||||
|
* 这导致每次用户点击,都会触发耗时约 1.5s 的外部抓取,既慢又浪费资源。
|
||||||
|
|
||||||
|
2. **任务依赖与执行时序错乱 (Race Condition)**:
|
||||||
|
* `api-gateway` 在接收到请求时,**同时**触发了数据抓取 (`DATA_FETCH_QUEUE`) 和分析报告生成 (`ANALYSIS_COMMANDS_QUEUE`)。
|
||||||
|
* 导致 `report-generator-service` 在数据还没抓回来时就启动了,读到空数据(或旧数据)后瞬间完成,导致 Token 消耗为 0,报告内容为空。
|
||||||
|
|
||||||
|
3. **前端无法展示数据 (Frontend Data Visibility)**:
|
||||||
|
* **根本原因**: API Gateway 路由缺失与路径映射错误。
|
||||||
|
* 前端 BFF (`frontend/src/app/api/financials/...`) 试图请求 `${BACKEND_BASE}/market-data/financial-statements/...`。
|
||||||
|
* 然而,`api-gateway` **并未暴露** 此路由(仅暴露了 `/v1/companies/{symbol}/profile`)。
|
||||||
|
* 因此,前端获取财务数据的请求全部 404 失败,导致界面始终显示 "暂无可展示的数据",即使用户多次运行也无效。
|
||||||
|
|
||||||
|
## 2. 目标 (Goals)
|
||||||
|
|
||||||
|
1. **实现"读写穿透"缓存策略**: 数据服务在抓取前必须先检查本地数据库数据的时效性。
|
||||||
|
2. **构建事件驱动的依赖工作流**: 分析服务必须严格等待数据服务完成后触发(通过 NATS 事件链)。
|
||||||
|
3. **修复数据访问层**: 确保 API Gateway 正确暴露并转发财务数据接口,使前端可见。
|
||||||
|
4. **定义数据时效性标准**: 针对不同类型数据实施差异化的缓存过期策略。
|
||||||
|
|
||||||
|
## 3. 详细技术方案 (Technical Plan)
|
||||||
|
|
||||||
|
### 3.1. 数据时效性与缓存策略 (Data Freshness Policy)
|
||||||
|
|
||||||
|
针对基本面分析场景,不同数据的更新频率和时效性要求如下:
|
||||||
|
|
||||||
|
| 数据类型 | 内容示例 | 更新频率 | 建议 TTL (缓存有效期) | 更新策略 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **公司概况 (Profile)** | 名称、行业、简介、高管 | 极低 (年/不定期) | **30 天** | **Stale-While-Revalidate (SWR)**<br>过期后先返回旧数据,后台异步更新。 |
|
||||||
|
| **财务报表 (Financials)** | 营收、利润、资产负债 (季/年) | 季度 (4/8/10月) | **24 小时** | **Cache-Aside**<br>每次请求先查库。若 `updated_at > 24h`,则强制 Fetch;否则直接返回库中数据。<br>*注: 对于同一天内的重复请求,将直接命中缓存,0延迟。* |
|
||||||
|
| **市场数据 (Market Data)** | PE/PB/市值/价格 | 实时/日频 | **1 小时** | 基本面分析不需要秒级价格。取最近一小时内的快照即可。若需实时价格,使用专用实时接口。 |
|
||||||
|
|
||||||
|
### 3.2. 数据服务层 (Providers)
|
||||||
|
* **涉及服务**: `yfinance-provider-service`, `alphavantage-provider-service`, `finnhub-provider-service`.
|
||||||
|
* **逻辑变更**:
|
||||||
|
1. 订阅 `FetchCommand`。
|
||||||
|
2. **Step 1 (Check DB)**: 调用 `PersistenceClient` 获取目标 Symbol 数据的 `updated_at`。
|
||||||
|
3. **Step 2 (Decision)**:
|
||||||
|
* 若 `now - updated_at < TTL`: **Hit Cache**. Log "Cache Hit", 跳过外部请求,直接进入 Step 4。
|
||||||
|
* 若数据不存在 或 `now - updated_at > TTL`: **Miss Cache**. Log "Cache Miss", 执行外部 API 抓取。
|
||||||
|
4. **Step 3 (Upsert)**: 将抓取的数据存入 DB (Update `updated_at` = now)。
|
||||||
|
5. **Step 4 (Publish Event)**: 发布 `CompanyDataPersistedEvent` (包含 symbol, data_types: ["profile", "financials"])。
|
||||||
|
|
||||||
|
### 3.3. 工作流编排 (Workflow Orchestration)
|
||||||
|
* **API Gateway**:
|
||||||
|
* 移除 `POST /data-requests` 中自动触发 Analysis 的逻辑。
|
||||||
|
* 只发布 `FetchCompanyDataCommand`。
|
||||||
|
* **Report Generator**:
|
||||||
|
* **不再监听** `StartAnalysisCommand` (作为触发源)。
|
||||||
|
* 改为监听 `CompanyDataPersistedEvent`。
|
||||||
|
* 收到事件后,检查事件中的 `request_id` 是否关联了待处理的分析任务(或者简单的:收到数据更新就检查是否有待跑的分析模板)。
|
||||||
|
* *临时方案*: 为了简化,可以在 API Gateway 发送 Fetch 命令时,在 payload 里带上 `trigger_analysis: true` 和 `template_id`。Data Provider 在发出的 `PersistedEvent` 里透传这些字段。Report Generator 看到 `trigger_analysis: true` 才执行。
|
||||||
|
|
||||||
|
### 3.4. API 修复 (Fixing Visibility)
|
||||||
|
* **Backend (API Gateway)**:
|
||||||
|
* 在 `create_v1_router` 中新增路由:
|
||||||
|
* `GET /v1/market-data/financial-statements/{symbol}` -> 转发至 Data Persistence Service。
|
||||||
|
* `GET /v1/market-data/quotes/{symbol}` -> 转发至 Data Persistence Service (可选)。
|
||||||
|
* **Frontend (Next.js API Route)**:
|
||||||
|
* 修改 `frontend/src/app/api/financials/[...slug]/route.ts`。
|
||||||
|
* 将请求路径从 `${BACKEND_BASE}/market-data/...` 修正为 `${BACKEND_BASE}/v1/market-data/...` (匹配 Gateway 新路由)。
|
||||||
|
* 或者直接修正为 Data Persistence Service 的正确路径 (但最佳实践是走 Gateway)。
|
||||||
|
|
||||||
|
## 4. 执行计划 (Action Items)
|
||||||
|
|
||||||
|
### Phase 1: API & Frontend 可见性修复 (立即执行)
|
||||||
|
1. [x] **API Gateway**: 添加 `/v1/market-data/financial-statements/{symbol}` 路由。
|
||||||
|
2. [x] **Frontend**: 修正 `route.ts` 中的后端请求路径。(通过修正 Gateway 路由适配前端)
|
||||||
|
3. [ ] **验证**: 打开页面,应能看到(哪怕是旧的)财务图表数据,不再显示 404/无数据。
|
||||||
|
|
||||||
|
### Phase 2: 缓存与时效性逻辑 (核心)
|
||||||
|
4. [x] **Data Providers**: 在 `worker.rs` 中实现 TTL 检查逻辑 (Profile: 30d, Financials: 24h)。(YFinance 已实现,其他 Provider 已适配事件)
|
||||||
|
5. [x] **Persistence Service**: 确保 `get_company_profile` 和 `get_financials` 返回 `updated_at` 字段(如果还没有的话)。
|
||||||
|
|
||||||
|
### Phase 3: 事件驱动工作流 (解决 Race Condition)
|
||||||
|
6. [x] **Contracts**: 定义新事件 `CompanyDataPersistedEvent` (含 `trigger_analysis` 标记)。
|
||||||
|
7. [x] **API Gateway**: 停止直接发送 Analysis 命令,将其参数打包进 Fetch 命令。
|
||||||
|
8. [x] **Data Providers**: 完成任务后发布 `PersistedEvent`。
|
||||||
|
9. [x] **Report Generator**: 监听 `PersistedEvent` 触发分析。
|
||||||
|
|
||||||
|
## 5. 待确认
|
||||||
|
* 是否需要为每个数据源单独设置 TTL?(暂定统一策略)
|
||||||
|
* 前端是否需要显示数据的"上次更新时间"?(建议加上,增强用户信任)
|
||||||
55
frontend/Dockerfile.prod
Normal file
55
frontend/Dockerfile.prod
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# 1. Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 2. Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY frontend ./
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 3. Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
@ -13,6 +13,9 @@ const nextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
proxyTimeout: 300000, // 300 seconds (5 minutes)
|
proxyTimeout: 300000, // 300 seconds (5 minutes)
|
||||||
},
|
},
|
||||||
|
// Optimize for Docker deployment only in production
|
||||||
|
// 当 NODE_ENV 为 production 时开启 standalone 模式
|
||||||
|
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
29
frontend/src/app/api/analysis-results/route.ts
Normal file
29
frontend/src/app/api/analysis-results/route.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const symbol = searchParams.get('symbol');
|
||||||
|
|
||||||
|
if (!symbol) {
|
||||||
|
return new Response('Missing symbol parameter', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/analysis-results?symbol=${encodeURIComponent(symbol)}`, { cache: 'no-store' });
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 404) {
|
||||||
|
// Return empty list if not found, to avoid UI errors
|
||||||
|
return Response.json([]);
|
||||||
|
}
|
||||||
|
return new Response(resp.statusText, { status: resp.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
return Response.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
41
frontend/src/app/api/configs/llm/test/route.ts
Normal file
41
frontend/src/app/api/configs/llm/test/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// 将请求转发到 API Gateway
|
||||||
|
const targetUrl = `${BACKEND_BASE.replace(/\/$/, '')}/configs/llm/test`;
|
||||||
|
|
||||||
|
const backendRes = await fetch(targetUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const backendResBody = await backendRes.text();
|
||||||
|
|
||||||
|
return new Response(backendResBody, {
|
||||||
|
status: backendRes.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('LLM测试代理失败:', error);
|
||||||
|
return new Response(JSON.stringify({ success: false, message: error.message || '代理请求时发生未知错误' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -13,6 +13,85 @@ export async function GET(
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const { slug } = await context.params;
|
const { slug } = await context.params;
|
||||||
const first = slug?.[0];
|
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
|
||||||
|
// Corrected path to match new API Gateway route
|
||||||
|
const metricsParam = url.searchParams.get('metrics') || '';
|
||||||
|
const fetchUrl = `${BACKEND_BASE}/market-data/financial-statements/${encodeURIComponent(symbol)}` +
|
||||||
|
(metricsParam ? `?metrics=${encodeURIComponent(metricsParam)}` : '');
|
||||||
|
|
||||||
|
const finResp = await fetch(fetchUrl, { 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
|
||||||
|
// Corrected path to match new API Gateway route
|
||||||
|
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 → 新分析模块配置
|
// 适配旧接口:analysis-config → 新分析模块配置
|
||||||
if (first === 'analysis-config') {
|
if (first === 'analysis-config') {
|
||||||
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });
|
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });
|
||||||
@ -28,6 +107,64 @@ export async function GET(
|
|||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
return new Response(text, { status: resp.status, headers: { 'Content-Type': 'application/json' } });
|
return new Response(text, { status: resp.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Match /api/financials/{market}/{symbol}/analysis/{type}/stream
|
||||||
|
// slug length = 5
|
||||||
|
// slug[0] = market
|
||||||
|
// slug[1] = symbol
|
||||||
|
// slug[2] = 'analysis'
|
||||||
|
// slug[3] = analysisType (module_id)
|
||||||
|
// slug[4] = 'stream'
|
||||||
|
if (slug.length === 5 && slug[2] === 'analysis' && slug[4] === 'stream') {
|
||||||
|
const symbol = slug[1];
|
||||||
|
const analysisType = slug[3];
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
// Polling logic
|
||||||
|
// We try for up to 60 seconds
|
||||||
|
const maxRetries = 30;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/analysis-results?symbol=${encodeURIComponent(symbol)}&module_id=${encodeURIComponent(analysisType)}`, { cache: 'no-store' });
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const results = await resp.json();
|
||||||
|
// Assuming results are sorted by created_at DESC (backend behavior)
|
||||||
|
if (Array.isArray(results) && results.length > 0) {
|
||||||
|
const latest = results[0];
|
||||||
|
// If result is found, send it and exit
|
||||||
|
if (latest && latest.content) {
|
||||||
|
controller.enqueue(encoder.encode(latest.content));
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error polling analysis results", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 2 seconds before next poll
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 其他旧 financials 端点在新架构中未实现:返回空对象以避免前端 JSON 解析错误
|
// 其他旧 financials 端点在新架构中未实现:返回空对象以避免前端 JSON 解析错误
|
||||||
return Response.json({}, { status: 200 });
|
return Response.json({}, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
686
frontend/src/app/config/components/AIConfigTab.tsx
Normal file
686
frontend/src/app/config/components/AIConfigTab.tsx
Normal file
@ -0,0 +1,686 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useLlmProviders, updateLlmProviders, discoverProviderModels, testLlmModel } from '@/hooks/useApi';
|
||||||
|
import type { LlmProvidersConfig, LlmModel } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export function AIConfigTab({
|
||||||
|
newProviderBaseUrl,
|
||||||
|
setNewProviderBaseUrl
|
||||||
|
}: {
|
||||||
|
newProviderBaseUrl: string;
|
||||||
|
setNewProviderBaseUrl: (url: string) => void;
|
||||||
|
}) {
|
||||||
|
const { data: llmProviders, mutate: mutateLlmProviders } = useLlmProviders();
|
||||||
|
|
||||||
|
const [localLlmProviders, setLocalLlmProviders] = useState<LlmProvidersConfig>({});
|
||||||
|
const [isSavingLlm, setIsSavingLlm] = useState(false);
|
||||||
|
const [llmSaveMessage, setLlmSaveMessage] = useState('');
|
||||||
|
|
||||||
|
// New Provider Form State
|
||||||
|
const [newProviderId, setNewProviderId] = useState('');
|
||||||
|
// newProviderBaseUrl is passed as prop
|
||||||
|
const [newProviderApiKey, setNewProviderApiKey] = useState('');
|
||||||
|
|
||||||
|
// Provider Management State
|
||||||
|
const [pendingApiKeys, setPendingApiKeys] = useState<Record<string, string>>({});
|
||||||
|
const [editingApiKey, setEditingApiKey] = useState<Record<string, boolean>>({});
|
||||||
|
const [discoverMessages, setDiscoverMessages] = useState<Record<string, string>>({});
|
||||||
|
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 Input State
|
||||||
|
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>>({});
|
||||||
|
|
||||||
|
// Test State
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
const [isTesting, setIsTesting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs for Auto-save
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Initialize from Server Data
|
||||||
|
useEffect(() => {
|
||||||
|
if (llmProviders) {
|
||||||
|
setLocalLlmProviders(llmProviders);
|
||||||
|
const normalized = normalizeProviders(llmProviders);
|
||||||
|
latestServerPayloadRef.current = JSON.stringify(normalized);
|
||||||
|
llmDirtyRef.current = false;
|
||||||
|
}
|
||||||
|
}, [llmProviders, normalizeProviders]);
|
||||||
|
|
||||||
|
// Auto-save Effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedLlmRef.current) {
|
||||||
|
hasInitializedLlmRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!llmDirtyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
autoSaveTimerRef.current = setTimeout(() => {
|
||||||
|
void flushSaveLlmImmediate();
|
||||||
|
}, 500);
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimerRef.current) {
|
||||||
|
clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [localLlmProviders, pendingApiKeys, flushSaveLlmImmediate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>AI Provider 管理</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 h-5">
|
||||||
|
{isSavingLlm && (
|
||||||
|
<>
|
||||||
|
<Spinner className="text-gray-600" />
|
||||||
|
<span className="text-xs text-muted-foreground">自动保存中...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isSavingLlm && llmSaveMessage && (
|
||||||
|
<span className={`text-xs ${llmSaveMessage.includes('失败') ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{llmSaveMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>管理多个 Provider(API Key、Base URL)及其模型清单</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 新增 Provider */}
|
||||||
|
<div className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-id">Provider ID</Label>
|
||||||
|
<Input id="provider-id" value={newProviderId} onChange={(e) => setNewProviderId(e.target.value)} placeholder="例如: openai_official" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-baseurl">Base URL</Label>
|
||||||
|
<Input id="provider-baseurl" value={newProviderBaseUrl} onChange={(e) => setNewProviderBaseUrl(e.target.value)} placeholder="例如: https://api.openai.com/v1" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="provider-apikey">API Key</Label>
|
||||||
|
<Input id="provider-apikey" type="password" value={newProviderApiKey} onChange={(e) => setNewProviderApiKey(e.target.value)} placeholder="输入新Provider的API Key" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
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('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增 Provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Provider 列表 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{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 (
|
||||||
|
<div key={providerId} className="space-y-4 p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold">{providerId}</h3>
|
||||||
|
<Badge variant="secondary">Base on ID</Badge>
|
||||||
|
<span className="ml-2 inline-flex items-center justify-start w-24 h-5">
|
||||||
|
{isSavingLlm ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Spinner className="text-gray-600" />
|
||||||
|
正在保存…
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{provider.api_base_url}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={async () => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}>刷新候选模型</Button>
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setModelPickerOpen(prev => ({ ...prev, [providerId]: !prev[providerId] }));
|
||||||
|
}}>{modelPickerOpen[providerId] ? '收起模型管理' : '管理模型'}</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...localLlmProviders };
|
||||||
|
delete next[providerId];
|
||||||
|
markLlmDirty();
|
||||||
|
setLocalLlmProviders(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除 Provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="text-sm">{message}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modelPickerOpen[providerId] && (
|
||||||
|
<div className="space-y-3 p-3 border rounded-md bg-white/40">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>搜索候选模型</Label>
|
||||||
|
<Input
|
||||||
|
value={modelSearch[providerId] || ''}
|
||||||
|
onChange={(e) => setModelSearch(prev => ({ ...prev, [providerId]: e.target.value }))}
|
||||||
|
placeholder="输入关键字过滤(前缀/包含均可)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
const sel: Record<string, boolean> = { ...(selectedCandidates[providerId] || {}) };
|
||||||
|
filteredCandidates.forEach(id => { sel[id] = true; });
|
||||||
|
setSelectedCandidates(prev => ({ ...prev, [providerId]: sel }));
|
||||||
|
}}>全选筛选结果</Button>
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setSelectedCandidates(prev => ({ ...prev, [providerId]: {} }));
|
||||||
|
}}>清空选择</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={() => {
|
||||||
|
const sel = selectedCandidates[providerId] || {};
|
||||||
|
const toAddIds = Object.keys(sel).filter(id => sel[id]);
|
||||||
|
if (toAddIds.length === 0) return;
|
||||||
|
markLlmDirty();
|
||||||
|
setLocalLlmProviders(prev => {
|
||||||
|
const existing = new Set((prev[providerId]?.models || []).map(m => m.model_id));
|
||||||
|
const toAdd: LlmModel[] = toAddIds
|
||||||
|
.filter(id => !existing.has(id))
|
||||||
|
.map(id => ({ model_id: id, name: null, is_active: true }));
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
...prev[providerId],
|
||||||
|
models: [...(prev[providerId]?.models || []), ...toAdd],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setSelectedCandidates(prev => ({ ...prev, [providerId]: {} }));
|
||||||
|
}}>添加选中模型</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="border rounded-md" style={{ maxHeight: 280 }}>
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{filteredCandidates.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground p-2">无匹配的候选模型</div>
|
||||||
|
) : (
|
||||||
|
filteredCandidates.map(id => {
|
||||||
|
const checked = !!selectedMap[id];
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
|
||||||
|
<div className="truncate text-sm">{id}</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
setSelectedCandidates(prev => {
|
||||||
|
const cur = { ...(prev[providerId] || {}) };
|
||||||
|
if (v) cur[id] = true; else delete cur[id];
|
||||||
|
return { ...prev, [providerId]: cur };
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Base URL</Label>
|
||||||
|
<Input
|
||||||
|
value={provider.api_base_url}
|
||||||
|
onChange={(e) => {
|
||||||
|
markLlmDirty();
|
||||||
|
setLocalLlmProviders(prev => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { ...prev[providerId], api_base_url: e.target.value },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>API Key</Label>
|
||||||
|
{!editingApiKey[providerId] ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={provider.api_key ? 'default' : 'secondary'}>
|
||||||
|
{provider.api_key ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditingApiKey(prev => ({ ...prev, [providerId]: true }))}
|
||||||
|
>
|
||||||
|
更改 API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="输入新的 API Key"
|
||||||
|
value={pendingApiKeys[providerId] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
markLlmDirty();
|
||||||
|
setPendingApiKeys(prev => ({ ...prev, [providerId]: e.target.value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditingApiKey(prev => ({ ...prev, [providerId]: false }))}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label>模型列表</Label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(provider.models || []).map((m) => (
|
||||||
|
<div key={m.model_id} className="grid grid-cols-1 md:grid-cols-3 gap-3 items-center">
|
||||||
|
<Input
|
||||||
|
value={m.model_id}
|
||||||
|
onChange={(e) => {
|
||||||
|
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
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={m.name || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
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="可选别名"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${providerId}-${m.model_id}-active`}
|
||||||
|
checked={!!m.is_active}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
markLlmDirty();
|
||||||
|
setLocalLlmProviders(prev => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
...prev[providerId],
|
||||||
|
models: (prev[providerId].models || []).map(mm =>
|
||||||
|
mm.model_id === m.model_id ? { ...mm, is_active: !!checked } : mm
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${providerId}-${m.model_id}-active`} className="text-sm">启用</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isTesting === m.model_id}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsTesting(m.model_id);
|
||||||
|
try {
|
||||||
|
const res = await testLlmModel(provider.api_base_url, provider.api_key || pendingApiKeys[providerId] || '', m.model_id);
|
||||||
|
setTestResult({ success: true, message: typeof res === 'string' ? res : JSON.stringify(res, null, 2) });
|
||||||
|
} catch (e: any) {
|
||||||
|
setTestResult({ success: false, message: e.message || 'Test failed' });
|
||||||
|
} finally {
|
||||||
|
setIsTesting(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isTesting === m.model_id ? <Spinner className="h-3 w-3" /> : 'Test'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
markLlmDirty();
|
||||||
|
setLocalLlmProviders(prev => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
...prev[providerId],
|
||||||
|
models: (prev[providerId].models || []).filter(mm => mm.model_id !== m.model_id),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-start">
|
||||||
|
<div className="space-y-1 relative">
|
||||||
|
<Input
|
||||||
|
placeholder="新增模型 ID(可从候选中选择)"
|
||||||
|
value={newModelInputs[providerId] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 (
|
||||||
|
<div className="absolute z-10 mt-1 w-full border rounded-md bg-white shadow max-h-60 overflow-auto">
|
||||||
|
{list.map((id, idx) => (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
className={`px-2 py-1 text-sm cursor-pointer ${hi === idx ? 'bg-gray-100' : 'hover:bg-gray-50'}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: idx }));
|
||||||
|
}}
|
||||||
|
onMouseDown={(ev) => {
|
||||||
|
// 防止失焦导致菜单关闭
|
||||||
|
ev.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setNewModelInputs(prev => ({ ...prev, [providerId]: id }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{id}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="模型别名(可选)"
|
||||||
|
value={newModelNameInputs[providerId] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setNewModelNameInputs(prev => ({ ...prev, [providerId]: v }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const id = (newModelInputs[providerId] || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
const exists = new Set((provider.models || []).map(m => m.model_id));
|
||||||
|
if (exists.has(id)) return;
|
||||||
|
const name = (newModelNameInputs[providerId] || '').trim();
|
||||||
|
markLlmDirty();
|
||||||
|
setLocalLlmProviders(prev => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
...prev[providerId],
|
||||||
|
models: [
|
||||||
|
...(prev[providerId].models || []),
|
||||||
|
{ model_id: id, name: name || null, is_active: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setNewModelInputs(prev => ({ ...prev, [providerId]: '' }));
|
||||||
|
setNewModelNameInputs(prev => ({ ...prev, [providerId]: '' }));
|
||||||
|
setNewModelMenuOpen(prev => ({ ...prev, [providerId]: false }));
|
||||||
|
setNewModelHighlightIndex(prev => ({ ...prev, [providerId]: 0 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
若无候选,请先点击上方“刷新候选模型”加载后再输入筛选。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={!!testResult} onOpenChange={(open) => !open && setTestResult(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{testResult?.success ? 'Test Successful' : 'Test Failed'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Response from model:
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-[300px] overflow-auto p-4 bg-muted rounded-md whitespace-pre-wrap font-mono text-sm">
|
||||||
|
{testResult?.message}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setTestResult(null)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
frontend/src/app/config/components/AnalysisConfigTab.tsx
Normal file
383
frontend/src/app/config/components/AnalysisConfigTab.tsx
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ModelSelector } from './ModelSelector';
|
||||||
|
import { useAnalysisTemplateSets, updateAnalysisTemplateSets, useLlmProviders } from '@/hooks/useApi';
|
||||||
|
import type { AnalysisTemplateSets, LlmModel } from '@/types';
|
||||||
|
|
||||||
|
export function AnalysisConfigTab() {
|
||||||
|
// LLM Providers for Model Selector
|
||||||
|
const { data: llmProviders } = useLlmProviders();
|
||||||
|
const allModels = useMemo(() => {
|
||||||
|
if (!llmProviders) return [];
|
||||||
|
const models: { providerId: string; providerName: string; model: LlmModel }[] = [];
|
||||||
|
Object.entries(llmProviders).forEach(([pId, provider]) => {
|
||||||
|
provider.models.forEach(m => {
|
||||||
|
if (m.is_active) {
|
||||||
|
models.push({
|
||||||
|
providerId: pId,
|
||||||
|
providerName: provider.name || pId,
|
||||||
|
model: m
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return models;
|
||||||
|
}, [llmProviders]);
|
||||||
|
|
||||||
|
// Analysis Config State
|
||||||
|
const { data: initialAnalysisTemplateSets, mutate: mutateAnalysisTemplateSets } = useAnalysisTemplateSets();
|
||||||
|
const [localTemplateSets, setLocalTemplateSets] = useState<AnalysisTemplateSets>({});
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(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('');
|
||||||
|
|
||||||
|
// Initialize local state
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialAnalysisTemplateSets) return;
|
||||||
|
setLocalTemplateSets(initialAnalysisTemplateSets);
|
||||||
|
// Only set if null (first load), to avoid resetting user selection on revalidation
|
||||||
|
setSelectedTemplateId(prev => prev ?? (Object.keys(initialAnalysisTemplateSets)[0] || null));
|
||||||
|
}, [initialAnalysisTemplateSets]);
|
||||||
|
|
||||||
|
const handleAnalysisChange = (moduleId: string, field: string, value: any) => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
setLocalTemplateSets(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedTemplateId]: {
|
||||||
|
...prev[selectedTemplateId],
|
||||||
|
modules: {
|
||||||
|
...prev[selectedTemplateId].modules,
|
||||||
|
[moduleId]: {
|
||||||
|
...prev[selectedTemplateId].modules[moduleId],
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAnalysis = async () => {
|
||||||
|
setIsSavingAnalysis(true);
|
||||||
|
setAnalysisSaveMessage('保存中...');
|
||||||
|
try {
|
||||||
|
const updated = await updateAnalysisTemplateSets(localTemplateSets);
|
||||||
|
await mutateAnalysisTemplateSets(updated, false);
|
||||||
|
setAnalysisSaveMessage('分析配置保存成功!');
|
||||||
|
} catch (e: any) {
|
||||||
|
setAnalysisSaveMessage(`保存失败: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsSavingAnalysis(false);
|
||||||
|
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAnalysisDependencies = (moduleId: string, dependency: string, checked: boolean) => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
setLocalTemplateSets(prev => {
|
||||||
|
const currentModule = prev[selectedTemplateId].modules[moduleId];
|
||||||
|
const currentDeps = currentModule.dependencies || [];
|
||||||
|
const newDeps = checked
|
||||||
|
? [...currentDeps, dependency]
|
||||||
|
: currentDeps.filter(d => d !== dependency);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[selectedTemplateId]: {
|
||||||
|
...prev[selectedTemplateId],
|
||||||
|
modules: {
|
||||||
|
...prev[selectedTemplateId].modules,
|
||||||
|
[moduleId]: {
|
||||||
|
...currentModule,
|
||||||
|
dependencies: [...new Set(newDeps)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTemplate = () => {
|
||||||
|
if (!newTemplateId || !newTemplateName) {
|
||||||
|
setAnalysisSaveMessage('模板 ID 和名称不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localTemplateSets[newTemplateId]) {
|
||||||
|
setAnalysisSaveMessage('模板 ID 已存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSet: AnalysisTemplateSets = {
|
||||||
|
...localTemplateSets,
|
||||||
|
[newTemplateId]: {
|
||||||
|
name: newTemplateName,
|
||||||
|
modules: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setLocalTemplateSets(newSet);
|
||||||
|
setSelectedTemplateId(newTemplateId);
|
||||||
|
setNewTemplateId('');
|
||||||
|
setNewTemplateName('');
|
||||||
|
setIsCreatingTemplate(false);
|
||||||
|
// 新建后立即持久化
|
||||||
|
(async () => {
|
||||||
|
setIsSavingAnalysis(true);
|
||||||
|
setAnalysisSaveMessage('保存中...');
|
||||||
|
try {
|
||||||
|
const updated = await updateAnalysisTemplateSets(newSet);
|
||||||
|
await mutateAnalysisTemplateSets(updated, false);
|
||||||
|
setAnalysisSaveMessage('分析配置保存成功!');
|
||||||
|
} catch (e: any) {
|
||||||
|
setAnalysisSaveMessage(`保存失败: ${e?.message || '未知错误'}`);
|
||||||
|
} finally {
|
||||||
|
setIsSavingAnalysis(false);
|
||||||
|
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTemplate = () => {
|
||||||
|
if (!selectedTemplateId || !window.confirm(`确定要删除模板 "${localTemplateSets[selectedTemplateId].name}" 吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newSets = { ...localTemplateSets };
|
||||||
|
delete newSets[selectedTemplateId];
|
||||||
|
setLocalTemplateSets(newSets);
|
||||||
|
const firstKey = Object.keys(newSets)[0] || null;
|
||||||
|
setSelectedTemplateId(firstKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNewModule = () => {
|
||||||
|
if (!selectedTemplateId || !newModuleId || !newModuleName) {
|
||||||
|
setAnalysisSaveMessage('模块 ID 和名称不能为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localTemplateSets[selectedTemplateId].modules[newModuleId]) {
|
||||||
|
setAnalysisSaveMessage('模块 ID 已存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocalTemplateSets(prev => ({
|
||||||
|
...prev,
|
||||||
|
[selectedTemplateId]: {
|
||||||
|
...prev[selectedTemplateId],
|
||||||
|
modules: {
|
||||||
|
...prev[selectedTemplateId].modules,
|
||||||
|
[newModuleId]: {
|
||||||
|
name: newModuleName,
|
||||||
|
provider_id: '',
|
||||||
|
model_id: '',
|
||||||
|
prompt_template: '',
|
||||||
|
dependencies: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setNewModuleId('');
|
||||||
|
setNewModuleName('');
|
||||||
|
setIsCreatingModule(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteModule = (moduleId: string) => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
setLocalTemplateSets(prev => {
|
||||||
|
const newModules = { ...prev[selectedTemplateId].modules };
|
||||||
|
delete newModules[moduleId];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[selectedTemplateId]: {
|
||||||
|
...prev[selectedTemplateId],
|
||||||
|
modules: newModules,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>分析模板与模块配置</CardTitle>
|
||||||
|
<CardDescription>管理不同的分析模板集,并为每个模板集内的模块配置模型、提示词和依赖关系。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
|
||||||
|
{/* --- Level 1: Template Set Management --- */}
|
||||||
|
<div className="p-4 border rounded-lg bg-slate-50 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Label className="font-semibold">当前分析模板:</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedTemplateId || ''}
|
||||||
|
onValueChange={(id) => setSelectedTemplateId(id)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[280px]">
|
||||||
|
<SelectValue placeholder="选择一个模板..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(localTemplateSets).map(([id, set]) => (
|
||||||
|
<SelectItem key={id} value={id}>{set.name} ({id})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" onClick={() => setIsCreatingTemplate(true)} disabled={isCreatingTemplate}>+ 新建模板</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteTemplate} disabled={!selectedTemplateId}>删除当前模板</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCreatingTemplate && (
|
||||||
|
<div className="space-y-3 p-3 border rounded-md border-dashed">
|
||||||
|
<h4 className="font-semibold">新建分析模板集</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="模板 ID (e.g., standard_v2)"
|
||||||
|
value={newTemplateId}
|
||||||
|
onChange={(e) => setNewTemplateId(e.target.value.replace(/\s/g, ''))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="模板名称 (e.g., 标准分析模板V2)"
|
||||||
|
value={newTemplateName}
|
||||||
|
onChange={(e) => setNewTemplateName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleAddTemplate}>确认创建</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setIsCreatingTemplate(false)}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* --- Level 2: Module Management (within selected template) --- */}
|
||||||
|
{selectedTemplateId && localTemplateSets[selectedTemplateId] ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(localTemplateSets[selectedTemplateId].modules).map(([moduleId, config]) => {
|
||||||
|
const allModulesInSet = localTemplateSets[selectedTemplateId].modules;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={moduleId} className="space-y-4 p-4 border rounded-lg">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold">{config.name || moduleId}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">ID: <Badge variant="secondary">{moduleId}</Badge></p>
|
||||||
|
</div>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => handleDeleteModule(moduleId)}>删除模块</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Model (Provider)</Label>
|
||||||
|
<ModelSelector
|
||||||
|
value={{ providerId: config.provider_id, modelId: config.model_id }}
|
||||||
|
onChange={(pId, mId) => {
|
||||||
|
handleAnalysisChange(moduleId, 'provider_id', pId);
|
||||||
|
handleAnalysisChange(moduleId, 'model_id', mId);
|
||||||
|
}}
|
||||||
|
allModels={allModels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${moduleId}-prompt`}>提示词模板</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`${moduleId}-prompt`}
|
||||||
|
value={config.prompt_template || ''}
|
||||||
|
onChange={(e) => handleAnalysisChange(moduleId, 'prompt_template', e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>依赖模块 (Dependencies)</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 p-2 border rounded-md">
|
||||||
|
{Object.keys(allModulesInSet)
|
||||||
|
.filter(id => id !== moduleId)
|
||||||
|
.map(depId => (
|
||||||
|
<div key={depId} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${moduleId}-${depId}`}
|
||||||
|
checked={(config.dependencies || []).includes(depId)}
|
||||||
|
onCheckedChange={(checked) => updateAnalysisDependencies(moduleId, depId, !!checked)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${moduleId}-${depId}`}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{allModulesInSet[depId]?.name || depId}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isCreatingModule && (
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg border-dashed">
|
||||||
|
<h3 className="text-lg font-semibold">在 "{localTemplateSets[selectedTemplateId].name}" 中新增分析模块</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-module-id">模块 ID (英文, 无空格)</Label>
|
||||||
|
<Input
|
||||||
|
id="new-module-id"
|
||||||
|
value={newModuleId}
|
||||||
|
onChange={(e) => setNewModuleId(e.target.value.replace(/\s/g, ''))}
|
||||||
|
placeholder="e.g. fundamental_analysis"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-module-name">模块名称</Label>
|
||||||
|
<Input
|
||||||
|
id="new-module-name"
|
||||||
|
value={newModuleName}
|
||||||
|
onChange={(e) => setNewModuleName(e.target.value)}
|
||||||
|
placeholder="e.g. 基本面分析"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleAddNewModule}>确认新增</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setIsCreatingModule(false)}>取消</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-4 border-t">
|
||||||
|
<Button onClick={() => setIsCreatingModule(true)} variant="outline" disabled={isCreatingModule}>
|
||||||
|
+ 新增分析模块
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveAnalysis} disabled={isSavingAnalysis}>
|
||||||
|
{isSavingAnalysis ? '保存中...' : '保存所有变更'}
|
||||||
|
</Button>
|
||||||
|
{analysisSaveMessage && (
|
||||||
|
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{analysisSaveMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-10">
|
||||||
|
<p>请先选择或创建一个分析模板集。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
159
frontend/src/app/config/components/DataSourcesConfigTab.tsx
Normal file
159
frontend/src/app/config/components/DataSourcesConfigTab.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { formatDetailsToYaml } from '../utils';
|
||||||
|
import type { DataSourcesConfig, DataSourceConfig, DataSourceProvider } from '@/types';
|
||||||
|
|
||||||
|
interface DataSourcesConfigTabProps {
|
||||||
|
dsLoading: boolean;
|
||||||
|
dsError: any;
|
||||||
|
localDataSources: DataSourcesConfig;
|
||||||
|
setLocalDataSources: React.Dispatch<React.SetStateAction<DataSourcesConfig>>;
|
||||||
|
testResults: Record<string, { success: boolean; summary: string; details?: string } | null>;
|
||||||
|
handleTestTushare: () => void;
|
||||||
|
handleTestFinnhub: () => void;
|
||||||
|
handleTestAlphaVantage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 DataSourcesConfigTab({
|
||||||
|
dsLoading,
|
||||||
|
dsError,
|
||||||
|
localDataSources,
|
||||||
|
setLocalDataSources,
|
||||||
|
testResults,
|
||||||
|
handleTestTushare,
|
||||||
|
handleTestFinnhub,
|
||||||
|
handleTestAlphaVantage,
|
||||||
|
}: DataSourcesConfigTabProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>数据源配置</CardTitle>
|
||||||
|
<CardDescription>外部数据源 API 设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{dsLoading && <div className="text-sm text-muted-foreground">加载数据源配置...</div>}
|
||||||
|
{dsError && <div className="text-sm text-red-600">数据源配置加载失败: {String(dsError)}</div>}
|
||||||
|
{!dsLoading && !dsError && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{(['tushare','finnhub','alphavantage','yfinance'] as DataSourceProvider[]).map((providerKey) => {
|
||||||
|
const item: DataSourceConfig = localDataSources[providerKey] || {
|
||||||
|
provider: providerKey,
|
||||||
|
api_key: '',
|
||||||
|
api_url: '',
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={providerKey} className="space-y-3 p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-base font-medium capitalize">{providerKey}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{providerKey === 'tushare' && '中国市场数据源'}
|
||||||
|
{providerKey === 'finnhub' && '全球市场数据源'}
|
||||||
|
{providerKey === 'alphavantage' && '全球市场数据源(MCP桥接)'}
|
||||||
|
{providerKey === 'yfinance' && '雅虎财经数据源'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${providerKey}-enabled`}
|
||||||
|
checked={!!item.enabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setLocalDataSources((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerKey]: { ...item, enabled: !!checked, provider: providerKey },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`${providerKey}-enabled`} className="text-sm">启用</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${providerKey}-api-key`}>API Key</Label>
|
||||||
|
<Input
|
||||||
|
id={`${providerKey}-api-key`}
|
||||||
|
type="password"
|
||||||
|
value={item.api_key ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setLocalDataSources((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerKey]: { ...item, api_key: v, provider: providerKey },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder="留空表示清空或保持(根据启用状态)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`${providerKey}-api-url`}>API URL</Label>
|
||||||
|
<Input
|
||||||
|
id={`${providerKey}-api-url`}
|
||||||
|
type="text"
|
||||||
|
value={item.api_url ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setLocalDataSources((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerKey]: { ...item, api_url: v, provider: providerKey },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={defaultUrls[providerKey] ?? 'https://...'}
|
||||||
|
disabled={providerKey === 'yfinance'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{providerKey === 'tushare' && (
|
||||||
|
<Button variant="outline" onClick={handleTestTushare}>测试 Tushare</Button>
|
||||||
|
)}
|
||||||
|
{providerKey === 'finnhub' && (
|
||||||
|
<Button variant="outline" onClick={handleTestFinnhub}>测试 Finnhub</Button>
|
||||||
|
)}
|
||||||
|
{providerKey === 'alphavantage' && (
|
||||||
|
<Button variant="outline" onClick={handleTestAlphaVantage}>测试 AlphaVantage</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{testResults[providerKey] ? (() => {
|
||||||
|
const r = testResults[providerKey]!;
|
||||||
|
if (r.success) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-green-600">
|
||||||
|
{r.summary}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-red-600">
|
||||||
|
<div className="font-medium">测试失败</div>
|
||||||
|
{r.details ? (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer text-red-700 underline">查看详细错误(YAML)</summary>
|
||||||
|
<pre className="mt-2 p-2 rounded bg-red-50 text-red-700 whitespace-pre-wrap break-words">
|
||||||
|
{formatDetailsToYaml(r.details)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
85
frontend/src/app/config/components/ModelSelector.tsx
Normal file
85
frontend/src/app/config/components/ModelSelector.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import type { LlmModel } from '@/types';
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{selectedModel
|
||||||
|
? `${selectedModel.model.name || selectedModel.model.model_id} (${selectedModel.providerName})`
|
||||||
|
: (value.modelId ? `${value.modelId}` : "选择模型")}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="搜索模型..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>未找到模型。</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allModels.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${item.providerId}-${item.model.model_id}`}
|
||||||
|
value={`${item.model.name || item.model.model_id} ${item.providerName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(item.providerId, item.model.model_id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value.modelId === item.model.model_id && value.providerId === item.providerId
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{item.model.name || item.model.model_id}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
({item.providerName})
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
128
frontend/src/app/config/components/SystemConfigTab.tsx
Normal file
128
frontend/src/app/config/components/SystemConfigTab.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { SystemConfig } from '@/stores/useConfigStore';
|
||||||
|
|
||||||
|
interface SystemConfigTabProps {
|
||||||
|
config: SystemConfig | null;
|
||||||
|
setSaveMessage: (msg: string) => void;
|
||||||
|
setNewProviderBaseUrl: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemConfigTab({ config, setSaveMessage, setNewProviderBaseUrl }: SystemConfigTabProps) {
|
||||||
|
|
||||||
|
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>) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>系统信息</CardTitle>
|
||||||
|
<CardDescription>当前系统状态和配置概览</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New API</Label>
|
||||||
|
<Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>
|
||||||
|
{config?.new_api?.api_key ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Tushare API</Label>
|
||||||
|
<Badge variant={config?.data_sources?.tushare?.api_key ? 'default' : 'secondary'}>
|
||||||
|
{config?.data_sources?.tushare?.api_key ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Finnhub API</Label>
|
||||||
|
<Badge variant={config?.data_sources?.finnhub?.api_key ? 'default' : 'secondary'}>
|
||||||
|
{config?.data_sources?.finnhub?.api_key ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>配置管理</CardTitle>
|
||||||
|
<CardDescription>导入、导出和备份配置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<Button onClick={handleExportConfig} variant="outline" className="flex-1">
|
||||||
|
📤 导出配置
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleImportConfig}
|
||||||
|
className="hidden"
|
||||||
|
id="import-config"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => document.getElementById('import-config')?.click()}
|
||||||
|
>
|
||||||
|
📥 导入配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>• 导出配置将下载当前所有配置的备份文件</p>
|
||||||
|
<p>• 导入配置将加载备份文件中的设置(不包含敏感信息)</p>
|
||||||
|
<p>• 建议定期备份配置以防数据丢失</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
139
frontend/src/app/config/utils.ts
Normal file
139
frontend/src/app/config/utils.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
|
||||||
|
// ---- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
115
frontend/src/app/report/[symbol]/components/AnalysisContent.tsx
Normal file
115
frontend/src/app/report/[symbol]/components/AnalysisContent.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
||||||
|
import { normalizeMarkdown, removeTitleFromContent } from '../utils';
|
||||||
|
|
||||||
|
interface AnalysisContentProps {
|
||||||
|
analysisType: string;
|
||||||
|
state: {
|
||||||
|
content: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
financials: any;
|
||||||
|
analysisConfig: any;
|
||||||
|
retryAnalysis: (type: string) => void;
|
||||||
|
currentAnalysisTask: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisContent({
|
||||||
|
analysisType,
|
||||||
|
state,
|
||||||
|
financials,
|
||||||
|
analysisConfig,
|
||||||
|
retryAnalysis,
|
||||||
|
currentAnalysisTask,
|
||||||
|
}: AnalysisContentProps) {
|
||||||
|
const analysisName = analysisType === 'company_profile'
|
||||||
|
? '公司简介'
|
||||||
|
: (analysisConfig?.analysis_modules?.[analysisType]?.name || analysisType);
|
||||||
|
const modelName = analysisConfig?.analysis_modules?.[analysisType]?.model;
|
||||||
|
|
||||||
|
// Process content
|
||||||
|
const contentWithoutTitle = removeTitleFromContent(state.content, analysisName);
|
||||||
|
const normalizedContent = normalizeMarkdown(contentWithoutTitle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">{analysisName}(来自 {modelName || 'AI'})</h2>
|
||||||
|
|
||||||
|
{!financials && (
|
||||||
|
<p className="text-sm text-muted-foreground">请等待财务数据加载完成...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{financials && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
{state.loading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : state.error ? (
|
||||||
|
<XCircle className="size-4 text-red-500" />
|
||||||
|
) : state.content ? (
|
||||||
|
<CheckCircle className="size-4 text-green-600" />
|
||||||
|
) : null}
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{state.loading
|
||||||
|
? `正在生成${analysisName}...`
|
||||||
|
: state.error
|
||||||
|
? '生成失败'
|
||||||
|
: state.content
|
||||||
|
? '生成完成'
|
||||||
|
: '待开始'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 始终可见的"重新生成分析"按钮 */}
|
||||||
|
{!state.loading && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => retryAnalysis(analysisType)}
|
||||||
|
disabled={currentAnalysisTask !== null}
|
||||||
|
>
|
||||||
|
<RotateCw className="size-4" />
|
||||||
|
重新生成分析
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<p className="text-red-500">加载失败: {state.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(state.loading || state.content) && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border rounded-lg p-6 bg-card">
|
||||||
|
<article className="markdown-body" style={{
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
minWidth: '200px',
|
||||||
|
maxWidth: '980px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '0'
|
||||||
|
}}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
>
|
||||||
|
{normalizedContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
{state.loading && (
|
||||||
|
<span className="inline-flex items-center gap-2 mt-2 text-muted-foreground">
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
<span className="text-sm">正在生成中...</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
147
frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx
Normal file
147
frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
||||||
|
import { formatMs } from '../utils';
|
||||||
|
|
||||||
|
interface AnalysisRecord {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'running' | 'done' | 'error';
|
||||||
|
start_ts?: string;
|
||||||
|
end_ts?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
tokens?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionDetailsProps {
|
||||||
|
financials: any;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: any;
|
||||||
|
analysisRecords: AnalysisRecord[];
|
||||||
|
currentAnalysisTask: string | null;
|
||||||
|
totalElapsedMs: number;
|
||||||
|
retryAnalysis: (type: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionDetails({
|
||||||
|
financials,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
analysisRecords,
|
||||||
|
currentAnalysisTask,
|
||||||
|
totalElapsedMs,
|
||||||
|
retryAnalysis,
|
||||||
|
}: ExecutionDetailsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">执行详情</h2>
|
||||||
|
|
||||||
|
{/* 执行概况卡片 */}
|
||||||
|
{financials && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">执行概况</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 财务数据状态 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : error ? (
|
||||||
|
<XCircle className="size-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="size-4 text-green-600" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium">财务数据</span>
|
||||||
|
</div>
|
||||||
|
{financials?.meta && (
|
||||||
|
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>耗时: {formatMs(financials.meta.elapsed_ms)}</div>
|
||||||
|
<div>API调用: {financials.meta.api_calls_total} 次</div>
|
||||||
|
<div>开始时间: {financials.meta.started_at}</div>
|
||||||
|
{financials.meta.finished_at && (
|
||||||
|
<div>结束时间: {financials.meta.finished_at}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分析任务状态 */}
|
||||||
|
{analysisRecords.length > 0 && (
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<div className="font-medium mb-2">分析任务</div>
|
||||||
|
<div className="ml-6 text-sm text-muted-foreground space-y-2">
|
||||||
|
{analysisRecords.map((record, idx) => (
|
||||||
|
<div key={idx} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{record.status === 'running' && <Spinner className="size-3" />}
|
||||||
|
{record.status === 'done' && <CheckCircle className="size-3 text-green-600" />}
|
||||||
|
{record.status === 'error' && <XCircle className="size-3 text-red-500" />}
|
||||||
|
<span className="font-medium">{record.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{record.status === 'running' ? '运行中' : record.status === 'done' ? '已完成' : record.status === 'error' ? '失败' : '待继续'}
|
||||||
|
</span>
|
||||||
|
{record.status === 'error' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
retryAnalysis(record.type);
|
||||||
|
}}
|
||||||
|
disabled={currentAnalysisTask !== null}
|
||||||
|
>
|
||||||
|
<RotateCw className="size-3" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{record.duration_ms !== undefined && (
|
||||||
|
<div className="ml-5">耗时: {formatMs(record.duration_ms)}</div>
|
||||||
|
)}
|
||||||
|
{record.tokens && (
|
||||||
|
<div className="ml-5">
|
||||||
|
Token: {record.tokens.total_tokens}
|
||||||
|
(Prompt: {record.tokens.prompt_tokens},
|
||||||
|
Completion: {record.tokens.completion_tokens})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{record.error && (
|
||||||
|
<div className="ml-5 text-red-500">错误: {record.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 总体统计 */}
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<div className="font-medium mb-2">总体统计</div>
|
||||||
|
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>总耗时: {formatMs(totalElapsedMs)}</div>
|
||||||
|
{financials?.meta?.steps && (
|
||||||
|
<div>财务数据完成步骤: {(financials.meta.steps as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).length}</div>
|
||||||
|
)}
|
||||||
|
{analysisRecords.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div>分析任务: {analysisRecords.filter(r => r.status === 'done').length}/{analysisRecords.length} 已完成</div>
|
||||||
|
<div>总Token消耗: {analysisRecords.reduce((sum, r) => sum + (r.tokens?.total_tokens || 0), 0)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
569
frontend/src/app/report/[symbol]/components/FinancialTable.tsx
Normal file
569
frontend/src/app/report/[symbol]/components/FinancialTable.tsx
Normal file
@ -0,0 +1,569 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import { formatReportPeriod } from '@/lib/financial-utils';
|
||||||
|
import { numberFormatter, integerFormatter } from '../utils';
|
||||||
|
|
||||||
|
interface FinancialTableProps {
|
||||||
|
financials: any;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: any;
|
||||||
|
financialConfig: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinancialTable({ financials, isLoading, error, financialConfig }: FinancialTableProps) {
|
||||||
|
// 创建 tushareParam 到 displayText 的映射
|
||||||
|
const metricDisplayMap = useMemo(() => {
|
||||||
|
if (!financialConfig?.api_groups) return {};
|
||||||
|
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
const groups = Object.values((financialConfig as any).api_groups || {}) as any[][];
|
||||||
|
groups.forEach((metrics) => {
|
||||||
|
(metrics || []).forEach((metric: any) => {
|
||||||
|
if (metric.tushareParam && metric.displayText) {
|
||||||
|
map[metric.tushareParam] = metric.displayText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [financialConfig]);
|
||||||
|
|
||||||
|
const metricGroupMap = useMemo(() => {
|
||||||
|
if (!financialConfig?.api_groups) return {} as Record<string, string>;
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
const entries = Object.entries((financialConfig as any).api_groups || {}) as [string, any[]][];
|
||||||
|
entries.forEach(([groupName, metrics]) => {
|
||||||
|
(metrics || []).forEach((metric: any) => {
|
||||||
|
if (metric.tushareParam) {
|
||||||
|
map[metric.tushareParam] = groupName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [financialConfig]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">财务数据</h2>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : error ? (
|
||||||
|
<XCircle className="size-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="size-4 text-green-600" />
|
||||||
|
)}
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{isLoading ? '正在读取数据…' : error ? '读取失败' : '读取完成'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500">加载失败</p>}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">加载中</span>
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{financials && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{(() => {
|
||||||
|
const series = financials?.series ?? {};
|
||||||
|
// 统一 period:优先 p.period;若仅有 year 则映射到 `${year}1231`
|
||||||
|
const toPeriod = (p: any): string | null => {
|
||||||
|
if (!p) return null;
|
||||||
|
if (p.period) return String(p.period);
|
||||||
|
if (p.year) return `${p.year}1231`;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayedKeys = [
|
||||||
|
'roe', 'roa', 'roic', 'grossprofit_margin', 'netprofit_margin', 'revenue', 'tr_yoy', 'n_income',
|
||||||
|
'dt_netprofit_yoy', 'n_cashflow_act', 'c_pay_acq_const_fiolta', '__free_cash_flow',
|
||||||
|
'dividend_amount', 'repurchase_amount', 'total_assets', 'total_hldr_eqy_exc_min_int', 'goodwill',
|
||||||
|
'__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio',
|
||||||
|
'__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', '__fix_assets_ratio',
|
||||||
|
'__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', '__ap_ratio', '__adv_ratio',
|
||||||
|
'__st_borr_ratio', '__lt_borr_ratio', '__operating_assets_ratio', '__interest_bearing_debt_ratio',
|
||||||
|
'invturn_days', 'arturn_days', 'payturn_days', 'fa_turn', 'assets_turn',
|
||||||
|
'employees', '__rev_per_emp', '__profit_per_emp', '__salary_per_emp',
|
||||||
|
'close', 'total_mv', 'pe', 'pb', 'holder_num'
|
||||||
|
];
|
||||||
|
|
||||||
|
const displayedSeries = Object.entries(series)
|
||||||
|
.filter(([key]) => displayedKeys.includes(key))
|
||||||
|
.map(([, value]) => value);
|
||||||
|
|
||||||
|
const allPeriods = Array.from(
|
||||||
|
new Set(
|
||||||
|
(displayedSeries.flat() as any[])
|
||||||
|
.map((p) => toPeriod(p))
|
||||||
|
.filter((v): v is string => Boolean(v))
|
||||||
|
)
|
||||||
|
).sort((a, b) => b.localeCompare(a)); // 最新在左(按 YYYYMMDD 排序)
|
||||||
|
|
||||||
|
if (allPeriods.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">暂无可展示的数据</p>;
|
||||||
|
}
|
||||||
|
const periods = allPeriods.slice(0, 10);
|
||||||
|
|
||||||
|
const getValueByPeriod = (points: any[] | undefined, period: string): number | null => {
|
||||||
|
if (!points) return null;
|
||||||
|
const hit = points.find((pp) => toPeriod(pp) === period);
|
||||||
|
const v = hit?.value;
|
||||||
|
if (v == null) return null;
|
||||||
|
const num = typeof v === 'number' ? v : Number(v);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Table className="min-w-full text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-left p-2">指标</TableHead>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(() => {
|
||||||
|
// 指定显示顺序(tushareParam)
|
||||||
|
const ORDER: Array<{ key: string; label?: string }> = [
|
||||||
|
{ key: 'roe' },
|
||||||
|
{ key: 'roa' },
|
||||||
|
{ key: 'roic' },
|
||||||
|
{ key: 'grossprofit_margin' },
|
||||||
|
{ key: 'netprofit_margin' },
|
||||||
|
{ key: 'revenue' },
|
||||||
|
{ key: 'tr_yoy' },
|
||||||
|
{ key: 'n_income' },
|
||||||
|
{ key: 'dt_netprofit_yoy' },
|
||||||
|
{ key: 'n_cashflow_act' },
|
||||||
|
{ key: 'c_pay_acq_const_fiolta' },
|
||||||
|
{ key: '__free_cash_flow', label: '自由现金流' },
|
||||||
|
{ key: 'dividend_amount', label: '分红' },
|
||||||
|
{ key: 'repurchase_amount', label: '回购' },
|
||||||
|
{ key: 'total_assets' },
|
||||||
|
{ key: 'total_hldr_eqy_exc_min_int' },
|
||||||
|
{ key: 'goodwill' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 在表格顶部插入"主要指标"行
|
||||||
|
const summaryRow = (
|
||||||
|
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 font-medium ">主要指标</TableCell>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableCell key={p} className="p-2"></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PERCENT_KEYS = new Set([
|
||||||
|
'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy',
|
||||||
|
// Add all calculated percentage rows
|
||||||
|
'__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio',
|
||||||
|
'__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio',
|
||||||
|
'__fix_assets_ratio', '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio',
|
||||||
|
'__ap_ratio', '__adv_ratio', '__st_borr_ratio', '__lt_borr_ratio',
|
||||||
|
'__operating_assets_ratio', '__interest_bearing_debt_ratio'
|
||||||
|
]);
|
||||||
|
const rows = ORDER.map(({ key, label }) => {
|
||||||
|
const points = series[key] as any[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={key} className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">
|
||||||
|
{label || metricDisplayMap[key] || key}
|
||||||
|
</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
|
||||||
|
const groupName = metricGroupMap[key];
|
||||||
|
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||||||
|
if (rawNum == null || Number.isNaN(rawNum)) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
if (PERCENT_KEYS.has(key)) {
|
||||||
|
const perc = Math.abs(rawNum) <= 1 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? rawNum * 100 : rawNum;
|
||||||
|
const text = Number.isFinite(perc) ? numberFormatter.format(perc) : '-';
|
||||||
|
const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy';
|
||||||
|
if (isGrowthRow) {
|
||||||
|
const isNeg = typeof perc === 'number' && perc < 0;
|
||||||
|
const isHighGrowth = typeof perc === 'number' && perc > 30;
|
||||||
|
|
||||||
|
let content = `${text}%`;
|
||||||
|
if (key === 'dt_netprofit_yoy' && typeof perc === 'number' && perc > 1000) {
|
||||||
|
content = `${(perc / 100).toFixed(1)}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableCellClassName = 'text-right p-2';
|
||||||
|
let spanClassName = 'italic';
|
||||||
|
|
||||||
|
if (isNeg) {
|
||||||
|
tableCellClassName += ' bg-red-100';
|
||||||
|
spanClassName += ' text-red-600';
|
||||||
|
} else if (isHighGrowth) {
|
||||||
|
tableCellClassName += ' bg-green-100';
|
||||||
|
spanClassName += ' text-green-800 font-bold';
|
||||||
|
} else {
|
||||||
|
spanClassName += ' text-blue-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className={tableCellClassName}>
|
||||||
|
<span className={spanClassName}>{content}</span>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isHighlighted = (key === 'roe' && typeof perc === 'number' && perc > 12.5) ||
|
||||||
|
(key === 'grossprofit_margin' && typeof perc === 'number' && perc > 35) ||
|
||||||
|
(key === 'netprofit_margin' && typeof perc === 'number' && perc > 15);
|
||||||
|
|
||||||
|
if (isHighlighted) {
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2 bg-green-100 text-green-800 font-bold">
|
||||||
|
{`${text}%`}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2">{`${text}%`}</TableCell>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow';
|
||||||
|
const scaled = key === 'total_mv'
|
||||||
|
? rawNum / 10000
|
||||||
|
: (isFinGroup || key === '__free_cash_flow' || key === 'repurchase_amount' ? rawNum / 1e8 : rawNum);
|
||||||
|
const formatter = key === 'total_mv' ? integerFormatter : numberFormatter;
|
||||||
|
const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-';
|
||||||
|
if (key === '__free_cash_flow') {
|
||||||
|
const isNeg = typeof scaled === 'number' && scaled < 0;
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2">
|
||||||
|
{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2">{text}</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 费用指标分组
|
||||||
|
// =========================
|
||||||
|
const feeHeaderRow = (
|
||||||
|
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 font-medium ">费用指标</TableCell>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableCell key={p} className="p-2"></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const feeRows = [
|
||||||
|
{ key: '__sell_rate', label: '销售费用率' },
|
||||||
|
{ key: '__admin_rate', label: '管理费用率' },
|
||||||
|
{ key: '__rd_rate', label: '研发费用率' },
|
||||||
|
{ key: '__other_fee_rate', label: '其他费用率' },
|
||||||
|
{ key: '__tax_rate', label: '所得税率' },
|
||||||
|
{ key: '__depr_ratio', label: '折旧费用占比' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<TableRow key={key} className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series[key] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
|
||||||
|
if (v == null || !Number.isFinite(v)) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
const rateText = numberFormatter.format(v);
|
||||||
|
const isNegative = v < 0;
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2">
|
||||||
|
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 资产占比分组
|
||||||
|
// =========================
|
||||||
|
const assetHeaderRow = (
|
||||||
|
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 font-medium ">资产占比</TableCell>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableCell key={p} className="p-2"></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ratioCell = (value: number | null, keyStr: string) => {
|
||||||
|
if (value == null || !Number.isFinite(value)) {
|
||||||
|
return <TableCell key={keyStr} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
const text = numberFormatter.format(value);
|
||||||
|
const isNegative = value < 0;
|
||||||
|
const isHighRatio = value > 30;
|
||||||
|
|
||||||
|
let cellClassName = "text-right p-2";
|
||||||
|
if (isHighRatio) {
|
||||||
|
cellClassName += " bg-red-100";
|
||||||
|
} else if (isNegative) {
|
||||||
|
cellClassName += " bg-red-100";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell key={keyStr} className={cellClassName}>
|
||||||
|
{isNegative ? <span className="text-red-600">{text}%</span> : `${text}%`}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assetRows = [
|
||||||
|
{ key: '__money_cap_ratio', label: '现金占比' },
|
||||||
|
{ key: '__inventories_ratio', label: '库存占比' },
|
||||||
|
{ key: '__ar_ratio', label: '应收款占比' },
|
||||||
|
{ key: '__prepay_ratio', label: '预付款占比' },
|
||||||
|
{ key: '__fix_assets_ratio', label: '固定资产占比' },
|
||||||
|
{ key: '__lt_invest_ratio', label: '长期投资占比' },
|
||||||
|
{ key: '__goodwill_ratio', label: '商誉占比' },
|
||||||
|
{ key: '__other_assets_ratio', label: '其他资产占比' },
|
||||||
|
{ key: '__ap_ratio', label: '应付款占比' },
|
||||||
|
{ key: '__adv_ratio', label: '预收款占比' },
|
||||||
|
{ key: '__st_borr_ratio', label: '短期借款占比' },
|
||||||
|
{ key: '__lt_borr_ratio', label: '长期借款占比' },
|
||||||
|
{ key: '__operating_assets_ratio', label: '运营资产占比' },
|
||||||
|
{ key: '__interest_bearing_debt_ratio', label: '有息负债率' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
|
||||||
|
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series[key] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
return ratioCell(v, p);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// 周转能力分组
|
||||||
|
// =========================
|
||||||
|
const turnoverHeaderRow = (
|
||||||
|
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 font-medium ">周转能力</TableCell>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableCell key={p} className="p-2"></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
const turnoverItems: Array<{ key: string; label: string }> = [
|
||||||
|
{ key: 'invturn_days', label: '存货周转天数' },
|
||||||
|
{ key: 'arturn_days', label: '应收款周转天数' },
|
||||||
|
{ key: 'payturn_days', label: '应付款周转天数' },
|
||||||
|
{ key: 'fa_turn', label: '固定资产周转率' },
|
||||||
|
{ key: 'assets_turn', label: '总资产周转率' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const turnoverRows = turnoverItems.map(({ key, label }) => (
|
||||||
|
<TableRow key={key} className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series[key] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
const value = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||||||
|
|
||||||
|
if (value == null || !Number.isFinite(value)) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
const text = numberFormatter.format(value);
|
||||||
|
if (key === 'arturn_days' && value > 90) {
|
||||||
|
return (
|
||||||
|
<TableCell key={p} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{text}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
|
||||||
|
return [
|
||||||
|
summaryRow,
|
||||||
|
...rows,
|
||||||
|
feeHeaderRow,
|
||||||
|
...feeRows,
|
||||||
|
assetHeaderRow,
|
||||||
|
...assetRows,
|
||||||
|
turnoverHeaderRow,
|
||||||
|
...turnoverRows,
|
||||||
|
// =========================
|
||||||
|
// 人均效率分组
|
||||||
|
// =========================
|
||||||
|
(
|
||||||
|
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 font-medium ">人均效率</TableCell>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableCell key={p} className="p-2"></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 员工人数(整数千分位)
|
||||||
|
(
|
||||||
|
<TableRow key="__employees_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">员工人数</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['employees'] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
if (v == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 人均创收 = 收入 / 员工人数(万元)
|
||||||
|
(
|
||||||
|
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">人均创收(万元)</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['__rev_per_emp'] as any[] | undefined;
|
||||||
|
const val = getValueByPeriod(points, p);
|
||||||
|
if (val == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 人均创利 = 净利润 / 员工人数(万元)
|
||||||
|
(
|
||||||
|
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">人均创利(万元)</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['__profit_per_emp'] as any[] | undefined;
|
||||||
|
const val = getValueByPeriod(points, p);
|
||||||
|
if (val == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 人均工资 = 支付给职工以及为职工支付的现金 / 员工人数(万元)
|
||||||
|
(
|
||||||
|
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">人均工资(万元)</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['__salary_per_emp'] as any[] | undefined;
|
||||||
|
const val = getValueByPeriod(points, p);
|
||||||
|
if (val == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// =========================
|
||||||
|
// 市场表现分组
|
||||||
|
// =========================
|
||||||
|
(
|
||||||
|
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 font-medium ">市场表现</TableCell>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<TableCell key={p} className="p-2"></TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 股价(收盘价)
|
||||||
|
(
|
||||||
|
<TableRow key="__price_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">股价</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['close'] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
if (v == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 市值(按亿为单位显示:乘以10000并整数千分位)
|
||||||
|
(
|
||||||
|
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">市值(亿元)</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['total_mv'] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
if (v == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
const scaled = v / 10000; // 转为亿元
|
||||||
|
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// PE
|
||||||
|
(
|
||||||
|
<TableRow key="__pe_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['pe'] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
if (v == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// PB
|
||||||
|
(
|
||||||
|
<TableRow key="__pb_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['pb'] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
if (v == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
// 股东户数
|
||||||
|
(
|
||||||
|
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
|
||||||
|
<TableCell className="p-2 text-muted-foreground">股东户数</TableCell>
|
||||||
|
{periods.map((p) => {
|
||||||
|
const points = series['holder_num'] as any[] | undefined;
|
||||||
|
const v = getValueByPeriod(points, p);
|
||||||
|
if (v == null) {
|
||||||
|
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||||
|
}
|
||||||
|
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
),
|
||||||
|
];
|
||||||
|
})()}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
164
frontend/src/app/report/[symbol]/components/ReportHeader.tsx
Normal file
164
frontend/src/app/report/[symbol]/components/ReportHeader.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
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;
|
||||||
|
displayMarket: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
financials: any;
|
||||||
|
snapshot: any;
|
||||||
|
snapshotLoading: boolean;
|
||||||
|
triggering: boolean;
|
||||||
|
hasRunningTask: boolean;
|
||||||
|
isAnalysisRunning: boolean;
|
||||||
|
onStartAnalysis: () => void;
|
||||||
|
onStopAnalysis: () => void;
|
||||||
|
onContinueAnalysis: () => void;
|
||||||
|
// Template props
|
||||||
|
templateSets: any;
|
||||||
|
selectedTemplateId: string;
|
||||||
|
onSelectTemplate: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportHeader({
|
||||||
|
unifiedSymbol,
|
||||||
|
displayMarket,
|
||||||
|
isLoading,
|
||||||
|
financials,
|
||||||
|
snapshot,
|
||||||
|
snapshotLoading,
|
||||||
|
triggering,
|
||||||
|
hasRunningTask,
|
||||||
|
isAnalysisRunning,
|
||||||
|
onStartAnalysis,
|
||||||
|
onStopAnalysis,
|
||||||
|
onContinueAnalysis,
|
||||||
|
templateSets,
|
||||||
|
selectedTemplateId,
|
||||||
|
onSelectTemplate,
|
||||||
|
}: ReportHeaderProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="flex-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">报告页面</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-20">股票代码:</span>
|
||||||
|
<span className="font-medium">{unifiedSymbol}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-20">交易市场:</span>
|
||||||
|
<span className="font-medium">{displayMarket}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-20">公司名称:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
<span className="text-muted-foreground">加载中...</span>
|
||||||
|
</span>
|
||||||
|
) : financials?.name ? (
|
||||||
|
financials.name
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</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="PB"
|
||||||
|
value={snapshot?.pb != null ? `${Number(snapshot.pb).toFixed(2)}` : undefined}
|
||||||
|
loading={snapshotLoading}
|
||||||
|
/>
|
||||||
|
<SnapshotItem
|
||||||
|
label="股价"
|
||||||
|
value={snapshot?.close != null ? `${Number(snapshot.close).toFixed(2)}` : undefined}
|
||||||
|
loading={snapshotLoading}
|
||||||
|
/>
|
||||||
|
<SnapshotItem
|
||||||
|
label="PE"
|
||||||
|
value={snapshot?.pe != null ? `${Number(snapshot.pe).toFixed(2)}` : undefined}
|
||||||
|
loading={snapshotLoading}
|
||||||
|
/>
|
||||||
|
<SnapshotItem
|
||||||
|
label="市值"
|
||||||
|
value={snapshot?.total_mv != null ? `${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元` : undefined}
|
||||||
|
loading={snapshotLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SnapshotItem({ label, value, loading }: { label: string; value?: string; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground min-w-8">{label}:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
</span>
|
||||||
|
) : value ? (
|
||||||
|
value
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/app/report/[symbol]/components/StockChart.tsx
Normal file
57
frontend/src/app/report/[symbol]/components/StockChart.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { CheckCircle } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
||||||
|
|
||||||
|
interface StockChartProps {
|
||||||
|
unifiedSymbol: string;
|
||||||
|
marketParam: string;
|
||||||
|
realtime: any;
|
||||||
|
realtimeLoading: boolean;
|
||||||
|
realtimeError: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockChart({
|
||||||
|
unifiedSymbol,
|
||||||
|
marketParam,
|
||||||
|
realtime,
|
||||||
|
realtimeLoading,
|
||||||
|
realtimeError,
|
||||||
|
}: StockChartProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-medium">股价图表(来自 TradingView)</h2>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="size-4 text-green-600" />
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
实时股价图表 - {unifiedSymbol}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{realtimeLoading ? (
|
||||||
|
<span className="inline-flex items-center gap-2"><Spinner className="size-3" /> 正在获取实时报价…</span>
|
||||||
|
) : realtimeError ? (
|
||||||
|
<span className="text-red-500">实时报价不可用</span>
|
||||||
|
) : (() => {
|
||||||
|
const priceRaw = realtime?.price;
|
||||||
|
const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw);
|
||||||
|
const tsRaw = realtime?.ts;
|
||||||
|
const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw));
|
||||||
|
const tsText = tsDate && !isNaN(tsDate.getTime()) ? `(${tsDate.toLocaleString()})` : '';
|
||||||
|
if (Number.isFinite(priceNum)) {
|
||||||
|
return <span>价格 {priceNum.toLocaleString()} {tsText}</span>;
|
||||||
|
}
|
||||||
|
return <span>暂无最新报价</span>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TradingViewWidget
|
||||||
|
symbol={unifiedSymbol}
|
||||||
|
market={marketParam}
|
||||||
|
height={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
77
frontend/src/app/report/[symbol]/components/TaskStatus.tsx
Normal file
77
frontend/src/app/report/[symbol]/components/TaskStatus.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { formatElapsedTime } from '../utils';
|
||||||
|
|
||||||
|
interface TaskStatusProps {
|
||||||
|
requestId: string | null;
|
||||||
|
taskProgress: any;
|
||||||
|
startTime: number | null;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
completionProgress: number;
|
||||||
|
currentAnalysisTask: string | null;
|
||||||
|
analysisConfig: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskStatus({
|
||||||
|
requestId,
|
||||||
|
taskProgress,
|
||||||
|
startTime,
|
||||||
|
elapsedSeconds,
|
||||||
|
completionProgress,
|
||||||
|
currentAnalysisTask,
|
||||||
|
analysisConfig,
|
||||||
|
}: TaskStatusProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="w-80">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">任务进度(新架构)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{requestId ? (
|
||||||
|
<pre>{JSON.stringify(taskProgress || {}, null, 2)}</pre>
|
||||||
|
) : (
|
||||||
|
<div>未触发任务</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-80">
|
||||||
|
<CardHeader className="flex flex-col space-y-2 pb-2">
|
||||||
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
|
<CardTitle className="text-xl">任务状态</CardTitle>
|
||||||
|
{startTime && (
|
||||||
|
<div className="text-sm font-medium text-muted-foreground ml-auto">
|
||||||
|
总耗时: {formatElapsedTime(elapsedSeconds)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-2 mt-1">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${completionProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{currentAnalysisTask && analysisConfig && (
|
||||||
|
(() => {
|
||||||
|
const analysisName = analysisConfig.analysis_modules?.[currentAnalysisTask]?.name || currentAnalysisTask;
|
||||||
|
const modelName = analysisConfig.analysis_modules?.[currentAnalysisTask]?.model || 'AI';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{analysisName}(来自 {modelName})</div>
|
||||||
|
<div className="text-xs text-muted-foreground">正在生成{analysisName}...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
446
frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts
Normal file
446
frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useDataRequest, useTaskProgress } from '@/hooks/useApi';
|
||||||
|
|
||||||
|
interface AnalysisState {
|
||||||
|
content: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
elapsed_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisRecord {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'running' | 'done' | 'error';
|
||||||
|
start_ts?: string;
|
||||||
|
end_ts?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
tokens?: {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnalysisRunner(
|
||||||
|
financials: any,
|
||||||
|
financialConfig: any,
|
||||||
|
normalizedMarket: string,
|
||||||
|
unifiedSymbol: string,
|
||||||
|
isLoading: boolean,
|
||||||
|
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 {
|
||||||
|
...financialConfig,
|
||||||
|
analysis_modules: activeTemplateSet.modules,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return financialConfig; // Fallback to global config (legacy behavior)
|
||||||
|
}, [activeTemplateSet, financialConfig]);
|
||||||
|
|
||||||
|
// 分析类型列表
|
||||||
|
const analysisTypes = useMemo(() => {
|
||||||
|
if (!activeAnalysisConfig?.analysis_modules) return [];
|
||||||
|
return Object.keys(activeAnalysisConfig.analysis_modules);
|
||||||
|
}, [activeAnalysisConfig]);
|
||||||
|
|
||||||
|
// 分析状态管理
|
||||||
|
const [analysisStates, setAnalysisStates] = useState<Record<string, AnalysisState>>({});
|
||||||
|
|
||||||
|
const fullAnalysisTriggeredRef = useRef<boolean>(false);
|
||||||
|
const isAnalysisRunningRef = useRef<boolean>(false);
|
||||||
|
const analysisFetchedRefs = useRef<Record<string, boolean>>({});
|
||||||
|
const stopRequestedRef = useRef<boolean>(false);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const currentAnalysisTypeRef = useRef<string | null>(null);
|
||||||
|
const [manualRunKey, setManualRunKey] = useState(0);
|
||||||
|
|
||||||
|
// 当前正在执行的分析任务
|
||||||
|
const [currentAnalysisTask, setCurrentAnalysisTask] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 计时器状态
|
||||||
|
const [startTime, setStartTime] = useState<number | null>(null);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
|
||||||
|
// 分析执行记录
|
||||||
|
const [analysisRecords, setAnalysisRecords] = useState<AnalysisRecord[]>([]);
|
||||||
|
|
||||||
|
// 新架构:触发分析与查看任务进度
|
||||||
|
const { trigger: triggerAnalysisRequest, isMutating: triggering } = useDataRequest();
|
||||||
|
const [requestId, setRequestId] = useState<string | null>(null);
|
||||||
|
const { progress: taskProgress } = useTaskProgress(requestId);
|
||||||
|
|
||||||
|
// 计算完成比例
|
||||||
|
const completionProgress = useMemo(() => {
|
||||||
|
const totalTasks = analysisRecords.length;
|
||||||
|
if (totalTasks === 0) return 0;
|
||||||
|
const completedTasks = analysisRecords.filter(r => r.status === 'done' || r.status === 'error').length;
|
||||||
|
return (completedTasks / totalTasks) * 100;
|
||||||
|
}, [analysisRecords]);
|
||||||
|
|
||||||
|
// 总耗时(ms)
|
||||||
|
const totalElapsedMs = useMemo(() => {
|
||||||
|
const finMs = financials?.meta?.elapsed_ms || 0;
|
||||||
|
const analysesMs = analysisRecords.reduce((sum, r) => sum + (r.duration_ms || 0), 0);
|
||||||
|
return finMs + analysesMs;
|
||||||
|
}, [financials?.meta?.elapsed_ms, analysisRecords]);
|
||||||
|
|
||||||
|
const hasRunningTask = useMemo(() => {
|
||||||
|
if (currentAnalysisTask !== null) return true;
|
||||||
|
if (analysisRecords.some(r => r.status === 'running')) return true;
|
||||||
|
return false;
|
||||||
|
}, [currentAnalysisTask, analysisRecords]);
|
||||||
|
|
||||||
|
// 全部任务是否完成
|
||||||
|
const allTasksCompleted = useMemo(() => {
|
||||||
|
if (analysisRecords.length === 0) return false;
|
||||||
|
const allDoneOrErrored = analysisRecords.every(r => r.status === 'done' || r.status === 'error');
|
||||||
|
return allDoneOrErrored && !hasRunningTask && currentAnalysisTask === null;
|
||||||
|
}, [analysisRecords, hasRunningTask, currentAnalysisTask]);
|
||||||
|
|
||||||
|
// 所有任务完成时,停止计时器
|
||||||
|
useEffect(() => {
|
||||||
|
if (allTasksCompleted) {
|
||||||
|
setStartTime(null);
|
||||||
|
}
|
||||||
|
}, [allTasksCompleted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startTime) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = Math.floor((now - startTime) / 1000);
|
||||||
|
setElapsedSeconds(elapsed);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [startTime]);
|
||||||
|
|
||||||
|
const retryAnalysis = async (analysisType: string) => {
|
||||||
|
if (!financials || !activeAnalysisConfig?.analysis_modules) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
analysisFetchedRefs.current[analysisType] = false;
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: { content: '', loading: true, error: null }
|
||||||
|
}));
|
||||||
|
setAnalysisRecords(prev => prev.filter(record => record.type !== analysisType));
|
||||||
|
const analysisName =
|
||||||
|
activeAnalysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||||||
|
const startTimeISO = new Date().toISOString();
|
||||||
|
setCurrentAnalysisTask(analysisType);
|
||||||
|
setAnalysisRecords(prev => [...prev, {
|
||||||
|
type: analysisType,
|
||||||
|
name: analysisName,
|
||||||
|
status: 'running',
|
||||||
|
start_ts: startTimeISO
|
||||||
|
}]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startedMsLocal = Date.now();
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/financials/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let aggregate = '';
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
aggregate += chunk;
|
||||||
|
const snapshot = aggregate;
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: {
|
||||||
|
...prev[analysisType],
|
||||||
|
content: snapshot,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
const elapsedMs = Date.now() - startedMsLocal;
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: {
|
||||||
|
...prev[analysisType],
|
||||||
|
content: aggregate,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
elapsed_ms: elapsedMs,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setAnalysisRecords(prev => prev.map(record =>
|
||||||
|
record.type === analysisType
|
||||||
|
? {
|
||||||
|
...record,
|
||||||
|
status: 'done',
|
||||||
|
end_ts: endTime,
|
||||||
|
duration_ms: elapsedMs,
|
||||||
|
}
|
||||||
|
: record
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: {
|
||||||
|
...prev[analysisType],
|
||||||
|
content: '',
|
||||||
|
loading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setAnalysisRecords(prev => prev.map(record =>
|
||||||
|
record.type === analysisType
|
||||||
|
? {
|
||||||
|
...record,
|
||||||
|
status: 'error',
|
||||||
|
end_ts: endTime,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
: record
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
setCurrentAnalysisTask(null);
|
||||||
|
analysisFetchedRefs.current[analysisType] = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || error || !financials || !activeAnalysisConfig?.analysis_modules || analysisTypes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAnalysisRunningRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const runAnalysesSequentially = async () => {
|
||||||
|
if (isAnalysisRunningRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAnalysisRunningRef.current = true;
|
||||||
|
try {
|
||||||
|
if (!stopRequestedRef.current && !startTime) {
|
||||||
|
setStartTime(Date.now());
|
||||||
|
}
|
||||||
|
for (let i = 0; i < analysisTypes.length; i++) {
|
||||||
|
const analysisType = analysisTypes[i];
|
||||||
|
if (stopRequestedRef.current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (analysisFetchedRefs.current[analysisType]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!analysisFetchedRefs.current || !activeAnalysisConfig?.analysis_modules) {
|
||||||
|
console.error("分析配置或refs未初始化,无法进行分析。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currentAnalysisTypeRef.current = analysisType;
|
||||||
|
const analysisName =
|
||||||
|
activeAnalysisConfig.analysis_modules[analysisType]?.name || analysisType;
|
||||||
|
const startTimeISO = new Date().toISOString();
|
||||||
|
setCurrentAnalysisTask(analysisType);
|
||||||
|
setAnalysisRecords(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
const idx = next.findIndex(r => r.type === analysisType);
|
||||||
|
const updated: AnalysisRecord = {
|
||||||
|
type: analysisType,
|
||||||
|
name: analysisName,
|
||||||
|
status: 'running' as const,
|
||||||
|
start_ts: startTimeISO
|
||||||
|
};
|
||||||
|
if (idx >= 0) {
|
||||||
|
next[idx] = { ...next[idx], ...updated };
|
||||||
|
} else {
|
||||||
|
next.push(updated);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: { content: '', loading: true, error: null }
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
const startedMsLocal = Date.now();
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/financials/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`,
|
||||||
|
{ signal: abortControllerRef.current.signal }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let aggregate = '';
|
||||||
|
if (reader) {
|
||||||
|
// 持续读取并追加到内容
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
aggregate += chunk;
|
||||||
|
const snapshot = aggregate;
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: {
|
||||||
|
...prev[analysisType],
|
||||||
|
content: snapshot,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
const elapsedMs = Date.now() - startedMsLocal;
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: {
|
||||||
|
...prev[analysisType],
|
||||||
|
content: aggregate,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
elapsed_ms: elapsedMs,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setAnalysisRecords(prev => prev.map(record =>
|
||||||
|
record.type === analysisType
|
||||||
|
? {
|
||||||
|
...record,
|
||||||
|
status: 'done',
|
||||||
|
end_ts: endTime,
|
||||||
|
duration_ms: elapsedMs,
|
||||||
|
}
|
||||||
|
: record
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && (err as any).name === 'AbortError') {
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: { content: '', loading: false, error: null }
|
||||||
|
}));
|
||||||
|
setAnalysisRecords(prev => prev.map(record =>
|
||||||
|
record.type === analysisType
|
||||||
|
? { ...record, status: 'pending', start_ts: undefined }
|
||||||
|
: record
|
||||||
|
));
|
||||||
|
analysisFetchedRefs.current[analysisType] = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const errorMessage = err instanceof Error ? err.message : '加载失败';
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
setAnalysisStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[analysisType]: {
|
||||||
|
content: '',
|
||||||
|
loading: false,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setAnalysisRecords(prev => prev.map(record =>
|
||||||
|
record.type === analysisType
|
||||||
|
? {
|
||||||
|
...record,
|
||||||
|
status: 'error',
|
||||||
|
end_ts: endTime,
|
||||||
|
error: errorMessage
|
||||||
|
}
|
||||||
|
: record
|
||||||
|
));
|
||||||
|
} finally {
|
||||||
|
setCurrentAnalysisTask(null);
|
||||||
|
currentAnalysisTypeRef.current = null;
|
||||||
|
analysisFetchedRefs.current[analysisType] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isAnalysisRunningRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
runAnalysesSequentially();
|
||||||
|
}, [isLoading, error, financials, activeAnalysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
|
||||||
|
|
||||||
|
const stopAll = () => {
|
||||||
|
stopRequestedRef.current = true;
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
isAnalysisRunningRef.current = false;
|
||||||
|
if (currentAnalysisTypeRef.current) {
|
||||||
|
analysisFetchedRefs.current[currentAnalysisTypeRef.current] = false;
|
||||||
|
}
|
||||||
|
setCurrentAnalysisTask(null);
|
||||||
|
setStartTime(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const continuePending = () => {
|
||||||
|
if (isAnalysisRunningRef.current) return;
|
||||||
|
stopRequestedRef.current = false;
|
||||||
|
setStartTime((prev) => (prev == null ? Date.now() - elapsedSeconds * 1000 : prev));
|
||||||
|
setManualRunKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerAnalysis = async () => {
|
||||||
|
const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || '', selectedTemplateId);
|
||||||
|
if (reqId) setRequestId(reqId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeAnalysisConfig, // Exported
|
||||||
|
analysisTypes,
|
||||||
|
analysisStates,
|
||||||
|
analysisRecords,
|
||||||
|
currentAnalysisTask,
|
||||||
|
triggerAnalysis,
|
||||||
|
triggering,
|
||||||
|
requestId,
|
||||||
|
setRequestId,
|
||||||
|
taskProgress,
|
||||||
|
startTime,
|
||||||
|
elapsedSeconds,
|
||||||
|
completionProgress,
|
||||||
|
totalElapsedMs,
|
||||||
|
stopAll,
|
||||||
|
continuePending,
|
||||||
|
retryAnalysis,
|
||||||
|
hasRunningTask,
|
||||||
|
isAnalysisRunning: isAnalysisRunningRef.current,
|
||||||
|
selectedTemplateId, // Exported
|
||||||
|
setSelectedTemplateId, // Exported
|
||||||
|
};
|
||||||
|
}
|
||||||
67
frontend/src/app/report/[symbol]/hooks/useReportData.ts
Normal file
67
frontend/src/app/report/[symbol]/hooks/useReportData.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisTemplateSets, useSnapshot, useRealtimeQuote, useAnalysisResults } from '@/hooks/useApi';
|
||||||
|
|
||||||
|
export function useReportData() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const symbol = params.symbol as string;
|
||||||
|
const marketParam = (searchParams.get('market') || '').toLowerCase();
|
||||||
|
const normalizedMarket = (() => {
|
||||||
|
if (marketParam === 'usa') return 'us';
|
||||||
|
if (marketParam === 'china') return 'cn';
|
||||||
|
if (marketParam === 'hkex') return 'hk';
|
||||||
|
if (marketParam === 'jpn') return 'jp';
|
||||||
|
return marketParam;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const displayMarket = marketParam === 'china' ? '中国' : marketParam;
|
||||||
|
|
||||||
|
const isChina = normalizedMarket === 'cn';
|
||||||
|
|
||||||
|
// 规范化中国市场 ts_code:若为6位数字或无后缀,自动推断交易所
|
||||||
|
const normalizedTsCode = (() => {
|
||||||
|
if (!isChina) return symbol;
|
||||||
|
if (!symbol) return symbol;
|
||||||
|
if (symbol.includes('.')) return symbol.toUpperCase();
|
||||||
|
const onlyDigits = symbol.replace(/\D/g, '');
|
||||||
|
if (onlyDigits.length === 6) {
|
||||||
|
const first = onlyDigits[0];
|
||||||
|
if (first === '6') return `${onlyDigits}.SH`;
|
||||||
|
if (first === '0' || first === '3') return `${onlyDigits}.SZ`;
|
||||||
|
}
|
||||||
|
return symbol.toUpperCase();
|
||||||
|
})();
|
||||||
|
|
||||||
|
const chinaFin = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
|
||||||
|
const otherFin = useFinancials(!isChina ? normalizedMarket : undefined, !isChina ? symbol : undefined, 10);
|
||||||
|
const financials = (chinaFin.data ?? otherFin.data) as any;
|
||||||
|
const error = chinaFin.error ?? otherFin.error;
|
||||||
|
const isLoading = chinaFin.isLoading || otherFin.isLoading;
|
||||||
|
const unifiedSymbol = isChina ? normalizedTsCode : symbol;
|
||||||
|
const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useSnapshot(normalizedMarket, unifiedSymbol);
|
||||||
|
const { data: realtime, error: realtimeError, isLoading: realtimeLoading } = useRealtimeQuote(normalizedMarket, unifiedSymbol, { maxAgeSeconds: 30, refreshIntervalMs: 5000 });
|
||||||
|
const { data: financialConfig } = useFinancialConfig();
|
||||||
|
const { data: templateSets } = useAnalysisTemplateSets();
|
||||||
|
const { data: historicalAnalysis } = useAnalysisResults(unifiedSymbol);
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
unifiedSymbol,
|
||||||
|
displayMarket,
|
||||||
|
normalizedMarket,
|
||||||
|
marketParam,
|
||||||
|
financials,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
snapshot,
|
||||||
|
snapshotLoading,
|
||||||
|
snapshotError,
|
||||||
|
realtime,
|
||||||
|
realtimeLoading,
|
||||||
|
realtimeError,
|
||||||
|
financialConfig: financialConfig as any,
|
||||||
|
templateSets,
|
||||||
|
historicalAnalysis
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
64
frontend/src/app/report/[symbol]/utils.ts
Normal file
64
frontend/src/app/report/[symbol]/utils.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
export const formatElapsedTime = (seconds: number): string => {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatMs = (ms?: number | null): string => {
|
||||||
|
const v = typeof ms === 'number' ? ms : 0;
|
||||||
|
if (v >= 1000) {
|
||||||
|
const s = v / 1000;
|
||||||
|
return `${s.toFixed(2)} s`;
|
||||||
|
}
|
||||||
|
return `${v} ms`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const numberFormatter = new Intl.NumberFormat('zh-CN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const integerFormatter = new Intl.NumberFormat('zh-CN', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeMarkdown = (content: string): string => {
|
||||||
|
if (!content) return content;
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
const out: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
let line = lines[i];
|
||||||
|
line = line.replace(/^(\s*)(\d+)[、,]\s*/u, '$1$2. ');
|
||||||
|
const onlyIndexMatch = line.match(/^\s*(\d+)\.[\s\u3000]*$/u);
|
||||||
|
if (onlyIndexMatch) {
|
||||||
|
const next = lines[i + 1] ?? '';
|
||||||
|
out.push(`${onlyIndexMatch[1]}. ${next}`);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = out.join('\n');
|
||||||
|
text = text.replace(/([^\n])\n(\s*\d+\.\s)/g, (_m, a, b) => `${a}\n\n${b}`);
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeTitleFromContent = (content: string, title: string): string => {
|
||||||
|
if (!content || !title) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const lines = content.split('\n');
|
||||||
|
// Trim and remove markdown from first line
|
||||||
|
const firstLine = (lines[0] || '').trim().replace(/^(#+\s*|\*\*|__)/, '').replace(/(\*\*|__)$/, '').trim();
|
||||||
|
if (firstLine === title) {
|
||||||
|
return lines.slice(1).join('\n').trim();
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
@ -118,8 +118,9 @@ export function TradingViewWidget({
|
|||||||
if (container.isConnected) {
|
if (container.isConnected) {
|
||||||
container.appendChild(script);
|
container.appendChild(script);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// 忽略偶发性 contentWindow 不可用的报错
|
// 忽略偶发性 contentWindow 不可用的报错
|
||||||
|
console.warn('TradingView widget mount error:', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
160
frontend/src/components/ui/command.tsx
Normal file
160
frontend/src/components/ui/command.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
|
||||||
@ -10,24 +10,39 @@ type DialogProps = BaseProps & {
|
|||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Dialog: React.FC<DialogProps> = ({ children }) => {
|
export const Dialog: React.FC<DialogProps> = ({ children, open, onOpenChange }) => {
|
||||||
return <div>{children}</div>;
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="relative bg-background p-6 rounded-lg shadow-lg max-w-lg w-full">
|
||||||
|
{children}
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700"
|
||||||
|
onClick={() => onOpenChange?.(false)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogContent: React.FC<BaseProps> = ({ children, className }) => {
|
export const DialogContent: React.FC<BaseProps> = ({ children, className }) => {
|
||||||
return <div className={className}>{children}</div>;
|
return <div className={className}>{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogHeader: React.FC<BaseProps> = ({ children }) => {
|
export const DialogHeader: React.FC<BaseProps> = ({ children, className }) => {
|
||||||
return <div>{children}</div>;
|
return <div className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}>{children}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogTitle: React.FC<BaseProps> = ({ children }) => {
|
export const DialogTitle: React.FC<BaseProps> = ({ children, className }) => {
|
||||||
return <h3>{children}</h3>;
|
return <h3 className={`text-lg font-semibold leading-none tracking-tight ${className}`}>{children}</h3>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogFooter: React.FC<BaseProps> = ({ children }) => {
|
export const DialogDescription: React.FC<BaseProps> = ({ children, className }) => {
|
||||||
return <div>{children}</div>;
|
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DialogFooter: React.FC<BaseProps> = ({ children, className }) => {
|
||||||
|
return <div className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}>{children}</div>;
|
||||||
|
};
|
||||||
|
|||||||
49
frontend/src/components/ui/popover.tsx
Normal file
49
frontend/src/components/ui/popover.tsx
Normal 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 }
|
||||||
|
|
||||||
@ -3,15 +3,13 @@ import {
|
|||||||
BatchFinancialDataResponse,
|
BatchFinancialDataResponse,
|
||||||
TodaySnapshotResponse,
|
TodaySnapshotResponse,
|
||||||
RealTimeQuoteResponse,
|
RealTimeQuoteResponse,
|
||||||
AnalysisConfigResponse,
|
|
||||||
LlmProvidersConfig,
|
LlmProvidersConfig,
|
||||||
AnalysisModulesConfig,
|
|
||||||
AnalysisTemplateSets, // New type
|
AnalysisTemplateSets, // New type
|
||||||
FinancialConfigResponse,
|
FinancialConfigResponse,
|
||||||
DataSourcesConfig,
|
DataSourcesConfig,
|
||||||
|
AnalysisResultDto,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
// Execution-step types not used currently; keep API minimal and explicit
|
|
||||||
import { useConfigStore } from "@/stores/useConfigStore";
|
import { useConfigStore } from "@/stores/useConfigStore";
|
||||||
import type { SystemConfig } from "@/stores/useConfigStore";
|
import type { SystemConfig } from "@/stores/useConfigStore";
|
||||||
|
|
||||||
@ -24,7 +22,7 @@ export function useDataRequest() {
|
|||||||
const [isMutating, setIsMutating] = useState(false);
|
const [isMutating, setIsMutating] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
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);
|
setIsMutating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@ -33,7 +31,7 @@ export function useDataRequest() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ symbol, market }),
|
body: JSON.stringify({ symbol, market, template_id: templateId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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 (用于查询最终数据) ---
|
// --- 保留的旧Hooks (用于查询最终数据) ---
|
||||||
|
|
||||||
export function useCompanyProfile(symbol?: string, market?: string) {
|
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) {
|
export function useChinaSnapshot(ts_code?: string) {
|
||||||
return useSWR<TodaySnapshotResponse>(
|
return useSWR<TodaySnapshotResponse>(
|
||||||
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}/snapshot` : null,
|
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}/snapshot` : null,
|
||||||
@ -378,6 +351,19 @@ export async function discoverProviderModelsPreview(apiBaseUrl: string, apiKey:
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testLlmModel(apiBaseUrl: string, apiKey: string, modelId: string) {
|
||||||
|
const res = await fetch('/api/configs/llm/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ api_base_url: apiBaseUrl, api_key: apiKey, model_id: modelId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Analysis Template Sets Config Hooks (NEW) ---
|
// --- Analysis Template Sets Config Hooks (NEW) ---
|
||||||
|
|
||||||
export function useAnalysisTemplateSets() {
|
export function useAnalysisTemplateSets() {
|
||||||
@ -397,26 +383,6 @@ export async function updateAnalysisTemplateSets(payload: AnalysisTemplateSets)
|
|||||||
return res.json() as Promise<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 ---
|
// --- Data Sources Config Hooks ---
|
||||||
|
|
||||||
export function useDataSourcesConfig() {
|
export function useDataSourcesConfig() {
|
||||||
|
|||||||
@ -102,6 +102,7 @@ export interface FinancialMeta {
|
|||||||
api_calls_by_group: Record<string, number>;
|
api_calls_by_group: Record<string, number>;
|
||||||
current_action?: string | null;
|
current_action?: string | null;
|
||||||
steps: StepRecord[];
|
steps: StepRecord[];
|
||||||
|
template_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchFinancialDataResponse {
|
export interface BatchFinancialDataResponse {
|
||||||
@ -183,6 +184,21 @@ export interface AnalysisResponse {
|
|||||||
error?: string;
|
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
697
package-lock.json
generated
@ -5,10 +5,559 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"immer": "^10.2.0",
|
"immer": "^10.2.0",
|
||||||
"zustand": "^5.0.8"
|
"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": {
|
"node_modules/immer": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
@ -19,6 +568,154 @@
|
|||||||
"url": "https://opencollective.com/immer"
|
"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": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"immer": "^10.2.0",
|
"immer": "^10.2.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
}
|
}
|
||||||
|
|||||||
287
scripts/deploy_to_harbor.sh
Executable file
287
scripts/deploy_to_harbor.sh
Executable file
@ -0,0 +1,287 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 遇到错误立即退出
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 配置变量
|
||||||
|
REGISTRY="harbor.3prism.ai"
|
||||||
|
PROJECT="fundamental_analysis"
|
||||||
|
VERSION="latest" # 或者使用 $(date +%Y%m%d%H%M%S) 生成时间戳版本
|
||||||
|
NAMESPACE="$REGISTRY/$PROJECT"
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== 开始构建并推送镜像到 $NAMESPACE ===${NC}"
|
||||||
|
|
||||||
|
# 定义服务列表及其 Dockerfile 路径
|
||||||
|
# 格式: "服务名:Dockerfile路径"
|
||||||
|
SERVICES=(
|
||||||
|
"data-persistence-service:services/data-persistence-service/Dockerfile"
|
||||||
|
"api-gateway:services/api-gateway/Dockerfile"
|
||||||
|
"alphavantage-provider-service:services/alphavantage-provider-service/Dockerfile"
|
||||||
|
"tushare-provider-service:services/tushare-provider-service/Dockerfile"
|
||||||
|
"finnhub-provider-service:services/finnhub-provider-service/Dockerfile"
|
||||||
|
"yfinance-provider-service:services/yfinance-provider-service/Dockerfile"
|
||||||
|
"report-generator-service:services/report-generator-service/Dockerfile"
|
||||||
|
"frontend:frontend/Dockerfile.prod"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 总大小计数器
|
||||||
|
TOTAL_SIZE=0
|
||||||
|
|
||||||
|
for entry in "${SERVICES[@]}"; do
|
||||||
|
KEY="${entry%%:*}"
|
||||||
|
DOCKERFILE="${entry#*:}"
|
||||||
|
IMAGE_NAME="$NAMESPACE/$KEY:$VERSION"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}>>> 正在构建 $KEY ...${NC}"
|
||||||
|
echo "使用 Dockerfile: $DOCKERFILE"
|
||||||
|
|
||||||
|
# 构建镜像
|
||||||
|
# 注意:构建上下文始终为项目根目录 (.)
|
||||||
|
docker build -t "$IMAGE_NAME" -f "$DOCKERFILE" .
|
||||||
|
|
||||||
|
# 获取镜像大小 (MB)
|
||||||
|
SIZE_BYTES=$(docker inspect "$IMAGE_NAME" --format='{{.Size}}')
|
||||||
|
SIZE_MB=$(echo "scale=2; $SIZE_BYTES / 1024 / 1024" | bc)
|
||||||
|
|
||||||
|
echo -e "${GREEN}√ $KEY 构建完成. 大小: ${SIZE_MB} MB${NC}"
|
||||||
|
|
||||||
|
# 累加大小
|
||||||
|
TOTAL_SIZE=$(echo "$TOTAL_SIZE + $SIZE_BYTES" | bc)
|
||||||
|
|
||||||
|
# 检查单个镜像大小是否异常 (例如超过 500MB 对于 Rust 微服务来说通常是不正常的,除非包含大模型)
|
||||||
|
if (( $(echo "$SIZE_MB > 500" | bc -l) )); then
|
||||||
|
echo -e "${RED}警告: $KEY 镜像大小超过 500MB,请检查 Dockerfile 是否包含不必要的文件!${NC}"
|
||||||
|
# 这里我们可以选择暂停询问用户,或者只是警告
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}>>> 正在推送 $KEY 到 Harbor ...${NC}"
|
||||||
|
docker push "$IMAGE_NAME"
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL_SIZE_MB=$(echo "scale=2; $TOTAL_SIZE / 1024 / 1024" | bc)
|
||||||
|
echo -e "\n${GREEN}=== 所有镜像处理完成 ===${NC}"
|
||||||
|
echo -e "${GREEN}总大小: ${TOTAL_SIZE_MB} MB${NC}"
|
||||||
|
|
||||||
|
# 检查总大小是否超过 1GB (1024 MB)
|
||||||
|
if (( $(echo "$TOTAL_SIZE_MB > 1024" | bc -l) )); then
|
||||||
|
echo -e "${RED}警告: 总镜像大小超过 1GB,请注意远程仓库的空间限制!${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}总大小在 1GB 限制范围内。${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 生成服务器使用的 docker-compose.server.yml
|
||||||
|
echo -e "\n${YELLOW}>>> 正在生成服务器部署文件 docker-compose.server.yml ...${NC}"
|
||||||
|
|
||||||
|
cat > docker-compose.server.yml <<EOF
|
||||||
|
services:
|
||||||
|
postgres-db:
|
||||||
|
image: timescale/timescaledb:2.15.2-pg16
|
||||||
|
container_name: fundamental-postgres
|
||||||
|
command: -c shared_preload_libraries=timescaledb
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: fundamental
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d fundamental"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
nats:
|
||||||
|
image: nats:2.9
|
||||||
|
volumes:
|
||||||
|
- nats_data:/data
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
data-persistence-service:
|
||||||
|
image: $NAMESPACE/data-persistence-service:$VERSION
|
||||||
|
container_name: data-persistence-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres-db:5432/fundamental
|
||||||
|
RUST_LOG: info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
postgres-db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: $NAMESPACE/frontend:$VERSION
|
||||||
|
container_name: fundamental-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1
|
||||||
|
FRONTEND_INTERNAL_URL: http://fundamental-frontend:3000
|
||||||
|
BACKEND_INTERNAL_URL: http://api-gateway:4000/v1
|
||||||
|
NODE_ENV: production
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
depends_on:
|
||||||
|
api-gateway:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
api-gateway:
|
||||||
|
image: $NAMESPACE/api-gateway:$VERSION
|
||||||
|
container_name: api-gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 4000
|
||||||
|
NATS_ADDR: nats://nats:4222
|
||||||
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
PROVIDER_SERVICES: '["http://alphavantage-provider-service:8000", "http://tushare-provider-service:8001", "http://finnhub-provider-service:8002", "http://yfinance-provider-service:8003"]'
|
||||||
|
RUST_LOG: info,axum=info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
- data-persistence-service
|
||||||
|
- alphavantage-provider-service
|
||||||
|
- tushare-provider-service
|
||||||
|
- finnhub-provider-service
|
||||||
|
- yfinance-provider-service
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:4000/health >/dev/null || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
alphavantage-provider-service:
|
||||||
|
image: $NAMESPACE/alphavantage-provider-service:$VERSION
|
||||||
|
container_name: alphavantage-provider-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 8000
|
||||||
|
NATS_ADDR: nats://nats:4222
|
||||||
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
RUST_LOG: info,axum=info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
- data-persistence-service
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8000/health >/dev/null || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
tushare-provider-service:
|
||||||
|
image: $NAMESPACE/tushare-provider-service:$VERSION
|
||||||
|
container_name: tushare-provider-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 8001
|
||||||
|
NATS_ADDR: nats://nats:4222
|
||||||
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
TUSHARE_API_URL: http://api.waditu.com
|
||||||
|
RUST_LOG: info,axum=info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
- data-persistence-service
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8001/health >/dev/null || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
finnhub-provider-service:
|
||||||
|
image: $NAMESPACE/finnhub-provider-service:$VERSION
|
||||||
|
container_name: finnhub-provider-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 8002
|
||||||
|
NATS_ADDR: nats://nats:4222
|
||||||
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
FINNHUB_API_URL: https://finnhub.io/api/v1
|
||||||
|
RUST_LOG: info,axum=info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
- data-persistence-service
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8002/health >/dev/null || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
yfinance-provider-service:
|
||||||
|
image: $NAMESPACE/yfinance-provider-service:$VERSION
|
||||||
|
container_name: yfinance-provider-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 8003
|
||||||
|
NATS_ADDR: nats://nats:4222
|
||||||
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
RUST_LOG: info,axum=info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
- data-persistence-service
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
dns:
|
||||||
|
- 8.8.8.8
|
||||||
|
- 8.8.4.4
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8003/health >/dev/null || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
report-generator-service:
|
||||||
|
image: $NAMESPACE/report-generator-service:$VERSION
|
||||||
|
container_name: report-generator-service
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: 8004
|
||||||
|
NATS_ADDR: nats://nats:4222
|
||||||
|
DATA_PERSISTENCE_SERVICE_URL: http://data-persistence-service:3000/api/v1
|
||||||
|
RUST_LOG: info,axum=info
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
depends_on:
|
||||||
|
- nats
|
||||||
|
- data-persistence-service
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8004/health >/dev/null || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
nats_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}生成完成: docker-compose.server.yml${NC}"
|
||||||
|
echo -e "请将此文件复制到远程服务器,并执行: docker-compose -f docker-compose.server.yml up -d"
|
||||||
|
|
||||||
@ -46,7 +46,6 @@ impl AvClient {
|
|||||||
|
|
||||||
pub async fn query(&self, function: &str, params: &[(&str, &str)]) -> Result<Value> {
|
pub async fn query(&self, function: &str, params: &[(&str, &str)]) -> Result<Value> {
|
||||||
let mut args = Map::new();
|
let mut args = Map::new();
|
||||||
args.insert("function".to_string(), Value::String(function.to_string()));
|
|
||||||
for (k, v) in params {
|
for (k, v) in params {
|
||||||
args.insert((*k).to_string(), Value::String((*v).to_string()));
|
args.insert((*k).to_string(), Value::String((*v).to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
62
services/alphavantage-provider-service/src/bin/debug_mcp.rs
Normal file
62
services/alphavantage-provider-service/src/bin/debug_mcp.rs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
#[path = "../config.rs"]
|
||||||
|
mod config;
|
||||||
|
#[path = "../error.rs"]
|
||||||
|
mod error;
|
||||||
|
#[path = "../transport.rs"]
|
||||||
|
mod transport;
|
||||||
|
#[path = "../av_client.rs"]
|
||||||
|
mod av_client;
|
||||||
|
|
||||||
|
use av_client::AvClient;
|
||||||
|
use tokio;
|
||||||
|
use tracing::{info, error};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let api_key = "PUOO7UPTNXN325NN";
|
||||||
|
let base_url = "https://mcp.alphavantage.co/mcp";
|
||||||
|
let url = format!("{}?apikey={}", base_url, api_key);
|
||||||
|
|
||||||
|
info!("Connecting to: {}", url);
|
||||||
|
|
||||||
|
let client = AvClient::connect(&url).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Connect failed: {:?}", e))?;
|
||||||
|
|
||||||
|
info!("Connected! Listing tools...");
|
||||||
|
|
||||||
|
let tools = client.list_tools().await
|
||||||
|
.map_err(|e| anyhow::anyhow!("List tools failed: {:?}", e))?;
|
||||||
|
|
||||||
|
info!("Found {} tools:", tools.len());
|
||||||
|
for tool in &tools {
|
||||||
|
if tool.name == "GLOBAL_QUOTE" || tool.name == "COMPANY_OVERVIEW" {
|
||||||
|
info!("Tool: {}", tool.name);
|
||||||
|
info!("Schema: {:?}", tool.input_schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test calling a tool
|
||||||
|
let symbol = "600519.SS";
|
||||||
|
let params = vec![("symbol", symbol), ("datatype", "json")];
|
||||||
|
|
||||||
|
info!("Testing GLOBAL_QUOTE for {} (json)...", symbol);
|
||||||
|
match client.query("GLOBAL_QUOTE", ¶ms).await {
|
||||||
|
Ok(v) => info!("GLOBAL_QUOTE result: {:?}", v),
|
||||||
|
Err(e) => error!("GLOBAL_QUOTE failed: {:?}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
let params_simple = vec![("symbol", symbol)];
|
||||||
|
info!("Testing INCOME_STATEMENT for {}...", symbol);
|
||||||
|
match client.query("INCOME_STATEMENT", ¶ms_simple).await {
|
||||||
|
Ok(v) => info!("INCOME_STATEMENT result: {:?}", v),
|
||||||
|
Err(e) => error!("INCOME_STATEMENT failed: {:?}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
use anyhow::anyhow;
|
|
||||||
use reqwest::Error as ReqwestError;
|
use reqwest::Error as ReqwestError;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,7 @@ pub fn parse_company_profile(v: Value) -> anyhow::Result<CompanyProfileDto> {
|
|||||||
"pe_ratio": v.get("PERatio"),
|
"pe_ratio": v.get("PERatio"),
|
||||||
"beta": v.get("Beta")
|
"beta": v.get("Beta")
|
||||||
})),
|
})),
|
||||||
|
updated_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,16 @@ impl PersistenceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_company_profile(&self, symbol: &str) -> Result<Option<CompanyProfileDto>> {
|
||||||
|
let url = format!("{}/companies/{}", self.base_url, symbol);
|
||||||
|
let resp = self.client.get(&url).send().await?;
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let profile = resp.error_for_status()?.json().await?;
|
||||||
|
Ok(Some(profile))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
||||||
let url = format!("{}/companies", self.base_url);
|
let url = format!("{}/companies", self.base_url);
|
||||||
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
||||||
|
|||||||
@ -6,11 +6,9 @@ use anyhow::Context;
|
|||||||
use chrono::{Utc, Datelike};
|
use chrono::{Utc, Datelike};
|
||||||
use common_contracts::messages::{FetchCompanyDataCommand, FinancialsPersistedEvent};
|
use common_contracts::messages::{FetchCompanyDataCommand, FinancialsPersistedEvent};
|
||||||
use common_contracts::observability::TaskProgress;
|
use common_contracts::observability::TaskProgress;
|
||||||
use secrecy::ExposeSecret;
|
use tracing::{error, info, instrument, warn};
|
||||||
use std::sync::Arc;
|
|
||||||
use tracing::{error, info, instrument};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::av_client::AvClient;
|
use serde_json::Value;
|
||||||
|
|
||||||
#[instrument(skip(state, command, publisher), fields(request_id = %command.request_id, symbol = %command.symbol))]
|
#[instrument(skip(state, command, publisher), fields(request_id = %command.request_id, symbol = %command.symbol))]
|
||||||
pub async fn handle_fetch_command(
|
pub async fn handle_fetch_command(
|
||||||
@ -49,6 +47,52 @@ pub async fn handle_fetch_command(
|
|||||||
PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
||||||
let symbol = command.symbol.clone();
|
let symbol = command.symbol.clone();
|
||||||
|
|
||||||
|
// Check freshness
|
||||||
|
let mut is_fresh = false;
|
||||||
|
match persistence_client.get_company_profile(&command.symbol).await {
|
||||||
|
Ok(Some(p)) => {
|
||||||
|
if let Some(updated_at) = p.updated_at {
|
||||||
|
let age = chrono::Utc::now() - updated_at;
|
||||||
|
if age < chrono::Duration::hours(24) {
|
||||||
|
info!("Data for {} is fresh (age: {}h). Skipping fetch.", command.symbol, age.num_hours());
|
||||||
|
is_fresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => tracing::warn!("Failed to check profile freshness: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_fresh {
|
||||||
|
let event = FinancialsPersistedEvent {
|
||||||
|
request_id: command.request_id,
|
||||||
|
symbol: command.symbol,
|
||||||
|
years_updated: vec![],
|
||||||
|
template_id: command.template_id,
|
||||||
|
};
|
||||||
|
let subject = "events.data.financials_persisted".to_string();
|
||||||
|
publisher
|
||||||
|
.publish(subject, serde_json::to_vec(&event).unwrap().into())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
update_task_progress(
|
||||||
|
&state.tasks,
|
||||||
|
command.request_id,
|
||||||
|
100,
|
||||||
|
"Data retrieved from cache",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol conversion for Chinese stocks
|
||||||
|
let av_symbol = if symbol.ends_with(".SH") {
|
||||||
|
symbol.replace(".SH", ".SS")
|
||||||
|
} else {
|
||||||
|
symbol.clone()
|
||||||
|
};
|
||||||
|
info!("Using symbol for AlphaVantage: {}", av_symbol);
|
||||||
|
|
||||||
update_task_progress(
|
update_task_progress(
|
||||||
&state.tasks,
|
&state.tasks,
|
||||||
command.request_id,
|
command.request_id,
|
||||||
@ -59,13 +103,14 @@ pub async fn handle_fetch_command(
|
|||||||
|
|
||||||
// --- 1. Fetch all data in parallel ---
|
// --- 1. Fetch all data in parallel ---
|
||||||
let (overview_json, income_json, balance_json, cashflow_json, quote_json) = {
|
let (overview_json, income_json, balance_json, cashflow_json, quote_json) = {
|
||||||
let params_overview = vec![("symbol", symbol.as_str())];
|
let params_overview = vec![("symbol", av_symbol.as_str())];
|
||||||
let params_income = vec![("symbol", symbol.as_str())];
|
let params_income = vec![("symbol", av_symbol.as_str())];
|
||||||
let params_balance = vec![("symbol", symbol.as_str())];
|
let params_balance = vec![("symbol", av_symbol.as_str())];
|
||||||
let params_cashflow = vec![("symbol", symbol.as_str())];
|
let params_cashflow = vec![("symbol", av_symbol.as_str())];
|
||||||
let params_quote = vec![("symbol", symbol.as_str())];
|
// Add datatype=json to force JSON response if supported (or at least Python-dict like)
|
||||||
|
let params_quote = vec![("symbol", av_symbol.as_str()), ("datatype", "json")];
|
||||||
|
|
||||||
let overview_task = client.query("OVERVIEW", ¶ms_overview);
|
let overview_task = client.query("COMPANY_OVERVIEW", ¶ms_overview);
|
||||||
let income_task = client.query("INCOME_STATEMENT", ¶ms_income);
|
let income_task = client.query("INCOME_STATEMENT", ¶ms_income);
|
||||||
let balance_task = client.query("BALANCE_SHEET", ¶ms_balance);
|
let balance_task = client.query("BALANCE_SHEET", ¶ms_balance);
|
||||||
let cashflow_task = client.query("CASH_FLOW", ¶ms_cashflow);
|
let cashflow_task = client.query("CASH_FLOW", ¶ms_cashflow);
|
||||||
@ -98,34 +143,84 @@ pub async fn handle_fetch_command(
|
|||||||
|
|
||||||
// --- 2. Transform and persist data ---
|
// --- 2. Transform and persist data ---
|
||||||
// Profile
|
// Profile
|
||||||
let profile_to_persist =
|
// Check if overview_json is empty (Symbol field check)
|
||||||
parse_company_profile(overview_json).context("Failed to parse CompanyProfile")?;
|
if let Some(_symbol_val) = overview_json.get("Symbol") {
|
||||||
|
match parse_company_profile(overview_json) {
|
||||||
|
Ok(profile_to_persist) => {
|
||||||
persistence_client
|
persistence_client
|
||||||
.upsert_company_profile(profile_to_persist)
|
.upsert_company_profile(profile_to_persist)
|
||||||
.await?;
|
.await?;
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse CompanyProfile: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("CompanyProfile data is empty or missing 'Symbol' for {}, skipping persistence.", av_symbol);
|
||||||
|
}
|
||||||
|
|
||||||
// Financials
|
// Financials
|
||||||
|
let mut years_updated: Vec<u16> = Vec::new();
|
||||||
|
// Only attempt to parse financials if we have data (simple check if income statement has annualReports)
|
||||||
|
if income_json.get("annualReports").is_some() {
|
||||||
let combined_financials = CombinedFinancials {
|
let combined_financials = CombinedFinancials {
|
||||||
income: income_json,
|
income: income_json,
|
||||||
balance_sheet: balance_json,
|
balance_sheet: balance_json,
|
||||||
cash_flow: cashflow_json,
|
cash_flow: cashflow_json,
|
||||||
};
|
};
|
||||||
let financials_to_persist =
|
match parse_financials(combined_financials) {
|
||||||
parse_financials(combined_financials).context("Failed to parse FinancialStatements")?;
|
Ok(financials_to_persist) => {
|
||||||
let years_updated: Vec<u16> = financials_to_persist
|
if !financials_to_persist.is_empty() {
|
||||||
|
years_updated = financials_to_persist
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| f.period_date.year() as u16)
|
.map(|f| f.period_date.year() as u16)
|
||||||
.collect();
|
.collect();
|
||||||
persistence_client
|
persistence_client
|
||||||
.batch_insert_financials(financials_to_persist)
|
.batch_insert_financials(financials_to_persist)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse Financials: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Financial data missing for {}, skipping.", av_symbol);
|
||||||
|
}
|
||||||
|
|
||||||
// Quote
|
// Quote
|
||||||
let quote_to_persist =
|
// Fix Python-dict string if necessary
|
||||||
parse_realtime_quote(quote_json, &command.market).context("Failed to parse RealtimeQuote")?;
|
let fixed_quote_json = if let Some(s) = quote_json.as_str() {
|
||||||
|
if s.trim().starts_with("{'Global Quote'") {
|
||||||
|
// Attempt to replace single quotes with double quotes
|
||||||
|
// Note: This is a naive fix but works for the expected format
|
||||||
|
let fixed = s.replace("'", "\"");
|
||||||
|
match serde_json::from_str::<Value>(&fixed) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to fix/parse quoted JSON string: {}. Error: {}", s, e);
|
||||||
|
quote_json // fallback to original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote_json
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote_json
|
||||||
|
};
|
||||||
|
|
||||||
|
match parse_realtime_quote(fixed_quote_json, &command.market) {
|
||||||
|
Ok(mut quote_to_persist) => {
|
||||||
|
// Restore original symbol if we converted it
|
||||||
|
quote_to_persist.symbol = command.symbol.clone();
|
||||||
persistence_client
|
persistence_client
|
||||||
.upsert_realtime_quote(quote_to_persist)
|
.upsert_realtime_quote(quote_to_persist)
|
||||||
.await?;
|
.await?;
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse RealtimeQuote: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update_task_progress(
|
update_task_progress(
|
||||||
&state.tasks,
|
&state.tasks,
|
||||||
@ -136,18 +231,23 @@ pub async fn handle_fetch_command(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// --- 3. Publish events ---
|
// --- 3. Publish events ---
|
||||||
|
// Only publish if we actually updated something
|
||||||
|
// Actually, we should publish event even if partial, to signal completion?
|
||||||
|
// The command is "FetchCompanyData", implies success if we fetched *available* data.
|
||||||
|
|
||||||
let event = FinancialsPersistedEvent {
|
let event = FinancialsPersistedEvent {
|
||||||
request_id: command.request_id,
|
request_id: command.request_id,
|
||||||
symbol: command.symbol,
|
symbol: command.symbol,
|
||||||
years_updated,
|
years_updated,
|
||||||
|
template_id: command.template_id,
|
||||||
};
|
};
|
||||||
let subject = "financials.persisted".to_string(); // NATS subject
|
let subject = "events.data.financials_persisted".to_string(); // NATS subject
|
||||||
publisher
|
publisher
|
||||||
.publish(subject, serde_json::to_vec(&event).unwrap().into())
|
.publish(subject, serde_json::to_vec(&event).unwrap().into())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
state.tasks.remove(&command.request_id);
|
state.tasks.remove(&command.request_id);
|
||||||
info!("Task completed successfully.");
|
info!("Task completed successfully (Partial data may be missing if provider lacks coverage).");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Json},
|
response::{IntoResponse, Json},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@ -23,6 +23,7 @@ const ANALYSIS_COMMANDS_QUEUE: &str = "analysis.commands.generate_report";
|
|||||||
pub struct DataRequest {
|
pub struct DataRequest {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub market: String,
|
pub market: String,
|
||||||
|
pub template_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@ -35,6 +36,11 @@ pub struct AnalysisRequest {
|
|||||||
pub template_id: String,
|
pub template_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AnalysisResultQuery {
|
||||||
|
pub symbol: String,
|
||||||
|
}
|
||||||
|
|
||||||
// --- Router Definition ---
|
// --- Router Definition ---
|
||||||
pub fn create_router(app_state: AppState) -> Router {
|
pub fn create_router(app_state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@ -51,7 +57,9 @@ fn create_v1_router() -> Router<AppState> {
|
|||||||
"/analysis-requests/{symbol}",
|
"/analysis-requests/{symbol}",
|
||||||
post(trigger_analysis_generation),
|
post(trigger_analysis_generation),
|
||||||
)
|
)
|
||||||
|
.route("/analysis-results", get(get_analysis_results_by_symbol))
|
||||||
.route("/companies/{symbol}/profile", get(get_company_profile))
|
.route("/companies/{symbol}/profile", get(get_company_profile))
|
||||||
|
.route("/market-data/financial-statements/{symbol}", get(get_financials_by_symbol))
|
||||||
.route("/tasks/{request_id}", get(get_task_progress))
|
.route("/tasks/{request_id}", get(get_task_progress))
|
||||||
// --- New Config Routes ---
|
// --- New Config Routes ---
|
||||||
.route(
|
.route(
|
||||||
@ -67,6 +75,7 @@ fn create_v1_router() -> Router<AppState> {
|
|||||||
get(get_data_sources_config).put(update_data_sources_config),
|
get(get_data_sources_config).put(update_data_sources_config),
|
||||||
)
|
)
|
||||||
.route("/configs/test", post(test_data_source_config))
|
.route("/configs/test", post(test_data_source_config))
|
||||||
|
.route("/configs/llm/test", post(test_llm_config))
|
||||||
// --- New Discover Routes ---
|
// --- New Discover Routes ---
|
||||||
.route("/discover-models/{provider_id}", get(discover_models))
|
.route("/discover-models/{provider_id}", get(discover_models))
|
||||||
.route("/discover-models", post(discover_models_preview))
|
.route("/discover-models", post(discover_models_preview))
|
||||||
@ -102,8 +111,9 @@ async fn trigger_data_fetch(
|
|||||||
let request_id = Uuid::new_v4();
|
let request_id = Uuid::new_v4();
|
||||||
let command = FetchCompanyDataCommand {
|
let command = FetchCompanyDataCommand {
|
||||||
request_id,
|
request_id,
|
||||||
symbol: payload.symbol,
|
symbol: payload.symbol.clone(),
|
||||||
market: payload.market,
|
market: payload.market,
|
||||||
|
template_id: payload.template_id.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(request_id = %request_id, "Publishing data fetch command");
|
info!(request_id = %request_id, "Publishing data fetch command");
|
||||||
@ -152,6 +162,15 @@ async fn trigger_analysis_generation(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [GET /v1/analysis-results?symbol=...]
|
||||||
|
async fn get_analysis_results_by_symbol(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<AnalysisResultQuery>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let results = state.persistence_client.get_analysis_results(&query.symbol).await?;
|
||||||
|
Ok(Json(results))
|
||||||
|
}
|
||||||
|
|
||||||
/// [GET /v1/companies/:symbol/profile]
|
/// [GET /v1/companies/:symbol/profile]
|
||||||
/// Queries the persisted company profile from the data-persistence-service.
|
/// Queries the persisted company profile from the data-persistence-service.
|
||||||
async fn get_company_profile(
|
async fn get_company_profile(
|
||||||
@ -162,6 +181,15 @@ async fn get_company_profile(
|
|||||||
Ok(Json(profile))
|
Ok(Json(profile))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [GET /v1/market-data/financial-statements/:symbol]
|
||||||
|
async fn get_financials_by_symbol(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(symbol): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let financials = state.persistence_client.get_financials(&symbol).await?;
|
||||||
|
Ok(Json(financials))
|
||||||
|
}
|
||||||
|
|
||||||
/// [GET /v1/tasks/:request_id]
|
/// [GET /v1/tasks/:request_id]
|
||||||
/// Aggregates task progress from all downstream provider services.
|
/// Aggregates task progress from all downstream provider services.
|
||||||
async fn get_task_progress(
|
async fn get_task_progress(
|
||||||
@ -273,6 +301,42 @@ async fn test_data_source_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize)]
|
||||||
|
struct TestLlmConfigRequest {
|
||||||
|
api_base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_llm_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<TestLlmConfigRequest>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let target_url = format!("{}/test-llm", state.config.report_generator_service_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&target_url)
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await?;
|
||||||
|
return Ok((
|
||||||
|
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY),
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "LLM test failed",
|
||||||
|
"details": error_text,
|
||||||
|
})),
|
||||||
|
).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_json: serde_json::Value = response.json().await?;
|
||||||
|
Ok((StatusCode::OK, Json(response_json)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Config API Handlers (Proxy to data-persistence-service) ---
|
// --- Config API Handlers (Proxy to data-persistence-service) ---
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ pub struct AppConfig {
|
|||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub nats_addr: String,
|
pub nats_addr: String,
|
||||||
pub data_persistence_service_url: String,
|
pub data_persistence_service_url: String,
|
||||||
|
pub report_generator_service_url: String,
|
||||||
pub provider_services: Vec<String>,
|
pub provider_services: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,6 +19,8 @@ impl AppConfig {
|
|||||||
let server_port: u16 = cfg.get::<u16>("server_port")?;
|
let server_port: u16 = cfg.get::<u16>("server_port")?;
|
||||||
let nats_addr: String = cfg.get::<String>("nats_addr")?;
|
let nats_addr: String = cfg.get::<String>("nats_addr")?;
|
||||||
let data_persistence_service_url: String = cfg.get::<String>("data_persistence_service_url")?;
|
let data_persistence_service_url: String = cfg.get::<String>("data_persistence_service_url")?;
|
||||||
|
let report_generator_service_url: String = cfg.get::<String>("report_generator_service_url")
|
||||||
|
.unwrap_or_else(|_| "http://report-generator-service:8004".to_string());
|
||||||
|
|
||||||
// Parse provider_services deterministically:
|
// Parse provider_services deterministically:
|
||||||
// 1) prefer array from env (e.g., PROVIDER_SERVICES__0, PROVIDER_SERVICES__1, ...)
|
// 1) prefer array from env (e.g., PROVIDER_SERVICES__0, PROVIDER_SERVICES__1, ...)
|
||||||
@ -45,6 +48,7 @@ impl AppConfig {
|
|||||||
server_port,
|
server_port,
|
||||||
nats_addr,
|
nats_addr,
|
||||||
data_persistence_service_url,
|
data_persistence_service_url,
|
||||||
|
report_generator_service_url,
|
||||||
provider_services,
|
provider_services,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
//!
|
//!
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use common_contracts::dtos::CompanyProfileDto;
|
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||||
use common_contracts::config_models::{LlmProvidersConfig, DataSourcesConfig, AnalysisTemplateSets};
|
use common_contracts::config_models::{LlmProvidersConfig, DataSourcesConfig, AnalysisTemplateSets};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -33,6 +33,32 @@ impl PersistenceClient {
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_financials(&self, symbol: &str) -> Result<Vec<TimeSeriesFinancialDto>> {
|
||||||
|
let url = format!("{}/market-data/financial-statements/{}", self.base_url, symbol);
|
||||||
|
let financials = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<Vec<TimeSeriesFinancialDto>>()
|
||||||
|
.await?;
|
||||||
|
Ok(financials)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_analysis_results(&self, symbol: &str) -> Result<Vec<common_contracts::dtos::AnalysisResultDto>> {
|
||||||
|
let url = format!("{}/analysis-results?symbol={}", self.base_url, symbol);
|
||||||
|
let results = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<Vec<common_contracts::dtos::AnalysisResultDto>>()
|
||||||
|
.await?;
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Config Methods ---
|
// --- Config Methods ---
|
||||||
|
|
||||||
pub async fn get_llm_providers_config(&self) -> Result<LlmProvidersConfig> {
|
pub async fn get_llm_providers_config(&self) -> Result<LlmProvidersConfig> {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ pub struct CompanyProfileDto {
|
|||||||
pub industry: Option<String>,
|
pub industry: Option<String>,
|
||||||
pub list_date: Option<NaiveDate>,
|
pub list_date: Option<NaiveDate>,
|
||||||
pub additional_info: Option<JsonValue>,
|
pub additional_info: Option<JsonValue>,
|
||||||
|
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Market Data API DTOs
|
// Market Data API DTOs
|
||||||
@ -62,7 +63,7 @@ pub struct NewAnalysisResult {
|
|||||||
/// Represents a persisted analysis result read from the database.
|
/// Represents a persisted analysis result read from the database.
|
||||||
#[api_dto]
|
#[api_dto]
|
||||||
pub struct AnalysisResultDto {
|
pub struct AnalysisResultDto {
|
||||||
pub id: i64,
|
pub id: Uuid,
|
||||||
pub request_id: Uuid,
|
pub request_id: Uuid,
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub template_id: String,
|
pub template_id: String,
|
||||||
|
|||||||
@ -3,5 +3,6 @@ pub mod models;
|
|||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod config_models;
|
pub mod config_models;
|
||||||
|
pub mod provider;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ pub struct FetchCompanyDataCommand {
|
|||||||
pub request_id: Uuid,
|
pub request_id: Uuid,
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub market: String,
|
pub market: String,
|
||||||
|
pub template_id: Option<String>, // Optional trigger for analysis
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Command to start a full report generation workflow.
|
/// Command to start a full report generation workflow.
|
||||||
@ -35,6 +36,7 @@ pub struct FinancialsPersistedEvent {
|
|||||||
pub request_id: Uuid,
|
pub request_id: Uuid,
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub years_updated: Vec<u16>,
|
pub years_updated: Vec<u16>,
|
||||||
|
pub template_id: Option<String>, // Pass-through for analysis trigger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
services/common-contracts/src/provider.rs
Normal file
8
services/common-contracts/src/provider.rs
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -8,10 +8,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use common_contracts::dtos::{AnalysisResultDto, NewAnalysisResult};
|
use common_contracts::dtos::{AnalysisResultDto, NewAnalysisResult};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::instrument;
|
use service_kit::api;
|
||||||
|
use tracing::{instrument, error};
|
||||||
use anyhow::Error as AnyhowError;
|
use anyhow::Error as AnyhowError;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
|
||||||
pub struct AnalysisQuery {
|
pub struct AnalysisQuery {
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub module_id: Option<String>,
|
pub module_id: Option<String>,
|
||||||
@ -19,15 +20,18 @@ pub struct AnalysisQuery {
|
|||||||
|
|
||||||
/// Creates a new analysis result and returns the created record.
|
/// Creates a new analysis result and returns the created record.
|
||||||
#[instrument(skip(state, payload), fields(request_id = %payload.request_id, symbol = %payload.symbol, module_id = %payload.module_id))]
|
#[instrument(skip(state, payload), fields(request_id = %payload.request_id, symbol = %payload.symbol, module_id = %payload.module_id))]
|
||||||
|
#[api(POST, "/api/v1/analysis-results")]
|
||||||
pub async fn create_analysis_result(
|
pub async fn create_analysis_result(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<NewAnalysisResult>,
|
Json(payload): Json<NewAnalysisResult>,
|
||||||
) -> Result<impl IntoResponse, ServerError> {
|
) -> Result<impl IntoResponse, ServerError> {
|
||||||
|
// Use explicit column names to avoid issues if DB schema and struct are slightly out of sync
|
||||||
|
// Also ensure we are returning all fields needed by AnalysisResult
|
||||||
let result = sqlx::query_as::<_, AnalysisResult>(
|
let result = sqlx::query_as::<_, AnalysisResult>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO analysis_results (request_id, symbol, template_id, module_id, content, meta_data)
|
INSERT INTO analysis_results (request_id, symbol, template_id, module_id, content, meta_data)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *
|
RETURNING id, request_id, symbol, template_id, module_id, content, meta_data, created_at
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(&payload.request_id)
|
.bind(&payload.request_id)
|
||||||
@ -38,7 +42,10 @@ pub async fn create_analysis_result(
|
|||||||
.bind(&payload.meta_data)
|
.bind(&payload.meta_data)
|
||||||
.fetch_one(state.pool())
|
.fetch_one(state.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(AnyhowError::from)?;
|
.map_err(|e| {
|
||||||
|
error!("Database error inserting analysis result: {}", e);
|
||||||
|
AnyhowError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
let dto = AnalysisResultDto {
|
let dto = AnalysisResultDto {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
@ -56,13 +63,33 @@ pub async fn create_analysis_result(
|
|||||||
|
|
||||||
/// Retrieves all analysis results for a given symbol.
|
/// Retrieves all analysis results for a given symbol.
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
|
#[api(GET, "/api/v1/analysis-results", output(list = "AnalysisResultDto"))]
|
||||||
pub async fn get_analysis_results(
|
pub async fn get_analysis_results(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(query): Query<AnalysisQuery>,
|
Query(query): Query<AnalysisQuery>,
|
||||||
) -> Result<Json<Vec<AnalysisResultDto>>, ServerError> {
|
) -> Result<Json<Vec<AnalysisResultDto>>, ServerError> {
|
||||||
let results = sqlx::query_as::<_, AnalysisResult>(
|
// Use string replacement for module_id to avoid lifetime issues with query_builder
|
||||||
|
// This is safe because we're not interpolating user input directly into the SQL structure, just deciding whether to add a clause.
|
||||||
|
// However, binding parameters is better. The issue with previous code was lifetime of temporary values.
|
||||||
|
|
||||||
|
let results = if let Some(mid) = &query.module_id {
|
||||||
|
sqlx::query_as::<_, AnalysisResult>(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM analysis_results
|
SELECT id, request_id, symbol, template_id, module_id, content, meta_data, created_at
|
||||||
|
FROM analysis_results
|
||||||
|
WHERE symbol = $1 AND module_id = $2
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(&query.symbol)
|
||||||
|
.bind(mid)
|
||||||
|
.fetch_all(state.pool())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, AnalysisResult>(
|
||||||
|
r#"
|
||||||
|
SELECT id, request_id, symbol, template_id, module_id, content, meta_data, created_at
|
||||||
|
FROM analysis_results
|
||||||
WHERE symbol = $1
|
WHERE symbol = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"#
|
"#
|
||||||
@ -70,7 +97,12 @@ pub async fn get_analysis_results(
|
|||||||
.bind(&query.symbol)
|
.bind(&query.symbol)
|
||||||
.fetch_all(state.pool())
|
.fetch_all(state.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(AnyhowError::from)?;
|
};
|
||||||
|
|
||||||
|
let results = results.map_err(|e| {
|
||||||
|
error!("Database error fetching analysis results: {}", e);
|
||||||
|
AnyhowError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
let dtos = results
|
let dtos = results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -89,22 +121,31 @@ pub async fn get_analysis_results(
|
|||||||
Ok(Json(dtos))
|
Ok(Json(dtos))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Retrieves a single analysis result by its primary ID.
|
/// Retrieves a single analysis result by its primary ID.
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
|
#[api(GET, "/api/v1/analysis-results/{id}", output(detail = "AnalysisResultDto"))]
|
||||||
pub async fn get_analysis_result_by_id(
|
pub async fn get_analysis_result_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id_str): Path<String>,
|
||||||
) -> Result<Json<AnalysisResultDto>, ServerError> {
|
) -> Result<Json<AnalysisResultDto>, ServerError> {
|
||||||
|
let id = Uuid::parse_str(&id_str).map_err(|_| ServerError::NotFound(format!("Invalid UUID: {}", id_str)))?;
|
||||||
|
|
||||||
let result = sqlx::query_as::<_, AnalysisResult>(
|
let result = sqlx::query_as::<_, AnalysisResult>(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM analysis_results
|
SELECT id, request_id, symbol, template_id, module_id, content, meta_data, created_at
|
||||||
|
FROM analysis_results
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_one(state.pool())
|
.fetch_one(state.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(AnyhowError::from)?;
|
.map_err(|e| {
|
||||||
|
error!("Database error fetching analysis result by id: {}", e);
|
||||||
|
AnyhowError::from(e)
|
||||||
|
})?;
|
||||||
|
|
||||||
let dto = AnalysisResultDto {
|
let dto = AnalysisResultDto {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
|
|||||||
@ -40,6 +40,7 @@ pub async fn get_company_by_symbol(
|
|||||||
industry: company.industry,
|
industry: company.industry,
|
||||||
list_date: company.list_date,
|
list_date: company.list_date,
|
||||||
additional_info: company.additional_info,
|
additional_info: company.additional_info,
|
||||||
|
updated_at: Some(company.updated_at),
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(target: "api", symbol = %dto.symbol, "get_company_by_symbol completed");
|
info!(target: "api", symbol = %dto.symbol, "get_company_by_symbol completed");
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use common_contracts::config_models::{AnalysisTemplateSets, DataSourcesConfig, LlmProvidersConfig};
|
use common_contracts::config_models::{AnalysisTemplateSets, DataSourcesConfig, LlmProvidersConfig};
|
||||||
use service_kit::api;
|
use service_kit::api;
|
||||||
use tracing::instrument;
|
|
||||||
use crate::{db::system_config, AppState, ServerError};
|
use crate::{db::system_config, AppState, ServerError};
|
||||||
|
|
||||||
#[api(GET, "/api/v1/configs/llm_providers", output(detail = "LlmProvidersConfig"))]
|
#[api(GET, "/api/v1/configs/llm_providers", output(detail = "LlmProvidersConfig"))]
|
||||||
|
|||||||
@ -29,7 +29,7 @@ pub async fn batch_insert_financials(
|
|||||||
Ok(axum::http::StatusCode::CREATED)
|
Ok(axum::http::StatusCode::CREATED)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[api(GET, "/api/v1/market-data/financials/{symbol}", output(list = "TimeSeriesFinancialDto"))]
|
#[api(GET, "/api/v1/market-data/financial-statements/{symbol}", output(list = "TimeSeriesFinancialDto"))]
|
||||||
pub async fn get_financials_by_symbol(
|
pub async fn get_financials_by_symbol(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(symbol): Path<String>,
|
Path(symbol): Path<String>,
|
||||||
|
|||||||
@ -14,9 +14,11 @@ pub struct SystemConfig {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct AnalysisResult {
|
pub struct AnalysisResult {
|
||||||
pub id: i64,
|
pub id: Uuid,
|
||||||
|
#[sqlx(default)] // request_id is missing in some schema versions, handle gracefully or ensure migration runs
|
||||||
pub request_id: Uuid,
|
pub request_id: Uuid,
|
||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
|
// template_id/module_id might be missing if schema is very old, but we rely on migrations
|
||||||
pub template_id: String,
|
pub template_id: String,
|
||||||
pub module_id: String,
|
pub module_id: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
//! One-time data seeding logic for initializing the database.
|
//! One-time data seeding logic for initializing the database.
|
||||||
|
|
||||||
use data_persistence_service::models::SystemConfig;
|
|
||||||
use common_contracts::config_models::{AnalysisModuleConfig, AnalysisTemplateSet, AnalysisTemplateSets};
|
use common_contracts::config_models::{AnalysisModuleConfig, AnalysisTemplateSet, AnalysisTemplateSets};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ async fn test_api_upsert_and_get_company(pool: PgPool) {
|
|||||||
industry: Some("API Testing".to_string()),
|
industry: Some("API Testing".to_string()),
|
||||||
list_date: Some(chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()),
|
list_date: Some(chrono::NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()),
|
||||||
additional_info: None,
|
additional_info: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request = Request::builder()
|
let request = Request::builder()
|
||||||
|
|||||||
@ -43,6 +43,7 @@ async fn test_upsert_and_get_company(pool: PgPool) {
|
|||||||
industry: Some("Testing".to_string()),
|
industry: Some("Testing".to_string()),
|
||||||
list_date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
|
list_date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
|
||||||
additional_info: Some(serde_json::json!({ "ceo": "John Doe" })),
|
additional_info: Some(serde_json::json!({ "ceo": "John Doe" })),
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Act: Call the upsert function
|
// 2. Act: Call the upsert function
|
||||||
|
|||||||
@ -6,6 +6,7 @@ use crate::{
|
|||||||
mapping::{map_financial_dtos, map_profile_dto},
|
mapping::{map_financial_dtos, map_profile_dto},
|
||||||
};
|
};
|
||||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||||
|
use common_contracts::provider::DataProvider;
|
||||||
use tokio;
|
use tokio;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@ -60,6 +61,16 @@ pub struct FinnhubDataProvider {
|
|||||||
client: FinnhubClient,
|
client: FinnhubClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DataProvider for FinnhubDataProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"finnhub"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FinnhubDataProvider {
|
impl FinnhubDataProvider {
|
||||||
pub fn new(api_url: String, api_token: String) -> Self {
|
pub fn new(api_url: String, api_token: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub fn map_profile_dto(profile_raw: &FinnhubProfile, symbol: &str) -> Result<Com
|
|||||||
industry,
|
industry,
|
||||||
list_date,
|
list_date,
|
||||||
additional_info: None,
|
additional_info: None,
|
||||||
|
updated_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,7 @@ pub async fn handle_fetch_command(
|
|||||||
request_id: command.request_id,
|
request_id: command.request_id,
|
||||||
symbol: command.symbol.clone(),
|
symbol: command.symbol.clone(),
|
||||||
years_updated: years_set.into_iter().collect(),
|
years_updated: years_set.into_iter().collect(),
|
||||||
|
template_id: command.template_id.clone(),
|
||||||
};
|
};
|
||||||
publisher
|
publisher
|
||||||
.publish(
|
.publish(
|
||||||
|
|||||||
274
services/report-generator-service/Cargo.lock
generated
274
services/report-generator-service/Cargo.lock
generated
@ -73,7 +73,7 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs 0.7.3",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"rustls-webpki 0.102.8",
|
"rustls-webpki 0.102.8",
|
||||||
"serde",
|
"serde",
|
||||||
@ -92,6 +92,43 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-openai"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6bf39a15c8d613eb61892dc9a287c02277639ebead41ee611ad23aaa613f1a82"
|
||||||
|
dependencies = [
|
||||||
|
"async-openai-macros",
|
||||||
|
"backoff",
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"derive_builder",
|
||||||
|
"eventsource-stream",
|
||||||
|
"futures",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"reqwest",
|
||||||
|
"reqwest-eventsource",
|
||||||
|
"secrecy",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-openai-macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0289cba6d5143bfe8251d57b4a8cac036adf158525a76533a7082ba65ec76398"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@ -176,6 +213,20 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backoff"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"getrandom 0.2.16",
|
||||||
|
"instant",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@ -440,6 +491,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@ -546,6 +607,41 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||||
|
dependencies = [
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.20.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.1.0"
|
version = "6.1.0"
|
||||||
@ -587,6 +683,37 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_core"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_builder_macro"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||||
|
dependencies = [
|
||||||
|
"derive_builder_core",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deunicode"
|
name = "deunicode"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
@ -716,6 +843,17 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "eventsource-stream"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"nom",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@ -730,9 +868,9 @@ checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fixedbitset"
|
name = "fixedbitset"
|
||||||
version = "0.4.2"
|
version = "0.5.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
@ -854,6 +992,12 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-timer"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@ -1100,6 +1244,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-native-certs 0.8.2",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@ -1236,6 +1381,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -1285,6 +1436,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instant"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inventory"
|
name = "inventory"
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
@ -1443,6 +1603,22 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@ -1469,6 +1645,16 @@ dependencies = [
|
|||||||
"signatory",
|
"signatory",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -1665,12 +1851,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "petgraph"
|
name = "petgraph"
|
||||||
version = "0.6.5"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
|
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fixedbitset",
|
"fixedbitset",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2046,6 +2234,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
|
"async-openai",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -2076,6 +2265,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@ -2084,10 +2274,12 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime_guess",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-native-certs 0.8.2",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -2095,16 +2287,34 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.4",
|
"webpki-roots 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest-eventsource"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde"
|
||||||
|
dependencies = [
|
||||||
|
"eventsource-stream",
|
||||||
|
"futures-core",
|
||||||
|
"futures-timer",
|
||||||
|
"mime",
|
||||||
|
"nom",
|
||||||
|
"pin-project-lite",
|
||||||
|
"reqwest",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@ -2247,7 +2457,19 @@ dependencies = [
|
|||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"schannel",
|
"schannel",
|
||||||
"security-framework",
|
"security-framework 2.11.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework 3.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2375,7 +2597,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
@ -2903,6 +3138,12 @@ dependencies = [
|
|||||||
"unicode-properties",
|
"unicode-properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@ -3369,6 +3610,12 @@ version = "0.1.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@ -3579,6 +3826,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.82"
|
version = "0.3.82"
|
||||||
|
|||||||
@ -41,4 +41,5 @@ thiserror = "2.0.17"
|
|||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
chrono = "0.4.38"
|
chrono = "0.4.38"
|
||||||
tera = "1.19"
|
tera = "1.19"
|
||||||
petgraph = "0.6.5"
|
petgraph = "0.8.3"
|
||||||
|
async-openai = "0.30.1"
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
response::Json,
|
response::Json,
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
|
use common_contracts::observability::{HealthStatus, ServiceStatus, TaskProgress};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use secrecy::SecretString;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::llm_client::LlmClient;
|
||||||
|
|
||||||
pub fn create_router(app_state: AppState) -> Router {
|
pub fn create_router(app_state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
.route("/tasks", get(get_current_tasks))
|
.route("/tasks", get(get_current_tasks))
|
||||||
|
.route("/test-llm", post(test_llm_connection))
|
||||||
.with_state(app_state)
|
.with_state(app_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,3 +46,26 @@ async fn get_current_tasks(State(state): State<AppState>) -> Json<Vec<TaskProgre
|
|||||||
.collect();
|
.collect();
|
||||||
Json(tasks)
|
Json(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TestLlmRequest {
|
||||||
|
pub api_base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub model_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_llm_connection(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
Json(payload): Json<TestLlmRequest>,
|
||||||
|
) -> Result<Json<String>, (StatusCode, String)> {
|
||||||
|
let client = LlmClient::new(
|
||||||
|
payload.api_base_url,
|
||||||
|
SecretString::from(payload.api_key),
|
||||||
|
payload.model_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client.generate_text("Hello".to_string()).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|||||||
@ -1,67 +1,78 @@
|
|||||||
use crate::error::ProviderError;
|
use crate::error::ProviderError;
|
||||||
use reqwest::Client;
|
use async_openai::{
|
||||||
|
config::OpenAIConfig,
|
||||||
|
types::{CreateChatCompletionRequestArgs, ChatCompletionRequestUserMessageArgs},
|
||||||
|
Client,
|
||||||
|
};
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use serde::Serialize;
|
use tracing::debug;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LlmClient {
|
pub struct LlmClient {
|
||||||
client: Client,
|
client: Client<OpenAIConfig>,
|
||||||
api_url: String,
|
|
||||||
api_key: SecretString,
|
|
||||||
model: String,
|
model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct LlmRequest {
|
|
||||||
model: String,
|
|
||||||
prompt: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
impl LlmClient {
|
impl LlmClient {
|
||||||
pub fn new(api_url: String, api_key: SecretString, model: String) -> Self {
|
pub fn new(api_url: String, api_key: SecretString, model: String) -> Self {
|
||||||
|
let api_url = api_url.trim();
|
||||||
|
// async_openai expects the base URL to NOT include /chat/completions
|
||||||
|
// It usually expects something like "https://api.openai.com/v1"
|
||||||
|
// If the user provided a full URL like ".../chat/completions", we should strip it.
|
||||||
|
|
||||||
|
let base_url = if api_url.ends_with("/chat/completions") {
|
||||||
|
api_url.trim_end_matches("/chat/completions").trim_end_matches('/').to_string()
|
||||||
|
} else if api_url.ends_with("/completions") {
|
||||||
|
api_url.trim_end_matches("/completions").trim_end_matches('/').to_string()
|
||||||
|
} else {
|
||||||
|
api_url.trim_end_matches('/').to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Initializing LlmClient with base_url: {}", base_url);
|
||||||
|
|
||||||
|
let config = OpenAIConfig::new()
|
||||||
|
.with_api_base(base_url)
|
||||||
|
.with_api_key(api_key.expose_secret());
|
||||||
|
|
||||||
|
let client = Client::with_config(config);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
client: Client::new(),
|
client,
|
||||||
api_url,
|
|
||||||
api_key,
|
|
||||||
model,
|
model,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn generate_text(&self, prompt: String) -> Result<String, ProviderError> {
|
pub async fn generate_text(&self, prompt: String) -> Result<String, ProviderError> {
|
||||||
let request_payload = LlmRequest {
|
debug!("Sending request to LLM model: {}", self.model);
|
||||||
model: self.model.clone(),
|
|
||||||
prompt,
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = self
|
let request = CreateChatCompletionRequestArgs::default()
|
||||||
.client
|
.model(&self.model)
|
||||||
.post(&self.api_url)
|
.messages([
|
||||||
.bearer_auth(self.api_key.expose_secret())
|
ChatCompletionRequestUserMessageArgs::default()
|
||||||
.json(&request_payload)
|
.content(prompt)
|
||||||
.send()
|
.build()
|
||||||
.await?;
|
.map_err(|e| ProviderError::LlmApi(format!("Failed to build message: {}", e)))?
|
||||||
|
.into()
|
||||||
|
])
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ProviderError::LlmApi(format!("Failed to build request: {}", e)))?;
|
||||||
|
|
||||||
if !res.status().is_success() {
|
let response = self.client.chat().create(request).await
|
||||||
let status = res.status();
|
.map_err(|e| {
|
||||||
let error_text = res
|
let err_msg = e.to_string();
|
||||||
.text()
|
if err_msg.contains("<!DOCTYPE html>") || err_msg.contains("<html") {
|
||||||
.await
|
ProviderError::LlmApi(format!("LLM API request failed: Received HTML response instead of JSON. Please check your LLM Provider Base URL configuration. It might be pointing to a web page (like the Frontend) instead of the API endpoint. Original error: {}", e))
|
||||||
.unwrap_or_else(|_| "Unknown LLM API error".to_string());
|
} else {
|
||||||
return Err(ProviderError::LlmApi(format!(
|
ProviderError::LlmApi(format!("LLM API request failed: {}", e))
|
||||||
"LLM API request failed with status {}: {}",
|
}
|
||||||
status,
|
})?;
|
||||||
error_text
|
|
||||||
)));
|
if let Some(choice) = response.choices.first() {
|
||||||
|
if let Some(content) = &choice.message.content {
|
||||||
|
return Ok(content.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This part needs to be adapted to the actual LLM provider's response format
|
Err(ProviderError::LlmApi("LLM returned no content".to_string()))
|
||||||
let response_data: serde_json::Value = res.json().await?;
|
|
||||||
let text = response_data["choices"][0]["text"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use common_contracts::messages::GenerateReportCommand;
|
use common_contracts::messages::{FinancialsPersistedEvent, GenerateReportCommand};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::{state::AppState, worker::run_report_generation_workflow};
|
use crate::{state::AppState, worker::run_report_generation_workflow};
|
||||||
|
|
||||||
const SUBJECT_NAME: &str = "analysis.commands.generate_report";
|
const SUBJECT_NAME: &str = "events.data.financials_persisted";
|
||||||
|
|
||||||
pub async fn subscribe_to_commands(
|
pub async fn subscribe_to_commands(
|
||||||
app_state: AppState,
|
app_state: AppState,
|
||||||
@ -14,27 +14,36 @@ pub async fn subscribe_to_commands(
|
|||||||
) -> Result<(), anyhow::Error> {
|
) -> Result<(), anyhow::Error> {
|
||||||
let mut subscriber = nats_client.subscribe(SUBJECT_NAME.to_string()).await?;
|
let mut subscriber = nats_client.subscribe(SUBJECT_NAME.to_string()).await?;
|
||||||
info!(
|
info!(
|
||||||
"Consumer started, waiting for commands on subject '{}'",
|
"Consumer started, waiting for events on subject '{}'",
|
||||||
SUBJECT_NAME
|
SUBJECT_NAME
|
||||||
);
|
);
|
||||||
|
|
||||||
while let Some(message) = subscriber.next().await {
|
while let Some(message) = subscriber.next().await {
|
||||||
info!("Received NATS command to generate report.");
|
info!("Received NATS event for persisted financials.");
|
||||||
let state_clone = app_state.clone();
|
let state_clone = app_state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match serde_json::from_slice::<GenerateReportCommand>(&message.payload) {
|
match serde_json::from_slice::<FinancialsPersistedEvent>(&message.payload) {
|
||||||
Ok(command) => {
|
Ok(event) => {
|
||||||
|
if let Some(template_id) = event.template_id {
|
||||||
info!(
|
info!(
|
||||||
"Deserialized command for symbol: {}, template: {}",
|
"Event triggered analysis for symbol: {}, template: {}",
|
||||||
command.symbol, command.template_id
|
event.symbol, template_id
|
||||||
);
|
);
|
||||||
|
let command = GenerateReportCommand {
|
||||||
|
request_id: event.request_id,
|
||||||
|
symbol: event.symbol,
|
||||||
|
template_id,
|
||||||
|
};
|
||||||
if let Err(e) = run_report_generation_workflow(Arc::new(state_clone), command).await
|
if let Err(e) = run_report_generation_workflow(Arc::new(state_clone), command).await
|
||||||
{
|
{
|
||||||
error!("Error running report generation workflow: {:?}", e);
|
error!("Error running report generation workflow: {:?}", e);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
info!("Received data persisted event for {} but no template_id provided. Skipping analysis.", event.symbol);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to deserialize GenerateReportCommand: {}", e);
|
error!("Failed to deserialize FinancialsPersistedEvent: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -103,7 +103,27 @@ pub async fn run_report_generation_workflow(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_msg = format!("LLM generation failed: {}", e);
|
let err_msg = format!("LLM generation failed: {}", e);
|
||||||
error!(module_id = %module_id, "{}", err_msg);
|
error!(module_id = %module_id, "{}", err_msg);
|
||||||
generated_results.insert(module_id.clone(), format!("Error: {}", err_msg));
|
let error_content = format!("Error: {}", err_msg);
|
||||||
|
|
||||||
|
// Persist error result so frontend knows it failed
|
||||||
|
let result_to_persist = NewAnalysisResult {
|
||||||
|
request_id: command.request_id,
|
||||||
|
symbol: command.symbol.clone(),
|
||||||
|
template_id: command.template_id.clone(),
|
||||||
|
module_id: module_id.clone(),
|
||||||
|
content: error_content.clone(),
|
||||||
|
meta_data: serde_json::json!({
|
||||||
|
"model_id": module_config.model_id,
|
||||||
|
"status": "error",
|
||||||
|
"error": err_msg
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(pe) = persistence_client.create_analysis_result(result_to_persist).await {
|
||||||
|
error!(module_id = %module_id, "Failed to persist analysis error result: {}", pe);
|
||||||
|
}
|
||||||
|
|
||||||
|
generated_results.insert(module_id.clone(), error_content);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -116,7 +136,10 @@ pub async fn run_report_generation_workflow(
|
|||||||
template_id: command.template_id.clone(),
|
template_id: command.template_id.clone(),
|
||||||
module_id: module_id.clone(),
|
module_id: module_id.clone(),
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
meta_data: serde_json::json!({ "model_id": module_config.model_id }),
|
meta_data: serde_json::json!({
|
||||||
|
"model_id": module_config.model_id,
|
||||||
|
"status": "success"
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = persistence_client.create_analysis_result(result_to_persist).await {
|
if let Err(e) = persistence_client.create_analysis_result(result_to_persist).await {
|
||||||
@ -186,8 +209,11 @@ fn create_llm_client_for_module(
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let api_url = provider.api_base_url.clone();
|
||||||
|
info!("Creating LLM client for module '{}' using provider '{}' with URL: '{}'", module_config.name, module_config.provider_id, api_url);
|
||||||
|
|
||||||
Ok(LlmClient::new(
|
Ok(LlmClient::new(
|
||||||
provider.api_base_url.clone(),
|
api_url,
|
||||||
provider.api_key.clone().into(),
|
provider.api_key.clone().into(),
|
||||||
module_config.model_id.clone(),
|
module_config.model_id.clone(),
|
||||||
))
|
))
|
||||||
|
|||||||
@ -24,6 +24,16 @@ impl PersistenceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_company_profile(&self, symbol: &str) -> Result<Option<CompanyProfileDto>> {
|
||||||
|
let url = format!("{}/companies/{}", self.base_url, symbol);
|
||||||
|
let resp = self.client.get(&url).send().await?;
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let profile = resp.error_for_status()?.json().await?;
|
||||||
|
Ok(Some(profile))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
||||||
let url = format!("{}/companies", self.base_url);
|
let url = format!("{}/companies", self.base_url);
|
||||||
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
||||||
|
|||||||
@ -9,12 +9,23 @@ use crate::{
|
|||||||
ts_client::TushareClient,
|
ts_client::TushareClient,
|
||||||
};
|
};
|
||||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||||
|
use common_contracts::provider::DataProvider;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TushareDataProvider {
|
pub struct TushareDataProvider {
|
||||||
client: TushareClient,
|
client: TushareClient,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DataProvider for TushareDataProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"tushare"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TushareDataProvider {
|
impl TushareDataProvider {
|
||||||
pub fn new(api_url: String, api_token: String) -> Self {
|
pub fn new(api_url: String, api_token: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -61,6 +72,7 @@ impl TushareDataProvider {
|
|||||||
industry,
|
industry,
|
||||||
list_date,
|
list_date,
|
||||||
additional_info: None,
|
additional_info: None,
|
||||||
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map time-series financials into DTOs
|
// Map time-series financials into DTOs
|
||||||
|
|||||||
@ -32,6 +32,65 @@ pub async fn run_tushare_workflow(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. Update task progress: Fetching data
|
// 1. Update task progress: Fetching data
|
||||||
|
{
|
||||||
|
let mut entry = state
|
||||||
|
.tasks
|
||||||
|
.get_mut(&task_id)
|
||||||
|
.ok_or_else(|| AppError::Internal("Task not found".to_string()))?;
|
||||||
|
entry.status = "CheckingCache".to_string();
|
||||||
|
entry.progress_percent = 5;
|
||||||
|
entry.details = "Checking local data freshness".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check freshness
|
||||||
|
let persistence_client = PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
||||||
|
let mut is_fresh = false;
|
||||||
|
match persistence_client.get_company_profile(&command.symbol).await {
|
||||||
|
Ok(Some(p)) => {
|
||||||
|
if let Some(updated_at) = p.updated_at {
|
||||||
|
let age = chrono::Utc::now() - updated_at;
|
||||||
|
if age < chrono::Duration::hours(24) {
|
||||||
|
info!("Data for {} is fresh (age: {}h). Skipping fetch.", command.symbol, age.num_hours());
|
||||||
|
is_fresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => tracing::warn!("Failed to check profile freshness: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_fresh {
|
||||||
|
{
|
||||||
|
let mut entry = state
|
||||||
|
.tasks
|
||||||
|
.get_mut(&task_id)
|
||||||
|
.ok_or_else(|| AppError::Internal("Task not found".to_string()))?;
|
||||||
|
entry.status = "Completed".to_string();
|
||||||
|
entry.progress_percent = 100;
|
||||||
|
entry.details = "Data retrieved from cache".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let nats_client = async_nats::connect(&state.config.nats_addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("NATS connection failed: {}", e)))?;
|
||||||
|
|
||||||
|
let financials_event = FinancialsPersistedEvent {
|
||||||
|
request_id: command.request_id,
|
||||||
|
symbol: command.symbol.clone(),
|
||||||
|
years_updated: vec![],
|
||||||
|
template_id: command.template_id.clone(),
|
||||||
|
};
|
||||||
|
nats_client
|
||||||
|
.publish(
|
||||||
|
"events.data.financials_persisted",
|
||||||
|
serde_json::to_vec(&financials_event).unwrap().into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = completion_tx.send(()).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut entry = state
|
let mut entry = state
|
||||||
.tasks
|
.tasks
|
||||||
@ -57,7 +116,7 @@ pub async fn run_tushare_workflow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Persist data
|
// 4. Persist data
|
||||||
let persistence_client = PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
// persistence_client already created above
|
||||||
persist_data(
|
persist_data(
|
||||||
&persistence_client,
|
&persistence_client,
|
||||||
&profile,
|
&profile,
|
||||||
@ -158,6 +217,7 @@ async fn publish_events(
|
|||||||
request_id: command.request_id,
|
request_id: command.request_id,
|
||||||
symbol: command.symbol.clone(),
|
symbol: command.symbol.clone(),
|
||||||
years_updated: years.into_iter().collect(),
|
years_updated: years.into_iter().collect(),
|
||||||
|
template_id: command.template_id.clone(),
|
||||||
};
|
};
|
||||||
nats_client
|
nats_client
|
||||||
.publish(
|
.publish(
|
||||||
|
|||||||
62
services/yfinance-provider-service/Cargo.lock
generated
62
services/yfinance-provider-service/Cargo.lock
generated
@ -398,6 +398,35 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie_store"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||||
|
dependencies = [
|
||||||
|
"cookie",
|
||||||
|
"document-features",
|
||||||
|
"idna",
|
||||||
|
"log",
|
||||||
|
"publicsuffix",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@ -568,6 +597,15 @@ dependencies = [
|
|||||||
"const-random",
|
"const-random",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "document-features"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||||
|
dependencies = [
|
||||||
|
"litrs",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dotenvy"
|
name = "dotenvy"
|
||||||
version = "0.15.7"
|
version = "0.15.7"
|
||||||
@ -1352,6 +1390,12 @@ version = "0.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litrs"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@ -1777,6 +1821,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psl-types"
|
||||||
|
version = "2.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptr_meta"
|
name = "ptr_meta"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@ -1797,6 +1847,16 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "publicsuffix"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||||
|
dependencies = [
|
||||||
|
"idna",
|
||||||
|
"psl-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.42"
|
version = "1.0.42"
|
||||||
@ -1923,6 +1983,8 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"cookie_store",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
"h2",
|
||||||
|
|||||||
@ -18,7 +18,7 @@ futures = "0.3"
|
|||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
|
||||||
# Data Persistence Client
|
# Data Persistence Client
|
||||||
reqwest = { version = "0.12.24", features = ["json"] }
|
reqwest = { version = "0.12.24", features = ["json", "cookies"] }
|
||||||
|
|
||||||
# Concurrency & Async
|
# Concurrency & Async
|
||||||
async-trait = "0.1.80"
|
async-trait = "0.1.80"
|
||||||
|
|||||||
@ -5,6 +5,8 @@ pub struct AppConfig {
|
|||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub nats_addr: String,
|
pub nats_addr: String,
|
||||||
pub data_persistence_service_url: String,
|
pub data_persistence_service_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub yfinance_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
|||||||
@ -41,6 +41,7 @@ pub fn map_profile(summary_json: &Value, symbol: &str) -> Result<CompanyProfileD
|
|||||||
industry,
|
industry,
|
||||||
list_date: None,
|
list_date: None,
|
||||||
additional_info: if additional.is_empty() { None } else { Some(Value::Object(additional)) },
|
additional_info: if additional.is_empty() { None } else { Some(Value::Object(additional)) },
|
||||||
|
updated_at: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use common_contracts::{
|
use common_contracts::{
|
||||||
|
config_models::DataSourcesConfig,
|
||||||
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
|
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
|
||||||
};
|
};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@ -24,6 +25,28 @@ impl PersistenceClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_data_sources_config(&self) -> Result<DataSourcesConfig> {
|
||||||
|
let url = format!("{}/configs/data_sources", self.base_url);
|
||||||
|
let config = self.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_company_profile(&self, symbol: &str) -> Result<Option<CompanyProfileDto>> {
|
||||||
|
let url = format!("{}/companies/{}", self.base_url, symbol);
|
||||||
|
let resp = self.client.get(&url).send().await?;
|
||||||
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let profile = resp.error_for_status()?.json().await?;
|
||||||
|
Ok(Some(profile))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
||||||
let url = format!("{}/companies", self.base_url);
|
let url = format!("{}/companies", self.base_url);
|
||||||
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ pub struct AppState {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: AppConfig) -> Self {
|
pub fn new(config: AppConfig) -> Self {
|
||||||
let provider = Arc::new(YFinanceDataProvider::new());
|
let provider = Arc::new(YFinanceDataProvider::new(config.yfinance_enabled));
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
tasks: Arc::new(DashMap::new()),
|
tasks: Arc::new(DashMap::new()),
|
||||||
|
|||||||
@ -12,15 +12,74 @@ pub async fn handle_fetch_command(
|
|||||||
publisher: async_nats::Client,
|
publisher: async_nats::Client,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let task_id = command.request_id;
|
let task_id = command.request_id;
|
||||||
|
|
||||||
|
let client = PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
||||||
|
|
||||||
|
// Fetch dynamic config from data-persistence-service
|
||||||
|
// adhering to "single source of truth" rule (DB config overrides env var)
|
||||||
|
let config = client.get_data_sources_config().await?;
|
||||||
|
let is_enabled = config.get("yfinance")
|
||||||
|
.map(|c| c.enabled)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_enabled {
|
||||||
|
info!("YFinance provider is disabled. Skipping task {}.", task_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
state.tasks.insert(task_id, common_contracts::observability::TaskProgress {
|
state.tasks.insert(task_id, common_contracts::observability::TaskProgress {
|
||||||
request_id: task_id,
|
request_id: task_id,
|
||||||
task_name: format!("yfinance:{}", command.symbol),
|
task_name: format!("yfinance:{}", command.symbol),
|
||||||
status: "FetchingData".to_string(),
|
status: "CheckingCache".to_string(),
|
||||||
progress_percent: 10,
|
progress_percent: 5,
|
||||||
details: "Fetching data from YFinance".to_string(),
|
details: "Checking local data freshness".to_string(),
|
||||||
started_at: chrono::Utc::now(),
|
started_at: chrono::Utc::now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check freshness
|
||||||
|
let mut is_fresh = false;
|
||||||
|
match client.get_company_profile(&command.symbol).await {
|
||||||
|
Ok(Some(p)) => {
|
||||||
|
if let Some(updated_at) = p.updated_at {
|
||||||
|
let age = chrono::Utc::now() - updated_at;
|
||||||
|
if age < chrono::Duration::hours(24) {
|
||||||
|
info!("Data for {} is fresh (age: {}h). Skipping fetch.", command.symbol, age.num_hours());
|
||||||
|
is_fresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => tracing::warn!("Failed to check profile freshness: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_fresh {
|
||||||
|
if let Some(mut task) = state.tasks.get_mut(&task_id) {
|
||||||
|
task.status = "Completed".to_string();
|
||||||
|
task.progress_percent = 100;
|
||||||
|
task.details = "Data retrieved from cache".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let financials_event = FinancialsPersistedEvent {
|
||||||
|
request_id: task_id,
|
||||||
|
symbol: command.symbol.clone(),
|
||||||
|
years_updated: vec![],
|
||||||
|
template_id: command.template_id.clone(),
|
||||||
|
};
|
||||||
|
publisher
|
||||||
|
.publish(
|
||||||
|
"events.data.financials_persisted".to_string(),
|
||||||
|
serde_json::to_vec(&financials_event).unwrap().into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut task) = state.tasks.get_mut(&task_id) {
|
||||||
|
task.status = "FetchingData".to_string();
|
||||||
|
task.progress_percent = 10;
|
||||||
|
task.details = "Fetching data from YFinance".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch
|
// Fetch
|
||||||
let (profile, financials): (CompanyProfileDto, Vec<TimeSeriesFinancialDto>) =
|
let (profile, financials): (CompanyProfileDto, Vec<TimeSeriesFinancialDto>) =
|
||||||
state.yfinance_provider.fetch_all_data(&command.symbol).await?;
|
state.yfinance_provider.fetch_all_data(&command.symbol).await?;
|
||||||
@ -33,7 +92,7 @@ pub async fn handle_fetch_command(
|
|||||||
task.details = "Persisting data".to_string();
|
task.details = "Persisting data".to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let client = PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
// client is already initialized at the beginning of the function
|
||||||
client.upsert_company_profile(profile).await?;
|
client.upsert_company_profile(profile).await?;
|
||||||
let years: std::collections::BTreeSet<u16> = financials.iter().map(|f| f.period_date.year() as u16).collect();
|
let years: std::collections::BTreeSet<u16> = financials.iter().map(|f| f.period_date.year() as u16).collect();
|
||||||
client.batch_insert_financials(financials).await?;
|
client.batch_insert_financials(financials).await?;
|
||||||
@ -54,6 +113,7 @@ pub async fn handle_fetch_command(
|
|||||||
request_id: task_id,
|
request_id: task_id,
|
||||||
symbol: command.symbol.clone(),
|
symbol: command.symbol.clone(),
|
||||||
years_updated: years.into_iter().collect(),
|
years_updated: years.into_iter().collect(),
|
||||||
|
template_id: command.template_id.clone(),
|
||||||
};
|
};
|
||||||
publisher
|
publisher
|
||||||
.publish(
|
.publish(
|
||||||
|
|||||||
@ -1,20 +1,85 @@
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::mapping::{map_financial_statements, map_profile};
|
use crate::mapping::{map_financial_statements, map_profile};
|
||||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||||
|
use common_contracts::provider::DataProvider;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct YFinanceDataProvider {
|
pub struct YFinanceDataProvider {
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
is_enabled: bool,
|
||||||
|
crumb: Arc<RwLock<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataProvider for YFinanceDataProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"yfinance"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_enabled(&self) -> bool {
|
||||||
|
self.is_enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YFinanceDataProvider {
|
impl YFinanceDataProvider {
|
||||||
pub fn new() -> Self {
|
pub fn new(is_enabled: bool) -> Self {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.cookie_store(true)
|
||||||
|
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.redirect(reqwest::redirect::Policy::default())
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client,
|
||||||
|
is_enabled,
|
||||||
|
crumb: Arc::new(RwLock::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_crumb(&self) -> Result<String, AppError> {
|
||||||
|
// 1. Fast path: read lock
|
||||||
|
if let Some(crumb) = self.crumb.read().await.as_ref() {
|
||||||
|
return Ok(crumb.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Slow path: write lock
|
||||||
|
let mut lock = self.crumb.write().await;
|
||||||
|
// Double-check if another thread filled it while we waited
|
||||||
|
if let Some(crumb) = lock.as_ref() {
|
||||||
|
return Ok(crumb.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Fetching new Yahoo Finance crumb...");
|
||||||
|
|
||||||
|
// 3. Fetch cookie from fc.yahoo.com
|
||||||
|
// We don't care about the body, just the cookies which reqwest handles automatically
|
||||||
|
let _ = self.client
|
||||||
|
.get("https://fc.yahoo.com")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
|
|
||||||
|
// 4. Fetch crumb
|
||||||
|
let crumb_resp = self.client
|
||||||
|
.get("https://query1.finance.yahoo.com/v1/test/getcrumb")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
|
|
||||||
|
let crumb_resp = crumb_resp.error_for_status().map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
|
let crumb_text = crumb_resp.text().await.map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
|
|
||||||
|
info!("Successfully fetched crumb: {}", crumb_text);
|
||||||
|
*lock = Some(crumb_text.clone());
|
||||||
|
|
||||||
|
Ok(crumb_text)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_all_data(
|
pub async fn fetch_all_data(
|
||||||
&self,
|
&self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
@ -29,15 +94,28 @@ impl YFinanceDataProvider {
|
|||||||
&self,
|
&self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
) -> Result<(serde_json::Value, serde_json::Value), AppError> {
|
) -> Result<(serde_json::Value, serde_json::Value), AppError> {
|
||||||
|
let crumb = self.ensure_crumb().await?;
|
||||||
|
|
||||||
|
// Convert suffix: .SH -> .SS for Yahoo Finance compatibility
|
||||||
|
// Shanghai: 600519.SH -> 600519.SS
|
||||||
|
// Shenzhen: 000001.SZ -> 000001.SZ (unchanged)
|
||||||
|
let yahoo_symbol = if symbol.ends_with(".SH") {
|
||||||
|
symbol.replace(".SH", ".SS")
|
||||||
|
} else {
|
||||||
|
symbol.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Fetching raw data for symbol: {} (mapped from {})", yahoo_symbol, symbol);
|
||||||
|
|
||||||
// Yahoo quoteSummary: assetProfile + quoteType
|
// Yahoo quoteSummary: assetProfile + quoteType
|
||||||
let summary_url = format!(
|
let summary_url = format!(
|
||||||
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=assetProfile,quoteType",
|
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=assetProfile,quoteType&crumb={}",
|
||||||
symbol
|
yahoo_symbol, crumb
|
||||||
);
|
);
|
||||||
// Yahoo financials: income/balance/cashflow histories
|
// Yahoo financials: income/balance/cashflow histories
|
||||||
let financials_url = format!(
|
let financials_url = format!(
|
||||||
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=incomeStatementHistory,balanceSheetHistory,cashflowStatementHistory",
|
"https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=incomeStatementHistory,balanceSheetHistory,cashflowStatementHistory&crumb={}",
|
||||||
symbol
|
yahoo_symbol, crumb
|
||||||
);
|
);
|
||||||
|
|
||||||
let summary_task = self.client.get(&summary_url).send();
|
let summary_task = self.client.get(&summary_url).send();
|
||||||
@ -46,8 +124,8 @@ impl YFinanceDataProvider {
|
|||||||
let (summary_res, financials_res) = tokio::try_join!(summary_task, financials_task)
|
let (summary_res, financials_res) = tokio::try_join!(summary_task, financials_task)
|
||||||
.map_err(|e| AppError::ServiceRequest(e))?;
|
.map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
|
|
||||||
let summary_res = summary_res.error_for_status()?;
|
let summary_res = summary_res.error_for_status().map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
let financials_res = financials_res.error_for_status()?;
|
let financials_res = financials_res.error_for_status().map_err(|e| AppError::ServiceRequest(e))?;
|
||||||
|
|
||||||
let summary_json: serde_json::Value = summary_res
|
let summary_json: serde_json::Value = summary_res
|
||||||
.json()
|
.json()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user