Spaces:
Running
Running
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 +53 -0
- README.md +14 -0
- backend/__init__.py +1 -0
- backend/main.py +79 -0
- backend/models.py +76 -0
- backend/routes/__init__.py +1 -0
- backend/routes/agent.py +149 -0
- backend/routes/auth.py +146 -0
- backend/session_manager.py +255 -0
- backend/websocket.py +67 -0
- frontend/eslint.config.js +28 -0
- frontend/index.html +16 -0
- frontend/package.json +35 -0
- frontend/public/vite.svg +3 -0
- frontend/src/App.tsx +12 -0
- frontend/src/components/ApprovalModal/ApprovalModal.tsx +208 -0
- frontend/src/components/Chat/ChatInput.tsx +77 -0
- frontend/src/components/Chat/MessageBubble.tsx +100 -0
- frontend/src/components/Chat/MessageList.tsx +59 -0
- frontend/src/components/Layout/AppLayout.tsx +227 -0
- frontend/src/components/SessionSidebar/SessionSidebar.tsx +161 -0
- frontend/src/hooks/useAgentWebSocket.ts +238 -0
- frontend/src/main.tsx +15 -0
- frontend/src/store/agentStore.ts +76 -0
- frontend/src/store/sessionStore.ts +78 -0
- frontend/src/theme.ts +113 -0
- frontend/src/types/agent.ts +49 -0
- frontend/src/types/events.ts +69 -0
- frontend/src/vite-env.d.ts +1 -0
- frontend/tsconfig.json +25 -0
- frontend/vite.config.ts +29 -0
- pyproject.toml +5 -0
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
|