feat(config): Implement database-centric LLM provider architecture
本次提交完成了一次全面的架构重构,实现了以数据库为中心的、支持多供应商的LLM配置体系。
**核心变更**:
1. **数据库驱动配置**: 废弃了所有基于本地文件的配置方案 (`analysis-config.json`),将LLM Provider和分析模块的配置作为结构化数据存入数据库的`system_config`表中,由`data-persistence-service`统一管理。
2. **Schema-in-Code**: 在`common-contracts`中定义了所有配置的Rust Structs,作为整个系统的“单一事实源”,确保了端到端的类型安全。
3. **服务职责重构**:
* `data-persistence-service`吸收了配置管理功能,成为配置的“守门人”。
* `config-service-rs`服务已被彻底移除。
* `report-generator-service`重构为可以为每个任务动态创建使用不同Provider配置的LLM客户端。
4. **前端功能增强**:
* 新增了独立的`/llm-config`页面,用于对LLM Providers及其可用模型进行完整的CRUD管理,并支持模型自动发现。
* 重构了旧的`/config`页面,为分析模块提供了级联选择器来精确指定所需的Provider和Model。
此次重构极大地提升了系统的灵活性和可扩展性,完全对齐了“配置即数据”的现代化设计原则。
This commit is contained in:
parent
53d69a00e5
commit
a1e4b265ba
@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
status: 'Pending'
|
||||||
|
created: '2025-11-16'
|
||||||
|
owner: '@lv'
|
||||||
|
---
|
||||||
|
|
||||||
|
# 任务:重构LLM Provider架构 (V2 - 数据库中心化)
|
||||||
|
|
||||||
|
## 1. 任务目标
|
||||||
|
|
||||||
|
为解决当前系统大语言模型(LLM)配置的僵化问题,本次任务旨在重构LLM的配置和调用工作流。我们将实现一个以数据库为中心的、支持多供应商的、结构化的配置体系。该体系将允许每个分析模块都能按需选择其所需的LLM供应商和具体模型,同时保证整个系统的类型安全和数据一致性。
|
||||||
|
|
||||||
|
## 2. 新架构设计:配置即数据
|
||||||
|
|
||||||
|
我们将废弃所有基于本地文件的配置方案 (`analysis-config.json`, `llm-providers.json`),并将所有配置信息作为结构化数据存入数据库。
|
||||||
|
|
||||||
|
### 2.1. 核心原则:Schema-in-Code
|
||||||
|
|
||||||
|
- **不新增数据表**: 我们将利用现有的 `system_config` 表及其 `JSONB` 字段来存储所有配置,无需修改数据库Schema。
|
||||||
|
- **强类型约束**: 所有配置的JSON结构,其“单一事实源”都将是在 **`common-contracts`** crate中定义的Rust Structs。所有服务都必须依赖这些共享的Structs来序列化和反序列化配置数据,从而在应用层面实现强类型约束。
|
||||||
|
|
||||||
|
### 2.2. `common-contracts`中的数据结构定义
|
||||||
|
|
||||||
|
将在`common-contracts`中创建一个新模块(例如 `config_models.rs`),定义如下结构:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In: common-contracts/src/config_models.rs
|
||||||
|
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// 单个启用的模型
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct LlmModel {
|
||||||
|
pub model_id: String, // e.g., "gpt-4o"
|
||||||
|
pub name: Option<String>, // 别名,用于UI显示
|
||||||
|
pub is_active: bool, // 是否在UI中可选
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个LLM供应商的完整配置
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct LlmProvider {
|
||||||
|
pub name: String, // "OpenAI 官方"
|
||||||
|
pub api_base_url: String,
|
||||||
|
pub api_key: String, // 直接明文存储
|
||||||
|
pub models: Vec<LlmModel>, // 该供应商下我们启用的模型列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整个LLM Provider注册中心的数据结构
|
||||||
|
pub type LlmProvidersConfig = HashMap<String, LlmProvider>; // Key: provider_id, e.g., "openai_official"
|
||||||
|
|
||||||
|
// 单个分析模块的配置
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct AnalysisModuleConfig {
|
||||||
|
pub name: String, // "看涨分析"
|
||||||
|
pub provider_id: String, // 引用 LlmProvidersConfig 的 Key
|
||||||
|
pub model_id: String, // 引用 LlmModel 中的 model_id
|
||||||
|
pub prompt_template: String,
|
||||||
|
pub dependencies: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整个分析模块配置集合的数据结构
|
||||||
|
pub type AnalysisModulesConfig = HashMap<String, AnalysisModuleConfig>; // Key: module_id, e.g., "bull_case"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3. `system_config` 表中的数据存储
|
||||||
|
|
||||||
|
我们将使用两个`config_key`来存储这些结构序列化后的JSON:
|
||||||
|
1. **Key: `"llm_providers"`**: 其`config_value`是一个序列化后的`LlmProvidersConfig`。
|
||||||
|
2. **Key: `"analysis_modules"`**: 其`config_value`是一个序列化后的`AnalysisModulesConfig`。
|
||||||
|
|
||||||
|
## 3. 实施步骤
|
||||||
|
|
||||||
|
### 步骤 1: 更新 `common-contracts` (地基)
|
||||||
|
|
||||||
|
1. 在`common-contracts/src/`下创建`config_models.rs`文件。
|
||||||
|
2. 将上述所有Rust Structs定义添加到该文件中,并确保它们在`lib.rs`中被正确导出。
|
||||||
|
|
||||||
|
### 步骤 2: 重构 `data-persistence-service` (配置守门人)
|
||||||
|
|
||||||
|
1. **移除 `config-service-rs`**: 该服务的功能将被`data-persistence-service`完全吸收和取代,可以准备将其从`docker-compose.yml`中移除。
|
||||||
|
2. **实现新的CRUD API**:
|
||||||
|
- `GET /api/v1/configs/llm_providers`: 读取并返回`system_config`中key为`llm_providers`的JSON文档。
|
||||||
|
- `PUT /api/v1/configs/llm_providers`: 接收一个`LlmProvidersConfig`的JSON payload,**使用`common-contracts`中的Structs进行反序列化验证**,验证通过后,将其存入数据库。
|
||||||
|
- `GET /api/v1/configs/analysis_modules`: 读取并返回key为`analysis_modules`的JSON文档。
|
||||||
|
- `PUT /api/v1/configs/analysis_modules`: 接收一个`AnalysisModulesConfig`的JSON payload,进行验证后存入数据库。
|
||||||
|
|
||||||
|
### 步骤 3: 重构 `frontend` (管理UI)
|
||||||
|
|
||||||
|
1. **创建LLM Provider管理页面**:
|
||||||
|
- 提供一个表单,用于新增/编辑`LlmProvider`(对应`llm_providers`JSON中的一个顶级条目)。
|
||||||
|
- 在每个Provider下,提供一个子表单来管理其`models`列表(增、删、改、切换`is_active`状态)。
|
||||||
|
- 实现“自动发现模型”功能,调用`api-gateway`的模型发现端点,让用户可以从中选择模型加入列表。
|
||||||
|
2. **更新分析模块配置页面**:
|
||||||
|
- 为每个分析模块提供两个级联下拉框:
|
||||||
|
1. 第一个下拉框选择`Provider` (数据来自`GET /api/v1/configs/llm_providers`)。
|
||||||
|
2. 第二个下拉框根据第一个的选择,动态加载该Provider下所有`is_active: true`的`Model`。
|
||||||
|
- 更新保存逻辑,以调用`PUT /api/v1/configs/analysis_modules`。
|
||||||
|
|
||||||
|
### 步骤 4: 更新 `api-gateway`
|
||||||
|
|
||||||
|
1. **移除对`config-service-rs`的代理**。
|
||||||
|
2. **代理新的配置API**: 将所有`/api/v1/configs/*`的请求正确地代理到`data-persistence-service`。
|
||||||
|
3. **实现模型发现端点**:
|
||||||
|
- 创建`GET /api/v1/discover-models/{provider_id}`。
|
||||||
|
- 该端点会先调用`data-persistence-service`获取指定provider的`api_base_url`和`api_key`。
|
||||||
|
- 然后使用这些信息向LLM供应商的官方`/models`接口发起请求,并将结果返回给前端。
|
||||||
|
|
||||||
|
### 步骤 5: 重构 `report-generator-service` (最终消费者)
|
||||||
|
|
||||||
|
1. **移除旧配置**:
|
||||||
|
- 修改`docker-compose.yml`,移除所有旧的`LLM_*`环境变量。
|
||||||
|
2. **重构工作流**:
|
||||||
|
- 当收到任务时(例如`bull_case`),它将:
|
||||||
|
a. 并行调用`data-persistence-service`的`GET /api/v1/configs/llm_providers`和`GET /api/v1/configs/analysis_modules`接口,获取完整的配置。
|
||||||
|
b. **使用`common-contracts`中的Structs反序列化**这两个JSON响应,得到类型安全的`LlmProvidersConfig`和`AnalysisModulesConfig`对象。
|
||||||
|
c. 通过`analysis_config["bull_case"]`找到`provider_id`和`model_id`。
|
||||||
|
d. 通过`providers_config[provider_id]`找到对应的`api_base_url`和`api_key`。
|
||||||
|
e. 动态创建`LlmClient`实例,并执行任务。
|
||||||
|
|
||||||
|
## 4. 验收标准
|
||||||
|
|
||||||
|
- ✅ `common-contracts` crate中包含了所有新定义的配置Structs。
|
||||||
|
- ✅ `data-persistence-service`提供了稳定、类型安全的API来管理存储在`system_config`表中的配置。
|
||||||
|
- ✅ `config-service-rs`服务已安全移除。
|
||||||
|
- ✅ 前端提供了一个功能完善的UI,用于管理LLM Providers、Models,并能将它们正确地指派给各个分析模块。
|
||||||
|
- ✅ `report-generator-service`能够正确地、动态地使用数据库中的配置,为不同的分析模块调用不同的LLM Provider和模型。
|
||||||
|
|
||||||
|
## 6. 任务实施清单 (TODO List)
|
||||||
|
|
||||||
|
### 阶段一:定义数据契约 (`common-contracts`)
|
||||||
|
- [x] 在 `src` 目录下创建 `config_models.rs` 文件。
|
||||||
|
- [x] 在 `config_models.rs` 中定义 `LlmModel`, `LlmProvider`, `LlmProvidersConfig`, `AnalysisModuleConfig`, `AnalysisModulesConfig` 等所有Structs。
|
||||||
|
- [x] 在 `lib.rs` 中正确导出 `config_models` 模块,使其对其他服务可见。
|
||||||
|
|
||||||
|
### 阶段二:实现配置的持久化与服务 (`data-persistence-service`)
|
||||||
|
- [x] **[API]** 实现 `GET /api/v1/configs/llm_providers` 端点。
|
||||||
|
- [x] **[API]** 实现 `PUT /api/v1/configs/llm_providers` 端点,并确保使用 `common-contracts` 中的Structs进行反序列化验证。
|
||||||
|
- [x] **[API]** 实现 `GET /api/v1/configs/analysis_modules` 端点。
|
||||||
|
- [x] **[API]** 实现 `PUT /api/v1/configs/analysis_modules` 端点,并进行相应的验证。
|
||||||
|
- [x] **[系统]** 从 `docker-compose.yml` 中安全移除 `config-service-rs` 服务,因其功能已被本服务吸收。
|
||||||
|
|
||||||
|
### 阶段三:更新API网关与前端 (`api-gateway` & `frontend`)
|
||||||
|
- [x] **[api-gateway]** 更新路由配置,将所有 `/api/v1/configs/*` 的请求代理到 `data-persistence-service`。
|
||||||
|
- [x] **[api-gateway]** 实现 `GET /api/v1/discover-models/{provider_id}` 模型发现代理端点。
|
||||||
|
- [x] **[frontend]** 创建全新的“LLM Provider管理”页面UI骨架。
|
||||||
|
- [x] **[frontend]** 实现调用新配置API对LLM Providers和Models进行增、删、改、查的完整逻辑。
|
||||||
|
- [x] **[frontend]** 在Provider管理页面上,实现“自动发现模型”的功能按钮及其后续的UI交互。
|
||||||
|
- [x] **[frontend]** 重构“分析模块配置”页面,使用级联下拉框来选择Provider和Model。
|
||||||
|
|
||||||
|
### 阶段四:重构报告生成服务 (`report-generator-service`)
|
||||||
|
- [x] **[配置]** 从 `docker-compose.yml` 中移除所有旧的、全局的 `LLM_*` 环境变量。
|
||||||
|
- [x] **[核心逻辑]** 重构服务的工作流,实现从 `data-persistence-service` 动态获取`LlmProvidersConfig`和`AnalysisModulesConfig`。
|
||||||
|
- [x] **[核心逻辑]** 实现动态创建 `LlmClient` 实例的逻辑,使其能够根据任务需求使用不同的Provider配置。
|
||||||
@ -1,11 +1,11 @@
|
|||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
context: { params: Promise<{ symbol: string }> }
|
context: { params: Promise<{ symbol: string }> }
|
||||||
) {
|
) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const { symbol } = await context.params;
|
const { symbol } = await context.params;
|
||||||
const target = `${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`;
|
const target = `${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`;
|
||||||
|
|||||||
@ -1,26 +1,75 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
|
// 聚合新后端的配置,提供给旧前端调用点一个稳定入口
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [providersResp, modulesResp] = await Promise.all([
|
||||||
|
fetch(`${BACKEND_BASE}/configs/llm_providers`, { cache: 'no-store' }),
|
||||||
|
fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' }),
|
||||||
|
]);
|
||||||
|
const providersText = await providersResp.text();
|
||||||
|
const modulesText = await modulesResp.text();
|
||||||
|
let providers: unknown = {};
|
||||||
|
let modules: unknown = {};
|
||||||
|
try { providers = providersText ? JSON.parse(providersText) : {}; } catch { providers = {}; }
|
||||||
|
try { modules = modulesText ? JSON.parse(modulesText) : {}; } catch { modules = {}; }
|
||||||
|
return Response.json({
|
||||||
|
llm_providers: providers,
|
||||||
|
analysis_modules: modules,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response(e?.message || 'Failed to load config', { status: 502 });
|
||||||
}
|
}
|
||||||
const resp = await fetch(`${BACKEND_BASE}/config`);
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 允许前端一次性提交部分配置;根据键路由到新后端
|
||||||
export async function PUT(req: NextRequest) {
|
export async function PUT(req: NextRequest) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const body = await req.text();
|
try {
|
||||||
const resp = await fetch(`${BACKEND_BASE}/config`, {
|
const incoming = await req.json().catch(() => ({}));
|
||||||
|
const tasks: Promise<Response>[] = [];
|
||||||
|
if (incoming.llm_providers) {
|
||||||
|
tasks.push(fetch(`${BACKEND_BASE}/configs/llm_providers`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body,
|
body: JSON.stringify(incoming.llm_providers),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (incoming.analysis_modules) {
|
||||||
|
tasks.push(fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(incoming.analysis_modules),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const results = await Promise.all(tasks);
|
||||||
|
const ok = results.every(r => r.ok);
|
||||||
|
if (!ok) {
|
||||||
|
const texts = await Promise.all(results.map(r => r.text().catch(() => '')));
|
||||||
|
return new Response(JSON.stringify({ error: 'Partial update failed', details: texts }), {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
const text = await resp.text();
|
}
|
||||||
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
|
// 返回最新聚合
|
||||||
|
const [providersResp, modulesResp] = await Promise.all([
|
||||||
|
fetch(`${BACKEND_BASE}/configs/llm_providers`, { cache: 'no-store' }),
|
||||||
|
fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' }),
|
||||||
|
]);
|
||||||
|
const providers = await providersResp.json().catch(() => ({}));
|
||||||
|
const modules = await modulesResp.json().catch(() => ({}));
|
||||||
|
return Response.json({
|
||||||
|
llm_providers: providers,
|
||||||
|
analysis_modules: modules,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
return new Response(e?.message || 'Failed to update config', { status: 502 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,12 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const body = await req.text();
|
// 新后端暂无统一 /config/test;先返回未实现
|
||||||
const resp = await fetch(`${BACKEND_BASE}/config/test`, {
|
const body = await req.text().catch(() => '');
|
||||||
method: 'POST',
|
return Response.json({ success: false, message: 'config/test 未实现', echo: body }, { status: 501 });
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const text = await resp.text();
|
|
||||||
return new Response(text, { status: resp.status, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' } });
|
|
||||||
}
|
}
|
||||||
|
|||||||
34
frontend/src/app/api/configs/analysis_modules/route.ts
Normal file
34
frontend/src/app/api/configs/analysis_modules/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const body = await req.text();
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
34
frontend/src/app/api/configs/llm_providers/route.ts
Normal file
34
frontend/src/app/api/configs/llm_providers/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/configs/llm_providers`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const body = await req.text();
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/configs/llm_providers`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const body = await req.text();
|
const body = await req.text();
|
||||||
const resp = await fetch(`${BACKEND_BASE}/data-requests`, {
|
const resp = await fetch(`${BACKEND_BASE}/data-requests`, {
|
||||||
|
|||||||
22
frontend/src/app/api/discover-models/[provider_id]/route.ts
Normal file
22
frontend/src/app/api/discover-models/[provider_id]/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
context: { params: Promise<{ provider_id: string }> }
|
||||||
|
) {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const { provider_id } = await context.params;
|
||||||
|
const target = `${BACKEND_BASE}/discover-models/${encodeURIComponent(provider_id)}`;
|
||||||
|
const resp = await fetch(target, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,27 +1,58 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
const FRONTEND_BASE = process.env.FRONTEND_INTERNAL_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ slug: string[] }> }
|
context: { params: Promise<{ slug: string[] }> }
|
||||||
) {
|
) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const { slug } = await context.params;
|
const { slug } = await context.params;
|
||||||
const path = slug.join('/');
|
const first = slug?.[0];
|
||||||
const target = `${BACKEND_BASE}/financials/${path}${url.search}`;
|
// 适配旧接口:analysis-config → 新分析模块配置
|
||||||
const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } });
|
if (first === 'analysis-config') {
|
||||||
// 透传后端响应(支持流式 body)
|
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { cache: 'no-store' });
|
||||||
const headers = new Headers();
|
const text = await resp.text();
|
||||||
// 复制关键头,减少代理层缓冲
|
return new Response(text, {
|
||||||
const contentType = resp.headers.get('content-type') || 'application/json; charset=utf-8';
|
status: resp.status,
|
||||||
headers.set('content-type', contentType);
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
const cacheControl = resp.headers.get('cache-control');
|
});
|
||||||
if (cacheControl) headers.set('cache-control', cacheControl);
|
}
|
||||||
const xAccelBuffering = resp.headers.get('x-accel-buffering');
|
// 适配旧接口:config → 聚合配置
|
||||||
if (xAccelBuffering) headers.set('x-accel-buffering', xAccelBuffering);
|
if (first === 'config') {
|
||||||
return new Response(resp.body, { status: resp.status, headers });
|
const resp = await fetch(`${FRONTEND_BASE}/api/config`, { cache: 'no-store' });
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, { status: resp.status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
// 其他旧 financials 端点在新架构中未实现:返回空对象以避免前端 JSON 解析错误
|
||||||
|
return Response.json({}, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ slug: string[] }> }
|
||||||
|
) {
|
||||||
|
if (!BACKEND_BASE) {
|
||||||
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
|
}
|
||||||
|
const { slug } = await context.params;
|
||||||
|
const first = slug?.[0];
|
||||||
|
if (first === 'analysis-config') {
|
||||||
|
const body = await req.text();
|
||||||
|
const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await resp.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
|
||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
// 历史报告列表功能在新架构中由后端持久化服务统一提供。
|
// 历史报告列表功能在新架构中由后端持久化服务统一提供。
|
||||||
// 当前网关未提供“全量列表”接口(需要 symbol 条件),因此此路由返回空集合。
|
// 当前网关未提供“全量列表”接口(需要 symbol 条件),因此此路由返回空集合。
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL;
|
const BACKEND_BASE = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
context: { params: Promise<{ request_id: string }> }
|
context: { params: Promise<{ request_id: string }> }
|
||||||
) {
|
) {
|
||||||
if (!BACKEND_BASE) {
|
if (!BACKEND_BASE) {
|
||||||
return new Response('NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
return new Response('BACKEND_INTERNAL_URL/NEXT_PUBLIC_BACKEND_URL 未配置', { status: 500 });
|
||||||
}
|
}
|
||||||
const { request_id } = await context.params;
|
const { request_id } = await context.params;
|
||||||
const target = `${BACKEND_BASE}/tasks/${encodeURIComponent(request_id)}`;
|
const target = `${BACKEND_BASE}/tasks/${encodeURIComponent(request_id)}`;
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useConfig, updateConfig, testConfig, useAnalysisConfig, updateAnalysisConfig } from '@/hooks/useApi';
|
import {
|
||||||
import { useConfigStore, SystemConfig } from '@/stores/useConfigStore';
|
useConfig, updateConfig, testConfig,
|
||||||
|
useAnalysisModules, updateAnalysisModules, useLlmProviders
|
||||||
|
} from '@/hooks/useApi';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import type { SystemConfig } from '@/stores/useConfigStore';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@ -12,7 +16,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import type { AnalysisConfigResponse } from '@/types';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
// Types are imported from '@/types'
|
||||||
|
import type { AnalysisModulesConfig } from '@/types';
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
// 从 Zustand store 获取全局状态
|
// 从 Zustand store 获取全局状态
|
||||||
@ -20,8 +26,8 @@ export default function ConfigPage() {
|
|||||||
// 使用 SWR hook 加载初始配置
|
// 使用 SWR hook 加载初始配置
|
||||||
useConfig();
|
useConfig();
|
||||||
|
|
||||||
// 加载分析配置
|
// 加载分析配置(统一使用 initialAnalysisModules)
|
||||||
const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig();
|
// const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisModules();
|
||||||
|
|
||||||
// 本地表单状态
|
// 本地表单状态
|
||||||
const [newApiApiKey, setNewApiApiKey] = useState('');
|
const [newApiApiKey, setNewApiApiKey] = useState('');
|
||||||
@ -30,16 +36,9 @@ export default function ConfigPage() {
|
|||||||
const [finnhubApiKey, setFinnhubApiKey] = useState('');
|
const [finnhubApiKey, setFinnhubApiKey] = useState('');
|
||||||
|
|
||||||
// 分析配置的本地状态
|
// 分析配置的本地状态
|
||||||
const [localAnalysisConfig, setLocalAnalysisConfig] = useState<Record<string, {
|
const [localAnalysisModules, setLocalAnalysisModules] = useState<AnalysisModulesConfig>({});
|
||||||
name: string;
|
|
||||||
model: string;
|
|
||||||
prompt_template: string;
|
|
||||||
dependencies?: string[];
|
|
||||||
}>>({});
|
|
||||||
|
|
||||||
// 分析配置保存状态
|
// 分析配置保存状态(状态定义在下方统一维护)
|
||||||
const [savingAnalysis, setSavingAnalysis] = useState(false);
|
|
||||||
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
|
|
||||||
|
|
||||||
// 测试结果状态
|
// 测试结果状态
|
||||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string } | null>>({});
|
||||||
@ -48,16 +47,45 @@ export default function ConfigPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveMessage, setSaveMessage] = useState('');
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
|
||||||
// 初始化分析配置的本地状态
|
// --- New State for Analysis Modules ---
|
||||||
|
const { data: llmProviders } = useLlmProviders();
|
||||||
|
const { data: initialAnalysisModules, mutate } = useAnalysisModules();
|
||||||
|
const [isSavingAnalysis, setIsSavingAnalysis] = useState(false);
|
||||||
|
const [analysisSaveMessage, setAnalysisSaveMessage] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (analysisConfig?.analysis_modules) {
|
if (initialAnalysisModules) {
|
||||||
setLocalAnalysisConfig(analysisConfig.analysis_modules);
|
setLocalAnalysisModules(initialAnalysisModules);
|
||||||
}
|
}
|
||||||
}, [analysisConfig]);
|
}, [initialAnalysisModules]);
|
||||||
|
|
||||||
|
const handleAnalysisChange = (moduleId: string, field: string, value: string) => {
|
||||||
|
setLocalAnalysisModules(prev => ({
|
||||||
|
...prev,
|
||||||
|
[moduleId]: { ...prev[moduleId], [field]: value }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAnalysis = async () => {
|
||||||
|
setIsSavingAnalysis(true);
|
||||||
|
setAnalysisSaveMessage('保存中...');
|
||||||
|
try {
|
||||||
|
const updated = await updateAnalysisModules(localAnalysisModules);
|
||||||
|
await mutate(updated, false);
|
||||||
|
setAnalysisSaveMessage('分析配置保存成功!');
|
||||||
|
} catch (e: any) {
|
||||||
|
setAnalysisSaveMessage(`保存失败: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsSavingAnalysis(false);
|
||||||
|
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化分析配置的本地状态(已在 initialAnalysisModules 的 Effect 中处理)
|
||||||
|
|
||||||
// 更新分析配置中的某个字段
|
// 更新分析配置中的某个字段
|
||||||
const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => {
|
const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => {
|
||||||
setLocalAnalysisConfig(prev => ({
|
setLocalAnalysisModules(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[type]: {
|
[type]: {
|
||||||
...prev[type],
|
...prev[type],
|
||||||
@ -68,7 +96,7 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
// 更新分析模块的依赖
|
// 更新分析模块的依赖
|
||||||
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
|
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
|
||||||
setLocalAnalysisConfig(prev => {
|
setLocalAnalysisModules(prev => {
|
||||||
const currentConfig = prev[type];
|
const currentConfig = prev[type];
|
||||||
const currentDeps = currentConfig.dependencies || [];
|
const currentDeps = currentConfig.dependencies || [];
|
||||||
|
|
||||||
@ -87,24 +115,7 @@ export default function ConfigPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存分析配置
|
// 旧版保存逻辑已移除,统一使用 handleSaveAnalysis
|
||||||
const handleSaveAnalysisConfig = async () => {
|
|
||||||
setSavingAnalysis(true);
|
|
||||||
setAnalysisSaveMessage('保存中...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await updateAnalysisConfig({
|
|
||||||
analysis_modules: localAnalysisConfig
|
|
||||||
});
|
|
||||||
await mutateAnalysisConfig(updated);
|
|
||||||
setAnalysisSaveMessage('保存成功!');
|
|
||||||
} catch (e: any) {
|
|
||||||
setAnalysisSaveMessage(`保存失败: ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
setSavingAnalysis(false);
|
|
||||||
setTimeout(() => setAnalysisSaveMessage(''), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateConfig = () => {
|
const validateConfig = () => {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
@ -241,9 +252,6 @@ export default function ConfigPage() {
|
|||||||
const importedConfig = JSON.parse(e.target?.result as string);
|
const importedConfig = JSON.parse(e.target?.result as string);
|
||||||
|
|
||||||
// 验证导入的配置格式
|
// 验证导入的配置格式
|
||||||
if (importedConfig.database?.url) {
|
|
||||||
setDbUrl(importedConfig.database.url);
|
|
||||||
}
|
|
||||||
if (importedConfig.new_api?.base_url) {
|
if (importedConfig.new_api?.base_url) {
|
||||||
setNewApiBaseUrl(importedConfig.new_api.base_url);
|
setNewApiBaseUrl(importedConfig.new_api.base_url);
|
||||||
}
|
}
|
||||||
@ -400,102 +408,63 @@ export default function ConfigPage() {
|
|||||||
<CardDescription>配置各个分析模块的模型和提示词</CardDescription>
|
<CardDescription>配置各个分析模块的模型和提示词</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{Object.entries(localAnalysisConfig).map(([type, config]) => {
|
{Object.entries(localAnalysisModules).map(([moduleId, config]) => {
|
||||||
const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type);
|
const availableModels = llmProviders?.[config.provider_id]?.models.filter(m => m.is_active) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={type} className="space-y-4 p-4 border rounded-lg">
|
<div key={moduleId} className="space-y-4 p-4 border rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<h3 className="text-lg font-semibold">{config.name || moduleId}</h3>
|
||||||
<h3 className="text-lg font-semibold">{config.name || type}</h3>
|
|
||||||
<Badge variant="secondary">{type}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${type}-name`}>显示名称</Label>
|
<Label>LLM Provider</Label>
|
||||||
<Input
|
<Select
|
||||||
id={`${type}-name`}
|
value={config.provider_id}
|
||||||
value={config.name || ''}
|
onValueChange={(value) => handleAnalysisChange(moduleId, 'provider_id', value)}
|
||||||
onChange={(e) => updateAnalysisField(type, 'name', e.target.value)}
|
|
||||||
placeholder="分析模块显示名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={`${type}-model`}>模型名称</Label>
|
|
||||||
<Input
|
|
||||||
id={`${type}-model`}
|
|
||||||
value={config.model || ''}
|
|
||||||
onChange={(e) => updateAnalysisField(type, 'model', e.target.value)}
|
|
||||||
placeholder="例如: gemini-1.5-pro"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
在 AI 服务中配置的模型名称
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>模块依赖</Label>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 rounded-lg border p-4">
|
|
||||||
{otherModuleKeys.map(depKey => (
|
|
||||||
<div key={depKey} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`${type}-dep-${depKey}`}
|
|
||||||
checked={config.dependencies?.includes(depKey)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
updateAnalysisDependencies(type, depKey, !!checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`${type}-dep-${depKey}`}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
>
|
||||||
{localAnalysisConfig[depKey]?.name || depKey}
|
<SelectTrigger>
|
||||||
</label>
|
<SelectValue placeholder="选择一个 Provider" />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{llmProviders && Object.entries(llmProviders).map(([pId, p]) => (
|
||||||
|
<SelectItem key={pId} value={pId}>{p.name}</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Model</Label>
|
||||||
|
<Select
|
||||||
|
value={config.model_id}
|
||||||
|
onValueChange={(value) => handleAnalysisChange(moduleId, 'model_id', value)}
|
||||||
|
disabled={!config.provider_id || availableModels.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择一个 Model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableModels.map(m => (
|
||||||
|
<SelectItem key={m.model_id} value={m.model_id}>{m.name || m.model_id}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
选择此模块在生成时需要依赖的其他模块。选中的模块结果将通过占位符注入提示词模板。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${type}-prompt`}>提示词模板</Label>
|
<Label htmlFor={`${moduleId}-prompt`}>提示词模板</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${type}-prompt`}
|
id={`${moduleId}-prompt`}
|
||||||
value={config.prompt_template || ''}
|
value={config.prompt_template || ''}
|
||||||
onChange={(e) => updateAnalysisField(type, 'prompt_template', e.target.value)}
|
onChange={(e) => handleAnalysisChange(moduleId, 'prompt_template', e.target.value)}
|
||||||
placeholder="提示词模板,支持 {company_name}, {ts_code}, {financial_data} 占位符"
|
|
||||||
rows={10}
|
rows={10}
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
基础占位符: <code>{`{company_name}`}</code>, <code>{`{ts_code}`}</code>, <code>{`{financial_data}`}</code>.
|
|
||||||
<br />
|
|
||||||
其他模块:{' '}
|
|
||||||
{otherModuleKeys.length > 0
|
|
||||||
? otherModuleKeys.map((key, index) => (
|
|
||||||
<span key={key}>
|
|
||||||
<code>{`{${key}}`}</code>
|
|
||||||
{index < otherModuleKeys.length - 1 ? ', ' : ''}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
: '无'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 pt-4">
|
<div className="flex items-center gap-4 pt-4">
|
||||||
<Button
|
<Button onClick={handleSaveAnalysis} disabled={isSavingAnalysis}>
|
||||||
onClick={handleSaveAnalysisConfig}
|
{isSavingAnalysis ? '保存中...' : '保存分析配置'}
|
||||||
disabled={savingAnalysis}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{savingAnalysis ? '保存中...' : '保存分析配置'}
|
|
||||||
</Button>
|
</Button>
|
||||||
{analysisSaveMessage && (
|
{analysisSaveMessage && (
|
||||||
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
|
<span className={`text-sm ${analysisSaveMessage.includes('成功') ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
|||||||
@ -2,18 +2,32 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
async function getMarkdownContent() {
|
async function getMarkdownContent() {
|
||||||
// process.cwd() is the root of the Next.js project (the 'frontend' directory)
|
// process.cwd() is the root of the Next.js project (the 'frontend' directory)
|
||||||
const mdPath = path.join(process.cwd(), '..', 'docs', 'user-guide.md');
|
const root = path.join(process.cwd(), '..', 'docs');
|
||||||
|
const candidates = [
|
||||||
|
path.join(root, '1_requirements', '20251109_[Active]_user-guide.md'),
|
||||||
|
path.join(root, '1_requirements', '20251108_[Active]_requirements.md'),
|
||||||
|
path.join(root, '2_architecture', '20251116_[Active]_system_architecture.md'),
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(mdPath, 'utf8');
|
const content = await fs.readFile(p, 'utf8');
|
||||||
return content;
|
return content;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to read user-guide.md:", error);
|
// try next
|
||||||
return "# 文档加载失败\n\n无法读取 `docs/user-guide.md` 文件。请检查文件是否存在以及服务器权限。";
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'# 文档加载失败',
|
||||||
|
'',
|
||||||
|
'未找到以下任意文档:',
|
||||||
|
'- docs/1_requirements/20251109_[Active]_user-guide.md',
|
||||||
|
'- docs/1_requirements/20251108_[Active]_requirements.md',
|
||||||
|
'- docs/2_architecture/20251116_[Active]_system_architecture.md',
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DocsPage() {
|
export default async function DocsPage() {
|
||||||
|
|||||||
404
frontend/src/app/llm-config/page.tsx
Normal file
404
frontend/src/app/llm-config/page.tsx
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useLlmProviders, updateLlmProviders, discoverProviderModels } from '@/hooks/useApi';
|
||||||
|
import type { LlmProvider, LlmModel } from '@/types';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLlmConfigStore } from '@/stores/useLlmConfigStore';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
|
// Main Page Component
|
||||||
|
export default function LlmConfigPage() {
|
||||||
|
const { data: initialProviders, error, isLoading, mutate } = useLlmProviders();
|
||||||
|
const {
|
||||||
|
providers,
|
||||||
|
setInitialProviders,
|
||||||
|
openModal,
|
||||||
|
modal,
|
||||||
|
deleteProvider,
|
||||||
|
} = useLlmConfigStore();
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialProviders) {
|
||||||
|
setInitialProviders(initialProviders);
|
||||||
|
}
|
||||||
|
}, [initialProviders, setInitialProviders]);
|
||||||
|
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setSaveMessage('保存中...');
|
||||||
|
try {
|
||||||
|
const updated = await updateLlmProviders(providers);
|
||||||
|
setInitialProviders(updated);
|
||||||
|
await mutate(updated, false); // revalidate SWR
|
||||||
|
setSaveMessage('保存成功!');
|
||||||
|
} catch (e: any) {
|
||||||
|
setSaveMessage(`保存失败: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
setTimeout(() => setSaveMessage(''), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-center p-8">加载中...</div>;
|
||||||
|
if (error) return <div className="text-center p-8 text-red-500">加载配置失败: {error.message}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-6 space-y-6">
|
||||||
|
<header className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">LLM Provider 配置</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
在这里管理所有可用的大语言模型(LLM)供应商及其模型。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => openModal({ type: 'addProvider' })}>新增 Provider</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Object.entries(providers).map(([providerId, provider]) => (
|
||||||
|
<Card key={providerId}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{provider.name}</CardTitle>
|
||||||
|
<CardDescription>ID: {providerId}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openModal({ type: 'editProvider', providerId })}>编辑</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => deleteProvider(providerId)}>删除</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
API Base URL: <code className="font-mono">{provider.api_base_url}</code>
|
||||||
|
</p>
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">模型管理</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openModal({ type: 'addModel', providerId })}>新增模型</Button>
|
||||||
|
<Button variant="outline" onClick={() => openModal({ type: 'discoverModels', providerId })}>发现模型</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{provider.models.map((model, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 border-b last:border-b-0">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{model.model_id}</p>
|
||||||
|
{model.name && <p className="text-sm text-muted-foreground">{model.name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge variant={model.is_active ? 'default' : 'secondary'}>
|
||||||
|
{model.is_active ? '已激活' : '未激活'}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openModal({ type: 'editModel', providerId, modelIndex: index })}>编辑</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-6 border-t">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{saveMessage}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button size="lg" onClick={handleSaveAll} disabled={isSaving}>
|
||||||
|
{isSaving ? '保存中...' : '保存所有更改'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal.type !== 'closed' && <ConfigModal />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Modal Components ---
|
||||||
|
|
||||||
|
function ConfigModal() {
|
||||||
|
const { modal } = useLlmConfigStore();
|
||||||
|
|
||||||
|
if (modal.type === 'addProvider' || modal.type === 'editProvider') {
|
||||||
|
return <ProviderEditModal />;
|
||||||
|
}
|
||||||
|
if (modal.type === 'addModel' || modal.type === 'editModel') {
|
||||||
|
return <ModelEditModal />;
|
||||||
|
}
|
||||||
|
if (modal.type === 'discoverModels') {
|
||||||
|
return <DiscoverModelsModal />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderFormData = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
api_base_url?: string;
|
||||||
|
api_key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProviderEditModal() {
|
||||||
|
const { modal, closeModal, providers, addProvider, updateProvider } = useLlmConfigStore();
|
||||||
|
const [formData, setFormData] = useState<ProviderFormData>({});
|
||||||
|
|
||||||
|
const isAdd = modal.type === 'addProvider';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (modal.type === 'editProvider') {
|
||||||
|
const provider = providers[modal.providerId];
|
||||||
|
setFormData({ ...provider, id: modal.providerId });
|
||||||
|
} else {
|
||||||
|
setFormData({});
|
||||||
|
}
|
||||||
|
}, [modal, providers]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isAdd) {
|
||||||
|
const { id, ...providerData } = formData;
|
||||||
|
const providerId = (id || '').trim();
|
||||||
|
if (!providerId) return;
|
||||||
|
const providerObj: LlmProvider = {
|
||||||
|
name: providerData.name || '',
|
||||||
|
api_base_url: providerData.api_base_url || '',
|
||||||
|
api_key: providerData.api_key || '',
|
||||||
|
models: [],
|
||||||
|
};
|
||||||
|
addProvider(providerId, providerObj);
|
||||||
|
} else if (modal.type === 'editProvider') {
|
||||||
|
const { id, ...providerData } = formData;
|
||||||
|
const providerId = (id || '').trim();
|
||||||
|
if (!providerId) return;
|
||||||
|
const existing = providers[providerId] as LlmProvider | undefined;
|
||||||
|
const providerObj: LlmProvider = {
|
||||||
|
name: providerData.name ?? existing?.name ?? '',
|
||||||
|
api_base_url: providerData.api_base_url ?? existing?.api_base_url ?? '',
|
||||||
|
api_key: providerData.api_key ?? existing?.api_key ?? '',
|
||||||
|
models: existing?.models ?? [],
|
||||||
|
};
|
||||||
|
updateProvider(providerId, providerObj);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={closeModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{isAdd ? '新增 Provider' : '编辑 Provider'}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{isAdd && <div className="space-y-2">
|
||||||
|
<Label htmlFor="id">Provider ID</Label>
|
||||||
|
<Input id="id" name="id" value={formData.id || ''} onChange={handleChange} placeholder="e.g., openai_official"/>
|
||||||
|
</div>}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">名称</Label>
|
||||||
|
<Input id="name" name="name" value={formData.name || ''} onChange={handleChange} placeholder="e.g., OpenAI 官方"/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api_base_url">API Base URL</Label>
|
||||||
|
<Input id="api_base_url" name="api_base_url" value={formData.api_base_url || ''} onChange={handleChange} placeholder="https://api.openai.com/v1"/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="api_key">API Key</Label>
|
||||||
|
<Input id="api_key" name="api_key" type="password" value={formData.api_key || ''} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeModal}>取消</Button>
|
||||||
|
<Button onClick={handleSubmit}>保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelFormData = {
|
||||||
|
model_id?: string;
|
||||||
|
name?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ModelEditModal() {
|
||||||
|
const { modal, closeModal, providers, addModel, updateModel } = useLlmConfigStore();
|
||||||
|
const [formData, setFormData] = useState<ModelFormData>({ is_active: true });
|
||||||
|
|
||||||
|
const isAdd = modal.type === 'addModel';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (modal.type === 'editModel') {
|
||||||
|
const model = providers[modal.providerId].models[modal.modelIndex];
|
||||||
|
setFormData(model);
|
||||||
|
} else {
|
||||||
|
setFormData({ is_active: true });
|
||||||
|
}
|
||||||
|
}, [modal, providers]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isAdd && modal.type === 'addModel') {
|
||||||
|
if (!formData.model_id) return;
|
||||||
|
const modelObj: LlmModel = {
|
||||||
|
model_id: formData.model_id,
|
||||||
|
name: formData.name ?? null,
|
||||||
|
is_active: !!formData.is_active,
|
||||||
|
};
|
||||||
|
addModel(modal.providerId, modelObj);
|
||||||
|
} else if (modal.type === 'editModel') {
|
||||||
|
if (!formData.model_id) return;
|
||||||
|
const modelObj: LlmModel = {
|
||||||
|
model_id: formData.model_id,
|
||||||
|
name: formData.name ?? null,
|
||||||
|
is_active: !!formData.is_active,
|
||||||
|
};
|
||||||
|
updateModel(modal.providerId, modal.modelIndex, modelObj);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={closeModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{isAdd ? '新增 Model' : '编辑 Model'}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model_id">Model ID</Label>
|
||||||
|
<Input id="model_id" name="model_id" value={formData.model_id || ''} onChange={handleChange} placeholder="e.g., gpt-4o"/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">别名 (可选)</Label>
|
||||||
|
<Input id="name" name="name" value={formData.name || ''} onChange={handleChange} placeholder="e.g., GPT-4o"/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="is_active" name="is_active" checked={!!formData.is_active} onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, is_active: checked }))}/>
|
||||||
|
<Label htmlFor="is_active">在UI中激活</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeModal}>取消</Button>
|
||||||
|
<Button onClick={handleSubmit}>保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveredModel = { id: string; name?: string };
|
||||||
|
|
||||||
|
function DiscoverModelsModal() {
|
||||||
|
const { modal, closeModal, providers, addModel } = useLlmConfigStore();
|
||||||
|
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedModels, setSelectedModels] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const providerId = modal.type === 'discoverModels' ? modal.providerId : '';
|
||||||
|
const existingModelIds = useMemo(() => {
|
||||||
|
if (!providerId) return new Set();
|
||||||
|
return new Set(providers[providerId].models.map(m => m.model_id));
|
||||||
|
}, [providerId, providers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providerId) return;
|
||||||
|
|
||||||
|
const fetchModels = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await discoverProviderModels(providerId);
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.provider_error || result.error);
|
||||||
|
}
|
||||||
|
// OpenAI format is { data: [...] }, others might be [...]
|
||||||
|
const models = (result.data || result) as DiscoveredModel[] | unknown;
|
||||||
|
if (Array.isArray(models)) {
|
||||||
|
setDiscoveredModels(models as DiscoveredModel[]);
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid response format from provider");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchModels();
|
||||||
|
}, [providerId]);
|
||||||
|
|
||||||
|
const handleToggleModel = (modelId: string, checked: boolean) => {
|
||||||
|
setSelectedModels(prev => ({ ...prev, [modelId]: checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
if (!providerId) return;
|
||||||
|
Object.entries(selectedModels).forEach(([modelId, isSelected]) => {
|
||||||
|
if (isSelected && !existingModelIds.has(modelId)) {
|
||||||
|
addModel(providerId, { model_id: modelId, name: null, is_active: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={closeModal}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader><DialogTitle>从 {providerId} 发现并导入模型</DialogTitle></DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
{isLoading && <p>正在从供应商API加载模型列表...</p>}
|
||||||
|
{error && <p className="text-red-500">加载失败: {error}</p>}
|
||||||
|
{!isLoading && !error && (
|
||||||
|
<ScrollArea className="h-72">
|
||||||
|
<div className="space-y-2 pr-4">
|
||||||
|
{discoveredModels.map(model => (
|
||||||
|
<div key={model.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={model.id}
|
||||||
|
onCheckedChange={(checked) => handleToggleModel(model.id, !!checked)}
|
||||||
|
disabled={existingModelIds.has(model.id)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={model.id}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{model.id} {existingModelIds.has(model.id) && "(已存在)"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeModal}>取消</Button>
|
||||||
|
<Button onClick={handleImport}>导入选中模型</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, useSearchParams } from 'next/navigation';
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis, useSnapshot, useRealtimeQuote } from '@/hooks/useApi';
|
import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, useSnapshot, useRealtimeQuote, useDataRequest, useTaskProgress } from '@/hooks/useApi';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
|
||||||
@ -13,7 +13,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
||||||
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
|
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { formatReportPeriod } from '@/lib/financial-utils';
|
import { formatReportPeriod } from '@/lib/financial-utils';
|
||||||
|
|
||||||
export default function ReportPage() {
|
export default function ReportPage() {
|
||||||
@ -107,10 +107,14 @@ export default function ReportPage() {
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}>>([]);
|
}>>([]);
|
||||||
|
// 新架构:触发分析与查看任务进度
|
||||||
|
const { trigger: triggerAnalysis, isMutating: triggering } = useDataRequest();
|
||||||
|
const [requestId, setRequestId] = useState<string | null>(null);
|
||||||
|
const { progress: taskProgress } = useTaskProgress(requestId);
|
||||||
|
|
||||||
|
|
||||||
const runFullAnalysis = async () => {
|
const runFullAnalysis = useCallback(async () => {
|
||||||
if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
|
if (!analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,14 +143,14 @@ export default function ReportPage() {
|
|||||||
|
|
||||||
// 触发顺序执行
|
// 触发顺序执行
|
||||||
setManualRunKey((k) => k + 1);
|
setManualRunKey((k) => k + 1);
|
||||||
};
|
}, [analysisConfig?.analysis_modules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (financials && !fullAnalysisTriggeredRef.current) {
|
if (analysisConfig?.analysis_modules && !fullAnalysisTriggeredRef.current) {
|
||||||
fullAnalysisTriggeredRef.current = true;
|
fullAnalysisTriggeredRef.current = true;
|
||||||
runFullAnalysis();
|
runFullAnalysis();
|
||||||
}
|
}
|
||||||
}, [financials]);
|
}, [analysisConfig?.analysis_modules, runFullAnalysis]);
|
||||||
|
|
||||||
// 计算完成比例
|
// 计算完成比例
|
||||||
const completionProgress = useMemo(() => {
|
const completionProgress = useMemo(() => {
|
||||||
@ -188,8 +192,9 @@ export default function ReportPage() {
|
|||||||
if (!financialConfig?.api_groups) return {};
|
if (!financialConfig?.api_groups) return {};
|
||||||
|
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
Object.values(financialConfig.api_groups).forEach(metrics => {
|
const groups = Object.values((financialConfig as Partial<import('@/types').FinancialConfigResponse>).api_groups || {}) as import('@/types').FinancialMetricConfig[][];
|
||||||
metrics.forEach(metric => {
|
groups.forEach((metrics) => {
|
||||||
|
(metrics || []).forEach((metric) => {
|
||||||
if (metric.tushareParam && metric.displayText) {
|
if (metric.tushareParam && metric.displayText) {
|
||||||
map[metric.tushareParam] = metric.displayText;
|
map[metric.tushareParam] = metric.displayText;
|
||||||
}
|
}
|
||||||
@ -201,8 +206,9 @@ export default function ReportPage() {
|
|||||||
const metricGroupMap = useMemo(() => {
|
const metricGroupMap = useMemo(() => {
|
||||||
if (!financialConfig?.api_groups) return {} as Record<string, string>;
|
if (!financialConfig?.api_groups) return {} as Record<string, string>;
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
Object.entries(financialConfig.api_groups).forEach(([groupName, metrics]) => {
|
const entries = Object.entries((financialConfig as Partial<import('@/types').FinancialConfigResponse>).api_groups || {}) as [string, import('@/types').FinancialMetricConfig[]][];
|
||||||
metrics.forEach((metric) => {
|
entries.forEach(([groupName, metrics]) => {
|
||||||
|
(metrics || []).forEach((metric) => {
|
||||||
if (metric.tushareParam) {
|
if (metric.tushareParam) {
|
||||||
map[metric.tushareParam] = groupName;
|
map[metric.tushareParam] = groupName;
|
||||||
}
|
}
|
||||||
@ -543,7 +549,7 @@ export default function ReportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
runAnalysesSequentially();
|
runAnalysesSequentially();
|
||||||
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]);
|
}, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, normalizedMarket, unifiedSymbol, startTime, manualRunKey]);
|
||||||
|
|
||||||
const stopAll = () => {
|
const stopAll = () => {
|
||||||
stopRequestedRef.current = true;
|
stopRequestedRef.current = true;
|
||||||
@ -686,8 +692,14 @@ export default function ReportPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className="w-40 flex-shrink-0">
|
<Card className="w-40 flex-shrink-0">
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
<Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}>
|
<Button
|
||||||
{isAnalysisRunningRef.current ? '正在分析…' : '开始分析'}
|
onClick={async () => {
|
||||||
|
const reqId = await triggerAnalysis(unifiedSymbol, normalizedMarket || '');
|
||||||
|
if (reqId) setRequestId(reqId);
|
||||||
|
}}
|
||||||
|
disabled={triggering}
|
||||||
|
>
|
||||||
|
{triggering ? '触发中…' : '触发分析(新架构)'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
|
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
|
||||||
停止
|
停止
|
||||||
@ -697,6 +709,18 @@ export default function ReportPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="w-80">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">任务进度(新架构)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-xs text-muted-foreground">
|
||||||
|
{requestId ? (
|
||||||
|
<pre>{JSON.stringify(taskProgress || {}, null, 2)}</pre>
|
||||||
|
) : (
|
||||||
|
<div>未触发任务</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<Card className="w-80">
|
<Card className="w-80">
|
||||||
<CardHeader className="flex flex-col space-y-2 pb-2">
|
<CardHeader className="flex flex-col space-y-2 pb-2">
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
@ -760,13 +784,17 @@ export default function ReportPage() {
|
|||||||
<span className="inline-flex items-center gap-2"><Spinner className="size-3" /> 正在获取实时报价…</span>
|
<span className="inline-flex items-center gap-2"><Spinner className="size-3" /> 正在获取实时报价…</span>
|
||||||
) : realtimeError ? (
|
) : realtimeError ? (
|
||||||
<span className="text-red-500">实时报价不可用</span>
|
<span className="text-red-500">实时报价不可用</span>
|
||||||
) : realtime ? (
|
) : (() => {
|
||||||
<span>
|
const priceRaw = (realtime as any)?.price;
|
||||||
价格 {realtime.price.toLocaleString()}({new Date(realtime.ts).toLocaleString()})
|
const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw);
|
||||||
</span>
|
const tsRaw = (realtime as any)?.ts;
|
||||||
) : (
|
const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw));
|
||||||
<span>暂无最新报价</span>
|
const tsText = tsDate && !isNaN(tsDate.getTime()) ? `(${tsDate.toLocaleString()})` : '';
|
||||||
)}
|
if (Number.isFinite(priceNum)) {
|
||||||
|
return <span>价格 {priceNum.toLocaleString()} {tsText}</span>;
|
||||||
|
}
|
||||||
|
return <span>暂无最新报价</span>;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1479,7 +1507,7 @@ export default function ReportPage() {
|
|||||||
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
<div className="ml-6 text-sm text-muted-foreground space-y-1">
|
||||||
<div>总耗时: {formatMs(totalElapsedMs)}</div>
|
<div>总耗时: {formatMs(totalElapsedMs)}</div>
|
||||||
{financials?.meta?.steps && (
|
{financials?.meta?.steps && (
|
||||||
<div>财务数据完成步骤: {financials.meta.steps.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div>
|
<div>财务数据完成步骤: {(financials.meta.steps as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).length}</div>
|
||||||
)}
|
)}
|
||||||
{analysisRecords.length > 0 && (
|
{analysisRecords.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
|||||||
const h = await headers()
|
const h = await headers()
|
||||||
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
||||||
const proto = h.get('x-forwarded-proto') || 'http'
|
const proto = h.get('x-forwarded-proto') || 'http'
|
||||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
const base = process.env.FRONTEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
||||||
const resp = await fetch(`${base}/api/reports/${encodeURIComponent(id)}`, { cache: 'no-store' })
|
const resp = await fetch(`${base}/api/reports/${encodeURIComponent(id)}`, { cache: 'no-store' })
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return <div className="text-sm text-red-600">未找到报告</div>
|
return <div className="text-sm text-red-600">未找到报告</div>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export default async function ReportsPage() {
|
|||||||
const h = await headers()
|
const h = await headers()
|
||||||
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000'
|
||||||
const proto = h.get('x-forwarded-proto') || 'http'
|
const proto = h.get('x-forwarded-proto') || 'http'
|
||||||
const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
const base = process.env.FRONTEND_INTERNAL_URL || process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`
|
||||||
const { items, total } = await fetchReports(base)
|
const { items, total } = await fetchReports(base)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
33
frontend/src/components/ui/dialog.tsx
Normal file
33
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type BaseProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DialogProps = BaseProps & {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dialog: React.FC<DialogProps> = ({ children }) => {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DialogContent: React.FC<BaseProps> = ({ children, className }) => {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DialogHeader: React.FC<BaseProps> = ({ children }) => {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DialogTitle: React.FC<BaseProps> = ({ children }) => {
|
||||||
|
return <h3>{children}</h3>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DialogFooter: React.FC<BaseProps> = ({ children }) => {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
17
frontend/src/components/ui/scroll-area.tsx
Normal file
17
frontend/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type ScrollAreaProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScrollArea: React.FC<ScrollAreaProps> = ({ children, className, style }) => {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ overflow: 'auto', ...style }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
24
frontend/src/components/ui/switch.tsx
Normal file
24
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type SwitchProps = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Switch: React.FC<SwitchProps> = ({ id, name, checked, onCheckedChange, className }) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type="checkbox"
|
||||||
|
className={className}
|
||||||
|
checked={!!checked}
|
||||||
|
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -405,7 +405,7 @@ export function useRowConfig(companyCode: string | null, rowIds: string[]) {
|
|||||||
console.warn('Failed to import config:', error);
|
console.warn('Failed to import config:', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [companyCode, stableRowIds]);
|
}, [companyCode, stableRowIds, saveConfigSafely]);
|
||||||
|
|
||||||
// 获取配置统计信息
|
// 获取配置统计信息
|
||||||
const getConfigStats = useCallback(() => {
|
const getConfigStats = useCallback(() => {
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
import useSWR, { SWRConfiguration } from "swr";
|
import useSWR, { SWRConfiguration } from "swr";
|
||||||
import { Financials, FinancialsIdentifier } from "@/types";
|
import {
|
||||||
|
BatchFinancialDataResponse,
|
||||||
|
TodaySnapshotResponse,
|
||||||
|
RealTimeQuoteResponse,
|
||||||
|
AnalysisConfigResponse,
|
||||||
|
LlmProvidersConfig,
|
||||||
|
AnalysisModulesConfig,
|
||||||
|
FinancialConfigResponse,
|
||||||
|
} from "@/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AnalysisStep, AnalysisTask } from "@/lib/execution-step-manager";
|
// Execution-step types not used currently; keep API minimal and explicit
|
||||||
import { useConfigStore } from "@/stores/useConfigStore";
|
import { useConfigStore } from "@/stores/useConfigStore";
|
||||||
import type { SystemConfig } from "@/stores/useConfigStore";
|
import type { SystemConfig } from "@/stores/useConfigStore";
|
||||||
|
|
||||||
@ -130,11 +138,11 @@ export function useFinancials(market?: string, stockCode?: string, years: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAnalysisConfig() {
|
export function useAnalysisConfig() {
|
||||||
return useSWR<AnalysisConfigResponse>('/api/financials/analysis-config', fetcher);
|
return useSWR<AnalysisConfigResponse>('/api/configs/analysis_modules', fetcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
|
export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
|
||||||
const res = await fetch('/api/financials/analysis-config', {
|
const res = await fetch('/api/configs/analysis_modules', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
@ -298,5 +306,50 @@ export async function testConfig(type: string, data: unknown) {
|
|||||||
|
|
||||||
export function useFinancialConfig() {
|
export function useFinancialConfig() {
|
||||||
// 透传后端的财务配置(如指标分组、显示名映射等)
|
// 透传后端的财务配置(如指标分组、显示名映射等)
|
||||||
return useSWR<any>('/api/financials/config', fetcher);
|
// 新后端暂未提供该配置,使用 Partial 以避免严格要求字段存在
|
||||||
|
return useSWR<Partial<FinancialConfigResponse>>('/api/config', fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LLM Provider Config Hooks ---
|
||||||
|
|
||||||
|
export function useLlmProviders() {
|
||||||
|
return useSWR<LlmProvidersConfig>('/api/configs/llm_providers', fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLlmProviders(payload: LlmProvidersConfig) {
|
||||||
|
const res = await fetch('/api/configs/llm_providers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<LlmProvidersConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverProviderModels(providerId: string) {
|
||||||
|
const res = await fetch(`/api/discover-models/${providerId}`);
|
||||||
|
// No error handling for status, as the gateway will return a structured error
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Analysis Modules Config Hooks ---
|
||||||
|
|
||||||
|
export function useAnalysisModules() {
|
||||||
|
return useSWR<AnalysisModulesConfig>('/api/configs/analysis_modules', fetcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAnalysisModules(payload: AnalysisModulesConfig) {
|
||||||
|
const res = await fetch('/api/configs/analysis_modules', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<AnalysisModulesConfig>;
|
||||||
}
|
}
|
||||||
|
|||||||
71
frontend/src/stores/useLlmConfigStore.ts
Normal file
71
frontend/src/stores/useLlmConfigStore.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { immer } from 'zustand/middleware/immer';
|
||||||
|
import type { LlmProvidersConfig, LlmProvider, LlmModel } from '@/types';
|
||||||
|
|
||||||
|
export type ModalState =
|
||||||
|
| { type: 'closed' }
|
||||||
|
| { type: 'editProvider'; providerId: string }
|
||||||
|
| { type: 'addProvider' }
|
||||||
|
| { type: 'editModel'; providerId: string; modelIndex: number }
|
||||||
|
| { type: 'addModel'; providerId: string }
|
||||||
|
| { type: 'discoverModels'; providerId: string };
|
||||||
|
|
||||||
|
type LlmConfigState = {
|
||||||
|
providers: LlmProvidersConfig;
|
||||||
|
modal: ModalState;
|
||||||
|
setInitialProviders: (providers: LlmProvidersConfig) => void;
|
||||||
|
openModal: (modalState: ModalState) => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
|
||||||
|
// Provider actions
|
||||||
|
updateProvider: (providerId: string, provider: LlmProvider) => void;
|
||||||
|
addProvider: (providerId: string, provider: LlmProvider) => void;
|
||||||
|
deleteProvider: (providerId: string) => void;
|
||||||
|
|
||||||
|
// Model actions
|
||||||
|
updateModel: (providerId: string, modelIndex: number, model: LlmModel) => void;
|
||||||
|
addModel: (providerId: string, model: LlmModel) => void;
|
||||||
|
deleteModel: (providerId: string, modelIndex: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLlmConfigStore = create<LlmConfigState>()(
|
||||||
|
immer((set) => ({
|
||||||
|
providers: {},
|
||||||
|
modal: { type: 'closed' },
|
||||||
|
|
||||||
|
setInitialProviders: (providers) => set({ providers }),
|
||||||
|
openModal: (modalState) => set({ modal: modalState }),
|
||||||
|
closeModal: () => set({ modal: { type: 'closed' } }),
|
||||||
|
|
||||||
|
updateProvider: (providerId, provider) => {
|
||||||
|
set((state) => {
|
||||||
|
state.providers[providerId] = provider;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addProvider: (providerId, provider) => {
|
||||||
|
set((state) => {
|
||||||
|
state.providers[providerId] = provider;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteProvider: (providerId) => {
|
||||||
|
set((state) => {
|
||||||
|
delete state.providers[providerId];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateModel: (providerId, modelIndex, model) => {
|
||||||
|
set((state) => {
|
||||||
|
state.providers[providerId].models[modelIndex] = model;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
addModel: (providerId, model) => {
|
||||||
|
set((state) => {
|
||||||
|
state.providers[providerId].models.push(model);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteModel: (providerId, modelIndex) => {
|
||||||
|
set((state) => {
|
||||||
|
state.providers[providerId].models.splice(modelIndex, 1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
@ -455,3 +455,46 @@ export const DEFAULT_CONFIG = {
|
|||||||
/** 错误状态显示时长 (毫秒) */
|
/** 错误状态显示时长 (毫秒) */
|
||||||
ERROR_DISPLAY_DURATION: 10000,
|
ERROR_DISPLAY_DURATION: 10000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LLM 配置相关类型(与后端 common-contracts 配置保持结构一致)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LlmModel {
|
||||||
|
/** 模型ID(如 gpt-4o) */
|
||||||
|
model_id: string;
|
||||||
|
/** 可选别名(用于 UI 展示) */
|
||||||
|
name?: string | null;
|
||||||
|
/** 是否在 UI 中可用 */
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LlmProvider {
|
||||||
|
/** 供应商中文名/展示名(如 OpenAI 官方) */
|
||||||
|
name: string;
|
||||||
|
/** API 基础地址 */
|
||||||
|
api_base_url: string;
|
||||||
|
/** API 密钥(明文存储,由后端负责保护) */
|
||||||
|
api_key: string;
|
||||||
|
/** 启用的模型列表 */
|
||||||
|
models: LlmModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LLM Provider 注册中心:键为 provider_id(如 openai_official) */
|
||||||
|
export type LlmProvidersConfig = Record<string, LlmProvider>;
|
||||||
|
|
||||||
|
export interface AnalysisModuleConfig {
|
||||||
|
/** 模块中文名/展示名(如 看涨分析) */
|
||||||
|
name: string;
|
||||||
|
/** 关联的 Provider ID(引用 LlmProvidersConfig 的键) */
|
||||||
|
provider_id: string;
|
||||||
|
/** 使用的模型 ID(引用 LlmModel.model_id) */
|
||||||
|
model_id: string;
|
||||||
|
/** 提示词模板(可使用 Tera 变量) */
|
||||||
|
prompt_template: string;
|
||||||
|
/** 依赖的其他模块 ID 列表 */
|
||||||
|
dependencies: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分析模块配置集合:键为 module_id(如 bull_case) */
|
||||||
|
export type AnalysisModulesConfig = Record<string, AnalysisModuleConfig>;
|
||||||
52
package-lock.json
generated
Normal file
52
package-lock.json
generated
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "Fundamental_Analysis",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"immer": "^10.2.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
package.json
Normal file
6
package.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"immer": "^10.2.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
348
services/api-gateway/Cargo.lock
generated
348
services/api-gateway/Cargo.lock
generated
@ -91,7 +91,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"regex",
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
@ -644,16 +644,6 @@ dependencies = [
|
|||||||
"typeid",
|
"typeid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "errno"
|
|
||||||
version = "0.3.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "etcetera"
|
name = "etcetera"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@ -676,12 +666,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrand"
|
|
||||||
version = "2.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@ -717,21 +701,6 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foreign-types"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
|
||||||
dependencies = [
|
|
||||||
"foreign-types-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "foreign-types-shared"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@ -848,8 +817,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -859,28 +830,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
]
|
"wasm-bindgen",
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h2"
|
|
||||||
version = "0.4.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
|
||||||
dependencies = [
|
|
||||||
"atomic-waker",
|
|
||||||
"bytes",
|
|
||||||
"fnv",
|
|
||||||
"futures-core",
|
|
||||||
"futures-sink",
|
|
||||||
"http",
|
|
||||||
"indexmap",
|
|
||||||
"slab",
|
|
||||||
"tokio",
|
|
||||||
"tokio-util",
|
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1019,7 +973,6 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@ -1046,22 +999,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
"webpki-roots 1.0.4",
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-tls"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"native-tls",
|
|
||||||
"tokio",
|
|
||||||
"tokio-native-tls",
|
|
||||||
"tower-service",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1083,11 +1021,9 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"system-configuration",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-registry",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1322,12 +1258,6 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -1349,6 +1279,12 @@ version = "0.4.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -1397,23 +1333,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "native-tls"
|
|
||||||
version = "0.2.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"openssl",
|
|
||||||
"openssl-probe",
|
|
||||||
"openssl-sys",
|
|
||||||
"schannel",
|
|
||||||
"security-framework",
|
|
||||||
"security-framework-sys",
|
|
||||||
"tempfile",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nkeys"
|
name = "nkeys"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@ -1425,7 +1344,7 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"signatory",
|
"signatory",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1444,7 +1363,7 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1458,7 +1377,7 @@ dependencies = [
|
|||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -1505,50 +1424,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl"
|
|
||||||
version = "0.10.75"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"cfg-if",
|
|
||||||
"foreign-types",
|
|
||||||
"libc",
|
|
||||||
"once_cell",
|
|
||||||
"openssl-macros",
|
|
||||||
"openssl-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl-macros"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.110",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "openssl-sys"
|
|
||||||
version = "0.9.111"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"pkg-config",
|
|
||||||
"vcpkg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-multimap"
|
name = "ordered-multimap"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -1779,6 +1660,61 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.42"
|
version = "1.0.42"
|
||||||
@ -1807,8 +1743,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1818,7 +1764,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"ppv-lite86",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1830,6 +1786,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@ -1905,29 +1870,26 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
"hyper-tls",
|
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
|
||||||
"native-tls",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@ -1935,6 +1897,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2007,7 +1970,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@ -2034,12 +1997,18 @@ dependencies = [
|
|||||||
"borsh",
|
"borsh",
|
||||||
"bytes",
|
"bytes",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -2049,19 +2018,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "1.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.35"
|
version = "0.23.35"
|
||||||
@ -2104,6 +2060,7 @@ version = "1.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2423,7 +2380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
"signature",
|
"signature",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@ -2435,7 +2392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
"rand_core",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2608,7 +2565,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2649,7 +2606,7 @@ dependencies = [
|
|||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -2754,46 +2711,12 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"core-foundation",
|
|
||||||
"system-configuration-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "system-configuration-sys"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
|
||||||
dependencies = [
|
|
||||||
"core-foundation-sys",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tap"
|
name = "tap"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tempfile"
|
|
||||||
version = "3.23.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand",
|
|
||||||
"getrandom 0.3.4",
|
|
||||||
"once_cell",
|
|
||||||
"rustix",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@ -2936,16 +2859,6 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tokio-native-tls"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
|
||||||
dependencies = [
|
|
||||||
"native-tls",
|
|
||||||
"tokio",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@ -2992,7 +2905,7 @@ dependencies = [
|
|||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"rand",
|
"rand 0.8.5",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
@ -3404,6 +3317,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "0.26.11"
|
version = "0.26.11"
|
||||||
@ -3473,17 +3396,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-registry"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link",
|
|
||||||
"windows-result",
|
|
||||||
"windows-strings",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|||||||
@ -17,7 +17,7 @@ async-nats = "0.45.0"
|
|||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
|
||||||
# HTTP Client
|
# HTTP Client
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
# Concurrency & Async
|
# Concurrency & Async
|
||||||
uuid = { version = "1.8", features = ["v4"] }
|
uuid = { version = "1.8", features = ["v4"] }
|
||||||
|
|||||||
@ -25,6 +25,7 @@ FROM debian:bookworm-slim
|
|||||||
# Set timezone
|
# Set timezone
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy the built binary from the builder stage
|
# Copy the built binary from the builder stage
|
||||||
COPY --from=builder /usr/src/app/services/api-gateway/target/release/api-gateway /usr/local/bin/
|
COPY --from=builder /usr/src/app/services/api-gateway/target/release/api-gateway /usr/local/bin/
|
||||||
|
|||||||
@ -34,12 +34,22 @@ pub fn create_router(app_state: AppState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
.route("/tasks", get(get_current_tasks)) // This is the old, stateless one
|
.route("/tasks", get(get_current_tasks)) // This is the old, stateless one
|
||||||
.route("/v1/data-requests", post(trigger_data_fetch))
|
.nest("/v1", create_v1_router())
|
||||||
.route("/v1/companies/:symbol/profile", get(get_company_profile))
|
|
||||||
.route("/v1/tasks/:request_id", get(get_task_progress))
|
|
||||||
.with_state(app_state)
|
.with_state(app_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_v1_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/data-requests", post(trigger_data_fetch))
|
||||||
|
.route("/companies/{symbol}/profile", get(get_company_profile))
|
||||||
|
.route("/tasks/{request_id}", get(get_task_progress))
|
||||||
|
// --- New Config Routes ---
|
||||||
|
.route("/configs/llm_providers", get(get_llm_providers_config).put(update_llm_providers_config))
|
||||||
|
.route("/configs/analysis_modules", get(get_analysis_modules_config).put(update_analysis_modules_config))
|
||||||
|
// --- New Discover Route ---
|
||||||
|
.route("/discover-models/{provider_id}", get(discover_models))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Health & Stateless Tasks ---
|
// --- Health & Stateless Tasks ---
|
||||||
async fn health_check(State(state): State<AppState>) -> Json<HealthStatus> {
|
async fn health_check(State(state): State<AppState>) -> Json<HealthStatus> {
|
||||||
let mut details = HashMap::new();
|
let mut details = HashMap::new();
|
||||||
@ -59,7 +69,7 @@ async fn get_current_tasks() -> Json<Vec<TaskProgress>> {
|
|||||||
Json(vec![])
|
Json(vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- API Handlers ---
|
// --- V1 API Handlers ---
|
||||||
|
|
||||||
/// [POST /v1/data-requests]
|
/// [POST /v1/data-requests]
|
||||||
/// Triggers the data fetching process by publishing a command to the message bus.
|
/// Triggers the data fetching process by publishing a command to the message bus.
|
||||||
@ -145,3 +155,86 @@ async fn get_task_progress(
|
|||||||
Ok((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Task not found"}))).into_response())
|
Ok((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Task not found"}))).into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Config API Handlers (Proxy to data-persistence-service) ---
|
||||||
|
|
||||||
|
use common_contracts::config_models::{LlmProvidersConfig, AnalysisModulesConfig};
|
||||||
|
|
||||||
|
/// [GET /v1/configs/llm_providers]
|
||||||
|
async fn get_llm_providers_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let config = state.persistence_client.get_llm_providers_config().await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [PUT /v1/configs/llm_providers]
|
||||||
|
async fn update_llm_providers_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<LlmProvidersConfig>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let updated_config = state.persistence_client.update_llm_providers_config(&payload).await?;
|
||||||
|
Ok(Json(updated_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [GET /v1/configs/analysis_modules]
|
||||||
|
async fn get_analysis_modules_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let config = state.persistence_client.get_analysis_modules_config().await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [PUT /v1/configs/analysis_modules]
|
||||||
|
async fn update_analysis_modules_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<AnalysisModulesConfig>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let updated_config = state.persistence_client.update_analysis_modules_config(&payload).await?;
|
||||||
|
Ok(Json(updated_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [GET /v1/discover-models/:provider_id]
|
||||||
|
async fn discover_models(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(provider_id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let providers = state.persistence_client.get_llm_providers_config().await?;
|
||||||
|
|
||||||
|
if let Some(provider) = providers.get(&provider_id) {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("{}/models", provider.api_base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&provider.api_key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await?;
|
||||||
|
warn!(
|
||||||
|
"Failed to discover models for provider '{}'. Status: {}, Body: {}",
|
||||||
|
provider_id, status, error_text
|
||||||
|
);
|
||||||
|
// Return a structured error to the frontend
|
||||||
|
return Ok((
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Failed to fetch models from provider",
|
||||||
|
"provider_error": error_text,
|
||||||
|
})),
|
||||||
|
).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let models_json: serde_json::Value = response.json().await?;
|
||||||
|
Ok((StatusCode::OK, Json(models_json)).into_response())
|
||||||
|
} else {
|
||||||
|
Ok((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "Provider not found" })),
|
||||||
|
).into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use config::Config;
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@ -10,10 +11,41 @@ pub struct AppConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, config::ConfigError> {
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||||||
let config = config::Config::builder()
|
let cfg: Config = config::Config::builder()
|
||||||
.add_source(config::Environment::default().separator("__"))
|
.add_source(config::Environment::default().separator("__"))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
config.try_deserialize()
|
let server_port: u16 = cfg.get::<u16>("server_port")?;
|
||||||
|
let nats_addr: String = cfg.get::<String>("nats_addr")?;
|
||||||
|
let data_persistence_service_url: String = cfg.get::<String>("data_persistence_service_url")?;
|
||||||
|
|
||||||
|
// Parse provider_services deterministically:
|
||||||
|
// 1) prefer array from env (e.g., PROVIDER_SERVICES__0, PROVIDER_SERVICES__1, ...)
|
||||||
|
// 2) fallback to explicit JSON in PROVIDER_SERVICES
|
||||||
|
let provider_services: Vec<String> = if let Ok(arr) = cfg.get_array("provider_services") {
|
||||||
|
let mut out: Vec<String> = Vec::with_capacity(arr.len());
|
||||||
|
for v in arr {
|
||||||
|
let s = v.into_string().map_err(|e| {
|
||||||
|
config::ConfigError::Message(format!("provider_services must be strings: {}", e))
|
||||||
|
})?;
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
} else {
|
||||||
|
let json = cfg.get_string("provider_services")?;
|
||||||
|
serde_json::from_str::<Vec<String>>(&json).map_err(|e| {
|
||||||
|
config::ConfigError::Message(format!(
|
||||||
|
"Invalid JSON for provider_services: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
server_port,
|
||||||
|
nats_addr,
|
||||||
|
data_persistence_service_url,
|
||||||
|
provider_services,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use common_contracts::dtos::CompanyProfileDto;
|
use common_contracts::dtos::CompanyProfileDto;
|
||||||
|
use common_contracts::config_models::{LlmProvidersConfig, AnalysisModulesConfig};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PersistenceClient {
|
pub struct PersistenceClient {
|
||||||
@ -31,4 +32,60 @@ impl PersistenceClient {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Config Methods ---
|
||||||
|
|
||||||
|
pub async fn get_llm_providers_config(&self) -> Result<LlmProvidersConfig> {
|
||||||
|
let url = format!("{}/configs/llm_providers", self.base_url);
|
||||||
|
let config = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<LlmProvidersConfig>()
|
||||||
|
.await?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_llm_providers_config(&self, payload: &LlmProvidersConfig) -> Result<LlmProvidersConfig> {
|
||||||
|
let url = format!("{}/configs/llm_providers", self.base_url);
|
||||||
|
let updated_config = self
|
||||||
|
.client
|
||||||
|
.put(&url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<LlmProvidersConfig>()
|
||||||
|
.await?;
|
||||||
|
Ok(updated_config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_analysis_modules_config(&self) -> Result<AnalysisModulesConfig> {
|
||||||
|
let url = format!("{}/configs/analysis_modules", self.base_url);
|
||||||
|
let config = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<AnalysisModulesConfig>()
|
||||||
|
.await?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_analysis_modules_config(&self, payload: &AnalysisModulesConfig) -> Result<AnalysisModulesConfig> {
|
||||||
|
let url = format!("{}/configs/analysis_modules", self.base_url);
|
||||||
|
let updated_config = self
|
||||||
|
.client
|
||||||
|
.put(&url)
|
||||||
|
.json(payload)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<AnalysisModulesConfig>()
|
||||||
|
.await?;
|
||||||
|
Ok(updated_config)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ use crate::error::Result;
|
|||||||
use crate::persistence::PersistenceClient;
|
use crate::persistence::PersistenceClient;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use async_nats::Client as NatsClient;
|
use async_nats::Client as NatsClient;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@ -13,7 +15,7 @@ pub struct AppState {
|
|||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub async fn new(config: AppConfig) -> Result<Self> {
|
pub async fn new(config: AppConfig) -> Result<Self> {
|
||||||
let nats_client = async_nats::connect(&config.nats_addr).await?;
|
let nats_client = connect_nats_with_retry(&config.nats_addr).await?;
|
||||||
|
|
||||||
let persistence_client =
|
let persistence_client =
|
||||||
PersistenceClient::new(config.data_persistence_service_url.clone());
|
PersistenceClient::new(config.data_persistence_service_url.clone());
|
||||||
@ -25,3 +27,34 @@ impl AppState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_NATS_CONNECT_ATTEMPTS: usize = 30;
|
||||||
|
const INITIAL_BACKOFF_MS: u64 = 200;
|
||||||
|
const MAX_BACKOFF_MS: u64 = 2_000;
|
||||||
|
|
||||||
|
async fn connect_nats_with_retry(nats_addr: &str) -> Result<NatsClient> {
|
||||||
|
let mut backoff_ms = INITIAL_BACKOFF_MS;
|
||||||
|
let mut last_err: Option<async_nats::error::Error<async_nats::ConnectErrorKind>> = None;
|
||||||
|
|
||||||
|
for attempt in 1..=MAX_NATS_CONNECT_ATTEMPTS {
|
||||||
|
match async_nats::connect(nats_addr).await {
|
||||||
|
Ok(client) => {
|
||||||
|
info!("Connected to NATS at {} (attempt {})", nats_addr, attempt);
|
||||||
|
return Ok(client);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to connect to NATS at {} (attempt {}): {}",
|
||||||
|
nats_addr, attempt, err
|
||||||
|
);
|
||||||
|
last_err = Some(err);
|
||||||
|
let delay = backoff_ms.min(MAX_BACKOFF_MS);
|
||||||
|
sleep(Duration::from_millis(delay)).await;
|
||||||
|
backoff_ms = backoff_ms.saturating_mul(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhausted all attempts; return the last error
|
||||||
|
Err(last_err.expect("last_err must be set after failed attempts").into())
|
||||||
|
}
|
||||||
|
|||||||
36
services/common-contracts/src/config_models.rs
Normal file
36
services/common-contracts/src/config_models.rs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
// 单个启用的模型
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||||
|
pub struct LlmModel {
|
||||||
|
pub model_id: String, // e.g., "gpt-4o"
|
||||||
|
pub name: Option<String>, // 别名,用于UI显示
|
||||||
|
pub is_active: bool, // 是否在UI中可选
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个LLM供应商的完整配置
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||||
|
pub struct LlmProvider {
|
||||||
|
pub name: String, // "OpenAI 官方"
|
||||||
|
pub api_base_url: String,
|
||||||
|
pub api_key: String, // 直接明文存储
|
||||||
|
pub models: Vec<LlmModel>, // 该供应商下我们启用的模型列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整个LLM Provider注册中心的数据结构
|
||||||
|
pub type LlmProvidersConfig = HashMap<String, LlmProvider>; // Key: provider_id, e.g., "openai_official"
|
||||||
|
|
||||||
|
// 单个分析模块的配置
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||||
|
pub struct AnalysisModuleConfig {
|
||||||
|
pub name: String, // "看涨分析"
|
||||||
|
pub provider_id: String, // 引用 LlmProvidersConfig 的 Key
|
||||||
|
pub model_id: String, // 引用 LlmModel 中的 model_id
|
||||||
|
pub prompt_template: String,
|
||||||
|
pub dependencies: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整个分析模块配置集合的数据结构
|
||||||
|
pub type AnalysisModulesConfig = HashMap<String, AnalysisModuleConfig>; // Key: module_id, e.g., "bull_case"
|
||||||
@ -2,5 +2,6 @@ pub mod dtos;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod config_models;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1220
services/config-service-rs/Cargo.lock
generated
1220
services/config-service-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,18 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "config-service-rs"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
axum = "0.8.7"
|
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
config = "0.15.19"
|
|
||||||
anyhow = "1.0"
|
|
||||||
tower-http = { version = "0.6.6", features = ["cors"] }
|
|
||||||
once_cell = "1.19"
|
|
||||||
thiserror = "2.0.17"
|
|
||||||
hyper = "1"
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
# 1. Build Stage
|
|
||||||
FROM rust:1.90 as builder
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
# Pre-build dependencies to leverage Docker layer caching
|
|
||||||
COPY ./services/config-service-rs/Cargo.toml ./services/config-service-rs/Cargo.lock* ./services/config-service-rs/
|
|
||||||
WORKDIR /usr/src/app/services/config-service-rs
|
|
||||||
RUN mkdir -p src && \
|
|
||||||
echo "fn main() {}" > src/main.rs && \
|
|
||||||
cargo build --release --bin config-service-rs
|
|
||||||
|
|
||||||
# Copy the full source code
|
|
||||||
COPY ./services/config-service-rs /usr/src/app/services/config-service-rs
|
|
||||||
COPY ./config /usr/src/app/config
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
WORKDIR /usr/src/app/services/config-service-rs
|
|
||||||
RUN cargo build --release --bin config-service-rs
|
|
||||||
|
|
||||||
# 2. Runtime Stage
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Set timezone
|
|
||||||
ENV TZ=Asia/Shanghai
|
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
|
||||||
|
|
||||||
# Copy the built binary and the config directory from the builder stage
|
|
||||||
COPY --from=builder /usr/src/app/services/config-service-rs/target/release/config-service-rs /usr/local/bin/
|
|
||||||
COPY --from=builder /usr/src/app/config ./config
|
|
||||||
|
|
||||||
# Set the binary as the entrypoint
|
|
||||||
ENTRYPOINT ["/usr/local/bin/config-service-rs"]
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
use axum::{response::Json, routing::get, Router};
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::{path::PathBuf, sync::Arc};
|
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
|
||||||
|
|
||||||
static CONFIGS: Lazy<Arc<CachedConfig>> = Lazy::new(|| Arc::new(CachedConfig::load_from_disk()));
|
|
||||||
|
|
||||||
struct CachedConfig {
|
|
||||||
system: Value,
|
|
||||||
analysis: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CachedConfig {
|
|
||||||
fn load_from_disk() -> Self {
|
|
||||||
let config = AppConfig::load().expect("Failed to load app config for caching");
|
|
||||||
let config_dir = PathBuf::from(&config.project_root).join("config");
|
|
||||||
|
|
||||||
let system_path = config_dir.join("config.json");
|
|
||||||
let analysis_path = config_dir.join("analysis-config.json");
|
|
||||||
|
|
||||||
let system_content = std::fs::read_to_string(system_path)
|
|
||||||
.expect("Failed to read system config.json");
|
|
||||||
let system: Value = serde_json::from_str(&system_content)
|
|
||||||
.expect("Failed to parse system config.json");
|
|
||||||
|
|
||||||
let analysis_content = std::fs::read_to_string(analysis_path)
|
|
||||||
.expect("Failed to read analysis-config.json");
|
|
||||||
let analysis: Value = serde_json::from_str(&analysis_content)
|
|
||||||
.expect("Failed to parse analysis-config.json");
|
|
||||||
|
|
||||||
Self { system, analysis }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_router() -> Router {
|
|
||||||
Router::new()
|
|
||||||
.route("/", get(root))
|
|
||||||
.route("/api/v1/system", get(get_system_config))
|
|
||||||
.route("/api/v1/analysis-modules", get(get_analysis_modules))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn root() -> &'static str {
|
|
||||||
"OK"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_system_config() -> Json<Value> {
|
|
||||||
Json(CONFIGS.system.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_analysis_modules() -> Json<Value> {
|
|
||||||
Json(CONFIGS.analysis.clone())
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
use config::{Config, ConfigError, Environment};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct AppConfig {
|
|
||||||
pub server_port: u16,
|
|
||||||
pub project_root: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppConfig {
|
|
||||||
pub fn load() -> Result<Self, ConfigError> {
|
|
||||||
let config = Config::builder()
|
|
||||||
.set_default("server_port", 8080)?
|
|
||||||
.set_default("project_root", "/workspace")?
|
|
||||||
.add_source(Environment::default().separator("__"))
|
|
||||||
.build()?;
|
|
||||||
config.try_deserialize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, AppError>;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum AppError {
|
|
||||||
#[error("Configuration error: {0}")]
|
|
||||||
Config(#[from] config::ConfigError),
|
|
||||||
|
|
||||||
#[error("IO error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
|
|
||||||
#[error("JSON parsing error: {0}")]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
|
|
||||||
#[error("Server startup error: {0}")]
|
|
||||||
Server(#[from] hyper::Error),
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
mod api;
|
|
||||||
mod config;
|
|
||||||
mod error;
|
|
||||||
|
|
||||||
use crate::{config::AppConfig, error::Result};
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let config = AppConfig::load()?;
|
|
||||||
let port = config.server_port;
|
|
||||||
|
|
||||||
let app = api::create_router();
|
|
||||||
|
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
|
||||||
info!("Server listening on {}", addr);
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
||||||
axum::serve(listener, app).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@ -74,6 +74,7 @@ wasm-cli = []
|
|||||||
mcp = ["service_kit/mcp"]
|
mcp = ["service_kit/mcp"]
|
||||||
# 可选:透传 api-cli 给 service_kit
|
# 可选:透传 api-cli 给 service_kit
|
||||||
# api-cli = ["service_kit/api-cli"]
|
# api-cli = ["service_kit/api-cli"]
|
||||||
|
full-data = []
|
||||||
|
|
||||||
# --- For Local Development ---
|
# --- For Local Development ---
|
||||||
# If you are developing `service_kit` locally, uncomment the following lines
|
# If you are developing `service_kit` locally, uncomment the following lines
|
||||||
|
|||||||
43
services/data-persistence-service/src/api/configs.rs
Normal file
43
services/data-persistence-service/src/api/configs.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use common_contracts::config_models::{LlmProvidersConfig, AnalysisModulesConfig};
|
||||||
|
use service_kit::api;
|
||||||
|
|
||||||
|
use crate::{db::system_config, AppState, ServerError};
|
||||||
|
|
||||||
|
#[api(GET, "/api/v1/configs/llm_providers", output(detail = "LlmProvidersConfig"))]
|
||||||
|
pub async fn get_llm_providers_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<LlmProvidersConfig>, ServerError> {
|
||||||
|
let pool = state.pool();
|
||||||
|
let config = system_config::get_config::<LlmProvidersConfig>(pool, "llm_providers").await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(PUT, "/api/v1/configs/llm_providers", output(detail = "LlmProvidersConfig"))]
|
||||||
|
pub async fn update_llm_providers_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<LlmProvidersConfig>,
|
||||||
|
) -> Result<Json<LlmProvidersConfig>, ServerError> {
|
||||||
|
let pool = state.pool();
|
||||||
|
let updated_config = system_config::update_config(pool, "llm_providers", &payload).await?;
|
||||||
|
Ok(Json(updated_config))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(GET, "/api/v1/configs/analysis_modules", output(detail = "AnalysisModulesConfig"))]
|
||||||
|
pub async fn get_analysis_modules_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<AnalysisModulesConfig>, ServerError> {
|
||||||
|
let pool = state.pool();
|
||||||
|
let config = system_config::get_config::<AnalysisModulesConfig>(pool, "analysis_modules").await?;
|
||||||
|
Ok(Json(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[api(PUT, "/api/v1/configs/analysis_modules", output(detail = "AnalysisModulesConfig"))]
|
||||||
|
pub async fn update_analysis_modules_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<AnalysisModulesConfig>,
|
||||||
|
) -> Result<Json<AnalysisModulesConfig>, ServerError> {
|
||||||
|
let pool = state.pool();
|
||||||
|
let updated_config = system_config::update_config(pool, "analysis_modules", &payload).await?;
|
||||||
|
Ok(Json(updated_config))
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
// This module will contain all the API handler definitions
|
// This module will contain all the API handler definitions
|
||||||
// which are then collected by the `inventory` crate.
|
// which are then collected by the `inventory` crate.
|
||||||
|
#[cfg(feature = "full-data")]
|
||||||
pub mod companies;
|
pub mod companies;
|
||||||
|
#[cfg(feature = "full-data")]
|
||||||
pub mod market_data;
|
pub mod market_data;
|
||||||
|
#[cfg(feature = "full-data")]
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
|
pub mod configs;
|
||||||
|
|||||||
@ -1,341 +0,0 @@
|
|||||||
// This module contains all the database interaction logic,
|
|
||||||
// using `sqlx` to query the PostgreSQL database.
|
|
||||||
//
|
|
||||||
// Functions in this module will be called by the API handlers
|
|
||||||
// to fetch or store data.
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
dtos::{CompanyProfileDto, DailyMarketDataDto, NewAnalysisResultDto, TimeSeriesFinancialDto, RealtimeQuoteDto},
|
|
||||||
models::{AnalysisResult, CompanyProfile, DailyMarketData, TimeSeriesFinancial, RealtimeQuote},
|
|
||||||
};
|
|
||||||
use anyhow::Result;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use chrono::NaiveDate;
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
/// Upserts a company profile into the database.
|
|
||||||
/// If a company with the same symbol already exists, it will be updated.
|
|
||||||
/// Otherwise, a new record will be inserted.
|
|
||||||
pub async fn upsert_company(pool: &PgPool, company: &CompanyProfileDto) -> Result<()> {
|
|
||||||
info!(target: "db", symbol = %company.symbol, "DB upsert_company started");
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO company_profiles (symbol, name, industry, list_date, additional_info, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
||||||
ON CONFLICT (symbol) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
industry = EXCLUDED.industry,
|
|
||||||
list_date = EXCLUDED.list_date,
|
|
||||||
additional_info = EXCLUDED.additional_info,
|
|
||||||
updated_at = NOW()
|
|
||||||
"#,
|
|
||||||
company.symbol,
|
|
||||||
company.name,
|
|
||||||
company.industry,
|
|
||||||
company.list_date,
|
|
||||||
company.additional_info,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!(target: "db", symbol = %company.symbol, "DB upsert_company finished");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetches a single company profile by its symbol.
|
|
||||||
pub async fn get_company_by_symbol(pool: &PgPool, symbol: &str) -> Result<Option<CompanyProfile>> {
|
|
||||||
info!(target: "db", symbol = %symbol, "DB get_company_by_symbol started");
|
|
||||||
let company = sqlx::query_as!(
|
|
||||||
CompanyProfile,
|
|
||||||
r#"
|
|
||||||
SELECT symbol, name, industry, list_date, additional_info, updated_at
|
|
||||||
FROM company_profiles
|
|
||||||
WHERE symbol = $1
|
|
||||||
"#,
|
|
||||||
symbol
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!(target: "db", symbol = %symbol, found = company.is_some(), "DB get_company_by_symbol finished");
|
|
||||||
Ok(company)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================================
|
|
||||||
// Market Data Functions (Task T3.2)
|
|
||||||
// =================================================================================
|
|
||||||
|
|
||||||
pub async fn batch_insert_financials(pool: &PgPool, financials: &[TimeSeriesFinancialDto]) -> Result<()> {
|
|
||||||
info!(target: "db", count = financials.len(), "DB batch_insert_financials started");
|
|
||||||
// Note: This is a simple iterative approach. For very high throughput,
|
|
||||||
// a single COPY statement or sqlx's `copy` module would be more performant.
|
|
||||||
for financial in financials {
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO time_series_financials (symbol, metric_name, period_date, value, source)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (symbol, metric_name, period_date) DO UPDATE SET
|
|
||||||
value = EXCLUDED.value,
|
|
||||||
source = EXCLUDED.source
|
|
||||||
"#,
|
|
||||||
financial.symbol,
|
|
||||||
financial.metric_name,
|
|
||||||
financial.period_date,
|
|
||||||
Decimal::from_f64_retain(financial.value).expect("invalid decimal conversion from f64"), // Convert f64 to Decimal
|
|
||||||
financial.source,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
info!(target: "db", count = financials.len(), "DB batch_insert_financials finished");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_financials_by_symbol(
|
|
||||||
pool: &PgPool,
|
|
||||||
symbol: &str,
|
|
||||||
metrics: Option<Vec<String>>,
|
|
||||||
) -> Result<Vec<TimeSeriesFinancial>> {
|
|
||||||
info!(target: "db", symbol = %symbol, has_metrics = metrics.as_ref().map(|m| !m.is_empty()).unwrap_or(false), "DB get_financials_by_symbol started");
|
|
||||||
let results = if let Some(metrics) = metrics {
|
|
||||||
sqlx::query_as!(
|
|
||||||
TimeSeriesFinancial,
|
|
||||||
r#"
|
|
||||||
SELECT symbol, metric_name, period_date, value, source
|
|
||||||
FROM time_series_financials
|
|
||||||
WHERE symbol = $1 AND metric_name = ANY($2)
|
|
||||||
ORDER BY period_date DESC
|
|
||||||
"#,
|
|
||||||
symbol,
|
|
||||||
&metrics
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as!(
|
|
||||||
TimeSeriesFinancial,
|
|
||||||
r#"
|
|
||||||
SELECT symbol, metric_name, period_date, value, source
|
|
||||||
FROM time_series_financials
|
|
||||||
WHERE symbol = $1
|
|
||||||
ORDER BY period_date DESC
|
|
||||||
"#,
|
|
||||||
symbol
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
info!(target: "db", symbol = %symbol, items = results.len(), "DB get_financials_by_symbol finished");
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn batch_insert_daily_data(pool: &PgPool, daily_data: &[DailyMarketDataDto]) -> Result<()> {
|
|
||||||
info!(target: "db", count = daily_data.len(), "DB batch_insert_daily_data started");
|
|
||||||
for data in daily_data {
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO daily_market_data (symbol, trade_date, open_price, high_price, low_price, close_price, volume, pe, pb, total_mv)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
||||||
ON CONFLICT (symbol, trade_date) DO UPDATE SET
|
|
||||||
open_price = EXCLUDED.open_price,
|
|
||||||
high_price = EXCLUDED.high_price,
|
|
||||||
low_price = EXCLUDED.low_price,
|
|
||||||
close_price = EXCLUDED.close_price,
|
|
||||||
volume = EXCLUDED.volume,
|
|
||||||
pe = EXCLUDED.pe,
|
|
||||||
pb = EXCLUDED.pb,
|
|
||||||
total_mv = EXCLUDED.total_mv
|
|
||||||
"#,
|
|
||||||
data.symbol,
|
|
||||||
data.trade_date,
|
|
||||||
data.open_price.and_then(Decimal::from_f64_retain),
|
|
||||||
data.high_price.and_then(Decimal::from_f64_retain),
|
|
||||||
data.low_price.and_then(Decimal::from_f64_retain),
|
|
||||||
data.close_price.and_then(Decimal::from_f64_retain),
|
|
||||||
data.volume,
|
|
||||||
data.pe.and_then(Decimal::from_f64_retain),
|
|
||||||
data.pb.and_then(Decimal::from_f64_retain),
|
|
||||||
data.total_mv.and_then(Decimal::from_f64_retain),
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
info!(target: "db", count = daily_data.len(), "DB batch_insert_daily_data finished");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_daily_data_by_symbol(
|
|
||||||
pool: &PgPool,
|
|
||||||
symbol: &str,
|
|
||||||
start_date: Option<NaiveDate>,
|
|
||||||
end_date: Option<NaiveDate>,
|
|
||||||
) -> Result<Vec<DailyMarketData>> {
|
|
||||||
// This query is simplified. A real-world scenario might need more complex date filtering.
|
|
||||||
info!(target: "db", symbol = %symbol, start = ?start_date, end = ?end_date, "DB get_daily_data_by_symbol started");
|
|
||||||
let daily_data = sqlx::query_as!(
|
|
||||||
DailyMarketData,
|
|
||||||
r#"
|
|
||||||
SELECT symbol, trade_date, open_price, high_price, low_price, close_price, volume, pe, pb, total_mv
|
|
||||||
FROM daily_market_data
|
|
||||||
WHERE symbol = $1
|
|
||||||
AND ($2::DATE IS NULL OR trade_date >= $2)
|
|
||||||
AND ($3::DATE IS NULL OR trade_date <= $3)
|
|
||||||
ORDER BY trade_date DESC
|
|
||||||
"#,
|
|
||||||
symbol,
|
|
||||||
start_date,
|
|
||||||
end_date
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!(target: "db", symbol = %symbol, items = daily_data.len(), "DB get_daily_data_by_symbol finished");
|
|
||||||
Ok(daily_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================================
|
|
||||||
// Realtime Quotes Functions
|
|
||||||
// =================================================================================
|
|
||||||
|
|
||||||
pub async fn insert_realtime_quote(pool: &PgPool, quote: &RealtimeQuoteDto) -> Result<()> {
|
|
||||||
info!(target: "db", symbol = %quote.symbol, market = %quote.market, ts = %quote.ts, "DB insert_realtime_quote started");
|
|
||||||
sqlx::query!(
|
|
||||||
r#"
|
|
||||||
INSERT INTO realtime_quotes (
|
|
||||||
symbol, market, ts, price, open_price, high_price, low_price, prev_close, change, change_percent, volume, source, updated_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW()
|
|
||||||
)
|
|
||||||
ON CONFLICT (symbol, market, ts) DO UPDATE SET
|
|
||||||
price = EXCLUDED.price,
|
|
||||||
open_price = EXCLUDED.open_price,
|
|
||||||
high_price = EXCLUDED.high_price,
|
|
||||||
low_price = EXCLUDED.low_price,
|
|
||||||
prev_close = EXCLUDED.prev_close,
|
|
||||||
change = EXCLUDED.change,
|
|
||||||
change_percent = EXCLUDED.change_percent,
|
|
||||||
volume = EXCLUDED.volume,
|
|
||||||
source = EXCLUDED.source,
|
|
||||||
updated_at = NOW()
|
|
||||||
"#,
|
|
||||||
quote.symbol,
|
|
||||||
quote.market,
|
|
||||||
quote.ts,
|
|
||||||
Decimal::from_f64_retain(quote.price).expect("invalid price"),
|
|
||||||
quote.open_price.and_then(Decimal::from_f64_retain),
|
|
||||||
quote.high_price.and_then(Decimal::from_f64_retain),
|
|
||||||
quote.low_price.and_then(Decimal::from_f64_retain),
|
|
||||||
quote.prev_close.and_then(Decimal::from_f64_retain),
|
|
||||||
quote.change.and_then(Decimal::from_f64_retain),
|
|
||||||
quote.change_percent.and_then(Decimal::from_f64_retain),
|
|
||||||
quote.volume,
|
|
||||||
quote.source.as_deref(),
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
info!(target: "db", symbol = %quote.symbol, market = %quote.market, "DB insert_realtime_quote finished");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_latest_realtime_quote(pool: &PgPool, market: &str, symbol: &str) -> Result<Option<RealtimeQuote>> {
|
|
||||||
info!(target: "db", symbol = %symbol, market = %market, "DB get_latest_realtime_quote started");
|
|
||||||
let rec = sqlx::query_as!(
|
|
||||||
RealtimeQuote,
|
|
||||||
r#"
|
|
||||||
SELECT symbol, market, ts, price, open_price, high_price, low_price, prev_close, change, change_percent, volume, source, updated_at
|
|
||||||
FROM realtime_quotes
|
|
||||||
WHERE symbol = $1 AND market = $2
|
|
||||||
ORDER BY ts DESC
|
|
||||||
LIMIT 1
|
|
||||||
"#,
|
|
||||||
symbol,
|
|
||||||
market
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
info!(target: "db", symbol = %symbol, market = %market, found = rec.is_some(), "DB get_latest_realtime_quote finished");
|
|
||||||
Ok(rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =================================================================================
|
|
||||||
// Analysis Results Functions (Task T3.3)
|
|
||||||
// =================================================================================
|
|
||||||
|
|
||||||
pub async fn create_analysis_result(pool: &PgPool, result: &NewAnalysisResultDto) -> Result<AnalysisResult> {
|
|
||||||
info!(target: "db", symbol = %result.symbol, module_id = %result.module_id, "DB create_analysis_result started");
|
|
||||||
let new_result = sqlx::query_as!(
|
|
||||||
AnalysisResult,
|
|
||||||
r#"
|
|
||||||
INSERT INTO analysis_results (symbol, module_id, model_name, content, meta_data)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING id, symbol, module_id, generated_at, model_name, content, meta_data
|
|
||||||
"#,
|
|
||||||
result.symbol,
|
|
||||||
result.module_id,
|
|
||||||
result.model_name,
|
|
||||||
result.content,
|
|
||||||
result.meta_data,
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!(target: "db", id = %new_result.id, symbol = %new_result.symbol, module_id = %new_result.module_id, "DB create_analysis_result finished");
|
|
||||||
Ok(new_result)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_analysis_results(
|
|
||||||
pool: &PgPool,
|
|
||||||
symbol: &str,
|
|
||||||
module_id: Option<&str>,
|
|
||||||
) -> Result<Vec<AnalysisResult>> {
|
|
||||||
info!(target: "db", symbol = %symbol, module = ?module_id, "DB get_analysis_results started");
|
|
||||||
let results = if let Some(module) = module_id {
|
|
||||||
sqlx::query_as!(
|
|
||||||
AnalysisResult,
|
|
||||||
r#"
|
|
||||||
SELECT id, symbol, module_id, generated_at, model_name, content, meta_data
|
|
||||||
FROM analysis_results
|
|
||||||
WHERE symbol = $1 AND module_id = $2
|
|
||||||
ORDER BY generated_at DESC
|
|
||||||
"#,
|
|
||||||
symbol,
|
|
||||||
module
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as!(
|
|
||||||
AnalysisResult,
|
|
||||||
r#"
|
|
||||||
SELECT id, symbol, module_id, generated_at, model_name, content, meta_data
|
|
||||||
FROM analysis_results
|
|
||||||
WHERE symbol = $1
|
|
||||||
ORDER BY generated_at DESC
|
|
||||||
"#,
|
|
||||||
symbol
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
info!(target: "db", symbol = %symbol, items = results.len(), "DB get_analysis_results finished");
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_analysis_result_by_id(pool: &PgPool, id: uuid::Uuid) -> Result<Option<AnalysisResult>> {
|
|
||||||
info!(target: "db", id = %id, "DB get_analysis_result_by_id started");
|
|
||||||
let result = sqlx::query_as!(
|
|
||||||
AnalysisResult,
|
|
||||||
r#"
|
|
||||||
SELECT id, symbol, module_id, generated_at, model_name, content, meta_data
|
|
||||||
FROM analysis_results
|
|
||||||
WHERE id = $1
|
|
||||||
"#,
|
|
||||||
id
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
info!(target: "db", id = %id, found = result.is_some(), "DB get_analysis_result_by_id finished");
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
7
services/data-persistence-service/src/db/mod.rs
Normal file
7
services/data-persistence-service/src/db/mod.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
// This module contains all the database interaction logic,
|
||||||
|
// using `sqlx` to query the PostgreSQL database.
|
||||||
|
//
|
||||||
|
// Functions in this module will be called by the API handlers
|
||||||
|
// to fetch or store data.
|
||||||
|
|
||||||
|
pub mod system_config;
|
||||||
63
services/data-persistence-service/src/db/system_config.rs
Normal file
63
services/data-persistence-service/src/db/system_config.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use sqlx::{Row, PgPool};
|
||||||
|
|
||||||
|
/// Fetches a configuration JSON from the `system_config` table
|
||||||
|
/// and deserializes it into the specified generic type `T`.
|
||||||
|
/// If the key does not exist, it returns a default `T`.
|
||||||
|
pub async fn get_config<T>(pool: &PgPool, key: &str) -> Result<T>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned + Default,
|
||||||
|
{
|
||||||
|
let row_opt = sqlx::query("SELECT config_value FROM system_config WHERE config_key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch from system_config")?;
|
||||||
|
|
||||||
|
if let Some(row) = row_opt {
|
||||||
|
// Postgres JSON/JSONB maps to serde_json::Value
|
||||||
|
let value: serde_json::Value = row
|
||||||
|
.try_get("config_value")
|
||||||
|
.context("Missing column 'config_value'")?;
|
||||||
|
let config: T = serde_json::from_value(value)
|
||||||
|
.context("Failed to deserialize config_value from database")?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Ok(T::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the provided configuration struct `T` into JSON
|
||||||
|
/// and upserts it into the `system_config` table for the given key.
|
||||||
|
pub async fn update_config<T>(pool: &PgPool, key: &str, config: &T) -> Result<T>
|
||||||
|
where
|
||||||
|
T: Serialize + DeserializeOwned,
|
||||||
|
{
|
||||||
|
let json_value = serde_json::to_value(config)
|
||||||
|
.context("Failed to serialize config to JSON value")?;
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO system_config (config_key, config_value, updated_at)
|
||||||
|
VALUES ($1, $2, NOW())
|
||||||
|
ON CONFLICT (config_key) DO UPDATE SET
|
||||||
|
config_value = EXCLUDED.config_value,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING config_value
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(json_value)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to upsert into system_config")?;
|
||||||
|
|
||||||
|
let value: serde_json::Value = row
|
||||||
|
.try_get("config_value")
|
||||||
|
.context("Missing returned column 'config_value'")?;
|
||||||
|
let result: T = serde_json::from_value(value)
|
||||||
|
.context("Failed to deserialize updated config_value from database")?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
@ -1,4 +1,3 @@
|
|||||||
use secrecy::SecretString;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
@ -6,15 +5,16 @@ pub struct AppConfig {
|
|||||||
pub server_port: u16,
|
pub server_port: u16,
|
||||||
pub nats_addr: String,
|
pub nats_addr: String,
|
||||||
pub data_persistence_service_url: String,
|
pub data_persistence_service_url: String,
|
||||||
pub llm_api_url: String,
|
|
||||||
pub llm_api_key: SecretString,
|
|
||||||
pub llm_model: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn load() -> Result<Self, config::ConfigError> {
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||||||
let config = config::Config::builder()
|
let config = config::Config::builder()
|
||||||
.add_source(config::Environment::default().separator("__"))
|
.add_source(
|
||||||
|
config::Environment::default()
|
||||||
|
.separator("__")
|
||||||
|
.ignore_empty(true),
|
||||||
|
)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
config.try_deserialize()
|
config.try_deserialize()
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use common_contracts::{
|
use common_contracts::{
|
||||||
|
config_models::{AnalysisModulesConfig, LlmProvidersConfig},
|
||||||
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
|
dtos::{CompanyProfileDto, RealtimeQuoteDto, TimeSeriesFinancialBatchDto, TimeSeriesFinancialDto},
|
||||||
};
|
};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@ -55,6 +56,36 @@ impl PersistenceClient {
|
|||||||
Ok(dtos)
|
Ok(dtos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Config Fetching Methods ---
|
||||||
|
|
||||||
|
pub async fn get_llm_providers_config(&self) -> Result<LlmProvidersConfig> {
|
||||||
|
let url = format!("{}/configs/llm_providers", self.base_url);
|
||||||
|
info!("Fetching LLM providers config from {}", url);
|
||||||
|
let config = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<LlmProvidersConfig>()
|
||||||
|
.await?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_analysis_modules_config(&self) -> Result<AnalysisModulesConfig> {
|
||||||
|
let url = format!("{}/configs/analysis_modules", self.base_url);
|
||||||
|
info!("Fetching analysis modules config from {}", url);
|
||||||
|
let config = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<AnalysisModulesConfig>()
|
||||||
|
.await?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
pub async fn upsert_company_profile(&self, profile: CompanyProfileDto) -> Result<()> {
|
||||||
let url = format!("{}/companies", self.base_url);
|
let url = format!("{}/companies", self.base_url);
|
||||||
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
info!("Upserting company profile for {} to {}", profile.symbol, url);
|
||||||
|
|||||||
@ -6,28 +6,20 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use common_contracts::observability::TaskProgress;
|
use common_contracts::observability::TaskProgress;
|
||||||
|
|
||||||
use crate::{config::AppConfig, llm_client::LlmClient, templates::load_tera};
|
use crate::{config::AppConfig, templates::load_tera};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
|
pub tasks: Arc<DashMap<Uuid, TaskProgress>>,
|
||||||
pub config: Arc<AppConfig>,
|
pub config: Arc<AppConfig>,
|
||||||
pub llm_client: Arc<LlmClient>,
|
|
||||||
pub tera: Arc<Tera>,
|
pub tera: Arc<Tera>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(config: AppConfig) -> Self {
|
pub fn new(config: AppConfig) -> Self {
|
||||||
let llm_client = Arc::new(LlmClient::new(
|
|
||||||
config.llm_api_url.clone(),
|
|
||||||
config.llm_api_key.clone(),
|
|
||||||
config.llm_model.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
tasks: Arc::new(DashMap::new()),
|
tasks: Arc::new(DashMap::new()),
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
llm_client,
|
|
||||||
tera: Arc::new(load_tera()),
|
tera: Arc::new(load_tera()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +1,128 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use common_contracts::config_models::{AnalysisModuleConfig, AnalysisModulesConfig, LlmProvider, LlmProvidersConfig};
|
||||||
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
use common_contracts::dtos::{CompanyProfileDto, TimeSeriesFinancialDto};
|
||||||
use common_contracts::messages::FinancialsPersistedEvent;
|
use common_contracts::messages::FinancialsPersistedEvent;
|
||||||
use tera::Context;
|
use tera::{Context, Tera};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn, instrument};
|
||||||
|
|
||||||
use crate::error::{ProviderError, Result};
|
use crate::error::{ProviderError, Result};
|
||||||
|
use crate::llm_client::LlmClient;
|
||||||
use crate::persistence::PersistenceClient;
|
use crate::persistence::PersistenceClient;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::templates::render_prompt;
|
use crate::templates::render_prompt;
|
||||||
|
|
||||||
|
#[instrument(skip_all, fields(symbol = %event.symbol))]
|
||||||
pub async fn run_report_generation_workflow(
|
pub async fn run_report_generation_workflow(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
event: FinancialsPersistedEvent,
|
event: FinancialsPersistedEvent,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!(
|
info!("Starting report generation workflow.");
|
||||||
"Starting report generation workflow for symbol: {}",
|
|
||||||
event.symbol
|
|
||||||
);
|
|
||||||
|
|
||||||
let persistence_client =
|
let persistence_client =
|
||||||
PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
PersistenceClient::new(state.config.data_persistence_service_url.clone());
|
||||||
|
|
||||||
// 1. Fetch all necessary data from the persistence service
|
// 1. Fetch all necessary data AND configurations in parallel
|
||||||
let (profile, financials) = fetch_data(&persistence_client, &event.symbol).await?;
|
let (profile, financials, llm_providers, analysis_modules) =
|
||||||
|
fetch_data_and_configs(&persistence_client, &event.symbol).await?;
|
||||||
|
|
||||||
if financials.is_empty() {
|
if financials.is_empty() {
|
||||||
warn!(
|
warn!("No financial data found. Aborting report generation.");
|
||||||
"No financial data found for symbol: {}. Aborting report generation.",
|
|
||||||
event.symbol
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create context and render the prompt
|
// --- New: Dynamic, Multi-Module Workflow ---
|
||||||
|
let mut generated_results: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
|
// Naive sequential execution based on dependencies. A proper topological sort would be better.
|
||||||
|
// For now, we just iterate multiple times to resolve dependencies.
|
||||||
|
for _ in 0..analysis_modules.len() {
|
||||||
|
for (module_id, module_config) in &analysis_modules {
|
||||||
|
if generated_results.contains_key(module_id.as_str()) {
|
||||||
|
continue; // Already generated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all dependencies are met
|
||||||
|
let deps_met = module_config.dependencies.iter().all(|dep| generated_results.contains_key(dep));
|
||||||
|
if !deps_met {
|
||||||
|
continue; // Will try again in the next iteration
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(module_id = %module_id, "All dependencies met. Generating report for module.");
|
||||||
|
|
||||||
|
// 2. Dynamically create LLM client for this module
|
||||||
|
let llm_client = create_llm_client_for_module(&state, &llm_providers, module_config)?;
|
||||||
|
|
||||||
|
// 3. Create context and render the prompt
|
||||||
let mut context = Context::new();
|
let mut context = Context::new();
|
||||||
context.insert("name", &profile.name);
|
context.insert("company_name", &profile.name);
|
||||||
context.insert("industry", &profile.industry);
|
context.insert("ts_code", &event.symbol);
|
||||||
context.insert("list_date", &profile.list_date.map(|d| d.to_string()));
|
// Inject dependencies into context
|
||||||
context.insert("records_count", &financials.len());
|
for dep in &module_config.dependencies {
|
||||||
|
if let Some(content) = generated_results.get(dep) {
|
||||||
|
context.insert(dep, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A placeholder for financial data, can be expanded
|
||||||
|
context.insert("financial_data", "...");
|
||||||
|
|
||||||
let prompt = render_prompt(&state.tera, "company_profile_summary", &context)
|
let prompt = Tera::one_off(&module_config.prompt_template, &context, true)
|
||||||
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed: {}", e)))?;
|
.map_err(|e| ProviderError::Internal(anyhow::anyhow!("Prompt rendering failed for module '{}': {}", module_id, e)))?;
|
||||||
|
|
||||||
// 3. Call the LLM to generate the summary
|
// 4. Call the LLM to generate the content for this module
|
||||||
info!("Generating summary for symbol: {}", event.symbol);
|
let content = llm_client.generate_text(prompt).await?;
|
||||||
let summary = state.llm_client.generate_text(prompt).await?;
|
info!(module_id = %module_id, "Successfully generated content.");
|
||||||
|
|
||||||
// 4. Persist the generated report (future work)
|
// TODO: Persist the generated result via persistence_client
|
||||||
info!(
|
|
||||||
"Successfully generated report for symbol: {} ({} records)",
|
|
||||||
event.symbol,
|
|
||||||
financials.len()
|
|
||||||
);
|
|
||||||
info!("Generated Summary: {}", summary);
|
|
||||||
|
|
||||||
|
generated_results.insert(module_id.clone(), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if generated_results.len() != analysis_modules.len() {
|
||||||
|
warn!("Could not generate all modules due to missing dependencies or circular dependency.");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Report generation workflow finished.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_data(
|
fn create_llm_client_for_module(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
llm_providers: &LlmProvidersConfig,
|
||||||
|
module_config: &AnalysisModuleConfig,
|
||||||
|
) -> Result<LlmClient> {
|
||||||
|
let provider = llm_providers.get(&module_config.provider_id).ok_or_else(|| {
|
||||||
|
ProviderError::Configuration(format!(
|
||||||
|
"Provider '{}' not found in llm_providers config",
|
||||||
|
module_config.provider_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// In the old design, the api key name was stored. In the new design, it's stored directly.
|
||||||
|
let api_key = provider.api_key.clone();
|
||||||
|
|
||||||
|
Ok(LlmClient::new(
|
||||||
|
provider.api_base_url.clone(),
|
||||||
|
api_key.into(), // Convert String to SecretString
|
||||||
|
module_config.model_id.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_data_and_configs(
|
||||||
client: &PersistenceClient,
|
client: &PersistenceClient,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
) -> Result<(CompanyProfileDto, Vec<TimeSeriesFinancialDto>)> {
|
) -> Result<(
|
||||||
let (profile, financials) = tokio::try_join!(
|
CompanyProfileDto,
|
||||||
|
Vec<TimeSeriesFinancialDto>,
|
||||||
|
LlmProvidersConfig,
|
||||||
|
AnalysisModulesConfig,
|
||||||
|
)> {
|
||||||
|
let (profile, financials, llm_providers, analysis_modules) = tokio::try_join!(
|
||||||
client.get_company_profile(symbol),
|
client.get_company_profile(symbol),
|
||||||
client.get_financial_statements(symbol)
|
client.get_financial_statements(symbol),
|
||||||
|
client.get_llm_providers_config(),
|
||||||
|
client.get_analysis_modules_config(),
|
||||||
)?;
|
)?;
|
||||||
Ok((profile, financials))
|
Ok((profile, financials, llm_providers, analysis_modules))
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user