diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index deb9fba9d8..1a45ee6471 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -11,10 +11,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts", "content/docs/execution/index.mdx", - "content/docs/connections/index.mdx", - ".next/dev/types/**/*.ts" + "content/docs/connections/index.mdx" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next"] } diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index cc9ec0272f..baa33e205f 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -20,6 +20,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { checkHybridAuth } from '@/lib/auth/hybrid' +import { generateInternalToken } from '@/lib/auth/internal' import { getBaseUrl } from '@/lib/core/utils/urls' const logger = createLogger('WorkflowMcpServeAPI') @@ -52,6 +53,8 @@ async function getServer(serverId: string) { id: workflowMcpServer.id, name: workflowMcpServer.name, workspaceId: workflowMcpServer.workspaceId, + isPublic: workflowMcpServer.isPublic, + createdBy: workflowMcpServer.createdBy, }) .from(workflowMcpServer) .where(eq(workflowMcpServer.id, serverId)) @@ -90,9 +93,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise }, - apiKey + apiKey, + server.isPublic ? server.createdBy : undefined ) default: @@ -200,7 +206,8 @@ async function handleToolsCall( id: RequestId, serverId: string, params: { name: string; arguments?: Record } | undefined, - apiKey?: string | null + apiKey?: string | null, + publicServerOwnerId?: string ): Promise { try { if (!params?.name) { @@ -243,7 +250,13 @@ async function handleToolsCall( const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute` const headers: Record = { 'Content-Type': 'application/json' } - if (apiKey) headers['X-API-Key'] = apiKey + + if (publicServerOwnerId) { + const internalToken = await generateInternalToken(publicServerOwnerId) + headers.Authorization = `Bearer ${internalToken}` + } else if (apiKey) { + headers['X-API-Key'] = apiKey + } logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 62266b817a..3ce0e00455 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -31,6 +31,7 @@ export const GET = withMcpAuth('read')( createdBy: workflowMcpServer.createdBy, name: workflowMcpServer.name, description: workflowMcpServer.description, + isPublic: workflowMcpServer.isPublic, createdAt: workflowMcpServer.createdAt, updatedAt: workflowMcpServer.updatedAt, }) @@ -98,6 +99,9 @@ export const PATCH = withMcpAuth('write')( if (body.description !== undefined) { updateData.description = body.description?.trim() || null } + if (body.isPublic !== undefined) { + updateData.isPublic = body.isPublic + } const [updatedServer] = await db .update(workflowMcpServer) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 4398bd4e53..d7fd532590 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -26,7 +26,6 @@ export const GET = withMcpAuth('read')( logger.info(`[${requestId}] Getting tool ${toolId} from server ${serverId}`) - // Verify server exists and belongs to workspace const [server] = await db .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) @@ -72,7 +71,6 @@ export const PATCH = withMcpAuth('write')( logger.info(`[${requestId}] Updating tool ${toolId} in server ${serverId}`) - // Verify server exists and belongs to workspace const [server] = await db .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) @@ -139,7 +137,6 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting tool ${toolId} from server ${serverId}`) - // Verify server exists and belongs to workspace const [server] = await db .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 5c39098b0f..b2cef8ee5b 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -6,24 +6,10 @@ import type { NextRequest } from 'next/server' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' +import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpToolsAPI') -/** - * Check if a workflow has a valid start block by loading from database - */ -async function hasValidStartBlock(workflowId: string): Promise { - try { - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) - return hasValidStartBlockInState(normalizedData) - } catch (error) { - logger.warn('Error checking for start block:', error) - return false - } -} - export const dynamic = 'force-dynamic' interface RouteParams { @@ -40,7 +26,6 @@ export const GET = withMcpAuth('read')( logger.info(`[${requestId}] Listing tools for workflow MCP server: ${serverId}`) - // Verify server exists and belongs to workspace const [server] = await db .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) @@ -53,7 +38,6 @@ export const GET = withMcpAuth('read')( return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) } - // Get tools with workflow details const tools = await db .select({ id: workflowMcpTool.id, @@ -107,7 +91,6 @@ export const POST = withMcpAuth('write')( ) } - // Verify server exists and belongs to workspace const [server] = await db .select({ id: workflowMcpServer.id }) .from(workflowMcpServer) @@ -120,7 +103,6 @@ export const POST = withMcpAuth('write')( return createMcpErrorResponse(new Error('Server not found'), 'Server not found', 404) } - // Verify workflow exists and is deployed const [workflowRecord] = await db .select({ id: workflow.id, @@ -137,7 +119,6 @@ export const POST = withMcpAuth('write')( return createMcpErrorResponse(new Error('Workflow not found'), 'Workflow not found', 404) } - // Verify workflow belongs to the same workspace if (workflowRecord.workspaceId !== workspaceId) { return createMcpErrorResponse( new Error('Workflow does not belong to this workspace'), @@ -154,7 +135,6 @@ export const POST = withMcpAuth('write')( ) } - // Verify workflow has a valid start block const hasStartBlock = await hasValidStartBlock(body.workflowId) if (!hasStartBlock) { return createMcpErrorResponse( @@ -164,7 +144,6 @@ export const POST = withMcpAuth('write')( ) } - // Check if tool already exists for this workflow const [existingTool] = await db .select({ id: workflowMcpTool.id }) .from(workflowMcpTool) @@ -190,7 +169,6 @@ export const POST = withMcpAuth('write')( workflowRecord.description || `Execute ${workflowRecord.name} workflow` - // Create the tool const toolId = crypto.randomUUID() const [tool] = await db .insert(workflowMcpTool) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 25258e0b21..e2900f5a88 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -1,10 +1,12 @@ import { db } from '@sim/db' -import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' +import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, inArray, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' +import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server' const logger = createLogger('WorkflowMcpServersAPI') @@ -25,18 +27,18 @@ export const GET = withMcpAuth('read')( createdBy: workflowMcpServer.createdBy, name: workflowMcpServer.name, description: workflowMcpServer.description, + isPublic: workflowMcpServer.isPublic, createdAt: workflowMcpServer.createdAt, updatedAt: workflowMcpServer.updatedAt, toolCount: sql`( - SELECT COUNT(*)::int - FROM "workflow_mcp_tool" + SELECT COUNT(*)::int + FROM "workflow_mcp_tool" WHERE "workflow_mcp_tool"."server_id" = "workflow_mcp_server"."id" )`.as('tool_count'), }) .from(workflowMcpServer) .where(eq(workflowMcpServer.workspaceId, workspaceId)) - // Fetch all tools for these servers const serverIds = servers.map((s) => s.id) const tools = serverIds.length > 0 @@ -49,7 +51,6 @@ export const GET = withMcpAuth('read')( .where(inArray(workflowMcpTool.serverId, serverIds)) : [] - // Group tool names by server const toolNamesByServer: Record = {} for (const tool of tools) { if (!toolNamesByServer[tool.serverId]) { @@ -58,7 +59,6 @@ export const GET = withMcpAuth('read')( toolNamesByServer[tool.serverId].push(tool.toolName) } - // Attach tool names to servers const serversWithToolNames = servers.map((server) => ({ ...server, toolNames: toolNamesByServer[server.id] || [], @@ -90,6 +90,7 @@ export const POST = withMcpAuth('write')( logger.info(`[${requestId}] Creating workflow MCP server:`, { name: body.name, workspaceId, + workflowIds: body.workflowIds, }) if (!body.name) { @@ -110,16 +111,76 @@ export const POST = withMcpAuth('write')( createdBy: userId, name: body.name.trim(), description: body.description?.trim() || null, + isPublic: body.isPublic ?? false, createdAt: new Date(), updatedAt: new Date(), }) .returning() + const workflowIds: string[] = body.workflowIds || [] + const addedTools: Array<{ workflowId: string; toolName: string }> = [] + + if (workflowIds.length > 0) { + const workflows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + isDeployed: workflow.isDeployed, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(inArray(workflow.id, workflowIds)) + + for (const workflowRecord of workflows) { + if (workflowRecord.workspaceId !== workspaceId) { + logger.warn( + `[${requestId}] Skipping workflow ${workflowRecord.id} - does not belong to workspace` + ) + continue + } + + if (!workflowRecord.isDeployed) { + logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - not deployed`) + continue + } + + const hasStartBlock = await hasValidStartBlock(workflowRecord.id) + if (!hasStartBlock) { + logger.warn(`[${requestId}] Skipping workflow ${workflowRecord.id} - no start block`) + continue + } + + const toolName = sanitizeToolName(workflowRecord.name) + const toolDescription = + workflowRecord.description || `Execute ${workflowRecord.name} workflow` + + const toolId = crypto.randomUUID() + await db.insert(workflowMcpTool).values({ + id: toolId, + serverId, + workflowId: workflowRecord.id, + toolName, + toolDescription, + parameterSchema: {}, + createdAt: new Date(), + updatedAt: new Date(), + }) + + addedTools.push({ workflowId: workflowRecord.id, toolName }) + } + + logger.info( + `[${requestId}] Added ${addedTools.length} tools to server ${serverId}:`, + addedTools.map((t) => t.toolName) + ) + } + logger.info( `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` ) - return createMcpSuccessResponse({ server }, 201) + return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { logger.error(`[${requestId}] Error creating workflow MCP server:`, error) return createMcpErrorResponse( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 03be428892..c723242d1f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -48,7 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge' -import { knowledgeKeys } from '@/hooks/queries/knowledge' +import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge' const logger = createLogger('Document') @@ -313,69 +313,22 @@ export function Document({ isFetching: isFetchingChunks, } = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL) - const [searchResults, setSearchResults] = useState([]) - const [isLoadingSearch, setIsLoadingSearch] = useState(false) - const [searchError, setSearchError] = useState(null) - - useEffect(() => { - if (!debouncedSearchQuery.trim()) { - setSearchResults([]) - setSearchError(null) - return - } - - let isMounted = true - - const searchAllChunks = async () => { - try { - setIsLoadingSearch(true) - setSearchError(null) - - const allResults: ChunkData[] = [] - let hasMore = true - let offset = 0 - const limit = 100 - - while (hasMore && isMounted) { - const response = await fetch( - `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?search=${encodeURIComponent(debouncedSearchQuery)}&limit=${limit}&offset=${offset}` - ) - - if (!response.ok) { - throw new Error('Search failed') - } - - const result = await response.json() - - if (result.success && result.data) { - allResults.push(...result.data) - hasMore = result.pagination?.hasMore || false - offset += limit - } else { - hasMore = false - } - } - - if (isMounted) { - setSearchResults(allResults) - } - } catch (err) { - if (isMounted) { - setSearchError(err instanceof Error ? err.message : 'Search failed') - } - } finally { - if (isMounted) { - setIsLoadingSearch(false) - } - } + const { + data: searchResults = [], + isLoading: isLoadingSearch, + error: searchQueryError, + } = useDocumentChunkSearchQuery( + { + knowledgeBaseId, + documentId, + search: debouncedSearchQuery, + }, + { + enabled: Boolean(debouncedSearchQuery.trim()), } + ) - searchAllChunks() - - return () => { - isMounted = false - } - }, [debouncedSearchQuery, knowledgeBaseId, documentId]) + const searchError = searchQueryError instanceof Error ? searchQueryError.message : null const [selectedChunks, setSelectedChunks] = useState>(new Set()) const [selectedChunk, setSelectedChunk] = useState(null) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx index ba45a336a6..fe8b66356b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx @@ -1,9 +1,10 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { X } from 'lucide-react' import { Badge, Combobox, type ComboboxOption } from '@/components/emcn' import { Skeleton } from '@/components/ui' +import { useWorkflows } from '@/hooks/queries/workflows' interface WorkflowSelectorProps { workspaceId: string @@ -25,26 +26,9 @@ export function WorkflowSelector({ onChange, error, }: WorkflowSelectorProps) { - const [workflows, setWorkflows] = useState>([]) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - const load = async () => { - try { - setIsLoading(true) - const response = await fetch(`/api/workflows?workspaceId=${workspaceId}`) - if (response.ok) { - const data = await response.json() - setWorkflows(data.data || []) - } - } catch { - setWorkflows([]) - } finally { - setIsLoading(false) - } - } - load() - }, [workspaceId]) + const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, { + syncRegistry: false, + }) const options: ComboboxOption[] = useMemo(() => { return workflows.map((w) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx index d03470f34f..204e6c5259 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/a2a/a2a.tsx @@ -83,8 +83,7 @@ interface A2aDeployProps { workflowNeedsRedeployment?: boolean onSubmittingChange?: (submitting: boolean) => void onCanSaveChange?: (canSave: boolean) => void - onAgentExistsChange?: (exists: boolean) => void - onPublishedChange?: (published: boolean) => void + /** Callback for when republish status changes - depends on local form state */ onNeedsRepublishChange?: (needsRepublish: boolean) => void onDeployWorkflow?: () => Promise } @@ -99,8 +98,6 @@ export function A2aDeploy({ workflowNeedsRedeployment, onSubmittingChange, onCanSaveChange, - onAgentExistsChange, - onPublishedChange, onNeedsRepublishChange, onDeployWorkflow, }: A2aDeployProps) { @@ -236,14 +233,6 @@ export function A2aDeploy({ } }, [existingAgent, workflowName, workflowDescription]) - useEffect(() => { - onAgentExistsChange?.(!!existingAgent) - }, [existingAgent, onAgentExistsChange]) - - useEffect(() => { - onPublishedChange?.(existingAgent?.isPublished ?? false) - }, [existingAgent?.isPublished, onPublishedChange]) - const hasFormChanges = useMemo(() => { if (!existingAgent) return false const savedSchemes = existingAgent.authentication?.schemes || [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 330cc8ac77..edd59cd819 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -29,9 +29,11 @@ import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo import { type AuthType, type ChatFormData, - useChatDeployment, - useIdentifierValidation, -} from './hooks' + useCreateChat, + useDeleteChat, + useUpdateChat, +} from '@/hooks/queries/chats' +import { useIdentifierValidation } from './hooks' const logger = createLogger('ChatDeploy') @@ -45,7 +47,6 @@ interface ChatDeployProps { existingChat: ExistingChat | null isLoadingChat: boolean onRefetchChat: () => Promise - onChatExistsChange?: (exists: boolean) => void chatSubmitting: boolean setChatSubmitting: (submitting: boolean) => void onValidationChange?: (isValid: boolean) => void @@ -97,7 +98,6 @@ export function ChatDeploy({ existingChat, isLoadingChat, onRefetchChat, - onChatExistsChange, chatSubmitting, setChatSubmitting, onValidationChange, @@ -121,8 +121,11 @@ export function ChatDeploy({ const [formData, setFormData] = useState(initialFormData) const [errors, setErrors] = useState({}) - const { deployChat } = useChatDeployment() const formRef = useRef(null) + + const createChatMutation = useCreateChat() + const updateChatMutation = useUpdateChat() + const deleteChatMutation = useDeleteChat() const [isIdentifierValid, setIsIdentifierValid] = useState(false) const [hasInitializedForm, setHasInitializedForm] = useState(false) @@ -231,15 +234,26 @@ export function ChatDeploy({ return } - const chatUrl = await deployChat( - workflowId, - formData, - deploymentInfo, - existingChat?.id, - imageUrl - ) + let chatUrl: string + + if (existingChat?.id) { + const result = await updateChatMutation.mutateAsync({ + chatId: existingChat.id, + workflowId, + formData, + imageUrl, + }) + chatUrl = result.chatUrl + } else { + const result = await createChatMutation.mutateAsync({ + workflowId, + formData, + apiKey: deploymentInfo?.apiKey, + imageUrl, + }) + chatUrl = result.chatUrl + } - onChatExistsChange?.(true) onDeployed?.() onVersionActivated?.() @@ -266,18 +280,13 @@ export function ChatDeploy({ try { setIsDeleting(true) - const response = await fetch(`/api/chat/manage/${existingChat.id}`, { - method: 'DELETE', + await deleteChatMutation.mutateAsync({ + chatId: existingChat.id, + workflowId, }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to delete chat') - } - setImageUrl(null) setHasInitializedForm(false) - onChatExistsChange?.(false) await onRefetchChat() onDeploymentComplete?.() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts index 34a3694b5f..5e80ad889b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/index.ts @@ -1,2 +1 @@ -export { type AuthType, type ChatFormData, useChatDeployment } from './use-chat-deployment' export { useIdentifierValidation } from './use-identifier-validation' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-chat-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-chat-deployment.ts deleted file mode 100644 index 14ab58d0a3..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/hooks/use-chat-deployment.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useCallback } from 'react' -import { createLogger } from '@sim/logger' -import { z } from 'zod' -import type { OutputConfig } from '@/stores/chat/types' - -const logger = createLogger('ChatDeployment') - -export type AuthType = 'public' | 'password' | 'email' | 'sso' - -export interface ChatFormData { - identifier: string - title: string - description: string - authType: AuthType - password: string - emails: string[] - welcomeMessage: string - selectedOutputBlocks: string[] -} - -const chatSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - identifier: z - .string() - .min(1, 'Identifier is required') - .regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'), - title: z.string().min(1, 'Title is required'), - description: z.string().optional(), - customizations: z.object({ - primaryColor: z.string(), - welcomeMessage: z.string(), - imageUrl: z.string().optional(), - }), - authType: z.enum(['public', 'password', 'email', 'sso']).default('public'), - password: z.string().optional(), - allowedEmails: z.array(z.string()).optional().default([]), - outputConfigs: z - .array( - z.object({ - blockId: z.string(), - path: z.string(), - }) - ) - .optional() - .default([]), -}) - -/** - * Parses output block selections into structured output configs - */ -function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] { - return selectedOutputBlocks - .map((outputId) => { - const firstUnderscoreIndex = outputId.indexOf('_') - if (firstUnderscoreIndex !== -1) { - const blockId = outputId.substring(0, firstUnderscoreIndex) - const path = outputId.substring(firstUnderscoreIndex + 1) - if (blockId && path) { - return { blockId, path } - } - } - return null - }) - .filter((config): config is OutputConfig => config !== null) -} - -/** - * Hook for deploying or updating a chat interface - */ -export function useChatDeployment() { - const deployChat = useCallback( - async ( - workflowId: string, - formData: ChatFormData, - deploymentInfo: { apiKey: string } | null, - existingChatId?: string, - imageUrl?: string | null - ): Promise => { - const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks) - - const payload = { - workflowId, - identifier: formData.identifier.trim(), - title: formData.title.trim(), - description: formData.description.trim(), - customizations: { - primaryColor: 'var(--brand-primary-hover-hex)', - welcomeMessage: formData.welcomeMessage.trim(), - ...(imageUrl && { imageUrl }), - }, - authType: formData.authType, - password: formData.authType === 'password' ? formData.password : undefined, - allowedEmails: - formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], - outputConfigs, - apiKey: deploymentInfo?.apiKey, - deployApiEnabled: !existingChatId, - } - - chatSchema.parse(payload) - - const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat' - const method = existingChatId ? 'PATCH' : 'POST' - - const response = await fetch(endpoint, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - - const result = await response.json() - - if (!response.ok) { - if (result.error === 'Identifier already in use') { - throw new Error('This identifier is already in use') - } - throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`) - } - - if (!result.chatUrl) { - throw new Error('Response missing chatUrl') - } - - logger.info(`Chat ${existingChatId ? 'updated' : 'deployed'} successfully:`, result.chatUrl) - return result.chatUrl - }, - [] - ) - - return { deployChat } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/form.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/form.tsx index 3f0fd9ea50..35adc9f2da 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/form.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/form.tsx @@ -17,11 +17,17 @@ import { Skeleton } from '@/components/ui' import { isDev } from '@/lib/core/config/feature-flags' import { cn } from '@/lib/core/utils/cn' import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls' +import { + type FieldConfig, + useCreateForm, + useDeleteForm, + useFormByWorkflow, + useUpdateForm, +} from '@/hooks/queries/forms' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { EmbedCodeGenerator } from './components/embed-code-generator' import { FormBuilder } from './components/form-builder' -import { useFormDeployment } from './hooks/use-form-deployment' import { useIdentifierValidation } from './hooks/use-identifier-validation' const logger = createLogger('FormDeploy') @@ -34,38 +40,11 @@ interface FormErrors { general?: string } -interface FieldConfig { - name: string - type: string - label: string - description?: string - required?: boolean -} - -export interface ExistingForm { - id: string - identifier: string - title: string - description?: string - customizations: { - primaryColor?: string - thankYouMessage?: string - logoUrl?: string - fieldConfigs?: FieldConfig[] - } - authType: 'public' | 'password' | 'email' - hasPassword?: boolean - allowedEmails?: string[] - showBranding: boolean - isActive: boolean -} - interface FormDeployProps { workflowId: string onDeploymentComplete?: () => void onValidationChange?: (isValid: boolean) => void onSubmittingChange?: (isSubmitting: boolean) => void - onExistingFormChange?: (exists: boolean) => void formSubmitting?: boolean setFormSubmitting?: (submitting: boolean) => void onDeployed?: () => Promise @@ -81,7 +60,6 @@ export function FormDeploy({ onDeploymentComplete, onValidationChange, onSubmittingChange, - onExistingFormChange, formSubmitting, setFormSubmitting, onDeployed, @@ -95,8 +73,6 @@ export function FormDeploy({ const [authType, setAuthType] = useState<'public' | 'password' | 'email'>('public') const [password, setPassword] = useState('') const [emailItems, setEmailItems] = useState([]) - const [existingForm, setExistingForm] = useState(null) - const [isLoading, setIsLoading] = useState(true) const [formUrl, setFormUrl] = useState('') const [inputFields, setInputFields] = useState<{ name: string; type: string }[]>([]) const [showPasswordField, setShowPasswordField] = useState(false) @@ -104,7 +80,12 @@ export function FormDeploy({ const [errors, setErrors] = useState({}) const [isIdentifierValid, setIsIdentifierValid] = useState(false) - const { createForm, updateForm, deleteForm, isSubmitting } = useFormDeployment() + const { data: existingForm, isLoading } = useFormByWorkflow(workflowId) + const createFormMutation = useCreateForm() + const updateFormMutation = useUpdateForm() + const deleteFormMutation = useDeleteForm() + + const isSubmitting = createFormMutation.isPending || updateFormMutation.isPending const { isChecking: isCheckingIdentifier, @@ -124,75 +105,45 @@ export function FormDeploy({ setErrors((prev) => ({ ...prev, [field]: undefined })) } - // Fetch existing form deployment + // Populate form fields when existing form data is loaded useEffect(() => { - async function fetchExistingForm() { - if (!workflowId) return + if (existingForm) { + setIdentifier(existingForm.identifier) + setTitle(existingForm.title) + setDescription(existingForm.description || '') + setThankYouMessage( + existingForm.customizations?.thankYouMessage || + 'Your response has been submitted successfully.' + ) + setAuthType(existingForm.authType) + setEmailItems( + (existingForm.allowedEmails || []).map((email) => ({ value: email, isValid: true })) + ) + if (existingForm.customizations?.fieldConfigs) { + setFieldConfigs(existingForm.customizations.fieldConfigs) + } + const baseUrl = getBaseUrl() try { - setIsLoading(true) - const response = await fetch(`/api/workflows/${workflowId}/form/status`) - - if (response.ok) { - const data = await response.json() - if (data.isDeployed && data.form) { - const detailResponse = await fetch(`/api/form/manage/${data.form.id}`) - if (detailResponse.ok) { - const formDetail = await detailResponse.json() - const form = formDetail.form as ExistingForm - setExistingForm(form) - onExistingFormChange?.(true) - - setIdentifier(form.identifier) - setTitle(form.title) - setDescription(form.description || '') - setThankYouMessage( - form.customizations?.thankYouMessage || - 'Your response has been submitted successfully.' - ) - setAuthType(form.authType) - setEmailItems( - (form.allowedEmails || []).map((email) => ({ value: email, isValid: true })) - ) - if (form.customizations?.fieldConfigs) { - setFieldConfigs(form.customizations.fieldConfigs) - } - - const baseUrl = getBaseUrl() - try { - const url = new URL(baseUrl) - let host = url.host - if (host.startsWith('www.')) host = host.substring(4) - setFormUrl(`${url.protocol}//${host}/form/${form.identifier}`) - } catch { - setFormUrl( - isDev - ? `http://localhost:3000/form/${form.identifier}` - : `https://sim.ai/form/${form.identifier}` - ) - } - } - } else { - setExistingForm(null) - onExistingFormChange?.(false) - - const workflowName = - useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]] - ?.name || 'Form' - setTitle(`${workflowName} Form`) - } - } - } catch (err) { - logger.error('Error fetching form deployment:', err) - } finally { - setIsLoading(false) + const url = new URL(baseUrl) + let host = url.host + if (host.startsWith('www.')) host = host.substring(4) + setFormUrl(`${url.protocol}//${host}/form/${existingForm.identifier}`) + } catch { + setFormUrl( + isDev + ? `http://localhost:3000/form/${existingForm.identifier}` + : `https://sim.ai/form/${existingForm.identifier}` + ) } + } else if (!isLoading) { + const workflowName = + useWorkflowStore.getState().blocks[Object.keys(useWorkflowStore.getState().blocks)[0]] + ?.name || 'Form' + setTitle(`${workflowName} Form`) } + }, [existingForm, isLoading]) - fetchExistingForm() - }, [workflowId, onExistingFormChange]) - - // Get input fields from start block and initialize field configs useEffect(() => { const blocks = Object.values(useWorkflowStore.getState().blocks) const startBlock = blocks.find((b) => b.type === 'starter' || b.type === 'start_trigger') @@ -202,7 +153,6 @@ export function FormDeploy({ if (inputFormat && Array.isArray(inputFormat)) { setInputFields(inputFormat) - // Initialize field configs if not already set if (fieldConfigs.length === 0) { setFieldConfigs( inputFormat.map((f: { name: string; type?: string }) => ({ @@ -222,7 +172,6 @@ export function FormDeploy({ const allowedEmails = emailItems.filter((item) => item.isValid).map((item) => item.value) - // Validate form useEffect(() => { const isValid = inputFields.length > 0 && @@ -253,7 +202,6 @@ export function FormDeploy({ e.preventDefault() setErrors({}) - // Validate before submit if (!isIdentifierValid && identifier !== existingForm?.identifier) { setError('identifier', 'Please wait for identifier validation to complete') return @@ -281,17 +229,21 @@ export function FormDeploy({ try { if (existingForm) { - await updateForm(existingForm.id, { - identifier, - title, - description, - customizations, - authType, - password: password || undefined, - allowedEmails, + await updateFormMutation.mutateAsync({ + formId: existingForm.id, + workflowId, + data: { + identifier, + title, + description, + customizations, + authType, + password: password || undefined, + allowedEmails, + }, }) } else { - const result = await createForm({ + const result = await createFormMutation.mutateAsync({ workflowId, identifier, title, @@ -304,7 +256,6 @@ export function FormDeploy({ if (result?.formUrl) { setFormUrl(result.formUrl) - // Open the form in a new window after successful deployment window.open(result.formUrl, '_blank', 'noopener,noreferrer') } } @@ -318,7 +269,6 @@ export function FormDeploy({ const message = err instanceof Error ? err.message : 'An error occurred' logger.error('Error deploying form:', err) - // Parse error message and show inline if (message.toLowerCase().includes('identifier')) { setError('identifier', message) } else if (message.toLowerCase().includes('password')) { @@ -342,8 +292,8 @@ export function FormDeploy({ password, allowedEmails, isIdentifierValid, - createForm, - updateForm, + createFormMutation, + updateFormMutation, onDeployed, onDeploymentComplete, ] @@ -353,9 +303,10 @@ export function FormDeploy({ if (!existingForm) return try { - await deleteForm(existingForm.id) - setExistingForm(null) - onExistingFormChange?.(false) + await deleteFormMutation.mutateAsync({ + formId: existingForm.id, + workflowId, + }) setIdentifier('') setTitle('') setDescription('') @@ -363,7 +314,7 @@ export function FormDeploy({ } catch (err) { logger.error('Error deleting form:', err) } - }, [existingForm, deleteForm, onExistingFormChange]) + }, [existingForm, deleteFormMutation, workflowId]) if (isLoading) { return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-form-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-form-deployment.ts deleted file mode 100644 index e9189f89dc..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/form/hooks/use-form-deployment.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { useCallback, useState } from 'react' -import { createLogger } from '@sim/logger' - -const logger = createLogger('useFormDeployment') - -interface CreateFormParams { - workflowId: string - identifier: string - title: string - description?: string - customizations?: { - primaryColor?: string - welcomeMessage?: string - thankYouTitle?: string - thankYouMessage?: string - logoUrl?: string - } - authType?: 'public' | 'password' | 'email' - password?: string - allowedEmails?: string[] - showBranding?: boolean -} - -interface UpdateFormParams { - identifier?: string - title?: string - description?: string - customizations?: { - primaryColor?: string - welcomeMessage?: string - thankYouTitle?: string - thankYouMessage?: string - logoUrl?: string - } - authType?: 'public' | 'password' | 'email' - password?: string - allowedEmails?: string[] - showBranding?: boolean - isActive?: boolean -} - -interface CreateFormResult { - id: string - formUrl: string -} - -export function useFormDeployment() { - const [isSubmitting, setIsSubmitting] = useState(false) - const [error, setError] = useState(null) - - const createForm = useCallback( - async (params: CreateFormParams): Promise => { - setIsSubmitting(true) - setError(null) - - try { - const response = await fetch('/api/form', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create form') - } - - logger.info('Form created successfully:', { id: data.id }) - return { - id: data.id, - formUrl: data.formUrl, - } - } catch (err: any) { - const errorMessage = err.message || 'Failed to create form' - setError(errorMessage) - logger.error('Error creating form:', err) - throw err - } finally { - setIsSubmitting(false) - } - }, - [] - ) - - const updateForm = useCallback(async (formId: string, params: UpdateFormParams) => { - setIsSubmitting(true) - setError(null) - - try { - const response = await fetch(`/api/form/manage/${formId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to update form') - } - - logger.info('Form updated successfully:', { id: formId }) - } catch (err: any) { - const errorMessage = err.message || 'Failed to update form' - setError(errorMessage) - logger.error('Error updating form:', err) - throw err - } finally { - setIsSubmitting(false) - } - }, []) - - const deleteForm = useCallback(async (formId: string) => { - setIsSubmitting(true) - setError(null) - - try { - const response = await fetch(`/api/form/manage/${formId}`, { - method: 'DELETE', - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete form') - } - - logger.info('Form deleted successfully:', { id: formId }) - } catch (err: any) { - const errorMessage = err.message || 'Failed to delete form' - setError(errorMessage) - logger.error('Error deleting form:', err) - throw err - } finally { - setIsSubmitting(false) - } - }, []) - - return { - createForm, - updateForm, - deleteForm, - isSubmitting, - error, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx index 4d562738ed..1620b68885 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -43,7 +43,6 @@ interface McpDeployProps { onAddedToServer?: () => void onSubmittingChange?: (submitting: boolean) => void onCanSaveChange?: (canSave: boolean) => void - onHasServersChange?: (hasServers: boolean) => void } /** @@ -92,7 +91,6 @@ export function McpDeploy({ onAddedToServer, onSubmittingChange, onCanSaveChange, - onHasServersChange, }: McpDeployProps) { const params = useParams() const workspaceId = params.workspaceId as string @@ -257,10 +255,6 @@ export function McpDeploy({ onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim()) }, [hasChanges, hasDeployedTools, toolName, onCanSaveChange]) - useEffect(() => { - onHasServersChange?.(servers.length > 0) - }, [servers.length, onHasServersChange]) - /** * Save tool configuration to all deployed servers */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx index 5d541a31fc..cd8d8755a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx @@ -20,6 +20,7 @@ import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' import { captureAndUploadOGImage, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from '@/lib/og' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview' +import { useCreatorProfiles } from '@/hooks/queries/creator-profile' import { useCreateTemplate, useDeleteTemplate, @@ -47,26 +48,11 @@ const initialFormData: TemplateFormData = { tags: [], } -interface CreatorOption { - id: string - name: string - referenceType: 'user' | 'organization' - referenceId: string -} - -interface TemplateStatus { - status: 'pending' | 'approved' | 'rejected' | null - views?: number - stars?: number -} - interface TemplateDeployProps { workflowId: string onDeploymentComplete?: () => void onValidationChange?: (isValid: boolean) => void onSubmittingChange?: (isSubmitting: boolean) => void - onExistingTemplateChange?: (exists: boolean) => void - onTemplateStatusChange?: (status: TemplateStatus | null) => void } export function TemplateDeploy({ @@ -74,13 +60,9 @@ export function TemplateDeploy({ onDeploymentComplete, onValidationChange, onSubmittingChange, - onExistingTemplateChange, - onTemplateStatusChange, }: TemplateDeployProps) { const { data: session } = useSession() const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [creatorOptions, setCreatorOptions] = useState([]) - const [loadingCreators, setLoadingCreators] = useState(false) const [isCapturing, setIsCapturing] = useState(false) const previewContainerRef = useRef(null) const ogCaptureRef = useRef(null) @@ -88,6 +70,7 @@ export function TemplateDeploy({ const [formData, setFormData] = useState(initialFormData) const { data: existingTemplate, isLoading: isLoadingTemplate } = useTemplateByWorkflow(workflowId) + const { data: creatorProfiles = [], isLoading: loadingCreators } = useCreatorProfiles() const createMutation = useCreateTemplate() const updateMutation = useUpdateTemplate() const deleteMutation = useDeleteTemplate() @@ -112,63 +95,15 @@ export function TemplateDeploy({ }, [isSubmitting, onSubmittingChange]) useEffect(() => { - onExistingTemplateChange?.(!!existingTemplate) - }, [existingTemplate, onExistingTemplateChange]) - - useEffect(() => { - if (existingTemplate) { - onTemplateStatusChange?.({ - status: existingTemplate.status as 'pending' | 'approved' | 'rejected', - views: existingTemplate.views, - stars: existingTemplate.stars, - }) - } else { - onTemplateStatusChange?.(null) + if (creatorProfiles.length === 1 && !formData.creatorId) { + updateField('creatorId', creatorProfiles[0].id) + logger.info('Auto-selected single creator profile:', creatorProfiles[0].name) } - }, [existingTemplate, onTemplateStatusChange]) - - const fetchCreatorOptions = async () => { - if (!session?.user?.id) return - - setLoadingCreators(true) - try { - const response = await fetch('/api/creators') - if (response.ok) { - const data = await response.json() - const profiles = (data.profiles || []).map((profile: any) => ({ - id: profile.id, - name: profile.name, - referenceType: profile.referenceType, - referenceId: profile.referenceId, - })) - setCreatorOptions(profiles) - return profiles - } - } catch (error) { - logger.error('Error fetching creator profiles:', error) - } finally { - setLoadingCreators(false) - } - return [] - } - - useEffect(() => { - fetchCreatorOptions() - }, [session?.user?.id]) + }, [creatorProfiles, formData.creatorId]) useEffect(() => { - if (creatorOptions.length === 1 && !formData.creatorId) { - updateField('creatorId', creatorOptions[0].id) - logger.info('Auto-selected single creator profile:', creatorOptions[0].name) - } - }, [creatorOptions, formData.creatorId]) - - useEffect(() => { - const handleCreatorProfileSaved = async () => { - logger.info('Creator profile saved, refreshing profiles...') - - await fetchCreatorOptions() - + const handleCreatorProfileSaved = () => { + logger.info('Creator profile saved, reopening deploy modal...') window.dispatchEvent(new CustomEvent('close-settings')) setTimeout(() => { window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab: 'template' } })) @@ -357,7 +292,7 @@ export function TemplateDeploy({ - {creatorOptions.length === 0 && !loadingCreators ? ( + {creatorProfiles.length === 0 && !loadingCreators ? (

A creator profile is required to publish templates. @@ -385,9 +320,9 @@ export function TemplateDeploy({

) : ( ({ - label: option.name, - value: option.id, + options={creatorProfiles.map((profile) => ({ + label: profile.name, + value: profile.id, }))} value={formData.creatorId} selectedValue={formData.creatorId} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 4e7f20b9c7..3db8509ec3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -1,7 +1,8 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { Badge, Button, @@ -17,11 +18,22 @@ import { } from '@/components/emcn' import { getBaseUrl } from '@/lib/core/utils/urls' import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils' -import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components' import { startsWithUuid } from '@/executor/constants' +import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents' import { useApiKeys } from '@/hooks/queries/api-keys' +import { + deploymentKeys, + useActivateDeploymentVersion, + useChatDeploymentInfo, + useDeploymentInfo, + useDeploymentVersions, + useDeployWorkflow, + useUndeployWorkflow, +} from '@/hooks/queries/deployments' +import { useTemplateByWorkflow } from '@/hooks/queries/templates' +import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsModalStore } from '@/stores/modals/settings/store' @@ -48,7 +60,7 @@ interface DeployModalProps { refetchDeployedState: () => Promise } -interface WorkflowDeploymentInfo { +interface WorkflowDeploymentInfoUI { isDeployed: boolean deployedAt?: string apiKey: string @@ -69,16 +81,12 @@ export function DeployModal({ isLoadingDeployedState, refetchDeployedState, }: DeployModalProps) { + const queryClient = useQueryClient() const openSettingsModal = useSettingsModalStore((state) => state.openModal) const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(workflowId) ) const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) - const [isSubmitting, setIsSubmitting] = useState(false) - const [isUndeploying, setIsUndeploying] = useState(false) - const [deploymentInfo, setDeploymentInfo] = useState(null) - const [isLoading, setIsLoading] = useState(false) const workflowMetadata = useWorkflowRegistry((state) => workflowId ? state.workflows[workflowId] : undefined ) @@ -86,33 +94,18 @@ export function DeployModal({ const [activeTab, setActiveTab] = useState('general') const [chatSubmitting, setChatSubmitting] = useState(false) const [apiDeployError, setApiDeployError] = useState(null) - const [chatExists, setChatExists] = useState(false) const [isChatFormValid, setIsChatFormValid] = useState(false) const [selectedStreamingOutputs, setSelectedStreamingOutputs] = useState([]) - const [versions, setVersions] = useState([]) - const [versionsLoading, setVersionsLoading] = useState(false) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) const [templateFormValid, setTemplateFormValid] = useState(false) const [templateSubmitting, setTemplateSubmitting] = useState(false) const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false) const [mcpToolCanSave, setMcpToolCanSave] = useState(false) - const [hasMcpServers, setHasMcpServers] = useState(false) const [a2aSubmitting, setA2aSubmitting] = useState(false) const [a2aCanSave, setA2aCanSave] = useState(false) - const [hasA2aAgent, setHasA2aAgent] = useState(false) - const [isA2aPublished, setIsA2aPublished] = useState(false) const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false) const [showA2aDeleteConfirm, setShowA2aDeleteConfirm] = useState(false) - const [hasExistingTemplate, setHasExistingTemplate] = useState(false) - const [templateStatus, setTemplateStatus] = useState<{ - status: 'pending' | 'approved' | 'rejected' | null - views?: number - stars?: number - } | null>(null) - - const [existingChat, setExistingChat] = useState(null) - const [isLoadingChat, setIsLoadingChat] = useState(false) const [chatSuccess, setChatSuccess] = useState(false) @@ -133,193 +126,107 @@ export function DeployModal({ const createButtonDisabled = isApiKeysLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys) - const getApiKeyLabel = (value?: string | null) => { - if (value && value.trim().length > 0) { - return value - } - return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys' - } - - const getApiHeaderPlaceholder = () => - workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY' - - const getInputFormatExample = (includeStreaming = false) => { - return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs) - } - - const fetchChatDeploymentInfo = useCallback(async () => { - if (!workflowId) return - - try { - setIsLoadingChat(true) - const response = await fetch(`/api/workflows/${workflowId}/chat/status`) - - if (response.ok) { - const data = await response.json() - if (data.isDeployed && data.deployment) { - const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`) - if (detailResponse.ok) { - const chatDetail = await detailResponse.json() - setExistingChat(chatDetail) - setChatExists(true) - } else { - setExistingChat(null) - setChatExists(false) - } - } else { - setExistingChat(null) - setChatExists(false) - } - } else { - setExistingChat(null) - setChatExists(false) - } - } catch (error) { - logger.error('Error fetching chat deployment info:', { error }) - setExistingChat(null) - setChatExists(false) - } finally { - setIsLoadingChat(false) - } - }, [workflowId]) - - useEffect(() => { - if (open && workflowId) { - setActiveTab('general') - setApiDeployError(null) - fetchChatDeploymentInfo() - } - }, [open, workflowId, fetchChatDeploymentInfo]) - - useEffect(() => { - async function fetchDeploymentInfo() { - if (!open || !workflowId || !isDeployed) { - setDeploymentInfo(null) - setIsLoading(false) - return - } - - if (deploymentInfo?.isDeployed && !needsRedeployment) { - setIsLoading(false) - return - } - - try { - setIsLoading(true) - - const response = await fetch(`/api/workflows/${workflowId}/deploy`) - - if (!response.ok) { - throw new Error('Failed to fetch deployment information') - } - - const data = await response.json() - const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) - const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY' - - setDeploymentInfo({ - isDeployed: data.isDeployed, - deployedAt: data.deployedAt, - apiKey: data.apiKey || placeholderKey, - endpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, - needsRedeployment, - }) - } catch (error) { - logger.error('Error fetching deployment info:', { error }) - } finally { - setIsLoading(false) + const { + data: deploymentInfoData, + isLoading: isLoadingDeploymentInfo, + refetch: refetchDeploymentInfo, + } = useDeploymentInfo(workflowId, { enabled: open && isDeployed }) + + const { + data: versionsData, + isLoading: versionsLoading, + refetch: refetchVersions, + } = useDeploymentVersions(workflowId, { enabled: open }) + + const { + isLoading: isLoadingChat, + chatExists, + existingChat, + refetch: refetchChatInfo, + } = useChatDeploymentInfo(workflowId, { enabled: open }) + + const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '') + const hasMcpServers = mcpServers.length > 0 + + const { data: existingA2aAgent } = useA2AAgentByWorkflow( + workflowWorkspaceId || '', + workflowId || '' + ) + const hasA2aAgent = !!existingA2aAgent + const isA2aPublished = existingA2aAgent?.isPublished ?? false + + const { data: existingTemplate } = useTemplateByWorkflow(workflowId || '', { + enabled: !!workflowId, + }) + const hasExistingTemplate = !!existingTemplate + const templateStatus = existingTemplate + ? { + status: existingTemplate.status as 'pending' | 'approved' | 'rejected' | null, + views: existingTemplate.views, + stars: existingTemplate.stars, } - } + : null - fetchDeploymentInfo() - }, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed]) + const deployMutation = useDeployWorkflow() + const undeployMutation = useUndeployWorkflow() + const activateVersionMutation = useActivateDeploymentVersion() - const onDeploy = async () => { - setApiDeployError(null) + const versions = versionsData?.versions ?? [] - try { - setIsSubmitting(true) - - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to deploy workflow') + const getApiKeyLabel = useCallback( + (value?: string | null) => { + if (value && value.trim().length > 0) { + return value } + return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys' + }, + [workflowWorkspaceId] + ) - const responseData = await response.json() - - const isDeployedStatus = responseData.isDeployed ?? false - const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined - const apiKeyLabel = getApiKeyLabel(responseData.apiKey) - - setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel) - - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } + const getApiHeaderPlaceholder = useCallback( + () => (workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'), + [workflowWorkspaceId] + ) - await refetchDeployedState() - await fetchVersions() - - const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) - if (deploymentInfoResponse.ok) { - const deploymentData = await deploymentInfoResponse.json() - const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) - const placeholderKey = getApiHeaderPlaceholder() - - setDeploymentInfo({ - isDeployed: deploymentData.isDeployed, - deployedAt: deploymentData.deployedAt, - apiKey: getApiKeyLabel(deploymentData.apiKey), - endpoint: apiEndpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, - needsRedeployment: false, - }) - } + const getInputFormatExample = useCallback( + (includeStreaming = false) => { + return getInputFormatExampleUtil(includeStreaming, selectedStreamingOutputs) + }, + [selectedStreamingOutputs] + ) - setApiDeployError(null) - } catch (error: unknown) { - logger.error('Error deploying workflow:', { error }) - const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' - setApiDeployError(errorMessage) - } finally { - setIsSubmitting(false) + const deploymentInfo: WorkflowDeploymentInfoUI | null = useMemo(() => { + if (!deploymentInfoData?.isDeployed || !workflowId) { + return null } - } - const fetchVersions = useCallback(async () => { - if (!workflowId) return - try { - const res = await fetch(`/api/workflows/${workflowId}/deployments`) - if (res.ok) { - const data = await res.json() - setVersions(Array.isArray(data.versions) ? data.versions : []) - } else { - setVersions([]) - } - } catch { - setVersions([]) + const endpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` + const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) + const placeholderKey = getApiHeaderPlaceholder() + + return { + isDeployed: deploymentInfoData.isDeployed, + deployedAt: deploymentInfoData.deployedAt ?? undefined, + apiKey: getApiKeyLabel(deploymentInfoData.apiKey), + endpoint, + exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, + needsRedeployment: deploymentInfoData.needsRedeployment, } - }, [workflowId]) + }, [ + deploymentInfoData, + workflowId, + selectedStreamingOutputs, + getInputFormatExample, + getApiHeaderPlaceholder, + getApiKeyLabel, + ]) useEffect(() => { if (open && workflowId) { - setVersionsLoading(true) - fetchVersions().finally(() => setVersionsLoading(false)) + setActiveTab('general') + setApiDeployError(null) } - }, [open, workflowId, fetchVersions]) + }, [open, workflowId]) useEffect(() => { if (!open || selectedStreamingOutputs.length === 0) return @@ -369,181 +276,88 @@ export function DeployModal({ } }, [onOpenChange]) + const onDeploy = useCallback(async () => { + if (!workflowId) return + + setApiDeployError(null) + + try { + await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) + await refetchDeployedState() + } catch (error: unknown) { + logger.error('Error deploying workflow:', { error }) + const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' + setApiDeployError(errorMessage) + } + }, [workflowId, deployMutation, refetchDeployedState]) + const handlePromoteToLive = useCallback( async (version: number) => { if (!workflowId) return - const previousVersions = [...versions] - setVersions((prev) => - prev.map((v) => ({ - ...v, - isActive: v.version === version, - })) - ) - try { - const response = await fetch( - `/api/workflows/${workflowId}/deployments/${version}/activate`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to promote version') - } - - const responseData = await response.json() - - const deployedAtTime = responseData.deployedAt - ? new Date(responseData.deployedAt) - : undefined - const apiKeyLabel = getApiKeyLabel(responseData.apiKey) - - setDeploymentStatus(workflowId, true, deployedAtTime, apiKeyLabel) - - refetchDeployedState() - fetchVersions() - - const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) - if (deploymentInfoResponse.ok) { - const deploymentData = await deploymentInfoResponse.json() - const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) - const placeholderKey = getApiHeaderPlaceholder() - - setDeploymentInfo({ - isDeployed: deploymentData.isDeployed, - deployedAt: deploymentData.deployedAt, - apiKey: getApiKeyLabel(deploymentData.apiKey), - endpoint: apiEndpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, - needsRedeployment: false, - }) - } + await activateVersionMutation.mutateAsync({ workflowId, version }) + await refetchDeployedState() } catch (error) { - setVersions(previousVersions) + logger.error('Error promoting version:', { error }) throw error } }, - [workflowId, versions, refetchDeployedState, fetchVersions, selectedStreamingOutputs] + [workflowId, activateVersionMutation, refetchDeployedState] ) - const handleUndeploy = async () => { - try { - setIsUndeploying(true) - - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'DELETE', - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to undeploy workflow') - } + const handleUndeploy = useCallback(async () => { + if (!workflowId) return - setDeploymentStatus(workflowId, false) - setChatExists(false) + try { + await undeployMutation.mutateAsync({ workflowId }) setShowUndeployConfirm(false) onOpenChange(false) } catch (error: unknown) { logger.error('Error undeploying workflow:', { error }) - } finally { - setIsUndeploying(false) } - } - - const handleRedeploy = async () => { - try { - setIsSubmitting(true) - - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to redeploy workflow') - } - - const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json() + }, [workflowId, undeployMutation, onOpenChange]) - setDeploymentStatus( - workflowId, - newDeployStatus, - deployedAt ? new Date(deployedAt) : undefined, - getApiKeyLabel(apiKey) - ) + const handleRedeploy = useCallback(async () => { + if (!workflowId) return - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } + setApiDeployError(null) + try { + await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) await refetchDeployedState() - await fetchVersions() - - setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev)) } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow' setApiDeployError(errorMessage) - } finally { - setIsSubmitting(false) } - } + }, [workflowId, deployMutation, refetchDeployedState]) - const handleCloseModal = () => { - setIsSubmitting(false) + const handleCloseModal = useCallback(() => { setChatSubmitting(false) setApiDeployError(null) onOpenChange(false) - } - - const handleChatDeployed = async () => { - await handlePostDeploymentUpdate() - setChatSuccess(true) - setTimeout(() => setChatSuccess(false), 2000) - } + }, [onOpenChange]) - const handlePostDeploymentUpdate = async () => { + const handleChatDeployed = useCallback(async () => { if (!workflowId) return - setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel()) - - const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) - if (deploymentInfoResponse.ok) { - const deploymentData = await deploymentInfoResponse.json() - const apiEndpoint = `${getBaseUrl()}/api/workflows/${workflowId}/execute` - const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) - - const placeholderKey = getApiHeaderPlaceholder() - - setDeploymentInfo({ - isDeployed: deploymentData.isDeployed, - deployedAt: deploymentData.deployedAt, - apiKey: getApiKeyLabel(deploymentData.apiKey), - endpoint: apiEndpoint, - exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, - needsRedeployment: false, - }) - } + queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }) + queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }) + queryClient.invalidateQueries({ queryKey: deploymentKeys.chatStatus(workflowId) }) await refetchDeployedState() - await fetchVersions() useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - const handleChatFormSubmit = () => { + setChatSuccess(true) + setTimeout(() => setChatSuccess(false), 2000) + }, [workflowId, queryClient, refetchDeployedState]) + + const handleRefetchChat = useCallback(async () => { + await refetchChatInfo() + }, [refetchChatInfo]) + + const handleChatFormSubmit = useCallback(() => { const form = document.getElementById('chat-deploy-form') as HTMLFormElement if (form) { const updateTrigger = form.querySelector('[data-update-trigger]') as HTMLButtonElement @@ -553,9 +367,9 @@ export function DeployModal({ form.requestSubmit() } } - } + }, []) - const handleChatDelete = () => { + const handleChatDelete = useCallback(() => { const form = document.getElementById('chat-deploy-form') as HTMLFormElement if (form) { const deleteButton = form.querySelector('[data-delete-trigger]') as HTMLButtonElement @@ -563,7 +377,7 @@ export function DeployModal({ deleteButton.click() } } - } + }, []) const handleTemplateFormSubmit = useCallback(() => { const form = document.getElementById('template-deploy-form') as HTMLFormElement @@ -623,6 +437,13 @@ export function DeployModal({ deleteTrigger?.click() }, []) + const handleFetchVersions = useCallback(async () => { + await refetchVersions() + }, [refetchVersions]) + + const isSubmitting = deployMutation.isPending + const isUndeploying = undeployMutation.isPending + return ( <> @@ -670,7 +491,7 @@ export function DeployModal({ versionsLoading={versionsLoading} onPromoteToLive={handlePromoteToLive} onLoadDeploymentComplete={handleCloseModal} - fetchVersions={fetchVersions} + fetchVersions={handleFetchVersions} /> @@ -678,7 +499,7 @@ export function DeployModal({ )} @@ -741,7 +559,6 @@ export function DeployModal({ isDeployed={isDeployed} onSubmittingChange={setMcpToolSubmitting} onCanSaveChange={setMcpToolCanSave} - onHasServersChange={setHasMcpServers} /> )} @@ -756,8 +573,6 @@ export function DeployModal({ workflowNeedsRedeployment={needsRedeployment} onSubmittingChange={setA2aSubmitting} onCanSaveChange={setA2aCanSave} - onAgentExistsChange={setHasA2aAgent} - onPublishedChange={setIsA2aPublished} onNeedsRepublishChange={setA2aNeedsRepublish} onDeployWorkflow={onDeploy} /> @@ -843,7 +658,7 @@ export function DeployModal({ onClick={handleMcpToolFormSubmit} disabled={mcpToolSubmitting || !mcpToolCanSave} > - {mcpToolSubmitting ? 'Saving...' : 'Save Tool Schema'} + {mcpToolSubmitting ? 'Saving...' : 'Save Tool'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx index 9e9f61887d..2b3c2a1f68 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/components/form-field/form-field.tsx @@ -3,13 +3,17 @@ import { Label } from '@/components/emcn' interface FormFieldProps { label: string children: React.ReactNode + optional?: boolean } -export function FormField({ label, children }: FormFieldProps) { +export function FormField({ label, children, optional }: FormFieldProps) { return (
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index ab13093682..90fba9595e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Plus, Search, X } from 'lucide-react' +import { ChevronDown, Plus, Search, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -77,10 +77,17 @@ interface EnvVarDropdownConfig { onClose: () => void } +interface McpToolSchema { + type: 'object' + properties?: Record + required?: string[] +} + interface McpTool { name: string description?: string serverId: string + inputSchema?: McpToolSchema } interface McpServer { @@ -381,6 +388,7 @@ export function MCP({ initialServerId }: MCPProps) { const [refreshingServers, setRefreshingServers] = useState< Record >({}) + const [expandedTools, setExpandedTools] = useState>(new Set()) const [showEnvVars, setShowEnvVars] = useState(false) const [envSearchTerm, setEnvSearchTerm] = useState('') @@ -669,6 +677,22 @@ export function MCP({ initialServerId }: MCPProps) { */ const handleBackToList = useCallback(() => { setSelectedServerId(null) + setExpandedTools(new Set()) + }, []) + + /** + * Toggles the expanded state of a tool's parameters. + */ + const toggleToolExpanded = useCallback((toolName: string) => { + setExpandedTools((prev) => { + const newSet = new Set(prev) + if (newSet.has(toolName)) { + newSet.delete(toolName) + } else { + newSet.add(toolName) + } + return newSet + }) }, []) /** @@ -843,38 +867,113 @@ export function MCP({ initialServerId }: MCPProps) { {tools.map((tool) => { const issues = getStoredToolIssues(server.id, tool.name) const affectedWorkflows = issues.map((i) => i.workflowName) + const isExpanded = expandedTools.has(tool.name) + const hasParams = + tool.inputSchema?.properties && + Object.keys(tool.inputSchema.properties).length > 0 + const requiredParams = tool.inputSchema?.required || [] + return (
-
-

- {tool.name} -

- {issues.length > 0 && ( - - -
- - {getIssueBadgeLabel(issues[0].issue)} - -
-
- - Update in: {affectedWorkflows.join(', ')} - -
+
- {tool.description && ( -

- {tool.description} -

+ + + {isExpanded && hasParams && ( +
+

+ Parameters +

+
+ {Object.entries(tool.inputSchema!.properties!).map( + ([paramName, param]) => { + const isRequired = requiredParams.includes(paramName) + const paramType = + typeof param === 'object' && param !== null + ? (param as { type?: string }).type || 'any' + : 'any' + const paramDesc = + typeof param === 'object' && param !== null + ? (param as { description?: string }).description + : undefined + + return ( +
+
+ + {paramName} + + + {paramType} + + {isRequired && ( + + required + + )} +
+ {paramDesc && ( +

+ {paramDesc} +

+ )} +
+ ) + } + )} +
+
)}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 0c1b6a4efe..b8a6237fa9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -7,6 +7,9 @@ import { useParams } from 'next/navigation' import { Badge, Button, + ButtonGroup, + ButtonGroupItem, + Code, Combobox, type ComboboxOption, Input as EmcnInput, @@ -16,22 +19,33 @@ import { ModalContent, ModalFooter, ModalHeader, + SModalTabs, + SModalTabsBody, + SModalTabsContent, + SModalTabsList, + SModalTabsTrigger, Textarea, + Tooltip, } from '@/components/emcn' import { Input, Skeleton } from '@/components/ui' import { getBaseUrl } from '@/lib/core/utils/urls' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useApiKeys } from '@/hooks/queries/api-keys' import { useAddWorkflowMcpTool, useCreateWorkflowMcpServer, useDeleteWorkflowMcpServer, useDeleteWorkflowMcpTool, useDeployedWorkflows, + useUpdateWorkflowMcpServer, useUpdateWorkflowMcpTool, useWorkflowMcpServer, useWorkflowMcpServers, type WorkflowMcpServer, type WorkflowMcpTool, } from '@/hooks/queries/workflow-mcp-servers' +import { useWorkspaceSettings } from '@/hooks/queries/workspace' +import { CreateApiKeyModal } from '../api-keys/components' import { FormField, McpServerSkeleton } from '../mcp/components' const logger = createLogger('WorkflowMcpServers') @@ -42,22 +56,63 @@ interface ServerDetailViewProps { onBack: () => void } +type McpClientType = 'cursor' | 'claude-code' | 'claude-desktop' | 'vscode' + function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) { - const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId) + const { data, isLoading, error } = useWorkflowMcpServer(workspaceId, serverId) const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = useDeployedWorkflows(workspaceId) const deleteToolMutation = useDeleteWorkflowMcpTool() const addToolMutation = useAddWorkflowMcpTool() const updateToolMutation = useUpdateWorkflowMcpTool() - const [copiedUrl, setCopiedUrl] = useState(false) + const updateServerMutation = useUpdateWorkflowMcpServer() + + // API Keys - for "Create API key" link + const { data: apiKeysData } = useApiKeys(workspaceId) + const { data: workspaceSettingsData } = useWorkspaceSettings(workspaceId) + const userPermissions = useUserPermissionsContext() + const [showCreateApiKeyModal, setShowCreateApiKeyModal] = useState(false) + + const existingKeyNames = [ + ...(apiKeysData?.workspaceKeys ?? []), + ...(apiKeysData?.personalKeys ?? []), + ].map((k) => k.name) + const allowPersonalApiKeys = + workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true + const canManageWorkspaceKeys = userPermissions.canAdmin + const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace' + + const [copiedConfig, setCopiedConfig] = useState(false) + const [activeConfigTab, setActiveConfigTab] = useState('cursor') const [toolToDelete, setToolToDelete] = useState(null) const [toolToView, setToolToView] = useState(null) const [editingDescription, setEditingDescription] = useState('') + const [editingParameterDescriptions, setEditingParameterDescriptions] = useState< + Record + >({}) const [showAddWorkflow, setShowAddWorkflow] = useState(false) + const [showEditServer, setShowEditServer] = useState(false) + const [editServerName, setEditServerName] = useState('') + const [editServerDescription, setEditServerDescription] = useState('') + const [editServerIsPublic, setEditServerIsPublic] = useState(false) + const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details') useEffect(() => { if (toolToView) { setEditingDescription(toolToView.toolDescription || '') + const schema = toolToView.parameterSchema as + | { properties?: Record } + | undefined + const properties = schema?.properties + if (properties) { + const descriptions: Record = {} + for (const [name, prop] of Object.entries(properties)) { + descriptions[name] = prop.description || '' + } + setEditingParameterDescriptions(descriptions) + } else { + setEditingParameterDescriptions({}) + } } }, [toolToView]) const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) @@ -66,12 +121,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return `${getBaseUrl()}/api/mcp/serve/${serverId}` }, [serverId]) - const handleCopyUrl = () => { - navigator.clipboard.writeText(mcpServerUrl) - setCopiedUrl(true) - setTimeout(() => setCopiedUrl(false), 2000) - } - const handleDeleteTool = async () => { if (!toolToDelete) return try { @@ -96,7 +145,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro }) setShowAddWorkflow(false) setSelectedWorkflowId(null) - refetch() + setActiveServerTab('workflows') } catch (err) { logger.error('Failed to add workflow:', err) } @@ -108,6 +157,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro const existingWorkflowIds = new Set(tools.map((t) => t.workflowId)) return deployedWorkflows.filter((w) => !existingWorkflowIds.has(w.id)) }, [deployedWorkflows, tools]) + const canAddWorkflow = availableWorkflows.length > 0 + const showAddDisabledTooltip = !canAddWorkflow && deployedWorkflows.length > 0 const workflowOptions: ComboboxOption[] = useMemo(() => { return availableWorkflows.map((w) => ({ @@ -120,6 +171,115 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return availableWorkflows.find((w) => w.id === selectedWorkflowId) }, [availableWorkflows, selectedWorkflowId]) + const getConfigSnippet = useCallback( + (client: McpClientType, isPublic: boolean, serverName: string): string => { + const safeName = serverName + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + + if (client === 'claude-code') { + if (isPublic) { + return `claude mcp add "${safeName}" --url "${mcpServerUrl}"` + } + return `claude mcp add "${safeName}" --url "${mcpServerUrl}" --header "X-API-Key:$SIM_API_KEY"` + } + + const mcpRemoteArgs = isPublic + ? ['-y', 'mcp-remote', mcpServerUrl] + : ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'] + + const baseServerConfig = { + command: 'npx', + args: mcpRemoteArgs, + } + + if (client === 'vscode') { + return JSON.stringify( + { + servers: { + [safeName]: { + type: 'stdio', + ...baseServerConfig, + }, + }, + }, + null, + 2 + ) + } + + return JSON.stringify( + { + mcpServers: { + [safeName]: baseServerConfig, + }, + }, + null, + 2 + ) + }, + [mcpServerUrl] + ) + + const handleCopyConfig = useCallback( + (isPublic: boolean, serverName: string) => { + const snippet = getConfigSnippet(activeConfigTab, isPublic, serverName) + navigator.clipboard.writeText(snippet) + setCopiedConfig(true) + setTimeout(() => setCopiedConfig(false), 2000) + }, + [activeConfigTab, getConfigSnippet] + ) + + const handleOpenEditServer = useCallback(() => { + if (data?.server) { + setEditServerName(data.server.name) + setEditServerDescription(data.server.description || '') + setEditServerIsPublic(data.server.isPublic) + setShowEditServer(true) + } + }, [data?.server]) + + const handleSaveServerEdit = async () => { + if (!editServerName.trim()) return + try { + await updateServerMutation.mutateAsync({ + workspaceId, + serverId, + name: editServerName.trim(), + description: editServerDescription.trim() || undefined, + isPublic: editServerIsPublic, + }) + setShowEditServer(false) + } catch (err) { + logger.error('Failed to update server:', err) + } + } + + const getCursorInstallUrl = useCallback( + (isPublic: boolean, serverName: string): string => { + const safeName = serverName + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + + const config = isPublic + ? { + command: 'npx', + args: ['-y', 'mcp-remote', mcpServerUrl], + } + : { + command: 'npx', + args: ['-y', 'mcp-remote', mcpServerUrl, '--header', 'X-API-Key:$SIM_API_KEY'], + } + + const base64Config = btoa(JSON.stringify(config)) + return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(safeName)}&config=${encodeURIComponent(base64Config)}` + }, + [mcpServerUrl] + ) + if (isLoading) { return (
@@ -148,97 +308,223 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return ( <>
-
-
-
- - Server Name - -

{server.name}

-
+ setActiveServerTab(value as 'workflows' | 'details')} + className='flex min-h-0 flex-1 flex-col' + > + + Details + Workflows + -
- Transport -

Streamable-HTTP

-
- -
- URL -
-

- {mcpServerUrl} -

- +
+ + + All deployed workflows have been added to this server. + + ) : ( - + )} - -
-
+
-
-
- - Workflows ({tools.length}) - - + {tools.length === 0 ? ( +

+ No workflows added yet. Click "Add" to add a deployed workflow. +

+ ) : ( +
+ {tools.map((tool) => ( +
+
+ {tool.toolName} +

+ {tool.toolDescription || 'No description'} +

+
+
+ + +
+
+ ))} +
+ )} + + {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( +

+ Deploy a workflow first to add it to this server. +

+ )}
+ - {tools.length === 0 ? ( -

- No workflows added yet. Click "Add" to add a deployed workflow. -

- ) : ( + +
- {tools.map((tool) => ( -
-
- {tool.toolName} -

- {tool.toolDescription || 'No description'} -

-
-
- - -
-
- ))} + + Server Name + +

{server.name}

- )} - {availableWorkflows.length === 0 && deployedWorkflows.length > 0 && ( -

- All deployed workflows have been added to this server. -

- )} - {deployedWorkflows.length === 0 && !isLoadingWorkflows && ( -

- Deploy a workflow first to add it to this server. -

- )} -
-
-
+ {server.description?.trim() && ( +
+ + Description + +

{server.description}

+
+ )} + +
+
+ + Transport + +

Streamable-HTTP

+
+
+ + Access + +

+ {server.isPublic ? 'Public' : 'API Key'} +

+
+
+ +
+ URL +

+ {mcpServerUrl} +

+
-
+
+
+ + MCP Client + +
+ setActiveConfigTab(v as McpClientType)} + > + Cursor + Claude Code + Claude Desktop + VS Code + +
+ +
+
+ + Configuration + + +
+
+ + {activeConfigTab === 'cursor' && ( + + Add to Cursor + + )} +
+ {!server.isPublic && ( +

+ Replace $SIM_API_KEY with your API key, or{' '} + +

+ )} +
+
+ + + + +
+
+ {activeServerTab === 'details' && ( + <> + + + + )} +
@@ -278,6 +564,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro if (!open) { setToolToView(null) setEditingDescription('') + setEditingParameterDescriptions({}) } }} > @@ -285,10 +572,10 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro {toolToView?.toolName}
-
- +
+