Merge pull request #13 from shy3130/feat/ai-financial-analysis

feat(financials): AI 财务分析 — 流式生成 + 全局胶囊 + 历史报告
This commit is contained in:
wshy
2026-06-25 22:40:15 +08:00
committed by GitHub
20 changed files with 1878 additions and 34 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
__version__ = "0.1.44"
__version__ = "0.1.45"
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
+85
View File
@@ -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}
+101
View File
@@ -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")
+209
View File
@@ -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)
+38 -4
View File
@@ -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
+15
View File
@@ -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})
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.44",
"version": "0.1.45",
"type": "module",
"scripts": {
"dev": "vite",
+5 -1
View File
@@ -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() {
</motion.main>
<ToastContainer />
<AlertToastContainer />
<AiAnalysisHost />
<AiReportBubble />
</div>
)
}
@@ -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<HTMLDivElement>(null)
const focusInputRef = useRef<HTMLInputElement>(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 (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* ===== 头部 ===== */}
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-purple-500/[0.06] via-fuchsia-500/[0.04] to-transparent">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 shrink-0">
{isHistory
? <History className="h-4.5 w-4.5 text-purple-300" />
: <Sparkles className="h-4.5 w-4.5 text-purple-300" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">
{isHistory ? '历史分析报告' : 'AI 财务分析'}
</span>
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
{meta?.summary ? (
<span className="flex items-center gap-1 truncate">
<Database className="h-2.5 w-2.5 shrink-0" />
<span className="truncate">{meta.summary}</span>
</span>
) : isWorking ? <span></span> : null}
{phase === 'streaming' && (
<span className="flex items-center gap-1 text-purple-300 shrink-0">
<span className="h-1.5 w-1.5 rounded-full bg-purple-400 animate-pulse" />
</span>
)}
{isHistory && task && 'created_at' in task && (
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
)}
</div>
</div>
{/* 右侧操作按钮 */}
<div className="flex items-center gap-1 shrink-0">
{/* 复制:仅在内容就绪且非生成中显示 */}
{content && !isWorking && (
<button onClick={handleCopy} title="复制全文"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
</button>
)}
{/* 生成中:仅最小化(后台继续生成),无关闭按钮 */}
{!isHistory && isWorking && (
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<Minimize2 className="h-4 w-4" />
</button>
)}
{/* 完成态/历史报告:显示关闭按钮 */}
{(!isWorking || isHistory) && (
<button onClick={closeDialog} title="关闭"
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* ===== 内容区 ===== */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
{/* 加载态 */}
{phase === 'loading' && !content && (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 flex items-center justify-center">
<Sparkles className="h-4.5 w-4.5 text-purple-300 animate-pulse" />
</div>
<Loader2 className="absolute -inset-1 h-12 w-12 text-purple-400/40 animate-spin" style={{ animationDuration: '3s' }} />
</div>
<div className="text-xs text-secondary">AI </div>
<div className="text-[10px] text-muted"> / / / ,</div>
</div>
)}
{/* 错误态 */}
{phase === 'error' && (
<div className="flex flex-col items-center justify-center py-14 gap-3">
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger" />
</div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
{error.includes('AI') && (
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
<Settings2 className="h-3.5 w-3.5" /> AI
</button>
)}
<button onClick={handleStartNew}
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-purple-500/15 border border-purple-400/30 text-xs text-purple-300 hover:bg-purple-500/20 transition-colors">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* 报告内容 */}
{(content || phase === 'streaming') && (
<div className="relative">
<MarkdownRenderer content={content} />
{phase === 'streaming' && (
<span className="inline-block w-1.5 h-3.5 bg-purple-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
)}
</div>
{/* ===== 底部:自定义关注点输入 ===== */}
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
<Wand2 className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
<input
ref={focusInputRef}
type="text"
value={focus}
onChange={e => 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 ? (
<button
onClick={handleStartNew}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 transition-all shrink-0"
title="以此关注点重新生成新报告"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={handleStartNew}
disabled={isWorking}
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-fuchsia-500/15 border border-purple-400/30 text-xs font-medium text-purple-300 hover:from-purple-500/30 hover:to-fuchsia-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
>
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
{phase === 'done' ? '重新分析' : '分析'}
</button>
)}
</div>
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
{isHistory
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
: '报告由项目已配置的 AI 模型基于本地财务数据生成;可在输入框追加关注点后重新生成。报告仅供参考,不构成投资建议。'}
</p>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)
}
// ===== 小工具 =====
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 '' }
}
@@ -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 <AiAnalysisDialog task={task} mode={mode} minimized={minimized} />
}
@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className="ai-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
style={{
width: `${BUBBLE_W}px`,
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
touchAction: 'none',
// 拖动时禁用过渡(通过 .dragging 类控制);静止时用 transition 让 resize/吸附有动画
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<AnimatePresence mode="popLayout">
{activeTasks.map((task, i) => (
<BubbleItem
key={task.id}
task={task}
isLast={i === activeTasks.length - 1}
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
/>
))}
</AnimatePresence>
{/* 内联样式:拖动时禁用过渡,确保 1:1 跟手 */}
<style>{`
.ai-bubble-root.dragging { transition: none !important; }
`}</style>
</div>
)
}
// ===== 单个胶囊卡片(紧凑玻璃拟态) =====
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 (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -8 }}
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
className={isLast ? '' : 'mb-1.5'}
>
<div
onPointerDown={onPointerDown}
role="button"
tabIndex={0}
title={isWorking ? '生成中,点击恢复对话框' : isError ? '分析失败,点击重试' : '点击查看报告'}
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
>
{/* 生成中:顶部进度流光 */}
{isWorking && (
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-purple-200 to-transparent animate-bubble-progress" />
</div>
)}
{/* 状态图标 */}
<span className="flex h-4 w-4 items-center justify-center shrink-0">
{isWorking ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isError ? (
<AlertCircle className="h-3 w-3" />
) : (
<Check className="h-3 w-3" />
)}
</span>
{/* 标的名(单行) */}
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
{task.name || task.symbol}
</span>
{/* 状态后缀 */}
<span className="shrink-0 text-[9px] leading-none">
{isWorking ? (
<span className="text-purple-300/80"></span>
) : isError ? (
<span className="text-red-300/80"></span>
) : (
<span className="text-emerald-300/80"></span>
)}
</span>
</div>
{/* 内联关键帧:进度条流动 */}
<style>{`
@keyframes bubble-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
.animate-bubble-progress { animation: bubble-progress 1.6s ease-in-out infinite; }
`}</style>
</motion.div>
)
}
// ===== 位置持久化 =====
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 */ }
}
@@ -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(<Fragment key={`${keyBase}-t-${i}`}>{text.slice(last, m.index)}</Fragment>)
if (m[1]) {
// 加粗
nodes.push(<strong key={`${keyBase}-b-${i}`} className="font-semibold text-foreground">{m[2]}</strong>)
} else if (m[3]) {
// 行内代码
nodes.push(
<code key={`${keyBase}-c-${i}`} className="px-1 py-0.5 rounded bg-elevated text-[0.85em] font-mono text-accent">
{m[4]}
</code>,
)
}
last = m.index + m[0].length
i++
}
if (last < text.length) nodes.push(<Fragment key={`${keyBase}-t-end`}>{text.slice(last)}</Fragment>)
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(<hr key={key++} className="my-3 border-border/40" />)
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(
<div key={key++} className={`${sizeCls} ${mtCls} mb-2 font-semibold text-foreground flex items-center gap-1.5`}>
{renderInline(text, `h-${key}`)}
</div>,
)
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(
<blockquote key={key++} className="my-2 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
{renderInline(quoteLines.join(' '), `q-${key}`)}
</blockquote>,
)
continue
}
// 表格
if (trimmed.startsWith('|')) {
const table = parseTable(lines, i)
if (table) {
const [header, ...body] = table.rows
const ncol = header.length
blocks.push(
<div key={key++} className="my-3 overflow-hidden rounded-btn border border-border/30">
<table className="w-full text-xs border-collapse table-fixed">
<colgroup>
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
<col className="w-auto" />
{Array.from({ length: ncol - 1 }).map((_, ci) => (
<col key={ci} className={ci === ncol - 2 ? 'w-1/2' : 'w-auto'} />
))}
</colgroup>
<thead>
<tr className="bg-elevated/50">
{header.map((cell, ci) => (
<th key={ci} className="px-2.5 py-1.5 text-left font-medium text-secondary border-b border-border/40 whitespace-nowrap">
{renderInline(cell, `th-${key}-${ci}`)}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, ri) => (
<tr key={ri} className="border-b border-border/20 last:border-0 hover:bg-elevated/20">
{row.map((cell, ci) => (
<td key={ci} className="px-2.5 py-1.5 text-foreground/90 align-top break-words">
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>,
)
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(
<ul key={key++} className="my-1.5 space-y-1">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
<span className="mt-1.5 h-1 w-1 rounded-full bg-accent/60 shrink-0" />
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
</li>
))}
</ul>,
)
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(
<ol key={key++} className="my-1.5 space-y-1">
{items.map((item, ii) => (
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
{ii + 1}
</span>
<span className="flex-1">{renderInline(item, `ol-${key}-${ii}`)}</span>
</li>
))}
</ol>,
)
continue
}
// 普通段落
blocks.push(
<p key={key++} className="my-1.5 text-xs text-foreground/90 leading-relaxed">
{renderInline(trimmed, `p-${key}`)}
</p>,
)
i++
}
return <div className="space-y-0">{blocks}</div>
}
@@ -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 (
<div className="rounded-card border border-border/40 bg-surface px-4 py-6 text-center">
<Loader2 />
</div>
)
}
if (reports.length === 0) {
return (
<div className="rounded-card border border-dashed border-border/50 bg-surface/50 px-6 py-8 text-center">
<History className="mx-auto h-6 w-6 text-muted/40" />
<div className="mt-2 text-xs text-muted"></div>
<div className="mt-0.5 text-[10px] text-muted/60">AI ,</div>
</div>
)
}
return (
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border/50 bg-elevated/20">
<div className="flex items-center gap-2">
<History className="h-3.5 w-3.5 text-secondary" />
<span className="text-xs font-medium text-foreground"></span>
<span className="text-[10px] text-muted">{reports.length}/20</span>
</div>
<span className="text-[10px] text-muted/60"> · 20 </span>
</div>
{/* 列表 */}
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
{reports.map(r => {
const isGenerating = activeSymbols.has(r.symbol)
return (
<div
key={r.id}
className="group flex items-center gap-3 px-4 py-2.5 hover:bg-elevated/30 transition-colors cursor-pointer"
onClick={() => openHistoryReport(r.id)}
>
{/* 图标 */}
<div className={`flex h-8 w-8 items-center justify-center rounded-lg shrink-0 ${
isGenerating
? 'bg-purple-400/10 text-purple-300'
: 'bg-elevated text-secondary group-hover:text-accent'
}`}>
{isGenerating
? <Sparkles className="h-3.5 w-3.5 animate-pulse" />
: <FileText className="h-3.5 w-3.5" />}
</div>
{/* 主信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
{r.focus && (
<span className="hidden sm:inline-block px-1.5 py-px rounded bg-purple-400/10 text-purple-300 text-[9px] shrink-0">
{r.focus}
</span>
)}
</div>
{/* 摘要 */}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted/70 flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
{fmtRelative(r.created_at)}
</span>
{r.summary && (
<span className="text-[10px] text-muted/50 truncate">{r.summary}</span>
)}
</div>
</div>
{/* 删除按钮 */}
<button
onClick={e => { e.stopPropagation(); deleteReport(r.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-danger/10 text-muted hover:text-danger transition-all shrink-0"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
</div>
)
}
// ===== 小工具 =====
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 '' }
}
@@ -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<TabKey>('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 (
<div className="relative rounded-card border border-border bg-surface overflow-hidden">
<div className="rounded-card border border-border bg-surface overflow-hidden">
{/* 头部:标的 + 报告期 */}
<div className="px-5 py-4 border-b border-border flex items-center gap-3 flex-wrap">
<div className="flex items-baseline gap-2 min-w-0">
@@ -151,6 +176,15 @@ export function StockFinancialDetail({ symbol, name }: Props) {
<span className="text-xs font-mono text-muted">{symbol}</span>
</div>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={handleAiClick}
disabled={checking}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 hover:border-purple-400/40 transition-all shrink-0 disabled:opacity-50"
title="AI 财务分析"
>
{checking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
AI
</button>
{latestPeriod && (
<div className="flex items-center gap-1.5 text-xs text-secondary">
<CalendarDays className="h-3.5 w-3.5" />
@@ -160,14 +194,6 @@ export function StockFinancialDetail({ symbol, name }: Props) {
)}
</div>
)}
<button
onClick={handleAiAnalysis}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-btn text-[11px] font-medium border border-purple-400/30 bg-purple-400/10 text-purple-300 hover:bg-purple-400/20 transition-colors shrink-0"
title="AI 财务分析(开发中)"
>
<Sparkles className="h-3 w-3" />
AI
</button>
</div>
</div>
@@ -241,12 +267,78 @@ export function StockFinancialDetail({ symbol, name }: Props) {
)}
</div>
{/* AI 分析开发中提示 */}
{showDevToast && (
<div className="absolute top-16 right-6 z-50 rounded-btn border border-purple-400/40 bg-purple-400/15 px-3 py-2 text-xs text-purple-200 shadow-lg backdrop-blur-sm animate-pulse">
AI
</div>
)}
{/* AI 分析二次确认:已有该标的历史报告 */}
<AnimatePresence>
{confirmReport && (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setConfirmReport(null)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative w-[90vw] max-w-[400px] rounded-card border border-border bg-base shadow-2xl p-6"
>
<div className="flex items-start gap-3">
<div className="shrink-0 h-10 w-10 rounded-full bg-purple-400/12 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-purple-300" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground mb-1.5"></h3>
<p className="text-xs text-secondary leading-relaxed">
<span className="font-medium text-foreground">{name}</span>
<span className="font-mono text-muted"> {symbol}</span>
<span className="text-purple-300 font-medium"> {fmtReportTime(confirmReport.created_at)} </span>
AI
</p>
<p className="mt-2 text-[11px] text-muted">
,
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button
onClick={() => setConfirmReport(null)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-xs transition-colors"
>
</button>
<button
onClick={() => { if (confirmReport) openHistoryReport(confirmReport.id); setConfirmReport(null) }}
className="px-3 py-1.5 rounded-btn border border-border text-secondary hover:text-foreground text-xs font-medium transition-colors"
>
</button>
<button
onClick={() => { doAnalysis(); setConfirmReport(null) }}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-purple-500/80 to-fuchsia-500/80 text-white text-xs font-medium hover:from-purple-500 hover:to-fuchsia-500 transition-all"
>
<Sparkles className="h-3.5 w-3.5" />
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
)
}
// 历史报告时间友好显示
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 '' }
}
+332
View File
@@ -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:<id>'(查看历史报告)
// - 或 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<ActiveTask>) {
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<void> {
try {
const res = await api.financialReportsList()
history = res.reports ?? []
historyLoaded = true
rebuildSnap()
emit()
} catch {
// 静默失败,列表会显示空
}
}
/**
* 查询某只股票最近一次的历史分析报告(用于二次确认提示)。
* 若历史未加载,先触发拉取。
* @returns 最近一条报告,或 null
*/
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
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<void> {
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()
}
+86
View File
@@ -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'),
+1 -1
View File
@@ -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() {
+7 -3
View File
@@ -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 (
<>
<PageHeader title="财务" subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert" />
<PageHeader title="财务分析" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert" />
<div className="px-8 py-10">
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
@@ -124,8 +125,8 @@ export function Financials() {
return (
<>
<PageHeader
title="财务"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 · Expert"
title="财务分析"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert"
right={
<div className="flex items-center gap-2">
{syncing && (
@@ -287,6 +288,9 @@ export function Financials() {
/>
)}
</div>
{/* AI 历史分析报告 */}
{available && <ReportHistoryPanel />}
</>
)}
</div>
+1 -1
View File
@@ -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 },