mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] OpenSwarm Pro: new managed plan alongside BYO — deep-link activation from billing checkout, onboarding + Settings card with live usage, gradient OpenSwarm Pro group in
the model picker, inline error cards for rate-limit/connection failures, debugger startup fix
This commit is contained in:
@@ -24,3 +24,6 @@ backend/npm-servers/*/node_modules/
|
||||
frontend/dist/
|
||||
# Bundled uv binaries (downloaded during build)
|
||||
backend/uv-bin/
|
||||
.account-factory
|
||||
openswarm-cloud
|
||||
.openswarm-cloud
|
||||
|
||||
@@ -1041,11 +1041,18 @@ class AgentManager:
|
||||
"disallowed_tools": effective_disallowed,
|
||||
"include_partial_messages": True,
|
||||
}
|
||||
# Priority: Anthropic API key (Anthropic models only) → 9Router.
|
||||
# Non-Anthropic api_types always route through 9Router regardless
|
||||
# of whether an Anthropic API key is set.
|
||||
# Priority: openswarm-pro mode → Anthropic API key → 9Router.
|
||||
# Non-Anthropic api_types always route through 9Router regardless.
|
||||
from backend.apps.nine_router import is_running as _9r_running
|
||||
if api_type == "anthropic" and global_settings.anthropic_api_key:
|
||||
if api_type == "anthropic" and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
proxy_url = getattr(global_settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
|
||||
bearer = getattr(global_settings, "openswarm_bearer_token", "") or ""
|
||||
options_kwargs["env"] = {
|
||||
"ANTHROPIC_AUTH_TOKEN": bearer,
|
||||
"ANTHROPIC_BASE_URL": proxy_url,
|
||||
}
|
||||
logger.info(f"[MCP-DEBUG] Using OpenSwarm Pro proxy at {proxy_url}")
|
||||
elif api_type == "anthropic" and global_settings.anthropic_api_key:
|
||||
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
|
||||
logger.info("[MCP-DEBUG] Using direct Anthropic API key")
|
||||
elif _9r_running():
|
||||
|
||||
@@ -336,7 +336,11 @@ async def list_models():
|
||||
continue
|
||||
elif api == "anthropic":
|
||||
has_key = bool(getattr(settings, "anthropic_api_key", None))
|
||||
if not has_key and "claude" not in connected:
|
||||
is_openswarm_pro = (
|
||||
getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
|
||||
and bool(getattr(settings, "openswarm_bearer_token", None))
|
||||
)
|
||||
if not has_key and "claude" not in connected and not is_openswarm_pro:
|
||||
continue
|
||||
visible.append({
|
||||
"value": m["value"],
|
||||
|
||||
@@ -259,6 +259,7 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
"""Resolve a short model name into the id string passed to ClaudeAgentOptions.
|
||||
|
||||
Priority:
|
||||
- Anthropic model + openswarm-pro mode → bare `model_id` (our cloud proxy)
|
||||
- Anthropic model with an API key set → bare `model_id` (real Anthropic API)
|
||||
- Everything else → `router_model_id` (9Router with cc/ cx/ gc/ gh/ prefix)
|
||||
- Unknown names pass through unchanged
|
||||
@@ -266,8 +267,11 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
entry = _find_builtin_model(short_name)
|
||||
if entry is None:
|
||||
return short_name
|
||||
if entry.get("api") == "anthropic" and getattr(settings, "anthropic_api_key", None):
|
||||
return entry.get("model_id", short_name)
|
||||
if entry.get("api") == "anthropic":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
return entry.get("model_id", short_name)
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
return entry.get("model_id", short_name)
|
||||
return entry.get("router_model_id", entry.get("model_id", short_name))
|
||||
|
||||
|
||||
@@ -294,6 +298,11 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
|
||||
sonnet_bare = "claude-sonnet-4-20250514"
|
||||
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
|
||||
|
||||
# OpenSwarm Pro — route through our cloud proxy
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
|
||||
return (bare, proxy_url)
|
||||
|
||||
# Direct API key wins
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
return (bare, None)
|
||||
@@ -361,10 +370,10 @@ def create_provider(
|
||||
|
||||
if api_type == "anthropic":
|
||||
from backend.apps.agents.providers.anthropic import AnthropicProvider
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
return AnthropicProvider(
|
||||
auth_token=getattr(settings, "openswarm_auth_token", None),
|
||||
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
|
||||
auth_token=getattr(settings, "openswarm_bearer_token", None),
|
||||
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com",
|
||||
)
|
||||
# Priority: API key → 9Router subscription
|
||||
if settings.anthropic_api_key:
|
||||
@@ -467,8 +476,8 @@ def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
|
||||
api_type = _get_api_type(provider_name)
|
||||
|
||||
if api_type == "anthropic":
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
return bool(getattr(settings, "openswarm_auth_token", None))
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
return bool(getattr(settings, "openswarm_bearer_token", None))
|
||||
return bool(settings.anthropic_api_key)
|
||||
if api_type == "openai":
|
||||
return bool(settings.openai_api_key)
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
import anthropic
|
||||
from backend.apps.settings.models import AppSettings
|
||||
|
||||
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
|
||||
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.com"
|
||||
|
||||
|
||||
def _check_9router() -> bool:
|
||||
@@ -42,8 +42,8 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
|
||||
return
|
||||
|
||||
if p == "anthropic":
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
if not getattr(settings, "openswarm_auth_token", None):
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
if not getattr(settings, "openswarm_bearer_token", None):
|
||||
raise ValueError("Open Swarm account not connected. Sign in via Settings -> API.")
|
||||
return
|
||||
if settings.anthropic_api_key:
|
||||
@@ -81,9 +81,9 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
|
||||
validate_credentials(settings, provider)
|
||||
|
||||
if p in ("anthropic", "claude"):
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
return {
|
||||
"auth_token": getattr(settings, "openswarm_auth_token", "") or "",
|
||||
"auth_token": getattr(settings, "openswarm_bearer_token", "") or "",
|
||||
"base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
|
||||
}
|
||||
return {"api_key": settings.anthropic_api_key or ""}
|
||||
@@ -116,10 +116,10 @@ def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
|
||||
"""
|
||||
validate_credentials(settings, "anthropic")
|
||||
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
|
||||
return {
|
||||
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
|
||||
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_bearer_token", ""),
|
||||
"ANTHROPIC_BASE_URL": proxy_url,
|
||||
}
|
||||
|
||||
@@ -133,10 +133,10 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
|
||||
"""
|
||||
import anthropic
|
||||
|
||||
if getattr(settings, "connection_mode", "own_key") == "managed":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
|
||||
return anthropic.AsyncAnthropic(
|
||||
auth_token=getattr(settings, "openswarm_auth_token", None),
|
||||
auth_token=getattr(settings, "openswarm_bearer_token", None),
|
||||
base_url=proxy_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,13 @@ class AppSettings(BaseModel):
|
||||
analytics_opt_in: bool = True
|
||||
installation_id: Optional[str] = None
|
||||
first_opened_at: Optional[str] = None # ISO timestamp of first app open
|
||||
# OpenSwarm Pro subscription
|
||||
connection_mode: str = "own_key" # "own_key" | "openswarm-pro"
|
||||
openswarm_bearer_token: Optional[str] = None
|
||||
openswarm_proxy_url: Optional[str] = None # default resolved in credentials.py
|
||||
openswarm_subscription_plan: Optional[str] = None # "hobby"|"pro"|"pro_plus"|"ultra"
|
||||
openswarm_subscription_expires: Optional[str] = None # ISO 8601
|
||||
openswarm_usage_cached: Optional[dict] = None # {count, limit, window_end_at}
|
||||
|
||||
|
||||
class CustomProvider(BaseModel):
|
||||
|
||||
@@ -27,11 +27,27 @@ async def settings_lifespan():
|
||||
settings = SubApp("settings", settings_lifespan)
|
||||
|
||||
|
||||
def _migrate_legacy_fields(raw: dict) -> dict:
|
||||
"""Translate deprecated field names/values so they survive into the new schema.
|
||||
|
||||
Pre-launch scaffolding used `connection_mode="managed"` and
|
||||
`openswarm_auth_token`; production names are `"openswarm-pro"` and
|
||||
`openswarm_bearer_token`. Zero known users are affected, but keep the
|
||||
mapping for safety.
|
||||
"""
|
||||
if raw.get("connection_mode") == "managed":
|
||||
raw["connection_mode"] = "openswarm-pro"
|
||||
if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
|
||||
raw["openswarm_bearer_token"] = raw.pop("openswarm_auth_token")
|
||||
return raw
|
||||
|
||||
|
||||
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))
|
||||
raw = _migrate_legacy_fields(json.load(f))
|
||||
settings = AppSettings(**raw)
|
||||
if settings.default_system_prompt is None:
|
||||
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
|
||||
return settings
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Desktop-side subscription endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
|
||||
from backend.apps.settings.settings import SETTINGS_FILE, load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def subscription_lifespan():
|
||||
yield
|
||||
|
||||
|
||||
subscription = SubApp("subscription", subscription_lifespan)
|
||||
|
||||
|
||||
def _proxy_url() -> str:
|
||||
"""Cloud router base URL. Overridable per-user via settings, falling back
|
||||
to the module-default. No trailing slash."""
|
||||
settings_obj = load_settings()
|
||||
url = (getattr(settings_obj, "openswarm_proxy_url", None)
|
||||
or OPENSWARM_DEFAULT_PROXY_URL)
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def _write_settings(settings_obj) -> None:
|
||||
"""Persist AppSettings to disk. Mirrors backend/apps/settings/settings.py
|
||||
_save_settings to avoid importing a private module member."""
|
||||
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
|
||||
with open(SETTINGS_FILE, "w") as f:
|
||||
json.dump(settings_obj.model_dump(), f, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/activate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ActivateRequest(BaseModel):
|
||||
token: str
|
||||
plan: Optional[str] = None
|
||||
expires: Optional[str] = None # ISO 8601
|
||||
|
||||
|
||||
@subscription.router.post("/activate")
|
||||
async def activate(body: ActivateRequest):
|
||||
"""Renderer calls this after catching an openswarm://auth deep link.
|
||||
|
||||
Validates the bearer by calling the cloud /api/me, then persists it to
|
||||
settings. On success the desktop app flips into openswarm-pro mode for
|
||||
subsequent Claude requests.
|
||||
"""
|
||||
if not body.token or len(body.token) < 16:
|
||||
raise HTTPException(status_code=400, detail="Invalid token")
|
||||
|
||||
proxy = _proxy_url()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.get(
|
||||
f"{proxy}/api/me",
|
||||
headers={"Authorization": f"Bearer {body.token}"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Could not reach subscription service: {e}",
|
||||
)
|
||||
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Token rejected by service")
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(
|
||||
status_code=r.status_code,
|
||||
detail=r.text[:200] or "Service error",
|
||||
)
|
||||
|
||||
me = r.json()
|
||||
|
||||
# Persist to settings. Prefer cloud-reported values; fall back to the
|
||||
# deep-link's own fields if cloud is sparse.
|
||||
settings_obj = load_settings()
|
||||
settings_obj.connection_mode = "openswarm-pro"
|
||||
settings_obj.openswarm_bearer_token = body.token
|
||||
settings_obj.openswarm_proxy_url = proxy
|
||||
settings_obj.openswarm_subscription_plan = (
|
||||
me.get("plan") or body.plan or "pro"
|
||||
)
|
||||
period_end = me.get("current_period_end")
|
||||
if isinstance(period_end, (int, float)):
|
||||
# cloud returns unix ms
|
||||
from datetime import datetime, timezone
|
||||
settings_obj.openswarm_subscription_expires = (
|
||||
datetime.fromtimestamp(period_end / 1000, tz=timezone.utc).isoformat()
|
||||
)
|
||||
elif body.expires:
|
||||
settings_obj.openswarm_subscription_expires = body.expires
|
||||
|
||||
usage = me.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
settings_obj.openswarm_usage_cached = usage
|
||||
|
||||
_write_settings(settings_obj)
|
||||
return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/subscription/status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.get("/status")
|
||||
async def status():
|
||||
"""Consolidated view for the Settings card. Reads persisted plan/expires,
|
||||
polls cloud for live usage when a bearer is present."""
|
||||
settings_obj = load_settings()
|
||||
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
|
||||
plan = getattr(settings_obj, "openswarm_subscription_plan", None)
|
||||
expires = getattr(settings_obj, "openswarm_subscription_expires", None)
|
||||
mode = getattr(settings_obj, "connection_mode", "own_key")
|
||||
|
||||
if mode != "openswarm-pro" or not bearer:
|
||||
return {
|
||||
"connected": False,
|
||||
"connection_mode": mode,
|
||||
}
|
||||
|
||||
# Best-effort live fetch — surface stale cache if cloud is unreachable.
|
||||
live_usage = None
|
||||
live_status = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
f"{_proxy_url()}/api/me",
|
||||
headers={"Authorization": f"Bearer {bearer}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
me = r.json()
|
||||
live_usage = me.get("usage")
|
||||
live_status = me.get("status")
|
||||
# Update cache for offline display.
|
||||
if isinstance(live_usage, dict):
|
||||
settings_obj.openswarm_usage_cached = live_usage
|
||||
_write_settings(settings_obj)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("subscription/status live fetch failed: %s", e)
|
||||
|
||||
return {
|
||||
"connected": True,
|
||||
"connection_mode": mode,
|
||||
"plan": plan,
|
||||
"status": live_status or "active",
|
||||
"expires": expires,
|
||||
"usage": live_usage or getattr(settings_obj, "openswarm_usage_cached", None),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/portal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/portal")
|
||||
async def portal():
|
||||
"""Returns a Stripe Customer Portal URL. Renderer opens it in the
|
||||
system browser via shell.openExternal."""
|
||||
settings_obj = load_settings()
|
||||
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
|
||||
if not bearer:
|
||||
raise HTTPException(status_code=400, detail="Not subscribed")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(
|
||||
f"{_proxy_url()}/api/billing/portal",
|
||||
headers={"Authorization": f"Bearer {bearer}"},
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(status_code=r.status_code, detail=r.text[:200])
|
||||
data = r.json()
|
||||
return {"url": data.get("url")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/disconnect")
|
||||
async def disconnect():
|
||||
"""Clears local bearer + reverts to own_key mode. Does NOT cancel the
|
||||
Stripe subscription (use the portal for that). Useful when a user wants
|
||||
to temporarily route through their own API key."""
|
||||
settings_obj = load_settings()
|
||||
settings_obj.connection_mode = "own_key"
|
||||
settings_obj.openswarm_bearer_token = None
|
||||
settings_obj.openswarm_subscription_plan = None
|
||||
settings_obj.openswarm_subscription_expires = None
|
||||
settings_obj.openswarm_usage_cached = None
|
||||
_write_settings(settings_obj)
|
||||
return {"ok": True}
|
||||
+2
-1
@@ -38,11 +38,12 @@ 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.analytics.analytics import analytics
|
||||
from backend.apps.subscription.router import subscription
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
import json
|
||||
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics])
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription])
|
||||
app = main_app.app
|
||||
|
||||
app.add_middleware(
|
||||
|
||||
@@ -87,7 +87,7 @@ class Directory:
|
||||
if debug_file.calls_debug_function():
|
||||
parent_dir.add_child(debug_file)
|
||||
else:
|
||||
raise Exception(f"[build_structure]: Entry is not dir or file: {entry.path}")
|
||||
continue
|
||||
|
||||
construct_project_structure(root_dir, self)
|
||||
# [print(f"[build_structure]: {file}") for file in project_structure]
|
||||
|
||||
+58
-1
@@ -12,11 +12,45 @@ const http = require('http');
|
||||
// (or macOS auto-launch + manual launch overlapping) spawns two independent
|
||||
// processes — each with its own backend on a different port — resulting in
|
||||
// one populated window and one empty window.
|
||||
// Register openswarm:// protocol handler BEFORE any gotLock branching.
|
||||
// Must happen synchronously at the top of main.js so the OS knows this
|
||||
// binary is the default handler even before whenReady fires.
|
||||
if (process.defaultApp) {
|
||||
// Dev run: `electron .` needs the entry-script path to re-launch cleanly.
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient('openswarm', process.execPath, [path.resolve(process.argv[1])]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('openswarm');
|
||||
}
|
||||
|
||||
// Pending deep-link captured before mainWindow exists (cold-launch case).
|
||||
// Flushed to renderer once mainWindow is ready.
|
||||
let pendingDeepLink = null;
|
||||
|
||||
function forwardDeepLinkToRenderer(url) {
|
||||
if (!url) return;
|
||||
if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) {
|
||||
mainWindow.webContents.send('openswarm:auth-url', url);
|
||||
} else {
|
||||
pendingDeepLink = url;
|
||||
}
|
||||
}
|
||||
|
||||
function extractOpenswarmUrl(argv) {
|
||||
return argv && argv.find((a) => typeof a === 'string' && a.startsWith('openswarm://'));
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.exit(0);
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
// Windows/Linux: a `openswarm://...` click lands here because the OS
|
||||
// re-launches the app with the URL as an argv. We swallow the second
|
||||
// instance, focus the existing window, and forward the URL to renderer.
|
||||
const url = extractOpenswarmUrl(argv);
|
||||
if (url) forwardDeepLinkToRenderer(url);
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
@@ -24,6 +58,14 @@ if (!gotLock) {
|
||||
});
|
||||
}
|
||||
|
||||
// macOS-only: clicks on openswarm:// links fire this event (instead of
|
||||
// relaunching the process).
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
forwardDeepLinkToRenderer(url);
|
||||
if (mainWindow) mainWindow.focus();
|
||||
});
|
||||
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
@@ -253,6 +295,15 @@ function createWindow() {
|
||||
mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
|
||||
});
|
||||
|
||||
// Once the renderer has loaded, flush any deep-link URL we captured before
|
||||
// the window existed (cold-launch via openswarm://).
|
||||
mainWindow.webContents.once('did-finish-load', () => {
|
||||
if (pendingDeepLink) {
|
||||
mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
|
||||
pendingDeepLink = null;
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
@@ -317,6 +368,12 @@ function killBackend() {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
|
||||
// in argv; macOS fires open-url AFTER whenReady which we handle above)
|
||||
// buffer the URL for when mainWindow loads.
|
||||
const initialDeepLink = extractOpenswarmUrl(process.argv);
|
||||
if (initialDeepLink) pendingDeepLink = initialDeepLink;
|
||||
|
||||
if (process.platform === 'darwin' && !isPackaged) {
|
||||
try { app.dock.setIcon(iconPath); } catch (_) {}
|
||||
}
|
||||
|
||||
@@ -54,5 +54,13 @@ const { contextBridge, ipcRenderer } = require('electron');
|
||||
ipcRenderer.on('webview-new-window', listener);
|
||||
return () => ipcRenderer.removeListener('webview-new-window', listener);
|
||||
},
|
||||
|
||||
// Deep-link callback: fires when the OS opens the app with an
|
||||
// openswarm://auth?token=... URL (after Stripe-hosted checkout).
|
||||
onAuthUrl: (cb) => {
|
||||
const listener = (_event, url) => cb(url);
|
||||
ipcRenderer.on('openswarm:auth-url', listener);
|
||||
return () => ipcRenderer.removeListener('openswarm:auth-url', listener);
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
||||
+25
-17
@@ -25,6 +25,7 @@ import Analytics from './pages/Analytics/Analytics';
|
||||
import OnboardingModal from './components/OnboardingModal';
|
||||
import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics';
|
||||
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
|
||||
import { useDeepLink } from '@/shared/hooks/useDeepLink';
|
||||
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
|
||||
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
@@ -155,6 +156,11 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
return <>{children}<KeyboardShortcutsHelp /></>;
|
||||
};
|
||||
|
||||
const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
useDeepLink();
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { setMode: setThemeMode } = useThemeMode();
|
||||
@@ -244,23 +250,25 @@ const ThemedApp: React.FC = () => {
|
||||
<ShortcutsProvider>
|
||||
<SettingsLoader>
|
||||
<UpdateListener>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardSelection />} />
|
||||
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
|
||||
persistently inside AppShell so its webviews survive navigation between
|
||||
routes. This route exists only so React Router matches the URL. */}
|
||||
<Route path="/dashboard/:id" element={null} />
|
||||
<Route path="/customization" element={<Customization />} />
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/actions" element={<Tools />} />
|
||||
<Route path="/modes" element={<Modes />} />
|
||||
<Route path="/apps" element={<Views />} />
|
||||
<Route path="/apps/:id" element={<Views />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<OnboardingModal />
|
||||
<DeepLinkListener>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardSelection />} />
|
||||
{/* Dashboard route is a no-op stub — the actual <Dashboard /> is rendered
|
||||
persistently inside AppShell so its webviews survive navigation between
|
||||
routes. This route exists only so React Router matches the URL. */}
|
||||
<Route path="/dashboard/:id" element={null} />
|
||||
<Route path="/customization" element={<Customization />} />
|
||||
<Route path="/skills" element={<Skills />} />
|
||||
<Route path="/actions" element={<Tools />} />
|
||||
<Route path="/modes" element={<Modes />} />
|
||||
<Route path="/apps" element={<Views />} />
|
||||
<Route path="/apps/:id" element={<Views />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<OnboardingModal />
|
||||
</DeepLinkListener>
|
||||
</UpdateListener>
|
||||
</SettingsLoader>
|
||||
</ShortcutsProvider>
|
||||
|
||||
@@ -45,10 +45,10 @@ function isValidEmail(email: string): boolean {
|
||||
}
|
||||
|
||||
const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
|
||||
{ id: 'openswarm-pro', name: 'OpenSwarm Pro', desc: 'One subscription — no setup, no Claude account needed', color: '#6366F1', preview: false, recommended: true },
|
||||
{ id: 'claude', name: 'Claude', desc: 'Use your own Claude Pro/Max subscription', color: '#E8927A', preview: false },
|
||||
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
|
||||
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true },
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
const USE_CASES = [
|
||||
@@ -150,6 +150,21 @@ const OnboardingModal: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-dismiss when a subscription activates via deep link while the
|
||||
// modal is open. activateSubscription refetches settings; we watch the
|
||||
// connection_mode field flipping to openswarm-pro.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const mode = (settings.data as any).connection_mode;
|
||||
const bearer = (settings.data as any).openswarm_bearer_token;
|
||||
if (mode === 'openswarm-pro' && bearer) {
|
||||
trackEvent('onboarding.openswarm_pro_activated');
|
||||
dismiss();
|
||||
}
|
||||
// dismiss is stable enough — don't include in deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, settings.data]);
|
||||
|
||||
const dismiss = async () => {
|
||||
localStorage.setItem('openswarm_onboarding_seen', 'true');
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
@@ -262,6 +277,28 @@ const OnboardingModal: React.FC = () => {
|
||||
setConnecting(providerId);
|
||||
trackEvent('onboarding.provider_selected', { provider: providerId });
|
||||
|
||||
// OpenSwarm Pro: no OAuth — just open the pricing/checkout page in the
|
||||
// system browser. The post-payment openswarm://auth deep link will
|
||||
// dismiss this modal automatically via useDeepLink → fetchSettings.
|
||||
if (providerId === 'openswarm-pro') {
|
||||
try {
|
||||
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
|
||||
});
|
||||
if (r.ok) {
|
||||
const { url } = await r.json();
|
||||
const api = (window as any).openswarm;
|
||||
if (url && api?.openExternal) api.openExternal(url);
|
||||
else if (url) window.open(url, '_blank');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create checkout session:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay before calling connect — avoids Claude OAuth rate limit on retries
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
|
||||
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
|
||||
const modelsLoaded = useAppSelector((state) => state.models.loaded);
|
||||
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
|
||||
const toolItems = useAppSelector((state) => state.tools.items);
|
||||
|
||||
// Count the total number of enabled MCP tool permissions (non-deny) across
|
||||
@@ -226,21 +227,27 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
|
||||
const MCP_WARNING_THRESHOLD = 20;
|
||||
|
||||
// Build flat model list with provider grouping
|
||||
// Build flat model list with provider grouping. When in openswarm-pro mode
|
||||
// the Anthropic section is renamed to "OpenSwarm Pro" so the user sees their
|
||||
// paid subscription is powering the Claude models.
|
||||
const allModelOptions = useMemo(() => {
|
||||
const isPro = connectionMode === 'openswarm-pro';
|
||||
const renameAnthropic = (prov: string) => (isPro && prov === 'Anthropic' ? 'OpenSwarm Pro' : prov);
|
||||
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
|
||||
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
|
||||
const key = renameAnthropic('Anthropic');
|
||||
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } };
|
||||
}
|
||||
const flat: Array<{ value: string; label: string; context_window: number; provider: string; reasoning: boolean }> = [];
|
||||
const grouped: Record<string, Array<{ value: string; label: string; context_window: number; reasoning: boolean }>> = {};
|
||||
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, reasoning: !!m.reasoning }));
|
||||
const key = renameAnthropic(prov);
|
||||
grouped[key] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
|
||||
for (const m of models) {
|
||||
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning });
|
||||
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: key, reasoning: !!m.reasoning });
|
||||
}
|
||||
}
|
||||
return { flat, grouped };
|
||||
}, [modelsByProvider, modelsLoaded]);
|
||||
}, [modelsByProvider, modelsLoaded, connectionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modesArr.length === 0) dispatch(fetchModes());
|
||||
@@ -1096,7 +1103,13 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
// menu layout static avoids the "cursor chases a moving
|
||||
// target" problem that happens when items above the cursor
|
||||
// appear/disappear.
|
||||
const isOpenSwarmPro = prov === 'OpenSwarm Pro';
|
||||
const brandColor = PROVIDER_COLORS[prov.toLowerCase()] ?? c.text.tertiary;
|
||||
// OpenSwarm Pro uses a warm blue→pink→orange gradient to stand
|
||||
// out as the recommended paid tier (distinct from plain provider
|
||||
// brand dots).
|
||||
const OPENSWARM_GRADIENT =
|
||||
'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)';
|
||||
return [
|
||||
<MenuItem
|
||||
key={`header-${prov}`}
|
||||
@@ -1108,8 +1121,10 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: brandColor,
|
||||
boxShadow: `0 0 6px ${brandColor}80`,
|
||||
background: isOpenSwarmPro ? OPENSWARM_GRADIENT : brandColor,
|
||||
boxShadow: isOpenSwarmPro
|
||||
? '0 0 8px rgba(229, 107, 196, 0.6)'
|
||||
: `0 0 6px ${brandColor}80`,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<Typography sx={{
|
||||
@@ -1117,7 +1132,14 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: brandColor,
|
||||
...(isOpenSwarmPro
|
||||
? {
|
||||
background: OPENSWARM_GRADIENT,
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}
|
||||
: { color: brandColor }),
|
||||
}}>
|
||||
{prov}
|
||||
</Typography>
|
||||
@@ -1133,6 +1155,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const provLower = prov.toLowerCase();
|
||||
const providerMap: Record<string, string> = {
|
||||
anthropic: 'anthropic',
|
||||
'openswarm pro': 'anthropic',
|
||||
openai: 'openai',
|
||||
google: 'gemini',
|
||||
xai: 'openrouter',
|
||||
@@ -1144,7 +1167,13 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
};
|
||||
onProviderChange(providerMap[provLower] || provLower);
|
||||
}
|
||||
if (prov.toLowerCase() !== 'anthropic' && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
|
||||
// OpenSwarm Pro routes Claude models through our proxy —
|
||||
// they're still Claude, so they support the deferred tool
|
||||
// loader. Only warn when the user picks a truly non-Claude
|
||||
// provider (GPT/Gemini/etc).
|
||||
const provLowerForWarn = prov.toLowerCase();
|
||||
const isClaudeProvider = provLowerForWarn === 'anthropic' || provLowerForWarn === 'openswarm pro';
|
||||
if (!isClaudeProvider && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
|
||||
setMcpWarningOpen(true);
|
||||
|
||||
@@ -62,6 +62,65 @@ const StreamingCursor: React.FC = () => {
|
||||
|
||||
const ELEMENT_SEPARATOR = '\n\n---\nSelected UI Elements:\n';
|
||||
|
||||
interface OpenSwarmErrorInfo {
|
||||
kind: 'cap' | 'capacity' | 'auth' | 'network';
|
||||
title: string;
|
||||
detail: string;
|
||||
ctaLabel?: string;
|
||||
ctaAction?: 'upgrade' | 'retry' | 'settings';
|
||||
}
|
||||
|
||||
// Turn a raw Claude-CLI / cloud error string into a user-friendly card.
|
||||
// Returns null for things that aren't obviously our errors — those fall
|
||||
// through to normal markdown rendering.
|
||||
function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
|
||||
if (!text) return null;
|
||||
// Rate-limit cap from our cloud
|
||||
if (/rate_limit_error|reached your OpenSwarm.*plan limit|Usage cap exceeded/i.test(text)) {
|
||||
const reset = text.match(/Resets in ([\dhms\s]+)/)?.[1];
|
||||
return {
|
||||
kind: 'cap',
|
||||
title: "You've hit your plan limit",
|
||||
detail: reset
|
||||
? `Your usage resets in ${reset}. Upgrade to keep going now, or wait for the window to reset.`
|
||||
: 'Upgrade to keep going now, or wait for your usage window to reset.',
|
||||
ctaLabel: 'Upgrade plan',
|
||||
ctaAction: 'upgrade',
|
||||
};
|
||||
}
|
||||
// Upstream capacity / 503
|
||||
if (/at capacity|Try again shortly|503|service unavailable/i.test(text)) {
|
||||
return {
|
||||
kind: 'capacity',
|
||||
title: 'OpenSwarm servers are busy',
|
||||
detail: "We're hitting capacity on our end — please retry in a moment. If this keeps happening, contact support.",
|
||||
ctaLabel: 'Try again',
|
||||
ctaAction: 'retry',
|
||||
};
|
||||
}
|
||||
// Auth / subscription problems
|
||||
if (/No active subscription|Subscription canceled|Subscription past_due|Invalid.*token|Missing bearer token/i.test(text)) {
|
||||
return {
|
||||
kind: 'auth',
|
||||
title: 'Subscription issue',
|
||||
detail: "We can't find an active OpenSwarm subscription. Check your billing status.",
|
||||
ctaLabel: 'Open Settings',
|
||||
ctaAction: 'settings',
|
||||
};
|
||||
}
|
||||
// Network issues (keep last so it doesn't swallow the specific cases above)
|
||||
if (/ECONNREFUSED|ENETUNREACH|fetch failed|ETIMEDOUT|network|Could not reach/i.test(text)) {
|
||||
return {
|
||||
kind: 'network',
|
||||
title: 'Network issue',
|
||||
detail: "Can't reach the OpenSwarm service. Check your internet connection and try again.",
|
||||
ctaLabel: 'Try again',
|
||||
ctaAction: 'retry',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ParsedElement {
|
||||
label: string;
|
||||
selector: string;
|
||||
@@ -600,6 +659,11 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
? parseElementContext(rawText)
|
||||
: { userMessage: rawText, elements: [] };
|
||||
|
||||
// Detect friendly OpenSwarm / upstream errors and render a card instead of
|
||||
// raw "API Error: ..." text. Checks both the wrapped format the Claude CLI
|
||||
// uses ("API Error: NNN …") and the raw JSON body.
|
||||
const openswarmError = !isUser ? parseOpenSwarmError(rawText) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editing) setEditText(rawText);
|
||||
}, [editing, rawText]);
|
||||
@@ -782,15 +846,77 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
'& a': { color: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
|
||||
),
|
||||
}}
|
||||
>{rawText}</ReactMarkdown>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
{openswarmError ? (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
p: 1.8,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${c.status.warning}40`,
|
||||
bgcolor: `${c.status.warning}10`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.7,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>
|
||||
{openswarmError.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
{openswarmError.detail}
|
||||
</Typography>
|
||||
{openswarmError.ctaLabel && (
|
||||
<Box sx={{ mt: 0.4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const api = (window as any).openswarm;
|
||||
if (openswarmError.ctaAction === 'upgrade') {
|
||||
const url = 'https://api.openswarm.com/api/stripe/checkout';
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan: 'pro_plus', billing_interval: 'monthly' }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then(({ url }) => {
|
||||
if (url && api?.openExternal) api.openExternal(url);
|
||||
else if (url) window.open(url, '_blank');
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (openswarmError.ctaAction === 'settings') {
|
||||
// Best-effort: dispatch a DOM event the Settings modal listens to
|
||||
window.dispatchEvent(new CustomEvent('openswarm:open-settings', { detail: { tab: 'models' } }));
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.78rem',
|
||||
borderColor: c.border.medium,
|
||||
color: c.text.primary,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
'&:hover': { borderColor: c.accent.primary },
|
||||
}}
|
||||
>
|
||||
{openswarmError.ctaLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
|
||||
),
|
||||
}}
|
||||
>{rawText}</ReactMarkdown>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -39,7 +39,7 @@ import LinearProgress from '@mui/material/LinearProgress';
|
||||
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 { updateSettings, closeSettingsModal, resetSystemPrompt, disconnectSubscription, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { setChecking, setUpdateError, setInstalling } from '@/shared/state/updateSlice';
|
||||
import { fetchModes } from '@/shared/state/modesSlice';
|
||||
@@ -126,6 +126,236 @@ const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; c
|
||||
);
|
||||
};
|
||||
|
||||
// ── OpenSwarm Pro managed-subscription card ──
|
||||
//
|
||||
// Renders either a "Subscribe" CTA (when not connected) or a live usage +
|
||||
// Manage/Disconnect card (when connection_mode === 'openswarm-pro'). All
|
||||
// billing details come from /api/subscription/status at runtime — no
|
||||
// pricing is hardcoded in this OSS repo.
|
||||
interface OpenSwarmProStatus {
|
||||
connected: boolean;
|
||||
connection_mode?: string;
|
||||
plan?: string | null;
|
||||
status?: string | null;
|
||||
expires?: string | null;
|
||||
usage?: {
|
||||
// Live utilization from Claude's /api/oauth/usage — 0-100 percent of the
|
||||
// shared pool subscription's 5h window consumed. Updated every ~30s.
|
||||
utilization?: number;
|
||||
window_hours?: number;
|
||||
window_ends_at?: number;
|
||||
pool_active_accounts?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const OpenSwarmProCard: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const [status, setStatus] = useState<OpenSwarmProStatus | null>(null);
|
||||
const [busy, setBusy] = useState<'manage' | 'disconnect' | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/subscription/status`);
|
||||
if (r.ok) setStatus(await r.json());
|
||||
} catch {
|
||||
// silently ignore — cloud might be offline
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const handleSubscribe = async () => {
|
||||
try {
|
||||
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
|
||||
});
|
||||
if (r.ok) {
|
||||
const { url } = await r.json();
|
||||
const api = (window as any).openswarm;
|
||||
if (url && api?.openExternal) api.openExternal(url);
|
||||
else if (url) window.open(url, '_blank');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create checkout session:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManage = async () => {
|
||||
setBusy('manage');
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/subscription/portal`, { method: 'POST' });
|
||||
if (r.ok) {
|
||||
const { url } = await r.json();
|
||||
const api = (window as any).openswarm;
|
||||
if (url && api?.openExternal) api.openExternal(url);
|
||||
else if (url) window.open(url, '_blank');
|
||||
}
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setBusy('disconnect');
|
||||
try {
|
||||
await dispatch(disconnectSubscription()).unwrap();
|
||||
await refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state — don't flash a CTA that disappears on first fetch.
|
||||
if (!status) return null;
|
||||
|
||||
const isConnected = !!status.connected;
|
||||
const usage = status.usage;
|
||||
// Pool utilization is live data from Claude's own /api/oauth/usage endpoint
|
||||
// — a 0-100 percentage for the current 5h window of the subscription we're
|
||||
// routing this user through.
|
||||
const pct = Math.max(0, Math.min(100, Math.round(usage?.utilization ?? 0)));
|
||||
const windowEndsAt = usage?.window_ends_at;
|
||||
|
||||
const expiresLabel = (() => {
|
||||
if (!status.expires) return null;
|
||||
try {
|
||||
const d = new Date(status.expires);
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const planLabel = (() => {
|
||||
if (!status.plan) return 'Pro';
|
||||
return status.plan
|
||||
.replace(/_/g, '+')
|
||||
.replace(/\b\w/g, (s) => s.toUpperCase());
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: `${c.radius.lg}px`,
|
||||
border: `1px solid ${isConnected ? c.accent.primary : c.border.subtle}`,
|
||||
bgcolor: isConnected ? `${c.accent.primary}08` : c.bg.surface,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: isConnected ? 1.5 : 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary }}>
|
||||
OpenSwarm Pro
|
||||
</Typography>
|
||||
{isConnected && (
|
||||
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}20` }}>
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.accent.primary, fontWeight: 600 }}>
|
||||
{planLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isConnected && (
|
||||
<Box sx={{ px: 0.9, py: 0.2, borderRadius: 999, bgcolor: `${c.accent.primary}15` }}>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.accent.primary, fontWeight: 600 }}>
|
||||
RECOMMENDED
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isConnected ? (
|
||||
<>
|
||||
{/* Usage bar — percentage only, no raw counts */}
|
||||
<Box sx={{ mb: 1.2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.secondary, fontWeight: 500 }}>
|
||||
Current usage
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
{pct}% used
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
bgcolor: `${c.accent.primary}15`,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: pct >= 90 ? c.status.warning : pct >= 70 ? c.status.info : c.accent.primary,
|
||||
borderRadius: 999,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{windowEndsAt && (
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted, mt: 0.4 }}>
|
||||
Resets {(() => {
|
||||
const diff = windowEndsAt - Date.now();
|
||||
if (diff <= 0) return 'soon';
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
const mins = Math.floor((diff % 3600000) / 60000);
|
||||
if (hrs > 0) return `in ${hrs} hr ${mins} min`;
|
||||
return `in ${mins} min`;
|
||||
})()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{expiresLabel && (
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, mb: 1.5 }}>
|
||||
{status.status === 'canceled' ? 'Expires' : 'Renews'} on {expiresLabel}
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
onClick={handleManage}
|
||||
disabled={busy !== null}
|
||||
size="small"
|
||||
variant="contained"
|
||||
sx={{ textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
{busy === 'manage' ? 'Opening…' : 'Manage in Stripe'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDisconnect}
|
||||
disabled={busy !== null}
|
||||
size="small"
|
||||
variant="text"
|
||||
sx={{ textTransform: 'none', fontSize: '0.78rem', color: c.text.muted }}
|
||||
>
|
||||
{busy === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1.5 }}>
|
||||
One subscription, no Claude account needed. We handle everything behind the scenes.
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={handleSubscribe}
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{ textTransform: 'none', fontSize: '0.82rem', borderRadius: `${c.radius.md}px` }}
|
||||
>
|
||||
Subscribe to OpenSwarm Pro
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const SubscriptionCards: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -1462,9 +1692,20 @@ const Settings: React.FC = () => {
|
||||
) : activeTab === 'models' ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5, animation: 'fadeIn 0.2s ease', '@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } } }}>
|
||||
|
||||
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
|
||||
{/* ── OPENSWARM PRO (managed) ── */}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
|
||||
Use Your Existing Subscriptions
|
||||
One Subscription, No Setup
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ ...descSx, mb: 0 }}>
|
||||
Don't have a Claude account? We'll handle it for you. One simple subscription covers Claude Sonnet, Opus, and Haiku.
|
||||
</Typography>
|
||||
|
||||
<OpenSwarmProCard />
|
||||
|
||||
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
|
||||
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
|
||||
Or Use Your Existing Subscriptions
|
||||
</Typography>
|
||||
|
||||
<Typography sx={{ ...descSx, mb: 0 }}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppDispatch } from '@/shared/hooks';
|
||||
import { activateSubscription } from '@/shared/state/settingsSlice';
|
||||
import { fetchModels } from '@/shared/state/modelsSlice';
|
||||
import { trackEvent } from '@/shared/analytics';
|
||||
|
||||
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
|
||||
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
|
||||
// payload and dispatches activateSubscription so the backend validates and
|
||||
// persists the bearer.
|
||||
//
|
||||
// Safe no-op in web/browser contexts where window.openswarm isn't defined.
|
||||
export function useDeepLink(): void {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
const api = (window as any).openswarm as OpenSwarmAPI | undefined;
|
||||
if (!api?.onAuthUrl) return;
|
||||
|
||||
const unsubscribe = api.onAuthUrl((rawUrl: string) => {
|
||||
try {
|
||||
// openswarm://auth?token=... (host = "auth", search carries fields)
|
||||
const url = new URL(rawUrl);
|
||||
if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') {
|
||||
console.warn('[deep-link] Unknown openswarm:// host:', url.host);
|
||||
return;
|
||||
}
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) {
|
||||
console.warn('[deep-link] Missing token in', rawUrl);
|
||||
return;
|
||||
}
|
||||
const plan = url.searchParams.get('plan');
|
||||
const expires = url.searchParams.get('expires');
|
||||
|
||||
trackEvent('subscription.deep_link_received', {
|
||||
plan: plan ?? 'unknown',
|
||||
});
|
||||
|
||||
dispatch(
|
||||
activateSubscription({
|
||||
token,
|
||||
plan,
|
||||
expires,
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
.then((res) => {
|
||||
trackEvent('subscription.activated', { plan: res.plan });
|
||||
// Re-fetch the model list so the Claude models (via OpenSwarm
|
||||
// Pro proxy) show up in the chat picker right away.
|
||||
dispatch(fetchModels());
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[deep-link] Activation failed:', err);
|
||||
trackEvent('subscription.activation_failed', {
|
||||
message: String(err).slice(0, 120),
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[deep-link] Failed to parse URL', rawUrl, e);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [dispatch]);
|
||||
}
|
||||
@@ -27,6 +27,13 @@ export interface CustomProvider {
|
||||
models: Array<{ value: string; label: string; context_window?: number }>;
|
||||
}
|
||||
|
||||
export interface SubscriptionUsage {
|
||||
requests_in_window: number;
|
||||
plan_limit: number;
|
||||
window_hours: number;
|
||||
window_ends_at: number; // unix ms
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
default_system_prompt: string | null;
|
||||
default_folder: string | null;
|
||||
@@ -46,6 +53,20 @@ export interface AppSettings {
|
||||
expand_new_chats_in_dashboard: boolean;
|
||||
auto_reveal_sub_agents: boolean;
|
||||
dev_mode: boolean;
|
||||
// Optional managed-subscription state (surfaces only when user has
|
||||
// subscribed via the cloud). Mirrors AppSettings on the backend.
|
||||
connection_mode?: 'own_key' | 'openswarm-pro';
|
||||
openswarm_bearer_token?: string | null;
|
||||
openswarm_proxy_url?: string | null;
|
||||
openswarm_subscription_plan?: string | null;
|
||||
openswarm_subscription_expires?: string | null;
|
||||
openswarm_usage_cached?: SubscriptionUsage | null;
|
||||
}
|
||||
|
||||
export interface ActivateSubscriptionPayload {
|
||||
token: string;
|
||||
plan?: string | null;
|
||||
expires?: string | null;
|
||||
}
|
||||
|
||||
export interface BrowseResult {
|
||||
@@ -123,6 +144,36 @@ export const browseDirectories = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/subscription/activate — called after the desktop catches an
|
||||
// openswarm://auth?token=... deep link. Validates + persists on the backend,
|
||||
// then refreshes settings so the Settings UI flips to "Pro" mode.
|
||||
export const activateSubscription = createAsyncThunk(
|
||||
'settings/activateSubscription',
|
||||
async (payload: ActivateSubscriptionPayload, { dispatch }) => {
|
||||
const res = await fetch(`${API_BASE}/subscription/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()) || 'Activation failed');
|
||||
// Pull the fresh settings so UI reflects connection_mode + plan.
|
||||
await dispatch(fetchSettings());
|
||||
return (await res.json()) as { ok: boolean; plan: string };
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/subscription/disconnect — clears bearer + reverts to own_key.
|
||||
// Doesn't cancel the Stripe subscription (that's the Portal).
|
||||
export const disconnectSubscription = createAsyncThunk(
|
||||
'settings/disconnectSubscription',
|
||||
async (_: void, { dispatch }) => {
|
||||
const res = await fetch(`${API_BASE}/subscription/disconnect`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Disconnect failed');
|
||||
await dispatch(fetchSettings());
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
name: 'settings',
|
||||
initialState,
|
||||
|
||||
Vendored
+3
@@ -45,6 +45,9 @@ declare global {
|
||||
onUpdateDownloaded: (cb: (info: OpenSwarmUpdateInfo) => void) => () => void;
|
||||
onUpdateError: (cb: (message: string) => void) => () => void;
|
||||
onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
// Deep-link listener — fires when OS opens the app with openswarm://... URL.
|
||||
onAuthUrl?: (cb: (url: string) => void) => () => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user