mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-03 00:08:46 +02:00
[eric] subscription UI fixes: cards no longer flicker to the Starting-subscription-service spinner mid-task (is_running TTL cache + retry/poll on mount), show
Anthropic as its own picker group alongside OpenSwarm Pro when both are connected so users can pick per-request
This commit is contained in:
@@ -1043,8 +1043,14 @@ class AgentManager:
|
||||
}
|
||||
# Priority: openswarm-pro mode → Anthropic API key → 9Router.
|
||||
# Non-Anthropic api_types always route through 9Router regardless.
|
||||
# A resolved_model carrying a 9Router prefix (cc/cx/gc/gh/) also
|
||||
# forces the 9Router branch — this is what makes pinned-route
|
||||
# Anthropic values ("sonnet-cc" etc.) bypass the OpenSwarm Pro
|
||||
# proxy and land on the user's own Claude subscription even while
|
||||
# connection_mode is openswarm-pro.
|
||||
from backend.apps.nine_router import is_running as _9r_running
|
||||
if api_type == "anthropic" and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
|
||||
resolved_is_9router = isinstance(resolved_model, str) and resolved_model.startswith(("cc/", "cx/", "gc/", "gh/"))
|
||||
if 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 ""
|
||||
options_kwargs["env"] = {
|
||||
@@ -1052,7 +1058,7 @@ class AgentManager:
|
||||
"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:
|
||||
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")
|
||||
elif _9r_running():
|
||||
|
||||
@@ -323,25 +323,58 @@ async def list_models():
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to fetch 9Router providers: {e}")
|
||||
|
||||
def _serialize(models: list[dict]) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"value": m["value"],
|
||||
"label": m["label"],
|
||||
"context_window": m.get("context_window", 128_000),
|
||||
"reasoning": bool(m.get("reasoning", False)),
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
|
||||
has_api_key = bool(getattr(settings, "anthropic_api_key", None))
|
||||
is_openswarm_pro = (
|
||||
getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
|
||||
and bool(getattr(settings, "openswarm_bearer_token", None))
|
||||
)
|
||||
has_claude_sub = "claude" in connected
|
||||
|
||||
result: dict[str, list[dict]] = {}
|
||||
|
||||
# Anthropic models: emit under "OpenSwarm Pro" (proxy-routed, adaptive
|
||||
# values) and/or "Anthropic" (direct API key OR 9Router claude sub). When
|
||||
# both the proxy and the personal claude sub are active we emit both
|
||||
# groups, with the Anthropic group using the pinned "-cc" variants so a
|
||||
# per-call selection actually routes through 9Router instead of the proxy.
|
||||
anthropic_models = BUILTIN_MODELS.get("Anthropic", [])
|
||||
adaptive = [m for m in anthropic_models if m.get("route") != "cc"]
|
||||
cc_variants = [m for m in anthropic_models if m.get("route") == "cc"]
|
||||
|
||||
if is_openswarm_pro and has_claude_sub:
|
||||
result["OpenSwarm Pro"] = _serialize(adaptive)
|
||||
result["Anthropic"] = _serialize(cc_variants)
|
||||
elif is_openswarm_pro:
|
||||
result["OpenSwarm Pro"] = _serialize(adaptive)
|
||||
elif has_api_key or has_claude_sub:
|
||||
result["Anthropic"] = _serialize(adaptive)
|
||||
|
||||
# Non-Anthropic providers (OpenAI, Google, OpenSwarm/Copilot, etc.) —
|
||||
# visibility is gated by 9Router's connected providers set.
|
||||
for provider_name, models in BUILTIN_MODELS.items():
|
||||
if provider_name == "Anthropic":
|
||||
continue
|
||||
visible = []
|
||||
for m in models:
|
||||
api = m.get("api", "")
|
||||
# GitHub Copilot is not yet available to end users — hide its models.
|
||||
if api == "github-copilot":
|
||||
# Hidden from end users for now — `gh/` path lives in the
|
||||
# registry but the Copilot subscription card is "Coming soon".
|
||||
continue
|
||||
if m.get("subscription_only"):
|
||||
if not nine_router_up or api not in connected:
|
||||
continue
|
||||
elif api == "anthropic":
|
||||
has_key = bool(getattr(settings, "anthropic_api_key", None))
|
||||
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"],
|
||||
"label": m["label"],
|
||||
|
||||
@@ -63,12 +63,27 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
|
||||
# (Feb 5 2026), Haiku 4.5 (Oct 2025). All three are the current
|
||||
# production flagships in their respective size tiers.
|
||||
"Anthropic": [
|
||||
# Adaptive entries: route is chosen at call time based on
|
||||
# settings.connection_mode (openswarm-pro → proxy; api_key → direct;
|
||||
# else → 9Router cc/).
|
||||
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
|
||||
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True},
|
||||
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True},
|
||||
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000,
|
||||
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True},
|
||||
# Pinned-subscription entries: always route via 9Router's `cc/` prefix
|
||||
# (the user's personal Claude Pro/Max subscription), regardless of
|
||||
# connection_mode. Surfaced in list_models only when the user has
|
||||
# BOTH openswarm-pro active AND the 9Router `claude` subscription
|
||||
# connected — so the model picker can offer a per-call choice between
|
||||
# the managed OpenSwarm proxy and their own Claude subscription.
|
||||
{"value": "sonnet-cc", "label": "Claude Sonnet 4.6", "context_window": 1_000_000,
|
||||
"model_id": "claude-sonnet-4-6", "router_model_id": "cc/claude-sonnet-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "opus-cc", "label": "Claude Opus 4.6", "context_window": 1_000_000,
|
||||
"model_id": "claude-opus-4-6", "router_model_id": "cc/claude-opus-4-6", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
{"value": "haiku-cc", "label": "Claude Haiku 4.5", "context_window": 200_000,
|
||||
"model_id": "claude-haiku-4-5", "router_model_id": "cc/claude-haiku-4-5-20251001", "api": "anthropic", "reasoning": True, "route": "cc"},
|
||||
],
|
||||
# OpenAI: ChatGPT Plus/Pro (Codex) subscription. gpt-5.4 is the
|
||||
# current flagship — combines GPT-5.3 Codex coding capabilities with
|
||||
@@ -267,6 +282,12 @@ 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
|
||||
# Pinned-route entries (e.g. "sonnet-cc") always use their router_model_id,
|
||||
# bypassing connection_mode. This is what lets the picker offer a
|
||||
# distinct "Anthropic" group pointing at the user's 9Router Claude
|
||||
# subscription even while openswarm-pro is the default Claude route.
|
||||
if entry.get("route") == "cc":
|
||||
return entry.get("router_model_id", 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)
|
||||
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -24,12 +25,29 @@ NINE_ROUTER_V1 = f"{NINE_ROUTER_URL}/v1"
|
||||
|
||||
_process: subprocess.Popen | None = None
|
||||
|
||||
# Short TTL cache for positive is_running() results. The probe is a sync
|
||||
# httpx.get that blocks the event loop, and under load (9Router busy
|
||||
# streaming inference) it can exceed its 2s timeout and return False even
|
||||
# though 9Router is fine. Caching a recent True result avoids those false
|
||||
# negatives without masking a real crash for more than _IS_RUNNING_TTL seconds.
|
||||
# Negative results are NOT cached so startup detection in ensure_running()
|
||||
# remains correct.
|
||||
_IS_RUNNING_TTL = 10.0
|
||||
_is_running_last_ok: float = 0.0
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
"""Check if 9Router is running."""
|
||||
global _is_running_last_ok
|
||||
now = time.monotonic()
|
||||
if now - _is_running_last_ok < _IS_RUNNING_TTL:
|
||||
return True
|
||||
try:
|
||||
r = httpx.get(f"{NINE_ROUTER_V1}/models", timeout=2.0)
|
||||
return r.status_code == 200
|
||||
if r.status_code == 200:
|
||||
_is_running_last_ok = now
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -54,6 +54,18 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Chrome's Private Network Access check: a page on https://api.openswarm.com
|
||||
# POSTing to http://127.0.0.1:8324 triggers a preflight that requires this
|
||||
# header. Without it the request is blocked and the post-checkout activation
|
||||
# flow silently fails. Harmless on every other request — it's only read when
|
||||
# the browser is crossing from a public origin into a private network.
|
||||
@app.middleware("http")
|
||||
async def _allow_private_network(request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Access-Control-Allow-Private-Network"] = "true"
|
||||
return response
|
||||
|
||||
@app.websocket("/ws/agents/{session_id}")
|
||||
async def websocket_session(websocket: WebSocket, session_id: str):
|
||||
await ws_manager.connect_session(session_id, websocket)
|
||||
|
||||
@@ -47,8 +47,8 @@ function isValidEmail(email: string): boolean {
|
||||
const SUBSCRIPTION_PROVIDERS = [
|
||||
{ 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: 'gemini-cli', name: 'Gemini', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro & Flash', color: '#4285F4', preview: false },
|
||||
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
|
||||
];
|
||||
|
||||
const USE_CASES = [
|
||||
@@ -150,14 +150,22 @@ 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.
|
||||
// Auto-dismiss ONLY on the inactive → active transition (i.e. Stripe
|
||||
// checkout deep-link activation while the modal is open). We used to fire
|
||||
// on any settings tick where Pro was active, which meant a returning user
|
||||
// who already had Pro (but cleared onboarding_seen) would see the modal
|
||||
// flash then immediately skip to the dashboard.
|
||||
const initialProActiveRef = useRef<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) { initialProActiveRef.current = null; return; }
|
||||
const mode = (settings.data as any).connection_mode;
|
||||
const bearer = (settings.data as any).openswarm_bearer_token;
|
||||
if (mode === 'openswarm-pro' && bearer) {
|
||||
const isActive = mode === 'openswarm-pro' && !!bearer;
|
||||
if (initialProActiveRef.current === null) {
|
||||
initialProActiveRef.current = isActive;
|
||||
return;
|
||||
}
|
||||
if (!initialProActiveRef.current && isActive) {
|
||||
trackEvent('onboarding.openswarm_pro_activated');
|
||||
dismiss();
|
||||
}
|
||||
|
||||
@@ -68,13 +68,31 @@ const STEPS: WalkthroughStep[] = [
|
||||
},
|
||||
{
|
||||
target: '',
|
||||
title: "You're all set!",
|
||||
description: 'Start chatting with your AI assistants. Explore your workspace, connect your tools, and make it yours.',
|
||||
title: 'Try your first task',
|
||||
description: "OpenSwarm is most useful when you give it something real. Open a new chat and try one of these:",
|
||||
placement: 'bottom',
|
||||
centerOverlay: true,
|
||||
},
|
||||
];
|
||||
|
||||
const EXAMPLE_PROMPTS: { emoji: string; label: string; prompt: string }[] = [
|
||||
{
|
||||
emoji: '🔎',
|
||||
label: 'Research a topic',
|
||||
prompt: 'Research the latest developments in AI agents and give me a short briefing',
|
||||
},
|
||||
{
|
||||
emoji: '🛠️',
|
||||
label: 'Build a mini app',
|
||||
prompt: 'Build me a simple habit tracker with a clean UI',
|
||||
},
|
||||
{
|
||||
emoji: '📄',
|
||||
label: 'Summarize a webpage',
|
||||
prompt: 'Browse https://news.ycombinator.com and summarize the top 5 stories',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
@@ -117,7 +135,13 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
|
||||
if (step.centerOverlay) {
|
||||
setSpotlightRect(null);
|
||||
setTooltipPos({ top: window.innerHeight / 2 - 100, left: window.innerWidth / 2 - 180 });
|
||||
const isAnnouncement = currentStep === STEPS.length - 1;
|
||||
const w = isAnnouncement ? 600 : 320;
|
||||
const h = isAnnouncement ? 640 : 200;
|
||||
setTooltipPos({
|
||||
top: Math.max(24, window.innerHeight / 2 - h / 2),
|
||||
left: Math.max(24, window.innerWidth / 2 - w / 2),
|
||||
});
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
@@ -225,6 +249,26 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
onComplete();
|
||||
}, [onComplete, currentStep, step]);
|
||||
|
||||
const handleStartFirstChat = useCallback((example?: { label: string; prompt: string }) => {
|
||||
trackEvent('walkthrough.first_chat_started', { example: example?.label || 'cta' });
|
||||
if (example?.prompt) {
|
||||
try {
|
||||
sessionStorage.setItem('openswarm_first_prompt', example.prompt);
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
sessionStorage.removeItem('openswarm_first_prompt');
|
||||
} catch {}
|
||||
}
|
||||
onComplete();
|
||||
setTimeout(() => {
|
||||
const btn = document.querySelector(
|
||||
'[data-onboarding="new-agent-button"]',
|
||||
) as HTMLElement | null;
|
||||
btn?.click();
|
||||
}, 150);
|
||||
}, [onComplete]);
|
||||
|
||||
// Allow clicking the spotlight target to advance for action steps
|
||||
useEffect(() => {
|
||||
if (!step?.actionHint || step.centerOverlay) return;
|
||||
@@ -305,18 +349,188 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
position: 'absolute',
|
||||
top: tooltipPos.top,
|
||||
left: tooltipPos.left,
|
||||
width: 320,
|
||||
width: isLastStep ? 600 : 320,
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.medium}`,
|
||||
borderRadius: `${c.radius.xl}px`,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.35)',
|
||||
p: 2.5,
|
||||
p: isLastStep ? 0 : 2.5,
|
||||
overflow: 'hidden',
|
||||
transition: 'top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s',
|
||||
opacity: visible ? 1 : 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
{isLastStep ? (
|
||||
<>
|
||||
{/* Hero image area — soft pastel multi-color blob */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 320,
|
||||
background: `
|
||||
radial-gradient(circle at 18% 78%, #F5A574 0%, rgba(245,165,116,0) 48%),
|
||||
radial-gradient(circle at 58% 55%, #E9A5D0 0%, rgba(233,165,208,0) 52%),
|
||||
radial-gradient(circle at 82% 22%, #B9C9F4 0%, rgba(185,201,244,0) 58%),
|
||||
linear-gradient(135deg, #C4D0F2 0%, #EDB3CC 50%, #F5B088 100%)
|
||||
`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
fontSize: 72,
|
||||
color: '#fff',
|
||||
filter: 'drop-shadow(0 4px 16px rgba(0,0,0,0.18))',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✦
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.25 }}>
|
||||
{/* Step counter dots */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.25, justifyContent: 'center' }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: i === currentStep ? 14 : 4,
|
||||
height: 4,
|
||||
borderRadius: 3,
|
||||
bgcolor: i === currentStep ? c.accent.primary : i < currentStep ? c.accent.primary + '60' : c.border.medium,
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
fontSize: '0.58rem',
|
||||
fontWeight: 700,
|
||||
color: c.accent.primary,
|
||||
bgcolor: c.accent.primary + '1f',
|
||||
px: 0.85,
|
||||
py: 0.2,
|
||||
borderRadius: `${c.radius.xs}px`,
|
||||
letterSpacing: '0.5px',
|
||||
mb: 0.75,
|
||||
fontFamily: c.font.sans,
|
||||
}}
|
||||
>
|
||||
READY TO TRY
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.92rem',
|
||||
fontWeight: 700,
|
||||
color: c.text.primary,
|
||||
mb: 0.35,
|
||||
fontFamily: c.font.sans,
|
||||
}}
|
||||
>
|
||||
{step.title}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: c.text.secondary,
|
||||
lineHeight: 1.45,
|
||||
mb: 1.25,
|
||||
fontFamily: c.font.sans,
|
||||
}}
|
||||
>
|
||||
{step.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, mb: 1.5 }}>
|
||||
{EXAMPLE_PROMPTS.map((ex) => (
|
||||
<Box
|
||||
key={ex.label}
|
||||
onClick={() => handleStartFirstChat(ex)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.elevated || c.bg.surface,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: c.accent.primary,
|
||||
bgcolor: c.accent.primary + '10',
|
||||
transform: 'translateX(2px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ fontSize: '0.95rem', lineHeight: 1 }}>{ex.emoji}</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: c.text.primary, fontFamily: c.font.sans }}>
|
||||
{ex.label}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.64rem', color: c.text.tertiary, fontFamily: c.font.sans, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{ex.prompt}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.8rem', color: c.text.tertiary, fontFamily: c.font.sans }}>→</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
color: c.text.tertiary,
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
fontFamily: c.font.sans,
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
}}
|
||||
>
|
||||
Not now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleStartFirstChat()}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: c.accent.primary,
|
||||
color: '#fff',
|
||||
borderRadius: `${c.radius.md}px`,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
fontFamily: c.font.sans,
|
||||
'&:hover': { bgcolor: c.accent.hover || c.accent.primary },
|
||||
}}
|
||||
>
|
||||
Start a new chat
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Step counter dots */}
|
||||
<Box sx={{ display: 'flex', gap: 0.5, mb: 1.5, justifyContent: 'center' }}>
|
||||
{STEPS.map((_, i) => (
|
||||
@@ -409,6 +623,8 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
|
||||
{isLastStep ? 'Get Started' : 'Next'}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
@@ -192,6 +192,43 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
}, []);
|
||||
|
||||
const [hasContent, setHasContent] = useState(() => !!_draftStore.get(ownerId));
|
||||
|
||||
// Ghost prompt suggestion from onboarding — typed out animated, click or press Enter to run.
|
||||
const [ghostPrompt, setGhostPrompt] = useState<string | null>(null);
|
||||
const [ghostTyped, setGhostTyped] = useState(0);
|
||||
const ghostOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const [ghostHeight, setGhostHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
let pending: string | null = null;
|
||||
try {
|
||||
pending = sessionStorage.getItem('openswarm_first_prompt');
|
||||
if (pending) sessionStorage.removeItem('openswarm_first_prompt');
|
||||
} catch {}
|
||||
if (!pending) return;
|
||||
if (_draftStore.get(ownerId)) return; // user already has a draft; don't overwrite
|
||||
setGhostPrompt(pending);
|
||||
setGhostTyped(0);
|
||||
let i = 0;
|
||||
const id = window.setInterval(() => {
|
||||
i += 1;
|
||||
setGhostTyped(i);
|
||||
if (i >= pending!.length) window.clearInterval(id);
|
||||
}, 29);
|
||||
return () => window.clearInterval(id);
|
||||
// Only on mount; ownerId is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Measure the ghost overlay so the editor can reserve matching height and
|
||||
// the toolbar row below doesn't get overlapped when the ghost text wraps.
|
||||
useLayoutEffect(() => {
|
||||
if (!ghostPrompt || hasContent) { setGhostHeight(0); return; }
|
||||
const el = ghostOverlayRef.current;
|
||||
if (!el) return;
|
||||
const h = el.offsetHeight;
|
||||
setGhostHeight((prev) => (prev === h ? prev : h));
|
||||
}, [ghostPrompt, ghostTyped, hasContent]);
|
||||
|
||||
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
|
||||
const attachedSkillsRef = useRef(attachedSkills);
|
||||
attachedSkillsRef.current = attachedSkills;
|
||||
@@ -227,23 +264,22 @@ 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. 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.
|
||||
// Build flat model list with provider grouping. Group names come from the
|
||||
// backend's /agents/models response verbatim — "OpenSwarm Pro" for
|
||||
// proxy-routed Claude, "Anthropic" for direct/subscription-routed Claude,
|
||||
// plus the non-Anthropic providers. Only the pre-load fallback still needs
|
||||
// to pick a label since no models have been fetched yet.
|
||||
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) {
|
||||
const key = renameAnthropic('Anthropic');
|
||||
const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : '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)) {
|
||||
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 }));
|
||||
grouped[prov] = 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: key, reasoning: !!m.reasoning });
|
||||
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning });
|
||||
}
|
||||
}
|
||||
return { flat, grouped };
|
||||
@@ -472,6 +508,25 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
elementSelection?.clearOwnerElements(ownerId);
|
||||
}, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]);
|
||||
|
||||
const acceptGhostPrompt = useCallback(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || !ghostPrompt) return;
|
||||
editor.textContent = ghostPrompt;
|
||||
setGhostPrompt(null);
|
||||
setHasContent(true);
|
||||
_draftStore.set(ownerId, editor.innerHTML);
|
||||
setTimeout(() => {
|
||||
editor.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editor);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
handleSend();
|
||||
}, 0);
|
||||
}, [ghostPrompt, ownerId, handleSend]);
|
||||
|
||||
const detectTrigger = useCallback(() => {
|
||||
const result = detectEditorTrigger();
|
||||
if (result) {
|
||||
@@ -576,6 +631,10 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) {
|
||||
e.preventDefault();
|
||||
if (ghostPrompt && !hasContent) {
|
||||
acceptGhostPrompt();
|
||||
return;
|
||||
}
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
@@ -967,7 +1026,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
onPaste={handlePaste}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '1.5em',
|
||||
minHeight: ghostPrompt && !hasContent && ghostHeight > 0 ? `${ghostHeight}px` : '1.5em',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
background: 'transparent',
|
||||
@@ -981,7 +1040,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
/>
|
||||
{!hasContent && (
|
||||
{!hasContent && !ghostPrompt && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -1000,6 +1059,38 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
{disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued — type another or wait…` : 'Agent is working — messages will queue…') : `${modeConf.label}, @ for context, / for commands`}
|
||||
</div>
|
||||
)}
|
||||
{!hasContent && ghostPrompt && (
|
||||
<div
|
||||
ref={ghostOverlayRef}
|
||||
onClick={acceptGhostPrompt}
|
||||
title="Press Enter or click to run"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: `${hasAttachments ? 4 : 10}px 12px`,
|
||||
color: c.text.secondary,
|
||||
opacity: 0.55,
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5',
|
||||
fontFamily: 'inherit',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{ghostPrompt.slice(0, ghostTyped)}
|
||||
{ghostTyped < ghostPrompt.length && (
|
||||
<span style={{ opacity: 0.8, animation: 'openswarm-ghost-caret 0.9s steps(1) infinite' }}>▍</span>
|
||||
)}
|
||||
{ghostTyped >= ghostPrompt.length && (
|
||||
<span style={{ marginLeft: 8, fontSize: '0.7rem', color: c.text.tertiary, opacity: 0.9 }}>
|
||||
↵ to run
|
||||
</span>
|
||||
)}
|
||||
<style>{`@keyframes openswarm-ghost-caret { 50% { opacity: 0; } }`}</style>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
||||
@@ -59,7 +59,6 @@ const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet 4.6, Opus 4.6, Haiku 4.5', color: '#E8927A', preview: false },
|
||||
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 3 Pro, 3 Flash, 2.5 Pro, 2.5 Flash', color: '#4285F4', preview: false },
|
||||
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, GPT-5.4 Mini, GPT-5.3 Codex', color: '#74AA9C', preview: false },
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude, GPT, Gemini, and more', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
|
||||
@@ -326,15 +325,6 @@ const OpenSwarmProCard: React.FC = () => {
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
@@ -365,12 +355,25 @@ const SubscriptionCards: React.FC = () => {
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [pollTimer, setPollTimer] = useState<any>(null);
|
||||
|
||||
const fetchStatus = () => {
|
||||
fetch(`${API_BASE}/agents/subscriptions/status`)
|
||||
.then(r => r.json())
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus({ running: false, providers: [], models: [] }));
|
||||
};
|
||||
// `preserveTransient` keeps a previously-seen `running: true` state when a
|
||||
// refresh comes back with `running: false`. The backend's is_running() probe
|
||||
// has a short sync timeout that can be exceeded while 9Router is streaming
|
||||
// inference, producing false negatives that would otherwise flip these
|
||||
// cards into a "Starting subscription service..." spinner mid-session.
|
||||
const fetchStatus = useCallback(async (opts?: { preserveTransient?: boolean }) => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/agents/subscriptions/status`);
|
||||
const data = await r.json();
|
||||
setStatus((prev: any) => {
|
||||
if (opts?.preserveTransient && prev?.running && !data?.running) return prev;
|
||||
return data;
|
||||
});
|
||||
return data;
|
||||
} catch {
|
||||
setStatus((prev: any) => prev ?? { running: false, providers: [], models: [] });
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh the chat model picker whenever subscription connection state
|
||||
// changes — GET /agents/models intersects BUILTIN_MODELS with 9Router's
|
||||
@@ -378,7 +381,22 @@ const SubscriptionCards: React.FC = () => {
|
||||
// their models in the dropdown immediately.
|
||||
const refreshPickerModels = () => { dispatch(fetchModels()); };
|
||||
|
||||
useEffect(() => { fetchStatus(); }, []);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
// Retry initial load — a single transient probe miss on mount would
|
||||
// otherwise wedge the UI on the loading spinner until the user closes
|
||||
// and reopens Settings.
|
||||
for (const delay of [0, 800, 2000]) {
|
||||
if (cancelled) return;
|
||||
if (delay) await new Promise(r => setTimeout(r, delay));
|
||||
const data = await fetchStatus();
|
||||
if (data?.running) break;
|
||||
}
|
||||
})();
|
||||
const interval = setInterval(() => fetchStatus({ preserveTransient: true }), 30000);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, [fetchStatus]);
|
||||
|
||||
const isConnected = (providerId: string) => {
|
||||
if (!status?.providers) return false;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user