feat: implement AgentOS frontend and live backend integration
- scaffold Vite + React + TypeScript frontend with Chakra UI and React Flow - implement AgentGraph, MetricHeader, and Dashboard components - connect FastAPI to live LangGraph events via astream_events - implement real-time event mapping for 'scan' and 'pipeline' - refactor run storage for shared access between REST and WebSockets
This commit is contained in:
parent
a26c93463a
commit
078d7e2f2a
|
|
@ -2,14 +2,12 @@ from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
from agent_os.backend.store import runs
|
||||||
from agent_os.backend.dependencies import get_current_user
|
from agent_os.backend.dependencies import get_current_user
|
||||||
from agent_os.backend.services.langgraph_engine import LangGraphEngine
|
from agent_os.backend.services.langgraph_engine import LangGraphEngine
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/run", tags=["runs"])
|
router = APIRouter(prefix="/api/run", tags=["runs"])
|
||||||
|
|
||||||
# In-memory store for demo (should be replaced by Redis/DB for persistence)
|
|
||||||
runs: Dict[str, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
engine = LangGraphEngine()
|
engine = LangGraphEngine()
|
||||||
|
|
||||||
@router.post("/scan")
|
@router.post("/scan")
|
||||||
|
|
@ -24,7 +22,8 @@ async def trigger_scan(
|
||||||
"type": "scan",
|
"type": "scan",
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"user_id": user["user_id"]
|
"user_id": user["user_id"],
|
||||||
|
"params": params or {}
|
||||||
}
|
}
|
||||||
background_tasks.add_task(engine.run_scan, run_id, params or {})
|
background_tasks.add_task(engine.run_scan, run_id, params or {})
|
||||||
return {"run_id": run_id, "status": "queued"}
|
return {"run_id": run_id, "status": "queued"}
|
||||||
|
|
@ -41,7 +40,8 @@ async def trigger_pipeline(
|
||||||
"type": "pipeline",
|
"type": "pipeline",
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"user_id": user["user_id"]
|
"user_id": user["user_id"],
|
||||||
|
"params": params or {}
|
||||||
}
|
}
|
||||||
background_tasks.add_task(engine.run_pipeline, run_id, params or {})
|
background_tasks.add_task(engine.run_pipeline, run_id, params or {})
|
||||||
return {"run_id": run_id, "status": "queued"}
|
return {"run_id": run_id, "status": "queued"}
|
||||||
|
|
@ -58,7 +58,8 @@ async def trigger_portfolio(
|
||||||
"type": "portfolio",
|
"type": "portfolio",
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"user_id": user["user_id"]
|
"user_id": user["user_id"],
|
||||||
|
"params": params or {}
|
||||||
}
|
}
|
||||||
background_tasks.add_task(engine.run_portfolio, run_id, params or {})
|
background_tasks.add_task(engine.run_portfolio, run_id, params or {})
|
||||||
return {"run_id": run_id, "status": "queued"}
|
return {"run_id": run_id, "status": "queued"}
|
||||||
|
|
@ -75,7 +76,8 @@ async def trigger_auto(
|
||||||
"type": "auto",
|
"type": "auto",
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"user_id": user["user_id"]
|
"user_id": user["user_id"],
|
||||||
|
"params": params or {}
|
||||||
}
|
}
|
||||||
background_tasks.add_task(engine.run_auto, run_id, params or {})
|
background_tasks.add_task(engine.run_auto, run_id, params or {})
|
||||||
return {"run_id": run_id, "status": "queued"}
|
return {"run_id": run_id, "status": "queued"}
|
||||||
|
|
|
||||||
|
|
@ -4,106 +4,53 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from agent_os.backend.dependencies import get_current_user
|
from agent_os.backend.dependencies import get_current_user
|
||||||
|
from agent_os.backend.store import runs
|
||||||
|
from agent_os.backend.services.langgraph_engine import LangGraphEngine
|
||||||
|
|
||||||
router = APIRouter(prefix="/ws", tags=["websocket"])
|
router = APIRouter(prefix="/ws", tags=["websocket"])
|
||||||
|
|
||||||
|
engine = LangGraphEngine()
|
||||||
|
|
||||||
@router.websocket("/stream/{run_id}")
|
@router.websocket("/stream/{run_id}")
|
||||||
async def websocket_endpoint(
|
async def websocket_endpoint(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
# user: dict = Depends(get_current_user) # In V2, validate token from query string
|
|
||||||
):
|
):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
print(f"WebSocket client connected to run: {run_id}")
|
print(f"WebSocket client connected to run: {run_id}")
|
||||||
|
|
||||||
|
if run_id not in runs:
|
||||||
|
await websocket.send_json({"type": "system", "message": f"Error: Run {run_id} not found."})
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
run_info = runs[run_id]
|
||||||
|
run_type = run_info["type"]
|
||||||
|
params = run_info.get("params", {})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# For now, we use a mock stream.
|
stream_gen = None
|
||||||
# In a real implementation, this would subscribe to an event queue or a database stream
|
if run_type == "scan":
|
||||||
# that's being populated by the BackgroundTask running the LangGraph.
|
stream_gen = engine.run_scan(run_id, params)
|
||||||
|
elif run_type == "pipeline":
|
||||||
|
stream_gen = engine.run_pipeline(run_id, params)
|
||||||
|
# Add other types as they are implemented in LangGraphEngine
|
||||||
|
|
||||||
mock_events = [
|
if stream_gen:
|
||||||
{
|
async for payload in stream_gen:
|
||||||
"id": "node_1",
|
# Add timestamp if not present
|
||||||
"node_id": "analyst_node",
|
if "timestamp" not in payload:
|
||||||
"parent_node_id": "start",
|
payload["timestamp"] = time.strftime("%H:%M:%S")
|
||||||
"type": "thought",
|
await websocket.send_json(payload)
|
||||||
"agent": "ANALYST",
|
else:
|
||||||
"message": "Evaluating market data...",
|
await websocket.send_json({"type": "system", "message": f"Error: Run type {run_type} streaming not yet implemented."})
|
||||||
"metrics": {
|
|
||||||
"model": "gpt-4-turbo",
|
|
||||||
"tokens_in": 120,
|
|
||||||
"tokens_out": 45,
|
|
||||||
"latency_ms": 450
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "node_2",
|
|
||||||
"node_id": "tool_node",
|
|
||||||
"parent_node_id": "analyst_node",
|
|
||||||
"type": "tool",
|
|
||||||
"agent": "ANALYST",
|
|
||||||
"message": "> Tool Call: get_news_sentiment",
|
|
||||||
"metrics": {
|
|
||||||
"latency_ms": 800
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "node_3",
|
|
||||||
"node_id": "research_node",
|
|
||||||
"parent_node_id": "analyst_node",
|
|
||||||
"type": "thought",
|
|
||||||
"agent": "RESEARCHER",
|
|
||||||
"message": "Synthesizing industry trends...",
|
|
||||||
"metrics": {
|
|
||||||
"model": "claude-3-opus",
|
|
||||||
"tokens_in": 800,
|
|
||||||
"tokens_out": 300,
|
|
||||||
"latency_ms": 2200
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "node_4",
|
|
||||||
"node_id": "trader_node",
|
|
||||||
"parent_node_id": "research_node",
|
|
||||||
"type": "result",
|
|
||||||
"agent": "TRADER",
|
|
||||||
"message": "Action determined: BUY VLO",
|
|
||||||
"details": {
|
|
||||||
"model_used": "gpt-4-turbo",
|
|
||||||
"latency_ms": 1200,
|
|
||||||
"input_tokens": 450,
|
|
||||||
"output_tokens": 120,
|
|
||||||
"raw_json_response": '{"action": "buy", "ticker": "VLO"}'
|
|
||||||
},
|
|
||||||
"metrics": {
|
|
||||||
"model": "gpt-4-turbo",
|
|
||||||
"tokens_in": 450,
|
|
||||||
"tokens_out": 120,
|
|
||||||
"latency_ms": 1200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
for evt in mock_events:
|
|
||||||
payload = {
|
|
||||||
"id": evt["id"],
|
|
||||||
"node_id": evt["node_id"],
|
|
||||||
"parent_node_id": evt["parent_node_id"],
|
|
||||||
"timestamp": time.strftime("%H:%M:%S"),
|
|
||||||
"agent": evt["agent"],
|
|
||||||
"tier": "mid" if evt["agent"] == "ANALYST" else "deep",
|
|
||||||
"type": evt["type"],
|
|
||||||
"message": evt["message"],
|
|
||||||
"details": evt.get("details"),
|
|
||||||
"metrics": evt.get("metrics")
|
|
||||||
}
|
|
||||||
await websocket.send_json(payload)
|
|
||||||
await asyncio.sleep(2) # Simulating execution delay
|
|
||||||
|
|
||||||
await websocket.send_json({"type": "system", "message": "Run completed."})
|
await websocket.send_json({"type": "system", "message": "Run completed."})
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
print(f"WebSocket client disconnected from run {run_id}")
|
print(f"WebSocket client disconnected from run {run_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
await websocket.send_json({"type": "system", "message": f"Error: {str(e)}"})
|
await websocket.send_json({"type": "system", "message": f"Error: {str(e)}"})
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,135 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, AsyncGenerator
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
from tradingagents.graph.scanner_graph import ScannerGraph
|
||||||
|
from tradingagents.default_config import DEFAULT_CONFIG
|
||||||
|
|
||||||
class LangGraphEngine:
|
class LangGraphEngine:
|
||||||
"""Orchestrates LangGraph pipeline executions for the AgentOS API."""
|
"""Orchestrates LangGraph pipeline executions and streams events."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# This is where you would import and setup your LangGraph workflows
|
self.config = DEFAULT_CONFIG.copy()
|
||||||
# e.g., from tradingagents.graph.setup import setup_trading_graph
|
# In-memory store to keep track of running tasks if needed
|
||||||
pass
|
self.active_runs = {}
|
||||||
|
|
||||||
async def run_scan(self, run_id: str, params: Dict[str, Any]):
|
async def run_scan(self, run_id: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]:
|
||||||
print(f"Engine: Starting SCAN {run_id} with params {params}")
|
"""Run the 3-phase macro scanner and stream events."""
|
||||||
# Placeholder for actual scanner graph execution
|
date = params.get("date", time.strftime("%Y-%m-%d"))
|
||||||
await asyncio.sleep(15)
|
|
||||||
print(f"Engine: SCAN {run_id} completed")
|
|
||||||
|
|
||||||
async def run_pipeline(self, run_id: str, params: Dict[str, Any]):
|
# Initialize ScannerGraph
|
||||||
print(f"Engine: Starting PIPELINE {run_id} with params {params}")
|
# Note: ScannerGraph in TradingAgents seems to take date and config
|
||||||
# Placeholder for actual analysis pipeline execution
|
scanner = ScannerGraph(date=date, config=self.config)
|
||||||
await asyncio.sleep(20)
|
|
||||||
print(f"Engine: PIPELINE {run_id} completed")
|
|
||||||
|
|
||||||
async def run_portfolio(self, run_id: str, params: Dict[str, Any]):
|
print(f"Engine: Starting SCAN {run_id} for date {date}")
|
||||||
print(f"Engine: Starting PORTFOLIO rebalance {run_id} with params {params}")
|
|
||||||
# Placeholder for actual portfolio manager graph execution
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
print(f"Engine: PORTFOLIO {run_id} completed")
|
|
||||||
|
|
||||||
async def run_auto(self, run_id: str, params: Dict[str, Any]):
|
# Initial state for scanner
|
||||||
print(f"Engine: Starting AUTO {run_id} with params {params}")
|
# Based on tradingagents/graph/scanner_graph.py
|
||||||
# Placeholder for full automated trading cycle
|
initial_state = {
|
||||||
await asyncio.sleep(30)
|
"date": date,
|
||||||
print(f"Engine: AUTO {run_id} completed")
|
"geopolitical_report": "",
|
||||||
|
"market_movers_report": "",
|
||||||
|
"sector_report": "",
|
||||||
|
"industry_deep_dive_report": "",
|
||||||
|
"macro_synthesis_report": "",
|
||||||
|
"top_10_watchlist": []
|
||||||
|
}
|
||||||
|
|
||||||
|
async for event in scanner.graph.astream_events(initial_state, version="v2"):
|
||||||
|
mapped_event = self._map_langgraph_event(event)
|
||||||
|
if mapped_event:
|
||||||
|
yield mapped_event
|
||||||
|
|
||||||
|
async def run_pipeline(self, run_id: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]:
|
||||||
|
"""Run per-ticker analysis pipeline and stream events."""
|
||||||
|
ticker = params.get("ticker", "AAPL")
|
||||||
|
date = params.get("date", time.strftime("%Y-%m-%d"))
|
||||||
|
analysts = params.get("analysts", ["market", "news", "fundamentals"])
|
||||||
|
|
||||||
|
print(f"Engine: Starting PIPELINE {run_id} for {ticker} on {date}")
|
||||||
|
|
||||||
|
# Initialize TradingAgentsGraph
|
||||||
|
graph_wrapper = TradingAgentsGraph(
|
||||||
|
selected_analysts=analysts,
|
||||||
|
config=self.config,
|
||||||
|
debug=True
|
||||||
|
)
|
||||||
|
|
||||||
|
initial_state = graph_wrapper.propagator.create_initial_state(ticker, date)
|
||||||
|
# We don't use propagator.get_graph_args() here because we want to stream events directly
|
||||||
|
|
||||||
|
async for event in graph_wrapper.graph.astream_events(initial_state, version="v2"):
|
||||||
|
mapped_event = self._map_langgraph_event(event)
|
||||||
|
if mapped_event:
|
||||||
|
yield mapped_event
|
||||||
|
|
||||||
|
def _map_langgraph_event(self, event: Dict[str, Any]) -> Dict[str, Any] | None:
|
||||||
|
"""Map LangGraph v2 events to AgentOS frontend contract."""
|
||||||
|
kind = event["event"]
|
||||||
|
name = event["name"]
|
||||||
|
tags = event.get("tags", [])
|
||||||
|
|
||||||
|
# Try to extract node name from tags or metadata
|
||||||
|
node_name = name
|
||||||
|
for tag in tags:
|
||||||
|
if tag.startswith("graph:node:"):
|
||||||
|
node_name = tag.split(":", 2)[-1]
|
||||||
|
|
||||||
|
# Filter for relevant events
|
||||||
|
if kind == "on_chat_model_start":
|
||||||
|
return {
|
||||||
|
"id": event["run_id"],
|
||||||
|
"node_id": node_name,
|
||||||
|
"parent_node_id": "start", # Simplified for now
|
||||||
|
"type": "thought",
|
||||||
|
"agent": node_name.upper(),
|
||||||
|
"message": f"Thinking...",
|
||||||
|
"metrics": {
|
||||||
|
"model": event["data"].get("invocation_params", {}).get("model_name", "unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elif kind == "on_tool_start":
|
||||||
|
return {
|
||||||
|
"id": event["run_id"],
|
||||||
|
"node_id": f"tool_{name}",
|
||||||
|
"parent_node_id": node_name,
|
||||||
|
"type": "tool",
|
||||||
|
"agent": node_name.upper(),
|
||||||
|
"message": f"> Tool Call: {name}",
|
||||||
|
"metrics": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
elif kind == "on_chat_model_end":
|
||||||
|
output = event["data"].get("output")
|
||||||
|
usage = {}
|
||||||
|
model = "unknown"
|
||||||
|
if hasattr(output, "usage_metadata") and output.usage_metadata:
|
||||||
|
usage = output.usage_metadata
|
||||||
|
if hasattr(output, "response_metadata") and output.response_metadata:
|
||||||
|
model = output.response_metadata.get("model_name", "unknown")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": f"{event['run_id']}_end",
|
||||||
|
"node_id": node_name,
|
||||||
|
"type": "result",
|
||||||
|
"agent": node_name.upper(),
|
||||||
|
"message": "Action determined.",
|
||||||
|
"metrics": {
|
||||||
|
"model": model,
|
||||||
|
"tokens_in": usage.get("input_tokens", 0),
|
||||||
|
"tokens_out": usage.get("output_tokens", 0),
|
||||||
|
# "latency_ms": ... # calculated in frontend or here if we track start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sync versions for BackgroundTasks (if we still want to use them)
|
||||||
|
async def run_scan_background(self, run_id: str, params: Dict[str, Any]):
|
||||||
|
async for _ in self.run_scan(run_id, params):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run_pipeline_background(self, run_id: str, params: Dict[str, Any]):
|
||||||
|
async for _ in self.run_pipeline(run_id, params):
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
# In-memory store for demo (should be replaced by Redis/DB for persistence)
|
||||||
|
runs: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AgentOS | Observability Command Center</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "agent-os-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@chakra-ui/react": "^2.8.2",
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"framer-motion": "^10.16.5",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"reactflow": "^11.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
|
import theme from './theme';
|
||||||
|
import { Dashboard } from './Dashboard';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ChakraProvider theme={theme}>
|
||||||
|
<Dashboard />
|
||||||
|
</ChakraProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Button,
|
||||||
|
useDisclosure,
|
||||||
|
Drawer,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerBody,
|
||||||
|
Divider,
|
||||||
|
Tag,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { LayoutDashboard, Wallet, Settings, Play, Terminal as TerminalIcon, ChevronRight } from 'lucide-react';
|
||||||
|
import { MetricHeader } from './components/MetricHeader';
|
||||||
|
import { AgentGraph } from './components/AgentGraph';
|
||||||
|
import { useAgentStream } from './hooks/useAgentStream';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:8000/api';
|
||||||
|
|
||||||
|
export const Dashboard: React.FC = () => {
|
||||||
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||||
|
const { events, status, clearEvents } = useAgentStream(activeRunId);
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const [selectedNode, setSelectedNode] = useState<any>(null);
|
||||||
|
|
||||||
|
const startRun = async (type: string) => {
|
||||||
|
try {
|
||||||
|
clearEvents();
|
||||||
|
const res = await axios.post(`${API_BASE}/run/${type}`);
|
||||||
|
setActiveRunId(res.data.run_id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to start run:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex h="100vh" bg="slate.950" color="white" overflow="hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<VStack w="64px" bg="slate.900" borderRight="1px solid" borderColor="whiteAlpha.100" py={4} spacing={6}>
|
||||||
|
<Box mb={4}><Text fontWeight="black" color="cyan.400" fontSize="xl">A</Text></Box>
|
||||||
|
<IconButton aria-label="Dashboard" icon={<LayoutDashboard size={20} />} variant="ghost" color="cyan.400" _hover={{ bg: "whiteAlpha.100" }} />
|
||||||
|
<IconButton aria-label="Portfolio" icon={<Wallet size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
||||||
|
<IconButton aria-label="Settings" icon={<Settings size={20} />} variant="ghost" color="whiteAlpha.600" _hover={{ bg: "whiteAlpha.100" }} />
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<Flex flex="1" direction="column">
|
||||||
|
{/* Top Metric Header */}
|
||||||
|
<MetricHeader />
|
||||||
|
|
||||||
|
{/* Dashboard Body */}
|
||||||
|
<Flex flex="1" overflow="hidden">
|
||||||
|
{/* Left Side: Graph Area */}
|
||||||
|
<Box flex="1" position="relative" borderRight="1px solid" borderColor="whiteAlpha.100">
|
||||||
|
<AgentGraph events={events} />
|
||||||
|
|
||||||
|
{/* Floating Control Panel */}
|
||||||
|
<HStack position="absolute" top={4} left={4} bg="blackAlpha.800" p={2} borderRadius="lg" backdropFilter="blur(10px)" border="1px solid" borderColor="whiteAlpha.200" spacing={3}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Play size={14} />}
|
||||||
|
colorScheme="cyan"
|
||||||
|
variant="solid"
|
||||||
|
onClick={() => startRun('scan')}
|
||||||
|
isLoading={status === 'connecting' || status === 'streaming'}
|
||||||
|
>
|
||||||
|
Start Market Scan
|
||||||
|
</Button>
|
||||||
|
<Divider orientation="vertical" h="20px" />
|
||||||
|
<Tag size="sm" colorScheme={status === 'streaming' ? 'green' : 'gray'}>
|
||||||
|
{status.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Right Side: Live Terminal */}
|
||||||
|
<VStack w="400px" bg="blackAlpha.400" align="stretch" spacing={0}>
|
||||||
|
<Flex p={3} bg="whiteAlpha.50" align="center" gap={2} borderBottom="1px solid" borderColor="whiteAlpha.100">
|
||||||
|
<TerminalIcon size={16} color="#4fd1c5" />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" textTransform="uppercase" letterSpacing="wider">Live Terminal</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Box flex="1" overflowY="auto" p={4} sx={{
|
||||||
|
'&::-webkit-scrollbar': { width: '4px' },
|
||||||
|
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||||
|
'&::-webkit-scrollbar-thumb': { background: 'whiteAlpha.300' }
|
||||||
|
}}>
|
||||||
|
{events.map((evt, i) => (
|
||||||
|
<Box key={evt.id} mb={3} fontSize="xs" fontFamily="mono">
|
||||||
|
<Flex gap={2}>
|
||||||
|
<Text color="whiteAlpha.400" minW="60px">[{evt.timestamp}]</Text>
|
||||||
|
<Text color={evt.type === 'tool' ? 'purple.400' : evt.type === 'result' ? 'amber.400' : 'cyan.400'} fontWeight="bold">
|
||||||
|
{evt.agent}
|
||||||
|
</Text>
|
||||||
|
<ChevronRight size={12} style={{ marginTop: 2 }} />
|
||||||
|
<Text color="whiteAlpha.800">{evt.message}</Text>
|
||||||
|
</Flex>
|
||||||
|
{evt.metrics && (
|
||||||
|
<HStack spacing={4} mt={1} ml="70px" color="whiteAlpha.400" fontSize="10px">
|
||||||
|
<Text>tokens: {evt.metrics.tokens_in}/{evt.metrics.tokens_out}</Text>
|
||||||
|
<Text>time: {evt.metrics.latency_ms}ms</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<Flex h="100%" align="center" justify="center" direction="column" gap={4} opacity={0.3}>
|
||||||
|
<TerminalIcon size={48} />
|
||||||
|
<Text fontSize="sm">Awaiting agent activation...</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Node Inspector Drawer */}
|
||||||
|
<Drawer isOpen={isOpen} placement="right" onClose={onClose} size="md">
|
||||||
|
<DrawerOverlay backdropFilter="blur(4px)" />
|
||||||
|
<DrawerContent bg="slate.900" color="white" borderLeft="1px solid" borderColor="whiteAlpha.200">
|
||||||
|
<DrawerHeader borderBottomWidth="1px" borderColor="whiteAlpha.100">
|
||||||
|
Node Inspector: {selectedNode?.agent}
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerBody>
|
||||||
|
{/* Inspector content would go here */}
|
||||||
|
<Text>Detailed metrics and raw JSON responses for the selected node.</Text>
|
||||||
|
</DrawerBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import React, { useMemo, useEffect } from 'react';
|
||||||
|
import ReactFlow, {
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
NodeProps,
|
||||||
|
} from 'reactflow';
|
||||||
|
import 'reactflow/dist/style.css';
|
||||||
|
import { Box, Text, Flex, Icon, Tooltip, Badge } from '@chakra-ui/react';
|
||||||
|
import { Cpu, Tool, Database, TrendingUp, Clock } from 'lucide-react';
|
||||||
|
import { AgentEvent } from '../hooks/useAgentStream';
|
||||||
|
|
||||||
|
// --- Custom Agent Node Component ---
|
||||||
|
const AgentNode = ({ data }: NodeProps) => {
|
||||||
|
const getIcon = (agent: string) => {
|
||||||
|
switch (agent.toUpperCase()) {
|
||||||
|
case 'ANALYST': return Cpu;
|
||||||
|
case 'RESEARCHER': return Database;
|
||||||
|
case 'TRADER': return TrendingUp;
|
||||||
|
default: return Tool;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running': return 'cyan.400';
|
||||||
|
case 'completed': return 'green.400';
|
||||||
|
case 'error': return 'red.400';
|
||||||
|
default: return 'whiteAlpha.500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="slate.900"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={getStatusColor(data.status)}
|
||||||
|
p={3}
|
||||||
|
borderRadius="lg"
|
||||||
|
minW="180px"
|
||||||
|
boxShadow="0 0 15px rgba(0,0,0,0.5)"
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} />
|
||||||
|
|
||||||
|
<Flex direction="column" gap={2}>
|
||||||
|
<Flex align="center" gap={2}>
|
||||||
|
<Icon as={getIcon(data.agent)} color={getStatusColor(data.status)} boxSize={4} />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="white">{data.agent}</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Box height="1px" bg="whiteAlpha.200" width="100%" />
|
||||||
|
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Flex align="center" gap={1}>
|
||||||
|
<Icon as={Clock} boxSize={3} color="whiteAlpha.500" />
|
||||||
|
<Text fontSize="2xs" color="whiteAlpha.600">{data.metrics?.latency_ms || 0}ms</Text>
|
||||||
|
</Flex>
|
||||||
|
{data.metrics?.model && (
|
||||||
|
<Badge variant="outline" fontSize="2xs" colorScheme="blue">{data.metrics.model}</Badge>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{data.status === 'running' && (
|
||||||
|
<Box width="100%" height="2px" bg="cyan.400" borderRadius="full" overflow="hidden">
|
||||||
|
<Box
|
||||||
|
as="div"
|
||||||
|
width="40%"
|
||||||
|
height="100%"
|
||||||
|
bg="white"
|
||||||
|
sx={{
|
||||||
|
animation: "shimmer 2s infinite linear",
|
||||||
|
"@keyframes shimmer": {
|
||||||
|
"0%": { transform: "translateX(-100%)" },
|
||||||
|
"100%": { transform: "translateX(300%)" }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Handle type="source" position={Position.Bottom} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
agentNode: AgentNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AgentGraphProps {
|
||||||
|
events: AgentEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentGraph: React.FC<AgentGraphProps> = ({ events }) => {
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const graphNodes: Node[] = [];
|
||||||
|
const graphEdges: Edge[] = [];
|
||||||
|
const seenNodes = new Set<string>();
|
||||||
|
|
||||||
|
events.forEach((evt) => {
|
||||||
|
if (!evt.node_id) return;
|
||||||
|
|
||||||
|
if (!seenNodes.has(evt.node_id)) {
|
||||||
|
graphNodes.push({
|
||||||
|
id: evt.node_id,
|
||||||
|
type: 'agentNode',
|
||||||
|
position: { x: 250, y: graphNodes.length * 150 + 50 }, // Simple vertical layout
|
||||||
|
data: {
|
||||||
|
agent: evt.agent,
|
||||||
|
status: evt.type === 'result' ? 'completed' : 'running',
|
||||||
|
metrics: evt.metrics
|
||||||
|
},
|
||||||
|
});
|
||||||
|
seenNodes.add(evt.node_id);
|
||||||
|
|
||||||
|
if (evt.parent_node_id && evt.parent_node_id !== 'start') {
|
||||||
|
graphEdges.push({
|
||||||
|
id: `e-${evt.parent_node_id}-${evt.node_id}`,
|
||||||
|
source: evt.parent_node_id,
|
||||||
|
target: evt.node_id,
|
||||||
|
animated: true,
|
||||||
|
style: { stroke: '#4fd1c5' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existing node status and metrics
|
||||||
|
const idx = graphNodes.findIndex(n => n.id === evt.node_id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
graphNodes[idx].data = {
|
||||||
|
...graphNodes[idx].data,
|
||||||
|
status: evt.type === 'result' ? 'completed' : 'running',
|
||||||
|
metrics: evt.metrics || graphNodes[idx].data.metrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: graphNodes, edges: graphEdges };
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box height="100%" width="100%" bg="slate.950">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
>
|
||||||
|
<Background color="#333" gap={16} />
|
||||||
|
<Controls />
|
||||||
|
</ReactFlow>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Flex, Text, Stat, StatLabel, StatNumber, StatHelpText, StatArrow, Badge, Icon } from '@chakra-ui/react';
|
||||||
|
import { Activity, ShieldAlert, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export const MetricHeader: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Flex bg="slate.900" borderBottom="1px solid" borderColor="whiteAlpha.200" p={4} gap={6} align="center" width="100%">
|
||||||
|
{/* Metric 1: Sharpe Ratio */}
|
||||||
|
<Box flex="1" bg="whiteAlpha.50" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
|
||||||
|
<Flex align="center" gap={2} mb={1}>
|
||||||
|
<Icon as={TrendingUp} color="green.400" boxSize={4} />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" textTransform="uppercase">Sharpe Ratio (30d)</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex align="baseline" gap={2}>
|
||||||
|
<Text fontSize="2xl" fontWeight="black" color="white">2.42</Text>
|
||||||
|
<Badge colorScheme="green" variant="subtle" fontSize="2xs">High Efficiency</Badge>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Metric 2: Market Regime */}
|
||||||
|
<Box flex="1" bg="whiteAlpha.50" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
|
||||||
|
<Flex align="center" gap={2} mb={1}>
|
||||||
|
<Icon as={Activity} color="cyan.400" boxSize={4} />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" textTransform="uppercase">Market Regime</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex align="baseline" gap={2}>
|
||||||
|
<Text fontSize="2xl" fontWeight="black" color="cyan.400">BULL</Text>
|
||||||
|
<Text fontSize="xs" color="whiteAlpha.500">Beta: 1.15</Text>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Metric 3: Risk / Drawdown */}
|
||||||
|
<Box flex="1" bg="whiteAlpha.50" p={3} borderRadius="md" border="1px solid" borderColor="whiteAlpha.100">
|
||||||
|
<Flex align="center" gap={2} mb={1}>
|
||||||
|
<Icon as={ShieldAlert} color="red.400" boxSize={4} />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="whiteAlpha.600" textTransform="uppercase">Risk / Drawdown</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex align="baseline" gap={2}>
|
||||||
|
<Text fontSize="2xl" fontWeight="black" color="red.400">-2.4%</Text>
|
||||||
|
<Text fontSize="xs" color="whiteAlpha.500">VaR (1d): $4.2k</Text>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { extendTheme, type ThemeConfig } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
const config: ThemeConfig = {
|
||||||
|
initialColorMode: 'dark',
|
||||||
|
useSystemColorMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = extendTheme({
|
||||||
|
config,
|
||||||
|
colors: {
|
||||||
|
slate: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
global: {
|
||||||
|
body: {
|
||||||
|
bg: 'slate.950',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default theme;
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue