Skip to content

Commit 41bf5b9

Browse files
authored
feat(tarko): add event stream viewer (#1374)
1 parent 1e14512 commit 41bf5b9

File tree

12 files changed

+333
-11
lines changed

12 files changed

+333
-11
lines changed

multimodal/agent-tars/core/src/webui-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,7 @@ export const AGENT_TARS_WEBUI_CONFIG: AgentWebUIImplementation = {
146146
defaultLayout: 'narrow-chat',
147147
enableLayoutSwitchButton: true,
148148
},
149+
debug: {
150+
enableEventStreamViewer: true,
151+
},
149152
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from 'react';
2+
3+
interface JsonRendererProps {
4+
data: unknown;
5+
className?: string;
6+
}
7+
8+
export const JsonRenderer: React.FC<JsonRendererProps> = ({ data, className = '' }) => {
9+
return (
10+
<pre
11+
className={`overflow-auto rounded bg-gray-950 text-gray-300 font-mono text-xs border border-gray-800 ${className}`}
12+
>
13+
{JSON.stringify(data, null, 2)}
14+
</pre>
15+
);
16+
};

multimodal/tarko/agent-ui/src/common/state/actions/eventProcessors/EventHandlerRegistry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { EventHandler } from './types';
22
import { AgentEventStream } from '@/common/types';
3+
import { isEventStreamViewerEnabled } from '@/config/web-ui-config';
34
import {
45
UserMessageHandler,
56
AssistantMessageHandler,
@@ -17,6 +18,8 @@ import { SystemMessageHandler, EnvironmentInputHandler } from './handlers/System
1718

1819
import { AgentRunStartHandler, AgentRunEndHandler } from './handlers/AgentRunHandler';
1920

21+
import { RawEventsHandler } from './handlers/RawEventsHandler';
22+
2023
/**
2124
* Event handler registry manages all event handlers
2225
*/
@@ -49,6 +52,11 @@ export class EventHandlerRegistry {
4952
// Agent run handlers
5053
this.register(new AgentRunStartHandler());
5154
this.register(new AgentRunEndHandler());
55+
56+
// Raw events handler (only register if event stream viewer is enabled)
57+
if (isEventStreamViewerEnabled()) {
58+
this.register(new RawEventsHandler());
59+
}
5260
}
5361

5462
/**
@@ -65,6 +73,13 @@ export class EventHandlerRegistry {
6573
return this.handlers.find((handler) => handler.canHandle(event)) || null;
6674
}
6775

76+
/**
77+
* Find all handlers that can handle an event
78+
*/
79+
findAllHandlers(event: AgentEventStream.Event): EventHandler[] {
80+
return this.handlers.filter((handler) => handler.canHandle(event));
81+
}
82+
6883
/**
6984
* Get all registered handlers
7085
*/
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { EventHandler, EventHandlerContext } from '../types';
2+
import { rawEventsAtom } from '@/common/state/atoms/rawEvents';
3+
import { AgentEventStream } from '@/common/types';
4+
5+
/**
6+
* Handler for storing all raw events in the rawEventsAtom
7+
* This enables the Event Stream Viewer to show real-time events
8+
*/
9+
export class RawEventsHandler implements EventHandler {
10+
canHandle(event: AgentEventStream.Event): event is AgentEventStream.Event {
11+
// Handle all event types
12+
return true;
13+
}
14+
15+
async handle(
16+
context: EventHandlerContext,
17+
sessionId: string,
18+
event: AgentEventStream.Event,
19+
): Promise<void> {
20+
const { get, set } = context;
21+
22+
// Get current raw events
23+
const currentRawEvents = get(rawEventsAtom);
24+
25+
// Initialize session events if not exists
26+
const sessionEvents = currentRawEvents[sessionId] || [];
27+
28+
// Add new event to the session
29+
const updatedSessionEvents = [...sessionEvents, event];
30+
31+
// Update the atom with new events
32+
set(rawEventsAtom, {
33+
...currentRawEvents,
34+
[sessionId]: updatedSessionEvents,
35+
});
36+
37+
console.log(`[RawEventsHandler] Added ${event.type} event to session ${sessionId}`);
38+
}
39+
}

multimodal/tarko/agent-ui/src/common/state/actions/eventProcessors/index.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,21 @@ export const processEventAction = atom(null, async (get, set, params: EventProce
2323
}
2424
}
2525

26-
const handler = eventHandlerRegistry.findHandler(event);
27-
28-
if (handler) {
29-
try {
30-
await handler.handle(context, sessionId, event);
31-
} catch (error) {
32-
console.error(`Error handling event ${event.type}:`, error);
33-
}
26+
// Find and execute all appropriate handlers
27+
const handlers = eventHandlerRegistry.findAllHandlers(event);
28+
29+
if (handlers.length > 0) {
30+
// Execute all handlers in parallel
31+
const handlerPromises = handlers.map(async (handler) => {
32+
try {
33+
await handler.handle(context, sessionId, event);
34+
} catch (error) {
35+
console.error(`Error in handler for event ${event.type}:`, error);
36+
// Continue processing to avoid breaking the event stream
37+
}
38+
});
39+
40+
await Promise.all(handlerPromises);
3441
} else {
3542
console.warn(`No handler found for event type: ${event.type}`);
3643
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { atom } from 'jotai';
2+
3+
/**
4+
* Event Stream Modal visibility state
5+
*/
6+
export const eventStreamModalOpenAtom = atom<boolean>(false);

multimodal/tarko/agent-ui/src/config/web-ui-config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,21 @@ export function isSidebarEnabled(): boolean {
150150
export function isHomeEnabled(): boolean {
151151
return getLayoutConfig().enableHome ?? true;
152152
}
153+
154+
/**
155+
* Get debug configuration from web UI config
156+
*/
157+
export function getDebugConfig() {
158+
return (
159+
getWebUIConfig().debug || {
160+
enableEventStreamViewer: false,
161+
}
162+
);
163+
}
164+
165+
/**
166+
* Check if Event Stream Viewer is enabled
167+
*/
168+
export function isEventStreamViewerEnabled(): boolean {
169+
return getDebugConfig().enableEventStreamViewer ?? false;
170+
}

multimodal/tarko/agent-ui/src/standalone/app/Layout/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import {
1111
closeMobileBottomSheetAtom,
1212
toggleMobileBottomSheetFullscreenAtom,
1313
} from '@/common/state/atoms/ui';
14+
import { eventStreamModalOpenAtom } from '@/common/state/atoms/eventStreamModal';
15+
import { EventStreamModal } from '@/standalone/modals/EventStreamModal';
16+
import { isEventStreamViewerEnabled } from '@/config/web-ui-config';
1417
import { Shell } from './Shell';
1518
import { MobileBottomSheet } from './MobileBottomSheet';
1619
import './Layout.css';
@@ -27,6 +30,8 @@ export const Layout: React.FC<LayoutProps> = ({ isReplayMode: propIsReplayMode }
2730
const mobileBottomSheet = useAtomValue(mobileBottomSheetAtom);
2831
const closeMobileBottomSheet = useSetAtom(closeMobileBottomSheetAtom);
2932
const toggleMobileBottomSheetFullscreen = useSetAtom(toggleMobileBottomSheetFullscreenAtom);
33+
const [isEventStreamModalOpen, setIsEventStreamModalOpen] = useAtom(eventStreamModalOpenAtom);
34+
const enableEventStreamViewer = isEventStreamViewerEnabled();
3035

3136
const isReplayMode = propIsReplayMode !== undefined ? propIsReplayMode : contextIsReplayMode;
3237

@@ -82,6 +87,14 @@ export const Layout: React.FC<LayoutProps> = ({ isReplayMode: propIsReplayMode }
8287
onClose={closeMobileBottomSheet}
8388
onToggleFullscreen={toggleMobileBottomSheetFullscreen}
8489
/>
90+
91+
{/* Event Stream Modal */}
92+
{enableEventStreamViewer && (
93+
<EventStreamModal
94+
isOpen={isEventStreamModalOpen}
95+
onClose={() => setIsEventStreamModalOpen(false)}
96+
/>
97+
)}
8598
</div>
8699
);
87100
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, { useEffect, useRef, useState, useMemo } from 'react';
2+
import { useAtom } from 'jotai';
3+
import { Dialog, DialogPanel } from '@tarko/ui';
4+
import { FiX, FiChevronRight, FiChevronDown, FiFilter } from 'react-icons/fi';
5+
import { rawEventsAtom } from '@/common/state/atoms/rawEvents';
6+
import { useSession } from '@/common/hooks/useSession';
7+
import { JsonRenderer } from '@/common/components/JsonRenderer';
8+
import { AgentEventStream } from '@/common/types';
9+
10+
interface EventStreamModalProps {
11+
isOpen: boolean;
12+
onClose: () => void;
13+
}
14+
15+
interface EventItemProps {
16+
event: AgentEventStream.Event;
17+
index: number;
18+
}
19+
20+
const EventItem: React.FC<EventItemProps> = ({ event, index }) => {
21+
const [isExpanded, setIsExpanded] = useState(false);
22+
23+
const getEventSummary = (event: AgentEventStream.Event): string => {
24+
switch (event.type) {
25+
case 'user_message':
26+
return `User: ${event.content?.slice(0, 50) || 'Message'}...`;
27+
case 'assistant_message':
28+
return `Assistant: ${event.content?.slice(0, 50) || 'Response'}...`;
29+
case 'tool_call':
30+
return `Tool Call: ${event.name || 'Unknown'}`;
31+
case 'tool_result':
32+
return `Tool Result: ${event.error ? 'Error' : 'Completed'}`;
33+
default:
34+
return event.type;
35+
}
36+
};
37+
38+
return (
39+
<div className="border-b border-gray-800">
40+
<button
41+
onClick={() => setIsExpanded(!isExpanded)}
42+
className="w-full flex items-center gap-3 p-4 hover:bg-gray-900 text-left"
43+
>
44+
{isExpanded ? (
45+
<FiChevronDown size={16} className="text-gray-400 flex-shrink-0" />
46+
) : (
47+
<FiChevronRight size={16} className="text-gray-400 flex-shrink-0" />
48+
)}
49+
<div className="flex-1 min-w-0">
50+
<div className="flex items-center gap-3 mb-1">
51+
<span className="text-yellow-400 font-mono text-sm">
52+
[{new Date(event.timestamp).toISOString()}]
53+
</span>
54+
<span className="text-blue-400 font-mono text-sm">{event.type}</span>
55+
</div>
56+
<div className="text-gray-300 text-sm truncate">{getEventSummary(event)}</div>
57+
</div>
58+
</button>
59+
60+
{isExpanded && <JsonRenderer data={event} className="text-sm p-3" />}
61+
</div>
62+
);
63+
};
64+
65+
export const EventStreamModal: React.FC<EventStreamModalProps> = ({ isOpen, onClose }) => {
66+
const [rawEvents] = useAtom(rawEventsAtom);
67+
const { activeSessionId } = useSession();
68+
const scrollRef = useRef<HTMLDivElement>(null);
69+
const [selectedFilter, setSelectedFilter] = useState<string>('all');
70+
71+
const currentSessionEvents = activeSessionId ? rawEvents[activeSessionId] || [] : [];
72+
73+
// Get unique event types for filter
74+
const eventTypes = useMemo(() => {
75+
const types = new Set(currentSessionEvents.map((event) => event.type));
76+
return Array.from(types).sort();
77+
}, [currentSessionEvents]);
78+
79+
// Filter events based on selected filter
80+
const filteredEvents = useMemo(() => {
81+
if (selectedFilter === 'all') {
82+
return currentSessionEvents;
83+
}
84+
return currentSessionEvents.filter((event) => event.type === selectedFilter);
85+
}, [currentSessionEvents, selectedFilter]);
86+
87+
// Auto-scroll to bottom when new events arrive
88+
useEffect(() => {
89+
if (scrollRef.current) {
90+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
91+
}
92+
}, [filteredEvents.length]);
93+
94+
return (
95+
<Dialog open={isOpen} onClose={onClose} fullScreen>
96+
<DialogPanel className="w-full h-full bg-black text-white flex flex-col">
97+
{/* Header */}
98+
<div className="flex items-center justify-between p-4 bg-gray-900 border-b border-gray-700">
99+
<div className="flex items-center gap-4">
100+
<h2 className="text-lg font-mono">Event Stream Debug</h2>
101+
<span className="text-sm text-gray-400">
102+
Session: {activeSessionId || 'None'} | Events: {filteredEvents.length}/
103+
{currentSessionEvents.length}
104+
</span>
105+
</div>
106+
107+
<div className="flex items-center gap-4">
108+
{/* Filter */}
109+
<div className="flex items-center gap-2">
110+
<FiFilter size={16} className="text-gray-400" />
111+
<select
112+
value={selectedFilter}
113+
onChange={(e) => setSelectedFilter(e.target.value)}
114+
className="bg-gray-800 text-white border border-gray-600 rounded px-3 py-1 text-sm font-mono focus:outline-none focus:border-blue-500"
115+
>
116+
<option value="all">All Types</option>
117+
{eventTypes.map((type) => (
118+
<option key={type} value={type}>
119+
{type}
120+
</option>
121+
))}
122+
</select>
123+
</div>
124+
125+
<button onClick={onClose} className="p-2 hover:bg-gray-800 rounded transition-colors">
126+
<FiX size={20} />
127+
</button>
128+
</div>
129+
</div>
130+
131+
{/* Content */}
132+
<div ref={scrollRef} className="flex-1 overflow-auto">
133+
{filteredEvents.length === 0 ? (
134+
<div className="flex items-center justify-center h-full">
135+
<div className="text-gray-500 font-mono">
136+
{currentSessionEvents.length === 0
137+
? 'No events yet...'
138+
: `No events of type "${selectedFilter}"`}
139+
</div>
140+
</div>
141+
) : (
142+
<div>
143+
{filteredEvents.map((event, index) => (
144+
<EventItem key={`${event.timestamp}-${index}`} event={event} index={index} />
145+
))}
146+
</div>
147+
)}
148+
</div>
149+
</DialogPanel>
150+
</Dialog>
151+
);
152+
};

0 commit comments

Comments
 (0)