feat: 实现动态分析配置并优化前端UI

本次提交引入了一系列重要功能,核心是实现了财务分析模块的动态配置,并对配置和报告页面的用户界面进行了改进。

主要变更:

- **动态配置:**
  - 后端实现了 `ConfigManager` 服务,用于动态管理 `analysis-config.json` 和 `config.json`。
  - 添加了用于读取和更新配置的 API 端点。
  - 开发了前端 `/config` 页面,允许用户实时查看和修改分析配置。

- **后端增强:**
  - 更新了 `AnalysisClient` 和 `CompanyProfileClient` 以使用新的配置系统。
  - 重构了财务数据相关的路由。

- **前端改进:**
  - 新增了可复用的 `Checkbox` UI 组件。
  - 使用更直观和用户友好的界面重新设计了配置页面。
  - 改进了财务报告页面的布局和数据展示。

- **文档与杂务:**
  - 更新了设计和需求文档以反映新功能。
  - 更新了前后端依赖。
  - 修改了开发脚本 `dev.sh`。
This commit is contained in:
xucheng 2025-10-30 14:50:36 +08:00
parent e0aa61b8c4
commit b5a4d2212c
18 changed files with 982 additions and 695 deletions

View File

@ -45,6 +45,177 @@ def _load_json(path: str) -> Dict:
return {} return {}
@router.post("/china/{ts_code}/analysis", response_model=List[AnalysisResponse])
async def generate_full_analysis(
ts_code: str,
company_name: str = Query(None, description="Company name for better context"),
):
"""
Generate a full analysis report by orchestrating multiple analysis modules
based on dependencies defined in the configuration.
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[API] Full analysis requested for {ts_code}")
# Load base and analysis configurations
base_cfg = _load_json(BASE_CONFIG_PATH)
llm_provider = base_cfg.get("llm", {}).get("provider", "gemini")
llm_config = base_cfg.get("llm", {}).get(llm_provider, {})
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url")
if not api_key:
logger.error(f"[API] API key for {llm_provider} not configured")
raise HTTPException(
status_code=500,
detail=f"API key for {llm_provider} not configured."
)
analysis_config_full = load_analysis_config()
modules_config = analysis_config_full.get("analysis_modules", {})
if not modules_config:
raise HTTPException(status_code=404, detail="Analysis modules configuration not found.")
# --- Dependency Resolution (Topological Sort) ---
def topological_sort(graph):
in_degree = {u: 0 for u in graph}
for u in graph:
for v in graph[u]:
in_degree[v] += 1
queue = [u for u in graph if in_degree[u] == 0]
sorted_order = []
while queue:
u = queue.pop(0)
sorted_order.append(u)
for v in graph.get(u, []):
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
if len(sorted_order) == len(graph):
return sorted_order
else:
# Detect cycles and provide a meaningful error
cycles = []
visited = set()
path = []
def find_cycle_util(node):
visited.add(node)
path.append(node)
for neighbor in graph.get(node, []):
if neighbor in path:
cycle_start_index = path.index(neighbor)
cycles.append(path[cycle_start_index:] + [neighbor])
return
if neighbor not in visited:
find_cycle_util(neighbor)
path.pop()
for node in graph:
if node not in visited:
find_cycle_util(node)
return None, cycles
# Build dependency graph
dependency_graph = {
name: config.get("dependencies", [])
for name, config in modules_config.items()
}
# Invert graph for topological sort (from dependency to dependent)
adj_list = {u: [] for u in dependency_graph}
for u, dependencies in dependency_graph.items():
for dep in dependencies:
if dep in adj_list:
adj_list[dep].append(u)
sorted_modules, cycle = topological_sort(adj_list)
if not sorted_modules:
raise HTTPException(
status_code=400,
detail=f"Circular dependency detected in analysis modules configuration. Cycle: {cycle}"
)
# --- Fetch common data (company name, financial data) ---
# This logic is duplicated, could be refactored into a helper
financial_data = None
if not company_name:
logger.info(f"[API] Fetching company name for {ts_code}")
try:
token = base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key")
if token:
tushare_client = TushareClient(token=token)
basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name")
if basic_data:
company_name = basic_data[0].get("name", ts_code)
logger.info(f"[API] Got company name: {company_name}")
except Exception as e:
logger.warning(f"Failed to get company name, proceeding with ts_code. Error: {e}")
company_name = ts_code
# --- Execute modules in order ---
analysis_results = []
completed_modules_content = {}
for module_type in sorted_modules:
module_config = modules_config[module_type]
logger.info(f"[Orchestrator] Starting analysis for module: {module_type}")
client = AnalysisClient(
api_key=api_key,
base_url=base_url,
model=module_config.get("model", "gemini-1.5-flash")
)
# Gather context from completed dependencies
context = {
dep: completed_modules_content.get(dep, "")
for dep in module_config.get("dependencies", [])
}
result = await client.generate_analysis(
analysis_type=module_type,
company_name=company_name,
ts_code=ts_code,
prompt_template=module_config.get("prompt_template", ""),
financial_data=financial_data,
context=context,
)
response = AnalysisResponse(
ts_code=ts_code,
company_name=company_name,
analysis_type=module_type,
content=result.get("content", ""),
model=result.get("model", module_config.get("model")),
tokens=result.get("tokens", {}),
elapsed_ms=result.get("elapsed_ms", 0),
success=result.get("success", False),
error=result.get("error")
)
analysis_results.append(response)
if response.success:
completed_modules_content[module_type] = response.content
else:
# If a module fails, subsequent dependent modules will get an empty string for its context.
# This prevents total failure but may affect quality.
completed_modules_content[module_type] = f"Error: Analysis for {module_type} failed."
logger.error(f"[Orchestrator] Module {module_type} failed: {response.error}")
logger.info(f"[API] Full analysis for {ts_code} completed.")
return analysis_results
@router.get("/config", response_model=FinancialConfigResponse) @router.get("/config", response_model=FinancialConfigResponse)
async def get_financial_config(): async def get_financial_config():
data = _load_json(FINANCIAL_CONFIG_PATH) data = _load_json(FINANCIAL_CONFIG_PATH)
@ -257,17 +428,24 @@ async def get_company_profile(
# Load config # Load config
base_cfg = _load_json(BASE_CONFIG_PATH) base_cfg = _load_json(BASE_CONFIG_PATH)
gemini_cfg = base_cfg.get("llm", {}).get("gemini", {}) llm_provider = base_cfg.get("llm", {}).get("provider", "gemini")
api_key = gemini_cfg.get("api_key") llm_config = base_cfg.get("llm", {}).get(llm_provider, {})
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url") # Will be None if not set, handled by client
if not api_key: if not api_key:
logger.error("[API] Gemini API key not configured") logger.error(f"[API] API key for {llm_provider} not configured")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Gemini API key not configured. Set config.json llm.gemini.api_key" detail=f"API key for {llm_provider} not configured."
) )
client = CompanyProfileClient(api_key=api_key) client = CompanyProfileClient(
api_key=api_key,
base_url=base_url,
model="gemini-1.5-flash"
)
# Get company name from ts_code if not provided # Get company name from ts_code if not provided
if not company_name: if not company_name:
@ -375,14 +553,17 @@ async def generate_analysis(
# Load config # Load config
base_cfg = _load_json(BASE_CONFIG_PATH) base_cfg = _load_json(BASE_CONFIG_PATH)
gemini_cfg = base_cfg.get("llm", {}).get("gemini", {}) llm_provider = base_cfg.get("llm", {}).get("provider", "gemini")
api_key = gemini_cfg.get("api_key") llm_config = base_cfg.get("llm", {}).get(llm_provider, {})
api_key = llm_config.get("api_key")
base_url = llm_config.get("base_url")
if not api_key: if not api_key:
logger.error("[API] Gemini API key not configured") logger.error(f"[API] API key for {llm_provider} not configured")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Gemini API key not configured. Set config.json llm.gemini.api_key" detail=f"API key for {llm_provider} not configured."
) )
# Get analysis configuration # Get analysis configuration
@ -486,7 +667,7 @@ async def generate_analysis(
logger.info(f"[API] Generating {analysis_type} for {company_name}") logger.info(f"[API] Generating {analysis_type} for {company_name}")
# Initialize analysis client with configured model # Initialize analysis client with configured model
client = AnalysisClient(api_key=api_key, model=model) client = AnalysisClient(api_key=api_key, base_url=base_url, model=model)
# Generate analysis # Generate analysis
result = await client.generate_analysis( result = await client.generate_analysis(

View File

@ -7,8 +7,8 @@ from pydantic import BaseModel, Field
class DatabaseConfig(BaseModel): class DatabaseConfig(BaseModel):
url: str = Field(..., description="数据库连接URL") url: str = Field(..., description="数据库连接URL")
class GeminiConfig(BaseModel): class NewApiConfig(BaseModel):
api_key: str = Field(..., description="Gemini API Key") api_key: str = Field(..., description="New API Key")
base_url: Optional[str] = None base_url: Optional[str] = None
class DataSourceConfig(BaseModel): class DataSourceConfig(BaseModel):
@ -16,12 +16,12 @@ class DataSourceConfig(BaseModel):
class ConfigResponse(BaseModel): class ConfigResponse(BaseModel):
database: DatabaseConfig database: DatabaseConfig
gemini_api: GeminiConfig new_api: NewApiConfig
data_sources: Dict[str, DataSourceConfig] data_sources: Dict[str, DataSourceConfig]
class ConfigUpdateRequest(BaseModel): class ConfigUpdateRequest(BaseModel):
database: Optional[DatabaseConfig] = None database: Optional[DatabaseConfig] = None
gemini_api: Optional[GeminiConfig] = None new_api: Optional[NewApiConfig] = None
data_sources: Optional[Dict[str, DataSourceConfig]] = None data_sources: Optional[Dict[str, DataSourceConfig]] = None
class ConfigTestRequest(BaseModel): class ConfigTestRequest(BaseModel):

View File

@ -1,39 +1,41 @@
""" """
Generic Analysis Client for various analysis types using Gemini API Generic Analysis Client for various analysis types using an OpenAI-compatible API
""" """
import time import time
import json import json
import os import os
from typing import Dict, Optional from typing import Dict, Optional
import google.generativeai as genai import openai
import string
class AnalysisClient: class AnalysisClient:
"""Generic client for generating various types of analysis using Gemini API""" """Generic client for generating various types of analysis using an OpenAI-compatible API"""
def __init__(self, api_key: str, model: str = "gemini-2.5-flash"): def __init__(self, api_key: str, base_url: str, model: str):
"""Initialize Gemini client with API key and model""" """Initialize OpenAI client with API key, base URL, and model"""
genai.configure(api_key=api_key) self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
self.model_name = model self.model_name = model
self.model = genai.GenerativeModel(model)
async def generate_analysis( async def generate_analysis(
self, self,
analysis_type: str, analysis_type: str,
company_name: str, company_name: str,
ts_code: str, ts_code: str,
prompt_template: str, prompt_template: str,
financial_data: Optional[Dict] = None financial_data: Optional[Dict] = None,
context: Optional[Dict] = None
) -> Dict: ) -> Dict:
""" """
Generate analysis using Gemini API (non-streaming) Generate analysis using OpenAI-compatible API (non-streaming)
Args: Args:
analysis_type: Type of analysis (e.g., "fundamental_analysis") analysis_type: Type of analysis (e.g., "fundamental_analysis")
company_name: Company name company_name: Company name
ts_code: Stock code ts_code: Stock code
prompt_template: Prompt template with placeholders {company_name}, {ts_code}, {financial_data} prompt_template: Prompt template with placeholders
financial_data: Optional financial data for context financial_data: Optional financial data for context
context: Optional dictionary with results from previous analyses
Returns: Returns:
Dict with analysis content and metadata Dict with analysis content and metadata
@ -45,31 +47,30 @@ class AnalysisClient:
prompt_template, prompt_template,
company_name, company_name,
ts_code, ts_code,
financial_data financial_data,
context
) )
# Call Gemini API (using sync API in async context) # Call OpenAI-compatible API
try: try:
import asyncio response = await self.client.chat.completions.create(
loop = asyncio.get_event_loop() model=self.model_name,
response = await loop.run_in_executor( messages=[{"role": "user", "content": prompt}],
None,
lambda: self.model.generate_content(prompt)
) )
# Get token usage content = response.choices[0].message.content if response.choices else ""
usage_metadata = response.usage_metadata if hasattr(response, 'usage_metadata') else None usage = response.usage
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return { return {
"content": response.text, "content": content,
"model": self.model_name, "model": self.model_name,
"tokens": { "tokens": {
"prompt_tokens": usage_metadata.prompt_token_count if usage_metadata else 0, "prompt_tokens": usage.prompt_tokens if usage else 0,
"completion_tokens": usage_metadata.candidates_token_count if usage_metadata else 0, "completion_tokens": usage.completion_tokens if usage else 0,
"total_tokens": usage_metadata.total_token_count if usage_metadata else 0, "total_tokens": usage.total_tokens if usage else 0,
} if usage_metadata else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, } if usage else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"success": True, "success": True,
"analysis_type": analysis_type, "analysis_type": analysis_type,
@ -91,23 +92,41 @@ class AnalysisClient:
prompt_template: str, prompt_template: str,
company_name: str, company_name: str,
ts_code: str, ts_code: str,
financial_data: Optional[Dict] = None financial_data: Optional[Dict] = None,
context: Optional[Dict] = None
) -> str: ) -> str:
"""Build prompt from template by replacing placeholders""" """Build prompt from template by replacing placeholders"""
# Format financial data as string if provided
# Start with base placeholders
placeholders = {
"company_name": company_name,
"ts_code": ts_code,
}
# Add financial data if provided
financial_data_str = "" financial_data_str = ""
if financial_data: if financial_data:
try: try:
financial_data_str = json.dumps(financial_data, ensure_ascii=False, indent=2) financial_data_str = json.dumps(financial_data, ensure_ascii=False, indent=2)
except Exception: except Exception:
financial_data_str = str(financial_data) financial_data_str = str(financial_data)
placeholders["financial_data"] = financial_data_str
# Add context from previous analysis steps
if context:
placeholders.update(context)
# Replace placeholders in template # Replace placeholders in template
prompt = prompt_template.format( # Use a custom formatter to handle missing keys gracefully
company_name=company_name, class SafeFormatter(string.Formatter):
ts_code=ts_code, def get_value(self, key, args, kwargs):
financial_data=financial_data_str if isinstance(key, str):
) return kwargs.get(key, f"{{{key}}}")
else:
return super().get_value(key, args, kwargs)
formatter = SafeFormatter()
prompt = formatter.format(prompt_template, **placeholders)
return prompt return prompt

View File

@ -1,16 +1,16 @@
""" """
Google Gemini API Client for company profile generation OpenAI-compatible API Client for company profile generation
""" """
import time import time
from typing import Dict, List, Optional from typing import Dict, List, Optional
import google.generativeai as genai import openai
class CompanyProfileClient: class CompanyProfileClient:
def __init__(self, api_key: str): def __init__(self, api_key: str, base_url: str, model: str = "gemini-1.5-flash"):
"""Initialize Gemini client with API key""" """Initialize OpenAI client with API key, base_url and model"""
genai.configure(api_key=api_key) self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url)
self.model = genai.GenerativeModel("gemini-2.5-flash") self.model_name = model
async def generate_profile( async def generate_profile(
self, self,
@ -19,7 +19,7 @@ class CompanyProfileClient:
financial_data: Optional[Dict] = None financial_data: Optional[Dict] = None
) -> Dict: ) -> Dict:
""" """
Generate company profile using Gemini API (non-streaming) Generate company profile using OpenAI-compatible API (non-streaming)
Args: Args:
company_name: Company name company_name: Company name
@ -34,29 +34,26 @@ class CompanyProfileClient:
# Build prompt # Build prompt
prompt = self._build_prompt(company_name, ts_code, financial_data) prompt = self._build_prompt(company_name, ts_code, financial_data)
# Call Gemini API (using sync API in async context) # Call OpenAI-compatible API
try: try:
# Run synchronous API call in executor response = await self.client.chat.completions.create(
import asyncio model=self.model_name,
loop = asyncio.get_event_loop() messages=[{"role": "user", "content": prompt}],
response = await loop.run_in_executor(
None,
lambda: self.model.generate_content(prompt)
) )
# Get token usage content = response.choices[0].message.content if response.choices else ""
usage_metadata = response.usage_metadata if hasattr(response, 'usage_metadata') else None usage = response.usage
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return { return {
"content": response.text, "content": content,
"model": "gemini-2.5-flash", "model": self.model_name,
"tokens": { "tokens": {
"prompt_tokens": usage_metadata.prompt_token_count if usage_metadata else 0, "prompt_tokens": usage.prompt_tokens if usage else 0,
"completion_tokens": usage_metadata.candidates_token_count if usage_metadata else 0, "completion_tokens": usage.completion_tokens if usage else 0,
"total_tokens": usage_metadata.total_token_count if usage_metadata else 0, "total_tokens": usage.total_tokens if usage else 0,
} if usage_metadata else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, } if usage else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"success": True, "success": True,
} }
@ -64,7 +61,7 @@ class CompanyProfileClient:
elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000)
return { return {
"content": "", "content": "",
"model": "gemini-2.5-flash", "model": self.model_name,
"tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"success": False, "success": False,

View File

@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, GeminiConfig, DataSourceConfig, ConfigTestResponse from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, NewApiConfig, DataSourceConfig, ConfigTestResponse
class ConfigManager: class ConfigManager:
"""Manages system configuration by merging a static JSON file with dynamic settings from the database.""" """Manages system configuration by merging a static JSON file with dynamic settings from the database."""
@ -69,12 +69,12 @@ class ConfigManager:
merged_config = self._merge_configs(base_config, db_config) merged_config = self._merge_configs(base_config, db_config)
# 兼容两种位置:优先使用 gemini_api其次回退到 llm.gemini # 兼容两种位置:优先使用 new_api其次回退到 llm.new_api
gemini_src = merged_config.get("gemini_api") or merged_config.get("llm", {}).get("gemini", {}) new_api_src = merged_config.get("new_api") or merged_config.get("llm", {}).get("new_api", {})
return ConfigResponse( return ConfigResponse(
database=DatabaseConfig(**merged_config.get("database", {})), database=DatabaseConfig(**merged_config.get("database", {})),
gemini_api=GeminiConfig(**(gemini_src or {})), new_api=NewApiConfig(**(new_api_src or {})),
data_sources={ data_sources={
k: DataSourceConfig(**v) k: DataSourceConfig(**v)
for k, v in merged_config.get("data_sources", {}).items() for k, v in merged_config.get("data_sources", {}).items()
@ -117,14 +117,14 @@ class ConfigManager:
if not url.startswith(("postgresql://", "postgresql+asyncpg://")): if not url.startswith(("postgresql://", "postgresql+asyncpg://")):
raise ValueError("数据库URL必须以 postgresql:// 或 postgresql+asyncpg:// 开头") raise ValueError("数据库URL必须以 postgresql:// 或 postgresql+asyncpg:// 开头")
if "gemini_api" in config_data: if "new_api" in config_data:
gemini_config = config_data["gemini_api"] new_api_config = config_data["new_api"]
if "api_key" in gemini_config and len(gemini_config["api_key"]) < 10: if "api_key" in new_api_config and len(new_api_config["api_key"]) < 10:
raise ValueError("Gemini API Key长度不能少于10个字符") raise ValueError("New API Key长度不能少于10个字符")
if "base_url" in gemini_config and gemini_config["base_url"]: if "base_url" in new_api_config and new_api_config["base_url"]:
base_url = gemini_config["base_url"] base_url = new_api_config["base_url"]
if not base_url.startswith(("http://", "https://")): if not base_url.startswith(("http://", "https://")):
raise ValueError("Gemini Base URL必须以 http:// 或 https:// 开头") raise ValueError("New API Base URL必须以 http:// 或 https:// 开头")
if "data_sources" in config_data: if "data_sources" in config_data:
for source_name, source_config in config_data["data_sources"].items(): for source_name, source_config in config_data["data_sources"].items():
@ -136,8 +136,8 @@ class ConfigManager:
try: try:
if config_type == "database": if config_type == "database":
return await self._test_database(config_data) return await self._test_database(config_data)
elif config_type == "gemini": elif config_type == "new_api":
return await self._test_gemini(config_data) return await self._test_new_api(config_data)
elif config_type == "tushare": elif config_type == "tushare":
return await self._test_tushare(config_data) return await self._test_tushare(config_data)
elif config_type == "finnhub": elif config_type == "finnhub":
@ -181,39 +181,39 @@ class ConfigManager:
message=f"数据库连接失败: {str(e)}" message=f"数据库连接失败: {str(e)}"
) )
async def _test_gemini(self, config_data: Dict[str, Any]) -> ConfigTestResponse: async def _test_new_api(self, config_data: Dict[str, Any]) -> ConfigTestResponse:
"""Test Gemini API connection.""" """Test New API (OpenAI-compatible) connection."""
api_key = config_data.get("api_key") api_key = config_data.get("api_key")
base_url = config_data.get("base_url", "https://generativelanguage.googleapis.com/v1beta") base_url = config_data.get("base_url")
if not api_key: if not api_key or not base_url:
return ConfigTestResponse( return ConfigTestResponse(
success=False, success=False,
message="Gemini API Key不能为空" message="New API Key和Base URL均不能为空"
) )
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
# 测试API可用性 # Test API availability by listing models
response = await client.get( response = await client.get(
f"{base_url}/models", f"{base_url.rstrip('/')}/models",
headers={"x-goog-api-key": api_key} headers={"Authorization": f"Bearer {api_key}"}
) )
if response.status_code == 200: if response.status_code == 200:
return ConfigTestResponse( return ConfigTestResponse(
success=True, success=True,
message="Gemini API连接成功" message="New API连接成功"
) )
else: else:
return ConfigTestResponse( return ConfigTestResponse(
success=False, success=False,
message=f"Gemini API测试失败: HTTP {response.status_code}" message=f"New API测试失败: HTTP {response.status_code} - {response.text}"
) )
except Exception as e: except Exception as e:
return ConfigTestResponse( return ConfigTestResponse(
success=False, success=False,
message=f"Gemini API连接失败: {str(e)}" message=f"New API连接失败: {str(e)}"
) )
async def _test_tushare(self, config_data: Dict[str, Any]) -> ConfigTestResponse: async def _test_tushare(self, config_data: Dict[str, Any]) -> ConfigTestResponse:

View File

@ -1,9 +1,9 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
httpx==0.27.2 httpx==0.27.0
pydantic-settings==2.5.2 pydantic-settings==2.5.2
SQLAlchemy==2.0.36 SQLAlchemy==2.0.36
aiosqlite==0.20.0 aiosqlite==0.20.0
alembic==1.13.3 alembic==1.13.3
google-generativeai==0.8.3 openai==1.37.0
asyncpg==0.29.0 asyncpg

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,13 @@
{ {
"llm": { "llm": {
"provider": "gemini", "provider": "new_api",
"gemini": { "gemini": {
"base_url": "", "base_url": "",
"api_key": "AIzaSyCe4KpiRWFU3hnP-iwWvDR28ZCEzFnN0x0" "api_key": "YOUR_GEMINI_API_KEY"
},
"new_api": {
"base_url": "http://192.168.3.214:3000/v1",
"api_key": "sk-DdTTQ5fdU1aFW6gnYxSNYDgFsVQg938zUcmY4vaB7oPtcNs7"
} }
}, },
"data_sources": { "data_sources": {

View File

@ -22,12 +22,13 @@
### 2.1. 架构概述 ### 2.1. 架构概述
系统采用前后端分离的现代化Web架构 系统采用前后端分离的现代化Web架构并通过前端API代理到后端
- **前端 (Frontend)**基于React (Next.js) 的单页面应用 (SPA)负责用户界面和交互逻辑。它通过RESTful API与后端通信。 - **前端 (Frontend)**:基于 React (Next.js App Router) 的应用,负责用户界面与交互。前端通过 Next.js 内置的 API 路由作为代理,转发请求到后端(`NEXT_PUBLIC_BACKEND_URL` 可配置,默认 `http://127.0.0.1:8000/api`)。
- **后端 (Backend)**基于Python FastAPI框架的异步API服务负责处理所有业务逻辑、数据操作和与外部服务的集成。 - **前端 API 代理**`/frontend/src/app/api/**` 下的路由将前端请求转发至后端对应路径,统一处理`Content-Type`与状态码。
- **数据库 (Database)**采用PostgreSQL作为关系型数据库存储所有持久化数据。 - **后端 (Backend)**:基于 Python FastAPI 的异步 API 服务负责财务数据聚合、AI生成、配置管理与分析配置管理。
- **异步任务队列**: 利用FastAPI的`BackgroundTasks`处理耗时的报告生成任务避免阻塞API请求并允许实时进度追踪。 - **数据库 (Database)**:使用 PostgreSQL已具备模型与迁移脚手架。当前 MVP 未落地“报告持久化”,主要以即时查询/生成返回为主;后续迭代将启用。
- **异步任务**:当前 MVP 版本以“同步请求-响应”为主不使用流式SSE或队列后续可评估以 SSE/队列增强体验。
![System Architecture Diagram](https://i.imgur.com/example.png) <!-- Placeholder for a real diagram --> ![System Architecture Diagram](https://i.imgur.com/example.png) <!-- Placeholder for a real diagram -->
@ -35,12 +36,14 @@
| 层次 | 技术 | 理由 | | 层次 | 技术 | 理由 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **前端** | React (Next.js), TypeScript, Shadcn/UI | 提供优秀的开发体验、类型安全、高性能的服务端渲染(SSR)和丰富的UI组件库。 | | **前端** | React (Next.js App Router), TypeScript, Shadcn/UI, SWR | App Router 简化路由与服务能力SWR 负责数据获取与缓存。 |
| **后端** | Python, FastAPI, SQLAlchemy (Async) | 异步框架带来高并发性能Python拥有强大的数据处理和AI生态SQLAlchemy提供强大的ORM能力。 | | **后端** | Python, FastAPI, SQLAlchemy (Async) | 异步框架带来高并发性能Python拥有强大的数据处理和AI生态SQLAlchemy提供强大的ORM能力。 |
| **数据库** | PostgreSQL | 功能强大、稳定可靠的开源关系型数据库支持JSONB等高级数据类型适合存储结构化报告数据。 | | **数据库** | PostgreSQL | 功能强大、稳定可靠的开源关系型数据库支持JSONB等高级数据类型适合存储结构化报告数据。 |
| **数据源** | Tushare API, Yahoo Finance | Tushare提供全面的中国市场数据Yahoo Finance作为其他市场的补充易于集成。 | | **数据源** | Tushare API, Yahoo Finance | Tushare提供全面的中国市场数据Yahoo Finance作为其他市场的补充易于集成。 |
| **AI模型** | Google Gemini | 强大的大语言模型,能够根据指令生成高质量的业务分析内容。 | | **AI模型** | Google Gemini | 强大的大语言模型,能够根据指令生成高质量的业务分析内容。 |
注:当前实现通过 `config/financial-tushare.json` 配置需要拉取的 Tushare 指标分组,通过 `config/analysis-config.json` 配置各分析模块名称、模型与 Prompt 模板。
## 3. 后端设计 (Backend Design) ## 3. 后端设计 (Backend Design)
### 3.1. 核心服务设计 ### 3.1. 核心服务设计
@ -54,32 +57,44 @@
- **ConfigManager (配置管理器)**: 负责从`config.json`或数据库中加载、更新和验证系统配置如API密钥、数据库URL - **ConfigManager (配置管理器)**: 负责从`config.json`或数据库中加载、更新和验证系统配置如API密钥、数据库URL
- **ProgressTracker (进度追踪器)**: 负责在报告生成的每个阶段更新状态、耗时和Token使用情况并提供给前端查询。 - **ProgressTracker (进度追踪器)**: 负责在报告生成的每个阶段更新状态、耗时和Token使用情况并提供给前端查询。
### 3.2. 异步任务处理 ### 3.2. 任务执行模型MVP
报告生成是一个耗时操作将通过FastAPI的`BackgroundTasks`在后台执行。当用户请求生成新报告时API会立即返回一个报告ID和“生成中”的状态并将生成任务添加到后台队列。前端可以通过该ID轮询或使用WebSocket/SSE来获取实时进度。 当前 MVP 以“按需生成/查询”的同步调用为主:
### 3.3. API 端点设计 - 财务数据请求到达后端即刻聚合最新数据并返回包含元信息耗时、步骤、API 调用统计)。
- 公司简介与各分析模块:到达后端实时调用 Gemini 生成,返回内容与 Token/耗时指标。
- 前端在同一页面内顺序执行“公司简介 → 各分析模块”,并以本地状态记录执行轨迹,不依赖 SSE/队列。
后端将提供以下RESTful API端点 ### 3.3. API 端点设计(当前实现)
后端 FastAPI 实现的主要端点(被前端 Next.js API 代理转发):
| Method | Endpoint | 描述 | 请求体/参数 | 响应体 | | Method | Endpoint | 描述 | 请求体/参数 | 响应体 |
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| `POST` | `/api/reports` | 创建或获取报告。如果报告已存在,返回现有报告;否则,启动后台任务生成新报告。 | `symbol`, `market` | `ReportResponse` | | `GET` | `/api/financials/config` | 获取财务指标分组配置 | - | `FinancialConfigResponse` |
| `POST` | `/api/reports/regenerate` | 强制重新生成报告。 | `symbol`, `market` | `ReportResponse` | | `GET` | `/api/financials/china/{ts_code}` | 聚合中国市场财务数据(按年汇总,含元信息与步骤) | `years` | `BatchFinancialDataResponse` |
| `GET` | `/api/reports/{report_id}` | 获取特定报告的详细内容,包括所有分析模块。 | `report_id` (UUID) | `ReportResponse` | | `GET` | `/api/financials/china/{ts_code}/company-profile` | 生成公司简介Gemini同步 | `company_name?` | `CompanyProfileResponse` |
| `GET` | `/api/reports` | 获取报告列表,支持分页和筛选。 | `skip`, `limit`, `status` | `List[ReportResponse]` | | `GET` | `/api/financials/analysis-config` | 获取分析模块配置 | - | `AnalysisConfigResponse` |
| `GET` | `/api/progress/stream/{report_id}` | (SSE) 实时流式传输报告生成进度。 | `report_id` (UUID) | `ProgressResponse` (Stream) | | `PUT` | `/api/financials/analysis-config` | 更新分析模块配置 | `AnalysisConfigResponse` | `AnalysisConfigResponse` |
| `GET` | `/api/config` | 获取当前系统所有配置。 | - | `ConfigResponse` | | `POST` | `/api/financials/china/{ts_code}/analysis` | 生成完整的分析报告(根据依赖关系编排) | `company_name?` | `List[AnalysisResponse]` |
| `PUT` | `/api/config` | 更新系统配置。 | `ConfigUpdateRequest` | `ConfigResponse` | | `GET` | `/api/config` | 获取系统配置 | - | `ConfigResponse` |
| `POST`| `/api/config/test` | 测试特定配置的有效性(如数据库连接)。 | `ConfigTestRequest` | `ConfigTestResponse` | | `PUT` | `/api/config` | 更新系统配置 | `ConfigUpdateRequest` | `ConfigResponse` |
| `POST` | `/api/config/test` | 测试配置有效性(数据库/Gemini/Tushare 等) | `ConfigTestRequest` | `ConfigTestResponse` |
说明:前端对应的代理路径如下(示例):
- 前端请求 `/api/financials/china/...` → 代理到后端 `${BACKEND_BASE}/financials/china/...`
- 前端请求 `/api/config`、`/api/config/test` → 代理到后端 `${BACKEND_BASE}/config`、`${BACKEND_BASE}/config/test`
## 4. 数据库设计 ## 4. 数据库设计
### 4.1. 数据模型 (Schema) ### 4.1. 数据模型 (Schema)
【规划中】以下表结构为后续“报告持久化与历史管理”功能的设计草案,当前 MVP 未启用:
**1. `reports` (报告表)** **1. `reports` (报告表)**
存储报告的元数据。 用于存储报告的元数据。
| 字段名 | 类型 | 描述 | 示例 | | 字段名 | 类型 | 描述 | 示例 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
@ -135,24 +150,76 @@
## 5. 前端设计 (Frontend Design) ## 5. 前端设计 (Frontend Design)
### 5.1. 组件设计 ### 5.1. 组件设计(当前实现)
- **`StockInputForm`**: 首页的核心组件,包含证券代码输入框和交易市场选择器。 - **`ReportPage` (`frontend/src/app/report/[symbol]/page.tsx`)**:报告主页面,基于 Tabs 展示:股价图表、财务数据表格、公司简介、各分析模块、执行详情。
- **`ReportPage`**: 报告的主页面,根据报告状态显示历史报告、进度追踪器或完整的分析模块。 - 中国市场 `ts_code` 规范化支持纯6位数字自动推断交易所0/3→SZ6→SH
- **`ProgressTracker`**: 实时进度组件通过订阅SSE或定时轮询来展示报告生成的步骤、状态和耗时。 - “公司简介 → 各分析模块”按顺序串行执行,带状态、耗时与 Token 统计,可单项重试。
- **`ModuleNavigator`**: 报告页面的侧边栏或顶部导航,允许用户在不同的分析模块间切换。 - 财务数据表按年列展示,包含主要指标、费用率、资产占比、周转能力、人均效率、市场表现等分组;含多项计算型指标与高亮规则(如 ROE/ROIC>12%)。
- **`ModuleViewer`**: 用于展示单个分析模块内容的组件,能渲染从`content` (JSONB)字段解析出的文本、图表和表格。 - **`TradingViewWidget`**:嵌入式股价图表,展示传入 `symbol` 的行情。
- **`ConfigPage`**: 系统配置页面提供表单来修改和测试数据库、API密钥等配置。 - **`ConfigPage` (`frontend/src/app/config/page.tsx`)**:配置中心,支持数据库/Gemini/Tushare/Finnhub 配置查看、保存与测试支持分析模块名称、模型、Prompt 模板的在线编辑与保存。
- **前端 API 代理**`/frontend/src/app/api/**` 路由用于转发到后端,解耦部署与本地开发。
### 5.2. 页面与路由 ### 5.2. 页面与路由(当前实现)
- `/`: 首页,展示`StockInputForm` - `/`: 入口页
- `/report/{symbol}`: 报告页面,动态路由,根据查询参数(如`market`)加载`ReportPage` - `/report/[symbol]?market=china|cn|...`: 报告页面(当前主要支持中国市场)
- `/report/{symbol}/{moduleId}`: 模块详情页,展示特定分析模块的内容 - `/config`: 配置中心
- `/config`: 系统配置页面,展示`ConfigPage` - `/docs`, `/logs`, `/reports`, `/query`: 辅助页面(如存在)
### 5.3. 状态管理 ### 5.3. 状态管理(当前实现)
- 使用Zustand或React Context进行全局状态管理主要管理用户信息、系统配置和当前的报告状态。 - **数据获取**:使用 SWR`useSWR`)封装于 `frontend/src/hooks/useApi.ts` 中,提供 `useChinaFinancials`、`useFinancialConfig`、`useAnalysisConfig`、`useConfig` 等。
- 组件内部状态将使用React的`useState`和`useReducer`。 - **全局配置**:使用 Zustand `useConfigStore` 存放系统配置与加载状态。
- 使用React Query或SWR来管理API数据获取、缓存和同步简化数据获取逻辑并提升用户体验。 - **页面局部状态**`ReportPage` 使用 `useState/useMemo/useRef` 管理任务队列、当前任务、计时器、执行记录与各模块的内容/错误/耗时/Token。
注:当前无 SSE进度条和“执行详情”来自本地状态与后端返回的元信息财务数据接口含步骤与耗时统计
### 5.4. 指标与显示规范(要点)
- 百分比字段支持 01 与百分数两种输入,自动规范化显示为 `%`
- 金额类(损益/资产负债/现金流)按“亿元/亿股”等进行缩放显示,市值按“亿元”整数化展示。
- 计算项示例:自由现金流 = 经营现金流 资本开支;费用率=费用/收入;其他费用率=毛利率−净利率−销售−管理−研发。
- 高亮规则:例如 ROE/ROIC>12% 标绿色背景,增长率为负标红等。
### 5.5. 分析模块编排
系统的核心能力之一是允许分析模块之间传递信息,即一个模块可以利用前序模块的分析结果作为上下文。这通过模块间的**依赖关系Dependencies**实现。
- **编排方式**
- 前端不再独立、依次请求每个分析模块,而是通过调用一个统一的编排端点 `POST /api/financials/china/{ts_code}/analysis` 来发起一次完整的报告生成任务。
- 后端编排器会读取 `config/analysis-config.json` 中定义的模块依赖关系,通过**拓扑排序**算法智能地计算出最优执行顺序,确保被依赖的模块总是先于依赖它的模块执行。
- **依赖配置与上下文注入**
- **如何配置**:在前端的“配置中心”页面,可以为每个分析模块通过复选框勾选其需要依赖的其他模块。保存后,这将在 `analysis-config.json` 对应模块下生成一个 `dependencies` 数组,例如:
```json
"bull_case": {
"name": "看涨分析",
"dependencies": ["fundamental_analysis"],
"prompt_template": "基于以下基本面分析:\n{fundamental_analysis}\n\n请生成看涨分析报告。"
}
```
- **如何工作**:当后端执行时,它会先运行 `fundamental_analysis` 模块,然后将其生成的**全部文本结果**,完整地替换掉 `bull_case` 模块提示词模板中的 `{fundamental_analysis}` 占位符。这样AI在执行看涨分析时就已经获得了充分的上下文。
- **重要:未配置依赖但使用占位符的后果**
- 如果您**没有**在配置中为一个模块(如 `bull_case`)勾选其对另一个模块(如 `fundamental_analysis`)的依赖,但依然在其提示词模板中使用了对应的占位符(`{fundamental_analysis}`),系统**不会报错**,但会发生以下情况:
1. **执行顺序不保证**:编排器认为这两个模块独立,`fundamental_analysis` 不一定会在 `bull_case` 之前运行。
2. **上下文不会被注入**:即使前序模块先运行完,由于 `dependencies` 列表为空,系统也不会进行占位符替换。
- **最终结果**:该占位符将作为**纯文本**(例如字符串 `"{fundamental_analysis}"`)被原封不动地包含在提示词中并发送给大语言模型。这通常会导致分析质量下降,因为模型会收到一个它无法理解的指令片段,但得益于后端的安全机制(`SafeFormatter`),整个流程不会因缺少数据而崩溃。
- **结论****必须通过配置页面的复选框明确声明依赖关系**,才能激活模块间的上下文传递。
- **失败处理**
- 当前的编排流程中,如果一个模块执行失败,依赖于它的后续模块在生成提示词时,会收到一段表示错误的文本(例如 `"Error: Analysis for a_module_failed."`)作为上下文,而不是空字符串。这可以防止后续模块因缺少信息而生成质量过低的内容,同时也为调试提供了线索。
- 原有的单项重试功能已由全局的“重新运行完整分析”按钮取代,以适应新的编排模式。
### 5.6. 股票代码规范化(中国市场)
- 输入为 6 位数字时自动添加交易所后缀:首位 `6`→`.SH``0/3`→`.SZ`;已有后缀将转为大写保留。
## 6. 与最初规划的差异与后续计划
- **报告持久化/历史列表/再生成功能**:目前未实现,仍按需生成并即时展示;后续将启用数据库表 `reports`、`analysis_modules` 与 `progress_tracking` 完成闭环。
- **SSE/队列化执行**:当前为前端串行请求 + 本地状态记录;后续可引入 SSE 或任务队列以提供更丝滑的进度与并发控制。
- **多市场支持**当前聚焦中国市场Tushare后续将补充美股等数据源与规则。
- **权限与用户体系**MVP 暂无;后续纳入登录、权限与审计。
本设计文档已对“当前实现”与“后续规划”进行了清晰标注,便于开发与验收。

View File

@ -8,6 +8,7 @@
"name": "frontend", "name": "frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@ -1049,6 +1050,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View File

@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",

View File

@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 type { AnalysisConfigResponse } from '@/types'; import type { AnalysisConfigResponse } from '@/types';
export default function ConfigPage() { export default function ConfigPage() {
@ -24,8 +25,8 @@ export default function ConfigPage() {
// 本地表单状态 // 本地表单状态
const [dbUrl, setDbUrl] = useState(''); const [dbUrl, setDbUrl] = useState('');
const [geminiApiKey, setGeminiApiKey] = useState(''); const [newApiApiKey, setNewApiApiKey] = useState('');
const [geminiBaseUrl, setGeminiBaseUrl] = useState(''); const [newApiBaseUrl, setNewApiBaseUrl] = useState('');
const [tushareApiKey, setTushareApiKey] = useState(''); const [tushareApiKey, setTushareApiKey] = useState('');
const [finnhubApiKey, setFinnhubApiKey] = useState(''); const [finnhubApiKey, setFinnhubApiKey] = useState('');
@ -34,6 +35,7 @@ export default function ConfigPage() {
name: string; name: string;
model: string; model: string;
prompt_template: string; prompt_template: string;
dependencies?: string[];
}>>({}); }>>({});
// 分析配置保存状态 // 分析配置保存状态
@ -65,6 +67,27 @@ export default function ConfigPage() {
})); }));
}; };
// 更新分析模块的依赖
const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => {
setLocalAnalysisConfig(prev => {
const currentConfig = prev[type];
const currentDeps = currentConfig.dependencies || [];
const newDeps = checked
? [...currentDeps, dependency]
// 移除依赖,并去重
: currentDeps.filter(d => d !== dependency);
return {
...prev,
[type]: {
...currentConfig,
dependencies: [...new Set(newDeps)] // 确保唯一性
}
};
});
};
// 保存分析配置 // 保存分析配置
const handleSaveAnalysisConfig = async () => { const handleSaveAnalysisConfig = async () => {
setSavingAnalysis(true); setSavingAnalysis(true);
@ -92,14 +115,14 @@ export default function ConfigPage() {
errors.push('数据库URL格式不正确应为 postgresql://user:pass@host:port/dbname'); errors.push('数据库URL格式不正确应为 postgresql://user:pass@host:port/dbname');
} }
// 验证Gemini Base URL格式 // 验证New API Base URL格式
if (geminiBaseUrl && !geminiBaseUrl.match(/^https?:\/\/.+/)) { if (newApiBaseUrl && !newApiBaseUrl.match(/^https?:\/\/.+/)) {
errors.push('Gemini Base URL格式不正确应为 http:// 或 https:// 开头'); errors.push('New API Base URL格式不正确应为 http:// 或 https:// 开头');
} }
// 验证API Key长度基本检查 // 验证API Key长度基本检查
if (geminiApiKey && geminiApiKey.length < 10) { if (newApiApiKey && newApiApiKey.length < 10) {
errors.push('Gemini API Key长度过短'); errors.push('New API Key长度过短');
} }
if (tushareApiKey && tushareApiKey.length < 10) { if (tushareApiKey && tushareApiKey.length < 10) {
@ -131,10 +154,10 @@ export default function ConfigPage() {
newConfig.database = { url: dbUrl }; newConfig.database = { url: dbUrl };
} }
if (geminiApiKey || geminiBaseUrl) { if (newApiApiKey || newApiBaseUrl) {
newConfig.gemini_api = { newConfig.new_api = {
api_key: geminiApiKey || config?.gemini_api?.api_key || '', api_key: newApiApiKey || config?.new_api?.api_key || '',
base_url: geminiBaseUrl || config?.gemini_api?.base_url || undefined, base_url: newApiBaseUrl || config?.new_api?.base_url || undefined,
}; };
} }
@ -151,7 +174,7 @@ export default function ConfigPage() {
setConfig(updated); // 更新全局状态 setConfig(updated); // 更新全局状态
setSaveMessage('保存成功!'); setSaveMessage('保存成功!');
// 清空敏感字段输入 // 清空敏感字段输入
setGeminiApiKey(''); setNewApiApiKey('');
setTushareApiKey(''); setTushareApiKey('');
setFinnhubApiKey(''); setFinnhubApiKey('');
} catch (e: any) { } catch (e: any) {
@ -178,10 +201,10 @@ export default function ConfigPage() {
handleTest('database', { url: dbUrl }); handleTest('database', { url: dbUrl });
}; };
const handleTestGemini = () => { const handleTestNewApi = () => {
handleTest('gemini', { handleTest('new_api', {
api_key: geminiApiKey || config?.gemini_api?.api_key, api_key: newApiApiKey || config?.new_api?.api_key,
base_url: geminiBaseUrl || config?.gemini_api?.base_url base_url: newApiBaseUrl || config?.new_api?.base_url
}); });
}; };
@ -195,8 +218,8 @@ export default function ConfigPage() {
const handleReset = () => { const handleReset = () => {
setDbUrl(''); setDbUrl('');
setGeminiApiKey(''); setNewApiApiKey('');
setGeminiBaseUrl(''); setNewApiBaseUrl('');
setTushareApiKey(''); setTushareApiKey('');
setFinnhubApiKey(''); setFinnhubApiKey('');
setTestResults({}); setTestResults({});
@ -208,7 +231,7 @@ export default function ConfigPage() {
const configToExport = { const configToExport = {
database: config.database, database: config.database,
gemini_api: config.gemini_api, new_api: config.new_api,
data_sources: config.data_sources, data_sources: config.data_sources,
export_time: new Date().toISOString(), export_time: new Date().toISOString(),
version: "1.0" version: "1.0"
@ -238,8 +261,8 @@ export default function ConfigPage() {
if (importedConfig.database?.url) { if (importedConfig.database?.url) {
setDbUrl(importedConfig.database.url); setDbUrl(importedConfig.database.url);
} }
if (importedConfig.gemini_api?.base_url) { if (importedConfig.new_api?.base_url) {
setGeminiBaseUrl(importedConfig.gemini_api.base_url); setNewApiBaseUrl(importedConfig.new_api.base_url);
} }
setSaveMessage('配置导入成功,请检查并保存'); setSaveMessage('配置导入成功,请检查并保存');
@ -322,39 +345,39 @@ export default function ConfigPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>AI </CardTitle> <CardTitle>AI </CardTitle>
<CardDescription>Google Gemini API </CardDescription> <CardDescription>New API ( OpenAI )</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="gemini-api-key">API Key</Label> <Label htmlFor="new-api-key">API Key</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="gemini-api-key" id="new-api-key"
type="password" type="password"
value={geminiApiKey} value={newApiApiKey}
onChange={(e) => setGeminiApiKey(e.target.value)} onChange={(e) => setNewApiApiKey(e.target.value)}
placeholder="留空表示保持当前值" placeholder="留空表示保持当前值"
className="flex-1" className="flex-1"
/> />
<Button onClick={handleTestGemini} variant="outline"> <Button onClick={handleTestNewApi} variant="outline">
</Button> </Button>
</div> </div>
{testResults.gemini && ( {testResults.new_api && (
<Badge variant={testResults.gemini.success ? 'default' : 'destructive'}> <Badge variant={testResults.new_api.success ? 'default' : 'destructive'}>
{testResults.gemini.message} {testResults.new_api.message}
</Badge> </Badge>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="gemini-base-url">Base URL ()</Label> <Label htmlFor="new-api-base-url">Base URL</Label>
<Input <Input
id="gemini-base-url" id="new-api-base-url"
type="text" type="text"
value={geminiBaseUrl} value={newApiBaseUrl}
onChange={(e) => setGeminiBaseUrl(e.target.value)} onChange={(e) => setNewApiBaseUrl(e.target.value)}
placeholder="https://generativelanguage.googleapis.com/v1beta" placeholder="例如: http://localhost:3000/v1"
className="flex-1" className="flex-1"
/> />
</div> </div>
@ -427,54 +450,94 @@ 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(localAnalysisConfig).map(([type, config]) => {
<div key={type} className="space-y-4 p-4 border rounded-lg"> const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type);
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{config.name || type}</h3> return (
<Badge variant="secondary">{type}</Badge> <div key={type} className="space-y-4 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{config.name || type}</h3>
<Badge variant="secondary">{type}</Badge>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-name`}></Label>
<Input
id={`${type}-name`}
value={config.name || ''}
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}
</label>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-prompt`}></Label>
<Textarea
id={`${type}-prompt`}
value={config.prompt_template || ''}
onChange={(e) => updateAnalysisField(type, 'prompt_template', e.target.value)}
placeholder="提示词模板,支持 {company_name}, {ts_code}, {financial_data} 占位符"
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>
<Separator />
</div> </div>
);
<div className="space-y-2"> })}
<Label htmlFor={`${type}-name`}></Label>
<Input
id={`${type}-name`}
value={config.name || ''}
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-2.5-flash"
/>
<p className="text-xs text-muted-foreground">
使Gemini模型名称
</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${type}-prompt`}></Label>
<Textarea
id={`${type}-prompt`}
value={config.prompt_template || ''}
onChange={(e) => updateAnalysisField(type, 'prompt_template', e.target.value)}
placeholder="提示词模板,支持 {company_name}, {ts_code}, {financial_data} 占位符"
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
使: {`{company_name}`}, {`{ts_code}`}, {`{financial_data}`}
</p>
</div>
<Separator />
</div>
))}
<div className="flex items-center gap-4 pt-4"> <div className="flex items-center gap-4 pt-4">
<Button <Button
@ -509,9 +572,9 @@ export default function ConfigPage() {
</Badge> </Badge>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Gemini API</Label> <Label>New API</Label>
<Badge variant={config?.gemini_api?.api_key ? 'default' : 'secondary'}> <Badge variant={config?.new_api?.api_key ? 'default' : 'secondary'}>
{config?.gemini_api?.api_key ? '已配置' : '未配置'} {config?.new_api?.api_key ? '已配置' : '未配置'}
</Badge> </Badge>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -1,24 +1,67 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { promises as fs } from 'fs';
import path from 'path';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
async function getMarkdownContent() {
// process.cwd() is the root of the Next.js project (the 'frontend' directory)
const mdPath = path.join(process.cwd(), '..', 'docs', 'design.md');
try {
const content = await fs.readFile(mdPath, 'utf8');
return content;
} catch (error) {
console.error("Failed to read design.md:", error);
return "# 文档加载失败\n\n无法读取 `docs/design.md` 文件。请检查文件是否存在以及服务器权限。";
}
}
export default async function DocsPage() {
const content = await getMarkdownContent();
export default function DocsPage() {
return ( return (
<div className="space-y-6"> <div className="container mx-auto py-6 space-y-6">
<header className="space-y-2"> <header className="space-y-2">
<h1 className="text-2xl font-semibold"></h1> <h1 className="text-3xl font-bold"></h1>
<p className="text-sm text-muted-foreground">使</p> <p className="text-muted-foreground">
</p>
</header> </header>
<Card> <Card>
<CardHeader> <CardContent className="p-6">
<CardTitle></CardTitle> <article className="prose prose-zinc max-w-none dark:prose-invert">
<CardDescription></CardDescription> <ReactMarkdown
</CardHeader> remarkPlugins={[remarkGfm]}
<CardContent className="prose prose-sm dark:prose-invert"> components={{
<ol className="list-decimal pl-5 space-y-1"> h1: ({node, ...props}) => <h1 className="text-3xl font-bold mb-4 mt-8 border-b pb-2" {...props} />,
<li> npm run dev </li> h2: ({node, ...props}) => <h2 className="text-2xl font-bold mb-3 mt-6 border-b pb-2" {...props} />,
<li> src/app </li> h3: ({node, ...props}) => <h3 className="text-xl font-semibold mb-2 mt-4" {...props} />,
<li>使 shadcn/ui </li> p: ({node, ...props}) => <p className="mb-4 leading-7" {...props} />,
</ol> ul: ({node, ...props}) => <ul className="list-disc list-inside mb-4 space-y-2" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal list-inside mb-4 space-y-2" {...props} />,
li: ({node, ...props}) => <li className="ml-4" {...props} />,
code: ({node, inline, className, children, ...props}: any) => {
const match = /language-(\w+)/.exec(className || '');
return !inline ? (
<code className={className} {...props}>
{children}
</code>
) : (
<code className="bg-muted px-1.5 py-1 rounded text-sm font-mono" {...props}>
{children}
</code>
);
},
pre: ({children}) => <pre className="bg-muted p-4 rounded my-4 overflow-x-auto">{children}</pre>,
table: ({node, ...props}) => <div className="overflow-x-auto my-4"><table className="border-collapse border border-border w-full" {...props} /></div>,
th: ({node, ...props}) => <th className="border border-border px-4 py-2 bg-muted font-semibold text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-border px-4 py-2" {...props} />,
a: ({node, ...props}) => <a className="text-primary underline hover:text-primary/80" {...props} />,
}}
>
{content}
</ReactMarkdown>
</article>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { useChinaFinancials, useFinancialConfig, useAnalysisConfig } from '@/hooks/useApi'; import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis } 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';
@ -44,17 +44,10 @@ export default function ReportPage() {
// 分析类型列表(按顺序) // 分析类型列表(按顺序)
const analysisTypes = useMemo(() => { const analysisTypes = useMemo(() => {
if (!analysisConfig?.analysis_modules) return []; if (!analysisConfig?.analysis_modules) return [];
const order = [ // The order now comes from the backend's topological sort,
'fundamental_analysis', // but we can define a preferred order for display if needed.
'bull_case', // For now, let's just get the keys.
'bear_case', return Object.keys(analysisConfig.analysis_modules);
'market_analysis',
'news_analysis',
'trading_analysis',
'insider_institutional',
'final_conclusion'
];
return order.filter(type => analysisConfig.analysis_modules[type]);
}, [analysisConfig]); }, [analysisConfig]);
// 分析状态管理 // 分析状态管理
@ -70,8 +63,13 @@ export default function ReportPage() {
}; };
}>>({}); }>>({});
const fullAnalysisTriggeredRef = useRef<boolean>(false);
const isAnalysisRunningRef = useRef<boolean>(false);
const analysisFetchedRefs = useRef<Record<string, boolean>>({}); const analysisFetchedRefs = useRef<Record<string, boolean>>({});
const isAnalysisRunningRef = useRef<boolean>(false); // 防止并发执行 const stopRequestedRef = useRef<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const currentAnalysisTypeRef = useRef<string | null>(null);
const [manualRunKey, setManualRunKey] = useState(0);
// 当前正在执行的分析任务 // 当前正在执行的分析任务
const [currentAnalysisTask, setCurrentAnalysisTask] = useState<string | null>(null); const [currentAnalysisTask, setCurrentAnalysisTask] = useState<string | null>(null);
@ -84,7 +82,7 @@ export default function ReportPage() {
const [analysisRecords, setAnalysisRecords] = useState<Array<{ const [analysisRecords, setAnalysisRecords] = useState<Array<{
type: string; type: string;
name: string; name: string;
status: 'running' | 'done' | 'error'; status: 'pending' | 'running' | 'done' | 'error';
start_ts?: string; start_ts?: string;
end_ts?: string; end_ts?: string;
duration_ms?: number; duration_ms?: number;
@ -95,27 +93,54 @@ export default function ReportPage() {
}; };
error?: string; error?: string;
}>>([]); }>>([]);
// 公司简介状态(一次性加载) const runFullAnalysis = async () => {
const [profileContent, setProfileContent] = useState(''); if (!isChina || !financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
const [profileLoading, setProfileLoading] = useState(false); return;
const [profileError, setProfileError] = useState<string | null>(null); }
const [profileElapsedMs, setProfileElapsedMs] = useState<number | null>(null);
const [profileTokens, setProfileTokens] = useState<{ // 初始化/重置状态,准备顺序执行
prompt_tokens: number; stopRequestedRef.current = false;
completion_tokens: number; abortControllerRef.current?.abort();
total_tokens: number; abortControllerRef.current = null;
} | null>(null); analysisFetchedRefs.current = {};
const fetchedRef = useRef<boolean>(false); // 防止重复请求 setCurrentAnalysisTask('开始全量分析...');
setStartTime(Date.now());
// 计算完成比例(包括公司简介) setElapsedSeconds(0);
const initialStates: typeof analysisStates = {};
const initialRecords: typeof analysisRecords = [];
const analysisModuleKeys = Object.keys(analysisConfig.analysis_modules);
for (const type of analysisModuleKeys) {
initialStates[type] = { content: '', loading: false, error: null };
initialRecords.push({
type,
name: analysisConfig.analysis_modules[type]?.name || type,
status: 'pending',
});
}
setAnalysisStates(initialStates);
setAnalysisRecords(initialRecords);
// 触发顺序执行
setManualRunKey((k) => k + 1);
};
useEffect(() => {
if (financials && !fullAnalysisTriggeredRef.current) {
fullAnalysisTriggeredRef.current = true;
runFullAnalysis();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [financials]);
// 计算完成比例
const completionProgress = useMemo(() => { const completionProgress = useMemo(() => {
// 总任务数 = 公司简介 + 分析任务数 const totalTasks = analysisRecords.length;
const totalTasks = 1 + analysisTypes.length; if (totalTasks === 0) return 0;
// 已完成任务数 = 公司简介(如果有内容)+ 已完成的分析任务数 const completedTasks = analysisRecords.filter(r => r.status === 'done' || r.status === 'error').length;
const completedTasks = (profileContent ? 1 : 0) + analysisRecords.filter(r => r.status === 'done' && r.type !== 'company_profile').length; return (completedTasks / totalTasks) * 100;
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; }, [analysisRecords]);
}, [analysisRecords, analysisTypes.length, profileContent]);
// 格式化耗时显示 // 格式化耗时显示
const formatElapsedTime = (seconds: number): string => { const formatElapsedTime = (seconds: number): string => {
@ -126,6 +151,24 @@ export default function ReportPage() {
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`; return `${minutes}m ${remainingSeconds}s`;
}; };
// 以 ms 为输入的格式化:>1000ms 显示为秒
const formatMs = (ms?: number | null): string => {
const v = typeof ms === 'number' ? ms : 0;
if (v >= 1000) {
const s = v / 1000;
// 保留两位小数
return `${s.toFixed(2)} s`;
}
return `${v} ms`;
};
// 总耗时ms财务 + 所有分析任务
const totalElapsedMs = useMemo(() => {
const finMs = financials?.meta?.elapsed_ms || 0;
const analysesMs = analysisRecords.reduce((sum, r) => sum + (r.duration_ms || 0), 0);
return finMs + analysesMs;
}, [financials?.meta?.elapsed_ms, analysisRecords]);
// 创建 tushareParam 到 displayText 的映射 // 创建 tushareParam 到 displayText 的映射
const metricDisplayMap = useMemo(() => { const metricDisplayMap = useMemo(() => {
@ -199,129 +242,19 @@ export default function ReportPage() {
}; };
}, []); }, []);
const normalizedProfileContent = useMemo(() => { // 取消独立公司简介加载;统一纳入顺序分析
return normalizeMarkdown(profileContent);
}, [profileContent, normalizeMarkdown]);
// 当财务数据加载完成后,加载公司简介
useEffect(() => {
if (!isChina || isLoading || error || !financials || fetchedRef.current) {
return;
}
// 开始计时
if (!startTime) {
setStartTime(Date.now());
}
fetchedRef.current = true; // 标记已请求
const fetchProfile = async () => {
setProfileLoading(true);
setProfileError(null);
setProfileContent('');
const startTime = new Date().toISOString();
// 添加公司简介到分析记录(运行中状态)
setAnalysisRecords(prev => [...prev, {
type: 'company_profile',
name: '公司简介',
status: 'running',
start_ts: startTime
}]);
try {
const response = await fetch(
`/api/financials/china/${normalizedTsCode}/company-profile?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CompanyProfileResponse = await response.json();
const endTime = new Date().toISOString();
if (data.success) {
setProfileContent(data.content);
setProfileElapsedMs(data.elapsed_ms || null);
setProfileTokens(data.tokens || null);
// 更新公司简介记录为完成状态
setAnalysisRecords(prev => prev.map(record =>
record.type === 'company_profile'
? {
...record,
status: 'done',
end_ts: endTime,
duration_ms: data.elapsed_ms || 0,
tokens: data.tokens || undefined
}
: record
));
} else {
setProfileError(data.error || '生成失败');
// 更新公司简介记录为失败状态
setAnalysisRecords(prev => prev.map(record =>
record.type === 'company_profile'
? {
...record,
status: 'error',
end_ts: endTime,
error: data.error || '生成失败'
}
: record
));
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '加载失败';
const endTime = new Date().toISOString();
setProfileError(errorMessage);
// 更新公司简介记录为失败状态
setAnalysisRecords(prev => prev.map(record =>
record.type === 'company_profile'
? {
...record,
status: 'error',
end_ts: endTime,
error: errorMessage
}
: record
));
} finally {
setProfileLoading(false);
}
};
fetchProfile();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isChina, isLoading, error, financials, normalizedTsCode]);
// 检查是否有正在进行的任务 // 检查是否有正在进行的任务
const hasRunningTask = useMemo(() => { const hasRunningTask = useMemo(() => {
// 检查公司简介是否正在加载
if (profileLoading) return true;
// 检查是否有分析任务正在运行
if (currentAnalysisTask !== null) return true; if (currentAnalysisTask !== null) return true;
// 检查执行记录中是否有运行中的任务
if (analysisRecords.some(r => r.status === 'running')) return true; if (analysisRecords.some(r => r.status === 'running')) return true;
return false; return false;
}, [profileLoading, currentAnalysisTask, analysisRecords]); }, [currentAnalysisTask, analysisRecords]);
// 计时器效果 // 计时器效果
useEffect(() => { useEffect(() => {
if (!startTime) return; if (!startTime) return;
// 如果没有正在进行的任务,停止计时器
if (!hasRunningTask) {
return;
}
const interval = setInterval(() => { const interval = setInterval(() => {
const now = Date.now(); const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000); const elapsed = Math.floor((now - startTime) / 1000);
@ -329,108 +262,16 @@ export default function ReportPage() {
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [startTime, hasRunningTask]); }, [startTime]);
// 重试公司简介
const retryProfile = async () => {
if (!isChina || !financials) {
return;
}
// 清除已完成标记
fetchedRef.current = false;
// 清除错误状态和内容
setProfileLoading(true);
setProfileError(null);
setProfileContent('');
setProfileElapsedMs(null);
setProfileTokens(null);
// 移除旧的公司简介记录
setAnalysisRecords(prev => prev.filter(record => record.type !== 'company_profile'));
const startTime = new Date().toISOString();
// 添加公司简介到分析记录(运行中状态)
setAnalysisRecords(prev => [...prev, {
type: 'company_profile',
name: '公司简介',
status: 'running',
start_ts: startTime
}]);
try {
const response = await fetch(
`/api/financials/china/${normalizedTsCode}/company-profile?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: CompanyProfileResponse = await response.json();
const endTime = new Date().toISOString();
if (data.success) {
setProfileContent(data.content);
setProfileElapsedMs(data.elapsed_ms || null);
setProfileTokens(data.tokens || null);
// 更新公司简介记录为完成状态
setAnalysisRecords(prev => prev.map(record =>
record.type === 'company_profile'
? {
...record,
status: 'done',
end_ts: endTime,
duration_ms: data.elapsed_ms || 0,
tokens: data.tokens || undefined
}
: record
));
} else {
setProfileError(data.error || '生成失败');
// 更新公司简介记录为失败状态
setAnalysisRecords(prev => prev.map(record =>
record.type === 'company_profile'
? {
...record,
status: 'error',
end_ts: endTime,
error: data.error || '生成失败'
}
: record
));
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '加载失败';
const endTime = new Date().toISOString();
setProfileError(errorMessage);
// 更新公司简介记录为失败状态
setAnalysisRecords(prev => prev.map(record =>
record.type === 'company_profile'
? {
...record,
status: 'error',
end_ts: endTime,
error: errorMessage
}
: record
));
} finally {
setProfileLoading(false);
fetchedRef.current = true;
}
};
// 重试单个分析任务 // 重试单个分析任务
const retryAnalysis = async (analysisType: string) => { const retryAnalysis = async (analysisType: string) => {
if (!isChina || !financials || !analysisConfig?.analysis_modules) { if (!isChina || !financials || !analysisConfig?.analysis_modules) {
return; return;
} }
// 允许 company_profile 通过通用通道重试
// 清除该任务的已完成标记,允许重新执行 // 清除该任务的已完成标记,允许重新执行
analysisFetchedRefs.current[analysisType] = false; analysisFetchedRefs.current[analysisType] = false;
@ -563,13 +404,6 @@ export default function ReportPage() {
return; return;
} }
// 等待公司简介完成(无论成功还是失败)后再开始分析任务
// profileLoading 为 true 表示还在加载中,需要等待
// profileLoading 为 false 且 (profileContent 或 profileError) 表示已完成
if (profileLoading || (!profileContent && !profileError)) {
return;
}
// 如果已经有分析任务正在运行,则跳过 // 如果已经有分析任务正在运行,则跳过
if (isAnalysisRunningRef.current) { if (isAnalysisRunningRef.current) {
return; return;
@ -583,9 +417,16 @@ export default function ReportPage() {
isAnalysisRunningRef.current = true; isAnalysisRunningRef.current = true;
try { try {
if (!startTime) {
setStartTime(Date.now());
}
for (let i = 0; i < analysisTypes.length; i++) { for (let i = 0; i < analysisTypes.length; i++) {
const analysisType = analysisTypes[i]; const analysisType = analysisTypes[i];
if (stopRequestedRef.current) {
break;
}
if (analysisFetchedRefs.current[analysisType]) { if (analysisFetchedRefs.current[analysisType]) {
continue; // 已加载过,跳过 continue; // 已加载过,跳过
} }
@ -596,7 +437,8 @@ export default function ReportPage() {
continue; continue;
} }
analysisFetchedRefs.current[analysisType] = true; // 记录当前类型
currentAnalysisTypeRef.current = analysisType;
const analysisName = const analysisName =
analysisConfig.analysis_modules[analysisType]?.name || analysisType; analysisConfig.analysis_modules[analysisType]?.name || analysisType;
const startTime = new Date().toISOString(); const startTime = new Date().toISOString();
@ -605,13 +447,23 @@ export default function ReportPage() {
setCurrentAnalysisTask(analysisType); setCurrentAnalysisTask(analysisType);
// 添加执行记录 // 设置/更新执行记录为 running避免重复项
setAnalysisRecords(prev => [...prev, { setAnalysisRecords(prev => {
type: analysisType, const next = [...prev];
name: analysisName, const idx = next.findIndex(r => r.type === analysisType);
status: 'running', const updated = {
start_ts: startTime type: analysisType,
}]); name: analysisName,
status: 'running' as const,
start_ts: startTime
};
if (idx >= 0) {
next[idx] = { ...next[idx], ...updated };
} else {
next.push(updated);
}
return next;
});
// 设置加载状态 // 设置加载状态
setAnalysisStates(prev => ({ setAnalysisStates(prev => ({
@ -620,8 +472,11 @@ export default function ReportPage() {
})); }));
try { try {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const response = await fetch( const response = await fetch(
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}` `/api/financials/china/${normalizedTsCode}/analysis/${analysisType}?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`,
{ signal: abortControllerRef.current.signal }
); );
if (!response.ok) { if (!response.ok) {
@ -684,6 +539,20 @@ export default function ReportPage() {
)); ));
} }
} catch (err) { } catch (err) {
// 若为主动中止,则把当前任务恢复为待处理并退出循环
if (err && typeof err === 'object' && (err as any).name === 'AbortError') {
setAnalysisStates(prev => ({
...prev,
[analysisType]: { content: '', loading: false, error: null }
}));
setAnalysisRecords(prev => prev.map(record =>
record.type === analysisType
? { ...record, status: 'pending', start_ts: undefined }
: record
));
analysisFetchedRefs.current[analysisType] = false;
break;
}
const errorMessage = err instanceof Error ? err.message : '加载失败'; const errorMessage = err instanceof Error ? err.message : '加载失败';
const endTime = new Date().toISOString(); const endTime = new Date().toISOString();
@ -711,6 +580,8 @@ export default function ReportPage() {
} finally { } finally {
// 清除当前任务 // 清除当前任务
setCurrentAnalysisTask(null); setCurrentAnalysisTask(null);
currentAnalysisTypeRef.current = null;
analysisFetchedRefs.current[analysisType] = true;
} }
} }
} finally { } finally {
@ -721,7 +592,28 @@ export default function ReportPage() {
runAnalysesSequentially(); runAnalysesSequentially();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isChina, isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, profileLoading, profileContent, profileError]); }, [isChina, isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]);
const stopAll = () => {
stopRequestedRef.current = true;
abortControllerRef.current?.abort();
abortControllerRef.current = null;
isAnalysisRunningRef.current = false;
if (currentAnalysisTypeRef.current) {
analysisFetchedRefs.current[currentAnalysisTypeRef.current] = false;
}
setCurrentAnalysisTask(null);
// 暂停计时器
setStartTime(null);
};
const continuePending = () => {
if (isAnalysisRunningRef.current) return;
stopRequestedRef.current = false;
// 恢复计时器:保持累计秒数继续计时
setStartTime((prev) => (prev == null ? Date.now() - elapsedSeconds * 1000 : prev));
setManualRunKey((k) => k + 1);
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -758,6 +650,23 @@ export default function ReportPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 中间:操作卡片 */}
{isChina && (
<Card className="w-40 flex-shrink-0">
<CardContent className="flex flex-col gap-2">
<Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}>
{isAnalysisRunningRef.current ? '正在分析…' : '开始分析'}
</Button>
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
</Button>
<Button variant="outline" onClick={continuePending} disabled={isAnalysisRunningRef.current}>
</Button>
</CardContent>
</Card>
)}
{/* 右侧:任务状态 */} {/* 右侧:任务状态 */}
{isChina && ( {isChina && (
<Card className="w-80"> <Card className="w-80">
@ -776,23 +685,28 @@ export default function ReportPage() {
style={{ width: `${completionProgress}%` }} style={{ width: `${completionProgress}%` }}
/> />
</div> </div>
{/* 操作按钮已移至左侧信息卡片 */}
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{/* 当前正在进行的任务 */} {/* 当前正在进行的任务 */}
{currentAnalysisTask && analysisConfig && ( {currentAnalysisTask && analysisConfig && (
<div className="flex items-center gap-2 text-sm"> (() => {
<Spinner className="size-4" /> const analysisName = analysisConfig.analysis_modules[currentAnalysisTask]?.name || currentAnalysisTask;
<div> const modelName = analysisConfig.analysis_modules[currentAnalysisTask]?.model || 'AI';
<div className="font-medium"> return (
{analysisConfig.analysis_modules[currentAnalysisTask]?.name || currentAnalysisTask} <div className="flex items-center gap-2 text-sm">
<Spinner className="size-4" />
<div>
<div className="font-medium">{analysisName} {modelName}</div>
<div className="text-xs text-muted-foreground">{analysisName}...</div>
</div>
</div> </div>
<div className="text-xs text-muted-foreground">...</div> );
</div> })()
</div>
)} )}
{/* 最近一个已完成的任务 */} {/* 最近一个已完成的任务 */}
{(() => { {(() => {
// 找到最近一个已完成的任务(按结束时间排序) // 找到最近一个已完成的任务(按结束时间排序)
const completedRecords = analysisRecords const completedRecords = analysisRecords
.filter(r => r.status === 'done' && r.end_ts) .filter(r => r.status === 'done' && r.end_ts)
@ -814,19 +728,6 @@ export default function ReportPage() {
); );
} }
// 如果没有已完成的记录,显示公司简介或财务数据状态
if (!isLoading && !error && financials && profileContent) {
return (
<div className="flex items-center gap-2 text-sm">
<CheckCircle className="size-4 text-green-600" />
<div>
<div className="font-medium"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
);
}
if (financials && !isLoading && !error) { if (financials && !isLoading && !error) {
return ( return (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@ -851,10 +752,9 @@ export default function ReportPage() {
<TabsList className="flex-wrap"> <TabsList className="flex-wrap">
<TabsTrigger value="chart"></TabsTrigger> <TabsTrigger value="chart"></TabsTrigger>
<TabsTrigger value="financial"></TabsTrigger> <TabsTrigger value="financial"></TabsTrigger>
<TabsTrigger value="profile"></TabsTrigger>
{analysisTypes.map(type => ( {analysisTypes.map(type => (
<TabsTrigger key={type} value={type}> <TabsTrigger key={type} value={type}>
{analysisConfig?.analysis_modules[type]?.name || type} {type === 'company_profile' ? '公司简介' : (analysisConfig?.analysis_modules[type]?.name || type)}
</TabsTrigger> </TabsTrigger>
))} ))}
<TabsTrigger value="execution"></TabsTrigger> <TabsTrigger value="execution"></TabsTrigger>
@ -1551,105 +1451,20 @@ export default function ReportPage() {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="profile" className="space-y-4">
<h2 className="text-lg font-medium"> Gemini AI</h2>
{!financials && (
<p className="text-sm text-muted-foreground">...</p>
)}
{financials && (
<>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-sm">
{profileLoading ? (
<Spinner className="size-4" />
) : profileError ? (
<XCircle className="size-4 text-red-500" />
) : profileContent ? (
<CheckCircle className="size-4 text-green-600" />
) : null}
<div className="text-muted-foreground">
{profileLoading
? '正在生成公司简介...'
: profileError
? '生成失败'
: profileContent
? '生成完成'
: ''}
</div>
</div>
{profileError && !profileLoading && (
<Button
variant="outline"
size="sm"
onClick={retryProfile}
disabled={profileLoading}
>
<RotateCw className="size-4" />
</Button>
)}
</div>
{profileError && (
<p className="text-red-500">: {profileError}</p>
)}
{(profileLoading || profileContent) && (
<div className="space-y-4">
<div className="border rounded-lg p-6 bg-card">
<div className="leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({node, ...props}) => <h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0" {...props} />,
h2: ({node, ...props}) => <h2 className="text-xl font-bold mb-3 mt-5 first:mt-0" {...props} />,
h3: ({node, ...props}) => <h3 className="text-lg font-semibold mb-2 mt-4 first:mt-0" {...props} />,
p: ({node, ...props}) => <p className="mb-2 leading-7" {...props} />,
ul: ({node, ...props}) => <ul className="list-disc list-inside mb-2 space-y-1 ml-4" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal list-inside mb-2 space-y-1 ml-4" {...props} />,
li: ({node, ...props}) => <li className="ml-2" {...props} />,
strong: ({node, ...props}) => <strong className="font-semibold" {...props} />,
em: ({node, ...props}) => <em className="italic text-muted-foreground" {...props} />,
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />,
code: ({node, inline, ...props}: any) =>
inline
? <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props} />
: <code className="block bg-muted p-4 rounded my-2 overflow-x-auto font-mono text-sm" {...props} />,
pre: ({node, ...props}) => <pre className="bg-muted p-4 rounded my-2 overflow-x-auto" {...props} />,
table: ({node, ...props}) => <div className="overflow-x-auto my-2"><table className="border-collapse border border-border w-full" {...props} /></div>,
th: ({node, ...props}) => <th className="border border-border px-3 py-2 bg-muted font-semibold text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-border px-3 py-2" {...props} />,
a: ({node, ...props}) => <a className="text-primary underline hover:text-primary/80" {...props} />,
hr: ({node, ...props}) => <hr className="my-4 border-border" {...props} />,
}}
>
{normalizedProfileContent}
</ReactMarkdown>
{profileLoading && (
<span className="inline-flex items-center gap-2 mt-2 text-muted-foreground">
<Spinner className="size-3" />
<span className="text-sm">...</span>
</span>
)}
</div>
</div>
</div>
)}
</>
)}
</TabsContent>
{/* 动态生成各个分析的TabsContent */} {/* 动态生成各个分析的TabsContent */}
{analysisTypes.map(analysisType => { {analysisTypes.map(analysisType => {
const state = analysisStates[analysisType] || { content: '', loading: false, error: null }; const state = analysisStates[analysisType] || { content: '', loading: false, error: null };
const normalizedContent = normalizeMarkdown(state.content); const normalizedContent = normalizeMarkdown(state.content);
const analysisName = analysisConfig?.analysis_modules[analysisType]?.name || analysisType; const analysisName = analysisType === 'company_profile'
? '公司简介'
: (analysisConfig?.analysis_modules[analysisType]?.name || analysisType);
const modelName = analysisConfig?.analysis_modules[analysisType]?.model;
return ( return (
<TabsContent key={analysisType} value={analysisType} className="space-y-4"> <TabsContent key={analysisType} value={analysisType} className="space-y-4">
<h2 className="text-lg font-medium">{analysisName} Gemini AI</h2> <h2 className="text-lg font-medium">{analysisName} {modelName || 'AI'}</h2>
{!financials && ( {!financials && (
<p className="text-sm text-muted-foreground">...</p> <p className="text-sm text-muted-foreground">...</p>
@ -1658,35 +1473,35 @@ export default function ReportPage() {
{financials && ( {financials && (
<> <>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
{state.loading ? ( {state.loading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : state.error ? ( ) : state.error ? (
<XCircle className="size-4 text-red-500" /> <XCircle className="size-4 text-red-500" />
) : state.content ? ( ) : state.content ? (
<CheckCircle className="size-4 text-green-600" /> <CheckCircle className="size-4 text-green-600" />
) : null} ) : null}
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{state.loading {state.loading
? `正在生成${analysisName}...` ? `正在生成${analysisName}...`
: state.error : state.error
? '生成失败' ? '生成失败'
: state.content : state.content
? '生成完成' ? '生成完成'
: ''} : '待开始'}
</div>
</div> </div>
</div> {state.error && !state.loading && (
{state.error && !state.loading && ( <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onClick={() => retryAnalysis(analysisType)}
onClick={() => retryAnalysis(analysisType)} disabled={currentAnalysisTask !== null}
disabled={currentAnalysisTask !== null} >
> <RotateCw className="size-4" />
<RotateCw className="size-4" />
</Button>
</Button> )}
)}
</div> </div>
{state.error && ( {state.error && (
@ -1694,45 +1509,45 @@ export default function ReportPage() {
)} )}
{(state.loading || state.content) && ( {(state.loading || state.content) && (
<div className="space-y-4"> <div className="space-y-4">
<div className="border rounded-lg p-6 bg-card"> <div className="border rounded-lg p-6 bg-card">
<div className="leading-relaxed"> <div className="leading-relaxed">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({node, ...props}) => <h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0" {...props} />, h1: ({node, ...props}) => <h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0" {...props} />,
h2: ({node, ...props}) => <h2 className="text-xl font-bold mb-3 mt-5 first:mt-0" {...props} />, h2: ({node, ...props}) => <h2 className="text-xl font-bold mb-3 mt-5 first:mt-0" {...props} />,
h3: ({node, ...props}) => <h3 className="text-lg font-semibold mb-2 mt-4 first:mt-0" {...props} />, h3: ({node, ...props}) => <h3 className="text-lg font-semibold mb-2 mt-4 first:mt-0" {...props} />,
p: ({node, ...props}) => <p className="mb-2 leading-7" {...props} />, p: ({node, ...props}) => <p className="mb-2 leading-7" {...props} />,
ul: ({node, ...props}) => <ul className="list-disc list-inside mb-2 space-y-1 ml-4" {...props} />, ul: ({node, ...props}) => <ul className="list-disc list-inside mb-2 space-y-1 ml-4" {...props} />,
ol: ({node, ...props}) => <ol className="list-decimal list-inside mb-2 space-y-1 ml-4" {...props} />, ol: ({node, ...props}) => <ol className="list-decimal list-inside mb-2 space-y-1 ml-4" {...props} />,
li: ({node, ...props}) => <li className="ml-2" {...props} />, li: ({node, ...props}) => <li className="ml-2" {...props} />,
strong: ({node, ...props}) => <strong className="font-semibold" {...props} />, strong: ({node, ...props}) => <strong className="font-semibold" {...props} />,
em: ({node, ...props}) => <em className="italic text-muted-foreground" {...props} />, em: ({node, ...props}) => <em className="italic text-muted-foreground" {...props} />,
blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />, blockquote: ({node, ...props}) => <blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />,
code: ({node, inline, ...props}: any) => code: ({node, inline, ...props}: any) =>
inline inline
? <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props} /> ? <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props} />
: <code className="block bg-muted p-4 rounded my-2 overflow-x-auto font-mono text-sm" {...props} />, : <code className="block bg-muted p-4 rounded my-2 overflow-x-auto font-mono text-sm" {...props} />,
pre: ({node, ...props}) => <pre className="bg-muted p-4 rounded my-2 overflow-x-auto" {...props} />, pre: ({node, ...props}) => <pre className="bg-muted p-4 rounded my-2 overflow-x-auto" {...props} />,
table: ({node, ...props}) => <div className="overflow-x-auto my-2"><table className="border-collapse border border-border w-full" {...props} /></div>, table: ({node, ...props}) => <div className="overflow-x-auto my-2"><table className="border-collapse border border-border w-full" {...props} /></div>,
th: ({node, ...props}) => <th className="border border-border px-3 py-2 bg-muted font-semibold text-left" {...props} />, th: ({node, ...props}) => <th className="border border-border px-3 py-2 bg-muted font-semibold text-left" {...props} />,
td: ({node, ...props}) => <td className="border border-border px-3 py-2" {...props} />, td: ({node, ...props}) => <td className="border border-border px-3 py-2" {...props} />,
a: ({node, ...props}) => <a className="text-primary underline hover:text-primary/80" {...props} />, a: ({node, ...props}) => <a className="text-primary underline hover:text-primary/80" {...props} />,
hr: ({node, ...props}) => <hr className="my-4 border-border" {...props} />, hr: ({node, ...props}) => <hr className="my-4 border-border" {...props} />,
}} }}
> >
{normalizedContent} {normalizedContent}
</ReactMarkdown> </ReactMarkdown>
{state.loading && ( {state.loading && (
<span className="inline-flex items-center gap-2 mt-2 text-muted-foreground"> <span className="inline-flex items-center gap-2 mt-2 text-muted-foreground">
<Spinner className="size-3" /> <Spinner className="size-3" />
<span className="text-sm">...</span> <span className="text-sm">...</span>
</span> </span>
)} )}
</div>
</div> </div>
</div> </div>
</div>
)} )}
</> </>
)} )}
@ -1764,7 +1579,7 @@ export default function ReportPage() {
</div> </div>
{financials?.meta && ( {financials?.meta && (
<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>: {financials.meta.elapsed_ms} ms</div> <div>: {formatMs(financials.meta.elapsed_ms)}</div>
<div>API调用: {financials.meta.api_calls_total} </div> <div>API调用: {financials.meta.api_calls_total} </div>
<div>: {financials.meta.started_at}</div> <div>: {financials.meta.started_at}</div>
{financials.meta.finished_at && ( {financials.meta.finished_at && (
@ -1786,19 +1601,18 @@ export default function ReportPage() {
{record.status === 'done' && <CheckCircle className="size-3 text-green-600" />} {record.status === 'done' && <CheckCircle className="size-3 text-green-600" />}
{record.status === 'error' && <XCircle className="size-3 text-red-500" />} {record.status === 'error' && <XCircle className="size-3 text-red-500" />}
<span className="font-medium">{record.name}</span> <span className="font-medium">{record.name}</span>
<span className="text-xs text-muted-foreground">
{record.status === 'running' ? '运行中' : record.status === 'done' ? '已完成' : record.status === 'error' ? '失败' : '待继续'}
</span>
{record.status === 'error' && ( {record.status === 'error' && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2 text-xs" className="h-6 px-2 text-xs"
onClick={() => { onClick={() => {
if (record.type === 'company_profile') { retryAnalysis(record.type);
retryProfile();
} else {
retryAnalysis(record.type);
}
}} }}
disabled={record.type === 'company_profile' ? profileLoading : currentAnalysisTask !== null} disabled={currentAnalysisTask !== null}
> >
<RotateCw className="size-3" /> <RotateCw className="size-3" />
@ -1806,7 +1620,7 @@ export default function ReportPage() {
)} )}
</div> </div>
{record.duration_ms !== undefined && ( {record.duration_ms !== undefined && (
<div className="ml-5">: {record.duration_ms} ms</div> <div className="ml-5">: {formatMs(record.duration_ms)}</div>
)} )}
{record.tokens && ( {record.tokens && (
<div className="ml-5"> <div className="ml-5">
@ -1828,9 +1642,9 @@ export default function ReportPage() {
<div className="pt-3 border-t"> <div className="pt-3 border-t">
<div className="font-medium mb-2"></div> <div className="font-medium mb-2"></div>
<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>: {financials?.meta?.elapsed_ms || 0} ms</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.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div>
)} )}
{analysisRecords.length > 0 && ( {analysisRecords.length > 0 && (
<> <>

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -87,3 +87,27 @@ export async function updateAnalysisConfig(config: AnalysisConfigResponse) {
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
export async function generateFullAnalysis(tsCode: string, companyName: string) {
const url = `/api/financials/china/${encodeURIComponent(tsCode)}/analysis?company_name=${encodeURIComponent(companyName)}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const text = await res.text();
if (!res.ok) {
try {
const errorJson = JSON.parse(text);
throw new Error(errorJson.detail || text);
} catch {
throw new Error(text || `Request failed: ${res.status}`);
}
}
try {
return JSON.parse(text);
} catch {
throw new Error('Invalid JSON response from server.');
}
}

View File

@ -5,7 +5,7 @@ export interface DatabaseConfig {
url: string; url: string;
} }
export interface GeminiConfig { export interface NewApiConfig {
api_key: string; api_key: string;
base_url?: string; base_url?: string;
} }
@ -16,7 +16,7 @@ export interface DataSourceConfig {
export interface SystemConfig { export interface SystemConfig {
database: DatabaseConfig; database: DatabaseConfig;
gemini_api: GeminiConfig; new_api: NewApiConfig;
data_sources: DataSourceConfig; data_sources: DataSourceConfig;
} }

View File

@ -20,7 +20,7 @@ FRONTEND_PORT=3000
# Kill process using specified port # Kill process using specified port
kill_port() { kill_port() {
local port=$1 local port=$1
local pids=$(lsof -ti tcp:"$port" 2>/dev/null || true) local pids=$(lsof -nP -ti tcp:"$port" 2>/dev/null || true)
if [[ -n "$pids" ]]; then if [[ -n "$pids" ]]; then
echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids" echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids"
echo "$pids" | xargs kill -9 2>/dev/null || true echo "$pids" | xargs kill -9 2>/dev/null || true