Spaces:
Sleeping
Sleeping
Commit Β·
0cfd364
1
Parent(s): f4a05c4
feat: implement React dashboard with components and hooks
Browse files- frontend/src/App.tsx +30 -0
- frontend/src/api/client.ts +229 -0
- frontend/src/components/ActionPanel.tsx +340 -0
- frontend/src/components/AgentView.tsx +258 -0
- frontend/src/components/Dashboard.tsx +218 -0
- frontend/src/components/EpisodePanel.tsx +229 -0
- frontend/src/components/MemoryPanel.tsx +245 -0
- frontend/src/components/ObservationView.tsx +310 -0
- frontend/src/components/RewardChart.tsx +297 -0
- frontend/src/components/Settings.tsx +264 -0
- frontend/src/components/ToolRegistry.tsx +287 -0
- frontend/src/components/ui/Badge.tsx +116 -0
- frontend/src/components/ui/Button.tsx +78 -0
- frontend/src/components/ui/Card.tsx +120 -0
- frontend/src/components/ui/Input.tsx +140 -0
- frontend/src/components/ui/Select.tsx +140 -0
- frontend/src/hooks/useAgents.ts +97 -0
- frontend/src/hooks/useEpisode.ts +114 -0
- frontend/src/hooks/useMemory.ts +106 -0
- frontend/src/hooks/useWebSocket.ts +154 -0
- frontend/src/index.css +181 -0
- frontend/src/main.tsx +10 -0
- frontend/src/types/index.ts +227 -0
- frontend/src/utils/helpers.ts +162 -0
- frontend/src/vite-env.d.ts +1 -0
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"><{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">></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" />
|