mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[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:
@@ -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 → 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,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 }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user