mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-28 19:05:31 +02:00
refactor tools UX: part 2
This commit is contained in:
parent
751a86c34d
commit
2e3a7916e9
40 changed files with 1499 additions and 2261 deletions
|
|
@ -0,0 +1,42 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { WebhookConfig } from './WebhookConfig';
|
||||
import { Button } from '@heroui/react';
|
||||
import { WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface AddWebhookToolProps {
|
||||
projectId: string;
|
||||
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
|
||||
}
|
||||
|
||||
export function AddWebhookTool({ projectId, onAddTool }: AddWebhookToolProps) {
|
||||
function handleAddTool() {
|
||||
onAddTool({
|
||||
description: 'Webhook tool',
|
||||
mockTool: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Add webhook tool
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<WebhookConfig projectId={projectId} />
|
||||
|
||||
<div>
|
||||
Click here to add a webhook tool:
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
color="primary"
|
||||
onPress={handleAddTool}
|
||||
>Add webhook tool</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,25 +3,30 @@
|
|||
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 { RefreshCw, Search } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { listToolkits, listTools, updateComposioSelectedTools, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions';
|
||||
import { listToolkits } 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';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
|
||||
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');
|
||||
|
||||
export function Composio({
|
||||
projectId,
|
||||
tools,
|
||||
onAddTool
|
||||
}: {
|
||||
projectId: string;
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
|
||||
}) {
|
||||
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
|
||||
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -29,8 +34,6 @@ export function Composio() {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
|
||||
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
|
||||
const [savingTools, setSavingTools] = useState(false);
|
||||
const [composioSelectedTools, setComposioSelectedTools] = useState<z.infer<typeof ZTool>[]>([]);
|
||||
|
||||
const loadProjectConfig = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -42,15 +45,6 @@ export function Composio() {
|
|||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadComposioSelectedTools = useCallback(async () => {
|
||||
try {
|
||||
const tools = await getComposioToolsFromWorkflow(projectId);
|
||||
setComposioSelectedTools(tools);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching composio selected tools:', err);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadAllToolkits = useCallback(async () => {
|
||||
let cursor: string | null = null;
|
||||
let allToolkits: ToolkitType[] = [];
|
||||
|
|
@ -85,7 +79,7 @@ export function Composio() {
|
|||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleManageTools = useCallback((toolkit: ToolkitType) => {
|
||||
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
|
||||
setSelectedToolkit(toolkit);
|
||||
setIsToolsPanelOpen(true);
|
||||
}, []);
|
||||
|
|
@ -95,69 +89,9 @@ export function Composio() {
|
|||
setIsToolsPanelOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleProjectConfigUpdate = useCallback(() => {
|
||||
loadProjectConfig();
|
||||
loadComposioSelectedTools();
|
||||
}, [loadProjectConfig, loadComposioSelectedTools]);
|
||||
|
||||
const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer<typeof ZTool>[]) => {
|
||||
if (!projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
// Get existing selected tools from workflow
|
||||
const existingSelectedTools = 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 data to get updated tools
|
||||
await loadComposioSelectedTools();
|
||||
} catch (error) {
|
||||
console.error('Error saving tool selection:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
}, [projectId, composioSelectedTools, loadComposioSelectedTools]);
|
||||
|
||||
const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => {
|
||||
if (!projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
// Get existing selected tools from workflow
|
||||
const existingSelectedTools = composioSelectedTools;
|
||||
|
||||
// Filter out all tools from the specified toolkit
|
||||
const filteredSelectedTools = existingSelectedTools.filter(tool =>
|
||||
tool.toolkit.slug !== toolkitSlug
|
||||
);
|
||||
|
||||
await updateComposioSelectedTools(projectId, filteredSelectedTools);
|
||||
|
||||
// Refresh data to get updated tools
|
||||
await loadComposioSelectedTools();
|
||||
} catch (error) {
|
||||
console.error('Error removing toolkit tools:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
}, [projectId, composioSelectedTools, loadComposioSelectedTools]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjectConfig();
|
||||
loadComposioSelectedTools();
|
||||
}, [loadProjectConfig, loadComposioSelectedTools]);
|
||||
}, [loadProjectConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllToolkits();
|
||||
|
|
@ -257,19 +191,14 @@ export function Composio() {
|
|||
<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}
|
||||
workflowTools={tools}
|
||||
onSelectToolkit={() => handleSelectToolkit(toolkit)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -284,16 +213,13 @@ export function Composio() {
|
|||
)}
|
||||
|
||||
{/* Tools Panel */}
|
||||
<ComposioToolsPanel
|
||||
{selectedToolkit && <ComposioToolsPanel
|
||||
toolkit={selectedToolkit}
|
||||
isOpen={isToolsPanelOpen}
|
||||
onClose={handleCloseToolsPanel}
|
||||
projectConfig={projectConfig}
|
||||
onUpdateToolsSelection={handleUpdateToolsSelection}
|
||||
onProjectConfigUpdate={handleProjectConfigUpdate}
|
||||
onRemoveToolkitTools={handleRemoveToolkitTools}
|
||||
isSaving={savingTools}
|
||||
/>
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,18 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import { Button, Checkbox } from '@heroui/react';
|
||||
import { ChevronLeft, ChevronRight, LinkIcon, Loader2, UnlinkIcon } from 'lucide-react';
|
||||
import { listTools, deleteConnectedAccount, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions';
|
||||
import { Button, Checkbox, Input } from '@heroui/react';
|
||||
import { ChevronLeft, ChevronRight, Search, X } from 'lucide-react';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
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';
|
||||
import { ToolkitAuthModal } from './ToolkitAuthModal';
|
||||
|
||||
type ToolType = z.infer<typeof ZTool>;
|
||||
type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
interface ComposioToolsPanelProps {
|
||||
toolkit: {
|
||||
|
|
@ -24,25 +22,19 @@ interface ComposioToolsPanelProps {
|
|||
logo: string;
|
||||
};
|
||||
no_auth?: boolean;
|
||||
} | null;
|
||||
};
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectConfig: ProjectType | null;
|
||||
onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void;
|
||||
onProjectConfigUpdate: () => void;
|
||||
onRemoveToolkitTools: (toolkitSlug: string) => void;
|
||||
isSaving: boolean;
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
|
||||
}
|
||||
|
||||
export function ComposioToolsPanel({
|
||||
toolkit,
|
||||
isOpen,
|
||||
onClose,
|
||||
projectConfig,
|
||||
onUpdateToolsSelection,
|
||||
onProjectConfigUpdate,
|
||||
onRemoveToolkitTools,
|
||||
isSaving
|
||||
tools: workflowTools,
|
||||
onAddTool,
|
||||
}: ComposioToolsPanelProps) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
|
|
@ -55,15 +47,30 @@ export function ComposioToolsPanel({
|
|||
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isProcessingAuth, setIsProcessingAuth] = useState(false);
|
||||
const [composioSelectedTools, setComposioSelectedTools] = useState<ToolType[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
|
||||
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => {
|
||||
const selectedToolSlugs = workflowTools
|
||||
.filter(tool => tool.isComposio && tool.composioData?.toolkitSlug === toolkit.slug)
|
||||
.map(tool => tool.composioData!.slug);
|
||||
|
||||
// Filter out already selected tools
|
||||
const availableTools = tools.filter(tool => !selectedToolSlugs.includes(tool.slug));
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null, search: string | null = null) => {
|
||||
try {
|
||||
setToolsLoading(true);
|
||||
|
||||
const response: ToolListResponse = await listTools(projectId, toolkitSlug, cursor);
|
||||
const response: ToolListResponse = await listTools(projectId, toolkitSlug, search, cursor);
|
||||
|
||||
setTools(response.items);
|
||||
setNextCursor(response.next_cursor);
|
||||
|
|
@ -81,27 +88,25 @@ export function ComposioToolsPanel({
|
|||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadComposioSelectedTools = useCallback(async () => {
|
||||
try {
|
||||
const tools = await getComposioToolsFromWorkflow(projectId);
|
||||
setComposioSelectedTools(tools);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching composio selected tools:', err);
|
||||
// Load tools when search query changes
|
||||
useEffect(() => {
|
||||
if (toolkit && isOpen) {
|
||||
loadToolsForToolkit(toolkit.slug, null, debouncedSearchQuery || null);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [toolkit, isOpen, debouncedSearchQuery, loadToolsForToolkit]);
|
||||
|
||||
const handleNextPage = useCallback(async () => {
|
||||
if (!nextCursor || !toolkit) return;
|
||||
if (!nextCursor) return;
|
||||
|
||||
// Add current cursor to history
|
||||
setCursorHistory(prev => [...prev, currentCursor || '']);
|
||||
setCurrentCursor(nextCursor);
|
||||
|
||||
await loadToolsForToolkit(toolkit.slug, nextCursor);
|
||||
}, [nextCursor, toolkit, currentCursor, loadToolsForToolkit]);
|
||||
await loadToolsForToolkit(toolkit.slug, nextCursor, debouncedSearchQuery || null);
|
||||
}, [nextCursor, toolkit, currentCursor, debouncedSearchQuery, loadToolsForToolkit]);
|
||||
|
||||
const handlePreviousPage = useCallback(async () => {
|
||||
if (cursorHistory.length === 0 || !toolkit) return;
|
||||
if (cursorHistory.length === 0) return;
|
||||
|
||||
// Get the previous cursor from history
|
||||
const previousCursor = cursorHistory[cursorHistory.length - 1];
|
||||
|
|
@ -110,8 +115,8 @@ export function ComposioToolsPanel({
|
|||
setCursorHistory(newHistory);
|
||||
setCurrentCursor(previousCursor);
|
||||
|
||||
await loadToolsForToolkit(toolkit.slug, previousCursor);
|
||||
}, [cursorHistory, toolkit, loadToolsForToolkit]);
|
||||
await loadToolsForToolkit(toolkit.slug, previousCursor, debouncedSearchQuery || null);
|
||||
}, [cursorHistory, toolkit, debouncedSearchQuery, loadToolsForToolkit]);
|
||||
|
||||
const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => {
|
||||
setSelectedTools(prev => {
|
||||
|
|
@ -126,243 +131,195 @@ export function ComposioToolsPanel({
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleSaveTools = useCallback(async () => {
|
||||
// Convert selected tool slugs to actual tool objects
|
||||
const handleAddSelectedTools = useCallback(() => {
|
||||
// Convert selected tool slugs to actual tool objects and add them
|
||||
const selectedToolObjects = tools.filter(tool => selectedTools.has(tool.slug));
|
||||
await onUpdateToolsSelection(selectedToolObjects);
|
||||
setHasChanges(false);
|
||||
}, [onUpdateToolsSelection, selectedTools, tools]);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
setShowAuthModal(true);
|
||||
}, []);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
if (!toolkit) return;
|
||||
|
||||
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
|
||||
selectedToolObjects.forEach(tool => {
|
||||
const toolToAdd = {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.input_parameters?.properties || {},
|
||||
required: tool.input_parameters?.required || [],
|
||||
},
|
||||
isComposio: true,
|
||||
composioData: {
|
||||
slug: tool.slug,
|
||||
noAuth: toolkit.no_auth || false,
|
||||
toolkitName: toolkit.name,
|
||||
toolkitSlug: toolkit.slug,
|
||||
logo: toolkit.meta.logo,
|
||||
},
|
||||
};
|
||||
|
||||
onAddTool(toolToAdd);
|
||||
});
|
||||
|
||||
setIsProcessingAuth(true);
|
||||
try {
|
||||
if (connectedAccountId) {
|
||||
await deleteConnectedAccount(projectId, toolkit.slug, connectedAccountId);
|
||||
onProjectConfigUpdate();
|
||||
onRemoveToolkitTools(toolkit.slug);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Disconnect failed:', err);
|
||||
} finally {
|
||||
setIsProcessingAuth(false);
|
||||
}
|
||||
}, [projectId, toolkit, projectConfig, onProjectConfigUpdate, onRemoveToolkitTools]);
|
||||
|
||||
const handleAuthComplete = useCallback(() => {
|
||||
setShowAuthModal(false);
|
||||
onProjectConfigUpdate();
|
||||
}, [onProjectConfigUpdate]);
|
||||
onClose();
|
||||
}, [selectedTools, tools, toolkit, onAddTool, onClose]);
|
||||
|
||||
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]);
|
||||
setSearchQuery('');
|
||||
setDebouncedSearchQuery('');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Initialize selected tools from workflow when opening the panel
|
||||
useEffect(() => {
|
||||
if (toolkit && isOpen) {
|
||||
loadComposioSelectedTools();
|
||||
}
|
||||
}, [toolkit, isOpen, loadComposioSelectedTools]);
|
||||
|
||||
// Set selected tools when composioSelectedTools is loaded
|
||||
useEffect(() => {
|
||||
if (toolkit && composioSelectedTools.length > 0) {
|
||||
const toolSlugs = new Set(composioSelectedTools.map(tool => tool.slug));
|
||||
setSelectedTools(toolSlugs);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [toolkit, composioSelectedTools]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toolkit && isOpen) {
|
||||
loadToolsForToolkit(toolkit.slug, null);
|
||||
}
|
||||
}, [toolkit, isOpen, loadToolsForToolkit]);
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{/* Connection Status Banner */}
|
||||
{!toolkit.no_auth && (
|
||||
<div className={`mb-6 p-4 rounded-lg border-2 ${
|
||||
isToolkitConnected
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
isToolkitConnected ? 'bg-emerald-500' : 'bg-blue-500'
|
||||
}`}></div>
|
||||
<div>
|
||||
<h3 className={`font-semibold text-sm ${
|
||||
isToolkitConnected
|
||||
? 'text-emerald-800 dark:text-emerald-200'
|
||||
: 'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
{isToolkitConnected ? 'Toolkit Connected' : 'Authentication Required'}
|
||||
</h3>
|
||||
<p className={`text-xs mt-0.5 ${
|
||||
isToolkitConnected
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{isToolkitConnected
|
||||
? 'You can select and use tools from this toolkit'
|
||||
: 'You can select tools now. Authentication will be required in the build view to use them.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isToolkitConnected && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onPress={handleDisconnect}
|
||||
disabled={isProcessingAuth}
|
||||
color="danger"
|
||||
isLoading={isProcessingAuth}
|
||||
startContent={<UnlinkIcon className="h-4 w-4" />}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={handleSaveTools}
|
||||
disabled={isSaving}
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span>{toolkit.name}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Select Tools</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Check the tools you want to add to your workflow
|
||||
</p>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={handleAddSelectedTools}
|
||||
>
|
||||
Add Selected ({selectedTools.size})
|
||||
</Button>
|
||||
)}
|
||||
</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 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">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
isSelected={selectedTools.has(tool.slug)}
|
||||
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
|
||||
size="sm"
|
||||
/>
|
||||
<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>
|
||||
{/* Search Box */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
size="sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</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">
|
||||
{searchQuery ? 'Searching tools...' : 'Loading tools...'}
|
||||
</p>
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{searchQuery ? 'No tools found matching your search.' : 'No tools available.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availableTools.map((tool) => (
|
||||
<div
|
||||
key={tool.slug}
|
||||
className="group p-4 rounded-lg transition-all duration-200 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
isSelected={selectedTools.has(tool.slug)}
|
||||
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 text-left flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-left">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<div className="font-mono text-xs text-gray-500 dark:text-gray-400 text-left truncate max-w-[300px] bg-gray-100 dark:bg-gray-700 p-1 rounded-md" title={tool.slug}>
|
||||
{tool.slug}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-left">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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="bordered"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={cursorHistory.length === 0 || toolsLoading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!nextCursor || toolsLoading}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</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-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{availableTools.length > 0 && (
|
||||
<span>
|
||||
{availableTools.length} tool{availableTools.length !== 1 ? 's' : ''} found
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={cursorHistory.length === 0 || toolsLoading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!nextCursor || toolsLoading}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
|
||||
{/* Auth Modal */}
|
||||
{toolkit && (
|
||||
<ToolkitAuthModal
|
||||
key={toolkit.slug}
|
||||
isOpen={showAuthModal}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
toolkitSlug={toolkit.slug}
|
||||
projectId={projectId}
|
||||
onComplete={handleAuthComplete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@heroui/react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { getProjectConfig } from '@/app/actions/project_actions';
|
||||
import { addServer, removeServer } from '@/app/actions/custom_mcp_server_actions';
|
||||
import { fetchTools } from "@/app/actions/custom_mcp_server_actions";
|
||||
import { ServerCard } from './ServerCard';
|
||||
import { McpToolsPanel } from './McpToolsPanel';
|
||||
import { ProjectWideChangeConfirmationModal } from '@/components/common/project-wide-change-confirmation-modal';
|
||||
|
||||
// Types
|
||||
const CustomMcpServerType = z.object({ serverUrl: z.string() });
|
||||
type CustomMcpServer = z.infer<typeof CustomMcpServerType>;
|
||||
|
||||
type ServerList = Record<string, CustomMcpServer>;
|
||||
|
||||
type CustomMcpServersProps = {
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
|
||||
};
|
||||
|
||||
export function CustomMcpServers({ tools: workflowTools, onAddTool }: CustomMcpServersProps) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
// State
|
||||
const [servers, setServers] = useState<ServerList>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addName, setAddName] = useState('');
|
||||
const [addUrl, setAddUrl] = useState('');
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [panelServer, setPanelServer] = useState<{ name: string; url: string } | null>(null);
|
||||
const [toolsLoading, setToolsLoading] = useState(false);
|
||||
const [toolsError, setToolsError] = useState<string | null>(null);
|
||||
const [serverTools, setServerTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
|
||||
|
||||
// Fetch servers on mount
|
||||
const fetchServers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const project = await getProjectConfig(projectId);
|
||||
setServers(project.customMcpServers || {});
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load servers');
|
||||
setServers({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
// Add server
|
||||
const handleAddServer = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addName || !addUrl) return;
|
||||
setAddLoading(true);
|
||||
setAddError(null);
|
||||
try {
|
||||
await addServer(projectId, addName, { serverUrl: addUrl });
|
||||
setAddName('');
|
||||
setAddUrl('');
|
||||
await fetchServers();
|
||||
} catch (err: any) {
|
||||
setAddError(err?.message || 'Failed to add server');
|
||||
} finally {
|
||||
setAddLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open delete modal
|
||||
const handleDeleteClick = (name: string) => {
|
||||
setServerToDelete(name);
|
||||
setDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
// Delete server
|
||||
const handleDeleteServer = async () => {
|
||||
if (!serverToDelete) return;
|
||||
try {
|
||||
await removeServer(projectId, serverToDelete);
|
||||
await fetchServers();
|
||||
setDeleteModalOpen(false);
|
||||
setServerToDelete(null);
|
||||
} catch (err: any) {
|
||||
alert(err?.message || 'Failed to delete server');
|
||||
}
|
||||
};
|
||||
|
||||
// Open panel and fetch tools
|
||||
const handleOpenPanel = async (name: string, url: string) => {
|
||||
setPanelServer({ name, url });
|
||||
setToolsLoading(true);
|
||||
setToolsError(null);
|
||||
setServerTools([]);
|
||||
try {
|
||||
const fetched = await fetchTools(url, name);
|
||||
setServerTools(fetched);
|
||||
} catch (err: any) {
|
||||
setToolsError(err?.message || 'Failed to fetch tools');
|
||||
} finally {
|
||||
setToolsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close panel
|
||||
const handleClosePanel = () => {
|
||||
setPanelServer(null);
|
||||
setServerTools([]);
|
||||
};
|
||||
|
||||
// UI
|
||||
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>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Add your own MCP servers here. Enter the server details and select tools to add to your workflow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add server form */}
|
||||
<form onSubmit={handleAddServer} className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={addName}
|
||||
onChange={e => setAddName(e.target.value)}
|
||||
placeholder="Server Name"
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={addUrl}
|
||||
onChange={e => setAddUrl(e.target.value)}
|
||||
placeholder="Server URL"
|
||||
required
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!addName || !addUrl || addLoading}
|
||||
startContent={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{addError && <div className="text-red-500 text-sm mt-1">{addError}</div>}
|
||||
</form>
|
||||
|
||||
{/* Server cards */}
|
||||
{loading ? (
|
||||
<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 servers...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500 dark:text-red-400">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Object.entries(servers).length === 0 ? (
|
||||
<div className="col-span-full text-gray-500 text-sm">No custom MCP servers added yet.</div>
|
||||
) : (
|
||||
Object.entries(servers).map(([name, { serverUrl }]) => (
|
||||
<ServerCard
|
||||
key={name}
|
||||
serverName={name}
|
||||
serverUrl={serverUrl}
|
||||
workflowTools={workflowTools}
|
||||
onSelectServer={() => handleOpenPanel(name, serverUrl)}
|
||||
onDeleteServer={() => handleDeleteClick(name)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<ProjectWideChangeConfirmationModal
|
||||
isOpen={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteServer}
|
||||
title="Delete Server"
|
||||
confirmationQuestion={`Are you sure you want to delete "${serverToDelete}"? This will delete the server from the project.`}
|
||||
confirmButtonText="Delete"
|
||||
/>
|
||||
|
||||
{/* MCP Tools Panel */}
|
||||
<McpToolsPanel
|
||||
server={panelServer}
|
||||
isOpen={!!panelServer}
|
||||
onClose={handleClosePanel}
|
||||
tools={workflowTools}
|
||||
onAddTool={onAddTool}
|
||||
serverTools={serverTools}
|
||||
toolsLoading={toolsLoading}
|
||||
toolsError={toolsError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Info, Plus, Search } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { z } from 'zod';
|
||||
import { MCPServer } from '@/app/lib/types/types';
|
||||
import {
|
||||
ServerCard,
|
||||
ToolManagementPanel
|
||||
} from './MCPServersCommon';
|
||||
import { fetchMcpToolsForServer } from '@/app/actions/mcp_actions';
|
||||
import {
|
||||
fetchCustomServers,
|
||||
addCustomServer,
|
||||
removeCustomServer,
|
||||
toggleCustomServer,
|
||||
updateCustomServerTools
|
||||
} from '@/app/actions/custom_server_actions';
|
||||
import { Modal } from '@/components/ui/modal';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
|
||||
|
||||
export function CustomServers({ onToolsUpdated }: { onToolsUpdated?: () => void }) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
const [servers, setServers] = useState<McpServerType[]>([]);
|
||||
const [selectedServer, setSelectedServer] = useState<McpServerType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [togglingServers, setTogglingServers] = useState<Set<string>>(new Set());
|
||||
const [serverOperations, setServerOperations] = useState<Map<string, 'setup' | 'delete'>>(new Map());
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasToolChanges, setHasToolChanges] = useState(false);
|
||||
const [savingTools, setSavingTools] = useState(false);
|
||||
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
|
||||
const [showAddServer, setShowAddServer] = useState(false);
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerUrl, setNewServerUrl] = useState('');
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const customServers = await fetchCustomServers(projectId);
|
||||
setServers(customServers);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load custom MCP servers');
|
||||
console.error('Error fetching servers:', err);
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
const handleToggleServer = async (server: McpServerType) => {
|
||||
try {
|
||||
const serverKey = server.name;
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(serverKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, server.isActive ? 'delete' : 'setup');
|
||||
return next;
|
||||
});
|
||||
|
||||
await toggleCustomServer(projectId, server.name, !server.isActive);
|
||||
|
||||
// Update local state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: !s.isActive
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
// Notify parent component about tool updates
|
||||
onToolsUpdated?.();
|
||||
} catch (err) {
|
||||
console.error('Toggle failed:', { server: server.name, error: err });
|
||||
} finally {
|
||||
const serverKey = server.name;
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncServer = async (server: McpServerType) => {
|
||||
if (!projectId || !server.isActive) return;
|
||||
|
||||
try {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(server.name);
|
||||
return next;
|
||||
});
|
||||
const enrichedTools = await fetchMcpToolsForServer(projectId, server.name);
|
||||
|
||||
const updatedAvailableTools = enrichedTools.map(tool => ({
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters
|
||||
}));
|
||||
|
||||
await updateCustomServerTools(
|
||||
projectId,
|
||||
server.name,
|
||||
updatedAvailableTools, // Auto-select all tools for custom servers
|
||||
updatedAvailableTools
|
||||
);
|
||||
|
||||
// Update servers state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === server.name) {
|
||||
return {
|
||||
...s,
|
||||
availableTools: updatedAvailableTools,
|
||||
tools: updatedAvailableTools
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
// If this server is currently selected, update the selectedTools state
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
availableTools: updatedAvailableTools,
|
||||
tools: updatedAvailableTools
|
||||
};
|
||||
});
|
||||
// Update selectedTools to include all tools for the custom server
|
||||
setSelectedTools(new Set(updatedAvailableTools.map(tool => tool.id)));
|
||||
}
|
||||
|
||||
// Notify parent component about tool updates
|
||||
onToolsUpdated?.();
|
||||
} finally {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(server.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add effect to sync selectedTools when selectedServer changes
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setSelectedTools(new Set(selectedServer.tools.map(tool => tool.id)));
|
||||
setHasToolChanges(false);
|
||||
}
|
||||
}, [selectedServer]);
|
||||
|
||||
const handleAddServer = async () => {
|
||||
if (!newServerName || !newServerUrl) return;
|
||||
|
||||
try {
|
||||
const newServer: McpServerType = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: newServerName,
|
||||
description: `Custom MCP server at ${newServerUrl}`,
|
||||
serverUrl: newServerUrl,
|
||||
tools: [],
|
||||
availableTools: [],
|
||||
isActive: true,
|
||||
isReady: true,
|
||||
serverType: 'custom',
|
||||
authNeeded: false,
|
||||
isAuthenticated: false
|
||||
};
|
||||
|
||||
// Add to MongoDB and get back the formatted server
|
||||
const formattedServer = await addCustomServer(projectId, newServer);
|
||||
|
||||
// Update local state with the formatted server
|
||||
setServers(prev => [...prev, formattedServer]);
|
||||
setShowAddServer(false);
|
||||
setNewServerName('');
|
||||
setNewServerUrl('');
|
||||
|
||||
// Fetch tools for the new server using the formatted URL
|
||||
await handleSyncServer(formattedServer);
|
||||
|
||||
// Notify parent component about tool updates
|
||||
onToolsUpdated?.();
|
||||
} catch (err) {
|
||||
console.error('Error adding server:', err);
|
||||
setError('Failed to add server. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveServer = async (server: McpServerType) => {
|
||||
// Show confirmation dialog
|
||||
const shouldRemove = window.confirm(
|
||||
"Are you sure you want to delete this server? Alternatively, you can toggle it OFF if you'd like to retain the configuration but not make it available to agents."
|
||||
);
|
||||
|
||||
if (!shouldRemove) return;
|
||||
|
||||
try {
|
||||
await removeCustomServer(projectId, server.name);
|
||||
// Update local state
|
||||
setServers(prev => prev.filter(s => s.name !== server.name));
|
||||
// If this server was selected, close the tool management panel
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
|
||||
// Notify parent component about tool updates
|
||||
onToolsUpdated?.();
|
||||
} catch (err) {
|
||||
console.error('Error removing server:', err);
|
||||
setError('Failed to remove server. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToolSelection = async () => {
|
||||
if (!selectedServer || !projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
const availableTools = selectedServer.availableTools || [];
|
||||
const selectedToolsList = availableTools.filter(tool => selectedTools.has(tool.id));
|
||||
|
||||
await updateCustomServerTools(
|
||||
projectId,
|
||||
selectedServer.name,
|
||||
selectedToolsList,
|
||||
availableTools
|
||||
);
|
||||
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === selectedServer.name) {
|
||||
return {
|
||||
...s,
|
||||
tools: selectedToolsList
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedServer(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
tools: selectedToolsList
|
||||
};
|
||||
});
|
||||
|
||||
setHasToolChanges(false);
|
||||
|
||||
// Notify parent component about tool updates
|
||||
onToolsUpdated?.();
|
||||
} catch (error) {
|
||||
console.error('Error saving tool selection:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredServers = servers.filter(server => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const serverTools = server.tools || [];
|
||||
return (
|
||||
server.name.toLowerCase().includes(searchLower) ||
|
||||
server.description.toLowerCase().includes(searchLower) ||
|
||||
serverTools.some(tool =>
|
||||
tool.name.toLowerCase().includes(searchLower) ||
|
||||
tool.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
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>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Add your own MCP servers here. These servers will be available to agents in the Build view once toggled ON.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => setShowAddServer(true)}
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-2">Add Server</span>
|
||||
</div>
|
||||
</Button>
|
||||
<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 servers or tools..."
|
||||
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">
|
||||
{filteredServers.length} {filteredServers.length === 1 ? 'server' : 'servers'} • {
|
||||
filteredServers.reduce((total, server) => total + (server.availableTools?.length || 0), 0)
|
||||
} tools
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showAddServer}
|
||||
onClose={() => {
|
||||
setShowAddServer(false);
|
||||
setNewServerName('');
|
||||
setNewServerUrl('');
|
||||
}}
|
||||
title="Add Custom MCP Server"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Server Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newServerName}
|
||||
onChange={(e) => setNewServerName(e.target.value)}
|
||||
placeholder="e.g., My Custom Server"
|
||||
className="w-full px-3 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
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Server URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newServerUrl}
|
||||
onChange={(e) => setNewServerUrl(e.target.value)}
|
||||
placeholder="e.g., http://localhost:3000"
|
||||
className="w-full px-3 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
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowAddServer(false);
|
||||
setNewServerName('');
|
||||
setNewServerUrl('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleAddServer}
|
||||
disabled={!newServerName || !newServerUrl}
|
||||
>
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{loading ? (
|
||||
<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 servers...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500 dark:text-red-400">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredServers.map((server) => (
|
||||
<ServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
onToggle={() => handleToggleServer(server)}
|
||||
onManageTools={() => setSelectedServer(server)}
|
||||
onSync={() => handleSyncServer(server)}
|
||||
onRemove={() => handleRemoveServer(server)}
|
||||
isToggling={togglingServers.has(server.name)}
|
||||
isSyncing={syncingServers.has(server.name)}
|
||||
operation={serverOperations.get(server.name)}
|
||||
error={error && error.includes(server.name) ? { message: error } : undefined}
|
||||
showAuth={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolManagementPanel
|
||||
server={selectedServer}
|
||||
onClose={() => {
|
||||
setSelectedServer(null);
|
||||
setSelectedTools(new Set());
|
||||
setHasToolChanges(false);
|
||||
}}
|
||||
selectedTools={selectedTools}
|
||||
onToolSelectionChange={(toolId, selected) => {
|
||||
setSelectedTools(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(toolId);
|
||||
} else {
|
||||
next.delete(toolId);
|
||||
}
|
||||
setHasToolChanges(true);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSaveTools={handleSaveToolSelection}
|
||||
onSyncTools={selectedServer ? () => handleSyncServer(selectedServer) : undefined}
|
||||
hasChanges={hasToolChanges}
|
||||
isSaving={savingTools}
|
||||
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button, Checkbox, Input } from '@heroui/react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { z } from 'zod';
|
||||
import { SlidePanel } from '@/components/ui/slide-panel';
|
||||
|
||||
interface McpToolsPanelProps {
|
||||
server: {
|
||||
name: string;
|
||||
url: string;
|
||||
} | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
|
||||
serverTools: z.infer<typeof WorkflowTool>[];
|
||||
toolsLoading: boolean;
|
||||
toolsError: string | null;
|
||||
}
|
||||
|
||||
export function McpToolsPanel({
|
||||
server,
|
||||
isOpen,
|
||||
onClose,
|
||||
tools: workflowTools,
|
||||
onAddTool,
|
||||
serverTools,
|
||||
toolsLoading,
|
||||
toolsError,
|
||||
}: McpToolsPanelProps) {
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
|
||||
// Filter out already selected tools
|
||||
const selectedToolNames = workflowTools
|
||||
.filter(tool => tool.isMcp && tool.mcpServerName === server?.name)
|
||||
.map(tool => tool.name);
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Filter tools based on search query
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!debouncedSearchQuery) return serverTools;
|
||||
|
||||
const query = debouncedSearchQuery.toLowerCase();
|
||||
return serverTools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(query) ||
|
||||
tool.description.toLowerCase().includes(query)
|
||||
);
|
||||
}, [serverTools, debouncedSearchQuery]);
|
||||
|
||||
// Filter out already added tools
|
||||
const availableTools = filteredTools.filter(tool => !selectedToolNames.includes(tool.name));
|
||||
|
||||
const handleToolSelectionChange = useCallback((toolName: string, selected: boolean) => {
|
||||
setSelectedTools(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(toolName);
|
||||
} else {
|
||||
next.delete(toolName);
|
||||
}
|
||||
setHasChanges(true);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddSelectedTools = useCallback(() => {
|
||||
// Convert selected tool names to actual tool objects and add them
|
||||
const selectedToolObjects = serverTools.filter(tool => selectedTools.has(tool.name));
|
||||
|
||||
selectedToolObjects.forEach(tool => {
|
||||
onAddTool(tool);
|
||||
});
|
||||
|
||||
onClose();
|
||||
}, [selectedTools, serverTools, onAddTool, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedTools(new Set());
|
||||
setHasChanges(false);
|
||||
setSearchQuery('');
|
||||
setDebouncedSearchQuery('');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
}, []);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 bg-blue-500 rounded-md flex items-center justify-center">
|
||||
<span className="text-white text-xs font-bold">MCP</span>
|
||||
</div>
|
||||
<span>{server.name}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Select Tools</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Check the tools you want to add to your workflow
|
||||
</p>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={handleAddSelectedTools}
|
||||
>
|
||||
Add Selected ({selectedTools.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
size="sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{toolsError && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{toolsError}</p>
|
||||
</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">
|
||||
{searchQuery ? 'Searching tools...' : 'Loading tools...'}
|
||||
</p>
|
||||
</div>
|
||||
) : availableTools.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{searchQuery ? 'No tools found matching your search.' : 'No tools available.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availableTools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="group p-4 rounded-lg transition-all duration-200 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
isSelected={selectedTools.has(tool.name)}
|
||||
onValueChange={(selected) => handleToolSelectionChange(tool.name, selected)}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 text-left flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-left">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-left">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{availableTools.length > 0 && (
|
||||
<span>
|
||||
{availableTools.length} tool{availableTools.length !== 1 ? 's' : ''} found
|
||||
{searchQuery && ` for "${searchQuery}"`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
onPress={handleAddSelectedTools}
|
||||
disabled={selectedTools.size === 0}
|
||||
>
|
||||
Add Selected ({selectedTools.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PictureImg } from '@/components/ui/picture-img';
|
||||
import clsx from 'clsx';
|
||||
import { z } from 'zod';
|
||||
import { Chip } from '@heroui/react';
|
||||
import { Server, MoreVertical } from 'lucide-react';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { fetchTools } from "@/app/actions/custom_mcp_server_actions";
|
||||
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react';
|
||||
import { Button } from '@heroui/react';
|
||||
|
||||
type ServerCardProps = {
|
||||
serverName: string;
|
||||
serverUrl: string;
|
||||
workflowTools: z.infer<typeof Workflow.shape.tools>;
|
||||
onSelectServer: () => void;
|
||||
onDeleteServer: () => void;
|
||||
};
|
||||
|
||||
const serverCardStyles = {
|
||||
base: clsx(
|
||||
"group p-6 rounded-xl transition-all duration-200 cursor-pointer",
|
||||
"bg-white dark:bg-gray-900",
|
||||
"border border-gray-200 dark:border-gray-700",
|
||||
"shadow-md dark:shadow-gray-900/20",
|
||||
"hover:shadow-lg dark:hover:shadow-gray-900/30",
|
||||
"hover:border-blue-300 dark:hover:border-blue-600",
|
||||
"hover:bg-gray-50/50 dark:hover:bg-gray-800/50",
|
||||
"hover:-translate-y-1",
|
||||
"min-h-[200px] flex flex-col"
|
||||
),
|
||||
};
|
||||
|
||||
export function ServerCard({
|
||||
serverName,
|
||||
serverUrl,
|
||||
workflowTools,
|
||||
onSelectServer,
|
||||
onDeleteServer,
|
||||
}: ServerCardProps) {
|
||||
const [tools, setTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
|
||||
const [toolsLoading, setToolsLoading] = useState(true);
|
||||
const [toolsError, setToolsError] = useState<string | null>(null);
|
||||
|
||||
// Fetch tools on mount
|
||||
useEffect(() => {
|
||||
const fetchServerTools = async () => {
|
||||
setToolsLoading(true);
|
||||
setToolsError(null);
|
||||
try {
|
||||
const fetched = await fetchTools(serverUrl, serverName);
|
||||
setTools(fetched);
|
||||
} catch (err: any) {
|
||||
setToolsError(err?.message || 'Failed to fetch tools');
|
||||
setTools([]);
|
||||
} finally {
|
||||
setToolsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchServerTools();
|
||||
}, [serverUrl, serverName]);
|
||||
|
||||
const handleCardClick = useCallback(() => {
|
||||
onSelectServer();
|
||||
}, [onSelectServer]);
|
||||
|
||||
// Calculate selected tools count for this server
|
||||
const selectedToolsCount = workflowTools
|
||||
.filter(tool => tool.isMcp && tool.mcpServerName === serverName)
|
||||
.length;
|
||||
|
||||
return (
|
||||
<div className={serverCardStyles.base} onClick={handleCardClick}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center flex-shrink-0">
|
||||
<Server className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100 truncate">
|
||||
{serverName}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{toolsLoading ? (
|
||||
<Chip
|
||||
color="secondary"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
>
|
||||
Loading tools...
|
||||
</Chip>
|
||||
) : toolsError ? (
|
||||
<Chip
|
||||
color="danger"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
>
|
||||
Error loading tools
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip
|
||||
color="secondary"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
>
|
||||
{selectedToolsCount > 0
|
||||
? `${tools.length} tools, ${selectedToolsCount} selected`
|
||||
: `${tools.length} tools`
|
||||
}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
isIconOnly
|
||||
title="More options"
|
||||
aria-label="More options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Server actions">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
color="danger"
|
||||
startContent={<MoreVertical className="h-4 w-4" />}
|
||||
onPress={onDeleteServer}
|
||||
>
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-3">
|
||||
Custom MCP server at {serverUrl}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip
|
||||
color='success'
|
||||
variant='flat'
|
||||
size="sm"
|
||||
>
|
||||
Custom Server
|
||||
</Chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,12 +5,11 @@ import { PictureImg } from '@/components/ui/picture-img';
|
|||
import clsx from 'clsx';
|
||||
import { z } from 'zod';
|
||||
import { ZToolkit } from '@/app/lib/composio/composio';
|
||||
import { Project } from '@/app/lib/types/project_types';
|
||||
import { Chip } from '@heroui/react';
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
import { Workflow } from '@/app/lib/types/workflow_types';
|
||||
|
||||
type ToolkitType = z.infer<typeof ZToolkit>;
|
||||
type ProjectType = z.infer<typeof Project>;
|
||||
|
||||
const toolkitCardStyles = {
|
||||
base: clsx(
|
||||
|
|
@ -28,32 +27,25 @@ const toolkitCardStyles = {
|
|||
|
||||
interface ToolkitCardProps {
|
||||
toolkit: ToolkitType;
|
||||
projectId: string;
|
||||
isConnected: boolean;
|
||||
connectedAccountId?: string;
|
||||
projectConfig: ProjectType | null;
|
||||
onManageTools: () => void;
|
||||
onProjectConfigUpdate: () => void;
|
||||
onRemoveToolkitTools: (toolkitSlug: string) => void;
|
||||
onSelectToolkit: () => void;
|
||||
workflowTools: z.infer<typeof Workflow.shape.tools>;
|
||||
}
|
||||
|
||||
export function ToolkitCard({
|
||||
toolkit,
|
||||
projectId,
|
||||
isConnected,
|
||||
connectedAccountId,
|
||||
projectConfig,
|
||||
onManageTools,
|
||||
onProjectConfigUpdate,
|
||||
onRemoveToolkitTools
|
||||
onSelectToolkit,
|
||||
workflowTools,
|
||||
}: ToolkitCardProps) {
|
||||
const handleCardClick = useCallback(() => {
|
||||
onManageTools();
|
||||
}, [onManageTools]);
|
||||
onSelectToolkit();
|
||||
}, [onSelectToolkit]);
|
||||
|
||||
// Calculate selected tools count for this toolkit
|
||||
// TODO: Update to use workflow-based tools count
|
||||
const selectedToolsCount = 0;
|
||||
const selectedToolsCount = workflowTools
|
||||
.filter(tool => tool.isComposio && tool.composioData?.toolkitSlug === toolkit.slug)
|
||||
.length;
|
||||
|
||||
return (
|
||||
<div className={toolkitCardStyles.base} onClick={handleCardClick}>
|
||||
|
|
|
|||
|
|
@ -2,23 +2,25 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import { Tabs, Tab } from '@/components/ui/tabs';
|
||||
import { HostedServers } from './HostedServers';
|
||||
import { CustomServers } from './CustomServers';
|
||||
import { WebhookConfig } from './WebhookConfig';
|
||||
import { CustomMcpServers } from './CustomMcpServer';
|
||||
import { Composio } from './Composio';
|
||||
import { AddWebhookTool } from './AddWebhookTool';
|
||||
import type { Key } from 'react';
|
||||
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function ToolsConfig({
|
||||
projectId,
|
||||
useComposioTools,
|
||||
useKlavisTools
|
||||
tools,
|
||||
onAddTool,
|
||||
}: {
|
||||
projectId: string;
|
||||
useComposioTools: boolean;
|
||||
useKlavisTools: boolean;
|
||||
tools: z.infer<typeof Workflow.shape.tools>;
|
||||
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
|
||||
}) {
|
||||
let defaultActiveTab = 'custom';
|
||||
if (useKlavisTools) {
|
||||
defaultActiveTab = 'hosted';
|
||||
}
|
||||
let defaultActiveTab = 'mcp';
|
||||
if (useComposioTools) {
|
||||
defaultActiveTab = 'composio';
|
||||
}
|
||||
|
|
@ -40,32 +42,28 @@ export function ToolsConfig({
|
|||
{useComposioTools && (
|
||||
<Tab key="composio" title="Composio">
|
||||
<div className="mt-4 p-6">
|
||||
<Composio />
|
||||
<Composio
|
||||
projectId={projectId}
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
)}
|
||||
{useKlavisTools && (
|
||||
<Tab key="hosted" title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Klavis</span>
|
||||
<span className="leading-none px-1.5 py-[2px] text-[9px] font-medium bg-linear-to-r from-pink-500 to-violet-500 text-white rounded-full">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
}>
|
||||
<div className="mt-4 p-6">
|
||||
<HostedServers onSwitchTab={key => setActiveTab(key)} />
|
||||
</div>
|
||||
</Tab>
|
||||
)}
|
||||
<Tab key="custom" title="Custom MCP Servers">
|
||||
<Tab key="mcp" title="Custom MCP Servers">
|
||||
<div className="mt-4 p-6">
|
||||
<CustomServers />
|
||||
<CustomMcpServers
|
||||
tools={tools}
|
||||
onAddTool={onAddTool}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key="webhook" title="Webhook">
|
||||
<div className="mt-4 p-6">
|
||||
<WebhookConfig />
|
||||
<AddWebhookTool
|
||||
projectId={projectId}
|
||||
onAddTool={onAddTool}
|
||||
/>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
|
|
|||
|
|
@ -1,42 +1,20 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { Spinner, Button, Input } from "@heroui/react";
|
||||
import { getProjectConfig, updateWebhookUrl } from "@/app/actions/project_actions";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clsx } from "clsx";
|
||||
import { ProjectWideChangeConfirmationModal } from '@/components/common/project-wide-change-confirmation-modal';
|
||||
|
||||
const sectionHeaderStyles = "block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2";
|
||||
const sectionDescriptionStyles = "text-sm text-gray-500 dark:text-gray-400 mb-4";
|
||||
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
|
||||
const inputStyles = "rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20";
|
||||
|
||||
function Section({ title, children, description }: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
|
||||
<div className="px-6 pt-4">
|
||||
<h2 className={sectionHeaderStyles}>{title}</h2>
|
||||
{description && (
|
||||
<p className={sectionDescriptionStyles}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookConfig() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId ? (typeof params.projectId === 'string' ? params.projectId : params.projectId[0]) : '';
|
||||
export function WebhookConfig({ projectId }: { projectId: string }) {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editValue, setEditValue] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
|
@ -46,6 +24,7 @@ export function WebhookConfig() {
|
|||
const project = await getProjectConfig(projectId);
|
||||
if (mounted) {
|
||||
setWebhookUrl(project.webhookUrl || null);
|
||||
setEditValue(project.webhookUrl || '');
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -67,59 +46,138 @@ export function WebhookConfig() {
|
|||
};
|
||||
}, [projectId]);
|
||||
|
||||
function validate(url: string) {
|
||||
if (!url.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
// validate on change in webhook
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return;
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
new URL(url);
|
||||
setError(null);
|
||||
return { valid: true };
|
||||
new URL(editValue || '');
|
||||
} catch {
|
||||
setError('Please enter a valid URL');
|
||||
return { valid: false, errorMessage: 'Please enter a valid URL' };
|
||||
}
|
||||
}, [editValue, isEditMode]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditMode(true);
|
||||
setEditValue(webhookUrl || '');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditMode(false);
|
||||
setEditValue(webhookUrl || '');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateWebhookUrl(projectId, editValue);
|
||||
setWebhookUrl(editValue);
|
||||
setIsEditMode(false);
|
||||
setShowConfirmModal(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to update webhook URL:', err);
|
||||
setError('Failed to update webhook URL');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Section
|
||||
title="Webhook URL"
|
||||
description="In workflow editor, tool calls will be posted to this URL, unless they are mocked."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className={clsx(
|
||||
"border rounded-lg focus-within:ring-2",
|
||||
error
|
||||
? "border-red-500 focus-within:ring-red-500/20"
|
||||
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
|
||||
)}>
|
||||
<Textarea
|
||||
value={webhookUrl || ''}
|
||||
useValidation={true}
|
||||
updateOnBlur={true}
|
||||
validate={validate}
|
||||
onValidatedChange={(value) => {
|
||||
setWebhookUrl(value);
|
||||
updateWebhookUrl(projectId, value);
|
||||
}}
|
||||
placeholder="Enter webhook URL..."
|
||||
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
|
||||
autoResize
|
||||
disabled={loading}
|
||||
/>
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
|
||||
<div className="px-6 pt-4">
|
||||
<h2 className="block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Webhook URL</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">In workflow editor, tool calls will be posted to this URL, unless they are mocked.</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
|
||||
<div className="px-6 pt-4">
|
||||
<h2 className="block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Webhook URL</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Tool calls will be posted to this URL, unless they are mocked.</p>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<div className="space-y-4">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<div className={clsx(
|
||||
"border rounded-lg focus-within:ring-2",
|
||||
error
|
||||
? "border-red-500 focus-within:ring-red-500/20"
|
||||
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
|
||||
)}>
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder="Enter webhook URL..."
|
||||
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="light"
|
||||
onPress={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={() => setShowConfirmModal(true)}
|
||||
disabled={!!error || saving}
|
||||
>
|
||||
Update Webhook URL
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{webhookUrl || 'No webhook URL configured'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
onPress={handleEdit}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectWideChangeConfirmationModal
|
||||
isOpen={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleSave}
|
||||
title="Update Webhook URL"
|
||||
confirmationQuestion="Are you sure you want to update the webhook URL? This will affect all workflow tool calls."
|
||||
confirmButtonText="Update"
|
||||
isLoading={saving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue