mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 16:03:26 +00:00
* perf: shrink the module graph that gates first paint in dev * docs: drop the stale synchronous-icons claim on the import card * fix: replay search opened before its modal loads, guard lazy icons * fix: only intercept search before load where the modal mounts * perf: mount app-shell modals on first open and keep monaco off the shell * perf: load the icon map on first read, not at module evaluation * fix: report stale chunks with a reload toast, guard the home page against monaco
1573 lines
50 KiB
Svelte
1573 lines
50 KiB
Svelte
<script lang="ts">
|
|
import GraphZoomControls from './GraphZoomControls.svelte'
|
|
import FlowPanelPlacementPicker from '$lib/components/flows/common/FlowPanelPlacementPicker.svelte'
|
|
import { overlayStack } from '$lib/components/common/overlayHost.svelte'
|
|
import { FlowService, type FlowModule, type FlowNote, type Job, type OpenFlow } from '../../gen'
|
|
import { findStepPath, parseExpandedSubflowId } from '$lib/components/restartFromStepPath'
|
|
import { expandedSubflowParentId } from '../flows/expandedSubflowStep'
|
|
import { sendUserToast } from '$lib/utils'
|
|
import { AI_OR_ASSET_NODE_TYPES, NODE, type GraphModuleState } from '.'
|
|
import { isTriggerStep } from '$lib/components/flows/flowStepSettings'
|
|
import { getContext, onDestroy, onMount, tick, untrack, type Snippet } from 'svelte'
|
|
import { createFlowDiffManager } from '../flows/flowDiffManager.svelte'
|
|
|
|
import { get, writable, type Writable } from 'svelte/store'
|
|
import '@xyflow/svelte/dist/base.css'
|
|
import {
|
|
SvelteFlow,
|
|
type Node,
|
|
type Edge,
|
|
ConnectionLineType,
|
|
Controls,
|
|
ControlButton,
|
|
SvelteFlowProvider,
|
|
type Viewport,
|
|
SelectionMode
|
|
} from '@xyflow/svelte'
|
|
import {
|
|
graphBuilder,
|
|
type InlineScript,
|
|
type InsertKind,
|
|
type NodeLayout,
|
|
type OnSelectedIteration,
|
|
type SimplifiableFlow
|
|
} from './graphBuilder.svelte'
|
|
import ModuleNode from './renderers/nodes/ModuleNode.svelte'
|
|
import FailureModuleNode from './renderers/nodes/FailureModuleNode.svelte'
|
|
import InputNode from './renderers/nodes/InputNode.svelte'
|
|
import BranchAllStart from './renderers/nodes/BranchAllStart.svelte'
|
|
import BranchAllEndNode from './renderers/nodes/BranchAllEndNode.svelte'
|
|
import ForLoopEndNode from './renderers/nodes/ForLoopEndNode.svelte'
|
|
import ForLoopStartNode from './renderers/nodes/ForLoopStartNode.svelte'
|
|
import ResultNode from './renderers/nodes/ResultNode.svelte'
|
|
import BaseEdge from './renderers/edges/BaseEdge.svelte'
|
|
import EmptyEdge from './renderers/edges/EmptyEdge.svelte'
|
|
import { Expand, MousePointer, Hand } from 'lucide-svelte'
|
|
import Toggle from '../Toggle.svelte'
|
|
import DataflowEdge from './renderers/edges/DataflowEdge.svelte'
|
|
import { encodeState, readFieldsRecursively, getModifierKey, isMac } from '$lib/utils'
|
|
import BranchOneStart from './renderers/nodes/BranchOneStart.svelte'
|
|
import NoBranchNode from './renderers/nodes/NoBranchNode.svelte'
|
|
import HiddenBaseEdge from './renderers/edges/HiddenBaseEdge.svelte'
|
|
import TriggersNode from './renderers/nodes/TriggersNode.svelte'
|
|
import { Alert, Drawer } from '../common'
|
|
import Button from '../common/button/Button.svelte'
|
|
import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte'
|
|
import type { TriggerContext } from '../triggers'
|
|
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
|
|
import DiffDrawer from '../DiffDrawer.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'
|
|
import AiToolNode, { computeAIToolNodes } from './renderers/nodes/AIToolNode.svelte'
|
|
import {
|
|
linkedAgentToolsForScope,
|
|
linkedAgentToolsVersion,
|
|
linkedToolsScope,
|
|
releaseLinkedToolsScope,
|
|
retainLinkedToolsScope
|
|
} from '$lib/components/flows/linkedAgentToolsStore.svelte'
|
|
import NewAiToolNode from './renderers/nodes/NewAIToolNode.svelte'
|
|
import NoteNode from './renderers/nodes/NoteNode.svelte'
|
|
import CollapsedGroupNode from './renderers/nodes/CollapsedGroupNode.svelte'
|
|
import GroupHeadNode from './renderers/nodes/GroupHeadNode.svelte'
|
|
import GroupEndNode from './renderers/nodes/GroupEndNode.svelte'
|
|
import NoteTool from './NoteTool.svelte'
|
|
import SelectionBoundingBox from './SelectionBoundingBox.svelte'
|
|
import GroupOverlay from './GroupOverlay.svelte'
|
|
import {
|
|
GroupDisplayState,
|
|
getGroupEditorContext,
|
|
groupKey,
|
|
type FlowGroup
|
|
} from './groupEditor.svelte'
|
|
import { buildStructureTree, computeGroupDepths, type FlowStructureNode } from './flowStructure'
|
|
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
|
import { computeGroupModuleIds } from './groupDetectionUtils'
|
|
import { getAllModules } from '../flows/flowExplorer'
|
|
import SelectionTool from './SelectionTool.svelte'
|
|
import PaneContextMenu from './PaneContextMenu.svelte'
|
|
import { SelectionManager, isFlowLevelPanelTarget } from './selectionUtils.svelte'
|
|
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
|
|
import { NoteManager } from './noteManager.svelte'
|
|
import type { MoveManager } from './moveManager.svelte'
|
|
import DragCoordinator from './DragCoordinator.svelte'
|
|
import { jobToGraphModuleState, type ModulesTestStates } from '../modulesTest.svelte'
|
|
import { compoundLayout } from './compoundLayout'
|
|
import { deepEqual } from 'fast-equals'
|
|
import type { AssetWithAltAccessType } from '../assets/lib'
|
|
import { computeNodeExtraSpace } from './nodeExtraSpace'
|
|
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
|
|
import { setGraphContext } from './graphContext'
|
|
import { setFlowRunStatusContext } from './flowRunStatus.svelte'
|
|
import { computeNoteNodes } from './noteUtils.svelte'
|
|
import { Tooltip } from '../meltComponents'
|
|
import { getNoteEditorContext } from './noteEditor.svelte'
|
|
import {
|
|
resolveSelectedModuleIds,
|
|
locateModules,
|
|
areContiguousSiblings
|
|
} from '../flows/multiSelectUtils'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
let useDataflow: Writable<boolean | undefined> = writable<boolean | undefined>(false)
|
|
let showAssets: Writable<boolean | undefined> = writable<boolean | undefined>(true)
|
|
let showNotes = $state(true)
|
|
|
|
const triggerContext = getContext<TriggerContext>('TriggerContext')
|
|
const overlays = overlayStack()
|
|
|
|
// Create diffManager instance for this FlowGraphV2
|
|
const diffManager = createFlowDiffManager()
|
|
|
|
let fullWidth = 0
|
|
let width = $state(0)
|
|
|
|
let simplifiableFlow: SimplifiableFlow | undefined = $state(undefined)
|
|
|
|
interface Props {
|
|
success?: boolean | undefined
|
|
modules?: FlowModule[] | undefined
|
|
groupedModules?: FlowStructureNode[]
|
|
groupError?: unknown
|
|
failureModule?: FlowModule | undefined
|
|
preprocessorModule?: FlowModule | undefined
|
|
minHeight?: number
|
|
maxHeight?: number | undefined
|
|
notSelectable?: boolean
|
|
flowModuleStates?: Record<string, GraphModuleState> | undefined
|
|
testModuleStates?: ModulesTestStates
|
|
moduleActions?: Record<string, ModuleActionInfo>
|
|
selectionManager?: SelectionManager
|
|
path?: string | undefined
|
|
// Flow path for the linked-agent tools bucket. Separate from `path` because that one also
|
|
// drives the Trigger node, which read-only viewers must not render.
|
|
linkedToolsPath?: string | undefined
|
|
newFlow?: boolean
|
|
insertable?: boolean
|
|
earlyStop?: boolean
|
|
cache?: boolean
|
|
scroll?: boolean
|
|
moveManager?: MoveManager
|
|
// Download: display a top level button to open the graph in a new tab
|
|
download?: boolean
|
|
fullSize?: boolean
|
|
disableAi?: boolean
|
|
triggerNode?: boolean
|
|
workspace?: string
|
|
editMode?: boolean
|
|
allowSimplifiedPoll?: boolean
|
|
expandedSubflows?: Record<string, { modules: FlowModule[]; groups?: FlowGroup[] }>
|
|
isOwner?: boolean
|
|
isRunning?: boolean
|
|
individualStepTests?: boolean
|
|
flowJob?: Job | undefined
|
|
showJobStatus?: boolean
|
|
suspendStatus?: Record<string, { job: Job; nb: number }>
|
|
noteMode?: boolean
|
|
notes?: FlowNote[]
|
|
groups?: FlowGroup[]
|
|
groupDisplayState?: GroupDisplayState
|
|
chatInputEnabled?: boolean
|
|
multiSelectEnabled?: boolean
|
|
onDeleteMultiple?: (ids: string[]) => void
|
|
onDuplicateMultiple?: (ids: string[]) => void
|
|
onMoveMultiple?: (ids: string[]) => void
|
|
movingIds?: string[]
|
|
onDelete?: (id: string) => void
|
|
/** Forget the run state of a node that only mirrors a run (the error handler marker), so it
|
|
* stops being rendered. Must not touch the flow itself. */
|
|
onDismissRunNode?: (id: string) => void
|
|
onInsert?: (detail: {
|
|
sourceId?: string
|
|
targetId?: string
|
|
branch?: { rootId: string; branch: number }
|
|
index: number
|
|
detail: string
|
|
isPreprocessor?: boolean
|
|
agentId?: string
|
|
inlineScript?: InlineScript
|
|
script?: { path: string; summary: string; hash: string | undefined }
|
|
flow?: { path: string; summary: string }
|
|
/** Saved `ai_agent` resource the inserted agent step links to, for `kind: 'aiagent'`. */
|
|
agentPath?: string
|
|
kind: InsertKind
|
|
expandGroup?: { groupId: string; position: 'top' | 'bottom' }
|
|
}) => Promise<void>
|
|
onNewBranch?: (id: string) => Promise<void>
|
|
onSelect?: (id: string | FlowModule) => void
|
|
onDeleteBranch?: (detail: { id: string; index: number }) => Promise<void>
|
|
onChangeId?: (detail: { id: string; newId: string; deps: Record<string, string[]> }) => void
|
|
onMove?: (id: string) => void
|
|
onDuplicate?: (id: string) => void
|
|
onUpdateMock?: (detail: { mock: FlowModule['mock']; id: string }) => void
|
|
onTestUpTo?: ((id: string) => void) | undefined
|
|
onSelectedIteration?: OnSelectedIteration
|
|
onEditInput?: (moduleId: string, key: string) => void
|
|
onTestFlow?: () => void
|
|
onCancelTestFlow?: () => void
|
|
onOpenPreview?: () => void
|
|
onHideJobStatus?: () => void
|
|
flowHasChanged?: boolean
|
|
exitNoteMode?: () => void
|
|
onNotePositionUpdate?: (noteId: string, position: { x: number; y: number }) => void
|
|
// Viewport synchronization props (for diff viewer)
|
|
sharedViewport?: Viewport
|
|
onViewportChange?: (viewport: Viewport, isUserInitiated: boolean) => void
|
|
leftHeader?: Snippet
|
|
// Diff mode props
|
|
diffBeforeFlow?: OpenFlow
|
|
currentInputSchema?: Record<string, any>
|
|
markRemovedAsShadowed?: boolean
|
|
controlsPosition?: 'top' | 'bottom'
|
|
outerDivClass?: string
|
|
/** Fires when the computed graph height changes. Diff views can use
|
|
* this to equalize heights of side-by-side graphs. */
|
|
onHeight?: (height: number) => void
|
|
}
|
|
|
|
let {
|
|
onInsert = undefined,
|
|
onDelete = undefined,
|
|
onDismissRunNode = undefined,
|
|
onMove = undefined,
|
|
onDuplicate = undefined,
|
|
onDeleteBranch = undefined,
|
|
onNewBranch = undefined,
|
|
onSelect = undefined,
|
|
onChangeId = undefined,
|
|
onUpdateMock = undefined,
|
|
onSelectedIteration = undefined,
|
|
success = undefined,
|
|
modules = [],
|
|
groupedModules: groupedModulesProp = undefined,
|
|
groupError = undefined,
|
|
failureModule = undefined,
|
|
preprocessorModule = undefined,
|
|
minHeight = 0,
|
|
maxHeight = undefined,
|
|
notSelectable = false,
|
|
flowModuleStates = undefined,
|
|
testModuleStates = undefined,
|
|
moduleActions = undefined,
|
|
selectionManager: selectionManagerProp = undefined,
|
|
path = undefined,
|
|
linkedToolsPath = undefined,
|
|
newFlow = false,
|
|
insertable = false,
|
|
earlyStop = false,
|
|
cache = false,
|
|
scroll = false,
|
|
moveManager = undefined,
|
|
download = false,
|
|
fullSize = false,
|
|
disableAi = false,
|
|
triggerNode = false,
|
|
workspace = $operatingWorkspace ?? 'NO_WORKSPACE',
|
|
editMode = false,
|
|
allowSimplifiedPoll = true,
|
|
expandedSubflows = $bindable({}),
|
|
onTestUpTo = undefined,
|
|
onEditInput = undefined,
|
|
isOwner = false,
|
|
onTestFlow = undefined,
|
|
isRunning = false,
|
|
onCancelTestFlow = undefined,
|
|
onOpenPreview = undefined,
|
|
onHideJobStatus = undefined,
|
|
individualStepTests = false,
|
|
flowJob = undefined,
|
|
showJobStatus = false,
|
|
suspendStatus = {},
|
|
flowHasChanged = false,
|
|
noteMode = false,
|
|
notes = undefined,
|
|
groups = undefined,
|
|
groupDisplayState: groupDisplayStateProp = undefined,
|
|
exitNoteMode = undefined,
|
|
onNotePositionUpdate = undefined,
|
|
chatInputEnabled = false,
|
|
sharedViewport = undefined,
|
|
onViewportChange = undefined,
|
|
leftHeader = undefined,
|
|
diffBeforeFlow = undefined,
|
|
currentInputSchema = undefined,
|
|
markRemovedAsShadowed = false,
|
|
multiSelectEnabled = false,
|
|
onDeleteMultiple = undefined,
|
|
onDuplicateMultiple = undefined,
|
|
onMoveMultiple = undefined,
|
|
movingIds = undefined,
|
|
controlsPosition = 'top',
|
|
outerDivClass = '',
|
|
onHeight = undefined
|
|
}: Props = $props()
|
|
|
|
// Hold the scope this graph draws from while it is mounted: the store's cap must never drop a
|
|
// bucket that something on screen is reading, and nothing would refetch it afterwards.
|
|
$effect(() => {
|
|
const scope = linkedToolsScope(workspace, linkedToolsPath ?? path)
|
|
retainLinkedToolsScope(scope)
|
|
return () => releaseLinkedToolsScope(scope)
|
|
})
|
|
|
|
// Initialize note manager with fine-grained reactivity
|
|
const noteManager = new NoteManager(
|
|
() => notes ?? [],
|
|
(newNodes) => {
|
|
nodes = newNodes
|
|
},
|
|
() => nodes
|
|
)
|
|
|
|
const groupDisplayState =
|
|
untrack(() => groupDisplayStateProp) ?? new GroupDisplayState(() => groups ?? [])
|
|
|
|
// Runtime text height tracking for notes (not stored in FlowNote)
|
|
let noteTextHeights = $state<Record<string, number>>({})
|
|
|
|
// Reference to pane context menu component
|
|
let paneContextMenu: PaneContextMenu | undefined = $state(undefined)
|
|
let flowContainer: HTMLDivElement | undefined = $state(undefined)
|
|
|
|
// Hover tracking for group overlay
|
|
|
|
// Selection manager - create one if not provided
|
|
let selectionManager = untrack(() => selectionManagerProp) || new SelectionManager()
|
|
const selectedId = $derived(selectionManager.getSelectedId())
|
|
|
|
const noteEditorContext = getNoteEditorContext()
|
|
|
|
// Function to calculate extra gap needed for notes below the lowest flow nodes
|
|
function calculateNoteGap(notes: FlowNote[] | undefined): number {
|
|
if (!notes || notes.length === 0) {
|
|
return 0
|
|
}
|
|
let maxNoteBelowGap = 0
|
|
|
|
notes.forEach((note) => {
|
|
if (note.position?.y && note.position.y < 0) {
|
|
maxNoteBelowGap = Math.max(maxNoteBelowGap, -note.position.y)
|
|
}
|
|
})
|
|
|
|
return maxNoteBelowGap
|
|
}
|
|
|
|
// Calculate note gap based on current nodes and notes
|
|
const topPadding = untrack(() => editMode) ? 100 : 24
|
|
const yOffset = calculateNoteGap(untrack(() => notes)) + topPadding
|
|
|
|
setGraphContext({
|
|
selectionManager: selectionManager,
|
|
useDataflow,
|
|
showAssets,
|
|
noteManager,
|
|
moveManager: untrack(() => moveManager),
|
|
clearFlowSelection,
|
|
yOffset,
|
|
diffManager,
|
|
getFlowNodes: () => currentGraphNodeDeps,
|
|
groupDisplayState
|
|
} as any)
|
|
|
|
const flowRunStatus = setFlowRunStatusContext()
|
|
$effect(() => {
|
|
flowRunStatus.flowJob = flowJob
|
|
})
|
|
$effect(() => {
|
|
flowRunStatus.suspendStatus = suspendStatus
|
|
})
|
|
$effect(() => {
|
|
// Each step's state object is replaced rather than mutated, so reading the top level
|
|
// catches every status change. Walking deeper would subscribe to every step's args and
|
|
// result on the hottest path in the graph.
|
|
Object.values(flowModuleStates ?? {})
|
|
// The loader mutates `flow_status` on the job it already handed us, so subscribing to the
|
|
// test state alone would never see an agent's calls land.
|
|
Object.values(testModuleStates?.states ?? {}).forEach((s) => [
|
|
s.loading,
|
|
s.testJob?.['flow_status']?.modules?.[0]?.agent_actions?.length
|
|
])
|
|
untrack(() => {
|
|
// Testing one step is its own small run, and its agent calls arrive on the test job
|
|
// rather than the flow's states. Fold them in so the renderers keep a single source.
|
|
let states = flowModuleStates
|
|
for (const [id, testState] of Object.entries(testModuleStates?.states ?? {})) {
|
|
const tested = jobToGraphModuleState(testState)
|
|
if (!tested?.agent_actions) continue
|
|
states = { ...(states ?? {}), [id]: { ...(states?.[id] ?? {}), ...tested } }
|
|
}
|
|
flowRunStatus.setModuleStates(states)
|
|
})
|
|
})
|
|
|
|
if (triggerContext && untrack(() => allowSimplifiedPoll)) {
|
|
if (isSimplifiable(untrack(() => modules))) {
|
|
triggerContext?.simplifiedPoll?.set(true)
|
|
}
|
|
triggerContext?.simplifiedPoll.subscribe((value) => {
|
|
computeSimplifiableFlow(modules ?? [], value ?? false)
|
|
})
|
|
}
|
|
|
|
function computeSimplifiableFlow(modules: FlowModule[], simplifiedFlow: boolean) {
|
|
const isSimplif = isSimplifiable(modules)
|
|
simplifiableFlow = isSimplif ? { simplifiedFlow } : undefined
|
|
}
|
|
|
|
onDestroy(() => {
|
|
if (isSimplifiable(modules)) {
|
|
triggerContext?.simplifiedPoll?.set(undefined)
|
|
}
|
|
diffManager.setDiffDrawer(undefined)
|
|
})
|
|
|
|
function onModulesChange(modules: FlowModule[]) {
|
|
computeSimplifiableFlow(
|
|
modules,
|
|
triggerContext?.simplifiedPoll ? (get(triggerContext.simplifiedPoll) ?? false) : false
|
|
)
|
|
}
|
|
|
|
type NodeDep = {
|
|
id: string
|
|
parentIds?: string[]
|
|
data?: { assets?: AssetWithAltAccessType[]; module?: any }
|
|
}
|
|
type NodePos = { position: { x: number; y: number } }
|
|
let lastNodes:
|
|
| [NodeDep[], Map<string, { top: number; bottom: number }> | undefined, (NodeDep & NodePos)[]]
|
|
| undefined = undefined
|
|
let currentGraphNodeDeps: { id: string; parentIds?: string[] }[] = $state([])
|
|
|
|
// Keep canCreateGroup in sync for consumers (SelectionBoundingBox, FlowSelectionPanel, etc.)
|
|
const groupEditorCtx = getGroupEditorContext()
|
|
|
|
$effect(() => {
|
|
if (!groupEditorCtx) return
|
|
const ids = selectionManager.selectedIds
|
|
groupEditorCtx.canCreateGroup.val =
|
|
ids.length >= 1 && groupEditorCtx.groupEditor.canCreateGroup(ids, currentGraphNodeDeps)
|
|
})
|
|
|
|
let lastGroupDimensions: Map<string, { width: number; height: number }> | undefined = undefined
|
|
|
|
function layoutNodes(
|
|
nodes: NodeDep[],
|
|
nodeExtraSpace?: Map<string, { top: number; bottom: number; left: number; right: number }>
|
|
): (NodeDep & NodePos)[] {
|
|
let lastResult = lastNodes?.[2]
|
|
if (
|
|
lastResult &&
|
|
deepEqual(nodes, lastNodes?.[0]) &&
|
|
deepEqual(nodeExtraSpace, lastNodes?.[1])
|
|
) {
|
|
console.debug('layoutNodes', 'same nodes')
|
|
return lastResult
|
|
}
|
|
console.debug('layoutNodes', nodes.length)
|
|
let seenId: string[] = []
|
|
for (const n of nodes) {
|
|
if (seenId.includes(n.id)) {
|
|
n.id = n.id + '_dup'
|
|
}
|
|
seenId.push(n.id)
|
|
}
|
|
|
|
// Run recursive compound layout with pre-computed extra space
|
|
const layoutResult = compoundLayout(
|
|
nodes,
|
|
{
|
|
nodeWidth: NODE.width,
|
|
nodeHeight: NODE.height,
|
|
gapH: NODE.gap.horizontal,
|
|
gapV: NODE.gap.vertical
|
|
},
|
|
nodeExtraSpace
|
|
)
|
|
const { positions, bbox } = layoutResult
|
|
lastGroupDimensions = layoutResult.groupDimensions
|
|
|
|
const xCenter = (fullSize ? fullWidth : width) / 2 - bbox.width / 2 - (width - fullWidth) / 2
|
|
|
|
// Center horizontally
|
|
const newNodes = nodes.map((n) => ({
|
|
id: n.id,
|
|
position: {
|
|
x: (positions.get(n.id)?.x ?? 0) + xCenter - NODE.width / 2,
|
|
y: positions.get(n.id)?.y ?? 0
|
|
}
|
|
}))
|
|
|
|
lastNodes = [nodes, nodeExtraSpace, newNodes]
|
|
return newNodes
|
|
}
|
|
|
|
let eventHandler = {
|
|
deleteBranch: (detail, label) => {
|
|
selectionManager.selectId(label)
|
|
onDeleteBranch?.(detail)
|
|
},
|
|
insert: (detail) => {
|
|
onInsert?.(detail)
|
|
},
|
|
select: (modId, opts) => {
|
|
// AI tools are not selectable by the flow. Selection has to be refactored to be simplier.
|
|
// Flow-level panels reach selection only through here, so they must go through
|
|
// selectId or their intent (and the modal panel) never fires.
|
|
if (
|
|
nodes.find((n) => n.data?.moduleId === modId)?.type === 'aiTool' ||
|
|
isFlowLevelPanelTarget(modId)
|
|
) {
|
|
selectionManager.selectId(modId, opts)
|
|
}
|
|
if (!notSelectable) {
|
|
onSelect?.(modId)
|
|
}
|
|
},
|
|
changeId: (detail) => {
|
|
onChangeId?.(detail)
|
|
},
|
|
delete: (detail) => {
|
|
onDelete?.(detail.id)
|
|
},
|
|
newBranch: (id) => {
|
|
onNewBranch?.(id)
|
|
},
|
|
move: (detail) => {
|
|
onMove?.(detail.id)
|
|
},
|
|
duplicate: (detail) => {
|
|
onDuplicate?.(detail.id)
|
|
},
|
|
selectedIteration: (detail) => {
|
|
onSelectedIteration?.(detail)
|
|
},
|
|
simplifyFlow: (detail) => {
|
|
triggerContext?.simplifiedPoll.set(detail)
|
|
},
|
|
expandSubflow: async (id: string, path: string) => {
|
|
// Reads the subflow's *current* definition, which a share link deliberately does
|
|
// not cover: it authorizes the run's job subtree, not the workspace's flow
|
|
// library. So a share-link viewer (and any anonymous one) is refused here — name
|
|
// that case, but only when the error actually says so.
|
|
let flow: OpenFlow
|
|
try {
|
|
flow = await FlowService.getFlowByPath({ workspace: workspace, path })
|
|
} catch (err) {
|
|
const denied = err?.status === 401 || err?.status === 403
|
|
sendUserToast(
|
|
`Could not expand subflow ${path}: ${
|
|
denied
|
|
? "viewing a subflow's definition requires being logged in with access to it"
|
|
: (err?.body ?? err)
|
|
}`,
|
|
true
|
|
)
|
|
return
|
|
}
|
|
expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups }
|
|
expandedSubflows = expandedSubflows
|
|
},
|
|
minimizeSubflow: (id: string) => {
|
|
delete expandedSubflows[id]
|
|
expandedSubflows = expandedSubflows
|
|
},
|
|
expandGroup: (groupId: string) => {
|
|
groupDisplayState.expandGroup(groupId)
|
|
},
|
|
updateMock: (detail) => {
|
|
onUpdateMock?.(detail)
|
|
},
|
|
testUpTo: (id: string) => {
|
|
onTestUpTo?.(id)
|
|
},
|
|
editInput: (moduleId: string, key: string) => {
|
|
onEditInput?.(moduleId, key)
|
|
},
|
|
testFlow: () => {
|
|
onTestFlow?.()
|
|
},
|
|
cancelTestFlow: () => {
|
|
onCancelTestFlow?.()
|
|
},
|
|
openPreview: () => {
|
|
onOpenPreview?.()
|
|
},
|
|
hideJobStatus: () => {
|
|
onHideJobStatus?.()
|
|
},
|
|
dismissRunNode: (id: string) => {
|
|
onDismissRunNode?.(id)
|
|
}
|
|
}
|
|
|
|
// Validation: error if both diffBeforeFlow and moduleActions are provided
|
|
$effect(() => {
|
|
if (diffBeforeFlow && moduleActions) {
|
|
throw new Error('Cannot provide both diffBeforeFlow and moduleActions props to FlowGraphV2')
|
|
}
|
|
})
|
|
|
|
// Sync props to diffManager
|
|
$effect(() => {
|
|
const currentFlowValue = {
|
|
modules: modules,
|
|
failure_module: failureModule,
|
|
preprocessor_module: preprocessorModule
|
|
}
|
|
diffManager.setCurrentFlow(currentFlowValue)
|
|
diffManager.setCurrentInputSchema(currentInputSchema)
|
|
|
|
// Handle diff mode setup
|
|
if (diffBeforeFlow) {
|
|
diffManager.setEditMode(editMode)
|
|
diffManager.setBeforeFlow(diffBeforeFlow)
|
|
diffManager.setMarkRemovedAsShadowed(markRemovedAsShadowed)
|
|
} else if (moduleActions) {
|
|
// Display-only mode: just set the module actions
|
|
diffManager.setModuleActions(moduleActions)
|
|
}
|
|
})
|
|
|
|
// Use diffManager state for rendering
|
|
let effectiveModuleActions = $derived(diffManager.moduleActions)
|
|
|
|
// Use merged flow when in diff mode (includes removed modules), otherwise use raw modules
|
|
let effectiveModules = $derived(diffManager.mergedFlow?.modules ?? modules)
|
|
|
|
let effectiveFailureModule = $derived(diffManager.mergedFlow?.failure_module ?? failureModule)
|
|
|
|
let effectivePreprocessorModule = $derived(
|
|
diffManager.mergedFlow?.preprocessor_module ?? preprocessorModule
|
|
)
|
|
|
|
let canUseDiffDrawer = $derived(diffBeforeFlow || moduleActions || editMode)
|
|
|
|
// Derived state for multi-select operations
|
|
let resolvedModuleIds = $derived(
|
|
resolveSelectedModuleIds(selectionManager.selectedIds, effectiveModules ?? [])
|
|
)
|
|
let canMoveSelected = $derived(
|
|
resolvedModuleIds.length > 0 &&
|
|
areContiguousSiblings(locateModules(resolvedModuleIds, effectiveModules ?? []))
|
|
)
|
|
|
|
// Initialize moduleTracker with effectiveModules
|
|
let moduleTracker = $state(new ChangeTracker<FlowModule[]>([]))
|
|
|
|
let nodes = $state.raw<Node[]>([])
|
|
let edges = $state.raw<Edge[]>([])
|
|
|
|
let height = $state(0)
|
|
|
|
/**
|
|
* A run only changes what the steps display, never the shape of the graph, but every
|
|
* status poll rebuilds these arrays from scratch. xyflow re-measures every node it is
|
|
* handed a new object for, and Svelte destroys and re-creates every edge, so handing
|
|
* back fresh identities re-renders a graph that did not change. Reuse the previous
|
|
* object wherever it still deep-equals the new one.
|
|
*/
|
|
function reuseUnchanged<T extends { id: string }>(previous: T[], next: T[]): T[] {
|
|
const previousById = new Map(previous.map((item) => [item.id, item]))
|
|
let changed = previous.length !== next.length
|
|
const reconciled = next.map((item, index) => {
|
|
const before = previousById.get(item.id)
|
|
if (before && deepEqual(before, item)) {
|
|
changed ||= previous[index] !== before
|
|
return before
|
|
}
|
|
changed = true
|
|
return item
|
|
})
|
|
return changed ? reconciled : previous
|
|
}
|
|
|
|
// Keyed by the source node, so a node that survived reconciliation keeps its offset
|
|
// object too — remapping every node would undo the identity reuse above.
|
|
let offsetNodeCache = new WeakMap<Node, Node>()
|
|
let offsetCacheKey: string | undefined = undefined
|
|
|
|
// Derived nodes with yOffset applied to all nodes uniformly and selectable flag set to false if notSelectable is true
|
|
const nodesWithOffset = $derived.by(() => {
|
|
const cacheKey = `${yOffset}:${notSelectable}`
|
|
if (cacheKey !== offsetCacheKey) {
|
|
offsetNodeCache = new WeakMap<Node, Node>()
|
|
offsetCacheKey = cacheKey
|
|
}
|
|
return nodes.map((node) => {
|
|
const cached = offsetNodeCache.get(node)
|
|
if (cached) {
|
|
return cached
|
|
}
|
|
const mapped =
|
|
node.type && !AI_OR_ASSET_NODE_TYPES.includes(node.type)
|
|
? {
|
|
...node,
|
|
position: { ...node.position, y: node.position.y + yOffset },
|
|
selectable: notSelectable ? false : node.selectable
|
|
}
|
|
: {
|
|
...node,
|
|
selectable: notSelectable ? false : node.selectable
|
|
}
|
|
offsetNodeCache.set(node, mapped)
|
|
return mapped
|
|
})
|
|
})
|
|
|
|
// Note feature state
|
|
|
|
function isSimplifiable(modules: FlowModule[] | undefined): boolean {
|
|
if (!modules || modules?.length !== 2) {
|
|
return false
|
|
}
|
|
if (isTriggerStep(modules?.[0])) {
|
|
let secondValue = modules?.[1].value
|
|
return secondValue.type == 'forloopflow'
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function clearFlowSelection() {
|
|
// Resetting the cache and reassigning `nodes` hands xyflow objects it has not seen, the
|
|
// only lever on its selection available from our own array.
|
|
offsetNodeCache = new WeakMap<Node, Node>()
|
|
nodes = nodes.map((node) => {
|
|
if (node.selected) {
|
|
return { ...node, selected: false }
|
|
}
|
|
return node
|
|
})
|
|
}
|
|
|
|
// Keyboard event handling
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
// Escape belongs to the topmost overlay. This listener is on `document` and theirs
|
|
// are on `window`, so this one always runs first — without the guard, dismissing a
|
|
// modal or picker would also clear the selection under it and lose the user's step.
|
|
if (event.key === 'Escape' && overlays.val.length > 0) {
|
|
return
|
|
}
|
|
selectionManager.handleKeyDown(event)
|
|
noteManager.handleKeyDown(event)
|
|
if (event.key === 'Escape') {
|
|
if (noteMode) {
|
|
exitNoteMode?.()
|
|
}
|
|
}
|
|
if ((event.key === 'Backspace' || event.key === 'Delete') && editMode) {
|
|
const active = document.activeElement
|
|
if (active && active !== document.body && !flowContainer?.contains(active)) {
|
|
return
|
|
}
|
|
if (
|
|
active instanceof HTMLInputElement ||
|
|
active instanceof HTMLTextAreaElement ||
|
|
active?.getAttribute('contenteditable') === 'true'
|
|
) {
|
|
return
|
|
}
|
|
if (noteManager.selectedNoteId && noteEditorContext) {
|
|
noteEditorContext.noteEditor.deleteNote(noteManager.selectedNoteId)
|
|
noteManager.clearNoteSelection()
|
|
return
|
|
}
|
|
if (resolvedModuleIds.length > 1) {
|
|
onDeleteMultiple?.(resolvedModuleIds)
|
|
} else if (resolvedModuleIds.length === 1) {
|
|
onDelete?.(resolvedModuleIds[0])
|
|
} else if (selectedId) {
|
|
onDelete?.(selectedId)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function updateStores() {
|
|
if (graph.error) {
|
|
return
|
|
}
|
|
|
|
const graphNodeDeps = Object.values(graph.nodes).map((n) => ({
|
|
id: n.id,
|
|
parentIds: n.parentIds,
|
|
data: { assets: (n.data as any).assets, module: (n.data as any).module }
|
|
}))
|
|
currentGraphNodeDeps = graphNodeDeps
|
|
|
|
// Pre-compute extra space per node for assets, AI tools, group notes, group headers
|
|
const resolvedLinkedTools = linkedAgentToolsForScope(
|
|
linkedToolsScope(workspace, linkedToolsPath ?? path)
|
|
)
|
|
const nodeExtraSpace = computeNodeExtraSpace(graphNodeDeps, {
|
|
showAssets: $showAssets ?? true,
|
|
showNotes,
|
|
notes,
|
|
noteTextHeights,
|
|
groupDisplayState,
|
|
insertable,
|
|
flowModuleStates,
|
|
linkedAgentTools: resolvedLinkedTools
|
|
})
|
|
|
|
// Layout with extra space baked into sugiyama
|
|
let layoutedNodes = layoutNodes(graphNodeDeps, nodeExtraSpace)
|
|
let newNodes: (Node & NodeLayout)[] = layoutedNodes.map((n) => {
|
|
const merged = { ...n, ...graph.nodes[n.id] }
|
|
// Augment group head nodes with wrapper dimensions from compound layout
|
|
if (graph.nodes[n.id]?.type === 'groupHead' && lastGroupDimensions?.has(n.id)) {
|
|
const dims = lastGroupDimensions.get(n.id)!
|
|
merged.data = { ...merged.data, wrapperWidth: dims.width, wrapperHeight: dims.height }
|
|
}
|
|
return merged
|
|
})
|
|
|
|
// Compute asset visual nodes (no position remapping)
|
|
let assetNodesResult = $showAssets
|
|
? computeAssetNodes(
|
|
newNodes.map((n) => ({
|
|
data: { assets: n.data?.assets as AssetWithAltAccessType[] },
|
|
id: n.id,
|
|
position: n.position
|
|
}))
|
|
)
|
|
: undefined
|
|
|
|
// Compute AI tool visual nodes (no position remapping)
|
|
let aiToolNodesResult = computeAIToolNodes(
|
|
newNodes,
|
|
eventHandler,
|
|
insertable,
|
|
flowModuleStates,
|
|
resolvedLinkedTools
|
|
)
|
|
|
|
let finalNodes: (Node & NodeLayout)[] = [
|
|
...newNodes,
|
|
...(assetNodesResult?.newAssetNodes ?? []),
|
|
...aiToolNodesResult.toolNodes
|
|
]
|
|
|
|
// Module IDs hidden inside collapsed groups: a note whose members are all in here has
|
|
// nothing on screen to wrap, so it is skipped.
|
|
const collapsedModuleIds = new Set<string>()
|
|
for (const n of finalNodes) {
|
|
if (n.type === 'collapsedGroup') {
|
|
const modules = (n.data as any)?.modules as FlowModule[] | undefined
|
|
if (modules) {
|
|
for (const m of modules) {
|
|
collapsedModuleIds.add(m.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute note nodes (no position remapping)
|
|
let noteNodesResult = showNotes
|
|
? computeNoteNodes(
|
|
finalNodes.map((n) => ({
|
|
id: n.id,
|
|
position: n.position,
|
|
parentIds: n.parentIds,
|
|
data: { assets: (n.data as any)?.assets },
|
|
type: n.type
|
|
})),
|
|
notes ?? [],
|
|
noteTextHeights,
|
|
(noteId: string, height: number) => {
|
|
noteTextHeights[noteId] = height
|
|
noteManager.render()
|
|
},
|
|
editMode,
|
|
noteEditorContext,
|
|
collapsedModuleIds.size > 0 ? collapsedModuleIds : undefined
|
|
)
|
|
: undefined
|
|
|
|
// update nodes
|
|
nodes = reuseUnchanged(nodes, [...finalNodes, ...(noteNodesResult?.noteNodes ?? [])])
|
|
|
|
edges = reuseUnchanged(edges, [
|
|
...(assetNodesResult?.newAssetEdges ?? []),
|
|
...aiToolNodesResult.toolEdges,
|
|
...graph.edges
|
|
])
|
|
|
|
await tick()
|
|
updateHeight()
|
|
}
|
|
|
|
function updateHeight() {
|
|
if (nodes.length === 0) {
|
|
height = minHeight
|
|
} else {
|
|
const minY = Math.min(...nodes.map((n) => n.position.y))
|
|
const maxBottom = Math.max(...nodes.map((n) => n.position.y + NODE.height + 100))
|
|
const computed = maxBottom - minY
|
|
height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight)
|
|
}
|
|
onHeight?.(height)
|
|
}
|
|
|
|
$effect(() => {
|
|
// Track both bounds — updateHeight() reads both, so missing one (as
|
|
// maxHeight was) leaves height stale when only that bound changes.
|
|
minHeight
|
|
maxHeight
|
|
untrack(() => updateHeight())
|
|
})
|
|
|
|
const nodeTypes = {
|
|
input2: InputNode,
|
|
module: ModuleNode,
|
|
failureModule: FailureModuleNode,
|
|
branchAllStart: BranchAllStart,
|
|
branchAllEnd: BranchAllEndNode,
|
|
forLoopEnd: ForLoopEndNode,
|
|
forLoopStart: ForLoopStartNode,
|
|
result: ResultNode,
|
|
whileLoopStart: ForLoopStartNode,
|
|
whileLoopEnd: ForLoopEndNode,
|
|
branchOneStart: BranchOneStart,
|
|
branchOneEnd: BranchOneEndNode,
|
|
subflowBound: SubflowBound,
|
|
noBranch: NoBranchNode,
|
|
trigger: TriggersNode,
|
|
asset: AssetNode,
|
|
assetsOverflowed: AssetsOverflowedNode,
|
|
aiTool: AiToolNode,
|
|
newAiTool: NewAiToolNode,
|
|
note: NoteNode,
|
|
collapsedGroup: CollapsedGroupNode,
|
|
groupHead: GroupHeadNode,
|
|
groupEnd: GroupEndNode
|
|
} as any
|
|
|
|
const edgeTypes = {
|
|
edge: BaseEdge,
|
|
empty: EmptyEdge,
|
|
dataflowedge: DataflowEdge,
|
|
hiddenedge: HiddenBaseEdge
|
|
} as any
|
|
|
|
const proOptions = { hideAttribution: true }
|
|
|
|
// onMount(() => {
|
|
// centerViewport(width)
|
|
// })
|
|
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
|
|
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
|
|
|
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
|
|
|
|
$effect(() => {
|
|
allowSimplifiedPoll && modules && untrack(() => onModulesChange(modules ?? []))
|
|
})
|
|
$effect(() => {
|
|
readFieldsRecursively(effectiveModules)
|
|
untrack(() => moduleTracker.track($state.snapshot(effectiveModules)))
|
|
})
|
|
|
|
// Wire up the diff drawer to the diffManager
|
|
$effect(() => {
|
|
diffManager.setDiffDrawer(diffDrawer)
|
|
})
|
|
|
|
let graph = $derived.by(() => {
|
|
moduleTracker.counter
|
|
effectiveModuleActions
|
|
currentGroups
|
|
// The poll replaces `flowJob` on every tick and is what makes the untracked
|
|
// `flowModuleStates` above get re-read, so it stays a dependency. It is deliberately
|
|
// not handed to graphBuilder: anything put there lands in every node and edge's data,
|
|
// and a per-tick value there re-creates the whole graph. Renderers read it from
|
|
// FlowRunStatus instead.
|
|
flowJob
|
|
suspendStatus
|
|
// Dataflow edges hang off the selected step. Selection is otherwise xyflow's own state,
|
|
// so it only rebuilds the graph while those edges are shown.
|
|
if ($useDataflow) selectedId
|
|
|
|
const collapsedGroupIds = new Set(
|
|
allGroups
|
|
.filter((g) => groupDisplayState.isRuntimeCollapsed(groupKey(g)))
|
|
.map((g) => groupKey(g))
|
|
)
|
|
|
|
if (groupError) {
|
|
return { nodes: {}, edges: [], error: groupError }
|
|
}
|
|
|
|
// Use provided structure tree (from proxy) or build locally (diff mode / read-only)
|
|
let gm: FlowStructureNode[] | undefined = groupedModulesProp
|
|
if (!gm) {
|
|
const allGroups = groups ?? []
|
|
const graphGroups = allGroups.map((g) => ({
|
|
...g,
|
|
id: groupKey(g),
|
|
moduleIds: untrack(() =>
|
|
computeGroupModuleIds(g.start_id, g.end_id, getAllModules(effectiveModules ?? []))
|
|
)
|
|
}))
|
|
try {
|
|
gm = buildStructureTree(
|
|
stateSnapshot(untrack(() => effectiveModules) ?? []) as FlowModule[],
|
|
graphGroups
|
|
)
|
|
} catch (e) {
|
|
return { nodes: {}, edges: [], error: e }
|
|
}
|
|
}
|
|
|
|
const result = graphBuilder(
|
|
gm,
|
|
untrack(() => effectiveModules),
|
|
{
|
|
disableAi,
|
|
insertable,
|
|
flowModuleStates: untrack(() => flowModuleStates),
|
|
testModuleStates: untrack(() => testModuleStates),
|
|
moduleActions: untrack(() => effectiveModuleActions),
|
|
selectedId: untrack(() => selectedId),
|
|
path,
|
|
newFlow,
|
|
cache,
|
|
earlyStop,
|
|
editMode,
|
|
isOwner,
|
|
isRunning,
|
|
individualStepTests,
|
|
showJobStatus,
|
|
flowHasChanged,
|
|
chatInputEnabled,
|
|
additionalAssetsMap: flowGraphAssetsCtx?.val.additionalAssetsMap
|
|
},
|
|
untrack(() => effectiveFailureModule),
|
|
effectivePreprocessorModule,
|
|
eventHandler,
|
|
success,
|
|
$useDataflow,
|
|
untrack(() => selectedId),
|
|
simplifiableFlow,
|
|
triggerNode ? path : undefined,
|
|
expandedSubflows,
|
|
showNotes,
|
|
collapsedGroupIds
|
|
)
|
|
return { ...result, structureTree: gm }
|
|
})
|
|
let hideAssetsToggle = $derived(
|
|
$showAssets && Object.values(nodes).every((n) => n.type !== 'asset')
|
|
)
|
|
let hideNotesToggle = $derived(
|
|
(!notes || notes.length === 0) && !(groups ?? []).some((g) => g.note != null)
|
|
)
|
|
|
|
let currentGroupDepths = $derived(
|
|
'structureTree' in graph && graph.structureTree ? computeGroupDepths(graph.structureTree) : {}
|
|
)
|
|
|
|
// All groups including those from expanded subflows (for overlay rendering)
|
|
let allGroups = $derived.by(() => {
|
|
const base = groups ?? []
|
|
const subflowGroups = Object.values(expandedSubflows).flatMap((sf) => sf.groups ?? [])
|
|
return subflowGroups.length > 0 ? [...base, ...subflowGroups] : base
|
|
})
|
|
|
|
// Track groups for re-layout when groups change
|
|
let currentGroups = $derived(groups ?? [])
|
|
|
|
/**
|
|
* An agent's tool calls become nodes, and they land one at a time while the step runs.
|
|
* Nothing else the graph reads changes as they arrive — the run only appends to an
|
|
* existing step's state — so without this the tools all appear at once when the step ends.
|
|
*/
|
|
let agentActionsVersion = $derived.by(() => {
|
|
// The editor draws the agent's declared tools and ignores the run's calls, so neither the
|
|
// layout nor the tool nodes can change as they land.
|
|
if (insertable) return ''
|
|
let version = ''
|
|
for (const [id, state] of Object.entries(flowModuleStates ?? {})) {
|
|
const actions = state?.agent_actions
|
|
if (!actions) continue
|
|
version += `${id}:${actions.length}:`
|
|
for (const action of actions) {
|
|
version += `${action.type}/${(action as { function_name?: string }).function_name ?? ''},`
|
|
}
|
|
}
|
|
return version
|
|
})
|
|
|
|
$effect(() => {
|
|
;[
|
|
graph,
|
|
allowSimplifiedPoll,
|
|
$showAssets,
|
|
showNotes,
|
|
noteManager.renderCount,
|
|
currentGroups,
|
|
groupDisplayState.renderCount,
|
|
agentActionsVersion,
|
|
// A linked step's tools resolve asynchronously; recompute tool nodes when they land.
|
|
linkedAgentToolsVersion()
|
|
]
|
|
untrack(async () => {
|
|
await updateStores()
|
|
})
|
|
})
|
|
|
|
// Add global keyboard event listener for selection controls
|
|
onMount(() => {
|
|
function globalKeyDownHandler(event: KeyboardEvent) {
|
|
handleKeyDown(event)
|
|
}
|
|
|
|
document.addEventListener('keydown', globalKeyDownHandler)
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', globalKeyDownHandler)
|
|
}
|
|
})
|
|
|
|
// DOM event handling for pane clicks in rect-select mode
|
|
$effect(() => {
|
|
// Only add manual handling when in rect-select mode
|
|
if (selectionManager.mode !== 'rect-select') {
|
|
return
|
|
}
|
|
|
|
function paneClickHandler(event: Event) {
|
|
// Find the pane within our specific flow container
|
|
const pane = flowContainer?.querySelector('.svelte-flow__pane')
|
|
if (!pane || !event.target || !pane.contains(event.target as Element)) {
|
|
return
|
|
}
|
|
|
|
// Don't trigger if clicking on nodes or UI elements
|
|
const target = event.target as Element
|
|
if (
|
|
target.closest('.svelte-flow__node') ||
|
|
target.closest('button') ||
|
|
target.closest('[role="button"]') ||
|
|
target.closest('.svelte-flow__controls')
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Trigger the same logic as onpaneclick
|
|
document.dispatchEvent(new Event('focus'))
|
|
selectionManager.clearSelection()
|
|
}
|
|
|
|
const pane = flowContainer?.querySelector('.svelte-flow__pane')
|
|
if (pane) {
|
|
pane.addEventListener('click', paneClickHandler)
|
|
}
|
|
|
|
return () => {
|
|
const pane = flowContainer?.querySelector('.svelte-flow__pane')
|
|
if (pane) {
|
|
pane.removeEventListener('click', paneClickHandler)
|
|
}
|
|
}
|
|
})
|
|
|
|
let showDataflow = $derived(
|
|
selectedId !== undefined &&
|
|
selectedId !== null &&
|
|
!selectedId?.startsWith('constants') &&
|
|
!selectedId?.startsWith('settings') &&
|
|
selectedId !== 'failure' &&
|
|
selectedId !== 'preprocessor' &&
|
|
selectedId !== 'Result' &&
|
|
selectedId !== 'Trigger'
|
|
)
|
|
let debouncedWidth: number | undefined = $state(undefined)
|
|
let timeout: number | undefined = $state(undefined)
|
|
$effect(() => {
|
|
if (!debouncedWidth) {
|
|
return
|
|
}
|
|
if (untrack(() => width) == undefined) {
|
|
width = debouncedWidth
|
|
return
|
|
}
|
|
if (untrack(() => timeout)) {
|
|
clearTimeout(untrack(() => timeout))
|
|
}
|
|
timeout = setTimeout(() => {
|
|
if (debouncedWidth && untrack(() => width) != debouncedWidth) {
|
|
width = debouncedWidth
|
|
}
|
|
}, 10)
|
|
})
|
|
|
|
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()
|
|
}
|
|
|
|
export function getDiffManager() {
|
|
return diffManager
|
|
}
|
|
|
|
export function enableNotes() {
|
|
if (!showNotes) {
|
|
showNotes = true
|
|
}
|
|
}
|
|
|
|
let latestReload = 0
|
|
|
|
/** Flow an expanded subflow node stands for, read from the step it inlines: the edited
|
|
* flow for a top-level expansion, the enclosing expansion's modules otherwise. */
|
|
function expandedSubflowPath(nodeId: string): string | undefined {
|
|
const parentId = expandedSubflowParentId(nodeId)
|
|
const parentModules = parentId == undefined ? modules : expandedSubflows[parentId]?.modules
|
|
const stepId = parseExpandedSubflowId(nodeId)?.leaf ?? nodeId
|
|
const value = parentModules && findStepPath(parentModules, stepId)?.target.value
|
|
return value && value.type === 'flow' ? value.path : undefined
|
|
}
|
|
|
|
/** Refetch the steps inlined by every expanded subflow, e.g. after one was deployed from
|
|
* the flow editor drawer. Outermost first, so a nested expansion resolves its path from
|
|
* its refreshed parent: a step now pointing at another flow must not keep rendering the
|
|
* one it pointed at when it was expanded. */
|
|
export async function reloadExpandedSubflows() {
|
|
const reload = ++latestReload
|
|
const ids = Object.keys(expandedSubflows).sort(
|
|
(a, b) =>
|
|
(parseExpandedSubflowId(a)?.subflowSteps.length ?? 0) -
|
|
(parseExpandedSubflowId(b)?.subflowSteps.length ?? 0)
|
|
)
|
|
for (const id of ids) {
|
|
// A later reload owns the state from here on.
|
|
if (reload !== latestReload) return
|
|
const expansion = expandedSubflows[id]
|
|
if (expansion == undefined) continue
|
|
const path = expandedSubflowPath(id)
|
|
if (path == undefined) {
|
|
delete expandedSubflows[id]
|
|
continue
|
|
}
|
|
try {
|
|
const flow = await FlowService.getFlowByPath({ workspace: workspace, path })
|
|
if (reload !== latestReload) return
|
|
// While this request was in flight the user may have collapsed the expansion, or
|
|
// collapsed it and re-expanded onto another flow: only commit onto the very
|
|
// expansion this response was fetched for.
|
|
if (expandedSubflows[id] !== expansion) continue
|
|
expandedSubflows[id] = { modules: flow.value.modules, groups: flow.value.groups }
|
|
} catch (err) {
|
|
sendUserToast(`Could not reload expanded subflow ${path}: ${err.body ?? err}`, true)
|
|
}
|
|
}
|
|
expandedSubflows = expandedSubflows
|
|
}
|
|
|
|
export function createGroupFromSelection(ids: string[]) {
|
|
if (groupEditorCtx?.groupEditor) {
|
|
groupEditorCtx.groupEditor.createGroup(ids, currentGraphNodeDeps)
|
|
tick().then(() => {
|
|
clearFlowSelection()
|
|
selectionManager.clearSelection()
|
|
})
|
|
}
|
|
}
|
|
|
|
const modifierKey = isMac() ? 'Meta' : 'Control'
|
|
</script>
|
|
|
|
{#if insertable}
|
|
<!-- Dynamic: the YAML editor is monaco, which read-only graphs (run previews) never need. -->
|
|
{#await import('../flows/header/FlowYamlEditor.svelte') then FlowYamlEditor}
|
|
<FlowYamlEditor.default bind:drawer={yamlEditorDrawer} />
|
|
{/await}
|
|
{/if}
|
|
{#if canUseDiffDrawer}
|
|
<DiffDrawer bind:this={diffDrawer} />
|
|
{/if}
|
|
<div
|
|
style={`height: ${height}px; max-height: ${maxHeight}px;`}
|
|
class="overflow-clip relative {outerDivClass}"
|
|
bind:clientWidth={debouncedWidth}
|
|
bind:this={flowContainer}
|
|
>
|
|
{#if graph?.error}
|
|
<div class="center-center p-2 mt-20">
|
|
<Alert title="Error parsing the flow" type="error" class="max-w-1/2">
|
|
{graph.error}
|
|
|
|
<Button
|
|
color="red"
|
|
size="xs"
|
|
btnClasses="mt-2 w-min"
|
|
on:click={() => yamlEditorDrawer?.openDrawer()}>Open YAML editor</Button
|
|
>
|
|
</Alert>
|
|
</div>
|
|
{:else}
|
|
<SvelteFlowProvider>
|
|
<ViewportResizer {height} {width} {nodes} bind:this={viewportResizer} />
|
|
{#if moveManager}
|
|
<DragCoordinator
|
|
{moveManager}
|
|
eventHandlers={eventHandler}
|
|
{edges}
|
|
nodes={nodesWithOffset}
|
|
/>
|
|
{/if}
|
|
{#if sharedViewport && onViewportChange}
|
|
<ViewportSynchronizer
|
|
{sharedViewport}
|
|
onLocalChange={onViewportChange}
|
|
bind:this={viewportSynchronizer}
|
|
/>
|
|
{/if}
|
|
<PaneContextMenu {editMode} bind:this={paneContextMenu} />
|
|
<SvelteFlow
|
|
onpaneclick={() => {
|
|
document.dispatchEvent(new Event('focus'))
|
|
selectionManager.clearSelection()
|
|
}}
|
|
onpanecontextmenu={({ event }) => {
|
|
paneContextMenu?.onPaneContextMenu(event)
|
|
}}
|
|
onnodedragstop={(event) => {
|
|
const node = event.targetNode
|
|
if (node && node.type === 'note') {
|
|
const positionWithOffset = {
|
|
x: node.position.x,
|
|
y: node.position.y - yOffset
|
|
}
|
|
onNotePositionUpdate?.(node.id, positionWithOffset)
|
|
}
|
|
}}
|
|
onmove={(event, viewport) => {
|
|
viewportSynchronizer?.handleLocalViewportChange(event, viewport)
|
|
}}
|
|
nodes={nodesWithOffset}
|
|
{edges}
|
|
{edgeTypes}
|
|
{nodeTypes}
|
|
{height}
|
|
{width}
|
|
minZoom={0.2}
|
|
maxZoom={1.6}
|
|
connectionLineType={ConnectionLineType.SmoothStep}
|
|
defaultEdgeOptions={{ type: 'smoothstep' }}
|
|
preventScrolling={scroll}
|
|
selectionOnDrag={selectionManager.mode === 'rect-select'}
|
|
elementsSelectable={true}
|
|
selectionMode={SelectionMode.Partial}
|
|
selectionKey={selectionManager.mode === 'rect-select' || !editMode ? null : modifierKey}
|
|
panActivationKey={selectionManager.mode === 'rect-select' ? modifierKey : null}
|
|
panOnDrag={selectionManager.mode === 'rect-select' ? [1] : true}
|
|
zoomOnDoubleClick={false}
|
|
elevateNodesOnSelect={false}
|
|
{proOptions}
|
|
multiSelectionKey={'Shift'}
|
|
deleteKey={null}
|
|
nodesDraggable={false}
|
|
--background-color={false}
|
|
>
|
|
<div class="absolute inset-0 !bg-surface-secondary h-full" id="flow-graph-v2"></div>
|
|
|
|
{#if noteMode}
|
|
<NoteTool {exitNoteMode} {yOffset} />
|
|
{/if}
|
|
|
|
{#if multiSelectEnabled}
|
|
<SelectionBoundingBox
|
|
selectedNodes={selectionManager.selectedIds.filter((id) =>
|
|
nodesWithOffset.some((n) => n.id === id)
|
|
)}
|
|
allNodes={nodesWithOffset as (Node & { type: string })[]}
|
|
onDeleteSelected={() => onDeleteMultiple?.(resolvedModuleIds)}
|
|
onDuplicateSelected={() => onDuplicateMultiple?.(resolvedModuleIds)}
|
|
onMoveSelected={() => onMoveMultiple?.(resolvedModuleIds)}
|
|
onCancelMove={() => onMoveMultiple?.(movingIds ?? [])}
|
|
{canMoveSelected}
|
|
isMoving={movingIds != null && movingIds.length > 0}
|
|
{resolvedModuleIds}
|
|
/>
|
|
{/if}
|
|
|
|
<GroupOverlay
|
|
allNodes={nodesWithOffset as (Node & { type: string })[]}
|
|
groups={allGroups}
|
|
groupDepths={currentGroupDepths}
|
|
/>
|
|
|
|
<!-- SelectionTool for handling selection changes and filtering -->
|
|
<SelectionTool {selectionManager} />
|
|
|
|
{#if leftHeader}
|
|
<div class="absolute top-2 left-2 z-10">
|
|
{@render leftHeader()}
|
|
</div>
|
|
{:else}
|
|
<!-- Their built-in glyphs are fill-based and sized differently from every other
|
|
icon in the editor, so the bar is built from lucide throughout. -->
|
|
<Controls
|
|
class="wm-flow-controls"
|
|
position={controlsPosition === 'bottom' ? 'bottom-right' : 'top-right'}
|
|
orientation="horizontal"
|
|
showZoom={false}
|
|
showFitView={false}
|
|
showLock={false}
|
|
>
|
|
<GraphZoomControls fitViewNodes={nodes.filter((n) => n.type !== 'note')} />
|
|
{#if multiSelectEnabled}
|
|
<div class="flex items-center gap-2">
|
|
<Tooltip>
|
|
<ControlButton
|
|
onclick={() => {
|
|
selectionManager.mode =
|
|
selectionManager.mode === 'normal' ? 'rect-select' : 'normal'
|
|
}}
|
|
>
|
|
{#if selectionManager.mode === 'rect-select'}
|
|
<MousePointer size="14" />
|
|
{:else}
|
|
<Hand size="14" />
|
|
{/if}
|
|
</ControlButton>
|
|
{#snippet text()}
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex items-center gap-2">
|
|
<Hand size="14" />
|
|
<span class="text-secondary"
|
|
><strong class="text-primary">Grab</strong>: Click and drag to pan. Hold
|
|
<kbd class="text-primary text-lg">{getModifierKey()}</kbd> to box select.</span
|
|
>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<MousePointer size="14" />
|
|
<span class="text-secondary"
|
|
><strong class="text-primary">Select</strong> Click and drag to box
|
|
select. Hold
|
|
<kbd class="text-primary text-lg">{getModifierKey()}</kbd> to pan.</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
</Tooltip>
|
|
</div>
|
|
{/if}
|
|
{#if download}
|
|
<ControlButton
|
|
onclick={() => {
|
|
try {
|
|
localStorage.setItem(
|
|
'svelvet',
|
|
encodeState({ modules, failureModule, preprocessorModule, notes, groups })
|
|
)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
window.open('/view_graph', '_blank')
|
|
}}
|
|
class="!bg-surface"
|
|
>
|
|
<Expand size="14" />
|
|
</ControlButton>
|
|
{/if}
|
|
<FlowPanelPlacementPicker variant="control" placement="top-end" />
|
|
</Controls>
|
|
|
|
<Controls
|
|
position="top-left"
|
|
orientation="vertical"
|
|
showLock={false}
|
|
showZoom={false}
|
|
showFitView={false}
|
|
class="!shadow-none gap-3"
|
|
style={leftHeader ? 'margin-top: 40px;' : ''}
|
|
>
|
|
{#if !hideAssetsToggle}
|
|
<Toggle bind:checked={$showAssets} size="xs" options={{ right: 'Assets' }} />
|
|
{/if}
|
|
{#if !hideNotesToggle}
|
|
<Toggle bind:checked={showNotes} size="xs" options={{ right: 'Notes' }} />
|
|
{/if}
|
|
{#if showDataflow}
|
|
<Toggle bind:checked={$useDataflow} size="xs" options={{ right: 'Dataflow' }} />
|
|
{/if}
|
|
</Controls>
|
|
{/if}
|
|
</SvelteFlow>
|
|
</SvelteFlowProvider>
|
|
{/if}
|
|
</div>
|
|
|
|
<style lang="postcss">
|
|
:global(.svelte-flow__handle) {
|
|
opacity: 0;
|
|
}
|
|
|
|
/* xy-flow's own rules are nested, so `.svelte-flow__controls-button svg` and the like
|
|
match ours exactly and load order decides. Scoping by the class we pass to <Controls>
|
|
outranks them instead of racing them. */
|
|
:global(.svelte-flow__controls.wm-flow-controls) {
|
|
/* Name the colour rather than leaning on the base layer's bare `.border`: preflight
|
|
sets a light `border-color` on every element, so whenever that base rule does not
|
|
make it into the bundle alongside this one the bar draws a white outline. */
|
|
@apply overflow-hidden rounded-md border border-border-light;
|
|
box-shadow: none;
|
|
}
|
|
:global(.wm-flow-controls .svelte-flow__controls-button) {
|
|
@apply bg-surface text-primary;
|
|
width: 32px;
|
|
height: 30px;
|
|
padding: 8px;
|
|
}
|
|
:global(.wm-flow-controls .svelte-flow__controls-button:hover) {
|
|
@apply bg-surface-hover;
|
|
}
|
|
:global(.wm-flow-controls.horizontal .svelte-flow__controls-button) {
|
|
@apply border-r border-gray-200 dark:border-gray-700;
|
|
}
|
|
:global(.wm-flow-controls.horizontal .svelte-flow__controls-button:last-child) {
|
|
@apply border-r-0;
|
|
}
|
|
/* Every glyph in this bar is lucide, so undo their base `fill: currentColor` — it beats
|
|
lucide's inline fill="none" and would flood the stroke icons solid — and lift the
|
|
12px cap that keeps them off the editor's icon scale. */
|
|
:global(.wm-flow-controls .svelte-flow__controls-button svg) {
|
|
max-width: 16px;
|
|
max-height: 16px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
}
|
|
|
|
:global(.svelte-flow__edgelabel-renderer) {
|
|
@apply z-50;
|
|
}
|
|
|
|
:global(.svelte-flow__selection) {
|
|
display: none;
|
|
pointer-events: none;
|
|
}
|
|
|
|
:global(.svelte-flow__selection-wrapper) {
|
|
pointer-events: none !important;
|
|
}
|
|
</style>
|