feat: rebuild Chainlit UI to match CLI experience

Replaces the barebones web UI with one that mirrors the CLI:
- Agent status table with team/agent/status tracking
- Reuses CLI's MessageBuffer, update_analyst_statuses, classify_message_type
- Shows full debate transcripts (Bull/Bear, Risk team)
- Live stats (LLM calls, tokens, elapsed time)
- Collapsible Steps for each phase with full report content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dtarkent2-sys 2026-02-20 02:04:13 +00:00
parent 76f1e0abf0
commit ac782d179d
1 changed files with 197 additions and 76 deletions

273
app.py
View File

@ -1,7 +1,9 @@
"""Chainlit web UI for TradingAgents — deployed on Railway.""" """Chainlit web UI for TradingAgents — mirrors the CLI experience."""
import os import os
import re import re
import time
import datetime
from datetime import date from datetime import date
import chainlit as cl import chainlit as cl
@ -9,31 +11,27 @@ import chainlit as cl
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from cli.stats_handler import StatsCallbackHandler from cli.stats_handler import StatsCallbackHandler
from cli.main import (
MessageBuffer,
classify_message_type,
update_analyst_statuses,
update_research_team_status,
ANALYST_ORDER,
)
def parse_ticker_date(text: str): def parse_ticker_date(text: str):
"""Extract ticker symbol and optional date from user message. """Extract ticker symbol and optional date from user message."""
Examples:
"NVDA" -> ("NVDA", today)
"Analyze AAPL 2024-12-01" -> ("AAPL", "2024-12-01")
"What about TSLA?" -> ("TSLA", today)
"""
# Try to find a date (YYYY-MM-DD)
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", text) date_match = re.search(r"(\d{4}-\d{2}-\d{2})", text)
trade_date = date_match.group(1) if date_match else str(date.today()) trade_date = date_match.group(1) if date_match else str(date.today())
# Find uppercase 1-5 letter words as candidate tickers
candidates = re.findall(r"\b([A-Z]{1,5})\b", text) candidates = re.findall(r"\b([A-Z]{1,5})\b", text)
# Filter out common English words
skip = {"I", "A", "THE", "AND", "OR", "FOR", "TO", "IN", "ON", "AT", "IS", skip = {"I", "A", "THE", "AND", "OR", "FOR", "TO", "IN", "ON", "AT", "IS",
"IT", "OF", "BY", "AS", "AN", "BE", "IF", "SO", "DO", "MY", "UP", "IT", "OF", "BY", "AS", "AN", "BE", "IF", "SO", "DO", "MY", "UP",
"NO", "NOT", "ALL", "BUT", "HOW", "GET", "HAS", "HAD", "CAN", "NO", "NOT", "ALL", "BUT", "HOW", "GET", "HAS", "HAD", "CAN",
"WHAT", "ABOUT", "BUY", "SELL", "HOLD"} "WHAT", "ABOUT", "BUY", "SELL", "HOLD"}
tickers = [c for c in candidates if c not in skip] tickers = [c for c in candidates if c not in skip]
return tickers[0] if tickers else None, trade_date
ticker = tickers[0] if tickers else None
return ticker, trade_date
def build_config(): def build_config():
@ -54,20 +52,54 @@ def build_config():
return config return config
# Report field -> display name def format_agent_status_table(buf):
REPORT_NAMES = { """Build a markdown table showing agent status (like the CLI progress panel)."""
"market_report": "Market Analyst", teams = {
"sentiment_report": "Sentiment Analyst", "Analyst Team": ["Market Analyst", "Social Analyst", "News Analyst", "Fundamentals Analyst"],
"news_report": "News Analyst", "Research Team": ["Bull Researcher", "Bear Researcher", "Research Manager"],
"fundamentals_report": "Fundamentals Analyst", "Trading Team": ["Trader"],
} "Risk Management": ["Aggressive Analyst", "Neutral Analyst", "Conservative Analyst"],
"Portfolio Management": ["Portfolio Manager"],
}
icons = {"pending": "\u23f3", "in_progress": "\u26a1", "completed": "\u2705", "error": "\u274c"}
lines = ["| Team | Agent | Status |", "|---|---|---|"]
for team, agents in teams.items():
active = [a for a in agents if a in buf.agent_status]
for i, agent in enumerate(active):
status = buf.agent_status.get(agent, "pending")
icon = icons.get(status, "")
team_col = team if i == 0 else ""
lines.append(f"| {team_col} | {agent} | {icon} {status} |")
return "\n".join(lines)
def format_stats(stats_handler, buf, start_time):
"""Format footer stats like the CLI."""
s = stats_handler.get_stats()
agents_done = sum(1 for v in buf.agent_status.values() if v == "completed")
agents_total = len(buf.agent_status)
reports_done = buf.get_completed_reports_count()
reports_total = len(buf.report_sections)
elapsed = time.time() - start_time
elapsed_str = f"{int(elapsed // 60):02d}:{int(elapsed % 60):02d}"
return (
f"Agents: {agents_done}/{agents_total} | "
f"LLM: {s['llm_calls']} | Tools: {s['tool_calls']} | "
f"Tokens: {s['tokens_in']:,}\u2191 {s['tokens_out']:,}\u2193 | "
f"Reports: {reports_done}/{reports_total} | "
f"\u23f1 {elapsed_str}"
)
@cl.on_chat_start @cl.on_chat_start
async def on_chat_start(): async def on_chat_start():
await cl.Message( await cl.Message(
content=( content=(
"**TradingAgents** — Multi-Agent LLM Trading Analysis\n\n" "**TradingAgents** \u2014 Multi-Agent LLM Trading Analysis\n\n"
"Send a ticker symbol to analyze. Examples:\n" "Send a ticker symbol to analyze. Examples:\n"
"- `NVDA`\n" "- `NVDA`\n"
"- `Analyze AAPL 2024-12-01`\n" "- `Analyze AAPL 2024-12-01`\n"
@ -88,70 +120,154 @@ async def on_message(message: cl.Message):
).send() ).send()
return return
# Status message # --- Build graph ---
status_msg = cl.Message(content=f"Analyzing **{ticker}** for **{trade_date}**...")
await status_msg.send()
# Build graph
config = build_config() config = build_config()
stats = StatsCallbackHandler() stats_handler = StatsCallbackHandler()
selected_analysts = ["market", "social", "news", "fundamentals"]
try: try:
graph = TradingAgentsGraph( graph = TradingAgentsGraph(
selected_analysts=["market", "social", "news", "fundamentals"], selected_analysts=selected_analysts,
debug=False, debug=False,
config=config, config=config,
callbacks=[stats], callbacks=[stats_handler],
) )
except Exception as e: except Exception as e:
await cl.Message(content=f"Failed to initialize agents: {e}").send() await cl.Message(content=f"Failed to initialize agents: {e}").send()
return return
# Create initial state and stream # --- Initialize message buffer (same as CLI) ---
init_state = graph.propagator.create_initial_state(ticker, trade_date) buf = MessageBuffer()
args = graph.propagator.get_graph_args(callbacks=[stats]) buf.init_for_analysis(selected_analysts)
# Track which reports/phases we've already shown # --- Status message (will be updated as agents progress) ---
seen_reports = set() status_msg = cl.Message(content=f"**Analyzing {ticker} for {trade_date}...**\n\n{format_agent_status_table(buf)}")
seen_debate = False await status_msg.send()
seen_risk = False
seen_trader = False # --- Stream the graph ---
init_state = graph.propagator.create_initial_state(ticker, trade_date)
args = graph.propagator.get_graph_args(callbacks=[stats_handler])
start_time = time.time()
# Steps we'll create as agents complete
analyst_steps = {} # field -> Step
research_step = None
trader_step = None
risk_step = None
last_status_update = 0
final_state = None final_state = None
try: try:
async for chunk in graph.graph.astream(init_state, **args): async for chunk in graph.graph.astream(init_state, **args):
final_state = chunk final_state = chunk
# --- Analyst reports --- # --- Process messages (same as CLI lines 1024-1044) ---
for field, name in REPORT_NAMES.items(): if chunk.get("messages") and len(chunk["messages"]) > 0:
if field not in seen_reports and chunk.get(field): last_msg = chunk["messages"][-1]
seen_reports.add(field) msg_id = getattr(last_msg, "id", None)
report = chunk[field] if msg_id != buf._last_message_id:
# Show as a collapsible Step buf._last_message_id = msg_id
async with cl.Step(name=f"{name} Report", type="tool") as step: msg_type, content = classify_message_type(last_msg)
step.output = report[:3000] if len(report) > 3000 else report if content and content.strip():
buf.add_message(msg_type, content)
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
for tc in last_msg.tool_calls:
if isinstance(tc, dict):
buf.add_tool_call(tc["name"], tc["args"])
else:
buf.add_tool_call(tc.name, tc.args)
# --- Investment debate (Bull vs Bear) --- # --- Update analyst statuses (same as CLI line 1047) ---
debate = chunk.get("investment_debate_state") update_analyst_statuses(buf, chunk)
if debate and not seen_debate and debate.get("judge_decision"):
seen_debate = True
async with cl.Step(name="Research Debate", type="tool") as step:
step.output = (
f"**Judge Decision:**\n{debate['judge_decision']}"
)
# --- Trader plan --- # --- Emit analyst report Steps as they complete ---
if not seen_trader and chunk.get("trader_investment_plan"): report_names = {
seen_trader = True "market_report": "Market Analyst",
async with cl.Step(name="Trader Plan", type="tool") as step: "sentiment_report": "Sentiment Analyst",
step.output = chunk["trader_investment_plan"][:3000] "news_report": "News Analyst",
"fundamentals_report": "Fundamentals Analyst",
}
for field, name in report_names.items():
if field not in analyst_steps and chunk.get(field):
analyst_steps[field] = True
async with cl.Step(name=f"\u2705 {name} Report", type="tool") as step:
report = chunk[field]
step.output = report[:4000] if len(report) > 4000 else report
# --- Risk debate --- # --- Research debate (same as CLI lines 1050-1072) ---
risk = chunk.get("risk_debate_state") if chunk.get("investment_debate_state"):
if risk and not seen_risk and risk.get("judge_decision"): debate = chunk["investment_debate_state"]
seen_risk = True bull = debate.get("bull_history", "").strip()
async with cl.Step(name="Risk Assessment", type="tool") as step: bear = debate.get("bear_history", "").strip()
step.output = f"**Risk Decision:**\n{risk['judge_decision']}" judge = debate.get("judge_decision", "").strip()
if bull or bear:
update_research_team_status("in_progress")
buf.update_report_section("investment_plan",
(f"### Bull Researcher\n{bull}\n\n### Bear Researcher\n{bear}") if bear else f"### Bull Researcher\n{bull}")
if judge and not research_step:
research_step = True
buf.update_report_section("investment_plan", f"### Research Manager Decision\n{judge}")
update_research_team_status("completed")
buf.update_agent_status("Trader", "in_progress")
async with cl.Step(name="\u2705 Research Debate", type="tool") as step:
step.output = f"**Bull Case:**\n{bull}\n\n---\n\n**Bear Case:**\n{bear}\n\n---\n\n**Research Manager Decision:**\n{judge}"
# --- Trader plan (same as CLI lines 1075-1081) ---
if chunk.get("trader_investment_plan") and not trader_step:
trader_step = True
buf.update_report_section("trader_investment_plan", chunk["trader_investment_plan"])
buf.update_agent_status("Trader", "completed")
buf.update_agent_status("Aggressive Analyst", "in_progress")
async with cl.Step(name="\u2705 Trader Plan", type="tool") as step:
plan = chunk["trader_investment_plan"]
step.output = plan[:4000] if len(plan) > 4000 else plan
# --- Risk debate (same as CLI lines 1084-1118) ---
if chunk.get("risk_debate_state"):
risk = chunk["risk_debate_state"]
agg = risk.get("aggressive_history", "").strip()
con = risk.get("conservative_history", "").strip()
neu = risk.get("neutral_history", "").strip()
judge = risk.get("judge_decision", "").strip()
if agg:
buf.update_agent_status("Aggressive Analyst", "in_progress")
if con:
buf.update_agent_status("Conservative Analyst", "in_progress")
if neu:
buf.update_agent_status("Neutral Analyst", "in_progress")
if judge and not risk_step:
risk_step = True
buf.update_agent_status("Aggressive Analyst", "completed")
buf.update_agent_status("Conservative Analyst", "completed")
buf.update_agent_status("Neutral Analyst", "completed")
buf.update_agent_status("Portfolio Manager", "completed")
buf.update_report_section("final_trade_decision", f"### Portfolio Manager Decision\n{judge}")
async with cl.Step(name="\u2705 Risk Assessment", type="tool") as step:
parts = []
if agg:
parts.append(f"**Aggressive Analyst:**\n{agg}")
if con:
parts.append(f"**Conservative Analyst:**\n{con}")
if neu:
parts.append(f"**Neutral Analyst:**\n{neu}")
parts.append(f"**Portfolio Manager Decision:**\n{judge}")
step.output = "\n\n---\n\n".join(parts)
# --- Update status message periodically ---
now = time.time()
if now - last_status_update > 5:
last_status_update = now
status_msg.content = (
f"**Analyzing {ticker} for {trade_date}...**\n\n"
f"{format_agent_status_table(buf)}\n\n"
f"*{format_stats(stats_handler, buf, start_time)}*"
)
await status_msg.update()
except Exception as e: except Exception as e:
await cl.Message(content=f"Error during analysis: {e}").send() await cl.Message(content=f"Error during analysis: {e}").send()
@ -161,23 +277,28 @@ async def on_message(message: cl.Message):
await cl.Message(content="Analysis produced no results.").send() await cl.Message(content="Analysis produced no results.").send()
return return
# Process final decision # --- Final decision ---
decision_text = final_state.get("final_trade_decision", "No decision reached.") decision_text = final_state.get("final_trade_decision", "No decision reached.")
signal = graph.process_signal(decision_text) signal = graph.process_signal(decision_text)
# Stats summary # Mark all agents completed
s = stats.get_stats() for agent in buf.agent_status:
stats_line = ( buf.update_agent_status(agent, "completed")
f"*{s['llm_calls']} LLM calls · {s['tool_calls']} tool calls · "
f"{s['tokens_in']:,} tokens in · {s['tokens_out']:,} tokens out*"
)
# Final status update
status_msg.content = (
f"**Analysis complete for {ticker} ({trade_date})**\n\n"
f"{format_agent_status_table(buf)}\n\n"
f"*{format_stats(stats_handler, buf, start_time)}*"
)
await status_msg.update()
# Send the final decision
await cl.Message( await cl.Message(
content=( content=(
f"## {ticker} — Trading Decision\n\n" f"## {ticker} \u2014 Trading Decision\n\n"
f"**Signal: {signal}**\n\n" f"### Signal: {signal}\n\n"
f"---\n\n" f"---\n\n"
f"{decision_text}\n\n" f"{decision_text}"
f"---\n{stats_line}"
) )
).send() ).send()