add wand to generate diff

This commit is contained in:
waleed
2026-01-28 11:51:51 -08:00
parent 43f7f3bc00
commit 242710f4d7
3 changed files with 466 additions and 13 deletions

View File

@@ -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({
<ModalHeader>
<span>Version Description</span>
</ModalHeader>
<ModalBody className='space-y-[12px]'>
<p className='text-[12px] text-[var(--text-secondary)]'>
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
</p>
<ModalBody className='space-y-[10px]'>
<div className='flex items-center justify-between'>
<p className='text-[12px] text-[var(--text-secondary)]'>
{currentDescription ? 'Edit the' : 'Add a'} description for{' '}
<span className='font-medium text-[var(--text-primary)]'>{versionName}</span>
</p>
<Button
variant='active'
className='-my-1 h-5 px-2 py-0 text-[11px]'
onClick={handleGenerateDescription}
disabled={isGenerating || updateMutation.isPending}
>
{isGenerating ? 'Generating...' : 'Generate'}
</Button>
</div>
<Textarea
placeholder='Describe the changes in this deployment version...'
className='min-h-[120px] resize-none'
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={500}
disabled={isGenerating}
/>
<div className='flex items-center justify-between'>
{updateMutation.error ? (
{(updateMutation.error || generateMutation.error) && (
<p className='text-[12px] text-[var(--text-error)]'>
{updateMutation.error.message}
{updateMutation.error?.message || generateMutation.error?.message}
</p>
) : (
<div />
)}
{!updateMutation.error && !generateMutation.error && <div />}
<p className='text-[11px] text-[var(--text-tertiary)]'>{description.length}/500</p>
</div>
</ModalBody>
@@ -103,14 +127,14 @@ export function VersionDescriptionModal({
<Button
variant='default'
onClick={handleCloseAttempt}
disabled={updateMutation.isPending}
disabled={updateMutation.isPending || isGenerating}
>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleSave}
disabled={updateMutation.isPending || !hasChanges}
disabled={updateMutation.isPending || isGenerating || !hasChanges}
>
{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>

View File

@@ -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<string> => {
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
*/

View File

@@ -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')
}