refactor tools UX: part 1

This commit is contained in:
arkml 2025-07-16 20:13:03 +05:30 committed by Ramnique Singh
parent cccd383b92
commit 751a86c34d
21 changed files with 1462 additions and 258 deletions

View file

@ -3,6 +3,9 @@ import { z } from "zod";
import {
listToolkits as libListToolkits,
listTools as libListTools,
searchTools as libSearchTools,
getToolsByIds as libGetToolsByIds,
getTool as libGetTool,
getConnectedAccount as libGetConnectedAccount,
deleteConnectedAccount as libDeleteConnectedAccount,
listAuthConfigs as libListAuthConfigs,
@ -20,6 +23,7 @@ import {
ZCredentials,
} from "@/app/lib/composio/composio";
import { ComposioConnectedAccount } from "@/app/lib/types/project_types";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { getProjectConfig, projectAuthCheck } from "./project_actions";
import { projectsCollection } from "../lib/mongodb";
@ -47,6 +51,24 @@ export async function listTools(projectId: string, toolkitSlug: string, cursor:
return await libListTools(toolkitSlug, cursor);
}
// New efficient search functions
export async function searchTools(projectId: string, searchQuery: string, cursor: string | null = null, limit?: number): Promise<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);
@ -215,12 +237,196 @@ export async function deleteConnectedAccount(projectId: string, toolkitSlug: str
const key = `composioConnectedAccounts.${toolkitSlug}`;
await projectsCollection.updateOne({ _id: projectId }, { $unset: { [key]: "" } });
// Notify other tabs about the tools update (lightweight refresh)
if (typeof window !== 'undefined') {
localStorage.setItem(`tools-light-refresh-${projectId}`, Date.now().toString());
}
return true;
}
// Note: composio tools are now stored in workflow.tools array with isComposio: true
// This function provides backward compatibility by updating workflow tools
export async function getComposioToolsFromWorkflow(projectId: string): Promise<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);
// update project with new selected tools
await projectsCollection.updateOne({ _id: projectId }, { $set: { composioSelectedTools: tools } });
// Get the project to access draft workflow
const project = await projectsCollection.findOne({ _id: projectId });
if (!project || !project.draftWorkflow) {
throw new Error(`Project ${projectId} not found or has no draft workflow`);
}
// Convert Composio tools to workflow tool format
const composioWorkflowTools: z.infer<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

@ -16,6 +16,13 @@ import { authorizeUserAction } from "./billing_actions";
import { Workflow } from "../lib/types/workflow_types";
import { WorkflowTool } from "../lib/types/workflow_types";
import { collectProjectTools as libCollectProjectTools } from "../lib/project_tools";
import {
searchTools as libSearchTools,
getToolsByIds as libGetToolsByIds,
getTool as libGetTool,
ZTool,
ZToolkit
} from "../lib/composio/composio";
const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
@ -306,6 +313,113 @@ export async function createProjectFromPrompt(formData: FormData): Promise<{ id:
return { id: projectId };
}
async function detectAndAddComposioTools(projectId: string, workflow: z.infer<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;
@ -327,6 +441,15 @@ export async function createProjectFromWorkflowJson(formData: FormData): Promise
return response;
}
const projectId = response.id;
// Automatically detect and add Composio tools mentioned in agent instructions
try {
await detectAndAddComposioTools(projectId, workflow);
} catch (error) {
// Log error but don't fail the import if tool detection fails
console.error('Failed to auto-detect Composio tools:', error);
}
return { id: projectId };
}

View file

@ -17,6 +17,7 @@ import { dataSourceDocsCollection, dataSourcesCollection, projectsCollection } f
import { qdrantClient } from '../lib/qdrant';
import { EmbeddingRecord } from "./types/datasource_types";
import { ConnectedEntity, sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, WorkflowTool } from "./types/workflow_types";
import { Project } from "./types/project_types";
import { CHILD_TRANSFER_RELATED_INSTRUCTIONS, CONVERSATION_TYPE_INSTRUCTIONS, RAG_INSTRUCTIONS, TASK_TYPE_INSTRUCTIONS } from "./agent_instructions";
import { PrefixLogger } from "./utils";
import { Message, AssistantMessage, AssistantMessageWithToolCalls, ToolMessage } from "./types/types";
@ -280,6 +281,8 @@ async function invokeComposioTool(
name: string,
composioData: z.infer<typeof WorkflowTool>['composioData'] & {},
input: any,
workflow: z.infer<typeof Workflow>,
toolDescription?: string,
) {
logger = logger.child(`invokeComposioTool`);
logger.log(`projectId: ${projectId}`);
@ -288,12 +291,36 @@ async function invokeComposioTool(
const { slug, toolkitSlug, noAuth } = composioData;
// Get project configuration to check for connected accounts (still stored in project)
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error(`project ${projectId} not found`);
}
// Check if toolkit is in mock mode (now from workflow)
const mockState = workflow.composioMockToolkitStates?.[toolkitSlug];
if (mockState?.isMocked) {
logger.log(`toolkit ${toolkitSlug} is in mock mode, using mock response`);
// Use the existing invokeMockTool function to generate a mock response
const mockInstructions = mockState.mockInstructions || 'Mock responses using GPT-4.1 based on tool descriptions.';
const description = toolDescription || `${name} tool from ${toolkitSlug} toolkit`;
const mockResponse = await invokeMockTool(
logger,
name,
JSON.stringify(input),
description,
mockInstructions
);
logger.log(`mock tool result: ${mockResponse}`);
return mockResponse;
}
// Normal execution path - check for authentication
let connectedAccountId: string | undefined = undefined;
if (!noAuth) {
const project = await projectsCollection.findOne({ _id: projectId });
if (!project) {
throw new Error(`project ${projectId} not found`);
}
connectedAccountId = project.composioConnectedAccounts?.[toolkitSlug]?.id;
if (!connectedAccountId) {
throw new Error(`connected account id not found for project ${projectId} and toolkit ${toolkitSlug}`);
@ -452,7 +479,8 @@ function createMcpTool(
function createComposioTool(
logger: PrefixLogger,
config: z.infer<typeof WorkflowTool>,
projectId: string
projectId: string,
workflow: z.infer<typeof Workflow>
): Tool {
const { name, description, parameters, composioData } = config;
@ -472,7 +500,7 @@ function createComposioTool(
},
async execute(input: any) {
try {
const result = await invokeComposioTool(logger, projectId, name, composioData, input);
const result = await invokeComposioTool(logger, projectId, name, composioData, input, workflow, description);
return JSON.stringify({
result,
});
@ -813,7 +841,7 @@ function createTools(
tools[toolName] = createMcpTool(logger, config, projectId);
logger.log(`created mcp tool: ${toolName}`);
} else if (config.isComposio) {
tools[toolName] = createComposioTool(logger, config, projectId);
tools[toolName] = createComposioTool(logger, config, projectId, workflow);
logger.log(`created composio tool: ${toolName}`);
} else if (config.mockTool) {
tools[toolName] = createMockTool(logger, config);

View file

@ -291,6 +291,39 @@ export async function listTools(toolkitSlug: string, cursor: string | null = nul
return composioApiCall(ZListResponse(ZTool), url.toString());
}
export async function searchTools(searchQuery: string, cursor: string | null = null, limit: number = 50): Promise<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

@ -33,28 +33,8 @@ export async function collectProjectTools(projectId: string): Promise<z.infer<ty
}
}
// Add Composio tools
if (project.composioSelectedTools) {
for (const tool of project.composioSelectedTools) {
tools.push({
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,
},
});
}
}
// 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

@ -1,7 +1,6 @@
import { z } from "zod";
import { MCPServer } from "./types";
import { Workflow, WorkflowTool } from "./workflow_types";
import { ZTool } from "../composio/composio";
export const ComposioConnectedAccount = z.object({
id: z.string(),
@ -30,7 +29,6 @@ export const Project = z.object({
testRunCounter: z.number().default(0),
mcpServers: z.array(MCPServer).optional(),
composioConnectedAccounts: z.record(z.string(), ComposioConnectedAccount).optional(),
composioSelectedTools: z.array(ZTool).optional(),
});
export const ProjectMember = z.object({

View file

@ -67,6 +67,14 @@ 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({

View file

@ -5,7 +5,7 @@ import { useParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Info, RefreshCw, Search } from 'lucide-react';
import clsx from 'clsx';
import { listToolkits, listTools, updateComposioSelectedTools } from '@/app/actions/composio_actions';
import { listToolkits, listTools, updateComposioSelectedTools, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions';
import { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
@ -30,6 +30,7 @@ export function Composio() {
const [selectedToolkit, setSelectedToolkit] = useState<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 {
@ -41,6 +42,15 @@ export function Composio() {
}
}, [projectId]);
const loadComposioSelectedTools = useCallback(async () => {
try {
const tools = await getComposioToolsFromWorkflow(projectId);
setComposioSelectedTools(tools);
} catch (err: any) {
console.error('Error fetching composio selected tools:', err);
}
}, [projectId]);
const loadAllToolkits = useCallback(async () => {
let cursor: string | null = null;
let allToolkits: ToolkitType[] = [];
@ -87,15 +97,16 @@ export function Composio() {
const handleProjectConfigUpdate = useCallback(() => {
loadProjectConfig();
}, [loadProjectConfig]);
loadComposioSelectedTools();
}, [loadProjectConfig, loadComposioSelectedTools]);
const handleUpdateToolsSelection = useCallback(async (selectedToolObjects: z.infer<typeof ZTool>[]) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from project config
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
// Get existing selected tools from workflow
const existingSelectedTools = composioSelectedTools;
// Create a map of existing tools by slug for easy lookup
const existingToolsMap = new Map(existingSelectedTools.map(tool => [tool.slug, tool]));
@ -110,22 +121,22 @@ export function Composio() {
await updateComposioSelectedTools(projectId, mergedSelectedTools);
// Refresh project config to get updated data
await loadProjectConfig();
// Refresh data to get updated tools
await loadComposioSelectedTools();
} catch (error) {
console.error('Error saving tool selection:', error);
} finally {
setSavingTools(false);
}
}, [projectId, projectConfig, loadProjectConfig]);
}, [projectId, composioSelectedTools, loadComposioSelectedTools]);
const handleRemoveToolkitTools = useCallback(async (toolkitSlug: string) => {
if (!projectId) return;
setSavingTools(true);
try {
// Get existing selected tools from project config
const existingSelectedTools = projectConfig?.composioSelectedTools || [];
// Get existing selected tools from workflow
const existingSelectedTools = composioSelectedTools;
// Filter out all tools from the specified toolkit
const filteredSelectedTools = existingSelectedTools.filter(tool =>
@ -134,18 +145,19 @@ export function Composio() {
await updateComposioSelectedTools(projectId, filteredSelectedTools);
// Refresh project config to get updated data
await loadProjectConfig();
// Refresh data to get updated tools
await loadComposioSelectedTools();
} catch (error) {
console.error('Error removing toolkit tools:', error);
} finally {
setSavingTools(false);
}
}, [projectId, projectConfig, loadProjectConfig]);
}, [projectId, composioSelectedTools, loadComposioSelectedTools]);
useEffect(() => {
loadProjectConfig();
}, [loadProjectConfig]);
loadComposioSelectedTools();
}, [loadProjectConfig, loadComposioSelectedTools]);
useEffect(() => {
loadAllToolkits();

View file

@ -5,7 +5,7 @@ import { useParams } from 'next/navigation';
import { PictureImg } from '@/components/ui/picture-img';
import { Button, Checkbox } from '@heroui/react';
import { ChevronLeft, ChevronRight, LinkIcon, Loader2, UnlinkIcon } from 'lucide-react';
import { listTools, deleteConnectedAccount } from '@/app/actions/composio_actions';
import { listTools, deleteConnectedAccount, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions';
import { z } from 'zod';
import { ZTool, ZListResponse } from '@/app/lib/composio/composio';
import { SlidePanel } from '@/components/ui/slide-panel';
@ -57,6 +57,7 @@ export function ComposioToolsPanel({
const [hasChanges, setHasChanges] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isProcessingAuth, setIsProcessingAuth] = useState(false);
const [composioSelectedTools, setComposioSelectedTools] = useState<ToolType[]>([]);
const loadToolsForToolkit = useCallback(async (toolkitSlug: string, cursor: string | null = null) => {
try {
@ -80,6 +81,15 @@ export function ComposioToolsPanel({
}
}, [projectId]);
const loadComposioSelectedTools = useCallback(async () => {
try {
const tools = await getComposioToolsFromWorkflow(projectId);
setComposioSelectedTools(tools);
} catch (err: any) {
console.error('Error fetching composio selected tools:', err);
}
}, [projectId]);
const handleNextPage = useCallback(async () => {
if (!nextCursor || !toolkit) return;
@ -164,14 +174,21 @@ export function ComposioToolsPanel({
}
}, [onClose, hasChanges]);
// Initialize selected tools from project config when opening the panel
// Initialize selected tools from workflow when opening the panel
useEffect(() => {
if (toolkit && isOpen && projectConfig?.composioSelectedTools) {
const toolSlugs = new Set(projectConfig.composioSelectedTools.map(tool => tool.slug));
if (toolkit && isOpen) {
loadComposioSelectedTools();
}
}, [toolkit, isOpen, loadComposioSelectedTools]);
// Set selected tools when composioSelectedTools is loaded
useEffect(() => {
if (toolkit && composioSelectedTools.length > 0) {
const toolSlugs = new Set(composioSelectedTools.map(tool => tool.slug));
setSelectedTools(toolSlugs);
setHasChanges(false);
}
}, [toolkit, isOpen, projectConfig]);
}, [toolkit, composioSelectedTools]);
useEffect(() => {
if (toolkit && isOpen) {
@ -210,44 +227,46 @@ export function ComposioToolsPanel({
<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-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-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-orange-500'
isToolkitConnected ? 'bg-emerald-500' : 'bg-blue-500'
}`}></div>
<div>
<h3 className={`font-semibold text-sm ${
isToolkitConnected
? 'text-emerald-800 dark:text-emerald-200'
: 'text-orange-800 dark:text-orange-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-orange-700 dark:text-orange-300'
: 'text-blue-700 dark:text-blue-300'
}`}>
{isToolkitConnected
? 'You can select and use tools from this toolkit'
: 'Connect your account to access and use tools'
: 'You can select tools now. Authentication will be required in the build view to use them.'
}
</p>
</div>
</div>
<Button
variant="solid"
size="sm"
onPress={isToolkitConnected ? handleDisconnect : handleConnect}
disabled={isProcessingAuth}
color={isToolkitConnected ? "danger" : "primary"}
isLoading={isProcessingAuth}
startContent={isToolkitConnected ? <UnlinkIcon className="h-4 w-4" /> : <LinkIcon className="h-4 w-4" />}
>
{isToolkitConnected ? 'Disconnect' : 'Connect Now'}
</Button>
{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>
)}
@ -263,7 +282,7 @@ export function ComposioToolsPanel({
size="sm"
color="primary"
onPress={handleSaveTools}
disabled={isSaving || !isToolkitConnected}
disabled={isSaving}
isLoading={isSaving}
>
Save Changes
@ -283,17 +302,12 @@ export function ComposioToolsPanel({
) : (
<div className="space-y-4">
{tools.map((tool) => (
<div key={tool.slug} className={`group p-4 rounded-lg transition-all duration-200 border border-transparent ${
isToolkitConnected
? 'bg-gray-50/50 dark:bg-gray-800/50 hover:bg-gray-100/50 dark:hover:bg-gray-700/50 hover:border-gray-200 dark:hover:border-gray-600'
: 'bg-gray-100/50 dark:bg-gray-900/50 opacity-60'
}`}>
<div 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"
isDisabled={!isToolkitConnected}
/>
<div className="flex-1">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">

View file

@ -24,7 +24,7 @@ import { Modal } from '@/components/ui/modal';
type McpServerType = z.infer<typeof MCPServer>;
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
export function CustomServers() {
export function CustomServers({ onToolsUpdated }: { onToolsUpdated?: () => void }) {
const params = useParams();
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
if (!projectId) throw new Error('Project ID is required');
@ -92,6 +92,9 @@ export function CustomServers() {
return s;
});
});
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (err) {
console.error('Toggle failed:', { server: server.name, error: err });
} finally {
@ -161,6 +164,9 @@ export function CustomServers() {
// Update selectedTools to include all tools for the custom server
setSelectedTools(new Set(updatedAvailableTools.map(tool => tool.id)));
}
// Notify parent component about tool updates
onToolsUpdated?.();
} finally {
setSyncingServers(prev => {
const next = new Set(prev);
@ -207,6 +213,9 @@ export function CustomServers() {
// Fetch tools for the new server using the formatted URL
await handleSyncServer(formattedServer);
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (err) {
console.error('Error adding server:', err);
setError('Failed to add server. Please try again.');
@ -229,6 +238,9 @@ export function CustomServers() {
if (selectedServer?.name === server.name) {
setSelectedServer(null);
}
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (err) {
console.error('Error removing server:', err);
setError('Failed to remove server. Please try again.');
@ -271,6 +283,9 @@ export function CustomServers() {
});
setHasToolChanges(false);
// Notify parent component about tool updates
onToolsUpdated?.();
} catch (error) {
console.error('Error saving tool selection:', error);
} finally {

View file

@ -52,9 +52,8 @@ export function ToolkitCard({
}, [onManageTools]);
// Calculate selected tools count for this toolkit
const selectedToolsCount = projectConfig?.composioSelectedTools?.filter(tool =>
tool.toolkit.slug === toolkit.slug
).length || 0;
// TODO: Update to use workflow-based tools count
const selectedToolsCount = 0;
return (
<div className={toolkitCardStyles.base} onClick={handleCardClick}>

View file

@ -16,7 +16,7 @@ export default async function ToolsPage() {
<div className="flex-1 p-6">
<Suspense fallback={<div>Loading...</div>}>
<ToolsConfig
useComposioTools={USE_COMPOSIO_TOOLS}
useComposioTools={false}
useKlavisTools={USE_KLAVIS_TOOLS}
/>
</Suspense>

View file

@ -1,6 +1,7 @@
"use client";
import { MCPServer, WithStringId } from "../../../lib/types/types";
import { DataSource } from "../../../lib/types/datasource_types";
import { Project } from "../../../lib/types/project_types";
import { z } from "zod";
import { useCallback, useEffect, useState } from "react";
import { WorkflowEditor } from "./workflow_editor";
@ -11,7 +12,6 @@ import { getProjectConfig } from "@/app/actions/project_actions";
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
import { getEligibleModels } from "@/app/actions/billing_actions";
import { ModelsResponse } from "@/app/lib/types/billing_types";
import { Project } from "@/app/lib/types/project_types";
export function App({
projectId,
@ -26,6 +26,7 @@ export function App({
const [project, setProject] = useState<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>>>([]);
@ -66,11 +67,70 @@ export function App({
setLoading(false);
}, [projectId]);
const handleProjectToolsUpdate = useCallback(async () => {
// Lightweight refresh for tool-only updates
const [projectConfig, projectTools] = await Promise.all([
getProjectConfig(projectId),
collectProjectTools(projectId),
]);
setProject(projectConfig);
setProjectConfig(projectConfig);
setProjectTools(projectTools);
// Update MCP servers if they changed
if (projectConfig.mcpServers) {
setProjectMcpServers(projectConfig.mcpServers);
}
// Update webhook URL if it changed
if (projectConfig.webhookUrl) {
setWebhookUrl(projectConfig.webhookUrl);
}
}, [projectId]);
// Add this useEffect for initial load
useEffect(() => {
loadData();
}, [mode, loadData, projectId]);
// Add focus-based refresh to handle cross-page updates
useEffect(() => {
const handleFocus = () => {
// Refresh data when user returns to this page/tab
loadData();
};
const handleVisibilityChange = () => {
if (!document.hidden) {
loadData();
}
};
const handleStorageChange = (e: StorageEvent) => {
// Listen for tool updates from other tabs
if (e.key === `tools-updated-${projectId}` && e.newValue) {
loadData();
// Clear the flag
localStorage.removeItem(`tools-updated-${projectId}`);
} else if (e.key === `tools-light-refresh-${projectId}` && e.newValue) {
// Lightweight refresh for tool-only updates
handleProjectToolsUpdate();
// Clear the flag
localStorage.removeItem(`tools-light-refresh-${projectId}`);
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('storage', handleStorageChange);
};
}, [loadData, handleProjectToolsUpdate, projectId]);
function handleSetMode(mode: 'draft' | 'live') {
setMode(mode);
}
@ -95,6 +155,7 @@ export function App({
workflow={workflow}
dataSources={dataSources}
projectTools={projectTools}
projectConfig={projectConfig || project}
useRag={useRag}
mcpServerUrls={projectMcpServers}
toolWebhookUrl={webhookUrl}
@ -102,6 +163,7 @@ export function App({
eligibleModels={eligibleModels}
onChangeMode={handleSetMode}
onRevertToLive={handleRevertToLive}
onProjectToolsUpdated={handleProjectToolsUpdate}
/>}
</>
}

View file

@ -0,0 +1,74 @@
'use client';
import React, { useState } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, Tabs, Tab } from '@heroui/react';
import { Composio } from '../../tools/components/Composio';
import { ComposioWithCallback } from './ComposioWithCallback';
import { CustomServers } from '../../tools/components/CustomServers';
import { WebhookConfig } from '../../tools/components/WebhookConfig';
import type { Key } from 'react';
interface ComposioToolsModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
onToolsUpdated?: () => void;
}
export function ComposioToolsModal({ isOpen, onClose, projectId, onToolsUpdated }: ComposioToolsModalProps) {
const [activeTab, setActiveTab] = useState('composio');
const handleTabChange = (key: Key) => {
setActiveTab(key.toString());
};
return (
<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

@ -0,0 +1,279 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Info, RefreshCw, Search } from 'lucide-react';
import clsx from 'clsx';
import { listToolkits, listTools, updateComposioSelectedTools, getComposioToolsFromWorkflow } from '@/app/actions/composio_actions';
import { getProjectConfig } from '@/app/actions/project_actions';
import { z } from 'zod';
import { ZToolkit, ZListResponse, ZTool } from '@/app/lib/composio/composio';
import { Project } from '@/app/lib/types/project_types';
import { ComposioToolsPanel } from '../../tools/components/ComposioToolsPanel';
import { ToolkitCard } from '../../tools/components/ToolkitCard';
type ToolkitType = z.infer<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

@ -1,8 +1,10 @@
import { z } from "zod";
import { WorkflowPrompt, WorkflowAgent, WorkflowTool } from "../../../lib/types/workflow_types";
import { WorkflowPrompt, WorkflowAgent, WorkflowTool, Workflow } from "../../../lib/types/workflow_types";
import { Project } from "../../../lib/types/project_types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
import { useRef, useEffect, useState } from "react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, Eye } from "lucide-react";
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical, Users, Cog, CheckCircle2, LinkIcon, UnlinkIcon, TestTube, Play, MoreVertical, Eye } from "lucide-react";
import { Tooltip } from "@heroui/react";
import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@ -13,6 +15,9 @@ import { clsx } from "clsx";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { ServerLogo } from '../tools/components/MCPServersCommon';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from "@heroui/react";
import { ComposioToolsModal } from './components/ComposioToolsModal';
import { ToolkitAuthModal } from '../tools/components/ToolkitAuthModal';
import { deleteConnectedAccount, toggleMockToolkitState } from '@/app/actions/composio_actions';
// Reduced gap size to match Cursor's UI
const GAP_SIZE = 4; // 1 unit * 4px (tailwind's default spacing unit)
@ -35,6 +40,7 @@ interface EntityListProps {
tools: z.infer<typeof WorkflowTool>[];
projectTools: z.infer<typeof WorkflowTool>[];
prompts: z.infer<typeof WorkflowPrompt>[];
workflow: z.infer<typeof Workflow>;
selectedEntity: {
type: "agent" | "tool" | "prompt" | "visualise";
name: string;
@ -52,6 +58,8 @@ interface EntityListProps {
onDeleteTool: (name: string) => void;
onDeletePrompt: (name: string) => void;
onShowVisualise: (name: string) => void;
onProjectToolsUpdated?: () => void;
projectConfig?: z.infer<typeof Project>;
}
interface EmptyStateProps {
@ -95,7 +103,7 @@ const ListItemWithMenu = ({
}) => {
return (
<div className={clsx(
"group flex items-center gap-2 px-2 py-1.5 rounded-md",
"group flex items-center gap-2 px-2 py-0.5 rounded-md min-h-[24px]",
{
"bg-indigo-50 dark:bg-indigo-950/30": isSelected,
"hover:bg-zinc-50 dark:hover:bg-zinc-800": !isSelected
@ -116,22 +124,20 @@ const ListItemWithMenu = ({
}}
disabled={disabled}
>
<div className={clsx("shrink-0 flex items-center justify-center w-4 h-4", iconClassName)}>
<div className={clsx("shrink-0 flex items-center justify-center w-3 h-3", iconClassName)}>
{mcpServerName ? (
<ServerLogo
serverName={mcpServerName}
className="h-4 w-4"
fallback={<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />}
className="h-3 w-3"
fallback={<ImportIcon className="w-3 h-3 text-blue-600 dark:text-blue-500" />}
/>
) : icon}
</div>
{name}
<span className="text-xs">{name}</span>
</button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{statusLabel}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
{menuContent}
</div>
{menuContent}
</div>
</div>
);
@ -166,43 +172,56 @@ const ServerCard = ({
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="mb-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md text-sm text-left"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
<div className="flex items-center gap-1">
<ServerLogo
serverName={serverName}
className="h-4 w-4"
fallback={<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />}
/>
<span>{serverName}</span>
</div>
</button>
{isExpanded && (
<div className="ml-6 mt-1 space-y-1">
{tools.map((tool, index) => (
<ListItemWithMenu
key={`tool-${index}`}
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={
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isMcp || tool.isLibrary}
/>
}
<div className="mb-1 group">
<div className="flex items-center gap-2 px-2 py-1 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex-1 flex items-center gap-2 text-sm text-left min-h-[28px]"
>
{/* Chevron - only show when has tools and on hover */}
<div className={`w-4 h-4 flex items-center justify-center transition-opacity ${
tools.length > 0 ? 'group-hover:opacity-100 opacity-60' : 'opacity-0'
}`}>
{tools.length > 0 && (isExpanded ? (
<ChevronDown className="w-3 h-3 text-gray-500" />
) : (
<ChevronRight className="w-3 h-3 text-gray-500" />
))}
</div>
<div className="flex items-center gap-2">
<ServerLogo
serverName={serverName}
className="h-4 w-4"
fallback={<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />}
/>
<span className="text-sm">{serverName}</span>
</div>
</button>
</div>
{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>
))}
</div>
)}
@ -222,6 +241,7 @@ export function EntityList({
tools,
projectTools,
prompts,
workflow,
selectedEntity,
startAgentName,
onSelectAgent,
@ -235,7 +255,9 @@ export function EntityList({
onDeleteAgent,
onDeleteTool,
onDeletePrompt,
onProjectToolsUpdated,
projectId,
projectConfig,
onReorderAgents,
onShowVisualise,
}: EntityListProps & {
@ -243,6 +265,7 @@ export function EntityList({
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void
}) {
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [showComposioToolsModal, setShowComposioToolsModal] = useState(false);
const handleAddAgentWithType = (agentType: 'internal' | 'user_facing') => {
onAddAgent({
@ -496,20 +519,36 @@ export function EntityList({
<Wrench className="w-4 h-4" />
<span>Tools</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
setExpandedPanels(prev => ({ ...prev, tools: true }));
onAddTool({});
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Tool"
>
<PlusIcon className="w-4 h-4" />
</Button>
<div className="flex items-center gap-2">
<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({});
}}
className={`group ${buttonClasses}`}
showHoverContent={true}
hoverContent="Add Tool"
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
</div>
}
>
@ -536,17 +575,37 @@ export function EntityList({
return (
<>
{/* Show composio cards */}
{Object.values(composioTools).map((card) => (
<ComposioCard
key={card.slug}
card={card}
selectedEntity={selectedEntity}
onSelectTool={handleToolSelection}
onDeleteTool={onDeleteTool}
selectedRef={selectedRef}
/>
))}
{/* 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}
card={card}
selectedEntity={selectedEntity}
onSelectTool={handleToolSelection}
onDeleteTool={onDeleteTool}
selectedRef={selectedRef}
projectConfig={projectConfig}
projectId={projectId}
workflow={workflow}
onProjectToolsUpdated={onProjectToolsUpdated}
/>
))}
{/* Show MCP server cards */}
{Object.entries(serverTools).map(([serverName, tools]) => (
@ -682,6 +741,12 @@ export function EntityList({
onClose={() => setShowAgentTypeModal(false)}
onConfirm={handleAddAgentWithType}
/>
<ComposioToolsModal
isOpen={showComposioToolsModal}
onClose={() => setShowComposioToolsModal(false)}
projectId={projectId}
onToolsUpdated={onProjectToolsUpdated}
/>
</div>
);
}
@ -769,6 +834,10 @@ interface ComposioCardProps {
onSelectTool: (name: string) => void;
onDeleteTool: (name: string) => void;
selectedRef: React.RefObject<HTMLButtonElement | null>;
projectConfig?: z.infer<typeof Project>;
projectId: string;
workflow: z.infer<typeof Workflow>;
onProjectToolsUpdated?: () => void;
}
const ComposioCard = ({
@ -777,70 +846,231 @@ const ComposioCard = ({
onSelectTool,
onDeleteTool,
selectedRef,
projectConfig,
projectId,
workflow,
onProjectToolsUpdated,
}: ComposioCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isProcessingAuth, setIsProcessingAuth] = useState(false);
const [isProcessingMock, setIsProcessingMock] = useState(false);
// Check if the toolkit requires authentication
const hasToolkitWithAuth = card.tools.some(tool => tool.composioData && !tool.composioData.noAuth);
// Check if toolkit is connected
const isToolkitConnected = !hasToolkitWithAuth || projectConfig?.composioConnectedAccounts?.[card.slug]?.status === 'ACTIVE';
// Check if toolkit is mocked
const isToolkitMocked = workflow?.composioMockToolkitStates?.[card.slug]?.isMocked || false;
const handleConnect = () => {
setShowAuthModal(true);
};
const handleDisconnect = async () => {
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[card.slug]?.id;
setIsProcessingAuth(true);
try {
if (connectedAccountId) {
await deleteConnectedAccount(projectId, card.slug, connectedAccountId);
onProjectToolsUpdated?.();
}
} catch (err: any) {
console.error('Disconnect failed:', err);
} finally {
setIsProcessingAuth(false);
}
};
const handleAuthComplete = () => {
setShowAuthModal(false);
onProjectToolsUpdated?.();
};
const handleToggleMock = async () => {
setIsProcessingMock(true);
try {
await toggleMockToolkitState(projectId, card.slug, !isToolkitMocked);
onProjectToolsUpdated?.();
} catch (err: any) {
console.error('Mock toggle failed:', err);
} finally {
setIsProcessingMock(false);
}
};
return (
<div className="mb-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md text-sm text-left"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
<div className="flex items-center gap-1">
{card.logo ? (
<div className="relative w-4 h-4">
<PictureImg
src={card.logo}
alt={`${card.name} logo`}
className="w-full h-full object-contain rounded"
/>
<>
<div className="mb-1 group">
<div className="flex items-center gap-2 px-2 py-1 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex-1 flex items-center gap-2 text-sm text-left min-h-[28px]"
>
{/* Chevron - only show on hover or when has tools */}
<div className={`w-4 h-4 flex items-center justify-center transition-opacity ${
card.tools.length > 0 ? 'group-hover:opacity-100 opacity-60' : 'opacity-0'
}`}>
{card.tools.length > 0 && (isExpanded ? (
<ChevronDown className="w-3 h-3 text-gray-500" />
) : (
<ChevronRight className="w-3 h-3 text-gray-500" />
))}
</div>
) : (
<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />
)}
<span>{card.name}</span>
<div className="flex items-center gap-2">
{card.logo ? (
<div className="relative w-4 h-4">
<PictureImg
src={card.logo}
alt={`${card.name} logo`}
className="w-full h-full object-contain rounded"
/>
</div>
) : (
<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />
)}
<span className="text-sm">{card.name}</span>
</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>
{/* Actions Dropdown - only show on hover */}
<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">
<MoreVertical className="h-3 w-3 text-gray-500" />
</button>
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
switch (key) {
case 'mock':
handleToggleMock();
break;
case 'connect':
handleConnect();
break;
case 'disconnect':
handleDisconnect();
break;
}
}}
disabledKeys={[
...(isProcessingMock ? ['mock'] : []),
...(isProcessingAuth ? ['connect', 'disconnect'] : []),
...(hasToolkitWithAuth && !isToolkitMocked && isToolkitConnected ? [] : ['disconnect']),
...(hasToolkitWithAuth && !isToolkitConnected ? [] : ['connect'])
]}
>
<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"
startContent={
isProcessingAuth ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600"></div>
) : (
<UnlinkIcon className="h-3 w-3" />
)
}
>
{isProcessingAuth ? 'Disconnecting...' : 'Disconnect'}
</DropdownItem>
<DropdownItem
key="connect"
startContent={
isProcessingAuth ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600"></div>
) : (
<LinkIcon className="h-3 w-3" />
)
}
>
{isProcessingAuth ? 'Connecting...' : 'Connect'}
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
</button>
{isExpanded && (
<div className="ml-6 mt-1 space-y-1">
<div className="ml-6 mt-0.5 space-y-0.5 border-l border-gray-200 dark:border-gray-700 pl-3">
{card.tools.map((tool, index) => (
<ListItemWithMenu
key={`composio-tool-${index}`}
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)}
disabled={tool.isLibrary}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
icon={
card.logo ? (
<div className="relative w-4 h-4">
<PictureImg
src={card.logo}
alt={`${card.name} logo`}
className="w-full h-full object-contain rounded"
<div key={`composio-tool-${index}`} className="group/tool">
<ListItemWithMenu
name={tool.name}
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
onClick={() => onSelectTool(tool.name)}
disabled={tool.isLibrary}
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
icon={
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600"></div>
}
menuContent={
<div className="opacity-0 group-hover/tool:opacity-100 transition-opacity">
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isComposio}
/>
</div>
) : (
<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />
)
}
menuContent={
<EntityDropdown
name={tool.name}
onDelete={onDeleteTool}
isLocked={tool.isComposio}
/>
}
/>
}
/>
</div>
))}
</div>
)}
</div>
</div>
{/* Auth Modal */}
{hasToolkitWithAuth && (
<ToolkitAuthModal
key={card.slug}
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={card.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
};

View file

@ -0,0 +1,151 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Checkbox } from "@heroui/react";
import { z } from "zod";
import { WorkflowTool } from "@/app/lib/types/workflow_types";
import { RefreshCwIcon } from "lucide-react";
import { fetchMcpTools } from "@/app/actions/mcp_actions";
interface McpImportToolsProps {
projectId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onImport: (tools: z.infer<typeof WorkflowTool>[]) => void;
}
export function McpImportTools({ projectId, isOpen, onOpenChange, onImport }: McpImportToolsProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tools, setTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<number>>(new Set());
const process = useCallback(async () => {
setLoading(true);
setError(null);
setSelectedTools(new Set());
try {
const result = await fetchMcpTools(projectId);
setTools(result);
// Select all tools by default
setSelectedTools(new Set(result.map((_, index) => index)));
} catch (error) {
setError(`Unable to fetch tools: ${error}`);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
console.log("mcp import tools useEffect", isOpen);
if (isOpen) {
process();
}
}, [isOpen, process]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Import from MCP servers</ModalHeader>
<ModalBody>
{loading && <div className="flex gap-2 items-center">
<Spinner size="sm" />
Fetching tools...
</div>}
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
{error}
<Button size="sm" color="danger" onPress={() => process()}>Retry</Button>
</div>}
{!loading && !error && <>
<div className="flex items-center justify-between mb-4">
<div className="text-gray-600">
{tools.length === 0 ? "No tools found" : `Found ${tools.length} tools:`}
</div>
<Button
size="sm"
variant="flat"
onPress={() => {
setTools([]);
process();
}}
startContent={<RefreshCwIcon className="w-4 h-4" />}
>
Refresh
</Button>
</div>
{tools.length > 0 && <div className="flex flex-col w-full mt-4">
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 rounded-t-lg border-b text-sm text-gray-700 font-medium">
<div className="w-8">
<Checkbox
size="sm"
isSelected={selectedTools.size === tools.length}
isIndeterminate={selectedTools.size > 0 && selectedTools.size < tools.length}
onValueChange={(checked) => {
if (checked) {
setSelectedTools(new Set(tools.map((_, i) => i)));
} else {
setSelectedTools(new Set());
}
}}
/>
</div>
<div className="w-36">Server</div>
<div className="flex-1">Tool Name</div>
</div>
<div className="border rounded-b-lg divide-y overflow-y-auto max-h-[300px]">
{tools.map((t, index) => (
<div
key={index}
className="flex items-center gap-4 px-4 py-2 hover:bg-gray-50 transition-colors"
>
<div className="w-8">
<Checkbox
size="sm"
isSelected={selectedTools.has(index)}
onValueChange={(checked) => {
const newSelected = new Set(selectedTools);
if (checked) {
newSelected.add(index);
} else {
newSelected.delete(index);
}
setSelectedTools(newSelected);
}}
/>
</div>
<div className="w-36">
<div className="bg-blue-50 px-2 py-1 rounded text-blue-700 text-sm font-medium border border-blue-100">
{t.mcpServerName}
</div>
</div>
<div className="flex-1 truncate text-gray-700">{t.name}</div>
</div>
))}
</div>
</div>}
{tools.length > 0 && (
<div className="mt-4 text-sm text-gray-600">
{selectedTools.size} of {tools.length} tools selected
</div>
)}
</>}
</ModalBody>
<ModalFooter>
<Button size="sm" variant="flat" onPress={onClose}>
Cancel
</Button>
{tools.length > 0 && <Button size="sm" onPress={() => {
const selectedToolsList = tools.filter((_, index) => selectedTools.has(index));
onImport(selectedToolsList);
onClose();
}}>
Import
</Button>}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}

View file

@ -3,6 +3,7 @@ import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, c
import { MCPServer, Message, WithStringId } from "../../../lib/types/types";
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { Project } from "../../../lib/types/project_types";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "../entities/agent_config";
import { ToolConfig } from "../entities/tool_config";
@ -142,6 +143,9 @@ export type Action = {
type: "show_visualise";
} | {
type: "hide_visualise";
} | {
type: "sync_workflow";
workflow: z.infer<typeof Workflow>;
};
function reducer(state: State, action: Action): State {
@ -207,6 +211,13 @@ function reducer(state: State, action: Action): State {
});
break;
}
case "sync_workflow": {
newState = produce(state, draft => {
draft.present.workflow = action.workflow;
draft.present.lastUpdatedAt = action.workflow.lastUpdatedAt;
});
break;
}
case "reorder_agents": {
const newState = produce(state.present, draft => {
draft.workflow.agents = action.agents;
@ -579,10 +590,12 @@ export function WorkflowEditor({
toolWebhookUrl,
defaultModel,
projectTools,
projectConfig,
eligibleModels,
isLive,
onChangeMode,
onRevertToLive,
onProjectToolsUpdated,
}: {
projectId: string;
dataSources: WithStringId<z.infer<typeof DataSource>>[];
@ -592,10 +605,12 @@ export function WorkflowEditor({
toolWebhookUrl: string;
defaultModel: string;
projectTools: z.infer<typeof WorkflowTool>[];
projectConfig: z.infer<typeof Project>;
eligibleModels: z.infer<typeof ModelsResponse> | "*";
isLive: boolean;
onChangeMode: (mode: 'draft' | 'live') => void;
onRevertToLive: () => void;
onProjectToolsUpdated?: () => void;
}) {
const [state, dispatch] = useReducer(reducer, {
@ -615,6 +630,12 @@ export function WorkflowEditor({
isLive,
}
});
// Sync workflow prop changes with reducer state (e.g., when composio tools are updated)
useEffect(() => {
dispatch({ type: "sync_workflow", workflow });
}, [workflow]);
const [chatMessages, setChatMessages] = useState<z.infer<typeof Message>[]>([]);
const updateChatMessages = useCallback((messages: z.infer<typeof Message>[]) => {
setChatMessages(messages);
@ -980,6 +1001,7 @@ export function WorkflowEditor({
tools={state.present.workflow.tools}
projectTools={projectTools}
prompts={state.present.workflow.prompts}
workflow={state.present.workflow}
selectedEntity={
state.present.selection &&
(state.present.selection.type === "agent" ||
@ -1002,6 +1024,8 @@ export function WorkflowEditor({
onDeletePrompt={handleDeletePrompt}
onShowVisualise={handleShowVisualise}
projectId={projectId}
onProjectToolsUpdated={onProjectToolsUpdated}
projectConfig={projectConfig}
onReorderAgents={handleReorderAgents}
/>
</div>

View file

@ -14,8 +14,7 @@ import {
ChevronRightIcon,
Moon,
Sun,
HelpCircle,
Wrench
HelpCircle
} from "lucide-react";
import { getProjectConfig } from "@/app/actions/project_actions";
import { useTheme } from "@/app/providers/theme-provider";
@ -69,13 +68,6 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
icon: DatabaseIcon,
requiresProject: true
}] : []),
{
href: 'tools',
label: 'Tools',
icon: Wrench,
requiresProject: true,
beta: true
},
{
href: 'config',
label: 'Settings',
@ -162,14 +154,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
`}
/>
{!collapsed && (
<>
<span>{item.label}</span>
{item.beta && (
<span className="ml-1.5 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>
)}
</>
<span>{item.label}</span>
)}
</Link>
</Tooltip>

View file

@ -43,7 +43,7 @@
"ioredis": "^5.6.1",
"jose": "^5.9.6",
"lucide-react": "^0.465.0",
"mermaid": "^11.8.1",
"mermaid": "^11.9.0",
"mongodb": "^6.8.0",
"next": "15.3.4",
"openai": "^4.67.2",
@ -1396,7 +1396,6 @@
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
@ -1407,7 +1406,6 @@
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
@ -1416,20 +1414,17 @@
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"license": "Apache-2.0"
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"license": "Apache-2.0"
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"license": "Apache-2.0"
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="
},
"node_modules/@composio/client": {
"version": "0.1.0-alpha.26",
@ -4582,10 +4577,9 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.1.tgz",
"integrity": "sha512-lCQNpV8R4lgsGcjX5667UiuDLk2micCtjtxR1YKbBXvN5w2v+FeLYoHrTSSrjwXdMcDYvE4ZBPvKT31dfeSmmA==",
"license": "MIT",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz",
"integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==",
"dependencies": {
"langium": "3.3.1"
}
@ -9222,7 +9216,6 @@
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@ -9236,7 +9229,6 @@
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
@ -12928,7 +12920,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
@ -13440,15 +13431,14 @@
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.1.0.tgz",
"integrity": "sha512-Me7BNa1aqrxVinDnFfvCgHh2yHvLbFvILBs899MhuBpbE5VPzpSqv7alaESfkqkgc9JNvUGH4gqwZeOzLnY8Jg==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
@ -13781,14 +13771,13 @@
}
},
"node_modules/mermaid": {
"version": "11.8.1",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.8.1.tgz",
"integrity": "sha512-VSXJLqP1Sqw5sGr273mhvpPRhXwE6NlmMSqBZQw+yZJoAJkOIPPn/uT3teeCBx60Fkt5zEI3FrH2eVT0jXRDzw==",
"license": "MIT",
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.9.0.tgz",
"integrity": "sha512-YdPXn9slEwO0omQfQIsW6vS84weVQftIyyTGAZCwM//MGhPzL1+l6vO6bkf0wnP4tHigH1alZ5Ooy3HXI2gOag==",
"dependencies": {
"@braintree/sanitize-url": "^7.0.4",
"@iconify/utils": "^2.1.33",
"@mermaid-js/parser": "^0.6.1",
"@mermaid-js/parser": "^0.6.2",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"cytoscape-cose-bilkent": "^4.1.0",
@ -13798,10 +13787,10 @@
"dagre-d3-es": "7.0.11",
"dayjs": "^1.11.13",
"dompurify": "^3.2.5",
"katex": "^0.16.9",
"katex": "^0.16.22",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.7",
"marked": "^16.0.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
@ -17797,7 +17786,6 @@
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@ -17806,7 +17794,6 @@
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
"license": "MIT",
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
},
@ -17818,7 +17805,6 @@
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
@ -17827,20 +17813,17 @@
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"license": "MIT"
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",

View file

@ -50,7 +50,7 @@
"ioredis": "^5.6.1",
"jose": "^5.9.6",
"lucide-react": "^0.465.0",
"mermaid": "^11.8.1",
"mermaid": "^11.9.0",
"mongodb": "^6.8.0",
"next": "15.3.4",
"openai": "^4.67.2",