NeerajCodz commited on
Commit
0cfd364
Β·
1 Parent(s): f4a05c4

feat: implement React dashboard with components and hooks

Browse files
frontend/src/App.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { BrowserRouter, Routes, Route } from 'react-router-dom';
3
+ import Dashboard from './components/Dashboard';
4
+ import Settings from './components/Settings';
5
+
6
+ const queryClient = new QueryClient({
7
+ defaultOptions: {
8
+ queries: {
9
+ staleTime: 5000,
10
+ refetchOnWindowFocus: false,
11
+ },
12
+ },
13
+ });
14
+
15
+ function App() {
16
+ return (
17
+ <QueryClientProvider client={queryClient}>
18
+ <BrowserRouter>
19
+ <div className="min-h-screen bg-gray-950 text-gray-100">
20
+ <Routes>
21
+ <Route path="/" element={<Dashboard />} />
22
+ <Route path="/settings" element={<Settings />} />
23
+ </Routes>
24
+ </div>
25
+ </BrowserRouter>
26
+ </QueryClientProvider>
27
+ );
28
+ }
29
+
30
+ export default App;
frontend/src/api/client.ts ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ Episode,
3
+ Observation,
4
+ Action,
5
+ Reward,
6
+ Agent,
7
+ MemoryState,
8
+ MCPTool,
9
+ SystemSettings,
10
+ APIResponse,
11
+ StepRequest,
12
+ ResetRequest,
13
+ EpisodeStats,
14
+ } from '@/types';
15
+
16
+ const API_BASE = '/api';
17
+
18
+ class APIError extends Error {
19
+ constructor(
20
+ message: string,
21
+ public status: number,
22
+ public data?: unknown
23
+ ) {
24
+ super(message);
25
+ this.name = 'APIError';
26
+ }
27
+ }
28
+
29
+ async function request<T>(
30
+ endpoint: string,
31
+ options: RequestInit = {}
32
+ ): Promise<T> {
33
+ const url = `${API_BASE}${endpoint}`;
34
+ const headers: HeadersInit = {
35
+ 'Content-Type': 'application/json',
36
+ ...options.headers,
37
+ };
38
+
39
+ const response = await fetch(url, {
40
+ ...options,
41
+ headers,
42
+ });
43
+
44
+ const data = await response.json() as APIResponse<T>;
45
+
46
+ if (!response.ok) {
47
+ throw new APIError(
48
+ data.error ?? 'An error occurred',
49
+ response.status,
50
+ data
51
+ );
52
+ }
53
+
54
+ if (!data.success) {
55
+ throw new APIError(data.error ?? 'Request failed', response.status, data);
56
+ }
57
+
58
+ return data.data as T;
59
+ }
60
+
61
+ export const apiClient = {
62
+ // Episode Management
63
+ async resetEpisode(params: ResetRequest): Promise<Episode> {
64
+ return request<Episode>('/episode/reset', {
65
+ method: 'POST',
66
+ body: JSON.stringify(params),
67
+ });
68
+ },
69
+
70
+ async getEpisode(episodeId: string): Promise<Episode> {
71
+ return request<Episode>(`/episode/${episodeId}`);
72
+ },
73
+
74
+ async getCurrentEpisode(): Promise<Episode | null> {
75
+ try {
76
+ return await request<Episode>('/episode/current');
77
+ } catch (error) {
78
+ if (error instanceof APIError && error.status === 404) {
79
+ return null;
80
+ }
81
+ throw error;
82
+ }
83
+ },
84
+
85
+ async stepEpisode(episodeId: string, step: StepRequest): Promise<{
86
+ observation: Observation;
87
+ reward: Reward;
88
+ done: boolean;
89
+ info: Record<string, unknown>;
90
+ }> {
91
+ return request(`/episode/${episodeId}/step`, {
92
+ method: 'POST',
93
+ body: JSON.stringify(step),
94
+ });
95
+ },
96
+
97
+ async terminateEpisode(episodeId: string): Promise<Episode> {
98
+ return request<Episode>(`/episode/${episodeId}/terminate`, {
99
+ method: 'POST',
100
+ });
101
+ },
102
+
103
+ // State Queries
104
+ async getState(episodeId: string): Promise<{
105
+ observation: Observation;
106
+ agents: Agent[];
107
+ memory: MemoryState;
108
+ }> {
109
+ return request(`/episode/${episodeId}/state`);
110
+ },
111
+
112
+ async getObservation(episodeId: string, step?: number): Promise<Observation> {
113
+ const query = step !== undefined ? `?step=${step}` : '';
114
+ return request<Observation>(`/episode/${episodeId}/observation${query}`);
115
+ },
116
+
117
+ async getActions(episodeId: string): Promise<Action[]> {
118
+ return request<Action[]>(`/episode/${episodeId}/actions`);
119
+ },
120
+
121
+ async getRewards(episodeId: string): Promise<Reward[]> {
122
+ return request<Reward[]>(`/episode/${episodeId}/rewards`);
123
+ },
124
+
125
+ // Agent Management
126
+ async getAgents(episodeId: string): Promise<Agent[]> {
127
+ return request<Agent[]>(`/episode/${episodeId}/agents`);
128
+ },
129
+
130
+ async getAgent(episodeId: string, agentId: string): Promise<Agent> {
131
+ return request<Agent>(`/episode/${episodeId}/agents/${agentId}`);
132
+ },
133
+
134
+ async updateAgent(
135
+ episodeId: string,
136
+ agentId: string,
137
+ updates: Partial<Agent>
138
+ ): Promise<Agent> {
139
+ return request<Agent>(`/episode/${episodeId}/agents/${agentId}`, {
140
+ method: 'PATCH',
141
+ body: JSON.stringify(updates),
142
+ });
143
+ },
144
+
145
+ // Memory Operations
146
+ async getMemory(episodeId: string): Promise<MemoryState> {
147
+ return request<MemoryState>(`/episode/${episodeId}/memory`);
148
+ },
149
+
150
+ async queryMemory(
151
+ episodeId: string,
152
+ query: string,
153
+ layer?: string,
154
+ limit?: number
155
+ ): Promise<import('@/types').MemoryEntry[]> {
156
+ const params = new URLSearchParams({ query });
157
+ if (layer) params.set('layer', layer);
158
+ if (limit) params.set('limit', limit.toString());
159
+ return request(`/episode/${episodeId}/memory/query?${params}`);
160
+ },
161
+
162
+ async addMemory(
163
+ episodeId: string,
164
+ entry: Omit<import('@/types').MemoryEntry, 'id' | 'timestamp'>
165
+ ): Promise<import('@/types').MemoryEntry> {
166
+ return request(`/episode/${episodeId}/memory`, {
167
+ method: 'POST',
168
+ body: JSON.stringify(entry),
169
+ });
170
+ },
171
+
172
+ async clearMemory(episodeId: string, layer?: string): Promise<void> {
173
+ const query = layer ? `?layer=${layer}` : '';
174
+ return request(`/episode/${episodeId}/memory${query}`, {
175
+ method: 'DELETE',
176
+ });
177
+ },
178
+
179
+ // Tools
180
+ async getTools(): Promise<MCPTool[]> {
181
+ return request<MCPTool[]>('/tools');
182
+ },
183
+
184
+ async getTool(name: string): Promise<MCPTool> {
185
+ return request<MCPTool>(`/tools/${name}`);
186
+ },
187
+
188
+ async executeTool(
189
+ name: string,
190
+ parameters: Record<string, unknown>
191
+ ): Promise<unknown> {
192
+ return request(`/tools/${name}/execute`, {
193
+ method: 'POST',
194
+ body: JSON.stringify(parameters),
195
+ });
196
+ },
197
+
198
+ async toggleTool(name: string, enabled: boolean): Promise<MCPTool> {
199
+ return request<MCPTool>(`/tools/${name}`, {
200
+ method: 'PATCH',
201
+ body: JSON.stringify({ enabled }),
202
+ });
203
+ },
204
+
205
+ // Settings
206
+ async getSettings(): Promise<SystemSettings> {
207
+ return request<SystemSettings>('/settings');
208
+ },
209
+
210
+ async updateSettings(settings: Partial<SystemSettings>): Promise<SystemSettings> {
211
+ return request<SystemSettings>('/settings', {
212
+ method: 'PATCH',
213
+ body: JSON.stringify(settings),
214
+ });
215
+ },
216
+
217
+ // Stats
218
+ async getStats(): Promise<EpisodeStats> {
219
+ return request<EpisodeStats>('/stats');
220
+ },
221
+
222
+ // Health Check
223
+ async healthCheck(): Promise<{ status: string; version: string }> {
224
+ return request('/health');
225
+ },
226
+ };
227
+
228
+ export { APIError };
229
+ export default apiClient;
frontend/src/components/ActionPanel.tsx ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Activity,
4
+ MousePointer,
5
+ Navigation,
6
+ Type,
7
+ Scroll,
8
+ Clock,
9
+ Camera,
10
+ Terminal,
11
+ Users,
12
+ StopCircle,
13
+ ChevronDown,
14
+ ChevronUp,
15
+ Filter,
16
+ } from 'lucide-react';
17
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
18
+ import { Badge } from '@/components/ui/Badge';
19
+ import { Button } from '@/components/ui/Button';
20
+ import { useEpisodeActions, useCurrentEpisode } from '@/hooks/useEpisode';
21
+ import { formatTimestamp, truncateText } from '@/utils/helpers';
22
+ import type { Action, ActionType } from '@/types';
23
+
24
+ interface ActionPanelProps {
25
+ className?: string;
26
+ }
27
+
28
+ const ACTION_ICONS: Record<ActionType, React.ReactNode> = {
29
+ navigate: <Navigation className="w-4 h-4" />,
30
+ click: <MousePointer className="w-4 h-4" />,
31
+ extract: <Terminal className="w-4 h-4" />,
32
+ scroll: <Scroll className="w-4 h-4" />,
33
+ input: <Type className="w-4 h-4" />,
34
+ wait: <Clock className="w-4 h-4" />,
35
+ screenshot: <Camera className="w-4 h-4" />,
36
+ execute_tool: <Terminal className="w-4 h-4" />,
37
+ delegate: <Users className="w-4 h-4" />,
38
+ terminate: <StopCircle className="w-4 h-4" />,
39
+ };
40
+
41
+ const ACTION_COLORS: Record<ActionType, string> = {
42
+ navigate: 'text-blue-400 bg-blue-400/10',
43
+ click: 'text-green-400 bg-green-400/10',
44
+ extract: 'text-purple-400 bg-purple-400/10',
45
+ scroll: 'text-yellow-400 bg-yellow-400/10',
46
+ input: 'text-cyan-400 bg-cyan-400/10',
47
+ wait: 'text-gray-400 bg-gray-400/10',
48
+ screenshot: 'text-pink-400 bg-pink-400/10',
49
+ execute_tool: 'text-orange-400 bg-orange-400/10',
50
+ delegate: 'text-indigo-400 bg-indigo-400/10',
51
+ terminate: 'text-red-400 bg-red-400/10',
52
+ };
53
+
54
+ interface ActionItemProps {
55
+ action: Action;
56
+ index: number;
57
+ isLast: boolean;
58
+ }
59
+
60
+ const ActionItem: React.FC<ActionItemProps> = ({ action, index, isLast }) => {
61
+ const [isExpanded, setIsExpanded] = useState(false);
62
+
63
+ const iconClass = ACTION_COLORS[action.type] ?? 'text-dark-400 bg-dark-700';
64
+
65
+ return (
66
+ <div className="relative">
67
+ {/* Timeline connector */}
68
+ {!isLast && (
69
+ <div className="absolute left-[15px] top-8 bottom-0 w-0.5 bg-dark-700" />
70
+ )}
71
+
72
+ <div
73
+ className="flex gap-3 cursor-pointer hover:bg-dark-700/30 rounded-lg p-2 transition-colors"
74
+ onClick={() => setIsExpanded(!isExpanded)}
75
+ >
76
+ {/* Icon */}
77
+ <div
78
+ className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${iconClass}`}
79
+ >
80
+ {ACTION_ICONS[action.type]}
81
+ </div>
82
+
83
+ {/* Content */}
84
+ <div className="flex-1 min-w-0">
85
+ <div className="flex items-center justify-between gap-2">
86
+ <div className="flex items-center gap-2">
87
+ <Badge variant="neutral" size="sm" className={iconClass}>
88
+ {action.type}
89
+ </Badge>
90
+ <span className="text-xs text-dark-500">#{index + 1}</span>
91
+ </div>
92
+ <div className="flex items-center gap-2">
93
+ <span className="text-xs text-dark-500">
94
+ {formatTimestamp(action.timestamp)}
95
+ </span>
96
+ {isExpanded ? (
97
+ <ChevronUp className="w-3 h-3 text-dark-500" />
98
+ ) : (
99
+ <ChevronDown className="w-3 h-3 text-dark-500" />
100
+ )}
101
+ </div>
102
+ </div>
103
+
104
+ {/* Quick info */}
105
+ <div className="mt-1 text-sm text-dark-300">
106
+ {action.target?.selector && (
107
+ <span className="font-mono text-xs text-dark-400">
108
+ {truncateText(action.target.selector, 40)}
109
+ </span>
110
+ )}
111
+ {action.value && (
112
+ <span className="text-dark-400">
113
+ {' β†’ '}
114
+ {truncateText(action.value, 30)}
115
+ </span>
116
+ )}
117
+ {action.reasoning && !action.target?.selector && !action.value && (
118
+ <span className="text-dark-400 italic">
119
+ {truncateText(action.reasoning, 50)}
120
+ </span>
121
+ )}
122
+ </div>
123
+
124
+ {/* Confidence bar */}
125
+ <div className="mt-1.5 flex items-center gap-2">
126
+ <div className="h-1 flex-1 max-w-[100px] bg-dark-700 rounded-full overflow-hidden">
127
+ <div
128
+ className={`h-full rounded-full ${
129
+ action.confidence > 0.8
130
+ ? 'bg-green-500'
131
+ : action.confidence > 0.5
132
+ ? 'bg-yellow-500'
133
+ : 'bg-red-500'
134
+ }`}
135
+ style={{ width: `${action.confidence * 100}%` }}
136
+ />
137
+ </div>
138
+ <span className="text-xs text-dark-500">
139
+ {(action.confidence * 100).toFixed(0)}%
140
+ </span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ {/* Expanded details */}
146
+ {isExpanded && (
147
+ <div className="ml-11 mt-2 p-3 bg-dark-900/50 rounded-lg space-y-2">
148
+ {action.reasoning && (
149
+ <div>
150
+ <div className="text-xs text-dark-400 mb-1">Reasoning</div>
151
+ <div className="text-sm text-dark-300">{action.reasoning}</div>
152
+ </div>
153
+ )}
154
+
155
+ {action.target && (
156
+ <div>
157
+ <div className="text-xs text-dark-400 mb-1">Target</div>
158
+ <div className="code-block text-xs">
159
+ {JSON.stringify(action.target, null, 2)}
160
+ </div>
161
+ </div>
162
+ )}
163
+
164
+ {action.parameters && Object.keys(action.parameters).length > 0 && (
165
+ <div>
166
+ <div className="text-xs text-dark-400 mb-1">Parameters</div>
167
+ <div className="code-block text-xs">
168
+ {JSON.stringify(action.parameters, null, 2)}
169
+ </div>
170
+ </div>
171
+ )}
172
+
173
+ <div className="flex items-center gap-4 text-xs text-dark-500">
174
+ <span>Agent: {action.agentId}</span>
175
+ <span>Confidence: {(action.confidence * 100).toFixed(1)}%</span>
176
+ </div>
177
+ </div>
178
+ )}
179
+ </div>
180
+ );
181
+ };
182
+
183
+ export const ActionPanel: React.FC<ActionPanelProps> = ({ className }) => {
184
+ const { data: episode } = useCurrentEpisode();
185
+ const { data: actions, isLoading } = useEpisodeActions(episode?.id);
186
+ const [typeFilter, setTypeFilter] = useState<ActionType | null>(null);
187
+ const [showFilters, setShowFilters] = useState(false);
188
+
189
+ const actionTypes = React.useMemo(() => {
190
+ if (!actions) return [];
191
+ return [...new Set(actions.map((a) => a.type))];
192
+ }, [actions]);
193
+
194
+ const filteredActions = React.useMemo(() => {
195
+ if (!actions) return [];
196
+ if (!typeFilter) return actions;
197
+ return actions.filter((a) => a.type === typeFilter);
198
+ }, [actions, typeFilter]);
199
+
200
+ const actionCounts = React.useMemo(() => {
201
+ if (!actions) return {};
202
+ return actions.reduce(
203
+ (acc, a) => {
204
+ acc[a.type] = (acc[a.type] || 0) + 1;
205
+ return acc;
206
+ },
207
+ {} as Record<string, number>
208
+ );
209
+ }, [actions]);
210
+
211
+ return (
212
+ <Card className={className}>
213
+ <CardHeader
214
+ title="Actions"
215
+ subtitle={`${actions?.length ?? 0} total`}
216
+ icon={<Activity className="w-4 h-4" />}
217
+ action={
218
+ <Button
219
+ variant="ghost"
220
+ size="sm"
221
+ onClick={() => setShowFilters(!showFilters)}
222
+ >
223
+ <Filter className="w-4 h-4" />
224
+ </Button>
225
+ }
226
+ />
227
+ <CardContent>
228
+ {/* Filter Bar */}
229
+ {showFilters && (
230
+ <div className="mb-4 p-3 bg-dark-900/50 rounded-lg">
231
+ <div className="text-xs text-dark-400 mb-2">Filter by type</div>
232
+ <div className="flex flex-wrap gap-2">
233
+ <button
234
+ onClick={() => setTypeFilter(null)}
235
+ className={`px-2 py-1 text-xs rounded transition-colors ${
236
+ !typeFilter
237
+ ? 'bg-accent-primary text-white'
238
+ : 'bg-dark-700 text-dark-300 hover:bg-dark-600'
239
+ }`}
240
+ >
241
+ All ({actions?.length ?? 0})
242
+ </button>
243
+ {actionTypes.map((type) => (
244
+ <button
245
+ key={type}
246
+ onClick={() =>
247
+ setTypeFilter(typeFilter === type ? null : type)
248
+ }
249
+ className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
250
+ typeFilter === type
251
+ ? ACTION_COLORS[type]
252
+ : 'bg-dark-700 text-dark-300 hover:bg-dark-600'
253
+ }`}
254
+ >
255
+ {ACTION_ICONS[type]}
256
+ {type} ({actionCounts[type] ?? 0})
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+ )}
262
+
263
+ {/* Action Stats */}
264
+ {actions && actions.length > 0 && (
265
+ <div className="flex flex-wrap gap-2 mb-4">
266
+ {Object.entries(actionCounts)
267
+ .sort((a, b) => b[1] - a[1])
268
+ .slice(0, 5)
269
+ .map(([type, count]) => (
270
+ <Badge
271
+ key={type}
272
+ variant="neutral"
273
+ size="sm"
274
+ className={ACTION_COLORS[type as ActionType]}
275
+ >
276
+ {type}: {count}
277
+ </Badge>
278
+ ))}
279
+ </div>
280
+ )}
281
+
282
+ {/* Action Timeline */}
283
+ <div className="max-h-[400px] overflow-y-auto">
284
+ {isLoading ? (
285
+ <div className="flex items-center justify-center py-8">
286
+ <Activity className="w-6 h-6 text-dark-500 animate-pulse" />
287
+ </div>
288
+ ) : !filteredActions || filteredActions.length === 0 ? (
289
+ <div className="text-center py-8 text-dark-500">
290
+ <Activity className="w-8 h-8 mx-auto mb-2 opacity-50" />
291
+ <p>No actions recorded</p>
292
+ </div>
293
+ ) : (
294
+ <div className="space-y-1">
295
+ {filteredActions
296
+ .slice()
297
+ .reverse()
298
+ .map((action, i, arr) => (
299
+ <ActionItem
300
+ key={`${action.timestamp}-${i}`}
301
+ action={action}
302
+ index={arr.length - 1 - i}
303
+ isLast={i === arr.length - 1}
304
+ />
305
+ ))}
306
+ </div>
307
+ )}
308
+ </div>
309
+
310
+ {/* Current Action Highlight */}
311
+ {actions && actions.length > 0 && (
312
+ <div className="mt-4 pt-4 border-t border-dark-700">
313
+ <div className="text-xs text-dark-400 mb-2">Latest Action</div>
314
+ <div className="bg-dark-900/50 rounded-lg p-3">
315
+ <div className="flex items-center gap-2 mb-1">
316
+ <div
317
+ className={`p-1.5 rounded ${
318
+ ACTION_COLORS[actions[actions.length - 1]!.type]
319
+ }`}
320
+ >
321
+ {ACTION_ICONS[actions[actions.length - 1]!.type]}
322
+ </div>
323
+ <span className="font-medium text-dark-200">
324
+ {actions[actions.length - 1]!.type}
325
+ </span>
326
+ </div>
327
+ {actions[actions.length - 1]!.reasoning && (
328
+ <p className="text-sm text-dark-400 mt-1">
329
+ {actions[actions.length - 1]!.reasoning}
330
+ </p>
331
+ )}
332
+ </div>
333
+ </div>
334
+ )}
335
+ </CardContent>
336
+ </Card>
337
+ );
338
+ };
339
+
340
+ export default ActionPanel;
frontend/src/components/AgentView.tsx ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Users,
4
+ Brain,
5
+ MessageSquare,
6
+ Activity,
7
+ ChevronDown,
8
+ ChevronUp,
9
+ } from 'lucide-react';
10
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
11
+ import { Badge, StatusBadge } from '@/components/ui/Badge';
12
+ import { useAgents, getAgentRoleIcon, getAgentRoleColor } from '@/hooks/useAgents';
13
+ import { useCurrentEpisode } from '@/hooks/useEpisode';
14
+ import { formatTimestamp, truncateText } from '@/utils/helpers';
15
+ import type { Agent, AgentThought } from '@/types';
16
+
17
+ interface AgentViewProps {
18
+ className?: string;
19
+ }
20
+
21
+ interface AgentCardProps {
22
+ agent: Agent;
23
+ isExpanded: boolean;
24
+ onToggle: () => void;
25
+ }
26
+
27
+ const ThoughtBubble: React.FC<{ thought: AgentThought }> = ({ thought }) => {
28
+ const typeColors: Record<string, string> = {
29
+ reasoning: 'border-l-blue-400',
30
+ planning: 'border-l-purple-400',
31
+ observation: 'border-l-green-400',
32
+ decision: 'border-l-orange-400',
33
+ };
34
+
35
+ return (
36
+ <div
37
+ className={`thought-bubble border-l-2 ${typeColors[thought.type] ?? 'border-l-dark-500'}`}
38
+ >
39
+ <div className="flex items-center gap-2 mb-1">
40
+ <Badge variant="neutral" size="sm">
41
+ {thought.type}
42
+ </Badge>
43
+ <span className="text-xs text-dark-500">
44
+ {formatTimestamp(thought.timestamp)}
45
+ </span>
46
+ </div>
47
+ <p className="text-dark-300">{thought.content}</p>
48
+ </div>
49
+ );
50
+ };
51
+
52
+ const AgentCard: React.FC<AgentCardProps> = ({
53
+ agent,
54
+ isExpanded,
55
+ onToggle,
56
+ }) => {
57
+ const roleIcon = getAgentRoleIcon(agent.role);
58
+ const roleColor = getAgentRoleColor(agent.role);
59
+
60
+ return (
61
+ <div className="bg-dark-900/50 rounded-lg overflow-hidden">
62
+ <button
63
+ onClick={onToggle}
64
+ className="w-full p-3 flex items-center justify-between hover:bg-dark-700/30 transition-colors"
65
+ >
66
+ <div className="flex items-center gap-3">
67
+ <div className="text-2xl">{roleIcon}</div>
68
+ <div className="text-left">
69
+ <div className="flex items-center gap-2">
70
+ <span className={`font-medium capitalize ${roleColor}`}>
71
+ {agent.role}
72
+ </span>
73
+ <StatusBadge status={agent.status} size="sm" />
74
+ </div>
75
+ <div className="text-xs text-dark-500">{agent.model}</div>
76
+ </div>
77
+ </div>
78
+ <div className="flex items-center gap-3">
79
+ <div className="text-right text-xs text-dark-400">
80
+ <div>{agent.actionsCount} actions</div>
81
+ <div className={agent.totalReward >= 0 ? 'text-green-400' : 'text-red-400'}>
82
+ {agent.totalReward >= 0 ? '+' : ''}{agent.totalReward.toFixed(2)}
83
+ </div>
84
+ </div>
85
+ {isExpanded ? (
86
+ <ChevronUp className="w-4 h-4 text-dark-500" />
87
+ ) : (
88
+ <ChevronDown className="w-4 h-4 text-dark-500" />
89
+ )}
90
+ </div>
91
+ </button>
92
+
93
+ {isExpanded && (
94
+ <div className="px-3 pb-3 border-t border-dark-700">
95
+ {/* Current Task */}
96
+ {agent.currentTask && (
97
+ <div className="mt-3 p-2 bg-dark-800 rounded">
98
+ <div className="text-xs text-dark-400 mb-1">Current Task</div>
99
+ <div className="text-sm text-dark-200">{agent.currentTask}</div>
100
+ </div>
101
+ )}
102
+
103
+ {/* Last Action */}
104
+ {agent.lastAction && (
105
+ <div className="mt-3 p-2 bg-dark-800 rounded">
106
+ <div className="text-xs text-dark-400 mb-1">Last Action</div>
107
+ <div className="flex items-center gap-2">
108
+ <Badge variant="info" size="sm">
109
+ {agent.lastAction.type}
110
+ </Badge>
111
+ {agent.lastAction.reasoning && (
112
+ <span className="text-xs text-dark-400">
113
+ {truncateText(agent.lastAction.reasoning, 50)}
114
+ </span>
115
+ )}
116
+ </div>
117
+ </div>
118
+ )}
119
+
120
+ {/* Thought Stream */}
121
+ {agent.thoughts.length > 0 && (
122
+ <div className="mt-3">
123
+ <div className="flex items-center gap-2 text-xs text-dark-400 mb-2">
124
+ <Brain className="w-3 h-3" />
125
+ <span>Thought Stream</span>
126
+ </div>
127
+ <div className="space-y-2 max-h-40 overflow-y-auto">
128
+ {agent.thoughts.slice(-5).map((thought, i) => (
129
+ <ThoughtBubble key={i} thought={thought} />
130
+ ))}
131
+ </div>
132
+ </div>
133
+ )}
134
+ </div>
135
+ )}
136
+ </div>
137
+ );
138
+ };
139
+
140
+ export const AgentView: React.FC<AgentViewProps> = ({ className }) => {
141
+ const { data: episode } = useCurrentEpisode();
142
+ const { data: agents, isLoading } = useAgents(episode?.id);
143
+ const [expandedAgents, setExpandedAgents] = useState<Set<string>>(new Set());
144
+
145
+ const toggleAgent = (agentId: string) => {
146
+ setExpandedAgents((prev) => {
147
+ const next = new Set(prev);
148
+ if (next.has(agentId)) {
149
+ next.delete(agentId);
150
+ } else {
151
+ next.add(agentId);
152
+ }
153
+ return next;
154
+ });
155
+ };
156
+
157
+ const activeAgents = agents?.filter((a) => a.status !== 'idle') ?? [];
158
+ const idleAgents = agents?.filter((a) => a.status === 'idle') ?? [];
159
+
160
+ return (
161
+ <Card className={className}>
162
+ <CardHeader
163
+ title="Agents"
164
+ subtitle={`${agents?.length ?? 0} agents`}
165
+ icon={<Users className="w-4 h-4" />}
166
+ action={
167
+ activeAgents.length > 0 && (
168
+ <Badge variant="success" dot pulse>
169
+ {activeAgents.length} active
170
+ </Badge>
171
+ )
172
+ }
173
+ />
174
+ <CardContent>
175
+ {isLoading ? (
176
+ <div className="flex items-center justify-center py-8">
177
+ <Activity className="w-6 h-6 text-dark-500 animate-pulse" />
178
+ </div>
179
+ ) : !agents || agents.length === 0 ? (
180
+ <div className="text-center py-8 text-dark-500">
181
+ <Users className="w-8 h-8 mx-auto mb-2 opacity-50" />
182
+ <p>No agents active</p>
183
+ </div>
184
+ ) : (
185
+ <div className="space-y-2">
186
+ {/* Active Agents */}
187
+ {activeAgents.map((agent) => (
188
+ <AgentCard
189
+ key={agent.id}
190
+ agent={agent}
191
+ isExpanded={expandedAgents.has(agent.id)}
192
+ onToggle={() => toggleAgent(agent.id)}
193
+ />
194
+ ))}
195
+
196
+ {/* Idle Agents */}
197
+ {idleAgents.length > 0 && activeAgents.length > 0 && (
198
+ <div className="text-xs text-dark-500 pt-2">Idle Agents</div>
199
+ )}
200
+ {idleAgents.map((agent) => (
201
+ <AgentCard
202
+ key={agent.id}
203
+ agent={agent}
204
+ isExpanded={expandedAgents.has(agent.id)}
205
+ onToggle={() => toggleAgent(agent.id)}
206
+ />
207
+ ))}
208
+ </div>
209
+ )}
210
+
211
+ {/* Thought Feed */}
212
+ {agents && agents.some((a) => a.thoughts.length > 0) && (
213
+ <div className="mt-4 pt-4 border-t border-dark-700">
214
+ <div className="flex items-center gap-2 text-sm text-dark-400 mb-3">
215
+ <MessageSquare className="w-4 h-4" />
216
+ <span>Latest Thoughts</span>
217
+ </div>
218
+ <div className="space-y-2 max-h-48 overflow-y-auto">
219
+ {agents
220
+ .flatMap((a) =>
221
+ a.thoughts.map((t) => ({ ...t, agentId: a.id, role: a.role }))
222
+ )
223
+ .sort(
224
+ (a, b) =>
225
+ new Date(b.timestamp).getTime() -
226
+ new Date(a.timestamp).getTime()
227
+ )
228
+ .slice(0, 5)
229
+ .map((thought, i) => (
230
+ <div
231
+ key={i}
232
+ className="p-2 bg-dark-900/50 rounded text-sm"
233
+ >
234
+ <div className="flex items-center gap-2 mb-1">
235
+ <span className="text-xs">
236
+ {getAgentRoleIcon(thought.role)}
237
+ </span>
238
+ <span className={`text-xs ${getAgentRoleColor(thought.role)}`}>
239
+ {thought.role}
240
+ </span>
241
+ <span className="text-xs text-dark-500">
242
+ {formatTimestamp(thought.timestamp)}
243
+ </span>
244
+ </div>
245
+ <p className="text-dark-300 text-xs">
246
+ {truncateText(thought.content, 100)}
247
+ </p>
248
+ </div>
249
+ ))}
250
+ </div>
251
+ </div>
252
+ )}
253
+ </CardContent>
254
+ </Card>
255
+ );
256
+ };
257
+
258
+ export default AgentView;
frontend/src/components/Dashboard.tsx ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ LayoutDashboard,
4
+ Settings as SettingsIcon,
5
+ Activity,
6
+ Menu,
7
+ X,
8
+ Wifi,
9
+ WifiOff,
10
+ ChevronLeft,
11
+ ChevronRight,
12
+ } from 'lucide-react';
13
+ import { EpisodePanel } from './EpisodePanel';
14
+ import { AgentView } from './AgentView';
15
+ import { MemoryPanel } from './MemoryPanel';
16
+ import { ToolRegistry } from './ToolRegistry';
17
+ import { RewardChart } from './RewardChart';
18
+ import { ObservationView } from './ObservationView';
19
+ import { ActionPanel } from './ActionPanel';
20
+ import { Settings } from './Settings';
21
+ import { Badge } from '@/components/ui/Badge';
22
+ import { useWebSocket } from '@/hooks/useWebSocket';
23
+ import { useCurrentEpisode } from '@/hooks/useEpisode';
24
+
25
+ type ViewMode = 'dashboard' | 'settings';
26
+
27
+ export const Dashboard: React.FC = () => {
28
+ const [viewMode, setViewMode] = useState<ViewMode>('dashboard');
29
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
30
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
31
+
32
+ const { isConnected, isConnecting } = useWebSocket('/ws', {
33
+ onMessage: (message) => {
34
+ console.log('WebSocket message:', message);
35
+ },
36
+ });
37
+
38
+ const { data: episode } = useCurrentEpisode();
39
+
40
+ return (
41
+ <div className="min-h-screen bg-dark-900 flex">
42
+ {/* Sidebar */}
43
+ <aside
44
+ className={`fixed lg:relative inset-y-0 left-0 z-40 bg-dark-800 border-r border-dark-700
45
+ transition-all duration-300 ${
46
+ sidebarCollapsed ? 'w-16' : 'w-64'
47
+ } ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}
48
+ >
49
+ {/* Logo */}
50
+ <div className="h-16 flex items-center justify-between px-4 border-b border-dark-700">
51
+ {!sidebarCollapsed && (
52
+ <div className="flex items-center gap-2">
53
+ <div className="w-8 h-8 bg-gradient-to-br from-accent-primary to-accent-tertiary rounded-lg flex items-center justify-center">
54
+ <Activity className="w-5 h-5 text-white" />
55
+ </div>
56
+ <span className="font-bold text-lg gradient-text">ScrapeRL</span>
57
+ </div>
58
+ )}
59
+ <button
60
+ onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
61
+ className="hidden lg:flex p-1.5 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded transition-colors"
62
+ >
63
+ {sidebarCollapsed ? (
64
+ <ChevronRight className="w-4 h-4" />
65
+ ) : (
66
+ <ChevronLeft className="w-4 h-4" />
67
+ )}
68
+ </button>
69
+ <button
70
+ onClick={() => setMobileMenuOpen(false)}
71
+ className="lg:hidden p-1.5 text-dark-400 hover:text-dark-200"
72
+ >
73
+ <X className="w-5 h-5" />
74
+ </button>
75
+ </div>
76
+
77
+ {/* Navigation */}
78
+ <nav className="p-2 space-y-1">
79
+ <button
80
+ onClick={() => {
81
+ setViewMode('dashboard');
82
+ setMobileMenuOpen(false);
83
+ }}
84
+ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
85
+ viewMode === 'dashboard'
86
+ ? 'bg-accent-primary/10 text-accent-primary'
87
+ : 'text-dark-400 hover:text-dark-200 hover:bg-dark-700'
88
+ }`}
89
+ >
90
+ <LayoutDashboard className="w-5 h-5 flex-shrink-0" />
91
+ {!sidebarCollapsed && <span>Dashboard</span>}
92
+ </button>
93
+ <button
94
+ onClick={() => {
95
+ setViewMode('settings');
96
+ setMobileMenuOpen(false);
97
+ }}
98
+ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
99
+ viewMode === 'settings'
100
+ ? 'bg-accent-primary/10 text-accent-primary'
101
+ : 'text-dark-400 hover:text-dark-200 hover:bg-dark-700'
102
+ }`}
103
+ >
104
+ <SettingsIcon className="w-5 h-5 flex-shrink-0" />
105
+ {!sidebarCollapsed && <span>Settings</span>}
106
+ </button>
107
+ </nav>
108
+
109
+ {/* Status */}
110
+ <div className="absolute bottom-0 left-0 right-0 p-4 border-t border-dark-700">
111
+ <div className="flex items-center gap-2">
112
+ {isConnected ? (
113
+ <Wifi className="w-4 h-4 text-green-400" />
114
+ ) : isConnecting ? (
115
+ <Wifi className="w-4 h-4 text-yellow-400 animate-pulse" />
116
+ ) : (
117
+ <WifiOff className="w-4 h-4 text-red-400" />
118
+ )}
119
+ {!sidebarCollapsed && (
120
+ <span className="text-xs text-dark-400">
121
+ {isConnected
122
+ ? 'Connected'
123
+ : isConnecting
124
+ ? 'Connecting...'
125
+ : 'Disconnected'}
126
+ </span>
127
+ )}
128
+ </div>
129
+ </div>
130
+ </aside>
131
+
132
+ {/* Mobile Overlay */}
133
+ {mobileMenuOpen && (
134
+ <div
135
+ className="fixed inset-0 bg-black/50 z-30 lg:hidden"
136
+ onClick={() => setMobileMenuOpen(false)}
137
+ />
138
+ )}
139
+
140
+ {/* Main Content */}
141
+ <main className="flex-1 overflow-hidden">
142
+ {/* Header */}
143
+ <header className="h-16 bg-dark-800 border-b border-dark-700 flex items-center justify-between px-4 lg:px-6">
144
+ <div className="flex items-center gap-4">
145
+ <button
146
+ onClick={() => setMobileMenuOpen(true)}
147
+ className="lg:hidden p-2 text-dark-400 hover:text-dark-200"
148
+ >
149
+ <Menu className="w-5 h-5" />
150
+ </button>
151
+ <div>
152
+ <h1 className="text-lg font-semibold text-dark-100">
153
+ {viewMode === 'dashboard' ? 'Dashboard' : 'Settings'}
154
+ </h1>
155
+ {episode && (
156
+ <p className="text-xs text-dark-400">
157
+ Episode: {episode.id.slice(0, 8)}...
158
+ </p>
159
+ )}
160
+ </div>
161
+ </div>
162
+
163
+ <div className="flex items-center gap-3">
164
+ {episode && (
165
+ <Badge
166
+ variant={
167
+ episode.status === 'running'
168
+ ? 'success'
169
+ : episode.status === 'failed'
170
+ ? 'error'
171
+ : 'neutral'
172
+ }
173
+ dot
174
+ pulse={episode.status === 'running'}
175
+ >
176
+ {episode.status}
177
+ </Badge>
178
+ )}
179
+ </div>
180
+ </header>
181
+
182
+ {/* Content Area */}
183
+ <div className="h-[calc(100vh-4rem)] overflow-auto p-4 lg:p-6">
184
+ {viewMode === 'dashboard' ? (
185
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-4 lg:gap-6">
186
+ {/* Left Column */}
187
+ <div className="lg:col-span-3 space-y-4 lg:space-y-6">
188
+ <EpisodePanel />
189
+ <AgentView />
190
+ </div>
191
+
192
+ {/* Center Column */}
193
+ <div className="lg:col-span-6 space-y-4 lg:space-y-6">
194
+ <ObservationView />
195
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6">
196
+ <RewardChart />
197
+ <ActionPanel />
198
+ </div>
199
+ </div>
200
+
201
+ {/* Right Column */}
202
+ <div className="lg:col-span-3 space-y-4 lg:space-y-6">
203
+ <MemoryPanel />
204
+ <ToolRegistry />
205
+ </div>
206
+ </div>
207
+ ) : (
208
+ <div className="max-w-2xl mx-auto">
209
+ <Settings />
210
+ </div>
211
+ )}
212
+ </div>
213
+ </main>
214
+ </div>
215
+ );
216
+ };
217
+
218
+ export default Dashboard;
frontend/src/components/EpisodePanel.tsx ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Play,
4
+ Pause,
5
+ RotateCcw,
6
+ Square,
7
+ Clock,
8
+ Target,
9
+ Zap,
10
+ TrendingUp,
11
+ } from 'lucide-react';
12
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
13
+ import { Button } from '@/components/ui/Button';
14
+ import { Badge, StatusBadge } from '@/components/ui/Badge';
15
+ import {
16
+ useCurrentEpisode,
17
+ useResetEpisode,
18
+ useTerminateEpisode,
19
+ } from '@/hooks/useEpisode';
20
+ import { formatDuration, formatTimestamp, calculateProgress } from '@/utils/helpers';
21
+
22
+ interface EpisodePanelProps {
23
+ className?: string;
24
+ }
25
+
26
+ export const EpisodePanel: React.FC<EpisodePanelProps> = ({ className }) => {
27
+ const { data: episode, isLoading } = useCurrentEpisode();
28
+ const resetMutation = useResetEpisode();
29
+ const terminateMutation = useTerminateEpisode();
30
+ const [isRunning, setIsRunning] = useState(false);
31
+
32
+ const handleReset = () => {
33
+ resetMutation.mutate({
34
+ task: {
35
+ description: 'Default scraping task',
36
+ targetUrl: 'https://example.com',
37
+ objectives: ['Extract main content'],
38
+ },
39
+ });
40
+ };
41
+
42
+ const handleToggleRun = () => {
43
+ setIsRunning(!isRunning);
44
+ };
45
+
46
+ const handleTerminate = () => {
47
+ if (episode?.id) {
48
+ terminateMutation.mutate(episode.id);
49
+ }
50
+ };
51
+
52
+ const progress = episode
53
+ ? calculateProgress(episode.currentStep, episode.config.maxSteps)
54
+ : 0;
55
+
56
+ const elapsedTime = episode
57
+ ? Date.now() - new Date(episode.startTime).getTime()
58
+ : 0;
59
+
60
+ return (
61
+ <Card className={className}>
62
+ <CardHeader
63
+ title="Episode Control"
64
+ icon={<Zap className="w-4 h-4" />}
65
+ action={
66
+ episode && (
67
+ <StatusBadge status={episode.status} />
68
+ )
69
+ }
70
+ />
71
+ <CardContent>
72
+ {/* Control Buttons */}
73
+ <div className="flex gap-2 mb-4">
74
+ <Button
75
+ variant="primary"
76
+ size="sm"
77
+ onClick={handleReset}
78
+ isLoading={resetMutation.isPending}
79
+ leftIcon={<RotateCcw className="w-4 h-4" />}
80
+ >
81
+ Reset
82
+ </Button>
83
+ <Button
84
+ variant="secondary"
85
+ size="sm"
86
+ onClick={handleToggleRun}
87
+ disabled={!episode}
88
+ leftIcon={
89
+ isRunning ? (
90
+ <Pause className="w-4 h-4" />
91
+ ) : (
92
+ <Play className="w-4 h-4" />
93
+ )
94
+ }
95
+ >
96
+ {isRunning ? 'Pause' : 'Run'}
97
+ </Button>
98
+ <Button
99
+ variant="danger"
100
+ size="sm"
101
+ onClick={handleTerminate}
102
+ disabled={!episode || episode.status !== 'running'}
103
+ isLoading={terminateMutation.isPending}
104
+ leftIcon={<Square className="w-4 h-4" />}
105
+ >
106
+ Stop
107
+ </Button>
108
+ </div>
109
+
110
+ {/* Progress Bar */}
111
+ <div className="mb-4">
112
+ <div className="flex justify-between text-sm text-dark-400 mb-1">
113
+ <span>Progress</span>
114
+ <span>{progress}%</span>
115
+ </div>
116
+ <div className="h-2 bg-dark-700 rounded-full overflow-hidden">
117
+ <div
118
+ className="h-full bg-gradient-to-r from-accent-primary to-accent-tertiary rounded-full transition-all duration-300"
119
+ style={{ width: `${progress}%` }}
120
+ />
121
+ </div>
122
+ </div>
123
+
124
+ {/* Stats Grid */}
125
+ <div className="grid grid-cols-2 gap-3">
126
+ <div className="bg-dark-900/50 rounded-lg p-3">
127
+ <div className="flex items-center gap-2 text-dark-400 text-xs mb-1">
128
+ <Target className="w-3 h-3" />
129
+ <span>Step</span>
130
+ </div>
131
+ <div className="text-xl font-semibold text-dark-100">
132
+ {isLoading ? '-' : `${episode?.currentStep ?? 0}`}
133
+ <span className="text-sm text-dark-500 font-normal">
134
+ /{episode?.config.maxSteps ?? 100}
135
+ </span>
136
+ </div>
137
+ </div>
138
+
139
+ <div className="bg-dark-900/50 rounded-lg p-3">
140
+ <div className="flex items-center gap-2 text-dark-400 text-xs mb-1">
141
+ <Clock className="w-3 h-3" />
142
+ <span>Time</span>
143
+ </div>
144
+ <div className="text-xl font-semibold text-dark-100">
145
+ {episode ? formatDuration(elapsedTime) : '--:--'}
146
+ </div>
147
+ </div>
148
+
149
+ <div className="bg-dark-900/50 rounded-lg p-3">
150
+ <div className="flex items-center gap-2 text-dark-400 text-xs mb-1">
151
+ <TrendingUp className="w-3 h-3" />
152
+ <span>Total Reward</span>
153
+ </div>
154
+ <div className="text-xl font-semibold">
155
+ <span
156
+ className={
157
+ (episode?.totalReward ?? 0) >= 0
158
+ ? 'text-green-400'
159
+ : 'text-red-400'
160
+ }
161
+ >
162
+ {episode?.totalReward?.toFixed(2) ?? '0.00'}
163
+ </span>
164
+ </div>
165
+ </div>
166
+
167
+ <div className="bg-dark-900/50 rounded-lg p-3">
168
+ <div className="flex items-center gap-2 text-dark-400 text-xs mb-1">
169
+ <Zap className="w-3 h-3" />
170
+ <span>Budget</span>
171
+ </div>
172
+ <div className="text-xl font-semibold text-dark-100">
173
+ ${episode?.config.budget?.toFixed(2) ?? '0.00'}
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ {/* Task Info */}
179
+ {episode?.task && (
180
+ <div className="mt-4 p-3 bg-dark-900/50 rounded-lg">
181
+ <div className="text-xs text-dark-400 mb-1">Current Task</div>
182
+ <div className="text-sm text-dark-200 mb-2">
183
+ {episode.task.description}
184
+ </div>
185
+ <div className="flex flex-wrap gap-1">
186
+ {episode.task.objectives.slice(0, 3).map((obj, i) => (
187
+ <Badge key={i} variant="info" size="sm">
188
+ {obj}
189
+ </Badge>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ )}
194
+
195
+ {/* Timeline */}
196
+ {episode && (
197
+ <div className="mt-4">
198
+ <div className="text-xs text-dark-400 mb-2">Timeline</div>
199
+ <div className="space-y-2 max-h-32 overflow-y-auto">
200
+ <div className="timeline-item">
201
+ <div className="timeline-dot" />
202
+ <div className="text-xs text-dark-300">
203
+ <span className="text-dark-500">
204
+ {formatTimestamp(episode.startTime)}
205
+ </span>
206
+ {' Β· '}Episode started
207
+ </div>
208
+ </div>
209
+ {episode.actions.slice(-3).map((action, i) => (
210
+ <div key={i} className="timeline-item">
211
+ <div className="timeline-dot" style={{ backgroundColor: '#6366f1' }} />
212
+ <div className="text-xs text-dark-300">
213
+ <span className="text-dark-500">
214
+ {formatTimestamp(action.timestamp)}
215
+ </span>
216
+ {' Β· '}
217
+ {action.type}
218
+ </div>
219
+ </div>
220
+ ))}
221
+ </div>
222
+ </div>
223
+ )}
224
+ </CardContent>
225
+ </Card>
226
+ );
227
+ };
228
+
229
+ export default EpisodePanel;
frontend/src/components/MemoryPanel.tsx ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Database,
4
+ Search,
5
+ Trash2,
6
+ Clock,
7
+ Layers,
8
+ Archive,
9
+ Share2,
10
+ AlertCircle,
11
+ } from 'lucide-react';
12
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
13
+ import { Button } from '@/components/ui/Button';
14
+ import { Badge } from '@/components/ui/Badge';
15
+ import { Input } from '@/components/ui/Input';
16
+ import {
17
+ useMemory,
18
+ useClearMemory,
19
+ useQueryMemory,
20
+ getMemoryLayerBadge,
21
+ formatMemorySize,
22
+ } from '@/hooks/useMemory';
23
+ import { useCurrentEpisode } from '@/hooks/useEpisode';
24
+ import { formatTimestamp, truncateText } from '@/utils/helpers';
25
+ import type { MemoryEntry, MemoryLayer } from '@/types';
26
+
27
+ interface MemoryPanelProps {
28
+ className?: string;
29
+ }
30
+
31
+ const MEMORY_TABS: { key: MemoryLayer; label: string; icon: React.ReactNode }[] = [
32
+ { key: 'short_term', label: 'Short-Term', icon: <Clock className="w-3 h-3" /> },
33
+ { key: 'working', label: 'Working', icon: <Layers className="w-3 h-3" /> },
34
+ { key: 'long_term', label: 'Long-Term', icon: <Archive className="w-3 h-3" /> },
35
+ { key: 'shared', label: 'Shared', icon: <Share2 className="w-3 h-3" /> },
36
+ ];
37
+
38
+ const MemoryEntryCard: React.FC<{ entry: MemoryEntry }> = ({ entry }) => {
39
+ const [isExpanded, setIsExpanded] = useState(false);
40
+
41
+ return (
42
+ <div
43
+ className="p-2 bg-dark-900/50 rounded-lg hover:bg-dark-900/70 transition-colors cursor-pointer"
44
+ onClick={() => setIsExpanded(!isExpanded)}
45
+ >
46
+ <div className="flex items-start justify-between gap-2">
47
+ <div className="flex-1 min-w-0">
48
+ <div className="flex items-center gap-2 mb-1">
49
+ <Badge variant="neutral" size="sm">
50
+ {entry.type}
51
+ </Badge>
52
+ <div
53
+ className="h-1.5 w-8 rounded-full bg-dark-700 overflow-hidden"
54
+ title={`Importance: ${(entry.importance * 100).toFixed(0)}%`}
55
+ >
56
+ <div
57
+ className="h-full bg-accent-primary"
58
+ style={{ width: `${entry.importance * 100}%` }}
59
+ />
60
+ </div>
61
+ </div>
62
+ <p className="text-sm text-dark-200">
63
+ {isExpanded ? entry.content : truncateText(entry.content, 80)}
64
+ </p>
65
+ </div>
66
+ <span className="text-xs text-dark-500 whitespace-nowrap">
67
+ {formatTimestamp(entry.timestamp)}
68
+ </span>
69
+ </div>
70
+ {isExpanded && entry.metadata && Object.keys(entry.metadata).length > 0 && (
71
+ <div className="mt-2 pt-2 border-t border-dark-700">
72
+ <div className="text-xs text-dark-500 font-mono">
73
+ {JSON.stringify(entry.metadata, null, 2)}
74
+ </div>
75
+ </div>
76
+ )}
77
+ </div>
78
+ );
79
+ };
80
+
81
+ export const MemoryPanel: React.FC<MemoryPanelProps> = ({ className }) => {
82
+ const { data: episode } = useCurrentEpisode();
83
+ const { data: memory, isLoading } = useMemory(episode?.id);
84
+ const clearMemoryMutation = useClearMemory(episode?.id);
85
+ const queryMemoryMutation = useQueryMemory(episode?.id);
86
+
87
+ const [activeTab, setActiveTab] = useState<MemoryLayer>('working');
88
+ const [searchQuery, setSearchQuery] = useState('');
89
+
90
+ const handleSearch = () => {
91
+ if (searchQuery.trim() && episode?.id) {
92
+ queryMemoryMutation.mutate({
93
+ query: searchQuery,
94
+ layer: activeTab,
95
+ limit: 10,
96
+ });
97
+ }
98
+ };
99
+
100
+ const handleClear = () => {
101
+ if (episode?.id && confirm(`Clear all ${activeTab.replace('_', ' ')} memory?`)) {
102
+ clearMemoryMutation.mutate(activeTab);
103
+ }
104
+ };
105
+
106
+ const getEntriesForTab = (): MemoryEntry[] => {
107
+ if (!memory) return [];
108
+ switch (activeTab) {
109
+ case 'short_term':
110
+ return memory.shortTerm;
111
+ case 'working':
112
+ return memory.working;
113
+ case 'long_term':
114
+ return memory.longTerm;
115
+ case 'shared':
116
+ return memory.shared;
117
+ default:
118
+ return [];
119
+ }
120
+ };
121
+
122
+ const entries = queryMemoryMutation.data ?? getEntriesForTab();
123
+
124
+ return (
125
+ <Card className={className}>
126
+ <CardHeader
127
+ title="Memory"
128
+ subtitle={memory ? `${memory.totalEntries} entries` : undefined}
129
+ icon={<Database className="w-4 h-4" />}
130
+ action={
131
+ memory && (
132
+ <span className="text-xs text-dark-500">
133
+ {formatMemorySize(memory.memoryUsage)}
134
+ </span>
135
+ )
136
+ }
137
+ />
138
+ <CardContent>
139
+ {/* Tabs */}
140
+ <div className="flex border-b border-dark-700 mb-4 overflow-x-auto">
141
+ {MEMORY_TABS.map((tab) => (
142
+ <button
143
+ key={tab.key}
144
+ onClick={() => {
145
+ setActiveTab(tab.key);
146
+ queryMemoryMutation.reset();
147
+ }}
148
+ className={`tab flex items-center gap-1.5 whitespace-nowrap ${
149
+ activeTab === tab.key ? 'tab-active' : ''
150
+ }`}
151
+ >
152
+ {tab.icon}
153
+ {tab.label}
154
+ {memory && (
155
+ <Badge
156
+ variant="neutral"
157
+ size="sm"
158
+ className={activeTab === tab.key ? getMemoryLayerBadge(tab.key) : ''}
159
+ >
160
+ {tab.key === 'short_term' && memory.shortTerm.length}
161
+ {tab.key === 'working' && memory.working.length}
162
+ {tab.key === 'long_term' && memory.longTerm.length}
163
+ {tab.key === 'shared' && memory.shared.length}
164
+ </Badge>
165
+ )}
166
+ </button>
167
+ ))}
168
+ </div>
169
+
170
+ {/* Search */}
171
+ <div className="flex gap-2 mb-4">
172
+ <Input
173
+ placeholder="Search memory..."
174
+ value={searchQuery}
175
+ onChange={(e) => setSearchQuery(e.target.value)}
176
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
177
+ leftIcon={<Search className="w-4 h-4" />}
178
+ className="flex-1"
179
+ />
180
+ <Button
181
+ variant="secondary"
182
+ size="sm"
183
+ onClick={handleSearch}
184
+ isLoading={queryMemoryMutation.isPending}
185
+ >
186
+ Search
187
+ </Button>
188
+ <Button
189
+ variant="ghost"
190
+ size="sm"
191
+ onClick={handleClear}
192
+ isLoading={clearMemoryMutation.isPending}
193
+ leftIcon={<Trash2 className="w-4 h-4" />}
194
+ />
195
+ </div>
196
+
197
+ {/* Memory Entries */}
198
+ <div className="space-y-2 max-h-[400px] overflow-y-auto">
199
+ {isLoading ? (
200
+ <div className="flex items-center justify-center py-8">
201
+ <Database className="w-6 h-6 text-dark-500 animate-pulse" />
202
+ </div>
203
+ ) : entries.length === 0 ? (
204
+ <div className="text-center py-8 text-dark-500">
205
+ <AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
206
+ <p>No {activeTab.replace('_', ' ')} memory entries</p>
207
+ </div>
208
+ ) : (
209
+ entries.map((entry) => (
210
+ <MemoryEntryCard key={entry.id} entry={entry} />
211
+ ))
212
+ )}
213
+ </div>
214
+
215
+ {/* Memory Stats */}
216
+ {memory && (
217
+ <div className="mt-4 pt-4 border-t border-dark-700">
218
+ <div className="grid grid-cols-4 gap-2 text-center">
219
+ {MEMORY_TABS.map((tab) => {
220
+ const count =
221
+ tab.key === 'short_term'
222
+ ? memory.shortTerm.length
223
+ : tab.key === 'working'
224
+ ? memory.working.length
225
+ : tab.key === 'long_term'
226
+ ? memory.longTerm.length
227
+ : memory.shared.length;
228
+ return (
229
+ <div key={tab.key} className="p-2 bg-dark-900/50 rounded">
230
+ <div className="text-lg font-semibold text-dark-200">
231
+ {count}
232
+ </div>
233
+ <div className="text-xs text-dark-500">{tab.label}</div>
234
+ </div>
235
+ );
236
+ })}
237
+ </div>
238
+ </div>
239
+ )}
240
+ </CardContent>
241
+ </Card>
242
+ );
243
+ };
244
+
245
+ export default MemoryPanel;
frontend/src/components/ObservationView.tsx ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import {
3
+ Eye,
4
+ Globe,
5
+ Code,
6
+ Image as ImageIcon,
7
+ FileText,
8
+ Clock,
9
+ ExternalLink,
10
+ ChevronDown,
11
+ ChevronUp,
12
+ Maximize2,
13
+ } from 'lucide-react';
14
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
15
+ import { Button } from '@/components/ui/Button';
16
+ import { Badge } from '@/components/ui/Badge';
17
+ import { useCurrentEpisode, useEpisodeState } from '@/hooks/useEpisode';
18
+ import { formatTimestamp, truncateText } from '@/utils/helpers';
19
+ import type { DOMElement } from '@/types';
20
+
21
+ interface ObservationViewProps {
22
+ className?: string;
23
+ }
24
+
25
+ const DOMTree: React.FC<{ elements: DOMElement[]; depth?: number }> = ({
26
+ elements,
27
+ depth = 0,
28
+ }) => {
29
+ const [expanded, setExpanded] = useState<Set<number>>(new Set([0, 1, 2]));
30
+
31
+ const toggleExpand = (index: number) => {
32
+ setExpanded((prev) => {
33
+ const next = new Set(prev);
34
+ if (next.has(index)) {
35
+ next.delete(index);
36
+ } else {
37
+ next.add(index);
38
+ }
39
+ return next;
40
+ });
41
+ };
42
+
43
+ if (depth > 3) return null;
44
+
45
+ return (
46
+ <div className="space-y-1">
47
+ {elements.slice(0, 10).map((el, i) => {
48
+ const hasChildren = el.children && el.children.length > 0;
49
+ const isExpanded = expanded.has(i);
50
+
51
+ return (
52
+ <div key={i} style={{ paddingLeft: `${depth * 12}px` }}>
53
+ <div className="flex items-center gap-1 text-xs font-mono hover:bg-dark-700/50 rounded px-1">
54
+ {hasChildren && (
55
+ <button
56
+ onClick={() => toggleExpand(i)}
57
+ className="text-dark-500 hover:text-dark-300"
58
+ >
59
+ {isExpanded ? (
60
+ <ChevronDown className="w-3 h-3" />
61
+ ) : (
62
+ <ChevronUp className="w-3 h-3" />
63
+ )}
64
+ </button>
65
+ )}
66
+ {!hasChildren && <span className="w-3" />}
67
+ <span className="text-purple-400">&lt;{el.tag}</span>
68
+ {el.id && (
69
+ <span className="text-yellow-400">#{el.id}</span>
70
+ )}
71
+ {el.classes.length > 0 && (
72
+ <span className="text-green-400">
73
+ .{el.classes.slice(0, 2).join('.')}
74
+ </span>
75
+ )}
76
+ <span className="text-purple-400">&gt;</span>
77
+ {el.text && (
78
+ <span className="text-dark-400 truncate max-w-[100px]">
79
+ {truncateText(el.text, 20)}
80
+ </span>
81
+ )}
82
+ </div>
83
+ {hasChildren && isExpanded && (
84
+ <DOMTree elements={el.children!} depth={depth + 1} />
85
+ )}
86
+ </div>
87
+ );
88
+ })}
89
+ {elements.length > 10 && (
90
+ <div
91
+ className="text-xs text-dark-500 italic"
92
+ style={{ paddingLeft: `${depth * 12}px` }}
93
+ >
94
+ +{elements.length - 10} more elements
95
+ </div>
96
+ )}
97
+ </div>
98
+ );
99
+ };
100
+
101
+ export const ObservationView: React.FC<ObservationViewProps> = ({
102
+ className,
103
+ }) => {
104
+ const { data: episode } = useCurrentEpisode();
105
+ const { data: state, isLoading } = useEpisodeState(episode?.id);
106
+ const [activeTab, setActiveTab] = useState<'page' | 'dom' | 'data' | 'screenshot'>('page');
107
+ const [showFullScreen, setShowFullScreen] = useState(false);
108
+
109
+ const observation = state?.observation;
110
+
111
+ const tabs = [
112
+ { key: 'page' as const, label: 'Page', icon: <Globe className="w-3 h-3" /> },
113
+ { key: 'dom' as const, label: 'DOM', icon: <Code className="w-3 h-3" /> },
114
+ { key: 'data' as const, label: 'Data', icon: <FileText className="w-3 h-3" /> },
115
+ { key: 'screenshot' as const, label: 'Screenshot', icon: <ImageIcon className="w-3 h-3" /> },
116
+ ];
117
+
118
+ return (
119
+ <Card className={className}>
120
+ <CardHeader
121
+ title="Observation"
122
+ subtitle={observation ? `Step ${observation.step}` : undefined}
123
+ icon={<Eye className="w-4 h-4" />}
124
+ action={
125
+ observation && (
126
+ <div className="flex items-center gap-2">
127
+ <Badge variant="info" size="sm">
128
+ {observation.interactableElements?.length ?? 0} elements
129
+ </Badge>
130
+ <Button
131
+ variant="ghost"
132
+ size="sm"
133
+ onClick={() => setShowFullScreen(true)}
134
+ >
135
+ <Maximize2 className="w-4 h-4" />
136
+ </Button>
137
+ </div>
138
+ )
139
+ }
140
+ />
141
+ <CardContent>
142
+ {/* Tabs */}
143
+ <div className="flex border-b border-dark-700 mb-4">
144
+ {tabs.map((tab) => (
145
+ <button
146
+ key={tab.key}
147
+ onClick={() => setActiveTab(tab.key)}
148
+ className={`tab flex items-center gap-1.5 ${
149
+ activeTab === tab.key ? 'tab-active' : ''
150
+ }`}
151
+ >
152
+ {tab.icon}
153
+ {tab.label}
154
+ </button>
155
+ ))}
156
+ </div>
157
+
158
+ {isLoading ? (
159
+ <div className="h-48 flex items-center justify-center">
160
+ <Eye className="w-6 h-6 text-dark-500 animate-pulse" />
161
+ </div>
162
+ ) : !observation ? (
163
+ <div className="h-48 flex items-center justify-center text-dark-500">
164
+ <div className="text-center">
165
+ <Eye className="w-8 h-8 mx-auto mb-2 opacity-50" />
166
+ <p>No observation data</p>
167
+ </div>
168
+ </div>
169
+ ) : (
170
+ <div className="min-h-[200px]">
171
+ {/* Page Info */}
172
+ {activeTab === 'page' && (
173
+ <div className="space-y-3">
174
+ <div className="bg-dark-900/50 rounded-lg p-3">
175
+ <div className="flex items-center gap-2 mb-2">
176
+ <Globe className="w-4 h-4 text-dark-400" />
177
+ <a
178
+ href={observation.page.url}
179
+ target="_blank"
180
+ rel="noopener noreferrer"
181
+ className="text-sm text-accent-primary hover:underline flex items-center gap-1"
182
+ >
183
+ {truncateText(observation.page.url, 50)}
184
+ <ExternalLink className="w-3 h-3" />
185
+ </a>
186
+ </div>
187
+ <div className="text-lg font-medium text-dark-100 mb-2">
188
+ {observation.page.title || 'Untitled'}
189
+ </div>
190
+ <div className="flex flex-wrap gap-2">
191
+ <Badge variant="neutral" size="sm">
192
+ {observation.page.domain}
193
+ </Badge>
194
+ <Badge
195
+ variant={
196
+ observation.page.statusCode < 400 ? 'success' : 'error'
197
+ }
198
+ size="sm"
199
+ >
200
+ {observation.page.statusCode}
201
+ </Badge>
202
+ <Badge variant="info" size="sm">
203
+ {observation.page.loadTime}ms
204
+ </Badge>
205
+ </div>
206
+ </div>
207
+
208
+ <div className="bg-dark-900/50 rounded-lg p-3">
209
+ <div className="text-xs text-dark-400 mb-2">Visible Text</div>
210
+ <div className="text-sm text-dark-300 max-h-32 overflow-y-auto">
211
+ {truncateText(observation.visibleText, 500) || 'No text content'}
212
+ </div>
213
+ </div>
214
+
215
+ <div className="flex items-center gap-2 text-xs text-dark-500">
216
+ <Clock className="w-3 h-3" />
217
+ <span>{formatTimestamp(observation.timestamp)}</span>
218
+ </div>
219
+ </div>
220
+ )}
221
+
222
+ {/* DOM Tree */}
223
+ {activeTab === 'dom' && (
224
+ <div className="bg-dark-900/50 rounded-lg p-3 max-h-[300px] overflow-auto">
225
+ <div className="text-xs text-dark-400 mb-2">
226
+ {observation.dom?.length ?? 0} root elements
227
+ </div>
228
+ {observation.dom && observation.dom.length > 0 ? (
229
+ <DOMTree elements={observation.dom} />
230
+ ) : (
231
+ <div className="text-dark-500 text-sm">No DOM data</div>
232
+ )}
233
+ </div>
234
+ )}
235
+
236
+ {/* Extracted Data */}
237
+ {activeTab === 'data' && (
238
+ <div className="space-y-3">
239
+ <div className="bg-dark-900/50 rounded-lg p-3">
240
+ <div className="text-xs text-dark-400 mb-2">
241
+ Extracted Data
242
+ </div>
243
+ <div className="code-block max-h-[200px] overflow-auto">
244
+ {Object.keys(observation.extractedData || {}).length > 0
245
+ ? JSON.stringify(observation.extractedData, null, 2)
246
+ : '// No extracted data yet'}
247
+ </div>
248
+ </div>
249
+
250
+ <div className="bg-dark-900/50 rounded-lg p-3">
251
+ <div className="text-xs text-dark-400 mb-2">Metadata</div>
252
+ <div className="code-block max-h-[100px] overflow-auto">
253
+ {JSON.stringify(observation.metadata, null, 2)}
254
+ </div>
255
+ </div>
256
+ </div>
257
+ )}
258
+
259
+ {/* Screenshot */}
260
+ {activeTab === 'screenshot' && (
261
+ <div className="bg-dark-900/50 rounded-lg p-3">
262
+ {observation.screenshot ? (
263
+ <img
264
+ src={`data:image/png;base64,${observation.screenshot}`}
265
+ alt="Page screenshot"
266
+ className="w-full rounded border border-dark-700"
267
+ />
268
+ ) : (
269
+ <div className="h-48 flex items-center justify-center text-dark-500">
270
+ <div className="text-center">
271
+ <ImageIcon className="w-8 h-8 mx-auto mb-2 opacity-50" />
272
+ <p>No screenshot available</p>
273
+ </div>
274
+ </div>
275
+ )}
276
+ </div>
277
+ )}
278
+ </div>
279
+ )}
280
+ </CardContent>
281
+
282
+ {/* Full Screen Modal */}
283
+ {showFullScreen && observation && (
284
+ <div
285
+ className="fixed inset-0 z-50 bg-dark-950/90 flex items-center justify-center p-8"
286
+ onClick={() => setShowFullScreen(false)}
287
+ >
288
+ <div
289
+ className="bg-dark-800 rounded-xl p-6 max-w-4xl max-h-[90vh] overflow-auto"
290
+ onClick={(e) => e.stopPropagation()}
291
+ >
292
+ <div className="flex justify-between items-center mb-4">
293
+ <h2 className="text-xl font-semibold">
294
+ Observation - Step {observation.step}
295
+ </h2>
296
+ <Button variant="ghost" onClick={() => setShowFullScreen(false)}>
297
+ Close
298
+ </Button>
299
+ </div>
300
+ <div className="code-block max-h-[70vh] overflow-auto">
301
+ {JSON.stringify(observation, null, 2)}
302
+ </div>
303
+ </div>
304
+ </div>
305
+ )}
306
+ </Card>
307
+ );
308
+ };
309
+
310
+ export default ObservationView;
frontend/src/components/RewardChart.tsx ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import {
3
+ BarChart,
4
+ Bar,
5
+ XAxis,
6
+ YAxis,
7
+ CartesianGrid,
8
+ Tooltip,
9
+ ResponsiveContainer,
10
+ Area,
11
+ AreaChart,
12
+ Cell,
13
+ PieChart,
14
+ Pie,
15
+ } from 'recharts';
16
+ import { TrendingUp, Award, Target, Zap } from 'lucide-react';
17
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
18
+ import { Badge } from '@/components/ui/Badge';
19
+ import { useEpisodeRewards, useCurrentEpisode } from '@/hooks/useEpisode';
20
+ import { formatReward } from '@/utils/helpers';
21
+
22
+ interface RewardChartProps {
23
+ className?: string;
24
+ }
25
+
26
+ const COLORS = [
27
+ '#10a37f',
28
+ '#6366f1',
29
+ '#f59e0b',
30
+ '#ef4444',
31
+ '#8b5cf6',
32
+ '#06b6d4',
33
+ '#ec4899',
34
+ '#84cc16',
35
+ ];
36
+
37
+ const CustomTooltip: React.FC<{
38
+ active?: boolean;
39
+ payload?: Array<{ value: number; name: string; color: string }>;
40
+ label?: string;
41
+ }> = ({ active, payload, label }) => {
42
+ if (!active || !payload || payload.length === 0) return null;
43
+
44
+ return (
45
+ <div className="bg-dark-800 border border-dark-600 rounded-lg p-3 shadow-xl">
46
+ <div className="text-xs text-dark-400 mb-2">Step {label}</div>
47
+ {payload.map((entry, i) => (
48
+ <div key={i} className="flex items-center gap-2 text-sm">
49
+ <div
50
+ className="w-2 h-2 rounded-full"
51
+ style={{ backgroundColor: entry.color }}
52
+ />
53
+ <span className="text-dark-300">{entry.name}:</span>
54
+ <span
55
+ className={entry.value >= 0 ? 'text-green-400' : 'text-red-400'}
56
+ >
57
+ {formatReward(entry.value)}
58
+ </span>
59
+ </div>
60
+ ))}
61
+ </div>
62
+ );
63
+ };
64
+
65
+ export const RewardChart: React.FC<RewardChartProps> = ({ className }) => {
66
+ const { data: episode } = useCurrentEpisode();
67
+ const { data: rewards, isLoading } = useEpisodeRewards(episode?.id);
68
+
69
+ const chartData = React.useMemo(() => {
70
+ if (!rewards || rewards.length === 0) return [];
71
+ return rewards.map((r, i) => ({
72
+ step: i + 1,
73
+ total: r.total,
74
+ cumulative: r.cumulative,
75
+ ...r.components.reduce(
76
+ (acc, c) => ({ ...acc, [c.name]: c.value }),
77
+ {} as Record<string, number>
78
+ ),
79
+ }));
80
+ }, [rewards]);
81
+
82
+ const componentNames = React.useMemo(() => {
83
+ if (!rewards || rewards.length === 0) return [];
84
+ const names = new Set<string>();
85
+ rewards.forEach((r) => r.components.forEach((c) => names.add(c.name)));
86
+ return Array.from(names);
87
+ }, [rewards]);
88
+
89
+ const latestReward = rewards?.[rewards.length - 1];
90
+ const componentBreakdown = latestReward?.components ?? [];
91
+
92
+ const pieData = componentBreakdown.map((c, i) => ({
93
+ name: c.name,
94
+ value: Math.abs(c.value),
95
+ fill: COLORS[i % COLORS.length],
96
+ originalValue: c.value,
97
+ }));
98
+
99
+ const stats = React.useMemo(() => {
100
+ if (!rewards || rewards.length === 0) {
101
+ return { total: 0, avg: 0, max: 0, min: 0 };
102
+ }
103
+ const totals = rewards.map((r) => r.total);
104
+ return {
105
+ total: rewards[rewards.length - 1]?.cumulative ?? 0,
106
+ avg: totals.reduce((a, b) => a + b, 0) / totals.length,
107
+ max: Math.max(...totals),
108
+ min: Math.min(...totals),
109
+ };
110
+ }, [rewards]);
111
+
112
+ return (
113
+ <Card className={className}>
114
+ <CardHeader
115
+ title="Rewards"
116
+ icon={<Award className="w-4 h-4" />}
117
+ action={
118
+ latestReward && (
119
+ <Badge
120
+ variant={latestReward.total >= 0 ? 'success' : 'error'}
121
+ size="sm"
122
+ >
123
+ {formatReward(latestReward.total)}
124
+ </Badge>
125
+ )
126
+ }
127
+ />
128
+ <CardContent>
129
+ {/* Stats Grid */}
130
+ <div className="grid grid-cols-4 gap-2 mb-4">
131
+ <div className="bg-dark-900/50 rounded-lg p-2 text-center">
132
+ <div className="flex items-center justify-center gap-1 text-xs text-dark-400 mb-1">
133
+ <TrendingUp className="w-3 h-3" />
134
+ <span>Total</span>
135
+ </div>
136
+ <div
137
+ className={`text-lg font-semibold ${
138
+ stats.total >= 0 ? 'text-green-400' : 'text-red-400'
139
+ }`}
140
+ >
141
+ {formatReward(stats.total)}
142
+ </div>
143
+ </div>
144
+ <div className="bg-dark-900/50 rounded-lg p-2 text-center">
145
+ <div className="flex items-center justify-center gap-1 text-xs text-dark-400 mb-1">
146
+ <Target className="w-3 h-3" />
147
+ <span>Avg</span>
148
+ </div>
149
+ <div className="text-lg font-semibold text-dark-200">
150
+ {formatReward(stats.avg)}
151
+ </div>
152
+ </div>
153
+ <div className="bg-dark-900/50 rounded-lg p-2 text-center">
154
+ <div className="flex items-center justify-center gap-1 text-xs text-dark-400 mb-1">
155
+ <Zap className="w-3 h-3" />
156
+ <span>Max</span>
157
+ </div>
158
+ <div className="text-lg font-semibold text-green-400">
159
+ {formatReward(stats.max)}
160
+ </div>
161
+ </div>
162
+ <div className="bg-dark-900/50 rounded-lg p-2 text-center">
163
+ <div className="flex items-center justify-center gap-1 text-xs text-dark-400 mb-1">
164
+ <Zap className="w-3 h-3" />
165
+ <span>Min</span>
166
+ </div>
167
+ <div className="text-lg font-semibold text-red-400">
168
+ {formatReward(stats.min)}
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ {/* Cumulative Reward Chart */}
174
+ {isLoading ? (
175
+ <div className="h-40 flex items-center justify-center">
176
+ <Award className="w-6 h-6 text-dark-500 animate-pulse" />
177
+ </div>
178
+ ) : chartData.length === 0 ? (
179
+ <div className="h-40 flex items-center justify-center text-dark-500">
180
+ <div className="text-center">
181
+ <Award className="w-8 h-8 mx-auto mb-2 opacity-50" />
182
+ <p>No reward data</p>
183
+ </div>
184
+ </div>
185
+ ) : (
186
+ <>
187
+ <div className="h-40 mb-4">
188
+ <ResponsiveContainer width="100%" height="100%">
189
+ <AreaChart data={chartData}>
190
+ <defs>
191
+ <linearGradient id="rewardGradient" x1="0" y1="0" x2="0" y2="1">
192
+ <stop offset="5%" stopColor="#10a37f" stopOpacity={0.3} />
193
+ <stop offset="95%" stopColor="#10a37f" stopOpacity={0} />
194
+ </linearGradient>
195
+ </defs>
196
+ <CartesianGrid strokeDasharray="3 3" stroke="#40414f" />
197
+ <XAxis
198
+ dataKey="step"
199
+ stroke="#8e8ea0"
200
+ fontSize={10}
201
+ tickLine={false}
202
+ />
203
+ <YAxis stroke="#8e8ea0" fontSize={10} tickLine={false} />
204
+ <Tooltip content={<CustomTooltip />} />
205
+ <Area
206
+ type="monotone"
207
+ dataKey="cumulative"
208
+ name="Cumulative"
209
+ stroke="#10a37f"
210
+ fill="url(#rewardGradient)"
211
+ strokeWidth={2}
212
+ />
213
+ </AreaChart>
214
+ </ResponsiveContainer>
215
+ </div>
216
+
217
+ {/* Component Breakdown Bar Chart */}
218
+ {componentNames.length > 0 && (
219
+ <div className="h-32 mb-4">
220
+ <div className="text-xs text-dark-400 mb-2">
221
+ Reward Components
222
+ </div>
223
+ <ResponsiveContainer width="100%" height="100%">
224
+ <BarChart data={chartData.slice(-10)}>
225
+ <CartesianGrid strokeDasharray="3 3" stroke="#40414f" />
226
+ <XAxis
227
+ dataKey="step"
228
+ stroke="#8e8ea0"
229
+ fontSize={10}
230
+ tickLine={false}
231
+ />
232
+ <YAxis stroke="#8e8ea0" fontSize={10} tickLine={false} />
233
+ <Tooltip content={<CustomTooltip />} />
234
+ {componentNames.map((name, i) => (
235
+ <Bar
236
+ key={name}
237
+ dataKey={name}
238
+ stackId="a"
239
+ fill={COLORS[i % COLORS.length]}
240
+ />
241
+ ))}
242
+ </BarChart>
243
+ </ResponsiveContainer>
244
+ </div>
245
+ )}
246
+
247
+ {/* Pie Chart for Latest Breakdown */}
248
+ {pieData.length > 0 && (
249
+ <div className="flex items-center gap-4">
250
+ <div className="w-24 h-24">
251
+ <ResponsiveContainer width="100%" height="100%">
252
+ <PieChart>
253
+ <Pie
254
+ data={pieData}
255
+ dataKey="value"
256
+ nameKey="name"
257
+ cx="50%"
258
+ cy="50%"
259
+ innerRadius={20}
260
+ outerRadius={35}
261
+ paddingAngle={2}
262
+ >
263
+ {pieData.map((entry, i) => (
264
+ <Cell key={i} fill={entry.fill} />
265
+ ))}
266
+ </Pie>
267
+ </PieChart>
268
+ </ResponsiveContainer>
269
+ </div>
270
+ <div className="flex-1 grid grid-cols-2 gap-1">
271
+ {componentBreakdown.map((c, i) => (
272
+ <div key={c.name} className="flex items-center gap-2 text-xs">
273
+ <div
274
+ className="w-2 h-2 rounded-full"
275
+ style={{ backgroundColor: COLORS[i % COLORS.length] }}
276
+ />
277
+ <span className="text-dark-400 truncate">{c.name}</span>
278
+ <span
279
+ className={
280
+ c.value >= 0 ? 'text-green-400' : 'text-red-400'
281
+ }
282
+ >
283
+ {formatReward(c.value)}
284
+ </span>
285
+ </div>
286
+ ))}
287
+ </div>
288
+ </div>
289
+ )}
290
+ </>
291
+ )}
292
+ </CardContent>
293
+ </Card>
294
+ );
295
+ };
296
+
297
+ export default RewardChart;
frontend/src/components/Settings.tsx ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ Settings as SettingsIcon,
5
+ Key,
6
+ Cpu,
7
+ Wifi,
8
+ Database,
9
+ Image,
10
+ RefreshCw,
11
+ Save,
12
+ AlertCircle,
13
+ CheckCircle,
14
+ } from 'lucide-react';
15
+ import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/Card';
16
+ import { Button } from '@/components/ui/Button';
17
+ import { Input } from '@/components/ui/Input';
18
+ import { Select, Toggle } from '@/components/ui/Select';
19
+ import { Badge } from '@/components/ui/Badge';
20
+ import { apiClient } from '@/api/client';
21
+ import type { SystemSettings } from '@/types';
22
+
23
+ interface SettingsProps {
24
+ className?: string;
25
+ }
26
+
27
+ export const Settings: React.FC<SettingsProps> = ({ className }) => {
28
+ const queryClient = useQueryClient();
29
+ const [localSettings, setLocalSettings] = useState<Partial<SystemSettings>>({});
30
+ const [hasChanges, setHasChanges] = useState(false);
31
+
32
+ const { data: settings, isLoading } = useQuery({
33
+ queryKey: ['settings'],
34
+ queryFn: () => apiClient.getSettings(),
35
+ });
36
+
37
+ const { data: health } = useQuery({
38
+ queryKey: ['health'],
39
+ queryFn: () => apiClient.healthCheck(),
40
+ refetchInterval: 10000,
41
+ });
42
+
43
+ const updateMutation = useMutation({
44
+ mutationFn: (newSettings: Partial<SystemSettings>) =>
45
+ apiClient.updateSettings(newSettings),
46
+ onSuccess: () => {
47
+ queryClient.invalidateQueries({ queryKey: ['settings'] });
48
+ setHasChanges(false);
49
+ },
50
+ });
51
+
52
+ useEffect(() => {
53
+ if (settings) {
54
+ setLocalSettings(settings);
55
+ }
56
+ }, [settings]);
57
+
58
+ const handleChange = <K extends keyof SystemSettings>(
59
+ key: K,
60
+ value: SystemSettings[K]
61
+ ) => {
62
+ setLocalSettings((prev) => ({ ...prev, [key]: value }));
63
+ setHasChanges(true);
64
+ };
65
+
66
+ const handleSave = () => {
67
+ updateMutation.mutate(localSettings);
68
+ };
69
+
70
+ const handleReset = () => {
71
+ if (settings) {
72
+ setLocalSettings(settings);
73
+ setHasChanges(false);
74
+ }
75
+ };
76
+
77
+ const modelOptions = (settings?.availableModels ?? [
78
+ 'gpt-4-turbo',
79
+ 'gpt-4',
80
+ 'gpt-3.5-turbo',
81
+ 'claude-3-opus',
82
+ 'claude-3-sonnet',
83
+ ]).map((m) => ({ value: m, label: m }));
84
+
85
+ const logLevelOptions = [
86
+ { value: 'debug', label: 'Debug' },
87
+ { value: 'info', label: 'Info' },
88
+ { value: 'warn', label: 'Warning' },
89
+ { value: 'error', label: 'Error' },
90
+ ];
91
+
92
+ return (
93
+ <Card className={className}>
94
+ <CardHeader
95
+ title="Settings"
96
+ icon={<SettingsIcon className="w-4 h-4" />}
97
+ action={
98
+ health && (
99
+ <Badge variant={health.status === 'ok' ? 'success' : 'error'} dot>
100
+ {health.status === 'ok' ? 'Connected' : 'Disconnected'}
101
+ </Badge>
102
+ )
103
+ }
104
+ />
105
+ <CardContent>
106
+ {isLoading ? (
107
+ <div className="flex items-center justify-center py-8">
108
+ <SettingsIcon className="w-6 h-6 text-dark-500 animate-spin" />
109
+ </div>
110
+ ) : (
111
+ <div className="space-y-6">
112
+ {/* API Configuration */}
113
+ <div>
114
+ <div className="flex items-center gap-2 text-sm font-medium text-dark-200 mb-3">
115
+ <Key className="w-4 h-4" />
116
+ API Configuration
117
+ </div>
118
+ <div className="space-y-3">
119
+ <Input
120
+ label="API Key"
121
+ type="password"
122
+ placeholder="sk-..."
123
+ value={localSettings.apiKey ?? ''}
124
+ onChange={(e) => handleChange('apiKey', e.target.value)}
125
+ hint="Your OpenAI or provider API key"
126
+ />
127
+ </div>
128
+ </div>
129
+
130
+ {/* Model Settings */}
131
+ <div>
132
+ <div className="flex items-center gap-2 text-sm font-medium text-dark-200 mb-3">
133
+ <Cpu className="w-4 h-4" />
134
+ Model Settings
135
+ </div>
136
+ <div className="space-y-3">
137
+ <Select
138
+ label="Default Model"
139
+ options={modelOptions}
140
+ value={localSettings.defaultModel ?? ''}
141
+ onChange={(e) => handleChange('defaultModel', e.target.value)}
142
+ placeholder="Select model"
143
+ />
144
+ <Input
145
+ label="Max Concurrent Agents"
146
+ type="number"
147
+ min={1}
148
+ max={10}
149
+ value={localSettings.maxConcurrentAgents ?? 4}
150
+ onChange={(e) =>
151
+ handleChange('maxConcurrentAgents', parseInt(e.target.value))
152
+ }
153
+ />
154
+ </div>
155
+ </div>
156
+
157
+ {/* Connection Settings */}
158
+ <div>
159
+ <div className="flex items-center gap-2 text-sm font-medium text-dark-200 mb-3">
160
+ <Wifi className="w-4 h-4" />
161
+ Connection
162
+ </div>
163
+ <div className="space-y-3">
164
+ <Toggle
165
+ label="WebSocket Updates"
166
+ description="Enable real-time episode updates"
167
+ checked={localSettings.enableWebSocket ?? true}
168
+ onChange={(checked) => handleChange('enableWebSocket', checked)}
169
+ />
170
+ <Select
171
+ label="Log Level"
172
+ options={logLevelOptions}
173
+ value={localSettings.logLevel ?? 'info'}
174
+ onChange={(e) =>
175
+ handleChange('logLevel', e.target.value as SystemSettings['logLevel'])
176
+ }
177
+ />
178
+ </div>
179
+ </div>
180
+
181
+ {/* Storage Settings */}
182
+ <div>
183
+ <div className="flex items-center gap-2 text-sm font-medium text-dark-200 mb-3">
184
+ <Database className="w-4 h-4" />
185
+ Storage
186
+ </div>
187
+ <div className="space-y-3">
188
+ <Toggle
189
+ label="Memory Persistence"
190
+ description="Persist memory across episodes"
191
+ checked={localSettings.memoryPersistence ?? false}
192
+ onChange={(checked) =>
193
+ handleChange('memoryPersistence', checked)
194
+ }
195
+ />
196
+ </div>
197
+ </div>
198
+
199
+ {/* Screenshot Settings */}
200
+ <div>
201
+ <div className="flex items-center gap-2 text-sm font-medium text-dark-200 mb-3">
202
+ <Image className="w-4 h-4" />
203
+ Screenshots
204
+ </div>
205
+ <div className="space-y-3">
206
+ <Input
207
+ label="Screenshot Quality"
208
+ type="range"
209
+ min={10}
210
+ max={100}
211
+ value={localSettings.screenshotQuality ?? 80}
212
+ onChange={(e) =>
213
+ handleChange('screenshotQuality', parseInt(e.target.value))
214
+ }
215
+ hint={`Quality: ${localSettings.screenshotQuality ?? 80}%`}
216
+ />
217
+ </div>
218
+ </div>
219
+
220
+ {/* Status Messages */}
221
+ {updateMutation.isSuccess && (
222
+ <div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
223
+ <CheckCircle className="w-4 h-4 text-green-400" />
224
+ <span className="text-sm text-green-400">
225
+ Settings saved successfully
226
+ </span>
227
+ </div>
228
+ )}
229
+
230
+ {updateMutation.isError && (
231
+ <div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
232
+ <AlertCircle className="w-4 h-4 text-red-400" />
233
+ <span className="text-sm text-red-400">
234
+ {(updateMutation.error as Error).message}
235
+ </span>
236
+ </div>
237
+ )}
238
+ </div>
239
+ )}
240
+ </CardContent>
241
+ <CardFooter>
242
+ <Button
243
+ variant="ghost"
244
+ onClick={handleReset}
245
+ disabled={!hasChanges}
246
+ leftIcon={<RefreshCw className="w-4 h-4" />}
247
+ >
248
+ Reset
249
+ </Button>
250
+ <Button
251
+ variant="primary"
252
+ onClick={handleSave}
253
+ disabled={!hasChanges}
254
+ isLoading={updateMutation.isPending}
255
+ leftIcon={<Save className="w-4 h-4" />}
256
+ >
257
+ Save Changes
258
+ </Button>
259
+ </CardFooter>
260
+ </Card>
261
+ );
262
+ };
263
+
264
+ export default Settings;
frontend/src/components/ToolRegistry.tsx ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ Wrench,
5
+ Search,
6
+ ToggleLeft,
7
+ ToggleRight,
8
+ Play,
9
+ ChevronDown,
10
+ ChevronUp,
11
+ Clock,
12
+ Hash,
13
+ } from 'lucide-react';
14
+ import { Card, CardHeader, CardContent } from '@/components/ui/Card';
15
+ import { Button } from '@/components/ui/Button';
16
+ import { Badge } from '@/components/ui/Badge';
17
+ import { Input } from '@/components/ui/Input';
18
+ import { apiClient } from '@/api/client';
19
+ import { formatTimestamp } from '@/utils/helpers';
20
+ import type { MCPTool } from '@/types';
21
+
22
+ interface ToolRegistryProps {
23
+ className?: string;
24
+ }
25
+
26
+ interface ToolCardProps {
27
+ tool: MCPTool;
28
+ onToggle: (enabled: boolean) => void;
29
+ onExecute: (params: Record<string, unknown>) => void;
30
+ isToggling: boolean;
31
+ }
32
+
33
+ const ToolCard: React.FC<ToolCardProps> = ({
34
+ tool,
35
+ onToggle,
36
+ onExecute,
37
+ isToggling,
38
+ }) => {
39
+ const [isExpanded, setIsExpanded] = useState(false);
40
+
41
+ const categoryColors: Record<string, string> = {
42
+ browser: 'text-blue-400 bg-blue-400/10',
43
+ extraction: 'text-purple-400 bg-purple-400/10',
44
+ navigation: 'text-green-400 bg-green-400/10',
45
+ validation: 'text-orange-400 bg-orange-400/10',
46
+ utility: 'text-gray-400 bg-gray-400/10',
47
+ };
48
+
49
+ return (
50
+ <div className="bg-dark-900/50 rounded-lg overflow-hidden">
51
+ <div className="p-3">
52
+ <div className="flex items-start justify-between gap-3">
53
+ <div className="flex-1 min-w-0">
54
+ <div className="flex items-center gap-2 mb-1">
55
+ <span className="font-mono text-sm text-dark-100">
56
+ {tool.name}
57
+ </span>
58
+ <Badge
59
+ variant={tool.enabled ? 'success' : 'neutral'}
60
+ size="sm"
61
+ >
62
+ {tool.enabled ? 'Enabled' : 'Disabled'}
63
+ </Badge>
64
+ </div>
65
+ <p className="text-xs text-dark-400 line-clamp-2">
66
+ {tool.description}
67
+ </p>
68
+ </div>
69
+ <div className="flex items-center gap-2">
70
+ <button
71
+ onClick={() => onToggle(!tool.enabled)}
72
+ disabled={isToggling}
73
+ className="text-dark-400 hover:text-dark-200 transition-colors"
74
+ >
75
+ {tool.enabled ? (
76
+ <ToggleRight className="w-6 h-6 text-accent-primary" />
77
+ ) : (
78
+ <ToggleLeft className="w-6 h-6" />
79
+ )}
80
+ </button>
81
+ <button
82
+ onClick={() => setIsExpanded(!isExpanded)}
83
+ className="text-dark-400 hover:text-dark-200 transition-colors"
84
+ >
85
+ {isExpanded ? (
86
+ <ChevronUp className="w-4 h-4" />
87
+ ) : (
88
+ <ChevronDown className="w-4 h-4" />
89
+ )}
90
+ </button>
91
+ </div>
92
+ </div>
93
+
94
+ <div className="flex items-center gap-3 mt-2">
95
+ <span
96
+ className={`text-xs px-2 py-0.5 rounded ${
97
+ categoryColors[tool.category] ?? categoryColors.utility
98
+ }`}
99
+ >
100
+ {tool.category}
101
+ </span>
102
+ <div className="flex items-center gap-1 text-xs text-dark-500">
103
+ <Hash className="w-3 h-3" />
104
+ <span>{tool.usageCount}</span>
105
+ </div>
106
+ {tool.lastUsed && (
107
+ <div className="flex items-center gap-1 text-xs text-dark-500">
108
+ <Clock className="w-3 h-3" />
109
+ <span>{formatTimestamp(tool.lastUsed)}</span>
110
+ </div>
111
+ )}
112
+ </div>
113
+ </div>
114
+
115
+ {isExpanded && (
116
+ <div className="px-3 pb-3 border-t border-dark-700">
117
+ <div className="mt-3">
118
+ <div className="text-xs text-dark-400 mb-2">Input Schema</div>
119
+ <div className="code-block text-xs max-h-40 overflow-y-auto">
120
+ {JSON.stringify(tool.inputSchema, null, 2)}
121
+ </div>
122
+ </div>
123
+
124
+ <div className="mt-3 flex gap-2">
125
+ <Button
126
+ variant="secondary"
127
+ size="sm"
128
+ onClick={() => onExecute({})}
129
+ disabled={!tool.enabled}
130
+ leftIcon={<Play className="w-3 h-3" />}
131
+ >
132
+ Test Execute
133
+ </Button>
134
+ </div>
135
+ </div>
136
+ )}
137
+ </div>
138
+ );
139
+ };
140
+
141
+ export const ToolRegistry: React.FC<ToolRegistryProps> = ({ className }) => {
142
+ const queryClient = useQueryClient();
143
+ const [searchQuery, setSearchQuery] = useState('');
144
+ const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
145
+
146
+ const { data: tools, isLoading } = useQuery({
147
+ queryKey: ['tools'],
148
+ queryFn: () => apiClient.getTools(),
149
+ });
150
+
151
+ const toggleMutation = useMutation({
152
+ mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) =>
153
+ apiClient.toggleTool(name, enabled),
154
+ onSuccess: () => {
155
+ queryClient.invalidateQueries({ queryKey: ['tools'] });
156
+ },
157
+ });
158
+
159
+ const executeMutation = useMutation({
160
+ mutationFn: ({
161
+ name,
162
+ params,
163
+ }: {
164
+ name: string;
165
+ params: Record<string, unknown>;
166
+ }) => apiClient.executeTool(name, params),
167
+ });
168
+
169
+ const categories = React.useMemo((): string[] => {
170
+ if (!tools) return [];
171
+ return [...new Set(tools.map((t) => t.category).filter((c): c is string => !!c))];
172
+ }, [tools]);
173
+
174
+ const filteredTools = React.useMemo(() => {
175
+ if (!tools) return [];
176
+ return tools.filter((tool) => {
177
+ const matchesSearch =
178
+ !searchQuery ||
179
+ tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
180
+ tool.description.toLowerCase().includes(searchQuery.toLowerCase());
181
+ const matchesCategory =
182
+ !categoryFilter || tool.category === categoryFilter;
183
+ return matchesSearch && matchesCategory;
184
+ });
185
+ }, [tools, searchQuery, categoryFilter]);
186
+
187
+ const enabledCount = tools?.filter((t) => t.enabled).length ?? 0;
188
+
189
+ return (
190
+ <Card className={className}>
191
+ <CardHeader
192
+ title="MCP Tools"
193
+ subtitle={`${enabledCount}/${tools?.length ?? 0} enabled`}
194
+ icon={<Wrench className="w-4 h-4" />}
195
+ />
196
+ <CardContent>
197
+ {/* Search & Filter */}
198
+ <div className="flex gap-2 mb-4">
199
+ <Input
200
+ placeholder="Search tools..."
201
+ value={searchQuery}
202
+ onChange={(e) => setSearchQuery(e.target.value)}
203
+ leftIcon={<Search className="w-4 h-4" />}
204
+ className="flex-1"
205
+ />
206
+ </div>
207
+
208
+ {/* Category Pills */}
209
+ <div className="flex flex-wrap gap-2 mb-4">
210
+ <button
211
+ onClick={() => setCategoryFilter(null)}
212
+ className={`px-3 py-1 text-xs rounded-full transition-colors ${
213
+ !categoryFilter
214
+ ? 'bg-accent-primary text-white'
215
+ : 'bg-dark-700 text-dark-300 hover:bg-dark-600'
216
+ }`}
217
+ >
218
+ All
219
+ </button>
220
+ {categories.map((cat: string) => (
221
+ <button
222
+ key={cat}
223
+ onClick={() =>
224
+ setCategoryFilter(categoryFilter === cat ? null : cat)
225
+ }
226
+ className={`px-3 py-1 text-xs rounded-full transition-colors capitalize ${
227
+ categoryFilter === cat
228
+ ? 'bg-accent-primary text-white'
229
+ : 'bg-dark-700 text-dark-300 hover:bg-dark-600'
230
+ }`}
231
+ >
232
+ {cat}
233
+ </button>
234
+ ))}
235
+ </div>
236
+
237
+ {/* Tools List */}
238
+ <div className="space-y-2 max-h-[400px] overflow-y-auto">
239
+ {isLoading ? (
240
+ <div className="flex items-center justify-center py-8">
241
+ <Wrench className="w-6 h-6 text-dark-500 animate-pulse" />
242
+ </div>
243
+ ) : filteredTools.length === 0 ? (
244
+ <div className="text-center py-8 text-dark-500">
245
+ <Wrench className="w-8 h-8 mx-auto mb-2 opacity-50" />
246
+ <p>No tools found</p>
247
+ </div>
248
+ ) : (
249
+ filteredTools.map((tool) => (
250
+ <ToolCard
251
+ key={tool.name}
252
+ tool={tool}
253
+ onToggle={(enabled) =>
254
+ toggleMutation.mutate({ name: tool.name, enabled })
255
+ }
256
+ onExecute={(params) =>
257
+ executeMutation.mutate({ name: tool.name, params })
258
+ }
259
+ isToggling={toggleMutation.isPending}
260
+ />
261
+ ))
262
+ )}
263
+ </div>
264
+
265
+ {/* Execution Result */}
266
+ {executeMutation.data !== undefined && (
267
+ <div className="mt-4 p-3 bg-dark-900/50 rounded-lg">
268
+ <div className="text-xs text-dark-400 mb-1">Execution Result</div>
269
+ <div className="code-block text-xs max-h-32 overflow-y-auto">
270
+ {JSON.stringify(executeMutation.data, null, 2)}
271
+ </div>
272
+ </div>
273
+ )}
274
+
275
+ {executeMutation.isError && (
276
+ <div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
277
+ <div className="text-xs text-red-400">
278
+ {(executeMutation.error as Error).message}
279
+ </div>
280
+ </div>
281
+ )}
282
+ </CardContent>
283
+ </Card>
284
+ );
285
+ };
286
+
287
+ export default ToolRegistry;
frontend/src/components/ui/Badge.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { classNames } from '@/utils/helpers';
3
+
4
+ type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral';
5
+ type BadgeSize = 'sm' | 'md' | 'lg';
6
+
7
+ interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
8
+ variant?: BadgeVariant;
9
+ size?: BadgeSize;
10
+ dot?: boolean;
11
+ pulse?: boolean;
12
+ icon?: React.ReactNode;
13
+ }
14
+
15
+ export const Badge: React.FC<BadgeProps> = ({
16
+ children,
17
+ variant = 'neutral',
18
+ size = 'md',
19
+ dot = false,
20
+ pulse = false,
21
+ icon,
22
+ className,
23
+ ...props
24
+ }) => {
25
+ const variantStyles: Record<BadgeVariant, string> = {
26
+ success: 'bg-green-500/20 text-green-400 border-green-500/30',
27
+ warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
28
+ error: 'bg-red-500/20 text-red-400 border-red-500/30',
29
+ info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
30
+ neutral: 'bg-dark-600/50 text-dark-300 border-dark-500/30',
31
+ };
32
+
33
+ const dotColors: Record<BadgeVariant, string> = {
34
+ success: 'bg-green-400',
35
+ warning: 'bg-yellow-400',
36
+ error: 'bg-red-400',
37
+ info: 'bg-blue-400',
38
+ neutral: 'bg-dark-400',
39
+ };
40
+
41
+ const sizeStyles: Record<BadgeSize, string> = {
42
+ sm: 'px-2 py-0.5 text-xs',
43
+ md: 'px-2.5 py-0.5 text-xs',
44
+ lg: 'px-3 py-1 text-sm',
45
+ };
46
+
47
+ return (
48
+ <span
49
+ className={classNames(
50
+ 'inline-flex items-center gap-1.5 rounded-full font-medium border',
51
+ variantStyles[variant],
52
+ sizeStyles[size],
53
+ className
54
+ )}
55
+ {...props}
56
+ >
57
+ {dot && (
58
+ <span
59
+ className={classNames(
60
+ 'w-1.5 h-1.5 rounded-full',
61
+ dotColors[variant],
62
+ pulse && 'animate-pulse'
63
+ )}
64
+ />
65
+ )}
66
+ {icon}
67
+ {children}
68
+ </span>
69
+ );
70
+ };
71
+
72
+ interface StatusBadgeProps {
73
+ status: string;
74
+ size?: BadgeSize;
75
+ }
76
+
77
+ export const StatusBadge: React.FC<StatusBadgeProps> = ({
78
+ status,
79
+ size = 'md',
80
+ }) => {
81
+ const getVariant = (): BadgeVariant => {
82
+ switch (status.toLowerCase()) {
83
+ case 'running':
84
+ case 'active':
85
+ case 'acting':
86
+ case 'completed':
87
+ case 'success':
88
+ return 'success';
89
+ case 'thinking':
90
+ case 'processing':
91
+ return 'info';
92
+ case 'idle':
93
+ case 'waiting':
94
+ case 'pending':
95
+ case 'timeout':
96
+ return 'warning';
97
+ case 'error':
98
+ case 'failed':
99
+ return 'error';
100
+ default:
101
+ return 'neutral';
102
+ }
103
+ };
104
+
105
+ const shouldPulse = ['running', 'acting', 'thinking', 'processing'].includes(
106
+ status.toLowerCase()
107
+ );
108
+
109
+ return (
110
+ <Badge variant={getVariant()} size={size} dot pulse={shouldPulse}>
111
+ {status}
112
+ </Badge>
113
+ );
114
+ };
115
+
116
+ export default Badge;
frontend/src/components/ui/Button.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { classNames } from '@/utils/helpers';
3
+
4
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
6
+ size?: 'sm' | 'md' | 'lg';
7
+ isLoading?: boolean;
8
+ leftIcon?: React.ReactNode;
9
+ rightIcon?: React.ReactNode;
10
+ }
11
+
12
+ export const Button: React.FC<ButtonProps> = ({
13
+ children,
14
+ variant = 'primary',
15
+ size = 'md',
16
+ isLoading = false,
17
+ leftIcon,
18
+ rightIcon,
19
+ className,
20
+ disabled,
21
+ ...props
22
+ }) => {
23
+ const baseStyles = 'btn';
24
+
25
+ const variantStyles = {
26
+ primary: 'btn-primary',
27
+ secondary: 'btn-secondary',
28
+ ghost: 'btn-ghost',
29
+ danger: 'btn-danger',
30
+ };
31
+
32
+ const sizeStyles = {
33
+ sm: 'px-3 py-1.5 text-xs',
34
+ md: 'px-4 py-2 text-sm',
35
+ lg: 'px-6 py-3 text-base',
36
+ };
37
+
38
+ return (
39
+ <button
40
+ className={classNames(
41
+ baseStyles,
42
+ variantStyles[variant],
43
+ sizeStyles[size],
44
+ className
45
+ )}
46
+ disabled={disabled || isLoading}
47
+ {...props}
48
+ >
49
+ {isLoading && (
50
+ <svg
51
+ className="animate-spin h-4 w-4"
52
+ xmlns="http://www.w3.org/2000/svg"
53
+ fill="none"
54
+ viewBox="0 0 24 24"
55
+ >
56
+ <circle
57
+ className="opacity-25"
58
+ cx="12"
59
+ cy="12"
60
+ r="10"
61
+ stroke="currentColor"
62
+ strokeWidth="4"
63
+ />
64
+ <path
65
+ className="opacity-75"
66
+ fill="currentColor"
67
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
68
+ />
69
+ </svg>
70
+ )}
71
+ {!isLoading && leftIcon}
72
+ {children}
73
+ {!isLoading && rightIcon}
74
+ </button>
75
+ );
76
+ };
77
+
78
+ export default Button;
frontend/src/components/ui/Card.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { classNames } from '@/utils/helpers';
3
+
4
+ interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ variant?: 'default' | 'bordered' | 'elevated' | 'glass';
6
+ padding?: 'none' | 'sm' | 'md' | 'lg';
7
+ }
8
+
9
+ export const Card: React.FC<CardProps> = ({
10
+ children,
11
+ variant = 'default',
12
+ padding = 'md',
13
+ className,
14
+ ...props
15
+ }) => {
16
+ const variantStyles = {
17
+ default: 'bg-dark-800 border border-dark-700',
18
+ bordered: 'bg-transparent border-2 border-dark-600',
19
+ elevated: 'bg-dark-800 shadow-xl shadow-black/20',
20
+ glass: 'bg-dark-800/80 backdrop-blur-sm border border-dark-700/50',
21
+ };
22
+
23
+ const paddingStyles = {
24
+ none: 'p-0',
25
+ sm: 'p-3',
26
+ md: 'p-4',
27
+ lg: 'p-6',
28
+ };
29
+
30
+ return (
31
+ <div
32
+ className={classNames(
33
+ 'rounded-xl',
34
+ variantStyles[variant],
35
+ paddingStyles[padding],
36
+ className
37
+ )}
38
+ {...props}
39
+ >
40
+ {children}
41
+ </div>
42
+ );
43
+ };
44
+
45
+ interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
46
+ title: string;
47
+ subtitle?: string;
48
+ action?: React.ReactNode;
49
+ icon?: React.ReactNode;
50
+ }
51
+
52
+ export const CardHeader: React.FC<CardHeaderProps> = ({
53
+ title,
54
+ subtitle,
55
+ action,
56
+ icon,
57
+ className,
58
+ ...props
59
+ }) => {
60
+ return (
61
+ <div
62
+ className={classNames(
63
+ 'flex items-center justify-between mb-4 pb-3 border-b border-dark-700',
64
+ className
65
+ )}
66
+ {...props}
67
+ >
68
+ <div className="flex items-center gap-3">
69
+ {icon && (
70
+ <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-dark-700 text-dark-300">
71
+ {icon}
72
+ </div>
73
+ )}
74
+ <div>
75
+ <h3 className="text-lg font-semibold text-dark-100">{title}</h3>
76
+ {subtitle && (
77
+ <p className="text-sm text-dark-400 mt-0.5">{subtitle}</p>
78
+ )}
79
+ </div>
80
+ </div>
81
+ {action && <div>{action}</div>}
82
+ </div>
83
+ );
84
+ };
85
+
86
+ type CardContentProps = React.HTMLAttributes<HTMLDivElement>;
87
+
88
+ export const CardContent: React.FC<CardContentProps> = ({
89
+ children,
90
+ className,
91
+ ...props
92
+ }) => {
93
+ return (
94
+ <div className={classNames('', className)} {...props}>
95
+ {children}
96
+ </div>
97
+ );
98
+ };
99
+
100
+ type CardFooterProps = React.HTMLAttributes<HTMLDivElement>;
101
+
102
+ export const CardFooter: React.FC<CardFooterProps> = ({
103
+ children,
104
+ className,
105
+ ...props
106
+ }) => {
107
+ return (
108
+ <div
109
+ className={classNames(
110
+ 'flex items-center justify-end gap-3 mt-4 pt-3 border-t border-dark-700',
111
+ className
112
+ )}
113
+ {...props}
114
+ >
115
+ {children}
116
+ </div>
117
+ );
118
+ };
119
+
120
+ export default Card;
frontend/src/components/ui/Input.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { forwardRef } from 'react';
2
+ import { classNames } from '@/utils/helpers';
3
+
4
+ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
5
+ label?: string;
6
+ error?: string;
7
+ hint?: string;
8
+ leftIcon?: React.ReactNode;
9
+ rightIcon?: React.ReactNode;
10
+ variant?: 'default' | 'filled';
11
+ }
12
+
13
+ export const Input = forwardRef<HTMLInputElement, InputProps>(
14
+ (
15
+ {
16
+ label,
17
+ error,
18
+ hint,
19
+ leftIcon,
20
+ rightIcon,
21
+ variant = 'default',
22
+ className,
23
+ id,
24
+ ...props
25
+ },
26
+ ref
27
+ ) => {
28
+ const inputId = id || `input-${Math.random().toString(36).slice(2, 9)}`;
29
+
30
+ const variantStyles = {
31
+ default: 'bg-dark-900 border-dark-600',
32
+ filled: 'bg-dark-700 border-transparent',
33
+ };
34
+
35
+ return (
36
+ <div className="w-full">
37
+ {label && (
38
+ <label
39
+ htmlFor={inputId}
40
+ className="block text-sm font-medium text-dark-300 mb-1.5"
41
+ >
42
+ {label}
43
+ </label>
44
+ )}
45
+ <div className="relative">
46
+ {leftIcon && (
47
+ <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-dark-500">
48
+ {leftIcon}
49
+ </div>
50
+ )}
51
+ <input
52
+ ref={ref}
53
+ id={inputId}
54
+ className={classNames(
55
+ 'w-full px-3 py-2 border rounded-lg text-dark-100 placeholder-dark-500',
56
+ 'focus:outline-none focus:border-accent-primary focus:ring-1 focus:ring-accent-primary/50',
57
+ 'transition-colors duration-200',
58
+ variantStyles[variant],
59
+ leftIcon ? 'pl-10' : '',
60
+ rightIcon ? 'pr-10' : '',
61
+ error ? 'border-red-500 focus:border-red-500 focus:ring-red-500/50' : '',
62
+ className
63
+ )}
64
+ {...props}
65
+ />
66
+ {rightIcon && (
67
+ <div className="absolute inset-y-0 right-0 pr-3 flex items-center text-dark-500">
68
+ {rightIcon}
69
+ </div>
70
+ )}
71
+ </div>
72
+ {(error || hint) && (
73
+ <p
74
+ className={classNames(
75
+ 'mt-1.5 text-sm',
76
+ error ? 'text-red-400' : 'text-dark-500'
77
+ )}
78
+ >
79
+ {error || hint}
80
+ </p>
81
+ )}
82
+ </div>
83
+ );
84
+ }
85
+ );
86
+
87
+ Input.displayName = 'Input';
88
+
89
+ interface TextareaProps
90
+ extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
91
+ label?: string;
92
+ error?: string;
93
+ hint?: string;
94
+ }
95
+
96
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
97
+ ({ label, error, hint, className, id, ...props }, ref) => {
98
+ const inputId = id || `textarea-${Math.random().toString(36).slice(2, 9)}`;
99
+
100
+ return (
101
+ <div className="w-full">
102
+ {label && (
103
+ <label
104
+ htmlFor={inputId}
105
+ className="block text-sm font-medium text-dark-300 mb-1.5"
106
+ >
107
+ {label}
108
+ </label>
109
+ )}
110
+ <textarea
111
+ ref={ref}
112
+ id={inputId}
113
+ className={classNames(
114
+ 'w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg',
115
+ 'text-dark-100 placeholder-dark-500 resize-none',
116
+ 'focus:outline-none focus:border-accent-primary focus:ring-1 focus:ring-accent-primary/50',
117
+ 'transition-colors duration-200',
118
+ error && 'border-red-500 focus:border-red-500 focus:ring-red-500/50',
119
+ className
120
+ )}
121
+ {...props}
122
+ />
123
+ {(error || hint) && (
124
+ <p
125
+ className={classNames(
126
+ 'mt-1.5 text-sm',
127
+ error ? 'text-red-400' : 'text-dark-500'
128
+ )}
129
+ >
130
+ {error || hint}
131
+ </p>
132
+ )}
133
+ </div>
134
+ );
135
+ }
136
+ );
137
+
138
+ Textarea.displayName = 'Textarea';
139
+
140
+ export default Input;
frontend/src/components/ui/Select.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { forwardRef } from 'react';
2
+ import { ChevronDown } from 'lucide-react';
3
+ import { classNames } from '@/utils/helpers';
4
+
5
+ interface SelectOption {
6
+ value: string;
7
+ label: string;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
12
+ label?: string;
13
+ error?: string;
14
+ hint?: string;
15
+ options: SelectOption[];
16
+ placeholder?: string;
17
+ }
18
+
19
+ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
20
+ ({ label, error, hint, options, placeholder, className, id, ...props }, ref) => {
21
+ const selectId = id || `select-${Math.random().toString(36).slice(2, 9)}`;
22
+
23
+ return (
24
+ <div className="w-full">
25
+ {label && (
26
+ <label
27
+ htmlFor={selectId}
28
+ className="block text-sm font-medium text-dark-300 mb-1.5"
29
+ >
30
+ {label}
31
+ </label>
32
+ )}
33
+ <div className="relative">
34
+ <select
35
+ ref={ref}
36
+ id={selectId}
37
+ className={classNames(
38
+ 'w-full px-3 py-2 pr-10 bg-dark-900 border border-dark-600 rounded-lg',
39
+ 'text-dark-100 appearance-none cursor-pointer',
40
+ 'focus:outline-none focus:border-accent-primary focus:ring-1 focus:ring-accent-primary/50',
41
+ 'transition-colors duration-200',
42
+ error && 'border-red-500 focus:border-red-500 focus:ring-red-500/50',
43
+ className
44
+ )}
45
+ {...props}
46
+ >
47
+ {placeholder && (
48
+ <option value="" disabled>
49
+ {placeholder}
50
+ </option>
51
+ )}
52
+ {options.map((option) => (
53
+ <option
54
+ key={option.value}
55
+ value={option.value}
56
+ disabled={option.disabled}
57
+ >
58
+ {option.label}
59
+ </option>
60
+ ))}
61
+ </select>
62
+ <div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-dark-500">
63
+ <ChevronDown className="w-4 h-4" />
64
+ </div>
65
+ </div>
66
+ {(error || hint) && (
67
+ <p
68
+ className={classNames(
69
+ 'mt-1.5 text-sm',
70
+ error ? 'text-red-400' : 'text-dark-500'
71
+ )}
72
+ >
73
+ {error || hint}
74
+ </p>
75
+ )}
76
+ </div>
77
+ );
78
+ }
79
+ );
80
+
81
+ Select.displayName = 'Select';
82
+
83
+ interface ToggleProps {
84
+ label?: string;
85
+ description?: string;
86
+ checked: boolean;
87
+ onChange: (checked: boolean) => void;
88
+ disabled?: boolean;
89
+ }
90
+
91
+ export const Toggle: React.FC<ToggleProps> = ({
92
+ label,
93
+ description,
94
+ checked,
95
+ onChange,
96
+ disabled = false,
97
+ }) => {
98
+ return (
99
+ <label
100
+ className={classNames(
101
+ 'flex items-center justify-between gap-4 cursor-pointer',
102
+ disabled && 'opacity-50 cursor-not-allowed'
103
+ )}
104
+ >
105
+ <div>
106
+ {label && (
107
+ <span className="block text-sm font-medium text-dark-200">
108
+ {label}
109
+ </span>
110
+ )}
111
+ {description && (
112
+ <span className="block text-sm text-dark-500 mt-0.5">
113
+ {description}
114
+ </span>
115
+ )}
116
+ </div>
117
+ <button
118
+ type="button"
119
+ role="switch"
120
+ aria-checked={checked}
121
+ disabled={disabled}
122
+ onClick={() => !disabled && onChange(!checked)}
123
+ className={classNames(
124
+ 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
125
+ 'focus:outline-none focus:ring-2 focus:ring-accent-primary/50',
126
+ checked ? 'bg-accent-primary' : 'bg-dark-600'
127
+ )}
128
+ >
129
+ <span
130
+ className={classNames(
131
+ 'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
132
+ checked ? 'translate-x-6' : 'translate-x-1'
133
+ )}
134
+ />
135
+ </button>
136
+ </label>
137
+ );
138
+ };
139
+
140
+ export default Select;
frontend/src/hooks/useAgents.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { apiClient } from '@/api/client';
3
+ import type { Agent } from '@/types';
4
+
5
+ export function useAgents(episodeId: string | undefined) {
6
+ return useQuery({
7
+ queryKey: ['episode', episodeId, 'agents'],
8
+ queryFn: () => (episodeId ? apiClient.getAgents(episodeId) : []),
9
+ enabled: !!episodeId,
10
+ refetchInterval: 1000,
11
+ });
12
+ }
13
+
14
+ export function useAgent(episodeId: string | undefined, agentId: string) {
15
+ return useQuery({
16
+ queryKey: ['episode', episodeId, 'agents', agentId],
17
+ queryFn: () =>
18
+ episodeId ? apiClient.getAgent(episodeId, agentId) : null,
19
+ enabled: !!episodeId && !!agentId,
20
+ refetchInterval: 500,
21
+ });
22
+ }
23
+
24
+ export function useUpdateAgent(episodeId: string | undefined) {
25
+ const queryClient = useQueryClient();
26
+
27
+ return useMutation({
28
+ mutationFn: ({
29
+ agentId,
30
+ updates,
31
+ }: {
32
+ agentId: string;
33
+ updates: Partial<Agent>;
34
+ }) =>
35
+ episodeId
36
+ ? apiClient.updateAgent(episodeId, agentId, updates)
37
+ : Promise.reject(new Error('No episode')),
38
+ onSuccess: (_, { agentId }) => {
39
+ queryClient.invalidateQueries({
40
+ queryKey: ['episode', episodeId, 'agents', agentId],
41
+ });
42
+ queryClient.invalidateQueries({
43
+ queryKey: ['episode', episodeId, 'agents'],
44
+ });
45
+ },
46
+ });
47
+ }
48
+
49
+ export function getAgentStatusVariant(
50
+ status: Agent['status']
51
+ ): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
52
+ switch (status) {
53
+ case 'acting':
54
+ return 'success';
55
+ case 'thinking':
56
+ return 'info';
57
+ case 'waiting':
58
+ return 'warning';
59
+ case 'error':
60
+ return 'error';
61
+ case 'idle':
62
+ default:
63
+ return 'neutral';
64
+ }
65
+ }
66
+
67
+ export function getAgentRoleColor(role: Agent['role']): string {
68
+ switch (role) {
69
+ case 'navigator':
70
+ return 'text-blue-400';
71
+ case 'extractor':
72
+ return 'text-purple-400';
73
+ case 'validator':
74
+ return 'text-green-400';
75
+ case 'coordinator':
76
+ return 'text-orange-400';
77
+ default:
78
+ return 'text-dark-400';
79
+ }
80
+ }
81
+
82
+ export function getAgentRoleIcon(role: Agent['role']): string {
83
+ switch (role) {
84
+ case 'navigator':
85
+ return '🧭';
86
+ case 'extractor':
87
+ return 'πŸ“Š';
88
+ case 'validator':
89
+ return 'βœ…';
90
+ case 'coordinator':
91
+ return '🎯';
92
+ default:
93
+ return 'πŸ€–';
94
+ }
95
+ }
96
+
97
+ export type { Agent };
frontend/src/hooks/useEpisode.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { apiClient } from '@/api/client';
3
+ import type { Episode, ResetRequest, StepRequest, Action } from '@/types';
4
+
5
+ export function useCurrentEpisode() {
6
+ return useQuery({
7
+ queryKey: ['episode', 'current'],
8
+ queryFn: () => apiClient.getCurrentEpisode(),
9
+ refetchInterval: 2000,
10
+ });
11
+ }
12
+
13
+ export function useEpisode(episodeId: string | undefined) {
14
+ return useQuery({
15
+ queryKey: ['episode', episodeId],
16
+ queryFn: () => (episodeId ? apiClient.getEpisode(episodeId) : null),
17
+ enabled: !!episodeId,
18
+ refetchInterval: 1000,
19
+ });
20
+ }
21
+
22
+ export function useResetEpisode() {
23
+ const queryClient = useQueryClient();
24
+
25
+ return useMutation({
26
+ mutationFn: (params: ResetRequest) => apiClient.resetEpisode(params),
27
+ onSuccess: (episode) => {
28
+ queryClient.setQueryData(['episode', 'current'], episode);
29
+ queryClient.setQueryData(['episode', episode.id], episode);
30
+ queryClient.invalidateQueries({ queryKey: ['episode'] });
31
+ },
32
+ });
33
+ }
34
+
35
+ export function useStepEpisode(episodeId: string | undefined) {
36
+ const queryClient = useQueryClient();
37
+
38
+ return useMutation({
39
+ mutationFn: (step: StepRequest) =>
40
+ episodeId
41
+ ? apiClient.stepEpisode(episodeId, step)
42
+ : Promise.reject(new Error('No episode')),
43
+ onSuccess: () => {
44
+ queryClient.invalidateQueries({ queryKey: ['episode', episodeId] });
45
+ queryClient.invalidateQueries({ queryKey: ['episode', 'current'] });
46
+ },
47
+ });
48
+ }
49
+
50
+ export function useTerminateEpisode() {
51
+ const queryClient = useQueryClient();
52
+
53
+ return useMutation({
54
+ mutationFn: (episodeId: string) => apiClient.terminateEpisode(episodeId),
55
+ onSuccess: (_, episodeId) => {
56
+ queryClient.invalidateQueries({ queryKey: ['episode', episodeId] });
57
+ queryClient.invalidateQueries({ queryKey: ['episode', 'current'] });
58
+ },
59
+ });
60
+ }
61
+
62
+ export function useEpisodeActions(episodeId: string | undefined) {
63
+ return useQuery({
64
+ queryKey: ['episode', episodeId, 'actions'],
65
+ queryFn: () => (episodeId ? apiClient.getActions(episodeId) : []),
66
+ enabled: !!episodeId,
67
+ refetchInterval: 1000,
68
+ });
69
+ }
70
+
71
+ export function useEpisodeRewards(episodeId: string | undefined) {
72
+ return useQuery({
73
+ queryKey: ['episode', episodeId, 'rewards'],
74
+ queryFn: () => (episodeId ? apiClient.getRewards(episodeId) : []),
75
+ enabled: !!episodeId,
76
+ refetchInterval: 1000,
77
+ });
78
+ }
79
+
80
+ export function useEpisodeStats() {
81
+ return useQuery({
82
+ queryKey: ['stats'],
83
+ queryFn: () => apiClient.getStats(),
84
+ refetchInterval: 5000,
85
+ });
86
+ }
87
+
88
+ export function useEpisodeState(episodeId: string | undefined) {
89
+ return useQuery({
90
+ queryKey: ['episode', episodeId, 'state'],
91
+ queryFn: () => (episodeId ? apiClient.getState(episodeId) : null),
92
+ enabled: !!episodeId,
93
+ refetchInterval: 500,
94
+ });
95
+ }
96
+
97
+ // Helper to create a quick action
98
+ export function createAction(
99
+ type: Action['type'],
100
+ agentId: string,
101
+ options: Partial<Omit<Action, 'type' | 'agentId' | 'timestamp'>> = {}
102
+ ): StepRequest {
103
+ return {
104
+ action: {
105
+ type,
106
+ agentId,
107
+ confidence: 1.0,
108
+ ...options,
109
+ },
110
+ agentId,
111
+ };
112
+ }
113
+
114
+ export type { Episode, ResetRequest, StepRequest };
frontend/src/hooks/useMemory.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { apiClient } from '@/api/client';
3
+ import type { MemoryState, MemoryEntry, MemoryLayer } from '@/types';
4
+
5
+ export function useMemory(episodeId: string | undefined) {
6
+ return useQuery({
7
+ queryKey: ['episode', episodeId, 'memory'],
8
+ queryFn: () => (episodeId ? apiClient.getMemory(episodeId) : null),
9
+ enabled: !!episodeId,
10
+ refetchInterval: 2000,
11
+ });
12
+ }
13
+
14
+ export function useQueryMemory(episodeId: string | undefined) {
15
+ const queryClient = useQueryClient();
16
+
17
+ return useMutation({
18
+ mutationFn: ({
19
+ query,
20
+ layer,
21
+ limit,
22
+ }: {
23
+ query: string;
24
+ layer?: MemoryLayer;
25
+ limit?: number;
26
+ }) =>
27
+ episodeId
28
+ ? apiClient.queryMemory(episodeId, query, layer, limit)
29
+ : Promise.reject(new Error('No episode')),
30
+ onSuccess: () => {
31
+ queryClient.invalidateQueries({
32
+ queryKey: ['episode', episodeId, 'memory'],
33
+ });
34
+ },
35
+ });
36
+ }
37
+
38
+ export function useAddMemory(episodeId: string | undefined) {
39
+ const queryClient = useQueryClient();
40
+
41
+ return useMutation({
42
+ mutationFn: (entry: Omit<MemoryEntry, 'id' | 'timestamp'>) =>
43
+ episodeId
44
+ ? apiClient.addMemory(episodeId, entry)
45
+ : Promise.reject(new Error('No episode')),
46
+ onSuccess: () => {
47
+ queryClient.invalidateQueries({
48
+ queryKey: ['episode', episodeId, 'memory'],
49
+ });
50
+ },
51
+ });
52
+ }
53
+
54
+ export function useClearMemory(episodeId: string | undefined) {
55
+ const queryClient = useQueryClient();
56
+
57
+ return useMutation({
58
+ mutationFn: (layer?: MemoryLayer) =>
59
+ episodeId
60
+ ? apiClient.clearMemory(episodeId, layer)
61
+ : Promise.reject(new Error('No episode')),
62
+ onSuccess: () => {
63
+ queryClient.invalidateQueries({
64
+ queryKey: ['episode', episodeId, 'memory'],
65
+ });
66
+ },
67
+ });
68
+ }
69
+
70
+ export function getMemoryLayerColor(layer: MemoryLayer): string {
71
+ switch (layer) {
72
+ case 'short_term':
73
+ return 'text-yellow-400';
74
+ case 'working':
75
+ return 'text-blue-400';
76
+ case 'long_term':
77
+ return 'text-purple-400';
78
+ case 'shared':
79
+ return 'text-green-400';
80
+ default:
81
+ return 'text-dark-400';
82
+ }
83
+ }
84
+
85
+ export function getMemoryLayerBadge(layer: MemoryLayer): string {
86
+ switch (layer) {
87
+ case 'short_term':
88
+ return 'badge-warning';
89
+ case 'working':
90
+ return 'badge-info';
91
+ case 'long_term':
92
+ return 'badge-neutral';
93
+ case 'shared':
94
+ return 'badge-success';
95
+ default:
96
+ return 'badge-neutral';
97
+ }
98
+ }
99
+
100
+ export function formatMemorySize(bytes: number): string {
101
+ if (bytes < 1024) return `${bytes} B`;
102
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
103
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
104
+ }
105
+
106
+ export type { MemoryState, MemoryEntry, MemoryLayer };
frontend/src/hooks/useWebSocket.ts ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useCallback, useState } from 'react';
2
+ import type { WebSocketMessage } from '@/types';
3
+
4
+ type MessageHandler = (message: WebSocketMessage) => void;
5
+
6
+ interface UseWebSocketOptions {
7
+ onMessage?: MessageHandler;
8
+ onOpen?: () => void;
9
+ onClose?: () => void;
10
+ onError?: (error: Event) => void;
11
+ reconnectAttempts?: number;
12
+ reconnectInterval?: number;
13
+ autoConnect?: boolean;
14
+ }
15
+
16
+ interface UseWebSocketReturn {
17
+ isConnected: boolean;
18
+ isConnecting: boolean;
19
+ connect: () => void;
20
+ disconnect: () => void;
21
+ send: (message: unknown) => void;
22
+ lastMessage: WebSocketMessage | null;
23
+ }
24
+
25
+ export function useWebSocket(
26
+ url: string = '/ws',
27
+ options: UseWebSocketOptions = {}
28
+ ): UseWebSocketReturn {
29
+ const {
30
+ onMessage,
31
+ onOpen,
32
+ onClose,
33
+ onError,
34
+ reconnectAttempts = 5,
35
+ reconnectInterval = 3000,
36
+ autoConnect = true,
37
+ } = options;
38
+
39
+ const wsRef = useRef<WebSocket | null>(null);
40
+ const reconnectCountRef = useRef(0);
41
+ const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
42
+
43
+ const [isConnected, setIsConnected] = useState(false);
44
+ const [isConnecting, setIsConnecting] = useState(false);
45
+ const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
46
+
47
+ const getWebSocketUrl = useCallback((): string => {
48
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
49
+ const host = window.location.host;
50
+ return url.startsWith('/') ? `${protocol}//${host}${url}` : url;
51
+ }, [url]);
52
+
53
+ const connect = useCallback(() => {
54
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
55
+ return;
56
+ }
57
+
58
+ setIsConnecting(true);
59
+
60
+ try {
61
+ const wsUrl = getWebSocketUrl();
62
+ wsRef.current = new WebSocket(wsUrl);
63
+
64
+ wsRef.current.onopen = () => {
65
+ setIsConnected(true);
66
+ setIsConnecting(false);
67
+ reconnectCountRef.current = 0;
68
+ onOpen?.();
69
+ };
70
+
71
+ wsRef.current.onclose = () => {
72
+ setIsConnected(false);
73
+ setIsConnecting(false);
74
+ onClose?.();
75
+
76
+ if (reconnectCountRef.current < reconnectAttempts) {
77
+ reconnectTimeoutRef.current = setTimeout(() => {
78
+ reconnectCountRef.current++;
79
+ connect();
80
+ }, reconnectInterval);
81
+ }
82
+ };
83
+
84
+ wsRef.current.onerror = (event) => {
85
+ setIsConnecting(false);
86
+ onError?.(event);
87
+ };
88
+
89
+ wsRef.current.onmessage = (event) => {
90
+ try {
91
+ const message = JSON.parse(event.data as string) as WebSocketMessage;
92
+ setLastMessage(message);
93
+ onMessage?.(message);
94
+ } catch (error) {
95
+ console.error('Failed to parse WebSocket message:', error);
96
+ }
97
+ };
98
+ } catch (error) {
99
+ setIsConnecting(false);
100
+ console.error('Failed to create WebSocket connection:', error);
101
+ }
102
+ }, [
103
+ getWebSocketUrl,
104
+ onMessage,
105
+ onOpen,
106
+ onClose,
107
+ onError,
108
+ reconnectAttempts,
109
+ reconnectInterval,
110
+ ]);
111
+
112
+ const disconnect = useCallback(() => {
113
+ if (reconnectTimeoutRef.current) {
114
+ clearTimeout(reconnectTimeoutRef.current);
115
+ reconnectTimeoutRef.current = null;
116
+ }
117
+ reconnectCountRef.current = reconnectAttempts;
118
+
119
+ if (wsRef.current) {
120
+ wsRef.current.close();
121
+ wsRef.current = null;
122
+ }
123
+ setIsConnected(false);
124
+ }, [reconnectAttempts]);
125
+
126
+ const send = useCallback((message: unknown) => {
127
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
128
+ wsRef.current.send(JSON.stringify(message));
129
+ } else {
130
+ console.warn('WebSocket is not connected');
131
+ }
132
+ }, []);
133
+
134
+ useEffect(() => {
135
+ if (autoConnect) {
136
+ connect();
137
+ }
138
+
139
+ return () => {
140
+ disconnect();
141
+ };
142
+ }, [autoConnect, connect, disconnect]);
143
+
144
+ return {
145
+ isConnected,
146
+ isConnecting,
147
+ connect,
148
+ disconnect,
149
+ send,
150
+ lastMessage,
151
+ };
152
+ }
153
+
154
+ export default useWebSocket;
frontend/src/index.css ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ * {
7
+ @apply border-dark-700;
8
+ }
9
+
10
+ body {
11
+ @apply bg-dark-900 text-dark-100 antialiased;
12
+ }
13
+
14
+ ::-webkit-scrollbar {
15
+ @apply w-2 h-2;
16
+ }
17
+
18
+ ::-webkit-scrollbar-track {
19
+ @apply bg-dark-800;
20
+ }
21
+
22
+ ::-webkit-scrollbar-thumb {
23
+ @apply bg-dark-600 rounded-full;
24
+ }
25
+
26
+ ::-webkit-scrollbar-thumb:hover {
27
+ @apply bg-dark-500;
28
+ }
29
+ }
30
+
31
+ @layer components {
32
+ .card {
33
+ @apply bg-dark-800 border border-dark-700 rounded-xl p-4 shadow-lg;
34
+ }
35
+
36
+ .card-header {
37
+ @apply flex items-center justify-between mb-4 pb-3 border-b border-dark-700;
38
+ }
39
+
40
+ .card-title {
41
+ @apply text-lg font-semibold text-dark-100;
42
+ }
43
+
44
+ .btn {
45
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium
46
+ rounded-lg transition-all duration-200 focus:outline-none focus:ring-2
47
+ focus:ring-accent-primary/50 disabled:opacity-50 disabled:cursor-not-allowed;
48
+ }
49
+
50
+ .btn-primary {
51
+ @apply bg-accent-primary text-white hover:bg-accent-secondary active:bg-accent-tertiary;
52
+ }
53
+
54
+ .btn-secondary {
55
+ @apply bg-dark-700 text-dark-200 hover:bg-dark-600 active:bg-dark-500;
56
+ }
57
+
58
+ .btn-ghost {
59
+ @apply bg-transparent text-dark-300 hover:bg-dark-700 hover:text-dark-100;
60
+ }
61
+
62
+ .btn-danger {
63
+ @apply bg-red-600 text-white hover:bg-red-700 active:bg-red-800;
64
+ }
65
+
66
+ .input {
67
+ @apply w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-dark-100
68
+ placeholder-dark-500 focus:outline-none focus:border-accent-primary focus:ring-1
69
+ focus:ring-accent-primary/50 transition-colors;
70
+ }
71
+
72
+ .select {
73
+ @apply w-full px-3 py-2 bg-dark-900 border border-dark-600 rounded-lg text-dark-100
74
+ focus:outline-none focus:border-accent-primary focus:ring-1
75
+ focus:ring-accent-primary/50 transition-colors cursor-pointer;
76
+ }
77
+
78
+ .badge {
79
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
80
+ }
81
+
82
+ .badge-success {
83
+ @apply bg-green-500/20 text-green-400 border border-green-500/30;
84
+ }
85
+
86
+ .badge-warning {
87
+ @apply bg-yellow-500/20 text-yellow-400 border border-yellow-500/30;
88
+ }
89
+
90
+ .badge-error {
91
+ @apply bg-red-500/20 text-red-400 border border-red-500/30;
92
+ }
93
+
94
+ .badge-info {
95
+ @apply bg-blue-500/20 text-blue-400 border border-blue-500/30;
96
+ }
97
+
98
+ .badge-neutral {
99
+ @apply bg-dark-600/50 text-dark-300 border border-dark-500/30;
100
+ }
101
+
102
+ .tab {
103
+ @apply px-4 py-2 text-sm font-medium text-dark-400 hover:text-dark-200
104
+ border-b-2 border-transparent transition-colors;
105
+ }
106
+
107
+ .tab-active {
108
+ @apply text-accent-primary border-accent-primary;
109
+ }
110
+
111
+ .panel {
112
+ @apply bg-dark-850 rounded-lg p-3;
113
+ }
114
+
115
+ .status-indicator {
116
+ @apply w-2.5 h-2.5 rounded-full;
117
+ }
118
+
119
+ .status-active {
120
+ @apply bg-green-500 animate-pulse;
121
+ }
122
+
123
+ .status-idle {
124
+ @apply bg-yellow-500;
125
+ }
126
+
127
+ .status-error {
128
+ @apply bg-red-500;
129
+ }
130
+
131
+ .status-offline {
132
+ @apply bg-dark-500;
133
+ }
134
+
135
+ .thought-bubble {
136
+ @apply bg-dark-700/50 border border-dark-600 rounded-lg p-3 text-sm text-dark-300
137
+ font-mono italic;
138
+ }
139
+
140
+ .code-block {
141
+ @apply bg-dark-950 border border-dark-700 rounded-lg p-3 font-mono text-sm
142
+ text-dark-200 overflow-x-auto;
143
+ }
144
+
145
+ .timeline-item {
146
+ @apply relative pl-6 pb-4 border-l-2 border-dark-700 last:border-l-transparent;
147
+ }
148
+
149
+ .timeline-dot {
150
+ @apply absolute left-0 top-0 w-3 h-3 bg-accent-primary rounded-full
151
+ -translate-x-[7px] ring-4 ring-dark-800;
152
+ }
153
+
154
+ .gradient-text {
155
+ @apply bg-gradient-to-r from-accent-primary to-accent-tertiary
156
+ bg-clip-text text-transparent;
157
+ }
158
+
159
+ .glass {
160
+ @apply bg-dark-800/80 backdrop-blur-sm;
161
+ }
162
+ }
163
+
164
+ @layer utilities {
165
+ .animate-in {
166
+ animation: fadeIn 0.3s ease-in-out;
167
+ }
168
+
169
+ .slide-in {
170
+ animation: slideUp 0.3s ease-out;
171
+ }
172
+
173
+ .no-scrollbar {
174
+ -ms-overflow-style: none;
175
+ scrollbar-width: none;
176
+ }
177
+
178
+ .no-scrollbar::-webkit-scrollbar {
179
+ display: none;
180
+ }
181
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App';
4
+ import './index.css';
5
+
6
+ createRoot(document.getElementById('root')!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );
frontend/src/types/index.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Core types matching backend models
2
+
3
+ export type AgentRole = 'navigator' | 'extractor' | 'validator' | 'coordinator';
4
+ export type AgentStatus = 'idle' | 'thinking' | 'acting' | 'waiting' | 'error';
5
+ export type MemoryLayer = 'short_term' | 'working' | 'long_term' | 'shared';
6
+ export type ActionType =
7
+ | 'navigate'
8
+ | 'click'
9
+ | 'extract'
10
+ | 'scroll'
11
+ | 'input'
12
+ | 'wait'
13
+ | 'screenshot'
14
+ | 'execute_tool'
15
+ | 'delegate'
16
+ | 'terminate';
17
+
18
+ export interface Position {
19
+ x: number;
20
+ y: number;
21
+ }
22
+
23
+ export interface BoundingBox {
24
+ x: number;
25
+ y: number;
26
+ width: number;
27
+ height: number;
28
+ }
29
+
30
+ export interface DOMElement {
31
+ tag: string;
32
+ id?: string;
33
+ classes: string[];
34
+ text?: string;
35
+ href?: string;
36
+ src?: string;
37
+ attributes: Record<string, string>;
38
+ boundingBox?: BoundingBox;
39
+ children?: DOMElement[];
40
+ }
41
+
42
+ export interface PageState {
43
+ url: string;
44
+ title: string;
45
+ domain: string;
46
+ loadTime: number;
47
+ contentType: string;
48
+ statusCode: number;
49
+ }
50
+
51
+ export interface Observation {
52
+ step: number;
53
+ timestamp: string;
54
+ page: PageState;
55
+ dom: DOMElement[];
56
+ screenshot?: string;
57
+ extractedData: Record<string, unknown>;
58
+ visibleText: string;
59
+ interactableElements: DOMElement[];
60
+ metadata: Record<string, unknown>;
61
+ }
62
+
63
+ export interface ActionTarget {
64
+ selector?: string;
65
+ xpath?: string;
66
+ text?: string;
67
+ position?: Position;
68
+ element?: DOMElement;
69
+ }
70
+
71
+ export interface Action {
72
+ type: ActionType;
73
+ target?: ActionTarget;
74
+ value?: string;
75
+ parameters?: Record<string, unknown>;
76
+ reasoning?: string;
77
+ confidence: number;
78
+ agentId: string;
79
+ timestamp: string;
80
+ }
81
+
82
+ export interface RewardComponent {
83
+ name: string;
84
+ value: number;
85
+ weight: number;
86
+ description?: string;
87
+ }
88
+
89
+ export interface Reward {
90
+ total: number;
91
+ components: RewardComponent[];
92
+ normalized: number;
93
+ cumulative: number;
94
+ timestamp: string;
95
+ }
96
+
97
+ export interface AgentThought {
98
+ content: string;
99
+ type: 'reasoning' | 'planning' | 'observation' | 'decision';
100
+ timestamp: string;
101
+ }
102
+
103
+ export interface Agent {
104
+ id: string;
105
+ role: AgentRole;
106
+ status: AgentStatus;
107
+ model: string;
108
+ currentTask?: string;
109
+ thoughts: AgentThought[];
110
+ actionsCount: number;
111
+ totalReward: number;
112
+ lastAction?: Action;
113
+ metadata: Record<string, unknown>;
114
+ }
115
+
116
+ export interface MemoryEntry {
117
+ id: string;
118
+ content: string;
119
+ type: string;
120
+ layer: MemoryLayer;
121
+ importance: number;
122
+ timestamp: string;
123
+ expiresAt?: string;
124
+ embedding?: number[];
125
+ metadata: Record<string, unknown>;
126
+ }
127
+
128
+ export interface MemoryState {
129
+ shortTerm: MemoryEntry[];
130
+ working: MemoryEntry[];
131
+ longTerm: MemoryEntry[];
132
+ shared: MemoryEntry[];
133
+ totalEntries: number;
134
+ memoryUsage: number;
135
+ }
136
+
137
+ export interface MCPTool {
138
+ name: string;
139
+ description: string;
140
+ inputSchema: Record<string, unknown>;
141
+ category: string;
142
+ enabled: boolean;
143
+ usageCount: number;
144
+ lastUsed?: string;
145
+ }
146
+
147
+ export interface Task {
148
+ id: string;
149
+ description: string;
150
+ targetUrl: string;
151
+ objectives: string[];
152
+ constraints: string[];
153
+ successCriteria: string[];
154
+ priority: number;
155
+ deadline?: string;
156
+ }
157
+
158
+ export interface EpisodeConfig {
159
+ maxSteps: number;
160
+ timeout: number;
161
+ budget: number;
162
+ allowNavigation: boolean;
163
+ allowInputs: boolean;
164
+ screenshotFrequency: number;
165
+ memoryLimit: number;
166
+ }
167
+
168
+ export interface Episode {
169
+ id: string;
170
+ task: Task;
171
+ config: EpisodeConfig;
172
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'timeout';
173
+ currentStep: number;
174
+ startTime: string;
175
+ endTime?: string;
176
+ totalReward: number;
177
+ observations: Observation[];
178
+ actions: Action[];
179
+ rewards: Reward[];
180
+ agents: Agent[];
181
+ memory: MemoryState;
182
+ success: boolean;
183
+ errorMessage?: string;
184
+ }
185
+
186
+ export interface EpisodeStats {
187
+ totalEpisodes: number;
188
+ successRate: number;
189
+ averageReward: number;
190
+ averageSteps: number;
191
+ totalActions: number;
192
+ }
193
+
194
+ export interface SystemSettings {
195
+ apiKey?: string;
196
+ defaultModel: string;
197
+ availableModels: string[];
198
+ maxConcurrentAgents: number;
199
+ enableWebSocket: boolean;
200
+ logLevel: 'debug' | 'info' | 'warn' | 'error';
201
+ screenshotQuality: number;
202
+ memoryPersistence: boolean;
203
+ }
204
+
205
+ export interface WebSocketMessage {
206
+ type: 'observation' | 'action' | 'reward' | 'agent_update' | 'episode_status' | 'error';
207
+ payload: unknown;
208
+ timestamp: string;
209
+ episodeId?: string;
210
+ }
211
+
212
+ export interface APIResponse<T> {
213
+ success: boolean;
214
+ data?: T;
215
+ error?: string;
216
+ timestamp: string;
217
+ }
218
+
219
+ export interface StepRequest {
220
+ action: Omit<Action, 'timestamp'>;
221
+ agentId?: string;
222
+ }
223
+
224
+ export interface ResetRequest {
225
+ task: Partial<Task>;
226
+ config?: Partial<EpisodeConfig>;
227
+ }
frontend/src/utils/helpers.ts ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function formatTimestamp(timestamp: string): string {
2
+ const date = new Date(timestamp);
3
+ return date.toLocaleTimeString('en-US', {
4
+ hour: '2-digit',
5
+ minute: '2-digit',
6
+ second: '2-digit',
7
+ });
8
+ }
9
+
10
+ export function formatDate(timestamp: string): string {
11
+ const date = new Date(timestamp);
12
+ return date.toLocaleDateString('en-US', {
13
+ month: 'short',
14
+ day: 'numeric',
15
+ year: 'numeric',
16
+ });
17
+ }
18
+
19
+ export function formatDuration(ms: number): string {
20
+ const seconds = Math.floor(ms / 1000);
21
+ const minutes = Math.floor(seconds / 60);
22
+ const hours = Math.floor(minutes / 60);
23
+
24
+ if (hours > 0) {
25
+ return `${hours}h ${minutes % 60}m`;
26
+ }
27
+ if (minutes > 0) {
28
+ return `${minutes}m ${seconds % 60}s`;
29
+ }
30
+ return `${seconds}s`;
31
+ }
32
+
33
+ export function formatNumber(num: number, decimals = 2): string {
34
+ if (Math.abs(num) >= 1e6) {
35
+ return `${(num / 1e6).toFixed(decimals)}M`;
36
+ }
37
+ if (Math.abs(num) >= 1e3) {
38
+ return `${(num / 1e3).toFixed(decimals)}K`;
39
+ }
40
+ return num.toFixed(decimals);
41
+ }
42
+
43
+ export function formatReward(reward: number): string {
44
+ const sign = reward >= 0 ? '+' : '';
45
+ return `${sign}${reward.toFixed(3)}`;
46
+ }
47
+
48
+ export function truncateText(text: string, maxLength: number): string {
49
+ if (text.length <= maxLength) return text;
50
+ return `${text.slice(0, maxLength - 3)}...`;
51
+ }
52
+
53
+ export function classNames(...classes: (string | boolean | undefined | null)[]): string {
54
+ return classes.filter(Boolean).join(' ');
55
+ }
56
+
57
+ export function getStatusColor(status: string): string {
58
+ const colors: Record<string, string> = {
59
+ idle: 'yellow',
60
+ thinking: 'blue',
61
+ acting: 'green',
62
+ waiting: 'orange',
63
+ error: 'red',
64
+ running: 'green',
65
+ completed: 'green',
66
+ failed: 'red',
67
+ pending: 'gray',
68
+ timeout: 'orange',
69
+ };
70
+ return colors[status] ?? 'gray';
71
+ }
72
+
73
+ export function getRoleIcon(role: string): string {
74
+ const icons: Record<string, string> = {
75
+ navigator: '🧭',
76
+ extractor: 'πŸ“Š',
77
+ validator: 'βœ…',
78
+ coordinator: '🎯',
79
+ };
80
+ return icons[role] ?? 'πŸ€–';
81
+ }
82
+
83
+ export function getActionIcon(actionType: string): string {
84
+ const icons: Record<string, string> = {
85
+ navigate: 'πŸ”—',
86
+ click: 'πŸ‘†',
87
+ extract: 'πŸ“€',
88
+ scroll: 'πŸ“œ',
89
+ input: '⌨️',
90
+ wait: '⏳',
91
+ screenshot: 'πŸ“Έ',
92
+ execute_tool: 'πŸ”§',
93
+ delegate: 'πŸ‘₯',
94
+ terminate: 'πŸ›‘',
95
+ };
96
+ return icons[actionType] ?? '⚑';
97
+ }
98
+
99
+ export function debounce<T extends (...args: unknown[]) => unknown>(
100
+ func: T,
101
+ wait: number
102
+ ): (...args: Parameters<T>) => void {
103
+ let timeout: ReturnType<typeof setTimeout> | null = null;
104
+
105
+ return (...args: Parameters<T>) => {
106
+ if (timeout) clearTimeout(timeout);
107
+ timeout = setTimeout(() => func(...args), wait);
108
+ };
109
+ }
110
+
111
+ export function throttle<T extends (...args: unknown[]) => unknown>(
112
+ func: T,
113
+ limit: number
114
+ ): (...args: Parameters<T>) => void {
115
+ let inThrottle = false;
116
+
117
+ return (...args: Parameters<T>) => {
118
+ if (!inThrottle) {
119
+ func(...args);
120
+ inThrottle = true;
121
+ setTimeout(() => (inThrottle = false), limit);
122
+ }
123
+ };
124
+ }
125
+
126
+ export function generateId(): string {
127
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
128
+ }
129
+
130
+ export function parseJSON<T>(json: string, fallback: T): T {
131
+ try {
132
+ return JSON.parse(json) as T;
133
+ } catch {
134
+ return fallback;
135
+ }
136
+ }
137
+
138
+ export function calculateProgress(current: number, total: number): number {
139
+ if (total === 0) return 0;
140
+ return Math.min(Math.round((current / total) * 100), 100);
141
+ }
142
+
143
+ export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
144
+ return array.reduce((groups, item) => {
145
+ const groupKey = String(item[key]);
146
+ return {
147
+ ...groups,
148
+ [groupKey]: [...(groups[groupKey] || []), item],
149
+ };
150
+ }, {} as Record<string, T[]>);
151
+ }
152
+
153
+ export function sortByTimestamp<T extends { timestamp: string }>(
154
+ items: T[],
155
+ order: 'asc' | 'desc' = 'desc'
156
+ ): T[] {
157
+ return [...items].sort((a, b) => {
158
+ const timeA = new Date(a.timestamp).getTime();
159
+ const timeB = new Date(b.timestamp).getTime();
160
+ return order === 'asc' ? timeA - timeB : timeB - timeA;
161
+ });
162
+ }
frontend/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />