diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a058a82..9115f5b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -95,5 +95,5 @@ jobs:
run: |
if [ -f packages/ui/package.json ]; then
cd packages/ui
- npm run build || true
+ npm run build
fi
diff --git a/packages/ui/app/globals.css b/packages/ui/app/globals.css
new file mode 100644
index 0000000..6152f1a
--- /dev/null
+++ b/packages/ui/app/globals.css
@@ -0,0 +1,72 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 240 10% 3.9%;
+ --foreground: 240 5% 96%;
+
+ --card: 240 10% 6%;
+ --card-foreground: 240 5% 96%;
+
+ --popover: 240 10% 3.9%;
+ --popover-foreground: 240 5% 96%;
+
+ --primary: 263 70% 50%;
+ --primary-foreground: 210 20% 98%;
+
+ --secondary: 240 4% 16%;
+ --secondary-foreground: 240 5% 96%;
+
+ --muted: 240 4% 16%;
+ --muted-foreground: 240 5% 65%;
+
+ --accent: 263 60% 40%;
+ --accent-foreground: 240 5% 96%;
+
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 210 20% 98%;
+
+ --border: 240 4% 16%;
+ --input: 240 4% 16%;
+ --ring: 263 70% 50%;
+ --radius: 0.75rem;
+ }
+}
+
+body {
+ background-color: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ overflow-x: hidden;
+}
+
+/* Custom scrollbars for dashboard views */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: hsl(var(--border));
+ border-radius: 9999px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: hsl(var(--muted-foreground));
+}
+
+/* Premium Card Glass Effects */
+.glass-panel {
+ background: rgba(15, 15, 23, 0.65);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+}
+.glass-panel-hover:hover {
+ border-color: rgba(147, 51, 234, 0.2);
+ box-shadow: 0 0 15px rgba(147, 51, 234, 0.05);
+ transition: all 0.2s ease-in-out;
+}
diff --git a/packages/ui/app/layout.tsx b/packages/ui/app/layout.tsx
new file mode 100644
index 0000000..e13ea09
--- /dev/null
+++ b/packages/ui/app/layout.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "AgentScope Observability Dashboard",
+ description: "Real-time multi-agent telemetry and monitoring dashboard",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/packages/ui/app/page.tsx b/packages/ui/app/page.tsx
new file mode 100644
index 0000000..b553a12
--- /dev/null
+++ b/packages/ui/app/page.tsx
@@ -0,0 +1,557 @@
+"use client";
+
+import React, { useEffect, useState, useRef } from "react";
+import { useSessionStore } from "../store/sessionStore";
+import { Session, AgentEvent, ReactFlowNode, ReactFlowEdge } from "../types";
+import {
+ Activity,
+ Layers,
+ Cpu,
+ Wrench,
+ Database,
+ ChevronDown,
+ ChevronUp,
+ DollarSign,
+ Clock,
+ AlertTriangle,
+ Play,
+ CheckCircle,
+ XCircle,
+ HelpCircle,
+ RefreshCw,
+ Search,
+} from "lucide-react";
+
+export default function Dashboard() {
+ const {
+ sessions,
+ activeSession,
+ events,
+ setSessions,
+ setActiveSession,
+ setEvents,
+ addEvent,
+ updateSessionMeta,
+ } = useSessionStore();
+
+ const [graphData, setGraphData] = useState<{
+ nodes: ReactFlowNode[];
+ edges: ReactFlowEdge[];
+ } | null>(null);
+ const [statsData, setStatsData] = useState(null);
+ const [expandedEvents, setExpandedEvents] = useState>(
+ {}
+ );
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const eventFeedEndRef = useRef(null);
+
+ // Auto-scroll event feed to bottom
+ useEffect(() => {
+ eventFeedEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [events]);
+
+ // Fetch Session list
+ const fetchSessionsList = async () => {
+ try {
+ const res = await fetch("http://127.0.0.1:8765/api/sessions");
+ if (res.ok) {
+ const data = await res.json();
+ setSessions(data);
+
+ // Auto-select first session if none selected
+ if (data.length > 0 && !activeSession) {
+ handleSelectSession(data[0]);
+ }
+ }
+ } catch (e) {
+ console.warn("Failed to fetch sessions list", e);
+ }
+ };
+
+ useEffect(() => {
+ fetchSessionsList();
+ const interval = setInterval(fetchSessionsList, 4000);
+ return () => clearInterval(interval);
+ }, [activeSession]);
+
+ // Handle active session selection
+ const handleSelectSession = async (session: Session) => {
+ setActiveSession(session);
+ setEvents([]);
+ setGraphData(null);
+ setStatsData(null);
+
+ // Fetch existing events
+ try {
+ const resEvents = await fetch(
+ `http://127.0.0.1:8765/api/sessions/${session.session_id}/events`
+ );
+ if (resEvents.ok) {
+ const eventsData = await resEvents.json();
+ setEvents(eventsData);
+ }
+ } catch (e) {
+ console.warn("Failed to fetch session events", e);
+ }
+
+ // Fetch graph
+ try {
+ const resGraph = await fetch(
+ `http://127.0.0.1:8765/api/sessions/${session.session_id}/graph`
+ );
+ if (resGraph.ok) {
+ const graph = await resGraph.json();
+ setGraphData(graph);
+ }
+ } catch (e) {
+ console.warn("Failed to fetch session graph", e);
+ }
+
+ // Fetch stats
+ try {
+ const resStats = await fetch(
+ `http://127.0.0.1:8765/api/sessions/${session.session_id}/stats`
+ );
+ if (resStats.ok) {
+ const stats = await resStats.json();
+ setStatsData(stats);
+ }
+ } catch (e) {
+ console.warn("Failed to fetch session stats", e);
+ }
+ };
+
+ // WebSocket event stream
+ useEffect(() => {
+ if (!activeSession) return;
+
+ let ws: WebSocket;
+ const connectWS = () => {
+ const host = window.location.hostname || "127.0.0.1";
+ ws = new WebSocket(`ws://${host}:8765/ws?client_type=ui`);
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+ if (message.type === "event" && message.session_id === activeSession.session_id) {
+ addEvent(message.event);
+ // Refresh graph & stats when new terminal event lands
+ if (message.event.event_type.endsWith("_end") || message.event.event_type.endsWith("_error")) {
+ fetchGraphAndStats(activeSession.session_id);
+ }
+ } else if (message.type === "session_update" && message.session_id === activeSession.session_id) {
+ updateSessionMeta(message.session_id, message.meta);
+ }
+ } catch (err) {
+ console.error("Error processing websocket payload:", err);
+ }
+ };
+
+ ws.onclose = () => {
+ setTimeout(connectWS, 2000);
+ };
+ };
+
+ connectWS();
+ return () => ws?.close();
+ }, [activeSession]);
+
+ const fetchGraphAndStats = async (sessionId: string) => {
+ try {
+ const resGraph = await fetch(`http://127.0.0.1:8765/api/sessions/${sessionId}/graph`);
+ if (resGraph.ok) setGraphData(await resGraph.json());
+
+ const resStats = await fetch(`http://127.0.0.1:8765/api/sessions/${sessionId}/stats`);
+ if (resStats.ok) setStatsData(await resStats.json());
+ } catch (e) {
+ console.warn("Refresh stats failed", e);
+ }
+ };
+
+ const toggleExpandEvent = (eventId: string) => {
+ setExpandedEvents((prev) => ({
+ ...prev,
+ [eventId]: !prev[eventId],
+ }));
+ };
+
+ const getEventIcon = (type: string) => {
+ switch (type) {
+ case "chain_start":
+ case "chain_end":
+ case "chain_error":
+ return ;
+ case "llm_start":
+ case "llm_end":
+ case "llm_token":
+ case "llm_error":
+ return ;
+ case "tool_start":
+ case "tool_end":
+ case "tool_error":
+ return ;
+ case "retriever_start":
+ case "retriever_end":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "completed":
+ return (
+
+ Completed
+
+ );
+ case "failed":
+ case "error":
+ return (
+
+ Failed
+
+ );
+ case "running":
+ return (
+
+ Running
+
+ );
+ default:
+ return (
+
+ Unknown
+
+ );
+ }
+ };
+
+ const formatTimestamp = (iso: string) => {
+ if (!iso) return "";
+ const d = new Date(iso);
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ };
+
+ // Filter sessions
+ const filteredSessions = sessions.filter((s) =>
+ s.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ return (
+
+ {/* Sidebar Session List */}
+
+
+ {/* Main Panel */}
+
+ {activeSession ? (
+ <>
+ {/* Top Stats Banner */}
+
+
+ {/* Metrics cards */}
+
+
+
+
+
+
+
+ Total Tokens
+
+
+ {statsData?.total_tokens ?? activeSession.total_tokens}
+
+
+
+
+
+
+
+
+
+
+ Total Cost (USD)
+
+
+ ${(statsData?.total_cost_usd ?? activeSession.total_cost_usd).toFixed(5)}
+
+
+
+
+
+
+
+
+
+
+ Duration (s)
+
+
+ {statsData?.total_duration_ms
+ ? (statsData.total_duration_ms / 1000).toFixed(2)
+ : "0.00"}
+
+
+
+
+
+
+
+
+ Errors
+
+
+ {statsData?.error_count ?? activeSession.error_count}
+
+
+
+
+
+
+
+
+
+
+ Active Agents
+
+
+ {statsData?.agents?.length ?? activeSession.agent_count}
+
+
+
+
+
+ {/* Split Screen Workspace */}
+
+ {/* Chronological Event Feed */}
+
+
+
+ Live Execution Feed
+
+
+ {events.length} Events Received
+
+
+
+
+ {events.map((event) => {
+ const isExpanded = !!expandedEvents[event.event_id];
+ return (
+
+
toggleExpandEvent(event.event_id)}
+ className="p-3 flex items-center justify-between cursor-pointer hover:bg-secondary/20 transition-colors"
+ >
+
+
+ {getEventIcon(event.event_type)}
+
+
+
+
+ {event.agent_name || "System"}
+
+
+ {event.event_type}
+
+
+
+ {formatTimestamp(event.timestamp)}
+
+
+
+
+ {getStatusBadge(event.status)}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Expandable Details Drawer */}
+ {isExpanded && (
+
+ {event.payload && (
+
+ {Object.entries(event.payload).map(([k, v]) => {
+ if (v === null || v === undefined) return null;
+ return (
+
+ {k}:
+
+ {typeof v === "object" ? JSON.stringify(v, null, 2) : String(v)}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+
+
+ {/* Agent Call Graph Visualization */}
+
+
+
+ Agent Call Hierarchy
+
+
+
+
+ {graphData && graphData.nodes?.length > 0 ? (
+ graphData.nodes.map((node) => {
+ return (
+
+
+
+
+
{node.data.agentName}
+
+ Type: {node.data.eventType}
+
+
+
+
+
+ {node.data.durationMs ? `${(node.data.durationMs / 1000).toFixed(2)}s` : "0.00s"}
+
+
+ {node.data.tokenCount ? `${node.data.tokenCount} tokens` : ""}
+
+
+
+ );
+ })
+ ) : (
+
+
+
No graph nodes generated for this session yet.
+
+ )}
+
+
+
+ >
+ ) : (
+
+
+
No Active Session Selected
+
+ Select an execution session from the left sidebar list or launch an agent pipeline to start streaming telemetry.
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/ui/next-env.d.ts b/packages/ui/next-env.d.ts
new file mode 100644
index 0000000..40c3d68
--- /dev/null
+++ b/packages/ui/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/packages/ui/next.config.js b/packages/ui/next.config.js
index e2f0e6c..a843cbe 100644
--- a/packages/ui/next.config.js
+++ b/packages/ui/next.config.js
@@ -4,4 +4,3 @@ const nextConfig = {
}
module.exports = nextConfig
-```
diff --git a/packages/ui/store/sessionStore.ts b/packages/ui/store/sessionStore.ts
index a4146e1..5a016ff 100644
--- a/packages/ui/store/sessionStore.ts
+++ b/packages/ui/store/sessionStore.ts
@@ -40,8 +40,8 @@ export const useSessionStore = create((set) => ({
const existing = state.events[existingIndex];
// Merge payloads avoiding empty values
- const mergedPayload = { ...existing.payload };
- const incomingPayload = event.payload || {};
+ const mergedPayload = { ...existing.payload } as Record;
+ const incomingPayload = (event.payload || {}) as Record;
for (const key in incomingPayload) {
if (Object.prototype.hasOwnProperty.call(incomingPayload, key)) {
const val = incomingPayload[key];