From 0616b0ecbc2eee7de426df102a6a4fa6f2dd75b6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 29 Mar 2026 16:33:36 -0700 Subject: [PATCH] [eric] v1.0.20: add common MCP integrations, mcp OAuth, one-click connect UX, onboarding tools step, ui/ux improvements --- backend/apps/agents/agent_manager.py | 15 + backend/apps/analytics/analytics.py | 2 +- backend/apps/settings/models.py | 2 +- backend/apps/tools_lib/models.py | 3 + backend/apps/tools_lib/tools_lib.py | 538 +- electron/package-lock.json | 4 +- electron/package.json | 2 +- .../src/app/components/OnboardingModal.tsx | 159 +- .../src/app/pages/Dashboard/AgentCard.tsx | 61 +- .../src/app/pages/Dashboard/Dashboard.tsx | 116 +- .../app/pages/Dashboard/useCanvasControls.ts | 10 +- frontend/src/app/pages/Tools/Tools.tsx | 475 +- frontend/src/shared/state/settingsSlice.ts | 2 +- frontend/src/shared/state/toolsSlice.ts | 6 +- frontend/tsconfig.tsbuildinfo | 2 +- .../skills/remotion-best-practices/SKILL.md | 61 + .../remotion-best-practices/rules/3d.md | 86 + .../rules/animations.md | 27 + .../remotion-best-practices/rules/assets.md | 78 + .../rules/assets/charts-bar-chart.tsx | 173 + .../assets/text-animations-typewriter.tsx | 100 + .../assets/text-animations-word-highlight.tsx | 103 + .../rules/audio-visualization.md | 198 + .../remotion-best-practices/rules/audio.md | 169 + .../rules/calculate-metadata.md | 134 + .../rules/can-decode.md | 81 + .../remotion-best-practices/rules/charts.md | 120 + .../rules/compositions.md | 154 + .../rules/display-captions.md | 184 + .../rules/extract-frames.md | 229 + .../remotion-best-practices/rules/ffmpeg.md | 38 + .../remotion-best-practices/rules/fonts.md | 152 + .../rules/get-audio-duration.md | 58 + .../rules/get-video-dimensions.md | 68 + .../rules/get-video-duration.md | 60 + .../remotion-best-practices/rules/gifs.md | 141 + .../remotion-best-practices/rules/images.md | 134 + .../rules/import-srt-captions.md | 69 + .../rules/light-leaks.md | 73 + .../remotion-best-practices/rules/lottie.md | 70 + .../remotion-best-practices/rules/maps.md | 412 ++ .../rules/measuring-dom-nodes.md | 34 + .../rules/measuring-text.md | 140 + .../rules/parameters.md | 109 + .../rules/sequencing.md | 118 + .../remotion-best-practices/rules/sfx.md | 30 + .../rules/subtitles.md | 36 + .../remotion-best-practices/rules/tailwind.md | 11 + .../rules/text-animations.md | 20 + .../remotion-best-practices/rules/timing.md | 179 + .../rules/transcribe-captions.md | 70 + .../rules/transitions.md | 197 + .../rules/transparent-videos.md | 106 + .../remotion-best-practices/rules/trimming.md | 51 + .../remotion-best-practices/rules/videos.md | 171 + .../rules/voiceover.md | 99 + .../.claude/skills/remotion-best-practices | 1 + .../.cursor/skills/remotion-best-practices | 1 + openswarm-demo1/.gitignore | 7 + .../.opencode/skills/remotion-best-practices | 1 + openswarm-demo1/.prettierrc | 5 + openswarm-demo1/README.md | 54 + openswarm-demo1/eslint.config.mjs | 3 + openswarm-demo1/package-lock.json | 4899 +++++++++++++++++ openswarm-demo1/package.json | 34 + openswarm-demo1/public/icon.png | Bin 0 -> 327756 bytes openswarm-demo1/public/screenshot.png | Bin 0 -> 65294 bytes openswarm-demo1/remotion.config.ts | 13 + openswarm-demo1/src/Composition.tsx | 65 + openswarm-demo1/src/Root.tsx | 22 + openswarm-demo1/src/index.css | 1 + openswarm-demo1/src/index.ts | 4 + openswarm-demo1/src/scenes/CtaScene.tsx | 148 + openswarm-demo1/src/scenes/DashboardScene.tsx | 135 + openswarm-demo1/src/scenes/FeaturesScene.tsx | 167 + openswarm-demo1/src/scenes/HookScene.tsx | 117 + openswarm-demo1/src/scenes/IntroScene.tsx | 118 + openswarm-demo1/tsconfig.json | 15 + 78 files changed, 11565 insertions(+), 185 deletions(-) create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/SKILL.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/3d.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/animations.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/assets.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/assets/charts-bar-chart.tsx create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/assets/text-animations-typewriter.tsx create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/assets/text-animations-word-highlight.tsx create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/audio-visualization.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/audio.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/calculate-metadata.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/can-decode.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/charts.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/compositions.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/display-captions.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/extract-frames.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/ffmpeg.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/fonts.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/get-audio-duration.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/get-video-dimensions.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/get-video-duration.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/gifs.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/images.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/import-srt-captions.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/light-leaks.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/lottie.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/maps.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/measuring-dom-nodes.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/measuring-text.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/parameters.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/sequencing.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/sfx.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/subtitles.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/tailwind.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/text-animations.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/timing.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/transcribe-captions.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/transitions.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/transparent-videos.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/trimming.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/videos.md create mode 100644 openswarm-demo1/.agents/skills/remotion-best-practices/rules/voiceover.md create mode 120000 openswarm-demo1/.claude/skills/remotion-best-practices create mode 120000 openswarm-demo1/.cursor/skills/remotion-best-practices create mode 100644 openswarm-demo1/.gitignore create mode 120000 openswarm-demo1/.opencode/skills/remotion-best-practices create mode 100644 openswarm-demo1/.prettierrc create mode 100644 openswarm-demo1/README.md create mode 100644 openswarm-demo1/eslint.config.mjs create mode 100644 openswarm-demo1/package-lock.json create mode 100644 openswarm-demo1/package.json create mode 100644 openswarm-demo1/public/icon.png create mode 100644 openswarm-demo1/public/screenshot.png create mode 100644 openswarm-demo1/remotion.config.ts create mode 100644 openswarm-demo1/src/Composition.tsx create mode 100644 openswarm-demo1/src/Root.tsx create mode 100644 openswarm-demo1/src/index.css create mode 100644 openswarm-demo1/src/index.ts create mode 100644 openswarm-demo1/src/scenes/CtaScene.tsx create mode 100644 openswarm-demo1/src/scenes/DashboardScene.tsx create mode 100644 openswarm-demo1/src/scenes/FeaturesScene.tsx create mode 100644 openswarm-demo1/src/scenes/HookScene.tsx create mode 100644 openswarm-demo1/src/scenes/IntroScene.tsx create mode 100644 openswarm-demo1/tsconfig.json diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 85a85580..dceffc58 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -207,6 +207,21 @@ class AgentManager: sections.append("\n".join(lines)) + # Add awareness of tools that are installed but not yet connected + not_connected = [ + t for t in all_tools + if t.mcp_config and t.enabled + and t.auth_type in ("oauth2", "env_vars") + and t.auth_status != "connected" + ] + if not_connected: + nc_lines = [ + "Tools installed but not yet connected (user needs to authorize in Settings → Tools):" + ] + for t in not_connected: + nc_lines.append(f" - {t.name}") + sections.append("\n".join(nc_lines)) + if not sections: return None return ( diff --git a/backend/apps/analytics/analytics.py b/backend/apps/analytics/analytics.py index bb6fa489..8859e715 100644 --- a/backend/apps/analytics/analytics.py +++ b/backend/apps/analytics/analytics.py @@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as logger = logging.getLogger(__name__) -APP_VERSION = "1.0.19" +APP_VERSION = "1.0.20" _heartbeat_task: asyncio.Task | None = None diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 130d06eb..2b4c53e2 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -38,7 +38,7 @@ class AppSettings(BaseModel): custom_providers: list["CustomProvider"] = Field(default_factory=list) # Dashboard / UI preferences auto_select_mode_on_new_agent: bool = False - expand_new_chats_in_dashboard: bool = False + expand_new_chats_in_dashboard: bool = True auto_reveal_sub_agents: bool = True dev_mode: bool = False # Subscription tokens (from CLI tools — alternative to API keys) diff --git a/backend/apps/tools_lib/models.py b/backend/apps/tools_lib/models.py index 857ccc3a..010aeee6 100644 --- a/backend/apps/tools_lib/models.py +++ b/backend/apps/tools_lib/models.py @@ -65,6 +65,7 @@ class ToolDefinition(BaseModel): credentials: dict[str, str] = Field(default_factory=dict) auth_type: str = "none" auth_status: str = "none" + oauth_provider: Optional[str] = None oauth_tokens: dict[str, Any] = Field(default_factory=dict) tool_permissions: dict[str, Any] = Field(default_factory=dict) connected_account_email: Optional[str] = None @@ -79,6 +80,7 @@ class ToolCreate(BaseModel): credentials: dict[str, str] = Field(default_factory=dict) auth_type: str = "none" auth_status: str = "none" + oauth_provider: Optional[str] = None class ToolUpdate(BaseModel): @@ -89,6 +91,7 @@ class ToolUpdate(BaseModel): credentials: Optional[dict[str, str]] = None auth_type: Optional[str] = None auth_status: Optional[str] = None + oauth_provider: Optional[str] = None oauth_tokens: Optional[dict[str, Any]] = None tool_permissions: Optional[dict[str, Any]] = None connected_account_email: Optional[str] = None diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index c27bb2eb..316908cc 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -1,11 +1,15 @@ import asyncio +import base64 +import hashlib import json import os import re import logging +import secrets import shutil import time from contextlib import asynccontextmanager +from dataclasses import dataclass, field from typing import Any, Optional from urllib.parse import urlencode @@ -26,6 +30,26 @@ _DEFAULT_GOOGLE_CLIENT_SECRET = "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" os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID) os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET) +# Default GitHub OAuth credentials for the OpenSwarm project. +os.environ.setdefault("GITHUB_OAUTH_CLIENT_ID", "Ov23liDcwNJaKMjXY2jI") +os.environ.setdefault("GITHUB_OAUTH_CLIENT_SECRET", "b25fe39409896aad3fd5155f032e9868440002f8") + +# Default Slack OAuth credentials (requires HTTPS redirect — not yet functional) +os.environ.setdefault("SLACK_CLIENT_ID", "10795695056323.10799999254534") +os.environ.setdefault("SLACK_CLIENT_SECRET", "d3a85a286bb0205157d7e4963502a91d") + +# Default Figma OAuth credentials for the OpenSwarm project. +os.environ.setdefault("FIGMA_CLIENT_ID", "q6WduT7UuPaO6lM88v6ddN") +os.environ.setdefault("FIGMA_CLIENT_SECRET", "dhNZdbEuyEWC15cKLwWpqTclyOSplD") + +# Default Airtable OAuth credentials for the OpenSwarm project. +os.environ.setdefault("AIRTABLE_CLIENT_ID", "0699038b-a3a4-46b2-8fa6-690eb76fadfa") +os.environ.setdefault("AIRTABLE_CLIENT_SECRET", "187fa83c8bab8ebcd11b8f226d75e7a1f14a8174ac0494463c1a53e66a3036d0") + +# Default HubSpot MCP Auth App credentials for the OpenSwarm project. +os.environ.setdefault("HUBSPOT_CLIENT_ID", "6f4a1d4c-6a2f-4336-9b65-2cd84e218ff6") +os.environ.setdefault("HUBSPOT_CLIENT_SECRET", "5747b5de-0800-4c35-a2da-e0655ee7ea37") + from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH load_dotenv(os.path.join(BACKEND_DIR, ".env")) @@ -53,7 +77,179 @@ GOOGLE_SCOPES = [ "https://www.googleapis.com/auth/contacts.readonly", ] + +# --------------------------------------------------------------------------- +# Multi-provider OAuth registry +# --------------------------------------------------------------------------- + +@dataclass +class OAuthProvider: + auth_url: str + token_url: str + scopes: list[str] + userinfo_url: str | None + userinfo_field: str # JSON field for display name/email + client_id_env: str + client_secret_env: str + token_env_mapping: dict[str, str] # oauth_tokens key -> MCP env var name + extra_auth_params: dict[str, str] = field(default_factory=dict) + revoke_url: str | None = None + # For providers where token response nests the access_token differently + token_response_path: str | None = None # e.g. "authed_user.access_token" for Slack + # Token exchange auth method: "form" (default), "basic" (Basic Auth header), "basic_json" (Basic Auth + JSON body) + token_auth_method: str = "form" + # Whether PKCE is required + pkce_required: bool = False + # Custom transform for env var value (e.g., wrapping token in JSON for Notion) + env_value_transform: str | None = None # e.g., "notion_headers" + # Extra token response fields to extract (e.g., Slack team_id) + extra_token_fields: dict[str, str] = field(default_factory=dict) # response_path -> env_var + + +OAUTH_PROVIDERS: dict[str, OAuthProvider] = { + "google": OAuthProvider( + auth_url=GOOGLE_AUTH_URL, + token_url=GOOGLE_TOKEN_URL, + scopes=GOOGLE_SCOPES, + userinfo_url=GOOGLE_USERINFO_URL, + userinfo_field="email", + client_id_env="GOOGLE_OAUTH_CLIENT_ID", + client_secret_env="GOOGLE_OAUTH_CLIENT_SECRET", + token_env_mapping={ + "access_token": "OAUTH_ACCESS_TOKEN", + "refresh_token": "GOOGLE_WORKSPACE_REFRESH_TOKEN", + "_client_id": "GOOGLE_WORKSPACE_CLIENT_ID", + "_client_secret": "GOOGLE_WORKSPACE_CLIENT_SECRET", + }, + extra_auth_params={"access_type": "offline", "prompt": "consent"}, + ), + "github": OAuthProvider( + auth_url="https://github.com/login/oauth/authorize", + token_url="https://github.com/login/oauth/access_token", + scopes=["repo", "read:user", "user:email"], + userinfo_url="https://api.github.com/user", + userinfo_field="login", + client_id_env="GITHUB_OAUTH_CLIENT_ID", + client_secret_env="GITHUB_OAUTH_CLIENT_SECRET", + token_env_mapping={ + "access_token": "GITHUB_PERSONAL_ACCESS_TOKEN", + }, + ), + "slack": OAuthProvider( + auth_url="https://slack.com/oauth/v2/authorize", + token_url="https://slack.com/api/oauth.v2.access", + scopes=[ + "channels:read", "channels:history", "chat:write", + "groups:read", "groups:history", "im:read", "im:history", + "mpim:read", "mpim:history", "users:read", "users:read.email", + "team:read", "reactions:read", "reactions:write", + "files:read", "files:write", + ], + userinfo_url="https://slack.com/api/auth.test", + userinfo_field="user", + client_id_env="SLACK_CLIENT_ID", + client_secret_env="SLACK_CLIENT_SECRET", + token_env_mapping={ + "access_token": "SLACK_BOT_TOKEN", + }, + extra_token_fields={"team.id": "SLACK_TEAM_ID"}, + ), + "notion": OAuthProvider( + auth_url="https://api.notion.com/v1/oauth/authorize", + token_url="https://api.notion.com/v1/oauth/token", + scopes=[], # Notion doesn't use scopes in the auth URL + userinfo_url=None, + userinfo_field="owner", + client_id_env="NOTION_OAUTH_CLIENT_ID", + client_secret_env="NOTION_OAUTH_CLIENT_SECRET", + token_env_mapping={ + "access_token": "OPENAPI_MCP_HEADERS", + }, + extra_auth_params={"owner": "user"}, + token_auth_method="basic_json", + env_value_transform="notion_headers", + ), + "spotify": OAuthProvider( + auth_url="https://accounts.spotify.com/authorize", + token_url="https://accounts.spotify.com/api/token", + scopes=[ + "user-read-playback-state", "user-modify-playback-state", + "user-read-currently-playing", "playlist-read-private", + "playlist-modify-public", "playlist-modify-private", + "user-library-read", "user-library-modify", + "user-read-recently-played", "user-top-read", + ], + userinfo_url="https://api.spotify.com/v1/me", + userinfo_field="display_name", + client_id_env="SPOTIFY_CLIENT_ID", + client_secret_env="SPOTIFY_CLIENT_SECRET", + token_env_mapping={ + "access_token": "SPOTIFY_ACCESS_TOKEN", + "refresh_token": "SPOTIFY_REFRESH_TOKEN", + "_client_id": "SPOTIFY_CLIENT_ID", + "_client_secret": "SPOTIFY_CLIENT_SECRET", + }, + token_auth_method="basic", + ), + "figma": OAuthProvider( + auth_url="https://www.figma.com/oauth", + token_url="https://api.figma.com/v1/oauth/token", + scopes=["current_user:read", "file_content:read", "file_metadata:read", "file_comments:read", "file_comments:write", "file_versions:read", "file_variables:read"], + userinfo_url="https://api.figma.com/v1/me", + userinfo_field="email", + client_id_env="FIGMA_CLIENT_ID", + client_secret_env="FIGMA_CLIENT_SECRET", + token_env_mapping={ + "access_token": "FIGMA_API_KEY", + }, + ), + "airtable": OAuthProvider( + auth_url="https://airtable.com/oauth2/v1/authorize", + token_url="https://airtable.com/oauth2/v1/token", + scopes=[ + "data.records:read", "data.records:write", + "data.recordComments:read", "data.recordComments:write", + "schema.bases:read", "schema.bases:write", + "user.email:read", "webhook:manage", + ], + userinfo_url="https://api.airtable.com/v0/meta/whoami", + userinfo_field="email", + client_id_env="AIRTABLE_CLIENT_ID", + client_secret_env="AIRTABLE_CLIENT_SECRET", + token_env_mapping={ + "access_token": "AIRTABLE_API_KEY", + }, + pkce_required=True, + token_auth_method="basic", + ), + "hubspot": OAuthProvider( + auth_url="https://mcp-na2.hubspot.com/oauth/authorize/user", + token_url="https://api.hubapi.com/oauth/v1/token", + scopes=[], # MCP Auth Apps have preconfigured scopes + userinfo_url=None, # HubSpot userinfo requires token-in-path, handle separately + userinfo_field="user", + client_id_env="HUBSPOT_CLIENT_ID", + client_secret_env="HUBSPOT_CLIENT_SECRET", + token_env_mapping={ + "access_token": "PRIVATE_APP_ACCESS_TOKEN", + "refresh_token": "HUBSPOT_REFRESH_TOKEN", + }, + pkce_required=True, + ), +} + + +def _resolve_oauth_provider(tool: ToolDefinition) -> OAuthProvider: + """Resolve the OAuth provider for a tool, defaulting to Google for backward compat.""" + key = tool.oauth_provider or "google" + provider = OAUTH_PROVIDERS.get(key) + if not provider: + raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {key}") + return provider + + _pending_oauth: dict[str, str] = {} +_pending_pkce: dict[str, str] = {} # state -> code_verifier (for PKCE flows) def _load_all() -> list[ToolDefinition]: @@ -122,49 +318,108 @@ async def list_tools(): @tools_lib.router.get("/oauth/callback") async def oauth_callback(code: str = Query(...), state: str = Query("")): + # Backward compat: old state was just tool_id, new state is "provider:tool_id" tool_id = _pending_oauth.pop(state, None) + if not tool_id: + # Try legacy format (state = tool_id directly) + tool_id = _pending_oauth.pop(state.split(":")[-1] if ":" in state else state, None) if not tool_id: return HTMLResponse("

Invalid OAuth state

", status_code=400) tool = _load(tool_id) - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") + provider = _resolve_oauth_provider(tool) + + client_id = os.environ.get(provider.client_id_env, "") + client_secret = os.environ.get(provider.client_secret_env, "") _port = os.environ.get("OPENSWARM_PORT", "8324") redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" + # Build token exchange request based on provider's auth method + token_data: dict[str, str] = { + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + } + headers: dict[str, str] = {} + + if provider.token_auth_method == "basic": + # Spotify, etc: Basic Auth header, credentials in form body + creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + headers["Authorization"] = f"Basic {creds}" + elif provider.token_auth_method == "basic_json": + # Notion: Basic Auth header, JSON body + creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + headers["Authorization"] = f"Basic {creds}" + headers["Content-Type"] = "application/json" + else: + # Default: credentials as form data fields + token_data["client_id"] = client_id + token_data["client_secret"] = client_secret + + # GitHub requires Accept: application/json + if (tool.oauth_provider or "google") == "github": + headers["Accept"] = "application/json" + + # PKCE: include code_verifier if we stored one + code_verifier = _pending_pkce.pop(state, None) + if code_verifier: + token_data["code_verifier"] = code_verifier + async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(GOOGLE_TOKEN_URL, data={ - "code": code, - "client_id": client_id, - "client_secret": client_secret, - "redirect_uri": redirect_uri, - "grant_type": "authorization_code", - }) + if provider.token_auth_method == "basic_json": + resp = await client.post(provider.token_url, json=token_data, headers=headers) + else: + resp = await client.post(provider.token_url, data=token_data, headers=headers) if resp.status_code != 200: logger.warning(f"OAuth token exchange failed: {resp.text}") return HTMLResponse(f"

Token exchange failed

{resp.text}
", status_code=400) tokens = resp.json() + + # Extract access_token, handling nested responses (e.g., Slack) access_token = tokens.get("access_token", "") + if provider.token_response_path and not access_token: + # Walk nested path like "authed_user.access_token" + obj = tokens + for part in provider.token_response_path.split("."): + obj = obj.get(part, {}) if isinstance(obj, dict) else "" + if isinstance(obj, str) and obj: + access_token = obj + tool.oauth_tokens = { "access_token": access_token, "refresh_token": tokens.get("refresh_token", ""), "token_expiry": time.time() + tokens.get("expires_in", 3600), } + + # Extract extra fields (e.g., Slack team_id) + for response_path, env_var in provider.extra_token_fields.items(): + obj: Any = tokens + for part in response_path.split("."): + obj = obj.get(part, "") if isinstance(obj, dict) else "" + if obj: + tool.oauth_tokens[env_var] = str(obj) + tool.auth_status = "connected" - if access_token: + if access_token and provider.userinfo_url: try: async with httpx.AsyncClient(timeout=10.0) as info_client: info_resp = await info_client.get( - GOOGLE_USERINFO_URL, + provider.userinfo_url, headers={"Authorization": f"Bearer {access_token}"}, ) if info_resp.status_code == 200: - tool.connected_account_email = info_resp.json().get("email") + tool.connected_account_email = info_resp.json().get(provider.userinfo_field) except Exception as e: - logger.warning(f"Failed to fetch Google userinfo: {e}") + logger.warning(f"Failed to fetch userinfo for {tool.oauth_provider or 'google'}: {e}") + + # Notion: extract workspace name from token response + if (tool.oauth_provider or "google") == "notion" and not tool.connected_account_email: + workspace_name = tokens.get("workspace_name") + if workspace_name: + tool.connected_account_email = workspace_name _save(tool) @@ -195,84 +450,17 @@ async def create_tool(body: ToolCreate): credentials=body.credentials, auth_type=body.auth_type, auth_status=body.auth_status, + oauth_provider=body.oauth_provider, ) _save(tool) return {"ok": True, "tool": tool.model_dump()} -_XBIRD_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "xbird") -_XBIRD_CONFIG_PATH = os.path.join(_XBIRD_CONFIG_DIR, "config.json") - - -async def _fetch_twitter_screen_name(auth_token: str, ct0: str) -> str | None: - """Fetch the logged-in Twitter/X screen name using session cookies.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get( - "https://api.twitter.com/1.1/account/verify_credentials.json", - headers={"x-csrf-token": ct0}, - cookies={"auth_token": auth_token, "ct0": ct0}, - ) - if resp.status_code == 200: - screen_name = resp.json().get("screen_name") - return f"@{screen_name}" if screen_name else None - except Exception as e: - logger.warning("Failed to fetch Twitter screen name: %s", e) - return None - - -def _sync_external_config(tool: ToolDefinition): - """Write credentials to external config files for tools that need them. - - xbird reads auth from ~/.config/xbird/config.json rather than env vars, - so we sync credentials there when the user connects via the UI. - """ - if tool.name == "xbird" and tool.credentials: - auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "") - ct0 = tool.credentials.get("TWITTER_CT0", "") - if auth_token and ct0: - os.makedirs(_XBIRD_CONFIG_DIR, exist_ok=True) - config = {} - if os.path.exists(_XBIRD_CONFIG_PATH): - try: - with open(_XBIRD_CONFIG_PATH) as f: - config = json.load(f) - except Exception: - pass - config["auth_token"] = auth_token - config["ct0"] = ct0 - with open(_XBIRD_CONFIG_PATH, "w") as f: - json.dump(config, f, indent=2) - os.chmod(_XBIRD_CONFIG_PATH, 0o600) - logger.info("Synced xbird credentials to %s", _XBIRD_CONFIG_PATH) - elif tool.name == "xbird" and not tool.credentials: - if os.path.exists(_XBIRD_CONFIG_PATH): - try: - with open(_XBIRD_CONFIG_PATH) as f: - config = json.load(f) - config.pop("auth_token", None) - config.pop("ct0", None) - with open(_XBIRD_CONFIG_PATH, "w") as f: - json.dump(config, f, indent=2) - logger.info("Cleared xbird credentials from %s", _XBIRD_CONFIG_PATH) - except Exception: - pass - - @tools_lib.router.put("/{tool_id}") async def update_tool(tool_id: str, body: ToolUpdate): tool = _load(tool_id) for k, v in body.model_dump(exclude_none=True).items(): setattr(tool, k, v) - _sync_external_config(tool) - - if tool.name == "xbird" and tool.auth_status == "connected" and tool.credentials: - auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "") - ct0 = tool.credentials.get("TWITTER_CT0", "") - if auth_token and ct0: - screen_name = await _fetch_twitter_screen_name(auth_token, ct0) - if screen_name: - tool.connected_account_email = screen_name _save(tool) return {"ok": True, "tool": tool.model_dump()} @@ -390,15 +578,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: headers["Authorization"] = f"Bearer {tool.oauth_tokens['access_token']}" else: env = config.setdefault("env", {}) - env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"] - if tool.oauth_tokens.get("refresh_token"): - env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"] - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") - if client_id: - env["GOOGLE_WORKSPACE_CLIENT_ID"] = client_id - if client_secret: - env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret + provider_key = tool.oauth_provider or "google" + provider = OAUTH_PROVIDERS.get(provider_key) + if provider: + for token_field, env_var in provider.token_env_mapping.items(): + if token_field.startswith("_client_id"): + val = os.environ.get(provider.client_id_env, "") + elif token_field.startswith("_client_secret"): + val = os.environ.get(provider.client_secret_env, "") + else: + val = tool.oauth_tokens.get(token_field, "") + if val: + # Apply value transforms (e.g., Notion needs JSON headers) + if provider.env_value_transform == "notion_headers" and token_field == "access_token": + val = json.dumps({ + "Authorization": f"Bearer {val}", + "Notion-Version": "2022-06-28", + }) + env[env_var] = val + # Inject extra token fields (e.g., Slack SLACK_TEAM_ID) + for _, env_var in provider.extra_token_fields.items(): + val = tool.oauth_tokens.get(env_var, "") + if val: + env[env_var] = val + # Figma: inject token as CLI arg (it doesn't read env vars) + if provider_key == "figma" and tool.oauth_tokens.get("access_token"): + args = config.get("args", []) + if "--figma-api-key" not in args: + config["args"] = args + ["--figma-api-key", tool.oauth_tokens["access_token"]] + else: + # Fallback: inject generic access token + env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"] if config.get("type") == "stdio": if config.get("command"): @@ -455,22 +665,86 @@ _SERVICE_RULES: list[tuple[list[str], str, str]] = [ (["contact"], "Contacts", "Google"), (["script", "deployment", "version", "trigger"], "Apps Script", "Google"), (["search_custom", "search_engine"], "Search", "Google"), - # Reddit (before Twitter so "search_reddit" etc. don't mis-match) + # Reddit (["subreddit"], "Subreddits", "Reddit"), (["search_reddit"], "Search", "Reddit"), (["post_detail"], "Posts", "Reddit"), (["user_analysis"], "Users", "Reddit"), (["reddit_explain"], "Reference", "Reddit"), - # Twitter / X - (["tweet", "thread", "reply", "replies", "quote", "retweet", "article"], "Tweets", "Twitter"), - (["timeline", "home", "news", "trending"], "Timeline", "Twitter"), - (["follower", "following", "follow", "unfollow"], "Network", "Twitter"), - (["like", "unlike", "bookmark"], "Engagement", "Twitter"), - (["mention"], "Mentions", "Twitter"), - (["user", "profile"], "Users", "Twitter"), - (["media", "upload", "image", "video"], "Media", "Twitter"), - (["search"], "Search", "Twitter"), - (["list", "list_member"], "Lists", "Twitter"), + # Sequential Thinking + (["sequentialthinking", "thinking"], "Thinking", "Sequential Thinking"), + # Memory (knowledge graph) + (["create_entities", "create_relations", "add_observations", "delete_entities", + "delete_observations", "delete_relations", "read_graph", "search_nodes", + "open_nodes"], "Knowledge Graph", "Memory"), + # Filesystem + (["read_file", "read_multiple_files", "write_file", "edit_file", + "create_directory", "list_directory", "directory_tree", "move_file", + "search_files", "get_file_info", "list_allowed_directories"], "Files", "Filesystem"), + # Playwright + (["browser_navigate", "browser_screenshot", "browser_click", "browser_fill", + "browser_select", "browser_hover", "browser_evaluate", "browser_console", + "browser_tab", "browser_close", "browser_resize", "browser_snapshot", + "browser_wait", "browser_pdf", "browser_drag"], "Browser", "Playwright"), + # Git + (["git_status", "git_diff", "git_diff_unstaged", "git_diff_staged", + "git_commit", "git_log", "git_add", "git_reset", "git_show", + "git_create_branch", "git_checkout", "git_list_branches", "git_init", + "git_clone"], "Repository", "Git"), + # YouTube Transcripts + (["get_transcript"], "Transcripts", "YouTube"), + # Desktop Commander + (["execute_command", "read_output", "force_terminate", "list_sessions", + "list_processes", "kill_process", "block_command", "unblock_command", + "read_file", "write_file", "search_code", "list_directory", + "get_file_info", "edit_block"], "System", "Desktop Commander"), + # GitHub + (["repository", "issue", "pull_request", "commit", "branch", "fork", "star", + "create_issue", "list_issues", "get_issue", "create_pull_request", + "list_commits", "search_repositories", "create_repository", + "get_file_contents", "push_files", "create_branch", + "search_code", "search_issues"], "Repository", "GitHub"), + # Slack + (["channel", "slack_message", "thread", "reply", "workspace", + "list_channels", "post_message", "reply_to_thread", "search_messages", + "get_channel_history", "get_thread_replies", "get_users", + "get_user_profile"], "Messaging", "Slack"), + # Notion + (["notion_page", "database", "block", "create_page", "update_page", + "search_pages", "get_page", "get_database", "query_database", + "create_database", "append_block_children"], "Pages", "Notion"), + # Spotify + (["play", "pause", "skip", "playlist", "track", "album", "artist", + "search_tracks", "get_playlist", "get_currently_playing", + "add_to_playlist", "create_playlist", "get_recommendations", + "get_top_items"], "Music", "Spotify"), + # Figma + (["figma", "design", "component", "style", "node", + "get_file", "get_file_nodes", "get_image", "get_comments", + "get_team_projects", "get_project_files"], "Design", "Figma"), + # Airtable + (["airtable", "base", "record", "field", "view", + "list_records", "get_record", "create_record", "update_record", + "delete_record", "list_bases", "get_base_schema"], "Data", "Airtable"), + # HubSpot + (["hubspot", "contact", "deal", "company", "ticket", "pipeline", + "crm", "engagement", "association"], "CRM", "HubSpot"), + # Discord + (["discord", "guild", "server", "channel_message", "send_message", + "get_messages", "get_guilds", "get_channels", "add_reaction"], "Messaging", "Discord"), + # Twitter / X (TweetSave) + (["tweetsave", "get_tweet", "get_thread", "to_blog", "batch", + "extract_media"], "Tweets", "Twitter"), + # Shopify Dev + (["shopify", "introspect", "graphql", "search_dev_docs", "liquid", + "polaris", "admin_api", "storefront_api"], "Developer", "Shopify"), + # Zoom + (["zoom", "meeting", "recording", "participant", "webinar", + "create_meeting", "list_meetings", "get_meeting", "delete_meeting", + "update_meeting"], "Meetings", "Zoom"), + # Microsoft 365 + (["outlook", "onedrive", "ms365", "microsoft", "mail_folder", + "email", "calendar_event", "contact", "drive_item"], "Mail & Files", "Microsoft 365"), ] @@ -597,6 +871,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=proc_env, + limit=1024 * 1024, # 1MB buffer for large MCP responses (e.g., MS365 with 87 tools) ) async def _send(msg: dict) -> None: @@ -765,15 +1040,17 @@ async def oauth_disconnect(tool_id: str): access_token = tool.oauth_tokens.get("access_token") if access_token: + provider = _resolve_oauth_provider(tool) + revoke_url = provider.revoke_url or "https://oauth2.googleapis.com/revoke" try: async with httpx.AsyncClient(timeout=10.0) as client: await client.post( - "https://oauth2.googleapis.com/revoke", + revoke_url, params={"token": access_token}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) except Exception as e: - logger.warning(f"Failed to revoke Google token for tool {tool.id}: {e}") + logger.warning(f"Failed to revoke token for tool {tool.id}: {e}") tool.oauth_tokens = {} tool.auth_status = "configured" @@ -784,14 +1061,17 @@ async def oauth_disconnect(tool_id: str): @tools_lib.router.post("/{tool_id}/oauth/start") async def oauth_start(tool_id: str): - _load(tool_id) - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") + tool = _load(tool_id) + provider = _resolve_oauth_provider(tool) + + client_id = os.environ.get(provider.client_id_env, "") if not client_id: - raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env") + raise HTTPException(status_code=400, detail=f"{provider.client_id_env} not set in backend .env") _port = os.environ.get("OPENSWARM_PORT", "8324") redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" - state = tool_id + provider_key = tool.oauth_provider or "google" + state = f"{provider_key}:{tool_id}" _pending_oauth[state] = tool_id @@ -799,19 +1079,28 @@ async def oauth_start(tool_id: str): "client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", - "scope": " ".join(GOOGLE_SCOPES), - "access_type": "offline", - "prompt": "consent", "state": state, + **provider.extra_auth_params, } - auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}" + if provider.scopes: + params["scope"] = " ".join(provider.scopes) + + # PKCE support (required by Airtable, etc.) + if provider.pkce_required: + code_verifier = secrets.token_urlsafe(64) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).rstrip(b"=").decode() + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + _pending_pkce[state] = code_verifier + + auth_url = f"{provider.auth_url}?{urlencode(params)}" return {"auth_url": auth_url} - - -async def refresh_google_token(tool: ToolDefinition) -> Optional[str]: - """Refresh an expired Google OAuth token. Returns the fresh access_token or None.""" +async def refresh_oauth_token(tool: ToolDefinition) -> Optional[str]: + """Refresh an expired OAuth token. Returns the fresh access_token or None.""" if tool.auth_type != "oauth2": return None refresh_token = tool.oauth_tokens.get("refresh_token") @@ -821,14 +1110,15 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]: if time.time() < expiry - 60: return tool.oauth_tokens.get("access_token") - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") + provider = _resolve_oauth_provider(tool) + client_id = os.environ.get(provider.client_id_env, "") + client_secret = os.environ.get(provider.client_secret_env, "") if not client_id or not client_secret: return None try: async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(GOOGLE_TOKEN_URL, data={ + resp = await client.post(provider.token_url, data={ "client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token, @@ -840,20 +1130,24 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]: tool.oauth_tokens["access_token"] = new_token tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600) - if not tool.connected_account_email: + if not tool.connected_account_email and provider.userinfo_url: try: async with httpx.AsyncClient(timeout=10.0) as info_client: info_resp = await info_client.get( - GOOGLE_USERINFO_URL, + provider.userinfo_url, headers={"Authorization": f"Bearer {new_token}"}, ) if info_resp.status_code == 200: - tool.connected_account_email = info_resp.json().get("email") + tool.connected_account_email = info_resp.json().get(provider.userinfo_field) except Exception: pass _save(tool) return new_token except Exception as e: - logger.warning(f"Google token refresh failed for tool {tool.id}: {e}") + logger.warning(f"OAuth token refresh failed for tool {tool.id}: {e}") return None + + +# Backward-compatible alias +refresh_google_token = refresh_oauth_token diff --git a/electron/package-lock.json b/electron/package-lock.json index 9309677e..ffbdda44 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.18", + "version": "1.0.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.18", + "version": "1.0.20", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.3.0", diff --git a/electron/package.json b/electron/package.json index c9cfbd91..dad514fc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.0.19", + "version": "1.0.20", "description": "OpenSwarm — AI Agent Orchestrator", "main": "main.js", "scripts": { diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index 749ce9c5..baac74b7 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -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(null); const [nineRouterReady, setNineRouterReady] = useState(null); + const [connectedTools, setConnectedTools] = useState>(new Set()); const pollTimerRef = useRef(null); const msgHandlerRef = useRef(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' ? ( + <> + + Connect Your Accounts + + + 10+ tools already active with no setup needed + + + Connect services below for even more capabilities + + + + {ONBOARDING_TOOL_INTEGRATIONS.map((ig) => { + const isConnected = connectedTools.has(ig.name); + const isConnecting = connecting === ig.name; + return ( + !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` } }), + }} + > + + {ig.name} + {ig.desc} + + {isConnected ? ( + + ) : ( + + {isConnecting ? 'Connecting...' : 'Connect \u2192'} + + )} + + ); + })} + + + + + ) : ( + <> Welcome to OpenSwarm @@ -268,6 +415,8 @@ const OnboardingModal: React.FC = () => { > Skip for now + + )} ); diff --git a/frontend/src/app/pages/Dashboard/AgentCard.tsx b/frontend/src/app/pages/Dashboard/AgentCard.tsx index dc009af9..43e1616f 100644 --- a/frontend/src/app/pages/Dashboard/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/AgentCard.tsx @@ -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 = ({ 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 = ({ } : { 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 ( + + {/* Header with close button */} + { e.stopPropagation(); onFocusExit?.(); }} + sx={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + mb: 1, flexShrink: 0, cursor: 'default', + }} + > + + + {session.name || 'Agent'} + + + {session.model} · {formatDuration(session.created_at, undefined, session.status)} + + onFocusExit?.()} sx={{ color: c.text.ghost }}> + + + + {/* Chat fills remaining space */} + + + + + ); + } + return ( = ({ 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 = ({ )} - {/* 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 }) => ( = ({ 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 = ({ px: 2, pt: 2, pb: 1.5, - cursor: isDragging ? 'grabbing' : 'grab', + cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none', flexShrink: 0, diff --git a/frontend/src/app/pages/Dashboard/Dashboard.tsx b/frontend/src/app/pages/Dashboard/Dashboard.tsx index d20b6b97..b4f16d21 100644 --- a/frontend/src/app/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/app/pages/Dashboard/Dashboard.tsx @@ -117,6 +117,7 @@ const DashboardInner: React.FC = () => { const toolbarRef = useRef(null); const [toolbarOpen, setToolbarOpen] = useState(false); + const [focusedCardId, setFocusedCardId] = useState(null); const [highlightedCardId, setHighlightedCardId] = useState(null); const highlightTimerRef = useRef | null>(null); const [autoFocusSessionId, setAutoFocusSessionId] = useState(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([]); + 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 = () => { {/* Floating zoom controls */} - - - + {!focusedCardId && ( + + + + )} + + {/* 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 ( + <> + + + {}} + onMeasuredHeight={() => {}} + onBringToFront={() => {}} + isFocused={true} + onFocusRequest={handleFocusRequest} + onFocusExit={handleFocusExit} + autoFocusInput={true} + /> + + + ); + })()} ); diff --git a/frontend/src/app/pages/Dashboard/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/useCanvasControls.ts index 71ebf214..1aa83b33 100644 --- a/frontend/src/app/pages/Dashboard/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/useCanvasControls.ts @@ -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, diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 272b7688..301b49a7 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -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[] = [ ), 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: ( + + + + + ), + }, + { + 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: ( + + + + + ), + }, + { + 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: ( + + + + + ), + }, + { + 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: ( + + + + + ), + }, + { + 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: ( + + + C7 + + ), + }, + { + 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: ( + + + + + ), + }, + { + 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: ( + + + + + + + + ), + }, + { + 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: ( + + + + + ), + }, + { + 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: ( + + + 𝕏 + + ), + }, + { + 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: ( + + + + + ), + }, + // --- 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: ( + + + + + ), + 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: ( + + + + + ), + 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: ( + + + + + + ), + 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: ( + + + + + ), + 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: ( + + + + + ), + 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: ( + + + + + ), + 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: ( + + + + + + + + ), + 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: ( + + + + + ), + 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: ( + + + + + ), + 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: ( + + + + + ), }, ]; @@ -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 = () => { {ig.description} - {isLoading && } - handleIntegrationToggle(ig)} - disabled={isLoading} - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color }, - '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color }, - }} - /> + {ig.comingSoon ? ( + + ) : ( + <> + {(ig.authType === 'oauth2' || ig.credentialFields) && ( + + )} + {isLoading && !(ig.authType === 'oauth2' || ig.credentialFields) && } + handleIntegrationToggle(ig)} + disabled={isLoading} + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { color: ig.color }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color }, + }} + /> + + )} @@ -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 ( @@ -1417,7 +1809,10 @@ const Tools: React.FC = () => { {tool.description && {tool.description}} - {!isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && ( + {isComingSoon && ( + + )} + {!isComingSoon && !isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && ( )} - {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( + {!isComingSoon && !isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( )} - {!isDisabled && ig && tool.auth_status === 'connected' && ( + {!isComingSoon && !isDisabled && ig && tool.auth_status === 'connected' && ( } @@ -1451,7 +1846,7 @@ const Tools: React.FC = () => { /> )} - {ig && ( + {ig && !isComingSoon && ( e.stopPropagation()}> {!!integrationLoading[ig.id] && } {