From d0c19782d3196a81a44c3f2cbab2a5db264a5af3 Mon Sep 17 00:00:00 2001 From: Beck Street's Bug Catcher Date: Wed, 12 Aug 2026 21:45:14 +0800 Subject: [PATCH 1/2] feat(WS-133): backend role-chain report contract Add tradingagents/utils/role_chain.py mapping the multi-agent graph final_state into a structured RoleChainReport (Risk Judge decision pinned -> Analyst Team -> Research Debate -> Trader -> Risk Debate -> summary). Integrate into report_formatter so /api/reports list/detail now serve a role_chain field (preview: decision+analysts+trading_decision label; detail: full chain). Persist role_chain into final_state at analysis completion. Heuristic priceBand/confidence/horizon degrade to null; risk-debate keys normalize fork risky/safe and upstream aggressive/ conservative. Includes docs/report-role-chain-contract.md. Verified: py_compile + sample-data end-to-end on both preview and detail. Co-authored-by: multica-agent --- docs/report-role-chain-contract.md | 62 +++++ tradingagents/utils/role_chain.py | 308 +++++++++++++++++++++++ web/backend/analysis_task.py | 16 ++ web/backend/services/report_formatter.py | 44 +++- 4 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 docs/report-role-chain-contract.md create mode 100644 tradingagents/utils/role_chain.py diff --git a/docs/report-role-chain-contract.md b/docs/report-role-chain-contract.md new file mode 100644 index 0000000..6042eb3 --- /dev/null +++ b/docs/report-role-chain-contract.md @@ -0,0 +1,62 @@ +# Report Role-Chain Contract + +The report API (`GET /api/reports/{id}`) returns a structured `role_chain` +field that the frontend report page renders as a multi-agent chain. This is the +authoritative backend contract, mirrored 1:1 by the frontend TypeScript types. + +## Source + +Built by `tradingagents/utils/role_chain.py` from the TradingAgents graph +`final_state`, persisted into `final_state.role_chain` at analysis completion, +and projected by `web/backend/services/report_formatter.py` onto the report API. + +## Chain order + +1. **Decision** (Risk Judge final verdict, pinned at top) +2. **Analyst Team** — Market / Social / News / Fundamentals +3. **Research Debate** — Bull / Bear / Research Manager +4. **Trading Plan** — Trader (non-executive research guidance) +5. **Risk Debate** — Risky / Safe / Neutral +6. **Summary** + +## Shape + +```jsonc +{ + "decision": { + "verdict": "buy", // strong_buy|buy|overweight|hold|reduce|watch + "verdictLabel": "买入", + "rationale": "...", // Risk Judge叙述 + "priceBand": { "low": 380, "high": 410, "currency": "HKD", "basis": "示例" }, // nullable + "riskLevel": "moderate", // low|moderate|elevated|high + "horizon": "1-3个月", // nullable + "confidence": 72 // 0-100, nullable + }, + "analysts": [ + { "role": "market", "code": "MKT", "title": "市场分析师", "stance": "positive", "summary": "...", "evidence": [], "hasContent": true } + // + social(SOC) / news(NEWS) / fundamentals(FND) + ], + "debate": { "bull": {…}, "bear": {…}, "manager": { "summary": "…" } }, + "traderPlan": { "verdict": "…", "verdictLabel": "…", "priceBand": {…}|null, "positionCapPct": null, "note": "研究建议,非下单执行入口", "hasContent": true }, + "riskDebate": { "risky": {…}, "safe": {…}, "neutral": {…} }, + "summary": "…", + "meta": { "sources": 3, "generatedAt": "…", "disclaimer": "…" } +} +``` + +## Frontend integration notes + +- `priceBand` / `confidence` / `horizon` are heuristic extractions and are + `null` when the underlying text is absent. Render a **"示例 / 延迟"** badge; + never invent numbers. +- `traderPlan.note` is the fixed string **"研究建议,非下单执行入口"**. The whole + site renders no order-execution entry. +- Risk-debate keys are normalized: the fork emits `risky`/`safe`/`neutral`; + upstream v0.2.5 `aggressive`/`conservative` are accepted on input and + normalized on output. +- When no agent content exists (early/partial analysis), `role_chain` is + omitted from the API response — fall back to the legacy `sections` view. +- `report_preview` (list/leaderboard cards) includes a trimmed + `role_chain: { decision, analysts }` plus a top-level `trading_decision` + label for card summaries. + diff --git a/tradingagents/utils/role_chain.py b/tradingagents/utils/role_chain.py new file mode 100644 index 0000000..f239bbe --- /dev/null +++ b/tradingagents/utils/role_chain.py @@ -0,0 +1,308 @@ +""" +Role-chain report builder. + +Maps the multi-agent TradingAgents graph final_state into the structured +RoleChainReport contract consumed by the frontend report page. + +Chain order (top -> bottom): + Risk Judge final decision (pinned) + -> Analyst Team (Market / Social / News / Fundamentals) + -> Research Debate (Bull / Bear / Research Manager) + -> Trading Plan (Trader, non-executive) + -> Risk Debate (Risky / Safe / Neutral) + -> Final summary + +Design rules (agreed with frontend, see docs/report-role-chain-contract.md): + * priceBand / confidence / horizon are heuristic extractions and MAY be None + when the underlying text is missing - frontend must show a "示例 / 延迟" + badge instead of inventing numbers. + * The Trader plan is research guidance only - note is a fixed string and the + whole site renders no order-execution entry. + * Risk-debate keys are normalized: upstream v0.2.5 uses aggressive / + conservative; this fork uses risky / safe. We accept both and emit a + canonical risky / safe / neutral shape. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, List, Optional + + +# Verdict mapping from raw BUY/SELL/HOLD (and Chinese variants) to the +# 5-level frontend vocabulary. final_trade_decision is a free-text string +# that the Risk Judge produces; we pattern-match it. +_VERDICT_RULES = [ + ("strong_buy", [r"strong\s*buy", r"强烈买入", r"大幅增持", r"重仓买入"]), + ("buy", [r"\bbuy\b", r"买入", r"增持", r"建仓"]), + ("overweight", [r"overweight", r"审慎增持", r"谨慎增持", r"适度增持"]), + ("reduce", [r"\bsell\b", r"卖出", r"减持", r"清仓"]), + ("watch", [r"\bhold\b", r"持有", r"观望", r"等待"]), +] + +VERDICT_LABELS = { + "strong_buy": "强势买入", + "buy": "买入", + "overweight": "审慎增持", + "hold": "持有", + "reduce": "减持", + "watch": "观望", +} + +STANCE_LABELS = { + "positive": "积极", + "warm": "偏多", + "neutral": "中性", + "cooling": "偏空", + "negative": "看空", +} + +RISK_LEVELS = {"low", "moderate", "elevated", "high"} + +TRADER_NOTE = "研究建议,非下单执行入口" + + +def _clip(text, limit=1600): + value = str(text or "").strip() + value = re.sub(r"\n{3,}", "\n\n", value) + return value[:limit] + + +def _to_str(text): + return _clip(text, 6000) + + +def _verdict_from_text(text): + lowered = (text or "").lower() + for verdict, patterns in _VERDICT_RULES: + for pattern in patterns: + if re.search(pattern, lowered): + return verdict + return "hold" + + +def _stance_from_text(role, text): + """Heuristic stance guess from an analyst report body.""" + body = (text or "").lower() + score = 0 + if re.search(r"看涨|上涨|bullish|上行|走强|突破|增[长加]|利好|机会", body): + score += 2 + if re.search(r"看跌|下跌|bearish|下行|走弱|跌破|下滑|利空|风险", body): + score -= 2 + if re.search(r"超买|高估|压力位|回调", body): + score -= 1 + if re.search(r"超卖|低估|支撑位|反弹|回升", body): + score += 1 + if score >= 2: + return "positive" + if score == 1: + return "warm" + if score <= -2: + return "negative" + if score == -1: + return "cooling" + return "neutral" + + +def _first_lines(text, n=2): + return [line.strip("-*• \t") for line in (text or "").splitlines() if line.strip()][:n] + + +def _price_band(text, market): + """Best-effort price-range extraction. Returns None when nothing parses.""" + if not text: + return None + currency = {"US": "USD", "HK": "HKD", "CN": "CNY"}.get((market or "").upper(), "USD") + pattern = re.compile( + r"(?:HK\$|¥|\$)?\s*([0-9]+(?:[.,][0-9]+)?)\s*(?:[-–—~]|到|至|to)\s*(?:HK\$|¥|\$)?\s*([0-9]+(?:[.,][0-9]+)?)" + ) + for match in pattern.finditer(text): + low = float(match.group(1).replace(",", "")) + high = float(match.group(2).replace(",", "")) + if 0 < low < high < low * 5: + return {"low": low, "high": high, "currency": currency, "basis": "示例"} + return None + + +def _confidence_from_text(text): + if not text: + return None + for pattern in (r"置信度[^\d]{0,6}([0-9]{1,3})", r"confidence[^\d]{0,6}([0-9]{1,3})", r"(\b[7-9][0-9])\s*%"): + match = re.search(pattern, text, re.IGNORECASE) + if match: + value = int(match.group(1)) + if 0 <= value <= 100: + return value + return None + + +def _horizon_from_text(text): + if not text: + return None + for pattern in ( + r"(1\s*[-—~]\s*3\s*个?\s*月)", + r"(3\s*[-—~]\s*6\s*个?\s*月)", + r"(6\s*[-—~]\s*12\s*个?\s*月)", + r"(短[期线])", + r"(中[期线])", + r"(长[期线])", + ): + match = re.search(pattern, text) + if match: + return match.group(1) + return None + + +def _risk_level_from_text(text): + body = (text or "") + if re.search(r"高风险|极高|aggressive|risky|波动剧烈", body, re.IGNORECASE): + return "high" + if re.search(r"偏高|elevated|较大波动", body, re.IGNORECASE): + return "elevated" + if re.search(r"较低|low risk|稳健", body, re.IGNORECASE): + return "low" + return "moderate" + + +def _as_dict(value): + return value if isinstance(value, dict) else {} + + +_ANALYST_DEFS = [ + ("market", "MKT", "市场分析师", "Market Analyst", "market_report"), + ("social", "SOC", "舆情分析师", "Social Media Analyst", "sentiment_report"), + ("news", "NEWS", "新闻分析师", "News Analyst", "news_report"), + ("fundamentals", "FND", "基本面分析师", "Fundamentals Analyst", "fundamentals_report"), +] + + +def _build_analysts(final_state): + out = [] + for role, code, zh, _en, key in _ANALYST_DEFS: + body = _to_str(final_state.get(key, "")) + out.append({ + "role": role, + "code": code, + "title": zh, + "subtitle": "", + "stance": _stance_from_text(role, body), + "summary": _clip(body, 400) or "暂无足够数据,待后续复核。", + "evidence": _first_lines(body, 3), + "hasContent": bool(body), + }) + return out + + +def _debate_side(history, current): + """Pick the most informative text for one side of a debate.""" + candidates = [current, history] + for candidate in candidates: + text = _to_str(candidate) + if text: + headline = _first_lines(text, 1)[0] if _first_lines(text, 1) else "" + return {"headline": headline, "summary": _clip(text, 800)} + return {"headline": "暂无发言", "summary": ""} + + +def _build_debate(final_state): + debate = _as_dict(final_state.get("investment_debate_state")) + bull = _debate_side(debate.get("bull_history"), debate.get("current_response") if debate.get("latest_speaker") == "bull" else None) + bear = _debate_side(debate.get("bear_history"), None) + manager_text = _to_str(debate.get("judge_decision")) + return { + "bull": bull, + "bear": bear, + "manager": {"summary": _clip(manager_text, 800) or "研究经理尚未给出裁决。"}, + } + + +def _build_trader(final_state, market, decision_text): + plan = _to_str(final_state.get("trader_investment_plan") or final_state.get("investment_plan")) + verdict = _verdict_from_text(plan or decision_text) + return { + "verdict": verdict, + "verdictLabel": VERDICT_LABELS.get(verdict, "持有"), + "priceBand": _price_band(plan, market), + "positionCapPct": None, + "note": TRADER_NOTE, + "hasContent": bool(plan), + "summary": _clip(plan, 800), + } + + +def _build_risk_debate(final_state): + debate = _as_dict(final_state.get("risk_debate_state")) + risky_text = _to_str(debate.get("current_risky_response") or debate.get("risky_history") or debate.get("current_aggressive_response") or debate.get("aggressive_history")) + safe_text = _to_str(debate.get("current_safe_response") or debate.get("safe_history") or debate.get("current_conservative_response") or debate.get("conservative_history")) + neutral_text = _to_str(debate.get("current_neutral_response") or debate.get("neutral_history")) + return { + "risky": _debate_side(risky_text, None), + "safe": _debate_side(safe_text, None), + "neutral": _debate_side(neutral_text, None), + } + + +def _build_decision(final_state, market): + decision_text = _to_str(final_state.get("final_trade_decision")) + risk_state = _as_dict(final_state.get("risk_debate_state")) + risk_text = _to_str(risk_state.get("judge_decision")) + verdict = _verdict_from_text(decision_text) + pool = decision_text + "\n" + risk_text + return { + "verdict": verdict, + "verdictLabel": VERDICT_LABELS.get(verdict, "持有"), + "rationale": _clip(decision_text, 1000) or "裁决尚未产出。", + "priceBand": _price_band(pool, market), + "riskLevel": _risk_level_from_text(pool), + "horizon": _horizon_from_text(pool), + "confidence": _confidence_from_text(pool), + } + + +def build_role_chain(final_state, *, ticker="", company="", market=None, + published_at="", model_id="", summary=""): + """Build the full RoleChainReport dict from a graph final_state. + + final_state may be {}; every node degrades gracefully so partial analyses + still render without crashing. + """ + final_state = _as_dict(final_state) + decision = _build_decision(final_state, market) + return { + "id": "", + "ticker": ticker, + "company": company or ticker, + "market": market, + "title": (company or ticker) + " 多智能体研究报告", + "publishedAt": published_at, + "author": {"name": "TradingAgents 多智能体"}, + "modelId": model_id, + "depth": "standard", + "decision": decision, + "analysts": _build_analysts(final_state), + "debate": _build_debate(final_state), + "traderPlan": _build_trader(final_state, market, decision.get("rationale", "")), + "riskDebate": _build_risk_debate(final_state), + "summary": _clip(summary or decision.get("rationale", ""), 1200), + "meta": { + "sources": len(final_state.get("grounded_evidence") or []) if isinstance(final_state.get("grounded_evidence"), list) else 0, + "generatedAt": published_at, + "disclaimer": "本报告由 AI 多智能体生成,所有行情与价格均为示例或延迟数据,仅供研究参考,不构成任何投资建议或下单执行入口。", + }, + } + + +def role_chain_is_empty(report): + """True when the role chain carries no real agent content (only scaffolding).""" + if not report: + return True + analysts = report.get("analysts") or [] + if any(a.get("hasContent") for a in analysts): + return False + mgr = (report.get("debate", {}).get("manager", {}).get("summary", "") or "").strip() + if mgr and mgr != "研究经理尚未给出裁决。": + return False + if report.get("traderPlan", {}).get("hasContent"): + return False + return True + diff --git a/web/backend/analysis_task.py b/web/backend/analysis_task.py index 5a8750e..bd52199 100644 --- a/web/backend/analysis_task.py +++ b/web/backend/analysis_task.py @@ -1107,6 +1107,22 @@ def stream_reader(): "structured_report": structured_report, } + # Build the role-chain view once and persist it alongside the structured + # report so the report API can serve it without re-running the builder. + try: + from tradingagents.utils.role_chain import build_role_chain + final_state["role_chain"] = build_role_chain( + report_sections, + ticker=ticker, + company=company_of_interest, + market=request_data.get("market"), + published_at=now_beijing.isoformat(), + model_id=request_data.get("deep_thinker") or request_data.get("shallow_thinker"), + summary=structured_report.get("summary") or str(decision), + ) + except Exception as role_chain_err: # pragma: no cover - never block persistence + print(f"⚠️ role_chain 构建失败(不影响分析结果): {role_chain_err}") + # 保存状态到文件(按用户、股票代码和分析ID分开,避免覆盖) user_ticker_dir = safe_join( "eval_results", diff --git a/web/backend/services/report_formatter.py b/web/backend/services/report_formatter.py index 556d3fe..1e8f0da 100644 --- a/web/backend/services/report_formatter.py +++ b/web/backend/services/report_formatter.py @@ -6,6 +6,12 @@ from datetime import datetime from typing import Any, Dict, List +try: # role_chain lives in the tradingagents package; keep formatter import-safe + from tradingagents.utils.role_chain import build_role_chain, role_chain_is_empty +except Exception: # pragma: no cover - import guard for environments without the package + build_role_chain = None # type: ignore + role_chain_is_empty = None # type: ignore + RATING_LABELS = { 1: "高风险", @@ -74,12 +80,37 @@ def _section_list(record: Any) -> List[Dict[str, Any]]: def report_id(record: Any) -> str: return record.analysis_id +def _role_chain(record: Any, source_session_id: str | None = None) -> Dict[str, Any] | None: + """Build the structured role-chain view from the stored final_state. + + Returns None when the role-chain module is unavailable or the analysis + produced no agent content yet (so the frontend can fall back to the legacy + section view without rendering empty scaffolding). + """ + if build_role_chain is None: + return None + final_state = _final_state(record) + chain = build_role_chain( + final_state, + ticker=record.ticker, + company=record.company_name or record.ticker, + market=record.market, + published_at=_iso(record.created_at), + model_id=getattr(record, "deep_thinker", "") or getattr(record, "shallow_thinker", ""), + summary=record.final_summary or record.trading_decision or "", + ) + chain["id"] = report_id(record) + chain["source"] = {"type": "conversation" if source_session_id else "scheduled_task", "session_id": source_session_id} + if role_chain_is_empty and role_chain_is_empty(chain): + return None + return chain + def report_preview(record: Any, source_session_id: str | None = None) -> Dict[str, Any]: structured = _structured(record) rating = int(structured.get("rating") or 3) sections = structured.get("sections") if isinstance(structured.get("sections"), dict) else {} - return { + preview = { "id": report_id(record), "ticker": record.ticker, "company_name": record.company_name or record.ticker, @@ -95,13 +126,18 @@ def report_preview(record: Any, source_session_id: str | None = None) -> Dict[st "status": _status(record.status), "created_at": _iso(record.created_at), } + chain = _role_chain(record, source_session_id) + if chain: + preview["role_chain"] = {"decision": chain.get("decision"), "analysts": chain.get("analysts")} + preview["trading_decision"] = chain.get("decision", {}).get("verdictLabel") + return preview def report_detail(record: Any, source_session_id: str | None = None, task_id: int | None = None) -> Dict[str, Any]: structured = _structured(record) rating = int(structured.get("rating") or 3) reflection = structured.get("reflection") if isinstance(structured.get("reflection"), dict) else {} - return { + detail = { "id": report_id(record), "ticker": record.ticker, "company_name": record.company_name or record.ticker, @@ -128,6 +164,10 @@ def report_detail(record: Any, source_session_id: str | None = None, task_id: in "created_at": _iso(record.created_at), "updated_at": _iso(record.updated_at), } + chain = _role_chain(record, source_session_id) + if chain: + detail["role_chain"] = chain + return detail def report_markdown(record: Any) -> str: From 11f7e346f8999240fe7f25a6abe4e7d5464ad0fd Mon Sep 17 00:00:00 2001 From: Beck Street's Bug Catcher Date: Wed, 12 Aug 2026 22:11:40 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(WS-133):=20dark-finance=20frontend=20r?= =?UTF-8?q?ebuild=20=E2=80=94=20search-first=20design=20+=20role-chain=20r?= =?UTF-8?q?eport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the frontend around the approved dark-finance research design: Design system - Evolve Tailwind palette to deep-finance tokens (#060a10 base, mint #9ee5c9 / gold #e5bd72 / red #f48b8b / blue #81bbed) + verdict semantic group; add Noto Serif SC headings, tabular mono numbers, verdict pills, surface/hero/chain-rail utilities. New search-first app shell - SiteHeader (research / 公开榜单 / 订阅 / 本地模型 / 管理控制台 nav, auth-aware), SiteFooter with global non-execution disclaimer, reusable SearchBar, SiteLayout wrapper. Pages (all routed per the approved 11-page design) - / search-first home with hero search, features, recent public research - /research launch a multi-agent analysis from a query - /leaderboard public research feed with market filters - /report?id= full role-chain report view (decision pinned -> analysts -> debate -> trader -> risk debate -> summary), MD/PDF export, legacy fallback - /subscription per-analysis plans + billing notes - /settings local LLM key vault (browser-only, reuses useLocalLLMKeys) - /me, /me/billing, /me/preferences user workspace - auth via existing /login /register /auth (auto-reskinned) Report contract wiring - types/report.ts mirrors the backend RoleChainReport 1:1 - lib/api/reports.ts report list/public-feed/detail client consuming /api/reports role_chain field Verification - tsc --noEmit exit 0 (also fixed a pre-existing refetch typecheck in scheduled-tasks) - next build compiled successfully; all new routes prerendered as static content under output: export Co-authored-by: multica-agent --- web/frontend/src/app/globals.css | 115 ++++++++++-- web/frontend/src/app/layout.tsx | 10 +- web/frontend/src/app/leaderboard/page.tsx | 57 ++++++ web/frontend/src/app/me/billing/page.tsx | 61 +++++++ web/frontend/src/app/me/page.tsx | 79 ++++++++ web/frontend/src/app/me/preferences/page.tsx | 72 ++++++++ web/frontend/src/app/page.tsx | 123 +++++++++++-- web/frontend/src/app/report/page.tsx | 87 +++++++++ web/frontend/src/app/research/page.tsx | 91 ++++++++++ web/frontend/src/app/scheduled-tasks/page.tsx | 2 +- web/frontend/src/app/settings/page.tsx | 89 +++++++++ web/frontend/src/app/subscription/page.tsx | 61 +++++++ .../src/components/report/RoleChainReport.tsx | 169 ++++++++++++++++++ .../src/components/site/DisclaimerBanner.tsx | 9 + .../src/components/site/SearchBar.tsx | 63 +++++++ .../src/components/site/SiteFooter.tsx | 28 +++ .../src/components/site/SiteHeader.tsx | 110 ++++++++++++ .../src/components/site/SiteLayout.tsx | 14 ++ web/frontend/src/lib/api/reports.ts | 66 +++++++ web/frontend/src/types/report.ts | 133 ++++++++++++++ web/frontend/tailwind.config.js | 43 +++-- 21 files changed, 1433 insertions(+), 49 deletions(-) create mode 100644 web/frontend/src/app/leaderboard/page.tsx create mode 100644 web/frontend/src/app/me/billing/page.tsx create mode 100644 web/frontend/src/app/me/page.tsx create mode 100644 web/frontend/src/app/me/preferences/page.tsx create mode 100644 web/frontend/src/app/report/page.tsx create mode 100644 web/frontend/src/app/research/page.tsx create mode 100644 web/frontend/src/app/settings/page.tsx create mode 100644 web/frontend/src/app/subscription/page.tsx create mode 100644 web/frontend/src/components/report/RoleChainReport.tsx create mode 100644 web/frontend/src/components/site/DisclaimerBanner.tsx create mode 100644 web/frontend/src/components/site/SearchBar.tsx create mode 100644 web/frontend/src/components/site/SiteFooter.tsx create mode 100644 web/frontend/src/components/site/SiteHeader.tsx create mode 100644 web/frontend/src/components/site/SiteLayout.tsx create mode 100644 web/frontend/src/lib/api/reports.ts create mode 100644 web/frontend/src/types/report.ts diff --git a/web/frontend/src/app/globals.css b/web/frontend/src/app/globals.css index 78f5e9e..bbc3589 100644 --- a/web/frontend/src/app/globals.css +++ b/web/frontend/src/app/globals.css @@ -7,17 +7,22 @@ box-sizing: border-box; } - :root { - /* Workflow Desk CSS Variables — kept in sync with tailwind.config.js */ - --bg-primary: #0a0d12; /* ink */ - --bg-secondary: #171f2b; /* surface */ - --bg-tertiary: #202b39; /* raised */ - --bg-elevated: #27333f; /* elevated */ - --bg-input: #0d131b; /* recessed inputs */ - --bg-rail: #111720; /* research rail */ - --border-default: #304154; /* structural line */ - --accent-primary: #9bffbe; /* mint */ - --accent-secondary: #8acbff;/* sky blue */ + :root { + /* Dark-finance CSS Variables — kept in sync with tailwind.config.js */ + --bg-primary: #060a10; /* ink — deep finance base */ + --bg-secondary: #0e1620; /* surface */ + --bg-tertiary: #16202d; /* raised */ + --bg-elevated: #1d2937; /* elevated */ + --bg-input: #0a1119; /* recessed inputs */ + --bg-rail: #0b131c; /* side rail */ + --bg-hover: #1a2636; /* generic hover */ + --border-default: #243243; /* structural line */ + --accent-primary: #9ee5c9; /* mint — bull / primary action */ + --accent-secondary: #81bbed;/* sky blue — safe / info */ + --verdict-bull: #9ee5c9; /* 看多 */ + --verdict-hold: #e5bd72; /* 持有 */ + --verdict-bear: #f48b8b; /* 看空 */ + --verdict-safe: #81bbed; /* 稳健 */ --text-primary: #f1f5f7; --text-secondary: #9aa9b8; --text-tertiary: #68798a; @@ -316,6 +321,92 @@ animation: fade-in 0.5s ease-out; } +/* ===== Dark-finance research design system ===== */ + +/* Surface helpers */ +.surface-panel { + @apply bg-dark-secondary border border-dark-border rounded-2xl; +} +.surface-card { + @apply bg-dark-secondary border border-dark-border rounded-xl transition-colors; +} +.surface-card:hover { + @apply border-dark-hover; +} + +/* Verdict pill — semantic color from stance/verdict */ +.verdict-pill { + @apply inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium font-mono; +} +.verdict-bull { color: var(--verdict-bull); background: rgba(158, 229, 201, 0.10); border: 1px solid rgba(158, 229, 201, 0.28); } +.verdict-hold { color: var(--verdict-hold); background: rgba(229, 189, 114, 0.10); border: 1px solid rgba(229, 189, 114, 0.28); } +.verdict-bear { color: var(--verdict-bear); background: rgba(244, 139, 139, 0.10); border: 1px solid rgba(244, 139, 139, 0.28); } +.verdict-safe { color: var(--verdict-safe); background: rgba(129, 187, 237, 0.10); border: 1px solid rgba(129, 187, 237, 0.28); } +.verdict-neutral{ color: var(--text-secondary); background: rgba(154, 169, 184, 0.08); border: 1px solid rgba(154, 169, 184, 0.22); } + +/* "示例 / 延迟" badge for non-actionable data */ +.data-sample-badge { + @apply inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium; + color: var(--text-tertiary); + background: rgba(154, 169, 184, 0.08); + border: 1px solid rgba(154, 169, 184, 0.18); +} + +/* Headline (Chinese serif) */ +.h-serif { + @apply font-heading font-semibold tracking-tight; + color: var(--text-primary); +} + +/* Tabular numbers for prices / confidence */ +.num { + @apply font-mono tabular-nums; +} + +/* Primary action button */ +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all; + background: var(--accent-primary); + color: #04130d; +} +.btn-primary:hover { filter: brightness(1.06); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-ghost { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors; + color: var(--text-secondary); + border: 1px solid var(--border-default); + background: transparent; +} +.btn-ghost:hover { color: var(--text-primary); border-color: var(--bg-hover); background: var(--bg-tertiary); } + +/* Search-first home hero */ +.hero-search { + background: + radial-gradient(900px 360px at 50% -10%, rgba(158, 229, 201, 0.10), transparent 70%), + radial-gradient(700px 300px at 80% 10%, rgba(129, 187, 237, 0.08), transparent 70%); +} + +/* Role-chain connector line */ +.chain-rail { + position: relative; +} +.chain-rail::before { + content: ""; + position: absolute; + left: 1.05rem; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(to bottom, var(--border-default), transparent); +} + +/* Global non-execution disclaimer strip */ +.disclaimer-strip { + @apply text-[11px]; + color: var(--text-tertiary); +} + /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { *, @@ -325,4 +416,4 @@ animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } -} \ No newline at end of file +} diff --git a/web/frontend/src/app/layout.tsx b/web/frontend/src/app/layout.tsx index 68aea04..bb299a7 100644 --- a/web/frontend/src/app/layout.tsx +++ b/web/frontend/src/app/layout.tsx @@ -3,8 +3,8 @@ import './globals.css'; import { Providers } from './providers'; export const metadata: Metadata = { - title: 'TradingAgents · Workflow Desk', - description: '基于 TradingAgents 多智能体研究图的现代化分析工作台', + title: 'TradingAgents · 多智能体股票研究', + description: 'AI 多智能体驱动的美股 / 港股 / A 股研究报告平台 — 搜索、分析、公开研究榜单', }; export default function RootLayout({ @@ -16,7 +16,7 @@ export default function RootLayout({ - + @@ -26,7 +26,7 @@ export default function RootLayout({ {/* Font Awesome 6.4.0 — self-hosted under /lib/font-awesome. Previously a render-blocking external to cdnjs.cloudflare.com @@ -63,4 +63,4 @@ export default function RootLayout({ ); -} \ No newline at end of file +} diff --git a/web/frontend/src/app/leaderboard/page.tsx b/web/frontend/src/app/leaderboard/page.tsx new file mode 100644 index 0000000..ba3eca2 --- /dev/null +++ b/web/frontend/src/app/leaderboard/page.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { reportAPI } from '@/lib/api/reports'; +import { ResearchCard } from '@/app/page'; +import { SearchBar } from '@/components/site/SearchBar'; + +export default function LeaderboardPage() { + const [market, setMarket] = useState(''); + const { data, isLoading } = useQuery({ + queryKey: ['public-reports', market], + queryFn: () => reportAPI.list({ limit: 30, ...(market ? { market } : {}) }), + }); + + const reports = data?.data ?? []; + + return ( + +
+

公开研究榜单

+

社区公开的多智能体研究报告,点击卡片查看完整角色链。

+
+ +
+ {[{ k: '', l: '全部' }, { k: 'US', l: '美股' }, { k: 'HK', l: '港股' }, { k: 'CN', l: 'A股' }].map((m) => ( + + ))} +
+ + {isLoading ? ( +
+ 加载中… +
+ ) : reports.length === 0 ? ( +
+ +

暂无公开研究报告

+
+
+ ) : ( +
+ {reports.map((r) => )} +
+ )} +
+ ); +} diff --git a/web/frontend/src/app/me/billing/page.tsx b/web/frontend/src/app/me/billing/page.tsx new file mode 100644 index 0000000..13aeb89 --- /dev/null +++ b/web/frontend/src/app/me/billing/page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { SiteLayout } from '@/components/site/SiteLayout'; +import { MeNav } from '@/app/me/page'; + +// 示例数据:订阅计费表尚未在后端落地,先用示例展示形态。 +const SAMPLE_LEDGER = [ + { id: 1, type: 'purchase', label: '购买 50 次套餐', delta: 50, balance: 50, at: '2026-08-10 14:20' }, + { id: 2, type: 'consume', label: '腾讯控股 0700.HK 研究', delta: -1, balance: 49, at: '2026-08-11 09:05' }, + { id: 3, type: 'consume', label: '宁德时代 300750 研究', delta: -1, balance: 48, at: '2026-08-11 16:40' }, +]; + +export default function BillingPage() { + return ( + +
+
+

订阅明细

+

订阅次数的购买与消耗记录。

+
+ +
+ +
+ +
+
当前可用次数(示例)
+
48
+
+ 示例数据 +
+ +
+ + + + + + + + + + + {SAMPLE_LEDGER.map((row) => ( + + + + + + + ))} + +
时间明细变动余额
{row.at}{row.label} 0 ? 'text-verdict-bull' : 'text-verdict-bear'}`}> + {row.delta > 0 ? '+' : ''}{row.delta} + {row.balance}
+
+

计费明细为示例数据;后端订阅配额表(SubscriptionProduct / UserQuota / QuotaLedger)落地后将对接真实记录。

+
+ ); +} + diff --git a/web/frontend/src/app/me/page.tsx b/web/frontend/src/app/me/page.tsx new file mode 100644 index 0000000..250c240 --- /dev/null +++ b/web/frontend/src/app/me/page.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { reportAPI } from '@/lib/api/reports'; +import { ResearchCard } from '@/app/page'; +import { useAuth } from '@/lib/auth'; + +export default function MyAnalysesPage() { + const { user, isLoading } = useAuth(); + const { data } = useQuery({ + queryKey: ['my-reports'], + queryFn: () => reportAPI.list({ limit: 30 }), + enabled: !!user, + }); + + if (!isLoading && !user) { + return ( + +
+ +

登录后查看你的分析记录

+ 去登录 +
+
+ ); + } + + const reports = data?.data ?? []; + + return ( + +
+
+

我的分析

+

你发起的多智能体研究报告,可在此管理与公开。

+
+ +
+ + {reports.length === 0 ? ( +
+ +

还没有分析记录

+ 发起第一次研究 +
+ ) : ( +
+ {reports.map((r) => )} +
+ )} +
+ ); +} + +export function MeNav({ active }: { active: 'me' | 'billing' | 'preferences' }) { + const items = [ + { k: 'me', href: '/me', label: '我的分析' }, + { k: 'billing', href: '/me/billing', label: '订阅明细' }, + { k: 'preferences', href: '/me/preferences', label: '账户偏好' }, + ]; + return ( + + ); +} + diff --git a/web/frontend/src/app/me/preferences/page.tsx b/web/frontend/src/app/me/preferences/page.tsx new file mode 100644 index 0000000..58a045d --- /dev/null +++ b/web/frontend/src/app/me/preferences/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { SiteLayout } from '@/components/site/SiteLayout'; +import { MeNav } from '@/app/me/page'; +import { useAuth } from '@/lib/auth'; +import { useState } from 'react'; + +export default function PreferencesPage() { + const { user } = useAuth(); + const [publicDefault, setPublicDefault] = useState(false); + const [emailNotify, setEmailNotify] = useState(true); + + return ( + +
+
+

账户偏好

+

分析结果的默认公开与通知设置。

+
+ +
+ +
+ + {user?.email ?? '—'} + + + {user?.role === 'admin' ? '管理员' : '普通用户'} + + + + + + + + + 前往设置 → + +
+
+ ); +} + +function PrefRow({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { + return ( +
+
+
{title}
+
{desc}
+
+
{children}
+
+ ); +} + +function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + diff --git a/web/frontend/src/app/page.tsx b/web/frontend/src/app/page.tsx index e1405a8..ba93947 100644 --- a/web/frontend/src/app/page.tsx +++ b/web/frontend/src/app/page.tsx @@ -1,27 +1,116 @@ 'use client'; -import React from 'react'; -import { useAuth } from '@/lib/auth'; -import { ConversationProvider } from '@/lib/conversation-context'; -import { AppNavbar } from '@/components/common/AppNavbar'; -import { ConversationWorkbench } from '@/components/conversation/ConversationWorkbench'; -import { PageLoading } from '@/components/ui/PageLoading'; +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { SearchBar } from '@/components/site/SearchBar'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { reportAPI } from '@/lib/api/reports'; +import type { ReportPreview } from '@/types/report'; +import { VERDICT_PILL } from '@/types/report'; + +const FEATURES = [ + { icon: 'fa-users-gear', title: '多智能体协作', desc: '市场 / 舆情 / 新闻 / 基本面分析师 + 多空辩论 + 风险裁决,结构化产出。' }, + { icon: 'fa-magnifying-glass-chart', title: '搜索即研究', desc: '股票代码、公司名称或自然语言指令,一句话触发全维分析。' }, + { icon: 'fa-shield-halved', title: '研究非交易', desc: '给出建议区间与仓位参考,全站无任何下单执行入口。' }, + { icon: 'fa-globe', title: '美股 / 港股 / A 股', desc: '三大市场统一研究流程,多模型路由与按次订阅配额。' }, +]; export default function HomePage() { - const { user, logout, isLoading } = useAuth(); + const [recent, setRecent] = useState([]); + + useEffect(() => { + reportAPI.publicFeed(6).then((res) => setRecent(res.data)); + }, []); + + return ( + + {/* Hero search */} +
+
+
+ + AI 多智能体驱动的股票研究工作台 +
+

+ 用一句话,发起一次专业级股票研究 +

+

+ 输入股票代码、公司名称或研究指令,多智能体团队将从市场、舆情、新闻、基本面到风险裁决, + 给出结构化的研究结论与建议区间。 +

+
+ +
+

+ 示例 / 延迟数据 · 仅供研究参考 · 非投资建议 · 无下单执行入口 +

+
+
- if (isLoading) { - return ; - } + {/* Features */} +
+ {FEATURES.map((f) => ( +
+ +

{f.title}

+

{f.desc}

+
+ ))} +
+ {/* Recent public research */} +
+
+

最新公开研究

+ + 查看全部榜单 → + +
+ {recent.length === 0 ? ( +
+ +

暂无公开研究报告

+

完成分析后在「我的分析」中开启公开,即可上榜。

+
+ ) : ( +
+ {recent.map((r) => ( + + ))} +
+ )} +
+
+ ); +} + +export function ResearchCard({ report }: { report: ReportPreview }) { + const decision = report.role_chain?.decision; + const verdict = decision?.verdict; + const pill = verdict ? VERDICT_PILL[verdict] : 'verdict-neutral'; return ( -
- -
- - - + +
+
+
+ {report.ticker} + + {report.market ? ({ US: '美股', HK: '港股', CN: 'A股' } as Record)[report.market] ?? report.market : '—'} + +
+

{report.company_name}

+
+ + {report.trading_decision || decision?.verdictLabel || '待裁决'} + +
+

+ {report.summary || '暂无摘要'} +

+
+ {report.created_at ? new Date(report.created_at).toLocaleDateString('zh-CN') : '—'} + 示例 / 延迟
-
+ ); } diff --git a/web/frontend/src/app/report/page.tsx b/web/frontend/src/app/report/page.tsx new file mode 100644 index 0000000..976ef8a --- /dev/null +++ b/web/frontend/src/app/report/page.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; +import { RoleChainReportView } from '@/components/report/RoleChainReport'; +import { DisclaimerBanner } from '@/components/site/DisclaimerBanner'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { reportAPI } from '@/lib/api/reports'; + +function ReportInner() { + const params = useSearchParams(); + const id = params.get('id') ?? ''; + + const { data, isLoading, error } = useQuery({ + queryKey: ['report', id], + queryFn: () => reportAPI.get(id), + enabled: !!id, + }); + + if (!id) { + return ( + +
+ +

未指定报告

+ 浏览公开榜单 +
+
+ ); + } + + return ( + + {isLoading && ( +
+ 正在加载报告… +
+ )} + {error && !isLoading && ( +
+ +

报告不存在或暂不可见

+

该报告可能为私有,需登录其作者账号查看。

+ 返回公开榜单 +
+ )} + {data && !isLoading && ( +
+
+ ← 返回 +
+ Markdown + PDF +
+
+ + {data.role_chain ? ( + + ) : ( + + )} +
+ )} +
+ ); +} + +function LegacyReportView() { + return ( +
+ +

该报告尚无结构化角色链数据

+

可能是较早的分析或仍在进行中。新分析完成后将自动渲染完整多智能体角色链。

+
+ ); +} + +export default function ReportPage() { + return ( + + + + ); +} + diff --git a/web/frontend/src/app/research/page.tsx b/web/frontend/src/app/research/page.tsx new file mode 100644 index 0000000..31e63be --- /dev/null +++ b/web/frontend/src/app/research/page.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; +import { SearchBar } from '@/components/site/SearchBar'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { useAuth } from '@/lib/auth'; +import { analysisAPI } from '@/lib/apiClient'; +import { useState } from 'react'; + +function ResearchInner() { + const router = useRouter(); + const params = useSearchParams(); + const q = params.get('q') ?? ''; + const { user } = useAuth(); + const [launching, setLaunching] = useState(false); + const [error, setError] = useState(''); + + const launch = async () => { + if (!q) return; + if (!user) { + router.push('/auth?next=/research?q=' + encodeURIComponent(q)); + return; + } + setLaunching(true); + setError(''); + try { + const res = await analysisAPI.startAnalysis({ ticker: q, of_company: q }); + const aid = res?.analysis_id || res?.id; + if (aid) router.push('/me?analysis=' + aid); + else setError('已提交,请到「我的分析」查看进度。'); + } catch (e) { + setError(e instanceof Error ? e.message : '启动分析失败'); + } finally { + setLaunching(false); + } + }; + + return ( + +

发起研究

+

确认你的研究目标,多智能体团队将开始全维度分析。

+ +
+ +
+
研究目标
+
{q || '(请在上方输入)'}
+ +
+ {[ + { i: 'fa-chart-line', t: '市场 / 技术面' }, + { i: 'fa-comments', t: '舆情 / 情绪' }, + { i: 'fa-newspaper', t: '新闻 / 宏观' }, + { i: 'fa-table-list', t: '基本面 / 财务' }, + { i: 'fa-users-rays', t: '多空研究辩论' }, + { i: 'fa-shield-halved', t: '风险裁决' }, + ].map((s) => ( +
+ + {s.t} +
+ ))} +
+ + {!user && ( +

+ 保存分析结果与公开报告需要先登录。 +

+ )} + {error &&

{error}

} + +
+

研究建议 · 非下单执行 · 示例 / 延迟数据

+ +
+
+
+ ); +} + +export default function ResearchPage() { + return ( + + + + ); +} + diff --git a/web/frontend/src/app/scheduled-tasks/page.tsx b/web/frontend/src/app/scheduled-tasks/page.tsx index 7aafc24..1b2a14c 100644 --- a/web/frontend/src/app/scheduled-tasks/page.tsx +++ b/web/frontend/src/app/scheduled-tasks/page.tsx @@ -22,7 +22,7 @@ export default function ScheduledTasksPage() { const limit = 10; // 每页显示10条 const isMobile = useIsMobile(); - const { data: listData, isLoading, error } = useScheduledTasks(page, limit); + const { data: listData, isLoading, error, refetch } = useScheduledTasks(page, limit); const { data: statsData } = useScheduledTaskStats(); const deleteTask = useDeleteScheduledTask(); const updateTask = useUpdateScheduledTask(); diff --git a/web/frontend/src/app/settings/page.tsx b/web/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..c4db12e --- /dev/null +++ b/web/frontend/src/app/settings/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { useAuth } from '@/lib/auth'; +import { useLocalLLMKeys } from '@/hooks/useLocalLLMKeys'; + +const PROVIDERS = [ + { key: 'openai', label: 'OpenAI', url: 'https://api.openai.com/v1', models: 'gpt-4o / gpt-4o-mini' }, + { key: 'deepseek', label: 'DeepSeek', url: 'https://api.deepseek.com/v1', models: 'deepseek-chat / deepseek-reasoner' }, + { key: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', models: '多模型聚合' }, + { key: 'custom', label: '自定义兼容端点', url: '', models: '兼容 OpenAI 协议' }, +]; + +export default function SettingsPage() { + const { user } = useAuth(); + const { hasLocalKey, saveLocalKey, clearLocalKey } = useLocalLLMKeys(); + const [drafts, setDrafts] = useState>({}); + const [saved, setSaved] = useState(null); + + const setDraft = (k: string, v: string) => setDrafts((p) => ({ ...p, [k]: v })); + + const save = (k: string) => { + const val = drafts[k]?.trim(); + if (!val) return; + saveLocalKey(k, val); + setDrafts((p) => ({ ...p, [k]: '' })); + setSaved(k); + setTimeout(() => setSaved(null), 1800); + }; + + return ( + +

本地模型设置

+

+ 配置自定义 LLM 接口与密钥。这些信息仅保存在你的浏览器本地,服务端不存储、不传输持久化。 +

+ +
+ + + 本地 Key 仅写入浏览器 localStorage(按账户隔离),不会随分析请求持久化到服务端,也不会出现在公开报告中。 + 清除浏览器数据将一并清除。配置后,分析将优先使用你的本地模型。 + +
+ + {!user && ( +

+ 本地 Key 按账户隔离保存,登录后才会启用。 +

+ )} + +
+ {PROVIDERS.map((p) => { + const has = user ? hasLocalKey(p.key) : false; + return ( +
+
+
+

{p.label}

+

{p.url || '需填写 Base URL'} · {p.models}

+
+ {has && 已配置} +
+
+ setDraft(p.key, e.target.value)} + placeholder={has ? '••••••••(输入新值可替换)' : '粘贴 API Key'} + className="h-10 flex-1 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none" + /> + + {has && ( + + )} +
+
+ ); + })} +
+
+ ); +} + diff --git a/web/frontend/src/app/subscription/page.tsx b/web/frontend/src/app/subscription/page.tsx new file mode 100644 index 0000000..5c14f7d --- /dev/null +++ b/web/frontend/src/app/subscription/page.tsx @@ -0,0 +1,61 @@ +'use client'; + +import Link from 'next/link'; +import { SiteLayout } from '@/components/site/SiteLayout'; +import { useAuth } from '@/lib/auth'; + +const PLANS = [ + { credits: 10, price: '¥39', per: '¥3.9 / 次', tag: '' }, + { credits: 50, price: '¥169', per: '¥3.38 / 次', tag: '推荐' }, + { credits: 200, price: '¥599', per: '¥2.995 / 次', tag: '超值' }, +]; + +export default function SubscriptionPage() { + const { user } = useAuth(); + return ( + +

订阅中心

+

+ 按次订阅。没有配置本地模型 Key 时,可消耗订阅次数使用系统大模型完成研究分析。 +

+ +
+
+ +
+
当前可用次数(示例)
+
{user ? '—' : '登录后查看'}
+
+
+

每完成一次研究分析扣除 1 次,使用本地 Key 不扣次数。

+
+ +
+ {PLANS.map((plan) => ( +
+ {plan.tag && ( + {plan.tag} + )} +
{plan.credits} 次
+
{plan.price}
+
{plan.per}
+ +
+ ))} +
+ +
+

计费说明

+
    +
  • · 系统模型分析按完成报告扣除订阅次数,启动前预扣,失败自动回补。
  • +
  • · 配置并使用本地模型 Key 时,不消耗订阅次数。
  • +
  • · 所有行情与价格为示例 / 延迟数据,研究结论非投资建议。
  • +
+ {!user && ( + 登录后管理订阅 + )} +
+
+ ); +} + diff --git a/web/frontend/src/components/report/RoleChainReport.tsx b/web/frontend/src/components/report/RoleChainReport.tsx new file mode 100644 index 0000000..ec00587 --- /dev/null +++ b/web/frontend/src/components/report/RoleChainReport.tsx @@ -0,0 +1,169 @@ +import type { RoleChainReport, AnalystNode, DebateSide } from '@/types/report'; +import { VERDICT_PILL, STANCE_PILL, STANCE_LABEL, RISK_LABEL } from '@/types/report'; + +function VerdictPill({ verdict, label }: { verdict: string; label: string }) { + const cls = (VERDICT_PILL as Record)[verdict] ?? 'verdict-neutral'; + return {label}; +} + +function PriceBandView({ band, currency }: { band: { low: number; high: number; currency: string } | null; currency?: string | undefined }) { + if (!band) return 示例 / 延迟; + const sym = ({ USD: '$', HKD: 'HK$', CNY: '¥' } as Record)[band.currency || currency || 'USD'] || '$'; + return ( + + {sym}{band.low} {sym}{band.high} + + ); +} + +function AnalystCard({ a }: { a: AnalystNode }) { + if (!a.hasContent) { + return ( +
+
+ {a.code} + {STANCE_LABEL[a.stance]} +
+

{a.title}

+

暂无产出,待后续复核。

+
+ ); + } + return ( +
+
+ {a.code} + {STANCE_LABEL[a.stance]} +
+

{a.title}

+

{a.summary}

+
+ ); +} + +function DebateColumn({ title, side, tone }: { title: string; side: DebateSide; tone: 'bull' | 'bear' | 'safe' | 'neutral' }) { + const toneIcon = { bull: 'fa-arrow-trend-up', bear: 'fa-arrow-trend-down', safe: 'fa-shield', neutral: 'fa-minus' }[tone]; + return ( +
+
+ + {title} +
+

{side.headline}

+

{side.summary}

+
+ ); +} + +export function RoleChainReportView({ report }: { report: RoleChainReport }) { + const d = report.decision; + return ( +
+ {/* 0. Pinned decision */} +
+
+
+
+ Risk Judge 最终裁决 +
+

{report.company} {report.ticker}

+
+
+ + {d.confidence != null && ( + 置信度 {d.confidence}% + )} +
+
+

{d.rationale}

+
+ )[report.market] : undefined} /> + {RISK_LABEL[d.riskLevel]} + {d.horizon ? {d.horizon} : 未给出} + {report.meta.sources} 条 +
+
+ + {/* 1. Analyst team */} + +
+ {report.analysts.map((a) => )} +
+
+ + {/* 2. Research debate */} + +
+ + +
+
+ + 研究经理裁决 +
+

{report.debate.manager.summary}

+
+
+
+ + {/* 3. Trader plan */} + +
+
+ + )[report.market] : undefined} /> + {report.traderPlan.positionCapPct != null && ( + 仓位上限 {report.traderPlan.positionCapPct}% + )} +
+ {report.traderPlan.hasContent && ( +

{report.traderPlan.summary}

+ )} +

+ {report.traderPlan.note} +

+
+
+ + {/* 4. Risk debate */} + +
+ + + +
+
+ + {/* 5. Summary */} + {report.summary && ( + +

{report.summary}

+
+ )} + +

{report.meta.disclaimer}

+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function ChainSection({ index, title, subtitle, children }: { index: string; title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+ {index} +

{title}

+ {subtitle && · {subtitle}} +
+ {children} +
+ ); +} diff --git a/web/frontend/src/components/site/DisclaimerBanner.tsx b/web/frontend/src/components/site/DisclaimerBanner.tsx new file mode 100644 index 0000000..b5540a5 --- /dev/null +++ b/web/frontend/src/components/site/DisclaimerBanner.tsx @@ -0,0 +1,9 @@ +export function DisclaimerBanner() { + return ( +
+ + 所有行情与价格为示例 / 延迟数据,本报告仅供研究参考,非投资建议,本站无任何下单执行入口。 +
+ ); +} + diff --git a/web/frontend/src/components/site/SearchBar.tsx b/web/frontend/src/components/site/SearchBar.tsx new file mode 100644 index 0000000..34dc153 --- /dev/null +++ b/web/frontend/src/components/site/SearchBar.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { FormEvent, useState } from 'react'; + +const EXAMPLES = [ + '宁德时代海外扩张对未来 12 个月利润的影响', + '拆解小米汽车业务对整体估值的拉动', + '0700', + '招商银行 600036', +]; + +export function SearchBar({ size = 'lg' }: { size?: 'lg' | 'md' }) { + const router = useRouter(); + const [value, setValue] = useState(''); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const q = value.trim(); + if (!q) return; + router.push(`/research?q=${encodeURIComponent(q)}`); + }; + + const height = size === 'lg' ? 'h-14' : 'h-11'; + const textSize = size === 'lg' ? 'text-base' : 'text-sm'; + + return ( +
+
+ + setValue(e.target.value)} + placeholder="输入股票代码、公司名称,或一句研究指令,如「把 minimax 加入港股通对后市的影响」" + className={`w-full rounded-xl border border-dark-border bg-dark-input ${height} ${textSize} pl-11 pr-28 text-text-primary placeholder:text-text-tertiary focus:border-accent-primary focus:outline-none focus:ring-1 focus:ring-accent-primary/40`} + autoFocus={size === 'lg'} + /> + + + {size === 'lg' && ( +
+ 快捷示例: + {EXAMPLES.map((ex) => ( + + ))} +
+ )} +
+ ); +} + diff --git a/web/frontend/src/components/site/SiteFooter.tsx b/web/frontend/src/components/site/SiteFooter.tsx new file mode 100644 index 0000000..6a32fe9 --- /dev/null +++ b/web/frontend/src/components/site/SiteFooter.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; + +export function SiteFooter() { + return ( +
+
+
+
+ + TradingAgents + 多智能体股票研究 +
+ +
+

+ 本平台由 AI 多智能体生成研究报告,所有行情与价格均为示例或延迟数据,仅供研究参考,不构成任何投资建议。 + 本平台不提供任何下单、委托或交易执行入口。 +

+
+
+ ); +} + diff --git a/web/frontend/src/components/site/SiteHeader.tsx b/web/frontend/src/components/site/SiteHeader.tsx new file mode 100644 index 0000000..30cc5fd --- /dev/null +++ b/web/frontend/src/components/site/SiteHeader.tsx @@ -0,0 +1,110 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useState } from 'react'; +import { useAuth } from '@/lib/auth'; + +const NAV = [ + { href: '/', label: '研究' }, + { href: '/leaderboard', label: '公开榜单' }, + { href: '/subscription', label: '订阅' }, + { href: '/settings', label: '本地模型' }, +]; + +export function SiteHeader() { + const { user, logout } = useAuth(); + const pathname = usePathname(); + const [menuOpen, setMenuOpen] = useState(false); + const isAdmin = user?.role === 'admin'; + + return ( +
+
+ + + + TradingAgents + + 多智能体研究 + + + + +
+ {user ? ( +
+ + {user.username} + + +
+ ) : ( + + 登录 + + )} + +
+
+ + {menuOpen && ( + + )} +
+ ); +} diff --git a/web/frontend/src/components/site/SiteLayout.tsx b/web/frontend/src/components/site/SiteLayout.tsx new file mode 100644 index 0000000..87a3f50 --- /dev/null +++ b/web/frontend/src/components/site/SiteLayout.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; +import { SiteHeader } from './SiteHeader'; +import { SiteFooter } from './SiteFooter'; + +export function SiteLayout({ children, maxWidth = 'max-w-6xl' }: { children: ReactNode; maxWidth?: string }) { + return ( +
+ +
{children}
+ +
+ ); +} + diff --git a/web/frontend/src/lib/api/reports.ts b/web/frontend/src/lib/api/reports.ts new file mode 100644 index 0000000..5501218 --- /dev/null +++ b/web/frontend/src/lib/api/reports.ts @@ -0,0 +1,66 @@ +import { apiClient, publicApiClient } from '@/lib/apiClient'; +import type { ReportPreview, RoleChainReport } from '@/types/report'; + +// Detail payload from GET /api/reports/{id} +export interface ReportDetail { + id: string; + ticker: string; + company_name: string; + market: 'US' | 'HK' | 'CN' | null; + source: { type: string; session_id: string | null; task_id: number | null }; + conclusion: { + rating: number; + rating_label: string; + summary: string; + key_points: string[]; + }; + sections: Array<{ + key: string; + title: string; + summary: string; + content: string; + }>; + status: string; + created_at: string | null; + updated_at: string | null; + role_chain?: RoleChainReport; +} + +function unwrap(res: { data?: T }): T { + return res.data as T; +} + +export const reportAPI = { + // Authenticated: my reports + public + list: async (params: { page?: number; limit?: number; ticker?: string; market?: string } = {}) => { + const { page = 1, limit = 20, ticker, market } = params; + const qs = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (ticker) qs.set('ticker', ticker); + if (market) qs.set('market', market); + const res = await apiClient.get<{ data: ReportPreview[]; meta: { total: number; has_next: boolean } }>( + `/api/reports?${qs.toString()}`, + ); + return unwrap(res); + }, + + // Public feed for the home / leaderboard (no auth required) + publicFeed: async (limit = 12) => { + try { + const res = await publicApiClient.get<{ data: ReportPreview[]; meta: { total: number } }>( + `/api/reports/public?limit=${limit}`, + ); + return unwrap(res); + } catch { + return { data: [], meta: { total: 0 } }; + } + }, + + // Full report detail incl. role_chain + get: async (id: string) => { + const res = await apiClient.get<{ data: ReportDetail }>(`/api/reports/${id}`); + return res.data.data; + }, + + exportUrl: (id: string, format: 'md' | 'json' | 'pdf') => + `/api/reports/${id}/export?format=${format}`, +}; diff --git a/web/frontend/src/types/report.ts b/web/frontend/src/types/report.ts new file mode 100644 index 0000000..f4c501e --- /dev/null +++ b/web/frontend/src/types/report.ts @@ -0,0 +1,133 @@ +// Role-chain report contract — mirrors docs/report-role-chain-contract.md +// and tradingagents/utils/role_chain.py 1:1. + +export type Market = 'US' | 'HK' | 'CN'; +export type Verdict = 'strong_buy' | 'buy' | 'overweight' | 'hold' | 'reduce' | 'watch'; +export type RiskLevel = 'low' | 'moderate' | 'elevated' | 'high'; +export type Stance = 'positive' | 'warm' | 'neutral' | 'cooling' | 'negative'; +export type Currency = 'USD' | 'HKD' | 'CNY'; + +export interface PriceBand { + low: number; + high: number; + currency: Currency; + basis: '示例' | '延迟' | '收盘'; +} + +export interface RoleDecision { + verdict: Verdict; + verdictLabel: string; + rationale: string; + priceBand: PriceBand | null; // null -> show "示例 / 延迟", never invent + riskLevel: RiskLevel; + horizon: string | null; + confidence: number | null; // 0-100, null -> hide +} + +export interface AnalystNode { + role: 'market' | 'social' | 'news' | 'fundamentals'; + code: 'MKT' | 'SOC' | 'NEWS' | 'FND'; + title: string; + subtitle: string; + stance: Stance; + summary: string; + evidence: string[]; + hasContent: boolean; +} + +export interface DebateSide { + headline: string; + summary: string; +} + +export interface TraderPlan { + verdict: Verdict; + verdictLabel: string; + priceBand: PriceBand | null; + positionCapPct: number | null; + note: string; // fixed: "研究建议,非下单执行入口" + hasContent: boolean; + summary: string; +} + +export interface RoleChainReport { + id: string; + ticker: string; + company: string; + market: Market | null; + title: string; + publishedAt: string; + author: { name: string }; + modelId: string; + depth: 'lite' | 'standard' | 'deep'; + decision: RoleDecision; + analysts: AnalystNode[]; + debate: { + bull: DebateSide; + bear: DebateSide; + manager: { summary: string }; + }; + traderPlan: TraderPlan; + riskDebate: { + risky: DebateSide; + safe: DebateSide; + neutral: DebateSide; + }; + summary: string; + meta: { sources: number; generatedAt: string; disclaimer: string }; +} + +// Trimmed shape returned by report list / leaderboard previews. +export interface ReportPreview { + id: string; + ticker: string; + company_name: string; + market: Market | null; + rating: number; + rating_label: string; + summary: string; + status: string; + created_at: string | null; + trading_decision?: string; + role_chain?: { decision: RoleDecision; analysts: AnalystNode[] }; +} + +// verdict -> pill class + color helpers +export const VERDICT_PILL: Record = { + strong_buy: 'verdict-bull', + buy: 'verdict-bull', + overweight: 'verdict-hold', + hold: 'verdict-hold', + reduce: 'verdict-bear', + watch: 'verdict-neutral', +}; + +export const STANCE_PILL: Record = { + positive: 'verdict-bull', + warm: 'verdict-hold', + neutral: 'verdict-neutral', + cooling: 'verdict-bear', + negative: 'verdict-bear', +}; + +export const STANCE_LABEL: Record = { + positive: '积极', + warm: '偏多', + neutral: '中性', + cooling: '偏空', + negative: '看空', +}; + +export const RISK_LABEL: Record = { + low: '低', + moderate: '中等', + elevated: '偏高', + high: '高', +}; + +export const MARKET_LABEL: Record = { + US: '美股', + HK: '港股', + CN: 'A股', +}; + diff --git a/web/frontend/tailwind.config.js b/web/frontend/tailwind.config.js index 1c7a140..f903563 100644 --- a/web/frontend/tailwind.config.js +++ b/web/frontend/tailwind.config.js @@ -13,21 +13,32 @@ module.exports = { colors: { // Dark surfaces (mapped 1:1 onto the prior semantic tokens so the whole // app re-skins without per-file edits). - dark: { - primary: '#0a0d12', // ink — application background (was #0a0e1a) - secondary: '#171f2b', // surface — panels / cards (was #141824) - tertiary: '#202b39', // raised — hover / secondary btns (was #1a1f2e) - elevated: '#27333f', // elevated surfaces (was #1f2937) - border: '#304154', // structural line (was #2d3748) + dark: { + primary: '#060a10', // ink — deep finance base + secondary: '#0e1620', // surface — panels / cards + tertiary: '#16202d', // raised — hover / secondary btns + elevated: '#1d2937', // elevated surfaces + border: '#243243', // structural line + input: '#0a1119', // recessed inputs + rail: '#0b131c', // side rail + hover: '#1a2636', // generic hover }, // Workflow Desk accents accent: { - primary: '#9bffbe', // mint — primary action / current stage / success - secondary: '#8acbff', // sky blue — flow / node identity + primary: '#9ee5c9', // mint — bull / primary action / success + secondary: '#81bbed', // sky blue — safe / flow / info tertiary: '#5fb6e8', // tertiary blue - hover: '#9bffbe', // hover state (mint) + hover: '#9ee5c9', // hover state (mint) focus: '#8acbff', // focus state (blue) }, + // Verdict / stance semantic palette for the research report + verdict: { + bull: '#9ee5c9', // 看多 / 买入 / 积极 + hold: '#e5bd72', // 持有 / 审慎 / 中性偏多 + bear: '#f48b8b', // 看空 / 减持 / 偏空 + safe: '#81bbed', // Safe / 稳健 / 信息 + neutral: '#9aa9b8', // 中性 + }, // Text colors for the dark theme text: { primary: '#f1f5f7', // primary text @@ -110,11 +121,15 @@ module.exports = { }, }, // Workflow Desk typography - fontFamily: { - sans: ['"Noto Sans SC"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'], - serif: ['"Instrument Serif"', 'ui-serif', 'Georgia', '"Times New Roman"', 'serif'], - mono: ['"DM Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'], - }, + fontFamily: { + sans: ['"Noto Sans SC"', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'], + // Noto Serif SC for Chinese financial headlines (authoritative, editorial) + serif: ['"Noto Serif SC"', '"Songti SC"', 'ui-serif', 'Georgia', '"Times New Roman"', 'serif'], + // Tabular monospace for tickers / prices / verdicts / confidence + mono: ['"DM Mono"', '"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'], + num: ['"DM Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'], + heading: ['"Noto Serif SC"', '"Noto Sans SC"', 'ui-serif', 'Georgia', 'serif'], + }, // Bootstrap-compatible spacing spacing: { '0.5': '0.125rem', // 2px