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:
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 (
+
+
+
+
+
+
+
+
+
+ | 时间 |
+ 明细 |
+ 变动 |
+ 余额 |
+
+
+
+ {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 (
+
+ );
+}
+
+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 && (
+
+
+
+ {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 (
+
+ );
+}
+
+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 (
+
+
+ {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 (
+
+ );
+}
+
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