Files
ciregenzandClaude Opus 4.6 b6f45e8412 [eric] multi-provider agent loop, PostHog analytics, settings overhaul
Multi-provider support (WIP - not fully tested):
- Owned agent loop replacing claude_agent_sdk (agent_loop.py, mcp_client.py)
- Provider adapters: Anthropic (native), OpenAI-compat (any endpoint), Gemini (native + schema cleaning)
- 19 models across 9 providers (Anthropic, OpenAI, Google, xAI, Meta, DeepSeek, Mistral, Qwen, Cohere)
- OpenRouter integration for 300+ models via single API key
- Builtin tool reimplementations (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch, AskUserQuestion)
- Standalone MCP client manager (stdio/sse/http)
- Frontend: grouped model dropdown, provider selection, dynamic context windows

Analytics (tested):
- PostHog integration as single analytics source
- Tracks: app.opened, session.started/completed, tool.called, tool.approval_resolved, error.occurred
- Rich session data: user messages, assistant messages, session titles, tools used, MCP servers, task categories
- PostHog dashboard with 14 insights created via API
- Usage stats in Settings (Usage tab) with pixel-art bars

Settings (tested):
- 4 tabs: General, Models, Usage, Commands
- Model Providers tab with OpenRouter (recommended), Anthropic, OpenAI, Google key fields
- "Get key" links for each provider
- Usage tab with session/cost/tool stats + analytics opt-in toggle
- analytics_opt_in defaults to true, installation_id auto-generated

Merged haik/updates-v1 (tested):
- Sub-agent spawning, chat branching, browser control improvements
- Settings: auto_select_mode, expand_new_chats, auto_reveal_sub_agents, dev_mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:50:21 -07:00

133 lines
4.1 KiB
Python

import json
import os
import tempfile
import logging
from contextlib import asynccontextmanager
from fastapi import HTTPException, Query, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional
from backend.config.Apps import SubApp
from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT
logger = logging.getLogger(__name__)
from backend.config.paths import SETTINGS_DIR as DATA_DIR
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
@asynccontextmanager
async def settings_lifespan():
os.makedirs(DATA_DIR, exist_ok=True)
yield
settings = SubApp("settings", settings_lifespan)
def load_settings() -> AppSettings:
"""Load settings from JSON file, returning defaults if not found."""
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE) as f:
settings = AppSettings(**json.load(f))
if settings.default_system_prompt is None:
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
return settings
return AppSettings()
def _save_settings(settings: AppSettings):
"""Persist settings to JSON file."""
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings.model_dump(), f, indent=2)
@settings.router.get("")
async def get_settings():
return load_settings().model_dump()
@settings.router.put("")
async def update_settings(body: AppSettings):
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(body.model_dump(), f, indent=2)
return {"ok": True, "settings": body.model_dump()}
@settings.router.get("/default-system-prompt")
async def get_default_system_prompt():
return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT}
@settings.router.post("/reset-system-prompt")
async def reset_system_prompt():
current = load_settings()
current.default_system_prompt = DEFAULT_SYSTEM_PROMPT
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(current.model_dump(), f, indent=2)
return {"ok": True, "settings": current.model_dump()}
class BrowseResponse(BaseModel):
current: str
parent: Optional[str]
directories: list[str]
files: list[str]
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "self-swarm-uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
@settings.router.post("/upload-files")
async def upload_files(files: list[UploadFile] = File(...)):
"""Accept dropped files, save them, and return their server-side paths."""
results = []
for f in files:
safe_name = os.path.basename(f.filename or "untitled")
dest = os.path.join(UPLOAD_DIR, safe_name)
counter = 1
base, ext = os.path.splitext(safe_name)
while os.path.exists(dest):
dest = os.path.join(UPLOAD_DIR, f"{base}_{counter}{ext}")
counter += 1
contents = await f.read()
with open(dest, "wb") as fh:
fh.write(contents)
results.append({"path": dest, "name": safe_name, "size": len(contents)})
return JSONResponse({"files": results})
@settings.router.get("/browse-directories")
async def browse_directories(path: str = Query(default="")) -> BrowseResponse:
target = path.strip() if path.strip() else os.path.expanduser("~")
target = os.path.expanduser(target)
target = os.path.abspath(target)
if not os.path.exists(target):
raise HTTPException(status_code=404, detail=f"Path not found: {target}")
if not os.path.isdir(target):
raise HTTPException(status_code=400, detail=f"Not a directory: {target}")
try:
entries = sorted(os.listdir(target))
except PermissionError:
raise HTTPException(status_code=403, detail=f"Permission denied: {target}")
visible = [e for e in entries if not e.startswith(".")]
directories = [e for e in visible if os.path.isdir(os.path.join(target, e))]
files = [e for e in visible if os.path.isfile(os.path.join(target, e))]
parent = os.path.dirname(target) if target != "/" else None
return BrowseResponse(current=target, parent=parent, directories=directories, files=files)