refactor tools UX: part 2

This commit is contained in:
Ramnique Singh 2025-07-23 15:37:49 +05:30
parent 751a86c34d
commit 2e3a7916e9
40 changed files with 1499 additions and 2261 deletions

View file

@ -60,7 +60,6 @@ export async function scrapeWebpage(url: string): Promise<z.infer<typeof Webpage
export async function getAssistantResponseStreamId( export async function getAssistantResponseStreamId(
projectId: string, projectId: string,
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[], messages: z.infer<typeof Message>[],
): Promise<{ streamId: string } | { billingError: string }> { ): Promise<{ streamId: string } | { billingError: string }> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
@ -83,6 +82,6 @@ export async function getAssistantResponseStreamId(
return { billingError: error || 'Billing error' }; return { billingError: error || 'Billing error' };
} }
const response = await getAgenticResponseStreamId(projectId, workflow, projectTools, messages); const response = await getAgenticResponseStreamId(projectId, workflow, messages);
return response; return response;
} }

View file

@ -3,9 +3,6 @@ import { z } from "zod";
import { import {
listToolkits as libListToolkits, listToolkits as libListToolkits,
listTools as libListTools, listTools as libListTools,
searchTools as libSearchTools,
getToolsByIds as libGetToolsByIds,
getTool as libGetTool,
getConnectedAccount as libGetConnectedAccount, getConnectedAccount as libGetConnectedAccount,
deleteConnectedAccount as libDeleteConnectedAccount, deleteConnectedAccount as libDeleteConnectedAccount,
listAuthConfigs as libListAuthConfigs, listAuthConfigs as libListAuthConfigs,
@ -23,7 +20,6 @@ import {
ZCredentials, ZCredentials,
} from "@/app/lib/composio/composio"; } from "@/app/lib/composio/composio";
import { ComposioConnectedAccount } from "@/app/lib/types/project_types"; import { ComposioConnectedAccount } from "@/app/lib/types/project_types";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { getProjectConfig, projectAuthCheck } from "./project_actions"; import { getProjectConfig, projectAuthCheck } from "./project_actions";
import { projectsCollection } from "../lib/mongodb"; import { projectsCollection } from "../lib/mongodb";
@ -46,29 +42,11 @@ export async function getToolkit(projectId: string, toolkitSlug: string): Promis
return await libGetToolkit(toolkitSlug); return await libGetToolkit(toolkitSlug);
} }
export async function listTools(projectId: string, toolkitSlug: string, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> { export async function listTools(projectId: string, toolkitSlug: string, searchQuery: string | null, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
await projectAuthCheck(projectId); 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<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
await projectAuthCheck(projectId);
return await libSearchTools(searchQuery, cursor, limit);
}
export async function getToolsByIds(projectId: string, toolSlugs: string[], cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
await projectAuthCheck(projectId);
return await libGetToolsByIds(toolSlugs, cursor);
}
export async function getTool(projectId: string, toolSlug: string): Promise<z.infer<typeof ZTool>> {
await projectAuthCheck(projectId);
return await libGetTool(toolSlug);
}
export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> { export async function createComposioManagedOauth2ConnectedAccount(projectId: string, toolkitSlug: string, callbackUrl: string): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
@ -237,196 +215,5 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str
const key = `composioConnectedAccounts.${toolkitSlug}`; const key = `composioConnectedAccounts.${toolkitSlug}`;
await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } }); 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; 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<z.infer<typeof ZTool>[]> {
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<typeof ZTool>[]): Promise<void> {
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<typeof WorkflowTool>[] = 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<void> {
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<void> {
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}`);
}
} }

View file

@ -11,8 +11,6 @@ import { check_query_limit } from "../lib/rate_limiting";
import { QueryLimitError } from "../lib/client_utils"; import { QueryLimitError } from "../lib/client_utils";
import { projectAuthCheck } from "./project_actions"; import { projectAuthCheck } from "./project_actions";
import { redisClient } from "../lib/redis"; 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 { authorizeUserAction, logUsage } from "./billing_actions";
import { USE_BILLING } from "../lib/feature_flags"; import { USE_BILLING } from "../lib/feature_flags";
import { WithStringId } from "../lib/types/types"; import { WithStringId } from "../lib/types/types";
@ -44,21 +42,12 @@ export async function getCopilotResponseStream(
if (!await check_query_limit(projectId)) { if (!await check_query_limit(projectId)) {
throw new QueryLimitError(); 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 // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId, projectId,
messages, messages,
workflow: wflow, workflow: current_workflow_config,
context, context,
dataSources: dataSources, dataSources: dataSources,
}; };
@ -97,20 +86,11 @@ export async function getCopilotAgentInstructions(
return { billingError: authResponse.error || 'Billing error' }; 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 // prepare request
const request: z.infer<typeof CopilotAPIRequest> = { const request: z.infer<typeof CopilotAPIRequest> = {
projectId, projectId,
messages, messages,
workflow: wflow, workflow: current_workflow_config,
context: { context: {
type: 'agent', type: 'agent',
name: agentName, name: agentName,

View file

@ -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<typeof CustomMcpServer>;
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<void> {
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<void> {
await projectAuthCheck(projectId);
await projectsCollection.updateOne(
{ _id: projectId },
{ $unset: { [`customMcpServers.${name}`]: "" } }
);
}
export async function fetchTools(serverUrl: string, serverName: string): Promise<z.infer<typeof WorkflowTool>[]> {
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,
};
});
}

View file

@ -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<typeof MCPServer>;
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<string, any> = {
"mcpServers.$.tools": tools
};
if (availableTools) {
update["mcpServers.$.availableTools"] = availableTools;
}
await projectsCollection.updateOne(
{ _id: projectId, "mcpServers.name": serverName },
{ $set: update }
);
}

View file

@ -14,15 +14,6 @@ import { USE_AUTH } from "../lib/feature_flags";
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions"; import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
import { authorizeUserAction } from "./billing_actions"; import { authorizeUserAction } from "./billing_actions";
import { Workflow } from "../lib/types/workflow_types"; 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 || ''; const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
@ -313,113 +304,6 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id:
return { id: projectId }; return { id: projectId };
} }
async function detectAndAddComposioTools(projectId: string, workflow: z.infer<typeof Workflow>) {
// Extract tool mentions from agent instructions
const toolMentionPattern = /\[@tool:([^\]]+)\]\(#mention[^\)]*\)/g;
const mentionedToolNames = new Set<string>();
// 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<typeof ZTool>[] = [];
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<typeof WorkflowTool>[] = 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 }> { export async function createProjectFromWorkflowJson(formData: FormData): Promise<{ id: string } | { billingError: string }> {
const user = await authCheck(); const user = await authCheck();
const workflowJson = formData.get('workflowJson') as string; const workflowJson = formData.get('workflowJson') as string;
@ -441,23 +325,9 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
return response; return response;
} }
const projectId = response.id; 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 }; return { id: projectId };
} }
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
return libCollectProjectTools(projectId);
}
export async function saveWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) { export async function saveWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);

View file

@ -13,7 +13,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
} }
// parse the payload // 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); console.log('payload', payload);
// fetch billing customer id // fetch billing customer id
@ -29,7 +29,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
async start(controller) { async start(controller) {
try { try {
// Iterate over the generator // 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) // Check if this is a message event (has role property)
if ('role' in event) { if ('role' in event) {
if (event.role === 'assistant') { if (event.role === 'assistant') {

View file

@ -1,6 +1,5 @@
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/app/lib/agents";
import { projectsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb"; import { projectsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { z } from "zod"; import { z } from "zod";
@ -78,12 +77,9 @@ export async function POST(request: Request) {
return reject('rejected'); return reject('rejected');
} }
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// this is the first turn, get the initial assistant response // this is the first turn, get the initial assistant response
// and validate it // and validate it
const { messages } = await getResponse(projectId, workflow, projectTools, []); const { messages } = await getResponse(projectId, workflow, []);
if (messages.length === 0) { if (messages.length === 0) {
logger.log('Agent response is empty'); logger.log('Agent response is empty');
return hangup(); return hangup();

View file

@ -1,6 +1,5 @@
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/app/lib/agents";
import { projectsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb"; import { projectsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
import { collectProjectTools } from "@/app/lib/project_tools";
import { PrefixLogger } from "@/app/lib/utils"; import { PrefixLogger } from "@/app/lib/utils";
import VoiceResponse from "twilio/lib/twiml/VoiceResponse"; import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
import { z } from "zod"; import { z } from "zod";
@ -50,9 +49,6 @@ export async function POST(
return hangup(); return hangup();
} }
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// add user speech as user message, and get assistant response // add user speech as user message, and get assistant response
const reqMessages: z.infer<typeof Message>[] = [ const reqMessages: z.infer<typeof Message>[] = [
...call.messages, ...call.messages,
@ -61,7 +57,7 @@ export async function POST(
content: data.SpeechResult, content: data.SpeechResult,
} }
]; ];
const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages); const { messages } = await getResponse(projectId, workflow, reqMessages);
if (messages.length === 0) { if (messages.length === 0) {
logger.log('Agent response is empty'); logger.log('Agent response is empty');
return hangup(); return hangup();

View file

@ -6,7 +6,6 @@ import { authCheck } from "../../utils";
import { ApiRequest, ApiResponse } from "../../../../lib/types/types"; import { ApiRequest, ApiResponse } from "../../../../lib/types/types";
import { check_query_limit } from "../../../../lib/rate_limiting"; import { check_query_limit } from "../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../lib/utils"; import { PrefixLogger } from "../../../../lib/utils";
import { collectProjectTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags"; import { USE_BILLING } from "@/app/lib/feature_flags";
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/app/lib/agents";
@ -61,9 +60,6 @@ export async function POST(
return Response.json({ error: "Project not found" }, { status: 404 }); return Response.json({ error: "Project not found" }, { status: 404 });
} }
// fetch project tools
const projectTools = await collectProjectTools(projectId);
// fetch workflow // fetch workflow
const workflow = project.liveWorkflow; const workflow = project.liveWorkflow;
if (!workflow) { if (!workflow) {
@ -94,7 +90,7 @@ export async function POST(
} }
// get assistant response // get assistant response
const { messages } = await getResponse(projectId, workflow, projectTools, reqMessages); const { messages } = await getResponse(projectId, workflow, reqMessages);
// log billing usage // log billing usage
if (USE_BILLING && billingCustomerId) { if (USE_BILLING && billingCustomerId) {

View file

@ -6,7 +6,6 @@ import { ObjectId, WithId } from "mongodb";
import { authCheck } from "../../../utils"; import { authCheck } from "../../../utils";
import { check_query_limit } from "../../../../../../lib/rate_limiting"; import { check_query_limit } from "../../../../../../lib/rate_limiting";
import { PrefixLogger } from "../../../../../../lib/utils"; import { PrefixLogger } from "../../../../../../lib/utils";
import { collectProjectTools } from "@/app/lib/project_tools";
import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing"; import { authorize, getCustomerIdForProject, logUsage } from "@/app/lib/billing";
import { USE_BILLING } from "@/app/lib/feature_flags"; import { USE_BILLING } from "@/app/lib/feature_flags";
import { getResponse } from "@/app/lib/agents"; import { getResponse } from "@/app/lib/agents";
@ -181,9 +180,6 @@ export async function POST(
throw new Error("Project settings not found"); throw new Error("Project settings not found");
} }
// fetch project tools
const projectTools = await collectProjectTools(session.projectId);
// fetch workflow // fetch workflow
const workflow = projectSettings.liveWorkflow; const workflow = projectSettings.liveWorkflow;
if (!workflow) { if (!workflow) {
@ -211,7 +207,7 @@ export async function POST(
const inMessages: z.infer<typeof Message>[] = convert(messages); const inMessages: z.infer<typeof Message>[] = convert(messages);
inMessages.push(userMessage); 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 convertedResponseMessages = convertBack(responseMessages);
const unsavedMessages = [ const unsavedMessages = [
userMessage, userMessage,

View file

@ -17,7 +17,6 @@ import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } f
import { qdrantClient } from '../lib/qdrant'; import { qdrantClient } from '../lib/qdrant';
import { EmbeddingRecord } from "./types/datasource_types"; import { EmbeddingRecord } from "./types/datasource_types";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./types/workflow_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 { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS } from "./agent_instructions";
import { PrefixLogger } from "./utils"; import { PrefixLogger } from "./utils";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "./types/types"; import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "./types/types";
@ -254,17 +253,27 @@ async function invokeMcpTool(
projectId: string, projectId: string,
name: string, name: string,
input: any, input: any,
mcpServerURL: string,
mcpServerName: string mcpServerName: string
) { ) {
logger = logger.child(`invokeMcpTool`); logger = logger.child(`invokeMcpTool`);
logger.log(`projectId: ${projectId}`); logger.log(`projectId: ${projectId}`);
logger.log(`name: ${name}`); logger.log(`name: ${name}`);
logger.log(`input: ${JSON.stringify(input)}`); logger.log(`input: ${JSON.stringify(input)}`);
logger.log(`mcpServerURL: ${mcpServerURL}`);
logger.log(`mcpServerName: ${mcpServerName}`); 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({ const result = await client.callTool({
name, name,
arguments: input, arguments: input,
@ -281,8 +290,6 @@ async function invokeComposioTool(
name: string, name: string,
composioData: z.infer<typeof WorkflowTool>['composioData'] & {}, composioData: z.infer<typeof WorkflowTool>['composioData'] & {},
input: any, input: any,
workflow: z.infer<typeof Workflow>,
toolDescription?: string,
) { ) {
logger = logger.child(`invokeComposioTool`); logger = logger.child(`invokeComposioTool`);
logger.log(`projectId: ${projectId}`); logger.log(`projectId: ${projectId}`);
@ -291,36 +298,12 @@ async function invokeComposioTool(
const { slug, toolkitSlug, noAuth } = composioData; 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; let connectedAccountId: string | undefined = undefined;
if (!noAuth) { if (!noAuth) {
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error(`project ${projectId} not found`);
}
connectedAccountId = project.composioConnectedAccounts?.[toolkitSlug]?.id; connectedAccountId = project.composioConnectedAccounts?.[toolkitSlug]?.id;
if (!connectedAccountId) { if (!connectedAccountId) {
throw new Error(`connected account id not found for project ${projectId} and toolkit ${toolkitSlug}`); throw new Error(`connected account id not found for project ${projectId} and toolkit ${toolkitSlug}`);
@ -447,7 +430,7 @@ function createMcpTool(
config: z.infer<typeof WorkflowTool>, config: z.infer<typeof WorkflowTool>,
projectId: string projectId: string
): Tool { ): Tool {
const { name, description, parameters, mcpServerName, mcpServerURL } = config; const { name, description, parameters, mcpServerName } = config;
return tool({ return tool({
name, name,
@ -461,7 +444,7 @@ function createMcpTool(
}, },
async execute(input: any) { async execute(input: any) {
try { try {
const result = await invokeMcpTool(logger, projectId, name, input, mcpServerURL || '', mcpServerName || ''); const result = await invokeMcpTool(logger, projectId, name, input, mcpServerName || '');
return JSON.stringify({ return JSON.stringify({
result, result,
}); });
@ -479,8 +462,7 @@ function createMcpTool(
function createComposioTool( function createComposioTool(
logger: PrefixLogger, logger: PrefixLogger,
config: z.infer<typeof WorkflowTool>, config: z.infer<typeof WorkflowTool>,
projectId: string, projectId: string
workflow: z.infer<typeof Workflow>
): Tool { ): Tool {
const { name, description, parameters, composioData } = config; const { name, description, parameters, composioData } = config;
@ -500,7 +482,7 @@ function createComposioTool(
}, },
async execute(input: any) { async execute(input: any) {
try { try {
const result = await invokeComposioTool(logger, projectId, name, composioData, input, workflow, description); const result = await invokeComposioTool(logger, projectId, name, composioData, input);
return JSON.stringify({ return JSON.stringify({
result, result,
}); });
@ -520,7 +502,6 @@ function createAgent(
projectId: string, projectId: string,
config: z.infer<typeof WorkflowAgent>, config: z.infer<typeof WorkflowAgent>,
tools: Record<string, Tool>, tools: Record<string, Tool>,
projectTools: z.infer<typeof WorkflowTool>[],
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
promptConfig: Record<string, z.infer<typeof WorkflowPrompt>>, promptConfig: Record<string, z.infer<typeof WorkflowPrompt>>,
): { agent: Agent, entities: z.infer<typeof ConnectedEntity>[] } { ): { agent: Agent, entities: z.infer<typeof ConnectedEntity>[] } {
@ -550,7 +531,7 @@ ${'-'.repeat(100)}
${CHILD_TRANSFER_RELATED_INSTRUCTIONS} ${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(`instructions: ${JSON.stringify(sanitized)}`);
agentLogger.log(`mentions: ${JSON.stringify(entities)}`); agentLogger.log(`mentions: ${JSON.stringify(entities)}`);
@ -783,7 +764,7 @@ Basic context:
} }
} }
function mapConfig(workflow: z.infer<typeof Workflow>, projectTools: z.infer<typeof WorkflowTool>[]): { function mapConfig(workflow: z.infer<typeof Workflow>): {
agentConfig: Record<string, z.infer<typeof WorkflowAgent>>; agentConfig: Record<string, z.infer<typeof WorkflowAgent>>;
toolConfig: Record<string, z.infer<typeof WorkflowTool>>; toolConfig: Record<string, z.infer<typeof WorkflowTool>>;
promptConfig: Record<string, z.infer<typeof WorkflowPrompt>>; promptConfig: Record<string, z.infer<typeof WorkflowPrompt>>;
@ -792,10 +773,7 @@ function mapConfig(workflow: z.infer<typeof Workflow>, projectTools: z.infer<typ
...acc, ...acc,
[agent.name]: agent [agent.name]: agent
}), {}); }), {});
const toolConfig: Record<string, z.infer<typeof WorkflowTool>> = [ const toolConfig: Record<string, z.infer<typeof WorkflowTool>> = workflow.tools.reduce((acc, tool) => ({
...workflow.tools,
...projectTools,
].reduce((acc, tool) => ({
...acc, ...acc,
[tool.name]: tool [tool.name]: tool
}), {}); }), {});
@ -837,15 +815,15 @@ function createTools(
mockInstructions: workflow.mockTools?.[toolName], // override mock instructions mockInstructions: workflow.mockTools?.[toolName], // override mock instructions
}); });
logger.log(`created mock tool: ${toolName}`); 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) { } else if (config.isMcp) {
tools[toolName] = createMcpTool(logger, config, projectId); tools[toolName] = createMcpTool(logger, config, projectId);
logger.log(`created mcp tool: ${toolName}`); logger.log(`created mcp tool: ${toolName}`);
} else if (config.isComposio) { } else if (config.isComposio) {
tools[toolName] = createComposioTool(logger, config, projectId, workflow); tools[toolName] = createComposioTool(logger, config, projectId);
logger.log(`created composio tool: ${toolName}`); logger.log(`created composio tool: ${toolName}`);
} else if (config.mockTool) {
tools[toolName] = createMockTool(logger, config);
logger.log(`created mock tool: ${toolName}`);
} else { } else {
tools[toolName] = createWebhookTool(logger, config, projectId); tools[toolName] = createWebhookTool(logger, config, projectId);
logger.log(`created webhook tool: ${toolName}`); logger.log(`created webhook tool: ${toolName}`);
@ -860,7 +838,6 @@ function createAgents(
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
agentConfig: Record<string, z.infer<typeof WorkflowAgent>>, agentConfig: Record<string, z.infer<typeof WorkflowAgent>>,
tools: Record<string, Tool>, tools: Record<string, Tool>,
projectTools: z.infer<typeof WorkflowTool>[],
promptConfig: Record<string, z.infer<typeof WorkflowPrompt>>, promptConfig: Record<string, z.infer<typeof WorkflowPrompt>>,
): { agents: Record<string, Agent>, mentions: Record<string, z.infer<typeof ConnectedEntity>[]>, originalInstructions: Record<string, string>, originalHandoffs: Record<string, Agent[]> } { ): { agents: Record<string, Agent>, mentions: Record<string, z.infer<typeof ConnectedEntity>[]>, originalInstructions: Record<string, string>, originalHandoffs: Record<string, Agent[]> } {
const agents: Record<string, Agent> = {}; const agents: Record<string, Agent> = {};
@ -875,7 +852,6 @@ function createAgents(
projectId, projectId,
config, config,
tools, tools,
projectTools,
workflow, workflow,
promptConfig, promptConfig,
); );
@ -956,7 +932,6 @@ function maybeInjectGiveUpControlInstructions(
export async function* streamResponse( export async function* streamResponse(
projectId: string, projectId: string,
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[], messages: z.infer<typeof Message>[],
): AsyncIterable<z.infer<typeof ZOutMessage> | z.infer<typeof ZUsage>> { ): AsyncIterable<z.infer<typeof ZOutMessage> | z.infer<typeof ZUsage>> {
// Divider log for tracking agent loop start // Divider log for tracking agent loop start
@ -975,7 +950,7 @@ export async function* streamResponse(
} }
// create map of agent, tool and prompt configs // create map of agent, tool and prompt configs
const { agentConfig, toolConfig, promptConfig } = mapConfig(workflow, projectTools); const { agentConfig, toolConfig, promptConfig } = mapConfig(workflow);
const stack: string[] = []; const stack: string[] = [];
@ -985,7 +960,7 @@ export async function* streamResponse(
const tools = createTools(logger, projectId, workflow, toolConfig); const tools = createTools(logger, projectId, workflow, toolConfig);
// create agents // 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 // track agent to agent calls
const transferCounter = new AgentTransferCounter(); const transferCounter = new AgentTransferCounter();
@ -1241,7 +1216,6 @@ export async function* streamResponse(
export async function getResponse( export async function getResponse(
projectId: string, projectId: string,
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[], messages: z.infer<typeof Message>[],
): Promise<{ ): Promise<{
messages: z.infer<typeof ZOutMessage>[], messages: z.infer<typeof ZOutMessage>[],
@ -1255,7 +1229,7 @@ export async function getResponse(
completion: 0, 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) { if ('role' in event) {
out.push(event); out.push(event);
} }

View file

@ -278,11 +278,14 @@ export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZG
return composioApiCall(ZGetToolkitResponse, url.toString()); return composioApiCall(ZGetToolkitResponse, url.toString());
} }
export async function listTools(toolkitSlug: string, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> { export async function listTools(toolkitSlug: string, searchQuery: string | null = null, cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
const url = new URL(`${BASE_URL}/tools`); const url = new URL(`${BASE_URL}/tools`);
// set params // set params
url.searchParams.set("toolkit_slug", toolkitSlug); url.searchParams.set("toolkit_slug", toolkitSlug);
if (searchQuery) {
url.searchParams.set("search", searchQuery);
}
if (cursor) { if (cursor) {
url.searchParams.set("cursor", 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()); return composioApiCall(ZListResponse(ZTool), url.toString());
} }
export async function searchTools(searchQuery: string, cursor: string | null = null, limit: number = 50): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
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<z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>> {
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<z.infer<typeof ZTool>> {
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<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> { export async function listAuthConfigs(toolkitSlug: string, cursor: string | null = null, managedOnly: boolean = false): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
const url = new URL(`${BASE_URL}/auth_configs`); const url = new URL(`${BASE_URL}/auth_configs`);
url.searchParams.set("toolkit_slug", toolkitSlug); url.searchParams.set("toolkit_slug", toolkitSlug);

View file

@ -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<z.infer<typeof WorkflowTool>[]> {
const tools: z.infer<typeof WorkflowTool>[] = [];
// 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;
}

View file

@ -14,6 +14,10 @@ export const ComposioConnectedAccount = z.object({
lastUpdatedAt: z.string().datetime(), lastUpdatedAt: z.string().datetime(),
}); });
export const CustomMcpServer = z.object({
serverUrl: z.string(),
});
export const Project = z.object({ export const Project = z.object({
_id: z.string().uuid(), _id: z.string().uuid(),
name: z.string(), name: z.string(),
@ -29,6 +33,7 @@ export const Project = z.object({
testRunCounter: z.number().default(0), testRunCounter: z.number().default(0),
mcpServers: z.array(MCPServer).optional(), mcpServers: z.array(MCPServer).optional(),
composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(), composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(),
customMcpServers: z.record(z.string(), CustomMcpServer).optional(),
}); });
export const ProjectMember = z.object({ export const ProjectMember = z.object({
@ -43,20 +48,4 @@ export const ApiKey = z.object({
key: z.string(), key: z.string(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(), lastUsedAt: z.string().datetime().optional(),
}); });
export function mergeProjectTools(
workflowTools: z.infer<typeof WorkflowTool>[],
projectTools: z.infer<typeof WorkflowTool>[]
): z.infer<typeof WorkflowTool>[] {
// 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;
}

View file

@ -210,6 +210,5 @@ export function convertMcpServerToolToWorkflowTool(
export const ZStreamAgentResponsePayload = z.object({ export const ZStreamAgentResponsePayload = z.object({
projectId: z.string(), projectId: z.string(),
workflow: Workflow, workflow: Workflow,
projectTools: z.array(WorkflowTool),
messages: z.array(Message), messages: z.array(Message),
}); });

View file

@ -39,7 +39,6 @@ export const WorkflowTool = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
mockTool: z.boolean().default(false).optional(), mockTool: z.boolean().default(false).optional(),
autoSubmitMockedResponse: z.boolean().default(false).optional(),
mockInstructions: z.string().optional(), mockInstructions: z.string().optional(),
parameters: z.object({ parameters: z.object({
type: z.literal('object'), type: z.literal('object'),
@ -48,10 +47,9 @@ export const WorkflowTool = z.object({
additionalProperties: z.boolean().optional(), additionalProperties: z.boolean().optional(),
}), }),
isMcp: z.boolean().default(false).optional(), isMcp: z.boolean().default(false).optional(),
isLibrary: z.boolean().default(false).optional(),
mcpServerName: z.string().optional(), mcpServerName: z.string().optional(),
mcpServerURL: z.string().optional(),
isComposio: z.boolean().optional(), // whether this is a Composio tool 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({ composioData: z.object({
slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE" slug: z.string(), // the slug for the Composio tool e.g. "GITHUB_CREATE_AN_ISSUE"
noAuth: z.boolean(), // whether the tool requires no authentication noAuth: z.boolean(), // whether the tool requires no authentication
@ -67,14 +65,6 @@ export const Workflow = z.object({
startAgent: z.string(), startAgent: z.string(),
lastUpdatedAt: z.string().datetime(), lastUpdatedAt: z.string().datetime(),
mockTools: z.record(z.string(), z.string()).optional(), // a dict of toolName => mockInstructions 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 export const WorkflowTemplate = Workflow
.omit({ .omit({
@ -97,7 +87,6 @@ export function sanitizeTextWithMentions(
tools: z.infer<typeof WorkflowTool>[], tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[], prompts: z.infer<typeof WorkflowPrompt>[],
}, },
projectTools: z.infer<typeof WorkflowTool>[] = []
): { ): {
sanitized: string; sanitized: string;
entities: z.infer<typeof ConnectedEntity>[]; entities: z.infer<typeof ConnectedEntity>[];
@ -127,8 +116,7 @@ export function sanitizeTextWithMentions(
if (entity.type === 'agent') { if (entity.type === 'agent') {
return workflow.agents.some(a => a.name === entity.name); return workflow.agents.some(a => a.name === entity.name);
} else if (entity.type === 'tool') { } else if (entity.type === 'tool') {
return workflow.tools.some(t => t.name === entity.name) || return workflow.tools.some(t => t.name === entity.name);
projectTools.some(t => t.name === entity.name);
} else if (entity.type === 'prompt') { } else if (entity.type === 'prompt') {
return workflow.prompts.some(p => p.name === entity.name); return workflow.prompts.some(p => p.name === entity.name);
} }

View file

@ -8,7 +8,6 @@ import { Message, ZStreamAgentResponsePayload } from "./types/types";
export async function getAgenticResponseStreamId( export async function getAgenticResponseStreamId(
projectId: string, projectId: string,
workflow: z.infer<typeof Workflow>, workflow: z.infer<typeof Workflow>,
projectTools: z.infer<typeof WorkflowTool>[],
messages: z.infer<typeof Message>[], messages: z.infer<typeof Message>[],
): Promise<{ ): Promise<{
streamId: string, streamId: string,
@ -16,7 +15,6 @@ export async function getAgenticResponseStreamId(
const payload: z.infer<typeof ZStreamAgentResponsePayload> = { const payload: z.infer<typeof ZStreamAgentResponsePayload> = {
projectId, projectId,
workflow, workflow,
projectTools,
messages, messages,
} }
// serialize the request // serialize the request

View file

@ -42,7 +42,6 @@ export function AgentConfig({
usedAgentNames, usedAgentNames,
agents, agents,
tools, tools,
projectTools,
prompts, prompts,
dataSources, dataSources,
handleUpdate, handleUpdate,
@ -57,7 +56,6 @@ export function AgentConfig({
usedAgentNames: Set<string>, usedAgentNames: Set<string>,
agents: z.infer<typeof WorkflowAgent>[], agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[], tools: z.infer<typeof WorkflowTool>[],
projectTools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[], prompts: z.infer<typeof WorkflowPrompt>[],
dataSources: WithStringId<z.infer<typeof DataSource>>[], dataSources: WithStringId<z.infer<typeof DataSource>>[],
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void, handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
@ -172,7 +170,7 @@ export function AgentConfig({
const atMentions = createAtMentions({ const atMentions = createAtMentions({
agents, agents,
prompts, prompts,
tools: [...tools, ...projectTools], tools,
currentAgentName: agent.name currentAgentName: agent.name
}); });

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { WorkflowTool } from "../../../lib/types/workflow_types"; 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 { 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 { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button"; 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 { ToolParamCard } from "@/components/common/tool-param-card";
import { UserIcon, Settings, Settings2 } from "lucide-react"; import { UserIcon, Settings, Settings2 } from "lucide-react";
import { EditableField } from "@/app/lib/components/editable-field"; import { EditableField } from "@/app/lib/components/editable-field";
import Link from "next/link";
// Update textarea styles with improved states // 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"; 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 || [] required: tool.parameters?.required || []
}); });
const params = useParams();
const projectId = params.projectId as string;
const [selectedParams, setSelectedParams] = useState(new Set([])); 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<string | null>(null); const [nameError, setNameError] = useState<string | null>(null);
// Log when parameters are being rendered // Log when parameters are being rendered
@ -337,6 +342,57 @@ export function ToolConfig({
} }
> >
<div className="flex flex-col gap-4 pb-4 pt-4 p-4"> <div className="flex flex-col gap-4 pb-4 pt-4 p-4">
{/* Tool Type Section */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
{tool.isMcp ? (
<ImportIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
) : tool.isComposio ? (
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
) : (
<Globe className="w-5 h-5 text-green-600 dark:text-green-400" />
)}
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
How this tool runs
</h3>
{tool.isMcp ? (
<div className="text-sm text-gray-700 dark:text-gray-300">
<p>This tool is powered by the <span className="font-medium text-blue-700 dark:text-blue-300">{tool.mcpServerName}</span> MCP server.</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
MCP (Model Context Protocol) tools are external services that provide additional capabilities to your agent.
</p>
</div>
) : tool.isComposio ? (
<div className="text-sm text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-2 mb-1">
<p>This tool is powered by <span className="font-medium text-purple-700 dark:text-purple-300">Composio</span></p>
{tool.composioData?.toolkitName && (
<span className="text-xs bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 px-2 py-1 rounded-full">
{tool.composioData.toolkitName}
</span>
)}
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Composio provides pre-built integrations with popular services and APIs.
</p>
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
<div className="flex items-center gap-1 mb-1">
<p>This tool is invoked using the webhook configured in <Link href={`/projects/${projectId}/config`} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 font-medium underline decoration-green-300 hover:decoration-green-500 transition-colors">project settings</Link></p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
Webhook tools make HTTP requests to your configured endpoint when called by the agent.
</p>
</div>
)}
</div>
</div>
</div>
{/* Identity Section */} {/* Identity Section */}
<SectionCard <SectionCard
icon={<UserIcon className="w-5 h-5 text-indigo-500" />} icon={<UserIcon className="w-5 h-5 text-indigo-500" />}
@ -350,6 +406,7 @@ export function ToolConfig({
<div className="flex-1"> <div className="flex-1">
<EditableField <EditableField
value={tool.name} value={tool.name}
locked={isReadOnly}
onChange={(value) => { onChange={(value) => {
setNameError(validateToolName(value)); setNameError(validateToolName(value));
if (!validateToolName(value)) { if (!validateToolName(value)) {
@ -375,6 +432,7 @@ export function ToolConfig({
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Description</label> <label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Description</label>
<div className="flex-1"> <div className="flex-1">
<EditableField <EditableField
locked={isReadOnly}
value={tool.description || ""} value={tool.description || ""}
onChange={(value) => handleUpdate({ ...tool, description: value })} onChange={(value) => handleUpdate({ ...tool, description: value })}
multiline={true} multiline={true}
@ -385,56 +443,37 @@ export function ToolConfig({
</div> </div>
</div> </div>
</SectionCard> </SectionCard>
{/* Behavior Section */} {/* Mock Section */}
<SectionCard <SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />} icon={<Settings className="w-5 h-5 text-indigo-500" />}
title="Behavior" title="Mock responses"
labelWidth="md:w-32" labelWidth="md:w-64"
className="mb-1" className="mb-1"
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{!isReadOnly && ( <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <div className="flex items-center gap-3">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Tool Mode</label> <Switch
<RadioGroup isSelected={tool.mockTool}
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
onValueChange={(value) => handleUpdate({ onValueChange={(value) => handleUpdate({
...tool, ...tool,
mockTool: value === "mock", mockTool: value,
autoSubmitMockedResponse: value === "mock" ? true : undefined
})} })}
orientation="horizontal" size="sm"
classNames={{ color="primary"
wrapper: "flex flex-col md:flex-row gap-2 md:gap-8 pl-0 md:pl-3", />
label: "text-sm" <label className="text-sm font-semibold text-gray-600 dark:text-gray-300">
}} Mock tool responses
> </label>
<Radio
value="mock"
classNames={{
base: "px-2 py-1 rounded-lg transition-colors",
label: "text-sm font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Mock tool responses
</Radio>
<Radio
value="api"
classNames={{
base: "px-2 py-1 rounded-lg transition-colors",
label: "text-sm font-normal text-gray-900 dark:text-gray-100 px-3 py-1"
}}
>
Connect tool to your API
</Radio>
</RadioGroup>
</div> </div>
)} <span className="text-xs text-gray-500 dark:text-gray-400 ml-12">
When enabled, this tool will be mocked.
</span>
</div>
{tool.mockTool && ( {tool.mockTool && (
<div className="flex flex-col gap-2 pl-0 md:pl-3 mt-2"> <div className="flex flex-col gap-2 ml-12">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Mock Response Schema</label> <label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Mock Response Instructions</label>
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1">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.</span> <span className="text-xs text-gray-500 dark:text-gray-400 mb-1">Describe the response the mock tool should return. This will be shown in the chat when the tool is called.</span>
<EditableField <EditableField
value={tool.mockInstructions || ''} value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({ onChange={(value) => handleUpdate({
@ -445,20 +484,6 @@ export function ToolConfig({
placeholder="Mock response instructions..." placeholder="Mock response instructions..."
className="w-full text-xs p-2 bg-white dark:bg-gray-900" className="w-full text-xs p-2 bg-white dark:bg-gray-900"
/> />
<Checkbox
size="sm"
isSelected={tool.autoSubmitMockedResponse ?? true}
onValueChange={(value) => handleUpdate({
...tool,
autoSubmitMockedResponse: value
})}
disabled={isReadOnly}
className="mt-2"
>
<span className="text-sm text-gray-600 dark:text-gray-300">
Automatically send mock response in chat
</span>
</Checkbox>
</div> </div>
)} )}
</div> </div>

View file

@ -19,10 +19,8 @@ export function App({
workflow, workflow,
messageSubscriber, messageSubscriber,
mcpServerUrls, mcpServerUrls,
toolWebhookUrl,
isInitialState = false, isInitialState = false,
onPanelClick, onPanelClick,
projectTools,
triggerCopilotChat, triggerCopilotChat,
}: { }: {
hidden?: boolean; hidden?: boolean;
@ -30,10 +28,8 @@ export function App({
workflow: z.infer<typeof Workflow>; workflow: z.infer<typeof Workflow>;
messageSubscriber?: (messages: z.infer<typeof Message>[]) => void; messageSubscriber?: (messages: z.infer<typeof Message>[]) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>; mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
isInitialState?: boolean; isInitialState?: boolean;
onPanelClick?: () => void; onPanelClick?: () => void;
projectTools: z.infer<typeof WorkflowTool>[];
triggerCopilotChat?: (message: string) => void; triggerCopilotChat?: (message: string) => void;
}) { }) {
const [counter, setCounter] = useState<number>(0); const [counter, setCounter] = useState<number>(0);
@ -156,10 +152,8 @@ export function App({
systemMessage={systemMessage} systemMessage={systemMessage}
onSystemMessageChange={handleSystemMessageChange} onSystemMessageChange={handleSystemMessageChange}
mcpServerUrls={mcpServerUrls} mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
onCopyClick={(fn) => { getCopyContentRef.current = fn; }} onCopyClick={(fn) => { getCopyContentRef.current = fn; }}
showDebugMessages={showDebugMessages} showDebugMessages={showDebugMessages}
projectTools={projectTools}
triggerCopilotChat={triggerCopilotChat} triggerCopilotChat={triggerCopilotChat}
/> />
</div> </div>

View file

@ -22,11 +22,9 @@ export function Chat({
systemMessage, systemMessage,
onSystemMessageChange, onSystemMessageChange,
mcpServerUrls, mcpServerUrls,
toolWebhookUrl,
onCopyClick, onCopyClick,
showDebugMessages = true, showDebugMessages = true,
showJsonMode = false, showJsonMode = false,
projectTools,
triggerCopilotChat, triggerCopilotChat,
}: { }: {
chat: z.infer<typeof PlaygroundChat>; chat: z.infer<typeof PlaygroundChat>;
@ -36,11 +34,9 @@ export function Chat({
systemMessage: string; systemMessage: string;
onSystemMessageChange: (message: string) => void; onSystemMessageChange: (message: string) => void;
mcpServerUrls: Array<z.infer<typeof MCPServer>>; mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
onCopyClick: (fn: () => string) => void; onCopyClick: (fn: () => string) => void;
showDebugMessages?: boolean; showDebugMessages?: boolean;
showJsonMode?: boolean; showJsonMode?: boolean;
projectTools: z.infer<typeof WorkflowTool>[];
triggerCopilotChat?: (message: string) => void; triggerCopilotChat?: (message: string) => void;
}) { }) {
const [messages, setMessages] = useState<z.infer<typeof Message>[]>(chat.messages); const [messages, setMessages] = useState<z.infer<typeof Message>[]>(chat.messages);
@ -210,7 +206,6 @@ export function Chat({
const response = await getAssistantResponseStreamId( const response = await getAssistantResponseStreamId(
projectId, projectId,
workflow, workflow,
projectTools,
[ [
{ {
role: 'system', role: 'system',
@ -336,9 +331,7 @@ export function Chat({
workflow, workflow,
systemMessage, systemMessage,
mcpServerUrls, mcpServerUrls,
toolWebhookUrl,
fetchResponseError, fetchResponseError,
projectTools,
]); ]);
// Add a stop handler function // Add a stop handler function

View file

@ -0,0 +1,42 @@
'use client';
import React from 'react';
import { WebhookConfig } from './WebhookConfig';
import { Button } from '@heroui/react';
import { WorkflowTool } from '@/app/lib/types/workflow_types';
import { z } from 'zod';
interface AddWebhookToolProps {
projectId: string;
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
}
export function AddWebhookTool({ projectId, onAddTool }: AddWebhookToolProps) {
function handleAddTool() {
onAddTool({
description: 'Webhook tool',
mockTool: false,
});
}
return (
<div className="space-y-6">
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Add webhook tool
</h2>
</div>
<WebhookConfig projectId={projectId} />
<div>
Click here to add a webhook tool:
</div>
<Button
size="lg"
color="primary"
onPress={handleAddTool}
>Add webhook tool</Button>
</div>
);
}

View file

@ -3,25 +3,30 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Info, RefreshCw, Search } from 'lucide-react'; import { RefreshCw, Search } from 'lucide-react';
import clsx from 'clsx'; 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 { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod'; import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio'; import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types'; import { Project } from '@/app/lib/types/project_types';
import { ComposioToolsPanel } from './ComposioToolsPanel'; import { ComposioToolsPanel } from './ComposioToolsPanel';
import { ToolkitCard } from './ToolkitCard'; import { ToolkitCard } from './ToolkitCard';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
type ToolkitType = z.infer<typeof ZToolkit>; type ToolkitType = z.infer<typeof ZToolkit>;
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>; type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
type ProjectType = z.infer<typeof Project>; type ProjectType = z.infer<typeof Project>;
export function Composio() { export function Composio({
const params = useParams(); projectId,
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0]; tools,
if (!projectId) throw new Error('Project ID is required'); onAddTool
}: {
projectId: string;
tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
}) {
const [toolkits, setToolkits] = useState<ToolkitType[]>([]); const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null); const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -29,8 +34,6 @@ export function Composio() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null); const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false); const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const [savingTools, setSavingTools] = useState(false);
const [composioSelectedTools, setComposioSelectedTools] = useState<z.infer<typeof ZTool>[]>([]);
const loadProjectConfig = useCallback(async () => { const loadProjectConfig = useCallback(async () => {
try { try {
@ -42,15 +45,6 @@ export function Composio() {
} }
}, [projectId]); }, [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 () => { const loadAllToolkits = useCallback(async () => {
let cursor: string | null = null; let cursor: string | null = null;
let allToolkits: ToolkitType[] = []; let allToolkits: ToolkitType[] = [];
@ -85,7 +79,7 @@ export function Composio() {
} }
}, [projectId]); }, [projectId]);
const handleManageTools = useCallback((toolkit: ToolkitType) => { const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
setSelectedToolkit(toolkit); setSelectedToolkit(toolkit);
setIsToolsPanelOpen(true); setIsToolsPanelOpen(true);
}, []); }, []);
@ -95,69 +89,9 @@ export function Composio() {
setIsToolsPanelOpen(false); setIsToolsPanelOpen(false);
}, []); }, []);
const handleProjectConfigUpdate = useCallback(() => {
loadProjectConfig();
loadComposioSelectedTools();
}, [loadProjectConfig, loadComposioSelectedTools]);
const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer<typeof ZTool>[]) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from workflow
const existingSelectedTools = composioSelectedTools;
// Create a map of existing tools by slug for easy lookup
const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool]));
// Add or update the new selections
for (const tool of selectedToolObjects) {
existingToolsMap.set(tool.slug, tool);
}
// Convert back to array
const mergedSelectedTools = Array.from(existingToolsMap.values());
await updateComposioSelectedTools(projectId, mergedSelectedTools);
// Refresh data to get updated tools
await loadComposioSelectedTools();
} catch (error) {
console.error('Error saving tool selection:', error);
} finally {
setSavingTools(false);
}
}, [projectId, composioSelectedTools, loadComposioSelectedTools]);
const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from workflow
const existingSelectedTools = composioSelectedTools;
// Filter out all tools from the specified toolkit
const filteredSelectedTools = existingSelectedTools.filter(tool =>
tool.toolkit.slug !== toolkitSlug
);
await updateComposioSelectedTools(projectId, filteredSelectedTools);
// Refresh data to get updated tools
await loadComposioSelectedTools();
} catch (error) {
console.error('Error removing toolkit tools:', error);
} finally {
setSavingTools(false);
}
}, [projectId, composioSelectedTools, loadComposioSelectedTools]);
useEffect(() => { useEffect(() => {
loadProjectConfig(); loadProjectConfig();
loadComposioSelectedTools(); }, [loadProjectConfig]);
}, [loadProjectConfig, loadComposioSelectedTools]);
useEffect(() => { useEffect(() => {
loadAllToolkits(); loadAllToolkits();
@ -257,19 +191,14 @@ export function Composio() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredToolkits.map((toolkit) => { {filteredToolkits.map((toolkit) => {
const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE'; const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
return ( return (
<ToolkitCard <ToolkitCard
key={toolkit.slug} key={toolkit.slug}
toolkit={toolkit} toolkit={toolkit}
projectId={projectId}
isConnected={isConnected} isConnected={isConnected}
connectedAccountId={connectedAccountId} workflowTools={tools}
projectConfig={projectConfig} onSelectToolkit={() => handleSelectToolkit(toolkit)}
onManageTools={() => handleManageTools(toolkit)}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
/> />
); );
})} })}
@ -284,16 +213,13 @@ export function Composio() {
)} )}
{/* Tools Panel */} {/* Tools Panel */}
<ComposioToolsPanel {selectedToolkit && <ComposioToolsPanel
toolkit={selectedToolkit} toolkit={selectedToolkit}
isOpen={isToolsPanelOpen} isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel} onClose={handleCloseToolsPanel}
projectConfig={projectConfig} tools={tools}
onUpdateToolsSelection={handleUpdateToolsSelection} onAddTool={onAddTool}
onProjectConfigUpdate={handleProjectConfigUpdate} />}
onRemoveToolkitTools={handleRemoveToolkitTools}
isSaving={savingTools}
/>
</div> </div>
); );
} }

View file

@ -1,20 +1,18 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { PictureImg } from '@/components/ui/picture-img'; import { PictureImg } from '@/components/ui/picture-img';
import { Button, Checkbox } from '@heroui/react'; import { Button, Checkbox, Input } from '@heroui/react';
import { ChevronLeft, ChevronRight, LinkIcon, Loader2, UnlinkIcon } from 'lucide-react'; import { ChevronLeft, ChevronRight, Search, X } from 'lucide-react';
import { listTools, deleteConnectedAccount, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions'; import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { listTools } from '@/app/actions/composio_actions';
import { z } from 'zod'; import { z } from 'zod';
import { ZTool, ZListResponse } from '@/app/lib/composio/composio'; import { ZTool, ZListResponse } from '@/app/lib/composio/composio';
import { SlidePanel } from '@/components/ui/slide-panel'; import { SlidePanel } from '@/components/ui/slide-panel';
import { Project } from '@/app/lib/types/project_types';
import { ToolkitAuthModal } from './ToolkitAuthModal';
type ToolType = z.infer<typeof ZTool>; type ToolType = z.infer<typeof ZTool>;
type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>; type ToolListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZTool>>>;
type ProjectType = z.infer<typeof Project>;
interface ComposioToolsPanelProps { interface ComposioToolsPanelProps {
toolkit: { toolkit: {
@ -24,25 +22,19 @@ interface ComposioToolsPanelProps {
logo: string; logo: string;
}; };
no_auth?: boolean; no_auth?: boolean;
} | null; };
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
projectConfig: ProjectType | null; tools: z.infer<typeof Workflow.shape.tools>;
onUpdateToolsSelection: (selectedToolObjects: ToolType[]) => void; onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
onProjectConfigUpdate: () => void;
onRemoveToolkitTools: (toolkitSlug: string) => void;
isSaving: boolean;
} }
export function ComposioToolsPanel({ export function ComposioToolsPanel({
toolkit, toolkit,
isOpen, isOpen,
onClose, onClose,
projectConfig, tools: workflowTools,
onUpdateToolsSelection, onAddTool,
onProjectConfigUpdate,
onRemoveToolkitTools,
isSaving
}: ComposioToolsPanelProps) { }: ComposioToolsPanelProps) {
const params = useParams(); const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0]; const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
@ -55,15 +47,30 @@ export function ComposioToolsPanel({
const [cursorHistory, setCursorHistory] = useState<string[]>([]); const [cursorHistory, setCursorHistory] = useState<string[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set()); const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false); const [searchQuery, setSearchQuery] = useState('');
const [isProcessingAuth, setIsProcessingAuth] = useState(false); const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [composioSelectedTools, setComposioSelectedTools] = useState<ToolType[]>([]);
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 { try {
setToolsLoading(true); setToolsLoading(true);
const response: ToolListResponse = await listTools(projectId, toolkitSlug, cursor); const response: ToolListResponse = await listTools(projectId, toolkitSlug, search, cursor);
setTools(response.items); setTools(response.items);
setNextCursor(response.next_cursor); setNextCursor(response.next_cursor);
@ -81,27 +88,25 @@ export function ComposioToolsPanel({
} }
}, [projectId]); }, [projectId]);
const loadComposioSelectedTools = useCallback(async () => { // Load tools when search query changes
try { useEffect(() => {
const tools = await getComposioToolsFromWorkflow(projectId); if (toolkit && isOpen) {
setComposioSelectedTools(tools); loadToolsForToolkit(toolkit.slug, null, debouncedSearchQuery || null);
} catch (err: any) {
console.error('Error fetching composio selected tools:', err);
} }
}, [projectId]); }, [toolkit, isOpen, debouncedSearchQuery, loadToolsForToolkit]);
const handleNextPage = useCallback(async () => { const handleNextPage = useCallback(async () => {
if (!nextCursor || !toolkit) return; if (!nextCursor) return;
// Add current cursor to history // Add current cursor to history
setCursorHistory(prev => [...prev, currentCursor || '']); setCursorHistory(prev => [...prev, currentCursor || '']);
setCurrentCursor(nextCursor); setCurrentCursor(nextCursor);
await loadToolsForToolkit(toolkit.slug, nextCursor); await loadToolsForToolkit(toolkit.slug, nextCursor, debouncedSearchQuery || null);
}, [nextCursor, toolkit, currentCursor, loadToolsForToolkit]); }, [nextCursor, toolkit, currentCursor, debouncedSearchQuery, loadToolsForToolkit]);
const handlePreviousPage = useCallback(async () => { const handlePreviousPage = useCallback(async () => {
if (cursorHistory.length === 0 || !toolkit) return; if (cursorHistory.length === 0) return;
// Get the previous cursor from history // Get the previous cursor from history
const previousCursor = cursorHistory[cursorHistory.length - 1]; const previousCursor = cursorHistory[cursorHistory.length - 1];
@ -110,8 +115,8 @@ export function ComposioToolsPanel({
setCursorHistory(newHistory); setCursorHistory(newHistory);
setCurrentCursor(previousCursor); setCurrentCursor(previousCursor);
await loadToolsForToolkit(toolkit.slug, previousCursor); await loadToolsForToolkit(toolkit.slug, previousCursor, debouncedSearchQuery || null);
}, [cursorHistory, toolkit, loadToolsForToolkit]); }, [cursorHistory, toolkit, debouncedSearchQuery, loadToolsForToolkit]);
const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => { const handleToolSelectionChange = useCallback((toolSlug: string, selected: boolean) => {
setSelectedTools(prev => { setSelectedTools(prev => {
@ -126,243 +131,195 @@ export function ComposioToolsPanel({
}); });
}, []); }, []);
const handleSaveTools = useCallback(async () => { const handleAddSelectedTools = useCallback(() => {
// Convert selected tool slugs to actual tool objects // Convert selected tool slugs to actual tool objects and add them
const selectedToolObjects = tools.filter(tool => selectedTools.has(tool.slug)); 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); onClose();
try { }, [selectedTools, tools, toolkit, onAddTool, onClose]);
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]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setTools([]); setTools([]);
setSelectedTools(new Set()); setSelectedTools(new Set());
setHasChanges(false); setHasChanges(false);
if (hasChanges) { setSearchQuery('');
if (window.confirm('You have unsaved changes. Are you sure you want to close?')) { setDebouncedSearchQuery('');
onClose(); onClose();
} }, [onClose]);
} else {
onClose();
}
}, [onClose, hasChanges]);
// Initialize selected tools from workflow when opening the panel const handleClearSearch = useCallback(() => {
useEffect(() => { setSearchQuery('');
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]);
if (!toolkit) return null; 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 ( return (
<> <SlidePanel
<SlidePanel isOpen={isOpen}
isOpen={isOpen} onClose={handleClose}
onClose={handleClose} title={
title={ <div className="flex items-center gap-3">
<div className="flex items-center gap-3"> {toolkit.meta.logo && (
{toolkit.meta.logo && ( <PictureImg
<PictureImg src={toolkit.meta.logo}
src={toolkit.meta.logo} alt={`${toolkit.name} logo`}
alt={`${toolkit.name} logo`} width={24}
width={24} height={24}
height={24} className="rounded-md object-cover"
className="rounded-md object-cover" />
/>
)}
<span>{toolkit.name}</span>
</div>
}
>
<div className="flex flex-col h-full">
{/* Connection Status Banner */}
{!toolkit.no_auth && (
<div className={`mb-6 p-4 rounded-lg border-2 ${
isToolkitConnected
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
isToolkitConnected ? 'bg-emerald-500' : 'bg-blue-500'
}`}></div>
<div>
<h3 className={`font-semibold text-sm ${
isToolkitConnected
? 'text-emerald-800 dark:text-emerald-200'
: 'text-blue-800 dark:text-blue-200'
}`}>
{isToolkitConnected ? 'Toolkit Connected' : 'Authentication Required'}
</h3>
<p className={`text-xs mt-0.5 ${
isToolkitConnected
? 'text-emerald-700 dark:text-emerald-300'
: 'text-blue-700 dark:text-blue-300'
}`}>
{isToolkitConnected
? 'You can select and use tools from this toolkit'
: 'You can select tools now. Authentication will be required in the build view to use them.'
}
</p>
</div>
</div>
{isToolkitConnected && (
<Button
variant="solid"
size="sm"
onPress={handleDisconnect}
disabled={isProcessingAuth}
color="danger"
isLoading={isProcessingAuth}
startContent={<UnlinkIcon className="h-4 w-4" />}
>
Disconnect
</Button>
)}
</div>
</div>
)} )}
<span>{toolkit.name}</span>
{/* Header */} </div>
<div className="mb-6"> }
<div className="flex items-center justify-between"> >
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4> <div className="flex flex-col h-full">
<div className="flex items-center gap-2"> {/* Header */}
{hasChanges && ( <div className="mb-6">
<Button <div className="flex items-center justify-between mb-4">
variant="solid" <div>
size="sm" <h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Select Tools</h4>
color="primary" <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
onPress={handleSaveTools} Check the tools you want to add to your workflow
disabled={isSaving} </p>
isLoading={isSaving}
>
Save Changes
</Button>
)}
</div>
</div> </div>
{hasChanges && (
<Button
variant="solid"
size="sm"
color="primary"
onPress={handleAddSelectedTools}
>
Add Selected ({selectedTools.size})
</Button>
)}
</div> </div>
{/* Scrollable Tools List */} {/* Search Box */}
<div className="flex-1 overflow-y-auto"> <div className="relative">
{toolsLoading ? ( <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<div className="text-center py-8"> <Search className="h-4 w-4 text-gray-400" />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div> </div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p> <Input
</div> type="text"
) : ( placeholder="Search tools..."
<div className="space-y-4"> value={searchQuery}
{tools.map((tool) => ( onChange={(e) => setSearchQuery(e.target.value)}
<div key={tool.slug} className="group p-4 rounded-lg transition-all duration-200 border border-transparent bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600"> className="pl-10 pr-10"
<div className="flex items-start gap-3"> size="sm"
<Checkbox />
isSelected={selectedTools.has(tool.slug)} {searchQuery && (
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)} <button
size="sm" onClick={handleClearSearch}
/> className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
<div className="flex-1"> >
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"> <X className="h-4 w-4" />
{tool.name} </button>
</h4> )}
<p className="text-sm text-gray-500 dark:text-gray-400"> </div>
{tool.description} </div>
</p>
{/* Scrollable Tools List */}
<div className="flex-1 overflow-y-auto">
{toolsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
{searchQuery ? 'Searching tools...' : 'Loading tools...'}
</p>
</div>
) : tools.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
{searchQuery ? 'No tools found matching your search.' : 'No tools available.'}
</p>
</div>
) : (
<div className="space-y-3">
{availableTools.map((tool) => (
<div
key={tool.slug}
className="group p-4 rounded-lg transition-all duration-200 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
>
<div className="flex items-start gap-3">
<Checkbox
isSelected={selectedTools.has(tool.slug)}
onValueChange={(selected) => handleToolSelectionChange(tool.slug, selected)}
size="sm"
/>
<div className="flex-1 text-left flex flex-col gap-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-left">
{tool.name}
</h4>
<div className="font-mono text-xs text-gray-500 dark:text-gray-400 text-left truncate max-w-[300px] bg-gray-100 dark:bg-gray-700 p-1 rounded-md" title={tool.slug}>
{tool.slug}
</div> </div>
<p className="text-sm text-gray-500 dark:text-gray-400 text-left">
{tool.description}
</p>
</div> </div>
</div> </div>
))} </div>
</div> ))}
)} </div>
</div> )}
</div>
{/* Fixed Pagination Controls */} {/* Fixed Pagination Controls */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex items-center justify-end"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="text-sm text-gray-500 dark:text-gray-400">
<Button {availableTools.length > 0 && (
variant="bordered" <span>
size="sm" {availableTools.length} tool{availableTools.length !== 1 ? 's' : ''} found
onClick={handlePreviousPage} {searchQuery && ` for "${searchQuery}"`}
disabled={cursorHistory.length === 0 || toolsLoading} </span>
> )}
<ChevronLeft className="h-4 w-4 mr-1" /> </div>
Previous <div className="flex items-center gap-2">
</Button> <Button
<Button variant="bordered"
variant="bordered" size="sm"
size="sm" onClick={handlePreviousPage}
onClick={handleNextPage} disabled={cursorHistory.length === 0 || toolsLoading}
disabled={!nextCursor || toolsLoading} >
> <ChevronLeft className="h-4 w-4 mr-1" />
Next Previous
<ChevronRight className="h-4 w-4 ml-1" /> </Button>
</Button> <Button
</div> variant="bordered"
size="sm"
onClick={handleNextPage}
disabled={!nextCursor || toolsLoading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div> </div>
</div> </div>
</div> </div>
</SlidePanel> </div>
</SlidePanel>
{/* Auth Modal */}
{toolkit && (
<ToolkitAuthModal
key={toolkit.slug}
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={toolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
); );
} }

View file

@ -0,0 +1,220 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@heroui/react';
import { Input } from '@/components/ui/input';
import { Info, Plus, Trash2 } from 'lucide-react';
import { z } from 'zod';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { getProjectConfig } from '@/app/actions/project_actions';
import { addServer, removeServer } from '@/app/actions/custom_mcp_server_actions';
import { fetchTools } from "@/app/actions/custom_mcp_server_actions";
import { ServerCard } from './ServerCard';
import { McpToolsPanel } from './McpToolsPanel';
import { ProjectWideChangeConfirmationModal } from '@/components/common/project-wide-change-confirmation-modal';
// Types
const CustomMcpServerType = z.object({ serverUrl: z.string() });
type CustomMcpServer = z.infer<typeof CustomMcpServerType>;
type ServerList = Record<string, CustomMcpServer>;
type CustomMcpServersProps = {
tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
};
export function CustomMcpServers({ tools: workflowTools, onAddTool }: CustomMcpServersProps) {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
// State
const [servers, setServers] = useState<ServerList>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [addName, setAddName] = useState('');
const [addUrl, setAddUrl] = useState('');
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const [panelServer, setPanelServer] = useState<{ name: string; url: string } | null>(null);
const [toolsLoading, setToolsLoading] = useState(false);
const [toolsError, setToolsError] = useState<string | null>(null);
const [serverTools, setServerTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
// Fetch servers on mount
const fetchServers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const project = await getProjectConfig(projectId);
setServers(project.customMcpServers || {});
} catch (err: any) {
setError(err?.message || 'Failed to load servers');
setServers({});
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchServers();
}, [fetchServers]);
// Add server
const handleAddServer = async (e: React.FormEvent) => {
e.preventDefault();
if (!addName || !addUrl) return;
setAddLoading(true);
setAddError(null);
try {
await addServer(projectId, addName, { serverUrl: addUrl });
setAddName('');
setAddUrl('');
await fetchServers();
} catch (err: any) {
setAddError(err?.message || 'Failed to add server');
} finally {
setAddLoading(false);
}
};
// Open delete modal
const handleDeleteClick = (name: string) => {
setServerToDelete(name);
setDeleteModalOpen(true);
};
// Delete server
const handleDeleteServer = async () => {
if (!serverToDelete) return;
try {
await removeServer(projectId, serverToDelete);
await fetchServers();
setDeleteModalOpen(false);
setServerToDelete(null);
} catch (err: any) {
alert(err?.message || 'Failed to delete server');
}
};
// Open panel and fetch tools
const handleOpenPanel = async (name: string, url: string) => {
setPanelServer({ name, url });
setToolsLoading(true);
setToolsError(null);
setServerTools([]);
try {
const fetched = await fetchTools(url, name);
setServerTools(fetched);
} catch (err: any) {
setToolsError(err?.message || 'Failed to fetch tools');
} finally {
setToolsLoading(false);
}
};
// Close panel
const handleClosePanel = () => {
setPanelServer(null);
setServerTools([]);
};
// UI
return (
<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="shrink-0">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<p className="text-sm text-blue-700 dark:text-blue-300">
Add your own MCP servers here. Enter the server details and select tools to add to your workflow.
</p>
</div>
</div>
{/* Add server form */}
<form onSubmit={handleAddServer} className="space-y-4">
<div className="flex gap-4">
<Input
type="text"
value={addName}
onChange={e => setAddName(e.target.value)}
placeholder="Server Name"
required
className="flex-1"
/>
<Input
type="text"
value={addUrl}
onChange={e => setAddUrl(e.target.value)}
placeholder="Server URL"
required
className="flex-1"
/>
<Button
type="submit"
disabled={!addName || !addUrl || addLoading}
startContent={<Plus className="h-4 w-4" />}
>
Add
</Button>
</div>
{addError && <div className="text-red-500 text-sm mt-1">{addError}</div>}
</form>
{/* Server cards */}
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading servers...</p>
</div>
) : error ? (
<div className="text-center py-8 text-red-500 dark:text-red-400">{error}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(servers).length === 0 ? (
<div className="col-span-full text-gray-500 text-sm">No custom MCP servers added yet.</div>
) : (
Object.entries(servers).map(([name, { serverUrl }]) => (
<ServerCard
key={name}
serverName={name}
serverUrl={serverUrl}
workflowTools={workflowTools}
onSelectServer={() => handleOpenPanel(name, serverUrl)}
onDeleteServer={() => handleDeleteClick(name)}
/>
))
)}
</div>
)}
{/* Delete confirmation modal */}
<ProjectWideChangeConfirmationModal
isOpen={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
onConfirm={handleDeleteServer}
title="Delete Server"
confirmationQuestion={`Are you sure you want to delete "${serverToDelete}"? This will delete the server from the project.`}
confirmButtonText="Delete"
/>
{/* MCP Tools Panel */}
<McpToolsPanel
server={panelServer}
isOpen={!!panelServer}
onClose={handleClosePanel}
tools={workflowTools}
onAddTool={onAddTool}
serverTools={serverTools}
toolsLoading={toolsLoading}
toolsError={toolsError}
/>
</div>
);
}

View file

@ -1,475 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Info, Plus, Search } from 'lucide-react';
import { clsx } from 'clsx';
import { z } from 'zod';
import { MCPServer } from '@/app/lib/types/types';
import {
ServerCard,
ToolManagementPanel
} from './MCPServersCommon';
import { fetchMcpToolsForServer } from '@/app/actions/mcp_actions';
import {
fetchCustomServers,
addCustomServer,
removeCustomServer,
toggleCustomServer,
updateCustomServerTools
} from '@/app/actions/custom_server_actions';
import { Modal } from '@/components/ui/modal';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
export function CustomServers({ onToolsUpdated }: { onToolsUpdated?: () => void }) {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
const [servers, setServers] = useState<McpServerType[]>([]);
const [selectedServer, setSelectedServer] = useState<McpServerType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [togglingServers, setTogglingServers] = useState<Set<string>>(new Set());
const [serverOperations, setServerOperations] = useState<Map<string, 'setup' | 'delete'>>(new Map());
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [hasToolChanges, setHasToolChanges] = useState(false);
const [savingTools, setSavingTools] = useState(false);
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
const [showAddServer, setShowAddServer] = useState(false);
const [newServerName, setNewServerName] = useState('');
const [newServerUrl, setNewServerUrl] = useState('');
const fetchServers = useCallback(async () => {
try {
setLoading(true);
const customServers = await fetchCustomServers(projectId);
setServers(customServers);
setError(null);
} catch (err: any) {
setError(err?.message || 'Failed to load custom MCP servers');
console.error('Error fetching servers:', err);
setServers([]);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleToggleServer = async (server: McpServerType) => {
try {
const serverKey = server.name;
setTogglingServers(prev => {
const next = new Set(prev);
next.add(serverKey);
return next;
});
setServerOperations(prev => {
const next = new Map(prev);
next.set(serverKey, server.isActive ? 'delete' : 'setup');
return next;
});
await toggleCustomServer(projectId, server.name, !server.isActive);
// Update local state
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === serverKey) {
return {
...s,
isActive: !s.isActive
};
}
return s;
});
});
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (err) {
console.error('Toggle failed:', { server: server.name, error: err });
} finally {
const serverKey = server.name;
setTogglingServers(prev => {
const next = new Set(prev);
next.delete(serverKey);
return next;
});
setServerOperations(prev => {
const next = new Map(prev);
next.delete(serverKey);
return next;
});
}
};
const handleSyncServer = async (server: McpServerType) => {
if (!projectId || !server.isActive) return;
try {
setSyncingServers(prev => {
const next = new Set(prev);
next.add(server.name);
return next;
});
const enrichedTools = await fetchMcpToolsForServer(projectId, server.name);
const updatedAvailableTools = enrichedTools.map(tool => ({
id: tool.name,
name: tool.name,
description: tool.description,
parameters: tool.parameters
}));
await updateCustomServerTools(
projectId,
server.name,
updatedAvailableTools, // Auto-select all tools for custom servers
updatedAvailableTools
);
// Update servers state
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === server.name) {
return {
...s,
availableTools: updatedAvailableTools,
tools: updatedAvailableTools
};
}
return s;
});
});
// If this server is currently selected, update the selectedTools state
if (selectedServer?.name === server.name) {
setSelectedServer(prev => {
if (!prev) return null;
return {
...prev,
availableTools: updatedAvailableTools,
tools: updatedAvailableTools
};
});
// Update selectedTools to include all tools for the custom server
setSelectedTools(new Set(updatedAvailableTools.map(tool => tool.id)));
}
// Notify parent component about tool updates
onToolsUpdated?.();
} finally {
setSyncingServers(prev => {
const next = new Set(prev);
next.delete(server.name);
return next;
});
}
};
// Add effect to sync selectedTools when selectedServer changes
useEffect(() => {
if (selectedServer) {
setSelectedTools(new Set(selectedServer.tools.map(tool => tool.id)));
setHasToolChanges(false);
}
}, [selectedServer]);
const handleAddServer = async () => {
if (!newServerName || !newServerUrl) return;
try {
const newServer: McpServerType = {
id: `custom-${Date.now()}`,
name: newServerName,
description: `Custom MCP server at ${newServerUrl}`,
serverUrl: newServerUrl,
tools: [],
availableTools: [],
isActive: true,
isReady: true,
serverType: 'custom',
authNeeded: false,
isAuthenticated: false
};
// Add to MongoDB and get back the formatted server
const formattedServer = await addCustomServer(projectId, newServer);
// Update local state with the formatted server
setServers(prev => [...prev, formattedServer]);
setShowAddServer(false);
setNewServerName('');
setNewServerUrl('');
// Fetch tools for the new server using the formatted URL
await handleSyncServer(formattedServer);
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (err) {
console.error('Error adding server:', err);
setError('Failed to add server. Please try again.');
}
};
const handleRemoveServer = async (server: McpServerType) => {
// Show confirmation dialog
const shouldRemove = window.confirm(
"Are you sure you want to delete this server? Alternatively, you can toggle it OFF if you'd like to retain the configuration but not make it available to agents."
);
if (!shouldRemove) return;
try {
await removeCustomServer(projectId, server.name);
// Update local state
setServers(prev => prev.filter(s => s.name !== server.name));
// If this server was selected, close the tool management panel
if (selectedServer?.name === server.name) {
setSelectedServer(null);
}
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (err) {
console.error('Error removing server:', err);
setError('Failed to remove server. Please try again.');
}
};
const handleSaveToolSelection = async () => {
if (!selectedServer || !projectId) return;
setSavingTools(true);
try {
const availableTools = selectedServer.availableTools || [];
const selectedToolsList = availableTools.filter(tool => selectedTools.has(tool.id));
await updateCustomServerTools(
projectId,
selectedServer.name,
selectedToolsList,
availableTools
);
setServers(prevServers => {
return prevServers.map(s => {
if (s.name === selectedServer.name) {
return {
...s,
tools: selectedToolsList
};
}
return s;
});
});
setSelectedServer(prev => {
if (!prev) return null;
return {
...prev,
tools: selectedToolsList
};
});
setHasToolChanges(false);
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (error) {
console.error('Error saving tool selection:', error);
} finally {
setSavingTools(false);
}
};
const filteredServers = servers.filter(server => {
const searchLower = searchQuery.toLowerCase();
const serverTools = server.tools || [];
return (
server.name.toLowerCase().includes(searchLower) ||
server.description.toLowerCase().includes(searchLower) ||
serverTools.some(tool =>
tool.name.toLowerCase().includes(searchLower) ||
tool.description.toLowerCase().includes(searchLower)
)
);
});
return (
<div className="space-y-6">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<div className="shrink-0">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<p className="text-sm text-blue-700 dark:text-blue-300">
Add your own MCP servers here. These servers will be available to agents in the Build view once toggled ON.
</p>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<Button
size="sm"
variant="primary"
onClick={() => setShowAddServer(true)}
>
<div className="inline-flex items-center">
<Plus className="h-4 w-4" />
<span className="ml-2">Add Server</span>
</div>
</Button>
<div className="flex-1 flex items-center gap-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
</div>
<input
type="text"
placeholder="Search servers or tools..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
placeholder-gray-400 dark:placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
/>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{filteredServers.length} {filteredServers.length === 1 ? 'server' : 'servers'} {
filteredServers.reduce((total, server) => total + (server.availableTools?.length || 0), 0)
} tools
</div>
</div>
</div>
<Modal
isOpen={showAddServer}
onClose={() => {
setShowAddServer(false);
setNewServerName('');
setNewServerUrl('');
}}
title="Add Custom MCP Server"
>
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Server Name
</label>
<input
type="text"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
placeholder="e.g., My Custom Server"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Server URL
</label>
<input
type="text"
value={newServerUrl}
onChange={(e) => setNewServerUrl(e.target.value)}
placeholder="e.g., http://localhost:3000"
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
size="sm"
variant="secondary"
onClick={() => {
setShowAddServer(false);
setNewServerName('');
setNewServerUrl('');
}}
>
Cancel
</Button>
<Button
size="sm"
variant="primary"
onClick={handleAddServer}
disabled={!newServerName || !newServerUrl}
>
Add Server
</Button>
</div>
</div>
</Modal>
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading servers...</p>
</div>
) : error ? (
<div className="text-center py-8 text-red-500 dark:text-red-400">{error}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredServers.map((server) => (
<ServerCard
key={server.id}
server={server}
onToggle={() => handleToggleServer(server)}
onManageTools={() => setSelectedServer(server)}
onSync={() => handleSyncServer(server)}
onRemove={() => handleRemoveServer(server)}
isToggling={togglingServers.has(server.name)}
isSyncing={syncingServers.has(server.name)}
operation={serverOperations.get(server.name)}
error={error && error.includes(server.name) ? { message: error } : undefined}
showAuth={false}
/>
))}
</div>
)}
<ToolManagementPanel
server={selectedServer}
onClose={() => {
setSelectedServer(null);
setSelectedTools(new Set());
setHasToolChanges(false);
}}
selectedTools={selectedTools}
onToolSelectionChange={(toolId, selected) => {
setSelectedTools(prev => {
const next = new Set(prev);
if (selected) {
next.add(toolId);
} else {
next.delete(toolId);
}
setHasToolChanges(true);
return next;
});
}}
onSaveTools={handleSaveToolSelection}
onSyncTools={selectedServer ? () => handleSyncServer(selectedServer) : undefined}
hasChanges={hasToolChanges}
isSaving={savingTools}
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
/>
</div>
);
}

View file

@ -0,0 +1,240 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Button, Checkbox, Input } from '@heroui/react';
import { Search, X } from 'lucide-react';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { z } from 'zod';
import { SlidePanel } from '@/components/ui/slide-panel';
interface McpToolsPanelProps {
server: {
name: string;
url: string;
} | null;
isOpen: boolean;
onClose: () => void;
tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: z.infer<typeof WorkflowTool>) => void;
serverTools: z.infer<typeof WorkflowTool>[];
toolsLoading: boolean;
toolsError: string | null;
}
export function McpToolsPanel({
server,
isOpen,
onClose,
tools: workflowTools,
onAddTool,
serverTools,
toolsLoading,
toolsError,
}: McpToolsPanelProps) {
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [hasChanges, setHasChanges] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
// Filter out already selected tools
const selectedToolNames = workflowTools
.filter(tool => tool.isMcp && tool.mcpServerName === server?.name)
.map(tool => tool.name);
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
// Filter tools based on search query
const filteredTools = useMemo(() => {
if (!debouncedSearchQuery) return serverTools;
const query = debouncedSearchQuery.toLowerCase();
return serverTools.filter(tool =>
tool.name.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query)
);
}, [serverTools, debouncedSearchQuery]);
// Filter out already added tools
const availableTools = filteredTools.filter(tool => !selectedToolNames.includes(tool.name));
const handleToolSelectionChange = useCallback((toolName: string, selected: boolean) => {
setSelectedTools(prev => {
const next = new Set(prev);
if (selected) {
next.add(toolName);
} else {
next.delete(toolName);
}
setHasChanges(true);
return next;
});
}, []);
const handleAddSelectedTools = useCallback(() => {
// Convert selected tool names to actual tool objects and add them
const selectedToolObjects = serverTools.filter(tool => selectedTools.has(tool.name));
selectedToolObjects.forEach(tool => {
onAddTool(tool);
});
onClose();
}, [selectedTools, serverTools, onAddTool, onClose]);
const handleClose = useCallback(() => {
setSelectedTools(new Set());
setHasChanges(false);
setSearchQuery('');
setDebouncedSearchQuery('');
onClose();
}, [onClose]);
const handleClearSearch = useCallback(() => {
setSearchQuery('');
}, []);
if (!server) return null;
return (
<SlidePanel
isOpen={isOpen}
onClose={handleClose}
title={
<div className="flex items-center gap-3">
<div className="w-6 h-6 bg-blue-500 rounded-md flex items-center justify-center">
<span className="text-white text-xs font-bold">MCP</span>
</div>
<span>{server.name}</span>
</div>
}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Select Tools</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Check the tools you want to add to your workflow
</p>
</div>
{hasChanges && (
<Button
variant="solid"
size="sm"
color="primary"
onPress={handleAddSelectedTools}
>
Add Selected ({selectedTools.size})
</Button>
)}
</div>
{/* Search Box */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400" />
</div>
<Input
type="text"
placeholder="Search tools..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10"
size="sm"
/>
{searchQuery && (
<button
onClick={handleClearSearch}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Error Display */}
{toolsError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300">{toolsError}</p>
</div>
)}
{/* Scrollable Tools List */}
<div className="flex-1 overflow-y-auto">
{toolsLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
{searchQuery ? 'Searching tools...' : 'Loading tools...'}
</p>
</div>
) : availableTools.length === 0 ? (
<div className="text-center py-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
{searchQuery ? 'No tools found matching your search.' : 'No tools available.'}
</p>
</div>
) : (
<div className="space-y-3">
{availableTools.map((tool) => (
<div
key={tool.name}
className="group p-4 rounded-lg transition-all duration-200 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
>
<div className="flex items-start gap-3">
<Checkbox
isSelected={selectedTools.has(tool.name)}
onValueChange={(selected) => handleToolSelectionChange(tool.name, selected)}
size="sm"
/>
<div className="flex-1 text-left flex flex-col gap-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors text-left">
{tool.name}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 text-left">
{tool.description}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Fixed Footer */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">
{availableTools.length > 0 && (
<span>
{availableTools.length} tool{availableTools.length !== 1 ? 's' : ''} found
{searchQuery && ` for "${searchQuery}"`}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="bordered"
size="sm"
onPress={handleAddSelectedTools}
disabled={selectedTools.size === 0}
>
Add Selected ({selectedTools.size})
</Button>
</div>
</div>
</div>
</div>
</SlidePanel>
);
}

View file

@ -0,0 +1,168 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { PictureImg } from '@/components/ui/picture-img';
import clsx from 'clsx';
import { z } from 'zod';
import { Chip } from '@heroui/react';
import { Server, MoreVertical } from 'lucide-react';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { fetchTools } from "@/app/actions/custom_mcp_server_actions";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem } from '@heroui/react';
import { Button } from '@heroui/react';
type ServerCardProps = {
serverName: string;
serverUrl: string;
workflowTools: z.infer<typeof Workflow.shape.tools>;
onSelectServer: () => void;
onDeleteServer: () => void;
};
const serverCardStyles = {
base: clsx(
"group p-6 rounded-xl transition-all duration-200 cursor-pointer",
"bg-white dark:bg-gray-900",
"border border-gray-200 dark:border-gray-700",
"shadow-md dark:shadow-gray-900/20",
"hover:shadow-lg dark:hover:shadow-gray-900/30",
"hover:border-blue-300 dark:hover:border-blue-600",
"hover:bg-gray-50/50 dark:hover:bg-gray-800/50",
"hover:-translate-y-1",
"min-h-[200px] flex flex-col"
),
};
export function ServerCard({
serverName,
serverUrl,
workflowTools,
onSelectServer,
onDeleteServer,
}: ServerCardProps) {
const [tools, setTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
const [toolsLoading, setToolsLoading] = useState(true);
const [toolsError, setToolsError] = useState<string | null>(null);
// Fetch tools on mount
useEffect(() => {
const fetchServerTools = async () => {
setToolsLoading(true);
setToolsError(null);
try {
const fetched = await fetchTools(serverUrl, serverName);
setTools(fetched);
} catch (err: any) {
setToolsError(err?.message || 'Failed to fetch tools');
setTools([]);
} finally {
setToolsLoading(false);
}
};
fetchServerTools();
}, [serverUrl, serverName]);
const handleCardClick = useCallback(() => {
onSelectServer();
}, [onSelectServer]);
// Calculate selected tools count for this server
const selectedToolsCount = workflowTools
.filter(tool => tool.isMcp && tool.mcpServerName === serverName)
.length;
return (
<div className={serverCardStyles.base} onClick={handleCardClick}>
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-start gap-3 mb-4">
<div className="w-8 h-8 bg-blue-500 rounded-md flex items-center justify-center flex-shrink-0">
<Server className="w-4 h-4 text-white" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100 truncate">
{serverName}
</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{toolsLoading ? (
<Chip
color="secondary"
variant="faded"
size="sm"
>
Loading tools...
</Chip>
) : toolsError ? (
<Chip
color="danger"
variant="faded"
size="sm"
>
Error loading tools
</Chip>
) : (
<Chip
color="secondary"
variant="faded"
size="sm"
>
{selectedToolsCount > 0
? `${tools.length} tools, ${selectedToolsCount} selected`
: `${tools.length} tools`
}
</Chip>
)}
</div>
</div>
<Dropdown>
<DropdownTrigger>
<Button
variant="light"
size="sm"
isIconOnly
title="More options"
aria-label="More options"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Server actions">
<DropdownItem
key="delete"
color="danger"
startContent={<MoreVertical className="h-4 w-4" />}
onPress={onDeleteServer}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
{/* Description */}
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-3">
Custom MCP server at {serverUrl}
</p>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Chip
color='success'
variant='flat'
size="sm"
>
Custom Server
</Chip>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -5,12 +5,11 @@ import { PictureImg } from '@/components/ui/picture-img';
import clsx from 'clsx'; import clsx from 'clsx';
import { z } from 'zod'; import { z } from 'zod';
import { ZToolkit } from '@/app/lib/composio/composio'; import { ZToolkit } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { Chip } from '@heroui/react'; import { Chip } from '@heroui/react';
import { LinkIcon } from 'lucide-react'; import { LinkIcon } from 'lucide-react';
import { Workflow } from '@/app/lib/types/workflow_types';
type ToolkitType = z.infer<typeof ZToolkit>; type ToolkitType = z.infer<typeof ZToolkit>;
type ProjectType = z.infer<typeof Project>;
const toolkitCardStyles = { const toolkitCardStyles = {
base: clsx( base: clsx(
@ -28,32 +27,25 @@ const toolkitCardStyles = {
interface ToolkitCardProps { interface ToolkitCardProps {
toolkit: ToolkitType; toolkit: ToolkitType;
projectId: string;
isConnected: boolean; isConnected: boolean;
connectedAccountId?: string; onSelectToolkit: () => void;
projectConfig: ProjectType | null; workflowTools: z.infer<typeof Workflow.shape.tools>;
onManageTools: () => void;
onProjectConfigUpdate: () => void;
onRemoveToolkitTools: (toolkitSlug: string) => void;
} }
export function ToolkitCard({ export function ToolkitCard({
toolkit, toolkit,
projectId,
isConnected, isConnected,
connectedAccountId, onSelectToolkit,
projectConfig, workflowTools,
onManageTools,
onProjectConfigUpdate,
onRemoveToolkitTools
}: ToolkitCardProps) { }: ToolkitCardProps) {
const handleCardClick = useCallback(() => { const handleCardClick = useCallback(() => {
onManageTools(); onSelectToolkit();
}, [onManageTools]); }, [onSelectToolkit]);
// Calculate selected tools count for this toolkit // Calculate selected tools count for this toolkit
// TODO: Update to use workflow-based tools count const selectedToolsCount = workflowTools
const selectedToolsCount = 0; .filter(tool => tool.isComposio && tool.composioData?.toolkitSlug === toolkit.slug)
.length;
return ( return (
<div className={toolkitCardStyles.base} onClick={handleCardClick}> <div className={toolkitCardStyles.base} onClick={handleCardClick}>

View file

@ -2,23 +2,25 @@
import { useState } from 'react'; import { useState } from 'react';
import { Tabs, Tab } from '@/components/ui/tabs'; import { Tabs, Tab } from '@/components/ui/tabs';
import { HostedServers } from './HostedServers'; import { CustomMcpServers } from './CustomMcpServer';
import { CustomServers } from './CustomServers';
import { WebhookConfig } from './WebhookConfig';
import { Composio } from './Composio'; import { Composio } from './Composio';
import { AddWebhookTool } from './AddWebhookTool';
import type { Key } from 'react'; import type { Key } from 'react';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
import { z } from 'zod';
export function ToolsConfig({ export function ToolsConfig({
projectId,
useComposioTools, useComposioTools,
useKlavisTools tools,
onAddTool,
}: { }: {
projectId: string;
useComposioTools: boolean; useComposioTools: boolean;
useKlavisTools: boolean; tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
}) { }) {
let defaultActiveTab = 'custom'; let defaultActiveTab = 'mcp';
if (useKlavisTools) {
defaultActiveTab = 'hosted';
}
if (useComposioTools) { if (useComposioTools) {
defaultActiveTab = 'composio'; defaultActiveTab = 'composio';
} }
@ -40,32 +42,28 @@ export function ToolsConfig({
{useComposioTools && ( {useComposioTools && (
<Tab key="composio" title="Composio"> <Tab key="composio" title="Composio">
<div className="mt-4 p-6"> <div className="mt-4 p-6">
<Composio /> <Composio
projectId={projectId}
tools={tools}
onAddTool={onAddTool}
/>
</div> </div>
</Tab> </Tab>
)} )}
{useKlavisTools && ( <Tab key="mcp" title="Custom MCP Servers">
<Tab key="hosted" title={
<div className="flex items-center gap-2">
<span>Klavis</span>
<span className="leading-none px-1.5 py-[2px] text-[9px] font-medium bg-linear-to-r from-pink-500 to-violet-500 text-white rounded-full">
BETA
</span>
</div>
}>
<div className="mt-4 p-6">
<HostedServers onSwitchTab={key => setActiveTab(key)} />
</div>
</Tab>
)}
<Tab key="custom" title="Custom MCP Servers">
<div className="mt-4 p-6"> <div className="mt-4 p-6">
<CustomServers /> <CustomMcpServers
tools={tools}
onAddTool={onAddTool}
/>
</div> </div>
</Tab> </Tab>
<Tab key="webhook" title="Webhook"> <Tab key="webhook" title="Webhook">
<div className="mt-4 p-6"> <div className="mt-4 p-6">
<WebhookConfig /> <AddWebhookTool
projectId={projectId}
onAddTool={onAddTool}
/>
</div> </div>
</Tab> </Tab>
</Tabs> </Tabs>

View file

@ -1,42 +1,20 @@
'use client'; 'use client';
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams } from 'next/navigation'; import { Spinner, Button, Input } from "@heroui/react";
import { Spinner } from "@heroui/react";
import { getProjectConfig, updateWebhookUrl } from "@/app/actions/project_actions"; import { getProjectConfig, updateWebhookUrl } from "@/app/actions/project_actions";
import { Textarea } from "@/components/ui/textarea";
import { clsx } from "clsx"; 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"; export function WebhookConfig({ projectId }: { projectId: string }) {
const sectionDescriptionStyles = "text-sm text-gray-500 dark:text-gray-400 mb-4";
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
const inputStyles = "rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20";
function Section({ title, children, description }: {
title: string;
children: React.ReactNode;
description?: string;
}) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-6 pt-4">
<h2 className={sectionHeaderStyles}>{title}</h2>
{description && (
<p className={sectionDescriptionStyles}>{description}</p>
)}
</div>
<div className="px-6 pb-6">{children}</div>
</div>
);
}
export function WebhookConfig() {
const params = useParams();
const projectId = params.projectId ? (typeof params.projectId === 'string' ? params.projectId : params.projectId[0]) : '';
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [webhookUrl, setWebhookUrl] = useState<string | null>(null); const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [saving, setSaving] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editValue, setEditValue] = useState<string>('');
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -46,6 +24,7 @@ export function WebhookConfig() {
const project = await getProjectConfig(projectId); const project = await getProjectConfig(projectId);
if (mounted) { if (mounted) {
setWebhookUrl(project.webhookUrl || null); setWebhookUrl(project.webhookUrl || null);
setEditValue(project.webhookUrl || '');
setError(null); setError(null);
} }
} catch (err) { } catch (err) {
@ -67,59 +46,138 @@ export function WebhookConfig() {
}; };
}, [projectId]); }, [projectId]);
function validate(url: string) { // validate on change in webhook
if (!url.trim()) { useEffect(() => {
return { valid: true }; if (!isEditMode) return;
}
setError(null);
try { try {
new URL(url); new URL(editValue || '');
setError(null);
return { valid: true };
} catch { } catch {
setError('Please enter a valid URL'); 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 ( if (loading) {
<div className="space-y-6"> return (
<Section <div className="space-y-6">
title="Webhook URL" <div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
description="In workflow editor, tool calls will be posted to this URL, unless they are mocked." <div className="px-6 pt-4">
> <h2 className="block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Webhook URL</h2>
<div className="space-y-2"> <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">In workflow editor, tool calls will be posted to this URL, unless they are mocked.</p>
<div className={clsx(
"border rounded-lg focus-within:ring-2",
error
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Textarea
value={webhookUrl || ''}
useValidation={true}
updateOnBlur={true}
validate={validate}
onValidatedChange={(value) => {
setWebhookUrl(value);
updateWebhookUrl(projectId, value);
}}
placeholder="Enter webhook URL..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
autoResize
disabled={loading}
/>
</div> </div>
{loading && ( <div className="px-6 pb-6">
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex items-center gap-2 text-sm text-gray-500">
<Spinner size="sm" /> <Spinner size="sm" />
<span>Loading...</span> <span>Loading...</span>
</div> </div>
)} </div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div> </div>
</Section> </div>
);
}
return (
<div className="space-y-6">
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-6 pt-4">
<h2 className="block text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Webhook URL</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Tool calls will be posted to this URL, unless they are mocked.</p>
</div>
<div className="px-6 pb-6">
<div className="space-y-4">
{isEditMode ? (
<>
<div className={clsx(
"border rounded-lg focus-within:ring-2",
error
? "border-red-500 focus-within:ring-red-500/20"
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
)}>
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
placeholder="Enter webhook URL..."
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<div className="flex gap-2 justify-end">
<Button
variant="light"
onPress={handleCancel}
disabled={saving}
>
Cancel
</Button>
<Button
color="primary"
onPress={() => setShowConfirmModal(true)}
disabled={!!error || saving}
>
Update Webhook URL
</Button>
</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400">
{webhookUrl || 'No webhook URL configured'}
</p>
</div>
<Button
variant="light"
onPress={handleEdit}
>
Edit
</Button>
</div>
</>
)}
</div>
</div>
</div>
<ProjectWideChangeConfirmationModal
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleSave}
title="Update Webhook URL"
confirmationQuestion="Are you sure you want to update the webhook URL? This will affect all workflow tool calls."
confirmButtonText="Update"
isLoading={saving}
/>
</div> </div>
); );
} }

View file

@ -1,26 +0,0 @@
import { Suspense } from 'react';
import { ToolsConfig } from './components/ToolsConfig';
import { PageHeader } from '@/components/ui/page-header';
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { USE_COMPOSIO_TOOLS, USE_KLAVIS_TOOLS } from '@/app/lib/feature_flags';
export default async function ToolsPage() {
await requireActiveBillingSubscription();
return (
<div className="flex flex-col h-full">
<PageHeader
title="Tools"
description="Configure and manage your project's tool integrations"
/>
<div className="flex-1 p-6">
<Suspense fallback={<div>Loading...</div>}>
<ToolsConfig
useComposioTools={false}
useKlavisTools={USE_KLAVIS_TOOLS}
/>
</Suspense>
</div>
</div>
);
}

View file

@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
import { WorkflowEditor } from "./workflow_editor"; import { WorkflowEditor } from "./workflow_editor";
import { Spinner } from "@heroui/react"; import { Spinner } from "@heroui/react";
import { listDataSources } from "../../../actions/datasource_actions"; import { listDataSources } from "../../../actions/datasource_actions";
import { collectProjectTools, revertToLiveWorkflow } from "@/app/actions/project_actions"; import { revertToLiveWorkflow } from "@/app/actions/project_actions";
import { getProjectConfig } from "@/app/actions/project_actions"; import { getProjectConfig } from "@/app/actions/project_actions";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types"; import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { getEligibleModels } from "@/app/actions/billing_actions"; import { getEligibleModels } from "@/app/actions/billing_actions";
@ -25,12 +25,10 @@ export function App({
const [mode, setMode] = useState<'draft' | 'live'>('draft'); const [mode, setMode] = useState<'draft' | 'live'>('draft');
const [project, setProject] = useState<WithStringId<z.infer<typeof Project>> | null>(null); const [project, setProject] = useState<WithStringId<z.infer<typeof Project>> | null>(null);
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null); const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
const [projectTools, setProjectTools] = useState<z.infer<typeof WorkflowTool>[] | null>(null);
const [projectConfig, setProjectConfig] = useState<z.infer<typeof Project> | null>(null); const [projectConfig, setProjectConfig] = useState<z.infer<typeof Project> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*"); const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*");
const [projectMcpServers, setProjectMcpServers] = useState<Array<z.infer<typeof MCPServer>>>([]); const [projectMcpServers, setProjectMcpServers] = useState<Array<z.infer<typeof MCPServer>>>([]);
const [webhookUrl, setWebhookUrl] = useState<string>('');
console.log('workflow app.tsx render'); console.log('workflow app.tsx render');
@ -45,92 +43,39 @@ export function App({
const [ const [
project, project,
dataSources, dataSources,
projectTools,
eligibleModels, eligibleModels,
] = await Promise.all([ ] = await Promise.all([
getProjectConfig(projectId), getProjectConfig(projectId),
listDataSources(projectId), listDataSources(projectId),
collectProjectTools(projectId),
getEligibleModels(), getEligibleModels(),
]); ]);
setProject(project); setProject(project);
setDataSources(dataSources); setDataSources(dataSources);
setProjectTools(projectTools);
setEligibleModels(eligibleModels); setEligibleModels(eligibleModels);
if (project.mcpServers) { if (project.mcpServers) {
setProjectMcpServers(project.mcpServers); setProjectMcpServers(project.mcpServers);
} }
if (project.webhookUrl) {
setWebhookUrl(project.webhookUrl);
}
setLoading(false); setLoading(false);
}, [projectId]); }, [projectId]);
const handleProjectToolsUpdate = useCallback(async () => { const handleProjectToolsUpdate = useCallback(async () => {
// Lightweight refresh for tool-only updates // Lightweight refresh for tool-only updates
const [projectConfig, projectTools] = await Promise.all([ const projectConfig = await getProjectConfig(projectId);
getProjectConfig(projectId),
collectProjectTools(projectId),
]);
setProject(projectConfig); setProject(projectConfig);
setProjectConfig(projectConfig); setProjectConfig(projectConfig);
setProjectTools(projectTools);
// Update MCP servers if they changed // Update MCP servers if they changed
if (projectConfig.mcpServers) { if (projectConfig.mcpServers) {
setProjectMcpServers(projectConfig.mcpServers); setProjectMcpServers(projectConfig.mcpServers);
} }
// Update webhook URL if it changed
if (projectConfig.webhookUrl) {
setWebhookUrl(projectConfig.webhookUrl);
}
}, [projectId]); }, [projectId]);
// Add this useEffect for initial load // Add this useEffect for initial load
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [mode, loadData, projectId]); }, [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') { function handleSetMode(mode: 'draft' | 'live') {
setMode(mode); setMode(mode);
} }
@ -149,16 +94,14 @@ export function App({
<div>Loading workflow...</div> <div>Loading workflow...</div>
</div>} </div>}
{!loading && !workflow && <div>No workflow found!</div>} {!loading && !workflow && <div>No workflow found!</div>}
{!loading && project && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor {!loading && project && workflow && (dataSources !== null) && <WorkflowEditor
projectId={projectId} projectId={projectId}
isLive={mode == 'live'} isLive={mode == 'live'}
workflow={workflow} workflow={workflow}
dataSources={dataSources} dataSources={dataSources}
projectTools={projectTools}
projectConfig={projectConfig || project} projectConfig={projectConfig || project}
useRag={useRag} useRag={useRag}
mcpServerUrls={projectMcpServers} mcpServerUrls={projectMcpServers}
toolWebhookUrl={webhookUrl}
defaultModel={defaultModel} defaultModel={defaultModel}
eligibleModels={eligibleModels} eligibleModels={eligibleModels}
onChangeMode={handleSetMode} onChangeMode={handleSetMode}

View file

@ -1,74 +0,0 @@
'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 (
<Modal
isOpen={isOpen}
onClose={onClose}
size="5xl"
scrollBehavior="inside"
classNames={{
base: "max-h-[90vh]",
body: "p-0",
header: "pb-3"
}}
>
<ModalContent>
<ModalHeader>
<h3 className="text-lg font-semibold">
Tools
</h3>
</ModalHeader>
<ModalBody>
<Tabs
selectedKey={activeTab}
onSelectionChange={handleTabChange}
aria-label="Tool configuration options"
className="w-full h-full"
fullWidth
classNames={{
panel: "h-full min-h-[60vh]"
}}
>
<Tab key="composio" title="Composio">
<div className="p-6 h-full">
<ComposioWithCallback projectId={projectId} onToolsUpdated={onToolsUpdated} />
</div>
</Tab>
<Tab key="custom" title="Custom MCP Servers">
<div className="p-6 h-full">
<CustomServers onToolsUpdated={onToolsUpdated} />
</div>
</Tab>
<Tab key="webhook" title="Webhook">
<div className="p-6 h-full">
<WebhookConfig />
</div>
</Tab>
</Tabs>
</ModalBody>
</ModalContent>
</Modal>
);
}

View file

@ -1,279 +0,0 @@
'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<typeof ZToolkit>;
type ToolkitListResponse = z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>;
type ProjectType = z.infer<typeof Project>;
interface ComposioWithCallbackProps {
projectId: string;
onToolsUpdated?: () => void;
}
export function ComposioWithCallback({ projectId, onToolsUpdated }: ComposioWithCallbackProps) {
const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedToolkit, setSelectedToolkit] = useState<ToolkitType | null>(null);
const [isToolsPanelOpen, setIsToolsPanelOpen] = useState(false);
const [savingTools, setSavingTools] = useState(false);
const [composioSelectedTools, setComposioSelectedTools] = useState<z.infer<typeof ZTool>[]>([]);
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<typeof ZTool>[]) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from workflow
const existingSelectedTools = composioSelectedTools;
// Create a map of existing tools by slug for easy lookup
const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool]));
// Add or update the new selections
for (const tool of selectedToolObjects) {
existingToolsMap.set(tool.slug, tool);
}
// Convert back to array
const mergedSelectedTools = Array.from(existingToolsMap.values());
await updateComposioSelectedTools(projectId, mergedSelectedTools);
// Refresh data to get updated tools
await loadComposioSelectedTools();
// 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 (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading Composio toolkits...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-[50vh] space-y-6 px-4">
<p className="text-center text-red-500 dark:text-red-400 max-w-[600px]">
{error}
</p>
<Button
variant="secondary"
onClick={() => {
loadProjectConfig();
loadAllToolkits();
}}
>
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Search Bar */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search toolkits..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
placeholder-gray-500 dark:placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Toolkits Grid */}
<div className="flex-1 overflow-y-auto">
{filteredToolkits.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">
{searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredToolkits.map((toolkit) => {
const isConnected = toolkit.no_auth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.id;
return (
<ToolkitCard
key={toolkit.slug}
toolkit={toolkit}
projectId={projectId}
isConnected={isConnected}
connectedAccountId={connectedAccountId}
projectConfig={projectConfig}
onManageTools={() => handleManageTools(toolkit)}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
/>
);
})}
</div>
)}
</div>
{/* Tools Panel */}
<ComposioToolsPanel
toolkit={selectedToolkit}
isOpen={isToolsPanelOpen}
onClose={handleCloseToolsPanel}
projectConfig={projectConfig}
onUpdateToolsSelection={handleUpdateToolsSelection}
onProjectConfigUpdate={handleProjectConfigUpdate}
onRemoveToolkitTools={handleRemoveToolkitTools}
isSaving={savingTools}
/>
</div>
);
}

View file

@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody } from '@heroui/react';
import { ToolsConfig } from '../../tools/components/ToolsConfig';
import { z } from 'zod';
import { Workflow, WorkflowTool } from '@/app/lib/types/workflow_types';
interface ToolsModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
tools: z.infer<typeof Workflow.shape.tools>;
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
}
export function ToolsModal({
isOpen,
onClose,
projectId,
tools,
onAddTool
}: ToolsModalProps) {
function handleAddTool(tool: Partial<z.infer<typeof WorkflowTool>>) {
onAddTool(tool);
onClose();
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size="5xl"
scrollBehavior="inside"
>
<ModalContent>
<ModalHeader>
<h3 className="text-lg font-semibold">
Add tools
</h3>
</ModalHeader>
<ModalBody>
<ToolsConfig
useComposioTools={true}
projectId={projectId}
tools={tools}
onAddTool={handleAddTool}
/>
</ModalBody>
</ModalContent>
</Modal>
);
}

View file

@ -3,7 +3,7 @@ import { WorkflowPrompt, WorkflowAgent, WorkflowTool, Workflow } from "../../../
import { Project } from "../../../lib/types/project_types"; import { Project } from "../../../lib/types/project_types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react"; import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRef, useEffect, useState } from "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, LinkIcon, UnlinkIcon, TestTube, Play, MoreVertical, Eye } from "lucide-react"; import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, LinkIcon, UnlinkIcon, MoreVertical, Eye } from "lucide-react";
import { Tooltip } from "@heroui/react"; import { Tooltip } from "@heroui/react";
import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
@ -15,9 +15,10 @@ import { clsx } from "clsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ServerLogo } from '../tools/components/MCPServersCommon'; import { ServerLogo } from '../tools/components/MCPServersCommon';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react"; import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { ComposioToolsModal } from './components/ComposioToolsModal'; import { ToolsModal } from './components/ToolsModal';
import { ToolkitAuthModal } from '../tools/components/ToolkitAuthModal'; import { ToolkitAuthModal } from '../tools/components/ToolkitAuthModal';
import { deleteConnectedAccount, toggleMockToolkitState } from '@/app/actions/composio_actions'; import { deleteConnectedAccount } from '@/app/actions/composio_actions';
import { ProjectWideChangeConfirmationModal } from '@/components/common/project-wide-change-confirmation-modal';
// Reduced gap size to match Cursor's UI // Reduced gap size to match Cursor's UI
const GAP_SIZE = 4; // 1 unit * 4px (tailwind's default spacing unit) const GAP_SIZE = 4; // 1 unit * 4px (tailwind's default spacing unit)
@ -38,7 +39,6 @@ const buttonClasses = "text-sm px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text
interface EntityListProps { interface EntityListProps {
agents: z.infer<typeof WorkflowAgent>[]; agents: z.infer<typeof WorkflowAgent>[];
tools: z.infer<typeof WorkflowTool>[]; tools: z.infer<typeof WorkflowTool>[];
projectTools: z.infer<typeof WorkflowTool>[];
prompts: z.infer<typeof WorkflowPrompt>[]; prompts: z.infer<typeof WorkflowPrompt>[];
workflow: z.infer<typeof Workflow>; workflow: z.infer<typeof Workflow>;
selectedEntity: { selectedEntity: {
@ -88,6 +88,7 @@ const ListItemWithMenu = ({
iconClassName, iconClassName,
mcpServerName, mcpServerName,
dragHandle, dragHandle,
isMocked,
}: { }: {
name: string; name: string;
isSelected?: boolean; isSelected?: boolean;
@ -100,6 +101,7 @@ const ListItemWithMenu = ({
iconClassName?: string; iconClassName?: string;
mcpServerName?: string; mcpServerName?: string;
dragHandle?: React.ReactNode; dragHandle?: React.ReactNode;
isMocked?: boolean;
}) => { }) => {
return ( return (
<div className={clsx( <div className={clsx(
@ -137,6 +139,13 @@ const ListItemWithMenu = ({
</button> </button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{statusLabel} {statusLabel}
{isMocked && (
<Tooltip content="Mocked" size="sm" delay={500}>
<div className="w-4 h-4 rounded-full bg-purple-500 flex items-center justify-center text-xs font-medium text-white">
M
</div>
</Tooltip>
)}
{menuContent} {menuContent}
</div> </div>
</div> </div>
@ -203,26 +212,27 @@ const ServerCard = ({
{isExpanded && ( {isExpanded && (
<div className="ml-6 mt-0.5 space-y-0.5 border-l border-gray-200 dark:border-gray-700 pl-3"> <div className="ml-6 mt-0.5 space-y-0.5 border-l border-gray-200 dark:border-gray-700 pl-3">
{tools.map((tool, index) => ( {tools.map((tool, index) => (
<div key={`tool-${index}`} className="group/tool"> <div key={`tool-${index}`} className="group/tool">
<ListItemWithMenu <ListItemWithMenu
name={tool.name} name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name} isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)} onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
mcpServerName={serverName} mcpServerName={serverName}
menuContent={ isMocked={tool.mockTool}
<div className="opacity-0 group-hover/tool:opacity-100 transition-opacity"> menuContent={
<EntityDropdown <div className="opacity-0 group-hover/tool:opacity-100 transition-opacity">
name={tool.name} <EntityDropdown
onDelete={onDeleteTool} name={tool.name}
isLocked={tool.isMcp || tool.isLibrary} onDelete={onDeleteTool}
/> isLocked={tool.isMcp || tool.isLibrary}
</div> />
} </div>
/> }
</div> />
))} </div>
))}
</div> </div>
)} )}
</div> </div>
@ -239,7 +249,6 @@ type ComposioToolkit = {
export function EntityList({ export function EntityList({
agents, agents,
tools, tools,
projectTools,
prompts, prompts,
workflow, workflow,
selectedEntity, selectedEntity,
@ -265,22 +274,20 @@ export function EntityList({
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void
}) { }) {
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false); const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [showComposioToolsModal, setShowComposioToolsModal] = useState(false); const [showToolsModal, setShowToolsModal] = useState(false);
const handleAddAgentWithType = (agentType: 'internal' | 'user_facing') => { const handleAddAgentWithType = (agentType: 'internal' | 'user_facing') => {
onAddAgent({ onAddAgent({
outputVisibility: agentType outputVisibility: agentType
}); });
}; };
// Merge workflow tools with project tools
const mergedTools = [...tools, ...projectTools];
const selectedRef = useRef<HTMLButtonElement>(null); const selectedRef = useRef<HTMLButtonElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(0); const [containerHeight, setContainerHeight] = useState<number>(0);
// collect composio tools // collect composio tools
const composioTools: Record<string, ComposioToolkit> = {}; const composioTools: Record<string, ComposioToolkit> = {};
for (const tool of mergedTools) { for (const tool of tools) {
if (tool.isComposio) { if (tool.isComposio) {
if (!composioTools[tool.composioData?.toolkitSlug || '']) { if (!composioTools[tool.composioData?.toolkitSlug || '']) {
composioTools[tool.composioData?.toolkitSlug || ''] = { composioTools[tool.composioData?.toolkitSlug || ''] = {
@ -519,28 +526,14 @@ export function EntityList({
<Wrench className="w-4 h-4" /> <Wrench className="w-4 h-4" />
<span>Tools</span> <span>Tools</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, tools: true })); setExpandedPanels(prev => ({ ...prev, tools: true }));
setShowComposioToolsModal(true); setShowToolsModal(true);
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Composio Tools"
>
<Component className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, tools: true }));
onAddTool({});
}} }}
className={`group ${buttonClasses}`} className={`group ${buttonClasses}`}
showHoverContent={true} showHoverContent={true}
@ -555,15 +548,15 @@ export function EntityList({
{expandedPanels.tools && ( {expandedPanels.tools && (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="p-2"> <div className="p-2">
{mergedTools.length > 0 ? ( {tools.length > 0 ? (
<div className="space-y-1"> <div className="space-y-1">
{/* Group tools by server */} {/* Group tools by server */}
{(() => { {(() => {
// Get custom tools (non-MCP tools) // Get custom tools (non-MCP tools)
const customTools = mergedTools.filter(tool => !tool.isMcp && !tool.isComposio); const customTools = tools.filter(tool => !tool.isMcp && !tool.isComposio);
// Group MCP tools by server // Group MCP tools by server
const serverTools = mergedTools.reduce((acc, tool) => { const serverTools = tools.reduce((acc, tool) => {
if (tool.isMcp && tool.mcpServerName) { if (tool.isMcp && tool.mcpServerName) {
if (!acc[tool.mcpServerName]) { if (!acc[tool.mcpServerName]) {
acc[tool.mcpServerName] = []; acc[tool.mcpServerName] = [];
@ -571,27 +564,12 @@ export function EntityList({
acc[tool.mcpServerName].push(tool); acc[tool.mcpServerName].push(tool);
} }
return acc; return acc;
}, {} as Record<string, typeof mergedTools>); }, {} as Record<string, typeof tools>);
return ( return (
<> <>
{/* Show composio cards - ordered by status */} {/* Show composio cards - ordered by status */}
{Object.values(composioTools) {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) => ( .map((card) => (
<ComposioCard <ComposioCard
key={card.slug} key={card.slug}
@ -623,23 +601,24 @@ export function EntityList({
{/* Show custom tools */} {/* Show custom tools */}
{customTools.length > 0 && ( {customTools.length > 0 && (
<div className="mt-2"> <div className="mt-2">
{customTools.map((tool, index) => ( {customTools.map((tool, index) => (
<ListItemWithMenu <ListItemWithMenu
key={`custom-tool-${index}`} key={`custom-tool-${index}`}
name={tool.name} name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name} isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => handleToolSelection(tool.name)} onClick={() => handleToolSelection(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined} selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
icon={<Boxes className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />} icon={<Boxes className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />}
menuContent={ isMocked={tool.mockTool}
<EntityDropdown menuContent={
name={tool.name} <EntityDropdown
onDelete={onDeleteTool} name={tool.name}
isLocked={tool.isLibrary} onDelete={onDeleteTool}
/> isLocked={tool.isLibrary}
}
/> />
))} }
/>
))}
</div> </div>
)} )}
</> </>
@ -741,11 +720,12 @@ export function EntityList({
onClose={() => setShowAgentTypeModal(false)} onClose={() => setShowAgentTypeModal(false)}
onConfirm={handleAddAgentWithType} onConfirm={handleAddAgentWithType}
/> />
<ComposioToolsModal <ToolsModal
isOpen={showComposioToolsModal} isOpen={showToolsModal}
onClose={() => setShowComposioToolsModal(false)} onClose={() => setShowToolsModal(false)}
projectId={projectId} projectId={projectId}
onToolsUpdated={onProjectToolsUpdated} tools={tools}
onAddTool={onAddTool}
/> />
</div> </div>
); );
@ -853,8 +833,8 @@ const ComposioCard = ({
}: ComposioCardProps) => { }: ComposioCardProps) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false);
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
const [isProcessingAuth, setIsProcessingAuth] = useState(false); const [isProcessingAuth, setIsProcessingAuth] = useState(false);
const [isProcessingMock, setIsProcessingMock] = useState(false);
// Check if the toolkit requires authentication // Check if the toolkit requires authentication
const hasToolkitWithAuth = card.tools.some(tool => tool.composioData && !tool.composioData.noAuth); const hasToolkitWithAuth = card.tools.some(tool => tool.composioData && !tool.composioData.noAuth);
@ -862,14 +842,17 @@ const ComposioCard = ({
// Check if toolkit is connected // Check if toolkit is connected
const isToolkitConnected = !hasToolkitWithAuth || projectConfig?.composioConnectedAccounts?.[card.slug]?.status === 'ACTIVE'; const isToolkitConnected = !hasToolkitWithAuth || projectConfig?.composioConnectedAccounts?.[card.slug]?.status === 'ACTIVE';
// Check if toolkit is mocked
const isToolkitMocked = workflow?.composioMockToolkitStates?.[card.slug]?.isMocked || false;
const handleConnect = () => { const handleConnect = () => {
setShowAuthModal(true); setShowAuthModal(true);
}; };
const handleDisconnect = async () => { const handleDisconnect = () => {
setShowDisconnectModal(true);
};
const handleConfirmDisconnect = async () => {
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[card.slug]?.id; const connectedAccountId = projectConfig?.composioConnectedAccounts?.[card.slug]?.id;
setIsProcessingAuth(true); setIsProcessingAuth(true);
@ -882,6 +865,7 @@ const ComposioCard = ({
console.error('Disconnect failed:', err); console.error('Disconnect failed:', err);
} finally { } finally {
setIsProcessingAuth(false); setIsProcessingAuth(false);
setShowDisconnectModal(false);
} }
}; };
@ -890,17 +874,7 @@ const ComposioCard = ({
onProjectToolsUpdated?.(); 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 ( return (
<> <>
@ -937,25 +911,22 @@ const ComposioCard = ({
</div> </div>
</button> </button>
{/* Compact Status Badge */} {/* Status Badge - only show orange when requires auth and not connected */}
<Tooltip {hasToolkitWithAuth && !isToolkitConnected && (
content={isToolkitMocked ? 'Mocked' : isToolkitConnected ? 'Connected' : 'Disconnected'} <Tooltip
size="sm" content="Disconnected"
delay={500} size="sm"
> delay={500}
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium text-white ${ >
isToolkitMocked <div className="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center text-xs font-medium text-white">
? 'bg-purple-500'
: isToolkitConnected </div>
? 'bg-emerald-500' </Tooltip>
: 'bg-orange-500' )}
}`}>
{isToolkitMocked ? 'M' : isToolkitConnected ? '●' : '○'}
</div>
</Tooltip>
{/* Actions Dropdown - only show on hover */} {/* Actions Dropdown - only show when requires auth */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity"> {hasToolkitWithAuth && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger>
<button className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"> <button className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors">
@ -965,9 +936,6 @@ const ComposioCard = ({
<DropdownMenu <DropdownMenu
onAction={(key) => { onAction={(key) => {
switch (key) { switch (key) {
case 'mock':
handleToggleMock();
break;
case 'connect': case 'connect':
handleConnect(); handleConnect();
break; break;
@ -977,29 +945,12 @@ const ComposioCard = ({
} }
}} }}
disabledKeys={[ disabledKeys={[
...(isProcessingMock ? ['mock'] : []),
...(isProcessingAuth ? ['connect', 'disconnect'] : []), ...(isProcessingAuth ? ['connect', 'disconnect'] : []),
...(hasToolkitWithAuth && !isToolkitMocked && isToolkitConnected ? [] : ['disconnect']), ...(hasToolkitWithAuth && isToolkitConnected ? [] : ['disconnect']),
...(hasToolkitWithAuth && !isToolkitConnected ? [] : ['connect']) ...(hasToolkitWithAuth && !isToolkitConnected ? [] : ['connect'])
]} ]}
> >
<DropdownItem
key="mock"
startContent={
isProcessingMock ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600"></div>
) : isToolkitMocked ? (
<Play className="h-3 w-3" />
) : (
<TestTube className="h-3 w-3" />
)
}
>
{isProcessingMock
? (isToolkitMocked ? 'Disabling Mock...' : 'Enabling Mock...')
: (isToolkitMocked ? 'Switch to Real Mode' : 'Switch to Mock Mode')
}
</DropdownItem>
<DropdownItem <DropdownItem
key="disconnect" key="disconnect"
@ -1029,6 +980,7 @@ const ComposioCard = ({
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
</div> </div>
)}
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="ml-6 mt-0.5 space-y-0.5 border-l border-gray-200 dark:border-gray-700 pl-3"> <div className="ml-6 mt-0.5 space-y-0.5 border-l border-gray-200 dark:border-gray-700 pl-3">
@ -1043,6 +995,7 @@ const ComposioCard = ({
icon={ icon={
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600"></div> <div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600"></div>
} }
isMocked={tool.mockTool}
menuContent={ menuContent={
<div className="opacity-0 group-hover/tool:opacity-100 transition-opacity"> <div className="opacity-0 group-hover/tool:opacity-100 transition-opacity">
<EntityDropdown <EntityDropdown
@ -1070,6 +1023,17 @@ const ComposioCard = ({
onComplete={handleAuthComplete} onComplete={handleAuthComplete}
/> />
)} )}
{/* Disconnect Confirmation Modal */}
<ProjectWideChangeConfirmationModal
isOpen={showDisconnectModal}
onClose={() => setShowDisconnectModal(false)}
onConfirm={handleConfirmDisconnect}
title={`Disconnect ${card.name}`}
confirmationQuestion={`Are you sure you want to disconnect the ${card.name} toolkit?`}
confirmButtonText="Disconnect"
isLoading={isProcessingAuth}
/>
</> </>
); );
}; };

View file

@ -143,9 +143,6 @@ export type Action = {
type: "show_visualise"; type: "show_visualise";
} | { } | {
type: "hide_visualise"; type: "hide_visualise";
} | {
type: "sync_workflow";
workflow: z.infer<typeof Workflow>;
}; };
function reducer(state: State, action: Action): State { function reducer(state: State, action: Action): State {
@ -211,13 +208,6 @@ function reducer(state: State, action: Action): State {
}); });
break; break;
} }
case "sync_workflow": {
newState = produce(state, draft => {
draft.present.workflow = action.workflow;
draft.present.lastUpdatedAt = action.workflow.lastUpdatedAt;
});
break;
}
case "reorder_agents": { case "reorder_agents": {
const newState = produce(state.present, draft => { const newState = produce(state.present, draft => {
draft.workflow.agents = action.agents; draft.workflow.agents = action.agents;
@ -326,7 +316,6 @@ function reducer(state: State, action: Action): State {
required: [] required: []
}, },
mockTool: true, mockTool: true,
autoSubmitMockedResponse: true,
...action.tool ...action.tool
}); });
draft.selection = { draft.selection = {
@ -587,9 +576,7 @@ export function WorkflowEditor({
workflow, workflow,
useRag, useRag,
mcpServerUrls, mcpServerUrls,
toolWebhookUrl,
defaultModel, defaultModel,
projectTools,
projectConfig, projectConfig,
eligibleModels, eligibleModels,
isLive, isLive,
@ -602,9 +589,7 @@ export function WorkflowEditor({
workflow: z.infer<typeof Workflow>; workflow: z.infer<typeof Workflow>;
useRag: boolean; useRag: boolean;
mcpServerUrls: Array<z.infer<typeof MCPServer>>; mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
defaultModel: string; defaultModel: string;
projectTools: z.infer<typeof WorkflowTool>[];
projectConfig: z.infer<typeof Project>; projectConfig: z.infer<typeof Project>;
eligibleModels: z.infer<typeof ModelsResponse> | "*"; eligibleModels: z.infer<typeof ModelsResponse> | "*";
isLive: boolean; isLive: boolean;
@ -631,11 +616,6 @@ export function WorkflowEditor({
} }
}); });
// 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<z.infer<typeof Message>[]>([]); const [chatMessages, setChatMessages] = useState<z.infer<typeof Message>[]>([]);
const updateChatMessages = useCallback((messages: z.infer<typeof Message>[]) => { const updateChatMessages = useCallback((messages: z.infer<typeof Message>[]) => {
setChatMessages(messages); setChatMessages(messages);
@ -999,7 +979,6 @@ export function WorkflowEditor({
<EntityList <EntityList
agents={state.present.workflow.agents} agents={state.present.workflow.agents}
tools={state.present.workflow.tools} tools={state.present.workflow.tools}
projectTools={projectTools}
prompts={state.present.workflow.prompts} prompts={state.present.workflow.prompts}
workflow={state.present.workflow} workflow={state.present.workflow}
selectedEntity={ selectedEntity={
@ -1043,10 +1022,8 @@ export function WorkflowEditor({
workflow={state.present.workflow} workflow={state.present.workflow}
messageSubscriber={updateChatMessages} messageSubscriber={updateChatMessages}
mcpServerUrls={mcpServerUrls} mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
isInitialState={isInitialState} isInitialState={isInitialState}
onPanelClick={handlePlaygroundClick} onPanelClick={handlePlaygroundClick}
projectTools={projectTools}
triggerCopilotChat={triggerCopilotChat} triggerCopilotChat={triggerCopilotChat}
/> />
{state.present.selection?.type === "agent" && <AgentConfig {state.present.selection?.type === "agent" && <AgentConfig
@ -1057,7 +1034,6 @@ export function WorkflowEditor({
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))} usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
agents={state.present.workflow.agents} agents={state.present.workflow.agents}
tools={state.present.workflow.tools} tools={state.present.workflow.tools}
projectTools={projectTools}
prompts={state.present.workflow.prompts} prompts={state.present.workflow.prompts}
dataSources={dataSources} dataSources={dataSources}
handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)} handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)}
@ -1069,15 +1045,12 @@ export function WorkflowEditor({
{state.present.selection?.type === "tool" && (() => { {state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find( const selectedTool = state.present.workflow.tools.find(
(tool) => tool.name === state.present.selection!.name (tool) => tool.name === state.present.selection!.name
) || projectTools.find(
(tool) => tool.name === state.present.selection!.name
); );
return <ToolConfig return <ToolConfig
key={state.present.selection.name} key={state.present.selection.name}
tool={selectedTool!} tool={selectedTool!}
usedToolNames={new Set([ usedToolNames={new Set([
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name), ...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
...projectTools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name)
])} ])}
handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)} handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)}
handleClose={handleUnselectTool} handleClose={handleUnselectTool}

View file

@ -0,0 +1,76 @@
'use client';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button } from "@heroui/react";
import { AlertTriangle } from "lucide-react";
export interface ProjectWideChangeConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
confirmationQuestion: string;
confirmButtonText?: string;
isLoading?: boolean;
disabled?: boolean;
}
export function ProjectWideChangeConfirmationModal({
isOpen,
onClose,
onConfirm,
title,
confirmationQuestion,
confirmButtonText = "Confirm",
isLoading = false,
disabled = false,
}: ProjectWideChangeConfirmationModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} size="md">
<ModalContent>
<ModalHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-600" />
<span>{title}</span>
</div>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{confirmationQuestion}
</p>
<div className="bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center text-xs font-medium text-white mt-0.5">
</div>
<div className="text-sm">
<p className="font-medium text-orange-800 dark:text-orange-200 mb-1">
This change will affect the deployed (Live) workflow as well!
</p>
</div>
</div>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="light"
onPress={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
color="primary"
onPress={onConfirm}
disabled={disabled || isLoading}
isLoading={isLoading}
>
{confirmButtonText}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}