mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] merge free-trial + value-first onboarding (flag off, reorder gated so no-trial = today's flow)
# Conflicts: # frontend/src/app/pages/AgentChat/bubbles/MessageBubble.tsx
This commit is contained in:
@@ -30,6 +30,7 @@ from backend.apps.agents.core.error_classify import (
|
||||
_NON_TRANSIENT_PATTERNS,
|
||||
_TRANSIENT_CAPACITY_PATTERNS,
|
||||
_is_auth_error,
|
||||
_is_free_trial_exhausted,
|
||||
_is_long_context_error,
|
||||
_is_transient_capacity_error,
|
||||
_is_unknown_model_error,
|
||||
@@ -1253,7 +1254,7 @@ class AgentManager:
|
||||
and _primary_is_claude
|
||||
and anthropic_web_search_is_reliable(
|
||||
uses_direct_anthropic_api=_uses_direct_anthropic_api,
|
||||
is_pro=(getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro"),
|
||||
is_pro=(getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1593,19 +1594,21 @@ class AgentManager:
|
||||
env["ENABLE_TOOL_SEARCH"] = "auto"
|
||||
options_kwargs["env"] = env
|
||||
logger.info(f"[MCP-DEBUG] Using OpenRouter for {session.model}")
|
||||
elif api_type == "anthropic" and not resolved_is_9router 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 ""
|
||||
elif api_type == "anthropic" and not resolved_is_9router and getattr(global_settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"):
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
bearer, proxy_url = proxy_auth(global_settings)
|
||||
bearer = bearer or ""
|
||||
options_kwargs["env"] = {
|
||||
"ANTHROPIC_AUTH_TOKEN": bearer,
|
||||
"ANTHROPIC_BASE_URL": proxy_url,
|
||||
# Pin subagent ids; CLI default 'claude-haiku-4-5-20251001'
|
||||
# gets rejected by Pro's surface as "No credentials for provider: anthropic".
|
||||
# (Free-trial clamps to its allowed Claude set + weights credits server-side.)
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL": "claude-sonnet-4-6",
|
||||
"ANTHROPIC_SMALL_FAST_MODEL": "claude-haiku-4-5-20251001",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
logger.info(f"[MCP-DEBUG] Using OpenSwarm Pro proxy at {proxy_url}")
|
||||
logger.info(f"[MCP-DEBUG] Using OpenSwarm cloud proxy at {proxy_url}")
|
||||
elif api_type == "anthropic" and not resolved_is_9router 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")
|
||||
@@ -2701,12 +2704,12 @@ class AgentManager:
|
||||
# showing the SDK's Anthropic-rate
|
||||
# estimate, which is meaningless here.
|
||||
_free_route = True
|
||||
if (
|
||||
api_type == "anthropic"
|
||||
and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro"
|
||||
and getattr(global_settings, "openswarm_bearer_token", None)
|
||||
):
|
||||
_free_route = True
|
||||
if api_type == "anthropic":
|
||||
from backend.apps.settings.credentials import proxy_auth as _proxy_auth
|
||||
_pa_tok, _ = _proxy_auth(global_settings)
|
||||
# Pro and free-trial both run server-funded, so per-token cost to the user is 0.
|
||||
if _pa_tok:
|
||||
_free_route = True
|
||||
|
||||
if _free_route:
|
||||
cost = 0.0
|
||||
@@ -2944,6 +2947,29 @@ class AgentManager:
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
|
||||
elif _is_free_trial_exhausted(e, extra_text=_stderr_tail):
|
||||
# Free runs spent. Flip back to own_key and show a friendly
|
||||
# "connect a model" upsell instead of a raw 402.
|
||||
try:
|
||||
from backend.apps.subscription.free_trial import clear_free_trial
|
||||
await clear_free_trial(load_settings())
|
||||
except Exception:
|
||||
logger.debug("clear_free_trial after exhaustion failed", exc_info=True)
|
||||
friendly_msg = (
|
||||
"You've used your free runs. Connect a model to keep going: "
|
||||
"your own API key, an AI subscription you already pay for, or "
|
||||
"OpenSwarm Pro."
|
||||
)
|
||||
error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id)
|
||||
session.messages.append(error_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:free_trial_exhausted", {
|
||||
"session_id": session_id,
|
||||
"message": friendly_msg,
|
||||
})
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
elif _is_auth_error(e, extra_text=_stderr_tail):
|
||||
# Three sub-cases the user can hit, with distinct fixes:
|
||||
# 1. "No credentials for provider: claude", user picked a
|
||||
|
||||
@@ -31,6 +31,7 @@ _NON_TRANSIENT_PATTERNS = re.compile(
|
||||
r"|missing\s+bearer\s+token"
|
||||
r"|extra\s+usage\s+is\s+required\s+for\s+long\s+context"
|
||||
r"|long\s+context\s+(?:requests?\s+)?(?:requires?|not\s+(?:available|enabled))"
|
||||
r"|free_trial_exhausted|used\s+your\s+free"
|
||||
r"|401|403)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
@@ -53,6 +54,21 @@ def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the cloud says the machine's free runs are spent (a 402 with
|
||||
type free_trial_exhausted). The catch-all path uses this to flip back to
|
||||
own_key and show a friendly connect-a-model upsell instead of a raw error.
|
||||
"""
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"free_trial_exhausted|used\s+your\s+free\s+(?:openswarm\s+)?runs",
|
||||
combined,
|
||||
re.IGNORECASE,
|
||||
))
|
||||
|
||||
|
||||
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the upstream error is a 401/403 auth failure.
|
||||
|
||||
|
||||
@@ -248,7 +248,11 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
|
||||
if entry.get("route") == "openrouter":
|
||||
return entry.get("router_model_id", short_name)
|
||||
if entry.get("api") == "anthropic":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
# openswarm-pro AND free-trial both proxy-route, so resolve to the bare
|
||||
# id (the proxy serves it) instead of the cc/-prefixed id that 401s when
|
||||
# no Claude subscription is connected. This is the line that otherwise
|
||||
# turns a free-trial user's first run into "No AI provider connected".
|
||||
if getattr(settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"):
|
||||
return entry.get("model_id", short_name)
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
return entry.get("model_id", short_name)
|
||||
@@ -337,9 +341,11 @@ async def resolve_aux_model(
|
||||
if "openrouter" in connected:
|
||||
return (or_aux, base_url)
|
||||
|
||||
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)
|
||||
if getattr(settings, "connection_mode", "own_key") in ("openswarm-pro", "free-trial"):
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
token, base = proxy_auth(settings)
|
||||
if token:
|
||||
return (bare, base)
|
||||
|
||||
if getattr(settings, "anthropic_api_key", None):
|
||||
return (bare, None)
|
||||
|
||||
@@ -326,17 +326,17 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str
|
||||
|
||||
|
||||
async def sync_pro_routing(settings_obj) -> None:
|
||||
"""Mirror the settings' pro-mode state into the 9Router Claude lane.
|
||||
Call after any flow that changes connection_mode or the bearer
|
||||
(activate, sign-in, sign-out, disconnect). Never raises."""
|
||||
"""Mirror the settings' cloud-proxy state (openswarm-pro OR free-trial) into
|
||||
the 9Router Claude lane. Call after any flow that changes connection_mode or
|
||||
the bearer (activate, sign-in, sign-out, disconnect, free-trial arm/clear).
|
||||
Never raises."""
|
||||
try:
|
||||
pro = getattr(settings_obj, "connection_mode", None) == "openswarm-pro"
|
||||
bearer = getattr(settings_obj, "openswarm_bearer_token", None)
|
||||
proxy = getattr(settings_obj, "openswarm_proxy_url", None) or "https://api.openswarm.com"
|
||||
active = bool(pro and bearer)
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
bearer, base = proxy_auth(settings_obj)
|
||||
active = bool(bearer)
|
||||
await sync_openswarm_pro_as_claude(
|
||||
bearer if active else None,
|
||||
proxy if active else None,
|
||||
base if active else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"OpenSwarm-Pro → Claude sync failed: {e}")
|
||||
|
||||
@@ -10,6 +10,26 @@ if TYPE_CHECKING:
|
||||
|
||||
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.com"
|
||||
|
||||
# Connection modes that route Claude traffic through our cloud proxy with a
|
||||
# bearer instead of a user-held key. Free-trial is openswarm-pro's cheaper
|
||||
# sibling: same proxy, but pointed at the /free sub-path the cloud meters and
|
||||
# forces to Haiku.
|
||||
PROXY_CONNECTION_MODES = ("openswarm-pro", "free-trial")
|
||||
|
||||
|
||||
def proxy_auth(settings: AppSettings) -> tuple[str | None, str | None]:
|
||||
"""(auth_token, base_url) for whichever cloud-proxy mode is active, else
|
||||
(None, None). Consumers append /v1/messages to base_url as usual; for
|
||||
free-trial the base carries the /free segment so the same SDK lands on the
|
||||
metered route."""
|
||||
mode = getattr(settings, "connection_mode", "own_key")
|
||||
base = (getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
|
||||
if mode == "openswarm-pro":
|
||||
return (getattr(settings, "openswarm_bearer_token", None), base)
|
||||
if mode == "free-trial":
|
||||
return (getattr(settings, "free_trial_token", None), base + "/free")
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _check_9router() -> bool:
|
||||
"""Check if 9Router is running locally."""
|
||||
@@ -34,8 +54,9 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
|
||||
return
|
||||
|
||||
if p == "anthropic":
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
if not getattr(settings, "openswarm_bearer_token", None):
|
||||
if getattr(settings, "connection_mode", "own_key") in PROXY_CONNECTION_MODES:
|
||||
token, _ = proxy_auth(settings)
|
||||
if not token:
|
||||
raise ValueError("Open Swarm account not connected. Sign in via Settings -> API.")
|
||||
return
|
||||
if settings.anthropic_api_key:
|
||||
@@ -72,10 +93,11 @@ 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") == "openswarm-pro":
|
||||
if getattr(settings, "connection_mode", "own_key") in PROXY_CONNECTION_MODES:
|
||||
token, base = proxy_auth(settings)
|
||||
return {
|
||||
"auth_token": getattr(settings, "openswarm_bearer_token", "") or "",
|
||||
"base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
|
||||
"auth_token": token or "",
|
||||
"base_url": base or OPENSWARM_DEFAULT_PROXY_URL,
|
||||
}
|
||||
return {"api_key": settings.anthropic_api_key or ""}
|
||||
|
||||
@@ -101,11 +123,11 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
|
||||
"""Return an AsyncAnthropic client for the user's current connection mode."""
|
||||
import anthropic
|
||||
|
||||
if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
|
||||
if getattr(settings, "connection_mode", "own_key") in PROXY_CONNECTION_MODES:
|
||||
token, base = proxy_auth(settings)
|
||||
return anthropic.AsyncAnthropic(
|
||||
auth_token=getattr(settings, "openswarm_bearer_token", None),
|
||||
base_url=proxy_url,
|
||||
auth_token=token,
|
||||
base_url=base or OPENSWARM_DEFAULT_PROXY_URL,
|
||||
)
|
||||
|
||||
# Prefer the user's own API key when present.
|
||||
|
||||
@@ -69,6 +69,13 @@ class AppSettings(BaseModel):
|
||||
connection_mode: str = "own_key"
|
||||
openswarm_bearer_token: Optional[str] = None
|
||||
openswarm_proxy_url: Optional[str] = None
|
||||
# Zero-config free trial: server-funded runs for a brand-new user with no
|
||||
# key and no subscription. connection_mode flips to "free-trial" while armed;
|
||||
# the token + remaining count are server-owned (minted by the cloud, sticky
|
||||
# per machine). remaining is cached for the onboarding "runs low" nudge.
|
||||
free_trial_token: Optional[str] = None
|
||||
free_trial_remaining: Optional[int] = None
|
||||
free_trial_runs_limit: Optional[int] = None
|
||||
openswarm_subscription_plan: Optional[str] = None
|
||||
openswarm_subscription_expires: Optional[str] = None
|
||||
openswarm_usage_cached: Optional[dict] = None
|
||||
|
||||
@@ -45,7 +45,7 @@ async def settings_lifespan():
|
||||
getattr(s, "google_api_key", None),
|
||||
getattr(s, "openai_api_key", None),
|
||||
getattr(s, "openrouter_api_key", None),
|
||||
getattr(s, "connection_mode", None) == "openswarm-pro",
|
||||
getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"),
|
||||
bool(getattr(s, "custom_providers", None) or []),
|
||||
])
|
||||
if needs_router:
|
||||
@@ -59,11 +59,11 @@ async def settings_lifespan():
|
||||
await sync_openai_api_key(s.openai_api_key)
|
||||
if getattr(s, "openrouter_api_key", None):
|
||||
await sync_openrouter_api_key(s.openrouter_api_key)
|
||||
if getattr(s, "connection_mode", None) == "openswarm-pro":
|
||||
bearer = getattr(s, "openswarm_bearer_token", None)
|
||||
proxy = getattr(s, "openswarm_proxy_url", None) or "https://api.openswarm.com"
|
||||
if getattr(s, "connection_mode", None) in ("openswarm-pro", "free-trial"):
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
bearer, base = proxy_auth(s)
|
||||
if bearer:
|
||||
await sync_openswarm_pro_as_claude(bearer, proxy)
|
||||
await sync_openswarm_pro_as_claude(bearer, base)
|
||||
await sync_custom_providers(getattr(s, "custom_providers", None) or [])
|
||||
|
||||
_asyncio.create_task(_boot_router_then_sync())
|
||||
@@ -123,6 +123,9 @@ SERVER_OWNED_FIELDS = (
|
||||
"openswarm_subscription_plan",
|
||||
"openswarm_subscription_expires",
|
||||
"openswarm_usage_cached",
|
||||
"free_trial_token",
|
||||
"free_trial_remaining",
|
||||
"free_trial_runs_limit",
|
||||
"user_id",
|
||||
"signin_method",
|
||||
"installation_id",
|
||||
@@ -140,9 +143,26 @@ async def update_settings(body: AppSettings):
|
||||
for k in SERVER_OWNED_FIELDS:
|
||||
setattr(body, k, getattr(old, k, None))
|
||||
|
||||
# If the user connects their own model while the free trial is armed, hand
|
||||
# the wheel back to their provider. Without this, connection_mode (server-
|
||||
# owned, so the loop above just restored it to "free-trial") would keep them
|
||||
# pinned to the forced Haiku lane even though they pasted a real key.
|
||||
if getattr(old, "connection_mode", "own_key") == "free-trial":
|
||||
from backend.apps.subscription.free_trial import _has_own_model
|
||||
if _has_own_model(body):
|
||||
body.connection_mode = "own_key"
|
||||
body.free_trial_token = None
|
||||
body.free_trial_remaining = None
|
||||
try:
|
||||
import asyncio as _aio
|
||||
from backend.apps.nine_router import sync_pro_routing as _spr
|
||||
_aio.create_task(_spr(body)) # drop the now-stale free-trial 9router node
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
|
||||
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
||||
"openswarm_bearer_token", "installation_id"}
|
||||
"openswarm_bearer_token", "free_trial_token", "installation_id"}
|
||||
safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys}
|
||||
_sync(safe)
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Zero-config free trial: arm/refresh/clear the server-funded free runs.
|
||||
|
||||
A brand-new user with no key and no subscription gets a small number of agent
|
||||
runs funded through the cloud's shared pool, so they see the product work before
|
||||
being asked to connect anything. Identity is a hashed hardware fingerprint
|
||||
computed here in the backend (no Electron IPC), so deleting/reinstalling the app
|
||||
does not reset the count. The cloud is authoritative for the run count and the
|
||||
forced cheap model; this module only mirrors state into settings and 9Router.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
|
||||
from backend.apps.settings.settings import save_settings_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Namespaces the hash so a raw hardware UUID never leaves the device. Public on
|
||||
# purpose (open-source): it only prevents transmitting the raw id, not a secret.
|
||||
_FP_SALT = "openswarm-free-trial-v1"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
# Default OFF: the cloud free-trial proxy is staging-only, so on a shipped
|
||||
# build arming would 404. Flip to "1" once the cloud ships to prod and the
|
||||
# packaged-build fingerprint is verified. Off = the reorder gate falls back
|
||||
# to today's connect-model-first onboarding (no regression).
|
||||
return os.environ.get("OPENSWARM_FREE_TRIAL_ENABLED", "0") == "1"
|
||||
|
||||
|
||||
def _raw_hardware_id() -> str | None:
|
||||
"""A stable per-machine id that survives app reinstall / data wipe."""
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Darwin":
|
||||
out = subprocess.run(
|
||||
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
).stdout
|
||||
m = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', out)
|
||||
return m.group(1) if m else None
|
||||
if system == "Windows":
|
||||
import winreg # type: ignore
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography"
|
||||
) as key:
|
||||
val, _ = winreg.QueryValueEx(key, "MachineGuid")
|
||||
return val or None
|
||||
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
||||
if os.path.exists(path):
|
||||
with open(path, "r") as f:
|
||||
v = f.read().strip()
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _fingerprint(settings_obj) -> str | None:
|
||||
raw = _raw_hardware_id()
|
||||
if not raw:
|
||||
# Fail-soft: installation_id is less durable (regenerates on wipe) but
|
||||
# better than nothing on a machine where the hardware id can't be read.
|
||||
raw = getattr(settings_obj, "installation_id", None)
|
||||
if not raw:
|
||||
return None
|
||||
return hashlib.sha256((_FP_SALT + raw).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _has_own_model(s) -> bool:
|
||||
"""True if the user already has any real model path in settings; never shadow it."""
|
||||
if any(getattr(s, k, None) for k in (
|
||||
"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
|
||||
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
|
||||
)):
|
||||
return True
|
||||
if getattr(s, "connection_mode", "own_key") == "openswarm-pro" and getattr(s, "openswarm_bearer_token", None):
|
||||
return True
|
||||
for cp in (getattr(s, "custom_providers", None) or []):
|
||||
name = cp.get("name") if isinstance(cp, dict) else getattr(cp, "name", None)
|
||||
base = cp.get("base_url") if isinstance(cp, dict) else getattr(cp, "base_url", None)
|
||||
if (name or "").strip() and (base or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _has_connected_subscription() -> bool:
|
||||
"""True if 9Router holds a live Claude/ChatGPT/Gemini subscription. Those
|
||||
connections live in 9Router, not settings, so the sync check above misses
|
||||
them; this catches a sub connected while the trial was armed."""
|
||||
try:
|
||||
from backend.apps.nine_router import is_running as _9r_running, get_providers as _9r_providers
|
||||
if not _9r_running():
|
||||
return False
|
||||
conns = await _9r_providers()
|
||||
return any(
|
||||
c.get("isActive") and c.get("provider") in ("claude", "codex", "gemini-cli")
|
||||
for c in conns
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _proxy_base(settings_obj) -> str:
|
||||
return (getattr(settings_obj, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL).rstrip("/")
|
||||
|
||||
|
||||
async def _sync_routing(settings_obj) -> None:
|
||||
try:
|
||||
from backend.apps.nine_router import sync_pro_routing
|
||||
await sync_pro_routing(settings_obj)
|
||||
except Exception as e:
|
||||
logger.debug("free-trial routing sync skipped: %s", e)
|
||||
|
||||
|
||||
async def clear_free_trial(settings_obj) -> None:
|
||||
"""Drop the trial token and revert to own_key. Keeps free_trial_remaining
|
||||
(so the UI knows it's spent) and never touches a real paid mode."""
|
||||
if getattr(settings_obj, "connection_mode", "own_key") == "free-trial":
|
||||
settings_obj.connection_mode = "own_key"
|
||||
settings_obj.free_trial_token = None
|
||||
await save_settings_async(settings_obj)
|
||||
await _sync_routing(settings_obj)
|
||||
|
||||
|
||||
async def arm_free_trial(settings_obj) -> dict:
|
||||
"""Mint (or re-fetch) the machine's grant and, if runs remain, flip into
|
||||
free-trial mode. Guarded: never arms over a real key/subscription."""
|
||||
if not _enabled():
|
||||
return {"armed": False, "reason": "disabled"}
|
||||
mode = getattr(settings_obj, "connection_mode", "own_key")
|
||||
if mode not in ("own_key", "free-trial"):
|
||||
return {"armed": False, "reason": "other_mode"}
|
||||
if _has_own_model(settings_obj) or await _has_connected_subscription():
|
||||
# A real model exists now (key, custom provider, or a 9Router sub). If we
|
||||
# were on the free lane, hand the wheel back instead of re-arming.
|
||||
if mode == "free-trial":
|
||||
await clear_free_trial(settings_obj)
|
||||
return {"armed": False, "reason": "has_model"}
|
||||
|
||||
fp = _fingerprint(settings_obj)
|
||||
if not fp:
|
||||
return {"armed": False, "reason": "no_fingerprint"}
|
||||
|
||||
base = _proxy_base(settings_obj)
|
||||
payload: dict = {"fingerprint_hash": fp}
|
||||
if getattr(settings_obj, "installation_id", None):
|
||||
payload["install_id"] = settings_obj.installation_id
|
||||
if getattr(settings_obj, "user_id", None):
|
||||
payload["user_id"] = settings_obj.user_id
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
r = await client.post(f"{base}/api/free-trial/mint", json=payload)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("free-trial mint network error: %s", e)
|
||||
return {"armed": False, "reason": "network"}
|
||||
if r.status_code != 200:
|
||||
return {"armed": False, "reason": "upstream", "code": r.status_code}
|
||||
|
||||
data = r.json()
|
||||
remaining = int(data.get("runs_remaining") or 0)
|
||||
settings_obj.free_trial_remaining = remaining
|
||||
settings_obj.free_trial_runs_limit = int(data.get("runs_limit") or 0) or None
|
||||
|
||||
if remaining > 0:
|
||||
settings_obj.connection_mode = "free-trial"
|
||||
settings_obj.free_trial_token = data.get("trial_token")
|
||||
settings_obj.openswarm_proxy_url = base
|
||||
await save_settings_async(settings_obj)
|
||||
await _sync_routing(settings_obj)
|
||||
return {"armed": True, "runs_remaining": remaining, "runs_limit": settings_obj.free_trial_runs_limit}
|
||||
|
||||
# Already spent on this machine: record it but don't arm.
|
||||
await clear_free_trial(settings_obj)
|
||||
return {"armed": False, "reason": "exhausted", "runs_remaining": 0}
|
||||
|
||||
|
||||
async def refresh_free_trial(settings_obj) -> dict:
|
||||
"""Re-read remaining runs from the cloud. Called after a session ends so the
|
||||
onboarding 'runs low' nudge stays honest. Clears the trial when spent."""
|
||||
token = getattr(settings_obj, "free_trial_token", None)
|
||||
if getattr(settings_obj, "connection_mode", "own_key") != "free-trial" or not token:
|
||||
return {"connected": False, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)}
|
||||
|
||||
base = _proxy_base(settings_obj)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.post(
|
||||
f"{base}/api/free-trial/status",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return {"connected": True, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)}
|
||||
|
||||
if r.status_code == 401:
|
||||
await clear_free_trial(settings_obj)
|
||||
return {"connected": False, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)}
|
||||
if r.status_code != 200:
|
||||
return {"connected": True, "runs_remaining": getattr(settings_obj, "free_trial_remaining", None)}
|
||||
|
||||
data = r.json()
|
||||
remaining = int(data.get("runs_remaining") or 0)
|
||||
settings_obj.free_trial_remaining = remaining
|
||||
if remaining <= 0:
|
||||
await clear_free_trial(settings_obj)
|
||||
return {"connected": False, "runs_remaining": 0}
|
||||
await save_settings_async(settings_obj)
|
||||
return {"connected": True, "runs_remaining": remaining, "runs_limit": getattr(settings_obj, "free_trial_runs_limit", None)}
|
||||
@@ -343,6 +343,26 @@ async def portal():
|
||||
return {"url": data.get("url")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Free zero-config trial
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@subscription.router.post("/free-trial/mint")
|
||||
async def free_trial_mint():
|
||||
"""Renderer calls this on launch when no model is connected. Mints (or
|
||||
re-fetches) the machine's grant and arms free-trial mode if runs remain.
|
||||
Best-effort: never raises, returns {armed: false, reason} on any miss."""
|
||||
from backend.apps.subscription.free_trial import arm_free_trial
|
||||
return await arm_free_trial(load_settings())
|
||||
|
||||
|
||||
@subscription.router.post("/free-trial/status")
|
||||
async def free_trial_status():
|
||||
"""Refresh remaining free runs from the cloud (drives the onboarding nudge)."""
|
||||
from backend.apps.subscription.free_trial import refresh_free_trial
|
||||
return await refresh_free_trial(load_settings())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/subscription/disconnect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Free-trial dispatch injection: the pure-logic pieces that decide routing."""
|
||||
|
||||
import backend # noqa: F401 (path sanity asserted below)
|
||||
|
||||
from backend.apps.settings.models import AppSettings
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
from backend.apps.agents.core.error_classify import (
|
||||
_is_free_trial_exhausted,
|
||||
_is_transient_capacity_error,
|
||||
)
|
||||
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
|
||||
from backend.apps.subscription.free_trial import _has_own_model
|
||||
|
||||
|
||||
def test_proxy_auth_for_each_mode():
|
||||
assert proxy_auth(AppSettings()) == (None, None)
|
||||
|
||||
pro = AppSettings(
|
||||
connection_mode="openswarm-pro",
|
||||
openswarm_bearer_token="bear",
|
||||
openswarm_proxy_url="https://api.openswarm.com",
|
||||
)
|
||||
assert proxy_auth(pro) == ("bear", "https://api.openswarm.com")
|
||||
|
||||
free = AppSettings(
|
||||
connection_mode="free-trial",
|
||||
free_trial_token="ftk",
|
||||
openswarm_proxy_url="https://api.openswarm.com",
|
||||
)
|
||||
# Free-trial carries the /free segment so the same SDK lands on the metered route.
|
||||
assert proxy_auth(free) == ("ftk", "https://api.openswarm.com/free")
|
||||
|
||||
|
||||
def test_free_trial_resolves_to_a_bare_anthropic_id():
|
||||
s = AppSettings(connection_mode="free-trial", free_trial_token="ftk")
|
||||
mid = resolve_model_id_for_sdk("sonnet", s)
|
||||
# The bug this fixes: without the free-trial branch this returns a cc/-prefixed
|
||||
# id that 401s when no Claude subscription is connected.
|
||||
assert "cc/" not in mid
|
||||
assert mid.startswith("claude-")
|
||||
|
||||
|
||||
def test_exhaustion_is_classified_and_not_retried():
|
||||
assert _is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
|
||||
assert _is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
|
||||
assert not _is_free_trial_exhausted(Exception("overloaded, try again"))
|
||||
# Must NOT look transient, or the agent loop would retry a spent trial forever.
|
||||
assert not _is_transient_capacity_error(Exception("free_trial_exhausted"))
|
||||
|
||||
|
||||
def test_has_own_model_never_shadows_a_real_provider():
|
||||
assert not _has_own_model(AppSettings(connection_mode="free-trial", free_trial_token="x"))
|
||||
assert not _has_own_model(AppSettings())
|
||||
assert _has_own_model(AppSettings(anthropic_api_key="sk-ant-x"))
|
||||
assert _has_own_model(
|
||||
AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="b")
|
||||
)
|
||||
@@ -232,7 +232,16 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
.then((r) => {
|
||||
if (r.ok) dispatch(fetchSettings());
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
// Arm the zero-config free trial when nothing is connected so a brand-new
|
||||
// user can run an agent immediately. The backend no-ops if a real key or
|
||||
// subscription exists, so this is safe to fire on every launch.
|
||||
fetch(`${API_BASE}/subscription/free-trial/mint`, { method: 'POST' })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => { if (data && data.armed) dispatch(fetchSettings()); })
|
||||
.catch(() => {});
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAppDispatch } from '@/shared/hooks';
|
||||
import { useOnboardingProgress } from './hooks/useOnboardingProgress';
|
||||
import { clearJustCompleted } from '@/shared/state/onboardingProgressSlice';
|
||||
import { STEPS, findStepById } from './steps';
|
||||
import { useUnlockedStepIds } from './steps/stepUnlock';
|
||||
import { STAGE_LABELS } from './steps/types';
|
||||
import { onboardingDirector } from './OnboardingDirector';
|
||||
import { report } from './telemetry';
|
||||
@@ -59,13 +60,26 @@ const OnboardingPanel: React.FC = () => {
|
||||
// Cooldown so rapid double-clicks don't fire parallel step starts; each one re-triggers in-flight backend seed/launch calls.
|
||||
const lastShowMeClickRef = useRef<number>(0);
|
||||
|
||||
const unlockedIds = useUnlockedStepIds();
|
||||
const currentStep = useMemo(() => {
|
||||
// Spotlight only lands on an unlocked, not-yet-done step, so we never tell
|
||||
// the user to "Show me" something they haven't unlocked yet.
|
||||
const explicit = progress.currentStepId
|
||||
? findStepById(progress.currentStepId)
|
||||
: null;
|
||||
if (explicit && !progress.completedSteps.includes(explicit.id)) return explicit;
|
||||
return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null;
|
||||
}, [progress.currentStepId, progress.completedSteps]);
|
||||
if (
|
||||
explicit &&
|
||||
!progress.completedSteps.includes(explicit.id) &&
|
||||
unlockedIds.has(explicit.id)
|
||||
) {
|
||||
return explicit;
|
||||
}
|
||||
return (
|
||||
STEPS.find(
|
||||
(s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id),
|
||||
) ?? null
|
||||
);
|
||||
}, [progress.currentStepId, progress.completedSteps, unlockedIds]);
|
||||
|
||||
// Stage name labels the panel; the count + bar stay global so progress never resets between stages.
|
||||
const stageOf = currentStep?.stage ?? 'get_started';
|
||||
|
||||
@@ -10,6 +10,7 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useOnboardingProgress } from './hooks/useOnboardingProgress';
|
||||
import { STAGE_GROUPS, STEPS, findStepById } from './steps';
|
||||
import { useUnlockedStepIds, unlockHintFor } from './steps/stepUnlock';
|
||||
import { STAGE_LABELS } from './steps/types';
|
||||
import { onboardingDirector } from './OnboardingDirector';
|
||||
import { report } from './telemetry';
|
||||
@@ -20,13 +21,16 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
const open = progress.panelMode === 'roadmap';
|
||||
const close = () => progress.setPanelMode('expanded');
|
||||
|
||||
const stage1Done = STAGE_GROUPS[0].steps.every((s) =>
|
||||
progress.completedSteps.includes(s.id),
|
||||
);
|
||||
const unlockedIds = useUnlockedStepIds();
|
||||
|
||||
const currentStep = progress.currentStepId
|
||||
? findStepById(progress.currentStepId)
|
||||
: STEPS.find((s) => !progress.completedSteps.includes(s.id));
|
||||
// Current = first unlocked, not-yet-done step (locked ones wait their turn).
|
||||
const currentStep = (() => {
|
||||
const explicit = progress.currentStepId ? findStepById(progress.currentStepId) : null;
|
||||
if (explicit && !progress.completedSteps.includes(explicit.id) && unlockedIds.has(explicit.id)) {
|
||||
return explicit;
|
||||
}
|
||||
return STEPS.find((s) => !progress.completedSteps.includes(s.id) && unlockedIds.has(s.id));
|
||||
})();
|
||||
|
||||
const totalDone = progress.completedSteps.length;
|
||||
const total = STEPS.length;
|
||||
@@ -120,13 +124,8 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
const stageDone = group.steps.filter((s) =>
|
||||
progress.completedSteps.includes(s.id),
|
||||
).length;
|
||||
const isLocked = gi === 1 && !stage1Done;
|
||||
const isInProgress = !isLocked && stageDone < group.steps.length;
|
||||
const stageLabel = isLocked
|
||||
? 'LOCKED'
|
||||
: isInProgress
|
||||
? 'IN PROGRESS'
|
||||
: 'COMPLETE';
|
||||
const isInProgress = stageDone < group.steps.length;
|
||||
const stageLabel = isInProgress ? 'IN PROGRESS' : 'COMPLETE';
|
||||
return (
|
||||
<Box key={group.stage} sx={{ mb: 2 }}>
|
||||
<Box
|
||||
@@ -143,11 +142,7 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
color: isLocked
|
||||
? c.text.tertiary
|
||||
: isInProgress
|
||||
? c.accent.primary
|
||||
: c.text.secondary,
|
||||
color: isInProgress ? c.accent.primary : c.text.secondary,
|
||||
}}
|
||||
>
|
||||
STAGE {gi + 1} · {stageLabel}
|
||||
@@ -162,7 +157,7 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
mb: 0.8,
|
||||
color: isLocked ? c.text.tertiary : c.text.primary,
|
||||
color: c.text.primary,
|
||||
}}
|
||||
>
|
||||
{STAGE_LABELS[group.stage]}
|
||||
@@ -170,12 +165,15 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.6 }}>
|
||||
{group.steps.map((step) => {
|
||||
const isDone = progress.completedSteps.includes(step.id);
|
||||
const isCurrent = currentStep?.id === step.id && !isDone;
|
||||
const isStepLocked = !isDone && !unlockedIds.has(step.id);
|
||||
const isCurrent =
|
||||
currentStep?.id === step.id && !isDone && !isStepLocked;
|
||||
const lockHint = isStepLocked ? unlockHintFor(step.id) : null;
|
||||
return (
|
||||
<Box
|
||||
key={step.id}
|
||||
onClick={() => {
|
||||
if (isLocked) return;
|
||||
if (isStepLocked) return;
|
||||
// Abort mid-flow step before jumping; otherwise AC keeps animating for a step the user no longer sees.
|
||||
if (progress.running) {
|
||||
onboardingDirector.cancelStep();
|
||||
@@ -194,15 +192,15 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
py: 0.45,
|
||||
px: 0.4,
|
||||
borderRadius: `${c.radius.sm}px`,
|
||||
cursor: isLocked ? 'default' : 'pointer',
|
||||
opacity: isLocked ? 0.55 : 1,
|
||||
cursor: isStepLocked ? 'default' : 'pointer',
|
||||
opacity: isStepLocked ? 0.55 : 1,
|
||||
transition: 'background 0.12s',
|
||||
'&:hover': isLocked
|
||||
'&:hover': isStepLocked
|
||||
? {}
|
||||
: { bgcolor: c.bg.secondary },
|
||||
}}
|
||||
>
|
||||
{isLocked ? (
|
||||
{isStepLocked ? (
|
||||
<LockIcon sx={{ fontSize: 16, color: c.text.tertiary }} />
|
||||
) : isDone ? (
|
||||
<CheckCircleIcon
|
||||
@@ -231,7 +229,7 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
>
|
||||
{step.title}
|
||||
</Typography>
|
||||
{isCurrent && (
|
||||
{isCurrent ? (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: 10.5,
|
||||
@@ -243,7 +241,13 @@ const OnboardingRoadmapModal: React.FC = () => {
|
||||
>
|
||||
current
|
||||
</Typography>
|
||||
)}
|
||||
) : lockHint ? (
|
||||
<Typography
|
||||
sx={{ fontSize: 10.5, color: c.text.tertiary, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{lockHint}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,10 +8,13 @@ import { step06 } from './step06_agentControlAgents';
|
||||
import { step07 } from './step07_installSkill';
|
||||
import { step08 } from './step08_makeApp';
|
||||
|
||||
// Value-first order: launch an agent (step03) FIRST so a brand-new user sees
|
||||
// the product work on the free trial, then connect-your-own-model (step01).
|
||||
// Everything else is "learn the features", revealed after the first win.
|
||||
export const STEPS: OnboardingStep[] = [
|
||||
step03,
|
||||
step01,
|
||||
step02,
|
||||
step03,
|
||||
step04,
|
||||
step05,
|
||||
step06,
|
||||
|
||||
@@ -26,6 +26,20 @@ export function hasModelConnected(s: RootState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True while the server-funded free trial is armed (no key needed yet). */
|
||||
export function hasFreeTrialActive(s: RootState): boolean {
|
||||
const d = s.settings.data as any;
|
||||
return !!(d && d.connection_mode === 'free-trial' && d.free_trial_token);
|
||||
}
|
||||
|
||||
/** True when free runs are running out (or already armed-and-spent). Surfaces the connect-model step. */
|
||||
export function freeRunsLow(s: RootState): boolean {
|
||||
const d = s.settings.data as any;
|
||||
if (!d) return false;
|
||||
const remaining = d.free_trial_remaining;
|
||||
return typeof remaining === 'number' && remaining <= 2;
|
||||
}
|
||||
|
||||
export function hasAnyToolEnabled(s: RootState): boolean {
|
||||
const items = s.tools?.items ?? {};
|
||||
// Match Tools.tsx Switch read: enabled !== false; pre-field tools treat undefined as on.
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { OnboardingStep } from './types';
|
||||
import { S } from '../selectors';
|
||||
import { hasModelConnected } from './skipPredicates';
|
||||
import { hasModelConnected, hasFreeTrialActive, freeRunsLow } from './skipPredicates';
|
||||
|
||||
export const step01: OnboardingStep = {
|
||||
id: 'connect_model',
|
||||
stage: 'get_started',
|
||||
index: 1,
|
||||
title: 'Connect an AI model',
|
||||
description: 'This is the brain behind your agents.',
|
||||
// Moved to last in "Get started": the user only meets this after they've
|
||||
// seen value, framed as "keep going". Stays suppressed while the free trial
|
||||
// is armed and runs aren't low; un-suppresses when they're about to run out.
|
||||
index: 2,
|
||||
title: 'Keep going: connect your model',
|
||||
description: 'Your free runs are limited. Add your own model to keep building.',
|
||||
videoSrc: './onboarding-videos/v2/01.mp4',
|
||||
videoDurationLabel: '0:24',
|
||||
skipIf: hasModelConnected,
|
||||
skipIf: (s) => hasModelConnected(s) || (hasFreeTrialActive(s) && !freeRunsLow(s)),
|
||||
ops: [
|
||||
{ kind: 'move_to', target: S.sidebarSettingsButton },
|
||||
{ kind: 'popup', text: 'Pop into Settings.' },
|
||||
|
||||
@@ -4,13 +4,14 @@ import { isYoutubeEnabled } from './skipPredicates';
|
||||
|
||||
export const step02: OnboardingStep = {
|
||||
id: 'enable_actions',
|
||||
stage: 'get_started',
|
||||
index: 2,
|
||||
// Demoted out of the first-run path: a feature to discover after the first win.
|
||||
stage: 'learn_features',
|
||||
index: 3,
|
||||
title: 'Enable agentic actions',
|
||||
description: 'Allow agents to work across your apps.',
|
||||
videoSrc: './onboarding-videos/v2/02.mp4',
|
||||
videoDurationLabel: '0:24',
|
||||
// Narrowed to YouTube so users with other tools still get walked; step 3 needs YouTube on.
|
||||
// Narrowed to YouTube so users with other tools still get walked.
|
||||
skipIf: isYoutubeEnabled,
|
||||
ops: [
|
||||
{ kind: 'move_to', target: S.sidebarActions },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OnboardingStep } from './types';
|
||||
import { S } from '../selectors';
|
||||
import { hasAnyAgentLaunched, isYoutubeEnabled } from './skipPredicates';
|
||||
import { hasAnyAgentLaunched, isYoutubeEnabled, hasModelConnected, hasFreeTrialActive } from './skipPredicates';
|
||||
|
||||
// Primary: YouTube summary (needs MCP from step 2). Fallback uses built-in web tools (no MCP).
|
||||
const YOUTUBE_PROMPT =
|
||||
@@ -11,12 +11,15 @@ const FALLBACK_PROMPT =
|
||||
export const step03: OnboardingStep = {
|
||||
id: 'launch_agent',
|
||||
stage: 'get_started',
|
||||
index: 3,
|
||||
// Value first: this leads WHEN there's a way to run (free trial armed, or a
|
||||
// model connected). With nothing to run on, it skips so connect-model leads
|
||||
// instead, which restores today's flow exactly (no trial = no regression).
|
||||
index: 1,
|
||||
title: 'Launch your first Agent',
|
||||
description: 'Click the chat bubble to fire up a new Agent in a dashboard.',
|
||||
videoSrc: './onboarding-videos/v2/03.mp4',
|
||||
videoDurationLabel: '0:24',
|
||||
skipIf: hasAnyAgentLaunched,
|
||||
skipIf: (s) => hasAnyAgentLaunched(s) || (!hasModelConnected(s) && !hasFreeTrialActive(s)),
|
||||
requiresDashboard: true,
|
||||
ops: [
|
||||
{ kind: 'move_to', target: S.newAgentButton },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { hasAnyBrowserSpawned } from './skipPredicates';
|
||||
|
||||
export const step04: OnboardingStep = {
|
||||
id: 'use_browser',
|
||||
stage: 'get_started',
|
||||
stage: 'learn_features',
|
||||
index: 4,
|
||||
title: 'Use the built-in browser',
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Soft, earned unlocks for the onboarding panel. A locked step is still fully
|
||||
// usable in the app, this only gates the guided spotlight + shows a lock icon
|
||||
// with a one-line teaser, so the tour reveals things in an order you earn by
|
||||
// doing real actions. Unlocks fire off the same milestone predicates the
|
||||
// skipIf scanner uses, so exploring (e.g. opening a browser yourself) unlocks
|
||||
// the next thing immediately, never punished for going off-script.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { RootState } from '@/shared/state/store';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { hasAnyAgentLaunched, hasAnyBrowserSpawned } from './skipPredicates';
|
||||
import { STEPS } from './index';
|
||||
|
||||
interface UnlockRule {
|
||||
by: (s: RootState) => boolean;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
// Steps without a rule are unlocked from the start (the get-started entry points).
|
||||
const RULES: Record<string, UnlockRule> = {
|
||||
enable_actions: { by: hasAnyAgentLaunched, hint: 'Run your first agent' },
|
||||
use_browser: { by: hasAnyAgentLaunched, hint: 'Run your first agent' },
|
||||
install_skill: { by: hasAnyAgentLaunched, hint: 'Run your first agent' },
|
||||
make_app: { by: hasAnyAgentLaunched, hint: 'Run your first agent' },
|
||||
agent_control_agents: { by: hasAnyAgentLaunched, hint: 'Run your first agent' },
|
||||
agent_use_browser: { by: hasAnyBrowserSpawned, hint: 'Open a browser' },
|
||||
};
|
||||
|
||||
export function isStepUnlocked(stepId: string, s: RootState): boolean {
|
||||
const rule = RULES[stepId];
|
||||
return rule ? rule.by(s) : true;
|
||||
}
|
||||
|
||||
export function unlockHintFor(stepId: string): string | null {
|
||||
return RULES[stepId]?.hint ?? null;
|
||||
}
|
||||
|
||||
/** Set of currently-unlocked step ids. Keyed on a stable string so the selector
|
||||
* only re-renders when the unlock set actually changes. */
|
||||
export function useUnlockedStepIds(): Set<string> {
|
||||
const key = useAppSelector((s) =>
|
||||
STEPS.filter((st) => isStepUnlocked(st.id, s)).map((st) => st.id).join('|'),
|
||||
);
|
||||
return useMemo(() => new Set(key ? key.split('|') : []), [key]);
|
||||
}
|
||||
@@ -101,6 +101,16 @@ function parseOpenSwarmError(text: string, ctx?: OverflowContext): OpenSwarmErro
|
||||
ctaAction: 'upgrade',
|
||||
};
|
||||
}
|
||||
if (/free_trial_exhausted|used your free|free OpenSwarm runs/i.test(text)) {
|
||||
return {
|
||||
kind: 'cap',
|
||||
title: "You've used your free runs",
|
||||
detail:
|
||||
'Connect a model to keep going: your own API key, an AI subscription you already pay for, or OpenSwarm Pro.',
|
||||
ctaLabel: 'Connect a model',
|
||||
ctaAction: 'settings',
|
||||
};
|
||||
}
|
||||
if (/unknown model|check the model code|\b1211\b|model_not_found/i.test(text)) {
|
||||
return {
|
||||
kind: 'auth',
|
||||
|
||||
@@ -215,7 +215,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
|
||||
/>
|
||||
|
||||
{sessionList.length === 0 && Object.keys(viewCards).length === 0 && Object.keys(browserCards).length === 0 ? (
|
||||
<DashboardEmptyState c={c} />
|
||||
<DashboardEmptyState c={c} onLaunch={onToolbarSend} />
|
||||
) : (
|
||||
<div
|
||||
ref={canvas.contentRef}
|
||||
|
||||
@@ -3,12 +3,44 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import { useDashboardActive } from '@/shared/hooks/useDashboardActive';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
hasModelConnected,
|
||||
hasFreeTrialActive,
|
||||
} from '@/app/components/Onboarding/steps/skipPredicates';
|
||||
import ChatBubbleTeardrop from '../ChatBubbleTeardrop';
|
||||
|
||||
const DashboardEmptyState: React.FC<{ c: ClaudeTokens }> = ({ c }) => {
|
||||
// Broad, one-click-complete prompts so a brand-new user gets the "it works"
|
||||
// moment without thinking up a task. Each runs to a useful result on its own.
|
||||
const STARTER_PROMPTS = [
|
||||
'Find the latest AI news and give me a short summary',
|
||||
'Research the top 3 standing desks and compare them',
|
||||
'Explain how RAG works like I\'m five',
|
||||
'Write a short poem about the sea',
|
||||
];
|
||||
|
||||
const DashboardEmptyState: React.FC<{
|
||||
c: ClaudeTokens;
|
||||
onLaunch?: (prompt: string, mode: string, model: string) => void;
|
||||
}> = ({ c, onLaunch }) => {
|
||||
// The host hides Dashboard with visibility:hidden (not display:none), which keeps
|
||||
// CSS animations ticking; gate on active so the shimmer only burns while watched.
|
||||
const active = useDashboardActive();
|
||||
const model = useAppSelector((s) => s.settings.data.default_model);
|
||||
const mode = useAppSelector((s) => s.settings.data.default_mode);
|
||||
const canRun = useAppSelector((s) => hasFreeTrialActive(s) || hasModelConnected(s));
|
||||
const [launching, setLaunching] = React.useState(false);
|
||||
|
||||
// Only offer chips once a run can actually succeed (free trial armed or a real
|
||||
// model connected); otherwise fall back to the plain hint.
|
||||
const showChips = !!onLaunch && canRun;
|
||||
|
||||
const launch = (prompt: string) => {
|
||||
if (launching || !onLaunch) return;
|
||||
setLaunching(true); // empty state unmounts on first session, but guard a fast double-click
|
||||
onLaunch(prompt, mode, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -47,6 +79,48 @@ const DashboardEmptyState: React.FC<{ c: ClaudeTokens }> = ({ c }) => {
|
||||
</Box>
|
||||
below to launch your first agent
|
||||
</Typography>
|
||||
|
||||
{showChips && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
maxWidth: 520,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ width: '100%', textAlign: 'center', color: c.text.ghost, fontSize: '0.8rem', mb: 0.5 }}>
|
||||
or try one of these
|
||||
</Typography>
|
||||
{STARTER_PROMPTS.map((prompt) => (
|
||||
<Box
|
||||
component="button"
|
||||
key={prompt}
|
||||
onClick={() => launch(prompt)}
|
||||
disabled={launching}
|
||||
sx={{
|
||||
px: 1.4,
|
||||
py: 0.8,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
background: c.bg.surface,
|
||||
color: c.text.secondary,
|
||||
fontSize: '0.82rem',
|
||||
cursor: launching ? 'default' : 'pointer',
|
||||
opacity: launching ? 0.5 : 1,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'background 150ms ease-in-out, border-color 150ms ease-in-out',
|
||||
'&:hover': launching ? {} : { background: c.bg.elevated, borderColor: c.border.strong },
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,9 +57,13 @@ export interface AppSettings {
|
||||
dev_mode: boolean;
|
||||
allow_experimental_updates: boolean;
|
||||
/** Managed subscription state; surfaces only when user has subscribed via cloud. */
|
||||
connection_mode?: 'own_key' | 'openswarm-pro';
|
||||
connection_mode?: 'own_key' | 'openswarm-pro' | 'free-trial';
|
||||
openswarm_bearer_token?: string | null;
|
||||
openswarm_proxy_url?: string | null;
|
||||
/** Zero-config free trial: server-owned, set by the cloud mint. remaining drives the onboarding "runs low" nudge. */
|
||||
free_trial_token?: string | null;
|
||||
free_trial_remaining?: number | null;
|
||||
free_trial_runs_limit?: number | null;
|
||||
openswarm_subscription_plan?: string | null;
|
||||
openswarm_subscription_expires?: string | null;
|
||||
openswarm_usage_cached?: SubscriptionUsage | null;
|
||||
|
||||
Reference in New Issue
Block a user