[eric] [eric] add PostHog analytics, 9Router subscription proxy, unified usage tracking

- PostHog integration: collector, analytics subapp, opt-in UI, Analytics page
  - 9Router: auto-start, OAuth subscription flow, /v1/messages Anthropic format support
  - Settings overhaul: multi-provider API keys, subscription connect UI, onboarding modal
  - Unified usage: merge 9Router cost/token data into Settings Usage tab
  - Provider system: providers/, agent_loop, tools/ (unused, for future non-Anthropic support)
  - Agent SDK: restored as primary with 9Router ANTHROPIC_BASE_URL fallback
  - Updated system prompt, credential resolution, dashboard analytics
This commit is contained in:
ciregenz
2026-03-24 14:02:58 -07:00
parent 8d09e46df5
commit ebea25f0c2
38 changed files with 4382 additions and 373 deletions
+4
View File
@@ -5,6 +5,7 @@ import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mu
import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import {
setAppVersion,
setUpdateAvailable,
@@ -24,6 +25,7 @@ import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
import Analytics from './pages/Analytics/Analytics';
import AnalyticsOptIn from './components/AnalyticsOptIn';
import OnboardingModal from './components/OnboardingModal';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -162,6 +164,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
@@ -234,6 +237,7 @@ const ThemedApp: React.FC = () => {
</Route>
</Routes>
<AnalyticsOptIn />
<OnboardingModal />
</UpdateListener>
</SettingsLoader>
</ShortcutsProvider>
@@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react';
import { Box, Typography, Modal, Button } from '@mui/material';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', 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 OnboardingModal: React.FC = () => {
const c = useClaudeTokens();
const settings = useAppSelector((s) => s.settings);
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [connecting, setConnecting] = useState<string | null>(null);
const [nineRouterStatus, setNineRouterStatus] = useState<any>(null);
// Check if user has any credentials configured
const hasAnyKey = !!(
settings.data.anthropic_api_key ||
settings.data.openai_api_key ||
settings.data.google_api_key ||
settings.data.openrouter_api_key
);
// Check 9Router subscription status
useEffect(() => {
fetch(`${API_BASE}/agents/subscriptions/status`)
.then((r) => r.json())
.then(setNineRouterStatus)
.catch(() => setNineRouterStatus(null));
}, []);
const hasSubscription = (() => {
if (!nineRouterStatus?.running) return false;
const connections = nineRouterStatus?.providers?.connections || [];
return connections.some((p: any) => p.isActive);
})();
// Show modal if no keys AND no subscriptions AND not dismissed
useEffect(() => {
if (!hasAnyKey && !hasSubscription && !dismissed && nineRouterStatus !== null) {
setOpen(true);
} else {
setOpen(false);
}
}, [hasAnyKey, hasSubscription, dismissed, nineRouterStatus]);
const handleConnect = async (providerId: string) => {
setConnecting(providerId);
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId }),
});
const data = await r.json();
if (data.flow === 'device_code') {
const verifyUrl = data.verification_uri;
if (verifyUrl) window.open(verifyUrl, '_blank');
// Poll for completion
const timer = setInterval(async () => {
try {
const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
});
const pd = await pr.json();
if (pd.success) {
clearInterval(timer);
setConnecting(null);
setOpen(false);
}
} catch {}
}, 5000);
setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000);
} else if (data.flow === 'authorization_code') {
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
const msgHandler = async (event: MessageEvent) => {
const d = event.data;
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
if (callbackData?.code) {
window.removeEventListener('message', msgHandler);
clearInterval(statusPoller);
if (popup && !popup.closed) popup.close();
try {
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: providerId, code: callbackData.code,
redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
state: callbackData.state || data.state,
}),
});
} catch {}
setConnecting(null);
setOpen(false);
}
};
window.addEventListener('message', msgHandler);
const statusPoller = setInterval(async () => {
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const conns = sd.providers?.connections || [];
if (conns.some((p: any) => p.provider === providerId && p.isActive)) {
clearInterval(statusPoller);
window.removeEventListener('message', msgHandler);
setConnecting(null);
setOpen(false);
}
} catch {}
}, 2000);
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
}
} catch {
setConnecting(null);
}
};
const handleApiKey = () => {
setDismissed(true);
setOpen(false);
};
const handleSkip = () => {
setDismissed(true);
setOpen(false);
};
if (!open) return null;
return (
<Modal open={open} onClose={handleSkip} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{
width: 480, maxWidth: '90vw', bgcolor: c.bg.surface, borderRadius: `${c.radius.xl}px`,
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 3, textAlign: 'center' }}>
Connect an AI model to get started
</Typography>
{/* Subscription options */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Use your existing subscription
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{SUBSCRIPTION_PROVIDERS.map((p) => (
<Box
key={p.id}
onClick={() => !p.preview && !connecting && handleConnect(p.id)}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: p.preview ? 'default' : connecting ? 'wait' : 'pointer',
opacity: p.preview ? 0.5 : 1,
transition: 'border-color 0.15s, background 0.15s',
...(!p.preview && { '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{p.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{p.desc}</Typography>
</Box>
<Typography sx={{ fontSize: '0.68rem', color: p.preview ? c.text.ghost : connecting === p.id ? c.accent.primary : c.text.tertiary, fontStyle: p.preview ? 'italic' : 'normal' }}>
{p.preview ? 'Coming soon' : connecting === p.id ? 'Connecting...' : 'Connect \u2192'}
</Typography>
</Box>
))}
</Box>
{/* API key option */}
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.text.tertiary, textTransform: 'uppercase', letterSpacing: '0.08em', mb: 1 }}>
Or use an API key
</Typography>
<Box
onClick={handleApiKey}
sx={{
p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
cursor: 'pointer', mb: 2.5,
'&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
}}
>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
I have an API key
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>
Go to Settings &rarr; Models to enter your key
</Typography>
</Box>
{/* Skip */}
<Button
onClick={handleSkip}
fullWidth
sx={{ textTransform: 'none', fontSize: '0.72rem', color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }}
>
Skip for now
</Button>
</Box>
</Modal>
);
};
export default OnboardingModal;
+67 -22
View File
@@ -67,6 +67,8 @@ interface Props {
onModeChange: (mode: string) => void;
model: string;
onModelChange: (model: string) => void;
provider?: string;
onProviderChange?: (provider: string) => void;
isRunning?: boolean;
onStop?: () => void;
autoRunMode?: boolean;
@@ -92,10 +94,10 @@ const ICON_MAP: Record<string, React.ReactNode> = {
const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
const MODEL_OPTIONS = [
{ value: 'sonnet', label: 'Sonnet', version: '4.6' },
{ value: 'opus', label: 'Opus', version: '4.6' },
{ value: 'haiku', label: 'Haiku', version: '3.5' },
const FALLBACK_MODELS = [
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 },
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 },
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 },
];
function formatTokenCount(n: number): string {
@@ -132,7 +134,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -158,6 +160,24 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const skills = useAppSelector((state) => state.skills.items);
const modesMap = useAppSelector((state) => state.modes.items);
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const modelsLoaded = useAppSelector((state) => state.models.loaded);
// Build flat model list with provider grouping
const allModelOptions = useMemo(() => {
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
}
const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = [];
const grouped: Record<string, Array<{ value: string; label: string; context_window: number }>> = {};
for (const [prov, models] of Object.entries(modelsByProvider)) {
grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 }));
for (const m of models) {
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov });
}
}
return { flat, grouped };
}, [modelsByProvider, modelsLoaded]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -566,7 +586,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '10px',
minWidth: 140,
minWidth: 180,
maxHeight: 400,
boxShadow: c.shadow.lg,
'& .MuiMenuItem-root': {
fontSize: '0.8rem',
@@ -979,7 +1000,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}}
>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{(() => { const m = MODEL_OPTIONS.find((m) => m.value === model); return m ? `${m.label} ${m.version}` : model; })()}
{(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
</Box>
@@ -992,21 +1013,45 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
>
{MODEL_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
setModelAnchor(null);
}}
>
<ListItemText
primary={`${opt.label} ${opt.version}`}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
))}
{Object.entries(allModelOptions.grouped).map(([prov, models]) => [
<MenuItem key={`header-${prov}`} disabled sx={{ opacity: '0.7 !important', py: 0.5, px: 1.5, minHeight: 'auto' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
{prov}
</Typography>
</MenuItem>,
...models.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
// Derive API-level provider key from the display group name
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'gemini',
// OpenRouter-backed providers
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
onProviderChange(providerMap[provLower] || provLower);
}
setModelAnchor(null);
}}
>
<ListItemText
primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)),
]).flat()}
</Menu>
<Box sx={{ flex: 1 }} />
@@ -72,8 +72,11 @@ const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ servi
return null;
};
function formatDuration(createdAt: string): string {
const seconds = Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000);
function formatDuration(createdAt: string, closedAt?: string | null, status?: string): string {
const start = new Date(createdAt).getTime();
const end = (closedAt ? new Date(closedAt).getTime() : null)
|| (status === 'running' || status === 'waiting_approval' ? Date.now() : Date.now());
const seconds = Math.max(0, Math.floor((end - start) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
@@ -778,7 +781,7 @@ const AgentCard: React.FC<Props> = ({
{session.mode}
</Typography>
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{formatDuration(session.created_at)}
{formatDuration(session.created_at, (session as any).closed_at, session.status)}
</Typography>
{session.cost_usd > 0 && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
+591 -121
View File
@@ -40,11 +40,508 @@ import Collapse from '@mui/material/Collapse';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, resetSystemPrompt, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
import { CommandsContent } from '@/app/pages/Commands/Commands';
import { API_BASE } from '@/shared/config';
// ── Copilot Auth Button ──
const CopilotAuthButton: React.FC = () => {
const c = useClaudeTokens();
const [status, setStatus] = useState<'idle' | 'waiting' | 'connected' | 'error'>('idle');
const [userCode, setUserCode] = useState('');
const [username, setUsername] = useState('');
const [error, setError] = useState('');
// Check if already connected
useEffect(() => {
fetch(`${API_BASE}/agents/copilot/models`)
.then(r => r.json())
.then(d => {
if (d.models && d.models.length > 0) setStatus('connected');
})
.catch(() => {});
}, []);
const startAuth = async () => {
setStatus('waiting');
setError('');
try {
const resp = await fetch(`${API_BASE}/agents/copilot/start-auth`, { method: 'POST' });
const data = await resp.json();
setUserCode(data.user_code);
window.open(data.verification_uri, '_blank');
// Poll for completion
const deviceCode = data.device_code;
const poll = setInterval(async () => {
try {
const r = await fetch(`${API_BASE}/agents/copilot/poll-auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code: deviceCode }),
});
const d = await r.json();
if (d.status === 'connected') {
clearInterval(poll);
setStatus('connected');
setUsername(d.username || '');
}
} catch {}
}, 5000);
// Timeout after 5 minutes
setTimeout(() => { clearInterval(poll); if (status === 'waiting') { setStatus('error'); setError('Auth timed out'); } }, 300000);
} catch (e: any) {
setStatus('error');
setError(e.message || 'Failed to start auth');
}
};
const disconnect = async () => {
await fetch(`${API_BASE}/agents/copilot/disconnect`, { method: 'POST' });
setStatus('idle');
setUsername('');
};
if (status === 'connected') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: c.status.success, flexShrink: 0 }} />
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary }}>
Connected{username ? ` as @${username}` : ''}
</Typography>
<Typography
onClick={disconnect}
sx={{ fontSize: '0.72rem', color: c.text.tertiary, cursor: 'pointer', ml: 'auto', '&:hover': { color: c.status.error } }}
>
Disconnect
</Typography>
</Box>
);
}
if (status === 'waiting') {
return (
<Box>
<Typography sx={{ fontSize: '0.78rem', color: c.text.primary, mb: 0.5 }}>
Enter code <strong style={{ fontFamily: 'monospace', fontSize: '0.9rem', letterSpacing: '0.1em' }}>{userCode}</strong> at github.com/login/device
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary }}>Waiting for authorization...</Typography>
</Box>
);
}
return (
<Box>
<Button
onClick={startAuth}
variant="outlined"
size="small"
sx={{
textTransform: 'none',
fontSize: '0.78rem',
color: c.text.primary,
borderColor: c.border.medium,
'&:hover': { borderColor: c.accent.primary, color: c.accent.primary },
}}
>
Sign in with GitHub
</Button>
{error && <Typography sx={{ fontSize: '0.7rem', color: c.status.error, mt: 0.5 }}>{error}</Typography>}
</Box>
);
};
// ── Subscription Provider Card ──
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
];
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode }) => {
const c = useClaudeTokens();
const isPreview = (provider as any).preview;
return (
<Box sx={{ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${connected ? c.status.success + '30' : c.border.subtle}`, bgcolor: connected ? `${c.status.success}04` : 'transparent', opacity: isPreview ? 0.5 : 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: connected ? c.status.success : c.border.medium, flexShrink: 0 }} />
<Box>
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{provider.desc}</Typography>
</Box>
</Box>
{isPreview ? (
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
Coming soon
</Typography>
) : connected ? (
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error } }}>
Disconnect
</Typography>
) : connecting && userCode ? (
<Box sx={{ textAlign: 'right' }}>
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
</Box>
) : (
<Button onClick={onConnect} disabled={connecting} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary } }}>
{connecting ? 'Waiting...' : 'Connect'}
</Button>
)}
</Box>
</Box>
);
};
const SubscriptionCards: React.FC = () => {
const c = useClaudeTokens();
const [status, setStatus] = useState<any>(null);
const [connecting, setConnecting] = useState<string | null>(null);
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: [] }));
};
useEffect(() => { fetchStatus(); }, []);
const isConnected = (providerId: string) => {
if (!status?.providers) return false;
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);
return connections.some((p: any) => p.provider === providerId && p.isActive);
};
const handleConnect = async (providerId: string) => {
setConnecting(providerId);
setUserCode('');
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId }),
});
const data = await r.json();
if (data.flow === 'device_code') {
// Device code flow (GitHub, Qwen, etc.) — show code, poll
const code = data.user_code || '';
setUserCode(code);
if (data.verification_uri) window.open(data.verification_uri, '_blank');
const timer = setInterval(async () => {
try {
const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
});
const pd = await pr.json();
if (pd.success) {
clearInterval(timer);
setConnecting(null);
setUserCode('');
fetchStatus();
}
} catch {}
}, 5000);
setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000);
} else if (data.flow === 'authorization_code') {
// Open auth URL as popup — window.opener lets callback page postMessage back
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
const msgHandler = async (event: MessageEvent) => {
const d = event.data;
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
if (callbackData?.code) {
window.removeEventListener('message', msgHandler);
clearInterval(statusPoller);
if (popup && !popup.closed) popup.close();
try {
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: providerId, code: callbackData.code,
redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
state: callbackData.state || data.state,
}),
});
} catch {}
setConnecting(null);
fetchStatus();
}
};
window.addEventListener('message', msgHandler);
const statusPoller = setInterval(async () => {
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
clearInterval(statusPoller);
window.removeEventListener('message', msgHandler);
setConnecting(null);
fetchStatus();
}
} catch {}
}, 2000);
setPollTimer(statusPoller);
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
} else {
setConnecting(null);
}
} catch { setConnecting(null); }
};
const handleDisconnect = async (providerId: string) => {
// TODO: implement disconnect via 9Router API
fetchStatus();
};
if (!status?.running) {
return (
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`, textAlign: 'center' }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 1 }}>
Starting subscription service...
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost }}>
This connects your existing AI subscriptions. If this doesn't load, make sure Node.js is installed.
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{SUBSCRIPTION_PROVIDERS.map(p => (
<SubscriptionCard
key={p.id}
provider={p}
connected={isConnected(p.id)}
onConnect={() => handleConnect(p.id)}
onDisconnect={() => handleDisconnect(p.id)}
connecting={connecting === p.id}
userCode={connecting === p.id ? userCode : undefined}
/>
))}
</Box>
);
};
// ── Pixel Bar ──
const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
return (
<Box sx={{ display: 'flex', gap: '1px', mt: 0.25 }}>
{Array.from({ length: width }, (_, i) => (
<Box
key={i}
sx={{
width: 5,
height: 5,
bgcolor: i < filled
? palette[Math.min(palette.length - 1, Math.floor((i / Math.max(filled - 1, 1)) * (palette.length - 1)))]
: c.border.subtle,
opacity: i < filled ? 1 : 0.3,
}}
/>
))}
</Box>
);
};
// ── Usage Stats Component ──
const UsageStats: React.FC = () => {
const c = useClaudeTokens();
const [stats, setStats] = useState<any>(null);
useEffect(() => {
fetch(`${API_BASE}/analytics/usage-summary`)
.then(r => r.json())
.then(setStats)
.catch(() => {});
}, []);
if (!stats) return null;
const formatCost = (v: number) => {
if (v === 0) return '$0.00';
if (v < 0.001) return `$${v.toFixed(6)}`;
if (v < 0.01) return `$${v.toFixed(5)}`;
if (v < 1) return `$${v.toFixed(4)}`;
return `$${v.toFixed(2)}`;
};
const formatDuration = (s: number) => {
if (s === 0) return '0s';
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
};
const formatTotalTime = (s: number) => {
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
return `${(s / 3600).toFixed(1)} hrs`;
};
const cardSx = {
p: 1.5,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
};
const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
// Pixel bar helper that passes tokens
const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
<PixelBarOuter {...props} tokens={c} />
);
const totalTime = stats.avg_duration_seconds * stats.total_sessions;
const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
const formatTokens = (n: number) => {
if (n === 0) return '0';
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
return `${(n / 1_000_000).toFixed(2)}M`;
};
const costSourceLabel = stats.cost_source === '9router' ? 'via subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
return (
<Box sx={{ mb: 2.5 }}>
{/* Row 1: Core metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Sessions</Typography>
<Typography sx={valueSx}>{stats.total_sessions.toLocaleString()}</Typography>
<Typography sx={subSx}>
{statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Cost</Typography>
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
<Typography sx={subSx}>
{costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Messages</Typography>
<Typography sx={valueSx}>{stats.total_messages.toLocaleString()}</Typography>
<Typography sx={subSx}>
{msgsPerSession} avg per session
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Tool Calls</Typography>
<Typography sx={valueSx}>{stats.total_tool_calls.toLocaleString()}</Typography>
<Typography sx={subSx}>
{toolsPerSession} avg per session
</Typography>
</Box>
</Box>
{/* Row 2: Time + efficiency + tokens */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Run Time</Typography>
<Typography sx={valueSx}>{formatTotalTime(totalTime)}</Typography>
<Typography sx={subSx}>across all sessions</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Avg Session</Typography>
<Typography sx={valueSx}>{formatDuration(stats.avg_duration_seconds)}</Typography>
<Typography sx={subSx}>per session duration</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Completion Rate</Typography>
<Typography sx={valueSx}>{(stats.completion_rate * 100).toFixed(1)}%</Typography>
<Typography sx={subSx}>
sessions finished successfully
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Tokens Used</Typography>
<Typography sx={valueSx}>
{stats.total_prompt_tokens || stats.total_completion_tokens
? formatTokens((stats.total_prompt_tokens || 0) + (stats.total_completion_tokens || 0))
: Object.keys(stats.providers_used || {}).length}
</Typography>
<Typography sx={subSx}>
{stats.total_prompt_tokens || stats.total_completion_tokens
? `${formatTokens(stats.total_prompt_tokens || 0)} in · ${formatTokens(stats.total_completion_tokens || 0)} out`
: providerEntries.map(([p]) => p).join(', ') || 'none'}
</Typography>
</Box>
</Box>
{/* Model + Provider + Tool breakdown */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{/* Models & Providers */}
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
return (
<Box key={model} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, fontWeight: 500 }}>{model}</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
{count} ({pct}%)
</Typography>
</Box>
<PixelBar value={count} max={stats.total_sessions} palette={PIXEL_BLUE} />
</Box>
);
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
</Box>
{/* Tools */}
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
{toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
return (
<Box key={tool} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}>{shortName}</Typography>
<Typography sx={{ fontSize: '0.62rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
{count} call{count !== 1 ? 's' : ''} ({pct}%)
</Typography>
</Box>
<PixelBar value={count} max={maxToolCount} />
</Box>
);
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No tool calls yet</Typography>}
</Box>
</Box>
</Box>
);
};
const API_KEY_STEPS = [
{
@@ -87,7 +584,7 @@ const Settings: React.FC = () => {
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
const updateError = useAppSelector((s) => s.update.error);
const [activeTab, setActiveTab] = useState<'general' | 'commands'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'models' | 'usage' | 'commands'>('general');
const [form, setForm] = useState<AppSettings>({ ...settings });
const [showApiKey, setShowApiKey] = useState(false);
const [browseOpen, setBrowseOpen] = useState(false);
@@ -143,6 +640,7 @@ const Settings: React.FC = () => {
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
dispatch(fetchModels());
setSaved(true);
};
@@ -165,6 +663,7 @@ const Settings: React.FC = () => {
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
dispatch(fetchModels());
setSaved(true);
setConfirmDiscard(false);
dispatch(closeSettingsModal());
@@ -272,6 +771,8 @@ const Settings: React.FC = () => {
}}
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
</Tabs>
</DialogTitle>
@@ -626,125 +1127,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* ── API ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>API</Typography>
<Box sx={rowLastSx}>
<Typography sx={labelSx}>Anthropic API key</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography sx={descSx}>
Stored securely in the local database.
</Typography>
<Typography
component="span"
onClick={() => setShowApiHelp((v) => !v)}
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 0.4,
whiteSpace: 'nowrap',
userSelect: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{showApiHelp ? 'Hide guide' : 'How do I get a key?'}
</Typography>
</Box>
<Collapse in={showApiHelp} timeout={250}>
<Box sx={{
mb: 1.5,
p: 2,
borderRadius: `${c.radius.md}px`,
bgcolor: `${c.accent.primary}08`,
border: `1px solid ${c.accent.primary}20`,
}}>
{API_KEY_STEPS.map((step, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: i < API_KEY_STEPS.length - 1 ? 1.5 : 0 }}>
<Box sx={{
width: 22,
height: 22,
borderRadius: '50%',
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.7rem',
fontWeight: 700,
flexShrink: 0,
mt: 0.1,
}}>
{i + 1}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500, lineHeight: 1.4 }}>
{step.title}
{step.link && (
<Typography
component="a"
href={step.link}
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
ml: 0.75,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
verticalAlign: 'middle',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Open
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Typography>
)}
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', lineHeight: 1.4 }}>
{step.detail}
</Typography>
</Box>
</Box>
))}
</Box>
</Collapse>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.anthropic_api_key ?? ''}
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-ant-..."
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowApiKey(!showApiKey)}
edge="end"
size="small"
sx={{ color: c.text.tertiary }}
>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
{/* ── Advanced ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
@@ -873,6 +1255,94 @@ const Settings: React.FC = () => {
</Box>
</Box>
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5 }}>
{/* ── USE EXISTING SUBSCRIPTIONS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
Use Your Existing Subscriptions
</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Already paying for Claude, ChatGPT, or Gemini? Connect your subscription no API key needed, no extra cost.
</Typography>
<SubscriptionCards />
{/* ── API KEYS ── */}
<Typography sx={{ fontSize: '0.7rem', color: c.text.ghost, textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600, mt: 1 }}>
Or Connect With API Keys
</Typography>
<Typography sx={{ ...descSx, mb: -1 }}>
Pay per use. Each key is stored locally on your device.
</Typography>
{/* Anthropic */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={labelSx}>Anthropic</Typography>
{form.anthropic_api_key ? (
<Typography sx={{ fontSize: '0.6rem', fontWeight: 600, color: c.status.success, bgcolor: `${c.status.success}15`, px: 0.75, py: 0.15, borderRadius: '3px' }}>CONNECTED</Typography>
) : null}
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>Claude Sonnet, Opus, Haiku.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.anthropic_api_key ?? ''}
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-ant-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
</Box>
) : activeTab === 'usage' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
<UsageStats />
{/* ── Analytics ── */}
<Typography sx={{ ...sectionSx, mt: 1 }}>Analytics</Typography>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Share anonymous usage data</Typography>
<Typography sx={descSx}>
Help improve OpenSwarm by sharing anonymous statistics like session counts, model usage, and feature adoption. No conversations, file paths, or personal information is ever collected.
</Typography>
</Box>
<Switch
checked={(form as any).analytics_opt_in ?? true}
onChange={(e) => setForm({ ...form, analytics_opt_in: e.target.checked } as any)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
</Box>
) : (
<Box sx={{ pt: 2.5, pb: 1 }}>
<CommandsContent />
@@ -880,7 +1350,7 @@ const Settings: React.FC = () => {
)}
</DialogContent>
{activeTab === 'general' && (
{(activeTab === 'general' || activeTab === 'models') && (
<DialogActions sx={{ borderTop: `1px solid ${c.border.subtle}`, px: 3, py: 1.5, justifyContent: 'flex-end' }}>
<Button
onClick={handleRequestClose}
+1
View File
@@ -3,3 +3,4 @@ const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
+17 -4
View File
@@ -50,6 +50,7 @@ export interface AgentSession {
id: string;
name: string;
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
provider: string;
model: string;
mode: string;
worktree_path: string | null;
@@ -75,6 +76,7 @@ export interface AgentSession {
export interface AgentConfig {
name?: string;
provider?: string;
model?: string;
mode?: string;
system_prompt?: string;
@@ -151,6 +153,7 @@ export interface SendMessagePayload {
prompt: string;
mode?: string;
model?: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
@@ -161,11 +164,11 @@ export interface SendMessagePayload {
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
});
return { sessionId, prompt };
}
@@ -213,6 +216,7 @@ export interface LaunchAndSendPayload {
prompt: string;
mode: string;
model: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
@@ -232,7 +236,7 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -244,7 +248,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
});
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
@@ -424,6 +428,7 @@ const agentsSlice = createSlice({
id: draftId,
name: 'New chat',
status: 'draft',
provider: 'anthropic',
model: 'sonnet',
mode,
worktree_path: null,
@@ -664,6 +669,13 @@ const agentsSlice = createSlice({
}
},
updateSessionProvider(state, action: PayloadAction<{ sessionId: string; provider: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.provider = action.payload.provider;
}
},
updateSessionModel(state, action: PayloadAction<{ sessionId: string; model: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -1004,6 +1016,7 @@ export const {
updateSessionCost,
addBranch,
setActiveBranch,
updateSessionProvider,
updateSessionModel,
updateSessionMode,
closeSessionFromWs,
+37 -190
View File
@@ -3,208 +3,64 @@ import { API_BASE } from '@/shared/config';
const ANALYTICS_API = `${API_BASE}/analytics`;
export interface AnalyticsSummary {
export interface UsageSummary {
total_sessions: number;
total_cost_usd: number;
total_messages: number;
total_tool_calls: number;
avg_session_duration_seconds: number;
session_completion_rate: number;
approval_rate: number;
models_used: Record<string, number>;
modes_used: Record<string, number>;
top_tools: [string, number][];
}
export interface UsagePoint {
date: string;
sessions: number;
cost: number;
}
export interface CostPoint {
date: string;
cost: number;
}
export interface ToolRank {
tool: string;
count: number;
}
export interface ApprovalStats {
allow: number;
deny: number;
total: number;
rate: number;
avg_latency_ms: number;
}
export interface SessionStats {
completed: number;
stopped: number;
error: number;
total: number;
completion_rate: number;
avg_duration_seconds: number;
avg_cost_per_session: number;
completion_rate: number;
models_used: Record<string, number>;
providers_used: Record<string, number>;
top_tools: Record<string, number>;
status_breakdown: Record<string, number>;
// 9Router enrichment
total_prompt_tokens: number;
total_completion_tokens: number;
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
cost_by_provider: Record<string, { cost: number; requests: number }>;
cost_source: ' 9router' | 'sdk' | 'none';
nine_router_available: boolean;
total_requests: number;
}
export interface HourlyPoint {
hour: number;
count: number;
}
export interface DurationBucket {
label: string;
count: number;
}
export interface CostByModel {
model: string;
cost: number;
sessions: number;
}
export interface CumulativeCostPoint {
date: string;
cumulative: number;
daily: number;
}
export interface ToolDuration {
tool: string;
calls: number;
avg_ms: number;
max_ms: number;
}
export interface SessionCost {
timestamp: string;
model: string;
cost: number;
duration: number;
messages: number;
export interface CostBreakdown {
available: boolean;
period: string;
total_cost: number;
total_requests: number;
total_prompt_tokens: number;
total_completion_tokens: number;
by_model: Record<string, any>;
by_provider: Record<string, any>;
}
interface AnalyticsState {
summary: AnalyticsSummary | null;
usage: UsagePoint[];
cost: CostPoint[];
tools: ToolRank[];
approvals: ApprovalStats | null;
sessionStats: SessionStats | null;
hourly: HourlyPoint[];
durationDist: DurationBucket[];
costByModel: CostByModel[];
cumulativeCost: CumulativeCostPoint[];
toolDurations: ToolDuration[];
sessionCosts: SessionCost[];
exportPreview: any | null;
summary: UsageSummary | null;
costBreakdown: CostBreakdown | null;
loading: boolean;
}
const initialState: AnalyticsState = {
summary: null,
usage: [],
cost: [],
tools: [],
approvals: null,
sessionStats: null,
hourly: [],
durationDist: [],
costByModel: [],
cumulativeCost: [],
toolDurations: [],
sessionCosts: [],
exportPreview: null,
costBreakdown: null,
loading: false,
};
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
const res = await fetch(`${ANALYTICS_API}/summary`);
return (await res.json()) as AnalyticsSummary;
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
return (await res.json()) as UsageSummary;
});
export const fetchUsage = createAsyncThunk(
'analytics/fetchUsage',
async ({ period, range }: { period: string; range: number }) => {
const res = await fetch(`${ANALYTICS_API}/usage?period=${period}&range=${range}`);
const data = await res.json();
return data.data as UsagePoint[];
export const fetchCostBreakdown = createAsyncThunk(
'analytics/fetchCostBreakdown',
async (period: string = '7d') => {
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
return (await res.json()) as CostBreakdown;
},
);
export const fetchCost = createAsyncThunk(
'analytics/fetchCost',
async ({ period, range }: { period: string; range: number }) => {
const res = await fetch(`${ANALYTICS_API}/cost?period=${period}&range=${range}`);
const data = await res.json();
return data.data as CostPoint[];
},
);
export const fetchTools = createAsyncThunk('analytics/fetchTools', async () => {
const res = await fetch(`${ANALYTICS_API}/tools?limit=20`);
const data = await res.json();
return data.data as ToolRank[];
});
export const fetchApprovals = createAsyncThunk('analytics/fetchApprovals', async () => {
const res = await fetch(`${ANALYTICS_API}/approvals`);
return (await res.json()) as ApprovalStats;
});
export const fetchSessionStats = createAsyncThunk('analytics/fetchSessionStats', async () => {
const res = await fetch(`${ANALYTICS_API}/sessions-stats`);
return (await res.json()) as SessionStats;
});
export const fetchHourlyActivity = createAsyncThunk('analytics/fetchHourly', async () => {
const res = await fetch(`${ANALYTICS_API}/hourly-activity`);
const data = await res.json();
return data.data as HourlyPoint[];
});
export const fetchDurationDistribution = createAsyncThunk('analytics/fetchDurationDist', async () => {
const res = await fetch(`${ANALYTICS_API}/duration-distribution`);
const data = await res.json();
return data.data as DurationBucket[];
});
export const fetchCostByModel = createAsyncThunk('analytics/fetchCostByModel', async () => {
const res = await fetch(`${ANALYTICS_API}/cost-by-model`);
const data = await res.json();
return data.data as CostByModel[];
});
export const fetchCumulativeCost = createAsyncThunk('analytics/fetchCumulativeCost', async () => {
const res = await fetch(`${ANALYTICS_API}/cumulative-cost?range=90`);
const data = await res.json();
return data.data as CumulativeCostPoint[];
});
export const fetchToolDurations = createAsyncThunk('analytics/fetchToolDurations', async () => {
const res = await fetch(`${ANALYTICS_API}/tool-durations`);
const data = await res.json();
return data.data as ToolDuration[];
});
export const fetchSessionCosts = createAsyncThunk('analytics/fetchSessionCosts', async () => {
const res = await fetch(`${ANALYTICS_API}/cost-per-session?limit=50`);
const data = await res.json();
return data.data as SessionCost[];
});
export const fetchExportPreview = createAsyncThunk('analytics/fetchExportPreview', async () => {
const res = await fetch(`${ANALYTICS_API}/export/preview`);
return await res.json();
});
export const doExport = createAsyncThunk('analytics/doExport', async () => {
const res = await fetch(`${ANALYTICS_API}/export`, { method: 'POST' });
return await res.json();
});
const analyticsSlice = createSlice({
name: 'analytics',
initialState,
@@ -217,18 +73,9 @@ const analyticsSlice = createSlice({
state.summary = action.payload;
})
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
.addCase(fetchUsage.fulfilled, (state, action) => { state.usage = action.payload; })
.addCase(fetchCost.fulfilled, (state, action) => { state.cost = action.payload; })
.addCase(fetchTools.fulfilled, (state, action) => { state.tools = action.payload; })
.addCase(fetchApprovals.fulfilled, (state, action) => { state.approvals = action.payload; })
.addCase(fetchSessionStats.fulfilled, (state, action) => { state.sessionStats = action.payload; })
.addCase(fetchHourlyActivity.fulfilled, (state, action) => { state.hourly = action.payload; })
.addCase(fetchDurationDistribution.fulfilled, (state, action) => { state.durationDist = action.payload; })
.addCase(fetchCostByModel.fulfilled, (state, action) => { state.costByModel = action.payload; })
.addCase(fetchCumulativeCost.fulfilled, (state, action) => { state.cumulativeCost = action.payload; })
.addCase(fetchToolDurations.fulfilled, (state, action) => { state.toolDurations = action.payload; })
.addCase(fetchSessionCosts.fulfilled, (state, action) => { state.sessionCosts = action.payload; })
.addCase(fetchExportPreview.fulfilled, (state, action) => { state.exportPreview = action.payload; });
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
state.costBreakdown = action.payload;
});
},
});
+49
View File
@@ -0,0 +1,49 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const AGENTS_API = `${API_BASE}/agents`;
export interface ModelOption {
value: string;
label: string;
version?: string;
context_window: number;
}
interface ModelsState {
byProvider: Record<string, ModelOption[]>;
loaded: boolean;
}
const initialState: ModelsState = {
byProvider: {},
loaded: false,
};
export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
const res = await fetch(`${AGENTS_API}/models`);
if (!res.ok) throw new Error('Failed to fetch models');
const data = await res.json();
// API returns { models: { provider: [...] } }
const models = data.models || data;
return models as Record<string, ModelOption[]>;
});
const modelsSlice = createSlice({
name: 'models',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchModels.fulfilled, (state, action) => {
state.byProvider = action.payload;
state.loaded = true;
})
.addCase(fetchModels.rejected, (state) => {
// Mark as loaded even on failure so we fall back to hardcoded options
state.loaded = true;
});
},
});
export default modelsSlice.reducer;
@@ -11,6 +11,13 @@ export const DEFAULT_SYSTEM_PROMPT =
`If a Browser is selected, prioritize this over other tools when it makes sense (so the user also has observability).\n\n` +
`If multiple Browsers are selected, parallelize the tasks across them.`;
export interface CustomProvider {
name: string;
base_url: string;
api_key: string;
models: Array<{ value: string; label: string; context_window?: number }>;
}
export interface AppSettings {
default_system_prompt: string | null;
default_folder: string | null;
@@ -21,6 +28,10 @@ export interface AppSettings {
theme: 'light' | 'dark';
new_agent_shortcut: string;
anthropic_api_key: string | null;
openai_api_key?: string | null;
google_api_key?: string | null;
openrouter_api_key?: string | null;
custom_providers?: CustomProvider[];
browser_homepage: string;
auto_select_mode_on_new_agent: boolean;
expand_new_chats_in_dashboard: boolean;
+2
View File
@@ -13,6 +13,7 @@ import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import analyticsReducer from './analyticsSlice';
import modelsReducer from './modelsSlice';
export const store = configureStore({
reducer: {
@@ -30,6 +31,7 @@ export const store = configureStore({
dashboards: dashboardsReducer,
update: updateReducer,
analytics: analyticsReducer,
models: modelsReducer,
},
});
+1 -1
View File
@@ -284,7 +284,7 @@ class WebSocketManager {
sendMessage(
sessionId: string,
prompt: string,
opts?: { mode?: string; model?: string; images?: Array<{ data: string; media_type: string }> },
opts?: { mode?: string; model?: string; provider?: string; images?: Array<{ data: string; media_type: string }> },
) {
this.send('agent:send_message', {
session_id: sessionId,