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

View file

@ -3,9 +3,6 @@ import { z } from "zod";
import {
listToolkits as libListToolkits,
listTools as libListTools,
searchTools as libSearchTools,
getToolsByIds as libGetToolsByIds,
getTool as libGetTool,
getConnectedAccount as libGetConnectedAccount,
deleteConnectedAccount as libDeleteConnectedAccount,
listAuthConfigs as libListAuthConfigs,
@ -23,7 +20,6 @@ import {
ZCredentials,
} from "@/app/lib/composio/composio";
import { ComposioConnectedAccount } from "@/app/lib/types/project_types";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { getProjectConfig, projectAuthCheck } from "./project_actions";
import { projectsCollection } from "../lib/mongodb";
@ -46,29 +42,11 @@ export async function getToolkit(projectId: string, toolkitSlug: string): Promis
return await libGetToolkit(toolkitSlug);
}
export async function listTools(projectId: string, toolkitSlug: string, cursor: string | null = null): Promise<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);
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>> {
await projectAuthCheck(projectId);
@ -237,196 +215,5 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str
const key = `composioConnectedAccounts.${toolkitSlug}`;
await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } });
// Notify other tabs about the tools update (lightweight refresh)
if (typeof window !== 'undefined') {
localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString());
}
return true;
}
// Note: composio tools are now stored in workflow.tools array with isComposio: true
// This function provides backward compatibility by updating workflow tools
export async function getComposioToolsFromWorkflow(projectId: string): Promise<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 { projectAuthCheck } from "./project_actions";
import { redisClient } from "../lib/redis";
import { collectProjectTools } from "../lib/project_tools";
import { mergeProjectTools } from "../lib/types/project_types";
import { authorizeUserAction, logUsage } from "./billing_actions";
import { USE_BILLING } from "../lib/feature_flags";
import { WithStringId } from "../lib/types/types";
@ -44,21 +42,12 @@ export async function getCopilotResponseStream(
if (!await check_query_limit(projectId)) {
throw new QueryLimitError();
}
// Get MCP tools from project and merge with workflow tools
const projectTools = await collectProjectTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const wflow = {
...current_workflow_config,
tools: mergeProjectTools(current_workflow_config.tools, projectTools)
};
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId,
messages,
workflow: wflow,
workflow: current_workflow_config,
context,
dataSources: dataSources,
};
@ -97,20 +86,11 @@ export async function getCopilotAgentInstructions(
return { billingError: authResponse.error || 'Billing error' };
}
// Get MCP tools from project and merge with workflow tools
const projectTools = await collectProjectTools(projectId);
// Convert workflow to copilot format with both workflow and project tools
const wflow = {
...current_workflow_config,
tools: mergeProjectTools(current_workflow_config.tools, projectTools)
};
// prepare request
const request: z.infer<typeof CopilotAPIRequest> = {
projectId,
messages,
workflow: wflow,
workflow: current_workflow_config,
context: {
type: 'agent',
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 { authorizeUserAction } from "./billing_actions";
import { Workflow } from "../lib/types/workflow_types";
import { WorkflowTool } from "../lib/types/workflow_types";
import { collectProjectTools as libCollectProjectTools } from "../lib/project_tools";
import {
searchTools as libSearchTools,
getToolsByIds as libGetToolsByIds,
getTool as libGetTool,
ZTool,
ZToolkit
} from "../lib/composio/composio";
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
@ -313,113 +304,6 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id:
return { id: projectId };
}
async function detectAndAddComposioTools(projectId: string, workflow: z.infer<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 }> {
const user = await authCheck();
const workflowJson = formData.get('workflowJson') as string;
@ -441,23 +325,9 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
return response;
}
const projectId = response.id;
// Automatically detect and add Composio tools mentioned in agent instructions
try {
await detectAndAddComposioTools(projectId, workflow);
} catch (error) {
// Log error but don't fail the import if tool detection fails
console.error('Failed to auto-detect Composio tools:', error);
}
return { id: projectId };
}
export async function collectProjectTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
await projectAuthCheck(projectId);
return libCollectProjectTools(projectId);
}
export async function saveWorkflow(projectId: string, workflow: z.infer<typeof Workflow>) {
await projectAuthCheck(projectId);

View file

@ -13,7 +13,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
}
// parse the payload
const { projectId, workflow, projectTools, messages } = ZStreamAgentResponsePayload.parse(JSON.parse(payload));
const { projectId, workflow, messages } = ZStreamAgentResponsePayload.parse(JSON.parse(payload));
console.log('payload', payload);
// fetch billing customer id
@ -29,7 +29,7 @@ export async function GET(request: Request, props: { params: Promise<{ streamId:
async start(controller) {
try {
// Iterate over the generator
for await (const event of streamResponse(projectId, workflow, projectTools, messages)) {
for await (const event of streamResponse(projectId, workflow, messages)) {
// Check if this is a message event (has role property)
if ('role' in event) {
if (event.role === 'assistant') {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -278,11 +278,14 @@ export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZG
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`);
// set params
url.searchParams.set("toolkit_slug", toolkitSlug);
if (searchQuery) {
url.searchParams.set("search", searchQuery);
}
if (cursor) {
url.searchParams.set("cursor", cursor);
}
@ -291,39 +294,6 @@ export async function listTools(toolkitSlug: string, cursor: string | null = nul
return composioApiCall(ZListResponse(ZTool), url.toString());
}
export async function searchTools(searchQuery: string, cursor: string | null = null, limit: number = 50): Promise<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>>>> {
const url = new URL(`${BASE_URL}/auth_configs`);
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(),
});
export const CustomMcpServer = z.object({
serverUrl: z.string(),
});
export const Project = z.object({
_id: z.string().uuid(),
name: z.string(),
@ -29,6 +33,7 @@ export const Project = z.object({
testRunCounter: z.number().default(0),
mcpServers: z.array(MCPServer).optional(),
composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(),
customMcpServers: z.record(z.string(), CustomMcpServer).optional(),
});
export const ProjectMember = z.object({
@ -43,20 +48,4 @@ export const ApiKey = z.object({
key: z.string(),
createdAt: z.string().datetime(),
lastUsedAt: z.string().datetime().optional(),
});
export function mergeProjectTools(
workflowTools: z.infer<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({
projectId: z.string(),
workflow: Workflow,
projectTools: z.array(WorkflowTool),
messages: z.array(Message),
});

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
"use client";
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { Checkbox, Select, SelectItem, RadioGroup, Radio } from "@heroui/react";
import { Checkbox, Select, SelectItem, Switch } from "@heroui/react";
import { z } from "zod";
import { ImportIcon, XIcon, PlusIcon, FolderIcon} from "lucide-react";
import { ImportIcon, XIcon, PlusIcon, FolderIcon, Globe, Zap, ExternalLink } from "lucide-react";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import { Textarea } from "@/components/ui/textarea";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
@ -12,6 +13,7 @@ import { SectionCard } from "@/components/common/section-card";
import { ToolParamCard } from "@/components/common/tool-param-card";
import { UserIcon, Settings, Settings2 } from "lucide-react";
import { EditableField } from "@/app/lib/components/editable-field";
import Link from "next/link";
// Update textarea styles with improved states
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
@ -173,8 +175,11 @@ export function ToolConfig({
required: tool.parameters?.required || []
});
const params = useParams();
const projectId = params.projectId as string;
const [selectedParams, setSelectedParams] = useState(new Set([]));
const isReadOnly = tool.isMcp || tool.isLibrary || tool.isComposio;
const isReadOnly = tool.isMcp || tool.isComposio;
const isWebhookTool = !tool.isMcp && !tool.isComposio;
const [nameError, setNameError] = useState<string | null>(null);
// 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">
{/* 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 */}
<SectionCard
icon={<UserIcon className="w-5 h-5 text-indigo-500" />}
@ -350,6 +406,7 @@ export function ToolConfig({
<div className="flex-1">
<EditableField
value={tool.name}
locked={isReadOnly}
onChange={(value) => {
setNameError(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>
<div className="flex-1">
<EditableField
locked={isReadOnly}
value={tool.description || ""}
onChange={(value) => handleUpdate({ ...tool, description: value })}
multiline={true}
@ -385,56 +443,37 @@ export function ToolConfig({
</div>
</div>
</SectionCard>
{/* Behavior Section */}
{/* Mock Section */}
<SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />}
title="Behavior"
labelWidth="md:w-32"
title="Mock responses"
labelWidth="md:w-64"
className="mb-1"
>
<div className="flex flex-col gap-4">
{!isReadOnly && (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Tool Mode</label>
<RadioGroup
defaultValue="mock"
value={tool.mockTool ? "mock" : "api"}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<Switch
isSelected={tool.mockTool}
onValueChange={(value) => handleUpdate({
...tool,
mockTool: value === "mock",
autoSubmitMockedResponse: value === "mock" ? true : undefined
mockTool: value,
})}
orientation="horizontal"
classNames={{
wrapper: "flex flex-col md:flex-row gap-2 md:gap-8 pl-0 md:pl-3",
label: "text-sm"
}}
>
<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>
size="sm"
color="primary"
/>
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300">
Mock tool responses
</label>
</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 && (
<div className="flex flex-col gap-2 pl-0 md:pl-3 mt-2">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Mock Response Schema</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>
<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 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.</span>
<EditableField
value={tool.mockInstructions || ''}
onChange={(value) => handleUpdate({
@ -445,20 +484,6 @@ export function ToolConfig({
placeholder="Mock response instructions..."
className="w-full text-xs p-2 bg-white dark:bg-gray-900"
/>
<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>

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

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 { Spinner } from "@heroui/react";
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 { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { getEligibleModels } from "@/app/actions/billing_actions";
@ -25,12 +25,10 @@ export function App({
const [mode, setMode] = useState<'draft' | 'live'>('draft');
const [project, setProject] = useState<WithStringId<z.infer<typeof Project>> | 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 [loading, setLoading] = useState(false);
const [eligibleModels, setEligibleModels] = useState<z.infer<typeof ModelsResponse> | "*">("*");
const [projectMcpServers, setProjectMcpServers] = useState<Array<z.infer<typeof MCPServer>>>([]);
const [webhookUrl, setWebhookUrl] = useState<string>('');
console.log('workflow app.tsx render');
@ -45,92 +43,39 @@ export function App({
const [
project,
dataSources,
projectTools,
eligibleModels,
] = await Promise.all([
getProjectConfig(projectId),
listDataSources(projectId),
collectProjectTools(projectId),
getEligibleModels(),
]);
setProject(project);
setDataSources(dataSources);
setProjectTools(projectTools);
setEligibleModels(eligibleModels);
if (project.mcpServers) {
setProjectMcpServers(project.mcpServers);
}
if (project.webhookUrl) {
setWebhookUrl(project.webhookUrl);
}
setLoading(false);
}, [projectId]);
const handleProjectToolsUpdate = useCallback(async () => {
// Lightweight refresh for tool-only updates
const [projectConfig, projectTools] = await Promise.all([
getProjectConfig(projectId),
collectProjectTools(projectId),
]);
const projectConfig = await getProjectConfig(projectId);
setProject(projectConfig);
setProjectConfig(projectConfig);
setProjectTools(projectTools);
// Update MCP servers if they changed
if (projectConfig.mcpServers) {
setProjectMcpServers(projectConfig.mcpServers);
}
// Update webhook URL if it changed
if (projectConfig.webhookUrl) {
setWebhookUrl(projectConfig.webhookUrl);
}
}, [projectId]);
// Add this useEffect for initial load
useEffect(() => {
loadData();
}, [mode, loadData, projectId]);
// Add focus-based refresh to handle cross-page updates
useEffect(() => {
const handleFocus = () => {
// Refresh data when user returns to this page/tab
loadData();
};
const handleVisibilityChange = () => {
if (!document.hidden) {
loadData();
}
};
const handleStorageChange = (e: StorageEvent) => {
// Listen for tool updates from other tabs
if (e.key === `tools-updated-${projectId}` && e.newValue) {
loadData();
// Clear the flag
localStorage.removeItem(`tools-updated-${projectId}`);
} else if (e.key === `tools-light-refresh-${projectId}` && e.newValue) {
// Lightweight refresh for tool-only updates
handleProjectToolsUpdate();
// Clear the flag
localStorage.removeItem(`tools-light-refresh-${projectId}`);
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('storage', handleStorageChange);
};
}, [loadData, handleProjectToolsUpdate, projectId]);
function handleSetMode(mode: 'draft' | 'live') {
setMode(mode);
}
@ -149,16 +94,14 @@ export function App({
<div>Loading workflow...</div>
</div>}
{!loading && !workflow && <div>No workflow found!</div>}
{!loading && project && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor
{!loading && project && workflow && (dataSources !== null) && <WorkflowEditor
projectId={projectId}
isLive={mode == 'live'}
workflow={workflow}
dataSources={dataSources}
projectTools={projectTools}
projectConfig={projectConfig || project}
useRag={useRag}
mcpServerUrls={projectMcpServers}
toolWebhookUrl={webhookUrl}
defaultModel={defaultModel}
eligibleModels={eligibleModels}
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 { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRef, useEffect, useState } from "react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, 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 { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
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 { ServerLogo } from '../tools/components/MCPServersCommon';
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 { 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
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 {
agents: z.infer<typeof WorkflowAgent>[];
tools: z.infer<typeof WorkflowTool>[];
projectTools: z.infer<typeof WorkflowTool>[];
prompts: z.infer<typeof WorkflowPrompt>[];
workflow: z.infer<typeof Workflow>;
selectedEntity: {
@ -88,6 +88,7 @@ const ListItemWithMenu = ({
iconClassName,
mcpServerName,
dragHandle,
isMocked,
}: {
name: string;
isSelected?: boolean;
@ -100,6 +101,7 @@ const ListItemWithMenu = ({
iconClassName?: string;
mcpServerName?: string;
dragHandle?: React.ReactNode;
isMocked?: boolean;
}) => {
return (
<div className={clsx(
@ -137,6 +139,13 @@ const ListItemWithMenu = ({
</button>
<div className="flex items-center gap-1">
{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}
</div>
</div>
@ -203,26 +212,27 @@ const ServerCard = ({
{isExpanded && (
<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) => (
<div key={`tool-${index}`} className="group/tool">
<ListItemWithMenu
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
mcpServerName={serverName}
menuContent={
<div className="opacity-0 group-hover/tool:opacity-100 transition-opacity">
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isMcp || tool.isLibrary}
/>
</div>
}
/>
</div>
))}
{tools.map((tool, index) => (
<div key={`tool-${index}`} className="group/tool">
<ListItemWithMenu
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
mcpServerName={serverName}
isMocked={tool.mockTool}
menuContent={
<div className="opacity-0 group-hover/tool:opacity-100 transition-opacity">
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isMcp || tool.isLibrary}
/>
</div>
}
/>
</div>
))}
</div>
)}
</div>
@ -239,7 +249,6 @@ type ComposioToolkit = {
export function EntityList({
agents,
tools,
projectTools,
prompts,
workflow,
selectedEntity,
@ -265,22 +274,20 @@ export function EntityList({
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void
}) {
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [showComposioToolsModal, setShowComposioToolsModal] = useState(false);
const [showToolsModal, setShowToolsModal] = useState(false);
const handleAddAgentWithType = (agentType: 'internal' | 'user_facing') => {
onAddAgent({
outputVisibility: agentType
});
};
// Merge workflow tools with project tools
const mergedTools = [...tools, ...projectTools];
const selectedRef = useRef<HTMLButtonElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number>(0);
// collect composio tools
const composioTools: Record<string, ComposioToolkit> = {};
for (const tool of mergedTools) {
for (const tool of tools) {
if (tool.isComposio) {
if (!composioTools[tool.composioData?.toolkitSlug || '']) {
composioTools[tool.composioData?.toolkitSlug || ''] = {
@ -519,28 +526,14 @@ export function EntityList({
<Wrench className="w-4 h-4" />
<span>Tools</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, tools: true }));
setShowComposioToolsModal(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({});
setShowToolsModal(true);
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
@ -555,15 +548,15 @@ export function EntityList({
{expandedPanels.tools && (
<div className="h-full overflow-y-auto">
<div className="p-2">
{mergedTools.length > 0 ? (
{tools.length > 0 ? (
<div className="space-y-1">
{/* Group tools by server */}
{(() => {
// 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
const serverTools = mergedTools.reduce((acc, tool) => {
const serverTools = tools.reduce((acc, tool) => {
if (tool.isMcp && tool.mcpServerName) {
if (!acc[tool.mcpServerName]) {
acc[tool.mcpServerName] = [];
@ -571,27 +564,12 @@ export function EntityList({
acc[tool.mcpServerName].push(tool);
}
return acc;
}, {} as Record<string, typeof mergedTools>);
}, {} as Record<string, typeof tools>);
return (
<>
{/* Show composio cards - ordered by status */}
{Object.values(composioTools)
.sort((a, b) => {
// Helper function to get toolkit status priority
const getStatusPriority = (toolkit: ComposioToolkit) => {
const hasAuth = toolkit.tools.some(tool => tool.composioData && !tool.composioData.noAuth);
const isConnected = !hasAuth || projectConfig?.composioConnectedAccounts?.[toolkit.slug]?.status === 'ACTIVE';
const isMocked = workflow?.composioMockToolkitStates?.[toolkit.slug]?.isMocked || false;
// Priority: Connected (1) > Mock (2) > Disconnected (3)
if (isMocked) return 2;
if (isConnected) return 1;
return 3;
};
return getStatusPriority(a) - getStatusPriority(b);
})
.map((card) => (
<ComposioCard
key={card.slug}
@ -623,23 +601,24 @@ export function EntityList({
{/* Show custom tools */}
{customTools.length > 0 && (
<div className="mt-2">
{customTools.map((tool, index) => (
<ListItemWithMenu
key={`custom-tool-${index}`}
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => handleToolSelection(tool.name)}
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" />}
menuContent={
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isLibrary}
/>
}
{customTools.map((tool, index) => (
<ListItemWithMenu
key={`custom-tool-${index}`}
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => handleToolSelection(tool.name)}
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" />}
isMocked={tool.mockTool}
menuContent={
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isLibrary}
/>
))}
}
/>
))}
</div>
)}
</>
@ -741,11 +720,12 @@ export function EntityList({
onClose={() => setShowAgentTypeModal(false)}
onConfirm={handleAddAgentWithType}
/>
<ComposioToolsModal
isOpen={showComposioToolsModal}
onClose={() => setShowComposioToolsModal(false)}
<ToolsModal
isOpen={showToolsModal}
onClose={() => setShowToolsModal(false)}
projectId={projectId}
onToolsUpdated={onProjectToolsUpdated}
tools={tools}
onAddTool={onAddTool}
/>
</div>
);
@ -853,8 +833,8 @@ const ComposioCard = ({
}: ComposioCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
const [isProcessingAuth, setIsProcessingAuth] = useState(false);
const [isProcessingMock, setIsProcessingMock] = useState(false);
// Check if the toolkit requires authentication
const hasToolkitWithAuth = card.tools.some(tool => tool.composioData && !tool.composioData.noAuth);
@ -862,14 +842,17 @@ const ComposioCard = ({
// Check if toolkit is connected
const isToolkitConnected = !hasToolkitWithAuth || projectConfig?.composioConnectedAccounts?.[card.slug]?.status === 'ACTIVE';
// Check if toolkit is mocked
const isToolkitMocked = workflow?.composioMockToolkitStates?.[card.slug]?.isMocked || false;
const handleConnect = () => {
setShowAuthModal(true);
};
const handleDisconnect = async () => {
const handleDisconnect = () => {
setShowDisconnectModal(true);
};
const handleConfirmDisconnect = async () => {
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[card.slug]?.id;
setIsProcessingAuth(true);
@ -882,6 +865,7 @@ const ComposioCard = ({
console.error('Disconnect failed:', err);
} finally {
setIsProcessingAuth(false);
setShowDisconnectModal(false);
}
};
@ -890,17 +874,7 @@ const ComposioCard = ({
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 (
<>
@ -937,25 +911,22 @@ const ComposioCard = ({
</div>
</button>
{/* Compact Status Badge */}
<Tooltip
content={isToolkitMocked ? 'Mocked' : isToolkitConnected ? 'Connected' : 'Disconnected'}
size="sm"
delay={500}
>
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-medium text-white ${
isToolkitMocked
? 'bg-purple-500'
: isToolkitConnected
? 'bg-emerald-500'
: 'bg-orange-500'
}`}>
{isToolkitMocked ? 'M' : isToolkitConnected ? '●' : '○'}
</div>
</Tooltip>
{/* Status Badge - only show orange when requires auth and not connected */}
{hasToolkitWithAuth && !isToolkitConnected && (
<Tooltip
content="Disconnected"
size="sm"
delay={500}
>
<div className="w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center text-xs font-medium text-white">
</div>
</Tooltip>
)}
{/* Actions Dropdown - only show on hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
{/* Actions Dropdown - only show when requires auth */}
{hasToolkitWithAuth && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<Dropdown>
<DropdownTrigger>
<button className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors">
@ -965,9 +936,6 @@ const ComposioCard = ({
<DropdownMenu
onAction={(key) => {
switch (key) {
case 'mock':
handleToggleMock();
break;
case 'connect':
handleConnect();
break;
@ -977,29 +945,12 @@ const ComposioCard = ({
}
}}
disabledKeys={[
...(isProcessingMock ? ['mock'] : []),
...(isProcessingAuth ? ['connect', 'disconnect'] : []),
...(hasToolkitWithAuth && !isToolkitMocked && isToolkitConnected ? [] : ['disconnect']),
...(hasToolkitWithAuth && isToolkitConnected ? [] : ['disconnect']),
...(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
key="disconnect"
@ -1029,6 +980,7 @@ const ComposioCard = ({
</DropdownMenu>
</Dropdown>
</div>
)}
</div>
{isExpanded && (
<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={
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600"></div>
}
isMocked={tool.mockTool}
menuContent={
<div className="opacity-0 group-hover/tool:opacity-100 transition-opacity">
<EntityDropdown
@ -1070,6 +1023,17 @@ const ComposioCard = ({
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: "hide_visualise";
} | {
type: "sync_workflow";
workflow: z.infer<typeof Workflow>;
};
function reducer(state: State, action: Action): State {
@ -211,13 +208,6 @@ function reducer(state: State, action: Action): State {
});
break;
}
case "sync_workflow": {
newState = produce(state, draft => {
draft.present.workflow = action.workflow;
draft.present.lastUpdatedAt = action.workflow.lastUpdatedAt;
});
break;
}
case "reorder_agents": {
const newState = produce(state.present, draft => {
draft.workflow.agents = action.agents;
@ -326,7 +316,6 @@ function reducer(state: State, action: Action): State {
required: []
},
mockTool: true,
autoSubmitMockedResponse: true,
...action.tool
});
draft.selection = {
@ -587,9 +576,7 @@ export function WorkflowEditor({
workflow,
useRag,
mcpServerUrls,
toolWebhookUrl,
defaultModel,
projectTools,
projectConfig,
eligibleModels,
isLive,
@ -602,9 +589,7 @@ export function WorkflowEditor({
workflow: z.infer<typeof Workflow>;
useRag: boolean;
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
toolWebhookUrl: string;
defaultModel: string;
projectTools: z.infer<typeof WorkflowTool>[];
projectConfig: z.infer<typeof Project>;
eligibleModels: z.infer<typeof ModelsResponse> | "*";
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 updateChatMessages = useCallback((messages: z.infer<typeof Message>[]) => {
setChatMessages(messages);
@ -999,7 +979,6 @@ export function WorkflowEditor({
<EntityList
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
projectTools={projectTools}
prompts={state.present.workflow.prompts}
workflow={state.present.workflow}
selectedEntity={
@ -1043,10 +1022,8 @@ export function WorkflowEditor({
workflow={state.present.workflow}
messageSubscriber={updateChatMessages}
mcpServerUrls={mcpServerUrls}
toolWebhookUrl={toolWebhookUrl}
isInitialState={isInitialState}
onPanelClick={handlePlaygroundClick}
projectTools={projectTools}
triggerCopilotChat={triggerCopilotChat}
/>
{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))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
projectTools={projectTools}
prompts={state.present.workflow.prompts}
dataSources={dataSources}
handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)}
@ -1069,15 +1045,12 @@ export function WorkflowEditor({
{state.present.selection?.type === "tool" && (() => {
const selectedTool = state.present.workflow.tools.find(
(tool) => tool.name === state.present.selection!.name
) || projectTools.find(
(tool) => tool.name === state.present.selection!.name
);
return <ToolConfig
key={state.present.selection.name}
tool={selectedTool!}
usedToolNames={new Set([
...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)}
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>
);
}