diff --git a/docs/3_project_management/tasks/pending/20251116_[Active]_refactor_llm_provider_architecture.md b/docs/3_project_management/tasks/pending/20251116_[Active]_refactor_llm_provider_architecture.md new file mode 100644 index 0000000..2bf0b66 --- /dev/null +++ b/docs/3_project_management/tasks/pending/20251116_[Active]_refactor_llm_provider_architecture.md @@ -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, // 别名,用于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, // 该供应商下我们启用的模型列表 +} + +// 整个LLM Provider注册中心的数据结构 +pub type LlmProvidersConfig = HashMap; // 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, +} + +// 整个分析模块配置集合的数据结构 +pub type AnalysisModulesConfig = HashMap; // 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配置。 diff --git a/frontend/src/app/api/companies/[symbol]/profile/route.ts b/frontend/src/app/api/companies/[symbol]/profile/route.ts index c0674d0..54e74a5 100644 --- a/frontend/src/app/api/companies/[symbol]/profile/route.ts +++ b/frontend/src/app/api/companies/[symbol]/profile/route.ts @@ -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( _req: Request, context: { params: Promise<{ symbol: string }> } ) { 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 target = `${BACKEND_BASE}/companies/${encodeURIComponent(symbol)}/profile`; diff --git a/frontend/src/app/api/config/route.ts b/frontend/src/app/api/config/route.ts index 42a39ae..b5437e5 100644 --- a/frontend/src/app/api/config/route.ts +++ b/frontend/src/app/api/config/route.ts @@ -1,26 +1,75 @@ 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() { 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) { 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 incoming = await req.json().catch(() => ({})); + const tasks: Promise[] = []; + if (incoming.llm_providers) { + tasks.push(fetch(`${BACKEND_BASE}/configs/llm_providers`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + 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 [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 }); } - const body = await req.text(); - const resp = await fetch(`${BACKEND_BASE}/config`, { - 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' } }); } diff --git a/frontend/src/app/api/config/test/route.ts b/frontend/src/app/api/config/test/route.ts index 485cb33..408c329 100644 --- a/frontend/src/app/api/config/test/route.ts +++ b/frontend/src/app/api/config/test/route.ts @@ -1,17 +1,12 @@ 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) { 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 resp = await fetch(`${BACKEND_BASE}/config/test`, { - method: 'POST', - 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' } }); + // 新后端暂无统一 /config/test;先返回未实现 + const body = await req.text().catch(() => ''); + return Response.json({ success: false, message: 'config/test 未实现', echo: body }, { status: 501 }); } diff --git a/frontend/src/app/api/configs/analysis_modules/route.ts b/frontend/src/app/api/configs/analysis_modules/route.ts new file mode 100644 index 0000000..50d107b --- /dev/null +++ b/frontend/src/app/api/configs/analysis_modules/route.ts @@ -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' }, + }); +} + diff --git a/frontend/src/app/api/configs/llm_providers/route.ts b/frontend/src/app/api/configs/llm_providers/route.ts new file mode 100644 index 0000000..7b4fb78 --- /dev/null +++ b/frontend/src/app/api/configs/llm_providers/route.ts @@ -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' }, + }); +} + diff --git a/frontend/src/app/api/data-requests/route.ts b/frontend/src/app/api/data-requests/route.ts index 6357e96..35a4bfb 100644 --- a/frontend/src/app/api/data-requests/route.ts +++ b/frontend/src/app/api/data-requests/route.ts @@ -1,10 +1,10 @@ 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) { 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 resp = await fetch(`${BACKEND_BASE}/data-requests`, { diff --git a/frontend/src/app/api/discover-models/[provider_id]/route.ts b/frontend/src/app/api/discover-models/[provider_id]/route.ts new file mode 100644 index 0000000..f02e008 --- /dev/null +++ b/frontend/src/app/api/discover-models/[provider_id]/route.ts @@ -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' }, + }); +} + diff --git a/frontend/src/app/api/financials/[...slug]/route.ts b/frontend/src/app/api/financials/[...slug]/route.ts index f2f9142..be57312 100644 --- a/frontend/src/app/api/financials/[...slug]/route.ts +++ b/frontend/src/app/api/financials/[...slug]/route.ts @@ -1,27 +1,58 @@ 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( req: NextRequest, context: { params: Promise<{ slug: string[] }> } ) { 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 { slug } = await context.params; - const path = slug.join('/'); - const target = `${BACKEND_BASE}/financials/${path}${url.search}`; - const resp = await fetch(target, { headers: { 'Content-Type': 'application/json' } }); - // 透传后端响应(支持流式 body) - const headers = new Headers(); - // 复制关键头,减少代理层缓冲 - const contentType = resp.headers.get('content-type') || 'application/json; charset=utf-8'; - headers.set('content-type', contentType); - const cacheControl = resp.headers.get('cache-control'); - if (cacheControl) headers.set('cache-control', cacheControl); - const xAccelBuffering = resp.headers.get('x-accel-buffering'); - if (xAccelBuffering) headers.set('x-accel-buffering', xAccelBuffering); - return new Response(resp.body, { status: resp.status, headers }); + const first = slug?.[0]; + // 适配旧接口:analysis-config → 新分析模块配置 + if (first === 'analysis-config') { + const resp = await fetch(`${BACKEND_BASE}/configs/analysis_modules`, { 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' }, + }); + } + // 适配旧接口:config → 聚合配置 + if (first === 'config') { + 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 }); } diff --git a/frontend/src/app/api/reports/[id]/route.ts b/frontend/src/app/api/reports/[id]/route.ts index f3f1a09..6a6b295 100644 --- a/frontend/src/app/api/reports/[id]/route.ts +++ b/frontend/src/app/api/reports/[id]/route.ts @@ -1,6 +1,6 @@ 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( req: NextRequest, diff --git a/frontend/src/app/api/reports/route.ts b/frontend/src/app/api/reports/route.ts index a234692..b2fa7d7 100644 --- a/frontend/src/app/api/reports/route.ts +++ b/frontend/src/app/api/reports/route.ts @@ -1,8 +1,6 @@ export const runtime = 'nodejs' import { NextRequest } from 'next/server' -const BACKEND_BASE = process.env.NEXT_PUBLIC_BACKEND_URL; - export async function GET(req: NextRequest) { // 历史报告列表功能在新架构中由后端持久化服务统一提供。 // 当前网关未提供“全量列表”接口(需要 symbol 条件),因此此路由返回空集合。 diff --git a/frontend/src/app/api/tasks/[request_id]/route.ts b/frontend/src/app/api/tasks/[request_id]/route.ts index 089de0e..e151167 100644 --- a/frontend/src/app/api/tasks/[request_id]/route.ts +++ b/frontend/src/app/api/tasks/[request_id]/route.ts @@ -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( _req: Request, context: { params: Promise<{ request_id: string }> } ) { 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 target = `${BACKEND_BASE}/tasks/${encodeURIComponent(request_id)}`; diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx index fcf4460..27a4fce 100644 --- a/frontend/src/app/config/page.tsx +++ b/frontend/src/app/config/page.tsx @@ -1,8 +1,12 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useConfig, updateConfig, testConfig, useAnalysisConfig, updateAnalysisConfig } from '@/hooks/useApi'; -import { useConfigStore, SystemConfig } from '@/stores/useConfigStore'; +import { + 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 { Input } from "@/components/ui/input"; 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 { Separator } from "@/components/ui/separator"; 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() { // 从 Zustand store 获取全局状态 @@ -20,8 +26,8 @@ export default function ConfigPage() { // 使用 SWR hook 加载初始配置 useConfig(); - // 加载分析配置 - const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig(); + // 加载分析配置(统一使用 initialAnalysisModules) + // const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisModules(); // 本地表单状态 const [newApiApiKey, setNewApiApiKey] = useState(''); @@ -30,16 +36,9 @@ export default function ConfigPage() { const [finnhubApiKey, setFinnhubApiKey] = useState(''); // 分析配置的本地状态 - const [localAnalysisConfig, setLocalAnalysisConfig] = useState>({}); + const [localAnalysisModules, setLocalAnalysisModules] = useState({}); - // 分析配置保存状态 - const [savingAnalysis, setSavingAnalysis] = useState(false); - const [analysisSaveMessage, setAnalysisSaveMessage] = useState(''); + // 分析配置保存状态(状态定义在下方统一维护) // 测试结果状态 const [testResults, setTestResults] = useState>({}); @@ -47,17 +46,46 @@ export default function ConfigPage() { // 保存状态 const [saving, setSaving] = useState(false); 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(() => { - if (analysisConfig?.analysis_modules) { - setLocalAnalysisConfig(analysisConfig.analysis_modules); - } - }, [analysisConfig]); + if (initialAnalysisModules) { + setLocalAnalysisModules(initialAnalysisModules); + } + }, [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) => { - setLocalAnalysisConfig(prev => ({ + setLocalAnalysisModules(prev => ({ ...prev, [type]: { ...prev[type], @@ -68,7 +96,7 @@ export default function ConfigPage() { // 更新分析模块的依赖 const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => { - setLocalAnalysisConfig(prev => { + setLocalAnalysisModules(prev => { const currentConfig = prev[type]; const currentDeps = currentConfig.dependencies || []; @@ -87,24 +115,7 @@ export default function ConfigPage() { }); }; - // 保存分析配置 - 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); - } - }; + // 旧版保存逻辑已移除,统一使用 handleSaveAnalysis const validateConfig = () => { const errors: string[] = []; @@ -241,9 +252,6 @@ export default function ConfigPage() { const importedConfig = JSON.parse(e.target?.result as string); // 验证导入的配置格式 - if (importedConfig.database?.url) { - setDbUrl(importedConfig.database.url); - } if (importedConfig.new_api?.base_url) { setNewApiBaseUrl(importedConfig.new_api.base_url); } @@ -400,102 +408,63 @@ export default function ConfigPage() { 配置各个分析模块的模型和提示词 - {Object.entries(localAnalysisConfig).map(([type, config]) => { - const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type); - + {Object.entries(localAnalysisModules).map(([moduleId, config]) => { + const availableModels = llmProviders?.[config.provider_id]?.models.filter(m => m.is_active) || []; return ( -
-
-

{config.name || type}

- {type} -
- -
- - updateAnalysisField(type, 'name', e.target.value)} - placeholder="分析模块显示名称" - /> -
- -
- - updateAnalysisField(type, 'model', e.target.value)} - placeholder="例如: gemini-1.5-pro" - /> -

- 在 AI 服务中配置的模型名称 -

-
- -
- -
- {otherModuleKeys.map(depKey => ( -
- { - updateAnalysisDependencies(type, depKey, !!checked); - }} - /> - -
- ))} -
-

- 选择此模块在生成时需要依赖的其他模块。选中的模块结果将通过占位符注入提示词模板。 -

-
- -
- -