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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
288
frontend/src/lib/components/FlowGraphDiffViewer.svelte
Normal file
288
frontend/src/lib/components/FlowGraphDiffViewer.svelte
Normal 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}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
573
frontend/src/lib/components/flows/flowDiff.ts
Normal file
573
frontend/src/lib/components/flows/flowDiff.ts
Normal 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)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: ' +
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user