diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index 33b95d5a7..6332971de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -10,7 +10,10 @@ import { ModalHeader, Textarea, } from '@/components/emcn' -import { useUpdateDeploymentVersion } from '@/hooks/queries/deployments' +import { + useGenerateVersionDescription, + useUpdateDeploymentVersion, +} from '@/hooks/queries/deployments' interface VersionDescriptionModalProps { open: boolean @@ -29,14 +32,15 @@ export function VersionDescriptionModal({ versionName, currentDescription, }: VersionDescriptionModalProps) { - // Initialize state from props - component remounts via key prop when version changes const initialDescription = currentDescription || '' const [description, setDescription] = useState(initialDescription) const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const updateMutation = useUpdateDeploymentVersion() + const generateMutation = useGenerateVersionDescription() const hasChanges = description.trim() !== initialDescription.trim() + const isGenerating = generateMutation.isPending const handleCloseAttempt = useCallback(() => { if (hasChanges && !updateMutation.isPending) { @@ -52,6 +56,16 @@ export function VersionDescriptionModal({ onOpenChange(false) }, [initialDescription, onOpenChange]) + const handleGenerateDescription = useCallback(() => { + generateMutation.mutate({ + workflowId, + version, + onStreamChunk: (accumulated) => { + setDescription(accumulated) + }, + }) + }, [workflowId, version, generateMutation]) + const handleSave = useCallback(async () => { if (!workflowId) return @@ -76,26 +90,36 @@ export function VersionDescriptionModal({ Version Description - - - {currentDescription ? 'Edit the' : 'Add a'} description for{' '} - {versionName} - + + + + {currentDescription ? 'Edit the' : 'Add a'} description for{' '} + {versionName} + + + {isGenerating ? 'Generating...' : 'Generate'} + + setDescription(e.target.value)} maxLength={500} + disabled={isGenerating} /> - {updateMutation.error ? ( + {(updateMutation.error || generateMutation.error) && ( - {updateMutation.error.message} + {updateMutation.error?.message || generateMutation.error?.message} - ) : ( - )} + {!updateMutation.error && !generateMutation.error && } {description.length}/500 @@ -103,14 +127,14 @@ export function VersionDescriptionModal({ Cancel {updateMutation.isPending ? 'Saving...' : 'Save'} diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index e48858bcb..c645336a6 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -411,6 +411,147 @@ export function useUpdateDeploymentVersion() { }) } +/** + * Variables for generating a version description + */ +interface GenerateVersionDescriptionVariables { + workflowId: string + version: number + onStreamChunk?: (accumulated: string) => void +} + +const VERSION_DESCRIPTION_SYSTEM_PROMPT = `You are a technical writer generating concise deployment version descriptions. + +Given a diff of changes between two workflow versions, write a brief, factual description (1-2 sentences, under 300 characters) that states ONLY what changed. + +RULES: +- State specific values when provided (e.g. "model changed from X to Y") +- Do NOT wrap your response in quotes +- Do NOT add filler phrases like "streamlining the workflow", "for improved efficiency" +- Do NOT use markdown formatting +- Do NOT include version numbers +- Do NOT start with "This version" or similar phrases + +Good examples: +- Changes model in Agent 1 from gpt-4o to claude-sonnet-4-20250514. +- Adds Slack notification block. Updates webhook URL to production endpoint. +- Removes Function block and its connection to Router. + +Bad examples: +- "Changes model..." (NO - don't wrap in quotes) +- Changes model, streamlining the workflow. (NO - don't add filler) + +Respond with ONLY the plain text description.` + +/** + * Hook for generating a version description using AI based on workflow diff + */ +export function useGenerateVersionDescription() { + return useMutation({ + mutationFn: async ({ + workflowId, + version, + onStreamChunk, + }: GenerateVersionDescriptionVariables): Promise => { + const { generateWorkflowDiffSummary, formatDiffSummaryForDescription } = await import( + '@/lib/workflows/comparison/compare' + ) + + const currentResponse = await fetch(`/api/workflows/${workflowId}/deployments/${version}`) + if (!currentResponse.ok) { + throw new Error('Failed to fetch current version state') + } + const currentData = await currentResponse.json() + const currentState = currentData.deployedState + + let previousState = null + if (version > 1) { + const previousResponse = await fetch( + `/api/workflows/${workflowId}/deployments/${version - 1}` + ) + if (previousResponse.ok) { + const previousData = await previousResponse.json() + previousState = previousData.deployedState + } + } + + const diffSummary = generateWorkflowDiffSummary(currentState, previousState) + const diffText = formatDiffSummaryForDescription(diffSummary) + + const wandResponse = await fetch('/api/wand', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-transform', + }, + body: JSON.stringify({ + prompt: `Generate a deployment version description based on these changes:\n\n${diffText}`, + systemPrompt: VERSION_DESCRIPTION_SYSTEM_PROMPT, + stream: true, + workflowId, + }), + cache: 'no-store', + }) + + if (!wandResponse.ok) { + const errorText = await wandResponse.text() + throw new Error(errorText || 'Failed to generate description') + } + + if (!wandResponse.body) { + throw new Error('Response body is null') + } + + const reader = wandResponse.body.getReader() + const decoder = new TextDecoder() + let accumulatedContent = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value) + const lines = chunk.split('\n\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const lineData = line.substring(6) + if (lineData === '[DONE]') continue + + try { + const data = JSON.parse(lineData) + if (data.error) throw new Error(data.error) + if (data.chunk) { + accumulatedContent += data.chunk + onStreamChunk?.(accumulatedContent) + } + if (data.done) break + } catch { + // Skip unparseable lines + } + } + } + } + } finally { + reader.releaseLock() + } + + if (!accumulatedContent) { + throw new Error('Failed to generate description') + } + + return accumulatedContent.trim() + }, + onSuccess: (content) => { + logger.info('Generated version description', { length: content.length }) + }, + onError: (error) => { + logger.error('Failed to generate version description', { error }) + }, + }) +} + /** * Variables for activate version mutation */ diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index c8abdd439..fc8405b96 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -275,3 +275,291 @@ export function hasWorkflowChanged( return false } + +/** + * Represents a single field change with old and new values + */ +export interface FieldChange { + field: string + oldValue: unknown + newValue: unknown +} + +/** + * Result of workflow diff analysis between two workflow states + */ +export interface WorkflowDiffSummary { + addedBlocks: Array<{ id: string; type: string; name?: string }> + removedBlocks: Array<{ id: string; type: string; name?: string }> + modifiedBlocks: Array<{ id: string; type: string; name?: string; changes: FieldChange[] }> + edgeChanges: { added: number; removed: number } + loopChanges: { added: number; removed: number } + parallelChanges: { added: number; removed: number } + variableChanges: { added: number; removed: number; modified: number } + hasChanges: boolean +} + +/** + * Generate a detailed diff summary between two workflow states + */ +export function generateWorkflowDiffSummary( + currentState: WorkflowState, + previousState: WorkflowState | null +): WorkflowDiffSummary { + const result: WorkflowDiffSummary = { + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0 }, + loopChanges: { added: 0, removed: 0 }, + parallelChanges: { added: 0, removed: 0 }, + variableChanges: { added: 0, removed: 0, modified: 0 }, + hasChanges: false, + } + + if (!previousState) { + const currentBlocks = currentState.blocks || {} + for (const [id, block] of Object.entries(currentBlocks)) { + result.addedBlocks.push({ + id, + type: block.type, + name: block.name, + }) + } + result.edgeChanges.added = (currentState.edges || []).length + result.loopChanges.added = Object.keys(currentState.loops || {}).length + result.parallelChanges.added = Object.keys(currentState.parallels || {}).length + result.variableChanges.added = Object.keys(currentState.variables || {}).length + result.hasChanges = true + return result + } + + const currentBlocks = currentState.blocks || {} + const previousBlocks = previousState.blocks || {} + const currentBlockIds = new Set(Object.keys(currentBlocks)) + const previousBlockIds = new Set(Object.keys(previousBlocks)) + + for (const id of currentBlockIds) { + if (!previousBlockIds.has(id)) { + const block = currentBlocks[id] + result.addedBlocks.push({ + id, + type: block.type, + name: block.name, + }) + } + } + + for (const id of previousBlockIds) { + if (!currentBlockIds.has(id)) { + const block = previousBlocks[id] + result.removedBlocks.push({ + id, + type: block.type, + name: block.name, + }) + } + } + + for (const id of currentBlockIds) { + if (!previousBlockIds.has(id)) continue + + const currentBlock = currentBlocks[id] as BlockWithDiffMarkers + const previousBlock = previousBlocks[id] as BlockWithDiffMarkers + const changes: FieldChange[] = [] + + if (currentBlock.name !== previousBlock.name) { + changes.push({ field: 'name', oldValue: previousBlock.name, newValue: currentBlock.name }) + } + if (currentBlock.enabled !== previousBlock.enabled) { + changes.push({ + field: 'enabled', + oldValue: previousBlock.enabled, + newValue: currentBlock.enabled, + }) + } + + const currentSubBlocks = currentBlock.subBlocks || {} + const previousSubBlocks = previousBlock.subBlocks || {} + const allSubBlockIds = new Set([ + ...Object.keys(currentSubBlocks), + ...Object.keys(previousSubBlocks), + ]) + + for (const subId of allSubBlockIds) { + if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subId) || SYSTEM_SUBBLOCK_IDS.includes(subId)) { + continue + } + + const currentSub = currentSubBlocks[subId] + const previousSub = previousSubBlocks[subId] + + if (!currentSub || !previousSub) { + changes.push({ + field: subId, + oldValue: previousSub?.value ?? null, + newValue: currentSub?.value ?? null, + }) + continue + } + + const currentValue = normalizeValue(currentSub.value ?? null) + const previousValue = normalizeValue(previousSub.value ?? null) + + if (normalizedStringify(currentValue) !== normalizedStringify(previousValue)) { + changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value }) + } + } + + if (changes.length > 0) { + result.modifiedBlocks.push({ + id, + type: currentBlock.type, + name: currentBlock.name, + changes, + }) + } + } + + const currentEdges = (currentState.edges || []).map(normalizeEdge) + const previousEdges = (previousState.edges || []).map(normalizeEdge) + const currentEdgeSet = new Set(currentEdges.map(normalizedStringify)) + const previousEdgeSet = new Set(previousEdges.map(normalizedStringify)) + + for (const edge of currentEdgeSet) { + if (!previousEdgeSet.has(edge)) result.edgeChanges.added++ + } + for (const edge of previousEdgeSet) { + if (!currentEdgeSet.has(edge)) result.edgeChanges.removed++ + } + + const currentLoopIds = Object.keys(currentState.loops || {}) + const previousLoopIds = Object.keys(previousState.loops || {}) + result.loopChanges.added = currentLoopIds.filter((id) => !previousLoopIds.includes(id)).length + result.loopChanges.removed = previousLoopIds.filter((id) => !currentLoopIds.includes(id)).length + + const currentParallelIds = Object.keys(currentState.parallels || {}) + const previousParallelIds = Object.keys(previousState.parallels || {}) + result.parallelChanges.added = currentParallelIds.filter( + (id) => !previousParallelIds.includes(id) + ).length + result.parallelChanges.removed = previousParallelIds.filter( + (id) => !currentParallelIds.includes(id) + ).length + + const currentVars = currentState.variables || {} + const previousVars = previousState.variables || {} + const currentVarIds = Object.keys(currentVars) + const previousVarIds = Object.keys(previousVars) + + result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length + result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length + + for (const id of currentVarIds) { + if (!previousVarIds.includes(id)) continue + const currentVar = normalizeValue(sanitizeVariable(currentVars[id])) + const previousVar = normalizeValue(sanitizeVariable(previousVars[id])) + if (normalizedStringify(currentVar) !== normalizedStringify(previousVar)) { + result.variableChanges.modified++ + } + } + + result.hasChanges = + result.addedBlocks.length > 0 || + result.removedBlocks.length > 0 || + result.modifiedBlocks.length > 0 || + result.edgeChanges.added > 0 || + result.edgeChanges.removed > 0 || + result.loopChanges.added > 0 || + result.loopChanges.removed > 0 || + result.parallelChanges.added > 0 || + result.parallelChanges.removed > 0 || + result.variableChanges.added > 0 || + result.variableChanges.removed > 0 || + result.variableChanges.modified > 0 + + return result +} + +function formatValueForDisplay(value: unknown): string { + if (value === null || value === undefined) return '(none)' + if (typeof value === 'string') { + if (value.length > 50) return `${value.slice(0, 50)}...` + return value || '(empty)' + } + if (typeof value === 'boolean') return value ? 'enabled' : 'disabled' + if (typeof value === 'number') return String(value) + if (Array.isArray(value)) return `[${value.length} items]` + if (typeof value === 'object') return `${JSON.stringify(value).slice(0, 50)}...` + return String(value) +} + +/** + * Convert a WorkflowDiffSummary to a human-readable string for AI description generation + */ +export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): string { + if (!summary.hasChanges) { + return 'No structural changes detected (configuration may have changed)' + } + + const changes: string[] = [] + + for (const block of summary.addedBlocks) { + const name = block.name || block.type + changes.push(`Added block: ${name} (${block.type})`) + } + + for (const block of summary.removedBlocks) { + const name = block.name || block.type + changes.push(`Removed block: ${name} (${block.type})`) + } + + for (const block of summary.modifiedBlocks) { + const name = block.name || block.type + for (const change of block.changes.slice(0, 3)) { + const oldStr = formatValueForDisplay(change.oldValue) + const newStr = formatValueForDisplay(change.newValue) + changes.push(`Modified ${name}: ${change.field} changed from "${oldStr}" to "${newStr}"`) + } + if (block.changes.length > 3) { + changes.push(` ...and ${block.changes.length - 3} more changes in ${name}`) + } + } + + if (summary.edgeChanges.added > 0) { + changes.push(`Added ${summary.edgeChanges.added} connection(s)`) + } + if (summary.edgeChanges.removed > 0) { + changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`) + } + + if (summary.loopChanges.added > 0) { + changes.push(`Added ${summary.loopChanges.added} loop(s)`) + } + if (summary.loopChanges.removed > 0) { + changes.push(`Removed ${summary.loopChanges.removed} loop(s)`) + } + + if (summary.parallelChanges.added > 0) { + changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`) + } + if (summary.parallelChanges.removed > 0) { + changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`) + } + + const varChanges: string[] = [] + if (summary.variableChanges.added > 0) { + varChanges.push(`${summary.variableChanges.added} added`) + } + if (summary.variableChanges.removed > 0) { + varChanges.push(`${summary.variableChanges.removed} removed`) + } + if (summary.variableChanges.modified > 0) { + varChanges.push(`${summary.variableChanges.modified} modified`) + } + if (varChanges.length > 0) { + changes.push(`Variables: ${varChanges.join(', ')}`) + } + + return changes.join('\n') +}
- {currentDescription ? 'Edit the' : 'Add a'} description for{' '} - {versionName} -
+ {currentDescription ? 'Edit the' : 'Add a'} description for{' '} + {versionName} +
- {updateMutation.error.message} + {updateMutation.error?.message || generateMutation.error?.message}
{description.length}/500