...
+ result_blocks = re.findall(
+ r']*class="[^"]*result|$)',
+ body,
+ flags=re.DOTALL,
+ )
+
+ entries: list[str] = []
+ for block in result_blocks:
+ if len(entries) >= num_results:
+ break
+
+ # Title + URL
+ link_match = re.search(
+ r'
]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)',
+ block,
+ flags=re.DOTALL,
+ )
+ if not link_match:
+ continue
+
+ raw_url = html.unescape(link_match.group(1))
+ title = _strip_html(link_match.group(2)).strip()
+
+ # Snippet
+ snippet_match = re.search(
+ r'
]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)',
+ block,
+ flags=re.DOTALL,
+ )
+ snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
+
+ # DuckDuckGo wraps URLs through a redirect; try to extract the real URL
+ real_url_match = re.search(r"uddg=([^&]+)", raw_url)
+ if real_url_match:
+ from urllib.parse import unquote
+ url = unquote(real_url_match.group(1))
+ else:
+ url = raw_url
+
+ entry = f"[{len(entries) + 1}] {title}\n {url}"
+ if snippet:
+ entry += f"\n {snippet}"
+ entries.append(entry)
+
+ return "\n\n".join(entries)
+
+
+# ───────────────────────────────────────────────────────────────────────────
+# WebFetchTool
+# ───────────────────────────────────────────────────────────────────────────
+
+
+class WebFetchTool(BaseTool):
+ name = "WebFetch"
+ description = (
+ "Fetch the contents of a URL and return the extracted text. "
+ "HTML is stripped to plain text. Output is truncated to ~100 KB."
+ )
+
+ def get_schema(self) -> dict:
+ return {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "The URL to fetch.",
+ },
+ "prompt": {
+ "type": "string",
+ "description": "Optional prompt/context describing what information to look for.",
+ },
+ },
+ "required": ["url"],
+ "additionalProperties": False,
+ }
+
+ async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
+ url: str = input_data["url"]
+ prompt: str | None = input_data.get("prompt")
+
+ try:
+ async with httpx.AsyncClient(
+ timeout=_HTTP_TIMEOUT,
+ follow_redirects=True,
+ headers={"User-Agent": _USER_AGENT},
+ ) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ except httpx.HTTPStatusError as exc:
+ return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
+ except Exception as exc:
+ return [{"type": "text", "text": f"Error fetching {url}: {exc}"}]
+
+ content_type = resp.headers.get("content-type", "")
+
+ if "html" in content_type or resp.text.strip().startswith(" list[dict]:
+ """Load all persisted session JSON files."""
+ results = []
+ if not os.path.exists(SESSIONS_DIR):
+ return results
+ for fname in os.listdir(SESSIONS_DIR):
+ if fname.endswith(".json"):
+ try:
+ with open(os.path.join(SESSIONS_DIR, fname)) as f:
+ results.append(json.load(f))
+ except Exception:
+ pass
+ return results
+
+
+@analytics.router.get("/usage-summary")
+async def usage_summary():
+ """Compute usage stats from persisted sessions for the Settings page."""
+ from backend.apps.agents.agent_manager import agent_manager
+
+ # Combine persisted + active sessions
+ sessions = _load_all_sessions()
+ for s in agent_manager.get_all_sessions():
+ sessions.append(s.model_dump(mode="json"))
+
+ total_sessions = len(sessions)
+ total_cost = sum(s.get("cost_usd", 0) for s in sessions)
+ total_messages = 0
+ total_tool_calls = 0
+ total_duration = 0.0
+ model_counts: Counter = Counter()
+ provider_counts: Counter = Counter()
+ tool_counts: Counter = Counter()
+ status_counts: Counter = Counter()
+
+ for s in sessions:
+ messages = s.get("messages", [])
+ user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
+ tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
+ total_messages += len(user_msgs)
+ total_tool_calls += len(tool_msgs)
+
+ model_counts[s.get("model", "unknown")] += 1
+ provider_counts[s.get("provider", "anthropic")] += 1
+ status_counts[s.get("status", "unknown")] += 1
+
+ # Duration
+ created = s.get("created_at")
+ closed = s.get("closed_at")
+ if created and closed:
+ try:
+ from datetime import datetime
+ fmt = "%Y-%m-%dT%H:%M:%S"
+ c_str = created[:19]
+ cl_str = closed[:19]
+ dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
+ if dur > 0:
+ total_duration += dur
+ except Exception:
+ pass
+
+ # Count individual tools
+ for m in tool_msgs:
+ content = m.get("content", {})
+ if isinstance(content, dict):
+ tool_name = content.get("tool", "")
+ if tool_name:
+ tool_counts[tool_name] += 1
+
+ avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
+ avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
+ completed = status_counts.get("completed", 0)
+ completion_rate = completed / total_sessions if total_sessions > 0 else 0
+
+ return {
+ "total_sessions": total_sessions,
+ "total_cost_usd": round(total_cost, 4),
+ "total_messages": total_messages,
+ "total_tool_calls": total_tool_calls,
+ "avg_duration_seconds": round(avg_duration, 1),
+ "avg_cost_per_session": round(avg_cost, 4),
+ "completion_rate": round(completion_rate, 3),
+ "models_used": dict(model_counts.most_common(10)),
+ "providers_used": dict(provider_counts.most_common(10)),
+ "top_tools": dict(tool_counts.most_common(15)),
+ "status_breakdown": dict(status_counts),
+ }
+
+
+@analytics.router.get("/status")
+async def analytics_status():
+ return {"status": "posthog", "enabled": True}
diff --git a/backend/apps/analytics/collector.py b/backend/apps/analytics/collector.py
new file mode 100644
index 00000000..dddcedc5
--- /dev/null
+++ b/backend/apps/analytics/collector.py
@@ -0,0 +1,123 @@
+"""PostHog-only analytics collector.
+
+All events go directly to PostHog. No local SQLite storage.
+
+Usage from any module:
+ from backend.apps.analytics.collector import record
+ record("session.started", {"model": "opus"}, session_id="abc123")
+"""
+
+import logging
+import platform
+from uuid import uuid4
+
+from posthog import Posthog
+
+logger = logging.getLogger(__name__)
+
+POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB"
+POSTHOG_HOST = "https://us.i.posthog.com"
+
+_posthog: Posthog | None = None
+_installation_id: str | None = None
+
+
+def init():
+ """Initialise PostHog. Called once at app startup."""
+ global _posthog
+ if _posthog is None:
+ _posthog = Posthog(
+ project_api_key=POSTHOG_API_KEY,
+ host=POSTHOG_HOST,
+ )
+ return _posthog
+
+
+def shutdown():
+ """Flush and close. Called at app shutdown."""
+ global _posthog
+ if _posthog:
+ try:
+ _posthog.shutdown()
+ except Exception:
+ pass
+ _posthog = None
+
+
+def _get_installation_id() -> str:
+ """Get or create a stable anonymous installation ID."""
+ global _installation_id
+ if _installation_id:
+ return _installation_id
+ try:
+ from backend.apps.settings.settings import load_settings, _save_settings
+ settings = load_settings()
+ iid = getattr(settings, "installation_id", None)
+ if not iid:
+ iid = uuid4().hex
+ settings.installation_id = iid
+ _save_settings(settings)
+ _installation_id = iid
+ except Exception:
+ _installation_id = uuid4().hex
+ return _installation_id
+
+
+def _is_opted_in() -> bool:
+ """Check if user has opted in to analytics."""
+ try:
+ from backend.apps.settings.settings import load_settings
+ return getattr(load_settings(), "analytics_opt_in", True)
+ except Exception:
+ return True
+
+
+def record(
+ event_type: str,
+ properties: dict | None = None,
+ session_id: str | None = None,
+ dashboard_id: str | None = None,
+):
+ """Record an analytics event to PostHog."""
+ if not _posthog or not _is_opted_in():
+ return
+
+ props = {**(properties or {})}
+ if session_id:
+ props["session_id"] = session_id
+ if dashboard_id:
+ props["dashboard_id"] = dashboard_id
+ props["os"] = platform.system()
+ props["platform"] = platform.platform()
+
+ try:
+ _posthog.capture(
+ event_type,
+ distinct_id=_get_installation_id(),
+ properties=props,
+ )
+ except Exception as e:
+ logger.debug(f"PostHog capture failed (non-critical): {e}")
+
+
+def identify(extra_properties: dict | None = None):
+ """Identify the current installation with properties."""
+ if not _posthog or not _is_opted_in():
+ return
+
+ try:
+ _posthog.identify(
+ _get_installation_id(),
+ properties={
+ "os": platform.system(),
+ "platform": platform.platform(),
+ **(extra_properties or {}),
+ },
+ )
+ except Exception as e:
+ logger.debug(f"PostHog identify failed (non-critical): {e}")
+
+
+def get_collector():
+ """Backward compat — returns None since we no longer have a local collector."""
+ return None
diff --git a/backend/apps/analytics/models.py b/backend/apps/analytics/models.py
new file mode 100644
index 00000000..a8696dde
--- /dev/null
+++ b/backend/apps/analytics/models.py
@@ -0,0 +1,37 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class AnalyticsEvent(BaseModel):
+ id: Optional[int] = None
+ timestamp: str
+ event_type: str
+ properties: dict
+ session_id: Optional[str] = None
+ dashboard_id: Optional[str] = None
+
+
+class UsageSummary(BaseModel):
+ total_sessions: int = 0
+ total_cost_usd: float = 0.0
+ total_messages: int = 0
+ total_tool_calls: int = 0
+ avg_session_duration_seconds: float = 0.0
+ session_completion_rate: float = 0.0
+ approval_rate: float = 0.0
+ models_used: dict[str, int] = {}
+ modes_used: dict[str, int] = {}
+ top_tools: list[list] = []
+
+
+class TimeSeriesPoint(BaseModel):
+ date: str
+ value: float
+
+
+class ExportPayload(BaseModel):
+ export_version: str = "1.0"
+ exported_at: str = ""
+ app_version: str = "unknown"
+ period: dict = {}
+ summary: dict = {}
diff --git a/backend/apps/auth/__init__.py b/backend/apps/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/auth/auth.py b/backend/apps/auth/auth.py
new file mode 100644
index 00000000..1110fa02
--- /dev/null
+++ b/backend/apps/auth/auth.py
@@ -0,0 +1,168 @@
+"""Auth SubApp — handles managed-mode authentication with Open Swarm service.
+
+All endpoints are currently stubbed with mock responses so the full UI flow
+works end-to-end without a real proxy server.
+"""
+
+from contextlib import asynccontextmanager
+from uuid import uuid4
+
+from pydantic import BaseModel
+from typing import Optional
+
+from backend.config.Apps import SubApp
+from backend.apps.settings.settings import load_settings, update_settings
+
+
+# ── Models ──────────────────────────────────────────────────────────────
+
+class LoginRequest(BaseModel):
+ email: str
+ password: str
+
+
+class GoogleCallbackRequest(BaseModel):
+ code: str
+ redirect_uri: str = ""
+
+
+class LoginResponse(BaseModel):
+ ok: bool
+ token: Optional[str] = None
+ email: Optional[str] = None
+ proxy_url: Optional[str] = None
+ error: Optional[str] = None
+
+
+class ValidateResponse(BaseModel):
+ valid: bool
+ email: Optional[str] = None
+
+
+class UsageResponse(BaseModel):
+ used_usd: float
+ quota_usd: float
+ reset_date: str
+
+
+# ── SubApp setup ────────────────────────────────────────────────────────
+
+@asynccontextmanager
+async def _lifespan():
+ yield
+
+auth = SubApp("auth", _lifespan)
+router = auth.router
+
+
+# ── Endpoints ───────────────────────────────────────────────────────────
+
+@router.post("/login")
+async def login(req: LoginRequest) -> LoginResponse:
+ """Authenticate with email + password.
+
+ TODO: replace with real API call to Open Swarm auth server.
+ """
+ if not req.email or not req.password:
+ return LoginResponse(ok=False, error="Email and password are required")
+
+ # Stub: generate a mock token for any valid-looking input
+ mock_token = f"osw_{uuid4().hex}"
+ proxy_url = "https://api.openswarm.ai"
+
+ # Persist credentials to settings
+ settings = load_settings()
+ settings.connection_mode = "managed"
+ settings.openswarm_auth_token = mock_token
+ settings.openswarm_proxy_url = proxy_url
+ settings.openswarm_user_email = req.email
+ await _save_settings(settings)
+
+ return LoginResponse(ok=True, token=mock_token, email=req.email, proxy_url=proxy_url)
+
+
+@router.post("/google-url")
+async def google_auth_url() -> dict:
+ """Return the Google OAuth authorize URL.
+
+ TODO: replace with real Google OAuth URL construction.
+ """
+ # Stub: return a placeholder URL
+ return {
+ "url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=PLACEHOLDER&response_type=code&scope=email+profile&redirect_uri=http://localhost:8324/api/auth/google-callback"
+ }
+
+
+@router.post("/google-callback")
+async def google_callback(req: GoogleCallbackRequest) -> LoginResponse:
+ """Exchange Google OAuth code for a session token.
+
+ TODO: replace with real OAuth code exchange + Open Swarm auth server call.
+ """
+ if not req.code:
+ return LoginResponse(ok=False, error="Authorization code is required")
+
+ # Stub: generate a mock token
+ mock_token = f"osw_{uuid4().hex}"
+ proxy_url = "https://api.openswarm.ai"
+ mock_email = "user@gmail.com"
+
+ settings = load_settings()
+ settings.connection_mode = "managed"
+ settings.openswarm_auth_token = mock_token
+ settings.openswarm_proxy_url = proxy_url
+ settings.openswarm_user_email = mock_email
+ await _save_settings(settings)
+
+ return LoginResponse(ok=True, token=mock_token, email=mock_email, proxy_url=proxy_url)
+
+
+@router.post("/validate")
+async def validate_token() -> ValidateResponse:
+ """Check if the stored auth token is still valid.
+
+ TODO: replace with real validation call to Open Swarm auth server.
+ """
+ settings = load_settings()
+ if not settings.openswarm_auth_token:
+ return ValidateResponse(valid=False)
+
+ # Stub: always return valid
+ return ValidateResponse(valid=True, email=settings.openswarm_user_email)
+
+
+@router.post("/logout")
+async def logout() -> dict:
+ """Clear managed-mode credentials from settings."""
+ settings = load_settings()
+ settings.connection_mode = "own_key"
+ settings.openswarm_auth_token = None
+ settings.openswarm_proxy_url = None
+ settings.openswarm_user_email = None
+ await _save_settings(settings)
+ return {"ok": True}
+
+
+@router.get("/usage")
+async def get_usage() -> UsageResponse:
+ """Fetch usage and quota information for the current managed-mode user.
+
+ TODO: replace with real API call to Open Swarm proxy server.
+ """
+ settings = load_settings()
+ if not settings.openswarm_auth_token:
+ return UsageResponse(used_usd=0, quota_usd=0, reset_date="")
+
+ # Stub: return mock usage data
+ return UsageResponse(used_usd=0, quota_usd=50, reset_date="2026-04-01")
+
+
+# ── Helpers ─────────────────────────────────────────────────────────────
+
+async def _save_settings(settings):
+ """Persist settings to disk (reuses the settings module's update logic)."""
+ import json
+ from backend.apps.settings.settings import SETTINGS_FILE
+
+ with open(SETTINGS_FILE, "w") as f:
+ json.dump(settings.model_dump(), f, indent=2)
diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py
index 29d32af3..a7a05d06 100644
--- a/backend/apps/dashboards/dashboards.py
+++ b/backend/apps/dashboards/dashboards.py
@@ -123,8 +123,10 @@ async def list_dashboards():
@dashboards.router.post("/create")
async def create_dashboard(body: DashboardCreate):
+ from backend.apps.analytics.collector import record as _analytics
dashboard = Dashboard(name=body.name)
_save(dashboard)
+ _analytics("dashboard.created", {}, dashboard_id=dashboard.id)
return dashboard.model_dump(mode="json")
@@ -151,13 +153,10 @@ async def generate_name(dashboard_id: str):
fallback = prompts[0][:40]
try:
- import anthropic
from backend.apps.settings.settings import load_settings
+ from backend.apps.settings.credentials import get_anthropic_client
global_settings = load_settings()
- if not global_settings.anthropic_api_key:
- raise ValueError("API key not configured")
-
- client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
+ client = get_anthropic_client(global_settings)
if len(prompts) == 1:
system = (
diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py
index 22b3bdf7..cb860f89 100644
--- a/backend/apps/outputs/outputs.py
+++ b/backend/apps/outputs/outputs.py
@@ -32,13 +32,11 @@ def _resolve_model(short_name: str) -> str:
def _get_anthropic_client():
- """Create an AsyncAnthropic client using the API key from app settings."""
- import anthropic
+ """Create an AsyncAnthropic client using credentials from app settings."""
+ from backend.apps.settings.credentials import get_anthropic_client
settings = load_settings()
- if not settings.anthropic_api_key:
- raise ValueError("Anthropic API key not configured. Set it in Settings.")
- return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
+ return get_anthropic_client(settings)
def _validate_against_schema(data: dict, schema: dict) -> str | None:
diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py
new file mode 100644
index 00000000..985ac117
--- /dev/null
+++ b/backend/apps/settings/credentials.py
@@ -0,0 +1,111 @@
+"""Centralized credential resolution for LLM API calls.
+
+Supports multiple providers: Anthropic (native), OpenAI, Gemini,
+OpenRouter, and user-configured custom providers.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import anthropic
+ from backend.apps.settings.models import AppSettings
+
+OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
+
+
+def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None:
+ """Raise ValueError if credentials are missing for the given provider."""
+ if provider == "anthropic":
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ if not getattr(settings, "openswarm_auth_token", None):
+ raise ValueError(
+ "Open Swarm account not connected. Sign in via Settings → API."
+ )
+ else:
+ if not settings.anthropic_api_key:
+ raise ValueError(
+ "Anthropic API key not configured. Set it in Settings."
+ )
+ elif provider == "openai":
+ if not settings.openai_api_key:
+ raise ValueError("OpenAI API key not configured. Set it in Settings.")
+ elif provider == "gemini":
+ if not getattr(settings, "google_api_key", None):
+ raise ValueError("Google API key not configured. Set it in Settings.")
+ elif provider == "openrouter":
+ if not getattr(settings, "openrouter_api_key", None):
+ raise ValueError("OpenRouter API key not configured. Set it in Settings.")
+ else:
+ # Custom provider — check if it exists in custom_providers
+ for cp in getattr(settings, "custom_providers", []):
+ if cp.name == provider:
+ return # Custom providers may have empty api_key (e.g. local Ollama)
+ raise ValueError(f"Provider '{provider}' not found in settings.")
+
+
+def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
+ """Return credential dict for a specific provider."""
+ validate_credentials(settings, provider)
+
+ if provider == "anthropic":
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ return {
+ "auth_token": getattr(settings, "openswarm_auth_token", "") or "",
+ "base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
+ }
+ return {"api_key": settings.anthropic_api_key or ""}
+
+ if provider == "openai":
+ return {"api_key": settings.openai_api_key or ""}
+
+ if provider == "gemini":
+ return {"api_key": getattr(settings, "google_api_key", "") or ""}
+
+ if provider == "openrouter":
+ return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
+
+ # Custom provider
+ for cp in getattr(settings, "custom_providers", []):
+ if cp.name == provider:
+ return {"api_key": cp.api_key, "base_url": cp.base_url}
+
+ raise ValueError(f"No credentials for provider: {provider}")
+
+
+# ---------------------------------------------------------------------------
+# Legacy helpers (kept for backward compat during migration)
+# ---------------------------------------------------------------------------
+
+def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
+ """Return the env dict for ClaudeAgentOptions based on connection mode.
+
+ DEPRECATED: Use create_provider() from providers.registry instead.
+ """
+ validate_credentials(settings, "anthropic")
+
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
+ return {
+ "ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
+ "ANTHROPIC_BASE_URL": proxy_url,
+ }
+
+ return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
+
+
+def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
+ """Return a configured AsyncAnthropic client based on connection mode."""
+ import anthropic
+
+ validate_credentials(settings, "anthropic")
+
+ if getattr(settings, "connection_mode", "own_key") == "managed":
+ proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
+ return anthropic.AsyncAnthropic(
+ auth_token=getattr(settings, "openswarm_auth_token", None),
+ base_url=proxy_url,
+ )
+
+ return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index 34a589df..b6760d8c 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -1,5 +1,5 @@
-from pydantic import BaseModel
-from typing import Optional
+from pydantic import BaseModel, Field
+from typing import Optional, Any
DEFAULT_SYSTEM_PROMPT = (
'"Ask the user as many follow ups as needed in order to eliminate any possible ambiguity. '
@@ -28,9 +28,22 @@ class AppSettings(BaseModel):
elevenlabs_api_key: Optional[str] = None
deepgram_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
+ google_api_key: Optional[str] = None
+ openrouter_api_key: Optional[str] = None
+ custom_providers: list["CustomProvider"] = Field(default_factory=list)
webhook_base_url: Optional[str] = None
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
+ # Analytics: opted in by default, user can toggle off
+ analytics_opt_in: bool = True
+ installation_id: Optional[str] = None
+
+
+class CustomProvider(BaseModel):
+ name: str
+ base_url: str
+ api_key: str = ""
+ models: list[dict[str, Any]] = Field(default_factory=list)
diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py
index e590d9a4..d0c8ca41 100644
--- a/backend/apps/settings/settings.py
+++ b/backend/apps/settings/settings.py
@@ -38,6 +38,13 @@ def load_settings() -> AppSettings:
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()
diff --git a/backend/config/paths.py b/backend/config/paths.py
index 1da2d323..62259a68 100644
--- a/backend/config/paths.py
+++ b/backend/config/paths.py
@@ -37,5 +37,6 @@ DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
CHANNELS_DIR = os.path.join(DATA_ROOT, "channels")
CHANNELS_SESSIONS_DIR = os.path.join(DATA_ROOT, "channels", "sessions")
+ANALYTICS_DIR = os.path.join(DATA_ROOT, "analytics")
BACKEND_DIR = _BACKEND_DIR
diff --git a/backend/main.py b/backend/main.py
index 1320084c..9f539480 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -20,11 +20,13 @@ from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.channels.channels import channels
+from backend.apps.analytics.analytics import analytics
+from backend.apps.auth.auth import auth
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
-main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, channels])
+main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, channels, analytics, auth])
app = main_app.app
app.add_middleware(
@@ -52,6 +54,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
payload.get("prompt", ""),
mode=payload.get("mode"),
model=payload.get("model"),
+ provider=payload.get("provider"),
images=payload.get("images"),
)
elif event == "agent:approval_response":
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 6351bcb0..99ef9c1e 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,5 +1,8 @@
anthropic
-claude-agent-sdk
+openai
+google-genai
+mcp
+posthog
jsonschema
fastapi[standard]
pydantic==2.10.5
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 50e051d8..1fbe8af1 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -5,6 +5,7 @@ import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mu
import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
+import { fetchModels } from '@/shared/state/modelsSlice';
import {
setAppVersion,
setUpdateAvailable,
@@ -23,6 +24,8 @@ import Modes from './pages/Modes/Modes';
import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
import Channels from './pages/Channels/Channels';
+import Analytics from './pages/Analytics/Analytics';
+import AnalyticsOptIn from './components/AnalyticsOptIn';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -161,6 +164,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(fetchSettings());
+ dispatch(fetchModels());
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
@@ -230,8 +234,10 @@ const ThemedApp: React.FC = () => {
} />
} />
} />
+
} />
+
diff --git a/frontend/src/app/components/AnalyticsOptIn.tsx b/frontend/src/app/components/AnalyticsOptIn.tsx
new file mode 100644
index 00000000..b96701dd
--- /dev/null
+++ b/frontend/src/app/components/AnalyticsOptIn.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import Paper from '@mui/material/Paper';
+import { useAppDispatch, useAppSelector } from '@/shared/hooks';
+import { updateSettings } from '@/shared/state/settingsSlice';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const AnalyticsOptIn: React.FC = () => {
+ const c = useClaudeTokens();
+ const dispatch = useAppDispatch();
+ const settings = useAppSelector((s) => s.settings.data);
+ const loaded = useAppSelector((s) => s.settings.loaded);
+
+ if (!loaded || settings.analytics_opt_in !== null) return null;
+
+ const handleChoice = (optIn: boolean) => {
+ dispatch(updateSettings({ ...settings, analytics_opt_in: optIn }));
+ };
+
+ return (
+
+
+
+ Help improve OpenSwarm
+
+
+ Share anonymous usage statistics like session counts, feature usage, and model preferences.
+ No conversations, file paths, or personal information — ever.
+ You can change this anytime in Settings.
+
+
+
+
+
+
+
+ );
+};
+
+export default AnalyticsOptIn;
diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx
index cd3e51a0..95c78d60 100644
--- a/frontend/src/app/components/Layout/AppShell.tsx
+++ b/frontend/src/app/components/Layout/AppShell.tsx
@@ -24,6 +24,7 @@ import AddIcon from '@mui/icons-material/Add';
import SettingsIcon from '@mui/icons-material/Settings';
import ExtensionIcon from '@mui/icons-material/Extension';
import PhoneIcon from '@mui/icons-material/Phone';
+import BarChartIcon from '@mui/icons-material/BarChart';
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
@@ -33,6 +34,7 @@ import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
+import TalkModeOverlay from '@/app/components/TalkModeOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
@@ -69,6 +71,7 @@ const AppShell: React.FC = () => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [renamingDashboardId, setRenamingDashboardId] = useState
(null);
const [renameValue, setRenameValue] = useState('');
+ const [talkModeOpen, setTalkModeOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
try {
const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
@@ -231,6 +234,7 @@ const AppShell: React.FC = () => {
const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/');
const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
const isChannelsRoute = location.pathname === '/channels';
+ const isAnalyticsRoute = location.pathname === '/analytics';
const activeDashboardId = location.pathname.startsWith('/dashboard/')
? location.pathname.split('/dashboard/')[1]
: null;
@@ -959,6 +963,10 @@ const AppShell: React.FC = () => {
+ setTalkModeOpen(false)}
+ />
void;
+ sessionId?: string;
+}
+
+// ─── Pixel Face Canvas Renderer ──────────────────────────────────
+// Ported from face.html — all the draw logic in one hook.
+
+function usePixelFace(
+ canvasRef: React.RefObject,
+ faceState: FaceState,
+ size: number,
+) {
+ const stateRef = useRef('idle');
+ const animRef = useRef(0);
+
+ useEffect(() => {
+ stateRef.current = faceState;
+ }, [faceState]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const PX = Math.max(4, Math.floor(size / 30));
+ const COLS = Math.ceil(size / PX);
+ const ROWS = Math.ceil(size / PX);
+ canvas.width = COLS * PX;
+ canvas.height = ROWS * PX;
+
+ const BG = '#E8927A';
+ const EYE = '#1E1E1E';
+ const MOUTH = '#1E1E1E';
+
+ let breath = 0, talk = 0, think = 0, sleepZ = 0, heartP = 0;
+ let eyeH = 3, eyeHTarget = 3;
+ let mouthW = 2, mouthWTarget = 2;
+ let mouthH = 2, mouthHTarget = 2;
+ let eyeOffX = 0, eyeOffXTarget = 0;
+ let eyeOffY = 0, eyeOffYTarget = 0;
+ let blinkOpen = true, blinkCD = 120 + Math.random() * 200;
+ let doubleBlink = false;
+ let idleSinceInput = 0, sleepTransitioned = false;
+ let idleAction = 'none', idleActionTimer = 0;
+ let idleGlanceX = 0, idleGlanceY = 0;
+
+ const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
+
+ const px = (col: number, row: number, color: string) => {
+ ctx.fillStyle = color;
+ ctx.fillRect(col * PX, row * PX, PX, PX);
+ };
+
+ const pxRect = (x: number, y: number, w: number, h: number, color: string) => {
+ for (let r = 0; r < Math.round(h); r++)
+ for (let c = 0; c < Math.round(w); c++)
+ px(Math.round(x) + c, Math.round(y) + r, color);
+ };
+
+ function draw() {
+ const state = stateRef.current;
+
+ breath += 0.025;
+ talk += 0.3;
+ think += 0.025;
+ sleepZ += 0.012;
+ heartP += 0.05;
+ idleSinceInput++;
+
+ if (idleSinceInput > 2700 && state === 'idle' && !sleepTransitioned) {
+ stateRef.current = 'sleeping';
+ sleepTransitioned = true;
+ }
+
+ if (state === 'idle') {
+ idleActionTimer--;
+ blinkCD--;
+ if (blinkCD <= 0 && blinkOpen) { blinkOpen = false; blinkCD = 6; doubleBlink = Math.random() < 0.3; }
+ else if (!blinkOpen && blinkCD <= 0) { blinkOpen = true; blinkCD = doubleBlink ? 8 : 100 + Math.random() * 280; doubleBlink = false; }
+
+ if (idleActionTimer <= 0) {
+ const roll = Math.random();
+ if (roll < 0.3) { idleAction = 'glance'; idleGlanceX = Math.floor(Math.random() * 5) - 2; idleGlanceY = (Math.random() - 0.5) * 1.2; idleActionTimer = 50 + Math.random() * 120; }
+ else if (roll < 0.45) { idleAction = 'scan'; idleActionTimer = 180; }
+ else if (roll < 0.55) { idleAction = 'squint'; idleActionTimer = 35 + Math.random() * 40; }
+ else if (roll < 0.65) { idleAction = 'lookup'; idleActionTimer = 50 + Math.random() * 70; }
+ else { idleAction = 'none'; idleGlanceX = 0; idleGlanceY = 0; idleActionTimer = 60 + Math.random() * 200; }
+ }
+ }
+
+ if (state === 'talking' || state === 'thinking') {
+ blinkCD--;
+ if (blinkCD <= 0 && blinkOpen) { blinkOpen = false; blinkCD = 6; }
+ else if (!blinkOpen && blinkCD <= 0) { blinkOpen = true; blinkCD = 120 + Math.random() * 250; }
+ }
+ if (state !== 'idle' && state !== 'talking' && state !== 'thinking') blinkOpen = true;
+
+ const b = Math.sin(breath) * 0.3;
+ switch (state) {
+ case 'idle': {
+ eyeHTarget = blinkOpen ? 3 : 0; mouthWTarget = 2; mouthHTarget = 2;
+ let gx = 0, gy = b;
+ if (idleAction === 'glance') { gx = idleGlanceX; gy = idleGlanceY + b; }
+ else if (idleAction === 'scan') { const t = 1 - (idleActionTimer / 180); gx = Math.sin(t * Math.PI * 2) * 2.5; }
+ else if (idleAction === 'squint') { eyeHTarget = blinkOpen ? 2 : 0; gy = b + 0.3; }
+ else if (idleAction === 'lookup') { gy = -1.2 + b; }
+ eyeOffXTarget = gx; eyeOffYTarget = gy; break;
+ }
+ case 'happy': eyeHTarget = 1; mouthWTarget = 6; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b; break;
+ case 'thinking': eyeHTarget = blinkOpen ? 3 : 0; mouthWTarget = 2; mouthHTarget = 2; eyeOffXTarget = 2; eyeOffYTarget = b; break;
+ case 'talking': { eyeHTarget = blinkOpen ? 3 : 0; const open = Math.round(Math.abs(Math.sin(talk)) * 2 + 1); mouthWTarget = 4; mouthHTarget = open; eyeOffXTarget = 0; eyeOffYTarget = b; break; }
+ case 'surprised': eyeHTarget = 4; mouthWTarget = 3; mouthHTarget = 3; eyeOffXTarget = 0; eyeOffYTarget = b; break;
+ case 'sleeping': eyeHTarget = 1; mouthWTarget = 2; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b * 2; break;
+ case 'angry': eyeHTarget = 2; mouthWTarget = 6; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b * 0.3; break;
+ case 'love': eyeHTarget = 3; mouthWTarget = 2; mouthHTarget = 2; eyeOffXTarget = 0; eyeOffYTarget = b; break;
+ }
+
+ eyeH = lerp(eyeH, eyeHTarget, 0.18);
+ mouthW = lerp(mouthW, mouthWTarget, 0.15);
+ mouthH = lerp(mouthH, mouthHTarget, 0.2);
+ eyeOffX = lerp(eyeOffX, eyeOffXTarget, 0.1);
+ eyeOffY = lerp(eyeOffY, eyeOffYTarget, 0.15);
+
+ // Draw
+ ctx.fillStyle = BG;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ const cx = Math.floor(COLS / 2);
+ const cy = Math.floor(ROWS / 2);
+ const eyeSpread = 5, eyeW = 3;
+ const eh = Math.max(1, Math.round(eyeH));
+ const eOffX = Math.round(eyeOffX), eOffY = Math.round(eyeOffY);
+ const eyeBaseY = cy - 2;
+ const blinkOff = Math.round((3 - eh) / 2);
+
+ if (state === 'love') {
+ const pulse = Math.sin(heartP) > 0 ? '#CC2244' : '#BB1E3E';
+ const heart = (hx: number, hy: number) => {
+ px(hx - 1, hy, pulse); px(hx + 1, hy, pulse);
+ px(hx - 2, hy + 1, pulse); px(hx - 1, hy + 1, pulse); px(hx, hy + 1, pulse); px(hx + 1, hy + 1, pulse); px(hx + 2, hy + 1, pulse);
+ px(hx - 1, hy + 2, pulse); px(hx, hy + 2, pulse); px(hx + 1, hy + 2, pulse);
+ px(hx, hy + 3, pulse);
+ };
+ heart(cx - eyeSpread + eOffX, eyeBaseY + eOffY);
+ heart(cx + eyeSpread + eOffX, eyeBaseY + eOffY);
+ } else {
+ pxRect(cx - eyeSpread - 1 + eOffX, eyeBaseY + blinkOff + eOffY, eyeW, eh, EYE);
+ pxRect(cx + eyeSpread - 1 + eOffX, eyeBaseY + blinkOff + eOffY, eyeW, eh, EYE);
+ }
+
+ if (state === 'angry') {
+ const lx = cx - eyeSpread - 1 + eOffX, ly = eyeBaseY + blinkOff + eOffY - 2;
+ px(lx, ly + 1, EYE); px(lx + 1, ly, EYE); px(lx + 2, ly, EYE);
+ const rx = cx + eyeSpread - 1 + eOffX;
+ px(rx + 2, ly + 1, EYE); px(rx + 1, ly, EYE); px(rx, ly, EYE);
+ }
+
+ const mw = Math.max(1, Math.round(mouthW)), mh = Math.max(1, Math.round(mouthH));
+ const mouthY = cy + 4 + Math.round(eyeOffY);
+
+ if (state === 'happy') {
+ pxRect(cx - Math.floor(mw / 2), mouthY, mw, 1, MOUTH);
+ px(cx - Math.floor(mw / 2), mouthY - 1, MOUTH);
+ px(cx - Math.floor(mw / 2) + mw - 1, mouthY - 1, MOUTH);
+ } else if (state === 'angry') {
+ pxRect(cx - Math.floor(mw / 2), mouthY, mw, 1, MOUTH);
+ px(cx - Math.floor(mw / 2), mouthY + 1, MOUTH);
+ px(cx - Math.floor(mw / 2) + mw - 1, mouthY + 1, MOUTH);
+ } else {
+ pxRect(cx - Math.floor(mw / 2), mouthY, mw, mh, MOUTH);
+ }
+
+ if (state === 'sleeping') {
+ const zFrame = Math.floor(sleepZ * 60) % 90;
+ const zy = Math.round(cy - 5 - (zFrame / 90) * 4);
+ const zx = cx + eyeSpread + 3;
+ if (zy >= 1 && zFrame < 70) {
+ px(zx, zy, '#5577CC'); px(zx + 1, zy, '#5577CC');
+ px(zx + 1, zy + 1, '#5577CC');
+ px(zx, zy + 2, '#5577CC'); px(zx + 1, zy + 2, '#5577CC');
+ }
+ }
+
+ if (state === 'thinking') {
+ const phase = Math.floor(think * 10) % 4;
+ const dx = cx + eyeSpread + 3, dy = cy - 5;
+ if (phase >= 1) px(dx, dy, '#7799DD');
+ if (phase >= 2) px(dx + 1, dy - 1, '#7799DD');
+ if (phase >= 3) px(dx + 2, dy - 2, '#7799DD');
+ }
+
+ animRef.current = requestAnimationFrame(draw);
+ }
+
+ animRef.current = requestAnimationFrame(draw);
+ return () => cancelAnimationFrame(animRef.current);
+ }, [canvasRef, size]);
+}
+
+// ─── Status Labels ───────────────────────────────────────────────
+
+const STATUS_LABELS: Record = {
+ idle: 'Tap to speak',
+ listening: 'Listening...',
+ processing: 'Thinking...',
+ speaking: 'Speaking...',
+};
+
+// ─── Main Component ─────────────────────────────────────────────
+
+const TalkModeOverlay: React.FC = ({ open, onClose, sessionId }) => {
+ const c = useClaudeTokens();
+ const canvasRef = useRef(null);
+ const [faceState, setFaceState] = useState('idle');
+ const [talkStatus, setTalkStatus] = useState('idle');
+ const [transcript, setTranscript] = useState('');
+ const [agentResponse, setAgentResponse] = useState('');
+ const wsRef = useRef(null);
+ const mediaRecorderRef = useRef(null);
+ const silenceTimerRef = useRef(0);
+ const audioContextRef = useRef(null);
+
+ usePixelFace(canvasRef, faceState, 240);
+
+ // Map talk status to face state
+ useEffect(() => {
+ switch (talkStatus) {
+ case 'idle': setFaceState('idle'); break;
+ case 'listening': setFaceState('idle'); break;
+ case 'processing': setFaceState('thinking'); break;
+ case 'speaking': setFaceState('talking'); break;
+ }
+ }, [talkStatus]);
+
+ // WebSocket connection for talk mode
+ useEffect(() => {
+ if (!open || !sessionId) return;
+
+ const ws = new WebSocket(`${WS_BASE}/ws/talk/${sessionId}`);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ ws.send(JSON.stringify({ type: 'config', stt: {}, tts: {} }));
+ };
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data);
+
+ switch (msg.type) {
+ case 'status':
+ if (msg.status === 'listening') setTalkStatus('idle');
+ else if (msg.status === 'processing') setTalkStatus('processing');
+ else if (msg.status === 'speaking') setTalkStatus('speaking');
+ break;
+
+ case 'transcript':
+ setTranscript(msg.text);
+ break;
+
+ case 'agent_response':
+ setAgentResponse(msg.text);
+ setFaceState('happy');
+ setTimeout(() => setFaceState('idle'), 2000);
+ break;
+
+ case 'audio': {
+ const audioData = atob(msg.data);
+ const audioArray = new Uint8Array(audioData.length);
+ for (let i = 0; i < audioData.length; i++) audioArray[i] = audioData.charCodeAt(i);
+
+ if (!audioContextRef.current) audioContextRef.current = new AudioContext();
+ const audioCtx = audioContextRef.current;
+ audioCtx.decodeAudioData(audioArray.buffer.slice(0), (buffer) => {
+ const source = audioCtx.createBufferSource();
+ source.buffer = buffer;
+ source.connect(audioCtx.destination);
+ source.onended = () => setTalkStatus('idle');
+ source.start(0);
+ setTalkStatus('speaking');
+ });
+ break;
+ }
+ }
+ };
+
+ ws.onerror = () => setFaceState('angry');
+ ws.onclose = () => {};
+
+ return () => {
+ ws.close();
+ wsRef.current = null;
+ };
+ }, [open, sessionId]);
+
+ // Keyboard: Esc to close
+ useEffect(() => {
+ if (!open) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [open, onClose]);
+
+ const startRecording = useCallback(async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
+ mediaRecorderRef.current = recorder;
+
+ const chunks: Blob[] = [];
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) chunks.push(e.data);
+ };
+
+ recorder.onstop = async () => {
+ stream.getTracks().forEach((t) => t.stop());
+ const blob = new Blob(chunks, { type: 'audio/webm' });
+ const reader = new FileReader();
+ reader.onload = () => {
+ const base64 = (reader.result as string).split(',')[1];
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: 'audio', data: base64, format: 'webm' }));
+ wsRef.current.send(JSON.stringify({ type: 'end_utterance', format: 'webm' }));
+ }
+ };
+ reader.readAsDataURL(blob);
+ setTalkStatus('processing');
+ };
+
+ recorder.start();
+ setTalkStatus('listening');
+ setTranscript('');
+ setAgentResponse('');
+
+ // Auto-stop after silence (simple timeout approach)
+ silenceTimerRef.current = window.setTimeout(() => {
+ if (mediaRecorderRef.current?.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ }
+ }, 5000);
+ } catch {
+ setFaceState('angry');
+ }
+ }, []);
+
+ const stopRecording = useCallback(() => {
+ window.clearTimeout(silenceTimerRef.current);
+ if (mediaRecorderRef.current?.state === 'recording') {
+ mediaRecorderRef.current.stop();
+ }
+ }, []);
+
+ const handleMicClick = useCallback(() => {
+ if (talkStatus === 'listening') {
+ stopRecording();
+ } else if (talkStatus === 'idle') {
+ startRecording();
+ }
+ }, [talkStatus, startRecording, stopRecording]);
+
+ if (!open) return null;
+
+ const micBg =
+ talkStatus === 'listening' ? c.accent.primary :
+ talkStatus === 'speaking' ? '#4caf50' :
+ c.bg.elevated;
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}
+ >
+ {/* Close button */}
+
+
+
+
+ {/* Face canvas */}
+
+
+
+
+ {/* Status label */}
+
+ {STATUS_LABELS[talkStatus]}
+
+
+ {/* Transcript area */}
+
+ {transcript && (
+
+ "{transcript}"
+
+ )}
+
+ {agentResponse && (
+
+ {agentResponse}
+
+ )}
+
+
+ {/* Mic button */}
+
+ {talkStatus === 'listening' ? :
+ talkStatus === 'speaking' ? :
+ }
+
+
+ {/* Hint */}
+
+ esc to close
+
+
+ );
+};
+
+export default TalkModeOverlay;
diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx
index 6d1764cd..ebf885fd 100644
--- a/frontend/src/app/pages/AgentChat/AgentChat.tsx
+++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx
@@ -27,6 +27,7 @@ import {
switchBranch,
duplicateSession,
setActiveSession,
+ updateSessionProvider,
updateSessionModel,
updateSessionMode,
fetchSession,
@@ -45,9 +46,9 @@ import DiffViewer from './DiffViewer';
import { setGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
-const CONTEXT_WINDOWS: Record = {
- sonnet: 200_000,
- opus: 200_000,
+const CONTEXT_WINDOWS_DEFAULT: Record = {
+ sonnet: 1_000_000,
+ opus: 1_000_000,
haiku: 200_000,
};
@@ -135,6 +136,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
+ const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const scrollContainerRef = useRef(null);
const chatInputRef = useRef(null);
const isAtBottomRef = useRef(true);
@@ -143,6 +145,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
const [awaitingResponse, setAwaitingResponse] = useState(false);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
+ const [provider, setProvider] = useState('anthropic');
const wsRef = useRef | null>(null);
const initialContextApplied = useRef(false);
@@ -185,6 +188,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
if (session) setModel(session.model);
}, [session?.model]);
+ useEffect(() => {
+ if (session?.provider) setProvider(session.provider);
+ }, [session?.provider]);
+
useEffect(() => {
if (Object.keys(modesMap).length === 0) dispatch(fetchModes());
}, [dispatch, modesMap]);
@@ -194,11 +201,11 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
setShowResumeBubble(false);
setAwaitingResponse(true);
if (isDraft) {
- const config: Record = { model, mode };
+ const config: Record = { provider, model, mode };
if (session?.system_prompt) config.system_prompt = session.system_prompt;
if (session?.target_directory) config.target_directory = session.target_directory;
dispatch(
- launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })
+ launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
@@ -212,14 +219,14 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id }));
}
- dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
+ dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
.then((action) => {
if (sendMessageThunk.rejected.match(action)) {
setAwaitingResponse(false);
}
});
}
- }, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]);
+ }, [id, isDraft, mode, model, provider, session?.system_prompt, session?.target_directory, dispatch]);
const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval'));
@@ -304,6 +311,11 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode }));
}, [id, isDraft, dispatch]);
+ const handleProviderChange = useCallback((newProvider: string) => {
+ setProvider(newProvider);
+ if (id && !isDraft) dispatch(updateSessionProvider({ sessionId: id, provider: newProvider }));
+ }, [id, isDraft, dispatch]);
+
const handleModelChange = useCallback((newModel: string) => {
setModel(newModel);
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
@@ -330,9 +342,10 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off",
mode,
model,
+ provider,
hidden: true,
}));
- }, [id, mode, model, dispatch]);
+ }, [id, mode, model, provider, dispatch]);
const [editingMessageId, setEditingMessageId] = useState(null);
@@ -427,7 +440,12 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
}, [id, dispatch, onBranch, session?.dashboard_id]);
const contextEstimate = useMemo(() => {
- const limit = CONTEXT_WINDOWS[model] || 200_000;
+ // Look up context window from dynamic models first, then fall back to defaults
+ let limit = CONTEXT_WINDOWS_DEFAULT[model] || 200_000;
+ for (const models of Object.values(modelsByProvider)) {
+ const found = models.find((m: any) => m.value === model);
+ if (found?.context_window) { limit = found.context_window; break; }
+ }
let totalChars = 0;
if (session?.system_prompt) totalChars += session.system_prompt.length;
for (const msg of activeBranchMessages) {
@@ -438,7 +456,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
}
const used = Math.round(totalChars / 4);
return { used, limit };
- }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]);
+ }, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model, modelsByProvider]);
const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval';
@@ -1116,6 +1134,8 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose
onModeChange={handleModeChange}
model={model}
onModelChange={handleModelChange}
+ provider={provider}
+ onProviderChange={handleProviderChange}
isRunning={agentBusy}
onStop={handleStop}
queueLength={queueLength}
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index d07e4cb1..fd0f058e 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -67,6 +67,8 @@ interface Props {
onModeChange: (mode: string) => void;
model: string;
onModelChange: (model: string) => void;
+ provider?: string;
+ onProviderChange?: (provider: string) => void;
isRunning?: boolean;
onStop?: () => void;
autoRunMode?: boolean;
@@ -92,10 +94,10 @@ const ICON_MAP: Record = {
const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
-const MODEL_OPTIONS = [
- { value: 'sonnet', label: 'Sonnet', version: '4.6' },
- { value: 'opus', label: 'Opus', version: '4.6' },
- { value: 'haiku', label: 'Haiku', version: '3.5' },
+const FALLBACK_MODELS = [
+ { value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 },
+ { value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 },
+ { value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 },
];
function formatTokenCount(n: number): string {
@@ -132,7 +134,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
-const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
+const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef(null);
const containerRef = useRef(null);
@@ -158,6 +160,24 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
const skills = useAppSelector((state) => state.skills.items);
const modesMap = useAppSelector((state) => state.modes.items);
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
+ const modelsByProvider = useAppSelector((state) => state.models.byProvider);
+ const modelsLoaded = useAppSelector((state) => state.models.loaded);
+
+ // Build flat model list with provider grouping
+ const allModelOptions = useMemo(() => {
+ if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
+ return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
+ }
+ const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = [];
+ const grouped: Record> = {};
+ for (const [prov, models] of Object.entries(modelsByProvider)) {
+ grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 }));
+ for (const m of models) {
+ flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov });
+ }
+ }
+ return { flat, grouped };
+ }, [modelsByProvider, modelsLoaded]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -566,7 +586,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '10px',
- minWidth: 140,
+ minWidth: 180,
+ maxHeight: 400,
boxShadow: c.shadow.lg,
'& .MuiMenuItem-root': {
fontSize: '0.8rem',
@@ -979,7 +1000,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
}}
>
- {(() => { const m = MODEL_OPTIONS.find((m) => m.value === model); return m ? `${m.label} ${m.version}` : model; })()}
+ {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()}
@@ -992,21 +1013,45 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
>
- {MODEL_OPTIONS.map((opt) => (
-
- ))}
+ {Object.entries(allModelOptions.grouped).map(([prov, models]) => [
+ ,
+ ...models.map((opt) => (
+
+ )),
+ ]).flat()}
diff --git a/frontend/src/app/pages/Analytics/Analytics.tsx b/frontend/src/app/pages/Analytics/Analytics.tsx
new file mode 100644
index 00000000..1c10703e
--- /dev/null
+++ b/frontend/src/app/pages/Analytics/Analytics.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Paper from '@mui/material/Paper';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const Analytics: React.FC = () => {
+ const c = useClaudeTokens();
+
+ return (
+
+
+
+ Analytics
+
+
+
+
+
+
+
+ Analytics powered by PostHog
+
+
+ Usage data is automatically collected — sessions, costs, tool usage, model distribution, and task categories.
+ All data is anonymous and can be disabled in Settings.
+
+
+
+ {[
+ { label: 'Sessions & Usage', desc: 'How often agents are launched, session duration, completion rates' },
+ { label: 'Cost Tracking', desc: 'Spend by model, provider, and time period' },
+ { label: 'Task Categories', desc: 'What users do — coding, email, research, social, browsing' },
+ { label: 'Model Distribution', desc: 'Which models and providers are most popular' },
+ { label: 'Tool Usage', desc: 'Most used MCP tools, execution times, approval rates' },
+ { label: 'Retention & Funnels', desc: 'User engagement, feature adoption, onboarding flow' },
+ ].map((item) => (
+
+
+ {item.label}
+
+
+ {item.desc}
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default Analytics;
diff --git a/frontend/src/app/pages/Analytics/PixelChart.tsx b/frontend/src/app/pages/Analytics/PixelChart.tsx
new file mode 100644
index 00000000..263b7a2d
--- /dev/null
+++ b/frontend/src/app/pages/Analytics/PixelChart.tsx
@@ -0,0 +1,406 @@
+import React, { useRef, useEffect, useCallback } from 'react';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+
+const PALETTES = {
+ salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'],
+ blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'],
+ coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'],
+ green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'],
+ purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'],
+} as const;
+
+type PaletteKey = keyof typeof PALETTES;
+
+interface PixelChartProps {
+ data: { label: string; value: number }[];
+ palette?: PaletteKey;
+ height?: number;
+ pixelSize?: number;
+ formatValue?: (v: number) => string;
+ glow?: boolean;
+ showXLabels?: boolean;
+ showYScale?: boolean;
+ mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars
+}
+
+const PixelChart: React.FC = ({
+ data,
+ palette = 'salmon',
+ height = 140,
+ pixelSize = 6,
+ formatValue,
+ glow = true,
+ showXLabels = true,
+ showYScale = true,
+ mode = 'bar',
+}) => {
+ const canvasRef = useRef(null);
+ const containerRef = useRef(null);
+ const animRef = useRef(0);
+ const progressRef = useRef(0);
+ const hoverIdxRef = useRef(-1);
+ const tooltipRef = useRef(null);
+ const c = useClaudeTokens();
+ const colors = PALETTES[palette];
+
+ const maxVal = Math.max(...data.map((d) => d.value), 0.001);
+
+ // Compute nice Y-axis ticks
+ const yTicks = (() => {
+ if (maxVal <= 0) return [0];
+ const rawStep = maxVal / 3;
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+ const normalised = rawStep / magnitude;
+ let niceStep: number;
+ if (normalised <= 1) niceStep = magnitude;
+ else if (normalised <= 2) niceStep = 2 * magnitude;
+ else if (normalised <= 5) niceStep = 5 * magnitude;
+ else niceStep = 10 * magnitude;
+ const ticks: number[] = [];
+ for (let v = 0; v <= maxVal * 1.1; v += niceStep) {
+ ticks.push(v);
+ }
+ if (ticks.length < 2) ticks.push(niceStep);
+ return ticks;
+ })();
+
+ // X-axis labels: show first, last, and up to 3 evenly spaced
+ const xLabels = (() => {
+ if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label }));
+ if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label }));
+ const result: { idx: number; label: string }[] = [];
+ result.push({ idx: 0, label: data[0].label });
+ const step = Math.floor(data.length / 4);
+ for (let i = 1; i <= 3; i++) {
+ const idx = Math.min(i * step, data.length - 2);
+ if (idx > 0 && idx < data.length - 1) {
+ result.push({ idx, label: data[idx].label });
+ }
+ }
+ result.push({ idx: data.length - 1, label: data[data.length - 1].label });
+ return result;
+ })();
+
+ const Y_LABEL_WIDTH = showYScale ? 80 : 0;
+
+ const draw = useCallback(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container || data.length === 0) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const totalW = container.clientWidth;
+ const chartW = totalW - Y_LABEL_WIDTH;
+ const h = height;
+ canvas.width = totalW * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${totalW}px`;
+ canvas.style.height = `${h}px`;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ ctx.scale(dpr, dpr);
+
+ const px = pixelSize;
+ const gridCols = Math.floor(chartW / px);
+ const gridRows = Math.floor(h / px);
+ const effectiveMax = yTicks[yTicks.length - 1] || maxVal;
+
+ ctx.clearRect(0, 0, totalW, h);
+
+ // Y-axis labels and horizontal grid lines
+ if (showYScale) {
+ ctx.font = '10px monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+
+ for (const tick of yTicks) {
+ const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0;
+ const yPx = h - yNorm * (h - px);
+
+ // Grid line
+ ctx.strokeStyle = c.border.subtle;
+ ctx.lineWidth = 0.5;
+ ctx.setLineDash([2, 4]);
+ ctx.beginPath();
+ ctx.moveTo(Y_LABEL_WIDTH, yPx);
+ ctx.lineTo(totalW, yPx);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Label
+ const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1));
+ ctx.fillStyle = c.text.ghost;
+ ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx);
+ }
+ }
+
+ // Subtle grid dots in chart area
+ ctx.fillStyle = c.border.subtle;
+ for (let gy = 0; gy < gridRows; gy += 5) {
+ for (let gx = 0; gx < gridCols; gx += 5) {
+ ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1);
+ }
+ }
+
+ const progress = Math.min(progressRef.current, 1);
+ const hoverIdx = hoverIdxRef.current;
+
+ if (mode === 'area') {
+ // ── Area / line chart mode ──
+ // Draw a smooth filled area under a line
+ const usableH = h - px * 2;
+ const points: { x: number; y: number }[] = [];
+
+ for (let i = 0; i < data.length; i++) {
+ const val = data[i].value;
+ const norm = effectiveMax > 0 ? val / effectiveMax : 0;
+ const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW;
+ const y = h - px - norm * usableH * progress;
+ points.push({ x, y });
+ }
+
+ if (points.length > 0) {
+ // Filled area with gradient
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
+ gradient.addColorStop(0, colors[colors.length - 1] + '60');
+ gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30');
+ gradient.addColorStop(1, colors[0] + '08');
+
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, h);
+ // Smooth curve through points
+ for (let i = 0; i < points.length; i++) {
+ if (i === 0) {
+ ctx.lineTo(points[i].x, points[i].y);
+ } else {
+ // Cubic bezier for smoothing
+ const prev = points[i - 1];
+ const curr = points[i];
+ const cpx = (prev.x + curr.x) / 2;
+ ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
+ }
+ }
+ ctx.lineTo(points[points.length - 1].x, h);
+ ctx.closePath();
+ ctx.fillStyle = gradient;
+ ctx.fill();
+
+ // Line on top
+ ctx.beginPath();
+ for (let i = 0; i < points.length; i++) {
+ if (i === 0) {
+ ctx.moveTo(points[i].x, points[i].y);
+ } else {
+ const prev = points[i - 1];
+ const curr = points[i];
+ const cpx = (prev.x + curr.x) / 2;
+ ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
+ }
+ }
+ ctx.strokeStyle = colors[colors.length - 1];
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // Glow on line
+ if (glow) {
+ ctx.shadowColor = colors[colors.length - 1];
+ ctx.shadowBlur = 8;
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+
+ // Data point dots
+ for (let i = 0; i < points.length; i++) {
+ if (data[i].value > 0) {
+ const isHov = i === hoverIdx;
+ ctx.beginPath();
+ ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2);
+ ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)];
+ ctx.fill();
+ if (isHov) {
+ ctx.strokeStyle = colors[colors.length - 1];
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
+ }
+ }
+
+ // Pixel scatter in the filled area for the pixel art feel
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = points[i];
+ const p2 = points[i + 1];
+ const steps = Math.ceil((p2.x - p1.x) / px);
+ for (let s = 0; s < steps; s++) {
+ const t = s / steps;
+ const x = p1.x + t * (p2.x - p1.x);
+ const lineY = p1.y + t * (p2.y - p1.y);
+ // Scatter pixels below the line
+ for (let py = lineY + px * 2; py < h - px; py += px * 2) {
+ if (Math.random() > 0.65) {
+ const depth = (py - lineY) / (h - lineY);
+ const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1)));
+ ctx.globalAlpha = 0.15 + (1 - depth) * 0.2;
+ ctx.fillStyle = colors[ci];
+ ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1);
+ }
+ }
+ }
+ }
+ ctx.globalAlpha = 1;
+ }
+ } else {
+ // ── Bar chart mode (original) ──
+ const barSlots = data.length;
+ const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots));
+ const barW = Math.max(1, totalBarPx - 1);
+
+ for (let i = 0; i < data.length; i++) {
+ const val = data[i].value;
+ const normalised = effectiveMax > 0 ? val / effectiveMax : 0;
+ const usableRows = gridRows - 2;
+ const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows));
+ const barH = Math.round(targetH * progress);
+ const barX = i * totalBarPx;
+ const isHovered = i === hoverIdx;
+
+ for (let row = 0; row < barH; row++) {
+ const y = gridRows - 1 - row;
+ const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1)));
+ const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx];
+
+ for (let col = 0; col < barW; col++) {
+ ctx.fillStyle = baseColor;
+ ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1);
+ }
+ }
+
+ if (glow && barH > 0) {
+ const topY = (gridRows - 1 - barH + 1) * px;
+ ctx.shadowColor = colors[colors.length - 1];
+ ctx.shadowBlur = 6;
+ ctx.fillStyle = colors[colors.length - 1];
+ for (let col = 0; col < barW; col++) {
+ ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1);
+ }
+ ctx.shadowBlur = 0;
+ }
+ }
+ }
+ }, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]);
+
+ useEffect(() => {
+ progressRef.current = 0;
+ let start: number | null = null;
+ const animate = (ts: number) => {
+ if (!start) start = ts;
+ progressRef.current = Math.min(1, (ts - start) / 600);
+ draw();
+ if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate);
+ };
+ animRef.current = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(animRef.current);
+ }, [data, draw]);
+
+ useEffect(() => {
+ const handleResize = () => draw();
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [draw]);
+
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ const tooltip = tooltipRef.current;
+ if (!canvas || !tooltip || data.length === 0) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left - Y_LABEL_WIDTH;
+ if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; }
+
+ const chartW = rect.width - Y_LABEL_WIDTH;
+ const gridCols = Math.floor(chartW / pixelSize);
+ const totalBarPx = Math.max(1, Math.floor(gridCols / data.length));
+ const idx = Math.floor(mx / (totalBarPx * pixelSize));
+
+ if (idx >= 0 && idx < data.length) {
+ hoverIdxRef.current = idx;
+ const d = data[idx];
+ const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2);
+ tooltip.textContent = `${d.label}: ${valStr}`;
+ tooltip.style.opacity = '1';
+ tooltip.style.left = `${e.clientX - rect.left}px`;
+ tooltip.style.top = `${e.clientY - rect.top - 28}px`;
+ } else {
+ hoverIdxRef.current = -1;
+ tooltip.style.opacity = '0';
+ }
+ draw();
+ },
+ [data, pixelSize, draw, formatValue, Y_LABEL_WIDTH],
+ );
+
+ const handleMouseLeave = useCallback(() => {
+ hoverIdxRef.current = -1;
+ if (tooltipRef.current) tooltipRef.current.style.opacity = '0';
+ draw();
+ }, [draw]);
+
+ return (
+
+
+ {/* X-axis labels */}
+ {showXLabels && data.length > 0 && (
+
+ {xLabels.map((xl) => (
+
+ {xl.label}
+
+ ))}
+
+ )}
+ {/* Tooltip */}
+
+
+ );
+};
+
+export default PixelChart;
diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
index 9dad9329..0b092864 100644
--- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
+++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx
@@ -95,6 +95,7 @@ const DashboardToolbar = React.forwardRef(
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
+ const [provider, setProvider] = useState('anthropic');
const settingsApplied = useRef(false);
useEffect(() => {
if (!settingsApplied.current) {
@@ -365,6 +366,8 @@ const DashboardToolbar = React.forwardRef(
onModeChange={setMode}
model={model}
onModelChange={setModel}
+ provider={provider}
+ onProviderChange={setProvider}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx
index 79f743bc..a888cacb 100644
--- a/frontend/src/app/pages/Settings/Settings.tsx
+++ b/frontend/src/app/pages/Settings/Settings.tsx
@@ -40,11 +40,203 @@ import Collapse from '@mui/material/Collapse';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, resetSystemPrompt, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
+import { fetchModels } from '@/shared/state/modelsSlice';
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
import { CommandsContent } from '@/app/pages/Commands/Commands';
+import { API_BASE } from '@/shared/config';
+
+// ── Pixel Bar ──
+const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
+const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
+
+const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
+ const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
+ return (
+
+ {Array.from({ length: width }, (_, i) => (
+
+ ))}
+
+ );
+};
+
+// ── Usage Stats Component ──
+const UsageStats: React.FC = () => {
+ const c = useClaudeTokens();
+ const [stats, setStats] = useState(null);
+
+ useEffect(() => {
+ fetch(`${API_BASE}/analytics/usage-summary`)
+ .then(r => r.json())
+ .then(setStats)
+ .catch(() => {});
+ }, []);
+
+ if (!stats) return null;
+
+ const formatCost = (v: number) => {
+ if (v === 0) return '$0.00';
+ if (v < 0.001) return `$${v.toFixed(6)}`;
+ if (v < 0.01) return `$${v.toFixed(5)}`;
+ if (v < 1) return `$${v.toFixed(4)}`;
+ return `$${v.toFixed(2)}`;
+ };
+ const formatDuration = (s: number) => {
+ if (s === 0) return '0s';
+ if (s < 60) return `${s.toFixed(1)}s`;
+ if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
+ };
+ const formatTotalTime = (s: number) => {
+ if (s < 60) return `${s.toFixed(1)}s`;
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
+ return `${(s / 3600).toFixed(1)} hrs`;
+ };
+
+ const cardSx = {
+ p: 1.5,
+ borderRadius: `${c.radius.md}px`,
+ bgcolor: c.bg.elevated,
+ border: `1px solid ${c.border.subtle}`,
+ };
+ const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
+ const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
+ const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
+
+ const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
+ const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
+ const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
+ const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
+ const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
+
+ // Pixel bar helper that passes tokens
+ const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
+
+ );
+
+ const totalTime = stats.avg_duration_seconds * stats.total_sessions;
+ const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
+ const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
+
+ return (
+
+ {/* Row 1: Core metrics */}
+
+
+ Total Sessions
+ {stats.total_sessions.toLocaleString()}
+
+ {statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
+
+
+
+ Total Cost
+ {formatCost(stats.total_cost_usd)}
+
+ {formatCost(stats.avg_cost_per_session)} avg per session
+
+
+
+ Total Messages
+ {stats.total_messages.toLocaleString()}
+
+ {msgsPerSession} avg per session
+
+
+
+ Total Tool Calls
+ {stats.total_tool_calls.toLocaleString()}
+
+ {toolsPerSession} avg per session
+
+
+
+
+ {/* Row 2: Time + efficiency */}
+
+
+ Total Run Time
+ {formatTotalTime(totalTime)}
+ across all sessions
+
+
+ Avg Session
+ {formatDuration(stats.avg_duration_seconds)}
+ per session duration
+
+
+ Completion Rate
+ {(stats.completion_rate * 100).toFixed(1)}%
+
+ sessions finished successfully
+
+
+
+ Providers
+ {Object.keys(stats.providers_used || {}).length}
+
+ {providerEntries.map(([p]) => p).join(', ') || 'none configured'}
+
+
+
+
+ {/* Model + Provider + Tool breakdown */}
+
+ {/* Models & Providers */}
+
+ Models Used
+ {modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
+ const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
+ return (
+
+
+ {model}
+
+ {count} ({pct}%)
+
+
+
+
+ );
+ }) : No sessions yet}
+
+
+ {/* Tools */}
+
+ Top Tools
+ {toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
+ const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
+ const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
+ return (
+
+
+ {shortName}
+
+ {count} call{count !== 1 ? 's' : ''} ({pct}%)
+
+
+
+
+ );
+ }) : No tool calls yet}
+
+
+
+ );
+};
const API_KEY_STEPS = [
{
@@ -87,7 +279,7 @@ const Settings: React.FC = () => {
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
const updateError = useAppSelector((s) => s.update.error);
- const [activeTab, setActiveTab] = useState<'general' | 'commands'>('general');
+ const [activeTab, setActiveTab] = useState<'general' | 'models' | 'usage' | 'commands'>('general');
const [form, setForm] = useState({ ...settings });
const [showApiKey, setShowApiKey] = useState(false);
const [browseOpen, setBrowseOpen] = useState(false);
@@ -143,6 +335,7 @@ const Settings: React.FC = () => {
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
+ dispatch(fetchModels());
setSaved(true);
};
@@ -165,6 +358,7 @@ const Settings: React.FC = () => {
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
+ dispatch(fetchModels());
setSaved(true);
setConfirmDiscard(false);
dispatch(closeSettingsModal());
@@ -272,6 +466,8 @@ const Settings: React.FC = () => {
}}
>
+
+
@@ -626,125 +822,6 @@ const Settings: React.FC = () => {
- {/* ── API ── */}
- API
-
-
- Anthropic API key
-
-
- Stored securely in the local database.
-
- setShowApiHelp((v) => !v)}
- sx={{
- color: c.accent.primary,
- fontSize: '0.75rem',
- cursor: 'pointer',
- display: 'inline-flex',
- alignItems: 'center',
- gap: 0.4,
- whiteSpace: 'nowrap',
- userSelect: 'none',
- '&:hover': { textDecoration: 'underline' },
- }}
- >
- {showApiHelp ? 'Hide guide' : 'How do I get a key?'}
-
-
-
-
-
- {API_KEY_STEPS.map((step, i) => (
-
-
- {i + 1}
-
-
-
- {step.title}
- {step.link && (
-
- Open
-
-
- )}
-
-
- {step.detail}
-
-
-
- ))}
-
-
-
- setForm({ ...form, anthropic_api_key: e.target.value || null })}
- size="small"
- fullWidth
- placeholder="sk-ant-..."
- sx={{
- ...fieldSx,
- '& .MuiOutlinedInput-root': {
- ...fieldSx['& .MuiOutlinedInput-root'],
- fontFamily: c.font.mono,
- },
- }}
- InputProps={{
- endAdornment: (
-
- setShowApiKey(!showApiKey)}
- edge="end"
- size="small"
- sx={{ color: c.text.tertiary }}
- >
- {showApiKey ? : }
-
-
- ),
- }}
- />
-
-
{/* ── Advanced ── */}
Advanced
@@ -873,6 +950,156 @@ const Settings: React.FC = () => {
+ ) : activeTab === 'models' ? (
+
+
+ Connect your AI model providers. Each key is stored locally on your device.
+
+
+ {/* OpenRouter — recommended */}
+
+
+ OpenRouter
+
+ RECOMMENDED
+
+
+
+ One key for 300+ models — Llama, DeepSeek, Mistral, Qwen, Grok, and more. Easiest way to get started.
+
+
+ setForm({ ...form, openrouter_api_key: e.target.value || null } as any)}
+ size="small"
+ fullWidth
+ placeholder="sk-or-..."
+ sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
+ />
+
+ Get key
+
+
+
+
+ {/* Anthropic */}
+
+ Anthropic
+ Claude Sonnet, Opus, Haiku — direct API access.
+
+ setForm({ ...form, anthropic_api_key: e.target.value || null })}
+ size="small"
+ fullWidth
+ placeholder="sk-ant-..."
+ sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
+ InputProps={{
+ endAdornment: (
+
+ setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
+ {showApiKey ? : }
+
+
+ ),
+ }}
+ />
+
+ Get key
+
+
+
+
+ {/* OpenAI */}
+
+ OpenAI
+ GPT-5.4, o3, o4-mini — direct API access.
+
+ setForm({ ...form, openai_api_key: e.target.value || null } as any)}
+ size="small"
+ fullWidth
+ placeholder="sk-..."
+ sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
+ />
+
+ Get key
+
+
+
+
+ {/* Google */}
+
+ Google
+ Gemini 2.5 Pro and Flash — direct API access.
+
+ setForm({ ...form, google_api_key: e.target.value || null } as any)}
+ size="small"
+ fullWidth
+ placeholder="AIza..."
+ sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
+ />
+
+ Get key
+
+
+
+
+ ) : activeTab === 'usage' ? (
+
+
+
+ {/* ── Analytics ── */}
+ Analytics
+
+
+
+ Share anonymous usage data
+
+ Help improve OpenSwarm by sharing anonymous statistics like session counts, model usage, and feature adoption. No conversations, file paths, or personal information is ever collected.
+
+
+ setForm({ ...form, analytics_opt_in: e.target.checked } as any)}
+ sx={{
+ '& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
+ '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
+ }}
+ />
+
+
) : (
diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx
index 75ea08c7..64ea271f 100644
--- a/frontend/src/app/pages/Views/ViewEditor.tsx
+++ b/frontend/src/app/pages/Views/ViewEditor.tsx
@@ -519,6 +519,7 @@ const ViewEditor: React.FC = ({ output, onClose }) => {
const [autoRunEnabled, setAutoRunEnabled] = useState(savedAutoRun?.enabled ?? false);
const [autoRunMode, setAutoRunMode] = useState(savedAutoRun?.mode ?? 'agent');
const [autoRunModel, setAutoRunModel] = useState(savedAutoRun?.model ?? 'sonnet');
+ const [autoRunProvider, setAutoRunProvider] = useState('anthropic');
const [autoRunning, setAutoRunning] = useState(false);
const autoRunInputRef = useRef(null);
const autoRunInitialized = useRef(false);
@@ -1539,6 +1540,8 @@ const ViewEditor: React.FC = ({ output, onClose }) => {
onModeChange={setAutoRunMode}
model={autoRunModel}
onModelChange={setAutoRunModel}
+ provider={autoRunProvider}
+ onProviderChange={setAutoRunProvider}
/>