mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: Add global settings modal and simplify agent controls
Adds a settings system for global configuration with YOLO mode toggle and
model selection. Simplifies the agent control UI by removing redundant
status indicator and pause functionality.
## Settings System
- New SettingsModal with YOLO mode toggle and model selection
- Settings persisted in SQLite (registry.db) - shared across all projects
- Models fetched from API endpoint (/api/settings/models)
- Single source of truth for models in registry.py - easy to add new models
- Optimistic UI updates with rollback on error
## Agent Control Simplification
- Removed StatusIndicator ("STOPPED"/"RUNNING" label) - redundant
- Removed Pause/Resume buttons - just Start/Stop toggle now
- Start button shows flame icon with fiery gradient when YOLO mode enabled
## Code Review Fixes
- Added focus trap to SettingsModal for accessibility
- Fixed YOLO button color contrast (WCAG AA compliance)
- Added model validation to AgentStartRequest schema
- Added model to AgentStatus response
- Added aria-labels to all icon-only buttons
- Added role="radiogroup" to model selection
- Added loading indicator during settings save
- Added SQLite timeout (30s) and retry logic with exponential backoff
- Added thread-safe database engine initialization
- Added orphaned lock file cleanup on server startup
## Files Changed
- registry.py: Model config, Settings CRUD, SQLite improvements
- server/routers/settings.py: New settings API
- server/schemas.py: Settings schemas with validation
- server/services/process_manager.py: Model param, orphan cleanup
- ui/src/components/SettingsModal.tsx: New modal component
- ui/src/components/AgentControl.tsx: Simplified to Start/Stop only
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,11 +21,12 @@ from .routers import (
|
||||
features_router,
|
||||
filesystem_router,
|
||||
projects_router,
|
||||
settings_router,
|
||||
spec_creation_router,
|
||||
)
|
||||
from .schemas import SetupStatus
|
||||
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
|
||||
from .services.process_manager import cleanup_all_managers
|
||||
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
|
||||
from .websocket import project_websocket
|
||||
|
||||
# Paths
|
||||
@@ -36,7 +37,8 @@ UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown."""
|
||||
# Startup
|
||||
# Startup - clean up orphaned lock files from previous runs
|
||||
cleanup_orphaned_locks()
|
||||
yield
|
||||
# Shutdown - cleanup all running agents and assistant sessions
|
||||
await cleanup_all_managers()
|
||||
@@ -92,6 +94,7 @@ app.include_router(agent_router)
|
||||
app.include_router(spec_creation_router)
|
||||
app.include_router(filesystem_router)
|
||||
app.include_router(assistant_chat_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -10,6 +10,7 @@ from .assistant_chat import router as assistant_chat_router
|
||||
from .features import router as features_router
|
||||
from .filesystem import router as filesystem_router
|
||||
from .projects import router as projects_router
|
||||
from .settings import router as settings_router
|
||||
from .spec_creation import router as spec_creation_router
|
||||
|
||||
__all__ = [
|
||||
@@ -19,4 +20,5 @@ __all__ = [
|
||||
"spec_creation_router",
|
||||
"filesystem_router",
|
||||
"assistant_chat_router",
|
||||
"settings_router",
|
||||
]
|
||||
|
||||
@@ -26,6 +26,21 @@ def _get_project_path(project_name: str) -> Path:
|
||||
return get_project_path(project_name)
|
||||
|
||||
|
||||
def _get_settings_defaults() -> tuple[bool, str]:
|
||||
"""Get YOLO mode and model defaults from global settings."""
|
||||
import sys
|
||||
root = Path(__file__).parent.parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
|
||||
from registry import DEFAULT_MODEL, get_all_settings
|
||||
|
||||
settings = get_all_settings()
|
||||
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
||||
model = settings.get("model", DEFAULT_MODEL)
|
||||
return yolo_mode, model
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
||||
|
||||
# Root directory for process manager
|
||||
@@ -69,6 +84,7 @@ async def get_agent_status(project_name: str):
|
||||
pid=manager.pid,
|
||||
started_at=manager.started_at,
|
||||
yolo_mode=manager.yolo_mode,
|
||||
model=manager.model,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,7 +96,12 @@ async def start_agent(
|
||||
"""Start the agent for a project."""
|
||||
manager = get_project_manager(project_name)
|
||||
|
||||
success, message = await manager.start(yolo_mode=request.yolo_mode)
|
||||
# Get defaults from global settings if not provided in request
|
||||
default_yolo, default_model = _get_settings_defaults()
|
||||
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
||||
model = request.model if request.model else default_model
|
||||
|
||||
success, message = await manager.start(yolo_mode=yolo_mode, model=model)
|
||||
|
||||
return AgentActionResponse(
|
||||
success=success,
|
||||
|
||||
75
server/routers/settings.py
Normal file
75
server/routers/settings.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Settings Router
|
||||
===============
|
||||
|
||||
API endpoints for global settings management.
|
||||
Settings are stored in the registry database and shared across all projects.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
||||
|
||||
# Add root to path for registry import
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from registry import (
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_YOLO_MODE,
|
||||
get_all_settings,
|
||||
set_setting,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
def _parse_yolo_mode(value: str | None) -> bool:
|
||||
"""Parse YOLO mode string to boolean."""
|
||||
return (value or "false").lower() == "true"
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelsResponse)
|
||||
async def get_available_models():
|
||||
"""Get list of available models.
|
||||
|
||||
Frontend should call this to get the current list of models
|
||||
instead of hardcoding them.
|
||||
"""
|
||||
return ModelsResponse(
|
||||
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
||||
default=DEFAULT_MODEL,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsResponse)
|
||||
async def get_settings():
|
||||
"""Get current global settings."""
|
||||
all_settings = get_all_settings()
|
||||
|
||||
return SettingsResponse(
|
||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||
model=all_settings.get("model", DEFAULT_MODEL),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("", response_model=SettingsResponse)
|
||||
async def update_settings(update: SettingsUpdate):
|
||||
"""Update global settings."""
|
||||
if update.yolo_mode is not None:
|
||||
set_setting("yolo_mode", "true" if update.yolo_mode else "false")
|
||||
|
||||
if update.model is not None:
|
||||
set_setting("model", update.model)
|
||||
|
||||
# Return updated settings
|
||||
all_settings = get_all_settings()
|
||||
return SettingsResponse(
|
||||
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||
model=all_settings.get("model", DEFAULT_MODEL),
|
||||
)
|
||||
@@ -6,11 +6,20 @@ Request/Response models for the API endpoints.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# Import model constants from registry (single source of truth)
|
||||
_root = Path(__file__).parent.parent
|
||||
if str(_root) not in sys.path:
|
||||
sys.path.insert(0, str(_root))
|
||||
|
||||
from registry import AVAILABLE_MODELS, DEFAULT_MODEL, VALID_MODELS
|
||||
|
||||
# ============================================================================
|
||||
# Project Schemas
|
||||
# ============================================================================
|
||||
@@ -102,7 +111,16 @@ class FeatureListResponse(BaseModel):
|
||||
|
||||
class AgentStartRequest(BaseModel):
|
||||
"""Request schema for starting the agent."""
|
||||
yolo_mode: bool = False
|
||||
yolo_mode: bool | None = None # None means use global settings
|
||||
model: str | None = None # None means use global settings
|
||||
|
||||
@field_validator('model')
|
||||
@classmethod
|
||||
def validate_model(cls, v: str | None) -> str | None:
|
||||
"""Validate model is in the allowed list."""
|
||||
if v is not None and v not in VALID_MODELS:
|
||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||
return v
|
||||
|
||||
|
||||
class AgentStatus(BaseModel):
|
||||
@@ -111,6 +129,7 @@ class AgentStatus(BaseModel):
|
||||
pid: int | None = None
|
||||
started_at: datetime | None = None
|
||||
yolo_mode: bool = False
|
||||
model: str | None = None # Model being used by running agent
|
||||
|
||||
|
||||
class AgentActionResponse(BaseModel):
|
||||
@@ -239,3 +258,41 @@ class CreateDirectoryRequest(BaseModel):
|
||||
"""Request to create a new directory."""
|
||||
parent_path: str
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Settings Schemas
|
||||
# ============================================================================
|
||||
|
||||
# Note: VALID_MODELS and DEFAULT_MODEL are imported from registry at the top of this file
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
"""Information about an available model."""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
"""Response schema for global settings."""
|
||||
yolo_mode: bool = False
|
||||
model: str = DEFAULT_MODEL
|
||||
|
||||
|
||||
class ModelsResponse(BaseModel):
|
||||
"""Response schema for available models list."""
|
||||
models: list[ModelInfo]
|
||||
default: str
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""Request schema for updating global settings."""
|
||||
yolo_mode: bool | None = None
|
||||
model: str | None = None
|
||||
|
||||
@field_validator('model')
|
||||
@classmethod
|
||||
def validate_model(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in VALID_MODELS:
|
||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||
return v
|
||||
|
||||
@@ -74,6 +74,7 @@ class AgentProcessManager:
|
||||
self.started_at: datetime | None = None
|
||||
self._output_task: asyncio.Task | None = None
|
||||
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
||||
self.model: str | None = None # Model being used
|
||||
|
||||
# Support multiple callbacks (for multiple WebSocket clients)
|
||||
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
||||
@@ -214,12 +215,13 @@ class AgentProcessManager:
|
||||
self.status = "stopped"
|
||||
self._remove_lock()
|
||||
|
||||
async def start(self, yolo_mode: bool = False) -> tuple[bool, str]:
|
||||
async def start(self, yolo_mode: bool = False, model: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Start the agent as a subprocess.
|
||||
|
||||
Args:
|
||||
yolo_mode: If True, run in YOLO mode (no browser testing)
|
||||
model: Model to use (e.g., claude-opus-4-5-20251101)
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
@@ -230,8 +232,9 @@ class AgentProcessManager:
|
||||
if not self._check_lock():
|
||||
return False, "Another agent instance is already running for this project"
|
||||
|
||||
# Store YOLO mode for status queries
|
||||
# Store for status queries
|
||||
self.yolo_mode = yolo_mode
|
||||
self.model = model
|
||||
|
||||
# Build command - pass absolute path to project directory
|
||||
cmd = [
|
||||
@@ -241,6 +244,10 @@ class AgentProcessManager:
|
||||
str(self.project_dir.resolve()),
|
||||
]
|
||||
|
||||
# Add --model flag if model is specified
|
||||
if model:
|
||||
cmd.extend(["--model", model])
|
||||
|
||||
# Add --yolo flag if YOLO mode is enabled
|
||||
if yolo_mode:
|
||||
cmd.append("--yolo")
|
||||
@@ -306,6 +313,7 @@ class AgentProcessManager:
|
||||
self.process = None
|
||||
self.started_at = None
|
||||
self.yolo_mode = False # Reset YOLO mode
|
||||
self.model = None # Reset model
|
||||
|
||||
return True, "Agent stopped"
|
||||
except Exception as e:
|
||||
@@ -387,6 +395,7 @@ class AgentProcessManager:
|
||||
"pid": self.pid,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"yolo_mode": self.yolo_mode,
|
||||
"model": self.model,
|
||||
}
|
||||
|
||||
|
||||
@@ -423,3 +432,73 @@ async def cleanup_all_managers() -> None:
|
||||
|
||||
with _managers_lock:
|
||||
_managers.clear()
|
||||
|
||||
|
||||
def cleanup_orphaned_locks() -> int:
|
||||
"""
|
||||
Clean up orphaned lock files from previous server runs.
|
||||
|
||||
Scans all registered projects for .agent.lock files and removes them
|
||||
if the referenced process is no longer running.
|
||||
|
||||
Returns:
|
||||
Number of orphaned lock files cleaned up
|
||||
"""
|
||||
import sys
|
||||
root = Path(__file__).parent.parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
|
||||
from registry import list_registered_projects
|
||||
|
||||
cleaned = 0
|
||||
try:
|
||||
projects = list_registered_projects()
|
||||
for name, info in projects.items():
|
||||
project_path = Path(info.get("path", ""))
|
||||
if not project_path.exists():
|
||||
continue
|
||||
|
||||
lock_file = project_path / ".agent.lock"
|
||||
if not lock_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
pid_str = lock_file.read_text().strip()
|
||||
pid = int(pid_str)
|
||||
|
||||
# Check if process is still running
|
||||
if psutil.pid_exists(pid):
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
cmdline = " ".join(proc.cmdline())
|
||||
if "autonomous_agent_demo.py" in cmdline:
|
||||
# Process is still running, don't remove
|
||||
logger.info(
|
||||
"Found running agent for project '%s' (PID %d)",
|
||||
name, pid
|
||||
)
|
||||
continue
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
# Process not running or not our agent - remove stale lock
|
||||
lock_file.unlink(missing_ok=True)
|
||||
cleaned += 1
|
||||
logger.info("Removed orphaned lock file for project '%s'", name)
|
||||
|
||||
except (ValueError, OSError) as e:
|
||||
# Invalid lock file content - remove it
|
||||
logger.warning(
|
||||
"Removing invalid lock file for project '%s': %s", name, e
|
||||
)
|
||||
lock_file.unlink(missing_ok=True)
|
||||
cleaned += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error during orphan cleanup: %s", e)
|
||||
|
||||
if cleaned:
|
||||
logger.info("Cleaned up %d orphaned lock file(s)", cleaned)
|
||||
|
||||
return cleaned
|
||||
|
||||
Reference in New Issue
Block a user