From 8db905a1fff46d61e3dfa00afa9af85fbd78287e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 13:45:39 -0700 Subject: [PATCH 001/184] Fix autolayout v1 --- apps/sim/app/api/tools/edit-workflow/route.ts | 10 +- .../api/workflows/[id]/autolayout/route.ts | 10 +- apps/sim/app/api/workflows/[id]/yaml/route.ts | 10 +- .../components/control-bar/control-bar.tsx | 64 ++- .../w/[workflowId]/components/panel/panel.tsx | 12 +- .../workflow-text-editor/workflow-applier.ts | 8 +- .../[workspaceId]/w/[workflowId]/utils.ts | 534 +----------------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 127 ++--- .../lib/autolayout/algorithms/hierarchical.ts | 37 +- apps/sim/lib/autolayout/algorithms/smart.ts | 31 +- apps/sim/lib/autolayout/service.ts | 88 ++- 11 files changed, 266 insertions(+), 665 deletions(-) diff --git a/apps/sim/app/api/tools/edit-workflow/route.ts b/apps/sim/app/api/tools/edit-workflow/route.ts index 5cd036c7128..4f9efcd3c8f 100644 --- a/apps/sim/app/api/tools/edit-workflow/route.ts +++ b/apps/sim/app/api/tools/edit-workflow/route.ts @@ -295,14 +295,14 @@ export async function POST(request: NextRequest) { strategy: 'smart', direction: 'auto', spacing: { - horizontal: 400, - vertical: 200, - layer: 600, + horizontal: 500, // Increased from 400 to match UI button + vertical: 400, // Increased from 200 to match UI button + layer: 700, // Increased from 600 to match UI button }, alignment: 'center', padding: { - x: 200, - y: 200, + x: 250, // Increased from 200 to match UI button + y: 250, // Increased from 200 to match UI button }, } ) diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index ae7539d6cdb..047a5a27318 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -128,14 +128,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ strategy: layoutOptions.strategy, direction: layoutOptions.direction, spacing: { - horizontal: layoutOptions.spacing?.horizontal || 400, - vertical: layoutOptions.spacing?.vertical || 200, - layer: layoutOptions.spacing?.layer || 600, + horizontal: layoutOptions.spacing?.horizontal || 500, // Updated from 400 to match improved spacing + vertical: layoutOptions.spacing?.vertical || 400, // Updated from 200 to match improved spacing + layer: layoutOptions.spacing?.layer || 700, // Updated from 600 to match improved spacing }, alignment: layoutOptions.alignment, padding: { - x: layoutOptions.padding?.x || 200, - y: layoutOptions.padding?.y || 200, + x: layoutOptions.padding?.x || 250, // Updated from 200 to match improved spacing + y: layoutOptions.padding?.y || 250, // Updated from 200 to match improved spacing }, } ) diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 1ed9645a78c..d2a2a20702f 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -426,14 +426,14 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ strategy: 'smart', direction: 'auto', spacing: { - horizontal: 400, - vertical: 200, - layer: 600, + horizontal: 500, // Increased from 400 to match UI button + vertical: 400, // Increased from 200 to match UI button + layer: 700, // Increased from 600 to match UI button }, alignment: 'center', padding: { - x: 200, - y: 200, + x: 250, // Increased from 200 to match UI button + y: 250, // Increased from 200 to match UI button }, } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 33f929b9a3d..92ee9dc7169 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -108,6 +108,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { const [, forceUpdate] = useState({}) const [isExpanded, setIsExpanded] = useState(false) const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false) + const [isAutoLayouting, setIsAutoLayouting] = useState(false) // Deployed state management const [deployedState, setDeployedState] = useState(null) @@ -543,21 +544,63 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { * Render auto-layout button */ const renderAutoLayoutButton = () => { - const handleAutoLayoutClick = () => { - if (isExecuting || isDebugging || !userPermissions.canEdit) { + const handleAutoLayoutClick = async () => { + if (isExecuting || isDebugging || !userPermissions.canEdit || isAutoLayouting) { return } - window.dispatchEvent(new CustomEvent('trigger-auto-layout')) + setIsAutoLayouting(true) + try { + const response = await fetch(`/api/workflows/${activeWorkflowId}/autolayout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700, + }, + alignment: 'center', + padding: { + x: 250, + y: 250, + }, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + logger.error('Auto layout failed:', errorData) + // You could add a toast notification here if available + return + } + + const result = await response.json() + logger.info('Auto layout completed successfully:', result) + + // Refresh the workflow data to show the new positions + // This will be handled automatically by the real-time system + + } catch (error) { + logger.error('Auto layout error:', error) + // You could add a toast notification here if available + } finally { + setIsAutoLayouting(false) + } } const canEdit = userPermissions.canEdit - const isDisabled = isExecuting || isDebugging || !canEdit + const isDisabled = isExecuting || isDebugging || !canEdit || isAutoLayouting const getTooltipText = () => { if (!canEdit) return 'Admin permission required to use auto-layout' if (isDebugging) return 'Cannot auto-layout while debugging' if (isExecuting) return 'Cannot auto-layout while workflow is running' + if (isAutoLayouting) return 'Applying auto-layout...' return 'Auto layout' } @@ -566,15 +609,24 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { {isDisabled ? (
- + {isAutoLayouting ? ( + + ) : ( + + )}
) : ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 5f1f477fb3e..4d60d06474e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -37,12 +37,7 @@ export function Panel() { const { activeWorkflowId } = useWorkflowRegistry() const handleTabClick = (tab: 'chat' | 'console' | 'variables' | 'copilot') => { - // Redirect copilot tab clicks to console since copilot is hidden - if (tab === 'copilot') { - setActiveTab('console') - } else { - setActiveTab(tab) - } + setActiveTab(tab) if (!isOpen) { togglePanel() } @@ -115,15 +110,14 @@ export function Panel() { > Console - {/* Temporarily hiding copilot tab */} - {/* */} + + + + + + {/* Preview Container */} +
+ +
+ + {/* Save As New Workflow Form */} + {showSaveAsNew && ( +
+
+ + setNewWorkflowName(e.target.value)} + className='w-full' + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && newWorkflowName.trim()) { + handleSaveAsNewWorkflow() + } + if (e.key === 'Escape') { + setShowSaveAsNew(false) + } + }} + /> +
+ + +
+
+
+ )} + + {/* Action Buttons */} +
+
+
+ 💡 This is a preview of the workflow the copilot wants to create. Choose how to proceed. +
+ +
+ + + +
+
+ + {/* Warning for current workflow changes */} + {currentWorkflow && !showSaveAsNew && ( +
+ +
+

+ This will replace your current workflow: "{currentWorkflow.name}" +

+

+ A checkpoint will be created automatically so you can revert if needed. +

+
+
+ )} +
+ + + ) +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index cd2a8481698..c0a0fe02706 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -18,6 +18,8 @@ import { CopilotModal } from './components/copilot-modal/copilot-modal' import { ProfessionalInput } from './components/professional-input/professional-input' import { ProfessionalMessage } from './components/professional-message/professional-message' import { CopilotWelcome } from './components/welcome/welcome' +import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' +import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' const logger = createLogger('Copilot') @@ -51,6 +53,9 @@ export const Copilot = forwardRef( const { activeWorkflowId } = useWorkflowRegistry() + // Use copilot sandbox for workflow previews + const { sandboxState, showSandbox, closeSandbox, applyToCurrentWorkflow, saveAsNewWorkflow } = useCopilotSandbox() + // Use the new copilot store const { currentChat, @@ -100,6 +105,102 @@ export const Copilot = forwardRef( } }, [messages]) + // Watch for completed preview_workflow tool calls and show sandbox modal + useEffect(() => { + if (!messages.length) return + + const lastMessage = messages[messages.length - 1] + if (lastMessage.role !== 'assistant') return + + logger.info('Checking last message for preview_workflow tool calls:', { + messageLength: lastMessage.content.length, + messagePreview: lastMessage.content.substring(0, 200) + '...', + }) + + // Look for completed preview_workflow tool calls in the message content + const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g + const matches = Array.from(lastMessage.content.matchAll(previewToolCallPattern)) + + logger.info('Found tool call events:', { matchCount: matches.length }) + + for (const match of matches) { + try { + const toolCallEvent = JSON.parse(match[1]) + + logger.info('Processing tool call event:', { + type: toolCallEvent.type, + toolName: toolCallEvent.toolCall?.name, + state: toolCallEvent.toolCall?.state, + hasResult: !!toolCallEvent.toolCall?.result, + }) + + // Special logging for preview_workflow + if (toolCallEvent.toolCall?.name === 'preview_workflow') { + logger.info('Preview workflow tool call detected:', { + type: toolCallEvent.type, + state: toolCallEvent.toolCall?.state, + result: toolCallEvent.toolCall?.result, + parameters: toolCallEvent.toolCall?.parameters, + }) + } + + if ( + toolCallEvent.type === 'tool_call_complete' && + toolCallEvent.toolCall?.name === 'preview_workflow' && + toolCallEvent.toolCall?.state === 'completed' && + toolCallEvent.toolCall?.result + ) { + const result = toolCallEvent.toolCall.result + + logger.info('Preview workflow tool result:', { + hasWorkflowState: !!result.workflowState, + hasParameters: !!toolCallEvent.toolCall?.parameters, + hasYamlContent: !!toolCallEvent.toolCall?.parameters?.yamlContent, + resultKeys: Object.keys(result), + parametersKeys: toolCallEvent.toolCall?.parameters ? Object.keys(toolCallEvent.toolCall.parameters) : [], + }) + + // Extract the workflow state and YAML content from the actual structure + let workflowState = null + let yamlContent = null + let description = null + + // The workflow state is directly in result.workflowState + if (result.workflowState) { + workflowState = result.workflowState + } + + // The YAML content and description are in the tool call parameters + if (toolCallEvent.toolCall?.parameters) { + yamlContent = toolCallEvent.toolCall.parameters.yamlContent + description = toolCallEvent.toolCall.parameters.description + } + + if (workflowState && yamlContent) { + logger.info('Showing sandbox modal with workflow state', { + blocksCount: Object.keys(workflowState.blocks || {}).length, + edgesCount: (workflowState.edges || []).length, + yamlLength: yamlContent.length, + description, + }) + + showSandbox(workflowState, yamlContent, description) + break // Only handle the first preview tool call + } else { + logger.warn('Missing required data for sandbox modal:', { + hasWorkflowState: !!workflowState, + hasYamlContent: !!yamlContent, + workflowStateType: typeof workflowState, + yamlContentType: typeof yamlContent, + }) + } + } + } catch (error) { + logger.error('Error parsing tool call event:', error) + } + } + }, [messages, showSandbox]) + // Handle chat deletion const handleDeleteChat = useCallback( async (chatId: string) => { @@ -394,6 +495,20 @@ export const Copilot = forwardRef( mode={mode} onModeChange={setMode} /> + + {/* Copilot Sandbox Modal */} + { + await saveAsNewWorkflow(name) + }} + isProcessing={sandboxState.isProcessing} + /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts new file mode 100644 index 00000000000..1a14c7725c0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts @@ -0,0 +1,181 @@ +import { useState, useCallback } from 'react' +import { useParams } from 'next/navigation' +import { createLogger } from '@/lib/logs/console-logger' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('useCopilotSandbox') + +interface SandboxState { + isOpen: boolean + proposedWorkflowState: WorkflowState | null + yamlContent: string + description?: string + isProcessing: boolean +} + +export function useCopilotSandbox() { + const [sandboxState, setSandboxState] = useState({ + isOpen: false, + proposedWorkflowState: null, + yamlContent: '', + description: undefined, + isProcessing: false, + }) + + const params = useParams() + const workspaceId = params.workspaceId as string + const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() + + const showSandbox = useCallback(( + workflowState: WorkflowState, + yamlContent: string, + description?: string + ) => { + setSandboxState({ + isOpen: true, + proposedWorkflowState: workflowState, + yamlContent, + description, + isProcessing: false, + }) + }, []) + + const closeSandbox = useCallback(() => { + setSandboxState({ + isOpen: false, + proposedWorkflowState: null, + yamlContent: '', + description: undefined, + isProcessing: false, + }) + }, []) + + const applyToCurrentWorkflow = useCallback(async () => { + if (!activeWorkflowId || !sandboxState.yamlContent) { + throw new Error('No active workflow or YAML content') + } + + try { + setSandboxState(prev => ({ ...prev, isProcessing: true })) + + logger.info('Applying sandbox workflow to current workflow', { + workflowId: activeWorkflowId, + yamlLength: sandboxState.yamlContent.length, + }) + + // Use the existing YAML endpoint to apply the changes + const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: sandboxState.yamlContent, + description: sandboxState.description || 'Applied copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: true, // Always create checkpoints for copilot changes + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to apply workflow changes') + } + + logger.info('Successfully applied sandbox workflow to current workflow', { + workflowId: activeWorkflowId, + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + + } catch (error) { + logger.error('Failed to apply sandbox workflow:', error) + throw error + } finally { + setSandboxState(prev => ({ ...prev, isProcessing: false })) + } + }, [activeWorkflowId, sandboxState.yamlContent, sandboxState.description]) + + const saveAsNewWorkflow = useCallback(async (name: string) => { + if (!sandboxState.yamlContent) { + throw new Error('No YAML content to save') + } + + try { + setSandboxState(prev => ({ ...prev, isProcessing: true })) + + logger.info('Creating new workflow from sandbox', { + name, + yamlLength: sandboxState.yamlContent.length, + }) + + // First create a new workflow + const newWorkflowId = await createWorkflow({ + name, + description: sandboxState.description, + workspaceId, + }) + + if (!newWorkflowId) { + throw new Error('Failed to create new workflow') + } + + // Then apply the YAML content to the new workflow + const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: sandboxState.yamlContent, + description: sandboxState.description || 'Created from copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: false, // No need for checkpoint on new workflow + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to save workflow') + } + + logger.info('Successfully created new workflow from sandbox', { + newWorkflowId, + name, + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + + return newWorkflowId + + } catch (error) { + logger.error('Failed to save sandbox workflow as new:', error) + throw error + } finally { + setSandboxState(prev => ({ ...prev, isProcessing: false })) + } + }, [sandboxState.yamlContent, sandboxState.description, createWorkflow]) + + return { + sandboxState, + showSandbox, + closeSandbox, + applyToCurrentWorkflow, + saveAsNewWorkflow, + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index d5f42251ed2..ea1af71a581 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -126,11 +126,12 @@ You are STRICTLY FORBIDDEN from calling "Edit Workflow" until you have completed - Ensuring correct workflow structure - MANDATORY before any workflow edits -**"Edit Workflow"** - ⚠️ RESTRICTED ACCESS: -- FORBIDDEN until ALL four prerequisite tools have been called -- You MUST have called: Get User's Workflow, Get All Blocks, Get Block Metadata, Get YAML Structure -- NO EXCEPTIONS: Every workflow edit requires these four tools first -- Only use after: user approval + complete prerequisite tool execution +**"Preview Workflow"** - 🎯 ONLY WORKFLOW TOOL: +- This is the ONLY tool for proposing workflow changes +- Shows users a safe preview before making any changes +- STILL REQUIRES all four prerequisite tools (Get User's Workflow, Get All Blocks, Get Block Metadata, Get YAML Structure) +- Gives users the choice to apply changes or save as new workflow +- NO OTHER WORKFLOW EDITING TOOLS ARE AVAILABLE **FLEXIBLE APPROACH:** You don't need to call every tool for every request. Use your judgment: @@ -143,10 +144,13 @@ You don't need to call every tool for every request. Use your judgment: **COMMON PATTERNS:** *New Workflow Creation:* -- Typically: Get All Blocks → Get Block Metadata (for chosen blocks) → Get YAML Guide → Edit Workflow +- Always: Get All Blocks → Get Block Metadata (for chosen blocks) → Get YAML Guide → Preview Workflow *Existing Workflow Modification:* -- Typically: Get User's Workflow → (optionally Get Block Metadata for new blocks) → Edit Workflow +- Always: Get User's Workflow → (optionally Get Block Metadata for new blocks) → Preview Workflow + +*All Workflow Changes:* +- End with Preview Workflow - this shows users the proposed changes and gives them options to apply or save as new workflow *Information/Analysis:* - Might only need: Get User's Workflow or Get Block Metadata diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 2dea26a9488..e8a212b8d7a 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -336,30 +336,53 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, }, { - id: 'edit_workflow', - name: 'Edit Workflow', + id: 'preview_workflow', + name: 'Preview Workflow', description: - 'Save/edit the current workflow by providing YAML content. This performs the same action as saving in the YAML code editor.', + 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. This is the ONLY way to propose workflow changes.', params: {}, parameters: { type: 'object', properties: { yamlContent: { type: 'string', - description: 'The complete YAML workflow content to save', + description: 'The complete YAML workflow content to preview', }, description: { type: 'string', - description: 'Optional description of the changes being made', + description: 'Optional description of the proposed changes', }, }, required: ['yamlContent'], }, }, + // { + // id: 'edit_workflow', + // name: 'Edit Workflow', + // description: + // 'Save/edit the current workflow by providing YAML content. This performs the same action as saving in the YAML code editor.', + // params: {}, + // parameters: { + // type: 'object', + // properties: { + // yamlContent: { + // type: 'string', + // description: 'The complete YAML workflow content to save', + // }, + // description: { + // type: 'string', + // description: 'Optional description of the changes being made', + // }, + // }, + // required: ['yamlContent'], + // }, + // }, ] // Filter tools based on mode - return mode === 'ask' ? allTools.filter((tool) => tool.id !== 'edit_workflow') : allTools + return mode === 'ask' + ? allTools.filter((tool) => tool.id !== 'preview_workflow') + : allTools } /** diff --git a/apps/sim/lib/tool-call-parser.ts b/apps/sim/lib/tool-call-parser.ts index 1521f7f5353..9ba1bf30200 100644 --- a/apps/sim/lib/tool-call-parser.ts +++ b/apps/sim/lib/tool-call-parser.ts @@ -12,7 +12,8 @@ const TOOL_DISPLAY_NAMES: Record = { get_blocks_and_tools: 'Getting context', get_blocks_metadata: 'Getting context', get_yaml_structure: 'Designing an approach', - edit_workflow: 'Building your workflow', + preview_workflow: 'Generating workflow preview', + // edit_workflow: 'Building your workflow', // Commented out - only preview is allowed } // Past tense versions for completed tool calls @@ -22,7 +23,8 @@ const TOOL_PAST_TENSE_NAMES: Record = { get_blocks_and_tools: 'Understood context', get_blocks_metadata: 'Understood context', get_yaml_structure: 'Designed an approach', - edit_workflow: 'Built your workflow', + preview_workflow: 'Generated workflow preview', + // edit_workflow: 'Built your workflow', // Commented out - only preview is allowed } // Regex patterns to detect structured tool call events diff --git a/apps/sim/tools/blocks/preview-workflow.ts b/apps/sim/tools/blocks/preview-workflow.ts new file mode 100644 index 00000000000..fea0676e9e6 --- /dev/null +++ b/apps/sim/tools/blocks/preview-workflow.ts @@ -0,0 +1,86 @@ +import type { ToolConfig } from '../types' + +interface PreviewWorkflowParams { + yamlContent: string + description?: string + _context?: { + workflowId?: string + chatId?: string + } +} + +interface PreviewWorkflowResponse { + success: boolean + output: { + success: boolean + workflowState?: any + message?: string + summary?: string + data?: { + blocksCount: number + edgesCount: number + loopsCount: number + parallelsCount: number + } + errors?: string[] + warnings?: string[] + } +} + +export const previewWorkflowTool: ToolConfig = { + id: 'preview_workflow', + name: 'Preview Workflow', + description: + 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. Always use this instead of directly editing when showing workflow proposals.', + version: '1.0.0', + + params: { + yamlContent: { + type: 'string', + required: true, + description: 'The complete YAML workflow content to preview', + }, + description: { + type: 'string', + required: false, + description: 'Optional description of the proposed changes', + }, + }, + + request: { + url: () => '/api/workflows/preview', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + yamlContent: params.yamlContent, + applyAutoLayout: true, // Always apply auto layout for previews + }), + isInternalRoute: true, + }, + + transformResponse: async (response: Response): Promise => { + if (!response.ok) { + throw new Error(`Preview workflow failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + if (!data.success) { + throw new Error(data.message || 'Failed to preview workflow') + } + + return { + success: true, + output: data, + } + }, + + transformError: (error: any): string => { + if (error instanceof Error) { + return `Failed to preview workflow: ${error.message}` + } + return 'An unexpected error occurred while previewing the workflow' + }, +} \ No newline at end of file diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 9f90582234f..af22b245c01 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -2,7 +2,8 @@ import { createLogger } from '@/lib/logs/console-logger' import { getBaseUrl } from '@/lib/urls/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' -import { editWorkflowTool } from '@/tools/blocks/edit-workflow' +// import { editWorkflowTool } from '@/tools/blocks/edit-workflow' // Commented out - only preview is allowed +import { previewWorkflowTool } from '@/tools/blocks/preview-workflow' import { getAllBlocksTool } from '@/tools/blocks/get-all' import { getBlockMetadataTool } from '@/tools/blocks/get-metadata' import { getYamlStructureTool } from '@/tools/blocks/get-yaml-structure' @@ -20,7 +21,8 @@ const internalTools: Record = { get_blocks_and_tools: getAllBlocksTool, get_blocks_metadata: getBlockMetadataTool, get_yaml_structure: getYamlStructureTool, - edit_workflow: editWorkflowTool, + // edit_workflow: editWorkflowTool, // Commented out - only preview is allowed + preview_workflow: previewWorkflowTool, } // Export the list of internal tool IDs for filtering purposes From f70c3ef1d7a9f6778416152119dfbc5a48f73668 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 16:42:21 -0700 Subject: [PATCH 004/184] Preview v2 --- apps/sim/app/api/workflows/preview/route.ts | 66 +++++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/workflows/preview/route.ts b/apps/sim/app/api/workflows/preview/route.ts index 38954df89cb..762c553a488 100644 --- a/apps/sim/app/api/workflows/preview/route.ts +++ b/apps/sim/app/api/workflows/preview/route.ts @@ -88,12 +88,39 @@ export async function POST(request: NextRequest) { const loopBlocks = generateLoopBlocks({ [newId]: block } as any) previewWorkflowState.loops = { ...previewWorkflowState.loops, ...loopBlocks } + // Get block config and populate subBlocks with YAML input values + const blockConfig = getBlock(block.type) + const subBlocks: Record = {} + + if (blockConfig) { + // Set up subBlocks from block configuration + blockConfig.subBlocks.forEach((subBlock) => { + const yamlValue = block.inputs[subBlock.id] + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: yamlValue !== undefined ? yamlValue : null, + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(block.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], + } + } + }) + } + previewWorkflowState.blocks[newId] = { id: newId, type: 'loop', name: block.name, position: block.position || { x: 0, y: 0 }, - subBlocks: {}, + subBlocks, outputs: {}, enabled: true, horizontalHandles: true, @@ -105,12 +132,39 @@ export async function POST(request: NextRequest) { const parallelBlocks = generateParallelBlocks({ [newId]: block } as any) previewWorkflowState.parallels = { ...previewWorkflowState.parallels, ...parallelBlocks } + // Get block config and populate subBlocks with YAML input values + const blockConfig = getBlock(block.type) + const subBlocks: Record = {} + + if (blockConfig) { + // Set up subBlocks from block configuration + blockConfig.subBlocks.forEach((subBlock) => { + const yamlValue = block.inputs[subBlock.id] + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: yamlValue !== undefined ? yamlValue : null, + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(block.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], + } + } + }) + } + previewWorkflowState.blocks[newId] = { id: newId, type: 'parallel', name: block.name, position: block.position || { x: 0, y: 0 }, - subBlocks: {}, + subBlocks, outputs: {}, enabled: true, horizontalHandles: true, @@ -126,20 +180,22 @@ export async function POST(request: NextRequest) { // Set up subBlocks from block configuration blockConfig.subBlocks.forEach((subBlock) => { + // Use the actual value from YAML inputs if available + const yamlValue = block.inputs[subBlock.id] subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type, - value: null, + value: yamlValue !== undefined ? yamlValue : null, } }) - // Also ensure we have subBlocks for any YAML inputs + // Also ensure we have subBlocks for any YAML inputs not in block config Object.keys(block.inputs).forEach((inputKey) => { if (!subBlocks[inputKey]) { subBlocks[inputKey] = { id: inputKey, type: 'short-input', - value: null, + value: block.inputs[inputKey], } } }) From 26f5d27d37d592845ef955260d3cc00803f29475 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 16:45:42 -0700 Subject: [PATCH 005/184] more updates --- .../components/copilot-sandbox-modal/copilot-sandbox-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx index 44a121fe497..81726a7d072 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx @@ -97,7 +97,7 @@ export function CopilotSandboxModal({ 'flex flex-col gap-0 p-0', isFullscreen ? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] rounded-none' - : 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1200px]' + : 'h-[90vh] max-h-[90vh] w-[95vw] max-w-[95vw] overflow-hidden' )} hideCloseButton={true} > From 0c5bbd3879a366e0d10e044461bb51c872ae83f9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 18:11:43 -0700 Subject: [PATCH 006/184] Checkpoint --- .../professional-message.tsx | 3 +- .../panel/components/copilot/copilot.tsx | 42 ++- .../preview-overlay/preview-overlay.tsx | 53 ++++ .../preview-overlay/review-files-button.tsx | 260 ++++++++++++++++ .../[workflowId]/components/review-button.tsx | 275 +++++++++++++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 4 + apps/sim/stores/copilot/preview-store.ts | 277 ++++++++++++++++++ 7 files changed, 909 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx create mode 100644 apps/sim/stores/copilot/preview-store.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 3c0ea41bd1c..12bb286b204 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -1,6 +1,6 @@ 'use client' -import { type FC, memo, useMemo } from 'react' +import { type FC, memo, useMemo, useEffect } from 'react' import { Bot, Copy, User } from 'lucide-react' import { useTheme } from 'next-themes' import ReactMarkdown from 'react-markdown' @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button' import { ToolCallCompletion, ToolCallExecution } from '@/components/ui/tool-call' import { parseMessageContent, stripToolCallIndicators } from '@/lib/tool-call-parser' import type { CopilotMessage } from '@/stores/copilot/types' +import { setLatestPreview } from '../../../../../review-button' interface ProfessionalMessageProps { message: CopilotMessage diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index c0a0fe02706..d4d0842dfcf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -20,6 +20,8 @@ import { ProfessionalMessage } from './components/professional-message/professio import { CopilotWelcome } from './components/welcome/welcome' import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' +import { usePreviewStore } from '@/stores/copilot/preview-store' +import { setLatestPreview, clearLatestPreview } from '../../../review-button' const logger = createLogger('Copilot') @@ -50,11 +52,15 @@ export const Copilot = forwardRef( const scrollAreaRef = useRef(null) const [isDropdownOpen, setIsDropdownOpen] = useState(false) const [showCheckpoints, setShowCheckpoints] = useState(false) + const scannedChatRef = useRef(null) const { activeWorkflowId } = useWorkflowRegistry() // Use copilot sandbox for workflow previews const { sandboxState, showSandbox, closeSandbox, applyToCurrentWorkflow, saveAsNewWorkflow } = useCopilotSandbox() + + // Use preview store to track seen previews + const { scanAndMarkExistingPreviews, isToolCallSeen } = usePreviewStore() // Use the new copilot store const { @@ -85,6 +91,12 @@ export const Copilot = forwardRef( } }, [activeWorkflowId, workflowId, setWorkflowId]) + // Clear any existing preview when component mounts or workflow changes + useEffect(() => { + console.log('Copilot mounted or workflow changed - clearing preview') + clearLatestPreview() + }, [activeWorkflowId]) + // Safety check: Clear any chat that doesn't belong to current workflow useEffect(() => { if (activeWorkflowId && workflowId === activeWorkflowId) { @@ -105,6 +117,17 @@ export const Copilot = forwardRef( } }, [messages]) + // Scan existing messages and mark preview tool calls as seen ONLY once per chat session + useEffect(() => { + const chatId = currentChat?.id || 'no-chat' + + if (messages.length > 0 && scannedChatRef.current !== chatId) { + console.log('Scanning existing messages for chat:', chatId, 'message count:', messages.length) + scanAndMarkExistingPreviews(messages) + scannedChatRef.current = chatId + } + }, [messages, currentChat?.id, scanAndMarkExistingPreviews]) // Run when messages change, but only scan once per chat + // Watch for completed preview_workflow tool calls and show sandbox modal useEffect(() => { if (!messages.length) return @@ -148,7 +171,9 @@ export const Copilot = forwardRef( toolCallEvent.type === 'tool_call_complete' && toolCallEvent.toolCall?.name === 'preview_workflow' && toolCallEvent.toolCall?.state === 'completed' && - toolCallEvent.toolCall?.result + toolCallEvent.toolCall?.result && + toolCallEvent.toolCall?.id && + !isToolCallSeen(toolCallEvent.toolCall.id) ) { const result = toolCallEvent.toolCall.result @@ -177,14 +202,20 @@ export const Copilot = forwardRef( } if (workflowState && yamlContent) { - logger.info('Showing sandbox modal with workflow state', { + logger.info('Preview workflow completed - storing for review button', { blocksCount: Object.keys(workflowState.blocks || {}).length, edgesCount: (workflowState.edges || []).length, yamlLength: yamlContent.length, description, }) - showSandbox(workflowState, yamlContent, description) + // Preview will be detected by the review button scanning messages + console.log('Preview workflow completed - will be detected by review button:', { + hasWorkflowState: !!workflowState, + yamlLength: yamlContent?.length, + description, + toolCallId: toolCallEvent.toolCall.id + }) break // Only handle the first preview tool call } else { logger.warn('Missing required data for sandbox modal:', { @@ -199,7 +230,7 @@ export const Copilot = forwardRef( logger.error('Error parsing tool call event:', error) } } - }, [messages, showSandbox]) + }, [messages, isToolCallSeen]) // Handle chat deletion const handleDeleteChat = useCallback( @@ -216,6 +247,9 @@ export const Copilot = forwardRef( // Handle new chat creation const handleStartNewChat = useCallback(() => { + // Clear any pending preview when starting new chat + clearLatestPreview() + clearMessages() logger.info('Started new chat') }, [clearMessages]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx new file mode 100644 index 00000000000..9dda6c9c52d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Eye, GitBranch } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { usePreviewStore } from '@/stores/copilot/preview-store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface PreviewOverlayProps { + onShowPreview: (previewId: string) => void +} + +export function PreviewOverlay({ onShowPreview }: PreviewOverlayProps) { + const { activeWorkflowId } = useWorkflowRegistry() + const { getLatestPendingPreview, previews } = usePreviewStore() + + // Get latest preview, reacting to store changes + const latestPreview = activeWorkflowId ? getLatestPendingPreview(activeWorkflowId) : null + + if (!latestPreview) { + return null + } + + return ( +
+ +
+
+ +
+
+
+ Workflow Changes Ready +
+
+ {latestPreview.description || 'New workflow preview available'} +
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx new file mode 100644 index 00000000000..e4a1f8d24da --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx @@ -0,0 +1,260 @@ +'use client' + +import { useState, useCallback } from 'react' +import { useParams } from 'next/navigation' +import { Eye, FileText, CheckCircle, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { usePreviewStore } from '@/stores/copilot/preview-store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useCopilotStore } from '@/stores/copilot/store' +import { CopilotSandboxModal } from '../copilot-sandbox-modal/copilot-sandbox-modal' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('ReviewFilesButton') + +export function ReviewFilesButton() { + const params = useParams() + const workspaceId = params.workspaceId as string + const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() + const previewStore = usePreviewStore() + const { currentChat } = useCopilotStore() + const [showModal, setShowModal] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + + // Get the latest pending preview for the current workflow and chat session + // Using the store object directly to ensure reactivity + const pendingPreview = activeWorkflowId ? previewStore.getLatestPendingPreview(activeWorkflowId, currentChat?.id) : null + + // Debug logging + logger.info('ReviewFilesButton render:', { + activeWorkflowId, + currentChatId: currentChat?.id, + hasPendingPreview: !!pendingPreview, + previewId: pendingPreview?.id, + previewStatus: pendingPreview?.status, + totalPreviews: Object.keys(previewStore.previews).length, + allPreviewIds: Object.keys(previewStore.previews), + }) + + const handleApplyToCurrentWorkflow = useCallback(async () => { + if (!activeWorkflowId || !pendingPreview?.yamlContent) { + throw new Error('No active workflow or YAML content') + } + + try { + setIsProcessing(true) + + logger.info('Applying preview to current workflow', { + workflowId: activeWorkflowId, + previewId: pendingPreview?.id, + yamlLength: pendingPreview?.yamlContent.length, + }) + + // Use the existing YAML endpoint to apply the changes + const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: pendingPreview?.yamlContent, + description: pendingPreview?.description || 'Applied copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: true, // Always create checkpoints for copilot changes + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to apply workflow changes') + } + + if (pendingPreview) { + logger.info('Accepting preview:', { previewId: pendingPreview.id }) + previewStore.acceptPreview(pendingPreview.id) + logger.info('Preview accepted, closing modal') + } + setShowModal(false) + + logger.info('Successfully applied preview to current workflow:', { + previewId: pendingPreview?.id, + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + + } catch (error) { + logger.error('Failed to apply preview:', error) + throw error + } finally { + setIsProcessing(false) + } + }, [activeWorkflowId, pendingPreview, acceptPreview]) + + const handleSaveAsNewWorkflow = useCallback(async (name: string) => { + if (!pendingPreview?.yamlContent) { + throw new Error('No YAML content to save') + } + + try { + setIsProcessing(true) + + logger.info('Creating new workflow from preview', { + name, + previewId: pendingPreview.id, + yamlLength: pendingPreview.yamlContent.length, + }) + + // First create a new workflow + const newWorkflowId = await createWorkflow({ + name, + description: pendingPreview.description, + workspaceId, + }) + + if (!newWorkflowId) { + throw new Error('Failed to create new workflow') + } + + // Then apply the YAML content to the new workflow + const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: pendingPreview.yamlContent, + description: pendingPreview.description || 'Created from copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: false, // No need for checkpoint on new workflow + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to save workflow') + } + + logger.info('Accepting preview after save as new:', { previewId: pendingPreview.id }) + previewStore.acceptPreview(pendingPreview.id) + setShowModal(false) + + logger.info('Successfully created new workflow from preview:', { + newWorkflowId, + name, + previewId: pendingPreview.id, + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + + } catch (error) { + logger.error('Failed to save preview as new workflow:', error) + throw error + } finally { + setIsProcessing(false) + } + }, [pendingPreview, createWorkflow, workspaceId, previewStore]) + + // Early return after all hooks are defined + if (!pendingPreview) { + return null + } + + const handleShowPreview = () => { + logger.info('Opening preview modal for pending preview:', { + previewId: pendingPreview.id, + workflowId: pendingPreview.workflowId, + }) + setShowModal(true) + } + + const handleReject = () => { + if (pendingPreview) { + logger.info('Rejecting preview:', { previewId: pendingPreview.id }) + previewStore.rejectPreview(pendingPreview.id) + logger.info('Preview rejected, closing modal') + } + setShowModal(false) + logger.info('Rejected preview:', { previewId: pendingPreview?.id }) + } + + const blockCount = Object.keys(pendingPreview.workflowState?.blocks || {}).length + const edgeCount = pendingPreview.workflowState?.edges?.length || 0 + + return ( + <> + {/* Review Files Button */} +
+
+
+
+
+ +
+
+
+ Copilot has proposed changes + + {blockCount} blocks, {edgeCount} connections + +
+ {pendingPreview.description && ( + {pendingPreview.description} + )} +
+
+ +
+ + +
+
+
+
+ + {/* Sandbox Modal */} + {showModal && ( + setShowModal(false)} + proposedWorkflowState={pendingPreview.workflowState} + yamlContent={pendingPreview.yamlContent} + description={pendingPreview.description} + onApplyToCurrentWorkflow={handleApplyToCurrentWorkflow} + onSaveAsNewWorkflow={handleSaveAsNewWorkflow} + isProcessing={isProcessing} + /> + )} + + ) +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx new file mode 100644 index 00000000000..5e7267c4f89 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -0,0 +1,275 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useParams } from 'next/navigation' +import { Eye, FileText } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { CopilotSandboxModal } from './copilot-sandbox-modal/copilot-sandbox-modal' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useCopilotStore } from '@/stores/copilot/store' +import { usePreviewStore } from '@/stores/copilot/preview-store' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('ReviewButton') + +// Helper function to extract preview data from messages +function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => boolean) { + if (!messages.length) return null + + // Go through messages in reverse order (newest first) + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== 'assistant' || !message.content) continue + + const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g + let match + + while ((match = previewToolCallPattern.exec(message.content)) !== null) { + try { + const toolCallEvent = JSON.parse(match[1]) + if ( + toolCallEvent.type === 'tool_call_complete' && + toolCallEvent.toolCall?.name === 'preview_workflow' && + toolCallEvent.toolCall?.state === 'completed' && + toolCallEvent.toolCall?.result && + toolCallEvent.toolCall?.id && + !isToolCallSeen(toolCallEvent.toolCall.id) + ) { + const result = toolCallEvent.toolCall.result + let workflowState = null + let yamlContent = null + let description = null + + if (result.workflowState) { + workflowState = result.workflowState + } + + if (toolCallEvent.toolCall?.parameters) { + yamlContent = toolCallEvent.toolCall.parameters.yamlContent + description = toolCallEvent.toolCall.parameters.description + } + + if (workflowState && yamlContent) { + return { + toolCallId: toolCallEvent.toolCall.id, + workflowState, + yamlContent, + description, + } + } + } + } catch (error) { + console.warn('Failed to parse tool call event:', error) + } + } + } + + return null +} + +// Dummy functions for backward compatibility +export function setLatestPreview() { + // This is now handled automatically by scanning messages +} + +export function clearLatestPreview() { + // This is now handled by marking tool calls as seen +} + +export function ReviewButton() { + const params = useParams() + const workspaceId = params.workspaceId as string + const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() + const { messages } = useCopilotStore() + const { markToolCallAsSeen, isToolCallSeen, seenToolCallIds } = usePreviewStore( + (state) => ({ + markToolCallAsSeen: state.markToolCallAsSeen, + isToolCallSeen: state.isToolCallSeen, + seenToolCallIds: state.seenToolCallIds, // Include this to trigger re-renders + }) + ) + const [showModal, setShowModal] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + + // Get the latest unseen preview from messages + const latestPreview = useMemo(() => { + console.log('useMemo: Checking for latest unseen preview, seenToolCallIds size:', seenToolCallIds.size) + const preview = getLatestUnseenPreview(messages, isToolCallSeen) + console.log('useMemo: Found preview:', !!preview, preview?.toolCallId) + return preview + }, [messages, isToolCallSeen, seenToolCallIds]) + + // Debug logging + console.log('ReviewButton render:', { + hasLatestPreview: !!latestPreview, + activeWorkflowId, + messageCount: messages.length + }) + + // Only show if there's a real preview from copilot + if (!latestPreview) { + return null + } + + const handleShowPreview = () => { + setShowModal(true) + } + + const handleApply = async () => { + if (!activeWorkflowId || !latestPreview.yamlContent) { + logger.error('No active workflow or YAML content') + return + } + + try { + setIsProcessing(true) + + logger.info('Applying preview to current workflow', { + workflowId: activeWorkflowId, + yamlLength: latestPreview.yamlContent.length, + }) + + // Use the existing YAML endpoint to apply the changes + const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: latestPreview.yamlContent, + description: latestPreview.description || 'Applied copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: true, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to apply workflow changes') + } + + logger.info('Successfully applied preview to workflow') + console.log('Marking tool call as seen:', latestPreview.toolCallId) + markToolCallAsSeen(latestPreview.toolCallId) + console.log('Tool call marked as seen, closing modal') + setShowModal(false) + } catch (error) { + logger.error('Failed to apply preview:', error) + } finally { + setIsProcessing(false) + } + } + + const handleSaveAsNew = async (name: string) => { + if (!latestPreview.yamlContent) { + logger.error('No YAML content to save') + return + } + + try { + setIsProcessing(true) + + logger.info('Creating new workflow from preview', { + name, + yamlLength: latestPreview.yamlContent.length, + }) + + // First create a new workflow + const newWorkflowId = await createWorkflow({ + name, + description: latestPreview.description, + workspaceId, + }) + + if (!newWorkflowId) { + throw new Error('Failed to create new workflow') + } + + // Then apply the YAML content to the new workflow + const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: latestPreview.yamlContent, + description: latestPreview.description || 'Created from copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: false, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to save workflow') + } + + logger.info('Successfully created new workflow from preview') + markToolCallAsSeen(latestPreview.toolCallId) + setShowModal(false) + } catch (error) { + logger.error('Failed to save preview as new workflow:', error) + } finally { + setIsProcessing(false) + } + } + + const handleClose = () => { + setShowModal(false) + } + + return ( + <> + {/* Simple button at bottom center */} +
+
+
+
+
+ +
+ Copilot has proposed changes +
+ +
+
+
+ + {/* Sandbox Modal */} + {showModal && latestPreview && ( + + )} + + ) +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index d3db8c059df..24905687bdc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -17,6 +17,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node' +import { ReviewButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/review-button' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' @@ -1529,6 +1530,9 @@ const WorkflowContent = React.memo(() => { style={{ backgroundColor: 'hsl(var(--workflow-background))' }} /> + + {/* Review Button - appears when there's a pending preview */} + ) diff --git a/apps/sim/stores/copilot/preview-store.ts b/apps/sim/stores/copilot/preview-store.ts new file mode 100644 index 00000000000..503af428772 --- /dev/null +++ b/apps/sim/stores/copilot/preview-store.ts @@ -0,0 +1,277 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export interface PreviewData { + id: string + workflowState: any + yamlContent: string + description?: string + timestamp: number + status: 'pending' | 'accepted' | 'rejected' + workflowId: string + toolCallId?: string + chatId?: string // Track which chat session this preview belongs to + messageTimestamp?: number // Track when the message containing this preview was created +} + +interface PreviewStore { + previews: Record + seenToolCallIds: Set + addPreview: (preview: Omit) => string + acceptPreview: (previewId: string) => void + rejectPreview: (previewId: string) => void + getLatestPendingPreview: (workflowId: string, chatId?: string) => PreviewData | null + getPreviewById: (previewId: string) => PreviewData | null + getPreviewsForWorkflow: (workflowId: string) => PreviewData[] + getPreviewByToolCall: (toolCallId: string) => PreviewData | null + clearPreviewsForWorkflow: (workflowId: string) => void + clearPreviewsForChat: (chatId: string) => void + clearStalePreviewsForWorkflow: (workflowId: string, maxAgeMinutes?: number) => void + expireOldPreviews: (maxAgeHours?: number) => void + markToolCallAsSeen: (toolCallId: string) => void + isToolCallSeen: (toolCallId: string) => boolean + scanAndMarkExistingPreviews: (messages: any[]) => void +} + +export const usePreviewStore = create()( + persist( + (set, get) => ({ + previews: {}, + seenToolCallIds: new Set(), + + addPreview: (preview) => { + const id = `preview_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + const newPreview: PreviewData = { + ...preview, + id, + timestamp: Date.now(), + status: 'pending', + } + + console.log('Adding new preview:', newPreview) + + set((state) => { + const newState = { + previews: { + ...state.previews, + [id]: newPreview, + }, + } + console.log('New state after adding preview:', Object.keys(newState.previews)) + return newState + }) + + return id + }, + + acceptPreview: (previewId) => { + console.log('acceptPreview called with:', previewId) + set((state) => { + const existingPreview = state.previews[previewId] + if (!existingPreview) { + console.warn('Preview not found:', previewId) + return state + } + + console.log('Updating preview status from', existingPreview.status, 'to accepted') + const newState = { + previews: { + ...state.previews, + [previewId]: { + ...existingPreview, + status: 'accepted' as const, + }, + }, + } + console.log('New preview state:', newState.previews[previewId]) + return newState + }) + }, + + rejectPreview: (previewId) => { + console.log('rejectPreview called with:', previewId) + set((state) => { + const existingPreview = state.previews[previewId] + if (!existingPreview) { + console.warn('Preview not found:', previewId) + return state + } + + console.log('Updating preview status from', existingPreview.status, 'to rejected') + return { + previews: { + ...state.previews, + [previewId]: { + ...existingPreview, + status: 'rejected' as const, + }, + }, + } + }) + }, + + getLatestPendingPreview: (workflowId, chatId) => { + const now = Date.now() + const maxAge = 30 * 60 * 1000 // 30 minutes + const allPreviews = Object.values(get().previews) + + console.log('getLatestPendingPreview called with:', { workflowId, chatId }) + console.log('All previews in store:', allPreviews.map(p => ({ + id: p.id, + workflowId: p.workflowId, + chatId: p.chatId, + status: p.status, + timestamp: p.timestamp, + age: now - p.timestamp, + }))) + + const previews = allPreviews + .filter((p) => { + console.log(`Filtering preview ${p.id}:`, { + workflowMatch: p.workflowId === workflowId, + statusPending: p.status === 'pending', + chatMatch: !chatId || !p.chatId || p.chatId === chatId, + ageOk: now - p.timestamp <= maxAge, + }) + + // Must be for the current workflow and pending + if (p.workflowId !== workflowId || p.status !== 'pending') { + return false + } + + // If chatId is provided, only show previews from this chat session + // If no chatId provided or preview has no chatId, allow it (for backward compatibility) + if (chatId && p.chatId && p.chatId !== chatId) { + return false + } + + // Filter out previews older than 30 minutes to avoid stale previews + if (now - p.timestamp > maxAge) { + return false + } + + return true + }) + .sort((a, b) => b.timestamp - a.timestamp) + + console.log('Filtered previews:', previews.map(p => ({ id: p.id, status: p.status }))) + const result = previews[0] || null + console.log('Returning preview:', result?.id || 'null') + return result + }, + + getPreviewById: (previewId) => { + return get().previews[previewId] || null + }, + + getPreviewsForWorkflow: (workflowId) => { + return Object.values(get().previews).filter((p) => p.workflowId === workflowId) + }, + + getPreviewByToolCall: (toolCallId) => { + return Object.values(get().previews).find((p) => p.toolCallId === toolCallId) || null + }, + + clearPreviewsForWorkflow: (workflowId) => { + set((state) => ({ + previews: Object.fromEntries( + Object.entries(state.previews).filter(([_, preview]) => preview.workflowId !== workflowId) + ), + })) + }, + + clearPreviewsForChat: (chatId) => { + set((state) => ({ + previews: Object.fromEntries( + Object.entries(state.previews).filter(([_, preview]) => preview.chatId !== chatId) + ), + })) + }, + + clearStalePreviewsForWorkflow: (workflowId, maxAgeMinutes = 30) => { + const now = Date.now() + const maxAge = maxAgeMinutes * 60 * 1000 + + set((state) => ({ + previews: Object.fromEntries( + Object.entries(state.previews).filter(([_, preview]) => { + if (preview.workflowId === workflowId && preview.status === 'pending') { + return now - preview.timestamp <= maxAge + } + return true // Keep previews from other workflows or accepted/rejected previews + }) + ), + })) + }, + + expireOldPreviews: (maxAgeHours = 24) => { + const now = Date.now() + const maxAge = maxAgeHours * 60 * 60 * 1000 + + set((state) => ({ + previews: Object.fromEntries( + Object.entries(state.previews).filter(([_, preview]) => now - preview.timestamp <= maxAge) + ), + })) + }, + + markToolCallAsSeen: (toolCallId) => { + set((state) => ({ + seenToolCallIds: new Set([...state.seenToolCallIds, toolCallId]) + })) + }, + + isToolCallSeen: (toolCallId) => { + return get().seenToolCallIds.has(toolCallId) + }, + + scanAndMarkExistingPreviews: (messages) => { + const toolCallIds = new Set() + + messages.forEach((message) => { + if (message.role === 'assistant' && message.content) { + const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g + let match + + while ((match = previewToolCallPattern.exec(message.content)) !== null) { + try { + const toolCallEvent = JSON.parse(match[1]) + if ( + toolCallEvent.type === 'tool_call_complete' && + toolCallEvent.toolCall?.name === 'preview_workflow' && + toolCallEvent.toolCall?.id + ) { + toolCallIds.add(toolCallEvent.toolCall.id) + } + } catch (error) { + console.warn('Failed to parse tool call event while scanning:', error) + } + } + } + }) + + set((state) => ({ + seenToolCallIds: new Set([...state.seenToolCallIds, ...toolCallIds]) + })) + + console.log('Scanned and marked existing preview tool calls:', Array.from(toolCallIds)) + }, + }), + { + name: 'copilot-preview-store', + partialize: (state) => ({ + previews: Object.fromEntries( + Object.entries(state.previews).filter( + ([_, preview]) => Date.now() - preview.timestamp < 24 * 60 * 60 * 1000 // Keep for 24 hours + ) + ), + seenToolCallIds: Array.from(state.seenToolCallIds), // Convert Set to Array for serialization + }), + merge: (persistedState: any, currentState) => ({ + ...currentState, + ...persistedState, + seenToolCallIds: new Set(persistedState?.seenToolCallIds || []), // Convert Array back to Set + }), + } + ) +) \ No newline at end of file From 9ade32192ee94179c3bd0cced5024d6418a2416e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 18:19:36 -0700 Subject: [PATCH 007/184] Checkpoint --- .../[workflowId]/components/review-button.tsx | 17 +++++- apps/sim/lib/copilot/prompts.ts | 2 + apps/sim/lib/copilot/service.ts | 2 +- apps/sim/stores/copilot/store.ts | 60 +++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 5e7267c4f89..e03abc5ad92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -80,7 +80,7 @@ export function ReviewButton() { const params = useParams() const workspaceId = params.workspaceId as string const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() - const { messages } = useCopilotStore() + const { messages, sendMessage } = useCopilotStore() const { markToolCallAsSeen, isToolCallSeen, seenToolCallIds } = usePreviewStore( (state) => ({ markToolCallAsSeen: state.markToolCallAsSeen, @@ -160,6 +160,9 @@ export function ReviewButton() { markToolCallAsSeen(latestPreview.toolCallId) console.log('Tool call marked as seen, closing modal') setShowModal(false) + + // Continue the copilot conversation with acceptance message + await sendMessage('I have accepted and applied the workflow changes. Please continue.') } catch (error) { logger.error('Failed to apply preview:', error) } finally { @@ -221,6 +224,9 @@ export function ReviewButton() { logger.info('Successfully created new workflow from preview') markToolCallAsSeen(latestPreview.toolCallId) setShowModal(false) + + // Continue the copilot conversation with save as new message + await sendMessage(`I have saved the workflow changes as a new workflow named "${name}". Please continue.`) } catch (error) { logger.error('Failed to save preview as new workflow:', error) } finally { @@ -228,8 +234,15 @@ export function ReviewButton() { } } - const handleClose = () => { + const handleClose = async () => { setShowModal(false) + + // If there's a preview when closing, mark it as seen and send rejection message + if (latestPreview) { + markToolCallAsSeen(latestPreview.toolCallId) + // Continue the copilot conversation with rejection message + await sendMessage('I have rejected the workflow changes. Please continue or make different modifications.') + } } return ( diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index ea1af71a581..48e38bb7d18 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -131,6 +131,7 @@ You are STRICTLY FORBIDDEN from calling "Edit Workflow" until you have completed - Shows users a safe preview before making any changes - STILL REQUIRES all four prerequisite tools (Get User's Workflow, Get All Blocks, Get Block Metadata, Get YAML Structure) - Gives users the choice to apply changes or save as new workflow +- ⚠️ **CRITICAL**: After calling this tool, you MUST stop your response immediately and wait for the user to accept, reject, or provide feedback - NO OTHER WORKFLOW EDITING TOOLS ARE AVAILABLE **FLEXIBLE APPROACH:** @@ -151,6 +152,7 @@ You don't need to call every tool for every request. Use your judgment: *All Workflow Changes:* - End with Preview Workflow - this shows users the proposed changes and gives them options to apply or save as new workflow +- STOP IMMEDIATELY after calling Preview Workflow - do not continue talking until user responds *Information/Analysis:* - Might only need: Get User's Workflow or Get Block Metadata diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index e8a212b8d7a..d39fe5231e6 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -339,7 +339,7 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { id: 'preview_workflow', name: 'Preview Workflow', description: - 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. This is the ONLY way to propose workflow changes.', + 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. This is the ONLY way to propose workflow changes. IMPORTANT: After calling this tool, you MUST stop your response immediately and wait for the user to either accept, reject, or provide additional feedback before continuing the conversation.', params: {}, parameters: { type: 'object', diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index fa46b739f4f..bcfe4e0cbff 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -84,6 +84,33 @@ function handleStoreError(error: unknown, fallbackMessage: string): string { return errorMessage } +/** + * Helper function to check if a preview_workflow tool call has completed in the content + */ +function checkForPreviewToolCompletion(content: string): boolean { + // Look for tool call completion events in the content + const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g + let match + + while ((match = previewToolCallPattern.exec(content)) !== null) { + try { + const toolCallEvent = JSON.parse(match[1]) + if ( + toolCallEvent.type === 'tool_call_complete' && + toolCallEvent.toolCall?.name === 'preview_workflow' && + toolCallEvent.toolCall?.state === 'completed' + ) { + return true // Found a completed preview tool call + } + } catch (error) { + // Ignore parsing errors for malformed events + continue + } + } + + return false +} + /** * Copilot store using the new unified API */ @@ -449,6 +476,39 @@ export const useCopilotStore = create()( } else if (data.type === 'content') { accumulatedContent += data.content + // Check if we just completed a preview_workflow tool call and should stop streaming + const shouldStopStreaming = checkForPreviewToolCompletion(accumulatedContent) + if (shouldStopStreaming) { + logger.info('Preview workflow tool completed - stopping stream') + streamComplete = true + + // Final update with current content + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent } : msg + ), + isSendingMessage: false, + })) + + // Save chat immediately when stopping for preview + const chatIdToSave = newChatId || get().currentChat?.id + if (chatIdToSave) { + try { + await get().saveChatMessages(chatIdToSave) + } catch (saveError) { + logger.warn(`Chat save failed after preview stop: ${saveError}`) + } + } + + // Close the reader to stop the stream + try { + reader.cancel() + } catch (error) { + // Ignore cancellation errors + } + return // Exit the entire streaming function + } + // Update the streaming message set((state) => ({ messages: state.messages.map((msg) => From 632a5789874776ba7891727099fbee74bf3669a9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 18:49:33 -0700 Subject: [PATCH 008/184] Better preview --- apps/sim/app/api/copilot/route.ts | 4 +- .../copilot-sandbox-modal.tsx | 51 ++++++++++-- .../[workflowId]/components/review-button.tsx | 27 +++++-- apps/sim/lib/copilot/api.ts | 1 + apps/sim/lib/copilot/service.ts | 17 +++- apps/sim/stores/copilot/store.ts | 78 ++++++++++++++++++- apps/sim/stores/copilot/types.ts | 3 +- 7 files changed, 161 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 9db48715d92..589ae4c73d3 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -28,6 +28,7 @@ const SendMessageSchema = z.object({ mode: z.enum(['ask', 'agent']).optional().default('ask'), createNewChat: z.boolean().optional().default(false), stream: z.boolean().optional().default(false), + implicitFeedback: z.string().optional(), }) // Schema for docs queries @@ -91,7 +92,7 @@ export async function POST(req: NextRequest) { try { const body = await req.json() - const { message, chatId, workflowId, mode, createNewChat, stream } = + const { message, chatId, workflowId, mode, createNewChat, stream, implicitFeedback } = SendMessageSchema.parse(body) const session = await getSession() @@ -116,6 +117,7 @@ export async function POST(req: NextRequest) { mode, createNewChat, stream, + implicitFeedback, userId: session.user.id, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx index 81726a7d072..6b84ef11459 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Eye, Maximize2, Minimize2, Save, CheckCircle, X, AlertCircle } from 'lucide-react' +import { Eye, Maximize2, Minimize2, Save, CheckCircle, X, AlertCircle, XCircle } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -23,6 +23,7 @@ interface CopilotSandboxModalProps { description?: string onApplyToCurrentWorkflow: () => Promise onSaveAsNewWorkflow: (name: string) => Promise + onReject?: () => Promise isProcessing?: boolean } @@ -34,6 +35,7 @@ export function CopilotSandboxModal({ description, onApplyToCurrentWorkflow, onSaveAsNewWorkflow, + onReject, isProcessing = false, }: CopilotSandboxModalProps) { const [isFullscreen, setIsFullscreen] = useState(false) @@ -41,6 +43,7 @@ export function CopilotSandboxModal({ const [newWorkflowName, setNewWorkflowName] = useState('') const [isSaving, setIsSaving] = useState(false) const [isApplying, setIsApplying] = useState(false) + const [isRejecting, setIsRejecting] = useState(false) const { workflows, activeWorkflowId } = useWorkflowRegistry() const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null @@ -77,6 +80,23 @@ export function CopilotSandboxModal({ } } + const handleReject = async () => { + if (!onReject) { + handleClose() + return + } + + try { + setIsRejecting(true) + await onReject() + onClose() + } catch (error) { + logger.error('Failed to reject workflow:', error) + } finally { + setIsRejecting(false) + } + } + const handleClose = () => { setShowSaveAsNew(false) setNewWorkflowName('') @@ -203,15 +223,36 @@ export function CopilotSandboxModal({ {/* Action Buttons */}
-
- 💡 This is a preview of the workflow the copilot wants to create. Choose how to proceed. +
+
+ 💡 This is a preview of the workflow the copilot wants to create. Choose how to proceed. +
+ +
- {/* Save As New Workflow Form */} - {showSaveAsNew && ( -
-
- - setNewWorkflowName(e.target.value)} - className='w-full' - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter' && newWorkflowName.trim()) { - handleSaveAsNewWorkflow() - } - if (e.key === 'Escape') { - setShowSaveAsNew(false) - } - }} - /> -
- - -
-
-
- )} + {/* Action Buttons */}
+
+ 💡 This is a preview of the workflow the copilot wants to create. Choose how to proceed. +
+
-
- 💡 This is a preview of the workflow the copilot wants to create. Choose how to proceed. -
- -
- -
- - - + {/* Split Accept Button - GitHub Style */} +
+ {/* Main Button - toggles between Accept and Save as New */} + + + {/* Dropdown Arrow Button */} + + + + + + setSaveAsNewMode(!saveAsNewMode)} + className='cursor-pointer' + > + {saveAsNewMode ? ( + <> + + Accept (Apply to Current) + + ) : ( + <> + + Save as New Workflow + + )} + + + +
{/* Warning for current workflow changes */} - {currentWorkflow && !showSaveAsNew && ( + {currentWorkflow && !saveAsNewMode && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index e85aa4fac54..43fb59e267b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -162,7 +162,7 @@ export function ReviewButton() { setShowModal(false) // Continue the copilot conversation with acceptance message - await sendImplicitFeedback('SYSTEM: The user has ACCEPTED your workflow proposal and the changes have been successfully applied to their workflow. You must now continue your previous response by: 1) Acknowledging the successful application 2) Explaining what was added/changed 3) Suggesting next steps or additional improvements. Provide a complete, helpful response of at least 2-3 sentences.') + await sendImplicitFeedback('The user has accepted and applied the workflow changes. Please continue.') } catch (error) { logger.error('Failed to apply preview:', error) } finally { @@ -226,7 +226,7 @@ export function ReviewButton() { setShowModal(false) // Continue the copilot conversation with save as new message - await sendImplicitFeedback(`SYSTEM: The user has SAVED your workflow proposal as a new workflow named "${name}". You must now continue your previous response by: 1) Acknowledging the successful creation of the new workflow 2) Explaining what the new workflow contains 3) Suggesting how they can use or modify it further. Provide a complete, helpful response of at least 2-3 sentences.`) + await sendImplicitFeedback(`The user has saved the workflow changes as a new workflow named "${name}". Please continue.`) } catch (error) { logger.error('Failed to save preview as new workflow:', error) } finally { @@ -243,7 +243,7 @@ export function ReviewButton() { setShowModal(false) // Continue the copilot conversation with rejection message - await sendImplicitFeedback('SYSTEM: The user has REJECTED your workflow proposal. You must now continue your previous response by: 1) Acknowledging that they declined the changes 2) Asking what specific modifications they would prefer 3) Offering alternative approaches or asking for clarification on their requirements. Provide a complete, helpful response of at least 2-3 sentences.') + await sendImplicitFeedback('The user has rejected the workflow changes. Please continue.') } catch (error) { logger.error('Failed to reject preview:', error) } finally { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 8a46cd96172..0560f69dc32 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -424,7 +424,7 @@ export const useCopilotStore = create()( try { const result = await sendStreamingMessage({ - message: 'IMPORTANT: You must continue your previous response that was interrupted. The user has provided feedback which is included in the system message. Continue naturally from where you left off and provide a complete, substantial response addressing their feedback.', // Very directive continuation prompt + message: 'Please continue your response.', // Simple continuation prompt chatId: currentChat?.id, workflowId, mode, From 474b12f8f286d9849fd0f8080cdb2dcd7f910515 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 19:28:31 -0700 Subject: [PATCH 010/184] Chat ui --- .../copilot-modal/copilot-modal.tsx | 380 +++++----- .../professional-message.tsx | 664 +++++++++++------- .../panel/components/copilot/copilot.tsx | 32 +- .../preview-overlay/review-files-button.tsx | 2 +- .../[workflowId]/components/review-button.tsx | 2 +- apps/sim/lib/tool-call-parser.ts | 285 ++++++-- 6 files changed, 853 insertions(+), 512 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx index 663061a797c..701af87bc34 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx @@ -17,6 +17,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { ScrollArea } from '@/components/ui/scroll-area' import type { CopilotChat } from '@/lib/copilot/api' import { createLogger } from '@/lib/logs/console-logger' import type { CopilotMessage } from '@/stores/copilot/types' @@ -72,211 +73,213 @@ export function CopilotModal({ // Fixed sidebar width for copilot modal positioning const sidebarWidth = 240 // w-60 (sidebar width from staging) - // Auto-scroll to bottom when new messages are added + // Auto-scroll to bottom when new messages are added with smooth behavior useEffect(() => { if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + messagesEndRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'end', + inline: 'nearest' + }) } }, [messages]) + // Auto-scroll when messages update during streaming + useEffect(() => { + if (isLoading && messagesContainerRef.current) { + const container = messagesContainerRef.current + const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100 + + if (isNearBottom) { + messagesEndRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }) + } + } + }, [messages, isLoading]) + if (!open) return null return (
- - - {/* Show loading state with centered pulsing agent icon */} - {isLoadingChats || isLoading ? ( -
-
- + }} + > +
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

Copilot Assistant

+

+ {mode === 'ask' ? 'Ask questions about your workflow' : 'Agent mode - Let me help you build'} +

+
-
- ) : ( - <> - {/* Close button in top right corner */} - - {/* Header with chat title and management */} -
-
- {/* Chat Title Dropdown */} - - - - - setIsDropdownOpen(false)} +
+ {/* Chat History Dropdown */} + + + + + +
{isLoadingChats ? ( -
Loading chats...
+
+
+ Loading chats... +
) : chats.length === 0 ? ( -
No chats yet
+
+ No chat history yet +
) : ( - // Sort chats by updated date (most recent first) for display - [...chats] - .sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() - ) - .map((chat) => ( -
- -
{ - onSelectChat(chat) - setIsDropdownOpen(false) - }} - className={`min-w-0 flex-1 cursor-pointer rounded-lg px-3 py-2.5 transition-all ${ - currentChat?.id === chat.id - ? 'bg-accent/80 text-accent-foreground' - : 'hover:bg-accent/40' - }`} - > -
-
- {chat.title || 'Untitled Chat'} -
-
- {new Date(chat.updatedAt).toLocaleDateString()} at{' '} - {new Date(chat.updatedAt).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })}{' '} - • {chat.messageCount} -
-
-
-
- - - - - - onDeleteChat(chat.id)} - className='cursor-pointer text-destructive hover:bg-destructive/10 hover:text-destructive focus:bg-destructive/10 focus:text-destructive' - > - - Delete - - - + chats.map((chat) => ( + { + onSelectChat(chat) + setIsDropdownOpen(false) + }} + > +
+
+ {chat.title || 'Untitled Chat'} +
+
+ {chat.messageCount} messages +
- )) + +
+ )) )} - - +
+ + - {/* Right side action buttons */} -
- {/* Checkpoint Toggle Button */} - +
- {/* New Chat Button */} - -
+ {/* Action buttons */} +
+ {/* Checkpoint Toggle Button */} + + + {/* New Chat Button */} + + + {/* Close Button */} +
+
- {/* Messages container or Checkpoint Panel */} + {/* Main Content Area */} +
{showCheckpoints ? ( -
+
) : ( -
-
- {messages.length === 0 ? ( - - ) : ( - messages.map((message) => ( - - )) - )} - -
-
-
- )} - - {/* Mode Selector and Input */} - {!showCheckpoints && ( <> - {/* Mode Selector */} -
-
-
+ {/* Messages Area */} + +
+ {messages.length === 0 ? ( +
+ +
+ ) : ( +
+ {messages.map((message) => ( + + ))} +
+ )} +
+
+ + + {/* Input Area */} +
+
+ {/* Mode Selector */} +
+ + {/* Input */} + { + await onSendMessage(message) + setCopilotMessage('') + }} + disabled={false} + isLoading={isLoading} + placeholder={ + mode === 'ask' + ? 'Ask me anything about your workflow...' + : 'Describe what you want to build...' + } + />
- - {/* Input area */} - { - await onSendMessage(message) - setCopilotMessage('') - }} - disabled={false} - isLoading={isLoading} - /> )} - - )} +
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 12bb286b204..742f4451860 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -1,16 +1,19 @@ 'use client' -import { type FC, memo, useMemo, useEffect } from 'react' -import { Bot, Copy, User } from 'lucide-react' +import { type FC, memo, useMemo, useEffect, useState } from 'react' +import { Bot, Copy, User, ChevronDown, ChevronRight, CheckCircle, Settings, XCircle, Loader2 } from 'lucide-react' import { useTheme } from 'next-themes' import ReactMarkdown from 'react-markdown' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import remarkGfm from 'remark-gfm' import { Button } from '@/components/ui/button' -import { ToolCallCompletion, ToolCallExecution } from '@/components/ui/tool-call' -import { parseMessageContent, stripToolCallIndicators } from '@/lib/tool-call-parser' +import { Badge } from '@/components/ui/badge' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { parseMessageContent, stripToolCallIndicators, groupDesignApproachTools, isDesignApproachTool } from '@/lib/tool-call-parser' +import { cn } from '@/lib/utils' import type { CopilotMessage } from '@/stores/copilot/types' +import type { ToolCallState } from '@/types/tool-call' import { setLatestPreview } from '../../../../../review-button' interface ProfessionalMessageProps { @@ -18,6 +21,229 @@ interface ProfessionalMessageProps { isStreaming?: boolean } +// Design Approach Group Component +function DesignApproachGroup({ tools, isCompleted }: { tools: ToolCallState[], isCompleted: boolean }) { + const [isExpanded, setIsExpanded] = useState(true) + + const activeToolIndex = tools.findIndex(tool => tool.state === 'executing') + const completedCount = tools.filter(tool => tool.state === 'completed').length + const hasError = tools.some(tool => tool.state === 'error') + + const getGroupStatus = () => { + if (hasError) return 'error' + if (completedCount === tools.length) return 'completed' // All tools completed + if (activeToolIndex >= 0) return 'executing' + return 'pending' + } + + const status = getGroupStatus() + + // Stable group title - always show as designed when all tools are done + const getGroupTitle = () => { + if (status === 'completed') return 'Designed an Approach' + if (status === 'executing') return 'Designing an Approach' + if (status === 'error') return 'Approach Design Failed' + return 'Designing an Approach' + } + + const getGroupSubtitle = () => { + if (status === 'executing' && activeToolIndex >= 0) { + return `Step ${activeToolIndex + 1} of ${tools.length} • ${tools[activeToolIndex].displayName || tools[activeToolIndex].name}` + } + if (status === 'completed') { + return 'Approach designed successfully' + } + if (status === 'error') { + return 'Error in approach design' + } + return `${completedCount}/${tools.length} steps completed` + } + + return ( +
+ + + + + +
+ {tools.map((tool, index) => ( + + ))} +
+
+
+
+ ) +} + +// Inline Tool Call Component +function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState, stepNumber?: number }) { + const getStateIcon = () => { + switch (tool.state) { + case 'executing': + return + case 'completed': + return + case 'error': + return + default: + return
+ } + } + + const getStateColors = () => { + switch (tool.state) { + case 'executing': + return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100' + case 'completed': + return 'border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100' + case 'error': + return 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-100' + default: + return 'border-gray-200 bg-gray-50 text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100' + } + } + + const formatDuration = (duration?: number) => { + if (!duration) return '' + return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s` + } + + // Special handling for preview workflow + const isPreviewTool = tool.name === 'preview_workflow' + + if (isPreviewTool) { + return ( +
+
+
+ {tool.state === 'executing' && } + {tool.state === 'completed' && } + {tool.state === 'error' && } +
+
+
+ {tool.displayName || tool.name} +
+
+ {tool.state === 'executing' + ? 'Building workflow...' + : tool.state === 'completed' + ? 'Changes ready for review' + : 'Workflow generation failed' + } +
+
+ {tool.duration && tool.state === 'completed' && ( + + {formatDuration(tool.duration)} + + )} +
+
+ ) + } + + return ( +
+
+ {stepNumber && ( +
+ {stepNumber} +
+ )} + {getStateIcon()} +
+ + {tool.displayName || tool.name} + + {tool.duration && tool.state === 'completed' && ( + + {formatDuration(tool.duration)} + + )} + {tool.state === 'executing' && tool.progress && ( + + {tool.progress} + + )} +
+ ) +} + const ProfessionalMessage: FC = memo(({ message, isStreaming }) => { const { theme } = useTheme() const isUser = message.role === 'user' @@ -36,10 +262,11 @@ const ProfessionalMessage: FC = memo(({ message, isStr // Parse message content to separate text and tool calls const parsedContent = useMemo(() => { if (isAssistant && message.content) { - return parseMessageContent(message.content) + const result = parseMessageContent(message.content) + return result } return null - }, [isAssistant, message.content]) + }, [isAssistant, message.content, message.id]) // Get clean text content without tool call indicators const cleanTextContent = useMemo(() => { @@ -49,7 +276,30 @@ const ProfessionalMessage: FC = memo(({ message, isStr return message.content }, [isAssistant, message.content]) - // Custom components for react-markdown + // Group design approach tools if they exist + const designApproachGroup = useMemo(() => { + if (parsedContent?.inlineContent) { + const group = groupDesignApproachTools(parsedContent.inlineContent) + + // Only warn if we have design tools but no group (indicates a problem) + const designTools = parsedContent.inlineContent.filter(item => + item.type === 'tool_call' && item.toolCall && isDesignApproachTool(item.toolCall.name) + ) + + if (designTools.length >= 2 && !group) { + console.warn('Design approach group should exist but was not detected:', { + messageId: message.id, + designToolCount: designTools.length, + designToolNames: designTools.map(item => item.toolCall?.name) + }) + } + + return group + } + return null + }, [parsedContent?.inlineContent, message.id, message.content.length]) + + // Custom components for react-markdown with improved styling const markdownComponents = { code: ({ inline, className, children, ...props }: any) => { const match = /language-(\w+)/.exec(className || '') @@ -57,83 +307,67 @@ const ProfessionalMessage: FC = memo(({ message, isStr if (!inline && language) { return ( -
-
-
- - {String(children).replace(/\n$/, '')} - -
+
+
+ + {language} + + +
+
+ + {String(children).replace(/\n$/, '')} +
-
) } return ( {children} ) }, - pre: ({ children }: any) => ( -
- {children} -
- ), + pre: ({ children }: any) => children, h1: ({ children }: any) => ( -

+

{children}

), h2: ({ children }: any) => ( -

{children}

+

{children}

), h3: ({ children }: any) => ( -

{children}

+

{children}

), p: ({ children }: any) => ( -

+

{children}

), @@ -142,47 +376,47 @@ const ProfessionalMessage: FC = memo(({ message, isStr href={href} target='_blank' rel='noopener noreferrer' - className='break-all font-medium text-blue-600 underline decoration-blue-600/30 underline-offset-2 transition-colors hover:text-blue-800 hover:decoration-blue-600/60 dark:text-blue-400 dark:hover:text-blue-300' + className='font-medium text-blue-600 underline decoration-blue-600/30 underline-offset-2 transition-colors hover:text-blue-700 hover:decoration-blue-600/60 dark:text-blue-400 dark:hover:text-blue-300' > {children} ), ul: ({ children }: any) => ( -
    {children}
+
    {children}
), ol: ({ children }: any) => ( -
    {children}
+
    {children}
), li: ({ children }: any) => ( -
  • {children}
  • +
  • {children}
  • ), blockquote: ({ children }: any) => ( -
    +
    {children}
    ), table: ({ children }: any) => ( -
    +
    {children}
    ), th: ({ children }: any) => ( - + {children} ), td: ({ children }: any) => ( - {children} + {children} ), } if (isUser) { return ( -
    +
    -
    -
    -
    +
    +
    +
    {message.content}
    @@ -210,189 +444,133 @@ const ProfessionalMessage: FC = memo(({ message, isStr if (isAssistant) { return ( - <> - -
    -
    - {/* Main message content with icon */} -
    - {/* Bot icon aligned with bottom of message bubble */} -
    - -
    +
    +
    + {/* Main message content with icon */} +
    + {/* Bot icon */} +
    + +
    - {/* Message content */} -
    - {/* Inline content rendering - tool calls and text in order */} - {parsedContent?.inlineContent && parsedContent.inlineContent.length > 0 ? ( -
    - {parsedContent.inlineContent.map((item, index) => { - if (item.type === 'tool_call' && item.toolCall) { - const toolCall = item.toolCall + {/* Message content */} +
    + {/* Render inline content */} + {parsedContent?.inlineContent && parsedContent.inlineContent.length > 0 ? ( +
    + {parsedContent.inlineContent.map((item, index) => { + // If this index is within the design approach group range, skip individual rendering + if (designApproachGroup && + index >= designApproachGroup.groupStart && + index <= designApproachGroup.groupEnd) { + // Only render the group once at the start position + if (index === designApproachGroup.groupStart) { return ( -
    - {toolCall.state === 'detecting' && ( -
    -
    - - Detecting {toolCall.displayName || toolCall.name}... - -
    - )} - {toolCall.state === 'executing' && ( - - )} - {(toolCall.state === 'completed' || toolCall.state === 'error') && ( - - )} -
    + t.state === 'completed' || t.state === 'error')} + /> ) } - if (item.type === 'text' && item.content.trim()) { - return ( -
    -
    + } + + if (item.type === 'text' && item.content.trim()) { + return ( +
    +
    + - - {item.content} - -
    + {item.content} +
    - ) - } - return null - })} -
    - ) : ( - /* Fallback for empty content or streaming */ -
    - {cleanTextContent ? ( -
    - - {cleanTextContent} - -
    - ) : isStreaming ? ( -
    -
    -
    -
    -
    - Thinking... + ) + } + return null + })} +
    + ) : ( + /* Fallback for empty content or streaming */ +
    + {cleanTextContent ? ( +
    + + {cleanTextContent} + +
    + ) : isStreaming ? ( +
    +
    +
    +
    +
    - ) : null} -
    - )} -
    -
    - - {/* Timestamp and actions - separate from main content */} -
    - - {formatTimestamp(message.timestamp)} - - {cleanTextContent && ( - + Thinking... +
    + ) : null} +
    )}
    +
    - {/* Citations if available */} - {message.citations && message.citations.length > 0 && ( -
    -
    Sources:
    -
    - {message.citations.map((citation) => ( - - {citation.title} - - ))} -
    -
    + {/* Timestamp and actions */} +
    + + {formatTimestamp(message.timestamp)} + + {cleanTextContent && ( + )}
    + + {/* Citations if available */} + {message.citations && message.citations.length > 0 && ( +
    +
    Sources:
    +
    + {message.citations.map((citation) => ( + + {citation.title} + + ))} +
    +
    + )}
    - +
    ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index d4d0842dfcf..6979b8eee09 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -447,20 +447,24 @@ export const Copilot = forwardRef( {showCheckpoints ? ( ) : ( - - {messages.length === 0 ? ( - - ) : ( - messages.map((message) => ( - - )) - )} + +
    + {messages.length === 0 ? ( +
    + +
    + ) : ( + messages.map((message) => ( + + )) + )} +
    )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx index e4a1f8d24da..0511c6a412f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx @@ -207,7 +207,7 @@ export function ReviewFilesButton() {
    - Copilot has proposed changes + {blockCount} blocks, {edgeCount} connections diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 43fb59e267b..7ca00fc8ba0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -274,7 +274,7 @@ export function ReviewButton() { className='h-8 bg-purple-600 px-3 hover:bg-purple-700' > - Review Files + Review Changes
    diff --git a/apps/sim/lib/tool-call-parser.ts b/apps/sim/lib/tool-call-parser.ts index 9ba1bf30200..ada61e6cbaa 100644 --- a/apps/sim/lib/tool-call-parser.ts +++ b/apps/sim/lib/tool-call-parser.ts @@ -10,9 +10,9 @@ const TOOL_DISPLAY_NAMES: Record = { docs_search_internal: 'Searching documentation', get_user_workflow: 'Analyzing your workflow', get_blocks_and_tools: 'Getting context', - get_blocks_metadata: 'Getting context', - get_yaml_structure: 'Designing an approach', - preview_workflow: 'Generating workflow preview', + get_blocks_metadata: 'Diving deeper', + get_yaml_structure: 'Structuring your workflow', + preview_workflow: 'Preview Ready', // edit_workflow: 'Building your workflow', // Commented out - only preview is allowed } @@ -20,13 +20,20 @@ const TOOL_DISPLAY_NAMES: Record = { const TOOL_PAST_TENSE_NAMES: Record = { docs_search_internal: 'Searched documentation', get_user_workflow: 'Analyzed your workflow', - get_blocks_and_tools: 'Understood context', - get_blocks_metadata: 'Understood context', - get_yaml_structure: 'Designed an approach', - preview_workflow: 'Generated workflow preview', + get_blocks_and_tools: 'Got context', + get_blocks_metadata: 'Dove deeper', + get_yaml_structure: 'Structured your workflow', + preview_workflow: 'Built Workflow', // edit_workflow: 'Built your workflow', // Commented out - only preview is allowed } +// Tool grouping for "Designing an Approach" +const DESIGN_APPROACH_TOOLS = new Set([ + 'get_blocks_and_tools', + 'get_blocks_metadata', + 'get_yaml_structure' +]) + // Regex patterns to detect structured tool call events const TOOL_CALL_PATTERNS = { // Matches structured tool call events: __TOOL_CALL_EVENT__{"type":"..."}__TOOL_CALL_EVENT__ @@ -65,6 +72,71 @@ export function getToolDisplayName(toolId: string, isCompleted = false): string return TOOL_DISPLAY_NAMES[toolId] || toolId.replace(/_/g, ' ') } +/** + * Check if a tool is part of the "Designing an Approach" group + */ +export function isDesignApproachTool(toolId: string): boolean { + return DESIGN_APPROACH_TOOLS.has(toolId) +} + +/** + * Group consecutive design approach tools together + */ +export function groupDesignApproachTools(inlineContent: InlineContent[]): { + groupStart: number + groupEnd: number + groupedTools: ToolCallState[] +} | null { + if (inlineContent.length < 2) return null + + // Find consecutive design approach tools in the inline content + let groupStart = -1 + let groupEnd = -1 + const groupedTools: ToolCallState[] = [] + let consecutiveDesignTools = 0 + + for (let i = 0; i < inlineContent.length; i++) { + const item = inlineContent[i] + + if (item.type === 'tool_call' && item.toolCall && isDesignApproachTool(item.toolCall.name)) { + if (groupStart === -1) { + groupStart = i + } + groupEnd = i + groupedTools.push(item.toolCall) + consecutiveDesignTools++ + } else if (item.type === 'tool_call' && item.toolCall && !isDesignApproachTool(item.toolCall.name)) { + // Found a non-design tool call - if we have at least 2 design tools, stop the group + if (consecutiveDesignTools >= 2) { + break + } else { + // Reset if we haven't found enough consecutive design tools yet + groupStart = -1 + groupEnd = -1 + groupedTools.length = 0 + consecutiveDesignTools = 0 + } + } + // Note: Text content doesn't break the group, only non-design tool calls do + } + + // Only group if we have at least 2 consecutive design tools + if (groupStart !== -1 && groupEnd > groupStart && groupedTools.length >= 2) { + // Ensure all tools in the group are either completed or in error state for stability + const allToolsFinished = groupedTools.every(tool => + tool.state === 'completed' || tool.state === 'error' + ) + + return { + groupStart, + groupEnd, + groupedTools + } + } + + return null +} + /** * Parse structured tool call events from the stream and maintain state transitions */ @@ -88,28 +160,35 @@ export function parseToolCallEvents( switch (eventData.type) { case 'tool_call_detected': if (!toolCallsMap.has(eventData.toolCall.id)) { - toolCallsMap.set(eventData.toolCall.id, { + const toolCall = { ...eventData.toolCall, + displayName: getToolDisplayName(eventData.toolCall.name), // Ensure displayName is set startTime: Date.now(), - }) + } + toolCallsMap.set(eventData.toolCall.id, toolCall) } break case 'tool_calls_start': eventData.toolCalls.forEach((toolCall: any) => { if (!toolCallsMap.has(toolCall.id)) { - toolCallsMap.set(toolCall.id, { + const enhancedToolCall = { ...toolCall, + displayName: getToolDisplayName(toolCall.name), // Ensure displayName is set + state: 'executing' as const, // Explicitly set state for new tool calls startTime: Date.now(), - }) + } + toolCallsMap.set(toolCall.id, enhancedToolCall) } else { // Update existing tool call to executing state const existing = toolCallsMap.get(toolCall.id)! - toolCallsMap.set(toolCall.id, { + const updatedToolCall = { ...existing, - state: 'executing', + displayName: getToolDisplayName(existing.name), // Ensure displayName is set + state: 'executing' as const, parameters: toolCall.parameters || existing.parameters, - }) + } + toolCallsMap.set(toolCall.id, updatedToolCall) } }) break @@ -119,17 +198,26 @@ export function parseToolCallEvents( if (toolCallsMap.has(completedToolCall.id)) { // Update existing tool call to completed state const existing = toolCallsMap.get(completedToolCall.id)! - toolCallsMap.set(completedToolCall.id, { + const state = completedToolCall.state === 'error' ? 'error' : 'completed' + const updatedToolCall = { ...existing, - state: completedToolCall.state, + displayName: getToolDisplayName(existing.name, true), // Use past tense for completed + state: state as 'completed' | 'error', endTime: completedToolCall.endTime, duration: completedToolCall.duration, result: completedToolCall.result, error: completedToolCall.error, - }) + } + toolCallsMap.set(completedToolCall.id, updatedToolCall) } else { // Create new completed tool call if it doesn't exist - toolCallsMap.set(completedToolCall.id, completedToolCall) + const state = completedToolCall.state === 'error' ? 'error' : 'completed' + const enhancedToolCall = { + ...completedToolCall, + displayName: getToolDisplayName(completedToolCall.name, true), // Use past tense for completed + state: state as 'completed' | 'error', + } + toolCallsMap.set(completedToolCall.id, enhancedToolCall) } break } @@ -236,77 +324,121 @@ export function parseMessageContent( if (segment.match(/__TOOL_CALL_EVENT__.*?__TOOL_CALL_EVENT__/)) { // This is a tool call event - try { const eventMatch = segment.match(/__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/) if (eventMatch) { const eventData = JSON.parse(eventMatch[1]) - let toolCallId: string | undefined - let toolCall: ToolCallState | undefined - + + // Handle different event types switch (eventData.type) { case 'tool_call_detected': { const id = eventData.toolCall?.id - if (id) { - toolCallId = id - toolCall = toolCallsMap.get(id) + if (id && toolCallsMap.has(id) && !toolCallPositions.has(id)) { + // Add text buffer before tool call + if (currentTextBuffer.trim()) { + inlineContent.push({ + type: 'text', + content: currentTextBuffer.trim(), + }) + currentTextBuffer = '' + } + + // Add tool call + const toolCall = toolCallsMap.get(id)! + const newIndex = inlineContent.length + inlineContent.push({ + type: 'tool_call', + content: segment, + toolCall, + }) + toolCallPositions.set(id, newIndex) + } else if (id && toolCallsMap.has(id) && toolCallPositions.has(id)) { + // Update existing tool call in place + const existingIndex = toolCallPositions.get(id)! + if (inlineContent[existingIndex]?.type === 'tool_call') { + inlineContent[existingIndex].toolCall = toolCallsMap.get(id)! + } } break } + case 'tool_calls_start': { - // For multiple tool calls, use the first one - const id = eventData.toolCalls?.[0]?.id - if (id) { - toolCallId = id - toolCall = toolCallsMap.get(id) + // Handle each tool call in the start event + if (eventData.toolCalls && Array.isArray(eventData.toolCalls)) { + let hasAddedToolCalls = false + + eventData.toolCalls.forEach((tc: any) => { + const id = tc.id + if (id && toolCallsMap.has(id)) { + if (!toolCallPositions.has(id)) { + // First time seeing this tool call - add text buffer once + if (!hasAddedToolCalls && currentTextBuffer.trim()) { + inlineContent.push({ + type: 'text', + content: currentTextBuffer.trim(), + }) + currentTextBuffer = '' + hasAddedToolCalls = true + } + + // Add each tool call + const toolCallFromMap = toolCallsMap.get(id)! + const newIndex = inlineContent.length + inlineContent.push({ + type: 'tool_call', + content: segment, + toolCall: toolCallFromMap, + }) + toolCallPositions.set(id, newIndex) + } else { + // Update existing tool call in place + const existingIndex = toolCallPositions.get(id)! + if (inlineContent[existingIndex]?.type === 'tool_call') { + inlineContent[existingIndex].toolCall = toolCallsMap.get(id)! + } + } + } + }) } break } + case 'tool_call_complete': { const id = eventData.toolCall?.id - if (id) { - toolCallId = id - toolCall = toolCallsMap.get(id) + if (id && toolCallsMap.has(id)) { + if (!toolCallPositions.has(id)) { + // Add text buffer before tool call + if (currentTextBuffer.trim()) { + inlineContent.push({ + type: 'text', + content: currentTextBuffer.trim(), + }) + currentTextBuffer = '' + } + + // Add tool call + const toolCall = toolCallsMap.get(id)! + const newIndex = inlineContent.length + inlineContent.push({ + type: 'tool_call', + content: segment, + toolCall, + }) + toolCallPositions.set(id, newIndex) + } else { + // Update existing tool call in place + const existingIndex = toolCallPositions.get(id)! + if (inlineContent[existingIndex]?.type === 'tool_call') { + inlineContent[existingIndex].toolCall = toolCallsMap.get(id)! + } + } } break } } - - if (toolCallId && toolCall) { - if (toolCallPositions.has(toolCallId)) { - // Update existing tool call in place - const existingIndex = toolCallPositions.get(toolCallId)! - if ( - inlineContent[existingIndex] && - inlineContent[existingIndex].type === 'tool_call' - ) { - inlineContent[existingIndex].toolCall = toolCall - } - } else { - // First time seeing this tool call - add accumulated text first - if (currentTextBuffer.trim()) { - inlineContent.push({ - type: 'text', - content: currentTextBuffer.trim(), - }) - currentTextBuffer = '' - } - - // Add new tool call and remember its position - const newIndex = inlineContent.length - inlineContent.push({ - type: 'tool_call', - content: segment, - toolCall, - }) - toolCallPositions.set(toolCallId, newIndex) - } - } else { - // If parsing fails or no tool call found, treat as text - currentTextBuffer += segment - } } } catch (error) { + console.warn('Failed to parse tool call event:', error) // If parsing fails, treat as text currentTextBuffer += segment } @@ -324,6 +456,25 @@ export function parseMessageContent( }) } + // FALLBACK: Ensure all tool calls from toolCallsMap are included in inlineContent + // This prevents tool calls from disappearing if parsing failed to include them + const missingToolCalls: ToolCallState[] = [] + for (const [id, toolCall] of toolCallsMap.entries()) { + if (!toolCallPositions.has(id)) { + missingToolCalls.push(toolCall) + console.warn('Tool call was not included in inline content, adding as fallback:', toolCall.name, toolCall.id) + } + } + + // Add missing tool calls at the end + missingToolCalls.forEach((toolCall) => { + inlineContent.push({ + type: 'tool_call', + content: `__TOOL_CALL_EVENT__{"type":"tool_call_complete","toolCall":${JSON.stringify(toolCall)}}__TOOL_CALL_EVENT__`, + toolCall, + }) + }) + // Create clean text content for fallback const cleanTextContent = content.replace(TOOL_CALL_PATTERNS.toolCallEvent, '').trim() From 6ccf5281b9a11faa81176712491dd00cb62496f9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 19:30:53 -0700 Subject: [PATCH 011/184] Ui --- .../[workspaceId]/w/[workflowId]/components/review-button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 7ca00fc8ba0..16b4b4fe3c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -258,7 +258,7 @@ export function ReviewButton() { return ( <> {/* Simple button at bottom center */} -
    +
    From 6fdf9979a73e983fbd26539c648ac628557c9c7c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 22 Jul 2025 19:37:13 -0700 Subject: [PATCH 012/184] Update --- .../[workflowId]/components/review-button.tsx | 79 +++++++++++++------ 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 16b4b4fe3c1..74b3be59496 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -16,7 +16,9 @@ const logger = createLogger('ReviewButton') function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => boolean) { if (!messages.length) return null - // Go through messages in reverse order (newest first) + const foundPreviews: { toolCallId: string; messageIndex: number; workflowState: any; yamlContent: string; description?: string }[] = [] + + // Go through messages in reverse order (newest first) to find all unseen previews for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] if (message.role !== 'assistant' || !message.content) continue @@ -50,12 +52,13 @@ function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => } if (workflowState && yamlContent) { - return { + foundPreviews.push({ toolCallId: toolCallEvent.toolCall.id, + messageIndex: i, workflowState, yamlContent, description, - } + }) } } } catch (error) { @@ -64,7 +67,23 @@ function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => } } - return null + if (foundPreviews.length === 0) { + return null + } + + // Sort by message index (newest first) + foundPreviews.sort((a, b) => b.messageIndex - a.messageIndex) + + // Return both the latest preview and all older preview IDs to invalidate + return { + latestPreview: { + toolCallId: foundPreviews[0].toolCallId, + workflowState: foundPreviews[0].workflowState, + yamlContent: foundPreviews[0].yamlContent, + description: foundPreviews[0].description, + }, + olderPreviewIds: foundPreviews.slice(1).map(p => p.toolCallId) + } } // Dummy functions for backward compatibility @@ -95,19 +114,29 @@ export function ReviewButton() { const latestPreview = useMemo(() => { console.log('useMemo: Checking for latest unseen preview, seenToolCallIds size:', seenToolCallIds.size) const preview = getLatestUnseenPreview(messages, isToolCallSeen) - console.log('useMemo: Found preview:', !!preview, preview?.toolCallId) + console.log('useMemo: Found preview:', !!preview, preview?.latestPreview?.toolCallId) return preview }, [messages, isToolCallSeen, seenToolCallIds]) + // Invalidate older previews when a new one is detected + useEffect(() => { + if (latestPreview && latestPreview.olderPreviewIds && latestPreview.olderPreviewIds.length > 0) { + console.log('Invalidating older previews:', latestPreview.olderPreviewIds) + latestPreview.olderPreviewIds.forEach(id => { + markToolCallAsSeen(id) + }) + } + }, [latestPreview?.latestPreview?.toolCallId, latestPreview?.olderPreviewIds, markToolCallAsSeen]) + // Debug logging console.log('ReviewButton render:', { - hasLatestPreview: !!latestPreview, + hasLatestPreview: !!latestPreview?.latestPreview, activeWorkflowId, messageCount: messages.length }) // Only show if there's a real preview from copilot - if (!latestPreview) { + if (!latestPreview?.latestPreview) { return null } @@ -116,7 +145,7 @@ export function ReviewButton() { } const handleApply = async () => { - if (!activeWorkflowId || !latestPreview.yamlContent) { + if (!activeWorkflowId || !latestPreview.latestPreview.yamlContent) { logger.error('No active workflow or YAML content') return } @@ -126,7 +155,7 @@ export function ReviewButton() { logger.info('Applying preview to current workflow', { workflowId: activeWorkflowId, - yamlLength: latestPreview.yamlContent.length, + yamlLength: latestPreview.latestPreview.yamlContent.length, }) // Use the existing YAML endpoint to apply the changes @@ -136,8 +165,8 @@ export function ReviewButton() { 'Content-Type': 'application/json', }, body: JSON.stringify({ - yamlContent: latestPreview.yamlContent, - description: latestPreview.description || 'Applied copilot proposal', + yamlContent: latestPreview.latestPreview.yamlContent, + description: latestPreview.latestPreview.description || 'Applied copilot proposal', source: 'copilot', applyAutoLayout: true, createCheckpoint: true, @@ -156,8 +185,8 @@ export function ReviewButton() { } logger.info('Successfully applied preview to workflow') - console.log('Marking tool call as seen:', latestPreview.toolCallId) - markToolCallAsSeen(latestPreview.toolCallId) + console.log('Marking tool call as seen:', latestPreview.latestPreview.toolCallId) + markToolCallAsSeen(latestPreview.latestPreview.toolCallId) console.log('Tool call marked as seen, closing modal') setShowModal(false) @@ -171,7 +200,7 @@ export function ReviewButton() { } const handleSaveAsNew = async (name: string) => { - if (!latestPreview.yamlContent) { + if (!latestPreview.latestPreview.yamlContent) { logger.error('No YAML content to save') return } @@ -181,13 +210,13 @@ export function ReviewButton() { logger.info('Creating new workflow from preview', { name, - yamlLength: latestPreview.yamlContent.length, + yamlLength: latestPreview.latestPreview.yamlContent.length, }) // First create a new workflow const newWorkflowId = await createWorkflow({ name, - description: latestPreview.description, + description: latestPreview.latestPreview.description, workspaceId, }) @@ -202,8 +231,8 @@ export function ReviewButton() { 'Content-Type': 'application/json', }, body: JSON.stringify({ - yamlContent: latestPreview.yamlContent, - description: latestPreview.description || 'Created from copilot proposal', + yamlContent: latestPreview.latestPreview.yamlContent, + description: latestPreview.latestPreview.description || 'Created from copilot proposal', source: 'copilot', applyAutoLayout: true, createCheckpoint: false, @@ -222,7 +251,7 @@ export function ReviewButton() { } logger.info('Successfully created new workflow from preview') - markToolCallAsSeen(latestPreview.toolCallId) + markToolCallAsSeen(latestPreview.latestPreview.toolCallId) setShowModal(false) // Continue the copilot conversation with save as new message @@ -235,11 +264,11 @@ export function ReviewButton() { } const handleReject = async () => { - if (!latestPreview) return + if (!latestPreview?.latestPreview) return try { setIsProcessing(true) - markToolCallAsSeen(latestPreview.toolCallId) + markToolCallAsSeen(latestPreview.latestPreview.toolCallId) setShowModal(false) // Continue the copilot conversation with rejection message @@ -281,13 +310,13 @@ export function ReviewButton() {
    {/* Sandbox Modal */} - {showModal && latestPreview && ( + {showModal && latestPreview?.latestPreview && ( Date: Tue, 22 Jul 2025 20:30:46 -0700 Subject: [PATCH 013/184] Changes --- .../components/control-bar/control-bar.tsx | 40 +-- .../panel/components/copilot/copilot.tsx | 50 +++- .../preview-overlay/review-files-button.tsx | 221 +++++++++++++-- .../[workflowId]/components/review-button.tsx | 258 +++++++++++++++--- .../w/[workflowId]/utils/auto-layout.ts | 219 +++++++++++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 43 +-- apps/sim/lib/copilot/config.ts | 6 +- apps/sim/stores/copilot/store.ts | 57 ++-- 8 files changed, 734 insertions(+), 160 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 92ee9dc7169..77312fe8c94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -551,39 +551,17 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { setIsAutoLayouting(true) try { - const response = await fetch(`/api/workflows/${activeWorkflowId}/autolayout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, - vertical: 400, - layer: 700, - }, - alignment: 'center', - padding: { - x: 250, - y: 250, - }, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Auto layout failed:', errorData) + // Use the shared auto layout utility for immediate frontend updates + const { applyAutoLayoutAndUpdateStore } = await import('../../utils/auto-layout') + + const result = await applyAutoLayoutAndUpdateStore(activeWorkflowId!) + + if (result.success) { + logger.info('Auto layout completed successfully') + } else { + logger.error('Auto layout failed:', result.error) // You could add a toast notification here if available - return } - - const result = await response.json() - logger.info('Auto layout completed successfully:', result) - - // Refresh the workflow data to show the new positions - // This will be handled automatically by the real-time system } catch (error) { logger.error('Auto layout error:', error) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 6979b8eee09..724bd82d1f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -21,7 +21,7 @@ import { CopilotWelcome } from './components/welcome/welcome' import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' import { usePreviewStore } from '@/stores/copilot/preview-store' -import { setLatestPreview, clearLatestPreview } from '../../../review-button' +import { clearLatestPreview, getLatestUnseenPreview } from '../../../review-button' const logger = createLogger('Copilot') @@ -60,7 +60,7 @@ export const Copilot = forwardRef( const { sandboxState, showSandbox, closeSandbox, applyToCurrentWorkflow, saveAsNewWorkflow } = useCopilotSandbox() // Use preview store to track seen previews - const { scanAndMarkExistingPreviews, isToolCallSeen } = usePreviewStore() + const { scanAndMarkExistingPreviews, isToolCallSeen, markToolCallAsSeen } = usePreviewStore() // Use the new copilot store const { @@ -118,15 +118,57 @@ export const Copilot = forwardRef( }, [messages]) // Scan existing messages and mark preview tool calls as seen ONLY once per chat session + // But preserve any currently visible preview to prevent race conditions useEffect(() => { const chatId = currentChat?.id || 'no-chat' if (messages.length > 0 && scannedChatRef.current !== chatId) { console.log('Scanning existing messages for chat:', chatId, 'message count:', messages.length) - scanAndMarkExistingPreviews(messages) + + // Before scanning, check if there's currently a visible preview + // We'll exclude this from being marked as seen during scanning + const currentlyVisiblePreview = getLatestUnseenPreview(messages, isToolCallSeen) + const protectedToolCallId = currentlyVisiblePreview?.latestPreview?.toolCallId + + console.log('Protecting currently visible preview during scan:', protectedToolCallId) + + // Create a modified version of scanAndMarkExistingPreviews that excludes the protected ID + const toolCallIds = new Set() + + messages.forEach((message) => { + if (message.role === 'assistant' && message.content) { + const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g + let match + + while ((match = previewToolCallPattern.exec(message.content)) !== null) { + try { + const toolCallEvent = JSON.parse(match[1]) + if ( + toolCallEvent.type === 'tool_call_complete' && + toolCallEvent.toolCall?.name === 'preview_workflow' && + toolCallEvent.toolCall?.id && + toolCallEvent.toolCall?.id !== protectedToolCallId // Don't mark the currently visible one as seen + ) { + toolCallIds.add(toolCallEvent.toolCall.id) + } + } catch (error) { + console.warn('Failed to parse tool call event while scanning:', error) + } + } + } + }) + + // Mark the non-protected tool calls as seen + if (toolCallIds.size > 0) { + console.log('Marking existing preview tool calls as seen (excluding protected):', Array.from(toolCallIds)) + toolCallIds.forEach(id => { + markToolCallAsSeen(id) + }) + } + scannedChatRef.current = chatId } - }, [messages, currentChat?.id, scanAndMarkExistingPreviews]) // Run when messages change, but only scan once per chat + }, [messages, currentChat?.id, isToolCallSeen]) // Added isToolCallSeen to dependencies // Watch for completed preview_workflow tool calls and show sandbox modal useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx index 0511c6a412f..cc4acab0182 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx @@ -45,49 +45,212 @@ export function ReviewFilesButton() { try { setIsProcessing(true) - logger.info('Applying preview to current workflow', { - workflowId: activeWorkflowId, + logger.info('Applying preview to current workflow (store-first)', { previewId: pendingPreview?.id, yamlLength: pendingPreview?.yamlContent.length, }) - // Use the existing YAML endpoint to apply the changes - const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: pendingPreview?.yamlContent, - description: pendingPreview?.description || 'Applied copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: true, // Always create checkpoints for copilot changes - }), - }) + // STEP 1: Parse YAML and update local store immediately + try { + // Import the YAML parser + const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') + const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') + const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + // Parse YAML content + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(pendingPreview.yamlContent) + + if (!yamlWorkflow || parseErrors.length > 0) { + throw new Error(`Failed to parse YAML: ${parseErrors.join(', ')}`) + } + + // Convert YAML to workflow format + const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) + + if (convertErrors.length > 0) { + throw new Error(`Failed to convert YAML: ${convertErrors.join(', ')}`) + } + + // Convert ImportedBlocks to workflow store format + const { getBlock } = await import('@/blocks') + const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') + + const workflowBlocks: Record = {} + const workflowEdges: any[] = [] + const blockIdMapping = new Map() + + // Process blocks - convert from array to record format + for (const block of blocks) { + const blockId = block.id + blockIdMapping.set(block.id, blockId) + + const blockConfig = getBlock(block.type) + + if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) { + // Handle loop/parallel blocks + workflowBlocks[blockId] = { + id: blockId, + type: block.type, + name: block.name, + position: block.position, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: (block as any).data || {}, + } + } else if (blockConfig) { + // Handle regular blocks with proper subBlocks setup + const subBlocks: Record = {} + + // Set up subBlocks from block configuration + blockConfig.subBlocks.forEach((subBlock) => { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: (block as any).inputs?.[subBlock.id] || null, + } + }) + + workflowBlocks[blockId] = { + id: blockId, + type: block.type, + name: block.name, + position: block.position, + subBlocks, + outputs: (block as any).outputs || {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: (block as any).data || {}, + } + } + } + + // Process edges + for (const edge of edges) { + workflowEdges.push({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default', + }) + } + + // Generate loops and parallels + const loops = generateLoopBlocks(workflowBlocks) + const parallels = generateParallelBlocks(workflowBlocks) + + // Apply auto layout using the shared utility + const { applyAutoLayoutToBlocks } = await import('../../utils/auto-layout') + const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) + + const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks + + if (layoutResult.success) { + logger.info('Successfully applied auto layout to preview blocks') + } else { + logger.warn('Auto layout failed, using original positions:', layoutResult.error) + } + + // Update workflow store immediately + const workflowStore = useWorkflowStore.getState() + const newWorkflowState = { + blocks: layoutedBlocks, + edges: workflowEdges, + loops, + parallels, + lastSaved: Date.now(), + isDeployed: workflowStore.isDeployed, + deployedAt: workflowStore.deployedAt, + deploymentStatuses: workflowStore.deploymentStatuses, + hasActiveWebhook: workflowStore.hasActiveWebhook, + } + + useWorkflowStore.setState(newWorkflowState) + + // Extract and update subblock values + const subblockValues: Record> = {} + Object.entries(layoutedBlocks).forEach(([blockId, block]) => { + subblockValues[blockId] = {} + Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = (subblock as any).value + }) + }) + + // Update subblock store + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues, + }, + })) + + logger.info('Successfully updated local stores with preview changes') + + } catch (storeError) { + logger.error('Failed to update local stores:', storeError) + throw new Error(`Store update failed: ${storeError instanceof Error ? storeError.message : 'Unknown error'}`) } - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to apply workflow changes') + // STEP 2: Save to database (in background, don't await to keep UI responsive) + const saveToDatabase = async () => { + try { + const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: pendingPreview?.yamlContent, + description: pendingPreview?.description || 'Applied copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: true, // Always create checkpoints for copilot changes + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to apply workflow changes') + } + + logger.info('Successfully saved preview to database:', { + previewId: pendingPreview?.id, + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + } catch (dbError) { + logger.error('Failed to save preview to database (store already updated):', dbError) + // Don't throw - the store is already updated, so the UI is correct + // The socket will eventually sync when the database is available + } } + // Save to database without blocking UI + saveToDatabase() + + // STEP 3: Only dismiss preview after successful store update (user has accepted) if (pendingPreview) { logger.info('Accepting preview:', { previewId: pendingPreview.id }) previewStore.acceptPreview(pendingPreview.id) logger.info('Preview accepted, closing modal') } setShowModal(false) - - logger.info('Successfully applied preview to current workflow:', { + + logger.info('Successfully applied preview to current workflow (store-first):', { previewId: pendingPreview?.id, - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, }) } catch (error) { @@ -96,7 +259,7 @@ export function ReviewFilesButton() { } finally { setIsProcessing(false) } - }, [activeWorkflowId, pendingPreview, acceptPreview]) + }, [activeWorkflowId, pendingPreview, previewStore]) const handleSaveAsNewWorkflow = useCallback(async (name: string) => { if (!pendingPreview?.yamlContent) { @@ -207,7 +370,7 @@ export function ReviewFilesButton() {
    - + Copilot has proposed changes {blockCount} blocks, {edgeCount} connections diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 74b3be59496..49d37fb9ea0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useParams } from 'next/navigation' import { Eye, FileText } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -13,7 +13,7 @@ import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('ReviewButton') // Helper function to extract preview data from messages -function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => boolean) { +export function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => boolean) { if (!messages.length) return null const foundPreviews: { toolCallId: string; messageIndex: number; workflowState: any; yamlContent: string; description?: string }[] = [] @@ -109,6 +109,10 @@ export function ReviewButton() { ) const [showModal, setShowModal] = useState(false) const [isProcessing, setIsProcessing] = useState(false) + + // Add debounce timer ref to prevent premature invalidation + const invalidationTimerRef = useRef(null) + const lastToolCallIdRef = useRef(null) // Get the latest unseen preview from messages const latestPreview = useMemo(() => { @@ -118,13 +122,41 @@ export function ReviewButton() { return preview }, [messages, isToolCallSeen, seenToolCallIds]) - // Invalidate older previews when a new one is detected + // Debounced invalidation of older previews when a new one is detected + // Add a 5-second delay to give users time to see and interact with the button useEffect(() => { + // Clear existing timer + if (invalidationTimerRef.current) { + clearTimeout(invalidationTimerRef.current) + invalidationTimerRef.current = null + } + if (latestPreview && latestPreview.olderPreviewIds && latestPreview.olderPreviewIds.length > 0) { - console.log('Invalidating older previews:', latestPreview.olderPreviewIds) - latestPreview.olderPreviewIds.forEach(id => { - markToolCallAsSeen(id) - }) + // Check if this is actually a new preview (different from the last one) + const currentToolCallId = latestPreview.latestPreview?.toolCallId + const isNewPreview = currentToolCallId !== lastToolCallIdRef.current + + if (isNewPreview && currentToolCallId) { + console.log('New preview detected, scheduling invalidation of older previews in 5 seconds:', latestPreview.olderPreviewIds) + lastToolCallIdRef.current = currentToolCallId + + // Set a timer to invalidate older previews after 5 seconds + invalidationTimerRef.current = setTimeout(() => { + console.log('Invalidating older previews after delay:', latestPreview.olderPreviewIds) + latestPreview.olderPreviewIds.forEach(id => { + markToolCallAsSeen(id) + }) + invalidationTimerRef.current = null + }, 5000) // 5 second delay + } + } + + // Cleanup function + return () => { + if (invalidationTimerRef.current) { + clearTimeout(invalidationTimerRef.current) + invalidationTimerRef.current = null + } } }, [latestPreview?.latestPreview?.toolCallId, latestPreview?.olderPreviewIds, markToolCallAsSeen]) @@ -145,46 +177,202 @@ export function ReviewButton() { } const handleApply = async () => { - if (!activeWorkflowId || !latestPreview.latestPreview.yamlContent) { - logger.error('No active workflow or YAML content') - return - } - + if (!latestPreview?.latestPreview) return + try { setIsProcessing(true) - logger.info('Applying preview to current workflow', { + logger.info('Applying preview to current workflow (store-first)', { workflowId: activeWorkflowId, yamlLength: latestPreview.latestPreview.yamlContent.length, }) - // Use the existing YAML endpoint to apply the changes - const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: latestPreview.latestPreview.yamlContent, - description: latestPreview.latestPreview.description || 'Applied copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: true, - }), - }) + // STEP 1: Parse YAML and update local store immediately + try { + // Import the necessary modules + const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') + const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') + const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') + const { getBlock } = await import('@/blocks') + const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') + + // Parse YAML content + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(latestPreview.latestPreview.yamlContent) + + if (!yamlWorkflow || parseErrors.length > 0) { + throw new Error(`Failed to parse YAML: ${parseErrors.join(', ')}`) + } - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + // Convert YAML to workflow format + const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) + + if (convertErrors.length > 0) { + throw new Error(`Failed to convert YAML: ${convertErrors.join(', ')}`) + } + + // Convert ImportedBlocks to workflow store format + const workflowBlocks: Record = {} + const workflowEdges: any[] = [] + + // Process blocks - convert from array to record format + for (const block of blocks) { + const blockId = block.id + const blockConfig = getBlock(block.type) + + if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) { + // Handle loop/parallel blocks + workflowBlocks[blockId] = { + id: blockId, + type: block.type, + name: block.name, + position: block.position, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: (block as any).data || {}, + } + } else if (blockConfig) { + // Handle regular blocks with proper subBlocks setup + const subBlocks: Record = {} + + // Set up subBlocks from block configuration + blockConfig.subBlocks.forEach((subBlock) => { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: (block as any).inputs?.[subBlock.id] || null, + } + }) + + workflowBlocks[blockId] = { + id: blockId, + type: block.type, + name: block.name, + position: block.position, + subBlocks, + outputs: (block as any).outputs || {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: (block as any).data || {}, + } + } + } + + // Process edges + for (const edge of edges) { + workflowEdges.push({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default', + }) + } + + // Generate loops and parallels + const loops = generateLoopBlocks(workflowBlocks) + const parallels = generateParallelBlocks(workflowBlocks) + + // Apply auto layout using the shared utility + const { applyAutoLayoutToBlocks } = await import('../utils/auto-layout') + const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) + + const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks + + if (layoutResult.success) { + logger.info('Successfully applied auto layout to preview blocks') + } else { + logger.warn('Auto layout failed, using original positions:', layoutResult.error) + } + + // Update workflow store immediately + const workflowStore = useWorkflowStore.getState() + const newWorkflowState = { + blocks: layoutedBlocks, + edges: workflowEdges, + loops, + parallels, + lastSaved: Date.now(), + isDeployed: workflowStore.isDeployed, + deployedAt: workflowStore.deployedAt, + deploymentStatuses: workflowStore.deploymentStatuses, + hasActiveWebhook: workflowStore.hasActiveWebhook, + } + + useWorkflowStore.setState(newWorkflowState) + + // Extract and update subblock values + const subblockValues: Record> = {} + Object.entries(layoutedBlocks).forEach(([blockId, block]) => { + subblockValues[blockId] = {} + Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = (subblock as any).value + }) + }) + + // Update subblock store + if (activeWorkflowId) { + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues, + }, + })) + } + + logger.info('Successfully updated local stores with preview changes') + + } catch (storeError) { + logger.error('Failed to update local stores:', storeError) + throw new Error(`Store update failed: ${storeError instanceof Error ? storeError.message : 'Unknown error'}`) } - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to apply workflow changes') + // STEP 2: Save to database (in background, don't await to keep UI responsive) + const saveToDatabase = async () => { + try { + const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: latestPreview.latestPreview.yamlContent, + description: latestPreview.latestPreview.description || 'Applied copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: true, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to apply workflow changes') + } + + logger.info('Successfully saved preview to database') + } catch (dbError) { + logger.error('Failed to save preview to database (store already updated):', dbError) + // Don't throw - the store is already updated, so the UI is correct + // The socket will eventually sync when the database is available + } } - logger.info('Successfully applied preview to workflow') + // Save to database without blocking UI + saveToDatabase() + + // STEP 3: Only dismiss preview after successful store update (user has accepted) console.log('Marking tool call as seen:', latestPreview.latestPreview.toolCallId) markToolCallAsSeen(latestPreview.latestPreview.toolCallId) console.log('Tool call marked as seen, closing modal') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts new file mode 100644 index 00000000000..1bf135fdecc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts @@ -0,0 +1,219 @@ +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('AutoLayoutUtils') + +/** + * Auto layout options interface + */ +export interface AutoLayoutOptions { + strategy?: 'smart' | 'hierarchical' | 'layered' | 'force-directed' + direction?: 'horizontal' | 'vertical' | 'auto' + spacing?: { + horizontal?: number + vertical?: number + layer?: number + } + alignment?: 'start' | 'center' | 'end' + padding?: { + x?: number + y?: number + } +} + +/** + * Default auto layout options + */ +const DEFAULT_AUTO_LAYOUT_OPTIONS: AutoLayoutOptions = { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700, + }, + alignment: 'center', + padding: { + x: 250, + y: 250, + }, +} + +/** + * Apply auto layout to workflow blocks and update the store + */ +export async function applyAutoLayoutToWorkflow( + workflowId: string, + blocks: Record, + edges: any[], + options: AutoLayoutOptions = {} +): Promise<{ + success: boolean + layoutedBlocks?: Record + error?: string +}> { + try { + logger.info('Applying auto layout to workflow', { + workflowId, + blockCount: Object.keys(blocks).length, + edgeCount: edges.length, + }) + + // Import auto layout service + const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') + + // Merge with default options and ensure all required properties are present + const layoutOptions = { + strategy: options.strategy || DEFAULT_AUTO_LAYOUT_OPTIONS.strategy!, + direction: options.direction || DEFAULT_AUTO_LAYOUT_OPTIONS.direction!, + spacing: { + horizontal: options.spacing?.horizontal || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing!.horizontal!, + vertical: options.spacing?.vertical || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing!.vertical!, + layer: options.spacing?.layer || DEFAULT_AUTO_LAYOUT_OPTIONS.spacing!.layer!, + }, + alignment: options.alignment || DEFAULT_AUTO_LAYOUT_OPTIONS.alignment!, + padding: { + x: options.padding?.x || DEFAULT_AUTO_LAYOUT_OPTIONS.padding!.x!, + y: options.padding?.y || DEFAULT_AUTO_LAYOUT_OPTIONS.padding!.y!, + }, + } + + // Apply auto layout + const layoutedBlocks = await autoLayoutWorkflow(blocks, edges, layoutOptions) + + logger.info('Successfully applied auto layout', { + workflowId, + originalBlockCount: Object.keys(blocks).length, + layoutedBlockCount: Object.keys(layoutedBlocks).length, + }) + + return { + success: true, + layoutedBlocks, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown auto layout error' + logger.error('Auto layout failed:', { workflowId, error: errorMessage }) + + return { + success: false, + error: errorMessage, + } + } +} + +/** + * Apply auto layout and update the workflow store immediately + */ +export async function applyAutoLayoutAndUpdateStore( + workflowId: string, + options: AutoLayoutOptions = {} +): Promise<{ + success: boolean + error?: string +}> { + try { + // Import workflow store + const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') + + const workflowStore = useWorkflowStore.getState() + const { blocks, edges } = workflowStore + + if (Object.keys(blocks).length === 0) { + logger.warn('No blocks to layout', { workflowId }) + return { success: false, error: 'No blocks to layout' } + } + + // Apply auto layout + const result = await applyAutoLayoutToWorkflow(workflowId, blocks, edges, options) + + if (!result.success || !result.layoutedBlocks) { + return { success: false, error: result.error } + } + + // Update workflow store immediately with new positions + const newWorkflowState = { + ...workflowStore, + blocks: result.layoutedBlocks, + lastSaved: Date.now(), + } + + useWorkflowStore.setState(newWorkflowState) + + logger.info('Successfully updated workflow store with auto layout', { workflowId }) + + // Save to database in background (don't await to keep UI responsive) + saveAutoLayoutToDatabase(workflowId, options) + + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown store update error' + logger.error('Failed to update store with auto layout:', { workflowId, error: errorMessage }) + + return { + success: false, + error: errorMessage, + } + } +} + +/** + * Save auto layout changes to database in background + */ +async function saveAutoLayoutToDatabase( + workflowId: string, + options: AutoLayoutOptions = {} +): Promise { + try { + logger.info('Saving auto layout to database', { workflowId }) + + const response = await fetch(`/api/workflows/${workflowId}/autolayout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + strategy: options.strategy || DEFAULT_AUTO_LAYOUT_OPTIONS.strategy, + direction: options.direction || DEFAULT_AUTO_LAYOUT_OPTIONS.direction, + spacing: { + ...DEFAULT_AUTO_LAYOUT_OPTIONS.spacing, + ...options.spacing, + }, + alignment: options.alignment || DEFAULT_AUTO_LAYOUT_OPTIONS.alignment, + padding: { + ...DEFAULT_AUTO_LAYOUT_OPTIONS.padding, + ...options.padding, + }, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`) + } + + const result = await response.json() + logger.info('Successfully saved auto layout to database', { workflowId, result }) + } catch (error) { + logger.error('Failed to save auto layout to database (store already updated):', { + workflowId, + error: error instanceof Error ? error.message : 'Unknown error', + }) + // Don't throw - the store is already updated, so the UI is correct + // The socket will eventually sync when the database is available + } +} + +/** + * Apply auto layout to a specific set of blocks (used by copilot preview) + */ +export async function applyAutoLayoutToBlocks( + blocks: Record, + edges: any[], + options: AutoLayoutOptions = {} +): Promise<{ + success: boolean + layoutedBlocks?: Record + error?: string +}> { + return applyAutoLayoutToWorkflow('preview', blocks, edges, options) +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 24905687bdc..9a18de11c04 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -215,47 +215,26 @@ const WorkflowContent = React.memo(() => { [getNodes] ) - // Auto-layout handler - now uses the centralized backend API + // Auto-layout handler - now uses frontend auto layout for immediate updates const handleAutoLayout = useCallback(async () => { if (Object.keys(blocks).length === 0) return try { - const response = await fetch(`/api/workflows/${activeWorkflowId}/autolayout`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, - vertical: 400, - layer: 700, - }, - alignment: 'center', - padding: { - x: 250, - y: 250, - }, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Auto layout failed:', errorData) - return - } - - const result = await response.json() - logger.info('Auto layout completed successfully:', result) + // Use the shared auto layout utility for immediate frontend updates + const { applyAutoLayoutAndUpdateStore } = await import('./utils/auto-layout') + + const result = await applyAutoLayoutAndUpdateStore(activeWorkflowId!) - // The real-time system will automatically update the UI with new positions + if (result.success) { + logger.info('Auto layout completed successfully') + } else { + logger.error('Auto layout failed:', result.error) + } } catch (error) { logger.error('Auto layout error:', error) } - }, [activeWorkflowId]) + }, [activeWorkflowId, blocks]) const debouncedAutoLayout = useCallback(() => { const debounceTimer = setTimeout(() => { diff --git a/apps/sim/lib/copilot/config.ts b/apps/sim/lib/copilot/config.ts index e678e613dc8..27c5fd54d14 100644 --- a/apps/sim/lib/copilot/config.ts +++ b/apps/sim/lib/copilot/config.ts @@ -118,19 +118,19 @@ function parseBooleanEnv(value: string | undefined): boolean | null { /** * Default copilot configuration - * Uses Claude 4 Sonnet as requested + * Uses Claude 3.7 Sonnet as requested */ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { chat: { defaultProvider: 'anthropic', - defaultModel: 'claude-sonnet-4-0', + defaultModel: 'claude-3-7-sonnet-latest', temperature: 0.1, maxTokens: 4000, systemPrompt: AGENT_MODE_SYSTEM_PROMPT, }, rag: { defaultProvider: 'anthropic', - defaultModel: 'claude-sonnet-4-0', + defaultModel: 'claude-3-7-sonnet-latest', temperature: 0.1, maxTokens: 2000, embeddingModel: 'text-embedding-3-small', diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 0560f69dc32..01a2e94a250 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -551,34 +551,39 @@ export const useCopilotStore = create()( // Skip this check during continuation since the existing content already contains the tool call const shouldStopStreaming = !isContinuation && checkForPreviewToolCompletion(accumulatedContent) if (shouldStopStreaming) { - logger.info('Preview workflow tool completed - stopping stream') - streamComplete = true + logger.info('Preview workflow tool completed - stopping stream with small delay to allow UI updates') - // Final update with current content - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent } : msg - ), - isSendingMessage: false, - })) - - // Save chat immediately when stopping for preview - const chatIdToSave = newChatId || get().currentChat?.id - if (chatIdToSave) { - try { - await get().saveChatMessages(chatIdToSave) - } catch (saveError) { - logger.warn(`Chat save failed after preview stop: ${saveError}`) + // Add a small delay to allow the review button to appear before processing + setTimeout(() => { + streamComplete = true + + // Final update with current content + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent } : msg + ), + isSendingMessage: false, + })) + + // Save chat immediately when stopping for preview + const chatIdToSave = newChatId || get().currentChat?.id + if (chatIdToSave) { + try { + get().saveChatMessages(chatIdToSave) + } catch (saveError) { + logger.warn(`Chat save failed after preview stop: ${saveError}`) + } } - } - - // Close the reader to stop the stream - try { - reader.cancel() - } catch (error) { - // Ignore cancellation errors - } - return // Exit the entire streaming function + + // Close the reader to stop the stream + try { + reader.cancel() + } catch (error) { + // Ignore cancellation errors + } + }, 100) // Small 100ms delay + + return // Exit the entire streaming function } // Update the streaming message From d8f0b33c43bada71e93631f710d68eaa49e44cd8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 12:00:16 -0700 Subject: [PATCH 014/184] Increase token limit for copilot --- apps/sim/lib/copilot/config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/config.ts b/apps/sim/lib/copilot/config.ts index 27c5fd54d14..66a2eff4c06 100644 --- a/apps/sim/lib/copilot/config.ts +++ b/apps/sim/lib/copilot/config.ts @@ -118,19 +118,19 @@ function parseBooleanEnv(value: string | undefined): boolean | null { /** * Default copilot configuration - * Uses Claude 3.7 Sonnet as requested + * Uses Claude 4 Sonnet as requested */ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { chat: { defaultProvider: 'anthropic', - defaultModel: 'claude-3-7-sonnet-latest', + defaultModel: 'claude-sonnet-4-0', temperature: 0.1, - maxTokens: 4000, + maxTokens: 8192, systemPrompt: AGENT_MODE_SYSTEM_PROMPT, }, rag: { defaultProvider: 'anthropic', - defaultModel: 'claude-3-7-sonnet-latest', + defaultModel: 'claude-sonnet-4-0', temperature: 0.1, maxTokens: 2000, embeddingModel: 'text-embedding-3-small', From d53ddd6106621fcf652fef31c54ae8deab5e6de7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 12:04:35 -0700 Subject: [PATCH 015/184] Preview layout fixes --- .../components/workflow-block/workflow-block.tsx | 7 ++++--- apps/sim/lib/copilot/config.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index c6d9890e6d8..74c662d7259 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -28,6 +28,7 @@ interface WorkflowBlockProps { isPending?: boolean isPreview?: boolean subBlockValues?: Record + blockState?: any // Block state data passed in preview mode } // Combine both interfaces into a single component @@ -63,9 +64,9 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Workflow store selectors const lastUpdate = useWorkflowStore((state) => state.lastUpdate) const isEnabled = useWorkflowStore((state) => state.blocks[id]?.enabled ?? true) - const horizontalHandles = useWorkflowStore( - (state) => state.blocks[id]?.horizontalHandles ?? false - ) + const horizontalHandles = data.isPreview + ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal + : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false) const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0) // Get per-block webhook status by checking if webhook is configured diff --git a/apps/sim/lib/copilot/config.ts b/apps/sim/lib/copilot/config.ts index 66a2eff4c06..18a8ab884e2 100644 --- a/apps/sim/lib/copilot/config.ts +++ b/apps/sim/lib/copilot/config.ts @@ -118,7 +118,7 @@ function parseBooleanEnv(value: string | undefined): boolean | null { /** * Default copilot configuration - * Uses Claude 4 Sonnet as requested + * Uses Claude 4 Sonnet */ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { chat: { From aabc323289d48c78426a255de30713276d6c6cf2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 14:21:46 -0700 Subject: [PATCH 016/184] Checkpoint --- apps/sim/executor/handlers/agent/agent-handler.ts | 3 ++- apps/sim/executor/handlers/evaluator/evaluator-handler.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index b739c71f0fa..cce65d4bf50 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -15,6 +15,7 @@ import { getApiKey, getProviderFromModel, transformBlockTool } from '@/providers import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' import { getTool, getToolAsync } from '@/tools/utils' +import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('AgentBlockHandler') @@ -477,7 +478,7 @@ export class AgentBlockHandler implements BlockHandler { ) { logger.info('Using HTTP provider request (browser environment)') - const url = new URL('/api/providers', env.NEXT_PUBLIC_APP_URL || '') + const url = new URL('/api/providers', getBaseUrl()) const response = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 9616d0d5b9d..b81997687e0 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -5,6 +5,7 @@ import { BlockType } from '@/executor/consts' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { calculateCost, getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' +import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('EvaluatorBlockHandler') @@ -104,8 +105,7 @@ export class EvaluatorBlockHandler implements BlockHandler { } try { - const baseUrl = env.NEXT_PUBLIC_APP_URL || '' - const url = new URL('/api/providers', baseUrl) + const url = new URL('/api/providers', getBaseUrl()) // Make sure we force JSON output in the request const providerRequest = { From 4a1849d543e82bce40b8f94d7f6daa873783611a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 14:36:27 -0700 Subject: [PATCH 017/184] sse checkpoint --- apps/sim/app/api/copilot/route.ts | 72 +--- apps/sim/lib/copilot/service.ts | 1 + apps/sim/providers/anthropic/index.ts | 500 ++++++++------------------ apps/sim/stores/copilot/store.ts | 151 ++++---- 4 files changed, 242 insertions(+), 482 deletions(-) diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 589ae4c73d3..00c75d4f499 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -154,68 +154,16 @@ export async function POST(req: NextRequest) { } if (streamToRead) { - logger.info(`[${requestId}] Returning streaming response`) - - const encoder = new TextEncoder() - - return new Response( - new ReadableStream({ - async start(controller) { - const reader = streamToRead!.getReader() - let accumulatedResponse = '' - - // Send initial metadata - const metadata = { - type: 'metadata', - chatId: result.chatId, - metadata: { - requestId, - message, - }, - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(metadata)}\n\n`)) - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - const chunkText = new TextDecoder().decode(value) - accumulatedResponse += chunkText - - const contentChunk = { - type: 'content', - content: chunkText, - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(contentChunk)}\n\n`)) - } - - // Send completion signal - const completion = { - type: 'complete', - finalContent: accumulatedResponse, - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(completion)}\n\n`)) - controller.close() - } catch (error) { - logger.error(`[${requestId}] Streaming error:`, error) - const errorChunk = { - type: 'error', - error: 'Streaming failed', - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorChunk)}\n\n`)) - controller.close() - } - }, - }), - { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - } - ) + logger.info(`[${requestId}] Returning native SSE streaming response`) + + // Pass through native Anthropic SSE events directly to the frontend + return new Response(streamToRead, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) } // Handle non-streaming response diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 3dc0bf68db7..4831099abed 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -529,6 +529,7 @@ export async function generateChatResponse( streamToolCalls: true, // Enable tool call streaming for copilot workflowId: options.workflowId, chatId: options.chatId, + copilotContext: true, // Flag to enable native SSE streaming for copilot }) // Handle StreamingExecution (from providers with tool calls) diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index bb4384ef675..6d33c5df9c8 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -32,6 +32,32 @@ function createReadableStreamFromAnthropicStream( }) } +/** + * Helper to create a native SSE stream for copilot that passes through all Anthropic events + * This preserves the native SSE format for better performance and simpler parsing + */ +function createNativeSSEStreamForCopilot( + anthropicStream: AsyncIterable +): ReadableStream { + return new ReadableStream({ + async start(controller) { + try { + const encoder = new TextEncoder() + + for await (const event of anthropicStream) { + // Pass through the raw Anthropic SSE event + const sseData = `data: ${JSON.stringify(event)}\n\n` + controller.enqueue(encoder.encode(sseData)) + } + + controller.close() + } catch (err) { + controller.error(err) + } + }, + }) +} + export const anthropicProvider: ProviderConfig = { id: 'anthropic', name: 'Anthropic', @@ -55,7 +81,7 @@ export const anthropicProvider: ProviderConfig = { } // Transform messages to Anthropic format - const messages = [] + const messages: any[] = [] // Add system prompt if present let systemPrompt = request.systemPrompt || '' @@ -333,7 +359,7 @@ ${fieldDescriptions} // STREAMING WITH INCREMENTAL PARSING: Handle both text and tool calls in real-time if (request.stream && shouldStreamToolCalls) { - logger.info('Using incremental streaming parser for Anthropic request', { + logger.info('Using native SSE streaming for Anthropic copilot request', { hasTools: !!(anthropicTools && anthropicTools.length > 0), }) @@ -347,371 +373,157 @@ ${fieldDescriptions} stream: true, }) - // State for incremental parsing - let currentBlockType: 'text' | 'tool_use' | null = null - let toolCallBuffer: any = null - const toolCalls: any[] = [] - let streamedContent = '' - - // Token usage tracking - const tokenUsage = { - prompt: 0, - completion: 0, - total: 0, - } - - // Create an incremental parsing stream - const incrementalParsingStream = new ReadableStream({ + // Create a native SSE stream that passes through Anthropic events directly + const nativeSSEStream = new ReadableStream({ async start(controller) { - try { - for await (const chunk of streamResponse) { - // Handle different chunk types - if (chunk.type === 'content_block_start') { - currentBlockType = chunk.content_block?.type - - if (currentBlockType === 'tool_use') { - // Start buffering a tool call - toolCallBuffer = { - id: chunk.content_block.id, - name: chunk.content_block.name, - input: {}, - } - logger.info(`Starting tool call: ${chunk.content_block.name}`) - - // Emit tool call detection event - const toolDetectionEvent = { - type: 'tool_call_detected', - toolCall: { - id: chunk.content_block.id, - name: chunk.content_block.name, - displayName: getToolDisplayName(chunk.content_block.name), - state: 'detecting', - }, - } - controller.enqueue( - new TextEncoder().encode( - `\n__TOOL_CALL_EVENT__${JSON.stringify(toolDetectionEvent)}__TOOL_CALL_EVENT__\n` - ) - ) - } - } else if (chunk.type === 'content_block_delta') { - if (currentBlockType === 'text' && chunk.delta?.text) { - // Stream text content immediately to user - const textContent = chunk.delta.text - streamedContent += textContent - controller.enqueue(new TextEncoder().encode(textContent)) - } else if (currentBlockType === 'tool_use' && chunk.delta?.partial_json) { - // Buffer tool call parameters - if (toolCallBuffer) { - try { - // Attempt to parse the accumulated JSON - const partialInput = chunk.delta.partial_json - // This is partial JSON, we'll parse it when the block is complete - toolCallBuffer.partialInput = - (toolCallBuffer.partialInput || '') + partialInput - } catch (error) { - // Ignore parsing errors for partial JSON - } - } - } - } else if (chunk.type === 'content_block_stop') { - if (currentBlockType === 'tool_use' && toolCallBuffer) { - try { - // Parse the complete tool call input - toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') - toolCalls.push(toolCallBuffer) - - // Queue tool call for execution - pendingToolCalls.push(toolCallBuffer) - - logger.info(`Completed tool call buffer for: ${toolCallBuffer.name}`) - } catch (error) { - logger.error('Error parsing tool call input:', { error, toolCallBuffer }) - } - toolCallBuffer = null - } - currentBlockType = null - } else if (chunk.type === 'message_start') { - // Track usage data if available - if (chunk.message?.usage) { - tokenUsage.prompt = chunk.message.usage.input_tokens || 0 - } - } else if (chunk.type === 'message_delta') { - // Update token counts as they become available - if (chunk.usage) { - tokenUsage.completion = chunk.usage.output_tokens || 0 - tokenUsage.total = tokenUsage.prompt + tokenUsage.completion - } - } else if (chunk.type === 'message_stop') { - // Stream is complete - execute any pending tool calls - logger.info('Initial stream completed', { - streamedContentLength: streamedContent.length, - toolCallsCount: toolCalls.length, - pendingToolCallsCount: pendingToolCalls.length, - }) + const encoder = new TextEncoder() + + // Track conversation state and tool calls + const conversationMessages: any[] = [...(messages || [])] + let pendingToolCalls: any[] = [] + let currentToolCall: any = null + + const executeToolsAndContinue = async (toolCalls: any[]) => { + try { + logger.info(`Executing ${toolCalls.length} tool calls`, { + toolNames: toolCalls.map((tc) => tc.name), + }) - if (pendingToolCalls.length > 0) { - // Send structured tool call indicators instead of text - const toolCallEvent = { - type: 'tool_calls_start', - toolCalls: pendingToolCalls.map((tc) => ({ - id: tc.id, - name: tc.name, - displayName: getToolDisplayName(tc.name), - parameters: tc.input, - state: 'executing', - })), + // Execute all tools in parallel + const toolResults = await Promise.all( + toolCalls.map(async (toolCall) => { + const tool = request.tools?.find((t: any) => t.id === toolCall.name) + if (!tool) { + logger.warn(`Tool not found: ${toolCall.name}`) + return { toolCall, result: null, success: false } } - controller.enqueue( - new TextEncoder().encode( - `\n__TOOL_CALL_EVENT__${JSON.stringify(toolCallEvent)}__TOOL_CALL_EVENT__\n` - ) - ) - - // Execute tools and continue conversation - await executeToolsAndContinue(pendingToolCalls, controller) - } - - controller.close() - break - } - } - } catch (error) { - logger.error('Error in incremental streaming:', { error }) - controller.error(error) - } - }, - }) - - // Track conversation state for multi-turn tool execution - const conversationMessages = [...messages] - const pendingToolCalls: any[] = [] - const completedToolCalls: any[] = [] - - // Tool ID to readable name mapping for better UX - const toolDisplayNames: Record = { - // Actual copilot tool IDs - docs_search_internal: 'Searching documentation', - get_user_workflow: 'Analyzing your workflow', - get_blocks_and_tools: 'Getting context', - get_blocks_metadata: 'Getting context', - get_yaml_structure: 'Designing an approach', - edit_workflow: 'Building your workflow', - } - // Helper function to get display name for tool - const getToolDisplayName = (toolId: string): string => { - return toolDisplayNames[toolId] || `Executing ${toolId}` - } - - // Helper function to group tools by their display names - const groupToolsByDisplayName = (toolCalls: any[]): string[] => { - const displayNameSet = new Set() - toolCalls.forEach((tc) => { - displayNameSet.add(getToolDisplayName(tc.name)) - }) - return Array.from(displayNameSet) - } - - // Helper function to execute tools and continue conversation - const executeToolsAndContinue = async ( - toolCalls: any[], - controller: ReadableStreamDefaultController - ) => { - try { - logger.info(`Executing ${toolCalls.length} tool calls`, { - toolNames: toolCalls.map((tc) => tc.name), - }) - - // Execute all tools in parallel - const toolResults = await Promise.all( - toolCalls.map(async (toolCall) => { - const tool = request.tools?.find((t: any) => t.id === toolCall.name) - if (!tool) { - logger.warn(`Tool not found: ${toolCall.name}`) - return null - } - - const toolCallStartTime = Date.now() - const mergedArgs = { - ...tool.params, - ...toolCall.input, - ...(request.workflowId - ? { - _context: { - workflowId: request.workflowId, - ...(request.chatId ? { chatId: request.chatId } : {}), - }, - } - : {}), - ...(request.environmentVariables ? { envVars: request.environmentVariables } : {}), - } + const toolCallStartTime = Date.now() + const mergedArgs = { + ...tool.params, + ...toolCall.input, + ...(request.workflowId + ? { + _context: { + workflowId: request.workflowId, + ...(request.chatId ? { chatId: request.chatId } : {}), + }, + } + : {}), + ...(request.environmentVariables ? { envVars: request.environmentVariables } : {}), + } - const result = await executeTool(toolCall.name, mergedArgs, true) - const toolCallEndTime = Date.now() + const result = await executeTool(toolCall.name, mergedArgs, true) + const toolCallEndTime = Date.now() + + logger.info(`Tool ${toolCall.name} ${result.success ? 'succeeded' : 'failed'}`) - if (result.success) { - completedToolCalls.push({ - name: toolCall.name, - arguments: toolCall.input, - startTime: new Date(toolCallStartTime).toISOString(), - endTime: new Date(toolCallEndTime).toISOString(), - duration: toolCallEndTime - toolCallStartTime, - result: result.output, + return { + toolCall, + result: result.success ? result.output : null, + success: result.success, + } }) - } - - // Emit tool completion event - const toolCompletionEvent = { - type: 'tool_call_complete', - toolCall: { - id: toolCall.id, - name: toolCall.name, - displayName: getToolDisplayName(toolCall.name), - parameters: toolCall.input, - state: result.success ? 'completed' : 'error', - startTime: toolCallStartTime, - endTime: toolCallEndTime, - duration: toolCallEndTime - toolCallStartTime, - result: result.success ? result.output : null, - error: result.success ? null : 'Tool execution failed', - }, - } - controller.enqueue( - new TextEncoder().encode( - `\n__TOOL_CALL_EVENT__${JSON.stringify(toolCompletionEvent)}__TOOL_CALL_EVENT__\n` - ) ) - return { - toolCall, - result: result.success ? result.output : null, - success: result.success, - } - }) - ) - - // Add tool calls and results to conversation - conversationMessages.push({ - role: 'assistant', - content: toolCalls.map((tc) => ({ - type: 'tool_use', - id: tc.id, - name: tc.name, - input: tc.input, - })) as any, - }) - - conversationMessages.push({ - role: 'user', - content: toolResults - .filter((tr) => tr?.success) - .map((tr) => ({ - type: 'tool_result', - tool_use_id: tr!.toolCall.id, - content: JSON.stringify(tr!.result), - })) as any, - }) - - // Add subtle completion indicator before continuing - const completionMessage = `\n` - controller.enqueue(new TextEncoder().encode(completionMessage)) + // Add tool calls and results to conversation + conversationMessages.push({ + role: 'assistant', + content: toolCalls.map((tc) => ({ + type: 'tool_use', + id: tc.id, + name: tc.name, + input: tc.input, + })) as any, + }) - // Continue the conversation with tool results - const nextStreamResponse = await anthropic.messages.create({ - ...payload, - messages: conversationMessages, - stream: true, - }) + conversationMessages.push({ + role: 'user', + content: toolResults + .filter((tr) => tr?.success) + .map((tr) => ({ + type: 'tool_result', + tool_use_id: tr!.toolCall.id, + content: JSON.stringify(tr!.result), + })) as any, + }) - // Parse the continuation stream - await parseContinuationStream(nextStreamResponse, controller) - } catch (error) { - logger.error('Error executing tools and continuing conversation:', { error }) - // Continue streaming even if tools fail - } - } + // Continue the conversation with tool results + const nextStreamResponse = await anthropic.messages.create({ + ...payload, + messages: conversationMessages, + stream: true, + }) - // Helper function to parse continuation streams (for tool result responses) - const parseContinuationStream = async ( - streamResponse: any, - controller: ReadableStreamDefaultController - ) => { - let currentBlockType: 'text' | 'tool_use' | null = null - let toolCallBuffer: any = null - const newToolCalls: any[] = [] - - for await (const chunk of streamResponse) { - if (chunk.type === 'content_block_start') { - currentBlockType = chunk.content_block?.type - - if (currentBlockType === 'tool_use') { - toolCallBuffer = { - id: chunk.content_block.id, - name: chunk.content_block.name, - input: {}, - } - } - } else if (chunk.type === 'content_block_delta') { - if (currentBlockType === 'text' && chunk.delta?.text) { - // Stream continuation text immediately - const textContent = chunk.delta.text - controller.enqueue(new TextEncoder().encode(textContent)) - } else if (currentBlockType === 'tool_use' && chunk.delta?.partial_json) { - if (toolCallBuffer) { - toolCallBuffer.partialInput = - (toolCallBuffer.partialInput || '') + chunk.delta.partial_json + // Stream the continuation response + for await (const chunk of nextStreamResponse as any) { + const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` + controller.enqueue(encoder.encode(sseEvent)) } - } - } else if (chunk.type === 'content_block_stop') { - if (currentBlockType === 'tool_use' && toolCallBuffer) { - try { - toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') - newToolCalls.push(toolCallBuffer) - } catch (error) { - logger.error('Error parsing continuation tool call:', { error }) + } catch (error) { + logger.error('Error executing tools and continuing conversation:', { error }) + // Send error event + const errorEvent = { + type: 'error', + error: 'Tool execution failed', } - toolCallBuffer = null + controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)) } - currentBlockType = null - } else if (chunk.type === 'message_stop') { - // If there are more tool calls, emit structured events and execute them - if (newToolCalls.length > 0) { - // Send structured tool call indicators for subsequent calls - const toolCallEvent = { - type: 'tool_calls_start', - toolCalls: newToolCalls.map((tc) => ({ - id: tc.id, - name: tc.name, - displayName: getToolDisplayName(tc.name), - parameters: tc.input, - state: 'executing', - })), + } + + try { + for await (const chunk of streamResponse) { + // Pass through the SSE event + const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` + controller.enqueue(encoder.encode(sseEvent)) + + // Track tool calls for execution + if (chunk.type === 'content_block_start' && chunk.content_block?.type === 'tool_use') { + currentToolCall = { + id: chunk.content_block.id, + name: chunk.content_block.name, + input: {}, + partialInput: '', + } + } else if (chunk.type === 'content_block_delta' && currentToolCall && chunk.delta?.partial_json) { + currentToolCall.partialInput += chunk.delta.partial_json + } else if (chunk.type === 'content_block_stop' && currentToolCall) { + try { + // Parse complete tool call input + currentToolCall.input = JSON.parse(currentToolCall.partialInput || '{}') + pendingToolCalls.push(currentToolCall) + logger.info(`Tool call ready: ${currentToolCall.name}`, currentToolCall.input) + } catch (error) { + logger.error('Error parsing tool call input:', error) + } + currentToolCall = null + } else if (chunk.type === 'message_stop') { + // If there are pending tool calls, execute them and continue + if (pendingToolCalls.length > 0) { + await executeToolsAndContinue(pendingToolCalls) + pendingToolCalls = [] + } + break } - controller.enqueue( - new TextEncoder().encode( - `\n__TOOL_CALL_EVENT__${JSON.stringify(toolCallEvent)}__TOOL_CALL_EVENT__\n` - ) - ) - - await executeToolsAndContinue(newToolCalls, controller) } - break + controller.close() + } catch (error) { + logger.error('Error in native SSE streaming:', { error }) + controller.error(error) } - } - } + }, + }) // Create the streaming result const streamingResult = { - stream: incrementalParsingStream, + stream: nativeSSEStream, execution: { success: true, output: { content: '', // Will be filled by streaming content model: request.model, - tokens: tokenUsage, - toolCalls: - toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + tokens: { prompt: 0, completion: 0, total: 0 }, + toolCalls: undefined, providerTiming: { startTime: providerStartTimeISO, endTime: new Date().toISOString(), @@ -719,7 +531,7 @@ ${fieldDescriptions} timeSegments: [ { type: 'model', - name: 'Incremental streaming with tools', + name: 'Native SSE streaming', startTime: providerStartTime, endTime: Date.now(), duration: Date.now() - providerStartTime, @@ -727,7 +539,7 @@ ${fieldDescriptions} ], }, cost: { - total: 0.0, // Will be updated as tokens are counted + total: 0.0, input: 0.0, output: 0.0, }, @@ -867,7 +679,7 @@ ${fieldDescriptions} const toolArgs = toolUse.input as Record // Get the tool from the tools registry - const tool = request.tools?.find((t) => t.id === toolName) + const tool = request.tools?.find((t: any) => t.id === toolName) if (!tool) continue // Execute the tool diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 01a2e94a250..f26ec7bc83f 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -521,6 +521,11 @@ export const useCopilotStore = create()( let newChatId: string | undefined let streamComplete = false + // Track tool calls for native Anthropic events + let currentBlockType: 'text' | 'tool_use' | null = null + let toolCallBuffer: any = null + const toolCalls: any[] = [] + try { while (true) { const { done, value } = await reader.read() @@ -535,91 +540,76 @@ export const useCopilotStore = create()( try { const data = JSON.parse(line.slice(6)) - if (data.type === 'metadata') { - if (data.chatId) { - newChatId = data.chatId - } - } else if (data.type === 'content') { - // Add a space before new content if this is a continuation - if (isContinuation && accumulatedContent && !accumulatedContent.endsWith(' ') && data.content && !data.content.startsWith(' ')) { - accumulatedContent += ' ' + data.content - } else { - accumulatedContent += data.content - } - - // Check if we just completed a preview_workflow tool call and should stop streaming - // Skip this check during continuation since the existing content already contains the tool call - const shouldStopStreaming = !isContinuation && checkForPreviewToolCompletion(accumulatedContent) - if (shouldStopStreaming) { - logger.info('Preview workflow tool completed - stopping stream with small delay to allow UI updates') - - // Add a small delay to allow the review button to appear before processing - setTimeout(() => { - streamComplete = true - - // Final update with current content - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent } : msg - ), - isSendingMessage: false, - })) - - // Save chat immediately when stopping for preview - const chatIdToSave = newChatId || get().currentChat?.id - if (chatIdToSave) { - try { - get().saveChatMessages(chatIdToSave) - } catch (saveError) { - logger.warn(`Chat save failed after preview stop: ${saveError}`) - } - } - - // Close the reader to stop the stream - try { - reader.cancel() - } catch (error) { - // Ignore cancellation errors - } - }, 100) // Small 100ms delay + // Handle native Anthropic SSE events + if (data.type === 'message_start') { + logger.info('Message started') + } else if (data.type === 'content_block_start') { + currentBlockType = data.content_block?.type + + if (currentBlockType === 'tool_use') { + // Start buffering a tool call + toolCallBuffer = { + id: data.content_block.id, + name: data.content_block.name, + input: {}, + partialInput: '', + } + logger.info(`Starting tool call: ${data.content_block.name}`) - return // Exit the entire streaming function + // Don't show any messages - backend handles tool execution automatically } + } else if (data.type === 'content_block_delta') { + if (currentBlockType === 'text' && data.delta?.text) { + // Add text content normally + if (isContinuation && accumulatedContent && !accumulatedContent.endsWith(' ') && data.delta.text && !data.delta.text.startsWith(' ')) { + accumulatedContent += ' ' + data.delta.text + } else { + accumulatedContent += data.delta.text + } - // Update the streaming message - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent } : msg - ), - })) - } else if (data.type === 'complete') { - // Final update - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent } : msg - ), - isSendingMessage: false, - })) - - // Save chat to database after streaming completes - const chatIdToSave = newChatId || get().currentChat?.id - if (chatIdToSave) { + // Update message in real-time + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent } : msg + ), + })) + } else if (currentBlockType === 'tool_use' && data.delta?.partial_json && toolCallBuffer) { + // Buffer partial JSON for tool calls (silently) + toolCallBuffer.partialInput += data.delta.partial_json + } + } else if (data.type === 'content_block_stop') { + if (currentBlockType === 'tool_use' && toolCallBuffer) { try { - await get().saveChatMessages(chatIdToSave) - } catch (saveError) { - logger.warn(`Chat save failed after streaming: ${saveError}`) + // Parse complete tool call input + toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') + toolCalls.push(toolCallBuffer) + logger.info(`Tool call completed: ${toolCallBuffer.name}`, toolCallBuffer.input) + + // Don't show any messages - backend handles execution + } catch (error) { + logger.error('Error parsing tool call input:', error) } + toolCallBuffer = null } - - // Handle new chat creation - if (newChatId && !get().currentChat) { - await get().handleNewChatCreation(newChatId) + currentBlockType = null + } else if (data.type === 'message_delta') { + // Handle token usage updates silently + if (data.delta?.stop_reason === 'tool_use') { + logger.info('Message stopped for tool use - backend will handle execution') + // Don't complete the stream - backend will continue with tool results } - + } else if (data.type === 'message_stop') { + // Only complete if this is the final stop (not a tool use stop) + // The backend will send another message_stop after tool execution + logger.info('Message stop received - checking if final') + + // Don't complete yet - let the backend continue if there are tools + // The stream will naturally complete when the backend closes it + } else if (data.type === 'error') { + // Handle error events from backend + logger.error('Backend error:', data.error) streamComplete = true break - } else if (data.type === 'error') { - throw new Error(data.error || 'Streaming error') } } catch (parseError) { logger.warn('Failed to parse SSE data:', parseError) @@ -628,7 +618,16 @@ export const useCopilotStore = create()( } } + // Stream ended naturally - finalize the message logger.info(`Completed streaming response, content length: ${accumulatedContent.length}`) + + // Final update when stream actually ends + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent } : msg + ), + isSendingMessage: false, + })) } catch (error) { logger.error('Error handling streaming response:', error) throw error From 45a02427be496810b505e80907bd424e80ddc15e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 14:59:43 -0700 Subject: [PATCH 018/184] Checkpoint --- apps/sim/app/api/copilot/route.ts | 34 +- .../professional-message.tsx | 230 +------ .../panel/components/copilot/copilot.tsx | 165 +---- .../[workflowId]/components/review-button.tsx | 74 +-- apps/sim/lib/copilot/service.ts | 86 +-- apps/sim/lib/tool-call-parser.ts | 569 +----------------- apps/sim/stores/copilot/preview-store.ts | 27 +- apps/sim/stores/copilot/store.ts | 119 ++-- apps/sim/stores/copilot/types.ts | 19 +- 9 files changed, 217 insertions(+), 1106 deletions(-) diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 00c75d4f499..09c4b281e7d 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -154,10 +154,38 @@ export async function POST(req: NextRequest) { } if (streamToRead) { - logger.info(`[${requestId}] Returning native SSE streaming response`) + logger.info(`[${requestId}] Returning native SSE streaming response with chatId: ${result.chatId}`) + + // Create a new stream that first sends the chatId, then forwards the actual response + const transformedStream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + + // First, send the chatId as an SSE event + if (result.chatId) { + const chatIdEvent = `data: ${JSON.stringify({ type: 'chat_id', chatId: result.chatId })}\n\n` + controller.enqueue(encoder.encode(chatIdEvent)) + } + + // Then forward the actual stream + const reader = streamToRead.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + controller.enqueue(value) + } + } catch (error) { + logger.error(`[${requestId}] Error forwarding stream:`, error) + controller.error(error) + } finally { + controller.close() + } + } + }) // Pass through native Anthropic SSE events directly to the frontend - return new Response(streamToRead, { + return new Response(transformedStream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -321,7 +349,7 @@ export async function PATCH(req: NextRequest) { const chat = await updateChat(chatId, session.user.id, { messages, title: titleToUse, - filterToolCalls: true, // Apply filtering when updating chat via API + }) if (!chat) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 742f4451860..1980eca8f88 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -1,6 +1,6 @@ 'use client' -import { type FC, memo, useMemo, useEffect, useState } from 'react' +import { type FC, memo, useMemo, useState } from 'react' import { Bot, Copy, User, ChevronDown, ChevronRight, CheckCircle, Settings, XCircle, Loader2 } from 'lucide-react' import { useTheme } from 'next-themes' import ReactMarkdown from 'react-markdown' @@ -10,7 +10,6 @@ import remarkGfm from 'remark-gfm' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { parseMessageContent, stripToolCallIndicators, groupDesignApproachTools, isDesignApproachTool } from '@/lib/tool-call-parser' import { cn } from '@/lib/utils' import type { CopilotMessage } from '@/stores/copilot/types' import type { ToolCallState } from '@/types/tool-call' @@ -21,113 +20,8 @@ interface ProfessionalMessageProps { isStreaming?: boolean } -// Design Approach Group Component -function DesignApproachGroup({ tools, isCompleted }: { tools: ToolCallState[], isCompleted: boolean }) { - const [isExpanded, setIsExpanded] = useState(true) - - const activeToolIndex = tools.findIndex(tool => tool.state === 'executing') - const completedCount = tools.filter(tool => tool.state === 'completed').length - const hasError = tools.some(tool => tool.state === 'error') - - const getGroupStatus = () => { - if (hasError) return 'error' - if (completedCount === tools.length) return 'completed' // All tools completed - if (activeToolIndex >= 0) return 'executing' - return 'pending' - } - - const status = getGroupStatus() - - // Stable group title - always show as designed when all tools are done - const getGroupTitle = () => { - if (status === 'completed') return 'Designed an Approach' - if (status === 'executing') return 'Designing an Approach' - if (status === 'error') return 'Approach Design Failed' - return 'Designing an Approach' - } - - const getGroupSubtitle = () => { - if (status === 'executing' && activeToolIndex >= 0) { - return `Step ${activeToolIndex + 1} of ${tools.length} • ${tools[activeToolIndex].displayName || tools[activeToolIndex].name}` - } - if (status === 'completed') { - return 'Approach designed successfully' - } - if (status === 'error') { - return 'Error in approach design' - } - return `${completedCount}/${tools.length} steps completed` - } - - return ( -
    - - - - - -
    - {tools.map((tool, index) => ( - - ))} -
    -
    -
    -
    - ) -} - // Inline Tool Call Component -function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState, stepNumber?: number }) { +function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepNumber?: number }) { const getStateIcon = () => { switch (tool.state) { case 'executing': @@ -250,54 +144,18 @@ const ProfessionalMessage: FC = memo(({ message, isStr const isAssistant = message.role === 'assistant' const handleCopyContent = () => { - // Copy clean text content without tool call indicators - const contentToCopy = isAssistant ? stripToolCallIndicators(message.content) : message.content - navigator.clipboard.writeText(contentToCopy) + // Copy clean text content + navigator.clipboard.writeText(message.content) } const formatTimestamp = (timestamp: string) => { return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } - // Parse message content to separate text and tool calls - const parsedContent = useMemo(() => { - if (isAssistant && message.content) { - const result = parseMessageContent(message.content) - return result - } - return null - }, [isAssistant, message.content, message.id]) - - // Get clean text content without tool call indicators + // Get clean text content (no processing needed with native SSE) const cleanTextContent = useMemo(() => { - if (isAssistant && message.content) { - return stripToolCallIndicators(message.content) - } return message.content - }, [isAssistant, message.content]) - - // Group design approach tools if they exist - const designApproachGroup = useMemo(() => { - if (parsedContent?.inlineContent) { - const group = groupDesignApproachTools(parsedContent.inlineContent) - - // Only warn if we have design tools but no group (indicates a problem) - const designTools = parsedContent.inlineContent.filter(item => - item.type === 'tool_call' && item.toolCall && isDesignApproachTool(item.toolCall.name) - ) - - if (designTools.length >= 2 && !group) { - console.warn('Design approach group should exist but was not detected:', { - messageId: message.id, - designToolCount: designTools.length, - designToolNames: designTools.map(item => item.toolCall?.name) - }) - } - - return group - } - return null - }, [parsedContent?.inlineContent, message.id, message.content.length]) + }, [message.content]) // Custom components for react-markdown with improved styling const markdownComponents = { @@ -455,61 +313,31 @@ const ProfessionalMessage: FC = memo(({ message, isStr {/* Message content */}
    - {/* Render inline content */} - {parsedContent?.inlineContent && parsedContent.inlineContent.length > 0 ? ( -
    - {parsedContent.inlineContent.map((item, index) => { - // If this index is within the design approach group range, skip individual rendering - if (designApproachGroup && - index >= designApproachGroup.groupStart && - index <= designApproachGroup.groupEnd) { - // Only render the group once at the start position - if (index === designApproachGroup.groupStart) { - return ( - t.state === 'completed' || t.state === 'error')} - /> - ) - } - return null - } - - if (item.type === 'tool_call' && item.toolCall) { - return - } - - if (item.type === 'text' && item.content.trim()) { - return ( -
    -
    - - {item.content} - -
    -
    - ) - } - return null - })} -
    - ) : ( - /* Fallback for empty content or streaming */ -
    - {cleanTextContent ? ( + {/* Tool calls and content */} +
    + {/* Tool calls if available */} + {message.toolCalls && message.toolCalls.length > 0 && ( +
    + {message.toolCalls.map((toolCall) => ( + + ))} +
    + )} + + {/* Regular text content */} + {cleanTextContent && ( +
    {cleanTextContent}
    - ) : isStreaming ? ( +
    + )} + + {/* Streaming indicator when no content yet */} + {!cleanTextContent && isStreaming && ( +
    = memo(({ message, isStr
    Thinking...
    - ) : null} -
    - )} +
    + )} +
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 724bd82d1f1..c76bca07728 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -117,162 +117,25 @@ export const Copilot = forwardRef( } }, [messages]) - // Scan existing messages and mark preview tool calls as seen ONLY once per chat session - // But preserve any currently visible preview to prevent race conditions - useEffect(() => { - const chatId = currentChat?.id || 'no-chat' - - if (messages.length > 0 && scannedChatRef.current !== chatId) { - console.log('Scanning existing messages for chat:', chatId, 'message count:', messages.length) - - // Before scanning, check if there's currently a visible preview - // We'll exclude this from being marked as seen during scanning - const currentlyVisiblePreview = getLatestUnseenPreview(messages, isToolCallSeen) - const protectedToolCallId = currentlyVisiblePreview?.latestPreview?.toolCallId - - console.log('Protecting currently visible preview during scan:', protectedToolCallId) - - // Create a modified version of scanAndMarkExistingPreviews that excludes the protected ID - const toolCallIds = new Set() - - messages.forEach((message) => { - if (message.role === 'assistant' && message.content) { - const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g - let match - - while ((match = previewToolCallPattern.exec(message.content)) !== null) { - try { - const toolCallEvent = JSON.parse(match[1]) - if ( - toolCallEvent.type === 'tool_call_complete' && - toolCallEvent.toolCall?.name === 'preview_workflow' && - toolCallEvent.toolCall?.id && - toolCallEvent.toolCall?.id !== protectedToolCallId // Don't mark the currently visible one as seen - ) { - toolCallIds.add(toolCallEvent.toolCall.id) - } - } catch (error) { - console.warn('Failed to parse tool call event while scanning:', error) - } - } - } - }) - - // Mark the non-protected tool calls as seen - if (toolCallIds.size > 0) { - console.log('Marking existing preview tool calls as seen (excluding protected):', Array.from(toolCallIds)) - toolCallIds.forEach(id => { - markToolCallAsSeen(id) - }) - } - - scannedChatRef.current = chatId - } - }, [messages, currentChat?.id, isToolCallSeen]) // Added isToolCallSeen to dependencies - - // Watch for completed preview_workflow tool calls and show sandbox modal + // Watch for completed preview_workflow tool calls in the new format useEffect(() => { if (!messages.length) return const lastMessage = messages[messages.length - 1] - if (lastMessage.role !== 'assistant') return - - logger.info('Checking last message for preview_workflow tool calls:', { - messageLength: lastMessage.content.length, - messagePreview: lastMessage.content.substring(0, 200) + '...', - }) - - // Look for completed preview_workflow tool calls in the message content - const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g - const matches = Array.from(lastMessage.content.matchAll(previewToolCallPattern)) - - logger.info('Found tool call events:', { matchCount: matches.length }) - - for (const match of matches) { - try { - const toolCallEvent = JSON.parse(match[1]) - - logger.info('Processing tool call event:', { - type: toolCallEvent.type, - toolName: toolCallEvent.toolCall?.name, - state: toolCallEvent.toolCall?.state, - hasResult: !!toolCallEvent.toolCall?.result, - }) - - // Special logging for preview_workflow - if (toolCallEvent.toolCall?.name === 'preview_workflow') { - logger.info('Preview workflow tool call detected:', { - type: toolCallEvent.type, - state: toolCallEvent.toolCall?.state, - result: toolCallEvent.toolCall?.result, - parameters: toolCallEvent.toolCall?.parameters, - }) - } - - if ( - toolCallEvent.type === 'tool_call_complete' && - toolCallEvent.toolCall?.name === 'preview_workflow' && - toolCallEvent.toolCall?.state === 'completed' && - toolCallEvent.toolCall?.result && - toolCallEvent.toolCall?.id && - !isToolCallSeen(toolCallEvent.toolCall.id) - ) { - const result = toolCallEvent.toolCall.result - - logger.info('Preview workflow tool result:', { - hasWorkflowState: !!result.workflowState, - hasParameters: !!toolCallEvent.toolCall?.parameters, - hasYamlContent: !!toolCallEvent.toolCall?.parameters?.yamlContent, - resultKeys: Object.keys(result), - parametersKeys: toolCallEvent.toolCall?.parameters ? Object.keys(toolCallEvent.toolCall.parameters) : [], - }) - - // Extract the workflow state and YAML content from the actual structure - let workflowState = null - let yamlContent = null - let description = null - - // The workflow state is directly in result.workflowState - if (result.workflowState) { - workflowState = result.workflowState - } - - // The YAML content and description are in the tool call parameters - if (toolCallEvent.toolCall?.parameters) { - yamlContent = toolCallEvent.toolCall.parameters.yamlContent - description = toolCallEvent.toolCall.parameters.description - } - - if (workflowState && yamlContent) { - logger.info('Preview workflow completed - storing for review button', { - blocksCount: Object.keys(workflowState.blocks || {}).length, - edgesCount: (workflowState.edges || []).length, - yamlLength: yamlContent.length, - description, - }) - - // Preview will be detected by the review button scanning messages - console.log('Preview workflow completed - will be detected by review button:', { - hasWorkflowState: !!workflowState, - yamlLength: yamlContent?.length, - description, - toolCallId: toolCallEvent.toolCall.id - }) - break // Only handle the first preview tool call - } else { - logger.warn('Missing required data for sandbox modal:', { - hasWorkflowState: !!workflowState, - hasYamlContent: !!yamlContent, - workflowStateType: typeof workflowState, - yamlContentType: typeof yamlContent, - }) - } - } - } catch (error) { - logger.error('Error parsing tool call event:', error) - } + if (lastMessage.role !== 'assistant' || !lastMessage.toolCalls) return + + // Check for completed preview_workflow tool calls + const previewToolCall = lastMessage.toolCalls.find( + tc => tc.name === 'preview_workflow' && tc.state === 'completed' && !isToolCallSeen(tc.id) + ) + + if (previewToolCall && previewToolCall.result) { + logger.info('Preview workflow completed via native SSE - handling result') + // Mark as seen to prevent duplicate processing + markToolCallAsSeen(previewToolCall.id) + // Tool call handling logic would go here if needed } - }, [messages, isToolCallSeen]) + }, [messages, isToolCallSeen, markToolCallAsSeen]) // Handle chat deletion const handleDeleteChat = useCallback( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 49d37fb9ea0..a1d3e9a05eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -9,11 +9,12 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useCopilotStore } from '@/stores/copilot/store' import { usePreviewStore } from '@/stores/copilot/preview-store' import { createLogger } from '@/lib/logs/console-logger' +import type { CopilotToolCall, CopilotMessage } from '@/stores/copilot/types' const logger = createLogger('ReviewButton') // Helper function to extract preview data from messages -export function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: string) => boolean) { +export function getLatestUnseenPreview(messages: CopilotMessage[], isToolCallSeen: (id: string) => boolean) { if (!messages.length) return null const foundPreviews: { toolCallId: string; messageIndex: number; workflowState: any; yamlContent: string; description?: string }[] = [] @@ -21,50 +22,41 @@ export function getLatestUnseenPreview(messages: any[], isToolCallSeen: (id: str // Go through messages in reverse order (newest first) to find all unseen previews for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] - if (message.role !== 'assistant' || !message.content) continue - - const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g - let match - - while ((match = previewToolCallPattern.exec(message.content)) !== null) { - try { - const toolCallEvent = JSON.parse(match[1]) - if ( - toolCallEvent.type === 'tool_call_complete' && - toolCallEvent.toolCall?.name === 'preview_workflow' && - toolCallEvent.toolCall?.state === 'completed' && - toolCallEvent.toolCall?.result && - toolCallEvent.toolCall?.id && - !isToolCallSeen(toolCallEvent.toolCall.id) - ) { - const result = toolCallEvent.toolCall.result - let workflowState = null - let yamlContent = null - let description = null - - if (result.workflowState) { - workflowState = result.workflowState - } + if (message.role !== 'assistant' || !message.toolCalls) continue + + message.toolCalls.forEach((toolCall: CopilotToolCall) => { + if ( + toolCall.name === 'preview_workflow' && + toolCall.state === 'completed' && + toolCall.result && + toolCall.id && + !isToolCallSeen(toolCall.id) + ) { + const result = toolCall.result + let workflowState = null + let yamlContent = null + let description = null + + if (result.workflowState) { + workflowState = result.workflowState + } - if (toolCallEvent.toolCall?.parameters) { - yamlContent = toolCallEvent.toolCall.parameters.yamlContent - description = toolCallEvent.toolCall.parameters.description - } + if (toolCall.input) { + yamlContent = toolCall.input.yamlContent + description = toolCall.input.description + } - if (workflowState && yamlContent) { - foundPreviews.push({ - toolCallId: toolCallEvent.toolCall.id, - messageIndex: i, - workflowState, - yamlContent, - description, - }) - } + if (workflowState && yamlContent) { + foundPreviews.push({ + toolCallId: toolCall.id, + messageIndex: i, + workflowState, + yamlContent, + description, + }) } - } catch (error) { - console.warn('Failed to parse tool call event:', error) } - } + }) } if (foundPreviews.length === 0) { diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 4831099abed..e8634387174 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -27,61 +27,7 @@ if (!promptValidation.agentMode.valid) { logger.error('Agent mode system prompt validation failed:', promptValidation.agentMode.issues) } -/** - * Tool names to filter out from chat history - */ -const FILTERED_TOOL_NAMES = ['get_blocks_metadata', 'get_user_workflow', 'get_yaml_structure'] -/** - * Filter out specific tool call events from message content - */ -function filterToolCallEvents(content: string): string { - // Regex to match tool call events - more robust pattern that handles nested JSON - const toolCallEventRegex = /__TOOL_CALL_EVENT__\{.*?\}__TOOL_CALL_EVENT__/g - - return content.replace(toolCallEventRegex, (match) => { - // Extract the JSON content between the markers - const jsonContent = match.replace(/__TOOL_CALL_EVENT__/g, '') - - try { - const parsed = JSON.parse(jsonContent) - - // Check for tool name in various possible locations - const toolName = - parsed.name || - parsed.toolCall?.name || - (parsed.toolCalls && parsed.toolCalls[0]?.name) || - null - - // If this tool should be filtered out, return empty string - if (toolName && FILTERED_TOOL_NAMES.includes(toolName)) { - logger.debug(`Filtering out tool call event: ${toolName}`) - return '' - } - } catch (error) { - // If JSON parsing fails, fall back to string matching - for (const filteredName of FILTERED_TOOL_NAMES) { - if (match.includes(`"name":"${filteredName}"`)) { - logger.debug(`Filtering out tool call event (fallback): ${filteredName}`) - return '' - } - } - } - - // Otherwise, keep the original match - return match - }) -} - -/** - * Filter messages to remove specific tool call events - */ -function filterMessages(messages: CopilotMessage[]): CopilotMessage[] { - return messages.map(message => ({ - ...message, - content: filterToolCallEvents(message.content) - })) -} /** * Citation information for documentation references @@ -178,7 +124,6 @@ export interface CreateChatOptions { export interface UpdateChatOptions { title?: string messages?: CopilotMessage[] - filterToolCalls?: boolean } /** @@ -528,8 +473,7 @@ export async function generateChatResponse( stream, streamToolCalls: true, // Enable tool call streaming for copilot workflowId: options.workflowId, - chatId: options.chatId, - copilotContext: true, // Flag to enable native SSE streaming for copilot + chatId: options.chatId }) // Handle StreamingExecution (from providers with tool calls) @@ -716,9 +660,7 @@ export async function updateChat( } if (updates.title !== undefined) updateData.title = updates.title - if (updates.messages !== undefined) { - updateData.messages = updates.filterToolCalls ? filterMessages(updates.messages) : updates.messages - } + if (updates.messages !== undefined) updateData.messages = updates.messages // Update the chat const [updatedChat] = await db @@ -824,7 +766,6 @@ export async function sendMessage(request: SendMessageRequest): Promise<{ await updateChat(currentChat.id, userId, { title: updatedTitle || undefined, messages: updatedMessages, - filterToolCalls: true, // Filter tool calls since response is complete }) } @@ -858,25 +799,4 @@ export async function updateChatMessages( } } -// Update chat messages with filtering (for when copilot is completely done) -export async function updateChatMessagesFiltered( - chatId: string, - messages: CopilotMessage[] -): Promise { - try { - // Filter out specific tool call events before saving - const filteredMessages = filterMessages(messages) - - await db - .update(copilotChats) - .set({ - messages: filteredMessages, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, chatId)) - .execute() - } catch (error) { - logger.error('Failed to update chat messages with filtering:', error) - throw error - } -} + diff --git a/apps/sim/lib/tool-call-parser.ts b/apps/sim/lib/tool-call-parser.ts index ada61e6cbaa..4e282d0e184 100644 --- a/apps/sim/lib/tool-call-parser.ts +++ b/apps/sim/lib/tool-call-parser.ts @@ -1,567 +1,2 @@ -import type { - InlineContent, - ParsedMessageContent, - ToolCallIndicator, - ToolCallState, -} from '@/types/tool-call' - -// Tool ID to display name mapping for better UX -const TOOL_DISPLAY_NAMES: Record = { - docs_search_internal: 'Searching documentation', - get_user_workflow: 'Analyzing your workflow', - get_blocks_and_tools: 'Getting context', - get_blocks_metadata: 'Diving deeper', - get_yaml_structure: 'Structuring your workflow', - preview_workflow: 'Preview Ready', - // edit_workflow: 'Building your workflow', // Commented out - only preview is allowed -} - -// Past tense versions for completed tool calls -const TOOL_PAST_TENSE_NAMES: Record = { - docs_search_internal: 'Searched documentation', - get_user_workflow: 'Analyzed your workflow', - get_blocks_and_tools: 'Got context', - get_blocks_metadata: 'Dove deeper', - get_yaml_structure: 'Structured your workflow', - preview_workflow: 'Built Workflow', - // edit_workflow: 'Built your workflow', // Commented out - only preview is allowed -} - -// Tool grouping for "Designing an Approach" -const DESIGN_APPROACH_TOOLS = new Set([ - 'get_blocks_and_tools', - 'get_blocks_metadata', - 'get_yaml_structure' -]) - -// Regex patterns to detect structured tool call events -const TOOL_CALL_PATTERNS = { - // Matches structured tool call events: __TOOL_CALL_EVENT__{"type":"..."}__TOOL_CALL_EVENT__ - toolCallEvent: /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g, - // Fallback patterns for legacy emoji indicators (if needed) - statusIndicator: /🔄\s+([^🔄\n]+)/gu, - thinkingPattern: /(\.\.\.|…|💭|🤔)/g, - functionCall: /(\w+)\s*\(\s*([^)]*)\s*\)/g, - completionIndicator: /✅|☑️|✓|Done|Complete/g, - errorIndicator: /❌|⚠️|Error|Failed/g, -} - -/** - * Extract tool names from a status message - */ -export function extractToolNames(statusMessage: string): string[] { - // Remove the 🔄 indicator and split on • or bullet points - const cleanMessage = statusMessage.replace(/🔄\s*/, '').trim() - - // Split on common separators - const toolNames = cleanMessage - .split(/[•·|,&]/) - .map((name) => name.trim()) - .filter((name) => name.length > 0) - - return toolNames -} - -/** - * Get display name for a tool - */ -export function getToolDisplayName(toolId: string, isCompleted = false): string { - if (isCompleted) { - return TOOL_PAST_TENSE_NAMES[toolId] || TOOL_DISPLAY_NAMES[toolId] || toolId.replace(/_/g, ' ') - } - return TOOL_DISPLAY_NAMES[toolId] || toolId.replace(/_/g, ' ') -} - -/** - * Check if a tool is part of the "Designing an Approach" group - */ -export function isDesignApproachTool(toolId: string): boolean { - return DESIGN_APPROACH_TOOLS.has(toolId) -} - -/** - * Group consecutive design approach tools together - */ -export function groupDesignApproachTools(inlineContent: InlineContent[]): { - groupStart: number - groupEnd: number - groupedTools: ToolCallState[] -} | null { - if (inlineContent.length < 2) return null - - // Find consecutive design approach tools in the inline content - let groupStart = -1 - let groupEnd = -1 - const groupedTools: ToolCallState[] = [] - let consecutiveDesignTools = 0 - - for (let i = 0; i < inlineContent.length; i++) { - const item = inlineContent[i] - - if (item.type === 'tool_call' && item.toolCall && isDesignApproachTool(item.toolCall.name)) { - if (groupStart === -1) { - groupStart = i - } - groupEnd = i - groupedTools.push(item.toolCall) - consecutiveDesignTools++ - } else if (item.type === 'tool_call' && item.toolCall && !isDesignApproachTool(item.toolCall.name)) { - // Found a non-design tool call - if we have at least 2 design tools, stop the group - if (consecutiveDesignTools >= 2) { - break - } else { - // Reset if we haven't found enough consecutive design tools yet - groupStart = -1 - groupEnd = -1 - groupedTools.length = 0 - consecutiveDesignTools = 0 - } - } - // Note: Text content doesn't break the group, only non-design tool calls do - } - - // Only group if we have at least 2 consecutive design tools - if (groupStart !== -1 && groupEnd > groupStart && groupedTools.length >= 2) { - // Ensure all tools in the group are either completed or in error state for stability - const allToolsFinished = groupedTools.every(tool => - tool.state === 'completed' || tool.state === 'error' - ) - - return { - groupStart, - groupEnd, - groupedTools - } - } - - return null -} - -/** - * Parse structured tool call events from the stream and maintain state transitions - */ -export function parseToolCallEvents( - content: string, - existingToolCalls: ToolCallState[] = [] -): ToolCallState[] { - const toolCallsMap = new Map() - - // Start with existing tool calls - existingToolCalls.forEach((tc) => { - toolCallsMap.set(tc.id, { ...tc }) - }) - - const matches = content.matchAll(TOOL_CALL_PATTERNS.toolCallEvent) - - for (const match of matches) { - try { - const eventData = JSON.parse(match[1]) - - switch (eventData.type) { - case 'tool_call_detected': - if (!toolCallsMap.has(eventData.toolCall.id)) { - const toolCall = { - ...eventData.toolCall, - displayName: getToolDisplayName(eventData.toolCall.name), // Ensure displayName is set - startTime: Date.now(), - } - toolCallsMap.set(eventData.toolCall.id, toolCall) - } - break - - case 'tool_calls_start': - eventData.toolCalls.forEach((toolCall: any) => { - if (!toolCallsMap.has(toolCall.id)) { - const enhancedToolCall = { - ...toolCall, - displayName: getToolDisplayName(toolCall.name), // Ensure displayName is set - state: 'executing' as const, // Explicitly set state for new tool calls - startTime: Date.now(), - } - toolCallsMap.set(toolCall.id, enhancedToolCall) - } else { - // Update existing tool call to executing state - const existing = toolCallsMap.get(toolCall.id)! - const updatedToolCall = { - ...existing, - displayName: getToolDisplayName(existing.name), // Ensure displayName is set - state: 'executing' as const, - parameters: toolCall.parameters || existing.parameters, - } - toolCallsMap.set(toolCall.id, updatedToolCall) - } - }) - break - - case 'tool_call_complete': { - const completedToolCall = eventData.toolCall - if (toolCallsMap.has(completedToolCall.id)) { - // Update existing tool call to completed state - const existing = toolCallsMap.get(completedToolCall.id)! - const state = completedToolCall.state === 'error' ? 'error' : 'completed' - const updatedToolCall = { - ...existing, - displayName: getToolDisplayName(existing.name, true), // Use past tense for completed - state: state as 'completed' | 'error', - endTime: completedToolCall.endTime, - duration: completedToolCall.duration, - result: completedToolCall.result, - error: completedToolCall.error, - } - toolCallsMap.set(completedToolCall.id, updatedToolCall) - } else { - // Create new completed tool call if it doesn't exist - const state = completedToolCall.state === 'error' ? 'error' : 'completed' - const enhancedToolCall = { - ...completedToolCall, - displayName: getToolDisplayName(completedToolCall.name, true), // Use past tense for completed - state: state as 'completed' | 'error', - } - toolCallsMap.set(completedToolCall.id, enhancedToolCall) - } - break - } - } - } catch (error) { - console.warn('Failed to parse tool call event:', error) - } - } - - return Array.from(toolCallsMap.values()) -} - -/** - * Parse a tool call status message and extract tool information (fallback for legacy) - */ -export function parseToolCallStatus(content: string): ToolCallIndicator | null { - // First check for structured events - const structuredEvents = parseToolCallEvents(content) - if (structuredEvents.length > 0) { - return { - type: 'status', - content: content, - toolNames: structuredEvents.map((e) => e.displayName || e.name), - } - } - - // Fallback to legacy emoji parsing - const statusMatch = content.match(TOOL_CALL_PATTERNS.statusIndicator) - - if (statusMatch) { - const statusText = statusMatch[0] - const toolNames = extractToolNames(statusText) - - return { - type: 'status', - content: statusText, - toolNames, - } - } - - // Check for thinking patterns - if (TOOL_CALL_PATTERNS.thinkingPattern.test(content)) { - return { - type: 'thinking', - content: content.trim(), - } - } - - // Check for function call patterns - const functionMatch = content.match(TOOL_CALL_PATTERNS.functionCall) - if (functionMatch) { - return { - type: 'execution', - content: content.trim(), - } - } - - return null -} - -/** - * Create a tool call state from detected information - */ -export function createToolCallState( - name: string, - parameters?: Record, - state: ToolCallState['state'] = 'detecting' -): ToolCallState { - return { - id: `${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`, - name, - displayName: getToolDisplayName(name), - parameters, - state, - startTime: Date.now(), - } -} - -/** - * Parse message content and maintain inline positioning of tool calls - */ -export function parseMessageContent( - content: string, - existingToolCalls: ToolCallState[] = [] -): ParsedMessageContent { - // Get all tool call events with state transitions - const toolCallEvents = parseToolCallEvents(content, existingToolCalls) - const toolCallsMap = new Map() - - toolCallEvents.forEach((tc) => { - toolCallsMap.set(tc.id, tc) - }) - - // Parse content maintaining inline positioning and deduplicating tool calls - const inlineContent: InlineContent[] = [] - const toolCallPositions = new Map() // Track where each tool call first appears - let currentTextBuffer = '' - - // Split content into segments, preserving tool call markers inline - const segments = content.split(/(__TOOL_CALL_EVENT__.*?__TOOL_CALL_EVENT__)/) - - for (let i = 0; i < segments.length; i++) { - const segment = segments[i] - - if (segment.match(/__TOOL_CALL_EVENT__.*?__TOOL_CALL_EVENT__/)) { - // This is a tool call event - try { - const eventMatch = segment.match(/__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/) - if (eventMatch) { - const eventData = JSON.parse(eventMatch[1]) - - // Handle different event types - switch (eventData.type) { - case 'tool_call_detected': { - const id = eventData.toolCall?.id - if (id && toolCallsMap.has(id) && !toolCallPositions.has(id)) { - // Add text buffer before tool call - if (currentTextBuffer.trim()) { - inlineContent.push({ - type: 'text', - content: currentTextBuffer.trim(), - }) - currentTextBuffer = '' - } - - // Add tool call - const toolCall = toolCallsMap.get(id)! - const newIndex = inlineContent.length - inlineContent.push({ - type: 'tool_call', - content: segment, - toolCall, - }) - toolCallPositions.set(id, newIndex) - } else if (id && toolCallsMap.has(id) && toolCallPositions.has(id)) { - // Update existing tool call in place - const existingIndex = toolCallPositions.get(id)! - if (inlineContent[existingIndex]?.type === 'tool_call') { - inlineContent[existingIndex].toolCall = toolCallsMap.get(id)! - } - } - break - } - - case 'tool_calls_start': { - // Handle each tool call in the start event - if (eventData.toolCalls && Array.isArray(eventData.toolCalls)) { - let hasAddedToolCalls = false - - eventData.toolCalls.forEach((tc: any) => { - const id = tc.id - if (id && toolCallsMap.has(id)) { - if (!toolCallPositions.has(id)) { - // First time seeing this tool call - add text buffer once - if (!hasAddedToolCalls && currentTextBuffer.trim()) { - inlineContent.push({ - type: 'text', - content: currentTextBuffer.trim(), - }) - currentTextBuffer = '' - hasAddedToolCalls = true - } - - // Add each tool call - const toolCallFromMap = toolCallsMap.get(id)! - const newIndex = inlineContent.length - inlineContent.push({ - type: 'tool_call', - content: segment, - toolCall: toolCallFromMap, - }) - toolCallPositions.set(id, newIndex) - } else { - // Update existing tool call in place - const existingIndex = toolCallPositions.get(id)! - if (inlineContent[existingIndex]?.type === 'tool_call') { - inlineContent[existingIndex].toolCall = toolCallsMap.get(id)! - } - } - } - }) - } - break - } - - case 'tool_call_complete': { - const id = eventData.toolCall?.id - if (id && toolCallsMap.has(id)) { - if (!toolCallPositions.has(id)) { - // Add text buffer before tool call - if (currentTextBuffer.trim()) { - inlineContent.push({ - type: 'text', - content: currentTextBuffer.trim(), - }) - currentTextBuffer = '' - } - - // Add tool call - const toolCall = toolCallsMap.get(id)! - const newIndex = inlineContent.length - inlineContent.push({ - type: 'tool_call', - content: segment, - toolCall, - }) - toolCallPositions.set(id, newIndex) - } else { - // Update existing tool call in place - const existingIndex = toolCallPositions.get(id)! - if (inlineContent[existingIndex]?.type === 'tool_call') { - inlineContent[existingIndex].toolCall = toolCallsMap.get(id)! - } - } - } - break - } - } - } - } catch (error) { - console.warn('Failed to parse tool call event:', error) - // If parsing fails, treat as text - currentTextBuffer += segment - } - } else { - // Regular text content - currentTextBuffer += segment - } - } - - // Add any remaining text - if (currentTextBuffer.trim()) { - inlineContent.push({ - type: 'text', - content: currentTextBuffer.trim(), - }) - } - - // FALLBACK: Ensure all tool calls from toolCallsMap are included in inlineContent - // This prevents tool calls from disappearing if parsing failed to include them - const missingToolCalls: ToolCallState[] = [] - for (const [id, toolCall] of toolCallsMap.entries()) { - if (!toolCallPositions.has(id)) { - missingToolCalls.push(toolCall) - console.warn('Tool call was not included in inline content, adding as fallback:', toolCall.name, toolCall.id) - } - } - - // Add missing tool calls at the end - missingToolCalls.forEach((toolCall) => { - inlineContent.push({ - type: 'tool_call', - content: `__TOOL_CALL_EVENT__{"type":"tool_call_complete","toolCall":${JSON.stringify(toolCall)}}__TOOL_CALL_EVENT__`, - toolCall, - }) - }) - - // Create clean text content for fallback - const cleanTextContent = content.replace(TOOL_CALL_PATTERNS.toolCallEvent, '').trim() - - return { - textContent: cleanTextContent, - toolCalls: Array.from(toolCallsMap.values()), - toolGroups: [], // No grouping for inline display - inlineContent, - } -} - -/** - * Update tool call states based on new content - */ -export function updateToolCallStates( - existingToolCalls: ToolCallState[], - newContent: string -): ToolCallState[] { - const updatedToolCalls = [...existingToolCalls] - - // Look for completion or error indicators - if (TOOL_CALL_PATTERNS.completionIndicator.test(newContent)) { - // Mark executing tools as completed - updatedToolCalls.forEach((toolCall) => { - if (toolCall.state === 'executing') { - toolCall.state = 'completed' - toolCall.endTime = Date.now() - toolCall.duration = toolCall.endTime - (toolCall.startTime || 0) - } - }) - } else if (TOOL_CALL_PATTERNS.errorIndicator.test(newContent)) { - // Mark executing tools as error - updatedToolCalls.forEach((toolCall) => { - if (toolCall.state === 'executing') { - toolCall.state = 'error' - toolCall.endTime = Date.now() - toolCall.duration = toolCall.endTime - (toolCall.startTime || 0) - toolCall.error = 'Tool execution failed' - } - }) - } - - return updatedToolCalls -} - -/** - * Check if content contains tool call indicators - */ -export function hasToolCallIndicators(content: string): boolean { - return ( - TOOL_CALL_PATTERNS.toolCallEvent.test(content) || - TOOL_CALL_PATTERNS.statusIndicator.test(content) || - TOOL_CALL_PATTERNS.functionCall.test(content) || - TOOL_CALL_PATTERNS.thinkingPattern.test(content) - ) -} - -/** - * Remove tool call indicators from content, leaving only text - */ -export function stripToolCallIndicators(content: string): string { - return content - .replace(TOOL_CALL_PATTERNS.toolCallEvent, '') - .replace(TOOL_CALL_PATTERNS.statusIndicator, '') - .replace(/\n\s*\n/g, '\n') - .trim() -} - -/** - * Parse streaming content incrementally - */ -export function parseStreamingContent( - accumulatedContent: string, - newChunk: string, - existingToolCalls: ToolCallState[] = [] -): { - parsedContent: ParsedMessageContent - updatedToolCalls: ToolCallState[] -} { - const fullContent = accumulatedContent + newChunk - const parsedContent = parseMessageContent(fullContent, existingToolCalls) - - // The parseMessageContent now handles state transitions, so we use its tool calls - const updatedToolCalls = parsedContent.toolCalls - - return { - parsedContent, - updatedToolCalls, - } -} +// This file has been removed - tool call parsing is now handled natively via SSE events +// Tool calls are stored directly in message.toolCalls array and rendered via React components diff --git a/apps/sim/stores/copilot/preview-store.ts b/apps/sim/stores/copilot/preview-store.ts index 503af428772..d209b9c8837 100644 --- a/apps/sim/stores/copilot/preview-store.ts +++ b/apps/sim/stores/copilot/preview-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import type { CopilotToolCall, CopilotMessage } from './types' export interface PreviewData { id: string @@ -30,7 +31,7 @@ interface PreviewStore { expireOldPreviews: (maxAgeHours?: number) => void markToolCallAsSeen: (toolCallId: string) => void isToolCallSeen: (toolCallId: string) => boolean - scanAndMarkExistingPreviews: (messages: any[]) => void + scanAndMarkExistingPreviews: (messages: CopilotMessage[]) => void } export const usePreviewStore = create()( @@ -225,28 +226,16 @@ export const usePreviewStore = create()( return get().seenToolCallIds.has(toolCallId) }, - scanAndMarkExistingPreviews: (messages) => { + scanAndMarkExistingPreviews: (messages: CopilotMessage[]) => { const toolCallIds = new Set() messages.forEach((message) => { - if (message.role === 'assistant' && message.content) { - const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g - let match - - while ((match = previewToolCallPattern.exec(message.content)) !== null) { - try { - const toolCallEvent = JSON.parse(match[1]) - if ( - toolCallEvent.type === 'tool_call_complete' && - toolCallEvent.toolCall?.name === 'preview_workflow' && - toolCallEvent.toolCall?.id - ) { - toolCallIds.add(toolCallEvent.toolCall.id) - } - } catch (error) { - console.warn('Failed to parse tool call event while scanning:', error) + if (message.role === 'assistant' && message.toolCalls) { + message.toolCalls.forEach((toolCall: CopilotToolCall) => { + if (toolCall.name === 'preview_workflow' && toolCall.state === 'completed' && toolCall.id) { + toolCallIds.add(toolCall.id) } - } + }) } }) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index f26ec7bc83f..fc64842f4cd 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -85,30 +85,27 @@ function handleStoreError(error: unknown, fallbackMessage: string): string { } /** - * Helper function to check if a preview_workflow tool call has completed in the content + * Helper function to get a display name for a tool */ -function checkForPreviewToolCompletion(content: string): boolean { - // Look for tool call completion events in the content - const previewToolCallPattern = /__TOOL_CALL_EVENT__(.*?)__TOOL_CALL_EVENT__/g - let match - - while ((match = previewToolCallPattern.exec(content)) !== null) { - try { - const toolCallEvent = JSON.parse(match[1]) - if ( - toolCallEvent.type === 'tool_call_complete' && - toolCallEvent.toolCall?.name === 'preview_workflow' && - toolCallEvent.toolCall?.state === 'completed' - ) { - return true // Found a completed preview tool call - } - } catch (error) { - // Ignore parsing errors for malformed events - continue - } +function getToolDisplayName(toolName: string): string { + switch (toolName) { + case 'docs_search_internal': + return 'Searching documentation' + case 'get_user_workflow': + return 'Analyzing your workflow' + case 'preview_workflow': + return 'Preview workflow changes' + case 'get_blocks_and_tools': + return 'Getting block information' + case 'get_blocks_metadata': + return 'Getting block metadata' + case 'get_yaml_structure': + return 'Analyzing workflow structure' + case 'edit_workflow': + return 'Editing your workflow' + default: + return toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) } - - return false } /** @@ -540,8 +537,19 @@ export const useCopilotStore = create()( try { const data = JSON.parse(line.slice(6)) + // Handle chat ID event (our custom event) + if (data.type === 'chat_id') { + newChatId = data.chatId + logger.info('Received chatId from stream:', newChatId) + + // Update current chat if we don't have one + const { currentChat } = get() + if (!currentChat && newChatId) { + await get().handleNewChatCreation(newChatId) + } + } // Handle native Anthropic SSE events - if (data.type === 'message_start') { + else if (data.type === 'message_start') { logger.info('Message started') } else if (data.type === 'content_block_start') { currentBlockType = data.content_block?.type @@ -551,12 +559,21 @@ export const useCopilotStore = create()( toolCallBuffer = { id: data.content_block.id, name: data.content_block.name, + displayName: getToolDisplayName(data.content_block.name), input: {}, partialInput: '', + state: 'executing', + startTime: Date.now(), } + toolCalls.push(toolCallBuffer) logger.info(`Starting tool call: ${data.content_block.name}`) - // Don't show any messages - backend handles tool execution automatically + // Update message with tool calls array + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + ), + })) } } else if (data.type === 'content_block_delta') { if (currentBlockType === 'text' && data.delta?.text) { @@ -570,7 +587,7 @@ export const useCopilotStore = create()( // Update message in real-time set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent } : msg + msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg ), })) } else if (currentBlockType === 'tool_use' && data.delta?.partial_json && toolCallBuffer) { @@ -582,12 +599,22 @@ export const useCopilotStore = create()( try { // Parse complete tool call input toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') - toolCalls.push(toolCallBuffer) + toolCallBuffer.state = 'completed' + toolCallBuffer.endTime = Date.now() + toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime logger.info(`Tool call completed: ${toolCallBuffer.name}`, toolCallBuffer.input) - // Don't show any messages - backend handles execution + // Update message with completed tool call + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + ), + })) } catch (error) { logger.error('Error parsing tool call input:', error) + toolCallBuffer.state = 'error' + toolCallBuffer.endTime = Date.now() + toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime } toolCallBuffer = null } @@ -596,15 +623,10 @@ export const useCopilotStore = create()( // Handle token usage updates silently if (data.delta?.stop_reason === 'tool_use') { logger.info('Message stopped for tool use - backend will handle execution') - // Don't complete the stream - backend will continue with tool results } } else if (data.type === 'message_stop') { - // Only complete if this is the final stop (not a tool use stop) - // The backend will send another message_stop after tool execution - logger.info('Message stop received - checking if final') - // Don't complete yet - let the backend continue if there are tools - // The stream will naturally complete when the backend closes it + logger.info('Message stop received - checking if final') } else if (data.type === 'error') { // Handle error events from backend logger.error('Backend error:', data.error) @@ -624,10 +646,25 @@ export const useCopilotStore = create()( // Final update when stream actually ends set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent } : msg + msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg ), isSendingMessage: false, })) + + // Auto-save messages after streaming completes + const { currentChat } = get() + const chatIdToSave = currentChat?.id || newChatId + + if (chatIdToSave) { + try { + logger.info('Auto-saving chat messages after streaming completion to chat:', chatIdToSave) + await get().saveChatMessages(chatIdToSave) + } catch (error) { + logger.error('Failed to auto-save chat messages:', error) + } + } else { + logger.warn('No chat ID available for auto-saving messages') + } } catch (error) { logger.error('Error handling streaming response:', error) throw error @@ -664,34 +701,36 @@ export const useCopilotStore = create()( // Save chat messages to database saveChatMessages: async (chatId: string) => { - const { messages } = get() + const { messages, chats } = get() set({ isSaving: true, saveError: null }) try { const result = await updateChatMessages(chatId, messages) if (result.success && result.chat) { + const updatedChat = result.chat + // Update local state with the saved chat set({ - currentChat: result.chat, - messages: result.chat.messages, + currentChat: updatedChat, + messages: updatedChat.messages, isSaving: false, saveError: null, }) // Update the chat in the chats list (atomic check, update, or add) set((state) => { - const chatExists = state.chats.some((chat) => chat.id === result.chat!.id) + const chatExists = state.chats.some((chat) => chat.id === updatedChat!.id) if (!chatExists) { // Chat doesn't exist, add it to the beginning return { - chats: [result.chat!, ...state.chats], + chats: [updatedChat!, ...state.chats], } } // Chat exists, update it const updatedChats = state.chats.map((chat) => - chat.id === result.chat!.id ? result.chat! : chat + chat.id === updatedChat!.id ? updatedChat! : chat ) return { chats: updatedChats } }) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 4809f73ed72..6fd43a7f6f3 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -9,7 +9,23 @@ export interface Citation { } /** - * Copilot message structure + * Tool call interface for copilot + */ +export interface CopilotToolCall { + id: string + name: string + displayName: string + input: Record + state: 'executing' | 'completed' | 'error' + startTime?: number + endTime?: number + duration?: number + result?: any + error?: string +} + +/** + * Copilot message interface */ export interface CopilotMessage { id: string @@ -17,6 +33,7 @@ export interface CopilotMessage { content: string timestamp: string citations?: Citation[] + toolCalls?: CopilotToolCall[] } /** From bc01f8dbe9aa81156b00c2616233fbd5ca144b60 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 15:06:50 -0700 Subject: [PATCH 019/184] Continuation logic --- apps/sim/providers/anthropic/index.ts | 30 ++++++++++++++++++++++++++- apps/sim/stores/copilot/store.ts | 29 +++++++++++++++++++++----- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 6d33c5df9c8..15f0727ed84 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -455,10 +455,38 @@ ${fieldDescriptions} stream: true, }) - // Stream the continuation response + // Stream the continuation response and handle any additional tool calls + let continuationToolCalls: any[] = [] + let currentContinuationToolCall: any = null + for await (const chunk of nextStreamResponse as any) { const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` controller.enqueue(encoder.encode(sseEvent)) + + // Check if the continuation response has its own tool calls + if (chunk.type === 'content_block_start' && chunk.content_block?.type === 'tool_use') { + currentContinuationToolCall = { + id: chunk.content_block.id, + name: chunk.content_block.name, + input: {}, + partialInput: '', + } + } else if (chunk.type === 'content_block_delta' && currentContinuationToolCall && chunk.delta?.partial_json) { + currentContinuationToolCall.partialInput += chunk.delta.partial_json + } else if (chunk.type === 'content_block_stop' && currentContinuationToolCall) { + try { + currentContinuationToolCall.input = JSON.parse(currentContinuationToolCall.partialInput || '{}') + continuationToolCalls.push(currentContinuationToolCall) + logger.info(`Continuation tool call ready: ${currentContinuationToolCall.name}`) + } catch (error) { + logger.error('Error parsing continuation tool call input:', error) + } + currentContinuationToolCall = null + } else if (chunk.type === 'message_stop' && continuationToolCalls.length > 0) { + // Recursively handle tool calls in the continuation + await executeToolsAndContinue(continuationToolCalls) + continuationToolCalls = [] + } } } catch (error) { logger.error('Error executing tools and continuing conversation:', { error }) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index fc64842f4cd..9892c430134 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -523,11 +523,20 @@ export const useCopilotStore = create()( let toolCallBuffer: any = null const toolCalls: any[] = [] + // Add timeout to prevent hanging + const timeoutId = setTimeout(() => { + logger.warn('Stream timeout reached, completing response') + streamComplete = true + }, 120000) // 2 minute timeout + try { while (true) { const { done, value } = await reader.read() - if (done || streamComplete) break + if (done || streamComplete) { + logger.info('Stream ended - done:', done, 'streamComplete:', streamComplete) + break + } const chunk = decoder.decode(value, { stream: true }) const lines = chunk.split('\n') @@ -622,16 +631,24 @@ export const useCopilotStore = create()( } else if (data.type === 'message_delta') { // Handle token usage updates silently if (data.delta?.stop_reason === 'tool_use') { - logger.info('Message stopped for tool use - backend will handle execution') + logger.info('Message stopped for tool use - backend will handle execution and continue') } } else if (data.type === 'message_stop') { - // Don't complete yet - let the backend continue if there are tools - logger.info('Message stop received - checking if final') + // Backend will continue streaming if there are tools to execute + // Don't break the loop - just continue listening for more events + logger.info('Message stopped - backend may continue after tool execution') + + // Reset block state for potential continuation + currentBlockType = null + toolCallBuffer = null } else if (data.type === 'error') { // Handle error events from backend logger.error('Backend error:', data.error) streamComplete = true break + } else { + // Log unhandled event types for debugging + logger.debug('Unhandled SSE event type:', data.type) } } catch (parseError) { logger.warn('Failed to parse SSE data:', parseError) @@ -668,6 +685,8 @@ export const useCopilotStore = create()( } catch (error) { logger.error('Error handling streaming response:', error) throw error + } finally { + clearTimeout(timeoutId) } }, @@ -711,9 +730,9 @@ export const useCopilotStore = create()( const updatedChat = result.chat // Update local state with the saved chat + // Don't overwrite messages - keep the current local state which has the latest content set({ currentChat: updatedChat, - messages: updatedChat.messages, isSaving: false, saveError: null, }) From 52887637739f6ea2cd3e0625f4b22c06f2e7480e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 15:37:26 -0700 Subject: [PATCH 020/184] Checkpoint --- apps/sim/app/api/copilot/route.ts | 5 +- .../[workflowId]/components/review-button.tsx | 315 +++++++----------- apps/sim/db/schema.ts | 1 + apps/sim/lib/copilot/api.ts | 3 +- apps/sim/lib/copilot/service.ts | 7 + apps/sim/providers/anthropic/index.ts | 30 +- apps/sim/stores/copilot/store.ts | 106 ++++++ apps/sim/stores/copilot/types.ts | 5 + 8 files changed, 270 insertions(+), 202 deletions(-) diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 09c4b281e7d..4071d750dda 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -74,6 +74,7 @@ const UpdateChatSchema = z.object({ ) .optional(), title: z.string().optional(), + previewYaml: z.string().nullable().optional(), }) // Schema for listing chats @@ -322,7 +323,7 @@ export async function PATCH(req: NextRequest) { } const body = await req.json() - const { chatId, messages, title } = UpdateChatSchema.parse(body) + const { chatId, messages, title, previewYaml } = UpdateChatSchema.parse(body) logger.info(`Updating chat ${chatId} for user ${session.user.id}`) @@ -349,7 +350,7 @@ export async function PATCH(req: NextRequest) { const chat = await updateChat(chatId, session.user.id, { messages, title: titleToUse, - + previewYaml: previewYaml !== undefined ? previewYaml : undefined, }) if (!chat) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index a1d3e9a05eb..9909b163903 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -1,182 +1,95 @@ 'use client' -import { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { useState, useCallback } from 'react' import { useParams } from 'next/navigation' import { Eye, FileText } from 'lucide-react' import { Button } from '@/components/ui/button' import { CopilotSandboxModal } from './copilot-sandbox-modal/copilot-sandbox-modal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useCopilotStore } from '@/stores/copilot/store' -import { usePreviewStore } from '@/stores/copilot/preview-store' import { createLogger } from '@/lib/logs/console-logger' -import type { CopilotToolCall, CopilotMessage } from '@/stores/copilot/types' const logger = createLogger('ReviewButton') -// Helper function to extract preview data from messages -export function getLatestUnseenPreview(messages: CopilotMessage[], isToolCallSeen: (id: string) => boolean) { - if (!messages.length) return null - - const foundPreviews: { toolCallId: string; messageIndex: number; workflowState: any; yamlContent: string; description?: string }[] = [] - - // Go through messages in reverse order (newest first) to find all unseen previews - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (message.role !== 'assistant' || !message.toolCalls) continue - - message.toolCalls.forEach((toolCall: CopilotToolCall) => { - if ( - toolCall.name === 'preview_workflow' && - toolCall.state === 'completed' && - toolCall.result && - toolCall.id && - !isToolCallSeen(toolCall.id) - ) { - const result = toolCall.result - let workflowState = null - let yamlContent = null - let description = null - - if (result.workflowState) { - workflowState = result.workflowState - } - - if (toolCall.input) { - yamlContent = toolCall.input.yamlContent - description = toolCall.input.description - } - - if (workflowState && yamlContent) { - foundPreviews.push({ - toolCallId: toolCall.id, - messageIndex: i, - workflowState, - yamlContent, - description, - }) - } - } - }) - } - - if (foundPreviews.length === 0) { - return null - } - - // Sort by message index (newest first) - foundPreviews.sort((a, b) => b.messageIndex - a.messageIndex) - - // Return both the latest preview and all older preview IDs to invalidate - return { - latestPreview: { - toolCallId: foundPreviews[0].toolCallId, - workflowState: foundPreviews[0].workflowState, - yamlContent: foundPreviews[0].yamlContent, - description: foundPreviews[0].description, - }, - olderPreviewIds: foundPreviews.slice(1).map(p => p.toolCallId) - } -} - // Dummy functions for backward compatibility export function setLatestPreview() { - // This is now handled automatically by scanning messages + // This is now handled automatically by the copilot store } export function clearLatestPreview() { - // This is now handled by marking tool calls as seen + // This is now handled by clearing preview YAML in the chat +} + +export function getLatestUnseenPreview() { + // Deprecated - now using currentChat.previewYaml + return null } export function ReviewButton() { const params = useParams() const workspaceId = params.workspaceId as string const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() - const { messages, sendImplicitFeedback } = useCopilotStore() - const { markToolCallAsSeen, isToolCallSeen, seenToolCallIds } = usePreviewStore( - (state) => ({ - markToolCallAsSeen: state.markToolCallAsSeen, - isToolCallSeen: state.isToolCallSeen, - seenToolCallIds: state.seenToolCallIds, // Include this to trigger re-renders - }) - ) + const { currentChat, sendImplicitFeedback, clearPreviewYaml } = useCopilotStore() const [showModal, setShowModal] = useState(false) const [isProcessing, setIsProcessing] = useState(false) - - // Add debounce timer ref to prevent premature invalidation - const invalidationTimerRef = useRef(null) - const lastToolCallIdRef = useRef(null) - - // Get the latest unseen preview from messages - const latestPreview = useMemo(() => { - console.log('useMemo: Checking for latest unseen preview, seenToolCallIds size:', seenToolCallIds.size) - const preview = getLatestUnseenPreview(messages, isToolCallSeen) - console.log('useMemo: Found preview:', !!preview, preview?.latestPreview?.toolCallId) - return preview - }, [messages, isToolCallSeen, seenToolCallIds]) - - // Debounced invalidation of older previews when a new one is detected - // Add a 5-second delay to give users time to see and interact with the button - useEffect(() => { - // Clear existing timer - if (invalidationTimerRef.current) { - clearTimeout(invalidationTimerRef.current) - invalidationTimerRef.current = null - } - - if (latestPreview && latestPreview.olderPreviewIds && latestPreview.olderPreviewIds.length > 0) { - // Check if this is actually a new preview (different from the last one) - const currentToolCallId = latestPreview.latestPreview?.toolCallId - const isNewPreview = currentToolCallId !== lastToolCallIdRef.current - - if (isNewPreview && currentToolCallId) { - console.log('New preview detected, scheduling invalidation of older previews in 5 seconds:', latestPreview.olderPreviewIds) - lastToolCallIdRef.current = currentToolCallId - - // Set a timer to invalidate older previews after 5 seconds - invalidationTimerRef.current = setTimeout(() => { - console.log('Invalidating older previews after delay:', latestPreview.olderPreviewIds) - latestPreview.olderPreviewIds.forEach(id => { - markToolCallAsSeen(id) - }) - invalidationTimerRef.current = null - }, 5000) // 5 second delay - } - } + const [previewWorkflowState, setPreviewWorkflowState] = useState(null) - // Cleanup function - return () => { - if (invalidationTimerRef.current) { - clearTimeout(invalidationTimerRef.current) - invalidationTimerRef.current = null - } - } - }, [latestPreview?.latestPreview?.toolCallId, latestPreview?.olderPreviewIds, markToolCallAsSeen]) + // Check if current chat has preview YAML + const hasPreview = currentChat?.previewYaml !== null && currentChat?.previewYaml !== undefined // Debug logging console.log('ReviewButton render:', { - hasLatestPreview: !!latestPreview?.latestPreview, + hasPreview, activeWorkflowId, - messageCount: messages.length + previewYamlLength: currentChat?.previewYaml?.length }) - // Only show if there's a real preview from copilot - if (!latestPreview?.latestPreview) { + // Only show if there's a preview YAML in the current chat + if (!hasPreview) { return null } - const handleShowPreview = () => { - setShowModal(true) + const handleShowPreview = async () => { + if (!currentChat?.previewYaml) return + + try { + // Generate workflow state from YAML for the modal + const response = await fetch('/api/workflows/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + yamlContent: currentChat.previewYaml, + applyAutoLayout: true, + }), + }) + + if (!response.ok) { + throw new Error('Failed to generate preview') + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to generate preview') + } + + // Set the generated workflow state and open modal + setPreviewWorkflowState(result.workflowState) + setShowModal(true) + } catch (error) { + logger.error('Failed to generate preview for modal:', error) + } } const handleApply = async () => { - if (!latestPreview?.latestPreview) return + if (!currentChat?.previewYaml) return try { setIsProcessing(true) logger.info('Applying preview to current workflow (store-first)', { workflowId: activeWorkflowId, - yamlLength: latestPreview.latestPreview.yamlContent.length, + yamlLength: currentChat.previewYaml.length, }) // STEP 1: Parse YAML and update local store immediately @@ -189,7 +102,7 @@ export function ReviewButton() { const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') // Parse YAML content - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(latestPreview.latestPreview.yamlContent) + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(currentChat.previewYaml) if (!yamlWorkflow || parseErrors.length > 0) { throw new Error(`Failed to parse YAML: ${parseErrors.join(', ')}`) @@ -205,52 +118,48 @@ export function ReviewButton() { // Convert ImportedBlocks to workflow store format const workflowBlocks: Record = {} const workflowEdges: any[] = [] - - // Process blocks - convert from array to record format + + // Process blocks for (const block of blocks) { - const blockId = block.id const blockConfig = getBlock(block.type) - - if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) { - // Handle loop/parallel blocks - workflowBlocks[blockId] = { - id: blockId, - type: block.type, - name: block.name, - position: block.position, - subBlocks: {}, - outputs: {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: (block as any).data || {}, - } - } else if (blockConfig) { - // Handle regular blocks with proper subBlocks setup + if (blockConfig) { const subBlocks: Record = {} - + // Set up subBlocks from block configuration blockConfig.subBlocks.forEach((subBlock) => { + const yamlValue = block.inputs[subBlock.id] subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type, - value: (block as any).inputs?.[subBlock.id] || null, + value: yamlValue !== undefined ? yamlValue : null, + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(block.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], + } } }) - - workflowBlocks[blockId] = { - id: blockId, + + const outputs = blockConfig.outputs || {} + + workflowBlocks[block.id] = { + id: block.id, type: block.type, name: block.name, - position: block.position, + position: block.position || { x: 0, y: 0 }, subBlocks, - outputs: (block as any).outputs || {}, + outputs, enabled: true, horizontalHandles: true, isWide: false, height: 0, - data: (block as any).data || {}, + data: block.data || {}, } } } @@ -301,28 +210,35 @@ export function ReviewButton() { // Extract and update subblock values const subblockValues: Record> = {} - Object.entries(layoutedBlocks).forEach(([blockId, block]) => { - subblockValues[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = (subblock as any).value - }) + Object.values(layoutedBlocks).forEach((block: any) => { + if (block.subBlocks) { + const blockValues: Record = {} + Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => { + if (subBlock.value !== undefined && subBlock.value !== null) { + blockValues[subBlockId] = subBlock.value + } + }) + if (Object.keys(blockValues).length > 0) { + subblockValues[block.id] = blockValues + } + } }) // Update subblock store - if (activeWorkflowId) { + if (Object.keys(subblockValues).length > 0) { useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, - [activeWorkflowId]: subblockValues, + [activeWorkflowId!]: subblockValues, }, })) } - logger.info('Successfully updated local stores with preview changes') + logger.info('Successfully updated local stores with preview content') - } catch (storeError) { - logger.error('Failed to update local stores:', storeError) - throw new Error(`Store update failed: ${storeError instanceof Error ? storeError.message : 'Unknown error'}`) + } catch (parseError) { + logger.error('Failed to parse and apply preview locally:', parseError) + throw parseError } // STEP 2: Save to database (in background, don't await to keep UI responsive) @@ -334,8 +250,8 @@ export function ReviewButton() { 'Content-Type': 'application/json', }, body: JSON.stringify({ - yamlContent: latestPreview.latestPreview.yamlContent, - description: latestPreview.latestPreview.description || 'Applied copilot proposal', + yamlContent: currentChat.previewYaml, + description: 'Applied copilot proposal', source: 'copilot', applyAutoLayout: true, createCheckpoint: true, @@ -364,11 +280,12 @@ export function ReviewButton() { // Save to database without blocking UI saveToDatabase() - // STEP 3: Only dismiss preview after successful store update (user has accepted) - console.log('Marking tool call as seen:', latestPreview.latestPreview.toolCallId) - markToolCallAsSeen(latestPreview.latestPreview.toolCallId) - console.log('Tool call marked as seen, closing modal') + // STEP 3: Clear preview YAML after successful store update (user has accepted) + console.log('Clearing preview YAML after successful apply') + await clearPreviewYaml() + console.log('Preview YAML cleared, closing modal') setShowModal(false) + setPreviewWorkflowState(null) // Continue the copilot conversation with acceptance message await sendImplicitFeedback('The user has accepted and applied the workflow changes. Please continue.') @@ -380,7 +297,7 @@ export function ReviewButton() { } const handleSaveAsNew = async (name: string) => { - if (!latestPreview.latestPreview.yamlContent) { + if (!currentChat?.previewYaml) { logger.error('No YAML content to save') return } @@ -390,13 +307,13 @@ export function ReviewButton() { logger.info('Creating new workflow from preview', { name, - yamlLength: latestPreview.latestPreview.yamlContent.length, + yamlLength: currentChat.previewYaml.length, }) // First create a new workflow const newWorkflowId = await createWorkflow({ name, - description: latestPreview.latestPreview.description, + description: 'Created from copilot proposal', workspaceId, }) @@ -411,8 +328,8 @@ export function ReviewButton() { 'Content-Type': 'application/json', }, body: JSON.stringify({ - yamlContent: latestPreview.latestPreview.yamlContent, - description: latestPreview.latestPreview.description || 'Created from copilot proposal', + yamlContent: currentChat.previewYaml, + description: 'Created from copilot proposal', source: 'copilot', applyAutoLayout: true, createCheckpoint: false, @@ -431,8 +348,9 @@ export function ReviewButton() { } logger.info('Successfully created new workflow from preview') - markToolCallAsSeen(latestPreview.latestPreview.toolCallId) + await clearPreviewYaml() setShowModal(false) + setPreviewWorkflowState(null) // Continue the copilot conversation with save as new message await sendImplicitFeedback(`The user has saved the workflow changes as a new workflow named "${name}". Please continue.`) @@ -444,12 +362,13 @@ export function ReviewButton() { } const handleReject = async () => { - if (!latestPreview?.latestPreview) return + if (!currentChat?.previewYaml) return try { setIsProcessing(true) - markToolCallAsSeen(latestPreview.latestPreview.toolCallId) + await clearPreviewYaml() setShowModal(false) + setPreviewWorkflowState(null) // Continue the copilot conversation with rejection message await sendImplicitFeedback('The user has rejected the workflow changes. Please continue.') @@ -462,8 +381,16 @@ export function ReviewButton() { const handleClose = () => { setShowModal(false) + setPreviewWorkflowState(null) } + // Create preview data for the sandbox modal + const previewData = currentChat?.previewYaml && previewWorkflowState ? { + workflowState: previewWorkflowState, + yamlContent: currentChat.previewYaml, + description: 'Copilot generated workflow preview' + } : null + return ( <> {/* Simple button at bottom center */} @@ -490,13 +417,13 @@ export function ReviewButton() {
    {/* Sandbox Modal */} - {showModal && latestPreview?.latestPreview && ( + {showModal && previewData && ( 0) { - // Recursively handle tool calls in the continuation - await executeToolsAndContinue(continuationToolCalls) - continuationToolCalls = [] - } + } else if (chunk.type === 'message_stop' && continuationToolCalls.length > 0) { + // Recursively handle tool calls in the continuation + await executeToolsAndContinue(continuationToolCalls) + continuationToolCalls = [] + } + + // Also check for any preview_workflow results in continuation + continuationToolCalls.forEach(toolCall => { + if (toolCall.name === 'preview_workflow') { + logger.info('Found preview_workflow in continuation, will send result after execution') + } + }) } } catch (error) { logger.error('Error executing tools and continuing conversation:', { error }) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 9892c430134..b1fa7655479 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -557,6 +557,25 @@ export const useCopilotStore = create()( await get().handleNewChatCreation(newChatId) } } + // Handle tool result events (our custom event for preview_workflow) + else if (data.type === 'tool_result') { + const { toolCallId, result } = data + if (toolCallId && result) { + // Find the corresponding tool call and update its result + const existingToolCall = toolCalls.find(tc => tc.id === toolCallId) + if (existingToolCall) { + existingToolCall.result = result + logger.info('Updated tool call result:', toolCallId, existingToolCall.name) + + // Update message with the result + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + ), + })) + } + } + } // Handle native Anthropic SSE events else if (data.type === 'message_start') { logger.info('Message started') @@ -619,6 +638,12 @@ export const useCopilotStore = create()( msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg ), })) + + // If this is a preview_workflow tool call, set the preview YAML + if (toolCallBuffer.name === 'preview_workflow' && toolCallBuffer.input?.yamlContent) { + logger.info('Setting preview YAML from completed preview_workflow tool call') + get().setPreviewYaml(toolCallBuffer.input.yamlContent) + } } catch (error) { logger.error('Error parsing tool call input:', error) toolCallBuffer.state = 'error' @@ -828,6 +853,87 @@ export const useCopilotStore = create()( logger.info('Cleared current chat and messages') }, + // Set preview YAML for current chat + setPreviewYaml: async (yamlContent: string) => { + const { currentChat } = get() + if (!currentChat) { + logger.warn('Cannot set preview YAML: no current chat') + return + } + + try { + // Update local state immediately + set((state) => ({ + currentChat: state.currentChat ? { + ...state.currentChat, + previewYaml: yamlContent + } : null + })) + + // Update database + const response = await fetch('/api/copilot', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: currentChat.id, + previewYaml: yamlContent, + }), + }) + + if (!response.ok) { + throw new Error('Failed to save preview YAML') + } + + logger.info('Preview YAML set successfully') + } catch (error) { + logger.error('Failed to set preview YAML:', error) + // Revert local state on error + set((state) => ({ + currentChat: state.currentChat ? { + ...state.currentChat, + previewYaml: null + } : null + })) + } + }, + + // Clear preview YAML for current chat + clearPreviewYaml: async () => { + const { currentChat } = get() + if (!currentChat) { + logger.warn('Cannot clear preview YAML: no current chat') + return + } + + try { + // Update local state immediately + set((state) => ({ + currentChat: state.currentChat ? { + ...state.currentChat, + previewYaml: null + } : null + })) + + // Update database + const response = await fetch('/api/copilot', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: currentChat.id, + previewYaml: null, + }), + }) + + if (!response.ok) { + throw new Error('Failed to clear preview YAML') + } + + logger.info('Preview YAML cleared successfully') + } catch (error) { + logger.error('Failed to clear preview YAML:', error) + } + }, + // Clear error state clearError: () => { set({ error: null }) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 6fd43a7f6f3..94b72a83d3a 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -63,6 +63,7 @@ export interface CopilotChat { model: string messages: CopilotMessage[] messageCount: number + previewYaml: string | null // YAML content for pending workflow preview createdAt: Date updatedAt: Date } @@ -145,6 +146,10 @@ export interface CopilotActions { loadCheckpoints: (chatId: string) => Promise revertToCheckpoint: (checkpointId: string) => Promise + // Preview management + setPreviewYaml: (yamlContent: string) => Promise + clearPreviewYaml: () => Promise + // Utility actions clearMessages: () => void clearError: () => void From 18c5f08ecbff2bbb41fe4762b244c93a0d88ffd3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 15:46:52 -0700 Subject: [PATCH 021/184] Updates --- .../professional-message.tsx | 95 +++++++++++++++---- apps/sim/stores/copilot/store.ts | 85 +++++++++++++++-- apps/sim/stores/copilot/types.ts | 18 ++++ 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 1980eca8f88..56545ac27af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -313,30 +313,85 @@ const ProfessionalMessage: FC = memo(({ message, isStr {/* Message content */}
    - {/* Tool calls and content */} + {/* Content blocks in chronological order or fallback to old layout */}
    - {/* Tool calls if available */} - {message.toolCalls && message.toolCalls.length > 0 && ( -
    - {message.toolCalls.map((toolCall) => ( - - ))} -
    - )} - - {/* Regular text content */} - {cleanTextContent && ( -
    -
    - - {cleanTextContent} - -
    -
    + {message.contentBlocks && message.contentBlocks.length > 0 ? ( + // Render content blocks in chronological order + <> + {message.contentBlocks.map((block, index) => { + if (block.type === 'text') { + const isLastTextBlock = index === message.contentBlocks!.length - 1 && block.type === 'text' + return ( +
    +
    + + {block.content} + + {/* Show streaming indicator for the last text block if message is streaming */} + {isStreaming && isLastTextBlock && ( + + )} +
    +
    + ) + } else if (block.type === 'tool_call') { + return ( + + ) + } + return null + })} + + {/* Show streaming indicator if streaming but no text content yet after tool calls */} + {isStreaming && !message.content && message.contentBlocks.every(block => block.type === 'tool_call') && ( +
    +
    +
    +
    +
    +
    +
    + Thinking... +
    +
    + )} + + ) : ( + // Fallback to old layout for messages without content blocks + <> + {/* Tool calls if available */} + {message.toolCalls && message.toolCalls.length > 0 && ( +
    + {message.toolCalls.map((toolCall) => ( + + ))} +
    + )} + + {/* Regular text content */} + {cleanTextContent && ( +
    +
    + + {cleanTextContent} + +
    +
    + )} + )} {/* Streaming indicator when no content yet */} - {!cleanTextContent && isStreaming && ( + {!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
    diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index b1fa7655479..db4b133423d 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -522,6 +522,10 @@ export const useCopilotStore = create()( let currentBlockType: 'text' | 'tool_use' | null = null let toolCallBuffer: any = null const toolCalls: any[] = [] + + // Track content blocks chronologically + const contentBlocks: any[] = [] + let currentTextBlock: any = null // Add timeout to prevent hanging const timeoutId = setTimeout(() => { @@ -582,7 +586,14 @@ export const useCopilotStore = create()( } else if (data.type === 'content_block_start') { currentBlockType = data.content_block?.type - if (currentBlockType === 'tool_use') { + if (currentBlockType === 'text') { + // Start a new text block + currentTextBlock = { + type: 'text', + content: '', + timestamp: Date.now(), + } + } else if (currentBlockType === 'tool_use') { // Start buffering a tool call toolCallBuffer = { id: data.content_block.id, @@ -594,28 +605,69 @@ export const useCopilotStore = create()( startTime: Date.now(), } toolCalls.push(toolCallBuffer) + + // Add tool call to content blocks + const toolCallBlock = { + type: 'tool_call', + toolCall: toolCallBuffer, + timestamp: Date.now(), + } + contentBlocks.push(toolCallBlock) + logger.info(`Starting tool call: ${data.content_block.name}`) - // Update message with tool calls array + // Update message with content blocks set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + msg.id === messageId ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks] + } : msg ), })) } } else if (data.type === 'content_block_delta') { if (currentBlockType === 'text' && data.delta?.text) { - // Add text content normally + // Add text content to accumulated content if (isContinuation && accumulatedContent && !accumulatedContent.endsWith(' ') && data.delta.text && !data.delta.text.startsWith(' ')) { accumulatedContent += ' ' + data.delta.text } else { accumulatedContent += data.delta.text } + // Add text to current text block + if (currentTextBlock) { + currentTextBlock.content += data.delta.text + + // Update the content blocks array with the streaming text block + const updatedContentBlocks = [...contentBlocks] + const existingBlockIndex = updatedContentBlocks.findIndex(block => + block.type === 'text' && block.timestamp === currentTextBlock.timestamp + ) + + if (existingBlockIndex >= 0) { + // Update existing block + updatedContentBlocks[existingBlockIndex] = { ...currentTextBlock } + } else { + // Add new text block to content blocks for real-time display + updatedContentBlocks.push({ ...currentTextBlock }) + } + + // Replace contentBlocks array contents + contentBlocks.splice(0, contentBlocks.length, ...updatedContentBlocks) + } + // Update message in real-time set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + msg.id === messageId ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks] + } : msg ), })) } else if (currentBlockType === 'tool_use' && data.delta?.partial_json && toolCallBuffer) { @@ -623,7 +675,10 @@ export const useCopilotStore = create()( toolCallBuffer.partialInput += data.delta.partial_json } } else if (data.type === 'content_block_stop') { - if (currentBlockType === 'tool_use' && toolCallBuffer) { + if (currentBlockType === 'text' && currentTextBlock) { + // Text block is already in contentBlocks from streaming, just clean up + currentTextBlock = null + } else if (currentBlockType === 'tool_use' && toolCallBuffer) { try { // Parse complete tool call input toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') @@ -632,10 +687,15 @@ export const useCopilotStore = create()( toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime logger.info(`Tool call completed: ${toolCallBuffer.name}`, toolCallBuffer.input) - // Update message with completed tool call + // Update message with completed tool call and content blocks set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + msg.id === messageId ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks] + } : msg ), })) @@ -685,10 +745,17 @@ export const useCopilotStore = create()( // Stream ended naturally - finalize the message logger.info(`Completed streaming response, content length: ${accumulatedContent.length}`) + // Text blocks are already in contentBlocks from streaming, no need to add again + // Final update when stream actually ends set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + msg.id === messageId ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks] + } : msg ), isSendingMessage: false, })) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 94b72a83d3a..0e0fd658adb 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -24,6 +24,23 @@ export interface CopilotToolCall { error?: string } +/** + * Content block types for preserving chronological order + */ +export interface TextContentBlock { + type: 'text' + content: string + timestamp: number +} + +export interface ToolCallContentBlock { + type: 'tool_call' + toolCall: CopilotToolCall + timestamp: number +} + +export type ContentBlock = TextContentBlock | ToolCallContentBlock + /** * Copilot message interface */ @@ -34,6 +51,7 @@ export interface CopilotMessage { timestamp: string citations?: Citation[] toolCalls?: CopilotToolCall[] + contentBlocks?: ContentBlock[] // New chronological content structure } /** From 35d9e7acbde2bdb7f0f6fe62b70a00216c936f62 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 16:04:49 -0700 Subject: [PATCH 022/184] UPdates --- .../professional-message.tsx | 54 ++++++++++++++----- .../[workflowId]/components/review-button.tsx | 39 +++++++++++--- apps/sim/stores/copilot/store.ts | 54 +++++++++++++------ apps/sim/stores/copilot/types.ts | 4 +- 4 files changed, 111 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 56545ac27af..07cbfaa2df2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -28,6 +28,12 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepN return case 'completed': return + case 'ready_for_review': + return + case 'applied': + return + case 'rejected': + return case 'error': return default: @@ -41,6 +47,12 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepN return 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100' case 'completed': return 'border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100' + case 'ready_for_review': + return 'border-purple-200 bg-purple-50 text-purple-900 dark:border-purple-800 dark:bg-purple-950 dark:text-purple-100' + case 'applied': + return 'border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100' + case 'rejected': + return 'border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-100' case 'error': return 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-100' default: @@ -60,45 +72,59 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepN return (
    - {tool.state === 'executing' && } - {tool.state === 'completed' && } + {tool.state === 'executing' && } + {tool.state === 'ready_for_review' && } + {tool.state === 'applied' && } + {tool.state === 'rejected' && } {tool.state === 'error' && }
    - {tool.displayName || tool.name} + {tool.state === 'executing' ? 'Building workflow' : (tool.displayName || tool.name)}
    {tool.state === 'executing' ? 'Building workflow...' - : tool.state === 'completed' - ? 'Changes ready for review' + : tool.state === 'ready_for_review' + ? 'Ready for review' + : tool.state === 'applied' + ? 'Applied changes' + : tool.state === 'rejected' + ? 'Rejected changes' : 'Workflow generation failed' }
    - {tool.duration && tool.state === 'completed' && ( + {tool.duration && (tool.state === 'ready_for_review' || tool.state === 'applied' || tool.state === 'rejected') && ( {formatDuration(tool.duration)} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 9909b163903..2bf146d1aac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -53,23 +53,34 @@ export function ReviewButton() { if (!currentChat?.previewYaml) return try { + // Validate YAML content before sending + const yamlContent = currentChat.previewYaml.trim() + if (!yamlContent) { + throw new Error('Preview YAML content is empty') + } + + logger.info('Generating preview with YAML content (first 200 chars):', yamlContent.substring(0, 200)) + // Generate workflow state from YAML for the modal const response = await fetch('/api/workflows/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - yamlContent: currentChat.previewYaml, + yamlContent, applyAutoLayout: true, }), }) if (!response.ok) { - throw new Error('Failed to generate preview') + const errorText = await response.text() + logger.error('Preview API response not ok:', { status: response.status, statusText: response.statusText, errorText }) + throw new Error(`Failed to generate preview: ${response.status} ${response.statusText}`) } const result = await response.json() if (!result.success) { + logger.error('Preview API returned error:', result) throw new Error(result.message || 'Failed to generate preview') } @@ -77,7 +88,12 @@ export function ReviewButton() { setPreviewWorkflowState(result.workflowState) setShowModal(true) } catch (error) { - logger.error('Failed to generate preview for modal:', error) + logger.error('Failed to generate preview for modal:', { + error: error instanceof Error ? error.message : String(error), + yamlLength: currentChat?.previewYaml?.length, + yamlPreview: currentChat?.previewYaml?.substring(0, 100) + }) + // TODO: Show user-friendly error message } } @@ -288,7 +304,10 @@ export function ReviewButton() { setPreviewWorkflowState(null) // Continue the copilot conversation with acceptance message - await sendImplicitFeedback('The user has accepted and applied the workflow changes. Please continue.') + await sendImplicitFeedback( + 'The user has accepted and applied the workflow changes. Please provide an acknowledgement.', + 'applied' + ) } catch (error) { logger.error('Failed to apply preview:', error) } finally { @@ -352,8 +371,11 @@ export function ReviewButton() { setShowModal(false) setPreviewWorkflowState(null) - // Continue the copilot conversation with save as new message - await sendImplicitFeedback(`The user has saved the workflow changes as a new workflow named "${name}". Please continue.`) + // Continue the copilot conversation with save as new message + await sendImplicitFeedback( + `The user has saved the workflow changes as a new workflow named "${name}". Please continue.`, + 'applied' + ) } catch (error) { logger.error('Failed to save preview as new workflow:', error) } finally { @@ -371,7 +393,10 @@ export function ReviewButton() { setPreviewWorkflowState(null) // Continue the copilot conversation with rejection message - await sendImplicitFeedback('The user has rejected the workflow changes. Please continue.') + await sendImplicitFeedback( + 'The user has rejected the workflow changes. Please continue.', + 'rejected' + ) } catch (error) { logger.error('Failed to reject preview:', error) } finally { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index db4b133423d..604ae428195 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -394,8 +394,8 @@ export const useCopilotStore = create()( } }, - // Send implicit feedback to continue conversation without showing user message - sendImplicitFeedback: async (implicitFeedback: string) => { + // Send implicit feedback and update preview tool call state + sendImplicitFeedback: async (implicitFeedback: string, toolCallState?: 'applied' | 'rejected') => { const { workflowId, currentChat, mode, messages } = get() if (!workflowId) { @@ -405,19 +405,38 @@ export const useCopilotStore = create()( set({ isSendingMessage: true, error: null }) - // Find the last assistant message (the one that was cut off by preview tool) - let lastAssistantMessage = null - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'assistant') { - lastAssistantMessage = messages[i] - break + // Update the preview_workflow tool call state if provided + if (toolCallState) { + // Find the last message with a preview_workflow tool call + const lastMessageWithPreview = [...messages].reverse().find(msg => + msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow') + ) + + if (lastMessageWithPreview) { + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === lastMessageWithPreview.id ? { + ...msg, + toolCalls: msg.toolCalls?.map(tc => + tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc + ), + contentBlocks: msg.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ) + } : msg + ), + })) } } - if (!lastAssistantMessage) { - logger.warn('No previous assistant message found to continue') - return - } + // Create a new assistant message for the response + const newAssistantMessage = createStreamingMessage() + + set((state) => ({ + messages: [...state.messages, newAssistantMessage], + })) try { const result = await sendStreamingMessage({ @@ -431,20 +450,20 @@ export const useCopilotStore = create()( }) if (result.success && result.stream) { - // Continue streaming to the existing assistant message - await get().handleStreamingResponse(result.stream, lastAssistantMessage.id, true) + // Stream to the new assistant message (not continuation) + await get().handleStreamingResponse(result.stream, newAssistantMessage.id, false) } else { throw new Error(result.error || 'Failed to send implicit feedback') } } catch (error) { const errorMessage = createErrorMessage( - lastAssistantMessage?.id || crypto.randomUUID(), + newAssistantMessage.id, 'Sorry, I encountered an error while processing your feedback. Please try again.' ) set((state) => ({ messages: state.messages.map((msg) => - msg.id === lastAssistantMessage?.id ? errorMessage : msg + msg.id === newAssistantMessage.id ? errorMessage : msg ), error: handleStoreError(error, 'Failed to send implicit feedback'), isSendingMessage: false, @@ -682,7 +701,8 @@ export const useCopilotStore = create()( try { // Parse complete tool call input toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') - toolCallBuffer.state = 'completed' + // Set preview_workflow tools to ready_for_review, others to completed + toolCallBuffer.state = toolCallBuffer.name === 'preview_workflow' ? 'ready_for_review' : 'completed' toolCallBuffer.endTime = Date.now() toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime logger.info(`Tool call completed: ${toolCallBuffer.name}`, toolCallBuffer.input) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 0e0fd658adb..bfa1b187684 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -16,7 +16,7 @@ export interface CopilotToolCall { name: string displayName: string input: Record - state: 'executing' | 'completed' | 'error' + state: 'executing' | 'completed' | 'error' | 'ready_for_review' | 'applied' | 'rejected' startTime?: number endTime?: number duration?: number @@ -156,7 +156,7 @@ export interface CopilotActions { // Message handling sendMessage: (message: string, options?: SendMessageOptions) => Promise - sendImplicitFeedback: (implicitFeedback: string) => Promise + sendImplicitFeedback: (implicitFeedback: string, toolCallState?: 'applied' | 'rejected') => Promise sendDocsMessage: (query: string, options?: SendDocsMessageOptions) => Promise saveChatMessages: (chatId: string) => Promise From bccdcfbdae964b641d7ac7d86c6c6a1ad13db862 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 16:11:10 -0700 Subject: [PATCH 023/184] Updateds --- apps/sim/stores/copilot/store.ts | 102 +++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 20 deletions(-) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 604ae428195..7544f615f0f 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -407,27 +407,46 @@ export const useCopilotStore = create()( // Update the preview_workflow tool call state if provided if (toolCallState) { + logger.info(`Updating preview tool call state to: ${toolCallState}`) + // Find the last message with a preview_workflow tool call const lastMessageWithPreview = [...messages].reverse().find(msg => msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow') ) if (lastMessageWithPreview) { + logger.info(`Found message to update with preview tool call:`, { + messageId: lastMessageWithPreview.id, + toolCallsCount: lastMessageWithPreview.toolCalls?.length, + contentBlocksCount: lastMessageWithPreview.contentBlocks?.length, + }) + set((state) => ({ - messages: state.messages.map((msg) => - msg.id === lastMessageWithPreview.id ? { - ...msg, - toolCalls: msg.toolCalls?.map(tc => - tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc - ), - contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' - ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } - : block - ) - } : msg - ), + messages: state.messages.map((msg) => { + if (msg.id === lastMessageWithPreview.id) { + const updatedMsg = { + ...msg, + toolCalls: msg.toolCalls?.map(tc => + tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc + ), + contentBlocks: msg.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ) + } + logger.info(`Updated message:`, { + messageId: updatedMsg.id, + toolCallStates: updatedMsg.toolCalls?.map(tc => ({ name: tc.name, state: tc.state })), + contentBlockStates: updatedMsg.contentBlocks?.filter(b => b.type === 'tool_call').map(b => ({ name: b.toolCall.name, state: b.toolCall.state })) + }) + return updatedMsg + } + return msg + }), })) + } else { + logger.warn('No message found with preview_workflow tool call to update') } } @@ -582,18 +601,45 @@ export const useCopilotStore = create()( } // Handle tool result events (our custom event for preview_workflow) else if (data.type === 'tool_result') { - const { toolCallId, result } = data - if (toolCallId && result) { + const { toolCallId, result, success } = data + if (toolCallId) { // Find the corresponding tool call and update its result const existingToolCall = toolCalls.find(tc => tc.id === toolCallId) if (existingToolCall) { - existingToolCall.result = result - logger.info('Updated tool call result:', toolCallId, existingToolCall.name) + if (success) { + existingToolCall.result = result + logger.info('Updated tool call result:', toolCallId, existingToolCall.name) + } else { + // Tool execution failed + existingToolCall.state = 'error' + existingToolCall.error = result || 'Tool execution failed' + logger.error('Tool call failed:', toolCallId, existingToolCall.name, result) + + // If this is a preview_workflow tool that failed, send error back to agent + if (existingToolCall.name === 'preview_workflow') { + logger.info('Preview workflow tool execution failed, sending error back to agent for retry') + // Send the error back to the agent after a brief delay to let the UI update + setTimeout(() => { + get().sendImplicitFeedback( + `The previous workflow YAML generation failed with error: "${existingToolCall.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` + ) + }, 1000) + } + } - // Update message with the result + // Update message with the result and content blocks set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { ...msg, content: accumulatedContent, toolCalls: [...toolCalls] } : msg + msg.id === messageId ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: msg.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall.id === toolCallId + ? { ...block, toolCall: { ...existingToolCall } } + : block + ) + } : msg ), })) } @@ -714,7 +760,11 @@ export const useCopilotStore = create()( ...msg, content: accumulatedContent, toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks] + contentBlocks: contentBlocks.map(block => + block.type === 'tool_call' && block.toolCall.id === toolCallBuffer.id + ? { ...block, toolCall: { ...toolCallBuffer } } + : block + ) } : msg ), })) @@ -729,6 +779,18 @@ export const useCopilotStore = create()( toolCallBuffer.state = 'error' toolCallBuffer.endTime = Date.now() toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime + toolCallBuffer.error = error instanceof Error ? error.message : String(error) + + // If this is a preview_workflow tool that failed, send error back to agent + if (toolCallBuffer.name === 'preview_workflow') { + logger.info('Preview workflow tool failed, sending error back to agent for retry') + // Send the error back to the agent after a brief delay to let the UI update + setTimeout(() => { + get().sendImplicitFeedback( + `The previous workflow YAML generation failed with error: "${toolCallBuffer.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` + ) + }, 1000) + } } toolCallBuffer = null } From 7cf6df2aec56f0dcea514ceed621ca04b426fd2a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 16:18:02 -0700 Subject: [PATCH 024/184] Cleanup --- .../panel/components/copilot/copilot.tsx | 8 +- .../preview-overlay/preview-overlay.tsx | 53 --- .../preview-overlay/review-files-button.tsx | 423 ------------------ .../[workflowId]/components/review-button.tsx | 24 +- apps/sim/stores/copilot/preview-store.ts | 52 +-- apps/sim/stores/copilot/store.ts | 45 +- 6 files changed, 27 insertions(+), 578 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index c76bca07728..65d36e8ad9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -21,7 +21,6 @@ import { CopilotWelcome } from './components/welcome/welcome' import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' import { usePreviewStore } from '@/stores/copilot/preview-store' -import { clearLatestPreview, getLatestUnseenPreview } from '../../../review-button' const logger = createLogger('Copilot') @@ -93,8 +92,7 @@ export const Copilot = forwardRef( // Clear any existing preview when component mounts or workflow changes useEffect(() => { - console.log('Copilot mounted or workflow changed - clearing preview') - clearLatestPreview() + // Preview clearing is now handled automatically by the copilot store }, [activeWorkflowId]) // Safety check: Clear any chat that doesn't belong to current workflow @@ -152,9 +150,7 @@ export const Copilot = forwardRef( // Handle new chat creation const handleStartNewChat = useCallback(() => { - // Clear any pending preview when starting new chat - clearLatestPreview() - + // Preview clearing is now handled automatically by the copilot store clearMessages() logger.info('Started new chat') }, [clearMessages]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx deleted file mode 100644 index 9dda6c9c52d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/preview-overlay.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Eye, GitBranch } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { usePreviewStore } from '@/stores/copilot/preview-store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface PreviewOverlayProps { - onShowPreview: (previewId: string) => void -} - -export function PreviewOverlay({ onShowPreview }: PreviewOverlayProps) { - const { activeWorkflowId } = useWorkflowRegistry() - const { getLatestPendingPreview, previews } = usePreviewStore() - - // Get latest preview, reacting to store changes - const latestPreview = activeWorkflowId ? getLatestPendingPreview(activeWorkflowId) : null - - if (!latestPreview) { - return null - } - - return ( -
    - -
    -
    - -
    -
    -
    - Workflow Changes Ready -
    -
    - {latestPreview.description || 'New workflow preview available'} -
    -
    - -
    -
    -
    - ) -} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx deleted file mode 100644 index cc4acab0182..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/preview-overlay/review-files-button.tsx +++ /dev/null @@ -1,423 +0,0 @@ -'use client' - -import { useState, useCallback } from 'react' -import { useParams } from 'next/navigation' -import { Eye, FileText, CheckCircle, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { usePreviewStore } from '@/stores/copilot/preview-store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useCopilotStore } from '@/stores/copilot/store' -import { CopilotSandboxModal } from '../copilot-sandbox-modal/copilot-sandbox-modal' -import { createLogger } from '@/lib/logs/console-logger' - -const logger = createLogger('ReviewFilesButton') - -export function ReviewFilesButton() { - const params = useParams() - const workspaceId = params.workspaceId as string - const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() - const previewStore = usePreviewStore() - const { currentChat } = useCopilotStore() - const [showModal, setShowModal] = useState(false) - const [isProcessing, setIsProcessing] = useState(false) - - // Get the latest pending preview for the current workflow and chat session - // Using the store object directly to ensure reactivity - const pendingPreview = activeWorkflowId ? previewStore.getLatestPendingPreview(activeWorkflowId, currentChat?.id) : null - - // Debug logging - logger.info('ReviewFilesButton render:', { - activeWorkflowId, - currentChatId: currentChat?.id, - hasPendingPreview: !!pendingPreview, - previewId: pendingPreview?.id, - previewStatus: pendingPreview?.status, - totalPreviews: Object.keys(previewStore.previews).length, - allPreviewIds: Object.keys(previewStore.previews), - }) - - const handleApplyToCurrentWorkflow = useCallback(async () => { - if (!activeWorkflowId || !pendingPreview?.yamlContent) { - throw new Error('No active workflow or YAML content') - } - - try { - setIsProcessing(true) - - logger.info('Applying preview to current workflow (store-first)', { - previewId: pendingPreview?.id, - yamlLength: pendingPreview?.yamlContent.length, - }) - - // STEP 1: Parse YAML and update local store immediately - try { - // Import the YAML parser - const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') - const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - - // Parse YAML content - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(pendingPreview.yamlContent) - - if (!yamlWorkflow || parseErrors.length > 0) { - throw new Error(`Failed to parse YAML: ${parseErrors.join(', ')}`) - } - - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - throw new Error(`Failed to convert YAML: ${convertErrors.join(', ')}`) - } - - // Convert ImportedBlocks to workflow store format - const { getBlock } = await import('@/blocks') - const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') - - const workflowBlocks: Record = {} - const workflowEdges: any[] = [] - const blockIdMapping = new Map() - - // Process blocks - convert from array to record format - for (const block of blocks) { - const blockId = block.id - blockIdMapping.set(block.id, blockId) - - const blockConfig = getBlock(block.type) - - if (!blockConfig && (block.type === 'loop' || block.type === 'parallel')) { - // Handle loop/parallel blocks - workflowBlocks[blockId] = { - id: blockId, - type: block.type, - name: block.name, - position: block.position, - subBlocks: {}, - outputs: {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: (block as any).data || {}, - } - } else if (blockConfig) { - // Handle regular blocks with proper subBlocks setup - const subBlocks: Record = {} - - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: (block as any).inputs?.[subBlock.id] || null, - } - }) - - workflowBlocks[blockId] = { - id: blockId, - type: block.type, - name: block.name, - position: block.position, - subBlocks, - outputs: (block as any).outputs || {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: (block as any).data || {}, - } - } - } - - // Process edges - for (const edge of edges) { - workflowEdges.push({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - }) - } - - // Generate loops and parallels - const loops = generateLoopBlocks(workflowBlocks) - const parallels = generateParallelBlocks(workflowBlocks) - - // Apply auto layout using the shared utility - const { applyAutoLayoutToBlocks } = await import('../../utils/auto-layout') - const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) - - const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks - - if (layoutResult.success) { - logger.info('Successfully applied auto layout to preview blocks') - } else { - logger.warn('Auto layout failed, using original positions:', layoutResult.error) - } - - // Update workflow store immediately - const workflowStore = useWorkflowStore.getState() - const newWorkflowState = { - blocks: layoutedBlocks, - edges: workflowEdges, - loops, - parallels, - lastSaved: Date.now(), - isDeployed: workflowStore.isDeployed, - deployedAt: workflowStore.deployedAt, - deploymentStatuses: workflowStore.deploymentStatuses, - hasActiveWebhook: workflowStore.hasActiveWebhook, - } - - useWorkflowStore.setState(newWorkflowState) - - // Extract and update subblock values - const subblockValues: Record> = {} - Object.entries(layoutedBlocks).forEach(([blockId, block]) => { - subblockValues[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = (subblock as any).value - }) - }) - - // Update subblock store - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: subblockValues, - }, - })) - - logger.info('Successfully updated local stores with preview changes') - - } catch (storeError) { - logger.error('Failed to update local stores:', storeError) - throw new Error(`Store update failed: ${storeError instanceof Error ? storeError.message : 'Unknown error'}`) - } - - // STEP 2: Save to database (in background, don't await to keep UI responsive) - const saveToDatabase = async () => { - try { - const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: pendingPreview?.yamlContent, - description: pendingPreview?.description || 'Applied copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: true, // Always create checkpoints for copilot changes - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) - } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to apply workflow changes') - } - - logger.info('Successfully saved preview to database:', { - previewId: pendingPreview?.id, - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, - }) - } catch (dbError) { - logger.error('Failed to save preview to database (store already updated):', dbError) - // Don't throw - the store is already updated, so the UI is correct - // The socket will eventually sync when the database is available - } - } - - // Save to database without blocking UI - saveToDatabase() - - // STEP 3: Only dismiss preview after successful store update (user has accepted) - if (pendingPreview) { - logger.info('Accepting preview:', { previewId: pendingPreview.id }) - previewStore.acceptPreview(pendingPreview.id) - logger.info('Preview accepted, closing modal') - } - setShowModal(false) - - logger.info('Successfully applied preview to current workflow (store-first):', { - previewId: pendingPreview?.id, - }) - - } catch (error) { - logger.error('Failed to apply preview:', error) - throw error - } finally { - setIsProcessing(false) - } - }, [activeWorkflowId, pendingPreview, previewStore]) - - const handleSaveAsNewWorkflow = useCallback(async (name: string) => { - if (!pendingPreview?.yamlContent) { - throw new Error('No YAML content to save') - } - - try { - setIsProcessing(true) - - logger.info('Creating new workflow from preview', { - name, - previewId: pendingPreview.id, - yamlLength: pendingPreview.yamlContent.length, - }) - - // First create a new workflow - const newWorkflowId = await createWorkflow({ - name, - description: pendingPreview.description, - workspaceId, - }) - - if (!newWorkflowId) { - throw new Error('Failed to create new workflow') - } - - // Then apply the YAML content to the new workflow - const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: pendingPreview.yamlContent, - description: pendingPreview.description || 'Created from copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: false, // No need for checkpoint on new workflow - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) - } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to save workflow') - } - - logger.info('Accepting preview after save as new:', { previewId: pendingPreview.id }) - previewStore.acceptPreview(pendingPreview.id) - setShowModal(false) - - logger.info('Successfully created new workflow from preview:', { - newWorkflowId, - name, - previewId: pendingPreview.id, - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, - }) - - } catch (error) { - logger.error('Failed to save preview as new workflow:', error) - throw error - } finally { - setIsProcessing(false) - } - }, [pendingPreview, createWorkflow, workspaceId, previewStore]) - - // Early return after all hooks are defined - if (!pendingPreview) { - return null - } - - const handleShowPreview = () => { - logger.info('Opening preview modal for pending preview:', { - previewId: pendingPreview.id, - workflowId: pendingPreview.workflowId, - }) - setShowModal(true) - } - - const handleReject = () => { - if (pendingPreview) { - logger.info('Rejecting preview:', { previewId: pendingPreview.id }) - previewStore.rejectPreview(pendingPreview.id) - logger.info('Preview rejected, closing modal') - } - setShowModal(false) - logger.info('Rejected preview:', { previewId: pendingPreview?.id }) - } - - const blockCount = Object.keys(pendingPreview.workflowState?.blocks || {}).length - const edgeCount = pendingPreview.workflowState?.edges?.length || 0 - - return ( - <> - {/* Review Files Button */} -
    -
    -
    -
    -
    - -
    -
    -
    - Copilot has proposed changes - - {blockCount} blocks, {edgeCount} connections - -
    - {pendingPreview.description && ( - {pendingPreview.description} - )} -
    -
    - -
    - - -
    -
    -
    -
    - - {/* Sandbox Modal */} - {showModal && ( - setShowModal(false)} - proposedWorkflowState={pendingPreview.workflowState} - yamlContent={pendingPreview.yamlContent} - description={pendingPreview.description} - onApplyToCurrentWorkflow={handleApplyToCurrentWorkflow} - onSaveAsNewWorkflow={handleSaveAsNewWorkflow} - isProcessing={isProcessing} - /> - )} - - ) -} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 2bf146d1aac..8b15915d8e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -11,19 +11,10 @@ import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('ReviewButton') -// Dummy functions for backward compatibility -export function setLatestPreview() { - // This is now handled automatically by the copilot store -} - -export function clearLatestPreview() { - // This is now handled by clearing preview YAML in the chat -} - -export function getLatestUnseenPreview() { - // Deprecated - now using currentChat.previewYaml - return null -} +// Backward compatibility exports (deprecated) +export function setLatestPreview() {} +export function clearLatestPreview() {} +export function getLatestUnseenPreview() { return null } export function ReviewButton() { const params = useParams() @@ -37,13 +28,6 @@ export function ReviewButton() { // Check if current chat has preview YAML const hasPreview = currentChat?.previewYaml !== null && currentChat?.previewYaml !== undefined - // Debug logging - console.log('ReviewButton render:', { - hasPreview, - activeWorkflowId, - previewYamlLength: currentChat?.previewYaml?.length - }) - // Only show if there's a preview YAML in the current chat if (!hasPreview) { return null diff --git a/apps/sim/stores/copilot/preview-store.ts b/apps/sim/stores/copilot/preview-store.ts index d209b9c8837..5c7f67a8d4b 100644 --- a/apps/sim/stores/copilot/preview-store.ts +++ b/apps/sim/stores/copilot/preview-store.ts @@ -49,33 +49,24 @@ export const usePreviewStore = create()( status: 'pending', } - console.log('Adding new preview:', newPreview) - - set((state) => { - const newState = { - previews: { - ...state.previews, - [id]: newPreview, - }, - } - console.log('New state after adding preview:', Object.keys(newState.previews)) - return newState - }) + set((state) => ({ + previews: { + ...state.previews, + [id]: newPreview, + }, + })) return id }, acceptPreview: (previewId) => { - console.log('acceptPreview called with:', previewId) set((state) => { const existingPreview = state.previews[previewId] if (!existingPreview) { - console.warn('Preview not found:', previewId) return state } - console.log('Updating preview status from', existingPreview.status, 'to accepted') - const newState = { + return { previews: { ...state.previews, [previewId]: { @@ -84,21 +75,16 @@ export const usePreviewStore = create()( }, }, } - console.log('New preview state:', newState.previews[previewId]) - return newState }) }, rejectPreview: (previewId) => { - console.log('rejectPreview called with:', previewId) set((state) => { const existingPreview = state.previews[previewId] if (!existingPreview) { - console.warn('Preview not found:', previewId) return state } - console.log('Updating preview status from', existingPreview.status, 'to rejected') return { previews: { ...state.previews, @@ -116,25 +102,8 @@ export const usePreviewStore = create()( const maxAge = 30 * 60 * 1000 // 30 minutes const allPreviews = Object.values(get().previews) - console.log('getLatestPendingPreview called with:', { workflowId, chatId }) - console.log('All previews in store:', allPreviews.map(p => ({ - id: p.id, - workflowId: p.workflowId, - chatId: p.chatId, - status: p.status, - timestamp: p.timestamp, - age: now - p.timestamp, - }))) - const previews = allPreviews .filter((p) => { - console.log(`Filtering preview ${p.id}:`, { - workflowMatch: p.workflowId === workflowId, - statusPending: p.status === 'pending', - chatMatch: !chatId || !p.chatId || p.chatId === chatId, - ageOk: now - p.timestamp <= maxAge, - }) - // Must be for the current workflow and pending if (p.workflowId !== workflowId || p.status !== 'pending') { return false @@ -155,10 +124,7 @@ export const usePreviewStore = create()( }) .sort((a, b) => b.timestamp - a.timestamp) - console.log('Filtered previews:', previews.map(p => ({ id: p.id, status: p.status }))) - const result = previews[0] || null - console.log('Returning preview:', result?.id || 'null') - return result + return previews[0] || null }, getPreviewById: (previewId) => { @@ -242,8 +208,6 @@ export const usePreviewStore = create()( set((state) => ({ seenToolCallIds: new Set([...state.seenToolCallIds, ...toolCallIds]) })) - - console.log('Scanned and marked existing preview tool calls:', Array.from(toolCallIds)) }, }), { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 7544f615f0f..702317709f5 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -407,46 +407,27 @@ export const useCopilotStore = create()( // Update the preview_workflow tool call state if provided if (toolCallState) { - logger.info(`Updating preview tool call state to: ${toolCallState}`) - // Find the last message with a preview_workflow tool call const lastMessageWithPreview = [...messages].reverse().find(msg => msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow') ) if (lastMessageWithPreview) { - logger.info(`Found message to update with preview tool call:`, { - messageId: lastMessageWithPreview.id, - toolCallsCount: lastMessageWithPreview.toolCalls?.length, - contentBlocksCount: lastMessageWithPreview.contentBlocks?.length, - }) - set((state) => ({ - messages: state.messages.map((msg) => { - if (msg.id === lastMessageWithPreview.id) { - const updatedMsg = { - ...msg, - toolCalls: msg.toolCalls?.map(tc => - tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc - ), - contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' - ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } - : block - ) - } - logger.info(`Updated message:`, { - messageId: updatedMsg.id, - toolCallStates: updatedMsg.toolCalls?.map(tc => ({ name: tc.name, state: tc.state })), - contentBlockStates: updatedMsg.contentBlocks?.filter(b => b.type === 'tool_call').map(b => ({ name: b.toolCall.name, state: b.toolCall.state })) - }) - return updatedMsg - } - return msg - }), + messages: state.messages.map((msg) => + msg.id === lastMessageWithPreview.id ? { + ...msg, + toolCalls: msg.toolCalls?.map(tc => + tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc + ), + contentBlocks: msg.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ) + } : msg + ), })) - } else { - logger.warn('No message found with preview_workflow tool call to update') } } From b80455820eba4b3398b3c8e46ba949914deba835 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 18:45:35 -0700 Subject: [PATCH 025/184] Update --- .../[workflowId]/components/review-button.tsx | 27 ++++-------------- apps/sim/stores/copilot/store.ts | 28 +++++++++++++++++++ apps/sim/stores/copilot/types.ts | 1 + 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 8b15915d8e5..199db066806 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -20,7 +20,7 @@ export function ReviewButton() { const params = useParams() const workspaceId = params.workspaceId as string const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() - const { currentChat, sendImplicitFeedback, clearPreviewYaml } = useCopilotStore() + const { currentChat, updatePreviewToolCallState, clearPreviewYaml } = useCopilotStore() const [showModal, setShowModal] = useState(false) const [isProcessing, setIsProcessing] = useState(false) const [previewWorkflowState, setPreviewWorkflowState] = useState(null) @@ -280,18 +280,11 @@ export function ReviewButton() { // Save to database without blocking UI saveToDatabase() - // STEP 3: Clear preview YAML after successful store update (user has accepted) - console.log('Clearing preview YAML after successful apply') + // Clear preview YAML after successful store update (user has accepted) + updatePreviewToolCallState('applied') await clearPreviewYaml() - console.log('Preview YAML cleared, closing modal') setShowModal(false) setPreviewWorkflowState(null) - - // Continue the copilot conversation with acceptance message - await sendImplicitFeedback( - 'The user has accepted and applied the workflow changes. Please provide an acknowledgement.', - 'applied' - ) } catch (error) { logger.error('Failed to apply preview:', error) } finally { @@ -351,15 +344,10 @@ export function ReviewButton() { } logger.info('Successfully created new workflow from preview') + updatePreviewToolCallState('applied') await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) - - // Continue the copilot conversation with save as new message - await sendImplicitFeedback( - `The user has saved the workflow changes as a new workflow named "${name}". Please continue.`, - 'applied' - ) } catch (error) { logger.error('Failed to save preview as new workflow:', error) } finally { @@ -372,15 +360,10 @@ export function ReviewButton() { try { setIsProcessing(true) + updatePreviewToolCallState('rejected') await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) - - // Continue the copilot conversation with rejection message - await sendImplicitFeedback( - 'The user has rejected the workflow changes. Please continue.', - 'rejected' - ) } catch (error) { logger.error('Failed to reject preview:', error) } finally { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 702317709f5..a53fbc0ee5f 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -394,6 +394,34 @@ export const useCopilotStore = create()( } }, + // Update preview tool call state without sending feedback + updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => { + const { messages } = get() + + // Find the last message with a preview_workflow tool call + const lastMessageWithPreview = [...messages].reverse().find(msg => + msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow') + ) + + if (lastMessageWithPreview) { + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === lastMessageWithPreview.id ? { + ...msg, + toolCalls: msg.toolCalls?.map(tc => + tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc + ), + contentBlocks: msg.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ) + } : msg + ), + })) + } + }, + // Send implicit feedback and update preview tool call state sendImplicitFeedback: async (implicitFeedback: string, toolCallState?: 'applied' | 'rejected') => { const { workflowId, currentChat, mode, messages } = get() diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index bfa1b187684..57248afc518 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -157,6 +157,7 @@ export interface CopilotActions { // Message handling sendMessage: (message: string, options?: SendMessageOptions) => Promise sendImplicitFeedback: (implicitFeedback: string, toolCallState?: 'applied' | 'rejected') => Promise + updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => void sendDocsMessage: (query: string, options?: SendDocsMessageOptions) => Promise saveChatMessages: (chatId: string) => Promise From af4f0a169944f8d6384f691c8fcd08f0ce446118 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 19:20:13 -0700 Subject: [PATCH 026/184] Diff checker --- apps/sim/app/api/workflows/diff/route.ts | 268 ++++++++++++++++++ .../copilot-sandbox-modal.tsx | 133 ++++++++- .../[workflowId]/components/review-button.tsx | 125 +++++++- 3 files changed, 513 insertions(+), 13 deletions(-) create mode 100644 apps/sim/app/api/workflows/diff/route.ts diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts new file mode 100644 index 00000000000..461eef315a0 --- /dev/null +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -0,0 +1,268 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import crypto from 'crypto' +import { createLogger } from '@/lib/logs/console-logger' +import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' + +const logger = createLogger('WorkflowYamlDiffAPI') + +// Request schema for YAML diff operations +const YamlDiffRequestSchema = z.object({ + original_yaml: z.string().min(1, 'Original YAML content is required'), + agent_yaml: z.string().min(1, 'Agent YAML content is required'), +}) + +type YamlDiffRequest = z.infer + +interface BlockHash { + blockId: string + name: string + hash: string +} + +interface DiffResult { + deleted_blocks: string[] + edited_blocks: string[] + new_blocks: string[] +} + +/** + * Create a hash of block contents excluding IDs, name, and connections + */ +function hashBlockContents(block: any): string { + // Create a copy of the block to avoid mutating the original + const blockCopy = JSON.parse(JSON.stringify(block)) + + // Extract the properties we want to hash + const hashableContent = { + type: blockCopy.type, + inputs: blockCopy.inputs || {}, + parentId: blockCopy.parentId || null, + } + + // Debug: Log what content will be hashed + console.log(`Hashing block content for ${block.name}:`, JSON.stringify(hashableContent, null, 2)) + + // Remove any ID fields from inputs recursively + function removeIds(obj: any): any { + if (obj === null || obj === undefined) { + return obj + } + + if (Array.isArray(obj)) { + return obj.map(removeIds) + } + + if (typeof obj === 'object') { + const cleaned: any = {} + for (const [key, value] of Object.entries(obj)) { + // Skip only actual ID fields (not fields like "apiKey" that contain "id") + if (key === 'id' || key === 'blockId' || key === 'targetId' || key === 'sourceId' || + key.endsWith('Id') || key.endsWith('_id')) { + continue + } + cleaned[key] = removeIds(value) + } + return cleaned + } + + return obj + } + + const cleanedContent = removeIds(hashableContent) + + // Debug: Log what content will actually be hashed after ID removal + console.log(`Cleaned content for ${block.name}:`, JSON.stringify(cleanedContent, null, 2)) + + // Create deterministic JSON string (sorted keys recursively) + const sortObjectKeys = (obj: any): any => { + if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) { + return obj + } + + const sorted: any = {} + Object.keys(obj).sort().forEach(key => { + sorted[key] = sortObjectKeys(obj[key]) + }) + return sorted + } + + const sortedContent = sortObjectKeys(cleanedContent) + const contentString = JSON.stringify(sortedContent) + console.log(`Final hash string for ${block.name}:`, contentString) + + // Generate SHA-256 hash + const hash = crypto.createHash('sha256').update(contentString).digest('hex') + console.log(`Generated hash for ${block.name}:`, hash.substring(0, 8)) + return hash +} + +/** + * Extract block hashes from a parsed YAML workflow + */ +function extractBlockHashes(yamlWorkflow: any): BlockHash[] { + const blockHashes: BlockHash[] = [] + + if (!yamlWorkflow.blocks || typeof yamlWorkflow.blocks !== 'object') { + return blockHashes + } + + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { + if (!block || typeof block !== 'object') { + return + } + + const hash = hashBlockContents(block) + blockHashes.push({ + blockId, + name: block.name || '', + hash, + }) + }) + + return blockHashes +} + +/** + * POST /api/workflows/diff + * Compare two YAML workflow configurations and return diff analysis + */ +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + const startTime = Date.now() + + try { + // Parse and validate request + const body = await request.json() + const { original_yaml, agent_yaml } = YamlDiffRequestSchema.parse(body) + + logger.info(`[${requestId}] Processing YAML diff request`, { + originalYamlLength: original_yaml.length, + agentYamlLength: agent_yaml.length, + }) + + // Debug: Log the actual YAML content being compared + logger.info(`[${requestId}] Original YAML content (first 500 chars):`, original_yaml.substring(0, 500)) + logger.info(`[${requestId}] Agent YAML content (first 500 chars):`, agent_yaml.substring(0, 500)) + + // Parse both YAML documents + const { data: originalWorkflow, errors: originalErrors } = parseWorkflowYaml(original_yaml) + const { data: agentWorkflow, errors: agentErrors } = parseWorkflowYaml(agent_yaml) + + // Check for parsing errors + if (!originalWorkflow || originalErrors.length > 0) { + logger.error(`[${requestId}] Original YAML parsing failed`, { originalErrors }) + return NextResponse.json({ + success: false, + message: 'Failed to parse original YAML workflow', + errors: originalErrors, + }, { status: 400 }) + } + + if (!agentWorkflow || agentErrors.length > 0) { + logger.error(`[${requestId}] Agent YAML parsing failed`, { agentErrors }) + return NextResponse.json({ + success: false, + message: 'Failed to parse agent YAML workflow', + errors: agentErrors, + }, { status: 400 }) + } + + // Extract block hashes from both workflows + const originalHashes = extractBlockHashes(originalWorkflow) + const agentHashes = extractBlockHashes(agentWorkflow) + + logger.info(`[${requestId}] Extracted block hashes`, { + originalBlockCount: originalHashes.length, + agentBlockCount: agentHashes.length, + }) + + // Create hash sets for efficient lookup + const originalHashSet = new Set(originalHashes.map(b => b.hash)) + const agentHashSet = new Set(agentHashes.map(b => b.hash)) + + // Create name-to-hash mappings for edited block detection + const originalNameToHash = new Map(originalHashes.map(b => [b.name, b.hash])) + const agentNameToHash = new Map(agentHashes.map(b => [b.name, b.hash])) + + // Create name-to-blockId mappings + const originalNameToId = new Map(originalHashes.map(b => [b.name, b.blockId])) + const agentNameToId = new Map(agentHashes.map(b => [b.name, b.blockId])) + + // Analyze differences + const result: DiffResult = { + deleted_blocks: [], + edited_blocks: [], + new_blocks: [], + } + + // Find deleted blocks: blocks in original that don't exist in agent (by name AND hash) + for (const originalBlock of originalHashes) { + const nameExistsInAgent = agentNameToHash.has(originalBlock.name) + const hashExistsInAgent = agentHashSet.has(originalBlock.hash) + + if (!nameExistsInAgent && !hashExistsInAgent) { + result.deleted_blocks.push(originalBlock.blockId) + } + } + + // Find edited and new blocks in agent workflow + for (const agentBlock of agentHashes) { + const nameExistsInOriginal = originalNameToHash.has(agentBlock.name) + const hashExistsInOriginal = originalHashSet.has(agentBlock.hash) + + logger.info(`[${requestId}] Checking agent block: ${agentBlock.name}`, { + nameExistsInOriginal, + hashExistsInOriginal, + agentHash: agentBlock.hash.substring(0, 8), + originalHash: originalNameToHash.get(agentBlock.name)?.substring(0, 8) || 'none' + }) + + if (nameExistsInOriginal) { + // Block name exists in original + const originalHash = originalNameToHash.get(agentBlock.name) + if (originalHash !== agentBlock.hash) { + // Same name but different hash = edited block + logger.info(`[${requestId}] Found edited block: ${agentBlock.name}`) + result.edited_blocks.push(agentBlock.blockId) + } + // If same name and same hash, it's unchanged (no action needed) + } else if (!hashExistsInOriginal) { + // Block name doesn't exist in original AND hash doesn't exist = new block + logger.info(`[${requestId}] Found new block: ${agentBlock.name}`) + result.new_blocks.push(agentBlock.blockId) + } + // If name doesn't exist but hash exists, it's a renamed block (treat as unchanged) + } + + const elapsed = Date.now() - startTime + + logger.info(`[${requestId}] YAML diff completed in ${elapsed}ms`, { + deletedCount: result.deleted_blocks.length, + editedCount: result.edited_blocks.length, + newCount: result.new_blocks.length, + originalBlocks: originalHashes.map(h => `${h.name}:${h.hash.substring(0, 8)}`), + agentBlocks: agentHashes.map(h => `${h.name}:${h.hash.substring(0, 8)}`), + }) + + return NextResponse.json({ + success: true, + data: result, + metadata: { + original_block_count: originalHashes.length, + agent_block_count: agentHashes.length, + processing_time_ms: elapsed, + }, + }) + + } catch (error) { + const elapsed = Date.now() - startTime + logger.error(`[${requestId}] YAML diff failed in ${elapsed}ms`, error) + + return NextResponse.json({ + success: false, + message: `Failed to process YAML diff: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: error instanceof Error ? error.message : 'Unknown error', + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx index 1504070424d..a2606b22b19 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Eye, Maximize2, Minimize2, Save, CheckCircle, X, AlertCircle, XCircle, ChevronDown } from 'lucide-react' +import { Eye, Maximize2, Minimize2, Save, CheckCircle, X, AlertCircle, XCircle, ChevronDown, Plus, Edit, Trash2 } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -14,12 +14,20 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('CopilotSandboxModal') +interface DiffInfo { + deleted_blocks: string[] + edited_blocks: string[] + new_blocks: string[] +} + interface CopilotSandboxModalProps { isOpen: boolean onClose: () => void proposedWorkflowState: WorkflowState | null yamlContent: string description?: string + diffInfo?: DiffInfo | null + isDiffLoading?: boolean onApplyToCurrentWorkflow: () => Promise onSaveAsNewWorkflow: (name: string) => Promise onReject?: () => Promise @@ -32,6 +40,8 @@ export function CopilotSandboxModal({ proposedWorkflowState, yamlContent, description, + diffInfo, + isDiffLoading = false, onApplyToCurrentWorkflow, onSaveAsNewWorkflow, onReject, @@ -107,6 +117,20 @@ export function CopilotSandboxModal({ const blockCount = Object.keys(proposedWorkflowState.blocks || {}).length const edgeCount = proposedWorkflowState.edges?.length || 0 + // Debug logging + console.log('CopilotSandboxModal rendering with props:', { + diffInfo: diffInfo ? 'present' : 'null', + isDiffLoading, + isOpen, + proposedWorkflowState: proposedWorkflowState ? 'present' : 'null' + }) + + // Helper function to get block name from ID + const getBlockName = (blockId: string): string => { + const block = proposedWorkflowState.blocks?.[blockId] + return block?.name || blockId + } + return ( + {/* Diff Information Section - Always Rendered */} +
    +
    +

    + Workflow Changes + + (Debug: diffInfo={diffInfo ? 'present' : 'null'}, loading={isDiffLoading ? 'true' : 'false'}) + +

    + + {isDiffLoading ? ( +
    +
    + Analyzing workflow changes... +
    + ) : diffInfo ? ( + <> +
    + {/* New Blocks */} + {diffInfo.new_blocks.length > 0 && ( +
    +
    + + + New Blocks ({diffInfo.new_blocks.length}) + +
    +
    + {diffInfo.new_blocks.map(blockId => ( + + {getBlockName(blockId)} + + ))} +
    +
    + )} + + {/* Edited Blocks */} + {diffInfo.edited_blocks.length > 0 && ( +
    +
    + + + Modified Blocks ({diffInfo.edited_blocks.length}) + +
    +
    + {diffInfo.edited_blocks.map(blockId => ( + + {getBlockName(blockId)} + + ))} +
    +
    + )} + + {/* Deleted Blocks */} + {diffInfo.deleted_blocks.length > 0 && ( +
    +
    + + + Deleted Blocks ({diffInfo.deleted_blocks.length}) + +
    +
    + {diffInfo.deleted_blocks.map(blockId => ( + + {blockId} + + ))} +
    +
    + )} +
    + + {/* Summary */} + {(diffInfo.new_blocks.length > 0 || diffInfo.edited_blocks.length > 0 || diffInfo.deleted_blocks.length > 0) ? ( +
    + {diffInfo.new_blocks.length + diffInfo.edited_blocks.length + diffInfo.deleted_blocks.length} total changes detected +
    + ) : ( +
    + + No changes detected - workflow appears to be identical +
    + )} + + ) : ( +
    +
    + Unable to analyze workflow changes - comparing against current workflow structure +
    +
    + Debug: No diff data available. This could be due to: +
      +
    • Current workflow has no existing blocks
    • +
    • API call to get current workflow failed
    • +
    • Diff API call failed
    • +
    • YAML parsing issues
    • +
    +
    +
    + )} +
    +
    + {/* Preview Container */}
    (null) + const [diffInfo, setDiffInfo] = useState(null) + const [isDiffLoading, setIsDiffLoading] = useState(false) // Check if current chat has preview YAML const hasPreview = currentChat?.previewYaml !== null && currentChat?.previewYaml !== undefined @@ -34,7 +36,7 @@ export function ReviewButton() { } const handleShowPreview = async () => { - if (!currentChat?.previewYaml) return + if (!currentChat?.previewYaml || !activeWorkflowId) return try { // Validate YAML content before sending @@ -46,7 +48,8 @@ export function ReviewButton() { logger.info('Generating preview with YAML content (first 200 chars):', yamlContent.substring(0, 200)) // Generate workflow state from YAML for the modal - const response = await fetch('/api/workflows/preview', { + logger.info('Step 1: Calling preview API...') + const previewResponse = await fetch('/api/workflows/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -54,29 +57,108 @@ export function ReviewButton() { applyAutoLayout: true, }), }) + logger.info('Step 1 complete: Preview API response received', { status: previewResponse.status }) - if (!response.ok) { - const errorText = await response.text() - logger.error('Preview API response not ok:', { status: response.status, statusText: response.statusText, errorText }) - throw new Error(`Failed to generate preview: ${response.status} ${response.statusText}`) + if (!previewResponse.ok) { + const errorText = await previewResponse.text() + logger.error('Preview API response not ok:', { status: previewResponse.status, statusText: previewResponse.statusText, errorText }) + throw new Error(`Failed to generate preview: ${previewResponse.status} ${previewResponse.statusText}`) } - const result = await response.json() + const previewResult = await previewResponse.json() + logger.info('Step 1 result: Preview API parsed successfully', { success: previewResult.success }) - if (!result.success) { - logger.error('Preview API returned error:', result) - throw new Error(result.message || 'Failed to generate preview') + if (!previewResult.success) { + logger.error('Preview API returned error:', previewResult) + throw new Error(previewResult.message || 'Failed to generate preview') + } + + // Get current workflow YAML for diff comparison + logger.info('Step 2: Getting current workflow YAML for diff comparison...') + let originalYaml = '' + try { + const currentWorkflowResponse = await fetch(`/api/tools/get-user-workflow`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowId: activeWorkflowId, + includeMetadata: false, + }), + }) + logger.info('Step 2: Current workflow API response received', { status: currentWorkflowResponse.status }) + + if (currentWorkflowResponse.ok) { + const currentWorkflowResult = await currentWorkflowResponse.json() + logger.info('Step 2: Current workflow API parsed', { success: currentWorkflowResult.success, hasYaml: !!currentWorkflowResult.output?.yaml }) + if (currentWorkflowResult.success && currentWorkflowResult.output?.yaml) { + originalYaml = currentWorkflowResult.output.yaml + logger.info('Step 2: Original YAML obtained', { length: originalYaml.length }) + } + } else { + logger.warn('Step 2: Current workflow API failed', { status: currentWorkflowResponse.status }) + } + } catch (yamlError) { + logger.error('Step 2: Failed to get current workflow YAML for diff:', yamlError) } - // Set the generated workflow state and open modal - setPreviewWorkflowState(result.workflowState) + // Generate diff information if we have original YAML + logger.info('Step 3: Generating diff information...') + let diffResult = null + if (originalYaml) { + try { + setIsDiffLoading(true) + logger.info('Step 3: Starting diff with original YAML length:', originalYaml.length, 'agent YAML length:', yamlContent.length) + + const diffResponse = await fetch('/api/workflows/diff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + original_yaml: originalYaml, + agent_yaml: yamlContent, + }), + }) + logger.info('Step 3: Diff API response received', { status: diffResponse.status }) + + if (diffResponse.ok) { + const diffData = await diffResponse.json() + logger.info('Step 3: Diff API response parsed:', diffData) + if (diffData.success) { + diffResult = diffData.data + logger.info('Step 3: Generated diff information successfully:', diffResult) + } else { + logger.error('Step 3: Diff API returned unsuccessful response:', diffData) + } + } else { + logger.error('Step 3: Diff API request failed:', diffResponse.status, diffResponse.statusText) + const errorText = await diffResponse.text() + logger.error('Step 3: Diff API error response:', errorText) + } + } catch (diffError) { + logger.error('Step 3: Failed to generate diff information:', diffError) + } finally { + setIsDiffLoading(false) + } + } else { + logger.warn('Step 3: No original YAML available for diff comparison') + setIsDiffLoading(false) + } + + // Set the generated workflow state, diff info, and open modal + logger.info('Step 4: Setting modal state and opening...') + setPreviewWorkflowState(previewResult.workflowState) + setDiffInfo(diffResult) + logger.info('Step 4: Opening modal with diff info:', diffResult) setShowModal(true) + logger.info('Step 4: Modal state should now be open') } catch (error) { logger.error('Failed to generate preview for modal:', { error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, yamlLength: currentChat?.previewYaml?.length, yamlPreview: currentChat?.previewYaml?.substring(0, 100) }) + // Reset loading states on error + setIsDiffLoading(false) // TODO: Show user-friendly error message } } @@ -285,6 +367,8 @@ export function ReviewButton() { await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) + setDiffInfo(null) + setIsDiffLoading(false) } catch (error) { logger.error('Failed to apply preview:', error) } finally { @@ -348,6 +432,8 @@ export function ReviewButton() { await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) + setDiffInfo(null) + setIsDiffLoading(false) } catch (error) { logger.error('Failed to save preview as new workflow:', error) } finally { @@ -364,6 +450,8 @@ export function ReviewButton() { await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) + setDiffInfo(null) + setIsDiffLoading(false) } catch (error) { logger.error('Failed to reject preview:', error) } finally { @@ -374,6 +462,8 @@ export function ReviewButton() { const handleClose = () => { setShowModal(false) setPreviewWorkflowState(null) + setDiffInfo(null) + setIsDiffLoading(false) } // Create preview data for the sandbox modal @@ -383,6 +473,15 @@ export function ReviewButton() { description: 'Copilot generated workflow preview' } : null + // Debug logging + console.log('ReviewButton render state:', { + showModal, + previewData: previewData ? 'present' : 'null', + diffInfo: diffInfo ? `present (${Object.keys(diffInfo).join(',')})` : 'null', + isDiffLoading, + hasPreviewYaml: !!currentChat?.previewYaml + }) + return ( <> {/* Simple button at bottom center */} @@ -416,6 +515,8 @@ export function ReviewButton() { proposedWorkflowState={previewData.workflowState} yamlContent={previewData.yamlContent} description={previewData.description} + diffInfo={diffInfo} + isDiffLoading={isDiffLoading} onApplyToCurrentWorkflow={handleApply} onSaveAsNewWorkflow={handleSaveAsNew} onReject={handleReject} From 80ec22cb2c7d82cb900b3b734a5ed515d4f28193 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 19:34:29 -0700 Subject: [PATCH 027/184] test --- .../[workflowId]/components/diff-controls.tsx | 126 +++++++++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 22 ++- apps/sim/stores/copilot/store.ts | 131 ++++++++++++- apps/sim/stores/copilot/types.ts | 1 + apps/sim/stores/workflow-diff/index.ts | 1 + apps/sim/stores/workflow-diff/store.ts | 175 ++++++++++++++++++ 6 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx create mode 100644 apps/sim/stores/workflow-diff/index.ts create mode 100644 apps/sim/stores/workflow-diff/store.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx new file mode 100644 index 00000000000..9216dfbedcf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -0,0 +1,126 @@ +import { Check, X, Eye } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useWorkflowDiffStore } from '@/stores/workflow-diff' +import { useCopilotStore } from '@/stores/copilot/store' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('DiffControls') + +export function DiffControls() { + const { + isShowingDiff, + diffWorkflow, + toggleDiffView, + acceptChanges, + rejectChanges, + diffMetadata + } = useWorkflowDiffStore() + + const { updatePreviewToolCallState, clearPreviewYaml } = useCopilotStore() + + // Don't show anything if no diff is available + if (!diffWorkflow) { + return null + } + + const handleToggleDiff = () => { + logger.info('Toggling diff view', { currentState: isShowingDiff }) + toggleDiffView() + } + + const handleAccept = async () => { + logger.info('Accepting proposed changes') + + try { + // Accept the changes in the diff store (this updates the main workflow store) + acceptChanges() + + // Update the copilot tool call state and clear preview YAML + updatePreviewToolCallState('applied') + await clearPreviewYaml() + + logger.info('Successfully accepted proposed changes') + } catch (error) { + logger.error('Failed to accept changes:', error) + } + } + + const handleReject = async () => { + logger.info('Rejecting proposed changes') + + try { + // Reject the changes in the diff store + rejectChanges() + + // Update the copilot tool call state and clear preview YAML + updatePreviewToolCallState('rejected') + await clearPreviewYaml() + + logger.info('Successfully rejected proposed changes') + } catch (error) { + logger.error('Failed to reject changes:', error) + } + } + + return ( +
    +
    +
    + {/* Info section */} +
    +
    + +
    +
    + + {isShowingDiff ? 'Viewing Proposed Changes' : 'Copilot has proposed changes'} + + {diffMetadata && ( + + Source: {diffMetadata.source} • {new Date(diffMetadata.timestamp).toLocaleTimeString()} + + )} +
    +
    + + {/* Controls */} +
    + {/* Toggle View Button */} + + + {/* Accept/Reject buttons - only show when viewing diff */} + {isShowingDiff && ( + <> + + + + )} +
    +
    +
    +
    + ) +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 9a18de11c04..18babea0048 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -13,6 +13,7 @@ import ReactFlow, { import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console-logger' import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar' +import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' @@ -27,6 +28,7 @@ import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' import { @@ -87,13 +89,21 @@ const WorkflowContent = React.memo(() => { const { workflows, activeWorkflowId, isLoading, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() + // Get workflow state from diff store if available, otherwise main store + const { getCurrentWorkflowForCanvas, isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + const currentWorkflowState = getCurrentWorkflowForCanvas() + const { - blocks, - edges, updateNodeDimensions, updateBlockPosition: storeUpdateBlockPosition, } = useWorkflowStore() + // Use current workflow state (could be actual or proposed) + const blocks = currentWorkflowState.blocks + const edges = currentWorkflowState.edges + const loops = currentWorkflowState.loops || {} + const parallels = currentWorkflowState.parallels || {} + // User permissions - get current user's specific permissions from context const userPermissions = useUserPermissionsContext() @@ -1510,8 +1520,12 @@ const WorkflowContent = React.memo(() => { /> - {/* Review Button - appears when there's a pending preview */} - + {/* Show DiffControls if diff is available, otherwise show ReviewButton if there's a pending preview */} + {diffWorkflow ? ( + + ) : ( + + )}
    ) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index a53fbc0ee5f..cf7e232509b 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -778,10 +778,13 @@ export const useCopilotStore = create()( ), })) - // If this is a preview_workflow tool call, set the preview YAML + // If this is a preview_workflow tool call, set the preview YAML and diff store if (toolCallBuffer.name === 'preview_workflow' && toolCallBuffer.input?.yamlContent) { logger.info('Setting preview YAML from completed preview_workflow tool call') get().setPreviewYaml(toolCallBuffer.input.yamlContent) + + // Also update the diff store with the proposed workflow state + get().updateDiffStore(toolCallBuffer.input.yamlContent) } } catch (error) { logger.error('Error parsing tool call input:', error) @@ -1116,6 +1119,132 @@ export const useCopilotStore = create()( reset: () => { set(initialState) }, + + // Update the diff store with proposed workflow changes + updateDiffStore: async (yamlContent: string) => { + try { + // Import diff store dynamically to avoid circular dependencies + const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') + + // Import YAML parsing utilities + const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') + const { getBlock } = await import('@/blocks') + const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') + + logger.info('Converting copilot YAML to workflow state for diff store') + + // Parse YAML content + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) + + if (!yamlWorkflow || parseErrors.length > 0) { + logger.error('Failed to parse YAML for diff store:', parseErrors) + return + } + + // Convert YAML to workflow format + const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) + + if (convertErrors.length > 0) { + logger.error('Failed to convert YAML for diff store:', convertErrors) + return + } + + // Convert ImportedBlocks to workflow store format + const workflowBlocks: Record = {} + const workflowEdges: any[] = [] + + // Process blocks + for (const block of blocks) { + const blockConfig = getBlock(block.type) + if (blockConfig) { + const subBlocks: Record = {} + + // Set up subBlocks from block configuration + blockConfig.subBlocks.forEach((subBlock) => { + const yamlValue = block.inputs[subBlock.id] + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: yamlValue !== undefined ? yamlValue : null, + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(block.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], + } + } + }) + + const outputs = blockConfig.outputs || {} + + workflowBlocks[block.id] = { + id: block.id, + type: block.type, + name: block.name, + position: block.position || { x: 0, y: 0 }, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: block.data || {}, + } + } + } + + // Process edges + for (const edge of edges) { + workflowEdges.push({ + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default', + }) + } + + // Generate loops and parallels + const loops = generateLoopBlocks(workflowBlocks) + const parallels = generateParallelBlocks(workflowBlocks) + + // Apply auto layout to the proposed workflow + const { applyAutoLayoutToBlocks } = await import('@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout') + const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) + + const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks + + if (layoutResult.success) { + logger.info('Successfully applied auto layout to proposed blocks') + } else { + logger.warn('Auto layout failed for proposed blocks, using original positions:', layoutResult.error) + } + + // Create the proposed workflow state + const proposedWorkflowState = { + blocks: layoutedBlocks, + edges: workflowEdges, + loops, + parallels, + lastSaved: Date.now(), + } + + // Set the proposed changes in the diff store + const diffStore = useWorkflowDiffStore.getState() + diffStore.setProposedChanges(proposedWorkflowState, 'copilot') + + logger.info('Successfully updated diff store with proposed workflow changes') + + } catch (error) { + logger.error('Failed to update diff store:', error) + } + }, }), { name: 'copilot-store' } ) diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 57248afc518..10515fe2bd3 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -180,6 +180,7 @@ export interface CopilotActions { // Internal helpers (not exposed publicly) handleStreamingResponse: (stream: ReadableStream, messageId: string, isContinuation?: boolean) => Promise handleNewChatCreation: (newChatId: string) => Promise + updateDiffStore: (yamlContent: string) => Promise } /** diff --git a/apps/sim/stores/workflow-diff/index.ts b/apps/sim/stores/workflow-diff/index.ts new file mode 100644 index 00000000000..92a2c84007d --- /dev/null +++ b/apps/sim/stores/workflow-diff/index.ts @@ -0,0 +1 @@ +export { useWorkflowDiffStore } from './store' \ No newline at end of file diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts new file mode 100644 index 00000000000..44e2ed6788e --- /dev/null +++ b/apps/sim/stores/workflow-diff/store.ts @@ -0,0 +1,175 @@ +import type { Edge } from 'reactflow' +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import { createLogger } from '@/lib/logs/console-logger' +import { useWorkflowStore } from '../workflows/workflow/store' +import { useSubBlockStore } from '../workflows/subblock/store' +import { useWorkflowRegistry } from '../workflows/registry/store' +import type { WorkflowState, BlockState } from '../workflows/workflow/types' + +const logger = createLogger('WorkflowDiffStore') + +interface WorkflowDiffState { + // The proposed workflow state to show on canvas + diffWorkflow: WorkflowState | null + // Whether we're currently showing the diff view + isShowingDiff: boolean + // Metadata about the diff + diffMetadata?: { + source: 'copilot' | 'manual' + timestamp: number + } +} + +interface WorkflowDiffActions { + // Set the proposed changes from copilot + setProposedChanges: (proposedWorkflow: WorkflowState, source?: 'copilot' | 'manual') => void + + // Toggle between showing actual vs proposed workflow + toggleDiffView: () => void + + // Accept the proposed changes (merge into main workflow store) + acceptChanges: () => void + + // Reject the proposed changes (clear diff store) + rejectChanges: () => void + + // Clear all diff state + clearDiff: () => void + + // Get the current workflow state to show on canvas (either actual or proposed) + getCurrentWorkflowForCanvas: () => WorkflowState +} + +type WorkflowDiffStore = WorkflowDiffState & WorkflowDiffActions + +const initialState: WorkflowDiffState = { + diffWorkflow: null, + isShowingDiff: false, +} + +export const useWorkflowDiffStore = create()( + devtools((set, get) => ({ + ...initialState, + + setProposedChanges: (proposedWorkflow: WorkflowState, source = 'copilot') => { + logger.info('Setting proposed changes', { source, blockCount: Object.keys(proposedWorkflow.blocks).length }) + + set({ + diffWorkflow: proposedWorkflow, + diffMetadata: { + source, + timestamp: Date.now(), + }, + // Don't automatically show diff - let user toggle + isShowingDiff: false, + }) + }, + + toggleDiffView: () => { + const { isShowingDiff, diffWorkflow } = get() + + if (!diffWorkflow) { + logger.warn('Cannot toggle diff view - no proposed changes available') + return + } + + logger.info('Toggling diff view', { newState: !isShowingDiff }) + set({ isShowingDiff: !isShowingDiff }) + }, + + acceptChanges: () => { + const { diffWorkflow } = get() + + if (!diffWorkflow) { + logger.warn('Cannot accept changes - no proposed changes available') + return + } + + logger.info('Accepting proposed changes') + + // Get the main workflow store and apply the changes + const workflowStore = useWorkflowStore.getState() + + // Update the main workflow store with the proposed changes + // Set the entire new state instead of clearing and re-adding to preserve all properties + useWorkflowStore.setState({ + blocks: diffWorkflow.blocks, + edges: diffWorkflow.edges, + loops: diffWorkflow.loops || {}, + parallels: diffWorkflow.parallels || {}, + lastSaved: Date.now(), + // Preserve existing deployment status and other metadata + isDeployed: workflowStore.isDeployed, + deployedAt: workflowStore.deployedAt, + deploymentStatuses: workflowStore.deploymentStatuses, + needsRedeployment: workflowStore.needsRedeployment, + hasActiveWebhook: workflowStore.hasActiveWebhook, + }) + + // Extract and update subblock values from the diff workflow + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + + if (activeWorkflowId) { + const subblockValues: Record> = {} + Object.entries(diffWorkflow.blocks).forEach(([blockId, block]) => { + subblockValues[blockId] = {} + Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if (subBlock.value !== undefined && subBlock.value !== null) { + subblockValues[blockId][subBlockId] = subBlock.value + } + }) + }) + + // Update subblock store with the new values + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues, + }, + })) + } + + // Clear the diff after accepting + get().clearDiff() + }, + + rejectChanges: () => { + logger.info('Rejecting proposed changes') + get().clearDiff() + }, + + clearDiff: () => { + logger.info('Clearing diff state') + set({ + diffWorkflow: null, + isShowingDiff: false, + diffMetadata: undefined, + }) + }, + + getCurrentWorkflowForCanvas: () => { + const { isShowingDiff, diffWorkflow } = get() + + if (isShowingDiff && diffWorkflow) { + logger.debug('Returning diff workflow for canvas') + return diffWorkflow + } + + // Return the actual workflow state + const workflowStore = useWorkflowStore.getState() + return { + blocks: workflowStore.blocks, + edges: workflowStore.edges, + loops: workflowStore.loops, + parallels: workflowStore.parallels, + lastSaved: workflowStore.lastSaved, + isDeployed: workflowStore.isDeployed, + deployedAt: workflowStore.deployedAt, + deploymentStatuses: workflowStore.deploymentStatuses, + needsRedeployment: workflowStore.needsRedeployment, + hasActiveWebhook: workflowStore.hasActiveWebhook, + } + }, + })) +) \ No newline at end of file From fbaca669c7f628042f1f8cd7207dd3409e435445 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 19:38:33 -0700 Subject: [PATCH 028/184] Checkpoint --- apps/sim/stores/workflow-diff/store.ts | 51 +++++++++++++++----------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 44e2ed6788e..d388d69a1e0 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -90,10 +90,10 @@ export const useWorkflowDiffStore = create()( // Get the main workflow store and apply the changes const workflowStore = useWorkflowStore.getState() - - // Update the main workflow store with the proposed changes - // Set the entire new state instead of clearing and re-adding to preserve all properties - useWorkflowStore.setState({ + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + + // Apply the diff workflow state directly (same as modal's approach) + const newWorkflowState = { blocks: diffWorkflow.blocks, edges: diffWorkflow.edges, loops: diffWorkflow.loops || {}, @@ -105,29 +105,36 @@ export const useWorkflowDiffStore = create()( deploymentStatuses: workflowStore.deploymentStatuses, needsRedeployment: workflowStore.needsRedeployment, hasActiveWebhook: workflowStore.hasActiveWebhook, - }) - - // Extract and update subblock values from the diff workflow - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - + } + + useWorkflowStore.setState(newWorkflowState) + + // Extract and update subblock values (exactly like modal's logic) if (activeWorkflowId) { const subblockValues: Record> = {} - Object.entries(diffWorkflow.blocks).forEach(([blockId, block]) => { - subblockValues[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if (subBlock.value !== undefined && subBlock.value !== null) { - subblockValues[blockId][subBlockId] = subBlock.value + Object.values(diffWorkflow.blocks).forEach((block: any) => { + if (block.subBlocks) { + const blockValues: Record = {} + Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => { + if (subBlock.value !== undefined && subBlock.value !== null) { + blockValues[subBlockId] = subBlock.value + } + }) + if (Object.keys(blockValues).length > 0) { + subblockValues[block.id] = blockValues } - }) + } }) - // Update subblock store with the new values - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: subblockValues, - }, - })) + // Update subblock store (exactly like modal's logic) + if (Object.keys(subblockValues).length > 0) { + useSubBlockStore.setState((state: any) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues, + }, + })) + } } // Clear the diff after accepting From 5b461e4fde3fe51bf6db70818d372b9593f85068 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 19:58:45 -0700 Subject: [PATCH 029/184] Checkpoint --- .../sub-block/hooks/use-sub-block-value.ts | 29 +++++++++++++++---- apps/sim/stores/copilot/store.ts | 3 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts index 44fde169acc..4d897cb3344 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts @@ -6,6 +6,7 @@ import { getProviderFromModel } from '@/providers/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' const logger = createLogger('SubBlockValue') @@ -60,6 +61,12 @@ export function useSubBlockValue( useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId]) ) + // Check if we're in diff mode and get diff value if available + const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + const diffValue = isShowingDiff && diffWorkflow + ? diffWorkflow.blocks?.[blockId]?.subBlocks?.[subBlockId]?.value ?? null + : null + // Check if this is an API key field that could be auto-filled const isApiKey = subBlockId === 'apiKey' || (subBlockId?.toLowerCase().includes('apikey') ?? false) @@ -100,6 +107,12 @@ export function useSubBlockValue( // Hook to set a value in the subblock store const setValue = useCallback( (newValue: T) => { + // Don't allow updates when in diff mode (readonly preview) + if (isShowingDiff) { + logger.debug('Ignoring setValue in diff mode', { blockId, subBlockId }) + return + } + // Use deep comparison to avoid unnecessary updates for complex objects if (!isEqual(valueRef.current, newValue)) { valueRef.current = newValue @@ -175,23 +188,27 @@ export function useSubBlockValue( modelValue, isStreaming, emitValue, + isShowingDiff, ] ) + // Determine the effective value: diff value takes precedence if in diff mode + const effectiveValue = isShowingDiff && diffValue !== null ? diffValue : (storeValue !== undefined ? storeValue : initialValue) + // Initialize valueRef on first render useEffect(() => { - valueRef.current = storeValue !== undefined ? storeValue : initialValue + valueRef.current = effectiveValue }, []) - // Update the ref if the store value changes + // Update the ref if the effective value changes // This ensures we're always working with the latest value useEffect(() => { // Use deep comparison for objects to prevent unnecessary updates - if (!isEqual(valueRef.current, storeValue)) { - valueRef.current = storeValue !== undefined ? storeValue : initialValue + if (!isEqual(valueRef.current, effectiveValue)) { + valueRef.current = effectiveValue } - }, [storeValue, initialValue]) + }, [effectiveValue]) // Return appropriate tuple based on whether options were provided - return [storeValue !== undefined ? storeValue : initialValue, setValue] as const + return [effectiveValue, setValue] as const } diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index cf7e232509b..7f524f7e5a3 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1129,6 +1129,7 @@ export const useCopilotStore = create()( // Import YAML parsing utilities const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') const { getBlock } = await import('@/blocks') + const { resolveOutputType } = await import('@/blocks/utils') const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') logger.info('Converting copilot YAML to workflow state for diff store') @@ -1180,7 +1181,7 @@ export const useCopilotStore = create()( } }) - const outputs = blockConfig.outputs || {} + const outputs = resolveOutputType(blockConfig.outputs) workflowBlocks[block.id] = { id: block.id, From 9fced5bb32954669e1a77039bcc0221fcd65b691 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 20:02:39 -0700 Subject: [PATCH 030/184] Checkpoint again --- .../[workflowId]/components/diff-controls.tsx | 2 +- apps/sim/stores/workflow-diff/store.ts | 90 +++++++++++-------- 2 files changed, 56 insertions(+), 36 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx index 9216dfbedcf..fe3848939ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -33,7 +33,7 @@ export function DiffControls() { try { // Accept the changes in the diff store (this updates the main workflow store) - acceptChanges() + await acceptChanges() // Update the copilot tool call state and clear preview YAML updatePreviewToolCallState('applied') diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index d388d69a1e0..4ed3fe40c0d 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -29,7 +29,7 @@ interface WorkflowDiffActions { toggleDiffView: () => void // Accept the proposed changes (merge into main workflow store) - acceptChanges: () => void + acceptChanges: () => Promise // Reject the proposed changes (clear diff store) rejectChanges: () => void @@ -78,7 +78,7 @@ export const useWorkflowDiffStore = create()( set({ isShowingDiff: !isShowingDiff }) }, - acceptChanges: () => { + acceptChanges: async () => { const { diffWorkflow } = get() if (!diffWorkflow) { @@ -88,30 +88,19 @@ export const useWorkflowDiffStore = create()( logger.info('Accepting proposed changes') - // Get the main workflow store and apply the changes - const workflowStore = useWorkflowStore.getState() const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - // Apply the diff workflow state directly (same as modal's approach) - const newWorkflowState = { - blocks: diffWorkflow.blocks, - edges: diffWorkflow.edges, - loops: diffWorkflow.loops || {}, - parallels: diffWorkflow.parallels || {}, - lastSaved: Date.now(), - // Preserve existing deployment status and other metadata - isDeployed: workflowStore.isDeployed, - deployedAt: workflowStore.deployedAt, - deploymentStatuses: workflowStore.deploymentStatuses, - needsRedeployment: workflowStore.needsRedeployment, - hasActiveWebhook: workflowStore.hasActiveWebhook, + + if (!activeWorkflowId) { + logger.error('No active workflow ID for accepting changes') + return } - useWorkflowStore.setState(newWorkflowState) - - // Extract and update subblock values (exactly like modal's logic) - if (activeWorkflowId) { - const subblockValues: Record> = {} + try { + // Convert diff workflow to YAML using the same approach as other components + const { generateWorkflowYaml } = await import('@/lib/workflows/yaml-generator') + + // Extract subblock values from diff workflow + const subBlockValues: Record> = {} Object.values(diffWorkflow.blocks).forEach((block: any) => { if (block.subBlocks) { const blockValues: Record = {} @@ -121,24 +110,55 @@ export const useWorkflowDiffStore = create()( } }) if (Object.keys(blockValues).length > 0) { - subblockValues[block.id] = blockValues + subBlockValues[block.id] = blockValues } } }) + + // Generate YAML from diff workflow + const yamlContent = generateWorkflowYaml(diffWorkflow, subBlockValues) + + // Use the same consolidated YAML endpoint as the YAML editor + const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent, + description: 'Applied copilot changes', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: false, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + logger.error('Failed to apply diff changes:', errorData) + throw new Error(errorData.message || `Failed to apply changes: ${response.statusText}`) + } - // Update subblock store (exactly like modal's logic) - if (Object.keys(subblockValues).length > 0) { - useSubBlockStore.setState((state: any) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: subblockValues, - }, - })) + const result = await response.json() + + if (!result.success) { + logger.error('Failed to apply diff changes:', result) + throw new Error(result.message || 'Failed to apply workflow changes') } + + logger.info('Successfully applied diff changes via YAML endpoint', { + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + + // Clear the diff after successful acceptance + get().clearDiff() + + } catch (error) { + logger.error('Error accepting diff changes:', error) + // Don't clear diff on error so user can try again + throw error } - - // Clear the diff after accepting - get().clearDiff() }, rejectChanges: () => { From 7162fc45edeff86d2ec22a37c0a1216879abe52b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 20:06:13 -0700 Subject: [PATCH 031/184] checkpoint again --- .../hooks/use-workflow-execution.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 9d57249bbb2..7c334cf69e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -17,6 +17,7 @@ import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' const logger = createLogger('useWorkflowExecution') @@ -45,6 +46,7 @@ interface DebugValidationResult { export function useWorkflowExecution() { const { blocks, edges, loops, parallels } = useWorkflowStore() const { activeWorkflowId } = useWorkflowRegistry() + const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() const { toggleConsole } = useConsoleStore() const { getAllVariables } = useEnvironmentStore() const { isDebugModeEnabled } = useGeneralStore() @@ -418,8 +420,24 @@ export function useWorkflowExecution() { onStream?: (se: StreamingExecution) => Promise, executionId?: string ): Promise => { + // Determine which workflow state to use - diff or main + const workflowBlocks = isShowingDiff && diffWorkflow ? diffWorkflow.blocks : blocks + const workflowEdges = isShowingDiff && diffWorkflow ? diffWorkflow.edges : edges + const workflowLoops = isShowingDiff && diffWorkflow ? diffWorkflow.loops || {} : loops + const workflowParallels = isShowingDiff && diffWorkflow ? diffWorkflow.parallels || {} : parallels + + logger.info('Executing workflow', { + mode: isShowingDiff ? 'diff' : 'main', + blocksCount: Object.keys(workflowBlocks).length, + edgesCount: workflowEdges.length + }) + // Use the mergeSubblockState utility to get all block states - const mergedStates = mergeSubblockState(blocks) + // In diff mode, subblock values are already in the workflow blocks + // In normal mode, we need to merge from the subblock store + const mergedStates = isShowingDiff && diffWorkflow + ? workflowBlocks // Diff blocks already have embedded subblock values + : mergeSubblockState(workflowBlocks) // Filter out trigger blocks for manual execution const filteredStates = Object.entries(mergedStates).reduce( @@ -476,7 +494,7 @@ export function useWorkflowExecution() { return blockConfig?.category === 'triggers' }) - const filteredEdges = edges.filter( + const filteredEdges = workflowEdges.filter( (edge) => !triggerBlockIds.includes(edge.source) && !triggerBlockIds.includes(edge.target) ) @@ -484,8 +502,8 @@ export function useWorkflowExecution() { const workflow = new Serializer().serializeWorkflow( filteredStates, filteredEdges, - loops, - parallels + workflowLoops, + workflowParallels ) // Determine if this is a chat execution From 9c0eba44d8d7a5ccbbf0a2a0c21b7c99ef2f8a8f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 20:10:16 -0700 Subject: [PATCH 032/184] Chat box output format --- .../components/output-select/output-select.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx index d1f8d75de0f..99d72e94aa3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils' import { getBlock } from '@/blocks' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' interface OutputSelectProps { workflowId: string | null @@ -24,6 +25,10 @@ export function OutputSelect({ const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false) const dropdownRef = useRef(null) const blocks = useWorkflowStore((state) => state.blocks) + const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + + // Use diff blocks when in diff mode, otherwise use main blocks + const workflowBlocks = isShowingDiff && diffWorkflow ? diffWorkflow.blocks : blocks // Get workflow outputs for the dropdown const workflowOutputs = useMemo(() => { @@ -39,7 +44,7 @@ export function OutputSelect({ if (!workflowId) return outputs // Process blocks to extract outputs - Object.values(blocks).forEach((block) => { + Object.values(workflowBlocks).forEach((block) => { // Skip starter/start blocks if (block.type === 'starter') return @@ -50,7 +55,10 @@ export function OutputSelect({ : `block-${block.id}` // Check for custom response format first - const responseFormatValue = useSubBlockStore.getState().getValue(block.id, 'responseFormat') + // In diff mode, get value from diff blocks; otherwise use store + const responseFormatValue = isShowingDiff && diffWorkflow + ? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value + : useSubBlockStore.getState().getValue(block.id, 'responseFormat') const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id) let outputsToProcess: Record = {} @@ -131,7 +139,7 @@ export function OutputSelect({ }) return outputs - }, [blocks, workflowId]) + }, [workflowBlocks, workflowId, isShowingDiff]) // Get selected outputs display text const selectedOutputsDisplayText = useMemo(() => { From 45e8b23e5a9a0c5dc1f78ef0663c2b4aaee5447c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 20:14:27 -0700 Subject: [PATCH 033/184] Auto open diff canvas --- apps/sim/stores/workflow-diff/store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 4ed3fe40c0d..29f2e568a2a 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -61,8 +61,8 @@ export const useWorkflowDiffStore = create()( source, timestamp: Date.now(), }, - // Don't automatically show diff - let user toggle - isShowingDiff: false, + // Automatically show diff for copilot changes, let user toggle for manual changes + isShowingDiff: source === 'copilot', }) }, From f97026f33799c82c1b66af2238516aab97ae7af9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 20:22:06 -0700 Subject: [PATCH 034/184] Checkpoint --- .../workflow-block/workflow-block.tsx | 16 +++- apps/sim/stores/copilot/store.ts | 2 +- apps/sim/stores/workflow-diff/store.ts | 73 ++++++++++++++++++- apps/sim/stores/workflows/workflow/types.ts | 1 + 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 74c662d7259..e9d015b530b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -16,6 +16,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' import { SubBlock } from './components/sub-block/sub-block' @@ -63,7 +64,17 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Workflow store selectors const lastUpdate = useWorkflowStore((state) => state.lastUpdate) - const isEnabled = useWorkflowStore((state) => state.blocks[id]?.enabled ?? true) + const mainBlock = useWorkflowStore((state) => state.blocks[id]) + + // Diff-aware block selector + const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + const diffBlock = diffWorkflow?.blocks[id] + + // Use diff block if in diff mode, otherwise use main block + const currentBlock = isShowingDiff && diffBlock ? diffBlock : mainBlock + + const isEnabled = currentBlock?.enabled ?? true + const diffStatus = currentBlock?.is_diff const horizontalHandles = data.isPreview ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency @@ -459,6 +470,9 @@ export function WorkflowBlock({ id, data }: NodeProps) { !isEnabled && 'shadow-sm', isActive && 'animate-pulse-ring ring-2 ring-blue-500', isPending && 'ring-2 ring-amber-500', + // Diff highlighting + diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', + diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', 'z-[20]' )} > diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 7f524f7e5a3..8610c6f0d6f 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1238,7 +1238,7 @@ export const useCopilotStore = create()( // Set the proposed changes in the diff store const diffStore = useWorkflowDiffStore.getState() - diffStore.setProposedChanges(proposedWorkflowState, 'copilot') + await diffStore.setProposedChanges(proposedWorkflowState, 'copilot') logger.info('Successfully updated diff store with proposed workflow changes') diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 29f2e568a2a..63e3ea19d91 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -18,12 +18,17 @@ interface WorkflowDiffState { diffMetadata?: { source: 'copilot' | 'manual' timestamp: number + diffAnalysis?: { + deleted_blocks: string[] + edited_blocks: string[] + new_blocks: string[] + } } } interface WorkflowDiffActions { // Set the proposed changes from copilot - setProposedChanges: (proposedWorkflow: WorkflowState, source?: 'copilot' | 'manual') => void + setProposedChanges: (proposedWorkflow: WorkflowState, source?: 'copilot' | 'manual') => Promise // Toggle between showing actual vs proposed workflow toggleDiffView: () => void @@ -52,14 +57,76 @@ export const useWorkflowDiffStore = create()( devtools((set, get) => ({ ...initialState, - setProposedChanges: (proposedWorkflow: WorkflowState, source = 'copilot') => { + setProposedChanges: async (proposedWorkflow: WorkflowState, source = 'copilot') => { logger.info('Setting proposed changes', { source, blockCount: Object.keys(proposedWorkflow.blocks).length }) + // Get current workflow YAML and analyze diff if possible + let diffAnalysis: any = null + try { + // Get current workflow YAML + const { activeWorkflowId } = useWorkflowRegistry.getState() + if (activeWorkflowId) { + const currentWorkflowResponse = await fetch('/api/tools/get-user-workflow', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflowId: activeWorkflowId, + includeMetadata: false, + }), + }) + + if (currentWorkflowResponse.ok) { + const currentWorkflowResult = await currentWorkflowResponse.json() + if (currentWorkflowResult.success && currentWorkflowResult.output?.yaml) { + // Convert proposed workflow to YAML for comparison + const { generateWorkflowYaml } = await import('@/lib/workflows/yaml-generator') + const proposedYaml = generateWorkflowYaml(proposedWorkflow) + + // Call diff API + const diffResponse = await fetch('/api/workflows/diff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + original_yaml: currentWorkflowResult.output.yaml, + agent_yaml: proposedYaml, + }), + }) + + if (diffResponse.ok) { + const diffData = await diffResponse.json() + if (diffData.success) { + diffAnalysis = diffData.data + logger.info('Generated diff analysis', diffAnalysis) + } + } + } + } + } + } catch (error) { + logger.error('Failed to generate diff analysis:', error) + } + + // Add is_diff field to blocks based on diff analysis + const enhancedWorkflow = { ...proposedWorkflow } + if (diffAnalysis) { + Object.keys(enhancedWorkflow.blocks).forEach(blockId => { + const block = enhancedWorkflow.blocks[blockId] + if (diffAnalysis.new_blocks.includes(blockId)) { + block.is_diff = 'new' + } else if (diffAnalysis.edited_blocks.includes(blockId)) { + block.is_diff = 'edited' + } else { + block.is_diff = 'unchanged' + } + }) + } + set({ - diffWorkflow: proposedWorkflow, + diffWorkflow: enhancedWorkflow, diffMetadata: { source, timestamp: Date.now(), + diffAnalysis, }, // Automatically show diff for copilot changes, let user toggle for manual changes isShowingDiff: source === 'copilot', diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 05b31bfbc25..e36674601dd 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -75,6 +75,7 @@ export interface BlockState { height?: number advancedMode?: boolean data?: BlockData + is_diff?: 'new' | 'edited' | 'unchanged' } export interface SubBlockState { From 8323596777912938c93635708c338111e2f8abb0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 23 Jul 2025 20:39:24 -0700 Subject: [PATCH 035/184] Checkpoit --- .../workspace/[workspaceId]/w/[workflowId]/workflow.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 18babea0048..2d5b9f2e09e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -18,7 +18,7 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel' import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node' -import { ReviewButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/review-button' +// import { ReviewButton } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/review-button' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' @@ -1521,11 +1521,16 @@ const WorkflowContent = React.memo(() => { {/* Show DiffControls if diff is available, otherwise show ReviewButton if there's a pending preview */} + {diffWorkflow && ( + + )} + {/* {diffWorkflow ? ( ) : ( )} + */}
    ) From 391ae78f2954530dc12f217b6db4fb3dc27bd15c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 11:07:33 -0700 Subject: [PATCH 036/184] Cleaning? --- .../w/[workflowId]/utils/auto-layout.ts | 2 +- apps/sim/stores/workflow-diff/store.ts | 17 +++-------------- apps/sim/stores/workflows/index.ts | 14 ++++++++------ apps/sim/stores/workflows/workflow/store.ts | 17 +++++++++++++++++ apps/sim/stores/workflows/workflow/types.ts | 3 +++ 5 files changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts index 1bf135fdecc..fb16acbe9b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts @@ -132,7 +132,7 @@ export async function applyAutoLayoutAndUpdateStore( // Update workflow store immediately with new positions const newWorkflowState = { - ...workflowStore, + ...workflowStore.getWorkflowState(), blocks: result.layoutedBlocks, lastSaved: Date.now(), } diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 63e3ea19d91..1d1cb23c078 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -250,20 +250,9 @@ export const useWorkflowDiffStore = create()( return diffWorkflow } - // Return the actual workflow state - const workflowStore = useWorkflowStore.getState() - return { - blocks: workflowStore.blocks, - edges: workflowStore.edges, - loops: workflowStore.loops, - parallels: workflowStore.parallels, - lastSaved: workflowStore.lastSaved, - isDeployed: workflowStore.isDeployed, - deployedAt: workflowStore.deployedAt, - deploymentStatuses: workflowStore.deploymentStatuses, - needsRedeployment: workflowStore.needsRedeployment, - hasActiveWebhook: workflowStore.hasActiveWebhook, - } + // Return the actual workflow state using the main store's method + // This eliminates code duplication and automatically stays in sync with WorkflowState changes + return useWorkflowStore.getState().getWorkflowState() }, })) ) \ No newline at end of file diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 5fe909c51f6..9a6603a1b7b 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -35,13 +35,11 @@ export function getWorkflowWithValues(workflowId: string) { // Use the current state from the store (only available for active workflow) const workflowState: WorkflowState = { - blocks: currentState.blocks, - edges: currentState.edges, - loops: currentState.loops, - parallels: currentState.parallels, + // Use the main store's method to get the base workflow state + ...useWorkflowStore.getState().getWorkflowState(), + // Override deployment fields with registry-specific deployment status isDeployed: deploymentStatus?.isDeployed || false, deployedAt: deploymentStatus?.deployedAt, - lastSaved: currentState.lastSaved, } // Merge the subblock values for this specific workflow @@ -104,13 +102,17 @@ export function getAllWorkflowsWithValues() { // Ensure state has all required fields for Zod validation const workflowState: WorkflowState = { + // Use the main store's method to get the base workflow state with fallback values + ...useWorkflowStore.getState().getWorkflowState(), + // Ensure fallback values for safer handling blocks: currentState.blocks || {}, edges: currentState.edges || [], loops: currentState.loops || {}, parallels: currentState.parallels || {}, + lastSaved: currentState.lastSaved || Date.now(), + // Override deployment fields with registry-specific deployment status isDeployed: deploymentStatus?.isDeployed || false, deployedAt: deploymentStatus?.deployedAt, - lastSaved: currentState.lastSaved || Date.now(), } // Merge the subblock values for this specific workflow diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 9af7799e029..a98e5434721 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -450,6 +450,23 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + // Add method to get current workflow state (eliminates duplication in diff store) + getWorkflowState: (): WorkflowState => { + const state = get() + return { + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + lastSaved: state.lastSaved, + isDeployed: state.isDeployed, + deployedAt: state.deployedAt, + deploymentStatuses: state.deploymentStatuses, + needsRedeployment: state.needsRedeployment, + hasActiveWebhook: state.hasActiveWebhook, + } + }, + toggleBlockEnabled: (id: string) => { const newState = { blocks: { diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index e36674601dd..108490d9afc 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -195,6 +195,9 @@ export interface WorkflowActions { // Add the sync control methods to the WorkflowActions interface sync: SyncControl + + // Add method to get current workflow state (eliminates duplication in diff store) + getWorkflowState: () => WorkflowState } export type WorkflowStore = WorkflowState & WorkflowActions From e69d818205d60b1fcb1c88e881be08ae07879898 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 11:21:08 -0700 Subject: [PATCH 037/184] Diff checkpoint --- .../hooks/use-workflow-execution.ts | 29 +++++++------ .../[workspaceId]/w/[workflowId]/workflow.tsx | 41 +++++++++++++------ 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 7c334cf69e9..7af08629ac4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -412,6 +412,8 @@ export function useWorkflowExecution() { setExecutor, setPendingBlocks, setActiveBlocks, + isShowingDiff, + diffWorkflow, ] ) @@ -420,14 +422,21 @@ export function useWorkflowExecution() { onStream?: (se: StreamingExecution) => Promise, executionId?: string ): Promise => { - // Determine which workflow state to use - diff or main - const workflowBlocks = isShowingDiff && diffWorkflow ? diffWorkflow.blocks : blocks - const workflowEdges = isShowingDiff && diffWorkflow ? diffWorkflow.edges : edges - const workflowLoops = isShowingDiff && diffWorkflow ? diffWorkflow.loops || {} : loops - const workflowParallels = isShowingDiff && diffWorkflow ? diffWorkflow.parallels || {} : parallels + // Use diff workflow when in diff mode, normal workflow when in normal mode + // This ensures chat tests the proposed changes when in diff mode + const isExecutingFromChat = workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput + const shouldUseDiffForExecution = isShowingDiff && diffWorkflow + + // Determine which workflow state to use based on current mode + const workflowBlocks = shouldUseDiffForExecution ? diffWorkflow.blocks : blocks + const workflowEdges = shouldUseDiffForExecution ? diffWorkflow.edges : edges + const workflowLoops = shouldUseDiffForExecution ? diffWorkflow.loops || {} : loops + const workflowParallels = shouldUseDiffForExecution ? diffWorkflow.parallels || {} : parallels logger.info('Executing workflow', { - mode: isShowingDiff ? 'diff' : 'main', + mode: shouldUseDiffForExecution ? 'diff' : 'main', + isExecutingFromChat, + isShowingDiff, blocksCount: Object.keys(workflowBlocks).length, edgesCount: workflowEdges.length }) @@ -506,13 +515,9 @@ export function useWorkflowExecution() { workflowParallels ) - // Determine if this is a chat execution - const isChatExecution = - workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput - // If this is a chat execution, get the selected outputs let selectedOutputIds: string[] | undefined - if (isChatExecution && activeWorkflowId) { + if (isExecutingFromChat && activeWorkflowId) { // Get selected outputs from chat store const chatStore = await import('@/stores/panel/chat/store').then((mod) => mod.useChatStore) selectedOutputIds = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) @@ -526,7 +531,7 @@ export function useWorkflowExecution() { workflowInput, workflowVariables, contextExtensions: { - stream: isChatExecution, + stream: isExecutingFromChat, selectedOutputIds, edges: workflow.connections.map((conn) => ({ source: conn.source, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2d5b9f2e09e..6ba17872614 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -107,6 +107,21 @@ const WorkflowContent = React.memo(() => { // User permissions - get current user's specific permissions from context const userPermissions = useUserPermissionsContext() + // Create diff-aware permissions that disable editing when in diff mode + const effectivePermissions = useMemo(() => { + if (isShowingDiff) { + // In diff mode, disable all editing regardless of user permissions + return { + ...userPermissions, + canEdit: false, + canAdmin: false, + // Keep canRead true so users can still view content + canRead: userPermissions.canRead, + } + } + return userPermissions + }, [userPermissions, isShowingDiff]) + // Workspace permissions - get all users and their permissions for this workspace const { permissions: workspacePermissions, error: permissionsError } = useWorkspacePermissions( workspaceId || null @@ -340,7 +355,7 @@ const WorkflowContent = React.memo(() => { useEffect(() => { const handleAddBlockFromToolbar = (event: CustomEvent) => { // Check if user has permission to interact with blocks - if (!userPermissions.canEdit) { + if (!effectivePermissions.canEdit) { return } @@ -463,7 +478,7 @@ const WorkflowContent = React.memo(() => { addEdge, findClosestOutput, determineSourceHandle, - userPermissions.canEdit, + effectivePermissions.canEdit, ]) // Update the onDrop handler @@ -1471,11 +1486,11 @@ const WorkflowContent = React.memo(() => { edges={edgesWithSelection} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} - onConnect={userPermissions.canEdit ? onConnect : undefined} + onConnect={effectivePermissions.canEdit ? onConnect : undefined} nodeTypes={nodeTypes} edgeTypes={edgeTypes} - onDrop={userPermissions.canEdit ? onDrop : undefined} - onDragOver={userPermissions.canEdit ? onDragOver : undefined} + onDrop={effectivePermissions.canEdit ? onDrop : undefined} + onDragOver={effectivePermissions.canEdit ? onDragOver : undefined} fitView minZoom={0.1} maxZoom={1.3} @@ -1495,22 +1510,22 @@ const WorkflowContent = React.memo(() => { onEdgeClick={onEdgeClick} elementsSelectable={true} selectNodesOnDrag={false} - nodesConnectable={userPermissions.canEdit} - nodesDraggable={userPermissions.canEdit} + nodesConnectable={effectivePermissions.canEdit} + nodesDraggable={effectivePermissions.canEdit} draggable={false} noWheelClassName='allow-scroll' edgesFocusable={true} - edgesUpdatable={userPermissions.canEdit} + edgesUpdatable={effectivePermissions.canEdit} className='workflow-container h-full' - onNodeDrag={userPermissions.canEdit ? onNodeDrag : undefined} - onNodeDragStop={userPermissions.canEdit ? onNodeDragStop : undefined} - onNodeDragStart={userPermissions.canEdit ? onNodeDragStart : undefined} + onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined} + onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined} + onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined} snapToGrid={false} snapGrid={[20, 20]} elevateEdgesOnSelect={true} elevateNodesOnSelect={true} - autoPanOnConnect={userPermissions.canEdit} - autoPanOnNodeDrag={userPermissions.canEdit} + autoPanOnConnect={effectivePermissions.canEdit} + autoPanOnNodeDrag={effectivePermissions.canEdit} > Date: Thu, 24 Jul 2025 12:47:55 -0700 Subject: [PATCH 038/184] Checkpoint --- .../workflow-block/workflow-block.tsx | 11 +- .../w/[workflowId]/hooks/index.ts | 6 + .../hooks/use-current-workflow.ts | 85 +++ .../hooks/use-workflow-execution.ts | 28 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 25 +- apps/sim/stores/copilot/store.ts | 84 ++- apps/sim/stores/workflow-diff/store.ts | 592 ++++++++++++------ apps/sim/stores/workflows/workflow/store.ts | 33 +- 8 files changed, 579 insertions(+), 285 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index e9d015b530b..8f35c1ea841 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -20,6 +20,7 @@ import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' import { SubBlock } from './components/sub-block/sub-block' +import { useCurrentWorkflow } from '../../hooks' interface WorkflowBlockProps { type: string @@ -64,14 +65,10 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Workflow store selectors const lastUpdate = useWorkflowStore((state) => state.lastUpdate) - const mainBlock = useWorkflowStore((state) => state.blocks[id]) - // Diff-aware block selector - const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() - const diffBlock = diffWorkflow?.blocks[id] - - // Use diff block if in diff mode, otherwise use main block - const currentBlock = isShowingDiff && diffBlock ? diffBlock : mainBlock + // Use the clean abstraction for current workflow state + const currentWorkflow = useCurrentWorkflow() + const currentBlock = currentWorkflow.getBlockById(id) const isEnabled = currentBlock?.enabled ?? true const diffStatus = currentBlock?.is_diff diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts new file mode 100644 index 00000000000..1dccb20b552 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -0,0 +1,6 @@ +// Export the current workflow abstraction +export { useCurrentWorkflow, type CurrentWorkflow } from './use-current-workflow' + +// Export other workflow-related hooks +export { useWorkflowExecution } from './use-workflow-execution' +export { useCodeGeneration } from './use-code-generation' \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts new file mode 100644 index 00000000000..2b396df90e7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts @@ -0,0 +1,85 @@ +import { useMemo } from 'react' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' +import type { WorkflowState, BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' +import type { DeploymentStatus } from '@/stores/workflows/registry/types' +import type { Edge } from 'reactflow' + +/** + * Interface for the current workflow abstraction + */ +export interface CurrentWorkflow { + // Current workflow state properties + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + lastSaved?: number + isDeployed?: boolean + deployedAt?: Date + deploymentStatuses?: Record + needsRedeployment?: boolean + hasActiveWebhook?: boolean + + // Mode information + isDiffMode: boolean + isNormalMode: boolean + + // Full workflow state (for cases that need the complete object) + workflowState: WorkflowState + + // Helper methods + getBlockById: (blockId: string) => BlockState | undefined + getBlockCount: () => number + getEdgeCount: () => number + hasBlocks: () => boolean + hasEdges: () => boolean +} + +/** + * Clean abstraction for accessing the current workflow state. + * Automatically handles diff vs normal mode without exposing the complexity to consumers. + */ +export function useCurrentWorkflow(): CurrentWorkflow { + // Get normal workflow state + const normalWorkflow = useWorkflowStore((state) => state.getWorkflowState()) + + // Get diff state + const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + + // Create the abstracted interface + const currentWorkflow = useMemo((): CurrentWorkflow => { + // Determine which workflow to use + const activeWorkflow = isShowingDiff && diffWorkflow ? diffWorkflow : normalWorkflow + + return { + // Current workflow state + blocks: activeWorkflow.blocks, + edges: activeWorkflow.edges, + loops: activeWorkflow.loops || {}, + parallels: activeWorkflow.parallels || {}, + lastSaved: activeWorkflow.lastSaved, + isDeployed: activeWorkflow.isDeployed, + deployedAt: activeWorkflow.deployedAt, + deploymentStatuses: activeWorkflow.deploymentStatuses, + needsRedeployment: activeWorkflow.needsRedeployment, + hasActiveWebhook: activeWorkflow.hasActiveWebhook, + + // Mode information + isDiffMode: isShowingDiff && !!diffWorkflow, + isNormalMode: !isShowingDiff || !diffWorkflow, + + // Full workflow state (for cases that need the complete object) + workflowState: activeWorkflow, + + // Helper methods + getBlockById: (blockId: string) => activeWorkflow.blocks[blockId], + getBlockCount: () => Object.keys(activeWorkflow.blocks).length, + getEdgeCount: () => activeWorkflow.edges.length, + hasBlocks: () => Object.keys(activeWorkflow.blocks).length > 0, + hasEdges: () => activeWorkflow.edges.length > 0, + } + }, [normalWorkflow, isShowingDiff, diffWorkflow]) + + return currentWorkflow +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 7af08629ac4..1ef84e70ff8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -18,6 +18,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' +import { useCurrentWorkflow } from './use-current-workflow' const logger = createLogger('useWorkflowExecution') @@ -44,9 +45,8 @@ interface DebugValidationResult { } export function useWorkflowExecution() { - const { blocks, edges, loops, parallels } = useWorkflowStore() + const currentWorkflow = useCurrentWorkflow() const { activeWorkflowId } = useWorkflowRegistry() - const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() const { toggleConsole } = useConsoleStore() const { getAllVariables } = useEnvironmentStore() const { isDebugModeEnabled } = useGeneralStore() @@ -398,10 +398,7 @@ export function useWorkflowExecution() { }, [ activeWorkflowId, - blocks, - edges, - loops, - parallels, + currentWorkflow, toggleConsole, getAllVariables, getVariablesByWorkflowId, @@ -412,8 +409,6 @@ export function useWorkflowExecution() { setExecutor, setPendingBlocks, setActiveBlocks, - isShowingDiff, - diffWorkflow, ] ) @@ -422,21 +417,14 @@ export function useWorkflowExecution() { onStream?: (se: StreamingExecution) => Promise, executionId?: string ): Promise => { - // Use diff workflow when in diff mode, normal workflow when in normal mode - // This ensures chat tests the proposed changes when in diff mode + // Use the current workflow abstraction (handles diff vs normal automatically) const isExecutingFromChat = workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput - const shouldUseDiffForExecution = isShowingDiff && diffWorkflow - - // Determine which workflow state to use based on current mode - const workflowBlocks = shouldUseDiffForExecution ? diffWorkflow.blocks : blocks - const workflowEdges = shouldUseDiffForExecution ? diffWorkflow.edges : edges - const workflowLoops = shouldUseDiffForExecution ? diffWorkflow.loops || {} : loops - const workflowParallels = shouldUseDiffForExecution ? diffWorkflow.parallels || {} : parallels + const { blocks: workflowBlocks, edges: workflowEdges, loops: workflowLoops, parallels: workflowParallels, isDiffMode } = currentWorkflow logger.info('Executing workflow', { - mode: shouldUseDiffForExecution ? 'diff' : 'main', + mode: isDiffMode ? 'diff' : 'main', isExecutingFromChat, - isShowingDiff, + isDiffMode, blocksCount: Object.keys(workflowBlocks).length, edgesCount: workflowEdges.length }) @@ -444,7 +432,7 @@ export function useWorkflowExecution() { // Use the mergeSubblockState utility to get all block states // In diff mode, subblock values are already in the workflow blocks // In normal mode, we need to merge from the subblock store - const mergedStates = isShowingDiff && diffWorkflow + const mergedStates = isDiffMode ? workflowBlocks // Diff blocks already have embedded subblock values : mergeSubblockState(workflowBlocks) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 6ba17872614..5a33c962c49 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -42,6 +42,7 @@ import { resizeLoopNodes, updateNodeParent as updateNodeParentUtil, } from './utils' +import { useCurrentWorkflow } from './hooks' const logger = createLogger('Workflow') @@ -89,27 +90,23 @@ const WorkflowContent = React.memo(() => { const { workflows, activeWorkflowId, isLoading, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - // Get workflow state from diff store if available, otherwise main store - const { getCurrentWorkflowForCanvas, isShowingDiff, diffWorkflow } = useWorkflowDiffStore() - const currentWorkflowState = getCurrentWorkflowForCanvas() + // Use the clean abstraction for current workflow state + const currentWorkflow = useCurrentWorkflow() const { updateNodeDimensions, updateBlockPosition: storeUpdateBlockPosition, } = useWorkflowStore() - // Use current workflow state (could be actual or proposed) - const blocks = currentWorkflowState.blocks - const edges = currentWorkflowState.edges - const loops = currentWorkflowState.loops || {} - const parallels = currentWorkflowState.parallels || {} + // Extract workflow data from the abstraction + const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow // User permissions - get current user's specific permissions from context const userPermissions = useUserPermissionsContext() // Create diff-aware permissions that disable editing when in diff mode const effectivePermissions = useMemo(() => { - if (isShowingDiff) { + if (isDiffMode) { // In diff mode, disable all editing regardless of user permissions return { ...userPermissions, @@ -120,7 +117,7 @@ const WorkflowContent = React.memo(() => { } } return userPermissions - }, [userPermissions, isShowingDiff]) + }, [userPermissions, isDiffMode]) // Workspace permissions - get all users and their permissions for this workspace const { permissions: workspacePermissions, error: permissionsError } = useWorkspacePermissions( @@ -1535,12 +1532,10 @@ const WorkflowContent = React.memo(() => { /> - {/* Show DiffControls if diff is available, otherwise show ReviewButton if there's a pending preview */} - {diffWorkflow && ( - - )} + {/* Show DiffControls if diff is available (regardless of current view mode) */} + {/* - {diffWorkflow ? ( + {isDiffMode ? ( ) : ( diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 8610c6f0d6f..0845aab5900 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1156,46 +1156,82 @@ export const useCopilotStore = create()( // Process blocks for (const block of blocks) { - const blockConfig = getBlock(block.type) - if (blockConfig) { + // Handle loop and parallel blocks specially (they don't have regular block configs) + if (block.type === 'loop' || block.type === 'parallel') { + // Get block config and populate subBlocks with YAML input values const subBlocks: Record = {} - - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - const yamlValue = block.inputs[subBlock.id] - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) - - // Also ensure we have subBlocks for any YAML inputs not in block config + + // For loop/parallel blocks, map inputs to subBlocks for compatibility Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], } }) - const outputs = resolveOutputType(blockConfig.outputs) - workflowBlocks[block.id] = { id: block.id, type: block.type, name: block.name, position: block.position || { x: 0, y: 0 }, subBlocks, - outputs, + outputs: {}, enabled: true, horizontalHandles: true, isWide: false, height: 0, data: block.data || {}, } + + logger.debug(`Processed ${block.type} block: ${block.id}`) + } else { + // Handle regular blocks + const blockConfig = getBlock(block.type) + if (blockConfig) { + const subBlocks: Record = {} + + // Set up subBlocks from block configuration + blockConfig.subBlocks.forEach((subBlock) => { + const yamlValue = block.inputs[subBlock.id] + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: yamlValue !== undefined ? yamlValue : null, + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(block.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], + } + } + }) + + const outputs = resolveOutputType(blockConfig.outputs) + + workflowBlocks[block.id] = { + id: block.id, + type: block.type, + name: block.name, + position: block.position || { x: 0, y: 0 }, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: block.data || {}, + } + + logger.debug(`Processed regular block: ${block.id}`) + } else { + logger.warn(`Unknown block type: ${block.type}`) + } } } @@ -1238,7 +1274,7 @@ export const useCopilotStore = create()( // Set the proposed changes in the diff store const diffStore = useWorkflowDiffStore.getState() - await diffStore.setProposedChanges(proposedWorkflowState, 'copilot') + diffStore.setProposedChanges(proposedWorkflowState) logger.info('Successfully updated diff store with proposed workflow changes') diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 1d1cb23c078..ed9045a7cbb 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -6,253 +6,431 @@ import { useWorkflowStore } from '../workflows/workflow/store' import { useSubBlockStore } from '../workflows/subblock/store' import { useWorkflowRegistry } from '../workflows/registry/store' import type { WorkflowState, BlockState } from '../workflows/workflow/types' +import { generateLoopBlocks, generateParallelBlocks } from '../workflows/workflow/utils' const logger = createLogger('WorkflowDiffStore') interface WorkflowDiffState { - // The proposed workflow state to show on canvas - diffWorkflow: WorkflowState | null - // Whether we're currently showing the diff view isShowingDiff: boolean - // Metadata about the diff + diffWorkflow: WorkflowState | null + diffAnalysis: any | null diffMetadata?: { - source: 'copilot' | 'manual' + source: string timestamp: number - diffAnalysis?: { - deleted_blocks: string[] - edited_blocks: string[] - new_blocks: string[] - } - } + } | null } interface WorkflowDiffActions { - // Set the proposed changes from copilot - setProposedChanges: (proposedWorkflow: WorkflowState, source?: 'copilot' | 'manual') => Promise - - // Toggle between showing actual vs proposed workflow + setProposedChanges: (proposedWorkflow: WorkflowState, diffAnalysis?: any) => void + clearDiff: () => void + getCurrentWorkflowForCanvas: () => WorkflowState toggleDiffView: () => void - - // Accept the proposed changes (merge into main workflow store) acceptChanges: () => Promise - - // Reject the proposed changes (clear diff store) rejectChanges: () => void +} + +/** + * Validate workflow blocks and edges (mirrors YAML import approach) + * Reports issues without making destructive changes + */ +function validateWorkflowStructure(blocks: Record, edges: Edge[]): { + errors: string[] + warnings: string[] +} { + const errors: string[] = [] + const warnings: string[] = [] + const blockIds = new Set(Object.keys(blocks)) + + // Validate block references in edges + edges.forEach((edge) => { + if (!blockIds.has(edge.source)) { + errors.push(`Edge ${edge.id} references non-existent source block '${edge.source}'`) + } + if (!blockIds.has(edge.target)) { + errors.push(`Edge ${edge.id} references non-existent target block '${edge.target}'`) + } + }) + + // Validate parent-child relationships + Object.entries(blocks).forEach(([blockId, block]) => { + const parentId = block.data?.parentId + if (parentId && !blockIds.has(parentId)) { + errors.push(`Block '${blockId}' references non-existent parent block '${parentId}'`) + } + }) + + return { errors, warnings } +} + +/** + * Create ID mapping from proposed IDs to new UUIDs (mirrors YAML import approach) + */ +function createIdMapping(proposedBlocks: Record): Map { + const idMapping = new Map() - // Clear all diff state - clearDiff: () => void + Object.keys(proposedBlocks).forEach(oldId => { + const newId = crypto.randomUUID() + idMapping.set(oldId, newId) + }) - // Get the current workflow state to show on canvas (either actual or proposed) - getCurrentWorkflowForCanvas: () => WorkflowState + logger.info('Created ID mapping for diff workflow', { + mappingCount: idMapping.size, + mappings: Array.from(idMapping.entries()) + }) + + return idMapping } -type WorkflowDiffStore = WorkflowDiffState & WorkflowDiffActions +/** + * Update block references in values with new mapped IDs (mirrors YAML import approach) + */ +function updateBlockReferences( + value: any, + blockIdMapping: Map +): any { + if (typeof value === 'string' && value.includes('<') && value.includes('>')) { + let processedValue = value + const blockMatches = value.match(/<([^>]+)>/g) -const initialState: WorkflowDiffState = { - diffWorkflow: null, - isShowingDiff: false, -} + if (blockMatches) { + for (const match of blockMatches) { + const path = match.slice(1, -1) + const [blockRef] = path.split('.') -export const useWorkflowDiffStore = create()( - devtools((set, get) => ({ - ...initialState, + // Skip system references (start, loop, parallel, variable) + if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { + continue + } - setProposedChanges: async (proposedWorkflow: WorkflowState, source = 'copilot') => { - logger.info('Setting proposed changes', { source, blockCount: Object.keys(proposedWorkflow.blocks).length }) - - // Get current workflow YAML and analyze diff if possible - let diffAnalysis: any = null - try { - // Get current workflow YAML - const { activeWorkflowId } = useWorkflowRegistry.getState() - if (activeWorkflowId) { - const currentWorkflowResponse = await fetch('/api/tools/get-user-workflow', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId: activeWorkflowId, - includeMetadata: false, - }), - }) - - if (currentWorkflowResponse.ok) { - const currentWorkflowResult = await currentWorkflowResponse.json() - if (currentWorkflowResult.success && currentWorkflowResult.output?.yaml) { - // Convert proposed workflow to YAML for comparison - const { generateWorkflowYaml } = await import('@/lib/workflows/yaml-generator') - const proposedYaml = generateWorkflowYaml(proposedWorkflow) - - // Call diff API - const diffResponse = await fetch('/api/workflows/diff', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - original_yaml: currentWorkflowResult.output.yaml, - agent_yaml: proposedYaml, - }), - }) - - if (diffResponse.ok) { - const diffData = await diffResponse.json() - if (diffData.success) { - diffAnalysis = diffData.data - logger.info('Generated diff analysis', diffAnalysis) - } - } - } - } + // Check if this references an old block ID that needs mapping + const newMappedId = blockIdMapping.get(blockRef) + if (newMappedId) { + logger.debug(`Updating block reference: ${blockRef} -> ${newMappedId}`) + processedValue = processedValue.replace( + new RegExp(`<${blockRef}\\.`, 'g'), + `<${newMappedId}.` + ) + processedValue = processedValue.replace( + new RegExp(`<${blockRef}>`, 'g'), + `<${newMappedId}>` + ) } - } catch (error) { - logger.error('Failed to generate diff analysis:', error) - } - - // Add is_diff field to blocks based on diff analysis - const enhancedWorkflow = { ...proposedWorkflow } - if (diffAnalysis) { - Object.keys(enhancedWorkflow.blocks).forEach(blockId => { - const block = enhancedWorkflow.blocks[blockId] - if (diffAnalysis.new_blocks.includes(blockId)) { - block.is_diff = 'new' - } else if (diffAnalysis.edited_blocks.includes(blockId)) { - block.is_diff = 'edited' - } else { - block.is_diff = 'unchanged' - } - }) } - - set({ - diffWorkflow: enhancedWorkflow, - diffMetadata: { - source, - timestamp: Date.now(), - diffAnalysis, - }, - // Automatically show diff for copilot changes, let user toggle for manual changes - isShowingDiff: source === 'copilot', - }) - }, + } - toggleDiffView: () => { - const { isShowingDiff, diffWorkflow } = get() - - if (!diffWorkflow) { - logger.warn('Cannot toggle diff view - no proposed changes available') - return - } - - logger.info('Toggling diff view', { newState: !isShowingDiff }) - set({ isShowingDiff: !isShowingDiff }) - }, + return processedValue + } - acceptChanges: async () => { - const { diffWorkflow } = get() - - if (!diffWorkflow) { - logger.warn('Cannot accept changes - no proposed changes available') - return - } - - logger.info('Accepting proposed changes') - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - if (!activeWorkflowId) { - logger.error('No active workflow ID for accepting changes') - return - } + // Handle arrays + if (Array.isArray(value)) { + return value.map((item) => updateBlockReferences(item, blockIdMapping)) + } - try { - // Convert diff workflow to YAML using the same approach as other components - const { generateWorkflowYaml } = await import('@/lib/workflows/yaml-generator') + // Handle objects + if (value !== null && typeof value === 'object') { + const result = { ...value } + for (const key in result) { + result[key] = updateBlockReferences(result[key], blockIdMapping) + } + return result + } + + return value +} + +export const useWorkflowDiffStore = create()( + devtools( + (set, get) => ({ + isShowingDiff: false, + diffWorkflow: null, + diffAnalysis: null, + diffMetadata: null, + + setProposedChanges: (proposedWorkflow: WorkflowState, diffAnalysis?: any) => { + logger.info('Setting proposed changes for diff mode with ID mapping') - // Extract subblock values from diff workflow - const subBlockValues: Record> = {} - Object.values(diffWorkflow.blocks).forEach((block: any) => { - if (block.subBlocks) { - const blockValues: Record = {} - Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== undefined && subBlock.value !== null) { - blockValues[subBlockId] = subBlock.value - } + // Log the incoming workflow structure for debugging + const incomingLoopBlocks = Object.entries(proposedWorkflow.blocks) + .filter(([_, block]) => block.type === 'loop') + .map(([id, block]) => ({ id, type: block.type, data: block.data })) + + logger.info('Incoming loop blocks:', incomingLoopBlocks) + + // Create ID mapping from proposed IDs to new UUIDs (like YAML import) + const blockIdMapping = createIdMapping(proposedWorkflow.blocks) + + // Create new blocks with mapped IDs and updated references + const mappedBlocks: Record = {} + + Object.entries(proposedWorkflow.blocks).forEach(([oldId, block]) => { + const newId = blockIdMapping.get(oldId)! + + // Create new block with mapped ID + const mappedBlock: BlockState = { + ...block, + id: newId, + // Update parent references if they exist + data: block.data ? { + ...block.data, + parentId: block.data.parentId ? blockIdMapping.get(block.data.parentId) : undefined + } : undefined + } + + // Special handling for loop and parallel blocks (like YAML import) + if (block.type === 'loop' || block.type === 'parallel') { + // For loop/parallel blocks, ensure proper data structure + mappedBlock.data = { + ...mappedBlock.data, + width: mappedBlock.data?.width || 500, + height: mappedBlock.data?.height || 300, + type: block.type === 'loop' ? 'loopNode' : 'parallelNode', + // Preserve loop-specific properties + loopType: mappedBlock.data?.loopType || 'for', + count: mappedBlock.data?.count || 5, + collection: mappedBlock.data?.collection || '', + // Preserve parallel-specific properties + parallelType: mappedBlock.data?.parallelType || 'collection', + } + + // For container blocks, subBlocks should be empty (they don't use them) + mappedBlock.subBlocks = {} + mappedBlock.outputs = {} + + logger.debug(`Mapped ${block.type} block with special data structure:`, { + oldId, + newId, + data: mappedBlock.data }) - if (Object.keys(blockValues).length > 0) { - subBlockValues[block.id] = blockValues + } else { + // Update block references in subblock values for regular blocks + if (mappedBlock.subBlocks) { + Object.entries(mappedBlock.subBlocks).forEach(([subBlockId, subBlock]) => { + if (subBlock.value !== null && subBlock.value !== undefined) { + subBlock.value = updateBlockReferences(subBlock.value, blockIdMapping) + } + }) } } + + mappedBlocks[newId] = mappedBlock }) - // Generate YAML from diff workflow - const yamlContent = generateWorkflowYaml(diffWorkflow, subBlockValues) + // Create new edges with mapped IDs + const mappedEdges: Edge[] = proposedWorkflow.edges.map(edge => ({ + ...edge, + id: crypto.randomUUID(), // Generate new edge ID + source: blockIdMapping.get(edge.source) || edge.source, + target: blockIdMapping.get(edge.target) || edge.target + })) - // Use the same consolidated YAML endpoint as the YAML editor - const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent, - description: 'Applied copilot changes', + // Create the enhanced workflow with mapped IDs + const enhancedWorkflow = { + ...proposedWorkflow, + blocks: mappedBlocks, + edges: mappedEdges + } + + // Add is_diff field to blocks based on diff analysis + if (diffAnalysis && diffAnalysis.new_blocks && diffAnalysis.edited_blocks) { + Object.keys(enhancedWorkflow.blocks).forEach(blockId => { + const block = enhancedWorkflow.blocks[blockId] + // Find original ID to check diff analysis + const originalId = Array.from(blockIdMapping.entries()).find(([_, newId]) => newId === blockId)?.[0] + + if (originalId) { + if (diffAnalysis.new_blocks.includes(originalId)) { + block.is_diff = 'new' + } else if (diffAnalysis.edited_blocks.includes(originalId)) { + block.is_diff = 'edited' + } else { + block.is_diff = 'unchanged' + } + } else { + block.is_diff = 'unchanged' + } + }) + } else { + // If no diff analysis provided, mark all blocks as unchanged + Object.keys(enhancedWorkflow.blocks).forEach(blockId => { + const block = enhancedWorkflow.blocks[blockId] + block.is_diff = 'unchanged' + }) + } + + // Generate loops and parallels from mapped blocks + const generatedLoops = generateLoopBlocks(enhancedWorkflow.blocks) + const generatedParallels = generateParallelBlocks(enhancedWorkflow.blocks) + + // Update loops and parallels with mapped IDs + enhancedWorkflow.loops = generatedLoops + enhancedWorkflow.parallels = generatedParallels + + // Log final loop blocks for debugging + const finalLoopBlocks = Object.entries(enhancedWorkflow.blocks) + .filter(([_, block]) => block.type === 'loop') + .map(([id, block]) => ({ id, type: block.type, data: block.data })) + + logger.info('Final processed loop blocks:', finalLoopBlocks) + logger.info('Generated loops from blocks:', generatedLoops) + + // Validate the workflow structure (like YAML import does) + const { errors, warnings } = validateWorkflowStructure( + enhancedWorkflow.blocks, + enhancedWorkflow.edges + ) + + // Log validation results without making destructive changes + if (errors.length > 0) { + logger.warn('Validation errors in proposed workflow changes:', errors) + // Log detailed block and edge information for debugging + logger.warn('Problematic workflow structure:', { + blockIds: Object.keys(enhancedWorkflow.blocks), + edges: enhancedWorkflow.edges.map(e => ({ id: e.id, source: e.source, target: e.target })), + blocksWithParents: Object.entries(enhancedWorkflow.blocks) + .filter(([_, block]) => block.data?.parentId) + .map(([id, block]) => ({ id, parentId: block.data?.parentId })) + }) + } + if (warnings.length > 0) { + logger.warn('Validation warnings in proposed workflow changes:', warnings) + } + + logger.info('Generated loops and parallels for diff workflow with ID mapping', { + loopsCount: Object.keys(generatedLoops).length, + parallelsCount: Object.keys(generatedParallels).length, + blocksCount: Object.keys(enhancedWorkflow.blocks).length, + edgesCount: enhancedWorkflow.edges.length, + validationErrors: errors.length, + validationWarnings: warnings.length, + idMappingsCount: blockIdMapping.size + }) + + set({ + diffWorkflow: enhancedWorkflow, + diffAnalysis, + isShowingDiff: true, + diffMetadata: { source: 'copilot', - applyAutoLayout: true, - createCheckpoint: false, - }), + timestamp: Date.now(), + }, }) + }, - if (!response.ok) { - const errorData = await response.json() - logger.error('Failed to apply diff changes:', errorData) - throw new Error(errorData.message || `Failed to apply changes: ${response.statusText}`) + clearDiff: () => { + logger.info('Clearing diff mode') + set({ + isShowingDiff: false, + diffWorkflow: null, + diffAnalysis: null, + diffMetadata: null, + }) + }, + + toggleDiffView: () => { + const { isShowingDiff } = get() + logger.info('Toggling diff view', { currentState: isShowingDiff }) + set({ isShowingDiff: !isShowingDiff }) + }, + + acceptChanges: async () => { + const { diffWorkflow } = get() + if (!diffWorkflow) { + logger.warn('No diff workflow to accept') + return } - const result = await response.json() + logger.info('Accepting proposed changes') - if (!result.success) { - logger.error('Failed to apply diff changes:', result) - throw new Error(result.message || 'Failed to apply workflow changes') + try { + // Apply the diff workflow to the main workflow store + const workflowStore = useWorkflowStore.getState() + + // Get the current active workflow ID for subblock store updates + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + + // Directly replace the workflow state instead of using addBlock + // This preserves all the block data including subBlocks with their values + const newState = { + blocks: { ...diffWorkflow.blocks }, + edges: [...diffWorkflow.edges], + loops: { ...diffWorkflow.loops }, + parallels: { ...diffWorkflow.parallels }, + } + + // Remove is_diff properties from blocks as they're no longer needed + Object.values(newState.blocks).forEach((block) => { + delete block.is_diff + }) + + // Update the main workflow store state + useWorkflowStore.setState((state) => ({ + ...state, + ...newState, + })) + + // Update the subblock store with the values from the diff workflow blocks + if (activeWorkflowId) { + const subblockValues: Record> = {} + + Object.entries(diffWorkflow.blocks).forEach(([blockId, block]) => { + subblockValues[blockId] = {} + Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = subblock.value + }) + }) + + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues, + }, + })) + + logger.info('Updated subblock store with diff values', { + blocksWithSubblocks: Object.keys(subblockValues).length, + totalSubblocks: Object.values(subblockValues).reduce((sum, blockSubblocks) => + sum + Object.keys(blockSubblocks).length, 0 + ) + }) + } + + // Trigger save and history + workflowStore.updateLastSaved() + + logger.info('Successfully applied diff workflow to main store', { + blocksCount: Object.keys(newState.blocks).length, + edgesCount: newState.edges.length, + loopsCount: Object.keys(newState.loops).length, + parallelsCount: Object.keys(newState.parallels).length, + }) + + // Clear the diff + get().clearDiff() + + } catch (error) { + logger.error('Failed to accept changes:', error) + throw error } + }, - logger.info('Successfully applied diff changes via YAML endpoint', { - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, - }) - - // Clear the diff after successful acceptance + rejectChanges: () => { + logger.info('Rejecting proposed changes') get().clearDiff() - - } catch (error) { - logger.error('Error accepting diff changes:', error) - // Don't clear diff on error so user can try again - throw error - } - }, - - rejectChanges: () => { - logger.info('Rejecting proposed changes') - get().clearDiff() - }, + }, - clearDiff: () => { - logger.info('Clearing diff state') - set({ - diffWorkflow: null, - isShowingDiff: false, - diffMetadata: undefined, - }) - }, - - getCurrentWorkflowForCanvas: () => { - const { isShowingDiff, diffWorkflow } = get() - - if (isShowingDiff && diffWorkflow) { - logger.debug('Returning diff workflow for canvas') - return diffWorkflow - } - - // Return the actual workflow state using the main store's method - // This eliminates code duplication and automatically stays in sync with WorkflowState changes - return useWorkflowStore.getState().getWorkflowState() - }, - })) + getCurrentWorkflowForCanvas: () => { + const { isShowingDiff, diffWorkflow } = get() + + if (isShowingDiff && diffWorkflow) { + logger.debug('Returning diff workflow for canvas') + return diffWorkflow + } + + // Return the actual workflow state using the main store's method + // This eliminates code duplication and automatically stays in sync with WorkflowState changes + return useWorkflowStore.getState().getWorkflowState() + }, + }), + { name: 'workflow-diff-store' } + ) ) \ No newline at end of file diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index a98e5434721..aabdecfa7d6 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -191,20 +191,29 @@ export const useWorkflowStore = create()( }, updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { - set((state) => ({ - blocks: { - ...state.blocks, - [id]: { - ...state.blocks[id], - data: { - ...state.blocks[id].data, - width: dimensions.width, - height: dimensions.height, + set((state) => { + // Check if the block exists before trying to update it + const block = state.blocks[id] + if (!block) { + logger.warn(`Cannot update dimensions: Block ${id} not found in workflow store`) + return state // Return unchanged state + } + + return { + blocks: { + ...state.blocks, + [id]: { + ...block, + data: { + ...block.data, + width: dimensions.width, + height: dimensions.height, + }, }, }, - }, - edges: [...state.edges], - })) + edges: [...state.edges], + } + }) get().updateLastSaved() // Note: Socket.IO handles real-time sync automatically }, From 13da897e46ea583b54113088aa05709d692f03f7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 12:54:44 -0700 Subject: [PATCH 039/184] Persist changes --- .../components/loop-node/loop-node.tsx | 11 +++- .../parallel-node/parallel-node.tsx | 11 +++- apps/sim/stores/workflow-diff/store.ts | 50 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx index 0131e04b792..da37b95a7ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx @@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card' import { cn } from '@/lib/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { LoopBadges } from './components/loop-badges' +import { useCurrentWorkflow } from '../../hooks' // Add these styles to your existing global CSS file or create a separate CSS module const LoopNodeStyles: React.FC = () => { @@ -71,6 +72,11 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const { getNodes } = useReactFlow() const { collaborativeRemoveBlock } = useCollaborativeWorkflow() const blockRef = useRef(null) + + // Use the clean abstraction for current workflow state + const currentWorkflow = useCurrentWorkflow() + const currentBlock = currentWorkflow.getBlockById(id) + const diffStatus = currentBlock?.is_diff // Check if this is preview mode const isPreview = data?.isPreview || false @@ -124,7 +130,10 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { data?.state === 'valid', nestingLevel > 0 && `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`, - data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50' + data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50', + // Diff highlighting + diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', + diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', )} style={{ width: data.width || 500, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx index 688296190d2..7890762c951 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx @@ -8,6 +8,7 @@ import { Card } from '@/components/ui/card' import { cn } from '@/lib/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { ParallelBadges } from './components/parallel-badges' +import { useCurrentWorkflow } from '../../hooks' const ParallelNodeStyles: React.FC = () => { return ( @@ -88,6 +89,11 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => const { getNodes } = useReactFlow() const { collaborativeRemoveBlock } = useCollaborativeWorkflow() const blockRef = useRef(null) + + // Use the clean abstraction for current workflow state + const currentWorkflow = useCurrentWorkflow() + const currentBlock = currentWorkflow.getBlockById(id) + const diffStatus = currentBlock?.is_diff // Check if this is preview mode const isPreview = data?.isPreview || false @@ -142,7 +148,10 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => data?.state === 'valid', nestingLevel > 0 && `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`, - data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50' + data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50', + // Diff highlighting + diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', + diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', )} style={{ width: data.width || 500, diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index ed9045a7cbb..4e786138835 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -348,6 +348,11 @@ export const useWorkflowDiffStore = create Date: Thu, 24 Jul 2025 12:58:34 -0700 Subject: [PATCH 040/184] Autolayout fixces --- apps/sim/stores/copilot/store.ts | 2 +- apps/sim/stores/workflow-diff/store.ts | 43 ++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 0845aab5900..4b59c3721f7 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1274,7 +1274,7 @@ export const useCopilotStore = create()( // Set the proposed changes in the diff store const diffStore = useWorkflowDiffStore.getState() - diffStore.setProposedChanges(proposedWorkflowState) + await diffStore.setProposedChanges(proposedWorkflowState) logger.info('Successfully updated diff store with proposed workflow changes') diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 4e786138835..4225c4ca775 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -21,7 +21,7 @@ interface WorkflowDiffState { } interface WorkflowDiffActions { - setProposedChanges: (proposedWorkflow: WorkflowState, diffAnalysis?: any) => void + setProposedChanges: (proposedWorkflow: WorkflowState, diffAnalysis?: any) => Promise clearDiff: () => void getCurrentWorkflowForCanvas: () => WorkflowState toggleDiffView: () => void @@ -146,7 +146,7 @@ export const useWorkflowDiffStore = create { + setProposedChanges: async (proposedWorkflow: WorkflowState, diffAnalysis?: any) => { logger.info('Setting proposed changes for diff mode with ID mapping') // Log the incoming workflow structure for debugging @@ -295,6 +295,45 @@ export const useWorkflowDiffStore = create Date: Thu, 24 Jul 2025 13:02:38 -0700 Subject: [PATCH 041/184] Color diff fixes --- apps/sim/stores/copilot/store.ts | 134 ++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 49 deletions(-) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 4b59c3721f7..61731413510 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1174,7 +1174,7 @@ export const useCopilotStore = create()( id: block.id, type: block.type, name: block.name, - position: block.position || { x: 0, y: 0 }, + position: block.position, subBlocks, outputs: {}, enabled: true, @@ -1183,55 +1183,56 @@ export const useCopilotStore = create()( height: 0, data: block.data || {}, } - - logger.debug(`Processed ${block.type} block: ${block.id}`) - } else { - // Handle regular blocks - const blockConfig = getBlock(block.type) - if (blockConfig) { - const subBlocks: Record = {} - - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - const yamlValue = block.inputs[subBlock.id] - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) + continue + } - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - } - }) + // Get block config and populate subBlocks with YAML input values + const blockConfig = getBlock(block.type) + if (!blockConfig) { + logger.warn(`Unknown block type: ${block.type}`) + continue + } - const outputs = resolveOutputType(blockConfig.outputs) - - workflowBlocks[block.id] = { - id: block.id, - type: block.type, - name: block.name, - position: block.position || { x: 0, y: 0 }, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, + // Initialize all subBlocks from block config + const subBlocks: Record = {} + blockConfig.subBlocks.forEach((subBlock) => { + const subBlockId = subBlock.id + // Check if this subBlock has a value from YAML + const yamlValue = block.inputs[subBlockId] + subBlocks[subBlockId] = { + id: subBlockId, + type: subBlock.type, + value: yamlValue !== undefined ? yamlValue : null, + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(block.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: block.inputs[inputKey], } - - logger.debug(`Processed regular block: ${block.id}`) - } else { - logger.warn(`Unknown block type: ${block.type}`) } + }) + + const outputs = resolveOutputType(blockConfig.outputs) + + workflowBlocks[block.id] = { + id: block.id, + type: block.type, + name: block.name, + position: block.position, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: block.data || {}, + parentId: block.parentId, + extent: block.extent, } } @@ -1263,6 +1264,41 @@ export const useCopilotStore = create()( logger.warn('Auto layout failed for proposed blocks, using original positions:', layoutResult.error) } + // Generate diff analysis by comparing current workflow with proposed workflow + let diffAnalysis = null + try { + // Get current workflow as YAML for comparison + const { useWorkflowYamlStore } = await import('@/stores/workflows/yaml/store') + const currentYaml = useWorkflowYamlStore.getState().getYaml() + + // Call the diff API to compare current vs proposed YAML + const diffResponse = await fetch('/api/workflows/diff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + original_yaml: currentYaml, + agent_yaml: yamlContent, + }), + }) + + if (diffResponse.ok) { + const diffResult = await diffResponse.json() + if (diffResult.success && diffResult.data) { + diffAnalysis = diffResult.data + logger.info('Successfully generated diff analysis', { + newBlocks: diffAnalysis.new_blocks?.length || 0, + editedBlocks: diffAnalysis.edited_blocks?.length || 0, + deletedBlocks: diffAnalysis.deleted_blocks?.length || 0, + }) + } + } else { + logger.warn('Failed to generate diff analysis, proceeding without it') + } + } catch (diffError) { + logger.warn('Error generating diff analysis:', diffError) + // Continue without diff analysis - blocks will be marked as unchanged + } + // Create the proposed workflow state const proposedWorkflowState = { blocks: layoutedBlocks, @@ -1272,11 +1308,11 @@ export const useCopilotStore = create()( lastSaved: Date.now(), } - // Set the proposed changes in the diff store + // Set the proposed changes in the diff store with diff analysis const diffStore = useWorkflowDiffStore.getState() - await diffStore.setProposedChanges(proposedWorkflowState) + await diffStore.setProposedChanges(proposedWorkflowState, diffAnalysis) - logger.info('Successfully updated diff store with proposed workflow changes') + logger.info('Successfully updated diff store with proposed workflow changes and diff analysis') } catch (error) { logger.error('Failed to update diff store:', error) From 804d1ab7b8c110a93946e3abdc5186edf571cbe5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 13:37:00 -0700 Subject: [PATCH 042/184] Opusss --- .../[workflowId]/components/review-button.tsx | 86 +--- .../workflow-block/workflow-block.tsx | 7 +- apps/sim/hooks/use-collaborative-workflow.ts | 33 +- apps/sim/lib/workflows/diff/diff-engine.ts | 222 +++++++++ apps/sim/lib/workflows/diff/index.ts | 4 + .../lib/workflows/diff/use-workflow-diff.ts | 169 +++++++ apps/sim/lib/workflows/yaml-converter.ts | 391 +++++++++++++++ apps/sim/stores/copilot/store.ts | 193 ++------ apps/sim/stores/workflow-diff/store.ts | 465 +++--------------- apps/sim/stores/workflows/workflow/types.ts | 1 - 10 files changed, 930 insertions(+), 641 deletions(-) create mode 100644 apps/sim/lib/workflows/diff/diff-engine.ts create mode 100644 apps/sim/lib/workflows/diff/index.ts create mode 100644 apps/sim/lib/workflows/diff/use-workflow-diff.ts create mode 100644 apps/sim/lib/workflows/yaml-converter.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index e77c312300f..5f028293958 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -177,90 +177,20 @@ export function ReviewButton() { // STEP 1: Parse YAML and update local store immediately try { // Import the necessary modules - const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') + const { convertYamlToWorkflowState } = await import('@/lib/workflows/yaml-converter') const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - const { getBlock } = await import('@/blocks') - const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') - // Parse YAML content - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(currentChat.previewYaml) - - if (!yamlWorkflow || parseErrors.length > 0) { - throw new Error(`Failed to parse YAML: ${parseErrors.join(', ')}`) - } - - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - throw new Error(`Failed to convert YAML: ${convertErrors.join(', ')}`) - } - - // Convert ImportedBlocks to workflow store format - const workflowBlocks: Record = {} - const workflowEdges: any[] = [] - - // Process blocks - for (const block of blocks) { - const blockConfig = getBlock(block.type) - if (blockConfig) { - const subBlocks: Record = {} - - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - const yamlValue = block.inputs[subBlock.id] - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) - - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - } - }) + // Convert YAML to workflow state using our unified converter + const conversionResult = await convertYamlToWorkflowState(currentChat.previewYaml, { + generateNewIds: false // Keep existing IDs for preview + }) - const outputs = blockConfig.outputs || {} - - workflowBlocks[block.id] = { - id: block.id, - type: block.type, - name: block.name, - position: block.position || { x: 0, y: 0 }, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - } - } - } - - // Process edges - for (const edge of edges) { - workflowEdges.push({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - }) + if (!conversionResult.success || !conversionResult.workflowState) { + throw new Error(`Failed to convert YAML: ${conversionResult.errors.join(', ')}`) } - // Generate loops and parallels - const loops = generateLoopBlocks(workflowBlocks) - const parallels = generateParallelBlocks(workflowBlocks) + const { blocks: workflowBlocks, edges: workflowEdges, loops, parallels } = conversionResult.workflowState // Apply auto layout using the shared utility const { applyAutoLayoutToBlocks } = await import('../utils/auto-layout') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8f35c1ea841..0420209922c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -16,7 +16,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' + import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' import { SubBlock } from './components/sub-block/sub-block' @@ -66,12 +66,12 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Workflow store selectors const lastUpdate = useWorkflowStore((state) => state.lastUpdate) + // Use the clean abstraction for current workflow state // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) const isEnabled = currentBlock?.enabled ?? true - const diffStatus = currentBlock?.is_diff const horizontalHandles = data.isPreview ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency @@ -467,9 +467,6 @@ export function WorkflowBlock({ id, data }: NodeProps) { !isEnabled && 'shadow-sm', isActive && 'animate-pulse-ring ring-2 ring-blue-500', isPending && 'ring-2 ring-amber-500', - // Diff highlighting - diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', - diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', 'z-[20]' )} > diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index cd7eb6f3742..15f6d021cc9 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -9,6 +9,7 @@ import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-que import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import type { Position } from '@/stores/workflows/workflow/types' const logger = createLogger('CollaborativeWorkflow') @@ -36,6 +37,7 @@ export function useCollaborativeWorkflow() { const workflowStore = useWorkflowStore() const subBlockStore = useSubBlockStore() const { data: session } = useSession() + const { isShowingDiff } = useWorkflowDiffStore() // Track if we're applying remote changes to avoid infinite loops const isApplyingRemoteChange = useRef(false) @@ -377,6 +379,12 @@ export function useCollaborativeWorkflow() { return } + // Skip socket operations when in diff mode + if (isShowingDiff) { + logger.debug('Skipping socket operation in diff mode:', operation) + return + } + const operationId = crypto.randomUUID() addToQueue({ @@ -392,18 +400,24 @@ export function useCollaborativeWorkflow() { localAction() }, - [addToQueue, session?.user?.id] + [addToQueue, session?.user?.id, isShowingDiff] ) const executeQueuedDebouncedOperation = useCallback( (operation: string, target: string, payload: any, localAction: () => void) => { if (isApplyingRemoteChange.current) return + // Skip socket operations when in diff mode + if (isShowingDiff) { + logger.debug('Skipping debounced socket operation in diff mode:', operation) + return + } + localAction() emitWorkflowOperation(operation, target, payload) }, - [emitWorkflowOperation] + [emitWorkflowOperation, isShowingDiff] ) const collaborativeAddBlock = useCallback( @@ -417,6 +431,12 @@ export function useCollaborativeWorkflow() { extent?: 'parent', autoConnectEdge?: Edge ) => { + // Skip socket operations when in diff mode + if (isShowingDiff) { + logger.debug('Skipping collaborative add block in diff mode') + return + } + const blockConfig = getBlock(type) // Handle loop/parallel blocks that don't use BlockConfig @@ -534,7 +554,7 @@ export function useCollaborativeWorkflow() { workflowStore.addEdge(autoConnectEdge) } }, - [workflowStore, emitWorkflowOperation, addToQueue, session?.user?.id] + [workflowStore, emitWorkflowOperation, addToQueue, session?.user?.id, isShowingDiff] ) const collaborativeRemoveBlock = useCallback( @@ -765,6 +785,12 @@ export function useCollaborativeWorkflow() { (blockId: string, subblockId: string, value: any) => { if (isApplyingRemoteChange.current) return + // Skip socket operations when in diff mode + if (isShowingDiff) { + logger.debug('Skipping collaborative subblock update in diff mode') + return + } + if (!currentWorkflowId || activeWorkflowId !== currentWorkflowId) { logger.debug('Skipping subblock update - not in active workflow', { currentWorkflowId, @@ -800,6 +826,7 @@ export function useCollaborativeWorkflow() { activeWorkflowId, addToQueue, session?.user?.id, + isShowingDiff, ] ) diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts new file mode 100644 index 00000000000..663ac237c4b --- /dev/null +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -0,0 +1,222 @@ +import { createLogger } from '@/lib/logs/console-logger' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { convertYamlToWorkflowState, applyAutoLayoutToBlocks } from '@/lib/workflows/yaml-converter' + +const logger = createLogger('WorkflowDiffEngine') + +export interface DiffMetadata { + source: string + timestamp: number +} + +export interface DiffAnalysis { + new_blocks: string[] + edited_blocks: string[] + deleted_blocks: string[] +} + +export interface WorkflowDiff { + proposedState: WorkflowState + diffAnalysis?: DiffAnalysis + metadata: DiffMetadata +} + +export interface DiffResult { + success: boolean + diff?: WorkflowDiff + errors?: string[] +} + +/** + * Clean diff engine that handles workflow diff operations + * without polluting core workflow stores + */ +export class WorkflowDiffEngine { + private currentDiff: WorkflowDiff | null = null + + /** + * Create a diff from YAML content + */ + async createDiffFromYaml( + yamlContent: string, + diffAnalysis?: DiffAnalysis + ): Promise { + try { + logger.info('Creating diff from YAML content') + + // Convert YAML to workflow state with new IDs + const conversionResult = await convertYamlToWorkflowState(yamlContent, { + generateNewIds: true + }) + + if (!conversionResult.success || !conversionResult.workflowState) { + return { + success: false, + errors: conversionResult.errors + } + } + + const proposedState = conversionResult.workflowState + + // Apply auto layout for better visualization + const layoutResult = await applyAutoLayoutToBlocks( + proposedState.blocks, + proposedState.edges + ) + + if (layoutResult.success && layoutResult.layoutedBlocks) { + proposedState.blocks = layoutResult.layoutedBlocks + } + + // Add diff markers to blocks if analysis is provided + if (diffAnalysis) { + this.applyDiffMarkers(proposedState, diffAnalysis, conversionResult.idMapping!) + } + + // Create the diff object + this.currentDiff = { + proposedState, + diffAnalysis, + metadata: { + source: 'copilot', + timestamp: Date.now() + } + } + + logger.info('Diff created successfully', { + blocksCount: Object.keys(proposedState.blocks).length, + edgesCount: proposedState.edges.length + }) + + return { + success: true, + diff: this.currentDiff + } + } catch (error) { + logger.error('Failed to create diff:', error) + return { + success: false, + errors: [error instanceof Error ? error.message : 'Failed to create diff'] + } + } + } + + /** + * Apply diff markers to blocks based on analysis + */ + private applyDiffMarkers( + state: WorkflowState, + analysis: DiffAnalysis, + idMapping: Map + ): void { + // Create reverse mapping from new IDs to original IDs + const reverseMapping = new Map() + idMapping.forEach((newId, originalId) => { + reverseMapping.set(newId, originalId) + }) + + Object.entries(state.blocks).forEach(([blockId, block]) => { + // Find original ID to check diff analysis + const originalId = reverseMapping.get(blockId) + + if (originalId) { + if (analysis.new_blocks.includes(originalId)) { + (block as any).is_diff = 'new' + } else if (analysis.edited_blocks.includes(originalId)) { + (block as any).is_diff = 'edited' + } else { + (block as any).is_diff = 'unchanged' + } + } else { + (block as any).is_diff = 'unchanged' + } + }) + } + + /** + * Get the current diff + */ + getCurrentDiff(): WorkflowDiff | null { + return this.currentDiff + } + + /** + * Clear the current diff + */ + clearDiff(): void { + this.currentDiff = null + logger.info('Diff cleared') + } + + /** + * Check if a diff is active + */ + hasDiff(): boolean { + return this.currentDiff !== null + } + + /** + * Get the workflow state for display (either diff or provided state) + */ + getDisplayState(currentState: WorkflowState): WorkflowState { + if (this.currentDiff) { + return this.currentDiff.proposedState + } + return currentState + } + + /** + * Accept the diff and return the clean state + */ + acceptDiff(): WorkflowState | null { + if (!this.currentDiff) { + logger.warn('No diff to accept') + return null + } + + const cleanState = { ...this.currentDiff.proposedState } + + // Remove diff markers + Object.values(cleanState.blocks).forEach(block => { + delete (block as any).is_diff + }) + + logger.info('Diff accepted', { + blocksCount: Object.keys(cleanState.blocks).length, + edgesCount: cleanState.edges.length + }) + + this.clearDiff() + return cleanState + } + + /** + * Analyze differences between two workflow states + */ + static async analyzeDiff( + originalYaml: string, + proposedYaml: string + ): Promise { + try { + const response = await fetch('/api/workflows/diff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + original_yaml: originalYaml, + agent_yaml: proposedYaml + }) + }) + + if (response.ok) { + const result = await response.json() + if (result.success && result.data) { + return result.data + } + } + } catch (error) { + logger.error('Failed to analyze diff:', error) + } + + return null + } +} \ No newline at end of file diff --git a/apps/sim/lib/workflows/diff/index.ts b/apps/sim/lib/workflows/diff/index.ts new file mode 100644 index 00000000000..cb9a585bd65 --- /dev/null +++ b/apps/sim/lib/workflows/diff/index.ts @@ -0,0 +1,4 @@ +export { WorkflowDiffEngine } from './diff-engine' +export type { DiffMetadata, DiffAnalysis, WorkflowDiff, DiffResult } from './diff-engine' +export { useWorkflowDiff } from './use-workflow-diff' +export type { UseWorkflowDiffReturn } from './use-workflow-diff' \ No newline at end of file diff --git a/apps/sim/lib/workflows/diff/use-workflow-diff.ts b/apps/sim/lib/workflows/diff/use-workflow-diff.ts new file mode 100644 index 00000000000..6b10a46ba0c --- /dev/null +++ b/apps/sim/lib/workflows/diff/use-workflow-diff.ts @@ -0,0 +1,169 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { createLogger } from '@/lib/logs/console-logger' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { WorkflowDiffEngine, type DiffAnalysis } from './diff-engine' + +const logger = createLogger('useWorkflowDiff') + +export interface UseWorkflowDiffReturn { + isShowingDiff: boolean + hasDiff: boolean + setProposedChanges: (yamlContent: string, diffAnalysis?: DiffAnalysis) => Promise + clearDiff: () => void + acceptChanges: () => Promise + rejectChanges: () => void + toggleDiffView: () => void + getCurrentWorkflowForCanvas: () => any +} + +/** + * Hook that provides workflow diff functionality + * without polluting core stores + */ +export function useWorkflowDiff(): UseWorkflowDiffReturn { + const [isShowingDiff, setIsShowingDiff] = useState(false) + const diffEngineRef = useRef(null) + + // Get store methods + const workflowStore = useWorkflowStore() + const activeWorkflowId = useWorkflowRegistry(state => state.activeWorkflowId) + + // Initialize diff engine + if (!diffEngineRef.current) { + diffEngineRef.current = new WorkflowDiffEngine() + } + + const setProposedChanges = useCallback(async ( + yamlContent: string, + diffAnalysis?: DiffAnalysis + ): Promise => { + try { + logger.info('Setting proposed changes') + + const result = await diffEngineRef.current!.createDiffFromYaml( + yamlContent, + diffAnalysis + ) + + if (result.success) { + setIsShowingDiff(true) + return true + } + + logger.error('Failed to create diff:', result.errors) + return false + } catch (error) { + logger.error('Error setting proposed changes:', error) + return false + } + }, []) + + const clearDiff = useCallback(() => { + logger.info('Clearing diff') + diffEngineRef.current!.clearDiff() + setIsShowingDiff(false) + }, []) + + const acceptChanges = useCallback(async (): Promise => { + if (!activeWorkflowId) { + logger.error('No active workflow ID') + return false + } + + try { + logger.info('Accepting diff changes') + + const cleanState = diffEngineRef.current!.acceptDiff() + if (!cleanState) { + logger.warn('No diff to accept') + return false + } + + // Update workflow store with the clean state + useWorkflowStore.setState({ + blocks: cleanState.blocks, + edges: cleanState.edges, + loops: cleanState.loops, + parallels: cleanState.parallels + }) + + // Update subblock store with values from diff + const subblockValues: Record> = {} + Object.entries(cleanState.blocks).forEach(([blockId, block]) => { + subblockValues[blockId] = {} + Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = subblock.value + }) + }) + + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues + } + })) + + // Update last saved timestamp + workflowStore.updateLastSaved() + + // Persist to database + try { + const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...cleanState, + lastSaved: Date.now() + }) + }) + + if (!response.ok) { + throw new Error(`Failed to save: ${response.statusText}`) + } + + logger.info('Diff changes persisted to database') + } catch (error) { + logger.error('Failed to persist diff changes:', error) + // State is already updated locally, so don't fail the operation + } + + setIsShowingDiff(false) + return true + } catch (error) { + logger.error('Failed to accept changes:', error) + return false + } + }, [activeWorkflowId, workflowStore]) + + const rejectChanges = useCallback(() => { + logger.info('Rejecting diff changes') + clearDiff() + }, [clearDiff]) + + const toggleDiffView = useCallback(() => { + setIsShowingDiff(prev => !prev) + }, []) + + const getCurrentWorkflowForCanvas = useCallback(() => { + const currentState = workflowStore.getWorkflowState() + + if (isShowingDiff && diffEngineRef.current!.hasDiff()) { + return diffEngineRef.current!.getDisplayState(currentState) + } + + return currentState + }, [isShowingDiff, workflowStore]) + + return { + isShowingDiff, + hasDiff: diffEngineRef.current!.hasDiff(), + setProposedChanges, + clearDiff, + acceptChanges, + rejectChanges, + toggleDiffView, + getCurrentWorkflowForCanvas + } +} \ No newline at end of file diff --git a/apps/sim/lib/workflows/yaml-converter.ts b/apps/sim/lib/workflows/yaml-converter.ts new file mode 100644 index 00000000000..e232d34eb52 --- /dev/null +++ b/apps/sim/lib/workflows/yaml-converter.ts @@ -0,0 +1,391 @@ +import { v4 as uuidv4 } from 'uuid' +import { createLogger } from '@/lib/logs/console-logger' +import { getBlock } from '@/blocks' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { + parseWorkflowYaml, + convertYamlToWorkflow +} from '@/stores/workflows/yaml/importer' +import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' +import type { ImportedEdge } from '@/stores/workflows/yaml/parsing-utils' + +// Define local types that aren't exported from importer +interface ImportedBlock { + id: string + type: string + name: string + inputs: Record + position: { x: number; y: number } + data?: Record + parentId?: string + extent?: 'parent' +} + +interface ImportResult { + blocks: ImportedBlock[] + edges: ImportedEdge[] + errors: string[] + warnings: string[] +} + +const logger = createLogger('YamlConverter') + +/** + * Unified YAML converter that handles all YAML<->WorkflowState conversions + * This consolidates logic from multiple places to avoid duplication + */ + +export interface YamlConversionResult { + success: boolean + workflowState?: WorkflowState + errors: string[] + warnings: string[] + idMapping?: Map +} + +export interface WorkflowToYamlResult { + success: boolean + yaml?: string + error?: string +} + +/** + * Convert YAML content to a complete WorkflowState + * This consolidates logic from diff store, copilot store, and API routes + */ +export async function convertYamlToWorkflowState( + yamlContent: string, + options: { + generateNewIds?: boolean + existingBlocks?: Record + preservePositions?: boolean + } = {} +): Promise { + const { generateNewIds = true, existingBlocks = {}, preservePositions = false } = options + + // Step 1: Parse YAML + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) + + if (!yamlWorkflow || parseErrors.length > 0) { + return { + success: false, + errors: parseErrors, + warnings: [] + } + } + + // Step 2: Convert YAML to imported blocks/edges + const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow) + + if (convertErrors.length > 0) { + return { + success: false, + errors: convertErrors, + warnings + } + } + + // Step 3: Create ID mapping + const idMapping = new Map() + + if (generateNewIds) { + blocks.forEach(block => { + const newId = uuidv4() + idMapping.set(block.id, newId) + }) + } else { + // Use existing IDs + blocks.forEach(block => { + idMapping.set(block.id, block.id) + }) + } + + // Step 4: Build WorkflowState with proper block configuration + const workflowBlocks: Record = {} + + for (const importedBlock of blocks) { + const blockId = idMapping.get(importedBlock.id)! + + // Handle special blocks (loop/parallel) + if (importedBlock.type === 'loop' || importedBlock.type === 'parallel') { + workflowBlocks[blockId] = createContainerBlock(blockId, importedBlock) + continue + } + + // Get block configuration + const blockConfig = getBlock(importedBlock.type) + if (!blockConfig) { + logger.warn(`Unknown block type: ${importedBlock.type}`) + continue + } + + // Create block with proper subBlocks + workflowBlocks[blockId] = createRegularBlock(blockId, importedBlock, blockConfig) + } + + // Step 5: Update block references in subblock values + updateBlockReferences(workflowBlocks, idMapping) + + // Step 6: Create edges with mapped IDs + const workflowEdges = edges.map(edge => ({ + id: uuidv4(), + source: idMapping.get(edge.source) || edge.source, + target: idMapping.get(edge.target) || edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default' + })) + + // Step 7: Generate loops and parallels + const loops = generateLoopBlocks(workflowBlocks) + const parallels = generateParallelBlocks(workflowBlocks) + + // Step 8: Create final WorkflowState + const workflowState: WorkflowState = { + blocks: workflowBlocks, + edges: workflowEdges, + loops, + parallels, + lastSaved: Date.now() + } + + return { + success: true, + workflowState, + errors: [], + warnings, + idMapping + } +} + +/** + * Convert WorkflowState to YAML + */ +export function convertWorkflowStateToYaml( + workflowState: WorkflowState, + subBlockValues?: Record> +): WorkflowToYamlResult { + try { + const yaml = generateWorkflowYaml(workflowState, subBlockValues) + return { + success: true, + yaml + } + } catch (error) { + logger.error('Failed to generate YAML:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } +} + +/** + * Create a container block (loop/parallel) + */ +function createContainerBlock( + blockId: string, + importedBlock: ImportedBlock +): BlockState { + return { + id: blockId, + type: importedBlock.type, + name: importedBlock.name, + position: importedBlock.position, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: { + ...importedBlock.data, + ...(importedBlock.parentId && { + parentId: importedBlock.parentId, + extent: importedBlock.extent + }) + } + } +} + +/** + * Create a regular block with proper subBlocks + */ +function createRegularBlock( + blockId: string, + importedBlock: ImportedBlock, + blockConfig: any +): BlockState { + // Initialize subBlocks from block configuration + const subBlocks: Record = {} + + blockConfig.subBlocks.forEach((subBlock: any) => { + const subBlockId = subBlock.id + const yamlValue = importedBlock.inputs[subBlockId] + + subBlocks[subBlockId] = { + id: subBlockId, + type: subBlock.type, + value: yamlValue !== undefined ? yamlValue : null + } + }) + + // Also ensure we have subBlocks for any YAML inputs not in block config + Object.keys(importedBlock.inputs).forEach((inputKey) => { + if (!subBlocks[inputKey]) { + subBlocks[inputKey] = { + id: inputKey, + type: 'short-input', + value: importedBlock.inputs[inputKey] + } + } + }) + + const outputs = resolveOutputType(blockConfig.outputs) + + return { + id: blockId, + type: importedBlock.type, + name: importedBlock.name, + position: importedBlock.position, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: { + ...importedBlock.data, + ...(importedBlock.parentId && { + parentId: importedBlock.parentId, + extent: importedBlock.extent + }) + } + } +} + +/** + * Update block references in subblock values + */ +function updateBlockReferences( + blocks: Record, + idMapping: Map +): void { + Object.values(blocks).forEach(block => { + Object.values(block.subBlocks).forEach(subBlock => { + if (subBlock.value !== null && subBlock.value !== undefined) { + subBlock.value = updateValueReferences(subBlock.value, idMapping) + } + }) + }) +} + +/** + * Recursively update block references in a value + */ +function updateValueReferences(value: any, idMapping: Map): any { + if (typeof value === 'string' && value.includes('<') && value.includes('>')) { + let processedValue = value + const blockMatches = value.match(/<([^>]+)>/g) + + if (blockMatches) { + for (const match of blockMatches) { + const path = match.slice(1, -1) + const [blockRef] = path.split('.') + + // Skip system references + if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { + continue + } + + // Check if this references an old block ID that needs mapping + const newMappedId = idMapping.get(blockRef) + if (newMappedId) { + processedValue = processedValue.replace( + new RegExp(`<${blockRef}\\.`, 'g'), + `<${newMappedId}.` + ) + processedValue = processedValue.replace( + new RegExp(`<${blockRef}>`, 'g'), + `<${newMappedId}>` + ) + } + } + } + + return processedValue + } + + // Handle arrays + if (Array.isArray(value)) { + return value.map((item) => updateValueReferences(item, idMapping)) + } + + // Handle objects + if (value !== null && typeof value === 'object') { + const result = { ...value } + for (const key in result) { + result[key] = updateValueReferences(result[key], idMapping) + } + return result + } + + return value +} + +/** + * Apply auto layout to workflow blocks + */ +export async function applyAutoLayoutToBlocks( + blocks: Record, + edges: any[] +): Promise<{ + success: boolean + layoutedBlocks?: Record + error?: string +}> { + try { + // Try to import from the actual auto-layout location + const autoLayoutModule = await import('@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout') + + if (autoLayoutModule.applyAutoLayoutToBlocks) { + // Use the existing auto-layout function + return await autoLayoutModule.applyAutoLayoutToBlocks(blocks, edges) + } + + // Fallback to autolayout service + const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') + + const layoutedBlocks = await autoLayoutWorkflow( + blocks, + edges, + { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700, + }, + alignment: 'center', + padding: { + x: 250, + y: 250, + }, + } + ) + + return { + success: true, + layoutedBlocks + } + } catch (error) { + logger.error('Auto layout failed:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Auto layout failed' + } + } +} \ No newline at end of file diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 61731413510..2d7cfb691bf 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -618,6 +618,16 @@ export const useCopilotStore = create()( if (success) { existingToolCall.result = result logger.info('Updated tool call result:', toolCallId, existingToolCall.name) + + // Handle successful preview_workflow tool result + if (existingToolCall.name === 'preview_workflow' && result?.yamlContent) { + logger.info('Setting preview YAML from tool_result event', { + yamlLength: result.yamlContent.length, + yamlPreview: result.yamlContent.substring(0, 100) + }) + get().setPreviewYaml(result.yamlContent) + get().updateDiffStore(result.yamlContent) + } } else { // Tool execution failed existingToolCall.state = 'error' @@ -779,12 +789,21 @@ export const useCopilotStore = create()( })) // If this is a preview_workflow tool call, set the preview YAML and diff store - if (toolCallBuffer.name === 'preview_workflow' && toolCallBuffer.input?.yamlContent) { - logger.info('Setting preview YAML from completed preview_workflow tool call') - get().setPreviewYaml(toolCallBuffer.input.yamlContent) + if (toolCallBuffer.name === 'preview_workflow') { + logger.info('Preview workflow tool completed with input:', toolCallBuffer.input) - // Also update the diff store with the proposed workflow state - get().updateDiffStore(toolCallBuffer.input.yamlContent) + if (toolCallBuffer.input?.yamlContent) { + logger.info('Setting preview YAML from completed preview_workflow tool call', { + yamlLength: toolCallBuffer.input.yamlContent.length, + yamlPreview: toolCallBuffer.input.yamlContent.substring(0, 100) + }) + get().setPreviewYaml(toolCallBuffer.input.yamlContent) + + // Also update the diff store with the proposed workflow state + get().updateDiffStore(toolCallBuffer.input.yamlContent) + } else { + logger.warn('Preview workflow tool completed but no yamlContent found in input') + } } } catch (error) { logger.error('Error parsing tool call input:', error) @@ -1126,145 +1145,9 @@ export const useCopilotStore = create()( // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') - // Import YAML parsing utilities - const { parseWorkflowYaml, convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') - const { getBlock } = await import('@/blocks') - const { resolveOutputType } = await import('@/blocks/utils') - const { generateLoopBlocks, generateParallelBlocks } = await import('@/stores/workflows/workflow/utils') - - logger.info('Converting copilot YAML to workflow state for diff store') - - // Parse YAML content - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) - - if (!yamlWorkflow || parseErrors.length > 0) { - logger.error('Failed to parse YAML for diff store:', parseErrors) - return - } - - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - logger.error('Failed to convert YAML for diff store:', convertErrors) - return - } - - // Convert ImportedBlocks to workflow store format - const workflowBlocks: Record = {} - const workflowEdges: any[] = [] - - // Process blocks - for (const block of blocks) { - // Handle loop and parallel blocks specially (they don't have regular block configs) - if (block.type === 'loop' || block.type === 'parallel') { - // Get block config and populate subBlocks with YAML input values - const subBlocks: Record = {} - - // For loop/parallel blocks, map inputs to subBlocks for compatibility - Object.keys(block.inputs).forEach((inputKey) => { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - }) - - workflowBlocks[block.id] = { - id: block.id, - type: block.type, - name: block.name, - position: block.position, - subBlocks, - outputs: {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - } - continue - } - - // Get block config and populate subBlocks with YAML input values - const blockConfig = getBlock(block.type) - if (!blockConfig) { - logger.warn(`Unknown block type: ${block.type}`) - continue - } - - // Initialize all subBlocks from block config - const subBlocks: Record = {} - blockConfig.subBlocks.forEach((subBlock) => { - const subBlockId = subBlock.id - // Check if this subBlock has a value from YAML - const yamlValue = block.inputs[subBlockId] - subBlocks[subBlockId] = { - id: subBlockId, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) + logger.info('Updating diff store with copilot YAML') - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - } - }) - - const outputs = resolveOutputType(blockConfig.outputs) - - workflowBlocks[block.id] = { - id: block.id, - type: block.type, - name: block.name, - position: block.position, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - parentId: block.parentId, - extent: block.extent, - } - } - - // Process edges - for (const edge of edges) { - workflowEdges.push({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - }) - } - - // Generate loops and parallels - const loops = generateLoopBlocks(workflowBlocks) - const parallels = generateParallelBlocks(workflowBlocks) - - // Apply auto layout to the proposed workflow - const { applyAutoLayoutToBlocks } = await import('@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout') - const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) - - const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks - - if (layoutResult.success) { - logger.info('Successfully applied auto layout to proposed blocks') - } else { - logger.warn('Auto layout failed for proposed blocks, using original positions:', layoutResult.error) - } - - // Generate diff analysis by comparing current workflow with proposed workflow + // Generate diff analysis by comparing current vs proposed YAML let diffAnalysis = null try { // Get current workflow as YAML for comparison @@ -1299,23 +1182,23 @@ export const useCopilotStore = create()( // Continue without diff analysis - blocks will be marked as unchanged } - // Create the proposed workflow state - const proposedWorkflowState = { - blocks: layoutedBlocks, - edges: workflowEdges, - loops, - parallels, - lastSaved: Date.now(), - } - - // Set the proposed changes in the diff store with diff analysis + // Set the proposed changes in the diff store + // The diff store now handles all YAML parsing and conversion internally const diffStore = useWorkflowDiffStore.getState() - await diffStore.setProposedChanges(proposedWorkflowState, diffAnalysis) + await diffStore.setProposedChanges(yamlContent, diffAnalysis) - logger.info('Successfully updated diff store with proposed workflow changes and diff analysis') + logger.info('Successfully updated diff store with proposed workflow changes') } catch (error) { logger.error('Failed to update diff store:', error) + // Show error to user + console.error('[Copilot] Error updating diff store:', error) + + // Try to show at least the preview YAML even if diff fails + const { currentChat } = get() + if (currentChat?.previewYaml) { + logger.info('Preview YAML is set, user can still view it despite diff error') + } } }, }), diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 4225c4ca775..932fa430501 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -1,27 +1,29 @@ -import type { Edge } from 'reactflow' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console-logger' +import { WorkflowDiffEngine, type DiffAnalysis } from '@/lib/workflows/diff' import { useWorkflowStore } from '../workflows/workflow/store' import { useSubBlockStore } from '../workflows/subblock/store' import { useWorkflowRegistry } from '../workflows/registry/store' -import type { WorkflowState, BlockState } from '../workflows/workflow/types' -import { generateLoopBlocks, generateParallelBlocks } from '../workflows/workflow/utils' +import type { WorkflowState } from '../workflows/workflow/types' const logger = createLogger('WorkflowDiffStore') +// Create a singleton diff engine instance +const diffEngine = new WorkflowDiffEngine() + interface WorkflowDiffState { isShowingDiff: boolean diffWorkflow: WorkflowState | null - diffAnalysis: any | null - diffMetadata?: { + diffAnalysis: DiffAnalysis | null + diffMetadata: { source: string timestamp: number } | null } interface WorkflowDiffActions { - setProposedChanges: (proposedWorkflow: WorkflowState, diffAnalysis?: any) => Promise + setProposedChanges: (yamlContent: string, diffAnalysis?: DiffAnalysis) => Promise clearDiff: () => void getCurrentWorkflowForCanvas: () => WorkflowState toggleDiffView: () => void @@ -30,114 +32,9 @@ interface WorkflowDiffActions { } /** - * Validate workflow blocks and edges (mirrors YAML import approach) - * Reports issues without making destructive changes - */ -function validateWorkflowStructure(blocks: Record, edges: Edge[]): { - errors: string[] - warnings: string[] -} { - const errors: string[] = [] - const warnings: string[] = [] - const blockIds = new Set(Object.keys(blocks)) - - // Validate block references in edges - edges.forEach((edge) => { - if (!blockIds.has(edge.source)) { - errors.push(`Edge ${edge.id} references non-existent source block '${edge.source}'`) - } - if (!blockIds.has(edge.target)) { - errors.push(`Edge ${edge.id} references non-existent target block '${edge.target}'`) - } - }) - - // Validate parent-child relationships - Object.entries(blocks).forEach(([blockId, block]) => { - const parentId = block.data?.parentId - if (parentId && !blockIds.has(parentId)) { - errors.push(`Block '${blockId}' references non-existent parent block '${parentId}'`) - } - }) - - return { errors, warnings } -} - -/** - * Create ID mapping from proposed IDs to new UUIDs (mirrors YAML import approach) - */ -function createIdMapping(proposedBlocks: Record): Map { - const idMapping = new Map() - - Object.keys(proposedBlocks).forEach(oldId => { - const newId = crypto.randomUUID() - idMapping.set(oldId, newId) - }) - - logger.info('Created ID mapping for diff workflow', { - mappingCount: idMapping.size, - mappings: Array.from(idMapping.entries()) - }) - - return idMapping -} - -/** - * Update block references in values with new mapped IDs (mirrors YAML import approach) + * Simplified diff store that delegates to the diff engine + * This maintains backward compatibility while removing redundant logic */ -function updateBlockReferences( - value: any, - blockIdMapping: Map -): any { - if (typeof value === 'string' && value.includes('<') && value.includes('>')) { - let processedValue = value - const blockMatches = value.match(/<([^>]+)>/g) - - if (blockMatches) { - for (const match of blockMatches) { - const path = match.slice(1, -1) - const [blockRef] = path.split('.') - - // Skip system references (start, loop, parallel, variable) - if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { - continue - } - - // Check if this references an old block ID that needs mapping - const newMappedId = blockIdMapping.get(blockRef) - if (newMappedId) { - logger.debug(`Updating block reference: ${blockRef} -> ${newMappedId}`) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}\\.`, 'g'), - `<${newMappedId}.` - ) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}>`, 'g'), - `<${newMappedId}>` - ) - } - } - } - - return processedValue - } - - // Handle arrays - if (Array.isArray(value)) { - return value.map((item) => updateBlockReferences(item, blockIdMapping)) - } - - // Handle objects - if (value !== null && typeof value === 'object') { - const result = { ...value } - for (const key in result) { - result[key] = updateBlockReferences(result[key], blockIdMapping) - } - return result - } - - return value -} - export const useWorkflowDiffStore = create()( devtools( (set, get) => ({ @@ -146,222 +43,33 @@ export const useWorkflowDiffStore = create { - logger.info('Setting proposed changes for diff mode with ID mapping') - - // Log the incoming workflow structure for debugging - const incomingLoopBlocks = Object.entries(proposedWorkflow.blocks) - .filter(([_, block]) => block.type === 'loop') - .map(([id, block]) => ({ id, type: block.type, data: block.data })) + setProposedChanges: async (yamlContent: string, diffAnalysis?: DiffAnalysis) => { + logger.info('Setting proposed changes via YAML') - logger.info('Incoming loop blocks:', incomingLoopBlocks) + const result = await diffEngine.createDiffFromYaml(yamlContent, diffAnalysis) - // Create ID mapping from proposed IDs to new UUIDs (like YAML import) - const blockIdMapping = createIdMapping(proposedWorkflow.blocks) - - // Create new blocks with mapped IDs and updated references - const mappedBlocks: Record = {} - - Object.entries(proposedWorkflow.blocks).forEach(([oldId, block]) => { - const newId = blockIdMapping.get(oldId)! - - // Create new block with mapped ID - const mappedBlock: BlockState = { - ...block, - id: newId, - // Update parent references if they exist - data: block.data ? { - ...block.data, - parentId: block.data.parentId ? blockIdMapping.get(block.data.parentId) : undefined - } : undefined - } - - // Special handling for loop and parallel blocks (like YAML import) - if (block.type === 'loop' || block.type === 'parallel') { - // For loop/parallel blocks, ensure proper data structure - mappedBlock.data = { - ...mappedBlock.data, - width: mappedBlock.data?.width || 500, - height: mappedBlock.data?.height || 300, - type: block.type === 'loop' ? 'loopNode' : 'parallelNode', - // Preserve loop-specific properties - loopType: mappedBlock.data?.loopType || 'for', - count: mappedBlock.data?.count || 5, - collection: mappedBlock.data?.collection || '', - // Preserve parallel-specific properties - parallelType: mappedBlock.data?.parallelType || 'collection', - } - - // For container blocks, subBlocks should be empty (they don't use them) - mappedBlock.subBlocks = {} - mappedBlock.outputs = {} - - logger.debug(`Mapped ${block.type} block with special data structure:`, { - oldId, - newId, - data: mappedBlock.data - }) - } else { - // Update block references in subblock values for regular blocks - if (mappedBlock.subBlocks) { - Object.entries(mappedBlock.subBlocks).forEach(([subBlockId, subBlock]) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subBlock.value = updateBlockReferences(subBlock.value, blockIdMapping) - } - }) - } - } - - mappedBlocks[newId] = mappedBlock - }) - - // Create new edges with mapped IDs - const mappedEdges: Edge[] = proposedWorkflow.edges.map(edge => ({ - ...edge, - id: crypto.randomUUID(), // Generate new edge ID - source: blockIdMapping.get(edge.source) || edge.source, - target: blockIdMapping.get(edge.target) || edge.target - })) - - // Create the enhanced workflow with mapped IDs - const enhancedWorkflow = { - ...proposedWorkflow, - blocks: mappedBlocks, - edges: mappedEdges - } - - // Add is_diff field to blocks based on diff analysis - if (diffAnalysis && diffAnalysis.new_blocks && diffAnalysis.edited_blocks) { - Object.keys(enhancedWorkflow.blocks).forEach(blockId => { - const block = enhancedWorkflow.blocks[blockId] - // Find original ID to check diff analysis - const originalId = Array.from(blockIdMapping.entries()).find(([_, newId]) => newId === blockId)?.[0] - - if (originalId) { - if (diffAnalysis.new_blocks.includes(originalId)) { - block.is_diff = 'new' - } else if (diffAnalysis.edited_blocks.includes(originalId)) { - block.is_diff = 'edited' - } else { - block.is_diff = 'unchanged' - } - } else { - block.is_diff = 'unchanged' - } + if (result.success && result.diff) { + set({ + isShowingDiff: true, + diffWorkflow: result.diff.proposedState, + diffAnalysis: result.diff.diffAnalysis || null, + diffMetadata: result.diff.metadata }) + logger.info('Diff created successfully') } else { - // If no diff analysis provided, mark all blocks as unchanged - Object.keys(enhancedWorkflow.blocks).forEach(blockId => { - const block = enhancedWorkflow.blocks[blockId] - block.is_diff = 'unchanged' - }) - } - - // Generate loops and parallels from mapped blocks - const generatedLoops = generateLoopBlocks(enhancedWorkflow.blocks) - const generatedParallels = generateParallelBlocks(enhancedWorkflow.blocks) - - // Update loops and parallels with mapped IDs - enhancedWorkflow.loops = generatedLoops - enhancedWorkflow.parallels = generatedParallels - - // Log final loop blocks for debugging - const finalLoopBlocks = Object.entries(enhancedWorkflow.blocks) - .filter(([_, block]) => block.type === 'loop') - .map(([id, block]) => ({ id, type: block.type, data: block.data })) - - logger.info('Final processed loop blocks:', finalLoopBlocks) - logger.info('Generated loops from blocks:', generatedLoops) - - // Validate the workflow structure (like YAML import does) - const { errors, warnings } = validateWorkflowStructure( - enhancedWorkflow.blocks, - enhancedWorkflow.edges - ) - - // Log validation results without making destructive changes - if (errors.length > 0) { - logger.warn('Validation errors in proposed workflow changes:', errors) - // Log detailed block and edge information for debugging - logger.warn('Problematic workflow structure:', { - blockIds: Object.keys(enhancedWorkflow.blocks), - edges: enhancedWorkflow.edges.map(e => ({ id: e.id, source: e.source, target: e.target })), - blocksWithParents: Object.entries(enhancedWorkflow.blocks) - .filter(([_, block]) => block.data?.parentId) - .map(([id, block]) => ({ id, parentId: block.data?.parentId })) - }) - } - if (warnings.length > 0) { - logger.warn('Validation warnings in proposed workflow changes:', warnings) - } - - // Apply autolayout to ensure blocks are well-positioned for diff review - try { - logger.info('Applying autolayout to diff workflow for better visualization') - - // Import autolayout service - const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - - // Apply autolayout with the same settings used in other parts of the codebase - const layoutedBlocks = await autoLayoutWorkflow( - enhancedWorkflow.blocks, - enhancedWorkflow.edges, - { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, - vertical: 400, - layer: 700, - }, - alignment: 'center', - padding: { - x: 250, - y: 250, - }, - } - ) - - // Update the workflow with the layouted blocks - enhancedWorkflow.blocks = layoutedBlocks - - logger.info('Successfully applied autolayout to diff workflow', { - blocksCount: Object.keys(layoutedBlocks).length, - }) - - } catch (layoutError) { - // Log the error but don't fail the diff - use original positions - logger.warn('Autolayout failed for diff workflow, using original positions:', layoutError) + logger.error('Failed to create diff:', result.errors) + throw new Error(result.errors?.join(', ') || 'Failed to create diff') } - - logger.info('Generated loops and parallels for diff workflow with ID mapping', { - loopsCount: Object.keys(generatedLoops).length, - parallelsCount: Object.keys(generatedParallels).length, - blocksCount: Object.keys(enhancedWorkflow.blocks).length, - edgesCount: enhancedWorkflow.edges.length, - validationErrors: errors.length, - validationWarnings: warnings.length, - idMappingsCount: blockIdMapping.size - }) - - set({ - diffWorkflow: enhancedWorkflow, - diffAnalysis, - isShowingDiff: true, - diffMetadata: { - source: 'copilot', - timestamp: Date.now(), - }, - }) }, clearDiff: () => { - logger.info('Clearing diff mode') - set({ + logger.info('Clearing diff') + diffEngine.clearDiff() + set({ isShowingDiff: false, diffWorkflow: null, diffAnalysis: null, - diffMetadata: null, + diffMetadata: null }) }, @@ -372,106 +80,66 @@ export const useWorkflowDiffStore = create { - const { diffWorkflow } = get() - if (!diffWorkflow) { - logger.warn('No diff workflow to accept') - return + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + + if (!activeWorkflowId) { + logger.error('No active workflow ID found when accepting diff') + throw new Error('No active workflow found') } logger.info('Accepting proposed changes') try { - // Apply the diff workflow to the main workflow store - const workflowStore = useWorkflowStore.getState() - - // Get the current active workflow ID for subblock store updates - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - if (!activeWorkflowId) { - logger.error('No active workflow ID found when accepting diff') - throw new Error('No active workflow found') + const cleanState = diffEngine.acceptDiff() + if (!cleanState) { + logger.warn('No diff to accept') + return } + + // Update the main workflow store state + useWorkflowStore.setState({ + blocks: cleanState.blocks, + edges: cleanState.edges, + loops: cleanState.loops, + parallels: cleanState.parallels, + }) - // Directly replace the workflow state instead of using addBlock - // This preserves all the block data including subBlocks with their values - const newState = { - blocks: { ...diffWorkflow.blocks }, - edges: [...diffWorkflow.edges], - loops: { ...diffWorkflow.loops }, - parallels: { ...diffWorkflow.parallels }, - } + // Update the subblock store with the values from the diff workflow blocks + const subblockValues: Record> = {} - // Remove is_diff properties from blocks as they're no longer needed - Object.values(newState.blocks).forEach((block) => { - delete block.is_diff + Object.entries(cleanState.blocks).forEach(([blockId, block]) => { + subblockValues[blockId] = {} + Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { + subblockValues[blockId][subblockId] = (subblock as any).value + }) }) - // Update the main workflow store state - useWorkflowStore.setState((state) => ({ - ...state, - ...newState, + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: subblockValues, + }, })) - // Update the subblock store with the values from the diff workflow blocks - if (activeWorkflowId) { - const subblockValues: Record> = {} - - Object.entries(diffWorkflow.blocks).forEach(([blockId, block]) => { - subblockValues[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = subblock.value - }) - }) - - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: subblockValues, - }, - })) - - logger.info('Updated subblock store with diff values', { - blocksWithSubblocks: Object.keys(subblockValues).length, - totalSubblocks: Object.values(subblockValues).reduce((sum, blockSubblocks) => - sum + Object.keys(blockSubblocks).length, 0 - ) - }) - } - // Trigger save and history + const workflowStore = useWorkflowStore.getState() workflowStore.updateLastSaved() - logger.info('Successfully applied diff workflow to main store', { - blocksCount: Object.keys(newState.blocks).length, - edgesCount: newState.edges.length, - loopsCount: Object.keys(newState.loops).length, - parallelsCount: Object.keys(newState.parallels).length, - }) + logger.info('Successfully applied diff workflow to main store') - // IMPORTANT: Persist to database - // This was the missing piece that caused accepted diffs not to be saved + // Persist to database try { logger.info('Persisting accepted diff changes to database') - // Get the complete workflow state for database persistence - const completeWorkflowState = { - blocks: newState.blocks, - edges: newState.edges, - loops: newState.loops, - parallels: newState.parallels, - lastSaved: Date.now(), - isDeployed: diffWorkflow.isDeployed || false, - deployedAt: diffWorkflow.deployedAt, - deploymentStatuses: diffWorkflow.deploymentStatuses || {}, - hasActiveWebhook: diffWorkflow.hasActiveWebhook || false, - } - const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(completeWorkflowState), + body: JSON.stringify({ + ...cleanState, + lastSaved: Date.now(), + }), }) if (!response.ok) { @@ -489,7 +157,6 @@ export const useWorkflowDiffStore = create { - const { isShowingDiff, diffWorkflow } = get() + const { isShowingDiff } = get() - if (isShowingDiff && diffWorkflow) { + if (isShowingDiff && diffEngine.hasDiff()) { logger.debug('Returning diff workflow for canvas') - return diffWorkflow + const currentState = useWorkflowStore.getState().getWorkflowState() + return diffEngine.getDisplayState(currentState) } // Return the actual workflow state using the main store's method - // This eliminates code duplication and automatically stays in sync with WorkflowState changes return useWorkflowStore.getState().getWorkflowState() }, }), diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 108490d9afc..5b3d32f3b00 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -75,7 +75,6 @@ export interface BlockState { height?: number advancedMode?: boolean data?: BlockData - is_diff?: 'new' | 'edited' | 'unchanged' } export interface SubBlockState { From 0671c76b3a16aee1df788a248550e14b4c87ae6b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 13:44:22 -0700 Subject: [PATCH 043/184] Works?? --- .../workflow-block/workflow-block.tsx | 15 ++++++++++- apps/sim/lib/workflows/diff/diff-engine.ts | 27 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 0420209922c..8474f6e5aa5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -66,12 +66,22 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Workflow store selectors const lastUpdate = useWorkflowStore((state) => state.lastUpdate) - // Use the clean abstraction for current workflow state // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) const isEnabled = currentBlock?.enabled ?? true + + // Get diff status from the block itself (set by diff engine) + const diffStatus = currentWorkflow.isDiffMode && currentBlock ? + (currentBlock as any).is_diff : undefined + + // Debug: Log when in diff mode + useEffect(() => { + if (currentWorkflow.isDiffMode) { + console.log(`[WorkflowBlock ${id}] Diff mode active, block exists: ${!!currentBlock}, diff status: ${diffStatus}`) + } + }, [currentWorkflow.isDiffMode, currentBlock, diffStatus, id]) const horizontalHandles = data.isPreview ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency @@ -467,6 +477,9 @@ export function WorkflowBlock({ id, data }: NodeProps) { !isEnabled && 'shadow-sm', isActive && 'animate-pulse-ring ring-2 ring-blue-500', isPending && 'ring-2 ring-amber-500', + // Diff highlighting + diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', + diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', 'z-[20]' )} > diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 663ac237c4b..15973eacf51 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -69,14 +69,22 @@ export class WorkflowDiffEngine { } // Add diff markers to blocks if analysis is provided + let mappedDiffAnalysis = diffAnalysis if (diffAnalysis) { + logger.info('Applying diff markers with analysis:', { + new_blocks: diffAnalysis.new_blocks, + edited_blocks: diffAnalysis.edited_blocks, + deleted_blocks: diffAnalysis.deleted_blocks + }) this.applyDiffMarkers(proposedState, diffAnalysis, conversionResult.idMapping!) + // Create a mapped version of the diff analysis with new IDs + mappedDiffAnalysis = this.createMappedDiffAnalysis(diffAnalysis, conversionResult.idMapping!) } // Create the diff object this.currentDiff = { proposedState, - diffAnalysis, + diffAnalysis: mappedDiffAnalysis, metadata: { source: 'copilot', timestamp: Date.now() @@ -101,6 +109,20 @@ export class WorkflowDiffEngine { } } + /** + * Create a mapped version of diff analysis with new IDs + */ + private createMappedDiffAnalysis( + analysis: DiffAnalysis, + idMapping: Map + ): DiffAnalysis { + return { + new_blocks: analysis.new_blocks.map(oldId => idMapping.get(oldId) || oldId), + edited_blocks: analysis.edited_blocks.map(oldId => idMapping.get(oldId) || oldId), + deleted_blocks: analysis.deleted_blocks // Deleted blocks won't have new IDs + } + } + /** * Apply diff markers to blocks based on analysis */ @@ -122,13 +144,16 @@ export class WorkflowDiffEngine { if (originalId) { if (analysis.new_blocks.includes(originalId)) { (block as any).is_diff = 'new' + logger.info(`Block ${blockId} (original: ${originalId}) marked as new`) } else if (analysis.edited_blocks.includes(originalId)) { (block as any).is_diff = 'edited' + logger.info(`Block ${blockId} (original: ${originalId}) marked as edited`) } else { (block as any).is_diff = 'unchanged' } } else { (block as any).is_diff = 'unchanged' + logger.warn(`Block ${blockId} has no original ID mapping`) } }) } From f4a79b3df94bae41067bcc9b4916043270e1d1b0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 14:16:14 -0700 Subject: [PATCH 044/184] getting there --- .../sim/app/api/tools/get-all-blocks/route.ts | 27 ++- .../api/tools/get-blocks-metadata/route.ts | 161 ++++++++++++++++++ apps/sim/lib/copilot/prompts.ts | 129 +++++++------- apps/sim/lib/workflows/diff/diff-engine.ts | 15 ++ apps/sim/lib/workflows/yaml-converter.ts | 37 +++- 5 files changed, 304 insertions(+), 65 deletions(-) diff --git a/apps/sim/app/api/tools/get-all-blocks/route.ts b/apps/sim/app/api/tools/get-all-blocks/route.ts index 6cf96bf1561..2e7b8b22bf5 100644 --- a/apps/sim/app/api/tools/get-all-blocks/route.ts +++ b/apps/sim/app/api/tools/get-all-blocks/route.ts @@ -31,7 +31,29 @@ export async function POST(request: NextRequest) { blockToToolsMapping[blockType] = blockTools }) - const totalBlocks = Object.keys(blockRegistry).length + // Add special blocks that aren't in the standard registry + // Loop and parallel blocks are handled differently but should be available + const specialBlocks = { + loop: { + tools: [], // Loop blocks don't use standard tools + category: 'blocks', + description: 'Control flow block for iterating over collections or repeating actions', + }, + parallel: { + tools: [], // Parallel blocks don't use standard tools + category: 'blocks', + description: 'Control flow block for executing multiple branches simultaneously', + } + } + + // Add special blocks if they pass the category filter + Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { + if (!filterCategory || blockInfo.category === filterCategory) { + blockToToolsMapping[blockType] = blockInfo.tools + } + }) + + const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length const includedBlocks = Object.keys(blockToToolsMapping).length const filteredBlocksCount = totalBlocks - includedBlocks @@ -47,6 +69,9 @@ export async function POST(request: NextRequest) { filterCategory, blockToolsMapping: blockToolsInfo, outputMapping: blockToToolsMapping, + specialBlocksAdded: Object.keys(specialBlocks).filter(blockType => + !filterCategory || specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory + ), }) return NextResponse.json({ diff --git a/apps/sim/app/api/tools/get-blocks-metadata/route.ts b/apps/sim/app/api/tools/get-blocks-metadata/route.ts index aa8bffa126a..33e6350dd48 100644 --- a/apps/sim/app/api/tools/get-blocks-metadata/route.ts +++ b/apps/sim/app/api/tools/get-blocks-metadata/route.ts @@ -26,6 +26,108 @@ const DOCS_FILE_MAPPING: Record = { webhook: 'webhook_trigger', } +// Special blocks that aren't in the standard registry but need metadata +const SPECIAL_BLOCKS_METADATA: Record = { + loop: { + type: 'loop', + name: 'Loop', + description: 'Control flow block for iterating over collections or repeating actions', + longDescription: 'Execute a set of blocks repeatedly, either for a fixed number of iterations or for each item in a collection. Loop blocks create sub-workflows that run multiple times with different iteration data.', + category: 'blocks', + bgColor: '#9333EA', + subBlocks: [ + { + id: 'iterationType', + title: 'Iteration Type', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Fixed Count', id: 'fixed' }, + { label: 'For Each Item', id: 'forEach' }, + ], + description: 'Choose how the loop should iterate', + }, + { + id: 'iterationCount', + title: 'Iteration Count', + type: 'short-input', + layout: 'half', + placeholder: '5', + condition: { field: 'iterationType', value: 'fixed' }, + description: 'Number of times to repeat the loop', + }, + { + id: 'collection', + title: 'Collection', + type: 'short-input', + layout: 'full', + placeholder: 'Reference to array or object', + condition: { field: 'iterationType', value: 'forEach' }, + description: 'Array or object to iterate over', + }, + ], + inputs: { + iterationType: { type: 'string', required: true }, + iterationCount: { type: 'number', required: false }, + collection: { type: 'array|object', required: false }, + }, + outputs: { + results: 'array', + iterations: 'number', + }, + tools: { access: [] }, + }, + parallel: { + type: 'parallel', + name: 'Parallel', + description: 'Control flow block for executing multiple branches simultaneously', + longDescription: 'Execute multiple sets of blocks simultaneously, either with a fixed number of parallel branches or by distributing items from a collection across parallel executions.', + category: 'blocks', + bgColor: '#059669', + subBlocks: [ + { + id: 'parallelType', + title: 'Parallel Type', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Fixed Count', id: 'count' }, + { label: 'Collection Distribution', id: 'collection' }, + ], + description: 'Choose how parallel execution should work', + }, + { + id: 'parallelCount', + title: 'Parallel Count', + type: 'short-input', + layout: 'half', + placeholder: '3', + condition: { field: 'parallelType', value: 'count' }, + description: 'Number of parallel branches to execute', + }, + { + id: 'collection', + title: 'Collection', + type: 'short-input', + layout: 'full', + placeholder: 'Reference to array to distribute', + condition: { field: 'parallelType', value: 'collection' }, + description: 'Array to distribute across parallel executions', + }, + ], + inputs: { + parallelType: { type: 'string', required: true }, + parallelCount: { type: 'number', required: false }, + collection: { type: 'array', required: false }, + }, + outputs: { + results: 'array', + branches: 'number', + }, + tools: { access: [] }, + }, +} + // Helper function to read YAML schema from dedicated YAML documentation files function getYamlSchemaFromDocs(blockType: string): string | null { try { @@ -80,6 +182,65 @@ export async function POST(request: NextRequest) { for (const blockId of blockIds) { const blockConfig = blockRegistry[blockId] + + // Check if it's a special block not in the standard registry + if (!blockConfig && SPECIAL_BLOCKS_METADATA[blockId]) { + const specialBlock = SPECIAL_BLOCKS_METADATA[blockId] + + // Check if this special block has YAML documentation + if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { + const yamlSchema = getYamlSchemaFromDocs(blockId) + + if (yamlSchema) { + result[blockId] = { + type: 'block', + description: specialBlock.description || '', + longDescription: specialBlock.longDescription, + category: specialBlock.category || '', + yamlSchema: yamlSchema, + docsLink: specialBlock.docsLink, + codeSchemas: { + inputs: specialBlock.inputs, + outputs: specialBlock.outputs, + subBlocks: specialBlock.subBlocks, + }, + } + } else { + // Fallback to regular metadata if YAML schema not found + result[blockId] = { + type: 'block', + description: specialBlock.description || '', + longDescription: specialBlock.longDescription, + category: specialBlock.category || '', + inputs: specialBlock.inputs, + outputs: specialBlock.outputs, + subBlocks: specialBlock.subBlocks, + codeSchemas: { + inputs: specialBlock.inputs, + outputs: specialBlock.outputs, + subBlocks: specialBlock.subBlocks, + }, + } + } + } else { + // For special blocks without YAML docs + result[blockId] = { + type: 'block', + description: specialBlock.description || '', + longDescription: specialBlock.longDescription, + category: specialBlock.category || '', + inputs: specialBlock.inputs, + outputs: specialBlock.outputs, + subBlocks: specialBlock.subBlocks, + codeSchemas: { + inputs: specialBlock.inputs, + outputs: specialBlock.outputs, + subBlocks: specialBlock.subBlocks, + }, + } + } + continue + } if (!blockConfig) { logger.warn(`Block not found: ${blockId}`) diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index 48e38bb7d18..105cd21f80e 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -87,77 +87,80 @@ const WORKFLOW_BUILDING_PROCESS = ` WORKFLOW BUILDING GUIDELINES: When working with workflows, use these tools strategically based on what information you need: -**CRITICAL REQUIREMENT - WORKFLOW EDITING PREREQUISITES:** -You are STRICTLY FORBIDDEN from calling "Edit Workflow" until you have completed ALL four prerequisite tools: +**⚠️ CRITICAL REQUIREMENT - MANDATORY TOOL SEQUENCE FOR WORKFLOW CREATION/EDITING:** -1. **"Get User's Specific Workflow"** - REQUIRED to understand current state -2. **"Get All Blocks and Tools"** - REQUIRED to know available options -3. **"Get Block Metadata"** - REQUIRED for any blocks you plan to use -4. **"Get YAML Workflow Structure Guide"** - REQUIRED for proper syntax +Before ANY workflow creation or editing, you MUST call these tools in this EXACT order: -⚠️ **ENFORCEMENT RULE**: You CANNOT and MUST NOT call "Edit Workflow" without first calling all four tools above. This is non-negotiable and must be followed in every workflow editing scenario. +1. **"Get User's Specific Workflow"** - ALWAYS FIRST (when modifying existing workflows) +2. **"Get All Blocks and Tools"** - ALWAYS SECOND +3. **"Get Block Metadata"** - ALWAYS THIRD (for any blocks you plan to use) +4. **"Get YAML Workflow Structure Guide"** - ALWAYS FOURTH -**TOOL USAGE GUIDELINES:** - -**"Get User's Specific Workflow"** - Use when: -- User mentions "my workflow", "this workflow", or "current workflow" -- Making modifications to existing workflows -- Need to understand current state before suggesting changes -- User asks about what they currently have built -- MANDATORY before any workflow edits - -**"Get All Blocks and Tools"** - Use when: -- Planning new workflows and need to explore available options -- User asks "what blocks should I use for..." -- Need to recommend specific blocks for a task -- Building workflows from scratch -- MANDATORY before any workflow edits - -**"Get Block Metadata"** - Use when: -- Need detailed configuration options for specific blocks -- Understanding input/output schemas -- Configuring block parameters correctly -- The "Get Block Metadata" tool accepts ONLY block IDs, not tool IDs (e.g., "starter", "agent", "gmail") -- MANDATORY before any workflow edits - -**"Get YAML Workflow Structure Guide"** - Use when: -- Need proper YAML syntax and formatting rules -- Building or editing complex workflows -- Ensuring correct workflow structure -- MANDATORY before any workflow edits - -**"Preview Workflow"** - 🎯 ONLY WORKFLOW TOOL: -- This is the ONLY tool for proposing workflow changes -- Shows users a safe preview before making any changes -- STILL REQUIRES all four prerequisite tools (Get User's Workflow, Get All Blocks, Get Block Metadata, Get YAML Structure) -- Gives users the choice to apply changes or save as new workflow -- ⚠️ **CRITICAL**: After calling this tool, you MUST stop your response immediately and wait for the user to accept, reject, or provide feedback -- NO OTHER WORKFLOW EDITING TOOLS ARE AVAILABLE -**FLEXIBLE APPROACH:** -You don't need to call every tool for every request. Use your judgment: +Only AFTER completing ALL prerequisite tools can you call: +5. **"Preview Workflow"** - The ONLY workflow editing tool available -- **Simple questions**: If user asks about a specific block, you might only need "Get Block Metadata" -- **Quick edits**: For minor modifications, you still MUST call all four prerequisite tools before "Edit Workflow" -- **Complex builds**: For new workflows, you'll likely need multiple tools to gather information -- **Exploration**: If user is exploring options, "Get All Blocks and Tools" might be sufficient +**ENFORCEMENT RULES:** +- You CANNOT skip any of these tools when creating or editing workflows +- You CANNOT change the order of these tools +- You CANNOT call "Preview Workflow" until you have completed ALL prerequisite steps +- This sequence is NON-NEGOTIABLE and must be followed in EVERY workflow editing scenario -**COMMON PATTERNS:** - -*New Workflow Creation:* -- Always: Get All Blocks → Get Block Metadata (for chosen blocks) → Get YAML Guide → Preview Workflow - -*Existing Workflow Modification:* -- Always: Get User's Workflow → (optionally Get Block Metadata for new blocks) → Preview Workflow - -*All Workflow Changes:* -- End with Preview Workflow - this shows users the proposed changes and gives them options to apply or save as new workflow -- STOP IMMEDIATELY after calling Preview Workflow - do not continue talking until user responds +**TOOL USAGE GUIDELINES:** -*Information/Analysis:* -- Might only need: Get User's Workflow or Get Block Metadata +**"Get User's Specific Workflow"** - MANDATORY FIRST STEP (for modifications): +- Must be called when modifying existing workflows +- Required to understand current state before making changes +- Use when user mentions "my workflow", "this workflow", or "current workflow" + +**"Get All Blocks and Tools"** - MANDATORY SECOND STEP: +- Must be called before any workflow creation or editing +- Shows all available blocks and their associated tools +- Required to understand what options are available +- Includes both standard blocks AND special blocks like loop and parallel + +**"Get Block Metadata"** - MANDATORY THIRD STEP: +- Must be called after "Get All Blocks and Tools" +- Required for detailed configuration of any blocks you plan to use +- Accepts block IDs (e.g., "starter", "agent", "loop", "parallel") +- Provides input/output schemas and configuration details + +**"Get YAML Workflow Structure Guide"** - MANDATORY FOURTH STEP: +- Must be called after "Get Block Metadata" +- Required for proper YAML syntax and formatting rules +- Essential for building valid workflow structures + +**"Preview Workflow"** - 🎯 ONLY WORKFLOW EDITING TOOL: +- This is the ONLY tool for creating or modifying workflows +- REQUIRES all prerequisite tools to be completed first +- Shows users a safe preview before making any changes +- Gives users the choice to apply changes or save as new workflow +- ⚠️ **CRITICAL**: After calling this tool, you MUST stop your response immediately and wait for the user to accept, reject, or provide feedback -Use the minimum tools necessary to provide accurate, helpful responses while ensuring you have enough information to complete the task successfully.` +**WORKFLOW PATTERNS:** + +*New Workflow Creation (MANDATORY SEQUENCE):* +1. Get All Blocks and Tools +2. Get Block Metadata (for chosen blocks) +3. Get YAML Workflow Structure Guide +4. Preview Workflow + +*Existing Workflow Modification (MANDATORY SEQUENCE):* +1. Get User's Specific Workflow +2. Get All Blocks and Tools +3. Get Block Metadata (for any new/modified blocks) +4. Get YAML Workflow Structure Guide +5. Preview Workflow + +*Information/Analysis Only:* +- May use individual tools like "Get User's Workflow" or "Get Block Metadata" without the full sequence +- Only the full sequence is required for actual workflow creation/editing + +**REMEMBER:** +- The sequence is MANDATORY for ALL workflow creation and editing +- You MUST complete ALL prerequisite tools before calling Preview Workflow +- After Preview Workflow, STOP and wait for user feedback +- This ensures the copilot has complete information before making workflow changes` /** * Ask mode workflow guidance - focused on providing detailed educational guidance diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 15973eacf51..4ba67658fba 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -81,6 +81,21 @@ export class WorkflowDiffEngine { mappedDiffAnalysis = this.createMappedDiffAnalysis(diffAnalysis, conversionResult.idMapping!) } + // Debug: Log blocks with parent relationships + const blocksWithParents = Object.values(proposedState.blocks).filter((block: any) => block.parentNode) + logger.info(`Found ${blocksWithParents.length} blocks with parent relationships`) + blocksWithParents.forEach((block: any) => { + logger.info(`Block ${block.id} has parentNode: ${block.parentNode}`) + }) + + // Debug: Log loop and parallel blocks + const containerBlocks = Object.values(proposedState.blocks).filter( + block => block.type === 'loop' || block.type === 'parallel' + ) + logger.info(`Found ${containerBlocks.length} container blocks (loops/parallels):`, + containerBlocks.map(b => ({ id: b.id, type: b.type, name: b.name })) + ) + // Create the diff object this.currentDiff = { proposedState, diff --git a/apps/sim/lib/workflows/yaml-converter.ts b/apps/sim/lib/workflows/yaml-converter.ts index e232d34eb52..33bde4c0fac 100644 --- a/apps/sim/lib/workflows/yaml-converter.ts +++ b/apps/sim/lib/workflows/yaml-converter.ts @@ -105,6 +105,20 @@ export async function convertYamlToWorkflowState( // Step 4: Build WorkflowState with proper block configuration const workflowBlocks: Record = {} + // First pass: Update all parentIds in imported blocks before creating BlockStates + blocks.forEach(importedBlock => { + if (importedBlock.parentId) { + const mappedParentId = idMapping.get(importedBlock.parentId) + if (mappedParentId) { + logger.info(`Updating parentId for block ${importedBlock.id}: ${importedBlock.parentId} -> ${mappedParentId}`) + importedBlock.parentId = mappedParentId + } else { + logger.warn(`Parent ID ${importedBlock.parentId} not found in ID mapping for block ${importedBlock.id}`) + } + } + }) + + // Second pass: Create the blocks for (const importedBlock of blocks) { const blockId = idMapping.get(importedBlock.id)! @@ -142,6 +156,20 @@ export async function convertYamlToWorkflowState( const loops = generateLoopBlocks(workflowBlocks) const parallels = generateParallelBlocks(workflowBlocks) + // Debug: Log parent-child relationships + logger.info('=== Parent-Child Relationships ===') + Object.values(workflowBlocks).forEach(block => { + const parentNode = (block as any).parentNode + const parentId = block.data?.parentId + if (parentNode || parentId) { + logger.info(`Block ${block.id} (${block.name}):`, { + parentNode, + parentId, + parentExists: parentNode ? !!workflowBlocks[parentNode] : 'N/A' + }) + } + }) + // Step 8: Create final WorkflowState const workflowState: WorkflowState = { blocks: workflowBlocks, @@ -245,7 +273,7 @@ function createRegularBlock( const outputs = resolveOutputType(blockConfig.outputs) - return { + const block: BlockState = { id: blockId, type: importedBlock.type, name: importedBlock.name, @@ -264,6 +292,13 @@ function createRegularBlock( }) } } + + // Add parentNode for ReactFlow if this block is inside a loop/parallel + if (importedBlock.parentId) { + (block as any).parentNode = importedBlock.parentId + } + + return block } /** From 080b5247bc3554ab233a05b6e0653cc9a50cd26a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 14:37:45 -0700 Subject: [PATCH 045/184] Fixes? --- apps/sim/lib/workflows/diff/diff-engine.ts | 168 +++++++++++++++++++-- apps/sim/lib/workflows/yaml-converter.ts | 27 +++- 2 files changed, 183 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 4ba67658fba..27a4c49cef3 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -1,5 +1,5 @@ import { createLogger } from '@/lib/logs/console-logger' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { WorkflowState, BlockState } from '@/stores/workflows/workflow/types' import { convertYamlToWorkflowState, applyAutoLayoutToBlocks } from '@/lib/workflows/yaml-converter' const logger = createLogger('WorkflowDiffEngine') @@ -57,16 +57,12 @@ export class WorkflowDiffEngine { } const proposedState = conversionResult.workflowState - - // Apply auto layout for better visualization - const layoutResult = await applyAutoLayoutToBlocks( - proposedState.blocks, - proposedState.edges - ) - - if (layoutResult.success && layoutResult.layoutedBlocks) { - proposedState.blocks = layoutResult.layoutedBlocks - } + + logger.info('Conversion result:', { + hasProposedState: !!proposedState, + blockCount: proposedState ? Object.keys(proposedState.blocks).length : 0, + edgeCount: proposedState ? proposedState.edges.length : 0 + }) // Add diff markers to blocks if analysis is provided let mappedDiffAnalysis = diffAnalysis @@ -79,6 +75,8 @@ export class WorkflowDiffEngine { this.applyDiffMarkers(proposedState, diffAnalysis, conversionResult.idMapping!) // Create a mapped version of the diff analysis with new IDs mappedDiffAnalysis = this.createMappedDiffAnalysis(diffAnalysis, conversionResult.idMapping!) + } else { + logger.info('No diff analysis provided, skipping diff markers') } // Debug: Log blocks with parent relationships @@ -96,6 +94,76 @@ export class WorkflowDiffEngine { containerBlocks.map(b => ({ id: b.id, type: b.type, name: b.name })) ) + // Ensure all blocks have their id property set + Object.entries(proposedState.blocks).forEach(([blockId, block]) => { + if (!block.id) { + logger.warn(`Block ${blockId} missing id property, setting it now`) + block.id = blockId + } + }) + + // Debug: Check what Object.values returns + const blockValues = Object.values(proposedState.blocks) + logger.info('Object.values(blocks) returns:', { + count: blockValues.length, + blocks: blockValues.map((block, index) => ({ + index, + hasId: !!block.id, + id: block.id, + type: block.type + })) + }) + + // Apply auto layout using the service directly + const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') + + try { + logger.info('Applying auto layout to diff workflow', { + blockCount: Object.keys(proposedState.blocks).length, + edgeCount: proposedState.edges.length, + blocks: Object.keys(proposedState.blocks) + }) + + const layoutedBlocks = await autoLayoutWorkflow( + proposedState.blocks, + proposedState.edges, + {} // Default options + ) + + if (layoutedBlocks) { + // Apply the layouted blocks + proposedState.blocks = layoutedBlocks + + // Ensure all blocks still have their id property after layout + Object.entries(proposedState.blocks).forEach(([blockId, block]) => { + if (!block.id) { + logger.warn(`Block ${blockId} lost its id property after layout, restoring it`) + block.id = blockId + } + }) + + // Re-apply diff markers after layout + if (mappedDiffAnalysis) { + Object.entries(proposedState.blocks).forEach(([blockId, block]) => { + if (mappedDiffAnalysis.new_blocks.includes(blockId)) { + (block as any).is_diff = 'new' + } else if (mappedDiffAnalysis.edited_blocks.includes(blockId)) { + (block as any).is_diff = 'edited' + } else { + (block as any).is_diff = 'unchanged' + } + }) + } + + logger.info('Auto layout applied successfully') + } else { + logger.warn('Auto layout returned no blocks') + } + } catch (error) { + logger.error('Auto layout failed:', error) + logger.info('Continuing without auto-layout') + } + // Create the diff object this.currentDiff = { proposedState, @@ -138,6 +206,84 @@ export class WorkflowDiffEngine { } } + /** + * Adjust child block positions to be relative to their parent containers + */ + private adjustChildBlockPositions(blocks: Record): void { + // Group blocks by their parent + const blocksByParent = new Map() + + Object.values(blocks).forEach(block => { + const parentId = block.data?.parentId || (block as any).parentNode + if (parentId && blocks[parentId]) { + if (!blocksByParent.has(parentId)) { + blocksByParent.set(parentId, []) + } + blocksByParent.get(parentId)!.push(block) + } + }) + + // Adjust positions for each parent's children + blocksByParent.forEach((childBlocks, parentId) => { + const parentBlock = blocks[parentId] + if (!parentBlock) return + + // Get parent position + const parentPos = parentBlock.position + + logger.info(`Adjusting ${childBlocks.length} child blocks for parent ${parentId}`) + + // Track bounds for container sizing + let maxX = 0 + let maxY = 0 + + // Make child positions relative to parent + childBlocks.forEach(childBlock => { + const currentPos = childBlock.position + + // Check if position is already relative (within reasonable bounds of parent container) + const isAlreadyRelative = Math.abs(currentPos.x) < 800 && Math.abs(currentPos.y) < 600 + + if (!isAlreadyRelative) { + // Position seems absolute, convert to relative + const relativePos = { + x: currentPos.x - parentPos.x, + y: currentPos.y - parentPos.y + } + + childBlock.position = relativePos + logger.info(`Adjusted child block ${childBlock.id} position from absolute`, currentPos, 'to relative', relativePos) + } else { + logger.info(`Child block ${childBlock.id} position already relative:`, currentPos) + } + + // Track max bounds for container sizing + const blockWidth = childBlock.isWide ? 450 : 350 + const blockHeight = Math.max(childBlock.height || 100, 100) + maxX = Math.max(maxX, childBlock.position.x + blockWidth) + maxY = Math.max(maxY, childBlock.position.y + blockHeight) + }) + + // Update container dimensions to fit all children + if (parentBlock.type === 'loop' || parentBlock.type === 'parallel') { + const padding = 150 // Extra padding for container + const minWidth = 500 + const minHeight = 300 + + parentBlock.data = { + ...parentBlock.data, + width: Math.max(minWidth, maxX + padding), + height: Math.max(minHeight, maxY + padding) + } + + logger.info(`Updated container ${parentId} dimensions:`, { + width: parentBlock.data.width, + height: parentBlock.data.height + }) + } + }) + } + /** * Apply diff markers to blocks based on analysis */ diff --git a/apps/sim/lib/workflows/yaml-converter.ts b/apps/sim/lib/workflows/yaml-converter.ts index 33bde4c0fac..014efe1e8ff 100644 --- a/apps/sim/lib/workflows/yaml-converter.ts +++ b/apps/sim/lib/workflows/yaml-converter.ts @@ -217,7 +217,7 @@ function createContainerBlock( blockId: string, importedBlock: ImportedBlock ): BlockState { - return { + const block: BlockState = { id: blockId, type: importedBlock.type, name: importedBlock.name, @@ -230,12 +230,23 @@ function createContainerBlock( height: 0, data: { ...importedBlock.data, + // Ensure container has dimensions + width: importedBlock.data?.width || 500, + height: importedBlock.data?.height || 300, + type: importedBlock.type === 'loop' ? 'loopNode' : 'parallelNode', ...(importedBlock.parentId && { parentId: importedBlock.parentId, extent: importedBlock.extent }) } } + + // Add parentNode for ReactFlow if this block is inside another container + if (importedBlock.parentId) { + (block as any).parentNode = importedBlock.parentId + } + + return block } /** @@ -381,18 +392,27 @@ export async function applyAutoLayoutToBlocks( layoutedBlocks?: Record error?: string }> { + logger.info('=== applyAutoLayoutToBlocks called ===', { + blockCount: Object.keys(blocks).length, + edgeCount: edges.length + }) + try { // Try to import from the actual auto-layout location + logger.info('Attempting to import auto-layout module...') const autoLayoutModule = await import('@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout') if (autoLayoutModule.applyAutoLayoutToBlocks) { + logger.info('Using auto-layout module function') // Use the existing auto-layout function return await autoLayoutModule.applyAutoLayoutToBlocks(blocks, edges) } // Fallback to autolayout service + logger.info('Falling back to autolayout service') const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') + logger.info('Calling autoLayoutWorkflow with options') const layoutedBlocks = await autoLayoutWorkflow( blocks, edges, @@ -412,6 +432,11 @@ export async function applyAutoLayoutToBlocks( } ) + logger.info('autoLayoutWorkflow returned:', { + hasLayoutedBlocks: !!layoutedBlocks, + layoutedBlockCount: layoutedBlocks ? Object.keys(layoutedBlocks).length : 0 + }) + return { success: true, layoutedBlocks From 3ab8a4d7ea317fafcc313966b7947ce04b697d83 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 14:43:38 -0700 Subject: [PATCH 046/184] Fixes? --- .../[workflowId]/components/workflow-block/workflow-block.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8474f6e5aa5..dca27a076c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -332,6 +332,9 @@ export function WorkflowBlock({ id, data }: NodeProps) { if (data.isPreview && data.subBlockValues) { // In preview mode, use the preview values stateToUse = data.subBlockValues + } else if (currentWorkflow.isDiffMode && currentBlock) { + // In diff mode, use the diff workflow's subblock values + stateToUse = currentBlock.subBlocks || {} } else { // In normal mode, use merged state const blocks = useWorkflowStore.getState().blocks From e753d7583eebdef96ae3870a1cd687b8eb5bba6a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 15:02:33 -0700 Subject: [PATCH 047/184] Chat fixes --- .../hooks/use-workflow-execution.ts | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 1ef84e70ff8..1f5687d40ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -417,28 +417,44 @@ export function useWorkflowExecution() { onStream?: (se: StreamingExecution) => Promise, executionId?: string ): Promise => { - // Use the current workflow abstraction (handles diff vs normal automatically) + // Use currentWorkflow but check if we're in diff mode + const { blocks: workflowBlocks, edges: workflowEdges, loops: workflowLoops, parallels: workflowParallels } = currentWorkflow + const isExecutingFromChat = workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput - const { blocks: workflowBlocks, edges: workflowEdges, loops: workflowLoops, parallels: workflowParallels, isDiffMode } = currentWorkflow logger.info('Executing workflow', { - mode: isDiffMode ? 'diff' : 'main', + isDiffMode: currentWorkflow.isDiffMode, isExecutingFromChat, - isDiffMode, blocksCount: Object.keys(workflowBlocks).length, edgesCount: workflowEdges.length }) - // Use the mergeSubblockState utility to get all block states - // In diff mode, subblock values are already in the workflow blocks - // In normal mode, we need to merge from the subblock store - const mergedStates = isDiffMode - ? workflowBlocks // Diff blocks already have embedded subblock values - : mergeSubblockState(workflowBlocks) + // Debug: Check for blocks with undefined types before merging + Object.entries(workflowBlocks).forEach(([blockId, block]) => { + if (!block || !block.type) { + logger.error('Found block with undefined type before merging:', { blockId, block }) + } + }) + + // Merge subblock states from the appropriate store + const mergedStates = mergeSubblockState(workflowBlocks) + + // Debug: Check for blocks with undefined types after merging + Object.entries(mergedStates).forEach(([blockId, block]) => { + if (!block || !block.type) { + logger.error('Found block with undefined type after merging:', { blockId, block }) + } + }) // Filter out trigger blocks for manual execution const filteredStates = Object.entries(mergedStates).reduce( (acc, [id, block]) => { + // Skip blocks with undefined type + if (!block || !block.type) { + logger.warn(`Skipping block with undefined type: ${id}`, block) + return acc + } + const blockConfig = getBlock(block.type) const isTriggerBlock = blockConfig?.category === 'triggers' From 56c1f46b0898f478df28d7582b476f0f62c03c65 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 15:07:46 -0700 Subject: [PATCH 048/184] Autolayout fixes --- apps/sim/lib/autolayout/algorithms/hierarchical.ts | 6 +++--- apps/sim/lib/autolayout/algorithms/smart.ts | 2 +- apps/sim/lib/autolayout/service.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/autolayout/algorithms/hierarchical.ts b/apps/sim/lib/autolayout/algorithms/hierarchical.ts index 8d66a852f1b..c3bd9e4e787 100644 --- a/apps/sim/lib/autolayout/algorithms/hierarchical.ts +++ b/apps/sim/lib/autolayout/algorithms/hierarchical.ts @@ -319,7 +319,7 @@ function calculatePositions( // Improved vertical spacing calculation to prevent overlaps // Use a minimum spacing that accounts for block heights plus extra buffer const minVerticalSpacing = Math.max(spacing.vertical, 100) - const adaptiveSpacing = layer.length > 1 ? Math.max(minVerticalSpacing, spacing.vertical * 1.5) : minVerticalSpacing + const adaptiveSpacing = layer.length > 1 ? Math.max(minVerticalSpacing, spacing.vertical * 1.2) : minVerticalSpacing // Calculate total layer height with improved spacing const totalHeight = @@ -350,7 +350,7 @@ function calculatePositions( // Use adaptive spacing that considers the current node's height if (nodeIndex < layer.length - 1) { const nextNode = layer[nodeIndex + 1] - const dynamicSpacing = Math.max(adaptiveSpacing, (node.height + nextNode.height) / 2 + 50) + const dynamicSpacing = Math.max(adaptiveSpacing, (node.height + nextNode.height) / 2 + 30) currentY += node.height + dynamicSpacing } }) @@ -398,7 +398,7 @@ function calculatePositions( // Use adaptive spacing that considers the current node's width if (nodeIndex < layer.length - 1) { const nextNode = layer[nodeIndex + 1] - const dynamicSpacing = Math.max(adaptiveSpacing, (node.width + nextNode.width) / 2 + 40) + const dynamicSpacing = Math.max(adaptiveSpacing, (node.width + nextNode.width) / 2 + 25) currentX += node.width + dynamicSpacing } }) diff --git a/apps/sim/lib/autolayout/algorithms/smart.ts b/apps/sim/lib/autolayout/algorithms/smart.ts index f2f3b78000c..b09c8a9b897 100644 --- a/apps/sim/lib/autolayout/algorithms/smart.ts +++ b/apps/sim/lib/autolayout/algorithms/smart.ts @@ -400,7 +400,7 @@ function calculateLayeredLayout( ...options, spacing: { horizontal: hasSignificantBranching ? options.spacing.horizontal * 1.2 : options.spacing.horizontal, - vertical: hasSignificantBranching ? Math.max(options.spacing.vertical * 2.5, 500) : options.spacing.vertical * 1.5, + vertical: hasSignificantBranching ? Math.max(options.spacing.vertical * 1.8, 350) : options.spacing.vertical * 1.2, layer: options.spacing.layer * 1.1, }, } diff --git a/apps/sim/lib/autolayout/service.ts b/apps/sim/lib/autolayout/service.ts index 9c9a484b94a..7aaa9e64a92 100644 --- a/apps/sim/lib/autolayout/service.ts +++ b/apps/sim/lib/autolayout/service.ts @@ -35,7 +35,7 @@ export class AutoLayoutService { direction: 'auto', spacing: { horizontal: 500, // Increased from 400 for better separation - vertical: 400, // Significantly increased from 200 to prevent overlaps + vertical: 180, // Reduced from 400 to prevent excessive vertical spacing layer: 700, // Increased from 600 for better layer separation }, alignment: 'center', @@ -256,7 +256,7 @@ export class AutoLayoutService { direction: 'auto', spacing: { horizontal: 400, // Increased from 300 for better child separation - vertical: 250, // Significantly increased from 150 to prevent child overlaps + vertical: 120, // Reduced from 250 to prevent excessive vertical spacing in containers layer: 500, // Increased from 400 for better child layer separation }, alignment: 'center', From 63bb3b1f2d0bd2bb613b4088eac32ce85403c1ba Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 15:18:04 -0700 Subject: [PATCH 049/184] Subblock update diffs --- apps/sim/app/api/workflows/diff/route.ts | 84 +++++++++++++++++-- .../components/sub-block/sub-block.tsx | 21 ++++- .../workflow-block/workflow-block.tsx | 18 +++- apps/sim/lib/workflows/diff/diff-engine.ts | 31 ++++++- 4 files changed, 139 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index 461eef315a0..29a1736398b 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -14,16 +14,62 @@ const YamlDiffRequestSchema = z.object({ type YamlDiffRequest = z.infer +interface DiffResult { + deleted_blocks: string[] + edited_blocks: string[] + new_blocks: string[] + field_diffs?: Record +} + interface BlockHash { blockId: string name: string hash: string + inputs?: Record } -interface DiffResult { - deleted_blocks: string[] - edited_blocks: string[] - new_blocks: string[] +/** + * Compare two block inputs to find which fields changed + */ +function compareBlockInputs( + originalInputs: Record, + agentInputs: Record +): { changed_fields: string[], unchanged_fields: string[] } { + const changed_fields: string[] = [] + const unchanged_fields: string[] = [] + + // Get all unique field names from both blocks + const allFields = new Set([ + ...Object.keys(originalInputs || {}), + ...Object.keys(agentInputs || {}) + ]) + + for (const field of allFields) { + const originalValue = originalInputs?.[field] + const agentValue = agentInputs?.[field] + + // Normalize values for comparison (handle null/undefined/empty string equivalence) + const normalizeValue = (value: any) => { + if (value === null || value === undefined || value === '') { + return null + } + if (typeof value === 'object') { + return JSON.stringify(value) + } + return String(value).trim() + } + + const normalizedOriginal = normalizeValue(originalValue) + const normalizedAgent = normalizeValue(agentValue) + + if (normalizedOriginal !== normalizedAgent) { + changed_fields.push(field) + } else { + unchanged_fields.push(field) + } + } + + return { changed_fields, unchanged_fields } } /** @@ -88,12 +134,12 @@ function hashBlockContents(block: any): string { } const sortedContent = sortObjectKeys(cleanedContent) - const contentString = JSON.stringify(sortedContent) - console.log(`Final hash string for ${block.name}:`, contentString) - // Generate SHA-256 hash - const hash = crypto.createHash('sha256').update(contentString).digest('hex') - console.log(`Generated hash for ${block.name}:`, hash.substring(0, 8)) + // Hash the content + const hash = crypto.createHash('sha256').update(JSON.stringify(sortedContent)).digest('hex') + + console.log(`Generated hash for ${block.name}: ${hash.substring(0, 8)}...`) + return hash } @@ -117,6 +163,7 @@ function extractBlockHashes(yamlWorkflow: any): BlockHash[] { blockId, name: block.name || '', hash, + inputs: block.inputs || {} }) }) @@ -188,12 +235,17 @@ export async function POST(request: NextRequest) { // Create name-to-blockId mappings const originalNameToId = new Map(originalHashes.map(b => [b.name, b.blockId])) const agentNameToId = new Map(agentHashes.map(b => [b.name, b.blockId])) + + // Create name-to-block mappings for field comparison + const originalNameToBlock = new Map(originalHashes.map(b => [b.name, b])) + const agentNameToBlock = new Map(agentHashes.map(b => [b.name, b])) // Analyze differences const result: DiffResult = { deleted_blocks: [], edited_blocks: [], new_blocks: [], + field_diffs: {} } // Find deleted blocks: blocks in original that don't exist in agent (by name AND hash) @@ -225,6 +277,18 @@ export async function POST(request: NextRequest) { // Same name but different hash = edited block logger.info(`[${requestId}] Found edited block: ${agentBlock.name}`) result.edited_blocks.push(agentBlock.blockId) + + // Calculate field-level differences for this edited block + const originalBlock = originalNameToBlock.get(agentBlock.name) + if (originalBlock) { + const fieldDiff = compareBlockInputs(originalBlock.inputs || {}, agentBlock.inputs || {}) + result.field_diffs![agentBlock.blockId] = fieldDiff + + logger.info(`[${requestId}] Field diff for ${agentBlock.name}:`, { + changed_fields: fieldDiff.changed_fields, + unchanged_fields: fieldDiff.unchanged_fields.length + }) + } } // If same name and same hash, it's unchanged (no action needed) } else if (!hashExistsInOriginal) { @@ -241,8 +305,10 @@ export async function POST(request: NextRequest) { deletedCount: result.deleted_blocks.length, editedCount: result.edited_blocks.length, newCount: result.new_blocks.length, + fieldDiffsCount: Object.keys(result.field_diffs || {}).length, originalBlocks: originalHashes.map(h => `${h.name}:${h.hash.substring(0, 8)}`), agentBlocks: agentHashes.map(h => `${h.name}:${h.hash.substring(0, 8)}`), + fieldDiffs: result.field_diffs }) return NextResponse.json({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx index ef64bc70533..6322c1a7d96 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react' +import React, { useState, useEffect } from 'react' import { AlertTriangle, Info } from 'lucide-react' import { Label } from '@/components/ui/label' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import { getBlock } from '@/blocks/index' import type { SubBlockConfig } from '@/blocks/types' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -39,6 +40,7 @@ interface SubBlockProps { isPreview?: boolean subBlockValues?: Record disabled?: boolean + fieldDiffStatus?: 'changed' | 'unchanged' } export function SubBlock({ @@ -48,8 +50,16 @@ export function SubBlock({ isPreview = false, subBlockValues, disabled = false, + fieldDiffStatus, }: SubBlockProps) { const [isValidJson, setIsValidJson] = useState(true) + + // Debug field diff status + useEffect(() => { + if (fieldDiffStatus) { + console.log(`[SubBlock ${config.id}] fieldDiffStatus:`, fieldDiffStatus) + } + }, [fieldDiffStatus, config.id]) const handleMouseDown = (e: React.MouseEvent) => { e.stopPropagation() @@ -405,7 +415,14 @@ export function SubBlock({ const required = isFieldRequired() return ( -
    +
    {config.type !== 'switch' && (
    ))} diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 27a4c49cef3..412985864da 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -13,6 +13,7 @@ export interface DiffAnalysis { new_blocks: string[] edited_blocks: string[] deleted_blocks: string[] + field_diffs?: Record } export interface WorkflowDiff { @@ -149,6 +150,11 @@ export class WorkflowDiffEngine { (block as any).is_diff = 'new' } else if (mappedDiffAnalysis.edited_blocks.includes(blockId)) { (block as any).is_diff = 'edited' + + // Re-apply field-level diff information if available + if (mappedDiffAnalysis.field_diffs && mappedDiffAnalysis.field_diffs[blockId]) { + (block as any).field_diff = mappedDiffAnalysis.field_diffs[blockId] + } } else { (block as any).is_diff = 'unchanged' } @@ -199,11 +205,22 @@ export class WorkflowDiffEngine { analysis: DiffAnalysis, idMapping: Map ): DiffAnalysis { - return { + const mapped: DiffAnalysis = { new_blocks: analysis.new_blocks.map(oldId => idMapping.get(oldId) || oldId), edited_blocks: analysis.edited_blocks.map(oldId => idMapping.get(oldId) || oldId), deleted_blocks: analysis.deleted_blocks // Deleted blocks won't have new IDs } + + // Map field diffs with new IDs + if (analysis.field_diffs) { + mapped.field_diffs = {} + Object.entries(analysis.field_diffs).forEach(([oldId, fieldDiff]) => { + const newId = idMapping.get(oldId) || oldId + mapped.field_diffs![newId] = fieldDiff + }) + } + + return mapped } /** @@ -308,7 +325,17 @@ export class WorkflowDiffEngine { logger.info(`Block ${blockId} (original: ${originalId}) marked as new`) } else if (analysis.edited_blocks.includes(originalId)) { (block as any).is_diff = 'edited' - logger.info(`Block ${blockId} (original: ${originalId}) marked as edited`) + + // Add field-level diff information if available + if (analysis.field_diffs && analysis.field_diffs[originalId]) { + (block as any).field_diff = analysis.field_diffs[originalId] + logger.info(`Block ${blockId} (original: ${originalId}) marked as edited with field diff:`, { + changed_fields: analysis.field_diffs[originalId].changed_fields, + unchanged_fields: analysis.field_diffs[originalId].unchanged_fields.length + }) + } else { + logger.info(`Block ${blockId} (original: ${originalId}) marked as edited`) + } } else { (block as any).is_diff = 'unchanged' } From 7e403a650ecb8df72cb56550730463bcab9afc91 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 15:27:28 -0700 Subject: [PATCH 050/184] Handle delete diffs --- .../workflow-block/workflow-block.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 5fa337921ef..462f2a6e5a2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -16,6 +16,7 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' @@ -80,7 +81,12 @@ export function WorkflowBlock({ id, data }: NodeProps) { const fieldDiff = currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diff : undefined - // Debug: Log when in diff mode + // Check if this block is marked for deletion (in original workflow, not diff) + const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) + const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) + const isDeletedBlock = !isShowingDiff && diffAnalysis?.deleted_blocks?.includes(id) + + // Debug: Log when in diff mode or when blocks are marked for deletion useEffect(() => { if (currentWorkflow.isDiffMode) { console.log(`[WorkflowBlock ${id}] Diff mode active, block exists: ${!!currentBlock}, diff status: ${diffStatus}`) @@ -88,7 +94,17 @@ export function WorkflowBlock({ id, data }: NodeProps) { console.log(`[WorkflowBlock ${id}] Field diff:`, fieldDiff) } } - }, [currentWorkflow.isDiffMode, currentBlock, diffStatus, fieldDiff || null, id]) + if (diffAnalysis && !isShowingDiff) { + console.log(`[WorkflowBlock ${id}] Diff analysis available in original workflow:`, { + deleted_blocks: diffAnalysis.deleted_blocks, + isDeletedBlock, + isShowingDiff + }) + } + if (isDeletedBlock) { + console.log(`[WorkflowBlock ${id}] Block marked for deletion in original workflow`) + } + }, [currentWorkflow.isDiffMode, currentBlock, diffStatus, fieldDiff || null, isDeletedBlock, diffAnalysis, isShowingDiff, id]) const horizontalHandles = data.isPreview ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency @@ -490,6 +506,8 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Diff highlighting diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', + // Deleted block highlighting (in original workflow) + isDeletedBlock && 'ring-2 ring-red-500 bg-red-50/50 dark:bg-red-900/10', 'z-[20]' )} > From e6f84748676d627a2caea046e0880fb21bcb7bc9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 16:04:03 -0700 Subject: [PATCH 051/184] Edge diffs v1 --- apps/sim/app/api/workflows/diff/route.ts | 197 ++++++++++++++++++ .../workflow-edge/workflow-edge.tsx | 122 ++++++++++- apps/sim/lib/workflows/diff/diff-engine.ts | 20 +- 3 files changed, 330 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index 29a1736398b..1c8415be1c1 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -14,11 +14,18 @@ const YamlDiffRequestSchema = z.object({ type YamlDiffRequest = z.infer +interface EdgeDiff { + new_edges: string[] + deleted_edges: string[] + unchanged_edges: string[] +} + interface DiffResult { deleted_blocks: string[] edited_blocks: string[] new_blocks: string[] field_diffs?: Record + edge_diff?: EdgeDiff } interface BlockHash { @@ -28,6 +35,172 @@ interface BlockHash { inputs?: Record } +interface EdgeIdentity { + id: string + source: string + target: string + sourceHandle?: string + targetHandle?: string +} + +/** + * Generate a unique identifier for an edge based on block names (not IDs) + * Must match the frontend logic which defaults sourceHandle to 'success' + */ +function generateEdgeIdentity(sourceName: string, targetName: string, sourceHandle?: string, targetHandle?: string): string { + // Match frontend logic: use 'success' as default when sourceHandle is undefined/null + const effectiveSourceHandle = sourceHandle || 'success' + return `${sourceName}:${effectiveSourceHandle}->${targetName}${targetHandle ? `:${targetHandle}` : ''}` +} + +/** + * Extract edges from YAML workflow connections using block names + */ +function extractEdges(yamlWorkflow: any): EdgeIdentity[] { + const edges: EdgeIdentity[] = [] + + if (!yamlWorkflow.blocks || typeof yamlWorkflow.blocks !== 'object') { + return edges + } + + // Create mapping from block ID to block name + const blockIdToName = new Map() + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { + if (block && typeof block === 'object' && block.name) { + blockIdToName.set(blockId, block.name) + } + }) + + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { + if (!block || typeof block !== 'object' || !block.connections) { + return + } + + const sourceName = blockIdToName.get(blockId) + if (!sourceName) return + + const connections = block.connections + + // Handle 'default' connections (simple format) + if (connections.default) { + const targets = Array.isArray(connections.default) ? connections.default : [connections.default] + targets.forEach((targetId: string) => { + const targetName = blockIdToName.get(targetId) + if (!targetName) return + + const edgeId = generateEdgeIdentity(sourceName, targetName) + edges.push({ + id: edgeId, + source: sourceName, + target: targetName, + }) + }) + } + + // Handle named output connections + Object.entries(connections).forEach(([outputName, targets]) => { + if (outputName === 'default') return // Already handled + + const targetList = Array.isArray(targets) ? targets : [targets] + targetList.forEach((target: any) => { + if (typeof target === 'string') { + const targetName = blockIdToName.get(target) + if (!targetName) return + + const edgeId = generateEdgeIdentity(sourceName, targetName, outputName) + edges.push({ + id: edgeId, + source: sourceName, + target: targetName, + sourceHandle: outputName, + }) + } else if (typeof target === 'object' && target.block) { + const targetName = blockIdToName.get(target.block) + if (!targetName) return + + const edgeId = generateEdgeIdentity(sourceName, targetName, outputName, target.input) + edges.push({ + id: edgeId, + source: sourceName, + target: targetName, + sourceHandle: outputName, + targetHandle: target.input, + }) + } + }) + }) + }) + + return edges +} + +/** + * Compare edges between two workflows to find differences + */ +function compareEdges( + originalEdges: EdgeIdentity[], + agentEdges: EdgeIdentity[], + blockNameToHash: { originalNameToHash: Map, agentNameToHash: Map }, + blockDiff: { new_blocks: string[], deleted_blocks: string[], edited_blocks: string[] } +): EdgeDiff { + const result: EdgeDiff = { + new_edges: [], + deleted_edges: [], + unchanged_edges: [], + } + + // Create edge ID sets for comparison + const originalEdgeIds = new Set(originalEdges.map(e => e.id)) + const agentEdgeIds = new Set(agentEdges.map(e => e.id)) + + // Get block names that are new or deleted + const newBlockNames = new Set() + const deletedBlockNames = new Set() + + // Map block IDs to names for new/deleted blocks + Array.from(blockNameToHash.originalNameToHash.entries()).forEach(([name, _]) => { + const nameExistsInAgent = blockNameToHash.agentNameToHash.has(name) + if (!nameExistsInAgent) { + deletedBlockNames.add(name) + } + }) + + Array.from(blockNameToHash.agentNameToHash.entries()).forEach(([name, _]) => { + const nameExistsInOriginal = blockNameToHash.originalNameToHash.has(name) + if (!nameExistsInOriginal) { + newBlockNames.add(name) + } + }) + + // Find deleted edges (in original but not in agent) + originalEdges.forEach(edge => { + // An edge is deleted if: + // 1. The edge doesn't exist in the agent workflow (was removed), OR + // 2. Either its source or target block was deleted + const edgeRemoved = !agentEdgeIds.has(edge.id) + const sourceDeleted = deletedBlockNames.has(edge.source) + const targetDeleted = deletedBlockNames.has(edge.target) + + if (edgeRemoved || sourceDeleted || targetDeleted) { + result.deleted_edges.push(edge.id) + } + }) + + // Find new and unchanged edges in agent workflow + agentEdges.forEach(edge => { + const isNewEdge = !originalEdgeIds.has(edge.id) + const connectsToNewBlock = newBlockNames.has(edge.source) || newBlockNames.has(edge.target) + + if (isNewEdge || connectsToNewBlock) { + result.new_edges.push(edge.id) + } else { + result.unchanged_edges.push(edge.id) + } + }) + + return result +} + /** * Compare two block inputs to find which fields changed */ @@ -299,6 +472,30 @@ export async function POST(request: NextRequest) { // If name doesn't exist but hash exists, it's a renamed block (treat as unchanged) } + // Extract and compare edges + const originalEdges = extractEdges(originalWorkflow) + const agentEdges = extractEdges(agentWorkflow) + + logger.info(`[${requestId}] Extracted edges`, { + originalEdgeCount: originalEdges.length, + agentEdgeCount: agentEdges.length, + }) + + // Compare edges + const edgeDiff = compareEdges( + originalEdges, + agentEdges, + { originalNameToHash, agentNameToHash }, + result + ) + result.edge_diff = edgeDiff + + logger.info(`[${requestId}] Edge diff analysis`, { + newEdges: edgeDiff.new_edges.length, + deletedEdges: edgeDiff.deleted_edges.length, + unchangedEdges: edgeDiff.unchanged_edges.length, + }) + const elapsed = Date.now() - startTime logger.info(`[${requestId}] YAML diff completed in ${elapsed}ms`, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 0b87808d062..a2c33509869 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -1,5 +1,13 @@ +import { useEffect } from 'react' import { X } from 'lucide-react' import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow' +import { useWorkflowDiffStore } from '@/stores/workflow-diff' +import { useCurrentWorkflow } from '../../hooks' + +interface WorkflowEdgeProps extends EdgeProps { + sourceHandle?: string | null + targetHandle?: string | null +} export const WorkflowEdge = ({ id, @@ -11,7 +19,11 @@ export const WorkflowEdge = ({ targetPosition, data, style, -}: EdgeProps) => { + source, + target, + sourceHandle, + targetHandle, +}: WorkflowEdgeProps) => { const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' const [edgePath, labelX, labelY] = getSmoothStepPath({ @@ -29,15 +41,108 @@ export const WorkflowEdge = ({ const isSelected = data?.isSelected ?? false const isInsideLoop = data?.isInsideLoop ?? false const parentLoopId = data?.parentLoopId + + // Get edge diff status + const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) + const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) + const currentWorkflow = useCurrentWorkflow() + + // Generate edge identifier using block names (not IDs) to match diff analysis + // This must exactly match the logic in /api/workflows/diff route + const generateEdgeIdentity = (sourceName: string, targetName: string, sourceHandle?: string | null, targetHandle?: string | null): string => { + // The API route uses "success" as the default handle when sourceHandle is null/undefined + // We need to match this logic exactly + const effectiveSourceHandle = sourceHandle || 'success' + return `${sourceName}:${effectiveSourceHandle}->${targetName}${targetHandle ? `:${targetHandle}` : ''}` + } + + // Get block names from workflow - handle both diff and normal modes + const sourceBlock = currentWorkflow.getBlockById(source) + const targetBlock = currentWorkflow.getBlockById(target) + const sourceName = sourceBlock?.name + const targetName = targetBlock?.name + + // Generate edge identifier using the exact same logic as the API route + const edgeIdentifier = sourceName && targetName ? + generateEdgeIdentity(sourceName, targetName, sourceHandle, targetHandle) : + null + + // Debug logging to understand what's happening + useEffect(() => { + if (edgeIdentifier && diffAnalysis?.edge_diff) { + console.log(`[Edge Debug] Edge ${id}:`, { + edgeIdentifier, + sourceName, + targetName, + sourceHandle, + targetHandle, + sourceBlockId: source, + targetBlockId: target, + isShowingDiff, + isDiffMode: currentWorkflow.isDiffMode, + edgeDiffAnalysis: diffAnalysis.edge_diff, + // Show actual array contents to see why matching fails + newEdgesArray: diffAnalysis.edge_diff.new_edges, + deletedEdgesArray: diffAnalysis.edge_diff.deleted_edges, + unchangedEdgesArray: diffAnalysis.edge_diff.unchanged_edges, + // Check if this edge matches any in the diff analysis + matchesNew: diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier), + matchesDeleted: diffAnalysis.edge_diff.deleted_edges.includes(edgeIdentifier), + matchesUnchanged: diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier), + }) + } + }, [edgeIdentifier, diffAnalysis, isShowingDiff, id, sourceName, targetName, sourceHandle, targetHandle, source, target, currentWorkflow.isDiffMode]) + + // One-time debug log of full diff analysis + useEffect(() => { + if (diffAnalysis && id === Object.keys(currentWorkflow.blocks)[0]) { // Only log once per diff + console.log('[Full Diff Analysis]:', { + edge_diff: diffAnalysis.edge_diff, + new_blocks: diffAnalysis.new_blocks, + edited_blocks: diffAnalysis.edited_blocks, + deleted_blocks: diffAnalysis.deleted_blocks, + isShowingDiff, + currentWorkflowEdgeCount: currentWorkflow.edges.length, + currentWorkflowBlockCount: Object.keys(currentWorkflow.blocks).length + }) + } + }, [diffAnalysis, id, currentWorkflow.blocks, currentWorkflow.edges, isShowingDiff]) + + // Determine edge diff status + let edgeDiffStatus: 'new' | 'deleted' | 'unchanged' | undefined = undefined + + if (diffAnalysis?.edge_diff && edgeIdentifier) { + if (isShowingDiff) { + // In diff view, show new edges + if (diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier)) { + edgeDiffStatus = 'new' + } else if (diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier)) { + edgeDiffStatus = 'unchanged' + } + } else { + // In original workflow, show deleted edges + if (diffAnalysis.edge_diff.deleted_edges.includes(edgeIdentifier)) { + edgeDiffStatus = 'deleted' + } + } + } - // Merge any style props passed from parent + // Merge any style props passed from parent with diff highlighting + const getEdgeColor = () => { + if (edgeDiffStatus === 'new') return '#22c55e' // Green for new edges + if (edgeDiffStatus === 'deleted') return '#ef4444' // Red for deleted edges + if (isSelected) return '#475569' + return '#94a3b8' + } + const edgeStyle = { - strokeWidth: isSelected ? 2.5 : 2, - stroke: isSelected ? '#475569' : '#94a3b8', - strokeDasharray: '5,5', + strokeWidth: edgeDiffStatus ? 3 : (isSelected ? 2.5 : 2), + stroke: getEdgeColor(), + strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : '5,5', // Longer dashes for deleted + opacity: edgeDiffStatus === 'deleted' ? 0.7 : 1, ...style, } - + return ( <> + {/* Animate dash offset for edge movement effect */} diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 412985864da..fda0fc10f7b 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -9,11 +9,18 @@ export interface DiffMetadata { timestamp: number } +export interface EdgeDiff { + new_edges: string[] + deleted_edges: string[] + unchanged_edges: string[] +} + export interface DiffAnalysis { new_blocks: string[] edited_blocks: string[] deleted_blocks: string[] field_diffs?: Record + edge_diff?: EdgeDiff } export interface WorkflowDiff { @@ -71,7 +78,8 @@ export class WorkflowDiffEngine { logger.info('Applying diff markers with analysis:', { new_blocks: diffAnalysis.new_blocks, edited_blocks: diffAnalysis.edited_blocks, - deleted_blocks: diffAnalysis.deleted_blocks + deleted_blocks: diffAnalysis.deleted_blocks, + edge_diff: diffAnalysis.edge_diff }) this.applyDiffMarkers(proposedState, diffAnalysis, conversionResult.idMapping!) // Create a mapped version of the diff analysis with new IDs @@ -220,6 +228,16 @@ export class WorkflowDiffEngine { }) } + // Edge identifiers use block names (not IDs), so they don't need mapping + // They should remain as-is since block names are stable between workflows + if (analysis.edge_diff) { + mapped.edge_diff = { + new_edges: analysis.edge_diff.new_edges, // Keep original - uses block names + deleted_edges: analysis.edge_diff.deleted_edges, // Keep original - uses block names + unchanged_edges: analysis.edge_diff.unchanged_edges // Keep original - uses block names + } + } + return mapped } From 0046be12ab2fa5f777df6789310551dc022f78b2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 16:07:26 -0700 Subject: [PATCH 052/184] Deletion edge diff --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5a33c962c49..fa6fc9f7a7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -9,6 +9,7 @@ import ReactFlow, { type NodeTypes, ReactFlowProvider, useReactFlow, + type Edge, } from 'reactflow' import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console-logger' @@ -28,7 +29,7 @@ import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' import { @@ -101,6 +102,58 @@ const WorkflowContent = React.memo(() => { // Extract workflow data from the abstraction const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow + // Get diff analysis for edge reconstruction + const { diffAnalysis, isShowingDiff } = useWorkflowDiffStore() + + // Reconstruct deleted edges when viewing original workflow + const edgesForDisplay = useMemo(() => { + // If we're not in diff mode and we have diff analysis with deleted edges, + // we need to reconstruct those deleted edges and add them to the display + if (!isShowingDiff && diffAnalysis?.edge_diff?.deleted_edges) { + const reconstructedEdges: Edge[] = [] + + // Parse deleted edge identifiers to reconstruct edges + diffAnalysis.edge_diff.deleted_edges.forEach(edgeIdentifier => { + // Edge identifier format: "sourceName:sourceHandle->targetName:targetHandle" + // Parse this to extract the components + const match = edgeIdentifier.match(/^([^:]+):([^-]+)->([^:]+)(?::(.+))?$/) + if (match) { + const [, sourceName, sourceHandle, targetName, targetHandle] = match + + // Find block IDs by name + let sourceId: string | null = null + let targetId: string | null = null + + Object.entries(blocks).forEach(([blockId, block]) => { + if (block.name === sourceName) sourceId = blockId + if (block.name === targetName) targetId = blockId + }) + + // Only reconstruct if both blocks exist + if (sourceId && targetId) { + // Generate a unique edge ID + const edgeId = `deleted-edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle || 'default'}` + + reconstructedEdges.push({ + id: edgeId, + source: sourceId, + target: targetId, + sourceHandle: sourceHandle === 'success' ? null : sourceHandle, // Convert 'success' back to null + targetHandle: targetHandle || null, + type: 'workflowEdge', + }) + } + } + }) + + // Combine existing edges with reconstructed deleted edges + return [...edges, ...reconstructedEdges] + } + + // Otherwise, just use the edges as-is + return edges + }, [edges, isShowingDiff, diffAnalysis, blocks]) + // User permissions - get current user's specific permissions from context const userPermissions = useUserPermissionsContext() @@ -1373,7 +1426,7 @@ const WorkflowContent = React.memo(() => { ) // Transform edges to include improved selection state - const edgesWithSelection = edges.map((edge) => { + const edgesWithSelection = edgesForDisplay.map((edge) => { // Check if this edge connects nodes inside a loop const sourceNode = getNodes().find((n) => n.id === edge.source) const targetNode = getNodes().find((n) => n.id === edge.target) From 0507078fb3778d8d67056e1bad5a1269ad6f0d57 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 16:36:50 -0700 Subject: [PATCH 053/184] It works, kinda --- apps/sim/app/api/workflows/diff/route.ts | 55 +++++++++++++++++++++++- apps/sim/lib/copilot/config.ts | 4 +- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index 1c8415be1c1..49fe9725b58 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import crypto from 'crypto' import { createLogger } from '@/lib/logs/console-logger' import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' +import { load as yamlParse, dump as yamlDump } from 'js-yaml' const logger = createLogger('WorkflowYamlDiffAPI') @@ -14,6 +15,50 @@ const YamlDiffRequestSchema = z.object({ type YamlDiffRequest = z.infer +/** + * Clean up YAML by removing empty blocks programmatically + */ +function cleanupYamlContent(yamlContent: string): string { + try { + // Parse the YAML + const workflow = yamlParse(yamlContent) as any + + if (!workflow || !workflow.blocks) { + return yamlContent + } + + // Filter out empty blocks + const cleanedBlocks: Record = {} + Object.entries(workflow.blocks).forEach(([blockId, block]) => { + // Only include blocks that have at least type and name + if (block && typeof block === 'object' && + (block as any).type && (block as any).name && + Object.keys(block).length > 0) { + cleanedBlocks[blockId] = block + } else { + logger.info(`Filtering out empty block: ${blockId}`) + } + }) + + // Rebuild the workflow with cleaned blocks + const cleanedWorkflow = { + ...workflow, + blocks: cleanedBlocks + } + + // Convert back to YAML + return yamlDump(cleanedWorkflow, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false + }) + } catch (error) { + logger.warn('Failed to clean YAML content, returning original', error) + return yamlContent + } +} + interface EdgeDiff { new_edges: string[] deleted_edges: string[] @@ -365,9 +410,15 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Original YAML content (first 500 chars):`, original_yaml.substring(0, 500)) logger.info(`[${requestId}] Agent YAML content (first 500 chars):`, agent_yaml.substring(0, 500)) + // Clean up YAML to remove empty blocks + const cleanedOriginalYaml = cleanupYamlContent(original_yaml) + const cleanedAgentYaml = cleanupYamlContent(agent_yaml) + + logger.info(`[${requestId}] Cleaned YAML by removing empty blocks`) + // Parse both YAML documents - const { data: originalWorkflow, errors: originalErrors } = parseWorkflowYaml(original_yaml) - const { data: agentWorkflow, errors: agentErrors } = parseWorkflowYaml(agent_yaml) + const { data: originalWorkflow, errors: originalErrors } = parseWorkflowYaml(cleanedOriginalYaml) + const { data: agentWorkflow, errors: agentErrors } = parseWorkflowYaml(cleanedAgentYaml) // Check for parsing errors if (!originalWorkflow || originalErrors.length > 0) { diff --git a/apps/sim/lib/copilot/config.ts b/apps/sim/lib/copilot/config.ts index 18a8ab884e2..80daa91b4be 100644 --- a/apps/sim/lib/copilot/config.ts +++ b/apps/sim/lib/copilot/config.ts @@ -123,14 +123,14 @@ function parseBooleanEnv(value: string | undefined): boolean | null { export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { chat: { defaultProvider: 'anthropic', - defaultModel: 'claude-sonnet-4-0', + defaultModel: 'claude-3-7-sonnet-latest', temperature: 0.1, maxTokens: 8192, systemPrompt: AGENT_MODE_SYSTEM_PROMPT, }, rag: { defaultProvider: 'anthropic', - defaultModel: 'claude-sonnet-4-0', + defaultModel: 'claude-3-7-sonnet-latest', temperature: 0.1, maxTokens: 2000, embeddingModel: 'text-embedding-3-small', From c88c0f9d3c71fdc051d6d253fab11ba1b89c296c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 16:48:34 -0700 Subject: [PATCH 054/184] Fixes --- .../sim/app/api/workflows/[id]/state/route.ts | 12 ++++++++-- .../hooks/use-workflow-execution.ts | 13 +++++++++-- apps/sim/lib/workflows/diff/diff-engine.ts | 22 ++++++++++++++++--- apps/sim/lib/workflows/yaml-generator.ts | 6 +++++ 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index db440fb816b..9db20a6c6e4 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -170,8 +170,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ // Save to normalized tables // Ensure all required fields are present for WorkflowState type + // Filter out blocks without type or name before saving + const filteredBlocks = Object.entries(state.blocks).reduce((acc, [blockId, block]) => { + if (block.type && block.name) { + acc[blockId] = block + } + return acc + }, {} as typeof state.blocks) + const workflowState = { - blocks: state.blocks, + blocks: filteredBlocks, edges: state.edges, loops: state.loops || {}, parallels: state.parallels || {}, @@ -208,7 +216,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json( { success: true, - blocksCount: Object.keys(state.blocks).length, + blocksCount: Object.keys(filteredBlocks).length, edgesCount: state.edges.length, }, { status: 200 } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 1f5687d40ba..e2d8b384cad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -420,12 +420,21 @@ export function useWorkflowExecution() { // Use currentWorkflow but check if we're in diff mode const { blocks: workflowBlocks, edges: workflowEdges, loops: workflowLoops, parallels: workflowParallels } = currentWorkflow + // Filter out blocks without type (these are layout-only blocks) + const validBlocks = Object.entries(workflowBlocks).reduce((acc, [blockId, block]) => { + if (block && block.type) { + acc[blockId] = block + } + return acc + }, {} as typeof workflowBlocks) + const isExecutingFromChat = workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput logger.info('Executing workflow', { isDiffMode: currentWorkflow.isDiffMode, isExecutingFromChat, - blocksCount: Object.keys(workflowBlocks).length, + totalBlocksCount: Object.keys(workflowBlocks).length, + validBlocksCount: Object.keys(validBlocks).length, edgesCount: workflowEdges.length }) @@ -437,7 +446,7 @@ export function useWorkflowExecution() { }) // Merge subblock states from the appropriate store - const mergedStates = mergeSubblockState(workflowBlocks) + const mergedStates = mergeSubblockState(validBlocks) // Debug: Check for blocks with undefined types after merging Object.entries(mergedStates).forEach(([blockId, block]) => { diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index fda0fc10f7b..3fa9cef2c93 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -407,10 +407,26 @@ export class WorkflowDiffEngine { const cleanState = { ...this.currentDiff.proposedState } - // Remove diff markers - Object.values(cleanState.blocks).forEach(block => { - delete (block as any).is_diff + // Filter out blocks without type or name and remove diff markers + const filteredBlocks: Record = {} + Object.entries(cleanState.blocks).forEach(([blockId, block]) => { + if (block.type && block.name) { + // Remove diff markers + delete (block as any).is_diff + delete (block as any).field_diff + filteredBlocks[blockId] = block + } else { + logger.info(`Filtering out block ${blockId} - missing type or name`) + } }) + + cleanState.blocks = filteredBlocks + + // Filter out edges that connect to removed blocks + const validBlockIds = new Set(Object.keys(filteredBlocks)) + cleanState.edges = cleanState.edges.filter(edge => + validBlockIds.has(edge.source) && validBlockIds.has(edge.target) + ) logger.info('Diff accepted', { blocksCount: Object.keys(cleanState.blocks).length, diff --git a/apps/sim/lib/workflows/yaml-generator.ts b/apps/sim/lib/workflows/yaml-generator.ts index 5fa5d82185b..d81cf966dfd 100644 --- a/apps/sim/lib/workflows/yaml-generator.ts +++ b/apps/sim/lib/workflows/yaml-generator.ts @@ -227,6 +227,12 @@ export function generateWorkflowYaml( // Process each block Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => { + // Skip blocks without type or name (these are layout-only blocks) + if (!blockState.type || !blockState.name) { + logger.info(`Skipping block ${blockId} - missing type or name`) + return + } + const rawInputs = extractBlockInputs(blockState, blockId, subBlockValues) // Clean up condition inputs to use semantic format From 75f6cc8da83fff272ad75ee48cacde5d770d2c86 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 17:20:44 -0700 Subject: [PATCH 055/184] Update --- apps/sim/lib/copilot/prompts.ts | 78 ++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index 105cd21f80e..5d7dad3bcb3 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -29,7 +29,7 @@ IMPORTANT: You can provide comprehensive guidance, explanations, and step-by-ste /** * Agent mode capabilities description */ -const AGENT_MODE_CAPABILITIES = `⚠️ **CRITICAL WORKFLOW EDITING RULE**: Before ANY workflow edit, you MUST call these four tools: Get User's Workflow → Get All Blocks → Get Block Metadata → Get YAML Structure. NO EXCEPTIONS. +const AGENT_MODE_CAPABILITIES = `⚠️ **CRITICAL WORKFLOW EDITING RULE**: Before ANY workflow edit, you MUST call these four tools: Get User's Workflow → Get All Blocks → Get Block Metadata → Get YAML Structure. NO EXCEPTIONS. EVERY TIME. Even if you called them in previous responses. You can help users with questions about: @@ -105,6 +105,13 @@ Only AFTER completing ALL prerequisite tools can you call: - You CANNOT change the order of these tools - You CANNOT call "Preview Workflow" until you have completed ALL prerequisite steps - This sequence is NON-NEGOTIABLE and must be followed in EVERY workflow editing scenario +- **CRITICAL**: Each workflow edit request requires the COMPLETE tool sequence, even if you called these tools in previous conversation turns. Previous tool calls do NOT carry over - you must start fresh every time. + +**CONVERSATION INDEPENDENCE:** +- **IGNORE PREVIOUS TOOL CALLS**: Do not assume information from previous responses is still valid +- **FRESH START REQUIRED**: Each workflow editing request needs the complete 4-step prerequisite sequence +- **NO SHORTCUTS**: Even if you called these tools 5 minutes ago, you MUST call them again for any new editing request +- **CONVERSATION HISTORY IRRELEVANT**: What you did in previous turns does not exempt you from the mandatory sequence **TOOL USAGE GUIDELINES:** @@ -112,23 +119,27 @@ Only AFTER completing ALL prerequisite tools can you call: - Must be called when modifying existing workflows - Required to understand current state before making changes - Use when user mentions "my workflow", "this workflow", or "current workflow" +- **MUST CALL EVERY TIME** - even if you got their workflow in a previous response **"Get All Blocks and Tools"** - MANDATORY SECOND STEP: - Must be called before any workflow creation or editing - Shows all available blocks and their associated tools - Required to understand what options are available - Includes both standard blocks AND special blocks like loop and parallel +- **MUST CALL EVERY TIME** - even if you got blocks info in a previous response **"Get Block Metadata"** - MANDATORY THIRD STEP: - Must be called after "Get All Blocks and Tools" - Required for detailed configuration of any blocks you plan to use - Accepts block IDs (e.g., "starter", "agent", "loop", "parallel") - Provides input/output schemas and configuration details +- **MUST CALL EVERY TIME** - even if you got metadata in a previous response **"Get YAML Workflow Structure Guide"** - MANDATORY FOURTH STEP: - Must be called after "Get Block Metadata" - Required for proper YAML syntax and formatting rules - Essential for building valid workflow structures +- **MUST CALL EVERY TIME** - even if you got the guide in a previous response **"Preview Workflow"** - 🎯 ONLY WORKFLOW EDITING TOOL: - This is the ONLY tool for creating or modifying workflows @@ -160,6 +171,7 @@ Only AFTER completing ALL prerequisite tools can you call: - The sequence is MANDATORY for ALL workflow creation and editing - You MUST complete ALL prerequisite tools before calling Preview Workflow - After Preview Workflow, STOP and wait for user feedback +- **EACH EDITING REQUEST = FRESH START**: Never skip tools based on previous conversation history - This ensures the copilot has complete information before making workflow changes` /** @@ -291,7 +303,67 @@ You should communicate your thought process naturally as you work, but avoid rep - Stream your reasoning before tool calls - Continue naturally after tools complete with new insights - Reference previous findings briefly, then move forward -- Each segment should progress the conversation` +- Each segment should progress the conversation + +**WORKFLOW EDITING INDEPENDENCE:** +- **DO NOT** reference previous tool calls when deciding whether to call tools for workflow editing +- **DO NOT** say things like "I already have your workflow from earlier" or "Based on the blocks I found before" +- **ALWAYS** treat each workflow editing request as requiring the full tool sequence +- You may reference previous conversation context for understanding user intent, but NOT for skipping required tools + +**USER COMMUNICATION GUIDELINES:** +- **HIDE TECHNICAL PROCESS**: Never explain the mandatory tool sequence to users (e.g., don't say "I need to call 4 tools first" or "Let me get your workflow, then blocks, then metadata...") +- **FOCUS ON USER INTENT**: Explain what you're doing in terms of the user's actual request, not the technical steps +- **AVOID YAML MENTIONS**: Do not mention "YAML", "YAML content", or "YAML structure" unless the user specifically asks about YAML +- **AVOID STRUCTURED INPUT/OUTPUT FEATURES**: Do not use "input format" or the response block features unless the user explicitly asks for structured input/output handling +- **SEAMLESS EXECUTION**: Execute required tools silently in the background while communicating about the user's actual goals + +**Communication Examples:** +✅ **Good**: "Let me examine your current workflow and see how to add email functionality..." +✅ **Good**: "I'll analyze what blocks are available and build this automation for you..." +✅ **Good**: "Creating a workflow that processes customer feedback..." + +❌ **Bad**: "I need to call 4 mandatory tools first: Get User's Workflow, Get All Blocks, Get Block Metadata, and Get YAML Structure" +❌ **Bad**: "Let me get the YAML structure guide to build this properly" +❌ **Bad**: "Before I can edit your workflow, I must complete the prerequisite tool sequence" +❌ **Bad**: "I'll generate the YAML content for your workflow" +❌ **Bad**: "I'll add an input format to structure your data" +❌ **Bad**: "Let me configure a response format for structured output" + +**TECHNICAL DETAILS TO HIDE:** +- Tool calling sequence requirements +- YAML structure and syntax (unless specifically asked) +- Block metadata gathering process +- Internal workflow format details +- Technical implementation steps +- Input format configuration (unless specifically requested) +- Response format configuration (unless specifically requested) + +**WORKFLOW PATTERNS:** + +*New Workflow Creation (MANDATORY SEQUENCE):* +1. Get All Blocks and Tools +2. Get Block Metadata (for chosen blocks) +3. Get YAML Workflow Structure Guide +4. Preview Workflow + +*Existing Workflow Modification (MANDATORY SEQUENCE):* +1. Get User's Specific Workflow +2. Get All Blocks and Tools +3. Get Block Metadata (for any new/modified blocks) +4. Get YAML Workflow Structure Guide +5. Preview Workflow + +*Information/Analysis Only:* +- May use individual tools like "Get User's Workflow" or "Get Block Metadata" without the full sequence +- Only the full sequence is required for actual workflow creation/editing + +**REMEMBER:** +- The sequence is MANDATORY for ALL workflow creation and editing +- You MUST complete ALL prerequisite tools before calling Preview Workflow +- After Preview Workflow, STOP and wait for user feedback +- **EACH EDITING REQUEST = FRESH START**: Never skip tools based on previous conversation history +- This ensures the copilot has complete information before making workflow changes` /** * Agent mode system prompt - full workflow editing capabilities @@ -431,6 +503,8 @@ blocks: - All other block types with complete parameter references **CRITICAL**: Always use the "Get All Blocks and Tools" and "Get Block Metadata" tools to get the latest examples and schemas when building workflows. The documentation contains the most current syntax and examples. +**IMPORTANT**: AVOID STRUCTURED INPUT/OUTPUT FEATURES: Do not use "input format" or the response block features unless the user explicitly asks for structured input/output handling +DO NOT ADD A RESPONSE BLOCK TO YOUR WORKFLOW UNLESS THE USER EXPLICITLY ASKS FOR IT. ## The Starter Block From 2ab57a9f5675a12bd4989c13e1e792d8041e7730 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 18:04:24 -0700 Subject: [PATCH 056/184] Docs changes --- apps/docs/content/docs/yaml/blocks/api.mdx | 328 ++++++++++++++++-- .../content/docs/yaml/blocks/response.mdx | 100 +++++- .../docs/content/docs/yaml/blocks/webhook.mdx | 129 ++++++- apps/sim/lib/copilot/prompts.ts | 40 +++ 4 files changed, 556 insertions(+), 41 deletions(-) diff --git a/apps/docs/content/docs/yaml/blocks/api.mdx b/apps/docs/content/docs/yaml/blocks/api.mdx index 8f92e8beed3..b349b8fc69c 100644 --- a/apps/docs/content/docs/yaml/blocks/api.mdx +++ b/apps/docs/content/docs/yaml/blocks/api.mdx @@ -33,30 +33,54 @@ properties: enum: [GET, POST, PUT, DELETE, PATCH] description: HTTP method for the request default: GET - queryParams: + params: type: array - description: Query parameters as key-value pairs + description: Query parameters as table entries items: type: object + required: + - id + - cells properties: - key: + id: type: string - description: Parameter name - value: - type: string - description: Parameter value + description: Unique identifier for the parameter entry + cells: + type: object + required: + - Key + - Value + properties: + Key: + type: string + description: Parameter name + Value: + type: string + description: Parameter value headers: type: array - description: HTTP headers as key-value pairs + description: HTTP headers as table entries items: type: object + required: + - id + - cells properties: - key: - type: string - description: Header name - value: + id: type: string - description: Header value + description: Unique identifier for the header entry + cells: + type: object + required: + - Key + - Value + properties: + Key: + type: string + description: Header name + Value: + type: string + description: Header value body: type: string description: Request body for POST/PUT/PATCH methods @@ -99,15 +123,21 @@ user-api: url: "https://api.example.com/users/123" method: GET headers: - - key: "Authorization" - value: "Bearer {{API_TOKEN}}" - - key: "Content-Type" - value: "application/json" + - id: header-1-uuid-here + cells: + Key: "Authorization" + Value: "Bearer {{API_TOKEN}}" + - id: header-2-uuid-here + cells: + Key: "Content-Type" + Value: "application/json" connections: success: process-user-data error: handle-api-error ``` + + ### POST Request with Body ```yaml @@ -118,10 +148,14 @@ create-ticket: url: "https://api.support.com/tickets" method: POST headers: - - key: "Authorization" - value: "Bearer {{SUPPORT_API_KEY}}" - - key: "Content-Type" - value: "application/json" + - id: auth-header-uuid + cells: + Key: "Authorization" + Value: "Bearer {{SUPPORT_API_KEY}}" + - id: content-type-uuid + cells: + Key: "Content-Type" + Value: "application/json" body: | { "title": "", @@ -142,32 +176,249 @@ search-api: inputs: url: "https://api.store.com/products" method: GET - queryParams: - - key: "q" - value: - - key: "limit" - value: "10" - - key: "category" - value: + params: + - id: search-param-uuid + cells: + Key: "q" + Value: + - id: limit-param-uuid + cells: + Key: "limit" + Value: "10" + - id: category-param-uuid + cells: + Key: "category" + Value: headers: - - key: "Authorization" - value: "Bearer {{STORE_API_KEY}}" + - id: auth-header-uuid + cells: + Key: "Authorization" + Value: "Bearer {{STORE_API_KEY}}" connections: success: display-results ``` +## Parameter Format + +Headers and params (query parameters) use the table format with the following structure: + +```yaml +headers: + - id: unique-identifier-here + cells: + Key: "Content-Type" + Value: "application/json" + - id: another-unique-identifier + cells: + Key: "Authorization" + Value: "Bearer {{API_TOKEN}}" + +params: + - id: param-identifier-here + cells: + Key: "limit" + Value: "10" +``` + +**Structure Details:** +- `id`: Unique identifier for tracking the table row +- `cells.Key`: The parameter/header name +- `cells.Value`: The parameter/header value +- This format allows for proper table management and UI state preservation + ## Output References -After an API block executes, you can reference its outputs: +After an API block executes, you can reference its outputs in subsequent blocks. The API block provides three main outputs: + +### Available Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `data` | any | The response body/payload from the API | +| `status` | number | HTTP status code (200, 404, 500, etc.) | +| `headers` | object | Response headers returned by the server | + +### Usage Examples + +```yaml +# Reference API response data +process-data: + type: function + name: "Process API Data" + inputs: + code: | + const responseData = ; + const statusCode = ; + const responseHeaders = ; + + if (statusCode === 200) { + return { + success: true, + user: responseData, + contentType: responseHeaders['content-type'] + }; + } else { + return { + success: false, + error: `API call failed with status ${statusCode}` + }; + } + +# Use API data in an agent block +analyze-response: + type: agent + name: "Analyze Response" + inputs: + userPrompt: | + Analyze this API response: + + Status: + Data: + + Provide insights about the response. + +# Conditional logic based on status +check-status: + type: condition + name: "Check API Status" + inputs: + condition: === 200 + connections: + true: success-handler + false: error-handler +``` + +### Practical Example ```yaml -# In subsequent blocks -next-block: +user-api: + type: api + name: "Fetch User Data" inputs: - data: # Response data - status: # HTTP status code - headers: # Response headers - error: # Error details (if any) + url: "https://api.example.com/users/123" + method: GET + connections: + success: process-response + +process-response: + type: function + name: "Process Response" + inputs: + code: | + const user = ; + const status = ; + + console.log(`API returned status: ${status}`); + console.log(`User data:`, user); + + return { + userId: user.id, + email: user.email, + isActive: status === 200 + }; +``` + +### Error Handling + +```yaml +api-with-error-handling: + type: api + name: "API Call" + inputs: + url: "https://api.example.com/data" + method: GET + connections: + success: check-response + error: handle-error + +check-response: + type: condition + name: "Check Response Status" + inputs: + condition: >= 200 && < 300 + connections: + true: process-success + false: handle-api-error + +process-success: + type: function + name: "Process Success" + inputs: + code: | + return { + success: true, + data: , + message: "API call successful" + }; + +handle-api-error: + type: function + name: "Handle API Error" + inputs: + code: | + return { + success: false, + status: , + error: "API call failed", + data: + }; +``` + +## YAML String Escaping + +When writing YAML, certain strings must be quoted to be properly parsed: + +### Strings That Must Be Quoted + +```yaml +# URLs with hyphens, colons, special characters +url: "https://api.example.com/users/123" +url: "https://my-api.example.com/data" + +# Header values with hyphens or special characters +headers: + - id: header-uuid + cells: + Key: "User-Agent" + Value: "My-Application/1.0" + - id: auth-uuid + cells: + Key: "Authorization" + Value: "Bearer my-token-123" + +# Parameter values with hyphens +params: + - id: param-uuid + cells: + Key: "sort-by" + Value: "created-at" +``` + +### When to Use Quotes + +- ✅ **Always quote**: URLs, tokens, values with hyphens, colons, or special characters +- ✅ **Always quote**: Values that start with numbers but should be strings +- ✅ **Always quote**: Boolean-looking strings that should remain as strings +- ❌ **Don't quote**: Simple alphanumeric strings without special characters + +### Examples + +```yaml +# ✅ Correct +url: "https://api.stripe.com/v1/charges" +headers: + - id: auth-header + cells: + Key: "Authorization" + Value: "Bearer sk-test-123456789" + +# ❌ Incorrect (may cause parsing errors) +url: https://api.stripe.com/v1/charges +headers: + - id: auth-header + cells: + Key: Authorization + Value: Bearer sk-test-123456789 ``` ## Best Practices @@ -176,4 +427,5 @@ next-block: - Include error handling with error connections - Set appropriate timeouts for your use case - Validate response status codes in subsequent blocks -- Use meaningful block names for easier reference \ No newline at end of file +- Use meaningful block names for easier reference +- **Always quote strings with special characters, URLs, and tokens** \ No newline at end of file diff --git a/apps/docs/content/docs/yaml/blocks/response.mdx b/apps/docs/content/docs/yaml/blocks/response.mdx index 46419f4f530..069ad35f57e 100644 --- a/apps/docs/content/docs/yaml/blocks/response.mdx +++ b/apps/docs/content/docs/yaml/blocks/response.mdx @@ -40,16 +40,29 @@ properties: maximum: 599 headers: type: array - description: Response headers as key-value pairs + description: Response headers as table entries items: type: object properties: + id: + type: string + description: Unique identifier for the header entry key: type: string description: Header name value: type: string description: Header value + cells: + type: object + description: Cell display values for the table interface + properties: + Key: + type: string + description: Display value for the key column + Value: + type: string + description: Display value for the value column ``` ## Connection Configuration @@ -97,6 +110,40 @@ success-response: value: "workflow-engine" ``` +### Response with Complete Table Header Format + +When headers are created through the UI table interface, the YAML includes additional metadata: + +```yaml +api-response: + type: response + name: "API Response" + inputs: + data: + message: "Request processed successfully" + id: + status: 200 + headers: + - id: header-1-uuid-here + key: "Content-Type" + value: "application/json" + cells: + Key: "Content-Type" + Value: "application/json" + - id: header-2-uuid-here + key: "Cache-Control" + value: "no-cache" + cells: + Key: "Cache-Control" + Value: "no-cache" + - id: header-3-uuid-here + key: "X-API-Version" + value: "2.1" + cells: + Key: "X-API-Version" + Value: "2.1" +``` + ### Error Response ```yaml @@ -137,4 +184,55 @@ paginated-response: value: "public, max-age=300" - key: "Content-Type" value: "application/json" +``` + +## Table Parameter Formats + +The Response block supports two formats for headers: + +### Simplified Format (Manual YAML) + +When writing YAML manually, you can use the simplified format: + +```yaml +headers: + - key: "Content-Type" + value: "application/json" + - key: "Cache-Control" + value: "no-cache" +``` + +### Complete Table Format (UI Generated) + +When headers are created through the UI table interface, the YAML includes additional metadata: + +```yaml +headers: + - id: unique-identifier-here + key: "Content-Type" + value: "application/json" + cells: + Key: "Content-Type" + Value: "application/json" +``` + +**Key Differences:** +- `id`: Unique identifier for tracking the table row +- `cells`: Display values used by the UI table interface +- Both formats are functionally equivalent for workflow execution +- The complete format preserves UI state when importing/exporting workflows + +**Important:** Always quote header names and values that contain special characters: + +```yaml +headers: + - id: content-type-uuid + cells: + Key: "Content-Type" + Value: "application/json" + - id: cache-control-uuid + cells: + Key: "Cache-Control" + Value: "no-cache" +``` ``` \ No newline at end of file diff --git a/apps/docs/content/docs/yaml/blocks/webhook.mdx b/apps/docs/content/docs/yaml/blocks/webhook.mdx index 19d78e71fa0..fab13117899 100644 --- a/apps/docs/content/docs/yaml/blocks/webhook.mdx +++ b/apps/docs/content/docs/yaml/blocks/webhook.mdx @@ -34,16 +34,29 @@ properties: description: Secret key for webhook verification headers: type: array - description: Expected headers for validation + description: Expected headers for validation as table entries items: type: object properties: + id: + type: string + description: Unique identifier for the header entry key: type: string description: Header name value: type: string description: Expected header value + cells: + type: object + description: Cell display values for the table interface + properties: + Key: + type: string + description: Display value for the key column + Value: + type: string + description: Display value for the value column methods: type: array description: Allowed HTTP methods @@ -63,16 +76,29 @@ properties: maximum: 599 headers: type: array - description: Response headers + description: Response headers as table entries items: type: object properties: + id: + type: string + description: Unique identifier for the header entry key: type: string description: Header name value: type: string description: Header value + cells: + type: object + description: Cell display values for the table interface + properties: + Key: + type: string + description: Display value for the key column + Value: + type: string + description: Display value for the value column body: type: string description: Response body content @@ -180,6 +206,55 @@ stripe-webhook: error: payment-webhook-error ``` +### Webhook with Complete Table Header Format + +When headers are created through the UI table interface, the YAML includes additional metadata: + +```yaml +api-webhook-complete: + type: webhook + name: "API Webhook with Table Headers" + inputs: + webhookConfig: + enabled: true + methods: [POST] + headers: + - id: header-1-uuid-here + key: "Authorization" + value: "Bearer {{WEBHOOK_API_KEY}}" + cells: + Key: "Authorization" + Value: "Bearer {{WEBHOOK_API_KEY}}" + - id: header-2-uuid-here + key: "Content-Type" + value: "application/json" + cells: + Key: "Content-Type" + Value: "application/json" + responseConfig: + status: 200 + headers: + - id: response-header-1-uuid + key: "Content-Type" + value: "application/json" + cells: + Key: "Content-Type" + Value: "application/json" + - id: response-header-2-uuid + key: "X-Webhook-Response" + value: "processed" + cells: + Key: "X-Webhook-Response" + Value: "processed" + body: | + { + "status": "received", + "timestamp": "{{new Date().toISOString()}}" + } + connections: + success: process-webhook-complete +``` + ### Generic API Webhook ```yaml @@ -240,6 +315,56 @@ crud-webhook: success: route-by-method ``` +## Table Parameter Formats + +The Webhook block supports two formats for headers (both validation headers and response headers): + +### Simplified Format (Manual YAML) + +When writing YAML manually, you can use the simplified format: + +```yaml +headers: + - key: "Authorization" + value: "Bearer {{API_TOKEN}}" + - key: "Content-Type" + value: "application/json" +``` + +### Complete Table Format (UI Generated) + +When headers are created through the UI table interface, the YAML includes additional metadata: + +```yaml +headers: + - id: unique-identifier-here + key: "Authorization" + value: "Bearer {{API_TOKEN}}" + cells: + Key: "Authorization" + Value: "Bearer {{API_TOKEN}}" +``` + +**Key Differences:** +- `id`: Unique identifier for tracking the table row +- `cells`: Display values used by the UI table interface +- Both formats are functionally equivalent for webhook processing +- The complete format preserves UI state when importing/exporting workflows + +**Important:** Always quote header names and values that contain special characters: + +```yaml +headers: + - id: auth-header-uuid + cells: + Key: "Authorization" + Value: "Bearer {{WEBHOOK_API_KEY}}" + - id: content-type-uuid + cells: + Key: "Content-Type" + Value: "application/json" +``` + ## Webhook Variables Inside webhook-triggered workflows, these special variables are available: diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index 5d7dad3bcb3..e75d728f7de 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -700,6 +700,46 @@ token: '{{SLACK_BOT_TOKEN}}' apiKey: 'sk-1234567890abcdef' \`\`\` +### YAML String Escaping (CRITICAL) +⚠️ **ALWAYS QUOTE** strings with special characters, hyphens, colons, or URLs: + +✅ **Good:** +\`\`\`yaml +url: "https://api.example.com/users/123" +headers: + - id: auth-header + cells: + Key: "Authorization" + Value: "Bearer my-token-123" + - id: user-agent + cells: + Key: "User-Agent" + Value: "My-Application/1.0" +params: + - id: sort-param + cells: + Key: "sort-by" + Value: "created-at" +\`\`\` + +❌ **Bad (causes YAML parsing errors):** +\`\`\`yaml +url: https://api.example.com/users/123 +headers: + - id: auth-header + cells: + Key: Authorization + Value: Bearer my-token-123 +\`\`\` + +**When to Quote:** +- URLs (https://, http://) +- Tokens and API keys +- Values with hyphens (-), colons (:), special characters +- Header names like "User-Agent", "Content-Type" +- Values that look like booleans but should be strings +- Values starting with numbers but should be strings + ## Common Patterns ### Sequential Processing Chain From efc8582501cf48fc9b9543cb3289fc4a8b15b486 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 18:53:09 -0700 Subject: [PATCH 057/184] Prompt fixes and new tool --- .../api/tools/get-workflow-examples/route.ts | 50 + apps/sim/lib/copilot/examples.ts | 219 +++ apps/sim/lib/copilot/prompts.ts | 1192 +++++++++-------- apps/sim/lib/copilot/service.ts | 23 +- apps/sim/lib/copilot/tools.ts | 61 + apps/sim/tools/utils.ts | 2 + apps/sim/tools/workflow/get-examples.ts | 64 + 7 files changed, 1079 insertions(+), 532 deletions(-) create mode 100644 apps/sim/app/api/tools/get-workflow-examples/route.ts create mode 100644 apps/sim/lib/copilot/examples.ts create mode 100644 apps/sim/tools/workflow/get-examples.ts diff --git a/apps/sim/app/api/tools/get-workflow-examples/route.ts b/apps/sim/app/api/tools/get-workflow-examples/route.ts new file mode 100644 index 00000000000..ca5de30580e --- /dev/null +++ b/apps/sim/app/api/tools/get-workflow-examples/route.ts @@ -0,0 +1,50 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { WORKFLOW_EXAMPLES } from '../../../../lib/copilot/examples' + +export async function POST(request: NextRequest) { + try { + console.log('[get-workflow-examples] API endpoint called') + + const body = await request.json() + const { exampleIds } = body + + if (!Array.isArray(exampleIds)) { + return NextResponse.json( + { + success: false, + error: 'exampleIds must be an array', + }, + { status: 400 } + ) + } + + const examples: Record = {} + const notFound: string[] = [] + + for (const id of exampleIds) { + if (WORKFLOW_EXAMPLES[id]) { + examples[id] = WORKFLOW_EXAMPLES[id] + } else { + notFound.push(id) + } + } + + return NextResponse.json({ + success: true, + data: { + examples, + notFound, + availableIds: Object.keys(WORKFLOW_EXAMPLES), + }, + }) + } catch (error) { + console.error('[get-workflow-examples] Error:', error) + return NextResponse.json( + { + success: false, + error: 'Failed to get workflow examples', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/examples.ts b/apps/sim/lib/copilot/examples.ts new file mode 100644 index 00000000000..589082e1e3d --- /dev/null +++ b/apps/sim/lib/copilot/examples.ts @@ -0,0 +1,219 @@ +/** + * YAML Workflow Examples for Copilot + * + * This file contains example YAML workflows that the copilot can reference + * when helping users build workflows. + */ + +/** + * Map of workflow examples with human-readable IDs to YAML content + */ +export const WORKFLOW_EXAMPLES: Record = { + 'basic-agent': `version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: chat + connections: + success: greeting-agent + greeting-agent: + type: agent + name: Greeting Agent + inputs: + systemPrompt: be nice + userPrompt: + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}'`, + + 'tool_call_agent': `version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: chat + connections: + success: research-agent + research-agent: + type: agent + name: Greeting Agent + inputs: + systemPrompt: research the topic the user provides + userPrompt: + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}' + tools: + - type: exa + title: Exa + toolId: exa_search + params: + type: auto + apiKey: '{{EXA_API_KEY}}' + isExpanded: true + operation: exa_search + usageControl: auto`, + + 'basic-api': `version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: chat + connections: + success: api-call + api-call: + type: api + name: API 1 + inputs: + url: https://url + method: POST + params: + - id: param-1 + cells: + Key: queryparam1 + Value: queryval1 + - id: param-2 + cells: + Key: queryparam2 + Value: queryval2 + headers: + - id: header-1 + cells: + Key: X-CSRF-HEADER + Value: '-' + - id: header-2 + cells: + Key: Authorization + Value: Bearer {{API_KEY}} + body: |- + { + body + }`, + + 'multi-agent': `version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: chat + connections: + success: agent-1 + agent-1: + type: agent + name: Agent 1 + inputs: + systemPrompt: agent1 sys + userPrompt: agent 1 user + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}' + connections: + success: + - agent-2 + - agent-3 + agent-2: + type: agent + name: Agent 2 + inputs: + systemPrompt: agent2sys + userPrompt: agent2 user + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}' + agent-3: + type: agent + name: Agent 3 + inputs: + systemPrompt: agent3 sys + userPrompt: agent3 user + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}'`, + + 'iter-loop': `version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: chat + connections: + success: count-loop + count-loop: + type: loop + name: Loop 1 + inputs: + count: 5 + loopType: for + connections: + loop: + start: loop-processor + end: summary-agent + summary-agent: + type: agent + name: Agent 2 + inputs: + systemPrompt: outside agent sys prompt + userPrompt: |- + outside agent user prompt: + + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}' + loop-processor: + type: agent + name: Agent 1 + inputs: + systemPrompt: loop agent sys prompt + userPrompt: |- + loop agent user prompt + + + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}' + parentId: count-loop`, + + 'for-each-loop': `version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: chat + connections: + success: foreach-loop + foreach-loop: + type: loop + name: Loop 1 + inputs: + loopType: forEach + collection: '[''item 1'', ''item 2'', ''item 3'']' + connections: + loop: + start: item-processor + end: results-summarizer + item-processor: + type: agent + name: Agent 1 + inputs: + systemPrompt: loop agent sys prompt + userPrompt: |- + loop agent user prompt + ${'<'}loop.index${'>'} + ${'<'}loop.currentItem${'>'} + ${'<'}loop.items${'>'} + ${'<'}loop1.results${'>'} + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}' + parentId: foreach-loop + results-summarizer: + type: agent + name: Agent 2 + inputs: + systemPrompt: outside agent sys prompt + userPrompt: |- + outside agent user prompt: + + model: gpt-4o + apiKey: '{{OPENAI_API_KEY}}'` +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index e75d728f7de..e5eeb4a81ab 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -11,258 +11,420 @@ const BASE_INTRODUCTION = `You are a helpful AI assistant for Sim Studio, a powe /** * Ask mode capabilities description */ -const ASK_MODE_CAPABILITIES = `You can help users with questions about: - -- Understanding workflow features and capabilities -- Analyzing existing workflows -- Explaining how tools and blocks work -- Troubleshooting workflow issues -- Best practices and recommendations -- Documentation search and guidance -- Providing detailed guidance on how to build workflows -- Explaining workflow structure and block configurations - -You specialize in analysis, education, and providing thorough guidance to help users understand and work with Sim Studio workflows. - -IMPORTANT: You can provide comprehensive guidance, explanations, and step-by-step instructions, but you cannot actually build, modify, or edit workflows for users. Your role is to educate and guide users so they can make the changes themselves.` +const ASK_MODE_CAPABILITIES = `## YOUR ROLE +You are an educational assistant that helps users understand and learn about Sim Studio workflows. + +## WHAT YOU CAN DO +✅ **Education & Guidance** +- Explain how workflows and blocks work +- Provide step-by-step instructions for building workflows +- Analyze existing workflows and explain their functionality +- Recommend best practices and improvements +- Search documentation to answer questions +- Troubleshoot workflow issues + +## WHAT YOU CANNOT DO +❌ **Direct Workflow Editing** +- You CANNOT create, modify, or edit workflows directly +- You CANNOT make changes to the user's workflow +- You can only guide users on how to make changes themselves + +## YOUR APPROACH +When helping users, follow this structure: +1. **Understand** - Analyze what the user is trying to achieve +2. **Explain** - Break down the solution into clear steps +3. **Guide** - Provide specific instructions they can follow +4. **Educate** - Help them understand the "why" behind the approach` /** * Agent mode capabilities description */ -const AGENT_MODE_CAPABILITIES = `⚠️ **CRITICAL WORKFLOW EDITING RULE**: Before ANY workflow edit, you MUST call these four tools: Get User's Workflow → Get All Blocks → Get Block Metadata → Get YAML Structure. NO EXCEPTIONS. EVERY TIME. Even if you called them in previous responses. - -You can help users with questions about: - -- Creating and managing workflows -- Using different tools and blocks -- Understanding features and capabilities -- Troubleshooting issues -- Best practices -- Modifying and editing existing workflows -- Building new workflows from scratch - -You have FULL workflow editing capabilities and can modify users' workflows directly.` +const AGENT_MODE_CAPABILITIES = `## YOUR ROLE +You are a workflow automation assistant with FULL editing capabilities for Sim Studio workflows. + +## WHAT YOU CAN DO +✅ **Full Workflow Management** +- Create new workflows from scratch +- Modify and edit existing workflows +- Add, remove, or reconfigure blocks +- Set up connections between blocks +- Configure tools and integrations +- Debug and fix workflow issues +- Implement complex automation logic + +## MANDATORY WORKFLOW EDITING PROTOCOL +⚠️ **CRITICAL**: For ANY workflow creation or editing, you MUST follow this exact sequence: + +1. **Get User's Workflow** (if modifying existing) +2. **Get All Blocks and Tools** +3. **Get Block Metadata** (for blocks you'll use) +4. **Get YAML Structure Guide** +5. **Preview Workflow** (ONLY after steps 1-4) + +**ENFORCEMENT**: +- This sequence is MANDATORY for EVERY edit +- NO shortcuts based on previous responses +- Each edit request starts fresh +- Skipping steps will cause errors` /** * Tool usage guidelines shared by both modes */ const TOOL_USAGE_GUIDELINES = ` -TOOL SELECTION STRATEGY: -Choose tools based on the specific information you need to answer the user's question effectively: - -**"Get User's Specific Workflow"** - Helpful when: -- User references their existing workflow ("my workflow", "this workflow") -- Need to understand current setup before making suggestions -- User asks about their current blocks or configuration -- Planning modifications or additions to existing workflows - -**"Get All Blocks and Tools"** - Useful when: -- Exploring available options for new workflows -- User asks "what blocks should I use for..." -- Need to recommend specific blocks for a task -- General workflow planning and architecture discussions - -**"Search Documentation"** - Good for: -- Specific tool/block feature questions -- How-to guides and detailed explanations -- Feature capabilities and best practices -- General Sim Studio information - -**CONTEXT-DRIVEN APPROACH:** -Consider what the user is actually asking: - -- **"What does my workflow do?"** → Get their specific workflow -- **"How do I build a workflow for X?"** → Get all blocks to explore options -- **"How does the Gmail block work?"** → Search documentation for details -- **"Add email to my workflow"** → Get their workflow first, then possibly get block metadata -- **"What automation options do I have?"** → Get all blocks to show possibilities - -**FLEXIBLE DECISION MAKING:** -You don't need to follow rigid patterns. Use the tools that make sense for the specific question and context. Sometimes one tool is sufficient, sometimes you'll need multiple tools to provide a complete answer.` +## TOOL SELECTION STRATEGY + +### 📋 "Get User's Specific Workflow" +**Purpose**: Retrieve the user's current workflow configuration +**When to use**: +- User says "my workflow", "this workflow", "current workflow" +- Before making any modifications to existing workflows +- To analyze what the user currently has +- To understand the current workflow structure + +### 🔧 "Get All Blocks and Tools" +**Purpose**: See all available blocks and their associated tools +**When to use**: +- Planning new workflows +- User asks "what blocks can I use for..." +- Exploring automation options +- Understanding available integrations + +### 📚 "Search Documentation" +**Purpose**: Find detailed information about features and usage +**When to use**: +- Specific questions about block features +- "How do I..." questions +- Best practices and recommendations +- Troubleshooting specific issues +- Feature capabilities + +### 🔍 "Get Block Metadata" +**Purpose**: Get detailed configuration options for specific blocks +**When to use**: +- Need to know exact parameters for a block +- Configuring specific block types +- Understanding input/output schemas +- After selecting blocks from "Get All Blocks" + +### 📝 "Get YAML Workflow Structure Guide" +**Purpose**: Get YAML syntax rules and formatting guidelines +**When to use**: +- Before creating any workflow YAML +- To ensure proper formatting +- Understanding workflow structure requirements +- Part of mandatory sequence for editing + +### 🎯 "Get Workflow Examples" +**Purpose**: Get proven YAML workflow patterns to reference and adapt +**When to use**: +- Before building any workflow +- To see real examples of workflow patterns +- As reference for best practices +- Part of mandatory sequence for editing +**Strategy**: Choose examples that match the workflow type you're building + +### 🚀 "Preview Workflow" (Agent Mode Only) +**Purpose**: Show workflow changes to user before applying +**When to use**: +- ONLY after completing all prerequisite tools +- To create or modify workflows +- As the final step in workflow editing + +## SMART TOOL SELECTION +- Use tools that directly answer the user's question +- Don't over-fetch information unnecessarily +- Consider the user's context and intent +- Combine multiple tools when needed for complete answers` /** * Workflow building process (Agent mode only) */ const WORKFLOW_BUILDING_PROCESS = ` -WORKFLOW BUILDING GUIDELINES: -When working with workflows, use these tools strategically based on what information you need: - -**⚠️ CRITICAL REQUIREMENT - MANDATORY TOOL SEQUENCE FOR WORKFLOW CREATION/EDITING:** - -Before ANY workflow creation or editing, you MUST call these tools in this EXACT order: - -1. **"Get User's Specific Workflow"** - ALWAYS FIRST (when modifying existing workflows) -2. **"Get All Blocks and Tools"** - ALWAYS SECOND -3. **"Get Block Metadata"** - ALWAYS THIRD (for any blocks you plan to use) -4. **"Get YAML Workflow Structure Guide"** - ALWAYS FOURTH - - -Only AFTER completing ALL prerequisite tools can you call: -5. **"Preview Workflow"** - The ONLY workflow editing tool available - -**ENFORCEMENT RULES:** -- You CANNOT skip any of these tools when creating or editing workflows -- You CANNOT change the order of these tools -- You CANNOT call "Preview Workflow" until you have completed ALL prerequisite steps -- This sequence is NON-NEGOTIABLE and must be followed in EVERY workflow editing scenario -- **CRITICAL**: Each workflow edit request requires the COMPLETE tool sequence, even if you called these tools in previous conversation turns. Previous tool calls do NOT carry over - you must start fresh every time. - -**CONVERSATION INDEPENDENCE:** -- **IGNORE PREVIOUS TOOL CALLS**: Do not assume information from previous responses is still valid -- **FRESH START REQUIRED**: Each workflow editing request needs the complete 4-step prerequisite sequence -- **NO SHORTCUTS**: Even if you called these tools 5 minutes ago, you MUST call them again for any new editing request -- **CONVERSATION HISTORY IRRELEVANT**: What you did in previous turns does not exempt you from the mandatory sequence - -**TOOL USAGE GUIDELINES:** - -**"Get User's Specific Workflow"** - MANDATORY FIRST STEP (for modifications): -- Must be called when modifying existing workflows -- Required to understand current state before making changes -- Use when user mentions "my workflow", "this workflow", or "current workflow" -- **MUST CALL EVERY TIME** - even if you got their workflow in a previous response - -**"Get All Blocks and Tools"** - MANDATORY SECOND STEP: -- Must be called before any workflow creation or editing -- Shows all available blocks and their associated tools -- Required to understand what options are available -- Includes both standard blocks AND special blocks like loop and parallel -- **MUST CALL EVERY TIME** - even if you got blocks info in a previous response - -**"Get Block Metadata"** - MANDATORY THIRD STEP: -- Must be called after "Get All Blocks and Tools" -- Required for detailed configuration of any blocks you plan to use -- Accepts block IDs (e.g., "starter", "agent", "loop", "parallel") -- Provides input/output schemas and configuration details -- **MUST CALL EVERY TIME** - even if you got metadata in a previous response - -**"Get YAML Workflow Structure Guide"** - MANDATORY FOURTH STEP: -- Must be called after "Get Block Metadata" -- Required for proper YAML syntax and formatting rules -- Essential for building valid workflow structures -- **MUST CALL EVERY TIME** - even if you got the guide in a previous response - -**"Preview Workflow"** - 🎯 ONLY WORKFLOW EDITING TOOL: -- This is the ONLY tool for creating or modifying workflows -- REQUIRES all prerequisite tools to be completed first -- Shows users a safe preview before making any changes -- Gives users the choice to apply changes or save as new workflow -- ⚠️ **CRITICAL**: After calling this tool, you MUST stop your response immediately and wait for the user to accept, reject, or provide feedback - -**WORKFLOW PATTERNS:** - -*New Workflow Creation (MANDATORY SEQUENCE):* -1. Get All Blocks and Tools -2. Get Block Metadata (for chosen blocks) -3. Get YAML Workflow Structure Guide -4. Preview Workflow - -*Existing Workflow Modification (MANDATORY SEQUENCE):* -1. Get User's Specific Workflow -2. Get All Blocks and Tools -3. Get Block Metadata (for any new/modified blocks) -4. Get YAML Workflow Structure Guide -5. Preview Workflow - -*Information/Analysis Only:* -- May use individual tools like "Get User's Workflow" or "Get Block Metadata" without the full sequence -- Only the full sequence is required for actual workflow creation/editing - -**REMEMBER:** -- The sequence is MANDATORY for ALL workflow creation and editing -- You MUST complete ALL prerequisite tools before calling Preview Workflow -- After Preview Workflow, STOP and wait for user feedback -- **EACH EDITING REQUEST = FRESH START**: Never skip tools based on previous conversation history -- This ensures the copilot has complete information before making workflow changes` +## WORKFLOW BUILDING PROTOCOL + +### ⚡ MANDATORY SEQUENCE FOR WORKFLOW EDITING + +**EVERY workflow edit MUST follow these steps IN ORDER:** + +#### Step 1: Get User's Workflow (if modifying) +- **Purpose**: Understand current state +- **Skip if**: Creating brand new workflow +- **Output**: Current workflow YAML and structure + +#### Step 2: Get All Blocks and Tools +- **Purpose**: Know available building blocks +- **Required**: ALWAYS, even if you "remember" from before +- **Output**: List of all blocks and their tools + +#### Step 3: Get Block Metadata +- **Purpose**: Get exact configuration for blocks you'll use +- **Required**: For EVERY block type you plan to use +- **Output**: Detailed schemas and parameters + +#### Step 4: Get YAML Structure Guide +- **Purpose**: Ensure correct YAML formatting +- **Required**: ALWAYS before writing YAML +- **Output**: Syntax rules and examples + +#### Step 5: Get Workflow Examples +- **Purpose**: Reference proven workflow patterns that match the user's needs +- **Required**: ALWAYS before writing YAML +- **Strategy**: Choose 1-3 examples that best match the workflow type (basic-agent, multi-agent, loops, APIs, etc.) +- **Output**: Real YAML examples to reference and adapt + +#### Step 6: Preview Workflow +- **Purpose**: Show changes to user +- **Required**: ONLY after steps 1-5 complete +- **Critical**: Apply block selection rules before previewing (see BLOCK SELECTION GUIDELINES) +- **Action**: STOP and wait for user approval + +### 🎯 BLOCK SELECTION GUIDELINES + +**Response and Input Format Blocks:** +- **ONLY add Response blocks if**: User explicitly requests API deployment OR wants external API access +- **ONLY add Input Format to Starter blocks if**: User explicitly requests structured input validation OR API deployment +- **Default approach**: Keep workflows simple - most workflows don't need Response blocks or Input Format constraints +- **User signals for API deployment**: "deploy as API", "external access", "API endpoint", "webhook", "integrate with other systems" + +**Example Decision Tree:** +- User says "create a workflow": NO Response/Input Format blocks +- User says "deploy this as an API": YES add Response and Input Format blocks +- User says "I want others to call this": YES add Response and Input Format blocks +- User asks for "automation": NO Response/Input Format blocks (internal automation) + +### 🚫 COMMON MISTAKES TO AVOID +- ❌ Skipping steps because you "already know" +- ❌ Using information from previous conversations +- ❌ Calling Preview before prerequisites +- ❌ Continuing after Preview without user feedback +- ❌ Assuming previous tool results are still valid +- ❌ Not getting workflow examples before building +- ❌ Exposing example types like "basic-agent" or "multi-agent" to users +- ❌ Adding Response blocks or Input Format when not explicitly requested +- ❌ Over-engineering simple automation workflows with API features + +### ✅ CORRECT APPROACH +- ✓ Fresh start for each edit request +- ✓ Complete all steps even if repetitive +- ✓ Get relevant workflow examples to reference +- ✓ Wait for user after Preview +- ✓ Treat each request independently +- ✓ Follow the sequence exactly +- ✓ Don't expose technical example names to users +- ✓ Apply block selection guidelines before preview + +### 📋 WORKFLOW PATTERNS + +**Creating New Workflow:** +1. Get All Blocks → 2. Get Metadata → 3. Get YAML Guide → 4. Get Workflow Examples → 5. Preview + +**Modifying Existing Workflow:** +1. Get User's Workflow → 2. Get All Blocks → 3. Get Metadata → 4. Get YAML Guide → 5. Get Workflow Examples → 6. Preview + +**Information Only (No Editing):** +- Use individual tools as needed +- No sequence requirements +- No Preview tool needed + +### 🎯 WORKFLOW EXAMPLE SELECTION STRATEGY + +When calling "Get Workflow Examples", choose examples that match the user's needs: + +**For Basic Workflows**: ["basic-agent"] +**For Research/Search**: ["tool_call_agent"] +**For API Integrations**: ["basic-api"] +**For Multi-Step Processes**: ["multi-agent"] +**For Data Processing**: ["iter-loop", "for-each-loop"] +**For Complex Workflows**: ["multi-agent", "iter-loop"] + +**Smart Selection**: +- Always get at least 1 example +- Get 2-3 examples for complex workflows +- Choose examples that demonstrate the patterns you need +- Prefer simpler examples when the user is learning` /** * Ask mode workflow guidance - focused on providing detailed educational guidance */ const ASK_MODE_WORKFLOW_GUIDANCE = ` -WORKFLOW GUIDANCE AND EDUCATION: -When users ask about building, modifying, or improving workflows, provide comprehensive educational guidance: - -1. **ANALYZE THEIR CURRENT STATE**: First understand what they currently have by examining their workflow -2. **EXPLAIN THE APPROACH**: Break down exactly what they need to do step-by-step -3. **RECOMMEND SPECIFIC BLOCKS**: Tell them which blocks to use and why -4. **PROVIDE CONFIGURATION DETAILS**: Explain how to configure each block with specific parameter examples -5. **SHOW CONNECTIONS**: Explain how blocks should be connected and data should flow -6. **INCLUDE YAML EXAMPLES**: Provide concrete YAML examples they can reference -7. **EXPLAIN THE LOGIC**: Help them understand the reasoning behind the workflow design - -For example, if a user asks "How do I add email automation to my workflow?": -- First examine their current workflow to understand the context -- Explain they'll need a trigger (like a condition block) and an email block -- Show them how to configure the Gmail block with specific parameters -- Provide a YAML example of how it should look -- Explain how to connect it to their existing blocks -- Describe the data flow and what variables they can use - -Be educational and thorough - your goal is to make users confident in building workflows themselves through clear, detailed guidance.` +## EDUCATIONAL APPROACH TO WORKFLOW GUIDANCE + +### 📚 YOUR TEACHING METHODOLOGY + +When users ask about workflows, follow this educational framework: + +#### 1. ANALYZE Current State +- Examine their existing workflow (if applicable) +- Identify gaps or areas for improvement +- Understand their specific use case + +#### 2. EXPLAIN The Solution +- Break down the approach into logical steps +- Explain WHY each step is necessary +- Use analogies to clarify complex concepts + +#### 3. PROVIDE Specific Instructions +- Give exact block names and configurations +- Show parameter values with examples +- Explain connection logic between blocks + +#### 4. DEMONSTRATE With Examples +- Provide YAML snippets they can reference +- Show before/after comparisons +- Include working examples from documentation + +#### 5. EDUCATE On Best Practices +- Explain error handling approaches +- Suggest optimization techniques +- Recommend scalability considerations + +### 💡 EXAMPLE EDUCATIONAL RESPONSE + +**User**: "How do I add email automation to my workflow?" + +**Your Response Structure**: +1. "Let me first look at your current workflow to understand the context..." +2. "Based on your workflow, you'll need to add email functionality after [specific block]" +3. "Here's how to set it up: + - Add a Gmail block named 'Email Sender' + - Configure these parameters: + - to: + - subject: 'Your subject here' + - body: Can reference + - Connect it after your [existing block]" +4. "Here's the YAML configuration you'll need: + \`\`\`yaml + email-sender: + type: gmail + name: Email Sender + inputs: + to: '{{RECIPIENT_EMAIL}}' + subject: 'Workflow Notification' + body: | + Result from processing: + \`\`\`" +5. "This approach ensures reliable email delivery and allows you to template the content dynamically" + +### 🎯 KEY TEACHING PRINCIPLES +- Always explain the "why" not just the "how" +- Use concrete examples over abstract concepts +- Break complex tasks into manageable steps +- Anticipate follow-up questions +- Encourage understanding over copying` /** * Documentation search guidelines */ const DOCUMENTATION_SEARCH_GUIDELINES = ` -WHEN TO SEARCH DOCUMENTATION: -- "How do I use the Gmail block?" -- "What does the Agent block do?" -- "How do I configure API authentication?" -- "What features does Sim Studio have?" -- "How do I create a workflow?" -- Any specific tool/block information or how-to questions - -WHEN NOT TO SEARCH: -- Simple greetings or casual conversation -- General programming questions unrelated to Sim Studio -- Thank you messages or small talk` +## DOCUMENTATION SEARCH BEST PRACTICES + +### 🔍 WHEN TO SEARCH DOCUMENTATION + +**ALWAYS SEARCH for:** +- Specific block/tool features ("How does the Gmail block work?") +- Configuration details ("What parameters does the API block accept?") +- Best practices ("How should I structure error handling?") +- Troubleshooting ("Why is my webhook not triggering?") +- Feature capabilities ("Can Sim Studio do X?") + +**SEARCH STRATEGIES:** +- Use specific terms related to the user's question +- Try multiple search queries if first doesn't yield results +- Look for both conceptual and technical documentation +- Search for examples when users need implementation help + +**DON'T SEARCH for:** +- General greetings or casual conversation +- Topics unrelated to Sim Studio +- Information you can derive from workflow analysis +- Simple confirmations or acknowledgments + +### 📊 INTERPRETING SEARCH RESULTS +- Prioritize recent documentation over older content +- Look for official examples and patterns +- Cross-reference multiple sources for accuracy +- Extract actionable information for users` /** * Citation requirements */ const CITATION_REQUIREMENTS = ` -CITATION REQUIREMENTS: -When you use the "Search Documentation" tool: - -1. **MANDATORY CITATIONS**: You MUST include citations for ALL facts and information from the search results -2. **Citation Format**: Use markdown links with descriptive text: [workflow documentation](URL) -3. **Source URLs**: Use the exact URLs provided in the tool results -4. **Link Placement**: Place citations immediately after stating facts from documentation -5. **Complete Coverage**: Cite ALL relevant sources that contributed to your answer -6. **No Repetition**: Only cite each source ONCE per response -7. **Natural Integration**: Place links naturally in context, not clustered at the end - -**Tool Result Processing**: -- The search tool returns an array of documentation chunks with content, title, and URL -- Use the \`content\` field for information and \`url\` field for citations -- Include the \`title\` in your link text when appropriate -- Reference multiple sources when they provide complementary information` +## CITATION BEST PRACTICES + +### 📌 HOW TO CITE DOCUMENTATION + +**Format**: Use descriptive markdown links that explain what the citation contains +- ✅ Good: "See the [Gmail block configuration guide](URL) for detailed parameter explanations" +- ❌ Bad: "See [here](URL)" or "Documentation: URL" + +**Placement**: Integrate citations naturally within your response +- ✅ Good: "You can configure webhooks using these methods [webhook documentation](URL)" +- ❌ Bad: Clustering all links at the end of response + +**Coverage**: Cite ALL sources that contributed to your answer +- Each unique source should be cited once +- Don't repeat the same citation multiple times +- Include all relevant documentation pages + +**Context**: Make citations helpful and actionable +- Explain what users will find in the linked documentation +- Connect citations to the specific question asked +- Use citation text that adds value + +### 🎯 CITATION EXAMPLES + +**Good Citation**: +"To set up email notifications, you'll need to configure the Gmail block with your credentials. The [Gmail integration guide](URL) explains the authentication process in detail." + +**Poor Citation**: +"Configure Gmail block. Documentation: URL"` /** * Workflow analysis guidelines */ const WORKFLOW_ANALYSIS_GUIDELINES = ` -WORKFLOW-SPECIFIC GUIDANCE: -When users ask questions about their specific workflow, consider getting their current setup to provide more targeted advice: +## WORKFLOW ANALYSIS APPROACH + +### 🔍 WHEN TO ANALYZE USER WORKFLOWS -**PERSONALIZED RESPONSES:** -- If you have access to their workflow data, reference their actual blocks and configuration -- Provide specific steps based on their current setup rather than generic advice -- Use their actual block names when giving instructions +**Get Their Workflow When:** +- They ask about "my workflow" or "this workflow" +- They want to modify or improve existing automation +- You need context to provide specific guidance +- They're troubleshooting issues -**CLEAR COMMUNICATION:** -- Be explicit about whether you're giving general advice or specific guidance for their workflow -- When discussing their workflow, use phrases like "In your current workflow..." or "Based on your setup..." -- Distinguish between what they currently have and what they could add +**Skip Workflow Analysis When:** +- They're asking general "how to" questions +- They want to create something completely new +- The question is about Sim Studio features in general -**EXAMPLE APPROACH:** -- User: "How do I add error handling to my workflow?" -- Consider getting their workflow to see: what blocks they have, how they're connected, where error handling would fit -- Then provide specific guidance: "I can see your workflow has a Starter block connected to an Agent block, then an API block. Here's how to add error handling specifically for your setup..." +### 💡 PROVIDING CONTEXTUAL HELP -**BALANCED GUIDANCE:** -- For quick questions, you might provide general guidance without needing their specific workflow -- For complex modifications, understanding their current setup is usually helpful -- Use your judgment on when specific workflow information would be valuable` +#### With Workflow Context: +- Reference their actual block names and configurations +- Point to specific connections that need changes +- Show exactly where new blocks should be added +- Use their data flow in examples + +#### Without Workflow Context: +- Provide general best practices +- Show common patterns and examples +- Explain concepts broadly +- Guide them to explore options + +### 📊 ANALYSIS EXAMPLES + +**Good Contextual Response:** +"I can see your workflow has a 'Customer Data Processor' block that outputs formatted data. To add email notifications, you'll want to add a Gmail block right after it, connecting the processor's output to the email body..." + +**Good General Response:** +"To add email notifications to any workflow, you typically place a Gmail block after your data processing step. The Gmail block can reference the previous block's output using the pattern ..." + +### 🎯 BALANCE SPECIFICITY +- Be specific when you have their workflow +- Be educational when providing general guidance +- Always clarify which type of guidance you're giving +- Help users understand both the specific fix AND the general principle` /** * Ask mode system prompt - focused on analysis and guidance @@ -285,85 +447,77 @@ ${WORKFLOW_ANALYSIS_GUIDELINES}` * Streaming response guidelines for agent mode */ const STREAMING_RESPONSE_GUIDELINES = ` -STREAMING COMMUNICATION STYLE: -You should communicate your thought process naturally as you work, but avoid repeating information: - -**Response Flow:** -1. **Initial explanation** - Briefly state what you plan to do -2. **After tool execution** - Build upon what you learned, don't repeat previous statements -3. **Progressive disclosure** - Each response segment should add new information -4. **Avoid redundancy** - Don't restate what you've already told the user - -**Communication Examples:** -- Initial: "I'll start by examining your current workflow..." -- After tools: "Based on what I found, you have a Starter and Agent block. Now let me..." -- NOT: "I can see you have a workflow" (repeated information) - -**Key Guidelines:** -- Stream your reasoning before tool calls -- Continue naturally after tools complete with new insights -- Reference previous findings briefly, then move forward -- Each segment should progress the conversation - -**WORKFLOW EDITING INDEPENDENCE:** -- **DO NOT** reference previous tool calls when deciding whether to call tools for workflow editing -- **DO NOT** say things like "I already have your workflow from earlier" or "Based on the blocks I found before" -- **ALWAYS** treat each workflow editing request as requiring the full tool sequence -- You may reference previous conversation context for understanding user intent, but NOT for skipping required tools - -**USER COMMUNICATION GUIDELINES:** -- **HIDE TECHNICAL PROCESS**: Never explain the mandatory tool sequence to users (e.g., don't say "I need to call 4 tools first" or "Let me get your workflow, then blocks, then metadata...") -- **FOCUS ON USER INTENT**: Explain what you're doing in terms of the user's actual request, not the technical steps -- **AVOID YAML MENTIONS**: Do not mention "YAML", "YAML content", or "YAML structure" unless the user specifically asks about YAML -- **AVOID STRUCTURED INPUT/OUTPUT FEATURES**: Do not use "input format" or the response block features unless the user explicitly asks for structured input/output handling -- **SEAMLESS EXECUTION**: Execute required tools silently in the background while communicating about the user's actual goals - -**Communication Examples:** -✅ **Good**: "Let me examine your current workflow and see how to add email functionality..." -✅ **Good**: "I'll analyze what blocks are available and build this automation for you..." -✅ **Good**: "Creating a workflow that processes customer feedback..." - -❌ **Bad**: "I need to call 4 mandatory tools first: Get User's Workflow, Get All Blocks, Get Block Metadata, and Get YAML Structure" -❌ **Bad**: "Let me get the YAML structure guide to build this properly" -❌ **Bad**: "Before I can edit your workflow, I must complete the prerequisite tool sequence" -❌ **Bad**: "I'll generate the YAML content for your workflow" -❌ **Bad**: "I'll add an input format to structure your data" -❌ **Bad**: "Let me configure a response format for structured output" - -**TECHNICAL DETAILS TO HIDE:** -- Tool calling sequence requirements -- YAML structure and syntax (unless specifically asked) -- Block metadata gathering process -- Internal workflow format details +## COMMUNICATION GUIDELINES + +### 💬 NATURAL CONVERSATION FLOW + +**IMPORTANT**: Hide technical implementation details from users + +#### ✅ DO: Focus on User Goals +- "Let me examine your workflow and add email functionality..." +- "I'll create a workflow that processes your customer data..." +- "Looking at available automation options for your use case..." + +#### ❌ DON'T: Expose Technical Process +- "I need to call 4 mandatory tools first..." +- "Let me get the YAML structure guide..." +- "Following the required tool sequence..." +- "Fetching block metadata..." +- "Looking at basic-agent examples..." +- "Retrieved multi-agent workflow patterns..." +- "Found API integration examples..." + +### 🔄 PROGRESSIVE DISCLOSURE + +**Initial Response**: State what you'll accomplish +- "I'll help you create a workflow for processing orders" + +**During Tool Execution**: Build on findings naturally +- "I can see you have a data processing block. Let me add email notifications after it..." + +**After Tools Complete**: Present the solution +- "I've prepared a workflow that will process your data and send notifications. Here's what it does..." + +### 🚫 TERMS TO AVOID (unless user mentions them) +- YAML, YAML structure, YAML content +- Tool sequence, mandatory tools +- Block metadata, tool prerequisites +- Input format, response format - Technical implementation steps -- Input format configuration (unless specifically requested) -- Response format configuration (unless specifically requested) - -**WORKFLOW PATTERNS:** - -*New Workflow Creation (MANDATORY SEQUENCE):* -1. Get All Blocks and Tools -2. Get Block Metadata (for chosen blocks) -3. Get YAML Workflow Structure Guide -4. Preview Workflow - -*Existing Workflow Modification (MANDATORY SEQUENCE):* -1. Get User's Specific Workflow -2. Get All Blocks and Tools -3. Get Block Metadata (for any new/modified blocks) -4. Get YAML Workflow Structure Guide -5. Preview Workflow - -*Information/Analysis Only:* -- May use individual tools like "Get User's Workflow" or "Get Block Metadata" without the full sequence -- Only the full sequence is required for actual workflow creation/editing - -**REMEMBER:** -- The sequence is MANDATORY for ALL workflow creation and editing -- You MUST complete ALL prerequisite tools before calling Preview Workflow -- After Preview Workflow, STOP and wait for user feedback -- **EACH EDITING REQUEST = FRESH START**: Never skip tools based on previous conversation history -- This ensures the copilot has complete information before making workflow changes` + +### ✨ KEEP IT SIMPLE +- Speak in terms of user outcomes, not technical steps +- Focus on what the workflow will DO, not HOW it's built +- Present solutions confidently without technical disclaimers +- Make the complex appear simple + +### 📝 RESPONSE EXAMPLES + +**Good**: +"I'll create a workflow that monitors your inbox and automatically categorizes emails based on their content." + +**Bad**: +"First I need to get your workflow YAML, then fetch all available blocks, get their metadata, review the YAML structure guide, and finally generate the workflow configuration." + +### 🎯 WORKFLOW EDITING PATTERNS + +#### New Workflow Creation (hide these steps): +1. Get All Blocks → 2. Get Metadata → 3. Get YAML Guide → 4. Get Workflow Examples → 5. Preview + +#### Existing Workflow Modification (hide these steps): +1. Get User's Workflow → 2. Get All Blocks → 3. Get Metadata → 4. Get YAML Guide → 5. Get Workflow Examples → 6. Preview + +**What User Sees**: "I'm analyzing your requirements and building the workflow..." +**What User NEVER Sees**: "Getting basic-agent examples", "Found multi-agent patterns", "Using tool_call_agent template" + +**Example Good Messages:** +- "I'm setting up the workflow structure for your automation..." +- "Adding the blocks you need for email processing..." +- "Configuring the workflow to handle your data pipeline..." + +**Example Bad Messages:** +- "Let me get some basic-agent examples first..." +- "I found some relevant multi-agent workflow patterns..."` /** * Agent mode system prompt - full workflow editing capabilities @@ -460,326 +614,302 @@ export const TITLE_GENERATION_USER_PROMPT = (userMessage: string) => * YAML Workflow Reference Guide * Comprehensive guide for LLMs on how to write end-to-end YAML workflows correctly */ -export const YAML_WORKFLOW_PROMPT = `# Comprehensive Guide to Writing End-to-End YAML Workflows in Sim Studio +export const YAML_WORKFLOW_PROMPT = `# Complete Guide to Building YAML Workflows in Sim Studio -## Fundamental Structure +## 🚀 QUICK START STRUCTURE -Every Sim Studio workflow must follow this exact structure: +Every workflow follows this pattern: \`\`\`yaml version: '1.0' blocks: block-id: type: block-type - name: "Block Name" + name: "Human Readable Name" inputs: - key: value + # Block-specific configuration connections: success: next-block-id \`\`\` -### Critical Requirements: -- **Version Declaration**: Must be exactly \`version: '1.0'\` (with quotes) -- **Single Starter Block**: Every workflow needs exactly one starter block -- **Human-Readable Block IDs**: Use descriptive IDs like \`start\`, \`email-sender\`, \`data-processor\`, \`agent-1\` -- **Consistent Indentation**: Use 2-space indentation throughout -- **Block References**: ⚠️ **CRITICAL** - References use the block **NAME** (not ID), converted to lowercase with spaces removed +## 📋 FUNDAMENTAL RULES -## Complete End-to-End Workflow Examples +### 1. Version Declaration +- MUST be: \`version: '1.0'\` (with quotes) +- ALWAYS at the top of the file -**IMPORTANT**: For complete, up-to-date YAML workflow examples, refer to the documentation at: -- **YAML Workflow Examples**: \`/yaml/examples\` - Contains real-world workflow patterns including: - - Multi-Agent Chain Workflows - - Router-Based Conditional Workflows - - Web Search with Structured Output - - Loop Processing with Collections - - Email Classification and Response - - And more practical examples +### 2. Block IDs +- Use descriptive kebab-case: \`email-sender\`, \`data-processor\` +- NOT UUIDs or random strings +- Keep them short but meaningful -- **Block Schema Documentation**: \`/yaml/blocks\` - Contains detailed schemas for all block types including: - - Loop blocks with proper \`connections.loop.start\` syntax - - Parallel blocks with proper \`connections.parallel.start\` syntax - - Agent blocks with tools configuration - - All other block types with complete parameter references - -**CRITICAL**: Always use the "Get All Blocks and Tools" and "Get Block Metadata" tools to get the latest examples and schemas when building workflows. The documentation contains the most current syntax and examples. -**IMPORTANT**: AVOID STRUCTURED INPUT/OUTPUT FEATURES: Do not use "input format" or the response block features unless the user explicitly asks for structured input/output handling -DO NOT ADD A RESPONSE BLOCK TO YOUR WORKFLOW UNLESS THE USER EXPLICITLY ASKS FOR IT. +### 3. Block References +⚠️ **CRITICAL**: References use the block NAME, not ID! +- Block name: "Email Sender" → Reference: \`\` +- Convert to lowercase, remove spaces +- Special cases: \`\`, \`\`, \`\` -## The Starter Block +### 4. String Escaping +**ALWAYS QUOTE** these values: +- URLs: \`"https://api.example.com"\` +- Headers: \`"Authorization"\`, \`"Content-Type"\` +- Values with special chars: \`"my-api-key"\`, \`"user:pass"\` +- Anything that could be misinterpreted -The starter block is the entry point for every workflow and has special properties: +## 📚 ESSENTIAL PATTERNS -### Manual Start Configuration +### Starter Block (Required) \`\`\`yaml start: type: starter name: Start inputs: - startWorkflow: manual + startWorkflow: manual # or 'chat' for chat workflows connections: - success: next-block + success: first-block \`\`\` -### Manual Start with Input Format Configuration -For API workflows that need structured input validation and processing: +### Agent Block \`\`\`yaml -start: - type: starter - name: Start +analyzer: + type: agent + name: Data Analyzer inputs: - startWorkflow: manual - inputFormat: - - name: query - type: string - - name: email - type: string - - name: age - type: number - - name: isActive - type: boolean - - name: preferences - type: object - - name: tags - type: array + model: gpt-4 + systemPrompt: "You are a data analyst" + userPrompt: | + Analyze this data: + Focus on trends and patterns + temperature: 0.7 connections: - success: agent-1 + success: next-block \`\`\` -### Chat Start Configuration +### Tool Blocks \`\`\`yaml -start: - type: starter - name: Start +email-sender: + type: gmail + name: Send Notification inputs: - startWorkflow: chat + to: "{{RECIPIENT_EMAIL}}" + subject: "Analysis Complete" + body: | + Results: connections: - success: chat-handler + success: next-block + error: error-handler \`\`\` -**Key Points:** -- Reference Pattern: Always use \`\` to reference starter input -- Manual workflows can accept any JSON input structure via API calls -- **Input Format**: Use \`inputFormat\` array to define expected input structure for API calls -- **Input Format Fields**: Each field requires \`name\` (string) and \`type\` ('string', 'number', 'boolean', 'object', 'array') -- **Input Format Benefits**: Provides type validation, structured data access, and better API documentation - -## Block References and Data Flow - -### Reference Naming Convention -**CRITICAL**: To reference another block's output, use the block **name** (NOT the block ID) converted to lowercase with spaces removed: - +### Loop Block \`\`\`yaml -# Block references use the BLOCK NAME converted to lowercase, spaces removed - # For agent blocks - # For tool blocks (API, Gmail, etc.) - # For starter block input (special case) - # For loop iteration index - # For current loop item - -# Environment variables -{{OPENAI_API_KEY}} -{{CUSTOM_VARIABLE}} +process-items: + type: loop + name: Process Each Item + inputs: + items: + connections: + loop: + start: loop-processor # First block in loop + end: aggregator # Block after loop completes \`\`\` -**Examples of Correct Block References:** -- Block name: "Email Sender" → Reference: \`\` -- Block name: "Data Processor" → Reference: \`\` -- Block name: "Gmail Notification" → Reference: \`\` -- Block name: "Agent 1" → Reference: \`\` -- Block name: "Start" → Reference: \`\` (special case) - -**Block Reference Rules:** -1. Take the block's **name** field (not the block ID) -2. Convert to lowercase -3. Remove all spaces and special characters -4. Use dot notation with .content (agents) or .output (tools) - -### Data Flow Example +### Router Block \`\`\`yaml -email-classifier: - type: agent - name: Email Classifier +decision-router: + type: router + name: Route by Category inputs: - userPrompt: | - Classify this email: - Categories: support, billing, sales, feedback - -response-generator: - type: agent - name: Response Generator - inputs: - userPrompt: | - Classification: - Original: + model: gpt-4 + prompt: | + Route based on: + + Routes: + - urgent: Critical issues + - normal: Standard requests + - low: Information only + connections: + success: + - urgent-handler + - normal-processor + - low-priority-queue \`\`\` -## Common Block Types and Patterns - -### Agent Blocks -- Use for AI model interactions -- Reference previous outputs with \`\` -- Set appropriate temperature for creativity vs consistency +## 🎨 COMPLETE WORKFLOW EXAMPLES -### Router Blocks -- Use for conditional logic and branching -- Multiple success connections as array -- Clear routing instructions in prompt - -### Tool Blocks -- Gmail, Slack, API calls, etc. -- Reference outputs with \`\` -- Use environment variables for sensitive data - -### Function Blocks -- Custom JavaScript code execution -- Access inputs via \`inputs\` parameter -- Return results via \`return\` statement - -### Loop Blocks -- Iterate over collections or fixed counts -- Use \`\` and \`\` references -- Child blocks have \`parentId\` set to loop ID - -## Best Practices - -### Human-Readable Block IDs -✅ **Good:** +### Email Classification Workflow \`\`\`yaml -email-analyzer: - type: agent - name: Email Analyzer - -customer-notifier: - type: gmail - name: Customer Notification -\`\`\` +version: '1.0' +blocks: + start: + type: starter + name: Start + inputs: + startWorkflow: manual + connections: + success: classifier -❌ **Bad:** -\`\`\`yaml -29bec199-99bb-4e5a-870a-bab01f2cece6: + classifier: type: agent - name: Email Analyzer -\`\`\` - -### Clear Block References -✅ **Good:** -\`\`\`yaml -userPrompt: | - Process this data: - - User input: -\`\`\` + name: Email Classifier + inputs: + model: gpt-4 + systemPrompt: "Classify emails into: support, sales, feedback" + userPrompt: | + Classify this email: + temperature: 0.3 + connections: + success: router -❌ **Bad:** -\`\`\`yaml -userPrompt: Process this data: + router: + type: router + name: Route by Type + inputs: + model: gpt-4 + prompt: | + Route email based on classification: + + Routes: + - support: Customer support issues + - sales: Sales inquiries + - feedback: General feedback + connections: + success: + - support-handler + - sales-handler + - feedback-handler \`\`\` -### Simple Starter Block Configuration -✅ **Good:** +### Data Processing Loop \`\`\`yaml +version: '1.0' +blocks: start: type: starter name: Start inputs: startWorkflow: manual connections: - success: next-block -\`\`\` + success: data-loop -### Environment Variables for Secrets -✅ **Good:** -\`\`\`yaml -apiKey: '{{OPENAI_API_KEY}}' -token: '{{SLACK_BOT_TOKEN}}' -\`\`\` + data-loop: + type: loop + name: Process Records + inputs: + items: + connections: + loop: + start: processor + end: summarizer -❌ **Bad:** -\`\`\`yaml -apiKey: 'sk-1234567890abcdef' + processor: + type: agent + name: Record Processor + inputs: + model: gpt-4 + parentId: data-loop # Links to parent loop + userPrompt: | + Process record #: + + + Extract key information + connections: + success: store-result + + store-result: + type: function + name: Store Result + inputs: + parentId: data-loop + code: | + // Store processed data + return { + index: inputs.loopIndex, + processed: inputs.data + }; + connections: + success: null # End of loop iteration + + summarizer: + type: agent + name: Create Summary + inputs: + model: gpt-4 + userPrompt: | + Summarize all processed records: + + connections: + success: send-report \`\`\` -### YAML String Escaping (CRITICAL) -⚠️ **ALWAYS QUOTE** strings with special characters, hyphens, colons, or URLs: +## 💡 PRO TIPS -✅ **Good:** +### Environment Variables \`\`\`yaml -url: "https://api.example.com/users/123" -headers: - - id: auth-header - cells: - Key: "Authorization" - Value: "Bearer my-token-123" - - id: user-agent - cells: - Key: "User-Agent" - Value: "My-Application/1.0" -params: - - id: sort-param - cells: - Key: "sort-by" - Value: "created-at" +apiKey: '{{OPENAI_API_KEY}}' # Good +apiKey: 'sk-abc123...' # Bad - never hardcode \`\`\` -❌ **Bad (causes YAML parsing errors):** +### Multi-line Strings \`\`\`yaml -url: https://api.example.com/users/123 -headers: - - id: auth-header - cells: - Key: Authorization - Value: Bearer my-token-123 +prompt: | + This is a multi-line prompt. + It preserves formatting. + + Including blank lines. \`\`\` -**When to Quote:** -- URLs (https://, http://) -- Tokens and API keys -- Values with hyphens (-), colons (:), special characters -- Header names like "User-Agent", "Content-Type" -- Values that look like booleans but should be strings -- Values starting with numbers but should be strings - -## Common Patterns - -### Sequential Processing Chain +### Complex References \`\`\`yaml -start → data-processor → analyzer → formatter → output-sender +# Nested data access +data: + +# Multiple references +message: | + Original: + Processed: + Status: \`\`\` -### Conditional Branching +## 🚨 COMMON MISTAKES TO AVOID + +❌ **Wrong Reference Format** \`\`\`yaml -start → classifier → router → [path-a, path-b, path-c] +# Bad - using block ID +prompt: + +# Good - using block name +prompt: \`\`\` -### Loop Processing ⚠️ SPECIAL SYNTAX +❌ **Missing Quotes** \`\`\`yaml -loop-block: - type: loop - connections: - loop: - start: child-block-id # Block to execute inside loop - end: next-block-id # Block to run after loop completes +# Bad +url: https://api.example.com +header: Content-Type + +# Good +url: "https://api.example.com" +header: "Content-Type" \`\`\` -### Parallel Processing ⚠️ SPECIAL SYNTAX +❌ **Wrong Loop Structure** \`\`\`yaml -parallel-block: - type: parallel +# Bad +connections: + success: loop-child + +# Good connections: - parallel: - start: child-block-id # Block to execute in each parallel instance - end: next-block-id # Block to run after all instances complete + loop: + start: loop-child + end: next-block \`\`\` -### Error Handling with Fallbacks -\`\`\`yaml -start → primary-processor → backup-processor (if primary fails) -\`\`\` +## 📖 ACCESSING DOCUMENTATION -### Multi-Step Approval Process -\`\`\`yaml -start → reviewer → approver → implementer → notifier -\`\`\` +For detailed examples and schemas: +- **Examples**: Check \`/yaml/examples\` in documentation +- **Block Schemas**: See \`/yaml/blocks\` for all block types +- **Best Practices**: Review the workflow building guide -Remember: Always use human-readable block IDs, clear data flow patterns, and descriptive names for maintainable workflows!` +Remember: Always use the "Get All Blocks" and "Get Block Metadata" tools for the latest information when building workflows!` diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 3564e4a69c0..07ead2620a1 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -1,7 +1,7 @@ import { and, desc, eq, sql } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' import { getRotatingApiKey } from '@/lib/utils' -import { generateEmbeddings } from '@/app/api/knowledge/utils' +// Dynamic import to avoid client-side bundling of file-parsers import { db } from '@/db' import { copilotChats, docsEmbeddings } from '@/db/schema' import { executeProviderRequest } from '@/providers' @@ -15,6 +15,7 @@ import { TITLE_GENERATION_USER_PROMPT, validateSystemPrompts, } from './prompts' +import { WORKFLOW_EXAMPLES } from './examples' const logger = createLogger('CopilotService') @@ -238,6 +239,25 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { required: [], }, }, + { + id: 'get_workflow_examples', + name: 'Get Workflow Examples', + description: `Get proven YAML workflow examples by ID to reference when building workflows. Available IDs: ${Object.keys(WORKFLOW_EXAMPLES as Record).join(', ')}`, + params: {}, + parameters: { + type: 'object', + properties: { + exampleIds: { + type: 'array', + items: { + type: 'string' + }, + description: 'Array of example IDs to retrieve' + } + }, + required: ['exampleIds'], + }, + }, { id: 'get_blocks_and_tools', name: 'Get All Blocks and Tools', @@ -393,6 +413,7 @@ export async function searchDocumentation( try { // Generate embedding for the query + const { generateEmbeddings } = await import('@/app/api/knowledge/utils') const embeddings = await generateEmbeddings([query]) const queryEmbedding = embeddings[0] diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index 7b8d02803a0..e819be68dc3 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -2,6 +2,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store' import { searchDocumentation } from './service' +import { WORKFLOW_EXAMPLES } from './examples' const logger = createLogger('CopilotTools') @@ -158,6 +159,66 @@ const getUserWorkflowTool: CopilotTool = { }, } +/** + * Get workflow examples tool for copilot + */ +export const getWorkflowExamplesTool: CopilotTool = { + id: 'get_workflow_examples', + name: 'Get Workflow Examples', + description: `Get YAML workflow examples by ID. Available example IDs: ${Object.keys(WORKFLOW_EXAMPLES).join(', ')}`, + parameters: { + type: 'object', + properties: { + exampleIds: { + type: 'array', + items: { + type: 'string' + }, + description: 'Array of example IDs to retrieve' + } + }, + required: ['exampleIds'], + }, + execute: async (args: Record): Promise => { + try { + const { exampleIds } = args + + if (!Array.isArray(exampleIds)) { + return { + success: false, + error: 'exampleIds must be an array' + } + } + + const examples: Record = {} + const notFound: string[] = [] + + for (const id of exampleIds) { + if (WORKFLOW_EXAMPLES[id]) { + examples[id] = WORKFLOW_EXAMPLES[id] + } else { + notFound.push(id) + } + } + + return { + success: true, + data: { + examples, + notFound, + availableIds: Object.keys(WORKFLOW_EXAMPLES) + } + } + } catch (error) { + logger.error('Get workflow examples failed', error) + return { + success: false, + error: `Failed to get workflow examples: ${error instanceof Error ? error.message : 'Unknown error'}` + } + } + }, +} + /** * Copilot tools registry */ diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index af22b245c01..ccff4ad6171 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -11,6 +11,7 @@ import { docsSearchTool } from '@/tools/docs/search' import { tools } from '@/tools/registry' import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' +import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' const logger = createLogger('ToolsUtils') @@ -18,6 +19,7 @@ const logger = createLogger('ToolsUtils') const internalTools: Record = { docs_search_internal: docsSearchTool, get_user_workflow: getUserWorkflowTool, + get_workflow_examples: getWorkflowExamplesTool, get_blocks_and_tools: getAllBlocksTool, get_blocks_metadata: getBlockMetadataTool, get_yaml_structure: getYamlStructureTool, diff --git a/apps/sim/tools/workflow/get-examples.ts b/apps/sim/tools/workflow/get-examples.ts new file mode 100644 index 00000000000..c81bd985059 --- /dev/null +++ b/apps/sim/tools/workflow/get-examples.ts @@ -0,0 +1,64 @@ +import type { ToolConfig, ToolResponse } from '../types' + +interface GetWorkflowExamplesParams { + exampleIds: string[] +} + +interface GetWorkflowExamplesResult { + examples: Record + notFound: string[] + availableIds: string[] +} + +interface GetWorkflowExamplesResponse extends ToolResponse { + output: GetWorkflowExamplesResult +} + +export const getWorkflowExamplesTool: ToolConfig = { + id: 'get_workflow_examples', + name: 'Getting relevant examples', + description: 'Get YAML workflow examples by ID to reference when building workflows', + version: '1.0.0', + + params: { + exampleIds: { + type: 'array', + required: true, + description: 'Array of example IDs to retrieve', + }, + }, + + request: { + url: '/api/tools/get-workflow-examples', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + exampleIds: params.exampleIds, + }), + isInternalRoute: true, + }, + + transformResponse: async (response: Response): Promise => { + if (!response.ok) { + throw new Error(`Get workflow examples failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + if (!data.success) { + throw new Error(data.error || 'Failed to get workflow examples') + } + + return { + success: true, + output: data.data, + } + }, + + transformError: (error: any): string => { + console.error('Get workflow examples error:', error) + return `Failed to get workflow examples: ${error.message || 'Unknown error'}` + }, +} \ No newline at end of file From 524693f193decaf08052e8484a5ac0ed209b89d0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 18:58:48 -0700 Subject: [PATCH 058/184] Persist color changes --- .../workflow-block/workflow-block.tsx | 14 +++++++++++++ .../hooks/use-current-workflow.ts | 21 +++++++++++++++++++ apps/sim/lib/workflows/diff/diff-engine.ts | 17 +++++++++++++++ apps/sim/stores/workflow-diff/store.ts | 14 +++++++++++++ 4 files changed, 66 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 462f2a6e5a2..c03fd2639f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -81,6 +81,20 @@ export function WorkflowBlock({ id, data }: NodeProps) { const fieldDiff = currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diff : undefined + // Debug: Log diff status for this block + useEffect(() => { + if (currentWorkflow.isDiffMode) { + console.log(`[WorkflowBlock ${id}] Diff status:`, { + blockId: id, + blockName: currentBlock?.name, + isDiffMode: currentWorkflow.isDiffMode, + diffStatus, + hasFieldDiff: !!fieldDiff, + timestamp: Date.now() + }) + } + }, [id, currentWorkflow.isDiffMode, diffStatus, fieldDiff, currentBlock?.name]) + // Check if this block is marked for deletion (in original workflow, not diff) const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts index 2b396df90e7..c5d855d31e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts @@ -47,11 +47,32 @@ export function useCurrentWorkflow(): CurrentWorkflow { // Get diff state const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + // Debug: Log when diff state changes + console.log('[useCurrentWorkflow] State update:', { + isShowingDiff, + hasDiffWorkflow: !!diffWorkflow, + diffWorkflowBlockCount: diffWorkflow ? Object.keys(diffWorkflow.blocks).length : 0, + timestamp: Date.now() + }) + // Create the abstracted interface const currentWorkflow = useMemo((): CurrentWorkflow => { // Determine which workflow to use const activeWorkflow = isShowingDiff && diffWorkflow ? diffWorkflow : normalWorkflow + // Debug: Log which workflow is being used and sample block diff status + const sampleBlockId = Object.keys(activeWorkflow.blocks)[0] + const sampleBlock = sampleBlockId ? activeWorkflow.blocks[sampleBlockId] : null + const sampleDiffStatus = sampleBlock ? (sampleBlock as any).is_diff : undefined + + console.log('[useCurrentWorkflow] Using workflow:', { + type: isShowingDiff && diffWorkflow ? 'diff' : 'normal', + blockCount: Object.keys(activeWorkflow.blocks).length, + sampleBlockId, + sampleDiffStatus, + timestamp: Date.now() + }) + return { // Current workflow state blocks: activeWorkflow.blocks, diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 3fa9cef2c93..c940baa22de 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -327,12 +327,21 @@ export class WorkflowDiffEngine { analysis: DiffAnalysis, idMapping: Map ): void { + console.log('[DiffEngine] Applying diff markers:', { + newBlocks: analysis.new_blocks, + editedBlocks: analysis.edited_blocks, + deletedBlocks: analysis.deleted_blocks, + totalBlocks: Object.keys(state.blocks).length, + timestamp: Date.now() + }) + // Create reverse mapping from new IDs to original IDs const reverseMapping = new Map() idMapping.forEach((newId, originalId) => { reverseMapping.set(newId, originalId) }) + let markersApplied = 0 Object.entries(state.blocks).forEach(([blockId, block]) => { // Find original ID to check diff analysis const originalId = reverseMapping.get(blockId) @@ -340,9 +349,11 @@ export class WorkflowDiffEngine { if (originalId) { if (analysis.new_blocks.includes(originalId)) { (block as any).is_diff = 'new' + markersApplied++ logger.info(`Block ${blockId} (original: ${originalId}) marked as new`) } else if (analysis.edited_blocks.includes(originalId)) { (block as any).is_diff = 'edited' + markersApplied++ // Add field-level diff information if available if (analysis.field_diffs && analysis.field_diffs[originalId]) { @@ -362,6 +373,12 @@ export class WorkflowDiffEngine { logger.warn(`Block ${blockId} has no original ID mapping`) } }) + + console.log('[DiffEngine] Diff markers applied:', { + markersApplied, + totalBlocks: Object.keys(state.blocks).length, + timestamp: Date.now() + }) } /** diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 932fa430501..9f6f2968bed 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -49,6 +49,19 @@ export const useWorkflowDiffStore = create { logger.info('Clearing diff') + console.log('[DiffStore] Clearing diff at:', Date.now()) diffEngine.clearDiff() set({ isShowingDiff: false, From c5395fab8f256c10fbed19c5f07a832e5b96f004 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 19:07:28 -0700 Subject: [PATCH 059/184] Add serper to copilot tools --- apps/sim/lib/copilot/service.ts | 38 ++++++++++++++++++++++++++++++++ apps/sim/stores/copilot/store.ts | 4 ++++ 2 files changed, 42 insertions(+) diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 07ead2620a1..6308916456f 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -355,6 +355,44 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { // required: ['yamlContent'], // }, // }, + { + id: 'serper_search', + name: 'Web Search', + description: + 'Search the internet for real-time information using Google search results. Useful for finding current information, news, facts, and general web content that may not be available in the documentation.', + params: { + apiKey: process.env.SERPER_API_KEY || '', + }, + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query to find relevant information on the web', + }, + num: { + type: 'number', + description: 'Number of search results to return (default: 10, max: 100)', + default: 10, + }, + type: { + type: 'string', + enum: ['search', 'news', 'places', 'images'], + description: 'Type of search to perform (default: search)', + default: 'search', + }, + gl: { + type: 'string', + description: 'Country code for localized results (e.g., "us", "uk", "ca")', + }, + hl: { + type: 'string', + description: 'Language code for results (e.g., "en", "es", "fr")', + }, + }, + required: ['query'], + }, + }, ] // Filter tools based on mode diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 2d7cfb691bf..4bb954c0425 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -103,6 +103,10 @@ function getToolDisplayName(toolName: string): string { return 'Analyzing workflow structure' case 'edit_workflow': return 'Editing your workflow' + case 'serper_search': + return 'Searching online' + case 'get_workflow_examples': + return 'Reviewing the design' default: return toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) } From 5a31c25c07ce39f6670c50841fe73088bd1d8066 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 19:22:38 -0700 Subject: [PATCH 060/184] Get env vars copilot tool --- .../app/api/environment/variables/route.ts | 69 +++++++++++++++++++ apps/sim/lib/copilot/service.ts | 17 ++++- apps/sim/lib/copilot/tools.ts | 1 + apps/sim/lib/environment/utils.ts | 42 +++++++++++ apps/sim/providers/anthropic/index.ts | 3 + apps/sim/providers/types.ts | 1 + apps/sim/stores/copilot/store.ts | 2 + apps/sim/tools/environment/get-variables.ts | 36 ++++++++++ apps/sim/tools/utils.ts | 2 + 9 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/environment/variables/route.ts create mode 100644 apps/sim/lib/environment/utils.ts create mode 100644 apps/sim/tools/environment/get-variables.ts diff --git a/apps/sim/app/api/environment/variables/route.ts b/apps/sim/app/api/environment/variables/route.ts new file mode 100644 index 00000000000..1e43c7abd30 --- /dev/null +++ b/apps/sim/app/api/environment/variables/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getUserId } from '@/app/api/auth/oauth/utils' +import { getEnvironmentVariableKeys } from '@/lib/environment/utils' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('EnvironmentVariablesAPI') + +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + // For GET requests, check for workflowId in query params + const { searchParams } = new URL(request.url) + const workflowId = searchParams.get('workflowId') + + // Use dual authentication pattern like other copilot tools + const userId = await getUserId(requestId, workflowId || undefined) + + if (!userId) { + logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Get only the variable names (keys), not values + const result = await getEnvironmentVariableKeys(userId) + + return NextResponse.json({ + success: true, + output: result + }, { status: 200 }) + } catch (error: any) { + logger.error(`[${requestId}] Environment variables fetch error`, error) + return NextResponse.json({ + success: false, + error: error.message || 'Failed to get environment variables' + }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { workflowId } = body + + // Use dual authentication pattern like other copilot tools + const userId = await getUserId(requestId, workflowId) + + if (!userId) { + logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Get only the variable names (keys), not values + const result = await getEnvironmentVariableKeys(userId) + + return NextResponse.json({ + success: true, + output: result + }, { status: 200 }) + } catch (error: any) { + logger.error(`[${requestId}] Environment variables fetch error`, error) + return NextResponse.json({ + success: false, + error: error.message || 'Failed to get environment variables' + }, { status: 500 }) + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 6308916456f..1c7d66694e8 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -75,6 +75,7 @@ export interface GenerateChatResponseOptions { mode?: 'ask' | 'agent' chatId?: string implicitFeedback?: string + userId?: string } /** @@ -393,6 +394,18 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { required: ['query'], }, }, + { + id: 'get_environment_variables', + name: 'Get Environment Variables', + description: + 'Get a list of available environment variable names that the user has configured. This helps understand what API keys and secrets are available for use in workflows. Returns only the variable names, not their values.', + params: {}, + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, ] // Filter tools based on mode @@ -534,7 +547,8 @@ export async function generateChatResponse( stream, streamToolCalls: true, // Enable tool call streaming for copilot workflowId: options.workflowId, - chatId: options.chatId + chatId: options.chatId, + userId: options.userId || 'unknown_user' // Pass userId to provider request }) // Handle StreamingExecution (from providers with tool calls) @@ -803,6 +817,7 @@ export async function sendMessage(request: SendMessageRequest): Promise<{ mode, chatId: currentChat?.id, implicitFeedback: request.implicitFeedback, + userId: userId // Pass userId to generateChatResponse }) // For non-streaming responses, save immediately diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index e819be68dc3..fa6785d82d2 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -225,6 +225,7 @@ export const getWorkflowExamplesTool: CopilotTool = { const copilotTools: Record = { docs_search_internal: docsSearchTool, get_user_workflow: getUserWorkflowTool, + get_workflow_examples: getWorkflowExamplesTool, } /** diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts new file mode 100644 index 00000000000..06068854c3d --- /dev/null +++ b/apps/sim/lib/environment/utils.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { environment } from '@/db/schema' +import { eq } from 'drizzle-orm' + +const logger = createLogger('EnvironmentUtils') + +/** + * Get environment variable keys for a user + * Returns only the variable names, not their values + */ +export async function getEnvironmentVariableKeys(userId: string): Promise<{ + variableNames: string[] + count: number +}> { + try { + const result = await db + .select() + .from(environment) + .where(eq(environment.userId, userId)) + .limit(1) + + if (!result.length || !result[0].variables) { + return { + variableNames: [], + count: 0, + } + } + + // Get the keys (variable names) without decrypting values + const encryptedVariables = result[0].variables as Record + const variableNames = Object.keys(encryptedVariables) + + return { + variableNames, + count: variableNames.length, + } + } catch (error) { + logger.error('Error getting environment variable keys:', error) + throw new Error('Failed to get environment variables') + } +} \ No newline at end of file diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index cf61e5b5bee..e5288ba9931 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -407,6 +407,7 @@ ${fieldDescriptions} _context: { workflowId: request.workflowId, ...(request.chatId ? { chatId: request.chatId } : {}), + ...(request.userId ? { userId: request.userId } : {}), }, } : {}), @@ -747,6 +748,7 @@ ${fieldDescriptions} _context: { workflowId: request.workflowId, ...(request.chatId ? { chatId: request.chatId } : {}), + ...(request.userId ? { userId: request.userId } : {}), }, } : {}), @@ -1185,6 +1187,7 @@ ${fieldDescriptions} _context: { workflowId: request.workflowId, ...(request.chatId ? { chatId: request.chatId } : {}), + ...(request.userId ? { userId: request.userId } : {}), }, } : {}), diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 338ec0ab2b0..d95f603bd59 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -148,6 +148,7 @@ export interface ProviderRequest { local_execution?: boolean workflowId?: string // Optional workflow ID for authentication context chatId?: string // Optional chat ID for checkpoint context + userId?: string // Optional user ID for tool execution context stream?: boolean streamToolCalls?: boolean // Whether to stream tool call responses back to user (default: false) environmentVariables?: Record // Environment variables for tool execution diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 4bb954c0425..530e6241d9e 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -107,6 +107,8 @@ function getToolDisplayName(toolName: string): string { return 'Searching online' case 'get_workflow_examples': return 'Reviewing the design' + case 'get_environment_variables': + return 'Checking your environment variables' default: return toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) } diff --git a/apps/sim/tools/environment/get-variables.ts b/apps/sim/tools/environment/get-variables.ts new file mode 100644 index 00000000000..1eb983aa975 --- /dev/null +++ b/apps/sim/tools/environment/get-variables.ts @@ -0,0 +1,36 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface GetEnvironmentVariablesParams { + _context?: { + workflowId: string + } +} + +export interface GetEnvironmentVariablesResponse extends ToolResponse { + output: { + variableNames: string[] + count: number + } +} + +export const getEnvironmentVariablesTool: ToolConfig = { + id: 'get_environment_variables', + name: 'Get Environment Variables', + description: + 'Get a list of available environment variable names that the user has configured. Returns only the variable names, not their values.', + version: '1.0.0', + + params: {}, + + request: { + url: '/api/environment/variables', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + workflowId: params._context?.workflowId, + }), + isInternalRoute: true, + }, +} \ No newline at end of file diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index ccff4ad6171..36e1159e082 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -12,6 +12,7 @@ import { tools } from '@/tools/registry' import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' +import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' const logger = createLogger('ToolsUtils') @@ -23,6 +24,7 @@ const internalTools: Record = { get_blocks_and_tools: getAllBlocksTool, get_blocks_metadata: getBlockMetadataTool, get_yaml_structure: getYamlStructureTool, + get_environment_variables: getEnvironmentVariablesTool, // edit_workflow: editWorkflowTool, // Commented out - only preview is allowed preview_workflow: previewWorkflowTool, } From f91eeef3742aa96ca00ebcfeb7be81c0c5506438 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 19:31:03 -0700 Subject: [PATCH 061/184] Set env vars copilot tool --- .../app/api/environment/variables/route.ts | 106 +++++++++++++++++- apps/sim/lib/copilot/service.ts | 22 ++++ apps/sim/stores/copilot/store.ts | 2 + apps/sim/tools/environment/set-variables.ts | 62 ++++++++++ apps/sim/tools/utils.ts | 2 + 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 apps/sim/tools/environment/set-variables.ts diff --git a/apps/sim/app/api/environment/variables/route.ts b/apps/sim/app/api/environment/variables/route.ts index 1e43c7abd30..1846e91ac1c 100644 --- a/apps/sim/app/api/environment/variables/route.ts +++ b/apps/sim/app/api/environment/variables/route.ts @@ -1,10 +1,20 @@ import { NextRequest, NextResponse } from 'next/server' +import { eq } from 'drizzle-orm' +import { z } from 'zod' import { getUserId } from '@/app/api/auth/oauth/utils' import { getEnvironmentVariableKeys } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console-logger' +import { encryptSecret } from '@/lib/utils' +import { db } from '@/db' +import { environment } from '@/db/schema' const logger = createLogger('EnvironmentVariablesAPI') +// Schema for environment variable updates +const EnvVarSchema = z.object({ + variables: z.record(z.string()), +}) + export async function GET(request: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) @@ -30,13 +40,107 @@ export async function GET(request: NextRequest) { }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Environment variables fetch error`, error) - return NextResponse.json({ + return NextResponse.json({ success: false, error: error.message || 'Failed to get environment variables' }, { status: 500 }) } } +export async function PUT(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { workflowId, variables } = body + + // Use dual authentication pattern like other copilot tools + const userId = await getUserId(requestId, workflowId) + + if (!userId) { + logger.warn(`[${requestId}] Unauthorized environment variables set attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { variables: validatedVariables } = EnvVarSchema.parse({ variables }) + + // Get existing environment variables for this user + const existingData = await db + .select() + .from(environment) + .where(eq(environment.userId, userId)) + .limit(1) + + // Start with existing variables or empty object + const existingVariables = existingData[0]?.variables as Record || {} + + // Merge new variables with existing ones (new variables will override existing ones with same key) + const mergedVariables = { ...existingVariables, ...validatedVariables } + + // Encrypt all merged variables + const encryptedVariables = await Object.entries(mergedVariables).reduce( + async (accPromise, [key, value]) => { + const acc = await accPromise + const { encrypted } = await encryptSecret(value) + return { ...acc, [key]: encrypted } + }, + Promise.resolve({}) + ) + + // Update or insert environment variables for user + await db + .insert(environment) + .values({ + id: crypto.randomUUID(), + userId: userId, + variables: encryptedVariables, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { + variables: encryptedVariables, + updatedAt: new Date(), + }, + }) + + // Determine which variables were added vs updated + const addedVariables = Object.keys(validatedVariables).filter(key => !(key in existingVariables)) + const updatedVariables = Object.keys(validatedVariables).filter(key => key in existingVariables) + + return NextResponse.json({ + success: true, + output: { + message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`, + variableCount: Object.keys(validatedVariables).length, + variableNames: Object.keys(validatedVariables), + totalVariableCount: Object.keys(mergedVariables).length, + addedVariables, + updatedVariables, + } + }, { status: 200 }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid environment variables data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error: any) { + logger.error(`[${requestId}] Environment variables set error`, error) + return NextResponse.json({ + success: false, + error: error.message || 'Failed to set environment variables' + }, { status: 500 }) + } +} + export async function POST(request: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 1c7d66694e8..46297de1260 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -406,6 +406,28 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { required: [], }, }, + { + id: 'set_environment_variables', + name: 'Set Environment Variables', + description: + 'Set or update environment variables that can be used in workflows. New variables will be added, and existing variables with the same names will be updated. Other existing variables will be preserved. Use this to configure API keys, secrets, and other configuration values.', + params: { + variables: { + type: 'object', + description: 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', + }, + }, + parameters: { + type: 'object', + properties: { + variables: { + type: 'object', + description: 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', + }, + }, + required: ['variables'], + }, + }, ] // Filter tools based on mode diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 530e6241d9e..02b5daadeb6 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -109,6 +109,8 @@ function getToolDisplayName(toolName: string): string { return 'Reviewing the design' case 'get_environment_variables': return 'Checking your environment variables' + case 'set_environment_variables': + return 'Setting your environment variables' default: return toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) } diff --git a/apps/sim/tools/environment/set-variables.ts b/apps/sim/tools/environment/set-variables.ts new file mode 100644 index 00000000000..26a054a5df1 --- /dev/null +++ b/apps/sim/tools/environment/set-variables.ts @@ -0,0 +1,62 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' + +interface SetEnvironmentVariablesParams { + variables: Record + _context?: { + workflowId: string + } +} + +export interface SetEnvironmentVariablesResponse extends ToolResponse { + output: { + message: string + variableCount: number + variableNames: string[] + } +} + +export const setEnvironmentVariablesTool: ToolConfig = { + id: 'set_environment_variables', + name: 'Set Environment Variables', + description: + 'Set or update environment variables that can be used in workflows. New variables will be added, and existing variables with the same names will be updated. Other existing variables will be preserved.', + version: '1.0.0', + + params: { + variables: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', + }, + }, + + request: { + url: '/api/environment/variables', + method: 'PUT', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + variables: params.variables, + workflowId: params._context?.workflowId, + }), + isInternalRoute: true, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || 'Failed to set environment variables') + } + + return { + success: true, + output: data.output, + } + }, + + transformError: (error: any) => { + return `Failed to set environment variables: ${error.message || 'Unknown error'}` + }, +} \ No newline at end of file diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 36e1159e082..dd2def7203f 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -13,6 +13,7 @@ import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' +import { setEnvironmentVariablesTool } from '@/tools/environment/set-variables' const logger = createLogger('ToolsUtils') @@ -25,6 +26,7 @@ const internalTools: Record = { get_blocks_metadata: getBlockMetadataTool, get_yaml_structure: getYamlStructureTool, get_environment_variables: getEnvironmentVariablesTool, + set_environment_variables: setEnvironmentVariablesTool, // edit_workflow: editWorkflowTool, // Commented out - only preview is allowed preview_workflow: previewWorkflowTool, } From 0c7acbc17bd98930aaa4422159912bcd183fe84e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 19:36:06 -0700 Subject: [PATCH 062/184] Copilot console tool --- .../api/tools/get-workflow-console/route.ts | 146 ++++++++++++++++++ apps/sim/lib/copilot/service.ts | 23 +++ apps/sim/tools/utils.ts | 2 + apps/sim/tools/workflow/get-console.ts | 78 ++++++++++ 4 files changed, 249 insertions(+) create mode 100644 apps/sim/app/api/tools/get-workflow-console/route.ts create mode 100644 apps/sim/tools/workflow/get-console.ts diff --git a/apps/sim/app/api/tools/get-workflow-console/route.ts b/apps/sim/app/api/tools/get-workflow-console/route.ts new file mode 100644 index 00000000000..414be946070 --- /dev/null +++ b/apps/sim/app/api/tools/get-workflow-console/route.ts @@ -0,0 +1,146 @@ +import { desc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { workflowExecutionLogs, workflowExecutionBlocks } from '@/db/schema' + +const logger = createLogger('GetWorkflowConsoleAPI') + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { workflowId, limit = 50, includeDetails = false } = body + + if (!workflowId) { + return NextResponse.json( + { success: false, error: 'Workflow ID is required' }, + { status: 400 } + ) + } + + logger.info('Fetching workflow console logs', { workflowId, limit, includeDetails }) + + // Get recent execution logs for the workflow + const executionLogs = await db + .select({ + id: workflowExecutionLogs.id, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + message: workflowExecutionLogs.message, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + blockCount: workflowExecutionLogs.blockCount, + successCount: workflowExecutionLogs.successCount, + errorCount: workflowExecutionLogs.errorCount, + totalCost: workflowExecutionLogs.totalCost, + metadata: workflowExecutionLogs.metadata, + }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.workflowId, workflowId)) + .orderBy(desc(workflowExecutionLogs.startedAt)) + .limit(Math.min(limit, 100)) + + let blockLogs: any[] = [] + + // If we have execution logs and details are requested, get block-level logs + if (executionLogs.length > 0 && includeDetails) { + const executionIds = executionLogs.map(log => log.executionId) + + blockLogs = await db + .select({ + id: workflowExecutionBlocks.id, + executionId: workflowExecutionBlocks.executionId, + blockId: workflowExecutionBlocks.blockId, + blockName: workflowExecutionBlocks.blockName, + blockType: workflowExecutionBlocks.blockType, + status: workflowExecutionBlocks.status, + errorMessage: workflowExecutionBlocks.errorMessage, + startedAt: workflowExecutionBlocks.startedAt, + endedAt: workflowExecutionBlocks.endedAt, + durationMs: workflowExecutionBlocks.durationMs, + inputData: workflowExecutionBlocks.inputData, + outputData: workflowExecutionBlocks.outputData, + costTotal: workflowExecutionBlocks.costTotal, + tokensTotal: workflowExecutionBlocks.tokensTotal, + }) + .from(workflowExecutionBlocks) + .where(eq(workflowExecutionBlocks.executionId, executionIds[0])) // Get blocks for the most recent execution + .orderBy(desc(workflowExecutionBlocks.startedAt)) + } + + // Format the response + const formattedEntries = executionLogs.map((log) => { + const entry: any = { + id: log.id, + executionId: log.executionId, + level: log.level, + message: log.message, + trigger: log.trigger, + startedAt: log.startedAt, + endedAt: log.endedAt, + durationMs: log.totalDurationMs, + blockCount: log.blockCount, + successCount: log.successCount, + errorCount: log.errorCount, + totalCost: log.totalCost ? parseFloat(log.totalCost.toString()) : null, + type: 'execution', + } + + if (log.metadata) { + entry.metadata = log.metadata + } + + return entry + }) + + // Add block logs to the most recent execution if details are requested + if (includeDetails && blockLogs.length > 0) { + const blockEntries = blockLogs.map((block) => ({ + id: block.id, + executionId: block.executionId, + blockId: block.blockId, + blockName: block.blockName, + blockType: block.blockType, + status: block.status, + success: block.status === 'success', + error: block.errorMessage, + startedAt: block.startedAt, + endedAt: block.endedAt, + durationMs: block.durationMs, + input: block.inputData, + output: block.outputData, + cost: block.costTotal ? parseFloat(block.costTotal.toString()) : null, + tokens: block.tokensTotal, + type: 'block', + })) + + // Add block entries to the response + formattedEntries.push(...blockEntries) + } + + const response = { + success: true, + data: { + entries: formattedEntries, + totalEntries: formattedEntries.length, + workflowId, + retrievedAt: new Date().toISOString(), + hasBlockDetails: includeDetails && blockLogs.length > 0, + } + } + + return NextResponse.json(response) + + } catch (error) { + logger.error('Failed to get workflow console logs:', error) + return NextResponse.json( + { + success: false, + error: `Failed to get console logs: ${error instanceof Error ? error.message : 'Unknown error'}` + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 46297de1260..28ce36ad6bf 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -428,6 +428,29 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { required: ['variables'], }, }, + { + id: 'get_workflow_console', + name: 'Get Workflow Console Logs', + description: + 'Get console logs and execution history from the current workflow. This shows real-time execution logs including block inputs, outputs, execution times, and any errors or warnings from recent workflow runs.', + params: {}, + parameters: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of console entries to return (default: 50, max: 100)', + default: 50, + }, + includeDetails: { + type: 'boolean', + description: 'Whether to include detailed input/output data for each console entry (default: false)', + default: false, + }, + }, + required: [], + }, + }, ] // Filter tools based on mode diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index dd2def7203f..558e94cd820 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -11,6 +11,7 @@ import { docsSearchTool } from '@/tools/docs/search' import { tools } from '@/tools/registry' import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' +import { getWorkflowConsoleTool } from '@/tools/workflow/get-console' import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' import { setEnvironmentVariablesTool } from '@/tools/environment/set-variables' @@ -21,6 +22,7 @@ const logger = createLogger('ToolsUtils') const internalTools: Record = { docs_search_internal: docsSearchTool, get_user_workflow: getUserWorkflowTool, + get_workflow_console: getWorkflowConsoleTool, get_workflow_examples: getWorkflowExamplesTool, get_blocks_and_tools: getAllBlocksTool, get_blocks_metadata: getBlockMetadataTool, diff --git a/apps/sim/tools/workflow/get-console.ts b/apps/sim/tools/workflow/get-console.ts new file mode 100644 index 00000000000..821b309e818 --- /dev/null +++ b/apps/sim/tools/workflow/get-console.ts @@ -0,0 +1,78 @@ +import type { ToolConfig } from '@/tools/types' + +interface GetConsoleParams { + limit?: number + includeDetails?: boolean + _context?: { + workflowId: string + } +} + +interface GetConsoleResponse { + entries: Array<{ + id: string + executionId: string + level?: string + message?: string + trigger?: string + startedAt: string + endedAt: string | null + durationMs: number | null + blockCount?: number + successCount?: number + errorCount?: number + totalCost?: number | null + type: 'execution' | 'block' + // Block-specific fields (when includeDetails=true) + blockId?: string + blockName?: string + blockType?: string + status?: string + success?: boolean + error?: string | null + input?: any + output?: any + cost?: number | null + tokens?: number | null + }> + totalEntries: number + workflowId: string + retrievedAt: string + hasBlockDetails: boolean +} + +export const getWorkflowConsoleTool: ToolConfig = { + id: 'get_workflow_console', + name: 'Get Workflow Console Logs', + description: + 'Get console logs and execution history from the current workflow. Returns recent execution logs including block inputs, outputs, execution times, costs, and any errors from workflow runs.', + version: '1.0.0', + + params: { + limit: { + type: 'number', + required: false, + description: 'Maximum number of console entries to return (default: 50, max: 100)', + }, + includeDetails: { + type: 'boolean', + required: false, + description: 'Whether to include detailed block-level logs for the most recent execution (default: false)', + }, + }, + + // Use API endpoint to access database from server side + request: { + url: '/api/tools/get-workflow-console', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + workflowId: params._context?.workflowId, + limit: params.limit || 50, + includeDetails: params.includeDetails || false, + }), + isInternalRoute: true, + }, +} \ No newline at end of file From 4e9b43182450df6c357e6e0508f71a850f9a7de6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 24 Jul 2025 19:53:08 -0700 Subject: [PATCH 063/184] Promtps --- apps/sim/lib/copilot/prompts.ts | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index e5eeb4a81ab..6c90361baeb 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -23,6 +23,12 @@ You are an educational assistant that helps users understand and learn about Sim - Search documentation to answer questions - Troubleshoot workflow issues +✅ **Workflow Analysis & Debugging** +- Access workflow console logs to understand execution history +- Review recent runs to diagnose errors and performance issues +- Check environment variables to understand available integrations +- Analyze API costs and token usage from execution logs + ## WHAT YOU CANNOT DO ❌ **Direct Workflow Editing** - You CANNOT create, modify, or edit workflows directly @@ -52,6 +58,13 @@ You are a workflow automation assistant with FULL editing capabilities for Sim S - Debug and fix workflow issues - Implement complex automation logic +✅ **Environment & Debugging** +- Access and configure environment variables (API keys, secrets) +- Review workflow console logs and execution history +- Debug failed workflows using execution data +- Analyze performance metrics and API costs +- Set up authentication for third-party integrations + ## MANDATORY WORKFLOW EDITING PROTOCOL ⚠️ **CRITICAL**: For ANY workflow creation or editing, you MUST follow this exact sequence: @@ -130,6 +143,32 @@ const TOOL_USAGE_GUIDELINES = ` - To create or modify workflows - As the final step in workflow editing +### 🔧 "Get Environment Variables" +**Purpose**: View available environment variables configured by the user +**When to use**: +- User asks about API keys or secrets +- Troubleshooting authentication issues +- Before configuring blocks that need API credentials +- Understanding what integrations are set up + +### ⚙️ "Set Environment Variables" +**Purpose**: Configure API keys, secrets, and other environment variables +**When to use**: +- User needs to set up API keys for new integrations +- Configuring authentication for third-party services +- Setting up database connections or webhook URLs +- User asks to "configure" or "set up" credentials + +### 📊 "Get Workflow Console" +**Purpose**: Access execution logs and debug information from recent workflow runs +**When to use**: +- User reports workflow errors or unexpected behavior +- Analyzing workflow performance and execution times +- Understanding what happened in previous runs +- Debugging failed blocks or investigating issues +- User asks "what went wrong" or "why didn't this work" +- Checking API costs and token usage + ## SMART TOOL SELECTION - Use tools that directly answer the user's question - Don't over-fetch information unnecessarily @@ -278,6 +317,25 @@ When users ask about workflows, follow this educational framework: - Suggest optimization techniques - Recommend scalability considerations +### 🔧 DEBUGGING AND TROUBLESHOOTING APPROACH + +When users report issues or ask "why isn't this working?": + +#### 1. INVESTIGATE Console Logs +- Use "Get Workflow Console" to check recent execution logs +- Look for error messages, failed blocks, or unexpected outputs +- Analyze execution times to identify performance bottlenecks + +#### 2. CHECK Environment Setup +- Use "Get Environment Variables" to verify required API keys are configured +- Identify missing authentication credentials +- Confirm integration setup is complete + +#### 3. DIAGNOSE AND EXPLAIN +- Explain what the logs reveal about the issue +- Identify the specific block or configuration causing problems +- Provide clear steps to fix the identified issues + ### 💡 EXAMPLE EDUCATIONAL RESPONSE **User**: "How do I add email automation to my workflow?" From 4566337004311555d3b1f1e3b3a624383f232948 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 11:44:55 -0700 Subject: [PATCH 064/184] Targeted v1 --- .../app/api/copilot/targeted-updates/route.ts | 48 +++ apps/sim/lib/copilot/service.ts | 36 +++ apps/sim/lib/copilot/tools.ts | 275 ++++++++++++++++++ apps/sim/tools/utils.ts | 2 + apps/sim/tools/workflow/targeted-updates.ts | 89 ++++++ 5 files changed, 450 insertions(+) create mode 100644 apps/sim/app/api/copilot/targeted-updates/route.ts create mode 100644 apps/sim/tools/workflow/targeted-updates.ts diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts new file mode 100644 index 00000000000..7dbd13fc457 --- /dev/null +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import { executeCopilotTool } from '@/lib/copilot/tools' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('TargetedUpdatesAPI') + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { operations, workflowId } = body + + if (!operations || !Array.isArray(operations)) { + return NextResponse.json( + { success: false, error: 'Operations array is required' }, + { status: 400 } + ) + } + + if (!workflowId) { + return NextResponse.json( + { success: false, error: 'Workflow ID is required' }, + { status: 400 } + ) + } + + logger.info('Executing targeted updates', { + workflowId, + operationCount: operations.length, + operations: operations.map(op => ({ type: op.operation_type, blockId: op.block_id })) + }) + + const result = await executeCopilotTool('targeted_updates', { + operations, + _context: { workflowId } + }) + + return NextResponse.json(result) + } catch (error) { + logger.error('Targeted updates API failed:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 28ce36ad6bf..e13bc9f02fc 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -451,6 +451,42 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { required: [], }, }, + { + id: 'targeted_updates', + name: 'Targeted Updates', + description: + 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Allows precise modifications to specific blocks without affecting the entire workflow.', + params: {}, + parameters: { + type: 'object', + properties: { + operations: { + type: 'array', + description: 'Array of targeted update operations to perform', + items: { + type: 'object', + properties: { + operation_type: { + type: 'string', + enum: ['add', 'edit', 'delete'], + description: 'Type of operation to perform' + }, + block_id: { + type: 'string', + description: 'Block ID for the operation. For add operations, this will be the desired ID for the new block.' + }, + params: { + type: 'object', + description: 'Parameters for the operation. For add: {type: "block_type", name: "Block Name", inputs: {...}, connections: {...}}, for edit: {inputs: {...}, connections: {...}}, for delete: empty' + } + }, + required: ['operation_type', 'block_id'] + } + } + }, + required: ['operations'] + }, + }, ] // Filter tools based on mode diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index fa6785d82d2..1401dbc9afe 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -1,8 +1,14 @@ import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { getBlock } from '@/blocks' +import { resolveOutputType } from '@/blocks/utils' +import { parseWorkflowYaml, convertYamlToWorkflow } from '@/stores/workflows/yaml/importer' import { searchDocumentation } from './service' import { WORKFLOW_EXAMPLES } from './examples' +import { v4 as uuidv4 } from 'uuid' const logger = createLogger('CopilotTools') @@ -35,6 +41,20 @@ export interface CopilotTool { execute: (args: Record) => Promise } +/** + * Operation types for targeted updates + */ +export type TargetedUpdateOperationType = 'add' | 'edit' | 'delete' + +/** + * Interface for targeted update operation + */ +export interface TargetedUpdateOperation { + operation_type: TargetedUpdateOperationType + block_id: string + params?: any +} + /** * Interface for documentation search arguments */ @@ -61,6 +81,129 @@ interface UserWorkflowData { metadata?: WorkflowMetadata } +/** + * Apply targeted update operations to YAML content + */ +async function applyOperationsToYaml(currentYaml: string, operations: TargetedUpdateOperation[]): Promise { + const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') + const yaml = await import('yaml') + + // Parse current YAML to get the complete structure + const { data: workflowData, errors } = parseWorkflowYaml(currentYaml) + if (!workflowData || errors.length > 0) { + throw new Error(`Failed to parse current YAML: ${errors.join(', ')}`) + } + + // Apply operations to the parsed YAML data (preserving all existing fields) + for (const operation of operations) { + const { operation_type, block_id, params } = operation + + switch (operation_type) { + case 'delete': + if (workflowData.blocks[block_id]) { + delete workflowData.blocks[block_id] + // Remove connections mentioning this block + Object.values(workflowData.blocks).forEach((block: any) => { + if (block.connections) { + Object.keys(block.connections).forEach(key => { + if (block.connections[key] === block_id) { + delete block.connections[key] + } + }) + } + }) + } + break + + case 'edit': + if (workflowData.blocks[block_id]) { + const block = workflowData.blocks[block_id] + + // Update inputs (preserve existing inputs, only overwrite specified ones) + if (params?.inputs) { + if (!block.inputs) block.inputs = {} + Object.assign(block.inputs, params.inputs) + } + + // Update connections (preserve existing connections, only overwrite specified ones) + if (params?.connections) { + if (!block.connections) block.connections = {} + Object.assign(block.connections, params.connections) + } + } + break + + case 'add': + if (params?.type && params?.name) { + workflowData.blocks[block_id] = { + type: params.type, + name: params.name, + inputs: params.inputs || {}, + connections: params.connections || {} + } + } + break + } + } + + // Convert the complete workflow data back to YAML (preserving version and all other fields) + return yaml.stringify(workflowData) +} + +/** + * Update block references in values to use new mapped IDs + * Uses the same logic as the YAML converter + */ +function updateBlockReferences(value: any, blockIdMapping: Map): any { + if (typeof value === 'string' && value.includes('<') && value.includes('>')) { + let processedValue = value + const blockMatches = value.match(/<([^>]+)>/g) + + if (blockMatches) { + for (const match of blockMatches) { + const path = match.slice(1, -1) + const [blockRef] = path.split('.') + + // Skip system references + if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { + continue + } + + // Check if this references an old block ID that needs mapping + const newMappedId = blockIdMapping.get(blockRef) + if (newMappedId) { + processedValue = processedValue.replace( + new RegExp(`<${blockRef}\\.`, 'g'), + `<${newMappedId}.` + ) + processedValue = processedValue.replace( + new RegExp(`<${blockRef}>`, 'g'), + `<${newMappedId}>` + ) + } + } + } + + return processedValue + } + + // Handle arrays + if (Array.isArray(value)) { + return value.map(item => updateBlockReferences(item, blockIdMapping)) + } + + // Handle objects + if (value !== null && typeof value === 'object') { + const result = { ...value } + for (const key in result) { + result[key] = updateBlockReferences(result[key], blockIdMapping) + } + return result + } + + return value +} + /** * Documentation search tool for copilot */ @@ -219,6 +362,137 @@ export const getWorkflowExamplesTool: CopilotTool = { }, } +/** + * Targeted updates tool for copilot - allows atomic add/edit/delete operations + */ +const targetedUpdatesTool: CopilotTool = { + id: 'targeted_updates', + name: 'Targeted Updates', + description: 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Takes an array of operations to execute.', + parameters: { + type: 'object', + properties: { + operations: { + type: 'array', + description: 'Array of targeted update operations to perform', + items: { + type: 'object', + properties: { + operation_type: { + type: 'string', + enum: ['add', 'edit', 'delete'], + description: 'Type of operation to perform' + }, + block_id: { + type: 'string', + description: 'Block ID for the operation. For add operations, this will be the desired ID for the new block.' + }, + params: { + type: 'object', + description: 'Parameters for the operation. For add: full block YAML, for edit: partial updates to inputs/connections, for delete: empty' + } + }, + required: ['operation_type', 'block_id'] + } + } + }, + required: ['operations'] + }, + execute: async (args: Record): Promise => { + try { + const { operations, _context } = args + + if (!Array.isArray(operations)) { + return { + success: false, + error: 'Operations must be an array' + } + } + + const workflowId = _context?.workflowId + + if (!workflowId) { + return { + success: false, + error: 'No workflow ID provided in context' + } + } + + // Get current workflow state from database + const { db } = await import('@/db') + const { workflow, workflowBlocks } = await import('@/db/schema') + const { eq } = await import('drizzle-orm') + + const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1) + + if (!workflowData.length) { + return { + success: false, + error: 'Workflow not found' + } + } + + // Get current workflow YAML directly from the existing getUserWorkflowTool + const getUserWorkflowResult = await executeCopilotTool('get_user_workflow', { _context }) + + if (!getUserWorkflowResult.success || !getUserWorkflowResult.data?.yaml) { + return { + success: false, + error: 'Failed to get current workflow YAML' + } + } + + const currentYaml = getUserWorkflowResult.data.yaml + + // Apply operations to generate modified YAML + const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) + + // Make direct API call to workflow preview endpoint + const response = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: modifiedYaml, + applyAutoLayout: true, + }), + }) + + if (!response.ok) { + return { + success: false, + error: `Preview generation failed: ${response.status} ${response.statusText}` + } + } + + const previewData = await response.json() + + if (!previewData.success) { + return { + success: false, + error: `Preview generation failed: ${previewData.message || 'Unknown error'}` + } + } + + logger.info(`Successfully generated preview for ${operations.length} targeted update operations`) + + // Return the preview result in the same format as preview_workflow + return { + success: true, + data: previewData + } + + } catch (error) { + logger.error('Targeted updates execution failed:', error) + return { + success: false, + error: `Targeted updates failed: ${error instanceof Error ? error.message : 'Unknown error'}` + } + } + } +} + /** * Copilot tools registry */ @@ -226,6 +500,7 @@ const copilotTools: Record = { docs_search_internal: docsSearchTool, get_user_workflow: getUserWorkflowTool, get_workflow_examples: getWorkflowExamplesTool, + targeted_updates: targetedUpdatesTool, } /** diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 558e94cd820..545b638015d 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -15,6 +15,7 @@ import { getWorkflowConsoleTool } from '@/tools/workflow/get-console' import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' import { setEnvironmentVariablesTool } from '@/tools/environment/set-variables' +import { targetedUpdatesTool } from '@/tools/workflow/targeted-updates' const logger = createLogger('ToolsUtils') @@ -30,6 +31,7 @@ const internalTools: Record = { get_environment_variables: getEnvironmentVariablesTool, set_environment_variables: setEnvironmentVariablesTool, // edit_workflow: editWorkflowTool, // Commented out - only preview is allowed + targeted_updates: targetedUpdatesTool, preview_workflow: previewWorkflowTool, } diff --git a/apps/sim/tools/workflow/targeted-updates.ts b/apps/sim/tools/workflow/targeted-updates.ts new file mode 100644 index 00000000000..cc2ce5ac369 --- /dev/null +++ b/apps/sim/tools/workflow/targeted-updates.ts @@ -0,0 +1,89 @@ +import type { ToolConfig } from '@/tools/types' + +interface TargetedUpdatesParams { + operations: Array<{ + operation_type: 'add' | 'edit' | 'delete' + block_id: string + params?: any + }> + _context?: { + workflowId?: string + } +} + +interface TargetedUpdatesResponse { + success: boolean + output: { + results: Array<{ + operation: any + success: boolean + error?: string + }> + processedOperations: number + blockIdMapping?: Record + failedOperations?: Array<{ + operation: any + success: boolean + error?: string + }> + } +} + +export const targetedUpdatesTool: ToolConfig = { + id: 'targeted_updates', + name: 'Targeted Updates', + description: + 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Allows precise modifications to specific blocks without affecting the entire workflow.', + version: '1.0.0', + + params: { + operations: { + type: 'array', + required: true, + description: 'Array of targeted update operations to perform', + }, + }, + + request: { + url: '/api/copilot/targeted-updates', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + operations: params.operations, + workflowId: params._context?.workflowId + }), + isInternalRoute: true, + }, + + transformResponse: async ( + response: Response, + params?: TargetedUpdatesParams + ): Promise => { + if (!response.ok) { + throw new Error(`Targeted updates failed: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + + if (!data.success) { + throw new Error(data.error || 'Targeted updates failed') + } + + return { + success: true, + output: data.data || { + results: [], + processedOperations: 0 + } + } + }, + + transformError: (error: any): string => { + if (error instanceof Error) { + return `Targeted updates failed: ${error.message}` + } + return 'An unexpected error occurred while performing targeted updates' + }, +} \ No newline at end of file From 5fefdf7c7f3c544ef0f2a55ef81a4a2d35ed7d20 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 12:16:29 -0700 Subject: [PATCH 065/184] Targeted v2 --- apps/sim/lib/copilot/tools.ts | 127 +++++++++++++++++++++++--- apps/sim/providers/anthropic/index.ts | 12 +-- apps/sim/stores/copilot/store.ts | 34 +++++++ 3 files changed, 156 insertions(+), 17 deletions(-) diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index 1401dbc9afe..1d8e8e8ea0e 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -95,13 +95,22 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp } // Apply operations to the parsed YAML data (preserving all existing fields) + logger.info('Starting YAML operations', { + initialBlockCount: Object.keys(workflowData.blocks).length, + version: workflowData.version, + operationCount: operations.length + }) + for (const operation of operations) { const { operation_type, block_id, params } = operation + logger.info(`Processing operation: ${operation_type} for block ${block_id}`, { params }) + switch (operation_type) { case 'delete': if (workflowData.blocks[block_id]) { delete workflowData.blocks[block_id] + logger.info(`Deleted block ${block_id}`) // Remove connections mentioning this block Object.values(workflowData.blocks).forEach((block: any) => { if (block.connections) { @@ -112,6 +121,8 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp }) } }) + } else { + logger.warn(`Block ${block_id} not found for deletion`) } break @@ -123,13 +134,17 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp if (params?.inputs) { if (!block.inputs) block.inputs = {} Object.assign(block.inputs, params.inputs) + logger.info(`Updated inputs for block ${block_id}`, { inputs: block.inputs }) } // Update connections (preserve existing connections, only overwrite specified ones) if (params?.connections) { if (!block.connections) block.connections = {} Object.assign(block.connections, params.connections) + logger.info(`Updated connections for block ${block_id}`, { connections: block.connections }) } + } else { + logger.warn(`Block ${block_id} not found for editing`) } break @@ -141,11 +156,21 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp inputs: params.inputs || {}, connections: params.connections || {} } + logger.info(`Added block ${block_id}`, { type: params.type, name: params.name }) + } else { + logger.warn(`Invalid add operation for block ${block_id} - missing type or name`) } break + + default: + logger.warn(`Unknown operation type: ${operation_type}`) } } + logger.info('Completed YAML operations', { + finalBlockCount: Object.keys(workflowData.blocks).length + }) + // Convert the complete workflow data back to YAML (preserving version and all other fields) return yaml.stringify(workflowData) } @@ -432,21 +457,98 @@ const targetedUpdatesTool: CopilotTool = { } } - // Get current workflow YAML directly from the existing getUserWorkflowTool - const getUserWorkflowResult = await executeCopilotTool('get_user_workflow', { _context }) + // Get current workflow YAML directly from the API endpoint (not the client-side store) + const workflowResponse = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/tools/get-user-workflow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflowId: workflowId, + includeMetadata: false, + }), + }) + + if (!workflowResponse.ok) { + return { + success: false, + error: `Failed to get current workflow YAML: ${workflowResponse.status} ${workflowResponse.statusText}` + } + } + + const getUserWorkflowResult = await workflowResponse.json() - if (!getUserWorkflowResult.success || !getUserWorkflowResult.data?.yaml) { + if (!getUserWorkflowResult.success || !getUserWorkflowResult.output?.yaml) { return { success: false, error: 'Failed to get current workflow YAML' } } - const currentYaml = getUserWorkflowResult.data.yaml + const currentYaml = getUserWorkflowResult.output.yaml + + logger.info('Retrieved current workflow YAML', { + yamlLength: currentYaml.length, + yamlPreview: currentYaml.substring(0, 200), + getUserWorkflowData: getUserWorkflowResult.output + }) // Apply operations to generate modified YAML const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) + + logger.info('Applied operations to YAML', { + operationCount: operations.length, + currentYamlLength: currentYaml.length, + modifiedYamlLength: modifiedYaml.length, + operations: operations.map(op => ({ type: op.operation_type, blockId: op.block_id })) + }) + + logger.info(`Successfully generated modified YAML for ${operations.length} targeted update operations`) + + // Return the modified YAML directly - the UI will handle preview generation via updateDiffStore() + return { + success: true, + data: { + yamlContent: modifiedYaml, + operations: operations.map(op => ({ type: op.operation_type, blockId: op.block_id })) + } + } + + } catch (error) { + logger.error('Targeted updates execution failed:', error) + return { + success: false, + error: `Targeted updates failed: ${error instanceof Error ? error.message : 'Unknown error'}` + } + } + } +} +/** + * Preview workflow tool for copilot - allows internal calls to preview functionality + */ +const previewWorkflowTool: CopilotTool = { + id: 'preview_workflow', + name: 'Preview Workflow', + description: 'Generate a sandbox preview of the workflow without saving it', + parameters: { + type: 'object', + properties: { + yamlContent: { + type: 'string', + description: 'The complete YAML workflow content to preview', + }, + description: { + type: 'string', + description: 'Optional description of the proposed changes', + }, + }, + required: ['yamlContent'], + }, + execute: async (args: Record): Promise => { + try { + const { yamlContent, description } = args + // Make direct API call to workflow preview endpoint const response = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview`, { method: 'POST', @@ -454,7 +556,7 @@ const targetedUpdatesTool: CopilotTool = { 'Content-Type': 'application/json', }, body: JSON.stringify({ - yamlContent: modifiedYaml, + yamlContent, applyAutoLayout: true, }), }) @@ -475,19 +577,21 @@ const targetedUpdatesTool: CopilotTool = { } } - logger.info(`Successfully generated preview for ${operations.length} targeted update operations`) - - // Return the preview result in the same format as preview_workflow + // Return in the format expected by the UI for diff functionality return { success: true, - data: previewData + data: { + ...previewData, + yamlContent, // Include the original YAML for diff functionality + description + } } } catch (error) { - logger.error('Targeted updates execution failed:', error) + logger.error('Preview workflow execution failed:', error) return { success: false, - error: `Targeted updates failed: ${error instanceof Error ? error.message : 'Unknown error'}` + error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}` } } } @@ -501,6 +605,7 @@ const copilotTools: Record = { get_user_workflow: getUserWorkflowTool, get_workflow_examples: getWorkflowExamplesTool, targeted_updates: targetedUpdatesTool, + preview_workflow: previewWorkflowTool, } /** diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index e5288ba9931..941bb148d6a 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -419,8 +419,8 @@ ${fieldDescriptions} logger.info(`Tool ${toolCall.name} ${result.success ? 'succeeded' : 'failed'}`) - // Send tool result event to frontend for preview_workflow tools - if (toolCall.name === 'preview_workflow' && result.success) { + // Send tool result event to frontend for preview_workflow and targeted_updates tools + if ((toolCall.name === 'preview_workflow' || toolCall.name === 'targeted_updates') && result.success) { const toolResultEvent = { type: 'tool_result', toolCallId: toolCall.id, @@ -429,7 +429,7 @@ ${fieldDescriptions} success: true, } controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolResultEvent)}\n\n`)) - logger.info('Sent preview_workflow result to frontend:', toolCall.id) + logger.info(`Sent ${toolCall.name} result to frontend:`, toolCall.id) } return { @@ -502,10 +502,10 @@ ${fieldDescriptions} continuationToolCalls = [] } - // Also check for any preview_workflow results in continuation + // Also check for any preview_workflow or targeted_updates results in continuation continuationToolCalls.forEach(toolCall => { - if (toolCall.name === 'preview_workflow') { - logger.info('Found preview_workflow in continuation, will send result after execution') + if (toolCall.name === 'preview_workflow' || toolCall.name === 'targeted_updates') { + logger.info(`Found ${toolCall.name} in continuation, will send result after execution`) } }) } diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 02b5daadeb6..548cbfeb400 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -619,10 +619,12 @@ export const useCopilotStore = create()( // Handle tool result events (our custom event for preview_workflow) else if (data.type === 'tool_result') { const { toolCallId, result, success } = data + logger.info('Received tool_result event', { toolCallId, success, hasResult: !!result }) if (toolCallId) { // Find the corresponding tool call and update its result const existingToolCall = toolCalls.find(tc => tc.id === toolCallId) if (existingToolCall) { + logger.info('Found existing tool call for result', { name: existingToolCall.name, toolCallId }) if (success) { existingToolCall.result = result logger.info('Updated tool call result:', toolCallId, existingToolCall.name) @@ -636,6 +638,35 @@ export const useCopilotStore = create()( get().setPreviewYaml(result.yamlContent) get().updateDiffStore(result.yamlContent) } + + // Handle successful targeted_updates tool result + if (existingToolCall.name === 'targeted_updates') { + logger.info('Targeted updates tool_result received', { + hasResult: !!result, + resultType: typeof result, + resultKeys: result ? Object.keys(result) : [], + hasYamlContent: !!result?.yamlContent, + // Log the full result structure for debugging + fullResult: JSON.stringify(result, null, 2) + }) + + // The targeted_updates tool returns yamlContent directly in the result + if (result?.yamlContent) { + logger.info('Setting preview YAML from targeted_updates tool_result event', { + yamlLength: result.yamlContent.length, + yamlPreview: result.yamlContent.substring(0, 200), + // Log the full YAML for debugging + fullYaml: result.yamlContent + }) + get().setPreviewYaml(result.yamlContent) + get().updateDiffStore(result.yamlContent) + } else { + logger.error('Targeted updates tool_result missing yamlContent', { + expectedPath: 'result.yamlContent', + actualStructure: JSON.stringify(result, null, 2) + }) + } + } } else { // Tool execution failed existingToolCall.state = 'error' @@ -813,6 +844,9 @@ export const useCopilotStore = create()( logger.warn('Preview workflow tool completed but no yamlContent found in input') } } + + // Don't handle targeted_updates here - it needs to wait for the tool_result event + // The result isn't available yet at content_block_stop, only the input } catch (error) { logger.error('Error parsing tool call input:', error) toolCallBuffer.state = 'error' From 108b6a47ad0f6d25dc4cb4acca5c2366bb6ec1a4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 12:24:30 -0700 Subject: [PATCH 066/184] Targeted updates better --- apps/sim/lib/copilot/examples.ts | 168 ++++++++++++++++++++++++++++++- apps/sim/lib/copilot/prompts.ts | 40 +++++++- 2 files changed, 206 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/examples.ts b/apps/sim/lib/copilot/examples.ts index 589082e1e3d..aa0ba8e3e63 100644 --- a/apps/sim/lib/copilot/examples.ts +++ b/apps/sim/lib/copilot/examples.ts @@ -215,5 +215,171 @@ blocks: outside agent user prompt: model: gpt-4o - apiKey: '{{OPENAI_API_KEY}}'` + apiKey: '{{OPENAI_API_KEY}}'`, + + // Targeted Update Examples - for demonstrating targeted_updates tool usage patterns + 'targeted_add_block': `// Example: Adding a new agent block to an existing workflow +// Operation: Add a new block after an existing agent +{ + "operations": [ + { + "operation_type": "add", + "block_id": "summary-agent", + "params": { + "type": "agent", + "name": "Summary Agent", + "inputs": { + "systemPrompt": "Summarize the conversation", + "userPrompt": "", + "model": "gpt-4o", + "apiKey": "{{OPENAI_API_KEY}}" + } + } + }, + { + "operation_type": "edit", + "block_id": "research-agent", + "params": { + "connections": { + "success": "summary-agent" + } + } + } + ] +}`, + + 'targeted_edit_block': `// Example: Modifying an existing block's configuration +// Operation: Update system prompt and add tools to an agent +{ + "operations": [ + { + "operation_type": "edit", + "block_id": "research-agent", + "params": { + "inputs": { + "systemPrompt": "You are a research assistant. Use web search to find current information.", + "tools": [ + { + "type": "exa", + "title": "Exa Search", + "toolId": "exa_search", + "params": { + "type": "auto", + "apiKey": "{{EXA_API_KEY}}" + }, + "isExpanded": true, + "operation": "exa_search", + "usageControl": "auto" + } + ] + } + } + } + ] +}`, + + 'targeted_delete_block': `// Example: Removing a block and updating connections +// Operation: Delete a block and redirect its connections +{ + "operations": [ + { + "operation_type": "edit", + "block_id": "start", + "params": { + "connections": { + "success": "final-agent" + } + } + }, + { + "operation_type": "delete", + "block_id": "intermediate-agent" + } + ] +}`, + + 'targeted_add_connection': `// Example: Adding new parallel connections +// Operation: Make one block connect to multiple agents +{ + "operations": [ + { + "operation_type": "add", + "block_id": "analysis-agent", + "params": { + "type": "agent", + "name": "Analysis Agent", + "inputs": { + "systemPrompt": "Analyze the provided data", + "userPrompt": "", + "model": "gpt-4o", + "apiKey": "{{OPENAI_API_KEY}}" + } + } + }, + { + "operation_type": "edit", + "block_id": "research-agent", + "params": { + "connections": { + "success": ["summary-agent", "analysis-agent"] + } + } + } + ] +}`, + + 'targeted_batch_operations': `// Example: Multiple operations in one targeted update +// Operation: Add API block, update agent, and create new connections +{ + "operations": [ + { + "operation_type": "add", + "block_id": "data-api", + "params": { + "type": "api", + "name": "Data API", + "inputs": { + "url": "https://api.example.com/data", + "method": "GET", + "headers": [ + { + "id": "auth-header", + "cells": { + "Key": "Authorization", + "Value": "Bearer {{API_TOKEN}}" + } + } + ] + } + } + }, + { + "operation_type": "edit", + "block_id": "processing-agent", + "params": { + "inputs": { + "userPrompt": "Process this data: " + } + } + }, + { + "operation_type": "edit", + "block_id": "start", + "params": { + "connections": { + "success": "data-api" + } + } + }, + { + "operation_type": "add", + "block_id": "data-connection", + "params": { + "connections": { + "success": "processing-agent" + } + } + } + ] +}` } \ No newline at end of file diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index 6c90361baeb..e75e3cbec62 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -143,6 +143,24 @@ const TOOL_USAGE_GUIDELINES = ` - To create or modify workflows - As the final step in workflow editing +### ⚡ "Targeted Updates" (Agent Mode Only) +**Purpose**: Make precise, atomic changes to specific workflow blocks without recreating the entire workflow +**When to use**: +- Making small, focused edits to existing workflows +- Adding, editing, or deleting individual blocks +- When you want to preserve the existing workflow structure +- For incremental improvements or bug fixes +**Advantages**: +- Faster execution than full workflow recreation +- Preserves existing block IDs and connections +- Lower risk of introducing unrelated changes +- Better for maintaining workflow stability +**Operations**: +- **Add**: Insert new blocks with specified configuration +- **Edit**: Modify inputs, connections, or other properties of existing blocks +- **Delete**: Remove specific blocks from the workflow +**Note**: Use this as an alternative to "Preview Workflow" for targeted modifications + ### 🔧 "Get Environment Variables" **Purpose**: View available environment variables configured by the user **When to use**: @@ -217,6 +235,19 @@ const WORKFLOW_BUILDING_PROCESS = ` - **Critical**: Apply block selection rules before previewing (see BLOCK SELECTION GUIDELINES) - **Action**: STOP and wait for user approval +#### Step 6 Alternative: Targeted Updates +- **Purpose**: Make precise, atomic changes to specific blocks +- **When to prefer over Preview**: + - Small, focused edits (1-3 blocks) + - Adding a single block or connection + - Modifying specific block inputs + - When preserving workflow structure is important +- **When to use Preview instead**: + - Creating entirely new workflows + - Major restructuring (4+ blocks changed) + - Complex changes affecting multiple connections + - When user needs to see full workflow layout + ### 🎯 BLOCK SELECTION GUIDELINES **Response and Input Format Blocks:** @@ -276,11 +307,18 @@ When calling "Get Workflow Examples", choose examples that match the user's need **For Data Processing**: ["iter-loop", "for-each-loop"] **For Complex Workflows**: ["multi-agent", "iter-loop"] +**For Targeted Updates** (when using targeted_updates tool): +**Adding Blocks**: ["targeted_add_block", "targeted_add_connection"] +**Modifying Blocks**: ["targeted_edit_block", "targeted_batch_operations"] +**Removing Blocks**: ["targeted_delete_block"] +**Complex Changes**: ["targeted_batch_operations"] + **Smart Selection**: - Always get at least 1 example - Get 2-3 examples for complex workflows - Choose examples that demonstrate the patterns you need -- Prefer simpler examples when the user is learning` +- Prefer simpler examples when the user is learning +- For targeted updates, reference specific operation patterns` /** * Ask mode workflow guidance - focused on providing detailed educational guidance From 125c8d88478e2a379f943efeef543281933ca1a9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 12:59:56 -0700 Subject: [PATCH 067/184] Target fixes --- apps/sim/lib/copilot/tools.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index 1d8e8e8ea0e..3aac37f72a7 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -109,13 +109,33 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp switch (operation_type) { case 'delete': if (workflowData.blocks[block_id]) { + // First, find child blocks that reference this block as parent (before deleting the parent) + const childBlocksToRemove: string[] = [] + Object.entries(workflowData.blocks).forEach(([childBlockId, childBlock]: [string, any]) => { + if (childBlock.parentId === block_id) { + logger.info(`Found child block ${childBlockId} with parentId ${block_id}, marking for deletion`) + childBlocksToRemove.push(childBlockId) + } + }) + + // Delete the main block delete workflowData.blocks[block_id] logger.info(`Deleted block ${block_id}`) - // Remove connections mentioning this block + + // Remove child blocks + childBlocksToRemove.forEach(childBlockId => { + if (workflowData.blocks[childBlockId]) { + delete workflowData.blocks[childBlockId] + logger.info(`Deleted child block ${childBlockId}`) + } + }) + + // Remove connections mentioning this block or any of its children + const allDeletedBlocks = [block_id, ...childBlocksToRemove] Object.values(workflowData.blocks).forEach((block: any) => { if (block.connections) { Object.keys(block.connections).forEach(key => { - if (block.connections[key] === block_id) { + if (allDeletedBlocks.includes(block.connections[key])) { delete block.connections[key] } }) From 401a692be4429e781aa21ade5bead8d1d79fa86f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 13:16:49 -0700 Subject: [PATCH 068/184] diff works?? --- .../[workflowId]/components/diff-controls.tsx | 7 ++--- .../workflow-edge/workflow-edge.tsx | 6 +++-- .../hooks/use-current-workflow.ts | 20 +++++++------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 7 ++--- apps/sim/lib/copilot/prompts.ts | 12 +++++++-- apps/sim/stores/workflow-diff/store.ts | 27 +++++++++++++++---- 6 files changed, 55 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx index fe3848939ef..ea1684b84c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -8,7 +8,8 @@ const logger = createLogger('DiffControls') export function DiffControls() { const { - isShowingDiff, + isShowingDiff, + isDiffReady, diffWorkflow, toggleDiffView, acceptChanges, @@ -18,8 +19,8 @@ export function DiffControls() { const { updatePreviewToolCallState, clearPreviewYaml } = useCopilotStore() - // Don't show anything if no diff is available - if (!diffWorkflow) { + // Don't show anything if no diff is available or diff is not ready + if (!diffWorkflow || !isDiffReady) { return null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index a2c33509869..492a0d5e86f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -45,6 +45,7 @@ export const WorkflowEdge = ({ // Get edge diff status const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) + const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady) const currentWorkflow = useCurrentWorkflow() // Generate edge identifier using block names (not IDs) to match diff analysis @@ -109,9 +110,10 @@ export const WorkflowEdge = ({ }, [diffAnalysis, id, currentWorkflow.blocks, currentWorkflow.edges, isShowingDiff]) // Determine edge diff status - let edgeDiffStatus: 'new' | 'deleted' | 'unchanged' | undefined = undefined + let edgeDiffStatus: 'new' | 'deleted' | 'unchanged' | null = null - if (diffAnalysis?.edge_diff && edgeIdentifier) { + // Only attempt to determine diff status if all required data is available + if (diffAnalysis?.edge_diff && edgeIdentifier && sourceName && targetName && isDiffReady) { if (isShowingDiff) { // In diff view, show new edges if (diffAnalysis.edge_diff.new_edges.includes(edgeIdentifier)) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts index c5d855d31e2..73d182b06ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts @@ -44,12 +44,13 @@ export function useCurrentWorkflow(): CurrentWorkflow { // Get normal workflow state const normalWorkflow = useWorkflowStore((state) => state.getWorkflowState()) - // Get diff state - const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() + // Get diff state - now including isDiffReady + const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore() // Debug: Log when diff state changes console.log('[useCurrentWorkflow] State update:', { isShowingDiff, + isDiffReady, hasDiffWorkflow: !!diffWorkflow, diffWorkflowBlockCount: diffWorkflow ? Object.keys(diffWorkflow.blocks).length : 0, timestamp: Date.now() @@ -57,8 +58,9 @@ export function useCurrentWorkflow(): CurrentWorkflow { // Create the abstracted interface const currentWorkflow = useMemo((): CurrentWorkflow => { - // Determine which workflow to use - const activeWorkflow = isShowingDiff && diffWorkflow ? diffWorkflow : normalWorkflow + // Determine which workflow to use - only use diff if it's ready + const shouldUseDiff = isShowingDiff && isDiffReady && !!diffWorkflow + const activeWorkflow = shouldUseDiff ? diffWorkflow : normalWorkflow // Debug: Log which workflow is being used and sample block diff status const sampleBlockId = Object.keys(activeWorkflow.blocks)[0] @@ -66,7 +68,7 @@ export function useCurrentWorkflow(): CurrentWorkflow { const sampleDiffStatus = sampleBlock ? (sampleBlock as any).is_diff : undefined console.log('[useCurrentWorkflow] Using workflow:', { - type: isShowingDiff && diffWorkflow ? 'diff' : 'normal', + type: shouldUseDiff ? 'diff' : 'normal', blockCount: Object.keys(activeWorkflow.blocks).length, sampleBlockId, sampleDiffStatus, @@ -86,9 +88,9 @@ export function useCurrentWorkflow(): CurrentWorkflow { needsRedeployment: activeWorkflow.needsRedeployment, hasActiveWebhook: activeWorkflow.hasActiveWebhook, - // Mode information - isDiffMode: isShowingDiff && !!diffWorkflow, - isNormalMode: !isShowingDiff || !diffWorkflow, + // Mode information - update to reflect ready state + isDiffMode: shouldUseDiff, + isNormalMode: !shouldUseDiff, // Full workflow state (for cases that need the complete object) workflowState: activeWorkflow, @@ -100,7 +102,7 @@ export function useCurrentWorkflow(): CurrentWorkflow { hasBlocks: () => Object.keys(activeWorkflow.blocks).length > 0, hasEdges: () => activeWorkflow.edges.length > 0, } - }, [normalWorkflow, isShowingDiff, diffWorkflow]) + }, [normalWorkflow, isShowingDiff, isDiffReady, diffWorkflow]) return currentWorkflow } \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index fa6fc9f7a7a..ad3675703fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -103,13 +103,14 @@ const WorkflowContent = React.memo(() => { const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow // Get diff analysis for edge reconstruction - const { diffAnalysis, isShowingDiff } = useWorkflowDiffStore() + const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore() // Reconstruct deleted edges when viewing original workflow const edgesForDisplay = useMemo(() => { // If we're not in diff mode and we have diff analysis with deleted edges, // we need to reconstruct those deleted edges and add them to the display - if (!isShowingDiff && diffAnalysis?.edge_diff?.deleted_edges) { + // Only do this if diff is ready to prevent race conditions + if (!isShowingDiff && isDiffReady && diffAnalysis?.edge_diff?.deleted_edges) { const reconstructedEdges: Edge[] = [] // Parse deleted edge identifiers to reconstruct edges @@ -152,7 +153,7 @@ const WorkflowContent = React.memo(() => { // Otherwise, just use the edges as-is return edges - }, [edges, isShowingDiff, diffAnalysis, blocks]) + }, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks]) // User permissions - get current user's specific permissions from context const userPermissions = useUserPermissionsContext() diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index e75e3cbec62..c6334523665 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -72,13 +72,21 @@ You are a workflow automation assistant with FULL editing capabilities for Sim S 2. **Get All Blocks and Tools** 3. **Get Block Metadata** (for blocks you'll use) 4. **Get YAML Structure Guide** -5. **Preview Workflow** (ONLY after steps 1-4) +5. **Preview Workflow** OR **Targeted Updates** (ONLY after steps 1-4) **ENFORCEMENT**: - This sequence is MANDATORY for EVERY edit - NO shortcuts based on previous responses - Each edit request starts fresh -- Skipping steps will cause errors` +- Skipping steps will cause errors + +**TARGETED UPDATES RESTRICTION**: +⚠️ **ABSOLUTELY NO TARGETED UPDATES WITHOUT PREREQUISITES**: +- You are FORBIDDEN from using the \`targeted_updates\` tool until you have completed ALL prerequisite steps (1-4) +- Even for "simple" changes or single block edits +- Even if you think you "remember" the workflow structure +- NO EXCEPTIONS - targeted updates are only allowed after going through the complete information gathering sequence +- Violation of this rule will result in errors and incomplete workflow modifications` /** * Tool usage guidelines shared by both modes diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 9f6f2968bed..0c17fc8e725 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -14,6 +14,7 @@ const diffEngine = new WorkflowDiffEngine() interface WorkflowDiffState { isShowingDiff: boolean + isDiffReady: boolean // New flag to track when diff is fully ready diffWorkflow: WorkflowState | null diffAnalysis: DiffAnalysis | null diffMetadata: { @@ -39,6 +40,7 @@ export const useWorkflowDiffStore = create ({ isShowingDiff: false, + isDiffReady: false, // Initialize to false diffWorkflow: null, diffAnalysis: null, diffMetadata: null, @@ -46,6 +48,9 @@ export const useWorkflowDiffStore = create { logger.info('Setting proposed changes via YAML') + // First, set isDiffReady to false to prevent premature rendering + set({ isDiffReady: false }) + const result = await diffEngine.createDiffFromYaml(yamlContent, diffAnalysis) if (result.success && result.diff) { @@ -62,8 +67,10 @@ export const useWorkflowDiffStore = create { - const { isShowingDiff } = get() - logger.info('Toggling diff view', { currentState: isShowingDiff }) - set({ isShowingDiff: !isShowingDiff }) + const { isShowingDiff, isDiffReady } = get() + logger.info('Toggling diff view', { currentState: isShowingDiff, isDiffReady }) + + // Only toggle if diff is ready or we're turning off diff view + if (!isShowingDiff || isDiffReady) { + set({ isShowingDiff: !isShowingDiff }) + } else { + logger.warn('Cannot toggle to diff view - diff not ready') + } }, acceptChanges: async () => { @@ -189,9 +205,10 @@ export const useWorkflowDiffStore = create { - const { isShowingDiff } = get() + const { isShowingDiff, isDiffReady } = get() - if (isShowingDiff && diffEngine.hasDiff()) { + // Only return diff workflow if both showing diff AND diff is ready + if (isShowingDiff && isDiffReady && diffEngine.hasDiff()) { logger.debug('Returning diff workflow for canvas') const currentState = useWorkflowStore.getState().getWorkflowState() return diffEngine.getDisplayState(currentState) From fc98e13983d96d67b8c955ad837523cc5b8f9569 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 13:30:09 -0700 Subject: [PATCH 069/184] Diff goes away when switching workflows --- .../panel/components/copilot/copilot.tsx | 4 +- apps/sim/stores/copilot/store.ts | 106 +++++++++++++++++- apps/sim/stores/copilot/types.ts | 2 +- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 65d36e8ad9b..d12c4210fe7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -86,7 +86,9 @@ export const Copilot = forwardRef( // Sync workflow ID with store useEffect(() => { if (activeWorkflowId !== workflowId) { - setWorkflowId(activeWorkflowId) + setWorkflowId(activeWorkflowId).catch((error) => { + console.error('Failed to set workflow ID:', error) + }) } }, [activeWorkflowId, workflowId, setWorkflowId]) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 548cbfeb400..713f3613d37 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -40,7 +40,7 @@ const initialState = { } /** - * Helper function to create a new user message + * Helper function to create a new user messagenow let */ function createUserMessage(content: string): CopilotMessage { return { @@ -132,11 +132,35 @@ export const useCopilotStore = create()( }, // Set current workflow ID - setWorkflowId: (workflowId: string | null) => { + setWorkflowId: async (workflowId: string | null) => { const currentWorkflowId = get().workflowId if (currentWorkflowId !== workflowId) { logger.info(`Workflow ID changed from ${currentWorkflowId} to ${workflowId}`) + // Auto-reject any pending diff changes before switching workflows + try { + // Import diff store dynamically to avoid circular dependencies + const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') + const diffStore = useWorkflowDiffStore.getState() + + // Check if there are any pending diff changes + if (diffStore.diffWorkflow && diffStore.isDiffReady) { + logger.info('Auto-rejecting pending diff changes before workflow change') + + // Reject the changes in the diff store + diffStore.rejectChanges() + + // Update copilot tool call state and clear preview YAML + get().updatePreviewToolCallState('rejected') + await get().clearPreviewYaml() + + logger.info('Successfully auto-rejected pending diff changes') + } + } catch (error) { + logger.error('Failed to auto-reject pending changes during workflow change:', error) + // Don't prevent workflow change if cleanup fails + } + // Clear all state to prevent cross-workflow data leaks set({ workflowId, @@ -229,13 +253,50 @@ export const useCopilotStore = create()( // Select a specific chat selectChat: async (chat: CopilotChat) => { - const { workflowId } = get() + const { workflowId, currentChat } = get() if (!workflowId) { logger.error('Cannot select chat: no workflow ID set') return } + // Auto-reject any pending diff changes before switching chats + if (currentChat && currentChat.id !== chat.id) { + logger.info(`Chat change detected: ${currentChat.id} -> ${chat.id}`) + try { + // Import diff store dynamically to avoid circular dependencies + const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') + const diffStore = useWorkflowDiffStore.getState() + + logger.info('Diff store state:', { + hasDiffWorkflow: !!diffStore.diffWorkflow, + isDiffReady: diffStore.isDiffReady, + isShowingDiff: diffStore.isShowingDiff + }) + + // Check if there are any pending diff changes + if (diffStore.diffWorkflow && diffStore.isDiffReady) { + logger.info('Auto-rejecting pending diff changes before chat change') + + // Reject the changes in the diff store + diffStore.rejectChanges() + + // Update copilot tool call state and clear preview YAML + get().updatePreviewToolCallState('rejected') + await get().clearPreviewYaml() + + logger.info('Successfully auto-rejected pending diff changes') + } else { + logger.info('No pending diff changes to reject') + } + } catch (error) { + logger.error('Failed to auto-reject pending changes during chat change:', error) + // Don't prevent chat change if cleanup fails + } + } else { + logger.info('No chat change detected or no current chat') + } + set({ isLoading: true, error: null }) try { @@ -270,12 +331,49 @@ export const useCopilotStore = create()( // Create a new chat createNewChat: async (options = {}) => { - const { workflowId } = get() + const { workflowId, currentChat } = get() if (!workflowId) { logger.warn('Cannot create chat: no workflow ID set') return } + // Auto-reject any pending diff changes before creating new chat + if (currentChat) { + logger.info(`Creating new chat while current chat exists: ${currentChat.id}`) + try { + // Import diff store dynamically to avoid circular dependencies + const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') + const diffStore = useWorkflowDiffStore.getState() + + logger.info('Diff store state:', { + hasDiffWorkflow: !!diffStore.diffWorkflow, + isDiffReady: diffStore.isDiffReady, + isShowingDiff: diffStore.isShowingDiff + }) + + // Check if there are any pending diff changes + if (diffStore.diffWorkflow && diffStore.isDiffReady) { + logger.info('Auto-rejecting pending diff changes before creating new chat') + + // Reject the changes in the diff store + diffStore.rejectChanges() + + // Update copilot tool call state and clear preview YAML + get().updatePreviewToolCallState('rejected') + await get().clearPreviewYaml() + + logger.info('Successfully auto-rejected pending diff changes') + } else { + logger.info('No pending diff changes to reject') + } + } catch (error) { + logger.error('Failed to auto-reject pending changes during new chat creation:', error) + // Don't prevent new chat creation if cleanup fails + } + } else { + logger.info('Creating new chat with no current chat') + } + set({ isLoading: true, error: null }) try { diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 10515fe2bd3..3f8aa45a77f 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -147,7 +147,7 @@ export interface CopilotActions { setMode: (mode: CopilotMode) => void // Chat management - setWorkflowId: (workflowId: string | null) => void + setWorkflowId: (workflowId: string | null) => Promise validateCurrentChat: () => boolean loadChats: () => Promise selectChat: (chat: CopilotChat) => Promise From 1bc8c04fbf6b1b4bc3c9832ed003b735a1676b2e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 14:00:53 -0700 Subject: [PATCH 070/184] Fixes --- .../professional-message.tsx | 13 ++++++---- apps/sim/lib/copilot/prompts.ts | 6 +++++ apps/sim/stores/copilot/store.ts | 26 ++++++++++++------- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 07cbfaa2df2..e6cffd15cfc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -65,8 +65,8 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepN return duration < 1000 ? `${duration}ms` : `${(duration / 1000).toFixed(1)}s` } - // Special handling for preview workflow - const isPreviewTool = tool.name === 'preview_workflow' + // Special handling for preview workflow and targeted updates + const isPreviewTool = tool.name === 'preview_workflow' || tool.name === 'targeted_updates' if (isPreviewTool) { return ( @@ -102,7 +102,10 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepN tool.state === 'rejected' && 'text-orange-900 dark:text-orange-100', tool.state === 'error' && 'text-red-900 dark:text-red-100' )}> - {tool.state === 'executing' ? 'Building workflow' : (tool.displayName || tool.name)} + {tool.state === 'executing' + ? (tool.name === 'targeted_updates' ? 'Editing workflow' : 'Building workflow') + : (tool.displayName || tool.name) + }
    {tool.state === 'executing' - ? 'Building workflow...' + ? (tool.name === 'targeted_updates' ? 'Editing workflow...' : 'Building workflow...') : tool.state === 'ready_for_review' ? 'Ready for review' : tool.state === 'applied' ? 'Applied changes' : tool.state === 'rejected' ? 'Rejected changes' - : 'Workflow generation failed' + : (tool.name === 'targeted_updates' ? 'Workflow editing failed' : 'Workflow generation failed') }
    diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index 2f4cc636801..a2689eacb0b 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -1017,3 +1017,9 @@ For detailed examples and schemas: - **Best Practices**: Review the workflow building guide Remember: Always use the "Get All Blocks" and "Get Block Metadata" tools for the latest information when building workflows!` + +/** + * Function wrapper for YAML_WORKFLOW_PROMPT to maintain compatibility with API routes + * that expect a function call for lazy loading + */ +export const getYamlWorkflowPrompt = () => YAML_WORKFLOW_PROMPT diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 713f3613d37..e664ded4668 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -111,6 +111,8 @@ function getToolDisplayName(toolName: string): string { return 'Checking your environment variables' case 'set_environment_variables': return 'Setting your environment variables' + case 'targeted_updates': + return 'Editing workflow' default: return toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) } @@ -504,9 +506,9 @@ export const useCopilotStore = create()( updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => { const { messages } = get() - // Find the last message with a preview_workflow tool call + // Find the last message with a preview_workflow or targeted_updates tool call const lastMessageWithPreview = [...messages].reverse().find(msg => - msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow') + msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow' || tc.name === 'targeted_updates') ) if (lastMessageWithPreview) { @@ -515,10 +517,10 @@ export const useCopilotStore = create()( msg.id === lastMessageWithPreview.id ? { ...msg, toolCalls: msg.toolCalls?.map(tc => - tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc + (tc.name === 'preview_workflow' || tc.name === 'targeted_updates') ? { ...tc, state: toolCallState } : tc ), contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' + block.type === 'tool_call' && (block.toolCall.name === 'preview_workflow' || block.toolCall.name === 'targeted_updates') ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } : block ) @@ -539,11 +541,11 @@ export const useCopilotStore = create()( set({ isSendingMessage: true, error: null }) - // Update the preview_workflow tool call state if provided + // Update the preview_workflow or targeted_updates tool call state if provided if (toolCallState) { - // Find the last message with a preview_workflow tool call + // Find the last message with a preview_workflow or targeted_updates tool call const lastMessageWithPreview = [...messages].reverse().find(msg => - msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow') + msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow' || tc.name === 'targeted_updates') ) if (lastMessageWithPreview) { @@ -552,10 +554,10 @@ export const useCopilotStore = create()( msg.id === lastMessageWithPreview.id ? { ...msg, toolCalls: msg.toolCalls?.map(tc => - tc.name === 'preview_workflow' ? { ...tc, state: toolCallState } : tc + (tc.name === 'preview_workflow' || tc.name === 'targeted_updates') ? { ...tc, state: toolCallState } : tc ), contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && block.toolCall.name === 'preview_workflow' + block.type === 'tool_call' && (block.toolCall.name === 'preview_workflow' || block.toolCall.name === 'targeted_updates') ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } : block ) @@ -758,11 +760,17 @@ export const useCopilotStore = create()( }) get().setPreviewYaml(result.yamlContent) get().updateDiffStore(result.yamlContent) + + // Set the tool call state to ready_for_review like preview_workflow + existingToolCall.state = 'ready_for_review' } else { logger.error('Targeted updates tool_result missing yamlContent', { expectedPath: 'result.yamlContent', actualStructure: JSON.stringify(result, null, 2) }) + // Set to error state if yamlContent is missing + existingToolCall.state = 'error' + existingToolCall.error = 'Missing yamlContent in result' } } } else { From f174259f453b855f1385f1111f7e80e3cd5da52c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 14:06:48 -0700 Subject: [PATCH 071/184] Edge fixes --- apps/sim/lib/copilot/tools.ts | 86 +++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index 3aac37f72a7..cfa5317cf80 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -135,8 +135,35 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp Object.values(workflowData.blocks).forEach((block: any) => { if (block.connections) { Object.keys(block.connections).forEach(key => { - if (allDeletedBlocks.includes(block.connections[key])) { - delete block.connections[key] + const connectionValue = block.connections[key] + + if (typeof connectionValue === 'string') { + // Simple format: connections: { default: "block2" } + if (allDeletedBlocks.includes(connectionValue)) { + delete block.connections[key] + logger.info(`Removed connection ${key} to deleted block ${connectionValue}`) + } + } else if (Array.isArray(connectionValue)) { + // Array format: connections: { default: ["block2", "block3"] } + block.connections[key] = connectionValue.filter((item: any) => { + if (typeof item === 'string') { + return !allDeletedBlocks.includes(item) + } else if (typeof item === 'object' && item.block) { + return !allDeletedBlocks.includes(item.block) + } + return true + }) + + // If array is empty after filtering, remove the connection + if (block.connections[key].length === 0) { + delete block.connections[key] + } + } else if (typeof connectionValue === 'object' && connectionValue.block) { + // Object format: connections: { success: { block: "block2", input: "data" } } + if (allDeletedBlocks.includes(connectionValue.block)) { + delete block.connections[key] + logger.info(`Removed object connection ${key} to deleted block ${connectionValue.block}`) + } } }) } @@ -160,9 +187,62 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp // Update connections (preserve existing connections, only overwrite specified ones) if (params?.connections) { if (!block.connections) block.connections = {} - Object.assign(block.connections, params.connections) + + // Handle edge removals - if a connection is explicitly set to null, remove it + Object.entries(params.connections).forEach(([key, value]) => { + if (value === null) { + delete (block.connections as any)[key] + logger.info(`Removed connection ${key} from block ${block_id}`) + } else { + (block.connections as any)[key] = value + } + }) + logger.info(`Updated connections for block ${block_id}`, { connections: block.connections }) } + + // Handle edge removals when specified in params + if (params?.removeEdges && Array.isArray(params.removeEdges)) { + params.removeEdges.forEach((edgeToRemove: { targetBlockId: string, sourceHandle?: string, targetHandle?: string }) => { + if (!block.connections) return + + const { targetBlockId, sourceHandle = 'default' } = edgeToRemove + + // Handle different connection formats + const connectionValue = (block.connections as any)[sourceHandle] + + if (typeof connectionValue === 'string') { + // Simple format: connections: { default: "block2" } + if (connectionValue === targetBlockId) { + delete (block.connections as any)[sourceHandle] + logger.info(`Removed edge from ${block_id}:${sourceHandle} to ${targetBlockId}`) + } + } else if (Array.isArray(connectionValue)) { + // Array format: connections: { default: ["block2", "block3"] } + (block.connections as any)[sourceHandle] = connectionValue.filter((item: any) => { + if (typeof item === 'string') { + return item !== targetBlockId + } else if (typeof item === 'object' && item.block) { + return item.block !== targetBlockId + } + return true + }) + + // If array is empty after filtering, remove the connection + if ((block.connections as any)[sourceHandle].length === 0) { + delete (block.connections as any)[sourceHandle] + } + + logger.info(`Updated array connection for ${block_id}:${sourceHandle}`) + } else if (typeof connectionValue === 'object' && connectionValue.block) { + // Object format: connections: { success: { block: "block2", input: "data" } } + if (connectionValue.block === targetBlockId) { + delete (block.connections as any)[sourceHandle] + logger.info(`Removed object connection from ${block_id}:${sourceHandle} to ${targetBlockId}`) + } + } + }) + } } else { logger.warn(`Block ${block_id} not found for editing`) } From 5ca31ba5cb13fa82237df2d4f6223432afdb1056 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 14:10:50 -0700 Subject: [PATCH 072/184] Remove brief error --- apps/sim/stores/copilot/store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index e664ded4668..94f53b865a7 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -911,8 +911,8 @@ export const useCopilotStore = create()( try { // Parse complete tool call input toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') - // Set preview_workflow tools to ready_for_review, others to completed - toolCallBuffer.state = toolCallBuffer.name === 'preview_workflow' ? 'ready_for_review' : 'completed' + // Set preview_workflow and targeted_updates tools to ready_for_review, others to completed + toolCallBuffer.state = (toolCallBuffer.name === 'preview_workflow' || toolCallBuffer.name === 'targeted_updates') ? 'ready_for_review' : 'completed' toolCallBuffer.endTime = Date.now() toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime logger.info(`Tool call completed: ${toolCallBuffer.name}`, toolCallBuffer.input) From 7744f4c9bf4e1b3ac5eb0c55daeb8bccbf41c49f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 14:20:58 -0700 Subject: [PATCH 073/184] Lint --- apps/sim/app/api/copilot/route.ts | 8 +- .../app/api/copilot/targeted-updates/route.ts | 16 +- .../app/api/environment/variables/route.ts | 110 +++-- .../sim/app/api/tools/get-all-blocks/route.ts | 10 +- .../api/tools/get-blocks-metadata/route.ts | 14 +- .../api/tools/get-workflow-console/route.ts | 23 +- .../api/tools/get-workflow-examples/route.ts | 2 +- .../sim/app/api/workflows/[id]/state/route.ts | 15 +- apps/sim/app/api/workflows/diff/route.ts | 290 ++++++------ apps/sim/app/api/workflows/preview/route.ts | 11 +- .../components/control-bar/control-bar.tsx | 5 +- .../copilot-sandbox-modal.tsx | 123 +++-- .../[workflowId]/components/diff-controls.tsx | 48 +- .../components/loop-node/loop-node.tsx | 9 +- .../output-select/output-select.tsx | 11 +- .../copilot-modal/copilot-modal.tsx | 47 +- .../professional-message.tsx | 306 +++++++------ .../panel/components/copilot/copilot.tsx | 17 +- .../parallel-node/parallel-node.tsx | 9 +- .../[workflowId]/components/review-button.tsx | 122 +++-- .../sub-block/hooks/use-sub-block-value.ts | 16 +- .../components/sub-block/sub-block.tsx | 12 +- .../workflow-block/workflow-block.tsx | 72 +-- .../workflow-edge/workflow-edge.tsx | 55 ++- .../w/[workflowId]/hooks/index.ts | 4 +- .../[workflowId]/hooks/use-copilot-sandbox.ts | 164 +++---- .../hooks/use-current-workflow.ts | 36 +- .../hooks/use-workflow-execution.ts | 35 +- .../w/[workflowId]/utils/auto-layout.ts | 18 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 37 +- .../executor/handlers/agent/agent-handler.ts | 1 - .../handlers/evaluator/evaluator-handler.ts | 3 +- apps/sim/hooks/use-collaborative-workflow.ts | 2 +- .../lib/autolayout/algorithms/hierarchical.ts | 14 +- apps/sim/lib/autolayout/algorithms/smart.ts | 16 +- apps/sim/lib/autolayout/service.ts | 4 +- apps/sim/lib/copilot/examples.ts | 18 +- apps/sim/lib/copilot/service.ts | 49 +- apps/sim/lib/copilot/tools.ts | 306 +++++++------ apps/sim/lib/environment/utils.ts | 4 +- apps/sim/lib/workflows/diff/diff-engine.ts | 196 ++++---- apps/sim/lib/workflows/diff/index.ts | 4 +- .../lib/workflows/diff/use-workflow-diff.ts | 77 ++-- apps/sim/lib/workflows/yaml-converter.ts | 156 ++++--- apps/sim/providers/anthropic/index.ts | 91 ++-- apps/sim/stores/copilot/preview-store.ts | 28 +- apps/sim/stores/copilot/store.ts | 422 +++++++++++------- apps/sim/stores/copilot/types.ts | 11 +- apps/sim/stores/workflow-diff/index.ts | 2 +- apps/sim/stores/workflow-diff/store.ts | 64 ++- apps/sim/stores/workflows/workflow/store.ts | 2 +- apps/sim/stores/workflows/workflow/types.ts | 2 +- apps/sim/tools/blocks/preview-workflow.ts | 2 +- apps/sim/tools/environment/get-variables.ts | 7 +- apps/sim/tools/environment/set-variables.ts | 10 +- apps/sim/tools/utils.ts | 10 +- apps/sim/tools/workflow/get-console.ts | 5 +- apps/sim/tools/workflow/get-examples.ts | 7 +- apps/sim/tools/workflow/targeted-updates.ts | 8 +- 59 files changed, 1784 insertions(+), 1382 deletions(-) diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 4071d750dda..7b7f6a79902 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -155,13 +155,15 @@ export async function POST(req: NextRequest) { } if (streamToRead) { - logger.info(`[${requestId}] Returning native SSE streaming response with chatId: ${result.chatId}`) + logger.info( + `[${requestId}] Returning native SSE streaming response with chatId: ${result.chatId}` + ) // Create a new stream that first sends the chatId, then forwards the actual response const transformedStream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() - + // First, send the chatId as an SSE event if (result.chatId) { const chatIdEvent = `data: ${JSON.stringify({ type: 'chat_id', chatId: result.chatId })}\n\n` @@ -182,7 +184,7 @@ export async function POST(req: NextRequest) { } finally { controller.close() } - } + }, }) // Pass through native Anthropic SSE events directly to the frontend diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts index 7dbd13fc457..83dd0c01724 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { executeCopilotTool } from '@/lib/copilot/tools' import { createLogger } from '@/lib/logs/console-logger' @@ -26,23 +26,23 @@ export async function POST(request: NextRequest) { logger.info('Executing targeted updates', { workflowId, operationCount: operations.length, - operations: operations.map(op => ({ type: op.operation_type, blockId: op.block_id })) + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), }) - const result = await executeCopilotTool('targeted_updates', { + const result = await executeCopilotTool('targeted_updates', { operations, - _context: { workflowId } + _context: { workflowId }, }) return NextResponse.json(result) } catch (error) { logger.error('Targeted updates API failed:', error) return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 } ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/environment/variables/route.ts b/apps/sim/app/api/environment/variables/route.ts index 1846e91ac1c..8c12db1e90f 100644 --- a/apps/sim/app/api/environment/variables/route.ts +++ b/apps/sim/app/api/environment/variables/route.ts @@ -1,10 +1,10 @@ -import { NextRequest, NextResponse } from 'next/server' import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getUserId } from '@/app/api/auth/oauth/utils' import { getEnvironmentVariableKeys } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console-logger' import { encryptSecret } from '@/lib/utils' +import { getUserId } from '@/app/api/auth/oauth/utils' import { db } from '@/db' import { environment } from '@/db/schema' @@ -22,10 +22,10 @@ export async function GET(request: NextRequest) { // For GET requests, check for workflowId in query params const { searchParams } = new URL(request.url) const workflowId = searchParams.get('workflowId') - + // Use dual authentication pattern like other copilot tools const userId = await getUserId(requestId, workflowId || undefined) - + if (!userId) { logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -34,16 +34,22 @@ export async function GET(request: NextRequest) { // Get only the variable names (keys), not values const result = await getEnvironmentVariableKeys(userId) - return NextResponse.json({ - success: true, - output: result - }, { status: 200 }) + return NextResponse.json( + { + success: true, + output: result, + }, + { status: 200 } + ) } catch (error: any) { logger.error(`[${requestId}] Environment variables fetch error`, error) - return NextResponse.json({ - success: false, - error: error.message || 'Failed to get environment variables' - }, { status: 500 }) + return NextResponse.json( + { + success: false, + error: error.message || 'Failed to get environment variables', + }, + { status: 500 } + ) } } @@ -53,10 +59,10 @@ export async function PUT(request: NextRequest) { try { const body = await request.json() const { workflowId, variables } = body - + // Use dual authentication pattern like other copilot tools const userId = await getUserId(requestId, workflowId) - + if (!userId) { logger.warn(`[${requestId}] Unauthorized environment variables set attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -73,7 +79,7 @@ export async function PUT(request: NextRequest) { .limit(1) // Start with existing variables or empty object - const existingVariables = existingData[0]?.variables as Record || {} + const existingVariables = (existingData[0]?.variables as Record) || {} // Merge new variables with existing ones (new variables will override existing ones with same key) const mergedVariables = { ...existingVariables, ...validatedVariables } @@ -106,20 +112,27 @@ export async function PUT(request: NextRequest) { }) // Determine which variables were added vs updated - const addedVariables = Object.keys(validatedVariables).filter(key => !(key in existingVariables)) - const updatedVariables = Object.keys(validatedVariables).filter(key => key in existingVariables) + const addedVariables = Object.keys(validatedVariables).filter( + (key) => !(key in existingVariables) + ) + const updatedVariables = Object.keys(validatedVariables).filter( + (key) => key in existingVariables + ) - return NextResponse.json({ - success: true, - output: { - message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`, - variableCount: Object.keys(validatedVariables).length, - variableNames: Object.keys(validatedVariables), - totalVariableCount: Object.keys(mergedVariables).length, - addedVariables, - updatedVariables, - } - }, { status: 200 }) + return NextResponse.json( + { + success: true, + output: { + message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`, + variableCount: Object.keys(validatedVariables).length, + variableNames: Object.keys(validatedVariables), + totalVariableCount: Object.keys(mergedVariables).length, + addedVariables, + updatedVariables, + }, + }, + { status: 200 } + ) } catch (validationError) { if (validationError instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid environment variables data`, { @@ -134,12 +147,15 @@ export async function PUT(request: NextRequest) { } } catch (error: any) { logger.error(`[${requestId}] Environment variables set error`, error) - return NextResponse.json({ - success: false, - error: error.message || 'Failed to set environment variables' - }, { status: 500 }) + return NextResponse.json( + { + success: false, + error: error.message || 'Failed to set environment variables', + }, + { status: 500 } + ) } -} +} export async function POST(request: NextRequest) { const requestId = crypto.randomUUID().slice(0, 8) @@ -147,10 +163,10 @@ export async function POST(request: NextRequest) { try { const body = await request.json() const { workflowId } = body - + // Use dual authentication pattern like other copilot tools const userId = await getUserId(requestId, workflowId) - + if (!userId) { logger.warn(`[${requestId}] Unauthorized environment variables access attempt`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -159,15 +175,21 @@ export async function POST(request: NextRequest) { // Get only the variable names (keys), not values const result = await getEnvironmentVariableKeys(userId) - return NextResponse.json({ - success: true, - output: result - }, { status: 200 }) + return NextResponse.json( + { + success: true, + output: result, + }, + { status: 200 } + ) } catch (error: any) { logger.error(`[${requestId}] Environment variables fetch error`, error) - return NextResponse.json({ - success: false, - error: error.message || 'Failed to get environment variables' - }, { status: 500 }) + return NextResponse.json( + { + success: false, + error: error.message || 'Failed to get environment variables', + }, + { status: 500 } + ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/tools/get-all-blocks/route.ts b/apps/sim/app/api/tools/get-all-blocks/route.ts index 2e7b8b22bf5..eafe504e9ad 100644 --- a/apps/sim/app/api/tools/get-all-blocks/route.ts +++ b/apps/sim/app/api/tools/get-all-blocks/route.ts @@ -40,10 +40,10 @@ export async function POST(request: NextRequest) { description: 'Control flow block for iterating over collections or repeating actions', }, parallel: { - tools: [], // Parallel blocks don't use standard tools + tools: [], // Parallel blocks don't use standard tools category: 'blocks', description: 'Control flow block for executing multiple branches simultaneously', - } + }, } // Add special blocks if they pass the category filter @@ -69,8 +69,10 @@ export async function POST(request: NextRequest) { filterCategory, blockToolsMapping: blockToolsInfo, outputMapping: blockToToolsMapping, - specialBlocksAdded: Object.keys(specialBlocks).filter(blockType => - !filterCategory || specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory + specialBlocksAdded: Object.keys(specialBlocks).filter( + (blockType) => + !filterCategory || + specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory ), }) diff --git a/apps/sim/app/api/tools/get-blocks-metadata/route.ts b/apps/sim/app/api/tools/get-blocks-metadata/route.ts index 33e6350dd48..ceda2f37160 100644 --- a/apps/sim/app/api/tools/get-blocks-metadata/route.ts +++ b/apps/sim/app/api/tools/get-blocks-metadata/route.ts @@ -32,7 +32,8 @@ const SPECIAL_BLOCKS_METADATA: Record = { type: 'loop', name: 'Loop', description: 'Control flow block for iterating over collections or repeating actions', - longDescription: 'Execute a set of blocks repeatedly, either for a fixed number of iterations or for each item in a collection. Loop blocks create sub-workflows that run multiple times with different iteration data.', + longDescription: + 'Execute a set of blocks repeatedly, either for a fixed number of iterations or for each item in a collection. Loop blocks create sub-workflows that run multiple times with different iteration data.', category: 'blocks', bgColor: '#9333EA', subBlocks: [ @@ -59,7 +60,7 @@ const SPECIAL_BLOCKS_METADATA: Record = { { id: 'collection', title: 'Collection', - type: 'short-input', + type: 'short-input', layout: 'full', placeholder: 'Reference to array or object', condition: { field: 'iterationType', value: 'forEach' }, @@ -81,7 +82,8 @@ const SPECIAL_BLOCKS_METADATA: Record = { type: 'parallel', name: 'Parallel', description: 'Control flow block for executing multiple branches simultaneously', - longDescription: 'Execute multiple sets of blocks simultaneously, either with a fixed number of parallel branches or by distributing items from a collection across parallel executions.', + longDescription: + 'Execute multiple sets of blocks simultaneously, either with a fixed number of parallel branches or by distributing items from a collection across parallel executions.', category: 'blocks', bgColor: '#059669', subBlocks: [ @@ -182,15 +184,15 @@ export async function POST(request: NextRequest) { for (const blockId of blockIds) { const blockConfig = blockRegistry[blockId] - + // Check if it's a special block not in the standard registry if (!blockConfig && SPECIAL_BLOCKS_METADATA[blockId]) { const specialBlock = SPECIAL_BLOCKS_METADATA[blockId] - + // Check if this special block has YAML documentation if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { const yamlSchema = getYamlSchemaFromDocs(blockId) - + if (yamlSchema) { result[blockId] = { type: 'block', diff --git a/apps/sim/app/api/tools/get-workflow-console/route.ts b/apps/sim/app/api/tools/get-workflow-console/route.ts index 414be946070..92896cc3a5a 100644 --- a/apps/sim/app/api/tools/get-workflow-console/route.ts +++ b/apps/sim/app/api/tools/get-workflow-console/route.ts @@ -2,7 +2,7 @@ import { desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { workflowExecutionLogs, workflowExecutionBlocks } from '@/db/schema' +import { workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema' const logger = createLogger('GetWorkflowConsoleAPI') @@ -43,11 +43,11 @@ export async function POST(request: NextRequest) { .limit(Math.min(limit, 100)) let blockLogs: any[] = [] - + // If we have execution logs and details are requested, get block-level logs if (executionLogs.length > 0 && includeDetails) { - const executionIds = executionLogs.map(log => log.executionId) - + const executionIds = executionLogs.map((log) => log.executionId) + blockLogs = await db .select({ id: workflowExecutionBlocks.id, @@ -84,7 +84,7 @@ export async function POST(request: NextRequest) { blockCount: log.blockCount, successCount: log.successCount, errorCount: log.errorCount, - totalCost: log.totalCost ? parseFloat(log.totalCost.toString()) : null, + totalCost: log.totalCost ? Number.parseFloat(log.totalCost.toString()) : null, type: 'execution', } @@ -111,7 +111,7 @@ export async function POST(request: NextRequest) { durationMs: block.durationMs, input: block.inputData, output: block.outputData, - cost: block.costTotal ? parseFloat(block.costTotal.toString()) : null, + cost: block.costTotal ? Number.parseFloat(block.costTotal.toString()) : null, tokens: block.tokensTotal, type: 'block', })) @@ -128,19 +128,18 @@ export async function POST(request: NextRequest) { workflowId, retrievedAt: new Date().toISOString(), hasBlockDetails: includeDetails && blockLogs.length > 0, - } + }, } return NextResponse.json(response) - } catch (error) { logger.error('Failed to get workflow console logs:', error) return NextResponse.json( - { - success: false, - error: `Failed to get console logs: ${error instanceof Error ? error.message : 'Unknown error'}` + { + success: false, + error: `Failed to get console logs: ${error instanceof Error ? error.message : 'Unknown error'}`, }, { status: 500 } ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/tools/get-workflow-examples/route.ts b/apps/sim/app/api/tools/get-workflow-examples/route.ts index ca5de30580e..455a6befaa1 100644 --- a/apps/sim/app/api/tools/get-workflow-examples/route.ts +++ b/apps/sim/app/api/tools/get-workflow-examples/route.ts @@ -47,4 +47,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index ea4a6593960..bf46321c148 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -174,12 +174,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ // Save to normalized tables // Ensure all required fields are present for WorkflowState type // Filter out blocks without type or name before saving - const filteredBlocks = Object.entries(state.blocks).reduce((acc, [blockId, block]) => { - if (block.type && block.name) { - acc[blockId] = block - } - return acc - }, {} as typeof state.blocks) + const filteredBlocks = Object.entries(state.blocks).reduce( + (acc, [blockId, block]) => { + if (block.type && block.name) { + acc[blockId] = block + } + return acc + }, + {} as typeof state.blocks + ) const workflowState = { blocks: filteredBlocks, diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index 49fe9725b58..ff08c96b36e 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -1,9 +1,9 @@ +import crypto from 'crypto' +import { dump as yamlDump, load as yamlParse } from 'js-yaml' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import crypto from 'crypto' import { createLogger } from '@/lib/logs/console-logger' import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' -import { load as yamlParse, dump as yamlDump } from 'js-yaml' const logger = createLogger('WorkflowYamlDiffAPI') @@ -22,36 +22,40 @@ function cleanupYamlContent(yamlContent: string): string { try { // Parse the YAML const workflow = yamlParse(yamlContent) as any - + if (!workflow || !workflow.blocks) { return yamlContent } - + // Filter out empty blocks const cleanedBlocks: Record = {} Object.entries(workflow.blocks).forEach(([blockId, block]) => { // Only include blocks that have at least type and name - if (block && typeof block === 'object' && - (block as any).type && (block as any).name && - Object.keys(block).length > 0) { + if ( + block && + typeof block === 'object' && + (block as any).type && + (block as any).name && + Object.keys(block).length > 0 + ) { cleanedBlocks[blockId] = block } else { logger.info(`Filtering out empty block: ${blockId}`) } }) - + // Rebuild the workflow with cleaned blocks const cleanedWorkflow = { ...workflow, - blocks: cleanedBlocks + blocks: cleanedBlocks, } - + // Convert back to YAML - return yamlDump(cleanedWorkflow, { + return yamlDump(cleanedWorkflow, { indent: 2, lineWidth: -1, noRefs: true, - sortKeys: false + sortKeys: false, }) } catch (error) { logger.warn('Failed to clean YAML content, returning original', error) @@ -69,7 +73,7 @@ interface DiffResult { deleted_blocks: string[] edited_blocks: string[] new_blocks: string[] - field_diffs?: Record + field_diffs?: Record edge_diff?: EdgeDiff } @@ -92,7 +96,12 @@ interface EdgeIdentity { * Generate a unique identifier for an edge based on block names (not IDs) * Must match the frontend logic which defaults sourceHandle to 'success' */ -function generateEdgeIdentity(sourceName: string, targetName: string, sourceHandle?: string, targetHandle?: string): string { +function generateEdgeIdentity( + sourceName: string, + targetName: string, + sourceHandle?: string, + targetHandle?: string +): string { // Match frontend logic: use 'success' as default when sourceHandle is undefined/null const effectiveSourceHandle = sourceHandle || 'success' return `${sourceName}:${effectiveSourceHandle}->${targetName}${targetHandle ? `:${targetHandle}` : ''}` @@ -103,11 +112,11 @@ function generateEdgeIdentity(sourceName: string, targetName: string, sourceHand */ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { const edges: EdgeIdentity[] = [] - + if (!yamlWorkflow.blocks || typeof yamlWorkflow.blocks !== 'object') { return edges } - + // Create mapping from block ID to block name const blockIdToName = new Map() Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { @@ -115,24 +124,26 @@ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { blockIdToName.set(blockId, block.name) } }) - + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { if (!block || typeof block !== 'object' || !block.connections) { return } - + const sourceName = blockIdToName.get(blockId) if (!sourceName) return - + const connections = block.connections - + // Handle 'default' connections (simple format) if (connections.default) { - const targets = Array.isArray(connections.default) ? connections.default : [connections.default] + const targets = Array.isArray(connections.default) + ? connections.default + : [connections.default] targets.forEach((targetId: string) => { const targetName = blockIdToName.get(targetId) if (!targetName) return - + const edgeId = generateEdgeIdentity(sourceName, targetName) edges.push({ id: edgeId, @@ -141,17 +152,17 @@ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { }) }) } - + // Handle named output connections Object.entries(connections).forEach(([outputName, targets]) => { if (outputName === 'default') return // Already handled - + const targetList = Array.isArray(targets) ? targets : [targets] targetList.forEach((target: any) => { if (typeof target === 'string') { const targetName = blockIdToName.get(target) if (!targetName) return - + const edgeId = generateEdgeIdentity(sourceName, targetName, outputName) edges.push({ id: edgeId, @@ -162,7 +173,7 @@ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { } else if (typeof target === 'object' && target.block) { const targetName = blockIdToName.get(target.block) if (!targetName) return - + const edgeId = generateEdgeIdentity(sourceName, targetName, outputName, target.input) edges.push({ id: edgeId, @@ -175,7 +186,7 @@ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { }) }) }) - + return edges } @@ -185,23 +196,26 @@ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { function compareEdges( originalEdges: EdgeIdentity[], agentEdges: EdgeIdentity[], - blockNameToHash: { originalNameToHash: Map, agentNameToHash: Map }, - blockDiff: { new_blocks: string[], deleted_blocks: string[], edited_blocks: string[] } + blockNameToHash: { + originalNameToHash: Map + agentNameToHash: Map + }, + blockDiff: { new_blocks: string[]; deleted_blocks: string[]; edited_blocks: string[] } ): EdgeDiff { const result: EdgeDiff = { new_edges: [], deleted_edges: [], unchanged_edges: [], } - + // Create edge ID sets for comparison - const originalEdgeIds = new Set(originalEdges.map(e => e.id)) - const agentEdgeIds = new Set(agentEdges.map(e => e.id)) - + const originalEdgeIds = new Set(originalEdges.map((e) => e.id)) + const agentEdgeIds = new Set(agentEdges.map((e) => e.id)) + // Get block names that are new or deleted const newBlockNames = new Set() const deletedBlockNames = new Set() - + // Map block IDs to names for new/deleted blocks Array.from(blockNameToHash.originalNameToHash.entries()).forEach(([name, _]) => { const nameExistsInAgent = blockNameToHash.agentNameToHash.has(name) @@ -209,40 +223,40 @@ function compareEdges( deletedBlockNames.add(name) } }) - + Array.from(blockNameToHash.agentNameToHash.entries()).forEach(([name, _]) => { const nameExistsInOriginal = blockNameToHash.originalNameToHash.has(name) if (!nameExistsInOriginal) { newBlockNames.add(name) } }) - + // Find deleted edges (in original but not in agent) - originalEdges.forEach(edge => { + originalEdges.forEach((edge) => { // An edge is deleted if: // 1. The edge doesn't exist in the agent workflow (was removed), OR // 2. Either its source or target block was deleted const edgeRemoved = !agentEdgeIds.has(edge.id) const sourceDeleted = deletedBlockNames.has(edge.source) const targetDeleted = deletedBlockNames.has(edge.target) - + if (edgeRemoved || sourceDeleted || targetDeleted) { result.deleted_edges.push(edge.id) } }) - + // Find new and unchanged edges in agent workflow - agentEdges.forEach(edge => { + agentEdges.forEach((edge) => { const isNewEdge = !originalEdgeIds.has(edge.id) const connectsToNewBlock = newBlockNames.has(edge.source) || newBlockNames.has(edge.target) - + if (isNewEdge || connectsToNewBlock) { result.new_edges.push(edge.id) } else { result.unchanged_edges.push(edge.id) } }) - + return result } @@ -252,20 +266,20 @@ function compareEdges( function compareBlockInputs( originalInputs: Record, agentInputs: Record -): { changed_fields: string[], unchanged_fields: string[] } { +): { changed_fields: string[]; unchanged_fields: string[] } { const changed_fields: string[] = [] const unchanged_fields: string[] = [] - + // Get all unique field names from both blocks const allFields = new Set([ ...Object.keys(originalInputs || {}), - ...Object.keys(agentInputs || {}) + ...Object.keys(agentInputs || {}), ]) - + for (const field of allFields) { const originalValue = originalInputs?.[field] const agentValue = agentInputs?.[field] - + // Normalize values for comparison (handle null/undefined/empty string equivalence) const normalizeValue = (value: any) => { if (value === null || value === undefined || value === '') { @@ -276,17 +290,17 @@ function compareBlockInputs( } return String(value).trim() } - + const normalizedOriginal = normalizeValue(originalValue) const normalizedAgent = normalizeValue(agentValue) - + if (normalizedOriginal !== normalizedAgent) { changed_fields.push(field) } else { unchanged_fields.push(field) } } - + return { changed_fields, unchanged_fields } } @@ -296,68 +310,76 @@ function compareBlockInputs( function hashBlockContents(block: any): string { // Create a copy of the block to avoid mutating the original const blockCopy = JSON.parse(JSON.stringify(block)) - + // Extract the properties we want to hash const hashableContent = { type: blockCopy.type, inputs: blockCopy.inputs || {}, parentId: blockCopy.parentId || null, } - + // Debug: Log what content will be hashed console.log(`Hashing block content for ${block.name}:`, JSON.stringify(hashableContent, null, 2)) - + // Remove any ID fields from inputs recursively function removeIds(obj: any): any { if (obj === null || obj === undefined) { return obj } - + if (Array.isArray(obj)) { return obj.map(removeIds) } - + if (typeof obj === 'object') { const cleaned: any = {} for (const [key, value] of Object.entries(obj)) { // Skip only actual ID fields (not fields like "apiKey" that contain "id") - if (key === 'id' || key === 'blockId' || key === 'targetId' || key === 'sourceId' || - key.endsWith('Id') || key.endsWith('_id')) { + if ( + key === 'id' || + key === 'blockId' || + key === 'targetId' || + key === 'sourceId' || + key.endsWith('Id') || + key.endsWith('_id') + ) { continue } cleaned[key] = removeIds(value) } return cleaned } - + return obj } - + const cleanedContent = removeIds(hashableContent) - + // Debug: Log what content will actually be hashed after ID removal console.log(`Cleaned content for ${block.name}:`, JSON.stringify(cleanedContent, null, 2)) - + // Create deterministic JSON string (sorted keys recursively) const sortObjectKeys = (obj: any): any => { if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) { return obj } - + const sorted: any = {} - Object.keys(obj).sort().forEach(key => { - sorted[key] = sortObjectKeys(obj[key]) - }) + Object.keys(obj) + .sort() + .forEach((key) => { + sorted[key] = sortObjectKeys(obj[key]) + }) return sorted } - + const sortedContent = sortObjectKeys(cleanedContent) - + // Hash the content const hash = crypto.createHash('sha256').update(JSON.stringify(sortedContent)).digest('hex') - + console.log(`Generated hash for ${block.name}: ${hash.substring(0, 8)}...`) - + return hash } @@ -366,25 +388,25 @@ function hashBlockContents(block: any): string { */ function extractBlockHashes(yamlWorkflow: any): BlockHash[] { const blockHashes: BlockHash[] = [] - + if (!yamlWorkflow.blocks || typeof yamlWorkflow.blocks !== 'object') { return blockHashes } - + Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { if (!block || typeof block !== 'object') { return } - + const hash = hashBlockContents(block) blockHashes.push({ blockId, name: block.name || '', hash, - inputs: block.inputs || {} + inputs: block.inputs || {}, }) }) - + return blockHashes } @@ -407,8 +429,14 @@ export async function POST(request: NextRequest) { }) // Debug: Log the actual YAML content being compared - logger.info(`[${requestId}] Original YAML content (first 500 chars):`, original_yaml.substring(0, 500)) - logger.info(`[${requestId}] Agent YAML content (first 500 chars):`, agent_yaml.substring(0, 500)) + logger.info( + `[${requestId}] Original YAML content (first 500 chars):`, + original_yaml.substring(0, 500) + ) + logger.info( + `[${requestId}] Agent YAML content (first 500 chars):`, + agent_yaml.substring(0, 500) + ) // Clean up YAML to remove empty blocks const cleanedOriginalYaml = cleanupYamlContent(original_yaml) @@ -417,26 +445,33 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Cleaned YAML by removing empty blocks`) // Parse both YAML documents - const { data: originalWorkflow, errors: originalErrors } = parseWorkflowYaml(cleanedOriginalYaml) + const { data: originalWorkflow, errors: originalErrors } = + parseWorkflowYaml(cleanedOriginalYaml) const { data: agentWorkflow, errors: agentErrors } = parseWorkflowYaml(cleanedAgentYaml) // Check for parsing errors if (!originalWorkflow || originalErrors.length > 0) { logger.error(`[${requestId}] Original YAML parsing failed`, { originalErrors }) - return NextResponse.json({ - success: false, - message: 'Failed to parse original YAML workflow', - errors: originalErrors, - }, { status: 400 }) + return NextResponse.json( + { + success: false, + message: 'Failed to parse original YAML workflow', + errors: originalErrors, + }, + { status: 400 } + ) } if (!agentWorkflow || agentErrors.length > 0) { logger.error(`[${requestId}] Agent YAML parsing failed`, { agentErrors }) - return NextResponse.json({ - success: false, - message: 'Failed to parse agent YAML workflow', - errors: agentErrors, - }, { status: 400 }) + return NextResponse.json( + { + success: false, + message: 'Failed to parse agent YAML workflow', + errors: agentErrors, + }, + { status: 400 } + ) } // Extract block hashes from both workflows @@ -449,34 +484,34 @@ export async function POST(request: NextRequest) { }) // Create hash sets for efficient lookup - const originalHashSet = new Set(originalHashes.map(b => b.hash)) - const agentHashSet = new Set(agentHashes.map(b => b.hash)) - + const originalHashSet = new Set(originalHashes.map((b) => b.hash)) + const agentHashSet = new Set(agentHashes.map((b) => b.hash)) + // Create name-to-hash mappings for edited block detection - const originalNameToHash = new Map(originalHashes.map(b => [b.name, b.hash])) - const agentNameToHash = new Map(agentHashes.map(b => [b.name, b.hash])) - + const originalNameToHash = new Map(originalHashes.map((b) => [b.name, b.hash])) + const agentNameToHash = new Map(agentHashes.map((b) => [b.name, b.hash])) + // Create name-to-blockId mappings - const originalNameToId = new Map(originalHashes.map(b => [b.name, b.blockId])) - const agentNameToId = new Map(agentHashes.map(b => [b.name, b.blockId])) - + const originalNameToId = new Map(originalHashes.map((b) => [b.name, b.blockId])) + const agentNameToId = new Map(agentHashes.map((b) => [b.name, b.blockId])) + // Create name-to-block mappings for field comparison - const originalNameToBlock = new Map(originalHashes.map(b => [b.name, b])) - const agentNameToBlock = new Map(agentHashes.map(b => [b.name, b])) + const originalNameToBlock = new Map(originalHashes.map((b) => [b.name, b])) + const agentNameToBlock = new Map(agentHashes.map((b) => [b.name, b])) // Analyze differences const result: DiffResult = { deleted_blocks: [], edited_blocks: [], new_blocks: [], - field_diffs: {} + field_diffs: {}, } // Find deleted blocks: blocks in original that don't exist in agent (by name AND hash) for (const originalBlock of originalHashes) { const nameExistsInAgent = agentNameToHash.has(originalBlock.name) const hashExistsInAgent = agentHashSet.has(originalBlock.hash) - + if (!nameExistsInAgent && !hashExistsInAgent) { result.deleted_blocks.push(originalBlock.blockId) } @@ -486,14 +521,14 @@ export async function POST(request: NextRequest) { for (const agentBlock of agentHashes) { const nameExistsInOriginal = originalNameToHash.has(agentBlock.name) const hashExistsInOriginal = originalHashSet.has(agentBlock.hash) - + logger.info(`[${requestId}] Checking agent block: ${agentBlock.name}`, { nameExistsInOriginal, hashExistsInOriginal, agentHash: agentBlock.hash.substring(0, 8), - originalHash: originalNameToHash.get(agentBlock.name)?.substring(0, 8) || 'none' + originalHash: originalNameToHash.get(agentBlock.name)?.substring(0, 8) || 'none', }) - + if (nameExistsInOriginal) { // Block name exists in original const originalHash = originalNameToHash.get(agentBlock.name) @@ -501,16 +536,19 @@ export async function POST(request: NextRequest) { // Same name but different hash = edited block logger.info(`[${requestId}] Found edited block: ${agentBlock.name}`) result.edited_blocks.push(agentBlock.blockId) - + // Calculate field-level differences for this edited block const originalBlock = originalNameToBlock.get(agentBlock.name) if (originalBlock) { - const fieldDiff = compareBlockInputs(originalBlock.inputs || {}, agentBlock.inputs || {}) + const fieldDiff = compareBlockInputs( + originalBlock.inputs || {}, + agentBlock.inputs || {} + ) result.field_diffs![agentBlock.blockId] = fieldDiff - + logger.info(`[${requestId}] Field diff for ${agentBlock.name}:`, { changed_fields: fieldDiff.changed_fields, - unchanged_fields: fieldDiff.unchanged_fields.length + unchanged_fields: fieldDiff.unchanged_fields.length, }) } } @@ -526,21 +564,21 @@ export async function POST(request: NextRequest) { // Extract and compare edges const originalEdges = extractEdges(originalWorkflow) const agentEdges = extractEdges(agentWorkflow) - + logger.info(`[${requestId}] Extracted edges`, { originalEdgeCount: originalEdges.length, agentEdgeCount: agentEdges.length, }) - + // Compare edges const edgeDiff = compareEdges( - originalEdges, - agentEdges, - { originalNameToHash, agentNameToHash }, + originalEdges, + agentEdges, + { originalNameToHash, agentNameToHash }, result ) result.edge_diff = edgeDiff - + logger.info(`[${requestId}] Edge diff analysis`, { newEdges: edgeDiff.new_edges.length, deletedEdges: edgeDiff.deleted_edges.length, @@ -548,15 +586,15 @@ export async function POST(request: NextRequest) { }) const elapsed = Date.now() - startTime - + logger.info(`[${requestId}] YAML diff completed in ${elapsed}ms`, { deletedCount: result.deleted_blocks.length, editedCount: result.edited_blocks.length, newCount: result.new_blocks.length, fieldDiffsCount: Object.keys(result.field_diffs || {}).length, - originalBlocks: originalHashes.map(h => `${h.name}:${h.hash.substring(0, 8)}`), - agentBlocks: agentHashes.map(h => `${h.name}:${h.hash.substring(0, 8)}`), - fieldDiffs: result.field_diffs + originalBlocks: originalHashes.map((h) => `${h.name}:${h.hash.substring(0, 8)}`), + agentBlocks: agentHashes.map((h) => `${h.name}:${h.hash.substring(0, 8)}`), + fieldDiffs: result.field_diffs, }) return NextResponse.json({ @@ -568,15 +606,17 @@ export async function POST(request: NextRequest) { processing_time_ms: elapsed, }, }) - } catch (error) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] YAML diff failed in ${elapsed}ms`, error) - - return NextResponse.json({ - success: false, - message: `Failed to process YAML diff: ${error instanceof Error ? error.message : 'Unknown error'}`, - error: error instanceof Error ? error.message : 'Unknown error', - }, { status: 500 }) + + return NextResponse.json( + { + success: false, + message: `Failed to process YAML diff: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/workflows/preview/route.ts b/apps/sim/app/api/workflows/preview/route.ts index 762c553a488..870b7eabe50 100644 --- a/apps/sim/app/api/workflows/preview/route.ts +++ b/apps/sim/app/api/workflows/preview/route.ts @@ -91,7 +91,7 @@ export async function POST(request: NextRequest) { // Get block config and populate subBlocks with YAML input values const blockConfig = getBlock(block.type) const subBlocks: Record = {} - + if (blockConfig) { // Set up subBlocks from block configuration blockConfig.subBlocks.forEach((subBlock) => { @@ -135,7 +135,7 @@ export async function POST(request: NextRequest) { // Get block config and populate subBlocks with YAML input values const blockConfig = getBlock(block.type) const subBlocks: Record = {} - + if (blockConfig) { // Set up subBlocks from block configuration blockConfig.subBlocks.forEach((subBlock) => { @@ -286,7 +286,10 @@ export async function POST(request: NextRequest) { previewWorkflowState.blocks = layoutedBlocks logger.info(`[${requestId}] Autolayout completed successfully for preview`) } catch (layoutError) { - logger.warn(`[${requestId}] Autolayout failed for preview, using original positions:`, layoutError) + logger.warn( + `[${requestId}] Autolayout failed for preview, using original positions:`, + layoutError + ) } } @@ -340,4 +343,4 @@ export async function POST(request: NextRequest) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 01722587143..d8d409929f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -553,16 +553,15 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { try { // Use the shared auto layout utility for immediate frontend updates const { applyAutoLayoutAndUpdateStore } = await import('../../utils/auto-layout') - + const result = await applyAutoLayoutAndUpdateStore(activeWorkflowId!) - + if (result.success) { logger.info('Auto layout completed successfully') } else { logger.error('Auto layout failed:', result.error) // You could add a toast notification here if available } - } catch (error) { logger.error('Auto layout error:', error) // You could add a toast notification here if available diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx index a2606b22b19..cb03bfc5617 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx @@ -1,11 +1,29 @@ 'use client' import { useState } from 'react' -import { Eye, Maximize2, Minimize2, Save, CheckCircle, X, AlertCircle, XCircle, ChevronDown, Plus, Edit, Trash2 } from 'lucide-react' +import { + AlertCircle, + CheckCircle, + ChevronDown, + Edit, + Eye, + Maximize2, + Minimize2, + Plus, + Save, + Trash2, + X, + XCircle, +} from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' @@ -76,7 +94,7 @@ export function CopilotSandboxModal({ try { setIsSaving(true) // Generate auto name based on description or use default - const autoName = description + const autoName = description ? `${description.slice(0, 50)}${description.length > 50 ? '...' : ''}` : 'Copilot Generated Workflow' await onSaveAsNewWorkflow(autoName) @@ -118,11 +136,11 @@ export function CopilotSandboxModal({ const edgeCount = proposedWorkflowState.edges?.length || 0 // Debug logging - console.log('CopilotSandboxModal rendering with props:', { - diffInfo: diffInfo ? 'present' : 'null', - isDiffLoading, + console.log('CopilotSandboxModal rendering with props:', { + diffInfo: diffInfo ? 'present' : 'null', + isDiffLoading, isOpen, - proposedWorkflowState: proposedWorkflowState ? 'present' : 'null' + proposedWorkflowState: proposedWorkflowState ? 'present' : 'null', }) // Helper function to get block name from ID @@ -179,33 +197,38 @@ export function CopilotSandboxModal({ {/* Diff Information Section - Always Rendered */}
    -

    - Workflow Changes - - (Debug: diffInfo={diffInfo ? 'present' : 'null'}, loading={isDiffLoading ? 'true' : 'false'}) +

    + Workflow Changes + + (Debug: diffInfo={diffInfo ? 'present' : 'null'}, loading= + {isDiffLoading ? 'true' : 'false'})

    - + {isDiffLoading ? (
    -
    +
    Analyzing workflow changes...
    ) : diffInfo ? ( <> -
    +
    {/* New Blocks */} {diffInfo.new_blocks.length > 0 && (
    - + New Blocks ({diffInfo.new_blocks.length})
    - {diffInfo.new_blocks.map(blockId => ( - + {diffInfo.new_blocks.map((blockId) => ( + {getBlockName(blockId)} ))} @@ -218,13 +241,17 @@ export function CopilotSandboxModal({
    - + Modified Blocks ({diffInfo.edited_blocks.length})
    - {diffInfo.edited_blocks.map(blockId => ( - + {diffInfo.edited_blocks.map((blockId) => ( + {getBlockName(blockId)} ))} @@ -237,13 +264,17 @@ export function CopilotSandboxModal({
    - + Deleted Blocks ({diffInfo.deleted_blocks.length})
    - {diffInfo.deleted_blocks.map(blockId => ( - + {diffInfo.deleted_blocks.map((blockId) => ( + {blockId} ))} @@ -253,9 +284,14 @@ export function CopilotSandboxModal({
    {/* Summary */} - {(diffInfo.new_blocks.length > 0 || diffInfo.edited_blocks.length > 0 || diffInfo.deleted_blocks.length > 0) ? ( + {diffInfo.new_blocks.length > 0 || + diffInfo.edited_blocks.length > 0 || + diffInfo.deleted_blocks.length > 0 ? (
    - {diffInfo.new_blocks.length + diffInfo.edited_blocks.length + diffInfo.deleted_blocks.length} total changes detected + {diffInfo.new_blocks.length + + diffInfo.edited_blocks.length + + diffInfo.deleted_blocks.length}{' '} + total changes detected
    ) : (
    @@ -269,9 +305,9 @@ export function CopilotSandboxModal({
    Unable to analyze workflow changes - comparing against current workflow structure
    -
    +
    Debug: No diff data available. This could be due to: -
      +
      • Current workflow has no existing blocks
      • API call to get current workflow failed
      • Diff API call failed
      • @@ -294,21 +330,20 @@ export function CopilotSandboxModal({ />
    - - {/* Action Buttons */}
    - 💡 This is a preview of the workflow the copilot wants to create. Choose how to proceed. + 💡 This is a preview of the workflow the copilot wants to create. Choose how to + proceed.
    - +
    - + {/* Dropdown Arrow Button */} @@ -353,9 +393,10 @@ export function CopilotSandboxModal({ variant='default' size='sm' disabled={isProcessing || isSaving || isApplying || isRejecting} - className={saveAsNewMode - ? 'bg-gray-600 hover:bg-gray-700 rounded-l-none border-l-0 px-2 h-10' - : 'bg-purple-600 hover:bg-purple-700 rounded-l-none border-l-0 px-2 h-10' + className={ + saveAsNewMode + ? 'h-10 rounded-l-none border-l-0 bg-gray-600 px-2 hover:bg-gray-700' + : 'h-10 rounded-l-none border-l-0 bg-purple-600 px-2 hover:bg-purple-700' } > @@ -402,4 +443,4 @@ export function CopilotSandboxModal({ ) -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx index ea1684b84c4..464013501ea 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -1,22 +1,22 @@ -import { Check, X, Eye } from 'lucide-react' +import { Check, Eye, X } from 'lucide-react' import { Button } from '@/components/ui/button' -import { useWorkflowDiffStore } from '@/stores/workflow-diff' -import { useCopilotStore } from '@/stores/copilot/store' import { createLogger } from '@/lib/logs/console-logger' +import { useCopilotStore } from '@/stores/copilot/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff' const logger = createLogger('DiffControls') export function DiffControls() { - const { + const { isShowingDiff, - isDiffReady, - diffWorkflow, - toggleDiffView, - acceptChanges, + isDiffReady, + diffWorkflow, + toggleDiffView, + acceptChanges, rejectChanges, - diffMetadata + diffMetadata, } = useWorkflowDiffStore() - + const { updatePreviewToolCallState, clearPreviewYaml } = useCopilotStore() // Don't show anything if no diff is available or diff is not ready @@ -31,15 +31,15 @@ export function DiffControls() { const handleAccept = async () => { logger.info('Accepting proposed changes') - + try { // Accept the changes in the diff store (this updates the main workflow store) await acceptChanges() - + // Update the copilot tool call state and clear preview YAML updatePreviewToolCallState('applied') await clearPreviewYaml() - + logger.info('Successfully accepted proposed changes') } catch (error) { logger.error('Failed to accept changes:', error) @@ -48,15 +48,15 @@ export function DiffControls() { const handleReject = async () => { logger.info('Rejecting proposed changes') - + try { // Reject the changes in the diff store rejectChanges() - + // Update the copilot tool call state and clear preview YAML updatePreviewToolCallState('rejected') await clearPreviewYaml() - + logger.info('Successfully rejected proposed changes') } catch (error) { logger.error('Failed to reject changes:', error) @@ -64,7 +64,7 @@ export function DiffControls() { } return ( -
    +
    {/* Info section */} @@ -77,8 +77,9 @@ export function DiffControls() { {isShowingDiff ? 'Viewing Proposed Changes' : 'Copilot has proposed changes'} {diffMetadata && ( - - Source: {diffMetadata.source} • {new Date(diffMetadata.timestamp).toLocaleTimeString()} + + Source: {diffMetadata.source} •{' '} + {new Date(diffMetadata.timestamp).toLocaleTimeString()} )}
    @@ -108,12 +109,7 @@ export function DiffControls() { Accept - @@ -124,4 +120,4 @@ export function DiffControls() {
    ) -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx index da37b95a7ca..77f03d15ccf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx @@ -7,8 +7,8 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { cn } from '@/lib/utils' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' -import { LoopBadges } from './components/loop-badges' import { useCurrentWorkflow } from '../../hooks' +import { LoopBadges } from './components/loop-badges' // Add these styles to your existing global CSS file or create a separate CSS module const LoopNodeStyles: React.FC = () => { @@ -72,7 +72,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const { getNodes } = useReactFlow() const { collaborativeRemoveBlock } = useCollaborativeWorkflow() const blockRef = useRef(null) - + // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) @@ -132,8 +132,9 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`, data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50', // Diff highlighting - diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', - diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', + diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10', + diffStatus === 'edited' && + 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10' )} style={{ width: data.width || 500, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx index 99d72e94aa3..ad276b41cde 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx @@ -3,9 +3,9 @@ import { Check, ChevronDown } from 'lucide-react' import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' import { cn } from '@/lib/utils' import { getBlock } from '@/blocks' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' interface OutputSelectProps { workflowId: string | null @@ -26,7 +26,7 @@ export function OutputSelect({ const dropdownRef = useRef(null) const blocks = useWorkflowStore((state) => state.blocks) const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() - + // Use diff blocks when in diff mode, otherwise use main blocks const workflowBlocks = isShowingDiff && diffWorkflow ? diffWorkflow.blocks : blocks @@ -56,9 +56,10 @@ export function OutputSelect({ // Check for custom response format first // In diff mode, get value from diff blocks; otherwise use store - const responseFormatValue = isShowingDiff && diffWorkflow - ? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value - : useSubBlockStore.getState().getValue(block.id, 'responseFormat') + const responseFormatValue = + isShowingDiff && diffWorkflow + ? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value + : useSubBlockStore.getState().getValue(block.id, 'responseFormat') const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id) let outputsToProcess: Record = {} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx index 701af87bc34..036e7314691 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx @@ -1,15 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { - Bot, - ChevronDown, - History, - MessageSquarePlus, - MoreHorizontal, - Trash2, - X, -} from 'lucide-react' +import { Bot, History, MessageSquarePlus, MoreHorizontal, Trash2, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -76,10 +68,10 @@ export function CopilotModal({ // Auto-scroll to bottom when new messages are added with smooth behavior useEffect(() => { if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ + messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end', - inline: 'nearest' + inline: 'nearest', }) } }, [messages]) @@ -88,12 +80,13 @@ export function CopilotModal({ useEffect(() => { if (isLoading && messagesContainerRef.current) { const container = messagesContainerRef.current - const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100 - + const isNearBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < 100 + if (isNearBottom) { - messagesEndRef.current?.scrollIntoView({ + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', - block: 'end' + block: 'end', }) } } @@ -126,7 +119,9 @@ export function CopilotModal({

    Copilot Assistant

    - {mode === 'ask' ? 'Ask questions about your workflow' : 'Agent mode - Let me help you build'} + {mode === 'ask' + ? 'Ask questions about your workflow' + : 'Agent mode - Let me help you build'}

    @@ -149,7 +144,7 @@ export function CopilotModal({ {isLoadingChats ? (
    - Loading chats... + Loading chats...
    ) : chats.length === 0 ? (
    @@ -159,14 +154,14 @@ export function CopilotModal({ chats.map((chat) => ( { onSelectChat(chat) setIsDropdownOpen(false) }} > -
    -
    +
    +
    {chat.title || 'Untitled Chat'}
    @@ -176,7 +171,7 @@ export function CopilotModal({
    @@ -302,7 +299,7 @@ export function CopilotModal({ Agent
    - + {/* Input */} { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index e6cffd15cfc..e49a66b0740 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -1,19 +1,17 @@ 'use client' -import { type FC, memo, useMemo, useState } from 'react' -import { Bot, Copy, User, ChevronDown, ChevronRight, CheckCircle, Settings, XCircle, Loader2 } from 'lucide-react' +import { type FC, memo, useMemo } from 'react' +import { Bot, CheckCircle, Copy, Loader2, User, XCircle } from 'lucide-react' import { useTheme } from 'next-themes' import ReactMarkdown from 'react-markdown' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import remarkGfm from 'remark-gfm' -import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { CopilotMessage } from '@/stores/copilot/types' import type { ToolCallState } from '@/types/tool-call' -import { setLatestPreview } from '../../../../../review-button' interface ProfessionalMessageProps { message: CopilotMessage @@ -21,26 +19,28 @@ interface ProfessionalMessageProps { } // Inline Tool Call Component -function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepNumber?: number }) { +function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepNumber?: number }) { const getStateIcon = () => { switch (tool.state) { case 'executing': - return + return case 'completed': - return + return case 'ready_for_review': - return + return case 'applied': - return + return case 'rejected': - return + return case 'error': - return + return default: - return
    + return ( +
    + ) } } - + const getStateColors = () => { switch (tool.state) { case 'executing': @@ -67,99 +67,129 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any, stepN // Special handling for preview workflow and targeted updates const isPreviewTool = tool.name === 'preview_workflow' || tool.name === 'targeted_updates' - + if (isPreviewTool) { return ( -
    -
    -
    - {tool.state === 'executing' && } - {tool.state === 'ready_for_review' && } - {tool.state === 'applied' && } - {tool.state === 'rejected' && } - {tool.state === 'error' && } +
    +
    +
    + {tool.state === 'executing' && ( + + )} + {tool.state === 'ready_for_review' && ( + + )} + {tool.state === 'applied' && ( + + )} + {tool.state === 'rejected' && ( + + )} + {tool.state === 'error' && ( + + )}
    -
    - {tool.state === 'executing' - ? (tool.name === 'targeted_updates' ? 'Editing workflow' : 'Building workflow') - : (tool.displayName || tool.name) - } +
    + {tool.state === 'executing' + ? tool.name === 'targeted_updates' + ? 'Editing workflow' + : 'Building workflow' + : tool.displayName || tool.name}
    -
    - {tool.state === 'executing' - ? (tool.name === 'targeted_updates' ? 'Editing workflow...' : 'Building workflow...') +
    + {tool.state === 'executing' + ? tool.name === 'targeted_updates' + ? 'Editing workflow...' + : 'Building workflow...' : tool.state === 'ready_for_review' - ? 'Ready for review' - : tool.state === 'applied' - ? 'Applied changes' - : tool.state === 'rejected' - ? 'Rejected changes' - : (tool.name === 'targeted_updates' ? 'Workflow editing failed' : 'Workflow generation failed') - } + ? 'Ready for review' + : tool.state === 'applied' + ? 'Applied changes' + : tool.state === 'rejected' + ? 'Rejected changes' + : tool.name === 'targeted_updates' + ? 'Workflow editing failed' + : 'Workflow generation failed'}
    - {tool.duration && (tool.state === 'ready_for_review' || tool.state === 'applied' || tool.state === 'rejected') && ( - - {formatDuration(tool.duration)} - - )} + {tool.duration && + (tool.state === 'ready_for_review' || + tool.state === 'applied' || + tool.state === 'rejected') && ( + + {formatDuration(tool.duration)} + + )}
    ) } return ( -
    -
    +
    +
    {stepNumber && ( -
    +
    {stepNumber}
    )} {getStateIcon()}
    - - {tool.displayName || tool.name} - + {tool.displayName || tool.name} {tool.duration && tool.state === 'completed' && ( - + {formatDuration(tool.duration)} )} {tool.state === 'executing' && tool.progress && ( - + {tool.progress} )} @@ -195,8 +225,8 @@ const ProfessionalMessage: FC = memo(({ message, isStr if (!inline && language) { return (
    -
    - +
    + {language}
    ), th: ({ children }: any) => ( - - {children} - - ), - td: ({ children }: any) => ( - {children} + {children} ), + td: ({ children }: any) => {children}, } if (isUser) { @@ -303,9 +318,7 @@ const ProfessionalMessage: FC = memo(({ message, isStr
    -
    - {message.content} -
    +
    {message.content}
    @@ -349,11 +362,18 @@ const ProfessionalMessage: FC = memo(({ message, isStr <> {message.contentBlocks.map((block, index) => { if (block.type === 'text') { - const isLastTextBlock = index === message.contentBlocks!.length - 1 && block.type === 'text' + const isLastTextBlock = + index === message.contentBlocks!.length - 1 && block.type === 'text' return ( -
    +
    - + {block.content} {/* Show streaming indicator for the last text block if message is streaming */} @@ -363,36 +383,39 @@ const ProfessionalMessage: FC = memo(({ message, isStr
    ) - } else if (block.type === 'tool_call') { + } + if (block.type === 'tool_call') { return ( ) } return null })} - + {/* Show streaming indicator if streaming but no text content yet after tool calls */} - {isStreaming && !message.content && message.contentBlocks.every(block => block.type === 'tool_call') && ( -
    -
    -
    -
    -
    -
    + {isStreaming && + !message.content && + message.contentBlocks.every((block) => block.type === 'tool_call') && ( +
    +
    +
    +
    +
    +
    +
    + Thinking...
    - Thinking...
    -
    - )} + )} ) : ( // Fallback to old layout for messages without content blocks @@ -405,12 +428,15 @@ const ProfessionalMessage: FC = memo(({ message, isStr ))}
    )} - + {/* Regular text content */} {cleanTextContent && (
    - + {cleanTextContent}
    @@ -418,7 +444,7 @@ const ProfessionalMessage: FC = memo(({ message, isStr )} )} - + {/* Streaming indicator when no content yet */} {!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
    @@ -446,7 +472,7 @@ const ProfessionalMessage: FC = memo(({ message, isStr
    {/* Timestamp and actions */} -
    +
    {formatTimestamp(message.timestamp)} @@ -464,7 +490,7 @@ const ProfessionalMessage: FC = memo(({ message, isStr {/* Citations if available */} {message.citations && message.citations.length > 0 && ( -
    +
    Sources:
    {message.citations.map((citation) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index d12c4210fe7..6a464f81fff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -11,16 +11,16 @@ import { } from '@/components/ui/dropdown-menu' import { ScrollArea } from '@/components/ui/scroll-area' import { createLogger } from '@/lib/logs/console-logger' +import { usePreviewStore } from '@/stores/copilot/preview-store' import { useCopilotStore } from '@/stores/copilot/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' +import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' import { CheckpointPanel } from './components/checkpoint-panel' import { CopilotModal } from './components/copilot-modal/copilot-modal' import { ProfessionalInput } from './components/professional-input/professional-input' import { ProfessionalMessage } from './components/professional-message/professional-message' import { CopilotWelcome } from './components/welcome/welcome' -import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' -import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' -import { usePreviewStore } from '@/stores/copilot/preview-store' const logger = createLogger('Copilot') @@ -56,8 +56,9 @@ export const Copilot = forwardRef( const { activeWorkflowId } = useWorkflowRegistry() // Use copilot sandbox for workflow previews - const { sandboxState, showSandbox, closeSandbox, applyToCurrentWorkflow, saveAsNewWorkflow } = useCopilotSandbox() - + const { sandboxState, showSandbox, closeSandbox, applyToCurrentWorkflow, saveAsNewWorkflow } = + useCopilotSandbox() + // Use preview store to track seen previews const { scanAndMarkExistingPreviews, isToolCallSeen, markToolCallAsSeen } = usePreviewStore() @@ -126,10 +127,10 @@ export const Copilot = forwardRef( // Check for completed preview_workflow tool calls const previewToolCall = lastMessage.toolCalls.find( - tc => tc.name === 'preview_workflow' && tc.state === 'completed' && !isToolCallSeen(tc.id) + (tc) => tc.name === 'preview_workflow' && tc.state === 'completed' && !isToolCallSeen(tc.id) ) - if (previewToolCall && previewToolCall.result) { + if (previewToolCall?.result) { logger.info('Preview workflow completed via native SSE - handling result') // Mark as seen to prevent duplicate processing markToolCallAsSeen(previewToolCall.id) @@ -436,7 +437,7 @@ export const Copilot = forwardRef( mode={mode} onModeChange={setMode} /> - + {/* Copilot Sandbox Modal */} { return ( @@ -89,7 +89,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => const { getNodes } = useReactFlow() const { collaborativeRemoveBlock } = useCollaborativeWorkflow() const blockRef = useRef(null) - + // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) @@ -150,8 +150,9 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`, data?.hasNestedError && 'border-2 border-red-500 bg-red-50/50', // Diff highlighting - diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', - diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', + diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10', + diffStatus === 'edited' && + 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10' )} style={{ width: data.width || 500, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 5f028293958..374dc0e1c5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -1,20 +1,22 @@ 'use client' -import { useState, useCallback } from 'react' -import { useParams } from 'next/navigation' +import { useState } from 'react' import { Eye, FileText } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { CopilotSandboxModal } from './copilot-sandbox-modal/copilot-sandbox-modal' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useCopilotStore } from '@/stores/copilot/store' import { createLogger } from '@/lib/logs/console-logger' +import { useCopilotStore } from '@/stores/copilot/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { CopilotSandboxModal } from './copilot-sandbox-modal/copilot-sandbox-modal' const logger = createLogger('ReviewButton') // Backward compatibility exports (deprecated) export function setLatestPreview() {} export function clearLatestPreview() {} -export function getLatestUnseenPreview() { return null } +export function getLatestUnseenPreview() { + return null +} export function ReviewButton() { const params = useParams() @@ -37,7 +39,7 @@ export function ReviewButton() { const handleShowPreview = async () => { if (!currentChat?.previewYaml || !activeWorkflowId) return - + try { // Validate YAML content before sending const yamlContent = currentChat.previewYaml.trim() @@ -45,8 +47,11 @@ export function ReviewButton() { throw new Error('Preview YAML content is empty') } - logger.info('Generating preview with YAML content (first 200 chars):', yamlContent.substring(0, 200)) - + logger.info( + 'Generating preview with YAML content (first 200 chars):', + yamlContent.substring(0, 200) + ) + // Generate workflow state from YAML for the modal logger.info('Step 1: Calling preview API...') const previewResponse = await fetch('/api/workflows/preview', { @@ -57,17 +62,27 @@ export function ReviewButton() { applyAutoLayout: true, }), }) - logger.info('Step 1 complete: Preview API response received', { status: previewResponse.status }) + logger.info('Step 1 complete: Preview API response received', { + status: previewResponse.status, + }) if (!previewResponse.ok) { const errorText = await previewResponse.text() - logger.error('Preview API response not ok:', { status: previewResponse.status, statusText: previewResponse.statusText, errorText }) - throw new Error(`Failed to generate preview: ${previewResponse.status} ${previewResponse.statusText}`) + logger.error('Preview API response not ok:', { + status: previewResponse.status, + statusText: previewResponse.statusText, + errorText, + }) + throw new Error( + `Failed to generate preview: ${previewResponse.status} ${previewResponse.statusText}` + ) } const previewResult = await previewResponse.json() - logger.info('Step 1 result: Preview API parsed successfully', { success: previewResult.success }) - + logger.info('Step 1 result: Preview API parsed successfully', { + success: previewResult.success, + }) + if (!previewResult.success) { logger.error('Preview API returned error:', previewResult) throw new Error(previewResult.message || 'Failed to generate preview') @@ -85,17 +100,24 @@ export function ReviewButton() { includeMetadata: false, }), }) - logger.info('Step 2: Current workflow API response received', { status: currentWorkflowResponse.status }) + logger.info('Step 2: Current workflow API response received', { + status: currentWorkflowResponse.status, + }) if (currentWorkflowResponse.ok) { const currentWorkflowResult = await currentWorkflowResponse.json() - logger.info('Step 2: Current workflow API parsed', { success: currentWorkflowResult.success, hasYaml: !!currentWorkflowResult.output?.yaml }) + logger.info('Step 2: Current workflow API parsed', { + success: currentWorkflowResult.success, + hasYaml: !!currentWorkflowResult.output?.yaml, + }) if (currentWorkflowResult.success && currentWorkflowResult.output?.yaml) { originalYaml = currentWorkflowResult.output.yaml logger.info('Step 2: Original YAML obtained', { length: originalYaml.length }) } } else { - logger.warn('Step 2: Current workflow API failed', { status: currentWorkflowResponse.status }) + logger.warn('Step 2: Current workflow API failed', { + status: currentWorkflowResponse.status, + }) } } catch (yamlError) { logger.error('Step 2: Failed to get current workflow YAML for diff:', yamlError) @@ -107,8 +129,13 @@ export function ReviewButton() { if (originalYaml) { try { setIsDiffLoading(true) - logger.info('Step 3: Starting diff with original YAML length:', originalYaml.length, 'agent YAML length:', yamlContent.length) - + logger.info( + 'Step 3: Starting diff with original YAML length:', + originalYaml.length, + 'agent YAML length:', + yamlContent.length + ) + const diffResponse = await fetch('/api/workflows/diff', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -129,7 +156,11 @@ export function ReviewButton() { logger.error('Step 3: Diff API returned unsuccessful response:', diffData) } } else { - logger.error('Step 3: Diff API request failed:', diffResponse.status, diffResponse.statusText) + logger.error( + 'Step 3: Diff API request failed:', + diffResponse.status, + diffResponse.statusText + ) const errorText = await diffResponse.text() logger.error('Step 3: Diff API error response:', errorText) } @@ -155,7 +186,7 @@ export function ReviewButton() { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, yamlLength: currentChat?.previewYaml?.length, - yamlPreview: currentChat?.previewYaml?.substring(0, 100) + yamlPreview: currentChat?.previewYaml?.substring(0, 100), }) // Reset loading states on error setIsDiffLoading(false) @@ -165,7 +196,7 @@ export function ReviewButton() { const handleApply = async () => { if (!currentChat?.previewYaml) return - + try { setIsProcessing(true) @@ -183,21 +214,26 @@ export function ReviewButton() { // Convert YAML to workflow state using our unified converter const conversionResult = await convertYamlToWorkflowState(currentChat.previewYaml, { - generateNewIds: false // Keep existing IDs for preview + generateNewIds: false, // Keep existing IDs for preview }) if (!conversionResult.success || !conversionResult.workflowState) { throw new Error(`Failed to convert YAML: ${conversionResult.errors.join(', ')}`) } - const { blocks: workflowBlocks, edges: workflowEdges, loops, parallels } = conversionResult.workflowState + const { + blocks: workflowBlocks, + edges: workflowEdges, + loops, + parallels, + } = conversionResult.workflowState // Apply auto layout using the shared utility const { applyAutoLayoutToBlocks } = await import('../utils/auto-layout') const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) - + const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks - + if (layoutResult.success) { logger.info('Successfully applied auto layout to preview blocks') } else { @@ -247,7 +283,6 @@ export function ReviewButton() { } logger.info('Successfully updated local stores with preview content') - } catch (parseError) { logger.error('Failed to parse and apply preview locally:', parseError) throw parseError @@ -276,7 +311,7 @@ export function ReviewButton() { } const result = await response.json() - + if (!result.success) { throw new Error(result.message || 'Failed to apply workflow changes') } @@ -352,7 +387,7 @@ export function ReviewButton() { } const result = await response.json() - + if (!result.success) { throw new Error(result.message || 'Failed to save workflow') } @@ -373,7 +408,7 @@ export function ReviewButton() { const handleReject = async () => { if (!currentChat?.previewYaml) return - + try { setIsProcessing(true) updatePreviewToolCallState('rejected') @@ -397,25 +432,28 @@ export function ReviewButton() { } // Create preview data for the sandbox modal - const previewData = currentChat?.previewYaml && previewWorkflowState ? { - workflowState: previewWorkflowState, - yamlContent: currentChat.previewYaml, - description: 'Copilot generated workflow preview' - } : null + const previewData = + currentChat?.previewYaml && previewWorkflowState + ? { + workflowState: previewWorkflowState, + yamlContent: currentChat.previewYaml, + description: 'Copilot generated workflow preview', + } + : null // Debug logging - console.log('ReviewButton render state:', { - showModal, - previewData: previewData ? 'present' : 'null', - diffInfo: diffInfo ? `present (${Object.keys(diffInfo).join(',')})` : 'null', + console.log('ReviewButton render state:', { + showModal, + previewData: previewData ? 'present' : 'null', + diffInfo: diffInfo ? `present (${Object.keys(diffInfo).join(',')})` : 'null', isDiffLoading, - hasPreviewYaml: !!currentChat?.previewYaml + hasPreviewYaml: !!currentChat?.previewYaml, }) return ( <> {/* Simple button at bottom center */} -
    +
    @@ -455,4 +493,4 @@ export function ReviewButton() { )} ) -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts index 4d897cb3344..8efb9b41c8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value.ts @@ -3,10 +3,10 @@ import { isEqual } from 'lodash' import { createLogger } from '@/lib/logs/console-logger' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { getProviderFromModel } from '@/providers/utils' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' const logger = createLogger('SubBlockValue') @@ -63,9 +63,10 @@ export function useSubBlockValue( // Check if we're in diff mode and get diff value if available const { isShowingDiff, diffWorkflow } = useWorkflowDiffStore() - const diffValue = isShowingDiff && diffWorkflow - ? diffWorkflow.blocks?.[blockId]?.subBlocks?.[subBlockId]?.value ?? null - : null + const diffValue = + isShowingDiff && diffWorkflow + ? (diffWorkflow.blocks?.[blockId]?.subBlocks?.[subBlockId]?.value ?? null) + : null // Check if this is an API key field that could be auto-filled const isApiKey = @@ -193,7 +194,12 @@ export function useSubBlockValue( ) // Determine the effective value: diff value takes precedence if in diff mode - const effectiveValue = isShowingDiff && diffValue !== null ? diffValue : (storeValue !== undefined ? storeValue : initialValue) + const effectiveValue = + isShowingDiff && diffValue !== null + ? diffValue + : storeValue !== undefined + ? storeValue + : initialValue // Initialize valueRef on first render useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx index 6322c1a7d96..03e9ac9380c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/sub-block.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from 'react' +import type React from 'react' +import { useEffect, useState } from 'react' import { AlertTriangle, Info } from 'lucide-react' import { Label } from '@/components/ui/label' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -53,7 +54,7 @@ export function SubBlock({ fieldDiffStatus, }: SubBlockProps) { const [isValidJson, setIsValidJson] = useState(true) - + // Debug field diff status useEffect(() => { if (fieldDiffStatus) { @@ -415,12 +416,13 @@ export function SubBlock({ const required = isFieldRequired() return ( -
    {config.type !== 'switch' && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index b87b0c5a73a..36bdf5658b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -12,16 +12,15 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/compone import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useExecutionStore } from '@/stores/execution/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff' - +import { useCurrentWorkflow } from '../../hooks' import { ActionBar } from './components/action-bar/action-bar' import { ConnectionBlocks } from './components/connection-blocks/connection-blocks' import { SubBlock } from './components/sub-block/sub-block' -import { useCurrentWorkflow } from '../../hooks' interface WorkflowBlockProps { type: string @@ -31,7 +30,7 @@ interface WorkflowBlockProps { isPending?: boolean isPreview?: boolean subBlockValues?: Record - blockState?: any // Block state data passed in preview mode + blockState?: any // Block state data passed in preview mode } // Combine both interfaces into a single component @@ -66,21 +65,21 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Workflow store selectors const lastUpdate = useWorkflowStore((state) => state.lastUpdate) - + // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) - + const isEnabled = currentBlock?.enabled ?? true - + // Get diff status from the block itself (set by diff engine) - const diffStatus = currentWorkflow.isDiffMode && currentBlock ? - (currentBlock as any).is_diff : undefined - + const diffStatus = + currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined + // Get field-level diff information - const fieldDiff = currentWorkflow.isDiffMode && currentBlock ? - (currentBlock as any).field_diff : undefined - + const fieldDiff = + currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diff : undefined + // Debug: Log diff status for this block useEffect(() => { if (currentWorkflow.isDiffMode) { @@ -90,20 +89,22 @@ export function WorkflowBlock({ id, data }: NodeProps) { isDiffMode: currentWorkflow.isDiffMode, diffStatus, hasFieldDiff: !!fieldDiff, - timestamp: Date.now() + timestamp: Date.now(), }) } }, [id, currentWorkflow.isDiffMode, diffStatus, fieldDiff, currentBlock?.name]) - + // Check if this block is marked for deletion (in original workflow, not diff) const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) const isDeletedBlock = !isShowingDiff && diffAnalysis?.deleted_blocks?.includes(id) - - // Debug: Log when in diff mode or when blocks are marked for deletion + + // Debug: Log when in diff mode or when blocks are marked for deletion useEffect(() => { if (currentWorkflow.isDiffMode) { - console.log(`[WorkflowBlock ${id}] Diff mode active, block exists: ${!!currentBlock}, diff status: ${diffStatus}`) + console.log( + `[WorkflowBlock ${id}] Diff mode active, block exists: ${!!currentBlock}, diff status: ${diffStatus}` + ) if (fieldDiff) { console.log(`[WorkflowBlock ${id}] Field diff:`, fieldDiff) } @@ -112,16 +113,25 @@ export function WorkflowBlock({ id, data }: NodeProps) { console.log(`[WorkflowBlock ${id}] Diff analysis available in original workflow:`, { deleted_blocks: diffAnalysis.deleted_blocks, isDeletedBlock, - isShowingDiff + isShowingDiff, }) } if (isDeletedBlock) { console.log(`[WorkflowBlock ${id}] Block marked for deletion in original workflow`) } - }, [currentWorkflow.isDiffMode, currentBlock, diffStatus, fieldDiff || null, isDeletedBlock, diffAnalysis, isShowingDiff, id]) - const horizontalHandles = data.isPreview - ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal - : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency + }, [ + currentWorkflow.isDiffMode, + currentBlock, + diffStatus, + fieldDiff || null, + isDeletedBlock, + diffAnalysis, + isShowingDiff, + id, + ]) + const horizontalHandles = data.isPreview + ? (data.blockState?.horizontalHandles ?? true) // In preview mode, use blockState and default to horizontal + : useWorkflowStore((state) => state.blocks[id]?.horizontalHandles ?? true) // Changed default to true for consistency const isWide = useWorkflowStore((state) => state.blocks[id]?.isWide ?? false) const blockHeight = useWorkflowStore((state) => state.blocks[id]?.height ?? 0) // Get per-block webhook status by checking if webhook is configured @@ -518,10 +528,10 @@ export function WorkflowBlock({ id, data }: NodeProps) { isActive && 'animate-pulse-ring ring-2 ring-blue-500', isPending && 'ring-2 ring-amber-500', // Diff highlighting - diffStatus === 'new' && 'ring-2 ring-green-500 bg-green-50/50 dark:bg-green-900/10', - diffStatus === 'edited' && 'ring-2 ring-orange-500 bg-orange-50/50 dark:bg-orange-900/10', + diffStatus === 'new' && 'bg-green-50/50 ring-2 ring-green-500 dark:bg-green-900/10', + diffStatus === 'edited' && 'bg-orange-50/50 ring-2 ring-orange-500 dark:bg-orange-900/10', // Deleted block highlighting (in original workflow) - isDeletedBlock && 'ring-2 ring-red-500 bg-red-50/50 dark:bg-red-900/10', + isDeletedBlock && 'bg-red-50/50 ring-2 ring-red-500 dark:bg-red-900/10', 'z-[20]' )} > @@ -858,11 +868,11 @@ export function WorkflowBlock({ id, data }: NodeProps) { subBlockValues={data.subBlockValues} disabled={!userPermissions.canEdit} fieldDiffStatus={ - fieldDiff && fieldDiff.changed_fields?.includes(subBlock.id) - ? 'changed' - : fieldDiff && fieldDiff.unchanged_fields?.includes(subBlock.id) - ? 'unchanged' - : undefined + fieldDiff?.changed_fields?.includes(subBlock.id) + ? 'changed' + : fieldDiff?.unchanged_fields?.includes(subBlock.id) + ? 'unchanged' + : undefined } />
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 492a0d5e86f..00ab55d8d69 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -41,33 +41,39 @@ export const WorkflowEdge = ({ const isSelected = data?.isSelected ?? false const isInsideLoop = data?.isInsideLoop ?? false const parentLoopId = data?.parentLoopId - + // Get edge diff status const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady) const currentWorkflow = useCurrentWorkflow() - + // Generate edge identifier using block names (not IDs) to match diff analysis // This must exactly match the logic in /api/workflows/diff route - const generateEdgeIdentity = (sourceName: string, targetName: string, sourceHandle?: string | null, targetHandle?: string | null): string => { + const generateEdgeIdentity = ( + sourceName: string, + targetName: string, + sourceHandle?: string | null, + targetHandle?: string | null + ): string => { // The API route uses "success" as the default handle when sourceHandle is null/undefined // We need to match this logic exactly const effectiveSourceHandle = sourceHandle || 'success' return `${sourceName}:${effectiveSourceHandle}->${targetName}${targetHandle ? `:${targetHandle}` : ''}` } - + // Get block names from workflow - handle both diff and normal modes const sourceBlock = currentWorkflow.getBlockById(source) const targetBlock = currentWorkflow.getBlockById(target) const sourceName = sourceBlock?.name const targetName = targetBlock?.name - + // Generate edge identifier using the exact same logic as the API route - const edgeIdentifier = sourceName && targetName ? - generateEdgeIdentity(sourceName, targetName, sourceHandle, targetHandle) : - null - + const edgeIdentifier = + sourceName && targetName + ? generateEdgeIdentity(sourceName, targetName, sourceHandle, targetHandle) + : null + // Debug logging to understand what's happening useEffect(() => { if (edgeIdentifier && diffAnalysis?.edge_diff) { @@ -92,11 +98,24 @@ export const WorkflowEdge = ({ matchesUnchanged: diffAnalysis.edge_diff.unchanged_edges.includes(edgeIdentifier), }) } - }, [edgeIdentifier, diffAnalysis, isShowingDiff, id, sourceName, targetName, sourceHandle, targetHandle, source, target, currentWorkflow.isDiffMode]) - + }, [ + edgeIdentifier, + diffAnalysis, + isShowingDiff, + id, + sourceName, + targetName, + sourceHandle, + targetHandle, + source, + target, + currentWorkflow.isDiffMode, + ]) + // One-time debug log of full diff analysis useEffect(() => { - if (diffAnalysis && id === Object.keys(currentWorkflow.blocks)[0]) { // Only log once per diff + if (diffAnalysis && id === Object.keys(currentWorkflow.blocks)[0]) { + // Only log once per diff console.log('[Full Diff Analysis]:', { edge_diff: diffAnalysis.edge_diff, new_blocks: diffAnalysis.new_blocks, @@ -104,14 +123,14 @@ export const WorkflowEdge = ({ deleted_blocks: diffAnalysis.deleted_blocks, isShowingDiff, currentWorkflowEdgeCount: currentWorkflow.edges.length, - currentWorkflowBlockCount: Object.keys(currentWorkflow.blocks).length + currentWorkflowBlockCount: Object.keys(currentWorkflow.blocks).length, }) } }, [diffAnalysis, id, currentWorkflow.blocks, currentWorkflow.edges, isShowingDiff]) - + // Determine edge diff status let edgeDiffStatus: 'new' | 'deleted' | 'unchanged' | null = null - + // Only attempt to determine diff status if all required data is available if (diffAnalysis?.edge_diff && edgeIdentifier && sourceName && targetName && isDiffReady) { if (isShowingDiff) { @@ -136,15 +155,15 @@ export const WorkflowEdge = ({ if (isSelected) return '#475569' return '#94a3b8' } - + const edgeStyle = { - strokeWidth: edgeDiffStatus ? 3 : (isSelected ? 2.5 : 2), + strokeWidth: edgeDiffStatus ? 3 : isSelected ? 2.5 : 2, stroke: getEdgeColor(), strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : '5,5', // Longer dashes for deleted opacity: edgeDiffStatus === 'deleted' ? 0.7 : 1, ...style, } - + return ( <> { - setSandboxState({ - isOpen: true, - proposedWorkflowState: workflowState, - yamlContent, - description, - isProcessing: false, - }) - }, []) + const showSandbox = useCallback( + (workflowState: WorkflowState, yamlContent: string, description?: string) => { + setSandboxState({ + isOpen: true, + proposedWorkflowState: workflowState, + yamlContent, + description, + isProcessing: false, + }) + }, + [] + ) const closeSandbox = useCallback(() => { setSandboxState({ @@ -57,7 +56,7 @@ export function useCopilotSandbox() { } try { - setSandboxState(prev => ({ ...prev, isProcessing: true })) + setSandboxState((prev) => ({ ...prev, isProcessing: true })) logger.info('Applying sandbox workflow to current workflow', { workflowId: activeWorkflowId, @@ -85,7 +84,7 @@ export function useCopilotSandbox() { } const result = await response.json() - + if (!result.success) { throw new Error(result.message || 'Failed to apply workflow changes') } @@ -95,81 +94,82 @@ export function useCopilotSandbox() { blocksCount: result.data?.blocksCount, edgesCount: result.data?.edgesCount, }) - } catch (error) { logger.error('Failed to apply sandbox workflow:', error) throw error } finally { - setSandboxState(prev => ({ ...prev, isProcessing: false })) + setSandboxState((prev) => ({ ...prev, isProcessing: false })) } }, [activeWorkflowId, sandboxState.yamlContent, sandboxState.description]) - const saveAsNewWorkflow = useCallback(async (name: string) => { - if (!sandboxState.yamlContent) { - throw new Error('No YAML content to save') - } - - try { - setSandboxState(prev => ({ ...prev, isProcessing: true })) - - logger.info('Creating new workflow from sandbox', { - name, - yamlLength: sandboxState.yamlContent.length, - }) - - // First create a new workflow - const newWorkflowId = await createWorkflow({ - name, - description: sandboxState.description, - workspaceId, - }) - - if (!newWorkflowId) { - throw new Error('Failed to create new workflow') - } - - // Then apply the YAML content to the new workflow - const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: sandboxState.yamlContent, - description: sandboxState.description || 'Created from copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: false, // No need for checkpoint on new workflow - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) + const saveAsNewWorkflow = useCallback( + async (name: string) => { + if (!sandboxState.yamlContent) { + throw new Error('No YAML content to save') } - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to save workflow') + try { + setSandboxState((prev) => ({ ...prev, isProcessing: true })) + + logger.info('Creating new workflow from sandbox', { + name, + yamlLength: sandboxState.yamlContent.length, + }) + + // First create a new workflow + const newWorkflowId = await createWorkflow({ + name, + description: sandboxState.description, + workspaceId, + }) + + if (!newWorkflowId) { + throw new Error('Failed to create new workflow') + } + + // Then apply the YAML content to the new workflow + const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: sandboxState.yamlContent, + description: sandboxState.description || 'Created from copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: false, // No need for checkpoint on new workflow + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to save workflow') + } + + logger.info('Successfully created new workflow from sandbox', { + newWorkflowId, + name, + blocksCount: result.data?.blocksCount, + edgesCount: result.data?.edgesCount, + }) + + return newWorkflowId + } catch (error) { + logger.error('Failed to save sandbox workflow as new:', error) + throw error + } finally { + setSandboxState((prev) => ({ ...prev, isProcessing: false })) } - - logger.info('Successfully created new workflow from sandbox', { - newWorkflowId, - name, - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, - }) - - return newWorkflowId - - } catch (error) { - logger.error('Failed to save sandbox workflow as new:', error) - throw error - } finally { - setSandboxState(prev => ({ ...prev, isProcessing: false })) - } - }, [sandboxState.yamlContent, sandboxState.description, createWorkflow]) + }, + [sandboxState.yamlContent, sandboxState.description, createWorkflow] + ) return { sandboxState, @@ -178,4 +178,4 @@ export function useCopilotSandbox() { applyToCurrentWorkflow, saveAsNewWorkflow, } -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts index 73d182b06ff..6265b973c17 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { Edge } from 'reactflow' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import type { WorkflowState, BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' import type { DeploymentStatus } from '@/stores/workflows/registry/types' -import type { Edge } from 'reactflow' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' /** * Interface for the current workflow abstraction @@ -20,14 +20,14 @@ export interface CurrentWorkflow { deploymentStatuses?: Record needsRedeployment?: boolean hasActiveWebhook?: boolean - + // Mode information isDiffMode: boolean isNormalMode: boolean - + // Full workflow state (for cases that need the complete object) workflowState: WorkflowState - + // Helper methods getBlockById: (blockId: string) => BlockState | undefined getBlockCount: () => number @@ -43,17 +43,17 @@ export interface CurrentWorkflow { export function useCurrentWorkflow(): CurrentWorkflow { // Get normal workflow state const normalWorkflow = useWorkflowStore((state) => state.getWorkflowState()) - + // Get diff state - now including isDiffReady const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore() - + // Debug: Log when diff state changes console.log('[useCurrentWorkflow] State update:', { isShowingDiff, isDiffReady, hasDiffWorkflow: !!diffWorkflow, diffWorkflowBlockCount: diffWorkflow ? Object.keys(diffWorkflow.blocks).length : 0, - timestamp: Date.now() + timestamp: Date.now(), }) // Create the abstracted interface @@ -61,20 +61,20 @@ export function useCurrentWorkflow(): CurrentWorkflow { // Determine which workflow to use - only use diff if it's ready const shouldUseDiff = isShowingDiff && isDiffReady && !!diffWorkflow const activeWorkflow = shouldUseDiff ? diffWorkflow : normalWorkflow - + // Debug: Log which workflow is being used and sample block diff status const sampleBlockId = Object.keys(activeWorkflow.blocks)[0] const sampleBlock = sampleBlockId ? activeWorkflow.blocks[sampleBlockId] : null const sampleDiffStatus = sampleBlock ? (sampleBlock as any).is_diff : undefined - + console.log('[useCurrentWorkflow] Using workflow:', { type: shouldUseDiff ? 'diff' : 'normal', blockCount: Object.keys(activeWorkflow.blocks).length, sampleBlockId, sampleDiffStatus, - timestamp: Date.now() + timestamp: Date.now(), }) - + return { // Current workflow state blocks: activeWorkflow.blocks, @@ -87,14 +87,14 @@ export function useCurrentWorkflow(): CurrentWorkflow { deploymentStatuses: activeWorkflow.deploymentStatuses, needsRedeployment: activeWorkflow.needsRedeployment, hasActiveWebhook: activeWorkflow.hasActiveWebhook, - + // Mode information - update to reflect ready state isDiffMode: shouldUseDiff, isNormalMode: !shouldUseDiff, - + // Full workflow state (for cases that need the complete object) workflowState: activeWorkflow, - + // Helper methods getBlockById: (blockId: string) => activeWorkflow.blocks[blockId], getBlockCount: () => Object.keys(activeWorkflow.blocks).length, @@ -103,6 +103,6 @@ export function useCurrentWorkflow(): CurrentWorkflow { hasEdges: () => activeWorkflow.edges.length > 0, } }, [normalWorkflow, isShowingDiff, isDiffReady, diffWorkflow]) - + return currentWorkflow -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index e2d8b384cad..cb810136d0e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -16,8 +16,6 @@ import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useCurrentWorkflow } from './use-current-workflow' const logger = createLogger('useWorkflowExecution') @@ -418,24 +416,33 @@ export function useWorkflowExecution() { executionId?: string ): Promise => { // Use currentWorkflow but check if we're in diff mode - const { blocks: workflowBlocks, edges: workflowEdges, loops: workflowLoops, parallels: workflowParallels } = currentWorkflow - + const { + blocks: workflowBlocks, + edges: workflowEdges, + loops: workflowLoops, + parallels: workflowParallels, + } = currentWorkflow + // Filter out blocks without type (these are layout-only blocks) - const validBlocks = Object.entries(workflowBlocks).reduce((acc, [blockId, block]) => { - if (block && block.type) { - acc[blockId] = block - } - return acc - }, {} as typeof workflowBlocks) - - const isExecutingFromChat = workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput + const validBlocks = Object.entries(workflowBlocks).reduce( + (acc, [blockId, block]) => { + if (block?.type) { + acc[blockId] = block + } + return acc + }, + {} as typeof workflowBlocks + ) + + const isExecutingFromChat = + workflowInput && typeof workflowInput === 'object' && 'input' in workflowInput - logger.info('Executing workflow', { + logger.info('Executing workflow', { isDiffMode: currentWorkflow.isDiffMode, isExecutingFromChat, totalBlocksCount: Object.keys(workflowBlocks).length, validBlocksCount: Object.keys(validBlocks).length, - edgesCount: workflowEdges.length + edgesCount: workflowEdges.length, }) // Debug: Check for blocks with undefined types before merging diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts index fb16acbe9b3..d0d0117f490 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts @@ -60,7 +60,7 @@ export async function applyAutoLayoutToWorkflow( // Import auto layout service const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - + // Merge with default options and ensure all required properties are present const layoutOptions = { strategy: options.strategy || DEFAULT_AUTO_LAYOUT_OPTIONS.strategy!, @@ -76,10 +76,10 @@ export async function applyAutoLayoutToWorkflow( y: options.padding?.y || DEFAULT_AUTO_LAYOUT_OPTIONS.padding!.y!, }, } - + // Apply auto layout const layoutedBlocks = await autoLayoutWorkflow(blocks, edges, layoutOptions) - + logger.info('Successfully applied auto layout', { workflowId, originalBlockCount: Object.keys(blocks).length, @@ -93,7 +93,7 @@ export async function applyAutoLayoutToWorkflow( } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown auto layout error' logger.error('Auto layout failed:', { workflowId, error: errorMessage }) - + return { success: false, error: errorMessage, @@ -114,7 +114,7 @@ export async function applyAutoLayoutAndUpdateStore( try { // Import workflow store const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') - + const workflowStore = useWorkflowStore.getState() const { blocks, edges } = workflowStore @@ -125,7 +125,7 @@ export async function applyAutoLayoutAndUpdateStore( // Apply auto layout const result = await applyAutoLayoutToWorkflow(workflowId, blocks, edges, options) - + if (!result.success || !result.layoutedBlocks) { return { success: false, error: result.error } } @@ -138,7 +138,7 @@ export async function applyAutoLayoutAndUpdateStore( } useWorkflowStore.setState(newWorkflowState) - + logger.info('Successfully updated workflow store with auto layout', { workflowId }) // Save to database in background (don't await to keep UI responsive) @@ -148,7 +148,7 @@ export async function applyAutoLayoutAndUpdateStore( } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown store update error' logger.error('Failed to update store with auto layout:', { workflowId, error: errorMessage }) - + return { success: false, error: errorMessage, @@ -216,4 +216,4 @@ export async function applyAutoLayoutToBlocks( error?: string }> { return applyAutoLayoutToWorkflow('preview', blocks, edges, options) -} \ No newline at end of file +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index c1f68607878..b1fad55356e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -5,11 +5,11 @@ import { useParams, useRouter } from 'next/navigation' import ReactFlow, { Background, ConnectionLineType, + type Edge, type EdgeTypes, type NodeTypes, ReactFlowProvider, useReactFlow, - type Edge, } from 'reactflow' import 'reactflow/dist/style.css' import { createLogger } from '@/lib/logs/console-logger' @@ -26,23 +26,20 @@ import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions' import { useExecutionStore } from '@/stores/execution/store' import { useVariablesStore } from '@/stores/panel/variables/store' import { useGeneralStore } from '@/stores/settings/general/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' +import { useCurrentWorkflow } from './hooks' import { - analyzeWorkflowGraph, - detectHandleOrientation, getNodeAbsolutePosition, getNodeDepth, getNodeHierarchy, isPointInLoopNode, - LayoutOptions, resizeLoopNodes, updateNodeParent as updateNodeParentUtil, } from './utils' -import { useCurrentWorkflow } from './hooks' const logger = createLogger('Workflow') @@ -92,11 +89,8 @@ const WorkflowContent = React.memo(() => { // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() - - const { - updateNodeDimensions, - updateBlockPosition: storeUpdateBlockPosition, - } = useWorkflowStore() + + const { updateNodeDimensions, updateBlockPosition: storeUpdateBlockPosition } = useWorkflowStore() // Extract workflow data from the abstraction const { blocks, edges, loops, parallels, isDiffMode } = currentWorkflow @@ -111,29 +105,29 @@ const WorkflowContent = React.memo(() => { // Only do this if diff is ready to prevent race conditions if (!isShowingDiff && isDiffReady && diffAnalysis?.edge_diff?.deleted_edges) { const reconstructedEdges: Edge[] = [] - + // Parse deleted edge identifiers to reconstruct edges - diffAnalysis.edge_diff.deleted_edges.forEach(edgeIdentifier => { + diffAnalysis.edge_diff.deleted_edges.forEach((edgeIdentifier) => { // Edge identifier format: "sourceName:sourceHandle->targetName:targetHandle" // Parse this to extract the components const match = edgeIdentifier.match(/^([^:]+):([^-]+)->([^:]+)(?::(.+))?$/) if (match) { const [, sourceName, sourceHandle, targetName, targetHandle] = match - + // Find block IDs by name let sourceId: string | null = null let targetId: string | null = null - + Object.entries(blocks).forEach(([blockId, block]) => { if (block.name === sourceName) sourceId = blockId if (block.name === targetName) targetId = blockId }) - + // Only reconstruct if both blocks exist if (sourceId && targetId) { // Generate a unique edge ID const edgeId = `deleted-edge-${sourceId}-${sourceHandle}-${targetId}-${targetHandle || 'default'}` - + reconstructedEdges.push({ id: edgeId, source: sourceId, @@ -145,11 +139,11 @@ const WorkflowContent = React.memo(() => { } } }) - + // Combine existing edges with reconstructed deleted edges return [...edges, ...reconstructedEdges] } - + // Otherwise, just use the edges as-is return edges }, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks]) @@ -297,15 +291,14 @@ const WorkflowContent = React.memo(() => { try { // Use the shared auto layout utility for immediate frontend updates const { applyAutoLayoutAndUpdateStore } = await import('./utils/auto-layout') - + const result = await applyAutoLayoutAndUpdateStore(activeWorkflowId!) - + if (result.success) { logger.info('Auto layout completed successfully') } else { logger.error('Auto layout failed:', result.error) } - } catch (error) { logger.error('Auto layout error:', error) } diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 37809c8f208..c9c618b70ff 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -15,7 +15,6 @@ import { getApiKey, getProviderFromModel, transformBlockTool } from '@/providers import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' import { getTool, getToolAsync } from '@/tools/utils' -import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('AgentBlockHandler') diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index b81997687e0..8ff7539f7f7 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -1,11 +1,10 @@ -import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' +import { getBaseUrl } from '@/lib/urls/utils' import type { BlockOutput } from '@/blocks/types' import { BlockType } from '@/executor/consts' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { calculateCost, getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' -import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('EvaluatorBlockHandler') diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 15f6d021cc9..981c95c219d 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -6,10 +6,10 @@ import { getBlock } from '@/blocks' import { resolveOutputType } from '@/blocks/utils' import { useSocket } from '@/contexts/socket-context' import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import type { Position } from '@/stores/workflows/workflow/types' const logger = createLogger('CollaborativeWorkflow') diff --git a/apps/sim/lib/autolayout/algorithms/hierarchical.ts b/apps/sim/lib/autolayout/algorithms/hierarchical.ts index c3bd9e4e787..75ecbe9571c 100644 --- a/apps/sim/lib/autolayout/algorithms/hierarchical.ts +++ b/apps/sim/lib/autolayout/algorithms/hierarchical.ts @@ -319,8 +319,9 @@ function calculatePositions( // Improved vertical spacing calculation to prevent overlaps // Use a minimum spacing that accounts for block heights plus extra buffer const minVerticalSpacing = Math.max(spacing.vertical, 100) - const adaptiveSpacing = layer.length > 1 ? Math.max(minVerticalSpacing, spacing.vertical * 1.2) : minVerticalSpacing - + const adaptiveSpacing = + layer.length > 1 ? Math.max(minVerticalSpacing, spacing.vertical * 1.2) : minVerticalSpacing + // Calculate total layer height with improved spacing const totalHeight = layer.reduce((sum, node) => sum + node.height, 0) + (layer.length - 1) * adaptiveSpacing @@ -346,7 +347,7 @@ function calculatePositions( id: node.id, position: { x: currentX, y: currentY }, }) - + // Use adaptive spacing that considers the current node's height if (nodeIndex < layer.length - 1) { const nextNode = layer[nodeIndex + 1] @@ -367,7 +368,10 @@ function calculatePositions( // Improved horizontal spacing calculation const minHorizontalSpacing = Math.max(spacing.horizontal, 80) - const adaptiveSpacing = layer.length > 1 ? Math.max(minHorizontalSpacing, spacing.horizontal * 1.2) : minHorizontalSpacing + const adaptiveSpacing = + layer.length > 1 + ? Math.max(minHorizontalSpacing, spacing.horizontal * 1.2) + : minHorizontalSpacing // Calculate total layer width with improved spacing const totalWidth = @@ -394,7 +398,7 @@ function calculatePositions( id: node.id, position: { x: currentX, y: currentY }, }) - + // Use adaptive spacing that considers the current node's width if (nodeIndex < layer.length - 1) { const nextNode = layer[nodeIndex + 1] diff --git a/apps/sim/lib/autolayout/algorithms/smart.ts b/apps/sim/lib/autolayout/algorithms/smart.ts index b09c8a9b897..5a48c474a14 100644 --- a/apps/sim/lib/autolayout/algorithms/smart.ts +++ b/apps/sim/lib/autolayout/algorithms/smart.ts @@ -390,27 +390,31 @@ function calculateLayeredLayout( if (!outgoing.has(edge.source)) outgoing.set(edge.source, []) outgoing.get(edge.source)!.push(edge.target) }) - + // Count nodes with multiple outputs (branching points) const branchingNodes = Array.from(outgoing.entries()).filter(([_, targets]) => targets.length > 1) const hasSignificantBranching = branchingNodes.length > 0 - + // Adjust spacing based on workflow characteristics const adjustedOptions: LayoutOptions = { ...options, spacing: { - horizontal: hasSignificantBranching ? options.spacing.horizontal * 1.2 : options.spacing.horizontal, - vertical: hasSignificantBranching ? Math.max(options.spacing.vertical * 1.8, 350) : options.spacing.vertical * 1.2, + horizontal: hasSignificantBranching + ? options.spacing.horizontal * 1.2 + : options.spacing.horizontal, + vertical: hasSignificantBranching + ? Math.max(options.spacing.vertical * 1.8, 350) + : options.spacing.vertical * 1.2, layer: options.spacing.layer * 1.1, }, } // Use the improved hierarchical layout with better spacing const result = calculateHierarchicalLayout(nodes, edges, adjustedOptions) - + // Update metadata to reflect this is a layered layout result.metadata.strategy = 'layered' - + return result } diff --git a/apps/sim/lib/autolayout/service.ts b/apps/sim/lib/autolayout/service.ts index 7aaa9e64a92..45889fe912f 100644 --- a/apps/sim/lib/autolayout/service.ts +++ b/apps/sim/lib/autolayout/service.ts @@ -136,7 +136,7 @@ export class AutoLayoutService { // For blocks without explicit height, estimate based on content const hasLongContent = block.subBlocks && Object.keys(block.subBlocks).length > 3 const isComplexBlock = ['agent', 'api', 'function'].includes(block.type) - + if (hasLongContent || isComplexBlock) { actualHeight = Math.max(actualHeight * 1.8, 200) // Increase estimated height for content-heavy blocks } else if (Object.keys(block.subBlocks || {}).length > 0) { @@ -359,7 +359,7 @@ export class AutoLayoutService { // For blocks without explicit height, estimate based on content const hasLongContent = block.subBlocks && Object.keys(block.subBlocks).length > 3 const isComplexBlock = ['agent', 'api', 'function'].includes(block.type) - + if (hasLongContent || isComplexBlock) { actualHeight = Math.max(actualHeight * 1.8, 200) // Increase estimated height for content-heavy blocks } else if (Object.keys(block.subBlocks || {}).length > 0) { diff --git a/apps/sim/lib/copilot/examples.ts b/apps/sim/lib/copilot/examples.ts index aa0ba8e3e63..b614ac25ee8 100644 --- a/apps/sim/lib/copilot/examples.ts +++ b/apps/sim/lib/copilot/examples.ts @@ -1,6 +1,6 @@ /** * YAML Workflow Examples for Copilot - * + * * This file contains example YAML workflows that the copilot can reference * when helping users build workflows. */ @@ -27,7 +27,7 @@ blocks: model: gpt-4o apiKey: '{{OPENAI_API_KEY}}'`, - 'tool_call_agent': `version: '1.0' + tool_call_agent: `version: '1.0' blocks: start: type: starter @@ -218,7 +218,7 @@ blocks: apiKey: '{{OPENAI_API_KEY}}'`, // Targeted Update Examples - for demonstrating targeted_updates tool usage patterns - 'targeted_add_block': `// Example: Adding a new agent block to an existing workflow + targeted_add_block: `// Example: Adding a new agent block to an existing workflow // Operation: Add a new block after an existing agent { "operations": [ @@ -248,7 +248,7 @@ blocks: ] }`, - 'targeted_edit_block': `// Example: Modifying an existing block's configuration + targeted_edit_block: `// Example: Modifying an existing block's configuration // Operation: Update system prompt and add tools to an agent { "operations": [ @@ -278,7 +278,7 @@ blocks: ] }`, - 'targeted_delete_block': `// Example: Removing a block and updating connections + targeted_delete_block: `// Example: Removing a block and updating connections // Operation: Delete a block and redirect its connections { "operations": [ @@ -298,7 +298,7 @@ blocks: ] }`, - 'targeted_add_connection': `// Example: Adding new parallel connections + targeted_add_connection: `// Example: Adding new parallel connections // Operation: Make one block connect to multiple agents { "operations": [ @@ -328,7 +328,7 @@ blocks: ] }`, - 'targeted_batch_operations': `// Example: Multiple operations in one targeted update + targeted_batch_operations: `// Example: Multiple operations in one targeted update // Operation: Add API block, update agent, and create new connections { "operations": [ @@ -381,5 +381,5 @@ blocks: } } ] -}` -} \ No newline at end of file +}`, +} diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index e13bc9f02fc..77a96b7d04e 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -8,6 +8,7 @@ import { executeProviderRequest } from '@/providers' import type { ProviderToolConfig } from '@/providers/types' import { getApiKey } from '@/providers/utils' import { getCopilotConfig, getCopilotModel } from './config' +import { WORKFLOW_EXAMPLES } from './examples' import { AGENT_MODE_SYSTEM_PROMPT, ASK_MODE_SYSTEM_PROMPT, @@ -15,7 +16,6 @@ import { TITLE_GENERATION_USER_PROMPT, validateSystemPrompts, } from './prompts' -import { WORKFLOW_EXAMPLES } from './examples' const logger = createLogger('CopilotService') @@ -28,8 +28,6 @@ if (!promptValidation.agentMode.valid) { logger.error('Agent mode system prompt validation failed:', promptValidation.agentMode.issues) } - - /** * Citation information for documentation references */ @@ -251,10 +249,10 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { exampleIds: { type: 'array', items: { - type: 'string' + type: 'string', }, - description: 'Array of example IDs to retrieve' - } + description: 'Array of example IDs to retrieve', + }, }, required: ['exampleIds'], }, @@ -414,7 +412,8 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { params: { variables: { type: 'object', - description: 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', + description: + 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', }, }, parameters: { @@ -422,7 +421,8 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { properties: { variables: { type: 'object', - description: 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', + description: + 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', }, }, required: ['variables'], @@ -444,7 +444,8 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, includeDetails: { type: 'boolean', - description: 'Whether to include detailed input/output data for each console entry (default: false)', + description: + 'Whether to include detailed input/output data for each console entry (default: false)', default: false, }, }, @@ -469,30 +470,30 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { operation_type: { type: 'string', enum: ['add', 'edit', 'delete'], - description: 'Type of operation to perform' + description: 'Type of operation to perform', }, block_id: { - type: 'string', - description: 'Block ID for the operation. For add operations, this will be the desired ID for the new block.' + type: 'string', + description: + 'Block ID for the operation. For add operations, this will be the desired ID for the new block.', }, params: { type: 'object', - description: 'Parameters for the operation. For add: {type: "block_type", name: "Block Name", inputs: {...}, connections: {...}}, for edit: {inputs: {...}, connections: {...}}, for delete: empty' - } + description: + 'Parameters for the operation. For add: {type: "block_type", name: "Block Name", inputs: {...}, connections: {...}}, for edit: {inputs: {...}, connections: {...}}, for delete: empty', + }, }, - required: ['operation_type', 'block_id'] - } - } + required: ['operation_type', 'block_id'], + }, + }, }, - required: ['operations'] + required: ['operations'], }, }, ] // Filter tools based on mode - return mode === 'ask' - ? allTools.filter((tool) => tool.id !== 'preview_workflow') - : allTools + return mode === 'ask' ? allTools.filter((tool) => tool.id !== 'preview_workflow') : allTools } /** @@ -629,7 +630,7 @@ export async function generateChatResponse( streamToolCalls: true, // Enable tool call streaming for copilot workflowId: options.workflowId, chatId: options.chatId, - userId: options.userId || 'unknown_user' // Pass userId to provider request + userId: options.userId || 'unknown_user', // Pass userId to provider request }) // Handle StreamingExecution (from providers with tool calls) @@ -898,7 +899,7 @@ export async function sendMessage(request: SendMessageRequest): Promise<{ mode, chatId: currentChat?.id, implicitFeedback: request.implicitFeedback, - userId: userId // Pass userId to generateChatResponse + userId: userId, // Pass userId to generateChatResponse }) // For non-streaming responses, save immediately @@ -960,5 +961,3 @@ export async function updateChatMessages( throw error } } - - diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index cfa5317cf80..747c059bab8 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -1,14 +1,8 @@ import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { getBlock } from '@/blocks' -import { resolveOutputType } from '@/blocks/utils' -import { parseWorkflowYaml, convertYamlToWorkflow } from '@/stores/workflows/yaml/importer' -import { searchDocumentation } from './service' import { WORKFLOW_EXAMPLES } from './examples' -import { v4 as uuidv4 } from 'uuid' +import { searchDocumentation } from './service' const logger = createLogger('CopilotTools') @@ -84,10 +78,13 @@ interface UserWorkflowData { /** * Apply targeted update operations to YAML content */ -async function applyOperationsToYaml(currentYaml: string, operations: TargetedUpdateOperation[]): Promise { +async function applyOperationsToYaml( + currentYaml: string, + operations: TargetedUpdateOperation[] +): Promise { const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') const yaml = await import('yaml') - + // Parse current YAML to get the complete structure const { data: workflowData, errors } = parseWorkflowYaml(currentYaml) if (!workflowData || errors.length > 0) { @@ -98,7 +95,7 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp logger.info('Starting YAML operations', { initialBlockCount: Object.keys(workflowData.blocks).length, version: workflowData.version, - operationCount: operations.length + operationCount: operations.length, }) for (const operation of operations) { @@ -111,32 +108,36 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp if (workflowData.blocks[block_id]) { // First, find child blocks that reference this block as parent (before deleting the parent) const childBlocksToRemove: string[] = [] - Object.entries(workflowData.blocks).forEach(([childBlockId, childBlock]: [string, any]) => { - if (childBlock.parentId === block_id) { - logger.info(`Found child block ${childBlockId} with parentId ${block_id}, marking for deletion`) - childBlocksToRemove.push(childBlockId) + Object.entries(workflowData.blocks).forEach( + ([childBlockId, childBlock]: [string, any]) => { + if (childBlock.parentId === block_id) { + logger.info( + `Found child block ${childBlockId} with parentId ${block_id}, marking for deletion` + ) + childBlocksToRemove.push(childBlockId) + } } - }) - + ) + // Delete the main block delete workflowData.blocks[block_id] logger.info(`Deleted block ${block_id}`) - + // Remove child blocks - childBlocksToRemove.forEach(childBlockId => { + childBlocksToRemove.forEach((childBlockId) => { if (workflowData.blocks[childBlockId]) { delete workflowData.blocks[childBlockId] logger.info(`Deleted child block ${childBlockId}`) } }) - + // Remove connections mentioning this block or any of its children const allDeletedBlocks = [block_id, ...childBlocksToRemove] Object.values(workflowData.blocks).forEach((block: any) => { if (block.connections) { - Object.keys(block.connections).forEach(key => { + Object.keys(block.connections).forEach((key) => { const connectionValue = block.connections[key] - + if (typeof connectionValue === 'string') { // Simple format: connections: { default: "block2" } if (allDeletedBlocks.includes(connectionValue)) { @@ -148,12 +149,13 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp block.connections[key] = connectionValue.filter((item: any) => { if (typeof item === 'string') { return !allDeletedBlocks.includes(item) - } else if (typeof item === 'object' && item.block) { + } + if (typeof item === 'object' && item.block) { return !allDeletedBlocks.includes(item.block) } return true }) - + // If array is empty after filtering, remove the connection if (block.connections[key].length === 0) { delete block.connections[key] @@ -162,7 +164,9 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp // Object format: connections: { success: { block: "block2", input: "data" } } if (allDeletedBlocks.includes(connectionValue.block)) { delete block.connections[key] - logger.info(`Removed object connection ${key} to deleted block ${connectionValue.block}`) + logger.info( + `Removed object connection ${key} to deleted block ${connectionValue.block}` + ) } } }) @@ -176,72 +180,85 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp case 'edit': if (workflowData.blocks[block_id]) { const block = workflowData.blocks[block_id] - + // Update inputs (preserve existing inputs, only overwrite specified ones) if (params?.inputs) { if (!block.inputs) block.inputs = {} Object.assign(block.inputs, params.inputs) logger.info(`Updated inputs for block ${block_id}`, { inputs: block.inputs }) } - + // Update connections (preserve existing connections, only overwrite specified ones) if (params?.connections) { if (!block.connections) block.connections = {} - + // Handle edge removals - if a connection is explicitly set to null, remove it Object.entries(params.connections).forEach(([key, value]) => { if (value === null) { delete (block.connections as any)[key] logger.info(`Removed connection ${key} from block ${block_id}`) } else { - (block.connections as any)[key] = value + ;(block.connections as any)[key] = value } }) - - logger.info(`Updated connections for block ${block_id}`, { connections: block.connections }) + + logger.info(`Updated connections for block ${block_id}`, { + connections: block.connections, + }) } - + // Handle edge removals when specified in params if (params?.removeEdges && Array.isArray(params.removeEdges)) { - params.removeEdges.forEach((edgeToRemove: { targetBlockId: string, sourceHandle?: string, targetHandle?: string }) => { - if (!block.connections) return - - const { targetBlockId, sourceHandle = 'default' } = edgeToRemove - - // Handle different connection formats - const connectionValue = (block.connections as any)[sourceHandle] - - if (typeof connectionValue === 'string') { - // Simple format: connections: { default: "block2" } - if (connectionValue === targetBlockId) { - delete (block.connections as any)[sourceHandle] - logger.info(`Removed edge from ${block_id}:${sourceHandle} to ${targetBlockId}`) - } - } else if (Array.isArray(connectionValue)) { - // Array format: connections: { default: ["block2", "block3"] } - (block.connections as any)[sourceHandle] = connectionValue.filter((item: any) => { - if (typeof item === 'string') { - return item !== targetBlockId - } else if (typeof item === 'object' && item.block) { - return item.block !== targetBlockId + params.removeEdges.forEach( + (edgeToRemove: { + targetBlockId: string + sourceHandle?: string + targetHandle?: string + }) => { + if (!block.connections) return + + const { targetBlockId, sourceHandle = 'default' } = edgeToRemove + + // Handle different connection formats + const connectionValue = (block.connections as any)[sourceHandle] + + if (typeof connectionValue === 'string') { + // Simple format: connections: { default: "block2" } + if (connectionValue === targetBlockId) { + delete (block.connections as any)[sourceHandle] + logger.info(`Removed edge from ${block_id}:${sourceHandle} to ${targetBlockId}`) + } + } else if (Array.isArray(connectionValue)) { + // Array format: connections: { default: ["block2", "block3"] } + ;(block.connections as any)[sourceHandle] = connectionValue.filter( + (item: any) => { + if (typeof item === 'string') { + return item !== targetBlockId + } + if (typeof item === 'object' && item.block) { + return item.block !== targetBlockId + } + return true + } + ) + + // If array is empty after filtering, remove the connection + if ((block.connections as any)[sourceHandle].length === 0) { + delete (block.connections as any)[sourceHandle] + } + + logger.info(`Updated array connection for ${block_id}:${sourceHandle}`) + } else if (typeof connectionValue === 'object' && connectionValue.block) { + // Object format: connections: { success: { block: "block2", input: "data" } } + if (connectionValue.block === targetBlockId) { + delete (block.connections as any)[sourceHandle] + logger.info( + `Removed object connection from ${block_id}:${sourceHandle} to ${targetBlockId}` + ) } - return true - }) - - // If array is empty after filtering, remove the connection - if ((block.connections as any)[sourceHandle].length === 0) { - delete (block.connections as any)[sourceHandle] - } - - logger.info(`Updated array connection for ${block_id}:${sourceHandle}`) - } else if (typeof connectionValue === 'object' && connectionValue.block) { - // Object format: connections: { success: { block: "block2", input: "data" } } - if (connectionValue.block === targetBlockId) { - delete (block.connections as any)[sourceHandle] - logger.info(`Removed object connection from ${block_id}:${sourceHandle} to ${targetBlockId}`) } } - }) + ) } } else { logger.warn(`Block ${block_id} not found for editing`) @@ -254,7 +271,7 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp type: params.type, name: params.name, inputs: params.inputs || {}, - connections: params.connections || {} + connections: params.connections || {}, } logger.info(`Added block ${block_id}`, { type: params.type, name: params.name }) } else { @@ -268,7 +285,7 @@ async function applyOperationsToYaml(currentYaml: string, operations: TargetedUp } logger.info('Completed YAML operations', { - finalBlockCount: Object.keys(workflowData.blocks).length + finalBlockCount: Object.keys(workflowData.blocks).length, }) // Convert the complete workflow data back to YAML (preserving version and all other fields) @@ -314,7 +331,7 @@ function updateBlockReferences(value: any, blockIdMapping: Map): // Handle arrays if (Array.isArray(value)) { - return value.map(item => updateBlockReferences(item, blockIdMapping)) + return value.map((item) => updateBlockReferences(item, blockIdMapping)) } // Handle objects @@ -440,21 +457,21 @@ export const getWorkflowExamplesTool: CopilotTool = { exampleIds: { type: 'array', items: { - type: 'string' + type: 'string', }, - description: 'Array of example IDs to retrieve' - } + description: 'Array of example IDs to retrieve', + }, }, required: ['exampleIds'], }, execute: async (args: Record): Promise => { try { const { exampleIds } = args - + if (!Array.isArray(exampleIds)) { return { success: false, - error: 'exampleIds must be an array' + error: 'exampleIds must be an array', } } @@ -474,14 +491,14 @@ export const getWorkflowExamplesTool: CopilotTool = { data: { examples, notFound, - availableIds: Object.keys(WORKFLOW_EXAMPLES) - } + availableIds: Object.keys(WORKFLOW_EXAMPLES), + }, } } catch (error) { logger.error('Get workflow examples failed', error) return { success: false, - error: `Failed to get workflow examples: ${error instanceof Error ? error.message : 'Unknown error'}` + error: `Failed to get workflow examples: ${error instanceof Error ? error.message : 'Unknown error'}`, } } }, @@ -493,7 +510,8 @@ export const getWorkflowExamplesTool: CopilotTool = { const targetedUpdatesTool: CopilotTool = { id: 'targeted_updates', name: 'Targeted Updates', - description: 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Takes an array of operations to execute.', + description: + 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Takes an array of operations to execute.', parameters: { type: 'object', properties: { @@ -506,40 +524,42 @@ const targetedUpdatesTool: CopilotTool = { operation_type: { type: 'string', enum: ['add', 'edit', 'delete'], - description: 'Type of operation to perform' + description: 'Type of operation to perform', }, block_id: { - type: 'string', - description: 'Block ID for the operation. For add operations, this will be the desired ID for the new block.' + type: 'string', + description: + 'Block ID for the operation. For add operations, this will be the desired ID for the new block.', }, params: { type: 'object', - description: 'Parameters for the operation. For add: full block YAML, for edit: partial updates to inputs/connections, for delete: empty' - } + description: + 'Parameters for the operation. For add: full block YAML, for edit: partial updates to inputs/connections, for delete: empty', + }, }, - required: ['operation_type', 'block_id'] - } - } + required: ['operation_type', 'block_id'], + }, + }, }, - required: ['operations'] + required: ['operations'], }, execute: async (args: Record): Promise => { try { const { operations, _context } = args - + if (!Array.isArray(operations)) { return { success: false, - error: 'Operations must be an array' + error: 'Operations must be an array', } } const workflowId = _context?.workflowId - + if (!workflowId) { return { success: false, - error: 'No workflow ID provided in context' + error: 'No workflow ID provided in context', } } @@ -547,81 +567,89 @@ const targetedUpdatesTool: CopilotTool = { const { db } = await import('@/db') const { workflow, workflowBlocks } = await import('@/db/schema') const { eq } = await import('drizzle-orm') - - const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1) - + + const workflowData = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + if (!workflowData.length) { return { success: false, - error: 'Workflow not found' + error: 'Workflow not found', } } // Get current workflow YAML directly from the API endpoint (not the client-side store) - const workflowResponse = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/tools/get-user-workflow`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workflowId: workflowId, - includeMetadata: false, - }), - }) + const workflowResponse = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/tools/get-user-workflow`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workflowId: workflowId, + includeMetadata: false, + }), + } + ) if (!workflowResponse.ok) { return { success: false, - error: `Failed to get current workflow YAML: ${workflowResponse.status} ${workflowResponse.statusText}` + error: `Failed to get current workflow YAML: ${workflowResponse.status} ${workflowResponse.statusText}`, } } const getUserWorkflowResult = await workflowResponse.json() - + if (!getUserWorkflowResult.success || !getUserWorkflowResult.output?.yaml) { return { success: false, - error: 'Failed to get current workflow YAML' + error: 'Failed to get current workflow YAML', } } const currentYaml = getUserWorkflowResult.output.yaml - + logger.info('Retrieved current workflow YAML', { yamlLength: currentYaml.length, yamlPreview: currentYaml.substring(0, 200), - getUserWorkflowData: getUserWorkflowResult.output + getUserWorkflowData: getUserWorkflowResult.output, }) // Apply operations to generate modified YAML const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) - + logger.info('Applied operations to YAML', { operationCount: operations.length, currentYamlLength: currentYaml.length, modifiedYamlLength: modifiedYaml.length, - operations: operations.map(op => ({ type: op.operation_type, blockId: op.block_id })) + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), }) - logger.info(`Successfully generated modified YAML for ${operations.length} targeted update operations`) - + logger.info( + `Successfully generated modified YAML for ${operations.length} targeted update operations` + ) + // Return the modified YAML directly - the UI will handle preview generation via updateDiffStore() return { success: true, data: { yamlContent: modifiedYaml, - operations: operations.map(op => ({ type: op.operation_type, blockId: op.block_id })) - } + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), + }, } - } catch (error) { logger.error('Targeted updates execution failed:', error) return { success: false, - error: `Targeted updates failed: ${error instanceof Error ? error.message : 'Unknown error'}` + error: `Targeted updates failed: ${error instanceof Error ? error.message : 'Unknown error'}`, } } - } + }, } /** @@ -648,23 +676,26 @@ const previewWorkflowTool: CopilotTool = { execute: async (args: Record): Promise => { try { const { yamlContent, description } = args - + // Make direct API call to workflow preview endpoint - const response = await fetch(`${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent, - applyAutoLayout: true, - }), - }) + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent, + applyAutoLayout: true, + }), + } + ) if (!response.ok) { return { success: false, - error: `Preview generation failed: ${response.status} ${response.statusText}` + error: `Preview generation failed: ${response.status} ${response.statusText}`, } } @@ -673,7 +704,7 @@ const previewWorkflowTool: CopilotTool = { if (!previewData.success) { return { success: false, - error: `Preview generation failed: ${previewData.message || 'Unknown error'}` + error: `Preview generation failed: ${previewData.message || 'Unknown error'}`, } } @@ -683,18 +714,17 @@ const previewWorkflowTool: CopilotTool = { data: { ...previewData, yamlContent, // Include the original YAML for diff functionality - description - } + description, + }, } - } catch (error) { logger.error('Preview workflow execution failed:', error) return { success: false, - error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}` + error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`, } } - } + }, } /** diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index 06068854c3d..dd2ffc88c75 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -1,7 +1,7 @@ +import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { environment } from '@/db/schema' -import { eq } from 'drizzle-orm' const logger = createLogger('EnvironmentUtils') @@ -39,4 +39,4 @@ export async function getEnvironmentVariableKeys(userId: string): Promise<{ logger.error('Error getting environment variable keys:', error) throw new Error('Failed to get environment variables') } -} \ No newline at end of file +} diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index c940baa22de..61b50f43500 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console-logger' -import type { WorkflowState, BlockState } from '@/stores/workflows/workflow/types' -import { convertYamlToWorkflowState, applyAutoLayoutToBlocks } from '@/lib/workflows/yaml-converter' +import { convertYamlToWorkflowState } from '@/lib/workflows/yaml-converter' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDiffEngine') @@ -19,7 +19,7 @@ export interface DiffAnalysis { new_blocks: string[] edited_blocks: string[] deleted_blocks: string[] - field_diffs?: Record + field_diffs?: Record edge_diff?: EdgeDiff } @@ -45,31 +45,28 @@ export class WorkflowDiffEngine { /** * Create a diff from YAML content */ - async createDiffFromYaml( - yamlContent: string, - diffAnalysis?: DiffAnalysis - ): Promise { + async createDiffFromYaml(yamlContent: string, diffAnalysis?: DiffAnalysis): Promise { try { logger.info('Creating diff from YAML content') // Convert YAML to workflow state with new IDs const conversionResult = await convertYamlToWorkflowState(yamlContent, { - generateNewIds: true + generateNewIds: true, }) if (!conversionResult.success || !conversionResult.workflowState) { return { success: false, - errors: conversionResult.errors + errors: conversionResult.errors, } } const proposedState = conversionResult.workflowState - + logger.info('Conversion result:', { hasProposedState: !!proposedState, blockCount: proposedState ? Object.keys(proposedState.blocks).length : 0, - edgeCount: proposedState ? proposedState.edges.length : 0 + edgeCount: proposedState ? proposedState.edges.length : 0, }) // Add diff markers to blocks if analysis is provided @@ -79,17 +76,22 @@ export class WorkflowDiffEngine { new_blocks: diffAnalysis.new_blocks, edited_blocks: diffAnalysis.edited_blocks, deleted_blocks: diffAnalysis.deleted_blocks, - edge_diff: diffAnalysis.edge_diff + edge_diff: diffAnalysis.edge_diff, }) this.applyDiffMarkers(proposedState, diffAnalysis, conversionResult.idMapping!) // Create a mapped version of the diff analysis with new IDs - mappedDiffAnalysis = this.createMappedDiffAnalysis(diffAnalysis, conversionResult.idMapping!) + mappedDiffAnalysis = this.createMappedDiffAnalysis( + diffAnalysis, + conversionResult.idMapping! + ) } else { logger.info('No diff analysis provided, skipping diff markers') } // Debug: Log blocks with parent relationships - const blocksWithParents = Object.values(proposedState.blocks).filter((block: any) => block.parentNode) + const blocksWithParents = Object.values(proposedState.blocks).filter( + (block: any) => block.parentNode + ) logger.info(`Found ${blocksWithParents.length} blocks with parent relationships`) blocksWithParents.forEach((block: any) => { logger.info(`Block ${block.id} has parentNode: ${block.parentNode}`) @@ -97,10 +99,11 @@ export class WorkflowDiffEngine { // Debug: Log loop and parallel blocks const containerBlocks = Object.values(proposedState.blocks).filter( - block => block.type === 'loop' || block.type === 'parallel' + (block) => block.type === 'loop' || block.type === 'parallel' ) - logger.info(`Found ${containerBlocks.length} container blocks (loops/parallels):`, - containerBlocks.map(b => ({ id: b.id, type: b.type, name: b.name })) + logger.info( + `Found ${containerBlocks.length} container blocks (loops/parallels):`, + containerBlocks.map((b) => ({ id: b.id, type: b.type, name: b.name })) ) // Ensure all blocks have their id property set @@ -110,7 +113,7 @@ export class WorkflowDiffEngine { block.id = blockId } }) - + // Debug: Check what Object.values returns const blockValues = Object.values(proposedState.blocks) logger.info('Object.values(blocks) returns:', { @@ -119,30 +122,30 @@ export class WorkflowDiffEngine { index, hasId: !!block.id, id: block.id, - type: block.type - })) + type: block.type, + })), }) - + // Apply auto layout using the service directly const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - + try { logger.info('Applying auto layout to diff workflow', { blockCount: Object.keys(proposedState.blocks).length, edgeCount: proposedState.edges.length, - blocks: Object.keys(proposedState.blocks) + blocks: Object.keys(proposedState.blocks), }) - + const layoutedBlocks = await autoLayoutWorkflow( proposedState.blocks, proposedState.edges, {} // Default options ) - + if (layoutedBlocks) { // Apply the layouted blocks proposedState.blocks = layoutedBlocks - + // Ensure all blocks still have their id property after layout Object.entries(proposedState.blocks).forEach(([blockId, block]) => { if (!block.id) { @@ -150,25 +153,25 @@ export class WorkflowDiffEngine { block.id = blockId } }) - + // Re-apply diff markers after layout if (mappedDiffAnalysis) { Object.entries(proposedState.blocks).forEach(([blockId, block]) => { if (mappedDiffAnalysis.new_blocks.includes(blockId)) { - (block as any).is_diff = 'new' + ;(block as any).is_diff = 'new' } else if (mappedDiffAnalysis.edited_blocks.includes(blockId)) { - (block as any).is_diff = 'edited' - + ;(block as any).is_diff = 'edited' + // Re-apply field-level diff information if available - if (mappedDiffAnalysis.field_diffs && mappedDiffAnalysis.field_diffs[blockId]) { - (block as any).field_diff = mappedDiffAnalysis.field_diffs[blockId] + if (mappedDiffAnalysis.field_diffs?.[blockId]) { + ;(block as any).field_diff = mappedDiffAnalysis.field_diffs[blockId] } } else { - (block as any).is_diff = 'unchanged' + ;(block as any).is_diff = 'unchanged' } }) } - + logger.info('Auto layout applied successfully') } else { logger.warn('Auto layout returned no blocks') @@ -184,24 +187,24 @@ export class WorkflowDiffEngine { diffAnalysis: mappedDiffAnalysis, metadata: { source: 'copilot', - timestamp: Date.now() - } + timestamp: Date.now(), + }, } logger.info('Diff created successfully', { blocksCount: Object.keys(proposedState.blocks).length, - edgesCount: proposedState.edges.length + edgesCount: proposedState.edges.length, }) return { success: true, - diff: this.currentDiff + diff: this.currentDiff, } } catch (error) { logger.error('Failed to create diff:', error) return { success: false, - errors: [error instanceof Error ? error.message : 'Failed to create diff'] + errors: [error instanceof Error ? error.message : 'Failed to create diff'], } } } @@ -214,11 +217,11 @@ export class WorkflowDiffEngine { idMapping: Map ): DiffAnalysis { const mapped: DiffAnalysis = { - new_blocks: analysis.new_blocks.map(oldId => idMapping.get(oldId) || oldId), - edited_blocks: analysis.edited_blocks.map(oldId => idMapping.get(oldId) || oldId), - deleted_blocks: analysis.deleted_blocks // Deleted blocks won't have new IDs + new_blocks: analysis.new_blocks.map((oldId) => idMapping.get(oldId) || oldId), + edited_blocks: analysis.edited_blocks.map((oldId) => idMapping.get(oldId) || oldId), + deleted_blocks: analysis.deleted_blocks, // Deleted blocks won't have new IDs } - + // Map field diffs with new IDs if (analysis.field_diffs) { mapped.field_diffs = {} @@ -227,17 +230,17 @@ export class WorkflowDiffEngine { mapped.field_diffs![newId] = fieldDiff }) } - + // Edge identifiers use block names (not IDs), so they don't need mapping // They should remain as-is since block names are stable between workflows if (analysis.edge_diff) { mapped.edge_diff = { new_edges: analysis.edge_diff.new_edges, // Keep original - uses block names deleted_edges: analysis.edge_diff.deleted_edges, // Keep original - uses block names - unchanged_edges: analysis.edge_diff.unchanged_edges // Keep original - uses block names + unchanged_edges: analysis.edge_diff.unchanged_edges, // Keep original - uses block names } } - + return mapped } @@ -247,8 +250,8 @@ export class WorkflowDiffEngine { private adjustChildBlockPositions(blocks: Record): void { // Group blocks by their parent const blocksByParent = new Map() - - Object.values(blocks).forEach(block => { + + Object.values(blocks).forEach((block) => { const parentId = block.data?.parentId || (block as any).parentNode if (parentId && blocks[parentId]) { if (!blocksByParent.has(parentId)) { @@ -257,63 +260,68 @@ export class WorkflowDiffEngine { blocksByParent.get(parentId)!.push(block) } }) - + // Adjust positions for each parent's children blocksByParent.forEach((childBlocks, parentId) => { const parentBlock = blocks[parentId] if (!parentBlock) return - + // Get parent position const parentPos = parentBlock.position - + logger.info(`Adjusting ${childBlocks.length} child blocks for parent ${parentId}`) - + // Track bounds for container sizing let maxX = 0 let maxY = 0 - + // Make child positions relative to parent - childBlocks.forEach(childBlock => { + childBlocks.forEach((childBlock) => { const currentPos = childBlock.position - + // Check if position is already relative (within reasonable bounds of parent container) const isAlreadyRelative = Math.abs(currentPos.x) < 800 && Math.abs(currentPos.y) < 600 - + if (!isAlreadyRelative) { // Position seems absolute, convert to relative const relativePos = { x: currentPos.x - parentPos.x, - y: currentPos.y - parentPos.y + y: currentPos.y - parentPos.y, } - + childBlock.position = relativePos - logger.info(`Adjusted child block ${childBlock.id} position from absolute`, currentPos, 'to relative', relativePos) + logger.info( + `Adjusted child block ${childBlock.id} position from absolute`, + currentPos, + 'to relative', + relativePos + ) } else { logger.info(`Child block ${childBlock.id} position already relative:`, currentPos) } - + // Track max bounds for container sizing const blockWidth = childBlock.isWide ? 450 : 350 const blockHeight = Math.max(childBlock.height || 100, 100) maxX = Math.max(maxX, childBlock.position.x + blockWidth) maxY = Math.max(maxY, childBlock.position.y + blockHeight) }) - + // Update container dimensions to fit all children if (parentBlock.type === 'loop' || parentBlock.type === 'parallel') { const padding = 150 // Extra padding for container const minWidth = 500 const minHeight = 300 - + parentBlock.data = { ...parentBlock.data, width: Math.max(minWidth, maxX + padding), - height: Math.max(minHeight, maxY + padding) + height: Math.max(minHeight, maxY + padding), } - + logger.info(`Updated container ${parentId} dimensions:`, { width: parentBlock.data.width, - height: parentBlock.data.height + height: parentBlock.data.height, }) } }) @@ -332,9 +340,9 @@ export class WorkflowDiffEngine { editedBlocks: analysis.edited_blocks, deletedBlocks: analysis.deleted_blocks, totalBlocks: Object.keys(state.blocks).length, - timestamp: Date.now() + timestamp: Date.now(), }) - + // Create reverse mapping from new IDs to original IDs const reverseMapping = new Map() idMapping.forEach((newId, originalId) => { @@ -345,39 +353,42 @@ export class WorkflowDiffEngine { Object.entries(state.blocks).forEach(([blockId, block]) => { // Find original ID to check diff analysis const originalId = reverseMapping.get(blockId) - + if (originalId) { if (analysis.new_blocks.includes(originalId)) { - (block as any).is_diff = 'new' + ;(block as any).is_diff = 'new' markersApplied++ logger.info(`Block ${blockId} (original: ${originalId}) marked as new`) } else if (analysis.edited_blocks.includes(originalId)) { - (block as any).is_diff = 'edited' + ;(block as any).is_diff = 'edited' markersApplied++ - + // Add field-level diff information if available - if (analysis.field_diffs && analysis.field_diffs[originalId]) { - (block as any).field_diff = analysis.field_diffs[originalId] - logger.info(`Block ${blockId} (original: ${originalId}) marked as edited with field diff:`, { - changed_fields: analysis.field_diffs[originalId].changed_fields, - unchanged_fields: analysis.field_diffs[originalId].unchanged_fields.length - }) + if (analysis.field_diffs?.[originalId]) { + ;(block as any).field_diff = analysis.field_diffs[originalId] + logger.info( + `Block ${blockId} (original: ${originalId}) marked as edited with field diff:`, + { + changed_fields: analysis.field_diffs[originalId].changed_fields, + unchanged_fields: analysis.field_diffs[originalId].unchanged_fields.length, + } + ) } else { logger.info(`Block ${blockId} (original: ${originalId}) marked as edited`) } } else { - (block as any).is_diff = 'unchanged' + ;(block as any).is_diff = 'unchanged' } } else { - (block as any).is_diff = 'unchanged' + ;(block as any).is_diff = 'unchanged' logger.warn(`Block ${blockId} has no original ID mapping`) } }) - + console.log('[DiffEngine] Diff markers applied:', { markersApplied, totalBlocks: Object.keys(state.blocks).length, - timestamp: Date.now() + timestamp: Date.now(), }) } @@ -423,31 +434,30 @@ export class WorkflowDiffEngine { } const cleanState = { ...this.currentDiff.proposedState } - + // Filter out blocks without type or name and remove diff markers const filteredBlocks: Record = {} Object.entries(cleanState.blocks).forEach(([blockId, block]) => { if (block.type && block.name) { // Remove diff markers - delete (block as any).is_diff - delete (block as any).field_diff + ;(block as any).is_diff = undefined(block as any).field_diff = undefined filteredBlocks[blockId] = block } else { logger.info(`Filtering out block ${blockId} - missing type or name`) } }) - + cleanState.blocks = filteredBlocks - + // Filter out edges that connect to removed blocks const validBlockIds = new Set(Object.keys(filteredBlocks)) - cleanState.edges = cleanState.edges.filter(edge => - validBlockIds.has(edge.source) && validBlockIds.has(edge.target) + cleanState.edges = cleanState.edges.filter( + (edge) => validBlockIds.has(edge.source) && validBlockIds.has(edge.target) ) logger.info('Diff accepted', { blocksCount: Object.keys(cleanState.blocks).length, - edgesCount: cleanState.edges.length + edgesCount: cleanState.edges.length, }) this.clearDiff() @@ -467,8 +477,8 @@ export class WorkflowDiffEngine { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ original_yaml: originalYaml, - agent_yaml: proposedYaml - }) + agent_yaml: proposedYaml, + }), }) if (response.ok) { @@ -480,7 +490,7 @@ export class WorkflowDiffEngine { } catch (error) { logger.error('Failed to analyze diff:', error) } - + return null } -} \ No newline at end of file +} diff --git a/apps/sim/lib/workflows/diff/index.ts b/apps/sim/lib/workflows/diff/index.ts index cb9a585bd65..59f54cb0d93 100644 --- a/apps/sim/lib/workflows/diff/index.ts +++ b/apps/sim/lib/workflows/diff/index.ts @@ -1,4 +1,4 @@ +export type { DiffAnalysis, DiffMetadata, DiffResult, WorkflowDiff } from './diff-engine' export { WorkflowDiffEngine } from './diff-engine' -export type { DiffMetadata, DiffAnalysis, WorkflowDiff, DiffResult } from './diff-engine' +export type { UseWorkflowDiffReturn } from './use-workflow-diff' export { useWorkflowDiff } from './use-workflow-diff' -export type { UseWorkflowDiffReturn } from './use-workflow-diff' \ No newline at end of file diff --git a/apps/sim/lib/workflows/diff/use-workflow-diff.ts b/apps/sim/lib/workflows/diff/use-workflow-diff.ts index 6b10a46ba0c..fd51372588b 100644 --- a/apps/sim/lib/workflows/diff/use-workflow-diff.ts +++ b/apps/sim/lib/workflows/diff/use-workflow-diff.ts @@ -1,9 +1,9 @@ -import { useState, useCallback, useRef, useEffect } from 'react' +import { useCallback, useRef, useState } from 'react' import { createLogger } from '@/lib/logs/console-logger' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { WorkflowDiffEngine, type DiffAnalysis } from './diff-engine' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { type DiffAnalysis, WorkflowDiffEngine } from './diff-engine' const logger = createLogger('useWorkflowDiff') @@ -25,40 +25,37 @@ export interface UseWorkflowDiffReturn { export function useWorkflowDiff(): UseWorkflowDiffReturn { const [isShowingDiff, setIsShowingDiff] = useState(false) const diffEngineRef = useRef(null) - + // Get store methods const workflowStore = useWorkflowStore() - const activeWorkflowId = useWorkflowRegistry(state => state.activeWorkflowId) + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) // Initialize diff engine if (!diffEngineRef.current) { diffEngineRef.current = new WorkflowDiffEngine() } - const setProposedChanges = useCallback(async ( - yamlContent: string, - diffAnalysis?: DiffAnalysis - ): Promise => { - try { - logger.info('Setting proposed changes') - - const result = await diffEngineRef.current!.createDiffFromYaml( - yamlContent, - diffAnalysis - ) - - if (result.success) { - setIsShowingDiff(true) - return true - } + const setProposedChanges = useCallback( + async (yamlContent: string, diffAnalysis?: DiffAnalysis): Promise => { + try { + logger.info('Setting proposed changes') - logger.error('Failed to create diff:', result.errors) - return false - } catch (error) { - logger.error('Error setting proposed changes:', error) - return false - } - }, []) + const result = await diffEngineRef.current!.createDiffFromYaml(yamlContent, diffAnalysis) + + if (result.success) { + setIsShowingDiff(true) + return true + } + + logger.error('Failed to create diff:', result.errors) + return false + } catch (error) { + logger.error('Error setting proposed changes:', error) + return false + } + }, + [] + ) const clearDiff = useCallback(() => { logger.info('Clearing diff') @@ -74,7 +71,7 @@ export function useWorkflowDiff(): UseWorkflowDiffReturn { try { logger.info('Accepting diff changes') - + const cleanState = diffEngineRef.current!.acceptDiff() if (!cleanState) { logger.warn('No diff to accept') @@ -86,7 +83,7 @@ export function useWorkflowDiff(): UseWorkflowDiffReturn { blocks: cleanState.blocks, edges: cleanState.edges, loops: cleanState.loops, - parallels: cleanState.parallels + parallels: cleanState.parallels, }) // Update subblock store with values from diff @@ -101,8 +98,8 @@ export function useWorkflowDiff(): UseWorkflowDiffReturn { useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, - [activeWorkflowId]: subblockValues - } + [activeWorkflowId]: subblockValues, + }, })) // Update last saved timestamp @@ -115,8 +112,8 @@ export function useWorkflowDiff(): UseWorkflowDiffReturn { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...cleanState, - lastSaved: Date.now() - }) + lastSaved: Date.now(), + }), }) if (!response.ok) { @@ -143,16 +140,16 @@ export function useWorkflowDiff(): UseWorkflowDiffReturn { }, [clearDiff]) const toggleDiffView = useCallback(() => { - setIsShowingDiff(prev => !prev) + setIsShowingDiff((prev) => !prev) }, []) const getCurrentWorkflowForCanvas = useCallback(() => { const currentState = workflowStore.getWorkflowState() - + if (isShowingDiff && diffEngineRef.current!.hasDiff()) { return diffEngineRef.current!.getDisplayState(currentState) } - + return currentState }, [isShowingDiff, workflowStore]) @@ -164,6 +161,6 @@ export function useWorkflowDiff(): UseWorkflowDiffReturn { acceptChanges, rejectChanges, toggleDiffView, - getCurrentWorkflowForCanvas + getCurrentWorkflowForCanvas, } -} \ No newline at end of file +} diff --git a/apps/sim/lib/workflows/yaml-converter.ts b/apps/sim/lib/workflows/yaml-converter.ts index 014efe1e8ff..6a557b8b7fd 100644 --- a/apps/sim/lib/workflows/yaml-converter.ts +++ b/apps/sim/lib/workflows/yaml-converter.ts @@ -1,14 +1,11 @@ import { v4 as uuidv4 } from 'uuid' import { createLogger } from '@/lib/logs/console-logger' +import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' import { getBlock } from '@/blocks' import { resolveOutputType } from '@/blocks/utils' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { - parseWorkflowYaml, - convertYamlToWorkflow -} from '@/stores/workflows/yaml/importer' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' +import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer' import type { ImportedEdge } from '@/stores/workflows/yaml/parsing-utils' // Define local types that aren't exported from importer @@ -67,61 +64,65 @@ export async function convertYamlToWorkflowState( // Step 1: Parse YAML const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) - + if (!yamlWorkflow || parseErrors.length > 0) { return { success: false, errors: parseErrors, - warnings: [] + warnings: [], } } // Step 2: Convert YAML to imported blocks/edges const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow) - + if (convertErrors.length > 0) { return { success: false, errors: convertErrors, - warnings + warnings, } } // Step 3: Create ID mapping const idMapping = new Map() - + if (generateNewIds) { - blocks.forEach(block => { + blocks.forEach((block) => { const newId = uuidv4() idMapping.set(block.id, newId) }) } else { // Use existing IDs - blocks.forEach(block => { + blocks.forEach((block) => { idMapping.set(block.id, block.id) }) } // Step 4: Build WorkflowState with proper block configuration const workflowBlocks: Record = {} - + // First pass: Update all parentIds in imported blocks before creating BlockStates - blocks.forEach(importedBlock => { + blocks.forEach((importedBlock) => { if (importedBlock.parentId) { const mappedParentId = idMapping.get(importedBlock.parentId) if (mappedParentId) { - logger.info(`Updating parentId for block ${importedBlock.id}: ${importedBlock.parentId} -> ${mappedParentId}`) + logger.info( + `Updating parentId for block ${importedBlock.id}: ${importedBlock.parentId} -> ${mappedParentId}` + ) importedBlock.parentId = mappedParentId } else { - logger.warn(`Parent ID ${importedBlock.parentId} not found in ID mapping for block ${importedBlock.id}`) + logger.warn( + `Parent ID ${importedBlock.parentId} not found in ID mapping for block ${importedBlock.id}` + ) } } }) - + // Second pass: Create the blocks for (const importedBlock of blocks) { const blockId = idMapping.get(importedBlock.id)! - + // Handle special blocks (loop/parallel) if (importedBlock.type === 'loop' || importedBlock.type === 'parallel') { workflowBlocks[blockId] = createContainerBlock(blockId, importedBlock) @@ -143,13 +144,13 @@ export async function convertYamlToWorkflowState( updateBlockReferences(workflowBlocks, idMapping) // Step 6: Create edges with mapped IDs - const workflowEdges = edges.map(edge => ({ + const workflowEdges = edges.map((edge) => ({ id: uuidv4(), source: idMapping.get(edge.source) || edge.source, target: idMapping.get(edge.target) || edge.target, sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, - type: edge.type || 'default' + type: edge.type || 'default', })) // Step 7: Generate loops and parallels @@ -158,14 +159,14 @@ export async function convertYamlToWorkflowState( // Debug: Log parent-child relationships logger.info('=== Parent-Child Relationships ===') - Object.values(workflowBlocks).forEach(block => { + Object.values(workflowBlocks).forEach((block) => { const parentNode = (block as any).parentNode const parentId = block.data?.parentId if (parentNode || parentId) { logger.info(`Block ${block.id} (${block.name}):`, { parentNode, parentId, - parentExists: parentNode ? !!workflowBlocks[parentNode] : 'N/A' + parentExists: parentNode ? !!workflowBlocks[parentNode] : 'N/A', }) } }) @@ -176,7 +177,7 @@ export async function convertYamlToWorkflowState( edges: workflowEdges, loops, parallels, - lastSaved: Date.now() + lastSaved: Date.now(), } return { @@ -184,7 +185,7 @@ export async function convertYamlToWorkflowState( workflowState, errors: [], warnings, - idMapping + idMapping, } } @@ -199,13 +200,13 @@ export function convertWorkflowStateToYaml( const yaml = generateWorkflowYaml(workflowState, subBlockValues) return { success: true, - yaml + yaml, } } catch (error) { logger.error('Failed to generate YAML:', error) return { success: false, - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', } } } @@ -213,10 +214,7 @@ export function convertWorkflowStateToYaml( /** * Create a container block (loop/parallel) */ -function createContainerBlock( - blockId: string, - importedBlock: ImportedBlock -): BlockState { +function createContainerBlock(blockId: string, importedBlock: ImportedBlock): BlockState { const block: BlockState = { id: blockId, type: importedBlock.type, @@ -234,18 +232,18 @@ function createContainerBlock( width: importedBlock.data?.width || 500, height: importedBlock.data?.height || 300, type: importedBlock.type === 'loop' ? 'loopNode' : 'parallelNode', - ...(importedBlock.parentId && { + ...(importedBlock.parentId && { parentId: importedBlock.parentId, - extent: importedBlock.extent - }) - } + extent: importedBlock.extent, + }), + }, } - + // Add parentNode for ReactFlow if this block is inside another container if (importedBlock.parentId) { - (block as any).parentNode = importedBlock.parentId + ;(block as any).parentNode = importedBlock.parentId } - + return block } @@ -259,15 +257,15 @@ function createRegularBlock( ): BlockState { // Initialize subBlocks from block configuration const subBlocks: Record = {} - + blockConfig.subBlocks.forEach((subBlock: any) => { const subBlockId = subBlock.id const yamlValue = importedBlock.inputs[subBlockId] - + subBlocks[subBlockId] = { id: subBlockId, type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null + value: yamlValue !== undefined ? yamlValue : null, } }) @@ -277,7 +275,7 @@ function createRegularBlock( subBlocks[inputKey] = { id: inputKey, type: 'short-input', - value: importedBlock.inputs[inputKey] + value: importedBlock.inputs[inputKey], } } }) @@ -297,16 +295,16 @@ function createRegularBlock( height: 0, data: { ...importedBlock.data, - ...(importedBlock.parentId && { + ...(importedBlock.parentId && { parentId: importedBlock.parentId, - extent: importedBlock.extent - }) - } + extent: importedBlock.extent, + }), + }, } // Add parentNode for ReactFlow if this block is inside a loop/parallel if (importedBlock.parentId) { - (block as any).parentNode = importedBlock.parentId + ;(block as any).parentNode = importedBlock.parentId } return block @@ -319,8 +317,8 @@ function updateBlockReferences( blocks: Record, idMapping: Map ): void { - Object.values(blocks).forEach(block => { - Object.values(block.subBlocks).forEach(subBlock => { + Object.values(blocks).forEach((block) => { + Object.values(block.subBlocks).forEach((subBlock) => { if (subBlock.value !== null && subBlock.value !== undefined) { subBlock.value = updateValueReferences(subBlock.value, idMapping) } @@ -387,65 +385,63 @@ function updateValueReferences(value: any, idMapping: Map): any export async function applyAutoLayoutToBlocks( blocks: Record, edges: any[] -): Promise<{ +): Promise<{ success: boolean layoutedBlocks?: Record - error?: string + error?: string }> { logger.info('=== applyAutoLayoutToBlocks called ===', { blockCount: Object.keys(blocks).length, - edgeCount: edges.length + edgeCount: edges.length, }) - + try { // Try to import from the actual auto-layout location logger.info('Attempting to import auto-layout module...') - const autoLayoutModule = await import('@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout') - + const autoLayoutModule = await import( + '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout' + ) + if (autoLayoutModule.applyAutoLayoutToBlocks) { logger.info('Using auto-layout module function') // Use the existing auto-layout function return await autoLayoutModule.applyAutoLayoutToBlocks(blocks, edges) } - + // Fallback to autolayout service logger.info('Falling back to autolayout service') const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - + logger.info('Calling autoLayoutWorkflow with options') - const layoutedBlocks = await autoLayoutWorkflow( - blocks, - edges, - { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, - vertical: 400, - layer: 700, - }, - alignment: 'center', - padding: { - x: 250, - y: 250, - }, - } - ) - + const layoutedBlocks = await autoLayoutWorkflow(blocks, edges, { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700, + }, + alignment: 'center', + padding: { + x: 250, + y: 250, + }, + }) + logger.info('autoLayoutWorkflow returned:', { hasLayoutedBlocks: !!layoutedBlocks, - layoutedBlockCount: layoutedBlocks ? Object.keys(layoutedBlocks).length : 0 + layoutedBlockCount: layoutedBlocks ? Object.keys(layoutedBlocks).length : 0, }) - + return { success: true, - layoutedBlocks + layoutedBlocks, } } catch (error) { logger.error('Auto layout failed:', error) return { success: false, - error: error instanceof Error ? error.message : 'Auto layout failed' + error: error instanceof Error ? error.message : 'Auto layout failed', } } -} \ No newline at end of file +} diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 941bb148d6a..c8aa82654f3 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -36,20 +36,18 @@ function createReadableStreamFromAnthropicStream( * Helper to create a native SSE stream for copilot that passes through all Anthropic events * This preserves the native SSE format for better performance and simpler parsing */ -function createNativeSSEStreamForCopilot( - anthropicStream: AsyncIterable -): ReadableStream { +function createNativeSSEStreamForCopilot(anthropicStream: AsyncIterable): ReadableStream { return new ReadableStream({ async start(controller) { try { const encoder = new TextEncoder() - + for await (const event of anthropicStream) { // Pass through the raw Anthropic SSE event const sseData = `data: ${JSON.stringify(event)}\n\n` controller.enqueue(encoder.encode(sseData)) } - + controller.close() } catch (err) { controller.error(err) @@ -377,12 +375,12 @@ ${fieldDescriptions} const nativeSSEStream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder() - + // Track conversation state and tool calls const conversationMessages: any[] = [...(messages || [])] let pendingToolCalls: any[] = [] let currentToolCall: any = null - + const executeToolsAndContinue = async (toolCalls: any[]) => { try { logger.info(`Executing ${toolCalls.length} tool calls`, { @@ -411,16 +409,22 @@ ${fieldDescriptions} }, } : {}), - ...(request.environmentVariables ? { envVars: request.environmentVariables } : {}), + ...(request.environmentVariables + ? { envVars: request.environmentVariables } + : {}), } const result = await executeTool(toolCall.name, mergedArgs, true) const toolCallEndTime = Date.now() - + logger.info(`Tool ${toolCall.name} ${result.success ? 'succeeded' : 'failed'}`) // Send tool result event to frontend for preview_workflow and targeted_updates tools - if ((toolCall.name === 'preview_workflow' || toolCall.name === 'targeted_updates') && result.success) { + if ( + (toolCall.name === 'preview_workflow' || + toolCall.name === 'targeted_updates') && + result.success + ) { const toolResultEvent = { type: 'tool_result', toolCallId: toolCall.id, @@ -428,7 +432,9 @@ ${fieldDescriptions} result: result.output, success: true, } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolResultEvent)}\n\n`)) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(toolResultEvent)}\n\n`) + ) logger.info(`Sent ${toolCall.name} result to frontend:`, toolCall.id) } @@ -472,42 +478,56 @@ ${fieldDescriptions} // Stream the continuation response and handle any additional tool calls let continuationToolCalls: any[] = [] let currentContinuationToolCall: any = null - + for await (const chunk of nextStreamResponse as any) { const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` controller.enqueue(encoder.encode(sseEvent)) - + // Check if the continuation response has its own tool calls - if (chunk.type === 'content_block_start' && chunk.content_block?.type === 'tool_use') { + if ( + chunk.type === 'content_block_start' && + chunk.content_block?.type === 'tool_use' + ) { currentContinuationToolCall = { id: chunk.content_block.id, name: chunk.content_block.name, input: {}, partialInput: '', } - } else if (chunk.type === 'content_block_delta' && currentContinuationToolCall && chunk.delta?.partial_json) { + } else if ( + chunk.type === 'content_block_delta' && + currentContinuationToolCall && + chunk.delta?.partial_json + ) { currentContinuationToolCall.partialInput += chunk.delta.partial_json } else if (chunk.type === 'content_block_stop' && currentContinuationToolCall) { try { - currentContinuationToolCall.input = JSON.parse(currentContinuationToolCall.partialInput || '{}') + currentContinuationToolCall.input = JSON.parse( + currentContinuationToolCall.partialInput || '{}' + ) continuationToolCalls.push(currentContinuationToolCall) logger.info(`Continuation tool call ready: ${currentContinuationToolCall.name}`) } catch (error) { logger.error('Error parsing continuation tool call input:', error) } currentContinuationToolCall = null - } else if (chunk.type === 'message_stop' && continuationToolCalls.length > 0) { - // Recursively handle tool calls in the continuation - await executeToolsAndContinue(continuationToolCalls) - continuationToolCalls = [] - } - - // Also check for any preview_workflow or targeted_updates results in continuation - continuationToolCalls.forEach(toolCall => { - if (toolCall.name === 'preview_workflow' || toolCall.name === 'targeted_updates') { - logger.info(`Found ${toolCall.name} in continuation, will send result after execution`) - } - }) + } else if (chunk.type === 'message_stop' && continuationToolCalls.length > 0) { + // Recursively handle tool calls in the continuation + await executeToolsAndContinue(continuationToolCalls) + continuationToolCalls = [] + } + + // Also check for any preview_workflow or targeted_updates results in continuation + continuationToolCalls.forEach((toolCall) => { + if ( + toolCall.name === 'preview_workflow' || + toolCall.name === 'targeted_updates' + ) { + logger.info( + `Found ${toolCall.name} in continuation, will send result after execution` + ) + } + }) } } catch (error) { logger.error('Error executing tools and continuing conversation:', { error }) @@ -519,22 +539,29 @@ ${fieldDescriptions} controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)) } } - + try { for await (const chunk of streamResponse) { // Pass through the SSE event const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` controller.enqueue(encoder.encode(sseEvent)) - + // Track tool calls for execution - if (chunk.type === 'content_block_start' && chunk.content_block?.type === 'tool_use') { + if ( + chunk.type === 'content_block_start' && + chunk.content_block?.type === 'tool_use' + ) { currentToolCall = { id: chunk.content_block.id, name: chunk.content_block.name, input: {}, partialInput: '', } - } else if (chunk.type === 'content_block_delta' && currentToolCall && chunk.delta?.partial_json) { + } else if ( + chunk.type === 'content_block_delta' && + currentToolCall && + chunk.delta?.partial_json + ) { currentToolCall.partialInput += chunk.delta.partial_json } else if (chunk.type === 'content_block_stop' && currentToolCall) { try { diff --git a/apps/sim/stores/copilot/preview-store.ts b/apps/sim/stores/copilot/preview-store.ts index 5c7f67a8d4b..2ed7c5e2d81 100644 --- a/apps/sim/stores/copilot/preview-store.ts +++ b/apps/sim/stores/copilot/preview-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import type { CopilotToolCall, CopilotMessage } from './types' +import type { CopilotMessage, CopilotToolCall } from './types' export interface PreviewData { id: string @@ -65,7 +65,7 @@ export const usePreviewStore = create()( if (!existingPreview) { return state } - + return { previews: { ...state.previews, @@ -84,7 +84,7 @@ export const usePreviewStore = create()( if (!existingPreview) { return state } - + return { previews: { ...state.previews, @@ -142,7 +142,9 @@ export const usePreviewStore = create()( clearPreviewsForWorkflow: (workflowId) => { set((state) => ({ previews: Object.fromEntries( - Object.entries(state.previews).filter(([_, preview]) => preview.workflowId !== workflowId) + Object.entries(state.previews).filter( + ([_, preview]) => preview.workflowId !== workflowId + ) ), })) }, @@ -177,14 +179,16 @@ export const usePreviewStore = create()( set((state) => ({ previews: Object.fromEntries( - Object.entries(state.previews).filter(([_, preview]) => now - preview.timestamp <= maxAge) + Object.entries(state.previews).filter( + ([_, preview]) => now - preview.timestamp <= maxAge + ) ), })) }, markToolCallAsSeen: (toolCallId) => { set((state) => ({ - seenToolCallIds: new Set([...state.seenToolCallIds, toolCallId]) + seenToolCallIds: new Set([...state.seenToolCallIds, toolCallId]), })) }, @@ -194,11 +198,15 @@ export const usePreviewStore = create()( scanAndMarkExistingPreviews: (messages: CopilotMessage[]) => { const toolCallIds = new Set() - + messages.forEach((message) => { if (message.role === 'assistant' && message.toolCalls) { message.toolCalls.forEach((toolCall: CopilotToolCall) => { - if (toolCall.name === 'preview_workflow' && toolCall.state === 'completed' && toolCall.id) { + if ( + toolCall.name === 'preview_workflow' && + toolCall.state === 'completed' && + toolCall.id + ) { toolCallIds.add(toolCall.id) } }) @@ -206,7 +214,7 @@ export const usePreviewStore = create()( }) set((state) => ({ - seenToolCallIds: new Set([...state.seenToolCallIds, ...toolCallIds]) + seenToolCallIds: new Set([...state.seenToolCallIds, ...toolCallIds]), })) }, }), @@ -227,4 +235,4 @@ export const usePreviewStore = create()( }), } ) -) \ No newline at end of file +) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 94f53b865a7..4c1c8d8c4a0 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -114,7 +114,7 @@ function getToolDisplayName(toolName: string): string { case 'targeted_updates': return 'Editing workflow' default: - return toolName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) + return toolName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) } } @@ -144,18 +144,18 @@ export const useCopilotStore = create()( // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') const diffStore = useWorkflowDiffStore.getState() - + // Check if there are any pending diff changes if (diffStore.diffWorkflow && diffStore.isDiffReady) { logger.info('Auto-rejecting pending diff changes before workflow change') - + // Reject the changes in the diff store diffStore.rejectChanges() - + // Update copilot tool call state and clear preview YAML get().updatePreviewToolCallState('rejected') await get().clearPreviewYaml() - + logger.info('Successfully auto-rejected pending diff changes') } } catch (error) { @@ -269,24 +269,24 @@ export const useCopilotStore = create()( // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') const diffStore = useWorkflowDiffStore.getState() - + logger.info('Diff store state:', { hasDiffWorkflow: !!diffStore.diffWorkflow, isDiffReady: diffStore.isDiffReady, - isShowingDiff: diffStore.isShowingDiff + isShowingDiff: diffStore.isShowingDiff, }) - + // Check if there are any pending diff changes if (diffStore.diffWorkflow && diffStore.isDiffReady) { logger.info('Auto-rejecting pending diff changes before chat change') - + // Reject the changes in the diff store diffStore.rejectChanges() - + // Update copilot tool call state and clear preview YAML get().updatePreviewToolCallState('rejected') await get().clearPreviewYaml() - + logger.info('Successfully auto-rejected pending diff changes') } else { logger.info('No pending diff changes to reject') @@ -346,24 +346,24 @@ export const useCopilotStore = create()( // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') const diffStore = useWorkflowDiffStore.getState() - + logger.info('Diff store state:', { hasDiffWorkflow: !!diffStore.diffWorkflow, isDiffReady: diffStore.isDiffReady, - isShowingDiff: diffStore.isShowingDiff + isShowingDiff: diffStore.isShowingDiff, }) - + // Check if there are any pending diff changes if (diffStore.diffWorkflow && diffStore.isDiffReady) { logger.info('Auto-rejecting pending diff changes before creating new chat') - + // Reject the changes in the diff store diffStore.rejectChanges() - + // Update copilot tool call state and clear preview YAML get().updatePreviewToolCallState('rejected') await get().clearPreviewYaml() - + logger.info('Successfully auto-rejected pending diff changes') } else { logger.info('No pending diff changes to reject') @@ -507,31 +507,46 @@ export const useCopilotStore = create()( const { messages } = get() // Find the last message with a preview_workflow or targeted_updates tool call - const lastMessageWithPreview = [...messages].reverse().find(msg => - msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow' || tc.name === 'targeted_updates') - ) + const lastMessageWithPreview = [...messages] + .reverse() + .find( + (msg) => + msg.role === 'assistant' && + msg.toolCalls?.some( + (tc) => tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + ) + ) if (lastMessageWithPreview) { set((state) => ({ messages: state.messages.map((msg) => - msg.id === lastMessageWithPreview.id ? { - ...msg, - toolCalls: msg.toolCalls?.map(tc => - (tc.name === 'preview_workflow' || tc.name === 'targeted_updates') ? { ...tc, state: toolCallState } : tc - ), - contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && (block.toolCall.name === 'preview_workflow' || block.toolCall.name === 'targeted_updates') - ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } - : block - ) - } : msg + msg.id === lastMessageWithPreview.id + ? { + ...msg, + toolCalls: msg.toolCalls?.map((tc) => + tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + ? { ...tc, state: toolCallState } + : tc + ), + contentBlocks: msg.contentBlocks?.map((block) => + block.type === 'tool_call' && + (block.toolCall.name === 'preview_workflow' || + block.toolCall.name === 'targeted_updates') + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ), + } + : msg ), })) } }, // Send implicit feedback and update preview tool call state - sendImplicitFeedback: async (implicitFeedback: string, toolCallState?: 'applied' | 'rejected') => { + sendImplicitFeedback: async ( + implicitFeedback: string, + toolCallState?: 'applied' | 'rejected' + ) => { const { workflowId, currentChat, mode, messages } = get() if (!workflowId) { @@ -544,24 +559,36 @@ export const useCopilotStore = create()( // Update the preview_workflow or targeted_updates tool call state if provided if (toolCallState) { // Find the last message with a preview_workflow or targeted_updates tool call - const lastMessageWithPreview = [...messages].reverse().find(msg => - msg.role === 'assistant' && msg.toolCalls?.some(tc => tc.name === 'preview_workflow' || tc.name === 'targeted_updates') - ) + const lastMessageWithPreview = [...messages] + .reverse() + .find( + (msg) => + msg.role === 'assistant' && + msg.toolCalls?.some( + (tc) => tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + ) + ) if (lastMessageWithPreview) { set((state) => ({ messages: state.messages.map((msg) => - msg.id === lastMessageWithPreview.id ? { - ...msg, - toolCalls: msg.toolCalls?.map(tc => - (tc.name === 'preview_workflow' || tc.name === 'targeted_updates') ? { ...tc, state: toolCallState } : tc - ), - contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && (block.toolCall.name === 'preview_workflow' || block.toolCall.name === 'targeted_updates') - ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } - : block - ) - } : msg + msg.id === lastMessageWithPreview.id + ? { + ...msg, + toolCalls: msg.toolCalls?.map((tc) => + tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + ? { ...tc, state: toolCallState } + : tc + ), + contentBlocks: msg.contentBlocks?.map((block) => + block.type === 'tool_call' && + (block.toolCall.name === 'preview_workflow' || + block.toolCall.name === 'targeted_updates') + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ), + } + : msg ), })) } @@ -658,18 +685,22 @@ export const useCopilotStore = create()( }, // Handle streaming response - handleStreamingResponse: async (stream: ReadableStream, messageId: string, isContinuation = false) => { + handleStreamingResponse: async ( + stream: ReadableStream, + messageId: string, + isContinuation = false + ) => { const reader = stream.getReader() const decoder = new TextDecoder() - + // If this is a continuation, start with the existing message content let accumulatedContent = '' if (isContinuation) { const { messages } = get() - const existingMessage = messages.find(msg => msg.id === messageId) + const existingMessage = messages.find((msg) => msg.id === messageId) accumulatedContent = existingMessage?.content || '' } - + let newChatId: string | undefined let streamComplete = false @@ -677,7 +708,7 @@ export const useCopilotStore = create()( let currentBlockType: 'text' | 'tool_use' | null = null let toolCallBuffer: any = null const toolCalls: any[] = [] - + // Track content blocks chronologically const contentBlocks: any[] = [] let currentTextBlock: any = null @@ -709,7 +740,7 @@ export const useCopilotStore = create()( if (data.type === 'chat_id') { newChatId = data.chatId logger.info('Received chatId from stream:', newChatId) - + // Update current chat if we don't have one const { currentChat } = get() if (!currentChat && newChatId) { @@ -719,26 +750,37 @@ export const useCopilotStore = create()( // Handle tool result events (our custom event for preview_workflow) else if (data.type === 'tool_result') { const { toolCallId, result, success } = data - logger.info('Received tool_result event', { toolCallId, success, hasResult: !!result }) + logger.info('Received tool_result event', { + toolCallId, + success, + hasResult: !!result, + }) if (toolCallId) { // Find the corresponding tool call and update its result - const existingToolCall = toolCalls.find(tc => tc.id === toolCallId) + const existingToolCall = toolCalls.find((tc) => tc.id === toolCallId) if (existingToolCall) { - logger.info('Found existing tool call for result', { name: existingToolCall.name, toolCallId }) + logger.info('Found existing tool call for result', { + name: existingToolCall.name, + toolCallId, + }) if (success) { existingToolCall.result = result - logger.info('Updated tool call result:', toolCallId, existingToolCall.name) - + logger.info( + 'Updated tool call result:', + toolCallId, + existingToolCall.name + ) + // Handle successful preview_workflow tool result if (existingToolCall.name === 'preview_workflow' && result?.yamlContent) { logger.info('Setting preview YAML from tool_result event', { yamlLength: result.yamlContent.length, - yamlPreview: result.yamlContent.substring(0, 100) + yamlPreview: result.yamlContent.substring(0, 100), }) get().setPreviewYaml(result.yamlContent) get().updateDiffStore(result.yamlContent) } - + // Handle successful targeted_updates tool result if (existingToolCall.name === 'targeted_updates') { logger.info('Targeted updates tool_result received', { @@ -747,26 +789,29 @@ export const useCopilotStore = create()( resultKeys: result ? Object.keys(result) : [], hasYamlContent: !!result?.yamlContent, // Log the full result structure for debugging - fullResult: JSON.stringify(result, null, 2) + fullResult: JSON.stringify(result, null, 2), }) - + // The targeted_updates tool returns yamlContent directly in the result if (result?.yamlContent) { - logger.info('Setting preview YAML from targeted_updates tool_result event', { - yamlLength: result.yamlContent.length, - yamlPreview: result.yamlContent.substring(0, 200), - // Log the full YAML for debugging - fullYaml: result.yamlContent - }) + logger.info( + 'Setting preview YAML from targeted_updates tool_result event', + { + yamlLength: result.yamlContent.length, + yamlPreview: result.yamlContent.substring(0, 200), + // Log the full YAML for debugging + fullYaml: result.yamlContent, + } + ) get().setPreviewYaml(result.yamlContent) get().updateDiffStore(result.yamlContent) - + // Set the tool call state to ready_for_review like preview_workflow existingToolCall.state = 'ready_for_review' } else { logger.error('Targeted updates tool_result missing yamlContent', { expectedPath: 'result.yamlContent', - actualStructure: JSON.stringify(result, null, 2) + actualStructure: JSON.stringify(result, null, 2), }) // Set to error state if yamlContent is missing existingToolCall.state = 'error' @@ -777,11 +822,18 @@ export const useCopilotStore = create()( // Tool execution failed existingToolCall.state = 'error' existingToolCall.error = result || 'Tool execution failed' - logger.error('Tool call failed:', toolCallId, existingToolCall.name, result) - + logger.error( + 'Tool call failed:', + toolCallId, + existingToolCall.name, + result + ) + // If this is a preview_workflow tool that failed, send error back to agent if (existingToolCall.name === 'preview_workflow') { - logger.info('Preview workflow tool execution failed, sending error back to agent for retry') + logger.info( + 'Preview workflow tool execution failed, sending error back to agent for retry' + ) // Send the error back to the agent after a brief delay to let the UI update setTimeout(() => { get().sendImplicitFeedback( @@ -790,20 +842,22 @@ export const useCopilotStore = create()( }, 1000) } } - + // Update message with the result and content blocks set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: msg.contentBlocks?.map(block => - block.type === 'tool_call' && block.toolCall.id === toolCallId - ? { ...block, toolCall: { ...existingToolCall } } - : block - ) - } : msg + msg.id === messageId + ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: msg.contentBlocks?.map((block) => + block.type === 'tool_call' && block.toolCall.id === toolCallId + ? { ...block, toolCall: { ...existingToolCall } } + : block + ), + } + : msg ), })) } @@ -814,7 +868,7 @@ export const useCopilotStore = create()( logger.info('Message started') } else if (data.type === 'content_block_start') { currentBlockType = data.content_block?.type - + if (currentBlockType === 'text') { // Start a new text block currentTextBlock = { @@ -834,7 +888,7 @@ export const useCopilotStore = create()( startTime: Date.now(), } toolCalls.push(toolCallBuffer) - + // Add tool call to content blocks const toolCallBlock = { type: 'tool_call', @@ -842,26 +896,34 @@ export const useCopilotStore = create()( timestamp: Date.now(), } contentBlocks.push(toolCallBlock) - + logger.info(`Starting tool call: ${data.content_block.name}`) - + // Update message with content blocks set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks] - } : msg + msg.id === messageId + ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks], + } + : msg ), })) } } else if (data.type === 'content_block_delta') { if (currentBlockType === 'text' && data.delta?.text) { // Add text content to accumulated content - if (isContinuation && accumulatedContent && !accumulatedContent.endsWith(' ') && data.delta.text && !data.delta.text.startsWith(' ')) { - accumulatedContent += ' ' + data.delta.text + if ( + isContinuation && + accumulatedContent && + !accumulatedContent.endsWith(' ') && + data.delta.text && + !data.delta.text.startsWith(' ') + ) { + accumulatedContent += ` ${data.delta.text}` } else { accumulatedContent += data.delta.text } @@ -869,13 +931,14 @@ export const useCopilotStore = create()( // Add text to current text block if (currentTextBlock) { currentTextBlock.content += data.delta.text - + // Update the content blocks array with the streaming text block const updatedContentBlocks = [...contentBlocks] - const existingBlockIndex = updatedContentBlocks.findIndex(block => - block.type === 'text' && block.timestamp === currentTextBlock.timestamp + const existingBlockIndex = updatedContentBlocks.findIndex( + (block) => + block.type === 'text' && block.timestamp === currentTextBlock.timestamp ) - + if (existingBlockIndex >= 0) { // Update existing block updatedContentBlocks[existingBlockIndex] = { ...currentTextBlock } @@ -883,7 +946,7 @@ export const useCopilotStore = create()( // Add new text block to content blocks for real-time display updatedContentBlocks.push({ ...currentTextBlock }) } - + // Replace contentBlocks array contents contentBlocks.splice(0, contentBlocks.length, ...updatedContentBlocks) } @@ -891,15 +954,21 @@ export const useCopilotStore = create()( // Update message in real-time set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks] - } : msg + msg.id === messageId + ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks], + } + : msg ), })) - } else if (currentBlockType === 'tool_use' && data.delta?.partial_json && toolCallBuffer) { + } else if ( + currentBlockType === 'tool_use' && + data.delta?.partial_json && + toolCallBuffer + ) { // Buffer partial JSON for tool calls (silently) toolCallBuffer.partialInput += data.delta.partial_json } @@ -912,45 +981,63 @@ export const useCopilotStore = create()( // Parse complete tool call input toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') // Set preview_workflow and targeted_updates tools to ready_for_review, others to completed - toolCallBuffer.state = (toolCallBuffer.name === 'preview_workflow' || toolCallBuffer.name === 'targeted_updates') ? 'ready_for_review' : 'completed' + toolCallBuffer.state = + toolCallBuffer.name === 'preview_workflow' || + toolCallBuffer.name === 'targeted_updates' + ? 'ready_for_review' + : 'completed' toolCallBuffer.endTime = Date.now() toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime - logger.info(`Tool call completed: ${toolCallBuffer.name}`, toolCallBuffer.input) - + logger.info( + `Tool call completed: ${toolCallBuffer.name}`, + toolCallBuffer.input + ) + // Update message with completed tool call and content blocks set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: contentBlocks.map(block => - block.type === 'tool_call' && block.toolCall.id === toolCallBuffer.id - ? { ...block, toolCall: { ...toolCallBuffer } } - : block - ) - } : msg + msg.id === messageId + ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: contentBlocks.map((block) => + block.type === 'tool_call' && + block.toolCall.id === toolCallBuffer.id + ? { ...block, toolCall: { ...toolCallBuffer } } + : block + ), + } + : msg ), })) - + // If this is a preview_workflow tool call, set the preview YAML and diff store if (toolCallBuffer.name === 'preview_workflow') { - logger.info('Preview workflow tool completed with input:', toolCallBuffer.input) - + logger.info( + 'Preview workflow tool completed with input:', + toolCallBuffer.input + ) + if (toolCallBuffer.input?.yamlContent) { - logger.info('Setting preview YAML from completed preview_workflow tool call', { - yamlLength: toolCallBuffer.input.yamlContent.length, - yamlPreview: toolCallBuffer.input.yamlContent.substring(0, 100) - }) + logger.info( + 'Setting preview YAML from completed preview_workflow tool call', + { + yamlLength: toolCallBuffer.input.yamlContent.length, + yamlPreview: toolCallBuffer.input.yamlContent.substring(0, 100), + } + ) get().setPreviewYaml(toolCallBuffer.input.yamlContent) - + // Also update the diff store with the proposed workflow state get().updateDiffStore(toolCallBuffer.input.yamlContent) } else { - logger.warn('Preview workflow tool completed but no yamlContent found in input') + logger.warn( + 'Preview workflow tool completed but no yamlContent found in input' + ) } } - + // Don't handle targeted_updates here - it needs to wait for the tool_result event // The result isn't available yet at content_block_stop, only the input } catch (error) { @@ -958,11 +1045,14 @@ export const useCopilotStore = create()( toolCallBuffer.state = 'error' toolCallBuffer.endTime = Date.now() toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime - toolCallBuffer.error = error instanceof Error ? error.message : String(error) - + toolCallBuffer.error = + error instanceof Error ? error.message : String(error) + // If this is a preview_workflow tool that failed, send error back to agent if (toolCallBuffer.name === 'preview_workflow') { - logger.info('Preview workflow tool failed, sending error back to agent for retry') + logger.info( + 'Preview workflow tool failed, sending error back to agent for retry' + ) // Send the error back to the agent after a brief delay to let the UI update setTimeout(() => { get().sendImplicitFeedback( @@ -977,13 +1067,15 @@ export const useCopilotStore = create()( } else if (data.type === 'message_delta') { // Handle token usage updates silently if (data.delta?.stop_reason === 'tool_use') { - logger.info('Message stopped for tool use - backend will handle execution and continue') + logger.info( + 'Message stopped for tool use - backend will handle execution and continue' + ) } } else if (data.type === 'message_stop') { // Backend will continue streaming if there are tools to execute // Don't break the loop - just continue listening for more events logger.info('Message stopped - backend may continue after tool execution') - + // Reset block state for potential continuation currentBlockType = null toolCallBuffer = null @@ -1005,18 +1097,20 @@ export const useCopilotStore = create()( // Stream ended naturally - finalize the message logger.info(`Completed streaming response, content length: ${accumulatedContent.length}`) - + // Text blocks are already in contentBlocks from streaming, no need to add again // Final update when stream actually ends set((state) => ({ messages: state.messages.map((msg) => - msg.id === messageId ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks] - } : msg + msg.id === messageId + ? { + ...msg, + content: accumulatedContent, + toolCalls: [...toolCalls], + contentBlocks: [...contentBlocks], + } + : msg ), isSendingMessage: false, })) @@ -1024,10 +1118,13 @@ export const useCopilotStore = create()( // Auto-save messages after streaming completes const { currentChat } = get() const chatIdToSave = currentChat?.id || newChatId - + if (chatIdToSave) { try { - logger.info('Auto-saving chat messages after streaming completion to chat:', chatIdToSave) + logger.info( + 'Auto-saving chat messages after streaming completion to chat:', + chatIdToSave + ) await get().saveChatMessages(chatIdToSave) } catch (error) { logger.error('Failed to auto-save chat messages:', error) @@ -1192,10 +1289,12 @@ export const useCopilotStore = create()( try { // Update local state immediately set((state) => ({ - currentChat: state.currentChat ? { - ...state.currentChat, - previewYaml: yamlContent - } : null + currentChat: state.currentChat + ? { + ...state.currentChat, + previewYaml: yamlContent, + } + : null, })) // Update database @@ -1217,10 +1316,12 @@ export const useCopilotStore = create()( logger.error('Failed to set preview YAML:', error) // Revert local state on error set((state) => ({ - currentChat: state.currentChat ? { - ...state.currentChat, - previewYaml: null - } : null + currentChat: state.currentChat + ? { + ...state.currentChat, + previewYaml: null, + } + : null, })) } }, @@ -1236,10 +1337,12 @@ export const useCopilotStore = create()( try { // Update local state immediately set((state) => ({ - currentChat: state.currentChat ? { - ...state.currentChat, - previewYaml: null - } : null + currentChat: state.currentChat + ? { + ...state.currentChat, + previewYaml: null, + } + : null, })) // Update database @@ -1292,7 +1395,7 @@ export const useCopilotStore = create()( try { // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') - + logger.info('Updating diff store with copilot YAML') // Generate diff analysis by comparing current vs proposed YAML @@ -1301,7 +1404,7 @@ export const useCopilotStore = create()( // Get current workflow as YAML for comparison const { useWorkflowYamlStore } = await import('@/stores/workflows/yaml/store') const currentYaml = useWorkflowYamlStore.getState().getYaml() - + // Call the diff API to compare current vs proposed YAML const diffResponse = await fetch('/api/workflows/diff', { method: 'POST', @@ -1336,12 +1439,11 @@ export const useCopilotStore = create()( await diffStore.setProposedChanges(yamlContent, diffAnalysis) logger.info('Successfully updated diff store with proposed workflow changes') - } catch (error) { logger.error('Failed to update diff store:', error) // Show error to user console.error('[Copilot] Error updating diff store:', error) - + // Try to show at least the preview YAML even if diff fails const { currentChat } = get() if (currentChat?.previewYaml) { diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 3f8aa45a77f..29215e6e05d 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -156,7 +156,10 @@ export interface CopilotActions { // Message handling sendMessage: (message: string, options?: SendMessageOptions) => Promise - sendImplicitFeedback: (implicitFeedback: string, toolCallState?: 'applied' | 'rejected') => Promise + sendImplicitFeedback: ( + implicitFeedback: string, + toolCallState?: 'applied' | 'rejected' + ) => Promise updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => void sendDocsMessage: (query: string, options?: SendDocsMessageOptions) => Promise saveChatMessages: (chatId: string) => Promise @@ -178,7 +181,11 @@ export interface CopilotActions { reset: () => void // Internal helpers (not exposed publicly) - handleStreamingResponse: (stream: ReadableStream, messageId: string, isContinuation?: boolean) => Promise + handleStreamingResponse: ( + stream: ReadableStream, + messageId: string, + isContinuation?: boolean + ) => Promise handleNewChatCreation: (newChatId: string) => Promise updateDiffStore: (yamlContent: string) => Promise } diff --git a/apps/sim/stores/workflow-diff/index.ts b/apps/sim/stores/workflow-diff/index.ts index 92a2c84007d..c9b345c4666 100644 --- a/apps/sim/stores/workflow-diff/index.ts +++ b/apps/sim/stores/workflow-diff/index.ts @@ -1 +1 @@ -export { useWorkflowDiffStore } from './store' \ No newline at end of file +export { useWorkflowDiffStore } from './store' diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 0c17fc8e725..2d06d14b7b6 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -1,10 +1,10 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console-logger' -import { WorkflowDiffEngine, type DiffAnalysis } from '@/lib/workflows/diff' -import { useWorkflowStore } from '../workflows/workflow/store' -import { useSubBlockStore } from '../workflows/subblock/store' +import { type DiffAnalysis, WorkflowDiffEngine } from '@/lib/workflows/diff' import { useWorkflowRegistry } from '../workflows/registry/store' +import { useSubBlockStore } from '../workflows/subblock/store' +import { useWorkflowStore } from '../workflows/workflow/store' import type { WorkflowState } from '../workflows/workflow/types' const logger = createLogger('WorkflowDiffStore') @@ -14,7 +14,7 @@ const diffEngine = new WorkflowDiffEngine() interface WorkflowDiffState { isShowingDiff: boolean - isDiffReady: boolean // New flag to track when diff is fully ready + isDiffReady: boolean // New flag to track when diff is fully ready diffWorkflow: WorkflowState | null diffAnalysis: DiffAnalysis | null diffMetadata: { @@ -40,40 +40,40 @@ export const useWorkflowDiffStore = create ({ isShowingDiff: false, - isDiffReady: false, // Initialize to false + isDiffReady: false, // Initialize to false diffWorkflow: null, diffAnalysis: null, diffMetadata: null, setProposedChanges: async (yamlContent: string, diffAnalysis?: DiffAnalysis) => { logger.info('Setting proposed changes via YAML') - + // First, set isDiffReady to false to prevent premature rendering set({ isDiffReady: false }) - + const result = await diffEngine.createDiffFromYaml(yamlContent, diffAnalysis) - + if (result.success && result.diff) { // Debug: Log the diff state being set const sampleBlockId = Object.keys(result.diff.proposedState.blocks)[0] const sampleBlock = sampleBlockId ? result.diff.proposedState.blocks[sampleBlockId] : null const sampleDiffStatus = sampleBlock ? (sampleBlock as any).is_diff : undefined - + console.log('[DiffStore] Setting new diff:', { blockCount: Object.keys(result.diff.proposedState.blocks).length, sampleBlockId, sampleDiffStatus, hasDiffAnalysis: !!result.diff.diffAnalysis, - timestamp: Date.now() + timestamp: Date.now(), }) - + // Set all state at once, with isDiffReady true to indicate everything is ready - set({ + set({ isShowingDiff: true, - isDiffReady: true, // Now it's safe to render + isDiffReady: true, // Now it's safe to render diffWorkflow: result.diff.proposedState, diffAnalysis: result.diff.diffAnalysis || null, - diffMetadata: result.diff.metadata + diffMetadata: result.diff.metadata, }) logger.info('Diff created successfully') } else { @@ -88,19 +88,19 @@ export const useWorkflowDiffStore = create { const { isShowingDiff, isDiffReady } = get() logger.info('Toggling diff view', { currentState: isShowingDiff, isDiffReady }) - + // Only toggle if diff is ready or we're turning off diff view if (!isShowingDiff || isDiffReady) { set({ isShowingDiff: !isShowingDiff }) @@ -111,14 +111,14 @@ export const useWorkflowDiffStore = create { const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - + if (!activeWorkflowId) { logger.error('No active workflow ID found when accepting diff') throw new Error('No active workflow found') } logger.info('Accepting proposed changes') - + try { const cleanState = diffEngine.acceptDiff() if (!cleanState) { @@ -133,34 +133,34 @@ export const useWorkflowDiffStore = create> = {} - + Object.entries(cleanState.blocks).forEach(([blockId, block]) => { subblockValues[blockId] = {} Object.entries(block.subBlocks || {}).forEach(([subblockId, subblock]) => { subblockValues[blockId][subblockId] = (subblock as any).value }) }) - + useSubBlockStore.setState((state) => ({ workflowValues: { ...state.workflowValues, [activeWorkflowId]: subblockValues, }, })) - + // Trigger save and history const workflowStore = useWorkflowStore.getState() workflowStore.updateLastSaved() - + logger.info('Successfully applied diff workflow to main store') - + // Persist to database try { logger.info('Persisting accepted diff changes to database') - + const response = await fetch(`/api/workflows/${activeWorkflowId}/state`, { method: 'PUT', headers: { @@ -183,16 +183,14 @@ export const useWorkflowDiffStore = create { const { isShowingDiff, isDiffReady } = get() - + // Only return diff workflow if both showing diff AND diff is ready if (isShowingDiff && isDiffReady && diffEngine.hasDiff()) { logger.debug('Returning diff workflow for canvas') const currentState = useWorkflowStore.getState().getWorkflowState() return diffEngine.getDisplayState(currentState) } - + // Return the actual workflow state using the main store's method return useWorkflowStore.getState().getWorkflowState() }, }), { name: 'workflow-diff-store' } ) -) \ No newline at end of file +) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index aabdecfa7d6..99de7197127 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -198,7 +198,7 @@ export const useWorkflowStore = create()( logger.warn(`Cannot update dimensions: Block ${id} not found in workflow store`) return state // Return unchanged state } - + return { blocks: { ...state.blocks, diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 5b3d32f3b00..675773e55dc 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -194,7 +194,7 @@ export interface WorkflowActions { // Add the sync control methods to the WorkflowActions interface sync: SyncControl - + // Add method to get current workflow state (eliminates duplication in diff store) getWorkflowState: () => WorkflowState } diff --git a/apps/sim/tools/blocks/preview-workflow.ts b/apps/sim/tools/blocks/preview-workflow.ts index fea0676e9e6..89a9b874de7 100644 --- a/apps/sim/tools/blocks/preview-workflow.ts +++ b/apps/sim/tools/blocks/preview-workflow.ts @@ -83,4 +83,4 @@ export const previewWorkflowTool: ToolConfig = { +export const getEnvironmentVariablesTool: ToolConfig< + GetEnvironmentVariablesParams, + GetEnvironmentVariablesResponse +> = { id: 'get_environment_variables', name: 'Get Environment Variables', description: @@ -33,4 +36,4 @@ export const getEnvironmentVariablesTool: ToolConfig = { +export const setEnvironmentVariablesTool: ToolConfig< + SetEnvironmentVariablesParams, + SetEnvironmentVariablesResponse +> = { id: 'set_environment_variables', name: 'Set Environment Variables', description: @@ -27,7 +30,8 @@ export const setEnvironmentVariablesTool: ToolConfig { return `Failed to set environment variables: ${error.message || 'Unknown error'}` }, -} \ No newline at end of file +} diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index 545b638015d..b16cf690905 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -2,19 +2,19 @@ import { createLogger } from '@/lib/logs/console-logger' import { getBaseUrl } from '@/lib/urls/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' -// import { editWorkflowTool } from '@/tools/blocks/edit-workflow' // Commented out - only preview is allowed -import { previewWorkflowTool } from '@/tools/blocks/preview-workflow' import { getAllBlocksTool } from '@/tools/blocks/get-all' import { getBlockMetadataTool } from '@/tools/blocks/get-metadata' import { getYamlStructureTool } from '@/tools/blocks/get-yaml-structure' +// import { editWorkflowTool } from '@/tools/blocks/edit-workflow' // Commented out - only preview is allowed +import { previewWorkflowTool } from '@/tools/blocks/preview-workflow' import { docsSearchTool } from '@/tools/docs/search' +import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' +import { setEnvironmentVariablesTool } from '@/tools/environment/set-variables' import { tools } from '@/tools/registry' import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' -import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' import { getWorkflowConsoleTool } from '@/tools/workflow/get-console' import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' -import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' -import { setEnvironmentVariablesTool } from '@/tools/environment/set-variables' +import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' import { targetedUpdatesTool } from '@/tools/workflow/targeted-updates' const logger = createLogger('ToolsUtils') diff --git a/apps/sim/tools/workflow/get-console.ts b/apps/sim/tools/workflow/get-console.ts index 821b309e818..18482a0e55a 100644 --- a/apps/sim/tools/workflow/get-console.ts +++ b/apps/sim/tools/workflow/get-console.ts @@ -57,7 +57,8 @@ export const getWorkflowConsoleTool: ToolConfig = { +export const getWorkflowExamplesTool: ToolConfig< + GetWorkflowExamplesParams, + GetWorkflowExamplesResponse +> = { id: 'get_workflow_examples', name: 'Getting relevant examples', description: 'Get YAML workflow examples by ID to reference when building workflows', @@ -61,4 +64,4 @@ export const getWorkflowExamplesTool: ToolConfig ({ operations: params.operations, - workflowId: params._context?.workflowId + workflowId: params._context?.workflowId, }), isInternalRoute: true, }, @@ -75,8 +75,8 @@ export const targetedUpdatesTool: ToolConfig Date: Fri, 25 Jul 2025 15:31:31 -0700 Subject: [PATCH 074/184] Minor fix --- apps/sim/lib/workflows/credential-resolver.ts | 218 ++++++++++++++++++ apps/sim/lib/workflows/diff/diff-engine.ts | 3 +- 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/workflows/credential-resolver.ts diff --git a/apps/sim/lib/workflows/credential-resolver.ts b/apps/sim/lib/workflows/credential-resolver.ts new file mode 100644 index 00000000000..05e3ec51dd8 --- /dev/null +++ b/apps/sim/lib/workflows/credential-resolver.ts @@ -0,0 +1,218 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { getBlock } from '@/blocks/index' +import type { SubBlockConfig } from '@/blocks/types' +import type { BlockState } from '@/stores/workflows/workflow/types' +import { getServiceIdFromScopes, getProviderIdFromServiceId } from '@/lib/oauth/oauth' + +const logger = createLogger('CredentialResolver') + +interface Credential { + id: string + isDefault: boolean + scopes?: string[] +} + +/** + * Resolves and auto-selects credentials for blocks before YAML generation + * This ensures that credential fields are populated with appropriate values + */ +export async function resolveCredentialsForWorkflow( + blocks: Record, + subBlockValues: Record>, + userId?: string +): Promise>> { + const resolvedValues = { ...subBlockValues } + + logger.info('Starting credential resolution for workflow', { + userId, + blockCount: Object.keys(blocks).length, + }) + + try { + // Process each block + for (const [blockId, blockState] of Object.entries(blocks)) { + const blockConfig = getBlock(blockState.type) + if (!blockConfig) { + logger.debug(`No config found for block type: ${blockState.type}`) + continue + } + + // Initialize block values if not present + if (!resolvedValues[blockId]) { + resolvedValues[blockId] = {} + } + + // Process each subBlock configuration + for (const subBlockConfig of blockConfig.subBlocks) { + // Only process oauth-input type subblocks (credential selectors) + if (subBlockConfig.type !== 'oauth-input') continue + + const subBlockId = subBlockConfig.id + const existingValue = resolvedValues[blockId][subBlockId] + + logger.debug(`Checking credential for ${blockId}.${subBlockId}`, { + blockType: blockState.type, + provider: subBlockConfig.provider, + hasExistingValue: !!existingValue, + existingValue, + }) + + // Skip if already has a valid value + if (existingValue && typeof existingValue === 'string' && existingValue.trim()) { + logger.debug(`Skipping - already has credential: ${existingValue}`) + continue + } + + // Resolve credential for this subblock + const credentialId = await resolveCredentialForSubBlock( + subBlockConfig, + blockState, + userId + ) + + if (credentialId) { + resolvedValues[blockId][subBlockId] = credentialId + logger.info(`Auto-selected credential for ${blockId}.${subBlockId}`, { + blockType: blockState.type, + provider: subBlockConfig.provider, + credentialId, + }) + } else { + logger.info(`No credential auto-selected for ${blockId}.${subBlockId}`, { + blockType: blockState.type, + provider: subBlockConfig.provider, + }) + } + } + } + + logger.info('Credential resolution completed', { + resolvedCount: Object.values(resolvedValues).reduce( + (count, blockValues) => count + Object.keys(blockValues).length, + 0 + ), + }) + + return resolvedValues + } catch (error) { + logger.error('Error resolving credentials for workflow:', error) + // Return original values on error + return subBlockValues + } +} + +/** + * Resolves a single credential for a subblock + */ +async function resolveCredentialForSubBlock( + subBlockConfig: SubBlockConfig & { provider?: string; requiredScopes?: string[]; serviceId?: string }, + blockState: BlockState, + userId?: string +): Promise { + try { + const provider = subBlockConfig.provider + const requiredScopes = subBlockConfig.requiredScopes || [] + const serviceId = subBlockConfig.serviceId + + logger.debug('Resolving credential for subblock', { + blockType: blockState.type, + provider, + serviceId, + requiredScopes, + userId, + }) + + if (!provider) { + logger.debug('No provider specified, skipping credential resolution') + return null + } + + // Derive service and provider IDs + const effectiveServiceId = serviceId || getServiceIdFromScopes(provider as any, requiredScopes) + const effectiveProviderId = getProviderIdFromServiceId(effectiveServiceId) + + logger.debug('Derived provider info', { + effectiveServiceId, + effectiveProviderId, + }) + + // Fetch credentials from the API + // Note: This assumes we're running in a server context with access to fetch + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const credentialsUrl = `${baseUrl}/api/auth/oauth/credentials?provider=${effectiveProviderId}` + + logger.debug('Fetching credentials', { url: credentialsUrl }) + + const response = await fetch(credentialsUrl, { + headers: userId ? { 'x-user-id': userId } : {}, + }) + + if (!response.ok) { + logger.error(`Failed to fetch credentials for provider ${effectiveProviderId}`, { + status: response.status, + statusText: response.statusText, + }) + return null + } + + const data = await response.json() + const credentials: Credential[] = data.credentials || [] + + logger.info(`Found ${credentials.length} credential(s) for provider ${effectiveProviderId}`, { + credentials: credentials.map(c => ({ + id: c.id, + isDefault: c.isDefault, + })), + }) + + if (credentials.length === 0) { + return null + } + + // Auto-selection logic (same as credential-selector component): + // 1. Look for default credential + // 2. If only one credential, select it + const defaultCred = credentials.find((cred) => cred.isDefault) + if (defaultCred) { + logger.info(`Selected default credential: ${defaultCred.id}`) + return defaultCred.id + } + + if (credentials.length === 1) { + logger.info(`Selected only credential: ${credentials[0].id}`) + return credentials[0].id + } + + // No clear selection, return null + logger.info('Multiple credentials available, none selected (user must choose)') + return null + } catch (error) { + logger.error('Error resolving credential for subblock:', error) + return null + } +} + +/** + * Checks if a workflow needs credential resolution + * Returns true if any block has credential-type subblocks without values + */ +export function needsCredentialResolution( + blocks: Record, + subBlockValues: Record> +): boolean { + for (const [blockId, blockState] of Object.entries(blocks)) { + const blockConfig = getBlock(blockState.type) + if (!blockConfig) continue + + for (const subBlockConfig of blockConfig.subBlocks) { + if (subBlockConfig.type !== 'oauth-input') continue + + const value = subBlockValues[blockId]?.[subBlockConfig.id] + if (!value || (typeof value === 'string' && !value.trim())) { + return true + } + } + } + + return false +} \ No newline at end of file diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 61b50f43500..7f3f7a27207 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -440,7 +440,8 @@ export class WorkflowDiffEngine { Object.entries(cleanState.blocks).forEach(([blockId, block]) => { if (block.type && block.name) { // Remove diff markers - ;(block as any).is_diff = undefined(block as any).field_diff = undefined + ;(block as any).is_diff = undefined + ;(block as any).field_diff = undefined filteredBlocks[blockId] = block } else { logger.info(`Filtering out block ${blockId} - missing type or name`) From 0932ff7f9c411a91160303d4241d432180ee30dd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 19:42:38 -0700 Subject: [PATCH 075/184] Add abort controller --- .../copilot-modal/copilot-modal.tsx | 6 + .../professional-input/professional-input.tsx | 66 +++++++--- .../panel/components/copilot/copilot.tsx | 6 + apps/sim/lib/copilot/api.ts | 44 ++++++- apps/sim/stores/copilot/store.ts | 121 +++++++++++++++++- apps/sim/stores/copilot/types.ts | 5 + 6 files changed, 222 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx index 036e7314691..abac66952c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx @@ -27,7 +27,9 @@ interface CopilotModalProps { setCopilotMessage: (message: string) => void messages: CopilotMessage[] onSendMessage: (message: string) => Promise + onAbortMessage?: () => void isLoading: boolean + isAborting?: boolean isLoadingChats: boolean // Chat management props chats: CopilotChat[] @@ -47,7 +49,9 @@ export function CopilotModal({ setCopilotMessage, messages, onSendMessage, + onAbortMessage, isLoading, + isAborting, isLoadingChats, chats, currentChat, @@ -306,8 +310,10 @@ export function CopilotModal({ await onSendMessage(message) setCopilotMessage('') }} + onAbort={onAbortMessage} disabled={false} isLoading={isLoading} + isAborting={isAborting} placeholder={ mode === 'ask' ? 'Ask me anything about your workflow...' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx index 5690a7c15e5..77e6bda05bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx @@ -1,23 +1,27 @@ 'use client' import { type FC, type KeyboardEvent, useRef, useState } from 'react' -import { ArrowUp, Loader2 } from 'lucide-react' +import { ArrowUp, Loader2, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' interface ProfessionalInputProps { onSubmit: (message: string) => void + onAbort?: () => void disabled?: boolean isLoading?: boolean + isAborting?: boolean placeholder?: string className?: string } const ProfessionalInput: FC = ({ onSubmit, + onAbort, disabled = false, isLoading = false, + isAborting = false, placeholder = 'How can I help you today?', className, }) => { @@ -37,6 +41,12 @@ const ProfessionalInput: FC = ({ } } + const handleAbort = () => { + if (onAbort && isLoading) { + onAbort() + } + } + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() @@ -55,6 +65,7 @@ const ProfessionalInput: FC = ({ } const canSubmit = message.trim().length > 0 && !disabled && !isLoading + const showAbortButton = isLoading && onAbort return (
    @@ -71,23 +82,42 @@ const ProfessionalInput: FC = ({ className='max-h-[120px] min-h-[50px] w-full max-w-full resize-none border-0 bg-transparent px-4 py-3 pr-12 text-sm placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' rows={1} /> - + {showAbortButton ? ( + + ) : ( + + )}
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 6a464f81fff..8652e33f746 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -70,6 +70,7 @@ export const Copilot = forwardRef( isLoading, isLoadingChats, isSendingMessage, + isAborting, error, workflowId, mode, @@ -79,6 +80,7 @@ export const Copilot = forwardRef( createNewChat, deleteChat, sendMessage, + abortMessage, clearMessages, clearError, setMode, @@ -410,8 +412,10 @@ export const Copilot = forwardRef( {/* Input area */} )} @@ -427,7 +431,9 @@ export const Copilot = forwardRef( setCopilotMessage={(message) => onFullscreenInputChange?.(message)} messages={messages} onSendMessage={handleModalSendMessage} + onAbortMessage={abortMessage} isLoading={isSendingMessage} + isAborting={isAborting} isLoadingChats={isLoadingChats} chats={chats} currentChat={currentChat} diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index a4964e4067f..29fdb8ff13a 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -61,6 +61,7 @@ export interface SendMessageRequest { createNewChat?: boolean stream?: boolean implicitFeedback?: string + abortSignal?: AbortSignal } /** @@ -75,6 +76,7 @@ export interface DocsQueryRequest { chatId?: string workflowId?: string createNewChat?: boolean + abortSignal?: AbortSignal } /** @@ -196,6 +198,16 @@ async function makeApiRequest( } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + // Handle AbortError gracefully - this is expected when user aborts + if (error instanceof Error && error.name === 'AbortError') { + logger.info(`API request was aborted: ${defaultErrorMessage}`) + return { + success: false, + error: 'Request was aborted', + } + } + logger.error(`API request failed: ${defaultErrorMessage}`, error) return { success: false, @@ -343,10 +355,12 @@ export async function sendStreamingMessage( request: SendMessageRequest ): Promise { try { + const { abortSignal, ...requestBody } = request const response = await fetch('/api/copilot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...request, stream: true }), + body: JSON.stringify({ ...requestBody, stream: true }), + signal: abortSignal, }) if (!response.ok) { @@ -363,6 +377,15 @@ export async function sendStreamingMessage( stream: response.body, } } catch (error) { + // Handle AbortError gracefully - this is expected when user aborts + if (error instanceof Error && error.name === 'AbortError') { + logger.info('Streaming message was aborted by user') + return { + success: false, + error: 'Request was aborted', + } + } + logger.error('Failed to send streaming message:', error) return { success: false, @@ -409,18 +432,20 @@ export async function sendStreamingDocsMessage( request: DocsQueryRequest ): Promise { try { - const message = `Please search the documentation and answer this question: ${request.query}` + const { abortSignal, ...requestData } = request + const message = `Please search the documentation and answer this question: ${requestData.query}` const response = await fetch('/api/copilot', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, - chatId: request.chatId, - workflowId: request.workflowId, - createNewChat: request.createNewChat, + chatId: requestData.chatId, + workflowId: requestData.workflowId, + createNewChat: requestData.createNewChat, stream: true, }), + signal: abortSignal, }) if (!response.ok) { @@ -437,6 +462,15 @@ export async function sendStreamingDocsMessage( stream: response.body, } } catch (error) { + // Handle AbortError gracefully - this is expected when user aborts + if (error instanceof Error && error.name === 'AbortError') { + logger.info('Streaming docs message was aborted by user') + return { + success: false, + error: 'Request was aborted', + } + } + logger.error('Failed to send streaming docs message:', error) return { success: false, diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 4c1c8d8c4a0..f369dfee072 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -33,10 +33,12 @@ const initialState = { isSendingMessage: false, isSaving: false, isRevertingCheckpoint: false, + isAborting: false, error: null, saveError: null, checkpointError: null, workflowId: null, + abortController: null, } /** @@ -462,7 +464,9 @@ export const useCopilotStore = create()( return } - set({ isSendingMessage: true, error: null }) + // Create abort controller for this request + const abortController = new AbortController() + set({ isSendingMessage: true, error: null, abortController }) const userMessage = createUserMessage(message) const streamingMessage = createStreamingMessage() @@ -479,14 +483,26 @@ export const useCopilotStore = create()( mode, createNewChat: !currentChat, stream, + abortSignal: abortController.signal, }) if (result.success && result.stream) { await get().handleStreamingResponse(result.stream, streamingMessage.id) } else { + // Handle abort gracefully + if (result.error === 'Request was aborted') { + logger.info('Message sending was aborted by user') + return // Don't throw or update state, abort handler already did + } throw new Error(result.error || 'Failed to send message') } } catch (error) { + // Check if this was an abort + if (error instanceof Error && error.name === 'AbortError') { + logger.info('Message sending was aborted') + return // Don't update state, abort handler already did + } + const errorMessage = createErrorMessage( streamingMessage.id, 'Sorry, I encountered an error while processing your message. Please try again.' @@ -498,10 +514,63 @@ export const useCopilotStore = create()( ), error: handleStoreError(error, 'Failed to send message'), isSendingMessage: false, + abortController: null, })) } }, + // Abort current message streaming + abortMessage: () => { + const { abortController, isSendingMessage, messages } = get() + + if (!isSendingMessage || !abortController) { + logger.warn('Cannot abort: no active streaming request') + return + } + + logger.info('Aborting message streaming') + set({ isAborting: true }) + + try { + // Abort the request + abortController.abort() + + // Find the last streaming message and replace it with an aborted message + const lastMessage = messages[messages.length - 1] + if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === '') { + const abortedMessage = createErrorMessage( + lastMessage.id, + 'Message was cancelled. You can continue the conversation below.' + ) + + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === lastMessage.id ? abortedMessage : msg + ), + isSendingMessage: false, + isAborting: false, + abortController: null, + })) + } else { + // No streaming message found, just reset the state + set({ + isSendingMessage: false, + isAborting: false, + abortController: null, + }) + } + + logger.info('Message streaming aborted successfully') + } catch (error) { + logger.error('Error during abort:', error) + set({ + isSendingMessage: false, + isAborting: false, + abortController: null, + }) + } + }, + // Update preview tool call state without sending feedback updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => { const { messages } = get() @@ -554,7 +623,9 @@ export const useCopilotStore = create()( return } - set({ isSendingMessage: true, error: null }) + // Create abort controller for this request + const abortController = new AbortController() + set({ isSendingMessage: true, error: null, abortController }) // Update the preview_workflow or targeted_updates tool call state if provided if (toolCallState) { @@ -610,15 +681,27 @@ export const useCopilotStore = create()( createNewChat: !currentChat, stream: true, implicitFeedback, // Pass the implicit feedback + abortSignal: abortController.signal, }) if (result.success && result.stream) { // Stream to the new assistant message (not continuation) await get().handleStreamingResponse(result.stream, newAssistantMessage.id, false) } else { + // Handle abort gracefully + if (result.error === 'Request was aborted') { + logger.info('Implicit feedback sending was aborted by user') + return // Don't throw or update state, abort handler already did + } throw new Error(result.error || 'Failed to send implicit feedback') } } catch (error) { + // Check if this was an abort + if (error instanceof Error && error.name === 'AbortError') { + logger.info('Implicit feedback sending was aborted') + return // Don't update state, abort handler already did + } + const errorMessage = createErrorMessage( newAssistantMessage.id, 'Sorry, I encountered an error while processing your feedback. Please try again.' @@ -630,6 +713,7 @@ export const useCopilotStore = create()( ), error: handleStoreError(error, 'Failed to send implicit feedback'), isSendingMessage: false, + abortController: null, })) } }, @@ -644,7 +728,9 @@ export const useCopilotStore = create()( return } - set({ isSendingMessage: true, error: null }) + // Create abort controller for this request + const abortController = new AbortController() + set({ isSendingMessage: true, error: null, abortController }) const userMessage = createUserMessage(query) const streamingMessage = createStreamingMessage() @@ -661,14 +747,26 @@ export const useCopilotStore = create()( workflowId, createNewChat: !currentChat, stream, + abortSignal: abortController.signal, }) if (result.success && result.stream) { await get().handleStreamingResponse(result.stream, streamingMessage.id) } else { + // Handle abort gracefully + if (result.error === 'Request was aborted') { + logger.info('Docs message sending was aborted by user') + return // Don't throw or update state, abort handler already did + } throw new Error(result.error || 'Failed to send docs message') } } catch (error) { + // Check if this was an abort + if (error instanceof Error && error.name === 'AbortError') { + logger.info('Docs message sending was aborted') + return // Don't update state, abort handler already did + } + const errorMessage = createErrorMessage( streamingMessage.id, 'Sorry, I encountered an error while searching the documentation. Please try again.' @@ -680,6 +778,7 @@ export const useCopilotStore = create()( ), error: handleStoreError(error, 'Failed to send docs message'), isSendingMessage: false, + abortController: null, })) } }, @@ -721,6 +820,15 @@ export const useCopilotStore = create()( try { while (true) { + const { abortController } = get() + + // Check if we should abort + if (abortController?.signal.aborted) { + logger.info('Stream reading aborted') + streamComplete = true + break + } + const { done, value } = await reader.read() if (done || streamComplete) { @@ -1113,6 +1221,7 @@ export const useCopilotStore = create()( : msg ), isSendingMessage: false, + abortController: null, // Clear abort controller when streaming completes })) // Auto-save messages after streaming completes @@ -1133,6 +1242,12 @@ export const useCopilotStore = create()( logger.warn('No chat ID available for auto-saving messages') } } catch (error) { + // Handle AbortError gracefully - this is expected when user aborts + if (error instanceof Error && error.name === 'AbortError') { + logger.info('Stream reading was aborted by user') + return // Don't throw or log as error + } + logger.error('Error handling streaming response:', error) throw error } finally { diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 29215e6e05d..6f0ef4431f8 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -132,11 +132,15 @@ export interface CopilotState { isSendingMessage: boolean isSaving: boolean isRevertingCheckpoint: boolean + isAborting: boolean // Error states error: string | null saveError: string | null checkpointError: string | null + + // Abort controller for cancelling requests + abortController: AbortController | null } /** @@ -156,6 +160,7 @@ export interface CopilotActions { // Message handling sendMessage: (message: string, options?: SendMessageOptions) => Promise + abortMessage: () => void sendImplicitFeedback: ( implicitFeedback: string, toolCallState?: 'applied' | 'rejected' From 3e3e35cb3bfcebc27823b06abcd8fab5448784da Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 20:11:10 -0700 Subject: [PATCH 076/184] Prompting --- apps/sim/app/api/workflows/diff/route.ts | 2 +- apps/sim/lib/copilot/prompts.ts | 109 +++++++++-------------- apps/sim/lib/copilot/service.ts | 9 -- 3 files changed, 44 insertions(+), 76 deletions(-) diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index ff08c96b36e..7e6f4e0a957 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -170,7 +170,7 @@ function extractEdges(yamlWorkflow: any): EdgeIdentity[] { target: targetName, sourceHandle: outputName, }) - } else if (typeof target === 'object' && target.block) { + } else if (target && typeof target === 'object' && target.block) { const targetName = blockIdToName.get(target.block) if (!targetName) return diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index a2689eacb0b..f33d70f9598 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -66,9 +66,23 @@ You are a workflow automation assistant with FULL editing capabilities for Sim S - Set up authentication for third-party integrations ## MANDATORY WORKFLOW EDITING PROTOCOL + +🚨 **EXTREMELY CRITICAL - WORKFLOW CONTEXT REQUIREMENT**: +⚠️ **ALWAYS GET USER'S WORKFLOW FIRST** when user mentions: +- "my workflow", "this workflow", "the workflow", "current workflow" +- "edit my...", "modify my...", "change my...", "update my..." +- "add to my workflow", "remove from my workflow" +- ANY request to modify existing workflow content + +**NEVER ASSUME OR PRETEND**: +- ❌ DO NOT respond "I've updated your workflow" without actually calling tools +- ❌ DO NOT say changes have been made without using Get User's Workflow first +- ❌ DO NOT provide generic responses when user refers to their specific workflow +- ❌ DO NOT skip getting their workflow "to save time" + ⚠️ **CRITICAL**: For ANY workflow creation or editing, you MUST follow this exact sequence: -1. **Get User's Workflow** (if modifying existing) +1. **Get User's Workflow** (if modifying existing) - **MANDATORY when user says "my workflow"** 2. **Get All Blocks and Tools** 3. **Get Block Metadata** (for blocks you'll use) 4. **Get YAML Structure Guide** @@ -243,18 +257,33 @@ const WORKFLOW_BUILDING_PROCESS = ` - **Critical**: Apply block selection rules before previewing (see BLOCK SELECTION GUIDELINES) - **Action**: STOP and wait for user approval -#### Step 6 Alternative: Targeted Updates -- **Purpose**: Make precise, atomic changes to specific blocks -- **When to prefer over Preview**: - - Small, focused edits (1-3 blocks) - - Adding a single block or connection - - Modifying specific block inputs - - When preserving workflow structure is important -- **When to use Preview instead**: - - Creating entirely new workflows - - Major restructuring (4+ blocks changed) - - Complex changes affecting multiple connections - - When user needs to see full workflow layout +#### Step 6 Alternative: Targeted Updates (for SMALL-SCALE edits) +- **Purpose**: Make precise, atomic changes to specific workflow blocks +- **When to prefer over Preview Workflow**: + - **Small, focused edits** (1-3 blocks maximum) + - **Adding a single block** or simple connection + - **Modifying specific block inputs** or parameters + - **Minor configuration changes** to existing blocks + - When preserving workflow structure and IDs is important + - Quick fixes or incremental improvements +- **When to use Preview Workflow instead (BUILD WORKFLOW)**: + - **Creating entirely new workflows from scratch** + - **Complete workflow redesign or restructuring** + - **Major overhauls** requiring significant changes (4+ blocks) + - **Fundamental workflow logic changes** + - **Complex changes affecting multiple connections** + - When user needs to see full workflow layout before applying + - **Starting fresh** or **rewriting the entire approach** + +#### 🔗 CRITICAL: Edge Changes in Targeted Updates +⚠️ **For edge/connection changes using Targeted Updates:** +- **You MUST explicitly edit BOTH blocks** surrounding the edge +- **Source block**: Update its 'connections' section to add/remove/modify the target +- **Target block**: Ensure it properly references the source block in its inputs +- **Example**: To connect Block A → Block B, you need: + 1. Edit Block A's connections to include Block B + 2. Edit Block B's inputs to reference Block A's output (if needed) +- **Never assume** that editing one block will automatically update the other ### 🎯 BLOCK SELECTION GUIDELINES @@ -648,59 +677,7 @@ ${WORKFLOW_ANALYSIS_GUIDELINES}` */ export const MAIN_CHAT_SYSTEM_PROMPT = AGENT_MODE_SYSTEM_PROMPT -/** - * Validate that the system prompts are properly constructed - * This helps catch any issues with template literal construction - */ -export function validateSystemPrompts(): { - askMode: { valid: boolean; issues: string[] } - agentMode: { valid: boolean; issues: string[] } -} { - const askIssues: string[] = [] - const agentIssues: string[] = [] - - // Check Ask mode prompt - if (!ASK_MODE_SYSTEM_PROMPT || ASK_MODE_SYSTEM_PROMPT.length < 500) { - askIssues.push('Prompt too short or undefined') - } - if (!ASK_MODE_SYSTEM_PROMPT.includes('analysis, education, and providing thorough guidance')) { - askIssues.push('Missing educational focus description') - } - if (!ASK_MODE_SYSTEM_PROMPT.includes('WORKFLOW GUIDANCE AND EDUCATION')) { - askIssues.push('Missing workflow guidance section') - } - if (ASK_MODE_SYSTEM_PROMPT.includes('AGENT mode')) { - askIssues.push('Should not reference AGENT mode') - } - if (ASK_MODE_SYSTEM_PROMPT.includes('switch to')) { - askIssues.push('Should not suggest switching modes') - } - if (ASK_MODE_SYSTEM_PROMPT.includes('WORKFLOW BUILDING PROCESS')) { - askIssues.push('Should not contain workflow building process (Agent only)') - } - if (ASK_MODE_SYSTEM_PROMPT.includes('Edit Workflow')) { - askIssues.push('Should not reference edit workflow capability') - } - - // Check Agent mode prompt - if (!AGENT_MODE_SYSTEM_PROMPT || AGENT_MODE_SYSTEM_PROMPT.length < 1000) { - agentIssues.push('Prompt too short or undefined') - } - if (!AGENT_MODE_SYSTEM_PROMPT.includes('WORKFLOW BUILDING PROCESS')) { - agentIssues.push('Missing workflow building process') - } - if (!AGENT_MODE_SYSTEM_PROMPT.includes('Edit Workflow')) { - agentIssues.push('Missing edit workflow capability') - } - if (!AGENT_MODE_SYSTEM_PROMPT.includes('CRITICAL REQUIREMENT')) { - agentIssues.push('Missing critical workflow editing requirements') - } - - return { - askMode: { valid: askIssues.length === 0, issues: askIssues }, - agentMode: { valid: agentIssues.length === 0, issues: agentIssues }, - } -} + /** * System prompt for generating chat titles diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 77a96b7d04e..4dbc7080968 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -14,19 +14,10 @@ import { ASK_MODE_SYSTEM_PROMPT, TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT, - validateSystemPrompts, } from './prompts' const logger = createLogger('CopilotService') -// Validate system prompts on module load -const promptValidation = validateSystemPrompts() -if (!promptValidation.askMode.valid) { - logger.error('Ask mode system prompt validation failed:', promptValidation.askMode.issues) -} -if (!promptValidation.agentMode.valid) { - logger.error('Agent mode system prompt validation failed:', promptValidation.agentMode.issues) -} /** * Citation information for documentation references From 6f6cf1152b41af44a1f9d551cb2a68ca285f6878 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 20:19:22 -0700 Subject: [PATCH 077/184] Lint --- .../professional-input/professional-input.tsx | 2 +- apps/sim/lib/copilot/api.ts | 8 ++++---- apps/sim/lib/copilot/prompts.ts | 2 -- apps/sim/lib/copilot/service.ts | 1 - apps/sim/lib/workflows/credential-resolver.ts | 20 +++++++++---------- apps/sim/stores/copilot/store.ts | 8 ++++---- 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx index 77e6bda05bc..f8b1e050855 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx @@ -91,7 +91,7 @@ const ProfessionalInput: FC = ({ 'absolute right-2 bottom-2 h-8 w-8 rounded-xl transition-all', 'bg-red-500 text-white shadow-sm hover:bg-red-600' )} - title="Stop generation" + title='Stop generation' > {isAborting ? ( diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index 29fdb8ff13a..1a087559d42 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -198,7 +198,7 @@ async function makeApiRequest( } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' - + // Handle AbortError gracefully - this is expected when user aborts if (error instanceof Error && error.name === 'AbortError') { logger.info(`API request was aborted: ${defaultErrorMessage}`) @@ -207,7 +207,7 @@ async function makeApiRequest( error: 'Request was aborted', } } - + logger.error(`API request failed: ${defaultErrorMessage}`, error) return { success: false, @@ -385,7 +385,7 @@ export async function sendStreamingMessage( error: 'Request was aborted', } } - + logger.error('Failed to send streaming message:', error) return { success: false, @@ -470,7 +470,7 @@ export async function sendStreamingDocsMessage( error: 'Request was aborted', } } - + logger.error('Failed to send streaming docs message:', error) return { success: false, diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index f33d70f9598..6f367c69498 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -677,8 +677,6 @@ ${WORKFLOW_ANALYSIS_GUIDELINES}` */ export const MAIN_CHAT_SYSTEM_PROMPT = AGENT_MODE_SYSTEM_PROMPT - - /** * System prompt for generating chat titles * Used when creating concise titles for new conversations diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 4dbc7080968..5fa87214086 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -18,7 +18,6 @@ import { const logger = createLogger('CopilotService') - /** * Citation information for documentation references */ diff --git a/apps/sim/lib/workflows/credential-resolver.ts b/apps/sim/lib/workflows/credential-resolver.ts index 05e3ec51dd8..f853ea10f2d 100644 --- a/apps/sim/lib/workflows/credential-resolver.ts +++ b/apps/sim/lib/workflows/credential-resolver.ts @@ -1,8 +1,8 @@ import { createLogger } from '@/lib/logs/console-logger' +import { getProviderIdFromServiceId, getServiceIdFromScopes } from '@/lib/oauth/oauth' import { getBlock } from '@/blocks/index' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' -import { getServiceIdFromScopes, getProviderIdFromServiceId } from '@/lib/oauth/oauth' const logger = createLogger('CredentialResolver') @@ -64,11 +64,7 @@ export async function resolveCredentialsForWorkflow( } // Resolve credential for this subblock - const credentialId = await resolveCredentialForSubBlock( - subBlockConfig, - blockState, - userId - ) + const credentialId = await resolveCredentialForSubBlock(subBlockConfig, blockState, userId) if (credentialId) { resolvedValues[blockId][subBlockId] = credentialId @@ -105,7 +101,11 @@ export async function resolveCredentialsForWorkflow( * Resolves a single credential for a subblock */ async function resolveCredentialForSubBlock( - subBlockConfig: SubBlockConfig & { provider?: string; requiredScopes?: string[]; serviceId?: string }, + subBlockConfig: SubBlockConfig & { + provider?: string + requiredScopes?: string[] + serviceId?: string + }, blockState: BlockState, userId?: string ): Promise { @@ -140,7 +140,7 @@ async function resolveCredentialForSubBlock( // Note: This assumes we're running in a server context with access to fetch const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' const credentialsUrl = `${baseUrl}/api/auth/oauth/credentials?provider=${effectiveProviderId}` - + logger.debug('Fetching credentials', { url: credentialsUrl }) const response = await fetch(credentialsUrl, { @@ -159,7 +159,7 @@ async function resolveCredentialForSubBlock( const credentials: Credential[] = data.credentials || [] logger.info(`Found ${credentials.length} credential(s) for provider ${effectiveProviderId}`, { - credentials: credentials.map(c => ({ + credentials: credentials.map((c) => ({ id: c.id, isDefault: c.isDefault, })), @@ -215,4 +215,4 @@ export function needsCredentialResolution( } return false -} \ No newline at end of file +} diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index f369dfee072..22217ceb614 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -522,7 +522,7 @@ export const useCopilotStore = create()( // Abort current message streaming abortMessage: () => { const { abortController, isSendingMessage, messages } = get() - + if (!isSendingMessage || !abortController) { logger.warn('Cannot abort: no active streaming request') return @@ -534,7 +534,7 @@ export const useCopilotStore = create()( try { // Abort the request abortController.abort() - + // Find the last streaming message and replace it with an aborted message const lastMessage = messages[messages.length - 1] if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === '') { @@ -821,7 +821,7 @@ export const useCopilotStore = create()( try { while (true) { const { abortController } = get() - + // Check if we should abort if (abortController?.signal.aborted) { logger.info('Stream reading aborted') @@ -1247,7 +1247,7 @@ export const useCopilotStore = create()( logger.info('Stream reading was aborted by user') return // Don't throw or log as error } - + logger.error('Error handling streaming response:', error) throw error } finally { From 8aad4343fd0e7eba8bd381354a14004cc0c08480 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 20:27:29 -0700 Subject: [PATCH 078/184] Fix test --- .../components/loop-node/loop-node.test.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx index 14f5343666f..5b772267d58 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx @@ -48,9 +48,14 @@ vi.mock('@/components/ui/card', () => ({ Card: ({ children, ...props }: any) => ({ children, ...props }), })) -vi.mock('@/components/icons', () => ({ - StartIcon: ({ className }: any) => ({ className }), -})) +vi.mock('@/components/icons', async (importOriginal) => { + const actual = await importOriginal() as any + return { + ...actual, + // Override specific icons if needed for testing + StartIcon: ({ className }: any) => ({ className }), + } +}) vi.mock('@/lib/utils', () => ({ cn: (...classes: any[]) => classes.filter(Boolean).join(' '), From bbb284448cd2eeb5e9b2bbec957d2d53a3fe035c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 25 Jul 2025 20:29:07 -0700 Subject: [PATCH 079/184] Fix lint --- .../w/[workflowId]/components/loop-node/loop-node.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx index 5b772267d58..56518f8d106 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.test.tsx @@ -49,7 +49,7 @@ vi.mock('@/components/ui/card', () => ({ })) vi.mock('@/components/icons', async (importOriginal) => { - const actual = await importOriginal() as any + const actual = (await importOriginal()) as any return { ...actual, // Override specific icons if needed for testing From 003d572d0903cba03333137d37416fce625e322d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 17:01:40 -0700 Subject: [PATCH 080/184] Update csp --- apps/sim/lib/security/csp.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/security/csp.ts b/apps/sim/lib/security/csp.ts index 79ab9d306aa..610ce684a7f 100644 --- a/apps/sim/lib/security/csp.ts +++ b/apps/sim/lib/security/csp.ts @@ -62,6 +62,7 @@ export const cspDirectives: CSPDirectives = { env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002', env.NEXT_PUBLIC_SOCKET_URL?.replace('http://', 'ws://').replace('https://', 'wss://') || 'ws://localhost:3002', + 'http://localhost:8000', 'https://*.up.railway.app', 'wss://*.up.railway.app', 'https://api.browser-use.com', From a4c79f2e73c411f82833e71e952cf0e39ad7398f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 17:32:29 -0700 Subject: [PATCH 081/184] Add route to send info to sim agent --- apps/sim/app/api/test-auth/route.ts | 58 ++++++ .../components/control-bar/control-bar.tsx | 86 +++++++++ apps/sim/lib/env.ts | 5 + apps/sim/lib/security/csp.ts | 1 + apps/sim/lib/sim-agent/client.ts | 182 ++++++++++++++++++ apps/sim/lib/sim-agent/index.ts | 9 + 6 files changed, 341 insertions(+) create mode 100644 apps/sim/app/api/test-auth/route.ts create mode 100644 apps/sim/lib/sim-agent/client.ts create mode 100644 apps/sim/lib/sim-agent/index.ts diff --git a/apps/sim/app/api/test-auth/route.ts b/apps/sim/app/api/test-auth/route.ts new file mode 100644 index 00000000000..e36b6e1586d --- /dev/null +++ b/apps/sim/app/api/test-auth/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { simAgentClient } from '@/lib/sim-agent/client' + +const logger = createLogger('TestAuthAPI') + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + // Get session for user info + const session = await getSession() + const body = await request.json() + const { cookie, workflowId, userId } = body + + if (!workflowId) { + return NextResponse.json( + { success: false, error: 'Workflow ID is required' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Test auth request`, { + workflowId, + userId: userId || session?.user?.id, + hasCookie: !!cookie, + hasSession: !!session, + }) + + // Use the sim-agent client + const result = await simAgentClient.testAuth({ + workflowId, + userId: userId || session?.user?.id, + cookie: cookie || request.headers.get('Cookie') || '', + }) + + logger.info(`[${requestId}] Sim-agent response`, { + success: result.success, + status: result.status, + hasData: !!result.data, + }) + + return NextResponse.json(result, { + status: result.success ? 200 : (result.status || 500) + }) + + } catch (error) { + logger.error(`[${requestId}] Test auth API failed:`, error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index d8d409929f1..99fc9fe4f90 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -30,6 +30,7 @@ import { import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' +import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider' @@ -972,6 +973,90 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { ) } + /** + * Handle test auth API call + */ + const handleTestAuth = async () => { + if (!activeWorkflowId) { + console.error('No active workflow ID') + return + } + + if (!session?.user?.id) { + console.error('No user session') + alert('Please log in to test the sim-agent connection') + return + } + + try { + // Get the session cookie from document.cookie + const sessionCookie = document.cookie + + console.log('Test Auth Debug:', { + workflowId: activeWorkflowId, + userId: session.user.id, + cookieLength: sessionCookie.length, + }) + + const response = await fetch('/api/test-auth', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + cookie: sessionCookie, + workflowId: activeWorkflowId, + userId: session.user.id, + }), + }) + + console.log('Response status:', response.status) + + let result + try { + const responseText = await response.text() + console.log('Raw response text:', responseText) + result = JSON.parse(responseText) + } catch (parseError) { + console.error('Failed to parse response as JSON:', parseError) + alert(`Failed to parse response as JSON. Status: ${response.status}`) + return + } + + if (result.success) { + console.log('Sim-agent test successful:', result) + alert('✅ Sim-agent connection successful! Check console for details.') + } else { + console.error('Sim-agent test failed:', result) + alert(`❌ Sim-agent test failed: ${result.error || 'Unknown error'}`) + } + } catch (error) { + console.error('Test auth error:', error) + alert(`❌ Test auth error: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Render test auth button + */ + const renderTestAuthButton = () => { + return ( + + + + + Test Auth API + + ) + } + /** * Render control bar toggle button */ @@ -1010,6 +1095,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { {!isDebugging && renderDebugModeToggle()} {renderPublishButton()} {renderDeployButton()} + {renderTestAuthButton()} {isDebugging ? renderDebugControlsBar() : renderRunButton()} {/* Template Modal */} diff --git a/apps/sim/lib/env.ts b/apps/sim/lib/env.ts index 18581f67d36..92e60d4212b 100644 --- a/apps/sim/lib/env.ts +++ b/apps/sim/lib/env.ts @@ -22,6 +22,7 @@ export const env = createEnv({ DISABLE_REGISTRATION: z.boolean().optional(), ENCRYPTION_KEY: z.string().min(32), INTERNAL_API_SECRET: z.string().min(32), + SIM_AGENT_API_KEY: z.string().min(1).optional(), POSTGRES_URL: z.string().url().optional(), STRIPE_SECRET_KEY: z.string().min(1).optional(), @@ -127,6 +128,8 @@ export const env = createEnv({ client: { NEXT_PUBLIC_APP_URL: z.string().url(), NEXT_PUBLIC_VERCEL_URL: z.string().optional(), + NEXT_PUBLIC_SIM_AGENT_URL: z.string().url().optional(), + NEXT_PUBLIC_SIM_AGENT_API_KEY: z.string().min(1).optional(), NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(), NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional(), NEXT_PUBLIC_GOOGLE_API_KEY: z.string().optional(), @@ -143,6 +146,8 @@ export const env = createEnv({ experimental__runtimeEnv: { NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL, + NEXT_PUBLIC_SIM_AGENT_URL: process.env.NEXT_PUBLIC_SIM_AGENT_URL, + NEXT_PUBLIC_SIM_AGENT_API_KEY: process.env.NEXT_PUBLIC_SIM_AGENT_API_KEY, NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID, NEXT_PUBLIC_GOOGLE_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_API_KEY, diff --git a/apps/sim/lib/security/csp.ts b/apps/sim/lib/security/csp.ts index 610ce684a7f..acccdffe4cb 100644 --- a/apps/sim/lib/security/csp.ts +++ b/apps/sim/lib/security/csp.ts @@ -58,6 +58,7 @@ export const cspDirectives: CSPDirectives = { 'connect-src': [ "'self'", env.NEXT_PUBLIC_APP_URL || '', + env.NEXT_PUBLIC_SIM_AGENT_URL || (env.NODE_ENV === 'development' ? 'http://localhost:8000' : 'https://sim-agent.vercel.app'), env.OLLAMA_URL || 'http://localhost:11434', env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3002', env.NEXT_PUBLIC_SOCKET_URL?.replace('http://', 'ws://').replace('https://', 'wss://') || diff --git a/apps/sim/lib/sim-agent/client.ts b/apps/sim/lib/sim-agent/client.ts new file mode 100644 index 00000000000..e9c7a7a72ad --- /dev/null +++ b/apps/sim/lib/sim-agent/client.ts @@ -0,0 +1,182 @@ +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('SimAgentClient') + +export interface SimAgentRequest { + workflowId: string + userId?: string + cookie?: string + data?: Record +} + +export interface SimAgentResponse { + success: boolean + data?: T + error?: string + status?: number +} + +class SimAgentClient { + private baseUrl: string + private apiKey: string + + constructor() { + // Determine base URL based on environment + this.baseUrl = env.NODE_ENV === 'development' + ? 'http://localhost:8000' + : (env.NEXT_PUBLIC_SIM_AGENT_URL || 'https://sim-agent.vercel.app') + + this.apiKey = env.SIM_AGENT_API_KEY || '' + + if (!this.apiKey) { + logger.warn('SIM_AGENT_API_KEY not configured') + } + } + + /** + * Make a request to the sim-agent service + */ + private async makeRequest( + endpoint: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' + body?: Record + headers?: Record + cookie?: string + } = {} + ): Promise> { + const requestId = crypto.randomUUID().slice(0, 8) + const { method = 'POST', body, headers = {}, cookie } = options + + try { + const url = `${this.baseUrl}${endpoint}` + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + ...headers, + } + + // Add cookie if provided + if (cookie) { + requestHeaders['Cookie'] = cookie + } + + logger.info(`[${requestId}] Making request to sim-agent`, { + url, + method, + hasApiKey: !!this.apiKey, + hasCookie: !!cookie, + hasBody: !!body, + }) + + const fetchOptions: RequestInit = { + method, + headers: requestHeaders, + } + + if (body && (method === 'POST' || method === 'PUT')) { + fetchOptions.body = JSON.stringify(body) + } + + const response = await fetch(url, fetchOptions) + const responseStatus = response.status + + let responseData + try { + const responseText = await response.text() + responseData = responseText ? JSON.parse(responseText) : null + } catch (parseError) { + logger.error(`[${requestId}] Failed to parse response`, parseError) + return { + success: false, + error: `Failed to parse response: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}`, + status: responseStatus, + } + } + + logger.info(`[${requestId}] Response received`, { + status: responseStatus, + success: response.ok, + hasData: !!responseData, + }) + + return { + success: response.ok, + data: responseData, + error: response.ok ? undefined : responseData?.error || `HTTP ${responseStatus}`, + status: responseStatus, + } + + } catch (fetchError) { + logger.error(`[${requestId}] Request failed`, fetchError) + return { + success: false, + error: `Connection failed: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`, + status: 0, + } + } + } + + /** + * Test authentication with the sim-agent service + */ + async testAuth(request: SimAgentRequest): Promise { + return this.makeRequest('/api/test-auth', { + method: 'POST', + body: { + cookie: request.cookie, + workflowId: request.workflowId, + userId: request.userId, + ...request.data, + }, + cookie: request.cookie, + }) + } + + /** + * Health check endpoint + */ + async healthCheck(): Promise { + return this.makeRequest('/api/health', { + method: 'GET', + }) + } + + /** + * Generic method for custom API calls + */ + async call( + endpoint: string, + request: SimAgentRequest, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'POST' + ): Promise> { + return this.makeRequest(endpoint, { + method, + body: { + workflowId: request.workflowId, + userId: request.userId, + ...request.data, + }, + cookie: request.cookie, + }) + } + + /** + * Get the current configuration + */ + getConfig() { + return { + baseUrl: this.baseUrl, + hasApiKey: !!this.apiKey, + environment: env.NODE_ENV, + } + } +} + +// Export singleton instance +export const simAgentClient = new SimAgentClient() + +// Export types and class for advanced usage +export { SimAgentClient } \ No newline at end of file diff --git a/apps/sim/lib/sim-agent/index.ts b/apps/sim/lib/sim-agent/index.ts new file mode 100644 index 00000000000..fbd4e5442a1 --- /dev/null +++ b/apps/sim/lib/sim-agent/index.ts @@ -0,0 +1,9 @@ +// Export the main client and types +export { simAgentClient, SimAgentClient } from './client' +export type { SimAgentRequest, SimAgentResponse } from './client' + +// Import for default export +import { simAgentClient } from './client' + +// Re-export for convenience +export default simAgentClient \ No newline at end of file From 69495a4430d43bda0a7e0839058fb1fd23c9181e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 18:49:35 -0700 Subject: [PATCH 082/184] Consolidated copilot --- .../copilot/checkpoints/[id]/revert/route.ts | 61 ++- apps/sim/app/api/copilot/checkpoints/route.ts | 69 +++- .../api/copilot/docs-search-internal/route.ts | 77 ++++ .../sim/app/api/copilot/execute-tool/route.ts | 37 ++ .../api/copilot/get-blocks-and-tools/route.ts | 93 +++++ .../get-blocks-metadata/route.ts | 0 .../get-environment-variables/route.ts | 96 +++++ .../get-user-workflow/route.ts | 16 +- .../get-workflow-console/route.ts | 0 .../get-workflow-examples/route.ts | 0 .../get-yaml-structure/route.ts | 0 .../app/api/copilot/preview-workflow/route.ts | 91 ++++ apps/sim/app/api/copilot/route.ts | 37 +- .../set-environment-variables/route.ts | 107 +++++ .../app/api/copilot/targeted-updates/route.ts | 39 ++ apps/sim/app/api/workflows/[id]/route.ts | 28 +- apps/sim/lib/auth/hybrid.ts | 141 +++++++ apps/sim/lib/copilot/provider-bridge.ts | 53 +++ apps/sim/lib/copilot/service.ts | 1 + apps/sim/lib/copilot/tools.ts | 390 +++++++++++++++--- apps/sim/providers/anthropic/index.ts | 33 +- apps/sim/providers/types.ts | 1 + apps/sim/tools/blocks/edit-workflow.ts | 88 ---- apps/sim/tools/blocks/get-all.ts | 78 ---- apps/sim/tools/blocks/get-metadata.ts | 104 ----- apps/sim/tools/blocks/get-yaml-structure.ts | 56 --- apps/sim/tools/blocks/preview-workflow.ts | 86 ---- apps/sim/tools/docs/search.ts | 103 ----- apps/sim/tools/environment/get-variables.ts | 39 -- apps/sim/tools/environment/set-variables.ts | 66 --- apps/sim/tools/utils.ts | 28 +- apps/sim/tools/workflow/get-console.ts | 79 ---- apps/sim/tools/workflow/get-examples.ts | 67 --- apps/sim/tools/workflow/get-yaml.ts | 49 --- apps/sim/tools/workflow/targeted-updates.ts | 89 ---- 35 files changed, 1292 insertions(+), 1010 deletions(-) create mode 100644 apps/sim/app/api/copilot/docs-search-internal/route.ts create mode 100644 apps/sim/app/api/copilot/execute-tool/route.ts create mode 100644 apps/sim/app/api/copilot/get-blocks-and-tools/route.ts rename apps/sim/app/api/{tools => copilot}/get-blocks-metadata/route.ts (100%) create mode 100644 apps/sim/app/api/copilot/get-environment-variables/route.ts rename apps/sim/app/api/{tools => copilot}/get-user-workflow/route.ts (94%) rename apps/sim/app/api/{tools => copilot}/get-workflow-console/route.ts (100%) rename apps/sim/app/api/{tools => copilot}/get-workflow-examples/route.ts (100%) rename apps/sim/app/api/{tools => copilot}/get-yaml-structure/route.ts (100%) create mode 100644 apps/sim/app/api/copilot/preview-workflow/route.ts create mode 100644 apps/sim/app/api/copilot/set-environment-variables/route.ts create mode 100644 apps/sim/lib/auth/hybrid.ts create mode 100644 apps/sim/lib/copilot/provider-bridge.ts delete mode 100644 apps/sim/tools/blocks/edit-workflow.ts delete mode 100644 apps/sim/tools/blocks/get-all.ts delete mode 100644 apps/sim/tools/blocks/get-metadata.ts delete mode 100644 apps/sim/tools/blocks/get-yaml-structure.ts delete mode 100644 apps/sim/tools/blocks/preview-workflow.ts delete mode 100644 apps/sim/tools/docs/search.ts delete mode 100644 apps/sim/tools/environment/get-variables.ts delete mode 100644 apps/sim/tools/environment/set-variables.ts delete mode 100644 apps/sim/tools/workflow/get-console.ts delete mode 100644 apps/sim/tools/workflow/get-examples.ts delete mode 100644 apps/sim/tools/workflow/get-yaml.ts delete mode 100644 apps/sim/tools/workflow/targeted-updates.ts diff --git a/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts index 3c307ef5fe3..e372cba95dd 100644 --- a/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts @@ -1,9 +1,10 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { verifyInternalToken } from '@/lib/auth/internal' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { copilotCheckpoints, workflow as workflowTable } from '@/db/schema' +import { apiKey as apiKeyTable, copilotCheckpoints, workflow as workflowTable } from '@/db/schema' const logger = createLogger('RevertCheckpointAPI') @@ -16,13 +17,61 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const checkpointId = (await params).id try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // Check for internal JWT token for server-side calls + const authHeader = request.headers.get('authorization') + let isInternalCall = false + + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1] + isInternalCall = await verifyInternalToken(token) + } + + let authenticatedUserId: string | null = null + + if (isInternalCall) { + // For internal calls, get the checkpoint owner as the user context + const [checkpointData] = await db + .select({ userId: copilotCheckpoints.userId }) + .from(copilotCheckpoints) + .where(eq(copilotCheckpoints.id, checkpointId)) + .limit(1) + + if (!checkpointData) { + return NextResponse.json({ error: 'Checkpoint not found' }, { status: 404 }) + } + authenticatedUserId = checkpointData.userId + } else { + // Try session auth first (for web UI) + const session = await getSession() + authenticatedUserId = session?.user?.id || null + + // If no session, check for API key auth + if (!authenticatedUserId) { + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + // Verify API key + const [apiKeyRecord] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKeyHeader)) + .limit(1) + + if (apiKeyRecord) { + authenticatedUserId = apiKeyRecord.userId + } + } + } + + if (!authenticatedUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } } + // TypeScript assertion: authenticatedUserId is guaranteed non-null at this point + const userId = authenticatedUserId as string + logger.info(`[${requestId}] Reverting to checkpoint: ${checkpointId}`, { - userId: session.user.id, + userId, }) // Get the checkpoint @@ -30,7 +79,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ .select() .from(copilotCheckpoints) .where( - and(eq(copilotCheckpoints.id, checkpointId), eq(copilotCheckpoints.userId, session.user.id)) + and(eq(copilotCheckpoints.id, checkpointId), eq(copilotCheckpoints.userId, userId)) ) .limit(1) diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index 2f7d97f960f..2c4ce64f49d 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -1,9 +1,10 @@ import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { verifyInternalToken } from '@/lib/auth/internal' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { copilotCheckpoints } from '@/db/schema' +import { apiKey as apiKeyTable, copilotCheckpoints, workflow } from '@/db/schema' const logger = createLogger('CopilotCheckpointsAPI') @@ -15,11 +16,67 @@ export async function GET(request: NextRequest) { const requestId = crypto.randomUUID() try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // Check for internal JWT token for server-side calls + const authHeader = request.headers.get('authorization') + let isInternalCall = false + + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1] + isInternalCall = await verifyInternalToken(token) + } + + let authenticatedUserId: string | null = null + + if (isInternalCall) { + // For internal calls, we need chatId to determine context + const { searchParams } = new URL(request.url) + const chatId = searchParams.get('chatId') + + if (!chatId) { + return NextResponse.json({ error: 'chatId required for internal calls' }, { status: 400 }) + } + + // Get the first checkpoint for this chat to determine the user + const [firstCheckpoint] = await db + .select({ userId: copilotCheckpoints.userId }) + .from(copilotCheckpoints) + .where(eq(copilotCheckpoints.chatId, chatId)) + .limit(1) + + if (!firstCheckpoint) { + return NextResponse.json({ error: 'No checkpoints found for chat' }, { status: 404 }) + } + authenticatedUserId = firstCheckpoint.userId + } else { + // Try session auth first (for web UI) + const session = await getSession() + authenticatedUserId = session?.user?.id || null + + // If no session, check for API key auth + if (!authenticatedUserId) { + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + // Verify API key + const [apiKeyRecord] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKeyHeader)) + .limit(1) + + if (apiKeyRecord) { + authenticatedUserId = apiKeyRecord.userId + } + } + } + + if (!authenticatedUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } } + // TypeScript assertion: authenticatedUserId is guaranteed non-null at this point + const userId = authenticatedUserId as string + const { searchParams } = new URL(request.url) const chatId = searchParams.get('chatId') const limit = Number(searchParams.get('limit')) || 10 @@ -30,7 +87,7 @@ export async function GET(request: NextRequest) { } logger.info(`[${requestId}] Listing checkpoints for chat: ${chatId}`, { - userId: session.user.id, + userId, limit, offset, }) @@ -39,7 +96,7 @@ export async function GET(request: NextRequest) { .select() .from(copilotCheckpoints) .where( - and(eq(copilotCheckpoints.userId, session.user.id), eq(copilotCheckpoints.chatId, chatId)) + and(eq(copilotCheckpoints.userId, userId), eq(copilotCheckpoints.chatId, chatId)) ) .orderBy(desc(copilotCheckpoints.createdAt)) .limit(limit) diff --git a/apps/sim/app/api/copilot/docs-search-internal/route.ts b/apps/sim/app/api/copilot/docs-search-internal/route.ts new file mode 100644 index 00000000000..803f2480196 --- /dev/null +++ b/apps/sim/app/api/copilot/docs-search-internal/route.ts @@ -0,0 +1,77 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('DocsSearchInternalAPI') + +export async function POST(request: NextRequest) { + try { + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error }, + { status: 401 } + ) + } + + const body = await request.json() + const { query, topK = 10 } = body + + if (!query) { + return NextResponse.json( + { success: false, error: 'Query is required' }, + { status: 400 } + ) + } + + logger.info('Executing docs search for copilot', { + query, + topK, + authType: authResult.authType, + userId: authResult.userId + }) + + // Forward the request to the existing docs search endpoint + const docsSearchUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/docs/search` + + const response = await fetch(docsSearchUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, topK }), + }) + + if (!response.ok) { + logger.error('Docs search API failed', { + status: response.status, + statusText: response.statusText + }) + return NextResponse.json( + { success: false, error: 'Documentation search failed' }, + { status: response.status } + ) + } + + const searchResults = await response.json() + + return NextResponse.json({ + success: true, + data: { + results: searchResults.results || [], + query, + totalResults: searchResults.totalResults || 0, + }, + }) + } catch (error) { + logger.error('Documentation search API failed:', error) + return NextResponse.json( + { + success: false, + error: `Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/execute-tool/route.ts b/apps/sim/app/api/copilot/execute-tool/route.ts new file mode 100644 index 00000000000..1911e087239 --- /dev/null +++ b/apps/sim/app/api/copilot/execute-tool/route.ts @@ -0,0 +1,37 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' +import { executeCopilotTool } from '@/lib/copilot/tools' + +const logger = createLogger('ExecuteCopilotToolAPI') + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { toolId, params } = body + + if (!toolId) { + return NextResponse.json( + { + success: false, + error: 'toolId is required', + }, + { status: 400 } + ) + } + + logger.info('Executing copilot tool', { toolId }) + + const result = await executeCopilotTool(toolId, params || {}) + + return NextResponse.json(result) + } catch (error) { + logger.error('Failed to execute copilot tool', error) + return NextResponse.json( + { + success: false, + error: `Failed to execute copilot tool: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts b/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts new file mode 100644 index 00000000000..eafe504e9ad --- /dev/null +++ b/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts @@ -0,0 +1,93 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' +import { registry as blockRegistry } from '@/blocks/registry' + +const logger = createLogger('GetAllBlocksAPI') + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { includeDetails = false, filterCategory } = body + + logger.info('Getting all blocks and tools', { includeDetails, filterCategory }) + + // Create mapping of block_id -> [tool_ids] + const blockToToolsMapping: Record = {} + + // Process blocks - filter out hidden blocks and map to their tools + Object.entries(blockRegistry) + .filter(([blockType, blockConfig]) => { + // Filter out hidden blocks + if (blockConfig.hideFromToolbar) return false + + // Apply category filter if specified + if (filterCategory && blockConfig.category !== filterCategory) return false + + return true + }) + .forEach(([blockType, blockConfig]) => { + // Get the tools for this block + const blockTools = blockConfig.tools?.access || [] + blockToToolsMapping[blockType] = blockTools + }) + + // Add special blocks that aren't in the standard registry + // Loop and parallel blocks are handled differently but should be available + const specialBlocks = { + loop: { + tools: [], // Loop blocks don't use standard tools + category: 'blocks', + description: 'Control flow block for iterating over collections or repeating actions', + }, + parallel: { + tools: [], // Parallel blocks don't use standard tools + category: 'blocks', + description: 'Control flow block for executing multiple branches simultaneously', + }, + } + + // Add special blocks if they pass the category filter + Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { + if (!filterCategory || blockInfo.category === filterCategory) { + blockToToolsMapping[blockType] = blockInfo.tools + } + }) + + const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length + const includedBlocks = Object.keys(blockToToolsMapping).length + const filteredBlocksCount = totalBlocks - includedBlocks + + // Log block to tools mapping for debugging + const blockToolsInfo = Object.entries(blockToToolsMapping) + .map(([blockType, tools]) => `${blockType}: [${tools.join(', ')}]`) + .sort() + + logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, { + totalBlocks, + includedBlocks, + filteredBlocks: filteredBlocksCount, + filterCategory, + blockToolsMapping: blockToolsInfo, + outputMapping: blockToToolsMapping, + specialBlocksAdded: Object.keys(specialBlocks).filter( + (blockType) => + !filterCategory || + specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory + ), + }) + + return NextResponse.json({ + success: true, + data: blockToToolsMapping, + }) + } catch (error) { + logger.error('Get all blocks failed', error) + return NextResponse.json( + { + success: false, + error: `Failed to get blocks and tools: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/get-blocks-metadata/route.ts b/apps/sim/app/api/copilot/get-blocks-metadata/route.ts similarity index 100% rename from apps/sim/app/api/tools/get-blocks-metadata/route.ts rename to apps/sim/app/api/copilot/get-blocks-metadata/route.ts diff --git a/apps/sim/app/api/copilot/get-environment-variables/route.ts b/apps/sim/app/api/copilot/get-environment-variables/route.ts new file mode 100644 index 00000000000..52777ff25c4 --- /dev/null +++ b/apps/sim/app/api/copilot/get-environment-variables/route.ts @@ -0,0 +1,96 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('GetEnvironmentVariablesAPI') + +export async function POST(request: NextRequest) { + try { + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error }, + { status: 401 } + ) + } + + // Ensure we have a user ID for this operation + if (!authResult.userId) { + return NextResponse.json( + { success: false, error: 'User ID required for environment variables access' }, + { status: 400 } + ) + } + + logger.info('Getting environment variables for copilot', { + authType: authResult.authType, + userId: authResult.userId + }) + + // Forward the request to the existing environment variables endpoint + const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` + + // Create headers for the forwarded request + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Forward authentication based on the original auth method + if (authResult.authType === 'api_key') { + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + headers['X-API-Key'] = apiKeyHeader + } + } else if (authResult.authType === 'internal_jwt') { + const authHeader = request.headers.get('authorization') + if (authHeader) { + headers['Authorization'] = authHeader + } + } else { + // For session auth, copy the cookies + const cookieHeader = request.headers.get('cookie') + if (cookieHeader) { + headers['Cookie'] = cookieHeader + } + } + + const response = await fetch(envUrl, { + method: 'GET', + headers, + }) + + if (!response.ok) { + logger.error('Environment variables API failed', { + status: response.status, + statusText: response.statusText + }) + return NextResponse.json( + { success: false, error: 'Failed to get environment variables' }, + { status: response.status } + ) + } + + const envData = await response.json() + + // Extract just the variable names (not values) for security + const variableNames = envData.data ? Object.keys(envData.data) : [] + + return NextResponse.json({ + success: true, + data: { + variableNames, + count: variableNames.length, + }, + }) + } catch (error) { + logger.error('Get environment variables API failed:', error) + return NextResponse.json( + { + success: false, + error: `Failed to get environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/tools/get-user-workflow/route.ts b/apps/sim/app/api/copilot/get-user-workflow/route.ts similarity index 94% rename from apps/sim/app/api/tools/get-user-workflow/route.ts rename to apps/sim/app/api/copilot/get-user-workflow/route.ts index 94889577acc..f8ed08149b7 100644 --- a/apps/sim/app/api/tools/get-user-workflow/route.ts +++ b/apps/sim/app/api/copilot/get-user-workflow/route.ts @@ -1,5 +1,6 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console-logger' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' @@ -11,6 +12,15 @@ const logger = createLogger('GetUserWorkflowAPI') export async function POST(request: NextRequest) { try { + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error }, + { status: 401 } + ) + } + const body = await request.json() const { workflowId, includeMetadata = false } = body @@ -21,7 +31,11 @@ export async function POST(request: NextRequest) { ) } - logger.info('Fetching user workflow', { workflowId }) + logger.info('Fetching user workflow', { + workflowId, + authType: authResult.authType, + userId: authResult.userId + }) // Fetch workflow from database const [workflowRecord] = await db diff --git a/apps/sim/app/api/tools/get-workflow-console/route.ts b/apps/sim/app/api/copilot/get-workflow-console/route.ts similarity index 100% rename from apps/sim/app/api/tools/get-workflow-console/route.ts rename to apps/sim/app/api/copilot/get-workflow-console/route.ts diff --git a/apps/sim/app/api/tools/get-workflow-examples/route.ts b/apps/sim/app/api/copilot/get-workflow-examples/route.ts similarity index 100% rename from apps/sim/app/api/tools/get-workflow-examples/route.ts rename to apps/sim/app/api/copilot/get-workflow-examples/route.ts diff --git a/apps/sim/app/api/tools/get-yaml-structure/route.ts b/apps/sim/app/api/copilot/get-yaml-structure/route.ts similarity index 100% rename from apps/sim/app/api/tools/get-yaml-structure/route.ts rename to apps/sim/app/api/copilot/get-yaml-structure/route.ts diff --git a/apps/sim/app/api/copilot/preview-workflow/route.ts b/apps/sim/app/api/copilot/preview-workflow/route.ts new file mode 100644 index 00000000000..c0b41e18bc7 --- /dev/null +++ b/apps/sim/app/api/copilot/preview-workflow/route.ts @@ -0,0 +1,91 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('PreviewWorkflowAPI') + +export async function POST(request: NextRequest) { + try { + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error }, + { status: 401 } + ) + } + + const body = await request.json() + const { yamlContent, description } = body + + if (!yamlContent) { + return NextResponse.json( + { success: false, error: 'yamlContent is required' }, + { status: 400 } + ) + } + + logger.info('Generating workflow preview for copilot', { + yamlLength: yamlContent.length, + description, + authType: authResult.authType, + userId: authResult.userId + }) + + // Forward the request to the existing workflow preview endpoint + const previewUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview` + + const response = await fetch(previewUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent, + applyAutoLayout: true, + }), + }) + + if (!response.ok) { + logger.error('Workflow preview API failed', { + status: response.status, + statusText: response.statusText + }) + return NextResponse.json( + { success: false, error: 'Workflow preview generation failed' }, + { status: response.status } + ) + } + + const previewData = await response.json() + + if (!previewData.success) { + return NextResponse.json( + { + success: false, + error: `Preview generation failed: ${previewData.message || 'Unknown error'}` + }, + { status: 400 } + ) + } + + // Return in the format expected by the copilot for diff functionality + return NextResponse.json({ + success: true, + data: { + ...previewData, + yamlContent, // Include the original YAML for diff functionality + description, + }, + }) + } catch (error) { + logger.error('Preview workflow API failed:', error) + return NextResponse.json( + { + success: false, + error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 7b7f6a79902..1f34251f829 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { checkHybridAuth } from '@/lib/auth/hybrid' import { createChat, deleteChat, @@ -92,13 +93,22 @@ export async function POST(req: NextRequest) { const requestId = crypto.randomUUID() try { + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(req) + if (!authResult.success) { + return NextResponse.json({ error: authResult.error }, { status: 401 }) + } + + // For routes that might not have userId (like internal calls without workflow context) + const userId = authResult.userId + const body = await req.json() const { message, chatId, workflowId, mode, createNewChat, stream, implicitFeedback } = SendMessageSchema.parse(body) - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // If no userId from auth, we need workflowId for internal calls + if (!userId && !workflowId) { + return NextResponse.json({ error: 'workflowId required for internal calls without user context' }, { status: 400 }) } logger.info(`[${requestId}] Copilot message: "${message}"`, { @@ -107,7 +117,8 @@ export async function POST(req: NextRequest) { mode, createNewChat, stream, - userId: session.user.id, + userId, + authType: authResult.authType, }) // Send message using the service @@ -119,7 +130,7 @@ export async function POST(req: NextRequest) { createNewChat, stream, implicitFeedback, - userId: session.user.id, + userId: userId || 'internal', // Use 'internal' for system calls without user context }) // Handle streaming response (ReadableStream or StreamingExecution) @@ -228,9 +239,15 @@ export async function POST(req: NextRequest) { */ export async function GET(req: NextRequest) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(req) + if (!authResult.success) { + return NextResponse.json({ error: authResult.error }, { status: 401 }) + } + + const userId = authResult.userId + if (!userId) { + return NextResponse.json({ error: 'User ID required for this operation' }, { status: 400 }) } const { searchParams } = new URL(req.url) @@ -238,7 +255,7 @@ export async function GET(req: NextRequest) { // If chatId is provided, get specific chat if (chatId) { - const chat = await getChat(chatId, session.user.id) + const chat = await getChat(chatId, userId) if (!chat) { return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) } @@ -261,7 +278,7 @@ export async function GET(req: NextRequest) { ) } - const chats = await listChats(session.user.id, workflowId, { limit, offset }) + const chats = await listChats(userId, workflowId, { limit, offset }) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/copilot/set-environment-variables/route.ts b/apps/sim/app/api/copilot/set-environment-variables/route.ts new file mode 100644 index 00000000000..491db1b32c5 --- /dev/null +++ b/apps/sim/app/api/copilot/set-environment-variables/route.ts @@ -0,0 +1,107 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('SetEnvironmentVariablesAPI') + +export async function POST(request: NextRequest) { + try { + // Check authentication (session, API key, or internal JWT) + const authResult = await checkHybridAuth(request) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error }, + { status: 401 } + ) + } + + // Ensure we have a user ID for this operation + if (!authResult.userId) { + return NextResponse.json( + { success: false, error: 'User ID required for environment variables access' }, + { status: 400 } + ) + } + + const body = await request.json() + const { variables } = body + + if (!variables || typeof variables !== 'object') { + return NextResponse.json( + { success: false, error: 'Variables object is required' }, + { status: 400 } + ) + } + + logger.info('Setting environment variables for copilot', { + variableCount: Object.keys(variables).length, + variableNames: Object.keys(variables), + authType: authResult.authType, + userId: authResult.userId + }) + + // Forward the request to the existing environment variables endpoint + const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` + + // Create headers for the forwarded request + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Forward authentication based on the original auth method + if (authResult.authType === 'api_key') { + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + headers['X-API-Key'] = apiKeyHeader + } + } else if (authResult.authType === 'internal_jwt') { + const authHeader = request.headers.get('authorization') + if (authHeader) { + headers['Authorization'] = authHeader + } + } else { + // For session auth, copy the cookies + const cookieHeader = request.headers.get('cookie') + if (cookieHeader) { + headers['Cookie'] = cookieHeader + } + } + + const response = await fetch(envUrl, { + method: 'PUT', + headers, + body: JSON.stringify({ variables }), + }) + + if (!response.ok) { + logger.error('Set environment variables API failed', { + status: response.status, + statusText: response.statusText + }) + return NextResponse.json( + { success: false, error: 'Failed to set environment variables' }, + { status: response.status } + ) + } + + const result = await response.json() + + return NextResponse.json({ + success: true, + data: { + message: 'Environment variables updated successfully', + updatedVariables: Object.keys(variables), + count: Object.keys(variables).length, + }, + }) + } catch (error) { + logger.error('Set environment variables API failed:', error) + return NextResponse.json( + { + success: false, + error: `Failed to set environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts index 83dd0c01724..a1292c3213a 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -1,14 +1,52 @@ import { type NextRequest, NextResponse } from 'next/server' +import { eq } from 'drizzle-orm' +import { getSession } from '@/lib/auth' import { executeCopilotTool } from '@/lib/copilot/tools' import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { apiKey as apiKeyTable } from '@/db/schema' const logger = createLogger('TargetedUpdatesAPI') export async function POST(request: NextRequest) { try { + // Try session auth first (for web UI) + const session = await getSession() + let authenticatedUserId: string | null = session?.user?.id || null + + // If no session, check for API key auth + if (!authenticatedUserId) { + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + // Verify API key + const [apiKeyRecord] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKeyHeader)) + .limit(1) + + if (apiKeyRecord) { + authenticatedUserId = apiKeyRecord.userId + } + } + } + + // Parse body early to check for workflowId const body = await request.json() const { operations, workflowId } = body + // If no authentication but workflowId is provided, allow internal calls + // This maintains backward compatibility for internal copilot tool calls + if (!authenticatedUserId) { + if (!workflowId) { + return NextResponse.json({ error: 'Unauthorized - authentication or workflowId required' }, { status: 401 }) + } + + // For internal calls without auth, we'll validate the workflow exists + // but won't enforce user ownership (as this was the original behavior) + logger.info('Allowing internal call to targeted-updates without authentication', { workflowId }) + } + if (!operations || !Array.isArray(operations)) { return NextResponse.json( { success: false, error: 'Operations array is required' }, @@ -25,6 +63,7 @@ export async function POST(request: NextRequest) { logger.info('Executing targeted updates', { workflowId, + userId: authenticatedUserId || 'internal_call', operationCount: operations.length, operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), }) diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 8f36987e606..060c99ad95d 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { db } from '@/db' -import { workflow } from '@/db/schema' +import { apiKey as apiKeyTable, workflow } from '@/db/schema' const logger = createLogger('WorkflowByIdAPI') @@ -47,13 +47,33 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // For internal calls, we'll skip user-specific access checks logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`) } else { - // Get the session for regular user calls + // Try session auth first (for web UI) const session = await getSession() - if (!session?.user?.id) { + let authenticatedUserId: string | null = session?.user?.id || null + + // If no session, check for API key auth + if (!authenticatedUserId) { + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + // Verify API key + const [apiKeyRecord] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKeyHeader)) + .limit(1) + + if (apiKeyRecord) { + authenticatedUserId = apiKeyRecord.userId + } + } + } + + if (!authenticatedUserId) { logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - userId = session.user.id + + userId = authenticatedUserId } // Fetch the workflow diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts new file mode 100644 index 00000000000..2e6594377d7 --- /dev/null +++ b/apps/sim/lib/auth/hybrid.ts @@ -0,0 +1,141 @@ +import { eq } from 'drizzle-orm' +import { type NextRequest } from 'next/server' +import { getSession } from '@/lib/auth' +import { verifyInternalToken } from '@/lib/auth/internal' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { apiKey as apiKeyTable, workflow } from '@/db/schema' + +const logger = createLogger('HybridAuth') + +export interface AuthResult { + success: boolean + userId?: string + authType?: 'session' | 'api_key' | 'internal_jwt' + error?: string +} + +/** + * Check for authentication using any of the 3 supported methods: + * 1. Session authentication (cookies) + * 2. API key authentication (X-API-Key header) + * 3. Internal JWT authentication (Authorization: Bearer header) + * + * For internal JWT calls, requires workflowId to determine user context + */ +export async function checkHybridAuth( + request: NextRequest, + options: { requireWorkflowId?: boolean } = {} +): Promise { + try { + // 1. Check for internal JWT token first + const authHeader = request.headers.get('authorization') + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.split(' ')[1] + const isInternalCall = await verifyInternalToken(token) + + if (isInternalCall) { + // For internal calls, we need workflowId to determine user context + let workflowId: string | null = null + + // Try to get workflowId from query params or request body + const { searchParams } = new URL(request.url) + workflowId = searchParams.get('workflowId') + + if (!workflowId && request.method === 'POST') { + try { + // Clone the request to avoid consuming the original body + const clonedRequest = request.clone() + const bodyText = await clonedRequest.text() + if (bodyText) { + const body = JSON.parse(bodyText) + workflowId = body.workflowId + } + } catch { + // Ignore JSON parse errors + } + } + + if (!workflowId && options.requireWorkflowId !== false) { + return { + success: false, + error: 'workflowId required for internal JWT calls' + } + } + + if (workflowId) { + // Get workflow owner as user context + const [workflowData] = await db + .select({ userId: workflow.userId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowData) { + return { + success: false, + error: 'Workflow not found' + } + } + + return { + success: true, + userId: workflowData.userId, + authType: 'internal_jwt' + } + } else { + // Internal call without workflow context - still valid for some routes + return { + success: true, + authType: 'internal_jwt' + } + } + } + } + + // 2. Try session auth (for web UI) + const session = await getSession() + if (session?.user?.id) { + return { + success: true, + userId: session.user.id, + authType: 'session' + } + } + + // 3. Try API key auth + const apiKeyHeader = request.headers.get('x-api-key') + if (apiKeyHeader) { + const [apiKeyRecord] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKeyHeader)) + .limit(1) + + if (apiKeyRecord) { + return { + success: true, + userId: apiKeyRecord.userId, + authType: 'api_key' + } + } else { + return { + success: false, + error: 'Invalid API key' + } + } + } + + // No authentication found + return { + success: false, + error: 'Authentication required - provide session, API key, or internal JWT' + } + } catch (error) { + logger.error('Error in hybrid authentication:', error) + return { + success: false, + error: 'Authentication error' + } + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/provider-bridge.ts b/apps/sim/lib/copilot/provider-bridge.ts new file mode 100644 index 00000000000..f56514f4ce3 --- /dev/null +++ b/apps/sim/lib/copilot/provider-bridge.ts @@ -0,0 +1,53 @@ +/** + * Bridge for providers to execute copilot tools without importing server-side dependencies + */ + +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('CopilotProviderBridge') + +/** + * Execute a copilot tool and return in ToolResponse format for providers + * This function avoids importing server-side dependencies by making an HTTP request + */ +export async function executeCopilotToolForProvider( + toolId: string, + params: Record +): Promise { + try { + // Make an HTTP request to execute the copilot tool + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/execute-tool`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + toolId, + params, + }), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Tool execution failed: ${response.status} ${response.statusText}`, + } + } + + const result = await response.json() + return { + success: result.success, + output: result.data, + error: result.error, + } + } catch (error) { + logger.error(`Copilot tool execution failed: ${toolId}`, error) + return { + success: false, + error: `Tool execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } +} \ No newline at end of file diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 5fa87214086..885b476d750 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -621,6 +621,7 @@ export async function generateChatResponse( workflowId: options.workflowId, chatId: options.chatId, userId: options.userId || 'unknown_user', // Pass userId to provider request + isCopilotRequest: true, // Flag to indicate this is from the copilot system }) // Handle StreamingExecution (from providers with tool calls) diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index 747c059bab8..fb1dd25ace3 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -372,16 +372,28 @@ const docsSearchTool: CopilotTool = { execute: async (args: Record): Promise => { try { const { query, topK = 10 } = args - const results = await searchDocumentation(query, { topK }) - return { - success: true, - data: { - results, - query, - totalResults: results.length, - }, + // Call the API route directly + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/docs-search-internal`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, topK }), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Documentation search failed: ${response.status} ${response.statusText}`, + } } + + const result = await response.json() + return result } catch (error) { logger.error('Documentation search failed', error) return { @@ -402,38 +414,41 @@ const getUserWorkflowTool: CopilotTool = { 'Get the current user workflow as YAML format. This shows all blocks, their configurations, inputs, and connections in the workflow.', parameters: { type: 'object', - properties: {}, + properties: { + includeMetadata: { + type: 'boolean', + description: 'Whether to include additional metadata about the workflow (default: false)', + default: false, + }, + }, required: [], }, execute: async (args: Record): Promise => { try { - // Get the current workflow YAML using the same logic as export - const yamlContent = useWorkflowYamlStore.getState().getYaml() - - // Get workflow metadata - const registry = useWorkflowRegistry.getState() - const activeWorkflowId = registry.activeWorkflowId - const activeWorkflow = activeWorkflowId ? registry.workflows[activeWorkflowId] : null - - let metadata: WorkflowMetadata | undefined - if (activeWorkflow && activeWorkflowId) { - metadata = { - workflowId: activeWorkflowId, - name: activeWorkflow.name || 'Untitled Workflow', - description: activeWorkflow.description, - workspaceId: activeWorkflow.workspaceId || '', + const { includeMetadata = false, _context } = args + const workflowId = _context?.workflowId + + // Call the API route directly + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-user-workflow`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ workflowId, includeMetadata }), } - } + ) - const data: UserWorkflowData = { - yaml: yamlContent, - metadata, + if (!response.ok) { + return { + success: false, + error: `Failed to get user workflow: ${response.status} ${response.statusText}`, + } } - return { - success: true, - data, - } + const result = await response.json() + return result } catch (error) { logger.error('Get user workflow failed', error) return { @@ -583,7 +598,7 @@ const targetedUpdatesTool: CopilotTool = { // Get current workflow YAML directly from the API endpoint (not the client-side store) const workflowResponse = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/tools/get-user-workflow`, + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-user-workflow`, { method: 'POST', headers: { @@ -677,18 +692,15 @@ const previewWorkflowTool: CopilotTool = { try { const { yamlContent, description } = args - // Make direct API call to workflow preview endpoint + // Call the API route directly const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview`, + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/preview-workflow`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ - yamlContent, - applyAutoLayout: true, - }), + body: JSON.stringify({ yamlContent, description }), } ) @@ -699,29 +711,296 @@ const previewWorkflowTool: CopilotTool = { } } - const previewData = await response.json() + const result = await response.json() + return result + } catch (error) { + logger.error('Preview workflow execution failed:', error) + return { + success: false, + error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } + }, +} + +/** + * Additional copilot tools + */ +const getBlocksAndToolsTool: CopilotTool = { + id: 'get_blocks_and_tools', + name: 'Get All Blocks and Tools', + description: 'Get a comprehensive list of all available blocks and tools in Sim Studio', + parameters: { + type: 'object', + properties: { + includeDetails: { + type: 'boolean', + description: 'Whether to include detailed information (default: false)', + default: false, + }, + filterCategory: { + type: 'string', + description: 'Optional category filter for blocks', + }, + }, + required: [], + }, + execute: async (args: Record): Promise => { + try { + const { includeDetails = false, filterCategory } = args + + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-blocks-and-tools`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ includeDetails, filterCategory }), + } + ) - if (!previewData.success) { + if (!response.ok) { return { success: false, - error: `Preview generation failed: ${previewData.message || 'Unknown error'}`, + error: `Failed to get blocks and tools: ${response.status} ${response.statusText}`, } } - // Return in the format expected by the UI for diff functionality + const result = await response.json() + return result + } catch (error) { return { - success: true, - data: { - ...previewData, - yamlContent, // Include the original YAML for diff functionality - description, - }, + success: false, + error: `Failed to get blocks and tools: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } + }, +} + +const getBlocksMetadataTool: CopilotTool = { + id: 'get_blocks_metadata', + name: 'Get Block Metadata', + description: 'Get detailed metadata for specific blocks', + parameters: { + type: 'object', + properties: { + blockIds: { + type: 'array', + items: { type: 'string' }, + description: 'Array of block IDs to get metadata for', + }, + }, + required: ['blockIds'], + }, + execute: async (args: Record): Promise => { + try { + const { blockIds } = args + + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-blocks-metadata`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ blockIds }), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Failed to get blocks metadata: ${response.status} ${response.statusText}`, + } } + + const result = await response.json() + return result } catch (error) { - logger.error('Preview workflow execution failed:', error) return { success: false, - error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: `Failed to get blocks metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } + }, +} + +const getYamlStructureTool: CopilotTool = { + id: 'get_yaml_structure', + name: 'Get YAML Structure Guide', + description: 'Get YAML workflow syntax guide and examples', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + execute: async (args: Record): Promise => { + try { + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-yaml-structure`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Failed to get YAML structure: ${response.status} ${response.statusText}`, + } + } + + const result = await response.json() + return result + } catch (error) { + return { + success: false, + error: `Failed to get YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } + }, +} + +const getEnvironmentVariablesTool: CopilotTool = { + id: 'get_environment_variables', + name: 'Get Environment Variables', + description: 'Get a list of available environment variable names', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + execute: async (args: Record): Promise => { + try { + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-environment-variables`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Failed to get environment variables: ${response.status} ${response.statusText}`, + } + } + + const result = await response.json() + return result + } catch (error) { + return { + success: false, + error: `Failed to get environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } + }, +} + +const setEnvironmentVariablesTool: CopilotTool = { + id: 'set_environment_variables', + name: 'Set Environment Variables', + description: 'Set or update environment variables', + parameters: { + type: 'object', + properties: { + variables: { + type: 'object', + description: 'Key-value object of environment variables to set', + }, + }, + required: ['variables'], + }, + execute: async (args: Record): Promise => { + try { + const { variables } = args + + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/set-environment-variables`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ variables }), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Failed to set environment variables: ${response.status} ${response.statusText}`, + } + } + + const result = await response.json() + return result + } catch (error) { + return { + success: false, + error: `Failed to set environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } + }, +} + +const getWorkflowConsoleTool: CopilotTool = { + id: 'get_workflow_console', + name: 'Get Workflow Console', + description: 'Get console logs and execution history from the workflow', + parameters: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of console entries to return (default: 50)', + default: 50, + }, + includeDetails: { + type: 'boolean', + description: 'Whether to include detailed input/output data (default: false)', + default: false, + }, + }, + required: [], + }, + execute: async (args: Record): Promise => { + try { + const { limit = 50, includeDetails = false } = args + + const response = await fetch( + `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-workflow-console`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ limit, includeDetails }), + } + ) + + if (!response.ok) { + return { + success: false, + error: `Failed to get workflow console: ${response.status} ${response.statusText}`, + } + } + + const result = await response.json() + return result + } catch (error) { + return { + success: false, + error: `Failed to get workflow console: ${error instanceof Error ? error.message : 'Unknown error'}`, } } }, @@ -734,8 +1013,14 @@ const copilotTools: Record = { docs_search_internal: docsSearchTool, get_user_workflow: getUserWorkflowTool, get_workflow_examples: getWorkflowExamplesTool, - targeted_updates: targetedUpdatesTool, + get_blocks_and_tools: getBlocksAndToolsTool, + get_blocks_metadata: getBlocksMetadataTool, + get_yaml_structure: getYamlStructureTool, preview_workflow: previewWorkflowTool, + targeted_updates: targetedUpdatesTool, + get_environment_variables: getEnvironmentVariablesTool, + set_environment_variables: setEnvironmentVariablesTool, + get_workflow_console: getWorkflowConsoleTool, } /** @@ -780,3 +1065,4 @@ export async function executeCopilotTool( export function getAllCopilotTools(): CopilotTool[] { return Object.values(copilotTools) } + diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index c8aa82654f3..64ad714182d 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -414,7 +414,16 @@ ${fieldDescriptions} : {}), } - const result = await executeTool(toolCall.name, mergedArgs, true) + // Choose tool execution method based on request type + let result + if (request.isCopilotRequest) { + // Use copilot tool system for copilot requests + const { executeCopilotToolForProvider } = await import('@/lib/copilot/provider-bridge') + result = await executeCopilotToolForProvider(toolCall.name, mergedArgs) + } else { + // Use general tool system for regular requests + result = await executeTool(toolCall.name, mergedArgs, true) + } const toolCallEndTime = Date.now() logger.info(`Tool ${toolCall.name} ${result.success ? 'succeeded' : 'failed'}`) @@ -784,7 +793,16 @@ ${fieldDescriptions} : {}), } - const result = await executeTool(toolName, executionParams, true) + // Choose tool execution method based on request type + let result + if (request.isCopilotRequest) { + // Use copilot tool system for copilot requests + const { executeCopilotToolForProvider } = await import('@/lib/copilot/provider-bridge') + result = await executeCopilotToolForProvider(toolName, executionParams) + } else { + // Use general tool system for regular requests + result = await executeTool(toolName, executionParams, true) + } const toolCallEndTime = Date.now() const toolCallDuration = toolCallEndTime - toolCallStartTime @@ -1221,7 +1239,16 @@ ${fieldDescriptions} ...(request.environmentVariables ? { envVars: request.environmentVariables } : {}), } - const result = await executeTool(toolName, executionParams, true) + // Choose tool execution method based on request type + let result + if (request.isCopilotRequest) { + // Use copilot tool system for copilot requests + const { executeCopilotToolForProvider } = await import('@/lib/copilot/provider-bridge') + result = await executeCopilotToolForProvider(toolName, executionParams) + } else { + // Use general tool system for regular requests + result = await executeTool(toolName, executionParams, true) + } const toolCallEndTime = Date.now() const toolCallDuration = toolCallEndTime - toolCallStartTime diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index d95f603bd59..72ad3a4ef03 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -152,6 +152,7 @@ export interface ProviderRequest { stream?: boolean streamToolCalls?: boolean // Whether to stream tool call responses back to user (default: false) environmentVariables?: Record // Environment variables for tool execution + isCopilotRequest?: boolean // Flag to indicate this request is from the copilot system // Azure OpenAI specific parameters azureEndpoint?: string azureApiVersion?: string diff --git a/apps/sim/tools/blocks/edit-workflow.ts b/apps/sim/tools/blocks/edit-workflow.ts deleted file mode 100644 index 36a9d8ea636..00000000000 --- a/apps/sim/tools/blocks/edit-workflow.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { ToolConfig, ToolResponse } from '../types' - -interface EditWorkflowParams { - yamlContent: string - description?: string - _context?: { - workflowId: string - chatId?: string - } -} - -interface EditWorkflowResponse extends ToolResponse { - output: { - success: boolean - message: string - summary?: string - errors: string[] - warnings: string[] - data?: { - blocksCount: number - edgesCount: number - loopsCount: number - parallelsCount: number - } - } -} - -export const editWorkflowTool: ToolConfig = { - id: 'edit_workflow', - name: 'Edit Workflow', - description: - 'Save/edit the current workflow by providing YAML content. This performs the same action as saving in the YAML code editor. Only call this after getting blocks info, metadata, and YAML structure guide.', - version: '1.0.0', - - params: { - yamlContent: { - type: 'string', - required: true, - description: 'The complete YAML workflow content to save', - }, - description: { - type: 'string', - required: false, - description: 'Optional description of the changes being made', - }, - }, - - request: { - url: (params) => `/api/workflows/${params._context?.workflowId}/yaml`, - method: 'PUT', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - yamlContent: params.yamlContent, - description: params.description, - chatId: params._context?.chatId, - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: true, // Always create checkpoints for copilot edits - }), - isInternalRoute: true, - }, - - transformResponse: async (response: Response): Promise => { - if (!response.ok) { - throw new Error(`Edit workflow failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.message || 'Failed to edit workflow') - } - - return { - success: true, - output: data, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Failed to edit workflow: ${error.message}` - } - return 'An unexpected error occurred while editing the workflow' - }, -} diff --git a/apps/sim/tools/blocks/get-all.ts b/apps/sim/tools/blocks/get-all.ts deleted file mode 100644 index b020b2cd0ac..00000000000 --- a/apps/sim/tools/blocks/get-all.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ToolConfig, ToolResponse } from '../types' - -interface GetAllBlocksParams { - includeDetails?: boolean - filterCategory?: string -} - -interface GetAllBlocksResult { - blockToToolsMapping: Record -} - -interface GetAllBlocksResponse extends ToolResponse { - output: GetAllBlocksResult -} - -export const getAllBlocksTool: ToolConfig = { - id: 'get_blocks_and_tools', - name: 'Get All Blocks and Tools', - description: - 'Get a comprehensive list of all available blocks and tools in Sim Studio with their descriptions, categories, and capabilities', - version: '1.0.0', - - params: { - includeDetails: { - type: 'boolean', - required: false, - description: - 'Whether to include detailed information like inputs, outputs, and sub-blocks (default: false)', - }, - filterCategory: { - type: 'string', - required: false, - description: 'Optional category filter for blocks (e.g., "tools", "blocks", "ai")', - }, - }, - - request: { - url: '/api/tools/get-all-blocks', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - includeDetails: params.includeDetails || false, - filterCategory: params.filterCategory, - }), - isInternalRoute: true, - }, - - transformResponse: async ( - response: Response, - params?: GetAllBlocksParams - ): Promise => { - if (!response.ok) { - throw new Error(`Get all blocks failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Failed to get blocks and tools') - } - - return { - success: true, - output: { - blockToToolsMapping: data.data, - }, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Failed to get blocks and tools: ${error.message}` - } - return 'An unexpected error occurred while getting blocks and tools' - }, -} diff --git a/apps/sim/tools/blocks/get-metadata.ts b/apps/sim/tools/blocks/get-metadata.ts deleted file mode 100644 index dfcb029a91e..00000000000 --- a/apps/sim/tools/blocks/get-metadata.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ToolConfig, ToolResponse } from '../types' - -interface GetBlockMetadataParams { - blockIds: string[] -} - -interface BlockMetadataInfo { - type: 'block' | 'tool' - description: string - longDescription?: string - category: string - docsLink?: string - // For core blocks with YAML documentation - yamlSchema?: string - // For tool blocks or fallback - inputs?: Record - outputs?: Record - subBlocks?: any[] - toolSchemas?: Record< - string, - { - id: string - name: string - description: string - version?: string - params?: Record - request?: { - method: string - url: string - headers?: any - isInternalRoute?: boolean - } - } - > - // Actual schemas from block code configuration - codeSchemas?: { - inputs?: Record - outputs?: Record - subBlocks?: any[] - } -} - -interface GetBlockMetadataResult { - [blockId: string]: BlockMetadataInfo -} - -interface GetBlockMetadataResponse extends ToolResponse { - output: GetBlockMetadataResult -} - -export const getBlockMetadataTool: ToolConfig = { - id: 'get_blocks_metadata', - name: 'Get Block Metadata', - description: - 'Get detailed metadata for specific blocks. Returns both documentation (YAML schemas) and actual code schemas (inputs, outputs, subBlocks). For core blocks (agent, function, api, etc.), includes YAML schema documentation from docs. For tool blocks, includes tool schema information with parameters and API details. All blocks include precise code schemas from their configuration.', - version: '1.0.0', - - params: { - blockIds: { - type: 'array', - required: true, - description: 'Array of block IDs to get descriptions for', - }, - }, - - request: { - url: '/api/tools/get-blocks-metadata', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - blockIds: params.blockIds, - }), - isInternalRoute: true, - }, - - transformResponse: async ( - response: Response, - params?: GetBlockMetadataParams - ): Promise => { - if (!response.ok) { - throw new Error(`Get block metadata failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Failed to get block metadata') - } - - return { - success: true, - output: data.data, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Failed to get block metadata: ${error.message}` - } - return 'An unexpected error occurred while getting block metadata' - }, -} diff --git a/apps/sim/tools/blocks/get-yaml-structure.ts b/apps/sim/tools/blocks/get-yaml-structure.ts deleted file mode 100644 index cd3dc483acf..00000000000 --- a/apps/sim/tools/blocks/get-yaml-structure.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ToolConfig, ToolResponse } from '../types' - -type GetYamlStructureParams = Record - -interface GetYamlStructureResult { - guide: string - message: string -} - -interface GetYamlStructureResponse extends ToolResponse { - output: GetYamlStructureResult -} - -export const getYamlStructureTool: ToolConfig = { - id: 'get_yaml_structure', - name: 'Get YAML Workflow Structure Guide', - description: - 'Get comprehensive YAML workflow syntax guide and examples to understand how to structure Sim Studio workflows', - version: '1.0.0', - - params: {}, - - request: { - url: '/api/tools/get-yaml-structure', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: () => ({}), - isInternalRoute: true, - }, - - transformResponse: async (response: Response): Promise => { - if (!response.ok) { - throw new Error(`Get YAML structure failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Failed to get YAML structure guide') - } - - return { - success: true, - output: data.data, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Failed to get YAML structure guide: ${error.message}` - } - return 'An unexpected error occurred while getting YAML structure guide' - }, -} diff --git a/apps/sim/tools/blocks/preview-workflow.ts b/apps/sim/tools/blocks/preview-workflow.ts deleted file mode 100644 index 89a9b874de7..00000000000 --- a/apps/sim/tools/blocks/preview-workflow.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { ToolConfig } from '../types' - -interface PreviewWorkflowParams { - yamlContent: string - description?: string - _context?: { - workflowId?: string - chatId?: string - } -} - -interface PreviewWorkflowResponse { - success: boolean - output: { - success: boolean - workflowState?: any - message?: string - summary?: string - data?: { - blocksCount: number - edgesCount: number - loopsCount: number - parallelsCount: number - } - errors?: string[] - warnings?: string[] - } -} - -export const previewWorkflowTool: ToolConfig = { - id: 'preview_workflow', - name: 'Preview Workflow', - description: - 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. Always use this instead of directly editing when showing workflow proposals.', - version: '1.0.0', - - params: { - yamlContent: { - type: 'string', - required: true, - description: 'The complete YAML workflow content to preview', - }, - description: { - type: 'string', - required: false, - description: 'Optional description of the proposed changes', - }, - }, - - request: { - url: () => '/api/workflows/preview', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - yamlContent: params.yamlContent, - applyAutoLayout: true, // Always apply auto layout for previews - }), - isInternalRoute: true, - }, - - transformResponse: async (response: Response): Promise => { - if (!response.ok) { - throw new Error(`Preview workflow failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.message || 'Failed to preview workflow') - } - - return { - success: true, - output: data, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Failed to preview workflow: ${error.message}` - } - return 'An unexpected error occurred while previewing the workflow' - }, -} diff --git a/apps/sim/tools/docs/search.ts b/apps/sim/tools/docs/search.ts deleted file mode 100644 index b6bdeed5496..00000000000 --- a/apps/sim/tools/docs/search.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface DocsSearchParams { - query: string - topK?: number -} - -interface DocsSearchResult { - id: string - title: string - content: string - url: string - score: number - metadata?: Record -} - -interface DocsSearchResponse extends ToolResponse { - output: { - results: DocsSearchResult[] - query: string - totalResults: number - searchTime: number - } -} - -export const docsSearchTool: ToolConfig = { - id: 'docs_search_internal', - name: 'Search Documentation', - description: - 'Search Sim Studio documentation for information about features, tools, workflows, and functionality', - version: '1.0.0', - - params: { - query: { - type: 'string', - required: true, - description: 'The search query to find relevant documentation', - }, - topK: { - type: 'number', - required: false, - description: 'Number of results to return (default: 10, max: 20)', - }, - }, - - request: { - url: '/api/docs/search', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => { - // Validate and clamp topK parameter - let topK = params.topK || 10 - if (topK > 20) topK = 20 - if (topK < 1) topK = 1 - - return { - query: params.query, - topK, - } - }, - isInternalRoute: true, - }, - - transformResponse: async ( - response: Response, - params?: DocsSearchParams - ): Promise => { - if (!response.ok) { - throw new Error(`Docs search failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - // Validate and transform the API response - const results: DocsSearchResult[] = (data.results || []).map((result: any) => ({ - id: result.id || '', - title: result.title || 'Untitled', - content: result.content || '', - url: result.url || '', - score: typeof result.score === 'number' ? result.score : 0, - metadata: result.metadata || {}, - })) - - return { - success: true, - output: { - results, - query: params?.query || '', - totalResults: results.length, - searchTime: data.searchTime || 0, - }, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Documentation search failed: ${error.message}` - } - return 'An unexpected error occurred while searching documentation' - }, -} diff --git a/apps/sim/tools/environment/get-variables.ts b/apps/sim/tools/environment/get-variables.ts deleted file mode 100644 index dab82160e09..00000000000 --- a/apps/sim/tools/environment/get-variables.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface GetEnvironmentVariablesParams { - _context?: { - workflowId: string - } -} - -export interface GetEnvironmentVariablesResponse extends ToolResponse { - output: { - variableNames: string[] - count: number - } -} - -export const getEnvironmentVariablesTool: ToolConfig< - GetEnvironmentVariablesParams, - GetEnvironmentVariablesResponse -> = { - id: 'get_environment_variables', - name: 'Get Environment Variables', - description: - 'Get a list of available environment variable names that the user has configured. Returns only the variable names, not their values.', - version: '1.0.0', - - params: {}, - - request: { - url: '/api/environment/variables', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - workflowId: params._context?.workflowId, - }), - isInternalRoute: true, - }, -} diff --git a/apps/sim/tools/environment/set-variables.ts b/apps/sim/tools/environment/set-variables.ts deleted file mode 100644 index a5051d9cb29..00000000000 --- a/apps/sim/tools/environment/set-variables.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface SetEnvironmentVariablesParams { - variables: Record - _context?: { - workflowId: string - } -} - -export interface SetEnvironmentVariablesResponse extends ToolResponse { - output: { - message: string - variableCount: number - variableNames: string[] - } -} - -export const setEnvironmentVariablesTool: ToolConfig< - SetEnvironmentVariablesParams, - SetEnvironmentVariablesResponse -> = { - id: 'set_environment_variables', - name: 'Set Environment Variables', - description: - 'Set or update environment variables that can be used in workflows. New variables will be added, and existing variables with the same names will be updated. Other existing variables will be preserved.', - version: '1.0.0', - - params: { - variables: { - type: 'json', - required: true, - visibility: 'user-or-llm', - description: - 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', - }, - }, - - request: { - url: '/api/environment/variables', - method: 'PUT', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - variables: params.variables, - workflowId: params._context?.workflowId, - }), - isInternalRoute: true, - }, - - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok) { - throw new Error(data.error || 'Failed to set environment variables') - } - - return { - success: true, - output: data.output, - } - }, - - transformError: (error: any) => { - return `Failed to set environment variables: ${error.message || 'Unknown error'}` - }, -} diff --git a/apps/sim/tools/utils.ts b/apps/sim/tools/utils.ts index b16cf690905..bbe718e31ad 100644 --- a/apps/sim/tools/utils.ts +++ b/apps/sim/tools/utils.ts @@ -2,37 +2,17 @@ import { createLogger } from '@/lib/logs/console-logger' import { getBaseUrl } from '@/lib/urls/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' -import { getAllBlocksTool } from '@/tools/blocks/get-all' -import { getBlockMetadataTool } from '@/tools/blocks/get-metadata' -import { getYamlStructureTool } from '@/tools/blocks/get-yaml-structure' -// import { editWorkflowTool } from '@/tools/blocks/edit-workflow' // Commented out - only preview is allowed -import { previewWorkflowTool } from '@/tools/blocks/preview-workflow' -import { docsSearchTool } from '@/tools/docs/search' -import { getEnvironmentVariablesTool } from '@/tools/environment/get-variables' -import { setEnvironmentVariablesTool } from '@/tools/environment/set-variables' +// Copilot-specific tools are now handled in @/lib/copilot/tools.ts import { tools } from '@/tools/registry' import type { TableRow, ToolConfig, ToolResponse } from '@/tools/types' -import { getWorkflowConsoleTool } from '@/tools/workflow/get-console' -import { getWorkflowExamplesTool } from '@/tools/workflow/get-examples' -import { getUserWorkflowTool } from '@/tools/workflow/get-yaml' -import { targetedUpdatesTool } from '@/tools/workflow/targeted-updates' +// Workflow tools moved to copilot system const logger = createLogger('ToolsUtils') // Internal-only tools (not exposed to users in workflows) +// Note: All copilot-specific tools are now handled in @/lib/copilot/tools.ts const internalTools: Record = { - docs_search_internal: docsSearchTool, - get_user_workflow: getUserWorkflowTool, - get_workflow_console: getWorkflowConsoleTool, - get_workflow_examples: getWorkflowExamplesTool, - get_blocks_and_tools: getAllBlocksTool, - get_blocks_metadata: getBlockMetadataTool, - get_yaml_structure: getYamlStructureTool, - get_environment_variables: getEnvironmentVariablesTool, - set_environment_variables: setEnvironmentVariablesTool, - // edit_workflow: editWorkflowTool, // Commented out - only preview is allowed - targeted_updates: targetedUpdatesTool, - preview_workflow: previewWorkflowTool, + // No internal tools remain - all have been moved to copilot system } // Export the list of internal tool IDs for filtering purposes diff --git a/apps/sim/tools/workflow/get-console.ts b/apps/sim/tools/workflow/get-console.ts deleted file mode 100644 index 18482a0e55a..00000000000 --- a/apps/sim/tools/workflow/get-console.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ToolConfig } from '@/tools/types' - -interface GetConsoleParams { - limit?: number - includeDetails?: boolean - _context?: { - workflowId: string - } -} - -interface GetConsoleResponse { - entries: Array<{ - id: string - executionId: string - level?: string - message?: string - trigger?: string - startedAt: string - endedAt: string | null - durationMs: number | null - blockCount?: number - successCount?: number - errorCount?: number - totalCost?: number | null - type: 'execution' | 'block' - // Block-specific fields (when includeDetails=true) - blockId?: string - blockName?: string - blockType?: string - status?: string - success?: boolean - error?: string | null - input?: any - output?: any - cost?: number | null - tokens?: number | null - }> - totalEntries: number - workflowId: string - retrievedAt: string - hasBlockDetails: boolean -} - -export const getWorkflowConsoleTool: ToolConfig = { - id: 'get_workflow_console', - name: 'Get Workflow Console Logs', - description: - 'Get console logs and execution history from the current workflow. Returns recent execution logs including block inputs, outputs, execution times, costs, and any errors from workflow runs.', - version: '1.0.0', - - params: { - limit: { - type: 'number', - required: false, - description: 'Maximum number of console entries to return (default: 50, max: 100)', - }, - includeDetails: { - type: 'boolean', - required: false, - description: - 'Whether to include detailed block-level logs for the most recent execution (default: false)', - }, - }, - - // Use API endpoint to access database from server side - request: { - url: '/api/tools/get-workflow-console', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - workflowId: params._context?.workflowId, - limit: params.limit || 50, - includeDetails: params.includeDetails || false, - }), - isInternalRoute: true, - }, -} diff --git a/apps/sim/tools/workflow/get-examples.ts b/apps/sim/tools/workflow/get-examples.ts deleted file mode 100644 index fa70606d600..00000000000 --- a/apps/sim/tools/workflow/get-examples.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ToolConfig, ToolResponse } from '../types' - -interface GetWorkflowExamplesParams { - exampleIds: string[] -} - -interface GetWorkflowExamplesResult { - examples: Record - notFound: string[] - availableIds: string[] -} - -interface GetWorkflowExamplesResponse extends ToolResponse { - output: GetWorkflowExamplesResult -} - -export const getWorkflowExamplesTool: ToolConfig< - GetWorkflowExamplesParams, - GetWorkflowExamplesResponse -> = { - id: 'get_workflow_examples', - name: 'Getting relevant examples', - description: 'Get YAML workflow examples by ID to reference when building workflows', - version: '1.0.0', - - params: { - exampleIds: { - type: 'array', - required: true, - description: 'Array of example IDs to retrieve', - }, - }, - - request: { - url: '/api/tools/get-workflow-examples', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - exampleIds: params.exampleIds, - }), - isInternalRoute: true, - }, - - transformResponse: async (response: Response): Promise => { - if (!response.ok) { - throw new Error(`Get workflow examples failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Failed to get workflow examples') - } - - return { - success: true, - output: data.data, - } - }, - - transformError: (error: any): string => { - console.error('Get workflow examples error:', error) - return `Failed to get workflow examples: ${error.message || 'Unknown error'}` - }, -} diff --git a/apps/sim/tools/workflow/get-yaml.ts b/apps/sim/tools/workflow/get-yaml.ts deleted file mode 100644 index 40af7713cf6..00000000000 --- a/apps/sim/tools/workflow/get-yaml.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface GetWorkflowParams { - includeMetadata?: boolean - _context?: { - workflowId: string - } -} - -interface GetWorkflowResponse extends ToolResponse { - output: { - yaml: string - metadata?: { - blockCount: number - connectionCount: number - lastModified: string - } - } -} - -export const getUserWorkflowTool: ToolConfig = { - id: 'get_user_workflow', - name: 'Get User Workflow', - description: - "Get the current user's specific workflow (not general Sim Studio documentation). Returns YAML format showing only the blocks that the user has actually built in their workflow, with their specific configurations, inputs, and connections.", - version: '1.0.0', - - params: { - includeMetadata: { - type: 'boolean', - required: false, - description: 'Whether to include additional metadata about the workflow (default: false)', - }, - }, - - // Use API endpoint to avoid Node.js module import issues in browser - request: { - url: '/api/tools/get-user-workflow', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - workflowId: params._context?.workflowId, - includeMetadata: params.includeMetadata || false, - }), - isInternalRoute: true, - }, -} diff --git a/apps/sim/tools/workflow/targeted-updates.ts b/apps/sim/tools/workflow/targeted-updates.ts deleted file mode 100644 index 5eaf799b4bb..00000000000 --- a/apps/sim/tools/workflow/targeted-updates.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ToolConfig } from '@/tools/types' - -interface TargetedUpdatesParams { - operations: Array<{ - operation_type: 'add' | 'edit' | 'delete' - block_id: string - params?: any - }> - _context?: { - workflowId?: string - } -} - -interface TargetedUpdatesResponse { - success: boolean - output: { - results: Array<{ - operation: any - success: boolean - error?: string - }> - processedOperations: number - blockIdMapping?: Record - failedOperations?: Array<{ - operation: any - success: boolean - error?: string - }> - } -} - -export const targetedUpdatesTool: ToolConfig = { - id: 'targeted_updates', - name: 'Targeted Updates', - description: - 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Allows precise modifications to specific blocks without affecting the entire workflow.', - version: '1.0.0', - - params: { - operations: { - type: 'array', - required: true, - description: 'Array of targeted update operations to perform', - }, - }, - - request: { - url: '/api/copilot/targeted-updates', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - operations: params.operations, - workflowId: params._context?.workflowId, - }), - isInternalRoute: true, - }, - - transformResponse: async ( - response: Response, - params?: TargetedUpdatesParams - ): Promise => { - if (!response.ok) { - throw new Error(`Targeted updates failed: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Targeted updates failed') - } - - return { - success: true, - output: data.data || { - results: [], - processedOperations: 0, - }, - } - }, - - transformError: (error: any): string => { - if (error instanceof Error) { - return `Targeted updates failed: ${error.message}` - } - return 'An unexpected error occurred while performing targeted updates' - }, -} From fabb69f54878c3a8cc9ca685011730a53355d7b4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 19:23:45 -0700 Subject: [PATCH 083/184] First refactor complete --- .../copilot/checkpoints/[id]/revert/route.ts | 187 -------- apps/sim/app/api/copilot/checkpoints/route.ts | 121 ----- .../api/copilot/docs-search-internal/route.ts | 101 ++-- .../sim/app/api/copilot/execute-tool/route.ts | 37 -- .../api/copilot/get-blocks-and-tools/route.ts | 140 +++--- .../get-environment-variables/route.ts | 108 +---- .../api/copilot/get-user-workflow/route.ts | 353 ++++++-------- .../api/copilot/get-workflow-console/route.ts | 185 +++---- .../copilot/get-workflow-examples/route.ts | 64 +-- .../app/api/copilot/preview-workflow/route.ts | 121 ++--- apps/sim/app/api/copilot/route.ts | 452 +++--------------- .../set-environment-variables/route.ts | 129 ++--- apps/sim/app/api/test-auth/route.ts | 6 +- .../components/control-bar/control-bar.tsx | 5 - apps/sim/lib/sim-agent/client.ts | 24 +- 15 files changed, 514 insertions(+), 1519 deletions(-) delete mode 100644 apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts delete mode 100644 apps/sim/app/api/copilot/checkpoints/route.ts delete mode 100644 apps/sim/app/api/copilot/execute-tool/route.ts diff --git a/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts deleted file mode 100644 index e372cba95dd..00000000000 --- a/apps/sim/app/api/copilot/checkpoints/[id]/revert/route.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { verifyInternalToken } from '@/lib/auth/internal' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { apiKey as apiKeyTable, copilotCheckpoints, workflow as workflowTable } from '@/db/schema' - -const logger = createLogger('RevertCheckpointAPI') - -/** - * POST /api/copilot/checkpoints/[id]/revert - * Revert workflow to a specific checkpoint - */ -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = crypto.randomUUID().slice(0, 8) - const checkpointId = (await params).id - - try { - // Check for internal JWT token for server-side calls - const authHeader = request.headers.get('authorization') - let isInternalCall = false - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1] - isInternalCall = await verifyInternalToken(token) - } - - let authenticatedUserId: string | null = null - - if (isInternalCall) { - // For internal calls, get the checkpoint owner as the user context - const [checkpointData] = await db - .select({ userId: copilotCheckpoints.userId }) - .from(copilotCheckpoints) - .where(eq(copilotCheckpoints.id, checkpointId)) - .limit(1) - - if (!checkpointData) { - return NextResponse.json({ error: 'Checkpoint not found' }, { status: 404 }) - } - authenticatedUserId = checkpointData.userId - } else { - // Try session auth first (for web UI) - const session = await getSession() - authenticatedUserId = session?.user?.id || null - - // If no session, check for API key auth - if (!authenticatedUserId) { - const apiKeyHeader = request.headers.get('x-api-key') - if (apiKeyHeader) { - // Verify API key - const [apiKeyRecord] = await db - .select({ userId: apiKeyTable.userId }) - .from(apiKeyTable) - .where(eq(apiKeyTable.key, apiKeyHeader)) - .limit(1) - - if (apiKeyRecord) { - authenticatedUserId = apiKeyRecord.userId - } - } - } - - if (!authenticatedUserId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - } - - // TypeScript assertion: authenticatedUserId is guaranteed non-null at this point - const userId = authenticatedUserId as string - - logger.info(`[${requestId}] Reverting to checkpoint: ${checkpointId}`, { - userId, - }) - - // Get the checkpoint - const checkpoint = await db - .select() - .from(copilotCheckpoints) - .where( - and(eq(copilotCheckpoints.id, checkpointId), eq(copilotCheckpoints.userId, userId)) - ) - .limit(1) - - if (!checkpoint.length) { - return NextResponse.json({ error: 'Checkpoint not found' }, { status: 404 }) - } - - const checkpointData = checkpoint[0] - const { workflowId, yaml: yamlContent } = checkpointData - - logger.info(`[${requestId}] Processing checkpoint revert`, { - workflowId, - yamlLength: yamlContent.length, - }) - - // Use the consolidated YAML endpoint instead of duplicating the processing logic - const yamlEndpointUrl = `${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/workflows/${workflowId}/yaml` - - const yamlResponse = await fetch(yamlEndpointUrl, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - // Forward auth cookies from the original request - Cookie: request.headers.get('Cookie') || '', - }, - body: JSON.stringify({ - yamlContent, - description: `Reverted to checkpoint from ${new Date(checkpointData.createdAt).toLocaleString()}`, - source: 'checkpoint_revert', - applyAutoLayout: true, - createCheckpoint: false, // Don't create a checkpoint when reverting to one - }), - }) - - if (!yamlResponse.ok) { - const errorData = await yamlResponse.json() - logger.error(`[${requestId}] Consolidated YAML endpoint failed:`, errorData) - return NextResponse.json( - { - success: false, - error: 'Failed to revert checkpoint via YAML endpoint', - details: errorData.errors || [errorData.error || 'Unknown error'], - }, - { status: yamlResponse.status } - ) - } - - const yamlResult = await yamlResponse.json() - - if (!yamlResult.success) { - logger.error(`[${requestId}] YAML endpoint returned failure:`, yamlResult) - return NextResponse.json( - { - success: false, - error: 'Failed to process checkpoint YAML', - details: yamlResult.errors || ['Unknown error'], - }, - { status: 400 } - ) - } - - // Update workflow's lastSynced timestamp - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowTable.id, workflowId)) - - // Notify the socket server to tell clients to rehydrate stores from database - try { - const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002' - await fetch(`${socketUrl}/api/copilot-workflow-edit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - description: `Reverted to checkpoint from ${new Date(checkpointData.createdAt).toLocaleString()}`, - }), - }) - logger.info(`[${requestId}] Notified socket server of checkpoint revert`) - } catch (socketError) { - logger.warn(`[${requestId}] Failed to notify socket server:`, socketError) - } - - logger.info(`[${requestId}] Successfully reverted to checkpoint`) - - return NextResponse.json({ - success: true, - message: `Successfully reverted to checkpoint from ${new Date(checkpointData.createdAt).toLocaleString()}`, - summary: yamlResult.summary || `Restored workflow from checkpoint.`, - warnings: yamlResult.warnings || [], - data: yamlResult.data, - }) - } catch (error) { - logger.error(`[${requestId}] Error reverting checkpoint:`, error) - return NextResponse.json( - { - error: `Failed to revert checkpoint: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts deleted file mode 100644 index 2c4ce64f49d..00000000000 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { and, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { verifyInternalToken } from '@/lib/auth/internal' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { apiKey as apiKeyTable, copilotCheckpoints, workflow } from '@/db/schema' - -const logger = createLogger('CopilotCheckpointsAPI') - -/** - * GET /api/copilot/checkpoints - * List checkpoints for a specific chat - */ -export async function GET(request: NextRequest) { - const requestId = crypto.randomUUID() - - try { - // Check for internal JWT token for server-side calls - const authHeader = request.headers.get('authorization') - let isInternalCall = false - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1] - isInternalCall = await verifyInternalToken(token) - } - - let authenticatedUserId: string | null = null - - if (isInternalCall) { - // For internal calls, we need chatId to determine context - const { searchParams } = new URL(request.url) - const chatId = searchParams.get('chatId') - - if (!chatId) { - return NextResponse.json({ error: 'chatId required for internal calls' }, { status: 400 }) - } - - // Get the first checkpoint for this chat to determine the user - const [firstCheckpoint] = await db - .select({ userId: copilotCheckpoints.userId }) - .from(copilotCheckpoints) - .where(eq(copilotCheckpoints.chatId, chatId)) - .limit(1) - - if (!firstCheckpoint) { - return NextResponse.json({ error: 'No checkpoints found for chat' }, { status: 404 }) - } - authenticatedUserId = firstCheckpoint.userId - } else { - // Try session auth first (for web UI) - const session = await getSession() - authenticatedUserId = session?.user?.id || null - - // If no session, check for API key auth - if (!authenticatedUserId) { - const apiKeyHeader = request.headers.get('x-api-key') - if (apiKeyHeader) { - // Verify API key - const [apiKeyRecord] = await db - .select({ userId: apiKeyTable.userId }) - .from(apiKeyTable) - .where(eq(apiKeyTable.key, apiKeyHeader)) - .limit(1) - - if (apiKeyRecord) { - authenticatedUserId = apiKeyRecord.userId - } - } - } - - if (!authenticatedUserId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - } - - // TypeScript assertion: authenticatedUserId is guaranteed non-null at this point - const userId = authenticatedUserId as string - - const { searchParams } = new URL(request.url) - const chatId = searchParams.get('chatId') - const limit = Number(searchParams.get('limit')) || 10 - const offset = Number(searchParams.get('offset')) || 0 - - if (!chatId) { - return NextResponse.json({ error: 'chatId is required' }, { status: 400 }) - } - - logger.info(`[${requestId}] Listing checkpoints for chat: ${chatId}`, { - userId, - limit, - offset, - }) - - const checkpoints = await db - .select() - .from(copilotCheckpoints) - .where( - and(eq(copilotCheckpoints.userId, userId), eq(copilotCheckpoints.chatId, chatId)) - ) - .orderBy(desc(copilotCheckpoints.createdAt)) - .limit(limit) - .offset(offset) - - // Format timestamps to ISO strings for consistent timezone handling - const formattedCheckpoints = checkpoints.map((checkpoint) => ({ - id: checkpoint.id, - userId: checkpoint.userId, - workflowId: checkpoint.workflowId, - chatId: checkpoint.chatId, - yaml: checkpoint.yaml, - createdAt: checkpoint.createdAt.toISOString(), - updatedAt: checkpoint.updatedAt.toISOString(), - })) - - return NextResponse.json({ checkpoints: formattedCheckpoints }) - } catch (error) { - logger.error(`[${requestId}] Error listing checkpoints:`, error) - return NextResponse.json({ error: 'Failed to list checkpoints' }, { status: 500 }) - } -} diff --git a/apps/sim/app/api/copilot/docs-search-internal/route.ts b/apps/sim/app/api/copilot/docs-search-internal/route.ts index 803f2480196..2e67f20dcd0 100644 --- a/apps/sim/app/api/copilot/docs-search-internal/route.ts +++ b/apps/sim/app/api/copilot/docs-search-internal/route.ts @@ -1,77 +1,46 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('DocsSearchInternalAPI') -export async function POST(request: NextRequest) { - try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(request) - if (!authResult.success) { - return NextResponse.json( - { success: false, error: authResult.error }, - { status: 401 } - ) - } +export async function docsSearchInternal(params: any) { + const { query, topK = 10 } = params - const body = await request.json() - const { query, topK = 10 } = body - - if (!query) { - return NextResponse.json( - { success: false, error: 'Query is required' }, - { status: 400 } - ) - } - - logger.info('Executing docs search for copilot', { - query, - topK, - authType: authResult.authType, - userId: authResult.userId - }) + if (!query) { + throw new Error('Query is required') + } - // Forward the request to the existing docs search endpoint - const docsSearchUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/docs/search` - - const response = await fetch(docsSearchUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query, topK }), + logger.info('Executing docs search for copilot', { + query, + topK, + }) + + // Forward the request to the existing docs search endpoint + const docsSearchUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/docs/search` + + const response = await fetch(docsSearchUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, topK }), + }) + + if (!response.ok) { + logger.error('Docs search API failed', { + status: response.status, + statusText: response.statusText }) + throw new Error('Documentation search failed') + } - if (!response.ok) { - logger.error('Docs search API failed', { - status: response.status, - statusText: response.statusText - }) - return NextResponse.json( - { success: false, error: 'Documentation search failed' }, - { status: response.status } - ) - } - - const searchResults = await response.json() + const searchResults = await response.json() - return NextResponse.json({ - success: true, - data: { - results: searchResults.results || [], - query, - totalResults: searchResults.totalResults || 0, - }, - }) - } catch (error) { - logger.error('Documentation search API failed:', error) - return NextResponse.json( - { - success: false, - error: `Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + return { + success: true, + data: { + results: searchResults.results || [], + query, + totalResults: searchResults.totalResults || 0, + }, } } \ No newline at end of file diff --git a/apps/sim/app/api/copilot/execute-tool/route.ts b/apps/sim/app/api/copilot/execute-tool/route.ts deleted file mode 100644 index 1911e087239..00000000000 --- a/apps/sim/app/api/copilot/execute-tool/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { executeCopilotTool } from '@/lib/copilot/tools' - -const logger = createLogger('ExecuteCopilotToolAPI') - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { toolId, params } = body - - if (!toolId) { - return NextResponse.json( - { - success: false, - error: 'toolId is required', - }, - { status: 400 } - ) - } - - logger.info('Executing copilot tool', { toolId }) - - const result = await executeCopilotTool(toolId, params || {}) - - return NextResponse.json(result) - } catch (error) { - logger.error('Failed to execute copilot tool', error) - return NextResponse.json( - { - success: false, - error: `Failed to execute copilot tool: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts b/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts index eafe504e9ad..c943ade3e1a 100644 --- a/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts +++ b/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts @@ -1,93 +1,83 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' import { registry as blockRegistry } from '@/blocks/registry' const logger = createLogger('GetAllBlocksAPI') -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { includeDetails = false, filterCategory } = body +export async function getBlocksAndTools(params: any) { + const { includeDetails = false, filterCategory } = params - logger.info('Getting all blocks and tools', { includeDetails, filterCategory }) + logger.info('Getting all blocks and tools', { + includeDetails, + filterCategory, + }) - // Create mapping of block_id -> [tool_ids] - const blockToToolsMapping: Record = {} + // Create mapping of block_id -> [tool_ids] + const blockToToolsMapping: Record = {} - // Process blocks - filter out hidden blocks and map to their tools - Object.entries(blockRegistry) - .filter(([blockType, blockConfig]) => { - // Filter out hidden blocks - if (blockConfig.hideFromToolbar) return false + // Process blocks - filter out hidden blocks and map to their tools + Object.entries(blockRegistry) + .filter(([blockType, blockConfig]) => { + // Filter out hidden blocks + if (blockConfig.hideFromToolbar) return false - // Apply category filter if specified - if (filterCategory && blockConfig.category !== filterCategory) return false + // Apply category filter if specified + if (filterCategory && blockConfig.category !== filterCategory) return false - return true - }) - .forEach(([blockType, blockConfig]) => { - // Get the tools for this block - const blockTools = blockConfig.tools?.access || [] - blockToToolsMapping[blockType] = blockTools - }) + return true + }) + .forEach(([blockType, blockConfig]) => { + // Get the tools for this block + const blockTools = blockConfig.tools?.access || [] + blockToToolsMapping[blockType] = blockTools + }) - // Add special blocks that aren't in the standard registry - // Loop and parallel blocks are handled differently but should be available - const specialBlocks = { - loop: { - tools: [], // Loop blocks don't use standard tools - category: 'blocks', - description: 'Control flow block for iterating over collections or repeating actions', - }, - parallel: { - tools: [], // Parallel blocks don't use standard tools - category: 'blocks', - description: 'Control flow block for executing multiple branches simultaneously', - }, - } + // Add special blocks that aren't in the standard registry + // Loop and parallel blocks are handled differently but should be available + const specialBlocks = { + loop: { + tools: [], // Loop blocks don't use standard tools + category: 'blocks', + description: 'Control flow block for iterating over collections or repeating actions', + }, + parallel: { + tools: [], // Parallel blocks don't use standard tools + category: 'blocks', + description: 'Control flow block for executing multiple branches simultaneously', + }, + } - // Add special blocks if they pass the category filter - Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { - if (!filterCategory || blockInfo.category === filterCategory) { - blockToToolsMapping[blockType] = blockInfo.tools - } - }) + // Add special blocks if they pass the category filter + Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { + if (!filterCategory || blockInfo.category === filterCategory) { + blockToToolsMapping[blockType] = blockInfo.tools + } + }) - const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length - const includedBlocks = Object.keys(blockToToolsMapping).length - const filteredBlocksCount = totalBlocks - includedBlocks + const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length + const includedBlocks = Object.keys(blockToToolsMapping).length + const filteredBlocksCount = totalBlocks - includedBlocks - // Log block to tools mapping for debugging - const blockToolsInfo = Object.entries(blockToToolsMapping) - .map(([blockType, tools]) => `${blockType}: [${tools.join(', ')}]`) - .sort() + // Log block to tools mapping for debugging + const blockToolsInfo = Object.entries(blockToToolsMapping) + .map(([blockType, tools]) => `${blockType}: [${tools.join(', ')}]`) + .sort() - logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, { - totalBlocks, - includedBlocks, - filteredBlocks: filteredBlocksCount, - filterCategory, - blockToolsMapping: blockToolsInfo, - outputMapping: blockToToolsMapping, - specialBlocksAdded: Object.keys(specialBlocks).filter( - (blockType) => - !filterCategory || - specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory - ), - }) + logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, { + totalBlocks, + includedBlocks, + filteredBlocks: filteredBlocksCount, + filterCategory, + blockToolsMapping: blockToolsInfo, + outputMapping: blockToToolsMapping, + specialBlocksAdded: Object.keys(specialBlocks).filter( + (blockType) => + !filterCategory || + specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory + ), + }) - return NextResponse.json({ - success: true, - data: blockToToolsMapping, - }) - } catch (error) { - logger.error('Get all blocks failed', error) - return NextResponse.json( - { - success: false, - error: `Failed to get blocks and tools: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + return { + success: true, + data: blockToToolsMapping, } } diff --git a/apps/sim/app/api/copilot/get-environment-variables/route.ts b/apps/sim/app/api/copilot/get-environment-variables/route.ts index 52777ff25c4..741bc3b25e7 100644 --- a/apps/sim/app/api/copilot/get-environment-variables/route.ts +++ b/apps/sim/app/api/copilot/get-environment-variables/route.ts @@ -1,96 +1,38 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('GetEnvironmentVariablesAPI') -export async function POST(request: NextRequest) { - try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(request) - if (!authResult.success) { - return NextResponse.json( - { success: false, error: authResult.error }, - { status: 401 } - ) - } +export async function getEnvironmentVariables(params: any) { + logger.info('Getting environment variables for copilot') - // Ensure we have a user ID for this operation - if (!authResult.userId) { - return NextResponse.json( - { success: false, error: 'User ID required for environment variables access' }, - { status: 400 } - ) - } - - logger.info('Getting environment variables for copilot', { - authType: authResult.authType, - userId: authResult.userId - }) - - // Forward the request to the existing environment variables endpoint - const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` - - // Create headers for the forwarded request - const headers: Record = { + // Forward the request to the existing environment variables endpoint + const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` + + const response = await fetch(envUrl, { + method: 'GET', + headers: { 'Content-Type': 'application/json', - } + }, + }) - // Forward authentication based on the original auth method - if (authResult.authType === 'api_key') { - const apiKeyHeader = request.headers.get('x-api-key') - if (apiKeyHeader) { - headers['X-API-Key'] = apiKeyHeader - } - } else if (authResult.authType === 'internal_jwt') { - const authHeader = request.headers.get('authorization') - if (authHeader) { - headers['Authorization'] = authHeader - } - } else { - // For session auth, copy the cookies - const cookieHeader = request.headers.get('cookie') - if (cookieHeader) { - headers['Cookie'] = cookieHeader - } - } - - const response = await fetch(envUrl, { - method: 'GET', - headers, + if (!response.ok) { + logger.error('Environment variables API failed', { + status: response.status, + statusText: response.statusText }) + throw new Error('Failed to get environment variables') + } - if (!response.ok) { - logger.error('Environment variables API failed', { - status: response.status, - statusText: response.statusText - }) - return NextResponse.json( - { success: false, error: 'Failed to get environment variables' }, - { status: response.status } - ) - } - - const envData = await response.json() + const envData = await response.json() - // Extract just the variable names (not values) for security - const variableNames = envData.data ? Object.keys(envData.data) : [] + // Extract just the variable names (not values) for security + const variableNames = envData.data ? Object.keys(envData.data) : [] - return NextResponse.json({ - success: true, - data: { - variableNames, - count: variableNames.length, - }, - }) - } catch (error) { - logger.error('Get environment variables API failed:', error) - return NextResponse.json( - { - success: false, - error: `Failed to get environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + return { + success: true, + data: { + variableNames, + count: variableNames.length, + }, } } \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-user-workflow/route.ts b/apps/sim/app/api/copilot/get-user-workflow/route.ts index f8ed08149b7..0e5ce981f52 100644 --- a/apps/sim/app/api/copilot/get-user-workflow/route.ts +++ b/apps/sim/app/api/copilot/get-user-workflow/route.ts @@ -1,6 +1,4 @@ import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console-logger' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' @@ -10,218 +8,181 @@ import { workflow as workflowTable } from '@/db/schema' const logger = createLogger('GetUserWorkflowAPI') -export async function POST(request: NextRequest) { - try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(request) - if (!authResult.success) { - return NextResponse.json( - { success: false, error: authResult.error }, - { status: 401 } - ) - } +export async function getUserWorkflow(params: any) { + const { workflowId, includeMetadata = false } = params - const body = await request.json() - const { workflowId, includeMetadata = false } = body + if (!workflowId) { + throw new Error('Workflow ID is required') + } - if (!workflowId) { - return NextResponse.json( - { success: false, error: 'Workflow ID is required' }, - { status: 400 } - ) - } + logger.info('Fetching user workflow', { workflowId }) - logger.info('Fetching user workflow', { - workflowId, - authType: authResult.authType, - userId: authResult.userId - }) + // Fetch workflow from database + const [workflowRecord] = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) - // Fetch workflow from database - const [workflowRecord] = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - - if (!workflowRecord) { - return NextResponse.json( - { success: false, error: `Workflow ${workflowId} not found` }, - { status: 404 } - ) - } + if (!workflowRecord) { + throw new Error(`Workflow ${workflowId} not found`) + } - // Try to load from normalized tables first, fallback to JSON blob - let workflowState: any = null - const subBlockValues: Record> = {} - - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) - if (normalizedData) { - workflowState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - } + // Try to load from normalized tables first, fallback to JSON blob + let workflowState: any = null + const subBlockValues: Record> = {} + + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (normalizedData) { + workflowState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + } - // Extract subblock values from normalized data - Object.entries(normalizedData.blocks).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if ((subBlock as any).value !== undefined) { - subBlockValues[blockId][subBlockId] = (subBlock as any).value - } - }) + // Extract subblock values from normalized data + Object.entries(normalizedData.blocks).forEach(([blockId, block]) => { + subBlockValues[blockId] = {} + Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if ((subBlock as any).value !== undefined) { + subBlockValues[blockId][subBlockId] = (subBlock as any).value + } }) - } else if (workflowRecord.state) { - // Fallback to JSON blob - workflowState = workflowRecord.state as any - // For JSON blob, subblock values are embedded in the block state - Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if ((subBlock as any).value !== undefined) { - subBlockValues[blockId][subBlockId] = (subBlock as any).value - } - }) + }) + } else if (workflowRecord.state) { + // Fallback to JSON blob + workflowState = workflowRecord.state as any + // For JSON blob, subblock values are embedded in the block state + Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => { + subBlockValues[blockId] = {} + Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if ((subBlock as any).value !== undefined) { + subBlockValues[blockId][subBlockId] = (subBlock as any).value + } }) - } - - if (!workflowState || !workflowState.blocks) { - return NextResponse.json( - { success: false, error: 'Workflow state is empty or invalid' }, - { status: 400 } - ) - } + }) + } - // Generate YAML using server-side function - const yaml = generateWorkflowYaml(workflowState, subBlockValues) + if (!workflowState || !workflowState.blocks) { + throw new Error('Workflow state is empty or invalid') + } - if (!yaml || yaml.trim() === '') { - return NextResponse.json( - { success: false, error: 'Generated YAML is empty' }, - { status: 400 } - ) - } + // Generate YAML using server-side function + const yaml = generateWorkflowYaml(workflowState, subBlockValues) - // Generate detailed block information with schemas - const blockSchemas: Record = {} - Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => { - const block = blockState as any - const blockConfig = getBlock(block.type) - - if (blockConfig) { - blockSchemas[blockId] = { - type: block.type, - name: block.name, - description: blockConfig.description, - longDescription: blockConfig.longDescription, - category: blockConfig.category, - docsLink: blockConfig.docsLink, - inputs: {}, - inputRequirements: blockConfig.inputs || {}, - outputs: blockConfig.outputs || {}, - tools: blockConfig.tools, - } + if (!yaml || yaml.trim() === '') { + throw new Error('Generated YAML is empty') + } - // Add input schema from subBlocks configuration - if (blockConfig.subBlocks) { - blockConfig.subBlocks.forEach((subBlock) => { - blockSchemas[blockId].inputs[subBlock.id] = { - type: subBlock.type, - title: subBlock.title, - description: subBlock.description || '', - layout: subBlock.layout, - ...(subBlock.options && { options: subBlock.options }), - ...(subBlock.placeholder && { placeholder: subBlock.placeholder }), - ...(subBlock.min !== undefined && { min: subBlock.min }), - ...(subBlock.max !== undefined && { max: subBlock.max }), - ...(subBlock.columns && { columns: subBlock.columns }), - ...(subBlock.hidden !== undefined && { hidden: subBlock.hidden }), - ...(subBlock.condition && { condition: subBlock.condition }), - } - }) - } - } else { - // Handle special block types like loops and parallels - blockSchemas[blockId] = { - type: block.type, - name: block.name, - description: `${block.type.charAt(0).toUpperCase() + block.type.slice(1)} container block`, - category: 'Control Flow', - inputs: {}, - outputs: {}, - } + // Generate detailed block information with schemas + const blockSchemas: Record = {} + Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => { + const block = blockState as any + const blockConfig = getBlock(block.type) + + if (blockConfig) { + blockSchemas[blockId] = { + type: block.type, + name: block.name, + description: blockConfig.description, + longDescription: blockConfig.longDescription, + category: blockConfig.category, + docsLink: blockConfig.docsLink, + inputs: {}, + inputRequirements: blockConfig.inputs || {}, + outputs: blockConfig.outputs || {}, + tools: blockConfig.tools, } - }) - // Generate workflow summary - const blockTypes = Object.values(workflowState.blocks).reduce( - (acc: Record, block: any) => { - acc[block.type] = (acc[block.type] || 0) + 1 - return acc - }, - {} - ) - - const categories = Object.values(blockSchemas).reduce( - (acc: Record, schema: any) => { - if (schema.category) { - acc[schema.category] = (acc[schema.category] || 0) + 1 - } - return acc - }, - {} - ) - - // Prepare response with clear context markers - const response: any = { - workflowContext: 'USER_SPECIFIC_WORKFLOW', // Clear marker for the LLM - note: 'This data represents only the blocks and configurations that the user has actually built in their current workflow, not all available Sim Studio capabilities.', - yaml, - format: 'yaml', - summary: { - workflowName: workflowRecord.name, - blockCount: Object.keys(workflowState.blocks).length, - edgeCount: (workflowState.edges || []).length, - blockTypes, - categories, - hasLoops: Object.keys(workflowState.loops || {}).length > 0, - hasParallels: Object.keys(workflowState.parallels || {}).length > 0, - }, - userBuiltBlocks: blockSchemas, // Renamed to be clearer + // Add input schema from subBlocks configuration + if (blockConfig.subBlocks) { + blockConfig.subBlocks.forEach((subBlock) => { + blockSchemas[blockId].inputs[subBlock.id] = { + type: subBlock.type, + title: subBlock.title, + description: subBlock.description || '', + layout: subBlock.layout, + ...(subBlock.options && { options: subBlock.options }), + ...(subBlock.placeholder && { placeholder: subBlock.placeholder }), + ...(subBlock.min !== undefined && { min: subBlock.min }), + ...(subBlock.max !== undefined && { max: subBlock.max }), + ...(subBlock.columns && { columns: subBlock.columns }), + ...(subBlock.hidden !== undefined && { hidden: subBlock.hidden }), + ...(subBlock.condition && { condition: subBlock.condition }), + } + }) + } + } else { + // Handle special block types like loops and parallels + blockSchemas[blockId] = { + type: block.type, + name: block.name, + description: `${block.type.charAt(0).toUpperCase() + block.type.slice(1)} container block`, + category: 'Control Flow', + inputs: {}, + outputs: {}, + } } - - // Add metadata if requested - if (includeMetadata) { - response.metadata = { - workflowId: workflowRecord.id, - name: workflowRecord.name, - description: workflowRecord.description, - workspaceId: workflowRecord.workspaceId, - createdAt: workflowRecord.createdAt, - updatedAt: workflowRecord.updatedAt, + }) + + // Generate workflow summary + const blockTypes = Object.values(workflowState.blocks).reduce( + (acc: Record, block: any) => { + acc[block.type] = (acc[block.type] || 0) + 1 + return acc + }, + {} + ) + + const categories = Object.values(blockSchemas).reduce( + (acc: Record, schema: any) => { + if (schema.category) { + acc[schema.category] = (acc[schema.category] || 0) + 1 } + return acc + }, + {} + ) + + // Prepare response with clear context markers + const response: any = { + workflowContext: 'USER_SPECIFIC_WORKFLOW', + note: 'This data represents only the blocks and configurations that the user has actually built in their current workflow, not all available Sim Studio capabilities.', + yaml, + format: 'yaml', + summary: { + workflowName: workflowRecord.name, + blockCount: Object.keys(workflowState.blocks).length, + edgeCount: (workflowState.edges || []).length, + blockTypes, + categories, + hasLoops: Object.keys(workflowState.loops || {}).length > 0, + hasParallels: Object.keys(workflowState.parallels || {}).length > 0, + }, + userBuiltBlocks: blockSchemas, + } + + // Add metadata if requested + if (includeMetadata) { + response.metadata = { + workflowId: workflowRecord.id, + name: workflowRecord.name, + description: workflowRecord.description, + workspaceId: workflowRecord.workspaceId, + createdAt: workflowRecord.createdAt, + updatedAt: workflowRecord.updatedAt, } + } - logger.info('Successfully fetched user workflow YAML', { - workflowId, - blockCount: response.summary.blockCount, - yamlLength: yaml.length, - }) + logger.info('Successfully fetched user workflow YAML', { + workflowId, + blockCount: response.summary.blockCount, + yamlLength: yaml.length, + }) - return NextResponse.json({ - success: true, - output: response, - }) - } catch (error) { - logger.error('Failed to get workflow YAML:', error) - return NextResponse.json( - { - success: false, - error: `Failed to get workflow YAML: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + return { + success: true, + output: response, } } diff --git a/apps/sim/app/api/copilot/get-workflow-console/route.ts b/apps/sim/app/api/copilot/get-workflow-console/route.ts index 92896cc3a5a..ba5994fc96c 100644 --- a/apps/sim/app/api/copilot/get-workflow-console/route.ts +++ b/apps/sim/app/api/copilot/get-workflow-console/route.ts @@ -1,145 +1,74 @@ import { desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { workflowExecutionBlocks, workflowExecutionLogs } from '@/db/schema' +import { workflowExecutionLogs } from '@/db/schema' const logger = createLogger('GetWorkflowConsoleAPI') -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { workflowId, limit = 50, includeDetails = false } = body +export async function getWorkflowConsole(params: any) { + const { workflowId, limit = 50, includeDetails = false } = params - if (!workflowId) { - return NextResponse.json( - { success: false, error: 'Workflow ID is required' }, - { status: 400 } - ) - } - - logger.info('Fetching workflow console logs', { workflowId, limit, includeDetails }) - - // Get recent execution logs for the workflow - const executionLogs = await db - .select({ - id: workflowExecutionLogs.id, - executionId: workflowExecutionLogs.executionId, - level: workflowExecutionLogs.level, - message: workflowExecutionLogs.message, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - blockCount: workflowExecutionLogs.blockCount, - successCount: workflowExecutionLogs.successCount, - errorCount: workflowExecutionLogs.errorCount, - totalCost: workflowExecutionLogs.totalCost, - metadata: workflowExecutionLogs.metadata, - }) - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.workflowId, workflowId)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(Math.min(limit, 100)) - - let blockLogs: any[] = [] - - // If we have execution logs and details are requested, get block-level logs - if (executionLogs.length > 0 && includeDetails) { - const executionIds = executionLogs.map((log) => log.executionId) - - blockLogs = await db - .select({ - id: workflowExecutionBlocks.id, - executionId: workflowExecutionBlocks.executionId, - blockId: workflowExecutionBlocks.blockId, - blockName: workflowExecutionBlocks.blockName, - blockType: workflowExecutionBlocks.blockType, - status: workflowExecutionBlocks.status, - errorMessage: workflowExecutionBlocks.errorMessage, - startedAt: workflowExecutionBlocks.startedAt, - endedAt: workflowExecutionBlocks.endedAt, - durationMs: workflowExecutionBlocks.durationMs, - inputData: workflowExecutionBlocks.inputData, - outputData: workflowExecutionBlocks.outputData, - costTotal: workflowExecutionBlocks.costTotal, - tokensTotal: workflowExecutionBlocks.tokensTotal, - }) - .from(workflowExecutionBlocks) - .where(eq(workflowExecutionBlocks.executionId, executionIds[0])) // Get blocks for the most recent execution - .orderBy(desc(workflowExecutionBlocks.startedAt)) - } - - // Format the response - const formattedEntries = executionLogs.map((log) => { - const entry: any = { - id: log.id, - executionId: log.executionId, - level: log.level, - message: log.message, - trigger: log.trigger, - startedAt: log.startedAt, - endedAt: log.endedAt, - durationMs: log.totalDurationMs, - blockCount: log.blockCount, - successCount: log.successCount, - errorCount: log.errorCount, - totalCost: log.totalCost ? Number.parseFloat(log.totalCost.toString()) : null, - type: 'execution', - } + if (!workflowId) { + throw new Error('Workflow ID is required') + } - if (log.metadata) { - entry.metadata = log.metadata - } + logger.info('Fetching workflow console logs', { workflowId, limit, includeDetails }) - return entry + // Get recent execution logs for the workflow + const executionLogs = await db + .select({ + id: workflowExecutionLogs.id, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + message: workflowExecutionLogs.message, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + blockCount: workflowExecutionLogs.blockCount, + successCount: workflowExecutionLogs.successCount, + errorCount: workflowExecutionLogs.errorCount, + totalCost: workflowExecutionLogs.totalCost, + metadata: workflowExecutionLogs.metadata, }) + .from(workflowExecutionLogs) + .where(eq(workflowExecutionLogs.workflowId, workflowId)) + .orderBy(desc(workflowExecutionLogs.startedAt)) + .limit(Math.min(limit, 100)) - // Add block logs to the most recent execution if details are requested - if (includeDetails && blockLogs.length > 0) { - const blockEntries = blockLogs.map((block) => ({ - id: block.id, - executionId: block.executionId, - blockId: block.blockId, - blockName: block.blockName, - blockType: block.blockType, - status: block.status, - success: block.status === 'success', - error: block.errorMessage, - startedAt: block.startedAt, - endedAt: block.endedAt, - durationMs: block.durationMs, - input: block.inputData, - output: block.outputData, - cost: block.costTotal ? Number.parseFloat(block.costTotal.toString()) : null, - tokens: block.tokensTotal, - type: 'block', - })) - - // Add block entries to the response - formattedEntries.push(...blockEntries) + // Format the response + const formattedEntries = executionLogs.map((log) => { + const entry: any = { + id: log.id, + executionId: log.executionId, + level: log.level, + message: log.message, + trigger: log.trigger, + startedAt: log.startedAt, + endedAt: log.endedAt, + durationMs: log.totalDurationMs, + blockCount: log.blockCount, + successCount: log.successCount, + errorCount: log.errorCount, + totalCost: log.totalCost ? Number.parseFloat(log.totalCost.toString()) : null, + type: 'execution', } - const response = { - success: true, - data: { - entries: formattedEntries, - totalEntries: formattedEntries.length, - workflowId, - retrievedAt: new Date().toISOString(), - hasBlockDetails: includeDetails && blockLogs.length > 0, - }, + if (log.metadata) { + entry.metadata = log.metadata } - return NextResponse.json(response) - } catch (error) { - logger.error('Failed to get workflow console logs:', error) - return NextResponse.json( - { - success: false, - error: `Failed to get console logs: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + return entry + }) + + return { + success: true, + data: { + entries: formattedEntries, + totalEntries: formattedEntries.length, + workflowId, + retrievedAt: new Date().toISOString(), + hasBlockDetails: false, + }, } } diff --git a/apps/sim/app/api/copilot/get-workflow-examples/route.ts b/apps/sim/app/api/copilot/get-workflow-examples/route.ts index 455a6befaa1..62ead3b7455 100644 --- a/apps/sim/app/api/copilot/get-workflow-examples/route.ts +++ b/apps/sim/app/api/copilot/get-workflow-examples/route.ts @@ -1,50 +1,34 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' import { WORKFLOW_EXAMPLES } from '../../../../lib/copilot/examples' -export async function POST(request: NextRequest) { - try { - console.log('[get-workflow-examples] API endpoint called') +const logger = createLogger('GetWorkflowExamplesAPI') - const body = await request.json() - const { exampleIds } = body +export async function getWorkflowExamples(params: any) { + logger.info('Getting workflow examples for copilot') - if (!Array.isArray(exampleIds)) { - return NextResponse.json( - { - success: false, - error: 'exampleIds must be an array', - }, - { status: 400 } - ) - } + const { exampleIds } = params + + if (!Array.isArray(exampleIds)) { + throw new Error('exampleIds must be an array') + } - const examples: Record = {} - const notFound: string[] = [] + const examples: Record = {} + const notFound: string[] = [] - for (const id of exampleIds) { - if (WORKFLOW_EXAMPLES[id]) { - examples[id] = WORKFLOW_EXAMPLES[id] - } else { - notFound.push(id) - } + for (const id of exampleIds) { + if (WORKFLOW_EXAMPLES[id]) { + examples[id] = WORKFLOW_EXAMPLES[id] + } else { + notFound.push(id) } + } - return NextResponse.json({ - success: true, - data: { - examples, - notFound, - availableIds: Object.keys(WORKFLOW_EXAMPLES), - }, - }) - } catch (error) { - console.error('[get-workflow-examples] Error:', error) - return NextResponse.json( - { - success: false, - error: 'Failed to get workflow examples', - }, - { status: 500 } - ) + return { + success: true, + data: { + examples, + notFound, + availableIds: Object.keys(WORKFLOW_EXAMPLES), + }, } } diff --git a/apps/sim/app/api/copilot/preview-workflow/route.ts b/apps/sim/app/api/copilot/preview-workflow/route.ts index c0b41e18bc7..05b39b89f3e 100644 --- a/apps/sim/app/api/copilot/preview-workflow/route.ts +++ b/apps/sim/app/api/copilot/preview-workflow/route.ts @@ -1,91 +1,54 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('PreviewWorkflowAPI') -export async function POST(request: NextRequest) { - try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(request) - if (!authResult.success) { - return NextResponse.json( - { success: false, error: authResult.error }, - { status: 401 } - ) - } +export async function previewWorkflow(params: any) { + const { yamlContent, description } = params - const body = await request.json() - const { yamlContent, description } = body - - if (!yamlContent) { - return NextResponse.json( - { success: false, error: 'yamlContent is required' }, - { status: 400 } - ) - } - - logger.info('Generating workflow preview for copilot', { - yamlLength: yamlContent.length, - description, - authType: authResult.authType, - userId: authResult.userId - }) + if (!yamlContent) { + throw new Error('yamlContent is required') + } - // Forward the request to the existing workflow preview endpoint - const previewUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview` - - const response = await fetch(previewUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent, - applyAutoLayout: true, - }), + logger.info('Generating workflow preview for copilot', { + yamlLength: yamlContent.length, + description, + }) + + // Forward the request to the existing workflow preview endpoint + const previewUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview` + + const response = await fetch(previewUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent, + applyAutoLayout: true, + }), + }) + + if (!response.ok) { + logger.error('Workflow preview API failed', { + status: response.status, + statusText: response.statusText }) + throw new Error('Workflow preview generation failed') + } - if (!response.ok) { - logger.error('Workflow preview API failed', { - status: response.status, - statusText: response.statusText - }) - return NextResponse.json( - { success: false, error: 'Workflow preview generation failed' }, - { status: response.status } - ) - } - - const previewData = await response.json() + const previewData = await response.json() - if (!previewData.success) { - return NextResponse.json( - { - success: false, - error: `Preview generation failed: ${previewData.message || 'Unknown error'}` - }, - { status: 400 } - ) - } + if (!previewData.success) { + throw new Error(`Preview generation failed: ${previewData.message || 'Unknown error'}`) + } - // Return in the format expected by the copilot for diff functionality - return NextResponse.json({ - success: true, - data: { - ...previewData, - yamlContent, // Include the original YAML for diff functionality - description, - }, - }) - } catch (error) { - logger.error('Preview workflow API failed:', error) - return NextResponse.json( - { - success: false, - error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + // Return in the format expected by the copilot for diff functionality + return { + success: true, + data: { + ...previewData, + yamlContent, // Include the original YAML for diff functionality + description, + }, } } \ No newline at end of file diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 1f34251f829..8f6b9249825 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -1,322 +1,94 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { checkHybridAuth } from '@/lib/auth/hybrid' -import { - createChat, - deleteChat, - generateChatTitle, - getChat, - listChats, - sendMessage, - updateChat, -} from '@/lib/copilot/service' import { createLogger } from '@/lib/logs/console-logger' +import { getBlocksAndTools } from './get-blocks-and-tools/route' +import { getWorkflowExamples } from './get-workflow-examples/route' +import { setEnvironmentVariables } from './set-environment-variables/route' +import { getEnvironmentVariables } from './get-environment-variables/route' +import { previewWorkflow } from './preview-workflow/route' +import { docsSearchInternal } from './docs-search-internal/route' +import { getWorkflowConsole } from './get-workflow-console/route' +import { getUserWorkflow } from './get-user-workflow/route' const logger = createLogger('CopilotAPI') -// Interface for StreamingExecution response -interface StreamingExecution { - stream: ReadableStream - execution: Promise -} - -// Schema for sending messages -const SendMessageSchema = z.object({ - message: z.string().min(1, 'Message is required'), - chatId: z.string().optional(), - workflowId: z.string().optional(), - mode: z.enum(['ask', 'agent']).optional().default('ask'), - createNewChat: z.boolean().optional().default(false), - stream: z.boolean().optional().default(false), - implicitFeedback: z.string().optional(), -}) - -// Schema for docs queries -const DocsQuerySchema = z.object({ - query: z.string().min(1, 'Query is required'), - topK: z.number().min(1).max(20).default(5), - provider: z.string().optional(), - model: z.string().optional(), - stream: z.boolean().optional().default(false), - chatId: z.string().optional(), - workflowId: z.string().optional(), - createNewChat: z.boolean().optional().default(false), +// Schema for method execution +const MethodExecutionSchema = z.object({ + methodId: z.string().min(1, 'Method ID is required'), + params: z.record(z.any()).optional().default({}), }) -// Schema for creating chats -const CreateChatSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - title: z.string().optional(), - initialMessage: z.string().optional(), -}) - -// Schema for updating chats -const UpdateChatSchema = z.object({ - chatId: z.string().min(1, 'Chat ID is required'), - messages: z - .array( - z.object({ - id: z.string(), - role: z.enum(['user', 'assistant', 'system']), - content: z.string(), - timestamp: z.string(), - citations: z - .array( - z.object({ - id: z.number(), - title: z.string(), - url: z.string(), - similarity: z.number().optional(), - }) - ) - .optional(), - }) - ) - .optional(), - title: z.string().optional(), - previewYaml: z.string().nullable().optional(), -}) +// Simple internal API key authentication +function checkInternalApiKey(req: NextRequest) { + const apiKey = req.headers.get('x-api-key') + const expectedApiKey = process.env.INTERNAL_API_KEY + + if (!expectedApiKey) { + return { success: false, error: 'Internal API key not configured' } + } + + if (!apiKey) { + return { success: false, error: 'API key required' } + } + + if (apiKey !== expectedApiKey) { + return { success: false, error: 'Invalid API key' } + } + + return { success: true } +} -// Schema for listing chats -const ListChatsSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - limit: z.number().min(1).max(100).optional().default(50), - offset: z.number().min(0).optional().default(0), -}) +// Method registry mapping methodId to method +const METHODS = { + 'get_blocks_and_tools': getBlocksAndTools, + 'get_workflow_examples': getWorkflowExamples, + 'set_environment_variables': setEnvironmentVariables, + 'get_environment_variables': getEnvironmentVariables, + 'preview_workflow': previewWorkflow, + 'docs_search_internal': docsSearchInternal, + 'get_workflow_console': getWorkflowConsole, + 'get_user_workflow': getUserWorkflow, +} as const /** * POST /api/copilot - * Send a message to the copilot + * Execute a method based on methodId with internal API key auth */ export async function POST(req: NextRequest) { const requestId = crypto.randomUUID() try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(req) + // Check authentication (internal API key) + const authResult = checkInternalApiKey(req) if (!authResult.success) { return NextResponse.json({ error: authResult.error }, { status: 401 }) } - // For routes that might not have userId (like internal calls without workflow context) - const userId = authResult.userId - const body = await req.json() - const { message, chatId, workflowId, mode, createNewChat, stream, implicitFeedback } = - SendMessageSchema.parse(body) - - // If no userId from auth, we need workflowId for internal calls - if (!userId && !workflowId) { - return NextResponse.json({ error: 'workflowId required for internal calls without user context' }, { status: 400 }) - } - - logger.info(`[${requestId}] Copilot message: "${message}"`, { - chatId, - workflowId, - mode, - createNewChat, - stream, - userId, - authType: authResult.authType, - }) - - // Send message using the service - const result = await sendMessage({ - message, - chatId, - workflowId, - mode, - createNewChat, - stream, - implicitFeedback, - userId: userId || 'internal', // Use 'internal' for system calls without user context - }) - - // Handle streaming response (ReadableStream or StreamingExecution) - let streamToRead: ReadableStream | null = null + const { methodId, params } = MethodExecutionSchema.parse(body) - // Debug logging to see what we actually got - logger.info(`[${requestId}] Response type analysis:`, { - responseType: typeof result.response, - isReadableStream: result.response instanceof ReadableStream, - hasStreamProperty: - typeof result.response === 'object' && result.response && 'stream' in result.response, - hasExecutionProperty: - typeof result.response === 'object' && result.response && 'execution' in result.response, - responseKeys: - typeof result.response === 'object' && result.response ? Object.keys(result.response) : [], + logger.info(`[${requestId}] Method execution: ${methodId}`, { + methodId, }) - if (result.response instanceof ReadableStream) { - logger.info(`[${requestId}] Direct ReadableStream detected`) - streamToRead = result.response - } else if ( - typeof result.response === 'object' && - result.response && - 'stream' in result.response && - 'execution' in result.response - ) { - // Handle StreamingExecution (from providers with tool calls) - logger.info(`[${requestId}] StreamingExecution detected`) - const streamingExecution = result.response as StreamingExecution - streamToRead = streamingExecution.stream - - // No need to extract citations - LLM generates direct markdown links - } - - if (streamToRead) { - logger.info( - `[${requestId}] Returning native SSE streaming response with chatId: ${result.chatId}` - ) - - // Create a new stream that first sends the chatId, then forwards the actual response - const transformedStream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - - // First, send the chatId as an SSE event - if (result.chatId) { - const chatIdEvent = `data: ${JSON.stringify({ type: 'chat_id', chatId: result.chatId })}\n\n` - controller.enqueue(encoder.encode(chatIdEvent)) - } - - // Then forward the actual stream - const reader = streamToRead.getReader() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - controller.enqueue(value) - } - } catch (error) { - logger.error(`[${requestId}] Error forwarding stream:`, error) - controller.error(error) - } finally { - controller.close() - } - }, - }) - - // Pass through native Anthropic SSE events directly to the frontend - return new Response(transformedStream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) - } - - // Handle non-streaming response - logger.info(`[${requestId}] Chat response generated successfully`) - - return NextResponse.json({ - success: true, - response: result.response, - chatId: result.chatId, - metadata: { - requestId, - message, - }, - }) - } catch (error) { - if (error instanceof z.ZodError) { + // Check if method exists + if (!(methodId in METHODS)) { return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, + { + error: `Unknown method: ${methodId}`, + availableMethods: Object.keys(METHODS) + }, { status: 400 } ) } - logger.error(`[${requestId}] Copilot error:`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * GET /api/copilot - * List chats or get a specific chat - */ -export async function GET(req: NextRequest) { - try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(req) - if (!authResult.success) { - return NextResponse.json({ error: authResult.error }, { status: 401 }) - } - - const userId = authResult.userId - if (!userId) { - return NextResponse.json({ error: 'User ID required for this operation' }, { status: 400 }) - } - - const { searchParams } = new URL(req.url) - const chatId = searchParams.get('chatId') - - // If chatId is provided, get specific chat - if (chatId) { - const chat = await getChat(chatId, userId) - if (!chat) { - return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) - } + // Execute the method + const method = METHODS[methodId as keyof typeof METHODS] + const result = await method(params) - return NextResponse.json({ - success: true, - chat, - }) - } - - // Otherwise, list chats - const workflowId = searchParams.get('workflowId') - const limit = Number.parseInt(searchParams.get('limit') || '50') - const offset = Number.parseInt(searchParams.get('offset') || '0') + logger.info(`[${requestId}] Method execution completed successfully: ${methodId}`) - if (!workflowId) { - return NextResponse.json( - { error: 'workflowId is required for listing chats' }, - { status: 400 } - ) - } - - const chats = await listChats(userId, workflowId, { limit, offset }) - - return NextResponse.json({ - success: true, - chats, - }) - } catch (error) { - logger.error('Failed to handle GET request:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * PUT /api/copilot - * Create a new chat - */ -export async function PUT(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await req.json() - const { workflowId, title, initialMessage } = CreateChatSchema.parse(body) - - logger.info(`Creating new chat for user ${session.user.id}, workflow ${workflowId}`) - - const chat = await createChat(session.user.id, workflowId, { - title, - initialMessage, - }) - - logger.info(`Created chat ${chat.id} for user ${session.user.id}`) - - return NextResponse.json({ - success: true, - chat, - }) + return NextResponse.json(result) } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( @@ -325,104 +97,12 @@ export async function PUT(req: NextRequest) { ) } - logger.error('Failed to create chat:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * PATCH /api/copilot - * Update a chat with new messages - */ -export async function PATCH(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await req.json() - const { chatId, messages, title, previewYaml } = UpdateChatSchema.parse(body) - - logger.info(`Updating chat ${chatId} for user ${session.user.id}`) - - // Get the current chat to check if it has a title - const existingChat = await getChat(chatId, session.user.id) - - let titleToUse = title - - // Generate title if chat doesn't have one and we have messages - if (!titleToUse && existingChat && !existingChat.title && messages && messages.length > 0) { - const firstUserMessage = messages.find((msg) => msg.role === 'user') - if (firstUserMessage) { - logger.info('Generating LLM-based title for chat without title') - try { - titleToUse = await generateChatTitle(firstUserMessage.content) - logger.info(`Generated title: ${titleToUse}`) - } catch (error) { - logger.error('Failed to generate chat title:', error) - titleToUse = 'New Chat' - } - } - } - - const chat = await updateChat(chatId, session.user.id, { - messages, - title: titleToUse, - previewYaml: previewYaml !== undefined ? previewYaml : undefined, - }) - - if (!chat) { - return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) - } - - return NextResponse.json({ - success: true, - chat, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - logger.error('Failed to update chat:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * DELETE /api/copilot - * Delete a chat - */ -export async function DELETE(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const chatId = searchParams.get('chatId') - - if (!chatId) { - return NextResponse.json({ error: 'chatId is required' }, { status: 400 }) - } - - const success = await deleteChat(chatId, session.user.id) - - if (!success) { - return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) - } - - return NextResponse.json({ - success: true, - message: 'Chat deleted successfully', - }) - } catch (error) { - logger.error('Failed to delete chat:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + logger.error(`[${requestId}] Method execution error:`, error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) } } diff --git a/apps/sim/app/api/copilot/set-environment-variables/route.ts b/apps/sim/app/api/copilot/set-environment-variables/route.ts index 491db1b32c5..dd413e941a7 100644 --- a/apps/sim/app/api/copilot/set-environment-variables/route.ts +++ b/apps/sim/app/api/copilot/set-environment-variables/route.ts @@ -1,107 +1,46 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('SetEnvironmentVariablesAPI') -export async function POST(request: NextRequest) { - try { - // Check authentication (session, API key, or internal JWT) - const authResult = await checkHybridAuth(request) - if (!authResult.success) { - return NextResponse.json( - { success: false, error: authResult.error }, - { status: 401 } - ) - } +export async function setEnvironmentVariables(params: any) { + const { variables } = params - // Ensure we have a user ID for this operation - if (!authResult.userId) { - return NextResponse.json( - { success: false, error: 'User ID required for environment variables access' }, - { status: 400 } - ) - } - - const body = await request.json() - const { variables } = body - - if (!variables || typeof variables !== 'object') { - return NextResponse.json( - { success: false, error: 'Variables object is required' }, - { status: 400 } - ) - } - - logger.info('Setting environment variables for copilot', { - variableCount: Object.keys(variables).length, - variableNames: Object.keys(variables), - authType: authResult.authType, - userId: authResult.userId - }) + if (!variables || typeof variables !== 'object') { + throw new Error('Variables object is required') + } - // Forward the request to the existing environment variables endpoint - const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` - - // Create headers for the forwarded request - const headers: Record = { + logger.info('Setting environment variables for copilot', { + variableCount: Object.keys(variables).length, + variableNames: Object.keys(variables), + }) + + // Forward the request to the existing environment variables endpoint + const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` + + const response = await fetch(envUrl, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', - } - - // Forward authentication based on the original auth method - if (authResult.authType === 'api_key') { - const apiKeyHeader = request.headers.get('x-api-key') - if (apiKeyHeader) { - headers['X-API-Key'] = apiKeyHeader - } - } else if (authResult.authType === 'internal_jwt') { - const authHeader = request.headers.get('authorization') - if (authHeader) { - headers['Authorization'] = authHeader - } - } else { - // For session auth, copy the cookies - const cookieHeader = request.headers.get('cookie') - if (cookieHeader) { - headers['Cookie'] = cookieHeader - } - } - - const response = await fetch(envUrl, { - method: 'PUT', - headers, - body: JSON.stringify({ variables }), + }, + body: JSON.stringify({ variables }), + }) + + if (!response.ok) { + logger.error('Set environment variables API failed', { + status: response.status, + statusText: response.statusText }) + throw new Error('Failed to set environment variables') + } - if (!response.ok) { - logger.error('Set environment variables API failed', { - status: response.status, - statusText: response.statusText - }) - return NextResponse.json( - { success: false, error: 'Failed to set environment variables' }, - { status: response.status } - ) - } - - const result = await response.json() + const result = await response.json() - return NextResponse.json({ - success: true, - data: { - message: 'Environment variables updated successfully', - updatedVariables: Object.keys(variables), - count: Object.keys(variables).length, - }, - }) - } catch (error) { - logger.error('Set environment variables API failed:', error) - return NextResponse.json( - { - success: false, - error: `Failed to set environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) + return { + success: true, + data: { + message: 'Environment variables updated successfully', + updatedVariables: Object.keys(variables), + count: Object.keys(variables).length, + }, } } \ No newline at end of file diff --git a/apps/sim/app/api/test-auth/route.ts b/apps/sim/app/api/test-auth/route.ts index e36b6e1586d..8a3586ac1d2 100644 --- a/apps/sim/app/api/test-auth/route.ts +++ b/apps/sim/app/api/test-auth/route.ts @@ -12,7 +12,7 @@ export async function POST(request: NextRequest) { // Get session for user info const session = await getSession() const body = await request.json() - const { cookie, workflowId, userId } = body + const { workflowId, userId } = body if (!workflowId) { return NextResponse.json( @@ -24,15 +24,13 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Test auth request`, { workflowId, userId: userId || session?.user?.id, - hasCookie: !!cookie, hasSession: !!session, }) - // Use the sim-agent client + // Use the sim-agent client - only send data, no cookies const result = await simAgentClient.testAuth({ workflowId, userId: userId || session?.user?.id, - cookie: cookie || request.headers.get('Cookie') || '', }) logger.info(`[${requestId}] Sim-agent response`, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 99fc9fe4f90..1c643a75550 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -989,13 +989,9 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { } try { - // Get the session cookie from document.cookie - const sessionCookie = document.cookie - console.log('Test Auth Debug:', { workflowId: activeWorkflowId, userId: session.user.id, - cookieLength: sessionCookie.length, }) const response = await fetch('/api/test-auth', { @@ -1004,7 +1000,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { 'Content-Type': 'application/json', }, body: JSON.stringify({ - cookie: sessionCookie, workflowId: activeWorkflowId, userId: session.user.id, }), diff --git a/apps/sim/lib/sim-agent/client.ts b/apps/sim/lib/sim-agent/client.ts index e9c7a7a72ad..a06519f132b 100644 --- a/apps/sim/lib/sim-agent/client.ts +++ b/apps/sim/lib/sim-agent/client.ts @@ -3,10 +3,14 @@ import { createLogger } from '@/lib/logs/console-logger' const logger = createLogger('SimAgentClient') +// Base URL for the sim-agent service +const SIM_AGENT_BASE_URL = env.NODE_ENV === 'development' + ? 'http://localhost:8000' + : (env.NEXT_PUBLIC_SIM_AGENT_URL || 'https://sim-agent.vercel.app') + export interface SimAgentRequest { workflowId: string userId?: string - cookie?: string data?: Record } @@ -22,11 +26,7 @@ class SimAgentClient { private apiKey: string constructor() { - // Determine base URL based on environment - this.baseUrl = env.NODE_ENV === 'development' - ? 'http://localhost:8000' - : (env.NEXT_PUBLIC_SIM_AGENT_URL || 'https://sim-agent.vercel.app') - + this.baseUrl = SIM_AGENT_BASE_URL this.apiKey = env.SIM_AGENT_API_KEY || '' if (!this.apiKey) { @@ -43,11 +43,10 @@ class SimAgentClient { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' body?: Record headers?: Record - cookie?: string } = {} ): Promise> { const requestId = crypto.randomUUID().slice(0, 8) - const { method = 'POST', body, headers = {}, cookie } = options + const { method = 'POST', body, headers = {} } = options try { const url = `${this.baseUrl}${endpoint}` @@ -58,16 +57,10 @@ class SimAgentClient { ...headers, } - // Add cookie if provided - if (cookie) { - requestHeaders['Cookie'] = cookie - } - logger.info(`[${requestId}] Making request to sim-agent`, { url, method, hasApiKey: !!this.apiKey, - hasCookie: !!cookie, hasBody: !!body, }) @@ -126,12 +119,10 @@ class SimAgentClient { return this.makeRequest('/api/test-auth', { method: 'POST', body: { - cookie: request.cookie, workflowId: request.workflowId, userId: request.userId, ...request.data, }, - cookie: request.cookie, }) } @@ -159,7 +150,6 @@ class SimAgentClient { userId: request.userId, ...request.data, }, - cookie: request.cookie, }) } From d26fd33cb83f88d124dca775703e641bcba813cf Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 19:31:19 -0700 Subject: [PATCH 084/184] Get user workflow now returns yaml --- .../api/copilot/get-user-workflow/route.ts | 38 +++---------------- 1 file changed, 5 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/copilot/get-user-workflow/route.ts b/apps/sim/app/api/copilot/get-user-workflow/route.ts index 0e5ce981f52..5377b6f138d 100644 --- a/apps/sim/app/api/copilot/get-user-workflow/route.ts +++ b/apps/sim/app/api/copilot/get-user-workflow/route.ts @@ -1,4 +1,5 @@ import { eq } from 'drizzle-orm' +import { dump as yamlDump } from 'js-yaml' import { createLogger } from '@/lib/logs/console-logger' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' @@ -145,44 +146,15 @@ export async function getUserWorkflow(params: any) { {} ) - // Prepare response with clear context markers - const response: any = { - workflowContext: 'USER_SPECIFIC_WORKFLOW', - note: 'This data represents only the blocks and configurations that the user has actually built in their current workflow, not all available Sim Studio capabilities.', - yaml, - format: 'yaml', - summary: { - workflowName: workflowRecord.name, - blockCount: Object.keys(workflowState.blocks).length, - edgeCount: (workflowState.edges || []).length, - blockTypes, - categories, - hasLoops: Object.keys(workflowState.loops || {}).length > 0, - hasParallels: Object.keys(workflowState.parallels || {}).length > 0, - }, - userBuiltBlocks: blockSchemas, - } - - // Add metadata if requested - if (includeMetadata) { - response.metadata = { - workflowId: workflowRecord.id, - name: workflowRecord.name, - description: workflowRecord.description, - workspaceId: workflowRecord.workspaceId, - createdAt: workflowRecord.createdAt, - updatedAt: workflowRecord.updatedAt, - } - } - - logger.info('Successfully fetched user workflow YAML', { + logger.info('Successfully fetched user workflow as YAML', { workflowId, - blockCount: response.summary.blockCount, + blockCount: Object.keys(workflowState.blocks).length, yamlLength: yaml.length, }) + // Return the condensed YAML format directly, just like the YAML editor does return { success: true, - output: response, + data: yaml, } } From 138e64a95300ee74af07292eeae0faeff61c33d0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 21:40:27 -0700 Subject: [PATCH 085/184] Checkpoint --- apps/sim/app/api/copilot/chat/route.ts | 544 ++++++++++++ .../api/copilot/get-user-workflow/route.ts | 2 + apps/sim/app/api/copilot/methods/route.ts | 108 +++ apps/sim/app/api/copilot/route.ts | 433 +++++++-- .../app/api/tools/get-user-workflow/route.ts | 213 +++++ apps/sim/lib/copilot/api.ts | 4 +- apps/sim/stores/copilot/store.ts | 830 +++++++++--------- 7 files changed, 1656 insertions(+), 478 deletions(-) create mode 100644 apps/sim/app/api/copilot/chat/route.ts create mode 100644 apps/sim/app/api/copilot/methods/route.ts create mode 100644 apps/sim/app/api/tools/get-user-workflow/route.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts new file mode 100644 index 00000000000..1d129477008 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -0,0 +1,544 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { apiKey as apiKeyTable, copilotChats } from '@/db/schema' +import { and, eq } from 'drizzle-orm' +import { executeProviderRequest } from '@/providers' +import { getCopilotModel } from '@/lib/copilot/config' +import { + TITLE_GENERATION_SYSTEM_PROMPT, + TITLE_GENERATION_USER_PROMPT +} from '@/lib/copilot/prompts' + +const logger = createLogger('CopilotChatAPI') + +// Schema for chat messages +const ChatMessageSchema = z.object({ + message: z.string().min(1, 'Message is required'), + chatId: z.string().optional(), + workflowId: z.string().min(1, 'Workflow ID is required'), + mode: z.enum(['ask', 'agent']).optional().default('agent'), + createNewChat: z.boolean().optional().default(false), + stream: z.boolean().optional().default(true), + implicitFeedback: z.string().optional(), +}) + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY || 'sk-simagent-api01-440c1bd94254e1d8e412e3a57f706c48bfeddb60346e41a5564e5850cd9747abb3542170c33b6ee13353a79eb3fa725e' + +/** + * Generate a chat title using LLM + */ +async function generateChatTitle(userMessage: string): Promise { + try { + const { provider, model } = getCopilotModel('title') + + // Get the appropriate API key for the provider + let apiKey: string | undefined + if (provider === 'anthropic') { + // Use rotating API key for Anthropic + const { getRotatingApiKey } = require('@/lib/utils') + try { + apiKey = getRotatingApiKey('anthropic') + logger.debug(`Using rotating API key for Anthropic title generation`) + } catch (e) { + // If rotation fails, let the provider handle it + logger.warn(`Failed to get rotating API key for Anthropic:`, e) + } + } + + const response = await executeProviderRequest(provider, { + model, + systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, + context: TITLE_GENERATION_USER_PROMPT(userMessage), + temperature: 0.3, + maxTokens: 50, + apiKey: apiKey || '', // Use rotating key or empty string + stream: false, + }) + + if (typeof response === 'object' && 'content' in response) { + return response.content?.trim() || 'New Chat' + } + + return 'New Chat' + } catch (error) { + logger.error('Failed to generate chat title:', error) + return 'New Chat' + } +} + +/** + * POST /api/copilot/chat + * Send messages to sim agent and handle chat persistence + */ +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() + const startTime = Date.now() + + try { + // Authenticate user + const session = await getSession() + let authenticatedUserId: string | null = session?.user?.id || null + + // If no session, check for API key auth + if (!authenticatedUserId) { + const apiKeyHeader = req.headers.get('x-api-key') + if (apiKeyHeader) { + // Verify API key + const [apiKeyRecord] = await db + .select({ userId: apiKeyTable.userId }) + .from(apiKeyTable) + .where(eq(apiKeyTable.key, apiKeyHeader)) + .limit(1) + + if (apiKeyRecord) { + authenticatedUserId = apiKeyRecord.userId + } + } + } + + if (!authenticatedUserId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const { message, chatId, workflowId, mode, createNewChat, stream, implicitFeedback } = + ChatMessageSchema.parse(body) + + logger.info(`[${requestId}] Processing copilot chat request`, { + userId: authenticatedUserId, + workflowId, + chatId, + mode, + stream, + createNewChat, + messageLength: message.length, + hasImplicitFeedback: !!implicitFeedback, + }) + + // Handle chat context + let currentChat: any = null + let conversationHistory: any[] = [] + let actualChatId = chatId + + if (chatId) { + // Load existing chat + const [chat] = await db + .select() + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, authenticatedUserId))) + .limit(1) + + if (chat) { + currentChat = chat + conversationHistory = Array.isArray(chat.messages) ? chat.messages : [] + } + } else if (createNewChat && workflowId) { + // Create new chat + const { provider, model } = getCopilotModel('chat') + const [newChat] = await db + .insert(copilotChats) + .values({ + userId: authenticatedUserId, + workflowId, + title: null, + model, + messages: [], + }) + .returning() + + if (newChat) { + currentChat = newChat + actualChatId = newChat.id + } + } + + // Build messages array for sim agent with conversation history + const messages = [] + + // Add conversation history + for (const msg of conversationHistory) { + messages.push({ + role: msg.role, + content: msg.content, + }) + } + + // Add implicit feedback if provided + if (implicitFeedback) { + messages.push({ + role: 'system', + content: implicitFeedback, + }) + } + + // Add current user message + messages.push({ + role: 'user', + content: message, + }) + + // Forward to sim agent API + logger.info(`[${requestId}] Sending request to sim agent API`, { + messageCount: messages.length, + endpoint: `${SIM_AGENT_API_URL}/api/chat-completion-streaming` + }) + + const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': SIM_AGENT_API_KEY, + }, + body: JSON.stringify({ + messages, + workflowId, + userId: authenticatedUserId, + stream: stream, + streamToolCalls: true, + mode: mode, + }), + }) + + if (!simAgentResponse.ok) { + const errorText = await simAgentResponse.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: simAgentResponse.status, + error: errorText, + }) + return NextResponse.json( + { error: `Sim agent API error: ${simAgentResponse.statusText}` }, + { status: simAgentResponse.status } + ) + } + + // If streaming is requested, forward the stream and update chat later + if (stream && simAgentResponse.body) { + logger.info(`[${requestId}] Streaming response from sim agent`) + + // Create user message to save + const userMessage = { + id: crypto.randomUUID(), + role: 'user', + content: message, + timestamp: new Date().toISOString(), + } + + // Create a pass-through stream that captures the response + const transformedStream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + let assistantContent = '' + let toolCalls: any[] = [] + let buffer = '' + let isFirstDone = true + + // Send chatId as first event + if (actualChatId) { + const chatIdEvent = `data: ${JSON.stringify({ + type: 'chat_id', + chatId: actualChatId + })}\n\n` + controller.enqueue(encoder.encode(chatIdEvent)) + logger.debug(`[${requestId}] Sent initial chatId event to client`) + } + + // Forward the sim agent stream and capture assistant response + const reader = simAgentResponse.body!.getReader() + const decoder = new TextDecoder() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + logger.info(`[${requestId}] Stream reading completed`) + break + } + + // Forward the chunk to client immediately + controller.enqueue(value) + const chunkSize = value.byteLength + + // Decode and parse SSE events for logging and capturing content + const decodedChunk = decoder.decode(value, { stream: true }) + buffer += decodedChunk + + // Log first few chunks for debugging + if (chunkSize > 0) { + logger.debug(`[${requestId}] Forwarded chunk to client:`, { + size: chunkSize, + preview: decodedChunk.substring(0, 100) + (decodedChunk.length > 100 ? '...' : '') + }) + } + const lines = buffer.split('\n') + buffer = lines.pop() || '' // Keep incomplete line in buffer + + for (const line of lines) { + if (line.trim() === '') continue // Skip empty lines + + if (line.startsWith('data: ') && line.length > 6) { + try { + const event = JSON.parse(line.slice(6)) + + // Log different event types comprehensively + switch (event.type) { + case 'content': + if (event.data) { + logger.debug(`[${requestId}] Content delta: "${event.data}"`) + assistantContent += event.data + } + break + + case 'tool_call': + logger.info(`[${requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`, { + id: event.data?.id, + name: event.data?.name, + arguments: event.data?.arguments, + blockIndex: event.data?._blockIndex + }) + if (!event.data?.partial) { + toolCalls.push(event.data) + } + break + + case 'tool_execution': + logger.info(`[${requestId}] Tool execution started:`, { + toolCallId: event.toolCallId, + toolName: event.toolName, + status: event.status + }) + break + + case 'tool_result': + logger.info(`[${requestId}] Tool result received:`, { + toolCallId: event.toolCallId, + toolName: event.toolName, + success: event.success, + result: JSON.stringify(event.result).substring(0, 200) + '...' + }) + break + + case 'tool_error': + logger.error(`[${requestId}] Tool error:`, { + toolCallId: event.toolCallId, + toolName: event.toolName, + error: event.error, + success: event.success + }) + break + + case 'done': + if (isFirstDone) { + logger.info(`[${requestId}] Initial AI response complete, tool count: ${toolCalls.length}`) + isFirstDone = false + } else { + logger.info(`[${requestId}] Conversation round complete`) + } + break + + case 'error': + logger.error(`[${requestId}] Stream error event:`, event.error) + break + + default: + logger.debug(`[${requestId}] Unknown event type: ${event.type}`, event) + } + } catch (e) { + logger.warn(`[${requestId}] Failed to parse SSE event: "${line}"`, e) + } + } else if (line.trim() && line !== 'data: [DONE]') { + logger.debug(`[${requestId}] Non-SSE line from sim agent: "${line}"`) + } + } + } + + // Process any remaining buffer + if (buffer.trim()) { + logger.debug(`[${requestId}] Processing remaining buffer: "${buffer}"`) + if (buffer.startsWith('data: ')) { + try { + const event = JSON.parse(buffer.slice(6)) + if (event.type === 'content' && event.data) { + assistantContent += event.data + } + } catch (e) { + logger.warn(`[${requestId}] Failed to parse final buffer: "${buffer}"`) + } + } + } + + // Log final streaming summary + logger.info(`[${requestId}] Streaming complete summary:`, { + totalContentLength: assistantContent.length, + toolCallsCount: toolCalls.length, + hasContent: assistantContent.length > 0, + toolNames: toolCalls.map(tc => tc?.name).filter(Boolean) + }) + + // Save messages to database after streaming completes + if (currentChat && assistantContent) { + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: assistantContent, + timestamp: new Date().toISOString(), + } + + const updatedMessages = [...conversationHistory, userMessage, assistantMessage] + + // Generate title if this is the first message + let titleToUse = currentChat.title + if (!titleToUse && conversationHistory.length === 0) { + titleToUse = await generateChatTitle(message) + } + + // Update chat in database + await db + .update(copilotChats) + .set({ + messages: updatedMessages, + title: titleToUse || currentChat.title, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, actualChatId!)) + + logger.info(`[${requestId}] Updated chat ${actualChatId} with new messages`, { + messageCount: updatedMessages.length, + title: titleToUse || currentChat.title + }) + } + } catch (error) { + logger.error(`[${requestId}] Error processing stream:`, error) + controller.error(error) + } finally { + controller.close() + } + }, + }) + + const response = new Response(transformedStream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) + + logger.info(`[${requestId}] Returning streaming response to client`, { + duration: Date.now() - startTime, + chatId: actualChatId, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + } + }) + + return response + } + + // For non-streaming responses + const responseData = await simAgentResponse.json() + logger.info(`[${requestId}] Non-streaming response from sim agent:`, { + hasContent: !!responseData.content, + contentLength: responseData.content?.length || 0, + model: responseData.model, + provider: responseData.provider, + toolCallsCount: responseData.toolCalls?.length || 0, + hasTokens: !!responseData.tokens + }) + + // Log tool calls if present + if (responseData.toolCalls?.length > 0) { + responseData.toolCalls.forEach((toolCall: any) => { + logger.info(`[${requestId}] Tool call in response:`, { + id: toolCall.id, + name: toolCall.name, + success: toolCall.success, + result: JSON.stringify(toolCall.result).substring(0, 200) + '...' + }) + }) + } + + // Save messages if we have a chat + if (currentChat && responseData.content) { + const userMessage = { + id: crypto.randomUUID(), + role: 'user', + content: message, + timestamp: new Date().toISOString(), + } + + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: responseData.content, + timestamp: new Date().toISOString(), + } + + const updatedMessages = [...conversationHistory, userMessage, assistantMessage] + + // Generate title if this is the first message + let titleToUse = currentChat.title + if (!titleToUse && conversationHistory.length === 0) { + titleToUse = await generateChatTitle(message) + } + + // Update chat in database + await db + .update(copilotChats) + .set({ + messages: updatedMessages, + title: titleToUse || currentChat.title, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, actualChatId!)) + } + + logger.info(`[${requestId}] Returning non-streaming response`, { + duration: Date.now() - startTime, + chatId: actualChatId, + responseLength: responseData.content?.length || 0 + }) + + return NextResponse.json({ + success: true, + response: responseData, + chatId: actualChatId, + metadata: { + requestId, + message, + duration: Date.now() - startTime, + }, + }) + } catch (error) { + const duration = Date.now() - startTime + + if (error instanceof z.ZodError) { + logger.error(`[${requestId}] Validation error:`, { + duration, + errors: error.errors + }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Error handling copilot chat:`, { + duration, + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }) + + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-user-workflow/route.ts b/apps/sim/app/api/copilot/get-user-workflow/route.ts index 5377b6f138d..f5116246c29 100644 --- a/apps/sim/app/api/copilot/get-user-workflow/route.ts +++ b/apps/sim/app/api/copilot/get-user-workflow/route.ts @@ -152,6 +152,8 @@ export async function getUserWorkflow(params: any) { yamlLength: yaml.length, }) + logger.info('YAML', { yaml }) + // Return the condensed YAML format directly, just like the YAML editor does return { success: true, diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts new file mode 100644 index 00000000000..f4d899016f5 --- /dev/null +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -0,0 +1,108 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getBlocksAndTools } from '../get-blocks-and-tools/route' +import { getWorkflowExamples } from '../get-workflow-examples/route' +import { setEnvironmentVariables } from '../set-environment-variables/route' +import { getEnvironmentVariables } from '../get-environment-variables/route' +import { previewWorkflow } from '../preview-workflow/route' +import { docsSearchInternal } from '../docs-search-internal/route' +import { getWorkflowConsole } from '../get-workflow-console/route' +import { getUserWorkflow } from '../get-user-workflow/route' + +const logger = createLogger('CopilotMethodsAPI') + +// Schema for method execution +const MethodExecutionSchema = z.object({ + methodId: z.string().min(1, 'Method ID is required'), + params: z.record(z.any()).optional().default({}), +}) + +// Simple internal API key authentication +function checkInternalApiKey(req: NextRequest) { + const apiKey = req.headers.get('x-api-key') + const expectedApiKey = process.env.INTERNAL_API_KEY + + if (!expectedApiKey) { + return { success: false, error: 'Internal API key not configured' } + } + + if (!apiKey) { + return { success: false, error: 'API key required' } + } + + if (apiKey !== expectedApiKey) { + return { success: false, error: 'Invalid API key' } + } + + return { success: true } +} + +// Method registry mapping methodId to method +const METHODS = { + 'get_blocks_and_tools': getBlocksAndTools, + 'get_workflow_examples': getWorkflowExamples, + 'set_environment_variables': setEnvironmentVariables, + 'get_environment_variables': getEnvironmentVariables, + 'preview_workflow': previewWorkflow, + 'docs_search_internal': docsSearchInternal, + 'get_workflow_console': getWorkflowConsole, + 'get_user_workflow': getUserWorkflow, +} as const + +/** + * POST /api/copilot/methods + * Execute a method based on methodId with internal API key auth + */ +export async function POST(req: NextRequest) { + const requestId = crypto.randomUUID() + + try { + // Check authentication (internal API key) + const authResult = checkInternalApiKey(req) + if (!authResult.success) { + return NextResponse.json({ error: authResult.error }, { status: 401 }) + } + + const body = await req.json() + const { methodId, params } = MethodExecutionSchema.parse(body) + + logger.info(`[${requestId}] Method execution: ${methodId}`, { + methodId, + }) + + // Check if method exists + if (!(methodId in METHODS)) { + return NextResponse.json( + { + error: `Unknown method: ${methodId}`, + availableMethods: Object.keys(METHODS) + }, + { status: 400 } + ) + } + + // Execute the method + const method = METHODS[methodId as keyof typeof METHODS] + const result = await method(params) + + logger.info(`[${requestId}] Method execution completed successfully: ${methodId}`) + + return NextResponse.json(result) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + logger.error(`[${requestId}] Method execution error:`, error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts index 8f6b9249825..7781e38c23b 100644 --- a/apps/sim/app/api/copilot/route.ts +++ b/apps/sim/app/api/copilot/route.ts @@ -1,94 +1,353 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' -import { getBlocksAndTools } from './get-blocks-and-tools/route' -import { getWorkflowExamples } from './get-workflow-examples/route' -import { setEnvironmentVariables } from './set-environment-variables/route' -import { getEnvironmentVariables } from './get-environment-variables/route' -import { previewWorkflow } from './preview-workflow/route' -import { docsSearchInternal } from './docs-search-internal/route' -import { getWorkflowConsole } from './get-workflow-console/route' -import { getUserWorkflow } from './get-user-workflow/route' +import { db } from '@/db' +import { copilotChats } from '@/db/schema' +import { and, eq, desc } from 'drizzle-orm' +import { executeProviderRequest } from '@/providers' +import { getCopilotConfig, getCopilotModel } from '@/lib/copilot/config' +import { + TITLE_GENERATION_SYSTEM_PROMPT, + TITLE_GENERATION_USER_PROMPT +} from '@/lib/copilot/prompts' const logger = createLogger('CopilotAPI') -// Schema for method execution -const MethodExecutionSchema = z.object({ - methodId: z.string().min(1, 'Method ID is required'), - params: z.record(z.any()).optional().default({}), +// Schema for creating chats +const CreateChatSchema = z.object({ + workflowId: z.string().min(1, 'Workflow ID is required'), + title: z.string().optional(), + initialMessage: z.string().optional(), }) -// Simple internal API key authentication -function checkInternalApiKey(req: NextRequest) { - const apiKey = req.headers.get('x-api-key') - const expectedApiKey = process.env.INTERNAL_API_KEY - - if (!expectedApiKey) { - return { success: false, error: 'Internal API key not configured' } - } - - if (!apiKey) { - return { success: false, error: 'API key required' } - } - - if (apiKey !== expectedApiKey) { - return { success: false, error: 'Invalid API key' } - } - - return { success: true } +// Schema for updating chats +const UpdateChatSchema = z.object({ + chatId: z.string().min(1, 'Chat ID is required'), + messages: z + .array( + z.object({ + id: z.string(), + role: z.enum(['user', 'assistant', 'system']), + content: z.string(), + timestamp: z.string(), + citations: z + .array( + z.object({ + id: z.number(), + title: z.string(), + url: z.string(), + similarity: z.number().optional(), + }) + ) + .optional(), + }) + ) + .optional(), + title: z.string().optional(), + previewYaml: z.string().nullable().optional(), +}) + +// Interface for copilot chat +interface CopilotChat { + id: string + title: string | null + model: string + messages: any[] + messageCount: number + previewYaml: string | null + createdAt: Date + updatedAt: Date } -// Method registry mapping methodId to method -const METHODS = { - 'get_blocks_and_tools': getBlocksAndTools, - 'get_workflow_examples': getWorkflowExamples, - 'set_environment_variables': setEnvironmentVariables, - 'get_environment_variables': getEnvironmentVariables, - 'preview_workflow': previewWorkflow, - 'docs_search_internal': docsSearchInternal, - 'get_workflow_console': getWorkflowConsole, - 'get_user_workflow': getUserWorkflow, -} as const +/** + * Generate a chat title using LLM + */ +async function generateChatTitle(userMessage: string): Promise { + try { + const { provider, model } = getCopilotModel('title') + + // Get the appropriate API key for the provider + let apiKey: string | undefined + if (provider === 'anthropic') { + // Use rotating API key for Anthropic + const { getRotatingApiKey } = require('@/lib/utils') + try { + apiKey = getRotatingApiKey('anthropic') + logger.debug(`Using rotating API key for Anthropic title generation`) + } catch (e) { + // If rotation fails, let the provider handle it + logger.warn(`Failed to get rotating API key for Anthropic:`, e) + } + } + + const response = await executeProviderRequest(provider, { + model, + systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, + context: TITLE_GENERATION_USER_PROMPT(userMessage), + temperature: 0.3, + maxTokens: 50, + apiKey: apiKey || '', // Use rotating key or empty string + stream: false, + }) + + if (typeof response === 'object' && 'content' in response) { + return response.content?.trim() || 'New Chat' + } + + return 'New Chat' + } catch (error) { + logger.error('Failed to generate chat title:', error) + return 'New Chat' + } +} /** - * POST /api/copilot - * Execute a method based on methodId with internal API key auth + * GET /api/copilot + * List chats or get a specific chat */ -export async function POST(req: NextRequest) { - const requestId = crypto.randomUUID() +export async function GET(req: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const chatId = searchParams.get('chatId') + + // If chatId is provided, get specific chat + if (chatId) { + const [chat] = await db + .select() + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) + .limit(1) + + if (!chat) { + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) + } + + const copilotChat: CopilotChat = { + id: chat.id, + title: chat.title, + model: chat.model, + messages: Array.isArray(chat.messages) ? chat.messages : [], + messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, + previewYaml: chat.previewYaml, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + } + + return NextResponse.json({ + success: true, + chat: copilotChat, + }) + } + // Otherwise, list chats + const workflowId = searchParams.get('workflowId') + const limit = Number.parseInt(searchParams.get('limit') || '50') + const offset = Number.parseInt(searchParams.get('offset') || '0') + + if (!workflowId) { + return NextResponse.json( + { error: 'workflowId is required for listing chats' }, + { status: 400 } + ) + } + + const chats = await db + .select() + .from(copilotChats) + .where(and(eq(copilotChats.userId, session.user.id), eq(copilotChats.workflowId, workflowId))) + .orderBy(desc(copilotChats.createdAt)) + .limit(limit) + .offset(offset) + + const formattedChats: CopilotChat[] = chats.map(chat => ({ + id: chat.id, + title: chat.title, + model: chat.model, + messages: Array.isArray(chat.messages) ? chat.messages : [], + messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, + previewYaml: chat.previewYaml, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + })) + + return NextResponse.json({ + success: true, + chats: formattedChats, + }) + } catch (error) { + logger.error('Failed to handle GET request:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * PUT /api/copilot + * Create a new chat + */ +export async function PUT(req: NextRequest) { try { - // Check authentication (internal API key) - const authResult = checkInternalApiKey(req) - if (!authResult.success) { - return NextResponse.json({ error: authResult.error }, { status: 401 }) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const body = await req.json() - const { methodId, params } = MethodExecutionSchema.parse(body) + const { workflowId, title, initialMessage } = CreateChatSchema.parse(body) - logger.info(`[${requestId}] Method execution: ${methodId}`, { - methodId, - }) + const { provider, model } = getCopilotModel('chat') + + logger.info(`Creating new chat for user ${session.user.id}, workflow ${workflowId}`) + + // Prepare initial messages array + const initialMessages = initialMessage + ? [ + { + id: crypto.randomUUID(), + role: 'user', + content: initialMessage, + timestamp: new Date().toISOString(), + }, + ] + : [] + + // Create the chat + const [newChat] = await db + .insert(copilotChats) + .values({ + userId: session.user.id, + workflowId, + title: title || null, + model, + messages: initialMessages, + }) + .returning() + + if (!newChat) { + throw new Error('Failed to create chat') + } + + const copilotChat: CopilotChat = { + id: newChat.id, + title: newChat.title, + model: newChat.model, + messages: Array.isArray(newChat.messages) ? newChat.messages : [], + messageCount: Array.isArray(newChat.messages) ? newChat.messages.length : 0, + previewYaml: newChat.previewYaml, + createdAt: newChat.createdAt, + updatedAt: newChat.updatedAt, + } + + logger.info(`Created chat ${copilotChat.id} for user ${session.user.id}`) - // Check if method exists - if (!(methodId in METHODS)) { + return NextResponse.json({ + success: true, + chat: copilotChat, + }) + } catch (error) { + if (error instanceof z.ZodError) { return NextResponse.json( - { - error: `Unknown method: ${methodId}`, - availableMethods: Object.keys(METHODS) - }, + { error: 'Invalid request data', details: error.errors }, { status: 400 } ) } - // Execute the method - const method = METHODS[methodId as keyof typeof METHODS] - const result = await method(params) + logger.error('Failed to create chat:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * PATCH /api/copilot + * Update a chat with new messages + */ +export async function PATCH(req: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const { chatId, messages, title, previewYaml } = UpdateChatSchema.parse(body) - logger.info(`[${requestId}] Method execution completed successfully: ${methodId}`) + logger.info(`Updating chat ${chatId} for user ${session.user.id}`) + + // Get the current chat to check if it has a title + const [existingChat] = await db + .select() + .from(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) + .limit(1) + + if (!existingChat) { + return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) + } + + let titleToUse = title + + // Generate title if chat doesn't have one and we have messages + if (!titleToUse && !existingChat.title && messages && messages.length > 0) { + const firstUserMessage = messages.find((msg) => msg.role === 'user') + if (firstUserMessage) { + logger.info('Generating LLM-based title for chat without title') + try { + titleToUse = await generateChatTitle(firstUserMessage.content) + logger.info(`Generated title: ${titleToUse}`) + } catch (error) { + logger.error('Failed to generate chat title:', error) + titleToUse = 'New Chat' + } + } + } + + // Build update object + const updateData: any = { + updatedAt: new Date(), + } + + if (messages !== undefined) { + updateData.messages = messages + } + + if (titleToUse !== undefined) { + updateData.title = titleToUse + } - return NextResponse.json(result) + if (previewYaml !== undefined) { + updateData.previewYaml = previewYaml + } + + const [updatedChat] = await db + .update(copilotChats) + .set(updateData) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) + .returning() + + if (!updatedChat) { + return NextResponse.json({ error: 'Failed to update chat' }, { status: 500 }) + } + + const copilotChat: CopilotChat = { + id: updatedChat.id, + title: updatedChat.title, + model: updatedChat.model, + messages: Array.isArray(updatedChat.messages) ? updatedChat.messages : [], + messageCount: Array.isArray(updatedChat.messages) ? updatedChat.messages.length : 0, + previewYaml: updatedChat.previewYaml, + createdAt: updatedChat.createdAt, + updatedAt: updatedChat.updatedAt, + } + + return NextResponse.json({ + success: true, + chat: copilotChat, + }) } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( @@ -97,12 +356,44 @@ export async function POST(req: NextRequest) { ) } - logger.error(`[${requestId}] Method execution error:`, error) - return NextResponse.json( - { - error: error instanceof Error ? error.message : 'Internal server error' - }, - { status: 500 } - ) + logger.error('Failed to update chat:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } + +/** + * DELETE /api/copilot + * Delete a chat + */ +export async function DELETE(req: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const chatId = searchParams.get('chatId') + + if (!chatId) { + return NextResponse.json({ error: 'chatId is required' }, { status: 400 }) + } + + const result = await db + .delete(copilotChats) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) + .returning({ id: copilotChats.id }) + + if (result.length === 0) { + return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) + } + + return NextResponse.json({ + success: true, + message: 'Chat deleted successfully', + }) + } catch (error) { + logger.error('Failed to delete chat:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/tools/get-user-workflow/route.ts b/apps/sim/app/api/tools/get-user-workflow/route.ts new file mode 100644 index 00000000000..94889577acc --- /dev/null +++ b/apps/sim/app/api/tools/get-user-workflow/route.ts @@ -0,0 +1,213 @@ +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' +import { getBlock } from '@/blocks' +import { db } from '@/db' +import { workflow as workflowTable } from '@/db/schema' + +const logger = createLogger('GetUserWorkflowAPI') + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { workflowId, includeMetadata = false } = body + + if (!workflowId) { + return NextResponse.json( + { success: false, error: 'Workflow ID is required' }, + { status: 400 } + ) + } + + logger.info('Fetching user workflow', { workflowId }) + + // Fetch workflow from database + const [workflowRecord] = await db + .select() + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + + if (!workflowRecord) { + return NextResponse.json( + { success: false, error: `Workflow ${workflowId} not found` }, + { status: 404 } + ) + } + + // Try to load from normalized tables first, fallback to JSON blob + let workflowState: any = null + const subBlockValues: Record> = {} + + const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + if (normalizedData) { + workflowState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + } + + // Extract subblock values from normalized data + Object.entries(normalizedData.blocks).forEach(([blockId, block]) => { + subBlockValues[blockId] = {} + Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if ((subBlock as any).value !== undefined) { + subBlockValues[blockId][subBlockId] = (subBlock as any).value + } + }) + }) + } else if (workflowRecord.state) { + // Fallback to JSON blob + workflowState = workflowRecord.state as any + // For JSON blob, subblock values are embedded in the block state + Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => { + subBlockValues[blockId] = {} + Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { + if ((subBlock as any).value !== undefined) { + subBlockValues[blockId][subBlockId] = (subBlock as any).value + } + }) + }) + } + + if (!workflowState || !workflowState.blocks) { + return NextResponse.json( + { success: false, error: 'Workflow state is empty or invalid' }, + { status: 400 } + ) + } + + // Generate YAML using server-side function + const yaml = generateWorkflowYaml(workflowState, subBlockValues) + + if (!yaml || yaml.trim() === '') { + return NextResponse.json( + { success: false, error: 'Generated YAML is empty' }, + { status: 400 } + ) + } + + // Generate detailed block information with schemas + const blockSchemas: Record = {} + Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => { + const block = blockState as any + const blockConfig = getBlock(block.type) + + if (blockConfig) { + blockSchemas[blockId] = { + type: block.type, + name: block.name, + description: blockConfig.description, + longDescription: blockConfig.longDescription, + category: blockConfig.category, + docsLink: blockConfig.docsLink, + inputs: {}, + inputRequirements: blockConfig.inputs || {}, + outputs: blockConfig.outputs || {}, + tools: blockConfig.tools, + } + + // Add input schema from subBlocks configuration + if (blockConfig.subBlocks) { + blockConfig.subBlocks.forEach((subBlock) => { + blockSchemas[blockId].inputs[subBlock.id] = { + type: subBlock.type, + title: subBlock.title, + description: subBlock.description || '', + layout: subBlock.layout, + ...(subBlock.options && { options: subBlock.options }), + ...(subBlock.placeholder && { placeholder: subBlock.placeholder }), + ...(subBlock.min !== undefined && { min: subBlock.min }), + ...(subBlock.max !== undefined && { max: subBlock.max }), + ...(subBlock.columns && { columns: subBlock.columns }), + ...(subBlock.hidden !== undefined && { hidden: subBlock.hidden }), + ...(subBlock.condition && { condition: subBlock.condition }), + } + }) + } + } else { + // Handle special block types like loops and parallels + blockSchemas[blockId] = { + type: block.type, + name: block.name, + description: `${block.type.charAt(0).toUpperCase() + block.type.slice(1)} container block`, + category: 'Control Flow', + inputs: {}, + outputs: {}, + } + } + }) + + // Generate workflow summary + const blockTypes = Object.values(workflowState.blocks).reduce( + (acc: Record, block: any) => { + acc[block.type] = (acc[block.type] || 0) + 1 + return acc + }, + {} + ) + + const categories = Object.values(blockSchemas).reduce( + (acc: Record, schema: any) => { + if (schema.category) { + acc[schema.category] = (acc[schema.category] || 0) + 1 + } + return acc + }, + {} + ) + + // Prepare response with clear context markers + const response: any = { + workflowContext: 'USER_SPECIFIC_WORKFLOW', // Clear marker for the LLM + note: 'This data represents only the blocks and configurations that the user has actually built in their current workflow, not all available Sim Studio capabilities.', + yaml, + format: 'yaml', + summary: { + workflowName: workflowRecord.name, + blockCount: Object.keys(workflowState.blocks).length, + edgeCount: (workflowState.edges || []).length, + blockTypes, + categories, + hasLoops: Object.keys(workflowState.loops || {}).length > 0, + hasParallels: Object.keys(workflowState.parallels || {}).length > 0, + }, + userBuiltBlocks: blockSchemas, // Renamed to be clearer + } + + // Add metadata if requested + if (includeMetadata) { + response.metadata = { + workflowId: workflowRecord.id, + name: workflowRecord.name, + description: workflowRecord.description, + workspaceId: workflowRecord.workspaceId, + createdAt: workflowRecord.createdAt, + updatedAt: workflowRecord.updatedAt, + } + } + + logger.info('Successfully fetched user workflow YAML', { + workflowId, + blockCount: response.summary.blockCount, + yamlLength: yaml.length, + }) + + return NextResponse.json({ + success: true, + output: response, + }) + } catch (error) { + logger.error('Failed to get workflow YAML:', error) + return NextResponse.json( + { + success: false, + error: `Failed to get workflow YAML: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index 1a087559d42..eecec91fa6e 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -356,11 +356,12 @@ export async function sendStreamingMessage( ): Promise { try { const { abortSignal, ...requestBody } = request - const response = await fetch('/api/copilot', { + const response = await fetch('/api/copilot/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...requestBody, stream: true }), signal: abortSignal, + credentials: 'include', // Include cookies for session authentication }) if (!response.ok) { @@ -446,6 +447,7 @@ export async function sendStreamingDocsMessage( stream: true, }), signal: abortSignal, + credentials: 'include', // Include cookies for session authentication }) if (!response.ok) { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 22217ceb614..0bc5d942266 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -120,6 +120,396 @@ function getToolDisplayName(toolName: string): string { } } +/** + * SSE event handlers for different event types + */ +interface StreamingContext { + messageId: string + accumulatedContent: string + toolCalls: any[] + contentBlocks: any[] + currentTextBlock: any | null + currentBlockType: 'text' | 'tool_use' | null + toolCallBuffer: any | null + newChatId?: string + doneEventCount: number + streamComplete?: boolean +} + +interface SSEHandler { + (data: any, context: StreamingContext, get: () => CopilotStore, set: any): Promise | void +} + +const sseHandlers: Record = { + // Handle chat ID event (custom event) + chat_id: async (data, context, get) => { + context.newChatId = data.chatId + logger.info('Received chatId from stream:', context.newChatId) + + const { currentChat } = get() + if (!currentChat && context.newChatId) { + await get().handleNewChatCreation(context.newChatId) + } + }, + + // Handle tool result events (custom event for preview_workflow) + tool_result: (data, context, get, set) => { + const { toolCallId, result, success } = data + logger.info('Received tool_result event', { toolCallId, success, hasResult: !!result }) + + if (!toolCallId) return + + const toolCall = context.toolCalls.find((tc) => tc.id === toolCallId) + if (!toolCall) return + + logger.info('Found existing tool call for result', { + name: toolCall.name, + toolCallId, + }) + + if (success) { + toolCall.result = result + toolCall.endTime = Date.now() + toolCall.duration = toolCall.endTime - (toolCall.startTime || Date.now()) + + // Set appropriate state based on tool type + if (toolCall.name === 'preview_workflow' || toolCall.name === 'targeted_updates') { + toolCall.state = 'ready_for_review' + } else { + toolCall.state = 'completed' + } + + logger.info('Updated tool call result:', toolCallId, toolCall.name) + + // Handle successful preview_workflow tool result + if (toolCall.name === 'preview_workflow' && result?.yamlContent) { + logger.info('Setting preview YAML from tool_result event', { + yamlLength: result.yamlContent.length, + yamlPreview: result.yamlContent.substring(0, 100), + }) + get().setPreviewYaml(result.yamlContent) + get().updateDiffStore(result.yamlContent) + } + + // Handle successful targeted_updates tool result + if (toolCall.name === 'targeted_updates' && result?.yamlContent) { + logger.info('Setting preview YAML from targeted_updates tool_result event', { + yamlLength: result.yamlContent.length, + yamlPreview: result.yamlContent.substring(0, 200), + }) + get().setPreviewYaml(result.yamlContent) + get().updateDiffStore(result.yamlContent) + } + } else { + // Tool execution failed + toolCall.state = 'error' + toolCall.error = result || 'Tool execution failed' + logger.error('Tool call failed:', toolCallId, toolCall.name, result) + + // If preview_workflow failed, send error back for retry + if (toolCall.name === 'preview_workflow') { + logger.info('Preview workflow tool execution failed, sending error back to agent for retry') + setTimeout(() => { + get().sendImplicitFeedback( + `The previous workflow YAML generation failed with error: "${toolCall.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` + ) + }, 1000) + } + } + + // Update contentBlocks with the updated tool call + updateContentBlockToolCall(context.contentBlocks, toolCallId, toolCall) + + // Update message + updateStreamingMessage(set, context) + }, + + // Handle Anthropic content block start + content_block_start: (data, context, get, set) => { + context.currentBlockType = data.content_block?.type + + if (context.currentBlockType === 'text') { + context.currentTextBlock = { + type: 'text', + content: '', + timestamp: Date.now(), + } + } else if (context.currentBlockType === 'tool_use') { + // Start buffering a tool call + context.toolCallBuffer = { + id: data.content_block.id, + name: data.content_block.name, + displayName: getToolDisplayName(data.content_block.name), + input: {}, + partialInput: '', + state: 'executing', + startTime: Date.now(), + } + context.toolCalls.push(context.toolCallBuffer) + + // Add tool call to content blocks + context.contentBlocks.push({ + type: 'tool_call', + toolCall: context.toolCallBuffer, + timestamp: Date.now(), + }) + + logger.info(`Starting tool call: ${data.content_block.name}`) + updateStreamingMessage(set, context) + } + }, + + // Handle sim agent's content format + content: (data, context, get, set) => { + if (!data.data) return + + context.accumulatedContent += data.data + + // Create or update text block + if (!context.currentTextBlock) { + context.currentTextBlock = { + type: 'text', + content: data.data, + timestamp: Date.now(), + } + context.contentBlocks.push(context.currentTextBlock) + } else { + context.currentTextBlock.content += data.data + updateContentBlockText(context.contentBlocks, context.currentTextBlock) + } + + updateStreamingMessage(set, context) + }, + + // Handle sim agent's tool call format + tool_call: (data, context, get, set) => { + const toolData = data.data + if (!toolData || toolData.partial) return + + const toolCall = { + id: toolData.id, + name: toolData.name, + input: toolData.arguments || {}, + state: 'executing', + timestamp: Date.now(), + displayName: getToolDisplayName(toolData.name), + startTime: Date.now(), + } + context.toolCalls.push(toolCall) + + context.contentBlocks.push({ + type: 'tool_call', + toolCall, + timestamp: Date.now(), + }) + + updateStreamingMessage(set, context) + }, + + // Handle tool execution event + tool_execution: (data, context, get, set) => { + logger.info('Tool execution started:', data.toolName) + const toolCall = context.toolCalls.find(tc => tc.id === data.toolCallId) + if (!toolCall) return + + toolCall.state = 'executing' + updateContentBlockToolCall(context.contentBlocks, data.toolCallId, toolCall) + updateStreamingMessage(set, context) + }, + + // Handle content block delta + content_block_delta: (data, context, get, set) => { + if (context.currentBlockType === 'text' && data.delta?.text) { + context.accumulatedContent += data.delta.text + + if (context.currentTextBlock) { + context.currentTextBlock.content += data.delta.text + updateContentBlockText(context.contentBlocks, context.currentTextBlock) + } + + updateStreamingMessage(set, context) + } else if (context.currentBlockType === 'tool_use' && data.delta?.partial_json && context.toolCallBuffer) { + context.toolCallBuffer.partialInput += data.delta.partial_json + } + }, + + // Handle content block stop + content_block_stop: (data, context, get, set) => { + if (context.currentBlockType === 'text') { + context.currentTextBlock = null + } else if (context.currentBlockType === 'tool_use' && context.toolCallBuffer) { + try { + // Parse complete tool call input + context.toolCallBuffer.input = JSON.parse(context.toolCallBuffer.partialInput || '{}') + context.toolCallBuffer.state = + context.toolCallBuffer.name === 'preview_workflow' || + context.toolCallBuffer.name === 'targeted_updates' + ? 'ready_for_review' + : 'completed' + context.toolCallBuffer.endTime = Date.now() + context.toolCallBuffer.duration = context.toolCallBuffer.endTime - context.toolCallBuffer.startTime + + logger.info(`Tool call completed: ${context.toolCallBuffer.name}`, context.toolCallBuffer.input) + + updateContentBlockToolCall(context.contentBlocks, context.toolCallBuffer.id, context.toolCallBuffer) + updateStreamingMessage(set, context) + + // Handle preview_workflow completion + if (context.toolCallBuffer.name === 'preview_workflow' && context.toolCallBuffer.input?.yamlContent) { + logger.info('Setting preview YAML from completed preview_workflow tool call') + get().setPreviewYaml(context.toolCallBuffer.input.yamlContent) + get().updateDiffStore(context.toolCallBuffer.input.yamlContent) + } + } catch (error) { + logger.error('Error parsing tool call input:', error) + context.toolCallBuffer.state = 'error' + context.toolCallBuffer.endTime = Date.now() + context.toolCallBuffer.duration = context.toolCallBuffer.endTime - context.toolCallBuffer.startTime + context.toolCallBuffer.error = error instanceof Error ? error.message : String(error) + + // Retry on preview_workflow failure + if (context.toolCallBuffer.name === 'preview_workflow') { + setTimeout(() => { + get().sendImplicitFeedback( + `The previous workflow YAML generation failed with error: "${context.toolCallBuffer.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` + ) + }, 1000) + } + } + context.toolCallBuffer = null + } + context.currentBlockType = null + }, + + // Handle sim agent's done event + done: (data, context) => { + context.doneEventCount++ + logger.info('Received done event from sim agent', { + doneEventCount: context.doneEventCount, + }) + + context.currentTextBlock = null + + // Complete stream after multiple done events (sim agent sends one after tools and one at end) + if (context.doneEventCount >= 2) { + logger.info('Received final done event, completing stream') + context.streamComplete = true + } + }, + + // Handle errors + error: (data, context, get, set) => { + logger.error('Received error:', data.error) + set((state: CopilotStore) => ({ + messages: state.messages.map((msg: CopilotMessage) => + msg.id === context.messageId + ? { + ...msg, + content: context.accumulatedContent || 'An error occurred while processing your request.', + error: data.error, + } + : msg + ), + })) + context.streamComplete = true + }, + + // Handle tool errors + tool_error: (data, context) => { + logger.error('Tool error:', data.toolName, data.error) + const toolCall = context.toolCalls.find(tc => tc.id === data.toolCallId) + if (toolCall) { + toolCall.state = 'error' + toolCall.error = data.error + } + }, + + // Default handler for unhandled events + default: (data) => { + // Silently handle these common events + const silentEvents = ['message_start', 'message_delta', 'message_stop'] + if (!silentEvents.includes(data.type)) { + logger.debug('Unhandled SSE event type:', data.type) + } + } +} + +/** + * Helper function to update content block with tool call + */ +function updateContentBlockToolCall(contentBlocks: any[], toolCallId: string, toolCall: any) { + for (let i = 0; i < contentBlocks.length; i++) { + const block = contentBlocks[i] + if (block.type === 'tool_call' && block.toolCall.id === toolCallId) { + contentBlocks[i] = { + type: 'tool_call', + toolCall: { ...toolCall }, + timestamp: block.timestamp, + } + break + } + } +} + +/** + * Helper function to update content block with text + */ +function updateContentBlockText(contentBlocks: any[], textBlock: any) { + for (let i = contentBlocks.length - 1; i >= 0; i--) { + if (contentBlocks[i] === textBlock || + (contentBlocks[i].type === 'text' && contentBlocks[i].timestamp === textBlock.timestamp)) { + contentBlocks[i] = { ...textBlock } + break + } + } +} + +/** + * Helper function to update streaming message in state + */ +function updateStreamingMessage(set: any, context: StreamingContext) { + set((state: CopilotStore) => ({ + messages: state.messages.map((msg: CopilotMessage) => + msg.id === context.messageId + ? { + ...msg, + content: context.accumulatedContent, + toolCalls: [...context.toolCalls], + contentBlocks: [...context.contentBlocks], + lastUpdated: Date.now(), + } + : msg + ), + })) +} + +/** + * Parse SSE stream and handle events + */ +async function* parseSSEStream(reader: ReadableStreamDefaultReader, decoder: TextDecoder) { + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (line.trim() === '') continue + if (line.startsWith('data: ')) { + try { + yield JSON.parse(line.slice(6)) + } catch (error) { + logger.warn('Failed to parse SSE data:', error) + } + } + } + } +} + /** * Copilot store using the new unified API */ @@ -792,460 +1182,88 @@ export const useCopilotStore = create()( const reader = stream.getReader() const decoder = new TextDecoder() - // If this is a continuation, start with the existing message content - let accumulatedContent = '' + // Initialize streaming context + const context: StreamingContext = { + messageId, + accumulatedContent: '', + toolCalls: [], + contentBlocks: [], + currentTextBlock: null, + currentBlockType: null, + toolCallBuffer: null, + doneEventCount: 0, + } + + // If continuation, start with existing message content if (isContinuation) { const { messages } = get() const existingMessage = messages.find((msg) => msg.id === messageId) - accumulatedContent = existingMessage?.content || '' + context.accumulatedContent = existingMessage?.content || '' } - let newChatId: string | undefined - let streamComplete = false - - // Track tool calls for native Anthropic events - let currentBlockType: 'text' | 'tool_use' | null = null - let toolCallBuffer: any = null - const toolCalls: any[] = [] - - // Track content blocks chronologically - const contentBlocks: any[] = [] - let currentTextBlock: any = null - // Add timeout to prevent hanging const timeoutId = setTimeout(() => { logger.warn('Stream timeout reached, completing response') - streamComplete = true + reader.cancel() }, 120000) // 2 minute timeout try { - while (true) { + // Process SSE events + for await (const data of parseSSEStream(reader, decoder)) { const { abortController } = get() // Check if we should abort if (abortController?.signal.aborted) { logger.info('Stream reading aborted') - streamComplete = true break } - const { done, value } = await reader.read() - - if (done || streamComplete) { - logger.info('Stream ended - done:', done, 'streamComplete:', streamComplete) + // Get handler for this event type + const handler = sseHandlers[data.type] || sseHandlers.default + await handler(data, context, get, set) + + // Check if handler set stream completion flag + if (context.streamComplete) { break } - - const chunk = decoder.decode(value, { stream: true }) - const lines = chunk.split('\n') - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)) - - // Handle chat ID event (our custom event) - if (data.type === 'chat_id') { - newChatId = data.chatId - logger.info('Received chatId from stream:', newChatId) - - // Update current chat if we don't have one - const { currentChat } = get() - if (!currentChat && newChatId) { - await get().handleNewChatCreation(newChatId) - } - } - // Handle tool result events (our custom event for preview_workflow) - else if (data.type === 'tool_result') { - const { toolCallId, result, success } = data - logger.info('Received tool_result event', { - toolCallId, - success, - hasResult: !!result, - }) - if (toolCallId) { - // Find the corresponding tool call and update its result - const existingToolCall = toolCalls.find((tc) => tc.id === toolCallId) - if (existingToolCall) { - logger.info('Found existing tool call for result', { - name: existingToolCall.name, - toolCallId, - }) - if (success) { - existingToolCall.result = result - logger.info( - 'Updated tool call result:', - toolCallId, - existingToolCall.name - ) - - // Handle successful preview_workflow tool result - if (existingToolCall.name === 'preview_workflow' && result?.yamlContent) { - logger.info('Setting preview YAML from tool_result event', { - yamlLength: result.yamlContent.length, - yamlPreview: result.yamlContent.substring(0, 100), - }) - get().setPreviewYaml(result.yamlContent) - get().updateDiffStore(result.yamlContent) - } - - // Handle successful targeted_updates tool result - if (existingToolCall.name === 'targeted_updates') { - logger.info('Targeted updates tool_result received', { - hasResult: !!result, - resultType: typeof result, - resultKeys: result ? Object.keys(result) : [], - hasYamlContent: !!result?.yamlContent, - // Log the full result structure for debugging - fullResult: JSON.stringify(result, null, 2), - }) - - // The targeted_updates tool returns yamlContent directly in the result - if (result?.yamlContent) { - logger.info( - 'Setting preview YAML from targeted_updates tool_result event', - { - yamlLength: result.yamlContent.length, - yamlPreview: result.yamlContent.substring(0, 200), - // Log the full YAML for debugging - fullYaml: result.yamlContent, - } - ) - get().setPreviewYaml(result.yamlContent) - get().updateDiffStore(result.yamlContent) - - // Set the tool call state to ready_for_review like preview_workflow - existingToolCall.state = 'ready_for_review' - } else { - logger.error('Targeted updates tool_result missing yamlContent', { - expectedPath: 'result.yamlContent', - actualStructure: JSON.stringify(result, null, 2), - }) - // Set to error state if yamlContent is missing - existingToolCall.state = 'error' - existingToolCall.error = 'Missing yamlContent in result' - } - } - } else { - // Tool execution failed - existingToolCall.state = 'error' - existingToolCall.error = result || 'Tool execution failed' - logger.error( - 'Tool call failed:', - toolCallId, - existingToolCall.name, - result - ) - - // If this is a preview_workflow tool that failed, send error back to agent - if (existingToolCall.name === 'preview_workflow') { - logger.info( - 'Preview workflow tool execution failed, sending error back to agent for retry' - ) - // Send the error back to the agent after a brief delay to let the UI update - setTimeout(() => { - get().sendImplicitFeedback( - `The previous workflow YAML generation failed with error: "${existingToolCall.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` - ) - }, 1000) - } - } - - // Update message with the result and content blocks - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId - ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: msg.contentBlocks?.map((block) => - block.type === 'tool_call' && block.toolCall.id === toolCallId - ? { ...block, toolCall: { ...existingToolCall } } - : block - ), - } - : msg - ), - })) - } - } - } - // Handle native Anthropic SSE events - else if (data.type === 'message_start') { - logger.info('Message started') - } else if (data.type === 'content_block_start') { - currentBlockType = data.content_block?.type - - if (currentBlockType === 'text') { - // Start a new text block - currentTextBlock = { - type: 'text', - content: '', - timestamp: Date.now(), - } - } else if (currentBlockType === 'tool_use') { - // Start buffering a tool call - toolCallBuffer = { - id: data.content_block.id, - name: data.content_block.name, - displayName: getToolDisplayName(data.content_block.name), - input: {}, - partialInput: '', - state: 'executing', - startTime: Date.now(), - } - toolCalls.push(toolCallBuffer) - - // Add tool call to content blocks - const toolCallBlock = { - type: 'tool_call', - toolCall: toolCallBuffer, - timestamp: Date.now(), - } - contentBlocks.push(toolCallBlock) - - logger.info(`Starting tool call: ${data.content_block.name}`) - - // Update message with content blocks - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId - ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks], - } - : msg - ), - })) - } - } else if (data.type === 'content_block_delta') { - if (currentBlockType === 'text' && data.delta?.text) { - // Add text content to accumulated content - if ( - isContinuation && - accumulatedContent && - !accumulatedContent.endsWith(' ') && - data.delta.text && - !data.delta.text.startsWith(' ') - ) { - accumulatedContent += ` ${data.delta.text}` - } else { - accumulatedContent += data.delta.text - } - - // Add text to current text block - if (currentTextBlock) { - currentTextBlock.content += data.delta.text - - // Update the content blocks array with the streaming text block - const updatedContentBlocks = [...contentBlocks] - const existingBlockIndex = updatedContentBlocks.findIndex( - (block) => - block.type === 'text' && block.timestamp === currentTextBlock.timestamp - ) - - if (existingBlockIndex >= 0) { - // Update existing block - updatedContentBlocks[existingBlockIndex] = { ...currentTextBlock } - } else { - // Add new text block to content blocks for real-time display - updatedContentBlocks.push({ ...currentTextBlock }) - } - - // Replace contentBlocks array contents - contentBlocks.splice(0, contentBlocks.length, ...updatedContentBlocks) - } - - // Update message in real-time - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId - ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks], - } - : msg - ), - })) - } else if ( - currentBlockType === 'tool_use' && - data.delta?.partial_json && - toolCallBuffer - ) { - // Buffer partial JSON for tool calls (silently) - toolCallBuffer.partialInput += data.delta.partial_json - } - } else if (data.type === 'content_block_stop') { - if (currentBlockType === 'text' && currentTextBlock) { - // Text block is already in contentBlocks from streaming, just clean up - currentTextBlock = null - } else if (currentBlockType === 'tool_use' && toolCallBuffer) { - try { - // Parse complete tool call input - toolCallBuffer.input = JSON.parse(toolCallBuffer.partialInput || '{}') - // Set preview_workflow and targeted_updates tools to ready_for_review, others to completed - toolCallBuffer.state = - toolCallBuffer.name === 'preview_workflow' || - toolCallBuffer.name === 'targeted_updates' - ? 'ready_for_review' - : 'completed' - toolCallBuffer.endTime = Date.now() - toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime - logger.info( - `Tool call completed: ${toolCallBuffer.name}`, - toolCallBuffer.input - ) - - // Update message with completed tool call and content blocks - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId - ? { - ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: contentBlocks.map((block) => - block.type === 'tool_call' && - block.toolCall.id === toolCallBuffer.id - ? { ...block, toolCall: { ...toolCallBuffer } } - : block - ), - } - : msg - ), - })) - - // If this is a preview_workflow tool call, set the preview YAML and diff store - if (toolCallBuffer.name === 'preview_workflow') { - logger.info( - 'Preview workflow tool completed with input:', - toolCallBuffer.input - ) - - if (toolCallBuffer.input?.yamlContent) { - logger.info( - 'Setting preview YAML from completed preview_workflow tool call', - { - yamlLength: toolCallBuffer.input.yamlContent.length, - yamlPreview: toolCallBuffer.input.yamlContent.substring(0, 100), - } - ) - get().setPreviewYaml(toolCallBuffer.input.yamlContent) - - // Also update the diff store with the proposed workflow state - get().updateDiffStore(toolCallBuffer.input.yamlContent) - } else { - logger.warn( - 'Preview workflow tool completed but no yamlContent found in input' - ) - } - } - - // Don't handle targeted_updates here - it needs to wait for the tool_result event - // The result isn't available yet at content_block_stop, only the input - } catch (error) { - logger.error('Error parsing tool call input:', error) - toolCallBuffer.state = 'error' - toolCallBuffer.endTime = Date.now() - toolCallBuffer.duration = toolCallBuffer.endTime - toolCallBuffer.startTime - toolCallBuffer.error = - error instanceof Error ? error.message : String(error) - - // If this is a preview_workflow tool that failed, send error back to agent - if (toolCallBuffer.name === 'preview_workflow') { - logger.info( - 'Preview workflow tool failed, sending error back to agent for retry' - ) - // Send the error back to the agent after a brief delay to let the UI update - setTimeout(() => { - get().sendImplicitFeedback( - `The previous workflow YAML generation failed with error: "${toolCallBuffer.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` - ) - }, 1000) - } - } - toolCallBuffer = null - } - currentBlockType = null - } else if (data.type === 'message_delta') { - // Handle token usage updates silently - if (data.delta?.stop_reason === 'tool_use') { - logger.info( - 'Message stopped for tool use - backend will handle execution and continue' - ) - } - } else if (data.type === 'message_stop') { - // Backend will continue streaming if there are tools to execute - // Don't break the loop - just continue listening for more events - logger.info('Message stopped - backend may continue after tool execution') - - // Reset block state for potential continuation - currentBlockType = null - toolCallBuffer = null - } else if (data.type === 'error') { - // Handle error events from backend - logger.error('Backend error:', data.error) - streamComplete = true - break - } else { - // Log unhandled event types for debugging - logger.debug('Unhandled SSE event type:', data.type) - } - } catch (parseError) { - logger.warn('Failed to parse SSE data:', parseError) - } - } - } } - // Stream ended naturally - finalize the message - logger.info(`Completed streaming response, content length: ${accumulatedContent.length}`) + // Stream ended - finalize the message + logger.info(`Completed streaming response, content length: ${context.accumulatedContent.length}`) - // Text blocks are already in contentBlocks from streaming, no need to add again - - // Final update when stream actually ends + // Final update set((state) => ({ messages: state.messages.map((msg) => msg.id === messageId ? { ...msg, - content: accumulatedContent, - toolCalls: [...toolCalls], - contentBlocks: [...contentBlocks], + content: context.accumulatedContent, + toolCalls: context.toolCalls, + contentBlocks: context.contentBlocks, } : msg ), isSendingMessage: false, - abortController: null, // Clear abort controller when streaming completes + abortController: null, })) // Auto-save messages after streaming completes const { currentChat } = get() - const chatIdToSave = currentChat?.id || newChatId + const chatIdToSave = currentChat?.id || context.newChatId if (chatIdToSave) { try { - logger.info( - 'Auto-saving chat messages after streaming completion to chat:', - chatIdToSave - ) + logger.info('Auto-saving chat messages after streaming completion') await get().saveChatMessages(chatIdToSave) } catch (error) { logger.error('Failed to auto-save chat messages:', error) } - } else { - logger.warn('No chat ID available for auto-saving messages') } } catch (error) { - // Handle AbortError gracefully - this is expected when user aborts + // Handle AbortError gracefully if (error instanceof Error && error.name === 'AbortError') { logger.info('Stream reading was aborted by user') - return // Don't throw or log as error + return } logger.error('Error handling streaming response:', error) From 0a0eae82bd15135dd739f61e175676ab39cfdf1b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 22:23:17 -0700 Subject: [PATCH 086/184] Checkpoitn --- .../api/copilot/get-blocks-metadata/route.ts | 119 ++++++++++ .../get-environment-variables/route.ts | 69 +++--- .../api/copilot/get-yaml-structure/route.ts | 20 ++ apps/sim/app/api/copilot/methods/route.ts | 6 + .../app/api/copilot/targeted-updates/route.ts | 56 +++++ .../sim/app/api/tools/get-all-blocks/route.ts | 93 -------- .../app/api/tools/get-user-workflow/route.ts | 213 ------------------ apps/sim/stores/copilot/store.ts | 141 ++++++++++-- 8 files changed, 366 insertions(+), 351 deletions(-) delete mode 100644 apps/sim/app/api/tools/get-all-blocks/route.ts delete mode 100644 apps/sim/app/api/tools/get-user-workflow/route.ts diff --git a/apps/sim/app/api/copilot/get-blocks-metadata/route.ts b/apps/sim/app/api/copilot/get-blocks-metadata/route.ts index ceda2f37160..0b84b736975 100644 --- a/apps/sim/app/api/copilot/get-blocks-metadata/route.ts +++ b/apps/sim/app/api/copilot/get-blocks-metadata/route.ts @@ -7,6 +7,125 @@ import { tools as toolsRegistry } from '@/tools/registry' const logger = createLogger('GetBlockMetadataAPI') +export async function getBlocksMetadata(params: any) { + const { blockIds } = params + + if (!blockIds || !Array.isArray(blockIds)) { + return { + success: false, + error: 'blockIds must be an array of block IDs', + } + } + + logger.info('Getting block metadata', { + blockIds, + blockCount: blockIds.length, + requestedBlocks: blockIds.join(', '), + }) + + try { + // Create result object + const result: Record = {} + + // Process each requested block ID + for (const blockId of blockIds) { + // Check if it's a special block first + if (SPECIAL_BLOCKS_METADATA[blockId]) { + result[blockId] = SPECIAL_BLOCKS_METADATA[blockId] + continue + } + + // Check if the block exists in the registry + const blockConfig = blockRegistry[blockId] + if (!blockConfig) { + logger.warn(`Block not found in registry: ${blockId}`) + continue + } + + const metadata: any = { + id: blockId, + name: blockConfig.name || blockId, + description: blockConfig.description || '', + category: blockConfig.category || 'general', + inputs: blockConfig.inputs || {}, + outputs: blockConfig.outputs || {}, + tools: blockConfig.tools?.access || [], + } + + // Read YAML schema from documentation if available + const docFileName = DOCS_FILE_MAPPING[blockId] || blockId + if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { + try { + const docPath = join(process.cwd(), 'content', 'docs', 'blocks', `${docFileName}.mdx`) + if (existsSync(docPath)) { + const docContent = readFileSync(docPath, 'utf-8') + + // Extract schema from the documentation + const schemaMatch = docContent.match(/```yaml\s*\n([\s\S]*?)```/i) + if (schemaMatch) { + const yamlSchema = schemaMatch[1].trim() + // Parse high-level structure only + const lines = yamlSchema.split('\n') + const schemaInfo: any = { + fields: [], + example: yamlSchema, + } + + // Extract field names and structure + lines.forEach(line => { + const match = line.match(/^(\s*)(\w+):/) + if (match) { + const indent = match[1].length + const fieldName = match[2] + if (indent === 0) { + schemaInfo.fields.push({ + name: fieldName, + level: 'root', + }) + } + } + }) + + metadata.schema = schemaInfo + } + } + } catch (error) { + logger.warn(`Failed to read documentation for ${blockId}:`, error) + } + } + + // Add tool metadata if requested + if (metadata.tools.length > 0) { + metadata.toolDetails = {} + for (const toolId of metadata.tools) { + const tool = toolsRegistry[toolId] + if (tool) { + metadata.toolDetails[toolId] = { + name: tool.name, + description: tool.description, + } + } + } + } + + result[blockId] = metadata + } + + logger.info(`Successfully retrieved metadata for ${Object.keys(result).length} blocks`) + + return { + success: true, + data: result, + } + } catch (error) { + logger.error('Get block metadata failed', error) + return { + success: false, + error: `Failed to get block metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } +} + // Core blocks that have documentation with YAML schemas const CORE_BLOCKS_WITH_DOCS = [ 'agent', diff --git a/apps/sim/app/api/copilot/get-environment-variables/route.ts b/apps/sim/app/api/copilot/get-environment-variables/route.ts index 741bc3b25e7..ea842bd927e 100644 --- a/apps/sim/app/api/copilot/get-environment-variables/route.ts +++ b/apps/sim/app/api/copilot/get-environment-variables/route.ts @@ -1,38 +1,53 @@ import { createLogger } from '@/lib/logs/console-logger' +import { getEnvironmentVariableKeys } from '@/lib/environment/utils' +import { getUserId } from '@/app/api/auth/oauth/utils' const logger = createLogger('GetEnvironmentVariablesAPI') export async function getEnvironmentVariables(params: any) { - logger.info('Getting environment variables for copilot') - - // Forward the request to the existing environment variables endpoint - const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` - - const response = await fetch(envUrl, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - logger.error('Environment variables API failed', { - status: response.status, - statusText: response.statusText + logger.info('Getting environment variables for copilot', { params }) + + const { userId: directUserId, workflowId } = params + + try { + // Resolve userId from workflowId if needed + const userId = directUserId || (workflowId ? await getUserId('copilot-env-vars', workflowId) : undefined) + + logger.info('Resolved userId', { + directUserId, + workflowId, + resolvedUserId: userId }) - throw new Error('Failed to get environment variables') - } - const envData = await response.json() + if (!userId) { + logger.warn('No userId could be determined', { directUserId, workflowId }) + return { + success: false, + error: 'Either userId or workflowId is required', + } + } + + // Get environment variable keys directly + const result = await getEnvironmentVariableKeys(userId) - // Extract just the variable names (not values) for security - const variableNames = envData.data ? Object.keys(envData.data) : [] + logger.info('Environment variable keys retrieved', { + userId, + result, + variableCount: result.count + }) - return { - success: true, - data: { - variableNames, - count: variableNames.length, - }, + return { + success: true, + data: { + variableNames: result.variableNames, + count: result.count, + }, + } + } catch (error) { + logger.error('Failed to get environment variables', error) + return { + success: false, + error: 'Failed to get environment variables', + } } } \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-yaml-structure/route.ts b/apps/sim/app/api/copilot/get-yaml-structure/route.ts index 4a8fd9cc0cc..50d2f9a2517 100644 --- a/apps/sim/app/api/copilot/get-yaml-structure/route.ts +++ b/apps/sim/app/api/copilot/get-yaml-structure/route.ts @@ -3,6 +3,26 @@ import { getYamlWorkflowPrompt } from '@/lib/copilot/prompts' export const dynamic = 'force-dynamic' +export async function getYamlStructure(params: any) { + try { + console.log('[get-yaml-structure] API endpoint called') + + return { + success: true, + data: { + guide: getYamlWorkflowPrompt(), + message: 'Complete YAML workflow syntax guide with examples and best practices', + }, + } + } catch (error) { + console.error('[get-yaml-structure] Error:', error) + return { + success: false, + error: 'Failed to get YAML structure', + } + } +} + export async function POST(request: NextRequest) { try { console.log('[get-yaml-structure] API endpoint called') diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index f4d899016f5..94bc1db60d6 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -9,6 +9,9 @@ import { previewWorkflow } from '../preview-workflow/route' import { docsSearchInternal } from '../docs-search-internal/route' import { getWorkflowConsole } from '../get-workflow-console/route' import { getUserWorkflow } from '../get-user-workflow/route' +import { getBlocksMetadata } from '../get-blocks-metadata/route' +import { getYamlStructure } from '../get-yaml-structure/route' +import { targetedUpdates } from '../targeted-updates/route' const logger = createLogger('CopilotMethodsAPI') @@ -48,6 +51,9 @@ const METHODS = { 'docs_search_internal': docsSearchInternal, 'get_workflow_console': getWorkflowConsole, 'get_user_workflow': getUserWorkflow, + 'get_blocks_metadata': getBlocksMetadata, + 'get_yaml_structure': getYamlStructure, + 'targeted_updates': targetedUpdates, } as const /** diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts index a1292c3213a..7262f249587 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -8,6 +8,62 @@ import { apiKey as apiKeyTable } from '@/db/schema' const logger = createLogger('TargetedUpdatesAPI') +export async function targetedUpdates(params: any) { + try { + // Get authenticated user ID from params (assumes authentication is handled by the methods route) + const authenticatedUserId = params.userId + + if (!authenticatedUserId) { + return { + success: false, + error: 'User authentication required', + } + } + + const { operations, workflowId } = params + + if (!operations || !Array.isArray(operations)) { + return { + success: false, + error: 'operations must be an array', + } + } + + if (!workflowId) { + return { + success: false, + error: 'workflowId is required', + } + } + + logger.info('Processing targeted update request', { + userId: authenticatedUserId + }) + + // Execute the copilot tool + const result = await executeCopilotTool('targeted_updates', { + operations: params.operations, + _context: { + workflowId: params.workflowId, + userId: authenticatedUserId + }, + }) + + logger.info('Targeted update completed successfully') + + return { + success: true, + data: result, + } + } catch (error) { + logger.error('Targeted update failed:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } +} + export async function POST(request: NextRequest) { try { // Try session auth first (for web UI) diff --git a/apps/sim/app/api/tools/get-all-blocks/route.ts b/apps/sim/app/api/tools/get-all-blocks/route.ts deleted file mode 100644 index eafe504e9ad..00000000000 --- a/apps/sim/app/api/tools/get-all-blocks/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { registry as blockRegistry } from '@/blocks/registry' - -const logger = createLogger('GetAllBlocksAPI') - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { includeDetails = false, filterCategory } = body - - logger.info('Getting all blocks and tools', { includeDetails, filterCategory }) - - // Create mapping of block_id -> [tool_ids] - const blockToToolsMapping: Record = {} - - // Process blocks - filter out hidden blocks and map to their tools - Object.entries(blockRegistry) - .filter(([blockType, blockConfig]) => { - // Filter out hidden blocks - if (blockConfig.hideFromToolbar) return false - - // Apply category filter if specified - if (filterCategory && blockConfig.category !== filterCategory) return false - - return true - }) - .forEach(([blockType, blockConfig]) => { - // Get the tools for this block - const blockTools = blockConfig.tools?.access || [] - blockToToolsMapping[blockType] = blockTools - }) - - // Add special blocks that aren't in the standard registry - // Loop and parallel blocks are handled differently but should be available - const specialBlocks = { - loop: { - tools: [], // Loop blocks don't use standard tools - category: 'blocks', - description: 'Control flow block for iterating over collections or repeating actions', - }, - parallel: { - tools: [], // Parallel blocks don't use standard tools - category: 'blocks', - description: 'Control flow block for executing multiple branches simultaneously', - }, - } - - // Add special blocks if they pass the category filter - Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { - if (!filterCategory || blockInfo.category === filterCategory) { - blockToToolsMapping[blockType] = blockInfo.tools - } - }) - - const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length - const includedBlocks = Object.keys(blockToToolsMapping).length - const filteredBlocksCount = totalBlocks - includedBlocks - - // Log block to tools mapping for debugging - const blockToolsInfo = Object.entries(blockToToolsMapping) - .map(([blockType, tools]) => `${blockType}: [${tools.join(', ')}]`) - .sort() - - logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, { - totalBlocks, - includedBlocks, - filteredBlocks: filteredBlocksCount, - filterCategory, - blockToolsMapping: blockToolsInfo, - outputMapping: blockToToolsMapping, - specialBlocksAdded: Object.keys(specialBlocks).filter( - (blockType) => - !filterCategory || - specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory - ), - }) - - return NextResponse.json({ - success: true, - data: blockToToolsMapping, - }) - } catch (error) { - logger.error('Get all blocks failed', error) - return NextResponse.json( - { - success: false, - error: `Failed to get blocks and tools: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/tools/get-user-workflow/route.ts b/apps/sim/app/api/tools/get-user-workflow/route.ts deleted file mode 100644 index 94889577acc..00000000000 --- a/apps/sim/app/api/tools/get-user-workflow/route.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' -import { getBlock } from '@/blocks' -import { db } from '@/db' -import { workflow as workflowTable } from '@/db/schema' - -const logger = createLogger('GetUserWorkflowAPI') - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { workflowId, includeMetadata = false } = body - - if (!workflowId) { - return NextResponse.json( - { success: false, error: 'Workflow ID is required' }, - { status: 400 } - ) - } - - logger.info('Fetching user workflow', { workflowId }) - - // Fetch workflow from database - const [workflowRecord] = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - - if (!workflowRecord) { - return NextResponse.json( - { success: false, error: `Workflow ${workflowId} not found` }, - { status: 404 } - ) - } - - // Try to load from normalized tables first, fallback to JSON blob - let workflowState: any = null - const subBlockValues: Record> = {} - - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) - if (normalizedData) { - workflowState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - } - - // Extract subblock values from normalized data - Object.entries(normalizedData.blocks).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if ((subBlock as any).value !== undefined) { - subBlockValues[blockId][subBlockId] = (subBlock as any).value - } - }) - }) - } else if (workflowRecord.state) { - // Fallback to JSON blob - workflowState = workflowRecord.state as any - // For JSON blob, subblock values are embedded in the block state - Object.entries((workflowState.blocks as any) || {}).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subBlockId, subBlock]) => { - if ((subBlock as any).value !== undefined) { - subBlockValues[blockId][subBlockId] = (subBlock as any).value - } - }) - }) - } - - if (!workflowState || !workflowState.blocks) { - return NextResponse.json( - { success: false, error: 'Workflow state is empty or invalid' }, - { status: 400 } - ) - } - - // Generate YAML using server-side function - const yaml = generateWorkflowYaml(workflowState, subBlockValues) - - if (!yaml || yaml.trim() === '') { - return NextResponse.json( - { success: false, error: 'Generated YAML is empty' }, - { status: 400 } - ) - } - - // Generate detailed block information with schemas - const blockSchemas: Record = {} - Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => { - const block = blockState as any - const blockConfig = getBlock(block.type) - - if (blockConfig) { - blockSchemas[blockId] = { - type: block.type, - name: block.name, - description: blockConfig.description, - longDescription: blockConfig.longDescription, - category: blockConfig.category, - docsLink: blockConfig.docsLink, - inputs: {}, - inputRequirements: blockConfig.inputs || {}, - outputs: blockConfig.outputs || {}, - tools: blockConfig.tools, - } - - // Add input schema from subBlocks configuration - if (blockConfig.subBlocks) { - blockConfig.subBlocks.forEach((subBlock) => { - blockSchemas[blockId].inputs[subBlock.id] = { - type: subBlock.type, - title: subBlock.title, - description: subBlock.description || '', - layout: subBlock.layout, - ...(subBlock.options && { options: subBlock.options }), - ...(subBlock.placeholder && { placeholder: subBlock.placeholder }), - ...(subBlock.min !== undefined && { min: subBlock.min }), - ...(subBlock.max !== undefined && { max: subBlock.max }), - ...(subBlock.columns && { columns: subBlock.columns }), - ...(subBlock.hidden !== undefined && { hidden: subBlock.hidden }), - ...(subBlock.condition && { condition: subBlock.condition }), - } - }) - } - } else { - // Handle special block types like loops and parallels - blockSchemas[blockId] = { - type: block.type, - name: block.name, - description: `${block.type.charAt(0).toUpperCase() + block.type.slice(1)} container block`, - category: 'Control Flow', - inputs: {}, - outputs: {}, - } - } - }) - - // Generate workflow summary - const blockTypes = Object.values(workflowState.blocks).reduce( - (acc: Record, block: any) => { - acc[block.type] = (acc[block.type] || 0) + 1 - return acc - }, - {} - ) - - const categories = Object.values(blockSchemas).reduce( - (acc: Record, schema: any) => { - if (schema.category) { - acc[schema.category] = (acc[schema.category] || 0) + 1 - } - return acc - }, - {} - ) - - // Prepare response with clear context markers - const response: any = { - workflowContext: 'USER_SPECIFIC_WORKFLOW', // Clear marker for the LLM - note: 'This data represents only the blocks and configurations that the user has actually built in their current workflow, not all available Sim Studio capabilities.', - yaml, - format: 'yaml', - summary: { - workflowName: workflowRecord.name, - blockCount: Object.keys(workflowState.blocks).length, - edgeCount: (workflowState.edges || []).length, - blockTypes, - categories, - hasLoops: Object.keys(workflowState.loops || {}).length > 0, - hasParallels: Object.keys(workflowState.parallels || {}).length > 0, - }, - userBuiltBlocks: blockSchemas, // Renamed to be clearer - } - - // Add metadata if requested - if (includeMetadata) { - response.metadata = { - workflowId: workflowRecord.id, - name: workflowRecord.name, - description: workflowRecord.description, - workspaceId: workflowRecord.workspaceId, - createdAt: workflowRecord.createdAt, - updatedAt: workflowRecord.updatedAt, - } - } - - logger.info('Successfully fetched user workflow YAML', { - workflowId, - blockCount: response.summary.blockCount, - yamlLength: yaml.length, - }) - - return NextResponse.json({ - success: true, - output: response, - }) - } catch (error) { - logger.error('Failed to get workflow YAML:', error) - return NextResponse.json( - { - success: false, - error: `Failed to get workflow YAML: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 0bc5d942266..44faa78eae7 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -155,12 +155,48 @@ const sseHandlers: Record = { // Handle tool result events (custom event for preview_workflow) tool_result: (data, context, get, set) => { const { toolCallId, result, success } = data - logger.info('Received tool_result event', { toolCallId, success, hasResult: !!result }) + logger.info('Received tool_result event', { + toolCallId, + success, + hasResult: !!result, + doneEventCount: context.doneEventCount, + streamComplete: context.streamComplete + }) + + // Reset stream completion if we're still receiving tool results + if (context.streamComplete) { + logger.warn('Received tool result after stream marked complete, reopening stream') + context.streamComplete = false + } if (!toolCallId) return - const toolCall = context.toolCalls.find((tc) => tc.id === toolCallId) - if (!toolCall) return + let toolCall = context.toolCalls.find((tc) => tc.id === toolCallId) + if (!toolCall) { + logger.warn('Tool call not found in context for result, checking content blocks', { + toolCallId, + existingToolCalls: context.toolCalls.map(tc => ({ id: tc.id, name: tc.name })) + }) + + // Try to find the tool call in existing content blocks + for (const block of context.contentBlocks) { + if (block.type === 'tool_call' && block.toolCall.id === toolCallId) { + toolCall = block.toolCall + // Add it back to context.toolCalls so we can update it + context.toolCalls.push(toolCall) + logger.info('Found tool call in content blocks, added to context', { + toolCallId, + toolName: toolCall.name + }) + break + } + } + + if (!toolCall) { + logger.error('Tool call not found anywhere for result', { toolCallId }) + return + } + } logger.info('Found existing tool call for result', { name: toolCall.name, @@ -168,7 +204,17 @@ const sseHandlers: Record = { }) if (success) { - toolCall.result = result + // Parse result if it's a string (sim agent sometimes stringifies the result) + let parsedResult = result + if (typeof result === 'string' && result.startsWith('{')) { + try { + parsedResult = JSON.parse(result) + } catch (e) { + logger.warn('Failed to parse tool result as JSON, using as-is', { toolName: toolCall.name }) + } + } + + toolCall.result = parsedResult toolCall.endTime = Date.now() toolCall.duration = toolCall.endTime - (toolCall.startTime || Date.now()) @@ -180,25 +226,37 @@ const sseHandlers: Record = { } logger.info('Updated tool call result:', toolCallId, toolCall.name) + + // Update the content block to reflect the tool completion + updateContentBlockToolCall(context.contentBlocks, toolCallId, toolCall) + updateStreamingMessage(set, context) + + // Log successful tool completion + logger.info('Tool completed successfully', { + toolId: toolCallId, + toolName: toolCall.name, + state: toolCall.state, + duration: toolCall.duration + }) // Handle successful preview_workflow tool result - if (toolCall.name === 'preview_workflow' && result?.yamlContent) { + if (toolCall.name === 'preview_workflow' && parsedResult?.yamlContent) { logger.info('Setting preview YAML from tool_result event', { - yamlLength: result.yamlContent.length, - yamlPreview: result.yamlContent.substring(0, 100), + yamlLength: parsedResult.yamlContent.length, + yamlPreview: parsedResult.yamlContent.substring(0, 100), }) - get().setPreviewYaml(result.yamlContent) - get().updateDiffStore(result.yamlContent) + get().setPreviewYaml(parsedResult.yamlContent) + get().updateDiffStore(parsedResult.yamlContent) } // Handle successful targeted_updates tool result - if (toolCall.name === 'targeted_updates' && result?.yamlContent) { + if (toolCall.name === 'targeted_updates' && parsedResult?.yamlContent) { logger.info('Setting preview YAML from targeted_updates tool_result event', { - yamlLength: result.yamlContent.length, - yamlPreview: result.yamlContent.substring(0, 200), + yamlLength: parsedResult.yamlContent.length, + yamlPreview: parsedResult.yamlContent.substring(0, 200), }) - get().setPreviewYaml(result.yamlContent) - get().updateDiffStore(result.yamlContent) + get().setPreviewYaml(parsedResult.yamlContent) + get().updateDiffStore(parsedResult.yamlContent) } } else { // Tool execution failed @@ -284,7 +342,34 @@ const sseHandlers: Record = { // Handle sim agent's tool call format tool_call: (data, context, get, set) => { const toolData = data.data - if (!toolData || toolData.partial) return + if (!toolData) return + + // Log partial tool calls for debugging + if (toolData.partial) { + logger.debug('Received partial tool_call', { + id: toolData.id, + name: toolData.name, + partial: true + }) + return + } + + // Check if this tool call already exists (in case of duplicate events) + const existingToolCall = context.toolCalls.find(tc => tc.id === toolData.id) + if (existingToolCall) { + logger.warn('Tool call already exists, skipping duplicate', { + id: toolData.id, + name: toolData.name, + existingState: existingToolCall.state + }) + return + } + + logger.info('Creating tool call from tool_call event', { + id: toolData.id, + name: toolData.name, + hasArguments: !!toolData.arguments + }) const toolCall = { id: toolData.id, @@ -382,14 +467,24 @@ const sseHandlers: Record = { }, // Handle sim agent's done event - done: (data, context) => { + done: (data, context, get, set) => { context.doneEventCount++ logger.info('Received done event from sim agent', { doneEventCount: context.doneEventCount, + pendingToolCalls: context.toolCalls.filter(tc => tc.state === 'executing').length }) context.currentTextBlock = null + // Don't complete stream if there are still executing tool calls + const executingToolCalls = context.toolCalls.filter(tc => tc.state === 'executing') + if (executingToolCalls.length > 0) { + logger.info('Done event received but tools still executing', { + executingTools: executingToolCalls.map(tc => ({ id: tc.id, name: tc.name })) + }) + return + } + // Complete stream after multiple done events (sim agent sends one after tools and one at end) if (context.doneEventCount >= 2) { logger.info('Received final done event, completing stream') @@ -1194,11 +1289,21 @@ export const useCopilotStore = create()( doneEventCount: 0, } - // If continuation, start with existing message content + // If continuation, preserve existing message state if (isContinuation) { const { messages } = get() const existingMessage = messages.find((msg) => msg.id === messageId) - context.accumulatedContent = existingMessage?.content || '' + if (existingMessage) { + context.accumulatedContent = existingMessage.content || '' + context.toolCalls = existingMessage.toolCalls ? [...existingMessage.toolCalls] : [] + context.contentBlocks = existingMessage.contentBlocks ? [...existingMessage.contentBlocks] : [] + + logger.info('Continuing stream with existing state', { + messageId, + existingToolCalls: context.toolCalls.length, + existingContentBlocks: context.contentBlocks.length + }) + } } // Add timeout to prevent hanging From cd35292c748988c75ab8ba7577d4be420c70ae3d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 22:57:39 -0700 Subject: [PATCH 087/184] Works --- apps/sim/app/api/copilot/methods/route.ts | 2 +- .../app/api/copilot/targeted-updates/route.ts | 28 ++-- apps/sim/lib/copilot/tools.ts | 53 ++------ apps/sim/stores/copilot/store.ts | 127 +++++++++++++----- 4 files changed, 118 insertions(+), 92 deletions(-) diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index 94bc1db60d6..f500fdbfedf 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -24,7 +24,7 @@ const MethodExecutionSchema = z.object({ // Simple internal API key authentication function checkInternalApiKey(req: NextRequest) { const apiKey = req.headers.get('x-api-key') - const expectedApiKey = process.env.INTERNAL_API_KEY + const expectedApiKey = process.env.INTERNAL_API_SECRET if (!expectedApiKey) { return { success: false, error: 'Internal API key not configured' } diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts index 7262f249587..6297ee0a19b 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -10,16 +10,6 @@ const logger = createLogger('TargetedUpdatesAPI') export async function targetedUpdates(params: any) { try { - // Get authenticated user ID from params (assumes authentication is handled by the methods route) - const authenticatedUserId = params.userId - - if (!authenticatedUserId) { - return { - success: false, - error: 'User authentication required', - } - } - const { operations, workflowId } = params if (!operations || !Array.isArray(operations)) { @@ -37,24 +27,30 @@ export async function targetedUpdates(params: any) { } logger.info('Processing targeted update request', { - userId: authenticatedUserId + workflowId, + operationCount: operations.length }) // Execute the copilot tool const result = await executeCopilotTool('targeted_updates', { operations: params.operations, _context: { - workflowId: params.workflowId, - userId: authenticatedUserId + workflowId: params.workflowId }, }) logger.info('Targeted update completed successfully') - return { - success: true, - data: result, + // Return the tool result directly if successful + if (result.success && result.data) { + return { + success: true, + data: result.data, + } } + + // Return error result as-is + return result } catch (error) { logger.error('Targeted update failed:', error) return { diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts index fb1dd25ace3..425bd0b833e 100644 --- a/apps/sim/lib/copilot/tools.ts +++ b/apps/sim/lib/copilot/tools.ts @@ -578,61 +578,26 @@ const targetedUpdatesTool: CopilotTool = { } } - // Get current workflow state from database - const { db } = await import('@/db') - const { workflow, workflowBlocks } = await import('@/db/schema') - const { eq } = await import('drizzle-orm') - - const workflowData = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData.length) { - return { - success: false, - error: 'Workflow not found', - } - } - - // Get current workflow YAML directly from the API endpoint (not the client-side store) - const workflowResponse = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-user-workflow`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workflowId: workflowId, - includeMetadata: false, - }), - } - ) - - if (!workflowResponse.ok) { - return { - success: false, - error: `Failed to get current workflow YAML: ${workflowResponse.status} ${workflowResponse.statusText}`, - } - } - - const getUserWorkflowResult = await workflowResponse.json() + // Get current workflow YAML directly by calling the function + const { getUserWorkflow } = await import('@/app/api/copilot/get-user-workflow/route') + + const getUserWorkflowResult = await getUserWorkflow({ + workflowId: workflowId, + includeMetadata: false, + }) - if (!getUserWorkflowResult.success || !getUserWorkflowResult.output?.yaml) { + if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) { return { success: false, error: 'Failed to get current workflow YAML', } } - const currentYaml = getUserWorkflowResult.output.yaml + const currentYaml = getUserWorkflowResult.data logger.info('Retrieved current workflow YAML', { yamlLength: currentYaml.length, yamlPreview: currentYaml.substring(0, 200), - getUserWorkflowData: getUserWorkflowResult.output, }) // Apply operations to generate modified YAML diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 44faa78eae7..a5a5ae77a72 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -240,23 +240,43 @@ const sseHandlers: Record = { }) // Handle successful preview_workflow tool result - if (toolCall.name === 'preview_workflow' && parsedResult?.yamlContent) { - logger.info('Setting preview YAML from tool_result event', { - yamlLength: parsedResult.yamlContent.length, - yamlPreview: parsedResult.yamlContent.substring(0, 100), - }) - get().setPreviewYaml(parsedResult.yamlContent) - get().updateDiffStore(parsedResult.yamlContent) + if (toolCall.name === 'preview_workflow') { + // Check both direct yamlContent and nested data.yamlContent + const yamlContent = parsedResult?.yamlContent || parsedResult?.data?.yamlContent + if (yamlContent) { + logger.info('Setting preview YAML from tool_result event', { + yamlLength: yamlContent.length, + yamlPreview: yamlContent.substring(0, 100), + }) + get().setPreviewYaml(yamlContent) + get().updateDiffStore(yamlContent) + } else { + logger.warn('No yamlContent found in preview_workflow result', { + hasDirectYaml: !!parsedResult?.yamlContent, + hasNestedYaml: !!parsedResult?.data?.yamlContent, + resultStructure: Object.keys(parsedResult || {}) + }) + } } // Handle successful targeted_updates tool result - if (toolCall.name === 'targeted_updates' && parsedResult?.yamlContent) { - logger.info('Setting preview YAML from targeted_updates tool_result event', { - yamlLength: parsedResult.yamlContent.length, - yamlPreview: parsedResult.yamlContent.substring(0, 200), - }) - get().setPreviewYaml(parsedResult.yamlContent) - get().updateDiffStore(parsedResult.yamlContent) + if (toolCall.name === 'targeted_updates') { + // Check both direct yamlContent and nested data.yamlContent + const yamlContent = parsedResult?.yamlContent || parsedResult?.data?.yamlContent + if (yamlContent) { + logger.info('Setting preview YAML from targeted_updates tool_result event', { + yamlLength: yamlContent.length, + yamlPreview: yamlContent.substring(0, 200), + }) + get().setPreviewYaml(yamlContent) + get().updateDiffStore(yamlContent) + } else { + logger.warn('No yamlContent found in targeted_updates result', { + hasDirectYaml: !!parsedResult?.yamlContent, + hasNestedYaml: !!parsedResult?.data?.yamlContent, + resultStructure: Object.keys(parsedResult || {}) + }) + } } } else { // Tool execution failed @@ -344,22 +364,18 @@ const sseHandlers: Record = { const toolData = data.data if (!toolData) return - // Log partial tool calls for debugging - if (toolData.partial) { - logger.debug('Received partial tool_call', { - id: toolData.id, - name: toolData.name, - partial: true - }) - return - } - // Check if this tool call already exists (in case of duplicate events) const existingToolCall = context.toolCalls.find(tc => tc.id === toolData.id) if (existingToolCall) { - logger.warn('Tool call already exists, skipping duplicate', { + // If it's a partial update, we might want to update the existing tool call + if (toolData.partial && toolData.arguments) { + // Update partial arguments if needed + existingToolCall.input = { ...existingToolCall.input, ...toolData.arguments } + } + logger.debug('Tool call already exists, skipping or updating', { id: toolData.id, name: toolData.name, + partial: toolData.partial, existingState: existingToolCall.state }) return @@ -368,7 +384,8 @@ const sseHandlers: Record = { logger.info('Creating tool call from tool_call event', { id: toolData.id, name: toolData.name, - hasArguments: !!toolData.arguments + hasArguments: !!toolData.arguments, + partial: toolData.partial }) const toolCall = { @@ -440,10 +457,33 @@ const sseHandlers: Record = { updateStreamingMessage(set, context) // Handle preview_workflow completion - if (context.toolCallBuffer.name === 'preview_workflow' && context.toolCallBuffer.input?.yamlContent) { - logger.info('Setting preview YAML from completed preview_workflow tool call') - get().setPreviewYaml(context.toolCallBuffer.input.yamlContent) - get().updateDiffStore(context.toolCallBuffer.input.yamlContent) + if (context.toolCallBuffer.name === 'preview_workflow') { + // Check both direct yamlContent and nested data.yamlContent + const yamlContent = context.toolCallBuffer.input?.yamlContent || + context.toolCallBuffer.input?.data?.yamlContent + if (yamlContent) { + logger.info('Setting preview YAML from completed preview_workflow tool call', { + yamlLength: yamlContent.length, + yamlPreview: yamlContent.substring(0, 100) + }) + get().setPreviewYaml(yamlContent) + get().updateDiffStore(yamlContent) + } + } + + // Handle targeted_updates completion + if (context.toolCallBuffer.name === 'targeted_updates') { + // Check both direct yamlContent and nested data.yamlContent + const yamlContent = context.toolCallBuffer.input?.yamlContent || + context.toolCallBuffer.input?.data?.yamlContent + if (yamlContent) { + logger.info('Setting preview YAML from completed targeted_updates tool call', { + yamlLength: yamlContent.length, + yamlPreview: yamlContent.substring(0, 100) + }) + get().setPreviewYaml(yamlContent) + get().updateDiffStore(yamlContent) + } } } catch (error) { logger.error('Error parsing tool call input:', error) @@ -1634,7 +1674,18 @@ export const useCopilotStore = create()( // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') - logger.info('Updating diff store with copilot YAML') + logger.info('Updating diff store with copilot YAML', { + yamlLength: yamlContent.length, + yamlPreview: yamlContent.substring(0, 200) + }) + + // Check current diff store state before update + const diffStoreBefore = useWorkflowDiffStore.getState() + logger.info('Diff store state before update:', { + isShowingDiff: diffStoreBefore.isShowingDiff, + isDiffReady: diffStoreBefore.isDiffReady, + hasDiffWorkflow: !!diffStoreBefore.diffWorkflow + }) // Generate diff analysis by comparing current vs proposed YAML let diffAnalysis = null @@ -1643,6 +1694,11 @@ export const useCopilotStore = create()( const { useWorkflowYamlStore } = await import('@/stores/workflows/yaml/store') const currentYaml = useWorkflowYamlStore.getState().getYaml() + logger.info('Got current workflow YAML for diff:', { + currentYamlLength: currentYaml?.length || 0, + hasCurrentYaml: !!currentYaml + }) + // Call the diff API to compare current vs proposed YAML const diffResponse = await fetch('/api/workflows/diff', { method: 'POST', @@ -1676,6 +1732,15 @@ export const useCopilotStore = create()( const diffStore = useWorkflowDiffStore.getState() await diffStore.setProposedChanges(yamlContent, diffAnalysis) + // Check diff store state after update + const diffStoreAfter = useWorkflowDiffStore.getState() + logger.info('Diff store state after update:', { + isShowingDiff: diffStoreAfter.isShowingDiff, + isDiffReady: diffStoreAfter.isDiffReady, + hasDiffWorkflow: !!diffStoreAfter.diffWorkflow, + diffWorkflowBlockCount: diffStoreAfter.diffWorkflow ? Object.keys(diffStoreAfter.diffWorkflow.blocks).length : 0 + }) + logger.info('Successfully updated diff store with proposed workflow changes') } catch (error) { logger.error('Failed to update diff store:', error) From 417ffb3eba5000b1d61e60ee230119dd1ac8b4d4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 23:20:21 -0700 Subject: [PATCH 088/184] It works --- apps/sim/app/api/copilot/methods/route.ts | 2 + .../app/api/copilot/online-search/route.ts | 72 ++ .../app/api/copilot/targeted-updates/route.ts | 277 ++++- apps/sim/lib/copilot/tools.ts | 1033 ----------------- 4 files changed, 336 insertions(+), 1048 deletions(-) create mode 100644 apps/sim/app/api/copilot/online-search/route.ts delete mode 100644 apps/sim/lib/copilot/tools.ts diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index f500fdbfedf..287dd2afedc 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -12,6 +12,7 @@ import { getUserWorkflow } from '../get-user-workflow/route' import { getBlocksMetadata } from '../get-blocks-metadata/route' import { getYamlStructure } from '../get-yaml-structure/route' import { targetedUpdates } from '../targeted-updates/route' +import { onlineSearch } from '../online-search/route' const logger = createLogger('CopilotMethodsAPI') @@ -54,6 +55,7 @@ const METHODS = { 'get_blocks_metadata': getBlocksMetadata, 'get_yaml_structure': getYamlStructure, 'targeted_updates': targetedUpdates, + 'online_search': onlineSearch, } as const /** diff --git a/apps/sim/app/api/copilot/online-search/route.ts b/apps/sim/app/api/copilot/online-search/route.ts new file mode 100644 index 00000000000..47033c09526 --- /dev/null +++ b/apps/sim/app/api/copilot/online-search/route.ts @@ -0,0 +1,72 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' +import { executeTool } from '@/tools' + +const logger = createLogger('OnlineSearchAPI') + +export const dynamic = 'force-dynamic' + +export async function onlineSearch(params: any) { + const { query, num = 10, type = 'search', gl, hl } = params + + if (!query) { + throw new Error('Query is required') + } + + logger.info('Performing online search', { + query, + num, + type, + gl, + hl + }) + + try { + // Execute the serper_search tool + const toolParams = { + query, + num, + type, + gl, + hl, + apiKey: process.env.SERPER_API_KEY || '', + } + + const result = await executeTool('serper_search', toolParams) + + if (!result.success) { + throw new Error(result.error || 'Search failed') + } + + // The serper tool already formats the results properly + return { + success: true, + data: { + results: result.output.searchResults || [], + query, + type, + totalResults: result.output.searchResults?.length || 0, + }, + } + } catch (error) { + logger.error('Online search failed', error) + throw error + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const result = await onlineSearch(body) + return NextResponse.json(result) + } catch (error) { + logger.error('Online search API error:', error) + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to perform online search', + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts index 6297ee0a19b..2f18db70cff 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -1,13 +1,236 @@ import { type NextRequest, NextResponse } from 'next/server' import { eq } from 'drizzle-orm' import { getSession } from '@/lib/auth' -import { executeCopilotTool } from '@/lib/copilot/tools' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { apiKey as apiKeyTable } from '@/db/schema' const logger = createLogger('TargetedUpdatesAPI') +// Types for operations +interface TargetedUpdateOperation { + operation_type: 'add' | 'edit' | 'delete' + block_id: string + params?: Record +} + +/** + * Apply operations to YAML workflow + */ +async function applyOperationsToYaml( + currentYaml: string, + operations: TargetedUpdateOperation[] +): Promise { + const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') + const yaml = await import('yaml') + + // Parse current YAML to get the complete structure + const { data: workflowData, errors } = parseWorkflowYaml(currentYaml) + if (!workflowData || errors.length > 0) { + throw new Error(`Failed to parse current YAML: ${errors.join(', ')}`) + } + + // Apply operations to the parsed YAML data (preserving all existing fields) + logger.info('Starting YAML operations', { + initialBlockCount: Object.keys(workflowData.blocks).length, + version: workflowData.version, + operationCount: operations.length, + }) + + for (const operation of operations) { + const { operation_type, block_id, params } = operation + + logger.info(`Processing operation: ${operation_type} for block ${block_id}`, { params }) + + switch (operation_type) { + case 'delete': + if (workflowData.blocks[block_id]) { + // First, find child blocks that reference this block as parent (before deleting the parent) + const childBlocksToRemove: string[] = [] + Object.entries(workflowData.blocks).forEach( + ([childBlockId, childBlock]: [string, any]) => { + if (childBlock.parentId === block_id) { + logger.info( + `Found child block ${childBlockId} with parentId ${block_id}, marking for deletion` + ) + childBlocksToRemove.push(childBlockId) + } + } + ) + + // Delete the main block + delete workflowData.blocks[block_id] + logger.info(`Deleted block ${block_id}`) + + // Remove child blocks + childBlocksToRemove.forEach((childBlockId) => { + if (workflowData.blocks[childBlockId]) { + delete workflowData.blocks[childBlockId] + logger.info(`Deleted child block ${childBlockId}`) + } + }) + + // Remove connections mentioning this block or any of its children + const allDeletedBlocks = [block_id, ...childBlocksToRemove] + Object.values(workflowData.blocks).forEach((block: any) => { + if (block.connections) { + Object.keys(block.connections).forEach((key) => { + const connectionValue = block.connections[key] + + if (typeof connectionValue === 'string') { + // Simple format: connections: { default: "block2" } + if (allDeletedBlocks.includes(connectionValue)) { + delete block.connections[key] + logger.info(`Removed connection ${key} to deleted block ${connectionValue}`) + } + } else if (Array.isArray(connectionValue)) { + // Array format: connections: { default: ["block2", "block3"] } + block.connections[key] = connectionValue.filter((item: any) => { + if (typeof item === 'string') { + return !allDeletedBlocks.includes(item) + } + if (typeof item === 'object' && item.block) { + return !allDeletedBlocks.includes(item.block) + } + return true + }) + + // If array is empty after filtering, remove the connection + if (block.connections[key].length === 0) { + delete block.connections[key] + } + } else if (typeof connectionValue === 'object' && connectionValue.block) { + // Object format: connections: { success: { block: "block2", input: "data" } } + if (allDeletedBlocks.includes(connectionValue.block)) { + delete block.connections[key] + logger.info( + `Removed object connection ${key} to deleted block ${connectionValue.block}` + ) + } + } + }) + } + }) + } else { + logger.warn(`Block ${block_id} not found for deletion`) + } + break + + case 'edit': + if (workflowData.blocks[block_id]) { + const block = workflowData.blocks[block_id] + + // Update inputs (preserve existing inputs, only overwrite specified ones) + if (params?.inputs) { + if (!block.inputs) block.inputs = {} + Object.assign(block.inputs, params.inputs) + logger.info(`Updated inputs for block ${block_id}`, { inputs: block.inputs }) + } + + // Update connections (preserve existing connections, only overwrite specified ones) + if (params?.connections) { + if (!block.connections) block.connections = {} + + // Handle edge removals - if a connection is explicitly set to null, remove it + Object.entries(params.connections).forEach(([key, value]) => { + if (value === null) { + delete (block.connections as any)[key] + logger.info(`Removed connection ${key} from block ${block_id}`) + } else { + ;(block.connections as any)[key] = value + } + }) + + logger.info(`Updated connections for block ${block_id}`, { + connections: block.connections, + }) + } + + // Handle edge removals when specified in params + if (params?.removeEdges && Array.isArray(params.removeEdges)) { + params.removeEdges.forEach( + (edgeToRemove: { + targetBlockId: string + sourceHandle?: string + targetHandle?: string + }) => { + if (!block.connections) return + + const { targetBlockId, sourceHandle = 'default' } = edgeToRemove + + // Handle different connection formats + const connectionValue = (block.connections as any)[sourceHandle] + + if (typeof connectionValue === 'string') { + // Simple format: connections: { default: "block2" } + if (connectionValue === targetBlockId) { + delete (block.connections as any)[sourceHandle] + logger.info(`Removed edge from ${block_id}:${sourceHandle} to ${targetBlockId}`) + } + } else if (Array.isArray(connectionValue)) { + // Array format: connections: { default: ["block2", "block3"] } + ;(block.connections as any)[sourceHandle] = connectionValue.filter( + (item: any) => { + if (typeof item === 'string') { + return item !== targetBlockId + } + if (typeof item === 'object' && item.block) { + return item.block !== targetBlockId + } + return true + } + ) + + // If array is empty after filtering, remove the connection + if ((block.connections as any)[sourceHandle].length === 0) { + delete (block.connections as any)[sourceHandle] + } + + logger.info(`Updated array connection for ${block_id}:${sourceHandle}`) + } else if (typeof connectionValue === 'object' && connectionValue.block) { + // Object format: connections: { success: { block: "block2", input: "data" } } + if (connectionValue.block === targetBlockId) { + delete (block.connections as any)[sourceHandle] + logger.info( + `Removed object connection from ${block_id}:${sourceHandle} to ${targetBlockId}` + ) + } + } + } + ) + } + } else { + logger.warn(`Block ${block_id} not found for editing`) + } + break + + case 'add': + if (params?.type && params?.name) { + workflowData.blocks[block_id] = { + type: params.type, + name: params.name, + inputs: params.inputs || {}, + connections: params.connections || {}, + } + logger.info(`Added block ${block_id}`, { type: params.type, name: params.name }) + } else { + logger.warn(`Invalid add operation for block ${block_id} - missing type or name`) + } + break + + default: + logger.warn(`Unknown operation type: ${operation_type}`) + } + } + + logger.info('Completed YAML operations', { + finalBlockCount: Object.keys(workflowData.blocks).length, + }) + + // Convert the complete workflow data back to YAML (preserving version and all other fields) + return yaml.stringify(workflowData) +} + export async function targetedUpdates(params: any) { try { const { operations, workflowId } = params @@ -31,26 +254,50 @@ export async function targetedUpdates(params: any) { operationCount: operations.length }) - // Execute the copilot tool - const result = await executeCopilotTool('targeted_updates', { - operations: params.operations, - _context: { - workflowId: params.workflowId - }, + // Get current workflow YAML directly by calling the function + const { getUserWorkflow } = await import('@/app/api/copilot/get-user-workflow/route') + + const getUserWorkflowResult = await getUserWorkflow({ + workflowId: workflowId, + includeMetadata: false, }) - logger.info('Targeted update completed successfully') - - // Return the tool result directly if successful - if (result.success && result.data) { + if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) { return { - success: true, - data: result.data, + success: false, + error: 'Failed to get current workflow YAML', } } - // Return error result as-is - return result + const currentYaml = getUserWorkflowResult.data + + logger.info('Retrieved current workflow YAML', { + yamlLength: currentYaml.length, + yamlPreview: currentYaml.substring(0, 200), + }) + + // Apply operations to generate modified YAML + const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) + + logger.info('Applied operations to YAML', { + operationCount: operations.length, + currentYamlLength: currentYaml.length, + modifiedYamlLength: modifiedYaml.length, + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), + }) + + logger.info( + `Successfully generated modified YAML for ${operations.length} targeted update operations` + ) + + // Return the modified YAML directly - the UI will handle preview generation via updateDiffStore() + return { + success: true, + data: { + yamlContent: modifiedYaml, + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), + }, + } } catch (error) { logger.error('Targeted update failed:', error) return { diff --git a/apps/sim/lib/copilot/tools.ts b/apps/sim/lib/copilot/tools.ts deleted file mode 100644 index 425bd0b833e..00000000000 --- a/apps/sim/lib/copilot/tools.ts +++ /dev/null @@ -1,1033 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowYamlStore } from '@/stores/workflows/yaml/store' -import { WORKFLOW_EXAMPLES } from './examples' -import { searchDocumentation } from './service' - -const logger = createLogger('CopilotTools') - -/** - * Interface for copilot tool execution results - */ -export interface CopilotToolResult { - success: boolean - data?: any - error?: string -} - -/** - * Interface for copilot tool parameters - */ -export interface CopilotToolParameters { - type: 'object' - properties: Record - required: string[] -} - -/** - * Interface for copilot tool definitions - */ -export interface CopilotTool { - id: string - name: string - description: string - parameters: CopilotToolParameters - execute: (args: Record) => Promise -} - -/** - * Operation types for targeted updates - */ -export type TargetedUpdateOperationType = 'add' | 'edit' | 'delete' - -/** - * Interface for targeted update operation - */ -export interface TargetedUpdateOperation { - operation_type: TargetedUpdateOperationType - block_id: string - params?: any -} - -/** - * Interface for documentation search arguments - */ -interface DocsSearchArgs { - query: string - topK?: number -} - -/** - * Interface for workflow metadata - */ -interface WorkflowMetadata { - workflowId: string - name: string - description: string | undefined - workspaceId: string -} - -/** - * Interface for user workflow data - */ -interface UserWorkflowData { - yaml: string - metadata?: WorkflowMetadata -} - -/** - * Apply targeted update operations to YAML content - */ -async function applyOperationsToYaml( - currentYaml: string, - operations: TargetedUpdateOperation[] -): Promise { - const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') - const yaml = await import('yaml') - - // Parse current YAML to get the complete structure - const { data: workflowData, errors } = parseWorkflowYaml(currentYaml) - if (!workflowData || errors.length > 0) { - throw new Error(`Failed to parse current YAML: ${errors.join(', ')}`) - } - - // Apply operations to the parsed YAML data (preserving all existing fields) - logger.info('Starting YAML operations', { - initialBlockCount: Object.keys(workflowData.blocks).length, - version: workflowData.version, - operationCount: operations.length, - }) - - for (const operation of operations) { - const { operation_type, block_id, params } = operation - - logger.info(`Processing operation: ${operation_type} for block ${block_id}`, { params }) - - switch (operation_type) { - case 'delete': - if (workflowData.blocks[block_id]) { - // First, find child blocks that reference this block as parent (before deleting the parent) - const childBlocksToRemove: string[] = [] - Object.entries(workflowData.blocks).forEach( - ([childBlockId, childBlock]: [string, any]) => { - if (childBlock.parentId === block_id) { - logger.info( - `Found child block ${childBlockId} with parentId ${block_id}, marking for deletion` - ) - childBlocksToRemove.push(childBlockId) - } - } - ) - - // Delete the main block - delete workflowData.blocks[block_id] - logger.info(`Deleted block ${block_id}`) - - // Remove child blocks - childBlocksToRemove.forEach((childBlockId) => { - if (workflowData.blocks[childBlockId]) { - delete workflowData.blocks[childBlockId] - logger.info(`Deleted child block ${childBlockId}`) - } - }) - - // Remove connections mentioning this block or any of its children - const allDeletedBlocks = [block_id, ...childBlocksToRemove] - Object.values(workflowData.blocks).forEach((block: any) => { - if (block.connections) { - Object.keys(block.connections).forEach((key) => { - const connectionValue = block.connections[key] - - if (typeof connectionValue === 'string') { - // Simple format: connections: { default: "block2" } - if (allDeletedBlocks.includes(connectionValue)) { - delete block.connections[key] - logger.info(`Removed connection ${key} to deleted block ${connectionValue}`) - } - } else if (Array.isArray(connectionValue)) { - // Array format: connections: { default: ["block2", "block3"] } - block.connections[key] = connectionValue.filter((item: any) => { - if (typeof item === 'string') { - return !allDeletedBlocks.includes(item) - } - if (typeof item === 'object' && item.block) { - return !allDeletedBlocks.includes(item.block) - } - return true - }) - - // If array is empty after filtering, remove the connection - if (block.connections[key].length === 0) { - delete block.connections[key] - } - } else if (typeof connectionValue === 'object' && connectionValue.block) { - // Object format: connections: { success: { block: "block2", input: "data" } } - if (allDeletedBlocks.includes(connectionValue.block)) { - delete block.connections[key] - logger.info( - `Removed object connection ${key} to deleted block ${connectionValue.block}` - ) - } - } - }) - } - }) - } else { - logger.warn(`Block ${block_id} not found for deletion`) - } - break - - case 'edit': - if (workflowData.blocks[block_id]) { - const block = workflowData.blocks[block_id] - - // Update inputs (preserve existing inputs, only overwrite specified ones) - if (params?.inputs) { - if (!block.inputs) block.inputs = {} - Object.assign(block.inputs, params.inputs) - logger.info(`Updated inputs for block ${block_id}`, { inputs: block.inputs }) - } - - // Update connections (preserve existing connections, only overwrite specified ones) - if (params?.connections) { - if (!block.connections) block.connections = {} - - // Handle edge removals - if a connection is explicitly set to null, remove it - Object.entries(params.connections).forEach(([key, value]) => { - if (value === null) { - delete (block.connections as any)[key] - logger.info(`Removed connection ${key} from block ${block_id}`) - } else { - ;(block.connections as any)[key] = value - } - }) - - logger.info(`Updated connections for block ${block_id}`, { - connections: block.connections, - }) - } - - // Handle edge removals when specified in params - if (params?.removeEdges && Array.isArray(params.removeEdges)) { - params.removeEdges.forEach( - (edgeToRemove: { - targetBlockId: string - sourceHandle?: string - targetHandle?: string - }) => { - if (!block.connections) return - - const { targetBlockId, sourceHandle = 'default' } = edgeToRemove - - // Handle different connection formats - const connectionValue = (block.connections as any)[sourceHandle] - - if (typeof connectionValue === 'string') { - // Simple format: connections: { default: "block2" } - if (connectionValue === targetBlockId) { - delete (block.connections as any)[sourceHandle] - logger.info(`Removed edge from ${block_id}:${sourceHandle} to ${targetBlockId}`) - } - } else if (Array.isArray(connectionValue)) { - // Array format: connections: { default: ["block2", "block3"] } - ;(block.connections as any)[sourceHandle] = connectionValue.filter( - (item: any) => { - if (typeof item === 'string') { - return item !== targetBlockId - } - if (typeof item === 'object' && item.block) { - return item.block !== targetBlockId - } - return true - } - ) - - // If array is empty after filtering, remove the connection - if ((block.connections as any)[sourceHandle].length === 0) { - delete (block.connections as any)[sourceHandle] - } - - logger.info(`Updated array connection for ${block_id}:${sourceHandle}`) - } else if (typeof connectionValue === 'object' && connectionValue.block) { - // Object format: connections: { success: { block: "block2", input: "data" } } - if (connectionValue.block === targetBlockId) { - delete (block.connections as any)[sourceHandle] - logger.info( - `Removed object connection from ${block_id}:${sourceHandle} to ${targetBlockId}` - ) - } - } - } - ) - } - } else { - logger.warn(`Block ${block_id} not found for editing`) - } - break - - case 'add': - if (params?.type && params?.name) { - workflowData.blocks[block_id] = { - type: params.type, - name: params.name, - inputs: params.inputs || {}, - connections: params.connections || {}, - } - logger.info(`Added block ${block_id}`, { type: params.type, name: params.name }) - } else { - logger.warn(`Invalid add operation for block ${block_id} - missing type or name`) - } - break - - default: - logger.warn(`Unknown operation type: ${operation_type}`) - } - } - - logger.info('Completed YAML operations', { - finalBlockCount: Object.keys(workflowData.blocks).length, - }) - - // Convert the complete workflow data back to YAML (preserving version and all other fields) - return yaml.stringify(workflowData) -} - -/** - * Update block references in values to use new mapped IDs - * Uses the same logic as the YAML converter - */ -function updateBlockReferences(value: any, blockIdMapping: Map): any { - if (typeof value === 'string' && value.includes('<') && value.includes('>')) { - let processedValue = value - const blockMatches = value.match(/<([^>]+)>/g) - - if (blockMatches) { - for (const match of blockMatches) { - const path = match.slice(1, -1) - const [blockRef] = path.split('.') - - // Skip system references - if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { - continue - } - - // Check if this references an old block ID that needs mapping - const newMappedId = blockIdMapping.get(blockRef) - if (newMappedId) { - processedValue = processedValue.replace( - new RegExp(`<${blockRef}\\.`, 'g'), - `<${newMappedId}.` - ) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}>`, 'g'), - `<${newMappedId}>` - ) - } - } - } - - return processedValue - } - - // Handle arrays - if (Array.isArray(value)) { - return value.map((item) => updateBlockReferences(item, blockIdMapping)) - } - - // Handle objects - if (value !== null && typeof value === 'object') { - const result = { ...value } - for (const key in result) { - result[key] = updateBlockReferences(result[key], blockIdMapping) - } - return result - } - - return value -} - -/** - * Documentation search tool for copilot - */ -const docsSearchTool: CopilotTool = { - id: 'docs_search_internal', - name: 'Search Documentation', - description: - 'Search Sim Studio documentation for information about features, tools, workflows, and functionality', - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to find relevant documentation', - }, - topK: { - type: 'number', - description: 'Number of results to return (default: 10, max: 10)', - default: 10, - }, - }, - required: ['query'], - }, - execute: async (args: Record): Promise => { - try { - const { query, topK = 10 } = args - - // Call the API route directly - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/docs-search-internal`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query, topK }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Documentation search failed: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - logger.error('Documentation search failed', error) - return { - success: false, - error: `Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -/** - * Get user workflow as YAML tool for copilot - */ -const getUserWorkflowTool: CopilotTool = { - id: 'get_user_workflow', - name: 'Get User Workflow', - description: - 'Get the current user workflow as YAML format. This shows all blocks, their configurations, inputs, and connections in the workflow.', - parameters: { - type: 'object', - properties: { - includeMetadata: { - type: 'boolean', - description: 'Whether to include additional metadata about the workflow (default: false)', - default: false, - }, - }, - required: [], - }, - execute: async (args: Record): Promise => { - try { - const { includeMetadata = false, _context } = args - const workflowId = _context?.workflowId - - // Call the API route directly - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-user-workflow`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ workflowId, includeMetadata }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to get user workflow: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - logger.error('Get user workflow failed', error) - return { - success: false, - error: `Failed to get user workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -/** - * Get workflow examples tool for copilot - */ -export const getWorkflowExamplesTool: CopilotTool = { - id: 'get_workflow_examples', - name: 'Get Workflow Examples', - description: `Get YAML workflow examples by ID. Available example IDs: ${Object.keys(WORKFLOW_EXAMPLES).join(', ')}`, - parameters: { - type: 'object', - properties: { - exampleIds: { - type: 'array', - items: { - type: 'string', - }, - description: 'Array of example IDs to retrieve', - }, - }, - required: ['exampleIds'], - }, - execute: async (args: Record): Promise => { - try { - const { exampleIds } = args - - if (!Array.isArray(exampleIds)) { - return { - success: false, - error: 'exampleIds must be an array', - } - } - - const examples: Record = {} - const notFound: string[] = [] - - for (const id of exampleIds) { - if (WORKFLOW_EXAMPLES[id]) { - examples[id] = WORKFLOW_EXAMPLES[id] - } else { - notFound.push(id) - } - } - - return { - success: true, - data: { - examples, - notFound, - availableIds: Object.keys(WORKFLOW_EXAMPLES), - }, - } - } catch (error) { - logger.error('Get workflow examples failed', error) - return { - success: false, - error: `Failed to get workflow examples: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -/** - * Targeted updates tool for copilot - allows atomic add/edit/delete operations - */ -const targetedUpdatesTool: CopilotTool = { - id: 'targeted_updates', - name: 'Targeted Updates', - description: - 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Takes an array of operations to execute.', - parameters: { - type: 'object', - properties: { - operations: { - type: 'array', - description: 'Array of targeted update operations to perform', - items: { - type: 'object', - properties: { - operation_type: { - type: 'string', - enum: ['add', 'edit', 'delete'], - description: 'Type of operation to perform', - }, - block_id: { - type: 'string', - description: - 'Block ID for the operation. For add operations, this will be the desired ID for the new block.', - }, - params: { - type: 'object', - description: - 'Parameters for the operation. For add: full block YAML, for edit: partial updates to inputs/connections, for delete: empty', - }, - }, - required: ['operation_type', 'block_id'], - }, - }, - }, - required: ['operations'], - }, - execute: async (args: Record): Promise => { - try { - const { operations, _context } = args - - if (!Array.isArray(operations)) { - return { - success: false, - error: 'Operations must be an array', - } - } - - const workflowId = _context?.workflowId - - if (!workflowId) { - return { - success: false, - error: 'No workflow ID provided in context', - } - } - - // Get current workflow YAML directly by calling the function - const { getUserWorkflow } = await import('@/app/api/copilot/get-user-workflow/route') - - const getUserWorkflowResult = await getUserWorkflow({ - workflowId: workflowId, - includeMetadata: false, - }) - - if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) { - return { - success: false, - error: 'Failed to get current workflow YAML', - } - } - - const currentYaml = getUserWorkflowResult.data - - logger.info('Retrieved current workflow YAML', { - yamlLength: currentYaml.length, - yamlPreview: currentYaml.substring(0, 200), - }) - - // Apply operations to generate modified YAML - const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) - - logger.info('Applied operations to YAML', { - operationCount: operations.length, - currentYamlLength: currentYaml.length, - modifiedYamlLength: modifiedYaml.length, - operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), - }) - - logger.info( - `Successfully generated modified YAML for ${operations.length} targeted update operations` - ) - - // Return the modified YAML directly - the UI will handle preview generation via updateDiffStore() - return { - success: true, - data: { - yamlContent: modifiedYaml, - operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), - }, - } - } catch (error) { - logger.error('Targeted updates execution failed:', error) - return { - success: false, - error: `Targeted updates failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -/** - * Preview workflow tool for copilot - allows internal calls to preview functionality - */ -const previewWorkflowTool: CopilotTool = { - id: 'preview_workflow', - name: 'Preview Workflow', - description: 'Generate a sandbox preview of the workflow without saving it', - parameters: { - type: 'object', - properties: { - yamlContent: { - type: 'string', - description: 'The complete YAML workflow content to preview', - }, - description: { - type: 'string', - description: 'Optional description of the proposed changes', - }, - }, - required: ['yamlContent'], - }, - execute: async (args: Record): Promise => { - try { - const { yamlContent, description } = args - - // Call the API route directly - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/preview-workflow`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ yamlContent, description }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Preview generation failed: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - logger.error('Preview workflow execution failed:', error) - return { - success: false, - error: `Preview workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -/** - * Additional copilot tools - */ -const getBlocksAndToolsTool: CopilotTool = { - id: 'get_blocks_and_tools', - name: 'Get All Blocks and Tools', - description: 'Get a comprehensive list of all available blocks and tools in Sim Studio', - parameters: { - type: 'object', - properties: { - includeDetails: { - type: 'boolean', - description: 'Whether to include detailed information (default: false)', - default: false, - }, - filterCategory: { - type: 'string', - description: 'Optional category filter for blocks', - }, - }, - required: [], - }, - execute: async (args: Record): Promise => { - try { - const { includeDetails = false, filterCategory } = args - - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-blocks-and-tools`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ includeDetails, filterCategory }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to get blocks and tools: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - return { - success: false, - error: `Failed to get blocks and tools: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -const getBlocksMetadataTool: CopilotTool = { - id: 'get_blocks_metadata', - name: 'Get Block Metadata', - description: 'Get detailed metadata for specific blocks', - parameters: { - type: 'object', - properties: { - blockIds: { - type: 'array', - items: { type: 'string' }, - description: 'Array of block IDs to get metadata for', - }, - }, - required: ['blockIds'], - }, - execute: async (args: Record): Promise => { - try { - const { blockIds } = args - - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-blocks-metadata`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ blockIds }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to get blocks metadata: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - return { - success: false, - error: `Failed to get blocks metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -const getYamlStructureTool: CopilotTool = { - id: 'get_yaml_structure', - name: 'Get YAML Structure Guide', - description: 'Get YAML workflow syntax guide and examples', - parameters: { - type: 'object', - properties: {}, - required: [], - }, - execute: async (args: Record): Promise => { - try { - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-yaml-structure`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to get YAML structure: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - return { - success: false, - error: `Failed to get YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -const getEnvironmentVariablesTool: CopilotTool = { - id: 'get_environment_variables', - name: 'Get Environment Variables', - description: 'Get a list of available environment variable names', - parameters: { - type: 'object', - properties: {}, - required: [], - }, - execute: async (args: Record): Promise => { - try { - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-environment-variables`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to get environment variables: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - return { - success: false, - error: `Failed to get environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -const setEnvironmentVariablesTool: CopilotTool = { - id: 'set_environment_variables', - name: 'Set Environment Variables', - description: 'Set or update environment variables', - parameters: { - type: 'object', - properties: { - variables: { - type: 'object', - description: 'Key-value object of environment variables to set', - }, - }, - required: ['variables'], - }, - execute: async (args: Record): Promise => { - try { - const { variables } = args - - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/set-environment-variables`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ variables }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to set environment variables: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - return { - success: false, - error: `Failed to set environment variables: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -const getWorkflowConsoleTool: CopilotTool = { - id: 'get_workflow_console', - name: 'Get Workflow Console', - description: 'Get console logs and execution history from the workflow', - parameters: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Maximum number of console entries to return (default: 50)', - default: 50, - }, - includeDetails: { - type: 'boolean', - description: 'Whether to include detailed input/output data (default: false)', - default: false, - }, - }, - required: [], - }, - execute: async (args: Record): Promise => { - try { - const { limit = 50, includeDetails = false } = args - - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/get-workflow-console`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ limit, includeDetails }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Failed to get workflow console: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return result - } catch (error) { - return { - success: false, - error: `Failed to get workflow console: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } - }, -} - -/** - * Copilot tools registry - */ -const copilotTools: Record = { - docs_search_internal: docsSearchTool, - get_user_workflow: getUserWorkflowTool, - get_workflow_examples: getWorkflowExamplesTool, - get_blocks_and_tools: getBlocksAndToolsTool, - get_blocks_metadata: getBlocksMetadataTool, - get_yaml_structure: getYamlStructureTool, - preview_workflow: previewWorkflowTool, - targeted_updates: targetedUpdatesTool, - get_environment_variables: getEnvironmentVariablesTool, - set_environment_variables: setEnvironmentVariablesTool, - get_workflow_console: getWorkflowConsoleTool, -} - -/** - * Get a copilot tool by ID - */ -export function getCopilotTool(toolId: string): CopilotTool | undefined { - return copilotTools[toolId] -} - -/** - * Execute a copilot tool - */ -export async function executeCopilotTool( - toolId: string, - args: Record -): Promise { - const tool = getCopilotTool(toolId) - - if (!tool) { - logger.error(`Copilot tool not found: ${toolId}`) - return { - success: false, - error: `Tool not found: ${toolId}`, - } - } - - try { - const result = await tool.execute(args) - return result - } catch (error) { - logger.error(`Copilot tool execution failed: ${toolId}`, error) - return { - success: false, - error: `Tool execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } -} - -/** - * Get all available copilot tools (for tool definitions in LLM requests) - */ -export function getAllCopilotTools(): CopilotTool[] { - return Object.values(copilotTools) -} - From 36f0947b6244d56d8d932ca511899aee3d55be83 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sat, 26 Jul 2025 23:53:53 -0700 Subject: [PATCH 089/184] Hi --- .../app/api/copilot/targeted-updates/route.ts | 4 +- .../components/message/message.tsx | 80 ++++--------------- .../components/loop-node/loop-node.tsx | 2 +- .../parallel-node/parallel-node.tsx | 2 +- apps/sim/components/ui/tool-call.tsx | 3 +- 5 files changed, 19 insertions(+), 72 deletions(-) diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/targeted-updates/route.ts index 2f18db70cff..e39b1e5e295 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/targeted-updates/route.ts @@ -367,9 +367,9 @@ export async function POST(request: NextRequest) { operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), }) - const result = await executeCopilotTool('targeted_updates', { + const result = await targetedUpdates({ operations, - _context: { workflowId }, + workflowId, }) return NextResponse.json(result) diff --git a/apps/sim/app/chat/[subdomain]/components/message/message.tsx b/apps/sim/app/chat/[subdomain]/components/message/message.tsx index 3ebdbf4d97f..491074f598d 100644 --- a/apps/sim/app/chat/[subdomain]/components/message/message.tsx +++ b/apps/sim/app/chat/[subdomain]/components/message/message.tsx @@ -5,7 +5,6 @@ import { Check, Copy } from 'lucide-react' import { Button } from '@/components/ui/button' import { ToolCallCompletion, ToolCallExecution } from '@/components/ui/tool-call' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { parseMessageContent, stripToolCallIndicators } from '@/lib/tool-call-parser' import MarkdownRenderer from './components/markdown-renderer' export interface ChatMessage { @@ -33,21 +32,9 @@ export const ClientChatMessage = memo( return typeof message.content === 'object' && message.content !== null }, [message.content]) - // Parse message content to separate text and tool calls (only for assistant messages) - const parsedContent = useMemo(() => { - if (message.type === 'assistant' && typeof message.content === 'string') { - return parseMessageContent(message.content) - } - return null - }, [message.type, message.content]) - - // Get clean text content without tool call indicators - const cleanTextContent = useMemo(() => { - if (message.type === 'assistant' && typeof message.content === 'string') { - return stripToolCallIndicators(message.content) - } - return message.content - }, [message.type, message.content]) + // Since tool calls are now handled via SSE events and stored in message.toolCalls, + // we can use the content directly without parsing + const cleanTextContent = message.content // For user messages (on the right) if (message.type === 'user') { @@ -75,57 +62,18 @@ export const ClientChatMessage = memo(
    - {/* Inline content rendering - tool calls and text in order */} - {parsedContent?.inlineContent && parsedContent.inlineContent.length > 0 ? ( -
    - {parsedContent.inlineContent.map((item, index) => { - if (item.type === 'tool_call' && item.toolCall) { - const toolCall = item.toolCall - return ( -
    - {toolCall.state === 'detecting' && ( -
    -
    - - Detecting {toolCall.displayName || toolCall.name}... - -
    - )} - {toolCall.state === 'executing' && ( - - )} - {(toolCall.state === 'completed' || toolCall.state === 'error') && ( - - )} -
    - ) - } - if (item.type === 'text' && item.content.trim()) { - return ( -
    -
    - -
    -
    - ) - } - return null - })} -
    - ) : ( - /* Fallback for empty content or no inline content */ -
    -
    - {isJsonObject ? ( -
    -                      {JSON.stringify(cleanTextContent, null, 2)}
    -                    
    - ) : ( - - )} -
    + {/* Direct content rendering - tool calls are now handled via SSE events */} +
    +
    + {isJsonObject ? ( +
    +                    {JSON.stringify(cleanTextContent, null, 2)}
    +                  
    + ) : ( + + )}
    - )} +
    {message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
    {/* Copy Button - Only show when not streaming */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx index 77f03d15ccf..34f4a659afc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx @@ -76,7 +76,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) - const diffStatus = currentBlock?.is_diff + const diffStatus = currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined // Check if this is preview mode const isPreview = data?.isPreview || false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx index e59329a4c71..7c9b6bde655 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node.tsx @@ -93,7 +93,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) => // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) - const diffStatus = currentBlock?.is_diff + const diffStatus = currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined // Check if this is preview mode const isPreview = data?.isPreview || false diff --git a/apps/sim/components/ui/tool-call.tsx b/apps/sim/components/ui/tool-call.tsx index 115c6b8c951..4d0099f9394 100644 --- a/apps/sim/components/ui/tool-call.tsx +++ b/apps/sim/components/ui/tool-call.tsx @@ -5,7 +5,6 @@ import { CheckCircle, ChevronDown, ChevronRight, Loader2, Settings, XCircle } fr import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { getToolDisplayName } from '@/lib/tool-call-parser' import { cn } from '@/lib/utils' import type { ToolCallGroup, ToolCallState } from '@/types/tool-call' @@ -132,7 +131,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp isError && 'text-red-800 dark:text-red-200' )} > - {getToolDisplayName(toolCall.name, true)} + {toolCall.displayName || toolCall.name} {toolCall.duration && ( Date: Sun, 27 Jul 2025 00:11:35 -0700 Subject: [PATCH 090/184] Cumulative target edit --- apps/sim/lib/workflows/diff/diff-engine.ts | 324 +++++++++++++++++++++ apps/sim/stores/copilot/store.ts | 42 ++- apps/sim/stores/copilot/types.ts | 2 +- apps/sim/stores/workflow-diff/store.ts | 40 +++ 4 files changed, 398 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 7f3f7a27207..2243c1ef6c3 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -209,6 +209,330 @@ export class WorkflowDiffEngine { } } + /** + * Merge new YAML content into existing diff + * Used for cumulative updates within the same message + */ + async mergeDiffFromYaml(yamlContent: string, diffAnalysis?: DiffAnalysis): Promise { + try { + logger.info('Merging diff from YAML content') + + // If no existing diff, create a new one + if (!this.currentDiff) { + logger.info('No existing diff, creating new diff') + return this.createDiffFromYaml(yamlContent, diffAnalysis) + } + + // Convert YAML to workflow state with new IDs + const conversionResult = await convertYamlToWorkflowState(yamlContent, { + generateNewIds: true, + }) + + if (!conversionResult.success || !conversionResult.workflowState) { + return { + success: false, + errors: conversionResult.errors, + } + } + + const newState = conversionResult.workflowState + + logger.info('Merging new state into existing diff:', { + existingBlockCount: Object.keys(this.currentDiff.proposedState.blocks).length, + newBlockCount: Object.keys(newState.blocks).length, + }) + + // Create a map of existing blocks by name+type for matching + const existingBlockMap = new Map() + Object.entries(this.currentDiff.proposedState.blocks).forEach(([id, block]) => { + const key = `${block.type}:${block.name}` + existingBlockMap.set(key, { id, block }) + }) + + // Merge blocks - update existing blocks, add new ones + const mergedBlocks = { ...this.currentDiff.proposedState.blocks } + const blockIdMapping = new Map() // Maps new IDs to existing IDs + + Object.entries(newState.blocks).forEach(([newBlockId, newBlock]) => { + const key = `${newBlock.type}:${newBlock.name}` + const existing = existingBlockMap.get(key) + + if (existing) { + // Update existing block, preserving its ID but updating properties + const previousDiffStatus = (existing.block as any).is_diff + mergedBlocks[existing.id] = { + ...existing.block, + ...newBlock, + id: existing.id, // Preserve the existing ID + position: newBlock.position, // Use new position from layout + // Temporarily preserve diff status - will be updated later based on diff analysis + } + // Preserve the diff status if it was already marked + if (previousDiffStatus) { + (mergedBlocks[existing.id] as any).is_diff = previousDiffStatus + } + blockIdMapping.set(newBlockId, existing.id) + logger.info(`Updating existing block: ${key} with ID ${existing.id}`, { + previousDiffStatus, + }) + } else { + // This is a truly new block + mergedBlocks[newBlockId] = newBlock + blockIdMapping.set(newBlockId, newBlockId) + logger.info(`Adding new block: ${key} with ID ${newBlockId}`) + } + }) + + // Update edges to use the correct block IDs + const remappedNewEdges = newState.edges.map(edge => ({ + ...edge, + source: blockIdMapping.get(edge.source) || edge.source, + target: blockIdMapping.get(edge.target) || edge.target, + })) + + // Merge edges - combine unique edges + const existingEdgeSet = new Set( + this.currentDiff.proposedState.edges.map(e => `${e.source}-${e.target}`) + ) + const mergedEdges = [...this.currentDiff.proposedState.edges] + remappedNewEdges.forEach(edge => { + const edgeKey = `${edge.source}-${edge.target}` + if (!existingEdgeSet.has(edgeKey)) { + mergedEdges.push(edge) + existingEdgeSet.add(edgeKey) + } + }) + + // Update loops and parallels with remapped IDs + const remapLoops = (loops: Record) => { + const remapped: Record = {} + Object.entries(loops).forEach(([loopId, loop]) => { + const mappedId = blockIdMapping.get(loopId) || loopId + remapped[mappedId] = { + ...loop, + id: mappedId, + blocks: loop.blocks?.map((id: string) => blockIdMapping.get(id) || id) || [] + } + }) + return remapped + } + + const remapParallels = (parallels: Record) => { + const remapped: Record = {} + Object.entries(parallels).forEach(([parallelId, parallel]) => { + const mappedId = blockIdMapping.get(parallelId) || parallelId + remapped[mappedId] = { + ...parallel, + id: mappedId, + branches: parallel.branches?.map((branch: any) => ({ + ...branch, + blocks: branch.blocks?.map((id: string) => blockIdMapping.get(id) || id) || [] + })) || [] + } + }) + return remapped + } + + // Merge loops and parallels + const mergedLoops = { + ...this.currentDiff.proposedState.loops, + ...remapLoops(newState.loops) + } + const mergedParallels = { + ...this.currentDiff.proposedState.parallels, + ...remapParallels(newState.parallels) + } + + // Create merged state + const mergedState: WorkflowState = { + blocks: mergedBlocks, + edges: mergedEdges, + loops: mergedLoops, + parallels: mergedParallels, + } + + // Apply diff markers if analysis is provided + let mappedDiffAnalysis = diffAnalysis + if (diffAnalysis) { + logger.info('Applying diff markers to merged state') + + // Create a combined ID mapping that includes our block remapping + const combinedIdMapping = new Map() + if (conversionResult.idMapping) { + conversionResult.idMapping.forEach((newId, oldId) => { + // Map original ID to final ID (which might be an existing block ID) + const finalId = blockIdMapping.get(newId) || newId + combinedIdMapping.set(oldId, finalId) + }) + } + + this.applyDiffMarkers(mergedState, diffAnalysis, combinedIdMapping) + mappedDiffAnalysis = this.createMappedDiffAnalysis( + diffAnalysis, + combinedIdMapping + ) + } + + // Merge diff analysis if both exist + if (this.currentDiff.diffAnalysis && mappedDiffAnalysis) { + // Get all blocks that were previously marked as new or edited + const previouslyNewBlocks = new Set(this.currentDiff.diffAnalysis.new_blocks) + const previouslyEditedBlocks = new Set(this.currentDiff.diffAnalysis.edited_blocks) + + // Blocks that are edited in the new analysis + const newlyEditedBlocks = new Set(mappedDiffAnalysis.edited_blocks) + + // If a block was previously 'new' and is now being edited, it stays 'new' + // If a block was previously 'edited' and is edited again, it stays 'edited' + const finalNewBlocks = new Set() + const finalEditedBlocks = new Set() + + // Add all previously new blocks + previouslyNewBlocks.forEach(id => finalNewBlocks.add(id)) + + // Add newly added blocks from this update + mappedDiffAnalysis.new_blocks.forEach(id => finalNewBlocks.add(id)) + + // Process edited blocks + newlyEditedBlocks.forEach(id => { + if (!finalNewBlocks.has(id)) { + // Only mark as edited if it's not already marked as new + finalEditedBlocks.add(id) + } + }) + + // Add previously edited blocks that aren't being marked as new + previouslyEditedBlocks.forEach(id => { + if (!finalNewBlocks.has(id)) { + finalEditedBlocks.add(id) + } + }) + + // Combine the diff analyses + const combinedAnalysis: DiffAnalysis = { + new_blocks: Array.from(finalNewBlocks), + edited_blocks: Array.from(finalEditedBlocks), + deleted_blocks: [ + ...new Set([ + ...this.currentDiff.diffAnalysis.deleted_blocks, + ...mappedDiffAnalysis.deleted_blocks + ]) + ], + edge_diff: { + new_edges: [ + ...(this.currentDiff.diffAnalysis.edge_diff?.new_edges || []), + ...(mappedDiffAnalysis.edge_diff?.new_edges || []) + ], + deleted_edges: [ + ...new Set([ + ...(this.currentDiff.diffAnalysis.edge_diff?.deleted_edges || []), + ...(mappedDiffAnalysis.edge_diff?.deleted_edges || []) + ]) + ], + unchanged_edges: [ + ...new Set([ + ...(this.currentDiff.diffAnalysis.edge_diff?.unchanged_edges || []), + ...(mappedDiffAnalysis.edge_diff?.unchanged_edges || []) + ]) + ], + }, + field_diffs: { + ...this.currentDiff.diffAnalysis.field_diffs, + ...mappedDiffAnalysis.field_diffs, + }, + } + mappedDiffAnalysis = combinedAnalysis + + logger.info('Combined diff analysis:', { + previousNew: previouslyNewBlocks.size, + previousEdited: previouslyEditedBlocks.size, + newlyEdited: newlyEditedBlocks.size, + finalNew: finalNewBlocks.size, + finalEdited: finalEditedBlocks.size, + }) + } else if (this.currentDiff.diffAnalysis) { + mappedDiffAnalysis = this.currentDiff.diffAnalysis + } + + // Apply auto layout to the merged state + try { + logger.info('Applying auto layout to merged diff workflow') + const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') + const layoutedBlocks = await autoLayoutWorkflow( + mergedState.blocks, + mergedState.edges, + {} + ) + + if (layoutedBlocks) { + mergedState.blocks = layoutedBlocks + + // Ensure all blocks still have their id property + Object.entries(mergedState.blocks).forEach(([blockId, block]) => { + if (!block.id) { + block.id = blockId + } + }) + + // Re-apply diff markers after layout + if (mappedDiffAnalysis) { + Object.entries(mergedState.blocks).forEach(([blockId, block]) => { + // Check if this block was part of the current update + const wasInCurrentUpdate = Array.from(blockIdMapping.values()).includes(blockId) + + if (mappedDiffAnalysis.new_blocks.includes(blockId)) { + ;(block as any).is_diff = 'new' + } else if (mappedDiffAnalysis.edited_blocks.includes(blockId)) { + ;(block as any).is_diff = 'edited' + if (mappedDiffAnalysis.field_diffs?.[blockId]) { + ;(block as any).field_diff = mappedDiffAnalysis.field_diffs[blockId] + } + } else if (wasInCurrentUpdate) { + // Block was in the update but not marked as new or edited + ;(block as any).is_diff = 'unchanged' + } + // Blocks not in the current update keep their existing diff status + }) + } + } + } catch (error) { + logger.error('Auto layout failed for merged state:', error) + } + + // Update current diff with merged state + this.currentDiff = { + proposedState: mergedState, + diffAnalysis: mappedDiffAnalysis, + metadata: { + source: 'copilot', + timestamp: Date.now(), + }, + } + + logger.info('Diff merged successfully', { + totalBlocksCount: Object.keys(mergedState.blocks).length, + totalEdgesCount: mergedState.edges.length, + updatedBlocks: Array.from(blockIdMapping.entries()) + .filter(([newId, existingId]) => newId !== existingId) + .map(([newId, existingId]) => ({ newId, existingId })), + newBlocks: Array.from(blockIdMapping.entries()) + .filter(([newId, existingId]) => newId === existingId) + .map(([newId]) => newId), + }) + + return { + success: true, + diff: this.currentDiff, + } + } catch (error) { + logger.error('Failed to merge diff:', error) + return { + success: false, + errors: [error instanceof Error ? error.message : 'Failed to merge diff'], + } + } + } + /** * Create a mapped version of diff analysis with new IDs */ diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index a5a5ae77a72..840d333a8d3 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -249,7 +249,7 @@ const sseHandlers: Record = { yamlPreview: yamlContent.substring(0, 100), }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent) + get().updateDiffStore(yamlContent, 'preview_workflow') } else { logger.warn('No yamlContent found in preview_workflow result', { hasDirectYaml: !!parsedResult?.yamlContent, @@ -269,7 +269,7 @@ const sseHandlers: Record = { yamlPreview: yamlContent.substring(0, 200), }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent) + get().updateDiffStore(yamlContent, 'targeted_updates') } else { logger.warn('No yamlContent found in targeted_updates result', { hasDirectYaml: !!parsedResult?.yamlContent, @@ -467,7 +467,7 @@ const sseHandlers: Record = { yamlPreview: yamlContent.substring(0, 100) }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent) + get().updateDiffStore(yamlContent, 'preview_workflow') } } @@ -482,7 +482,7 @@ const sseHandlers: Record = { yamlPreview: yamlContent.substring(0, 100) }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent) + get().updateDiffStore(yamlContent, 'targeted_updates') } } } catch (error) { @@ -1669,14 +1669,15 @@ export const useCopilotStore = create()( }, // Update the diff store with proposed workflow changes - updateDiffStore: async (yamlContent: string) => { + updateDiffStore: async (yamlContent: string, toolName?: string) => { try { // Import diff store dynamically to avoid circular dependencies const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') logger.info('Updating diff store with copilot YAML', { yamlLength: yamlContent.length, - yamlPreview: yamlContent.substring(0, 200) + yamlPreview: yamlContent.substring(0, 200), + toolName: toolName || 'unknown' }) // Check current diff store state before update @@ -1687,6 +1688,24 @@ export const useCopilotStore = create()( hasDiffWorkflow: !!diffStoreBefore.diffWorkflow }) + // Determine if we should clear or merge based on tool type and message context + const { messages } = get() + const currentMessage = messages[messages.length - 1] + const messageHasExistingEdits = currentMessage?.toolCalls?.some( + tc => (tc.name === 'preview_workflow' || tc.name === 'targeted_updates') && + tc.state !== 'executing' + ) || false + + const shouldClearDiff = + toolName === 'preview_workflow' || // preview_workflow always clears + (toolName === 'targeted_updates' && !messageHasExistingEdits) // first targeted_updates in message clears + + logger.info('Diff merge strategy:', { + toolName, + messageHasExistingEdits, + shouldClearDiff + }) + // Generate diff analysis by comparing current vs proposed YAML let diffAnalysis = null try { @@ -1727,10 +1746,15 @@ export const useCopilotStore = create()( // Continue without diff analysis - blocks will be marked as unchanged } - // Set the proposed changes in the diff store - // The diff store now handles all YAML parsing and conversion internally + // Set or merge the proposed changes in the diff store based on the strategy const diffStore = useWorkflowDiffStore.getState() - await diffStore.setProposedChanges(yamlContent, diffAnalysis) + if (shouldClearDiff || !diffStoreBefore.diffWorkflow) { + // Use setProposedChanges which will create a new diff + await diffStore.setProposedChanges(yamlContent, diffAnalysis) + } else { + // Use mergeProposedChanges which will merge into existing diff + await diffStore.mergeProposedChanges(yamlContent, diffAnalysis) + } // Check diff store state after update const diffStoreAfter = useWorkflowDiffStore.getState() diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 6f0ef4431f8..7c32fc63021 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -192,7 +192,7 @@ export interface CopilotActions { isContinuation?: boolean ) => Promise handleNewChatCreation: (newChatId: string) => Promise - updateDiffStore: (yamlContent: string) => Promise + updateDiffStore: (yamlContent: string, toolName?: string) => Promise } /** diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index 2d06d14b7b6..bdb0b5a044b 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -25,6 +25,7 @@ interface WorkflowDiffState { interface WorkflowDiffActions { setProposedChanges: (yamlContent: string, diffAnalysis?: DiffAnalysis) => Promise + mergeProposedChanges: (yamlContent: string, diffAnalysis?: DiffAnalysis) => Promise clearDiff: () => void getCurrentWorkflowForCanvas: () => WorkflowState toggleDiffView: () => void @@ -84,6 +85,45 @@ export const useWorkflowDiffStore = create { + logger.info('Merging proposed changes via YAML') + + // First, set isDiffReady to false to prevent premature rendering + set({ isDiffReady: false }) + + const result = await diffEngine.mergeDiffFromYaml(yamlContent, diffAnalysis) + + if (result.success && result.diff) { + // Debug: Log the diff state being merged + const sampleBlockId = Object.keys(result.diff.proposedState.blocks)[0] + const sampleBlock = sampleBlockId ? result.diff.proposedState.blocks[sampleBlockId] : null + const sampleDiffStatus = sampleBlock ? (sampleBlock as any).is_diff : undefined + + console.log('[DiffStore] Merging diff:', { + blockCount: Object.keys(result.diff.proposedState.blocks).length, + sampleBlockId, + sampleDiffStatus, + hasDiffAnalysis: !!result.diff.diffAnalysis, + timestamp: Date.now(), + }) + + // Set all state at once, with isDiffReady true to indicate everything is ready + set({ + isShowingDiff: true, + isDiffReady: true, // Now it's safe to render + diffWorkflow: result.diff.proposedState, + diffAnalysis: result.diff.diffAnalysis || null, + diffMetadata: result.diff.metadata, + }) + logger.info('Diff merged successfully') + } else { + logger.error('Failed to merge diff:', result.errors) + // Reset isDiffReady on failure + set({ isDiffReady: false }) + throw new Error(result.errors?.join(', ') || 'Failed to merge diff') + } + }, + clearDiff: () => { logger.info('Clearing diff') console.log('[DiffStore] Clearing diff at:', Date.now()) From 180e1cea171dc7f3525fe95ab1124f3709838435 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 13:10:39 -0700 Subject: [PATCH 091/184] Checkpont --- apps/sim/app/api/copilot/chat/route.ts | 4 +- .../api/copilot/docs-search-internal/route.ts | 46 -- .../api/copilot/get-blocks-and-tools/route.ts | 83 --- .../api/copilot/get-blocks-metadata/route.ts | 521 ------------------ .../get-environment-variables/route.ts | 53 -- .../copilot/get-workflow-examples/route.ts | 34 -- .../api/copilot/get-yaml-structure/route.ts | 47 -- apps/sim/app/api/copilot/methods/route.ts | 90 +-- apps/sim/app/api/copilot/methods/utils.ts | 24 + .../app/api/copilot/online-search/route.ts | 72 --- apps/sim/app/api/copilot/route.ts | 399 -------------- .../set-environment-variables/route.ts | 46 -- apps/sim/app/api/copilot/tools/base.ts | 80 +++ .../tools/blocks/get-blocks-and-tools.ts | 68 +++ .../tools/blocks/get-blocks-metadata.ts | 301 ++++++++++ .../tools/blocks/get-workflow-examples.ts | 56 ++ .../tools/blocks/get-yaml-structure.ts | 36 ++ .../tools/docs/docs-search-internal.ts | 116 ++++ .../api/copilot/tools/other/online-search.ts | 68 +++ apps/sim/app/api/copilot/tools/registry.ts | 106 ++++ .../tools/user/get-environment-variables.ts | 64 +++ .../tools/user/set-environment-variables.ts | 62 +++ .../workflow/get-user-workflow.ts} | 30 +- .../workflow/get-workflow-console.ts} | 46 +- .../workflow/preview-workflow.ts} | 39 +- .../workflow/targeted-updates.ts} | 204 ++----- apps/sim/app/api/test-auth/route.ts | 56 -- .../professional-message.tsx | 9 +- .../panel/components/copilot/copilot.tsx | 3 +- apps/sim/lib/copilot/examples.ts | 2 +- apps/sim/lib/copilot/prompts.ts | 16 +- apps/sim/lib/copilot/service.ts | 226 ++++---- apps/sim/providers/anthropic/index.ts | 29 +- apps/sim/stores/constants.ts | 18 + apps/sim/stores/copilot/constants.ts | 17 + apps/sim/stores/copilot/preview-store.ts | 5 +- apps/sim/stores/copilot/store.ts | 122 ++-- 37 files changed, 1397 insertions(+), 1801 deletions(-) delete mode 100644 apps/sim/app/api/copilot/docs-search-internal/route.ts delete mode 100644 apps/sim/app/api/copilot/get-blocks-and-tools/route.ts delete mode 100644 apps/sim/app/api/copilot/get-blocks-metadata/route.ts delete mode 100644 apps/sim/app/api/copilot/get-environment-variables/route.ts delete mode 100644 apps/sim/app/api/copilot/get-workflow-examples/route.ts delete mode 100644 apps/sim/app/api/copilot/get-yaml-structure/route.ts create mode 100644 apps/sim/app/api/copilot/methods/utils.ts delete mode 100644 apps/sim/app/api/copilot/online-search/route.ts delete mode 100644 apps/sim/app/api/copilot/route.ts delete mode 100644 apps/sim/app/api/copilot/set-environment-variables/route.ts create mode 100644 apps/sim/app/api/copilot/tools/base.ts create mode 100644 apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts create mode 100644 apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts create mode 100644 apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts create mode 100644 apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts create mode 100644 apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts create mode 100644 apps/sim/app/api/copilot/tools/other/online-search.ts create mode 100644 apps/sim/app/api/copilot/tools/registry.ts create mode 100644 apps/sim/app/api/copilot/tools/user/get-environment-variables.ts create mode 100644 apps/sim/app/api/copilot/tools/user/set-environment-variables.ts rename apps/sim/app/api/copilot/{get-user-workflow/route.ts => tools/workflow/get-user-workflow.ts} (88%) rename apps/sim/app/api/copilot/{get-workflow-console/route.ts => tools/workflow/get-workflow-console.ts} (64%) rename apps/sim/app/api/copilot/{preview-workflow/route.ts => tools/workflow/preview-workflow.ts} (53%) rename apps/sim/app/api/copilot/{targeted-updates/route.ts => tools/workflow/targeted-updates.ts} (65%) delete mode 100644 apps/sim/app/api/test-auth/route.ts create mode 100644 apps/sim/stores/copilot/constants.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 1d129477008..4fe656e84bc 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -27,7 +27,7 @@ const ChatMessageSchema = z.object({ // Sim Agent API configuration const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' -const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY || 'sk-simagent-api01-440c1bd94254e1d8e412e3a57f706c48bfeddb60346e41a5564e5850cd9747abb3542170c33b6ee13353a79eb3fa725e' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY /** * Generate a chat title using LLM @@ -192,7 +192,7 @@ export async function POST(req: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-api-key': SIM_AGENT_API_KEY, + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), }, body: JSON.stringify({ messages, diff --git a/apps/sim/app/api/copilot/docs-search-internal/route.ts b/apps/sim/app/api/copilot/docs-search-internal/route.ts deleted file mode 100644 index 2e67f20dcd0..00000000000 --- a/apps/sim/app/api/copilot/docs-search-internal/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' - -const logger = createLogger('DocsSearchInternalAPI') - -export async function docsSearchInternal(params: any) { - const { query, topK = 10 } = params - - if (!query) { - throw new Error('Query is required') - } - - logger.info('Executing docs search for copilot', { - query, - topK, - }) - - // Forward the request to the existing docs search endpoint - const docsSearchUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/docs/search` - - const response = await fetch(docsSearchUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query, topK }), - }) - - if (!response.ok) { - logger.error('Docs search API failed', { - status: response.status, - statusText: response.statusText - }) - throw new Error('Documentation search failed') - } - - const searchResults = await response.json() - - return { - success: true, - data: { - results: searchResults.results || [], - query, - totalResults: searchResults.totalResults || 0, - }, - } -} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts b/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts deleted file mode 100644 index c943ade3e1a..00000000000 --- a/apps/sim/app/api/copilot/get-blocks-and-tools/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { registry as blockRegistry } from '@/blocks/registry' - -const logger = createLogger('GetAllBlocksAPI') - -export async function getBlocksAndTools(params: any) { - const { includeDetails = false, filterCategory } = params - - logger.info('Getting all blocks and tools', { - includeDetails, - filterCategory, - }) - - // Create mapping of block_id -> [tool_ids] - const blockToToolsMapping: Record = {} - - // Process blocks - filter out hidden blocks and map to their tools - Object.entries(blockRegistry) - .filter(([blockType, blockConfig]) => { - // Filter out hidden blocks - if (blockConfig.hideFromToolbar) return false - - // Apply category filter if specified - if (filterCategory && blockConfig.category !== filterCategory) return false - - return true - }) - .forEach(([blockType, blockConfig]) => { - // Get the tools for this block - const blockTools = blockConfig.tools?.access || [] - blockToToolsMapping[blockType] = blockTools - }) - - // Add special blocks that aren't in the standard registry - // Loop and parallel blocks are handled differently but should be available - const specialBlocks = { - loop: { - tools: [], // Loop blocks don't use standard tools - category: 'blocks', - description: 'Control flow block for iterating over collections or repeating actions', - }, - parallel: { - tools: [], // Parallel blocks don't use standard tools - category: 'blocks', - description: 'Control flow block for executing multiple branches simultaneously', - }, - } - - // Add special blocks if they pass the category filter - Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { - if (!filterCategory || blockInfo.category === filterCategory) { - blockToToolsMapping[blockType] = blockInfo.tools - } - }) - - const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length - const includedBlocks = Object.keys(blockToToolsMapping).length - const filteredBlocksCount = totalBlocks - includedBlocks - - // Log block to tools mapping for debugging - const blockToolsInfo = Object.entries(blockToToolsMapping) - .map(([blockType, tools]) => `${blockType}: [${tools.join(', ')}]`) - .sort() - - logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, { - totalBlocks, - includedBlocks, - filteredBlocks: filteredBlocksCount, - filterCategory, - blockToolsMapping: blockToolsInfo, - outputMapping: blockToToolsMapping, - specialBlocksAdded: Object.keys(specialBlocks).filter( - (blockType) => - !filterCategory || - specialBlocks[blockType as keyof typeof specialBlocks].category === filterCategory - ), - }) - - return { - success: true, - data: blockToToolsMapping, - } -} diff --git a/apps/sim/app/api/copilot/get-blocks-metadata/route.ts b/apps/sim/app/api/copilot/get-blocks-metadata/route.ts deleted file mode 100644 index 0b84b736975..00000000000 --- a/apps/sim/app/api/copilot/get-blocks-metadata/route.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { registry as blockRegistry } from '@/blocks/registry' -import { tools as toolsRegistry } from '@/tools/registry' - -const logger = createLogger('GetBlockMetadataAPI') - -export async function getBlocksMetadata(params: any) { - const { blockIds } = params - - if (!blockIds || !Array.isArray(blockIds)) { - return { - success: false, - error: 'blockIds must be an array of block IDs', - } - } - - logger.info('Getting block metadata', { - blockIds, - blockCount: blockIds.length, - requestedBlocks: blockIds.join(', '), - }) - - try { - // Create result object - const result: Record = {} - - // Process each requested block ID - for (const blockId of blockIds) { - // Check if it's a special block first - if (SPECIAL_BLOCKS_METADATA[blockId]) { - result[blockId] = SPECIAL_BLOCKS_METADATA[blockId] - continue - } - - // Check if the block exists in the registry - const blockConfig = blockRegistry[blockId] - if (!blockConfig) { - logger.warn(`Block not found in registry: ${blockId}`) - continue - } - - const metadata: any = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.description || '', - category: blockConfig.category || 'general', - inputs: blockConfig.inputs || {}, - outputs: blockConfig.outputs || {}, - tools: blockConfig.tools?.access || [], - } - - // Read YAML schema from documentation if available - const docFileName = DOCS_FILE_MAPPING[blockId] || blockId - if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { - try { - const docPath = join(process.cwd(), 'content', 'docs', 'blocks', `${docFileName}.mdx`) - if (existsSync(docPath)) { - const docContent = readFileSync(docPath, 'utf-8') - - // Extract schema from the documentation - const schemaMatch = docContent.match(/```yaml\s*\n([\s\S]*?)```/i) - if (schemaMatch) { - const yamlSchema = schemaMatch[1].trim() - // Parse high-level structure only - const lines = yamlSchema.split('\n') - const schemaInfo: any = { - fields: [], - example: yamlSchema, - } - - // Extract field names and structure - lines.forEach(line => { - const match = line.match(/^(\s*)(\w+):/) - if (match) { - const indent = match[1].length - const fieldName = match[2] - if (indent === 0) { - schemaInfo.fields.push({ - name: fieldName, - level: 'root', - }) - } - } - }) - - metadata.schema = schemaInfo - } - } - } catch (error) { - logger.warn(`Failed to read documentation for ${blockId}:`, error) - } - } - - // Add tool metadata if requested - if (metadata.tools.length > 0) { - metadata.toolDetails = {} - for (const toolId of metadata.tools) { - const tool = toolsRegistry[toolId] - if (tool) { - metadata.toolDetails[toolId] = { - name: tool.name, - description: tool.description, - } - } - } - } - - result[blockId] = metadata - } - - logger.info(`Successfully retrieved metadata for ${Object.keys(result).length} blocks`) - - return { - success: true, - data: result, - } - } catch (error) { - logger.error('Get block metadata failed', error) - return { - success: false, - error: `Failed to get block metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } -} - -// Core blocks that have documentation with YAML schemas -const CORE_BLOCKS_WITH_DOCS = [ - 'agent', - 'function', - 'api', - 'condition', - 'loop', - 'parallel', - 'response', - 'router', - 'evaluator', - 'webhook', -] - -// Mapping for blocks that have different doc file names -const DOCS_FILE_MAPPING: Record = { - webhook: 'webhook_trigger', -} - -// Special blocks that aren't in the standard registry but need metadata -const SPECIAL_BLOCKS_METADATA: Record = { - loop: { - type: 'loop', - name: 'Loop', - description: 'Control flow block for iterating over collections or repeating actions', - longDescription: - 'Execute a set of blocks repeatedly, either for a fixed number of iterations or for each item in a collection. Loop blocks create sub-workflows that run multiple times with different iteration data.', - category: 'blocks', - bgColor: '#9333EA', - subBlocks: [ - { - id: 'iterationType', - title: 'Iteration Type', - type: 'dropdown', - layout: 'full', - options: [ - { label: 'Fixed Count', id: 'fixed' }, - { label: 'For Each Item', id: 'forEach' }, - ], - description: 'Choose how the loop should iterate', - }, - { - id: 'iterationCount', - title: 'Iteration Count', - type: 'short-input', - layout: 'half', - placeholder: '5', - condition: { field: 'iterationType', value: 'fixed' }, - description: 'Number of times to repeat the loop', - }, - { - id: 'collection', - title: 'Collection', - type: 'short-input', - layout: 'full', - placeholder: 'Reference to array or object', - condition: { field: 'iterationType', value: 'forEach' }, - description: 'Array or object to iterate over', - }, - ], - inputs: { - iterationType: { type: 'string', required: true }, - iterationCount: { type: 'number', required: false }, - collection: { type: 'array|object', required: false }, - }, - outputs: { - results: 'array', - iterations: 'number', - }, - tools: { access: [] }, - }, - parallel: { - type: 'parallel', - name: 'Parallel', - description: 'Control flow block for executing multiple branches simultaneously', - longDescription: - 'Execute multiple sets of blocks simultaneously, either with a fixed number of parallel branches or by distributing items from a collection across parallel executions.', - category: 'blocks', - bgColor: '#059669', - subBlocks: [ - { - id: 'parallelType', - title: 'Parallel Type', - type: 'dropdown', - layout: 'full', - options: [ - { label: 'Fixed Count', id: 'count' }, - { label: 'Collection Distribution', id: 'collection' }, - ], - description: 'Choose how parallel execution should work', - }, - { - id: 'parallelCount', - title: 'Parallel Count', - type: 'short-input', - layout: 'half', - placeholder: '3', - condition: { field: 'parallelType', value: 'count' }, - description: 'Number of parallel branches to execute', - }, - { - id: 'collection', - title: 'Collection', - type: 'short-input', - layout: 'full', - placeholder: 'Reference to array to distribute', - condition: { field: 'parallelType', value: 'collection' }, - description: 'Array to distribute across parallel executions', - }, - ], - inputs: { - parallelType: { type: 'string', required: true }, - parallelCount: { type: 'number', required: false }, - collection: { type: 'array', required: false }, - }, - outputs: { - results: 'array', - branches: 'number', - }, - tools: { access: [] }, - }, -} - -// Helper function to read YAML schema from dedicated YAML documentation files -function getYamlSchemaFromDocs(blockType: string): string | null { - try { - const docFileName = DOCS_FILE_MAPPING[blockType] || blockType - // Read from the new YAML documentation structure - const yamlDocsPath = join( - process.cwd(), - '..', - 'docs/content/docs/yaml/blocks', - `${docFileName}.mdx` - ) - - if (!existsSync(yamlDocsPath)) { - logger.warn(`YAML schema file not found for ${blockType} at ${yamlDocsPath}`) - return null - } - - const content = readFileSync(yamlDocsPath, 'utf-8') - - // Remove the frontmatter and return the content after the title - const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') - return contentWithoutFrontmatter.trim() - } catch (error) { - logger.warn(`Failed to read YAML schema for ${blockType}:`, error) - return null - } -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const { blockIds } = body - - if (!blockIds || !Array.isArray(blockIds)) { - return NextResponse.json( - { - success: false, - error: 'blockIds must be an array of block IDs', - }, - { status: 400 } - ) - } - - logger.info('Getting block metadata', { - blockIds, - blockCount: blockIds.length, - requestedBlocks: blockIds.join(', '), - }) - - // Create result object - const result: Record = {} - - for (const blockId of blockIds) { - const blockConfig = blockRegistry[blockId] - - // Check if it's a special block not in the standard registry - if (!blockConfig && SPECIAL_BLOCKS_METADATA[blockId]) { - const specialBlock = SPECIAL_BLOCKS_METADATA[blockId] - - // Check if this special block has YAML documentation - if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { - const yamlSchema = getYamlSchemaFromDocs(blockId) - - if (yamlSchema) { - result[blockId] = { - type: 'block', - description: specialBlock.description || '', - longDescription: specialBlock.longDescription, - category: specialBlock.category || '', - yamlSchema: yamlSchema, - docsLink: specialBlock.docsLink, - codeSchemas: { - inputs: specialBlock.inputs, - outputs: specialBlock.outputs, - subBlocks: specialBlock.subBlocks, - }, - } - } else { - // Fallback to regular metadata if YAML schema not found - result[blockId] = { - type: 'block', - description: specialBlock.description || '', - longDescription: specialBlock.longDescription, - category: specialBlock.category || '', - inputs: specialBlock.inputs, - outputs: specialBlock.outputs, - subBlocks: specialBlock.subBlocks, - codeSchemas: { - inputs: specialBlock.inputs, - outputs: specialBlock.outputs, - subBlocks: specialBlock.subBlocks, - }, - } - } - } else { - // For special blocks without YAML docs - result[blockId] = { - type: 'block', - description: specialBlock.description || '', - longDescription: specialBlock.longDescription, - category: specialBlock.category || '', - inputs: specialBlock.inputs, - outputs: specialBlock.outputs, - subBlocks: specialBlock.subBlocks, - codeSchemas: { - inputs: specialBlock.inputs, - outputs: specialBlock.outputs, - subBlocks: specialBlock.subBlocks, - }, - } - } - continue - } - - if (!blockConfig) { - logger.warn(`Block not found: ${blockId}`) - continue - } - - // Always include code schemas from block configuration - const codeSchemas = { - inputs: blockConfig.inputs, - outputs: blockConfig.outputs, - subBlocks: blockConfig.subBlocks, - } - - // Check if this is a core block with YAML documentation - if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { - // For core blocks, return both YAML schema from documentation AND code schemas - const yamlSchema = getYamlSchemaFromDocs(blockId) - - if (yamlSchema) { - result[blockId] = { - type: 'block', - description: blockConfig.description || '', - longDescription: blockConfig.longDescription, - category: blockConfig.category || '', - yamlSchema: yamlSchema, - docsLink: blockConfig.docsLink, - // Include actual schemas from code - codeSchemas: codeSchemas, - } - } else { - // Fallback to regular metadata if YAML schema not found - result[blockId] = { - type: 'block', - description: blockConfig.description || '', - longDescription: blockConfig.longDescription, - category: blockConfig.category || '', - inputs: blockConfig.inputs, - outputs: blockConfig.outputs, - subBlocks: blockConfig.subBlocks, - // Include actual schemas from code - codeSchemas: codeSchemas, - } - } - } else { - // For tool blocks, return tool schema information AND code schemas - const blockTools = blockConfig.tools?.access || [] - const toolSchemas: Record = {} - - for (const toolId of blockTools) { - const toolConfig = toolsRegistry[toolId] - if (toolConfig) { - toolSchemas[toolId] = { - id: toolConfig.id, - name: toolConfig.name, - description: toolConfig.description || '', - version: toolConfig.version, - params: toolConfig.params, - request: toolConfig.request - ? { - method: toolConfig.request.method, - url: toolConfig.request.url, - headers: - typeof toolConfig.request.headers === 'function' - ? 'function' - : toolConfig.request.headers, - isInternalRoute: toolConfig.request.isInternalRoute, - } - : undefined, - } - } else { - logger.warn(`Tool not found: ${toolId} for block: ${blockId}`) - toolSchemas[toolId] = { - id: toolId, - description: 'Tool not found', - } - } - } - - result[blockId] = { - type: 'tool', - description: blockConfig.description || '', - longDescription: blockConfig.longDescription, - category: blockConfig.category || '', - inputs: blockConfig.inputs, - outputs: blockConfig.outputs, - subBlocks: blockConfig.subBlocks, - toolSchemas: toolSchemas, - // Include actual schemas from code - codeSchemas: codeSchemas, - } - } - } - - const processedBlocks = Object.keys(result).length - const requestedBlocks = blockIds.length - const notFoundBlocks = requestedBlocks - processedBlocks - - // Log detailed output for debugging - Object.entries(result).forEach(([blockId, blockData]) => { - if (blockData.type === 'block' && blockData.yamlSchema) { - logger.info(`Retrieved YAML schema + code schemas for core block: ${blockId}`, { - blockId, - type: blockData.type, - description: blockData.description, - yamlSchemaLength: blockData.yamlSchema.length, - yamlSchemaPreview: `${blockData.yamlSchema.substring(0, 200)}...`, - hasCodeSchemas: !!blockData.codeSchemas, - codeSubBlocksCount: blockData.codeSchemas?.subBlocks?.length || 0, - }) - } else if (blockData.type === 'tool' && blockData.toolSchemas) { - const toolIds = Object.keys(blockData.toolSchemas) - logger.info(`Retrieved tool schemas + code schemas for tool block: ${blockId}`, { - blockId, - type: blockData.type, - description: blockData.description, - toolCount: toolIds.length, - toolIds: toolIds, - hasCodeSchemas: !!blockData.codeSchemas, - codeSubBlocksCount: blockData.codeSchemas?.subBlocks?.length || 0, - }) - } else { - logger.info(`Retrieved metadata + code schemas for block: ${blockId}`, { - blockId, - type: blockData.type, - description: blockData.description, - hasInputs: !!blockData.inputs, - hasOutputs: !!blockData.outputs, - hasSubBlocks: !!blockData.subBlocks, - hasCodeSchemas: !!blockData.codeSchemas, - codeSubBlocksCount: blockData.codeSchemas?.subBlocks?.length || 0, - }) - } - }) - - logger.info(`Successfully processed ${processedBlocks} block metadata`, { - requestedBlocks, - processedBlocks, - notFoundBlocks, - coreBlocks: blockIds.filter((id) => CORE_BLOCKS_WITH_DOCS.includes(id)), - toolBlocks: blockIds.filter((id) => !CORE_BLOCKS_WITH_DOCS.includes(id)), - }) - - return NextResponse.json({ - success: true, - data: result, - }) - } catch (error) { - logger.error('Get block metadata failed', error) - return NextResponse.json( - { - success: false, - error: `Failed to get block metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/copilot/get-environment-variables/route.ts b/apps/sim/app/api/copilot/get-environment-variables/route.ts deleted file mode 100644 index ea842bd927e..00000000000 --- a/apps/sim/app/api/copilot/get-environment-variables/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { getEnvironmentVariableKeys } from '@/lib/environment/utils' -import { getUserId } from '@/app/api/auth/oauth/utils' - -const logger = createLogger('GetEnvironmentVariablesAPI') - -export async function getEnvironmentVariables(params: any) { - logger.info('Getting environment variables for copilot', { params }) - - const { userId: directUserId, workflowId } = params - - try { - // Resolve userId from workflowId if needed - const userId = directUserId || (workflowId ? await getUserId('copilot-env-vars', workflowId) : undefined) - - logger.info('Resolved userId', { - directUserId, - workflowId, - resolvedUserId: userId - }) - - if (!userId) { - logger.warn('No userId could be determined', { directUserId, workflowId }) - return { - success: false, - error: 'Either userId or workflowId is required', - } - } - - // Get environment variable keys directly - const result = await getEnvironmentVariableKeys(userId) - - logger.info('Environment variable keys retrieved', { - userId, - result, - variableCount: result.count - }) - - return { - success: true, - data: { - variableNames: result.variableNames, - count: result.count, - }, - } - } catch (error) { - logger.error('Failed to get environment variables', error) - return { - success: false, - error: 'Failed to get environment variables', - } - } -} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-workflow-examples/route.ts b/apps/sim/app/api/copilot/get-workflow-examples/route.ts deleted file mode 100644 index 62ead3b7455..00000000000 --- a/apps/sim/app/api/copilot/get-workflow-examples/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { WORKFLOW_EXAMPLES } from '../../../../lib/copilot/examples' - -const logger = createLogger('GetWorkflowExamplesAPI') - -export async function getWorkflowExamples(params: any) { - logger.info('Getting workflow examples for copilot') - - const { exampleIds } = params - - if (!Array.isArray(exampleIds)) { - throw new Error('exampleIds must be an array') - } - - const examples: Record = {} - const notFound: string[] = [] - - for (const id of exampleIds) { - if (WORKFLOW_EXAMPLES[id]) { - examples[id] = WORKFLOW_EXAMPLES[id] - } else { - notFound.push(id) - } - } - - return { - success: true, - data: { - examples, - notFound, - availableIds: Object.keys(WORKFLOW_EXAMPLES), - }, - } -} diff --git a/apps/sim/app/api/copilot/get-yaml-structure/route.ts b/apps/sim/app/api/copilot/get-yaml-structure/route.ts deleted file mode 100644 index 50d2f9a2517..00000000000 --- a/apps/sim/app/api/copilot/get-yaml-structure/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getYamlWorkflowPrompt } from '@/lib/copilot/prompts' - -export const dynamic = 'force-dynamic' - -export async function getYamlStructure(params: any) { - try { - console.log('[get-yaml-structure] API endpoint called') - - return { - success: true, - data: { - guide: getYamlWorkflowPrompt(), - message: 'Complete YAML workflow syntax guide with examples and best practices', - }, - } - } catch (error) { - console.error('[get-yaml-structure] Error:', error) - return { - success: false, - error: 'Failed to get YAML structure', - } - } -} - -export async function POST(request: NextRequest) { - try { - console.log('[get-yaml-structure] API endpoint called') - - return NextResponse.json({ - success: true, - data: { - guide: getYamlWorkflowPrompt(), - message: 'Complete YAML workflow syntax guide with examples and best practices', - }, - }) - } catch (error) { - console.error('[get-yaml-structure] Error:', error) - return NextResponse.json( - { - success: false, - error: 'Failed to get YAML structure', - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index 287dd2afedc..e6b0cc6b3c4 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -1,18 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console-logger' -import { getBlocksAndTools } from '../get-blocks-and-tools/route' -import { getWorkflowExamples } from '../get-workflow-examples/route' -import { setEnvironmentVariables } from '../set-environment-variables/route' -import { getEnvironmentVariables } from '../get-environment-variables/route' -import { previewWorkflow } from '../preview-workflow/route' -import { docsSearchInternal } from '../docs-search-internal/route' -import { getWorkflowConsole } from '../get-workflow-console/route' -import { getUserWorkflow } from '../get-user-workflow/route' -import { getBlocksMetadata } from '../get-blocks-metadata/route' -import { getYamlStructure } from '../get-yaml-structure/route' -import { targetedUpdates } from '../targeted-updates/route' -import { onlineSearch } from '../online-search/route' +import { createErrorResponse } from './utils' +import { copilotToolRegistry } from '../tools/registry' const logger = createLogger('CopilotMethodsAPI') @@ -42,74 +32,84 @@ function checkInternalApiKey(req: NextRequest) { return { success: true } } -// Method registry mapping methodId to method -const METHODS = { - 'get_blocks_and_tools': getBlocksAndTools, - 'get_workflow_examples': getWorkflowExamples, - 'set_environment_variables': setEnvironmentVariables, - 'get_environment_variables': getEnvironmentVariables, - 'preview_workflow': previewWorkflow, - 'docs_search_internal': docsSearchInternal, - 'get_workflow_console': getWorkflowConsole, - 'get_user_workflow': getUserWorkflow, - 'get_blocks_metadata': getBlocksMetadata, - 'get_yaml_structure': getYamlStructure, - 'targeted_updates': targetedUpdates, - 'online_search': onlineSearch, -} as const - /** * POST /api/copilot/methods * Execute a method based on methodId with internal API key auth */ export async function POST(req: NextRequest) { const requestId = crypto.randomUUID() + const startTime = Date.now() try { // Check authentication (internal API key) const authResult = checkInternalApiKey(req) if (!authResult.success) { - return NextResponse.json({ error: authResult.error }, { status: 401 }) + return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), { status: 401 }) } const body = await req.json() const { methodId, params } = MethodExecutionSchema.parse(body) - logger.info(`[${requestId}] Method execution: ${methodId}`, { + logger.info(`[${requestId}] Method execution request: ${methodId}`, { methodId, + hasParams: !!params && Object.keys(params).length > 0, }) - // Check if method exists - if (!(methodId in METHODS)) { + // Check if tool exists in registry + if (!copilotToolRegistry.has(methodId)) { + logger.error(`[${requestId}] Tool not found in registry: ${methodId}`, { + methodId, + availableTools: copilotToolRegistry.getAvailableIds(), + registrySize: copilotToolRegistry.getAvailableIds().length + }) return NextResponse.json( - { - error: `Unknown method: ${methodId}`, - availableMethods: Object.keys(METHODS) - }, + createErrorResponse(`Unknown method: ${methodId}. Available methods: ${copilotToolRegistry.getAvailableIds().join(', ')}`), { status: 400 } ) } - // Execute the method - const method = METHODS[methodId as keyof typeof METHODS] - const result = await method(params) + logger.info(`[${requestId}] Tool found in registry: ${methodId}`) + + // Execute the tool directly via registry + const result = await copilotToolRegistry.execute(methodId, params) + + logger.info(`[${requestId}] Tool execution result:`, { + methodId, + success: result.success, + hasData: !!result.data, + hasError: !!result.error + }) - logger.info(`[${requestId}] Method execution completed successfully: ${methodId}`) + const duration = Date.now() - startTime + logger.info(`[${requestId}] Method execution completed: ${methodId}`, { + methodId, + duration, + success: result.success, + }) return NextResponse.json(result) } catch (error) { + const duration = Date.now() - startTime + if (error instanceof z.ZodError) { + logger.error(`[${requestId}] Request validation error:`, { + duration, + errors: error.errors + }) return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, + createErrorResponse(`Invalid request data: ${error.errors.map(e => e.message).join(', ')}`), { status: 400 } ) } - logger.error(`[${requestId}] Method execution error:`, error) + logger.error(`[${requestId}] Unexpected error:`, { + duration, + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined + }) + return NextResponse.json( - { - error: error instanceof Error ? error.message : 'Internal server error' - }, + createErrorResponse(error instanceof Error ? error.message : 'Internal server error'), { status: 500 } ) } diff --git a/apps/sim/app/api/copilot/methods/utils.ts b/apps/sim/app/api/copilot/methods/utils.ts new file mode 100644 index 00000000000..abb0915d8ac --- /dev/null +++ b/apps/sim/app/api/copilot/methods/utils.ts @@ -0,0 +1,24 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { CopilotToolResponse } from '../tools/base' + +const logger = createLogger('CopilotMethodsUtils') + +/** + * Create a standardized error response + */ +export function createErrorResponse(error: string): CopilotToolResponse { + return { + success: false, + error, + } +} + +/** + * Create a standardized success response + */ +export function createSuccessResponse(data: any): CopilotToolResponse { + return { + success: true, + data, + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/online-search/route.ts b/apps/sim/app/api/copilot/online-search/route.ts deleted file mode 100644 index 47033c09526..00000000000 --- a/apps/sim/app/api/copilot/online-search/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { executeTool } from '@/tools' - -const logger = createLogger('OnlineSearchAPI') - -export const dynamic = 'force-dynamic' - -export async function onlineSearch(params: any) { - const { query, num = 10, type = 'search', gl, hl } = params - - if (!query) { - throw new Error('Query is required') - } - - logger.info('Performing online search', { - query, - num, - type, - gl, - hl - }) - - try { - // Execute the serper_search tool - const toolParams = { - query, - num, - type, - gl, - hl, - apiKey: process.env.SERPER_API_KEY || '', - } - - const result = await executeTool('serper_search', toolParams) - - if (!result.success) { - throw new Error(result.error || 'Search failed') - } - - // The serper tool already formats the results properly - return { - success: true, - data: { - results: result.output.searchResults || [], - query, - type, - totalResults: result.output.searchResults?.length || 0, - }, - } - } catch (error) { - logger.error('Online search failed', error) - throw error - } -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const result = await onlineSearch(body) - return NextResponse.json(result) - } catch (error) { - logger.error('Online search API error:', error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Failed to perform online search', - }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/route.ts b/apps/sim/app/api/copilot/route.ts deleted file mode 100644 index 7781e38c23b..00000000000 --- a/apps/sim/app/api/copilot/route.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { copilotChats } from '@/db/schema' -import { and, eq, desc } from 'drizzle-orm' -import { executeProviderRequest } from '@/providers' -import { getCopilotConfig, getCopilotModel } from '@/lib/copilot/config' -import { - TITLE_GENERATION_SYSTEM_PROMPT, - TITLE_GENERATION_USER_PROMPT -} from '@/lib/copilot/prompts' - -const logger = createLogger('CopilotAPI') - -// Schema for creating chats -const CreateChatSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - title: z.string().optional(), - initialMessage: z.string().optional(), -}) - -// Schema for updating chats -const UpdateChatSchema = z.object({ - chatId: z.string().min(1, 'Chat ID is required'), - messages: z - .array( - z.object({ - id: z.string(), - role: z.enum(['user', 'assistant', 'system']), - content: z.string(), - timestamp: z.string(), - citations: z - .array( - z.object({ - id: z.number(), - title: z.string(), - url: z.string(), - similarity: z.number().optional(), - }) - ) - .optional(), - }) - ) - .optional(), - title: z.string().optional(), - previewYaml: z.string().nullable().optional(), -}) - -// Interface for copilot chat -interface CopilotChat { - id: string - title: string | null - model: string - messages: any[] - messageCount: number - previewYaml: string | null - createdAt: Date - updatedAt: Date -} - -/** - * Generate a chat title using LLM - */ -async function generateChatTitle(userMessage: string): Promise { - try { - const { provider, model } = getCopilotModel('title') - - // Get the appropriate API key for the provider - let apiKey: string | undefined - if (provider === 'anthropic') { - // Use rotating API key for Anthropic - const { getRotatingApiKey } = require('@/lib/utils') - try { - apiKey = getRotatingApiKey('anthropic') - logger.debug(`Using rotating API key for Anthropic title generation`) - } catch (e) { - // If rotation fails, let the provider handle it - logger.warn(`Failed to get rotating API key for Anthropic:`, e) - } - } - - const response = await executeProviderRequest(provider, { - model, - systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, - context: TITLE_GENERATION_USER_PROMPT(userMessage), - temperature: 0.3, - maxTokens: 50, - apiKey: apiKey || '', // Use rotating key or empty string - stream: false, - }) - - if (typeof response === 'object' && 'content' in response) { - return response.content?.trim() || 'New Chat' - } - - return 'New Chat' - } catch (error) { - logger.error('Failed to generate chat title:', error) - return 'New Chat' - } -} - -/** - * GET /api/copilot - * List chats or get a specific chat - */ -export async function GET(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const chatId = searchParams.get('chatId') - - // If chatId is provided, get specific chat - if (chatId) { - const [chat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .limit(1) - - if (!chat) { - return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) - } - - const copilotChat: CopilotChat = { - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - previewYaml: chat.previewYaml, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - } - - return NextResponse.json({ - success: true, - chat: copilotChat, - }) - } - - // Otherwise, list chats - const workflowId = searchParams.get('workflowId') - const limit = Number.parseInt(searchParams.get('limit') || '50') - const offset = Number.parseInt(searchParams.get('offset') || '0') - - if (!workflowId) { - return NextResponse.json( - { error: 'workflowId is required for listing chats' }, - { status: 400 } - ) - } - - const chats = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.userId, session.user.id), eq(copilotChats.workflowId, workflowId))) - .orderBy(desc(copilotChats.createdAt)) - .limit(limit) - .offset(offset) - - const formattedChats: CopilotChat[] = chats.map(chat => ({ - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - previewYaml: chat.previewYaml, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - })) - - return NextResponse.json({ - success: true, - chats: formattedChats, - }) - } catch (error) { - logger.error('Failed to handle GET request:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * PUT /api/copilot - * Create a new chat - */ -export async function PUT(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await req.json() - const { workflowId, title, initialMessage } = CreateChatSchema.parse(body) - - const { provider, model } = getCopilotModel('chat') - - logger.info(`Creating new chat for user ${session.user.id}, workflow ${workflowId}`) - - // Prepare initial messages array - const initialMessages = initialMessage - ? [ - { - id: crypto.randomUUID(), - role: 'user', - content: initialMessage, - timestamp: new Date().toISOString(), - }, - ] - : [] - - // Create the chat - const [newChat] = await db - .insert(copilotChats) - .values({ - userId: session.user.id, - workflowId, - title: title || null, - model, - messages: initialMessages, - }) - .returning() - - if (!newChat) { - throw new Error('Failed to create chat') - } - - const copilotChat: CopilotChat = { - id: newChat.id, - title: newChat.title, - model: newChat.model, - messages: Array.isArray(newChat.messages) ? newChat.messages : [], - messageCount: Array.isArray(newChat.messages) ? newChat.messages.length : 0, - previewYaml: newChat.previewYaml, - createdAt: newChat.createdAt, - updatedAt: newChat.updatedAt, - } - - logger.info(`Created chat ${copilotChat.id} for user ${session.user.id}`) - - return NextResponse.json({ - success: true, - chat: copilotChat, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - logger.error('Failed to create chat:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * PATCH /api/copilot - * Update a chat with new messages - */ -export async function PATCH(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await req.json() - const { chatId, messages, title, previewYaml } = UpdateChatSchema.parse(body) - - logger.info(`Updating chat ${chatId} for user ${session.user.id}`) - - // Get the current chat to check if it has a title - const [existingChat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .limit(1) - - if (!existingChat) { - return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) - } - - let titleToUse = title - - // Generate title if chat doesn't have one and we have messages - if (!titleToUse && !existingChat.title && messages && messages.length > 0) { - const firstUserMessage = messages.find((msg) => msg.role === 'user') - if (firstUserMessage) { - logger.info('Generating LLM-based title for chat without title') - try { - titleToUse = await generateChatTitle(firstUserMessage.content) - logger.info(`Generated title: ${titleToUse}`) - } catch (error) { - logger.error('Failed to generate chat title:', error) - titleToUse = 'New Chat' - } - } - } - - // Build update object - const updateData: any = { - updatedAt: new Date(), - } - - if (messages !== undefined) { - updateData.messages = messages - } - - if (titleToUse !== undefined) { - updateData.title = titleToUse - } - - if (previewYaml !== undefined) { - updateData.previewYaml = previewYaml - } - - const [updatedChat] = await db - .update(copilotChats) - .set(updateData) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .returning() - - if (!updatedChat) { - return NextResponse.json({ error: 'Failed to update chat' }, { status: 500 }) - } - - const copilotChat: CopilotChat = { - id: updatedChat.id, - title: updatedChat.title, - model: updatedChat.model, - messages: Array.isArray(updatedChat.messages) ? updatedChat.messages : [], - messageCount: Array.isArray(updatedChat.messages) ? updatedChat.messages.length : 0, - previewYaml: updatedChat.previewYaml, - createdAt: updatedChat.createdAt, - updatedAt: updatedChat.updatedAt, - } - - return NextResponse.json({ - success: true, - chat: copilotChat, - }) - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request data', details: error.errors }, - { status: 400 } - ) - } - - logger.error('Failed to update chat:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -/** - * DELETE /api/copilot - * Delete a chat - */ -export async function DELETE(req: NextRequest) { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const chatId = searchParams.get('chatId') - - if (!chatId) { - return NextResponse.json({ error: 'chatId is required' }, { status: 400 }) - } - - const result = await db - .delete(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .returning({ id: copilotChats.id }) - - if (result.length === 0) { - return NextResponse.json({ error: 'Chat not found or access denied' }, { status: 404 }) - } - - return NextResponse.json({ - success: true, - message: 'Chat deleted successfully', - }) - } catch (error) { - logger.error('Failed to delete chat:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/set-environment-variables/route.ts b/apps/sim/app/api/copilot/set-environment-variables/route.ts deleted file mode 100644 index dd413e941a7..00000000000 --- a/apps/sim/app/api/copilot/set-environment-variables/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' - -const logger = createLogger('SetEnvironmentVariablesAPI') - -export async function setEnvironmentVariables(params: any) { - const { variables } = params - - if (!variables || typeof variables !== 'object') { - throw new Error('Variables object is required') - } - - logger.info('Setting environment variables for copilot', { - variableCount: Object.keys(variables).length, - variableNames: Object.keys(variables), - }) - - // Forward the request to the existing environment variables endpoint - const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` - - const response = await fetch(envUrl, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ variables }), - }) - - if (!response.ok) { - logger.error('Set environment variables API failed', { - status: response.status, - statusText: response.statusText - }) - throw new Error('Failed to set environment variables') - } - - const result = await response.json() - - return { - success: true, - data: { - message: 'Environment variables updated successfully', - updatedVariables: Object.keys(variables), - count: Object.keys(variables).length, - }, - } -} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/base.ts b/apps/sim/app/api/copilot/tools/base.ts new file mode 100644 index 00000000000..067edd0c229 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/base.ts @@ -0,0 +1,80 @@ +import { z, type ZodSchema } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' + +// Base tool response interface +export interface CopilotToolResponse { + success: boolean + data?: T + error?: string +} + +// Base tool interface that all copilot tools must implement +export interface CopilotTool { + readonly id: string + readonly displayName: string + execute(params: TParams): Promise> +} + +// Abstract base class for copilot tools +export abstract class BaseCopilotTool implements CopilotTool { + abstract readonly id: string + abstract readonly displayName: string + + private _logger?: ReturnType + + protected get logger() { + if (!this._logger) { + this._logger = createLogger(`CopilotTool:${this.id}`) + } + return this._logger + } + + /** + * Execute the tool with error handling + */ + async execute(params: TParams): Promise> { + const startTime = Date.now() + + try { + this.logger.info(`Executing tool: ${this.id}`, { + toolId: this.id, + paramsKeys: Object.keys(params || {}), + }) + + // Execute the tool logic + const result = await this.executeImpl(params) + + const duration = Date.now() - startTime + this.logger.info(`Tool execution completed: ${this.id}`, { + toolId: this.id, + duration, + hasResult: !!result, + }) + + return { + success: true, + data: result, + } + } catch (error) { + const duration = Date.now() - startTime + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + this.logger.error(`Tool execution failed: ${this.id}`, { + toolId: this.id, + duration, + error: errorMessage, + stack: error instanceof Error ? error.stack : undefined, + }) + + return { + success: false, + error: errorMessage, + } + } + } + + /** + * Abstract method that each tool must implement with their specific logic + */ + protected abstract executeImpl(params: TParams): Promise +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts new file mode 100644 index 00000000000..c93517f12c2 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts @@ -0,0 +1,68 @@ +import { registry as blockRegistry } from '@/blocks/registry' +import { BaseCopilotTool } from '../base' +import { createLogger } from '@/lib/logs/console-logger' + +interface GetBlocksAndToolsParams { + // No parameters needed - just return all blocks and tools +} + +class GetBlocksAndToolsTool extends BaseCopilotTool> { + readonly id = 'get_blocks_and_tools' + readonly displayName = 'Getting block information' + + protected async executeImpl(params: GetBlocksAndToolsParams): Promise> { + return getBlocksAndTools() + } +} + +// Export the tool instance +export const getBlocksAndToolsTool = new GetBlocksAndToolsTool() + +// Implementation function +async function getBlocksAndTools(): Promise> { + const logger = createLogger('GetBlocksAndTools') + + logger.info('Getting all blocks and tools') + + // Create mapping of block_id -> [tool_ids] + const blockToToolsMapping: Record = {} + + // Process blocks - filter out hidden blocks and map to their tools + Object.entries(blockRegistry) + .filter(([blockType, blockConfig]) => { + // Filter out hidden blocks + if (blockConfig.hideFromToolbar) return false + return true + }) + .forEach(([blockType, blockConfig]) => { + // Get the tools for this block + const blockTools = blockConfig.tools?.access || [] + blockToToolsMapping[blockType] = blockTools + }) + + // Add special blocks that aren't in the standard registry + const specialBlocks = { + loop: { + tools: [], // Loop blocks don't use standard tools + }, + parallel: { + tools: [], // Parallel blocks don't use standard tools + }, + } + + // Add special blocks + Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { + blockToToolsMapping[blockType] = blockInfo.tools + }) + + const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length + const includedBlocks = Object.keys(blockToToolsMapping).length + + logger.info(`Successfully mapped ${includedBlocks} blocks to their tools`, { + totalBlocks, + includedBlocks, + outputMapping: blockToToolsMapping, + }) + + return blockToToolsMapping +} diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts new file mode 100644 index 00000000000..afc03940078 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts @@ -0,0 +1,301 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { createLogger } from '@/lib/logs/console-logger' +import { registry as blockRegistry } from '@/blocks/registry' +import { tools as toolsRegistry } from '@/tools/registry' +import { BaseCopilotTool } from '../base' + +const logger = createLogger('GetBlockMetadataAPI') + +interface GetBlocksMetadataParams { + blockIds: string[] +} + +interface BlocksMetadataResult { + success: boolean + data?: Record + error?: string +} + +class GetBlocksMetadataTool extends BaseCopilotTool { + readonly id = 'get_blocks_metadata' + readonly displayName = 'Getting block metadata' + + protected async executeImpl(params: GetBlocksMetadataParams): Promise { + return getBlocksMetadata(params) + } +} + +// Export the tool instance +export const getBlocksMetadataTool = new GetBlocksMetadataTool() + +// Implementation function +export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promise { + const { blockIds } = params + + if (!blockIds || !Array.isArray(blockIds)) { + return { + success: false, + error: 'blockIds must be an array of block IDs', + } + } + + logger.info('Getting block metadata', { + blockIds, + blockCount: blockIds.length, + requestedBlocks: blockIds.join(', '), + }) + + try { + // Create result object + const result: Record = {} + + // Process each requested block ID + for (const blockId of blockIds) { + // Check if it's a special block first + if (SPECIAL_BLOCKS_METADATA[blockId]) { + result[blockId] = SPECIAL_BLOCKS_METADATA[blockId] + continue + } + + // Check if the block exists in the registry + const blockConfig = blockRegistry[blockId] + if (!blockConfig) { + logger.warn(`Block not found in registry: ${blockId}`) + continue + } + + const metadata: any = { + id: blockId, + name: blockConfig.name || blockId, + description: blockConfig.description || '', + category: blockConfig.category || 'general', + inputs: blockConfig.inputs || {}, + outputs: blockConfig.outputs || {}, + tools: blockConfig.tools?.access || [], + } + + // Read YAML schema from documentation if available + const docFileName = DOCS_FILE_MAPPING[blockId] || blockId + if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { + try { + const docPath = join(process.cwd(), 'content', 'docs', 'blocks', `${docFileName}.mdx`) + if (existsSync(docPath)) { + const docContent = readFileSync(docPath, 'utf-8') + + // Extract schema from the documentation + const schemaMatch = docContent.match(/```yaml\s*\n([\s\S]*?)```/i) + if (schemaMatch) { + const yamlSchema = schemaMatch[1].trim() + // Parse high-level structure only + const lines = yamlSchema.split('\n') + const schemaInfo: any = { + fields: [], + example: yamlSchema, + } + + // Extract field names and structure + lines.forEach(line => { + const match = line.match(/^(\s*)(\w+):/) + if (match) { + const indent = match[1].length + const fieldName = match[2] + if (indent === 0) { + schemaInfo.fields.push({ + name: fieldName, + level: 'root', + }) + } + } + }) + + metadata.schema = schemaInfo + } + } + } catch (error) { + logger.warn(`Failed to read documentation for ${blockId}:`, error) + } + } + + // Add tool metadata if requested + if (metadata.tools.length > 0) { + metadata.toolDetails = {} + for (const toolId of metadata.tools) { + const tool = toolsRegistry[toolId] + if (tool) { + metadata.toolDetails[toolId] = { + name: tool.name, + description: tool.description, + } + } + } + } + + result[blockId] = metadata + } + + logger.info(`Successfully retrieved metadata for ${Object.keys(result).length} blocks`) + + return { + success: true, + data: result, + } + } catch (error) { + logger.error('Get block metadata failed', error) + return { + success: false, + error: `Failed to get block metadata: ${error instanceof Error ? error.message : 'Unknown error'}`, + } + } +} + +// Core blocks that have documentation with YAML schemas +const CORE_BLOCKS_WITH_DOCS = [ + 'agent', + 'function', + 'api', + 'condition', + 'loop', + 'parallel', + 'response', + 'router', + 'evaluator', + 'webhook', +] + +// Mapping for blocks that have different doc file names +const DOCS_FILE_MAPPING: Record = { + webhook: 'webhook_trigger', +} + +// Special blocks that aren't in the standard registry but need metadata +const SPECIAL_BLOCKS_METADATA: Record = { + loop: { + type: 'loop', + name: 'Loop', + description: 'Control flow block for iterating over collections or repeating actions', + longDescription: + 'Execute a set of blocks repeatedly, either for a fixed number of iterations or for each item in a collection. Loop blocks create sub-workflows that run multiple times with different iteration data.', + category: 'blocks', + bgColor: '#9333EA', + subBlocks: [ + { + id: 'iterationType', + title: 'Iteration Type', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Fixed Count', id: 'fixed' }, + { label: 'For Each Item', id: 'forEach' }, + ], + description: 'Choose how the loop should iterate', + }, + { + id: 'iterationCount', + title: 'Iteration Count', + type: 'short-input', + layout: 'half', + placeholder: '5', + condition: { field: 'iterationType', value: 'fixed' }, + description: 'Number of times to repeat the loop', + }, + { + id: 'collection', + title: 'Collection', + type: 'short-input', + layout: 'full', + placeholder: 'Reference to array or object', + condition: { field: 'iterationType', value: 'forEach' }, + description: 'Array or object to iterate over', + }, + ], + inputs: { + iterationType: { type: 'string', required: true }, + iterationCount: { type: 'number', required: false }, + collection: { type: 'array|object', required: false }, + }, + outputs: { + results: 'array', + iterations: 'number', + }, + tools: { access: [] }, + }, + parallel: { + type: 'parallel', + name: 'Parallel', + description: 'Control flow block for executing multiple branches simultaneously', + longDescription: + 'Execute multiple sets of blocks simultaneously, either with a fixed number of parallel branches or by distributing items from a collection across parallel executions.', + category: 'blocks', + bgColor: '#059669', + subBlocks: [ + { + id: 'parallelType', + title: 'Parallel Type', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Fixed Count', id: 'count' }, + { label: 'Collection Distribution', id: 'collection' }, + ], + description: 'Choose how parallel execution should work', + }, + { + id: 'parallelCount', + title: 'Parallel Count', + type: 'short-input', + layout: 'half', + placeholder: '3', + condition: { field: 'parallelType', value: 'count' }, + description: 'Number of parallel branches to execute', + }, + { + id: 'collection', + title: 'Collection', + type: 'short-input', + layout: 'full', + placeholder: 'Reference to array to distribute', + condition: { field: 'parallelType', value: 'collection' }, + description: 'Array to distribute across parallel executions', + }, + ], + inputs: { + parallelType: { type: 'string', required: true }, + parallelCount: { type: 'number', required: false }, + collection: { type: 'array', required: false }, + }, + outputs: { + results: 'array', + branches: 'number', + }, + tools: { access: [] }, + }, +} + +// Helper function to read YAML schema from dedicated YAML documentation files +function getYamlSchemaFromDocs(blockType: string): string | null { + try { + const docFileName = DOCS_FILE_MAPPING[blockType] || blockType + // Read from the new YAML documentation structure + const yamlDocsPath = join( + process.cwd(), + '..', + 'docs/content/docs/yaml/blocks', + `${docFileName}.mdx` + ) + + if (!existsSync(yamlDocsPath)) { + logger.warn(`YAML schema file not found for ${blockType} at ${yamlDocsPath}`) + return null + } + + const content = readFileSync(yamlDocsPath, 'utf-8') + + // Remove the frontmatter and return the content after the title + const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') + return contentWithoutFrontmatter.trim() + } catch (error) { + logger.warn(`Failed to read YAML schema for ${blockType}:`, error) + return null + } +} diff --git a/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts b/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts new file mode 100644 index 00000000000..c7ff51f56e5 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { WORKFLOW_EXAMPLES } from '@/lib/copilot/examples' +import { BaseCopilotTool } from '../base' + +interface GetWorkflowExamplesParams { + exampleIds: string[] +} + +interface WorkflowExamplesResult { + examples: Record + notFound: string[] + availableIds: string[] +} + +class GetWorkflowExamplesTool extends BaseCopilotTool { + readonly id = 'get_workflow_examples' + readonly displayName = 'Getting workflow examples' + + protected async executeImpl(params: GetWorkflowExamplesParams): Promise { + return getWorkflowExamples(params) + } +} + +// Export the tool instance +export const getWorkflowExamplesTool = new GetWorkflowExamplesTool() + +// Implementation function +async function getWorkflowExamples(params: GetWorkflowExamplesParams): Promise { + const logger = createLogger('GetWorkflowExamples') + + // Strict validation - exampleIds is required + if (!params || !params.exampleIds || !Array.isArray(params.exampleIds) || params.exampleIds.length === 0) { + throw new Error('exampleIds parameter is required and must be a non-empty array of example IDs') + } + + const { exampleIds } = params + + logger.info('Getting workflow examples for copilot', { exampleCount: exampleIds.length }) + + const examples: Record = {} + const notFound: string[] = [] + + for (const id of exampleIds) { + if (WORKFLOW_EXAMPLES[id]) { + examples[id] = WORKFLOW_EXAMPLES[id] + } else { + notFound.push(id) + } + } + + return { + examples, + notFound, + availableIds: Object.keys(WORKFLOW_EXAMPLES), + } +} diff --git a/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts b/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts new file mode 100644 index 00000000000..0af00c90fac --- /dev/null +++ b/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts @@ -0,0 +1,36 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { getYamlWorkflowPrompt } from '@/lib/copilot/prompts' +import { BaseCopilotTool } from '../base' + +interface GetYamlStructureParams { + // No parameters needed - just return the YAML structure guide +} + +interface YamlStructureResult { + guide: string + message: string +} + +class GetYamlStructureTool extends BaseCopilotTool { + readonly id = 'get_yaml_structure' + readonly displayName = 'Analyzing workflow structure' + + protected async executeImpl(params: GetYamlStructureParams): Promise { + return getYamlStructure() + } +} + +// Export the tool instance +export const getYamlStructureTool = new GetYamlStructureTool() + +// Implementation function +async function getYamlStructure(): Promise { + const logger = createLogger('GetYamlStructure') + + logger.info('Getting YAML structure guide') + + return { + guide: getYamlWorkflowPrompt(), + message: 'Complete YAML workflow syntax guide with examples and best practices', + } +} diff --git a/apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts b/apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts new file mode 100644 index 00000000000..e372edf71ba --- /dev/null +++ b/apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { db } from '@/db' +import { docsEmbeddings } from '@/db/schema' +import { sql } from 'drizzle-orm' +import { getCopilotConfig } from '@/lib/copilot/config' +import { BaseCopilotTool } from '../base' + +interface DocsSearchParams { + query: string + topK?: number + threshold?: number +} + +interface DocumentationSearchResult { + id: number + title: string + url: string + content: string + similarity: number +} + +interface DocsSearchResult { + results: DocumentationSearchResult[] + query: string + totalResults: number +} + +class DocsSearchInternalTool extends BaseCopilotTool { + readonly id = 'search_documentation' + readonly displayName = 'Searching documentation' + + protected async executeImpl(params: DocsSearchParams): Promise { + return docsSearch(params) + } +} + +// Export the tool instance +export const docsSearchInternalTool = new DocsSearchInternalTool() + +// Implementation function +async function docsSearch(params: DocsSearchParams): Promise { + const logger = createLogger('DocsSearch') + const { query, topK = 10, threshold } = params + + logger.info('Executing docs search for copilot', { + query, + topK, + }) + + try { + const config = getCopilotConfig() + const similarityThreshold = threshold ?? config.rag.similarityThreshold + + // Generate embedding for the query + const { generateEmbeddings } = await import('@/app/api/knowledge/utils') + + logger.info('About to generate embeddings for query', { query, queryLength: query.length }) + + const embeddings = await generateEmbeddings([query]) + const queryEmbedding = embeddings[0] + + if (!queryEmbedding || queryEmbedding.length === 0) { + logger.warn('Failed to generate query embedding') + return { + results: [], + query, + totalResults: 0, + } + } + + logger.info('Successfully generated query embedding', { embeddingLength: queryEmbedding.length }) + + // Search docs embeddings using vector similarity + const results = await db + .select({ + chunkId: docsEmbeddings.chunkId, + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + headerLevel: docsEmbeddings.headerLevel, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + // Filter by similarity threshold + const filteredResults = results.filter((result) => result.similarity >= similarityThreshold) + + const documentationResults: DocumentationSearchResult[] = filteredResults.map((result, index) => ({ + id: index + 1, + title: String(result.headerText || 'Untitled Section'), + url: String(result.sourceLink || '#'), + content: String(result.chunkText || ''), + similarity: result.similarity, + })) + + logger.info(`Found ${documentationResults.length} documentation results`, { query }) + + return { + results: documentationResults, + query, + totalResults: documentationResults.length, + } + } catch (error) { + logger.error('Documentation search failed with detailed error:', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + query, + errorType: error?.constructor?.name, + status: (error as any)?.status + }) + throw new Error(`Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/other/online-search.ts b/apps/sim/app/api/copilot/tools/other/online-search.ts new file mode 100644 index 00000000000..870041e934a --- /dev/null +++ b/apps/sim/app/api/copilot/tools/other/online-search.ts @@ -0,0 +1,68 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { executeTool } from '@/tools' +import { BaseCopilotTool } from '../base' + +interface OnlineSearchParams { + query: string + num?: number + type?: string + gl?: string + hl?: string +} + +interface OnlineSearchResult { + results: any[] + query: string + type: string + totalResults: number +} + +class OnlineSearchTool extends BaseCopilotTool { + readonly id = 'search_online' + readonly displayName = 'Searching online' + + protected async executeImpl(params: OnlineSearchParams): Promise { + return onlineSearch(params) + } +} + +// Export the tool instance +export const onlineSearchTool = new OnlineSearchTool() + +// Implementation function +async function onlineSearch(params: OnlineSearchParams): Promise { + const logger = createLogger('OnlineSearch') + const { query, num = 10, type = 'search', gl, hl } = params + + logger.info('Performing online search', { + query, + num, + type, + gl, + hl + }) + + // Execute the serper_search tool + const toolParams = { + query, + num, + type, + gl, + hl, + apiKey: process.env.SERPER_API_KEY || '', + } + + const result = await executeTool('serper_search', toolParams) + + if (!result.success) { + throw new Error(result.error || 'Search failed') + } + + // The serper tool already formats the results properly + return { + results: result.output.searchResults || [], + query, + type, + totalResults: result.output.searchResults?.length || 0, + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/registry.ts b/apps/sim/app/api/copilot/tools/registry.ts new file mode 100644 index 00000000000..21ff3f69560 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/registry.ts @@ -0,0 +1,106 @@ +import { CopilotTool } from './base' +import { COPILOT_TOOL_DISPLAY_NAMES, type CopilotToolId } from '@/stores/constants' +// Import all tools to register them +import { getBlocksAndToolsTool } from './blocks/get-blocks-and-tools' +import { getBlocksMetadataTool } from './blocks/get-blocks-metadata' +import { getWorkflowExamplesTool } from './blocks/get-workflow-examples' +import { getYamlStructureTool } from './blocks/get-yaml-structure' +import { docsSearchInternalTool } from './docs/docs-search-internal' +import { onlineSearchTool } from './other/online-search' +import { getEnvironmentVariablesTool } from './user/get-environment-variables' +import { setEnvironmentVariablesTool } from './user/set-environment-variables' +import { getUserWorkflowTool } from './workflow/get-user-workflow' +import { previewWorkflowTool } from './workflow/preview-workflow' +import { getWorkflowConsoleTool } from './workflow/get-workflow-console' +import { targetedUpdatesTool } from './workflow/targeted-updates' + +// Registry of all copilot tools +export class CopilotToolRegistry { + private tools = new Map() + + /** + * Register a tool in the registry + */ + register(tool: CopilotTool): void { + if (this.tools.has(tool.id)) { + throw new Error(`Tool with id '${tool.id}' is already registered`) + } + this.tools.set(tool.id, tool) + } + + /** + * Get a tool by its ID + */ + get(id: string): CopilotTool | undefined { + return this.tools.get(id) + } + + /** + * Check if a tool exists + */ + has(id: string): boolean { + return this.tools.has(id) + } + + /** + * Get all available tool IDs + */ + getAvailableIds(): string[] { + return Array.from(this.tools.keys()) + } + + /** + * Get all tools + */ + getAll(): CopilotTool[] { + return Array.from(this.tools.values()) + } + + /** + * Execute a tool by ID with parameters + */ + async execute(toolId: string, params: any): Promise { + const tool = this.get(toolId) + if (!tool) { + throw new Error(`Tool not found: ${toolId}`) + } + return tool.execute(params) + } + + /** + * Get display name for a tool ID + */ + getDisplayName(toolId: string): string { + return COPILOT_TOOL_DISPLAY_NAMES[toolId] || toolId + } + + /** + * Get all tool display names as a record + */ + getAllDisplayNames(): Record { + return COPILOT_TOOL_DISPLAY_NAMES + } +} + +// Global registry instance +export const copilotToolRegistry = new CopilotToolRegistry() + +// Register all tools +copilotToolRegistry.register(getBlocksAndToolsTool) +copilotToolRegistry.register(getBlocksMetadataTool) +copilotToolRegistry.register(getWorkflowExamplesTool) +copilotToolRegistry.register(getYamlStructureTool) +copilotToolRegistry.register(docsSearchInternalTool) +copilotToolRegistry.register(onlineSearchTool) +copilotToolRegistry.register(getEnvironmentVariablesTool) +copilotToolRegistry.register(setEnvironmentVariablesTool) +copilotToolRegistry.register(getUserWorkflowTool) +copilotToolRegistry.register(previewWorkflowTool) +copilotToolRegistry.register(getWorkflowConsoleTool) +copilotToolRegistry.register(targetedUpdatesTool) + +// Dynamically generated constants - single source of truth +export const COPILOT_TOOL_IDS = copilotToolRegistry.getAvailableIds() + +// Export the type from shared constants +export type { CopilotToolId } \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/user/get-environment-variables.ts b/apps/sim/app/api/copilot/tools/user/get-environment-variables.ts new file mode 100644 index 00000000000..a61ecb8b298 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/user/get-environment-variables.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { getEnvironmentVariableKeys } from '@/lib/environment/utils' +import { getUserId } from '@/app/api/auth/oauth/utils' +import { BaseCopilotTool } from '../base' + +interface GetEnvironmentVariablesParams { + userId?: string + workflowId?: string +} + +interface EnvironmentVariablesResult { + variableNames: string[] + count: number +} + +class GetEnvironmentVariablesTool extends BaseCopilotTool { + readonly id = 'get_environment_variables' + readonly displayName = 'Getting environment variables' + + protected async executeImpl(params: GetEnvironmentVariablesParams): Promise { + return getEnvironmentVariables(params) + } +} + +// Export the tool instance +export const getEnvironmentVariablesTool = new GetEnvironmentVariablesTool() + +// Implementation function +async function getEnvironmentVariables(params: GetEnvironmentVariablesParams): Promise { + const logger = createLogger('GetEnvironmentVariables') + const { userId: directUserId, workflowId } = params + + logger.info('Getting environment variables for copilot', { + hasUserId: !!directUserId, + hasWorkflowId: !!workflowId + }) + + // Resolve userId from workflowId if needed + const userId = directUserId || (workflowId ? await getUserId('copilot-env-vars', workflowId) : undefined) + + logger.info('Resolved userId', { + directUserId, + workflowId, + resolvedUserId: userId + }) + + if (!userId) { + logger.warn('No userId could be determined', { directUserId, workflowId }) + throw new Error('Either userId or workflowId is required') + } + + // Get environment variable keys directly + const result = await getEnvironmentVariableKeys(userId) + + logger.info('Environment variable keys retrieved', { + userId, + variableCount: result.count + }) + + return { + variableNames: result.variableNames, + count: result.count, + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts b/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts new file mode 100644 index 00000000000..62ac3b8d9a0 --- /dev/null +++ b/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts @@ -0,0 +1,62 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { BaseCopilotTool } from '../base' + +interface SetEnvironmentVariablesParams { + variables: Record +} + +interface SetEnvironmentVariablesResult { + message: string + updatedVariables: string[] + count: number +} + +class SetEnvironmentVariablesTool extends BaseCopilotTool { + readonly id = 'set_environment_variables' + readonly displayName = 'Setting environment variables' + + protected async executeImpl(params: SetEnvironmentVariablesParams): Promise { + return setEnvironmentVariables(params) + } +} + +// Export the tool instance +export const setEnvironmentVariablesTool = new SetEnvironmentVariablesTool() + +// Implementation function +async function setEnvironmentVariables(params: SetEnvironmentVariablesParams): Promise { + const logger = createLogger('SetEnvironmentVariables') + const { variables } = params + + logger.info('Setting environment variables for copilot', { + variableCount: Object.keys(variables).length, + variableNames: Object.keys(variables), + }) + + // Forward the request to the existing environment variables endpoint + const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` + + const response = await fetch(envUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ variables }), + }) + + if (!response.ok) { + logger.error('Set environment variables API failed', { + status: response.status, + statusText: response.statusText + }) + throw new Error('Failed to set environment variables') + } + + await response.json() + + return { + message: 'Environment variables updated successfully', + updatedVariables: Object.keys(variables), + count: Object.keys(variables).length, + } +} \ No newline at end of file diff --git a/apps/sim/app/api/copilot/get-user-workflow/route.ts b/apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts similarity index 88% rename from apps/sim/app/api/copilot/get-user-workflow/route.ts rename to apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts index f5116246c29..2b27c50cf2b 100644 --- a/apps/sim/app/api/copilot/get-user-workflow/route.ts +++ b/apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts @@ -1,20 +1,33 @@ import { eq } from 'drizzle-orm' -import { dump as yamlDump } from 'js-yaml' import { createLogger } from '@/lib/logs/console-logger' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' import { getBlock } from '@/blocks' import { db } from '@/db' import { workflow as workflowTable } from '@/db/schema' +import { BaseCopilotTool } from '../base' -const logger = createLogger('GetUserWorkflowAPI') +interface GetUserWorkflowParams { + workflowId: string + includeMetadata?: boolean +} -export async function getUserWorkflow(params: any) { - const { workflowId, includeMetadata = false } = params +class GetUserWorkflowTool extends BaseCopilotTool { + readonly id = 'get_user_workflow' + readonly displayName = 'Analyzing your workflow' - if (!workflowId) { - throw new Error('Workflow ID is required') + protected async executeImpl(params: GetUserWorkflowParams): Promise { + return getUserWorkflow(params) } +} + +// Export the tool instance +export const getUserWorkflowTool = new GetUserWorkflowTool() + +// Implementation function +async function getUserWorkflow(params: GetUserWorkflowParams): Promise { + const logger = createLogger('GetUserWorkflow') + const { workflowId, includeMetadata = false } = params logger.info('Fetching user workflow', { workflowId }) @@ -155,8 +168,5 @@ export async function getUserWorkflow(params: any) { logger.info('YAML', { yaml }) // Return the condensed YAML format directly, just like the YAML editor does - return { - success: true, - data: yaml, - } + return yaml } diff --git a/apps/sim/app/api/copilot/get-workflow-console/route.ts b/apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts similarity index 64% rename from apps/sim/app/api/copilot/get-workflow-console/route.ts rename to apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts index ba5994fc96c..97f0d4c5b0c 100644 --- a/apps/sim/app/api/copilot/get-workflow-console/route.ts +++ b/apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts @@ -2,15 +2,38 @@ import { desc, eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { workflowExecutionLogs } from '@/db/schema' +import { BaseCopilotTool } from '../base' -const logger = createLogger('GetWorkflowConsoleAPI') +interface GetWorkflowConsoleParams { + workflowId: string + limit?: number + includeDetails?: boolean +} -export async function getWorkflowConsole(params: any) { - const { workflowId, limit = 50, includeDetails = false } = params +interface WorkflowConsoleResult { + entries: any[] + totalEntries: number + workflowId: string + retrievedAt: string + hasBlockDetails: boolean +} + +class GetWorkflowConsoleTool extends BaseCopilotTool { + readonly id = 'get_workflow_console' + readonly displayName = 'Getting workflow console' - if (!workflowId) { - throw new Error('Workflow ID is required') + protected async executeImpl(params: GetWorkflowConsoleParams): Promise { + return getWorkflowConsole(params) } +} + +// Export the tool instance +export const getWorkflowConsoleTool = new GetWorkflowConsoleTool() + +// Implementation function +async function getWorkflowConsole(params: GetWorkflowConsoleParams): Promise { + const logger = createLogger('GetWorkflowConsole') + const { workflowId, limit = 50, includeDetails = false } = params logger.info('Fetching workflow console logs', { workflowId, limit, includeDetails }) @@ -62,13 +85,10 @@ export async function getWorkflowConsole(params: any) { }) return { - success: true, - data: { - entries: formattedEntries, - totalEntries: formattedEntries.length, - workflowId, - retrievedAt: new Date().toISOString(), - hasBlockDetails: false, - }, + entries: formattedEntries, + totalEntries: formattedEntries.length, + workflowId, + retrievedAt: new Date().toISOString(), + hasBlockDetails: false, } } diff --git a/apps/sim/app/api/copilot/preview-workflow/route.ts b/apps/sim/app/api/copilot/tools/workflow/preview-workflow.ts similarity index 53% rename from apps/sim/app/api/copilot/preview-workflow/route.ts rename to apps/sim/app/api/copilot/tools/workflow/preview-workflow.ts index 05b39b89f3e..4c93ecbbd24 100644 --- a/apps/sim/app/api/copilot/preview-workflow/route.ts +++ b/apps/sim/app/api/copilot/tools/workflow/preview-workflow.ts @@ -1,13 +1,33 @@ import { createLogger } from '@/lib/logs/console-logger' +import { BaseCopilotTool } from '../base' -const logger = createLogger('PreviewWorkflowAPI') +interface PreviewWorkflowParams { + yamlContent: string + description?: string +} -export async function previewWorkflow(params: any) { - const { yamlContent, description } = params +interface PreviewWorkflowResult { + yamlContent: string + description?: string + [key: string]: any // For the preview data fields +} + +class PreviewWorkflowTool extends BaseCopilotTool { + readonly id = 'build_workflow' + readonly displayName = 'Preview workflow changes' - if (!yamlContent) { - throw new Error('yamlContent is required') + protected async executeImpl(params: PreviewWorkflowParams): Promise { + return previewWorkflow(params) } +} + +// Export the tool instance +export const previewWorkflowTool = new PreviewWorkflowTool() + +// Implementation function +async function previewWorkflow(params: PreviewWorkflowParams): Promise { + const logger = createLogger('PreviewWorkflow') + const { yamlContent, description } = params logger.info('Generating workflow preview for copilot', { yamlLength: yamlContent.length, @@ -44,11 +64,8 @@ export async function previewWorkflow(params: any) { // Return in the format expected by the copilot for diff functionality return { - success: true, - data: { - ...previewData, - yamlContent, // Include the original YAML for diff functionality - description, - }, + ...previewData, + yamlContent, // Include the original YAML for diff functionality + description, } } \ No newline at end of file diff --git a/apps/sim/app/api/copilot/targeted-updates/route.ts b/apps/sim/app/api/copilot/tools/workflow/targeted-updates.ts similarity index 65% rename from apps/sim/app/api/copilot/targeted-updates/route.ts rename to apps/sim/app/api/copilot/tools/workflow/targeted-updates.ts index e39b1e5e295..17e0a2b82d0 100644 --- a/apps/sim/app/api/copilot/targeted-updates/route.ts +++ b/apps/sim/app/api/copilot/tools/workflow/targeted-updates.ts @@ -1,9 +1,4 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { eq } from 'drizzle-orm' -import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { apiKey as apiKeyTable } from '@/db/schema' const logger = createLogger('TargetedUpdatesAPI') @@ -231,156 +226,75 @@ async function applyOperationsToYaml( return yaml.stringify(workflowData) } -export async function targetedUpdates(params: any) { - try { - const { operations, workflowId } = params +import { BaseCopilotTool } from '../base' - if (!operations || !Array.isArray(operations)) { - return { - success: false, - error: 'operations must be an array', - } - } +interface TargetedUpdatesParams { + operations: TargetedUpdateOperation[] + workflowId: string +} - if (!workflowId) { - return { - success: false, - error: 'workflowId is required', - } - } +interface TargetedUpdatesResult { + yamlContent: string + operations: Array<{ type: string; blockId: string }> +} - logger.info('Processing targeted update request', { - workflowId, - operationCount: operations.length - }) - - // Get current workflow YAML directly by calling the function - const { getUserWorkflow } = await import('@/app/api/copilot/get-user-workflow/route') - - const getUserWorkflowResult = await getUserWorkflow({ - workflowId: workflowId, - includeMetadata: false, - }) - - if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) { - return { - success: false, - error: 'Failed to get current workflow YAML', - } - } +class TargetedUpdatesTool extends BaseCopilotTool { + readonly id = 'edit_workflow' + readonly displayName = 'Updating workflow' - const currentYaml = getUserWorkflowResult.data - - logger.info('Retrieved current workflow YAML', { - yamlLength: currentYaml.length, - yamlPreview: currentYaml.substring(0, 200), - }) - - // Apply operations to generate modified YAML - const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) - - logger.info('Applied operations to YAML', { - operationCount: operations.length, - currentYamlLength: currentYaml.length, - modifiedYamlLength: modifiedYaml.length, - operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), - }) - - logger.info( - `Successfully generated modified YAML for ${operations.length} targeted update operations` - ) - - // Return the modified YAML directly - the UI will handle preview generation via updateDiffStore() - return { - success: true, - data: { - yamlContent: modifiedYaml, - operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), - }, - } - } catch (error) { - logger.error('Targeted update failed:', error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } + protected async executeImpl(params: TargetedUpdatesParams): Promise { + return targetedUpdates(params) } } -export async function POST(request: NextRequest) { - try { - // Try session auth first (for web UI) - const session = await getSession() - let authenticatedUserId: string | null = session?.user?.id || null - - // If no session, check for API key auth - if (!authenticatedUserId) { - const apiKeyHeader = request.headers.get('x-api-key') - if (apiKeyHeader) { - // Verify API key - const [apiKeyRecord] = await db - .select({ userId: apiKeyTable.userId }) - .from(apiKeyTable) - .where(eq(apiKeyTable.key, apiKeyHeader)) - .limit(1) - - if (apiKeyRecord) { - authenticatedUserId = apiKeyRecord.userId - } - } - } +// Export the tool instance +export const targetedUpdatesTool = new TargetedUpdatesTool() - // Parse body early to check for workflowId - const body = await request.json() - const { operations, workflowId } = body - - // If no authentication but workflowId is provided, allow internal calls - // This maintains backward compatibility for internal copilot tool calls - if (!authenticatedUserId) { - if (!workflowId) { - return NextResponse.json({ error: 'Unauthorized - authentication or workflowId required' }, { status: 401 }) - } - - // For internal calls without auth, we'll validate the workflow exists - // but won't enforce user ownership (as this was the original behavior) - logger.info('Allowing internal call to targeted-updates without authentication', { workflowId }) - } +// Implementation function +async function targetedUpdates(params: TargetedUpdatesParams): Promise { + const { operations, workflowId } = params - if (!operations || !Array.isArray(operations)) { - return NextResponse.json( - { success: false, error: 'Operations array is required' }, - { status: 400 } - ) - } + logger.info('Processing targeted update request', { + workflowId, + operationCount: operations.length + }) - if (!workflowId) { - return NextResponse.json( - { success: false, error: 'Workflow ID is required' }, - { status: 400 } - ) - } + // Get current workflow YAML directly by calling the function + const { getUserWorkflowTool } = await import('./get-user-workflow') + + const getUserWorkflowResult = await getUserWorkflowTool.execute({ + workflowId: workflowId, + includeMetadata: false, + }) + + if (!getUserWorkflowResult.success || !getUserWorkflowResult.data) { + throw new Error('Failed to get current workflow YAML') + } + + const currentYaml = getUserWorkflowResult.data + + logger.info('Retrieved current workflow YAML', { + yamlLength: currentYaml.length, + yamlPreview: currentYaml.substring(0, 200), + }) + + // Apply operations to generate modified YAML + const modifiedYaml = await applyOperationsToYaml(currentYaml, operations) + + logger.info('Applied operations to YAML', { + operationCount: operations.length, + currentYamlLength: currentYaml.length, + modifiedYamlLength: modifiedYaml.length, + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), + }) + + logger.info( + `Successfully generated modified YAML for ${operations.length} targeted update operations` + ) - logger.info('Executing targeted updates', { - workflowId, - userId: authenticatedUserId || 'internal_call', - operationCount: operations.length, - operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), - }) - - const result = await targetedUpdates({ - operations, - workflowId, - }) - - return NextResponse.json(result) - } catch (error) { - logger.error('Targeted updates API failed:', error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ) + // Return the modified YAML directly - the UI will handle preview generation via updateDiffStore() + return { + yamlContent: modifiedYaml, + operations: operations.map((op) => ({ type: op.operation_type, blockId: op.block_id })), } } diff --git a/apps/sim/app/api/test-auth/route.ts b/apps/sim/app/api/test-auth/route.ts deleted file mode 100644 index 8a3586ac1d2..00000000000 --- a/apps/sim/app/api/test-auth/route.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console-logger' -import { simAgentClient } from '@/lib/sim-agent/client' - -const logger = createLogger('TestAuthAPI') - -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - // Get session for user info - const session = await getSession() - const body = await request.json() - const { workflowId, userId } = body - - if (!workflowId) { - return NextResponse.json( - { success: false, error: 'Workflow ID is required' }, - { status: 400 } - ) - } - - logger.info(`[${requestId}] Test auth request`, { - workflowId, - userId: userId || session?.user?.id, - hasSession: !!session, - }) - - // Use the sim-agent client - only send data, no cookies - const result = await simAgentClient.testAuth({ - workflowId, - userId: userId || session?.user?.id, - }) - - logger.info(`[${requestId}] Sim-agent response`, { - success: result.success, - status: result.status, - hasData: !!result.data, - }) - - return NextResponse.json(result, { - status: result.success ? 200 : (result.status || 500) - }) - - } catch (error) { - logger.error(`[${requestId}] Test auth API failed:`, error) - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index e49a66b0740..32a859c2797 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { CopilotMessage } from '@/stores/copilot/types' import type { ToolCallState } from '@/types/tool-call' +import { COPILOT_TOOL_IDS } from '@/stores/copilot/constants' interface ProfessionalMessageProps { message: CopilotMessage @@ -66,7 +67,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN } // Special handling for preview workflow and targeted updates - const isPreviewTool = tool.name === 'preview_workflow' || tool.name === 'targeted_updates' + const isPreviewTool = tool.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tool.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW if (isPreviewTool) { return ( @@ -124,7 +125,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN )} > {tool.state === 'executing' - ? tool.name === 'targeted_updates' + ? tool.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ? 'Editing workflow' : 'Building workflow' : tool.displayName || tool.name} @@ -140,7 +141,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN )} > {tool.state === 'executing' - ? tool.name === 'targeted_updates' + ? tool.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ? 'Editing workflow...' : 'Building workflow...' : tool.state === 'ready_for_review' @@ -149,7 +150,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN ? 'Applied changes' : tool.state === 'rejected' ? 'Rejected changes' - : tool.name === 'targeted_updates' + : tool.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ? 'Workflow editing failed' : 'Workflow generation failed'}
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 8652e33f746..287c6954089 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -13,6 +13,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { createLogger } from '@/lib/logs/console-logger' import { usePreviewStore } from '@/stores/copilot/preview-store' import { useCopilotStore } from '@/stores/copilot/store' +import { COPILOT_TOOL_IDS } from '@/stores/copilot/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' @@ -129,7 +130,7 @@ export const Copilot = forwardRef( // Check for completed preview_workflow tool calls const previewToolCall = lastMessage.toolCalls.find( - (tc) => tc.name === 'preview_workflow' && tc.state === 'completed' && !isToolCallSeen(tc.id) + (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW && tc.state === 'completed' && !isToolCallSeen(tc.id) ) if (previewToolCall?.result) { diff --git a/apps/sim/lib/copilot/examples.ts b/apps/sim/lib/copilot/examples.ts index b614ac25ee8..3656a8eae45 100644 --- a/apps/sim/lib/copilot/examples.ts +++ b/apps/sim/lib/copilot/examples.ts @@ -217,7 +217,7 @@ blocks: model: gpt-4o apiKey: '{{OPENAI_API_KEY}}'`, - // Targeted Update Examples - for demonstrating targeted_updates tool usage patterns + // Targeted Update Examples - for demonstrating edit_workflow tool usage patterns targeted_add_block: `// Example: Adding a new agent block to an existing workflow // Operation: Add a new block after an existing agent { diff --git a/apps/sim/lib/copilot/prompts.ts b/apps/sim/lib/copilot/prompts.ts index 6f367c69498..688c161dfdc 100644 --- a/apps/sim/lib/copilot/prompts.ts +++ b/apps/sim/lib/copilot/prompts.ts @@ -86,7 +86,7 @@ You are a workflow automation assistant with FULL editing capabilities for Sim S 2. **Get All Blocks and Tools** 3. **Get Block Metadata** (for blocks you'll use) 4. **Get YAML Structure Guide** -5. **Preview Workflow** OR **Targeted Updates** (ONLY after steps 1-4) +5. **Build Workflow** OR **Edit Workflow** (ONLY after steps 1-4) **ENFORCEMENT**: - This sequence is MANDATORY for EVERY edit @@ -96,7 +96,7 @@ You are a workflow automation assistant with FULL editing capabilities for Sim S **TARGETED UPDATES RESTRICTION**: ⚠️ **ABSOLUTELY NO TARGETED UPDATES WITHOUT PREREQUISITES**: -- You are FORBIDDEN from using the \`targeted_updates\` tool until you have completed ALL prerequisite steps (1-4) +- You are FORBIDDEN from using the \`edit_workflow\` tool until you have completed ALL prerequisite steps (1-4) - Even for "simple" changes or single block edits - Even if you think you "remember" the workflow structure - NO EXCEPTIONS - targeted updates are only allowed after going through the complete information gathering sequence @@ -158,7 +158,7 @@ const TOOL_USAGE_GUIDELINES = ` - Part of mandatory sequence for editing **Strategy**: Choose examples that match the workflow type you're building -### 🚀 "Preview Workflow" (Agent Mode Only) +### 🚀 "Build Workflow" (Agent Mode Only) **Purpose**: Show workflow changes to user before applying **When to use**: - ONLY after completing all prerequisite tools @@ -181,7 +181,7 @@ const TOOL_USAGE_GUIDELINES = ` - **Add**: Insert new blocks with specified configuration - **Edit**: Modify inputs, connections, or other properties of existing blocks - **Delete**: Remove specific blocks from the workflow -**Note**: Use this as an alternative to "Preview Workflow" for targeted modifications +**Note**: Use this as an alternative to "Build Workflow" for targeted modifications ### 🔧 "Get Environment Variables" **Purpose**: View available environment variables configured by the user @@ -251,7 +251,7 @@ const WORKFLOW_BUILDING_PROCESS = ` - **Strategy**: Choose 1-3 examples that best match the workflow type (basic-agent, multi-agent, loops, APIs, etc.) - **Output**: Real YAML examples to reference and adapt -#### Step 6: Preview Workflow +#### Step 6: Build Workflow - **Purpose**: Show changes to user - **Required**: ONLY after steps 1-5 complete - **Critical**: Apply block selection rules before previewing (see BLOCK SELECTION GUIDELINES) @@ -259,14 +259,14 @@ const WORKFLOW_BUILDING_PROCESS = ` #### Step 6 Alternative: Targeted Updates (for SMALL-SCALE edits) - **Purpose**: Make precise, atomic changes to specific workflow blocks -- **When to prefer over Preview Workflow**: +- **When to prefer over Build Workflow**: - **Small, focused edits** (1-3 blocks maximum) - **Adding a single block** or simple connection - **Modifying specific block inputs** or parameters - **Minor configuration changes** to existing blocks - When preserving workflow structure and IDs is important - Quick fixes or incremental improvements -- **When to use Preview Workflow instead (BUILD WORKFLOW)**: +- **When to use Build Workflow instead**: - **Creating entirely new workflows from scratch** - **Complete workflow redesign or restructuring** - **Major overhauls** requiring significant changes (4+ blocks) @@ -344,7 +344,7 @@ When calling "Get Workflow Examples", choose examples that match the user's need **For Data Processing**: ["iter-loop", "for-each-loop"] **For Complex Workflows**: ["multi-agent", "iter-loop"] -**For Targeted Updates** (when using targeted_updates tool): +**For Targeted Updates** (when using edit_workflow tool): **Adding Blocks**: ["targeted_add_block", "targeted_add_connection"] **Modifying Blocks**: ["targeted_edit_block", "targeted_batch_operations"] **Removing Blocks**: ["targeted_delete_block"] diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index 885b476d750..a680d3db82e 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -7,6 +7,7 @@ import { copilotChats, docsEmbeddings } from '@/db/schema' import { executeProviderRequest } from '@/providers' import type { ProviderToolConfig } from '@/providers/types' import { getApiKey } from '@/providers/utils' +import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants' import { getCopilotConfig, getCopilotModel } from './config' import { WORKFLOW_EXAMPLES } from './examples' import { @@ -186,9 +187,24 @@ function buildConversationMessages( * Get available tools for the given mode */ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { + // Use tool IDs from shared constants + const buildWorkflowId = Object.keys(COPILOT_TOOL_DISPLAY_NAMES).find(id => + COPILOT_TOOL_DISPLAY_NAMES[id].includes('Building') || + COPILOT_TOOL_DISPLAY_NAMES[id].includes('Preview') + ) || 'build_workflow' + + const editWorkflowId = Object.keys(COPILOT_TOOL_DISPLAY_NAMES).find(id => + COPILOT_TOOL_DISPLAY_NAMES[id].includes('Updating') || + COPILOT_TOOL_DISPLAY_NAMES[id].includes('Edit') + ) || 'edit_workflow' + + const searchDocsId = Object.keys(COPILOT_TOOL_DISPLAY_NAMES).find(id => + COPILOT_TOOL_DISPLAY_NAMES[id].includes('documentation') + ) || 'search_documentation' + const allTools: ProviderToolConfig[] = [ { - id: 'docs_search_internal', + id: searchDocsId, name: 'Search Documentation', description: 'Search Sim Studio documentation for information about features, tools, workflows, and functionality', @@ -198,75 +214,43 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { properties: { query: { type: 'string', - description: 'The search query to find relevant documentation', + description: 'Search query for documentation', }, topK: { type: 'number', - description: 'Number of results to return (default: 10, max: 10)', + description: 'Number of top results to return (default: 10)', default: 10, }, + threshold: { + type: 'number', + description: 'Similarity threshold for results (default: 0.7)', + default: 0.7, + }, }, required: ['query'], }, }, { id: 'get_user_workflow', - name: "Get User's Specific Workflow", + name: 'Get User Workflow', description: - "Get the user's current workflow - this shows ONLY the blocks they have actually built and configured in their specific workflow, not general Sim Studio capabilities.", + 'Retrieve the current YAML configuration of the user\'s workflow. This should be your FIRST step when analyzing or modifying workflows.', params: {}, parameters: { type: 'object', - properties: { - includeMetadata: { - type: 'boolean', - description: - 'Whether to include additional metadata about the workflow (default: false)', - default: false, - }, - }, + properties: {}, required: [], }, }, - { - id: 'get_workflow_examples', - name: 'Get Workflow Examples', - description: `Get proven YAML workflow examples by ID to reference when building workflows. Available IDs: ${Object.keys(WORKFLOW_EXAMPLES as Record).join(', ')}`, - params: {}, - parameters: { - type: 'object', - properties: { - exampleIds: { - type: 'array', - items: { - type: 'string', - }, - description: 'Array of example IDs to retrieve', - }, - }, - required: ['exampleIds'], - }, - }, { id: 'get_blocks_and_tools', - name: 'Get All Blocks and Tools', + name: 'Get Blocks and Tools', description: - 'Get a comprehensive list of all available blocks and tools in Sim Studio with their descriptions, categories, and capabilities.', + 'Get a comprehensive mapping of all available blocks and their associated tools. Essential for understanding what blocks are available for workflow creation.', params: {}, parameters: { type: 'object', - properties: { - includeDetails: { - type: 'boolean', - description: - 'Whether to include detailed information like inputs, outputs, and sub-blocks (default: false)', - default: false, - }, - filterCategory: { - type: 'string', - description: 'Optional category filter for blocks (e.g., "tools", "blocks", "ai")', - }, - }, + properties: {}, required: [], }, }, @@ -292,9 +276,9 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, { id: 'get_yaml_structure', - name: 'Get YAML Workflow Structure Guide', + name: 'Get YAML Structure', description: - 'Get comprehensive YAML workflow syntax guide and examples to understand how to structure Sim Studio workflows.', + 'Get the YAML workflow structure guide and best practices for creating workflows.', params: {}, parameters: { type: 'object', @@ -303,8 +287,8 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, }, { - id: 'preview_workflow', - name: 'Preview Workflow', + id: buildWorkflowId, + name: 'Build Workflow', description: 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. This is the ONLY way to propose workflow changes. IMPORTANT: After calling this tool, you MUST stop your response immediately and wait for the user to either accept, reject, or provide additional feedback before continuing the conversation.', params: {}, @@ -323,74 +307,43 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { required: ['yamlContent'], }, }, - // { - // id: 'edit_workflow', - // name: 'Edit Workflow', - // description: - // 'Save/edit the current workflow by providing YAML content. This performs the same action as saving in the YAML code editor.', - // params: {}, - // parameters: { - // type: 'object', - // properties: { - // yamlContent: { - // type: 'string', - // description: 'The complete YAML workflow content to save', - // }, - // description: { - // type: 'string', - // description: 'Optional description of the changes being made', - // }, - // }, - // required: ['yamlContent'], - // }, - // }, { - id: 'serper_search', - name: 'Web Search', + id: 'get_workflow_examples', + name: 'Get Workflow Examples', description: - 'Search the internet for real-time information using Google search results. Useful for finding current information, news, facts, and general web content that may not be available in the documentation.', - params: { - apiKey: process.env.SERPER_API_KEY || '', - }, + 'Get example workflows for reference. Useful for understanding patterns and structures.', + params: {}, parameters: { type: 'object', properties: { - query: { - type: 'string', - description: 'The search query to find relevant information on the web', - }, - num: { - type: 'number', - description: 'Number of search results to return (default: 10, max: 100)', - default: 10, - }, - type: { - type: 'string', - enum: ['search', 'news', 'places', 'images'], - description: 'Type of search to perform (default: search)', - default: 'search', - }, - gl: { - type: 'string', - description: 'Country code for localized results (e.g., "us", "uk", "ca")', - }, - hl: { - type: 'string', - description: 'Language code for results (e.g., "en", "es", "fr")', + exampleIds: { + type: 'array', + items: { + type: 'string', + }, + description: 'Array of example IDs to retrieve', }, }, - required: ['query'], + required: ['exampleIds'], }, }, { id: 'get_environment_variables', name: 'Get Environment Variables', - description: - 'Get a list of available environment variable names that the user has configured. This helps understand what API keys and secrets are available for use in workflows. Returns only the variable names, not their values.', + description: 'Get list of environment variable names (not values) that are set for the user.', params: {}, parameters: { type: 'object', - properties: {}, + properties: { + userId: { + type: 'string', + description: 'User ID (optional)', + }, + workflowId: { + type: 'string', + description: 'Workflow ID (optional)', + }, + }, required: [], }, }, @@ -398,21 +351,15 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { id: 'set_environment_variables', name: 'Set Environment Variables', description: - 'Set or update environment variables that can be used in workflows. New variables will be added, and existing variables with the same names will be updated. Other existing variables will be preserved. Use this to configure API keys, secrets, and other configuration values.', - params: { - variables: { - type: 'object', - description: - 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', - }, - }, + 'Set environment variables for the user. This tool should only be used when the user explicitly asks to set environment variables.', + params: {}, parameters: { type: 'object', properties: { variables: { type: 'object', - description: - 'A key-value object containing the environment variables to set. Example: {"API_KEY": "your-key", "DATABASE_URL": "your-url"}', + description: 'Object containing variable names as keys and their values', + additionalProperties: true, }, }, required: ['variables'], @@ -420,22 +367,21 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, { id: 'get_workflow_console', - name: 'Get Workflow Console Logs', + name: 'Get Workflow Console', description: - 'Get console logs and execution history from the current workflow. This shows real-time execution logs including block inputs, outputs, execution times, and any errors or warnings from recent workflow runs.', + 'Get console logs and execution history for a workflow to help debug issues.', params: {}, parameters: { type: 'object', properties: { limit: { type: 'number', - description: 'Maximum number of console entries to return (default: 50, max: 100)', + description: 'Maximum number of log entries to return (default: 50)', default: 50, }, includeDetails: { type: 'boolean', - description: - 'Whether to include detailed input/output data for each console entry (default: false)', + description: 'Include detailed block execution information', default: false, }, }, @@ -443,7 +389,43 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, }, { - id: 'targeted_updates', + id: 'search_online', + name: 'Search Online', + description: 'Search online for information when documentation doesn\'t have the answer.', + params: {}, + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query', + }, + num: { + type: 'number', + description: 'Number of results to return (default: 10)', + default: 10, + }, + type: { + type: 'string', + description: 'Type of search (default: "search")', + default: 'search', + }, + gl: { + type: 'string', + description: 'Country code (default: "us")', + default: 'us', + }, + hl: { + type: 'string', + description: 'Language code (default: "en")', + default: 'en', + }, + }, + required: ['query'], + }, + }, + { + id: editWorkflowId, name: 'Targeted Updates', description: 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Allows precise modifications to specific blocks without affecting the entire workflow.', @@ -469,8 +451,8 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { }, params: { type: 'object', - description: - 'Parameters for the operation. For add: {type: "block_type", name: "Block Name", inputs: {...}, connections: {...}}, for edit: {inputs: {...}, connections: {...}}, for delete: empty', + description: 'Parameters for the operation (required for add and edit operations)', + additionalProperties: true, }, }, required: ['operation_type', 'block_id'], @@ -483,7 +465,7 @@ function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { ] // Filter tools based on mode - return mode === 'ask' ? allTools.filter((tool) => tool.id !== 'preview_workflow') : allTools + return mode === 'ask' ? allTools.filter((tool) => tool.id !== buildWorkflowId) : allTools } /** diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 64ad714182d..54c8bb15b64 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -5,6 +5,7 @@ import { executeTool } from '@/tools' import { getProviderDefaultModel, getProviderModels } from '../models' import type { ProviderConfig, ProviderRequest, ProviderResponse, TimeSegment } from '../types' import { prepareToolsWithUsageControl, trackForcedToolUsage } from '../utils' +import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants' const logger = createLogger('AnthropicProvider') @@ -428,10 +429,16 @@ ${fieldDescriptions} logger.info(`Tool ${toolCall.name} ${result.success ? 'succeeded' : 'failed'}`) - // Send tool result event to frontend for preview_workflow and targeted_updates tools - if ( - (toolCall.name === 'preview_workflow' || - toolCall.name === 'targeted_updates') && + // Send tool result event to frontend for workflow tools + const toolDisplayName = COPILOT_TOOL_DISPLAY_NAMES[toolCall.name] + const isWorkflowTool = toolDisplayName && + (toolDisplayName.includes('Building') || + toolDisplayName.includes('Updating') || + toolDisplayName.includes('Preview') || + toolDisplayName.includes('Edit')) + + if ( + isWorkflowTool && result.success ) { const toolResultEvent = { @@ -526,12 +533,16 @@ ${fieldDescriptions} continuationToolCalls = [] } - // Also check for any preview_workflow or targeted_updates results in continuation + // Also check for any workflow tool results in continuation continuationToolCalls.forEach((toolCall) => { - if ( - toolCall.name === 'preview_workflow' || - toolCall.name === 'targeted_updates' - ) { + const toolDisplayName = COPILOT_TOOL_DISPLAY_NAMES[toolCall.name] + const isWorkflowTool = toolDisplayName && + (toolDisplayName.includes('Building') || + toolDisplayName.includes('Updating') || + toolDisplayName.includes('Preview') || + toolDisplayName.includes('Edit')) + + if (isWorkflowTool) { logger.info( `Found ${toolCall.name} in continuation, will send result after execution` ) diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index c781c1f4b53..0925e3e6ee9 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -10,3 +10,21 @@ export const API_ENDPOINTS = { } // Removed SYNC_INTERVALS - Socket.IO handles real-time sync + +// Copilot tool display names - shared between client and server +export const COPILOT_TOOL_DISPLAY_NAMES: Record = { + 'search_documentation': 'Searching documentation', + 'get_user_workflow': 'Analyzing your workflow', + 'build_workflow': 'Building your workflow', + 'get_blocks_and_tools': 'Getting block information', + 'get_blocks_metadata': 'Getting block metadata', + 'get_yaml_structure': 'Analyzing workflow structure', + 'get_workflow_examples': 'Getting workflow examples', + 'get_environment_variables': 'Getting environment variables', + 'set_environment_variables': 'Setting environment variables', + 'get_workflow_console': 'Getting workflow console', + 'edit_workflow': 'Updating workflow', + 'search_online': 'Searching online', +} as const + +export type CopilotToolId = keyof typeof COPILOT_TOOL_DISPLAY_NAMES diff --git a/apps/sim/stores/copilot/constants.ts b/apps/sim/stores/copilot/constants.ts new file mode 100644 index 00000000000..2d4e0689224 --- /dev/null +++ b/apps/sim/stores/copilot/constants.ts @@ -0,0 +1,17 @@ +// Client-side constants for copilot tools +// These are safe to import in client code since they don't pull in server dependencies + +export const COPILOT_TOOL_IDS = { + SEARCH_DOCUMENTATION: 'search_documentation', + GET_USER_WORKFLOW: 'get_user_workflow', + BUILD_WORKFLOW: 'build_workflow', + GET_BLOCKS_AND_TOOLS: 'get_blocks_and_tools', + GET_BLOCKS_METADATA: 'get_blocks_metadata', + GET_YAML_STRUCTURE: 'get_yaml_structure', + GET_WORKFLOW_EXAMPLES: 'get_workflow_examples', + GET_ENVIRONMENT_VARIABLES: 'get_environment_variables', + SET_ENVIRONMENT_VARIABLES: 'set_environment_variables', + GET_WORKFLOW_CONSOLE: 'get_workflow_console', + EDIT_WORKFLOW: 'edit_workflow', + SEARCH_ONLINE: 'search_online', +} as const \ No newline at end of file diff --git a/apps/sim/stores/copilot/preview-store.ts b/apps/sim/stores/copilot/preview-store.ts index 2ed7c5e2d81..be026f9cb61 100644 --- a/apps/sim/stores/copilot/preview-store.ts +++ b/apps/sim/stores/copilot/preview-store.ts @@ -1,6 +1,9 @@ +'use client' + import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { CopilotMessage, CopilotToolCall } from './types' +import { COPILOT_TOOL_IDS } from './constants' export interface PreviewData { id: string @@ -203,7 +206,7 @@ export const usePreviewStore = create()( if (message.role === 'assistant' && message.toolCalls) { message.toolCalls.forEach((toolCall: CopilotToolCall) => { if ( - toolCall.name === 'preview_workflow' && + toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW && toolCall.state === 'completed' && toolCall.id ) { diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 840d333a8d3..d2aa7a3972a 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1,3 +1,5 @@ +'use client' + import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { @@ -15,6 +17,8 @@ import { } from '@/lib/copilot/api' import { createLogger } from '@/lib/logs/console-logger' import type { CopilotStore } from './types' +import { COPILOT_TOOL_IDS } from './constants' +import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants' const logger = createLogger('CopilotStore') @@ -90,34 +94,8 @@ function handleStoreError(error: unknown, fallbackMessage: string): string { * Helper function to get a display name for a tool */ function getToolDisplayName(toolName: string): string { - switch (toolName) { - case 'docs_search_internal': - return 'Searching documentation' - case 'get_user_workflow': - return 'Analyzing your workflow' - case 'preview_workflow': - return 'Preview workflow changes' - case 'get_blocks_and_tools': - return 'Getting block information' - case 'get_blocks_metadata': - return 'Getting block metadata' - case 'get_yaml_structure': - return 'Analyzing workflow structure' - case 'edit_workflow': - return 'Editing your workflow' - case 'serper_search': - return 'Searching online' - case 'get_workflow_examples': - return 'Reviewing the design' - case 'get_environment_variables': - return 'Checking your environment variables' - case 'set_environment_variables': - return 'Setting your environment variables' - case 'targeted_updates': - return 'Editing workflow' - default: - return toolName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) - } + // Use dynamically generated display names from the tool registry + return COPILOT_TOOL_DISPLAY_NAMES[toolName] || toolName } /** @@ -219,7 +197,7 @@ const sseHandlers: Record = { toolCall.duration = toolCall.endTime - (toolCall.startTime || Date.now()) // Set appropriate state based on tool type - if (toolCall.name === 'preview_workflow' || toolCall.name === 'targeted_updates') { + if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { toolCall.state = 'ready_for_review' } else { toolCall.state = 'completed' @@ -239,8 +217,8 @@ const sseHandlers: Record = { duration: toolCall.duration }) - // Handle successful preview_workflow tool result - if (toolCall.name === 'preview_workflow') { + // Handle successful build_workflow tool result + if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { // Check both direct yamlContent and nested data.yamlContent const yamlContent = parsedResult?.yamlContent || parsedResult?.data?.yamlContent if (yamlContent) { @@ -249,9 +227,9 @@ const sseHandlers: Record = { yamlPreview: yamlContent.substring(0, 100), }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, 'preview_workflow') + get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.BUILD_WORKFLOW) } else { - logger.warn('No yamlContent found in preview_workflow result', { + logger.warn('No yamlContent found in build_workflow result', { hasDirectYaml: !!parsedResult?.yamlContent, hasNestedYaml: !!parsedResult?.data?.yamlContent, resultStructure: Object.keys(parsedResult || {}) @@ -259,19 +237,19 @@ const sseHandlers: Record = { } } - // Handle successful targeted_updates tool result - if (toolCall.name === 'targeted_updates') { + // Handle successful edit_workflow tool result + if (toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { // Check both direct yamlContent and nested data.yamlContent const yamlContent = parsedResult?.yamlContent || parsedResult?.data?.yamlContent if (yamlContent) { - logger.info('Setting preview YAML from targeted_updates tool_result event', { + logger.info('Setting preview YAML from edit_workflow tool_result event', { yamlLength: yamlContent.length, yamlPreview: yamlContent.substring(0, 200), }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, 'targeted_updates') + get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.EDIT_WORKFLOW) } else { - logger.warn('No yamlContent found in targeted_updates result', { + logger.warn('No yamlContent found in edit_workflow result', { hasDirectYaml: !!parsedResult?.yamlContent, hasNestedYaml: !!parsedResult?.data?.yamlContent, resultStructure: Object.keys(parsedResult || {}) @@ -284,9 +262,9 @@ const sseHandlers: Record = { toolCall.error = result || 'Tool execution failed' logger.error('Tool call failed:', toolCallId, toolCall.name, result) - // If preview_workflow failed, send error back for retry - if (toolCall.name === 'preview_workflow') { - logger.info('Preview workflow tool execution failed, sending error back to agent for retry') + // If build_workflow failed, send error back for retry + if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { + logger.info('Build workflow tool execution failed, sending error back to agent for retry') setTimeout(() => { get().sendImplicitFeedback( `The previous workflow YAML generation failed with error: "${toolCall.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` @@ -444,8 +422,8 @@ const sseHandlers: Record = { // Parse complete tool call input context.toolCallBuffer.input = JSON.parse(context.toolCallBuffer.partialInput || '{}') context.toolCallBuffer.state = - context.toolCallBuffer.name === 'preview_workflow' || - context.toolCallBuffer.name === 'targeted_updates' + context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + context.toolCallBuffer.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ? 'ready_for_review' : 'completed' context.toolCallBuffer.endTime = Date.now() @@ -456,33 +434,33 @@ const sseHandlers: Record = { updateContentBlockToolCall(context.contentBlocks, context.toolCallBuffer.id, context.toolCallBuffer) updateStreamingMessage(set, context) - // Handle preview_workflow completion - if (context.toolCallBuffer.name === 'preview_workflow') { + // Handle build_workflow completion + if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { // Check both direct yamlContent and nested data.yamlContent const yamlContent = context.toolCallBuffer.input?.yamlContent || context.toolCallBuffer.input?.data?.yamlContent if (yamlContent) { - logger.info('Setting preview YAML from completed preview_workflow tool call', { + logger.info('Setting preview YAML from completed build_workflow tool call', { yamlLength: yamlContent.length, yamlPreview: yamlContent.substring(0, 100) }) get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, 'preview_workflow') + get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.BUILD_WORKFLOW) } } - // Handle targeted_updates completion - if (context.toolCallBuffer.name === 'targeted_updates') { + // Handle edit_workflow completion + if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { // Check both direct yamlContent and nested data.yamlContent const yamlContent = context.toolCallBuffer.input?.yamlContent || context.toolCallBuffer.input?.data?.yamlContent if (yamlContent) { - logger.info('Setting preview YAML from completed targeted_updates tool call', { + logger.info('Setting preview YAML from completed edit_workflow tool call', { yamlLength: yamlContent.length, yamlPreview: yamlContent.substring(0, 100) }) - get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, 'targeted_updates') + get().setPreviewYaml(yamlContent) + get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.EDIT_WORKFLOW) } } } catch (error) { @@ -492,8 +470,8 @@ const sseHandlers: Record = { context.toolCallBuffer.duration = context.toolCallBuffer.endTime - context.toolCallBuffer.startTime context.toolCallBuffer.error = error instanceof Error ? error.message : String(error) - // Retry on preview_workflow failure - if (context.toolCallBuffer.name === 'preview_workflow') { + // Retry on build_workflow failure + if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { setTimeout(() => { get().sendImplicitFeedback( `The previous workflow YAML generation failed with error: "${context.toolCallBuffer.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` @@ -1107,7 +1085,7 @@ export const useCopilotStore = create()( (msg) => msg.role === 'assistant' && msg.toolCalls?.some( - (tc) => tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ) ) @@ -1118,14 +1096,14 @@ export const useCopilotStore = create()( ? { ...msg, toolCalls: msg.toolCalls?.map((tc) => - tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ? { ...tc, state: toolCallState } : tc ), contentBlocks: msg.contentBlocks?.map((block) => block.type === 'tool_call' && - (block.toolCall.name === 'preview_workflow' || - block.toolCall.name === 'targeted_updates') + (block.toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + block.toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } : block ), @@ -1161,7 +1139,7 @@ export const useCopilotStore = create()( (msg) => msg.role === 'assistant' && msg.toolCalls?.some( - (tc) => tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ) ) @@ -1172,14 +1150,14 @@ export const useCopilotStore = create()( ? { ...msg, toolCalls: msg.toolCalls?.map((tc) => - tc.name === 'preview_workflow' || tc.name === 'targeted_updates' + tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW ? { ...tc, state: toolCallState } : tc ), contentBlocks: msg.contentBlocks?.map((block) => block.type === 'tool_call' && - (block.toolCall.name === 'preview_workflow' || - block.toolCall.name === 'targeted_updates') + (block.toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + block.toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } : block ), @@ -1366,7 +1344,7 @@ export const useCopilotStore = create()( // Get handler for this event type const handler = sseHandlers[data.type] || sseHandlers.default await handler(data, context, get, set) - + // Check if handler set stream completion flag if (context.streamComplete) { break @@ -1377,11 +1355,11 @@ export const useCopilotStore = create()( logger.info(`Completed streaming response, content length: ${context.accumulatedContent.length}`) // Final update - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId - ? { - ...msg, + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId + ? { + ...msg, content: context.accumulatedContent, toolCalls: context.toolCalls, contentBlocks: context.contentBlocks, @@ -1692,13 +1670,13 @@ export const useCopilotStore = create()( const { messages } = get() const currentMessage = messages[messages.length - 1] const messageHasExistingEdits = currentMessage?.toolCalls?.some( - tc => (tc.name === 'preview_workflow' || tc.name === 'targeted_updates') && + tc => (tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) && tc.state !== 'executing' ) || false const shouldClearDiff = - toolName === 'preview_workflow' || // preview_workflow always clears - (toolName === 'targeted_updates' && !messageHasExistingEdits) // first targeted_updates in message clears + toolName === COPILOT_TOOL_IDS.BUILD_WORKFLOW || // build_workflow always clears + (toolName === COPILOT_TOOL_IDS.EDIT_WORKFLOW && !messageHasExistingEdits) // first edit_workflow in message clears logger.info('Diff merge strategy:', { toolName, @@ -1750,7 +1728,7 @@ export const useCopilotStore = create()( const diffStore = useWorkflowDiffStore.getState() if (shouldClearDiff || !diffStoreBefore.diffWorkflow) { // Use setProposedChanges which will create a new diff - await diffStore.setProposedChanges(yamlContent, diffAnalysis) + await diffStore.setProposedChanges(yamlContent, diffAnalysis) } else { // Use mergeProposedChanges which will merge into existing diff await diffStore.mergeProposedChanges(yamlContent, diffAnalysis) From 5e304df4d824f8bd7e6d8670831d198a1c60c0a6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 13:41:31 -0700 Subject: [PATCH 092/184] Checkpoint --- apps/sim/lib/copilot/api.ts | 415 +-------------- apps/sim/lib/copilot/service.ts | 866 +------------------------------ apps/sim/stores/copilot/store.ts | 850 ++++++------------------------ 3 files changed, 183 insertions(+), 1948 deletions(-) diff --git a/apps/sim/lib/copilot/api.ts b/apps/sim/lib/copilot/api.ts index eecec91fa6e..54ff0a7683e 100644 --- a/apps/sim/lib/copilot/api.ts +++ b/apps/sim/lib/copilot/api.ts @@ -12,19 +12,6 @@ export interface Citation { similarity?: number } -/** - * Checkpoint interface for copilot workflow checkpoints - */ -export interface CopilotCheckpoint { - id: string - userId: string - workflowId: string - chatId: string - yaml: string - createdAt: Date - updatedAt: Date -} - /** * Message interface for copilot conversations */ @@ -37,7 +24,7 @@ export interface CopilotMessage { } /** - * Chat interface for copilot conversations (API layer) + * Chat interface for copilot conversations */ export interface CopilotChat { id: string @@ -65,74 +52,11 @@ export interface SendMessageRequest { } /** - * Request interface for docs queries - */ -export interface DocsQueryRequest { - query: string - topK?: number - provider?: string - model?: string - stream?: boolean - chatId?: string - workflowId?: string - createNewChat?: boolean - abortSignal?: AbortSignal -} - -/** - * Options for creating a new chat - */ -export interface CreateChatOptions { - title?: string - initialMessage?: string -} - -/** - * Options for listing chats - */ -export interface ListChatsOptions { - limit?: number - offset?: number -} - -/** - * Options for listing checkpoints - */ -export interface ListCheckpointsOptions { - limit?: number - offset?: number -} - -/** - * API response interface + * Base API response interface */ -export interface ApiResponse { +export interface ApiResponse { success: boolean error?: string - data?: T -} - -/** - * Chat response interface - */ -export interface ChatResponse extends ApiResponse { - chat?: CopilotChat -} - -/** - * Chats list response interface - */ -export interface ChatsListResponse extends ApiResponse { - chats: CopilotChat[] -} - -/** - * Message response interface - */ -export interface MessageResponse extends ApiResponse { - response?: string - chatId?: string - citations?: Citation[] } /** @@ -140,216 +64,23 @@ export interface MessageResponse extends ApiResponse { */ export interface StreamingResponse extends ApiResponse { stream?: ReadableStream - chatId?: string -} - -/** - * Docs response interface - */ -export interface DocsResponse extends ApiResponse { - response?: string - chatId?: string - sources?: Array<{ - title: string - document: string - link: string - similarity: number - }> -} - -/** - * Checkpoints response interface - */ -export interface CheckpointsResponse extends ApiResponse { - checkpoints: CopilotCheckpoint[] } /** - * Helper function to handle API errors + * Handle API errors and return user-friendly error messages */ async function handleApiError(response: Response, defaultMessage: string): Promise { try { - const errorData = await response.json() - return errorData.error || defaultMessage - } catch { - return response.statusText || defaultMessage - } -} - -/** - * Helper function to make API requests with consistent error handling - */ -async function makeApiRequest( - url: string, - options: RequestInit, - defaultErrorMessage: string -): Promise> { - try { - const response = await fetch(url, options) const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || defaultErrorMessage) - } - - return { - success: true, - data, - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - - // Handle AbortError gracefully - this is expected when user aborts - if (error instanceof Error && error.name === 'AbortError') { - logger.info(`API request was aborted: ${defaultErrorMessage}`) - return { - success: false, - error: 'Request was aborted', - } - } - - logger.error(`API request failed: ${defaultErrorMessage}`, error) - return { - success: false, - error: errorMessage, - } - } -} - -/** - * Create a new copilot chat - */ -export async function createChat( - workflowId: string, - options: CreateChatOptions = {} -): Promise { - const result = await makeApiRequest( - '/api/copilot', - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - ...options, - }), - }, - 'Failed to create chat' - ) - - return { - success: result.success, - chat: result.data?.chat, - error: result.error, - } -} - -/** - * List chats for a specific workflow - */ -export async function listChats( - workflowId: string, - options: ListChatsOptions = {} -): Promise { - const params = new URLSearchParams({ - workflowId, - limit: (options.limit || 50).toString(), - offset: (options.offset || 0).toString(), - }) - - const result = await makeApiRequest(`/api/copilot?${params}`, {}, 'Failed to list chats') - - return { - success: result.success, - chats: result.data?.chats || [], - error: result.error, - } -} - -/** - * Get a specific chat with full message history - */ -export async function getChat(chatId: string): Promise { - const result = await makeApiRequest( - `/api/copilot?chatId=${chatId}`, - {}, - 'Failed to get chat' - ) - - return { - success: result.success, - chat: result.data?.chat, - error: result.error, - } -} - -/** - * Update a chat with new messages - */ -export async function updateChatMessages( - chatId: string, - messages: CopilotMessage[] -): Promise { - const result = await makeApiRequest( - '/api/copilot', - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chatId, - messages, - }), - }, - 'Failed to update chat' - ) - - return { - success: result.success, - chat: result.data?.chat, - error: result.error, - } -} - -/** - * Delete a chat - */ -export async function deleteChat(chatId: string): Promise { - const result = await makeApiRequest( - `/api/copilot?chatId=${chatId}`, - { method: 'DELETE' }, - 'Failed to delete chat' - ) - - return { - success: result.success, - error: result.error, - } -} - -/** - * Send a message using the unified copilot API - */ -export async function sendMessage(request: SendMessageRequest): Promise { - const result = await makeApiRequest( - '/api/copilot', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), - }, - 'Failed to send message' - ) - - return { - success: result.success, - response: result.data?.response, - chatId: result.data?.chatId, - citations: result.data?.citations, - error: result.error, + return data.error || defaultMessage + } catch { + return `${defaultMessage} (${response.status})` } } /** - * Send a streaming message using the unified copilot API + * Send a streaming message to the copilot chat API + * This is the main API endpoint that handles all chat operations */ export async function sendStreamingMessage( request: SendMessageRequest @@ -394,131 +125,3 @@ export async function sendStreamingMessage( } } } - -/** - * Send a documentation query using the main copilot API - */ -export async function sendDocsMessage(request: DocsQueryRequest): Promise { - const message = `Please search the documentation and answer this question: ${request.query}` - - const result = await makeApiRequest( - '/api/copilot', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message, - chatId: request.chatId, - workflowId: request.workflowId, - createNewChat: request.createNewChat, - stream: request.stream, - }), - }, - 'Failed to send docs message' - ) - - return { - success: result.success, - response: result.data?.response, - chatId: result.data?.chatId, - sources: [], // Main agent embeds citations directly in response - error: result.error, - } -} - -/** - * Send a streaming documentation query using the main copilot API - */ -export async function sendStreamingDocsMessage( - request: DocsQueryRequest -): Promise { - try { - const { abortSignal, ...requestData } = request - const message = `Please search the documentation and answer this question: ${requestData.query}` - - const response = await fetch('/api/copilot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message, - chatId: requestData.chatId, - workflowId: requestData.workflowId, - createNewChat: requestData.createNewChat, - stream: true, - }), - signal: abortSignal, - credentials: 'include', // Include cookies for session authentication - }) - - if (!response.ok) { - const errorMessage = await handleApiError(response, 'Failed to send streaming docs message') - throw new Error(errorMessage) - } - - if (!response.body) { - throw new Error('No response body received') - } - - return { - success: true, - stream: response.body, - } - } catch (error) { - // Handle AbortError gracefully - this is expected when user aborts - if (error instanceof Error && error.name === 'AbortError') { - logger.info('Streaming docs message was aborted by user') - return { - success: false, - error: 'Request was aborted', - } - } - - logger.error('Failed to send streaming docs message:', error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } - } -} - -/** - * List checkpoints for a specific chat - */ -export async function listCheckpoints( - chatId: string, - options: ListCheckpointsOptions = {} -): Promise { - const params = new URLSearchParams({ - chatId, - limit: (options.limit || 10).toString(), - offset: (options.offset || 0).toString(), - }) - - const result = await makeApiRequest( - `/api/copilot/checkpoints?${params}`, - {}, - 'Failed to list checkpoints' - ) - - return { - success: result.success, - checkpoints: result.data?.checkpoints || [], - error: result.error, - } -} - -/** - * Revert workflow to a specific checkpoint - */ -export async function revertToCheckpoint(checkpointId: string): Promise { - const result = await makeApiRequest( - `/api/copilot/checkpoints/${checkpointId}/revert`, - { method: 'POST' }, - 'Failed to revert to checkpoint' - ) - - return { - success: result.success, - error: result.error, - } -} diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts index a680d3db82e..60709c5dbb3 100644 --- a/apps/sim/lib/copilot/service.ts +++ b/apps/sim/lib/copilot/service.ts @@ -1,96 +1,10 @@ -import { and, desc, eq, sql } from 'drizzle-orm' +import { sql } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' -import { getRotatingApiKey } from '@/lib/utils' -// Dynamic import to avoid client-side bundling of file-parsers import { db } from '@/db' -import { copilotChats, docsEmbeddings } from '@/db/schema' -import { executeProviderRequest } from '@/providers' -import type { ProviderToolConfig } from '@/providers/types' -import { getApiKey } from '@/providers/utils' -import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants' -import { getCopilotConfig, getCopilotModel } from './config' -import { WORKFLOW_EXAMPLES } from './examples' -import { - AGENT_MODE_SYSTEM_PROMPT, - ASK_MODE_SYSTEM_PROMPT, - TITLE_GENERATION_SYSTEM_PROMPT, - TITLE_GENERATION_USER_PROMPT, -} from './prompts' +import { docsEmbeddings } from '@/db/schema' const logger = createLogger('CopilotService') -/** - * Citation information for documentation references - */ -export interface Citation { - id: number - title: string - url: string - similarity?: number -} - -/** - * Message interface for copilot conversations - */ -export interface CopilotMessage { - id: string - role: 'user' | 'assistant' | 'system' - content: string - timestamp: string - citations?: Citation[] -} - -/** - * Chat interface for copilot conversations - */ -export interface CopilotChat { - id: string - title: string | null - model: string - messages: CopilotMessage[] - messageCount: number - previewYaml: string | null - createdAt: Date - updatedAt: Date -} - -/** - * Options for generating chat responses - */ -export interface GenerateChatResponseOptions { - stream?: boolean - workflowId?: string - requestId?: string - mode?: 'ask' | 'agent' - chatId?: string - implicitFeedback?: string - userId?: string -} - -/** - * Request interface for sending messages - */ -export interface SendMessageRequest { - message: string - chatId?: string - workflowId?: string - mode?: 'ask' | 'agent' - createNewChat?: boolean - stream?: boolean - implicitFeedback?: string - userId: string -} - -/** - * Response interface for sending messages - */ -export interface SendMessageResponse { - content: string - chatId?: string - citations?: Citation[] - metadata?: Record -} - /** * Documentation search result */ @@ -102,31 +16,6 @@ export interface DocumentationSearchResult { similarity: number } -/** - * Options for creating a new chat - */ -export interface CreateChatOptions { - title?: string - initialMessage?: string -} - -/** - * Options for updating a chat - */ -export interface UpdateChatOptions { - title?: string - messages?: CopilotMessage[] - previewYaml?: string | null -} - -/** - * Options for listing chats - */ -export interface ListChatsOptions { - limit?: number - offset?: number -} - /** * Options for documentation search */ @@ -135,377 +24,6 @@ export interface SearchDocumentationOptions { threshold?: number } -/** - * Get API key for the given provider - */ -function getProviderApiKey(provider: string, model: string): string { - if (provider === 'openai' || provider === 'anthropic') { - return getRotatingApiKey(provider) - } - return getApiKey(provider, model) -} - -/** - * Build conversation messages for LLM - */ -function buildConversationMessages( - message: string, - conversationHistory: CopilotMessage[], - maxHistory: number, - implicitFeedback?: string -): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> { - const messages = [] - - // Add conversation history (limited by config) - const recentHistory = conversationHistory.slice(-maxHistory) - - for (const msg of recentHistory) { - messages.push({ - role: msg.role as 'user' | 'assistant' | 'system', - content: msg.content, - }) - } - - // Add implicit system feedback if provided (for preview accept/reject) - if (implicitFeedback) { - messages.push({ - role: 'system' as const, - content: implicitFeedback, - }) - } - - // Add current user message - messages.push({ - role: 'user' as const, - content: message, - }) - - return messages -} - -/** - * Get available tools for the given mode - */ -function getAvailableTools(mode: 'ask' | 'agent'): ProviderToolConfig[] { - // Use tool IDs from shared constants - const buildWorkflowId = Object.keys(COPILOT_TOOL_DISPLAY_NAMES).find(id => - COPILOT_TOOL_DISPLAY_NAMES[id].includes('Building') || - COPILOT_TOOL_DISPLAY_NAMES[id].includes('Preview') - ) || 'build_workflow' - - const editWorkflowId = Object.keys(COPILOT_TOOL_DISPLAY_NAMES).find(id => - COPILOT_TOOL_DISPLAY_NAMES[id].includes('Updating') || - COPILOT_TOOL_DISPLAY_NAMES[id].includes('Edit') - ) || 'edit_workflow' - - const searchDocsId = Object.keys(COPILOT_TOOL_DISPLAY_NAMES).find(id => - COPILOT_TOOL_DISPLAY_NAMES[id].includes('documentation') - ) || 'search_documentation' - - const allTools: ProviderToolConfig[] = [ - { - id: searchDocsId, - name: 'Search Documentation', - description: - 'Search Sim Studio documentation for information about features, tools, workflows, and functionality', - params: {}, - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query for documentation', - }, - topK: { - type: 'number', - description: 'Number of top results to return (default: 10)', - default: 10, - }, - threshold: { - type: 'number', - description: 'Similarity threshold for results (default: 0.7)', - default: 0.7, - }, - }, - required: ['query'], - }, - }, - { - id: 'get_user_workflow', - name: 'Get User Workflow', - description: - 'Retrieve the current YAML configuration of the user\'s workflow. This should be your FIRST step when analyzing or modifying workflows.', - params: {}, - parameters: { - type: 'object', - properties: {}, - required: [], - }, - }, - { - id: 'get_blocks_and_tools', - name: 'Get Blocks and Tools', - description: - 'Get a comprehensive mapping of all available blocks and their associated tools. Essential for understanding what blocks are available for workflow creation.', - params: {}, - parameters: { - type: 'object', - properties: {}, - required: [], - }, - }, - { - id: 'get_blocks_metadata', - name: 'Get Block Metadata', - description: - 'Get detailed metadata including descriptions, schemas, inputs, outputs, and subblocks for specific blocks and their associated tools.', - params: {}, - parameters: { - type: 'object', - properties: { - blockIds: { - type: 'array', - items: { - type: 'string', - }, - description: 'Array of block IDs to get metadata for', - }, - }, - required: ['blockIds'], - }, - }, - { - id: 'get_yaml_structure', - name: 'Get YAML Structure', - description: - 'Get the YAML workflow structure guide and best practices for creating workflows.', - params: {}, - parameters: { - type: 'object', - properties: {}, - required: [], - }, - }, - { - id: buildWorkflowId, - name: 'Build Workflow', - description: - 'Generate a sandbox preview of the workflow without saving it. This allows users to see the proposed changes before applying them. This is the ONLY way to propose workflow changes. IMPORTANT: After calling this tool, you MUST stop your response immediately and wait for the user to either accept, reject, or provide additional feedback before continuing the conversation.', - params: {}, - parameters: { - type: 'object', - properties: { - yamlContent: { - type: 'string', - description: 'The complete YAML workflow content to preview', - }, - description: { - type: 'string', - description: 'Optional description of the proposed changes', - }, - }, - required: ['yamlContent'], - }, - }, - { - id: 'get_workflow_examples', - name: 'Get Workflow Examples', - description: - 'Get example workflows for reference. Useful for understanding patterns and structures.', - params: {}, - parameters: { - type: 'object', - properties: { - exampleIds: { - type: 'array', - items: { - type: 'string', - }, - description: 'Array of example IDs to retrieve', - }, - }, - required: ['exampleIds'], - }, - }, - { - id: 'get_environment_variables', - name: 'Get Environment Variables', - description: 'Get list of environment variable names (not values) that are set for the user.', - params: {}, - parameters: { - type: 'object', - properties: { - userId: { - type: 'string', - description: 'User ID (optional)', - }, - workflowId: { - type: 'string', - description: 'Workflow ID (optional)', - }, - }, - required: [], - }, - }, - { - id: 'set_environment_variables', - name: 'Set Environment Variables', - description: - 'Set environment variables for the user. This tool should only be used when the user explicitly asks to set environment variables.', - params: {}, - parameters: { - type: 'object', - properties: { - variables: { - type: 'object', - description: 'Object containing variable names as keys and their values', - additionalProperties: true, - }, - }, - required: ['variables'], - }, - }, - { - id: 'get_workflow_console', - name: 'Get Workflow Console', - description: - 'Get console logs and execution history for a workflow to help debug issues.', - params: {}, - parameters: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Maximum number of log entries to return (default: 50)', - default: 50, - }, - includeDetails: { - type: 'boolean', - description: 'Include detailed block execution information', - default: false, - }, - }, - required: [], - }, - }, - { - id: 'search_online', - name: 'Search Online', - description: 'Search online for information when documentation doesn\'t have the answer.', - params: {}, - parameters: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Search query', - }, - num: { - type: 'number', - description: 'Number of results to return (default: 10)', - default: 10, - }, - type: { - type: 'string', - description: 'Type of search (default: "search")', - default: 'search', - }, - gl: { - type: 'string', - description: 'Country code (default: "us")', - default: 'us', - }, - hl: { - type: 'string', - description: 'Language code (default: "en")', - default: 'en', - }, - }, - required: ['query'], - }, - }, - { - id: editWorkflowId, - name: 'Targeted Updates', - description: - 'Make targeted updates to the workflow with atomic add, edit, or delete operations. Allows precise modifications to specific blocks without affecting the entire workflow.', - params: {}, - parameters: { - type: 'object', - properties: { - operations: { - type: 'array', - description: 'Array of targeted update operations to perform', - items: { - type: 'object', - properties: { - operation_type: { - type: 'string', - enum: ['add', 'edit', 'delete'], - description: 'Type of operation to perform', - }, - block_id: { - type: 'string', - description: - 'Block ID for the operation. For add operations, this will be the desired ID for the new block.', - }, - params: { - type: 'object', - description: 'Parameters for the operation (required for add and edit operations)', - additionalProperties: true, - }, - }, - required: ['operation_type', 'block_id'], - }, - }, - }, - required: ['operations'], - }, - }, - ] - - // Filter tools based on mode - return mode === 'ask' ? allTools.filter((tool) => tool.id !== buildWorkflowId) : allTools -} - -/** - * Validate system prompt for the given mode - */ -function validateSystemPrompt(mode: 'ask' | 'agent', systemPrompt: string): void { - if (!systemPrompt || systemPrompt.length < 100) { - throw new Error(`System prompt not properly configured for mode: ${mode}`) - } -} - -/** - * Generate a chat title using LLM - */ -export async function generateChatTitle(userMessage: string): Promise { - try { - const { provider, model } = getCopilotModel('title') - const apiKey = getProviderApiKey(provider, model) - - const response = await executeProviderRequest(provider, { - model, - systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, - context: TITLE_GENERATION_USER_PROMPT(userMessage), - temperature: 0.3, - maxTokens: 50, - apiKey, - stream: false, - }) - - if (typeof response === 'object' && 'content' in response) { - return response.content?.trim() || 'New Chat' - } - - return 'New Chat' - } catch (error) { - logger.error('Failed to generate chat title:', error) - return 'New Chat' - } -} - /** * Search documentation using RAG */ @@ -513,8 +31,7 @@ export async function searchDocumentation( query: string, options: SearchDocumentationOptions = {} ): Promise { - const config = getCopilotConfig() - const { topK = config.rag.maxSources, threshold = config.rag.similarityThreshold } = options + const { topK = 10, threshold = 0.7 } = options try { // Generate embedding for the query @@ -557,380 +74,3 @@ export async function searchDocumentation( return [] } } - -/** - * Generate chat response using LLM with optional documentation search - */ -export async function generateChatResponse( - message: string, - conversationHistory: CopilotMessage[] = [], - options: GenerateChatResponseOptions = {} -): Promise { - const config = getCopilotConfig() - const { provider, model } = getCopilotModel('chat') - const { stream = config.general.streamingEnabled, mode = 'ask' } = options - - try { - const apiKey = getProviderApiKey(provider, model) - - // Build conversation context - const messages = buildConversationMessages( - message, - conversationHistory, - config.general.maxConversationHistory, - options.implicitFeedback - ) - - // Get available tools for the mode - const tools = getAvailableTools(mode) - - // Get the appropriate system prompt for the mode - const systemPrompt = mode === 'ask' ? ASK_MODE_SYSTEM_PROMPT : AGENT_MODE_SYSTEM_PROMPT - - // Validate system prompt - validateSystemPrompt(mode, systemPrompt) - - const response = await executeProviderRequest(provider, { - model, - systemPrompt, - messages, - tools, - temperature: config.chat.temperature, - maxTokens: config.chat.maxTokens, - apiKey, - stream, - streamToolCalls: true, // Enable tool call streaming for copilot - workflowId: options.workflowId, - chatId: options.chatId, - userId: options.userId || 'unknown_user', // Pass userId to provider request - isCopilotRequest: true, // Flag to indicate this is from the copilot system - }) - - // Handle StreamingExecution (from providers with tool calls) - if ( - typeof response === 'object' && - response && - 'stream' in response && - 'execution' in response - ) { - return (response as any).stream - } - - // Handle ProviderResponse (non-streaming with tool calls) - if (typeof response === 'object' && 'content' in response) { - const content = response.content || 'Sorry, I could not generate a response.' - - // If streaming was requested, wrap the content in a ReadableStream - if (stream) { - return new ReadableStream({ - start(controller) { - const encoder = new TextEncoder() - controller.enqueue(encoder.encode(content)) - controller.close() - }, - }) - } - - return content - } - - // Handle direct ReadableStream response - if (response instanceof ReadableStream) { - return response - } - - return 'Sorry, I could not generate a response.' - } catch (error) { - logger.error('Failed to generate chat response:', error) - throw new Error( - `Failed to generate response: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } -} - -/** - * Create a new copilot chat - */ -export async function createChat( - userId: string, - workflowId: string, - options: CreateChatOptions = {} -): Promise { - const { provider, model } = getCopilotModel('chat') - const { title, initialMessage } = options - - try { - // Prepare initial messages array - const initialMessages: CopilotMessage[] = initialMessage - ? [ - { - id: crypto.randomUUID(), - role: 'user', - content: initialMessage, - timestamp: new Date().toISOString(), - }, - ] - : [] - - // Create the chat - const [newChat] = await db - .insert(copilotChats) - .values({ - userId, - workflowId, - title: title || null, - model, - messages: initialMessages, - }) - .returning() - - if (!newChat) { - throw new Error('Failed to create chat') - } - - return { - id: newChat.id, - title: newChat.title, - model: newChat.model, - messages: Array.isArray(newChat.messages) ? newChat.messages : [], - messageCount: Array.isArray(newChat.messages) ? newChat.messages.length : 0, - previewYaml: newChat.previewYaml, - createdAt: newChat.createdAt, - updatedAt: newChat.updatedAt, - } - } catch (error) { - logger.error('Failed to create chat:', error) - throw new Error( - `Failed to create chat: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } -} - -/** - * Get a specific chat - */ -export async function getChat(chatId: string, userId: string): Promise { - try { - const [chat] = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .limit(1) - - if (!chat) { - return null - } - - return { - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - previewYaml: chat.previewYaml, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - } - } catch (error) { - logger.error('Failed to get chat:', error) - return null - } -} - -/** - * List chats for a workflow - */ -export async function listChats( - userId: string, - workflowId: string, - options: ListChatsOptions = {} -): Promise { - const { limit = 50, offset = 0 } = options - - try { - const chats = await db - .select() - .from(copilotChats) - .where(and(eq(copilotChats.userId, userId), eq(copilotChats.workflowId, workflowId))) - .orderBy(desc(copilotChats.createdAt)) - .limit(limit) - .offset(offset) - - return chats.map((chat) => ({ - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - previewYaml: chat.previewYaml, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - })) - } catch (error) { - logger.error('Failed to list chats:', error) - return [] - } -} - -/** - * Update a chat (add messages, update title, etc.) - */ -export async function updateChat( - chatId: string, - userId: string, - updates: UpdateChatOptions -): Promise { - try { - // Verify the chat exists and belongs to the user - const existingChat = await getChat(chatId, userId) - if (!existingChat) { - return null - } - - // Prepare update data - const updateData: any = { - updatedAt: new Date(), - } - - if (updates.title !== undefined) updateData.title = updates.title - if (updates.messages !== undefined) updateData.messages = updates.messages - if (updates.previewYaml !== undefined) updateData.previewYaml = updates.previewYaml - - // Update the chat - const [updatedChat] = await db - .update(copilotChats) - .set(updateData) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .returning() - - if (!updatedChat) { - return null - } - - return { - id: updatedChat.id, - title: updatedChat.title, - model: updatedChat.model, - messages: Array.isArray(updatedChat.messages) ? updatedChat.messages : [], - messageCount: Array.isArray(updatedChat.messages) ? updatedChat.messages.length : 0, - previewYaml: updatedChat.previewYaml, - createdAt: updatedChat.createdAt, - updatedAt: updatedChat.updatedAt, - } - } catch (error) { - logger.error('Failed to update chat:', error) - return null - } -} - -/** - * Delete a chat - */ -export async function deleteChat(chatId: string, userId: string): Promise { - try { - const result = await db - .delete(copilotChats) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) - .returning({ id: copilotChats.id }) - - return result.length > 0 - } catch (error) { - logger.error('Failed to delete chat:', error) - return false - } -} - -/** - * Send a message and get a response - */ -export async function sendMessage(request: SendMessageRequest): Promise<{ - response: string | ReadableStream | any - chatId?: string -}> { - const { message, chatId, workflowId, mode, createNewChat, stream, userId } = request - - try { - // Handle chat context - let currentChat: CopilotChat | null = null - let conversationHistory: CopilotMessage[] = [] - - if (chatId) { - // Load existing chat - currentChat = await getChat(chatId, userId) - if (currentChat) { - conversationHistory = currentChat.messages - } - } else if (createNewChat && workflowId) { - // Create new chat - currentChat = await createChat(userId, workflowId) - } - - // Generate chat response - const response = await generateChatResponse(message, conversationHistory, { - stream, - workflowId, - mode, - chatId: currentChat?.id, - implicitFeedback: request.implicitFeedback, - userId: userId, // Pass userId to generateChatResponse - }) - - // For non-streaming responses, save immediately - if (currentChat && typeof response === 'string') { - const userMessage: CopilotMessage = { - id: crypto.randomUUID(), - role: 'user', - content: message, - timestamp: new Date().toISOString(), - } - - const assistantMessage: CopilotMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: response, - timestamp: new Date().toISOString(), - } - - const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - - // Generate title if this is the first message - let updatedTitle = currentChat.title - if (!updatedTitle && conversationHistory.length === 0) { - updatedTitle = await generateChatTitle(message) - } - - await updateChat(currentChat.id, userId, { - title: updatedTitle || undefined, - messages: updatedMessages, - }) - } - - return { - response, - chatId: currentChat?.id, - } - } catch (error) { - logger.error('Failed to send message:', error) - throw error - } -} - -// Update existing chat messages (for streaming responses) -export async function updateChatMessages( - chatId: string, - messages: CopilotMessage[] -): Promise { - try { - await db - .update(copilotChats) - .set({ - messages, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, chatId)) - .execute() - } catch (error) { - logger.error('Failed to update chat messages:', error) - throw error - } -} diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index d2aa7a3972a..070b2a34206 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -5,15 +5,7 @@ import { devtools } from 'zustand/middleware' import { type CopilotChat, type CopilotMessage, - createChat, - deleteChat as deleteApiChat, - getChat, - listChats, - listCheckpoints, - revertToCheckpoint, - sendStreamingDocsMessage, sendStreamingMessage, - updateChatMessages, } from '@/lib/copilot/api' import { createLogger } from '@/lib/logs/console-logger' import type { CopilotStore } from './types' @@ -638,326 +630,85 @@ export const useCopilotStore = create()( logger.info(`Copilot mode changed from ${previousMode} to ${mode}`) }, - // Set current workflow ID + // Clear messages for current chat + clearMessages: () => { + set({ messages: [] }) + logger.info('Cleared messages') + }, + + // Set workflow ID and reset state setWorkflowId: async (workflowId: string | null) => { const currentWorkflowId = get().workflowId - if (currentWorkflowId !== workflowId) { - logger.info(`Workflow ID changed from ${currentWorkflowId} to ${workflowId}`) - - // Auto-reject any pending diff changes before switching workflows - try { - // Import diff store dynamically to avoid circular dependencies - const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') - const diffStore = useWorkflowDiffStore.getState() - - // Check if there are any pending diff changes - if (diffStore.diffWorkflow && diffStore.isDiffReady) { - logger.info('Auto-rejecting pending diff changes before workflow change') - - // Reject the changes in the diff store - diffStore.rejectChanges() - // Update copilot tool call state and clear preview YAML - get().updatePreviewToolCallState('rejected') - await get().clearPreviewYaml() + if (currentWorkflowId === workflowId) { + return + } - logger.info('Successfully auto-rejected pending diff changes') - } - } catch (error) { - logger.error('Failed to auto-reject pending changes during workflow change:', error) - // Don't prevent workflow change if cleanup fails - } + logger.info(`Setting workflow ID: ${workflowId}`) - // Clear all state to prevent cross-workflow data leaks - set({ - workflowId, - currentChat: null, - chats: [], - messages: [], - error: null, - saveError: null, - isSaving: false, - isLoading: false, - isLoadingChats: false, - }) - - // Load chats for the new workflow - if (workflowId) { - get() - .loadChats() - .catch((error) => { - logger.error('Failed to load chats after workflow change:', error) - }) - } - } + // Reset state when switching workflows + set({ + ...initialState, + workflowId, + mode: get().mode, // Preserve mode + }) }, - // Validate current chat belongs to current workflow + // Validate that current chat belongs to current workflow validateCurrentChat: () => { - const { currentChat, chats, workflowId } = get() + const { currentChat, workflowId } = get() if (!currentChat || !workflowId) { - return true - } - - // Check if current chat exists in the current workflow's chat list - const chatBelongsToWorkflow = chats.some((chat) => chat.id === currentChat.id) - - if (!chatBelongsToWorkflow) { - logger.warn(`Current chat ${currentChat.id} does not belong to workflow ${workflowId}`) - set({ - currentChat: null, - messages: [], - }) return false } + // For now, we can't validate without the API + // The backend will handle this validation return true }, - // Load chats for current workflow - loadChats: async () => { - const { workflowId } = get() - if (!workflowId) { - logger.warn('Cannot load chats: no workflow ID set') - return - } - - set({ isLoadingChats: true, error: null }) - - try { - const result = await listChats(workflowId) - - if (result.success) { - set({ - chats: result.chats, - isLoadingChats: false, - }) - logger.info(`Loaded ${result.chats.length} chats for workflow ${workflowId}`) - - // Auto-select the most recent chat if no current chat is selected and chats exist - const { currentChat } = get() - if (!currentChat && result.chats.length > 0) { - // Sort by updatedAt descending to get the most recent chat - const sortedChats = [...result.chats].sort( - (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() - ) - const mostRecentChat = sortedChats[0] - - logger.info(`Auto-selecting most recent chat: ${mostRecentChat.title || 'Untitled'}`) - await get().selectChat(mostRecentChat) - } - } else { - throw new Error(result.error || 'Failed to load chats') - } - } catch (error) { - set({ - error: handleStoreError(error, 'Failed to load chats'), - isLoadingChats: false, - }) - } - }, - - // Select a specific chat + // Simple chat management without API calls selectChat: async (chat: CopilotChat) => { - const { workflowId, currentChat } = get() - - if (!workflowId) { - logger.error('Cannot select chat: no workflow ID set') - return - } - - // Auto-reject any pending diff changes before switching chats - if (currentChat && currentChat.id !== chat.id) { - logger.info(`Chat change detected: ${currentChat.id} -> ${chat.id}`) - try { - // Import diff store dynamically to avoid circular dependencies - const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') - const diffStore = useWorkflowDiffStore.getState() - - logger.info('Diff store state:', { - hasDiffWorkflow: !!diffStore.diffWorkflow, - isDiffReady: diffStore.isDiffReady, - isShowingDiff: diffStore.isShowingDiff, - }) - - // Check if there are any pending diff changes - if (diffStore.diffWorkflow && diffStore.isDiffReady) { - logger.info('Auto-rejecting pending diff changes before chat change') - - // Reject the changes in the diff store - diffStore.rejectChanges() - - // Update copilot tool call state and clear preview YAML - get().updatePreviewToolCallState('rejected') - await get().clearPreviewYaml() - - logger.info('Successfully auto-rejected pending diff changes') - } else { - logger.info('No pending diff changes to reject') - } - } catch (error) { - logger.error('Failed to auto-reject pending changes during chat change:', error) - // Don't prevent chat change if cleanup fails - } - } else { - logger.info('No chat change detected or no current chat') - } - - set({ isLoading: true, error: null }) - - try { - const result = await getChat(chat.id) - - if (result.success && result.chat) { - // Verify workflow hasn't changed during selection - const currentWorkflow = get().workflowId - if (currentWorkflow !== workflowId) { - logger.warn('Workflow changed during chat selection') - set({ isLoading: false }) - return - } - - set({ - currentChat: result.chat, - messages: result.chat.messages, - isLoading: false, - }) - - logger.info(`Selected chat: ${result.chat.title || 'Untitled'}`) - } else { - throw new Error(result.error || 'Failed to load chat') - } - } catch (error) { - set({ - error: handleStoreError(error, 'Failed to load chat'), - isLoading: false, - }) - } + set({ + currentChat: chat, + messages: chat.messages || [], + }) + logger.info(`Selected chat: ${chat.title || 'Untitled'}`) }, - // Create a new chat - createNewChat: async (options = {}) => { - const { workflowId, currentChat } = get() - if (!workflowId) { - logger.warn('Cannot create chat: no workflow ID set') - return - } - - // Auto-reject any pending diff changes before creating new chat - if (currentChat) { - logger.info(`Creating new chat while current chat exists: ${currentChat.id}`) - try { - // Import diff store dynamically to avoid circular dependencies - const { useWorkflowDiffStore } = await import('@/stores/workflow-diff') - const diffStore = useWorkflowDiffStore.getState() - - logger.info('Diff store state:', { - hasDiffWorkflow: !!diffStore.diffWorkflow, - isDiffReady: diffStore.isDiffReady, - isShowingDiff: diffStore.isShowingDiff, - }) - - // Check if there are any pending diff changes - if (diffStore.diffWorkflow && diffStore.isDiffReady) { - logger.info('Auto-rejecting pending diff changes before creating new chat') - - // Reject the changes in the diff store - diffStore.rejectChanges() - - // Update copilot tool call state and clear preview YAML - get().updatePreviewToolCallState('rejected') - await get().clearPreviewYaml() - - logger.info('Successfully auto-rejected pending diff changes') - } else { - logger.info('No pending diff changes to reject') - } - } catch (error) { - logger.error('Failed to auto-reject pending changes during new chat creation:', error) - // Don't prevent new chat creation if cleanup fails - } - } else { - logger.info('Creating new chat with no current chat') + // Create a new chat locally (will be persisted when sending first message) + createNewChat: async () => { + const newChat: CopilotChat = { + id: `temp-${Date.now()}`, // Temporary ID until backend creates real one + title: null, + model: 'gpt-4', + messages: [], + messageCount: 0, + previewYaml: null, + createdAt: new Date(), + updatedAt: new Date(), } - set({ isLoading: true, error: null }) - - try { - const result = await createChat(workflowId, options) - - if (result.success && result.chat) { - set({ - currentChat: result.chat, - messages: result.chat.messages, - isLoading: false, - }) - - // Add the new chat to the chats list - set((state) => ({ - chats: [result.chat!, ...state.chats], - })) - - logger.info(`Created new chat: ${result.chat.id}`) - } else { - throw new Error(result.error || 'Failed to create chat') - } - } catch (error) { - set({ - error: handleStoreError(error, 'Failed to create chat'), - isLoading: false, - }) - } + set({ + currentChat: newChat, + messages: [], + }) + logger.info('Created new local chat') }, - // Delete a chat + // Delete chat is now a no-op since we don't have the API deleteChat: async (chatId: string) => { - try { - const result = await deleteApiChat(chatId) - - if (result.success) { - const { currentChat } = get() - - // Remove from chats list - set((state) => ({ - chats: state.chats.filter((chat) => chat.id !== chatId), - })) - - // If this was the current chat, clear it and select another one - if (currentChat?.id === chatId) { - // Get the updated chats list (after removal) in a single atomic operation - const { chats: updatedChats } = get() - const remainingChats = updatedChats.filter((chat) => chat.id !== chatId) - - if (remainingChats.length > 0) { - const sortedByCreation = [...remainingChats].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) - set({ - currentChat: null, - messages: [], - }) - await get().selectChat(sortedByCreation[0]) - } else { - set({ - currentChat: null, - messages: [], - }) - } - } + logger.warn('Chat deletion not implemented without API endpoint') + // The interface expects Promise, not Promise + }, - logger.info(`Deleted chat: ${chatId}`) - } else { - throw new Error(result.error || 'Failed to delete chat') - } - } catch (error) { - set({ - error: handleStoreError(error, 'Failed to delete chat'), - }) - } + // Load chats - now a no-op + loadChats: async () => { + logger.warn('Chat loading not implemented without API endpoint') + set({ chats: [] }) }, - // Send a regular message + // Send a message sendMessage: async (message: string, options = {}) => { const { workflowId, currentChat, mode } = get() const { stream = true } = options @@ -1074,100 +825,27 @@ export const useCopilotStore = create()( } }, - // Update preview tool call state without sending feedback - updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => { - const { messages } = get() - - // Find the last message with a preview_workflow or targeted_updates tool call - const lastMessageWithPreview = [...messages] - .reverse() - .find( - (msg) => - msg.role === 'assistant' && - msg.toolCalls?.some( - (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW - ) - ) - - if (lastMessageWithPreview) { - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === lastMessageWithPreview.id - ? { - ...msg, - toolCalls: msg.toolCalls?.map((tc) => - tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW - ? { ...tc, state: toolCallState } - : tc - ), - contentBlocks: msg.contentBlocks?.map((block) => - block.type === 'tool_call' && - (block.toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || - block.toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) - ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } - : block - ), - } - : msg - ), - })) - } - }, - - // Send implicit feedback and update preview tool call state + // Send implicit feedback sendImplicitFeedback: async ( implicitFeedback: string, toolCallState?: 'applied' | 'rejected' ) => { - const { workflowId, currentChat, mode, messages } = get() + const { workflowId, currentChat, mode } = get() if (!workflowId) { logger.warn('Cannot send implicit feedback: no workflow ID set') return } + // Update the tool call state if provided + if (toolCallState) { + get().updatePreviewToolCallState(toolCallState) + } + // Create abort controller for this request const abortController = new AbortController() set({ isSendingMessage: true, error: null, abortController }) - // Update the preview_workflow or targeted_updates tool call state if provided - if (toolCallState) { - // Find the last message with a preview_workflow or targeted_updates tool call - const lastMessageWithPreview = [...messages] - .reverse() - .find( - (msg) => - msg.role === 'assistant' && - msg.toolCalls?.some( - (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW - ) - ) - - if (lastMessageWithPreview) { - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === lastMessageWithPreview.id - ? { - ...msg, - toolCalls: msg.toolCalls?.map((tc) => - tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW - ? { ...tc, state: toolCallState } - : tc - ), - contentBlocks: msg.contentBlocks?.map((block) => - block.type === 'tool_call' && - (block.toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || - block.toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) - ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } - : block - ), - } - : msg - ), - })) - } - } - // Create a new assistant message for the response const newAssistantMessage = createStreamingMessage() @@ -1188,21 +866,18 @@ export const useCopilotStore = create()( }) if (result.success && result.stream) { - // Stream to the new assistant message (not continuation) await get().handleStreamingResponse(result.stream, newAssistantMessage.id, false) } else { - // Handle abort gracefully if (result.error === 'Request was aborted') { logger.info('Implicit feedback sending was aborted by user') - return // Don't throw or update state, abort handler already did + return } throw new Error(result.error || 'Failed to send implicit feedback') } } catch (error) { - // Check if this was an abort if (error instanceof Error && error.name === 'AbortError') { logger.info('Implicit feedback sending was aborted') - return // Don't update state, abort handler already did + return } const errorMessage = createErrorMessage( @@ -1221,69 +896,104 @@ export const useCopilotStore = create()( } }, - // Send a docs RAG message - sendDocsMessage: async (query: string, options = {}) => { - const { workflowId, currentChat } = get() - const { stream = true, topK = 10 } = options + // Update preview tool call state + updatePreviewToolCallState: (toolCallState: 'applied' | 'rejected') => { + const { messages } = get() - if (!workflowId) { - logger.warn('Cannot send docs message: no workflow ID set') - return + // Find the last message with a preview_workflow or targeted_updates tool call + const lastMessageWithPreview = [...messages] + .reverse() + .find( + (msg) => + msg.role === 'assistant' && + msg.toolCalls?.some( + (tc) => tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW + ) + ) + + if (lastMessageWithPreview) { + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === lastMessageWithPreview.id + ? { + ...msg, + toolCalls: msg.toolCalls?.map((tc) => + tc.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || tc.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW + ? { ...tc, state: toolCallState } + : tc + ), + contentBlocks: msg.contentBlocks?.map((block) => + block.type === 'tool_call' && + (block.toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + block.toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) + ? { ...block, toolCall: { ...block.toolCall, state: toolCallState } } + : block + ), + } + : msg + ), + })) } + }, - // Create abort controller for this request - const abortController = new AbortController() - set({ isSendingMessage: true, error: null, abortController }) + // Send docs message - simplified without separate API + sendDocsMessage: async (query: string) => { + // Just send as a regular message since docs search is now a tool + await get().sendMessage(query) + }, - const userMessage = createUserMessage(query) - const streamingMessage = createStreamingMessage() + // Save chat messages - no-op for now + saveChatMessages: async (chatId: string) => { + logger.info('Chat saving handled automatically by backend') + }, - set((state) => ({ - messages: [...state.messages, userMessage, streamingMessage], - })) + // Load checkpoints - no-op + loadCheckpoints: async (chatId: string) => { + logger.warn('Checkpoint loading not implemented') + set({ checkpoints: [] }) + }, - try { - const result = await sendStreamingDocsMessage({ - query, - topK, - chatId: currentChat?.id, - workflowId, - createNewChat: !currentChat, - stream, - abortSignal: abortController.signal, - }) + // Revert checkpoint - no-op + revertToCheckpoint: async (checkpointId: string) => { + logger.warn('Checkpoint reverting not implemented') + }, - if (result.success && result.stream) { - await get().handleStreamingResponse(result.stream, streamingMessage.id) - } else { - // Handle abort gracefully - if (result.error === 'Request was aborted') { - logger.info('Docs message sending was aborted by user') - return // Don't throw or update state, abort handler already did - } - throw new Error(result.error || 'Failed to send docs message') - } - } catch (error) { - // Check if this was an abort - if (error instanceof Error && error.name === 'AbortError') { - logger.info('Docs message sending was aborted') - return // Don't update state, abort handler already did - } + // Set preview YAML + setPreviewYaml: async (yamlContent: string) => { + const { currentChat } = get() + if (!currentChat) { + logger.warn('Cannot set preview YAML: no current chat') + return + } - const errorMessage = createErrorMessage( - streamingMessage.id, - 'Sorry, I encountered an error while searching the documentation. Please try again.' - ) + set((state) => ({ + currentChat: state.currentChat + ? { + ...state.currentChat, + previewYaml: yamlContent, + } + : null, + })) + logger.info('Preview YAML set locally') + }, - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === streamingMessage.id ? errorMessage : msg - ), - error: handleStoreError(error, 'Failed to send docs message'), - isSendingMessage: false, - abortController: null, - })) + // Clear preview YAML + clearPreviewYaml: async () => { + const { currentChat } = get() + if (!currentChat) { + logger.warn('Cannot clear preview YAML: no current chat') + return } + + set((state) => ({ + currentChat: state.currentChat + ? { + ...state.currentChat, + previewYaml: null, + } + : null, + })) + logger.info('Preview YAML cleared locally') }, // Handle streaming response @@ -1315,12 +1025,6 @@ export const useCopilotStore = create()( context.accumulatedContent = existingMessage.content || '' context.toolCalls = existingMessage.toolCalls ? [...existingMessage.toolCalls] : [] context.contentBlocks = existingMessage.contentBlocks ? [...existingMessage.contentBlocks] : [] - - logger.info('Continuing stream with existing state', { - messageId, - existingToolCalls: context.toolCalls.length, - existingContentBlocks: context.contentBlocks.length - }) } } @@ -1355,11 +1059,11 @@ export const useCopilotStore = create()( logger.info(`Completed streaming response, content length: ${context.accumulatedContent.length}`) // Final update - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === messageId - ? { - ...msg, + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === messageId + ? { + ...msg, content: context.accumulatedContent, toolCalls: context.toolCalls, contentBlocks: context.contentBlocks, @@ -1370,17 +1074,9 @@ export const useCopilotStore = create()( abortController: null, })) - // Auto-save messages after streaming completes - const { currentChat } = get() - const chatIdToSave = currentChat?.id || context.newChatId - - if (chatIdToSave) { - try { - logger.info('Auto-saving chat messages after streaming completion') - await get().saveChatMessages(chatIdToSave) - } catch (error) { - logger.error('Failed to auto-save chat messages:', error) - } + // Handle new chat creation if needed + if (context.newChatId && !get().currentChat) { + await get().handleNewChatCreation(context.newChatId) } } catch (error) { // Handle AbortError gracefully @@ -1398,230 +1094,26 @@ export const useCopilotStore = create()( // Handle new chat creation after streaming handleNewChatCreation: async (newChatId: string) => { - try { - const chatResult = await getChat(newChatId) - if (chatResult.success && chatResult.chat) { - // Set the new chat as current - set({ - currentChat: chatResult.chat, - }) - - // Add to chats list if not already there (atomic check and update) - set((state) => { - const chatExists = state.chats.some((chat) => chat.id === newChatId) - if (!chatExists) { - return { - chats: [chatResult.chat!, ...state.chats], - } - } - return state - }) - } - } catch (error) { - logger.error('Failed to fetch new chat after creation:', error) - // Fallback: reload all chats - await get().loadChats() - } - }, - - // Save chat messages to database - saveChatMessages: async (chatId: string) => { - const { messages, chats } = get() - set({ isSaving: true, saveError: null }) - - try { - const result = await updateChatMessages(chatId, messages) - - if (result.success && result.chat) { - const updatedChat = result.chat - - // Update local state with the saved chat - // Don't overwrite messages - keep the current local state which has the latest content - set({ - currentChat: updatedChat, - isSaving: false, - saveError: null, - }) - - // Update the chat in the chats list (atomic check, update, or add) - set((state) => { - const chatExists = state.chats.some((chat) => chat.id === updatedChat!.id) - - if (!chatExists) { - // Chat doesn't exist, add it to the beginning - return { - chats: [updatedChat!, ...state.chats], - } - } - // Chat exists, update it - const updatedChats = state.chats.map((chat) => - chat.id === updatedChat!.id ? updatedChat! : chat - ) - return { chats: updatedChats } - }) - - logger.info(`Successfully saved chat ${chatId}`) - } else { - const errorMessage = result.error || 'Failed to save chat' - set({ - isSaving: false, - saveError: errorMessage, - }) - throw new Error(errorMessage) - } - } catch (error) { - const errorMessage = handleStoreError(error, 'Error saving chat') - set({ - isSaving: false, - saveError: errorMessage, - }) - throw error + // Create a proper chat object from the ID + const newChat: CopilotChat = { + id: newChatId, + title: null, + model: 'gpt-4', + messages: get().messages, + messageCount: get().messages.length, + previewYaml: null, + createdAt: new Date(), + updatedAt: new Date(), } - }, - // Load checkpoints for current chat - loadCheckpoints: async (chatId: string) => { - set({ isLoadingCheckpoints: true, checkpointError: null }) - - try { - const result = await listCheckpoints(chatId) - - if (result.success) { - set({ - checkpoints: result.checkpoints, - isLoadingCheckpoints: false, - }) - logger.info(`Loaded ${result.checkpoints.length} checkpoints for chat ${chatId}`) - } else { - throw new Error(result.error || 'Failed to load checkpoints') - } - } catch (error) { - set({ - checkpointError: handleStoreError(error, 'Failed to load checkpoints'), - isLoadingCheckpoints: false, - }) - } - }, - - // Revert to a specific checkpoint - revertToCheckpoint: async (checkpointId: string) => { - set({ isRevertingCheckpoint: true, checkpointError: null }) - - try { - const result = await revertToCheckpoint(checkpointId) - - if (result.success) { - set({ isRevertingCheckpoint: false }) - logger.info(`Successfully reverted to checkpoint ${checkpointId}`) - } else { - throw new Error(result.error || 'Failed to revert to checkpoint') - } - } catch (error) { - set({ - checkpointError: handleStoreError(error, 'Failed to revert to checkpoint'), - isRevertingCheckpoint: false, - }) - } - }, - - // Clear current messages - clearMessages: () => { set({ - currentChat: null, - messages: [], - error: null, + currentChat: newChat, + chats: [newChat, ...get().chats], }) - logger.info('Cleared current chat and messages') - }, - - // Set preview YAML for current chat - setPreviewYaml: async (yamlContent: string) => { - const { currentChat } = get() - if (!currentChat) { - logger.warn('Cannot set preview YAML: no current chat') - return - } - - try { - // Update local state immediately - set((state) => ({ - currentChat: state.currentChat - ? { - ...state.currentChat, - previewYaml: yamlContent, - } - : null, - })) - - // Update database - const response = await fetch('/api/copilot', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chatId: currentChat.id, - previewYaml: yamlContent, - }), - }) - - if (!response.ok) { - throw new Error('Failed to save preview YAML') - } - - logger.info('Preview YAML set successfully') - } catch (error) { - logger.error('Failed to set preview YAML:', error) - // Revert local state on error - set((state) => ({ - currentChat: state.currentChat - ? { - ...state.currentChat, - previewYaml: null, - } - : null, - })) - } - }, - - // Clear preview YAML for current chat - clearPreviewYaml: async () => { - const { currentChat } = get() - if (!currentChat) { - logger.warn('Cannot clear preview YAML: no current chat') - return - } - - try { - // Update local state immediately - set((state) => ({ - currentChat: state.currentChat - ? { - ...state.currentChat, - previewYaml: null, - } - : null, - })) - - // Update database - const response = await fetch('/api/copilot', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chatId: currentChat.id, - previewYaml: null, - }), - }) - - if (!response.ok) { - throw new Error('Failed to clear preview YAML') - } - - logger.info('Preview YAML cleared successfully') - } catch (error) { - logger.error('Failed to clear preview YAML:', error) - } + logger.info(`Created new chat from streaming response: ${newChatId}`) }, - // Clear error state + // Clear error clearError: () => { set({ error: null }) }, From a351af55a674518ba0aadfb3cd44f93ca0d84bb1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 13:47:20 -0700 Subject: [PATCH 093/184] Store updates --- apps/sim/stores/copilot/store.ts | 508 +++++++++++++------------------ 1 file changed, 205 insertions(+), 303 deletions(-) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 070b2a34206..176c2ad0151 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -90,6 +90,92 @@ function getToolDisplayName(toolName: string): string { return COPILOT_TOOL_DISPLAY_NAMES[toolName] || toolName } +/** + * Helper function to process workflow tool results (build_workflow or edit_workflow) + */ +function processWorkflowToolResult( + toolCall: any, + result: any, + get: () => CopilotStore +): void { + // Extract YAML content from various possible locations in the result + const yamlContent = result?.yamlContent || + result?.data?.yamlContent || + toolCall.input?.yamlContent || + toolCall.input?.data?.yamlContent + + if (yamlContent) { + logger.info(`Setting preview YAML from ${toolCall.name} tool`, { + yamlLength: yamlContent.length, + yamlPreview: yamlContent.substring(0, 100) + }) + get().setPreviewYaml(yamlContent) + get().updateDiffStore(yamlContent, toolCall.name) + } else { + logger.warn(`No yamlContent found in ${toolCall.name} result`, { + resultKeys: Object.keys(result || {}), + inputKeys: Object.keys(toolCall.input || {}) + }) + } +} + +/** + * Helper function to handle tool execution failure + */ +function handleToolFailure( + toolCall: any, + error: string, + get: () => CopilotStore +): void { + toolCall.state = 'error' + toolCall.error = error + + logger.error('Tool call failed:', toolCall.id, toolCall.name, error) + + // Retry workflow generation on failure + if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { + logger.info(`${toolCall.name} failed, sending error back to agent for retry`) + setTimeout(() => { + get().sendImplicitFeedback( + `The previous workflow YAML generation failed with error: "${error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` + ) + }, 1000) + } +} + +/** + * Helper function to create a tool call object + */ +function createToolCall(id: string, name: string, input: any = {}): any { + return { + id, + name, + input, + displayName: getToolDisplayName(name), + state: 'executing', + startTime: Date.now(), + timestamp: Date.now() + } +} + +/** + * Helper function to finalize a tool call + */ +function finalizeToolCall(toolCall: any, success: boolean, result?: any): void { + toolCall.endTime = Date.now() + toolCall.duration = toolCall.endTime - toolCall.startTime + + if (success) { + toolCall.result = result + // Workflow tools need review, others are completed + toolCall.state = (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) + ? 'ready_for_review' + : 'completed' + } +} + /** * SSE event handlers for different event types */ @@ -122,251 +208,95 @@ const sseHandlers: Record = { } }, - // Handle tool result events (custom event for preview_workflow) + // Handle tool result events - simplified tool_result: (data, context, get, set) => { const { toolCallId, result, success } = data - logger.info('Received tool_result event', { - toolCallId, - success, - hasResult: !!result, - doneEventCount: context.doneEventCount, - streamComplete: context.streamComplete - }) - - // Reset stream completion if we're still receiving tool results - if (context.streamComplete) { - logger.warn('Received tool result after stream marked complete, reopening stream') - context.streamComplete = false - } if (!toolCallId) return - let toolCall = context.toolCalls.find((tc) => tc.id === toolCallId) + // Find tool call in context + const toolCall = context.toolCalls.find(tc => tc.id === toolCallId) || + context.contentBlocks + .filter(b => b.type === 'tool_call') + .map(b => b.toolCall) + .find(tc => tc.id === toolCallId) + if (!toolCall) { - logger.warn('Tool call not found in context for result, checking content blocks', { - toolCallId, - existingToolCalls: context.toolCalls.map(tc => ({ id: tc.id, name: tc.name })) - }) - - // Try to find the tool call in existing content blocks - for (const block of context.contentBlocks) { - if (block.type === 'tool_call' && block.toolCall.id === toolCallId) { - toolCall = block.toolCall - // Add it back to context.toolCalls so we can update it - context.toolCalls.push(toolCall) - logger.info('Found tool call in content blocks, added to context', { - toolCallId, - toolName: toolCall.name - }) - break - } - } - - if (!toolCall) { - logger.error('Tool call not found anywhere for result', { toolCallId }) - return - } + logger.error('Tool call not found for result', { toolCallId }) + return } - logger.info('Found existing tool call for result', { - name: toolCall.name, - toolCallId, - }) + // Ensure tool call is in context for updates + if (!context.toolCalls.find(tc => tc.id === toolCallId)) { + context.toolCalls.push(toolCall) + } if (success) { - // Parse result if it's a string (sim agent sometimes stringifies the result) - let parsedResult = result - if (typeof result === 'string' && result.startsWith('{')) { - try { - parsedResult = JSON.parse(result) - } catch (e) { - logger.warn('Failed to parse tool result as JSON, using as-is', { toolName: toolCall.name }) - } - } - - toolCall.result = parsedResult - toolCall.endTime = Date.now() - toolCall.duration = toolCall.endTime - (toolCall.startTime || Date.now()) - - // Set appropriate state based on tool type - if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { - toolCall.state = 'ready_for_review' - } else { - toolCall.state = 'completed' - } + // Parse result if needed + const parsedResult = typeof result === 'string' && result.startsWith('{') + ? (() => { try { return JSON.parse(result) } catch { return result } })() + : result - logger.info('Updated tool call result:', toolCallId, toolCall.name) + finalizeToolCall(toolCall, true, parsedResult) - // Update the content block to reflect the tool completion - updateContentBlockToolCall(context.contentBlocks, toolCallId, toolCall) - updateStreamingMessage(set, context) - - // Log successful tool completion - logger.info('Tool completed successfully', { - toolId: toolCallId, - toolName: toolCall.name, - state: toolCall.state, - duration: toolCall.duration - }) - - // Handle successful build_workflow tool result - if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { - // Check both direct yamlContent and nested data.yamlContent - const yamlContent = parsedResult?.yamlContent || parsedResult?.data?.yamlContent - if (yamlContent) { - logger.info('Setting preview YAML from tool_result event', { - yamlLength: yamlContent.length, - yamlPreview: yamlContent.substring(0, 100), - }) - get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.BUILD_WORKFLOW) - } else { - logger.warn('No yamlContent found in build_workflow result', { - hasDirectYaml: !!parsedResult?.yamlContent, - hasNestedYaml: !!parsedResult?.data?.yamlContent, - resultStructure: Object.keys(parsedResult || {}) - }) - } - } - - // Handle successful edit_workflow tool result - if (toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { - // Check both direct yamlContent and nested data.yamlContent - const yamlContent = parsedResult?.yamlContent || parsedResult?.data?.yamlContent - if (yamlContent) { - logger.info('Setting preview YAML from edit_workflow tool_result event', { - yamlLength: yamlContent.length, - yamlPreview: yamlContent.substring(0, 200), - }) - get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.EDIT_WORKFLOW) - } else { - logger.warn('No yamlContent found in edit_workflow result', { - hasDirectYaml: !!parsedResult?.yamlContent, - hasNestedYaml: !!parsedResult?.data?.yamlContent, - resultStructure: Object.keys(parsedResult || {}) - }) - } + // Handle workflow tools + if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { + processWorkflowToolResult(toolCall, parsedResult, get) } } else { - // Tool execution failed - toolCall.state = 'error' - toolCall.error = result || 'Tool execution failed' - logger.error('Tool call failed:', toolCallId, toolCall.name, result) - - // If build_workflow failed, send error back for retry - if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { - logger.info('Build workflow tool execution failed, sending error back to agent for retry') - setTimeout(() => { - get().sendImplicitFeedback( - `The previous workflow YAML generation failed with error: "${toolCall.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` - ) - }, 1000) - } + handleToolFailure(toolCall, result || 'Tool execution failed', get) } - // Update contentBlocks with the updated tool call updateContentBlockToolCall(context.contentBlocks, toolCallId, toolCall) - - // Update message updateStreamingMessage(set, context) }, - // Handle Anthropic content block start - content_block_start: (data, context, get, set) => { - context.currentBlockType = data.content_block?.type - - if (context.currentBlockType === 'text') { - context.currentTextBlock = { - type: 'text', - content: '', - timestamp: Date.now(), - } - } else if (context.currentBlockType === 'tool_use') { - // Start buffering a tool call - context.toolCallBuffer = { - id: data.content_block.id, - name: data.content_block.name, - displayName: getToolDisplayName(data.content_block.name), - input: {}, - partialInput: '', - state: 'executing', - startTime: Date.now(), - } - context.toolCalls.push(context.toolCallBuffer) - - // Add tool call to content blocks - context.contentBlocks.push({ - type: 'tool_call', - toolCall: context.toolCallBuffer, - timestamp: Date.now(), - }) - - logger.info(`Starting tool call: ${data.content_block.name}`) - updateStreamingMessage(set, context) - } - }, - - // Handle sim agent's content format + // Handle content events content: (data, context, get, set) => { if (!data.data) return context.accumulatedContent += data.data - // Create or update text block - if (!context.currentTextBlock) { + // Update existing text block or create new one + if (context.currentTextBlock && context.contentBlocks.length > 0) { + // Find the last text block and update it + const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] + if (lastBlock.type === 'text') { + lastBlock.content += data.data + } else { + // Last block is not text, create a new text block + context.currentTextBlock = { + type: 'text', + content: data.data, + timestamp: Date.now(), + } + context.contentBlocks.push(context.currentTextBlock) + } + } else { + // No current text block, create one context.currentTextBlock = { type: 'text', content: data.data, timestamp: Date.now(), } context.contentBlocks.push(context.currentTextBlock) - } else { - context.currentTextBlock.content += data.data - updateContentBlockText(context.contentBlocks, context.currentTextBlock) } updateStreamingMessage(set, context) }, - // Handle sim agent's tool call format + // Handle tool call events - simplified tool_call: (data, context, get, set) => { const toolData = data.data if (!toolData) return - // Check if this tool call already exists (in case of duplicate events) - const existingToolCall = context.toolCalls.find(tc => tc.id === toolData.id) - if (existingToolCall) { - // If it's a partial update, we might want to update the existing tool call - if (toolData.partial && toolData.arguments) { - // Update partial arguments if needed - existingToolCall.input = { ...existingToolCall.input, ...toolData.arguments } - } - logger.debug('Tool call already exists, skipping or updating', { - id: toolData.id, - name: toolData.name, - partial: toolData.partial, - existingState: existingToolCall.state - }) + // Skip if already exists + if (context.toolCalls.find(tc => tc.id === toolData.id)) { return } - logger.info('Creating tool call from tool_call event', { - id: toolData.id, - name: toolData.name, - hasArguments: !!toolData.arguments, - partial: toolData.partial - }) - - const toolCall = { - id: toolData.id, - name: toolData.name, - input: toolData.arguments || {}, - state: 'executing', - timestamp: Date.now(), - displayName: getToolDisplayName(toolData.name), - startTime: Date.now(), - } + const toolCall = createToolCall(toolData.id, toolData.name, toolData.arguments) context.toolCalls.push(toolCall) context.contentBlocks.push({ @@ -380,131 +310,102 @@ const sseHandlers: Record = { // Handle tool execution event tool_execution: (data, context, get, set) => { - logger.info('Tool execution started:', data.toolName) const toolCall = context.toolCalls.find(tc => tc.id === data.toolCallId) - if (!toolCall) return - - toolCall.state = 'executing' - updateContentBlockToolCall(context.contentBlocks, data.toolCallId, toolCall) - updateStreamingMessage(set, context) + if (toolCall) { + toolCall.state = 'executing' + updateContentBlockToolCall(context.contentBlocks, data.toolCallId, toolCall) + updateStreamingMessage(set, context) + } + }, + + // Handle Anthropic content block events - simplified + content_block_start: (data, context) => { + context.currentBlockType = data.content_block?.type + + if (context.currentBlockType === 'text') { + // Start a new text block + context.currentTextBlock = { + type: 'text', + content: '', + timestamp: Date.now(), + } + context.contentBlocks.push(context.currentTextBlock) + } else if (context.currentBlockType === 'tool_use') { + // Mark that we're no longer in a text block + context.currentTextBlock = null + + const toolCall = createToolCall( + data.content_block.id, + data.content_block.name + ) + toolCall.partialInput = '' + + context.toolCallBuffer = toolCall + context.toolCalls.push(toolCall) + + context.contentBlocks.push({ + type: 'tool_call', + toolCall, + timestamp: Date.now(), + }) + } }, - // Handle content block delta content_block_delta: (data, context, get, set) => { if (context.currentBlockType === 'text' && data.delta?.text) { + // For Anthropic, update the current text block context.accumulatedContent += data.delta.text - if (context.currentTextBlock) { context.currentTextBlock.content += data.delta.text - updateContentBlockText(context.contentBlocks, context.currentTextBlock) + updateStreamingMessage(set, context) } - - updateStreamingMessage(set, context) } else if (context.currentBlockType === 'tool_use' && data.delta?.partial_json && context.toolCallBuffer) { context.toolCallBuffer.partialInput += data.delta.partial_json } }, - // Handle content block stop content_block_stop: (data, context, get, set) => { if (context.currentBlockType === 'text') { + // Text block is complete context.currentTextBlock = null } else if (context.currentBlockType === 'tool_use' && context.toolCallBuffer) { try { - // Parse complete tool call input + // Parse complete tool input context.toolCallBuffer.input = JSON.parse(context.toolCallBuffer.partialInput || '{}') - context.toolCallBuffer.state = - context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || - context.toolCallBuffer.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW - ? 'ready_for_review' - : 'completed' - context.toolCallBuffer.endTime = Date.now() - context.toolCallBuffer.duration = context.toolCallBuffer.endTime - context.toolCallBuffer.startTime + finalizeToolCall(context.toolCallBuffer, true) - logger.info(`Tool call completed: ${context.toolCallBuffer.name}`, context.toolCallBuffer.input) + // Handle workflow tools immediately + if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || + context.toolCallBuffer.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { + processWorkflowToolResult(context.toolCallBuffer, context.toolCallBuffer.input, get) + } updateContentBlockToolCall(context.contentBlocks, context.toolCallBuffer.id, context.toolCallBuffer) updateStreamingMessage(set, context) - - // Handle build_workflow completion - if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { - // Check both direct yamlContent and nested data.yamlContent - const yamlContent = context.toolCallBuffer.input?.yamlContent || - context.toolCallBuffer.input?.data?.yamlContent - if (yamlContent) { - logger.info('Setting preview YAML from completed build_workflow tool call', { - yamlLength: yamlContent.length, - yamlPreview: yamlContent.substring(0, 100) - }) - get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.BUILD_WORKFLOW) - } - } - - // Handle edit_workflow completion - if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { - // Check both direct yamlContent and nested data.yamlContent - const yamlContent = context.toolCallBuffer.input?.yamlContent || - context.toolCallBuffer.input?.data?.yamlContent - if (yamlContent) { - logger.info('Setting preview YAML from completed edit_workflow tool call', { - yamlLength: yamlContent.length, - yamlPreview: yamlContent.substring(0, 100) - }) - get().setPreviewYaml(yamlContent) - get().updateDiffStore(yamlContent, COPILOT_TOOL_IDS.EDIT_WORKFLOW) - } - } } catch (error) { - logger.error('Error parsing tool call input:', error) - context.toolCallBuffer.state = 'error' - context.toolCallBuffer.endTime = Date.now() - context.toolCallBuffer.duration = context.toolCallBuffer.endTime - context.toolCallBuffer.startTime - context.toolCallBuffer.error = error instanceof Error ? error.message : String(error) - - // Retry on build_workflow failure - if (context.toolCallBuffer.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW) { - setTimeout(() => { - get().sendImplicitFeedback( - `The previous workflow YAML generation failed with error: "${context.toolCallBuffer.error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` - ) - }, 1000) - } + const errorMsg = error instanceof Error ? error.message : String(error) + handleToolFailure(context.toolCallBuffer, errorMsg, get) } + context.toolCallBuffer = null } context.currentBlockType = null }, - // Handle sim agent's done event - done: (data, context, get, set) => { + // Handle done event + done: (data, context) => { context.doneEventCount++ - logger.info('Received done event from sim agent', { - doneEventCount: context.doneEventCount, - pendingToolCalls: context.toolCalls.filter(tc => tc.state === 'executing').length - }) - - context.currentTextBlock = null - // Don't complete stream if there are still executing tool calls - const executingToolCalls = context.toolCalls.filter(tc => tc.state === 'executing') - if (executingToolCalls.length > 0) { - logger.info('Done event received but tools still executing', { - executingTools: executingToolCalls.map(tc => ({ id: tc.id, name: tc.name })) - }) - return - } - - // Complete stream after multiple done events (sim agent sends one after tools and one at end) - if (context.doneEventCount >= 2) { - logger.info('Received final done event, completing stream') + // Only complete after all tools are done and we've received multiple done events + const executingTools = context.toolCalls.filter(tc => tc.state === 'executing') + if (executingTools.length === 0 && context.doneEventCount >= 2) { context.streamComplete = true } }, // Handle errors error: (data, context, get, set) => { - logger.error('Received error:', data.error) + logger.error('Stream error:', data.error) set((state: CopilotStore) => ({ messages: state.messages.map((msg: CopilotMessage) => msg.id === context.messageId @@ -520,22 +421,18 @@ const sseHandlers: Record = { }, // Handle tool errors - tool_error: (data, context) => { - logger.error('Tool error:', data.toolName, data.error) + tool_error: (data, context, get, set) => { const toolCall = context.toolCalls.find(tc => tc.id === data.toolCallId) if (toolCall) { - toolCall.state = 'error' - toolCall.error = data.error + handleToolFailure(toolCall, data.error, get) + updateContentBlockToolCall(context.contentBlocks, data.toolCallId, toolCall) + updateStreamingMessage(set, context) } }, - // Default handler for unhandled events - default: (data) => { - // Silently handle these common events - const silentEvents = ['message_start', 'message_delta', 'message_stop'] - if (!silentEvents.includes(data.type)) { - logger.debug('Unhandled SSE event type:', data.type) - } + // Default handler + default: () => { + // Silently ignore unhandled events } } @@ -578,9 +475,9 @@ function updateStreamingMessage(set: any, context: StreamingContext) { msg.id === context.messageId ? { ...msg, - content: context.accumulatedContent, + content: '', // Don't use accumulated content for display toolCalls: [...context.toolCalls], - contentBlocks: [...context.contentBlocks], + contentBlocks: [...context.contentBlocks], // This preserves stream order lastUpdated: Date.now(), } : msg @@ -1058,13 +955,18 @@ export const useCopilotStore = create()( // Stream ended - finalize the message logger.info(`Completed streaming response, content length: ${context.accumulatedContent.length}`) - // Final update + // Final update - build content from contentBlocks for final message + const finalContent = context.contentBlocks + .filter(block => block.type === 'text') + .map(block => block.content) + .join('') + set((state) => ({ messages: state.messages.map((msg) => msg.id === messageId ? { ...msg, - content: context.accumulatedContent, + content: finalContent, // Set final content for non-streaming display toolCalls: context.toolCalls, contentBlocks: context.contentBlocks, } From f638dae3bbb13e072eceea2fc9fef9333257460c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 14:05:52 -0700 Subject: [PATCH 094/184] Checkpoitn --- apps/sim/app/api/copilot/chat/route.ts | 85 +++++-- .../[workflowId]/components/diff-controls.tsx | 54 ++--- .../[workflowId]/components/review-button.tsx | 228 ++++++++---------- apps/sim/stores/copilot/store.ts | 14 ++ 4 files changed, 203 insertions(+), 178 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 4fe656e84bc..08a7b63cfe2 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -49,14 +49,14 @@ async function generateChatTitle(userMessage: string): Promise { logger.warn(`Failed to get rotating API key for Anthropic:`, e) } } - + const response = await executeProviderRequest(provider, { model, systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, context: TITLE_GENERATION_USER_PROMPT(userMessage), temperature: 0.3, maxTokens: 50, - apiKey: apiKey || '', // Use rotating key or empty string + apiKey: apiKey || '', stream: false, }) @@ -71,6 +71,47 @@ async function generateChatTitle(userMessage: string): Promise { } } +/** + * Generate chat title asynchronously and update the database + */ +async function generateChatTitleAsync( + chatId: string, + userMessage: string, + requestId: string, + streamController?: ReadableStreamDefaultController +): Promise { + try { + logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`) + + const title = await generateChatTitle(userMessage) + + // Update the chat with the generated title + await db + .update(copilotChats) + .set({ + title, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) + + // Send title_updated event to client if streaming + if (streamController) { + const encoder = new TextEncoder() + const titleEvent = `data: ${JSON.stringify({ + type: 'title_updated', + title: title + })}\n\n` + streamController.enqueue(encoder.encode(titleEvent)) + logger.debug(`[${requestId}] Sent title_updated event to client: "${title}"`) + } + + logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`) + } catch (error) { + logger.error(`[${requestId}] Failed to generate title for chat ${chatId}:`, error) + // Don't throw - this is a background operation + } +} + /** * POST /api/copilot/chat * Send messages to sim agent and handle chat persistence @@ -182,6 +223,11 @@ export async function POST(req: NextRequest) { content: message, }) + // Start title generation in parallel if this is a new chat with first message + if (actualChatId && !currentChat?.title && conversationHistory.length === 0) { + logger.info(`[${requestId}] Will start parallel title generation inside stream`) + } + // Forward to sim agent API logger.info(`[${requestId}] Sending request to sim agent API`, { messageCount: messages.length, @@ -247,6 +293,15 @@ export async function POST(req: NextRequest) { logger.debug(`[${requestId}] Sent initial chatId event to client`) } + // Start title generation in parallel if needed + if (actualChatId && !currentChat?.title && conversationHistory.length === 0) { + logger.info(`[${requestId}] Starting title generation with stream updates`) + generateChatTitleAsync(actualChatId, message, requestId, controller) + .catch(error => { + logger.error(`[${requestId}] Title generation failed:`, error) + }) + } + // Forward the sim agent stream and capture assistant response const reader = simAgentResponse.body!.getReader() const decoder = new TextDecoder() @@ -390,25 +445,17 @@ export async function POST(req: NextRequest) { const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - // Generate title if this is the first message - let titleToUse = currentChat.title - if (!titleToUse && conversationHistory.length === 0) { - titleToUse = await generateChatTitle(message) - } - - // Update chat in database + // Update chat in database immediately (without title) await db .update(copilotChats) .set({ messages: updatedMessages, - title: titleToUse || currentChat.title, updatedAt: new Date(), }) .where(eq(copilotChats.id, actualChatId!)) logger.info(`[${requestId}] Updated chat ${actualChatId} with new messages`, { messageCount: updatedMessages.length, - title: titleToUse || currentChat.title }) } } catch (error) { @@ -483,21 +530,23 @@ export async function POST(req: NextRequest) { const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - // Generate title if this is the first message - let titleToUse = currentChat.title - if (!titleToUse && conversationHistory.length === 0) { - titleToUse = await generateChatTitle(message) + // Start title generation in parallel if this is first message (non-streaming) + if (actualChatId && !currentChat.title && conversationHistory.length === 0) { + logger.info(`[${requestId}] Starting title generation for non-streaming response`) + generateChatTitleAsync(actualChatId, message, requestId) + .catch(error => { + logger.error(`[${requestId}] Title generation failed:`, error) + }) } - // Update chat in database + // Update chat in database immediately (without blocking for title) await db .update(copilotChats) .set({ messages: updatedMessages, - title: titleToUse || currentChat.title, updatedAt: new Date(), }) - .where(eq(copilotChats.id, actualChatId!)) + .where(eq(copilotChats.id, actualChatId!)) } logger.info(`[${requestId}] Returning non-streaming response`, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx index 464013501ea..1104cfa8264 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx @@ -29,38 +29,38 @@ export function DiffControls() { toggleDiffView() } - const handleAccept = async () => { - logger.info('Accepting proposed changes') - - try { - // Accept the changes in the diff store (this updates the main workflow store) - await acceptChanges() - - // Update the copilot tool call state and clear preview YAML - updatePreviewToolCallState('applied') - await clearPreviewYaml() - - logger.info('Successfully accepted proposed changes') - } catch (error) { - logger.error('Failed to accept changes:', error) - } + const handleAccept = () => { + logger.info('Accepting proposed changes (optimistic)') + + // Immediately update UI state (optimistic) + updatePreviewToolCallState('applied') + clearPreviewYaml().catch((error) => { + logger.warn('Failed to clear preview YAML:', error) + }) + + // Start background save without awaiting + acceptChanges().catch((error) => { + logger.error('Failed to accept changes in background:', error) + // TODO: Consider showing a toast notification for save failures + // For now, the optimistic update stands since the UI state is already correct + }) + + logger.info('Optimistically applied changes, saving in background') } - const handleReject = async () => { - logger.info('Rejecting proposed changes') + const handleReject = () => { + logger.info('Rejecting proposed changes (optimistic)') - try { - // Reject the changes in the diff store - rejectChanges() + // Immediately update UI state (optimistic) + updatePreviewToolCallState('rejected') + clearPreviewYaml().catch((error) => { + logger.warn('Failed to clear preview YAML:', error) + }) - // Update the copilot tool call state and clear preview YAML - updatePreviewToolCallState('rejected') - await clearPreviewYaml() + // Reject is immediate (no server save needed) + rejectChanges() - logger.info('Successfully rejected proposed changes') - } catch (error) { - logger.error('Failed to reject changes:', error) - } + logger.info('Successfully rejected proposed changes') } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx index 374dc0e1c5d..26142b0520b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx @@ -8,6 +8,8 @@ import { createLogger } from '@/lib/logs/console-logger' import { useCopilotStore } from '@/stores/copilot/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { CopilotSandboxModal } from './copilot-sandbox-modal/copilot-sandbox-modal' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' const logger = createLogger('ReviewButton') @@ -195,102 +197,26 @@ export function ReviewButton() { } const handleApply = async () => { - if (!currentChat?.previewYaml) return + if (!currentChat?.previewYaml) { + logger.error('No YAML content to apply') + return + } try { setIsProcessing(true) - - logger.info('Applying preview to current workflow (store-first)', { - workflowId: activeWorkflowId, + + // Optimistically update tool call state immediately + updatePreviewToolCallState('applied') + + logger.info('Applying preview workflow', { yamlLength: currentChat.previewYaml.length, + yamlPreview: currentChat.previewYaml.substring(0, 200), }) - // STEP 1: Parse YAML and update local store immediately - try { - // Import the necessary modules - const { convertYamlToWorkflowState } = await import('@/lib/workflows/yaml-converter') - const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - - // Convert YAML to workflow state using our unified converter - const conversionResult = await convertYamlToWorkflowState(currentChat.previewYaml, { - generateNewIds: false, // Keep existing IDs for preview - }) - - if (!conversionResult.success || !conversionResult.workflowState) { - throw new Error(`Failed to convert YAML: ${conversionResult.errors.join(', ')}`) - } - - const { - blocks: workflowBlocks, - edges: workflowEdges, - loops, - parallels, - } = conversionResult.workflowState - - // Apply auto layout using the shared utility - const { applyAutoLayoutToBlocks } = await import('../utils/auto-layout') - const layoutResult = await applyAutoLayoutToBlocks(workflowBlocks, workflowEdges) - - const layoutedBlocks = layoutResult.success ? layoutResult.layoutedBlocks! : workflowBlocks - - if (layoutResult.success) { - logger.info('Successfully applied auto layout to preview blocks') - } else { - logger.warn('Auto layout failed, using original positions:', layoutResult.error) - } - - // Update workflow store immediately - const workflowStore = useWorkflowStore.getState() - const newWorkflowState = { - blocks: layoutedBlocks, - edges: workflowEdges, - loops, - parallels, - lastSaved: Date.now(), - isDeployed: workflowStore.isDeployed, - deployedAt: workflowStore.deployedAt, - deploymentStatuses: workflowStore.deploymentStatuses, - hasActiveWebhook: workflowStore.hasActiveWebhook, - } - - useWorkflowStore.setState(newWorkflowState) - - // Extract and update subblock values - const subblockValues: Record> = {} - Object.values(layoutedBlocks).forEach((block: any) => { - if (block.subBlocks) { - const blockValues: Record = {} - Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== undefined && subBlock.value !== null) { - blockValues[subBlockId] = subBlock.value - } - }) - if (Object.keys(blockValues).length > 0) { - subblockValues[block.id] = blockValues - } - } - }) - - // Update subblock store - if (Object.keys(subblockValues).length > 0) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId!]: subblockValues, - }, - })) - } - - logger.info('Successfully updated local stores with preview content') - } catch (parseError) { - logger.error('Failed to parse and apply preview locally:', parseError) - throw parseError - } - - // STEP 2: Save to database (in background, don't await to keep UI responsive) - const saveToDatabase = async () => { + // Rest of the async operations happen in background + const applyInBackground = async () => { try { + // Apply the workflow YAML content const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { method: 'PUT', headers: { @@ -298,9 +224,9 @@ export function ReviewButton() { }, body: JSON.stringify({ yamlContent: currentChat.previewYaml, - description: 'Applied copilot proposal', + description: 'Applied from copilot proposal', source: 'copilot', - applyAutoLayout: true, + applyAutoLayout: false, createCheckpoint: true, }), }) @@ -313,22 +239,41 @@ export function ReviewButton() { const result = await response.json() if (!result.success) { - throw new Error(result.message || 'Failed to apply workflow changes') + throw new Error(result.message || 'Failed to apply workflow') + } + + logger.info('Successfully applied preview to main workflow') + + // Update local stores to reflect the applied changes + const { blocksUpdated, edgesUpdated, subBlocksUpdated } = result + + if (blocksUpdated) { + useWorkflowStore.setState({ blocks: blocksUpdated }) + } + if (edgesUpdated) { + useWorkflowStore.setState({ edges: edgesUpdated }) + } + if (subBlocksUpdated) { + useSubBlockStore.setState((state: any) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId as string]: subBlocksUpdated, + }, + })) } - logger.info('Successfully saved preview to database') - } catch (dbError) { - logger.error('Failed to save preview to database (store already updated):', dbError) - // Don't throw - the store is already updated, so the UI is correct - // The socket will eventually sync when the database is available + logger.info('Updated local stores with applied workflow state') + } catch (error) { + logger.error('Failed to apply preview in background:', error) + // TODO: Consider showing a toast notification for save failures + // The optimistic UI update already happened, so the user sees the intended state } } - // Save to database without blocking UI - saveToDatabase() + // Start background apply + applyInBackground() - // Clear preview YAML after successful store update (user has accepted) - updatePreviewToolCallState('applied') + // Clear preview YAML after optimistic update await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) @@ -349,51 +294,65 @@ export function ReviewButton() { try { setIsProcessing(true) + + // Optimistically update tool call state immediately + updatePreviewToolCallState('applied') logger.info('Creating new workflow from preview', { name, yamlLength: currentChat.previewYaml.length, }) - // First create a new workflow - const newWorkflowId = await createWorkflow({ - name, - description: 'Created from copilot proposal', - workspaceId, - }) + // Background save operation + const saveInBackground = async () => { + try { + // First create a new workflow + const newWorkflowId = await createWorkflow({ + name, + description: 'Created from copilot proposal', + workspaceId, + }) - if (!newWorkflowId) { - throw new Error('Failed to create new workflow') - } + if (!newWorkflowId) { + throw new Error('Failed to create new workflow') + } - // Then apply the YAML content to the new workflow - const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: currentChat.previewYaml, - description: 'Created from copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: false, - }), - }) + // Then apply the YAML content to the new workflow + const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + yamlContent: currentChat.previewYaml, + description: 'Created from copilot proposal', + source: 'copilot', + applyAutoLayout: true, + createCheckpoint: false, + }), + }) - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) - } + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) + } + + const result = await response.json() - const result = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to save workflow') + } - if (!result.success) { - throw new Error(result.message || 'Failed to save workflow') + logger.info('Successfully created new workflow from preview') + } catch (error) { + logger.error('Failed to save preview as new workflow in background:', error) + // TODO: Consider showing a toast notification for save failures + } } - logger.info('Successfully created new workflow from preview') - updatePreviewToolCallState('applied') + // Start background save + saveInBackground() + await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) @@ -411,7 +370,10 @@ export function ReviewButton() { try { setIsProcessing(true) + + // Optimistically update tool call state immediately updatePreviewToolCallState('rejected') + await clearPreviewYaml() setShowModal(false) setPreviewWorkflowState(null) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 176c2ad0151..0e32ceb3df0 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -208,6 +208,20 @@ const sseHandlers: Record = { } }, + // Handle chat title update event (custom event) + title_updated: async (data, context, get, set) => { + const { title } = data + logger.info('Received title update from stream:', title) + + set((state: CopilotStore) => ({ + currentChat: state.currentChat ? { + ...state.currentChat, + title, + updatedAt: new Date(), + } : state.currentChat, + })) + }, + // Handle tool result events - simplified tool_result: (data, context, get, set) => { const { toolCallId, result, success } = data From 02e77137df28bfa7cb7286b75cdf06ad909166a0 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 14:51:43 -0700 Subject: [PATCH 095/184] Smart title generation --- apps/sim/app/api/copilot/chat/route.ts | 16 +++++++++++++- apps/sim/stores/copilot/store.ts | 30 +++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 08a7b63cfe2..42587ce1176 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -295,11 +295,25 @@ export async function POST(req: NextRequest) { // Start title generation in parallel if needed if (actualChatId && !currentChat?.title && conversationHistory.length === 0) { - logger.info(`[${requestId}] Starting title generation with stream updates`) + logger.info(`[${requestId}] Starting title generation with stream updates`, { + chatId: actualChatId, + hasTitle: !!currentChat?.title, + conversationLength: conversationHistory.length, + message: message.substring(0, 100) + (message.length > 100 ? '...' : '') + }) generateChatTitleAsync(actualChatId, message, requestId, controller) .catch(error => { logger.error(`[${requestId}] Title generation failed:`, error) }) + } else { + logger.debug(`[${requestId}] Skipping title generation`, { + chatId: actualChatId, + hasTitle: !!currentChat?.title, + conversationLength: conversationHistory.length, + reason: !actualChatId ? 'no chatId' : + currentChat?.title ? 'already has title' : + conversationHistory.length > 0 ? 'not first message' : 'unknown' + }) } // Forward the sim agent stream and capture assistant response diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 0e32ceb3df0..181cb0bd414 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -211,7 +211,14 @@ const sseHandlers: Record = { // Handle chat title update event (custom event) title_updated: async (data, context, get, set) => { const { title } = data - logger.info('Received title update from stream:', title) + const { currentChat } = get() + const previousTitle = currentChat?.title + + logger.info('Received title update from stream:', { + newTitle: title, + previousTitle, + isOptimisticReplacement: previousTitle !== null && previousTitle !== title + }) set((state: CopilotStore) => ({ currentChat: state.currentChat ? { @@ -636,10 +643,31 @@ export const useCopilotStore = create()( const userMessage = createUserMessage(message) const streamingMessage = createStreamingMessage() + // Check if this is the first message before updating state + const currentMessages = get().messages + const isFirstMessage = currentMessages.length === 0 && !currentChat?.title + set((state) => ({ messages: [...state.messages, userMessage, streamingMessage], })) + // Optimistic title update for first message + if (isFirstMessage) { + // Generate optimistic title from first few words of user message + const optimisticTitle = message.length > 50 + ? message.substring(0, 47) + '...' + : message + + set((state) => ({ + currentChat: state.currentChat ? { + ...state.currentChat, + title: optimisticTitle, + } : state.currentChat, + })) + + logger.info('Set optimistic title for first message:', optimisticTitle) + } + try { const result = await sendStreamingMessage({ message, From d735c6b6b7921da5c7e926187dd83e2a0d4f4516 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 15:02:26 -0700 Subject: [PATCH 096/184] Handle copilot aborts --- apps/sim/app/api/copilot/chat/route.ts | 39 +++++++++++++------ .../professional-message.tsx | 20 ++++++++-- apps/sim/components/ui/tool-call.tsx | 14 +++++-- apps/sim/stores/copilot/store.ts | 36 ++++++++++++----- apps/sim/stores/copilot/types.ts | 2 +- apps/sim/types/tool-call.ts | 2 +- 6 files changed, 84 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 42587ce1176..6d0b4181ee4 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -328,8 +328,16 @@ export async function POST(req: NextRequest) { break } - // Forward the chunk to client immediately - controller.enqueue(value) + // Check if client disconnected before processing chunk + try { + // Forward the chunk to client immediately + controller.enqueue(value) + } catch (error) { + // Client disconnected - stop reading from sim agent + logger.info(`[${requestId}] Client disconnected, stopping stream processing`) + reader.cancel() // Stop reading from sim agent + break + } const chunkSize = value.byteLength // Decode and parse SSE events for logging and capturing content @@ -448,17 +456,24 @@ export async function POST(req: NextRequest) { toolNames: toolCalls.map(tc => tc?.name).filter(Boolean) }) - // Save messages to database after streaming completes - if (currentChat && assistantContent) { - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: assistantContent, - timestamp: new Date().toISOString(), + // Save messages to database after streaming completes (including aborted messages) + if (currentChat) { + let updatedMessages = [...conversationHistory, userMessage] + + // Save assistant message if there's any content (even partial from abort) + if (assistantContent.trim()) { + const assistantMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: assistantContent, + timestamp: new Date().toISOString(), + } + updatedMessages.push(assistantMessage) + logger.info(`[${requestId}] Saving assistant message with content (${assistantContent.length} chars)`) + } else { + logger.info(`[${requestId}] No assistant content to save (aborted before response)`) } - const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - // Update chat in database immediately (without title) await db .update(copilotChats) @@ -470,6 +485,8 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Updated chat ${actualChatId} with new messages`, { messageCount: updatedMessages.length, + savedUserMessage: true, + savedAssistantMessage: assistantContent.trim().length > 0, }) } } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx index 32a859c2797..5ac9a8ca1cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-message/professional-message.tsx @@ -33,6 +33,8 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN return case 'rejected': return + case 'aborted': + return case 'error': return default: @@ -54,6 +56,8 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN return 'border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100' case 'rejected': return 'border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-100' + case 'aborted': + return 'border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800 dark:bg-orange-950 dark:text-orange-100' case 'error': return 'border-red-200 bg-red-50 text-red-900 dark:border-red-800 dark:bg-red-950 dark:text-red-100' default: @@ -82,6 +86,8 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN 'border-green-200 bg-gradient-to-r from-green-50 to-emerald-50 dark:border-green-800 dark:from-green-950/50 dark:to-emerald-950/50', tool.state === 'rejected' && 'border-orange-200 bg-gradient-to-r from-orange-50 to-amber-50 dark:border-orange-800 dark:from-orange-950/50 dark:to-amber-950/50', + tool.state === 'aborted' && + 'border-orange-200 bg-gradient-to-r from-orange-50 to-amber-50 dark:border-orange-800 dark:from-orange-950/50 dark:to-amber-950/50', tool.state === 'error' && 'border-red-200 bg-gradient-to-r from-red-50 to-pink-50 dark:border-red-800 dark:from-red-950/50 dark:to-pink-950/50' )} @@ -94,6 +100,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN tool.state === 'ready_for_review' && 'bg-purple-100 dark:bg-purple-900', tool.state === 'applied' && 'bg-green-100 dark:bg-green-900', tool.state === 'rejected' && 'bg-orange-100 dark:bg-orange-900', + tool.state === 'aborted' && 'bg-orange-100 dark:bg-orange-900', tool.state === 'error' && 'bg-red-100 dark:bg-red-900' )} > @@ -109,6 +116,9 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN {tool.state === 'rejected' && ( )} + {tool.state === 'aborted' && ( + + )} {tool.state === 'error' && ( )} @@ -121,6 +131,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN tool.state === 'ready_for_review' && 'text-purple-900 dark:text-purple-100', tool.state === 'applied' && 'text-green-900 dark:text-green-100', tool.state === 'rejected' && 'text-orange-900 dark:text-orange-100', + tool.state === 'aborted' && 'text-orange-900 dark:text-orange-100', tool.state === 'error' && 'text-red-900 dark:text-red-100' )} > @@ -137,6 +148,7 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN tool.state === 'ready_for_review' && 'text-purple-700 dark:text-purple-300', tool.state === 'applied' && 'text-green-700 dark:text-green-300', tool.state === 'rejected' && 'text-orange-700 dark:text-orange-300', + tool.state === 'aborted' && 'text-orange-700 dark:text-orange-300', tool.state === 'error' && 'text-red-700 dark:text-red-300' )} > @@ -150,9 +162,11 @@ function InlineToolCall({ tool, stepNumber }: { tool: ToolCallState | any; stepN ? 'Applied changes' : tool.state === 'rejected' ? 'Rejected changes' - : tool.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW - ? 'Workflow editing failed' - : 'Workflow generation failed'} + : tool.state === 'aborted' + ? 'Aborted' + : tool.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW + ? 'Workflow editing failed' + : 'Workflow generation failed'}
    {tool.duration && diff --git a/apps/sim/components/ui/tool-call.tsx b/apps/sim/components/ui/tool-call.tsx index 4d0099f9394..23448fe7f9a 100644 --- a/apps/sim/components/ui/tool-call.tsx +++ b/apps/sim/components/ui/tool-call.tsx @@ -95,6 +95,7 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp const [isExpanded, setIsExpanded] = useState(false) const isSuccess = toolCall.state === 'completed' const isError = toolCall.state === 'error' + const isAborted = toolCall.state === 'aborted' const formatDuration = (duration?: number) => { if (!duration) return '' @@ -106,7 +107,8 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp className={cn( 'min-w-0 rounded-lg border', isSuccess && 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950', - isError && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950' + isError && 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950', + isAborted && 'border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950' )} > @@ -116,7 +118,8 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp className={cn( 'w-full min-w-0 justify-between px-3 py-4', isSuccess && 'hover:bg-green-100 dark:hover:bg-green-900', - isError && 'hover:bg-red-100 dark:hover:bg-red-900' + isError && 'hover:bg-red-100 dark:hover:bg-red-900', + isAborted && 'hover:bg-orange-100 dark:hover:bg-orange-900' )} >
    @@ -124,11 +127,13 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp )} {isError && } + {isAborted && } {toolCall.displayName || toolCall.name} @@ -139,7 +144,8 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp className={cn( 'shrink-0 text-xs', isSuccess && 'text-green-700 dark:text-green-300', - isError && 'text-red-700 dark:text-red-300' + isError && 'text-red-700 dark:text-red-300', + isAborted && 'text-orange-700 dark:text-orange-300' )} style={{ fontSize: '0.625rem' }} > diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 181cb0bd414..3d216755e08 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -728,22 +728,41 @@ export const useCopilotStore = create()( // Abort the request abortController.abort() - // Find the last streaming message and replace it with an aborted message + // Find the last streaming message and mark any executing tool calls as aborted const lastMessage = messages[messages.length - 1] - if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === '') { - const abortedMessage = createErrorMessage( - lastMessage.id, - 'Message was cancelled. You can continue the conversation below.' - ) + if (lastMessage && lastMessage.role === 'assistant') { + // Mark any executing tool calls as aborted + const updatedToolCalls = lastMessage.toolCalls?.map(toolCall => + toolCall.state === 'executing' + ? { ...toolCall, state: 'aborted' as const, endTime: Date.now() } + : toolCall + ) || [] + + // Update content blocks to reflect aborted tool calls + const updatedContentBlocks = lastMessage.contentBlocks?.map(block => + block.type === 'tool_call' && block.toolCall.state === 'executing' + ? { ...block, toolCall: { ...block.toolCall, state: 'aborted' as const, endTime: Date.now() } } + : block + ) || [] + + const abortedCount = updatedToolCalls.filter(tc => tc.state === 'aborted').length set((state) => ({ messages: state.messages.map((msg) => - msg.id === lastMessage.id ? abortedMessage : msg + msg.id === lastMessage.id + ? { + ...msg, + toolCalls: updatedToolCalls, + contentBlocks: updatedContentBlocks, + } + : msg ), isSendingMessage: false, isAborting: false, abortController: null, })) + + logger.info(`Message streaming aborted successfully. Marked ${abortedCount} tool calls as aborted.`) } else { // No streaming message found, just reset the state set({ @@ -751,9 +770,8 @@ export const useCopilotStore = create()( isAborting: false, abortController: null, }) + logger.info('Message streaming aborted successfully') } - - logger.info('Message streaming aborted successfully') } catch (error) { logger.error('Error during abort:', error) set({ diff --git a/apps/sim/stores/copilot/types.ts b/apps/sim/stores/copilot/types.ts index 7c32fc63021..f754b9af44a 100644 --- a/apps/sim/stores/copilot/types.ts +++ b/apps/sim/stores/copilot/types.ts @@ -16,7 +16,7 @@ export interface CopilotToolCall { name: string displayName: string input: Record - state: 'executing' | 'completed' | 'error' | 'ready_for_review' | 'applied' | 'rejected' + state: 'executing' | 'completed' | 'error' | 'ready_for_review' | 'applied' | 'rejected' | 'aborted' startTime?: number endTime?: number duration?: number diff --git a/apps/sim/types/tool-call.ts b/apps/sim/types/tool-call.ts index bd9d609f928..1e3e02657d3 100644 --- a/apps/sim/types/tool-call.ts +++ b/apps/sim/types/tool-call.ts @@ -3,7 +3,7 @@ export interface ToolCallState { name: string displayName?: string parameters?: Record - state: 'detecting' | 'executing' | 'completed' | 'error' + state: 'detecting' | 'executing' | 'completed' | 'error' | 'aborted' startTime?: number endTime?: number duration?: number From 9860ecde6151f222529f46dcd9970ff5d78bf8eb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 15:19:08 -0700 Subject: [PATCH 097/184] Clean up old copilot code --- apps/sim/app/api/docs/search/route.ts | 52 ++- apps/sim/lib/copilot/provider-bridge.ts | 53 --- apps/sim/lib/copilot/service.ts | 76 ----- apps/sim/providers/anthropic/index.ts | 417 +----------------------- 4 files changed, 54 insertions(+), 544 deletions(-) delete mode 100644 apps/sim/lib/copilot/provider-bridge.ts delete mode 100644 apps/sim/lib/copilot/service.ts diff --git a/apps/sim/app/api/docs/search/route.ts b/apps/sim/app/api/docs/search/route.ts index 28ab0bb9eca..cb6057705fd 100644 --- a/apps/sim/app/api/docs/search/route.ts +++ b/apps/sim/app/api/docs/search/route.ts @@ -1,6 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' -import { searchDocumentation } from '@/lib/copilot/service' import { createLogger } from '@/lib/logs/console-logger' +import { sql } from 'drizzle-orm' +import { db } from '@/db' +import { docsEmbeddings } from '@/db/schema' const logger = createLogger('DocsSearchAPI') @@ -49,9 +51,53 @@ export async function POST( logger.info('Executing documentation search', { query, topK }) const startTime = Date.now() - const results = await searchDocumentation(query, { topK }) - const searchTime = Date.now() - startTime + + // Search documentation using RAG - inlined from copilot service + let results: DocsSearchResult[] = [] + try { + const threshold = 0.7 + + // Generate embedding for the query + const { generateEmbeddings } = await import('@/app/api/knowledge/utils') + const embeddings = await generateEmbeddings([query]) + const queryEmbedding = embeddings[0] + + if (!queryEmbedding || queryEmbedding.length === 0) { + logger.warn('Failed to generate query embedding') + results = [] + } else { + // Search docs embeddings using vector similarity + const dbResults = await db + .select({ + chunkId: docsEmbeddings.chunkId, + chunkText: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, + sourceLink: docsEmbeddings.sourceLink, + headerText: docsEmbeddings.headerText, + headerLevel: docsEmbeddings.headerLevel, + similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, + }) + .from(docsEmbeddings) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) + .limit(topK) + + // Filter by similarity threshold + const filteredResults = dbResults.filter((result) => result.similarity >= threshold) + results = filteredResults.map((result, index) => ({ + id: index + 1, + title: String(result.headerText || 'Untitled Section'), + url: String(result.sourceLink || '#'), + content: String(result.chunkText || ''), + similarity: result.similarity, + })) + } + } catch (error) { + logger.error('Failed to search documentation:', error) + results = [] + } + + const searchTime = Date.now() - startTime logger.info(`Found ${results.length} documentation results`, { query }) const successResponse: DocsSearchSuccessResponse = { diff --git a/apps/sim/lib/copilot/provider-bridge.ts b/apps/sim/lib/copilot/provider-bridge.ts deleted file mode 100644 index f56514f4ce3..00000000000 --- a/apps/sim/lib/copilot/provider-bridge.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Bridge for providers to execute copilot tools without importing server-side dependencies - */ - -import { createLogger } from '@/lib/logs/console-logger' - -const logger = createLogger('CopilotProviderBridge') - -/** - * Execute a copilot tool and return in ToolResponse format for providers - * This function avoids importing server-side dependencies by making an HTTP request - */ -export async function executeCopilotToolForProvider( - toolId: string, - params: Record -): Promise { - try { - // Make an HTTP request to execute the copilot tool - const response = await fetch( - `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/copilot/execute-tool`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - toolId, - params, - }), - } - ) - - if (!response.ok) { - return { - success: false, - error: `Tool execution failed: ${response.status} ${response.statusText}`, - } - } - - const result = await response.json() - return { - success: result.success, - output: result.data, - error: result.error, - } - } catch (error) { - logger.error(`Copilot tool execution failed: ${toolId}`, error) - return { - success: false, - error: `Tool execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - } -} \ No newline at end of file diff --git a/apps/sim/lib/copilot/service.ts b/apps/sim/lib/copilot/service.ts deleted file mode 100644 index 60709c5dbb3..00000000000 --- a/apps/sim/lib/copilot/service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { sql } from 'drizzle-orm' -import { createLogger } from '@/lib/logs/console-logger' -import { db } from '@/db' -import { docsEmbeddings } from '@/db/schema' - -const logger = createLogger('CopilotService') - -/** - * Documentation search result - */ -export interface DocumentationSearchResult { - id: number - title: string - url: string - content: string - similarity: number -} - -/** - * Options for documentation search - */ -export interface SearchDocumentationOptions { - topK?: number - threshold?: number -} - -/** - * Search documentation using RAG - */ -export async function searchDocumentation( - query: string, - options: SearchDocumentationOptions = {} -): Promise { - const { topK = 10, threshold = 0.7 } = options - - try { - // Generate embedding for the query - const { generateEmbeddings } = await import('@/app/api/knowledge/utils') - const embeddings = await generateEmbeddings([query]) - const queryEmbedding = embeddings[0] - - if (!queryEmbedding || queryEmbedding.length === 0) { - logger.warn('Failed to generate query embedding') - return [] - } - - // Search docs embeddings using vector similarity - const results = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - // Filter by similarity threshold - const filteredResults = results.filter((result) => result.similarity >= threshold) - - return filteredResults.map((result, index) => ({ - id: index + 1, - title: String(result.headerText || 'Untitled Section'), - url: String(result.sourceLink || '#'), - content: String(result.chunkText || ''), - similarity: result.similarity, - })) - } catch (error) { - logger.error('Failed to search documentation:', error) - return [] - } -} diff --git a/apps/sim/providers/anthropic/index.ts b/apps/sim/providers/anthropic/index.ts index 54c8bb15b64..4c7da92fb67 100644 --- a/apps/sim/providers/anthropic/index.ts +++ b/apps/sim/providers/anthropic/index.ts @@ -10,9 +10,7 @@ import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants' const logger = createLogger('AnthropicProvider') /** - * Helper to wrap Anthropic streaming (async iterable of SSE events) into a browser-friendly - * ReadableStream of raw assistant text chunks. We enqueue only `content_block_delta` events - * with `delta.type === 'text_delta'`, since that contains the incremental text tokens. + * Helper to wrap Anthropic streaming into a browser-friendly ReadableStream */ function createReadableStreamFromAnthropicStream( anthropicStream: AsyncIterable @@ -33,30 +31,6 @@ function createReadableStreamFromAnthropicStream( }) } -/** - * Helper to create a native SSE stream for copilot that passes through all Anthropic events - * This preserves the native SSE format for better performance and simpler parsing - */ -function createNativeSSEStreamForCopilot(anthropicStream: AsyncIterable): ReadableStream { - return new ReadableStream({ - async start(controller) { - try { - const encoder = new TextEncoder() - - for await (const event of anthropicStream) { - // Pass through the raw Anthropic SSE event - const sseData = `data: ${JSON.stringify(event)}\n\n` - controller.enqueue(encoder.encode(sseData)) - } - - controller.close() - } catch (err) { - controller.error(err) - } - }, - }) -} - export const anthropicProvider: ProviderConfig = { id: 'anthropic', name: 'Anthropic', @@ -356,304 +330,6 @@ ${fieldDescriptions} return streamingResult as StreamingExecution } - // STREAMING WITH INCREMENTAL PARSING: Handle both text and tool calls in real-time - if (request.stream && shouldStreamToolCalls) { - logger.info('Using native SSE streaming for Anthropic copilot request', { - hasTools: !!(anthropicTools && anthropicTools.length > 0), - }) - - // Start execution timer for the entire provider execution - const providerStartTime = Date.now() - const providerStartTimeISO = new Date(providerStartTime).toISOString() - - // Create a streaming request - const streamResponse: any = await anthropic.messages.create({ - ...payload, - stream: true, - }) - - // Create a native SSE stream that passes through Anthropic events directly - const nativeSSEStream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - - // Track conversation state and tool calls - const conversationMessages: any[] = [...(messages || [])] - let pendingToolCalls: any[] = [] - let currentToolCall: any = null - - const executeToolsAndContinue = async (toolCalls: any[]) => { - try { - logger.info(`Executing ${toolCalls.length} tool calls`, { - toolNames: toolCalls.map((tc) => tc.name), - }) - - // Execute all tools in parallel - const toolResults = await Promise.all( - toolCalls.map(async (toolCall) => { - const tool = request.tools?.find((t: any) => t.id === toolCall.name) - if (!tool) { - logger.warn(`Tool not found: ${toolCall.name}`) - return { toolCall, result: null, success: false } - } - - const toolCallStartTime = Date.now() - const mergedArgs = { - ...tool.params, - ...toolCall.input, - ...(request.workflowId - ? { - _context: { - workflowId: request.workflowId, - ...(request.chatId ? { chatId: request.chatId } : {}), - ...(request.userId ? { userId: request.userId } : {}), - }, - } - : {}), - ...(request.environmentVariables - ? { envVars: request.environmentVariables } - : {}), - } - - // Choose tool execution method based on request type - let result - if (request.isCopilotRequest) { - // Use copilot tool system for copilot requests - const { executeCopilotToolForProvider } = await import('@/lib/copilot/provider-bridge') - result = await executeCopilotToolForProvider(toolCall.name, mergedArgs) - } else { - // Use general tool system for regular requests - result = await executeTool(toolCall.name, mergedArgs, true) - } - const toolCallEndTime = Date.now() - - logger.info(`Tool ${toolCall.name} ${result.success ? 'succeeded' : 'failed'}`) - - // Send tool result event to frontend for workflow tools - const toolDisplayName = COPILOT_TOOL_DISPLAY_NAMES[toolCall.name] - const isWorkflowTool = toolDisplayName && - (toolDisplayName.includes('Building') || - toolDisplayName.includes('Updating') || - toolDisplayName.includes('Preview') || - toolDisplayName.includes('Edit')) - - if ( - isWorkflowTool && - result.success - ) { - const toolResultEvent = { - type: 'tool_result', - toolCallId: toolCall.id, - toolName: toolCall.name, - result: result.output, - success: true, - } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(toolResultEvent)}\n\n`) - ) - logger.info(`Sent ${toolCall.name} result to frontend:`, toolCall.id) - } - - return { - toolCall, - result: result.success ? result.output : null, - success: result.success, - } - }) - ) - - // Add tool calls and results to conversation - conversationMessages.push({ - role: 'assistant', - content: toolCalls.map((tc) => ({ - type: 'tool_use', - id: tc.id, - name: tc.name, - input: tc.input, - })) as any, - }) - - conversationMessages.push({ - role: 'user', - content: toolResults - .filter((tr) => tr?.success) - .map((tr) => ({ - type: 'tool_result', - tool_use_id: tr!.toolCall.id, - content: JSON.stringify(tr!.result), - })) as any, - }) - - // Continue the conversation with tool results - const nextStreamResponse = await anthropic.messages.create({ - ...payload, - messages: conversationMessages, - stream: true, - }) - - // Stream the continuation response and handle any additional tool calls - let continuationToolCalls: any[] = [] - let currentContinuationToolCall: any = null - - for await (const chunk of nextStreamResponse as any) { - const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` - controller.enqueue(encoder.encode(sseEvent)) - - // Check if the continuation response has its own tool calls - if ( - chunk.type === 'content_block_start' && - chunk.content_block?.type === 'tool_use' - ) { - currentContinuationToolCall = { - id: chunk.content_block.id, - name: chunk.content_block.name, - input: {}, - partialInput: '', - } - } else if ( - chunk.type === 'content_block_delta' && - currentContinuationToolCall && - chunk.delta?.partial_json - ) { - currentContinuationToolCall.partialInput += chunk.delta.partial_json - } else if (chunk.type === 'content_block_stop' && currentContinuationToolCall) { - try { - currentContinuationToolCall.input = JSON.parse( - currentContinuationToolCall.partialInput || '{}' - ) - continuationToolCalls.push(currentContinuationToolCall) - logger.info(`Continuation tool call ready: ${currentContinuationToolCall.name}`) - } catch (error) { - logger.error('Error parsing continuation tool call input:', error) - } - currentContinuationToolCall = null - } else if (chunk.type === 'message_stop' && continuationToolCalls.length > 0) { - // Recursively handle tool calls in the continuation - await executeToolsAndContinue(continuationToolCalls) - continuationToolCalls = [] - } - - // Also check for any workflow tool results in continuation - continuationToolCalls.forEach((toolCall) => { - const toolDisplayName = COPILOT_TOOL_DISPLAY_NAMES[toolCall.name] - const isWorkflowTool = toolDisplayName && - (toolDisplayName.includes('Building') || - toolDisplayName.includes('Updating') || - toolDisplayName.includes('Preview') || - toolDisplayName.includes('Edit')) - - if (isWorkflowTool) { - logger.info( - `Found ${toolCall.name} in continuation, will send result after execution` - ) - } - }) - } - } catch (error) { - logger.error('Error executing tools and continuing conversation:', { error }) - // Send error event - const errorEvent = { - type: 'error', - error: 'Tool execution failed', - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`)) - } - } - - try { - for await (const chunk of streamResponse) { - // Pass through the SSE event - const sseEvent = `data: ${JSON.stringify(chunk)}\n\n` - controller.enqueue(encoder.encode(sseEvent)) - - // Track tool calls for execution - if ( - chunk.type === 'content_block_start' && - chunk.content_block?.type === 'tool_use' - ) { - currentToolCall = { - id: chunk.content_block.id, - name: chunk.content_block.name, - input: {}, - partialInput: '', - } - } else if ( - chunk.type === 'content_block_delta' && - currentToolCall && - chunk.delta?.partial_json - ) { - currentToolCall.partialInput += chunk.delta.partial_json - } else if (chunk.type === 'content_block_stop' && currentToolCall) { - try { - // Parse complete tool call input - currentToolCall.input = JSON.parse(currentToolCall.partialInput || '{}') - pendingToolCalls.push(currentToolCall) - logger.info(`Tool call ready: ${currentToolCall.name}`, currentToolCall.input) - } catch (error) { - logger.error('Error parsing tool call input:', error) - } - currentToolCall = null - } else if (chunk.type === 'message_stop') { - // If there are pending tool calls, execute them and continue - if (pendingToolCalls.length > 0) { - await executeToolsAndContinue(pendingToolCalls) - pendingToolCalls = [] - } - break - } - } - controller.close() - } catch (error) { - logger.error('Error in native SSE streaming:', { error }) - controller.error(error) - } - }, - }) - - // Create the streaming result - const streamingResult = { - stream: nativeSSEStream, - execution: { - success: true, - output: { - content: '', // Will be filled by streaming content - model: request.model, - tokens: { prompt: 0, completion: 0, total: 0 }, - toolCalls: undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - timeSegments: [ - { - type: 'model', - name: 'Native SSE streaming', - startTime: providerStartTime, - endTime: Date.now(), - duration: Date.now() - providerStartTime, - }, - ], - }, - cost: { - total: 0.0, - input: 0.0, - output: 0.0, - }, - }, - logs: [], - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } - - // Return the streaming execution object - return streamingResult as StreamingExecution - } - // NON-STREAMING WITH FINAL RESPONSE: Execute all tools silently and return only final response if (request.stream && !shouldStreamToolCalls) { logger.info('Using non-streaming mode for Anthropic request (tool calls executed silently)') @@ -804,16 +480,8 @@ ${fieldDescriptions} : {}), } - // Choose tool execution method based on request type - let result - if (request.isCopilotRequest) { - // Use copilot tool system for copilot requests - const { executeCopilotToolForProvider } = await import('@/lib/copilot/provider-bridge') - result = await executeCopilotToolForProvider(toolName, executionParams) - } else { - // Use general tool system for regular requests - result = await executeTool(toolName, executionParams, true) - } + // Use general tool system for requests + const result = await executeTool(toolName, executionParams, true) const toolCallEndTime = Date.now() const toolCallDuration = toolCallEndTime - toolCallStartTime @@ -986,74 +654,7 @@ ${fieldDescriptions} const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime - // For non-streaming mode with tools, we stream only the final response - if (iterationCount > 0) { - logger.info( - 'Using streaming for final Anthropic response after tool calls (non-streaming mode)' - ) - // When streaming after tool calls with forced tools, make sure tool_choice is removed - // This prevents the API from trying to force tool usage again in the final streaming response - const streamingPayload = { - ...payload, - messages: currentMessages, - // For Anthropic, omit tool_choice entirely rather than setting it to 'none' - stream: true, - } - - // Remove the tool_choice parameter as Anthropic doesn't accept 'none' as a string value - streamingPayload.tool_choice = undefined - - const streamResponse: any = await anthropic.messages.create(streamingPayload) - - // Create a StreamingExecution response with all collected data - const streamingResult = { - stream: createReadableStreamFromAnthropicStream(streamResponse), - execution: { - success: true, - output: { - content: '', // Will be filled by the callback - model: request.model || 'claude-3-7-sonnet-20250219', - tokens: { - prompt: tokens.prompt, - completion: tokens.completion, - total: tokens.total, - }, - toolCalls: - toolCalls.length > 0 - ? { - list: toolCalls, - count: toolCalls.length, - } - : undefined, - providerTiming: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - modelTime: modelTime, - toolsTime: toolsTime, - firstResponseTime: firstResponseTime, - iterations: iterationCount + 1, - timeSegments: timeSegments, - }, - cost: { - total: (tokens.total || 0) * 0.0001, // Estimate cost based on tokens - input: (tokens.prompt || 0) * 0.0001, - output: (tokens.completion || 0) * 0.0001, - }, - }, - logs: [], // No block logs at provider level - metadata: { - startTime: providerStartTimeISO, - endTime: new Date().toISOString(), - duration: Date.now() - providerStartTime, - }, - isStreaming: true, - }, - } - - return streamingResult as StreamingExecution - } // If no tool calls were made, return a direct response return { @@ -1250,16 +851,8 @@ ${fieldDescriptions} ...(request.environmentVariables ? { envVars: request.environmentVariables } : {}), } - // Choose tool execution method based on request type - let result - if (request.isCopilotRequest) { - // Use copilot tool system for copilot requests - const { executeCopilotToolForProvider } = await import('@/lib/copilot/provider-bridge') - result = await executeCopilotToolForProvider(toolName, executionParams) - } else { - // Use general tool system for regular requests - result = await executeTool(toolName, executionParams, true) - } + // Use general tool system for requests + const result = await executeTool(toolName, executionParams, true) const toolCallEndTime = Date.now() const toolCallDuration = toolCallEndTime - toolCallStartTime From 0d5d38d758ac3cd5a75ca27199f3564e545b6210 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 15:32:00 -0700 Subject: [PATCH 098/184] Refactor tool names --- ...docs-search-internal.ts => search-docs.ts} | 8 +- apps/sim/app/api/copilot/tools/registry.ts | 12 +- ...{preview-workflow.ts => build-workflow.ts} | 16 +-- .../{targeted-updates.ts => edit-workflow.ts} | 22 ++-- apps/sim/app/api/docs/search/route.ts | 122 ------------------ apps/sim/lib/tool-call-parser.ts | 2 - 6 files changed, 29 insertions(+), 153 deletions(-) rename apps/sim/app/api/copilot/tools/docs/{docs-search-internal.ts => search-docs.ts} (93%) rename apps/sim/app/api/copilot/tools/workflow/{preview-workflow.ts => build-workflow.ts} (75%) rename apps/sim/app/api/copilot/tools/workflow/{targeted-updates.ts => edit-workflow.ts} (94%) delete mode 100644 apps/sim/app/api/docs/search/route.ts delete mode 100644 apps/sim/lib/tool-call-parser.ts diff --git a/apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts b/apps/sim/app/api/copilot/tools/docs/search-docs.ts similarity index 93% rename from apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts rename to apps/sim/app/api/copilot/tools/docs/search-docs.ts index e372edf71ba..6401c2a51cf 100644 --- a/apps/sim/app/api/copilot/tools/docs/docs-search-internal.ts +++ b/apps/sim/app/api/copilot/tools/docs/search-docs.ts @@ -25,20 +25,20 @@ interface DocsSearchResult { totalResults: number } -class DocsSearchInternalTool extends BaseCopilotTool { +class SearchDocsTool extends BaseCopilotTool { readonly id = 'search_documentation' readonly displayName = 'Searching documentation' protected async executeImpl(params: DocsSearchParams): Promise { - return docsSearch(params) + return searchDocs(params) } } // Export the tool instance -export const docsSearchInternalTool = new DocsSearchInternalTool() +export const searchDocsTool = new SearchDocsTool() // Implementation function -async function docsSearch(params: DocsSearchParams): Promise { +async function searchDocs(params: DocsSearchParams): Promise { const logger = createLogger('DocsSearch') const { query, topK = 10, threshold } = params diff --git a/apps/sim/app/api/copilot/tools/registry.ts b/apps/sim/app/api/copilot/tools/registry.ts index 21ff3f69560..be4258ac729 100644 --- a/apps/sim/app/api/copilot/tools/registry.ts +++ b/apps/sim/app/api/copilot/tools/registry.ts @@ -5,14 +5,14 @@ import { getBlocksAndToolsTool } from './blocks/get-blocks-and-tools' import { getBlocksMetadataTool } from './blocks/get-blocks-metadata' import { getWorkflowExamplesTool } from './blocks/get-workflow-examples' import { getYamlStructureTool } from './blocks/get-yaml-structure' -import { docsSearchInternalTool } from './docs/docs-search-internal' +import { searchDocsTool } from './docs/search-docs' import { onlineSearchTool } from './other/online-search' import { getEnvironmentVariablesTool } from './user/get-environment-variables' import { setEnvironmentVariablesTool } from './user/set-environment-variables' import { getUserWorkflowTool } from './workflow/get-user-workflow' -import { previewWorkflowTool } from './workflow/preview-workflow' +import { buildWorkflowTool } from './workflow/build-workflow' import { getWorkflowConsoleTool } from './workflow/get-workflow-console' -import { targetedUpdatesTool } from './workflow/targeted-updates' +import { editWorkflowTool } from './workflow/edit-workflow' // Registry of all copilot tools export class CopilotToolRegistry { @@ -90,14 +90,14 @@ copilotToolRegistry.register(getBlocksAndToolsTool) copilotToolRegistry.register(getBlocksMetadataTool) copilotToolRegistry.register(getWorkflowExamplesTool) copilotToolRegistry.register(getYamlStructureTool) -copilotToolRegistry.register(docsSearchInternalTool) +copilotToolRegistry.register(searchDocsTool) copilotToolRegistry.register(onlineSearchTool) copilotToolRegistry.register(getEnvironmentVariablesTool) copilotToolRegistry.register(setEnvironmentVariablesTool) copilotToolRegistry.register(getUserWorkflowTool) -copilotToolRegistry.register(previewWorkflowTool) +copilotToolRegistry.register(buildWorkflowTool) copilotToolRegistry.register(getWorkflowConsoleTool) -copilotToolRegistry.register(targetedUpdatesTool) +copilotToolRegistry.register(editWorkflowTool) // Dynamically generated constants - single source of truth export const COPILOT_TOOL_IDS = copilotToolRegistry.getAvailableIds() diff --git a/apps/sim/app/api/copilot/tools/workflow/preview-workflow.ts b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts similarity index 75% rename from apps/sim/app/api/copilot/tools/workflow/preview-workflow.ts rename to apps/sim/app/api/copilot/tools/workflow/build-workflow.ts index 4c93ecbbd24..f1ca68af846 100644 --- a/apps/sim/app/api/copilot/tools/workflow/preview-workflow.ts +++ b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts @@ -1,31 +1,31 @@ import { createLogger } from '@/lib/logs/console-logger' import { BaseCopilotTool } from '../base' -interface PreviewWorkflowParams { +interface BuildWorkflowParams { yamlContent: string description?: string } -interface PreviewWorkflowResult { +interface BuildWorkflowResult { yamlContent: string description?: string [key: string]: any // For the preview data fields } -class PreviewWorkflowTool extends BaseCopilotTool { +class BuildWorkflowTool extends BaseCopilotTool { readonly id = 'build_workflow' - readonly displayName = 'Preview workflow changes' + readonly displayName = 'Building workflow' - protected async executeImpl(params: PreviewWorkflowParams): Promise { - return previewWorkflow(params) + protected async executeImpl(params: BuildWorkflowParams): Promise { + return buildWorkflow(params) } } // Export the tool instance -export const previewWorkflowTool = new PreviewWorkflowTool() +export const buildWorkflowTool = new BuildWorkflowTool() // Implementation function -async function previewWorkflow(params: PreviewWorkflowParams): Promise { +async function buildWorkflow(params: BuildWorkflowParams): Promise { const logger = createLogger('PreviewWorkflow') const { yamlContent, description } = params diff --git a/apps/sim/app/api/copilot/tools/workflow/targeted-updates.ts b/apps/sim/app/api/copilot/tools/workflow/edit-workflow.ts similarity index 94% rename from apps/sim/app/api/copilot/tools/workflow/targeted-updates.ts rename to apps/sim/app/api/copilot/tools/workflow/edit-workflow.ts index 17e0a2b82d0..e7949ad06f2 100644 --- a/apps/sim/app/api/copilot/tools/workflow/targeted-updates.ts +++ b/apps/sim/app/api/copilot/tools/workflow/edit-workflow.ts @@ -1,9 +1,9 @@ import { createLogger } from '@/lib/logs/console-logger' -const logger = createLogger('TargetedUpdatesAPI') +const logger = createLogger('EditWorkflowAPI') // Types for operations -interface TargetedUpdateOperation { +interface EditWorkflowOperation { operation_type: 'add' | 'edit' | 'delete' block_id: string params?: Record @@ -14,7 +14,7 @@ interface TargetedUpdateOperation { */ async function applyOperationsToYaml( currentYaml: string, - operations: TargetedUpdateOperation[] + operations: EditWorkflowOperation[] ): Promise { const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') const yaml = await import('yaml') @@ -228,30 +228,30 @@ async function applyOperationsToYaml( import { BaseCopilotTool } from '../base' -interface TargetedUpdatesParams { - operations: TargetedUpdateOperation[] +interface EditWorkflowParams { + operations: EditWorkflowOperation[] workflowId: string } -interface TargetedUpdatesResult { +interface EditWorkflowResult { yamlContent: string operations: Array<{ type: string; blockId: string }> } -class TargetedUpdatesTool extends BaseCopilotTool { +class EditWorkflowTool extends BaseCopilotTool { readonly id = 'edit_workflow' readonly displayName = 'Updating workflow' - protected async executeImpl(params: TargetedUpdatesParams): Promise { - return targetedUpdates(params) + protected async executeImpl(params: EditWorkflowParams): Promise { + return editWorkflow(params) } } // Export the tool instance -export const targetedUpdatesTool = new TargetedUpdatesTool() +export const editWorkflowTool = new EditWorkflowTool() // Implementation function -async function targetedUpdates(params: TargetedUpdatesParams): Promise { +async function editWorkflow(params: EditWorkflowParams): Promise { const { operations, workflowId } = params logger.info('Processing targeted update request', { diff --git a/apps/sim/app/api/docs/search/route.ts b/apps/sim/app/api/docs/search/route.ts deleted file mode 100644 index cb6057705fd..00000000000 --- a/apps/sim/app/api/docs/search/route.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console-logger' -import { sql } from 'drizzle-orm' -import { db } from '@/db' -import { docsEmbeddings } from '@/db/schema' - -const logger = createLogger('DocsSearchAPI') - -// Request and response type definitions -interface DocsSearchRequest { - query: string - topK?: number -} - -interface DocsSearchResult { - id: number - title: string - url: string - content: string - similarity: number -} - -interface DocsSearchSuccessResponse { - success: true - results: DocsSearchResult[] - query: string - totalResults: number - searchTime?: number -} - -interface DocsSearchErrorResponse { - success: false - error: string -} - -export async function POST( - request: NextRequest -): Promise> { - try { - const requestBody: DocsSearchRequest = await request.json() - const { query, topK = 10 } = requestBody - - if (!query) { - const errorResponse: DocsSearchErrorResponse = { - success: false, - error: 'Query is required', - } - return NextResponse.json(errorResponse, { status: 400 }) - } - - logger.info('Executing documentation search', { query, topK }) - - const startTime = Date.now() - - // Search documentation using RAG - inlined from copilot service - let results: DocsSearchResult[] = [] - try { - const threshold = 0.7 - - // Generate embedding for the query - const { generateEmbeddings } = await import('@/app/api/knowledge/utils') - const embeddings = await generateEmbeddings([query]) - const queryEmbedding = embeddings[0] - - if (!queryEmbedding || queryEmbedding.length === 0) { - logger.warn('Failed to generate query embedding') - results = [] - } else { - // Search docs embeddings using vector similarity - const dbResults = await db - .select({ - chunkId: docsEmbeddings.chunkId, - chunkText: docsEmbeddings.chunkText, - sourceDocument: docsEmbeddings.sourceDocument, - sourceLink: docsEmbeddings.sourceLink, - headerText: docsEmbeddings.headerText, - headerLevel: docsEmbeddings.headerLevel, - similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector)`, - }) - .from(docsEmbeddings) - .orderBy(sql`${docsEmbeddings.embedding} <=> ${JSON.stringify(queryEmbedding)}::vector`) - .limit(topK) - - // Filter by similarity threshold - const filteredResults = dbResults.filter((result) => result.similarity >= threshold) - - results = filteredResults.map((result, index) => ({ - id: index + 1, - title: String(result.headerText || 'Untitled Section'), - url: String(result.sourceLink || '#'), - content: String(result.chunkText || ''), - similarity: result.similarity, - })) - } - } catch (error) { - logger.error('Failed to search documentation:', error) - results = [] - } - - const searchTime = Date.now() - startTime - logger.info(`Found ${results.length} documentation results`, { query }) - - const successResponse: DocsSearchSuccessResponse = { - success: true, - results, - query, - totalResults: results.length, - searchTime, - } - - return NextResponse.json(successResponse) - } catch (error) { - logger.error('Documentation search API failed', error) - - const errorResponse: DocsSearchErrorResponse = { - success: false, - error: `Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, - } - - return NextResponse.json(errorResponse, { status: 500 }) - } -} diff --git a/apps/sim/lib/tool-call-parser.ts b/apps/sim/lib/tool-call-parser.ts deleted file mode 100644 index 4e282d0e184..00000000000 --- a/apps/sim/lib/tool-call-parser.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This file has been removed - tool call parsing is now handled natively via SSE events -// Tool calls are stored directly in message.toolCalls array and rendered via React components From d23bf40bb12b996fadfe08977d7f17b2f2174240 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 15:51:52 -0700 Subject: [PATCH 099/184] Checkpoint --- apps/sim/app/api/copilot/tools/registry.ts | 2 +- .../copilot/tools/workflow/build-workflow.ts | 130 +++-- apps/sim/app/api/workflows/preview/route.ts | 346 ------------- .../copilot-sandbox-modal.tsx | 446 ----------------- .../copilot-modal/copilot-modal.tsx | 331 ------------- .../components/copilot/copilot-modal.tsx | 0 .../panel/components/copilot/copilot.tsx | 61 +-- .../w/[workflowId]/components/panel/panel.tsx | 6 +- .../[workflowId]/components/review-button.tsx | 458 ------------------ .../[workflowId]/hooks/use-copilot-sandbox.ts | 181 ------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 7 - 11 files changed, 107 insertions(+), 1861 deletions(-) delete mode 100644 apps/sim/app/api/workflows/preview/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot-modal.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts diff --git a/apps/sim/app/api/copilot/tools/registry.ts b/apps/sim/app/api/copilot/tools/registry.ts index be4258ac729..75dd28f245f 100644 --- a/apps/sim/app/api/copilot/tools/registry.ts +++ b/apps/sim/app/api/copilot/tools/registry.ts @@ -103,4 +103,4 @@ copilotToolRegistry.register(editWorkflowTool) export const COPILOT_TOOL_IDS = copilotToolRegistry.getAvailableIds() // Export the type from shared constants -export type { CopilotToolId } \ No newline at end of file +export type { CopilotToolId } from '@/stores/constants' \ No newline at end of file diff --git a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts index f1ca68af846..ab2eca3a91a 100644 --- a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts +++ b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts @@ -9,7 +9,13 @@ interface BuildWorkflowParams { interface BuildWorkflowResult { yamlContent: string description?: string - [key: string]: any // For the preview data fields + success: boolean + message: string + workflowState?: any + data?: { + blocksCount: number + edgesCount: number + } } class BuildWorkflowTool extends BaseCopilotTool { @@ -24,48 +30,108 @@ class BuildWorkflowTool extends BaseCopilotTool { - const logger = createLogger('PreviewWorkflow') + const logger = createLogger('BuildWorkflow') const { yamlContent, description } = params - logger.info('Generating workflow preview for copilot', { + logger.info('Building workflow for copilot', { yamlLength: yamlContent.length, description, }) - // Forward the request to the existing workflow preview endpoint - const previewUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/workflows/preview` - - const response = await fetch(previewUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent, - applyAutoLayout: true, - }), - }) + try { + // Import the necessary functions dynamically to avoid import issues + const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') + const { convertYamlToWorkflow } = await import('@/stores/workflows/yaml/importer') + + // Parse YAML content + const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) + + if (!yamlWorkflow || parseErrors.length > 0) { + logger.error('YAML parsing failed', { parseErrors }) + return { + success: false, + message: `Failed to parse YAML workflow: ${parseErrors.join(', ')}`, + yamlContent, + description, + } + } + + // Convert YAML to workflow format + const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) + + if (convertErrors.length > 0) { + logger.error('YAML conversion failed', { convertErrors }) + return { + success: false, + message: `Failed to convert YAML to workflow: ${convertErrors.join(', ')}`, + yamlContent, + description, + } + } + + // Create a basic workflow state structure + const workflowState = { + blocks: {} as Record, + edges: [] as any[], + loops: {} as Record, + parallels: {} as Record, + lastSaved: Date.now(), + isDeployed: false, + } - if (!response.ok) { - logger.error('Workflow preview API failed', { - status: response.status, - statusText: response.statusText + // Process blocks with unique IDs + const blockIdMapping = new Map() + + Object.keys(blocks).forEach((blockId) => { + const previewId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 7)}` + blockIdMapping.set(blockId, previewId) }) - throw new Error('Workflow preview generation failed') - } - const previewData = await response.json() + // Add blocks to workflow state + for (const [originalBlockId, blockData] of Object.entries(blocks)) { + const previewBlockId = blockIdMapping.get(originalBlockId)! + + workflowState.blocks[previewBlockId] = { + ...blockData, + id: previewBlockId, + position: (blockData as any).position || { x: 0, y: 0 }, + enabled: true, + } + } - if (!previewData.success) { - throw new Error(`Preview generation failed: ${previewData.message || 'Unknown error'}`) - } + // Process edges with updated block IDs + workflowState.edges = edges.map((edge: any) => ({ + ...edge, + id: `edge-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, + source: blockIdMapping.get(edge.source) || edge.source, + target: blockIdMapping.get(edge.target) || edge.target, + })) - // Return in the format expected by the copilot for diff functionality - return { - ...previewData, - yamlContent, // Include the original YAML for diff functionality - description, + const blocksCount = Object.keys(workflowState.blocks).length + const edgesCount = workflowState.edges.length + + logger.info('Workflow built successfully', { blocksCount, edgesCount }) + + return { + success: true, + message: `Successfully built workflow with ${blocksCount} blocks and ${edgesCount} connections`, + yamlContent, + description: description || 'Built workflow', + workflowState, + data: { + blocksCount, + edgesCount, + }, + } + } catch (error) { + logger.error('Failed to build workflow:', error) + return { + success: false, + message: `Workflow build failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + yamlContent, + description, + } } } \ No newline at end of file diff --git a/apps/sim/app/api/workflows/preview/route.ts b/apps/sim/app/api/workflows/preview/route.ts deleted file mode 100644 index 870b7eabe50..00000000000 --- a/apps/sim/app/api/workflows/preview/route.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { autoLayoutWorkflow } from '@/lib/autolayout/service' -import { createLogger } from '@/lib/logs/console-logger' -import { getBlock } from '@/blocks' -import { resolveOutputType } from '@/blocks/utils' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer' - -const logger = createLogger('WorkflowPreviewAPI') - -// Request schema for workflow preview operations -const WorkflowPreviewRequestSchema = z.object({ - yamlContent: z.string().min(1, 'YAML content is required'), - applyAutoLayout: z.boolean().default(true), -}) - -type WorkflowPreviewRequest = z.infer - -/** - * POST /api/workflows/preview - * Generate a workflow preview from YAML content without saving to database - * This is used by the copilot sandbox to show workflow previews - */ -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - const startTime = Date.now() - - try { - // Parse and validate request - const body = await request.json() - const { yamlContent, applyAutoLayout } = WorkflowPreviewRequestSchema.parse(body) - - logger.info(`[${requestId}] Processing workflow preview request`, { - yamlLength: yamlContent.length, - applyAutoLayout, - }) - - // Parse YAML content - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) - - if (!yamlWorkflow || parseErrors.length > 0) { - logger.error(`[${requestId}] YAML parsing failed`, { parseErrors }) - return NextResponse.json({ - success: false, - message: 'Failed to parse YAML workflow', - errors: parseErrors, - warnings: [], - }) - } - - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - logger.error(`[${requestId}] YAML conversion failed`, { convertErrors }) - return NextResponse.json({ - success: false, - message: 'Failed to convert YAML to workflow', - errors: convertErrors, - warnings, - }) - } - - // Create workflow state for preview - const previewWorkflowState: any = { - blocks: {} as Record, - edges: [] as any[], - loops: {} as Record, - parallels: {} as Record, - lastSaved: Date.now(), - isDeployed: false, - deployedAt: undefined, - deploymentStatuses: {} as Record, - hasActiveSchedule: false, - hasActiveWebhook: false, - } - - // Process blocks and assign preview IDs - const blockIdMapping = new Map() - - for (const block of blocks) { - const newId = crypto.randomUUID() - blockIdMapping.set(block.id, newId) - - // Handle different block types - if (block.type === 'loop') { - const loopBlocks = generateLoopBlocks({ [newId]: block } as any) - previewWorkflowState.loops = { ...previewWorkflowState.loops, ...loopBlocks } - - // Get block config and populate subBlocks with YAML input values - const blockConfig = getBlock(block.type) - const subBlocks: Record = {} - - if (blockConfig) { - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - const yamlValue = block.inputs[subBlock.id] - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) - - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - } - }) - } - - previewWorkflowState.blocks[newId] = { - id: newId, - type: 'loop', - name: block.name, - position: block.position || { x: 0, y: 0 }, - subBlocks, - outputs: {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - } - } else if (block.type === 'parallel') { - const parallelBlocks = generateParallelBlocks({ [newId]: block } as any) - previewWorkflowState.parallels = { ...previewWorkflowState.parallels, ...parallelBlocks } - - // Get block config and populate subBlocks with YAML input values - const blockConfig = getBlock(block.type) - const subBlocks: Record = {} - - if (blockConfig) { - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - const yamlValue = block.inputs[subBlock.id] - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) - - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - } - }) - } - - previewWorkflowState.blocks[newId] = { - id: newId, - type: 'parallel', - name: block.name, - position: block.position || { x: 0, y: 0 }, - subBlocks, - outputs: {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - } - } else { - // Handle regular blocks - const blockConfig = getBlock(block.type) - if (blockConfig) { - const subBlocks: Record = {} - - // Set up subBlocks from block configuration - blockConfig.subBlocks.forEach((subBlock) => { - // Use the actual value from YAML inputs if available - const yamlValue = block.inputs[subBlock.id] - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) - - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: block.inputs[inputKey], - } - } - }) - - // Set up outputs from block configuration - const outputs = resolveOutputType(blockConfig.outputs) - - previewWorkflowState.blocks[newId] = { - id: newId, - type: block.type, - name: block.name, - position: block.position || { x: 0, y: 0 }, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - } - - logger.debug(`[${requestId}] Processed regular block: ${block.id} -> ${newId}`) - } else { - logger.warn(`[${requestId}] Unknown block type: ${block.type}`) - } - } - } - - // Process edges with mapped IDs - for (const edge of edges) { - const sourceId = blockIdMapping.get(edge.source) - const targetId = blockIdMapping.get(edge.target) - - if (sourceId && targetId) { - const newEdgeId = crypto.randomUUID() - previewWorkflowState.edges.push({ - id: newEdgeId, - source: sourceId, - target: targetId, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - }) - } else { - logger.warn( - `[${requestId}] Skipping edge - missing blocks: ${edge.source} -> ${edge.target}` - ) - } - } - - // Generate loop and parallel configurations - const loops = generateLoopBlocks(previewWorkflowState.blocks) - const parallels = generateParallelBlocks(previewWorkflowState.blocks) - previewWorkflowState.loops = loops - previewWorkflowState.parallels = parallels - - logger.info(`[${requestId}] Generated preview workflow state`, { - blocksCount: Object.keys(previewWorkflowState.blocks).length, - edgesCount: previewWorkflowState.edges.length, - loopsCount: Object.keys(loops).length, - parallelsCount: Object.keys(parallels).length, - }) - - // Apply intelligent autolayout if requested - if (applyAutoLayout) { - try { - logger.info(`[${requestId}] Applying autolayout to preview`) - - const layoutedBlocks = await autoLayoutWorkflow( - previewWorkflowState.blocks, - previewWorkflowState.edges, - { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, - vertical: 400, - layer: 700, - }, - alignment: 'center', - padding: { - x: 250, - y: 250, - }, - } - ) - - previewWorkflowState.blocks = layoutedBlocks - logger.info(`[${requestId}] Autolayout completed successfully for preview`) - } catch (layoutError) { - logger.warn( - `[${requestId}] Autolayout failed for preview, using original positions:`, - layoutError - ) - } - } - - const elapsed = Date.now() - startTime - const totalBlocksInWorkflow = Object.keys(previewWorkflowState.blocks).length - const summary = `Successfully generated preview with ${totalBlocksInWorkflow} blocks and ${previewWorkflowState.edges.length} connections.` - - logger.info(`[${requestId}] Workflow preview completed in ${elapsed}ms`, { - success: true, - blocksCount: totalBlocksInWorkflow, - edgesCount: previewWorkflowState.edges.length, - }) - - return NextResponse.json({ - success: true, - message: 'Workflow preview generated successfully', - summary, - workflowState: previewWorkflowState, - data: { - blocksCount: totalBlocksInWorkflow, - edgesCount: previewWorkflowState.edges.length, - loopsCount: Object.keys(loops).length, - parallelsCount: Object.keys(parallels).length, - }, - errors: [], - warnings, - }) - } catch (error) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] Workflow preview failed in ${elapsed}ms:`, error) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { - success: false, - message: 'Invalid request data', - errors: error.errors.map((e) => `${e.path.join('.')}: ${e.message}`), - warnings: [], - }, - { status: 400 } - ) - } - - return NextResponse.json( - { - success: false, - message: `Failed to generate workflow preview: ${error instanceof Error ? error.message : 'Unknown error'}`, - errors: [error instanceof Error ? error.message : 'Unknown error'], - warnings: [], - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx deleted file mode 100644 index cb03bfc5617..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/copilot-sandbox-modal/copilot-sandbox-modal.tsx +++ /dev/null @@ -1,446 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - AlertCircle, - CheckCircle, - ChevronDown, - Edit, - Eye, - Maximize2, - Minimize2, - Plus, - Save, - Trash2, - X, - XCircle, -} from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { createLogger } from '@/lib/logs/console-logger' -import { cn } from '@/lib/utils' -import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('CopilotSandboxModal') - -interface DiffInfo { - deleted_blocks: string[] - edited_blocks: string[] - new_blocks: string[] -} - -interface CopilotSandboxModalProps { - isOpen: boolean - onClose: () => void - proposedWorkflowState: WorkflowState | null - yamlContent: string - description?: string - diffInfo?: DiffInfo | null - isDiffLoading?: boolean - onApplyToCurrentWorkflow: () => Promise - onSaveAsNewWorkflow: (name: string) => Promise - onReject?: () => Promise - isProcessing?: boolean -} - -export function CopilotSandboxModal({ - isOpen, - onClose, - proposedWorkflowState, - yamlContent, - description, - diffInfo, - isDiffLoading = false, - onApplyToCurrentWorkflow, - onSaveAsNewWorkflow, - onReject, - isProcessing = false, -}: CopilotSandboxModalProps) { - const [isFullscreen, setIsFullscreen] = useState(false) - const [saveAsNewMode, setSaveAsNewMode] = useState(false) - const [isSaving, setIsSaving] = useState(false) - const [isApplying, setIsApplying] = useState(false) - const [isRejecting, setIsRejecting] = useState(false) - - const { workflows, activeWorkflowId } = useWorkflowRegistry() - const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null - - const toggleFullscreen = () => { - setIsFullscreen(!isFullscreen) - } - - const handleApplyToCurrentWorkflow = async () => { - try { - setIsApplying(true) - await onApplyToCurrentWorkflow() - onClose() - } catch (error) { - logger.error('Failed to apply workflow changes:', error) - } finally { - setIsApplying(false) - } - } - - const handleSaveAsNewWorkflow = async () => { - try { - setIsSaving(true) - // Generate auto name based on description or use default - const autoName = description - ? `${description.slice(0, 50)}${description.length > 50 ? '...' : ''}` - : 'Copilot Generated Workflow' - await onSaveAsNewWorkflow(autoName) - onClose() - } catch (error) { - logger.error('Failed to save as new workflow:', error) - } finally { - setIsSaving(false) - } - } - - const handleReject = async () => { - if (!onReject) { - handleClose() - return - } - - try { - setIsRejecting(true) - await onReject() - onClose() - } catch (error) { - logger.error('Failed to reject workflow:', error) - } finally { - setIsRejecting(false) - } - } - - const handleClose = () => { - setSaveAsNewMode(false) - onClose() - } - - if (!proposedWorkflowState) { - return null - } - - const blockCount = Object.keys(proposedWorkflowState.blocks || {}).length - const edgeCount = proposedWorkflowState.edges?.length || 0 - - // Debug logging - console.log('CopilotSandboxModal rendering with props:', { - diffInfo: diffInfo ? 'present' : 'null', - isDiffLoading, - isOpen, - proposedWorkflowState: proposedWorkflowState ? 'present' : 'null', - }) - - // Helper function to get block name from ID - const getBlockName = (blockId: string): string => { - const block = proposedWorkflowState.blocks?.[blockId] - return block?.name || blockId - } - - return ( - - - {/* Header */} - -
    -
    - -
    -
    - - Workflow Preview - Copilot Proposal - -
    - {description && ( - {description} - )} - - {blockCount} blocks, {edgeCount} connections - - - Sandbox - -
    -
    -
    - -
    - - -
    -
    - - {/* Diff Information Section - Always Rendered */} -
    -
    -

    - Workflow Changes - - (Debug: diffInfo={diffInfo ? 'present' : 'null'}, loading= - {isDiffLoading ? 'true' : 'false'}) - -

    - - {isDiffLoading ? ( -
    -
    - Analyzing workflow changes... -
    - ) : diffInfo ? ( - <> -
    - {/* New Blocks */} - {diffInfo.new_blocks.length > 0 && ( -
    -
    - - - New Blocks ({diffInfo.new_blocks.length}) - -
    -
    - {diffInfo.new_blocks.map((blockId) => ( - - {getBlockName(blockId)} - - ))} -
    -
    - )} - - {/* Edited Blocks */} - {diffInfo.edited_blocks.length > 0 && ( -
    -
    - - - Modified Blocks ({diffInfo.edited_blocks.length}) - -
    -
    - {diffInfo.edited_blocks.map((blockId) => ( - - {getBlockName(blockId)} - - ))} -
    -
    - )} - - {/* Deleted Blocks */} - {diffInfo.deleted_blocks.length > 0 && ( -
    -
    - - - Deleted Blocks ({diffInfo.deleted_blocks.length}) - -
    -
    - {diffInfo.deleted_blocks.map((blockId) => ( - - {blockId} - - ))} -
    -
    - )} -
    - - {/* Summary */} - {diffInfo.new_blocks.length > 0 || - diffInfo.edited_blocks.length > 0 || - diffInfo.deleted_blocks.length > 0 ? ( -
    - {diffInfo.new_blocks.length + - diffInfo.edited_blocks.length + - diffInfo.deleted_blocks.length}{' '} - total changes detected -
    - ) : ( -
    - - No changes detected - workflow appears to be identical -
    - )} - - ) : ( -
    -
    - Unable to analyze workflow changes - comparing against current workflow structure -
    -
    - Debug: No diff data available. This could be due to: -
      -
    • Current workflow has no existing blocks
    • -
    • API call to get current workflow failed
    • -
    • Diff API call failed
    • -
    • YAML parsing issues
    • -
    -
    -
    - )} -
    -
    - - {/* Preview Container */} -
    - -
    - - {/* Action Buttons */} -
    -
    -
    - 💡 This is a preview of the workflow the copilot wants to create. Choose how to - proceed. -
    - -
    - - {/* Split Accept Button - GitHub Style */} -
    - {/* Main Button - toggles between Accept and Save as New */} - - - {/* Dropdown Arrow Button */} - - - - - - setSaveAsNewMode(!saveAsNewMode)} - className='cursor-pointer' - > - {saveAsNewMode ? ( - <> - - Accept (Apply to Current) - - ) : ( - <> - - Save as New Workflow - - )} - - - -
    -
    -
    - - {/* Warning for current workflow changes */} - {currentWorkflow && !saveAsNewMode && ( -
    - -
    -

    - This will replace your current workflow: "{currentWorkflow.name}" -

    -

    - A checkpoint will be created automatically so you can revert if needed. -

    -
    -
    - )} -
    - -
    - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx deleted file mode 100644 index abac66952c4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-modal/copilot-modal.tsx +++ /dev/null @@ -1,331 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' -import { Bot, History, MessageSquarePlus, MoreHorizontal, Trash2, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { ScrollArea } from '@/components/ui/scroll-area' -import type { CopilotChat } from '@/lib/copilot/api' -import { createLogger } from '@/lib/logs/console-logger' -import type { CopilotMessage } from '@/stores/copilot/types' -import { CheckpointPanel } from '../checkpoint-panel' -import { ProfessionalInput } from '../professional-input/professional-input' -import { ProfessionalMessage } from '../professional-message/professional-message' -import { CopilotWelcome } from '../welcome/welcome' - -const logger = createLogger('CopilotModal') - -interface CopilotModalProps { - open: boolean - onOpenChange: (open: boolean) => void - copilotMessage: string - setCopilotMessage: (message: string) => void - messages: CopilotMessage[] - onSendMessage: (message: string) => Promise - onAbortMessage?: () => void - isLoading: boolean - isAborting?: boolean - isLoadingChats: boolean - // Chat management props - chats: CopilotChat[] - currentChat: CopilotChat | null - onSelectChat: (chat: CopilotChat) => void - onStartNewChat: () => void - onDeleteChat: (chatId: string) => void - // Mode props - mode: 'ask' | 'agent' - onModeChange: (mode: 'ask' | 'agent') => void -} - -export function CopilotModal({ - open, - onOpenChange, - copilotMessage, - setCopilotMessage, - messages, - onSendMessage, - onAbortMessage, - isLoading, - isAborting, - isLoadingChats, - chats, - currentChat, - onSelectChat, - onStartNewChat, - onDeleteChat, - mode, - onModeChange, -}: CopilotModalProps) { - const messagesEndRef = useRef(null) - const messagesContainerRef = useRef(null) - const [isDropdownOpen, setIsDropdownOpen] = useState(false) - const [showCheckpoints, setShowCheckpoints] = useState(false) - - // Fixed sidebar width for copilot modal positioning - const sidebarWidth = 240 // w-60 (sidebar width from staging) - - // Auto-scroll to bottom when new messages are added with smooth behavior - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'end', - inline: 'nearest', - }) - } - }, [messages]) - - // Auto-scroll when messages update during streaming - useEffect(() => { - if (isLoading && messagesContainerRef.current) { - const container = messagesContainerRef.current - const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 100 - - if (isNearBottom) { - messagesEndRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'end', - }) - } - } - }, [messages, isLoading]) - - if (!open) return null - - return ( -
    { - if (e.target === e.currentTarget) { - onOpenChange(false) - } - }} - > -
    e.stopPropagation()} - > - {/* Header */} -
    -
    -
    - -
    -
    -

    Copilot Assistant

    -

    - {mode === 'ask' - ? 'Ask questions about your workflow' - : 'Agent mode - Let me help you build'} -

    -
    -
    - -
    - {/* Chat History Dropdown */} - - - - - -
    - {isLoadingChats ? ( -
    -
    - Loading chats... -
    - ) : chats.length === 0 ? ( -
    - No chat history yet -
    - ) : ( - chats.map((chat) => ( - { - onSelectChat(chat) - setIsDropdownOpen(false) - }} - > -
    -
    - {chat.title || 'Untitled Chat'} -
    -
    - {chat.messageCount} messages -
    -
    - -
    - )) - )} -
    - - - -
    - - {/* Action buttons */} -
    - {/* Checkpoint Toggle Button */} - - - {/* New Chat Button */} - - - {/* Close Button */} - -
    -
    -
    - - {/* Main Content Area */} -
    - {showCheckpoints ? ( -
    - -
    - ) : ( - <> - {/* Messages Area */} - -
    - {messages.length === 0 ? ( -
    - -
    - ) : ( -
    - {messages.map((message) => ( - - ))} -
    - )} -
    -
    - - - {/* Input Area */} -
    -
    - {/* Mode Selector */} -
    - - -
    - - {/* Input */} - { - await onSendMessage(message) - setCopilotMessage('') - }} - onAbort={onAbortMessage} - disabled={false} - isLoading={isLoading} - isAborting={isAborting} - placeholder={ - mode === 'ask' - ? 'Ask me anything about your workflow...' - : 'Describe what you want to build...' - } - /> -
    -
    - - )} -
    -
    -
    - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot-modal.tsx deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 287c6954089..26d1fb21b6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -15,10 +15,9 @@ import { usePreviewStore } from '@/stores/copilot/preview-store' import { useCopilotStore } from '@/stores/copilot/store' import { COPILOT_TOOL_IDS } from '@/stores/copilot/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useCopilotSandbox } from '../../../../hooks/use-copilot-sandbox' -import { CopilotSandboxModal } from '../../../copilot-sandbox-modal/copilot-sandbox-modal' + import { CheckpointPanel } from './components/checkpoint-panel' -import { CopilotModal } from './components/copilot-modal/copilot-modal' + import { ProfessionalInput } from './components/professional-input/professional-input' import { ProfessionalMessage } from './components/professional-message/professional-message' import { CopilotWelcome } from './components/welcome/welcome' @@ -27,10 +26,6 @@ const logger = createLogger('Copilot') interface CopilotProps { panelWidth: number - isFullscreen?: boolean - onFullscreenToggle?: (fullscreen: boolean) => void - fullscreenInput?: string - onFullscreenInputChange?: (input: string) => void } interface CopilotRef { @@ -39,16 +34,7 @@ interface CopilotRef { } export const Copilot = forwardRef( - ( - { - panelWidth, - isFullscreen = false, - onFullscreenToggle, - fullscreenInput = '', - onFullscreenInputChange, - }, - ref - ) => { + ({ panelWidth }, ref) => { const scrollAreaRef = useRef(null) const [isDropdownOpen, setIsDropdownOpen] = useState(false) const [showCheckpoints, setShowCheckpoints] = useState(false) @@ -56,9 +42,7 @@ export const Copilot = forwardRef( const { activeWorkflowId } = useWorkflowRegistry() - // Use copilot sandbox for workflow previews - const { sandboxState, showSandbox, closeSandbox, applyToCurrentWorkflow, saveAsNewWorkflow } = - useCopilotSandbox() + // Use preview store to track seen previews const { scanAndMarkExistingPreviews, isToolCallSeen, markToolCallAsSeen } = usePreviewStore() @@ -424,40 +408,9 @@ export const Copilot = forwardRef( )}
    - {/* Fullscreen Modal */} - onFullscreenToggle?.(open)} - copilotMessage={fullscreenInput} - setCopilotMessage={(message) => onFullscreenInputChange?.(message)} - messages={messages} - onSendMessage={handleModalSendMessage} - onAbortMessage={abortMessage} - isLoading={isSendingMessage} - isAborting={isAborting} - isLoadingChats={isLoadingChats} - chats={chats} - currentChat={currentChat} - onSelectChat={selectChat} - onStartNewChat={handleStartNewChat} - onDeleteChat={handleDeleteChat} - mode={mode} - onModeChange={setMode} - /> - - {/* Copilot Sandbox Modal */} - { - await saveAsNewWorkflow(name) - }} - isProcessing={sandboxState.isProcessing} - /> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d60d06474e..6f382af1e67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -17,7 +17,7 @@ export function Panel() { const [chatMessage, setChatMessage] = useState('') const [copilotMessage, setCopilotMessage] = useState('') const [isChatModalOpen, setIsChatModalOpen] = useState(false) - const [isCopilotModalOpen, setIsCopilotModalOpen] = useState(false) + const [isResizing, setIsResizing] = useState(false) const [resizeStartX, setResizeStartX] = useState(0) const [resizeStartWidth, setResizeStartWidth] = useState(0) @@ -218,10 +218,6 @@ export function Panel() { ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx deleted file mode 100644 index 26142b0520b..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/review-button.tsx +++ /dev/null @@ -1,458 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Eye, FileText } from 'lucide-react' -import { useParams } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { createLogger } from '@/lib/logs/console-logger' -import { useCopilotStore } from '@/stores/copilot/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { CopilotSandboxModal } from './copilot-sandbox-modal/copilot-sandbox-modal' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -const logger = createLogger('ReviewButton') - -// Backward compatibility exports (deprecated) -export function setLatestPreview() {} -export function clearLatestPreview() {} -export function getLatestUnseenPreview() { - return null -} - -export function ReviewButton() { - const params = useParams() - const workspaceId = params.workspaceId as string - const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() - const { currentChat, updatePreviewToolCallState, clearPreviewYaml } = useCopilotStore() - const [showModal, setShowModal] = useState(false) - const [isProcessing, setIsProcessing] = useState(false) - const [previewWorkflowState, setPreviewWorkflowState] = useState(null) - const [diffInfo, setDiffInfo] = useState(null) - const [isDiffLoading, setIsDiffLoading] = useState(false) - - // Check if current chat has preview YAML - const hasPreview = currentChat?.previewYaml !== null && currentChat?.previewYaml !== undefined - - // Only show if there's a preview YAML in the current chat - if (!hasPreview) { - return null - } - - const handleShowPreview = async () => { - if (!currentChat?.previewYaml || !activeWorkflowId) return - - try { - // Validate YAML content before sending - const yamlContent = currentChat.previewYaml.trim() - if (!yamlContent) { - throw new Error('Preview YAML content is empty') - } - - logger.info( - 'Generating preview with YAML content (first 200 chars):', - yamlContent.substring(0, 200) - ) - - // Generate workflow state from YAML for the modal - logger.info('Step 1: Calling preview API...') - const previewResponse = await fetch('/api/workflows/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - yamlContent, - applyAutoLayout: true, - }), - }) - logger.info('Step 1 complete: Preview API response received', { - status: previewResponse.status, - }) - - if (!previewResponse.ok) { - const errorText = await previewResponse.text() - logger.error('Preview API response not ok:', { - status: previewResponse.status, - statusText: previewResponse.statusText, - errorText, - }) - throw new Error( - `Failed to generate preview: ${previewResponse.status} ${previewResponse.statusText}` - ) - } - - const previewResult = await previewResponse.json() - logger.info('Step 1 result: Preview API parsed successfully', { - success: previewResult.success, - }) - - if (!previewResult.success) { - logger.error('Preview API returned error:', previewResult) - throw new Error(previewResult.message || 'Failed to generate preview') - } - - // Get current workflow YAML for diff comparison - logger.info('Step 2: Getting current workflow YAML for diff comparison...') - let originalYaml = '' - try { - const currentWorkflowResponse = await fetch(`/api/tools/get-user-workflow`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId: activeWorkflowId, - includeMetadata: false, - }), - }) - logger.info('Step 2: Current workflow API response received', { - status: currentWorkflowResponse.status, - }) - - if (currentWorkflowResponse.ok) { - const currentWorkflowResult = await currentWorkflowResponse.json() - logger.info('Step 2: Current workflow API parsed', { - success: currentWorkflowResult.success, - hasYaml: !!currentWorkflowResult.output?.yaml, - }) - if (currentWorkflowResult.success && currentWorkflowResult.output?.yaml) { - originalYaml = currentWorkflowResult.output.yaml - logger.info('Step 2: Original YAML obtained', { length: originalYaml.length }) - } - } else { - logger.warn('Step 2: Current workflow API failed', { - status: currentWorkflowResponse.status, - }) - } - } catch (yamlError) { - logger.error('Step 2: Failed to get current workflow YAML for diff:', yamlError) - } - - // Generate diff information if we have original YAML - logger.info('Step 3: Generating diff information...') - let diffResult = null - if (originalYaml) { - try { - setIsDiffLoading(true) - logger.info( - 'Step 3: Starting diff with original YAML length:', - originalYaml.length, - 'agent YAML length:', - yamlContent.length - ) - - const diffResponse = await fetch('/api/workflows/diff', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - original_yaml: originalYaml, - agent_yaml: yamlContent, - }), - }) - logger.info('Step 3: Diff API response received', { status: diffResponse.status }) - - if (diffResponse.ok) { - const diffData = await diffResponse.json() - logger.info('Step 3: Diff API response parsed:', diffData) - if (diffData.success) { - diffResult = diffData.data - logger.info('Step 3: Generated diff information successfully:', diffResult) - } else { - logger.error('Step 3: Diff API returned unsuccessful response:', diffData) - } - } else { - logger.error( - 'Step 3: Diff API request failed:', - diffResponse.status, - diffResponse.statusText - ) - const errorText = await diffResponse.text() - logger.error('Step 3: Diff API error response:', errorText) - } - } catch (diffError) { - logger.error('Step 3: Failed to generate diff information:', diffError) - } finally { - setIsDiffLoading(false) - } - } else { - logger.warn('Step 3: No original YAML available for diff comparison') - setIsDiffLoading(false) - } - - // Set the generated workflow state, diff info, and open modal - logger.info('Step 4: Setting modal state and opening...') - setPreviewWorkflowState(previewResult.workflowState) - setDiffInfo(diffResult) - logger.info('Step 4: Opening modal with diff info:', diffResult) - setShowModal(true) - logger.info('Step 4: Modal state should now be open') - } catch (error) { - logger.error('Failed to generate preview for modal:', { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - yamlLength: currentChat?.previewYaml?.length, - yamlPreview: currentChat?.previewYaml?.substring(0, 100), - }) - // Reset loading states on error - setIsDiffLoading(false) - // TODO: Show user-friendly error message - } - } - - const handleApply = async () => { - if (!currentChat?.previewYaml) { - logger.error('No YAML content to apply') - return - } - - try { - setIsProcessing(true) - - // Optimistically update tool call state immediately - updatePreviewToolCallState('applied') - - logger.info('Applying preview workflow', { - yamlLength: currentChat.previewYaml.length, - yamlPreview: currentChat.previewYaml.substring(0, 200), - }) - - // Rest of the async operations happen in background - const applyInBackground = async () => { - try { - // Apply the workflow YAML content - const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: currentChat.previewYaml, - description: 'Applied from copilot proposal', - source: 'copilot', - applyAutoLayout: false, - createCheckpoint: true, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) - } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to apply workflow') - } - - logger.info('Successfully applied preview to main workflow') - - // Update local stores to reflect the applied changes - const { blocksUpdated, edgesUpdated, subBlocksUpdated } = result - - if (blocksUpdated) { - useWorkflowStore.setState({ blocks: blocksUpdated }) - } - if (edgesUpdated) { - useWorkflowStore.setState({ edges: edgesUpdated }) - } - if (subBlocksUpdated) { - useSubBlockStore.setState((state: any) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId as string]: subBlocksUpdated, - }, - })) - } - - logger.info('Updated local stores with applied workflow state') - } catch (error) { - logger.error('Failed to apply preview in background:', error) - // TODO: Consider showing a toast notification for save failures - // The optimistic UI update already happened, so the user sees the intended state - } - } - - // Start background apply - applyInBackground() - - // Clear preview YAML after optimistic update - await clearPreviewYaml() - setShowModal(false) - setPreviewWorkflowState(null) - setDiffInfo(null) - setIsDiffLoading(false) - } catch (error) { - logger.error('Failed to apply preview:', error) - } finally { - setIsProcessing(false) - } - } - - const handleSaveAsNew = async (name: string) => { - if (!currentChat?.previewYaml) { - logger.error('No YAML content to save') - return - } - - try { - setIsProcessing(true) - - // Optimistically update tool call state immediately - updatePreviewToolCallState('applied') - - logger.info('Creating new workflow from preview', { - name, - yamlLength: currentChat.previewYaml.length, - }) - - // Background save operation - const saveInBackground = async () => { - try { - // First create a new workflow - const newWorkflowId = await createWorkflow({ - name, - description: 'Created from copilot proposal', - workspaceId, - }) - - if (!newWorkflowId) { - throw new Error('Failed to create new workflow') - } - - // Then apply the YAML content to the new workflow - const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: currentChat.previewYaml, - description: 'Created from copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) - } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to save workflow') - } - - logger.info('Successfully created new workflow from preview') - } catch (error) { - logger.error('Failed to save preview as new workflow in background:', error) - // TODO: Consider showing a toast notification for save failures - } - } - - // Start background save - saveInBackground() - - await clearPreviewYaml() - setShowModal(false) - setPreviewWorkflowState(null) - setDiffInfo(null) - setIsDiffLoading(false) - } catch (error) { - logger.error('Failed to save preview as new workflow:', error) - } finally { - setIsProcessing(false) - } - } - - const handleReject = async () => { - if (!currentChat?.previewYaml) return - - try { - setIsProcessing(true) - - // Optimistically update tool call state immediately - updatePreviewToolCallState('rejected') - - await clearPreviewYaml() - setShowModal(false) - setPreviewWorkflowState(null) - setDiffInfo(null) - setIsDiffLoading(false) - } catch (error) { - logger.error('Failed to reject preview:', error) - } finally { - setIsProcessing(false) - } - } - - const handleClose = () => { - setShowModal(false) - setPreviewWorkflowState(null) - setDiffInfo(null) - setIsDiffLoading(false) - } - - // Create preview data for the sandbox modal - const previewData = - currentChat?.previewYaml && previewWorkflowState - ? { - workflowState: previewWorkflowState, - yamlContent: currentChat.previewYaml, - description: 'Copilot generated workflow preview', - } - : null - - // Debug logging - console.log('ReviewButton render state:', { - showModal, - previewData: previewData ? 'present' : 'null', - diffInfo: diffInfo ? `present (${Object.keys(diffInfo).join(',')})` : 'null', - isDiffLoading, - hasPreviewYaml: !!currentChat?.previewYaml, - }) - - return ( - <> - {/* Simple button at bottom center */} -
    -
    -
    -
    -
    - -
    - Copilot has proposed changes -
    - -
    -
    -
    - - {/* Sandbox Modal */} - {showModal && previewData && ( - - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts deleted file mode 100644 index e20d5e2efe8..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-copilot-sandbox.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { useCallback, useState } from 'react' -import { useParams } from 'next/navigation' -import { createLogger } from '@/lib/logs/console-logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('useCopilotSandbox') - -interface SandboxState { - isOpen: boolean - proposedWorkflowState: WorkflowState | null - yamlContent: string - description?: string - isProcessing: boolean -} - -export function useCopilotSandbox() { - const [sandboxState, setSandboxState] = useState({ - isOpen: false, - proposedWorkflowState: null, - yamlContent: '', - description: undefined, - isProcessing: false, - }) - - const params = useParams() - const workspaceId = params.workspaceId as string - const { activeWorkflowId, createWorkflow } = useWorkflowRegistry() - - const showSandbox = useCallback( - (workflowState: WorkflowState, yamlContent: string, description?: string) => { - setSandboxState({ - isOpen: true, - proposedWorkflowState: workflowState, - yamlContent, - description, - isProcessing: false, - }) - }, - [] - ) - - const closeSandbox = useCallback(() => { - setSandboxState({ - isOpen: false, - proposedWorkflowState: null, - yamlContent: '', - description: undefined, - isProcessing: false, - }) - }, []) - - const applyToCurrentWorkflow = useCallback(async () => { - if (!activeWorkflowId || !sandboxState.yamlContent) { - throw new Error('No active workflow or YAML content') - } - - try { - setSandboxState((prev) => ({ ...prev, isProcessing: true })) - - logger.info('Applying sandbox workflow to current workflow', { - workflowId: activeWorkflowId, - yamlLength: sandboxState.yamlContent.length, - }) - - // Use the existing YAML endpoint to apply the changes - const response = await fetch(`/api/workflows/${activeWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: sandboxState.yamlContent, - description: sandboxState.description || 'Applied copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: true, // Always create checkpoints for copilot changes - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to apply workflow: ${response.statusText}`) - } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to apply workflow changes') - } - - logger.info('Successfully applied sandbox workflow to current workflow', { - workflowId: activeWorkflowId, - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, - }) - } catch (error) { - logger.error('Failed to apply sandbox workflow:', error) - throw error - } finally { - setSandboxState((prev) => ({ ...prev, isProcessing: false })) - } - }, [activeWorkflowId, sandboxState.yamlContent, sandboxState.description]) - - const saveAsNewWorkflow = useCallback( - async (name: string) => { - if (!sandboxState.yamlContent) { - throw new Error('No YAML content to save') - } - - try { - setSandboxState((prev) => ({ ...prev, isProcessing: true })) - - logger.info('Creating new workflow from sandbox', { - name, - yamlLength: sandboxState.yamlContent.length, - }) - - // First create a new workflow - const newWorkflowId = await createWorkflow({ - name, - description: sandboxState.description, - workspaceId, - }) - - if (!newWorkflowId) { - throw new Error('Failed to create new workflow') - } - - // Then apply the YAML content to the new workflow - const response = await fetch(`/api/workflows/${newWorkflowId}/yaml`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - yamlContent: sandboxState.yamlContent, - description: sandboxState.description || 'Created from copilot proposal', - source: 'copilot', - applyAutoLayout: true, - createCheckpoint: false, // No need for checkpoint on new workflow - }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.message || `Failed to save workflow: ${response.statusText}`) - } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || 'Failed to save workflow') - } - - logger.info('Successfully created new workflow from sandbox', { - newWorkflowId, - name, - blocksCount: result.data?.blocksCount, - edgesCount: result.data?.edgesCount, - }) - - return newWorkflowId - } catch (error) { - logger.error('Failed to save sandbox workflow as new:', error) - throw error - } finally { - setSandboxState((prev) => ({ ...prev, isProcessing: false })) - } - }, - [sandboxState.yamlContent, sandboxState.description, createWorkflow] - ) - - return { - sandboxState, - showSandbox, - closeSandbox, - applyToCurrentWorkflow, - saveAsNewWorkflow, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index b1fad55356e..12b514ed6ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1580,13 +1580,6 @@ const WorkflowContent = React.memo(() => { {/* Show DiffControls if diff is available (regardless of current view mode) */} - {/* - {isDiffMode ? ( - - ) : ( - - )} - */}
    ) From fa683567e0f414d804af60d48a76e8100de88cc3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 15:54:38 -0700 Subject: [PATCH 100/184] Remove old route --- apps/sim/app/api/tools/edit-workflow/route.ts | 414 ------------------ 1 file changed, 414 deletions(-) delete mode 100644 apps/sim/app/api/tools/edit-workflow/route.ts diff --git a/apps/sim/app/api/tools/edit-workflow/route.ts b/apps/sim/app/api/tools/edit-workflow/route.ts deleted file mode 100644 index 20d38db7b11..00000000000 --- a/apps/sim/app/api/tools/edit-workflow/route.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { autoLayoutWorkflow } from '@/lib/autolayout/service' -import { createLogger } from '@/lib/logs/console-logger' -import { - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/db-helpers' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' -import { getUserId } from '@/app/api/auth/oauth/utils' -import { getBlock } from '@/blocks' -import { db } from '@/db' -import { copilotCheckpoints, workflow as workflowTable } from '@/db/schema' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('EditWorkflowAPI') - -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const body = await request.json() - const { yamlContent, workflowId, description, chatId } = body - - if (!yamlContent) { - return NextResponse.json( - { success: false, error: 'yamlContent is required' }, - { status: 400 } - ) - } - - if (!workflowId) { - return NextResponse.json({ success: false, error: 'workflowId is required' }, { status: 400 }) - } - - logger.info(`[${requestId}] Processing workflow edit request`, { - workflowId, - yamlLength: yamlContent.length, - hasDescription: !!description, - hasChatId: !!chatId, - }) - - // Log the full YAML content for debugging - logger.info(`[${requestId}] Full YAML content from copilot:`) - logger.info('='.repeat(80)) - logger.info(yamlContent) - logger.info('='.repeat(80)) - - // Get the user ID for checkpoint creation - const userId = await getUserId(requestId, workflowId) - if (!userId) { - return NextResponse.json({ success: false, error: 'User not found' }, { status: 404 }) - } - - // Create checkpoint before making changes (only if chatId is provided) - if (chatId) { - try { - logger.info(`[${requestId}] Creating checkpoint before workflow edit`) - - // Get current workflow state - const currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) - - if (currentWorkflowData) { - // Generate YAML from current state - const currentYaml = generateWorkflowYaml(currentWorkflowData) - - // Create checkpoint - await db.insert(copilotCheckpoints).values({ - userId, - workflowId, - chatId, - yaml: currentYaml, - }) - - logger.info(`[${requestId}] Checkpoint created successfully`) - } else { - logger.warn(`[${requestId}] Could not load current workflow state for checkpoint`) - } - } catch (checkpointError) { - logger.error(`[${requestId}] Failed to create checkpoint:`, checkpointError) - // Continue with workflow edit even if checkpoint fails - } - } - - // Parse YAML content server-side - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) - - if (!yamlWorkflow || parseErrors.length > 0) { - logger.error('[edit-workflow] YAML parsing failed', { parseErrors }) - return NextResponse.json({ - success: true, - data: { - success: false, - message: 'Failed to parse YAML workflow', - errors: parseErrors, - warnings: [], - }, - }) - } - - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - logger.error('[edit-workflow] YAML conversion failed', { convertErrors }) - return NextResponse.json({ - success: true, - data: { - success: false, - message: 'Failed to convert YAML to workflow', - errors: convertErrors, - warnings, - }, - }) - } - - // Create workflow state (same format as applyWorkflowDiff) - const newWorkflowState: any = { - blocks: {} as Record, - edges: [] as any[], - loops: {} as Record, - parallels: {} as Record, - lastSaved: Date.now(), - isDeployed: false, - deployedAt: undefined, - deploymentStatuses: {} as Record, - hasActiveSchedule: false, - hasActiveWebhook: false, - } - - // Process blocks and assign new IDs (complete replacement) - const blockIdMapping = new Map() - - for (const block of blocks) { - const newId = crypto.randomUUID() - blockIdMapping.set(block.id, newId) - - // Get block configuration to set proper defaults - const blockConfig = getBlock(block.type) - const subBlocks: Record = {} - const outputs: Record = {} - - // Set up subBlocks from block configuration - if (blockConfig?.subBlocks) { - blockConfig.subBlocks.forEach((subBlock) => { - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: null, - } - }) - } - - // Set up outputs from block configuration - if (blockConfig?.outputs) { - if (Array.isArray(blockConfig.outputs)) { - blockConfig.outputs.forEach((output) => { - outputs[output.id] = { type: output.type } - }) - } else if (typeof blockConfig.outputs === 'object') { - Object.assign(outputs, blockConfig.outputs) - } - } - - newWorkflowState.blocks[newId] = { - id: newId, - type: block.type, - name: block.name, - position: block.position, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: block.data || {}, - } - - // Set input values as subblock values with block reference mapping - if (block.inputs && typeof block.inputs === 'object') { - Object.entries(block.inputs).forEach(([key, value]) => { - if (newWorkflowState.blocks[newId].subBlocks[key]) { - // Update block references in values to use new mapped IDs - let processedValue = value - if (typeof value === 'string' && value.includes('<') && value.includes('>')) { - // Update block references to use new mapped IDs - const blockMatches = value.match(/<([^>]+)>/g) - if (blockMatches) { - for (const match of blockMatches) { - const path = match.slice(1, -1) - const [blockRef] = path.split('.') - - // Skip system references (start, loop, parallel, variable) - if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { - continue - } - - // Check if this references an old block ID that needs mapping - const newMappedId = blockIdMapping.get(blockRef) - if (newMappedId) { - logger.info( - `[${requestId}] Updating block reference: ${blockRef} -> ${newMappedId}` - ) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}\\.`, 'g'), - `<${newMappedId}.` - ) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}>`, 'g'), - `<${newMappedId}>` - ) - } - } - } - } - newWorkflowState.blocks[newId].subBlocks[key].value = processedValue - } - }) - } - } - - // Update parent-child relationships with mapped IDs - logger.info(`[${requestId}] Block ID mapping:`, Object.fromEntries(blockIdMapping)) - for (const [newId, blockData] of Object.entries(newWorkflowState.blocks)) { - const block = blockData as any - if (block.data?.parentId) { - logger.info( - `[${requestId}] Found child block ${block.name} with parentId: ${block.data.parentId}` - ) - const mappedParentId = blockIdMapping.get(block.data.parentId) - if (mappedParentId) { - logger.info( - `[${requestId}] Updating parent reference: ${block.data.parentId} -> ${mappedParentId}` - ) - block.data.parentId = mappedParentId - // Ensure extent is set for child blocks - if (!block.data.extent) { - block.data.extent = 'parent' - } - } else { - logger.error( - `[${requestId}] ❌ Parent block not found for mapping: ${block.data.parentId}` - ) - logger.error(`[${requestId}] Available mappings:`, Array.from(blockIdMapping.keys())) - // Remove invalid parent reference - block.data.parentId = undefined - block.data.extent = undefined - } - } - } - - // Process edges with mapped IDs - for (const edge of edges) { - const sourceId = blockIdMapping.get(edge.source) - const targetId = blockIdMapping.get(edge.target) - - if (sourceId && targetId) { - newWorkflowState.edges.push({ - id: crypto.randomUUID(), - source: sourceId, - target: targetId, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - }) - } - } - - // Generate loop and parallel configurations from the imported blocks - const loops = generateLoopBlocks(newWorkflowState.blocks) - const parallels = generateParallelBlocks(newWorkflowState.blocks) - - // Update workflow state with generated configurations - newWorkflowState.loops = loops - newWorkflowState.parallels = parallels - - logger.info(`[${requestId}] Generated loop and parallel configurations`, { - loopsCount: Object.keys(loops).length, - parallelsCount: Object.keys(parallels).length, - loopIds: Object.keys(loops), - parallelIds: Object.keys(parallels), - }) - - // Apply intelligent autolayout to optimize block positions - try { - logger.info( - `[${requestId}] Applying autolayout to ${Object.keys(newWorkflowState.blocks).length} blocks` - ) - - const layoutedBlocks = await autoLayoutWorkflow( - newWorkflowState.blocks, - newWorkflowState.edges, - { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, // Increased from 400 to match UI button - vertical: 400, // Increased from 200 to match UI button - layer: 700, // Increased from 600 to match UI button - }, - alignment: 'center', - padding: { - x: 250, // Increased from 200 to match UI button - y: 250, // Increased from 200 to match UI button - }, - } - ) - - // Update workflow state with optimized positions - newWorkflowState.blocks = layoutedBlocks - - logger.info(`[${requestId}] Autolayout completed successfully`) - } catch (layoutError) { - // Log the error but don't fail the entire workflow save - logger.warn(`[${requestId}] Autolayout failed, using original positions:`, layoutError) - } - - // Save directly to database using the same function as the workflow state API - const saveResult = await saveWorkflowToNormalizedTables(workflowId, newWorkflowState) - - if (!saveResult.success) { - logger.error('[edit-workflow] Failed to save workflow state:', saveResult.error) - return NextResponse.json({ - success: true, - data: { - success: false, - message: `Database save failed: ${saveResult.error || 'Unknown error'}`, - errors: [saveResult.error || 'Database save failed'], - warnings, - }, - }) - } - - // Update workflow's lastSynced timestamp - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - state: saveResult.jsonBlob, // Also update JSON blob for backward compatibility - }) - .where(eq(workflowTable.id, workflowId)) - - // Notify the socket server to tell clients to rehydrate stores from database - try { - const socketUrl = process.env.SOCKET_URL || 'http://localhost:3002' - await fetch(`${socketUrl}/api/copilot-workflow-edit`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - workflowId, - description: description || 'Copilot edited workflow', - }), - }) - logger.info('[edit-workflow] Notified socket server to rehydrate client stores from database') - } catch (socketError) { - // Don't fail the main request if socket notification fails - logger.warn('[edit-workflow] Failed to notify socket server:', socketError) - } - - // Calculate summary with loop/parallel information - const loopBlocksCount = Object.values(newWorkflowState.blocks).filter( - (b: any) => b.type === 'loop' - ).length - const parallelBlocksCount = Object.values(newWorkflowState.blocks).filter( - (b: any) => b.type === 'parallel' - ).length - - let summaryDetails = `Successfully created workflow with ${blocks.length} blocks and ${edges.length} connections.` - - if (loopBlocksCount > 0 || parallelBlocksCount > 0) { - summaryDetails += ` Generated ${Object.keys(loops).length} loop configurations and ${Object.keys(parallels).length} parallel configurations.` - } - - const result = { - success: true, - errors: [], - warnings, - summary: summaryDetails, - } - - logger.info('[edit-workflow] Import result', { - success: result.success, - errorCount: result.errors.length, - warningCount: result.warnings.length, - summary: result.summary, - }) - - return NextResponse.json({ - success: true, - data: { - success: result.success, - message: result.success - ? `Workflow updated successfully${description ? `: ${description}` : ''}` - : 'Failed to update workflow', - summary: result.summary, - errors: result.errors, - warnings: result.warnings, - }, - }) - } catch (error) { - logger.error('[edit-workflow] Error:', error) - return NextResponse.json( - { - success: false, - error: `Failed to edit workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, - }, - { status: 500 } - ) - } -} From 210ea578aa022423deec8c049eae7f3c8a12dc1e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 15:58:15 -0700 Subject: [PATCH 101/184] Fix chat loading --- apps/sim/app/api/copilot/chat/route.ts | 65 ++++++++++++++++++- .../panel/components/copilot/copilot.tsx | 12 +++- apps/sim/stores/copilot/store.ts | 54 ++++++++++++++- 3 files changed, 126 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 6d0b4181ee4..18a5850abbd 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -4,7 +4,7 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { apiKey as apiKeyTable, copilotChats } from '@/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, desc } from 'drizzle-orm' import { executeProviderRequest } from '@/providers' import { getCopilotModel } from '@/lib/copilot/config' import { @@ -621,4 +621,67 @@ export async function POST(req: NextRequest) { { status: 500 } ) } +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const workflowId = searchParams.get('workflowId') + + if (!workflowId) { + return NextResponse.json({ error: 'workflowId is required' }, { status: 400 }) + } + + // Get authenticated user + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const authenticatedUserId = session.user.id + + // Fetch chats for this user and workflow + const chats = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + model: copilotChats.model, + messages: copilotChats.messages, + createdAt: copilotChats.createdAt, + updatedAt: copilotChats.updatedAt, + }) + .from(copilotChats) + .where( + and( + eq(copilotChats.userId, authenticatedUserId), + eq(copilotChats.workflowId, workflowId) + ) + ) + .orderBy(desc(copilotChats.updatedAt)) + + // Transform the data to include message count + const transformedChats = chats.map((chat) => ({ + id: chat.id, + title: chat.title, + model: chat.model, + messages: Array.isArray(chat.messages) ? chat.messages : [], + messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, + previewYaml: null, // Not needed for chat list + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + })) + + logger.info(`Retrieved ${transformedChats.length} chats for workflow ${workflowId}`) + + return NextResponse.json({ + success: true, + chats: transformedChats, + }) + } catch (error) { + logger.error('Error fetching copilot chats:', error) + return NextResponse.json( + { error: 'Failed to fetch chats' }, + { status: 500 } + ) + } } \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 26d1fb21b6b..cd31076db5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -69,9 +69,10 @@ export const Copilot = forwardRef( clearMessages, clearError, setMode, + loadChats, } = useCopilotStore() - // Sync workflow ID with store + // Sync workflow ID with store and load chats useEffect(() => { if (activeWorkflowId !== workflowId) { setWorkflowId(activeWorkflowId).catch((error) => { @@ -80,6 +81,15 @@ export const Copilot = forwardRef( } }, [activeWorkflowId, workflowId, setWorkflowId]) + // Load chats when workflow ID is set + useEffect(() => { + if (workflowId && workflowId === activeWorkflowId) { + loadChats().catch((error) => { + console.error('Failed to load chats:', error) + }) + } + }, [workflowId, activeWorkflowId, loadChats]) + // Clear any existing preview when component mounts or workflow changes useEffect(() => { // Preview clearing is now handled automatically by the copilot store diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 3d216755e08..64914e48d4d 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -620,10 +620,58 @@ export const useCopilotStore = create()( // The interface expects Promise, not Promise }, - // Load chats - now a no-op + // Load chats for current workflow loadChats: async () => { - logger.warn('Chat loading not implemented without API endpoint') - set({ chats: [] }) + const { workflowId } = get() + + if (!workflowId) { + logger.warn('No workflow ID set, cannot load chats') + set({ chats: [], isLoadingChats: false }) + return + } + + set({ isLoadingChats: true }) + + try { + const response = await fetch(`/api/copilot/chat?workflowId=${workflowId}`) + + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.status}`) + } + + const data = await response.json() + + if (data.success && Array.isArray(data.chats)) { + const { currentChat } = get() + + set({ + chats: data.chats, + isLoadingChats: false + }) + + // Auto-select the most recent chat if no chat is currently selected + // and there are chats available (they're already sorted by updatedAt desc) + if (!currentChat && data.chats.length > 0) { + const mostRecentChat = data.chats[0] + set({ + currentChat: mostRecentChat, + messages: mostRecentChat.messages || [], + }) + logger.info(`Auto-selected most recent chat: ${mostRecentChat.title || 'Untitled'}`) + } + + logger.info(`Loaded ${data.chats.length} chats for workflow ${workflowId}`) + } else { + throw new Error('Invalid response format') + } + } catch (error) { + logger.error('Failed to load chats:', error) + set({ + chats: [], + isLoadingChats: false, + error: error instanceof Error ? error.message : 'Failed to load chats' + }) + } }, // Send a message From ce53f51ca0841aff0803d2594b2a38c7ad95d779 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 16:01:55 -0700 Subject: [PATCH 102/184] Scope copilot chat to workflow id --- .../components/control-bar/control-bar.tsx | 80 +------------------ apps/sim/lib/sim-agent/client.ts | 23 ------ apps/sim/stores/copilot/store.ts | 16 ++-- 3 files changed, 12 insertions(+), 107 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index 1c643a75550..be0f8367e36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -973,84 +973,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { ) } - /** - * Handle test auth API call - */ - const handleTestAuth = async () => { - if (!activeWorkflowId) { - console.error('No active workflow ID') - return - } - - if (!session?.user?.id) { - console.error('No user session') - alert('Please log in to test the sim-agent connection') - return - } - - try { - console.log('Test Auth Debug:', { - workflowId: activeWorkflowId, - userId: session.user.id, - }) - - const response = await fetch('/api/test-auth', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workflowId: activeWorkflowId, - userId: session.user.id, - }), - }) - - console.log('Response status:', response.status) - - let result - try { - const responseText = await response.text() - console.log('Raw response text:', responseText) - result = JSON.parse(responseText) - } catch (parseError) { - console.error('Failed to parse response as JSON:', parseError) - alert(`Failed to parse response as JSON. Status: ${response.status}`) - return - } - - if (result.success) { - console.log('Sim-agent test successful:', result) - alert('✅ Sim-agent connection successful! Check console for details.') - } else { - console.error('Sim-agent test failed:', result) - alert(`❌ Sim-agent test failed: ${result.error || 'Unknown error'}`) - } - } catch (error) { - console.error('Test auth error:', error) - alert(`❌ Test auth error: ${error instanceof Error ? error.message : 'Unknown error'}`) - } - } - - /** - * Render test auth button - */ - const renderTestAuthButton = () => { - return ( - - - - - Test Auth API - - ) - } + /** * Render control bar toggle button @@ -1090,7 +1013,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { {!isDebugging && renderDebugModeToggle()} {renderPublishButton()} {renderDeployButton()} - {renderTestAuthButton()} {isDebugging ? renderDebugControlsBar() : renderRunButton()} {/* Template Modal */} diff --git a/apps/sim/lib/sim-agent/client.ts b/apps/sim/lib/sim-agent/client.ts index a06519f132b..4351e8d8609 100644 --- a/apps/sim/lib/sim-agent/client.ts +++ b/apps/sim/lib/sim-agent/client.ts @@ -112,29 +112,6 @@ class SimAgentClient { } } - /** - * Test authentication with the sim-agent service - */ - async testAuth(request: SimAgentRequest): Promise { - return this.makeRequest('/api/test-auth', { - method: 'POST', - body: { - workflowId: request.workflowId, - userId: request.userId, - ...request.data, - }, - }) - } - - /** - * Health check endpoint - */ - async healthCheck(): Promise { - return this.makeRequest('/api/health', { - method: 'GET', - }) - } - /** * Generic method for custom API calls */ diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 64914e48d4d..097457a15c3 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -642,22 +642,28 @@ export const useCopilotStore = create()( const data = await response.json() if (data.success && Array.isArray(data.chats)) { - const { currentChat } = get() set({ chats: data.chats, isLoadingChats: false }) - // Auto-select the most recent chat if no chat is currently selected - // and there are chats available (they're already sorted by updatedAt desc) - if (!currentChat && data.chats.length > 0) { + // Auto-select the most recent chat if there are any chats for this workflow + // Since chats are filtered by workflow ID, any existing currentChat would be stale + if (data.chats.length > 0) { const mostRecentChat = data.chats[0] set({ currentChat: mostRecentChat, messages: mostRecentChat.messages || [], }) - logger.info(`Auto-selected most recent chat: ${mostRecentChat.title || 'Untitled'}`) + logger.info(`Auto-selected most recent chat for workflow ${workflowId}: ${mostRecentChat.title || 'Untitled'}`) + } else { + // Ensure we clear everything if there are no chats for this workflow + set({ + currentChat: null, + messages: [], + }) + logger.info(`No chats found for workflow ${workflowId}, cleared chat state`) } logger.info(`Loaded ${data.chats.length} chats for workflow ${workflowId}`) From 2cb4fc4f9bf42419099f28f025a11a6fc94897f3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 17:50:44 -0700 Subject: [PATCH 103/184] New chat fixes --- .../panel/components/copilot/copilot.tsx | 4 ++-- apps/sim/stores/constants.ts | 3 ++- apps/sim/stores/copilot/store.ts | 24 ++++++++----------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index cd31076db5a..3f21d32f0da 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -151,9 +151,9 @@ export const Copilot = forwardRef( // Handle new chat creation const handleStartNewChat = useCallback(() => { // Preview clearing is now handled automatically by the copilot store - clearMessages() + createNewChat() logger.info('Started new chat') - }, [clearMessages]) + }, [createNewChat]) // Expose functions to parent useImperativeHandle( diff --git a/apps/sim/stores/constants.ts b/apps/sim/stores/constants.ts index 0925e3e6ee9..0573b805773 100644 --- a/apps/sim/stores/constants.ts +++ b/apps/sim/stores/constants.ts @@ -19,7 +19,8 @@ export const COPILOT_TOOL_DISPLAY_NAMES: Record = { 'get_blocks_and_tools': 'Getting block information', 'get_blocks_metadata': 'Getting block metadata', 'get_yaml_structure': 'Analyzing workflow structure', - 'get_workflow_examples': 'Getting workflow examples', + 'get_build_workflow_examples': 'Getting workflow examples', + 'get_edit_workflow_examples': 'Getting workflow examples', 'get_environment_variables': 'Getting environment variables', 'set_environment_variables': 'Setting environment variables', 'get_workflow_console': 'Getting workflow console', diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 097457a15c3..c4cbe004779 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -226,6 +226,12 @@ const sseHandlers: Record = { title, updatedAt: new Date(), } : state.currentChat, + // Also update the chat in the chats array + chats: state.chats.map(chat => + chat.id === state.currentChat?.id + ? { ...chat, title, updatedAt: new Date() } + : chat + ), })) }, @@ -594,24 +600,14 @@ export const useCopilotStore = create()( logger.info(`Selected chat: ${chat.title || 'Untitled'}`) }, - // Create a new chat locally (will be persisted when sending first message) + // Create a new chat - clear current chat state like when switching workflows createNewChat: async () => { - const newChat: CopilotChat = { - id: `temp-${Date.now()}`, // Temporary ID until backend creates real one - title: null, - model: 'gpt-4', - messages: [], - messageCount: 0, - previewYaml: null, - createdAt: new Date(), - updatedAt: new Date(), - } - + // Set state to null so backend creates a new chat on first message set({ - currentChat: newChat, + currentChat: null, messages: [], }) - logger.info('Created new local chat') + logger.info('Cleared chat state for new conversation') }, // Delete chat is now a no-op since we don't have the API From 51d3196582a802e81481a751417c243fc652febb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 18:26:59 -0700 Subject: [PATCH 104/184] Fix chat loading --- apps/sim/stores/copilot/store.ts | 43 ++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index c4cbe004779..9f1a705f150 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -591,13 +591,52 @@ export const useCopilotStore = create()( return true }, - // Simple chat management without API calls + // Select chat and load latest messages selectChat: async (chat: CopilotChat) => { + const { workflowId } = get() + + if (!workflowId) { + logger.warn('Cannot select chat: no workflow ID set') + return + } + + // Optimistically set the chat first set({ currentChat: chat, messages: chat.messages || [], }) - logger.info(`Selected chat: ${chat.title || 'Untitled'}`) + + try { + // Fetch the latest version of this specific chat to get updated messages + const response = await fetch(`/api/copilot/chat?workflowId=${workflowId}`) + + if (!response.ok) { + throw new Error(`Failed to fetch latest chat data: ${response.status}`) + } + + const data = await response.json() + + if (data.success && Array.isArray(data.chats)) { + // Find the selected chat in the fresh data + const latestChat = data.chats.find((c: CopilotChat) => c.id === chat.id) + + if (latestChat) { + // Update with the latest messages + set({ + currentChat: latestChat, + messages: latestChat.messages || [], + // Also update the chat in the chats array with latest data + chats: get().chats.map((c: CopilotChat) => c.id === chat.id ? latestChat : c) + }) + logger.info(`Selected chat with latest messages: ${latestChat.title || 'Untitled'} (${latestChat.messages?.length || 0} messages)`) + } else { + logger.warn(`Selected chat ${chat.id} not found in latest data`) + } + } + } catch (error) { + logger.error('Failed to fetch latest chat data, using cached messages:', error) + // Already set optimistically above, so just log the error + } }, // Create a new chat - clear current chat state like when switching workflows From 9db84a19e31a9f386ddcb53c9fa181588569f6c7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 19:02:14 -0700 Subject: [PATCH 105/184] Update get all blocks and tools --- .../tools/blocks/get-blocks-and-tools.ts | 37 +++++++++++++++---- .../tools/blocks/get-blocks-metadata.ts | 32 ++-------------- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts index c93517f12c2..e6ff8d2df5e 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts @@ -1,4 +1,5 @@ import { registry as blockRegistry } from '@/blocks/registry' +import { tools as toolsRegistry } from '@/tools/registry' import { BaseCopilotTool } from '../base' import { createLogger } from '@/lib/logs/console-logger' @@ -6,11 +7,16 @@ interface GetBlocksAndToolsParams { // No parameters needed - just return all blocks and tools } -class GetBlocksAndToolsTool extends BaseCopilotTool> { +interface BlockInfo { + block_name: string + tool_names: string[] +} + +class GetBlocksAndToolsTool extends BaseCopilotTool> { readonly id = 'get_blocks_and_tools' readonly displayName = 'Getting block information' - protected async executeImpl(params: GetBlocksAndToolsParams): Promise> { + protected async executeImpl(params: GetBlocksAndToolsParams): Promise> { return getBlocksAndTools() } } @@ -19,13 +25,13 @@ class GetBlocksAndToolsTool extends BaseCopilotTool> { +async function getBlocksAndTools(): Promise> { const logger = createLogger('GetBlocksAndTools') logger.info('Getting all blocks and tools') - // Create mapping of block_id -> [tool_ids] - const blockToToolsMapping: Record = {} + // Create mapping of block_id -> {block_name, tool_names} + const blockToToolsMapping: Record = {} // Process blocks - filter out hidden blocks and map to their tools Object.entries(blockRegistry) @@ -36,23 +42,38 @@ async function getBlocksAndTools(): Promise> { }) .forEach(([blockType, blockConfig]) => { // Get the tools for this block - const blockTools = blockConfig.tools?.access || [] - blockToToolsMapping[blockType] = blockTools + const blockToolIds = blockConfig.tools?.access || [] + + // Map tool IDs to tool names + const toolNames = blockToolIds.map(toolId => { + const toolConfig = toolsRegistry[toolId] + return toolConfig ? toolConfig.name : toolId // Fallback to ID if name not found + }) + + blockToToolsMapping[blockType] = { + block_name: blockConfig.name || blockType, + tool_names: toolNames + } }) // Add special blocks that aren't in the standard registry const specialBlocks = { loop: { + name: 'Loop', tools: [], // Loop blocks don't use standard tools }, parallel: { + name: 'Parallel', tools: [], // Parallel blocks don't use standard tools }, } // Add special blocks Object.entries(specialBlocks).forEach(([blockType, blockInfo]) => { - blockToToolsMapping[blockType] = blockInfo.tools + blockToToolsMapping[blockType] = { + block_name: blockInfo.name, + tool_names: blockInfo.tools + } }) const totalBlocks = Object.keys(blockRegistry).length + Object.keys(specialBlocks).length diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts index afc03940078..d372c8cc6c9 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts @@ -79,7 +79,8 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis const docFileName = DOCS_FILE_MAPPING[blockId] || blockId if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { try { - const docPath = join(process.cwd(), 'content', 'docs', 'blocks', `${docFileName}.mdx`) + // Updated path to point to the actual YAML documentation location + const docPath = join(process.cwd(), 'apps', 'docs', 'content', 'docs', 'yaml', 'blocks', `${docFileName}.mdx`) if (existsSync(docPath)) { const docContent = readFileSync(docPath, 'utf-8') @@ -165,7 +166,8 @@ const CORE_BLOCKS_WITH_DOCS = [ // Mapping for blocks that have different doc file names const DOCS_FILE_MAPPING: Record = { - webhook: 'webhook_trigger', + // All core blocks use their registry ID as the doc filename + // e.g., 'api' block -> 'api.mdx', 'agent' block -> 'agent.mdx' } // Special blocks that aren't in the standard registry but need metadata @@ -272,30 +274,4 @@ const SPECIAL_BLOCKS_METADATA: Record = { }, } -// Helper function to read YAML schema from dedicated YAML documentation files -function getYamlSchemaFromDocs(blockType: string): string | null { - try { - const docFileName = DOCS_FILE_MAPPING[blockType] || blockType - // Read from the new YAML documentation structure - const yamlDocsPath = join( - process.cwd(), - '..', - 'docs/content/docs/yaml/blocks', - `${docFileName}.mdx` - ) - - if (!existsSync(yamlDocsPath)) { - logger.warn(`YAML schema file not found for ${blockType} at ${yamlDocsPath}`) - return null - } - - const content = readFileSync(yamlDocsPath, 'utf-8') - // Remove the frontmatter and return the content after the title - const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') - return contentWithoutFrontmatter.trim() - } catch (error) { - logger.warn(`Failed to read YAML schema for ${blockType}:`, error) - return null - } -} From 78045129da49e6f85e8d8de677d53b6a1cff1d40 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 28 Jul 2025 19:29:50 -0700 Subject: [PATCH 106/184] Make metadata better --- .../tools/blocks/get-blocks-metadata.ts | 135 +++++------------- 1 file changed, 37 insertions(+), 98 deletions(-) diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts index d372c8cc6c9..1cf6d52d809 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts @@ -52,30 +52,33 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis // Process each requested block ID for (const blockId of blockIds) { + let metadata: any = {} + // Check if it's a special block first if (SPECIAL_BLOCKS_METADATA[blockId]) { - result[blockId] = SPECIAL_BLOCKS_METADATA[blockId] - continue - } - - // Check if the block exists in the registry - const blockConfig = blockRegistry[blockId] - if (!blockConfig) { - logger.warn(`Block not found in registry: ${blockId}`) - continue - } + // Start with the special block metadata + metadata = { ...SPECIAL_BLOCKS_METADATA[blockId] } + // Normalize tools structure to match regular blocks + metadata.tools = metadata.tools?.access || [] + } else { + // Check if the block exists in the registry + const blockConfig = blockRegistry[blockId] + if (!blockConfig) { + logger.warn(`Block not found in registry: ${blockId}`) + continue + } - const metadata: any = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.description || '', - category: blockConfig.category || 'general', - inputs: blockConfig.inputs || {}, - outputs: blockConfig.outputs || {}, - tools: blockConfig.tools?.access || [], + metadata = { + id: blockId, + name: blockConfig.name || blockId, + description: blockConfig.description || '', + inputs: blockConfig.inputs || {}, + outputs: blockConfig.outputs || {}, + tools: blockConfig.tools?.access || [], + } } - // Read YAML schema from documentation if available + // Read YAML schema from documentation if available (for both regular and special blocks) const docFileName = DOCS_FILE_MAPPING[blockId] || blockId if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { try { @@ -119,7 +122,7 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis } // Add tool metadata if requested - if (metadata.tools.length > 0) { + if (metadata.tools && metadata.tools.length > 0) { metadata.toolDetails = {} for (const toolId of metadata.tools) { const tool = toolsRegistry[toolId] @@ -176,49 +179,17 @@ const SPECIAL_BLOCKS_METADATA: Record = { type: 'loop', name: 'Loop', description: 'Control flow block for iterating over collections or repeating actions', - longDescription: - 'Execute a set of blocks repeatedly, either for a fixed number of iterations or for each item in a collection. Loop blocks create sub-workflows that run multiple times with different iteration data.', - category: 'blocks', - bgColor: '#9333EA', - subBlocks: [ - { - id: 'iterationType', - title: 'Iteration Type', - type: 'dropdown', - layout: 'full', - options: [ - { label: 'Fixed Count', id: 'fixed' }, - { label: 'For Each Item', id: 'forEach' }, - ], - description: 'Choose how the loop should iterate', - }, - { - id: 'iterationCount', - title: 'Iteration Count', - type: 'short-input', - layout: 'half', - placeholder: '5', - condition: { field: 'iterationType', value: 'fixed' }, - description: 'Number of times to repeat the loop', - }, - { - id: 'collection', - title: 'Collection', - type: 'short-input', - layout: 'full', - placeholder: 'Reference to array or object', - condition: { field: 'iterationType', value: 'forEach' }, - description: 'Array or object to iterate over', - }, - ], inputs: { - iterationType: { type: 'string', required: true }, - iterationCount: { type: 'number', required: false }, - collection: { type: 'array|object', required: false }, + loopType: { type: 'string', required: true, enum: ['for', 'forEach'] }, + iterations: { type: 'number', required: false, minimum: 1, maximum: 1000 }, + collection: { type: 'string', required: false }, + maxConcurrency: { type: 'number', required: false, default: 1, minimum: 1, maximum: 10 }, }, outputs: { results: 'array', - iterations: 'number', + currentIndex: 'number', + currentItem: 'any', + totalIterations: 'number', }, tools: { access: [] }, }, @@ -226,49 +197,17 @@ const SPECIAL_BLOCKS_METADATA: Record = { type: 'parallel', name: 'Parallel', description: 'Control flow block for executing multiple branches simultaneously', - longDescription: - 'Execute multiple sets of blocks simultaneously, either with a fixed number of parallel branches or by distributing items from a collection across parallel executions.', - category: 'blocks', - bgColor: '#059669', - subBlocks: [ - { - id: 'parallelType', - title: 'Parallel Type', - type: 'dropdown', - layout: 'full', - options: [ - { label: 'Fixed Count', id: 'count' }, - { label: 'Collection Distribution', id: 'collection' }, - ], - description: 'Choose how parallel execution should work', - }, - { - id: 'parallelCount', - title: 'Parallel Count', - type: 'short-input', - layout: 'half', - placeholder: '3', - condition: { field: 'parallelType', value: 'count' }, - description: 'Number of parallel branches to execute', - }, - { - id: 'collection', - title: 'Collection', - type: 'short-input', - layout: 'full', - placeholder: 'Reference to array to distribute', - condition: { field: 'parallelType', value: 'collection' }, - description: 'Array to distribute across parallel executions', - }, - ], inputs: { - parallelType: { type: 'string', required: true }, - parallelCount: { type: 'number', required: false }, - collection: { type: 'array', required: false }, + parallelType: { type: 'string', required: true, enum: ['count', 'collection'] }, + count: { type: 'number', required: false, minimum: 1, maximum: 100 }, + collection: { type: 'string', required: false }, + maxConcurrency: { type: 'number', required: false, default: 10, minimum: 1, maximum: 50 }, }, outputs: { results: 'array', - branches: 'number', + branchId: 'number', + branchItem: 'any', + totalBranches: 'number', }, tools: { access: [] }, }, From 481c5a975ebce3a905256c414e782125402af6f6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 29 Jul 2025 13:31:21 -0700 Subject: [PATCH 107/184] Update docs --- apps/docs/content/docs/yaml/blocks/loop.mdx | 10 --- .../content/docs/yaml/blocks/parallel.mdx | 10 --- .../tools/blocks/get-blocks-metadata.ts | 66 +++++++++++-------- apps/sim/stores/copilot/store.ts | 20 ++---- 4 files changed, 41 insertions(+), 65 deletions(-) diff --git a/apps/docs/content/docs/yaml/blocks/loop.mdx b/apps/docs/content/docs/yaml/blocks/loop.mdx index cad80a7b8dd..b4177a1235f 100644 --- a/apps/docs/content/docs/yaml/blocks/loop.mdx +++ b/apps/docs/content/docs/yaml/blocks/loop.mdx @@ -59,9 +59,6 @@ properties: end: type: string description: Target block ID for loop completion (optional) - success: - type: string - description: Target block ID after loop completion (alternative format) error: type: string description: Target block ID for error handling @@ -79,13 +76,6 @@ connections: error: # Target block ID for error handling (optional) ``` -Alternative format (legacy): -```yaml -connections: - success: # Target block ID after loop completion - error: # Target block ID for error handling (optional) -``` - ## Child Block Configuration Blocks inside a loop must have their `parentId` set to the loop block ID: diff --git a/apps/docs/content/docs/yaml/blocks/parallel.mdx b/apps/docs/content/docs/yaml/blocks/parallel.mdx index 9700aa7c9d2..1a7dacf6328 100644 --- a/apps/docs/content/docs/yaml/blocks/parallel.mdx +++ b/apps/docs/content/docs/yaml/blocks/parallel.mdx @@ -59,9 +59,6 @@ properties: end: type: string description: Target block ID after all parallel instances complete (optional) - success: - type: string - description: Target block ID after all instances complete (alternative format) error: type: string description: Target block ID for error handling @@ -79,13 +76,6 @@ connections: error: # Target block ID for error handling (optional) ``` -Alternative format (legacy): -```yaml -connections: - success: # Target block ID after all instances complete - error: # Target block ID for error handling (optional) -``` - ## Child Block Configuration Blocks inside a parallel block must have their `parentId` set to the parallel block ID: diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts index 1cf6d52d809..0b9485a4239 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts @@ -50,16 +50,22 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis // Create result object const result: Record = {} + logger.info('=== GET BLOCKS METADATA DEBUG ===') + logger.info('Requested block IDs:', blockIds) + // Process each requested block ID for (const blockId of blockIds) { + logger.info(`\n--- Processing block: ${blockId} ---`) let metadata: any = {} // Check if it's a special block first if (SPECIAL_BLOCKS_METADATA[blockId]) { + logger.info(`✓ Found ${blockId} in SPECIAL_BLOCKS_METADATA`) // Start with the special block metadata metadata = { ...SPECIAL_BLOCKS_METADATA[blockId] } // Normalize tools structure to match regular blocks metadata.tools = metadata.tools?.access || [] + logger.info(`Initial metadata keys for ${blockId}:`, Object.keys(metadata)) } else { // Check if the block exists in the registry const blockConfig = blockRegistry[blockId] @@ -80,45 +86,34 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis // Read YAML schema from documentation if available (for both regular and special blocks) const docFileName = DOCS_FILE_MAPPING[blockId] || blockId + logger.info(`Checking if ${blockId} is in CORE_BLOCKS_WITH_DOCS:`, CORE_BLOCKS_WITH_DOCS.includes(blockId)) + if (CORE_BLOCKS_WITH_DOCS.includes(blockId)) { try { // Updated path to point to the actual YAML documentation location - const docPath = join(process.cwd(), 'apps', 'docs', 'content', 'docs', 'yaml', 'blocks', `${docFileName}.mdx`) + // Handle both monorepo root and apps/sim as working directory + const workingDir = process.cwd() + const isInAppsSim = workingDir.endsWith('/apps/sim') || workingDir.endsWith('\\apps\\sim') + const basePath = isInAppsSim ? join(workingDir, '..', '..') : workingDir + const docPath = join(basePath, 'apps', 'docs', 'content', 'docs', 'yaml', 'blocks', `${docFileName}.mdx`) + logger.info(`Looking for docs at: ${docPath}`) + logger.info(`File exists: ${existsSync(docPath)}`) + if (existsSync(docPath)) { const docContent = readFileSync(docPath, 'utf-8') + logger.info(`Doc content length: ${docContent.length}`) - // Extract schema from the documentation - const schemaMatch = docContent.match(/```yaml\s*\n([\s\S]*?)```/i) - if (schemaMatch) { - const yamlSchema = schemaMatch[1].trim() - // Parse high-level structure only - const lines = yamlSchema.split('\n') - const schemaInfo: any = { - fields: [], - example: yamlSchema, - } - - // Extract field names and structure - lines.forEach(line => { - const match = line.match(/^(\s*)(\w+):/) - if (match) { - const indent = match[1].length - const fieldName = match[2] - if (indent === 0) { - schemaInfo.fields.push({ - name: fieldName, - level: 'root', - }) - } - } - }) - - metadata.schema = schemaInfo - } + // Include the entire YAML documentation content + metadata.yamlDocumentation = docContent + logger.info(`✓ Added full YAML documentation for ${blockId}`) + } else { + logger.warn(`Documentation file not found for ${blockId}`) } } catch (error) { logger.warn(`Failed to read documentation for ${blockId}:`, error) } + } else { + logger.info(`${blockId} is NOT in CORE_BLOCKS_WITH_DOCS`) } // Add tool metadata if requested @@ -135,10 +130,23 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis } } + logger.info(`Final metadata keys for ${blockId}:`, Object.keys(metadata)) + logger.info(`Has YAML documentation: ${!!metadata.yamlDocumentation}`) + result[blockId] = metadata } + logger.info('\n=== FINAL RESULT ===') logger.info(`Successfully retrieved metadata for ${Object.keys(result).length} blocks`) + logger.info('Result keys:', Object.keys(result)) + + // Log the full result for parallel block if it's included + if (result.parallel) { + logger.info('\nParallel block metadata keys:', Object.keys(result.parallel)) + if (result.parallel.yamlDocumentation) { + logger.info('YAML documentation length:', result.parallel.yamlDocumentation.length) + } + } return { success: true, diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 9f1a705f150..c69b3fb0aec 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -124,24 +124,12 @@ function processWorkflowToolResult( */ function handleToolFailure( toolCall: any, - error: string, - get: () => CopilotStore + error: string ): void { toolCall.state = 'error' toolCall.error = error logger.error('Tool call failed:', toolCall.id, toolCall.name, error) - - // Retry workflow generation on failure - if (toolCall.name === COPILOT_TOOL_IDS.BUILD_WORKFLOW || - toolCall.name === COPILOT_TOOL_IDS.EDIT_WORKFLOW) { - logger.info(`${toolCall.name} failed, sending error back to agent for retry`) - setTimeout(() => { - get().sendImplicitFeedback( - `The previous workflow YAML generation failed with error: "${error}". Please analyze the error and try generating the workflow YAML again with the necessary fixes.` - ) - }, 1000) - } } /** @@ -272,7 +260,7 @@ const sseHandlers: Record = { processWorkflowToolResult(toolCall, parsedResult, get) } } else { - handleToolFailure(toolCall, result || 'Tool execution failed', get) + handleToolFailure(toolCall, result || 'Tool execution failed') } updateContentBlockToolCall(context.contentBlocks, toolCallId, toolCall) @@ -411,7 +399,7 @@ const sseHandlers: Record = { updateStreamingMessage(set, context) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) - handleToolFailure(context.toolCallBuffer, errorMsg, get) + handleToolFailure(context.toolCallBuffer, errorMsg) } context.toolCallBuffer = null @@ -451,7 +439,7 @@ const sseHandlers: Record = { tool_error: (data, context, get, set) => { const toolCall = context.toolCalls.find(tc => tc.id === data.toolCallId) if (toolCall) { - handleToolFailure(toolCall, data.error, get) + handleToolFailure(toolCall, data.error) updateContentBlockToolCall(context.contentBlocks, data.toolCallId, toolCall) updateStreamingMessage(set, context) } From 3880b02d0d87de2d61c725f21d80ec642f9c20f8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 29 Jul 2025 15:13:13 -0700 Subject: [PATCH 108/184] Conditional update --- apps/sim/executor/handlers/condition/condition-handler.ts | 7 ++++--- apps/sim/stores/workflows/yaml/parsing-utils.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index 57667c25f7d..a7ae11538dd 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -103,14 +103,15 @@ export class ConditionBlockHandler implements BlockHandler { } // 2. Resolve references WITHIN the specific condition's value string - let resolvedConditionValue = condition.value + const conditionValueString = String(condition.value || '') + let resolvedConditionValue = conditionValueString try { // Use full resolution pipeline: variables -> block references -> env vars - const resolvedVars = this.resolver.resolveVariableReferences(condition.value, block) + const resolvedVars = this.resolver.resolveVariableReferences(conditionValueString, block) const resolvedRefs = this.resolver.resolveBlockReferences(resolvedVars, context, block) resolvedConditionValue = this.resolver.resolveEnvVariables(resolvedRefs, true) logger.info( - `Resolved condition "${condition.title}" (${condition.id}): from "${condition.value}" to "${resolvedConditionValue}"` + `Resolved condition "${condition.title}" (${condition.id}): from "${conditionValueString}" to "${resolvedConditionValue}"` ) } catch (resolveError: any) { logger.error(`Failed to resolve references in condition: ${resolveError.message}`, { diff --git a/apps/sim/stores/workflows/yaml/parsing-utils.ts b/apps/sim/stores/workflows/yaml/parsing-utils.ts index 2beda16820b..b0acbf4e940 100644 --- a/apps/sim/stores/workflows/yaml/parsing-utils.ts +++ b/apps/sim/stores/workflows/yaml/parsing-utils.ts @@ -298,8 +298,9 @@ export function cleanConditionInputs( } } - if (condition.value?.trim()) { - tempConditions.push({ key, value: condition.value.trim() }) + const stringValue = String(condition.value || '') + if (stringValue.trim()) { + tempConditions.push({ key, value: stringValue.trim() }) } } }) @@ -372,7 +373,7 @@ export function expandConditionInputs( conditionsArray.push({ id: conditionId, title: title, - value: value || '', + value: String(value || ''), showTags: false, showEnvVars: false, searchTerm: '', From 9261f7cf5aa3fb64db408980c6d87b209add9327 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 29 Jul 2025 15:52:35 -0700 Subject: [PATCH 109/184] Yaml refactor --- .../copilot/tools/workflow/build-workflow.ts | 62 +++++++++---------- .../copilot/tools/workflow/edit-workflow.ts | 12 ++-- apps/sim/app/api/workflows/diff/route.ts | 25 +++----- .../workflow-text-editor/workflow-exporter.ts | 9 ++- .../workflow-text-editor.tsx | 30 ++++++--- 5 files changed, 73 insertions(+), 65 deletions(-) diff --git a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts index ab2eca3a91a..a318c0bfe80 100644 --- a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts +++ b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts @@ -41,38 +41,32 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise 0) { - logger.error('YAML parsing failed', { parseErrors }) + if (!conversionResult.success || !conversionResult.workflowState) { + logger.error('YAML conversion failed', { + errors: conversionResult.errors, + warnings: conversionResult.warnings + }) return { success: false, - message: `Failed to parse YAML workflow: ${parseErrors.join(', ')}`, + message: `Failed to convert YAML workflow: ${conversionResult.errors.join(', ')}`, yamlContent, description, } } - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - logger.error('YAML conversion failed', { convertErrors }) - return { - success: false, - message: `Failed to convert YAML to workflow: ${convertErrors.join(', ')}`, - yamlContent, - description, - } - } + const { workflowState, idMapping } = conversionResult - // Create a basic workflow state structure - const workflowState = { + // Create a basic workflow state structure for preview + const previewWorkflowState = { blocks: {} as Record, edges: [] as any[], loops: {} as Record, @@ -81,36 +75,36 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise() - Object.keys(blocks).forEach((blockId) => { + Object.keys(workflowState.blocks).forEach((blockId) => { const previewId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 7)}` blockIdMapping.set(blockId, previewId) }) - // Add blocks to workflow state - for (const [originalBlockId, blockData] of Object.entries(blocks)) { - const previewBlockId = blockIdMapping.get(originalBlockId)! + // Add blocks to preview workflow state + for (const [originalId, block] of Object.entries(workflowState.blocks)) { + const previewBlockId = blockIdMapping.get(originalId)! - workflowState.blocks[previewBlockId] = { - ...blockData, + previewWorkflowState.blocks[previewBlockId] = { + ...block, id: previewBlockId, - position: (blockData as any).position || { x: 0, y: 0 }, + position: (block as any).position || { x: 0, y: 0 }, enabled: true, } } // Process edges with updated block IDs - workflowState.edges = edges.map((edge: any) => ({ + previewWorkflowState.edges = workflowState.edges.map((edge: any) => ({ ...edge, id: `edge-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, source: blockIdMapping.get(edge.source) || edge.source, target: blockIdMapping.get(edge.target) || edge.target, })) - const blocksCount = Object.keys(workflowState.blocks).length - const edgesCount = workflowState.edges.length + const blocksCount = Object.keys(previewWorkflowState.blocks).length + const edgesCount = previewWorkflowState.edges.length logger.info('Workflow built successfully', { blocksCount, edgesCount }) @@ -119,7 +113,7 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise { + // Parse current YAML using unified converter for validation + const { convertYamlToWorkflowState } = await import('@/lib/workflows/yaml-converter') const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') - const yaml = await import('yaml') - - // Parse current YAML to get the complete structure + const { data: workflowData, errors } = parseWorkflowYaml(currentYaml) + if (!workflowData || errors.length > 0) { - throw new Error(`Failed to parse current YAML: ${errors.join(', ')}`) + throw new Error(`Invalid YAML format: ${errors.join(', ')}`) } // Apply operations to the parsed YAML data (preserving all existing fields) @@ -223,7 +224,8 @@ async function applyOperationsToYaml( }) // Convert the complete workflow data back to YAML (preserving version and all other fields) - return yaml.stringify(workflowData) + const { dump: yamlDump } = await import('js-yaml') + return yamlDump(workflowData) } import { BaseCopilotTool } from '../base' diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index 7e6f4e0a957..574ef7ca88a 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { dump as yamlDump, load as yamlParse } from 'js-yaml' +import { dump as yamlDump } from 'js-yaml' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console-logger' @@ -16,49 +16,44 @@ const YamlDiffRequestSchema = z.object({ type YamlDiffRequest = z.infer /** - * Clean up YAML by removing empty blocks programmatically + * Clean up YAML content by removing empty blocks and formatting */ function cleanupYamlContent(yamlContent: string): string { try { - // Parse the YAML - const workflow = yamlParse(yamlContent) as any - - if (!workflow || !workflow.blocks) { + // Parse the YAML using the validated parser + const { data: workflowData, errors } = parseWorkflowYaml(yamlContent) + + if (errors.length > 0 || !workflowData || !workflowData.blocks) { return yamlContent } // Filter out empty blocks const cleanedBlocks: Record = {} - Object.entries(workflow.blocks).forEach(([blockId, block]) => { + Object.entries(workflowData.blocks).forEach(([blockId, block]) => { // Only include blocks that have at least type and name if ( block && typeof block === 'object' && (block as any).type && - (block as any).name && - Object.keys(block).length > 0 + (block as any).name ) { cleanedBlocks[blockId] = block - } else { - logger.info(`Filtering out empty block: ${blockId}`) } }) // Rebuild the workflow with cleaned blocks const cleanedWorkflow = { - ...workflow, + ...workflowData, blocks: cleanedBlocks, } - // Convert back to YAML return yamlDump(cleanedWorkflow, { indent: 2, lineWidth: -1, noRefs: true, - sortKeys: false, }) } catch (error) { - logger.warn('Failed to clean YAML content, returning original', error) + logger.error('Failed to cleanup YAML content:', error) return yamlContent } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts index cb12300d04a..f13f0581be1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts @@ -1,6 +1,7 @@ -import { dump as yamlDump, load as yamlLoad } from 'js-yaml' +import { dump as yamlDump } from 'js-yaml' import { createLogger } from '@/lib/logs/console-logger' import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' +import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -91,7 +92,11 @@ export function exportWorkflow(format: EditorFormat): string { */ export function parseWorkflowContent(content: string, format: EditorFormat): any { if (format === 'yaml') { - return yamlLoad(content) + const { data, errors } = parseWorkflowYaml(content) + if (errors.length > 0) { + throw new Error(`YAML parsing errors: ${errors.join(', ')}`) + } + return data } return JSON.parse(content) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx index 0aba8678571..621a5b98c5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx @@ -1,7 +1,8 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' -import { dump as yamlDump, load as yamlParse } from 'js-yaml' +import { useState, useCallback, useMemo, useEffect } from 'react' +import { dump as yamlDump } from 'js-yaml' +import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' import { AlertCircle, Check, FileCode, Save } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -53,7 +54,7 @@ export function WorkflowTextEditor({ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) // Validate content based on format - const validateContent = useCallback((text: string, fmt: EditorFormat): ValidationError[] => { + const validateSyntax = useCallback((text: string, fmt: EditorFormat): ValidationError[] => { const errors: ValidationError[] = [] if (!text.trim()) { @@ -62,7 +63,14 @@ export function WorkflowTextEditor({ try { if (fmt === 'yaml') { - yamlParse(text) + const { errors: yamlErrors } = parseWorkflowYaml(text) + if (yamlErrors.length > 0) { + yamlErrors.forEach(error => { + errors.push({ + message: error, + }) + }) + } } else if (fmt === 'json') { JSON.parse(text) } @@ -94,7 +102,11 @@ export function WorkflowTextEditor({ let parsed: any if (fromFormat === 'yaml') { - parsed = yamlParse(text) + const { data, errors } = parseWorkflowYaml(text) + if (errors.length > 0) { + throw new Error(`YAML parsing errors: ${errors.join(', ')}`) + } + parsed = data } else { parsed = JSON.parse(text) } @@ -122,13 +134,13 @@ export function WorkflowTextEditor({ setHasUnsavedChanges(newContent !== initialValue) // Validate on change - const errors = validateContent(newContent, currentFormat) + const errors = validateSyntax(newContent, currentFormat) setValidationErrors(errors) // Clear save result when editing setSaveResult(null) }, - [initialValue, currentFormat, validateContent] + [initialValue, currentFormat, validateSyntax] ) // Handle format changes @@ -143,13 +155,13 @@ export function WorkflowTextEditor({ setContent(convertedContent) // Validate converted content - const errors = validateContent(convertedContent, newFormat) + const errors = validateSyntax(convertedContent, newFormat) setValidationErrors(errors) // Notify parent onFormatChange?.(newFormat) }, - [content, currentFormat, convertFormat, validateContent, onFormatChange] + [content, currentFormat, convertFormat, validateSyntax, onFormatChange] ) // Handle save From 381c63b7fd1eced426b216d705444da2a227b506 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 29 Jul 2025 16:29:29 -0700 Subject: [PATCH 110/184] Set up yaml service client --- apps/sim/lib/yaml-service-client.ts | 164 ++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 apps/sim/lib/yaml-service-client.ts diff --git a/apps/sim/lib/yaml-service-client.ts b/apps/sim/lib/yaml-service-client.ts new file mode 100644 index 00000000000..91ed9d54218 --- /dev/null +++ b/apps/sim/lib/yaml-service-client.ts @@ -0,0 +1,164 @@ +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlServiceClient') + +interface YamlServiceConfig { + blockRegistry: Record + utilities: { + generateLoopBlocks: string + generateParallelBlocks: string + resolveOutputType: string + } +} + +interface ParseYamlResponse { + success: boolean + data?: any + errors: string[] +} + +interface ConvertYamlToWorkflowResponse { + success: boolean + workflowState?: WorkflowState + errors: string[] + warnings: string[] + idMapping?: Record +} + +interface GenerateYamlResponse { + success: boolean + yaml?: string + error?: string +} + +interface DiffYamlResponse { + changes: any[] + errors: string[] +} + +export class YamlServiceClient { + private simAgentClient: any + + constructor() { + // Lazy load sim-agent client to avoid circular dependencies + this.simAgentClient = null + } + + private async getSimAgentClient() { + if (!this.simAgentClient) { + const { simAgentClient } = await import('@/lib/sim-agent/client') + this.simAgentClient = simAgentClient + } + return this.simAgentClient + } + + private async getConfig(): Promise { + // Gather all dependencies needed by the YAML service + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + // Get the block type from the block config + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, // Add id field for YAML service + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + return { + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + } + } + + private async fetchFromService(endpoint: string, body: any): Promise { + try { + const client = await this.getSimAgentClient() + + // Use the sim-agent client to make the request + const response = await client.call(endpoint, { + workflowId: body.workflowId || 'yaml-service', + data: body + }) + + if (!response.success) { + throw new Error(response.error || 'YAML service error') + } + + return response.data + } catch (error) { + logger.error(`Failed to call YAML service ${endpoint}:`, error) + throw error + } + } + + async parseYaml(yamlContent: string): Promise { + return this.fetchFromService('/api/yaml/parse', { yamlContent }) + } + + async convertYamlToWorkflow( + yamlContent: string, + options?: { + generateNewIds?: boolean + preservePositions?: boolean + existingBlocks?: Record + } + ): Promise { + const config = await this.getConfig() + return this.fetchFromService('/api/yaml/to-workflow', { + yamlContent, + ...config, + options + }) + } + + async generateYaml( + workflowState: WorkflowState, + subBlockValues?: Record> + ): Promise { + const config = await this.getConfig() + return this.fetchFromService('/api/workflow/to-yaml', { + workflowState, + subBlockValues, + ...config + }) + } + + async diffYaml(originalYaml: string, modifiedYaml: string): Promise { + const config = await this.getConfig() + return this.fetchFromService('/api/yaml/diff', { + originalYaml, + modifiedYaml, + ...config + }) + } + + // Helper method to check if external service is available + async healthCheck(): Promise { + try { + const client = await this.getSimAgentClient() + // Check if sim-agent is configured and available + const config = client.getConfig() + return !!config.baseUrl && !!config.hasApiKey + } catch { + return false + } + } +} + +// Export singleton instance +export const yamlService = new YamlServiceClient() + +// Export types for consumers +export type { ParseYamlResponse, ConvertYamlToWorkflowResponse, GenerateYamlResponse, DiffYamlResponse } \ No newline at end of file From 8aa180c7b02f68add8251c9583ef52692d49103a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 29 Jul 2025 19:07:54 -0700 Subject: [PATCH 111/184] Yaml service migration --- .../tools/blocks/get-workflow-examples.ts | 56 --- .../tools/blocks/get-yaml-structure.ts | 36 -- apps/sim/app/api/copilot/tools/registry.ts | 4 - .../copilot/tools/workflow/build-workflow.ts | 60 ++- .../copilot/tools/workflow/edit-workflow.ts | 55 ++- .../tools/workflow/get-user-workflow.ts | 55 ++- .../sim/app/api/workflows/[id]/state/route.ts | 12 +- apps/sim/app/api/workflows/[id]/yaml/route.ts | 176 +++++-- apps/sim/app/api/workflows/diff/route.ts | 119 +++-- .../app/api/workflows/yaml/convert/route.ts | 20 +- apps/sim/app/api/yaml/diff/route.ts | 97 ++++ apps/sim/app/api/yaml/generate/route.ts | 97 ++++ apps/sim/app/api/yaml/health/route.ts | 47 ++ apps/sim/app/api/yaml/parse/route.ts | 94 ++++ apps/sim/app/api/yaml/to-workflow/route.ts | 102 ++++ .../workflow-text-editor/workflow-exporter.ts | 23 +- .../workflow-text-editor-modal.tsx | 22 +- .../workflow-text-editor.tsx | 23 +- .../create-menu/import-controls.tsx | 10 +- apps/sim/components/ui/tag-dropdown.tsx | 20 +- apps/sim/lib/workflows/diff/diff-engine.ts | 40 +- apps/sim/lib/workflows/yaml-converter.ts | 447 ------------------ apps/sim/lib/workflows/yaml-generator.ts | 280 ----------- apps/sim/lib/yaml-service-client.ts | 124 ++--- apps/sim/stores/workflows/yaml/store.ts | 23 +- 25 files changed, 990 insertions(+), 1052 deletions(-) delete mode 100644 apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts delete mode 100644 apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts create mode 100644 apps/sim/app/api/yaml/diff/route.ts create mode 100644 apps/sim/app/api/yaml/generate/route.ts create mode 100644 apps/sim/app/api/yaml/health/route.ts create mode 100644 apps/sim/app/api/yaml/parse/route.ts create mode 100644 apps/sim/app/api/yaml/to-workflow/route.ts delete mode 100644 apps/sim/lib/workflows/yaml-converter.ts delete mode 100644 apps/sim/lib/workflows/yaml-generator.ts diff --git a/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts b/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts deleted file mode 100644 index c7ff51f56e5..00000000000 --- a/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { WORKFLOW_EXAMPLES } from '@/lib/copilot/examples' -import { BaseCopilotTool } from '../base' - -interface GetWorkflowExamplesParams { - exampleIds: string[] -} - -interface WorkflowExamplesResult { - examples: Record - notFound: string[] - availableIds: string[] -} - -class GetWorkflowExamplesTool extends BaseCopilotTool { - readonly id = 'get_workflow_examples' - readonly displayName = 'Getting workflow examples' - - protected async executeImpl(params: GetWorkflowExamplesParams): Promise { - return getWorkflowExamples(params) - } -} - -// Export the tool instance -export const getWorkflowExamplesTool = new GetWorkflowExamplesTool() - -// Implementation function -async function getWorkflowExamples(params: GetWorkflowExamplesParams): Promise { - const logger = createLogger('GetWorkflowExamples') - - // Strict validation - exampleIds is required - if (!params || !params.exampleIds || !Array.isArray(params.exampleIds) || params.exampleIds.length === 0) { - throw new Error('exampleIds parameter is required and must be a non-empty array of example IDs') - } - - const { exampleIds } = params - - logger.info('Getting workflow examples for copilot', { exampleCount: exampleIds.length }) - - const examples: Record = {} - const notFound: string[] = [] - - for (const id of exampleIds) { - if (WORKFLOW_EXAMPLES[id]) { - examples[id] = WORKFLOW_EXAMPLES[id] - } else { - notFound.push(id) - } - } - - return { - examples, - notFound, - availableIds: Object.keys(WORKFLOW_EXAMPLES), - } -} diff --git a/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts b/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts deleted file mode 100644 index 0af00c90fac..00000000000 --- a/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { getYamlWorkflowPrompt } from '@/lib/copilot/prompts' -import { BaseCopilotTool } from '../base' - -interface GetYamlStructureParams { - // No parameters needed - just return the YAML structure guide -} - -interface YamlStructureResult { - guide: string - message: string -} - -class GetYamlStructureTool extends BaseCopilotTool { - readonly id = 'get_yaml_structure' - readonly displayName = 'Analyzing workflow structure' - - protected async executeImpl(params: GetYamlStructureParams): Promise { - return getYamlStructure() - } -} - -// Export the tool instance -export const getYamlStructureTool = new GetYamlStructureTool() - -// Implementation function -async function getYamlStructure(): Promise { - const logger = createLogger('GetYamlStructure') - - logger.info('Getting YAML structure guide') - - return { - guide: getYamlWorkflowPrompt(), - message: 'Complete YAML workflow syntax guide with examples and best practices', - } -} diff --git a/apps/sim/app/api/copilot/tools/registry.ts b/apps/sim/app/api/copilot/tools/registry.ts index 75dd28f245f..4141acb2283 100644 --- a/apps/sim/app/api/copilot/tools/registry.ts +++ b/apps/sim/app/api/copilot/tools/registry.ts @@ -3,8 +3,6 @@ import { COPILOT_TOOL_DISPLAY_NAMES, type CopilotToolId } from '@/stores/constan // Import all tools to register them import { getBlocksAndToolsTool } from './blocks/get-blocks-and-tools' import { getBlocksMetadataTool } from './blocks/get-blocks-metadata' -import { getWorkflowExamplesTool } from './blocks/get-workflow-examples' -import { getYamlStructureTool } from './blocks/get-yaml-structure' import { searchDocsTool } from './docs/search-docs' import { onlineSearchTool } from './other/online-search' import { getEnvironmentVariablesTool } from './user/get-environment-variables' @@ -88,8 +86,6 @@ export const copilotToolRegistry = new CopilotToolRegistry() // Register all tools copilotToolRegistry.register(getBlocksAndToolsTool) copilotToolRegistry.register(getBlocksMetadataTool) -copilotToolRegistry.register(getWorkflowExamplesTool) -copilotToolRegistry.register(getYamlStructureTool) copilotToolRegistry.register(searchDocsTool) copilotToolRegistry.register(onlineSearchTool) copilotToolRegistry.register(getEnvironmentVariablesTool) diff --git a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts index a318c0bfe80..61fb4d3b2b4 100644 --- a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts +++ b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts @@ -1,6 +1,14 @@ import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' import { BaseCopilotTool } from '../base' +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + interface BuildWorkflowParams { yamlContent: string description?: string @@ -41,15 +49,48 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/to-workflow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + }, + options: { + generateNewIds: true, + preservePositions: false + } + }), }) + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Sim agent API error: ${response.statusText}`) + } + + const conversionResult = await response.json() + if (!conversionResult.success || !conversionResult.workflowState) { logger.error('YAML conversion failed', { errors: conversionResult.errors, @@ -86,11 +127,12 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise { - // Parse current YAML using unified converter for validation - const { convertYamlToWorkflowState } = await import('@/lib/workflows/yaml-converter') - const { parseWorkflowYaml } = await import('@/stores/workflows/yaml/importer') - - const { data: workflowData, errors } = parseWorkflowYaml(currentYaml) + // Parse current YAML by calling sim-agent directly + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/parse`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent: currentYaml, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + + if (!response.ok) { + throw new Error(`Sim agent API error: ${response.statusText}`) + } + + const parseResult = await response.json() - if (!workflowData || errors.length > 0) { - throw new Error(`Invalid YAML format: ${errors.join(', ')}`) + if (!parseResult.success || !parseResult.data || parseResult.errors?.length > 0) { + throw new Error(`Invalid YAML format: ${parseResult.errors?.join(', ') || 'Unknown error'}`) } + + const workflowData = parseResult.data // Apply operations to the parsed YAML data (preserving all existing fields) logger.info('Starting YAML operations', { diff --git a/apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts b/apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts index 2b27c50cf2b..a4e46da7fb1 100644 --- a/apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts +++ b/apps/sim/app/api/copilot/tools/workflow/get-user-workflow.ts @@ -1,12 +1,19 @@ import { eq } from 'drizzle-orm' import { createLogger } from '@/lib/logs/console-logger' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' import { getBlock } from '@/blocks' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' import { db } from '@/db' import { workflow as workflowTable } from '@/db/schema' import { BaseCopilotTool } from '../base' +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + interface GetUserWorkflowParams { workflowId: string includeMetadata?: boolean @@ -82,9 +89,51 @@ async function getUserWorkflow(params: GetUserWorkflowParams): Promise { throw new Error('Workflow state is empty or invalid') } - // Generate YAML using server-side function - const yaml = generateWorkflowYaml(workflowState, subBlockValues) + // Generate YAML by calling sim-agent directly + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + const response = await fetch(`${SIM_AGENT_API_URL}/api/workflow/to-yaml`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + workflowState, + subBlockValues, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Sim agent API error: ${response.statusText}`) + } + + const generateResult = await response.json() + + if (!generateResult.success || !generateResult.yaml) { + throw new Error(generateResult.error || 'Failed to generate YAML') + } + + const yaml = generateResult.yaml + if (!yaml || yaml.trim() === '') { throw new Error('Generated YAML is empty') } diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index bf46321c148..1be68668806 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -177,7 +177,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const filteredBlocks = Object.entries(state.blocks).reduce( (acc, [blockId, block]) => { if (block.type && block.name) { - acc[blockId] = block + // Ensure all required fields are present + acc[blockId] = { + ...block, + enabled: block.enabled !== undefined ? block.enabled : true, + horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, + isWide: block.isWide !== undefined ? block.isWide : false, + height: block.height !== undefined ? block.height : 0, + subBlocks: block.subBlocks || {}, + outputs: block.outputs || {}, + data: block.data || {} + } } return acc }, diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index 85c1fc02bd5..bc8f4a47b64 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -8,14 +8,18 @@ import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' import { getUserId as getOAuthUserId } from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' +import { getAllBlocks } from '@/blocks/registry' import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' import { db } from '@/db' import { copilotCheckpoints, workflow as workflowTable } from '@/db/schema' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer' + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY export const dynamic = 'force-dynamic' @@ -50,7 +54,46 @@ async function createWorkflowCheckpoint( if (currentWorkflowData) { // Generate YAML from current state - const currentYaml = generateWorkflowYaml(currentWorkflowData) + // Gather block registry and utilities for sim-agent + const allBlockConfigs = getAllBlocks() + const blockRegistry = allBlockConfigs.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + const generateResponse = await fetch(`${SIM_AGENT_API_URL}/api/workflow/to-yaml`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + workflowState: currentWorkflowData, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + + if (!generateResponse.ok) { + const errorText = await generateResponse.text() + throw new Error(`Failed to generate YAML: ${errorText}`) + } + + const generateResult = await generateResponse.json() + if (!generateResult.success || !generateResult.yaml) { + throw new Error(generateResult.error || 'Failed to generate YAML') + } + const currentYaml = generateResult.yaml // Create checkpoint await db.insert(copilotCheckpoints).values({ @@ -221,32 +264,105 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ await createWorkflowCheckpoint(userId, workflowId, chatId, requestId) } - // Parse YAML content - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) + // Convert YAML to workflow state by calling sim-agent directly + // Gather block registry and utilities for sim-agent + const allBlockTypes = getAllBlocks() + const blockRegistry = allBlockTypes.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + const conversionResponse = await fetch(`${SIM_AGENT_API_URL}/api/yaml/to-workflow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + }, + options: { + generateNewIds: false, // We'll handle ID generation manually for now + preservePositions: true + } + }), + }) - if (!yamlWorkflow || parseErrors.length > 0) { - logger.error(`[${requestId}] YAML parsing failed`, { parseErrors }) + if (!conversionResponse.ok) { + const errorText = await conversionResponse.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: conversionResponse.status, + error: errorText, + }) return NextResponse.json({ success: false, - message: 'Failed to parse YAML workflow', - errors: parseErrors, + message: 'Failed to convert YAML to workflow', + errors: [`Sim agent API error: ${conversionResponse.statusText}`], warnings: [], }) } - // Convert YAML to workflow format - const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow) + const conversionResult = await conversionResponse.json() - if (convertErrors.length > 0) { - logger.error(`[${requestId}] YAML conversion failed`, { convertErrors }) + if (!conversionResult.success || !conversionResult.workflowState) { + logger.error(`[${requestId}] YAML conversion failed`, { + errors: conversionResult.errors, + warnings: conversionResult.warnings + }) return NextResponse.json({ success: false, message: 'Failed to convert YAML to workflow', - errors: convertErrors, - warnings, + errors: conversionResult.errors, + warnings: conversionResult.warnings || [], }) } + const { workflowState } = conversionResult + + // Ensure all blocks have required fields + Object.values(workflowState.blocks).forEach((block: any) => { + if (block.enabled === undefined) { + block.enabled = true + } + if (block.horizontalHandles === undefined) { + block.horizontalHandles = true + } + if (block.isWide === undefined) { + block.isWide = false + } + if (block.height === undefined) { + block.height = 0 + } + if (!block.subBlocks) { + block.subBlocks = {} + } + if (!block.outputs) { + block.outputs = {} + } + }) + + const blocks = Object.values(workflowState.blocks) as Array<{ + id: string + type: string + name: string + position: { x: number; y: number } + subBlocks?: Record + data?: Record + }> + const edges = workflowState.edges + const warnings = conversionResult.warnings || [] + // Create workflow state const newWorkflowState: any = { blocks: {} as Record, @@ -300,17 +416,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } }) - // Also ensure we have subBlocks for any YAML inputs that might not be in the config + // Also ensure we have subBlocks for any existing subBlocks from conversion // This handles cases where hidden fields or dynamic configurations exist - Object.keys(block.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', // Default type for dynamic inputs - value: null, + if (block.subBlocks) { + Object.keys(block.subBlocks).forEach((subBlockKey) => { + if (!subBlocks[subBlockKey]) { + subBlocks[subBlockKey] = { + id: subBlockKey, + type: block.subBlocks![subBlockKey].type || 'short-input', + value: block.subBlocks![subBlockKey].value || null, + } } - } - }) + }) + } // Set up outputs from block configuration const outputs = resolveOutputType(blockConfig.outputs) @@ -335,16 +453,16 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } - // Set input values as subblock values with block reference mapping + // Set subblock values with block reference mapping for (const block of blocks) { const newId = blockIdMapping.get(block.id) if (!newId || !newWorkflowState.blocks[newId]) continue - if (block.inputs && typeof block.inputs === 'object') { - Object.entries(block.inputs).forEach(([key, value]) => { - if (newWorkflowState.blocks[newId].subBlocks[key]) { + if (block.subBlocks && typeof block.subBlocks === 'object') { + Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + if (newWorkflowState.blocks[newId].subBlocks[key] && subBlock.value !== undefined) { // Update block references in values to use new mapped IDs - const processedValue = updateBlockReferences(value, blockIdMapping, requestId) + const processedValue = updateBlockReferences(subBlock.value, blockIdMapping, requestId) newWorkflowState.blocks[newId].subBlocks[key].value = processedValue } }) diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts index 574ef7ca88a..28a6d6e627e 100644 --- a/apps/sim/app/api/workflows/diff/route.ts +++ b/apps/sim/app/api/workflows/diff/route.ts @@ -3,10 +3,58 @@ import { dump as yamlDump } from 'js-yaml' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console-logger' -import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' const logger = createLogger('WorkflowYamlDiffAPI') +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +/** + * Helper function to parse YAML by calling sim-agent + */ +async function parseYamlViaSim(yamlContent: string) { + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/parse`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + + if (!response.ok) { + throw new Error(`Sim agent API error: ${response.statusText}`) + } + + return response.json() +} + // Request schema for YAML diff operations const YamlDiffRequestSchema = z.object({ original_yaml: z.string().min(1, 'Original YAML content is required'), @@ -18,14 +66,16 @@ type YamlDiffRequest = z.infer /** * Clean up YAML content by removing empty blocks and formatting */ -function cleanupYamlContent(yamlContent: string): string { +async function cleanupYamlContent(yamlContent: string): Promise { try { - // Parse the YAML using the validated parser - const { data: workflowData, errors } = parseWorkflowYaml(yamlContent) + // Parse the YAML by calling sim-agent directly + const parseResult = await parseYamlViaSim(yamlContent) - if (errors.length > 0 || !workflowData || !workflowData.blocks) { + if (!parseResult.success || !parseResult.data || !parseResult.data.blocks) { return yamlContent } + + const workflowData = parseResult.data // Filter out empty blocks const cleanedBlocks: Record = {} @@ -433,30 +483,45 @@ export async function POST(request: NextRequest) { agent_yaml.substring(0, 500) ) - // Clean up YAML to remove empty blocks - const cleanedOriginalYaml = cleanupYamlContent(original_yaml) - const cleanedAgentYaml = cleanupYamlContent(agent_yaml) - - logger.info(`[${requestId}] Cleaned YAML by removing empty blocks`) - - // Parse both YAML documents - const { data: originalWorkflow, errors: originalErrors } = - parseWorkflowYaml(cleanedOriginalYaml) - const { data: agentWorkflow, errors: agentErrors } = parseWorkflowYaml(cleanedAgentYaml) - - // Check for parsing errors - if (!originalWorkflow || originalErrors.length > 0) { - logger.error(`[${requestId}] Original YAML parsing failed`, { originalErrors }) - return NextResponse.json( - { - success: false, - message: 'Failed to parse original YAML workflow', - errors: originalErrors, - }, - { status: 400 } - ) + // Handle empty original YAML (new workflow case) + let originalWorkflow: any = null + let originalErrors: string[] = [] + + if (!original_yaml || original_yaml.trim() === '') { + logger.info(`[${requestId}] No original YAML provided, treating as new workflow`) + // Create empty workflow structure for comparison + originalWorkflow = { + name: 'New Workflow', + blocks: {}, + edges: [] + } + } else { + // Clean up and parse original YAML + const cleanedOriginalYaml = await cleanupYamlContent(original_yaml) + const originalParseResult = await parseYamlViaSim(cleanedOriginalYaml) + originalWorkflow = originalParseResult.data + originalErrors = originalParseResult.errors || [] + + // Check for parsing errors + if (!originalWorkflow || originalErrors.length > 0) { + logger.error(`[${requestId}] Original YAML parsing failed`, { originalErrors }) + return NextResponse.json( + { + success: false, + message: 'Failed to parse original YAML workflow', + errors: originalErrors, + }, + { status: 400 } + ) + } } + // Clean up and parse agent YAML + const cleanedAgentYaml = await cleanupYamlContent(agent_yaml) + const agentParseResult = await parseYamlViaSim(cleanedAgentYaml) + const agentWorkflow = agentParseResult.data + const agentErrors = agentParseResult.errors || [] + if (!agentWorkflow || agentErrors.length > 0) { logger.error(`[${requestId}] Agent YAML parsing failed`, { agentErrors }) return NextResponse.json( diff --git a/apps/sim/app/api/workflows/yaml/convert/route.ts b/apps/sim/app/api/workflows/yaml/convert/route.ts index 4da955add23..b6e23b92f3a 100644 --- a/apps/sim/app/api/workflows/yaml/convert/route.ts +++ b/apps/sim/app/api/workflows/yaml/convert/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console-logger' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' +import { yamlService } from '@/lib/yaml-service-client' const logger = createLogger('WorkflowYamlAPI') @@ -20,16 +20,26 @@ export async function POST(request: NextRequest) { ) } - // Generate YAML using the shared utility - const yamlContent = generateWorkflowYaml(workflowState, subBlockValues) + // Generate YAML using the yaml service + const result = await yamlService.generateYaml(workflowState, subBlockValues) + + if (!result.success || !result.yaml) { + return NextResponse.json( + { + success: false, + error: result.error || 'Failed to generate YAML', + }, + { status: 500 } + ) + } logger.info(`[${requestId}] Successfully generated YAML`, { - yamlLength: yamlContent.length, + yamlLength: result.yaml.length, }) return NextResponse.json({ success: true, - yaml: yamlContent, + yaml: result.yaml, }) } catch (error) { logger.error(`[${requestId}] YAML generation failed`, error) diff --git a/apps/sim/app/api/yaml/diff/route.ts b/apps/sim/app/api/yaml/diff/route.ts new file mode 100644 index 00000000000..195c28b9b0a --- /dev/null +++ b/apps/sim/app/api/yaml/diff/route.ts @@ -0,0 +1,97 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlDiffAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const DiffRequestSchema = z.object({ + originalYaml: z.string(), + modifiedYaml: z.string(), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { originalYaml, modifiedYaml } = DiffRequestSchema.parse(body) + + logger.info(`[${requestId}] Diffing YAML`, { + originalLength: originalYaml.length, + modifiedLength: modifiedYaml.length, + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/diff`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + originalYaml, + modifiedYaml, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + }) + return NextResponse.json( + { changes: [], errors: [`Sim agent API error: ${response.statusText}`] }, + { status: response.status } + ) + } + + const result = await response.json() + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] YAML diff failed:`, error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { changes: [], errors: error.errors.map(e => e.message) }, + { status: 400 } + ) + } + + return NextResponse.json( + { + changes: [], + errors: [error instanceof Error ? error.message : 'Unknown error'] + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/yaml/generate/route.ts b/apps/sim/app/api/yaml/generate/route.ts new file mode 100644 index 00000000000..be68053bf20 --- /dev/null +++ b/apps/sim/app/api/yaml/generate/route.ts @@ -0,0 +1,97 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlGenerateAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const GenerateRequestSchema = z.object({ + workflowState: z.any(), // Let the yaml service handle validation + subBlockValues: z.record(z.record(z.any())).optional(), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { workflowState, subBlockValues } = GenerateRequestSchema.parse(body) + + logger.info(`[${requestId}] Generating YAML from workflow`, { + blocksCount: workflowState.blocks ? Object.keys(workflowState.blocks).length : 0, + edgesCount: workflowState.edges ? workflowState.edges.length : 0, + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/workflow/to-yaml`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + workflowState, + subBlockValues, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + }) + return NextResponse.json( + { success: false, error: `Sim agent API error: ${response.statusText}` }, + { status: response.status } + ) + } + + const result = await response.json() + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] YAML generation failed:`, error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: error.errors.map(e => e.message).join(', ') }, + { status: 400 } + ) + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/yaml/health/route.ts b/apps/sim/app/api/yaml/health/route.ts new file mode 100644 index 00000000000..b222f9c1a8e --- /dev/null +++ b/apps/sim/app/api/yaml/health/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('YamlHealthAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +export async function GET() { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + logger.info(`[${requestId}] Checking YAML service health`, { + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Check sim-agent health + const response = await fetch(`${SIM_AGENT_API_URL}/health`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + }) + + const isHealthy = response.ok + + return NextResponse.json({ + success: true, + healthy: isHealthy, + service: 'yaml' + }) + } catch (error) { + logger.error(`[${requestId}] YAML health check failed:`, error) + + return NextResponse.json( + { + success: false, + healthy: false, + service: 'yaml', + error: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/yaml/parse/route.ts b/apps/sim/app/api/yaml/parse/route.ts new file mode 100644 index 00000000000..f7341591284 --- /dev/null +++ b/apps/sim/app/api/yaml/parse/route.ts @@ -0,0 +1,94 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlParseAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const ParseRequestSchema = z.object({ + yamlContent: z.string().min(1), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { yamlContent } = ParseRequestSchema.parse(body) + + logger.info(`[${requestId}] Parsing YAML`, { + contentLength: yamlContent.length, + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/parse`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + } + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + }) + return NextResponse.json( + { success: false, errors: [`Sim agent API error: ${response.statusText}`] }, + { status: response.status } + ) + } + + const result = await response.json() + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] YAML parse failed:`, error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, errors: error.errors.map(e => e.message) }, + { status: 400 } + ) + } + + return NextResponse.json( + { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown error'] + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/yaml/to-workflow/route.ts b/apps/sim/app/api/yaml/to-workflow/route.ts new file mode 100644 index 00000000000..a5d78e71a88 --- /dev/null +++ b/apps/sim/app/api/yaml/to-workflow/route.ts @@ -0,0 +1,102 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlToWorkflowAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const ConvertRequestSchema = z.object({ + yamlContent: z.string().min(1), + options: z.object({ + generateNewIds: z.boolean().optional(), + preservePositions: z.boolean().optional(), + existingBlocks: z.record(z.any()).optional(), + }).optional(), +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { yamlContent, options } = ConvertRequestSchema.parse(body) + + logger.info(`[${requestId}] Converting YAML to workflow`, { + contentLength: yamlContent.length, + hasOptions: !!options, + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/to-workflow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent, + blockRegistry, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString() + }, + options + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + }) + return NextResponse.json( + { success: false, errors: [`Sim agent API error: ${response.statusText}`], warnings: [] }, + { status: response.status } + ) + } + + const result = await response.json() + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] YAML to workflow conversion failed:`, error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, errors: error.errors.map(e => e.message), warnings: [] }, + { status: 400 } + ) + } + + return NextResponse.json( + { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown error'], + warnings: [] + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts index f13f0581be1..fbf4076d6b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-exporter.ts @@ -1,7 +1,6 @@ import { dump as yamlDump } from 'js-yaml' import { createLogger } from '@/lib/logs/console-logger' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' -import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' +import { yamlService } from '@/lib/yaml-service-client' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -70,13 +69,17 @@ export function generateFullWorkflowData() { /** * Export workflow in the specified format */ -export function exportWorkflow(format: EditorFormat): string { +export async function exportWorkflow(format: EditorFormat): Promise { try { if (format === 'yaml') { - // Use the existing YAML generator for condensed format + // Use the YAML service for conversion const workflowState = useWorkflowStore.getState() const subBlockValues = getSubBlockValues() - return generateWorkflowYaml(workflowState, subBlockValues) + const result = await yamlService.generateYaml(workflowState, subBlockValues) + if (!result.success || !result.yaml) { + throw new Error(result.error || 'Failed to generate YAML') + } + return result.yaml } // Generate full JSON format const fullData = generateFullWorkflowData() @@ -90,13 +93,13 @@ export function exportWorkflow(format: EditorFormat): string { /** * Parse workflow content based on format */ -export function parseWorkflowContent(content: string, format: EditorFormat): any { +export async function parseWorkflowContent(content: string, format: EditorFormat): Promise { if (format === 'yaml') { - const { data, errors } = parseWorkflowYaml(content) - if (errors.length > 0) { - throw new Error(`YAML parsing errors: ${errors.join(', ')}`) + const result = await yamlService.parseYaml(content) + if (!result.success || result.errors.length > 0) { + throw new Error(`YAML parsing errors: ${result.errors.join(', ')}`) } - return data + return result.data } return JSON.parse(content) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor-modal.tsx index 420f86d6853..b2334b1f2f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor-modal.tsx @@ -41,15 +41,17 @@ export function WorkflowTextEditorModal({ useEffect(() => { if (isOpen && activeWorkflowId) { setIsLoading(true) - try { - const content = exportWorkflow(format) - setInitialContent(content) - } catch (error) { - logger.error('Failed to export workflow:', error) - setInitialContent('# Error loading workflow content') - } finally { - setIsLoading(false) - } + exportWorkflow(format) + .then(content => { + setInitialContent(content) + }) + .catch(error => { + logger.error('Failed to export workflow:', error) + setInitialContent('# Error loading workflow content') + }) + .finally(() => { + setIsLoading(false) + }) } }, [isOpen, format, activeWorkflowId]) @@ -88,7 +90,7 @@ export function WorkflowTextEditorModal({ // Update initial content to reflect current state try { - const updatedContent = exportWorkflow(contentFormat) + const updatedContent = await exportWorkflow(contentFormat) setInitialContent(updatedContent) } catch (error) { logger.error('Failed to refresh content after save:', error) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx index 621a5b98c5e..9ad399d9df4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-text-editor/workflow-text-editor.tsx @@ -1,8 +1,8 @@ 'use client' import { useState, useCallback, useMemo, useEffect } from 'react' -import { dump as yamlDump } from 'js-yaml' -import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' +import { dump as yamlDump, load as yamlLoad } from 'js-yaml' +import { yamlService } from '@/lib/yaml-service-client' import { AlertCircle, Check, FileCode, Save } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -63,18 +63,12 @@ export function WorkflowTextEditor({ try { if (fmt === 'yaml') { - const { errors: yamlErrors } = parseWorkflowYaml(text) - if (yamlErrors.length > 0) { - yamlErrors.forEach(error => { - errors.push({ - message: error, - }) - }) - } + // Basic YAML syntax validation using js-yaml + yamlLoad(text) } else if (fmt === 'json') { JSON.parse(text) } - } catch (error) { + } catch (error: any) { const errorMessage = error instanceof Error ? error.message : 'Parse error' // Extract line/column info if available @@ -102,11 +96,8 @@ export function WorkflowTextEditor({ let parsed: any if (fromFormat === 'yaml') { - const { data, errors } = parseWorkflowYaml(text) - if (errors.length > 0) { - throw new Error(`YAML parsing errors: ${errors.join(', ')}`) - } - parsed = data + // Use basic YAML parsing for synchronous conversion + parsed = yamlLoad(text) } else { parsed = JSON.parse(text) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx index a6852f819e5..8de79170b11 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/create-menu/import-controls.tsx @@ -4,7 +4,7 @@ import { forwardRef, useImperativeHandle, useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { parseWorkflowYaml } from '@/stores/workflows/yaml/importer' +import { yamlService } from '@/lib/yaml-service-client' const logger = createLogger('ImportControls') @@ -86,16 +86,18 @@ export const ImportControls = forwardRef try { // First validate the YAML without importing - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(content) + const parseResult = await yamlService.parseYaml(content) - if (!yamlWorkflow || parseErrors.length > 0) { + if (!parseResult.success || !parseResult.data) { setImportResult({ success: false, - errors: parseErrors, + errors: parseResult.errors || ['Failed to parse YAML'], warnings: [], }) return } + + const yamlWorkflow = parseResult.data // Create a new workflow const newWorkflowId = await createWorkflow({ diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index df2f613df84..04f70ab6bc5 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -176,12 +176,12 @@ export const TagDropdown: React.FC = ({ const schemaFields = extractFieldsFromSchema(responseFormat) if (schemaFields.length > 0) { blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`) - } else { - // Fallback to default if schema extraction failed - const outputPaths = generateOutputPaths(blockConfig.outputs) - blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) - } - } else if (Object.keys(blockConfig.outputs).length === 0) { + } else { + // Fallback to default if schema extraction failed + const outputPaths = generateOutputPaths(blockConfig.outputs || {}) + blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + } + } else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) { // Handle blocks with no outputs (like starter) - check for custom input fields if (sourceBlock.type === 'starter') { // Check what start workflow mode is selected @@ -218,7 +218,7 @@ export const TagDropdown: React.FC = ({ } } else { // Use default block outputs - const outputPaths = generateOutputPaths(blockConfig.outputs) + const outputPaths = generateOutputPaths(blockConfig.outputs || {}) blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) } @@ -437,10 +437,10 @@ export const TagDropdown: React.FC = ({ blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`) } else { // Fallback to default if schema extraction failed - const outputPaths = generateOutputPaths(blockConfig.outputs) + const outputPaths = generateOutputPaths(blockConfig.outputs || {}) blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) } - } else if (Object.keys(blockConfig.outputs).length === 0) { + } else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) { // Handle blocks with no outputs (like starter) - check for custom input fields if (accessibleBlock.type === 'starter') { // Check what start workflow mode is selected @@ -477,7 +477,7 @@ export const TagDropdown: React.FC = ({ } } else { // Use default block outputs - const outputPaths = generateOutputPaths(blockConfig.outputs) + const outputPaths = generateOutputPaths(blockConfig.outputs || {}) blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) } diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 2243c1ef6c3..7925437f4ba 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -1,5 +1,5 @@ import { createLogger } from '@/lib/logs/console-logger' -import { convertYamlToWorkflowState } from '@/lib/workflows/yaml-converter' +import { yamlService } from '@/lib/yaml-service-client' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDiffEngine') @@ -50,7 +50,7 @@ export class WorkflowDiffEngine { logger.info('Creating diff from YAML content') // Convert YAML to workflow state with new IDs - const conversionResult = await convertYamlToWorkflowState(yamlContent, { + const conversionResult = await yamlService.convertYamlToWorkflow(yamlContent, { generateNewIds: true, }) @@ -78,11 +78,12 @@ export class WorkflowDiffEngine { deleted_blocks: diffAnalysis.deleted_blocks, edge_diff: diffAnalysis.edge_diff, }) - this.applyDiffMarkers(proposedState, diffAnalysis, conversionResult.idMapping!) + const idMapping = conversionResult.idMapping ? new Map(Object.entries(conversionResult.idMapping)) : new Map() + this.applyDiffMarkers(proposedState, diffAnalysis, idMapping) // Create a mapped version of the diff analysis with new IDs mappedDiffAnalysis = this.createMappedDiffAnalysis( diffAnalysis, - conversionResult.idMapping! + idMapping ) } else { logger.info('No diff analysis provided, skipping diff markers') @@ -224,7 +225,7 @@ export class WorkflowDiffEngine { } // Convert YAML to workflow state with new IDs - const conversionResult = await convertYamlToWorkflowState(yamlContent, { + const conversionResult = await yamlService.convertYamlToWorkflow(yamlContent, { generateNewIds: true, }) @@ -359,7 +360,7 @@ export class WorkflowDiffEngine { // Create a combined ID mapping that includes our block remapping const combinedIdMapping = new Map() if (conversionResult.idMapping) { - conversionResult.idMapping.forEach((newId, oldId) => { + Object.entries(conversionResult.idMapping).forEach(([oldId, newId]) => { // Map original ID to final ID (which might be an existing block ID) const finalId = blockIdMapping.get(newId) || newId combinedIdMapping.set(oldId, finalId) @@ -763,15 +764,28 @@ export class WorkflowDiffEngine { const filteredBlocks: Record = {} Object.entries(cleanState.blocks).forEach(([blockId, block]) => { if (block.type && block.name) { + // Remove diff markers and ensure all required fields are present + const cleanBlock: BlockState = { + ...block, + enabled: block.enabled !== undefined ? block.enabled : true, + horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, + isWide: block.isWide !== undefined ? block.isWide : false, + height: block.height !== undefined ? block.height : 0, + subBlocks: block.subBlocks || {}, + outputs: block.outputs !== undefined && block.outputs !== null ? block.outputs : {}, + data: block.data || {} + } + // Remove diff markers - ;(block as any).is_diff = undefined - ;(block as any).field_diff = undefined - filteredBlocks[blockId] = block + ;(cleanBlock as any).is_diff = undefined + ;(cleanBlock as any).field_diff = undefined + + filteredBlocks[blockId] = cleanBlock } else { logger.info(`Filtering out block ${blockId} - missing type or name`) } }) - + cleanState.blocks = filteredBlocks // Filter out edges that connect to removed blocks @@ -780,9 +794,15 @@ export class WorkflowDiffEngine { (edge) => validBlockIds.has(edge.source) && validBlockIds.has(edge.target) ) + // Ensure loops and parallels are always present (even if empty) + cleanState.loops = cleanState.loops || {} + cleanState.parallels = cleanState.parallels || {} + logger.info('Diff accepted', { blocksCount: Object.keys(cleanState.blocks).length, edgesCount: cleanState.edges.length, + loopsCount: Object.keys(cleanState.loops).length, + parallelsCount: Object.keys(cleanState.parallels).length, }) this.clearDiff() diff --git a/apps/sim/lib/workflows/yaml-converter.ts b/apps/sim/lib/workflows/yaml-converter.ts deleted file mode 100644 index 6a557b8b7fd..00000000000 --- a/apps/sim/lib/workflows/yaml-converter.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { v4 as uuidv4 } from 'uuid' -import { createLogger } from '@/lib/logs/console-logger' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' -import { getBlock } from '@/blocks' -import { resolveOutputType } from '@/blocks/utils' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { convertYamlToWorkflow, parseWorkflowYaml } from '@/stores/workflows/yaml/importer' -import type { ImportedEdge } from '@/stores/workflows/yaml/parsing-utils' - -// Define local types that aren't exported from importer -interface ImportedBlock { - id: string - type: string - name: string - inputs: Record - position: { x: number; y: number } - data?: Record - parentId?: string - extent?: 'parent' -} - -interface ImportResult { - blocks: ImportedBlock[] - edges: ImportedEdge[] - errors: string[] - warnings: string[] -} - -const logger = createLogger('YamlConverter') - -/** - * Unified YAML converter that handles all YAML<->WorkflowState conversions - * This consolidates logic from multiple places to avoid duplication - */ - -export interface YamlConversionResult { - success: boolean - workflowState?: WorkflowState - errors: string[] - warnings: string[] - idMapping?: Map -} - -export interface WorkflowToYamlResult { - success: boolean - yaml?: string - error?: string -} - -/** - * Convert YAML content to a complete WorkflowState - * This consolidates logic from diff store, copilot store, and API routes - */ -export async function convertYamlToWorkflowState( - yamlContent: string, - options: { - generateNewIds?: boolean - existingBlocks?: Record - preservePositions?: boolean - } = {} -): Promise { - const { generateNewIds = true, existingBlocks = {}, preservePositions = false } = options - - // Step 1: Parse YAML - const { data: yamlWorkflow, errors: parseErrors } = parseWorkflowYaml(yamlContent) - - if (!yamlWorkflow || parseErrors.length > 0) { - return { - success: false, - errors: parseErrors, - warnings: [], - } - } - - // Step 2: Convert YAML to imported blocks/edges - const { blocks, edges, errors: convertErrors, warnings } = convertYamlToWorkflow(yamlWorkflow) - - if (convertErrors.length > 0) { - return { - success: false, - errors: convertErrors, - warnings, - } - } - - // Step 3: Create ID mapping - const idMapping = new Map() - - if (generateNewIds) { - blocks.forEach((block) => { - const newId = uuidv4() - idMapping.set(block.id, newId) - }) - } else { - // Use existing IDs - blocks.forEach((block) => { - idMapping.set(block.id, block.id) - }) - } - - // Step 4: Build WorkflowState with proper block configuration - const workflowBlocks: Record = {} - - // First pass: Update all parentIds in imported blocks before creating BlockStates - blocks.forEach((importedBlock) => { - if (importedBlock.parentId) { - const mappedParentId = idMapping.get(importedBlock.parentId) - if (mappedParentId) { - logger.info( - `Updating parentId for block ${importedBlock.id}: ${importedBlock.parentId} -> ${mappedParentId}` - ) - importedBlock.parentId = mappedParentId - } else { - logger.warn( - `Parent ID ${importedBlock.parentId} not found in ID mapping for block ${importedBlock.id}` - ) - } - } - }) - - // Second pass: Create the blocks - for (const importedBlock of blocks) { - const blockId = idMapping.get(importedBlock.id)! - - // Handle special blocks (loop/parallel) - if (importedBlock.type === 'loop' || importedBlock.type === 'parallel') { - workflowBlocks[blockId] = createContainerBlock(blockId, importedBlock) - continue - } - - // Get block configuration - const blockConfig = getBlock(importedBlock.type) - if (!blockConfig) { - logger.warn(`Unknown block type: ${importedBlock.type}`) - continue - } - - // Create block with proper subBlocks - workflowBlocks[blockId] = createRegularBlock(blockId, importedBlock, blockConfig) - } - - // Step 5: Update block references in subblock values - updateBlockReferences(workflowBlocks, idMapping) - - // Step 6: Create edges with mapped IDs - const workflowEdges = edges.map((edge) => ({ - id: uuidv4(), - source: idMapping.get(edge.source) || edge.source, - target: idMapping.get(edge.target) || edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - })) - - // Step 7: Generate loops and parallels - const loops = generateLoopBlocks(workflowBlocks) - const parallels = generateParallelBlocks(workflowBlocks) - - // Debug: Log parent-child relationships - logger.info('=== Parent-Child Relationships ===') - Object.values(workflowBlocks).forEach((block) => { - const parentNode = (block as any).parentNode - const parentId = block.data?.parentId - if (parentNode || parentId) { - logger.info(`Block ${block.id} (${block.name}):`, { - parentNode, - parentId, - parentExists: parentNode ? !!workflowBlocks[parentNode] : 'N/A', - }) - } - }) - - // Step 8: Create final WorkflowState - const workflowState: WorkflowState = { - blocks: workflowBlocks, - edges: workflowEdges, - loops, - parallels, - lastSaved: Date.now(), - } - - return { - success: true, - workflowState, - errors: [], - warnings, - idMapping, - } -} - -/** - * Convert WorkflowState to YAML - */ -export function convertWorkflowStateToYaml( - workflowState: WorkflowState, - subBlockValues?: Record> -): WorkflowToYamlResult { - try { - const yaml = generateWorkflowYaml(workflowState, subBlockValues) - return { - success: true, - yaml, - } - } catch (error) { - logger.error('Failed to generate YAML:', error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } - } -} - -/** - * Create a container block (loop/parallel) - */ -function createContainerBlock(blockId: string, importedBlock: ImportedBlock): BlockState { - const block: BlockState = { - id: blockId, - type: importedBlock.type, - name: importedBlock.name, - position: importedBlock.position, - subBlocks: {}, - outputs: {}, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: { - ...importedBlock.data, - // Ensure container has dimensions - width: importedBlock.data?.width || 500, - height: importedBlock.data?.height || 300, - type: importedBlock.type === 'loop' ? 'loopNode' : 'parallelNode', - ...(importedBlock.parentId && { - parentId: importedBlock.parentId, - extent: importedBlock.extent, - }), - }, - } - - // Add parentNode for ReactFlow if this block is inside another container - if (importedBlock.parentId) { - ;(block as any).parentNode = importedBlock.parentId - } - - return block -} - -/** - * Create a regular block with proper subBlocks - */ -function createRegularBlock( - blockId: string, - importedBlock: ImportedBlock, - blockConfig: any -): BlockState { - // Initialize subBlocks from block configuration - const subBlocks: Record = {} - - blockConfig.subBlocks.forEach((subBlock: any) => { - const subBlockId = subBlock.id - const yamlValue = importedBlock.inputs[subBlockId] - - subBlocks[subBlockId] = { - id: subBlockId, - type: subBlock.type, - value: yamlValue !== undefined ? yamlValue : null, - } - }) - - // Also ensure we have subBlocks for any YAML inputs not in block config - Object.keys(importedBlock.inputs).forEach((inputKey) => { - if (!subBlocks[inputKey]) { - subBlocks[inputKey] = { - id: inputKey, - type: 'short-input', - value: importedBlock.inputs[inputKey], - } - } - }) - - const outputs = resolveOutputType(blockConfig.outputs) - - const block: BlockState = { - id: blockId, - type: importedBlock.type, - name: importedBlock.name, - position: importedBlock.position, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - isWide: false, - height: 0, - data: { - ...importedBlock.data, - ...(importedBlock.parentId && { - parentId: importedBlock.parentId, - extent: importedBlock.extent, - }), - }, - } - - // Add parentNode for ReactFlow if this block is inside a loop/parallel - if (importedBlock.parentId) { - ;(block as any).parentNode = importedBlock.parentId - } - - return block -} - -/** - * Update block references in subblock values - */ -function updateBlockReferences( - blocks: Record, - idMapping: Map -): void { - Object.values(blocks).forEach((block) => { - Object.values(block.subBlocks).forEach((subBlock) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subBlock.value = updateValueReferences(subBlock.value, idMapping) - } - }) - }) -} - -/** - * Recursively update block references in a value - */ -function updateValueReferences(value: any, idMapping: Map): any { - if (typeof value === 'string' && value.includes('<') && value.includes('>')) { - let processedValue = value - const blockMatches = value.match(/<([^>]+)>/g) - - if (blockMatches) { - for (const match of blockMatches) { - const path = match.slice(1, -1) - const [blockRef] = path.split('.') - - // Skip system references - if (['start', 'loop', 'parallel', 'variable'].includes(blockRef.toLowerCase())) { - continue - } - - // Check if this references an old block ID that needs mapping - const newMappedId = idMapping.get(blockRef) - if (newMappedId) { - processedValue = processedValue.replace( - new RegExp(`<${blockRef}\\.`, 'g'), - `<${newMappedId}.` - ) - processedValue = processedValue.replace( - new RegExp(`<${blockRef}>`, 'g'), - `<${newMappedId}>` - ) - } - } - } - - return processedValue - } - - // Handle arrays - if (Array.isArray(value)) { - return value.map((item) => updateValueReferences(item, idMapping)) - } - - // Handle objects - if (value !== null && typeof value === 'object') { - const result = { ...value } - for (const key in result) { - result[key] = updateValueReferences(result[key], idMapping) - } - return result - } - - return value -} - -/** - * Apply auto layout to workflow blocks - */ -export async function applyAutoLayoutToBlocks( - blocks: Record, - edges: any[] -): Promise<{ - success: boolean - layoutedBlocks?: Record - error?: string -}> { - logger.info('=== applyAutoLayoutToBlocks called ===', { - blockCount: Object.keys(blocks).length, - edgeCount: edges.length, - }) - - try { - // Try to import from the actual auto-layout location - logger.info('Attempting to import auto-layout module...') - const autoLayoutModule = await import( - '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout' - ) - - if (autoLayoutModule.applyAutoLayoutToBlocks) { - logger.info('Using auto-layout module function') - // Use the existing auto-layout function - return await autoLayoutModule.applyAutoLayoutToBlocks(blocks, edges) - } - - // Fallback to autolayout service - logger.info('Falling back to autolayout service') - const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - - logger.info('Calling autoLayoutWorkflow with options') - const layoutedBlocks = await autoLayoutWorkflow(blocks, edges, { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, - vertical: 400, - layer: 700, - }, - alignment: 'center', - padding: { - x: 250, - y: 250, - }, - }) - - logger.info('autoLayoutWorkflow returned:', { - hasLayoutedBlocks: !!layoutedBlocks, - layoutedBlockCount: layoutedBlocks ? Object.keys(layoutedBlocks).length : 0, - }) - - return { - success: true, - layoutedBlocks, - } - } catch (error) { - logger.error('Auto layout failed:', error) - return { - success: false, - error: error instanceof Error ? error.message : 'Auto layout failed', - } - } -} diff --git a/apps/sim/lib/workflows/yaml-generator.ts b/apps/sim/lib/workflows/yaml-generator.ts deleted file mode 100644 index d81cf966dfd..00000000000 --- a/apps/sim/lib/workflows/yaml-generator.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { dump as yamlDump } from 'js-yaml' -import type { Edge } from 'reactflow' -import { createLogger } from '@/lib/logs/console-logger' -import { getBlock } from '@/blocks' -import type { SubBlockConfig } from '@/blocks/types' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import { - type ConnectionsFormat, - cleanConditionInputs, - generateBlockConnections, -} from '@/stores/workflows/yaml/parsing-utils' - -const logger = createLogger('WorkflowYamlGenerator') - -interface YamlBlock { - type: string - name: string - inputs?: Record - connections?: ConnectionsFormat - parentId?: string // Add parentId for nested blocks -} - -interface YamlWorkflow { - version: string - blocks: Record -} - -/** - * Extract input values from a block's subBlocks based on its configuration - * This version works without client-side stores by using the provided subblock values - */ -function extractBlockInputs( - blockState: BlockState, - blockId: string, - subBlockValues?: Record> -): Record { - const blockConfig = getBlock(blockState.type) - const inputs: Record = {} - - // Get subblock values for this block (if provided) - const blockSubBlockValues = subBlockValues?.[blockId] || {} - - // Special handling for loop and parallel blocks - if (blockState.type === 'loop' || blockState.type === 'parallel') { - // Extract configuration from blockState.data instead of subBlocks - if (blockState.data) { - Object.entries(blockState.data).forEach(([key, value]) => { - // Include relevant configuration properties - if ( - key === 'count' || - key === 'loopType' || - key === 'collection' || - key === 'parallelType' || - key === 'distribution' - ) { - if (value !== undefined && value !== null && value !== '') { - inputs[key] = value - } - } - // Also include any override values from subBlockValues if they exist - const overrideValue = blockSubBlockValues[key] - if (overrideValue !== undefined && overrideValue !== null && overrideValue !== '') { - inputs[key] = overrideValue - } - }) - } - - // Include any additional values from subBlockValues that might not be in data - Object.entries(blockSubBlockValues).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '' && !Object.hasOwn(inputs, key)) { - inputs[key] = value - } - }) - - return inputs - } - - if (!blockConfig) { - // For other custom blocks without config, extract available subBlock values - Object.entries(blockState.subBlocks || {}).forEach(([subBlockId, subBlockState]) => { - const value = blockSubBlockValues[subBlockId] ?? subBlockState.value - if (value !== undefined && value !== null && value !== '') { - inputs[subBlockId] = value - } - }) - return inputs - } - - // Process each subBlock configuration for regular blocks - blockConfig.subBlocks.forEach((subBlockConfig: SubBlockConfig) => { - const subBlockId = subBlockConfig.id - - // Get value from provided values or fallback to block state - const value = blockSubBlockValues[subBlockId] ?? blockState.subBlocks[subBlockId]?.value - - // Skip hidden fields ONLY if they have no value (don't skip configured hidden fields) - if (subBlockConfig.hidden && (value === undefined || value === null || value === '')) { - return - } - - // Include value if it exists and isn't empty - if (value !== undefined && value !== null && value !== '') { - // Handle different input types appropriately - switch (subBlockConfig.type) { - case 'table': - // Tables are arrays of objects - if (Array.isArray(value) && value.length > 0) { - inputs[subBlockId] = value - } - break - - case 'checkbox-list': - // Checkbox lists return arrays - if (Array.isArray(value) && value.length > 0) { - inputs[subBlockId] = value - } - break - - case 'code': - // Code blocks should preserve formatting - if (typeof value === 'string' && value.trim()) { - inputs[subBlockId] = value - } else if (typeof value === 'object') { - inputs[subBlockId] = value - } - break - - case 'input-format': - // Clean up input format to only include essential fields - if (Array.isArray(value) && value.length > 0) { - inputs[subBlockId] = value - .map((field: any) => ({ - name: field.name, - type: field.type, - })) - .filter((field: any) => field.name && field.type) - } - break - - case 'switch': - // Boolean values - inputs[subBlockId] = Boolean(value) - break - - case 'slider': - // Numeric values - if ( - typeof value === 'number' || - (typeof value === 'string' && !Number.isNaN(Number(value))) - ) { - inputs[subBlockId] = Number(value) - } - break - - default: - // Text inputs, dropdowns, etc. - if (typeof value === 'string' && value.trim()) { - inputs[subBlockId] = value.trim() - } else if ( - typeof value === 'object' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - inputs[subBlockId] = value - } - break - } - } - }) - - return inputs -} - -/** - * Find incoming connections for a given block ID - */ -function findIncomingConnections( - blockId: string, - edges: Edge[] -): Array<{ - source: string - sourceHandle?: string - targetHandle?: string -}> { - return edges - .filter((edge) => edge.target === blockId) - .map((edge) => ({ - source: edge.source, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, - })) -} - -/** - * Find outgoing connections for a given block ID - */ -function findOutgoingConnections( - blockId: string, - edges: Edge[] -): Array<{ - target: string - sourceHandle?: string - targetHandle?: string -}> { - return edges - .filter((edge) => edge.source === blockId) - .map((edge) => ({ - target: edge.target, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, - })) -} - -/** - * Generate YAML representation of the workflow - * This is the core function extracted from the client store, made server-compatible - */ -export function generateWorkflowYaml( - workflowState: WorkflowState, - subBlockValues?: Record> -): string { - try { - const yamlWorkflow: YamlWorkflow = { - version: '1.0', - blocks: {}, - } - - // Process each block - Object.entries(workflowState.blocks).forEach(([blockId, blockState]) => { - // Skip blocks without type or name (these are layout-only blocks) - if (!blockState.type || !blockState.name) { - logger.info(`Skipping block ${blockId} - missing type or name`) - return - } - - const rawInputs = extractBlockInputs(blockState, blockId, subBlockValues) - - // Clean up condition inputs to use semantic format - const inputs = - blockState.type === 'condition' ? cleanConditionInputs(blockId, rawInputs) : rawInputs - - // Use shared utility to generate connections in new format - const connections = generateBlockConnections(blockId, workflowState.edges) - - const yamlBlock: YamlBlock = { - type: blockState.type, - name: blockState.name, - } - - // Only include inputs if they exist - if (Object.keys(inputs).length > 0) { - yamlBlock.inputs = inputs - } - - // Only include connections if they exist (check if any connection type has content) - const hasConnections = Object.keys(connections).length > 0 - if (hasConnections) { - yamlBlock.connections = connections - } - - // Include parent-child relationship for nested blocks - if (blockState.data?.parentId) { - yamlBlock.parentId = blockState.data.parentId - } - - yamlWorkflow.blocks[blockId] = yamlBlock - }) - - // Convert to YAML with clean formatting - return yamlDump(yamlWorkflow, { - indent: 2, - lineWidth: -1, // Disable line wrapping - noRefs: true, - sortKeys: false, - }) - } catch (error) { - logger.error('Failed to generate workflow YAML:', error) - return `# Error generating YAML: ${error instanceof Error ? error.message : 'Unknown error'}` - } -} diff --git a/apps/sim/lib/yaml-service-client.ts b/apps/sim/lib/yaml-service-client.ts index 91ed9d54218..88cf7c31476 100644 --- a/apps/sim/lib/yaml-service-client.ts +++ b/apps/sim/lib/yaml-service-client.ts @@ -1,21 +1,8 @@ import { createLogger } from '@/lib/logs/console-logger' -import { getAllBlocks } from '@/blocks' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { resolveOutputType } from '@/blocks/utils' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' -import type { BlockConfig } from '@/blocks/types' +import type { WorkflowState, BlockState } from '@/stores/workflows/workflow/types' const logger = createLogger('YamlServiceClient') -interface YamlServiceConfig { - blockRegistry: Record - utilities: { - generateLoopBlocks: string - generateParallelBlocks: string - resolveOutputType: string - } -} - interface ParseYamlResponse { success: boolean data?: any @@ -42,69 +29,41 @@ interface DiffYamlResponse { } export class YamlServiceClient { - private simAgentClient: any - constructor() { - // Lazy load sim-agent client to avoid circular dependencies - this.simAgentClient = null - } - - private async getSimAgentClient() { - if (!this.simAgentClient) { - const { simAgentClient } = await import('@/lib/sim-agent/client') - this.simAgentClient = simAgentClient - } - return this.simAgentClient - } - - private async getConfig(): Promise { - // Gather all dependencies needed by the YAML service - const blocks = getAllBlocks() - const blockRegistry = blocks.reduce((acc, block) => { - // Get the block type from the block config - const blockType = block.type - acc[blockType] = { - ...block, - id: blockType, // Add id field for YAML service - subBlocks: block.subBlocks || [], - outputs: block.outputs || {}, - } as any - return acc - }, {} as Record) - - return { - blockRegistry, - utilities: { - generateLoopBlocks: generateLoopBlocks.toString(), - generateParallelBlocks: generateParallelBlocks.toString(), - resolveOutputType: resolveOutputType.toString() - } - } + logger.info('YamlServiceClient initialized') } - private async fetchFromService(endpoint: string, body: any): Promise { + /** + * Make a request to our API routes + */ + private async fetchFromAPI(endpoint: string, body: any): Promise { try { - const client = await this.getSimAgentClient() - - // Use the sim-agent client to make the request - const response = await client.call(endpoint, { - workflowId: body.workflowId || 'yaml-service', - data: body + const response = await fetch(`/api/yaml${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), }) - if (!response.success) { - throw new Error(response.error || 'YAML service error') + if (!response.ok) { + const errorData = await response.json().catch(() => null) + logger.error(`API error for ${endpoint}:`, { + status: response.status, + error: errorData, + }) + throw new Error(errorData?.error || `API error: ${response.statusText}`) } - return response.data + return await response.json() } catch (error) { - logger.error(`Failed to call YAML service ${endpoint}:`, error) + logger.error(`Failed to call API ${endpoint}:`, error) throw error } } async parseYaml(yamlContent: string): Promise { - return this.fetchFromService('/api/yaml/parse', { yamlContent }) + return this.fetchFromAPI('/parse', { yamlContent }) } async convertYamlToWorkflow( @@ -115,10 +74,8 @@ export class YamlServiceClient { existingBlocks?: Record } ): Promise { - const config = await this.getConfig() - return this.fetchFromService('/api/yaml/to-workflow', { + return this.fetchFromAPI('/to-workflow', { yamlContent, - ...config, options }) } @@ -127,31 +84,40 @@ export class YamlServiceClient { workflowState: WorkflowState, subBlockValues?: Record> ): Promise { - const config = await this.getConfig() - return this.fetchFromService('/api/workflow/to-yaml', { + return this.fetchFromAPI('/generate', { workflowState, - subBlockValues, - ...config + subBlockValues }) } async diffYaml(originalYaml: string, modifiedYaml: string): Promise { - const config = await this.getConfig() - return this.fetchFromService('/api/yaml/diff', { + return this.fetchFromAPI('/diff', { originalYaml, - modifiedYaml, - ...config + modifiedYaml }) } // Helper method to check if external service is available async healthCheck(): Promise { try { - const client = await this.getSimAgentClient() - // Check if sim-agent is configured and available - const config = client.getConfig() - return !!config.baseUrl && !!config.hasApiKey - } catch { + const response = await fetch('/api/yaml/health', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + logger.error('YAML service health check failed:', { + status: response.status, + }) + return false + } + + const data = await response.json() + return data.healthy === true + } catch (error) { + logger.error('YAML service health check failed:', error) return false } } diff --git a/apps/sim/stores/workflows/yaml/store.ts b/apps/sim/stores/workflows/yaml/store.ts index f42330c480b..17c306c2ee3 100644 --- a/apps/sim/stores/workflows/yaml/store.ts +++ b/apps/sim/stores/workflows/yaml/store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console-logger' -import { generateWorkflowYaml } from '@/lib/workflows/yaml-generator' +import { yamlService } from '@/lib/yaml-service-client' import { useSubBlockStore } from '../subblock/store' import { useWorkflowStore } from '../workflow/store' @@ -13,7 +13,7 @@ interface WorkflowYamlState { } interface WorkflowYamlActions { - generateYaml: () => void + generateYaml: () => Promise getYaml: () => string refreshYaml: () => void } @@ -115,18 +115,23 @@ export const useWorkflowYamlStore = create()( yaml: '', lastGenerated: undefined, - generateYaml: () => { + generateYaml: async () => { // Initialize subscriptions on first use initializeSubscriptions() const workflowState = useWorkflowStore.getState() const subBlockValues = getSubBlockValues() - const yaml = generateWorkflowYaml(workflowState, subBlockValues) - - set({ - yaml, - lastGenerated: Date.now(), - }) + + const result = await yamlService.generateYaml(workflowState, subBlockValues) + + if (result.success && result.yaml) { + set({ + yaml: result.yaml, + lastGenerated: Date.now(), + }) + } else { + logger.error('Failed to generate YAML:', result.error) + } }, getYaml: () => { From 491ecde5d0cb223c79c10a50ee2e25ccf4411f99 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 30 Jul 2025 11:02:32 -0700 Subject: [PATCH 112/184] Migrate diff engine to sim agent --- apps/sim/app/api/copilot/chat/route.ts | 7 - apps/sim/app/api/yaml/autolayout/route.ts | 184 +++++ apps/sim/app/api/yaml/diff/create/route.ts | 156 ++++ apps/sim/app/api/yaml/diff/merge/route.ts | 158 ++++ .../w/[workflowId]/utils/auto-layout.ts | 50 +- apps/sim/lib/workflows/diff/diff-engine.ts | 725 ++---------------- apps/sim/lib/yaml-service-client.ts | 84 +- apps/sim/stores/copilot/store.ts | 49 +- 8 files changed, 722 insertions(+), 691 deletions(-) create mode 100644 apps/sim/app/api/yaml/autolayout/route.ts create mode 100644 apps/sim/app/api/yaml/diff/create/route.ts create mode 100644 apps/sim/app/api/yaml/diff/merge/route.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 18a5850abbd..9311be241f9 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -344,13 +344,6 @@ export async function POST(req: NextRequest) { const decodedChunk = decoder.decode(value, { stream: true }) buffer += decodedChunk - // Log first few chunks for debugging - if (chunkSize > 0) { - logger.debug(`[${requestId}] Forwarded chunk to client:`, { - size: chunkSize, - preview: decodedChunk.substring(0, 100) + (decodedChunk.length > 100 ? '...' : '') - }) - } const lines = buffer.split('\n') buffer = lines.pop() || '' // Keep incomplete line in buffer diff --git a/apps/sim/app/api/yaml/autolayout/route.ts b/apps/sim/app/api/yaml/autolayout/route.ts new file mode 100644 index 00000000000..e86e5920033 --- /dev/null +++ b/apps/sim/app/api/yaml/autolayout/route.ts @@ -0,0 +1,184 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks, convertLoopBlockToLoop, convertParallelBlockToParallel, findChildNodes, findAllDescendantNodes } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from '@/lib/autolayout/types' +import { autoLayoutWorkflow } from '@/lib/autolayout/service' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlAutoLayoutAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const AutoLayoutRequestSchema = z.object({ + workflowState: z.object({ + blocks: z.record(z.any()), + edges: z.array(z.any()), + loops: z.record(z.any()).optional().default({}), + parallels: z.record(z.any()).optional().default({}) + }), + options: z.object({ + strategy: z.enum(['smart', 'hierarchical', 'layered', 'force-directed']).optional(), + direction: z.enum(['horizontal', 'vertical', 'auto']).optional(), + spacing: z.object({ + horizontal: z.number().optional(), + vertical: z.number().optional(), + layer: z.number().optional() + }).optional(), + alignment: z.enum(['start', 'center', 'end']).optional(), + padding: z.object({ + x: z.number().optional(), + y: z.number().optional() + }).optional() + }).optional() +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { workflowState, options } = AutoLayoutRequestSchema.parse(body) + + logger.info(`[${requestId}] Applying auto layout`, { + blockCount: Object.keys(workflowState.blocks).length, + edgeCount: workflowState.edges.length, + hasApiKey: !!SIM_AGENT_API_KEY, + strategy: options?.strategy || 'smart', + simAgentUrl: SIM_AGENT_API_URL + }) + + // Gather block registry and utilities + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Log sample block data for debugging + const sampleBlockId = Object.keys(workflowState.blocks)[0] + if (sampleBlockId) { + logger.info(`[${requestId}] Sample block data:`, { + blockId: sampleBlockId, + blockType: workflowState.blocks[sampleBlockId].type, + hasPosition: !!workflowState.blocks[sampleBlockId].position, + position: workflowState.blocks[sampleBlockId].position + }) + } + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/autolayout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + blocks: workflowState.blocks, + edges: workflowState.edges, + loops: workflowState.loops || {}, + parallels: workflowState.parallels || {}, + options, + blockRegistry, + blockMappings: { + categories: BLOCK_CATEGORIES, + dimensions: BLOCK_DIMENSIONS + }, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString(), + convertLoopBlockToLoop: convertLoopBlockToLoop.toString(), + convertParallelBlockToParallel: convertParallelBlockToParallel.toString(), + findChildNodes: findChildNodes.toString(), + findAllDescendantNodes: findAllDescendantNodes.toString(), + autoLayoutWorkflow: autoLayoutWorkflow.toString() + } + }), + }) + + if (!response.ok) { + const errorText = await response.text() + + // Try to parse the error as JSON for better error messages + let errorMessage = `Sim agent API error: ${response.statusText}` + + // Check if it's a 404 error + if (response.status === 404) { + errorMessage = 'Auto-layout endpoint not found on sim agent. Please ensure the /api/yaml/autolayout endpoint is implemented in the sim agent service.' + } else { + try { + const errorJson = JSON.parse(errorText) + if (errorJson.errors && Array.isArray(errorJson.errors)) { + errorMessage = errorJson.errors.join(', ') + } else if (errorJson.error) { + errorMessage = errorJson.error + } + } catch (e) { + // If not JSON, use the raw text + errorMessage = errorText || errorMessage + } + } + + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + parsedError: errorMessage + }) + + return NextResponse.json( + { success: false, errors: [errorMessage] }, + { status: response.status } + ) + } + + const result = await response.json() + + logger.info(`[${requestId}] Sim agent response summary:`, { + success: result.success, + hasBlocks: !!result.blocks, + blockCount: result.blocks ? Object.keys(result.blocks).length : 0, + responseKeys: Object.keys(result) + }) + + // Transform the response to match the expected format + const transformedResponse = { + success: result.success, + workflowState: { + blocks: result.blocks || {}, + edges: workflowState.edges || [], + loops: workflowState.loops || {}, + parallels: workflowState.parallels || {} + }, + errors: result.errors + } + + logger.info(`[${requestId}] Transformed response:`, { + success: transformedResponse.success, + blockCount: Object.keys(transformedResponse.workflowState.blocks).length, + hasWorkflowState: true + }) + + return NextResponse.json(transformedResponse) + } catch (error) { + logger.error(`[${requestId}] Auto layout failed:`, error) + + return NextResponse.json( + { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown auto layout error'] + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/yaml/diff/create/route.ts b/apps/sim/app/api/yaml/diff/create/route.ts new file mode 100644 index 00000000000..649190b6ab4 --- /dev/null +++ b/apps/sim/app/api/yaml/diff/create/route.ts @@ -0,0 +1,156 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks, convertLoopBlockToLoop, convertParallelBlockToParallel, findChildNodes, findAllDescendantNodes } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from '@/lib/autolayout/types' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlDiffCreateAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const CreateDiffRequestSchema = z.object({ + yamlContent: z.string().min(1), + diffAnalysis: z.object({ + new_blocks: z.array(z.string()), + edited_blocks: z.array(z.string()), + deleted_blocks: z.array(z.string()), + field_diffs: z.record(z.object({ + changed_fields: z.array(z.string()), + unchanged_fields: z.array(z.string()) + })).optional(), + edge_diff: z.object({ + new_edges: z.array(z.string()), + deleted_edges: z.array(z.string()), + unchanged_edges: z.array(z.string()) + }).optional() + }).optional(), + options: z.object({ + applyAutoLayout: z.boolean().optional(), + layoutOptions: z.any().optional() + }).optional() +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { yamlContent, diffAnalysis, options } = CreateDiffRequestSchema.parse(body) + + logger.info(`[${requestId}] Creating diff from YAML`, { + contentLength: yamlContent.length, + hasDiffAnalysis: !!diffAnalysis, + hasOptions: !!options, + options: options, + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Gather block registry + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/diff/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + yamlContent, + diffAnalysis, + blockRegistry, + blockMappings: { + categories: BLOCK_CATEGORIES, + dimensions: BLOCK_DIMENSIONS + }, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString(), + convertLoopBlockToLoop: convertLoopBlockToLoop.toString(), + convertParallelBlockToParallel: convertParallelBlockToParallel.toString(), + findChildNodes: findChildNodes.toString(), + findAllDescendantNodes: findAllDescendantNodes.toString() + }, + options + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + }) + return NextResponse.json( + { success: false, errors: [`Sim agent API error: ${response.statusText}`] }, + { status: response.status } + ) + } + + const result = await response.json() + + // Log the full response to see if auto-layout is happening + logger.info(`[${requestId}] Full sim agent response:`, JSON.stringify(result, null, 2)) + + // If the sim agent returned blocks directly (when auto-layout is applied), + // transform it to the expected diff format + if (result.success && result.blocks && !result.diff) { + logger.info(`[${requestId}] Transforming sim agent blocks response to diff format`) + + const transformedResult = { + success: result.success, + diff: { + proposedState: { + blocks: result.blocks, + edges: result.edges || [], + loops: result.loops || {}, + parallels: result.parallels || {} + }, + diffAnalysis: diffAnalysis, + metadata: result.metadata || { + source: 'sim-agent', + timestamp: Date.now() + } + }, + errors: result.errors || [] + } + + return NextResponse.json(transformedResult) + } + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Diff creation failed:`, error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, errors: error.errors.map(e => e.message) }, + { status: 400 } + ) + } + + return NextResponse.json( + { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown error'] + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/api/yaml/diff/merge/route.ts b/apps/sim/app/api/yaml/diff/merge/route.ts new file mode 100644 index 00000000000..030666da385 --- /dev/null +++ b/apps/sim/app/api/yaml/diff/merge/route.ts @@ -0,0 +1,158 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console-logger' +import { getAllBlocks } from '@/blocks/registry' +import { generateLoopBlocks, generateParallelBlocks, convertLoopBlockToLoop, convertParallelBlockToParallel, findChildNodes, findAllDescendantNodes } from '@/stores/workflows/workflow/utils' +import { resolveOutputType } from '@/blocks/utils' +import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from '@/lib/autolayout/types' +import type { BlockConfig } from '@/blocks/types' + +const logger = createLogger('YamlDiffMergeAPI') + +// Sim Agent API configuration +const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' +const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY + +const MergeDiffRequestSchema = z.object({ + existingDiff: z.object({ + proposedState: z.object({ + blocks: z.record(z.any()), + edges: z.array(z.any()), + loops: z.record(z.any()), + parallels: z.record(z.any()) + }), + diffAnalysis: z.any().optional(), + metadata: z.object({ + source: z.string(), + timestamp: z.number() + }) + }), + yamlContent: z.string().min(1), + diffAnalysis: z.any().optional(), + options: z.object({ + applyAutoLayout: z.boolean().optional(), + layoutOptions: z.any().optional() + }).optional() +}) + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const body = await request.json() + const { existingDiff, yamlContent, diffAnalysis, options } = MergeDiffRequestSchema.parse(body) + + logger.info(`[${requestId}] Merging diff from YAML`, { + contentLength: yamlContent.length, + existingBlockCount: Object.keys(existingDiff.proposedState.blocks).length, + hasDiffAnalysis: !!diffAnalysis, + hasOptions: !!options, + options: options, + hasApiKey: !!SIM_AGENT_API_KEY, + }) + + // Gather block registry + const blocks = getAllBlocks() + const blockRegistry = blocks.reduce((acc, block) => { + const blockType = block.type + acc[blockType] = { + ...block, + id: blockType, + subBlocks: block.subBlocks || [], + outputs: block.outputs || {}, + } as any + return acc + }, {} as Record) + + // Call sim-agent API + const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/diff/merge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), + }, + body: JSON.stringify({ + existingDiff, + yamlContent, + diffAnalysis, + blockRegistry, + blockMappings: { + categories: BLOCK_CATEGORIES, + dimensions: BLOCK_DIMENSIONS + }, + utilities: { + generateLoopBlocks: generateLoopBlocks.toString(), + generateParallelBlocks: generateParallelBlocks.toString(), + resolveOutputType: resolveOutputType.toString(), + convertLoopBlockToLoop: convertLoopBlockToLoop.toString(), + convertParallelBlockToParallel: convertParallelBlockToParallel.toString(), + findChildNodes: findChildNodes.toString(), + findAllDescendantNodes: findAllDescendantNodes.toString() + }, + options + }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Sim agent API error:`, { + status: response.status, + error: errorText, + }) + return NextResponse.json( + { success: false, errors: [`Sim agent API error: ${response.statusText}`] }, + { status: response.status } + ) + } + + const result = await response.json() + + // Log the full response to see if auto-layout is happening + logger.info(`[${requestId}] Full sim agent response:`, JSON.stringify(result, null, 2)) + + // If the sim agent returned blocks directly (when auto-layout is applied), + // transform it to the expected diff format + if (result.success && result.blocks && !result.diff) { + logger.info(`[${requestId}] Transforming sim agent blocks response to diff format`) + + const transformedResult = { + success: result.success, + diff: { + proposedState: { + blocks: result.blocks, + edges: result.edges || existingDiff.proposedState.edges || [], + loops: result.loops || existingDiff.proposedState.loops || {}, + parallels: result.parallels || existingDiff.proposedState.parallels || {} + }, + diffAnalysis: diffAnalysis, + metadata: result.metadata || { + source: 'sim-agent', + timestamp: Date.now() + } + }, + errors: result.errors || [] + } + + return NextResponse.json(transformedResult) + } + + return NextResponse.json(result) + } catch (error) { + logger.error(`[${requestId}] Diff merge failed:`, error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, errors: error.errors.map(e => e.message) }, + { status: 400 } + ) + } + + return NextResponse.json( + { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown error'] + }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts index d0d0117f490..ff4764ca2e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts @@ -45,6 +45,8 @@ export async function applyAutoLayoutToWorkflow( workflowId: string, blocks: Record, edges: any[], + loops: Record = {}, + parallels: Record = {}, options: AutoLayoutOptions = {} ): Promise<{ success: boolean @@ -58,8 +60,8 @@ export async function applyAutoLayoutToWorkflow( edgeCount: edges.length, }) - // Import auto layout service - const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') + // Import yaml service + const { yamlService } = await import('@/lib/yaml-service-client') // Merge with default options and ensure all required properties are present const layoutOptions = { @@ -77,18 +79,40 @@ export async function applyAutoLayoutToWorkflow( }, } - // Apply auto layout - const layoutedBlocks = await autoLayoutWorkflow(blocks, edges, layoutOptions) + // Create workflow state object + const workflowState = { + blocks, + edges, + loops, + parallels + } + + // Apply auto layout using sim agent + const response = await yamlService.autoLayout(workflowState, layoutOptions) + + if (!response.success || !response.workflowState) { + const errorMessage = response.errors?.join(', ') || 'Auto layout failed' + logger.error('Auto layout response failed:', { + success: response.success, + hasWorkflowState: !!response.workflowState, + errors: response.errors, + errorMessage + }) + return { + success: false, + error: errorMessage + } + } logger.info('Successfully applied auto layout', { workflowId, originalBlockCount: Object.keys(blocks).length, - layoutedBlockCount: Object.keys(layoutedBlocks).length, + layoutedBlockCount: Object.keys(response.workflowState.blocks).length, }) return { success: true, - layoutedBlocks, + layoutedBlocks: response.workflowState.blocks, } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown auto layout error' @@ -116,7 +140,15 @@ export async function applyAutoLayoutAndUpdateStore( const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') const workflowStore = useWorkflowStore.getState() - const { blocks, edges } = workflowStore + const { blocks, edges, loops = {}, parallels = {} } = workflowStore + + logger.info('Auto layout store data:', { + workflowId, + blockCount: Object.keys(blocks).length, + edgeCount: edges.length, + loopCount: Object.keys(loops).length, + parallelCount: Object.keys(parallels).length + }) if (Object.keys(blocks).length === 0) { logger.warn('No blocks to layout', { workflowId }) @@ -124,7 +156,7 @@ export async function applyAutoLayoutAndUpdateStore( } // Apply auto layout - const result = await applyAutoLayoutToWorkflow(workflowId, blocks, edges, options) + const result = await applyAutoLayoutToWorkflow(workflowId, blocks, edges, loops, parallels, options) if (!result.success || !result.layoutedBlocks) { return { success: false, error: result.error } @@ -215,5 +247,5 @@ export async function applyAutoLayoutToBlocks( layoutedBlocks?: Record error?: string }> { - return applyAutoLayoutToWorkflow('preview', blocks, edges, options) + return applyAutoLayoutToWorkflow('preview', blocks, edges, {}, {}, options) } diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 7925437f4ba..be4ce03603e 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -49,152 +49,24 @@ export class WorkflowDiffEngine { try { logger.info('Creating diff from YAML content') - // Convert YAML to workflow state with new IDs - const conversionResult = await yamlService.convertYamlToWorkflow(yamlContent, { - generateNewIds: true, + // Call the sim agent service to create the diff + const response = await yamlService.createDiff(yamlContent, diffAnalysis, { + applyAutoLayout: true }) - if (!conversionResult.success || !conversionResult.workflowState) { + if (!response.success || !response.diff) { return { success: false, - errors: conversionResult.errors, + errors: response.errors, } } - const proposedState = conversionResult.workflowState - - logger.info('Conversion result:', { - hasProposedState: !!proposedState, - blockCount: proposedState ? Object.keys(proposedState.blocks).length : 0, - edgeCount: proposedState ? proposedState.edges.length : 0, - }) - - // Add diff markers to blocks if analysis is provided - let mappedDiffAnalysis = diffAnalysis - if (diffAnalysis) { - logger.info('Applying diff markers with analysis:', { - new_blocks: diffAnalysis.new_blocks, - edited_blocks: diffAnalysis.edited_blocks, - deleted_blocks: diffAnalysis.deleted_blocks, - edge_diff: diffAnalysis.edge_diff, - }) - const idMapping = conversionResult.idMapping ? new Map(Object.entries(conversionResult.idMapping)) : new Map() - this.applyDiffMarkers(proposedState, diffAnalysis, idMapping) - // Create a mapped version of the diff analysis with new IDs - mappedDiffAnalysis = this.createMappedDiffAnalysis( - diffAnalysis, - idMapping - ) - } else { - logger.info('No diff analysis provided, skipping diff markers') - } - - // Debug: Log blocks with parent relationships - const blocksWithParents = Object.values(proposedState.blocks).filter( - (block: any) => block.parentNode - ) - logger.info(`Found ${blocksWithParents.length} blocks with parent relationships`) - blocksWithParents.forEach((block: any) => { - logger.info(`Block ${block.id} has parentNode: ${block.parentNode}`) - }) - - // Debug: Log loop and parallel blocks - const containerBlocks = Object.values(proposedState.blocks).filter( - (block) => block.type === 'loop' || block.type === 'parallel' - ) - logger.info( - `Found ${containerBlocks.length} container blocks (loops/parallels):`, - containerBlocks.map((b) => ({ id: b.id, type: b.type, name: b.name })) - ) - - // Ensure all blocks have their id property set - Object.entries(proposedState.blocks).forEach(([blockId, block]) => { - if (!block.id) { - logger.warn(`Block ${blockId} missing id property, setting it now`) - block.id = blockId - } - }) - - // Debug: Check what Object.values returns - const blockValues = Object.values(proposedState.blocks) - logger.info('Object.values(blocks) returns:', { - count: blockValues.length, - blocks: blockValues.map((block, index) => ({ - index, - hasId: !!block.id, - id: block.id, - type: block.type, - })), - }) - - // Apply auto layout using the service directly - const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - - try { - logger.info('Applying auto layout to diff workflow', { - blockCount: Object.keys(proposedState.blocks).length, - edgeCount: proposedState.edges.length, - blocks: Object.keys(proposedState.blocks), - }) - - const layoutedBlocks = await autoLayoutWorkflow( - proposedState.blocks, - proposedState.edges, - {} // Default options - ) - - if (layoutedBlocks) { - // Apply the layouted blocks - proposedState.blocks = layoutedBlocks - - // Ensure all blocks still have their id property after layout - Object.entries(proposedState.blocks).forEach(([blockId, block]) => { - if (!block.id) { - logger.warn(`Block ${blockId} lost its id property after layout, restoring it`) - block.id = blockId - } - }) - - // Re-apply diff markers after layout - if (mappedDiffAnalysis) { - Object.entries(proposedState.blocks).forEach(([blockId, block]) => { - if (mappedDiffAnalysis.new_blocks.includes(blockId)) { - ;(block as any).is_diff = 'new' - } else if (mappedDiffAnalysis.edited_blocks.includes(blockId)) { - ;(block as any).is_diff = 'edited' - - // Re-apply field-level diff information if available - if (mappedDiffAnalysis.field_diffs?.[blockId]) { - ;(block as any).field_diff = mappedDiffAnalysis.field_diffs[blockId] - } - } else { - ;(block as any).is_diff = 'unchanged' - } - }) - } - - logger.info('Auto layout applied successfully') - } else { - logger.warn('Auto layout returned no blocks') - } - } catch (error) { - logger.error('Auto layout failed:', error) - logger.info('Continuing without auto-layout') - } - - // Create the diff object - this.currentDiff = { - proposedState, - diffAnalysis: mappedDiffAnalysis, - metadata: { - source: 'copilot', - timestamp: Date.now(), - }, - } + // Store the current diff + this.currentDiff = response.diff logger.info('Diff created successfully', { - blocksCount: Object.keys(proposedState.blocks).length, - edgesCount: proposedState.edges.length, + blocksCount: Object.keys(response.diff.proposedState.blocks).length, + edgesCount: response.diff.proposedState.edges.length, }) return { @@ -224,301 +96,29 @@ export class WorkflowDiffEngine { return this.createDiffFromYaml(yamlContent, diffAnalysis) } - // Convert YAML to workflow state with new IDs - const conversionResult = await yamlService.convertYamlToWorkflow(yamlContent, { - generateNewIds: true, - }) - - if (!conversionResult.success || !conversionResult.workflowState) { - return { - success: false, - errors: conversionResult.errors, + // Call the sim agent service to merge the diff + const response = await yamlService.mergeDiff( + this.currentDiff, + yamlContent, + diffAnalysis, + { + applyAutoLayout: true } - } - - const newState = conversionResult.workflowState - - logger.info('Merging new state into existing diff:', { - existingBlockCount: Object.keys(this.currentDiff.proposedState.blocks).length, - newBlockCount: Object.keys(newState.blocks).length, - }) - - // Create a map of existing blocks by name+type for matching - const existingBlockMap = new Map() - Object.entries(this.currentDiff.proposedState.blocks).forEach(([id, block]) => { - const key = `${block.type}:${block.name}` - existingBlockMap.set(key, { id, block }) - }) - - // Merge blocks - update existing blocks, add new ones - const mergedBlocks = { ...this.currentDiff.proposedState.blocks } - const blockIdMapping = new Map() // Maps new IDs to existing IDs - - Object.entries(newState.blocks).forEach(([newBlockId, newBlock]) => { - const key = `${newBlock.type}:${newBlock.name}` - const existing = existingBlockMap.get(key) - - if (existing) { - // Update existing block, preserving its ID but updating properties - const previousDiffStatus = (existing.block as any).is_diff - mergedBlocks[existing.id] = { - ...existing.block, - ...newBlock, - id: existing.id, // Preserve the existing ID - position: newBlock.position, // Use new position from layout - // Temporarily preserve diff status - will be updated later based on diff analysis - } - // Preserve the diff status if it was already marked - if (previousDiffStatus) { - (mergedBlocks[existing.id] as any).is_diff = previousDiffStatus - } - blockIdMapping.set(newBlockId, existing.id) - logger.info(`Updating existing block: ${key} with ID ${existing.id}`, { - previousDiffStatus, - }) - } else { - // This is a truly new block - mergedBlocks[newBlockId] = newBlock - blockIdMapping.set(newBlockId, newBlockId) - logger.info(`Adding new block: ${key} with ID ${newBlockId}`) - } - }) - - // Update edges to use the correct block IDs - const remappedNewEdges = newState.edges.map(edge => ({ - ...edge, - source: blockIdMapping.get(edge.source) || edge.source, - target: blockIdMapping.get(edge.target) || edge.target, - })) - - // Merge edges - combine unique edges - const existingEdgeSet = new Set( - this.currentDiff.proposedState.edges.map(e => `${e.source}-${e.target}`) ) - const mergedEdges = [...this.currentDiff.proposedState.edges] - remappedNewEdges.forEach(edge => { - const edgeKey = `${edge.source}-${edge.target}` - if (!existingEdgeSet.has(edgeKey)) { - mergedEdges.push(edge) - existingEdgeSet.add(edgeKey) - } - }) - - // Update loops and parallels with remapped IDs - const remapLoops = (loops: Record) => { - const remapped: Record = {} - Object.entries(loops).forEach(([loopId, loop]) => { - const mappedId = blockIdMapping.get(loopId) || loopId - remapped[mappedId] = { - ...loop, - id: mappedId, - blocks: loop.blocks?.map((id: string) => blockIdMapping.get(id) || id) || [] - } - }) - return remapped - } - const remapParallels = (parallels: Record) => { - const remapped: Record = {} - Object.entries(parallels).forEach(([parallelId, parallel]) => { - const mappedId = blockIdMapping.get(parallelId) || parallelId - remapped[mappedId] = { - ...parallel, - id: mappedId, - branches: parallel.branches?.map((branch: any) => ({ - ...branch, - blocks: branch.blocks?.map((id: string) => blockIdMapping.get(id) || id) || [] - })) || [] - } - }) - return remapped - } - - // Merge loops and parallels - const mergedLoops = { - ...this.currentDiff.proposedState.loops, - ...remapLoops(newState.loops) - } - const mergedParallels = { - ...this.currentDiff.proposedState.parallels, - ...remapParallels(newState.parallels) - } - - // Create merged state - const mergedState: WorkflowState = { - blocks: mergedBlocks, - edges: mergedEdges, - loops: mergedLoops, - parallels: mergedParallels, - } - - // Apply diff markers if analysis is provided - let mappedDiffAnalysis = diffAnalysis - if (diffAnalysis) { - logger.info('Applying diff markers to merged state') - - // Create a combined ID mapping that includes our block remapping - const combinedIdMapping = new Map() - if (conversionResult.idMapping) { - Object.entries(conversionResult.idMapping).forEach(([oldId, newId]) => { - // Map original ID to final ID (which might be an existing block ID) - const finalId = blockIdMapping.get(newId) || newId - combinedIdMapping.set(oldId, finalId) - }) - } - - this.applyDiffMarkers(mergedState, diffAnalysis, combinedIdMapping) - mappedDiffAnalysis = this.createMappedDiffAnalysis( - diffAnalysis, - combinedIdMapping - ) - } - - // Merge diff analysis if both exist - if (this.currentDiff.diffAnalysis && mappedDiffAnalysis) { - // Get all blocks that were previously marked as new or edited - const previouslyNewBlocks = new Set(this.currentDiff.diffAnalysis.new_blocks) - const previouslyEditedBlocks = new Set(this.currentDiff.diffAnalysis.edited_blocks) - - // Blocks that are edited in the new analysis - const newlyEditedBlocks = new Set(mappedDiffAnalysis.edited_blocks) - - // If a block was previously 'new' and is now being edited, it stays 'new' - // If a block was previously 'edited' and is edited again, it stays 'edited' - const finalNewBlocks = new Set() - const finalEditedBlocks = new Set() - - // Add all previously new blocks - previouslyNewBlocks.forEach(id => finalNewBlocks.add(id)) - - // Add newly added blocks from this update - mappedDiffAnalysis.new_blocks.forEach(id => finalNewBlocks.add(id)) - - // Process edited blocks - newlyEditedBlocks.forEach(id => { - if (!finalNewBlocks.has(id)) { - // Only mark as edited if it's not already marked as new - finalEditedBlocks.add(id) - } - }) - - // Add previously edited blocks that aren't being marked as new - previouslyEditedBlocks.forEach(id => { - if (!finalNewBlocks.has(id)) { - finalEditedBlocks.add(id) - } - }) - - // Combine the diff analyses - const combinedAnalysis: DiffAnalysis = { - new_blocks: Array.from(finalNewBlocks), - edited_blocks: Array.from(finalEditedBlocks), - deleted_blocks: [ - ...new Set([ - ...this.currentDiff.diffAnalysis.deleted_blocks, - ...mappedDiffAnalysis.deleted_blocks - ]) - ], - edge_diff: { - new_edges: [ - ...(this.currentDiff.diffAnalysis.edge_diff?.new_edges || []), - ...(mappedDiffAnalysis.edge_diff?.new_edges || []) - ], - deleted_edges: [ - ...new Set([ - ...(this.currentDiff.diffAnalysis.edge_diff?.deleted_edges || []), - ...(mappedDiffAnalysis.edge_diff?.deleted_edges || []) - ]) - ], - unchanged_edges: [ - ...new Set([ - ...(this.currentDiff.diffAnalysis.edge_diff?.unchanged_edges || []), - ...(mappedDiffAnalysis.edge_diff?.unchanged_edges || []) - ]) - ], - }, - field_diffs: { - ...this.currentDiff.diffAnalysis.field_diffs, - ...mappedDiffAnalysis.field_diffs, - }, - } - mappedDiffAnalysis = combinedAnalysis - - logger.info('Combined diff analysis:', { - previousNew: previouslyNewBlocks.size, - previousEdited: previouslyEditedBlocks.size, - newlyEdited: newlyEditedBlocks.size, - finalNew: finalNewBlocks.size, - finalEdited: finalEditedBlocks.size, - }) - } else if (this.currentDiff.diffAnalysis) { - mappedDiffAnalysis = this.currentDiff.diffAnalysis - } - - // Apply auto layout to the merged state - try { - logger.info('Applying auto layout to merged diff workflow') - const { autoLayoutWorkflow } = await import('@/lib/autolayout/service') - const layoutedBlocks = await autoLayoutWorkflow( - mergedState.blocks, - mergedState.edges, - {} - ) - - if (layoutedBlocks) { - mergedState.blocks = layoutedBlocks - - // Ensure all blocks still have their id property - Object.entries(mergedState.blocks).forEach(([blockId, block]) => { - if (!block.id) { - block.id = blockId - } - }) - - // Re-apply diff markers after layout - if (mappedDiffAnalysis) { - Object.entries(mergedState.blocks).forEach(([blockId, block]) => { - // Check if this block was part of the current update - const wasInCurrentUpdate = Array.from(blockIdMapping.values()).includes(blockId) - - if (mappedDiffAnalysis.new_blocks.includes(blockId)) { - ;(block as any).is_diff = 'new' - } else if (mappedDiffAnalysis.edited_blocks.includes(blockId)) { - ;(block as any).is_diff = 'edited' - if (mappedDiffAnalysis.field_diffs?.[blockId]) { - ;(block as any).field_diff = mappedDiffAnalysis.field_diffs[blockId] - } - } else if (wasInCurrentUpdate) { - // Block was in the update but not marked as new or edited - ;(block as any).is_diff = 'unchanged' - } - // Blocks not in the current update keep their existing diff status - }) - } + if (!response.success || !response.diff) { + return { + success: false, + errors: response.errors, } - } catch (error) { - logger.error('Auto layout failed for merged state:', error) } - // Update current diff with merged state - this.currentDiff = { - proposedState: mergedState, - diffAnalysis: mappedDiffAnalysis, - metadata: { - source: 'copilot', - timestamp: Date.now(), - }, - } + // Update the current diff + this.currentDiff = response.diff logger.info('Diff merged successfully', { - totalBlocksCount: Object.keys(mergedState.blocks).length, - totalEdgesCount: mergedState.edges.length, - updatedBlocks: Array.from(blockIdMapping.entries()) - .filter(([newId, existingId]) => newId !== existingId) - .map(([newId, existingId]) => ({ newId, existingId })), - newBlocks: Array.from(blockIdMapping.entries()) - .filter(([newId, existingId]) => newId === existingId) - .map(([newId]) => newId), + totalBlocksCount: Object.keys(response.diff.proposedState.blocks).length, + totalEdgesCount: response.diff.proposedState.edges.length, }) return { @@ -534,188 +134,7 @@ export class WorkflowDiffEngine { } } - /** - * Create a mapped version of diff analysis with new IDs - */ - private createMappedDiffAnalysis( - analysis: DiffAnalysis, - idMapping: Map - ): DiffAnalysis { - const mapped: DiffAnalysis = { - new_blocks: analysis.new_blocks.map((oldId) => idMapping.get(oldId) || oldId), - edited_blocks: analysis.edited_blocks.map((oldId) => idMapping.get(oldId) || oldId), - deleted_blocks: analysis.deleted_blocks, // Deleted blocks won't have new IDs - } - - // Map field diffs with new IDs - if (analysis.field_diffs) { - mapped.field_diffs = {} - Object.entries(analysis.field_diffs).forEach(([oldId, fieldDiff]) => { - const newId = idMapping.get(oldId) || oldId - mapped.field_diffs![newId] = fieldDiff - }) - } - - // Edge identifiers use block names (not IDs), so they don't need mapping - // They should remain as-is since block names are stable between workflows - if (analysis.edge_diff) { - mapped.edge_diff = { - new_edges: analysis.edge_diff.new_edges, // Keep original - uses block names - deleted_edges: analysis.edge_diff.deleted_edges, // Keep original - uses block names - unchanged_edges: analysis.edge_diff.unchanged_edges, // Keep original - uses block names - } - } - - return mapped - } - - /** - * Adjust child block positions to be relative to their parent containers - */ - private adjustChildBlockPositions(blocks: Record): void { - // Group blocks by their parent - const blocksByParent = new Map() - - Object.values(blocks).forEach((block) => { - const parentId = block.data?.parentId || (block as any).parentNode - if (parentId && blocks[parentId]) { - if (!blocksByParent.has(parentId)) { - blocksByParent.set(parentId, []) - } - blocksByParent.get(parentId)!.push(block) - } - }) - - // Adjust positions for each parent's children - blocksByParent.forEach((childBlocks, parentId) => { - const parentBlock = blocks[parentId] - if (!parentBlock) return - - // Get parent position - const parentPos = parentBlock.position - - logger.info(`Adjusting ${childBlocks.length} child blocks for parent ${parentId}`) - - // Track bounds for container sizing - let maxX = 0 - let maxY = 0 - - // Make child positions relative to parent - childBlocks.forEach((childBlock) => { - const currentPos = childBlock.position - - // Check if position is already relative (within reasonable bounds of parent container) - const isAlreadyRelative = Math.abs(currentPos.x) < 800 && Math.abs(currentPos.y) < 600 - - if (!isAlreadyRelative) { - // Position seems absolute, convert to relative - const relativePos = { - x: currentPos.x - parentPos.x, - y: currentPos.y - parentPos.y, - } - - childBlock.position = relativePos - logger.info( - `Adjusted child block ${childBlock.id} position from absolute`, - currentPos, - 'to relative', - relativePos - ) - } else { - logger.info(`Child block ${childBlock.id} position already relative:`, currentPos) - } - - // Track max bounds for container sizing - const blockWidth = childBlock.isWide ? 450 : 350 - const blockHeight = Math.max(childBlock.height || 100, 100) - maxX = Math.max(maxX, childBlock.position.x + blockWidth) - maxY = Math.max(maxY, childBlock.position.y + blockHeight) - }) - - // Update container dimensions to fit all children - if (parentBlock.type === 'loop' || parentBlock.type === 'parallel') { - const padding = 150 // Extra padding for container - const minWidth = 500 - const minHeight = 300 - - parentBlock.data = { - ...parentBlock.data, - width: Math.max(minWidth, maxX + padding), - height: Math.max(minHeight, maxY + padding), - } - - logger.info(`Updated container ${parentId} dimensions:`, { - width: parentBlock.data.width, - height: parentBlock.data.height, - }) - } - }) - } - - /** - * Apply diff markers to blocks based on analysis - */ - private applyDiffMarkers( - state: WorkflowState, - analysis: DiffAnalysis, - idMapping: Map - ): void { - console.log('[DiffEngine] Applying diff markers:', { - newBlocks: analysis.new_blocks, - editedBlocks: analysis.edited_blocks, - deletedBlocks: analysis.deleted_blocks, - totalBlocks: Object.keys(state.blocks).length, - timestamp: Date.now(), - }) - - // Create reverse mapping from new IDs to original IDs - const reverseMapping = new Map() - idMapping.forEach((newId, originalId) => { - reverseMapping.set(newId, originalId) - }) - - let markersApplied = 0 - Object.entries(state.blocks).forEach(([blockId, block]) => { - // Find original ID to check diff analysis - const originalId = reverseMapping.get(blockId) - - if (originalId) { - if (analysis.new_blocks.includes(originalId)) { - ;(block as any).is_diff = 'new' - markersApplied++ - logger.info(`Block ${blockId} (original: ${originalId}) marked as new`) - } else if (analysis.edited_blocks.includes(originalId)) { - ;(block as any).is_diff = 'edited' - markersApplied++ - - // Add field-level diff information if available - if (analysis.field_diffs?.[originalId]) { - ;(block as any).field_diff = analysis.field_diffs[originalId] - logger.info( - `Block ${blockId} (original: ${originalId}) marked as edited with field diff:`, - { - changed_fields: analysis.field_diffs[originalId].changed_fields, - unchanged_fields: analysis.field_diffs[originalId].unchanged_fields.length, - } - ) - } else { - logger.info(`Block ${blockId} (original: ${originalId}) marked as edited`) - } - } else { - ;(block as any).is_diff = 'unchanged' - } - } else { - ;(block as any).is_diff = 'unchanged' - logger.warn(`Block ${blockId} has no original ID mapping`) - } - }) - console.log('[DiffEngine] Diff markers applied:', { - markersApplied, - totalBlocks: Object.keys(state.blocks).length, - timestamp: Date.now(), - }) - } /** * Get the current diff @@ -749,7 +168,7 @@ export class WorkflowDiffEngine { return currentState } - /** + /** * Accept the diff and return the clean state */ acceptDiff(): WorkflowState | null { @@ -758,55 +177,53 @@ export class WorkflowDiffEngine { return null } - const cleanState = { ...this.currentDiff.proposedState } - - // Filter out blocks without type or name and remove diff markers - const filteredBlocks: Record = {} - Object.entries(cleanState.blocks).forEach(([blockId, block]) => { - if (block.type && block.name) { - // Remove diff markers and ensure all required fields are present - const cleanBlock: BlockState = { - ...block, - enabled: block.enabled !== undefined ? block.enabled : true, - horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, - isWide: block.isWide !== undefined ? block.isWide : false, - height: block.height !== undefined ? block.height : 0, - subBlocks: block.subBlocks || {}, - outputs: block.outputs !== undefined && block.outputs !== null ? block.outputs : {}, - data: block.data || {} - } - - // Remove diff markers - ;(cleanBlock as any).is_diff = undefined - ;(cleanBlock as any).field_diff = undefined - - filteredBlocks[blockId] = cleanBlock - } else { - logger.info(`Filtering out block ${blockId} - missing type or name`) + try { + // Clean up the proposed state by removing diff markers + const cleanState = this.cleanDiffMarkers(this.currentDiff.proposedState) + + logger.info('Diff accepted', { + blocksCount: Object.keys(cleanState.blocks).length, + edgesCount: cleanState.edges.length, + loopsCount: Object.keys(cleanState.loops).length, + parallelsCount: Object.keys(cleanState.parallels).length, + }) + + this.clearDiff() + return cleanState + } catch (error) { + logger.error('Failed to accept diff:', error) + return null + } + } + + /** + * Clean diff markers from a workflow state + */ + private cleanDiffMarkers(state: WorkflowState): WorkflowState { + const cleanBlocks: Record = {} + + // Remove diff markers from each block + for (const [blockId, block] of Object.entries(state.blocks)) { + const cleanBlock = { ...block } + + // Remove diff markers using bracket notation to avoid TypeScript errors + delete (cleanBlock as any)['is_diff'] + delete (cleanBlock as any)['field_diff'] + + // Ensure outputs is never null/undefined + if (cleanBlock.outputs === undefined || cleanBlock.outputs === null) { + cleanBlock.outputs = {} } - }) - - cleanState.blocks = filteredBlocks - - // Filter out edges that connect to removed blocks - const validBlockIds = new Set(Object.keys(filteredBlocks)) - cleanState.edges = cleanState.edges.filter( - (edge) => validBlockIds.has(edge.source) && validBlockIds.has(edge.target) - ) - - // Ensure loops and parallels are always present (even if empty) - cleanState.loops = cleanState.loops || {} - cleanState.parallels = cleanState.parallels || {} - - logger.info('Diff accepted', { - blocksCount: Object.keys(cleanState.blocks).length, - edgesCount: cleanState.edges.length, - loopsCount: Object.keys(cleanState.loops).length, - parallelsCount: Object.keys(cleanState.parallels).length, - }) - - this.clearDiff() - return cleanState + + cleanBlocks[blockId] = cleanBlock + } + + return { + blocks: cleanBlocks, + edges: state.edges || [], + loops: state.loops || {}, + parallels: state.parallels || {}, + } } /** diff --git a/apps/sim/lib/yaml-service-client.ts b/apps/sim/lib/yaml-service-client.ts index 88cf7c31476..1abf84eaff0 100644 --- a/apps/sim/lib/yaml-service-client.ts +++ b/apps/sim/lib/yaml-service-client.ts @@ -1,5 +1,6 @@ import { createLogger } from '@/lib/logs/console-logger' import type { WorkflowState, BlockState } from '@/stores/workflows/workflow/types' +import type { DiffAnalysis, WorkflowDiff } from '@/lib/workflows/diff/diff-engine' const logger = createLogger('YamlServiceClient') @@ -28,6 +29,32 @@ interface DiffYamlResponse { errors: string[] } +interface CreateDiffResponse { + success: boolean + diff?: WorkflowDiff + errors: string[] +} + +interface MergeDiffResponse { + success: boolean + diff?: WorkflowDiff + errors: string[] +} + + + +interface AnalyzeDiffResponse { + success: boolean + data?: DiffAnalysis + errors: string[] +} + +interface AutoLayoutResponse { + success: boolean + workflowState?: WorkflowState + errors?: string[] +} + export class YamlServiceClient { constructor() { logger.info('YamlServiceClient initialized') @@ -97,6 +124,61 @@ export class YamlServiceClient { }) } + async createDiff( + yamlContent: string, + diffAnalysis?: DiffAnalysis, + options?: { + applyAutoLayout?: boolean + layoutOptions?: any + } + ): Promise { + return this.fetchFromAPI('/diff/create', { + yamlContent, + diffAnalysis, + options + }) + } + + async mergeDiff( + existingDiff: WorkflowDiff, + yamlContent: string, + diffAnalysis?: DiffAnalysis, + options?: { + applyAutoLayout?: boolean + layoutOptions?: any + } + ): Promise { + return this.fetchFromAPI('/diff/merge', { + existingDiff, + yamlContent, + diffAnalysis, + options + }) + } + + async autoLayout( + workflowState: WorkflowState, + options?: { + strategy?: 'smart' | 'hierarchical' | 'layered' | 'force-directed' + direction?: 'horizontal' | 'vertical' | 'auto' + spacing?: { + horizontal?: number + vertical?: number + layer?: number + } + alignment?: 'start' | 'center' | 'end' + padding?: { + x?: number + y?: number + } + } + ): Promise { + return this.fetchFromAPI('/autolayout', { + workflowState, + options + }) + } + // Helper method to check if external service is available async healthCheck(): Promise { try { @@ -127,4 +209,4 @@ export class YamlServiceClient { export const yamlService = new YamlServiceClient() // Export types for consumers -export type { ParseYamlResponse, ConvertYamlToWorkflowResponse, GenerateYamlResponse, DiffYamlResponse } \ No newline at end of file +export type { ParseYamlResponse, ConvertYamlToWorkflowResponse, GenerateYamlResponse, DiffYamlResponse, AutoLayoutResponse } \ No newline at end of file diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index c69b3fb0aec..3e21fcd670f 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -1220,6 +1220,9 @@ export const useCopilotStore = create()( try { // Get current workflow as YAML for comparison const { useWorkflowYamlStore } = await import('@/stores/workflows/yaml/store') + + // Ensure YAML is generated before proceeding + await useWorkflowYamlStore.getState().generateYaml() const currentYaml = useWorkflowYamlStore.getState().getYaml() logger.info('Got current workflow YAML for diff:', { @@ -1227,28 +1230,34 @@ export const useCopilotStore = create()( hasCurrentYaml: !!currentYaml }) - // Call the diff API to compare current vs proposed YAML - const diffResponse = await fetch('/api/workflows/diff', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - original_yaml: currentYaml, - agent_yaml: yamlContent, - }), - }) + // If current YAML is empty, skip diff analysis - everything will be new + if (!currentYaml || currentYaml.trim() === '') { + logger.info('Current workflow is empty, treating all blocks as new') + // Don't call diff API - let the diff engine handle it as all new blocks + } else { + // Call the diff API to compare current vs proposed YAML + const diffResponse = await fetch('/api/workflows/diff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + original_yaml: currentYaml, + agent_yaml: yamlContent, + }), + }) - if (diffResponse.ok) { - const diffResult = await diffResponse.json() - if (diffResult.success && diffResult.data) { - diffAnalysis = diffResult.data - logger.info('Successfully generated diff analysis', { - newBlocks: diffAnalysis.new_blocks?.length || 0, - editedBlocks: diffAnalysis.edited_blocks?.length || 0, - deletedBlocks: diffAnalysis.deleted_blocks?.length || 0, - }) + if (diffResponse.ok) { + const diffResult = await diffResponse.json() + if (diffResult.success && diffResult.data) { + diffAnalysis = diffResult.data + logger.info('Successfully generated diff analysis', { + newBlocks: diffAnalysis.new_blocks?.length || 0, + editedBlocks: diffAnalysis.edited_blocks?.length || 0, + deletedBlocks: diffAnalysis.deleted_blocks?.length || 0, + }) + } + } else { + logger.warn('Failed to generate diff analysis, proceeding without it') } - } else { - logger.warn('Failed to generate diff analysis, proceeding without it') } } catch (diffError) { logger.warn('Error generating diff analysis:', diffError) From d94f638acf862e5b2505dab31e993530c0716314 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 30 Jul 2025 11:59:19 -0700 Subject: [PATCH 113/184] Refactor diff engine --- apps/sim/app/api/copilot/chat/route.ts | 1 - .../api/workflows/[id]/autolayout/route.ts | 51 +- apps/sim/app/api/workflows/[id]/yaml/route.ts | 50 +- apps/sim/app/api/workflows/diff/route.ts | 682 ------------------ apps/sim/app/api/yaml/autolayout/route.ts | 10 +- apps/sim/app/api/yaml/diff/create/route.ts | 33 +- apps/sim/app/api/yaml/diff/merge/route.ts | 7 +- .../workflow-block/workflow-block.tsx | 2 +- .../workflow-edge/workflow-edge.tsx | 2 +- .../lib/autolayout/algorithms/hierarchical.ts | 452 ------------ apps/sim/lib/autolayout/algorithms/smart.ts | 608 ---------------- apps/sim/lib/autolayout/service.ts | 592 --------------- apps/sim/lib/autolayout/types.ts | 101 --- apps/sim/lib/workflows/diff/diff-engine.ts | 68 +- apps/sim/lib/yaml-service-client.ts | 53 +- apps/sim/stores/copilot/store.ts | 78 +- apps/sim/stores/workflow-diff/store.ts | 20 +- apps/sim/stores/workflows/yaml/importer.ts | 429 ----------- 18 files changed, 240 insertions(+), 2999 deletions(-) delete mode 100644 apps/sim/app/api/workflows/diff/route.ts delete mode 100644 apps/sim/lib/autolayout/algorithms/hierarchical.ts delete mode 100644 apps/sim/lib/autolayout/algorithms/smart.ts delete mode 100644 apps/sim/lib/autolayout/service.ts delete mode 100644 apps/sim/lib/autolayout/types.ts delete mode 100644 apps/sim/stores/workflows/yaml/importer.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 9311be241f9..9d3231b96d2 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -358,7 +358,6 @@ export async function POST(req: NextRequest) { switch (event.type) { case 'content': if (event.data) { - logger.debug(`[${requestId}] Content delta: "${event.data}"`) assistantContent += event.data } break diff --git a/apps/sim/app/api/workflows/[id]/autolayout/route.ts b/apps/sim/app/api/workflows/[id]/autolayout/route.ts index 8416a365d91..fb4ed8030a2 100644 --- a/apps/sim/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/sim/app/api/workflows/[id]/autolayout/route.ts @@ -2,7 +2,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { autoLayoutWorkflow } from '@/lib/autolayout/service' +import { yamlService } from '@/lib/yaml-service-client' import { createLogger } from '@/lib/logs/console-logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { @@ -123,24 +123,37 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ `[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks` ) - const layoutedBlocks = await autoLayoutWorkflow( - currentWorkflowData.blocks, - currentWorkflowData.edges, - { - strategy: layoutOptions.strategy, - direction: layoutOptions.direction, - spacing: { - horizontal: layoutOptions.spacing?.horizontal || 500, // Updated from 400 to match improved spacing - vertical: layoutOptions.spacing?.vertical || 400, // Updated from 200 to match improved spacing - layer: layoutOptions.spacing?.layer || 700, // Updated from 600 to match improved spacing - }, - alignment: layoutOptions.alignment, - padding: { - x: layoutOptions.padding?.x || 250, // Updated from 200 to match improved spacing - y: layoutOptions.padding?.y || 250, // Updated from 200 to match improved spacing - }, - } - ) + // Create workflow state for autolayout + const workflowState = { + blocks: currentWorkflowData.blocks, + edges: currentWorkflowData.edges, + loops: currentWorkflowData.loops || {}, + parallels: currentWorkflowData.parallels || {} + } + + const autoLayoutOptions = { + strategy: layoutOptions.strategy, + direction: layoutOptions.direction, + spacing: { + horizontal: layoutOptions.spacing?.horizontal || 500, + vertical: layoutOptions.spacing?.vertical || 400, + layer: layoutOptions.spacing?.layer || 700, + }, + alignment: layoutOptions.alignment, + padding: { + x: layoutOptions.padding?.x || 250, + y: layoutOptions.padding?.y || 250, + }, + } + + const autoLayoutResult = await yamlService.autoLayout(workflowState, autoLayoutOptions) + + if (!autoLayoutResult.success || !autoLayoutResult.workflowState) { + logger.error(`[${requestId}] Auto layout failed:`, autoLayoutResult.errors) + return NextResponse.json({ error: 'Auto layout failed' }, { status: 500 }) + } + + const layoutedBlocks = autoLayoutResult.workflowState.blocks // Create updated workflow state const updatedWorkflowState = { diff --git a/apps/sim/app/api/workflows/[id]/yaml/route.ts b/apps/sim/app/api/workflows/[id]/yaml/route.ts index bc8f4a47b64..70cfd82da7a 100644 --- a/apps/sim/app/api/workflows/[id]/yaml/route.ts +++ b/apps/sim/app/api/workflows/[id]/yaml/route.ts @@ -1,7 +1,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { autoLayoutWorkflow } from '@/lib/autolayout/service' +import { yamlService } from '@/lib/yaml-service-client' import { createLogger } from '@/lib/logs/console-logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { @@ -539,26 +539,36 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ try { logger.info(`[${requestId}] Applying autolayout`) - const layoutedBlocks = await autoLayoutWorkflow( - newWorkflowState.blocks, - newWorkflowState.edges, - { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, // Increased from 400 to match UI button - vertical: 400, // Increased from 200 to match UI button - layer: 700, // Increased from 600 to match UI button - }, - alignment: 'center', - padding: { - x: 250, // Increased from 200 to match UI button - y: 250, // Increased from 200 to match UI button - }, - } - ) + // Create workflow state for autolayout + const workflowStateForLayout = { + blocks: newWorkflowState.blocks, + edges: newWorkflowState.edges, + loops: newWorkflowState.loops || {}, + parallels: newWorkflowState.parallels || {} + } - newWorkflowState.blocks = layoutedBlocks + const autoLayoutOptions = { + strategy: 'smart' as const, + direction: 'auto' as const, + spacing: { + horizontal: 500, + vertical: 400, + layer: 700, + }, + alignment: 'center' as const, + padding: { + x: 250, + y: 250, + }, + } + + const autoLayoutResult = await yamlService.autoLayout(workflowStateForLayout, autoLayoutOptions) + + if (autoLayoutResult.success && autoLayoutResult.workflowState) { + newWorkflowState.blocks = autoLayoutResult.workflowState.blocks + } else { + logger.warn(`[${requestId}] Auto layout failed, using original positions:`, autoLayoutResult.errors) + } logger.info(`[${requestId}] Autolayout completed successfully`) } catch (layoutError) { logger.warn(`[${requestId}] Autolayout failed, using original positions:`, layoutError) diff --git a/apps/sim/app/api/workflows/diff/route.ts b/apps/sim/app/api/workflows/diff/route.ts deleted file mode 100644 index 28a6d6e627e..00000000000 --- a/apps/sim/app/api/workflows/diff/route.ts +++ /dev/null @@ -1,682 +0,0 @@ -import crypto from 'crypto' -import { dump as yamlDump } from 'js-yaml' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { createLogger } from '@/lib/logs/console-logger' -import { getAllBlocks } from '@/blocks/registry' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { resolveOutputType } from '@/blocks/utils' -import type { BlockConfig } from '@/blocks/types' - -const logger = createLogger('WorkflowYamlDiffAPI') - -// Sim Agent API configuration -const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' -const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY - -/** - * Helper function to parse YAML by calling sim-agent - */ -async function parseYamlViaSim(yamlContent: string) { - // Gather block registry and utilities - const blocks = getAllBlocks() - const blockRegistry = blocks.reduce((acc, block) => { - const blockType = block.type - acc[blockType] = { - ...block, - id: blockType, - subBlocks: block.subBlocks || [], - outputs: block.outputs || {}, - } as any - return acc - }, {} as Record) - - const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/parse`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), - }, - body: JSON.stringify({ - yamlContent, - blockRegistry, - utilities: { - generateLoopBlocks: generateLoopBlocks.toString(), - generateParallelBlocks: generateParallelBlocks.toString(), - resolveOutputType: resolveOutputType.toString() - } - }), - }) - - if (!response.ok) { - throw new Error(`Sim agent API error: ${response.statusText}`) - } - - return response.json() -} - -// Request schema for YAML diff operations -const YamlDiffRequestSchema = z.object({ - original_yaml: z.string().min(1, 'Original YAML content is required'), - agent_yaml: z.string().min(1, 'Agent YAML content is required'), -}) - -type YamlDiffRequest = z.infer - -/** - * Clean up YAML content by removing empty blocks and formatting - */ -async function cleanupYamlContent(yamlContent: string): Promise { - try { - // Parse the YAML by calling sim-agent directly - const parseResult = await parseYamlViaSim(yamlContent) - - if (!parseResult.success || !parseResult.data || !parseResult.data.blocks) { - return yamlContent - } - - const workflowData = parseResult.data - - // Filter out empty blocks - const cleanedBlocks: Record = {} - Object.entries(workflowData.blocks).forEach(([blockId, block]) => { - // Only include blocks that have at least type and name - if ( - block && - typeof block === 'object' && - (block as any).type && - (block as any).name - ) { - cleanedBlocks[blockId] = block - } - }) - - // Rebuild the workflow with cleaned blocks - const cleanedWorkflow = { - ...workflowData, - blocks: cleanedBlocks, - } - - return yamlDump(cleanedWorkflow, { - indent: 2, - lineWidth: -1, - noRefs: true, - }) - } catch (error) { - logger.error('Failed to cleanup YAML content:', error) - return yamlContent - } -} - -interface EdgeDiff { - new_edges: string[] - deleted_edges: string[] - unchanged_edges: string[] -} - -interface DiffResult { - deleted_blocks: string[] - edited_blocks: string[] - new_blocks: string[] - field_diffs?: Record - edge_diff?: EdgeDiff -} - -interface BlockHash { - blockId: string - name: string - hash: string - inputs?: Record -} - -interface EdgeIdentity { - id: string - source: string - target: string - sourceHandle?: string - targetHandle?: string -} - -/** - * Generate a unique identifier for an edge based on block names (not IDs) - * Must match the frontend logic which defaults sourceHandle to 'success' - */ -function generateEdgeIdentity( - sourceName: string, - targetName: string, - sourceHandle?: string, - targetHandle?: string -): string { - // Match frontend logic: use 'success' as default when sourceHandle is undefined/null - const effectiveSourceHandle = sourceHandle || 'success' - return `${sourceName}:${effectiveSourceHandle}->${targetName}${targetHandle ? `:${targetHandle}` : ''}` -} - -/** - * Extract edges from YAML workflow connections using block names - */ -function extractEdges(yamlWorkflow: any): EdgeIdentity[] { - const edges: EdgeIdentity[] = [] - - if (!yamlWorkflow.blocks || typeof yamlWorkflow.blocks !== 'object') { - return edges - } - - // Create mapping from block ID to block name - const blockIdToName = new Map() - Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { - if (block && typeof block === 'object' && block.name) { - blockIdToName.set(blockId, block.name) - } - }) - - Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { - if (!block || typeof block !== 'object' || !block.connections) { - return - } - - const sourceName = blockIdToName.get(blockId) - if (!sourceName) return - - const connections = block.connections - - // Handle 'default' connections (simple format) - if (connections.default) { - const targets = Array.isArray(connections.default) - ? connections.default - : [connections.default] - targets.forEach((targetId: string) => { - const targetName = blockIdToName.get(targetId) - if (!targetName) return - - const edgeId = generateEdgeIdentity(sourceName, targetName) - edges.push({ - id: edgeId, - source: sourceName, - target: targetName, - }) - }) - } - - // Handle named output connections - Object.entries(connections).forEach(([outputName, targets]) => { - if (outputName === 'default') return // Already handled - - const targetList = Array.isArray(targets) ? targets : [targets] - targetList.forEach((target: any) => { - if (typeof target === 'string') { - const targetName = blockIdToName.get(target) - if (!targetName) return - - const edgeId = generateEdgeIdentity(sourceName, targetName, outputName) - edges.push({ - id: edgeId, - source: sourceName, - target: targetName, - sourceHandle: outputName, - }) - } else if (target && typeof target === 'object' && target.block) { - const targetName = blockIdToName.get(target.block) - if (!targetName) return - - const edgeId = generateEdgeIdentity(sourceName, targetName, outputName, target.input) - edges.push({ - id: edgeId, - source: sourceName, - target: targetName, - sourceHandle: outputName, - targetHandle: target.input, - }) - } - }) - }) - }) - - return edges -} - -/** - * Compare edges between two workflows to find differences - */ -function compareEdges( - originalEdges: EdgeIdentity[], - agentEdges: EdgeIdentity[], - blockNameToHash: { - originalNameToHash: Map - agentNameToHash: Map - }, - blockDiff: { new_blocks: string[]; deleted_blocks: string[]; edited_blocks: string[] } -): EdgeDiff { - const result: EdgeDiff = { - new_edges: [], - deleted_edges: [], - unchanged_edges: [], - } - - // Create edge ID sets for comparison - const originalEdgeIds = new Set(originalEdges.map((e) => e.id)) - const agentEdgeIds = new Set(agentEdges.map((e) => e.id)) - - // Get block names that are new or deleted - const newBlockNames = new Set() - const deletedBlockNames = new Set() - - // Map block IDs to names for new/deleted blocks - Array.from(blockNameToHash.originalNameToHash.entries()).forEach(([name, _]) => { - const nameExistsInAgent = blockNameToHash.agentNameToHash.has(name) - if (!nameExistsInAgent) { - deletedBlockNames.add(name) - } - }) - - Array.from(blockNameToHash.agentNameToHash.entries()).forEach(([name, _]) => { - const nameExistsInOriginal = blockNameToHash.originalNameToHash.has(name) - if (!nameExistsInOriginal) { - newBlockNames.add(name) - } - }) - - // Find deleted edges (in original but not in agent) - originalEdges.forEach((edge) => { - // An edge is deleted if: - // 1. The edge doesn't exist in the agent workflow (was removed), OR - // 2. Either its source or target block was deleted - const edgeRemoved = !agentEdgeIds.has(edge.id) - const sourceDeleted = deletedBlockNames.has(edge.source) - const targetDeleted = deletedBlockNames.has(edge.target) - - if (edgeRemoved || sourceDeleted || targetDeleted) { - result.deleted_edges.push(edge.id) - } - }) - - // Find new and unchanged edges in agent workflow - agentEdges.forEach((edge) => { - const isNewEdge = !originalEdgeIds.has(edge.id) - const connectsToNewBlock = newBlockNames.has(edge.source) || newBlockNames.has(edge.target) - - if (isNewEdge || connectsToNewBlock) { - result.new_edges.push(edge.id) - } else { - result.unchanged_edges.push(edge.id) - } - }) - - return result -} - -/** - * Compare two block inputs to find which fields changed - */ -function compareBlockInputs( - originalInputs: Record, - agentInputs: Record -): { changed_fields: string[]; unchanged_fields: string[] } { - const changed_fields: string[] = [] - const unchanged_fields: string[] = [] - - // Get all unique field names from both blocks - const allFields = new Set([ - ...Object.keys(originalInputs || {}), - ...Object.keys(agentInputs || {}), - ]) - - for (const field of allFields) { - const originalValue = originalInputs?.[field] - const agentValue = agentInputs?.[field] - - // Normalize values for comparison (handle null/undefined/empty string equivalence) - const normalizeValue = (value: any) => { - if (value === null || value === undefined || value === '') { - return null - } - if (typeof value === 'object') { - return JSON.stringify(value) - } - return String(value).trim() - } - - const normalizedOriginal = normalizeValue(originalValue) - const normalizedAgent = normalizeValue(agentValue) - - if (normalizedOriginal !== normalizedAgent) { - changed_fields.push(field) - } else { - unchanged_fields.push(field) - } - } - - return { changed_fields, unchanged_fields } -} - -/** - * Create a hash of block contents excluding IDs, name, and connections - */ -function hashBlockContents(block: any): string { - // Create a copy of the block to avoid mutating the original - const blockCopy = JSON.parse(JSON.stringify(block)) - - // Extract the properties we want to hash - const hashableContent = { - type: blockCopy.type, - inputs: blockCopy.inputs || {}, - parentId: blockCopy.parentId || null, - } - - // Debug: Log what content will be hashed - console.log(`Hashing block content for ${block.name}:`, JSON.stringify(hashableContent, null, 2)) - - // Remove any ID fields from inputs recursively - function removeIds(obj: any): any { - if (obj === null || obj === undefined) { - return obj - } - - if (Array.isArray(obj)) { - return obj.map(removeIds) - } - - if (typeof obj === 'object') { - const cleaned: any = {} - for (const [key, value] of Object.entries(obj)) { - // Skip only actual ID fields (not fields like "apiKey" that contain "id") - if ( - key === 'id' || - key === 'blockId' || - key === 'targetId' || - key === 'sourceId' || - key.endsWith('Id') || - key.endsWith('_id') - ) { - continue - } - cleaned[key] = removeIds(value) - } - return cleaned - } - - return obj - } - - const cleanedContent = removeIds(hashableContent) - - // Debug: Log what content will actually be hashed after ID removal - console.log(`Cleaned content for ${block.name}:`, JSON.stringify(cleanedContent, null, 2)) - - // Create deterministic JSON string (sorted keys recursively) - const sortObjectKeys = (obj: any): any => { - if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) { - return obj - } - - const sorted: any = {} - Object.keys(obj) - .sort() - .forEach((key) => { - sorted[key] = sortObjectKeys(obj[key]) - }) - return sorted - } - - const sortedContent = sortObjectKeys(cleanedContent) - - // Hash the content - const hash = crypto.createHash('sha256').update(JSON.stringify(sortedContent)).digest('hex') - - console.log(`Generated hash for ${block.name}: ${hash.substring(0, 8)}...`) - - return hash -} - -/** - * Extract block hashes from a parsed YAML workflow - */ -function extractBlockHashes(yamlWorkflow: any): BlockHash[] { - const blockHashes: BlockHash[] = [] - - if (!yamlWorkflow.blocks || typeof yamlWorkflow.blocks !== 'object') { - return blockHashes - } - - Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]: [string, any]) => { - if (!block || typeof block !== 'object') { - return - } - - const hash = hashBlockContents(block) - blockHashes.push({ - blockId, - name: block.name || '', - hash, - inputs: block.inputs || {}, - }) - }) - - return blockHashes -} - -/** - * POST /api/workflows/diff - * Compare two YAML workflow configurations and return diff analysis - */ -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - const startTime = Date.now() - - try { - // Parse and validate request - const body = await request.json() - const { original_yaml, agent_yaml } = YamlDiffRequestSchema.parse(body) - - logger.info(`[${requestId}] Processing YAML diff request`, { - originalYamlLength: original_yaml.length, - agentYamlLength: agent_yaml.length, - }) - - // Debug: Log the actual YAML content being compared - logger.info( - `[${requestId}] Original YAML content (first 500 chars):`, - original_yaml.substring(0, 500) - ) - logger.info( - `[${requestId}] Agent YAML content (first 500 chars):`, - agent_yaml.substring(0, 500) - ) - - // Handle empty original YAML (new workflow case) - let originalWorkflow: any = null - let originalErrors: string[] = [] - - if (!original_yaml || original_yaml.trim() === '') { - logger.info(`[${requestId}] No original YAML provided, treating as new workflow`) - // Create empty workflow structure for comparison - originalWorkflow = { - name: 'New Workflow', - blocks: {}, - edges: [] - } - } else { - // Clean up and parse original YAML - const cleanedOriginalYaml = await cleanupYamlContent(original_yaml) - const originalParseResult = await parseYamlViaSim(cleanedOriginalYaml) - originalWorkflow = originalParseResult.data - originalErrors = originalParseResult.errors || [] - - // Check for parsing errors - if (!originalWorkflow || originalErrors.length > 0) { - logger.error(`[${requestId}] Original YAML parsing failed`, { originalErrors }) - return NextResponse.json( - { - success: false, - message: 'Failed to parse original YAML workflow', - errors: originalErrors, - }, - { status: 400 } - ) - } - } - - // Clean up and parse agent YAML - const cleanedAgentYaml = await cleanupYamlContent(agent_yaml) - const agentParseResult = await parseYamlViaSim(cleanedAgentYaml) - const agentWorkflow = agentParseResult.data - const agentErrors = agentParseResult.errors || [] - - if (!agentWorkflow || agentErrors.length > 0) { - logger.error(`[${requestId}] Agent YAML parsing failed`, { agentErrors }) - return NextResponse.json( - { - success: false, - message: 'Failed to parse agent YAML workflow', - errors: agentErrors, - }, - { status: 400 } - ) - } - - // Extract block hashes from both workflows - const originalHashes = extractBlockHashes(originalWorkflow) - const agentHashes = extractBlockHashes(agentWorkflow) - - logger.info(`[${requestId}] Extracted block hashes`, { - originalBlockCount: originalHashes.length, - agentBlockCount: agentHashes.length, - }) - - // Create hash sets for efficient lookup - const originalHashSet = new Set(originalHashes.map((b) => b.hash)) - const agentHashSet = new Set(agentHashes.map((b) => b.hash)) - - // Create name-to-hash mappings for edited block detection - const originalNameToHash = new Map(originalHashes.map((b) => [b.name, b.hash])) - const agentNameToHash = new Map(agentHashes.map((b) => [b.name, b.hash])) - - // Create name-to-blockId mappings - const originalNameToId = new Map(originalHashes.map((b) => [b.name, b.blockId])) - const agentNameToId = new Map(agentHashes.map((b) => [b.name, b.blockId])) - - // Create name-to-block mappings for field comparison - const originalNameToBlock = new Map(originalHashes.map((b) => [b.name, b])) - const agentNameToBlock = new Map(agentHashes.map((b) => [b.name, b])) - - // Analyze differences - const result: DiffResult = { - deleted_blocks: [], - edited_blocks: [], - new_blocks: [], - field_diffs: {}, - } - - // Find deleted blocks: blocks in original that don't exist in agent (by name AND hash) - for (const originalBlock of originalHashes) { - const nameExistsInAgent = agentNameToHash.has(originalBlock.name) - const hashExistsInAgent = agentHashSet.has(originalBlock.hash) - - if (!nameExistsInAgent && !hashExistsInAgent) { - result.deleted_blocks.push(originalBlock.blockId) - } - } - - // Find edited and new blocks in agent workflow - for (const agentBlock of agentHashes) { - const nameExistsInOriginal = originalNameToHash.has(agentBlock.name) - const hashExistsInOriginal = originalHashSet.has(agentBlock.hash) - - logger.info(`[${requestId}] Checking agent block: ${agentBlock.name}`, { - nameExistsInOriginal, - hashExistsInOriginal, - agentHash: agentBlock.hash.substring(0, 8), - originalHash: originalNameToHash.get(agentBlock.name)?.substring(0, 8) || 'none', - }) - - if (nameExistsInOriginal) { - // Block name exists in original - const originalHash = originalNameToHash.get(agentBlock.name) - if (originalHash !== agentBlock.hash) { - // Same name but different hash = edited block - logger.info(`[${requestId}] Found edited block: ${agentBlock.name}`) - result.edited_blocks.push(agentBlock.blockId) - - // Calculate field-level differences for this edited block - const originalBlock = originalNameToBlock.get(agentBlock.name) - if (originalBlock) { - const fieldDiff = compareBlockInputs( - originalBlock.inputs || {}, - agentBlock.inputs || {} - ) - result.field_diffs![agentBlock.blockId] = fieldDiff - - logger.info(`[${requestId}] Field diff for ${agentBlock.name}:`, { - changed_fields: fieldDiff.changed_fields, - unchanged_fields: fieldDiff.unchanged_fields.length, - }) - } - } - // If same name and same hash, it's unchanged (no action needed) - } else if (!hashExistsInOriginal) { - // Block name doesn't exist in original AND hash doesn't exist = new block - logger.info(`[${requestId}] Found new block: ${agentBlock.name}`) - result.new_blocks.push(agentBlock.blockId) - } - // If name doesn't exist but hash exists, it's a renamed block (treat as unchanged) - } - - // Extract and compare edges - const originalEdges = extractEdges(originalWorkflow) - const agentEdges = extractEdges(agentWorkflow) - - logger.info(`[${requestId}] Extracted edges`, { - originalEdgeCount: originalEdges.length, - agentEdgeCount: agentEdges.length, - }) - - // Compare edges - const edgeDiff = compareEdges( - originalEdges, - agentEdges, - { originalNameToHash, agentNameToHash }, - result - ) - result.edge_diff = edgeDiff - - logger.info(`[${requestId}] Edge diff analysis`, { - newEdges: edgeDiff.new_edges.length, - deletedEdges: edgeDiff.deleted_edges.length, - unchangedEdges: edgeDiff.unchanged_edges.length, - }) - - const elapsed = Date.now() - startTime - - logger.info(`[${requestId}] YAML diff completed in ${elapsed}ms`, { - deletedCount: result.deleted_blocks.length, - editedCount: result.edited_blocks.length, - newCount: result.new_blocks.length, - fieldDiffsCount: Object.keys(result.field_diffs || {}).length, - originalBlocks: originalHashes.map((h) => `${h.name}:${h.hash.substring(0, 8)}`), - agentBlocks: agentHashes.map((h) => `${h.name}:${h.hash.substring(0, 8)}`), - fieldDiffs: result.field_diffs, - }) - - return NextResponse.json({ - success: true, - data: result, - metadata: { - original_block_count: originalHashes.length, - agent_block_count: agentHashes.length, - processing_time_ms: elapsed, - }, - }) - } catch (error) { - const elapsed = Date.now() - startTime - logger.error(`[${requestId}] YAML diff failed in ${elapsed}ms`, error) - - return NextResponse.json( - { - success: false, - message: `Failed to process YAML diff: ${error instanceof Error ? error.message : 'Unknown error'}`, - error: error instanceof Error ? error.message : 'Unknown error', - }, - { status: 500 } - ) - } -} diff --git a/apps/sim/app/api/yaml/autolayout/route.ts b/apps/sim/app/api/yaml/autolayout/route.ts index e86e5920033..81774412699 100644 --- a/apps/sim/app/api/yaml/autolayout/route.ts +++ b/apps/sim/app/api/yaml/autolayout/route.ts @@ -4,8 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { getAllBlocks } from '@/blocks/registry' import { generateLoopBlocks, generateParallelBlocks, convertLoopBlockToLoop, convertParallelBlockToParallel, findChildNodes, findAllDescendantNodes } from '@/stores/workflows/workflow/utils' import { resolveOutputType } from '@/blocks/utils' -import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from '@/lib/autolayout/types' -import { autoLayoutWorkflow } from '@/lib/autolayout/service' + import type { BlockConfig } from '@/blocks/types' const logger = createLogger('YamlAutoLayoutAPI') @@ -90,10 +89,7 @@ export async function POST(request: NextRequest) { parallels: workflowState.parallels || {}, options, blockRegistry, - blockMappings: { - categories: BLOCK_CATEGORIES, - dimensions: BLOCK_DIMENSIONS - }, + utilities: { generateLoopBlocks: generateLoopBlocks.toString(), generateParallelBlocks: generateParallelBlocks.toString(), @@ -102,7 +98,7 @@ export async function POST(request: NextRequest) { convertParallelBlockToParallel: convertParallelBlockToParallel.toString(), findChildNodes: findChildNodes.toString(), findAllDescendantNodes: findAllDescendantNodes.toString(), - autoLayoutWorkflow: autoLayoutWorkflow.toString() + } }), }) diff --git a/apps/sim/app/api/yaml/diff/create/route.ts b/apps/sim/app/api/yaml/diff/create/route.ts index 649190b6ab4..1d30f6ee12a 100644 --- a/apps/sim/app/api/yaml/diff/create/route.ts +++ b/apps/sim/app/api/yaml/diff/create/route.ts @@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { getAllBlocks } from '@/blocks/registry' import { generateLoopBlocks, generateParallelBlocks, convertLoopBlockToLoop, convertParallelBlockToParallel, findChildNodes, findAllDescendantNodes } from '@/stores/workflows/workflow/utils' import { resolveOutputType } from '@/blocks/utils' -import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from '@/lib/autolayout/types' + import type { BlockConfig } from '@/blocks/types' const logger = createLogger('YamlDiffCreateAPI') @@ -32,6 +32,12 @@ const CreateDiffRequestSchema = z.object({ options: z.object({ applyAutoLayout: z.boolean().optional(), layoutOptions: z.any().optional() + }).optional(), + currentWorkflowState: z.object({ + blocks: z.record(z.any()), + edges: z.array(z.any()), + loops: z.record(z.any()).optional(), + parallels: z.record(z.any()).optional() }).optional() }) @@ -42,12 +48,18 @@ export async function POST(request: NextRequest) { const body = await request.json() const { yamlContent, diffAnalysis, options } = CreateDiffRequestSchema.parse(body) + // Get current workflow state for comparison + // Note: This endpoint is stateless, so we need to get this from the request + const currentWorkflowState = (body as any).currentWorkflowState + logger.info(`[${requestId}] Creating diff from YAML`, { contentLength: yamlContent.length, hasDiffAnalysis: !!diffAnalysis, hasOptions: !!options, options: options, hasApiKey: !!SIM_AGENT_API_KEY, + hasCurrentWorkflowState: !!currentWorkflowState, + currentBlockCount: currentWorkflowState ? Object.keys(currentWorkflowState.blocks || {}).length : 0 }) // Gather block registry @@ -74,10 +86,8 @@ export async function POST(request: NextRequest) { yamlContent, diffAnalysis, blockRegistry, - blockMappings: { - categories: BLOCK_CATEGORIES, - dimensions: BLOCK_DIMENSIONS - }, + currentWorkflowState, // Pass current state for comparison + utilities: { generateLoopBlocks: generateLoopBlocks.toString(), generateParallelBlocks: generateParallelBlocks.toString(), @@ -108,6 +118,19 @@ export async function POST(request: NextRequest) { // Log the full response to see if auto-layout is happening logger.info(`[${requestId}] Full sim agent response:`, JSON.stringify(result, null, 2)) + // Log diff analysis specifically + if (result.diff?.diffAnalysis) { + logger.info(`[${requestId}] Diff analysis received:`, { + new_blocks: result.diff.diffAnalysis.new_blocks || [], + edited_blocks: result.diff.diffAnalysis.edited_blocks || [], + deleted_blocks: result.diff.diffAnalysis.deleted_blocks || [], + has_field_diffs: !!result.diff.diffAnalysis.field_diffs, + has_edge_diff: !!result.diff.diffAnalysis.edge_diff + }) + } else { + logger.warn(`[${requestId}] No diff analysis in response!`) + } + // If the sim agent returned blocks directly (when auto-layout is applied), // transform it to the expected diff format if (result.success && result.blocks && !result.diff) { diff --git a/apps/sim/app/api/yaml/diff/merge/route.ts b/apps/sim/app/api/yaml/diff/merge/route.ts index 030666da385..5bbdabb8ebd 100644 --- a/apps/sim/app/api/yaml/diff/merge/route.ts +++ b/apps/sim/app/api/yaml/diff/merge/route.ts @@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console-logger' import { getAllBlocks } from '@/blocks/registry' import { generateLoopBlocks, generateParallelBlocks, convertLoopBlockToLoop, convertParallelBlockToParallel, findChildNodes, findAllDescendantNodes } from '@/stores/workflows/workflow/utils' import { resolveOutputType } from '@/blocks/utils' -import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from '@/lib/autolayout/types' + import type { BlockConfig } from '@/blocks/types' const logger = createLogger('YamlDiffMergeAPI') @@ -76,10 +76,7 @@ export async function POST(request: NextRequest) { yamlContent, diffAnalysis, blockRegistry, - blockMappings: { - categories: BLOCK_CATEGORIES, - dimensions: BLOCK_DIMENSIONS - }, + utilities: { generateLoopBlocks: generateLoopBlocks.toString(), generateParallelBlocks: generateParallelBlocks.toString(), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 36bdf5658b6..f0ff07ee6d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -78,7 +78,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { // Get field-level diff information const fieldDiff = - currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diff : undefined + currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).field_diffs : undefined // Debug: Log diff status for this block useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 00ab55d8d69..2f91dfc9e39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -49,7 +49,7 @@ export const WorkflowEdge = ({ const currentWorkflow = useCurrentWorkflow() // Generate edge identifier using block names (not IDs) to match diff analysis - // This must exactly match the logic in /api/workflows/diff route + // This must exactly match the logic used by the yaml service diff analysis const generateEdgeIdentity = ( sourceName: string, targetName: string, diff --git a/apps/sim/lib/autolayout/algorithms/hierarchical.ts b/apps/sim/lib/autolayout/algorithms/hierarchical.ts deleted file mode 100644 index 75ecbe9571c..00000000000 --- a/apps/sim/lib/autolayout/algorithms/hierarchical.ts +++ /dev/null @@ -1,452 +0,0 @@ -import type { LayoutEdge, LayoutNode, LayoutOptions, LayoutResult } from '../types' - -interface LayerNode { - node: LayoutNode - layer: number - position: number -} - -/** - * Hierarchical layout algorithm optimized for workflow visualization - * Creates clear layers based on workflow flow with minimal edge crossings - */ -export function calculateHierarchicalLayout( - nodes: LayoutNode[], - edges: LayoutEdge[], - options: LayoutOptions -): LayoutResult { - const TIMEOUT_MS = 3000 // 3 second timeout for hierarchical layout - const startTime = Date.now() - - const checkTimeout = () => { - if (Date.now() - startTime > TIMEOUT_MS) { - throw new Error('Hierarchical layout timeout') - } - } - - const { direction, spacing, alignment, padding } = options - - try { - // Step 1: Determine layout direction - checkTimeout() - const isHorizontal = - direction === 'horizontal' || - (direction === 'auto' && shouldUseHorizontalLayout(nodes, edges)) - - // Step 2: Build adjacency lists - checkTimeout() - const { incomingEdges, outgoingEdges } = buildAdjacencyLists(edges) - - // Step 3: Assign nodes to layers using longest path layering - checkTimeout() - const layeredNodes = assignLayers(nodes, incomingEdges, outgoingEdges) - - // Step 4: Order nodes within layers to minimize crossings - checkTimeout() - const orderedLayers = minimizeCrossings(layeredNodes, edges, incomingEdges, outgoingEdges) - - // Step 5: Calculate positions - checkTimeout() - const positionedNodes = calculatePositions( - orderedLayers, - nodes, - isHorizontal, - spacing, - alignment, - padding - ) - - // Step 6: Calculate metadata - const metadata = calculateLayoutMetadata(positionedNodes, edges, orderedLayers.length) - - return { - nodes: positionedNodes, - metadata: { - ...metadata, - strategy: 'hierarchical', - }, - } - } catch (error) { - if (error instanceof Error && error.message.includes('timeout')) { - // Fallback to simple linear layout - return createSimpleLinearLayout(nodes, options) - } - throw error - } -} - -/** - * Simple fallback layout when hierarchical layout times out - */ -function createSimpleLinearLayout(nodes: LayoutNode[], options: LayoutOptions): LayoutResult { - const positions: Array<{ id: string; position: { x: number; y: number } }> = [] - const spacing = options.spacing.horizontal || 400 - const startX = options.padding?.x || 200 - const startY = options.padding?.y || 200 - - nodes.forEach((node, index) => { - positions.push({ - id: node.id, - position: { - x: startX + index * spacing, - y: startY, - }, - }) - }) - - return { - nodes: positions, - metadata: { - strategy: 'simple-linear', - totalWidth: nodes.length * spacing, - totalHeight: 200, - layerCount: 1, - stats: { - crossings: 0, - totalEdgeLength: 0, - nodeOverlaps: 0, - }, - }, - } -} - -function shouldUseHorizontalLayout(nodes: LayoutNode[], edges: LayoutEdge[]): boolean { - // Analyze edge directions and node handle preferences - let horizontalPreference = 0 - - nodes.forEach((node) => { - if (node.horizontalHandles) horizontalPreference++ - else horizontalPreference-- - }) - - // Analyze workflow complexity - simpler workflows work better horizontally - const complexity = edges.length / Math.max(nodes.length, 1) - if (complexity < 1.5) horizontalPreference += 2 - - return horizontalPreference > 0 -} - -function buildAdjacencyLists(edges: LayoutEdge[]) { - const incomingEdges = new Map() - const outgoingEdges = new Map() - - edges.forEach((edge) => { - if (!outgoingEdges.has(edge.source)) { - outgoingEdges.set(edge.source, []) - } - if (!incomingEdges.has(edge.target)) { - incomingEdges.set(edge.target, []) - } - - outgoingEdges.get(edge.source)!.push(edge.target) - incomingEdges.get(edge.target)!.push(edge.source) - }) - - return { incomingEdges, outgoingEdges } -} - -function assignLayers( - nodes: LayoutNode[], - incomingEdges: Map, - outgoingEdges: Map -): Map { - const nodeToLayer = new Map() - const layers = new Map() - - // Find root nodes (no incoming edges) - const rootNodes = nodes.filter( - (node) => !incomingEdges.has(node.id) || incomingEdges.get(node.id)!.length === 0 - ) - - // If no root nodes, pick nodes with highest category priority - if (rootNodes.length === 0) { - const triggerNodes = nodes.filter((node) => node.category === 'trigger') - rootNodes.push(...(triggerNodes.length > 0 ? triggerNodes : [nodes[0]])) - } - - // Assign layer 0 to root nodes - rootNodes.forEach((node) => { - nodeToLayer.set(node.id, 0) - }) - - // Use longest path layering algorithm with iteration limit - let maxLayer = 0 - let changed = true - let iterations = 0 - const maxIterations = nodes.length * 2 // Prevent infinite loops - - while (changed && iterations < maxIterations) { - changed = false - iterations++ - - nodes.forEach((node) => { - const predecessors = incomingEdges.get(node.id) || [] - - if (predecessors.length > 0) { - const maxPredecessorLayer = Math.max( - ...predecessors.map((predId) => nodeToLayer.get(predId) || 0) - ) - const currentLayer = nodeToLayer.get(node.id) || 0 - const newLayer = maxPredecessorLayer + 1 - - if (newLayer > currentLayer) { - nodeToLayer.set(node.id, newLayer) - maxLayer = Math.max(maxLayer, newLayer) - changed = true - } - } - }) - } - - if (iterations >= maxIterations) { - console.warn('Layer assignment reached maximum iterations, may have cycles in graph') - } - - // Group nodes by layer - nodes.forEach((node) => { - const layer = nodeToLayer.get(node.id) || 0 - if (!layers.has(layer)) { - layers.set(layer, []) - } - layers.get(layer)!.push(node) - }) - - return layers -} - -function minimizeCrossings( - layeredNodes: Map, - edges: LayoutEdge[], - incomingEdges: Map, - outgoingEdges: Map -): LayoutNode[][] { - const layers: LayoutNode[][] = [] - const maxLayer = Math.max(...layeredNodes.keys()) - - // Initialize layers - for (let i = 0; i <= maxLayer; i++) { - layers[i] = layeredNodes.get(i) || [] - } - - // Apply barycenter heuristic for crossing minimization with limited iterations - const maxIterations = Math.min(4, Math.max(1, Math.ceil(maxLayer / 2))) - for (let iteration = 0; iteration < maxIterations; iteration++) { - if (iteration % 2 === 0) { - // Forward pass - for (let layer = 1; layer <= maxLayer; layer++) { - sortLayerByBarycenter(layers[layer], layers[layer - 1], incomingEdges, true) - } - } else { - // Backward pass - for (let layer = maxLayer - 1; layer >= 0; layer--) { - sortLayerByBarycenter(layers[layer], layers[layer + 1], outgoingEdges, false) - } - } - } - - return layers -} - -function sortLayerByBarycenter( - currentLayer: LayoutNode[], - adjacentLayer: LayoutNode[], - edgeMap: Map, - useIncoming: boolean -) { - const barycenters: Array<{ node: LayoutNode; barycenter: number }> = [] - - currentLayer.forEach((node) => { - const connectedNodes = edgeMap.get(node.id) || [] - let barycenter = 0 - - if (connectedNodes.length > 0) { - const positions = connectedNodes - .map((connectedId) => adjacentLayer.findIndex((n) => n.id === connectedId)) - .filter((pos) => pos !== -1) - - if (positions.length > 0) { - barycenter = positions.reduce((sum, pos) => sum + pos, 0) / positions.length - } - } - - barycenters.push({ node, barycenter }) - }) - - // Sort by barycenter, then by category priority, then by name for stability - barycenters.sort((a, b) => { - if (Math.abs(a.barycenter - b.barycenter) < 0.1) { - const priorityA = getCategoryPriority(a.node.category) - const priorityB = getCategoryPriority(b.node.category) - if (priorityA !== priorityB) return priorityA - priorityB - return a.node.name.localeCompare(b.node.name) - } - return a.barycenter - b.barycenter - }) - - // Update layer order - currentLayer.splice(0, currentLayer.length, ...barycenters.map((item) => item.node)) -} - -function getCategoryPriority(category: LayoutNode['category']): number { - const priorities = { - trigger: 0, - processing: 1, - logic: 2, - container: 3, - output: 4, - } - return priorities[category] || 10 -} - -function calculatePositions( - layers: LayoutNode[][], - allNodes: LayoutNode[], - isHorizontal: boolean, - spacing: LayoutOptions['spacing'], - alignment: LayoutOptions['alignment'], - padding: LayoutOptions['padding'] -): Array<{ id: string; position: { x: number; y: number } }> { - const positions: Array<{ id: string; position: { x: number; y: number } }> = [] - - if (isHorizontal) { - // Horizontal layout (left-to-right) - let currentX = padding.x - - layers.forEach((layer, layerIndex) => { - // Calculate layer width (max node width in this layer) - const layerWidth = Math.max(...layer.map((node) => node.width), 0) - - // Improved vertical spacing calculation to prevent overlaps - // Use a minimum spacing that accounts for block heights plus extra buffer - const minVerticalSpacing = Math.max(spacing.vertical, 100) - const adaptiveSpacing = - layer.length > 1 ? Math.max(minVerticalSpacing, spacing.vertical * 1.2) : minVerticalSpacing - - // Calculate total layer height with improved spacing - const totalHeight = - layer.reduce((sum, node) => sum + node.height, 0) + (layer.length - 1) * adaptiveSpacing - - // Starting Y position based on alignment - let startY: number - switch (alignment) { - case 'start': - startY = padding.y - break - case 'end': - startY = -totalHeight + padding.y - break - default: - startY = -totalHeight / 2 + padding.y - break - } - - let currentY = startY - - layer.forEach((node, nodeIndex) => { - positions.push({ - id: node.id, - position: { x: currentX, y: currentY }, - }) - - // Use adaptive spacing that considers the current node's height - if (nodeIndex < layer.length - 1) { - const nextNode = layer[nodeIndex + 1] - const dynamicSpacing = Math.max(adaptiveSpacing, (node.height + nextNode.height) / 2 + 30) - currentY += node.height + dynamicSpacing - } - }) - - currentX += layerWidth + spacing.layer - }) - } else { - // Vertical layout (top-to-bottom) - let currentY = padding.y - - layers.forEach((layer, layerIndex) => { - // Calculate layer height (max node height in this layer) - const layerHeight = Math.max(...layer.map((node) => node.height), 0) - - // Improved horizontal spacing calculation - const minHorizontalSpacing = Math.max(spacing.horizontal, 80) - const adaptiveSpacing = - layer.length > 1 - ? Math.max(minHorizontalSpacing, spacing.horizontal * 1.2) - : minHorizontalSpacing - - // Calculate total layer width with improved spacing - const totalWidth = - layer.reduce((sum, node) => sum + node.width, 0) + (layer.length - 1) * adaptiveSpacing - - // Starting X position based on alignment - let startX: number - switch (alignment) { - case 'start': - startX = padding.x - break - case 'end': - startX = -totalWidth + padding.x - break - default: - startX = -totalWidth / 2 + padding.x - break - } - - let currentX = startX - - layer.forEach((node, nodeIndex) => { - positions.push({ - id: node.id, - position: { x: currentX, y: currentY }, - }) - - // Use adaptive spacing that considers the current node's width - if (nodeIndex < layer.length - 1) { - const nextNode = layer[nodeIndex + 1] - const dynamicSpacing = Math.max(adaptiveSpacing, (node.width + nextNode.width) / 2 + 25) - currentX += node.width + dynamicSpacing - } - }) - - currentY += layerHeight + spacing.layer - }) - } - - return positions -} - -function calculateLayoutMetadata( - positions: Array<{ id: string; position: { x: number; y: number } }>, - edges: LayoutEdge[], - layerCount: number -) { - const nodeMap = new Map(positions.map((p) => [p.id, p.position])) - - // Calculate bounding box - const xs = positions.map((p) => p.position.x) - const ys = positions.map((p) => p.position.y) - const totalWidth = Math.max(...xs) - Math.min(...xs) - const totalHeight = Math.max(...ys) - Math.min(...ys) - - // Calculate total edge length - let totalEdgeLength = 0 - edges.forEach((edge) => { - const sourcePos = nodeMap.get(edge.source) - const targetPos = nodeMap.get(edge.target) - if (sourcePos && targetPos) { - const dx = targetPos.x - sourcePos.x - const dy = targetPos.y - sourcePos.y - totalEdgeLength += Math.sqrt(dx * dx + dy * dy) - } - }) - - return { - totalWidth, - totalHeight, - layerCount, - stats: { - crossings: 0, // TODO: Implement crossing calculation - totalEdgeLength, - nodeOverlaps: 0, // No overlaps in hierarchical layout - }, - } -} diff --git a/apps/sim/lib/autolayout/algorithms/smart.ts b/apps/sim/lib/autolayout/algorithms/smart.ts deleted file mode 100644 index 5a48c474a14..00000000000 --- a/apps/sim/lib/autolayout/algorithms/smart.ts +++ /dev/null @@ -1,608 +0,0 @@ -import type { LayoutEdge, LayoutNode, LayoutOptions, LayoutResult } from '../types' -import { calculateHierarchicalLayout } from './hierarchical' - -interface WorkflowAnalysis { - nodeCount: number - edgeCount: number - maxDepth: number - branchingFactor: number - hasParallelPaths: boolean - hasLoops: boolean - complexity: 'simple' | 'medium' | 'complex' - recommendedStrategy: 'hierarchical' | 'layered' | 'force-directed' - recommendedDirection: 'horizontal' | 'vertical' -} - -/** - * Smart layout algorithm that analyzes the workflow and chooses the optimal layout strategy - */ -export function calculateSmartLayout( - nodes: LayoutNode[], - edges: LayoutEdge[], - options: LayoutOptions -): LayoutResult { - const TIMEOUT_MS = 5000 // 5 second timeout - const startTime = Date.now() - - const checkTimeout = () => { - if (Date.now() - startTime > TIMEOUT_MS) { - throw new Error('Layout calculation timeout - falling back to simple positioning') - } - } - - try { - // Step 1: Analyze the workflow structure - checkTimeout() - const analysis = analyzeWorkflow(nodes, edges) - - // Step 2: Choose optimal strategy and direction - checkTimeout() - const optimizedOptions: LayoutOptions = { - ...options, - strategy: analysis.recommendedStrategy, - direction: options.direction === 'auto' ? analysis.recommendedDirection : options.direction, - spacing: optimizeSpacing(analysis, options.spacing), - } - - // Step 3: Apply the chosen strategy - checkTimeout() - let result: LayoutResult - - switch (analysis.recommendedStrategy) { - case 'hierarchical': - result = calculateHierarchicalLayout(nodes, edges, optimizedOptions) - break - case 'layered': - result = calculateLayeredLayout(nodes, edges, optimizedOptions) - break - case 'force-directed': - result = calculateForceDirectedLayout(nodes, edges, optimizedOptions) - break - default: - result = calculateHierarchicalLayout(nodes, edges, optimizedOptions) - } - - // Step 4: Apply post-processing optimizations - checkTimeout() - result = applyPostProcessingOptimizations(result, nodes, edges, analysis) - - // Step 5: Update metadata - result.metadata.strategy = 'smart' - - return result - } catch (error) { - if (error instanceof Error && error.message.includes('timeout')) { - // Fallback to simple grid layout on timeout - return createFallbackLayout(nodes, edges, options) - } - throw error - } -} - -/** - * Fallback layout for when smart layout times out or fails - */ -function createFallbackLayout( - nodes: LayoutNode[], - edges: LayoutEdge[], - options: LayoutOptions -): LayoutResult { - const positions: Array<{ id: string; position: { x: number; y: number } }> = [] - - // Simple grid layout - const cols = Math.ceil(Math.sqrt(nodes.length)) - const spacing = 400 - - nodes.forEach((node, index) => { - const row = Math.floor(index / cols) - const col = index % cols - - positions.push({ - id: node.id, - position: { - x: col * spacing + (options.padding?.x || 200), - y: row * spacing + (options.padding?.y || 200), - }, - }) - }) - - const maxX = Math.max(...positions.map((p) => p.position.x)) - const maxY = Math.max(...positions.map((p) => p.position.y)) - - return { - nodes: positions, - metadata: { - strategy: 'fallback-grid', - totalWidth: maxX + 400, - totalHeight: maxY + 200, - layerCount: Math.ceil(nodes.length / cols), - stats: { - crossings: 0, - totalEdgeLength: 0, - nodeOverlaps: 0, - }, - }, - } -} - -function analyzeWorkflow(nodes: LayoutNode[], edges: LayoutEdge[]): WorkflowAnalysis { - const nodeCount = nodes.length - const edgeCount = edges.length - - // Build adjacency lists for analysis - const outgoing = new Map() - const incoming = new Map() - - edges.forEach((edge) => { - if (!outgoing.has(edge.source)) outgoing.set(edge.source, []) - if (!incoming.has(edge.target)) incoming.set(edge.target, []) - outgoing.get(edge.source)!.push(edge.target) - incoming.get(edge.target)!.push(edge.source) - }) - - // Calculate max depth using BFS - const maxDepth = calculateMaxDepth(nodes, edges, outgoing) - - // Calculate average branching factor - const branchingFactors = Array.from(outgoing.values()).map((targets) => targets.length) - const avgBranchingFactor = - branchingFactors.length > 0 - ? branchingFactors.reduce((a, b) => a + b, 0) / branchingFactors.length - : 0 - - // Detect parallel paths - const hasParallelPaths = detectParallelPaths(nodes, edges, outgoing) - - // Detect loops/cycles (simplified check) - const hasLoops = nodes.some((node) => node.type === 'loop' || node.isContainer) - - // Determine complexity - let complexity: WorkflowAnalysis['complexity'] - if (nodeCount <= 5 && edgeCount <= 6 && maxDepth <= 3) { - complexity = 'simple' - } else if (nodeCount <= 15 && edgeCount <= 20 && maxDepth <= 6) { - complexity = 'medium' - } else { - complexity = 'complex' - } - - // Choose optimal strategy based on analysis - let recommendedStrategy: WorkflowAnalysis['recommendedStrategy'] - let recommendedDirection: WorkflowAnalysis['recommendedDirection'] - - if (complexity === 'simple' && !hasParallelPaths) { - recommendedStrategy = 'hierarchical' - recommendedDirection = avgBranchingFactor < 1.5 ? 'horizontal' : 'vertical' - } else if (hasParallelPaths || avgBranchingFactor > 2) { - recommendedStrategy = 'layered' - recommendedDirection = 'vertical' - } else if (complexity === 'complex' && !hasLoops) { - recommendedStrategy = 'force-directed' - recommendedDirection = 'horizontal' - } else { - recommendedStrategy = 'hierarchical' - recommendedDirection = maxDepth > 4 ? 'vertical' : 'horizontal' - } - - // Consider user preferences from nodes - const horizontalPreference = nodes.filter((n) => n.horizontalHandles).length - const verticalPreference = nodes.length - horizontalPreference - - if (horizontalPreference > verticalPreference * 1.5) { - recommendedDirection = 'horizontal' - } else if (verticalPreference > horizontalPreference * 1.5) { - recommendedDirection = 'vertical' - } - - return { - nodeCount, - edgeCount, - maxDepth, - branchingFactor: avgBranchingFactor, - hasParallelPaths, - hasLoops, - complexity, - recommendedStrategy, - recommendedDirection, - } -} - -function calculateMaxDepth( - nodes: LayoutNode[], - edges: LayoutEdge[], - outgoing: Map -): number { - const visited = new Set() - const visiting = new Set() // Track nodes currently being visited to detect cycles - let maxDepth = 0 - - // Find root nodes - const roots = nodes.filter( - (node) => !edges.some((edge) => edge.target === node.id) || node.category === 'trigger' - ) - - if (roots.length === 0 && nodes.length > 0) { - roots.push(nodes[0]) - } - - // DFS to find maximum depth with cycle detection - function dfs(nodeId: string, depth: number): number { - if (visiting.has(nodeId)) { - // Cycle detected, return current depth to avoid infinite loop - return depth - } - if (visited.has(nodeId)) return depth - - visiting.add(nodeId) - visited.add(nodeId) - - let localMaxDepth = depth - const children = outgoing.get(nodeId) || [] - - for (const childId of children) { - const childDepth = dfs(childId, depth + 1) - localMaxDepth = Math.max(localMaxDepth, childDepth) - } - - visiting.delete(nodeId) - return localMaxDepth - } - - for (const root of roots) { - const rootDepth = dfs(root.id, 0) - maxDepth = Math.max(maxDepth, rootDepth) - } - - return maxDepth -} - -function detectParallelPaths( - nodes: LayoutNode[], - edges: LayoutEdge[], - outgoing: Map -): boolean { - // Quick check - look for nodes that have multiple outgoing edges - const nodesWithMultipleOutputs = Array.from(outgoing.entries()).filter( - ([_, targets]) => targets.length > 1 - ) - - // If there are too many to check efficiently, just return true (assume parallel paths exist) - if (nodesWithMultipleOutputs.length > 10) { - return true - } - - for (const [nodeId, targets] of nodesWithMultipleOutputs) { - // Quick heuristic - if targets have different types, they're likely parallel paths - const targetNodes = targets - .map((targetId) => nodes.find((n) => n.id === targetId)) - .filter((n): n is LayoutNode => n !== undefined) - const uniqueCategories = new Set(targetNodes.map((n) => n.category)) - - if (uniqueCategories.size > 1) { - return true // Different categories suggest parallel processing paths - } - - // Only do expensive convergence check for simple cases - if (targets.length <= 3) { - if (hasConvergingPaths(targets, outgoing, new Set())) { - return true - } - } - } - return false -} - -function hasConvergingPaths( - startNodes: string[], - outgoing: Map, - visited: Set -): boolean { - // Early exit for simple cases - if (startNodes.length <= 1) return false - - const MAX_DEPTH = 10 // Limit traversal depth to prevent infinite loops - const paths = new Map>() - - // Trace each path with depth limit - startNodes.forEach((startNode, index) => { - const pathNodes = new Set() - const queue: Array<{ node: string; depth: number }> = [{ node: startNode, depth: 0 }] - const pathVisited = new Set() - - while (queue.length > 0) { - const { node: current, depth } = queue.shift()! - - if (pathVisited.has(current) || depth > MAX_DEPTH) continue - pathVisited.add(current) - pathNodes.add(current) - - const children = outgoing.get(current) || [] - children.forEach((childId) => { - if (!pathVisited.has(childId)) { - queue.push({ node: childId, depth: depth + 1 }) - } - }) - } - - paths.set(`path-${index}`, pathNodes) - }) - - // Optimized convergence check - early exit on first intersection - const pathSets = Array.from(paths.values()) - for (let i = 0; i < pathSets.length; i++) { - for (let j = i + 1; j < pathSets.length; j++) { - // Quick check - any common node? - for (const node of pathSets[i]) { - if (pathSets[j].has(node)) { - return true - } - } - } - } - - return false -} - -function optimizeSpacing( - analysis: WorkflowAnalysis, - baseSpacing: LayoutOptions['spacing'] -): LayoutOptions['spacing'] { - const { complexity, nodeCount, branchingFactor } = analysis - - let multiplier = 1 - - // Adjust spacing based on complexity - switch (complexity) { - case 'simple': - multiplier = 0.8 - break - case 'medium': - multiplier = 1.0 - break - case 'complex': - multiplier = 1.2 - break - } - - // Adjust for node count - if (nodeCount > 20) multiplier *= 1.1 - if (nodeCount > 50) multiplier *= 1.2 - - // Adjust for branching factor - if (branchingFactor > 3) multiplier *= 1.15 - - return { - horizontal: Math.round(baseSpacing.horizontal * multiplier), - vertical: Math.round(baseSpacing.vertical * multiplier), - layer: Math.round(baseSpacing.layer * multiplier), - } -} - -// Improved layered layout for medium complexity workflows with parallel branches -function calculateLayeredLayout( - nodes: LayoutNode[], - edges: LayoutEdge[], - options: LayoutOptions -): LayoutResult { - // Analyze the workflow to detect parallel paths - const outgoing = new Map() - edges.forEach((edge) => { - if (!outgoing.has(edge.source)) outgoing.set(edge.source, []) - outgoing.get(edge.source)!.push(edge.target) - }) - - // Count nodes with multiple outputs (branching points) - const branchingNodes = Array.from(outgoing.entries()).filter(([_, targets]) => targets.length > 1) - const hasSignificantBranching = branchingNodes.length > 0 - - // Adjust spacing based on workflow characteristics - const adjustedOptions: LayoutOptions = { - ...options, - spacing: { - horizontal: hasSignificantBranching - ? options.spacing.horizontal * 1.2 - : options.spacing.horizontal, - vertical: hasSignificantBranching - ? Math.max(options.spacing.vertical * 1.8, 350) - : options.spacing.vertical * 1.2, - layer: options.spacing.layer * 1.1, - }, - } - - // Use the improved hierarchical layout with better spacing - const result = calculateHierarchicalLayout(nodes, edges, adjustedOptions) - - // Update metadata to reflect this is a layered layout - result.metadata.strategy = 'layered' - - return result -} - -// Simplified force-directed layout for complex workflows -function calculateForceDirectedLayout( - nodes: LayoutNode[], - edges: LayoutEdge[], - options: LayoutOptions -): LayoutResult { - // For now, use a simplified force-directed approach - const positions: Array<{ id: string; position: { x: number; y: number } }> = [] - - // Start with hierarchical layout as base - const hierarchicalResult = calculateHierarchicalLayout(nodes, edges, options) - - // Apply some force-directed adjustments - const nodePositions = new Map(hierarchicalResult.nodes.map((n) => [n.id, n.position])) - - // Simple force simulation (simplified) - const iterations = 10 - const edgeLength = options.spacing.layer - - for (let iter = 0; iter < iterations; iter++) { - const forces = new Map() - - // Initialize forces - nodes.forEach((node) => { - forces.set(node.id, { x: 0, y: 0 }) - }) - - // Attractive forces along edges - edges.forEach((edge) => { - const sourcePos = nodePositions.get(edge.source) - const targetPos = nodePositions.get(edge.target) - - if (sourcePos && targetPos) { - const dx = targetPos.x - sourcePos.x - const dy = targetPos.y - sourcePos.y - const distance = Math.sqrt(dx * dx + dy * dy) - - if (distance > 0) { - const force = (distance - edgeLength) * 0.1 - const fx = (dx / distance) * force - const fy = (dy / distance) * force - - const sourceForce = forces.get(edge.source)! - const targetForce = forces.get(edge.target)! - - sourceForce.x += fx - sourceForce.y += fy - targetForce.x -= fx - targetForce.y -= fy - } - } - }) - - // Apply forces with damping - nodes.forEach((node) => { - const pos = nodePositions.get(node.id)! - const force = forces.get(node.id)! - - pos.x += force.x * 0.5 - pos.y += force.y * 0.5 - }) - } - - // Convert back to result format - nodePositions.forEach((position, id) => { - positions.push({ id, position }) - }) - - return { - nodes: positions, - metadata: { - ...hierarchicalResult.metadata, - strategy: 'force-directed', - }, - } -} - -function applyPostProcessingOptimizations( - result: LayoutResult, - nodes: LayoutNode[], - edges: LayoutEdge[], - analysis: WorkflowAnalysis -): LayoutResult { - // Apply alignment improvements - result = improveAlignment(result, nodes, analysis) - - // Apply spacing optimizations - result = optimizeNodeSpacing(result, nodes, edges) - - // Apply aesthetic improvements - result = improveAesthetics(result, nodes, edges) - - return result -} - -function improveAlignment( - result: LayoutResult, - nodes: LayoutNode[], - analysis: WorkflowAnalysis -): LayoutResult { - // For simple workflows, ensure better alignment of key nodes - if (analysis.complexity === 'simple') { - const nodeMap = new Map(nodes.map((n) => [n.id, n])) - const positions = new Map(result.nodes.map((n) => [n.id, n.position])) - - // Align trigger nodes - const triggerNodes = result.nodes.filter((n) => { - const node = nodeMap.get(n.id) - return node?.category === 'trigger' - }) - - if (triggerNodes.length > 1) { - const avgY = triggerNodes.reduce((sum, n) => sum + n.position.y, 0) / triggerNodes.length - triggerNodes.forEach((n) => { - n.position.y = avgY - }) - } - } - - return result -} - -function optimizeNodeSpacing( - result: LayoutResult, - nodes: LayoutNode[], - edges: LayoutEdge[] -): LayoutResult { - // Ensure minimum spacing between nodes - const minSpacing = 50 - const nodeMap = new Map(nodes.map((n) => [n.id, n])) - - result.nodes.forEach((nodeA, i) => { - result.nodes.forEach((nodeB, j) => { - if (i >= j) return - - const nodeAData = nodeMap.get(nodeA.id) - const nodeBData = nodeMap.get(nodeB.id) - - if (!nodeAData || !nodeBData) return - - const dx = nodeB.position.x - nodeA.position.x - const dy = nodeB.position.y - nodeA.position.y - const distance = Math.sqrt(dx * dx + dy * dy) - - const requiredDistance = (nodeAData.width + nodeBData.width) / 2 + minSpacing - - if (distance > 0 && distance < requiredDistance) { - const adjustmentFactor = (requiredDistance - distance) / distance / 2 - const adjustX = dx * adjustmentFactor - const adjustY = dy * adjustmentFactor - - nodeA.position.x -= adjustX - nodeA.position.y -= adjustY - nodeB.position.x += adjustX - nodeB.position.y += adjustY - } - }) - }) - - return result -} - -function improveAesthetics( - result: LayoutResult, - nodes: LayoutNode[], - edges: LayoutEdge[] -): LayoutResult { - // Center the layout around origin - const positions = result.nodes.map((n) => n.position) - const minX = Math.min(...positions.map((p) => p.x)) - const minY = Math.min(...positions.map((p) => p.y)) - const maxX = Math.max(...positions.map((p) => p.x)) - const maxY = Math.max(...positions.map((p) => p.y)) - - const centerX = (minX + maxX) / 2 - const centerY = (minY + maxY) / 2 - - result.nodes.forEach((node) => { - node.position.x -= centerX - node.position.y -= centerY - }) - - // Update metadata - result.metadata.totalWidth = maxX - minX - result.metadata.totalHeight = maxY - minY - - return result -} diff --git a/apps/sim/lib/autolayout/service.ts b/apps/sim/lib/autolayout/service.ts deleted file mode 100644 index 45889fe912f..00000000000 --- a/apps/sim/lib/autolayout/service.ts +++ /dev/null @@ -1,592 +0,0 @@ -import { createLogger } from '@/lib/logs/console-logger' -import { calculateHierarchicalLayout } from './algorithms/hierarchical' -import { calculateSmartLayout } from './algorithms/smart' -import type { LayoutEdge, LayoutNode, LayoutOptions, LayoutResult, WorkflowGraph } from './types' -import { BLOCK_CATEGORIES, BLOCK_DIMENSIONS } from './types' - -const logger = createLogger('AutoLayoutService') - -/** - * Main autolayout service for workflow blocks - */ -export class AutoLayoutService { - private static instance: AutoLayoutService - - static getInstance(): AutoLayoutService { - if (!AutoLayoutService.instance) { - AutoLayoutService.instance = new AutoLayoutService() - } - return AutoLayoutService.instance - } - - /** - * Calculate optimal layout for workflow blocks, including nested blocks - */ - async calculateLayout( - workflowGraph: WorkflowGraph, - options: Partial = {} - ): Promise { - const startTime = Date.now() - - try { - // Merge with default options - const layoutOptions: LayoutOptions = { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 500, // Increased from 400 for better separation - vertical: 180, // Reduced from 400 to prevent excessive vertical spacing - layer: 700, // Increased from 600 for better layer separation - }, - alignment: 'center', - padding: { - x: 250, // Increased from 200 for better margins - y: 250, // Increased from 200 for better margins - }, - ...options, - } - - logger.info('Calculating layout with nested block support', { - nodeCount: workflowGraph.nodes.length, - edgeCount: workflowGraph.edges.length, - strategy: layoutOptions.strategy, - direction: layoutOptions.direction, - }) - - // Validate input - this.validateWorkflowGraph(workflowGraph) - - // Calculate layout based on strategy - let result: LayoutResult - - switch (layoutOptions.strategy) { - case 'hierarchical': - result = calculateHierarchicalLayout( - workflowGraph.nodes, - workflowGraph.edges, - layoutOptions - ) - break - case 'smart': - result = calculateSmartLayout(workflowGraph.nodes, workflowGraph.edges, layoutOptions) - break - default: - logger.warn(`Unknown layout strategy: ${layoutOptions.strategy}, falling back to smart`) - result = calculateSmartLayout(workflowGraph.nodes, workflowGraph.edges, layoutOptions) - } - - const elapsed = Date.now() - startTime - logger.info('Layout calculation completed', { - strategy: result.metadata.strategy, - nodeCount: result.nodes.length, - totalWidth: result.metadata.totalWidth, - totalHeight: result.metadata.totalHeight, - layerCount: result.metadata.layerCount, - elapsed: `${elapsed}ms`, - }) - - return result - } catch (error) { - const elapsed = Date.now() - startTime - logger.error('Layout calculation failed', { - error: error instanceof Error ? error.message : 'Unknown error', - elapsed: `${elapsed}ms`, - nodeCount: workflowGraph.nodes.length, - edgeCount: workflowGraph.edges.length, - }) - throw error - } - } - - /** - * Convert workflow store blocks and edges to layout format with nested block support - */ - convertWorkflowToGraph(blocks: Record, edges: any[]): WorkflowGraph { - try { - // Convert all blocks to layout nodes - const allNodes: LayoutNode[] = Object.values(blocks).map((block) => { - const category = BLOCK_CATEGORIES[block.type] || 'processing' - const isContainer = block.type === 'loop' || block.type === 'parallel' - - // Determine dimensions with better height detection - let dimensions = BLOCK_DIMENSIONS.default - if (isContainer) { - dimensions = BLOCK_DIMENSIONS.container - } else if (block.isWide) { - dimensions = BLOCK_DIMENSIONS.wide - } - - // Use actual block dimensions with proper fallbacks - let actualWidth = dimensions.width - let actualHeight = dimensions.height - - // Check for actual width from block data - if (block.data?.width && block.data.width > 0) { - actualWidth = block.data.width - } else if (block.width && block.width > 0) { - actualWidth = block.width - } - - // Check for actual height from block data with more comprehensive detection - if (block.data?.height && block.data.height > 0) { - actualHeight = Math.max(block.data.height, dimensions.height) - } else if (block.height && block.height > 0) { - actualHeight = Math.max(block.height, dimensions.height) - } else { - // For blocks without explicit height, estimate based on content - const hasLongContent = block.subBlocks && Object.keys(block.subBlocks).length > 3 - const isComplexBlock = ['agent', 'api', 'function'].includes(block.type) - - if (hasLongContent || isComplexBlock) { - actualHeight = Math.max(actualHeight * 1.8, 200) // Increase estimated height for content-heavy blocks - } else if (Object.keys(block.subBlocks || {}).length > 0) { - actualHeight = Math.max(actualHeight * 1.3, 150) // Moderate increase for blocks with some content - } - } - - return { - id: block.id, - type: block.type, - name: block.name || `${block.type} Block`, - width: actualWidth, - height: actualHeight, - position: block.position, - category, - isContainer, - parentId: block.data?.parentId || block.parentId, - horizontalHandles: block.horizontalHandles ?? true, - isWide: block.isWide ?? false, - } - }) - - // Convert edges to layout format - const layoutEdges: LayoutEdge[] = edges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type, - })) - - // For the main graph, only include top-level nodes - const topLevelNodes = allNodes.filter((node) => !node.parentId) - const topLevelEdges = layoutEdges.filter((edge) => { - const sourceIsTopLevel = topLevelNodes.some((n) => n.id === edge.source) - const targetIsTopLevel = topLevelNodes.some((n) => n.id === edge.target) - return sourceIsTopLevel && targetIsTopLevel - }) - - logger.info('Converted workflow to graph with nested support', { - totalNodes: allNodes.length, - topLevelNodes: topLevelNodes.length, - edges: layoutEdges.length, - topLevelEdges: topLevelEdges.length, - categories: this.countByCategory(topLevelNodes), - }) - - return { - nodes: topLevelNodes, - edges: topLevelEdges, - } - } catch (error) { - logger.error('Failed to convert workflow to graph', { - error: error instanceof Error ? error.message : 'Unknown error', - blockCount: Object.keys(blocks).length, - edgeCount: edges.length, - }) - throw error - } - } - - /** - * Convert layout result back to workflow store format with nested block support - */ - convertResultToWorkflow( - result: LayoutResult, - originalBlocks: Record, - allBlocks: Record, - allEdges: any[] - ): Record { - const updatedBlocks = { ...originalBlocks } - - // Apply top-level layout results - result.nodes.forEach(({ id, position }) => { - if (updatedBlocks[id]) { - updatedBlocks[id] = { - ...updatedBlocks[id], - position: { - x: Math.round(position.x), - y: Math.round(position.y), - }, - } - } - }) - - // Handle nested blocks inside containers - const containerBlocks = result.nodes.filter( - (node) => - updatedBlocks[node.id]?.type === 'loop' || updatedBlocks[node.id]?.type === 'parallel' - ) - - containerBlocks.forEach((containerNode) => { - const containerId = containerNode.id - - // Get child blocks for this container - const childBlocks = Object.fromEntries( - Object.entries(allBlocks).filter( - ([_, block]) => block.data?.parentId === containerId || block.parentId === containerId - ) - ) - - if (Object.keys(childBlocks).length === 0) return - - // Get edges between child blocks - const childEdges = allEdges.filter( - (edge) => childBlocks[edge.source] && childBlocks[edge.target] - ) - - // Layout child blocks with container-specific options - const childGraph = this.createChildGraph(childBlocks, childEdges) - - if (childGraph.nodes.length > 0) { - try { - const childLayoutOptions: LayoutOptions = { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 400, // Increased from 300 for better child separation - vertical: 120, // Reduced from 250 to prevent excessive vertical spacing in containers - layer: 500, // Increased from 400 for better child layer separation - }, - alignment: 'center', - padding: { - x: 80, // Increased from 50 for better child margins - y: 120, // Increased from 80 for better child margins - }, - } - - const childResult = this.calculateChildLayout(childGraph, childLayoutOptions) - - // Apply child positions relative to container - childResult.nodes.forEach(({ id, position }) => { - if (updatedBlocks[id]) { - updatedBlocks[id] = { - ...updatedBlocks[id], - position: { - x: Math.round(position.x), - y: Math.round(position.y), - }, - } - } - }) - - // Update container dimensions to fit children - const containerDimensions = this.calculateContainerDimensions( - childResult.nodes, - childBlocks - ) - - if (updatedBlocks[containerId]) { - updatedBlocks[containerId] = { - ...updatedBlocks[containerId], - data: { - ...updatedBlocks[containerId].data, - width: containerDimensions.width, - height: containerDimensions.height, - }, - } - } - - logger.info('Laid out child blocks for container', { - containerId, - childCount: childResult.nodes.length, - containerWidth: containerDimensions.width, - containerHeight: containerDimensions.height, - }) - } catch (error) { - logger.warn('Failed to layout child blocks for container', { - containerId, - error: error instanceof Error ? error.message : 'Unknown error', - childCount: Object.keys(childBlocks).length, - }) - } - } - }) - - logger.info('Converted layout result to workflow format with nested blocks', { - updatedNodes: result.nodes.length, - totalBlocks: Object.keys(updatedBlocks).length, - containerBlocks: containerBlocks.length, - }) - - return updatedBlocks - } - - /** - * Create a graph for child blocks inside a container - */ - private createChildGraph(childBlocks: Record, childEdges: any[]): WorkflowGraph { - const nodes: LayoutNode[] = Object.values(childBlocks).map((block) => { - const category = BLOCK_CATEGORIES[block.type] || 'processing' - const isContainer = block.type === 'loop' || block.type === 'parallel' - - // Determine dimensions with better height detection - let dimensions = BLOCK_DIMENSIONS.default - if (isContainer) { - dimensions = BLOCK_DIMENSIONS.container - } else if (block.isWide) { - dimensions = BLOCK_DIMENSIONS.wide - } - - // Use actual block dimensions with proper fallbacks - let actualWidth = dimensions.width - let actualHeight = dimensions.height - - // Check for actual width from block data - if (block.data?.width && block.data.width > 0) { - actualWidth = block.data.width - } else if (block.width && block.width > 0) { - actualWidth = block.width - } - - // Check for actual height from block data with more comprehensive detection - if (block.data?.height && block.data.height > 0) { - actualHeight = Math.max(block.data.height, dimensions.height) - } else if (block.height && block.height > 0) { - actualHeight = Math.max(block.height, dimensions.height) - } else { - // For blocks without explicit height, estimate based on content - const hasLongContent = block.subBlocks && Object.keys(block.subBlocks).length > 3 - const isComplexBlock = ['agent', 'api', 'function'].includes(block.type) - - if (hasLongContent || isComplexBlock) { - actualHeight = Math.max(actualHeight * 1.8, 200) // Increase estimated height for content-heavy blocks - } else if (Object.keys(block.subBlocks || {}).length > 0) { - actualHeight = Math.max(actualHeight * 1.3, 150) // Moderate increase for blocks with some content - } - } - - return { - id: block.id, - type: block.type, - name: block.name || `${block.type} Block`, - width: actualWidth, - height: actualHeight, - position: block.position, - category, - isContainer, - parentId: block.data?.parentId || block.parentId, - horizontalHandles: block.horizontalHandles ?? true, - isWide: block.isWide ?? false, - } - }) - - const edges: LayoutEdge[] = childEdges.map((edge) => ({ - id: edge.id, - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type, - })) - - return { nodes, edges } - } - - /** - * Calculate layout for child blocks using simplified algorithms - */ - private calculateChildLayout(childGraph: WorkflowGraph, options: LayoutOptions): LayoutResult { - // Use hierarchical layout for child blocks as it's simpler and more predictable - return calculateHierarchicalLayout(childGraph.nodes, childGraph.edges, options) - } - - /** - * Calculate optimal container dimensions based on child blocks - */ - private calculateContainerDimensions( - childPositions: Array<{ id: string; position: { x: number; y: number } }>, - childBlocks: Record - ): { width: number; height: number } { - const minWidth = 500 - const minHeight = 300 - const padding = 100 - - if (childPositions.length === 0) { - return { width: minWidth, height: minHeight } - } - - let maxX = 0 - let maxY = 0 - - childPositions.forEach(({ id, position }) => { - const block = childBlocks[id] - if (!block) return - - let blockWidth = BLOCK_DIMENSIONS.default.width - let blockHeight = BLOCK_DIMENSIONS.default.height - - if (block.isWide) { - blockWidth = BLOCK_DIMENSIONS.wide.width - } - if (block.height && block.height > 0) { - blockHeight = block.height - } - - maxX = Math.max(maxX, position.x + blockWidth) - maxY = Math.max(maxY, position.y + blockHeight) - }) - - return { - width: Math.max(minWidth, maxX + padding), - height: Math.max(minHeight, maxY + padding), - } - } - - /** - * Get default layout options optimized for different scenarios - */ - getDefaultOptions(scenario: 'simple' | 'complex' | 'presentation' = 'simple'): LayoutOptions { - const baseOptions: LayoutOptions = { - strategy: 'smart', - direction: 'auto', - spacing: { - horizontal: 400, - vertical: 200, - layer: 600, - }, - alignment: 'center', - padding: { - x: 200, - y: 200, - }, - } - - switch (scenario) { - case 'simple': - return { - ...baseOptions, - spacing: { - horizontal: 350, - vertical: 150, - layer: 500, - }, - } - case 'complex': - return { - ...baseOptions, - spacing: { - horizontal: 450, - vertical: 250, - layer: 700, - }, - padding: { - x: 300, - y: 300, - }, - } - case 'presentation': - return { - ...baseOptions, - spacing: { - horizontal: 500, - vertical: 300, - layer: 800, - }, - padding: { - x: 400, - y: 400, - }, - alignment: 'center', - } - default: - return baseOptions - } - } - - private validateWorkflowGraph(graph: WorkflowGraph): void { - if (!graph.nodes || graph.nodes.length === 0) { - throw new Error('Workflow graph must contain at least one node') - } - - if (!graph.edges) { - throw new Error('Workflow graph must have edges array (can be empty)') - } - - // Validate node structure - graph.nodes.forEach((node, index) => { - if (!node.id) { - throw new Error(`Node at index ${index} is missing id`) - } - if (!node.type) { - throw new Error(`Node ${node.id} is missing type`) - } - if (typeof node.width !== 'number' || node.width <= 0) { - throw new Error(`Node ${node.id} has invalid width`) - } - if (typeof node.height !== 'number' || node.height <= 0) { - throw new Error(`Node ${node.id} has invalid height`) - } - }) - - // Validate edge structure - graph.edges.forEach((edge, index) => { - if (!edge.id) { - throw new Error(`Edge at index ${index} is missing id`) - } - if (!edge.source) { - throw new Error(`Edge ${edge.id} is missing source`) - } - if (!edge.target) { - throw new Error(`Edge ${edge.id} is missing target`) - } - - // Check if source and target nodes exist - const sourceExists = graph.nodes.some((n) => n.id === edge.source) - const targetExists = graph.nodes.some((n) => n.id === edge.target) - - if (!sourceExists) { - throw new Error(`Edge ${edge.id} references non-existent source node: ${edge.source}`) - } - if (!targetExists) { - throw new Error(`Edge ${edge.id} references non-existent target node: ${edge.target}`) - } - }) - } - - private countByCategory(nodes: LayoutNode[]): Record { - const counts: Record = {} - nodes.forEach((node) => { - counts[node.category] = (counts[node.category] || 0) + 1 - }) - return counts - } -} - -// Export singleton instance -export const autoLayoutService = AutoLayoutService.getInstance() - -// Export utility functions -export function createWorkflowGraph(blocks: Record, edges: any[]): WorkflowGraph { - return autoLayoutService.convertWorkflowToGraph(blocks, edges) -} - -export function applyLayoutToWorkflow( - result: LayoutResult, - originalBlocks: Record, - allBlocks: Record, - allEdges: any[] -): Record { - return autoLayoutService.convertResultToWorkflow(result, originalBlocks, allBlocks, allEdges) -} - -export async function autoLayoutWorkflow( - blocks: Record, - edges: any[], - options: Partial = {} -): Promise> { - const graph = createWorkflowGraph(blocks, edges) - const result = await autoLayoutService.calculateLayout(graph, options) - return applyLayoutToWorkflow(result, blocks, blocks, edges) -} diff --git a/apps/sim/lib/autolayout/types.ts b/apps/sim/lib/autolayout/types.ts deleted file mode 100644 index a6522c79624..00000000000 --- a/apps/sim/lib/autolayout/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -export interface LayoutNode { - id: string - type: string - name: string - // Physical dimensions - width: number - height: number - // Current position (if any) - position?: { x: number; y: number } - // Metadata for layout decisions - category: 'trigger' | 'processing' | 'logic' | 'output' | 'container' - isContainer: boolean - parentId?: string - // Handle configuration - horizontalHandles?: boolean - isWide?: boolean -} - -export interface LayoutEdge { - id: string - source: string - target: string - sourceHandle?: string - targetHandle?: string - type?: string -} - -export interface LayoutOptions { - strategy: 'hierarchical' | 'force-directed' | 'layered' | 'smart' - direction: 'horizontal' | 'vertical' | 'auto' - spacing: { - horizontal: number - vertical: number - layer: number - } - alignment: 'start' | 'center' | 'end' - padding: { - x: number - y: number - } - constraints?: { - maxWidth?: number - maxHeight?: number - preserveUserPositions?: boolean - } -} - -export interface LayoutResult { - nodes: Array<{ - id: string - position: { x: number; y: number } - }> - metadata: { - strategy: string - totalWidth: number - totalHeight: number - layerCount: number - stats: { - crossings: number - totalEdgeLength: number - nodeOverlaps: number - } - } -} - -export interface WorkflowGraph { - nodes: LayoutNode[] - edges: LayoutEdge[] -} - -// Block category mapping for better layout decisions -export const BLOCK_CATEGORIES: Record = { - // Triggers - starter: 'trigger', - schedule: 'trigger', - webhook: 'trigger', - - // Processing - agent: 'processing', - api: 'processing', - function: 'processing', - - // Logic - condition: 'logic', - router: 'logic', - evaluator: 'logic', - - // Output - response: 'output', - - // Containers - loop: 'container', - parallel: 'container', -} - -// Default dimensions for different block types -export const BLOCK_DIMENSIONS: Record = { - default: { width: 320, height: 120 }, - wide: { width: 480, height: 120 }, - container: { width: 500, height: 300 }, -} diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index be4ce03603e..edd94fe227e 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -47,11 +47,36 @@ export class WorkflowDiffEngine { */ async createDiffFromYaml(yamlContent: string, diffAnalysis?: DiffAnalysis): Promise { try { - logger.info('Creating diff from YAML content') + logger.info('WorkflowDiffEngine.createDiffFromYaml called with:', { + yamlContentLength: yamlContent.length, + diffAnalysis: diffAnalysis, + diffAnalysisType: typeof diffAnalysis, + diffAnalysisUndefined: diffAnalysis === undefined, + diffAnalysisNull: diffAnalysis === null + }) + + // Get current workflow state for comparison + const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') + const currentWorkflowState = useWorkflowStore.getState().getWorkflowState() + + logger.info('WorkflowDiffEngine current workflow state:', { + blockCount: Object.keys(currentWorkflowState.blocks || {}).length, + edgeCount: currentWorkflowState.edges?.length || 0, + hasLoops: Object.keys(currentWorkflowState.loops || {}).length > 0, + hasParallels: Object.keys(currentWorkflowState.parallels || {}).length > 0 + }) // Call the sim agent service to create the diff const response = await yamlService.createDiff(yamlContent, diffAnalysis, { - applyAutoLayout: true + applyAutoLayout: true, + currentWorkflowState: currentWorkflowState + }) + + logger.info('WorkflowDiffEngine.createDiffFromYaml response:', { + success: response.success, + hasDiff: !!response.diff, + errors: response.errors, + hasDiffAnalysis: !!response.diff?.diffAnalysis }) if (!response.success || !response.diff) { @@ -61,12 +86,30 @@ export class WorkflowDiffEngine { } } + // Log diff analysis details + if (response.diff.diffAnalysis) { + logger.info('WorkflowDiffEngine diff analysis:', { + new_blocks: response.diff.diffAnalysis.new_blocks, + edited_blocks: response.diff.diffAnalysis.edited_blocks, + deleted_blocks: response.diff.diffAnalysis.deleted_blocks, + field_diffs: response.diff.diffAnalysis.field_diffs ? Object.keys(response.diff.diffAnalysis.field_diffs) : [], + edge_diff: response.diff.diffAnalysis.edge_diff ? { + new_edges_count: response.diff.diffAnalysis.edge_diff.new_edges.length, + deleted_edges_count: response.diff.diffAnalysis.edge_diff.deleted_edges.length, + unchanged_edges_count: response.diff.diffAnalysis.edge_diff.unchanged_edges.length + } : null + }) + } else { + logger.warn('WorkflowDiffEngine: No diff analysis in response!') + } + // Store the current diff this.currentDiff = response.diff logger.info('Diff created successfully', { blocksCount: Object.keys(response.diff.proposedState.blocks).length, edgesCount: response.diff.proposedState.edges.length, + hasDiffAnalysis: !!response.diff.diffAnalysis }) return { @@ -234,19 +277,14 @@ export class WorkflowDiffEngine { proposedYaml: string ): Promise { try { - const response = await fetch('/api/workflows/diff', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - original_yaml: originalYaml, - agent_yaml: proposedYaml, - }), - }) - - if (response.ok) { - const result = await response.json() - if (result.success && result.data) { - return result.data + const result = await yamlService.diffYaml(originalYaml, proposedYaml) + + if (result && result.changes && result.changes.length > 0) { + // Convert the diff result to DiffAnalysis format + // The yaml service returns changes array, we need to extract the analysis + const firstChange = result.changes[0] + if (firstChange && firstChange.data) { + return firstChange.data } } } catch (error) { diff --git a/apps/sim/lib/yaml-service-client.ts b/apps/sim/lib/yaml-service-client.ts index 1abf84eaff0..855b5453e32 100644 --- a/apps/sim/lib/yaml-service-client.ts +++ b/apps/sim/lib/yaml-service-client.ts @@ -118,7 +118,7 @@ export class YamlServiceClient { } async diffYaml(originalYaml: string, modifiedYaml: string): Promise { - return this.fetchFromAPI('/diff', { + return this.fetchFromAPI('/diff/create', { originalYaml, modifiedYaml }) @@ -130,13 +130,41 @@ export class YamlServiceClient { options?: { applyAutoLayout?: boolean layoutOptions?: any + currentWorkflowState?: WorkflowState } ): Promise { - return this.fetchFromAPI('/diff/create', { - yamlContent, - diffAnalysis, - options + logger.info('YamlServiceClient.createDiff called with:', { + yamlContentLength: yamlContent.length, + diffAnalysis: diffAnalysis, + diffAnalysisType: typeof diffAnalysis, + options: options + }) + + const body: any = { yamlContent } + if (diffAnalysis !== undefined && diffAnalysis !== null) { + body.diffAnalysis = diffAnalysis + } + if (options !== undefined && options !== null) { + body.options = options + // Extract currentWorkflowState from options to send at top level + if (options.currentWorkflowState) { + body.currentWorkflowState = options.currentWorkflowState + // Remove from options to avoid sending it twice + const { currentWorkflowState, ...restOptions } = options + body.options = restOptions + } + } + + logger.info('YamlServiceClient.createDiff sending body:', { + yamlContentLength: body.yamlContent?.length || 0, + hasDiffAnalysis: !!body.diffAnalysis, + diffAnalysis: body.diffAnalysis, + hasCurrentWorkflowState: !!body.currentWorkflowState, + currentBlockCount: body.currentWorkflowState ? Object.keys(body.currentWorkflowState.blocks || {}).length : 0, + currentEdgeCount: body.currentWorkflowState?.edges?.length || 0, + options: body.options }) + return this.fetchFromAPI('/diff/create', body) } async mergeDiff( @@ -148,12 +176,17 @@ export class YamlServiceClient { layoutOptions?: any } ): Promise { - return this.fetchFromAPI('/diff/merge', { + const body: any = { existingDiff, - yamlContent, - diffAnalysis, - options - }) + yamlContent + } + if (diffAnalysis !== undefined) { + body.diffAnalysis = diffAnalysis + } + if (options !== undefined) { + body.options = options + } + return this.fetchFromAPI('/diff/merge', body) } async autoLayout( diff --git a/apps/sim/stores/copilot/store.ts b/apps/sim/stores/copilot/store.ts index 3e21fcd670f..5aacb561b3a 100644 --- a/apps/sim/stores/copilot/store.ts +++ b/apps/sim/stores/copilot/store.ts @@ -8,6 +8,7 @@ import { sendStreamingMessage, } from '@/lib/copilot/api' import { createLogger } from '@/lib/logs/console-logger' +import { yamlService } from '@/lib/yaml-service-client' import type { CopilotStore } from './types' import { COPILOT_TOOL_IDS } from './constants' import { COPILOT_TOOL_DISPLAY_NAMES } from '@/stores/constants' @@ -1216,66 +1217,43 @@ export const useCopilotStore = create()( }) // Generate diff analysis by comparing current vs proposed YAML - let diffAnalysis = null - try { - // Get current workflow as YAML for comparison - const { useWorkflowYamlStore } = await import('@/stores/workflows/yaml/store') - - // Ensure YAML is generated before proceeding - await useWorkflowYamlStore.getState().generateYaml() - const currentYaml = useWorkflowYamlStore.getState().getYaml() - - logger.info('Got current workflow YAML for diff:', { - currentYamlLength: currentYaml?.length || 0, - hasCurrentYaml: !!currentYaml - }) + // Skip diff analysis - let sim-agent handle it through /api/yaml/diff/create + // The diff/create endpoint will compare against the current workflow state + // and generate the diff analysis automatically + logger.info('Proceeding to create diff without pre-analysis') - // If current YAML is empty, skip diff analysis - everything will be new - if (!currentYaml || currentYaml.trim() === '') { - logger.info('Current workflow is empty, treating all blocks as new') - // Don't call diff API - let the diff engine handle it as all new blocks - } else { - // Call the diff API to compare current vs proposed YAML - const diffResponse = await fetch('/api/workflows/diff', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - original_yaml: currentYaml, - agent_yaml: yamlContent, - }), - }) - - if (diffResponse.ok) { - const diffResult = await diffResponse.json() - if (diffResult.success && diffResult.data) { - diffAnalysis = diffResult.data - logger.info('Successfully generated diff analysis', { - newBlocks: diffAnalysis.new_blocks?.length || 0, - editedBlocks: diffAnalysis.edited_blocks?.length || 0, - deletedBlocks: diffAnalysis.deleted_blocks?.length || 0, - }) - } - } else { - logger.warn('Failed to generate diff analysis, proceeding without it') - } - } - } catch (diffError) { - logger.warn('Error generating diff analysis:', diffError) - // Continue without diff analysis - blocks will be marked as unchanged - } - - // Set or merge the proposed changes in the diff store based on the strategy + // Set or merge the proposed changes in the diff store based on the strategy const diffStore = useWorkflowDiffStore.getState() + + logger.info('CopilotStore.updateDiffStore calling setProposedChanges with:', { + yamlContentLength: yamlContent.length, + diffAnalysis: undefined, + diffAnalysisType: 'undefined', + diffAnalysisUndefined: true, + diffAnalysisNull: false, + shouldClearDiff: shouldClearDiff, + hasDiffWorkflow: !!diffStoreBefore.diffWorkflow + }) + if (shouldClearDiff || !diffStoreBefore.diffWorkflow) { // Use setProposedChanges which will create a new diff - await diffStore.setProposedChanges(yamlContent, diffAnalysis) + // Pass undefined to let sim-agent generate the diff analysis + await diffStore.setProposedChanges(yamlContent, undefined) } else { // Use mergeProposedChanges which will merge into existing diff - await diffStore.mergeProposedChanges(yamlContent, diffAnalysis) + // Pass undefined to let sim-agent generate the diff analysis + await diffStore.mergeProposedChanges(yamlContent, undefined) } // Check diff store state after update const diffStoreAfter = useWorkflowDiffStore.getState() + + // Log the diff state after update + logger.info('CopilotStore diff updated:', { + hasDiffWorkflow: !!diffStoreAfter.diffWorkflow, + hasDiffAnalysis: !!diffStoreAfter.diffAnalysis, + diffAnalysis: diffStoreAfter.diffAnalysis + }) logger.info('Diff store state after update:', { isShowingDiff: diffStoreAfter.isShowingDiff, isDiffReady: diffStoreAfter.isDiffReady, diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts index bdb0b5a044b..1d61bb81116 100644 --- a/apps/sim/stores/workflow-diff/store.ts +++ b/apps/sim/stores/workflow-diff/store.ts @@ -47,7 +47,13 @@ export const useWorkflowDiffStore = create { - logger.info('Setting proposed changes via YAML') + logger.info('WorkflowDiffStore.setProposedChanges called with:', { + yamlContentLength: yamlContent.length, + diffAnalysis: diffAnalysis, + diffAnalysisType: typeof diffAnalysis, + diffAnalysisUndefined: diffAnalysis === undefined, + diffAnalysisNull: diffAnalysis === null + }) // First, set isDiffReady to false to prevent premature rendering set({ isDiffReady: false }) @@ -60,6 +66,18 @@ export const useWorkflowDiffStore = create - connections?: ConnectionsFormat - parentId?: string // Add parentId for nested blocks -} - -interface YamlWorkflow { - version: string - blocks: Record -} - -interface ImportedBlock { - id: string - type: string - name: string - inputs: Record - position: { x: number; y: number } - data?: Record - parentId?: string - extent?: 'parent' -} - -interface ImportResult { - blocks: ImportedBlock[] - edges: ImportedEdge[] - errors: string[] - warnings: string[] -} - -/** - * Parse YAML content and validate its structure - */ -export function parseWorkflowYaml(yamlContent: string): { - data: YamlWorkflow | null - errors: string[] -} { - const errors: string[] = [] - - try { - const data = yamlParse(yamlContent) as unknown - - // Validate top-level structure - if (!data || typeof data !== 'object') { - errors.push('Invalid YAML: Root must be an object') - return { data: null, errors } - } - - // Type guard to check if data has the expected structure - const parsedData = data as Record - - if (!parsedData.version) { - errors.push('Missing required field: version') - } - - if (!parsedData.blocks || typeof parsedData.blocks !== 'object') { - errors.push('Missing or invalid field: blocks') - return { data: null, errors } - } - - // Validate blocks structure - const blocks = parsedData.blocks as Record - Object.entries(blocks).forEach(([blockId, block]: [string, unknown]) => { - if (!block || typeof block !== 'object') { - errors.push(`Invalid block definition for '${blockId}': must be an object`) - return - } - - const blockData = block as Record - - if (!blockData.type || typeof blockData.type !== 'string') { - errors.push(`Invalid block '${blockId}': missing or invalid 'type' field`) - } - - if (!blockData.name || typeof blockData.name !== 'string') { - errors.push(`Invalid block '${blockId}': missing or invalid 'name' field`) - } - - if (blockData.inputs && typeof blockData.inputs !== 'object') { - errors.push(`Invalid block '${blockId}': 'inputs' must be an object`) - } - - if (blockData.preceding && !Array.isArray(blockData.preceding)) { - errors.push(`Invalid block '${blockId}': 'preceding' must be an array`) - } - - if (blockData.following && !Array.isArray(blockData.following)) { - errors.push(`Invalid block '${blockId}': 'following' must be an array`) - } - }) - - if (errors.length > 0) { - return { data: null, errors } - } - - return { data: parsedData as unknown as YamlWorkflow, errors: [] } - } catch (error) { - errors.push(`YAML parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`) - return { data: null, errors } - } -} - -/** - * Validate that block types exist and are valid - */ -function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; warnings: string[] } { - const errors: string[] = [] - const warnings: string[] = [] - - Object.entries(yamlWorkflow.blocks).forEach(([blockId, block]) => { - // Use shared structure validation - const { errors: structureErrors, warnings: structureWarnings } = validateBlockStructure( - blockId, - block - ) - errors.push(...structureErrors) - warnings.push(...structureWarnings) - - // Check if block type exists - const blockConfig = getBlock(block.type) - - // Special handling for container blocks - if (block.type === 'loop' || block.type === 'parallel') { - // These are valid container types - return - } - - if (!blockConfig) { - errors.push(`Unknown block type '${block.type}' for block '${blockId}'`) - return - } - - // Validate inputs against block configuration - if (block.inputs && blockConfig.subBlocks) { - Object.keys(block.inputs).forEach((inputKey) => { - const subBlockConfig = blockConfig.subBlocks.find((sb) => sb.id === inputKey) - if (!subBlockConfig) { - warnings.push( - `Block '${blockId}' has unknown input '${inputKey}' for type '${block.type}'` - ) - } - }) - } - }) - - return { errors, warnings } -} - -/** - * Calculate positions for blocks based on their connections - * Uses a simple layered approach similar to the auto-layout algorithm - */ -function calculateBlockPositions( - yamlWorkflow: YamlWorkflow -): Record { - const positions: Record = {} - const blockIds = Object.keys(yamlWorkflow.blocks) - - // Find starter blocks (no incoming connections) - const starterBlocks = blockIds.filter((id) => { - const block = yamlWorkflow.blocks[id] - return !block.connections?.incoming || block.connections.incoming.length === 0 - }) - - // If no starter blocks found, use first block as starter - if (starterBlocks.length === 0 && blockIds.length > 0) { - starterBlocks.push(blockIds[0]) - } - - // Build layers - const layers: string[][] = [] - const visited = new Set() - const queue = [...starterBlocks] - - // BFS to organize blocks into layers - while (queue.length > 0) { - const currentLayer: string[] = [] - const currentLayerSize = queue.length - - for (let i = 0; i < currentLayerSize; i++) { - const blockId = queue.shift()! - if (visited.has(blockId)) continue - - visited.add(blockId) - currentLayer.push(blockId) - - // Add following blocks to queue - const block = yamlWorkflow.blocks[blockId] - if (block.connections?.outgoing) { - block.connections.outgoing.forEach((connection) => { - if (!visited.has(connection.target)) { - queue.push(connection.target) - } - }) - } - } - - if (currentLayer.length > 0) { - layers.push(currentLayer) - } - } - - // Add any remaining blocks as isolated layer - const remainingBlocks = blockIds.filter((id) => !visited.has(id)) - if (remainingBlocks.length > 0) { - layers.push(remainingBlocks) - } - - // Calculate positions - const horizontalSpacing = 600 - const verticalSpacing = 200 - const startX = 150 - const startY = 300 - - layers.forEach((layer, layerIndex) => { - const layerX = startX + layerIndex * horizontalSpacing - - layer.forEach((blockId, blockIndex) => { - const blockY = startY + (blockIndex - layer.length / 2) * verticalSpacing - positions[blockId] = { x: layerX, y: blockY } - }) - }) - - return positions -} - -/** - * Sort blocks to ensure parents are processed before children - * This ensures proper creation order for nested blocks - */ -function sortBlocksByParentChildOrder(blocks: ImportedBlock[]): ImportedBlock[] { - const sorted: ImportedBlock[] = [] - const processed = new Set() - const visiting = new Set() // Track blocks currently being processed to detect cycles - - // Create a map for quick lookup - const blockMap = new Map() - blocks.forEach((block) => blockMap.set(block.id, block)) - - // Process blocks recursively, ensuring parents are added first - function processBlock(block: ImportedBlock) { - if (processed.has(block.id)) { - return // Already processed - } - - if (visiting.has(block.id)) { - // Circular dependency detected - break the cycle by processing this block without its parent - logger.warn(`Circular parent-child dependency detected for block ${block.id}, breaking cycle`) - sorted.push(block) - processed.add(block.id) - return - } - - visiting.add(block.id) - - // If this block has a parent, ensure the parent is processed first - if (block.parentId) { - const parentBlock = blockMap.get(block.parentId) - if (parentBlock && !processed.has(block.parentId)) { - processBlock(parentBlock) - } - } - - // Now process this block - visiting.delete(block.id) - sorted.push(block) - processed.add(block.id) - } - - // Process all blocks - blocks.forEach((block) => processBlock(block)) - - return sorted -} - -/** - * Convert YAML workflow to importable format - */ -export function convertYamlToWorkflow(yamlWorkflow: YamlWorkflow): ImportResult { - const errors: string[] = [] - const warnings: string[] = [] - const blocks: ImportedBlock[] = [] - const edges: ImportedEdge[] = [] - - // Validate block references - const referenceErrors = validateBlockReferences(yamlWorkflow.blocks) - errors.push(...referenceErrors) - - // Validate block types - const { errors: typeErrors, warnings: typeWarnings } = validateBlockTypes(yamlWorkflow) - errors.push(...typeErrors) - warnings.push(...typeWarnings) - - if (errors.length > 0) { - return { blocks: [], edges: [], errors, warnings } - } - - // Calculate positions - const positions = calculateBlockPositions(yamlWorkflow) - - // Convert blocks - Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => { - const position = positions[blockId] || { x: 100, y: 100 } - - // Expand condition inputs from clean format to internal format - const processedInputs = - yamlBlock.type === 'condition' - ? expandConditionInputs(blockId, yamlBlock.inputs || {}) - : yamlBlock.inputs || {} - - const importedBlock: ImportedBlock = { - id: blockId, - type: yamlBlock.type, - name: yamlBlock.name, - inputs: processedInputs, - position, - } - - // Add container-specific data - if (yamlBlock.type === 'loop' || yamlBlock.type === 'parallel') { - // For loop/parallel blocks, map the inputs to the data field since they don't use subBlocks - importedBlock.data = { - width: 500, - height: 300, - type: yamlBlock.type === 'loop' ? 'loopNode' : 'parallelNode', - // Map YAML inputs to data properties for loop/parallel blocks - ...(yamlBlock.inputs || {}), - } - // Clear inputs since they're now in data - importedBlock.inputs = {} - } - - // Handle parent-child relationships for nested blocks - if (yamlBlock.parentId) { - importedBlock.parentId = yamlBlock.parentId - importedBlock.extent = 'parent' - // Also add to data for consistency with how the system works - if (!importedBlock.data) { - importedBlock.data = {} - } - importedBlock.data.parentId = yamlBlock.parentId - importedBlock.data.extent = 'parent' - } - - blocks.push(importedBlock) - }) - - // Convert edges from connections using shared parser - Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => { - const { - edges: blockEdges, - errors: connectionErrors, - warnings: connectionWarnings, - } = parseBlockConnections(blockId, yamlBlock.connections, yamlBlock.type) - - edges.push(...blockEdges) - errors.push(...connectionErrors) - warnings.push(...connectionWarnings) - }) - - // Sort blocks to ensure parents are created before children - const sortedBlocks = sortBlocksByParentChildOrder(blocks) - - return { blocks: sortedBlocks, edges, errors, warnings } -} - -/** - * Create smart ID mapping that preserves existing block IDs and generates new ones for new blocks - */ -function createSmartIdMapping( - yamlBlocks: ImportedBlock[], - existingBlocks: Record, - activeWorkflowId: string, - forceNewIds = false -): Map { - const yamlIdToActualId = new Map() - const existingBlockIds = new Set(Object.keys(existingBlocks)) - - logger.info('Creating smart ID mapping', { - activeWorkflowId, - yamlBlockCount: yamlBlocks.length, - existingBlockCount: Object.keys(existingBlocks).length, - existingBlockIds: Array.from(existingBlockIds), - yamlBlockIds: yamlBlocks.map((b) => b.id), - forceNewIds, - }) - - for (const block of yamlBlocks) { - if (forceNewIds || !existingBlockIds.has(block.id)) { - // Force new ID or block ID doesn't exist in current workflow - generate new UUID - const newId = uuidv4() - yamlIdToActualId.set(block.id, newId) - logger.info( - `🆕 Mapping new block: ${block.id} -> ${newId} (${forceNewIds ? 'forced new ID' : `not found in workflow ${activeWorkflowId}`})` - ) - } else { - // Block ID exists in current workflow - preserve it - yamlIdToActualId.set(block.id, block.id) - logger.info( - `✅ Preserving existing block ID: ${block.id} (exists in workflow ${activeWorkflowId})` - ) - } - } - - logger.info('Smart ID mapping completed', { - mappings: Array.from(yamlIdToActualId.entries()), - preservedCount: Array.from(yamlIdToActualId.entries()).filter(([old, new_]) => old === new_) - .length, - newCount: Array.from(yamlIdToActualId.entries()).filter(([old, new_]) => old !== new_).length, - }) - - return yamlIdToActualId -} From e4d28b70dc560369782c78b229ac1d688a605955 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 30 Jul 2025 12:14:08 -0700 Subject: [PATCH 114/184] Fix autolayout problems and clean up code some more --- apps/sim/app/api/yaml/autolayout/route.ts | 33 ++++++- apps/sim/app/api/yaml/diff/route.ts | 97 ------------------- .../w/[workflowId]/utils/auto-layout.ts | 27 +++++- apps/sim/lib/workflows/diff/diff-engine.ts | 55 ++++++----- apps/sim/lib/yaml-service-client.ts | 37 ++++--- 5 files changed, 98 insertions(+), 151 deletions(-) delete mode 100644 apps/sim/app/api/yaml/diff/route.ts diff --git a/apps/sim/app/api/yaml/autolayout/route.ts b/apps/sim/app/api/yaml/autolayout/route.ts index 81774412699..04aa648fa54 100644 --- a/apps/sim/app/api/yaml/autolayout/route.ts +++ b/apps/sim/app/api/yaml/autolayout/route.ts @@ -75,6 +75,13 @@ export async function POST(request: NextRequest) { }) } + logger.info(`[${requestId}] Calling sim-agent autolayout with strategy:`, { + strategy: options?.strategy || 'smart (default)', + direction: options?.direction || 'auto (default)', + spacing: options?.spacing, + alignment: options?.alignment || 'center (default)' + }) + // Call sim-agent API const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/autolayout`, { method: 'POST', @@ -83,11 +90,27 @@ export async function POST(request: NextRequest) { ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), }, body: JSON.stringify({ - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops || {}, - parallels: workflowState.parallels || {}, - options, + workflowState: { + blocks: workflowState.blocks, + edges: workflowState.edges, + loops: workflowState.loops || {}, + parallels: workflowState.parallels || {} + }, + options: { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700 + }, + alignment: 'center', + padding: { + x: 250, + y: 250 + }, + ...options // Allow override of defaults + }, blockRegistry, utilities: { diff --git a/apps/sim/app/api/yaml/diff/route.ts b/apps/sim/app/api/yaml/diff/route.ts deleted file mode 100644 index 195c28b9b0a..00000000000 --- a/apps/sim/app/api/yaml/diff/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { createLogger } from '@/lib/logs/console-logger' -import { getAllBlocks } from '@/blocks/registry' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { resolveOutputType } from '@/blocks/utils' -import type { BlockConfig } from '@/blocks/types' - -const logger = createLogger('YamlDiffAPI') - -// Sim Agent API configuration -const SIM_AGENT_API_URL = process.env.SIM_AGENT_API_URL || 'http://localhost:8000' -const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY - -const DiffRequestSchema = z.object({ - originalYaml: z.string(), - modifiedYaml: z.string(), -}) - -export async function POST(request: NextRequest) { - const requestId = crypto.randomUUID().slice(0, 8) - - try { - const body = await request.json() - const { originalYaml, modifiedYaml } = DiffRequestSchema.parse(body) - - logger.info(`[${requestId}] Diffing YAML`, { - originalLength: originalYaml.length, - modifiedLength: modifiedYaml.length, - hasApiKey: !!SIM_AGENT_API_KEY, - }) - - // Gather block registry and utilities - const blocks = getAllBlocks() - const blockRegistry = blocks.reduce((acc, block) => { - const blockType = block.type - acc[blockType] = { - ...block, - id: blockType, - subBlocks: block.subBlocks || [], - outputs: block.outputs || {}, - } as any - return acc - }, {} as Record) - - // Call sim-agent API - const response = await fetch(`${SIM_AGENT_API_URL}/api/yaml/diff`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }), - }, - body: JSON.stringify({ - originalYaml, - modifiedYaml, - blockRegistry, - utilities: { - generateLoopBlocks: generateLoopBlocks.toString(), - generateParallelBlocks: generateParallelBlocks.toString(), - resolveOutputType: resolveOutputType.toString() - } - }), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Sim agent API error:`, { - status: response.status, - error: errorText, - }) - return NextResponse.json( - { changes: [], errors: [`Sim agent API error: ${response.statusText}`] }, - { status: response.status } - ) - } - - const result = await response.json() - return NextResponse.json(result) - } catch (error) { - logger.error(`[${requestId}] YAML diff failed:`, error) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { changes: [], errors: error.errors.map(e => e.message) }, - { status: 400 } - ) - } - - return NextResponse.json( - { - changes: [], - errors: [error instanceof Error ? error.message : 'Unknown error'] - }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts index ff4764ca2e7..b7a21cc895d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout.ts @@ -173,10 +173,29 @@ export async function applyAutoLayoutAndUpdateStore( logger.info('Successfully updated workflow store with auto layout', { workflowId }) - // Save to database in background (don't await to keep UI responsive) - saveAutoLayoutToDatabase(workflowId, options) - - return { success: true } + // Save to database and handle errors properly + try { + await saveAutoLayoutToDatabase(workflowId, options) + logger.info('Auto layout successfully persisted to database', { workflowId }) + return { success: true } + } catch (saveError) { + logger.error('Failed to save auto layout to database, reverting store changes:', { + workflowId, + error: saveError + }) + + // Revert the store changes since database save failed + useWorkflowStore.setState({ + ...workflowStore.getWorkflowState(), + blocks: blocks, // Revert to original blocks + lastSaved: workflowStore.lastSaved, // Revert lastSaved + }) + + return { + success: false, + error: `Failed to save positions to database: ${saveError instanceof Error ? saveError.message : 'Unknown error'}` + } + } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown store update error' logger.error('Failed to update store with auto layout:', { workflowId, error: errorMessage }) diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index edd94fe227e..69464a1b159 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -69,7 +69,21 @@ export class WorkflowDiffEngine { // Call the sim agent service to create the diff const response = await yamlService.createDiff(yamlContent, diffAnalysis, { applyAutoLayout: true, - currentWorkflowState: currentWorkflowState + currentWorkflowState: currentWorkflowState, + layoutOptions: { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700 + }, + alignment: 'center', + padding: { + x: 250, + y: 250 + } + } }) logger.info('WorkflowDiffEngine.createDiffFromYaml response:', { @@ -145,7 +159,21 @@ export class WorkflowDiffEngine { yamlContent, diffAnalysis, { - applyAutoLayout: true + applyAutoLayout: true, + layoutOptions: { + strategy: 'smart', + direction: 'auto', + spacing: { + horizontal: 500, + vertical: 400, + layer: 700 + }, + alignment: 'center', + padding: { + x: 250, + y: 250 + } + } } ) @@ -269,28 +297,5 @@ export class WorkflowDiffEngine { } } - /** - * Analyze differences between two workflow states - */ - static async analyzeDiff( - originalYaml: string, - proposedYaml: string - ): Promise { - try { - const result = await yamlService.diffYaml(originalYaml, proposedYaml) - - if (result && result.changes && result.changes.length > 0) { - // Convert the diff result to DiffAnalysis format - // The yaml service returns changes array, we need to extract the analysis - const firstChange = result.changes[0] - if (firstChange && firstChange.data) { - return firstChange.data - } - } - } catch (error) { - logger.error('Failed to analyze diff:', error) - } - return null - } } diff --git a/apps/sim/lib/yaml-service-client.ts b/apps/sim/lib/yaml-service-client.ts index 855b5453e32..e47bab6d76a 100644 --- a/apps/sim/lib/yaml-service-client.ts +++ b/apps/sim/lib/yaml-service-client.ts @@ -24,10 +24,7 @@ interface GenerateYamlResponse { error?: string } -interface DiffYamlResponse { - changes: any[] - errors: string[] -} + interface CreateDiffResponse { success: boolean @@ -42,13 +39,6 @@ interface MergeDiffResponse { } - -interface AnalyzeDiffResponse { - success: boolean - data?: DiffAnalysis - errors: string[] -} - interface AutoLayoutResponse { success: boolean workflowState?: WorkflowState @@ -65,7 +55,13 @@ export class YamlServiceClient { */ private async fetchFromAPI(endpoint: string, body: any): Promise { try { - const response = await fetch(`/api/yaml${endpoint}`, { + // Construct absolute URL for server-side context + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const url = `${baseUrl}/api/yaml${endpoint}` + + logger.info(`YamlServiceClient calling: ${url}`) + + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -78,6 +74,7 @@ export class YamlServiceClient { logger.error(`API error for ${endpoint}:`, { status: response.status, error: errorData, + url: url }) throw new Error(errorData?.error || `API error: ${response.statusText}`) } @@ -117,12 +114,7 @@ export class YamlServiceClient { }) } - async diffYaml(originalYaml: string, modifiedYaml: string): Promise { - return this.fetchFromAPI('/diff/create', { - originalYaml, - modifiedYaml - }) - } + async createDiff( yamlContent: string, @@ -215,7 +207,11 @@ export class YamlServiceClient { // Helper method to check if external service is available async healthCheck(): Promise { try { - const response = await fetch('/api/yaml/health', { + // Construct absolute URL for server-side context + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const url = `${baseUrl}/api/yaml/health` + + const response = await fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -225,6 +221,7 @@ export class YamlServiceClient { if (!response.ok) { logger.error('YAML service health check failed:', { status: response.status, + url: url }) return false } @@ -242,4 +239,4 @@ export class YamlServiceClient { export const yamlService = new YamlServiceClient() // Export types for consumers -export type { ParseYamlResponse, ConvertYamlToWorkflowResponse, GenerateYamlResponse, DiffYamlResponse, AutoLayoutResponse } \ No newline at end of file +export type { ParseYamlResponse, ConvertYamlToWorkflowResponse, GenerateYamlResponse, AutoLayoutResponse } \ No newline at end of file From 599538f5a50617f6cf1e0217046c1c1d8dbe776b Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Wed, 30 Jul 2025 12:22:01 -0700 Subject: [PATCH 115/184] improvement: copilot panel ui/ux --- apps/sim/app/api/copilot/chat/route.ts | 171 ++--- apps/sim/app/api/copilot/methods/route.ts | 36 +- apps/sim/app/api/copilot/methods/utils.ts | 4 +- apps/sim/app/api/copilot/tools/base.ts | 17 +- .../tools/blocks/get-blocks-and-tools.ts | 11 +- .../tools/blocks/get-blocks-metadata.ts | 8 +- .../tools/blocks/get-workflow-examples.ts | 22 +- .../tools/blocks/get-yaml-structure.ts | 6 +- .../app/api/copilot/tools/docs/search-docs.ts | 40 +- .../api/copilot/tools/other/online-search.ts | 8 +- apps/sim/app/api/copilot/tools/registry.ts | 10 +- .../tools/user/get-environment-variables.ts | 38 +- .../tools/user/set-environment-variables.ts | 25 +- .../copilot/tools/workflow/build-workflow.ts | 12 +- .../copilot/tools/workflow/edit-workflow.ts | 6 +- .../tools/workflow/get-workflow-console.ts | 9 +- apps/sim/app/api/workflows/[id]/route.ts | 2 +- .../components/message/message.tsx | 1 - .../components/control-bar/control-bar.tsx | 3 - .../components/loop-node/loop-node.tsx | 3 +- .../chat/components/chat-modal/chat-modal.tsx | 254 ------- .../professional-input/professional-input.tsx | 151 ++-- .../professional-message.tsx | 663 +++++++++--------- .../panel/components/copilot/copilot.tsx | 539 ++++---------- .../w/[workflowId]/components/panel/panel.tsx | 339 ++++++++- .../parallel-node/parallel-node.tsx | 3 +- .../sub-block/components/eval-input.tsx | 25 +- apps/sim/components/ui/tool-call.tsx | 4 +- apps/sim/lib/auth/hybrid.ts | 80 ++- apps/sim/lib/security/csp.ts | 3 +- apps/sim/lib/sim-agent/client.ts | 14 +- apps/sim/lib/sim-agent/index.ts | 5 +- apps/sim/lib/workflows/diff/diff-engine.ts | 88 ++- apps/sim/providers/anthropic/index.ts | 3 - apps/sim/stores/constants.ts | 26 +- apps/sim/stores/copilot/constants.ts | 2 +- apps/sim/stores/copilot/preview-store.ts | 2 +- apps/sim/stores/copilot/store.ts | 436 +++++++----- apps/sim/stores/copilot/types.ts | 16 +- apps/sim/tools/utils.ts | 1 + 40 files changed, 1523 insertions(+), 1563 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 18a5850abbd..c72fb77ceaa 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,16 +1,13 @@ +import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' +import { getCopilotModel } from '@/lib/copilot/config' +import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { apiKey as apiKeyTable, copilotChats } from '@/db/schema' -import { and, eq, desc } from 'drizzle-orm' import { executeProviderRequest } from '@/providers' -import { getCopilotModel } from '@/lib/copilot/config' -import { - TITLE_GENERATION_SYSTEM_PROMPT, - TITLE_GENERATION_USER_PROMPT -} from '@/lib/copilot/prompts' const logger = createLogger('CopilotChatAPI') @@ -35,7 +32,7 @@ const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY async function generateChatTitle(userMessage: string): Promise { try { const { provider, model } = getCopilotModel('title') - + // Get the appropriate API key for the provider let apiKey: string | undefined if (provider === 'anthropic') { @@ -75,16 +72,16 @@ async function generateChatTitle(userMessage: string): Promise { * Generate chat title asynchronously and update the database */ async function generateChatTitleAsync( - chatId: string, - userMessage: string, + chatId: string, + userMessage: string, requestId: string, streamController?: ReadableStreamDefaultController ): Promise { try { logger.info(`[${requestId}] Starting async title generation for chat ${chatId}`) - + const title = await generateChatTitle(userMessage) - + // Update the chat with the generated title await db .update(copilotChats) @@ -93,18 +90,18 @@ async function generateChatTitleAsync( updatedAt: new Date(), }) .where(eq(copilotChats.id, chatId)) - + // Send title_updated event to client if streaming if (streamController) { const encoder = new TextEncoder() - const titleEvent = `data: ${JSON.stringify({ - type: 'title_updated', - title: title + const titleEvent = `data: ${JSON.stringify({ + type: 'title_updated', + title: title, })}\n\n` streamController.enqueue(encoder.encode(titleEvent)) logger.debug(`[${requestId}] Sent title_updated event to client: "${title}"`) } - + logger.info(`[${requestId}] Generated title for chat ${chatId}: "${title}"`) } catch (error) { logger.error(`[${requestId}] Failed to generate title for chat ${chatId}:`, error) @@ -147,7 +144,7 @@ export async function POST(req: NextRequest) { } const body = await req.json() - const { message, chatId, workflowId, mode, createNewChat, stream, implicitFeedback } = + const { message, chatId, workflowId, mode, createNewChat, stream, implicitFeedback } = ChatMessageSchema.parse(body) logger.info(`[${requestId}] Processing copilot chat request`, { @@ -200,7 +197,7 @@ export async function POST(req: NextRequest) { // Build messages array for sim agent with conversation history const messages = [] - + // Add conversation history for (const msg of conversationHistory) { messages.push({ @@ -231,9 +228,9 @@ export async function POST(req: NextRequest) { // Forward to sim agent API logger.info(`[${requestId}] Sending request to sim agent API`, { messageCount: messages.length, - endpoint: `${SIM_AGENT_API_URL}/api/chat-completion-streaming` + endpoint: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`, }) - + const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, { method: 'POST', headers: { @@ -279,15 +276,15 @@ export async function POST(req: NextRequest) { async start(controller) { const encoder = new TextEncoder() let assistantContent = '' - let toolCalls: any[] = [] + const toolCalls: any[] = [] let buffer = '' let isFirstDone = true // Send chatId as first event if (actualChatId) { - const chatIdEvent = `data: ${JSON.stringify({ - type: 'chat_id', - chatId: actualChatId + const chatIdEvent = `data: ${JSON.stringify({ + type: 'chat_id', + chatId: actualChatId, })}\n\n` controller.enqueue(encoder.encode(chatIdEvent)) logger.debug(`[${requestId}] Sent initial chatId event to client`) @@ -299,27 +296,30 @@ export async function POST(req: NextRequest) { chatId: actualChatId, hasTitle: !!currentChat?.title, conversationLength: conversationHistory.length, - message: message.substring(0, 100) + (message.length > 100 ? '...' : '') + message: message.substring(0, 100) + (message.length > 100 ? '...' : ''), + }) + generateChatTitleAsync(actualChatId, message, requestId, controller).catch((error) => { + logger.error(`[${requestId}] Title generation failed:`, error) }) - generateChatTitleAsync(actualChatId, message, requestId, controller) - .catch(error => { - logger.error(`[${requestId}] Title generation failed:`, error) - }) } else { logger.debug(`[${requestId}] Skipping title generation`, { chatId: actualChatId, hasTitle: !!currentChat?.title, conversationLength: conversationHistory.length, - reason: !actualChatId ? 'no chatId' : - currentChat?.title ? 'already has title' : - conversationHistory.length > 0 ? 'not first message' : 'unknown' + reason: !actualChatId + ? 'no chatId' + : currentChat?.title + ? 'already has title' + : conversationHistory.length > 0 + ? 'not first message' + : 'unknown', }) } // Forward the sim agent stream and capture assistant response const reader = simAgentResponse.body!.getReader() const decoder = new TextDecoder() - + try { while (true) { const { done, value } = await reader.read() @@ -327,7 +327,7 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Stream reading completed`) break } - + // Check if client disconnected before processing chunk try { // Forward the chunk to client immediately @@ -339,28 +339,29 @@ export async function POST(req: NextRequest) { break } const chunkSize = value.byteLength - + // Decode and parse SSE events for logging and capturing content const decodedChunk = decoder.decode(value, { stream: true }) buffer += decodedChunk - + // Log first few chunks for debugging if (chunkSize > 0) { logger.debug(`[${requestId}] Forwarded chunk to client:`, { size: chunkSize, - preview: decodedChunk.substring(0, 100) + (decodedChunk.length > 100 ? '...' : '') + preview: + decodedChunk.substring(0, 100) + (decodedChunk.length > 100 ? '...' : ''), }) } const lines = buffer.split('\n') buffer = lines.pop() || '' // Keep incomplete line in buffer - + for (const line of lines) { if (line.trim() === '') continue // Skip empty lines - + if (line.startsWith('data: ') && line.length > 6) { try { const event = JSON.parse(line.slice(6)) - + // Log different event types comprehensively switch (event.type) { case 'content': @@ -369,58 +370,63 @@ export async function POST(req: NextRequest) { assistantContent += event.data } break - + case 'tool_call': - logger.info(`[${requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`, { - id: event.data?.id, - name: event.data?.name, - arguments: event.data?.arguments, - blockIndex: event.data?._blockIndex - }) + logger.info( + `[${requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`, + { + id: event.data?.id, + name: event.data?.name, + arguments: event.data?.arguments, + blockIndex: event.data?._blockIndex, + } + ) if (!event.data?.partial) { toolCalls.push(event.data) } break - + case 'tool_execution': logger.info(`[${requestId}] Tool execution started:`, { toolCallId: event.toolCallId, toolName: event.toolName, - status: event.status + status: event.status, }) break - + case 'tool_result': logger.info(`[${requestId}] Tool result received:`, { toolCallId: event.toolCallId, toolName: event.toolName, success: event.success, - result: JSON.stringify(event.result).substring(0, 200) + '...' + result: `${JSON.stringify(event.result).substring(0, 200)}...`, }) break - + case 'tool_error': logger.error(`[${requestId}] Tool error:`, { toolCallId: event.toolCallId, toolName: event.toolName, error: event.error, - success: event.success + success: event.success, }) break - + case 'done': if (isFirstDone) { - logger.info(`[${requestId}] Initial AI response complete, tool count: ${toolCalls.length}`) + logger.info( + `[${requestId}] Initial AI response complete, tool count: ${toolCalls.length}` + ) isFirstDone = false } else { logger.info(`[${requestId}] Conversation round complete`) } break - + case 'error': logger.error(`[${requestId}] Stream error event:`, event.error) break - + default: logger.debug(`[${requestId}] Unknown event type: ${event.type}`, event) } @@ -432,7 +438,7 @@ export async function POST(req: NextRequest) { } } } - + // Process any remaining buffer if (buffer.trim()) { logger.debug(`[${requestId}] Processing remaining buffer: "${buffer}"`) @@ -453,12 +459,12 @@ export async function POST(req: NextRequest) { totalContentLength: assistantContent.length, toolCallsCount: toolCalls.length, hasContent: assistantContent.length > 0, - toolNames: toolCalls.map(tc => tc?.name).filter(Boolean) + toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean), }) // Save messages to database after streaming completes (including aborted messages) if (currentChat) { - let updatedMessages = [...conversationHistory, userMessage] + const updatedMessages = [...conversationHistory, userMessage] // Save assistant message if there's any content (even partial from abort) if (assistantContent.trim()) { @@ -469,7 +475,9 @@ export async function POST(req: NextRequest) { timestamp: new Date().toISOString(), } updatedMessages.push(assistantMessage) - logger.info(`[${requestId}] Saving assistant message with content (${assistantContent.length} chars)`) + logger.info( + `[${requestId}] Saving assistant message with content (${assistantContent.length} chars)` + ) } else { logger.info(`[${requestId}] No assistant content to save (aborted before response)`) } @@ -506,7 +514,7 @@ export async function POST(req: NextRequest) { 'X-Accel-Buffering': 'no', }, }) - + logger.info(`[${requestId}] Returning streaming response to client`, { duration: Date.now() - startTime, chatId: actualChatId, @@ -514,9 +522,9 @@ export async function POST(req: NextRequest) { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', - } + }, }) - + return response } @@ -528,7 +536,7 @@ export async function POST(req: NextRequest) { model: responseData.model, provider: responseData.provider, toolCallsCount: responseData.toolCalls?.length || 0, - hasTokens: !!responseData.tokens + hasTokens: !!responseData.tokens, }) // Log tool calls if present @@ -538,7 +546,7 @@ export async function POST(req: NextRequest) { id: toolCall.id, name: toolCall.name, success: toolCall.success, - result: JSON.stringify(toolCall.result).substring(0, 200) + '...' + result: `${JSON.stringify(toolCall.result).substring(0, 200)}...`, }) }) } @@ -564,10 +572,9 @@ export async function POST(req: NextRequest) { // Start title generation in parallel if this is first message (non-streaming) if (actualChatId && !currentChat.title && conversationHistory.length === 0) { logger.info(`[${requestId}] Starting title generation for non-streaming response`) - generateChatTitleAsync(actualChatId, message, requestId) - .catch(error => { - logger.error(`[${requestId}] Title generation failed:`, error) - }) + generateChatTitleAsync(actualChatId, message, requestId).catch((error) => { + logger.error(`[${requestId}] Title generation failed:`, error) + }) } // Update chat in database immediately (without blocking for title) @@ -583,7 +590,7 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Returning non-streaming response`, { duration: Date.now() - startTime, chatId: actualChatId, - responseLength: responseData.content?.length || 0 + responseLength: responseData.content?.length || 0, }) return NextResponse.json({ @@ -598,11 +605,11 @@ export async function POST(req: NextRequest) { }) } catch (error) { const duration = Date.now() - startTime - + if (error instanceof z.ZodError) { logger.error(`[${requestId}] Validation error:`, { duration, - errors: error.errors + errors: error.errors, }) return NextResponse.json( { error: 'Invalid request data', details: error.errors }, @@ -613,15 +620,15 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Error handling copilot chat:`, { duration, error: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined + stack: error instanceof Error ? error.stack : undefined, }) - + return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, { status: 500 } ) } -} +} export async function GET(req: NextRequest) { try { @@ -652,10 +659,7 @@ export async function GET(req: NextRequest) { }) .from(copilotChats) .where( - and( - eq(copilotChats.userId, authenticatedUserId), - eq(copilotChats.workflowId, workflowId) - ) + and(eq(copilotChats.userId, authenticatedUserId), eq(copilotChats.workflowId, workflowId)) ) .orderBy(desc(copilotChats.updatedAt)) @@ -679,9 +683,6 @@ export async function GET(req: NextRequest) { }) } catch (error) { logger.error('Error fetching copilot chats:', error) - return NextResponse.json( - { error: 'Failed to fetch chats' }, - { status: 500 } - ) + return NextResponse.json({ error: 'Failed to fetch chats' }, { status: 500 }) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/methods/route.ts b/apps/sim/app/api/copilot/methods/route.ts index e6b0cc6b3c4..012407ed62f 100644 --- a/apps/sim/app/api/copilot/methods/route.ts +++ b/apps/sim/app/api/copilot/methods/route.ts @@ -1,8 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console-logger' -import { createErrorResponse } from './utils' import { copilotToolRegistry } from '../tools/registry' +import { createErrorResponse } from './utils' const logger = createLogger('CopilotMethodsAPI') @@ -16,19 +16,19 @@ const MethodExecutionSchema = z.object({ function checkInternalApiKey(req: NextRequest) { const apiKey = req.headers.get('x-api-key') const expectedApiKey = process.env.INTERNAL_API_SECRET - + if (!expectedApiKey) { return { success: false, error: 'Internal API key not configured' } } - + if (!apiKey) { return { success: false, error: 'API key required' } } - + if (apiKey !== expectedApiKey) { return { success: false, error: 'Invalid API key' } } - + return { success: true } } @@ -44,7 +44,9 @@ export async function POST(req: NextRequest) { // Check authentication (internal API key) const authResult = checkInternalApiKey(req) if (!authResult.success) { - return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), { status: 401 }) + return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), { + status: 401, + }) } const body = await req.json() @@ -60,10 +62,12 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Tool not found in registry: ${methodId}`, { methodId, availableTools: copilotToolRegistry.getAvailableIds(), - registrySize: copilotToolRegistry.getAvailableIds().length + registrySize: copilotToolRegistry.getAvailableIds().length, }) return NextResponse.json( - createErrorResponse(`Unknown method: ${methodId}. Available methods: ${copilotToolRegistry.getAvailableIds().join(', ')}`), + createErrorResponse( + `Unknown method: ${methodId}. Available methods: ${copilotToolRegistry.getAvailableIds().join(', ')}` + ), { status: 400 } ) } @@ -77,7 +81,7 @@ export async function POST(req: NextRequest) { methodId, success: result.success, hasData: !!result.data, - hasError: !!result.error + hasError: !!result.error, }) const duration = Date.now() - startTime @@ -90,14 +94,16 @@ export async function POST(req: NextRequest) { return NextResponse.json(result) } catch (error) { const duration = Date.now() - startTime - + if (error instanceof z.ZodError) { logger.error(`[${requestId}] Request validation error:`, { duration, - errors: error.errors + errors: error.errors, }) return NextResponse.json( - createErrorResponse(`Invalid request data: ${error.errors.map(e => e.message).join(', ')}`), + createErrorResponse( + `Invalid request data: ${error.errors.map((e) => e.message).join(', ')}` + ), { status: 400 } ) } @@ -105,12 +111,12 @@ export async function POST(req: NextRequest) { logger.error(`[${requestId}] Unexpected error:`, { duration, error: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined + stack: error instanceof Error ? error.stack : undefined, }) - + return NextResponse.json( createErrorResponse(error instanceof Error ? error.message : 'Internal server error'), { status: 500 } ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/methods/utils.ts b/apps/sim/app/api/copilot/methods/utils.ts index abb0915d8ac..955a8def31d 100644 --- a/apps/sim/app/api/copilot/methods/utils.ts +++ b/apps/sim/app/api/copilot/methods/utils.ts @@ -1,5 +1,5 @@ import { createLogger } from '@/lib/logs/console-logger' -import { CopilotToolResponse } from '../tools/base' +import type { CopilotToolResponse } from '../tools/base' const logger = createLogger('CopilotMethodsUtils') @@ -21,4 +21,4 @@ export function createSuccessResponse(data: any): CopilotToolResponse { success: true, data, } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/tools/base.ts b/apps/sim/app/api/copilot/tools/base.ts index 067edd0c229..65dfd8116a6 100644 --- a/apps/sim/app/api/copilot/tools/base.ts +++ b/apps/sim/app/api/copilot/tools/base.ts @@ -1,4 +1,3 @@ -import { z, type ZodSchema } from 'zod' import { createLogger } from '@/lib/logs/console-logger' // Base tool response interface @@ -16,12 +15,14 @@ export interface CopilotTool { } // Abstract base class for copilot tools -export abstract class BaseCopilotTool implements CopilotTool { +export abstract class BaseCopilotTool + implements CopilotTool +{ abstract readonly id: string abstract readonly displayName: string - + private _logger?: ReturnType - + protected get logger() { if (!this._logger) { this._logger = createLogger(`CopilotTool:${this.id}`) @@ -34,7 +35,7 @@ export abstract class BaseCopilotTool implements C */ async execute(params: TParams): Promise> { const startTime = Date.now() - + try { this.logger.info(`Executing tool: ${this.id}`, { toolId: this.id, @@ -43,7 +44,7 @@ export abstract class BaseCopilotTool implements C // Execute the tool logic const result = await this.executeImpl(params) - + const duration = Date.now() - startTime this.logger.info(`Tool execution completed: ${this.id}`, { toolId: this.id, @@ -58,7 +59,7 @@ export abstract class BaseCopilotTool implements C } catch (error) { const duration = Date.now() - startTime const errorMessage = error instanceof Error ? error.message : 'Unknown error' - + this.logger.error(`Tool execution failed: ${this.id}`, { toolId: this.id, duration, @@ -77,4 +78,4 @@ export abstract class BaseCopilotTool implements C * Abstract method that each tool must implement with their specific logic */ protected abstract executeImpl(params: TParams): Promise -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts index c93517f12c2..577e65fd10f 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-and-tools.ts @@ -1,12 +1,13 @@ +import { createLogger } from '@/lib/logs/console-logger' import { registry as blockRegistry } from '@/blocks/registry' import { BaseCopilotTool } from '../base' -import { createLogger } from '@/lib/logs/console-logger' -interface GetBlocksAndToolsParams { - // No parameters needed - just return all blocks and tools -} +type GetBlocksAndToolsParams = {} -class GetBlocksAndToolsTool extends BaseCopilotTool> { +class GetBlocksAndToolsTool extends BaseCopilotTool< + GetBlocksAndToolsParams, + Record +> { readonly id = 'get_blocks_and_tools' readonly displayName = 'Getting block information' diff --git a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts index afc03940078..20a5eac9a8e 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-blocks-metadata.ts @@ -30,7 +30,9 @@ class GetBlocksMetadataTool extends BaseCopilotTool { +export async function getBlocksMetadata( + params: GetBlocksMetadataParams +): Promise { const { blockIds } = params if (!blockIds || !Array.isArray(blockIds)) { @@ -82,7 +84,7 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis const docPath = join(process.cwd(), 'content', 'docs', 'blocks', `${docFileName}.mdx`) if (existsSync(docPath)) { const docContent = readFileSync(docPath, 'utf-8') - + // Extract schema from the documentation const schemaMatch = docContent.match(/```yaml\s*\n([\s\S]*?)```/i) if (schemaMatch) { @@ -95,7 +97,7 @@ export async function getBlocksMetadata(params: GetBlocksMetadataParams): Promis } // Extract field names and structure - lines.forEach(line => { + lines.forEach((line) => { const match = line.match(/^(\s*)(\w+):/) if (match) { const indent = match[1].length diff --git a/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts b/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts index c7ff51f56e5..d7dd2affe52 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-workflow-examples.ts @@ -1,5 +1,5 @@ -import { createLogger } from '@/lib/logs/console-logger' import { WORKFLOW_EXAMPLES } from '@/lib/copilot/examples' +import { createLogger } from '@/lib/logs/console-logger' import { BaseCopilotTool } from '../base' interface GetWorkflowExamplesParams { @@ -12,7 +12,10 @@ interface WorkflowExamplesResult { availableIds: string[] } -class GetWorkflowExamplesTool extends BaseCopilotTool { +class GetWorkflowExamplesTool extends BaseCopilotTool< + GetWorkflowExamplesParams, + WorkflowExamplesResult +> { readonly id = 'get_workflow_examples' readonly displayName = 'Getting workflow examples' @@ -25,14 +28,21 @@ class GetWorkflowExamplesTool extends BaseCopilotTool { +async function getWorkflowExamples( + params: GetWorkflowExamplesParams +): Promise { const logger = createLogger('GetWorkflowExamples') - + // Strict validation - exampleIds is required - if (!params || !params.exampleIds || !Array.isArray(params.exampleIds) || params.exampleIds.length === 0) { + if ( + !params || + !params.exampleIds || + !Array.isArray(params.exampleIds) || + params.exampleIds.length === 0 + ) { throw new Error('exampleIds parameter is required and must be a non-empty array of example IDs') } - + const { exampleIds } = params logger.info('Getting workflow examples for copilot', { exampleCount: exampleIds.length }) diff --git a/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts b/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts index 0af00c90fac..fccd1b7eb86 100644 --- a/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts +++ b/apps/sim/app/api/copilot/tools/blocks/get-yaml-structure.ts @@ -1,10 +1,8 @@ -import { createLogger } from '@/lib/logs/console-logger' import { getYamlWorkflowPrompt } from '@/lib/copilot/prompts' +import { createLogger } from '@/lib/logs/console-logger' import { BaseCopilotTool } from '../base' -interface GetYamlStructureParams { - // No parameters needed - just return the YAML structure guide -} +type GetYamlStructureParams = {} interface YamlStructureResult { guide: string diff --git a/apps/sim/app/api/copilot/tools/docs/search-docs.ts b/apps/sim/app/api/copilot/tools/docs/search-docs.ts index 6401c2a51cf..614927abec5 100644 --- a/apps/sim/app/api/copilot/tools/docs/search-docs.ts +++ b/apps/sim/app/api/copilot/tools/docs/search-docs.ts @@ -1,8 +1,8 @@ +import { sql } from 'drizzle-orm' +import { getCopilotConfig } from '@/lib/copilot/config' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' import { docsEmbeddings } from '@/db/schema' -import { sql } from 'drizzle-orm' -import { getCopilotConfig } from '@/lib/copilot/config' import { BaseCopilotTool } from '../base' interface DocsSearchParams { @@ -42,8 +42,8 @@ async function searchDocs(params: DocsSearchParams): Promise { const logger = createLogger('DocsSearch') const { query, topK = 10, threshold } = params - logger.info('Executing docs search for copilot', { - query, + logger.info('Executing docs search for copilot', { + query, topK, }) @@ -53,9 +53,9 @@ async function searchDocs(params: DocsSearchParams): Promise { // Generate embedding for the query const { generateEmbeddings } = await import('@/app/api/knowledge/utils') - + logger.info('About to generate embeddings for query', { query, queryLength: query.length }) - + const embeddings = await generateEmbeddings([query]) const queryEmbedding = embeddings[0] @@ -68,7 +68,9 @@ async function searchDocs(params: DocsSearchParams): Promise { } } - logger.info('Successfully generated query embedding', { embeddingLength: queryEmbedding.length }) + logger.info('Successfully generated query embedding', { + embeddingLength: queryEmbedding.length, + }) // Search docs embeddings using vector similarity const results = await db @@ -88,13 +90,15 @@ async function searchDocs(params: DocsSearchParams): Promise { // Filter by similarity threshold const filteredResults = results.filter((result) => result.similarity >= similarityThreshold) - const documentationResults: DocumentationSearchResult[] = filteredResults.map((result, index) => ({ - id: index + 1, - title: String(result.headerText || 'Untitled Section'), - url: String(result.sourceLink || '#'), - content: String(result.chunkText || ''), - similarity: result.similarity, - })) + const documentationResults: DocumentationSearchResult[] = filteredResults.map( + (result, index) => ({ + id: index + 1, + title: String(result.headerText || 'Untitled Section'), + url: String(result.sourceLink || '#'), + content: String(result.chunkText || ''), + similarity: result.similarity, + }) + ) logger.info(`Found ${documentationResults.length} documentation results`, { query }) @@ -109,8 +113,10 @@ async function searchDocs(params: DocsSearchParams): Promise { stack: error instanceof Error ? error.stack : undefined, query, errorType: error?.constructor?.name, - status: (error as any)?.status + status: (error as any)?.status, }) - throw new Error(`Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + throw new Error( + `Documentation search failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ) } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/tools/other/online-search.ts b/apps/sim/app/api/copilot/tools/other/online-search.ts index 870041e934a..b14dede4ce7 100644 --- a/apps/sim/app/api/copilot/tools/other/online-search.ts +++ b/apps/sim/app/api/copilot/tools/other/online-search.ts @@ -34,12 +34,12 @@ async function onlineSearch(params: OnlineSearchParams): Promise { +class GetEnvironmentVariablesTool extends BaseCopilotTool< + GetEnvironmentVariablesParams, + EnvironmentVariablesResult +> { readonly id = 'get_environment_variables' readonly displayName = 'Getting environment variables' - protected async executeImpl(params: GetEnvironmentVariablesParams): Promise { + protected async executeImpl( + params: GetEnvironmentVariablesParams + ): Promise { return getEnvironmentVariables(params) } } @@ -26,22 +31,25 @@ class GetEnvironmentVariablesTool extends BaseCopilotTool { +async function getEnvironmentVariables( + params: GetEnvironmentVariablesParams +): Promise { const logger = createLogger('GetEnvironmentVariables') const { userId: directUserId, workflowId } = params - logger.info('Getting environment variables for copilot', { - hasUserId: !!directUserId, - hasWorkflowId: !!workflowId + logger.info('Getting environment variables for copilot', { + hasUserId: !!directUserId, + hasWorkflowId: !!workflowId, }) // Resolve userId from workflowId if needed - const userId = directUserId || (workflowId ? await getUserId('copilot-env-vars', workflowId) : undefined) + const userId = + directUserId || (workflowId ? await getUserId('copilot-env-vars', workflowId) : undefined) - logger.info('Resolved userId', { - directUserId, - workflowId, - resolvedUserId: userId + logger.info('Resolved userId', { + directUserId, + workflowId, + resolvedUserId: userId, }) if (!userId) { @@ -52,13 +60,13 @@ async function getEnvironmentVariables(params: GetEnvironmentVariablesParams): P // Get environment variable keys directly const result = await getEnvironmentVariableKeys(userId) - logger.info('Environment variable keys retrieved', { + logger.info('Environment variable keys retrieved', { userId, - variableCount: result.count + variableCount: result.count, }) return { variableNames: result.variableNames, count: result.count, } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts b/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts index 62ac3b8d9a0..88c31ab7dfb 100644 --- a/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts +++ b/apps/sim/app/api/copilot/tools/user/set-environment-variables.ts @@ -11,11 +11,16 @@ interface SetEnvironmentVariablesResult { count: number } -class SetEnvironmentVariablesTool extends BaseCopilotTool { +class SetEnvironmentVariablesTool extends BaseCopilotTool< + SetEnvironmentVariablesParams, + SetEnvironmentVariablesResult +> { readonly id = 'set_environment_variables' readonly displayName = 'Setting environment variables' - protected async executeImpl(params: SetEnvironmentVariablesParams): Promise { + protected async executeImpl( + params: SetEnvironmentVariablesParams + ): Promise { return setEnvironmentVariables(params) } } @@ -24,18 +29,20 @@ class SetEnvironmentVariablesTool extends BaseCopilotTool { +async function setEnvironmentVariables( + params: SetEnvironmentVariablesParams +): Promise { const logger = createLogger('SetEnvironmentVariables') const { variables } = params - logger.info('Setting environment variables for copilot', { + logger.info('Setting environment variables for copilot', { variableCount: Object.keys(variables).length, variableNames: Object.keys(variables), }) // Forward the request to the existing environment variables endpoint const envUrl = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/api/environment/variables` - + const response = await fetch(envUrl, { method: 'PUT', headers: { @@ -45,9 +52,9 @@ async function setEnvironmentVariables(params: SetEnvironmentVariablesParams): P }) if (!response.ok) { - logger.error('Set environment variables API failed', { - status: response.status, - statusText: response.statusText + logger.error('Set environment variables API failed', { + status: response.status, + statusText: response.statusText, }) throw new Error('Failed to set environment variables') } @@ -59,4 +66,4 @@ async function setEnvironmentVariables(params: SetEnvironmentVariablesParams): P updatedVariables: Object.keys(variables), count: Object.keys(variables).length, } -} \ No newline at end of file +} diff --git a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts index ab2eca3a91a..27c6b774ea7 100644 --- a/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts +++ b/apps/sim/app/api/copilot/tools/workflow/build-workflow.ts @@ -35,7 +35,7 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise() - + Object.keys(blocks).forEach((blockId) => { const previewId = `preview-${Date.now()}-${Math.random().toString(36).substring(2, 7)}` blockIdMapping.set(blockId, previewId) @@ -92,7 +92,7 @@ async function buildWorkflow(params: BuildWorkflowParams): Promise { const { operations, workflowId } = params - logger.info('Processing targeted update request', { + logger.info('Processing targeted update request', { workflowId, - operationCount: operations.length + operationCount: operations.length, }) // Get current workflow YAML directly by calling the function const { getUserWorkflowTool } = await import('./get-user-workflow') - + const getUserWorkflowResult = await getUserWorkflowTool.execute({ workflowId: workflowId, includeMetadata: false, diff --git a/apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts b/apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts index 97f0d4c5b0c..cf8715878aa 100644 --- a/apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts +++ b/apps/sim/app/api/copilot/tools/workflow/get-workflow-console.ts @@ -18,7 +18,10 @@ interface WorkflowConsoleResult { hasBlockDetails: boolean } -class GetWorkflowConsoleTool extends BaseCopilotTool { +class GetWorkflowConsoleTool extends BaseCopilotTool< + GetWorkflowConsoleParams, + WorkflowConsoleResult +> { readonly id = 'get_workflow_console' readonly displayName = 'Getting workflow console' @@ -31,7 +34,9 @@ class GetWorkflowConsoleTool extends BaseCopilotTool { +async function getWorkflowConsole( + params: GetWorkflowConsoleParams +): Promise { const logger = createLogger('GetWorkflowConsole') const { workflowId, limit = 50, includeDetails = false } = params diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 060c99ad95d..7f12a95aca4 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -72,7 +72,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - + userId = authenticatedUserId } diff --git a/apps/sim/app/chat/[subdomain]/components/message/message.tsx b/apps/sim/app/chat/[subdomain]/components/message/message.tsx index 491074f598d..82e2a5d362f 100644 --- a/apps/sim/app/chat/[subdomain]/components/message/message.tsx +++ b/apps/sim/app/chat/[subdomain]/components/message/message.tsx @@ -3,7 +3,6 @@ import { memo, useMemo, useState } from 'react' import { Check, Copy } from 'lucide-react' import { Button } from '@/components/ui/button' -import { ToolCallCompletion, ToolCallExecution } from '@/components/ui/tool-call' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import MarkdownRenderer from './components/markdown-renderer' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx index be0f8367e36..d8d409929f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar.tsx @@ -30,7 +30,6 @@ import { import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' -import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider' @@ -973,8 +972,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) { ) } - - /** * Render control bar toggle button */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx index 34f4a659afc..aabd00b1284 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node.tsx @@ -76,7 +76,8 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() const currentBlock = currentWorkflow.getBlockById(id) - const diffStatus = currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined + const diffStatus = + currentWorkflow.isDiffMode && currentBlock ? (currentBlock as any).is_diff : undefined // Check if this is preview mode const isPreview = data?.isPreview || false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx deleted file mode 100644 index c29b30882de..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-modal/chat-modal.tsx +++ /dev/null @@ -1,254 +0,0 @@ -'use client' - -import { type KeyboardEvent, useEffect, useMemo, useRef } from 'react' -import { ArrowUp, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components/json-view/json-view' -import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' -import { useExecutionStore } from '@/stores/execution/store' -import { useChatStore } from '@/stores/panel/chat/store' -import type { ChatMessage as ChatMessageType } from '@/stores/panel/chat/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -interface ChatMessageProps { - message: ChatMessageType -} - -// ChatGPT-style message component specifically for modal -function ModalChatMessage({ message }: ChatMessageProps) { - // Check if content is a JSON object - const isJsonObject = useMemo(() => { - return typeof message.content === 'object' && message.content !== null - }, [message.content]) - - // For user messages (on the right) - if (message.type === 'user') { - return ( -
    -
    -
    -
    -
    - {isJsonObject ? ( - - ) : ( - {message.content} - )} -
    -
    -
    -
    -
    - ) - } - - // For assistant messages (on the left) - return ( -
    -
    -
    -
    -
    - {isJsonObject ? : {message.content}} -
    -
    -
    -
    -
    - ) -} - -interface ChatModalProps { - open: boolean - onOpenChange: (open: boolean) => void - chatMessage: string - setChatMessage: (message: string) => void -} - -export function ChatModal({ open, onOpenChange, chatMessage, setChatMessage }: ChatModalProps) { - const messagesEndRef = useRef(null) - const messagesContainerRef = useRef(null) - const inputRef = useRef(null) - - const { activeWorkflowId } = useWorkflowRegistry() - const { messages, addMessage, getConversationId } = useChatStore() - - // Use the execution store state to track if a workflow is executing - const { isExecuting } = useExecutionStore() - - // Get workflow execution functionality - const { handleRunWorkflow } = useWorkflowExecution() - - // Get filtered messages for current workflow - const workflowMessages = useMemo(() => { - if (!activeWorkflowId) return [] - return messages - .filter((msg) => msg.workflowId === activeWorkflowId) - .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) - }, [messages, activeWorkflowId]) - - // Auto-scroll to bottom when new messages are added - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, [workflowMessages]) - - // Focus input when modal opens - useEffect(() => { - if (open && inputRef.current) { - inputRef.current.focus() - } - }, [open]) - - // Handle send message - const handleSendMessage = async () => { - if (!chatMessage.trim() || !activeWorkflowId || isExecuting) return - - // Store the message being sent for reference - const sentMessage = chatMessage.trim() - - // Get the conversationId for this workflow before adding the message - const conversationId = getConversationId(activeWorkflowId) - - // Add user message - addMessage({ - content: sentMessage, - workflowId: activeWorkflowId, - type: 'user', - }) - - // Clear input - setChatMessage('') - - // Ensure input stays focused - if (inputRef.current) { - inputRef.current.focus() - } - - // Execute the workflow to generate a response - await handleRunWorkflow({ - input: sentMessage, - conversationId: conversationId, - }) - - // Ensure input stays focused even after response - if (inputRef.current) { - inputRef.current.focus() - } - } - - // Handle key press - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() - } - } - - if (!open) return null - - return ( -
    - - - {/* Header with title and close button */} -
    -

    Chat

    - -
    - - {/* Messages container */} -
    -
    - {workflowMessages.length === 0 ? ( -
    -
    -

    How can I help you today?

    -

    - Ask me anything about your workflow. -

    -
    -
    - ) : ( - workflowMessages.map((message) => ( - - )) - )} - - {/* Loading indicator (shows only when executing) */} - {isExecuting && ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - )} - -
    -
    -
    - - {/* Input area (fixed at bottom) */} -
    -
    -
    - setChatMessage(e.target.value)} - onKeyDown={handleKeyPress} - placeholder='Message...' - className='min-h-[50px] flex-1 rounded-2xl border-0 bg-transparent py-7 pr-16 pl-6 text-base focus-visible:ring-0 focus-visible:ring-offset-0' - disabled={!activeWorkflowId} - /> - -
    - -
    -

    - {activeWorkflowId - ? 'Your messages will be processed by the active workflow' - : 'Select a workflow to start chatting'} -

    -
    -
    -
    -
    - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx index f8b1e050855..7f2ca090dad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/professional-input/professional-input.tsx @@ -1,7 +1,7 @@ 'use client' -import { type FC, type KeyboardEvent, useRef, useState } from 'react' -import { ArrowUp, Loader2, X } from 'lucide-react' +import { type FC, type KeyboardEvent, useEffect, useRef, useState } from 'react' +import { ArrowUp, Loader2, MessageCircle, Package, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' @@ -14,6 +14,8 @@ interface ProfessionalInputProps { isAborting?: boolean placeholder?: string className?: string + mode?: 'ask' | 'agent' + onModeChange?: (mode: 'ask' | 'agent') => void } const ProfessionalInput: FC = ({ @@ -24,21 +26,27 @@ const ProfessionalInput: FC = ({ isAborting = false, placeholder = 'How can I help you today?', className, + mode = 'agent', + onModeChange, }) => { const [message, setMessage] = useState('') const textareaRef = useRef(null) + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current + if (textarea) { + textarea.style.height = 'auto' + textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` // Max height of 120px + } + }, [message]) + const handleSubmit = () => { const trimmedMessage = message.trim() if (!trimmedMessage || disabled || isLoading) return onSubmit(trimmedMessage) setMessage('') - - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto' - } } const handleAbort = () => { @@ -56,69 +64,84 @@ const ProfessionalInput: FC = ({ const handleInputChange = (e: React.ChangeEvent) => { setMessage(e.target.value) - - // Auto-resize textarea - if (textareaRef.current) { - textareaRef.current.style.height = 'auto' - textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px` - } } const canSubmit = message.trim().length > 0 && !disabled && !isLoading const showAbortButton = isLoading && onAbort + const handleModeToggle = () => { + if (onModeChange) { + onModeChange(mode === 'ask' ? 'agent' : 'ask') + } + } + + const getModeIcon = () => { + return mode === 'ask' ? ( + + ) : ( + + ) + } + return ( -
    -
    -
    -
    -