Aksel Joonas Reedi commited on
Commit
da44165
·
2 Parent(s): d3ffa60a8796d4

Merge pull request #21 from huggingface/feature/web-frontend

Browse files
Files changed (46) hide show
  1. .gitignore +1 -0
  2. Dockerfile +59 -0
  3. README.md +14 -0
  4. agent/core/agent_loop.py +4 -2
  5. agent/core/tools.py +7 -1
  6. agent/prompts/system_prompt.yaml +1 -1
  7. agent/tools/jobs_tool.py +61 -9
  8. agent/tools/plan_tool.py +16 -4
  9. backend/__init__.py +1 -0
  10. backend/main.py +79 -0
  11. backend/models.py +76 -0
  12. backend/routes/__init__.py +1 -0
  13. backend/routes/agent.py +151 -0
  14. backend/routes/auth.py +148 -0
  15. backend/session_manager.py +276 -0
  16. backend/websocket.py +72 -0
  17. configs/main_agent_config.json +1 -1
  18. eval/.amp_batch_solve.py.swp +0 -0
  19. frontend/eslint.config.js +28 -0
  20. frontend/index.html +16 -0
  21. frontend/package-lock.json +0 -0
  22. frontend/package.json +38 -0
  23. frontend/public/vite.svg +3 -0
  24. frontend/src/App.tsx +12 -0
  25. frontend/src/components/ApprovalModal/ApprovalModal.tsx +208 -0
  26. frontend/src/components/Chat/ApprovalFlow.tsx +490 -0
  27. frontend/src/components/Chat/ChatInput.tsx +126 -0
  28. frontend/src/components/Chat/MessageBubble.tsx +178 -0
  29. frontend/src/components/Chat/MessageList.tsx +100 -0
  30. frontend/src/components/CodePanel/CodePanel.tsx +204 -0
  31. frontend/src/components/Layout/AppLayout.tsx +284 -0
  32. frontend/src/components/SessionSidebar/SessionSidebar.tsx +248 -0
  33. frontend/src/hooks/useAgentWebSocket.ts +405 -0
  34. frontend/src/main.tsx +15 -0
  35. frontend/src/store/agentStore.ts +209 -0
  36. frontend/src/store/layoutStore.ts +23 -0
  37. frontend/src/store/sessionStore.ts +78 -0
  38. frontend/src/theme.ts +158 -0
  39. frontend/src/types/agent.ts +65 -0
  40. frontend/src/types/events.ts +80 -0
  41. frontend/src/utils/logProcessor.ts +81 -0
  42. frontend/src/vite-env.d.ts +1 -0
  43. frontend/tsconfig.json +25 -0
  44. frontend/vite.config.ts +29 -0
  45. pyproject.toml +5 -0
  46. uv.lock +190 -0
.gitignore CHANGED
@@ -68,3 +68,4 @@ models/
68
  checkpoint-*/
69
  runs/
70
  wandb/
 
 
68
  checkpoint-*/
69
  runs/
70
  wandb/
71
+ frontend/tsconfig.tsbuildinfo
Dockerfile ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build frontend
2
+ FROM node:20-alpine AS frontend-builder
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package.json frontend/package-lock.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
+ # Install uv directly from official image
13
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
14
+
15
+ # Create user with UID 1000 (required for HF Spaces)
16
+ RUN useradd -m -u 1000 user
17
+
18
+ WORKDIR /app
19
+
20
+ # Install system dependencies
21
+ RUN apt-get update && apt-get install -y --no-install-recommends \
22
+ git \
23
+ curl \
24
+ && rm -rf /var/lib/apt/lists/*
25
+
26
+ # Copy dependency files
27
+ COPY pyproject.toml uv.lock ./
28
+
29
+ # Install dependencies into /app/.venv
30
+ # Use --frozen to ensure exact versions from uv.lock
31
+ RUN uv sync --extra agent --no-dev --frozen
32
+
33
+ # Copy application code
34
+ COPY agent/ ./agent/
35
+ COPY backend/ ./backend/
36
+ COPY configs/ ./configs/
37
+
38
+ # Copy built frontend
39
+ COPY --from=frontend-builder /app/frontend/dist ./static/
40
+
41
+ # Create directories and set ownership
42
+ RUN mkdir -p /app/session_logs && \
43
+ chown -R user:user /app
44
+
45
+ # Switch to non-root user
46
+ USER user
47
+
48
+ # Set environment
49
+ ENV HOME=/home/user \
50
+ PYTHONUNBUFFERED=1 \
51
+ PYTHONPATH=/app \
52
+ PATH="/app/.venv/bin:$PATH"
53
+
54
+ # Expose port
55
+ EXPOSE 7860
56
+
57
+ # Run the application from backend directory
58
+ WORKDIR /app/backend
59
+ CMD ["uvicorn", "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.
agent/core/agent_loop.py CHANGED
@@ -203,7 +203,7 @@ class Handlers:
203
  )
204
 
205
  output, success = await session.tool_router.call_tool(
206
- tool_name, tool_args
207
  )
208
 
209
  # Add tool result to history
@@ -376,7 +376,9 @@ class Handlers:
376
  )
377
  )
378
 
379
- output, success = await session.tool_router.call_tool(tool_name, tool_args)
 
 
380
 
381
  return (tc, tool_name, output, success)
382
 
 
203
  )
204
 
205
  output, success = await session.tool_router.call_tool(
206
+ tool_name, tool_args, session=session
207
  )
208
 
209
  # Add tool result to history
 
376
  )
377
  )
378
 
379
+ output, success = await session.tool_router.call_tool(
380
+ tool_name, tool_args, session=session
381
+ )
382
 
383
  return (tc, tool_name, output, success)
384
 
agent/core/tools.py CHANGED
@@ -219,7 +219,7 @@ class ToolRouter:
219
 
220
  @observe(name="call_tool")
221
  async def call_tool(
222
- self, tool_name: str, arguments: dict[str, Any]
223
  ) -> tuple[str, bool]:
224
  """
225
  Call a tool and return (output_string, success_bool).
@@ -230,6 +230,12 @@ class ToolRouter:
230
  # Check if this is a built-in tool with a handler
231
  tool = self.tools.get(tool_name)
232
  if tool and tool.handler:
 
 
 
 
 
 
233
  return await tool.handler(arguments)
234
 
235
  # Otherwise, use MCP client
 
219
 
220
  @observe(name="call_tool")
221
  async def call_tool(
222
+ self, tool_name: str, arguments: dict[str, Any], session: Any = None
223
  ) -> tuple[str, bool]:
224
  """
225
  Call a tool and return (output_string, success_bool).
 
230
  # Check if this is a built-in tool with a handler
231
  tool = self.tools.get(tool_name)
232
  if tool and tool.handler:
233
+ import inspect
234
+
235
+ # Check if handler accepts session argument
236
+ sig = inspect.signature(tool.handler)
237
+ if "session" in sig.parameters:
238
+ return await tool.handler(arguments, session=session)
239
  return await tool.handler(arguments)
240
 
241
  # Otherwise, use MCP client
agent/prompts/system_prompt.yaml CHANGED
@@ -77,7 +77,7 @@ system_prompt: |
77
 
78
  - Be concise and direct.
79
  - Don't flatter the user.
80
- - Don't use emojis nor exclamation points.
81
  - If you are limited in a task, offer alternatives.
82
  - Don't thank the user when he provides results.
83
  - Explain what you're doing for non-trivial operations.
 
77
 
78
  - Be concise and direct.
79
  - Don't flatter the user.
80
+ - Never use emojis nor exclamation points.
81
  - If you are limited in a task, offer alternatives.
82
  - Don't thank the user when he provides results.
83
  - Explain what you're doing for non-trivial operations.
agent/tools/jobs_tool.py CHANGED
@@ -9,12 +9,13 @@ import base64
9
  import http.client
10
  import os
11
  import re
12
- from typing import Any, Dict, Literal, Optional
13
 
14
  import httpx
15
  from huggingface_hub import HfApi
16
  from huggingface_hub.utils import HfHubHTTPError
17
 
 
18
  from agent.tools.types import ToolResult
19
  from agent.tools.utilities import (
20
  format_job_details,
@@ -269,9 +270,15 @@ def _scheduled_job_info_to_dict(scheduled_job_info) -> Dict[str, Any]:
269
  class HfJobsTool:
270
  """Tool for managing Hugging Face compute jobs using huggingface-hub library"""
271
 
272
- def __init__(self, hf_token: Optional[str] = None, namespace: Optional[str] = None):
 
 
 
 
 
273
  self.api = HfApi(token=hf_token)
274
  self.namespace = namespace
 
275
 
276
  async def execute(self, params: Dict[str, Any]) -> ToolResult:
277
  """Execute the specified operation"""
@@ -360,15 +367,48 @@ class HfJobsTool:
360
 
361
  for _ in range(max_retries):
362
  try:
363
- # Fetch logs - generator streams logs as they arrive
364
- logs_gen = self.api.fetch_job_logs(job_id=job_id, namespace=namespace)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
 
366
- # Stream logs in real-time
367
- for log_line in logs_gen:
368
  print("\t" + log_line)
 
 
369
  all_logs.append(log_line)
370
 
371
- # If we get here, streaming completed normally
 
 
372
  break
373
 
374
  except (
@@ -963,10 +1003,22 @@ HF_JOBS_TOOL_SPEC = {
963
  }
964
 
965
 
966
- async def hf_jobs_handler(arguments: Dict[str, Any]) -> tuple[str, bool]:
 
 
967
  """Handler for agent tool router"""
968
  try:
969
- tool = HfJobsTool(namespace=os.environ.get("HF_NAMESPACE", ""))
 
 
 
 
 
 
 
 
 
 
970
  result = await tool.execute(arguments)
971
  return result["formatted"], not result.get("isError", False)
972
  except Exception as e:
 
9
  import http.client
10
  import os
11
  import re
12
+ from typing import Any, Dict, Literal, Optional, Callable, Awaitable
13
 
14
  import httpx
15
  from huggingface_hub import HfApi
16
  from huggingface_hub.utils import HfHubHTTPError
17
 
18
+ from agent.core.session import Event
19
  from agent.tools.types import ToolResult
20
  from agent.tools.utilities import (
21
  format_job_details,
 
270
  class HfJobsTool:
271
  """Tool for managing Hugging Face compute jobs using huggingface-hub library"""
272
 
273
+ def __init__(
274
+ self,
275
+ hf_token: Optional[str] = None,
276
+ namespace: Optional[str] = None,
277
+ log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
278
+ ):
279
  self.api = HfApi(token=hf_token)
280
  self.namespace = namespace
281
+ self.log_callback = log_callback
282
 
283
  async def execute(self, params: Dict[str, Any]) -> ToolResult:
284
  """Execute the specified operation"""
 
367
 
368
  for _ in range(max_retries):
369
  try:
370
+ # Use a queue to bridge sync generator to async consumer
371
+ queue = asyncio.Queue()
372
+ loop = asyncio.get_running_loop()
373
+
374
+ def log_producer():
375
+ try:
376
+ # fetch_job_logs is a blocking sync generator
377
+ logs_gen = self.api.fetch_job_logs(job_id=job_id, namespace=namespace)
378
+ for line in logs_gen:
379
+ # Push line to queue thread-safely
380
+ loop.call_soon_threadsafe(queue.put_nowait, line)
381
+ # Signal EOF
382
+ loop.call_soon_threadsafe(queue.put_nowait, None)
383
+ except Exception as e:
384
+ # Signal error
385
+ loop.call_soon_threadsafe(queue.put_nowait, e)
386
+
387
+ # Start producer in a background thread so it doesn't block the event loop
388
+ producer_future = loop.run_in_executor(None, log_producer)
389
+
390
+ # Consume logs from the queue as they arrive
391
+ while True:
392
+ item = await queue.get()
393
+
394
+ # EOF sentinel
395
+ if item is None:
396
+ break
397
+
398
+ # Error occurred in producer
399
+ if isinstance(item, Exception):
400
+ raise item
401
 
402
+ # Process log line
403
+ log_line = item
404
  print("\t" + log_line)
405
+ if self.log_callback:
406
+ await self.log_callback(log_line)
407
  all_logs.append(log_line)
408
 
409
+ # If we get here, streaming completed normally (EOF received)
410
+ # Wait for thread to cleanup (should be done)
411
+ await producer_future
412
  break
413
 
414
  except (
 
1003
  }
1004
 
1005
 
1006
+ async def hf_jobs_handler(
1007
+ arguments: Dict[str, Any], session: Any = None
1008
+ ) -> tuple[str, bool]:
1009
  """Handler for agent tool router"""
1010
  try:
1011
+
1012
+ async def log_callback(log: str):
1013
+ if session:
1014
+ await session.send_event(
1015
+ Event(event_type="tool_log", data={"tool": "hf_jobs", "log": log})
1016
+ )
1017
+
1018
+ tool = HfJobsTool(
1019
+ namespace=os.environ.get("HF_NAMESPACE", ""),
1020
+ log_callback=log_callback if session else None,
1021
+ )
1022
  result = await tool.execute(arguments)
1023
  return result["formatted"], not result.get("isError", False)
1024
  except Exception as e:
agent/tools/plan_tool.py CHANGED
@@ -1,5 +1,6 @@
1
  from typing import Any, Dict, List
2
 
 
3
  from agent.utils.terminal_display import format_plan_tool_output
4
 
5
  from .types import ToolResult
@@ -11,8 +12,8 @@ _current_plan: List[Dict[str, str]] = []
11
  class PlanTool:
12
  """Tool for managing a list of todos with status tracking."""
13
 
14
- def __init__(self):
15
- pass
16
 
17
  async def execute(self, params: Dict[str, Any]) -> ToolResult:
18
  """
@@ -56,6 +57,15 @@ class PlanTool:
56
  # Store the raw todos structure in memory
57
  _current_plan = todos
58
 
 
 
 
 
 
 
 
 
 
59
  # Format only for display using terminal_display utility
60
  formatted_output = format_plan_tool_output(todos)
61
 
@@ -120,7 +130,9 @@ PLAN_TOOL_SPEC = {
120
  }
121
 
122
 
123
- async def plan_tool_handler(arguments: Dict[str, Any]) -> tuple[str, bool]:
124
- tool = PlanTool()
 
 
125
  result = await tool.execute(arguments)
126
  return result["formatted"], not result.get("isError", False)
 
1
  from typing import Any, Dict, List
2
 
3
+ from agent.core.session import Event
4
  from agent.utils.terminal_display import format_plan_tool_output
5
 
6
  from .types import ToolResult
 
12
  class PlanTool:
13
  """Tool for managing a list of todos with status tracking."""
14
 
15
+ def __init__(self, session: Any = None):
16
+ self.session = session
17
 
18
  async def execute(self, params: Dict[str, Any]) -> ToolResult:
19
  """
 
57
  # Store the raw todos structure in memory
58
  _current_plan = todos
59
 
60
+ # Emit plan update event if session is available
61
+ if self.session:
62
+ await self.session.send_event(
63
+ Event(
64
+ event_type="plan_update",
65
+ data={"plan": todos},
66
+ )
67
+ )
68
+
69
  # Format only for display using terminal_display utility
70
  formatted_output = format_plan_tool_output(todos)
71
 
 
130
  }
131
 
132
 
133
+ async def plan_tool_handler(
134
+ arguments: Dict[str, Any], session: Any = None
135
+ ) -> tuple[str, bool]:
136
+ tool = PlanTool(session=session)
137
  result = await tool.execute(arguments)
138
  return result["formatted"], not result.get("isError", False)
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 routes.agent import router as agent_router
13
+ from 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,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent API routes - WebSocket and REST endpoints."""
2
+
3
+ import logging
4
+
5
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
6
+
7
+ from models import (
8
+ ApprovalRequest,
9
+ HealthResponse,
10
+ SessionInfo,
11
+ SessionResponse,
12
+ SubmitRequest,
13
+ )
14
+ from session_manager import session_manager
15
+ from 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
+ logger.info(f"WebSocket connection request for session {session_id}")
128
+ # Verify session exists
129
+ info = session_manager.get_session_info(session_id)
130
+ if not info:
131
+ logger.warning(f"WebSocket connection rejected: Session {session_id} not found")
132
+ await websocket.close(code=4004, reason="Session not found")
133
+ return
134
+
135
+ await ws_manager.connect(websocket, session_id)
136
+
137
+ try:
138
+ while True:
139
+ # Keep connection alive, handle ping/pong
140
+ data = await websocket.receive_json()
141
+
142
+ # Handle client messages (e.g., ping)
143
+ if data.get("type") == "ping":
144
+ await websocket.send_json({"type": "pong"})
145
+
146
+ except WebSocketDisconnect:
147
+ logger.info(f"WebSocket disconnected for session {session_id}")
148
+ except Exception as e:
149
+ logger.error(f"WebSocket error for session {session_id}: {e}")
150
+ finally:
151
+ ws_manager.disconnect(session_id)
backend/routes/auth.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(
60
+ request: Request, code: str = "", state: str = ""
61
+ ) -> RedirectResponse:
62
+ """Handle OAuth callback."""
63
+ # Verify state
64
+ if state not in oauth_states:
65
+ raise HTTPException(status_code=400, detail="Invalid state parameter")
66
+
67
+ stored_state = oauth_states.pop(state)
68
+ redirect_uri = stored_state["redirect_uri"]
69
+
70
+ if not code:
71
+ raise HTTPException(status_code=400, detail="No authorization code provided")
72
+
73
+ # Exchange code for token
74
+ token_url = f"{OPENID_PROVIDER_URL}/oauth/token"
75
+ async with httpx.AsyncClient() as client:
76
+ try:
77
+ response = await client.post(
78
+ token_url,
79
+ data={
80
+ "grant_type": "authorization_code",
81
+ "code": code,
82
+ "redirect_uri": redirect_uri,
83
+ "client_id": OAUTH_CLIENT_ID,
84
+ "client_secret": OAUTH_CLIENT_SECRET,
85
+ },
86
+ )
87
+ response.raise_for_status()
88
+ token_data = response.json()
89
+ except httpx.HTTPError as e:
90
+ raise HTTPException(status_code=500, detail=f"Token exchange failed: {e}")
91
+
92
+ # Get user info
93
+ access_token = token_data.get("access_token")
94
+ if access_token:
95
+ async with httpx.AsyncClient() as client:
96
+ try:
97
+ userinfo_response = await client.get(
98
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
99
+ headers={"Authorization": f"Bearer {access_token}"},
100
+ )
101
+ userinfo_response.raise_for_status()
102
+ user_info = userinfo_response.json()
103
+ except httpx.HTTPError:
104
+ user_info = {}
105
+ else:
106
+ user_info = {}
107
+
108
+ # For now, redirect to home with token in query params
109
+ # In production, use secure cookies or session storage
110
+ redirect_params = {
111
+ "access_token": access_token,
112
+ "username": user_info.get("preferred_username", ""),
113
+ }
114
+
115
+ return RedirectResponse(url=f"/?{urlencode(redirect_params)}")
116
+
117
+
118
+ @router.get("/logout")
119
+ async def logout() -> RedirectResponse:
120
+ """Log out the user."""
121
+ return RedirectResponse(url="/")
122
+
123
+
124
+ @router.get("/me")
125
+ async def get_current_user(request: Request) -> dict:
126
+ """Get current user info from Authorization header."""
127
+ auth_header = request.headers.get("Authorization", "")
128
+ if not auth_header.startswith("Bearer "):
129
+ return {"authenticated": False}
130
+
131
+ token = auth_header.split(" ")[1]
132
+
133
+ async with httpx.AsyncClient() as client:
134
+ try:
135
+ response = await client.get(
136
+ f"{OPENID_PROVIDER_URL}/oauth/userinfo",
137
+ headers={"Authorization": f"Bearer {token}"},
138
+ )
139
+ response.raise_for_status()
140
+ user_info = response.json()
141
+ return {
142
+ "authenticated": True,
143
+ "username": user_info.get("preferred_username"),
144
+ "name": user_info.get("name"),
145
+ "picture": user_info.get("picture"),
146
+ }
147
+ except httpx.HTTPError:
148
+ return {"authenticated": False}
backend/session_manager.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ from websocket import manager as ws_manager
12
+
13
+ from agent.config import load_config
14
+ from agent.core.agent_loop import process_submission
15
+ from agent.core.session import Event, OpType, Session
16
+ from agent.core.tools import ToolRouter
17
+
18
+ # Get project root (parent of backend directory)
19
+ PROJECT_ROOT = Path(__file__).parent.parent
20
+ DEFAULT_CONFIG_PATH = str(PROJECT_ROOT / "configs" / "main_agent_config.json")
21
+
22
+
23
+ # These dataclasses match agent/main.py structure
24
+ @dataclass
25
+ class Operation:
26
+ """Operation to be executed by the agent."""
27
+
28
+ op_type: OpType
29
+ data: Optional[dict[str, Any]] = None
30
+
31
+
32
+ @dataclass
33
+ class Submission:
34
+ """Submission to the agent loop."""
35
+
36
+ id: str
37
+ operation: Operation
38
+
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ @dataclass
44
+ class AgentSession:
45
+ """Wrapper for an agent session with its associated resources."""
46
+
47
+ session_id: str
48
+ session: Session
49
+ tool_router: ToolRouter
50
+ submission_queue: asyncio.Queue
51
+ task: asyncio.Task | None = None
52
+ created_at: datetime = field(default_factory=datetime.utcnow)
53
+ is_active: bool = True
54
+
55
+
56
+ class SessionManager:
57
+ """Manages multiple concurrent agent sessions."""
58
+
59
+ def __init__(self, config_path: str | None = None) -> None:
60
+ self.config = load_config(config_path or DEFAULT_CONFIG_PATH)
61
+ self.sessions: dict[str, AgentSession] = {}
62
+ self._lock = asyncio.Lock()
63
+
64
+ async def create_session(self) -> str:
65
+ """Create a new agent session and return its ID."""
66
+ session_id = str(uuid.uuid4())
67
+
68
+ # Create queues for this session
69
+ submission_queue: asyncio.Queue = asyncio.Queue()
70
+ event_queue: asyncio.Queue = asyncio.Queue()
71
+
72
+ # Create tool router
73
+ tool_router = ToolRouter(self.config.mcpServers)
74
+
75
+ # Create the agent session
76
+ session = Session(event_queue, config=self.config, tool_router=tool_router)
77
+
78
+ # Create wrapper
79
+ agent_session = AgentSession(
80
+ session_id=session_id,
81
+ session=session,
82
+ tool_router=tool_router,
83
+ submission_queue=submission_queue,
84
+ )
85
+
86
+ async with self._lock:
87
+ self.sessions[session_id] = agent_session
88
+
89
+ # Start the agent loop task
90
+ task = asyncio.create_task(
91
+ self._run_session(session_id, submission_queue, event_queue, tool_router)
92
+ )
93
+ agent_session.task = task
94
+
95
+ logger.info(f"Created session {session_id}")
96
+ return session_id
97
+
98
+ async def _run_session(
99
+ self,
100
+ session_id: str,
101
+ submission_queue: asyncio.Queue,
102
+ event_queue: asyncio.Queue,
103
+ tool_router: ToolRouter,
104
+ ) -> None:
105
+ """Run the agent loop for a session and forward events to WebSocket."""
106
+ agent_session = self.sessions.get(session_id)
107
+ if not agent_session:
108
+ logger.error(f"Session {session_id} not found")
109
+ return
110
+
111
+ session = agent_session.session
112
+
113
+ # Start event forwarder task
114
+ event_forwarder = asyncio.create_task(
115
+ self._forward_events(session_id, event_queue)
116
+ )
117
+
118
+ try:
119
+ async with tool_router:
120
+ # Send ready event
121
+ await session.send_event(
122
+ Event(event_type="ready", data={"message": "Agent initialized"})
123
+ )
124
+
125
+ while session.is_running:
126
+ try:
127
+ # Wait for submission with timeout to allow checking is_running
128
+ submission = await asyncio.wait_for(
129
+ submission_queue.get(), timeout=1.0
130
+ )
131
+ should_continue = await process_submission(session, submission)
132
+ if not should_continue:
133
+ break
134
+ except asyncio.TimeoutError:
135
+ continue
136
+ except asyncio.CancelledError:
137
+ logger.info(f"Session {session_id} cancelled")
138
+ break
139
+ except Exception as e:
140
+ logger.error(f"Error in session {session_id}: {e}")
141
+ await session.send_event(
142
+ Event(event_type="error", data={"error": str(e)})
143
+ )
144
+
145
+ finally:
146
+ event_forwarder.cancel()
147
+ try:
148
+ await event_forwarder
149
+ except asyncio.CancelledError:
150
+ pass
151
+
152
+ async with self._lock:
153
+ if session_id in self.sessions:
154
+ self.sessions[session_id].is_active = False
155
+
156
+ logger.info(f"Session {session_id} ended")
157
+
158
+ async def _forward_events(
159
+ self, session_id: str, event_queue: asyncio.Queue
160
+ ) -> None:
161
+ """Forward events from the agent to the WebSocket."""
162
+ while True:
163
+ try:
164
+ event: Event = await event_queue.get()
165
+ await ws_manager.send_event(session_id, event.event_type, event.data)
166
+ except asyncio.CancelledError:
167
+ break
168
+ except Exception as e:
169
+ logger.error(f"Error forwarding event for {session_id}: {e}")
170
+
171
+ async def submit(self, session_id: str, operation: Operation) -> bool:
172
+ """Submit an operation to a session."""
173
+ async with self._lock:
174
+ agent_session = self.sessions.get(session_id)
175
+
176
+ if not agent_session or not agent_session.is_active:
177
+ logger.warning(f"Session {session_id} not found or inactive")
178
+ return False
179
+
180
+ submission = Submission(id=f"sub_{uuid.uuid4().hex[:8]}", operation=operation)
181
+ await agent_session.submission_queue.put(submission)
182
+ return True
183
+
184
+ async def submit_user_input(self, session_id: str, text: str) -> bool:
185
+ """Submit user input to a session."""
186
+ operation = Operation(op_type=OpType.USER_INPUT, data={"text": text})
187
+ return await self.submit(session_id, operation)
188
+
189
+ async def submit_approval(
190
+ self, session_id: str, approvals: list[dict[str, Any]]
191
+ ) -> bool:
192
+ """Submit tool approvals to a session."""
193
+ operation = Operation(
194
+ op_type=OpType.EXEC_APPROVAL, data={"approvals": approvals}
195
+ )
196
+ return await self.submit(session_id, operation)
197
+
198
+ async def interrupt(self, session_id: str) -> bool:
199
+ """Interrupt a session."""
200
+ operation = Operation(op_type=OpType.INTERRUPT)
201
+ return await self.submit(session_id, operation)
202
+
203
+ async def undo(self, session_id: str) -> bool:
204
+ """Undo last turn in a session."""
205
+ operation = Operation(op_type=OpType.UNDO)
206
+ return await self.submit(session_id, operation)
207
+
208
+ async def compact(self, session_id: str) -> bool:
209
+ """Compact context in a session."""
210
+ operation = Operation(op_type=OpType.COMPACT)
211
+ return await self.submit(session_id, operation)
212
+
213
+ async def shutdown_session(self, session_id: str) -> bool:
214
+ """Shutdown a specific session."""
215
+ operation = Operation(op_type=OpType.SHUTDOWN)
216
+ success = await self.submit(session_id, operation)
217
+
218
+ if success:
219
+ async with self._lock:
220
+ agent_session = self.sessions.get(session_id)
221
+ if agent_session and agent_session.task:
222
+ # Wait for task to complete
223
+ try:
224
+ await asyncio.wait_for(agent_session.task, timeout=5.0)
225
+ except asyncio.TimeoutError:
226
+ agent_session.task.cancel()
227
+
228
+ return success
229
+
230
+ async def delete_session(self, session_id: str) -> bool:
231
+ """Delete a session entirely."""
232
+ async with self._lock:
233
+ agent_session = self.sessions.pop(session_id, None)
234
+
235
+ if not agent_session:
236
+ return False
237
+
238
+ # Cancel the task if running
239
+ if agent_session.task and not agent_session.task.done():
240
+ agent_session.task.cancel()
241
+ try:
242
+ await agent_session.task
243
+ except asyncio.CancelledError:
244
+ pass
245
+
246
+ return True
247
+
248
+ def get_session_info(self, session_id: str) -> dict[str, Any] | None:
249
+ """Get information about a session."""
250
+ agent_session = self.sessions.get(session_id)
251
+ if not agent_session:
252
+ return None
253
+
254
+ return {
255
+ "session_id": session_id,
256
+ "created_at": agent_session.created_at.isoformat(),
257
+ "is_active": agent_session.is_active,
258
+ "message_count": len(agent_session.session.context_manager.items),
259
+ }
260
+
261
+ def list_sessions(self) -> list[dict[str, Any]]:
262
+ """List all sessions."""
263
+ return [
264
+ self.get_session_info(sid)
265
+ for sid in self.sessions
266
+ if self.get_session_info(sid)
267
+ ]
268
+
269
+ @property
270
+ def active_session_count(self) -> int:
271
+ """Get count of active sessions."""
272
+ return sum(1 for s in self.sessions.values() if s.is_active)
273
+
274
+
275
+ # Global session manager instance
276
+ session_manager = SessionManager()
backend/websocket.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ logger.info(f"Attempting to accept WebSocket for session {session_id}")
24
+ await websocket.accept()
25
+ self.active_connections[session_id] = websocket
26
+ self.message_queues[session_id] = asyncio.Queue()
27
+ logger.info(f"WebSocket connected and registered for session {session_id}")
28
+
29
+ def disconnect(self, session_id: str) -> None:
30
+ """Remove a WebSocket connection."""
31
+ if session_id in self.active_connections:
32
+ del self.active_connections[session_id]
33
+ if session_id in self.message_queues:
34
+ del self.message_queues[session_id]
35
+ logger.info(f"WebSocket disconnected for session {session_id}")
36
+
37
+ async def send_event(
38
+ self, session_id: str, event_type: str, data: dict[str, Any] | None = None
39
+ ) -> None:
40
+ """Send an event to a specific session's WebSocket."""
41
+ if session_id not in self.active_connections:
42
+ logger.warning(f"No active connection for session {session_id}")
43
+ return
44
+
45
+ message = {"event_type": event_type}
46
+ if data is not None:
47
+ message["data"] = data
48
+
49
+ try:
50
+ await self.active_connections[session_id].send_json(message)
51
+ except Exception as e:
52
+ logger.error(f"Error sending to session {session_id}: {e}")
53
+ self.disconnect(session_id)
54
+
55
+ async def broadcast(
56
+ self, event_type: str, data: dict[str, Any] | None = None
57
+ ) -> None:
58
+ """Broadcast an event to all connected sessions."""
59
+ for session_id in list(self.active_connections.keys()):
60
+ await self.send_event(session_id, event_type, data)
61
+
62
+ def is_connected(self, session_id: str) -> bool:
63
+ """Check if a session has an active WebSocket connection."""
64
+ return session_id in self.active_connections
65
+
66
+ def get_queue(self, session_id: str) -> asyncio.Queue | None:
67
+ """Get the message queue for a session."""
68
+ return self.message_queues.get(session_id)
69
+
70
+
71
+ # Global connection manager instance
72
+ manager = ConnectionManager()
configs/main_agent_config.json CHANGED
@@ -3,7 +3,7 @@
3
  "save_sessions": true,
4
  "session_dataset_repo": "akseljoonas/hf-agent-sessions",
5
  "yolo_mode": false,
6
- "confirm_cpu_jobs": false,
7
  "auto_file_upload": false,
8
  "mcpServers": {
9
  "hf-mcp-server": {
 
3
  "save_sessions": true,
4
  "session_dataset_repo": "akseljoonas/hf-agent-sessions",
5
  "yolo_mode": false,
6
+ "confirm_cpu_jobs": true,
7
  "auto_file_upload": false,
8
  "mcpServers": {
9
  "hf-mcp-server": {
eval/.amp_batch_solve.py.swp DELETED
Binary file (12.3 kB)
 
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/png" href="/hf-log-only-white.png" />
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 style="margin: 0; padding: 0; background-color: #0D1117; color: #E6EDF3;">
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "react-syntax-highlighter": "^16.1.0",
21
+ "remark-gfm": "^4.0.1",
22
+ "zustand": "^5.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@eslint/js": "^9.13.0",
26
+ "@types/react": "^18.3.12",
27
+ "@types/react-dom": "^18.3.1",
28
+ "@types/react-syntax-highlighter": "^15.5.13",
29
+ "@vitejs/plugin-react": "^4.3.3",
30
+ "eslint": "^9.13.0",
31
+ "eslint-plugin-react-hooks": "^5.0.0",
32
+ "eslint-plugin-react-refresh": "^0.4.13",
33
+ "globals": "^15.11.0",
34
+ "typescript": "~5.6.2",
35
+ "typescript-eslint": "^8.10.0",
36
+ "vite": "^5.4.10"
37
+ }
38
+ }
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/ApprovalFlow.tsx ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect } from 'react';
2
+ import { Box, Typography, Button, TextField, IconButton, Link } from '@mui/material';
3
+ import SendIcon from '@mui/icons-material/Send';
4
+ import OpenInNewIcon from '@mui/icons-material/OpenInNew';
5
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
6
+ import CancelIcon from '@mui/icons-material/Cancel';
7
+ import LaunchIcon from '@mui/icons-material/Launch';
8
+ import { useAgentStore } from '@/store/agentStore';
9
+ import { useLayoutStore } from '@/store/layoutStore';
10
+ import { useSessionStore } from '@/store/sessionStore';
11
+ import type { Message, ToolApproval } from '@/types/agent';
12
+
13
+ interface ApprovalFlowProps {
14
+ message: Message;
15
+ }
16
+
17
+ export default function ApprovalFlow({ message }: ApprovalFlowProps) {
18
+ const { setPanelContent, setPanelTab, setActivePanelTab, clearPanelTabs, updateMessage } = useAgentStore();
19
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
20
+ const { activeSessionId } = useSessionStore();
21
+ const [currentIndex, setCurrentIndex] = useState(0);
22
+ const [feedback, setFeedback] = useState('');
23
+ const [decisions, setDecisions] = useState<ToolApproval[]>([]);
24
+
25
+ const approvalData = message.approval;
26
+
27
+ if (!approvalData) return null;
28
+
29
+ const { batch, status } = approvalData;
30
+
31
+ // Parse toolOutput to extract job info (URL, status, logs)
32
+ let logsContent = '';
33
+ let showLogsButton = false;
34
+ let jobUrl = '';
35
+ let jobId = '';
36
+ let jobStatus = '';
37
+ let jobFailed = false;
38
+
39
+ if (message.toolOutput) {
40
+ // Extract job URL: **View at:** https://...
41
+ const urlMatch = message.toolOutput.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
42
+ if (urlMatch) {
43
+ jobUrl = urlMatch[1];
44
+ }
45
+
46
+ // Extract job ID: **Job ID:** ...
47
+ const idMatch = message.toolOutput.match(/\*\*Job ID:\*\*\s*([^\s\n]+)/);
48
+ if (idMatch) {
49
+ jobId = idMatch[1];
50
+ }
51
+
52
+ // Extract job status: **Final Status:** ...
53
+ const statusMatch = message.toolOutput.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
54
+ if (statusMatch) {
55
+ jobStatus = statusMatch[1].trim();
56
+ jobFailed = jobStatus.toLowerCase().includes('error') || jobStatus.toLowerCase().includes('failed');
57
+ }
58
+
59
+ // Extract logs
60
+ if (message.toolOutput.includes('**Logs:**')) {
61
+ const parts = message.toolOutput.split('**Logs:**');
62
+ if (parts.length > 1) {
63
+ const logsPart = parts[1].trim();
64
+ const codeBlockMatch = logsPart.match(/```([\s\S]*?)```/);
65
+ if (codeBlockMatch) {
66
+ logsContent = codeBlockMatch[1].trim();
67
+ showLogsButton = true;
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ // Sync right panel with current tool
74
+ useEffect(() => {
75
+ if (!batch || currentIndex >= batch.tools.length) return;
76
+
77
+ // Only auto-open panel if pending
78
+ if (status !== 'pending') return;
79
+
80
+ const tool = batch.tools[currentIndex];
81
+ const args = tool.arguments as any;
82
+
83
+ if (tool.tool === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
84
+ setPanelContent({
85
+ title: 'Compute Job Script',
86
+ content: args.script,
87
+ language: 'python',
88
+ parameters: args
89
+ });
90
+ // Don't auto-open if already resolved
91
+ } else if (tool.tool === 'hf_repo_files' && args.operation === 'upload' && args.content) {
92
+ setPanelContent({
93
+ title: `File Upload: ${args.path || 'unnamed'}`,
94
+ content: args.content,
95
+ parameters: args
96
+ });
97
+ }
98
+ }, [currentIndex, batch, status, setPanelContent]);
99
+
100
+ const handleResolve = useCallback(async (approved: boolean) => {
101
+ if (!batch || !activeSessionId) return;
102
+
103
+ const currentTool = batch.tools[currentIndex];
104
+ const newDecisions = [
105
+ ...decisions,
106
+ {
107
+ tool_call_id: currentTool.tool_call_id,
108
+ approved,
109
+ feedback: approved ? null : feedback || 'Rejected by user',
110
+ },
111
+ ];
112
+
113
+ if (currentIndex < batch.tools.length - 1) {
114
+ setDecisions(newDecisions);
115
+ setCurrentIndex(currentIndex + 1);
116
+ setFeedback('');
117
+ } else {
118
+ // All tools in batch resolved
119
+ try {
120
+ await fetch('/api/approve', {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({
124
+ session_id: activeSessionId,
125
+ approvals: newDecisions,
126
+ }),
127
+ });
128
+
129
+ // Update message status
130
+ updateMessage(activeSessionId, message.id, {
131
+ approval: {
132
+ ...approvalData!,
133
+ status: approved ? 'approved' : 'rejected',
134
+ decisions: newDecisions
135
+ }
136
+ });
137
+
138
+ } catch (e) {
139
+ console.error('Approval submission failed:', e);
140
+ }
141
+ }
142
+ }, [activeSessionId, message.id, batch, currentIndex, feedback, decisions, approvalData, updateMessage]);
143
+
144
+ if (!batch || currentIndex >= batch.tools.length) return null;
145
+
146
+ const currentTool = batch.tools[currentIndex];
147
+
148
+ // Check if script contains push_to_hub or upload_file
149
+ const args = currentTool.arguments as any;
150
+ const containsPushToHub = currentTool.tool === 'hf_jobs' && args.script && (args.script.includes('push_to_hub') || args.script.includes('upload_file'));
151
+
152
+ const getToolDescription = (toolName: string, args: any) => {
153
+ if (toolName === 'hf_jobs') {
154
+ return (
155
+ <Box sx={{ flex: 1 }}>
156
+ <Typography variant="body2" sx={{ color: 'var(--muted-text)' }}>
157
+ The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>hf_jobs</Box> on{' '}
158
+ <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.hardware_flavor || 'default'}</Box> with a timeout of{' '}
159
+ <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.timeout || '30m'}</Box>
160
+ </Typography>
161
+ </Box>
162
+ );
163
+ }
164
+ return (
165
+ <Typography variant="body2" sx={{ color: 'var(--muted-text)', flex: 1 }}>
166
+ The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{toolName}</Box>
167
+ </Typography>
168
+ );
169
+ };
170
+
171
+ const showCode = () => {
172
+ const args = currentTool.arguments as any;
173
+ if (currentTool.tool === 'hf_jobs' && args.script) {
174
+ // Clear existing tabs and set up script tab (and logs if available)
175
+ clearPanelTabs();
176
+ setPanelTab({
177
+ id: 'script',
178
+ title: 'Script',
179
+ content: args.script,
180
+ language: 'python',
181
+ parameters: args
182
+ });
183
+ // If logs are available (job completed), also add logs tab
184
+ if (logsContent) {
185
+ setPanelTab({
186
+ id: 'logs',
187
+ title: 'Logs',
188
+ content: logsContent,
189
+ language: 'text'
190
+ });
191
+ }
192
+ setActivePanelTab('script');
193
+ setRightPanelOpen(true);
194
+ setLeftSidebarOpen(false);
195
+ } else {
196
+ setPanelContent({
197
+ title: `Tool: ${currentTool.tool}`,
198
+ content: JSON.stringify(args, null, 2),
199
+ language: 'json',
200
+ parameters: args
201
+ });
202
+ setRightPanelOpen(true);
203
+ setLeftSidebarOpen(false);
204
+ }
205
+ };
206
+
207
+ const handleViewLogs = (e: React.MouseEvent) => {
208
+ e.stopPropagation();
209
+ const args = currentTool.arguments as any;
210
+ // Set up both tabs so user can switch between script and logs
211
+ clearPanelTabs();
212
+ if (currentTool.tool === 'hf_jobs' && args.script) {
213
+ setPanelTab({
214
+ id: 'script',
215
+ title: 'Script',
216
+ content: args.script,
217
+ language: 'python',
218
+ parameters: args
219
+ });
220
+ }
221
+ setPanelTab({
222
+ id: 'logs',
223
+ title: 'Logs',
224
+ content: logsContent,
225
+ language: 'text'
226
+ });
227
+ setActivePanelTab('logs');
228
+ setRightPanelOpen(true);
229
+ setLeftSidebarOpen(false);
230
+ };
231
+
232
+ return (
233
+ <Box
234
+ className="action-card"
235
+ sx={{
236
+ width: '100%',
237
+ padding: '18px',
238
+ borderRadius: 'var(--radius-md)',
239
+ background: 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
240
+ border: '1px solid rgba(255,255,255,0.03)',
241
+ display: 'flex',
242
+ flexDirection: 'column',
243
+ gap: '12px',
244
+ opacity: status !== 'pending' && !showLogsButton ? 0.8 : 1
245
+ }}
246
+ >
247
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
248
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'var(--text)' }}>
249
+ {status === 'pending' ? 'Approval Required' : status === 'approved' ? 'Approved' : 'Rejected'}
250
+ </Typography>
251
+ <Typography variant="caption" sx={{ color: 'var(--muted-text)' }}>
252
+ ({currentIndex + 1}/{batch.count})
253
+ </Typography>
254
+ {status === 'approved' && <CheckCircleIcon sx={{ fontSize: 18, color: 'var(--accent-green)' }} />}
255
+ {status === 'rejected' && <CancelIcon sx={{ fontSize: 18, color: 'var(--accent-red)' }} />}
256
+ </Box>
257
+
258
+ <Box
259
+ onClick={showCode}
260
+ sx={{
261
+ display: 'flex',
262
+ alignItems: 'center',
263
+ gap: 1,
264
+ cursor: 'pointer',
265
+ p: 1.5,
266
+ borderRadius: '8px',
267
+ bgcolor: 'rgba(0,0,0,0.2)',
268
+ border: '1px solid rgba(255,255,255,0.05)',
269
+ transition: 'all 0.2s',
270
+ '&:hover': {
271
+ bgcolor: 'rgba(255,255,255,0.03)',
272
+ borderColor: 'var(--accent-primary)',
273
+ }
274
+ }}
275
+ >
276
+ {getToolDescription(currentTool.tool, currentTool.arguments)}
277
+ <OpenInNewIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.7 }} />
278
+ </Box>
279
+
280
+ {currentTool.tool === 'hf_jobs' && (
281
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
282
+ {/* Show specific job URL if available (after execution), otherwise generic link */}
283
+ {jobUrl ? (
284
+ <Link
285
+ href={jobUrl}
286
+ target="_blank"
287
+ rel="noopener noreferrer"
288
+ sx={{
289
+ display: 'flex',
290
+ alignItems: 'center',
291
+ gap: 0.5,
292
+ color: 'var(--accent-primary)',
293
+ fontSize: '0.8rem',
294
+ textDecoration: 'none',
295
+ opacity: 0.9,
296
+ '&:hover': {
297
+ opacity: 1,
298
+ textDecoration: 'underline',
299
+ }
300
+ }}
301
+ >
302
+ <LaunchIcon sx={{ fontSize: 14 }} />
303
+ View job{jobId ? ` (${jobId.substring(0, 8)}...)` : ''} on Hugging Face
304
+ </Link>
305
+ ) : (
306
+ <Link
307
+ href="https://huggingface.co/settings/jobs"
308
+ target="_blank"
309
+ rel="noopener noreferrer"
310
+ sx={{
311
+ display: 'flex',
312
+ alignItems: 'center',
313
+ gap: 0.5,
314
+ color: 'var(--muted-text)',
315
+ fontSize: '0.8rem',
316
+ textDecoration: 'none',
317
+ opacity: 0.7,
318
+ '&:hover': {
319
+ opacity: 1,
320
+ textDecoration: 'underline',
321
+ }
322
+ }}
323
+ >
324
+ <LaunchIcon sx={{ fontSize: 14 }} />
325
+ View all jobs on Hugging Face
326
+ </Link>
327
+ )}
328
+
329
+ {/* Show job status if available */}
330
+ {jobStatus && (
331
+ <Typography
332
+ variant="caption"
333
+ sx={{
334
+ color: jobFailed ? 'var(--accent-red)' : 'var(--accent-green)',
335
+ fontSize: '0.75rem',
336
+ fontWeight: 500,
337
+ }}
338
+ >
339
+ Status: {jobStatus}
340
+ </Typography>
341
+ )}
342
+ </Box>
343
+ )}
344
+
345
+ {containsPushToHub && (
346
+ <Typography variant="caption" sx={{ color: 'var(--accent-green)', fontSize: '0.75rem', opacity: 0.8, px: 0.5 }}>
347
+ We've detected the result will be pushed to hub.
348
+ </Typography>
349
+ )}
350
+
351
+ {/* Show script/logs buttons for completed jobs */}
352
+ {status !== 'pending' && currentTool.tool === 'hf_jobs' && (args.script || showLogsButton) && (
353
+ <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
354
+ {args.script && (
355
+ <Button
356
+ variant="outlined"
357
+ size="small"
358
+ startIcon={<OpenInNewIcon />}
359
+ onClick={showCode}
360
+ sx={{
361
+ textTransform: 'none',
362
+ borderColor: 'rgba(255,255,255,0.1)',
363
+ color: 'var(--muted-text)',
364
+ fontSize: '0.75rem',
365
+ py: 0.5,
366
+ '&:hover': {
367
+ borderColor: 'var(--accent-primary)',
368
+ color: 'var(--accent-primary)',
369
+ bgcolor: 'rgba(255,255,255,0.03)'
370
+ }
371
+ }}
372
+ >
373
+ View Script
374
+ </Button>
375
+ )}
376
+ {showLogsButton && (
377
+ <Button
378
+ variant="outlined"
379
+ size="small"
380
+ startIcon={<OpenInNewIcon />}
381
+ onClick={handleViewLogs}
382
+ sx={{
383
+ textTransform: 'none',
384
+ borderColor: 'rgba(255,255,255,0.1)',
385
+ color: 'var(--accent-primary)',
386
+ fontSize: '0.75rem',
387
+ py: 0.5,
388
+ '&:hover': {
389
+ borderColor: 'var(--accent-primary)',
390
+ bgcolor: 'rgba(255,255,255,0.03)'
391
+ }
392
+ }}
393
+ >
394
+ View Logs
395
+ </Button>
396
+ )}
397
+ </Box>
398
+ )}
399
+
400
+ {status === 'pending' && (
401
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
402
+ <Box sx={{ display: 'flex', gap: 1 }}>
403
+ <TextField
404
+ fullWidth
405
+ size="small"
406
+ placeholder="Feedback (optional)"
407
+ value={feedback}
408
+ onChange={(e) => setFeedback(e.target.value)}
409
+ variant="outlined"
410
+ sx={{
411
+ '& .MuiOutlinedInput-root': {
412
+ bgcolor: 'rgba(0,0,0,0.2)',
413
+ fontFamily: 'inherit',
414
+ fontSize: '0.9rem'
415
+ }
416
+ }}
417
+ />
418
+ <IconButton
419
+ onClick={() => handleResolve(false)}
420
+ disabled={!feedback}
421
+ title="Reject with feedback"
422
+ sx={{
423
+ color: 'var(--accent-red)',
424
+ border: '1px solid rgba(255,255,255,0.05)',
425
+ borderRadius: '8px',
426
+ width: 40,
427
+ height: 40,
428
+ '&:hover': {
429
+ bgcolor: 'rgba(224, 90, 79, 0.1)',
430
+ borderColor: 'var(--accent-red)',
431
+ },
432
+ '&.Mui-disabled': {
433
+ color: 'rgba(255,255,255,0.1)',
434
+ borderColor: 'rgba(255,255,255,0.02)'
435
+ }
436
+ }}
437
+ >
438
+ <SendIcon fontSize="small" />
439
+ </IconButton>
440
+ </Box>
441
+
442
+ <Box className="action-buttons" sx={{ display: 'flex', gap: '10px' }}>
443
+ <Button
444
+ className="btn-reject"
445
+ onClick={() => handleResolve(false)}
446
+ sx={{
447
+ flex: 1,
448
+ background: 'transparent',
449
+ border: '1px solid rgba(255,255,255,0.05)',
450
+ color: 'var(--accent-red)',
451
+ padding: '10px 14px',
452
+ borderRadius: '10px',
453
+ '&:hover': {
454
+ bgcolor: 'rgba(224, 90, 79, 0.05)',
455
+ borderColor: 'var(--accent-red)',
456
+ }
457
+ }}
458
+ >
459
+ Reject
460
+ </Button>
461
+ <Button
462
+ className="btn-approve"
463
+ onClick={() => handleResolve(true)}
464
+ sx={{
465
+ flex: 1,
466
+ background: 'transparent',
467
+ border: '1px solid rgba(255,255,255,0.05)',
468
+ color: 'var(--accent-green)',
469
+ padding: '10px 14px',
470
+ borderRadius: '10px',
471
+ '&:hover': {
472
+ bgcolor: 'rgba(47, 204, 113, 0.05)',
473
+ borderColor: 'var(--accent-green)',
474
+ }
475
+ }}
476
+ >
477
+ Approve
478
+ </Button>
479
+ </Box>
480
+ </Box>
481
+ )}
482
+
483
+ {status === 'rejected' && decisions.some(d => d.feedback) && (
484
+ <Typography variant="body2" sx={{ color: 'var(--accent-red)', mt: 1 }}>
485
+ Feedback: {decisions.find(d => d.feedback)?.feedback}
486
+ </Typography>
487
+ )}
488
+ </Box>
489
+ );
490
+ }
frontend/src/components/Chat/ChatInput.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, KeyboardEvent } from 'react';
2
+ import { Box, TextField, IconButton, CircularProgress, Typography } from '@mui/material';
3
+ import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
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
+ pb: 4,
34
+ pt: 2,
35
+ position: 'relative',
36
+ zIndex: 10,
37
+ }}
38
+ >
39
+ <Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: 2 }}>
40
+ <Box
41
+ className="composer"
42
+ sx={{
43
+ display: 'flex',
44
+ gap: '10px',
45
+ alignItems: 'flex-start',
46
+ bgcolor: 'rgba(255,255,255,0.01)',
47
+ borderRadius: 'var(--radius-md)',
48
+ p: '12px',
49
+ border: '1px solid rgba(255,255,255,0.03)',
50
+ transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
51
+ '&:focus-within': {
52
+ borderColor: 'var(--accent-yellow)',
53
+ boxShadow: 'var(--focus)',
54
+ }
55
+ }}
56
+ >
57
+ <TextField
58
+ fullWidth
59
+ multiline
60
+ maxRows={6}
61
+ value={input}
62
+ onChange={(e) => setInput(e.target.value)}
63
+ onKeyDown={handleKeyDown}
64
+ placeholder="Ask anything..."
65
+ disabled={disabled}
66
+ variant="standard"
67
+ InputProps={{
68
+ disableUnderline: true,
69
+ sx: {
70
+ color: 'var(--text)',
71
+ fontSize: '15px',
72
+ fontFamily: 'inherit',
73
+ padding: 0,
74
+ lineHeight: 1.5,
75
+ minHeight: '56px',
76
+ alignItems: 'flex-start',
77
+ }
78
+ }}
79
+ sx={{
80
+ flex: 1,
81
+ '& .MuiInputBase-root': {
82
+ p: 0,
83
+ backgroundColor: 'transparent',
84
+ },
85
+ '& textarea': {
86
+ resize: 'none',
87
+ padding: '0 !important',
88
+ }
89
+ }}
90
+ />
91
+ <IconButton
92
+ onClick={handleSend}
93
+ disabled={disabled || !input.trim()}
94
+ sx={{
95
+ mt: 1,
96
+ p: 1,
97
+ borderRadius: '10px',
98
+ color: 'var(--muted-text)',
99
+ transition: 'all 0.2s',
100
+ '&:hover': {
101
+ color: 'var(--accent-yellow)',
102
+ bgcolor: 'rgba(255,255,255,0.05)',
103
+ },
104
+ '&.Mui-disabled': {
105
+ opacity: 0.3,
106
+ },
107
+ }}
108
+ >
109
+ {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
110
+ </IconButton>
111
+ </Box>
112
+
113
+ {/* Powered By Badge */}
114
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mt: 1.5, gap: 0.8, opacity: 0.5 }}>
115
+ <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
116
+ powered by
117
+ </Typography>
118
+ <img src="/claude-logo.png" alt="Claude" style={{ height: '12px', objectFit: 'contain' }} />
119
+ <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
120
+ claude-opus-4-5-20251101
121
+ </Typography>
122
+ </Box>
123
+ </Box>
124
+ </Box>
125
+ );
126
+ }
frontend/src/components/Chat/MessageBubble.tsx ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Box, Paper, Typography, Chip } from '@mui/material';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import ApprovalFlow from './ApprovalFlow';
5
+ import type { Message } from '@/types/agent';
6
+
7
+ interface MessageBubbleProps {
8
+ message: Message;
9
+ }
10
+
11
+ export default function MessageBubble({ message }: MessageBubbleProps) {
12
+ const isUser = message.role === 'user';
13
+ const isTool = message.role === 'tool';
14
+ const isAssistant = message.role === 'assistant';
15
+
16
+ if (message.approval) {
17
+ return (
18
+ <Box sx={{ width: '100%', maxWidth: '880px', mx: 'auto', my: 2 }}>
19
+ <ApprovalFlow message={message} />
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ return (
25
+ <Box
26
+ sx={{
27
+ display: 'flex',
28
+ justifyContent: isUser ? 'flex-end' : 'flex-start',
29
+ width: '100%',
30
+ maxWidth: '880px',
31
+ mx: 'auto',
32
+ }}
33
+ >
34
+ <Paper
35
+ elevation={0}
36
+ className={`message ${isUser ? 'user' : isAssistant ? 'assistant' : ''}`}
37
+ sx={{
38
+ p: '14px 18px',
39
+ margin: '10px 0',
40
+ width: isTool ? '100%' : 'auto',
41
+ maxWidth: '100%',
42
+ borderRadius: 'var(--radius-lg)',
43
+ borderTopLeftRadius: isAssistant ? '6px' : undefined,
44
+ lineHeight: 1.45,
45
+ boxShadow: 'var(--shadow-1)',
46
+ border: '1px solid rgba(255,255,255,0.03)',
47
+ background: 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
48
+ }}
49
+ >
50
+ {isTool && (
51
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
52
+ <Typography variant="caption" color="text.secondary">
53
+ Tool
54
+ </Typography>
55
+ {message.toolName && (
56
+ <Chip
57
+ label={message.toolName}
58
+ size="small"
59
+ variant="outlined"
60
+ sx={{ ml: 1, height: 20, fontSize: '0.7rem' }}
61
+ />
62
+ )}
63
+ </Box>
64
+ )}
65
+
66
+ <Box
67
+ sx={{
68
+ '& p': { m: 0, color: isUser ? 'var(--text)' : 'var(--text)' }, // User might want different text color? Defaults to --text
69
+ '& pre': {
70
+ bgcolor: 'rgba(0,0,0,0.5)',
71
+ p: 1.5,
72
+ borderRadius: 1,
73
+ overflow: 'auto',
74
+ fontSize: '0.85rem',
75
+ border: '1px solid rgba(255,255,255,0.05)',
76
+ },
77
+ '& code': {
78
+ bgcolor: 'rgba(255,255,255,0.05)',
79
+ px: 0.5,
80
+ py: 0.25,
81
+ borderRadius: 0.5,
82
+ fontSize: '0.85rem',
83
+ fontFamily: '"JetBrains Mono", monospace',
84
+ },
85
+ '& pre code': {
86
+ bgcolor: 'transparent',
87
+ p: 0,
88
+ },
89
+ '& a': {
90
+ color: 'var(--accent-yellow)',
91
+ textDecoration: 'none',
92
+ '&:hover': {
93
+ textDecoration: 'underline',
94
+ },
95
+ },
96
+ '& ul, & ol': {
97
+ pl: 2,
98
+ my: 1,
99
+ },
100
+ '& table': {
101
+ borderCollapse: 'collapse',
102
+ width: '100%',
103
+ my: 2,
104
+ fontSize: '0.875rem',
105
+ },
106
+ '& th': {
107
+ borderBottom: '1px solid',
108
+ borderColor: 'rgba(255,255,255,0.1)',
109
+ textAlign: 'left',
110
+ p: 1,
111
+ bgcolor: 'rgba(255,255,255,0.02)',
112
+ },
113
+ '& td': {
114
+ borderBottom: '1px solid',
115
+ borderColor: 'rgba(255,255,255,0.05)',
116
+ p: 1,
117
+ },
118
+ }}
119
+ >
120
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
121
+ </Box>
122
+
123
+ {/* Persisted Trace Logs - Now at the bottom */}
124
+ {message.trace && message.trace.length > 0 && (
125
+ <Box
126
+ sx={{
127
+ bgcolor: 'rgba(0,0,0,0.3)',
128
+ borderRadius: 1,
129
+ p: 1.5,
130
+ border: 1,
131
+ borderColor: 'rgba(255,255,255,0.05)',
132
+ width: '100%',
133
+ mt: 2,
134
+ }}
135
+ >
136
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
137
+ {message.trace.map((log) => {
138
+ // Extract tool name from text "Agent is executing {toolName}..."
139
+ const match = log.text.match(/Agent is executing (.+)\.\.\./);
140
+ const toolName = match ? match[1] : log.tool;
141
+
142
+ return (
143
+ <Typography
144
+ key={log.id}
145
+ variant="caption"
146
+ component="div"
147
+ sx={{
148
+ color: 'var(--muted-text)',
149
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
150
+ fontSize: '0.75rem',
151
+ display: 'flex',
152
+ alignItems: 'center',
153
+ gap: 0.5,
154
+ }}
155
+ >
156
+ <span style={{ color: log.completed ? '#FDB022' : 'inherit' }}>*</span>
157
+ <span>Agent is executing </span>
158
+ <span style={{
159
+ fontWeight: 600,
160
+ color: 'rgba(255, 255, 255, 0.9)',
161
+ }}>
162
+ {toolName}
163
+ </span>
164
+ <span>...</span>
165
+ </Typography>
166
+ );
167
+ })}
168
+ </Box>
169
+ </Box>
170
+ )}
171
+
172
+ <Typography className="meta" variant="caption" sx={{ display: 'block', textAlign: 'right', mt: 1, fontSize: '11px', opacity: 0.5 }}>
173
+ {new Date(message.timestamp).toLocaleTimeString()}
174
+ </Typography>
175
+ </Paper>
176
+ </Box>
177
+ );
178
+ }
frontend/src/components/Chat/MessageList.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+ import { Box, Typography } from '@mui/material';
3
+ import { useSessionStore } from '@/store/sessionStore';
4
+ import MessageBubble from './MessageBubble';
5
+ import type { Message } from '@/types/agent';
6
+
7
+ interface MessageListProps {
8
+ messages: Message[];
9
+ isProcessing: boolean;
10
+ }
11
+
12
+ const TechnicalIndicator = () => (
13
+ <Box
14
+ component="span"
15
+ sx={{
16
+ color: 'primary.main',
17
+ fontFamily: 'monospace',
18
+ fontWeight: 'bold',
19
+ fontSize: '1.2rem',
20
+ lineHeight: 0,
21
+ display: 'inline-block',
22
+ verticalAlign: 'middle',
23
+ width: '1em',
24
+ letterSpacing: '-3px',
25
+ transform: 'scale(0.6) translateY(-2px)',
26
+ '&::after': {
27
+ content: '""',
28
+ animation: 'dots 2s steps(4, end) infinite',
29
+ },
30
+ '@keyframes dots': {
31
+ '0%': { content: '""' },
32
+ '25%': { content: '"."' },
33
+ '50%': { content: '".."' },
34
+ '75%, 100%': { content: '"..."' },
35
+ },
36
+ }}
37
+ />
38
+ );
39
+
40
+ export default function MessageList({ messages, isProcessing }: MessageListProps) {
41
+ const bottomRef = useRef<HTMLDivElement>(null);
42
+ const { activeSessionId } = useSessionStore();
43
+
44
+ // Auto-scroll to bottom when new messages arrive
45
+ useEffect(() => {
46
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
47
+ }, [messages, isProcessing]);
48
+
49
+ return (
50
+ <Box
51
+ sx={{
52
+ flex: 1,
53
+ overflow: 'auto',
54
+ p: 2,
55
+ display: 'flex',
56
+ flexDirection: 'column',
57
+ }}
58
+ >
59
+ <Box sx={{ maxWidth: 'md', mx: 'auto', width: '100%', display: 'flex', flexDirection: 'column', gap: 2 }}>
60
+ {messages.length === 0 && !isProcessing ? (
61
+ <Box
62
+ sx={{
63
+ flex: 1,
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ justifyContent: 'center',
67
+ py: 8,
68
+ }}
69
+ >
70
+ <Typography color="text.secondary" sx={{ fontFamily: 'monospace' }}>
71
+ Awaiting input…
72
+ </Typography>
73
+ </Box>
74
+ ) : (
75
+ messages.map((message) => (
76
+ <MessageBubble key={message.id} message={message} />
77
+ ))
78
+ )}
79
+
80
+ {isProcessing && (
81
+ <Box sx={{ width: '100%', mb: 2 }}>
82
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, px: 0.5 }}>
83
+ <Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
84
+ Thinking
85
+ </Typography>
86
+ <TechnicalIndicator />
87
+ </Box>
88
+ </Box>
89
+ )}
90
+
91
+ {activeSessionId && (
92
+ // ApprovalFlow is now handled within messages
93
+ null
94
+ )}
95
+
96
+ <div ref={bottomRef} />
97
+ </Box>
98
+ </Box>
99
+ );
100
+ }
frontend/src/components/CodePanel/CodePanel.tsx ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect, useMemo } from 'react';
2
+ import { Box, Typography, IconButton, Tabs, Tab } from '@mui/material';
3
+ import CloseIcon from '@mui/icons-material/Close';
4
+ import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
5
+ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
6
+ import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
7
+ import CodeIcon from '@mui/icons-material/Code';
8
+ import TerminalIcon from '@mui/icons-material/Terminal';
9
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
10
+ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
11
+ import { useAgentStore } from '@/store/agentStore';
12
+ import { useLayoutStore } from '@/store/layoutStore';
13
+ import { processLogs } from '@/utils/logProcessor';
14
+
15
+ export default function CodePanel() {
16
+ const { panelContent, panelTabs, activePanelTab, setActivePanelTab, plan } = useAgentStore();
17
+ const { setRightPanelOpen } = useLayoutStore();
18
+ const scrollRef = useRef<HTMLDivElement>(null);
19
+
20
+ // Get the active tab content, or fall back to panelContent for backwards compatibility
21
+ const activeTab = panelTabs.find(t => t.id === activePanelTab);
22
+ const currentContent = activeTab || panelContent;
23
+
24
+ const displayContent = useMemo(() => {
25
+ if (!currentContent?.content) return '';
26
+ // Apply log processing only for text/logs, not for code/json
27
+ if (!currentContent.language || currentContent.language === 'text') {
28
+ return processLogs(currentContent.content);
29
+ }
30
+ return currentContent.content;
31
+ }, [currentContent?.content, currentContent?.language]);
32
+
33
+ useEffect(() => {
34
+ // Auto-scroll only for logs tab
35
+ if (scrollRef.current && activePanelTab === 'logs') {
36
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
37
+ }
38
+ }, [displayContent, activePanelTab]);
39
+
40
+ const hasTabs = panelTabs.length > 0;
41
+
42
+ return (
43
+ <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
44
+ {/* Header - Fixed 60px to align */}
45
+ <Box sx={{
46
+ height: '60px',
47
+ display: 'flex',
48
+ alignItems: 'center',
49
+ justifyContent: 'space-between',
50
+ px: 2,
51
+ borderBottom: '1px solid rgba(255,255,255,0.03)'
52
+ }}>
53
+ {hasTabs ? (
54
+ <Tabs
55
+ value={activePanelTab || panelTabs[0]?.id}
56
+ onChange={(_, newValue) => setActivePanelTab(newValue)}
57
+ sx={{
58
+ minHeight: 36,
59
+ '& .MuiTabs-indicator': {
60
+ backgroundColor: 'var(--accent-primary)',
61
+ },
62
+ '& .MuiTab-root': {
63
+ minHeight: 36,
64
+ minWidth: 'auto',
65
+ px: 2,
66
+ py: 0.5,
67
+ fontSize: '0.75rem',
68
+ fontWeight: 600,
69
+ textTransform: 'uppercase',
70
+ letterSpacing: '0.05em',
71
+ color: 'var(--muted-text)',
72
+ '&.Mui-selected': {
73
+ color: 'var(--text)',
74
+ },
75
+ },
76
+ }}
77
+ >
78
+ {panelTabs.map((tab) => (
79
+ <Tab
80
+ key={tab.id}
81
+ value={tab.id}
82
+ label={tab.title}
83
+ icon={tab.id === 'script' ? <CodeIcon sx={{ fontSize: 16 }} /> : <TerminalIcon sx={{ fontSize: 16 }} />}
84
+ iconPosition="start"
85
+ />
86
+ ))}
87
+ </Tabs>
88
+ ) : (
89
+ <Typography variant="caption" sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
90
+ {currentContent?.title || 'Code Panel'}
91
+ </Typography>
92
+ )}
93
+ <IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
94
+ <CloseIcon fontSize="small" />
95
+ </IconButton>
96
+ </Box>
97
+
98
+ {/* Main Content Area */}
99
+ <Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
100
+ {!currentContent ? (
101
+ <Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
102
+ <Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
103
+ NO DATA LOADED
104
+ </Typography>
105
+ </Box>
106
+ ) : (
107
+ <Box sx={{ flex: 1, overflow: 'hidden', p: 2 }}>
108
+ <Box
109
+ ref={scrollRef}
110
+ className="code-panel"
111
+ sx={{
112
+ background: '#0A0B0C',
113
+ borderRadius: 'var(--radius-md)',
114
+ padding: '18px',
115
+ border: '1px solid rgba(255,255,255,0.03)',
116
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
117
+ fontSize: '13px',
118
+ lineHeight: 1.55,
119
+ height: '100%',
120
+ overflow: 'auto',
121
+ }}
122
+ >
123
+ {currentContent.content ? (
124
+ currentContent.language === 'python' ? (
125
+ <SyntaxHighlighter
126
+ language="python"
127
+ style={vscDarkPlus}
128
+ customStyle={{
129
+ margin: 0,
130
+ padding: 0,
131
+ background: 'transparent',
132
+ fontSize: '13px',
133
+ fontFamily: 'inherit',
134
+ }}
135
+ wrapLines={true}
136
+ wrapLongLines={true}
137
+ >
138
+ {displayContent}
139
+ </SyntaxHighlighter>
140
+ ) : (
141
+ <Box component="pre" sx={{
142
+ m: 0,
143
+ fontFamily: 'inherit',
144
+ color: 'var(--text)',
145
+ whiteSpace: 'pre-wrap',
146
+ wordBreak: 'break-all'
147
+ }}>
148
+ <code>{displayContent}</code>
149
+ </Box>
150
+ )
151
+ ) : (
152
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
153
+ <Typography variant="caption">
154
+ NO CONTENT TO DISPLAY
155
+ </Typography>
156
+ </Box>
157
+ )}
158
+ </Box>
159
+ </Box>
160
+ )}
161
+ </Box>
162
+
163
+ {/* Plan Display at Bottom */}
164
+ {plan && plan.length > 0 && (
165
+ <Box sx={{
166
+ borderTop: '1px solid rgba(255,255,255,0.03)',
167
+ bgcolor: 'rgba(0,0,0,0.2)',
168
+ maxHeight: '30%',
169
+ display: 'flex',
170
+ flexDirection: 'column'
171
+ }}>
172
+ <Box sx={{ p: 1.5, borderBottom: '1px solid rgba(255,255,255,0.03)', display: 'flex', alignItems: 'center', gap: 1 }}>
173
+ <Typography variant="caption" sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
174
+ CURRENT PLAN
175
+ </Typography>
176
+ </Box>
177
+ <Box sx={{ p: 2, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 1 }}>
178
+ {plan.map((item) => (
179
+ <Box key={item.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
180
+ <Box sx={{ mt: 0.2 }}>
181
+ {item.status === 'completed' && <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />}
182
+ {item.status === 'in_progress' && <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />}
183
+ {item.status === 'pending' && <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />}
184
+ </Box>
185
+ <Typography
186
+ variant="body2"
187
+ sx={{
188
+ fontSize: '13px',
189
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
190
+ color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
191
+ textDecoration: item.status === 'completed' ? 'line-through' : 'none',
192
+ opacity: item.status === 'pending' ? 0.7 : 1
193
+ }}
194
+ >
195
+ {item.content}
196
+ </Typography>
197
+ </Box>
198
+ ))}
199
+ </Box>
200
+ </Box>
201
+ )}
202
+ </Box>
203
+ );
204
+ }
frontend/src/components/Layout/AppLayout.tsx ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useRef, useEffect } from 'react';
2
+ import {
3
+ Box,
4
+ Drawer,
5
+ Typography,
6
+ IconButton,
7
+ } from '@mui/material';
8
+ import MenuIcon from '@mui/icons-material/Menu';
9
+ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
10
+ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
11
+
12
+ import { useSessionStore } from '@/store/sessionStore';
13
+ import { useAgentStore } from '@/store/agentStore';
14
+ import { useLayoutStore } from '@/store/layoutStore';
15
+ import { useAgentWebSocket } from '@/hooks/useAgentWebSocket';
16
+ import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
17
+ import CodePanel from '@/components/CodePanel/CodePanel';
18
+ import ChatInput from '@/components/Chat/ChatInput';
19
+ import MessageList from '@/components/Chat/MessageList';
20
+ import type { Message } from '@/types/agent';
21
+
22
+ const DRAWER_WIDTH = 260;
23
+
24
+ export default function AppLayout() {
25
+ const { activeSessionId } = useSessionStore();
26
+ const { isConnected, isProcessing, getMessages, addMessage } = useAgentStore();
27
+ const {
28
+ isLeftSidebarOpen,
29
+ isRightPanelOpen,
30
+ rightPanelWidth,
31
+ setRightPanelWidth,
32
+ toggleLeftSidebar,
33
+ toggleRightPanel
34
+ } = useLayoutStore();
35
+
36
+ const isResizing = useRef(false);
37
+
38
+ const startResizing = useCallback((e: React.MouseEvent) => {
39
+ e.preventDefault();
40
+ isResizing.current = true;
41
+ document.addEventListener('mousemove', handleMouseMove);
42
+ document.addEventListener('mouseup', stopResizing);
43
+ document.body.style.cursor = 'col-resize';
44
+ }, []);
45
+
46
+ const stopResizing = useCallback(() => {
47
+ isResizing.current = false;
48
+ document.removeEventListener('mousemove', handleMouseMove);
49
+ document.removeEventListener('mouseup', stopResizing);
50
+ document.body.style.cursor = 'default';
51
+ }, []);
52
+
53
+ const handleMouseMove = useCallback((e: MouseEvent) => {
54
+ if (!isResizing.current) return;
55
+ const newWidth = window.innerWidth - e.clientX;
56
+ const maxWidth = window.innerWidth * 0.8;
57
+ const minWidth = 300;
58
+ if (newWidth > minWidth && newWidth < maxWidth) {
59
+ setRightPanelWidth(newWidth);
60
+ }
61
+ }, [setRightPanelWidth]);
62
+
63
+ useEffect(() => {
64
+ return () => {
65
+ document.removeEventListener('mousemove', handleMouseMove);
66
+ document.removeEventListener('mouseup', stopResizing);
67
+ };
68
+ }, [handleMouseMove, stopResizing]);
69
+
70
+ const messages = activeSessionId ? getMessages(activeSessionId) : [];
71
+
72
+ useAgentWebSocket({
73
+ sessionId: activeSessionId,
74
+ onReady: () => console.log('Agent ready'),
75
+ onError: (error) => console.error('Agent error:', error),
76
+ });
77
+
78
+ const handleSendMessage = useCallback(
79
+ async (text: string) => {
80
+ if (!activeSessionId || !text.trim()) return;
81
+
82
+ const userMsg: Message = {
83
+ id: `user_${Date.now()}`,
84
+ role: 'user',
85
+ content: text.trim(),
86
+ timestamp: new Date().toISOString(),
87
+ };
88
+ addMessage(activeSessionId, userMsg);
89
+
90
+ try {
91
+ await fetch('/api/submit', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({
95
+ session_id: activeSessionId,
96
+ text: text.trim(),
97
+ }),
98
+ });
99
+ } catch (e) {
100
+ console.error('Send failed:', e);
101
+ }
102
+ },
103
+ [activeSessionId, addMessage]
104
+ );
105
+
106
+ return (
107
+ <Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
108
+ {/* Left Sidebar Drawer */}
109
+ <Box
110
+ component="nav"
111
+ sx={{
112
+ width: { md: isLeftSidebarOpen ? DRAWER_WIDTH : 0 },
113
+ flexShrink: { md: 0 },
114
+ transition: isResizing.current ? 'none' : 'width 0.2s',
115
+ overflow: 'hidden',
116
+ }}
117
+ >
118
+ <Drawer
119
+ variant="persistent"
120
+ sx={{
121
+ display: { xs: 'none', md: 'block' },
122
+ '& .MuiDrawer-paper': {
123
+ boxSizing: 'border-box',
124
+ width: DRAWER_WIDTH,
125
+ borderRight: '1px solid',
126
+ borderColor: 'divider',
127
+ top: 0,
128
+ height: '100%',
129
+ bgcolor: 'var(--panel)', // Ensure correct background matches sidebar
130
+ },
131
+ }}
132
+ open={isLeftSidebarOpen}
133
+ >
134
+ <SessionSidebar />
135
+ </Drawer>
136
+ </Box>
137
+
138
+ {/* Main Content Area */}
139
+ <Box
140
+ sx={{
141
+ flexGrow: 1,
142
+ height: '100%',
143
+ display: 'flex',
144
+ flexDirection: 'column',
145
+ transition: isResizing.current ? 'none' : 'width 0.2s',
146
+ position: 'relative',
147
+ overflow: 'hidden',
148
+ }}
149
+ >
150
+ {/* Top Header Bar (Fixed) */}
151
+ <Box sx={{
152
+ height: '60px',
153
+ px: 1,
154
+ display: 'flex',
155
+ alignItems: 'center',
156
+ borderBottom: 1,
157
+ borderColor: 'divider',
158
+ bgcolor: 'background.default',
159
+ zIndex: 1200,
160
+ }}>
161
+ <IconButton onClick={toggleLeftSidebar} size="small">
162
+ {isLeftSidebarOpen ? <ChevronLeftIcon /> : <MenuIcon />}
163
+ </IconButton>
164
+
165
+ <Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
166
+ <img
167
+ src="/hf-logo-white.png"
168
+ alt="Hugging Face"
169
+ style={{ height: '40px', objectFit: 'contain' }}
170
+ />
171
+ </Box>
172
+
173
+ <IconButton
174
+ onClick={toggleRightPanel}
175
+ size="small"
176
+ sx={{ visibility: isRightPanelOpen ? 'hidden' : 'visible' }}
177
+ >
178
+ <MenuIcon />
179
+ </IconButton>
180
+ </Box>
181
+
182
+ <Box
183
+ component="main"
184
+ className="chat-pane"
185
+ sx={{
186
+ flexGrow: 1,
187
+ display: 'flex',
188
+ flexDirection: 'column',
189
+ overflow: 'hidden',
190
+ background: 'linear-gradient(180deg, var(--bg), var(--panel))',
191
+ padding: '24px',
192
+ }}
193
+ >
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" sx={{ fontFamily: 'monospace' }}>
214
+ NO SESSION SELECTED
215
+ </Typography>
216
+ <Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
217
+ Initialize a session via the sidebar
218
+ </Typography>
219
+ </Box>
220
+ )}
221
+ </Box>
222
+ </Box>
223
+
224
+ {/* Resize Handle */}
225
+ {isRightPanelOpen && (
226
+ <Box
227
+ onMouseDown={startResizing}
228
+ sx={{
229
+ width: '4px',
230
+ cursor: 'col-resize',
231
+ bgcolor: 'divider',
232
+ display: 'flex',
233
+ alignItems: 'center',
234
+ justifyContent: 'center',
235
+ transition: 'background-color 0.2s',
236
+ zIndex: 1300,
237
+ overflow: 'hidden',
238
+ '&:hover': {
239
+ bgcolor: 'primary.main',
240
+ },
241
+ }}
242
+ >
243
+ <DragIndicatorIcon
244
+ sx={{
245
+ fontSize: '0.8rem',
246
+ color: 'text.secondary',
247
+ pointerEvents: 'none',
248
+ }}
249
+ />
250
+ </Box>
251
+ )}
252
+
253
+ {/* Right Panel Drawer */}
254
+ <Box
255
+ component="nav"
256
+ sx={{
257
+ width: { md: isRightPanelOpen ? rightPanelWidth : 0 },
258
+ flexShrink: { md: 0 },
259
+ transition: isResizing.current ? 'none' : 'width 0.2s',
260
+ overflow: 'hidden',
261
+ }}
262
+ >
263
+ <Drawer
264
+ anchor="right"
265
+ variant="persistent"
266
+ sx={{
267
+ display: { xs: 'none', md: 'block' },
268
+ '& .MuiDrawer-paper': {
269
+ boxSizing: 'border-box',
270
+ width: rightPanelWidth,
271
+ borderLeft: 'none',
272
+ top: 0,
273
+ height: '100%',
274
+ bgcolor: 'var(--panel)',
275
+ },
276
+ }}
277
+ open={isRightPanelOpen}
278
+ >
279
+ <CodePanel />
280
+ </Drawer>
281
+ </Box>
282
+ </Box>
283
+ );
284
+ }
frontend/src/components/SessionSidebar/SessionSidebar.tsx ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ List,
5
+ ListItem,
6
+ IconButton,
7
+ Typography,
8
+ Button,
9
+ Tooltip,
10
+ } from '@mui/material';
11
+ import DeleteIcon from '@mui/icons-material/Delete';
12
+ import UndoIcon from '@mui/icons-material/Undo';
13
+ import { useSessionStore } from '@/store/sessionStore';
14
+ import { useAgentStore } from '@/store/agentStore';
15
+
16
+ interface SessionSidebarProps {
17
+ onClose?: () => void;
18
+ }
19
+
20
+ const StatusDiode = ({ connected }: { connected: boolean }) => (
21
+ <Box
22
+ sx={{
23
+ width: 10,
24
+ height: 10,
25
+ borderRadius: '50%',
26
+ bgcolor: connected ? 'var(--accent-green)' : 'var(--accent-red)', // Use green/red for connection status
27
+ boxShadow: connected ? '0 0 6px rgba(47, 204, 113, 0.4)' : 'none',
28
+ transition: 'all 0.3s ease',
29
+ }}
30
+ />
31
+ );
32
+
33
+ const RunningIndicator = () => (
34
+ <Box
35
+ className="running-indicator"
36
+ sx={{
37
+ width: 10,
38
+ height: 10,
39
+ borderRadius: '50%',
40
+ bgcolor: 'var(--accent-yellow)',
41
+ boxShadow: '0 0 6px rgba(199,165,0,0.18)',
42
+ }}
43
+ />
44
+ );
45
+
46
+ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
47
+ const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
48
+ useSessionStore();
49
+ const { clearMessages, isConnected, isProcessing, setPlan, setPanelContent } = useAgentStore();
50
+
51
+ const handleNewSession = useCallback(async () => {
52
+ try {
53
+ const response = await fetch('/api/session', { method: 'POST' });
54
+ const data = await response.json();
55
+ createSession(data.session_id);
56
+ // Clear plan and code panel for new session
57
+ setPlan([]);
58
+ setPanelContent(null);
59
+ onClose?.();
60
+ } catch (e) {
61
+ console.error('Failed to create session:', e);
62
+ }
63
+ }, [createSession, setPlan, setPanelContent, onClose]);
64
+
65
+ const handleDeleteSession = useCallback(
66
+ async (sessionId: string, e: React.MouseEvent) => {
67
+ e.stopPropagation();
68
+ try {
69
+ await fetch(`/api/session/${sessionId}`, { method: 'DELETE' });
70
+ deleteSession(sessionId);
71
+ clearMessages(sessionId);
72
+ } catch (e) {
73
+ console.error('Failed to delete session:', e);
74
+ }
75
+ },
76
+ [deleteSession, clearMessages]
77
+ );
78
+
79
+ const handleSelectSession = useCallback(
80
+ (sessionId: string) => {
81
+ switchSession(sessionId);
82
+ // Clear plan and code panel when switching sessions
83
+ setPlan([]);
84
+ setPanelContent(null);
85
+ onClose?.();
86
+ },
87
+ [switchSession, setPlan, setPanelContent, onClose]
88
+ );
89
+
90
+ const handleUndo = useCallback(async () => {
91
+ if (!activeSessionId) return;
92
+ try {
93
+ await fetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
94
+ } catch (e) {
95
+ console.error('Undo failed:', e);
96
+ }
97
+ }, [activeSessionId]);
98
+
99
+ const formatTime = (dateString: string) => {
100
+ return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
101
+ };
102
+
103
+ return (
104
+ <Box className="sidebar" sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
105
+ {/* Header - Aligned with AppLayout 60px */}
106
+ <Box sx={{
107
+ height: '60px',
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ px: 2,
111
+ borderBottom: '1px solid rgba(255,255,255,0.03)'
112
+ }}>
113
+ <Box className="brand-logo" sx={{ display: 'flex' }}>
114
+ <img
115
+ src="/hf-log-only-white.png"
116
+ alt="HF Agent"
117
+ style={{ height: '24px', objectFit: 'contain' }}
118
+ />
119
+ </Box>
120
+ </Box>
121
+
122
+ {/* Content */}
123
+ <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', p: 2, overflow: 'hidden' }}>
124
+ {/* System Info / Status */}
125
+ <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
126
+ <StatusDiode connected={isConnected} />
127
+ <Typography variant="caption" sx={{ color: 'var(--muted-text)', fontFamily: 'inherit' }}>
128
+ {isConnected ? 'System Online' : 'Disconnected'}
129
+ </Typography>
130
+ </Box>
131
+
132
+ <Button
133
+ fullWidth
134
+ className="create-session"
135
+ onClick={handleNewSession}
136
+ sx={{
137
+ display: 'inline-flex',
138
+ alignItems: 'center',
139
+ justifyContent: 'flex-start',
140
+ gap: '10px',
141
+ padding: '10px 14px',
142
+ borderRadius: 'var(--radius-md)',
143
+ border: '1px solid rgba(255,255,255,0.06)',
144
+ bgcolor: 'transparent',
145
+ color: 'var(--text)',
146
+ fontWeight: 600,
147
+ textTransform: 'none',
148
+ mb: 3,
149
+ '&:hover': {
150
+ bgcolor: 'rgba(255,255,255,0.02)',
151
+ border: '1px solid rgba(255,255,255,0.1)',
152
+ },
153
+ '&::before': {
154
+ content: '""',
155
+ width: '4px',
156
+ height: '20px',
157
+ background: 'linear-gradient(180deg, var(--accent-yellow), rgba(199,165,0,0.9))',
158
+ borderRadius: '4px',
159
+ }
160
+ }}
161
+ >
162
+ New Session
163
+ </Button>
164
+
165
+ {/* Session List */}
166
+ <Box sx={{ flex: 1, overflow: 'auto', mx: -1, px: 1 }}>
167
+ <List disablePadding sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
168
+ {[...sessions].reverse().map((session, index) => {
169
+ const sessionNumber = sessions.length - index;
170
+ const isSelected = session.id === activeSessionId;
171
+ return (
172
+ <ListItem
173
+ key={session.id}
174
+ disablePadding
175
+ className="session-item"
176
+ onClick={() => handleSelectSession(session.id)}
177
+ sx={{
178
+ display: 'flex',
179
+ alignItems: 'center',
180
+ gap: '12px',
181
+ padding: '10px',
182
+ borderRadius: 'var(--radius-md)',
183
+ bgcolor: isSelected ? 'rgba(255,255,255,0.05)' : 'transparent',
184
+ cursor: 'pointer',
185
+ transition: 'background 0.18s ease, transform 0.08s ease',
186
+ '&:hover': {
187
+ bgcolor: 'rgba(255,255,255,0.02)',
188
+ transform: 'translateY(-1px)',
189
+ },
190
+ '& .delete-btn': {
191
+ opacity: 0,
192
+ transition: 'opacity 0.2s',
193
+ },
194
+ '&:hover .delete-btn': {
195
+ opacity: 1,
196
+ }
197
+ }}
198
+ >
199
+ <Box sx={{ flex: 1, overflow: 'hidden' }}>
200
+ <Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
201
+ Session {String(sessionNumber).padStart(2, '0')}
202
+ </Typography>
203
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
204
+ {session.isActive && <RunningIndicator />}
205
+ <Typography className="time" variant="caption" sx={{ fontSize: '12px', color: 'var(--muted-text)' }}>
206
+ {formatTime(session.createdAt)}
207
+ </Typography>
208
+ </Box>
209
+ </Box>
210
+
211
+ <IconButton
212
+ className="delete-btn"
213
+ size="small"
214
+ onClick={(e) => handleDeleteSession(session.id, e)}
215
+ sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--accent-red)' } }}
216
+ >
217
+ <DeleteIcon fontSize="small" />
218
+ </IconButton>
219
+ </ListItem>
220
+ );
221
+ })}
222
+ </List>
223
+ </Box>
224
+ </Box>
225
+
226
+ {/* Footer */}
227
+ <Box sx={{ p: 2, borderTop: '1px solid rgba(255,255,255,0.03)' }}>
228
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
229
+ <Typography variant="caption" className="small-note" sx={{ fontSize: '12px', color: 'var(--muted-text)' }}>
230
+ {sessions.length} active
231
+ </Typography>
232
+ <Tooltip title="Undo last turn">
233
+ <span>
234
+ <IconButton
235
+ onClick={handleUndo}
236
+ disabled={!activeSessionId || isProcessing}
237
+ size="small"
238
+ sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--text)' } }}
239
+ >
240
+ <UndoIcon fontSize="small" />
241
+ </IconButton>
242
+ </span>
243
+ </Tooltip>
244
+ </Box>
245
+ </Box>
246
+ </Box>
247
+ );
248
+ }
frontend/src/hooks/useAgentWebSocket.ts ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { useAgentStore } from '@/store/agentStore';
3
+ import { useSessionStore } from '@/store/sessionStore';
4
+ import { useLayoutStore } from '@/store/layoutStore';
5
+ import type { AgentEvent } from '@/types/events';
6
+ import type { Message, TraceLog } from '@/types/agent';
7
+
8
+ const WS_RECONNECT_DELAY = 1000;
9
+ const WS_MAX_RECONNECT_DELAY = 30000;
10
+
11
+ interface UseAgentWebSocketOptions {
12
+ sessionId: string | null;
13
+ onReady?: () => void;
14
+ onError?: (error: string) => void;
15
+ }
16
+
17
+ export function useAgentWebSocket({
18
+ sessionId,
19
+ onReady,
20
+ onError,
21
+ }: UseAgentWebSocketOptions) {
22
+ const wsRef = useRef<WebSocket | null>(null);
23
+ const reconnectTimeoutRef = useRef<number | null>(null);
24
+ const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
25
+
26
+ const {
27
+ addMessage,
28
+ updateMessage,
29
+ setProcessing,
30
+ setConnected,
31
+ setPendingApprovals,
32
+ setError,
33
+ addTraceLog,
34
+ updateTraceLog,
35
+ clearTraceLogs,
36
+ setPanelContent,
37
+ setPanelTab,
38
+ setActivePanelTab,
39
+ clearPanelTabs,
40
+ setPlan,
41
+ setCurrentTurnMessageId,
42
+ updateCurrentTurnTrace,
43
+ } = useAgentStore();
44
+
45
+ const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
46
+
47
+ const { setSessionActive } = useSessionStore();
48
+
49
+ const handleEvent = useCallback(
50
+ (event: AgentEvent) => {
51
+ if (!sessionId) return;
52
+
53
+ switch (event.event_type) {
54
+ case 'ready':
55
+ setConnected(true);
56
+ setProcessing(false);
57
+ setSessionActive(sessionId, true);
58
+ onReady?.();
59
+ break;
60
+
61
+ case 'processing':
62
+ setProcessing(true);
63
+ clearTraceLogs();
64
+ // Don't clear panel tabs here - they should persist during approval flow
65
+ // Tabs will be cleared when a new tool_call sets up new content
66
+ setCurrentTurnMessageId(null); // Start a new turn
67
+ break;
68
+
69
+ case 'assistant_message': {
70
+ const content = (event.data?.content as string) || '';
71
+ const currentTrace = useAgentStore.getState().traceLogs;
72
+ const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
73
+
74
+ if (currentTurnMsgId) {
75
+ // Update existing message - append content and update trace
76
+ const messages = useAgentStore.getState().getMessages(sessionId);
77
+ const existingMsg = messages.find(m => m.id === currentTurnMsgId);
78
+
79
+ if (existingMsg) {
80
+ const newContent = existingMsg.content ? existingMsg.content + '\n\n' + content : content;
81
+ updateMessage(sessionId, currentTurnMsgId, {
82
+ content: newContent,
83
+ trace: currentTrace.length > 0 ? [...currentTrace] : undefined,
84
+ });
85
+ }
86
+ } else {
87
+ // Create new message
88
+ const messageId = `msg_${Date.now()}`;
89
+ const message: Message = {
90
+ id: messageId,
91
+ role: 'assistant',
92
+ content,
93
+ timestamp: new Date().toISOString(),
94
+ trace: currentTrace.length > 0 ? [...currentTrace] : undefined,
95
+ };
96
+ addMessage(sessionId, message);
97
+ setCurrentTurnMessageId(messageId);
98
+ }
99
+ break;
100
+ }
101
+
102
+ case 'tool_call': {
103
+ const toolName = (event.data?.tool as string) || 'unknown';
104
+ const args = (event.data?.arguments as Record<string, any>) || {};
105
+
106
+ // Don't display plan_tool in trace logs (it shows up elsewhere in the UI)
107
+ if (toolName !== 'plan_tool') {
108
+ const log: TraceLog = {
109
+ id: `tool_${Date.now()}`,
110
+ type: 'call',
111
+ text: `Agent is executing ${toolName}...`,
112
+ tool: toolName,
113
+ timestamp: new Date().toISOString(),
114
+ completed: false,
115
+ };
116
+ addTraceLog(log);
117
+ // Update the current turn message's trace in real-time
118
+ updateCurrentTurnTrace(sessionId);
119
+ }
120
+
121
+ // Auto-expand Right Panel for specific tools
122
+ if (toolName === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
123
+ // Clear any existing tabs from previous jobs before setting new script
124
+ clearPanelTabs();
125
+ // Use tab system for jobs - add script tab immediately
126
+ setPanelTab({
127
+ id: 'script',
128
+ title: 'Script',
129
+ content: args.script,
130
+ language: 'python',
131
+ parameters: args
132
+ });
133
+ setActivePanelTab('script');
134
+ setRightPanelOpen(true);
135
+ setLeftSidebarOpen(false);
136
+ } else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
137
+ setPanelContent({
138
+ title: `File Upload: ${args.path || 'unnamed'}`,
139
+ content: args.content,
140
+ parameters: args,
141
+ language: args.path?.endsWith('.py') ? 'python' : undefined
142
+ });
143
+ setRightPanelOpen(true);
144
+ setLeftSidebarOpen(false);
145
+ }
146
+
147
+ console.log('Tool call:', toolName, args);
148
+ break;
149
+ }
150
+
151
+ case 'tool_output': {
152
+ const toolName = (event.data?.tool as string) || 'unknown';
153
+ const output = (event.data?.output as string) || '';
154
+ const success = event.data?.success as boolean;
155
+
156
+ // Mark the corresponding trace log as completed
157
+ updateTraceLog(toolName, { completed: true });
158
+ // Update the current turn message's trace in real-time
159
+ updateCurrentTurnTrace(sessionId);
160
+
161
+ // Special handling for hf_jobs - update the approval message with output
162
+ if (toolName === 'hf_jobs') {
163
+ const messages = useAgentStore.getState().getMessages(sessionId);
164
+ const lastApprovalMsg = [...messages].reverse().find(m => m.approval);
165
+
166
+ if (lastApprovalMsg) {
167
+ const currentOutput = lastApprovalMsg.toolOutput || '';
168
+ const newOutput = currentOutput ? currentOutput + '\n\n' + output : output;
169
+
170
+ useAgentStore.getState().updateMessage(sessionId, lastApprovalMsg.id, {
171
+ toolOutput: newOutput
172
+ });
173
+ console.log('Updated approval message with tool output:', toolName);
174
+ } else {
175
+ console.warn('Received hf_jobs output but no approval message found to update.');
176
+ }
177
+ }
178
+
179
+ // Don't create message bubbles for tool outputs - they only show in trace logs
180
+ console.log('Tool output:', toolName, success);
181
+ break;
182
+ }
183
+
184
+ case 'tool_log': {
185
+ const toolName = (event.data?.tool as string) || 'unknown';
186
+ const log = (event.data?.log as string) || '';
187
+
188
+ if (toolName === 'hf_jobs') {
189
+ const currentTabs = useAgentStore.getState().panelTabs;
190
+ const logsTab = currentTabs.find(t => t.id === 'logs');
191
+
192
+ // Append to existing logs tab or create new one
193
+ const newContent = logsTab
194
+ ? logsTab.content + '\n' + log
195
+ : '--- Job execution started ---\n' + log;
196
+
197
+ setPanelTab({
198
+ id: 'logs',
199
+ title: 'Logs',
200
+ content: newContent,
201
+ language: 'text'
202
+ });
203
+
204
+ // Auto-switch to logs tab when logs start streaming
205
+ setActivePanelTab('logs');
206
+
207
+ if (!useLayoutStore.getState().isRightPanelOpen) {
208
+ setRightPanelOpen(true);
209
+ }
210
+ }
211
+ break;
212
+ }
213
+
214
+ case 'plan_update': {
215
+ const plan = (event.data?.plan as any[]) || [];
216
+ setPlan(plan);
217
+ if (!useLayoutStore.getState().isRightPanelOpen) {
218
+ setRightPanelOpen(true);
219
+ }
220
+ break;
221
+ }
222
+
223
+ case 'approval_required': {
224
+ const tools = event.data?.tools as Array<{
225
+ tool: string;
226
+ arguments: Record<string, unknown>;
227
+ tool_call_id: string;
228
+ }>;
229
+ const count = (event.data?.count as number) || 0;
230
+
231
+ // Create a persistent message for the approval request
232
+ const message: Message = {
233
+ id: `msg_approval_${Date.now()}`,
234
+ role: 'assistant',
235
+ content: '', // Content is handled by the approval UI
236
+ timestamp: new Date().toISOString(),
237
+ approval: {
238
+ status: 'pending',
239
+ batch: { tools, count }
240
+ }
241
+ };
242
+ addMessage(sessionId, message);
243
+
244
+ // Clear currentTurnMessageId so subsequent assistant_message events create a new message below the approval
245
+ setCurrentTurnMessageId(null);
246
+
247
+ // We don't set pendingApprovals in the global store anymore as the message handles the UI
248
+ setPendingApprovals(null);
249
+ setProcessing(false);
250
+ break;
251
+ }
252
+
253
+ case 'turn_complete':
254
+ setProcessing(false);
255
+ setCurrentTurnMessageId(null); // Clear the current turn
256
+ break;
257
+
258
+ case 'compacted': {
259
+ const oldTokens = event.data?.old_tokens as number;
260
+ const newTokens = event.data?.new_tokens as number;
261
+ console.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
262
+ break;
263
+ }
264
+
265
+ case 'error': {
266
+ const errorMsg = (event.data?.error as string) || 'Unknown error';
267
+ setError(errorMsg);
268
+ setProcessing(false);
269
+ onError?.(errorMsg);
270
+ break;
271
+ }
272
+
273
+ case 'shutdown':
274
+ setConnected(false);
275
+ setProcessing(false);
276
+ break;
277
+
278
+ case 'interrupted':
279
+ setProcessing(false);
280
+ break;
281
+
282
+ case 'undo_complete':
283
+ // Could remove last messages from store
284
+ break;
285
+
286
+ default:
287
+ console.log('Unknown event:', event);
288
+ }
289
+ },
290
+ // Zustand setters are stable, so we don't need them in deps
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
292
+ [sessionId, onReady, onError]
293
+ );
294
+
295
+ const connect = useCallback(() => {
296
+ if (!sessionId) return;
297
+
298
+ // Don't connect if already connected or connecting
299
+ if (wsRef.current?.readyState === WebSocket.OPEN ||
300
+ wsRef.current?.readyState === WebSocket.CONNECTING) {
301
+ return;
302
+ }
303
+
304
+ // Connect directly to backend (Vite doesn't proxy WebSockets)
305
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
306
+ // In development, connect directly to backend port 7860
307
+ // In production, use the same host
308
+ const isDev = import.meta.env.DEV;
309
+ const host = isDev ? '127.0.0.1:7860' : window.location.host;
310
+ const wsUrl = `${protocol}//${host}/api/ws/${sessionId}`;
311
+
312
+ console.log('Connecting to WebSocket:', wsUrl);
313
+ const ws = new WebSocket(wsUrl);
314
+
315
+ ws.onopen = () => {
316
+ console.log('WebSocket connected');
317
+ setConnected(true);
318
+ reconnectDelayRef.current = WS_RECONNECT_DELAY;
319
+ };
320
+
321
+ ws.onmessage = (event) => {
322
+ try {
323
+ const data = JSON.parse(event.data) as AgentEvent;
324
+ handleEvent(data);
325
+ } catch (e) {
326
+ console.error('Failed to parse WebSocket message:', e);
327
+ }
328
+ };
329
+
330
+ ws.onerror = (error) => {
331
+ console.error('WebSocket error:', error);
332
+ };
333
+
334
+ ws.onclose = (event) => {
335
+ console.log('WebSocket closed', event.code, event.reason);
336
+ setConnected(false);
337
+
338
+ // Only reconnect if it wasn't a normal closure and session still exists
339
+ if (event.code !== 1000 && sessionId) {
340
+ // Attempt to reconnect with exponential backoff
341
+ if (reconnectTimeoutRef.current) {
342
+ clearTimeout(reconnectTimeoutRef.current);
343
+ }
344
+ reconnectTimeoutRef.current = window.setTimeout(() => {
345
+ reconnectDelayRef.current = Math.min(
346
+ reconnectDelayRef.current * 2,
347
+ WS_MAX_RECONNECT_DELAY
348
+ );
349
+ connect();
350
+ }, reconnectDelayRef.current);
351
+ }
352
+ };
353
+
354
+ wsRef.current = ws;
355
+ }, [sessionId, handleEvent]);
356
+
357
+ const disconnect = useCallback(() => {
358
+ if (reconnectTimeoutRef.current) {
359
+ clearTimeout(reconnectTimeoutRef.current);
360
+ reconnectTimeoutRef.current = null;
361
+ }
362
+ if (wsRef.current) {
363
+ wsRef.current.close();
364
+ wsRef.current = null;
365
+ }
366
+ setConnected(false);
367
+ }, []);
368
+
369
+ const sendPing = useCallback(() => {
370
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
371
+ wsRef.current.send(JSON.stringify({ type: 'ping' }));
372
+ }
373
+ }, []);
374
+
375
+ // Connect when sessionId changes (with a small delay to ensure session is ready)
376
+ useEffect(() => {
377
+ if (!sessionId) {
378
+ disconnect();
379
+ return;
380
+ }
381
+
382
+ // Small delay to ensure session is fully created on backend
383
+ const timeoutId = setTimeout(() => {
384
+ connect();
385
+ }, 100);
386
+
387
+ return () => {
388
+ clearTimeout(timeoutId);
389
+ disconnect();
390
+ };
391
+ // eslint-disable-next-line react-hooks/exhaustive-deps
392
+ }, [sessionId]);
393
+
394
+ // Heartbeat
395
+ useEffect(() => {
396
+ const interval = setInterval(sendPing, 30000);
397
+ return () => clearInterval(interval);
398
+ }, [sendPing]);
399
+
400
+ return {
401
+ isConnected: wsRef.current?.readyState === WebSocket.OPEN,
402
+ connect,
403
+ disconnect,
404
+ };
405
+ }
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,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+ import type { Message, ApprovalBatch, User, TraceLog } from '@/types/agent';
3
+
4
+ export interface PlanItem {
5
+ id: string;
6
+ content: string;
7
+ status: 'pending' | 'in_progress' | 'completed';
8
+ }
9
+
10
+ interface PanelTab {
11
+ id: string;
12
+ title: string;
13
+ content: string;
14
+ language?: string;
15
+ parameters?: any;
16
+ }
17
+
18
+ interface AgentStore {
19
+ // State per session (keyed by session ID)
20
+ messagesBySession: Record<string, Message[]>;
21
+ isProcessing: boolean;
22
+ isConnected: boolean;
23
+ pendingApprovals: ApprovalBatch | null;
24
+ user: User | null;
25
+ error: string | null;
26
+ traceLogs: TraceLog[];
27
+ panelContent: { title: string; content: string; language?: string; parameters?: any } | null;
28
+ panelTabs: PanelTab[];
29
+ activePanelTab: string | null;
30
+ plan: PlanItem[];
31
+ currentTurnMessageId: string | null; // Track the current turn's assistant message
32
+
33
+ // Actions
34
+ addMessage: (sessionId: string, message: Message) => void;
35
+ updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => void;
36
+ clearMessages: (sessionId: string) => void;
37
+ setProcessing: (isProcessing: boolean) => void;
38
+ setConnected: (isConnected: boolean) => void;
39
+ setPendingApprovals: (approvals: ApprovalBatch | null) => void;
40
+ setUser: (user: User | null) => void;
41
+ setError: (error: string | null) => void;
42
+ getMessages: (sessionId: string) => Message[];
43
+ addTraceLog: (log: TraceLog) => void;
44
+ updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => void;
45
+ clearTraceLogs: () => void;
46
+ setPanelContent: (content: { title: string; content: string; language?: string; parameters?: any } | null) => void;
47
+ setPanelTab: (tab: PanelTab) => void;
48
+ setActivePanelTab: (tabId: string) => void;
49
+ clearPanelTabs: () => void;
50
+ setPlan: (plan: PlanItem[]) => void;
51
+ setCurrentTurnMessageId: (id: string | null) => void;
52
+ updateCurrentTurnTrace: (sessionId: string) => void;
53
+ }
54
+
55
+ export const useAgentStore = create<AgentStore>((set, get) => ({
56
+ messagesBySession: {},
57
+ isProcessing: false,
58
+ isConnected: false,
59
+ pendingApprovals: null,
60
+ user: null,
61
+ error: null,
62
+ traceLogs: [],
63
+ panelContent: null,
64
+ panelTabs: [],
65
+ activePanelTab: null,
66
+ plan: [],
67
+ currentTurnMessageId: null,
68
+
69
+ addMessage: (sessionId: string, message: Message) => {
70
+ set((state) => {
71
+ const currentMessages = state.messagesBySession[sessionId] || [];
72
+ return {
73
+ messagesBySession: {
74
+ ...state.messagesBySession,
75
+ [sessionId]: [...currentMessages, message],
76
+ },
77
+ };
78
+ });
79
+ },
80
+
81
+ updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => {
82
+ set((state) => {
83
+ const currentMessages = state.messagesBySession[sessionId] || [];
84
+ const updatedMessages = currentMessages.map((msg) =>
85
+ msg.id === messageId ? { ...msg, ...updates } : msg
86
+ );
87
+ return {
88
+ messagesBySession: {
89
+ ...state.messagesBySession,
90
+ [sessionId]: updatedMessages,
91
+ },
92
+ };
93
+ });
94
+ },
95
+
96
+ clearMessages: (sessionId: string) => {
97
+ set((state) => ({
98
+ messagesBySession: {
99
+ ...state.messagesBySession,
100
+ [sessionId]: [],
101
+ },
102
+ }));
103
+ },
104
+
105
+ setProcessing: (isProcessing: boolean) => {
106
+ set({ isProcessing });
107
+ },
108
+
109
+ setConnected: (isConnected: boolean) => {
110
+ set({ isConnected });
111
+ },
112
+
113
+ setPendingApprovals: (approvals: ApprovalBatch | null) => {
114
+ set({ pendingApprovals: approvals });
115
+ },
116
+
117
+ setUser: (user: User | null) => {
118
+ set({ user });
119
+ },
120
+
121
+ setError: (error: string | null) => {
122
+ set({ error });
123
+ },
124
+
125
+ getMessages: (sessionId: string) => {
126
+ return get().messagesBySession[sessionId] || [];
127
+ },
128
+
129
+ addTraceLog: (log: TraceLog) => {
130
+ set((state) => ({
131
+ traceLogs: [...state.traceLogs, log],
132
+ }));
133
+ },
134
+
135
+ updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => {
136
+ set((state) => {
137
+ // Find the last trace log with this tool name and update it
138
+ const traceLogs = [...state.traceLogs];
139
+ for (let i = traceLogs.length - 1; i >= 0; i--) {
140
+ if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call') {
141
+ traceLogs[i] = { ...traceLogs[i], ...updates };
142
+ break;
143
+ }
144
+ }
145
+ return { traceLogs };
146
+ });
147
+ },
148
+
149
+ clearTraceLogs: () => {
150
+ set({ traceLogs: [] });
151
+ },
152
+
153
+ setPanelContent: (content) => {
154
+ set({ panelContent: content });
155
+ },
156
+
157
+ setPanelTab: (tab: PanelTab) => {
158
+ set((state) => {
159
+ const existingIndex = state.panelTabs.findIndex(t => t.id === tab.id);
160
+ let newTabs: PanelTab[];
161
+ if (existingIndex >= 0) {
162
+ // Update existing tab
163
+ newTabs = [...state.panelTabs];
164
+ newTabs[existingIndex] = tab;
165
+ } else {
166
+ // Add new tab
167
+ newTabs = [...state.panelTabs, tab];
168
+ }
169
+ return {
170
+ panelTabs: newTabs,
171
+ activePanelTab: state.activePanelTab || tab.id, // Auto-select first tab
172
+ };
173
+ });
174
+ },
175
+
176
+ setActivePanelTab: (tabId: string) => {
177
+ set({ activePanelTab: tabId });
178
+ },
179
+
180
+ clearPanelTabs: () => {
181
+ set({ panelTabs: [], activePanelTab: null });
182
+ },
183
+
184
+ setPlan: (plan: PlanItem[]) => {
185
+ set({ plan });
186
+ },
187
+
188
+ setCurrentTurnMessageId: (id: string | null) => {
189
+ set({ currentTurnMessageId: id });
190
+ },
191
+
192
+ updateCurrentTurnTrace: (sessionId: string) => {
193
+ const state = get();
194
+ if (state.currentTurnMessageId) {
195
+ const currentMessages = state.messagesBySession[sessionId] || [];
196
+ const updatedMessages = currentMessages.map((msg) =>
197
+ msg.id === state.currentTurnMessageId
198
+ ? { ...msg, trace: state.traceLogs.length > 0 ? [...state.traceLogs] : undefined }
199
+ : msg
200
+ );
201
+ set({
202
+ messagesBySession: {
203
+ ...state.messagesBySession,
204
+ [sessionId]: updatedMessages,
205
+ },
206
+ });
207
+ }
208
+ },
209
+ }));
frontend/src/store/layoutStore.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+
3
+ interface LayoutStore {
4
+ isLeftSidebarOpen: boolean;
5
+ isRightPanelOpen: boolean;
6
+ rightPanelWidth: number;
7
+ setLeftSidebarOpen: (open: boolean) => void;
8
+ setRightPanelOpen: (open: boolean) => void;
9
+ setRightPanelWidth: (width: number) => void;
10
+ toggleLeftSidebar: () => void;
11
+ toggleRightPanel: () => void;
12
+ }
13
+
14
+ export const useLayoutStore = create<LayoutStore>((set) => ({
15
+ isLeftSidebarOpen: true,
16
+ isRightPanelOpen: false,
17
+ rightPanelWidth: 450,
18
+ setLeftSidebarOpen: (open) => set({ isLeftSidebarOpen: open }),
19
+ setRightPanelOpen: (open) => set({ isRightPanelOpen: open }),
20
+ setRightPanelWidth: (width) => set({ rightPanelWidth: width }),
21
+ toggleLeftSidebar: () => set((state) => ({ isLeftSidebarOpen: !state.isLeftSidebarOpen })),
22
+ toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
23
+ }));
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,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createTheme } from '@mui/material/styles';
2
+
3
+ const theme = createTheme({
4
+ palette: {
5
+ mode: 'dark',
6
+ primary: {
7
+ main: '#C7A500', // --accent-yellow
8
+ },
9
+ secondary: {
10
+ main: '#FF9D00',
11
+ },
12
+ background: {
13
+ default: '#0B0D10', // --bg
14
+ paper: '#0F1316', // --panel
15
+ },
16
+ text: {
17
+ primary: '#E6EEF8', // --text
18
+ secondary: '#98A0AA', // --muted-text
19
+ },
20
+ divider: 'rgba(255,255,255,0.03)',
21
+ success: {
22
+ main: '#2FCC71', // --accent-green
23
+ },
24
+ error: {
25
+ main: '#E05A4F', // --accent-red
26
+ },
27
+ warning: {
28
+ main: '#C7A500',
29
+ },
30
+ info: {
31
+ main: '#58A6FF',
32
+ },
33
+ },
34
+ typography: {
35
+ fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
36
+ fontSize: 15,
37
+ h1: { fontWeight: 600, color: '#E6EEF8' },
38
+ h2: { fontWeight: 600, color: '#E6EEF8' },
39
+ h3: { fontWeight: 600, color: '#E6EEF8' },
40
+ h4: { fontWeight: 600, color: '#E6EEF8' },
41
+ h5: { fontWeight: 600, color: '#E6EEF8' },
42
+ h6: { fontWeight: 600, color: '#E6EEF8' },
43
+ body1: { fontSize: '15px', color: '#E6EEF8' },
44
+ body2: { fontSize: '0.875rem', color: '#98A0AA' },
45
+ button: {
46
+ fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
47
+ textTransform: 'none',
48
+ fontWeight: 600,
49
+ },
50
+ },
51
+ components: {
52
+ MuiCssBaseline: {
53
+ styleOverrides: {
54
+ ':root': {
55
+ '--bg': '#0B0D10',
56
+ '--panel': '#0F1316',
57
+ '--surface': '#121416',
58
+ '--text': '#E6EEF8',
59
+ '--muted-text': '#98A0AA',
60
+ '--accent-yellow': '#C7A500',
61
+ '--accent-yellow-weak': 'rgba(199,165,0,0.08)',
62
+ '--accent-green': '#2FCC71',
63
+ '--accent-red': '#E05A4F',
64
+ '--shadow-1': '0 6px 18px rgba(2,6,12,0.55)',
65
+ '--radius-lg': '20px',
66
+ '--radius-md': '12px',
67
+ '--focus': '0 0 0 3px rgba(199,165,0,0.12)',
68
+ },
69
+ body: {
70
+ background: 'linear-gradient(180deg, var(--bg), #090B0D)',
71
+ color: 'var(--text)',
72
+ scrollbarWidth: 'thin',
73
+ '&::-webkit-scrollbar': {
74
+ width: '8px',
75
+ height: '8px',
76
+ },
77
+ '&::-webkit-scrollbar-thumb': {
78
+ backgroundColor: '#30363D',
79
+ borderRadius: '2px',
80
+ },
81
+ '&::-webkit-scrollbar-track': {
82
+ backgroundColor: 'transparent',
83
+ },
84
+ },
85
+ 'code, pre': {
86
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
87
+ },
88
+ '.brand-logo': {
89
+ position: 'relative',
90
+ padding: '6px',
91
+ borderRadius: '8px',
92
+ '&::after': {
93
+ content: '""',
94
+ position: 'absolute',
95
+ inset: '-6px',
96
+ borderRadius: '10px',
97
+ background: 'var(--accent-yellow-weak)',
98
+ zIndex: -1,
99
+ pointerEvents: 'none',
100
+ },
101
+ },
102
+ },
103
+ },
104
+ MuiButton: {
105
+ styleOverrides: {
106
+ root: {
107
+ borderRadius: '10px',
108
+ fontWeight: 600,
109
+ transition: 'transform 0.06s ease, background 0.12s ease, box-shadow 0.12s ease',
110
+ '&:hover': {
111
+ transform: 'translateY(-1px)',
112
+ },
113
+ },
114
+ },
115
+ },
116
+ MuiPaper: {
117
+ styleOverrides: {
118
+ root: {
119
+ backgroundImage: 'none',
120
+ backgroundColor: 'transparent', // Default to transparent for gradients
121
+ },
122
+ },
123
+ },
124
+ MuiDrawer: {
125
+ styleOverrides: {
126
+ paper: {
127
+ backgroundColor: 'var(--panel)',
128
+ borderRight: '1px solid rgba(255,255,255,0.03)',
129
+ },
130
+ },
131
+ },
132
+ MuiTextField: {
133
+ styleOverrides: {
134
+ root: {
135
+ '& .MuiOutlinedInput-root': {
136
+ borderRadius: 'var(--radius-md)',
137
+ '& fieldset': {
138
+ borderColor: 'rgba(255,255,255,0.03)',
139
+ },
140
+ '&:hover fieldset': {
141
+ borderColor: 'rgba(255,255,255,0.1)',
142
+ },
143
+ '&.Mui-focused fieldset': {
144
+ borderColor: 'var(--accent-yellow)',
145
+ borderWidth: '1px',
146
+ boxShadow: 'var(--focus)',
147
+ },
148
+ },
149
+ },
150
+ },
151
+ },
152
+ },
153
+ shape: {
154
+ borderRadius: 12,
155
+ },
156
+ });
157
+
158
+ export default theme;
frontend/src/types/agent.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ tool_call_id?: string;
19
+ trace?: TraceLog[];
20
+ approval?: {
21
+ status: 'pending' | 'approved' | 'rejected';
22
+ batch: ApprovalBatch;
23
+ decisions?: ToolApproval[];
24
+ };
25
+ toolOutput?: string;
26
+ }
27
+
28
+ export interface ToolCall {
29
+ id: string;
30
+ tool: string;
31
+ arguments: Record<string, unknown>;
32
+ status: 'pending' | 'running' | 'completed' | 'failed';
33
+ output?: string;
34
+ }
35
+
36
+ export interface ToolApproval {
37
+ tool_call_id: string;
38
+ approved: boolean;
39
+ feedback?: string | null;
40
+ }
41
+
42
+ export interface ApprovalBatch {
43
+ tools: Array<{
44
+ tool: string;
45
+ arguments: Record<string, unknown>;
46
+ tool_call_id: string;
47
+ }>;
48
+ count: number;
49
+ }
50
+
51
+ export interface TraceLog {
52
+ id: string;
53
+ type: 'call' | 'output';
54
+ text: string;
55
+ tool: string;
56
+ timestamp: string;
57
+ completed?: boolean;
58
+ }
59
+
60
+ export interface User {
61
+ authenticated: boolean;
62
+ username?: string;
63
+ name?: string;
64
+ picture?: string;
65
+ }
frontend/src/types/events.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ | 'tool_log'
12
+ | 'approval_required'
13
+ | 'turn_complete'
14
+ | 'compacted'
15
+ | 'error'
16
+ | 'shutdown'
17
+ | 'interrupted'
18
+ | 'undo_complete'
19
+ | 'plan_update';
20
+
21
+ export interface AgentEvent {
22
+ event_type: EventType;
23
+ data?: Record<string, unknown>;
24
+ }
25
+
26
+ export interface ReadyEventData {
27
+ message: string;
28
+ }
29
+
30
+ export interface ProcessingEventData {
31
+ message: string;
32
+ }
33
+
34
+ export interface AssistantMessageEventData {
35
+ content: string;
36
+ }
37
+
38
+ export interface ToolCallEventData {
39
+ tool: string;
40
+ arguments: Record<string, unknown>;
41
+ }
42
+
43
+ export interface ToolOutputEventData {
44
+ tool: string;
45
+ output: string;
46
+ success: boolean;
47
+ }
48
+
49
+ export interface ToolLogEventData {
50
+ tool: string;
51
+ log: string;
52
+ }
53
+
54
+ export interface PlanUpdateEventData {
55
+ plan: Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>;
56
+ }
57
+
58
+ export interface ApprovalRequiredEventData {
59
+ tools: ApprovalToolItem[];
60
+ count: number;
61
+ }
62
+
63
+ export interface ApprovalToolItem {
64
+ tool: string;
65
+ arguments: Record<string, unknown>;
66
+ tool_call_id: string;
67
+ }
68
+
69
+ export interface TurnCompleteEventData {
70
+ history_size: number;
71
+ }
72
+
73
+ export interface CompactedEventData {
74
+ old_tokens: number;
75
+ new_tokens: number;
76
+ }
77
+
78
+ export interface ErrorEventData {
79
+ error: string;
80
+ }
frontend/src/utils/logProcessor.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function processLogs(logs: string): string {
2
+ if (!logs) return '';
3
+
4
+ // 1. Handle \r (Carriage Return) for progress bars
5
+ const rawLines = logs.split('\n');
6
+ const processedLines: string[] = [];
7
+
8
+ for (const rawLine of rawLines) {
9
+ // Remove potential trailing \r from \r\n split
10
+ let line = rawLine;
11
+ if (line.endsWith('\r')) {
12
+ line = line.slice(0, -1);
13
+ }
14
+
15
+ if (line.includes('\r')) {
16
+ const segments = line.split('\r');
17
+ // Find the last non-empty segment
18
+ // Iterate backwards
19
+ let found = false;
20
+ for (let i = segments.length - 1; i >= 0; i--) {
21
+ if (segments[i].length > 0) {
22
+ processedLines.push(segments[i]);
23
+ found = true;
24
+ break;
25
+ }
26
+ }
27
+ if (!found) {
28
+ // If all segments were empty, push empty string (or skip?)
29
+ processedLines.push("");
30
+ }
31
+ } else {
32
+ processedLines.push(line);
33
+ }
34
+ }
35
+
36
+ // 2. Compaction (Downloading & TQDM)
37
+ const finalLines: string[] = [];
38
+
39
+ // Regex for "Downloading <package>" or "Downloaded <package>"
40
+ const downloadPattern = /^(Downloading|Downloaded)\s+/;
41
+
42
+ // Regex for TQDM-like progress bars
43
+ // Examples:
44
+ // "100%|██████████| 10/10 [00:01<00:00, 8.00it/s]"
45
+ // " 20%|## | ..."
46
+ // "Downloading: 10%"
47
+ const tqdmPattern = /^\s*\d+%\|.*\||^\s*\d+%\s+/;
48
+
49
+ for (let i = 0; i < processedLines.length; i++) {
50
+ const line = processedLines[i];
51
+
52
+ // Check for Download pattern
53
+ if (downloadPattern.test(line)) {
54
+ // Look ahead for consecutive download lines
55
+ let nextIsDownload = false;
56
+ if (i + 1 < processedLines.length) {
57
+ nextIsDownload = downloadPattern.test(processedLines[i + 1]);
58
+ }
59
+
60
+ if (nextIsDownload) {
61
+ continue; // Skip this line
62
+ }
63
+ }
64
+ // Check for TQDM pattern
65
+ else if (tqdmPattern.test(line)) {
66
+ // Look ahead for consecutive TQDM lines
67
+ let nextIsTqdm = false;
68
+ if (i + 1 < processedLines.length) {
69
+ nextIsTqdm = tqdmPattern.test(processedLines[i + 1]);
70
+ }
71
+
72
+ if (nextIsTqdm) {
73
+ continue; // Skip this line
74
+ }
75
+ }
76
+
77
+ finalLines.push(line);
78
+ }
79
+
80
+ return finalLines.join('\n');
81
+ }
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:7860',
17
+ changeOrigin: true,
18
+ },
19
+ '/auth': {
20
+ target: 'http://localhost:7860',
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
uv.lock CHANGED
@@ -169,6 +169,15 @@ wheels = [
169
  { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
170
  ]
171
 
 
 
 
 
 
 
 
 
 
172
  [[package]]
173
  name = "annotated-types"
174
  version = "0.7.0"
@@ -638,6 +647,21 @@ wheels = [
638
  { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
639
  ]
640
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  [[package]]
642
  name = "fastjsonschema"
643
  version = "2.21.2"
@@ -910,7 +934,9 @@ dependencies = [
910
  [package.optional-dependencies]
911
  agent = [
912
  { name = "datasets" },
 
913
  { name = "fastmcp" },
 
914
  { name = "huggingface-hub" },
915
  { name = "litellm" },
916
  { name = "lmnr" },
@@ -919,10 +945,15 @@ agent = [
919
  { name = "prompt-toolkit" },
920
  { name = "requests" },
921
  { name = "thefuzz" },
 
 
 
922
  ]
923
  all = [
924
  { name = "datasets" },
 
925
  { name = "fastmcp" },
 
926
  { name = "huggingface-hub" },
927
  { name = "inspect-ai" },
928
  { name = "litellm" },
@@ -935,6 +966,9 @@ all = [
935
  { name = "requests" },
936
  { name = "tenacity" },
937
  { name = "thefuzz" },
 
 
 
938
  ]
939
  dev = [
940
  { name = "pytest" },
@@ -951,8 +985,10 @@ requires-dist = [
951
  { name = "datasets", specifier = ">=4.4.1" },
952
  { name = "datasets", marker = "extra == 'agent'", specifier = ">=4.3.0" },
953
  { name = "datasets", marker = "extra == 'eval'", specifier = ">=4.3.0" },
 
954
  { name = "fastmcp", marker = "extra == 'agent'", specifier = ">=2.4.0" },
955
  { name = "hf-agent", extras = ["agent", "eval", "dev"], marker = "extra == 'all'" },
 
956
  { name = "huggingface-hub", marker = "extra == 'agent'", specifier = ">=1.0.1" },
957
  { name = "inspect-ai", marker = "extra == 'eval'", specifier = ">=0.3.149" },
958
  { name = "litellm", marker = "extra == 'agent'", specifier = ">=1.0.0" },
@@ -967,6 +1003,9 @@ requires-dist = [
967
  { name = "requests", marker = "extra == 'agent'", specifier = ">=2.32.5" },
968
  { name = "tenacity", marker = "extra == 'eval'", specifier = ">=8.0.0" },
969
  { name = "thefuzz", marker = "extra == 'agent'", specifier = ">=0.22.1" },
 
 
 
970
  ]
971
  provides-extras = ["agent", "eval", "dev", "all"]
972
 
@@ -1012,6 +1051,35 @@ wheels = [
1012
  { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
1013
  ]
1014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
  [[package]]
1016
  name = "httpx"
1017
  version = "0.28.1"
@@ -3525,6 +3593,119 @@ wheels = [
3525
  { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
3526
  ]
3527
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3528
  [[package]]
3529
  name = "wcwidth"
3530
  version = "0.2.14"
@@ -3574,6 +3755,15 @@ wheels = [
3574
  { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
3575
  ]
3576
 
 
 
 
 
 
 
 
 
 
3577
  [[package]]
3578
  name = "wrapt"
3579
  version = "1.17.3"
 
169
  { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
170
  ]
171
 
172
+ [[package]]
173
+ name = "annotated-doc"
174
+ version = "0.0.4"
175
+ source = { registry = "https://pypi.org/simple" }
176
+ sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
177
+ wheels = [
178
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
179
+ ]
180
+
181
  [[package]]
182
  name = "annotated-types"
183
  version = "0.7.0"
 
647
  { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
648
  ]
649
 
650
+ [[package]]
651
+ name = "fastapi"
652
+ version = "0.128.0"
653
+ source = { registry = "https://pypi.org/simple" }
654
+ dependencies = [
655
+ { name = "annotated-doc" },
656
+ { name = "pydantic" },
657
+ { name = "starlette" },
658
+ { name = "typing-extensions" },
659
+ ]
660
+ sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
661
+ wheels = [
662
+ { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
663
+ ]
664
+
665
  [[package]]
666
  name = "fastjsonschema"
667
  version = "2.21.2"
 
934
  [package.optional-dependencies]
935
  agent = [
936
  { name = "datasets" },
937
+ { name = "fastapi" },
938
  { name = "fastmcp" },
939
+ { name = "httpx" },
940
  { name = "huggingface-hub" },
941
  { name = "litellm" },
942
  { name = "lmnr" },
 
945
  { name = "prompt-toolkit" },
946
  { name = "requests" },
947
  { name = "thefuzz" },
948
+ { name = "uvicorn", extra = ["standard"] },
949
+ { name = "websockets" },
950
+ { name = "whoosh" },
951
  ]
952
  all = [
953
  { name = "datasets" },
954
+ { name = "fastapi" },
955
  { name = "fastmcp" },
956
+ { name = "httpx" },
957
  { name = "huggingface-hub" },
958
  { name = "inspect-ai" },
959
  { name = "litellm" },
 
966
  { name = "requests" },
967
  { name = "tenacity" },
968
  { name = "thefuzz" },
969
+ { name = "uvicorn", extra = ["standard"] },
970
+ { name = "websockets" },
971
+ { name = "whoosh" },
972
  ]
973
  dev = [
974
  { name = "pytest" },
 
985
  { name = "datasets", specifier = ">=4.4.1" },
986
  { name = "datasets", marker = "extra == 'agent'", specifier = ">=4.3.0" },
987
  { name = "datasets", marker = "extra == 'eval'", specifier = ">=4.3.0" },
988
+ { name = "fastapi", marker = "extra == 'agent'", specifier = ">=0.115.0" },
989
  { name = "fastmcp", marker = "extra == 'agent'", specifier = ">=2.4.0" },
990
  { name = "hf-agent", extras = ["agent", "eval", "dev"], marker = "extra == 'all'" },
991
+ { name = "httpx", marker = "extra == 'agent'", specifier = ">=0.27.0" },
992
  { name = "huggingface-hub", marker = "extra == 'agent'", specifier = ">=1.0.1" },
993
  { name = "inspect-ai", marker = "extra == 'eval'", specifier = ">=0.3.149" },
994
  { name = "litellm", marker = "extra == 'agent'", specifier = ">=1.0.0" },
 
1003
  { name = "requests", marker = "extra == 'agent'", specifier = ">=2.32.5" },
1004
  { name = "tenacity", marker = "extra == 'eval'", specifier = ">=8.0.0" },
1005
  { name = "thefuzz", marker = "extra == 'agent'", specifier = ">=0.22.1" },
1006
+ { name = "uvicorn", extras = ["standard"], marker = "extra == 'agent'", specifier = ">=0.32.0" },
1007
+ { name = "websockets", marker = "extra == 'agent'", specifier = ">=13.0" },
1008
+ { name = "whoosh", marker = "extra == 'agent'", specifier = ">=2.7.4" },
1009
  ]
1010
  provides-extras = ["agent", "eval", "dev", "all"]
1011
 
 
1051
  { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
1052
  ]
1053
 
1054
+ [[package]]
1055
+ name = "httptools"
1056
+ version = "0.7.1"
1057
+ source = { registry = "https://pypi.org/simple" }
1058
+ sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
1059
+ wheels = [
1060
+ { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
1061
+ { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
1062
+ { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
1063
+ { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
1064
+ { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
1065
+ { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
1066
+ { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
1067
+ { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
1068
+ { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
1069
+ { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
1070
+ { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
1071
+ { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
1072
+ { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
1073
+ { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
1074
+ { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
1075
+ { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
1076
+ { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
1077
+ { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
1078
+ { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
1079
+ { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
1080
+ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
1081
+ ]
1082
+
1083
  [[package]]
1084
  name = "httpx"
1085
  version = "0.28.1"
 
3593
  { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
3594
  ]
3595
 
3596
+ [package.optional-dependencies]
3597
+ standard = [
3598
+ { name = "colorama", marker = "sys_platform == 'win32'" },
3599
+ { name = "httptools" },
3600
+ { name = "python-dotenv" },
3601
+ { name = "pyyaml" },
3602
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
3603
+ { name = "watchfiles" },
3604
+ { name = "websockets" },
3605
+ ]
3606
+
3607
+ [[package]]
3608
+ name = "uvloop"
3609
+ version = "0.22.1"
3610
+ source = { registry = "https://pypi.org/simple" }
3611
+ sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
3612
+ wheels = [
3613
+ { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
3614
+ { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
3615
+ { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
3616
+ { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
3617
+ { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
3618
+ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
3619
+ { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
3620
+ { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
3621
+ { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
3622
+ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
3623
+ { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
3624
+ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
3625
+ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
3626
+ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
3627
+ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
3628
+ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
3629
+ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
3630
+ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
3631
+ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
3632
+ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
3633
+ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
3634
+ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
3635
+ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
3636
+ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
3637
+ ]
3638
+
3639
+ [[package]]
3640
+ name = "watchfiles"
3641
+ version = "1.1.1"
3642
+ source = { registry = "https://pypi.org/simple" }
3643
+ dependencies = [
3644
+ { name = "anyio" },
3645
+ ]
3646
+ sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
3647
+ wheels = [
3648
+ { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
3649
+ { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
3650
+ { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
3651
+ { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
3652
+ { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
3653
+ { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
3654
+ { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
3655
+ { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
3656
+ { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
3657
+ { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
3658
+ { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
3659
+ { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
3660
+ { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
3661
+ { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
3662
+ { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
3663
+ { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
3664
+ { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
3665
+ { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
3666
+ { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
3667
+ { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
3668
+ { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
3669
+ { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
3670
+ { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
3671
+ { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
3672
+ { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
3673
+ { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
3674
+ { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
3675
+ { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
3676
+ { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
3677
+ { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
3678
+ { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
3679
+ { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
3680
+ { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
3681
+ { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
3682
+ { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
3683
+ { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
3684
+ { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
3685
+ { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
3686
+ { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
3687
+ { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
3688
+ { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
3689
+ { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
3690
+ { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
3691
+ { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
3692
+ { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
3693
+ { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
3694
+ { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
3695
+ { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
3696
+ { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
3697
+ { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
3698
+ { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
3699
+ { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
3700
+ { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
3701
+ { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
3702
+ { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
3703
+ { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
3704
+ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
3705
+ { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
3706
+ { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
3707
+ ]
3708
+
3709
  [[package]]
3710
  name = "wcwidth"
3711
  version = "0.2.14"
 
3755
  { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
3756
  ]
3757
 
3758
+ [[package]]
3759
+ name = "whoosh"
3760
+ version = "2.7.4"
3761
+ source = { registry = "https://pypi.org/simple" }
3762
+ sdist = { url = "https://files.pythonhosted.org/packages/25/2b/6beed2107b148edc1321da0d489afc4617b9ed317ef7b72d4993cad9b684/Whoosh-2.7.4.tar.gz", hash = "sha256:7ca5633dbfa9e0e0fa400d3151a8a0c4bec53bd2ecedc0a67705b17565c31a83", size = 968741, upload-time = "2016-04-04T01:19:32.327Z" }
3763
+ wheels = [
3764
+ { url = "https://files.pythonhosted.org/packages/ba/19/24d0f1f454a2c1eb689ca28d2f178db81e5024f42d82729a4ff6771155cf/Whoosh-2.7.4-py2.py3-none-any.whl", hash = "sha256:aa39c3c3426e3fd107dcb4bde64ca1e276a65a889d9085a6e4b54ba82420a852", size = 468790, upload-time = "2016-04-04T01:19:40.379Z" },
3765
+ ]
3766
+
3767
  [[package]]
3768
  name = "wrapt"
3769
  version = "1.17.3"