feat(flow): Add graph diff visualizer (#6948)

* graph mode

* show colors

* show module diff viewer button

* better diff logic

* small width merge graph diff

* invert logic

* simplify

* better logic

* put removed modules in initial position

* nit

* nit

* fix conflicting ids

* fix

* add shadowed for after

* better position logic

* fix

* cleaning

* use splitpanes

* add toggle

* fix

* sync move

* icons

* handle zoom

* left header snippet

* cleaning

* cleaning

* remove stats

* big cleaning

* fix

* fix

* fix

* remove not working logic

* invert logic

* nit

* use in deploymentui

* fix typo

* no custom style

* handle nested

* simpler logic

* fix

* fix
This commit is contained in:
centdix
2025-11-01 15:17:56 +01:00
committed by GitHub
parent b5e341fde7
commit 04d2ef419d
15 changed files with 1137 additions and 62 deletions

View File

@@ -60,6 +60,7 @@
let diffDrawer: DiffDrawer | undefined = $state(undefined)
let notSet: boolean | undefined = $state(undefined)
let isFlow: boolean | undefined = $state(undefined)
async function reload(path: string) {
try {
@@ -638,7 +639,7 @@
{:else if seeTarget == true}
<h3 class="mb-6 mt-16">All related deployable items</h3>
<DiffDrawer bind:this={diffDrawer} />
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<div class="grid grid-cols-9 justify-center max-w-3xl gap-2">
{#each dependencies ?? [] as { kind, path, include }, i}
{@const statusPath = computeStatusPath(kind, path)}
@@ -683,6 +684,7 @@
class="text-blue-600 font-normal mt-1"
onclick={() => {
showDiff(kind, path)
isFlow = kind === 'flow'
}}>diff</button
>
{/if}</div

View File

@@ -20,6 +20,7 @@
}
let diffType: 'draft' | 'deployed' | 'custom' | undefined = $state(undefined)
let flowdiffMode: 'yaml' | 'graph' = $state('yaml')
let contentType = $derived.by(() => {
if (!data || !diffType) return undefined
@@ -30,14 +31,16 @@
? 'metadata'
: undefined
})
let diffViewer: Drawer | undefined = $state(undefined)
interface Props {
restoreDeployed?: () => Promise<void>
restoreDraft?: () => Promise<void>
isFlow?: boolean
}
let { restoreDeployed = async () => {}, restoreDraft = async () => {} }: Props = $props()
let { restoreDeployed = undefined, restoreDraft = undefined, isFlow = false }: Props = $props()
let data:
| {
@@ -158,7 +161,7 @@
unifiedSize="md"
variant="default"
wrapperClasses="self-start"
on:click={restoreDraft}
onClick={restoreDraft}
disabled={orderedJsonStringify(data.draft) === orderedJsonStringify(data.current)}
>Restore to latest saved draft</Button
>
@@ -167,7 +170,7 @@
unifiedSize="md"
variant="default"
wrapperClasses="self-start"
on:click={restoreDeployed}
onClick={restoreDeployed}
disabled={!data.draft &&
orderedJsonStringify(data.deployed) === orderedJsonStringify(data.current)}
>
@@ -228,19 +231,50 @@
/>
{/await}
{:else if contentType === 'metadata'}
{#await import('$lib/components/DiffEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={metadata}
defaultModified={data.current.metadata}
readOnly
/>
{/await}
{#if isFlow}
<Tabs bind:selected={flowdiffMode}>
<Tab value="yaml" label={`YAML`} />
<Tab value="graph" label={`Graph`} />
</Tabs>
{#if flowdiffMode === 'yaml'}
{#await import('$lib/components/DiffEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={metadata}
defaultModified={data.current.metadata}
readOnly
/>
{/await}
{:else if flowdiffMode === 'graph'}
{#await import('$lib/components/FlowGraphDiffViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
beforeYaml={metadata ?? ''}
afterYaml={data.current.metadata}
/>
{/await}
{/if}
{:else}
{#await import('$lib/components/DiffEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
open={true}
automaticLayout
className="h-full"
defaultLang="yaml"
defaultOriginal={metadata}
defaultModified={data.current.metadata}
readOnly
/>
{/await}
{/if}
{/if}
{/key}
</div>
@@ -264,7 +298,7 @@
{#if data?.button}
<Button
variant="subtle"
on:click={() => {
onClick={() => {
if (data?.button) {
data.button.onClick()
diffViewer?.closeDrawer()

View File

@@ -0,0 +1,288 @@
<script lang="ts">
import type { FlowModule, FlowValue, OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { buildFlowTimeline, hasInputSchemaChanged } from './flows/flowDiff'
import { dfs } from './flows/dfs'
import DiffDrawer from './DiffDrawer.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte'
import type { Viewport } from '@xyflow/svelte'
const SIDE_BY_SIDE_MIN_WIDTH = 700
interface Props {
beforeYaml: string
afterYaml: string
}
let { beforeYaml, afterYaml }: Props = $props()
let parseError = $state<string | undefined>(undefined)
let moduleDiffDrawer: DiffDrawer | undefined = $state(undefined)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
// Shared viewport for synchronizing both graphs in side-by-side mode
let sharedViewport = $state<Viewport>({ x: 0, y: 0, zoom: 1 })
let beforeGraph: FlowGraphV2 | undefined = $state(undefined)
let afterGraph: FlowGraphV2 | undefined = $state(undefined)
let beforeFlow: OpenFlow | undefined = $derived.by(() => {
try {
const parsed = YAML.parse(beforeYaml)
return parsed as OpenFlow
} catch (error) {
parseError = `Error parsing before flow: ${error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'}`
return undefined
}
})
let afterFlow: OpenFlow | undefined = $derived.by(() => {
try {
const parsed = YAML.parse(afterYaml)
return parsed as OpenFlow
} catch (error) {
parseError = `Error parsing after flow: ${error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'}`
return undefined
}
})
// Detect if input schema has changed
let inputSchemaModified = $derived(hasInputSchemaChanged(beforeFlow, afterFlow))
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
// In unified view, mark removed modules as 'removed' to show them in red
let timeline = $derived.by(() => {
if (!beforeFlow || !afterFlow) return undefined
return buildFlowTimeline(beforeFlow.value, afterFlow.value, {
markRemovedAsShadowed: isSideBySide
})
})
// Extract merged flow from timeline
let mergedFlow = $derived(timeline?.mergedFlow)
// Get the unified actions directly from timeline
let unifiedActions = $derived(timeline?.afterActions ?? {})
// Helper to find module by ID in a flow
function getModuleById(flow: FlowValue, moduleId: string): FlowModule | undefined {
const allModules = dfs(flow.modules ?? [], (m) => m)
return (
allModules.find((m) => m?.id === moduleId) ??
(flow.failure_module?.id === moduleId ? flow.failure_module : undefined) ??
(flow.preprocessor_module?.id === moduleId ? flow.preprocessor_module : undefined)
)
}
// Handler for viewport changes - updates shared state for synchronization
function handleViewportChange(viewport: Viewport, isUserInitiated: boolean) {
if (isUserInitiated) {
sharedViewport = viewport
}
}
// Callback to show module diff
function handleShowModuleDiff(moduleId: string) {
if (!beforeFlow || !afterFlow) return
// Handle special case for Input schema diff
if (moduleId === 'Input') {
moduleDiffDrawer?.openDrawer()
moduleDiffDrawer?.setDiff({
mode: 'simple',
title: 'Flow Input Schema Diff',
original: { schema: beforeFlow.schema ?? {} },
current: { schema: afterFlow.schema ?? {} }
})
return
}
const beforeModule = getModuleById(beforeFlow.value, moduleId)
const afterModule = getModuleById(afterFlow.value, moduleId)
if (beforeModule && afterModule) {
moduleDiffDrawer?.openDrawer()
moduleDiffDrawer?.setDiff({
mode: 'simple',
title: `Module Diff: ${moduleId}`,
original: beforeModule,
current: afterModule
})
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
} else {
viewMode = 'sidebyside'
}
})
</script>
{#if parseError}
<Alert type="error" title="Parse Error">
{parseError}
</Alert>
{:else if beforeFlow && afterFlow}
<div class="h-full flex flex-col" bind:clientWidth={viewerWidth}>
<!-- Header with view toggle -->
<div class="flex flex-row items-center justify-end m-2 gap-4">
<div>
<ToggleButtonGroup bind:selected={viewMode}>
{#snippet children({ item })}
<ToggleButton {item} value="unified" label="Unified" icon={DiffIcon} />
<ToggleButton
{item}
value="sidebyside"
label="Side by Side"
icon={SquareSplitHorizontal}
/>
{/snippet}
</ToggleButtonGroup>
</div>
<!-- Header with controls and view toggle -->
{#if isSideBySide}
<!-- Shared controls for both graphs in side-by-side mode -->
<div class="flex">
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
beforeGraph?.zoomIn()
afterGraph?.zoomIn()
}}
iconOnly
startIcon={{ icon: Plus }}
/>
<Button
size="xs"
color="light"
variant="border"
onClick={() => {
beforeGraph?.zoomOut()
afterGraph?.zoomOut()
}}
iconOnly
startIcon={{ icon: Minus }}
/>
</div>
{/if}
</div>
<!-- Main content area -->
<div class="flex-1 overflow-hidden">
{#if isSideBySide}
<!-- Side-by-side view for wide screens -->
<Splitpanes class="!overflow-visible h-full">
<!-- Before (Left) -->
<Pane bind:size={beforePaneSize} minSize={30}>
<div class="flex flex-col h-full border-r border-gray-200 dark:border-gray-700">
<div class="flex-1 overflow-hidden">
<FlowGraphV2
bind:this={beforeGraph}
modules={beforeFlow.value.modules}
failureModule={beforeFlow.value.failure_module}
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={timeline?.beforeActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">Before</span>
{/snippet}
</FlowGraphV2>
</div>
</div>
</Pane>
<!-- After (Right) - Show merged flow with shadowed removed modules -->
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
{#if mergedFlow}
<FlowGraphV2
bind:this={afterGraph}
modules={mergedFlow.modules}
failureModule={mergedFlow.failure_module}
preprocessorModule={mergedFlow.preprocessor_module}
earlyStop={mergedFlow.skip_expr !== undefined}
cache={mergedFlow.cache_ttl !== undefined}
moduleActions={unifiedActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
{/if}
</div>
</div>
</Pane>
</Splitpanes>
{:else}
<!-- Unified view for narrow screens - show merged flow with all diff colors -->
{#if mergedFlow}
<div class="h-full overflow-hidden">
<FlowGraphV2
modules={mergedFlow.modules}
failureModule={mergedFlow.failure_module}
preprocessorModule={mergedFlow.preprocessor_module}
earlyStop={mergedFlow.skip_expr !== undefined}
cache={mergedFlow.cache_ttl !== undefined}
moduleActions={unifiedActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
/>
</div>
{/if}
{/if}
</div>
<!-- Nested DiffDrawer for module-level diffs -->
<DiffDrawer bind:this={moduleDiffDrawer} />
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-gray-500">Loading graphs...</p>
</div>
{/if}

View File

@@ -28,7 +28,7 @@ import {
import type { ContextElement } from '../context'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
export type AIModuleAction = 'added' | 'modified' | 'removed'
export type AIModuleAction = 'added' | 'modified' | 'removed' | 'shadowed' | undefined
export interface FlowAIChatHelpers {
// flow context

View File

@@ -92,6 +92,8 @@ export function aiModuleActionToBgColor(action: AIModuleAction | undefined) {
return '!bg-green-200 dark:!bg-green-800'
case 'removed':
return '!bg-red-200/50 dark:!bg-red-800/50'
case 'shadowed':
return '!bg-gray-200/30 dark:!bg-gray-800/30 !opacity-50'
default:
return ''
}
@@ -104,6 +106,8 @@ export function aiModuleActionToBorderColor(action: AIModuleAction | undefined)
return '!border-green-400 dark:!border-green-700'
case 'removed':
return '!border-red-300 dark:!border-red-700'
case 'shadowed':
return '!border-gray-300 dark:!border-gray-600'
default:
return ''
}
@@ -117,6 +121,8 @@ export function aiModuleActionToTextColor(action: AIModuleAction | undefined) {
return '!text-green-800 dark:!text-green-200'
case 'removed':
return '!text-red-800 dark:!text-red-200'
case 'shadowed':
return '!text-gray-600 dark:!text-gray-400'
default:
return ''
}

View File

@@ -0,0 +1,573 @@
import type { FlowModule, FlowValue } from '$lib/gen'
import { dfs } from './dfs'
import { deepEqual } from 'fast-equals'
import type { AIModuleAction } from '../copilot/chat/flow/core'
/**
* The complete diff result with action maps and merged flow
*/
export type FlowTimeline = {
/** Actions for modules in the before flow */
beforeActions: Record<string, AIModuleAction>
/** Actions for modules in the after flow (adjusted based on display mode) */
afterActions: Record<string, AIModuleAction>
/** The merged flow containing both after modules and removed modules properly nested */
mergedFlow: FlowValue
}
/**
* Computes the difference between two flow versions and returns a map of module IDs to their actions.
*
* When a module exists in both flows but has a different type, it's treated as removed + added
* rather than modified, since it's effectively a completely different module.
*
* @param beforeFlow - The original flow value
* @param afterFlow - The modified flow value
* @returns A record mapping module IDs to their diff result with separate before/after actions
*/
export function computeFlowModuleDiff(
beforeFlow: FlowValue,
afterFlow: FlowValue
): { beforeActions: Record<string, AIModuleAction>; afterActions: Record<string, AIModuleAction> } {
const beforeActions: Record<string, AIModuleAction> = {}
const afterActions: Record<string, AIModuleAction> = {}
// Get all modules from both flows using dfs
const beforeModules = getAllModulesMap(beforeFlow)
const afterModules = getAllModulesMap(afterFlow)
// Find all module IDs
const allModuleIds = new Set([...beforeModules.keys(), ...afterModules.keys()])
for (const moduleId of allModuleIds) {
const beforeModule = beforeModules.get(moduleId)
const afterModule = afterModules.get(moduleId)
if (!beforeModule && afterModule) {
// Module exists in after but not before -> added
afterActions[moduleId] = 'added'
} else if (beforeModule && !afterModule) {
// Module exists in before but not after -> removed
beforeActions[moduleId] = 'removed'
afterActions[moduleId] = 'shadowed'
} else if (beforeModule && afterModule) {
// Module exists in both -> check type and content
const typeChanged = beforeModule.value.type !== afterModule.value.type
if (typeChanged) {
// Type changed -> treat as removed + added
beforeActions[moduleId] = 'removed'
afterActions[moduleId] = 'added'
} else if (!deepEqual(beforeModule, afterModule)) {
// Same type but different content -> modified
beforeActions[moduleId] = 'modified'
afterActions[moduleId] = 'modified'
}
}
}
return { beforeActions, afterActions }
}
/**
* Helper function to get all modules from a flow as a Map
*/
function getAllModulesMap(flow: FlowValue): Map<string, FlowModule> {
const moduleMap = new Map<string, FlowModule>()
// Get all regular modules
const allModules = dfs(flow.modules ?? [], (m) => m)
for (const module of allModules) {
if (module?.id) {
moduleMap.set(module.id, module)
}
}
// Add failure module if it exists
if (flow.failure_module?.id) {
moduleMap.set(flow.failure_module.id, flow.failure_module)
}
// Add preprocessor module if it exists
if (flow.preprocessor_module?.id) {
moduleMap.set(flow.preprocessor_module.id, flow.preprocessor_module)
}
return moduleMap
}
/**
* Represents the parent location of a module
*/
type ModuleParentLocation =
| { type: 'root'; index: number }
| { type: 'forloop' | 'whileloop'; parentId: string; index: number }
| { type: 'branchone-default'; parentId: string; index: number }
| { type: 'branchone-branch'; parentId: string; branchIndex: number; index: number }
| { type: 'branchall-branch'; parentId: string; branchIndex: number; index: number }
| { type: 'aiagent'; parentId: string; index: number }
| { type: 'failure'; index: -1 }
| { type: 'preprocessor'; index: -1 }
/**
* Finds the parent location of a module in a flow
*/
function findModuleParent(flow: FlowValue, moduleId: string): ModuleParentLocation | null {
// Check special modules
if (flow.failure_module?.id === moduleId) {
return { type: 'failure', index: -1 }
}
if (flow.preprocessor_module?.id === moduleId) {
return { type: 'preprocessor', index: -1 }
}
// Check root level
const rootIndex = flow.modules?.findIndex((m) => m.id === moduleId)
if (rootIndex !== undefined && rootIndex >= 0) {
return { type: 'root', index: rootIndex }
}
// Recursively search nested modules
function searchInModules(modules: FlowModule[]): ModuleParentLocation | null {
for (const module of modules) {
// Check forloopflow
if (module.value.type === 'forloopflow') {
const index = module.value.modules.findIndex((m) => m.id === moduleId)
if (index >= 0) {
return { type: 'forloop', parentId: module.id, index }
}
const nested = searchInModules(module.value.modules)
if (nested) return nested
}
// Check whileloopflow
if (module.value.type === 'whileloopflow') {
const index = module.value.modules.findIndex((m) => m.id === moduleId)
if (index >= 0) {
return { type: 'whileloop', parentId: module.id, index }
}
const nested = searchInModules(module.value.modules)
if (nested) return nested
}
// Check branchone
if (module.value.type === 'branchone') {
// Check default branch
const defaultIndex = module.value.default.findIndex((m) => m.id === moduleId)
if (defaultIndex >= 0) {
return { type: 'branchone-default', parentId: module.id, index: defaultIndex }
}
const nestedDefault = searchInModules(module.value.default)
if (nestedDefault) return nestedDefault
// Check other branches
for (let branchIndex = 0; branchIndex < module.value.branches.length; branchIndex++) {
const branch = module.value.branches[branchIndex]
const index = branch.modules.findIndex((m) => m.id === moduleId)
if (index >= 0) {
return { type: 'branchone-branch', parentId: module.id, branchIndex, index }
}
const nested = searchInModules(branch.modules)
if (nested) return nested
}
}
// Check branchall
if (module.value.type === 'branchall') {
for (let branchIndex = 0; branchIndex < module.value.branches.length; branchIndex++) {
const branch = module.value.branches[branchIndex]
const index = branch.modules.findIndex((m) => m.id === moduleId)
if (index >= 0) {
return { type: 'branchall-branch', parentId: module.id, branchIndex, index }
}
const nested = searchInModules(branch.modules)
if (nested) return nested
}
}
// Check aiagent
if (module.value.type === 'aiagent' && module.value.tools) {
const index = (module.value.tools as FlowModule[]).findIndex((m) => m.id === moduleId)
if (index >= 0) {
return { type: 'aiagent', parentId: module.id, index }
}
const nested = searchInModules(module.value.tools as FlowModule[])
if (nested) return nested
}
}
return null
}
return searchInModules(flow.modules ?? [])
}
/**
* Deep clones a module to avoid mutation
*/
function cloneModule(module: FlowModule): FlowModule {
return JSON.parse(JSON.stringify(module))
}
/**
* Prepends a prefix to a module's ID to avoid collisions
*/
function prependModuleId(module: FlowModule, prefix: string): FlowModule {
const newModule = cloneModule(module)
newModule.id = prefix + newModule.id
return newModule
}
/**
* Collects all module IDs from a flow structure recursively
*/
function getAllModuleIds(flow: FlowValue): Set<string> {
const ids = new Set<string>()
function collectFromModules(modules: FlowModule[]): void {
for (const module of modules) {
if (module.id) {
ids.add(module.id)
}
// Recursively collect from nested modules
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
collectFromModules(module.value.modules)
} else if (module.value.type === 'branchone') {
collectFromModules(module.value.default)
for (const branch of module.value.branches) {
collectFromModules(branch.modules)
}
} else if (module.value.type === 'branchall') {
for (const branch of module.value.branches) {
collectFromModules(branch.modules)
}
} else if (module.value.type === 'aiagent' && module.value.tools) {
collectFromModules(module.value.tools as FlowModule[])
}
}
}
// Collect from root modules
if (flow.modules) {
collectFromModules(flow.modules)
}
// Collect from special modules
if (flow.failure_module?.id) {
ids.add(flow.failure_module.id)
}
if (flow.preprocessor_module?.id) {
ids.add(flow.preprocessor_module.id)
}
return ids
}
/**
* Reconstructs the merged flow with removed modules properly nested
*/
function reconstructMergedFlow(
afterFlow: FlowValue,
beforeFlow: FlowValue,
beforeActions: Record<string, AIModuleAction>
): FlowValue {
// Deep clone afterFlow to avoid mutation
const merged: FlowValue = JSON.parse(JSON.stringify(afterFlow))
// Get all removed/shadowed modules from beforeFlow
const removedModules = Object.entries(beforeActions)
.filter(([_, action]) => action === 'removed' || action === 'shadowed')
.map(([id]) => id)
// Create a Set for faster lookup
const removedModulesSet = new Set(removedModules)
// For each removed module, find its parent and insert it
for (const removedId of removedModules) {
const beforeModule = getAllModulesMap(beforeFlow).get(removedId)
if (!beforeModule) continue
const parentLocation = findModuleParent(beforeFlow, removedId)
if (!parentLocation) continue
// Skip if parent is also removed - the module will be inserted as part of its parent
// This prevents duplicates when removing container modules with nested children
if (
parentLocation.type !== 'root' &&
parentLocation.type !== 'failure' &&
parentLocation.type !== 'preprocessor'
) {
if (removedModulesSet.has(parentLocation.parentId)) {
// Parent is also removed, skip this module
continue
}
}
let clonedModule = cloneModule(beforeModule)
// Check for ID collision - this happens when a module type changed
// In this case, the new module is already in the merged flow as 'added'
// We need to prepend "__" to the removed module's ID so both can coexist
const existingIds = getAllModuleIds(merged)
if (existingIds.has(clonedModule.id)) {
clonedModule = prependModuleId(clonedModule, '__')
}
// Insert based on parent location
if (parentLocation.type === 'failure') {
merged.failure_module = clonedModule
} else if (parentLocation.type === 'preprocessor') {
merged.preprocessor_module = clonedModule
} else if (parentLocation.type === 'root') {
// Find the best position to insert in root modules
const insertIndex = findBestInsertPosition(
merged.modules ?? [],
beforeFlow.modules ?? [],
parentLocation.index,
removedId
)
if (!merged.modules) merged.modules = []
merged.modules.splice(insertIndex, 0, clonedModule)
} else {
// Find the parent module in merged flow and insert into it
insertIntoNestedParent(merged, parentLocation, clonedModule, beforeFlow)
}
}
return merged
}
/**
* Finds the best position to insert a removed module in a module array
*/
function findBestInsertPosition(
targetModules: FlowModule[],
beforeModules: FlowModule[],
originalIndex: number,
removedId: string
): number {
// Look for anchors (modules that exist in both before and after)
// Try to find previous anchor
for (let i = originalIndex - 1; i >= 0; i--) {
const anchorId = beforeModules[i]?.id
const anchorIndex = targetModules.findIndex((m) => m.id === anchorId)
if (anchorIndex >= 0) {
return anchorIndex + 1
}
}
// Try to find next anchor
for (let i = originalIndex + 1; i < beforeModules.length; i++) {
const anchorId = beforeModules[i]?.id
const anchorIndex = targetModules.findIndex((m) => m.id === anchorId)
if (anchorIndex >= 0) {
return anchorIndex
}
}
// No anchors found, append to end
return targetModules.length
}
/**
* Inserts a removed module into its nested parent in the merged flow
*/
function insertIntoNestedParent(
merged: FlowValue,
parentLocation: ModuleParentLocation,
moduleToInsert: FlowModule,
beforeFlow: FlowValue
): void {
if (
parentLocation.type === 'root' ||
parentLocation.type === 'failure' ||
parentLocation.type === 'preprocessor'
) {
return
}
// Find the parent module in merged flow
const parentModule = findModuleById(merged, parentLocation.parentId)
if (!parentModule) return
// Get the before parent to know original ordering
const beforeParent = findModuleById(beforeFlow, parentLocation.parentId)
if (!beforeParent) return
// Insert based on type
if (parentLocation.type === 'forloop' && parentModule.value.type === 'forloopflow') {
const beforeModules = (beforeParent.value as any).modules ?? []
const insertIndex = findBestInsertPosition(
parentModule.value.modules,
beforeModules,
parentLocation.index,
moduleToInsert.id
)
parentModule.value.modules.splice(insertIndex, 0, moduleToInsert)
} else if (parentLocation.type === 'whileloop' && parentModule.value.type === 'whileloopflow') {
const beforeModules = (beforeParent.value as any).modules ?? []
const insertIndex = findBestInsertPosition(
parentModule.value.modules,
beforeModules,
parentLocation.index,
moduleToInsert.id
)
parentModule.value.modules.splice(insertIndex, 0, moduleToInsert)
} else if (
parentLocation.type === 'branchone-default' &&
parentModule.value.type === 'branchone'
) {
const beforeModules = (beforeParent.value as any).default ?? []
const insertIndex = findBestInsertPosition(
parentModule.value.default,
beforeModules,
parentLocation.index,
moduleToInsert.id
)
parentModule.value.default.splice(insertIndex, 0, moduleToInsert)
} else if (
parentLocation.type === 'branchone-branch' &&
parentModule.value.type === 'branchone'
) {
const branch = parentModule.value.branches[parentLocation.branchIndex]
if (branch) {
const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex]
const beforeModules = beforeBranch?.modules ?? []
const insertIndex = findBestInsertPosition(
branch.modules,
beforeModules,
parentLocation.index,
moduleToInsert.id
)
branch.modules.splice(insertIndex, 0, moduleToInsert)
}
} else if (
parentLocation.type === 'branchall-branch' &&
parentModule.value.type === 'branchall'
) {
const branch = parentModule.value.branches[parentLocation.branchIndex]
if (branch) {
const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex]
const beforeModules = beforeBranch?.modules ?? []
const insertIndex = findBestInsertPosition(
branch.modules,
beforeModules,
parentLocation.index,
moduleToInsert.id
)
branch.modules.splice(insertIndex, 0, moduleToInsert)
}
} else if (parentLocation.type === 'aiagent' && parentModule.value.type === 'aiagent') {
const tools = (parentModule.value.tools as FlowModule[]) ?? []
const beforeTools = ((beforeParent.value as any).tools as FlowModule[]) ?? []
const insertIndex = findBestInsertPosition(
tools,
beforeTools,
parentLocation.index,
moduleToInsert.id
)
tools.splice(insertIndex, 0, moduleToInsert)
}
}
/**
* Finds a module by ID anywhere in the flow
*/
function findModuleById(flow: FlowValue, moduleId: string): FlowModule | null {
const moduleMap = getAllModulesMap(flow)
return moduleMap.get(moduleId) ?? null
}
/**
* Adjusts the after actions based on display mode and adds entries for prefixed IDs
*/
function adjustActionsForDisplay(
afterActions: Record<string, AIModuleAction>,
beforeActions: Record<string, AIModuleAction>,
markRemovedAsShadowed: boolean,
mergedFlow: FlowValue
): Record<string, AIModuleAction> {
const adjusted: Record<string, AIModuleAction> = {}
// Copy all existing actions
for (const [id, action] of Object.entries(afterActions)) {
if (!markRemovedAsShadowed && action === 'shadowed') {
// In unified mode, change 'shadowed' to 'removed' for proper coloring
adjusted[id] = 'removed'
} else {
adjusted[id] = action
}
}
// Add entries for prefixed IDs (modules that had type changes or were removed)
// These are the old versions that got "__" prepended to their ID
const allMergedIds = getAllModuleIds(mergedFlow)
for (const id of allMergedIds) {
if (id.startsWith('__') && !adjusted[id]) {
// This is a prefixed ID for a module that was removed
const originalId = id.substring(2)
// Check beforeActions to see if this module was removed
if (beforeActions[originalId] === 'removed') {
adjusted[id] = markRemovedAsShadowed ? 'shadowed' : 'removed'
}
}
}
return adjusted
}
/**
* Builds the complete flow diff result with action maps and merged flow.
* The merged flow contains all modules from afterFlow plus removed modules from
* beforeFlow properly nested in their original locations.
*
* @param beforeFlow - The original flow value
* @param afterFlow - The modified flow value
* @param options - Display options
* @returns Complete diff result with beforeActions, afterActions, and mergedFlow
*/
export function buildFlowTimeline(
beforeFlow: FlowValue,
afterFlow: FlowValue,
options: { markRemovedAsShadowed: boolean } = { markRemovedAsShadowed: false }
): FlowTimeline {
// Compute the diff between the two flows
const { beforeActions, afterActions } = computeFlowModuleDiff(beforeFlow, afterFlow)
// Reconstruct merged flow with removed modules properly nested
const mergedFlow = reconstructMergedFlow(afterFlow, beforeFlow, beforeActions)
// Adjust after actions based on display mode and add entries for prefixed IDs
const adjustedAfterActions = adjustActionsForDisplay(
afterActions,
beforeActions,
options.markRemovedAsShadowed,
mergedFlow
)
return {
beforeActions,
afterActions: adjustedAfterActions,
mergedFlow
}
}
/**
* Checks if the input schema has changed between two flow versions.
* The input schema always exists (even if empty), so we only check for modifications.
*
* @param beforeFlow - The original flow (can be OpenFlow or just have schema property)
* @param afterFlow - The modified flow (can be OpenFlow or just have schema property)
* @returns true if the schemas are different, false if identical
*/
export function hasInputSchemaChanged(
beforeFlow: { schema?: { [key: string]: unknown } } | undefined,
afterFlow: { schema?: { [key: string]: unknown } } | undefined
): boolean {
if (!beforeFlow || !afterFlow) {
return false
}
return !deepEqual(beforeFlow.schema, afterFlow.schema)
}

View File

@@ -18,6 +18,7 @@
Loader2,
TriangleAlert,
Timer,
DiffIcon,
Maximize2
} from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
@@ -45,10 +46,13 @@
import { aiModuleActionToBgColor } from '$lib/components/copilot/chat/flow/utils'
import type { Job } from '$lib/gen'
import { getNodeColorClasses, type FlowNodeState } from '$lib/components/graph'
import type { AIModuleAction } from '$lib/components/copilot/chat/flow/core'
interface Props {
selected?: boolean
deletable?: boolean
moduleAction: AIModuleAction | undefined
onShowModuleDiff?: (moduleId: string) => void
retry?: boolean
cache?: boolean
earlyStop?: boolean
@@ -90,6 +94,8 @@
let {
selected = false,
deletable = false,
moduleAction = undefined,
onShowModuleDiff = undefined,
retry = false,
cache = false,
earlyStop = false,
@@ -263,12 +269,13 @@
{/if}
<div class="relative">
<!-- TODO: Use existing function to get module color classes instead of using aiModuleActionToBgColor -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-base',
deletable ? aiModuleActionToBgColor(action) : '',
deletable || moduleAction ? aiModuleActionToBgColor(moduleAction ?? action) : '',
colorClasses.bg
)}
style="width: 275px; height: 34px;"
@@ -277,7 +284,20 @@
onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))}
>
{#if deletable}
<ModuleAcceptReject {action} {id} />
<ModuleAcceptReject action={moduleAction ?? action} {id} />
{/if}
{#if moduleAction === 'modified' && onShowModuleDiff && id}
<div class="absolute right-0 left-0 top-0 -translate-y-full flex justify-start z-50">
<Button
class="p-1 bg-surface hover:bg-surface-hover rounded-t-md text-3xs font-normal flex flex-row items-center gap-1 text-orange-800 dark:text-orange-400"
onClick={() => {
onShowModuleDiff?.(id)
}}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{/if}
<div
class={classNames('absolute z-0 rounded-md outline-offset-0', colorClasses.outline)}

View File

@@ -16,11 +16,14 @@
import type { FlowEditorContext } from '$lib/components/flows/types'
import { twMerge } from 'tailwind-merge'
import type { FlowNodeState } from '$lib/components/graph'
import type { AIModuleAction } from '$lib/components/copilot/chat/flow/core'
interface Props {
moduleId: string
mod: FlowModule
insertable: boolean
moduleAction: AIModuleAction | undefined
onShowModuleDiff?: (moduleId: string) => void
annotation?: string | undefined
nodeState?: FlowNodeState
moving?: string | undefined
@@ -53,6 +56,8 @@
moduleId,
mod = $bindable(),
insertable,
moduleAction = undefined,
onShowModuleDiff = undefined,
annotation = undefined,
nodeState,
moving = undefined,
@@ -148,6 +153,8 @@
<FlowModuleSchemaItem
deletable={insertable}
{editMode}
{moduleAction}
{onShowModuleDiff}
label={`${
mod.summary || (mod.value.type == 'forloopflow' ? 'For loop' : 'While loop')
} ${mod.value.parallel ? '(parallel)' : ''} ${
@@ -181,6 +188,8 @@
<FlowModuleSchemaItem
deletable={insertable}
{editMode}
{moduleAction}
{onShowModuleDiff}
on:changeId
on:delete
on:move
@@ -199,6 +208,8 @@
<FlowModuleSchemaItem
deletable={insertable}
{editMode}
{moduleAction}
{onShowModuleDiff}
on:changeId
on:delete
on:move
@@ -217,6 +228,8 @@
<FlowModuleSchemaItem
{retries}
{editMode}
{moduleAction}
{onShowModuleDiff}
on:changeId
on:pointerdown={() => onSelect(mod.id)}
on:delete

View File

@@ -28,6 +28,7 @@
cache?: boolean
earlyStop?: boolean
editMode?: boolean
action?: 'added' | 'removed' | 'modified' | 'shadowed' | undefined
icon?: import('svelte').Snippet
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
@@ -57,6 +58,7 @@
cache = false,
earlyStop = false,
editMode = false,
action: actionProp = undefined,
icon,
onUpdateMock,
onEditInput,
@@ -75,7 +77,7 @@
(nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode
)
let action = $derived(label === 'Input' ? getAiModuleAction(label) : undefined)
let action = $derived(actionProp ?? (label === 'Input' ? getAiModuleAction(label) : undefined))
let hoverButton = $state(false)
const outputType = $derived(

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { FlowService, type FlowModule, type Job } from '../../gen'
import { NODE, type GraphModuleState } from '.'
import { getContext, onDestroy, setContext, tick, untrack } from 'svelte'
import { getContext, onDestroy, setContext, tick, untrack, type Snippet } from 'svelte'
import { get, writable, type Writable } from 'svelte/store'
import '@xyflow/svelte/dist/base.css'
@@ -12,7 +12,8 @@
ConnectionLineType,
Controls,
ControlButton,
SvelteFlowProvider
SvelteFlowProvider,
type Viewport
} from '@xyflow/svelte'
import {
graphBuilder,
@@ -50,6 +51,7 @@
import { workspaceStore } from '$lib/stores'
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
import ViewportResizer from './ViewportResizer.svelte'
import ViewportSynchronizer from './ViewportSynchronizer.svelte'
import AssetNode, { computeAssetNodes } from './renderers/nodes/AssetNode.svelte'
import AssetsOverflowedNode from './renderers/nodes/AssetsOverflowedNode.svelte'
import type { FlowGraphAssetContext } from '../flows/types'
@@ -59,6 +61,7 @@
import type { ModulesTestStates } from '../modulesTest.svelte'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import type { AIModuleAction } from '../copilot/chat/flow/core'
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
@@ -80,6 +83,8 @@
notSelectable?: boolean
flowModuleStates?: Record<string, GraphModuleState> | undefined
testModuleStates?: ModulesTestStates
moduleActions?: Record<string, AIModuleAction>
inputSchemaModified?: boolean
selectedId?: Writable<string | undefined>
path?: string | undefined
newFlow?: boolean
@@ -131,7 +136,12 @@
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
onShowModuleDiff?: (moduleId: string) => void
flowHasChanged?: boolean
// Viewport synchronization props (for diff viewer)
sharedViewport?: Viewport
onViewportChange?: (viewport: Viewport, isUserInitiated: boolean) => void
leftHeader?: Snippet
}
let {
@@ -154,6 +164,8 @@
notSelectable = false,
flowModuleStates = undefined,
testModuleStates = undefined,
moduleActions = undefined,
inputSchemaModified = undefined,
selectedId = writable<string | undefined>(undefined),
path = undefined,
newFlow = false,
@@ -178,12 +190,16 @@
onCancelTestFlow = undefined,
onOpenPreview = undefined,
onHideJobStatus = undefined,
onShowModuleDiff = undefined,
individualStepTests = false,
flowJob = undefined,
showJobStatus = false,
suspendStatus = {},
flowHasChanged = false,
chatInputEnabled = false
chatInputEnabled = false,
sharedViewport = undefined,
onViewportChange = undefined,
leftHeader = undefined
}: Props = $props()
setContext<{
@@ -483,6 +499,8 @@
insertable,
flowModuleStates: untrack(() => flowModuleStates),
testModuleStates: untrack(() => testModuleStates),
moduleActions: untrack(() => moduleActions),
inputSchemaModified: untrack(() => inputSchemaModified),
selectedId: untrack(() => $selectedId),
path,
newFlow,
@@ -497,6 +515,7 @@
suspendStatus,
flowHasChanged,
chatInputEnabled,
onShowModuleDiff: untrack(() => onShowModuleDiff),
additionalAssetsMap: flowGraphAssetsCtx?.val.additionalAssetsMap
},
untrack(() => failureModule),
@@ -547,9 +566,19 @@
})
let viewportResizer: ViewportResizer | undefined = $state(undefined)
let viewportSynchronizer: ViewportSynchronizer | undefined = $state(undefined)
export function isNodeVisible(nodeId: string): boolean {
return viewportResizer?.isNodeVisible(nodeId) ?? false
}
export function zoomIn() {
viewportSynchronizer?.zoomIn()
}
export function zoomOut() {
viewportSynchronizer?.zoomOut()
}
</script>
{#if insertable}
@@ -576,10 +605,20 @@
{:else}
<SvelteFlowProvider>
<ViewportResizer {height} {width} {nodes} bind:this={viewportResizer} />
{#if sharedViewport && onViewportChange}
<ViewportSynchronizer
{sharedViewport}
onLocalChange={onViewportChange}
bind:this={viewportSynchronizer}
/>
{/if}
<SvelteFlow
onpaneclick={(e) => {
document.dispatchEvent(new Event('focus'))
}}
onmove={(event, viewport) => {
viewportSynchronizer?.handleLocalViewportChange(event, viewport)
}}
{nodes}
{edges}
{edgeTypes}
@@ -597,41 +636,48 @@
nodesDraggable={false}
--background-color={false}
>
<div class="absolute inset-0 !bg-surface-secondary h-full" id="flow-graph-v2"></div>
<Controls position="top-right" orientation="horizontal" showLock={false}>
{#if download}
<ControlButton
onclick={() => {
try {
localStorage.setItem(
'svelvet',
encodeState({ modules, failureModule, preprocessorModule })
)
} catch (e) {
console.error('error interacting with local storage', e)
}
window.open('/view_graph', '_blank')
}}
class="!bg-surface"
>
<Expand size="14" />
</ControlButton>
{/if}
</Controls>
<div class="absolute inset-0 !bg-surface-secondary h-full"></div>
{#if leftHeader}
<div class="absolute top-2 left-2 z-10">
{@render leftHeader()}
</div>
{:else}
<Controls position="top-right" orientation="horizontal" showLock={false}>
{#if download}
<ControlButton
onclick={() => {
try {
localStorage.setItem(
'svelvet',
encodeState({ modules, failureModule, preprocessorModule })
)
} catch (e) {
console.error('error interacting with local storage', e)
}
window.open('/view_graph', '_blank')
}}
class="!bg-surface"
>
<Expand size="14" />
</ControlButton>
{/if}
</Controls>
<Controls
position="top-left"
orientation="vertical"
showLock={false}
showZoom={false}
showFitView={false}
class="!shadow-none gap-3"
>
<Toggle bind:checked={$showAssets} size="xs" options={{ right: 'Assets' }} />
{#if showDataflow}
<Toggle bind:checked={$useDataflow} size="xs" options={{ right: 'Dataflow' }} />
{/if}
</Controls>
<Controls
position="top-left"
orientation="vertical"
showLock={false}
showZoom={false}
showFitView={false}
class="!shadow-none gap-3"
style={leftHeader ? 'margin-top: 40px;' : ''}
>
<Toggle bind:checked={$showAssets} size="xs" options={{ right: 'Assets' }} />
{#if showDataflow}
<Toggle bind:checked={$useDataflow} size="xs" options={{ right: 'Dataflow' }} />
{/if}
</Controls>
{/if}
</SvelteFlow>
</SvelteFlowProvider>
{/if}

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { useSvelteFlow, type Viewport } from '@xyflow/svelte'
import { tick, untrack } from 'svelte'
interface Props {
sharedViewport: Viewport
onLocalChange: (viewport: Viewport, isUserInitiated: boolean) => void
}
let { sharedViewport, onLocalChange }: Props = $props()
const { setViewport, getViewport } = useSvelteFlow()
let isApplyingSharedChange = false
// Watch for shared viewport changes and apply them locally
$effect(() => {
;(sharedViewport.x, sharedViewport.y, sharedViewport.zoom)
untrack(() => {
if (!isApplyingSharedChange) {
setViewport(sharedViewport, { duration: 0 })
}
})
})
// Export function to be called when local viewport changes (from onmove)
export async function handleLocalViewportChange(
event: MouseEvent | TouchEvent | null,
viewport: Viewport
) {
// Only propagate user-initiated changes (not programmatic ones)
const isUserInitiated = event !== null
if (isUserInitiated) {
isApplyingSharedChange = true
onLocalChange(viewport, isUserInitiated)
await tick()
isApplyingSharedChange = false
}
}
export async function zoomIn() {
const viewport = getViewport()
const newZoom = Math.min(viewport.zoom + 0.1, 1.2)
setViewport({ ...viewport, zoom: newZoom })
await tick()
const updatedViewport = getViewport()
isApplyingSharedChange = true
onLocalChange(updatedViewport, false)
await tick()
isApplyingSharedChange = false
}
export async function zoomOut() {
const viewport = getViewport()
const newZoom = Math.max(viewport.zoom - 0.1, 0.2)
setViewport({ ...viewport, zoom: newZoom })
await tick()
const updatedViewport = getViewport()
isApplyingSharedChange = true
onLocalChange(updatedViewport, false)
await tick()
isApplyingSharedChange = false
}
</script>

View File

@@ -7,6 +7,7 @@ import type { GraphModuleState } from './model'
import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib'
import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte'
import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte'
import { type AIModuleAction } from '../copilot/chat/flow/core'
export type InsertKind =
| 'script'
@@ -126,6 +127,8 @@ export type InputN = {
showJobStatus: boolean
flowHasChanged: boolean
chatInputEnabled: boolean
inputSchemaModified?: boolean
onShowModuleDiff?: (moduleId: string) => void
assets?: AssetWithAltAccessType[] | undefined
}
}
@@ -146,6 +149,8 @@ export type ModuleN = {
flowJob: Job | undefined
isOwner: boolean
assets: AssetWithAltAccessType[] | undefined
moduleAction: AIModuleAction | undefined
onShowModuleDiff?: (moduleId: string) => void
}
}
@@ -364,6 +369,8 @@ export function graphBuilder(
insertable: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
testModuleStates: ModulesTestStates | undefined
moduleActions?: Record<string, AIModuleAction>
inputSchemaModified?: boolean
selectedId: string | undefined
path: string | undefined
newFlow: boolean
@@ -378,6 +385,7 @@ export function graphBuilder(
suspendStatus: Record<string, { job: Job; nb: number }>
flowHasChanged: boolean
chatInputEnabled: boolean
onShowModuleDiff?: (moduleId: string) => void
additionalAssetsMap?: Record<string, AssetWithAltAccessType[]>
},
failureModule: FlowModule | undefined,
@@ -435,7 +443,9 @@ export function graphBuilder(
editMode: extra.editMode,
isOwner: extra.isOwner,
flowJob: extra.flowJob,
assets: getFlowModuleAssets(module, extra.additionalAssetsMap)
assets: getFlowModuleAssets(module, extra.additionalAssetsMap),
moduleAction: extra.moduleActions?.[module.id],
onShowModuleDiff: extra.onShowModuleDiff
},
type: 'module'
})
@@ -553,6 +563,8 @@ export function graphBuilder(
showJobStatus: extra.showJobStatus,
flowHasChanged: extra.flowHasChanged,
chatInputEnabled: extra.chatInputEnabled,
inputSchemaModified: extra.inputSchemaModified,
onShowModuleDiff: extra.onShowModuleDiff,
...(inputAssets ? { assets: inputAssets } : {})
}
}

View File

@@ -9,7 +9,8 @@
import { schemaToObject } from '$lib/schema'
import type { Schema } from '$lib/common'
import type { FlowEditorContext } from '$lib/components/flows/types'
import { MessageSquare } from 'lucide-svelte'
import { MessageSquare, DiffIcon } from 'lucide-svelte'
import { Button } from '$lib/components/common'
interface Props {
data: InputN['data']
@@ -33,6 +34,18 @@
let inputLabel = $derived(data.chatInputEnabled ? 'Chat message' : 'Input')
</script>
{#if data.inputSchemaModified && data.onShowModuleDiff}
<div class="absolute right-0 left-0 top-0 -translate-y-full flex justify-start z-50">
<Button
class="p-1 bg-surface hover:bg-surface-hover rounded-t-md text-3xs font-normal flex flex-row items-center gap-1 text-orange-800 dark:text-orange-400"
onClick={() => {
data.onShowModuleDiff?.('Input')
}}
startIcon={{ icon: DiffIcon }}>Diff</Button
>
</div>
{/if}
<NodeWrapper>
{#snippet children({ darkMode })}
{#if data.insertable && !data.hasPreprocessor}
@@ -82,6 +95,7 @@
cache={data.cache}
earlyStop={data.earlyStop}
editMode={data.editMode}
action={data.inputSchemaModified ? 'modified' : undefined}
onEditInput={data.eventHandlers.editInput}
onTestFlow={() => {
data.eventHandlers.testFlow()

View File

@@ -44,6 +44,8 @@
mod={data.module}
insertable={data.insertable}
editMode={data.editMode}
moduleAction={data.moduleAction}
onShowModuleDiff={data.onShowModuleDiff}
annotation={flowJobs &&
(data.module.value.type === 'forloopflow' || data.module.value.type === 'whileloopflow')
? 'Iteration: ' +

View File

@@ -266,7 +266,7 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} isFlow />
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {page.params.path}</h1>