mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-17 15:17:40 +02:00
[eric] v1.0.20: add common MCP integrations, mcp OAuth, one-click connect UX, onboarding tools step, ui/ux improvements
This commit is contained in:
@@ -1,9 +1,22 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, Typography, Modal, Button, CircularProgress } from '@mui/material';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const ONBOARDING_TOOL_INTEGRATIONS = [
|
||||
{ name: 'Google Workspace', desc: 'Gmail, Calendar, Drive, Docs, Sheets', color: '#4285F4', oauthProvider: 'google',
|
||||
mcp_config: { type: 'stdio', command: 'uvx', args: ['--from', 'google-workspace-mcp', 'google-workspace-worker'] } },
|
||||
{ name: 'GitHub', desc: 'Repos, issues, pull requests', color: '#24292E', oauthProvider: 'github',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] } },
|
||||
{ name: 'Slack', desc: 'Channels, messages, search', color: '#4A154B', oauthProvider: 'slack',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] } },
|
||||
{ name: 'Notion', desc: 'Pages, databases, search', color: '#000000', oauthProvider: 'notion',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] } },
|
||||
];
|
||||
|
||||
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 },
|
||||
@@ -15,8 +28,10 @@ const OnboardingModal: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const settings = useAppSelector((s) => s.settings);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'provider' | 'tools'>('provider');
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
|
||||
const [connectedTools, setConnectedTools] = useState<Set<string>>(new Set());
|
||||
const pollTimerRef = useRef<any>(null);
|
||||
const msgHandlerRef = useRef<any>(null);
|
||||
|
||||
@@ -121,7 +136,7 @@ const OnboardingModal: React.FC = () => {
|
||||
if (pd.success) {
|
||||
clearInterval(timer);
|
||||
pollTimerRef.current = null;
|
||||
dismiss();
|
||||
advanceToTools();
|
||||
}
|
||||
} catch {}
|
||||
}, 5000);
|
||||
@@ -144,7 +159,7 @@ const OnboardingModal: React.FC = () => {
|
||||
window.removeEventListener('message', msgHandlerRef.current);
|
||||
msgHandlerRef.current = null;
|
||||
}
|
||||
dismiss();
|
||||
advanceToTools();
|
||||
}
|
||||
} catch {}
|
||||
}, 2000);
|
||||
@@ -172,7 +187,7 @@ const OnboardingModal: React.FC = () => {
|
||||
}),
|
||||
});
|
||||
} catch {}
|
||||
dismiss();
|
||||
advanceToTools();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', msgHandler);
|
||||
@@ -192,8 +207,78 @@ const OnboardingModal: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKey = () => dismiss();
|
||||
const handleSkip = () => dismiss();
|
||||
const advanceToTools = () => {
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
|
||||
setConnecting(null);
|
||||
setStep('tools');
|
||||
};
|
||||
|
||||
const handleToolConnect = async (integration: typeof ONBOARDING_TOOL_INTEGRATIONS[0]) => {
|
||||
setConnecting(integration.name);
|
||||
try {
|
||||
// Create the tool
|
||||
const createRes = await fetch(`${API_BASE}/tools/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: integration.name,
|
||||
description: integration.desc,
|
||||
mcp_config: integration.mcp_config,
|
||||
auth_type: 'oauth2',
|
||||
auth_status: 'configured',
|
||||
oauth_provider: integration.oauthProvider,
|
||||
}),
|
||||
});
|
||||
if (!createRes.ok) { setConnecting(null); return; }
|
||||
const { tool } = await createRes.json();
|
||||
|
||||
// Start OAuth
|
||||
const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' });
|
||||
if (!oauthRes.ok) { setConnecting(null); return; }
|
||||
const { auth_url } = await oauthRes.json();
|
||||
|
||||
// Open popup
|
||||
const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100');
|
||||
|
||||
// Listen for completion
|
||||
const onMsg = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) {
|
||||
window.removeEventListener('message', onMsg);
|
||||
setConnectedTools((prev) => new Set(prev).add(integration.name));
|
||||
setConnecting(null);
|
||||
// Trigger discovery in background
|
||||
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
|
||||
// Fallback: poll for popup close
|
||||
const poller = setInterval(() => {
|
||||
if (popup && popup.closed) {
|
||||
clearInterval(poller);
|
||||
window.removeEventListener('message', onMsg);
|
||||
// Check if connected
|
||||
fetch(`${API_BASE}/tools/${tool.id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.tool?.auth_status === 'connected') {
|
||||
setConnectedTools((prev) => new Set(prev).add(integration.name));
|
||||
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
setConnecting(null);
|
||||
}
|
||||
}, 1000);
|
||||
setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000);
|
||||
} catch {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKey = () => advanceToTools();
|
||||
const handleSkip = () => step === 'tools' ? dismiss() : dismiss();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -204,6 +289,68 @@ const OnboardingModal: React.FC = () => {
|
||||
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
{step === 'tools' ? (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
|
||||
Connect Your Accounts
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5, textAlign: 'center' }}>
|
||||
10+ tools already active with no setup needed
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mb: 3, textAlign: 'center' }}>
|
||||
Connect services below for even more capabilities
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
|
||||
{ONBOARDING_TOOL_INTEGRATIONS.map((ig) => {
|
||||
const isConnected = connectedTools.has(ig.name);
|
||||
const isConnecting = connecting === ig.name;
|
||||
return (
|
||||
<Box
|
||||
key={ig.name}
|
||||
onClick={() => !isConnected && !isConnecting && !connecting && handleToolConnect(ig)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${isConnected ? `${ig.color}40` : c.border.subtle}`,
|
||||
cursor: isConnected ? 'default' : connecting ? 'wait' : 'pointer',
|
||||
bgcolor: isConnected ? `${ig.color}08` : 'transparent',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
...(!isConnected && !connecting && { '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}05` } }),
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{ig.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{ig.desc}</Typography>
|
||||
</Box>
|
||||
{isConnected ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 18, color: ig.color }} />
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '0.68rem', color: isConnecting ? ig.color : c.text.tertiary }}>
|
||||
{isConnecting ? 'Connecting...' : 'Connect \u2192'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
onClick={dismiss}
|
||||
fullWidth
|
||||
variant={connectedTools.size > 0 ? 'contained' : 'text'}
|
||||
sx={{
|
||||
textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px`,
|
||||
...(connectedTools.size > 0
|
||||
? { bgcolor: c.accent.primary, color: '#fff', '&:hover': { bgcolor: c.accent.hover } }
|
||||
: { color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }),
|
||||
}}
|
||||
>
|
||||
{connectedTools.size > 0 ? 'Done' : 'Skip for now'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
|
||||
Welcome to OpenSwarm
|
||||
</Typography>
|
||||
@@ -268,6 +415,8 @@ const OnboardingModal: React.FC = () => {
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -190,6 +190,9 @@ interface Props {
|
||||
autoFocusInput?: boolean;
|
||||
cardZOrder?: number;
|
||||
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
|
||||
isFocused?: boolean;
|
||||
onFocusRequest?: (sessionId: string) => void;
|
||||
onFocusExit?: () => void;
|
||||
}
|
||||
|
||||
const MIN_W = 480;
|
||||
@@ -207,6 +210,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
|
||||
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
|
||||
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onBringToFront,
|
||||
isFocused = false, onFocusRequest, onFocusExit,
|
||||
}) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -463,6 +467,51 @@ const AgentCard: React.FC<Props> = ({
|
||||
}
|
||||
: { opacity: 0, scale: 0.85, transition: { duration: 0.2 } };
|
||||
|
||||
if (isFocused) {
|
||||
// Focus mode: render as a simple box filling its container (outside canvas transform)
|
||||
return (
|
||||
<Box
|
||||
ref={cardBoxRef}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.strong}`,
|
||||
borderRadius: 3,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<Box
|
||||
onDoubleClick={(e) => { e.stopPropagation(); onFocusExit?.(); }}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
mb: 1, flexShrink: 0, cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{session.name || 'Agent'}
|
||||
</Typography>
|
||||
<Chip label={session.status} size="small" sx={{ fontSize: '0.7rem', height: 20, bgcolor: session.status === 'running' ? c.status.info : session.status === 'completed' ? c.status.success : c.bg.elevated, color: c.text.secondary }} />
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>{session.model} · {formatDuration(session.created_at, undefined, session.status)}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => onFocusExit?.()} sx={{ color: c.text.ghost }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{/* Chat fills remaining space */}
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<AgentChat sessionId={session.id} autoFocus={true} embedded={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout={false}
|
||||
@@ -481,7 +530,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
data-select-type="agent-card"
|
||||
data-select-id={session.id}
|
||||
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
|
||||
|
||||
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
if (justDraggedRef.current) return;
|
||||
if (!isSelected && !e.shiftKey) {
|
||||
@@ -642,8 +691,8 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Resize handles: 4 edges + 4 corners */}
|
||||
{HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
{/* Resize handles: 4 edges + 4 corners (hidden in focus mode) */}
|
||||
{!isFocused && HANDLE_DEFS.map(({ dir, sx }) => (
|
||||
<Box
|
||||
key={dir}
|
||||
onPointerDown={handleResizeDown(dir)}
|
||||
@@ -687,6 +736,10 @@ const AgentCard: React.FC<Props> = ({
|
||||
onPointerDown={handleDragPointerDown}
|
||||
onPointerMove={handleDragPointerMove}
|
||||
onPointerUp={handleDragPointerUp}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFocusRequest?.(session.id);
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 16,
|
||||
@@ -695,7 +748,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1.5,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -117,6 +117,7 @@ const DashboardInner: React.FC = () => {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [toolbarOpen, setToolbarOpen] = useState(false);
|
||||
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
|
||||
const [highlightedCardId, setHighlightedCardId] = useState<string | null>(null);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
|
||||
@@ -531,6 +532,66 @@ const DashboardInner: React.FC = () => {
|
||||
return () => window.removeEventListener('keydown', handleEnter);
|
||||
}, [selection.selectedIds, dispatch]);
|
||||
|
||||
// Focus mode: pop a card out as full-viewport overlay
|
||||
const handleFocusRequest = useCallback((sessionId: string) => {
|
||||
// Auto-expand if collapsed
|
||||
if (!expandedSessionIds.includes(sessionId)) {
|
||||
dispatch(toggleExpandSession(sessionId));
|
||||
}
|
||||
setFocusedCardId(sessionId);
|
||||
}, [expandedSessionIds, dispatch]);
|
||||
|
||||
const handleFocusExit = useCallback(() => {
|
||||
setFocusedCardId(null);
|
||||
}, []);
|
||||
|
||||
// Focus mode keyboard: Escape to exit, F to enter
|
||||
useEffect(() => {
|
||||
const handleFocusKeys = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||
|
||||
if (e.key === 'Escape' && focusedCardId) {
|
||||
e.preventDefault();
|
||||
setFocusedCardId(null);
|
||||
return;
|
||||
}
|
||||
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !focusedCardId) {
|
||||
if (selection.selectedIds.size !== 1) return;
|
||||
const [id, type] = selection.selectedIds.entries().next().value!;
|
||||
if (type !== 'agent') return;
|
||||
e.preventDefault();
|
||||
handleFocusRequest(id);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleFocusKeys);
|
||||
return () => window.removeEventListener('keydown', handleFocusKeys);
|
||||
}, [focusedCardId, selection.selectedIds, handleFocusRequest]);
|
||||
|
||||
// Auto-zoom to card when it gets expanded (not on initial load)
|
||||
const prevExpandedRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!layoutInitialized) {
|
||||
prevExpandedRef.current = expandedSessionIds;
|
||||
return;
|
||||
}
|
||||
const prev = new Set(prevExpandedRef.current);
|
||||
const newlyExpanded = expandedSessionIds.filter((id) => !prev.has(id));
|
||||
prevExpandedRef.current = expandedSessionIds;
|
||||
|
||||
// Only auto-zoom for single-card expansions (not bulk restore)
|
||||
if (newlyExpanded.length !== 1) return;
|
||||
|
||||
const cardId = newlyExpanded[0];
|
||||
const card = cards[cardId];
|
||||
if (!card) return;
|
||||
|
||||
setTimeout(() => {
|
||||
const height = Math.max(EXPANDED_CARD_MIN_H, measuredHeightsRef.current[cardId] || card.height);
|
||||
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height }], 2.0, true);
|
||||
}, 200);
|
||||
}, [expandedSessionIds, layoutInitialized, cards, canvas.actions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDelete = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
||||
@@ -1303,6 +1364,8 @@ const DashboardInner: React.FC = () => {
|
||||
{Object.values(cards).map((card) => {
|
||||
const session = sessions[card.session_id];
|
||||
if (!session) return null;
|
||||
// Skip focused card here — rendered outside the canvas transform
|
||||
if (focusedCardId === session.id) return null;
|
||||
|
||||
let origin = spawnOriginsRef.current[session.id];
|
||||
if (origin) {
|
||||
@@ -1378,6 +1441,9 @@ const DashboardInner: React.FC = () => {
|
||||
snapColumn={snapColumn}
|
||||
autoFocusInput={autoFocusSessionId === session.id}
|
||||
onBringToFront={handleBringToFront}
|
||||
isFocused={focusedCardId === session.id}
|
||||
onFocusRequest={handleFocusRequest}
|
||||
onFocusExit={handleFocusExit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1467,9 +1533,53 @@ const DashboardInner: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
{/* Floating zoom controls */}
|
||||
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
|
||||
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
|
||||
</Box>
|
||||
{!focusedCardId && (
|
||||
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
|
||||
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Focus mode: backdrop + card rendered outside canvas transform */}
|
||||
{focusedCardId && (() => {
|
||||
const focusedCard = cards[focusedCardId];
|
||||
const focusedSession = focusedCard ? sessions[focusedCard.session_id] : null;
|
||||
if (!focusedSession || !focusedCard) return null;
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={handleFocusExit}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1200,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ position: 'fixed', inset: 48, zIndex: 1250 }}>
|
||||
<AgentCard
|
||||
session={focusedSession}
|
||||
expanded={true}
|
||||
cardX={0}
|
||||
cardY={0}
|
||||
cardWidth={0}
|
||||
cardHeight={0}
|
||||
cardZOrder={100000}
|
||||
zoom={1}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
onCardSelect={() => {}}
|
||||
onMeasuredHeight={() => {}}
|
||||
onBringToFront={() => {}}
|
||||
isFocused={true}
|
||||
onFocusRequest={handleFocusRequest}
|
||||
onFocusExit={handleFocusExit}
|
||||
autoFocusInput={true}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 3.0;
|
||||
const ZOOM_IN_FACTOR = 1.1;
|
||||
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
|
||||
const FIT_PADDING = 200;
|
||||
const FIT_PADDING = 80;
|
||||
|
||||
// Maps the 1–100 user setting to an internal multiplier.
|
||||
// 50 (default) → 0.004, 1 → 0.0004, 100 → 0.008
|
||||
@@ -80,7 +80,11 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
|
||||
setState((prev) => {
|
||||
const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
|
||||
const factor = Math.pow(2, -delta * sensitivityToMultiplier(sensitivityRef.current));
|
||||
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
let newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
// Snap to 100% when crossing through the 0.97–1.03 band (trackpad only)
|
||||
if (newZoom > 0.97 && newZoom < 1.03 && (prev.zoom <= 0.97 || prev.zoom >= 1.03)) {
|
||||
newZoom = 1.0;
|
||||
}
|
||||
const ratio = newZoom / prev.zoom;
|
||||
return {
|
||||
panX: cx - (cx - prev.panX) * ratio,
|
||||
|
||||
@@ -94,6 +94,7 @@ interface CredentialField {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
helpText?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
interface Integration {
|
||||
@@ -108,28 +109,15 @@ interface Integration {
|
||||
connectLabel?: string;
|
||||
connectInstructions?: string;
|
||||
authType?: 'none' | 'oauth2' | 'env_vars';
|
||||
oauthProvider?: string;
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
{
|
||||
id: 'xbird',
|
||||
name: 'xbird',
|
||||
description: 'Twitter/X research — search tweets, read profiles, threads, timelines.',
|
||||
mcp_config: { type: 'stdio', command: 'bunx', args: ['@checkra1n/xbird'] },
|
||||
color: '#1DA1F2',
|
||||
website: 'https://xbird.dev',
|
||||
icon: '𝕏',
|
||||
connectLabel: 'Connect 𝕏',
|
||||
connectInstructions: 'Open x.com in your browser, press F12 → Application → Cookies → x.com, and copy the values for auth_token and ct0.',
|
||||
credentialFields: [
|
||||
{ key: 'TWITTER_AUTH_TOKEN', label: 'auth_token', placeholder: 'Paste auth_token cookie value' },
|
||||
{ key: 'TWITTER_CT0', label: 'ct0', placeholder: 'Paste ct0 cookie value' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reddit',
|
||||
name: 'Reddit',
|
||||
description: 'Browse subreddits, search posts, get post details, analyze users. No API keys required.',
|
||||
description: 'Browse subreddits, search posts, get post details, analyze users.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'reddit-mcp-buddy'] },
|
||||
color: '#FF4500',
|
||||
website: 'https://github.com/karanb192/reddit-mcp-buddy',
|
||||
@@ -156,6 +144,336 @@ const INTEGRATIONS: Integration[] = [
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'google',
|
||||
},
|
||||
{
|
||||
id: 'sequential-thinking',
|
||||
name: 'Sequential Thinking',
|
||||
description: 'Dynamic, reflective problem-solving through structured thought sequences.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] },
|
||||
color: '#8B5CF6',
|
||||
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#8B5CF6"/>
|
||||
<path d="M12 6a4 4 0 0 0-4 4c0 1.5.8 2.8 2 3.5V15h4v-1.5c1.2-.7 2-2 2-3.5a4 4 0 0 0-4-4zm-1 11h2v1h-2z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
name: 'Memory',
|
||||
description: 'Persistent memory using a local knowledge graph. Entities, relations, and observations.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] },
|
||||
color: '#06B6D4',
|
||||
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#06B6D4"/>
|
||||
<path d="M12 4C9.2 4 7 6.2 7 9c0 1.9 1 3.5 2.5 4.3V15h5v-1.7C16 12.5 17 10.9 17 9c0-2.8-2.2-5-5-5zm-1.5 13h3v1h-3zm0 2h3v1h-3z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'filesystem',
|
||||
name: 'Filesystem',
|
||||
description: 'Read, write, search, and manage local files and directories.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/'] },
|
||||
color: '#10B981',
|
||||
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#10B981"/>
|
||||
<path d="M6 6h5l2 2h5v10H6V6zm2 2v8h8V10h-4l-2-2H8z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'playwright',
|
||||
name: 'Playwright',
|
||||
description: 'Browser automation — navigate, click, fill forms, take screenshots.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@playwright/mcp'] },
|
||||
color: '#2EAD33',
|
||||
website: 'https://github.com/microsoft/playwright-mcp',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#2EAD33"/>
|
||||
<path d="M7 8h10v8H7V8zm2 2v4h6v-4H9zm1 1h4v2h-4v-2z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'context7',
|
||||
name: 'Context7',
|
||||
description: 'Live, up-to-date documentation lookup for libraries and frameworks.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@upstash/context7-mcp@latest'] },
|
||||
color: '#00E599',
|
||||
website: 'https://context7.com',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#00E599"/>
|
||||
<text x="12" y="16.5" textAnchor="middle" fill="#fff" fontSize="12" fontWeight="bold">C7</text>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'desktop-commander',
|
||||
name: 'Desktop Commander',
|
||||
description: 'Terminal commands, file operations, process management, and diff-based editing.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@wonderwhy-er/desktop-commander'] },
|
||||
color: '#F59E0B',
|
||||
website: 'https://github.com/wonderwhy-er/DesktopCommanderMCP',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#F59E0B"/>
|
||||
<path d="M7 8l4 4-4 4M13 16h4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
name: 'Git',
|
||||
description: 'Git operations — log, diff, commit, branch, status, and more on local repositories.',
|
||||
mcp_config: { type: 'stdio', command: 'uvx', args: ['mcp-server-git'] },
|
||||
color: '#F05032',
|
||||
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/git',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#F05032"/>
|
||||
<path d="M12.5 4.5l7 7a.7.7 0 0 1 0 1l-7 7a.7.7 0 0 1-1 0l-7-7a.7.7 0 0 1 0-1l7-7a.7.7 0 0 1 1 0z" fill="none" stroke="#fff" strokeWidth="1.5"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#fff"/>
|
||||
<circle cx="9" cy="9" r="1.2" fill="#fff"/>
|
||||
<line x1="10" y1="10" x2="11" y2="11" stroke="#fff" strokeWidth="1"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'youtube-transcripts',
|
||||
name: 'YouTube Transcripts',
|
||||
description: 'Fetch transcripts and captions from YouTube videos.',
|
||||
comingSoon: true,
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@kimtaeyoon83/mcp-server-youtube-transcript'] },
|
||||
color: '#FF0000',
|
||||
website: 'https://github.com/kimtaeyoon83/mcp-server-youtube-transcript',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#FF0000"/>
|
||||
<path d="M9.5 8.5v7l6-3.5z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'twitter',
|
||||
name: 'Twitter / X',
|
||||
description: 'Fetch tweets, threads, and media from Twitter/X. Read-only.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'tweetsave-mcp'] },
|
||||
color: '#000000',
|
||||
website: 'https://github.com/zezeron/tweetsave-mcp',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#000"/>
|
||||
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="13" fontWeight="bold">𝕏</text>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'shopify-dev',
|
||||
name: 'Shopify Dev',
|
||||
description: 'Search Shopify docs, explore API schemas, and validate GraphQL queries.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@shopify/dev-mcp@latest'] },
|
||||
color: '#96BF48',
|
||||
website: 'https://github.com/Shopify/dev-mcp',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#96BF48"/>
|
||||
<path d="M15.5 5.5l-1.2-.7c-.1-.1-.2-.1-.3 0l-.5.3c-.3-.2-.7-.3-1-.4l-.2-.8c0-.1-.1-.2-.3-.2h-1.4c-.1 0-.2.1-.3.2l-.2.8c-.4.1-.7.2-1 .4l-.5-.3c-.1-.1-.2-.1-.3 0L7.1 5.5c-.1.1-.1.2 0 .3l.4.5c-.1.3-.2.6-.2 1H6.5c-.2 0-.3.1-.3.3v1.4c0 .2.1.3.3.3h.8c.1.4.2.7.4 1l-.4.5c-.1.1-.1.2 0 .3l1 1c.1.1.2.1.3 0l.5-.4c.3.2.6.3 1 .4l.1.8c0 .1.1.3.3.3h1.4c.2 0 .3-.1.3-.3l.1-.8c.4-.1.7-.2 1-.4l.5.4c.1.1.2.1.3 0l1-1c.1-.1.1-.2 0-.3l-.4-.5c.2-.3.3-.6.4-1h.8c.2 0 .3-.1.3-.3V7.6c0-.2-.1-.3-.3-.3h-.8c-.1-.4-.2-.7-.4-1l.4-.5c.1-.1.1-.2 0-.3zM11.3 10a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" fill="#fff" transform="translate(0.7, 3)"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// --- OAuth integrations (click Connect → sign in → done) ---
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
description: 'Manage repositories, issues, pull requests, branches, and code search.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
|
||||
color: '#24292E',
|
||||
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#24292E"/>
|
||||
<path d="M12 5C8.13 5 5 8.13 5 12c0 3.1 2 5.7 4.8 6.6.35.07.48-.15.48-.34v-1.2c-1.95.42-2.36-.94-2.36-.94-.32-.81-.78-1.03-.78-1.03-.64-.43.05-.42.05-.42.7.05 1.07.72 1.07.72.63 1.07 1.64.76 2.04.58.06-.45.24-.76.44-.94-1.56-.18-3.2-.78-3.2-3.46 0-.76.27-1.39.72-1.88-.07-.18-.31-.89.07-1.85 0 0 .59-.19 1.93.72a6.7 6.7 0 0 1 3.5 0c1.34-.91 1.93-.72 1.93-.72.38.96.14 1.67.07 1.85.45.49.72 1.12.72 1.88 0 2.69-1.64 3.28-3.2 3.45.25.22.48.65.48 1.3v1.93c0 .19.13.41.48.34C17 17.7 19 15.1 19 12c0-3.87-3.13-7-7-7z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'github',
|
||||
connectLabel: 'Connect GitHub',
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Send messages, read channels, search conversations, and manage workspaces.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] },
|
||||
color: '#4A154B',
|
||||
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#4A154B"/>
|
||||
<path d="M9.1 14.1a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 1.2-1.2h1.2v1.2zm.6 0a1.2 1.2 0 1 1 2.4 0v3a1.2 1.2 0 1 1-2.4 0v-3zm1.2-5a1.2 1.2 0 1 1 0-2.4 1.2 1.2 0 0 1 1.2 1.2v1.2H10.9zm0 .6a1.2 1.2 0 1 1 0 2.4h-3a1.2 1.2 0 1 1 0-2.4h3zm5 1.2a1.2 1.2 0 1 1 2.4 0 1.2 1.2 0 0 1-1.2 1.2h-1.2v-1.2zm-.6 0a1.2 1.2 0 1 1-2.4 0v-3a1.2 1.2 0 1 1 2.4 0v3zm-1.2 5a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1-1.2-1.2v-1.2h1.2zm0-.6a1.2 1.2 0 1 1 0-2.4h3a1.2 1.2 0 1 1 0 2.4h-3z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'slack',
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion',
|
||||
description: 'Read and write pages, search databases, and manage workspace content.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] },
|
||||
color: '#000000',
|
||||
website: 'https://github.com/makenotion/notion-mcp-server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#000"/>
|
||||
<path d="M7.5 6.5h5.8l3.2 3.6v7.4H7.5V6.5zm1.2 1.2v8.6h6.6V10.8l-2.6-3.1H8.7z" fill="#fff"/>
|
||||
<path d="M9.5 10h3M9.5 12h5M9.5 14h4" stroke="#fff" strokeWidth="0.7" fill="none"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'notion',
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'spotify',
|
||||
name: 'Spotify',
|
||||
description: 'Control playback, search music, manage playlists, and browse your library.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@tbrgeek/spotify-mcp-server'] },
|
||||
color: '#1DB954',
|
||||
website: 'https://github.com/tbrgeek/spotify-mcp-server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#1DB954"/>
|
||||
<path d="M16.5 10.5c-2.5-1.5-6.5-1.6-8.8-.9-.4.1-.8-.1-.9-.5s.1-.8.5-.9c2.7-.8 7.1-.7 9.9 1 .4.2.5.7.3 1-.2.4-.7.5-1 .3zm-.3 2.7c-.2.3-.6.4-.9.2-2.1-1.3-5.3-1.7-7.7-.9-.3.1-.7 0-.8-.4-.1-.3 0-.7.4-.8 2.8-.9 6.3-.4 8.7 1 .3.2.4.6.3.9zm-1 2.6c-.2.2-.5.3-.7.2-1.8-1.1-4.1-1.4-6.8-.7-.3.1-.5-.1-.6-.4s.1-.5.4-.6c3-.7 5.5-.4 7.5.8.3.2.3.5.2.7z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'spotify',
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'figma',
|
||||
name: 'Figma',
|
||||
description: 'Access design files, inspect components, and extract design data.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'figma-developer-mcp', '--stdio'] },
|
||||
color: '#F24E1E',
|
||||
website: 'https://github.com/anthropics/claude-code-mcp-server-figma',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#F24E1E"/>
|
||||
<path d="M10.5 6h3v3h-3zm0 3h-3v3h3zm0 3h3v3h-3zm3-3h3v3h-3zm-3 6h-3v1.5a1.5 1.5 0 0 0 3 0V18z" fill="#fff" fillOpacity="0.9"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'figma',
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'airtable',
|
||||
name: 'Airtable',
|
||||
description: 'Read and write records, manage bases, and search structured data.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'airtable-mcp-server'] },
|
||||
color: '#18BFFF',
|
||||
website: 'https://github.com/domdomegg/airtable-mcp-server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#18BFFF"/>
|
||||
<path d="M6 8h12v2H6zm0 3h5v5H6zm7 0h5v5h-5z" fill="#fff" fillOpacity="0.9"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'airtable',
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'hubspot',
|
||||
name: 'HubSpot',
|
||||
description: 'Manage contacts, deals, companies, and CRM data.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@hubspot/mcp-server'] },
|
||||
color: '#FF7A59',
|
||||
website: 'https://github.com/HubSpot/hubspot-mcp-server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#FF7A59"/>
|
||||
<circle cx="12" cy="10.5" r="2.5" fill="none" stroke="#fff" strokeWidth="1.2"/>
|
||||
<circle cx="16" cy="13.5" r="1.2" fill="#fff"/>
|
||||
<line x1="13.8" y1="11.8" x2="15" y2="13" stroke="#fff" strokeWidth="1"/>
|
||||
<path d="M12 13v2.5" stroke="#fff" strokeWidth="1.2"/>
|
||||
</svg>
|
||||
),
|
||||
authType: 'oauth2',
|
||||
oauthProvider: 'hubspot',
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
description: 'Manage servers, send messages, and interact with Discord communities.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'mcp-discord'] },
|
||||
color: '#5865F2',
|
||||
website: 'https://github.com/DiscordMCP/discord-mcp',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#5865F2"/>
|
||||
<path d="M15.5 8.5c-1-.5-2-.8-3.1-.9l-.2.4c1.1.2 2 .6 2.9 1.2a10 10 0 0 0-6.2 0c.9-.6 1.8-1 2.9-1.2l-.2-.4c-1.1.1-2.1.4-3.1.9-2 2.9-2.5 5.7-2.2 8.5 1.2.9 2.4 1.4 3.5 1.8.3-.4.5-.8.7-1.2-.4-.1-.7-.3-1-.5l.3-.2c2.2 1 4.6 1 6.8 0l.3.2c-.3.2-.7.4-1 .5.2.4.5.8.7 1.2 1.1-.4 2.3-.9 3.5-1.8.4-3.2-.6-6-2.6-8.5zM9.7 15c-.7 0-1.3-.7-1.3-1.5s.6-1.5 1.3-1.5 1.3.7 1.3 1.5-.6 1.5-1.3 1.5zm4.6 0c-.7 0-1.3-.7-1.3-1.5s.6-1.5 1.3-1.5 1.3.7 1.3 1.5-.6 1.5-1.3 1.5z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
connectLabel: 'Coming Soon',
|
||||
comingSoon: true,
|
||||
connectInstructions: 'Create a Discord bot at discord.com/developers → New Application → Bot, then copy the bot token.',
|
||||
credentialFields: [
|
||||
{ key: 'DISCORD_TOKEN', label: 'Bot Token', placeholder: 'Paste your Discord bot token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zoom',
|
||||
name: 'Zoom',
|
||||
description: 'Create, manage, and join Zoom meetings.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@prathamesh0901/zoom-mcp-server'] },
|
||||
color: '#2D8CFF',
|
||||
website: 'https://github.com/pras-ops/Zoom_MCP_Server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#2D8CFF"/>
|
||||
<path d="M7 9h6.5c.3 0 .5.2.5.5v5c0 .3-.2.5-.5.5H7c-.3 0-.5-.2-.5-.5v-5c0-.3.2-.5.5-.5zm8 1.5l2.5-1.5v6l-2.5-1.5v-3z" fill="#fff"/>
|
||||
</svg>
|
||||
),
|
||||
connectLabel: 'Connect Zoom',
|
||||
connectInstructions: 'Go to marketplace.zoom.us → Develop → Build App → Server-to-Server OAuth App. Activate it, then copy the Account ID, Client ID, and Client Secret.',
|
||||
credentialFields: [
|
||||
{ key: 'ZOOM_ACCOUNT_ID', label: 'Account ID', placeholder: 'Your Zoom Account ID' },
|
||||
{ key: 'ZOOM_CLIENT_ID', label: 'Client ID', placeholder: 'Your Zoom Client ID' },
|
||||
{ key: 'ZOOM_CLIENT_SECRET', label: 'Client Secret', placeholder: 'Your Zoom Client Secret' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'microsoft-365',
|
||||
name: 'Microsoft 365',
|
||||
description: 'Outlook email, calendar, OneDrive, contacts, Teams, and more. Sign in via the agent when first used.',
|
||||
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@softeria/ms-365-mcp-server'] },
|
||||
color: '#0078D4',
|
||||
website: 'https://github.com/softeria-eu/ms-365-mcp-server',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="11" fill="#0078D4"/>
|
||||
<path d="M6 7h5v5H6V7zm6.5 0H17v5h-4.5V7zM6 13h5v5H6v-5zm6.5 0H17v5h-4.5v-5z" fill="#fff" fillOpacity="0.9"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -379,8 +697,35 @@ const Tools: React.FC = () => {
|
||||
const outputItems = useAppSelector((s) => s.outputs.items);
|
||||
const outputs = useMemo(() => Object.values(outputItems), [outputItems]);
|
||||
const allTools = Object.values(items);
|
||||
const tools = allTools;
|
||||
const uninstalledIntegrations = useMemo(() => INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name)), [allTools]);
|
||||
const tools = useMemo(() => {
|
||||
return [...allTools].sort((a, b) => {
|
||||
const aIg = INTEGRATIONS.find(ig => ig.name === a.name);
|
||||
const bIg = INTEGRATIONS.find(ig => ig.name === b.name);
|
||||
const aComingSoon = aIg?.comingSoon ? 1 : 0;
|
||||
const bComingSoon = bIg?.comingSoon ? 1 : 0;
|
||||
// Coming Soon always at bottom
|
||||
if (aComingSoon !== bComingSoon) return aComingSoon - bComingSoon;
|
||||
const aPerms = Object.keys(a.tool_permissions || {}).filter(k => !k.startsWith('_')).length;
|
||||
const bPerms = Object.keys(b.tool_permissions || {}).filter(k => !k.startsWith('_')).length;
|
||||
const aConnected = a.auth_status === 'connected' ? 1 : 0;
|
||||
const bConnected = b.auth_status === 'connected' ? 1 : 0;
|
||||
const aEnabled = a.enabled !== false ? 1 : 0;
|
||||
const bEnabled = b.enabled !== false ? 1 : 0;
|
||||
const aScore = aEnabled * 4 + aConnected * 2 + (aPerms > 0 ? 1 : 0);
|
||||
const bScore = bEnabled * 4 + bConnected * 2 + (bPerms > 0 ? 1 : 0);
|
||||
if (bScore !== aScore) return bScore - aScore;
|
||||
return bPerms - aPerms;
|
||||
});
|
||||
}, [allTools]);
|
||||
const uninstalledIntegrations = useMemo(() => {
|
||||
const uninstalled = INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name));
|
||||
// Coming Soon goes to the bottom
|
||||
return uninstalled.sort((a, b) => {
|
||||
if (a.comingSoon && !b.comingSoon) return 1;
|
||||
if (!a.comingSoon && b.comingSoon) return -1;
|
||||
return 0;
|
||||
});
|
||||
}, [allTools]);
|
||||
const getIntegrationForTool = useCallback((tool: ToolDefinition) => INTEGRATIONS.find((ig) => ig.name === tool.name), []);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -465,6 +810,7 @@ const Tools: React.FC = () => {
|
||||
credentials: {},
|
||||
auth_type: integration.authType || 'none',
|
||||
auth_status: 'configured',
|
||||
...(integration.oauthProvider ? { oauth_provider: integration.oauthProvider } : {}),
|
||||
}));
|
||||
if (createTool.fulfilled.match(result)) {
|
||||
const newTool = result.payload;
|
||||
@@ -486,6 +832,31 @@ const Tools: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectConnect = async (integration: Integration) => {
|
||||
setIntegrationLoading((p) => ({ ...p, [integration.id]: true }));
|
||||
try {
|
||||
const result = await dispatch(createTool({
|
||||
name: integration.name,
|
||||
description: integration.description,
|
||||
command: '',
|
||||
mcp_config: integration.mcp_config,
|
||||
credentials: {},
|
||||
auth_type: integration.authType || 'none',
|
||||
auth_status: 'configured',
|
||||
...(integration.oauthProvider ? { oauth_provider: integration.oauthProvider } : {}),
|
||||
}));
|
||||
if (!createTool.fulfilled.match(result)) return;
|
||||
const newTool = result.payload;
|
||||
if (integration.authType === 'oauth2') {
|
||||
handleOAuthConnect(newTool.id);
|
||||
} else if (integration.credentialFields) {
|
||||
openCredentialsDialog(newTool.id, integration);
|
||||
}
|
||||
} finally {
|
||||
setIntegrationLoading((p) => ({ ...p, [integration.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async (toolId: string) => {
|
||||
setDiscovering(true);
|
||||
try {
|
||||
@@ -730,7 +1101,7 @@ const Tools: React.FC = () => {
|
||||
auth_type: 'oauth2',
|
||||
auth_status: 'configured',
|
||||
}));
|
||||
setSnackbar({ open: true, message: `Installed "${f.name}" — click "Connect Google" to authorize` });
|
||||
setSnackbar({ open: true, message: `Installed "${f.name}" — click "Connect" to authorize` });
|
||||
} else if (hasConfig && mcpConfig.type === 'stdio') {
|
||||
const result = await dispatch(createTool({
|
||||
name: f.name,
|
||||
@@ -773,11 +1144,12 @@ const Tools: React.FC = () => {
|
||||
const afterConnect = async () => {
|
||||
const statusResult = await dispatch(fetchToolStatus(toolId));
|
||||
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
|
||||
setSnackbar({ open: true, message: 'Google account connected! Discovering actions…' });
|
||||
const toolName = allTools.find(t => t.id === toolId)?.name || 'Account';
|
||||
setSnackbar({ open: true, message: `${toolName} connected! Discovering actions…` });
|
||||
setExpandedToolId(toolId);
|
||||
dispatch(discoverTools(toolId));
|
||||
} else {
|
||||
setSnackbar({ open: true, message: 'Google account connected!' });
|
||||
setSnackbar({ open: true, message: `${allTools.find(t => t.id === toolId)?.name || 'Account'} connected!` });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -797,7 +1169,8 @@ const Tools: React.FC = () => {
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setSnackbar({ open: true, message: 'OAuth failed — make sure GOOGLE_OAUTH_CLIENT_ID is set in backend .env', severity: 'error' });
|
||||
const errMsg = (result as any)?.payload?.detail || (result as any)?.error?.message || 'OAuth failed — check backend .env for required credentials';
|
||||
setSnackbar({ open: true, message: errMsg, severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -816,7 +1189,7 @@ const Tools: React.FC = () => {
|
||||
|
||||
const handleCredentialsSave = async () => {
|
||||
if (!credDialogToolId || !credDialogIntegration) return;
|
||||
const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim());
|
||||
const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !f.optional && !credDialogValues[f.key]?.trim());
|
||||
if (hasEmpty) return;
|
||||
|
||||
setCredDialogSaving(true);
|
||||
@@ -1201,16 +1574,34 @@ const Tools: React.FC = () => {
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{ig.description}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
{isLoading && <CircularProgress size={16} sx={{ color: ig.color }} />}
|
||||
<Switch
|
||||
checked={false}
|
||||
onChange={() => handleIntegrationToggle(ig)}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
|
||||
}}
|
||||
/>
|
||||
{ig.comingSoon ? (
|
||||
<Chip label="Coming Soon" size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', fontStyle: 'italic', height: 24 }} />
|
||||
) : (
|
||||
<>
|
||||
{(ig.authType === 'oauth2' || ig.credentialFields) && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={isLoading ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
|
||||
onClick={() => handleDirectConnect(ig)}
|
||||
disabled={isLoading}
|
||||
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0, mr: 0.5 }}
|
||||
>
|
||||
{ig.connectLabel || `Connect ${ig.name}`}
|
||||
</Button>
|
||||
)}
|
||||
{isLoading && !(ig.authType === 'oauth2' || ig.credentialFields) && <CircularProgress size={16} sx={{ color: ig.color }} />}
|
||||
<Switch
|
||||
checked={false}
|
||||
onChange={() => handleIntegrationToggle(ig)}
|
||||
disabled={isLoading}
|
||||
sx={{
|
||||
'& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
|
||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
@@ -1379,7 +1770,8 @@ const Tools: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const isDisabled = tool.enabled === false;
|
||||
const isComingSoon = ig?.comingSoon === true;
|
||||
const isDisabled = tool.enabled === false || isComingSoon;
|
||||
|
||||
return (
|
||||
<Card key={tool.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
|
||||
@@ -1417,7 +1809,10 @@ const Tools: React.FC = () => {
|
||||
</Box>
|
||||
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
|
||||
</Box>
|
||||
{!isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
|
||||
{isComingSoon && (
|
||||
<Chip label="Coming Soon" size="small" sx={{ bgcolor: `${ig?.color || c.text.ghost}15`, color: ig?.color || c.text.ghost, fontSize: '0.7rem', fontStyle: 'italic', height: 24, flexShrink: 0 }} />
|
||||
)}
|
||||
{!isComingSoon && !isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -1425,10 +1820,10 @@ const Tools: React.FC = () => {
|
||||
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
|
||||
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
|
||||
>
|
||||
Connect Google
|
||||
{ig?.connectLabel || `Connect ${ig?.name || 'Account'}`}
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
|
||||
{!isComingSoon && !isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@@ -1439,7 +1834,7 @@ const Tools: React.FC = () => {
|
||||
{ig.connectLabel || 'Connect'}
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && ig && tool.auth_status === 'connected' && (
|
||||
{!isComingSoon && !isDisabled && ig && tool.auth_status === 'connected' && (
|
||||
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' ? 'Disconnect' : ''}>
|
||||
<Chip
|
||||
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
|
||||
@@ -1451,7 +1846,7 @@ const Tools: React.FC = () => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ig && (
|
||||
{ig && !isComingSoon && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
|
||||
{!!integrationLoading[ig.id] && <CircularProgress size={16} sx={{ color: ig.color }} />}
|
||||
<Switch
|
||||
@@ -2058,7 +2453,7 @@ const Tools: React.FC = () => {
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCredentialsSave}
|
||||
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim())}
|
||||
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !f.optional && !credDialogValues[f.key]?.trim())}
|
||||
startIcon={credDialogSaving ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{ bgcolor: credDialogIntegration?.color || c.accent.primary, '&:hover': { bgcolor: credDialogIntegration?.color || c.accent.pressed, filter: 'brightness(0.9)' }, textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
|
||||
@@ -75,7 +75,7 @@ const initialState: SettingsState = {
|
||||
anthropic_api_key: null,
|
||||
browser_homepage: 'https://www.google.com',
|
||||
auto_select_mode_on_new_agent: false,
|
||||
expand_new_chats_in_dashboard: false,
|
||||
expand_new_chats_in_dashboard: true,
|
||||
auto_reveal_sub_agents: true,
|
||||
dev_mode: false,
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ToolDefinition {
|
||||
credentials: Record<string, string>;
|
||||
auth_type: string;
|
||||
auth_status: string;
|
||||
oauth_provider?: string;
|
||||
oauth_tokens: Record<string, any>;
|
||||
tool_permissions: Record<string, any>;
|
||||
connected_account_email?: string;
|
||||
@@ -92,7 +93,10 @@ export const startOAuth = createAsyncThunk(
|
||||
'tools/startOAuth',
|
||||
async (toolId: string) => {
|
||||
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/start`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to start OAuth');
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Failed to start OAuth');
|
||||
}
|
||||
const data = await res.json();
|
||||
return data as { auth_url: string };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user