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
|
||||
import uuid
|
||||
import time
|
||||
from agent_os.backend.store import runs
|
||||
from agent_os.backend.dependencies import get_current_user
|
||||
from agent_os.backend.services.langgraph_engine import LangGraphEngine
|
||||
|
||||
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()
|
||||
|
||||
@router.post("/scan")
|
||||
|
|
@ -24,7 +22,8 @@ async def trigger_scan(
|
|||
"type": "scan",
|
||||
"status": "queued",
|
||||
"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 {})
|
||||
return {"run_id": run_id, "status": "queued"}
|
||||
|
|
@ -41,7 +40,8 @@ async def trigger_pipeline(
|
|||
"type": "pipeline",
|
||||
"status": "queued",
|
||||
"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 {})
|
||||
return {"run_id": run_id, "status": "queued"}
|
||||
|
|
@ -58,7 +58,8 @@ async def trigger_portfolio(
|
|||
"type": "portfolio",
|
||||
"status": "queued",
|
||||
"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 {})
|
||||
return {"run_id": run_id, "status": "queued"}
|
||||
|
|
@ -75,7 +76,8 @@ async def trigger_auto(
|
|||
"type": "auto",
|
||||
"status": "queued",
|
||||
"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 {})
|
||||
return {"run_id": run_id, "status": "queued"}
|
||||
|
|
|
|||
|
|
@ -4,106 +4,53 @@ import time
|
|||
import uuid
|
||||
from typing import Dict, Any
|
||||
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"])
|
||||
|
||||
engine = LangGraphEngine()
|
||||
|
||||
@router.websocket("/stream/{run_id}")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
run_id: str,
|
||||
# user: dict = Depends(get_current_user) # In V2, validate token from query string
|
||||
):
|
||||
await websocket.accept()
|
||||
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:
|
||||
# For now, we use a mock stream.
|
||||
# In a real implementation, this would subscribe to an event queue or a database stream
|
||||
# that's being populated by the BackgroundTask running the LangGraph.
|
||||
stream_gen = None
|
||||
if run_type == "scan":
|
||||
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 = [
|
||||
{
|
||||
"id": "node_1",
|
||||
"node_id": "analyst_node",
|
||||
"parent_node_id": "start",
|
||||
"type": "thought",
|
||||
"agent": "ANALYST",
|
||||
"message": "Evaluating market data...",
|
||||
"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
|
||||
if stream_gen:
|
||||
async for payload in stream_gen:
|
||||
# Add timestamp if not present
|
||||
if "timestamp" not in payload:
|
||||
payload["timestamp"] = time.strftime("%H:%M:%S")
|
||||
await websocket.send_json(payload)
|
||||
else:
|
||||
await websocket.send_json({"type": "system", "message": f"Error: Run type {run_type} streaming not yet implemented."})
|
||||
|
||||
await websocket.send_json({"type": "system", "message": "Run completed."})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
print(f"WebSocket client disconnected from run {run_id}")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await websocket.send_json({"type": "system", "message": f"Error: {str(e)}"})
|
||||
await websocket.close()
|
||||
|
|
|
|||
|
|
@ -1,35 +1,135 @@
|
|||
import asyncio
|
||||
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:
|
||||
"""Orchestrates LangGraph pipeline executions for the AgentOS API."""
|
||||
"""Orchestrates LangGraph pipeline executions and streams events."""
|
||||
|
||||
def __init__(self):
|
||||
# This is where you would import and setup your LangGraph workflows
|
||||
# e.g., from tradingagents.graph.setup import setup_trading_graph
|
||||
pass
|
||||
self.config = DEFAULT_CONFIG.copy()
|
||||
# In-memory store to keep track of running tasks if needed
|
||||
self.active_runs = {}
|
||||
|
||||
async def run_scan(self, run_id: str, params: Dict[str, Any]):
|
||||
print(f"Engine: Starting SCAN {run_id} with params {params}")
|
||||
# Placeholder for actual scanner graph execution
|
||||
await asyncio.sleep(15)
|
||||
print(f"Engine: SCAN {run_id} completed")
|
||||
async def run_scan(self, run_id: str, params: Dict[str, Any]) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""Run the 3-phase macro scanner and stream events."""
|
||||
date = params.get("date", time.strftime("%Y-%m-%d"))
|
||||
|
||||
# Initialize ScannerGraph
|
||||
# Note: ScannerGraph in TradingAgents seems to take date and config
|
||||
scanner = ScannerGraph(date=date, config=self.config)
|
||||
|
||||
print(f"Engine: Starting SCAN {run_id} for date {date}")
|
||||
|
||||
# Initial state for scanner
|
||||
# Based on tradingagents/graph/scanner_graph.py
|
||||
initial_state = {
|
||||
"date": date,
|
||||
"geopolitical_report": "",
|
||||
"market_movers_report": "",
|
||||
"sector_report": "",
|
||||
"industry_deep_dive_report": "",
|
||||
"macro_synthesis_report": "",
|
||||
"top_10_watchlist": []
|
||||
}
|
||||
|
||||
async def run_pipeline(self, run_id: str, params: Dict[str, Any]):
|
||||
print(f"Engine: Starting PIPELINE {run_id} with params {params}")
|
||||
# Placeholder for actual analysis pipeline execution
|
||||
await asyncio.sleep(20)
|
||||
print(f"Engine: PIPELINE {run_id} completed")
|
||||
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_portfolio(self, run_id: str, params: Dict[str, Any]):
|
||||
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_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
|
||||
|
||||
async def run_auto(self, run_id: str, params: Dict[str, Any]):
|
||||
print(f"Engine: Starting AUTO {run_id} with params {params}")
|
||||
# Placeholder for full automated trading cycle
|
||||
await asyncio.sleep(30)
|
||||
print(f"Engine: AUTO {run_id} completed")
|
||||
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