feat: 实现动态分析配置并优化前端UI
本次提交引入了一系列重要功能,核心是实现了财务分析模块的动态配置,并对配置和报告页面的用户界面进行了改进。 主要变更: - **动态配置:** - 后端实现了 `ConfigManager` 服务,用于动态管理 `analysis-config.json` 和 `config.json`。 - 添加了用于读取和更新配置的 API 端点。 - 开发了前端 `/config` 页面,允许用户实时查看和修改分析配置。 - **后端增强:** - 更新了 `AnalysisClient` 和 `CompanyProfileClient` 以使用新的配置系统。 - 重构了财务数据相关的路由。 - **前端改进:** - 新增了可复用的 `Checkbox` UI 组件。 - 使用更直观和用户友好的界面重新设计了配置页面。 - 改进了财务报告页面的布局和数据展示。 - **文档与杂务:** - 更新了设计和需求文档以反映新功能。 - 更新了前后端依赖。 - 修改了开发脚本 `dev.sh`。
This commit is contained in:
parent
e0aa61b8c4
commit
b5a4d2212c
@ -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(
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
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,
|
||||||
@ -23,17 +23,19 @@ class AnalysisClient:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
@ -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": {
|
||||||
|
|||||||
137
docs/design.md
137
docs/design.md
@ -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/队列增强体验。
|
||||||
|
|
||||||
 <!-- Placeholder for a real diagram -->
|
 <!-- 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→SZ,6→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. 指标与显示规范(要点)
|
||||||
|
|
||||||
|
- 百分比字段支持 0–1 与百分数两种输入,自动规范化显示为 `%`。
|
||||||
|
- 金额类(损益/资产负债/现金流)按“亿元/亿股”等进行缩放显示,市值按“亿元”整数化展示。
|
||||||
|
- 计算项示例:自由现金流 = 经营现金流 − 资本开支;费用率=费用/收入;其他费用率=毛利率−净利率−销售−管理−研发。
|
||||||
|
- 高亮规则:例如 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 暂无;后续纳入登录、权限与审计。
|
||||||
|
|
||||||
|
本设计文档已对“当前实现”与“后续规划”进行了清晰标注,便于开发与验收。
|
||||||
|
|||||||
31
frontend/package-lock.json
generated
31
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
|
||||||
<Badge variant="secondary">{type}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
return (
|
||||||
<Label htmlFor={`${type}-name`}>显示名称</Label>
|
<div key={type} className="space-y-4 p-4 border rounded-lg">
|
||||||
<Input
|
<div className="flex items-center justify-between">
|
||||||
id={`${type}-name`}
|
<h3 className="text-lg font-semibold">{config.name || type}</h3>
|
||||||
value={config.name || ''}
|
<Badge variant="secondary">{type}</Badge>
|
||||||
onChange={(e) => updateAnalysisField(type, 'name', e.target.value)}
|
</div>
|
||||||
placeholder="分析模块显示名称"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${type}-model`}>模型名称</Label>
|
<Label htmlFor={`${type}-name`}>显示名称</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${type}-model`}
|
id={`${type}-name`}
|
||||||
value={config.model || ''}
|
value={config.name || ''}
|
||||||
onChange={(e) => updateAnalysisField(type, 'model', e.target.value)}
|
onChange={(e) => updateAnalysisField(type, 'name', e.target.value)}
|
||||||
placeholder="例如: gemini-2.5-flash"
|
placeholder="分析模块显示名称"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
使用的Gemini模型名称
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${type}-prompt`}>提示词模板</Label>
|
<Label htmlFor={`${type}-model`}>模型名称</Label>
|
||||||
<Textarea
|
<Input
|
||||||
id={`${type}-prompt`}
|
id={`${type}-model`}
|
||||||
value={config.prompt_template || ''}
|
value={config.model || ''}
|
||||||
onChange={(e) => updateAnalysisField(type, 'prompt_template', e.target.value)}
|
onChange={(e) => updateAnalysisField(type, 'model', e.target.value)}
|
||||||
placeholder="提示词模板,支持 {company_name}, {ts_code}, {financial_data} 占位符"
|
placeholder="例如: gemini-1.5-pro"
|
||||||
rows={10}
|
/>
|
||||||
className="font-mono text-sm"
|
<p className="text-xs text-muted-foreground">
|
||||||
/>
|
在 AI 服务中配置的模型名称
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
提示词模板,可以使用占位符: {`{company_name}`}, {`{ts_code}`}, {`{financial_data}`}
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<div className="space-y-2">
|
||||||
</div>
|
<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 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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
@ -96,26 +94,53 @@ 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;
|
|
||||||
completion_tokens: number;
|
|
||||||
total_tokens: number;
|
|
||||||
} | null>(null);
|
|
||||||
const fetchedRef = useRef<boolean>(false); // 防止重复请求
|
|
||||||
|
|
||||||
// 计算完成比例(包括公司简介)
|
// 初始化/重置状态,准备顺序执行
|
||||||
|
stopRequestedRef.current = false;
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
analysisFetchedRefs.current = {};
|
||||||
|
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 => {
|
||||||
@ -127,6 +152,24 @@ export default function ReportPage() {
|
|||||||
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(() => {
|
||||||
if (!financialConfig?.api_groups) return {};
|
if (!financialConfig?.api_groups) return {};
|
||||||
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user