From a59b994a92700ae91dc433f61217c925c75c2eab Mon Sep 17 00:00:00 2001 From: "Lv, Qi" Date: Thu, 27 Nov 2025 02:45:56 +0800 Subject: [PATCH] WIP: Commit all pending changes across services, frontend, and docs - Sync updates for provider services (AlphaVantage, Finnhub, YFinance, Tushare) - Update Frontend components and pages for recent config changes - Update API Gateway and Registry - Include design docs and tasks status --- crates/workflow-context/Cargo.lock | 24 + docker-compose.test.yml | 2 +- docker-compose.yml | 2 + ...251123_refactor_dynamic_provider_config.md | 130 ++ ...1125_refactor_data_provider_abstraction.md | 89 ++ ...1125_refactor_data_provider_abstraction.md | 89 ++ .../pending/20251126_design_0_overview.md | 97 ++ .../tasks/pending/20251126_design_1_vgcs.md | 150 ++ .../tasks/pending/20251126_design_2_doc_os.md | 100 ++ frontend/package-lock.json | 538 ++++++- frontend/package.json | 9 +- frontend/scripts/docker-dev-entrypoint.sh | 49 + frontend/src/api/client.ts | 33 +- frontend/src/api/schema.gen.ts | 634 ++++++++ .../components/config/DynamicConfigForm.tsx | 184 +++ frontend/src/components/layout/Header.tsx | 3 - frontend/src/components/layout/RootLayout.tsx | 2 + .../src/components/report/FinancialTable.tsx | 17 +- frontend/src/components/ui/add-row-menu.tsx | 4 +- frontend/src/components/ui/select.tsx | 2 +- frontend/src/components/ui/switch.tsx | 4 +- frontend/src/components/ui/toaster.tsx | 23 + .../workflow/WorkflowVisualizer.tsx | 193 ++- frontend/src/hooks/use-toast.ts | 80 + frontend/src/hooks/useConfig.ts | 136 +- frontend/src/pages/Dashboard.tsx | 56 +- frontend/src/pages/ReportPage.tsx | 202 +-- frontend/src/pages/config/AIProviderTab.tsx | 317 +++- frontend/src/pages/config/DataSourceTab.tsx | 185 ++- frontend/src/pages/config/TemplateTab.tsx | 352 ++++- frontend/src/stores/useWorkflowStore.ts | 107 +- frontend/src/types/config.ts | 63 +- frontend/src/types/workflow.ts | 29 +- frontend/vite.config.ts | 38 +- openapi.json | 1312 +++++++++++++++++ package-lock.json | 7 + package.json | 1 + scripts/update_api_spec.sh | 24 + .../alphavantage-provider-service/Cargo.lock | 617 +------- .../alphavantage-provider-service/Cargo.toml | 4 +- .../alphavantage-provider-service/Dockerfile | 4 +- .../src/api_test.rs | 5 +- .../src/config.rs | 3 +- .../src/config_poller.rs | 3 +- .../alphavantage-provider-service/src/main.rs | 24 +- .../src/state.rs | 5 +- services/api-gateway/Cargo.lock | 623 +------- services/api-gateway/Cargo.toml | 2 +- services/api-gateway/Dockerfile | 6 +- services/api-gateway/src/api.rs | 193 +-- services/api-gateway/src/api/registry.rs | 19 +- .../api-gateway/src/api/registry_tests.rs | 60 + services/api-gateway/src/main.rs | 2 + services/api-gateway/src/openapi.rs | 36 +- services/api-gateway/src/persistence.rs | 1 + .../api-gateway/src/registry_unit_test.rs | 64 + services/api-gateway/src/state.rs | 113 +- services/common-contracts/Cargo.lock | 360 ++++- services/common-contracts/Cargo.toml | 13 +- services/common-contracts/src/abstraction.rs | 30 + .../common-contracts/src/config_models.rs | 75 +- services/common-contracts/src/lifecycle.rs | 8 +- .../common-contracts/src/observability.rs | 1 - .../src/persistence_client.rs | 8 +- services/common-contracts/src/registry.rs | 84 +- .../common-contracts/src/workflow_harness.rs | 176 +++ services/data-persistence-service/Cargo.lock | 353 ++++- services/data-persistence-service/Dockerfile | 6 +- services/finnhub-provider-service/Cargo.lock | 664 +-------- services/finnhub-provider-service/Cargo.toml | 6 +- services/finnhub-provider-service/Dockerfile | 4 +- .../finnhub-provider-service/src/config.rs | 3 +- .../src/config_poller.rs | 3 +- .../finnhub-provider-service/src/error.rs | 1 - services/finnhub-provider-service/src/main.rs | 24 +- .../finnhub-provider-service/src/state.rs | 7 +- services/report-generator-service/Cargo.lock | 600 +------- services/report-generator-service/Cargo.toml | 3 +- services/report-generator-service/Dockerfile | 4 +- services/report-generator-service/cookies.txt | 5 + services/report-generator-service/src/api.rs | 3 +- .../src/llm_client.rs | 7 +- services/report-generator-service/src/main.rs | 3 +- .../report-generator-service/src/worker.rs | 14 +- services/tushare-provider-service/Cargo.lock | 739 ++-------- services/tushare-provider-service/Dockerfile | 4 +- services/tushare-provider-service/src/api.rs | 3 +- .../tushare-provider-service/src/config.rs | 3 +- .../src/config_poller.rs | 3 +- .../tushare-provider-service/src/error.rs | 1 - .../tushare-provider-service/src/state.rs | 43 +- .../tushare-provider-service/src/worker.rs | 285 +--- .../workflow-orchestrator-service/Cargo.lock | 736 ++------- .../workflow-orchestrator-service/Dockerfile | 4 +- services/yfinance-provider-service/Cargo.lock | 663 +-------- services/yfinance-provider-service/Cargo.toml | 9 +- services/yfinance-provider-service/Dockerfile | 4 +- services/yfinance-provider-service/src/api.rs | 18 +- .../yfinance-provider-service/src/error.rs | 3 - .../yfinance-provider-service/src/main.rs | 13 +- .../yfinance-provider-service/src/state.rs | 38 +- .../yfinance-provider-service/src/worker.rs | 269 +--- .../yfinance-provider-service/src/yfinance.rs | 24 +- 103 files changed, 6740 insertions(+), 5658 deletions(-) create mode 100644 docs/3_project_management/tasks/completed/20251123_refactor_dynamic_provider_config.md create mode 100644 docs/3_project_management/tasks/completed/20251125_refactor_data_provider_abstraction.md create mode 100644 docs/3_project_management/tasks/pending/20251125_refactor_data_provider_abstraction.md create mode 100644 docs/3_project_management/tasks/pending/20251126_design_0_overview.md create mode 100644 docs/3_project_management/tasks/pending/20251126_design_1_vgcs.md create mode 100644 docs/3_project_management/tasks/pending/20251126_design_2_doc_os.md create mode 100755 frontend/scripts/docker-dev-entrypoint.sh create mode 100644 frontend/src/api/schema.gen.ts create mode 100644 frontend/src/components/config/DynamicConfigForm.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/hooks/use-toast.ts create mode 100644 openapi.json create mode 100755 scripts/update_api_spec.sh create mode 100644 services/api-gateway/src/api/registry_tests.rs create mode 100644 services/api-gateway/src/registry_unit_test.rs create mode 100644 services/common-contracts/src/abstraction.rs create mode 100644 services/common-contracts/src/workflow_harness.rs create mode 100644 services/report-generator-service/cookies.txt diff --git a/crates/workflow-context/Cargo.lock b/crates/workflow-context/Cargo.lock index 6c89031..b06e2b0 100644 --- a/crates/workflow-context/Cargo.lock +++ b/crates/workflow-context/Cargo.lock @@ -32,6 +32,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "cc" version = "1.2.47" @@ -158,6 +168,19 @@ dependencies = [ "url", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hex" version = "0.4.3" @@ -722,6 +745,7 @@ version = "0.1.0" dependencies = [ "anyhow", "git2", + "globset", "hex", "regex", "serde", diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1ee472a..2a89305 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -38,7 +38,7 @@ services: RUST_LOG: info RUST_BACKTRACE: "1" ports: - - "3001:3000" + - "3005:3000" depends_on: postgres-test: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index a0aa949..451ebb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,8 @@ services: working_dir: /workspace/frontend command: ["/workspace/frontend/scripts/docker-dev-entrypoint.sh"] environment: + # Vite Proxy Target + VITE_API_TARGET: http://api-gateway:4000 # 让 Next 的 API 路由代理到新的 api-gateway NEXT_PUBLIC_BACKEND_URL: http://api-gateway:4000/v1 # SSR 内部访问自身 API 的内部地址,避免使用 x-forwarded-host 导致访问宿主机端口 diff --git a/docs/3_project_management/tasks/completed/20251123_refactor_dynamic_provider_config.md b/docs/3_project_management/tasks/completed/20251123_refactor_dynamic_provider_config.md new file mode 100644 index 0000000..c15d400 --- /dev/null +++ b/docs/3_project_management/tasks/completed/20251123_refactor_dynamic_provider_config.md @@ -0,0 +1,130 @@ +# 重构任务:动态数据提供商配置架构 (Dynamic Data Provider Configuration) + +## 1. 背景与目标 (Background & Objective) + +目前系统的前端页面 (`DataSourceTab`) 硬编码了支持的数据源列表(Tushare, Finnhub 等)及其配置表单。这导致每增加一个新的数据源,都需要修改前端代码,违反了“单一来源”和“开闭原则”。 + +本次重构的目标是实现 **“前端无知 (Frontend Agnostic)”** 的架构: +1. **后端驱动**:各 Provider 服务在启动注册时,声明自己的元数据(名称、描述)和配置规范(需要哪些字段)。 +2. **动态渲染**:前端通过 Gateway 获取所有已注册服务的元数据,动态生成配置表单。 +3. **通用交互**:测试连接、保存配置等操作通过统一的接口进行,不再针对特定 Provider 编写逻辑。 + +## 2. 核心数据结构设计 (Core Data Structures) + +我们在 `common-contracts` 中扩展了服务注册的定义。 + +### 2.1 配置字段定义 (Config Field Definition) + +不使用复杂的通用 JSON Schema,而是定义一套符合我们 UI 需求的强类型 Schema。 + +```rust +/// 字段类型枚举 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub enum FieldType { + Text, // 普通文本 + Password, // 密码/Token (前端掩码显示) + Url, // URL 地址 + Boolean, // 开关 + Select, // 下拉选框 (需要 options) +} + +/// 单个配置字段的定义 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ConfigFieldSchema { + pub key: String, // 字段键名 (如 "api_key") + pub label: String, // 显示名称 (如 "API Token") + pub field_type: FieldType,// 字段类型 + pub required: bool, // 是否必填 + pub placeholder: Option, // 占位符 + pub default_value: Option, // 默认值 + pub description: Option, // 字段说明 +} +``` + +### 2.2 服务元数据扩展 (Service Metadata Extension) + +修改 `ServiceRegistration` 并增加了 `ProviderMetadata` 结构。 + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ProviderMetadata { + pub id: String, // 服务ID (如 "tushare") + pub name_en: String, // 英文名 (如 "Tushare Pro") + pub name_cn: String, // 中文名 (如 "Tushare Pro (中国股市)") + pub description: String, // 描述 + pub icon_url: Option, // 图标 (可选) + + /// 该服务需要的配置字段列表 + pub config_schema: Vec, + + /// 是否支持“测试连接”功能 + pub supports_test_connection: bool, +} +``` + +## 3. 架构交互流程 (Architecture Flow) + +### 3.1 服务注册 (Registration Phase) +1. **Provider 启动** (如 `tushare-provider-service`): + * 构建 `ProviderMetadata`,声明自己需要 `api_key` (必填, Password) 和 `api_url` (选填, Url, 默认值...)。 + * 调用 API Gateway 的 `/register` 接口,将 Metadata 发送给 Gateway。 + +2. **API Gateway**: + * 在内存 Registry 中存储这些 Metadata。 + +### 3.2 前端渲染 (Rendering Phase) +1. **前端请求**: `GET /v1/registry/providers` (网关聚合接口)。 +2. **网关响应**: 返回所有活跃 Provider 的 `ProviderMetadata` 列表。 +3. **前端请求**: `GET /v1/configs/data_sources` (获取用户已保存的配置值)。 +4. **UI 生成**: + * 前端遍历 Metadata 列表。 + * 对每个 Provider,根据 `config_schema` 动态生成 Input/Switch 组件。 + * 将已保存的配置值填充到表单中。 + +### 3.3 动作执行 (Action Phase) +1. **保存 (Save)**: + * 前端收集表单数据,发送标准 JSON 到 `POST /v1/configs/data_sources`。 + * 后端持久化服务保存数据。 + +2. **测试连接 (Test Connection)**: + * 前端发送 `POST /v1/configs/test` (注意:Gateway已有通用接口)。 + * Payload: `{ type: "tushare", data: { "api_key": "..." } }`。 + * 网关将请求路由到对应的 Provider 微服务进行自我检查。 + +## 4. 任务拆解 (Task Breakdown) + +### 4.1 后端开发 (Backend Development) - [已完成/Completed] +1. **Common Contracts**: 更新 `registry.rs`,添加 `ProviderMetadata` 和 `ConfigFieldSchema` 定义。 (Done) +2. **API Gateway**: + * 更新注册逻辑,接收并存储 Metadata。 (Done) + * 新增接口 `GET /v1/registry/providers` 供前端获取元数据。 (Done) + * **移除旧版接口**: 已删除 Legacy 的静态 schema 接口,强制转向动态机制。 (Done) + * **单元测试**: 实现了 `ServiceRegistry` 的单元测试 (`registry_unit_test.rs`),覆盖注册、心跳、发现、注销等核心逻辑。 (Done) +3. **Provider Services** (Tushare, Finnhub, etc.): + * 实现 `ConfigSchema` 的构建逻辑。 (Done) + * 更新注册调用,发送 Schema。 (Done for Tushare, Finnhub, AlphaVantage, YFinance) + +### 4.2 后端验证测试 (Backend Verification) - [已完成/Completed] +在开始前端重构前,已进行一次集成测试,确保后端服务启动后能正确交互。 + +1. **启动服务**: 使用 `tests/api-e2e/run_registry_test.sh` 启动最小化服务集。 +2. **API 验证**: + * 调用 `GET /v1/registry/providers`,验证返回的 JSON 是否包含所有 Provider 的 Metadata 和 Schema。 + * 使用 `tests/api-e2e/registry_verifier.py` 脚本验证了 Tushare 的 Schema 字段正确性。 +3. **结果确认**: + * ✅ Tushare 服务成功注册。 + * ✅ Schema 正确包含 `api_token` (Password, Required)。 + * ✅ E2E 测试集 (`tests/end-to-end`) 也已更新并验证通过。 + +### 4.3 前端重构 (Frontend Refactor) - [待办/Pending] +1. **API Client**: 更新 Client SDK 以支持新的元数据接口。 + * **自动化更新脚本**: `scripts/update_api_spec.sh` + * **执行逻辑**: + 1. 调用 `cargo test ... generate_openapi_json` 生成 `openapi.json`。 + 2. 调用 `npm run gen:api` 重新生成前端 TypeScript 类型定义。 + * **输出位置**: `frontend/src/api/schema.gen.ts` +2. **State Management**: 修改 `useConfig` hook,增加获取 Provider Metadata 的逻辑。 +3. **UI Refactor**: + * 废弃 `supportedProviders` 硬编码。 + * 创建 `DynamicConfigForm` 组件,根据 `ConfigFieldSchema` 渲染界面。 + * 对接新的测试连接和保存逻辑。 diff --git a/docs/3_project_management/tasks/completed/20251125_refactor_data_provider_abstraction.md b/docs/3_project_management/tasks/completed/20251125_refactor_data_provider_abstraction.md new file mode 100644 index 0000000..da9f4c3 --- /dev/null +++ b/docs/3_project_management/tasks/completed/20251125_refactor_data_provider_abstraction.md @@ -0,0 +1,89 @@ +# 重构任务:统一 Data Provider 工作流抽象 + +## 1. 背景 (Background) + +目前的 Data Provider 服务(Tushare, YFinance, AlphaVantage 等)在架构上存在严重的重复代码和实现不一致问题。每个服务都独立实现了完整的工作流,包括: +- NATS 消息接收与反序列化 +- 缓存检查与写入 (Cache-Aside Pattern) +- 任务状态管理 (Observability/Task Progress) +- Session Data 持久化 +- NATS 事件发布 (Success/Failure events) + +这种"散弹式"架构导致了以下问题: +1. **Bug 易发且难以统一修复**:例如 Tushare 服务因未执行 NATS Flush 导致事件丢失,而 YFinance 却因为实现方式不同而没有此问题。修复一个 Bug 需要在每个服务中重复操作。 +2. **逻辑不一致**:不同 Provider 对缓存策略、错误处理、重试机制的实现可能存在细微差异,违背了系统的统一性。 +3. **维护成本高**:新增一个 Provider 需要复制粘贴大量基础设施代码(Boilerplate),容易出错。 + +## 2. 目标 (Objectives) + +贯彻 "Rustic" 的设计理念(强类型、单一来源、早失败),通过控制反转(IoC)和模板方法模式,将**业务逻辑**与**基础设施逻辑**彻底分离。 + +- **单一来源 (Single Source of Truth)**:工作流的核心逻辑(缓存、持久化、通知)只在一个地方定义和维护。 +- **降低耦合**:具体 Provider 只需关注 "如何从 API 获取数据",而无需关心 "如何与系统交互"。 +- **提升稳定性**:统一修复基础设施层面的问题(如 NATS Flush),所有 Provider 自动受益。 + +## 3. 技术方案 (Technical Design) + +### 3.1 核心抽象 (The Trait) + +在 `common-contracts` 中定义纯粹的业务逻辑接口: + +```rust +#[async_trait] +pub trait DataProviderLogic: Send + Sync { + /// Provider 的唯一标识符 (e.g., "tushare", "yfinance") + fn provider_id(&self) -> &str; + + /// 检查是否支持该市场 (前置检查) + fn supports_market(&self, market: &str) -> bool { + true + } + + /// 核心业务:从外部源获取原始数据并转换为标准 DTO + /// 不涉及任何 DB 或 NATS 操作 + async fn fetch_data(&self, symbol: &str) -> Result<(CompanyProfileDto, Vec), anyhow::Error>; +} +``` + +### 3.2 通用工作流引擎 (The Engine) + +实现一个泛型结构体或函数 `StandardFetchWorkflow`,封装所有基础设施逻辑: + +1. **接收指令**:解析 `FetchCompanyDataCommand`。 +2. **前置检查**:调用 `supports_market`。 +3. **状态更新**:向 `AppState` 写入 "InProgress"。 +4. **缓存层**: + * 检查 `persistence_client` 缓存。 + * HIT -> 直接返回。 + * MISS -> 调用 `fetch_data`,然后写入缓存。 +5. **持久化层**:将结果写入 `SessionData`。 +6. **事件通知**: + * 构建 `FinancialsPersistedEvent`。 + * 发布 NATS 消息。 + * **关键:执行 `flush().await`。** +7. **错误处理**:统一捕获错误,发布 `DataFetchFailedEvent`,更新 Task 状态为 Failed。 + +## 4. 执行步骤 (Execution Plan) + +1. **基础设施准备**: + * 在 `services/common-contracts` 中添加 `DataProviderLogic` trait。 + * 在 `services/common-contracts` (或新建 `service-kit` 模块) 中实现 `StandardFetchWorkflow`。 + +2. **重构 Tushare Service**: + * 创建 `TushareFetcher` 实现 `DataProviderLogic`。 + * 删除 `worker.rs` 中的冗余代码,替换为对 `StandardFetchWorkflow` 的调用。 + * 验证 NATS Flush 问题是否自然解决。 + +3. **重构 YFinance Service**: + * 同样方式重构,验证通用性。 + +4. **验证**: + * 运行 E2E 测试,确保数据获取流程依然通畅。 + +## 5. 验收标准 (Acceptance Criteria) + +- `common-contracts` 中包含清晰的 Trait 定义。 +- Tushare 和 YFinance 的 `worker.rs` 代码量显著减少(预计减少 60%+)。 +- 所有 Provider 的行为(日志格式、状态更新频率、缓存行为)完全一致。 +- 即使不手动写 `flush`,重构后的 Provider 也能可靠发送 NATS 消息。 + diff --git a/docs/3_project_management/tasks/pending/20251125_refactor_data_provider_abstraction.md b/docs/3_project_management/tasks/pending/20251125_refactor_data_provider_abstraction.md new file mode 100644 index 0000000..da9f4c3 --- /dev/null +++ b/docs/3_project_management/tasks/pending/20251125_refactor_data_provider_abstraction.md @@ -0,0 +1,89 @@ +# 重构任务:统一 Data Provider 工作流抽象 + +## 1. 背景 (Background) + +目前的 Data Provider 服务(Tushare, YFinance, AlphaVantage 等)在架构上存在严重的重复代码和实现不一致问题。每个服务都独立实现了完整的工作流,包括: +- NATS 消息接收与反序列化 +- 缓存检查与写入 (Cache-Aside Pattern) +- 任务状态管理 (Observability/Task Progress) +- Session Data 持久化 +- NATS 事件发布 (Success/Failure events) + +这种"散弹式"架构导致了以下问题: +1. **Bug 易发且难以统一修复**:例如 Tushare 服务因未执行 NATS Flush 导致事件丢失,而 YFinance 却因为实现方式不同而没有此问题。修复一个 Bug 需要在每个服务中重复操作。 +2. **逻辑不一致**:不同 Provider 对缓存策略、错误处理、重试机制的实现可能存在细微差异,违背了系统的统一性。 +3. **维护成本高**:新增一个 Provider 需要复制粘贴大量基础设施代码(Boilerplate),容易出错。 + +## 2. 目标 (Objectives) + +贯彻 "Rustic" 的设计理念(强类型、单一来源、早失败),通过控制反转(IoC)和模板方法模式,将**业务逻辑**与**基础设施逻辑**彻底分离。 + +- **单一来源 (Single Source of Truth)**:工作流的核心逻辑(缓存、持久化、通知)只在一个地方定义和维护。 +- **降低耦合**:具体 Provider 只需关注 "如何从 API 获取数据",而无需关心 "如何与系统交互"。 +- **提升稳定性**:统一修复基础设施层面的问题(如 NATS Flush),所有 Provider 自动受益。 + +## 3. 技术方案 (Technical Design) + +### 3.1 核心抽象 (The Trait) + +在 `common-contracts` 中定义纯粹的业务逻辑接口: + +```rust +#[async_trait] +pub trait DataProviderLogic: Send + Sync { + /// Provider 的唯一标识符 (e.g., "tushare", "yfinance") + fn provider_id(&self) -> &str; + + /// 检查是否支持该市场 (前置检查) + fn supports_market(&self, market: &str) -> bool { + true + } + + /// 核心业务:从外部源获取原始数据并转换为标准 DTO + /// 不涉及任何 DB 或 NATS 操作 + async fn fetch_data(&self, symbol: &str) -> Result<(CompanyProfileDto, Vec), anyhow::Error>; +} +``` + +### 3.2 通用工作流引擎 (The Engine) + +实现一个泛型结构体或函数 `StandardFetchWorkflow`,封装所有基础设施逻辑: + +1. **接收指令**:解析 `FetchCompanyDataCommand`。 +2. **前置检查**:调用 `supports_market`。 +3. **状态更新**:向 `AppState` 写入 "InProgress"。 +4. **缓存层**: + * 检查 `persistence_client` 缓存。 + * HIT -> 直接返回。 + * MISS -> 调用 `fetch_data`,然后写入缓存。 +5. **持久化层**:将结果写入 `SessionData`。 +6. **事件通知**: + * 构建 `FinancialsPersistedEvent`。 + * 发布 NATS 消息。 + * **关键:执行 `flush().await`。** +7. **错误处理**:统一捕获错误,发布 `DataFetchFailedEvent`,更新 Task 状态为 Failed。 + +## 4. 执行步骤 (Execution Plan) + +1. **基础设施准备**: + * 在 `services/common-contracts` 中添加 `DataProviderLogic` trait。 + * 在 `services/common-contracts` (或新建 `service-kit` 模块) 中实现 `StandardFetchWorkflow`。 + +2. **重构 Tushare Service**: + * 创建 `TushareFetcher` 实现 `DataProviderLogic`。 + * 删除 `worker.rs` 中的冗余代码,替换为对 `StandardFetchWorkflow` 的调用。 + * 验证 NATS Flush 问题是否自然解决。 + +3. **重构 YFinance Service**: + * 同样方式重构,验证通用性。 + +4. **验证**: + * 运行 E2E 测试,确保数据获取流程依然通畅。 + +## 5. 验收标准 (Acceptance Criteria) + +- `common-contracts` 中包含清晰的 Trait 定义。 +- Tushare 和 YFinance 的 `worker.rs` 代码量显著减少(预计减少 60%+)。 +- 所有 Provider 的行为(日志格式、状态更新频率、缓存行为)完全一致。 +- 即使不手动写 `flush`,重构后的 Provider 也能可靠发送 NATS 消息。 + diff --git a/docs/3_project_management/tasks/pending/20251126_design_0_overview.md b/docs/3_project_management/tasks/pending/20251126_design_0_overview.md new file mode 100644 index 0000000..ea41ba2 --- /dev/null +++ b/docs/3_project_management/tasks/pending/20251126_design_0_overview.md @@ -0,0 +1,97 @@ +# 设计方案 0: 系统总览与开发指南 (Overview & Setup) + +## 1. 项目背景 (Context) + +本项目是一个 **金融基本面分析系统 (Fundamental Analysis System)**。 +目标是通过自动化的工作流,从多个数据源(Tushare, YFinance)抓取数据,经过处理,最终生成一份包含估值、风险分析的综合报告。 + +本次重构旨在解决原系统调度逻辑僵化、上下文管理混乱、大文件支持差的问题。我们将构建一个基于 **Git 内核** 的分布式上下文管理系统。 + +## 2. 系统架构 (Architecture) + +系统由四个核心模块组成: + +1. **VGCS (Virtual Git Context System)**: 底层存储。基于 Git + Blob Store 的版本化文件系统。 +2. **DocOS (Document Object System)**: 逻辑层。提供“文档树”的裂变、聚合操作,屏蔽底层文件细节。 +3. **Worker Runtime**: 适配层。提供 SDK 给业务 Worker(Python/Rust),使其能读写上下文。 +4. **Orchestrator**: 调度层。负责任务分发、依赖管理和并行结果合并。 + +```mermaid +graph TD + subgraph "Storage Layer (Shared Volume)" + Repo[Git Bare Repo] + Blob[Blob Store] + end + + subgraph "Library Layer (Rust Crate)" + VGCS[VGCS Lib] --> Repo & Blob + DocOS[DocOS Lib] --> VGCS + end + + subgraph "Execution Layer" + Orch[Orchestrator Service] --> DocOS + Worker[Python/Rust Worker] --> Runtime[Worker Runtime SDK] + Runtime --> DocOS + end + + Orch -- NATS (RPC) --> Worker +``` + +## 3. 技术栈规范 (Tech Stack) + +### 3.1 Rust (VGCS, DocOS, Orchestrator) +* **Version**: Stable (1.75+) +* **Key Crates**: + * `git2` (0.18): libgit2 bindings. **注意**: 默认开启 `vendored-openssl` feature 以确保静态链接。 + * `serde`, `serde_json`: 序列化。 + * `thiserror`, `anyhow`: 错误处理。 + * `async-nats` (0.33): 消息队列。 + * `tokio` (1.0+): 异步运行时。 + * `sha2`, `hex`: 哈希计算。 + +### 3.2 Python (Worker Runtime) +* **Version**: 3.10+ +* **Package Manager**: Poetry +* **Key Libs**: + * `nats-py`: NATS Client. + * `pydantic`: 数据验证。 + * (可选) `libgit2-python`: 如果需要高性能 Git 操作,否则通过 FFI 调用 Rust Lib 或 Shell Out。 + +## 4. 开发环境搭建 (Dev Setup) + +### 4.1 目录准备 +在本地开发机上,我们需要模拟共享存储。 +```bash +mkdir -p /tmp/workflow_dev/repos +mkdir -p /tmp/workflow_dev/blobs +export WORKFLOW_DATA_PATH=/tmp/workflow_dev +``` + +### 4.2 Docker 环境 +为了确保一致性,所有服务应在 Docker Compose 中运行,挂载同一个 Volume。 +```yaml +volumes: + workflow_data: + +services: + orchestrator: + image: rust-builder + volumes: + - workflow_data:/mnt/workflow_data + environment: + - WORKFLOW_DATA_PATH=/mnt/workflow_data +``` + +## 5. 模块集成方式 +* **VGCS & DocOS**: 将实现为一个独立的 Rust Workspace Member (`crates/workflow-context`)。 +* **Orchestrator**: 引用该 Crate。 +* **Worker (Rust)**: 引用该 Crate。 +* **Worker (Python)**: 通过 `PyO3` 绑定该 Crate,或者(MVP阶段)重写部分逻辑/Shell调用。**建议 MVP 阶段 Python SDK 仅封装 git binary 调用以简化构建**。 + +## 6. 新人上手路径 +1. 阅读 `design_1_vgcs.md`,理解如何在不 clone 的情况下读写 git object。 +2. 阅读 `design_2_doc_os.md`,理解“文件变目录”的逻辑。 +3. 实现 Rust Crate `workflow-context` (包含 VGCS + DocOS)。 +4. 编写单元测试,验证 File -> Blob 的分流逻辑。 +5. 集成到 Orchestrator。 + diff --git a/docs/3_project_management/tasks/pending/20251126_design_1_vgcs.md b/docs/3_project_management/tasks/pending/20251126_design_1_vgcs.md new file mode 100644 index 0000000..34d89c2 --- /dev/null +++ b/docs/3_project_management/tasks/pending/20251126_design_1_vgcs.md @@ -0,0 +1,150 @@ +# 设计方案 1: VGCS (Virtual Git Context System) [详细设计版] + +## 1. 定位与目标 + +VGCS 是底层存储引擎,提供版本化、高性能、支持大文件的分布式文件系统接口。 +它基于 **git2-rs (libgit2)** 实现,直接操作 Git Object DB。 + +## 2. 物理存储规范 (Specification) + +所有服务共享挂载卷 `/mnt/workflow_data`。 + +### 2.1 目录结构 +```text +/mnt/workflow_data/ + ├── repos/ + │ └── {request_id}.git/ <-- Bare Git Repo + │ ├── HEAD + │ ├── config + │ ├── objects/ <-- Standard Git Objects + │ └── refs/ + └── blobs/ + └── {request_id}/ <-- Blob Store Root + ├── ab/ + │ └── ab1234... <-- Raw File (SHA-256 of content) + └── cd/ + └── cd5678... +``` + +### 2.2 Blob 引用文件格式 (.ref) +当文件 > 1MB 时,Git Blob 存储如下 JSON 内容: +```json +{ + "$vgcs_ref": "v1", + "sha256": "ab1234567890...", + "size": 10485760, + "mime_type": "application/json", + "original_name": "data.json" +} +``` + +## 3. 核心接口定义 (Rust Trait) + +### 3.1 ContextStore Trait + +```rust +use anyhow::Result; +use std::io::Read; + +pub trait ContextStore { + /// 初始化仓库 + fn init_repo(&self, req_id: &str) -> Result<()>; + + /// 读取文件内容 + /// 自动逻辑:读取 Git Blob -> 解析 JSON -> 如果是 Ref,读 Blob Store;否则直接返回 + fn read_file(&self, req_id: &str, commit_hash: &str, path: &str) -> Result>; + + /// 读取目录 + fn list_dir(&self, req_id: &str, commit_hash: &str, path: &str) -> Result>; + + /// 获取变更 + fn diff(&self, req_id: &str, from_commit: &str, to_commit: &str) -> Result>; + + /// 三路合并 (In-Memory) + /// 返回新生成的 Tree OID,不生成 Commit + fn merge_trees(&self, req_id: &str, base: &str, ours: &str, theirs: &str) -> Result; + + /// 创建写事务 + fn begin_transaction(&self, req_id: &str, base_commit: &str) -> Result>; +} + +#[derive(Debug)] +pub struct DirEntry { + pub name: String, + pub kind: EntryKind, // File | Dir + pub object_id: String, +} + +#[derive(Debug)] +pub enum FileChange { + Added(String), + Modified(String), + Deleted(String), +} +``` + +### 3.2 Transaction Trait (写操作) + +```rust +pub trait Transaction { + /// 写入文件 + /// 内部逻辑: + /// 1. 计算 content SHA-256 + /// 2. if size > 1MB: 写 Blob Store,构造 Ref JSON + /// 3. 写 Git Blob (Raw 或 Ref) + /// 4. 更新内存中的 Index/TreeBuilder + fn write(&mut self, path: &str, content: &[u8]) -> Result<()>; + + /// 删除文件 + fn remove(&mut self, path: &str) -> Result<()>; + + /// 提交更改 + /// 1. Write Tree Object + /// 2. Create Commit Object (Parent = base_commit) + /// 3. Return new Commit Hash + fn commit(self, message: &str, author: &str) -> Result; +} +``` + +## 4. 实现细节规范 + +### 4.1 读操作流程 (read_file) +1. Open Repo: `/mnt/workflow_data/repos/{req_id}.git` +2. Locate Tree: Parse `commit_hash` -> `tree_id`. +3. Find Entry: 在 Tree 中查找 `path`. +4. Read Blob: 读取 Git Blob 内容。 +5. **Check Ref**: 尝试解析为 `BlobRef` 结构。 + * **Success**: 构建 Blob Store 路径 `/mnt/workflow_data/blobs/{req_id}/{sha256[0..2]}/{sha256}`,打开文件流。 + * **Fail** (说明是普通小文件): 返回 `Cursor::new(blob_content)`. + +### 4.2 写操作流程 (write) +1. Check Size: `content.len()`. +2. **Large File Path**: + * Calc SHA-256. + * Write to Blob Store (Ensure parent dir exists). + * Content = JSON String of `BlobRef`. +3. **Git Write**: + * `odb.write(Blob, content)`. + * Update in-memory `git2::TreeBuilder`. + +### 4.3 合并流程 (merge_trees) +1. Load Trees: `base_tree`, `our_tree`, `their_tree`. +2. `repo.merge_trees(base, our, their, opts)`. +3. Check Conflicts: `index.has_conflicts()`. + * If conflict: Return Error (Complex resolution left to Orchestrator Agent). +4. Write Result: `index.write_tree_to(repo)`. +5. Return Tree Hash. + +## 5. Web UI & Fuse +* **Web**: 基于 Axum,路由 `GET /api/v1/repo/:req_id/tree/:commit/*path`。复用 `ContextStore` 接口。 +* **Fuse**: 基于 `fuser`。 + * Mount Point: `/mnt/vgcs_view/{req_id}/{commit}`. + * `read()` -> `store.read_file()`. + * `readdir()` -> `store.list_dir()`. + * 只读挂载,用于 Debug。 + +## 6. 依赖库 +* `git2` (0.18): 核心操作。 +* `sha2` (0.10): 哈希计算。 +* `serde_json`: Ref 序列化。 +* `anyhow`: 错误处理。 diff --git a/docs/3_project_management/tasks/pending/20251126_design_2_doc_os.md b/docs/3_project_management/tasks/pending/20251126_design_2_doc_os.md new file mode 100644 index 0000000..0ad01f7 --- /dev/null +++ b/docs/3_project_management/tasks/pending/20251126_design_2_doc_os.md @@ -0,0 +1,100 @@ +# 设计方案 2: DocOS (Document Object System) [详细设计版] + +## 1. 定位与目标 + +DocOS 是构建在 VGCS 之上的逻辑层,负责处理文档结构的演进(裂变、聚合)。 +它不操作 Git Hash,而是操作逻辑路径。它通过 `ContextStore` 接口与底层交互。 + +## 2. 数据结构定义 + +### 2.1 逻辑节点 (DocNode) + +这屏蔽了底层是 `File` 还是 `Dir` 的差异。 + +```rust +#[derive(Debug, Serialize, Deserialize)] +pub enum DocNodeKind { + Leaf, // 纯内容节点 (对应文件) + Composite, // 复合节点 (对应目录,含 index.md) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DocNode { + pub name: String, + pub path: String, // 逻辑路径 e.g., "Analysis/Revenue" + pub kind: DocNodeKind, + pub children: Vec, // 仅 Composite 有值 +} +``` + +## 3. 核心接口定义 (DocManager Trait) + +```rust +pub trait DocManager { + /// 基于最新的 Commit 重新加载状态 + fn reload(&mut self, commit_hash: &str) -> Result<()>; + + /// 获取当前文档树大纲 + fn get_outline(&self) -> Result; + + /// 读取节点内容 + /// 逻辑: + /// - Leaf: 读 `path` + /// - Composite: 读 `path/index.md` + fn read_content(&self, path: &str) -> Result; + + // --- 写入操作 (Buffer Action) --- + + /// 写入内容 (Upsert) + /// 逻辑: + /// - 路径存在且是 Leaf -> 覆盖 + /// - 路径存在且是 Composite -> 覆盖 index.md + /// - 路径不存在 -> 创建 Leaf + fn write_content(&mut self, path: &str, content: &str); + + /// 插入子章节 (Implies Promotion) + /// 逻辑: + /// - 如果 parent 是 Leaf -> 执行 Promote (Rename Leaf->Dir/index.md) -> 创建 Child + /// - 如果 parent 是 Composite -> 直接创建 Child + fn insert_subsection(&mut self, parent_path: &str, name: &str, content: &str); + + /// 提交变更 + fn save(&mut self, message: &str) -> Result; +} +``` +## 4. 关键演进逻辑 (Implementation Specs) + +### 4.1 裂变 (Promote Leaf to Composite) +假设 `path = "A"` 是 Leaf (对应文件 `A`). +Action `insert_subsection("A", "B")`: +1. Read content of `A`. +2. Delete file `A` (in transaction). +3. Write content to `A/index.md`. +4. Write new content to `A/B`. +*注意*: Git 会将其视为 Delete + Add,或 Rename。VGCS 底层会处理。 + +### 4.2 聚合 (Demote Composite to Leaf) - *Optional* +假设 `path = "A"` 是 Composite (目录 `A/`). +Action `demote("A")`: +1. Read `A/index.md`. +2. Concatenate children content (Optional policy). +3. Delete dir `A/`. +4. Write content to file `A`. + +### 4.3 路径规范 +* **Root**: `/` (对应 Repo Root). +* **Meta**: `_meta.json` (用于存储手动排序信息,如果需要). +* **Content File**: + * Leaf: `Name` (No extension assumption, or `.md` default). + * Composite: `Name/index.md`. + +## 5. 实现依赖 +DocOS 需要持有一个 `Box` 实例。 +所有的 `write_*` 操作都只是在调用 Transaction 的 `write`。 +只有调用 `save()` 时,Transaction 才会 `commit`。 + +## 6. 错误处理 +* `PathNotFound`: 读不存在的路径。 +* `PathCollision`: 尝试创建已存在的文件。 +* `InvalidOperation`: 尝试在 Leaf 节点下创建子节点(需显式调用 Promote 或 insert_subsection)。 + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5540749..6d7d502 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,10 +17,12 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.10", + "@zodios/core": "^10.9.6", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "elkjs": "^0.11.0", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -31,7 +33,8 @@ "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "tailwindcss-typography": "^3.1.0", - "zod": "^4.1.12", + "web-worker": "^1.5.0", + "zod": "^3.24.1", "zustand": "^5.0.8" }, "devDependencies": { @@ -47,6 +50,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "openapi-zod-client": "^1.18.3", "postcss": "^8.5.6", "tailwindcss": "^4.1.17", "typescript": "~5.9.3", @@ -67,6 +71,99 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", + "integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz", + "integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.7.2", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1088,6 +1185,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@liuli-util/fs-extra": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@liuli-util/fs-extra/-/fs-extra-0.1.0.tgz", + "integrity": "sha512-eaAyDyMGT23QuRGbITVY3SOJff3G9ekAAyGqB9joAnTBmqvFN+9a1FazOdO70G6IUqgpKV451eBHYSRcOJ/FNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.13", + "fs-extra": "^10.1.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2796,6 +2911,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", @@ -3205,6 +3380,16 @@ "@types/estree": "*" } }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3575,6 +3760,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@zodios/core": { + "version": "10.9.6", + "resolved": "https://registry.npmjs.org/@zodios/core/-/core-10.9.6.tgz", + "integrity": "sha512-aH4rOdb3AcezN7ws8vDgBfGboZMk2JGGzEq/DtW65MhnRxyTGRuLJRWVQ/2KxDgWvV2F5oTkAS+5pnjKbl0n+A==", + "license": "MIT", + "peerDependencies": { + "axios": "^0.x || ^1.0.0", + "zod": "^3.x" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3790,6 +3985,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3803,6 +4008,13 @@ "node": ">= 0.4" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4253,6 +4465,12 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.0", + "resolved": "http://npm.repo.lan/elkjs/-/elkjs-0.11.0.tgz", + "integrity": "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw==", + "license": "EPL-2.0" + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -4571,6 +4789,13 @@ "node": ">=0.10.0" } }, + "node_modules/eval-estree-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eval-estree-expression/-/eval-estree-expression-3.0.1.tgz", + "integrity": "sha512-zTLKGbiVdQYp4rQkSoXPibrFf5ZoPn6jzExegRLEQ13F+FSxu5iLgaRH6hlDs2kWSUa6vp8yD20cdJi0me6pEw==", + "dev": true, + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4628,6 +4853,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4752,6 +4994,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4884,6 +5141,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5216,6 +5495,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6491,6 +6783,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6523,6 +6825,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6540,6 +6849,49 @@ "node": ">=0.10.0" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openapi-zod-client": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/openapi-zod-client/-/openapi-zod-client-1.18.3.tgz", + "integrity": "sha512-10vYK7xo1yyZfcoRvYNGIsDeej1CG9k63u8dkjbGBlr+NHZMy2Iy2h9s11UWNKdj6XMDWbNOPp5gIy8YdpgPtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", + "@liuli-util/fs-extra": "^0.1.0", + "@zodios/core": "^10.3.1", + "axios": "^1.6.0", + "cac": "^6.7.14", + "handlebars": "^4.7.7", + "openapi-types": "^12.0.2", + "openapi3-ts": "3.1.0", + "pastable": "^2.2.1", + "prettier": "^2.7.1", + "tanu": "^0.1.13", + "ts-pattern": "^5.0.1", + "whence": "^2.0.0", + "zod": "^3.19.1" + }, + "bin": { + "openapi-zod-client": "bin.js" + } + }, + "node_modules/openapi3-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-3.1.0.tgz", + "integrity": "sha512-1qKTvCCVoV0rkwUh1zq5o8QyghmwYPuhdvtjv1rFjuOnJToXhQyF8eGjNETQ8QmGjr9Jz/tkAKLITIl2s7dw3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.1.3" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6628,6 +6980,32 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/pastable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/pastable/-/pastable-2.2.1.tgz", + "integrity": "sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==", + "dev": true, + "dependencies": { + "@babel/core": "^7.20.12", + "ts-toolbelt": "^9.6.0", + "type-fest": "^3.5.3" + }, + "engines": { + "node": ">=14.x" + }, + "peerDependencies": { + "react": ">=17", + "xstate": ">=4.32.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "xstate": { + "optional": true + } + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6728,6 +7106,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -7024,6 +7418,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7156,6 +7560,16 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -7269,6 +7683,31 @@ "lodash": "^4.17.15" } }, + "node_modules/tanu": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tanu/-/tanu-0.1.13.tgz", + "integrity": "sha512-UbRmX7ccZ4wMVOY/Uw+7ji4VOkEYSYJG1+I4qzbnn4qh/jtvVbrm6BFnF12NQQ4+jGv21wKmjb1iFyUSVnBWcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0", + "typescript": "^4.7.4" + } + }, + "node_modules/tanu/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -7377,6 +7816,20 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -7396,6 +7849,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7434,6 +7900,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -7528,6 +8008,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -7762,6 +8252,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "http://npm.repo.lan/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, + "node_modules/whence": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/whence/-/whence-2.1.0.tgz", + "integrity": "sha512-4UBPMg5mng5KLzdliVQdQ4fJwCdIMXkE8CkoDmGKRy5r8pV9xq+nVgf/sKXpmNEIOtFp7m7v2bFdb7JoLvh+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "eval-estree-expression": "^3.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7788,6 +8298,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7795,6 +8312,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7809,9 +8339,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "3.25.76", + "resolved": "http://npm.repo.lan/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/frontend/package.json b/frontend/package.json index 31de683..4dc5ca5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "gen:api": "openapi-zod-client ../openapi.json -o src/api/schema.gen.ts --export-schemas --export-types && sed -i 's/^type /export type /' src/api/schema.gen.ts" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", @@ -19,10 +20,12 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.10", + "@zodios/core": "^10.9.6", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "elkjs": "^0.11.0", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -33,7 +36,8 @@ "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "tailwindcss-typography": "^3.1.0", - "zod": "^4.1.12", + "web-worker": "^1.5.0", + "zod": "^3.24.1", "zustand": "^5.0.8" }, "devDependencies": { @@ -49,6 +53,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "openapi-zod-client": "^1.18.3", "postcss": "^8.5.6", "tailwindcss": "^4.1.17", "typescript": "~5.9.3", diff --git a/frontend/scripts/docker-dev-entrypoint.sh b/frontend/scripts/docker-dev-entrypoint.sh new file mode 100755 index 0000000..85fbbbb --- /dev/null +++ b/frontend/scripts/docker-dev-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail + +PROJECT_DIR="${PROJECT_DIR:-/workspace/frontend}" +LOCKFILE="${PROJECT_DIR}/package-lock.json" +NODE_MODULES_DIR="${PROJECT_DIR}/node_modules" +HASH_FILE="${NODE_MODULES_DIR}/.package-lock.hash" +DEV_COMMAND="${DEV_COMMAND:-npm run dev -- --host 0.0.0.0 --port 3001}" + +cd "${PROJECT_DIR}" + +calculate_lock_hash() { + sha256sum "${LOCKFILE}" | awk '{print $1}' +} + +write_hash() { + calculate_lock_hash > "${HASH_FILE}" +} + +install_dependencies() { + echo "[frontend] 安装/更新依赖..." + npm ci + write_hash +} + +if [ ! -d "${NODE_MODULES_DIR}" ]; then + install_dependencies +elif [ ! -f "${HASH_FILE}" ]; then + install_dependencies +else + current_hash="$(calculate_lock_hash)" + installed_hash="$(cat "${HASH_FILE}" 2>/dev/null || true)" + + if [ "${current_hash}" != "${installed_hash}" ]; then + echo "[frontend] package-lock.json 发生变化,重新安装依赖..." + install_dependencies + else + echo "[frontend] 依赖哈希一致,跳过 npm ci。" + fi +fi + +# Check if node_modules/.bin exists in PATH, if not add it +if [[ ":$PATH:" != *":${NODE_MODULES_DIR}/.bin:"* ]]; then + export PATH="${NODE_MODULES_DIR}/.bin:$PATH" +fi + +echo "Starting development server..." +exec ${DEV_COMMAND} + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c1a6395..7bee58e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,18 +1,21 @@ -import axios from 'axios'; +import { api } from './schema.gen'; -export const apiClient = axios.create({ - baseURL: '/api/v1', - headers: { - 'Content-Type': 'application/json', - }, -}); +// Initialize the Zodios client with the base URL +export const apiClient = api; -// Add response interceptor for error handling if needed -apiClient.interceptors.response.use( - (response) => response, - (error) => { - // Handle global errors like 401, 403 here - return Promise.reject(error); - } -); +// Configure base URL (Zodios doesn't strictly require it if paths are relative, but good practice) +// Note: api variable from schema.gen.ts is already a Zodios instance with endpoints. +// But we might need to set the baseURL. +// Actually, Zodios constructor takes (baseUrl, endpoints). +// schema.gen.ts exports `export const api = new Zodios(endpoints);` which implies no base URL. +// We should probably use the factory function if available, or mutate axios instance. +// Let's look at schema.gen.ts again. +// export function createApiClient(baseUrl: string, options?: ZodiosOptions) { +// return new Zodios(baseUrl, endpoints, options); +// } + +// So we should use createApiClient. +import { createApiClient } from './schema.gen'; + +export const client = createApiClient('/'); diff --git a/frontend/src/api/schema.gen.ts b/frontend/src/api/schema.gen.ts new file mode 100644 index 0000000..71ba35e --- /dev/null +++ b/frontend/src/api/schema.gen.ts @@ -0,0 +1,634 @@ +import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; +import { z } from "zod"; + +export type AnalysisTemplateSet = { + modules: {}; + name: string; +}; +export type AnalysisModuleConfig = { + dependencies: Array; + model_id: string; + name: string; + prompt_template: string; + provider_id: string; +}; +export type AnalysisTemplateSets = {}; +export type ConfigFieldSchema = { + default_value?: (string | null) | undefined; + description?: (string | null) | undefined; + field_type: FieldType; + key: ConfigKey; + label: string; + options?: (Array | null) | undefined; + placeholder?: (string | null) | undefined; + required: boolean; +}; +export type FieldType = "Text" | "Password" | "Url" | "Boolean" | "Select"; +export type ConfigKey = + | "ApiKey" + | "ApiToken" + | "ApiUrl" + | "BaseUrl" + | "SecretKey" + | "Username" + | "Password" + | "SandboxMode" + | "Region"; +export type DataSourceConfig = { + api_key?: (string | null) | undefined; + api_url?: (string | null) | undefined; + enabled: boolean; + provider: DataSourceProvider; +}; +export type DataSourceProvider = "Tushare" | "Finnhub" | "Alphavantage" | "Yfinance"; +export type DataSourcesConfig = {}; +export type HealthStatus = { + details: {}; + module_id: string; + status: ServiceStatus; + version: string; +}; +export type ServiceStatus = "Ok" | "Degraded" | "Unhealthy"; +export type LlmProvider = { + api_base_url: string; + api_key: string; + models: Array; + name: string; +}; +export type LlmModel = { + is_active: boolean; + model_id: string; + name?: (string | null) | undefined; +}; +export type LlmProvidersConfig = {}; +export type ProviderMetadata = { + config_schema: Array; + description: string; + icon_url?: (string | null) | undefined; + id: string; + name_cn: string; + name_en: string; + supports_test_connection: boolean; +}; +export type StartWorkflowCommand = { + market: string; + request_id: string; + symbol: CanonicalSymbol; + template_id: string; +}; +export type CanonicalSymbol = string; +export type TaskNode = { + id: string; + initial_status: TaskStatus; + name: string; + type: TaskType; +}; +export type TaskStatus = + | "Pending" + | "Scheduled" + | "Running" + | "Completed" + | "Failed" + | "Skipped"; +export type TaskType = "DataFetch" | "DataProcessing" | "Analysis"; +export type TaskProgress = { + details: string; + progress_percent: number; + request_id: string; + started_at: string; + status: ObservabilityTaskStatus; + task_name: string; +}; +export type ObservabilityTaskStatus = "Queued" | "InProgress" | "Completed" | "Failed"; +export type WorkflowDag = { + edges: Array; + nodes: Array; +}; +export type TaskDependency = { + from: string; + to: string; +}; +export type WorkflowEvent = + | { + payload: { + task_graph: WorkflowDag; + timestamp: number; + }; + type: "WorkflowStarted"; + } + | { + payload: { + message?: (string | null) | undefined; + progress?: (number | null) | undefined; + status: TaskStatus; + task_id: string; + task_type: TaskType; + timestamp: number; + }; + type: "TaskStateChanged"; + } + | { + payload: { + content_delta: string; + index: number; + task_id: string; + }; + type: "TaskStreamUpdate"; + } + | { + payload: { + end_timestamp: number; + result_summary: unknown; + }; + type: "WorkflowCompleted"; + } + | { + payload: { + end_timestamp: number; + is_fatal: boolean; + reason: string; + }; + type: "WorkflowFailed"; + } + | { + payload: { + task_graph: WorkflowDag; + tasks_output: {}; + tasks_status: {}; + timestamp: number; + }; + type: "WorkflowStateSnapshot"; + }; + +const AnalysisModuleConfig: z.ZodType = z.object({ + dependencies: z.array(z.string()), + model_id: z.string(), + name: z.string(), + prompt_template: z.string(), + provider_id: z.string(), +}); +const AnalysisTemplateSet: z.ZodType = z.object({ + modules: z.record(AnalysisModuleConfig), + name: z.string(), +}); +const AnalysisTemplateSets: z.ZodType = + z.record(AnalysisTemplateSet); +const DataSourceProvider = z.enum([ + "Tushare", + "Finnhub", + "Alphavantage", + "Yfinance", +]); +const DataSourceConfig: z.ZodType = z.object({ + api_key: z.union([z.string(), z.null()]).optional(), + api_url: z.union([z.string(), z.null()]).optional(), + enabled: z.boolean(), + provider: DataSourceProvider, +}); +const DataSourcesConfig: z.ZodType = + z.record(DataSourceConfig); +const TestLlmConfigRequest = z.object({ + api_base_url: z.string(), + api_key: z.string(), + model_id: z.string(), +}); +const LlmModel: z.ZodType = z.object({ + is_active: z.boolean(), + model_id: z.string(), + name: z.union([z.string(), z.null()]).optional(), +}); +const LlmProvider: z.ZodType = z.object({ + api_base_url: z.string(), + api_key: z.string(), + models: z.array(LlmModel), + name: z.string(), +}); +const LlmProvidersConfig: z.ZodType = z.record(LlmProvider); +const TestConfigRequest = z.object({ data: z.unknown(), type: z.string() }); +const TestConnectionResponse = z.object({ + message: z.string(), + success: z.boolean(), +}); +const DiscoverPreviewRequest = z.object({ + api_base_url: z.string(), + api_key: z.string(), +}); +const FieldType = z.enum(["Text", "Password", "Url", "Boolean", "Select"]); +const ConfigKey = z.enum([ + "ApiKey", + "ApiToken", + "ApiUrl", + "BaseUrl", + "SecretKey", + "Username", + "Password", + "SandboxMode", + "Region", +]); +const ConfigFieldSchema: z.ZodType = z.object({ + default_value: z.union([z.string(), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + field_type: FieldType, + key: ConfigKey, + label: z.string(), + options: z.union([z.array(z.string()), z.null()]).optional(), + placeholder: z.union([z.string(), z.null()]).optional(), + required: z.boolean(), +}); +const ProviderMetadata: z.ZodType = z.object({ + config_schema: z.array(ConfigFieldSchema), + description: z.string(), + icon_url: z.union([z.string(), z.null()]).optional(), + id: z.string(), + name_cn: z.string(), + name_en: z.string(), + supports_test_connection: z.boolean(), +}); +const SymbolResolveRequest = z.object({ + market: z.union([z.string(), z.null()]).optional(), + symbol: z.string(), +}); +const SymbolResolveResponse = z.object({ + market: z.string(), + symbol: z.string(), +}); +const DataRequest = z.object({ + market: z.union([z.string(), z.null()]).optional(), + symbol: z.string(), + template_id: z.string(), +}); +const RequestAcceptedResponse = z.object({ + market: z.string(), + request_id: z.string().uuid(), + symbol: z.string(), +}); +const ObservabilityTaskStatus = z.enum([ + "Queued", + "InProgress", + "Completed", + "Failed", +]); +const TaskProgress: z.ZodType = z.object({ + details: z.string(), + progress_percent: z.number().int().gte(0), + request_id: z.string().uuid(), + started_at: z.string().datetime({ offset: true }), + status: ObservabilityTaskStatus, + task_name: z.string(), +}); +const CanonicalSymbol = z.string(); +const ServiceStatus = z.enum(["Ok", "Degraded", "Unhealthy"]); +const HealthStatus: z.ZodType = z.object({ + details: z.record(z.string()), + module_id: z.string(), + status: ServiceStatus, + version: z.string(), +}); +const StartWorkflowCommand: z.ZodType = z.object({ + market: z.string(), + request_id: z.string().uuid(), + symbol: CanonicalSymbol, + template_id: z.string(), +}); +const TaskDependency: z.ZodType = z.object({ + from: z.string(), + to: z.string(), +}); +const TaskStatus = z.enum([ + "Pending", + "Scheduled", + "Running", + "Completed", + "Failed", + "Skipped", +]); +const TaskType = z.enum(["DataFetch", "DataProcessing", "Analysis"]); +const TaskNode: z.ZodType = z.object({ + id: z.string(), + initial_status: TaskStatus, + name: z.string(), + type: TaskType, +}); +const WorkflowDag: z.ZodType = z.object({ + edges: z.array(TaskDependency), + nodes: z.array(TaskNode), +}); +const WorkflowEvent: z.ZodType = z.union([ + z + .object({ + payload: z + .object({ task_graph: WorkflowDag, timestamp: z.number().int() }) + .passthrough(), + type: z.literal("WorkflowStarted"), + }) + .passthrough(), + z + .object({ + payload: z + .object({ + message: z.union([z.string(), z.null()]).optional(), + progress: z.union([z.number(), z.null()]).optional(), + status: TaskStatus, + task_id: z.string(), + task_type: TaskType, + timestamp: z.number().int(), + }) + .passthrough(), + type: z.literal("TaskStateChanged"), + }) + .passthrough(), + z + .object({ + payload: z + .object({ + content_delta: z.string(), + index: z.number().int().gte(0), + task_id: z.string(), + }) + .passthrough(), + type: z.literal("TaskStreamUpdate"), + }) + .passthrough(), + z + .object({ + payload: z + .object({ + end_timestamp: z.number().int(), + result_summary: z.unknown(), + }) + .passthrough(), + type: z.literal("WorkflowCompleted"), + }) + .passthrough(), + z + .object({ + payload: z + .object({ + end_timestamp: z.number().int(), + is_fatal: z.boolean(), + reason: z.string(), + }) + .passthrough(), + type: z.literal("WorkflowFailed"), + }) + .passthrough(), + z + .object({ + payload: z + .object({ + task_graph: WorkflowDag, + tasks_output: z.record(z.union([z.string(), z.null()])), + tasks_status: z.record(TaskStatus), + timestamp: z.number().int(), + }) + .passthrough(), + type: z.literal("WorkflowStateSnapshot"), + }) + .passthrough(), +]); + +export const schemas = { + AnalysisModuleConfig, + AnalysisTemplateSet, + AnalysisTemplateSets, + DataSourceProvider, + DataSourceConfig, + DataSourcesConfig, + TestLlmConfigRequest, + LlmModel, + LlmProvider, + LlmProvidersConfig, + TestConfigRequest, + TestConnectionResponse, + DiscoverPreviewRequest, + FieldType, + ConfigKey, + ConfigFieldSchema, + ProviderMetadata, + SymbolResolveRequest, + SymbolResolveResponse, + DataRequest, + RequestAcceptedResponse, + ObservabilityTaskStatus, + TaskProgress, + CanonicalSymbol, + ServiceStatus, + HealthStatus, + StartWorkflowCommand, + TaskDependency, + TaskStatus, + TaskType, + TaskNode, + WorkflowDag, + WorkflowEvent, +}; + +const endpoints = makeApi([ + { + method: "get", + path: "/api/v1/configs/analysis_template_sets", + alias: "get_analysis_template_sets", + requestFormat: "json", + response: z.record(AnalysisTemplateSet), + }, + { + method: "put", + path: "/api/v1/configs/analysis_template_sets", + alias: "update_analysis_template_sets", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: z.record(AnalysisTemplateSet), + }, + ], + response: z.record(AnalysisTemplateSet), + }, + { + method: "get", + path: "/api/v1/configs/data_sources", + alias: "get_data_sources_config", + requestFormat: "json", + response: z.record(DataSourceConfig), + }, + { + method: "put", + path: "/api/v1/configs/data_sources", + alias: "update_data_sources_config", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: z.record(DataSourceConfig), + }, + ], + response: z.record(DataSourceConfig), + }, + { + method: "get", + path: "/api/v1/configs/llm_providers", + alias: "get_llm_providers_config", + requestFormat: "json", + response: z.record(LlmProvider), + }, + { + method: "put", + path: "/api/v1/configs/llm_providers", + alias: "update_llm_providers_config", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: z.record(LlmProvider), + }, + ], + response: z.record(LlmProvider), + }, + { + method: "post", + path: "/api/v1/configs/llm/test", + alias: "test_llm_config", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: TestLlmConfigRequest, + }, + ], + response: z.void(), + }, + { + method: "post", + path: "/api/v1/configs/test", + alias: "test_data_source_config", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: z.object({ data: z.unknown(), type: z.string() }), + }, + ], + response: TestConnectionResponse, + }, + { + method: "post", + path: "/api/v1/discover-models", + alias: "discover_models_preview", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: DiscoverPreviewRequest, + }, + ], + response: z.void(), + errors: [ + { + status: 502, + description: `Provider error`, + schema: z.void(), + }, + ], + }, + { + method: "get", + path: "/api/v1/discover-models/:provider_id", + alias: "discover_models", + requestFormat: "json", + parameters: [ + { + name: "provider_id", + type: "Path", + schema: z.string(), + }, + ], + response: z.void(), + errors: [ + { + status: 404, + description: `Provider not found`, + schema: z.void(), + }, + { + status: 502, + description: `Provider error`, + schema: z.void(), + }, + ], + }, + { + method: "get", + path: "/api/v1/registry/providers", + alias: "get_registered_providers", + requestFormat: "json", + response: z.array(ProviderMetadata), + }, + { + method: "post", + path: "/api/v1/tools/resolve-symbol", + alias: "resolve_symbol", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: SymbolResolveRequest, + }, + ], + response: SymbolResolveResponse, + }, + { + method: "post", + path: "/api/v1/workflow/start", + alias: "start_workflow", + requestFormat: "json", + parameters: [ + { + name: "body", + type: "Body", + schema: DataRequest, + }, + ], + response: RequestAcceptedResponse, + }, + { + method: "get", + path: "/health", + alias: "health_check", + requestFormat: "json", + response: z.void(), + }, + { + method: "get", + path: "/tasks/:request_id", + alias: "get_task_progress", + requestFormat: "json", + parameters: [ + { + name: "request_id", + type: "Path", + schema: z.string().uuid(), + }, + ], + response: z.array(TaskProgress), + errors: [ + { + status: 404, + description: `Tasks not found`, + schema: z.void(), + }, + ], + }, +]); + +export const api = new Zodios(endpoints); + +export function createApiClient(baseUrl: string, options?: ZodiosOptions) { + return new Zodios(baseUrl, endpoints, options); +} diff --git a/frontend/src/components/config/DynamicConfigForm.tsx b/frontend/src/components/config/DynamicConfigForm.tsx new file mode 100644 index 0000000..885bf2b --- /dev/null +++ b/frontend/src/components/config/DynamicConfigForm.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { AlertCircle } from "lucide-react" +import { ProviderMetadata, ConfigFieldSchema, schemas } from "@/api/schema.gen" +import { DataSourceConfig } from "@/types/config" + +interface DynamicConfigFormProps { + metadata: ProviderMetadata; + initialConfig: DataSourceConfig; + onSave: (config: DataSourceConfig) => void; + onTest: (config: DataSourceConfig) => void; + isSaving: boolean; + isTesting: boolean; +} + +export function DynamicConfigForm({ + metadata, + initialConfig, + onSave, + onTest, + isSaving, + isTesting +}: DynamicConfigFormProps) { + const [config, setConfig] = useState(initialConfig); + const [isDirty, setIsDirty] = useState(false); + + // Reset local state when initialConfig changes (e.g. after save or load) + useEffect(() => { + setConfig(initialConfig); + setIsDirty(false); + }, [initialConfig]); + + // Helper function to map PascalCase key (from Enum serialization) to snake_case key (DataSourceConfig struct) + // Example: "ApiKey" -> "api_key", "ApiUrl" -> "api_url" + const mapKeyToConfigField = (key: string): keyof DataSourceConfig => { + // Simple mapping: convert to snake_case. + // Since we know the keys are things like "ApiKey", "ApiToken" (which maps to api_key usually?) + // Wait, DataSourceConfig has `api_key`, `api_url`. + // ConfigKey enum has `ApiKey`, `ApiUrl`, `ApiToken`. + // `ApiToken` usually maps to `api_key` in our `DataSourceConfig` struct (as seen in main.rs mapping comment). + + // Manual mapping based on knowledge of DataSourceConfig structure + // If the key is unknown/unmapped, we try to use it as is or lowercase it. + switch (key) { + case "ApiKey": return "api_key"; + case "ApiToken": return "api_key"; // Tushare uses ApiToken but stores in api_key + case "ApiUrl": return "api_url"; + case "BaseUrl": return "api_url"; // Maybe? + default: + // Fallback: try snake case conversion or just lowercase + return key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`).replace(/^_/, "") as keyof DataSourceConfig; + } + } + + // Generic handler for field updates + const handleChange = (fieldKey: string, value: any) => { + const configField = mapKeyToConfigField(fieldKey); + setConfig(prev => ({ ...prev, [configField]: value })); + setIsDirty(true); + } + + const renderField = (fieldSchema: ConfigFieldSchema) => { + // fieldSchema.key is the Enum string (e.g. "ApiKey") + const configField = mapKeyToConfigField(fieldSchema.key as string); + + // Access the config value using the mapped key + const currentValue = (config as any)[configField] || ""; + const isEnabled = config.enabled; + + switch (fieldSchema.field_type) { + case schemas.FieldType.enum.Boolean: + return ( +
+ + handleChange(fieldSchema.key, checked)} + disabled={!isEnabled} + /> +
+ ); + case schemas.FieldType.enum.Select: + return ( +
+ + + {fieldSchema.description &&

{fieldSchema.description}

} +
+ ); + case schemas.FieldType.enum.Password: + return ( +
+ + handleChange(fieldSchema.key, e.target.value)} + placeholder={fieldSchema.placeholder || ""} + disabled={!isEnabled} + /> + {fieldSchema.description &&

{fieldSchema.description}

} +
+ ); + default: // Text, Url + return ( +
+ + handleChange(fieldSchema.key, e.target.value)} + placeholder={fieldSchema.placeholder || ""} + disabled={!isEnabled} + /> + {fieldSchema.description &&

{fieldSchema.description}

} +
+ ); + } + } + + return ( + + +
+ + {metadata.name_cn || metadata.name_en} + {config.enabled ? + Active : + Inactive + } + + {metadata.description} +
+ handleChange('enabled', checked)} + /> +
+ + {metadata.config_schema.map(field => renderField(field))} + + + + + +
+ ) +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index bd929a4..b851c3f 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,11 +1,9 @@ -import React from 'react'; import { Link, useLocation } from 'react-router-dom'; import { cn } from '@/lib/utils'; import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, - NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, } from "@/components/ui/navigation-menu" @@ -72,4 +70,3 @@ export function Header() { ); } - diff --git a/frontend/src/components/layout/RootLayout.tsx b/frontend/src/components/layout/RootLayout.tsx index 6a85d15..5149634 100644 --- a/frontend/src/components/layout/RootLayout.tsx +++ b/frontend/src/components/layout/RootLayout.tsx @@ -1,5 +1,6 @@ import { Outlet } from 'react-router-dom'; import { Header } from './Header'; +import { Toaster } from '@/components/ui/toaster'; export function RootLayout() { return ( @@ -8,6 +9,7 @@ export function RootLayout() {
+ ); } diff --git a/frontend/src/components/report/FinancialTable.tsx b/frontend/src/components/report/FinancialTable.tsx index e8fcd21..3236860 100644 --- a/frontend/src/components/report/FinancialTable.tsx +++ b/frontend/src/components/report/FinancialTable.tsx @@ -2,15 +2,16 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { ScrollArea } from "@/components/ui/scroll-area" import { Badge } from "@/components/ui/badge" -import { CheckCircle2, XCircle, Loader2, Code } from "lucide-react" +import { CheckCircle2, XCircle, Loader2 } from "lucide-react" import { useWorkflowStore } from "@/stores/useWorkflowStore" import { TaskStatus } from "@/types/workflow" +import { schemas } from "@/api/schema.gen" export function FinancialTable() { const { tasks, dag } = useWorkflowStore(); // Identify DataFetch tasks dynamically from DAG - const fetchTasks = dag?.nodes.filter(n => n.type === 'DataFetch') || []; + const fetchTasks = dag?.nodes.filter(n => n.type === schemas.TaskType.enum.DataFetch) || []; return (
@@ -18,13 +19,13 @@ export function FinancialTable() {
{fetchTasks.map(node => { const taskState = tasks[node.id]; - const status = taskState?.status || 'Pending'; + const status = taskState?.status || schemas.TaskStatus.enum.Pending; return (
- {node.label} + {node.name}
@@ -34,7 +35,7 @@ export function FinancialTable() {
{/* Mock Raw Data Preview if Completed */} - {status === 'Completed' && ( + {status === schemas.TaskStatus.enum.Completed && (
Raw Response Preview
@@ -104,11 +105,11 @@ export function FinancialTable() { function StatusBadge({ status }: { status: TaskStatus }) { switch (status) { - case 'Running': + case schemas.TaskStatus.enum.Running: return Fetching; - case 'Completed': + case schemas.TaskStatus.enum.Completed: return Success; - case 'Failed': + case schemas.TaskStatus.enum.Failed: return Failed; default: return Pending; diff --git a/frontend/src/components/ui/add-row-menu.tsx b/frontend/src/components/ui/add-row-menu.tsx index 7602dfb..80a84a9 100644 --- a/frontend/src/components/ui/add-row-menu.tsx +++ b/frontend/src/components/ui/add-row-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -161,4 +161,4 @@ export function AddRowMenu({ onAddRow, disabled = false, className }: AddRowMenu
); -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index 25e5439..d9db03f 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -62,7 +62,7 @@ function SelectContent({ void; className?: string; + disabled?: boolean; }; -export const Switch: React.FC = ({ id, name, checked, onCheckedChange, className }) => { +export const Switch: React.FC = ({ id, name, checked, onCheckedChange, className, disabled }) => { return ( = ({ id, name, checked, onCheckedChan className={className} checked={!!checked} onChange={(e) => onCheckedChange?.(e.target.checked)} + disabled={disabled} /> ); }; diff --git a/frontend/src/components/ui/toaster.tsx b/frontend/src/components/ui/toaster.tsx new file mode 100644 index 0000000..61c72d9 --- /dev/null +++ b/frontend/src/components/ui/toaster.tsx @@ -0,0 +1,23 @@ +import { useToast } from "@/hooks/use-toast" +import { Notification } from "@/components/ui/notification" + +export function Toaster() { + const { toasts, dismiss } = useToast() + + return ( +
+ {toasts.map(function ({ id, title, description, type }) { + return ( + dismiss(id)} + autoHide={false} // handled by hook + /> + ) + })} +
+ ) +} diff --git a/frontend/src/components/workflow/WorkflowVisualizer.tsx b/frontend/src/components/workflow/WorkflowVisualizer.tsx index 9f6bb28..2b9b103 100644 --- a/frontend/src/components/workflow/WorkflowVisualizer.tsx +++ b/frontend/src/components/workflow/WorkflowVisualizer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useCallback } from 'react'; +import { useEffect, useCallback } from 'react'; import ReactFlow, { Background, Controls, @@ -9,11 +9,13 @@ import ReactFlow, { MarkerType, Position, Handle, - useOnSelectionChange, NodeMouseHandler } from 'reactflow'; import 'reactflow/dist/style.css'; +// @ts-ignore +import ELK from 'elkjs/lib/elk.bundled.js'; import { useWorkflowStore } from '@/stores/useWorkflowStore'; +import { schemas } from '@/api/schema.gen'; import { TaskStatus } from '@/types/workflow'; import { cn } from '@/lib/utils'; import { CheckCircle2, Circle, Clock, AlertCircle, Loader2, Ban } from 'lucide-react'; @@ -21,28 +23,28 @@ import { CheckCircle2, Circle, Clock, AlertCircle, Loader2, Ban } from 'lucide-r // --- Custom Node Component --- const StatusIcon = ({ status }: { status: TaskStatus }) => { switch (status) { - case 'Completed': return ; - case 'Running': return ; - case 'Failed': return ; - case 'Skipped': return ; - case 'Scheduled': return ; + case schemas.TaskStatus.enum.Completed: return ; + case schemas.TaskStatus.enum.Running: return ; + case schemas.TaskStatus.enum.Failed: return ; + case schemas.TaskStatus.enum.Skipped: return ; + case schemas.TaskStatus.enum.Scheduled: return ; default: return ; } }; const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskStatus, type: string }, selected: boolean }) => { - const statusColors = { - 'Pending': 'border-muted bg-card', - 'Scheduled': 'border-yellow-500/50 bg-yellow-50/10', - 'Running': 'border-blue-500 ring-2 ring-blue-500/20 bg-blue-50/10', - 'Completed': 'border-green-500 bg-green-50/10', - 'Failed': 'border-red-500 bg-red-50/10', - 'Skipped': 'border-gray-200 bg-gray-50/5 opacity-60', + const statusColors: Record = { + [schemas.TaskStatus.enum.Pending]: 'border-muted bg-card', + [schemas.TaskStatus.enum.Scheduled]: 'border-yellow-500/50 bg-yellow-50/10', + [schemas.TaskStatus.enum.Running]: 'border-blue-500 ring-2 ring-blue-500/20 bg-blue-50/10', + [schemas.TaskStatus.enum.Completed]: 'border-green-500 bg-green-50/10', + [schemas.TaskStatus.enum.Failed]: 'border-red-500 bg-red-50/10', + [schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60', }; return (
@@ -61,83 +63,126 @@ const nodeTypes = { taskNode: WorkflowNode, }; +// --- Layout Helper with ELK --- +const elk = new ELK(); + +const useLayout = () => { + const getLayoutedElements = useCallback(async (nodes: Node[], edges: Edge[]) => { + const graph = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': 'DOWN', + 'elk.spacing.nodeNode': '60', // Horizontal spacing + 'elk.layered.spacing.nodeNodeBetweenLayers': '80', // Vertical spacing + 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', + // Optimize for fewer edge bends/crossings + 'elk.edgeRouting': 'SPLINES', + }, + children: nodes.map((node) => ({ + id: node.id, + width: 180, // Match min-w-[180px] + height: 64, // Approx height + })), + edges: edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; + + try { + const layoutedGraph = await elk.layout(graph); + + const layoutedNodes = nodes.map((node) => { + const nodeElk = layoutedGraph.children?.find((n) => n.id === node.id); + return { + ...node, + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + position: { + x: nodeElk?.x || 0, + y: nodeElk?.y || 0, + }, + }; + }); + + return { nodes: layoutedNodes, edges }; + } catch (e) { + console.error('ELK Layout Error:', e); + return { nodes, edges }; + } + }, []); + + return { getLayoutedElements }; +}; + // --- Main Visualizer Component --- export function WorkflowVisualizer() { const { dag, tasks, setActiveTab, activeTab } = useWorkflowStore(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { getLayoutedElements } = useLayout(); - const onNodeClick: NodeMouseHandler = useCallback((event, node) => { - setActiveTab(node.id); + const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { + if (node.data.type === schemas.TaskType.enum.DataFetch) { + setActiveTab('data'); + } else { + setActiveTab(node.id); + } }, [setActiveTab]); // Transform DAG to ReactFlow nodes/edges when DAG or Task Status changes useEffect(() => { if (!dag) return; - const levels: Record = {}; - const getLevel = (id: string): number => { - if (levels[id] !== undefined) return levels[id]; - const node = dag.nodes.find(n => n.id === id); - if (!node || node.dependencies.length === 0) { - levels[id] = 0; - return 0; - } - const maxParentLevel = Math.max(...node.dependencies.map(getLevel)); - levels[id] = maxParentLevel + 1; - return maxParentLevel + 1; + const createGraph = async () => { + // 1. Create initial Nodes + const initialNodes: Node[] = dag.nodes.map(node => { + const taskState = tasks[node.id]; + const status = taskState ? taskState.status : node.initial_status; + + return { + id: node.id, + type: 'taskNode', + position: { x: 0, y: 0 }, + data: { + label: node.name, + status, + type: node.type + }, + selected: activeTab === node.id, + }; + }); + + // 2. Create Edges (Use Default Bezier for better visuals than Smoothstep with hierarchical) + const initialEdges: Edge[] = dag.edges.map(edge => { + return { + id: `${edge.from}-${edge.to}`, + source: edge.from, + target: edge.to, + type: 'default', // Bezier curve + markerEnd: { type: MarkerType.ArrowClosed }, + animated: tasks[edge.from]?.status === schemas.TaskStatus.enum.Running || tasks[edge.to]?.status === schemas.TaskStatus.enum.Running, + style: { stroke: '#64748b', strokeWidth: 1.5 } + }; + }); + + // 3. Apply Layout + const { nodes: layoutedNodes, edges: layoutedEdges } = await getLayoutedElements(initialNodes, initialEdges); + + setNodes(layoutedNodes); + setEdges(layoutedEdges); }; - dag.nodes.forEach(n => getLevel(n.id)); - - const levelCounts: Record = {}; - const newNodes: Node[] = dag.nodes.map(node => { - const level = levels[node.id]; - const indexInLevel = levelCounts[level] || 0; - levelCounts[level] = indexInLevel + 1; - - const taskState = tasks[node.id]; - const status = taskState ? taskState.status : node.initial_status; - - return { - id: node.id, - type: 'taskNode', - position: { x: indexInLevel * 220, y: level * 120 }, - data: { - label: node.label || node.id, - status, - type: node.type - }, - selected: activeTab === node.id, // Highlight active node - targetPosition: Position.Top, - sourcePosition: Position.Bottom, - }; - }); - - const newEdges: Edge[] = []; - dag.nodes.forEach(node => { - node.dependencies.forEach(depId => { - newEdges.push({ - id: `${depId}-${node.id}`, - source: depId, - target: node.id, - type: 'smoothstep', - markerEnd: { type: MarkerType.ArrowClosed }, - animated: tasks[depId]?.status === 'Running' || tasks[node.id]?.status === 'Running', - style: { stroke: '#64748b', strokeWidth: 1.5 } - }); - }); - }); - - setNodes(newNodes); - setEdges(newEdges); - }, [dag, tasks, activeTab, setNodes, setEdges]); // Depend on activeTab for selection + createGraph(); + }, [dag, tasks, activeTab, setNodes, setEdges, getLayoutedElements]); if (!dag) return
Waiting for workflow to start...
; return ( -
+
- +
); diff --git a/frontend/src/hooks/use-toast.ts b/frontend/src/hooks/use-toast.ts new file mode 100644 index 0000000..348b5c3 --- /dev/null +++ b/frontend/src/hooks/use-toast.ts @@ -0,0 +1,80 @@ +import { useState, useEffect } from "react" + +// Simple event bus for toasts +const listeners: Array<(state: State) => void> = [] + +type ToastType = 'success' | 'error' | 'info' | 'warning' + +interface Toast { + id: string + title?: string + description?: string + type?: ToastType + duration?: number +} + +interface State { + toasts: Toast[] +} + +let memoryState: State = { toasts: [] } + +function dispatch(action: State) { + memoryState = { ...action } + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +function toast({ ...props }: Omit) { + const id = Math.random().toString(36).substring(2, 9) + const newToast = { ...props, id } + + dispatch({ + toasts: [...memoryState.toasts, newToast], + }) + + if (props.duration !== Infinity) { + setTimeout(() => { + dismiss(id) + }, props.duration || 3000) + } + + return { + id, + dismiss: () => dismiss(id), + update: (props: Partial) => + dispatch({ + toasts: memoryState.toasts.map((t) => + t.id === id ? { ...t, ...props } : t + ), + }), + } +} + +function dismiss(toastId?: string) { + dispatch({ + toasts: memoryState.toasts.filter((t) => t.id !== toastId), + }) +} + +export function useToast() { + const [state, setState] = useState(memoryState) + + useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss, + } +} + diff --git a/frontend/src/hooks/useConfig.ts b/frontend/src/hooks/useConfig.ts index 45ea675..2134aa9 100644 --- a/frontend/src/hooks/useConfig.ts +++ b/frontend/src/hooks/useConfig.ts @@ -1,54 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { LlmProvidersConfig, DataSourcesConfig, AnalysisTemplateSets, DataSourceProvider } from '../types/config'; - -// MOCK DATA -const MOCK_PROVIDERS: LlmProvidersConfig = { - "openai_official": { - name: "OpenAI Official", - api_base_url: "https://api.openai.com/v1", - api_key: "sk-****************", - models: [ - { model_id: "gpt-4o", name: "GPT-4o", is_active: true }, - { model_id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo", is_active: true } - ] - }, - "local_ollama": { - name: "Local Ollama", - api_base_url: "http://localhost:11434/v1", - api_key: "ollama", - models: [ - { model_id: "llama3", name: "Llama 3", is_active: true } - ] - } -}; - -const MOCK_DATA_SOURCES: DataSourcesConfig = { - "tushare_main": { - provider: DataSourceProvider.Tushare, - api_key: "89******************************", - enabled: true - }, - "finnhub_backup": { - provider: DataSourceProvider.Finnhub, - api_key: "c1****************", - enabled: false - } -}; - -const MOCK_TEMPLATES: AnalysisTemplateSets = { - "quick_scan": { - name: "快速扫描 (Quick Scan)", - modules: { - "summary": { - name: "概览", - provider_id: "openai_official", - model_id: "gpt-4o", - prompt_template: "Analyze this...", - dependencies: [] - } - } - } -}; +import { LlmProvidersConfig, DataSourcesConfig, AnalysisTemplateSets, TestConfigRequest, TestLlmConfigRequest } from '../types/config'; +import { client } from '../api/client'; // --- Hooks --- @@ -56,11 +8,19 @@ export function useLlmProviders() { return useQuery({ queryKey: ['llm-providers'], queryFn: async () => { - // TODO: Replace with actual API call - // const { data } = await apiClient.get('/configs/llm_providers'); - // return data; - await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay - return MOCK_PROVIDERS; + return await client.get_llm_providers_config(); + } + }); +} + +export function useUpdateLlmProviders() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (config: LlmProvidersConfig) => { + return await client.update_llm_providers_config(config); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['llm-providers'] }); } }); } @@ -69,8 +29,28 @@ export function useDataSources() { return useQuery({ queryKey: ['data-sources'], queryFn: async () => { - await new Promise(resolve => setTimeout(resolve, 500)); - return MOCK_DATA_SOURCES; + return await client.get_data_sources_config(); + } + }); +} + +export function useUpdateDataSources() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (config: DataSourcesConfig) => { + return await client.update_data_sources_config(config); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['data-sources'] }); + } + }); +} + +export function useRegisteredProviders() { + return useQuery({ + queryKey: ['registered-providers'], + queryFn: async () => { + return await client.get_registered_providers(); } }); } @@ -79,9 +59,47 @@ export function useAnalysisTemplates() { return useQuery({ queryKey: ['analysis-templates'], queryFn: async () => { - await new Promise(resolve => setTimeout(resolve, 500)); - return MOCK_TEMPLATES; + return await client.get_analysis_template_sets(); } }); } +export function useUpdateAnalysisTemplates() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (config: AnalysisTemplateSets) => { + return await client.update_analysis_template_sets(config); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['analysis-templates'] }); + } + }); +} + +export function useDiscoverModels() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (providerId: string) => { + return await client.discover_models({ params: { provider_id: providerId } }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['llm-providers'] }); + } + }); +} + +export function useTestDataSource() { + return useMutation({ + mutationFn: async (config: TestConfigRequest) => { + return await client.test_data_source_config(config); + } + }); +} + +export function useTestLlmConfig() { + return useMutation({ + mutationFn: async (config: TestLlmConfigRequest) => { + return await client.test_llm_config(config); + } + }); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 00e1f69..ca22769 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" import { Input } from "@/components/ui/input" @@ -7,9 +8,13 @@ import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { BarChart3, Search, Sparkles, Loader2 } from "lucide-react" import { useAnalysisTemplates } from "@/hooks/useConfig" +import { client } from '@/api/client'; +import { type DataRequest as DataRequestDTO } from '@/api/schema.gen'; +import { useToast } from "@/hooks/use-toast" export function Dashboard() { const navigate = useNavigate(); + const { toast } = useToast(); const [symbol, setSymbol] = useState(""); const [market, setMarket] = useState("CN"); const [templateId, setTemplateId] = useState(""); @@ -23,12 +28,30 @@ export function Dashboard() { } }, [templates, templateId]); + const startWorkflowMutation = useMutation({ + mutationFn: async (payload: DataRequestDTO) => { + return await client.start_workflow(payload); + }, + onSuccess: (data) => { + navigate(`/report/${data.request_id}?symbol=${data.symbol}&market=${data.market}&templateId=${templateId}`); + }, + onError: (error) => { + toast({ + title: "Start Failed", + description: "Failed to start analysis workflow. Please try again.", + type: "error" + }); + console.error("Workflow start error:", error); + } + }); + const handleStart = () => { if (!symbol || !templateId) return; - // In real app, we would POST to create a workflow, get ID, then navigate. - // Here we just mock an ID. - const mockRequestId = "req-" + Math.random().toString(36).substr(2, 9); - navigate(`/report/${mockRequestId}?symbol=${symbol}&market=${market}&templateId=${templateId}`); + startWorkflowMutation.mutate({ + symbol, + market, + template_id: templateId + }); }; return ( @@ -65,6 +88,11 @@ export function Dashboard() { className="pl-9" value={symbol} onChange={(e) => setSymbol(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && symbol && templateId) { + handleStart(); + } + }} />
@@ -90,14 +118,16 @@ export function Dashboard() { - {templates ? ( + {templates && Object.keys(templates).length > 0 ? ( Object.entries(templates).map(([id, t]) => ( {t.name} )) ) : ( - Loading... + + {isTemplatesLoading ? "Loading..." : "No templates found"} + )} @@ -105,9 +135,17 @@ export function Dashboard() { - diff --git a/frontend/src/pages/ReportPage.tsx b/frontend/src/pages/ReportPage.tsx index 27022f5..ced4a7d 100644 --- a/frontend/src/pages/ReportPage.tsx +++ b/frontend/src/pages/ReportPage.tsx @@ -1,14 +1,13 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { ScrollArea } from "@/components/ui/scroll-area" -import { Separator } from "@/components/ui/separator" import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer'; import { useWorkflowStore } from '@/stores/useWorkflowStore'; -import { WorkflowDag, TaskStatus } from '@/types/workflow'; -import { Terminal, Play, Loader2, Sparkles, Table, CheckCircle2 } from 'lucide-react'; +import { TaskStatus, schemas } from '@/api/schema.gen'; +import { Terminal, Loader2, Sparkles, CheckCircle2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -24,170 +23,51 @@ export function ReportPage() { const { initialize, - setDag, - updateTaskStatus, - updateTaskContent, - appendTaskLog, - setActiveTab, + handleEvent, status, tasks, dag, - activeTab + activeTab, + setActiveTab } = useWorkflowStore(); const { data: templates } = useAnalysisTemplates(); const templateName = templates && templateId ? templates[templateId]?.name : templateId; - // SSE Connection Logic (Mocked for now) + // SSE Connection Logic useEffect(() => { if (!id) return; initialize(id); - // Set default active tab to overview or first task - setActiveTab('overview'); - - // --- MOCK SIMULATION START --- - // In real app, this would be: - // const eventSource = new EventSource(`/api/v1/workflow/events/${id}`); + // Connect to real backend SSE + const eventSource = new EventSource(`/api/v1/workflow/events/${id}`); - let timeouts: NodeJS.Timeout[] = []; - - const mockDag: WorkflowDag = { - nodes: [ - { id: 'fetch:tushare', type: 'DataFetch', label: 'Fetch Tushare', dependencies: [], initial_status: 'Pending' }, - { id: 'fetch:finnhub', type: 'DataFetch', label: 'Fetch Finnhub', dependencies: [], initial_status: 'Pending' }, - { id: 'process:financials', type: 'DataProcessing', label: 'Clean Financials', dependencies: ['fetch:tushare', 'fetch:finnhub'], initial_status: 'Pending' }, - { id: 'analysis:basic', type: 'Analysis', label: 'Basic Analysis', dependencies: ['process:financials'], initial_status: 'Pending' }, - { id: 'analysis:risk', type: 'Analysis', label: 'Risk Assess', dependencies: ['process:financials'], initial_status: 'Pending' }, - { id: 'analysis:summary', type: 'Analysis', label: 'Final Summary', dependencies: ['analysis:basic', 'analysis:risk'], initial_status: 'Pending' }, - ] + eventSource.onmessage = (event) => { + try { + const parsedEvent = JSON.parse(event.data); + handleEvent(parsedEvent); + } catch (e) { + console.error("Failed to parse SSE event:", e); + } }; - // 1. Start - timeouts.push(setTimeout(() => { - setDag(mockDag); - }, 1000)); + eventSource.onerror = (err) => { + console.error("SSE Connection Error:", err); + // Optional: Retry logic or error state update + // eventSource.close(); + }; - // 2. Run Fetch - timeouts.push(setTimeout(() => { - updateTaskStatus('fetch:tushare', 'Running', 'Connecting to Tushare API...'); - updateTaskStatus('fetch:finnhub', 'Running', 'Connecting to Finnhub...'); - }, 2000)); - - timeouts.push(setTimeout(() => { - updateTaskStatus('fetch:tushare', 'Completed', 'Fetched 3 years of income statements.'); - updateTaskStatus('fetch:finnhub', 'Completed', 'Fetched price history.'); - }, 4000)); - - // 3. Process - timeouts.push(setTimeout(() => { - updateTaskStatus('process:financials', 'Running', 'Normalizing currency...'); - }, 4500)); - - timeouts.push(setTimeout(() => { - updateTaskStatus('process:financials', 'Completed', 'Data aligned to fiscal years.'); - }, 5500)); - - // 4. Parallel Analysis - timeouts.push(setTimeout(() => { - updateTaskStatus('analysis:basic', 'Running', 'Generating basic metrics...'); - updateTaskStatus('analysis:risk', 'Running', 'Evaluating volatility...'); - }, 6000)); - - // Stream logs - timeouts.push(setTimeout(() => appendTaskLog('analysis:basic', 'Calculated ROE: 15%'), 7000)); - timeouts.push(setTimeout(() => appendTaskLog('analysis:basic', 'Calculated Net Margin: 22%'), 7500)); - timeouts.push(setTimeout(() => appendTaskLog('analysis:risk', 'Beta: 0.85'), 7200)); - - // Stream Content for Basic Analysis - const basicReport = `### Basic Analysis - -**Profitability**: -- ROE: **15%** -- Net Margin: **22%** - -The company shows strong operational efficiency.`; - - streamMockContent(basicReport, 'analysis:basic', 6500, timeouts, updateTaskContent); - - // Stream Content for Risk - const riskReport = `### Risk Assessment - -**Volatility**: -- Beta: **0.85** (Low Risk) -- Debt/Equity: **0.4** (Healthy) - -No significant red flags detected in the balance sheet.`; - streamMockContent(riskReport, 'analysis:risk', 6500, timeouts, updateTaskContent); - - timeouts.push(setTimeout(() => { - updateTaskStatus('analysis:basic', 'Completed'); - updateTaskStatus('analysis:risk', 'Completed'); - }, 8500)); - - // 5. Final (With Streaming Content) - timeouts.push(setTimeout(() => { - updateTaskStatus('analysis:summary', 'Running', 'Synthesizing report...'); - // Auto switch to summary tab when it starts - setActiveTab('analysis:summary'); - }, 9000)); - - // Simulate Typing Effect for Markdown - const fullReport = ` -# ${symbol} Financial Analysis Report - -## 1. Executive Summary -Based on the financial data from **Tushare** and **Finnhub**, ${symbol} demonstrates strong fundamental performance. - -* **Profitability**: ROE is at **15%**, exceeding industry average. -* **Risk**: Beta of **0.85** indicates lower volatility than the market. - -## 2. Financial Health -The company has maintained a solid balance sheet. - -| Metric | 2023 | 2024 | -| :--- | :--- | :--- | -| Revenue | $10B | $12B | -| Net Income | $1.5B | $2.0B | - -> "The company's growth trajectory remains positive despite macroeconomic headwinds." - -## 3. Conclusion -**Buy** rating is recommended for long-term investors. - `; - - streamMockContent(fullReport, 'analysis:summary', 9100, timeouts, updateTaskContent); - - timeouts.push(setTimeout(() => { - updateTaskStatus('analysis:summary', 'Completed', 'Report generated.'); - }, 13000)); - - return () => timeouts.forEach(clearTimeout); - // --- MOCK SIMULATION END --- - - }, [id]); - - // Helper to stream mock content char by char - const streamMockContent = (text: string, taskId: string, startDelay: number, timeouts: NodeJS.Timeout[], updateFn: (id: string, delta: string) => void) => { - const chunks = text.split(""); - let delay = startDelay; - chunks.forEach((char) => { - delay += 20; // Typing speed - timeouts.push(setTimeout(() => { - updateFn(taskId, char); - }, delay)); - }); - }; + return () => { + eventSource.close(); + }; + }, [id, initialize, handleEvent]); // Combine logs from all tasks for the "Global Log" view const allLogs = Object.entries(tasks).flatMap(([taskId, state]) => state.logs.map(log => ({ taskId, log })) ); - // Filter nodes that should have their own tabs (e.g. Analysis & Processing) - // We keep DataFetch nodes out of tabs to reduce clutter, or maybe group them in "Overview"? - // For now, let's show tabs for all Analysis nodes + Overview + Data - const tabNodes = dag?.nodes.filter(n => n.type === 'Analysis') || []; + const tabNodes = dag?.nodes.filter(n => n.type === schemas.TaskType.enum.Analysis) || []; return (
@@ -222,16 +102,16 @@ The company has maintained a solid balance sheet. - - + + Real-time Logs - - -
+ +
+
{allLogs.length === 0 && Waiting for logs...} {allLogs.map((entry, i) => (
@@ -240,7 +120,7 @@ The company has maintained a solid balance sheet.
))}
- +
@@ -262,8 +142,8 @@ The company has maintained a solid balance sheet. value={node.id} className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3 gap-2" > - {node.label} - + {node.name} + ))} @@ -298,11 +178,11 @@ The company has maintained a solid balance sheet. ) : (
- {tasks[node.id]?.status === 'Pending' &&

Waiting to start...

} - {tasks[node.id]?.status === 'Running' && !tasks[node.id]?.content && } + {tasks[node.id]?.status === schemas.TaskStatus.enum.Pending &&

Waiting to start...

} + {tasks[node.id]?.status === schemas.TaskStatus.enum.Running && !tasks[node.id]?.content && }
)} - {tasks[node.id]?.status === 'Running' && ( + {tasks[node.id]?.status === schemas.TaskStatus.enum.Running && ( )}
@@ -336,9 +216,9 @@ function WorkflowStatusBadge({ status }: { status: string }) { function TaskStatusIndicator({ status }: { status: TaskStatus }) { switch (status) { - case 'Running': return ; - case 'Completed': return ; - case 'Failed': return
; + case schemas.TaskStatus.enum.Running: return ; + case schemas.TaskStatus.enum.Completed: return ; + case schemas.TaskStatus.enum.Failed: return
; default: return null; } } diff --git a/frontend/src/pages/config/AIProviderTab.tsx b/frontend/src/pages/config/AIProviderTab.tsx index 33e3288..6866aa5 100644 --- a/frontend/src/pages/config/AIProviderTab.tsx +++ b/frontend/src/pages/config/AIProviderTab.tsx @@ -1,21 +1,83 @@ -import { Plus, Trash2, RefreshCw, Eye, EyeOff } from "lucide-react" -import { useState } from "react" +import { Plus, Trash2, RefreshCw, Eye, EyeOff, Save, X, Search } from "lucide-react" +import { useState, useRef, useEffect } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { useLlmProviders } from "@/hooks/useConfig" -import { LlmProvider } from "@/types/config" +import { useLlmProviders, useUpdateLlmProviders, useDiscoverModels } from "@/hooks/useConfig" +import { LlmProvider, LlmModel } from "@/types/config" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { useToast } from "@/hooks/use-toast" +import axios from "axios" export function AIProviderTab() { const { data: providers, isLoading } = useLlmProviders(); + const updateProviders = useUpdateLlmProviders(); + const { toast } = useToast(); + const [isAddOpen, setIsAddOpen] = useState(false); + const [formData, setFormData] = useState({ name: '', url: '', key: '' }); + + const handleSave = () => { + if (!formData.name || !formData.url || !formData.key) { + toast({ title: "Validation Error", description: "All fields are required", type: "error" }); + return; + } + + const id = formData.name.toLowerCase().replace(/\s+/g, '_'); + if (providers && providers[id]) { + toast({ title: "Validation Error", description: "Provider with this name already exists", type: "error" }); + return; + } + + const newProvider: LlmProvider = { + name: formData.name, + api_base_url: formData.url, + api_key: formData.key, + models: [] + }; + + const newProviders = { ...providers, [id]: newProvider }; + + updateProviders.mutate(newProviders, { + onSuccess: () => { + toast({ title: "Success", description: "Provider added successfully" }); + setIsAddOpen(false); + setFormData({ name: '', url: '', key: '' }); + }, + onError: (err) => { + toast({ title: "Error", description: "Failed to add provider: " + err, type: "error" }); + } + }); + } + + const handleDelete = (id: string) => { + if (!providers) return; + const { [id]: removed, ...rest } = providers; + updateProviders.mutate(rest, { + onSuccess: () => toast({ title: "Success", description: "Provider removed" }), + onError: () => toast({ title: "Error", description: "Failed to remove provider", type: "error" }) + }); + } + + const handleUpdateProvider = (id: string, updatedProvider: LlmProvider) => { + if (!providers) return; + const newProviders = { ...providers, [id]: updatedProvider }; + updateProviders.mutate(newProviders, { + onSuccess: () => toast({ title: "Success", description: "Provider updated" }), + onError: (err) => toast({ title: "Error", description: "Failed to update provider", type: "error" }) + }); + } if (isLoading) { return
Loading providers...
; } + + // Handle empty state explicitly if data is loaded but empty + if (providers && Object.keys(providers).length === 0) { + // Fallthrough to render normally, showing empty list + } return (
@@ -38,19 +100,39 @@ export function AIProviderTab() {
- + setFormData({...formData, name: e.target.value})} + />
- + setFormData({...formData, url: e.target.value})} + />
- + setFormData({...formData, key: e.target.value})} + />
- + @@ -58,15 +140,137 @@ export function AIProviderTab() {
{providers && Object.entries(providers).map(([id, provider]) => ( - + handleDelete(id)} + onUpdate={(p) => handleUpdateProvider(id, p)} + /> ))}
) } -function ProviderCard({ id, provider }: { id: string, provider: LlmProvider }) { +function ProviderCard({ id, provider, onDelete, onUpdate }: { id: string, provider: LlmProvider, onDelete: () => void, onUpdate: (p: LlmProvider) => void }) { const [showKey, setShowKey] = useState(false); + const discoverModels = useDiscoverModels(); + const { toast } = useToast(); + + // Discovered models cache for search (not saved to config) + const [discoveredModels, setDiscoveredModels] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchFocused, setIsSearchFocused] = useState(false); + const searchRef = useRef(null); + + useEffect(() => { + // Close search results on click outside + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setIsSearchFocused(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleRefresh = async () => { + try { + const response = await axios.get(`/api/v1/discover-models/${id}`); + const data = response.data; + + let models: LlmModel[] = []; + if (data && Array.isArray(data.data)) { + models = data.data.map((m: any) => ({ + model_id: m.id, + name: m.name || m.id, + is_active: true + })); + } else if (Array.isArray(data)) { + models = data.map((m: any) => ({ + model_id: m.id, + name: m.name || m.id, + is_active: true + })); + } + + setDiscoveredModels(models); + + if (models.length === 0) { + toast({ title: "Info", description: "No models found in response" }); + } else if (models.length < 10) { + // If few models, add them all automatically + const updatedProvider = { ...provider, models }; + onUpdate(updatedProvider); + toast({ title: "Success", description: `Found and added ${models.length} models` }); + } else { + // If many models, just notify user to search/add + toast({ + title: "Found many models", + description: `Discovered ${models.length} models. Please search and add specific models below.` + }); + } + } catch (err) { + console.error(err); + toast({ title: "Error", description: "Failed to refresh models", type: "error" }); + } + } + + const handleAddModel = (model: LlmModel) => { + // Check if already exists + if (provider.models.some(m => m.model_id === model.model_id)) { + toast({ title: "Info", description: "Model already added" }); + setSearchQuery(""); + setIsSearchFocused(false); + return; + } + + const updatedProvider = { + ...provider, + models: [...provider.models, model] + }; + onUpdate(updatedProvider); + setSearchQuery(""); + setIsSearchFocused(false); + toast({ title: "Success", description: `Added ${model.name || model.model_id}` }); + } + + const handleManualAdd = () => { + if (!searchQuery.trim()) return; + const newModel = { + model_id: searchQuery, + name: searchQuery, + is_active: true + }; + handleAddModel(newModel); + } + + const handleRemoveModel = (modelId: string) => { + const updatedProvider = { + ...provider, + models: provider.models.filter(m => m.model_id !== modelId) + }; + onUpdate(updatedProvider); + } + + const handleClearModels = () => { + if (provider.models.length === 0) return; + if (confirm("确定要清空所有已添加的模型吗?")) { + const updatedProvider = { + ...provider, + models: [] + }; + onUpdate(updatedProvider); + toast({ title: "Success", description: "已清空所有模型" }); + } + } + + const filteredModels = searchQuery + ? discoveredModels.filter(m => + (m.name?.toLowerCase().includes(searchQuery.toLowerCase()) || m.model_id.toLowerCase().includes(searchQuery.toLowerCase())) + ) + : []; return ( @@ -103,21 +307,102 @@ function ProviderCard({ id, provider }: { id: string, provider: LlmProvider }) {
- +
+ {provider.models.length > 0 && ( + + )} + +
-
+ + {/* Search / Add Input */} +
+
+ + setSearchQuery(e.target.value)} + onFocus={() => setIsSearchFocused(true)} + className="pl-8 h-9 text-sm" + /> + {searchQuery && ( + + )} +
+ + {/* Autocomplete Dropdown */} + {isSearchFocused && searchQuery && discoveredModels.length > 0 && ( +
+ {filteredModels.length > 0 ? ( + filteredModels.map((model) => ( + + )) + ) : ( +
+ No discovered models match "{searchQuery}". Click 'Add' to add manually. +
+ )} +
+ )} +
+ + {/* Active Models List */} +
+ {provider.models.length === 0 && No active models.} {provider.models.map(model => ( - + {model.name || model.model_id} + ))}
- diff --git a/frontend/src/pages/config/DataSourceTab.tsx b/frontend/src/pages/config/DataSourceTab.tsx index 5d628b0..ec8bc20 100644 --- a/frontend/src/pages/config/DataSourceTab.tsx +++ b/frontend/src/pages/config/DataSourceTab.tsx @@ -1,92 +1,131 @@ -import { Check, X, Save, AlertCircle } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Switch } from "@/components/ui/switch" -import { useDataSources } from "@/hooks/useConfig" -import { DataSourceConfig, DataSourceProvider } from "@/types/config" -import { Badge } from "@/components/ui/badge" +import { useDataSources, useUpdateDataSources, useTestDataSource, useRegisteredProviders } from "@/hooks/useConfig" +import { DataSourceConfig } from "@/types/config" +import { useToast } from "@/hooks/use-toast" +import { DynamicConfigForm } from "@/components/config/DynamicConfigForm" export function DataSourceTab() { - const { data: dataSources, isLoading } = useDataSources(); + const { data: dataSources, isLoading: isConfigLoading } = useDataSources(); + const { data: providersMetadata, isLoading: isMetadataLoading } = useRegisteredProviders(); + + const updateDataSources = useUpdateDataSources(); + const testDataSource = useTestDataSource(); + const { toast } = useToast(); - if (isLoading) { + if (isConfigLoading || isMetadataLoading) { return
Loading data sources...
; } - // We want to show all supported providers, even if not yet in config - const supportedProviders = [ - { type: DataSourceProvider.Tushare, name: "Tushare Pro", desc: "中国股市数据 (A股/港股)" }, - { type: DataSourceProvider.Finnhub, name: "Finnhub", desc: "全球美股与外汇数据" }, - { type: DataSourceProvider.Alphavantage, name: "Alpha Vantage", desc: "技术指标与外汇" }, - { type: DataSourceProvider.Yfinance, name: "Yahoo Finance", desc: "基础免费数据源 (非官方)" }, - ]; + if (!providersMetadata || providersMetadata.length === 0) { + return ( +
+ No data providers registered. Please ensure provider services are running. +
+ ); + } + + const handleSave = (providerId: string, config: DataSourceConfig) => { + // We need to reconstruct the full DataSourcesConfig map + // Note: dataSources is a HashMap + const newDataSources = { ...dataSources, [providerId]: config }; + updateDataSources.mutate(newDataSources, { + onSuccess: () => toast({ title: "Success", description: `${providerId} configuration saved` }), + onError: (err) => toast({ title: "Error", description: "Failed to save: " + err, type: "error" }) + }); + }; + + const handleTest = (providerId: string, config: DataSourceConfig) => { + // Construct payload for generic test endpoint + const payload = { + type: providerId, + data: config // Send the whole config object as data + }; + + // We cast to any because TestConfigRequest type definition in frontend might be loose or generic + testDataSource.mutate(payload as any, { + onSuccess: (data: any) => { + console.log("Test Connection Success Payload:", data); + // Check both explicit false and explicit "false" string, or any other falsy indicator + if (data.success === false || data.success === "false") { + console.warn("Test reported success but payload indicates failure:", data); + toast({ + title: "Test Failed", + description: data.message || "Connection test failed", + type: "error" + }); + } else { + console.log("Test confirmed successful."); + toast({ + title: "Success", + description: data.message || "Connection test successful" + }); + } + }, + onError: (err: any) => { + console.error("Test Connection Error:", err); + let errorMessage = "Connection test failed"; + + if (err.response?.data) { + const data = err.response.data; + console.log("Error Response Data:", data); + // Try to parse 'details' if it exists and is a string (common-contracts error format) + if (typeof data.details === 'string') { + try { + const parsed = JSON.parse(data.details); + if (parsed.message) errorMessage = parsed.message; + else errorMessage = data.details; + } catch (e) { + errorMessage = data.details; + } + } else if (data.message) { + errorMessage = data.message; + } else if (data.error) { + errorMessage = data.error; + } + } + + toast({ + title: "Test Failed", + description: errorMessage, + type: "error" + }); + } + }); + }; return (
- {supportedProviders.map(p => { + {providersMetadata.map(meta => { // Find existing config or create default - // In reality, the key in the map might not match the enum exactly (e.g. "tushare_main" vs "Tushare") - // But for this mock, let's assume we search by provider type - const configEntry = dataSources ? Object.values(dataSources).find(d => d.provider === p.type) : undefined; + const configEntry = dataSources ? (dataSources as Record)[meta.id] : undefined; - const config = configEntry || { - provider: p.type, + // Default config structure. + // Note: We default 'provider' field to the ID from metadata. + // Backend expects specific enum values for 'provider', but currently our IDs match (lowercase/uppercase handling needed?) + // The backend DataSourceProvider enum is PascalCase (Tushare), but IDs are likely lowercase (tushare). + // However, DataSourceConfig.provider is an enum. + // We might need to map ID to Enum if strict. + // For now, assuming the backend persistence can handle the string or we just store it. + // Actually, the 'provider' field in DataSourceConfig is DataSourceProvider enum. + // Let's hope the JSON deserialization handles "tushare" -> Tushare. + + const config = (configEntry || { + provider: meta.id, // This might need capitalization adjustment enabled: false, - api_key: "", - api_url: "" - } as DataSourceConfig; + // We init other fields as empty, they will be filled by DynamicConfigForm + }) as DataSourceConfig; return ( - + handleSave(meta.id, c)} + onTest={(c) => handleTest(meta.id, c)} + isSaving={updateDataSources.isPending} + isTesting={testDataSource.isPending} + /> ) })}
) } - -function DataSourceCard({ meta, config }: { meta: { type: DataSourceProvider, name: string, desc: string }, config: DataSourceConfig }) { - const isEnabled = config.enabled; - - return ( - - -
- - {meta.name} - {isEnabled ? - Active : - Inactive - } - - {meta.desc} -
- -
- -
- - -
- {meta.type === DataSourceProvider.Tushare && ( -
- - -
- )} -
- - - - -
- ) -} diff --git a/frontend/src/pages/config/TemplateTab.tsx b/frontend/src/pages/config/TemplateTab.tsx index e55a7d9..df28c26 100644 --- a/frontend/src/pages/config/TemplateTab.tsx +++ b/frontend/src/pages/config/TemplateTab.tsx @@ -1,26 +1,73 @@ -import { useState } from "react" -import { useAnalysisTemplates } from "@/hooks/useConfig" -import { AnalysisTemplateSets, AnalysisTemplateSet, AnalysisModuleConfig } from "@/types/config" +import { useState, useEffect } from "react" +import { useAnalysisTemplates, useUpdateAnalysisTemplates, useLlmProviders } from "@/hooks/useConfig" +import { AnalysisTemplateSet, AnalysisModuleConfig } from "@/types/config" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" -import { Separator } from "@/components/ui/separator" import { Badge } from "@/components/ui/badge" -import { ArrowRight, Box, Layers } from "lucide-react" +import { ArrowRight, Box, Layers, Save, Plus, Pencil, Trash2, ChevronDown, ChevronUp } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Label } from "@/components/ui/label" +import { useToast } from "@/hooks/use-toast" export function TemplateTab() { const { data: templates, isLoading } = useAnalysisTemplates(); + const updateTemplates = useUpdateAnalysisTemplates(); + const { toast } = useToast(); const [selectedId, setSelectedId] = useState(null); // Auto select first if none selected + useEffect(() => { if (templates && !selectedId && Object.keys(templates).length > 0) { setSelectedId(Object.keys(templates)[0]); } + }, [templates, selectedId]); if (isLoading) return
Loading templates...
; - if (!templates) return
No templates found.
; - const activeTemplate = selectedId ? templates[selectedId] : null; + const handleCreateTemplate = () => { + if (!templates) return; + const newId = crypto.randomUUID(); + const newTemplate: AnalysisTemplateSet = { + name: "New Template", + modules: {} + }; + + const newTemplates = { ...templates, [newId]: newTemplate }; + updateTemplates.mutate(newTemplates, { + onSuccess: () => { + toast({ title: "Success", description: "Template created" }); + setSelectedId(newId); + }, + onError: () => toast({ title: "Error", description: "Failed to create template", type: "error" }) + }); + } + + const handleUpdateTemplate = (id: string, updatedTemplate: AnalysisTemplateSet) => { + if (!templates) return; + const newTemplates = { ...templates, [id]: updatedTemplate }; + updateTemplates.mutate(newTemplates, { + onSuccess: () => toast({ title: "Success", description: "Template saved" }), + onError: () => toast({ title: "Error", description: "Failed to save template", type: "error" }) + }); + } + + const handleDeleteTemplate = (id: string) => { + if (!templates) return; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [id]: removed, ...rest } = templates; + updateTemplates.mutate(rest, { + onSuccess: () => { + toast({ title: "Success", description: "Template deleted" }); + if (selectedId === id) setSelectedId(null); + }, + onError: () => toast({ title: "Error", description: "Failed to delete template", type: "error" }) + }); + } + + const activeTemplate = (templates && selectedId) ? templates[selectedId] : null; return (
@@ -32,34 +79,47 @@ export function TemplateTab() {
- {Object.entries(templates).map(([id, t]) => ( + {templates && Object.entries(templates).map(([id, t]) => ( +
+ {/* Delete button visible on hover */} + +
))}
-
{/* Main Content */}
- {activeTemplate ? ( - + {activeTemplate && selectedId ? ( + handleUpdateTemplate(selectedId, t)} + isSaving={updateTemplates.isPending} + /> ) : (
- Select a template + {templates && Object.keys(templates).length === 0 ? "No templates found. Create one." : "Select a template"}
)}
@@ -67,20 +127,95 @@ export function TemplateTab() { ) } -function TemplateDetailView({ template }: { template: AnalysisTemplateSet }) { +function TemplateDetailView({ template, onSave, isSaving }: { template: AnalysisTemplateSet, onSave: (t: AnalysisTemplateSet) => void, isSaving: boolean }) { + const [localTemplate, setLocalTemplate] = useState(template); + const [isDirty, setIsDirty] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [newName, setNewName] = useState(template.name); + + useEffect(() => { + setLocalTemplate(template); + setNewName(template.name); + setIsDirty(false); + }, [template]); + + const handleRename = () => { + setLocalTemplate(prev => ({ ...prev, name: newName })); + setIsRenaming(false); + setIsDirty(true); + } + + const handleAddModule = () => { + const newModuleId = "module_" + Math.random().toString(36).substring(2, 9); + const newModule: AnalysisModuleConfig = { + name: "New Analysis Module", + model_id: "", // Empty to force selection + provider_id: "", + prompt_template: "Analyze the following financial data:\n\n{{data}}\n\nProvide insights on...", + dependencies: [] + }; + + setLocalTemplate(prev => ({ + ...prev, + modules: { + ...prev.modules, + [newModuleId]: newModule + } + })); + setIsDirty(true); + } + + const handleDeleteModule = (moduleId: string) => { + setLocalTemplate(prev => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [moduleId]: removed, ...rest } = prev.modules; + return { ...prev, modules: rest }; + }); + setIsDirty(true); + } + + const handleUpdateModule = (moduleId: string, updatedModule: AnalysisModuleConfig) => { + setLocalTemplate(prev => ({ + ...prev, + modules: { + ...prev.modules, + [moduleId]: updatedModule + } + })); + setIsDirty(true); + } + return (
-
-

{template.name}

+
+ {isRenaming ? ( +
+ setNewName(e.target.value)} + className="h-8 text-lg font-bold" + /> + + +
+ ) : ( +
+

{localTemplate.name}

+ +
+ )}

- 包含 {Object.keys(template.modules).length} 个分析模块。 + 包含 {Object.keys(localTemplate.modules).length} 个分析模块。

- - +
@@ -92,55 +227,178 @@ function TemplateDetailView({ template }: { template: AnalysisTemplateSet }) { 模块流水线 (Pipeline) - +
- {Object.entries(template.modules).map(([moduleId, module]) => ( - - ))} + {Object.entries(localTemplate.modules).length === 0 ? ( +
+ No modules configured. Add one to start. +
+ ) : ( + Object.entries(localTemplate.modules).map(([moduleId, module]) => ( + k !== moduleId)} + allModules={localTemplate.modules} + onDelete={() => handleDeleteModule(moduleId)} + onUpdate={(m) => handleUpdateModule(moduleId, m)} + /> + )) + )}
) } -function ModuleCard({ id, module }: { id: string, module: AnalysisModuleConfig }) { +function ModuleCard({ id, module, availableModules, allModules, onDelete, onUpdate }: { + id: string, + module: AnalysisModuleConfig, + availableModules: string[], + allModules: Record, + onDelete: () => void, + onUpdate: (m: AnalysisModuleConfig) => void +}) { + const [isExpanded, setIsExpanded] = useState(false); + const { data: providers } = useLlmProviders(); + + // Flatten models for select + const allModels: { providerId: string, modelId: string, name: string }[] = []; + const seenKeys = new Set(); + + if (providers) { + Object.entries(providers).forEach(([pid, p]) => { + p.models.forEach((m: { model_id: string, name?: string | null }) => { + const uniqueKey = `${pid}::${m.model_id}`; + if (!seenKeys.has(uniqueKey)) { + seenKeys.add(uniqueKey); + allModels.push({ + providerId: pid, + modelId: m.model_id, + name: `${p.name} - ${m.name || m.model_id}` + }); + } + }); + }); + } + + const handleModelChange = (uniqueId: string) => { + const [pid, mid] = uniqueId.split('::'); + onUpdate({ ...module, provider_id: pid, model_id: mid }); + } + + const currentModelUniqueId = module.provider_id && module.model_id ? `${module.provider_id}::${module.model_id}` : undefined; + + const toggleDependency = (depId: string) => { + const newDeps = module.dependencies.includes(depId) + ? module.dependencies.filter(d => d !== depId) + : [...module.dependencies, depId]; + onUpdate({ ...module, dependencies: newDeps }); + } + return ( - - + + setIsExpanded(!isExpanded)}>
- {module.name} - ID: {id} + + {module.name} + ({id}) + + +
+ + {module.provider_id || "?"} / {module.model_id || "?"} + + {module.dependencies.length > 0 && ( + + Depends on: {module.dependencies.length} + + )} +
- - {module.provider_id} / {module.model_id} - +
+ + +
- - {module.dependencies.length > 0 ? ( -
- Depends on: - {module.dependencies.map(d => ( - {d} - ))} + + {isExpanded && ( + +
+
+ + onUpdate({...module, name: e.target.value})} /> +
+
+ + +
- ) : ( -
No dependencies (Root node)
- )} - -
-

- Prompt: {module.prompt_template.substring(0, 50)}... + +

+ +