akseljoonas HF Staff Claude Opus 4.5 commited on
Commit
a56db97
·
1 Parent(s): e83f59a

Add web frontend infrastructure for HF Agent

Browse files

- Backend: FastAPI server with WebSocket support
- Session manager for multi-session handling
- Agent API routes (create, submit, approve, interrupt, undo, compact)
- HF OAuth authentication routes
- WebSocket connection manager for real-time events

- Frontend: React + Vite + MUI + TypeScript
- Zustand stores for agent state and session management
- Chat UI with markdown rendering
- Tool approval modal for batch approvals
- Session sidebar for multi-session support
- WebSocket hook with auto-reconnection

- Docker: Multi-stage build with uv for Python deps
- Updated pyproject.toml with web backend dependencies
- Added HF Space metadata to README.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Dockerfile ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build frontend
2
+ FROM node:20-alpine AS frontend-builder
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package.json ./
5
+ RUN npm install
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
+
9
+ # Stage 2: Production
10
+ FROM python:3.12-slim
11
+
12
+ # Create user with UID 1000 (required for HF Spaces)
13
+ RUN useradd -m -u 1000 user
14
+
15
+ WORKDIR /app
16
+
17
+ # Install system dependencies and uv
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ git \
20
+ curl \
21
+ && curl -LsSf https://astral.sh/uv/install.sh | sh \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ ENV PATH="/root/.local/bin:$PATH"
25
+
26
+ # Copy pyproject.toml and install dependencies with uv
27
+ COPY pyproject.toml .
28
+ RUN uv sync --extra agent --no-dev
29
+
30
+ # Copy application code with proper ownership
31
+ COPY --chown=user agent/ ./agent/
32
+ COPY --chown=user backend/ ./backend/
33
+ COPY --chown=user configs/ ./configs/
34
+
35
+ # Copy built frontend
36
+ COPY --from=frontend-builder --chown=user /app/frontend/dist ./static/
37
+
38
+ # Create directories for session logs
39
+ RUN mkdir -p /app/session_logs && chown user:user /app/session_logs
40
+
41
+ # Switch to non-root user
42
+ USER user
43
+
44
+ # Set environment
45
+ ENV HOME=/home/user \
46
+ PATH=/home/user/.local/bin:$PATH \
47
+ PYTHONUNBUFFERED=1
48
+
49
+ # Expose port
50
+ EXPOSE 7860
51
+
52
+ # Run the application
53
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,3 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # HF Agent
2
 
3
  An MLE agent CLI with MCP (Model Context Protocol) integration and built-in tool support.
 
1
+ ---
2
+ title: HF Agent
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ hf_oauth: true
9
+ hf_oauth_scopes:
10
+ - read-repos
11
+ - write-repos
12
+ - inference-api
13
+ ---
14
+
15
  # HF Agent
16
 
17
  An MLE agent CLI with MCP (Model Context Protocol) integration and built-in tool support.
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Backend package for HF Agent web interface
backend/main.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application for HF Agent web interface."""
2
+
3
+ import logging
4
+ import os
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ from backend.routes.agent import router as agent_router
13
+ from backend.routes.auth import router as auth_router
14
+
15
+ # Configure logging
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @asynccontextmanager
24
+ async def lifespan(app: FastAPI):
25
+ """Application lifespan handler."""
26
+ logger.info("Starting HF Agent backend...")
27
+ yield
28
+ logger.info("Shutting down HF Agent backend...")
29
+
30
+
31
+ app = FastAPI(
32
+ title="HF Agent",
33
+ description="ML Engineering Assistant API",
34
+ version="1.0.0",
35
+ lifespan=lifespan,
36
+ )
37
+
38
+ # CORS middleware for development
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=[
42
+ "http://localhost:5173", # Vite dev server
43
+ "http://localhost:3000",
44
+ "http://127.0.0.1:5173",
45
+ "http://127.0.0.1:3000",
46
+ ],
47
+ allow_credentials=True,
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
+
52
+ # Include routers
53
+ app.include_router(agent_router)
54
+ app.include_router(auth_router)
55
+
56
+ # Serve static files (frontend build) in production
57
+ static_path = Path(__file__).parent.parent / "static"
58
+ if static_path.exists():
59
+ app.mount("/", StaticFiles(directory=str(static_path), html=True), name="static")
60
+ logger.info(f"Serving static files from {static_path}")
61
+ else:
62
+ logger.info("No static directory found, running in API-only mode")
63
+
64
+
65
+ @app.get("/api")
66
+ async def api_root():
67
+ """API root endpoint."""
68
+ return {
69
+ "name": "HF Agent API",
70
+ "version": "1.0.0",
71
+ "docs": "/docs",
72
+ }
73
+
74
+
75
+ if __name__ == "__main__":
76
+ import uvicorn
77
+
78
+ port = int(os.environ.get("PORT", 7860))
79
+ uvicorn.run(app, host="0.0.0.0", port=port)
backend/models.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for API requests and responses."""
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class OpType(str, Enum):
10
+ """Operation types matching agent/core/agent_loop.py."""
11
+
12
+ USER_INPUT = "user_input"
13
+ EXEC_APPROVAL = "exec_approval"
14
+ INTERRUPT = "interrupt"
15
+ UNDO = "undo"
16
+ COMPACT = "compact"
17
+ SHUTDOWN = "shutdown"
18
+
19
+
20
+ class Operation(BaseModel):
21
+ """Operation to be submitted to the agent."""
22
+
23
+ op_type: OpType
24
+ data: dict[str, Any] | None = None
25
+
26
+
27
+ class Submission(BaseModel):
28
+ """Submission wrapper with ID and operation."""
29
+
30
+ id: str
31
+ operation: Operation
32
+
33
+
34
+ class ToolApproval(BaseModel):
35
+ """Approval decision for a single tool call."""
36
+
37
+ tool_call_id: str
38
+ approved: bool
39
+ feedback: str | None = None
40
+
41
+
42
+ class ApprovalRequest(BaseModel):
43
+ """Request to approve/reject tool calls."""
44
+
45
+ session_id: str
46
+ approvals: list[ToolApproval]
47
+
48
+
49
+ class SubmitRequest(BaseModel):
50
+ """Request to submit user input."""
51
+
52
+ session_id: str
53
+ text: str
54
+
55
+
56
+ class SessionResponse(BaseModel):
57
+ """Response when creating a new session."""
58
+
59
+ session_id: str
60
+ ready: bool = True
61
+
62
+
63
+ class SessionInfo(BaseModel):
64
+ """Session metadata."""
65
+
66
+ session_id: str
67
+ created_at: str
68
+ is_active: bool
69
+ message_count: int
70
+
71
+
72
+ class HealthResponse(BaseModel):
73
+ """Health check response."""
74
+
75
+ status: str = "ok"
76
+ active_sessions: int = 0
backend/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Routes package
backend/routes/agent.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent API routes - WebSocket and REST endpoints."""
2
+
3
+ import logging
4
+
5
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
6
+
7
+ from backend.models import (
8
+ ApprovalRequest,
9
+ HealthResponse,
10
+ SessionInfo,
11
+ SessionResponse,
12
+ SubmitRequest,
13
+ )
14
+ from backend.session_manager import session_manager
15
+ from backend.websocket import manager as ws_manager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ router = APIRouter(prefix="/api", tags=["agent"])
20
+
21
+
22
+ @router.get("/health", response_model=HealthResponse)
23
+ async def health_check() -> HealthResponse:
24
+ """Health check endpoint."""
25
+ return HealthResponse(
26
+ status="ok", active_sessions=session_manager.active_session_count
27
+ )
28
+
29
+
30
+ @router.post("/session", response_model=SessionResponse)
31
+ async def create_session() -> SessionResponse:
32
+ """Create a new agent session."""
33
+ session_id = await session_manager.create_session()
34
+ return SessionResponse(session_id=session_id, ready=True)
35
+
36
+
37
+ @router.get("/session/{session_id}", response_model=SessionInfo)
38
+ async def get_session(session_id: str) -> SessionInfo:
39
+ """Get session information."""
40
+ info = session_manager.get_session_info(session_id)
41
+ if not info:
42
+ raise HTTPException(status_code=404, detail="Session not found")
43
+ return SessionInfo(**info)
44
+
45
+
46
+ @router.get("/sessions", response_model=list[SessionInfo])
47
+ async def list_sessions() -> list[SessionInfo]:
48
+ """List all sessions."""
49
+ sessions = session_manager.list_sessions()
50
+ return [SessionInfo(**s) for s in sessions]
51
+
52
+
53
+ @router.delete("/session/{session_id}")
54
+ async def delete_session(session_id: str) -> dict:
55
+ """Delete a session."""
56
+ success = await session_manager.delete_session(session_id)
57
+ if not success:
58
+ raise HTTPException(status_code=404, detail="Session not found")
59
+ return {"status": "deleted", "session_id": session_id}
60
+
61
+
62
+ @router.post("/submit")
63
+ async def submit_input(request: SubmitRequest) -> dict:
64
+ """Submit user input to a session."""
65
+ success = await session_manager.submit_user_input(request.session_id, request.text)
66
+ if not success:
67
+ raise HTTPException(status_code=404, detail="Session not found or inactive")
68
+ return {"status": "submitted", "session_id": request.session_id}
69
+
70
+
71
+ @router.post("/approve")
72
+ async def submit_approval(request: ApprovalRequest) -> dict:
73
+ """Submit tool approvals to a session."""
74
+ approvals = [
75
+ {
76
+ "tool_call_id": a.tool_call_id,
77
+ "approved": a.approved,
78
+ "feedback": a.feedback,
79
+ }
80
+ for a in request.approvals
81
+ ]
82
+ success = await session_manager.submit_approval(request.session_id, approvals)
83
+ if not success:
84
+ raise HTTPException(status_code=404, detail="Session not found or inactive")
85
+ return {"status": "submitted", "session_id": request.session_id}
86
+
87
+
88
+ @router.post("/interrupt/{session_id}")
89
+ async def interrupt_session(session_id: str) -> dict:
90
+ """Interrupt the current operation in a session."""
91
+ success = await session_manager.interrupt(session_id)
92
+ if not success:
93
+ raise HTTPException(status_code=404, detail="Session not found or inactive")
94
+ return {"status": "interrupted", "session_id": session_id}
95
+
96
+
97
+ @router.post("/undo/{session_id}")
98
+ async def undo_session(session_id: str) -> dict:
99
+ """Undo the last turn in a session."""
100
+ success = await session_manager.undo(session_id)
101
+ if not success:
102
+ raise HTTPException(status_code=404, detail="Session not found or inactive")
103
+ return {"status": "undo_requested", "session_id": session_id}
104
+
105
+
106
+ @router.post("/compact/{session_id}")
107
+ async def compact_session(session_id: str) -> dict:
108
+ """Compact the context in a session."""
109
+ success = await session_manager.compact(session_id)
110
+ if not success:
111
+ raise HTTPException(status_code=404, detail="Session not found or inactive")
112
+ return {"status": "compact_requested", "session_id": session_id}
113
+
114
+
115
+ @router.post("/shutdown/{session_id}")
116
+ async def shutdown_session(session_id: str) -> dict:
117
+ """Shutdown a session."""
118
+ success = await session_manager.shutdown_session(session_id)
119
+ if not success:
120
+ raise HTTPException(status_code=404, detail="Session not found or inactive")
121
+ return {"status": "shutdown_requested", "session_id": session_id}
122
+
123
+
124
+ @router.websocket("/ws/{session_id}")
125
+ async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
126
+ """WebSocket endpoint for real-time events."""
127
+ # Verify session exists
128
+ info = session_manager.get_session_info(session_id)
129
+ if not info:
130
+ await websocket.close(code=4004, reason="Session not found")
131
+ return
132
+
133
+ await ws_manager.connect(websocket, session_id)
134
+
135
+ try:
136
+ while True:
137
+ # Keep connection alive, handle ping/pong
138
+ data = await websocket.receive_json()
139
+
140
+ # Handle client messages (e.g., ping)
141
+ if data.get("type") == "ping":
142
+ await websocket.send_json({"type": "pong"})
143
+
144
+ except WebSocketDisconnect:
145
+ logger.info(f"WebSocket disconnected for session {session_id}")
146
+ except Exception as e:
147
+ logger.error(f"WebSocket error for session {session_id}: {e}")
148
+ finally:
149
+ ws_manager.disconnect(session_id)
backend/routes/auth.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication routes for HF OAuth."""
2
+
3
+ import os
4
+ import secrets
5
+ from urllib.parse import urlencode
6
+
7
+ import httpx
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from fastapi.responses import RedirectResponse
10
+
11
+ router = APIRouter(prefix="/auth", tags=["auth"])
12
+
13
+ # OAuth configuration from environment
14
+ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
15
+ OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
16
+ OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
17
+
18
+ # In-memory session store (replace with proper session management in production)
19
+ oauth_states: dict[str, dict] = {}
20
+
21
+
22
+ def get_redirect_uri(request: Request) -> str:
23
+ """Get the OAuth callback redirect URI."""
24
+ # In HF Spaces, use the SPACE_HOST if available
25
+ space_host = os.environ.get("SPACE_HOST")
26
+ if space_host:
27
+ return f"https://{space_host}/auth/callback"
28
+ # Otherwise construct from request
29
+ return str(request.url_for("oauth_callback"))
30
+
31
+
32
+ @router.get("/login")
33
+ async def oauth_login(request: Request) -> RedirectResponse:
34
+ """Initiate OAuth login flow."""
35
+ if not OAUTH_CLIENT_ID:
36
+ raise HTTPException(
37
+ status_code=500,
38
+ detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
39
+ )
40
+
41
+ # Generate state for CSRF protection
42
+ state = secrets.token_urlsafe(32)
43
+ oauth_states[state] = {"redirect_uri": get_redirect_uri(request)}
44
+
45
+ # Build authorization URL
46
+ params = {
47
+ "client_id": OAUTH_CLIENT_ID,
48
+ "redirect_uri": get_redirect_uri(request),
49
+ "scope": "openid profile",
50
+ "response_type": "code",
51
+ "state": state,
52
+ }
53
+ auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
54
+
55
+ return RedirectResponse(url=auth_url)
56
+
57
+
58
+ @router.get("/callback")
59
+ async def oauth_callback(request: Request, code: str = "", state: str = "") -> RedirectResponse:
60
+ """Handle OAuth callback."""
61
+ # Verify state
62
+ if state not in oauth_states:
63
+ raise HTTPException(status_code=400, detail="Invalid state parameter")
64
+
65
+ stored_state = oauth_states.pop(state)
66
+ redirect_uri = stored_state["redirect_uri"]
67
+
68
+ if not code:
69
+ raise HTTPException(status_code=400, detail="No authorization code provided")
70
+
71
+ # Exchange code for token
72
+ token_url = f"{OPENID_PROVIDER_URL}/oauth/token"
73
+ async with httpx.AsyncClient() as client:
74
+ try:
75
+ response = await client.post(
76
+ token_url,
77
+ data={
78
+ "grant_type": "authorization_code",
79
+ "code": code,
80
+ "redirect_uri": redirect_uri,
81
+ "client_id": OAUTH_CLIENT_ID,
82
+ "client_secret": OAUTH_CLIENT_SECRET,
83
+ },
84
+ )
85
+ response.raise_for_status()
86
+ token_data = response.json()
87
+ except httpx.HTTPError as e:
88
+ raise HTTPException(status_code=500, detail=f"Token exchange failed: {e}")
89
+
90
+ # Get user info
91
+ access_token = token_data.get("access_token")
92
+ if access_token:
93
+ async with httpx.AsyncClient() as client:
94
+ try:
95
+ userinfo_response = await client.get(
96
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
97
+ headers={"Authorization": f"Bearer {access_token}"},
98
+ )
99
+ userinfo_response.raise_for_status()
100
+ user_info = userinfo_response.json()
101
+ except httpx.HTTPError:
102
+ user_info = {}
103
+ else:
104
+ user_info = {}
105
+
106
+ # For now, redirect to home with token in query params
107
+ # In production, use secure cookies or session storage
108
+ redirect_params = {
109
+ "access_token": access_token,
110
+ "username": user_info.get("preferred_username", ""),
111
+ }
112
+
113
+ return RedirectResponse(url=f"/?{urlencode(redirect_params)}")
114
+
115
+
116
+ @router.get("/logout")
117
+ async def logout() -> RedirectResponse:
118
+ """Log out the user."""
119
+ return RedirectResponse(url="/")
120
+
121
+
122
+ @router.get("/me")
123
+ async def get_current_user(request: Request) -> dict:
124
+ """Get current user info from Authorization header."""
125
+ auth_header = request.headers.get("Authorization", "")
126
+ if not auth_header.startswith("Bearer "):
127
+ return {"authenticated": False}
128
+
129
+ token = auth_header.split(" ")[1]
130
+
131
+ async with httpx.AsyncClient() as client:
132
+ try:
133
+ response = await client.get(
134
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
135
+ headers={"Authorization": f"Bearer {token}"},
136
+ )
137
+ response.raise_for_status()
138
+ user_info = response.json()
139
+ return {
140
+ "authenticated": True,
141
+ "username": user_info.get("preferred_username"),
142
+ "name": user_info.get("name"),
143
+ "picture": user_info.get("picture"),
144
+ }
145
+ except httpx.HTTPError:
146
+ return {"authenticated": False}
backend/session_manager.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Session manager for handling multiple concurrent agent sessions."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from agent.config import Config, load_config
11
+ from agent.core.agent_loop import process_submission
12
+ from agent.core.session import Event, OpType, Operation, Session, Submission
13
+ from agent.core.tools import ToolRouter
14
+
15
+ from backend.websocket import manager as ws_manager
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class AgentSession:
22
+ """Wrapper for an agent session with its associated resources."""
23
+
24
+ session_id: str
25
+ session: Session
26
+ tool_router: ToolRouter
27
+ submission_queue: asyncio.Queue
28
+ task: asyncio.Task | None = None
29
+ created_at: datetime = field(default_factory=datetime.utcnow)
30
+ is_active: bool = True
31
+
32
+
33
+ class SessionManager:
34
+ """Manages multiple concurrent agent sessions."""
35
+
36
+ def __init__(self, config_path: str = "configs/main_agent_config.json") -> None:
37
+ self.config = load_config(config_path)
38
+ self.sessions: dict[str, AgentSession] = {}
39
+ self._lock = asyncio.Lock()
40
+
41
+ async def create_session(self) -> str:
42
+ """Create a new agent session and return its ID."""
43
+ session_id = str(uuid.uuid4())
44
+
45
+ # Create queues for this session
46
+ submission_queue: asyncio.Queue = asyncio.Queue()
47
+ event_queue: asyncio.Queue = asyncio.Queue()
48
+
49
+ # Create tool router
50
+ tool_router = ToolRouter(config=self.config)
51
+
52
+ # Create the agent session
53
+ session = Session(event_queue, config=self.config, tool_router=tool_router)
54
+
55
+ # Create wrapper
56
+ agent_session = AgentSession(
57
+ session_id=session_id,
58
+ session=session,
59
+ tool_router=tool_router,
60
+ submission_queue=submission_queue,
61
+ )
62
+
63
+ async with self._lock:
64
+ self.sessions[session_id] = agent_session
65
+
66
+ # Start the agent loop task
67
+ task = asyncio.create_task(
68
+ self._run_session(session_id, submission_queue, event_queue, tool_router)
69
+ )
70
+ agent_session.task = task
71
+
72
+ logger.info(f"Created session {session_id}")
73
+ return session_id
74
+
75
+ async def _run_session(
76
+ self,
77
+ session_id: str,
78
+ submission_queue: asyncio.Queue,
79
+ event_queue: asyncio.Queue,
80
+ tool_router: ToolRouter,
81
+ ) -> None:
82
+ """Run the agent loop for a session and forward events to WebSocket."""
83
+ agent_session = self.sessions.get(session_id)
84
+ if not agent_session:
85
+ logger.error(f"Session {session_id} not found")
86
+ return
87
+
88
+ session = agent_session.session
89
+
90
+ # Start event forwarder task
91
+ event_forwarder = asyncio.create_task(
92
+ self._forward_events(session_id, event_queue)
93
+ )
94
+
95
+ try:
96
+ async with tool_router:
97
+ # Send ready event
98
+ await session.send_event(
99
+ Event(event_type="ready", data={"message": "Agent initialized"})
100
+ )
101
+
102
+ while session.is_running:
103
+ try:
104
+ # Wait for submission with timeout to allow checking is_running
105
+ submission = await asyncio.wait_for(
106
+ submission_queue.get(), timeout=1.0
107
+ )
108
+ should_continue = await process_submission(session, submission)
109
+ if not should_continue:
110
+ break
111
+ except asyncio.TimeoutError:
112
+ continue
113
+ except asyncio.CancelledError:
114
+ logger.info(f"Session {session_id} cancelled")
115
+ break
116
+ except Exception as e:
117
+ logger.error(f"Error in session {session_id}: {e}")
118
+ await session.send_event(
119
+ Event(event_type="error", data={"error": str(e)})
120
+ )
121
+
122
+ finally:
123
+ event_forwarder.cancel()
124
+ try:
125
+ await event_forwarder
126
+ except asyncio.CancelledError:
127
+ pass
128
+
129
+ async with self._lock:
130
+ if session_id in self.sessions:
131
+ self.sessions[session_id].is_active = False
132
+
133
+ logger.info(f"Session {session_id} ended")
134
+
135
+ async def _forward_events(
136
+ self, session_id: str, event_queue: asyncio.Queue
137
+ ) -> None:
138
+ """Forward events from the agent to the WebSocket."""
139
+ while True:
140
+ try:
141
+ event: Event = await event_queue.get()
142
+ await ws_manager.send_event(
143
+ session_id, event.event_type, event.data
144
+ )
145
+ except asyncio.CancelledError:
146
+ break
147
+ except Exception as e:
148
+ logger.error(f"Error forwarding event for {session_id}: {e}")
149
+
150
+ async def submit(self, session_id: str, operation: Operation) -> bool:
151
+ """Submit an operation to a session."""
152
+ async with self._lock:
153
+ agent_session = self.sessions.get(session_id)
154
+
155
+ if not agent_session or not agent_session.is_active:
156
+ logger.warning(f"Session {session_id} not found or inactive")
157
+ return False
158
+
159
+ submission = Submission(
160
+ id=f"sub_{uuid.uuid4().hex[:8]}", operation=operation
161
+ )
162
+ await agent_session.submission_queue.put(submission)
163
+ return True
164
+
165
+ async def submit_user_input(self, session_id: str, text: str) -> bool:
166
+ """Submit user input to a session."""
167
+ operation = Operation(op_type=OpType.USER_INPUT, data={"text": text})
168
+ return await self.submit(session_id, operation)
169
+
170
+ async def submit_approval(
171
+ self, session_id: str, approvals: list[dict[str, Any]]
172
+ ) -> bool:
173
+ """Submit tool approvals to a session."""
174
+ operation = Operation(op_type=OpType.EXEC_APPROVAL, data={"approvals": approvals})
175
+ return await self.submit(session_id, operation)
176
+
177
+ async def interrupt(self, session_id: str) -> bool:
178
+ """Interrupt a session."""
179
+ operation = Operation(op_type=OpType.INTERRUPT)
180
+ return await self.submit(session_id, operation)
181
+
182
+ async def undo(self, session_id: str) -> bool:
183
+ """Undo last turn in a session."""
184
+ operation = Operation(op_type=OpType.UNDO)
185
+ return await self.submit(session_id, operation)
186
+
187
+ async def compact(self, session_id: str) -> bool:
188
+ """Compact context in a session."""
189
+ operation = Operation(op_type=OpType.COMPACT)
190
+ return await self.submit(session_id, operation)
191
+
192
+ async def shutdown_session(self, session_id: str) -> bool:
193
+ """Shutdown a specific session."""
194
+ operation = Operation(op_type=OpType.SHUTDOWN)
195
+ success = await self.submit(session_id, operation)
196
+
197
+ if success:
198
+ async with self._lock:
199
+ agent_session = self.sessions.get(session_id)
200
+ if agent_session and agent_session.task:
201
+ # Wait for task to complete
202
+ try:
203
+ await asyncio.wait_for(agent_session.task, timeout=5.0)
204
+ except asyncio.TimeoutError:
205
+ agent_session.task.cancel()
206
+
207
+ return success
208
+
209
+ async def delete_session(self, session_id: str) -> bool:
210
+ """Delete a session entirely."""
211
+ async with self._lock:
212
+ agent_session = self.sessions.pop(session_id, None)
213
+
214
+ if not agent_session:
215
+ return False
216
+
217
+ # Cancel the task if running
218
+ if agent_session.task and not agent_session.task.done():
219
+ agent_session.task.cancel()
220
+ try:
221
+ await agent_session.task
222
+ except asyncio.CancelledError:
223
+ pass
224
+
225
+ return True
226
+
227
+ def get_session_info(self, session_id: str) -> dict[str, Any] | None:
228
+ """Get information about a session."""
229
+ agent_session = self.sessions.get(session_id)
230
+ if not agent_session:
231
+ return None
232
+
233
+ return {
234
+ "session_id": session_id,
235
+ "created_at": agent_session.created_at.isoformat(),
236
+ "is_active": agent_session.is_active,
237
+ "message_count": len(agent_session.session.context_manager.messages),
238
+ }
239
+
240
+ def list_sessions(self) -> list[dict[str, Any]]:
241
+ """List all sessions."""
242
+ return [
243
+ self.get_session_info(sid)
244
+ for sid in self.sessions
245
+ if self.get_session_info(sid)
246
+ ]
247
+
248
+ @property
249
+ def active_session_count(self) -> int:
250
+ """Get count of active sessions."""
251
+ return sum(1 for s in self.sessions.values() if s.is_active)
252
+
253
+
254
+ # Global session manager instance
255
+ session_manager = SessionManager()
backend/websocket.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WebSocket connection manager for real-time communication."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ from fastapi import WebSocket
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ConnectionManager:
13
+ """Manages WebSocket connections for multiple sessions."""
14
+
15
+ def __init__(self) -> None:
16
+ # session_id -> WebSocket
17
+ self.active_connections: dict[str, WebSocket] = {}
18
+ # session_id -> asyncio.Queue for outgoing messages
19
+ self.message_queues: dict[str, asyncio.Queue] = {}
20
+
21
+ async def connect(self, websocket: WebSocket, session_id: str) -> None:
22
+ """Accept a WebSocket connection and register it."""
23
+ await websocket.accept()
24
+ self.active_connections[session_id] = websocket
25
+ self.message_queues[session_id] = asyncio.Queue()
26
+ logger.info(f"WebSocket connected for session {session_id}")
27
+
28
+ def disconnect(self, session_id: str) -> None:
29
+ """Remove a WebSocket connection."""
30
+ if session_id in self.active_connections:
31
+ del self.active_connections[session_id]
32
+ if session_id in self.message_queues:
33
+ del self.message_queues[session_id]
34
+ logger.info(f"WebSocket disconnected for session {session_id}")
35
+
36
+ async def send_event(self, session_id: str, event_type: str, data: dict[str, Any] | None = None) -> None:
37
+ """Send an event to a specific session's WebSocket."""
38
+ if session_id not in self.active_connections:
39
+ logger.warning(f"No active connection for session {session_id}")
40
+ return
41
+
42
+ message = {"event_type": event_type}
43
+ if data is not None:
44
+ message["data"] = data
45
+
46
+ try:
47
+ await self.active_connections[session_id].send_json(message)
48
+ except Exception as e:
49
+ logger.error(f"Error sending to session {session_id}: {e}")
50
+ self.disconnect(session_id)
51
+
52
+ async def broadcast(self, event_type: str, data: dict[str, Any] | None = None) -> None:
53
+ """Broadcast an event to all connected sessions."""
54
+ for session_id in list(self.active_connections.keys()):
55
+ await self.send_event(session_id, event_type, data)
56
+
57
+ def is_connected(self, session_id: str) -> bool:
58
+ """Check if a session has an active WebSocket connection."""
59
+ return session_id in self.active_connections
60
+
61
+ def get_queue(self, session_id: str) -> asyncio.Queue | None:
62
+ """Get the message queue for a session."""
63
+ return self.message_queues.get(session_id)
64
+
65
+
66
+ # Global connection manager instance
67
+ manager = ConnectionManager()
frontend/eslint.config.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+
7
+ export default tseslint.config(
8
+ { ignores: ['dist'] },
9
+ {
10
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
+ files: ['**/*.{ts,tsx}'],
12
+ languageOptions: {
13
+ ecmaVersion: 2020,
14
+ globals: globals.browser,
15
+ },
16
+ plugins: {
17
+ 'react-hooks': reactHooks,
18
+ 'react-refresh': reactRefresh,
19
+ },
20
+ rules: {
21
+ ...reactHooks.configs.recommended.rules,
22
+ 'react-refresh/only-export-components': [
23
+ 'warn',
24
+ { allowConstantExport: true },
25
+ ],
26
+ },
27
+ },
28
+ )
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>HF Agent</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hf-agent-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@emotion/react": "^11.13.0",
14
+ "@emotion/styled": "^11.13.0",
15
+ "@mui/icons-material": "^6.1.0",
16
+ "@mui/material": "^6.1.0",
17
+ "react": "^18.3.1",
18
+ "react-dom": "^18.3.1",
19
+ "react-markdown": "^9.0.1",
20
+ "zustand": "^5.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@eslint/js": "^9.13.0",
24
+ "@types/react": "^18.3.12",
25
+ "@types/react-dom": "^18.3.1",
26
+ "@vitejs/plugin-react": "^4.3.3",
27
+ "eslint": "^9.13.0",
28
+ "eslint-plugin-react-hooks": "^5.0.0",
29
+ "eslint-plugin-react-refresh": "^0.4.13",
30
+ "globals": "^15.11.0",
31
+ "typescript": "~5.6.2",
32
+ "typescript-eslint": "^8.10.0",
33
+ "vite": "^5.4.10"
34
+ }
35
+ }
frontend/public/vite.svg ADDED
frontend/src/App.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box } from '@mui/material';
2
+ import AppLayout from '@/components/Layout/AppLayout';
3
+
4
+ function App() {
5
+ return (
6
+ <Box sx={{ height: '100vh', display: 'flex' }}>
7
+ <AppLayout />
8
+ </Box>
9
+ );
10
+ }
11
+
12
+ export default App;
frontend/src/components/ApprovalModal/ApprovalModal.tsx ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from 'react';
2
+ import {
3
+ Dialog,
4
+ DialogTitle,
5
+ DialogContent,
6
+ DialogActions,
7
+ Button,
8
+ Box,
9
+ Typography,
10
+ Checkbox,
11
+ FormControlLabel,
12
+ Accordion,
13
+ AccordionSummary,
14
+ AccordionDetails,
15
+ TextField,
16
+ Chip,
17
+ } from '@mui/material';
18
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
19
+ import WarningIcon from '@mui/icons-material/Warning';
20
+ import { useAgentStore } from '@/store/agentStore';
21
+
22
+ interface ApprovalModalProps {
23
+ sessionId: string | null;
24
+ }
25
+
26
+ interface ApprovalState {
27
+ [toolCallId: string]: {
28
+ approved: boolean;
29
+ feedback: string;
30
+ };
31
+ }
32
+
33
+ export default function ApprovalModal({ sessionId }: ApprovalModalProps) {
34
+ const { pendingApprovals, setPendingApprovals } = useAgentStore();
35
+ const [approvalState, setApprovalState] = useState<ApprovalState>({});
36
+
37
+ const isOpen = pendingApprovals !== null && pendingApprovals.tools.length > 0;
38
+
39
+ const handleApprovalChange = useCallback(
40
+ (toolCallId: string, approved: boolean) => {
41
+ setApprovalState((prev) => ({
42
+ ...prev,
43
+ [toolCallId]: {
44
+ ...prev[toolCallId],
45
+ approved,
46
+ feedback: prev[toolCallId]?.feedback || '',
47
+ },
48
+ }));
49
+ },
50
+ []
51
+ );
52
+
53
+ const handleFeedbackChange = useCallback(
54
+ (toolCallId: string, feedback: string) => {
55
+ setApprovalState((prev) => ({
56
+ ...prev,
57
+ [toolCallId]: {
58
+ ...prev[toolCallId],
59
+ feedback,
60
+ },
61
+ }));
62
+ },
63
+ []
64
+ );
65
+
66
+ const handleSubmit = useCallback(async () => {
67
+ if (!sessionId || !pendingApprovals) return;
68
+
69
+ const approvals = pendingApprovals.tools.map((tool) => ({
70
+ tool_call_id: tool.tool_call_id,
71
+ approved: approvalState[tool.tool_call_id]?.approved ?? false,
72
+ feedback: approvalState[tool.tool_call_id]?.feedback || null,
73
+ }));
74
+
75
+ try {
76
+ await fetch('/api/approve', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({
80
+ session_id: sessionId,
81
+ approvals,
82
+ }),
83
+ });
84
+ setPendingApprovals(null);
85
+ setApprovalState({});
86
+ } catch (e) {
87
+ console.error('Approval submission failed:', e);
88
+ }
89
+ }, [sessionId, pendingApprovals, approvalState, setPendingApprovals]);
90
+
91
+ const handleApproveAll = useCallback(() => {
92
+ if (!pendingApprovals) return;
93
+ const newState: ApprovalState = {};
94
+ pendingApprovals.tools.forEach((tool) => {
95
+ newState[tool.tool_call_id] = { approved: true, feedback: '' };
96
+ });
97
+ setApprovalState(newState);
98
+ }, [pendingApprovals]);
99
+
100
+ const handleRejectAll = useCallback(() => {
101
+ if (!pendingApprovals) return;
102
+ const newState: ApprovalState = {};
103
+ pendingApprovals.tools.forEach((tool) => {
104
+ newState[tool.tool_call_id] = { approved: false, feedback: '' };
105
+ });
106
+ setApprovalState(newState);
107
+ }, [pendingApprovals]);
108
+
109
+ if (!isOpen || !pendingApprovals) return null;
110
+
111
+ const approvedCount = Object.values(approvalState).filter((s) => s.approved).length;
112
+
113
+ return (
114
+ <Dialog
115
+ open={isOpen}
116
+ maxWidth="md"
117
+ fullWidth
118
+ PaperProps={{
119
+ sx: { bgcolor: 'background.paper' },
120
+ }}
121
+ >
122
+ <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
123
+ <WarningIcon color="warning" />
124
+ Approval Required
125
+ <Chip
126
+ label={`${pendingApprovals.count} tool${pendingApprovals.count > 1 ? 's' : ''}`}
127
+ size="small"
128
+ sx={{ ml: 1 }}
129
+ />
130
+ </DialogTitle>
131
+ <DialogContent dividers>
132
+ <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
133
+ The following tool calls require your approval before execution:
134
+ </Typography>
135
+ {pendingApprovals.tools.map((tool, index) => (
136
+ <Accordion key={tool.tool_call_id} defaultExpanded={index === 0}>
137
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
138
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
139
+ <FormControlLabel
140
+ control={
141
+ <Checkbox
142
+ checked={approvalState[tool.tool_call_id]?.approved ?? false}
143
+ onChange={(e) => {
144
+ e.stopPropagation();
145
+ handleApprovalChange(tool.tool_call_id, e.target.checked);
146
+ }}
147
+ onClick={(e) => e.stopPropagation()}
148
+ />
149
+ }
150
+ label=""
151
+ sx={{ m: 0 }}
152
+ />
153
+ <Chip label={tool.tool} size="small" color="primary" variant="outlined" />
154
+ <Typography variant="body2" color="text.secondary" sx={{ ml: 'auto' }}>
155
+ {approvalState[tool.tool_call_id]?.approved ? 'Approved' : 'Pending'}
156
+ </Typography>
157
+ </Box>
158
+ </AccordionSummary>
159
+ <AccordionDetails>
160
+ <Typography variant="subtitle2" gutterBottom>
161
+ Arguments:
162
+ </Typography>
163
+ <Box
164
+ component="pre"
165
+ sx={{
166
+ bgcolor: 'background.default',
167
+ p: 1.5,
168
+ borderRadius: 1,
169
+ overflow: 'auto',
170
+ fontSize: '0.8rem',
171
+ maxHeight: 200,
172
+ }}
173
+ >
174
+ {JSON.stringify(tool.arguments, null, 2)}
175
+ </Box>
176
+ {!approvalState[tool.tool_call_id]?.approved && (
177
+ <TextField
178
+ fullWidth
179
+ size="small"
180
+ label="Feedback (optional)"
181
+ placeholder="Explain why you're rejecting this..."
182
+ value={approvalState[tool.tool_call_id]?.feedback || ''}
183
+ onChange={(e) => handleFeedbackChange(tool.tool_call_id, e.target.value)}
184
+ sx={{ mt: 2 }}
185
+ />
186
+ )}
187
+ </AccordionDetails>
188
+ </Accordion>
189
+ ))}
190
+ </DialogContent>
191
+ <DialogActions sx={{ px: 3, py: 2 }}>
192
+ <Button onClick={handleRejectAll} color="error" variant="outlined">
193
+ Reject All
194
+ </Button>
195
+ <Button onClick={handleApproveAll} color="success" variant="outlined">
196
+ Approve All
197
+ </Button>
198
+ <Box sx={{ flex: 1 }} />
199
+ <Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
200
+ {approvedCount} of {pendingApprovals.count} approved
201
+ </Typography>
202
+ <Button onClick={handleSubmit} variant="contained" color="primary">
203
+ Submit
204
+ </Button>
205
+ </DialogActions>
206
+ </Dialog>
207
+ );
208
+ }
frontend/src/components/Chat/ChatInput.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, KeyboardEvent } from 'react';
2
+ import { Box, TextField, IconButton, CircularProgress } from '@mui/material';
3
+ import SendIcon from '@mui/icons-material/Send';
4
+
5
+ interface ChatInputProps {
6
+ onSend: (text: string) => void;
7
+ disabled?: boolean;
8
+ }
9
+
10
+ export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
11
+ const [input, setInput] = useState('');
12
+
13
+ const handleSend = useCallback(() => {
14
+ if (input.trim() && !disabled) {
15
+ onSend(input);
16
+ setInput('');
17
+ }
18
+ }, [input, disabled, onSend]);
19
+
20
+ const handleKeyDown = useCallback(
21
+ (e: KeyboardEvent<HTMLDivElement>) => {
22
+ if (e.key === 'Enter' && !e.shiftKey) {
23
+ e.preventDefault();
24
+ handleSend();
25
+ }
26
+ },
27
+ [handleSend]
28
+ );
29
+
30
+ return (
31
+ <Box
32
+ sx={{
33
+ p: 2,
34
+ borderTop: 1,
35
+ borderColor: 'divider',
36
+ bgcolor: 'background.paper',
37
+ }}
38
+ >
39
+ <Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
40
+ <TextField
41
+ fullWidth
42
+ multiline
43
+ maxRows={6}
44
+ value={input}
45
+ onChange={(e) => setInput(e.target.value)}
46
+ onKeyDown={handleKeyDown}
47
+ placeholder="Type a message..."
48
+ disabled={disabled}
49
+ variant="outlined"
50
+ size="small"
51
+ sx={{
52
+ '& .MuiOutlinedInput-root': {
53
+ bgcolor: 'background.default',
54
+ },
55
+ }}
56
+ />
57
+ <IconButton
58
+ onClick={handleSend}
59
+ disabled={disabled || !input.trim()}
60
+ color="primary"
61
+ sx={{
62
+ bgcolor: 'primary.main',
63
+ color: 'primary.contrastText',
64
+ '&:hover': {
65
+ bgcolor: 'primary.dark',
66
+ },
67
+ '&.Mui-disabled': {
68
+ bgcolor: 'action.disabledBackground',
69
+ },
70
+ }}
71
+ >
72
+ {disabled ? <CircularProgress size={24} color="inherit" /> : <SendIcon />}
73
+ </IconButton>
74
+ </Box>
75
+ </Box>
76
+ );
77
+ }
frontend/src/components/Chat/MessageBubble.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Paper, Typography, Chip } from '@mui/material';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import PersonIcon from '@mui/icons-material/Person';
4
+ import SmartToyIcon from '@mui/icons-material/SmartToy';
5
+ import BuildIcon from '@mui/icons-material/Build';
6
+ import type { Message } from '@/types/agent';
7
+
8
+ interface MessageBubbleProps {
9
+ message: Message;
10
+ }
11
+
12
+ export default function MessageBubble({ message }: MessageBubbleProps) {
13
+ const isUser = message.role === 'user';
14
+ const isTool = message.role === 'tool';
15
+
16
+ const getIcon = () => {
17
+ if (isUser) return <PersonIcon fontSize="small" />;
18
+ if (isTool) return <BuildIcon fontSize="small" />;
19
+ return <SmartToyIcon fontSize="small" />;
20
+ };
21
+
22
+ const getBgColor = () => {
23
+ if (isUser) return 'primary.dark';
24
+ if (isTool) return 'background.default';
25
+ return 'background.paper';
26
+ };
27
+
28
+ return (
29
+ <Box
30
+ sx={{
31
+ display: 'flex',
32
+ justifyContent: isUser ? 'flex-end' : 'flex-start',
33
+ width: '100%',
34
+ }}
35
+ >
36
+ <Paper
37
+ elevation={0}
38
+ sx={{
39
+ p: 2,
40
+ maxWidth: isTool ? '100%' : '80%',
41
+ width: isTool ? '100%' : 'auto',
42
+ bgcolor: getBgColor(),
43
+ border: 1,
44
+ borderColor: 'divider',
45
+ }}
46
+ >
47
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
48
+ {getIcon()}
49
+ <Typography variant="caption" color="text.secondary">
50
+ {isUser ? 'You' : isTool ? 'Tool' : 'Assistant'}
51
+ </Typography>
52
+ {isTool && message.toolName && (
53
+ <Chip
54
+ label={message.toolName}
55
+ size="small"
56
+ variant="outlined"
57
+ sx={{ ml: 1, height: 20, fontSize: '0.7rem' }}
58
+ />
59
+ )}
60
+ <Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>
61
+ {new Date(message.timestamp).toLocaleTimeString()}
62
+ </Typography>
63
+ </Box>
64
+ <Box
65
+ sx={{
66
+ '& p': { m: 0 },
67
+ '& pre': {
68
+ bgcolor: 'background.default',
69
+ p: 1.5,
70
+ borderRadius: 1,
71
+ overflow: 'auto',
72
+ fontSize: '0.85rem',
73
+ },
74
+ '& code': {
75
+ bgcolor: 'background.default',
76
+ px: 0.5,
77
+ py: 0.25,
78
+ borderRadius: 0.5,
79
+ fontSize: '0.85rem',
80
+ fontFamily: '"JetBrains Mono", monospace',
81
+ },
82
+ '& pre code': {
83
+ bgcolor: 'transparent',
84
+ p: 0,
85
+ },
86
+ '& a': {
87
+ color: 'primary.main',
88
+ },
89
+ '& ul, & ol': {
90
+ pl: 2,
91
+ my: 1,
92
+ },
93
+ }}
94
+ >
95
+ <ReactMarkdown>{message.content}</ReactMarkdown>
96
+ </Box>
97
+ </Paper>
98
+ </Box>
99
+ );
100
+ }
frontend/src/components/Chat/MessageList.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { Box, Typography, CircularProgress } from '@mui/material';
3
+ import MessageBubble from './MessageBubble';
4
+ import type { Message } from '@/types/agent';
5
+
6
+ interface MessageListProps {
7
+ messages: Message[];
8
+ isProcessing: boolean;
9
+ }
10
+
11
+ export default function MessageList({ messages, isProcessing }: MessageListProps) {
12
+ const bottomRef = useRef<HTMLDivElement>(null);
13
+
14
+ // Auto-scroll to bottom when new messages arrive
15
+ useEffect(() => {
16
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
17
+ }, [messages]);
18
+
19
+ return (
20
+ <Box
21
+ sx={{
22
+ flex: 1,
23
+ overflow: 'auto',
24
+ p: 2,
25
+ display: 'flex',
26
+ flexDirection: 'column',
27
+ gap: 2,
28
+ }}
29
+ >
30
+ {messages.length === 0 ? (
31
+ <Box
32
+ sx={{
33
+ flex: 1,
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ }}
38
+ >
39
+ <Typography color="text.secondary">
40
+ Start a conversation by typing a message below
41
+ </Typography>
42
+ </Box>
43
+ ) : (
44
+ messages.map((message) => (
45
+ <MessageBubble key={message.id} message={message} />
46
+ ))
47
+ )}
48
+ {isProcessing && (
49
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 2 }}>
50
+ <CircularProgress size={16} />
51
+ <Typography variant="body2" color="text.secondary">
52
+ Processing...
53
+ </Typography>
54
+ </Box>
55
+ )}
56
+ <div ref={bottomRef} />
57
+ </Box>
58
+ );
59
+ }
frontend/src/components/Layout/AppLayout.tsx ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ AppBar,
5
+ Toolbar,
6
+ Typography,
7
+ IconButton,
8
+ Drawer,
9
+ useMediaQuery,
10
+ useTheme,
11
+ Button,
12
+ Chip,
13
+ } from '@mui/material';
14
+ import MenuIcon from '@mui/icons-material/Menu';
15
+ import UndoIcon from '@mui/icons-material/Undo';
16
+ import CompressIcon from '@mui/icons-material/Compress';
17
+ import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
18
+
19
+ import { useSessionStore } from '@/store/sessionStore';
20
+ import { useAgentStore } from '@/store/agentStore';
21
+ import { useAgentWebSocket } from '@/hooks/useAgentWebSocket';
22
+ import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
23
+ import ChatInput from '@/components/Chat/ChatInput';
24
+ import MessageList from '@/components/Chat/MessageList';
25
+ import ApprovalModal from '@/components/ApprovalModal/ApprovalModal';
26
+
27
+ const DRAWER_WIDTH = 280;
28
+
29
+ export default function AppLayout() {
30
+ const theme = useTheme();
31
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'));
32
+ const [mobileOpen, setMobileOpen] = useState(false);
33
+
34
+ const { activeSessionId } = useSessionStore();
35
+ const { isConnected, isProcessing, getMessages } = useAgentStore();
36
+
37
+ const messages = activeSessionId ? getMessages(activeSessionId) : [];
38
+
39
+ useAgentWebSocket({
40
+ sessionId: activeSessionId,
41
+ onReady: () => console.log('Agent ready'),
42
+ onError: (error) => console.error('Agent error:', error),
43
+ });
44
+
45
+ const handleDrawerToggle = () => {
46
+ setMobileOpen(!mobileOpen);
47
+ };
48
+
49
+ const handleUndo = useCallback(async () => {
50
+ if (!activeSessionId) return;
51
+ try {
52
+ await fetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
53
+ } catch (e) {
54
+ console.error('Undo failed:', e);
55
+ }
56
+ }, [activeSessionId]);
57
+
58
+ const handleCompact = useCallback(async () => {
59
+ if (!activeSessionId) return;
60
+ try {
61
+ await fetch(`/api/compact/${activeSessionId}`, { method: 'POST' });
62
+ } catch (e) {
63
+ console.error('Compact failed:', e);
64
+ }
65
+ }, [activeSessionId]);
66
+
67
+ const handleSendMessage = useCallback(
68
+ async (text: string) => {
69
+ if (!activeSessionId || !text.trim()) return;
70
+ try {
71
+ await fetch('/api/submit', {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({
75
+ session_id: activeSessionId,
76
+ text: text.trim(),
77
+ }),
78
+ });
79
+ } catch (e) {
80
+ console.error('Send failed:', e);
81
+ }
82
+ },
83
+ [activeSessionId]
84
+ );
85
+
86
+ const drawer = <SessionSidebar onClose={() => setMobileOpen(false)} />;
87
+
88
+ return (
89
+ <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
90
+ {/* App Bar */}
91
+ <AppBar
92
+ position="fixed"
93
+ sx={{
94
+ width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
95
+ ml: { md: `${DRAWER_WIDTH}px` },
96
+ bgcolor: 'background.paper',
97
+ borderBottom: 1,
98
+ borderColor: 'divider',
99
+ }}
100
+ elevation={0}
101
+ >
102
+ <Toolbar>
103
+ <IconButton
104
+ color="inherit"
105
+ edge="start"
106
+ onClick={handleDrawerToggle}
107
+ sx={{ mr: 2, display: { md: 'none' } }}
108
+ >
109
+ <MenuIcon />
110
+ </IconButton>
111
+ <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
112
+ HF Agent
113
+ </Typography>
114
+ <Chip
115
+ icon={
116
+ <FiberManualRecordIcon
117
+ sx={{
118
+ fontSize: 12,
119
+ color: isConnected ? 'success.main' : 'error.main',
120
+ }}
121
+ />
122
+ }
123
+ label={isConnected ? 'Connected' : 'Disconnected'}
124
+ size="small"
125
+ variant="outlined"
126
+ sx={{ mr: 2 }}
127
+ />
128
+ <IconButton
129
+ onClick={handleUndo}
130
+ disabled={!activeSessionId || isProcessing}
131
+ title="Undo last turn"
132
+ >
133
+ <UndoIcon />
134
+ </IconButton>
135
+ <IconButton
136
+ onClick={handleCompact}
137
+ disabled={!activeSessionId || isProcessing}
138
+ title="Compact context"
139
+ >
140
+ <CompressIcon />
141
+ </IconButton>
142
+ </Toolbar>
143
+ </AppBar>
144
+
145
+ {/* Sidebar Drawer */}
146
+ <Box
147
+ component="nav"
148
+ sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}
149
+ >
150
+ {/* Mobile drawer */}
151
+ <Drawer
152
+ variant="temporary"
153
+ open={mobileOpen}
154
+ onClose={handleDrawerToggle}
155
+ ModalProps={{ keepMounted: true }}
156
+ sx={{
157
+ display: { xs: 'block', md: 'none' },
158
+ '& .MuiDrawer-paper': {
159
+ boxSizing: 'border-box',
160
+ width: DRAWER_WIDTH,
161
+ },
162
+ }}
163
+ >
164
+ {drawer}
165
+ </Drawer>
166
+ {/* Desktop drawer */}
167
+ <Drawer
168
+ variant="permanent"
169
+ sx={{
170
+ display: { xs: 'none', md: 'block' },
171
+ '& .MuiDrawer-paper': {
172
+ boxSizing: 'border-box',
173
+ width: DRAWER_WIDTH,
174
+ },
175
+ }}
176
+ open
177
+ >
178
+ {drawer}
179
+ </Drawer>
180
+ </Box>
181
+
182
+ {/* Main Content */}
183
+ <Box
184
+ component="main"
185
+ sx={{
186
+ flexGrow: 1,
187
+ width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
188
+ height: '100%',
189
+ display: 'flex',
190
+ flexDirection: 'column',
191
+ }}
192
+ >
193
+ <Toolbar /> {/* Spacer for fixed AppBar */}
194
+ {activeSessionId ? (
195
+ <>
196
+ <MessageList messages={messages} isProcessing={isProcessing} />
197
+ <ChatInput
198
+ onSend={handleSendMessage}
199
+ disabled={isProcessing || !isConnected}
200
+ />
201
+ </>
202
+ ) : (
203
+ <Box
204
+ sx={{
205
+ flex: 1,
206
+ display: 'flex',
207
+ alignItems: 'center',
208
+ justifyContent: 'center',
209
+ flexDirection: 'column',
210
+ gap: 2,
211
+ }}
212
+ >
213
+ <Typography variant="h5" color="text.secondary">
214
+ No session selected
215
+ </Typography>
216
+ <Typography variant="body2" color="text.secondary">
217
+ Create a new session from the sidebar to get started
218
+ </Typography>
219
+ </Box>
220
+ )}
221
+ </Box>
222
+
223
+ {/* Approval Modal */}
224
+ <ApprovalModal sessionId={activeSessionId} />
225
+ </Box>
226
+ );
227
+ }
frontend/src/components/SessionSidebar/SessionSidebar.tsx ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ List,
5
+ ListItem,
6
+ ListItemButton,
7
+ ListItemText,
8
+ ListItemSecondaryAction,
9
+ IconButton,
10
+ Typography,
11
+ Button,
12
+ Divider,
13
+ Chip,
14
+ } from '@mui/material';
15
+ import AddIcon from '@mui/icons-material/Add';
16
+ import DeleteIcon from '@mui/icons-material/Delete';
17
+ import ChatIcon from '@mui/icons-material/Chat';
18
+ import { useSessionStore } from '@/store/sessionStore';
19
+ import { useAgentStore } from '@/store/agentStore';
20
+
21
+ interface SessionSidebarProps {
22
+ onClose?: () => void;
23
+ }
24
+
25
+ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
26
+ const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
27
+ useSessionStore();
28
+ const { clearMessages } = useAgentStore();
29
+
30
+ const handleNewSession = useCallback(async () => {
31
+ try {
32
+ const response = await fetch('/api/session', { method: 'POST' });
33
+ const data = await response.json();
34
+ createSession(data.session_id);
35
+ onClose?.();
36
+ } catch (e) {
37
+ console.error('Failed to create session:', e);
38
+ }
39
+ }, [createSession, onClose]);
40
+
41
+ const handleDeleteSession = useCallback(
42
+ async (sessionId: string, e: React.MouseEvent) => {
43
+ e.stopPropagation();
44
+ try {
45
+ await fetch(`/api/session/${sessionId}`, { method: 'DELETE' });
46
+ deleteSession(sessionId);
47
+ clearMessages(sessionId);
48
+ } catch (e) {
49
+ console.error('Failed to delete session:', e);
50
+ }
51
+ },
52
+ [deleteSession, clearMessages]
53
+ );
54
+
55
+ const handleSelectSession = useCallback(
56
+ (sessionId: string) => {
57
+ switchSession(sessionId);
58
+ onClose?.();
59
+ },
60
+ [switchSession, onClose]
61
+ );
62
+
63
+ const formatDate = (dateString: string) => {
64
+ const date = new Date(dateString);
65
+ const now = new Date();
66
+ const isToday = date.toDateString() === now.toDateString();
67
+ if (isToday) {
68
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
69
+ }
70
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
71
+ };
72
+
73
+ return (
74
+ <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
75
+ {/* Header */}
76
+ <Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
77
+ <Typography variant="h6" sx={{ mb: 2 }}>
78
+ Sessions
79
+ </Typography>
80
+ <Button
81
+ fullWidth
82
+ variant="contained"
83
+ startIcon={<AddIcon />}
84
+ onClick={handleNewSession}
85
+ >
86
+ New Session
87
+ </Button>
88
+ </Box>
89
+
90
+ {/* Session List */}
91
+ <Box sx={{ flex: 1, overflow: 'auto' }}>
92
+ {sessions.length === 0 ? (
93
+ <Box sx={{ p: 3, textAlign: 'center' }}>
94
+ <ChatIcon sx={{ fontSize: 48, color: 'text.secondary', mb: 1 }} />
95
+ <Typography variant="body2" color="text.secondary">
96
+ No sessions yet
97
+ </Typography>
98
+ <Typography variant="caption" color="text.secondary">
99
+ Create a new session to get started
100
+ </Typography>
101
+ </Box>
102
+ ) : (
103
+ <List disablePadding>
104
+ {[...sessions].reverse().map((session) => (
105
+ <ListItem key={session.id} disablePadding divider>
106
+ <ListItemButton
107
+ selected={session.id === activeSessionId}
108
+ onClick={() => handleSelectSession(session.id)}
109
+ sx={{
110
+ '&.Mui-selected': {
111
+ bgcolor: 'action.selected',
112
+ '&:hover': {
113
+ bgcolor: 'action.selected',
114
+ },
115
+ },
116
+ }}
117
+ >
118
+ <ListItemText
119
+ primary={
120
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
121
+ <Typography variant="body2" noWrap>
122
+ {session.title}
123
+ </Typography>
124
+ {session.isActive && (
125
+ <Chip
126
+ label="active"
127
+ size="small"
128
+ color="success"
129
+ sx={{ height: 18, fontSize: '0.65rem' }}
130
+ />
131
+ )}
132
+ </Box>
133
+ }
134
+ secondary={formatDate(session.createdAt)}
135
+ />
136
+ <ListItemSecondaryAction>
137
+ <IconButton
138
+ edge="end"
139
+ size="small"
140
+ onClick={(e) => handleDeleteSession(session.id, e)}
141
+ >
142
+ <DeleteIcon fontSize="small" />
143
+ </IconButton>
144
+ </ListItemSecondaryAction>
145
+ </ListItemButton>
146
+ </ListItem>
147
+ ))}
148
+ </List>
149
+ )}
150
+ </Box>
151
+
152
+ {/* Footer */}
153
+ <Divider />
154
+ <Box sx={{ p: 2 }}>
155
+ <Typography variant="caption" color="text.secondary">
156
+ {sessions.length} session{sessions.length !== 1 ? 's' : ''}
157
+ </Typography>
158
+ </Box>
159
+ </Box>
160
+ );
161
+ }
frontend/src/hooks/useAgentWebSocket.ts ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { useAgentStore } from '@/store/agentStore';
3
+ import type { AgentEvent } from '@/types/events';
4
+ import type { Message } from '@/types/agent';
5
+
6
+ const WS_RECONNECT_DELAY = 1000;
7
+ const WS_MAX_RECONNECT_DELAY = 30000;
8
+
9
+ interface UseAgentWebSocketOptions {
10
+ sessionId: string | null;
11
+ onReady?: () => void;
12
+ onError?: (error: string) => void;
13
+ }
14
+
15
+ export function useAgentWebSocket({
16
+ sessionId,
17
+ onReady,
18
+ onError,
19
+ }: UseAgentWebSocketOptions) {
20
+ const wsRef = useRef<WebSocket | null>(null);
21
+ const reconnectTimeoutRef = useRef<number | null>(null);
22
+ const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
23
+
24
+ const {
25
+ addMessage,
26
+ setProcessing,
27
+ setConnected,
28
+ setPendingApprovals,
29
+ setError,
30
+ } = useAgentStore();
31
+
32
+ const handleEvent = useCallback(
33
+ (event: AgentEvent) => {
34
+ if (!sessionId) return;
35
+
36
+ switch (event.event_type) {
37
+ case 'ready':
38
+ setConnected(true);
39
+ setProcessing(false);
40
+ onReady?.();
41
+ break;
42
+
43
+ case 'processing':
44
+ setProcessing(true);
45
+ break;
46
+
47
+ case 'assistant_message': {
48
+ const content = (event.data?.content as string) || '';
49
+ const message: Message = {
50
+ id: `msg_${Date.now()}`,
51
+ role: 'assistant',
52
+ content,
53
+ timestamp: new Date().toISOString(),
54
+ };
55
+ addMessage(sessionId, message);
56
+ break;
57
+ }
58
+
59
+ case 'tool_call': {
60
+ const toolName = (event.data?.tool as string) || 'unknown';
61
+ const args = event.data?.arguments || {};
62
+ const message: Message = {
63
+ id: `tool_${Date.now()}`,
64
+ role: 'tool',
65
+ content: `Calling ${toolName}...`,
66
+ timestamp: new Date().toISOString(),
67
+ toolName,
68
+ };
69
+ addMessage(sessionId, message);
70
+ // Store tool call args for display
71
+ console.log('Tool call:', toolName, args);
72
+ break;
73
+ }
74
+
75
+ case 'tool_output': {
76
+ const toolName = (event.data?.tool as string) || 'unknown';
77
+ const output = (event.data?.output as string) || '';
78
+ const success = event.data?.success as boolean;
79
+ const message: Message = {
80
+ id: `tool_out_${Date.now()}`,
81
+ role: 'tool',
82
+ content: output,
83
+ timestamp: new Date().toISOString(),
84
+ toolName,
85
+ };
86
+ addMessage(sessionId, message);
87
+ console.log('Tool output:', toolName, success);
88
+ break;
89
+ }
90
+
91
+ case 'approval_required': {
92
+ const tools = event.data?.tools as Array<{
93
+ tool: string;
94
+ arguments: Record<string, unknown>;
95
+ tool_call_id: string;
96
+ }>;
97
+ const count = (event.data?.count as number) || 0;
98
+ setPendingApprovals({ tools, count });
99
+ setProcessing(false);
100
+ break;
101
+ }
102
+
103
+ case 'turn_complete':
104
+ setProcessing(false);
105
+ break;
106
+
107
+ case 'compacted': {
108
+ const oldTokens = event.data?.old_tokens as number;
109
+ const newTokens = event.data?.new_tokens as number;
110
+ console.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
111
+ break;
112
+ }
113
+
114
+ case 'error': {
115
+ const errorMsg = (event.data?.error as string) || 'Unknown error';
116
+ setError(errorMsg);
117
+ setProcessing(false);
118
+ onError?.(errorMsg);
119
+ break;
120
+ }
121
+
122
+ case 'shutdown':
123
+ setConnected(false);
124
+ setProcessing(false);
125
+ break;
126
+
127
+ case 'interrupted':
128
+ setProcessing(false);
129
+ break;
130
+
131
+ case 'undo_complete':
132
+ // Could remove last messages from store
133
+ break;
134
+
135
+ default:
136
+ console.log('Unknown event:', event);
137
+ }
138
+ },
139
+ [
140
+ sessionId,
141
+ addMessage,
142
+ setProcessing,
143
+ setConnected,
144
+ setPendingApprovals,
145
+ setError,
146
+ onReady,
147
+ onError,
148
+ ]
149
+ );
150
+
151
+ const connect = useCallback(() => {
152
+ if (!sessionId || wsRef.current?.readyState === WebSocket.OPEN) return;
153
+
154
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
155
+ const host = window.location.host;
156
+ const wsUrl = `${protocol}//${host}/api/ws/${sessionId}`;
157
+
158
+ const ws = new WebSocket(wsUrl);
159
+
160
+ ws.onopen = () => {
161
+ console.log('WebSocket connected');
162
+ setConnected(true);
163
+ reconnectDelayRef.current = WS_RECONNECT_DELAY;
164
+ };
165
+
166
+ ws.onmessage = (event) => {
167
+ try {
168
+ const data = JSON.parse(event.data) as AgentEvent;
169
+ handleEvent(data);
170
+ } catch (e) {
171
+ console.error('Failed to parse WebSocket message:', e);
172
+ }
173
+ };
174
+
175
+ ws.onerror = (error) => {
176
+ console.error('WebSocket error:', error);
177
+ };
178
+
179
+ ws.onclose = () => {
180
+ console.log('WebSocket closed');
181
+ setConnected(false);
182
+
183
+ // Attempt to reconnect with exponential backoff
184
+ if (reconnectTimeoutRef.current) {
185
+ clearTimeout(reconnectTimeoutRef.current);
186
+ }
187
+ reconnectTimeoutRef.current = window.setTimeout(() => {
188
+ reconnectDelayRef.current = Math.min(
189
+ reconnectDelayRef.current * 2,
190
+ WS_MAX_RECONNECT_DELAY
191
+ );
192
+ connect();
193
+ }, reconnectDelayRef.current);
194
+ };
195
+
196
+ wsRef.current = ws;
197
+ }, [sessionId, handleEvent, setConnected]);
198
+
199
+ const disconnect = useCallback(() => {
200
+ if (reconnectTimeoutRef.current) {
201
+ clearTimeout(reconnectTimeoutRef.current);
202
+ reconnectTimeoutRef.current = null;
203
+ }
204
+ if (wsRef.current) {
205
+ wsRef.current.close();
206
+ wsRef.current = null;
207
+ }
208
+ setConnected(false);
209
+ }, [setConnected]);
210
+
211
+ const sendPing = useCallback(() => {
212
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
213
+ wsRef.current.send(JSON.stringify({ type: 'ping' }));
214
+ }
215
+ }, []);
216
+
217
+ // Connect when sessionId changes
218
+ useEffect(() => {
219
+ if (sessionId) {
220
+ connect();
221
+ }
222
+ return () => {
223
+ disconnect();
224
+ };
225
+ }, [sessionId, connect, disconnect]);
226
+
227
+ // Heartbeat
228
+ useEffect(() => {
229
+ const interval = setInterval(sendPing, 30000);
230
+ return () => clearInterval(interval);
231
+ }, [sendPing]);
232
+
233
+ return {
234
+ isConnected: wsRef.current?.readyState === WebSocket.OPEN,
235
+ connect,
236
+ disconnect,
237
+ };
238
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { ThemeProvider } from '@mui/material/styles';
4
+ import CssBaseline from '@mui/material/CssBaseline';
5
+ import App from './App';
6
+ import theme from './theme';
7
+
8
+ createRoot(document.getElementById('root')!).render(
9
+ <StrictMode>
10
+ <ThemeProvider theme={theme}>
11
+ <CssBaseline />
12
+ <App />
13
+ </ThemeProvider>
14
+ </StrictMode>
15
+ );
frontend/src/store/agentStore.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+ import type { Message, ApprovalBatch, User } from '@/types/agent';
3
+
4
+ interface AgentStore {
5
+ // State per session (keyed by session ID)
6
+ messagesBySession: Record<string, Message[]>;
7
+ isProcessing: boolean;
8
+ isConnected: boolean;
9
+ pendingApprovals: ApprovalBatch | null;
10
+ user: User | null;
11
+ error: string | null;
12
+
13
+ // Actions
14
+ addMessage: (sessionId: string, message: Message) => void;
15
+ clearMessages: (sessionId: string) => void;
16
+ setProcessing: (isProcessing: boolean) => void;
17
+ setConnected: (isConnected: boolean) => void;
18
+ setPendingApprovals: (approvals: ApprovalBatch | null) => void;
19
+ setUser: (user: User | null) => void;
20
+ setError: (error: string | null) => void;
21
+ getMessages: (sessionId: string) => Message[];
22
+ }
23
+
24
+ export const useAgentStore = create<AgentStore>((set, get) => ({
25
+ messagesBySession: {},
26
+ isProcessing: false,
27
+ isConnected: false,
28
+ pendingApprovals: null,
29
+ user: null,
30
+ error: null,
31
+
32
+ addMessage: (sessionId: string, message: Message) => {
33
+ set((state) => {
34
+ const currentMessages = state.messagesBySession[sessionId] || [];
35
+ return {
36
+ messagesBySession: {
37
+ ...state.messagesBySession,
38
+ [sessionId]: [...currentMessages, message],
39
+ },
40
+ };
41
+ });
42
+ },
43
+
44
+ clearMessages: (sessionId: string) => {
45
+ set((state) => ({
46
+ messagesBySession: {
47
+ ...state.messagesBySession,
48
+ [sessionId]: [],
49
+ },
50
+ }));
51
+ },
52
+
53
+ setProcessing: (isProcessing: boolean) => {
54
+ set({ isProcessing });
55
+ },
56
+
57
+ setConnected: (isConnected: boolean) => {
58
+ set({ isConnected });
59
+ },
60
+
61
+ setPendingApprovals: (approvals: ApprovalBatch | null) => {
62
+ set({ pendingApprovals: approvals });
63
+ },
64
+
65
+ setUser: (user: User | null) => {
66
+ set({ user });
67
+ },
68
+
69
+ setError: (error: string | null) => {
70
+ set({ error });
71
+ },
72
+
73
+ getMessages: (sessionId: string) => {
74
+ return get().messagesBySession[sessionId] || [];
75
+ },
76
+ }));
frontend/src/store/sessionStore.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
+ import type { SessionMeta } from '@/types/agent';
4
+
5
+ interface SessionStore {
6
+ sessions: SessionMeta[];
7
+ activeSessionId: string | null;
8
+
9
+ // Actions
10
+ createSession: (id: string) => void;
11
+ deleteSession: (id: string) => void;
12
+ switchSession: (id: string) => void;
13
+ updateSessionTitle: (id: string, title: string) => void;
14
+ setSessionActive: (id: string, isActive: boolean) => void;
15
+ }
16
+
17
+ export const useSessionStore = create<SessionStore>()(
18
+ persist(
19
+ (set, get) => ({
20
+ sessions: [],
21
+ activeSessionId: null,
22
+
23
+ createSession: (id: string) => {
24
+ const newSession: SessionMeta = {
25
+ id,
26
+ title: `Chat ${get().sessions.length + 1}`,
27
+ createdAt: new Date().toISOString(),
28
+ isActive: true,
29
+ };
30
+ set((state) => ({
31
+ sessions: [...state.sessions, newSession],
32
+ activeSessionId: id,
33
+ }));
34
+ },
35
+
36
+ deleteSession: (id: string) => {
37
+ set((state) => {
38
+ const newSessions = state.sessions.filter((s) => s.id !== id);
39
+ const newActiveId =
40
+ state.activeSessionId === id
41
+ ? newSessions[newSessions.length - 1]?.id || null
42
+ : state.activeSessionId;
43
+ return {
44
+ sessions: newSessions,
45
+ activeSessionId: newActiveId,
46
+ };
47
+ });
48
+ },
49
+
50
+ switchSession: (id: string) => {
51
+ set({ activeSessionId: id });
52
+ },
53
+
54
+ updateSessionTitle: (id: string, title: string) => {
55
+ set((state) => ({
56
+ sessions: state.sessions.map((s) =>
57
+ s.id === id ? { ...s, title } : s
58
+ ),
59
+ }));
60
+ },
61
+
62
+ setSessionActive: (id: string, isActive: boolean) => {
63
+ set((state) => ({
64
+ sessions: state.sessions.map((s) =>
65
+ s.id === id ? { ...s, isActive } : s
66
+ ),
67
+ }));
68
+ },
69
+ }),
70
+ {
71
+ name: 'hf-agent-sessions',
72
+ partialize: (state) => ({
73
+ sessions: state.sessions,
74
+ activeSessionId: state.activeSessionId,
75
+ }),
76
+ }
77
+ )
78
+ );
frontend/src/theme.ts ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTheme } from '@mui/material/styles';
2
+
3
+ const theme = createTheme({
4
+ palette: {
5
+ mode: 'dark',
6
+ primary: {
7
+ main: '#FFD21E',
8
+ light: '#FFE066',
9
+ dark: '#E6BD1B',
10
+ },
11
+ secondary: {
12
+ main: '#FF9D00',
13
+ },
14
+ background: {
15
+ default: '#0D1117',
16
+ paper: '#161B22',
17
+ },
18
+ text: {
19
+ primary: '#E6EDF3',
20
+ secondary: '#8B949E',
21
+ },
22
+ divider: '#30363D',
23
+ success: {
24
+ main: '#3FB950',
25
+ },
26
+ error: {
27
+ main: '#F85149',
28
+ },
29
+ warning: {
30
+ main: '#D29922',
31
+ },
32
+ info: {
33
+ main: '#58A6FF',
34
+ },
35
+ },
36
+ typography: {
37
+ fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
38
+ h1: {
39
+ fontWeight: 600,
40
+ },
41
+ h2: {
42
+ fontWeight: 600,
43
+ },
44
+ h3: {
45
+ fontWeight: 600,
46
+ },
47
+ h4: {
48
+ fontWeight: 600,
49
+ },
50
+ h5: {
51
+ fontWeight: 600,
52
+ },
53
+ h6: {
54
+ fontWeight: 600,
55
+ },
56
+ body1: {
57
+ fontSize: '0.9375rem',
58
+ },
59
+ body2: {
60
+ fontSize: '0.875rem',
61
+ },
62
+ },
63
+ components: {
64
+ MuiCssBaseline: {
65
+ styleOverrides: {
66
+ body: {
67
+ scrollbarWidth: 'thin',
68
+ '&::-webkit-scrollbar': {
69
+ width: '8px',
70
+ height: '8px',
71
+ },
72
+ '&::-webkit-scrollbar-thumb': {
73
+ backgroundColor: '#30363D',
74
+ borderRadius: '4px',
75
+ },
76
+ '&::-webkit-scrollbar-track': {
77
+ backgroundColor: 'transparent',
78
+ },
79
+ },
80
+ 'code, pre': {
81
+ fontFamily: '"JetBrains Mono", "Fira Code", monospace',
82
+ },
83
+ },
84
+ },
85
+ MuiButton: {
86
+ styleOverrides: {
87
+ root: {
88
+ textTransform: 'none',
89
+ fontWeight: 500,
90
+ },
91
+ },
92
+ },
93
+ MuiPaper: {
94
+ styleOverrides: {
95
+ root: {
96
+ backgroundImage: 'none',
97
+ },
98
+ },
99
+ },
100
+ MuiDrawer: {
101
+ styleOverrides: {
102
+ paper: {
103
+ borderRight: '1px solid #30363D',
104
+ },
105
+ },
106
+ },
107
+ },
108
+ shape: {
109
+ borderRadius: 8,
110
+ },
111
+ });
112
+
113
+ export default theme;
frontend/src/types/agent.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Agent-related types
3
+ */
4
+
5
+ export interface SessionMeta {
6
+ id: string;
7
+ title: string;
8
+ createdAt: string;
9
+ isActive: boolean;
10
+ }
11
+
12
+ export interface Message {
13
+ id: string;
14
+ role: 'user' | 'assistant' | 'tool';
15
+ content: string;
16
+ timestamp: string;
17
+ toolName?: string;
18
+ toolCallId?: string;
19
+ }
20
+
21
+ export interface ToolCall {
22
+ id: string;
23
+ tool: string;
24
+ arguments: Record<string, unknown>;
25
+ status: 'pending' | 'running' | 'completed' | 'failed';
26
+ output?: string;
27
+ }
28
+
29
+ export interface ToolApproval {
30
+ toolCallId: string;
31
+ approved: boolean;
32
+ feedback?: string;
33
+ }
34
+
35
+ export interface ApprovalBatch {
36
+ tools: Array<{
37
+ tool: string;
38
+ arguments: Record<string, unknown>;
39
+ tool_call_id: string;
40
+ }>;
41
+ count: number;
42
+ }
43
+
44
+ export interface User {
45
+ authenticated: boolean;
46
+ username?: string;
47
+ name?: string;
48
+ picture?: string;
49
+ }
frontend/src/types/events.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Event types from the agent backend
3
+ */
4
+
5
+ export type EventType =
6
+ | 'ready'
7
+ | 'processing'
8
+ | 'assistant_message'
9
+ | 'tool_call'
10
+ | 'tool_output'
11
+ | 'approval_required'
12
+ | 'turn_complete'
13
+ | 'compacted'
14
+ | 'error'
15
+ | 'shutdown'
16
+ | 'interrupted'
17
+ | 'undo_complete';
18
+
19
+ export interface AgentEvent {
20
+ event_type: EventType;
21
+ data?: Record<string, unknown>;
22
+ }
23
+
24
+ export interface ReadyEventData {
25
+ message: string;
26
+ }
27
+
28
+ export interface ProcessingEventData {
29
+ message: string;
30
+ }
31
+
32
+ export interface AssistantMessageEventData {
33
+ content: string;
34
+ }
35
+
36
+ export interface ToolCallEventData {
37
+ tool: string;
38
+ arguments: Record<string, unknown>;
39
+ }
40
+
41
+ export interface ToolOutputEventData {
42
+ tool: string;
43
+ output: string;
44
+ success: boolean;
45
+ }
46
+
47
+ export interface ApprovalRequiredEventData {
48
+ tools: ApprovalToolItem[];
49
+ count: number;
50
+ }
51
+
52
+ export interface ApprovalToolItem {
53
+ tool: string;
54
+ arguments: Record<string, unknown>;
55
+ tool_call_id: string;
56
+ }
57
+
58
+ export interface TurnCompleteEventData {
59
+ history_size: number;
60
+ }
61
+
62
+ export interface CompactedEventData {
63
+ old_tokens: number;
64
+ new_tokens: number;
65
+ }
66
+
67
+ export interface ErrorEventData {
68
+ error: string;
69
+ }
frontend/src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
frontend/tsconfig.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedSideEffectImports": true,
19
+ "baseUrl": ".",
20
+ "paths": {
21
+ "@/*": ["src/*"]
22
+ }
23
+ },
24
+ "include": ["src"]
25
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import path from 'path'
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ resolve: {
8
+ alias: {
9
+ '@': path.resolve(__dirname, './src'),
10
+ },
11
+ },
12
+ server: {
13
+ port: 5173,
14
+ proxy: {
15
+ '/api': {
16
+ target: 'http://localhost:8000',
17
+ changeOrigin: true,
18
+ },
19
+ '/auth': {
20
+ target: 'http://localhost:8000',
21
+ changeOrigin: true,
22
+ },
23
+ },
24
+ },
25
+ build: {
26
+ outDir: 'dist',
27
+ sourcemap: false,
28
+ },
29
+ })
pyproject.toml CHANGED
@@ -25,6 +25,11 @@ agent = [
25
  "nbformat>=5.10.4",
26
  "datasets>=4.3.0", # For session logging to HF datasets
27
  "whoosh>=2.7.4",
 
 
 
 
 
28
  ]
29
 
30
  # Evaluation/benchmarking dependencies
 
25
  "nbformat>=5.10.4",
26
  "datasets>=4.3.0", # For session logging to HF datasets
27
  "whoosh>=2.7.4",
28
+ # Web backend dependencies
29
+ "fastapi>=0.115.0",
30
+ "uvicorn[standard]>=0.32.0",
31
+ "httpx>=0.27.0",
32
+ "websockets>=13.0",
33
  ]
34
 
35
  # Evaluation/benchmarking dependencies