mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
add composio tools
This commit is contained in:
parent
8038d52495
commit
078f785a9e
27 changed files with 2514 additions and 140 deletions
|
|
@ -0,0 +1,283 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Info, RefreshCw, Search } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { listToolkits, listTools, updateComposioSelectedTools } from '@/app/actions/composio_actions';
|
||||
import { getProjectConfig } from '@/app/actions/project_actions';
|
||||
import { z } from 'zod';
|
||||
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
import { ComposioToolsPanel } from './ComposioToolsPanel';
|
||||
import { ToolkitCard } from './ToolkitCard';
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
export function Composio() {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
||||
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
|
||||
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
|
||||
const [savingTools, setSavingTools] = useState(false);
|
||||
|
||||
const loadProjectConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await getProjectConfig(projectId);
|
||||
setProjectConfig(config);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching project config:', err);
|
||||
setError('Unable to load project configuration.');
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadAllToolkits = useCallback(async () => {
|
||||
let cursor: string | null = null;
|
||||
let allToolkits: ToolkitType[] = [];
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
do {
|
||||
const response: ToolkitListResponse = await listToolkits(projectId, cursor);
|
||||
allToolkits = [...allToolkits, ...response.items];
|
||||
cursor = response.next_cursor;
|
||||
} while (cursor !== null);
|
||||
|
||||
// // Only show those toolkits that
|
||||
// // - either do not require authentication, OR
|
||||
// // - have oauth2 managed by Composio
|
||||
// const filteredToolkits = allToolkits.filter(toolkit => {
|
||||
// const noAuth = toolkit.no_auth;
|
||||
// const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2');
|
||||
// const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2');
|
||||
// return noAuth || hasOAuth2;
|
||||
// });
|
||||
|
||||
setToolkits(allToolkits);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError('Unable to load all Composio toolkits. Please check your connection and try again.');
|
||||
console.error('Error fetching all toolkits:', err);
|
||||
setToolkits([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleManageTools = useCallback((toolkit: ToolkitType) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseToolsPanel = useCallback(() => {
|
||||
setSelectedToolkit(null);
|
||||
setIsToolsPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleProjectConfigUpdate = useCallback(() => {
|
||||
loadProjectConfig();
|
||||
}, [loadProjectConfig]);
|
||||
|
||||
const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer<typeof ZTool>[]) => {
|
||||
if (!projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
// Get existing selected tools from project config
|
||||
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
|
||||
|
||||
// Create a map of existing tools by slug for easy lookup
|
||||
const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool]));
|
||||
|
||||
// Add or update the new selections
|
||||
for (const tool of selectedToolObjects) {
|
||||
existingToolsMap.set(tool.slug, tool);
|
||||
}
|
||||
|
||||
// Convert back to array
|
||||
const mergedSelectedTools = Array.from(existingToolsMap.values());
|
||||
|
||||
await updateComposioSelectedTools(projectId, mergedSelectedTools);
|
||||
|
||||
// Refresh project config to get updated data
|
||||
await loadProjectConfig();
|
||||
} catch (error) {
|
||||
console.error('Error saving tool selection:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
}, [projectId, projectConfig, loadProjectConfig]);
|
||||
|
||||
const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => {
|
||||
if (!projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
// Get existing selected tools from project config
|
||||
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
|
||||
|
||||
// Filter out all tools from the specified toolkit
|
||||
const filteredSelectedTools = existingSelectedTools.filter(tool =>
|
||||
tool.toolkit.slug !== toolkitSlug
|
||||
);
|
||||
|
||||
await updateComposioSelectedTools(projectId, filteredSelectedTools);
|
||||
|
||||
// Refresh project config to get updated data
|
||||
await loadProjectConfig();
|
||||
} catch (error) {
|
||||
console.error('Error removing toolkit tools:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
}, [projectId, projectConfig, loadProjectConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectConfig();
|
||||
}, [loadProjectConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllToolkits();
|
||||
}, [loadAllToolkits]);
|
||||
|
||||
const filteredToolkits = toolkits.filter(toolkit => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
toolkit.name.toLowerCase().includes(searchLower) ||
|
||||
toolkit.meta.description.toLowerCase().includes(searchLower) ||
|
||||
toolkit.slug.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading Composio toolkits...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] space-y-6 px-4">
|
||||
<p className="text-center text-red-500 dark:text-red-400 max-w-[600px]">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
loadProjectConfig();
|
||||
loadAllToolkits();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search toolkits..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
loadProjectConfig();
|
||||
loadAllToolkits();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<RefreshCw className={clsx("h-4 w-4", loading && "animate-spin")} />
|
||||
<span className="ml-2">Refresh</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredToolkits.map((toolkit) => {
|
||||
const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
|
||||
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
|
||||
|
||||
return (
|
||||
<ToolkitCard
|
||||
key={toolkit.slug}
|
||||
toolkit={toolkit}
|
||||
projectId={projectId}
|
||||
isConnected={isConnected}
|
||||
connectedAccountId={connectedAccountId}
|
||||
projectConfig={projectConfig}
|
||||
onManageTools={() => handleManageTools(toolkit)}
|
||||
onProjectConfigUpdate={handleProjectConfigUpdate}
|
||||
onRemoveToolkitTools={handleRemoveToolkitTools}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredToolkits.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tools Panel */}
|
||||
<ComposioToolsPanel
|
||||
toolkit={selectedToolkit}
|
||||
isOpen={isToolsPanelOpen}
|
||||
onClose={handleCloseToolsPanel}
|
||||
projectConfig={projectConfig}
|
||||
onUpdateToolsSelection={handleUpdateToolsSelection}
|
||||
isSaving={savingTools}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import { Checkbox } from '@heroui/react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { listTools } from '@/app/actions/composio_actions';
|
||||
import { z } from 'zod';
|
||||
import { ZTool, ZListResponse } from '@/app/lib/composio/composio';
|
||||
import { SlidePanel } from '@/components/ui/slide-panel';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
|
||||
type ToolType = z.infer<typeof ZTool>;
|
||||
type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
interface ComposioToolsPanelProps {
|
||||
toolkit: {
|
||||
slug: string;
|
||||
name: string;
|
||||
meta: {
|
||||
logo: string;
|
||||
};
|
||||
no_auth?: boolean;
|
||||
} | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectConfig: ProjectType | null;
|
||||
onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function ComposioToolsPanel({
|
||||
toolkit,
|
||||
isOpen,
|
||||
onClose,
|
||||
projectConfig,
|
||||
onUpdateToolsSelection,
|
||||
isSaving
|
||||
}: ComposioToolsPanelProps) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
const [tools, setTools] = useState<ToolType[]>([]);
|
||||
const [toolsLoading, setToolsLoading] = useState(false);
|
||||
const [currentCursor, setCurrentCursor] = useState<string | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => {
|
||||
try {
|
||||
setToolsLoading(true);
|
||||
|
||||
const response: ToolListResponse = await listTools(projectId, toolkitSlug, cursor);
|
||||
|
||||
setTools(response.items);
|
||||
setNextCursor(response.next_cursor);
|
||||
|
||||
if (cursor === null) {
|
||||
// First page - reset pagination state
|
||||
setCurrentCursor(null);
|
||||
setCursorHistory([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching tools:', err);
|
||||
setTools([]);
|
||||
} finally {
|
||||
setToolsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleNextPage = useCallback(async () => {
|
||||
if (!nextCursor || !toolkit) return;
|
||||
|
||||
// Add current cursor to history
|
||||
setCursorHistory(prev => [...prev, currentCursor || '']);
|
||||
setCurrentCursor(nextCursor);
|
||||
|
||||
await loadToolsForToolkit(toolkit.slug, nextCursor);
|
||||
}, [nextCursor, toolkit, currentCursor, loadToolsForToolkit]);
|
||||
|
||||
const handlePreviousPage = useCallback(async () => {
|
||||
if (cursorHistory.length === 0 || !toolkit) return;
|
||||
|
||||
// Get the previous cursor from history
|
||||
const previousCursor = cursorHistory[cursorHistory.length - 1];
|
||||
const newHistory = cursorHistory.slice(0, -1);
|
||||
|
||||
setCursorHistory(newHistory);
|
||||
setCurrentCursor(previousCursor);
|
||||
|
||||
await loadToolsForToolkit(toolkit.slug, previousCursor);
|
||||
}, [cursorHistory, toolkit, loadToolsForToolkit]);
|
||||
|
||||
const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => {
|
||||
setSelectedTools(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(toolSlug);
|
||||
} else {
|
||||
next.delete(toolSlug);
|
||||
}
|
||||
setHasChanges(true);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSaveTools = useCallback(async () => {
|
||||
// Convert selected tool slugs to actual tool objects
|
||||
const selectedToolObjects = tools.filter(tool => selectedTools.has(tool.slug));
|
||||
await onUpdateToolsSelection(selectedToolObjects);
|
||||
setHasChanges(false);
|
||||
}, [onUpdateToolsSelection, selectedTools, tools]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setTools([]);
|
||||
setSelectedTools(new Set());
|
||||
setHasChanges(false);
|
||||
if (hasChanges) {
|
||||
if (window.confirm('You have unsaved changes. Are you sure you want to close?')) {
|
||||
onClose();
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose, hasChanges]);
|
||||
|
||||
// Initialize selected tools from project config when opening the panel
|
||||
useEffect(() => {
|
||||
if (toolkit && isOpen && projectConfig?.composioSelectedTools) {
|
||||
const toolSlugs = new Set(projectConfig.composioSelectedTools.map(tool => tool.slug));
|
||||
setSelectedTools(toolSlugs);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [toolkit, isOpen, projectConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolkit && isOpen) {
|
||||
loadToolsForToolkit(toolkit.slug, null);
|
||||
}
|
||||
}, [toolkit, isOpen, loadToolsForToolkit]);
|
||||
|
||||
if (!toolkit) return null;
|
||||
|
||||
// Check if the toolkit is connected (has an active connected account) or doesn't require auth
|
||||
const isToolkitConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{toolkit.meta.logo && (
|
||||
<PictureImg
|
||||
src={toolkit.meta.logo}
|
||||
alt={`${toolkit.name} logo`}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-md object-cover"
|
||||
/>
|
||||
)}
|
||||
<span>{toolkit.name}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isToolkitConnected && !toolkit.no_auth && (
|
||||
<div className="text-sm text-orange-600 dark:text-orange-400 px-3 py-1 rounded-full bg-orange-50 dark:bg-orange-900/20">
|
||||
Toolkit not connected
|
||||
</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveTools}
|
||||
disabled={isSaving || !isToolkitConnected}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-b-transparent border-white mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Tools List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{toolsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tools.map((tool) => (
|
||||
<div key={tool.slug} className={`group p-4 rounded-lg transition-all duration-200 border border-transparent ${
|
||||
isToolkitConnected
|
||||
? 'bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600'
|
||||
: 'bg-gray-100/50 dark:bg-gray-900/50 opacity-60'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
isSelected={selectedTools.has(tool.slug)}
|
||||
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
|
||||
size="sm"
|
||||
isDisabled={!isToolkitConnected}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Pagination Controls */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={cursorHistory.length === 0 || toolsLoading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!nextCursor || toolsLoading}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Spinner, Button as HeroButton, Input } from "@heroui/react";
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import { Wrench, Shield, Key, Globe, ArrowLeft } from "lucide-react";
|
||||
import { getToolkit, createComposioManagedOauth2ConnectedAccount, syncConnectedAccount, listToolkits, createCustomConnectedAccount } from '@/app/actions/composio_actions';
|
||||
import { z } from 'zod';
|
||||
import { ZGetToolkitResponse, ZToolkit, ZComposioField, ZAuthScheme } from '@/app/lib/composio/composio';
|
||||
|
||||
interface ToolkitAuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
toolkitSlug: string;
|
||||
projectId: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function ToolkitAuthModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
toolkitSlug,
|
||||
projectId,
|
||||
onComplete
|
||||
}: ToolkitAuthModalProps) {
|
||||
const [toolkit, setToolkit] = useState<z.infer<typeof ZGetToolkitResponse> | null>(null);
|
||||
const [toolkitDetails, setToolkitDetails] = useState<z.infer<typeof ZToolkit> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedAuthScheme, setSelectedAuthScheme] = useState<z.infer<typeof ZAuthScheme> | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
|
||||
// Fetch toolkit details when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && toolkitSlug) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch both toolkit auth details and full toolkit info
|
||||
Promise.all([
|
||||
getToolkit(projectId, toolkitSlug),
|
||||
listToolkits(projectId).then(response =>
|
||||
response.items.find(t => t.slug === toolkitSlug) || null
|
||||
)
|
||||
])
|
||||
.then(([authDetails, fullDetails]) => {
|
||||
setToolkit(authDetails);
|
||||
setToolkitDetails(fullDetails);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to fetch toolkit:', err);
|
||||
setError('Failed to load toolkit details');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isOpen, toolkitSlug, projectId]);
|
||||
|
||||
// Reset form state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setShowForm(false);
|
||||
setSelectedAuthScheme(null);
|
||||
setFormData({});
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOAuthCompletion = useCallback(async (connectedAccountId: string) => {
|
||||
try {
|
||||
// Sync the connected account to get the latest status
|
||||
await syncConnectedAccount(projectId, toolkitSlug, connectedAccountId);
|
||||
|
||||
// Call completion callback
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error syncing connected account after OAuth:', error);
|
||||
setError('Authentication completed but failed to sync status. Please refresh and try again.');
|
||||
}
|
||||
}, [projectId, toolkitSlug, onComplete, onClose]);
|
||||
|
||||
const handleComposioOAuth2 = useCallback(async () => {
|
||||
setError(null);
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
// Start OAuth flow
|
||||
const returnUrl = `${window.location.origin}/composio/oauth2/callback`;
|
||||
const response = await createComposioManagedOauth2ConnectedAccount(projectId, toolkitSlug, returnUrl);
|
||||
console.log('OAuth response:', JSON.stringify(response, null, 2));
|
||||
|
||||
// if error, set error
|
||||
if ('error' in response) {
|
||||
if (response.error === 'CUSTOM_OAUTH2_CONFIG_REQUIRED') {
|
||||
setError('Please set up a custom OAuth2 configuration for this toolkit in the Composio dashboard');
|
||||
} else {
|
||||
setError('Failed to connect to toolkit');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Open OAuth window
|
||||
const authWindow = window.open(
|
||||
response.connectionData.val.redirectUrl as string,
|
||||
'_blank',
|
||||
'width=600,height=700'
|
||||
);
|
||||
|
||||
if (authWindow) {
|
||||
// Use postMessage since we control the callback URL
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Only accept messages from our own origin
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an OAuth completion message
|
||||
if (event.data && event.data.type === 'OAUTH_COMPLETE') {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
clearInterval(checkInterval);
|
||||
|
||||
if (event.data.success) {
|
||||
// Handle successful OAuth completion
|
||||
handleOAuthCompletion(response.id);
|
||||
} else {
|
||||
// Handle OAuth error
|
||||
const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for postMessage from our callback page
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
// Minimal fallback: check if window closes without message
|
||||
const checkInterval = setInterval(() => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(checkInterval);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
|
||||
// If we didn't get a postMessage, still try to sync
|
||||
// (in case the message was missed for some reason)
|
||||
handleOAuthCompletion(response.id);
|
||||
}
|
||||
}, 1000); // Check less frequently since we expect postMessage
|
||||
} else {
|
||||
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
|
||||
setError('Failed to open authentication window');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('OAuth flow failed:', err);
|
||||
const errorMessage = err.message || 'Failed to connect to toolkit';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [projectId, toolkitSlug, handleOAuthCompletion]);
|
||||
|
||||
const handleCustomAuth = useCallback((authScheme: z.infer<typeof ZAuthScheme>) => {
|
||||
setSelectedAuthScheme(authScheme);
|
||||
|
||||
// Initialize form data with default values
|
||||
const authConfig = toolkit?.auth_config_details?.find(config => config.mode === authScheme);
|
||||
|
||||
if (authConfig) {
|
||||
const initialData: Record<string, string> = {};
|
||||
|
||||
// Try connected_account_initiation first, fallback to auth_config_creation
|
||||
const requiredFields = authConfig.fields.connected_account_initiation.required.length > 0
|
||||
? authConfig.fields.connected_account_initiation.required
|
||||
: authConfig.fields.auth_config_creation.required;
|
||||
|
||||
const optionalFields = authConfig.fields.connected_account_initiation.optional.length > 0
|
||||
? authConfig.fields.connected_account_initiation.optional
|
||||
: authConfig.fields.auth_config_creation.optional;
|
||||
|
||||
// Add defaults for required fields
|
||||
requiredFields.forEach(field => {
|
||||
if (field.default) {
|
||||
initialData[field.name] = field.default;
|
||||
}
|
||||
});
|
||||
|
||||
// Add defaults for optional fields
|
||||
optionalFields.forEach(field => {
|
||||
if (field.default) {
|
||||
initialData[field.name] = field.default;
|
||||
}
|
||||
});
|
||||
|
||||
setFormData(initialData);
|
||||
}
|
||||
|
||||
setShowForm(true);
|
||||
}, [toolkit]);
|
||||
|
||||
const handleFormSubmit = useCallback(async () => {
|
||||
if (!selectedAuthScheme || !toolkit) return;
|
||||
|
||||
setError(null);
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
const callbackUrl = `${window.location.origin}/composio/oauth2/callback`;
|
||||
const response = await createCustomConnectedAccount(projectId, {
|
||||
toolkitSlug: toolkit.slug,
|
||||
authConfig: {
|
||||
authScheme: selectedAuthScheme,
|
||||
credentials: formData,
|
||||
},
|
||||
callbackUrl,
|
||||
});
|
||||
|
||||
console.log('Custom auth response:', JSON.stringify(response, null, 2));
|
||||
|
||||
// Check if we need to open a popup window (OAuth2 flow)
|
||||
if ('connectionData' in response &&
|
||||
response.connectionData.val &&
|
||||
'redirectUrl' in response.connectionData.val &&
|
||||
response.connectionData.val.redirectUrl) {
|
||||
|
||||
// Open OAuth window for custom OAuth2
|
||||
const authWindow = window.open(
|
||||
response.connectionData.val.redirectUrl as string,
|
||||
'_blank',
|
||||
'width=600,height=700'
|
||||
);
|
||||
|
||||
if (authWindow) {
|
||||
// Use the same postMessage logic as Composio OAuth2
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'OAUTH_COMPLETE') {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
clearInterval(checkInterval);
|
||||
|
||||
if (event.data.success) {
|
||||
handleOAuthCompletion(response.id);
|
||||
} else {
|
||||
const errorMessage = event.data.errorDescription || event.data.error || 'OAuth authentication failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(checkInterval);
|
||||
window.removeEventListener('message', handleMessage);
|
||||
handleOAuthCompletion(response.id);
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
|
||||
setError('Failed to open authentication window');
|
||||
}
|
||||
} else {
|
||||
// No redirect needed, just sync and complete
|
||||
await syncConnectedAccount(projectId, toolkitSlug, response.id);
|
||||
onComplete();
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Custom auth failed:', err);
|
||||
const errorMessage = err.message || 'Failed to authenticate with toolkit';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [selectedAuthScheme, toolkit, projectId, formData, handleOAuthCompletion, onComplete, onClose, toolkitSlug]);
|
||||
|
||||
const handleBackToOptions = useCallback(() => {
|
||||
setShowForm(false);
|
||||
setSelectedAuthScheme(null);
|
||||
setFormData({});
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const getAuthMethodIcon = (authScheme: string) => {
|
||||
switch (authScheme) {
|
||||
case 'OAUTH2':
|
||||
return <Shield className="h-5 w-5" />;
|
||||
case 'API_KEY':
|
||||
return <Key className="h-5 w-5" />;
|
||||
case 'BEARER_TOKEN':
|
||||
return <Key className="h-5 w-5" />;
|
||||
default:
|
||||
return <Globe className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthMethodName = (authScheme: string) => {
|
||||
switch (authScheme) {
|
||||
case 'OAUTH2':
|
||||
return 'OAuth2';
|
||||
case 'API_KEY':
|
||||
return 'API Key';
|
||||
case 'BEARER_TOKEN':
|
||||
return 'Bearer Token';
|
||||
case 'BASIC':
|
||||
return 'Basic Auth';
|
||||
default:
|
||||
return authScheme.toLowerCase().replace('_', ' ');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onClose}
|
||||
size="md"
|
||||
classNames={{
|
||||
base: "bg-white dark:bg-gray-900",
|
||||
header: "border-b border-gray-200 dark:border-gray-800",
|
||||
footer: "border-t border-gray-200 dark:border-gray-800",
|
||||
}}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader className="flex gap-3 items-center">
|
||||
{showForm && (
|
||||
<HeroButton
|
||||
variant="light"
|
||||
size="sm"
|
||||
isIconOnly
|
||||
onPress={handleBackToOptions}
|
||||
className="mr-1"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</HeroButton>
|
||||
)}
|
||||
{toolkitDetails?.meta?.logo ? (
|
||||
<PictureImg
|
||||
src={toolkitDetails.meta.logo}
|
||||
alt={`${toolkitSlug} logo`}
|
||||
className="w-8 h-8 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="w-5 h-5 text-blue-500" />
|
||||
)}
|
||||
<span>
|
||||
{showForm
|
||||
? `Configure ${getAuthMethodName(selectedAuthScheme || '')}`
|
||||
: `Connect to ${toolkitSlug}`
|
||||
}
|
||||
</span>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
) : toolkit ? (
|
||||
showForm ? (
|
||||
// Form view
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Enter your credentials for {getAuthMethodName(selectedAuthScheme || '')} authentication:
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const authConfig = toolkit.auth_config_details?.find(config => config.mode === selectedAuthScheme);
|
||||
|
||||
if (!authConfig) {
|
||||
return <div>No configuration found for {selectedAuthScheme}</div>;
|
||||
}
|
||||
|
||||
// Try connected_account_initiation first, fallback to auth_config_creation
|
||||
const allFields = [
|
||||
...authConfig.fields.connected_account_initiation.required.map(field => ({ ...field, required: true })),
|
||||
...authConfig.fields.connected_account_initiation.optional.map(field => ({ ...field, required: false }))
|
||||
];
|
||||
|
||||
// If no fields in connected_account_initiation, try auth_config_creation
|
||||
if (allFields.length === 0) {
|
||||
allFields.push(
|
||||
...authConfig.fields.auth_config_creation.required.map(field => ({ ...field, required: true })),
|
||||
...authConfig.fields.auth_config_creation.optional.map(field => ({ ...field, required: false }))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{allFields.map(field => (
|
||||
<Input
|
||||
key={field.name}
|
||||
label={field.displayName}
|
||||
placeholder={field.description}
|
||||
value={formData[field.name] || ''}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, [field.name]: value }))}
|
||||
isRequired={field.required}
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
variant="bordered"
|
||||
description={field.description}
|
||||
required={field.required}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// Auth options view
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Choose how you'd like to authenticate with this toolkit:
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* OAuth2 Composio Managed */}
|
||||
{toolkit.composio_managed_auth_schemes.includes('OAUTH2') && (
|
||||
<HeroButton
|
||||
className="w-full justify-start gap-3 h-auto py-4 px-4"
|
||||
variant="bordered"
|
||||
onPress={handleComposioOAuth2}
|
||||
isDisabled={processing}
|
||||
size="lg"
|
||||
>
|
||||
<div className="bg-green-100 dark:bg-green-900/20 p-2 rounded-lg">
|
||||
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Connect using OAuth2</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Secure authentication managed by Composio
|
||||
</div>
|
||||
</div>
|
||||
{processing && <Spinner size="sm" className="ml-auto" />}
|
||||
</HeroButton>
|
||||
)}
|
||||
|
||||
{/* Custom OAuth2 - always show if OAuth2 is supported */}
|
||||
{(toolkit.composio_managed_auth_schemes.includes('OAUTH2') ||
|
||||
toolkit.auth_config_details?.some(config => config.mode === 'OAUTH2')) && (
|
||||
<HeroButton
|
||||
className="w-full justify-start gap-3 h-auto py-4 px-4"
|
||||
variant="bordered"
|
||||
onPress={() => handleCustomAuth('OAUTH2')}
|
||||
isDisabled={processing}
|
||||
size="lg"
|
||||
>
|
||||
<div className="bg-orange-100 dark:bg-orange-900/20 p-2 rounded-lg">
|
||||
<Shield className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Connect using custom OAuth2 app</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Use your own OAuth2 configuration
|
||||
</div>
|
||||
</div>
|
||||
</HeroButton>
|
||||
)}
|
||||
|
||||
{/* Other auth schemes (excluding OAuth2 since it's shown above) */}
|
||||
{toolkit.auth_config_details?.filter(config => config.mode !== 'OAUTH2').map(config => (
|
||||
<HeroButton
|
||||
key={config.mode}
|
||||
className="w-full justify-start gap-3 h-auto py-4 px-4"
|
||||
variant="bordered"
|
||||
onPress={() => handleCustomAuth(config.mode)}
|
||||
isDisabled={processing}
|
||||
size="lg"
|
||||
>
|
||||
<div className="bg-blue-100 dark:bg-blue-900/20 p-2 rounded-lg">
|
||||
{getAuthMethodIcon(config.mode)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Connect using {getAuthMethodName(config.mode)}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Enter your credentials
|
||||
</div>
|
||||
</div>
|
||||
</HeroButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{showForm ? (
|
||||
<>
|
||||
<HeroButton variant="bordered" onPress={handleBackToOptions} isDisabled={processing}>
|
||||
Back
|
||||
</HeroButton>
|
||||
<HeroButton
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={handleFormSubmit}
|
||||
isDisabled={processing}
|
||||
isLoading={processing}
|
||||
>
|
||||
{processing ? 'Connecting...' : 'Connect'}
|
||||
</HeroButton>
|
||||
</>
|
||||
) : (
|
||||
<HeroButton variant="bordered" onPress={onClose}>
|
||||
Cancel
|
||||
</HeroButton>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { Spinner } from '@heroui/react';
|
||||
import { deleteConnectedAccount } from '@/app/actions/composio_actions';
|
||||
import { z } from 'zod';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
import { ToolkitAuthModal } from './ToolkitAuthModal';
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
const toolkitCardStyles = {
|
||||
base: clsx(
|
||||
"group p-6 rounded-xl transition-all duration-200",
|
||||
"bg-white dark:bg-gray-900 shadow-sm dark:shadow-none",
|
||||
"border-2 border-gray-200/80 dark:border-gray-700/80",
|
||||
"hover:shadow-md dark:hover:shadow-none",
|
||||
"hover:border-blue-200 dark:hover:border-blue-900",
|
||||
"min-h-[280px] flex flex-col"
|
||||
),
|
||||
};
|
||||
|
||||
interface ToolkitCardProps {
|
||||
toolkit: ToolkitType;
|
||||
projectId: string;
|
||||
isConnected: boolean;
|
||||
connectedAccountId?: string;
|
||||
projectConfig: ProjectType | null;
|
||||
onManageTools: () => void;
|
||||
onProjectConfigUpdate: () => void;
|
||||
onRemoveToolkitTools: (toolkitSlug: string) => void;
|
||||
}
|
||||
|
||||
export function ToolkitCard({
|
||||
toolkit,
|
||||
projectId,
|
||||
isConnected,
|
||||
connectedAccountId,
|
||||
projectConfig,
|
||||
onManageTools,
|
||||
onProjectConfigUpdate,
|
||||
onRemoveToolkitTools
|
||||
}: ToolkitCardProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
|
||||
const handleToggleConnection = useCallback(async () => {
|
||||
const newState = !isConnected;
|
||||
|
||||
// Clear any previous error when starting a new operation
|
||||
setError(null);
|
||||
|
||||
if (newState) {
|
||||
// Show authentication modal
|
||||
setShowAuthModal(true);
|
||||
} else {
|
||||
// Disconnect - remove the connected account
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (connectedAccountId) {
|
||||
await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId);
|
||||
onProjectConfigUpdate();
|
||||
onRemoveToolkitTools(toolkit.slug);
|
||||
} else {
|
||||
// Fallback: just refresh the project config
|
||||
onProjectConfigUpdate();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Disconnect failed:', err);
|
||||
const errorMessage = err.message || 'Failed to disconnect toolkit';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
}, [projectId, toolkit.slug, isConnected, connectedAccountId, onProjectConfigUpdate, onRemoveToolkitTools]);
|
||||
|
||||
const handleAuthComplete = useCallback(() => {
|
||||
// Update project config when authentication completes
|
||||
onProjectConfigUpdate();
|
||||
}, [onProjectConfigUpdate]);
|
||||
|
||||
// Calculate selected tools count for this toolkit
|
||||
const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool =>
|
||||
tool.toolkit.slug === toolkit.slug
|
||||
).length || 0;
|
||||
|
||||
return (
|
||||
<div className={toolkitCardStyles.base}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{toolkit.meta.logo && (
|
||||
<PictureImg
|
||||
src={toolkit.meta.logo}
|
||||
alt={`${toolkit.name} logo`}
|
||||
className="w-8 h-8 rounded-md object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100">
|
||||
{toolkit.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
|
||||
bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
||||
{toolkit.meta.tools_count} tools
|
||||
</span>
|
||||
{selectedToolsCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
|
||||
bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300">
|
||||
{selectedToolsCount} selected
|
||||
</span>
|
||||
)}
|
||||
{toolkit.no_auth && (
|
||||
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
|
||||
bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300">
|
||||
No Auth
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{toolkit.no_auth ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={true}
|
||||
onCheckedChange={() => {}} // No-op for no-auth toolkits
|
||||
disabled={true}
|
||||
className={clsx(
|
||||
"data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600",
|
||||
"data-[state=unchecked]:bg-emerald-500 dark:data-[state=unchecked]:bg-emerald-600",
|
||||
"opacity-50 cursor-not-allowed",
|
||||
"scale-75"
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
Always Available
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Switch
|
||||
checked={isConnected}
|
||||
onCheckedChange={handleToggleConnection}
|
||||
disabled={isProcessing}
|
||||
className={clsx(
|
||||
"data-[state=checked]:bg-blue-500 dark:data-[state=checked]:bg-blue-600",
|
||||
"data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-700",
|
||||
isProcessing && "opacity-50 cursor-not-allowed",
|
||||
"scale-75"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-3">
|
||||
{toolkit.meta.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
ID: {toolkit.slug}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-1 text-xs py-1 px-2 rounded-full text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20">
|
||||
<Spinner size="sm" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
{(isConnected || toolkit.no_auth) && !isProcessing && (
|
||||
<div className="text-xs py-1 px-2 rounded-full text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20">
|
||||
{toolkit.no_auth ? 'Available' : 'Connected'}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs py-1 px-2 rounded-full text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onManageTools}
|
||||
className="text-xs"
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
<span className="ml-1.5">Tools</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolkitAuthModal
|
||||
key={toolkit.slug}
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
toolkitSlug={toolkit.slug}
|
||||
projectId={projectId}
|
||||
onComplete={handleAuthComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,10 +4,11 @@ import { useState } from 'react';
|
|||
import { Tabs, Tab } from '@/components/ui/tabs';
|
||||
import { HostedServers } from './HostedServers';
|
||||
import { CustomServers } from './CustomServers';
|
||||
import { Composio } from './Composio';
|
||||
import type { Key } from 'react';
|
||||
|
||||
export function ToolsConfig() {
|
||||
const [activeTab, setActiveTab] = useState('hosted');
|
||||
export function ToolsConfig({ useComposioTools }: { useComposioTools: boolean }) {
|
||||
const [activeTab, setActiveTab] = useState(useComposioTools ? 'composio' : 'hosted');
|
||||
|
||||
const handleTabChange = (key: Key) => {
|
||||
setActiveTab(key.toString());
|
||||
|
|
@ -22,6 +23,13 @@ export function ToolsConfig() {
|
|||
className="w-full"
|
||||
fullWidth
|
||||
>
|
||||
{useComposioTools && (
|
||||
<Tab key="composio" title="Composio">
|
||||
<div className="mt-4 p-6">
|
||||
<Composio />
|
||||
</div>
|
||||
</Tab>
|
||||
)}
|
||||
<Tab key="hosted" title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Tools Library</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue