From 751a86c34d105cac84497595a44b183d06b82d02 Mon Sep 17 00:00:00 2001 From: arkml Date: Wed, 16 Jul 2025 20:13:03 +0530 Subject: [PATCH 1/2] refactor tools UX: part 1 --- apps/rowboat/app/actions/composio_actions.ts | 210 +++++++- apps/rowboat/app/actions/project_actions.ts | 123 +++++ apps/rowboat/app/lib/agents.ts | 42 +- apps/rowboat/app/lib/composio/composio.ts | 33 ++ apps/rowboat/app/lib/project_tools.ts | 24 +- apps/rowboat/app/lib/types/project_types.ts | 2 - apps/rowboat/app/lib/types/workflow_types.ts | 8 + .../[projectId]/tools/components/Composio.tsx | 38 +- .../tools/components/ComposioToolsPanel.tsx | 70 +-- .../tools/components/CustomServers.tsx | 17 +- .../tools/components/ToolkitCard.tsx | 5 +- .../app/projects/[projectId]/tools/page.tsx | 2 +- .../app/projects/[projectId]/workflow/app.tsx | 64 ++- .../components/ComposioToolsModal.tsx | 74 +++ .../components/ComposioWithCallback.tsx | 279 ++++++++++ .../[projectId]/workflow/entity_list.tsx | 476 +++++++++++++----- .../[projectId]/workflow/mcp_imports.tsx | 151 ++++++ .../[projectId]/workflow/workflow_editor.tsx | 24 + .../projects/layout/components/sidebar.tsx | 19 +- apps/rowboat/package-lock.json | 57 +-- apps/rowboat/package.json | 2 +- 21 files changed, 1462 insertions(+), 258 deletions(-) create mode 100644 apps/rowboat/app/projects/[projectId]/workflow/components/ComposioToolsModal.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/workflow/components/ComposioWithCallback.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/workflow/mcp_imports.tsx diff --git a/apps/rowboat/app/actions/composio_actions.ts b/apps/rowboat/app/actions/composio_actions.ts index b123b80d..7e60a105 100644 --- a/apps/rowboat/app/actions/composio_actions.ts +++ b/apps/rowboat/app/actions/composio_actions.ts @@ -3,6 +3,9 @@ import { z } from "zod"; import { listToolkits as libListToolkits, listTools as libListTools, + searchTools as libSearchTools, + getToolsByIds as libGetToolsByIds, + getTool as libGetTool, getConnectedAccount as libGetConnectedAccount, deleteConnectedAccount as libDeleteConnectedAccount, listAuthConfigs as libListAuthConfigs, @@ -20,6 +23,7 @@ import { ZCredentials, } from "@/app/lib/composio/composio"; import { ComposioConnectedAccount } from "@/app/lib/types/project_types"; +import { WorkflowTool } from "@/app/lib/types/workflow_types"; import { getProjectConfig, projectAuthCheck } from "./project_actions"; import { projectsCollection } from "../lib/mongodb"; @@ -47,6 +51,24 @@ export async function listTools(projectId: string, toolkitSlug: string, cursor: return await libListTools(toolkitSlug, cursor); } +// New efficient search functions + +export async function searchTools(projectId: string, searchQuery: string, cursor: string | null = null, limit?: number): Promise>>> { + await projectAuthCheck(projectId); + return await libSearchTools(searchQuery, cursor, limit); +} + +export async function getToolsByIds(projectId: string, toolSlugs: string[], cursor: string | null = null): Promise>>> { + await projectAuthCheck(projectId); + return await libGetToolsByIds(toolSlugs, cursor); +} + +export async function getTool(projectId: string, toolSlug: string): Promise> { + await projectAuthCheck(projectId); + return await libGetTool(toolSlug); +} + + export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise> { await projectAuthCheck(projectId); @@ -215,12 +237,196 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str const key = `composioConnectedAccounts.${toolkitSlug}`; await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } }); + // Notify other tabs about the tools update (lightweight refresh) + if (typeof window !== 'undefined') { + localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString()); + } + return true; } +// Note: composio tools are now stored in workflow.tools array with isComposio: true +// This function provides backward compatibility by updating workflow tools +export async function getComposioToolsFromWorkflow(projectId: string): Promise[]> { + await projectAuthCheck(projectId); + + // Get the project to access draft workflow + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project || !project.draftWorkflow) { + return []; + } + + // Extract composio tools from workflow and convert back to ZTool format + const composioTools = project.draftWorkflow.tools + .filter(tool => tool.isComposio && tool.composioData) + .map(tool => ({ + slug: tool.composioData!.slug, + name: tool.name, + description: tool.description, + no_auth: tool.composioData!.noAuth, + input_parameters: { + type: 'object' as const, + properties: tool.parameters.properties, + required: tool.parameters.required || [] + }, + toolkit: { + name: tool.composioData!.toolkitName, + slug: tool.composioData!.toolkitSlug, + logo: tool.composioData!.logo, + } + })); + + return composioTools; +} + export async function updateComposioSelectedTools(projectId: string, tools: z.infer[]): Promise { await projectAuthCheck(projectId); - // update project with new selected tools - await projectsCollection.updateOne({ _id: projectId }, { $set: { composioSelectedTools: tools } }); + // Get the project to access draft workflow + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project || !project.draftWorkflow) { + throw new Error(`Project ${projectId} not found or has no draft workflow`); + } + + // Convert Composio tools to workflow tool format + const composioWorkflowTools: z.infer[] = tools.map(tool => ({ + name: tool.slug, + 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: tool.no_auth, + toolkitName: tool.toolkit.name, + toolkitSlug: tool.toolkit.slug, + logo: tool.toolkit.logo, + }, + })); + + // Remove existing composio tools and add new ones + const nonComposioTools = project.draftWorkflow.tools.filter(tool => !tool.isComposio); + const updatedWorkflow = { + ...project.draftWorkflow, + tools: [...nonComposioTools, ...composioWorkflowTools], + lastUpdatedAt: new Date().toISOString() + }; + + // Update the project's draft workflow + const result = await projectsCollection.updateOne( + { _id: projectId }, + { $set: { draftWorkflow: updatedWorkflow } } + ); + + if (result.modifiedCount === 0) { + throw new Error(`Failed to update workflow for project ${projectId}`); + } + + // Notify other tabs about the tools update (lightweight refresh) + if (typeof window !== 'undefined') { + localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString()); + } +} + +// Note: composio mock states are now stored in workflow.composioMockToolkitStates +// This function provides backward compatibility by updating workflow mock states +export async function toggleMockToolkitState(projectId: string, toolkitSlug: string, isMocked: boolean, mockInstructions?: string): Promise { + await projectAuthCheck(projectId); + + // Get the project to access draft workflow + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project || !project.draftWorkflow) { + throw new Error(`Project ${projectId} not found or has no draft workflow`); + } + + const now = new Date().toISOString(); + let updatedMockToolkitStates = { ...(project.draftWorkflow.composioMockToolkitStates || {}) }; + + if (isMocked) { + // Enable mock mode + updatedMockToolkitStates[toolkitSlug] = { + toolkitSlug, + isMocked: true, + mockInstructions: mockInstructions || 'Mock responses using GPT-4.1 based on tool descriptions.', + autoSubmitMockedResponse: false, + createdAt: now, + lastUpdatedAt: now, + }; + } else { + // Disable mock mode - remove the toolkit from the object + delete updatedMockToolkitStates[toolkitSlug]; + } + + // Update the workflow with new mock states + const updatedWorkflow = { + ...project.draftWorkflow, + composioMockToolkitStates: updatedMockToolkitStates, + lastUpdatedAt: now + }; + + // Update the project's draft workflow + const result = await projectsCollection.updateOne( + { _id: projectId }, + { $set: { draftWorkflow: updatedWorkflow } } + ); + + if (result.modifiedCount === 0) { + throw new Error(`Failed to update workflow mock states for project ${projectId}`); + } + + // Notify other tabs about the tools update (lightweight refresh) + if (typeof window !== 'undefined') { + localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString()); + } +} + +// Note: composio mock states are now stored in workflow.composioMockToolkitStates +// This function provides backward compatibility by updating workflow mock states +export async function updateMockToolkitInstructions(projectId: string, toolkitSlug: string, mockInstructions: string): Promise { + await projectAuthCheck(projectId); + + // Get the project to access draft workflow + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project || !project.draftWorkflow) { + throw new Error(`Project ${projectId} not found or has no draft workflow`); + } + + const now = new Date().toISOString(); + let updatedMockToolkitStates = { ...(project.draftWorkflow.composioMockToolkitStates || {}) }; + + // Update the mock instructions for the specified toolkit + if (updatedMockToolkitStates[toolkitSlug]) { + updatedMockToolkitStates[toolkitSlug] = { + ...updatedMockToolkitStates[toolkitSlug], + mockInstructions, + lastUpdatedAt: now + }; + + // Update the workflow with new mock states + const updatedWorkflow = { + ...project.draftWorkflow, + composioMockToolkitStates: updatedMockToolkitStates, + lastUpdatedAt: now + }; + + // Update the project's draft workflow + const result = await projectsCollection.updateOne( + { _id: projectId }, + { $set: { draftWorkflow: updatedWorkflow } } + ); + + if (result.modifiedCount === 0) { + throw new Error(`Failed to update workflow mock instructions for project ${projectId}`); + } + + // Notify other tabs about the tools update + if (typeof window !== 'undefined') { + localStorage.setItem(`tools-updated-${projectId}`, Date.now().toString()); + } + } else { + throw new Error(`Mock toolkit state for ${toolkitSlug} not found in project ${projectId}`); + } } \ No newline at end of file diff --git a/apps/rowboat/app/actions/project_actions.ts b/apps/rowboat/app/actions/project_actions.ts index 66cb58f6..d9809494 100644 --- a/apps/rowboat/app/actions/project_actions.ts +++ b/apps/rowboat/app/actions/project_actions.ts @@ -16,6 +16,13 @@ import { authorizeUserAction } from "./billing_actions"; import { Workflow } from "../lib/types/workflow_types"; import { WorkflowTool } from "../lib/types/workflow_types"; import { collectProjectTools as libCollectProjectTools } from "../lib/project_tools"; +import { + searchTools as libSearchTools, + getToolsByIds as libGetToolsByIds, + getTool as libGetTool, + ZTool, + ZToolkit +} from "../lib/composio/composio"; const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || ''; @@ -306,6 +313,113 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id: return { id: projectId }; } +async function detectAndAddComposioTools(projectId: string, workflow: z.infer) { + // Extract tool mentions from agent instructions + const toolMentionPattern = /\[@tool:([^\]]+)\]\(#mention[^\)]*\)/g; + const mentionedToolNames = new Set(); + + // Scan all agent instructions for tool mentions + for (const agent of workflow.agents || []) { + const instructions = agent.instructions || ""; + let match: RegExpExecArray | null; + while ((match = toolMentionPattern.exec(instructions))) { + mentionedToolNames.add(match[1]); + } + } + + if (mentionedToolNames.size === 0) { + return; // No tool mentions found + } + + console.log(`Found ${mentionedToolNames.size} tool mentions in workflow:`, Array.from(mentionedToolNames)); + + // Search for these tools in Composio using the new efficient search methods + const foundTools: z.infer[] = []; + + try { + // Method 1: Try to get tools directly by their exact slugs/names + const mentionedToolNamesArray = Array.from(mentionedToolNames); + + try { + const directToolsResponse = await libGetToolsByIds(mentionedToolNamesArray); + foundTools.push(...directToolsResponse.items); + console.log(`Found ${directToolsResponse.items.length} tools by direct lookup`); + } catch (error) { + console.log('Direct tool lookup failed, trying search approach'); + } + + // Method 2: For any remaining tools, use search functionality + const foundToolSlugs = new Set(foundTools.map(tool => tool.slug)); + const foundToolNames = new Set(foundTools.map(tool => tool.name)); + const remainingToolNames = mentionedToolNamesArray.filter(name => + !foundToolSlugs.has(name) && !foundToolNames.has(name) + ); + + for (const toolName of remainingToolNames) { + try { + // Search for tools by name/description + const searchResponse = await libSearchTools(toolName, null, 10); + + // Find exact matches by name or slug + const exactMatches = searchResponse.items.filter(tool => + tool.name === toolName || + tool.slug === toolName || + tool.name.toLowerCase() === toolName.toLowerCase() || + tool.slug.toLowerCase() === toolName.toLowerCase() + ); + + if (exactMatches.length > 0) { + foundTools.push(...exactMatches); + console.log(`Found ${exactMatches.length} tools for search term "${toolName}"`); + } else { + console.log(`No exact matches found for tool "${toolName}"`); + } + } catch (error) { + console.error(`Error searching for tool "${toolName}":`, error); + } + } + + } catch (error) { + console.error('Error searching for Composio tools:', error); + return; + } + + if (foundTools.length > 0) { + console.log(`Adding ${foundTools.length} Composio tools to workflow`); + + // Remove duplicates based on slug + const uniqueTools = foundTools.filter((tool, index, self) => + index === self.findIndex(t => t.slug === tool.slug) + ); + + // Convert Composio tools to workflow tool format + const composioWorkflowTools: z.infer[] = uniqueTools.map(tool => ({ + name: tool.slug, + 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: tool.no_auth, + toolkitName: tool.toolkit.name, + toolkitSlug: tool.toolkit.slug, + logo: tool.toolkit.logo, + }, + })); + + // Add these tools to the workflow.tools array + workflow.tools = [...workflow.tools, ...composioWorkflowTools]; + + console.log(`Added ${composioWorkflowTools.length} Composio tools to workflow`); + } else { + console.log('No matching Composio tools found for the mentioned tool names'); + } +} + export async function createProjectFromWorkflowJson(formData: FormData): Promise<{ id: string } | { billingError: string }> { const user = await authCheck(); const workflowJson = formData.get('workflowJson') as string; @@ -327,6 +441,15 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise return response; } const projectId = response.id; + + // Automatically detect and add Composio tools mentioned in agent instructions + try { + await detectAndAddComposioTools(projectId, workflow); + } catch (error) { + // Log error but don't fail the import if tool detection fails + console.error('Failed to auto-detect Composio tools:', error); + } + return { id: projectId }; } diff --git a/apps/rowboat/app/lib/agents.ts b/apps/rowboat/app/lib/agents.ts index ef06efc0..618a5e3e 100644 --- a/apps/rowboat/app/lib/agents.ts +++ b/apps/rowboat/app/lib/agents.ts @@ -17,6 +17,7 @@ import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } f import { qdrantClient } from '../lib/qdrant'; import { EmbeddingRecord } from "./types/datasource_types"; import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./types/workflow_types"; +import { Project } from "./types/project_types"; import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS } from "./agent_instructions"; import { PrefixLogger } from "./utils"; import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "./types/types"; @@ -280,6 +281,8 @@ async function invokeComposioTool( name: string, composioData: z.infer['composioData'] & {}, input: any, + workflow: z.infer, + toolDescription?: string, ) { logger = logger.child(`invokeComposioTool`); logger.log(`projectId: ${projectId}`); @@ -288,12 +291,36 @@ async function invokeComposioTool( const { slug, toolkitSlug, noAuth } = composioData; + // Get project configuration to check for connected accounts (still stored in project) + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + throw new Error(`project ${projectId} not found`); + } + + // Check if toolkit is in mock mode (now from workflow) + const mockState = workflow.composioMockToolkitStates?.[toolkitSlug]; + if (mockState?.isMocked) { + logger.log(`toolkit ${toolkitSlug} is in mock mode, using mock response`); + + // Use the existing invokeMockTool function to generate a mock response + const mockInstructions = mockState.mockInstructions || 'Mock responses using GPT-4.1 based on tool descriptions.'; + const description = toolDescription || `${name} tool from ${toolkitSlug} toolkit`; + + const mockResponse = await invokeMockTool( + logger, + name, + JSON.stringify(input), + description, + mockInstructions + ); + + logger.log(`mock tool result: ${mockResponse}`); + return mockResponse; + } + + // Normal execution path - check for authentication let connectedAccountId: string | undefined = undefined; if (!noAuth) { - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project) { - throw new Error(`project ${projectId} not found`); - } connectedAccountId = project.composioConnectedAccounts?.[toolkitSlug]?.id; if (!connectedAccountId) { throw new Error(`connected account id not found for project ${projectId} and toolkit ${toolkitSlug}`); @@ -452,7 +479,8 @@ function createMcpTool( function createComposioTool( logger: PrefixLogger, config: z.infer, - projectId: string + projectId: string, + workflow: z.infer ): Tool { const { name, description, parameters, composioData } = config; @@ -472,7 +500,7 @@ function createComposioTool( }, async execute(input: any) { try { - const result = await invokeComposioTool(logger, projectId, name, composioData, input); + const result = await invokeComposioTool(logger, projectId, name, composioData, input, workflow, description); return JSON.stringify({ result, }); @@ -813,7 +841,7 @@ function createTools( tools[toolName] = createMcpTool(logger, config, projectId); logger.log(`created mcp tool: ${toolName}`); } else if (config.isComposio) { - tools[toolName] = createComposioTool(logger, config, projectId); + tools[toolName] = createComposioTool(logger, config, projectId, workflow); logger.log(`created composio tool: ${toolName}`); } else if (config.mockTool) { tools[toolName] = createMockTool(logger, config); diff --git a/apps/rowboat/app/lib/composio/composio.ts b/apps/rowboat/app/lib/composio/composio.ts index 3441f9fb..64c07679 100644 --- a/apps/rowboat/app/lib/composio/composio.ts +++ b/apps/rowboat/app/lib/composio/composio.ts @@ -291,6 +291,39 @@ export async function listTools(toolkitSlug: string, cursor: string | null = nul return composioApiCall(ZListResponse(ZTool), url.toString()); } +export async function searchTools(searchQuery: string, cursor: string | null = null, limit: number = 50): Promise>>> { + const url = new URL(`${BASE_URL}/tools`); + + // set params + url.searchParams.set("search", searchQuery); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + url.searchParams.set("limit", limit.toString()); + + // fetch + return composioApiCall(ZListResponse(ZTool), url.toString()); +} + +export async function getToolsByIds(toolSlugs: string[], cursor: string | null = null): Promise>>> { + const url = new URL(`${BASE_URL}/tools`); + + // set params - pass tool slugs as comma-separated string + url.searchParams.set("tool_slugs", toolSlugs.join(",")); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + // fetch + return composioApiCall(ZListResponse(ZTool), url.toString()); +} + +export async function getTool(toolSlug: string): Promise> { + const url = new URL(`${BASE_URL}/tools/${toolSlug}`); + return composioApiCall(ZTool, url.toString()); +} + + export async function listAuthConfigs(toolkitSlug: string, cursor: string | null = null, managedOnly: boolean = false): Promise>>> { const url = new URL(`${BASE_URL}/auth_configs`); url.searchParams.set("toolkit_slug", toolkitSlug); diff --git a/apps/rowboat/app/lib/project_tools.ts b/apps/rowboat/app/lib/project_tools.ts index a65ba535..7d8fb8f5 100644 --- a/apps/rowboat/app/lib/project_tools.ts +++ b/apps/rowboat/app/lib/project_tools.ts @@ -33,28 +33,8 @@ export async function collectProjectTools(projectId: string): Promise mockInstructions + composioMockToolkitStates: z.record(z.string(), z.object({ + toolkitSlug: z.string(), + isMocked: z.boolean(), + mockInstructions: z.string().optional(), + autoSubmitMockedResponse: z.boolean().default(false), + createdAt: z.string().datetime(), + lastUpdatedAt: z.string().datetime(), + })).optional(), }); export const WorkflowTemplate = Workflow .omit({ diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx index dbb7c5f1..380bb5b7 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx @@ -5,7 +5,7 @@ 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 { listToolkits, listTools, updateComposioSelectedTools, getComposioToolsFromWorkflow } 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'; @@ -30,6 +30,7 @@ export function Composio() { const [selectedToolkit, setSelectedToolkit] = useState(null); const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false); const [savingTools, setSavingTools] = useState(false); + const [composioSelectedTools, setComposioSelectedTools] = useState[]>([]); const loadProjectConfig = useCallback(async () => { try { @@ -41,6 +42,15 @@ 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[] = []; @@ -87,15 +97,16 @@ export function Composio() { const handleProjectConfigUpdate = useCallback(() => { loadProjectConfig(); - }, [loadProjectConfig]); + loadComposioSelectedTools(); + }, [loadProjectConfig, loadComposioSelectedTools]); const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer[]) => { if (!projectId) return; setSavingTools(true); try { - // Get existing selected tools from project config - const existingSelectedTools = projectConfig?.composioSelectedTools || []; + // 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])); @@ -110,22 +121,22 @@ export function Composio() { await updateComposioSelectedTools(projectId, mergedSelectedTools); - // Refresh project config to get updated data - await loadProjectConfig(); + // Refresh data to get updated tools + await loadComposioSelectedTools(); } catch (error) { console.error('Error saving tool selection:', error); } finally { setSavingTools(false); } - }, [projectId, projectConfig, loadProjectConfig]); + }, [projectId, composioSelectedTools, loadComposioSelectedTools]); const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => { if (!projectId) return; setSavingTools(true); try { - // Get existing selected tools from project config - const existingSelectedTools = projectConfig?.composioSelectedTools || []; + // Get existing selected tools from workflow + const existingSelectedTools = composioSelectedTools; // Filter out all tools from the specified toolkit const filteredSelectedTools = existingSelectedTools.filter(tool => @@ -134,18 +145,19 @@ export function Composio() { await updateComposioSelectedTools(projectId, filteredSelectedTools); - // Refresh project config to get updated data - await loadProjectConfig(); + // Refresh data to get updated tools + await loadComposioSelectedTools(); } catch (error) { console.error('Error removing toolkit tools:', error); } finally { setSavingTools(false); } - }, [projectId, projectConfig, loadProjectConfig]); + }, [projectId, composioSelectedTools, loadComposioSelectedTools]); useEffect(() => { loadProjectConfig(); - }, [loadProjectConfig]); + loadComposioSelectedTools(); + }, [loadProjectConfig, loadComposioSelectedTools]); useEffect(() => { loadAllToolkits(); diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx index 7cfb5348..e05140a7 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx @@ -5,7 +5,7 @@ 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 } from '@/app/actions/composio_actions'; +import { listTools, deleteConnectedAccount, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions'; import { z } from 'zod'; import { ZTool, ZListResponse } from '@/app/lib/composio/composio'; import { SlidePanel } from '@/components/ui/slide-panel'; @@ -57,6 +57,7 @@ export function ComposioToolsPanel({ const [hasChanges, setHasChanges] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); const [isProcessingAuth, setIsProcessingAuth] = useState(false); + const [composioSelectedTools, setComposioSelectedTools] = useState([]); const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => { try { @@ -80,6 +81,15 @@ 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); + } + }, [projectId]); + const handleNextPage = useCallback(async () => { if (!nextCursor || !toolkit) return; @@ -164,14 +174,21 @@ export function ComposioToolsPanel({ } }, [onClose, hasChanges]); - // Initialize selected tools from project config when opening the panel + // Initialize selected tools from workflow when opening the panel useEffect(() => { - if (toolkit && isOpen && projectConfig?.composioSelectedTools) { - const toolSlugs = new Set(projectConfig.composioSelectedTools.map(tool => tool.slug)); + 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, isOpen, projectConfig]); + }, [toolkit, composioSelectedTools]); useEffect(() => { if (toolkit && isOpen) { @@ -210,44 +227,46 @@ export function ComposioToolsPanel({

{isToolkitConnected ? 'Toolkit Connected' : 'Authentication Required'}

{isToolkitConnected ? 'You can select and use tools from this toolkit' - : 'Connect your account to access and use tools' + : 'You can select tools now. Authentication will be required in the build view to use them.' }

- + {isToolkitConnected && ( + + )}
)} @@ -263,7 +282,7 @@ export function ComposioToolsPanel({ size="sm" color="primary" onPress={handleSaveTools} - disabled={isSaving || !isToolkitConnected} + disabled={isSaving} isLoading={isSaving} > Save Changes @@ -283,17 +302,12 @@ export function ComposioToolsPanel({ ) : (
{tools.map((tool) => ( -
+
handleToolSelectionChange(tool.slug, selected)} size="sm" - isDisabled={!isToolkitConnected} />

diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx index 366e58d2..ccf95d69 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx @@ -24,7 +24,7 @@ import { Modal } from '@/components/ui/modal'; type McpServerType = z.infer; type McpToolType = z.infer['tools'][number]; -export function CustomServers() { +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'); @@ -92,6 +92,9 @@ export function CustomServers() { return s; }); }); + + // Notify parent component about tool updates + onToolsUpdated?.(); } catch (err) { console.error('Toggle failed:', { server: server.name, error: err }); } finally { @@ -161,6 +164,9 @@ export function CustomServers() { // 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); @@ -207,6 +213,9 @@ export function CustomServers() { // 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.'); @@ -229,6 +238,9 @@ export function CustomServers() { 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.'); @@ -271,6 +283,9 @@ export function CustomServers() { }); setHasToolChanges(false); + + // Notify parent component about tool updates + onToolsUpdated?.(); } catch (error) { console.error('Error saving tool selection:', error); } finally { diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx index 9fc7e278..a0147ac3 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx @@ -52,9 +52,8 @@ export function ToolkitCard({ }, [onManageTools]); // Calculate selected tools count for this toolkit - const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool => - tool.toolkit.slug === toolkit.slug - ).length || 0; + // TODO: Update to use workflow-based tools count + const selectedToolsCount = 0; return (
diff --git a/apps/rowboat/app/projects/[projectId]/tools/page.tsx b/apps/rowboat/app/projects/[projectId]/tools/page.tsx index 2fa0d764..c2bdd9d4 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/page.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/page.tsx @@ -16,7 +16,7 @@ export default async function ToolsPage() {
Loading...
}> diff --git a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx index aeec01a3..21d9c521 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/app.tsx @@ -1,6 +1,7 @@ "use client"; import { MCPServer, WithStringId } from "../../../lib/types/types"; import { DataSource } from "../../../lib/types/datasource_types"; +import { Project } from "../../../lib/types/project_types"; import { z } from "zod"; import { useCallback, useEffect, useState } from "react"; import { WorkflowEditor } from "./workflow_editor"; @@ -11,7 +12,6 @@ import { getProjectConfig } from "@/app/actions/project_actions"; import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types"; import { getEligibleModels } from "@/app/actions/billing_actions"; import { ModelsResponse } from "@/app/lib/types/billing_types"; -import { Project } from "@/app/lib/types/project_types"; export function App({ projectId, @@ -26,6 +26,7 @@ export function App({ const [project, setProject] = useState> | null>(null); const [dataSources, setDataSources] = useState>[] | null>(null); const [projectTools, setProjectTools] = useState[] | null>(null); + const [projectConfig, setProjectConfig] = useState | null>(null); const [loading, setLoading] = useState(false); const [eligibleModels, setEligibleModels] = useState | "*">("*"); const [projectMcpServers, setProjectMcpServers] = useState>>([]); @@ -66,11 +67,70 @@ export function App({ setLoading(false); }, [projectId]); + const handleProjectToolsUpdate = useCallback(async () => { + // Lightweight refresh for tool-only updates + const [projectConfig, projectTools] = await Promise.all([ + getProjectConfig(projectId), + collectProjectTools(projectId), + ]); + + setProject(projectConfig); + setProjectConfig(projectConfig); + setProjectTools(projectTools); + + // Update MCP servers if they changed + if (projectConfig.mcpServers) { + setProjectMcpServers(projectConfig.mcpServers); + } + + // Update webhook URL if it changed + if (projectConfig.webhookUrl) { + setWebhookUrl(projectConfig.webhookUrl); + } + }, [projectId]); // Add this useEffect for initial load useEffect(() => { loadData(); }, [mode, loadData, projectId]); + // Add focus-based refresh to handle cross-page updates + useEffect(() => { + const handleFocus = () => { + // Refresh data when user returns to this page/tab + loadData(); + }; + + const handleVisibilityChange = () => { + if (!document.hidden) { + loadData(); + } + }; + + const handleStorageChange = (e: StorageEvent) => { + // Listen for tool updates from other tabs + if (e.key === `tools-updated-${projectId}` && e.newValue) { + loadData(); + // Clear the flag + localStorage.removeItem(`tools-updated-${projectId}`); + } else if (e.key === `tools-light-refresh-${projectId}` && e.newValue) { + // Lightweight refresh for tool-only updates + handleProjectToolsUpdate(); + // Clear the flag + localStorage.removeItem(`tools-light-refresh-${projectId}`); + } + }; + + window.addEventListener('focus', handleFocus); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('storage', handleStorageChange); + + return () => { + window.removeEventListener('focus', handleFocus); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('storage', handleStorageChange); + }; + }, [loadData, handleProjectToolsUpdate, projectId]); + function handleSetMode(mode: 'draft' | 'live') { setMode(mode); } @@ -95,6 +155,7 @@ export function App({ workflow={workflow} dataSources={dataSources} projectTools={projectTools} + projectConfig={projectConfig || project} useRag={useRag} mcpServerUrls={projectMcpServers} toolWebhookUrl={webhookUrl} @@ -102,6 +163,7 @@ export function App({ eligibleModels={eligibleModels} onChangeMode={handleSetMode} onRevertToLive={handleRevertToLive} + onProjectToolsUpdated={handleProjectToolsUpdate} />} } diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioToolsModal.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioToolsModal.tsx new file mode 100644 index 00000000..3e8a409d --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioToolsModal.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React, { useState } from 'react'; +import { Modal, ModalContent, ModalHeader, ModalBody, Tabs, Tab } from '@heroui/react'; +import { Composio } from '../../tools/components/Composio'; +import { ComposioWithCallback } from './ComposioWithCallback'; +import { CustomServers } from '../../tools/components/CustomServers'; +import { WebhookConfig } from '../../tools/components/WebhookConfig'; +import type { Key } from 'react'; + +interface ComposioToolsModalProps { + isOpen: boolean; + onClose: () => void; + projectId: string; + onToolsUpdated?: () => void; +} + +export function ComposioToolsModal({ isOpen, onClose, projectId, onToolsUpdated }: ComposioToolsModalProps) { + const [activeTab, setActiveTab] = useState('composio'); + + const handleTabChange = (key: Key) => { + setActiveTab(key.toString()); + }; + + return ( + + + +

+ Tools +

+
+ + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioWithCallback.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioWithCallback.tsx new file mode 100644 index 00000000..e2d5a92d --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/ComposioWithCallback.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Info, RefreshCw, Search } from 'lucide-react'; +import clsx from 'clsx'; +import { listToolkits, listTools, updateComposioSelectedTools, getComposioToolsFromWorkflow } 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 '../../tools/components/ComposioToolsPanel'; +import { ToolkitCard } from '../../tools/components/ToolkitCard'; + +type ToolkitType = z.infer; +type ToolkitListResponse = z.infer>>; +type ProjectType = z.infer; + +interface ComposioWithCallbackProps { + projectId: string; + onToolsUpdated?: () => void; +} + +export function ComposioWithCallback({ projectId, onToolsUpdated }: ComposioWithCallbackProps) { + + const [toolkits, setToolkits] = useState([]); + const [projectConfig, setProjectConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedToolkit, setSelectedToolkit] = useState(null); + const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false); + const [savingTools, setSavingTools] = useState(false); + const [composioSelectedTools, setComposioSelectedTools] = useState[]>([]); + + 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 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[] = []; + + try { + setLoading(true); + + do { + const response: ToolkitListResponse = await listToolkits(projectId, cursor); + allToolkits = [...allToolkits, ...response.items]; + cursor = response.next_cursor; + } while (cursor !== null); + + 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(); + loadComposioSelectedTools(); + }, [loadProjectConfig, loadComposioSelectedTools]); + + const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer[]) => { + 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(); + + // Notify parent component that tools were updated + if (onToolsUpdated) { + onToolsUpdated(); + } + } catch (error) { + console.error('Error saving tool selection:', error); + } finally { + setSavingTools(false); + } + }, [projectId, composioSelectedTools, loadComposioSelectedTools, onToolsUpdated]); + + 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(); + + // Notify parent component that tools were updated + if (onToolsUpdated) { + onToolsUpdated(); + } + } catch (error) { + console.error('Error removing toolkit tools:', error); + } finally { + setSavingTools(false); + } + }, [projectId, composioSelectedTools, loadComposioSelectedTools, onToolsUpdated]); + + useEffect(() => { + loadProjectConfig(); + }, [loadProjectConfig]); + + useEffect(() => { + loadAllToolkits(); + loadComposioSelectedTools(); + }, [loadAllToolkits, loadComposioSelectedTools]); + + 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) + ); + }).sort((a, b) => { + // Sort by actual connection status first (only connected tools, not no-auth) + const aConnected = !a.no_auth && projectConfig?.composioConnectedAccounts?.[a.slug]?.status === 'ACTIVE'; + const bConnected = !b.no_auth && projectConfig?.composioConnectedAccounts?.[b.slug]?.status === 'ACTIVE'; + + if (aConnected && !bConnected) return -1; + if (!aConnected && bConnected) return 1; + + // If both have same connection status, maintain original order (don't sort alphabetically) + return 0; + }); + + if (loading) { + return ( +
+
+

Loading Composio toolkits...

+
+ ); + } + + if (error) { + return ( +
+

+ {error} +

+ +
+ ); + } + + return ( +
+ {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Toolkits Grid */} +
+ {filteredToolkits.length === 0 ? ( +
+

+ {searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'} +

+
+ ) : ( +
+ {filteredToolkits.map((toolkit) => { + const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE'; + const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id; + + return ( + handleManageTools(toolkit)} + onProjectConfigUpdate={handleProjectConfigUpdate} + onRemoveToolkitTools={handleRemoveToolkitTools} + /> + ); + })} +
+ )} +
+ + {/* Tools Panel */} + +
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx b/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx index 03c256e1..445b3c75 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/entity_list.tsx @@ -1,8 +1,10 @@ import { z } from "zod"; -import { WorkflowPrompt, WorkflowAgent, WorkflowTool } from "../../../lib/types/workflow_types"; +import { WorkflowPrompt, WorkflowAgent, WorkflowTool, Workflow } from "../../../lib/types/workflow_types"; +import { Project } from "../../../lib/types/project_types"; import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react"; import { useRef, useEffect, useState } from "react"; -import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, Eye } from "lucide-react"; +import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, LinkIcon, UnlinkIcon, TestTube, Play, MoreVertical, Eye } from "lucide-react"; +import { Tooltip } from "@heroui/react"; import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -13,6 +15,9 @@ import { clsx } from "clsx"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { ServerLogo } from '../tools/components/MCPServersCommon'; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react"; +import { ComposioToolsModal } from './components/ComposioToolsModal'; +import { ToolkitAuthModal } from '../tools/components/ToolkitAuthModal'; +import { deleteConnectedAccount, toggleMockToolkitState } from '@/app/actions/composio_actions'; // Reduced gap size to match Cursor's UI const GAP_SIZE = 4; // 1 unit * 4px (tailwind's default spacing unit) @@ -35,6 +40,7 @@ interface EntityListProps { tools: z.infer[]; projectTools: z.infer[]; prompts: z.infer[]; + workflow: z.infer; selectedEntity: { type: "agent" | "tool" | "prompt" | "visualise"; name: string; @@ -52,6 +58,8 @@ interface EntityListProps { onDeleteTool: (name: string) => void; onDeletePrompt: (name: string) => void; onShowVisualise: (name: string) => void; + onProjectToolsUpdated?: () => void; + projectConfig?: z.infer; } interface EmptyStateProps { @@ -95,7 +103,7 @@ const ListItemWithMenu = ({ }) => { return (
-
+
{mcpServerName ? ( } + className="h-3 w-3" + fallback={} /> ) : icon}
- {name} + {name} -
+
{statusLabel} -
- {menuContent} -
+ {menuContent}
); @@ -166,43 +172,56 @@ const ServerCard = ({ const [isExpanded, setIsExpanded] = useState(false); return ( -
- - {isExpanded && ( -
- {tools.map((tool, index) => ( - onSelectTool(tool.name)} - selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} - mcpServerName={serverName} - menuContent={ - - } +
+
+ + +
+ + {isExpanded && ( +
+ {tools.map((tool, index) => ( +
+ onSelectTool(tool.name)} + selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} + mcpServerName={serverName} + menuContent={ +
+ +
+ } + /> +
))}
)} @@ -222,6 +241,7 @@ export function EntityList({ tools, projectTools, prompts, + workflow, selectedEntity, startAgentName, onSelectAgent, @@ -235,7 +255,9 @@ export function EntityList({ onDeleteAgent, onDeleteTool, onDeletePrompt, + onProjectToolsUpdated, projectId, + projectConfig, onReorderAgents, onShowVisualise, }: EntityListProps & { @@ -243,6 +265,7 @@ export function EntityList({ onReorderAgents: (agents: z.infer[]) => void }) { const [showAgentTypeModal, setShowAgentTypeModal] = useState(false); + const [showComposioToolsModal, setShowComposioToolsModal] = useState(false); const handleAddAgentWithType = (agentType: 'internal' | 'user_facing') => { onAddAgent({ @@ -496,20 +519,36 @@ export function EntityList({ Tools
- +
+ + +
} > @@ -536,17 +575,37 @@ export function EntityList({ return ( <> - {/* Show composio cards */} - {Object.values(composioTools).map((card) => ( - - ))} + {/* Show composio cards - ordered by status */} + {Object.values(composioTools) + .sort((a, b) => { + // Helper function to get toolkit status priority + const getStatusPriority = (toolkit: ComposioToolkit) => { + const hasAuth = toolkit.tools.some(tool => tool.composioData && !tool.composioData.noAuth); + const isConnected = !hasAuth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE'; + const isMocked = workflow?.composioMockToolkitStates?.[toolkit.slug]?.isMocked || false; + + // Priority: Connected (1) > Mock (2) > Disconnected (3) + if (isMocked) return 2; + if (isConnected) return 1; + return 3; + }; + + return getStatusPriority(a) - getStatusPriority(b); + }) + .map((card) => ( + + ))} {/* Show MCP server cards */} {Object.entries(serverTools).map(([serverName, tools]) => ( @@ -682,6 +741,12 @@ export function EntityList({ onClose={() => setShowAgentTypeModal(false)} onConfirm={handleAddAgentWithType} /> + setShowComposioToolsModal(false)} + projectId={projectId} + onToolsUpdated={onProjectToolsUpdated} + />
); } @@ -769,6 +834,10 @@ interface ComposioCardProps { onSelectTool: (name: string) => void; onDeleteTool: (name: string) => void; selectedRef: React.RefObject; + projectConfig?: z.infer; + projectId: string; + workflow: z.infer; + onProjectToolsUpdated?: () => void; } const ComposioCard = ({ @@ -777,70 +846,231 @@ const ComposioCard = ({ onSelectTool, onDeleteTool, selectedRef, + projectConfig, + projectId, + workflow, + onProjectToolsUpdated, }: ComposioCardProps) => { const [isExpanded, setIsExpanded] = useState(false); + const [showAuthModal, setShowAuthModal] = useState(false); + const [isProcessingAuth, setIsProcessingAuth] = useState(false); + const [isProcessingMock, setIsProcessingMock] = useState(false); + + // Check if the toolkit requires authentication + const hasToolkitWithAuth = card.tools.some(tool => tool.composioData && !tool.composioData.noAuth); + + // Check if toolkit is connected + const isToolkitConnected = !hasToolkitWithAuth || projectConfig?.composioConnectedAccounts?.[card.slug]?.status === 'ACTIVE'; + + // Check if toolkit is mocked + const isToolkitMocked = workflow?.composioMockToolkitStates?.[card.slug]?.isMocked || false; + + const handleConnect = () => { + setShowAuthModal(true); + }; + + const handleDisconnect = async () => { + const connectedAccountId = projectConfig?.composioConnectedAccounts?.[card.slug]?.id; + + setIsProcessingAuth(true); + try { + if (connectedAccountId) { + await deleteConnectedAccount(projectId, card.slug, connectedAccountId); + onProjectToolsUpdated?.(); + } + } catch (err: any) { + console.error('Disconnect failed:', err); + } finally { + setIsProcessingAuth(false); + } + }; + + const handleAuthComplete = () => { + setShowAuthModal(false); + onProjectToolsUpdated?.(); + }; + + const handleToggleMock = async () => { + setIsProcessingMock(true); + try { + await toggleMockToolkitState(projectId, card.slug, !isToolkitMocked); + onProjectToolsUpdated?.(); + } catch (err: any) { + console.error('Mock toggle failed:', err); + } finally { + setIsProcessingMock(false); + } + }; return ( -
- + + {/* Compact Status Badge */} + +
+ {isToolkitMocked ? 'M' : isToolkitConnected ? '●' : '○'} +
+
+ + {/* Actions Dropdown - only show on hover */} +
+ + + + + { + switch (key) { + case 'mock': + handleToggleMock(); + break; + case 'connect': + handleConnect(); + break; + case 'disconnect': + handleDisconnect(); + break; + } + }} + disabledKeys={[ + ...(isProcessingMock ? ['mock'] : []), + ...(isProcessingAuth ? ['connect', 'disconnect'] : []), + ...(hasToolkitWithAuth && !isToolkitMocked && isToolkitConnected ? [] : ['disconnect']), + ...(hasToolkitWithAuth && !isToolkitConnected ? [] : ['connect']) + ]} + > +
+ ) : isToolkitMocked ? ( + + ) : ( + + ) + } + > + {isProcessingMock + ? (isToolkitMocked ? 'Disabling Mock...' : 'Enabling Mock...') + : (isToolkitMocked ? 'Switch to Real Mode' : 'Switch to Mock Mode') + } + + +
+ ) : ( + + ) + } + > + {isProcessingAuth ? 'Disconnecting...' : 'Disconnect'} + + +
+ ) : ( + + ) + } + > + {isProcessingAuth ? 'Connecting...' : 'Connect'} + + + +
- {isExpanded && ( -
+
{card.tools.map((tool, index) => ( - onSelectTool(tool.name)} - disabled={tool.isLibrary} - selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} - icon={ - card.logo ? ( -
- + onSelectTool(tool.name)} + disabled={tool.isLibrary} + selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} + icon={ +
+ } + menuContent={ +
+
- ) : ( - - ) - } - menuContent={ - - } - /> + } + /> +
))}
)} -
+

+ + {/* Auth Modal */} + {hasToolkitWithAuth && ( + setShowAuthModal(false)} + toolkitSlug={card.slug} + projectId={projectId} + onComplete={handleAuthComplete} + /> + )} + ); }; diff --git a/apps/rowboat/app/projects/[projectId]/workflow/mcp_imports.tsx b/apps/rowboat/app/projects/[projectId]/workflow/mcp_imports.tsx new file mode 100644 index 00000000..c6700480 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/workflow/mcp_imports.tsx @@ -0,0 +1,151 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Checkbox } from "@heroui/react"; +import { z } from "zod"; +import { WorkflowTool } from "@/app/lib/types/workflow_types"; +import { RefreshCwIcon } from "lucide-react"; +import { fetchMcpTools } from "@/app/actions/mcp_actions"; + +interface McpImportToolsProps { + projectId: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onImport: (tools: z.infer[]) => void; +} + +export function McpImportTools({ projectId, isOpen, onOpenChange, onImport }: McpImportToolsProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tools, setTools] = useState[]>([]); + const [selectedTools, setSelectedTools] = useState>(new Set()); + + const process = useCallback(async () => { + setLoading(true); + setError(null); + setSelectedTools(new Set()); + try { + const result = await fetchMcpTools(projectId); + setTools(result); + // Select all tools by default + setSelectedTools(new Set(result.map((_, index) => index))); + } catch (error) { + setError(`Unable to fetch tools: ${error}`); + } finally { + setLoading(false); + } + }, [projectId]); + + useEffect(() => { + console.log("mcp import tools useEffect", isOpen); + if (isOpen) { + process(); + } + }, [isOpen, process]); + + return ( + + + {(onClose) => ( + <> + Import from MCP servers + + {loading &&
+ + Fetching tools... +
} + {error &&
+ {error} + +
} + {!loading && !error && <> +
+
+ {tools.length === 0 ? "No tools found" : `Found ${tools.length} tools:`} +
+ +
+ {tools.length > 0 &&
+
+
+ 0 && selectedTools.size < tools.length} + onValueChange={(checked) => { + if (checked) { + setSelectedTools(new Set(tools.map((_, i) => i))); + } else { + setSelectedTools(new Set()); + } + }} + /> +
+
Server
+
Tool Name
+
+
+ {tools.map((t, index) => ( +
+
+ { + const newSelected = new Set(selectedTools); + if (checked) { + newSelected.add(index); + } else { + newSelected.delete(index); + } + setSelectedTools(newSelected); + }} + /> +
+
+
+ {t.mcpServerName} +
+
+
{t.name}
+
+ ))} +
+
} + {tools.length > 0 && ( +
+ {selectedTools.size} of {tools.length} tools selected +
+ )} + } +
+ + + {tools.length > 0 && } + + + )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index 5f5a8bce..94ba6696 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -3,6 +3,7 @@ import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, c import { MCPServer, Message, WithStringId } from "../../../lib/types/types"; import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent } from "../../../lib/types/workflow_types"; import { DataSource } from "../../../lib/types/datasource_types"; +import { Project } from "../../../lib/types/project_types"; import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer'; import { AgentConfig } from "../entities/agent_config"; import { ToolConfig } from "../entities/tool_config"; @@ -142,6 +143,9 @@ export type Action = { type: "show_visualise"; } | { type: "hide_visualise"; +} | { + type: "sync_workflow"; + workflow: z.infer; }; function reducer(state: State, action: Action): State { @@ -207,6 +211,13 @@ function reducer(state: State, action: Action): State { }); break; } + case "sync_workflow": { + newState = produce(state, draft => { + draft.present.workflow = action.workflow; + draft.present.lastUpdatedAt = action.workflow.lastUpdatedAt; + }); + break; + } case "reorder_agents": { const newState = produce(state.present, draft => { draft.workflow.agents = action.agents; @@ -579,10 +590,12 @@ export function WorkflowEditor({ toolWebhookUrl, defaultModel, projectTools, + projectConfig, eligibleModels, isLive, onChangeMode, onRevertToLive, + onProjectToolsUpdated, }: { projectId: string; dataSources: WithStringId>[]; @@ -592,10 +605,12 @@ export function WorkflowEditor({ toolWebhookUrl: string; defaultModel: string; projectTools: z.infer[]; + projectConfig: z.infer; eligibleModels: z.infer | "*"; isLive: boolean; onChangeMode: (mode: 'draft' | 'live') => void; onRevertToLive: () => void; + onProjectToolsUpdated?: () => void; }) { const [state, dispatch] = useReducer(reducer, { @@ -615,6 +630,12 @@ export function WorkflowEditor({ isLive, } }); + + // Sync workflow prop changes with reducer state (e.g., when composio tools are updated) + useEffect(() => { + dispatch({ type: "sync_workflow", workflow }); + }, [workflow]); + const [chatMessages, setChatMessages] = useState[]>([]); const updateChatMessages = useCallback((messages: z.infer[]) => { setChatMessages(messages); @@ -980,6 +1001,7 @@ export function WorkflowEditor({ tools={state.present.workflow.tools} projectTools={projectTools} prompts={state.present.workflow.prompts} + workflow={state.present.workflow} selectedEntity={ state.present.selection && (state.present.selection.type === "agent" || @@ -1002,6 +1024,8 @@ export function WorkflowEditor({ onDeletePrompt={handleDeletePrompt} onShowVisualise={handleShowVisualise} projectId={projectId} + onProjectToolsUpdated={onProjectToolsUpdated} + projectConfig={projectConfig} onReorderAgents={handleReorderAgents} />
diff --git a/apps/rowboat/app/projects/layout/components/sidebar.tsx b/apps/rowboat/app/projects/layout/components/sidebar.tsx index 3859e8eb..3960b4fa 100644 --- a/apps/rowboat/app/projects/layout/components/sidebar.tsx +++ b/apps/rowboat/app/projects/layout/components/sidebar.tsx @@ -14,8 +14,7 @@ import { ChevronRightIcon, Moon, Sun, - HelpCircle, - Wrench + HelpCircle } from "lucide-react"; import { getProjectConfig } from "@/app/actions/project_actions"; import { useTheme } from "@/app/providers/theme-provider"; @@ -69,13 +68,6 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, icon: DatabaseIcon, requiresProject: true }] : []), - { - href: 'tools', - label: 'Tools', - icon: Wrench, - requiresProject: true, - beta: true - }, { href: 'config', label: 'Settings', @@ -162,14 +154,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false, `} /> {!collapsed && ( - <> - {item.label} - {item.beta && ( - - BETA - - )} - + {item.label} )} diff --git a/apps/rowboat/package-lock.json b/apps/rowboat/package-lock.json index 39ba889c..ffa101b8 100644 --- a/apps/rowboat/package-lock.json +++ b/apps/rowboat/package-lock.json @@ -43,7 +43,7 @@ "ioredis": "^5.6.1", "jose": "^5.9.6", "lucide-react": "^0.465.0", - "mermaid": "^11.8.1", + "mermaid": "^11.9.0", "mongodb": "^6.8.0", "next": "15.3.4", "openai": "^4.67.2", @@ -1396,7 +1396,6 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", - "license": "Apache-2.0", "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", @@ -1407,7 +1406,6 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", - "license": "Apache-2.0", "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" @@ -1416,20 +1414,17 @@ "node_modules/@chevrotain/regexp-to-ast": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", - "license": "Apache-2.0" + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==" }, "node_modules/@chevrotain/types": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", - "license": "Apache-2.0" + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==" }, "node_modules/@chevrotain/utils": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", - "license": "Apache-2.0" + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==" }, "node_modules/@composio/client": { "version": "0.1.0-alpha.26", @@ -4582,10 +4577,9 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.1.tgz", - "integrity": "sha512-lCQNpV8R4lgsGcjX5667UiuDLk2micCtjtxR1YKbBXvN5w2v+FeLYoHrTSSrjwXdMcDYvE4ZBPvKT31dfeSmmA==", - "license": "MIT", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", + "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", "dependencies": { "langium": "3.3.1" } @@ -9222,7 +9216,6 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", - "license": "Apache-2.0", "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -9236,7 +9229,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", - "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" }, @@ -12928,7 +12920,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", - "license": "MIT", "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", @@ -13440,15 +13431,14 @@ } }, "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.1.0.tgz", + "integrity": "sha512-Me7BNa1aqrxVinDnFfvCgHh2yHvLbFvILBs899MhuBpbE5VPzpSqv7alaESfkqkgc9JNvUGH4gqwZeOzLnY8Jg==", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/math-intrinsics": { @@ -13781,14 +13771,13 @@ } }, "node_modules/mermaid": { - "version": "11.8.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.8.1.tgz", - "integrity": "sha512-VSXJLqP1Sqw5sGr273mhvpPRhXwE6NlmMSqBZQw+yZJoAJkOIPPn/uT3teeCBx60Fkt5zEI3FrH2eVT0jXRDzw==", - "license": "MIT", + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.9.0.tgz", + "integrity": "sha512-YdPXn9slEwO0omQfQIsW6vS84weVQftIyyTGAZCwM//MGhPzL1+l6vO6bkf0wnP4tHigH1alZ5Ooy3HXI2gOag==", "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", - "@mermaid-js/parser": "^0.6.1", + "@mermaid-js/parser": "^0.6.2", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", @@ -13798,10 +13787,10 @@ "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.5", - "katex": "^0.16.9", + "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.21", - "marked": "^15.0.7", + "marked": "^16.0.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -17797,7 +17786,6 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -17806,7 +17794,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, @@ -17818,7 +17805,6 @@ "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" @@ -17827,20 +17813,17 @@ "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" }, "node_modules/vscode-uri": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", - "license": "MIT" + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" }, "node_modules/web-streams-polyfill": { "version": "4.0.0-beta.3", diff --git a/apps/rowboat/package.json b/apps/rowboat/package.json index 43198960..46750a5c 100644 --- a/apps/rowboat/package.json +++ b/apps/rowboat/package.json @@ -50,7 +50,7 @@ "ioredis": "^5.6.1", "jose": "^5.9.6", "lucide-react": "^0.465.0", - "mermaid": "^11.8.1", + "mermaid": "^11.9.0", "mongodb": "^6.8.0", "next": "15.3.4", "openai": "^4.67.2", From 2e3a7916e90da3dcbb39aa9958a93fa80948cc88 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:37:49 +0530 Subject: [PATCH 2/2] refactor tools UX: part 2 --- apps/rowboat/app/actions/actions.ts | 3 +- apps/rowboat/app/actions/composio_actions.ts | 217 +------- apps/rowboat/app/actions/copilot_actions.ts | 24 +- .../app/actions/custom_mcp_server_actions.ts | 67 +++ .../app/actions/custom_server_actions.ts | 93 ---- apps/rowboat/app/actions/project_actions.ts | 130 ----- .../api/stream-response/[streamId]/route.ts | 4 +- .../app/api/twilio/inbound_call/route.ts | 6 +- .../app/api/twilio/turn/[callSid]/route.ts | 6 +- .../app/api/v1/[projectId]/chat/route.ts | 6 +- .../widget/v1/chats/[chatId]/turn/route.ts | 6 +- apps/rowboat/app/lib/agents.ts | 88 ++-- apps/rowboat/app/lib/composio/composio.ts | 38 +- apps/rowboat/app/lib/project_tools.ts | 40 -- apps/rowboat/app/lib/types/project_types.ts | 23 +- apps/rowboat/app/lib/types/types.ts | 1 - apps/rowboat/app/lib/types/workflow_types.ts | 16 +- apps/rowboat/app/lib/utils.ts | 2 - .../[projectId]/entities/agent_config.tsx | 4 +- .../[projectId]/entities/tool_config.tsx | 139 ++--- .../projects/[projectId]/playground/app.tsx | 6 - .../playground/components/chat.tsx | 7 - .../tools/components/AddWebhookTool.tsx | 42 ++ .../[projectId]/tools/components/Composio.tsx | 114 +---- .../tools/components/ComposioToolsPanel.tsx | 457 ++++++++--------- .../tools/components/CustomMcpServer.tsx | 220 ++++++++ .../tools/components/CustomServers.tsx | 475 ------------------ .../tools/components/McpToolsPanel.tsx | 240 +++++++++ .../tools/components/ServerCard.tsx | 168 +++++++ .../tools/components/ToolkitCard.tsx | 28 +- .../tools/components/ToolsConfig.tsx | 52 +- .../tools/components/WebhookConfig.tsx | 198 +++++--- .../app/projects/[projectId]/tools/page.tsx | 26 - .../app/projects/[projectId]/workflow/app.tsx | 63 +-- .../components/ComposioToolsModal.tsx | 74 --- .../components/ComposioWithCallback.tsx | 279 ---------- .../workflow/components/ToolsModal.tsx | 53 ++ .../[projectId]/workflow/entity_list.tsx | 242 ++++----- .../[projectId]/workflow/workflow_editor.tsx | 27 - ...project-wide-change-confirmation-modal.tsx | 76 +++ 40 files changed, 1499 insertions(+), 2261 deletions(-) create mode 100644 apps/rowboat/app/actions/custom_mcp_server_actions.ts delete mode 100644 apps/rowboat/app/actions/custom_server_actions.ts delete mode 100644 apps/rowboat/app/lib/project_tools.ts create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/AddWebhookTool.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/CustomMcpServer.tsx delete mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/McpToolsPanel.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/tools/components/ServerCard.tsx delete mode 100644 apps/rowboat/app/projects/[projectId]/tools/page.tsx delete mode 100644 apps/rowboat/app/projects/[projectId]/workflow/components/ComposioToolsModal.tsx delete mode 100644 apps/rowboat/app/projects/[projectId]/workflow/components/ComposioWithCallback.tsx create mode 100644 apps/rowboat/app/projects/[projectId]/workflow/components/ToolsModal.tsx create mode 100644 apps/rowboat/components/common/project-wide-change-confirmation-modal.tsx diff --git a/apps/rowboat/app/actions/actions.ts b/apps/rowboat/app/actions/actions.ts index 4b765e85..c866c047 100644 --- a/apps/rowboat/app/actions/actions.ts +++ b/apps/rowboat/app/actions/actions.ts @@ -60,7 +60,6 @@ export async function scrapeWebpage(url: string): Promise, - projectTools: z.infer[], messages: z.infer[], ): Promise<{ streamId: string } | { billingError: string }> { await projectAuthCheck(projectId); @@ -83,6 +82,6 @@ export async function getAssistantResponseStreamId( return { billingError: error || 'Billing error' }; } - const response = await getAgenticResponseStreamId(projectId, workflow, projectTools, messages); + const response = await getAgenticResponseStreamId(projectId, workflow, messages); return response; } \ No newline at end of file diff --git a/apps/rowboat/app/actions/composio_actions.ts b/apps/rowboat/app/actions/composio_actions.ts index 7e60a105..cc2e9376 100644 --- a/apps/rowboat/app/actions/composio_actions.ts +++ b/apps/rowboat/app/actions/composio_actions.ts @@ -3,9 +3,6 @@ import { z } from "zod"; import { listToolkits as libListToolkits, listTools as libListTools, - searchTools as libSearchTools, - getToolsByIds as libGetToolsByIds, - getTool as libGetTool, getConnectedAccount as libGetConnectedAccount, deleteConnectedAccount as libDeleteConnectedAccount, listAuthConfigs as libListAuthConfigs, @@ -23,7 +20,6 @@ import { ZCredentials, } from "@/app/lib/composio/composio"; import { ComposioConnectedAccount } from "@/app/lib/types/project_types"; -import { WorkflowTool } from "@/app/lib/types/workflow_types"; import { getProjectConfig, projectAuthCheck } from "./project_actions"; import { projectsCollection } from "../lib/mongodb"; @@ -46,29 +42,11 @@ export async function getToolkit(projectId: string, toolkitSlug: string): Promis return await libGetToolkit(toolkitSlug); } -export async function listTools(projectId: string, toolkitSlug: string, cursor: string | null = null): Promise>>> { +export async function listTools(projectId: string, toolkitSlug: string, searchQuery: string | null, cursor: string | null = null): Promise>>> { await projectAuthCheck(projectId); - return await libListTools(toolkitSlug, cursor); + return await libListTools(toolkitSlug, searchQuery, cursor); } -// New efficient search functions - -export async function searchTools(projectId: string, searchQuery: string, cursor: string | null = null, limit?: number): Promise>>> { - await projectAuthCheck(projectId); - return await libSearchTools(searchQuery, cursor, limit); -} - -export async function getToolsByIds(projectId: string, toolSlugs: string[], cursor: string | null = null): Promise>>> { - await projectAuthCheck(projectId); - return await libGetToolsByIds(toolSlugs, cursor); -} - -export async function getTool(projectId: string, toolSlug: string): Promise> { - await projectAuthCheck(projectId); - return await libGetTool(toolSlug); -} - - export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise> { await projectAuthCheck(projectId); @@ -237,196 +215,5 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str const key = `composioConnectedAccounts.${toolkitSlug}`; await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } }); - // Notify other tabs about the tools update (lightweight refresh) - if (typeof window !== 'undefined') { - localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString()); - } - return true; -} - -// Note: composio tools are now stored in workflow.tools array with isComposio: true -// This function provides backward compatibility by updating workflow tools -export async function getComposioToolsFromWorkflow(projectId: string): Promise[]> { - await projectAuthCheck(projectId); - - // Get the project to access draft workflow - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project || !project.draftWorkflow) { - return []; - } - - // Extract composio tools from workflow and convert back to ZTool format - const composioTools = project.draftWorkflow.tools - .filter(tool => tool.isComposio && tool.composioData) - .map(tool => ({ - slug: tool.composioData!.slug, - name: tool.name, - description: tool.description, - no_auth: tool.composioData!.noAuth, - input_parameters: { - type: 'object' as const, - properties: tool.parameters.properties, - required: tool.parameters.required || [] - }, - toolkit: { - name: tool.composioData!.toolkitName, - slug: tool.composioData!.toolkitSlug, - logo: tool.composioData!.logo, - } - })); - - return composioTools; -} - -export async function updateComposioSelectedTools(projectId: string, tools: z.infer[]): Promise { - await projectAuthCheck(projectId); - - // Get the project to access draft workflow - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project || !project.draftWorkflow) { - throw new Error(`Project ${projectId} not found or has no draft workflow`); - } - - // Convert Composio tools to workflow tool format - const composioWorkflowTools: z.infer[] = tools.map(tool => ({ - name: tool.slug, - 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: tool.no_auth, - toolkitName: tool.toolkit.name, - toolkitSlug: tool.toolkit.slug, - logo: tool.toolkit.logo, - }, - })); - - // Remove existing composio tools and add new ones - const nonComposioTools = project.draftWorkflow.tools.filter(tool => !tool.isComposio); - const updatedWorkflow = { - ...project.draftWorkflow, - tools: [...nonComposioTools, ...composioWorkflowTools], - lastUpdatedAt: new Date().toISOString() - }; - - // Update the project's draft workflow - const result = await projectsCollection.updateOne( - { _id: projectId }, - { $set: { draftWorkflow: updatedWorkflow } } - ); - - if (result.modifiedCount === 0) { - throw new Error(`Failed to update workflow for project ${projectId}`); - } - - // Notify other tabs about the tools update (lightweight refresh) - if (typeof window !== 'undefined') { - localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString()); - } -} - -// Note: composio mock states are now stored in workflow.composioMockToolkitStates -// This function provides backward compatibility by updating workflow mock states -export async function toggleMockToolkitState(projectId: string, toolkitSlug: string, isMocked: boolean, mockInstructions?: string): Promise { - await projectAuthCheck(projectId); - - // Get the project to access draft workflow - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project || !project.draftWorkflow) { - throw new Error(`Project ${projectId} not found or has no draft workflow`); - } - - const now = new Date().toISOString(); - let updatedMockToolkitStates = { ...(project.draftWorkflow.composioMockToolkitStates || {}) }; - - if (isMocked) { - // Enable mock mode - updatedMockToolkitStates[toolkitSlug] = { - toolkitSlug, - isMocked: true, - mockInstructions: mockInstructions || 'Mock responses using GPT-4.1 based on tool descriptions.', - autoSubmitMockedResponse: false, - createdAt: now, - lastUpdatedAt: now, - }; - } else { - // Disable mock mode - remove the toolkit from the object - delete updatedMockToolkitStates[toolkitSlug]; - } - - // Update the workflow with new mock states - const updatedWorkflow = { - ...project.draftWorkflow, - composioMockToolkitStates: updatedMockToolkitStates, - lastUpdatedAt: now - }; - - // Update the project's draft workflow - const result = await projectsCollection.updateOne( - { _id: projectId }, - { $set: { draftWorkflow: updatedWorkflow } } - ); - - if (result.modifiedCount === 0) { - throw new Error(`Failed to update workflow mock states for project ${projectId}`); - } - - // Notify other tabs about the tools update (lightweight refresh) - if (typeof window !== 'undefined') { - localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString()); - } -} - -// Note: composio mock states are now stored in workflow.composioMockToolkitStates -// This function provides backward compatibility by updating workflow mock states -export async function updateMockToolkitInstructions(projectId: string, toolkitSlug: string, mockInstructions: string): Promise { - await projectAuthCheck(projectId); - - // Get the project to access draft workflow - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project || !project.draftWorkflow) { - throw new Error(`Project ${projectId} not found or has no draft workflow`); - } - - const now = new Date().toISOString(); - let updatedMockToolkitStates = { ...(project.draftWorkflow.composioMockToolkitStates || {}) }; - - // Update the mock instructions for the specified toolkit - if (updatedMockToolkitStates[toolkitSlug]) { - updatedMockToolkitStates[toolkitSlug] = { - ...updatedMockToolkitStates[toolkitSlug], - mockInstructions, - lastUpdatedAt: now - }; - - // Update the workflow with new mock states - const updatedWorkflow = { - ...project.draftWorkflow, - composioMockToolkitStates: updatedMockToolkitStates, - lastUpdatedAt: now - }; - - // Update the project's draft workflow - const result = await projectsCollection.updateOne( - { _id: projectId }, - { $set: { draftWorkflow: updatedWorkflow } } - ); - - if (result.modifiedCount === 0) { - throw new Error(`Failed to update workflow mock instructions for project ${projectId}`); - } - - // Notify other tabs about the tools update - if (typeof window !== 'undefined') { - localStorage.setItem(`tools-updated-${projectId}`, Date.now().toString()); - } - } else { - throw new Error(`Mock toolkit state for ${toolkitSlug} not found in project ${projectId}`); - } } \ No newline at end of file diff --git a/apps/rowboat/app/actions/copilot_actions.ts b/apps/rowboat/app/actions/copilot_actions.ts index f2df813e..84bd0828 100644 --- a/apps/rowboat/app/actions/copilot_actions.ts +++ b/apps/rowboat/app/actions/copilot_actions.ts @@ -11,8 +11,6 @@ import { check_query_limit } from "../lib/rate_limiting"; import { QueryLimitError } from "../lib/client_utils"; import { projectAuthCheck } from "./project_actions"; import { redisClient } from "../lib/redis"; -import { collectProjectTools } from "../lib/project_tools"; -import { mergeProjectTools } from "../lib/types/project_types"; import { authorizeUserAction, logUsage } from "./billing_actions"; import { USE_BILLING } from "../lib/feature_flags"; import { WithStringId } from "../lib/types/types"; @@ -44,21 +42,12 @@ export async function getCopilotResponseStream( if (!await check_query_limit(projectId)) { throw new QueryLimitError(); } - - // Get MCP tools from project and merge with workflow tools - const projectTools = await collectProjectTools(projectId); - // Convert workflow to copilot format with both workflow and project tools - const wflow = { - ...current_workflow_config, - tools: mergeProjectTools(current_workflow_config.tools, projectTools) - }; - // prepare request const request: z.infer = { projectId, messages, - workflow: wflow, + workflow: current_workflow_config, context, dataSources: dataSources, }; @@ -97,20 +86,11 @@ export async function getCopilotAgentInstructions( return { billingError: authResponse.error || 'Billing error' }; } - // Get MCP tools from project and merge with workflow tools - const projectTools = await collectProjectTools(projectId); - - // Convert workflow to copilot format with both workflow and project tools - const wflow = { - ...current_workflow_config, - tools: mergeProjectTools(current_workflow_config.tools, projectTools) - }; - // prepare request const request: z.infer = { projectId, messages, - workflow: wflow, + workflow: current_workflow_config, context: { type: 'agent', name: agentName, diff --git a/apps/rowboat/app/actions/custom_mcp_server_actions.ts b/apps/rowboat/app/actions/custom_mcp_server_actions.ts new file mode 100644 index 00000000..a678dec9 --- /dev/null +++ b/apps/rowboat/app/actions/custom_mcp_server_actions.ts @@ -0,0 +1,67 @@ +'use server'; + +import { projectsCollection } from '../lib/mongodb'; +import { z } from 'zod'; +import { projectAuthCheck } from './project_actions'; +import { CustomMcpServer } from '../lib/types/project_types'; +import { getMcpClient } from '../lib/mcp'; +import { WorkflowTool } from '../lib/types/workflow_types'; +import { authCheck } from './auth_actions'; + +type McpServerType = z.infer; + +function validateUrl(url: string): string { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error('Invalid protocol'); + } + return parsedUrl.toString(); + } catch (error) { + throw new Error('Invalid URL'); + } +} + +export async function addServer(projectId: string, name: string, server: McpServerType): Promise { + await projectAuthCheck(projectId); + + // Validate the server URL + validateUrl(server.serverUrl); + + // Update the customMcpServers record with the server + await projectsCollection.updateOne( + { _id: projectId }, + { $set: { [`customMcpServers.${name}`]: server } } + ); +} + +export async function removeServer(projectId: string, name: string): Promise { + await projectAuthCheck(projectId); + + await projectsCollection.updateOne( + { _id: projectId }, + { $unset: { [`customMcpServers.${name}`]: "" } } + ); +} + +export async function fetchTools(serverUrl: string, serverName: string): Promise[]> { + await authCheck(); + + const client = await getMcpClient(serverUrl, serverName); + const result = await client.listTools(); + return result.tools.map(tool => { + return { + name: tool.name, + description: tool.description || '', + parameters: { + type: 'object', + properties: tool.inputSchema?.properties || {}, + required: tool.inputSchema?.required || [], + additionalProperties: true, + }, + isMcp: true, + mcpServerName: serverName, + mcpServerURL: serverUrl, + }; + }); +} diff --git a/apps/rowboat/app/actions/custom_server_actions.ts b/apps/rowboat/app/actions/custom_server_actions.ts deleted file mode 100644 index cb7a82c7..00000000 --- a/apps/rowboat/app/actions/custom_server_actions.ts +++ /dev/null @@ -1,93 +0,0 @@ -'use server'; - -import { projectsCollection } from '../lib/mongodb'; -import { MCPServer } from '../lib/types/types'; -import { z } from 'zod'; -import { projectAuthCheck } from './project_actions'; - -type McpServerType = z.infer; - -function formatServerUrl(url: string): string { - // Ensure URL starts with http:// or https:// - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'http://' + url; - } - // Remove trailing slash if present - return url.replace(/\/$/, ''); -} - -export async function fetchCustomServers(projectId: string) { - await projectAuthCheck(projectId); - - const project = await projectsCollection.findOne({ _id: projectId }); - return (project?.mcpServers || []) - .filter(server => server.serverType === 'custom') - .map(server => ({ - ...server, - serverType: 'custom' as const, - isReady: true // Custom servers are always ready - })); -} - -export async function addCustomServer(projectId: string, server: McpServerType) { - await projectAuthCheck(projectId); - - // Format the server URL and ensure isReady is true for custom servers - const formattedServer = { - ...server, - serverUrl: formatServerUrl(server.serverUrl || ''), - isReady: true // Custom servers are always ready - }; - - await projectsCollection.updateOne( - { _id: projectId }, - { $push: { mcpServers: formattedServer } } - ); - - return formattedServer; -} - -export async function removeCustomServer(projectId: string, serverName: string) { - await projectAuthCheck(projectId); - - await projectsCollection.updateOne( - { _id: projectId }, - { $pull: { mcpServers: { name: serverName } } } - ); -} - -export async function toggleCustomServer(projectId: string, serverName: string, isActive: boolean) { - await projectAuthCheck(projectId); - - await projectsCollection.updateOne( - { _id: projectId, "mcpServers.name": serverName }, - { - $set: { - "mcpServers.$.isActive": isActive, - "mcpServers.$.isReady": isActive // Update isReady along with isActive - } - } - ); -} - -export async function updateCustomServerTools( - projectId: string, - serverName: string, - tools: McpServerType['tools'], - availableTools?: McpServerType['availableTools'] -) { - await projectAuthCheck(projectId); - - const update: Record = { - "mcpServers.$.tools": tools - }; - - if (availableTools) { - update["mcpServers.$.availableTools"] = availableTools; - } - - await projectsCollection.updateOne( - { _id: projectId, "mcpServers.name": serverName }, - { $set: update } - ); -} \ No newline at end of file diff --git a/apps/rowboat/app/actions/project_actions.ts b/apps/rowboat/app/actions/project_actions.ts index d9809494..0300c91c 100644 --- a/apps/rowboat/app/actions/project_actions.ts +++ b/apps/rowboat/app/actions/project_actions.ts @@ -14,15 +14,6 @@ import { USE_AUTH } from "../lib/feature_flags"; import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions"; import { authorizeUserAction } from "./billing_actions"; import { Workflow } from "../lib/types/workflow_types"; -import { WorkflowTool } from "../lib/types/workflow_types"; -import { collectProjectTools as libCollectProjectTools } from "../lib/project_tools"; -import { - searchTools as libSearchTools, - getToolsByIds as libGetToolsByIds, - getTool as libGetTool, - ZTool, - ZToolkit -} from "../lib/composio/composio"; const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || ''; @@ -313,113 +304,6 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id: return { id: projectId }; } -async function detectAndAddComposioTools(projectId: string, workflow: z.infer) { - // Extract tool mentions from agent instructions - const toolMentionPattern = /\[@tool:([^\]]+)\]\(#mention[^\)]*\)/g; - const mentionedToolNames = new Set(); - - // Scan all agent instructions for tool mentions - for (const agent of workflow.agents || []) { - const instructions = agent.instructions || ""; - let match: RegExpExecArray | null; - while ((match = toolMentionPattern.exec(instructions))) { - mentionedToolNames.add(match[1]); - } - } - - if (mentionedToolNames.size === 0) { - return; // No tool mentions found - } - - console.log(`Found ${mentionedToolNames.size} tool mentions in workflow:`, Array.from(mentionedToolNames)); - - // Search for these tools in Composio using the new efficient search methods - const foundTools: z.infer[] = []; - - try { - // Method 1: Try to get tools directly by their exact slugs/names - const mentionedToolNamesArray = Array.from(mentionedToolNames); - - try { - const directToolsResponse = await libGetToolsByIds(mentionedToolNamesArray); - foundTools.push(...directToolsResponse.items); - console.log(`Found ${directToolsResponse.items.length} tools by direct lookup`); - } catch (error) { - console.log('Direct tool lookup failed, trying search approach'); - } - - // Method 2: For any remaining tools, use search functionality - const foundToolSlugs = new Set(foundTools.map(tool => tool.slug)); - const foundToolNames = new Set(foundTools.map(tool => tool.name)); - const remainingToolNames = mentionedToolNamesArray.filter(name => - !foundToolSlugs.has(name) && !foundToolNames.has(name) - ); - - for (const toolName of remainingToolNames) { - try { - // Search for tools by name/description - const searchResponse = await libSearchTools(toolName, null, 10); - - // Find exact matches by name or slug - const exactMatches = searchResponse.items.filter(tool => - tool.name === toolName || - tool.slug === toolName || - tool.name.toLowerCase() === toolName.toLowerCase() || - tool.slug.toLowerCase() === toolName.toLowerCase() - ); - - if (exactMatches.length > 0) { - foundTools.push(...exactMatches); - console.log(`Found ${exactMatches.length} tools for search term "${toolName}"`); - } else { - console.log(`No exact matches found for tool "${toolName}"`); - } - } catch (error) { - console.error(`Error searching for tool "${toolName}":`, error); - } - } - - } catch (error) { - console.error('Error searching for Composio tools:', error); - return; - } - - if (foundTools.length > 0) { - console.log(`Adding ${foundTools.length} Composio tools to workflow`); - - // Remove duplicates based on slug - const uniqueTools = foundTools.filter((tool, index, self) => - index === self.findIndex(t => t.slug === tool.slug) - ); - - // Convert Composio tools to workflow tool format - const composioWorkflowTools: z.infer[] = uniqueTools.map(tool => ({ - name: tool.slug, - 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: tool.no_auth, - toolkitName: tool.toolkit.name, - toolkitSlug: tool.toolkit.slug, - logo: tool.toolkit.logo, - }, - })); - - // Add these tools to the workflow.tools array - workflow.tools = [...workflow.tools, ...composioWorkflowTools]; - - console.log(`Added ${composioWorkflowTools.length} Composio tools to workflow`); - } else { - console.log('No matching Composio tools found for the mentioned tool names'); - } -} - export async function createProjectFromWorkflowJson(formData: FormData): Promise<{ id: string } | { billingError: string }> { const user = await authCheck(); const workflowJson = formData.get('workflowJson') as string; @@ -441,23 +325,9 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise return response; } const projectId = response.id; - - // Automatically detect and add Composio tools mentioned in agent instructions - try { - await detectAndAddComposioTools(projectId, workflow); - } catch (error) { - // Log error but don't fail the import if tool detection fails - console.error('Failed to auto-detect Composio tools:', error); - } - return { id: projectId }; } -export async function collectProjectTools(projectId: string): Promise[]> { - await projectAuthCheck(projectId); - return libCollectProjectTools(projectId); -} - export async function saveWorkflow(projectId: string, workflow: z.infer) { await projectAuthCheck(projectId); diff --git a/apps/rowboat/app/api/stream-response/[streamId]/route.ts b/apps/rowboat/app/api/stream-response/[streamId]/route.ts index 14d09a3d..743acf53 100644 --- a/apps/rowboat/app/api/stream-response/[streamId]/route.ts +++ b/apps/rowboat/app/api/stream-response/[streamId]/route.ts @@ -13,7 +13,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId: } // parse the payload - const { projectId, workflow, projectTools, messages } = ZStreamAgentResponsePayload.parse(JSON.parse(payload)); + const { projectId, workflow, messages } = ZStreamAgentResponsePayload.parse(JSON.parse(payload)); console.log('payload', payload); // fetch billing customer id @@ -29,7 +29,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId: async start(controller) { try { // Iterate over the generator - for await (const event of streamResponse(projectId, workflow, projectTools, messages)) { + for await (const event of streamResponse(projectId, workflow, messages)) { // Check if this is a message event (has role property) if ('role' in event) { if (event.role === 'assistant') { diff --git a/apps/rowboat/app/api/twilio/inbound_call/route.ts b/apps/rowboat/app/api/twilio/inbound_call/route.ts index 1869a954..7fb2a886 100644 --- a/apps/rowboat/app/api/twilio/inbound_call/route.ts +++ b/apps/rowboat/app/api/twilio/inbound_call/route.ts @@ -1,6 +1,5 @@ import { getResponse } from "@/app/lib/agents"; import { projectsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb"; -import { collectProjectTools } from "@/app/lib/project_tools"; import { PrefixLogger } from "@/app/lib/utils"; import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; import { z } from "zod"; @@ -78,12 +77,9 @@ export async function POST(request: Request) { return reject('rejected'); } - // fetch project tools - const projectTools = await collectProjectTools(projectId); - // this is the first turn, get the initial assistant response // and validate it - const { messages } = await getResponse(projectId, workflow, projectTools, []); + const { messages } = await getResponse(projectId, workflow, []); if (messages.length === 0) { logger.log('Agent response is empty'); return hangup(); diff --git a/apps/rowboat/app/api/twilio/turn/[callSid]/route.ts b/apps/rowboat/app/api/twilio/turn/[callSid]/route.ts index 29fa71dd..56e60b0c 100644 --- a/apps/rowboat/app/api/twilio/turn/[callSid]/route.ts +++ b/apps/rowboat/app/api/twilio/turn/[callSid]/route.ts @@ -1,6 +1,5 @@ import { getResponse } from "@/app/lib/agents"; import { projectsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb"; -import { collectProjectTools } from "@/app/lib/project_tools"; import { PrefixLogger } from "@/app/lib/utils"; import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; import { z } from "zod"; @@ -50,9 +49,6 @@ export async function POST( return hangup(); } - // fetch project tools - const projectTools = await collectProjectTools(projectId); - // add user speech as user message, and get assistant response const reqMessages: z.infer[] = [ ...call.messages, @@ -61,7 +57,7 @@ export async function POST( content: data.SpeechResult, } ]; - const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages); + const { messages } = await getResponse(projectId, workflow, reqMessages); if (messages.length === 0) { logger.log('Agent response is empty'); return hangup(); diff --git a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts index 1001cf9c..8008fae3 100644 --- a/apps/rowboat/app/api/v1/[projectId]/chat/route.ts +++ b/apps/rowboat/app/api/v1/[projectId]/chat/route.ts @@ -6,7 +6,6 @@ import { authCheck } from "../../utils"; import { ApiRequest, ApiResponse } from "../../../../lib/types/types"; import { check_query_limit } from "../../../../lib/rate_limiting"; import { PrefixLogger } from "../../../../lib/utils"; -import { collectProjectTools } from "@/app/lib/project_tools"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { USE_BILLING } from "@/app/lib/feature_flags"; import { getResponse } from "@/app/lib/agents"; @@ -61,9 +60,6 @@ export async function POST( return Response.json({ error: "Project not found" }, { status: 404 }); } - // fetch project tools - const projectTools = await collectProjectTools(projectId); - // fetch workflow const workflow = project.liveWorkflow; if (!workflow) { @@ -94,7 +90,7 @@ export async function POST( } // get assistant response - const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages); + const { messages } = await getResponse(projectId, workflow, reqMessages); // log billing usage if (USE_BILLING && billingCustomerId) { diff --git a/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts b/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts index 7f901e70..e0101da0 100644 --- a/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts +++ b/apps/rowboat/app/api/widget/v1/chats/[chatId]/turn/route.ts @@ -6,7 +6,6 @@ import { ObjectId, WithId } from "mongodb"; import { authCheck } from "../../../utils"; import { check_query_limit } from "../../../../../../lib/rate_limiting"; import { PrefixLogger } from "../../../../../../lib/utils"; -import { collectProjectTools } from "@/app/lib/project_tools"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { USE_BILLING } from "@/app/lib/feature_flags"; import { getResponse } from "@/app/lib/agents"; @@ -181,9 +180,6 @@ export async function POST( throw new Error("Project settings not found"); } - // fetch project tools - const projectTools = await collectProjectTools(session.projectId); - // fetch workflow const workflow = projectSettings.liveWorkflow; if (!workflow) { @@ -211,7 +207,7 @@ export async function POST( const inMessages: z.infer[] = convert(messages); inMessages.push(userMessage); - const { messages: responseMessages } = await getResponse(session.projectId, workflow, projectTools, [systemMessage, ...inMessages]); + const { messages: responseMessages } = await getResponse(session.projectId, workflow, [systemMessage, ...inMessages]); const convertedResponseMessages = convertBack(responseMessages); const unsavedMessages = [ userMessage, diff --git a/apps/rowboat/app/lib/agents.ts b/apps/rowboat/app/lib/agents.ts index 618a5e3e..60b2ae09 100644 --- a/apps/rowboat/app/lib/agents.ts +++ b/apps/rowboat/app/lib/agents.ts @@ -17,7 +17,6 @@ import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } f import { qdrantClient } from '../lib/qdrant'; import { EmbeddingRecord } from "./types/datasource_types"; import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./types/workflow_types"; -import { Project } from "./types/project_types"; import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS } from "./agent_instructions"; import { PrefixLogger } from "./utils"; import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "./types/types"; @@ -254,17 +253,27 @@ async function invokeMcpTool( projectId: string, name: string, input: any, - mcpServerURL: string, mcpServerName: string ) { logger = logger.child(`invokeMcpTool`); logger.log(`projectId: ${projectId}`); logger.log(`name: ${name}`); logger.log(`input: ${JSON.stringify(input)}`); - logger.log(`mcpServerURL: ${mcpServerURL}`); logger.log(`mcpServerName: ${mcpServerName}`); - const client = await getMcpClient(mcpServerURL, mcpServerName || ''); + // Get project configuration + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + throw new Error(`project ${projectId} not found`); + } + + // get server url from project data + const mcpServerURL = project.customMcpServers?.[mcpServerName]?.serverUrl; + if (!mcpServerURL) { + throw new Error(`mcp server url not found for project ${projectId} and server ${mcpServerName}`); + } + + const client = await getMcpClient(mcpServerURL, mcpServerName); const result = await client.callTool({ name, arguments: input, @@ -281,8 +290,6 @@ async function invokeComposioTool( name: string, composioData: z.infer['composioData'] & {}, input: any, - workflow: z.infer, - toolDescription?: string, ) { logger = logger.child(`invokeComposioTool`); logger.log(`projectId: ${projectId}`); @@ -291,36 +298,12 @@ async function invokeComposioTool( const { slug, toolkitSlug, noAuth } = composioData; - // Get project configuration to check for connected accounts (still stored in project) - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project) { - throw new Error(`project ${projectId} not found`); - } - - // Check if toolkit is in mock mode (now from workflow) - const mockState = workflow.composioMockToolkitStates?.[toolkitSlug]; - if (mockState?.isMocked) { - logger.log(`toolkit ${toolkitSlug} is in mock mode, using mock response`); - - // Use the existing invokeMockTool function to generate a mock response - const mockInstructions = mockState.mockInstructions || 'Mock responses using GPT-4.1 based on tool descriptions.'; - const description = toolDescription || `${name} tool from ${toolkitSlug} toolkit`; - - const mockResponse = await invokeMockTool( - logger, - name, - JSON.stringify(input), - description, - mockInstructions - ); - - logger.log(`mock tool result: ${mockResponse}`); - return mockResponse; - } - - // Normal execution path - check for authentication let connectedAccountId: string | undefined = undefined; if (!noAuth) { + const project = await projectsCollection.findOne({ _id: projectId }); + if (!project) { + throw new Error(`project ${projectId} not found`); + } connectedAccountId = project.composioConnectedAccounts?.[toolkitSlug]?.id; if (!connectedAccountId) { throw new Error(`connected account id not found for project ${projectId} and toolkit ${toolkitSlug}`); @@ -447,7 +430,7 @@ function createMcpTool( config: z.infer, projectId: string ): Tool { - const { name, description, parameters, mcpServerName, mcpServerURL } = config; + const { name, description, parameters, mcpServerName } = config; return tool({ name, @@ -461,7 +444,7 @@ function createMcpTool( }, async execute(input: any) { try { - const result = await invokeMcpTool(logger, projectId, name, input, mcpServerURL || '', mcpServerName || ''); + const result = await invokeMcpTool(logger, projectId, name, input, mcpServerName || ''); return JSON.stringify({ result, }); @@ -479,8 +462,7 @@ function createMcpTool( function createComposioTool( logger: PrefixLogger, config: z.infer, - projectId: string, - workflow: z.infer + projectId: string ): Tool { const { name, description, parameters, composioData } = config; @@ -500,7 +482,7 @@ function createComposioTool( }, async execute(input: any) { try { - const result = await invokeComposioTool(logger, projectId, name, composioData, input, workflow, description); + const result = await invokeComposioTool(logger, projectId, name, composioData, input); return JSON.stringify({ result, }); @@ -520,7 +502,6 @@ function createAgent( projectId: string, config: z.infer, tools: Record, - projectTools: z.infer[], workflow: z.infer, promptConfig: Record>, ): { agent: Agent, entities: z.infer[] } { @@ -550,7 +531,7 @@ ${'-'.repeat(100)} ${CHILD_TRANSFER_RELATED_INSTRUCTIONS} `; - let { sanitized, entities } = sanitizeTextWithMentions(instructions, workflow, projectTools); + let { sanitized, entities } = sanitizeTextWithMentions(instructions, workflow); agentLogger.log(`instructions: ${JSON.stringify(sanitized)}`); agentLogger.log(`mentions: ${JSON.stringify(entities)}`); @@ -783,7 +764,7 @@ Basic context: } } -function mapConfig(workflow: z.infer, projectTools: z.infer[]): { +function mapConfig(workflow: z.infer): { agentConfig: Record>; toolConfig: Record>; promptConfig: Record>; @@ -792,10 +773,7 @@ function mapConfig(workflow: z.infer, projectTools: z.infer> = [ - ...workflow.tools, - ...projectTools, - ].reduce((acc, tool) => ({ + const toolConfig: Record> = workflow.tools.reduce((acc, tool) => ({ ...acc, [tool.name]: tool }), {}); @@ -837,15 +815,15 @@ function createTools( mockInstructions: workflow.mockTools?.[toolName], // override mock instructions }); logger.log(`created mock tool: ${toolName}`); + } else if (config.mockTool) { + tools[toolName] = createMockTool(logger, config); + logger.log(`created mock tool: ${toolName}`); } else if (config.isMcp) { tools[toolName] = createMcpTool(logger, config, projectId); logger.log(`created mcp tool: ${toolName}`); } else if (config.isComposio) { - tools[toolName] = createComposioTool(logger, config, projectId, workflow); + tools[toolName] = createComposioTool(logger, config, projectId); logger.log(`created composio tool: ${toolName}`); - } else if (config.mockTool) { - tools[toolName] = createMockTool(logger, config); - logger.log(`created mock tool: ${toolName}`); } else { tools[toolName] = createWebhookTool(logger, config, projectId); logger.log(`created webhook tool: ${toolName}`); @@ -860,7 +838,6 @@ function createAgents( workflow: z.infer, agentConfig: Record>, tools: Record, - projectTools: z.infer[], promptConfig: Record>, ): { agents: Record, mentions: Record[]>, originalInstructions: Record, originalHandoffs: Record } { const agents: Record = {}; @@ -875,7 +852,6 @@ function createAgents( projectId, config, tools, - projectTools, workflow, promptConfig, ); @@ -956,7 +932,6 @@ function maybeInjectGiveUpControlInstructions( export async function* streamResponse( projectId: string, workflow: z.infer, - projectTools: z.infer[], messages: z.infer[], ): AsyncIterable | z.infer> { // Divider log for tracking agent loop start @@ -975,7 +950,7 @@ export async function* streamResponse( } // create map of agent, tool and prompt configs - const { agentConfig, toolConfig, promptConfig } = mapConfig(workflow, projectTools); + const { agentConfig, toolConfig, promptConfig } = mapConfig(workflow); const stack: string[] = []; @@ -985,7 +960,7 @@ export async function* streamResponse( const tools = createTools(logger, projectId, workflow, toolConfig); // create agents - const { agents, originalInstructions, originalHandoffs } = createAgents(logger, projectId, workflow, agentConfig, tools, projectTools, promptConfig); + const { agents, originalInstructions, originalHandoffs } = createAgents(logger, projectId, workflow, agentConfig, tools, promptConfig); // track agent to agent calls const transferCounter = new AgentTransferCounter(); @@ -1241,7 +1216,6 @@ export async function* streamResponse( export async function getResponse( projectId: string, workflow: z.infer, - projectTools: z.infer[], messages: z.infer[], ): Promise<{ messages: z.infer[], @@ -1255,7 +1229,7 @@ export async function getResponse( completion: 0, }, }; - for await (const event of streamResponse(projectId, workflow, projectTools, messages)) { + for await (const event of streamResponse(projectId, workflow, messages)) { if ('role' in event) { out.push(event); } diff --git a/apps/rowboat/app/lib/composio/composio.ts b/apps/rowboat/app/lib/composio/composio.ts index 64c07679..2515fe45 100644 --- a/apps/rowboat/app/lib/composio/composio.ts +++ b/apps/rowboat/app/lib/composio/composio.ts @@ -278,11 +278,14 @@ export async function getToolkit(toolkitSlug: string): Promise>>> { +export async function listTools(toolkitSlug: string, searchQuery: string | null = null, cursor: string | null = null): Promise>>> { const url = new URL(`${BASE_URL}/tools`); // set params url.searchParams.set("toolkit_slug", toolkitSlug); + if (searchQuery) { + url.searchParams.set("search", searchQuery); + } if (cursor) { url.searchParams.set("cursor", cursor); } @@ -291,39 +294,6 @@ export async function listTools(toolkitSlug: string, cursor: string | null = nul return composioApiCall(ZListResponse(ZTool), url.toString()); } -export async function searchTools(searchQuery: string, cursor: string | null = null, limit: number = 50): Promise>>> { - const url = new URL(`${BASE_URL}/tools`); - - // set params - url.searchParams.set("search", searchQuery); - if (cursor) { - url.searchParams.set("cursor", cursor); - } - url.searchParams.set("limit", limit.toString()); - - // fetch - return composioApiCall(ZListResponse(ZTool), url.toString()); -} - -export async function getToolsByIds(toolSlugs: string[], cursor: string | null = null): Promise>>> { - const url = new URL(`${BASE_URL}/tools`); - - // set params - pass tool slugs as comma-separated string - url.searchParams.set("tool_slugs", toolSlugs.join(",")); - if (cursor) { - url.searchParams.set("cursor", cursor); - } - - // fetch - return composioApiCall(ZListResponse(ZTool), url.toString()); -} - -export async function getTool(toolSlug: string): Promise> { - const url = new URL(`${BASE_URL}/tools/${toolSlug}`); - return composioApiCall(ZTool, url.toString()); -} - - export async function listAuthConfigs(toolkitSlug: string, cursor: string | null = null, managedOnly: boolean = false): Promise>>> { const url = new URL(`${BASE_URL}/auth_configs`); url.searchParams.set("toolkit_slug", toolkitSlug); diff --git a/apps/rowboat/app/lib/project_tools.ts b/apps/rowboat/app/lib/project_tools.ts deleted file mode 100644 index 7d8fb8f5..00000000 --- a/apps/rowboat/app/lib/project_tools.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { z } from "zod"; -import { projectsCollection } from "./mongodb"; -import { WorkflowTool } from "./types/workflow_types"; - -export async function collectProjectTools(projectId: string): Promise[]> { - const tools: z.infer[] = []; - - // Get project data - const project = await projectsCollection.findOne({ _id: projectId }); - if (!project) { - throw new Error(`Project ${projectId} not found`); - } - - // Convert MCP tools to workflow tools format, but only from ready servers - if (project.mcpServers) { - for (const server of project.mcpServers) { - if (server.isReady) { - for (const tool of server.tools) { - tools.push({ - name: tool.name, - description: tool.description || "", - parameters: { - type: 'object' as const, - properties: tool.parameters?.properties || {}, - required: tool.parameters?.required || [] - }, - isMcp: true, - mcpServerName: server.name, - mcpServerURL: server.serverUrl, - }); - } - } - } - } - - // Note: Composio tools are now stored in workflow.tools array with isComposio: true - // This function now only collects MCP tools since composio tools are managed in workflow - - return tools; -} diff --git a/apps/rowboat/app/lib/types/project_types.ts b/apps/rowboat/app/lib/types/project_types.ts index 86308e44..9dd1957d 100644 --- a/apps/rowboat/app/lib/types/project_types.ts +++ b/apps/rowboat/app/lib/types/project_types.ts @@ -14,6 +14,10 @@ export const ComposioConnectedAccount = z.object({ lastUpdatedAt: z.string().datetime(), }); +export const CustomMcpServer = z.object({ + serverUrl: z.string(), +}); + export const Project = z.object({ _id: z.string().uuid(), name: z.string(), @@ -29,6 +33,7 @@ export const Project = z.object({ testRunCounter: z.number().default(0), mcpServers: z.array(MCPServer).optional(), composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(), + customMcpServers: z.record(z.string(), CustomMcpServer).optional(), }); export const ProjectMember = z.object({ @@ -43,20 +48,4 @@ export const ApiKey = z.object({ key: z.string(), createdAt: z.string().datetime(), lastUsedAt: z.string().datetime().optional(), -}); - -export function mergeProjectTools( - workflowTools: z.infer[], - projectTools: z.infer[] -): z.infer[] { - // Filter out any existing MCP tools from workflow tools - const nonMcpTools = workflowTools.filter(t => !t.isMcp); - - // Merge with project tools - const merged = [ - ...nonMcpTools, - ...projectTools - ]; - - return merged; -} +}); \ No newline at end of file diff --git a/apps/rowboat/app/lib/types/types.ts b/apps/rowboat/app/lib/types/types.ts index 0900d748..dab9bdde 100644 --- a/apps/rowboat/app/lib/types/types.ts +++ b/apps/rowboat/app/lib/types/types.ts @@ -210,6 +210,5 @@ export function convertMcpServerToolToWorkflowTool( export const ZStreamAgentResponsePayload = z.object({ projectId: z.string(), workflow: Workflow, - projectTools: z.array(WorkflowTool), messages: z.array(Message), }); diff --git a/apps/rowboat/app/lib/types/workflow_types.ts b/apps/rowboat/app/lib/types/workflow_types.ts index 73e36935..2a8e5dbb 100644 --- a/apps/rowboat/app/lib/types/workflow_types.ts +++ b/apps/rowboat/app/lib/types/workflow_types.ts @@ -39,7 +39,6 @@ export const WorkflowTool = z.object({ name: z.string(), description: z.string(), mockTool: z.boolean().default(false).optional(), - autoSubmitMockedResponse: z.boolean().default(false).optional(), mockInstructions: z.string().optional(), parameters: z.object({ type: z.literal('object'), @@ -48,10 +47,9 @@ export const WorkflowTool = z.object({ additionalProperties: z.boolean().optional(), }), isMcp: z.boolean().default(false).optional(), - isLibrary: z.boolean().default(false).optional(), mcpServerName: z.string().optional(), - mcpServerURL: z.string().optional(), isComposio: z.boolean().optional(), // whether this is a Composio tool + isLibrary: z.boolean().default(false).optional(), // whether this is a library tool composioData: z.object({ slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE" noAuth: z.boolean(), // whether the tool requires no authentication @@ -67,14 +65,6 @@ export const Workflow = z.object({ startAgent: z.string(), lastUpdatedAt: z.string().datetime(), mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions - composioMockToolkitStates: z.record(z.string(), z.object({ - toolkitSlug: z.string(), - isMocked: z.boolean(), - mockInstructions: z.string().optional(), - autoSubmitMockedResponse: z.boolean().default(false), - createdAt: z.string().datetime(), - lastUpdatedAt: z.string().datetime(), - })).optional(), }); export const WorkflowTemplate = Workflow .omit({ @@ -97,7 +87,6 @@ export function sanitizeTextWithMentions( tools: z.infer[], prompts: z.infer[], }, - projectTools: z.infer[] = [] ): { sanitized: string; entities: z.infer[]; @@ -127,8 +116,7 @@ export function sanitizeTextWithMentions( if (entity.type === 'agent') { return workflow.agents.some(a => a.name === entity.name); } else if (entity.type === 'tool') { - return workflow.tools.some(t => t.name === entity.name) || - projectTools.some(t => t.name === entity.name); + return workflow.tools.some(t => t.name === entity.name); } else if (entity.type === 'prompt') { return workflow.prompts.some(p => p.name === entity.name); } diff --git a/apps/rowboat/app/lib/utils.ts b/apps/rowboat/app/lib/utils.ts index c054d355..4d6ee2ff 100644 --- a/apps/rowboat/app/lib/utils.ts +++ b/apps/rowboat/app/lib/utils.ts @@ -8,7 +8,6 @@ import { Message, ZStreamAgentResponsePayload } from "./types/types"; export async function getAgenticResponseStreamId( projectId: string, workflow: z.infer, - projectTools: z.infer[], messages: z.infer[], ): Promise<{ streamId: string, @@ -16,7 +15,6 @@ export async function getAgenticResponseStreamId( const payload: z.infer = { projectId, workflow, - projectTools, messages, } // serialize the request diff --git a/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx b/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx index e977fc67..f74c63ed 100644 --- a/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx +++ b/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx @@ -42,7 +42,6 @@ export function AgentConfig({ usedAgentNames, agents, tools, - projectTools, prompts, dataSources, handleUpdate, @@ -57,7 +56,6 @@ export function AgentConfig({ usedAgentNames: Set, agents: z.infer[], tools: z.infer[], - projectTools: z.infer[], prompts: z.infer[], dataSources: WithStringId>[], handleUpdate: (agent: z.infer) => void, @@ -172,7 +170,7 @@ export function AgentConfig({ const atMentions = createAtMentions({ agents, prompts, - tools: [...tools, ...projectTools], + tools, currentAgentName: agent.name }); diff --git a/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx b/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx index f88c4dbc..fd758380 100644 --- a/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx +++ b/apps/rowboat/app/projects/[projectId]/entities/tool_config.tsx @@ -1,9 +1,10 @@ "use client"; import { WorkflowTool } from "../../../lib/types/workflow_types"; -import { Checkbox, Select, SelectItem, RadioGroup, Radio } from "@heroui/react"; +import { Checkbox, Select, SelectItem, Switch } from "@heroui/react"; import { z } from "zod"; -import { ImportIcon, XIcon, PlusIcon, FolderIcon} from "lucide-react"; +import { ImportIcon, XIcon, PlusIcon, FolderIcon, Globe, Zap, ExternalLink } from "lucide-react"; import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; import { Textarea } from "@/components/ui/textarea"; import { Panel } from "@/components/common/panel-common"; import { Button } from "@/components/ui/button"; @@ -12,6 +13,7 @@ import { SectionCard } from "@/components/common/section-card"; import { ToolParamCard } from "@/components/common/tool-param-card"; import { UserIcon, Settings, Settings2 } from "lucide-react"; import { EditableField } from "@/app/lib/components/editable-field"; +import Link from "next/link"; // Update textarea styles with improved states 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"; @@ -173,8 +175,11 @@ export function ToolConfig({ required: tool.parameters?.required || [] }); + const params = useParams(); + const projectId = params.projectId as string; const [selectedParams, setSelectedParams] = useState(new Set([])); - const isReadOnly = tool.isMcp || tool.isLibrary || tool.isComposio; + const isReadOnly = tool.isMcp || tool.isComposio; + const isWebhookTool = !tool.isMcp && !tool.isComposio; const [nameError, setNameError] = useState(null); // Log when parameters are being rendered @@ -337,6 +342,57 @@ export function ToolConfig({ } >
+ {/* Tool Type Section */} +
+
+
+ {tool.isMcp ? ( + + ) : tool.isComposio ? ( + + ) : ( + + )} +
+
+

+ How this tool runs +

+ {tool.isMcp ? ( +
+

This tool is powered by the {tool.mcpServerName} MCP server.

+

+ MCP (Model Context Protocol) tools are external services that provide additional capabilities to your agent. +

+
+ ) : tool.isComposio ? ( +
+
+

This tool is powered by Composio

+ {tool.composioData?.toolkitName && ( + + {tool.composioData.toolkitName} + + )} +
+

+ Composio provides pre-built integrations with popular services and APIs. +

+
+ ) : ( +
+
+

This tool is invoked using the webhook configured in project settings

+
+

+ Webhook tools make HTTP requests to your configured endpoint when called by the agent. +

+
+ )} +
+
+
+ {/* Identity Section */} } @@ -350,6 +406,7 @@ export function ToolConfig({
{ setNameError(validateToolName(value)); if (!validateToolName(value)) { @@ -375,6 +432,7 @@ export function ToolConfig({
handleUpdate({ ...tool, description: value })} multiline={true} @@ -385,56 +443,37 @@ export function ToolConfig({
- {/* Behavior Section */} + {/* Mock Section */} } - title="Behavior" - labelWidth="md:w-32" + title="Mock responses" + labelWidth="md:w-64" className="mb-1" >
- {!isReadOnly && ( -
- - +
+ handleUpdate({ ...tool, - mockTool: value === "mock", - autoSubmitMockedResponse: value === "mock" ? true : undefined + mockTool: value, })} - orientation="horizontal" - classNames={{ - wrapper: "flex flex-col md:flex-row gap-2 md:gap-8 pl-0 md:pl-3", - label: "text-sm" - }} - > - - Mock tool responses - - - Connect tool to your API - - + size="sm" + color="primary" + /> +
- )} + + When enabled, this tool will be mocked. + +
{tool.mockTool && ( -
- - Describe the response the mock tool should return. This will be shown in the chat when the tool is called. You can also provide a JSON schema for the response. +
+ + Describe the response the mock tool should return. This will be shown in the chat when the tool is called. handleUpdate({ @@ -445,20 +484,6 @@ export function ToolConfig({ placeholder="Mock response instructions..." className="w-full text-xs p-2 bg-white dark:bg-gray-900" /> - handleUpdate({ - ...tool, - autoSubmitMockedResponse: value - })} - disabled={isReadOnly} - className="mt-2" - > - - Automatically send mock response in chat - -
)}
diff --git a/apps/rowboat/app/projects/[projectId]/playground/app.tsx b/apps/rowboat/app/projects/[projectId]/playground/app.tsx index 5ab76f37..30519109 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/app.tsx @@ -19,10 +19,8 @@ export function App({ workflow, messageSubscriber, mcpServerUrls, - toolWebhookUrl, isInitialState = false, onPanelClick, - projectTools, triggerCopilotChat, }: { hidden?: boolean; @@ -30,10 +28,8 @@ export function App({ workflow: z.infer; messageSubscriber?: (messages: z.infer[]) => void; mcpServerUrls: Array>; - toolWebhookUrl: string; isInitialState?: boolean; onPanelClick?: () => void; - projectTools: z.infer[]; triggerCopilotChat?: (message: string) => void; }) { const [counter, setCounter] = useState(0); @@ -156,10 +152,8 @@ export function App({ systemMessage={systemMessage} onSystemMessageChange={handleSystemMessageChange} mcpServerUrls={mcpServerUrls} - toolWebhookUrl={toolWebhookUrl} onCopyClick={(fn) => { getCopyContentRef.current = fn; }} showDebugMessages={showDebugMessages} - projectTools={projectTools} triggerCopilotChat={triggerCopilotChat} />
diff --git a/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx b/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx index a6b2ee86..473ef311 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/components/chat.tsx @@ -22,11 +22,9 @@ export function Chat({ systemMessage, onSystemMessageChange, mcpServerUrls, - toolWebhookUrl, onCopyClick, showDebugMessages = true, showJsonMode = false, - projectTools, triggerCopilotChat, }: { chat: z.infer; @@ -36,11 +34,9 @@ export function Chat({ systemMessage: string; onSystemMessageChange: (message: string) => void; mcpServerUrls: Array>; - toolWebhookUrl: string; onCopyClick: (fn: () => string) => void; showDebugMessages?: boolean; showJsonMode?: boolean; - projectTools: z.infer[]; triggerCopilotChat?: (message: string) => void; }) { const [messages, setMessages] = useState[]>(chat.messages); @@ -210,7 +206,6 @@ export function Chat({ const response = await getAssistantResponseStreamId( projectId, workflow, - projectTools, [ { role: 'system', @@ -336,9 +331,7 @@ export function Chat({ workflow, systemMessage, mcpServerUrls, - toolWebhookUrl, fetchResponseError, - projectTools, ]); // Add a stop handler function diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/AddWebhookTool.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/AddWebhookTool.tsx new file mode 100644 index 00000000..6935c4ed --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/AddWebhookTool.tsx @@ -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>) => void; +} + +export function AddWebhookTool({ projectId, onAddTool }: AddWebhookToolProps) { + function handleAddTool() { + onAddTool({ + description: 'Webhook tool', + mockTool: false, + }); + } + + return ( +
+
+

+ Add webhook tool +

+
+ + + +
+ Click here to add a webhook tool: +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx index 380bb5b7..c66a4de6 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/Composio.tsx @@ -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; type ToolkitListResponse = z.infer>>; type ProjectType = z.infer; -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; + onAddTool: (tool: z.infer) => void; +}) { const [toolkits, setToolkits] = useState([]); const [projectConfig, setProjectConfig] = useState(null); const [loading, setLoading] = useState(true); @@ -29,8 +34,6 @@ export function Composio() { const [searchQuery, setSearchQuery] = useState(''); const [selectedToolkit, setSelectedToolkit] = useState(null); const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false); - const [savingTools, setSavingTools] = useState(false); - const [composioSelectedTools, setComposioSelectedTools] = useState[]>([]); 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[]) => { - 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() {
{filteredToolkits.map((toolkit) => { const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE'; - const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id; return ( handleManageTools(toolkit)} - onProjectConfigUpdate={handleProjectConfigUpdate} - onRemoveToolkitTools={handleRemoveToolkitTools} + workflowTools={tools} + onSelectToolkit={() => handleSelectToolkit(toolkit)} /> ); })} @@ -284,16 +213,13 @@ export function Composio() { )} {/* Tools Panel */} - + tools={tools} + onAddTool={onAddTool} + />}
); } \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx index e05140a7..cd0fa538 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ComposioToolsPanel.tsx @@ -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; type ToolListResponse = z.infer>>; -type ProjectType = z.infer; 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; + onAddTool: (tool: z.infer) => 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([]); const [selectedTools, setSelectedTools] = useState>(new Set()); const [hasChanges, setHasChanges] = useState(false); - const [showAuthModal, setShowAuthModal] = useState(false); - const [isProcessingAuth, setIsProcessingAuth] = useState(false); - const [composioSelectedTools, setComposioSelectedTools] = useState([]); + 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 ( - <> - - {toolkit.meta.logo && ( - - )} - {toolkit.name} -
- } - > -
- {/* Connection Status Banner */} - {!toolkit.no_auth && ( -
-
-
-
-
-

- {isToolkitConnected ? 'Toolkit Connected' : 'Authentication Required'} -

-

- {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.' - } -

-
-
- {isToolkitConnected && ( - - )} -
-
+ + {toolkit.meta.logo && ( + )} - - {/* Header */} -
-
-

Available Tools

-
- {hasChanges && ( - - )} -
+ {toolkit.name} +
+ } + > +
+ {/* Header */} +
+
+
+

Select Tools

+

+ Check the tools you want to add to your workflow +

+ {hasChanges && ( + + )}
- {/* Scrollable Tools List */} -
- {toolsLoading ? ( -
-
-

Loading tools...

-
- ) : ( -
- {tools.map((tool) => ( -
-
- handleToolSelectionChange(tool.slug, selected)} - size="sm" - /> -
-

- {tool.name} -

-

- {tool.description} -

+ {/* Search Box */} +
+
+ +
+ setSearchQuery(e.target.value)} + className="pl-10 pr-10" + size="sm" + /> + {searchQuery && ( + + )} +
+
+ + {/* Scrollable Tools List */} +
+ {toolsLoading ? ( +
+
+

+ {searchQuery ? 'Searching tools...' : 'Loading tools...'} +

+
+ ) : tools.length === 0 ? ( +
+

+ {searchQuery ? 'No tools found matching your search.' : 'No tools available.'} +

+
+ ) : ( +
+ {availableTools.map((tool) => ( +
+
+ handleToolSelectionChange(tool.slug, selected)} + size="sm" + /> +
+

+ {tool.name} +

+
+ {tool.slug}
+

+ {tool.description} +

- ))} -
- )} -
+
+ ))} +
+ )} +
- {/* Fixed Pagination Controls */} -
-
-
- - -
+ {/* Fixed Pagination Controls */} +
+
+
+ {availableTools.length > 0 && ( + + {availableTools.length} tool{availableTools.length !== 1 ? 's' : ''} found + {searchQuery && ` for "${searchQuery}"`} + + )} +
+
+ +
- - - {/* Auth Modal */} - {toolkit && ( - setShowAuthModal(false)} - toolkitSlug={toolkit.slug} - projectId={projectId} - onComplete={handleAuthComplete} - /> - )} - +
+ ); } \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/CustomMcpServer.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/CustomMcpServer.tsx new file mode 100644 index 00000000..5ee7becc --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/CustomMcpServer.tsx @@ -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; + +type ServerList = Record; + +type CustomMcpServersProps = { + tools: z.infer; + onAddTool: (tool: z.infer) => 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({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [addName, setAddName] = useState(''); + const [addUrl, setAddUrl] = useState(''); + const [addLoading, setAddLoading] = useState(false); + const [addError, setAddError] = useState(null); + const [panelServer, setPanelServer] = useState<{ name: string; url: string } | null>(null); + const [toolsLoading, setToolsLoading] = useState(false); + const [toolsError, setToolsError] = useState(null); + const [serverTools, setServerTools] = useState[]>([]); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [serverToDelete, setServerToDelete] = useState(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 ( +
+
+
+
+ +
+

+ Add your own MCP servers here. Enter the server details and select tools to add to your workflow. +

+
+
+ + {/* Add server form */} +
+
+ setAddName(e.target.value)} + placeholder="Server Name" + required + className="flex-1" + /> + setAddUrl(e.target.value)} + placeholder="Server URL" + required + className="flex-1" + /> + +
+ {addError &&
{addError}
} +
+ + {/* Server cards */} + {loading ? ( +
+
+

Loading servers...

+
+ ) : error ? ( +
{error}
+ ) : ( +
+ {Object.entries(servers).length === 0 ? ( +
No custom MCP servers added yet.
+ ) : ( + Object.entries(servers).map(([name, { serverUrl }]) => ( + handleOpenPanel(name, serverUrl)} + onDeleteServer={() => handleDeleteClick(name)} + /> + )) + )} +
+ )} + + {/* Delete confirmation modal */} + 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 */} + +
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx deleted file mode 100644 index ccf95d69..00000000 --- a/apps/rowboat/app/projects/[projectId]/tools/components/CustomServers.tsx +++ /dev/null @@ -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; -type McpToolType = z.infer['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([]); - const [selectedServer, setSelectedServer] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [togglingServers, setTogglingServers] = useState>(new Set()); - const [serverOperations, setServerOperations] = useState>(new Map()); - const [selectedTools, setSelectedTools] = useState>(new Set()); - const [hasToolChanges, setHasToolChanges] = useState(false); - const [savingTools, setSavingTools] = useState(false); - const [syncingServers, setSyncingServers] = useState>(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 ( -
-
-
-
- -
-

- Add your own MCP servers here. These servers will be available to agents in the Build view once toggled ON. -

-
-
- -
- -
-
-
- -
- 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" - /> -
-
- {filteredServers.length} {filteredServers.length === 1 ? 'server' : 'servers'} • { - filteredServers.reduce((total, server) => total + (server.availableTools?.length || 0), 0) - } tools -
-
-
- - { - setShowAddServer(false); - setNewServerName(''); - setNewServerUrl(''); - }} - title="Add Custom MCP Server" - > -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - -
-
-
- - {loading ? ( -
-
-

Loading servers...

-
- ) : error ? ( -
{error}
- ) : ( -
- {filteredServers.map((server) => ( - 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} - /> - ))} -
- )} - - { - 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} - /> -
- ); -} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/McpToolsPanel.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/McpToolsPanel.tsx new file mode 100644 index 00000000..a3f5e9d5 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/McpToolsPanel.tsx @@ -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; + onAddTool: (tool: z.infer) => void; + serverTools: z.infer[]; + toolsLoading: boolean; + toolsError: string | null; +} + +export function McpToolsPanel({ + server, + isOpen, + onClose, + tools: workflowTools, + onAddTool, + serverTools, + toolsLoading, + toolsError, +}: McpToolsPanelProps) { + const [selectedTools, setSelectedTools] = useState>(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 ( + +
+ MCP +
+ {server.name} +
+ } + > +
+ {/* Header */} +
+
+
+

Select Tools

+

+ Check the tools you want to add to your workflow +

+
+ {hasChanges && ( + + )} +
+ + {/* Search Box */} +
+
+ +
+ setSearchQuery(e.target.value)} + className="pl-10 pr-10" + size="sm" + /> + {searchQuery && ( + + )} +
+
+ + {/* Error Display */} + {toolsError && ( +
+

{toolsError}

+
+ )} + + {/* Scrollable Tools List */} +
+ {toolsLoading ? ( +
+
+

+ {searchQuery ? 'Searching tools...' : 'Loading tools...'} +

+
+ ) : availableTools.length === 0 ? ( +
+

+ {searchQuery ? 'No tools found matching your search.' : 'No tools available.'} +

+
+ ) : ( +
+ {availableTools.map((tool) => ( +
+
+ handleToolSelectionChange(tool.name, selected)} + size="sm" + /> +
+

+ {tool.name} +

+

+ {tool.description} +

+
+
+
+ ))} +
+ )} +
+ + {/* Fixed Footer */} +
+
+
+ {availableTools.length > 0 && ( + + {availableTools.length} tool{availableTools.length !== 1 ? 's' : ''} found + {searchQuery && ` for "${searchQuery}"`} + + )} +
+
+ +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ServerCard.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ServerCard.tsx new file mode 100644 index 00000000..88d32667 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ServerCard.tsx @@ -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; + 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[]>([]); + const [toolsLoading, setToolsLoading] = useState(true); + const [toolsError, setToolsError] = useState(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 ( +
+
+ {/* Header */} +
+
+ +
+
+

+ {serverName} +

+
+ {toolsLoading ? ( + + Loading tools... + + ) : toolsError ? ( + + Error loading tools + + ) : ( + + {selectedToolsCount > 0 + ? `${tools.length} tools, ${selectedToolsCount} selected` + : `${tools.length} tools` + } + + )} +
+
+ + + + + + } + onPress={onDeleteServer} + > + Delete + + + +
+ + {/* Description */} +
+

+ Custom MCP server at {serverUrl} +

+
+ + {/* Footer */} +
+
+
+ + Custom Server + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx index a0147ac3..f6457c30 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx @@ -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; -type ProjectType = z.infer; 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; } 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 (
diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx index aa4f1128..0a063aff 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx @@ -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; + onAddTool: (tool: Partial>) => void; }) { - let defaultActiveTab = 'custom'; - if (useKlavisTools) { - defaultActiveTab = 'hosted'; - } + let defaultActiveTab = 'mcp'; if (useComposioTools) { defaultActiveTab = 'composio'; } @@ -40,32 +42,28 @@ export function ToolsConfig({ {useComposioTools && (
- +
)} - {useKlavisTools && ( - - Klavis - - BETA - -
- }> -
- setActiveTab(key)} /> -
- - )} - +
- +
- +
diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/WebhookConfig.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/WebhookConfig.tsx index edc82b4a..577dfac4 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/WebhookConfig.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/WebhookConfig.tsx @@ -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 ( -
-
-

{title}

- {description && ( -

{description}

- )} -
-
{children}
-
- ); -} - -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(null); const [error, setError] = useState(null); + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [saving, setSaving] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editValue, setEditValue] = useState(''); 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 ( -
-
-
-
-