react structure

This commit is contained in:
bryan
2026-02-22 14:52:15 -08:00
parent 6661934fed
commit 35738c8279
26 changed files with 3352 additions and 4 deletions
+1
View File
@@ -46,6 +46,7 @@ coverage/
# TypeScript # TypeScript
*.tsbuildinfo *.tsbuildinfo
vite.config.d.ts
# Python # Python
__pycache__/ __pycache__/
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: lint format check test install-hooks help .PHONY: lint format check test install-hooks help frontend-dev frontend-build
help: ## Show this help help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
@@ -26,3 +26,9 @@ test: ## Run all tests
install-hooks: ## Install pre-commit hooks install-hooks: ## Install pre-commit hooks
uv pip install pre-commit uv pip install pre-commit
pre-commit install pre-commit install
frontend-dev: ## Start frontend dev server
cd core/frontend && npm run dev
frontend-build: ## Build frontend for production
cd core/frontend && npm run build
+4 -2
View File
@@ -135,10 +135,12 @@ def create_app(model: str | None = None) -> web.Application:
def _setup_static_serving(app: web.Application) -> None: def _setup_static_serving(app: web.Application) -> None:
"""Serve frontend static files if the dist directory exists.""" """Serve frontend static files if the dist directory exists."""
# Try relative to CWD (repo root) and relative to this file # Try: CWD/frontend/dist, core/frontend/dist, repo_root/frontend/dist
_here = Path(__file__).resolve().parent # core/framework/server/
candidates = [ candidates = [
Path("frontend/dist"), Path("frontend/dist"),
Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "dist", _here.parent.parent / "frontend" / "dist", # core/frontend/dist
_here.parent.parent.parent / "frontend" / "dist", # repo_root/frontend/dist
] ]
dist_dir: Path | None = None dist_dir: Path | None = None
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hive</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2550
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "hive-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.3.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Routes, Route } from "react-router-dom";
import Index from "./pages/index";
function App() {
return (
<Routes>
<Route path="/" element={<Index />} />
</Routes>
);
}
export default App;
+34
View File
@@ -0,0 +1,34 @@
import { api } from "./client";
import type {
Agent,
AgentDetail,
DiscoverResult,
EntryPoint,
} from "./types";
export const agentsApi = {
discover: () => api.get<DiscoverResult>("/discover"),
list: () => api.get<{ agents: Agent[] }>("/agents"),
load: (agentPath: string, agentId?: string, model?: string) =>
api.post<Agent>("/agents", {
agent_path: agentPath,
agent_id: agentId,
model,
}),
get: (agentId: string) => api.get<AgentDetail>(`/agents/${agentId}`),
unload: (agentId: string) =>
api.delete<{ unloaded: string }>(`/agents/${agentId}`),
stats: (agentId: string) =>
api.get<Record<string, unknown>>(`/agents/${agentId}/stats`),
entryPoints: (agentId: string) =>
api.get<{ entry_points: EntryPoint[] }>(`/agents/${agentId}/entry-points`),
graphs: (agentId: string) =>
api.get<{ graphs: string[] }>(`/agents/${agentId}/graphs`),
};
+41
View File
@@ -0,0 +1,41 @@
const API_BASE = "/api";
export class ApiError extends Error {
constructor(
public status: number,
public body: { error: string; type?: string },
) {
super(body.error);
this.name = "ApiError";
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const body = await response
.json()
.catch(() => ({ error: response.statusText }));
throw new ApiError(response.status, body);
}
return response.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
};
+64
View File
@@ -0,0 +1,64 @@
import { api } from "./client";
import type {
TriggerResult,
InjectResult,
ChatResult,
StopResult,
ResumeResult,
ReplayResult,
GoalProgress,
} from "./types";
export const executionApi = {
trigger: (
agentId: string,
entryPointId: string,
inputData: Record<string, unknown>,
sessionState?: Record<string, unknown>,
) =>
api.post<TriggerResult>(`/agents/${agentId}/trigger`, {
entry_point_id: entryPointId,
input_data: inputData,
session_state: sessionState,
}),
inject: (
agentId: string,
nodeId: string,
content: string,
graphId?: string,
) =>
api.post<InjectResult>(`/agents/${agentId}/inject`, {
node_id: nodeId,
content,
graph_id: graphId,
}),
chat: (agentId: string, message: string) =>
api.post<ChatResult>(`/agents/${agentId}/chat`, { message }),
stop: (agentId: string, executionId: string) =>
api.post<StopResult>(`/agents/${agentId}/stop`, {
execution_id: executionId,
}),
pause: (agentId: string, executionId: string) =>
api.post<StopResult>(`/agents/${agentId}/pause`, {
execution_id: executionId,
}),
resume: (agentId: string, sessionId: string, checkpointId?: string) =>
api.post<ResumeResult>(`/agents/${agentId}/resume`, {
session_id: sessionId,
checkpoint_id: checkpointId,
}),
replay: (agentId: string, sessionId: string, checkpointId: string) =>
api.post<ReplayResult>(`/agents/${agentId}/replay`, {
session_id: sessionId,
checkpoint_id: checkpointId,
}),
goalProgress: (agentId: string) =>
api.get<GoalProgress>(`/agents/${agentId}/goal-progress`),
};
+24
View File
@@ -0,0 +1,24 @@
import { api } from "./client";
import type { NodeSpec, NodeDetail, NodeCriteria } from "./types";
export const graphsApi = {
nodes: (agentId: string, graphId: string, sessionId?: string) =>
api.get<{ nodes: NodeSpec[] }>(
`/agents/${agentId}/graphs/${graphId}/nodes${sessionId ? `?session_id=${sessionId}` : ""}`,
),
node: (agentId: string, graphId: string, nodeId: string) =>
api.get<NodeDetail>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}`,
),
nodeCriteria: (
agentId: string,
graphId: string,
nodeId: string,
sessionId?: string,
) =>
api.get<NodeCriteria>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}/criteria${sessionId ? `?session_id=${sessionId}` : ""}`,
),
};
+40
View File
@@ -0,0 +1,40 @@
import { api } from "./client";
import type { LogEntry, LogNodeDetail, LogToolStep } from "./types";
export const logsApi = {
list: (agentId: string, limit?: number) =>
api.get<{ logs: LogEntry[] }>(
`/agents/${agentId}/logs${limit ? `?limit=${limit}` : ""}`,
),
summary: (agentId: string, sessionId: string) =>
api.get<LogEntry>(
`/agents/${agentId}/logs?session_id=${sessionId}&level=summary`,
),
details: (agentId: string, sessionId: string) =>
api.get<{ session_id: string; nodes: LogNodeDetail[] }>(
`/agents/${agentId}/logs?session_id=${sessionId}&level=details`,
),
tools: (agentId: string, sessionId: string) =>
api.get<{ session_id: string; steps: LogToolStep[] }>(
`/agents/${agentId}/logs?session_id=${sessionId}&level=tools`,
),
nodeLogs: (
agentId: string,
graphId: string,
nodeId: string,
sessionId: string,
level?: string,
) =>
api.get<{
session_id: string;
node_id: string;
details?: LogNodeDetail[];
tool_logs?: LogToolStep[];
}>(
`/agents/${agentId}/graphs/${graphId}/nodes/${nodeId}/logs?session_id=${sessionId}${level ? `&level=${level}` : ""}`,
),
};
+33
View File
@@ -0,0 +1,33 @@
import { api } from "./client";
import type {
SessionSummary,
SessionDetail,
Checkpoint,
Message,
} from "./types";
export const sessionsApi = {
list: (agentId: string) =>
api.get<{ sessions: SessionSummary[] }>(`/agents/${agentId}/sessions`),
get: (agentId: string, sessionId: string) =>
api.get<SessionDetail>(`/agents/${agentId}/sessions/${sessionId}`),
delete: (agentId: string, sessionId: string) =>
api.delete<{ deleted: string }>(`/agents/${agentId}/sessions/${sessionId}`),
checkpoints: (agentId: string, sessionId: string) =>
api.get<{ checkpoints: Checkpoint[] }>(
`/agents/${agentId}/sessions/${sessionId}/checkpoints`,
),
restore: (agentId: string, sessionId: string, checkpointId: string) =>
api.post<{ execution_id: string }>(
`/agents/${agentId}/sessions/${sessionId}/checkpoints/${checkpointId}/restore`,
),
messages: (agentId: string, sessionId: string, nodeId?: string) =>
api.get<{ messages: Message[] }>(
`/agents/${agentId}/sessions/${sessionId}/messages${nodeId ? `?node_id=${nodeId}` : ""}`,
),
};
+242
View File
@@ -0,0 +1,242 @@
// --- Agent types ---
export interface Agent {
id: string;
agent_path: string;
name: string;
description: string;
goal: string;
node_count: number;
loaded_at: number;
uptime_seconds: number;
}
export interface EntryPoint {
id: string;
name: string;
entry_node: string;
trigger_type: string;
}
export interface AgentDetail extends Agent {
entry_points: EntryPoint[];
graphs: string[];
}
export interface DiscoverEntry {
path: string;
name: string;
description: string;
category: string;
session_count: number;
node_count: number;
tool_count: number;
tags: string[];
}
/** Keyed by category name. */
export type DiscoverResult = Record<string, DiscoverEntry[]>;
// --- Execution types ---
export interface TriggerResult {
execution_id: string;
}
export interface InjectResult {
delivered: boolean;
}
export interface ChatResult {
status: "started" | "injected";
execution_id?: string;
node_id?: string;
delivered?: boolean;
}
export interface StopResult {
stopped: boolean;
execution_id?: string;
error?: string;
}
export interface ResumeResult {
execution_id: string;
resumed_from: string;
checkpoint_id: string | null;
}
export interface ReplayResult {
execution_id: string;
replayed_from: string;
checkpoint_id: string;
}
export interface GoalProgress {
progress: number;
criteria: unknown[];
}
// --- Session types ---
export interface SessionSummary {
session_id: string;
status?: string;
started_at?: string | null;
completed_at?: string | null;
steps?: number;
paused_at?: string | null;
checkpoint_count: number;
}
export interface SessionDetail {
status: string;
started_at: string;
completed_at: string | null;
input_data: Record<string, unknown>;
memory: Record<string, unknown>;
progress: {
current_node: string | null;
paused_at: string | null;
steps_executed: number;
path: string[];
node_visit_counts: Record<string, number>;
nodes_with_failures: string[];
resume_from?: string;
};
}
export interface Checkpoint {
checkpoint_id: string;
current_node: string | null;
next_node: string | null;
is_clean: boolean;
timestamp: string | null;
error?: string;
}
export interface Message {
seq: number;
role: string;
content: string;
_node_id: string;
[key: string]: unknown;
}
// --- Graph / Node types ---
export interface NodeSpec {
id: string;
name: string;
description: string;
node_type: string;
input_keys: string[];
output_keys: string[];
nullable_output_keys: string[];
tools: string[];
routes: Record<string, string>;
max_retries: number;
max_node_visits: number;
client_facing: boolean;
success_criteria: string | null;
// Runtime enrichment (when session_id provided)
visit_count?: number;
has_failures?: boolean;
is_current?: boolean;
in_path?: boolean;
}
export interface EdgeInfo {
target: string;
condition: string;
priority: number;
}
export interface NodeDetail extends NodeSpec {
edges: EdgeInfo[];
}
export interface NodeCriteria {
node_id: string;
success_criteria: string | null;
output_keys: string[];
last_execution?: {
success: boolean;
error: string | null;
retry_count: number;
needs_attention: boolean;
attention_reasons: string[];
};
}
// --- Log types ---
export interface LogEntry {
[key: string]: unknown;
}
export interface LogNodeDetail {
node_id: string;
node_name: string;
success: boolean;
error?: string;
retry_count?: number;
needs_attention?: boolean;
attention_reasons?: string[];
total_steps: number;
}
export interface LogToolStep {
node_id: string;
step_index: number;
llm_text: string;
[key: string]: unknown;
}
// --- SSE Event types ---
export type EventTypeName =
| "execution_started"
| "execution_completed"
| "execution_failed"
| "execution_paused"
| "execution_resumed"
| "state_changed"
| "state_conflict"
| "goal_progress"
| "goal_achieved"
| "constraint_violation"
| "stream_started"
| "stream_stopped"
| "node_loop_started"
| "node_loop_iteration"
| "node_loop_completed"
| "llm_text_delta"
| "llm_reasoning_delta"
| "tool_call_started"
| "tool_call_completed"
| "client_output_delta"
| "client_input_requested"
| "node_internal_output"
| "node_input_blocked"
| "node_stalled"
| "node_tool_doom_loop"
| "judge_verdict"
| "output_key_set"
| "node_retry"
| "edge_traversed"
| "context_compacted"
| "webhook_received"
| "custom"
| "escalation_requested";
export interface AgentEvent {
type: EventTypeName;
stream_id: string;
node_id: string | null;
execution_id: string | null;
data: Record<string, unknown>;
timestamp: string;
correlation_id: string | null;
graph_id: string | null;
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef, useCallback, useState } from "react";
import type { AgentEvent, EventTypeName } from "@/api/types";
interface UseSSEOptions {
agentId: string;
eventTypes?: EventTypeName[];
onEvent?: (event: AgentEvent) => void;
enabled?: boolean;
}
export function useSSE({
agentId,
eventTypes,
onEvent,
enabled = true,
}: UseSSEOptions) {
const [connected, setConnected] = useState(false);
const [lastEvent, setLastEvent] = useState<AgentEvent | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const typesKey = eventTypes?.join(",") ?? "";
useEffect(() => {
if (!enabled || !agentId) return;
let url = `/api/agents/${agentId}/events`;
if (eventTypes?.length) {
url += `?types=${eventTypes.join(",")}`;
}
const es = new EventSource(url);
eventSourceRef.current = es;
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
const handler = (e: MessageEvent) => {
try {
const event: AgentEvent = JSON.parse(e.data);
setLastEvent(event);
onEventRef.current?.(event);
} catch {
// Ignore parse errors (keepalive comments)
}
};
// Listen on generic message for all events
es.onmessage = handler;
return () => {
es.close();
eventSourceRef.current = null;
setConnected(false);
};
}, [agentId, enabled, typesKey]);
const close = useCallback(() => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
setConnected(false);
}, []);
return { connected, lastEvent, close };
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+10
View File
@@ -0,0 +1,10 @@
export default function Index() {
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 text-white">
<div className="text-center">
<h1 className="text-5xl font-bold mb-4">Hive</h1>
<p className="text-neutral-400 text-lg">Agent Dashboard</p>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"composite": true,
"emitDeclarationOnly": true,
"declaration": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8787",
changeOrigin: true,
},
},
},
});
+4 -1
View File
@@ -9,7 +9,10 @@
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"test:duplicates": "bun test scripts/auto-close-duplicates" "test:duplicates": "bun test scripts/auto-close-duplicates",
"frontend:dev": "cd core/frontend && npm run dev",
"frontend:build": "cd core/frontend && npm run build",
"frontend:preview": "cd core/frontend && npm run preview"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
+71
View File
@@ -0,0 +1,71 @@
# Plan: Set Up React Frontend for Hive
## Context
The `feat/open-hive` branch has a complete backend HTTP API (aiohttp on port 8787) with CRUD, execution control, sessions, SSE streaming, and more. The server already has SPA static-file serving built in — it looks for `frontend/dist/index.html` and serves it with a catch-all fallback. **No frontend exists yet.** The user has a Lovable.dev design they'll paste pages from later, so the scaffold must be Lovable-compatible (React 18, Vite, Tailwind, shadcn/ui, React Router).
The goal: create a deployable frontend shell with a typed API client layer, so the user can immediately start dropping in Lovable pages.
## Key Decisions
| Decision | Choice | Why |
|----------|--------|-----|
| Location | `core/frontend/` | Keeps frontend co-located with the Python framework inside `core/`. Requires a small tweak to app.py to add `core/frontend/dist` as a lookup candidate. |
| Build tool | **Vite** | SPA output, Lovable uses Vite, CRA deprecated, Next.js is overkill for SPA |
| Package manager | **npm** | Root `package.json` declares `npm@10.2.0` — stay consistent |
| Styling | **Tailwind CSS v4 + shadcn/ui** | Lovable generates these; shadcn copies source into project |
| Routing | **React Router** | Lovable uses it; SPA client-side routing matches backend catch-all |
| Dev proxy | Vite `server.proxy``:8787` | Avoids CORS issues, SSE EventSource works through proxy |
## Files to Create
```
core/frontend/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tsconfig.node.json
├── index.html
├── components.json # shadcn config (via npx shadcn@latest init)
├── src/
│ ├── main.tsx # React entry point
│ ├── App.tsx # Router shell
│ ├── index.css # Tailwind imports
│ ├── vite-env.d.ts # Vite type declarations
│ ├── lib/
│ │ └── utils.ts # shadcn cn() utility
│ ├── api/
│ │ ├── client.ts # Base fetch wrapper (/api prefix)
│ │ ├── types.ts # All TS types matching backend responses
│ │ ├── agents.ts # Agent CRUD endpoints
│ │ ├── execution.ts # Trigger, chat, inject, stop, resume
│ │ ├── sessions.ts # Sessions & checkpoints
│ │ ├── graphs.ts # Graph/node inspection
│ │ └── logs.ts # Log retrieval
│ ├── hooks/
│ │ └── use-sse.ts # SSE EventSource hook
│ └── pages/
│ └── index.tsx # Placeholder landing page
```
## Files to Modify
- `core/framework/server/app.py` — add `core/frontend/dist` as a static-file lookup candidate
- `package.json` — add `frontend:dev` and `frontend:build` convenience scripts
- `Makefile` — add `frontend-dev` and `frontend-build` targets
## Lovable Compatibility
When pasting Lovable pages later:
1. **Imports like `@/components/ui/button`** work via the `@` alias
2. **Run `npx shadcn@latest add <component>`** for each UI component a page needs
3. **Add routes** to `App.tsx` — Lovable pages export default React components
4. **If pages use `@tanstack/react-query`**, install it: `npm install @tanstack/react-query`
5. **Tailwind classes** work out of the box
## Verification
1. `cd core/frontend && npm run build` succeeds and produces `core/frontend/dist/index.html`
2. Start backend: `cd core && uv run python -m framework.runner.cli serve` — logs "Serving frontend from ..."
3. Open `http://localhost:8787` — placeholder page renders
4. Dev mode: `cd core/frontend && npm run dev` on `:5173`, API calls proxy to `:8787`