diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 8309b99..b2c6a87 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.44" +__version__ = "0.1.45" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/api/financials.py b/backend/app/api/financials.py index 8e6427d..b8414b3 100644 --- a/backend/app/api/financials.py +++ b/backend/app/api/financials.py @@ -5,8 +5,12 @@ import logging import polars as pl from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel from app.services.financial_sync import get_financial_df +from app.services.financial_analyzer import analyze_financials_stream +from app.services import ai_reports from app.tickflow.capabilities import Cap logger = logging.getLogger(__name__) @@ -130,3 +134,84 @@ def sync_table(request: Request, table: str): result = fs.trigger(target) return {"status": "ok", "synced": result} + + +class AnalyzeRequest(BaseModel): + """AI 财务分析请求。""" + symbol: str + focus: str = "" # 可选:用户追加的分析关注点 + + +@router.post("/analyze") +async def analyze_financials(request: Request, req: AnalyzeRequest): + """AI 财务分析 — SSE 流式返回。 + + 后端读取该标的 4 张财务表 → 注入 CFA 分析师级提示词 → 流式调用 LLM → + 逐 chunk 以 SSE 形式推给前端(JSON per line, 非 text/event-stream, + 以便前端用 ReadableStream 逐行解析,更简单可靠)。 + """ + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + + if not req.symbol: + raise HTTPException(400, "symbol 不能为空") + + data_dir = request.app.state.repo.store.data_dir + + async def stream_gen(): + async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus): + yield chunk + "\n" + + return StreamingResponse( + stream_gen(), + media_type="application/x-ndjson", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# ================================================================ +# AI 报告 CRUD(历史报告持久化) +# ================================================================ + +class SaveReportRequest(BaseModel): + """保存一条 AI 财务分析报告。""" + symbol: str + name: str = "" + focus: str = "" + content: str + periods: int | None = None + summary: str = "" + + +@router.get("/reports") +def list_reports(request: Request): + """获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。""" + capset = request.app.state.capabilities + if not capset.has(Cap.FINANCIAL): + return {"reports": []} + return {"reports": ai_reports.list_reports()} + + +@router.post("/reports") +def save_report(request: Request, req: SaveReportRequest): + """保存一条报告。""" + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + report = ai_reports.save_report({ + "symbol": req.symbol, + "name": req.name, + "focus": req.focus, + "content": req.content, + "periods": req.periods, + "summary": req.summary, + }) + return {"ok": True, "report": report} + + +@router.delete("/reports/{report_id}") +def delete_report(request: Request, report_id: str): + """删除一条报告。""" + capset = request.app.state.capabilities + capset.require(Cap.FINANCIAL) + ok = ai_reports.delete_report(report_id) + return {"ok": ok} diff --git a/backend/app/services/ai_reports.py b/backend/app/services/ai_reports.py new file mode 100644 index 0000000..caf4115 --- /dev/null +++ b/backend/app/services/ai_reports.py @@ -0,0 +1,101 @@ +"""AI 财务分析报告持久化存储。 + +存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序) +保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。 + +每条报告结构: +{ + "id": "rpt_xxx", # 唯一 id + "symbol": "600519.SH", + "name": "贵州茅台", + "focus": "", # 用户追加的关心点(可为空) + "content": "# ...markdown", # 报告正文 + "periods": 4, # 基于几期数据生成 + "summary": "metrics: 1期...", # 数据摘要 + "created_at": "2026-06-25T10:00:00" +} +""" +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +MAX_REPORTS = 20 + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "ai_reports.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def list_reports() -> list[dict]: + """返回全部报告(按 created_at 降序)。""" + p = _path() + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, list): + return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True) + except Exception as e: # noqa: BLE001 + logger.warning("ai_reports.json malformed: %s", e) + return [] + + +def _save_all(reports: list[dict]) -> None: + """全量写入(裁剪到 MAX_REPORTS)。""" + # 保持降序 + reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) + if len(reports) > MAX_REPORTS: + reports = reports[:MAX_REPORTS] + _path().write_text( + json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8", + ) + + +def save_report(report: dict) -> dict: + """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。 + + 自动补全 id 与 created_at(若缺),并裁剪到上限。 + """ + reports = list_reports() + if not report.get("id"): + report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}" + if not report.get("created_at"): + report["created_at"] = _now_iso() + reports.append(report) + _save_all(reports) + logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports)) + return report + + +def delete_report(report_id: str) -> bool: + """删除指定报告。返回是否删除成功。""" + reports = list_reports() + before = len(reports) + reports = [r for r in reports if r.get("id") != report_id] + if len(reports) < before: + _save_all(reports) + return True + return False + + +def clear_reports() -> int: + """清空全部报告。返回删除数量。""" + reports = list_reports() + n = len(reports) + if n > 0: + _save_all([]) + return n + + +def _now_iso() -> str: + """当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。""" + from datetime import datetime + return datetime.now().isoformat(timespec="seconds") diff --git a/backend/app/services/financial_analyzer.py b/backend/app/services/financial_analyzer.py new file mode 100644 index 0000000..772de9b --- /dev/null +++ b/backend/app/services/financial_analyzer.py @@ -0,0 +1,209 @@ +"""AI 财务分析服务 — 读取个股财务数据 → 构建专业提示词 → 流式调用 LLM。 + +职责: 拉取单只标的的 4 张财务表 → 转成紧凑 JSON → 拼装 CFA 分析师级系统提示词 + → 流式调用 OpenAI 兼容 API → 逐 chunk 吐给前端。 + +不知道: HTTP、前端、配置持久化。 +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import AsyncIterator + +import polars as pl + +from app.services.financial_sync import get_financial_df + +logger = logging.getLogger(__name__) + +# 最多注入的报告期数(最新 N 期),避免上下文爆炸 / token 浪费 +_MAX_PERIODS = 4 + + +def _load_stock_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]: + """读取该标的的 4 张财务表,返回 {table: [records...]}(按 period_end 降序,截取最新 N 期)。 + + 数值统一做 NaN/Inf → null 清洗,保证 JSON 序列化不报错。 + """ + result: dict[str, list[dict]] = {} + for table in ("metrics", "income", "balance_sheet", "cash_flow"): + df = get_financial_df(data_dir, table) + if df.is_empty(): + result[table] = [] + continue + df = df.filter(pl.col("symbol") == symbol) + if df.is_empty(): + result[table] = [] + continue + # 按 period_end 降序,截取最新 N 期 + if "period_end" in df.columns: + df = df.sort("period_end", descending=True).head(_MAX_PERIODS) + # 清洗 NaN/Inf,转成 JSON 安全的 dict 列表 + rows = [] + for rec in df.to_dicts(): + clean = {} + for k, v in rec.items(): + if k == "symbol": + continue # 不需要重复回传 symbol + if isinstance(v, float): + import math + clean[k] = None if not math.isfinite(v) else v + else: + clean[k] = v + rows.append(clean) + result[table] = rows + return result + + +def _summarize(fins: dict[str, list[dict]]) -> str: + """生成一行业务摘要,便于 LLM 快速把握数据全貌(行数/期数)。""" + parts = [] + for table in ("metrics", "income", "balance_sheet", "cash_flow"): + rows = fins.get(table, []) + if rows: + periods = [r.get("period_end") for r in rows if r.get("period_end")] + parts.append(f"{table}: {len(rows)}期 ({', '.join(str(p) for p in periods[:3])})") + else: + parts.append(f"{table}: 无数据") + return " · ".join(parts) + + +# ================================================================ +# 系统提示词 —— CFA 分析师级,九维分析框架 +# ================================================================ + +_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股投研经验的资深财务分析师(CFA + CPA),服务于专业机构投资者。你的任务是:基于提供的上市公司财务数据,产出一份**严谨、专业、可直接用于投资决策**的财务分析报告。 + +## 输出规范 + +用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。 + +### 1. 📌 核心摘要(1-2 句) +用一句话概括该公司的财务画像:盈利质量、成长动能、财务健康度的最关键判断。结尾用【综合评级:★★★☆☆】给出 1-5 星评级。 + +### 2. ✅ 亮点(2-3 条) +列出最值得关注的**积极信号**,每条用加粗短语领起,配数据支撑。例如盈利高增、ROE 持续提升、现金流充沛等。 + +### 3. ⚠️ 风险提示(2-3 条) +客观指出**潜在风险或值得警惕的信号**,例如应收激增、存货堆积、经营现金流与净利润背离、债务攀升等。宁可保守,不要回避。 + +### 4. 📊 分项诊断 +用**表格**呈现各维度的诊断结论,列为「维度 / 关键指标 / 判断」。维度包括: +- **盈利能力**:ROE / ROA / 毛利率 / 净利率 +- **成长性**:营收同比 / 净利润同比 +- **偿债能力**:资产负债率 / 流动比率(用资产/负债估算) +- **现金流**:经营现金流净额 / 与净利润的匹配度 +- **营运效率**:存货周转率等(有数据时) + +每个判断给「优秀 / 良好 / 一般 / 偏弱 / 警惕」之一,并一句话说明依据。 + +### 5. 🎯 综合评估与展望 +2-3 段总结:该公司当前的财务状态(优秀/稳健/承压/恶化)、核心驱动力、未来需重点跟踪的指标。**结尾给出"投资参考"**:从纯财务质量角度,该股属于(高质量蓝筹 / 稳健成长 / 周期波动 / 财务承压 / 高风险)中的哪一类。 + +## 分析准则(务必遵守) + +1. **数据说话**:每个判断必须引用具体数值(如"营收同比 +28.5%"),严禁空泛套话 +2. **纵向对比**:利用多期数据看趋势(改善/恶化),而非只看单期 +3. **交叉验证**:经营现金流 vs 净利润(是否造血)、毛利率 vs 费用率(盈利结构)、负债 vs 资产(杠杆) +4. **行业常识**:对照 A 股常识判断水平(如 ROE>15% 优秀,资产负债率>70% 偏高,毛利率<20% 偏低) +5. **诚实中立**:数据不支持时直言"数据不足,无法判断",绝不编造或过度演绎 +6. **简明有力**:避免冗长,用专业投资者能扫读的密度输出,总字数 800-1500 字 + +## 重要免责 +报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开财务数据生成,仅供参考,不构成任何投资建议。" + +现在请基于下方数据进行分析。""" + + +def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) -> str: + """构建用户消息:标的代码 + 数据 JSON + 可选关注点。""" + data_json = json.dumps(fins, ensure_ascii=False, indent=2) + lines = [ + f"标的标准代码: {symbol}", + f"数据概览: {_summarize(fins)}", + "", + "以下是该标的最新财务数据(JSON 格式,金额单位为元,比率类指标为百分点):", + "```json", + data_json, + "```", + ] + if focus.strip(): + lines.extend([ + "", + f"本次分析请特别关注: {focus.strip()}", + ]) + return "\n".join(lines) + + +async def analyze_financials_stream( + data_dir: Path, + symbol: str, + focus: str = "", +) -> AsyncIterator[str]: + """流式分析:yield 出每个文本 chunk。 + + - 启动时先 yield 一条 {"type":"meta",...} 让前端显示数据摘要 + - 之后逐 chunk yield {"type":"delta","content":"..."} + - 出错时 yield {"type":"error","message":"..."} + - 结束 yield {"type":"done"} + """ + # 1. 加载数据 + fins = _load_stock_financials(data_dir, symbol) + total_rows = sum(len(v) for v in fins.values()) + if total_rows == 0: + yield json.dumps({"type": "error", "message": f"标的 {symbol} 暂无任何财务数据,请先同步财务表"}, ensure_ascii=False) + return + + # 2. meta + yield json.dumps({ + "type": "meta", + "symbol": symbol, + "summary": _summarize(fins), + "periods": total_rows, + }, ensure_ascii=False) + + # 3. 调用 LLM 流式 + try: + from openai import AsyncOpenAI + from app import secrets_store + from app.config import settings + + ai_key = secrets_store.get_ai_key() + if not ai_key: + yield json.dumps({"type": "error", "message": "AI API Key 未配置,请在「设置 → AI」中配置"}, ensure_ascii=False) + return + + user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent + client = AsyncOpenAI( + api_key=ai_key, + base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"), + timeout=180.0, + max_retries=2, + default_headers={"User-Agent": user_agent}, + ) + + user_prompt = _build_user_prompt(fins, symbol, focus) + stream = await client.chat.completions.create( + model=secrets_store.get_ai_config("ai_model", "gpt-5.5"), + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.4, + max_tokens=4000, + stream=True, + ) + + async for chunk in stream: + delta = chunk.choices[0].delta if chunk.choices else None + if delta and delta.content: + yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False) + + except Exception as e: # noqa: BLE001 + logger.exception("AI financial analysis failed for %s: %s", symbol, e) + yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False) + return + + yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index 2f9fe14..22dee64 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -8,7 +8,7 @@ from __future__ import annotations import asyncio import logging import threading -from datetime import date, datetime, timezone +from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -207,9 +207,43 @@ class FinancialScheduler: self._data_dir = data_dir self._capset = capset self._running = True + # 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步" + try: + from app.services import preferences + restored = dict(preferences.get_financial_sync_times()) + # 老用户迁移兜底: 若某表在 preferences 无记录但 parquet 已存在(升级前同步过), + # 用 parquet 文件的修改时间作为同步时间并补写持久化。 + for table in FINANCIAL_TABLES: + if table in restored: + continue + parquet = data_dir / "financials" / table / "part.parquet" + if parquet.exists(): + mtime = datetime.fromtimestamp(parquet.stat().st_mtime, tz=timezone.utc).isoformat() + restored[table] = mtime + preferences.set_financial_sync_time(table, mtime) + logger.info("FinancialScheduler backfilled last_sync for %s from parquet mtime", table) + self._last_sync = restored + if self._last_sync: + logger.info("FinancialScheduler restored last_sync: %s", list(self._last_sync.keys())) + except Exception as e: # noqa: BLE001 + logger.warning("restore financial_sync_times failed: %s", e) self._task = asyncio.create_task(self._run_loop()) logger.info("FinancialScheduler started") + def _record_sync(self, table: str) -> None: + """记录一张表的同步完成时间: 更新内存 + 持久化到 preferences.json。 + + 持久化确保即使重启,前端 /status 仍返回真实的最后同步时间, + 不会错误地显示"尚未同步"。 + """ + ts = datetime.now(timezone.utc).isoformat() + self._last_sync[table] = ts + try: + from app.services import preferences + preferences.set_financial_sync_time(table, ts) + except Exception as e: # noqa: BLE001 + logger.warning("persist financial_sync_time(%s) failed: %s", table, e) + def stop(self) -> None: self._running = False if self._task: @@ -229,7 +263,7 @@ class FinancialScheduler: # 每周: 只同步 metrics try: rows = sync_metrics(self._data_dir, self._capset) - self._last_sync["metrics"] = datetime.now(timezone.utc).isoformat() + self._record_sync("metrics") logger.info("FinancialScheduler: metrics synced, %d rows", rows) except Exception as e: logger.warning("FinancialScheduler: metrics sync failed: %s", e) @@ -259,14 +293,14 @@ class FinancialScheduler: if not fn: return {} rows = fn(self._data_dir, self._capset) - self._last_sync[table] = datetime.now(timezone.utc).isoformat() + self._record_sync(table) return {table: rows} # 全部同步 symbols = _get_symbols(self._data_dir) result: dict[str, int] = {} for t in FINANCIAL_TABLES: result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True) - self._last_sync[t] = datetime.now(timezone.utc).isoformat() + self._record_sync(t) _refresh_financials_views(self._data_dir) return result diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index a7e88d9..f462b01 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -311,3 +311,18 @@ def set_onboarding_completed(done: bool = True) -> bool: """标记首次使用向导完成状态。""" save({"onboarding_completed": bool(done)}) return bool(done) + + +# ===== 财务数据同步时间(持久化,重启不丢失) ===== +# 结构: { "metrics": "2026-06-25T10:00:00+08:00", "income": ..., ... } + +def get_financial_sync_times() -> dict[str, str]: + """返回各财务表的最后同步时间(ISO 字符串)。未同步过的表不在返回值中。""" + return load().get("financial_sync_times", {}) or {} + + +def set_financial_sync_time(table: str, iso_ts: str) -> None: + """更新单张财务表的最后同步时间(合并写入,不清除其他表)。""" + times = get_financial_sync_times() + times[table] = iso_ts + save({"financial_sync_times": times}) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5059581..8aa1493 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.44" +version = "0.1.45" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" diff --git a/frontend/package.json b/frontend/package.json index c8c160d..9dbef2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.44", + "version": "0.1.45", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0af878b..e12b714 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,8 @@ import { motion } from 'framer-motion' import { useQuoteStream } from '@/lib/useQuoteStream' import { ToastContainer } from '@/components/Toast' import { AlertToastContainer } from '@/components/AlertToast' +import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' +import { AiReportBubble } from '@/components/financials/AiReportBubble' import { useCapabilities, useSettings, @@ -65,7 +67,7 @@ const nav = [ { to: '/concept-analysis', label: '概念分析', icon: Layers3 }, { to: '/industry-analysis', label: '行业分析', icon: Landmark }, { to: '/stock-analysis', label: '个股分析', icon: TrendingUp }, - { to: '/financials', label: '财务', icon: FileText }, + { to: '/financials', label: '财务分析', icon: FileText }, { to: '/indices', label: '指数', icon: BarChart3 }, { to: '/trading', label: '交易', icon: Cable }, { to: '/monitor', label: '监控中心', icon: RadioTower }, @@ -529,6 +531,8 @@ export function Layout() { + + ) } diff --git a/frontend/src/components/financials/AiAnalysisDialog.tsx b/frontend/src/components/financials/AiAnalysisDialog.tsx new file mode 100644 index 0000000..4e004c2 --- /dev/null +++ b/frontend/src/components/financials/AiAnalysisDialog.tsx @@ -0,0 +1,266 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { + X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw, + Database, Settings2, Send, Wand2, Minimize2, History, +} from 'lucide-react' +import { cn } from '@/lib/cn' +import { MarkdownRenderer } from './MarkdownRenderer' +import { + type ActiveTask, type HistoryReport, + minimizeDialog, closeDialog, startAnalysis, +} from '@/lib/aiReportStore' + +interface Props { + /** 当前展示的任务;活跃任务或历史报告 */ + task: ActiveTask | HistoryReport | null + mode: 'active' | 'history' | null + minimized: boolean +} + +type Phase = 'loading' | 'streaming' | 'done' | 'error' + +// 统一字段读取:活跃任务有 phase/createdAt,历史报告没有(按 done 处理) +function getPhase(task: ActiveTask | HistoryReport | null): Phase { + if (!task) return 'loading' + if ('phase' in task) return task.phase + return 'done' // 历史报告视为已完成 +} +function getContent(task: ActiveTask | HistoryReport | null): string { + return task?.content ?? '' +} +function getMeta(task: ActiveTask | HistoryReport | null) { + if (!task) return null + if ('meta' in task) return task.meta + // 历史报告 + return { summary: task.summary, periods: task.periods } +} + +export function AiAnalysisDialog({ task, mode, minimized }: Props) { + const scrollRef = useRef(null) + const focusInputRef = useRef(null) + const [focus, setFocus] = useState('') + const [copied, setCopied] = useState(false) + + const phase = getPhase(task) + const content = getContent(task) + const meta = getMeta(task) + const isHistory = mode === 'history' + const isWorking = phase === 'loading' || phase === 'streaming' + const open = !!task && !minimized + + // 流式时自动滚动到底部 + useEffect(() => { + if (open && phase === 'streaming' && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [content, phase, open]) + + // 切换任务时回填 focus + useEffect(() => { + setFocus(task && 'focus' in task ? task.focus : '') + }, [task]) + + const handleStartNew = useCallback(async () => { + if (!task) return + const name = 'name' in task ? task.name : '' + await startAnalysis(task.symbol, name, focus.trim()) + }, [task, focus]) + + const handleCopy = async () => { + if (!content) return + try { + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { /* ignore */ } + } + + if (!open) return null + + const error = task && 'error' in task ? task.error : '' + + return ( + + { if (e.target === e.currentTarget && !isWorking) closeDialog() }} + > + + {/* ===== 头部 ===== */} +
+
+
+ {isHistory + ? + : } +
+
+
+ + {isHistory ? '历史分析报告' : 'AI 财务分析'} + + {task && {task.name}} + {task && {task.symbol}} +
+
+ {meta?.summary ? ( + + + {meta.summary} + + ) : isWorking ? 正在准备数据… : null} + {phase === 'streaming' && ( + + 生成中 + + )} + {isHistory && task && 'created_at' in task && ( + {fmtRelative(task.created_at)} + )} +
+
+ {/* 右侧操作按钮 */} +
+ {/* 复制:仅在内容就绪且非生成中显示 */} + {content && !isWorking && ( + + )} + {/* 生成中:仅最小化(后台继续生成),无关闭按钮 */} + {!isHistory && isWorking && ( + + )} + {/* 完成态/历史报告:显示关闭按钮 */} + {(!isWorking || isHistory) && ( + + )} +
+
+
+ + {/* ===== 内容区 ===== */} +
+ {/* 加载态 */} + {phase === 'loading' && !content && ( +
+
+
+ +
+ +
+
AI 正在分析财务数据…
+
读取利润表 / 资负表 / 现金流 / 核心指标,生成专业报告
+
+ )} + + {/* 错误态 */} + {phase === 'error' && ( +
+
+ +
+
分析失败
+
{error}
+ {error.includes('AI') && ( + + )} + +
+ )} + + {/* 报告内容 */} + {(content || phase === 'streaming') && ( +
+ + {phase === 'streaming' && ( + + )} +
+ )} +
+ + {/* ===== 底部:自定义关注点输入 ===== */} +
+
+
+ + 关注重点 +
+ setFocus(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }} + disabled={isWorking} + placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看债务风险…回车重新分析' : '可留空,留空则全面分析')} + className={cn( + 'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40', + 'focus:outline-none focus:ring-2 focus:ring-purple-400/30 transition-shadow disabled:opacity-50', + )} + /> + {isHistory ? ( + + ) : ( + + )} +
+

+ {isHistory + ? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。' + : '报告由项目已配置的 AI 模型基于本地财务数据生成;可在输入框追加关注点后重新生成。报告仅供参考,不构成投资建议。'} +

+
+
+
+
+ ) +} + +// ===== 小工具 ===== +function fmtRelative(iso: string): string { + try { + const t = new Date(iso).getTime() + const diff = Date.now() - t + if (diff < 60_000) return '刚刚' + if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前` + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前` + if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前` + return new Date(iso).toLocaleDateString('zh-CN') + } catch { return '' } +} \ No newline at end of file diff --git a/frontend/src/components/financials/AiAnalysisHost.tsx b/frontend/src/components/financials/AiAnalysisHost.tsx new file mode 100644 index 0000000..a8908bd --- /dev/null +++ b/frontend/src/components/financials/AiAnalysisHost.tsx @@ -0,0 +1,14 @@ +import { useDialogTask, useDialogState } from '@/lib/aiReportStore' +import { AiAnalysisDialog } from './AiAnalysisDialog' + +/** + * AI 分析对话框宿主 —— 单点挂载在 Layout。 + * + * 从 store 读取当前对话框状态(任务 + 最小化),把 AiAnalysisDialog 作为纯视图渲染。 + * 一次挂载,全局生效:任意页面发起的分析都会显示在这个对话框里。 + */ +export function AiAnalysisHost() { + const { task, mode } = useDialogTask() + const { minimized } = useDialogState() + return +} diff --git a/frontend/src/components/financials/AiReportBubble.tsx b/frontend/src/components/financials/AiReportBubble.tsx new file mode 100644 index 0000000..759f756 --- /dev/null +++ b/frontend/src/components/financials/AiReportBubble.tsx @@ -0,0 +1,254 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Loader2, Check, AlertCircle } from 'lucide-react' +import { useActiveTasks, restoreDialog } from '@/lib/aiReportStore' +import type { ActiveTask } from '@/lib/aiReportStore' + +/** + * AI 分析任务全局气泡容器 —— 玻璃拟态卡片,挂在网页右侧。 + * + * 拖拽丝滑的关键(60fps): + * - 位置用 transform: translate3d 存储(走 GPU 合成层,不触发 layout/paint) + * - 拖动期间直接操作 DOM.style.transform,完全绕开 React setState 重渲染 + * - 拖动结束才同步一次 state + 持久化 localStorage + * - 拖动时给容器加 .dragging 类,禁用所有 transition,消除回弹延迟 + * + * 视觉: + * - 玻璃拟态(frosted glass):半透明 + backdrop-blur + 细边框 + 内发光 + * - 固定宽度,内容居中,多任务竖向堆叠 + * - 生成中:柔和呼吸光环(非刺眼 ping) + * - hover:展开操作区,带平滑过渡 + */ + +const BUBBLE_W = 148 // 卡片固定宽度(紧凑单行版) +const EDGE_MARGIN = 12 // 距视口边缘最小间距 + +export function AiReportBubble() { + const activeTasks = useActiveTasks() + const containerRef = useRef(null) + // pos 只在拖拽结束时更新一次(用于初始化/持久化),拖拽过程不触发它 + const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos()) + + // ===== 拖拽(纯 DOM 操作,60fps) ===== + const draggingRef = useRef(false) + const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 }) // 鼠标起点 + 元素起点 + const movedRef = useRef(false) // 本次是否真的移动了(区分点击) + // 记录 pointerdown 时命中的卡片回调(松手时若未拖动则触发它 = 点击) + const clickTargetRef = useRef<(() => void) | null>(null) + + const applyTransform = useCallback((x: number, y: number) => { + const el = containerRef.current + if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)` + }, []) + + const clamp = useCallback((x: number, y: number) => { + const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN + const maxY = window.innerHeight - 80 + return { + x: Math.max(EDGE_MARGIN, Math.min(maxX, x)), + y: Math.max(EDGE_MARGIN, Math.min(maxY, y)), + } + }, []) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + draggingRef.current = true + movedRef.current = false + dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y } + const el = containerRef.current + if (el) el.classList.add('dragging') + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) + }, [pos.x, pos.y]) + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!draggingRef.current) return + const dx = e.clientX - dragData.current.mx + const dy = e.clientY - dragData.current.my + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true + const nx = dragData.current.ox + dx + const ny = dragData.current.oy + dy + const c = clamp(nx, ny) + applyTransform(c.x, c.y) // ← 直接改 DOM,不走 React,丝滑 + }, [clamp, applyTransform]) + + const onPointerUp = useCallback(() => { + if (!draggingRef.current) return + draggingRef.current = false + const el = containerRef.current + if (el) el.classList.remove('dragging') + if (movedRef.current) { + // 拖动结束 → 持久化位置 + setPos(prev => { + const transform = el?.style.transform ?? '' + const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/) + const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev + savePos(finalPos) + return finalPos + }) + } else { + // 未移动 → 视为点击,触发卡片回调 + const fn = clickTargetRef.current + clickTargetRef.current = null + fn?.() + } + }, []) + + // 窗口尺寸变化时确保不越界 + useEffect(() => { + const onResize = () => { + setPos(prev => { + const c = clamp(prev.x, prev.y) + if (c.x !== prev.x || c.y !== prev.y) { + applyTransform(c.x, c.y) + return c + } + return prev + }) + } + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [clamp, applyTransform]) + + // 初始化 transform(pos 变化时同步,如 resize / 首次挂载) + useEffect(() => { + applyTransform(pos.x, pos.y) + }, [pos.x, pos.y, applyTransform]) + + if (activeTasks.length === 0) return null + + return ( +
+ + {activeTasks.map((task, i) => ( + { clickTargetRef.current = () => restoreDialog(task.id) }} + /> + ))} + + + {/* 内联样式:拖动时禁用过渡,确保 1:1 跟手 */} + +
+ ) +} + +// ===== 单个胶囊卡片(紧凑玻璃拟态) ===== +function BubbleItem({ task, isLast, onPointerDown }: { + task: ActiveTask + isLast: boolean + onPointerDown: () => void +}) { + const isWorking = task.phase === 'loading' || task.phase === 'streaming' + const isError = task.phase === 'error' + + // 状态配色 + const accent = isWorking + ? 'from-purple-500/25 to-fuchsia-500/20 text-purple-300 border-purple-300/40 shadow-[0_6px_24px_-10px_rgba(168,85,247,0.5)]' + : isError + ? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]' + : 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]' + + return ( + +
+ {/* 生成中:顶部进度流光 */} + {isWorking && ( +
+
+
+ )} + + {/* 状态图标 */} + + {isWorking ? ( + + ) : isError ? ( + + ) : ( + + )} + + + {/* 标的名(单行) */} + + {task.name || task.symbol} + + + {/* 状态后缀 */} + + {isWorking ? ( + 分析中 + ) : isError ? ( + 失败 + ) : ( + 点击查看 + )} + +
+ + {/* 内联关键帧:进度条流动 */} + + + ) +} + +// ===== 位置持久化 ===== +const POS_KEY = 'ai_bubble_pos' +function loadPos(): { x: number; y: number } { + // 默认:右下角(距右边缘 EDGE_MARGIN,距底部留出空间避开右下角元素) + const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN) + const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 200) + try { + const v = localStorage.getItem(POS_KEY) + if (v) { + const p = JSON.parse(v) + if (typeof p.x === 'number' && typeof p.y === 'number') { + // 钳制到当前视口(防止保存的位置在缩小后的窗口外) + return { + x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)), + y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)), + } + } + } + } catch { /* ignore */ } + return { x: defaultX, y: defaultY } +} +function savePos(p: { x: number; y: number }) { + try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ } +} diff --git a/frontend/src/components/financials/MarkdownRenderer.tsx b/frontend/src/components/financials/MarkdownRenderer.tsx new file mode 100644 index 0000000..8f85df3 --- /dev/null +++ b/frontend/src/components/financials/MarkdownRenderer.tsx @@ -0,0 +1,223 @@ +import { Fragment, type ReactNode } from 'react' + +/** + * 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。 + * + * 支持的语法(AI 财务分析提示词约束的子集,足够用): + * - 标题 # ## ### #### + * - 加粗 **text** + * - 行内代码 `code` + * - 无序列表 - / * + * - 有序列表 1. + * - 表格 | a | b | + * - 引用 > + * - 分隔线 --- / *** + * - 段落 + * + * 不追求完整 GFM,只覆盖 AI 报告会产出的结构。 + */ + +// ===== 行内格式:加粗 / 行内代码 / 星号评级 ===== + +function renderInline(text: string, keyBase: string): ReactNode[] { + const nodes: ReactNode[] = [] + // 正则:匹配 **加粗** 或 `代码` 或 ★ 评级 + const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g + let last = 0 + let m: RegExpExecArray | null + let i = 0 + while ((m = re.exec(text)) !== null) { + if (m.index > last) nodes.push({text.slice(last, m.index)}) + if (m[1]) { + // 加粗 + nodes.push({m[2]}) + } else if (m[3]) { + // 行内代码 + nodes.push( + + {m[4]} + , + ) + } + last = m.index + m[0].length + i++ + } + if (last < text.length) nodes.push({text.slice(last)}) + return nodes +} + +// ===== 表格解析 ===== + +function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null { + // 找到连续的表格行(以 | 开头) + const tableLines: string[] = [] + let idx = start + while (idx < lines.length && lines[idx].trim().startsWith('|')) { + tableLines.push(lines[idx].trim()) + idx++ + } + if (tableLines.length < 2) return null + // 第二行必须是分隔行 |---|---| + if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) { + return null + } + const parseRow = (line: string) => + line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()) + const header = parseRow(tableLines[0]) + const body = tableLines.slice(2).map(parseRow) + return { rows: [header, ...body], consumed: tableLines.length } +} + +// ===== 主渲染 ===== + +export function MarkdownRenderer({ content }: { content: string }) { + const lines = content.replace(/\r\n/g, '\n').split('\n') + const blocks: ReactNode[] = [] + let i = 0 + let key = 0 + + while (i < lines.length) { + const line = lines[i] + const trimmed = line.trim() + + // 空行 + if (!trimmed) { + i++ + continue + } + + // 分隔线 + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + blocks.push(
) + i++ + continue + } + + // 标题 + const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/) + if (hMatch) { + const level = hMatch[1].length + const text = hMatch[2] + const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs' + const mtCls = level <= 2 ? 'mt-4' : 'mt-3' + blocks.push( +
+ {renderInline(text, `h-${key}`)} +
, + ) + i++ + continue + } + + // 引用 + if (trimmed.startsWith('>')) { + const quoteLines: string[] = [] + while (i < lines.length && lines[i].trim().startsWith('>')) { + quoteLines.push(lines[i].trim().replace(/^>\s?/, '')) + i++ + } + blocks.push( +
+ {renderInline(quoteLines.join(' '), `q-${key}`)} +
, + ) + continue + } + + // 表格 + if (trimmed.startsWith('|')) { + const table = parseTable(lines, i) + if (table) { + const [header, ...body] = table.rows + const ncol = header.length + blocks.push( +
+ + + {/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */} + + {Array.from({ length: ncol - 1 }).map((_, ci) => ( + + ))} + + + + {header.map((cell, ci) => ( + + ))} + + + + {body.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {renderInline(cell, `th-${key}-${ci}`)} +
+ {renderInline(cell, `td-${key}-${ri}-${ci}`)} +
+
, + ) + i += table.consumed + continue + } + } + + // 无序列表 + if (/^[-*]\s+/.test(trimmed)) { + const items: string[] = [] + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\s*[-*]\s+/, '')) + i++ + } + blocks.push( +
    + {items.map((item, ii) => ( +
  • + + {renderInline(item, `li-${key}-${ii}`)} +
  • + ))} +
, + ) + continue + } + + // 有序列表 + if (/^\d+\.\s+/.test(trimmed)) { + const items: string[] = [] + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\s*\d+\.\s+/, '')) + i++ + } + blocks.push( +
    + {items.map((item, ii) => ( +
  1. + + {ii + 1} + + {renderInline(item, `ol-${key}-${ii}`)} +
  2. + ))} +
, + ) + continue + } + + // 普通段落 + blocks.push( +

+ {renderInline(trimmed, `p-${key}`)} +

, + ) + i++ + } + + return
{blocks}
+} diff --git a/frontend/src/components/financials/ReportHistoryPanel.tsx b/frontend/src/components/financials/ReportHistoryPanel.tsx new file mode 100644 index 0000000..2b78c3a --- /dev/null +++ b/frontend/src/components/financials/ReportHistoryPanel.tsx @@ -0,0 +1,125 @@ +import { useEffect } from 'react' +import { History, Trash2, FileText, Clock, Sparkles, Loader2 } from 'lucide-react' +import { useHistoryReports, openHistoryReport, deleteReport, loadHistory } from '@/lib/aiReportStore' +import { useActiveTasks } from '@/lib/aiReportStore' + +/** + * AI 财务分析历史报告面板 —— 显示在财务页底部。 + * + * - 列出最近 20 条报告(后端裁剪) + * - 点击查看 → 打开到对话框(历史模式) + * - 显示正在生成中的对应标的(若该标的有活跃任务,标注) + * - 支持删除单条 + */ +export function ReportHistoryPanel() { + const { reports, loaded } = useHistoryReports() + const activeTasks = useActiveTasks() + + // 首次挂载拉取一次 + useEffect(() => { loadHistory() }, []) + + // 活跃任务的 symbol 集合(用于在历史列表里标注"生成中") + const activeSymbols = new Set(activeTasks.map(t => t.symbol)) + + if (!loaded) { + return ( +
+ +
+ ) + } + + if (reports.length === 0) { + return ( +
+ +
暂无历史分析报告
+
选择个股后点击「AI 财务分析」生成,报告会自动保存在此
+
+ ) + } + + return ( +
+ {/* 标题栏 */} +
+
+ + 历史分析报告 + {reports.length}/20 +
+ 点击查看 · 报告最多保留 20 条 +
+ + {/* 列表 */} +
+ {reports.map(r => { + const isGenerating = activeSymbols.has(r.symbol) + return ( +
openHistoryReport(r.id)} + > + {/* 图标 */} +
+ {isGenerating + ? + : } +
+ + {/* 主信息 */} +
+
+ {r.name || r.symbol} + {r.symbol} + {r.focus && ( + + {r.focus} + + )} +
+ {/* 摘要 */} +
+ + + {fmtRelative(r.created_at)} + + {r.summary && ( + {r.summary} + )} +
+
+ + {/* 删除按钮 */} + +
+ ) + })} +
+
+ ) +} + +// ===== 小工具 ===== +function fmtRelative(iso: string): string { + try { + const t = new Date(iso).getTime() + const diff = Date.now() - t + if (diff < 60_000) return '刚刚' + if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前` + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前` + if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前` + return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) + } catch { return '' } +} diff --git a/frontend/src/components/financials/StockFinancialDetail.tsx b/frontend/src/components/financials/StockFinancialDetail.tsx index 4a15b55..18e8c12 100644 --- a/frontend/src/components/financials/StockFinancialDetail.tsx +++ b/frontend/src/components/financials/StockFinancialDetail.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles } from 'lucide-react' +import { motion, AnimatePresence } from 'framer-motion' +import { CalendarDays, TrendingUp, FileText, Wallet, Activity, Sparkles, AlertTriangle, Loader2 } from 'lucide-react' import { useFinancialMetrics, useFinancialIncome, @@ -8,6 +9,8 @@ import { } from '@/lib/useFinancials' import { fmtPrice, fmtBigNum, fmtDate } from '@/lib/format' import { Skeleton } from '@/components/data/Skeleton' +import { startAnalysis, findLatestHistoryReport, openHistoryReport } from '@/lib/aiReportStore' +import { toast } from '@/components/Toast' interface Props { symbol: string @@ -112,11 +115,33 @@ function formatValue(v: number | null | undefined, fmt: FmtType): string { export function StockFinancialDetail({ symbol, name }: Props) { const [tab, setTab] = useState('metrics') - // AI 财务分析占位: 功能开发中, 点击提示 - const [showDevToast, setShowDevToast] = useState(false) - const handleAiAnalysis = () => { - setShowDevToast(true) - setTimeout(() => setShowDevToast(false), 2500) + // AI 分析:点击时检查历史,若已有同标的报告则二次确认 + const [checking, setChecking] = useState(false) + const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null) + + const handleAiClick = async () => { + if (checking) return + setChecking(true) + try { + const latest = await findLatestHistoryReport(symbol) + if (latest) { + // 有历史报告 → 弹二次确认 + setConfirmReport({ id: latest.id, created_at: latest.created_at, focus: latest.focus }) + } else { + // 无历史 → 直接分析 + await doAnalysis() + } + } catch { + // 查询失败不阻塞,直接分析 + await doAnalysis() + } finally { + setChecking(false) + } + } + + const doAnalysis = async () => { + const r = await startAnalysis(symbol, name) + if (r.error) toast(r.error, 'error') } const metrics = useFinancialMetrics(symbol) @@ -143,7 +168,7 @@ export function StockFinancialDetail({ symbol, name }: Props) { const latestAnnounce = rows[0]?.announce_date ?? metrics.data?.data?.[0]?.announce_date ?? null return ( -
+
{/* 头部:标的 + 报告期 */}
@@ -151,6 +176,15 @@ export function StockFinancialDetail({ symbol, name }: Props) { {symbol}
+ {latestPeriod && (
@@ -160,14 +194,6 @@ export function StockFinancialDetail({ symbol, name }: Props) { )}
)} -
@@ -241,12 +267,78 @@ export function StockFinancialDetail({ symbol, name }: Props) { )}
- {/* AI 分析开发中提示 */} - {showDevToast && ( -
- ✨ AI 财务分析功能开发中,敬请期待 -
- )} + {/* AI 分析二次确认:已有该标的历史报告 */} + + {confirmReport && ( +
+ setConfirmReport(null)} + /> + +
+
+ +
+
+

该个股已有分析报告

+

+ {name} + {symbol} 在 + {fmtReportTime(confirmReport.created_at)} + 已生成过 AI 财务分析报告。 +

+

+ 您可以查看历史报告,或基于最新数据重新生成一份。 +

+
+
+
+ + + +
+
+
+ )} +
) } + +// 历史报告时间友好显示 +function fmtReportTime(iso: string): string { + try { + const t = new Date(iso).getTime() + const diff = Date.now() - t + if (diff < 60_000) return '刚刚' + if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前` + if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前` + if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前` + return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) + } catch { return '' } +} diff --git a/frontend/src/lib/aiReportStore.ts b/frontend/src/lib/aiReportStore.ts new file mode 100644 index 0000000..38985f5 --- /dev/null +++ b/frontend/src/lib/aiReportStore.ts @@ -0,0 +1,332 @@ +import { useSyncExternalStore } from 'react' +import { api } from './api' + +/** + * AI 财务分析 —— 全局任务/报告 store(与 UI 解耦)。 + * + * 设计要点: + * 1. 流式接收逻辑在这里,与弹窗组件解耦 → 用户关闭/最小化弹窗,后台流照常累积。 + * 2. useSyncExternalStore 订阅 → 任意组件(弹窗、气泡、历史面板)实时同步。 + * 3. "活跃任务"上限 MAX_ACTIVE=3:同时进行的任务最多 3 个,超出拒绝新建。 + * (历史报告名额 MAX_REPORTS=20 在后端裁剪,与活跃任务名额分离) + * 4. 同 symbol 已有活跃任务 → 直接聚焦那个,不新建第 2 个。 + * 5. 任务完成(收到 done 或 content 非空且流结束)→ 自动存后端 + 移入历史 + 弹窗可恢复为"历史模式"。 + */ + +export type Phase = 'loading' | 'streaming' | 'done' | 'error' + +export interface ActiveTask { + id: string // 任务 id(前端生成,与最终 report id 解耦) + symbol: string + name: string + focus: string + phase: Phase + content: string // 累积的 Markdown + error: string + meta: { summary?: string; periods?: number } | null + createdAt: number // ms 时间戳 + savedReportId?: string // 完成后存到后端的报告 id + doneAt?: number // 进入 done/error 态的时间戳(用于气泡过期清理) + dismissed?: boolean // 用户已从气泡点击查看过 → 不再在气泡显示 +} + +export interface HistoryReport { + id: string + symbol: string + name: string + focus: string + content: string + periods?: number + summary?: string + created_at: string +} + +const MAX_ACTIVE = 3 + +// ===== 全局状态 ===== +let activeTasks: ActiveTask[] = [] +let history: HistoryReport[] = [] +let historyLoaded = false +const listeners = new Set<() => void>() + +// 当前"前台"展示的任务: +// - 活跃任务 id(正在生成/刚完成,对话框打开) +// - 或 'history:'(查看历史报告) +// - 或 null(对话框关闭/最小化) +let activeDialogTaskId: string | null = null +let dialogMinimized = false // 对话框是否最小化为气泡 + +function emit() { listeners.forEach(fn => fn()) } + +function subscribe(fn: () => void) { + listeners.add(fn) + return () => { listeners.delete(fn) } +} + +// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。 +// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。 +let _activeSnap: ActiveTask[] = [] +let _historySnap: HistoryReport[] = [] +interface DialogSnap { taskId: string | null; minimized: boolean } +let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized } + +function rebuildSnap() { + _activeSnap = activeTasks + _historySnap = history + _dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized } +} + +function getActiveSnapshot() { return _activeSnap } +function getHistorySnapshot() { return _historySnap } +function getDialogSnapshot() { return _dialogSnap } + +function patchTask(id: string, patch: Partial) { + activeTasks = activeTasks.map(t => { + if (t.id !== id) return t + const next = { ...t, ...patch } + // 首次进入 done/error 态时记录 doneAt + if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) { + next.doneAt = Date.now() + } + return next + }) + rebuildSnap() + emit() +} + +// ===== 公开:查询 hooks ===== + +export function useBubbleTasks(): ActiveTask[] { + const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => []) + // 同时订阅对话框状态:最小化/打开/关闭会改变气泡可见性,需独立触发重渲染。 + // (否则最小化时 activeTasks 引用未变,useSyncExternalStore 不会重渲染,胶囊不出现) + useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false })) + const ds = _dialogSnap + return all.filter(t => { + // 进行中:始终显示(dismissed 仅作用于完成态,不影响生成中的任务再次最小化) + if (t.phase === 'loading' || t.phase === 'streaming') { + // 除非对话框正打开看着它(非最小化) + return !(ds.taskId === t.id && !ds.minimized) + } + // 完成/失败态:常驻显示,直到用户主动点击查看(dismissed)。 + // 不设自动过期 —— 胶囊是持续可见的状态指示器,历史报告列表是查看入口。 + if (t.dismissed) return false // 用户已点击查看过 → 移除 + if (!ds.minimized && ds.taskId === t.id) return false // 对话框正展示 → 不重复 + return true + }) +} + +/** 兼容旧调用名(Layout 等处可能引用) */ +export const useActiveTasks = useBubbleTasks + +export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } { + const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => []) + return { reports, loaded: historyLoaded } +} + +export function useDialogState() { + return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false })) +} + +/** 当前对话框要展示的任务(活跃或历史),null=未打开。 */ +export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } { + const ds = useDialogState() + const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => []) + const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => []) + if (!ds.taskId) return { task: null, mode: null } + if (ds.taskId.startsWith('history:')) { + const rid = ds.taskId.slice('history:'.length) + return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' } + } + return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' } +} + +// ===== 公开:动作 ===== + +/** 拉取历史报告(惰性,首次需要时调用)。 */ +export async function loadHistory(): Promise { + try { + const res = await api.financialReportsList() + history = res.reports ?? [] + historyLoaded = true + rebuildSnap() + emit() + } catch { + // 静默失败,列表会显示空 + } +} + +/** + * 查询某只股票最近一次的历史分析报告(用于二次确认提示)。 + * 若历史未加载,先触发拉取。 + * @returns 最近一条报告,或 null + */ +export async function findLatestHistoryReport(symbol: string): Promise { + if (!historyLoaded) await loadHistory() + // history 已按 created_at 降序,取第一条匹配 + return history.find(r => r.symbol === symbol) ?? null +} + +/** + * 启动一个新的 AI 分析任务。 + * @returns 任务 id;若超出上限或已有活跃任务,返回 { error }。 + */ +export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> { + // 同 symbol 已有活跃任务 → 直接聚焦它 + const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming')) + if (existing) { + activeDialogTaskId = existing.id + dialogMinimized = false + rebuildSnap() + emit() + return { id: existing.id } + } + // 上限检查 + const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming') + if (ongoing.length >= MAX_ACTIVE) { + return { error: `同时进行的分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` } + } + + const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` + const task: ActiveTask = { + id, symbol, name, focus, + phase: 'loading', content: '', error: '', + meta: null, createdAt: Date.now(), + } + activeTasks = [...activeTasks, task] + activeDialogTaskId = id + dialogMinimized = false + rebuildSnap() + emit() + + // 启动流式接收(后台运行,不阻塞) + runStream(id, symbol, focus) + return { id } +} + +async function runStream(id: string, symbol: string, focus: string) { + try { + let firstDelta = true + for await (const chunk of api.financialAnalyzeStream(symbol, focus)) { + // 任务可能已被取消(不在列表里了)→ 终止 + const cur = activeTasks.find(t => t.id === id) + if (!cur) return + switch (chunk.type) { + case 'meta': + patchTask(id, { meta: { summary: chunk.summary, periods: chunk.periods } }) + break + case 'delta': + if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false } + patchTask(id, { content: cur.content + (chunk.content ?? '') }) + break + case 'error': + patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' }) + return + case 'done': + // 标记完成,稍后持久化(content 可能还在最后几个 delta 里,以 done 时为准) + patchTask(id, { phase: 'done' }) + break + } + } + // 流正常结束 → 持久化报告 + const final = activeTasks.find(t => t.id === id) + if (final && final.phase !== 'error' && final.content) { + try { + const res = await api.financialReportSave({ + symbol: final.symbol, + name: final.name, + focus: final.focus, + content: final.content, + periods: final.meta?.periods, + summary: final.meta?.summary ?? '', + }) + if (res.report) { + patchTask(id, { savedReportId: res.report.id }) + // 加到历史列表头部 + history = [res.report, ...history.filter(r => r.id !== res.report.id)] + historyLoaded = true + rebuildSnap() + emit() + // 任务完成:不自动弹出对话框,只在胶囊显示"已完成"态,用户想看再点。 + // (若对话框正打开看此任务,内容已实时更新;最小化/在别处则胶囊亮起完成态) + } + } catch { + // 持久化失败不影响前端已展示的内容 + } + } + } catch (e: any) { + const msg = String(e?.message ?? '分析失败') + patchTask(id, { + phase: 'error', + error: msg.includes('API Key') || msg.includes('api_key') + ? 'AI API Key 未配置或无效,请在「设置 → AI」中配置' + : msg, + }) + } +} + +/** 打开对话框(活跃任务或历史报告)。 */ +export function openDialog(taskId: string) { + activeDialogTaskId = taskId + dialogMinimized = false + rebuildSnap() + emit() +} + +/** 最小化对话框 → 变成气泡。 */ +export function minimizeDialog() { + dialogMinimized = true + rebuildSnap() + emit() +} + +/** 关闭对话框(活跃任务继续在后台跑,仅移除对话框视图)。 + * 对历史报告:仅关闭视图。 + */ +export function closeDialog() { + activeDialogTaskId = null + dialogMinimized = false + rebuildSnap() + emit() +} + +/** 从气泡恢复对话框。 + * 仅对已完成/失败的任务标记 dismissed(看过结果就不必再弹); + * 生成中的任务不标记 —— 用户再次最小化时气泡应重新出现。 + */ +export function restoreDialog(taskId: string) { + const t = activeTasks.find(x => x.id === taskId) + if (t && (t.phase === 'done' || t.phase === 'error')) { + patchTask(taskId, { dismissed: true }) + } + activeDialogTaskId = taskId + dialogMinimized = false + rebuildSnap() + emit() +} + +/** 重试一个失败/已完成的任务(以新任务方式重新分析)。 */ +export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> { + return startAnalysis(task.symbol, task.name, task.focus) +} + +/** 删除历史报告。 */ +export async function deleteReport(reportId: string): Promise { + try { + await api.financialReportDelete(reportId) + history = history.filter(r => r.id !== reportId) + rebuildSnap() + emit() + } catch { + // 静默 + } +} + +/** 打开历史报告到对话框。 */ +export function openHistoryReport(reportId: string) { + activeDialogTaskId = `history:${reportId}` + dialogMinimized = false + rebuildSnap() + emit() +} + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f778756..9a5a0d0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -104,6 +104,18 @@ export interface FinancialCashFlowRecord { [key: string]: any } +/** AI 财务分析历史报告 */ +export interface AiFinancialReport { + id: string + symbol: string + name: string + focus: string + content: string + periods?: number + summary?: string + created_at: string +} + // ===== Kline ===== export interface MinuteKlineRow { datetime: string @@ -1172,6 +1184,80 @@ export const api = { `/api/financials/sync/${table}`, { method: 'POST' }, ), + /** AI 分析报告 CRUD */ + financialReportsList: () => + request<{ reports: AiFinancialReport[] }>('/api/financials/reports'), + + financialReportSave: (r: { + symbol: string; name?: string; focus?: string; content: string + periods?: number; summary?: string + }) => + request<{ ok: boolean; report: AiFinancialReport }>('/api/financials/reports', { + method: 'POST', body: JSON.stringify(r), + }), + + financialReportDelete: (reportId: string) => + request<{ ok: boolean }>(`/api/financials/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }), + + /** + * AI 财务分析 — 流式调用。 + * + * 返回一个可逐行读取的 async generator,每行是 JSON: + * {type:"meta",symbol,summary,periods} + * {type:"delta",content:"..."} ← 文本片段,逐个累加 + * {type:"error",message:"..."} + * {type:"done"} + * + * 用 ReadableStream 解析(而非 SSE EventSource),支持 POST body 且更简单。 + */ + async *financialAnalyzeStream(symbol: string, focus?: string): AsyncGenerator<{ + type: 'meta' | 'delta' | 'error' | 'done' + symbol?: string + summary?: string + periods?: number + content?: string + message?: string + }> { + const res = await fetch('/api/financials/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ symbol, focus: focus ?? '' }), + }) + if (!res.ok) { + let detail = '' + try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ } + const msg = detail || `${res.status} ${res.statusText}` + toast(msg, 'error') + throw new Error(msg) + } + if (!res.body) throw new Error('响应无 body') + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + // 按行分割(保留最后不完整的行在 buf) + const lines = buf.split('\n') + buf = lines.pop() ?? '' + for (const line of lines) { + const s = line.trim() + if (!s) continue + try { + yield JSON.parse(s) + } catch { + // 忽略无法解析的行 + } + } + } + // 处理残余 + if (buf.trim()) { + try { yield JSON.parse(buf.trim()) } catch { /* ignore */ } + } + }, + // ===== Strategy Engine ===== strategyList: () => request<{ strategies: StrategyDetail[] }>('/api/strategies'), diff --git a/frontend/src/pages/Branding.tsx b/frontend/src/pages/Branding.tsx index c6ebc1e..0dc350c 100644 --- a/frontend/src/pages/Branding.tsx +++ b/frontend/src/pages/Branding.tsx @@ -77,7 +77,7 @@ const MOCK_NAV = [ { icon: History, label: '回测' }, { icon: SignalIcon, label: '信号' }, { icon: Eye, label: '监控' }, - { icon: FileText, label: '财务' }, + { icon: FileText, label: '财务分析' }, ] export function Branding() { diff --git a/frontend/src/pages/Financials.tsx b/frontend/src/pages/Financials.tsx index c7e1cfc..d8f6330 100644 --- a/frontend/src/pages/Financials.tsx +++ b/frontend/src/pages/Financials.tsx @@ -6,6 +6,7 @@ import { useCapabilities } from '@/lib/useSharedQueries' import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials' import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch' import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail' +import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel' import { fmtBigNum } from '@/lib/format' import { toast } from '@/components/Toast' @@ -52,7 +53,7 @@ export function Financials() { if (!hasFinancial) { return ( <> - +
@@ -124,8 +125,8 @@ export function Financials() { return ( <> {syncing && ( @@ -287,6 +288,9 @@ export function Financials() { /> )}
+ + {/* AI 历史分析报告 */} + {available && } )}
diff --git a/frontend/src/pages/settings/MenuSettings.tsx b/frontend/src/pages/settings/MenuSettings.tsx index 74ea6dc..505bbbc 100644 --- a/frontend/src/pages/settings/MenuSettings.tsx +++ b/frontend/src/pages/settings/MenuSettings.tsx @@ -39,7 +39,7 @@ const BUILTIN_PAGES: NavEntry[] = [ { id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true }, { id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true }, { id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true }, - { id: '/financials', label: '财务', type: 'builtin', visible: true }, + { id: '/financials', label: '财务分析', type: 'builtin', visible: true }, { id: '/indices', label: '指数', type: 'builtin', visible: true }, { id: '/trading', label: '交易', type: 'builtin', visible: true }, { id: '/monitor', label: '监控中心', type: 'builtin', visible: true },