Restructure web app directories
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
"""FastAPI server backing the local web GUI.
|
||||
|
||||
Wraps a single global :class:`LocalCodingAgent`, exposes JSON endpoints for
|
||||
chat, slash commands, and saved sessions, and serves the static SPA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.agent_runtime import LocalCodingAgent
|
||||
from src.agent_slash_commands import get_slash_command_specs
|
||||
from src.agent_types import (
|
||||
AgentPermissions,
|
||||
AgentRuntimeConfig,
|
||||
ModelConfig,
|
||||
)
|
||||
from src.bundled_skills import get_bundled_skills
|
||||
from src.session_store import (
|
||||
DEFAULT_AGENT_SESSION_DIR,
|
||||
StoredAgentSession,
|
||||
load_agent_session,
|
||||
)
|
||||
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent state holder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentState:
|
||||
"""Holds the live agent instance plus a lock for serialized access."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cwd: Path,
|
||||
model: str,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
allow_shell: bool,
|
||||
allow_write: bool,
|
||||
session_directory: Path,
|
||||
) -> None:
|
||||
self.cwd = cwd.resolve()
|
||||
self.session_directory = session_directory
|
||||
self._lock = Lock()
|
||||
self._agent: LocalCodingAgent | None = None
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.allow_shell = allow_shell
|
||||
self.allow_write = allow_write
|
||||
self._build_agent()
|
||||
|
||||
def _build_agent(self) -> None:
|
||||
permissions = AgentPermissions(
|
||||
allow_file_write=self.allow_write,
|
||||
allow_shell_commands=self.allow_shell,
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
cwd=self.cwd,
|
||||
permissions=permissions,
|
||||
session_directory=self.session_directory,
|
||||
)
|
||||
model_config = ModelConfig(
|
||||
model=self.model,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
)
|
||||
self._agent = LocalCodingAgent(
|
||||
model_config=model_config,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
|
||||
@property
|
||||
def agent(self) -> LocalCodingAgent:
|
||||
assert self._agent is not None
|
||||
return self._agent
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
cwd: str | None = None,
|
||||
allow_shell: bool | None = None,
|
||||
allow_write: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if model is not None:
|
||||
self.model = model
|
||||
if base_url is not None:
|
||||
self.base_url = base_url
|
||||
if api_key is not None:
|
||||
self.api_key = api_key
|
||||
if cwd is not None:
|
||||
resolved = Path(cwd).expanduser().resolve()
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f'cwd does not exist: {resolved}')
|
||||
self.cwd = resolved
|
||||
if allow_shell is not None:
|
||||
self.allow_shell = allow_shell
|
||||
if allow_write is not None:
|
||||
self.allow_write = allow_write
|
||||
self._build_agent()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
'model': self.model,
|
||||
'base_url': self.base_url,
|
||||
'cwd': str(self.cwd),
|
||||
'session_directory': str(self.session_directory),
|
||||
'allow_shell': self.allow_shell,
|
||||
'allow_write': self.allow_write,
|
||||
'active_session_id': self.agent.active_session_id,
|
||||
}
|
||||
|
||||
def lock(self) -> Lock:
|
||||
return self._lock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
prompt: str = Field(min_length=1)
|
||||
resume_session_id: str | None = None
|
||||
|
||||
|
||||
class StateUpdate(BaseModel):
|
||||
model: str | None = None
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
cwd: str | None = None
|
||||
allow_shell: bool | None = None
|
||||
allow_write: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_app(state: AgentState) -> FastAPI:
|
||||
app = FastAPI(title='Claw Code GUI', version='1.0')
|
||||
|
||||
# ------------- static + index ------------------------------------------
|
||||
app.mount(
|
||||
'/static',
|
||||
StaticFiles(directory=str(STATIC_DIR)),
|
||||
name='static',
|
||||
)
|
||||
|
||||
@app.get('/', include_in_schema=False)
|
||||
async def root() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / 'index.html')
|
||||
|
||||
# ------------- info ------------------------------------------------------
|
||||
@app.get('/api/state')
|
||||
async def get_state() -> dict[str, Any]:
|
||||
return state.snapshot()
|
||||
|
||||
@app.post('/api/state')
|
||||
async def post_state(payload: StateUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
state.update(**payload.model_dump(exclude_none=True))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return state.snapshot()
|
||||
|
||||
@app.get('/api/slash-commands')
|
||||
async def list_slash_commands() -> list[dict[str, Any]]:
|
||||
commands: list[dict[str, Any]] = []
|
||||
for spec in get_slash_command_specs():
|
||||
commands.append(
|
||||
{
|
||||
'names': list(spec.names),
|
||||
'primary': spec.names[0],
|
||||
'description': spec.description,
|
||||
}
|
||||
)
|
||||
return commands
|
||||
|
||||
@app.get('/api/skills')
|
||||
async def list_skills() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'name': skill.name,
|
||||
'description': skill.description,
|
||||
'when_to_use': skill.when_to_use,
|
||||
'aliases': list(skill.aliases),
|
||||
'allowed_tools': list(skill.allowed_tools),
|
||||
}
|
||||
for skill in get_bundled_skills(state.cwd)
|
||||
if skill.user_invocable
|
||||
]
|
||||
|
||||
# ------------- sessions --------------------------------------------------
|
||||
@app.get('/api/sessions')
|
||||
async def list_sessions() -> list[dict[str, Any]]:
|
||||
directory = state.session_directory
|
||||
if not directory.exists():
|
||||
return []
|
||||
results: list[dict[str, Any]] = []
|
||||
for path in sorted(
|
||||
directory.glob('*.json'),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
messages = data.get('messages') or []
|
||||
preview = ''
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str):
|
||||
preview = content[:120]
|
||||
break
|
||||
results.append(
|
||||
{
|
||||
'session_id': data.get('session_id', path.stem),
|
||||
'turns': data.get('turns', 0),
|
||||
'tool_calls': data.get('tool_calls', 0),
|
||||
'preview': preview,
|
||||
'modified_at': path.stat().st_mtime,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
@app.get('/api/sessions/{session_id}')
|
||||
async def get_session(session_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
stored = load_agent_session(session_id, directory=state.session_directory)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail='Session not found')
|
||||
return _serialize_stored_session(stored)
|
||||
|
||||
# ------------- chat ------------------------------------------------------
|
||||
@app.post('/api/chat')
|
||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||
prompt = request.prompt.strip()
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
with state.lock():
|
||||
agent = state.agent
|
||||
if request.resume_session_id is not None:
|
||||
try:
|
||||
stored = load_agent_session(
|
||||
request.resume_session_id,
|
||||
directory=state.session_directory,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail='Session to resume not found',
|
||||
)
|
||||
result = agent.resume(prompt, stored)
|
||||
else:
|
||||
result = agent.run(prompt)
|
||||
return _serialize_run_result(result)
|
||||
|
||||
try:
|
||||
payload = await asyncio.to_thread(_run)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # surface the error in the UI
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
'error': str(exc),
|
||||
'error_type': type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return payload
|
||||
|
||||
@app.post('/api/clear')
|
||||
async def clear_state() -> dict[str, Any]:
|
||||
with state.lock():
|
||||
state.agent.clear_runtime_state()
|
||||
return state.snapshot()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _serialize_run_result(result: Any) -> dict[str, Any]:
|
||||
return {
|
||||
'final_output': result.final_output,
|
||||
'turns': result.turns,
|
||||
'tool_calls': result.tool_calls,
|
||||
'transcript': [_normalize_transcript_entry(entry) for entry in result.transcript],
|
||||
'session_id': result.session_id,
|
||||
'usage': result.usage.to_dict(),
|
||||
'total_cost_usd': result.total_cost_usd,
|
||||
'stop_reason': result.stop_reason,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_transcript_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
'role': entry.get('role', ''),
|
||||
'content': entry.get('content', ''),
|
||||
}
|
||||
for key in ('name', 'tool_call_id', 'tool_calls', 'metadata', 'message_id'):
|
||||
if key in entry and entry[key] not in (None, '', [], {}):
|
||||
out[key] = entry[key]
|
||||
return out
|
||||
|
||||
|
||||
def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
|
||||
return {
|
||||
'session_id': stored.session_id,
|
||||
'turns': stored.turns,
|
||||
'tool_calls': stored.tool_calls,
|
||||
'messages': [_normalize_transcript_entry(dict(m)) for m in stored.messages],
|
||||
'usage': stored.usage,
|
||||
'total_cost_usd': stored.total_cost_usd,
|
||||
'model': stored.model_config.get('model'),
|
||||
}
|
||||
Reference in New Issue
Block a user