mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
* draft
* Phase 1: Remove deprecated granular flow AI tools
Simplify AI chat flow mode to use only YAML-based editing:
- Remove all commented-out granular tools (add_step, remove_step, set_code, etc.)
- Clean up FlowAIChatHelpers interface to only essential methods
- Update system prompts to focus on YAML-only workflow
- Remove unused imports and type definitions
This is part of a larger refactoring to simplify the flow editing
experience to a single YAML editing tool with automatic diff visualization.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* use minified json
* use openflow in system prompt
* handle inputs
* cleaning
* cleaning
* diffmode in flowgraph
* remove acceptrejectmodule
* use new diff mode
* cleaning
* better props
* better logic
* cleaning
* accept reject logic
* use get set
* draft manager
* use diff manager
* draft
* Refactor flowDiffManager to be instance-based with auto-computation
- Remove singleton export, making it instantiable per FlowGraphV2
- Add afterFlow state tracking for auto-diff computation
- Add beforeInputSchema/afterInputSchema for schema change tracking
- Add $effect for reactive auto-computation when beforeFlow/afterFlow changes
- Add setAfterFlow() and setInputSchemas() methods
- Simplify accept/reject methods to just mark pending=false
- Add validation to throw error when accepting/rejecting without beforeFlow
- Update setSnapshot to accept undefined for clearing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Refactor FlowGraphV2 to own diffManager instance
- Import and create diffManager instance per FlowGraphV2
- Remove onAcceptModule and onRejectModule props
- Add validation $effect to error if both diffBeforeFlow and moduleActions provided
- Add $effect to sync props (diffBeforeFlow or moduleActions) to diffManager
- Add $effect to watch current flow changes and update afterFlow
- Replace computedDiff with diffManager.moduleActions
- Use raw modules instead of merged flow (diffManager handles merging)
- Expose getDiffManager() and setBeforeFlow() methods
- Pass diffManager to graph context instead of callbacks
- Remove $inspect for removed props
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Update FlowModuleSchemaMap to use FlowGraphV2's diffManager
- Remove import of flowDiffManager singleton
- Update setBeforeFlow to call graph.setBeforeFlow()
- Update setModuleActions and getModuleActions to use graph.getDiffManager()
- Add getDiffManager() proxy method
- Simplify handleAcceptModule and handleRejectModule to use new API
- Handle editor state separately from diff operations
- Remove diffBeforeFlow, moduleActions, onAcceptModule, onRejectModule props passed to FlowGraphV2
- Remove onAcceptModule and onRejectModule from Props interface and destructured props
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Update FlowAIChat to use flowModuleSchemaMap's diffManager
- Remove import of flowDiffManager singleton
- Update revertToSnapshot to use flowModuleSchemaMap.getDiffManager()
- Add null check for diffManager before using
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Verify FlowGraphDiffViewer compatibility with refactored architecture
FlowGraphDiffViewer already uses the correct prop patterns:
- Before graph: moduleActions prop (display-only mode)
- After graph: diffBeforeFlow prop (full diff mode with auto-computation)
Each FlowGraphV2 instance creates its own diffManager, making the side-by-side
view work correctly with independent diff state per graph.
No code changes required.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Update graph components to use diffManager instead of callbacks
- Update graphBuilder.svelte.ts to pass diffManager instead of onAcceptModule/onRejectModule
- Update InputNode and ModuleN type definitions with diffManager
- Update ModuleNode.svelte to pass diffManager to MapItem
- Update MapItem.svelte to pass diffManager to FlowModuleSchemaItem
- Update FlowModuleSchemaItem.svelte to use diffManager directly for accept/reject
- Replace callback-based accept/reject with direct diffManager calls
- Only show accept/reject buttons when beforeFlow exists and action is pending
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix removed modules not showing in diff viewer
Problem: After refactoring, removed modules were no longer appearing in the
diff viewer because we changed effectiveModules from using the merged flow
(which includes removed modules) to using raw modules.
Solution:
- Add mergedFlow state to flowDiffManager to store timeline's merged flow
- Add markRemovedAsShadowed parameter support for side-by-side view
- Store timeline.mergedFlow in auto-computation $effect
- Add getter for mergedFlow and setMarkRemovedAsShadowed method
- Clear mergedFlow in clearSnapshot()
- Update FlowGraphV2 to set markRemovedAsShadowed in diffManager
- Update effectiveModules/FailureModule/PreprocessorModule to use mergedFlow
The merged flow contains all modules including removed ones, enabling:
- Unified view: Removed modules appear in red with "removed" badge
- Side-by-side view: Removed modules show as shadowed in After graph
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Simplify accept/reject logic by removing actions instead of toggling pending state
Previously, accepting or rejecting a module action would set pending to false but keep the action in the moduleActions map. This caused a bug where the $effect would overwrite moduleActions with fresh actions having pending: true, making accept/reject buttons reappear on previously handled modules.
Now, when a user accepts or rejects a module action, we remove it entirely from the moduleActions map. This is simpler and fixes the button reappearing issue.
Changes:
- acceptModule: Remove action from moduleActions instead of setting pending: false
- rejectModule: Remove action from moduleActions instead of setting pending: false
- checkAndClearSnapshot: Check if moduleActions is empty instead of checking pending states
- Fix typo: getModuleFromFrom → getModuleFromFlow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* cleaning
* fix logic
* make diff drawer part of manager
* accept submodules
* fixes
* Phase 4: Add checkAndApplyChanges() helper to flowDiffManager
- Added new checkAndApplyChanges() function to apply mergedFlow to flowStore when all changes are decided
- This replaces the old checkAndClearSnapshot() behavior and ensures flowStore is updated atomically
- Handles both flow structure and input schema updates
* Phase 2: Simplify acceptModule() - only modify mergedFlow
- Remove flowStore mutations from acceptModule()
- For removed modules: just delete the shadowed (__prefix) version from mergedFlow
- For added/modified: no action needed (already correct in mergedFlow)
- Call checkAndApplyChanges() to apply changes when all decided
* Phase 3: Simplify rejectModule() - only modify mergedFlow
- Remove all flowStore mutations from rejectModule()
- For added modules: delete from mergedFlow
- For removed modules: replace shadowed (__) module with original from beforeFlow
- For modified modules: restore old version in mergedFlow
- For Input schema: revert afterInputSchema
- Call checkAndApplyChanges() to apply changes when all decided
* Phase 5: Verify acceptAll/rejectAll work with new architecture
- acceptAll() and rejectAll() already pass options correctly to acceptModule/rejectModule
- They will automatically benefit from checkAndApplyChanges()
- No changes needed for this phase
* Phase 6: Remove FlowGraphV2 reactive effect that updates afterFlow
- Removed the (lines 252-266) that continuously updated afterFlow
- This effect created reactive loops when flowStore changed
- afterFlow should only be set once when AI generates changes via setFlowYaml()
- The initial sync effect (lines 226-250) is kept for prop-driven diff mode
* Phase 7: Update FlowAIChat setFlowYaml to use diffManager
- Changed setFlowYaml() to use diffManager.setAfterFlow() instead of modifying flowStore
- flowStore remains unchanged during AI review phase
- Changes are staged in mergedFlow for user review
- Only applied to flowStore when all changes are accepted/rejected
- Added error handling for missing diffManager
* Fix linter warnings
- Remove unused FlowTimeline type import
- Fix ChangeTracker initialization with proper type parameter
- Keep deleteModuleFromFlow and checkAndClearSnapshot for potential future use
* Update plan document with implementation status
- Mark all phases as complete
- Add commit references
- Update file checklist
- Add implementation summary at top of document
* Add comprehensive implementation summary document
- Detailed overview of architecture changes
- Before/after comparisons for each file
- Complete testing scenarios checklist
- Troubleshooting guide
- Migration notes and backwards compatibility info
* Show pending modules in editor panel
- Pass diffManager from FlowModuleSchemaMap to FlowEditorPanel
- Add effectiveModules derived value that uses mergedFlow when in diff mode
- Update module iteration to use effectiveModules instead of flowStore
- Allows users to view added/modified modules during AI review
- Fixes issue where clicking on pending modules showed nothing
* Add implementation summary for show pending modules feature
* fix
* shorter system prompt
* Fix Input schema diff mode issues
- Add Accept/Reject buttons to Input node (previously only showed Diff button)
- Pass diffManager to FlowInput component
- Add effectiveSchema derived value that uses afterInputSchema when in diff mode
- Add effectiveDisabled to prevent editing Input when reviewing AI changes
- Update FlowInputViewer to show pending schema changes
- Fixes issue where Input schema changes couldn't be accepted/rejected
- Fixes issue where pending Input schema wasn't visible in the panel
* Disable delete and move buttons when in pending mode
- Add effectiveDeletable derived value that checks diffManager.hasPendingChanges
- Replace all instances of deletable with effectiveDeletable in template
- Prevents delete/move operations when AI changes are being reviewed
- Delete and move buttons are hidden when there are pending changes
- Buttons reappear once all changes are accepted or rejected
- Prevents conflicting operations during review phase
* no move or delte when reviewing
* use context
* inline script reduction
* use json
* rollback to direct modif
* fix merge
* cleaning
* fix reject removed
* add set step code tool
* better prompt
* add back relevant tools
* add back accept reject
* use edit mode for pending
* fix input
* remove unneeded effect
* cleaner + bug fix
* fix failure and preprocessor
* fix show diff for failure module
* fix accept reject on failre module
* no auto add module to context
* cleaning
* add back effect
* cleaning
* fix multiple setflowjson
* track effectivemoduleactions for graph rendering
* nit prompt
* styling
* rm md files
* rm flake copy
* cleaning
* fix z index
* fix revert
* only change before after
* use add remove modify tools
* input + failure + preproc tools
* parsing issues
* nit
* use raw schema for tools
* resolve ref for gemini
* fix schema
* show test on graph
* much cleaner logic
* ignore empty assets
* Remove debug console.log statements from production code
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Remove debug $inspect calls from FlowGraphV2
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Add error logging to setFlowJson before re-throwing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Standardize null/undefined handling to prefer null
- Use .nullable().optional() instead of .nullish() in Zod schemas
- Simplify addModuleToFlow signature to use string | null
- Coerce undefined to null when extracting parsed args
- Simplify null checks to only check !== null
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Remove debug console.log from AI tool functions
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Extract special module IDs to constants
Add SPECIAL_MODULE_IDS constant with INPUT, PREPROCESSOR, and FAILURE
to avoid magic strings throughout the flow AI chat code.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Add cleanup for diffDrawer reference on unmount
Prevents potential memory leaks by clearing the diffDrawer reference
when the FlowGraphV2 component is destroyed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Use structuredClone instead of JSON.parse(JSON.stringify())
structuredClone is more efficient and type-safe for deep cloning objects.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Cache module lookups in reconstructMergedFlow
Move getAllModulesMap and getAllModuleIds calls outside the loop to avoid
redundant recomputation. Track merged IDs incrementally as modules are added.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* Revert "Use structuredClone instead of JSON.parse(JSON.stringify())"
This reverts commit a62ba5b980.
* cleaning
* allow delete
* better openflow for ai agents + truncate system prompt
* handle ai agent tools
* fix set code for tool
* fix wrong cancel request called
* mark tool calls as canceled
* get lang instructions
* use streamiing args
* give db url to claude
* fix revert
* save and clear when leaving editor
* keep whitespace in user message
* uniformize colors
* fix diff button
* remove db from backend claude
* remove move module tool
* no failure and preprocessor
* fix error given to llm
* fix z index
* fix ts errors
* cleaning
* fix add module logic
* fix(copilot): add 'tools' to branchPath description for aiagent containers
The branchPath parameter description was missing 'tools' option for aiagent
containers and didn't mention branchall support.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(copilot): correct AI agent tool IDs and summaries documentation
Tool summaries CAN contain spaces (they're human-readable descriptions).
Only tool IDs must avoid spaces.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(copilot): remove reference to non-existent set_flow_json tool
The set_module_code tool description referenced set_flow_json which
doesn't exist as an exposed tool (it's an internal helper).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(copilot): clarify inspect_inline_script is read-only
The tool description incorrectly suggested it could modify code.
This tool only inspects - use set_module_code to modify.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(copilot): clarify afterId behavior for AI agent tools
Updated wording to clarify that afterId can be used but is optional
for AI agent tools since tool order doesn't affect execution.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor(copilot): remove unused id param from get_instructions_for_code_generation
The id parameter was only used to check for preprocessor, which is no
longer needed. Simplified the tool to only require the language param.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(copilot): add result format to search_scripts tool description
Helps AI understand what data format to expect from the tool.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(copilot): add result format to resource_type tool description
Helps AI understand what data format to expect from the tool and
provides example resource type names.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* nit
* Add support for adding branches to branchall/branchone via add_module
Previously, add_module could only add modules inside existing branches.
Now, using insideId with branchPath=null will add a NEW branch to a
branchall or branchone container.
API:
- add_module({ insideId: "my_branchall", branchPath: null, value: { summary: "New Branch", skip_failure: false, modules: [] } })
- add_module({ insideId: "my_branchone", branchPath: null, value: { summary: "Condition", expr: "...", modules: [] } })
Changes:
- Extended addModuleToFlow to handle branchPath=null case
- Updated validation to allow branchPath=null when adding branches
- Updated tool descriptions and system prompt documentation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* nit
* add remove branch tool
* check all ids for duplicates
* no dup
* nit
* cleaning
* fix dup ids
* split core.ts
* only mount diff drawer if useful
* remove wrong logic
* update exprs
* fix
* chore(flow): Add unit tests to flow diff manager (#7291)
* setup
* add basic tests for flowdiff
* add complex tests
* fix branch issue
* more complex tests
* add flow diff manager tests
* add utils
* better handling of moved case
* more tests for move case
* add buggy test case
* rm
* rework config
* cleaning
* fix config
* rm
* fix for reverting type change module
* all good
* rm
* add missing testmode
---------
Co-authored-by: Claude <noreply@anthropic.com>
1270 lines
36 KiB
Svelte
1270 lines
36 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
FlowService,
|
|
type Flow,
|
|
DraftService,
|
|
type PathScript,
|
|
type OpenFlow,
|
|
type InputTransform,
|
|
type TriggersCount,
|
|
CaptureService,
|
|
type Job
|
|
} from '$lib/gen'
|
|
import { initHistory, redo, undo } from '$lib/history.svelte'
|
|
import {
|
|
enterpriseLicense,
|
|
userStore,
|
|
workspaceStore,
|
|
usedTriggerKinds
|
|
} from '$lib/stores'
|
|
import {
|
|
cleanValueProperties,
|
|
encodeState,
|
|
generateRandomString,
|
|
orderedJsonStringify,
|
|
readFieldsRecursively,
|
|
replaceFalseWithUndefined,
|
|
type StateStore,
|
|
type Value
|
|
} from '$lib/utils'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { Drawer } from '$lib/components/common'
|
|
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
|
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
|
|
|
|
import { onMount, setContext, untrack, type ComponentType } from 'svelte'
|
|
import { writable } from 'svelte/store'
|
|
import CenteredPage from './CenteredPage.svelte'
|
|
import { Badge, Button, UndoRedo } from './common'
|
|
import FlowEditor from './flows/FlowEditor.svelte'
|
|
import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte'
|
|
import { dfs as dfsApply } from './flows/dfs'
|
|
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
|
|
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
|
|
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
|
|
import { SelectionManager } from './graph/selectionUtils.svelte'
|
|
import { NoteEditor } from './graph/noteEditor.svelte'
|
|
import { setNoteEditorContext } from './graph/noteEditor.svelte'
|
|
import { cleanFlow } from './flows/utils.svelte'
|
|
import {
|
|
Calendar,
|
|
Pen,
|
|
Save,
|
|
DiffIcon,
|
|
HistoryIcon,
|
|
FileJson,
|
|
type Icon,
|
|
Settings
|
|
} from 'lucide-svelte'
|
|
import Awareness from './Awareness.svelte'
|
|
import { getAllModules } from './flows/flowExplorer'
|
|
import { type FlowCopilotContext } from './copilot/flow'
|
|
import { loadFlowModuleState } from './flows/flowStateUtils.svelte'
|
|
import Dropdown from '$lib/components/DropdownV2.svelte'
|
|
import FlowTutorials from './FlowTutorials.svelte'
|
|
import FlowHistory from './flows/FlowHistory.svelte'
|
|
import FlowEditorTutorial from './flows/FlowEditorTutorial.svelte'
|
|
import Summary from './Summary.svelte'
|
|
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
|
|
import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte'
|
|
import { type TriggerContext, type ScheduleTrigger } from './triggers'
|
|
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
|
|
import DeployButton from './DeployButton.svelte'
|
|
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
|
|
import {
|
|
deployTriggers,
|
|
filterDraftTriggers,
|
|
handleSelectTriggerFromKind
|
|
} from './triggers/utils'
|
|
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
|
import { Triggers } from './triggers/triggers.svelte'
|
|
import { StepsInputArgs } from './flows/stepsInputArgs.svelte'
|
|
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
|
|
import type { GraphModuleState } from './graph'
|
|
import { validateRetryConfig } from '$lib/utils'
|
|
import {
|
|
setStepHistoryLoaderContext,
|
|
StepHistoryLoader,
|
|
type stepState
|
|
} from './stepHistoryLoader.svelte'
|
|
import type { FlowBuilderProps } from './flow_builder'
|
|
import { ModulesTestStates } from './modulesTest.svelte'
|
|
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from './flows/FlowAssetsHandler.svelte'
|
|
import { inputSizeClasses } from './text_input/TextInput.svelte'
|
|
|
|
let {
|
|
initialPath = $bindable(''),
|
|
pathStoreInit = undefined,
|
|
newFlow,
|
|
selectedId,
|
|
initialArgs = {},
|
|
loading = false,
|
|
flowStore,
|
|
flowStateStore,
|
|
savedFlow = $bindable(undefined),
|
|
diffDrawer = undefined,
|
|
customUi = {},
|
|
disableAi = false,
|
|
disabledFlowInputs = false,
|
|
savedPrimarySchedule = undefined,
|
|
version = undefined,
|
|
setSavedraftCb = undefined,
|
|
draftTriggersFromUrl = undefined,
|
|
selectedTriggerIndexFromUrl = undefined,
|
|
children,
|
|
loadedFromHistoryFromUrl,
|
|
noInitial = false,
|
|
onSaveInitial,
|
|
onSaveDraft,
|
|
onDeploy,
|
|
onDeployError,
|
|
onDetails,
|
|
onSaveDraftError,
|
|
onSaveDraftOnlyAtNewPath,
|
|
onHistoryRestore
|
|
}: FlowBuilderProps = $props()
|
|
|
|
let initialPathStore = writable(initialPath)
|
|
|
|
// used for new flows for captures
|
|
let fakeInitialPath =
|
|
'u/' +
|
|
($userStore?.username?.includes('@')
|
|
? $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
|
|
: $userStore?.username) +
|
|
'/' +
|
|
generateRandomString(12)
|
|
|
|
// Used by multiplayer deploy collision warning
|
|
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
|
let deployedBy: string | undefined = $state(undefined) // Author
|
|
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
|
let open: boolean = $state(false) // Is confirmation modal open
|
|
|
|
// Draft triggers confirmation modal
|
|
let draftTriggersModalOpen = $state(false)
|
|
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
|
|
|
|
// AI changes warning modal
|
|
let aiChangesWarningOpen = $state(false)
|
|
let aiChangesConfirmCallback = $state<() => void>(() => {})
|
|
|
|
// Flow preview
|
|
let flowPreviewButtons: FlowPreviewButtons | undefined = $state()
|
|
const flowPreviewContent = $derived(flowPreviewButtons?.getFlowPreviewContent())
|
|
const job: Job | undefined = $derived(flowPreviewContent?.getJob())
|
|
let showJobStatus = $state(false)
|
|
|
|
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
|
|
const { selectedTriggers } = event.detail
|
|
// Continue with saving the flow
|
|
draftTriggersModalOpen = false
|
|
confirmDeploymentCallback(selectedTriggers)
|
|
}
|
|
|
|
function hasAIChanges(): boolean {
|
|
return aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
|
|
}
|
|
|
|
function withAIChangesWarning(callback: () => void) {
|
|
if (hasAIChanges()) {
|
|
aiChangesConfirmCallback = () => {
|
|
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
|
|
callback()
|
|
}
|
|
aiChangesWarningOpen = true
|
|
} else {
|
|
callback()
|
|
}
|
|
}
|
|
|
|
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
|
return {
|
|
savedValue: savedFlow,
|
|
modifiedValue: {
|
|
...flowStore.val,
|
|
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
|
}
|
|
}
|
|
}
|
|
let onLatest = true
|
|
async function compareVersions() {
|
|
if (version === undefined) {
|
|
return
|
|
}
|
|
try {
|
|
if (initialPath && initialPath != '') {
|
|
const flowVersion = await FlowService.getFlowLatestVersion({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath
|
|
})
|
|
|
|
onLatest = version === flowVersion?.id
|
|
} else {
|
|
onLatest = true
|
|
}
|
|
} catch (err) {
|
|
console.error('Error comparing versions', err)
|
|
onLatest = true
|
|
}
|
|
}
|
|
|
|
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
|
|
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
|
const simplifiedPoll = writable(false)
|
|
|
|
// used to set the primary schedule in the legacy primaryScheduleStore
|
|
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
|
|
primaryScheduleStore.set(schedule)
|
|
}
|
|
|
|
export function setDraftTriggers(triggers: Trigger[] | undefined) {
|
|
triggersState.setTriggers([
|
|
...(triggers ?? []),
|
|
...triggersState.triggers.filter((t) => !t.draftConfig)
|
|
])
|
|
loadTriggers()
|
|
}
|
|
|
|
export function setSelectedTriggerIndex(index: number | undefined) {
|
|
triggersState.selectedTriggerIndex = index
|
|
}
|
|
|
|
let loadingSave = $state(false)
|
|
let loadingDraft = $state(false)
|
|
|
|
export async function saveDraft(forceSave = false): Promise<void> {
|
|
withAIChangesWarning(async () => {
|
|
await saveDraftInternal(forceSave)
|
|
})
|
|
}
|
|
|
|
async function saveDraftInternal(forceSave = false): Promise<void> {
|
|
if (!newFlow && !savedFlow) {
|
|
return
|
|
}
|
|
|
|
if (savedFlow) {
|
|
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
|
|
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
|
const current = cleanValueProperties(
|
|
$state.snapshot({
|
|
...flowStore.val,
|
|
path: $pathStore,
|
|
draft_triggers: currentDraftTriggers
|
|
})
|
|
)
|
|
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
|
sendUserToast('No changes detected, ignoring', false, [
|
|
{
|
|
label: 'Save anyway',
|
|
callback: () => {
|
|
saveDraftInternal(true)
|
|
}
|
|
}
|
|
])
|
|
return
|
|
}
|
|
}
|
|
loadingDraft = true
|
|
try {
|
|
const flow = cleanFlow(flowStore.val)
|
|
try {
|
|
localStorage.removeItem('flow')
|
|
localStorage.removeItem(`flow-${$pathStore}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
if (newFlow || savedFlow?.draft_only) {
|
|
if (savedFlow?.draft_only) {
|
|
await FlowService.deleteFlowByPath({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath,
|
|
keepCaptures: true
|
|
})
|
|
}
|
|
if (!initialPath || $pathStore != initialPath) {
|
|
await CaptureService.moveCapturesAndConfigs({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath || fakeInitialPath,
|
|
requestBody: {
|
|
new_path: $pathStore
|
|
},
|
|
runnableKind: 'flow'
|
|
})
|
|
}
|
|
await FlowService.createFlow({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: $pathStore,
|
|
summary: flow.summary ?? '',
|
|
description: flow.description ?? '',
|
|
value: flow.value,
|
|
schema: flow.schema,
|
|
tag: flow.tag,
|
|
draft_only: true,
|
|
ws_error_handler_muted: flow.ws_error_handler_muted,
|
|
visible_to_runner_only: flow.visible_to_runner_only,
|
|
on_behalf_of_email: flow.on_behalf_of_email
|
|
}
|
|
})
|
|
}
|
|
await DraftService.createDraft({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: newFlow || savedFlow?.draft_only ? $pathStore : initialPath,
|
|
typ: 'flow',
|
|
value: {
|
|
...flow,
|
|
path: $pathStore,
|
|
draft_triggers: triggersState.getDraftTriggersSnapshot()
|
|
}
|
|
}
|
|
})
|
|
|
|
savedFlow = {
|
|
...(newFlow || savedFlow?.draft_only
|
|
? {
|
|
...structuredClone($state.snapshot(flowStore.val)),
|
|
path: $pathStore,
|
|
draft_only: true
|
|
}
|
|
: savedFlow),
|
|
draft: {
|
|
...structuredClone($state.snapshot(flowStore.val)),
|
|
path: $pathStore,
|
|
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
|
}
|
|
} as FlowWithDraftAndDraftTriggers
|
|
|
|
let savedAtNewPath = false
|
|
if (newFlow) {
|
|
onSaveInitial?.({ path: $pathStore, id: getSelectedId() ?? 'settings' })
|
|
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
|
|
savedAtNewPath = true
|
|
initialPath = $pathStore
|
|
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() ?? 'settings' })
|
|
// this is so we can use the flow builder outside of sveltekit
|
|
}
|
|
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
|
|
sendUserToast('Saved as draft')
|
|
} catch (error) {
|
|
sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true)
|
|
onSaveDraftError?.({ error })
|
|
}
|
|
loadingDraft = false
|
|
}
|
|
|
|
onMount(() => {
|
|
setSavedraftCb?.(() => saveDraft())
|
|
})
|
|
|
|
export function computeUnlockedSteps(flow: Flow) {
|
|
return Object.fromEntries(
|
|
getAllModules(flow.value.modules, flow.value.failure_module)
|
|
.filter((m) => m.value.type == 'script' && m.value.hash == null)
|
|
.map((m) => [m.id, (m.value as PathScript).path])
|
|
)
|
|
}
|
|
|
|
async function handleSaveFlow(deploymentMsg?: string) {
|
|
withAIChangesWarning(async () => {
|
|
await handleSaveFlowInternal(deploymentMsg)
|
|
})
|
|
}
|
|
|
|
async function handleSaveFlowInternal(deploymentMsg?: string) {
|
|
await compareVersions()
|
|
if (onLatest || initialPath == '' || savedFlow?.draft_only) {
|
|
// Handle directly
|
|
await saveFlow(deploymentMsg)
|
|
} else {
|
|
// We need it for diff
|
|
await syncWithDeployed()
|
|
|
|
if (
|
|
deployedValue &&
|
|
flowStore.val &&
|
|
orderedJsonStringify(deployedValue) ===
|
|
orderedJsonStringify(replaceFalseWithUndefined({ ...flowStore.val, path: $pathStore }))
|
|
) {
|
|
await saveFlow(deploymentMsg)
|
|
} else {
|
|
// Handle through confirmation modal
|
|
confirmCallback = async () => {
|
|
await saveFlow(deploymentMsg)
|
|
}
|
|
// Open confirmation modal
|
|
open = true
|
|
}
|
|
}
|
|
}
|
|
async function syncWithDeployed() {
|
|
const flow = await FlowService.getFlowByPath({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath,
|
|
withStarredInfo: true
|
|
})
|
|
deployedValue = replaceFalseWithUndefined({
|
|
...flow,
|
|
edited_at: undefined,
|
|
edited_by: undefined,
|
|
workspace_id: undefined
|
|
})
|
|
deployedBy = flow.edited_by
|
|
}
|
|
|
|
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
|
|
if (!triggersToDeploy) {
|
|
// Check if there are draft triggers that need confirmation
|
|
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
|
|
if (draftTriggers.length > 0) {
|
|
draftTriggersModalOpen = true
|
|
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
|
|
await saveFlow(deploymentMsg, triggersToDeploy)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
loadingSave = true
|
|
try {
|
|
const flow = cleanFlow(flowStore.val)
|
|
|
|
if (flow.value?.modules) {
|
|
const validationErrors: string[] = []
|
|
dfsApply(flow.value.modules, (module) => {
|
|
const error = validateRetryConfig(module.retry)
|
|
if (error) {
|
|
validationErrors.push(`Step '${module.id}': ${error}`)
|
|
}
|
|
})
|
|
|
|
if (flow.value.failure_module) {
|
|
// add validation logic here for failure module
|
|
}
|
|
|
|
if (flow.value.preprocessor_module) {
|
|
// add validation logic here for preprocessor module
|
|
}
|
|
|
|
if (validationErrors.length > 0) {
|
|
throw new Error(validationErrors.join('\n'))
|
|
}
|
|
}
|
|
// console.log('flow', computeUnlockedSteps(flow)) // del
|
|
// loadingSave = false // del
|
|
// return
|
|
|
|
if (newFlow) {
|
|
try {
|
|
localStorage.removeItem('flow')
|
|
localStorage.removeItem(`flow-${$pathStore}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
await FlowService.createFlow({
|
|
workspace: $workspaceStore!,
|
|
requestBody: {
|
|
path: $pathStore,
|
|
summary: flow.summary ?? '',
|
|
description: flow.description ?? '',
|
|
value: flow.value,
|
|
schema: flow.schema,
|
|
ws_error_handler_muted: flow.ws_error_handler_muted,
|
|
tag: flow.tag,
|
|
dedicated_worker: flow.dedicated_worker,
|
|
visible_to_runner_only: flow.visible_to_runner_only,
|
|
on_behalf_of_email: flow.on_behalf_of_email,
|
|
deployment_message: deploymentMsg || undefined
|
|
}
|
|
})
|
|
await CaptureService.moveCapturesAndConfigs({
|
|
workspace: $workspaceStore!,
|
|
path: fakeInitialPath,
|
|
requestBody: {
|
|
new_path: $pathStore
|
|
},
|
|
runnableKind: 'flow'
|
|
})
|
|
if (triggersToDeploy) {
|
|
await deployTriggers(
|
|
triggersToDeploy,
|
|
$workspaceStore,
|
|
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
|
|
usedTriggerKinds,
|
|
$pathStore,
|
|
true
|
|
)
|
|
}
|
|
} else {
|
|
try {
|
|
localStorage.removeItem(`flow-${initialPath}`)
|
|
} catch (e) {
|
|
console.error('error interacting with local storage', e)
|
|
}
|
|
|
|
if (triggersToDeploy) {
|
|
await deployTriggers(
|
|
triggersToDeploy,
|
|
$workspaceStore,
|
|
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
|
|
usedTriggerKinds,
|
|
initialPath
|
|
)
|
|
}
|
|
|
|
await FlowService.updateFlow({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath,
|
|
requestBody: {
|
|
path: $pathStore,
|
|
summary: flow.summary,
|
|
description: flow.description ?? '',
|
|
value: flow.value,
|
|
schema: flow.schema,
|
|
tag: flow.tag,
|
|
dedicated_worker: flow.dedicated_worker,
|
|
ws_error_handler_muted: flow.ws_error_handler_muted,
|
|
visible_to_runner_only: flow.visible_to_runner_only,
|
|
on_behalf_of_email: flow.on_behalf_of_email,
|
|
deployment_message: deploymentMsg || undefined
|
|
}
|
|
})
|
|
}
|
|
|
|
const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & {
|
|
draft_triggers: Trigger[]
|
|
}
|
|
savedFlow = {
|
|
...structuredClone($state.snapshot(newSavedFlow)),
|
|
path: $pathStore
|
|
} as Flow
|
|
setDraftTriggers([])
|
|
loadingSave = false
|
|
onDeploy?.({ path: $pathStore })
|
|
} catch (err) {
|
|
onDeployError?.({ error: err })
|
|
// this is so we can use the flow builder outside of sveltekit
|
|
sendUserToast(`The flow could not be saved: ${err.body ?? err}`, true)
|
|
loadingSave = false
|
|
}
|
|
}
|
|
|
|
let timeout: number | undefined = undefined
|
|
|
|
function saveSessionDraft() {
|
|
timeout && clearTimeout(timeout)
|
|
timeout = setTimeout(() => {
|
|
try {
|
|
localStorage.setItem(
|
|
initialPath && initialPath != '' ? `flow-${initialPath}` : 'flow',
|
|
encodeState({
|
|
flow: flowStore.val,
|
|
path: $pathStore,
|
|
selectedId: selectedIdStore,
|
|
draft_triggers: triggersState.getDraftTriggersSnapshot(),
|
|
selected_trigger: triggersState.getSelectedTriggerSnapshot(),
|
|
loadedFromHistory: {
|
|
flowJobInitial: stepHistoryLoader.flowJobInitial,
|
|
stepsState: stepHistoryLoader.stepStates
|
|
}
|
|
})
|
|
)
|
|
} catch (err) {
|
|
console.error(err)
|
|
}
|
|
}, 500)
|
|
}
|
|
|
|
const selectionManager = new SelectionManager()
|
|
const selectedIdStore = $derived(selectionManager.getSelectedId())
|
|
// Initialize with selected id if provided
|
|
if (selectedId) {
|
|
selectionManager.selectId(selectedId)
|
|
} else {
|
|
selectionManager.selectId('settings-metadata')
|
|
}
|
|
|
|
export function getSelectedId() {
|
|
return selectedIdStore
|
|
}
|
|
|
|
const previewArgsStore = $state({ val: initialArgs })
|
|
const scriptEditorDrawer = writable<ScriptEditorDrawer | undefined>(undefined)
|
|
const moving = writable<{ id: string } | undefined>(undefined)
|
|
const history = initHistory(flowStore.val)
|
|
const pathStore = writable<string>(pathStoreInit ?? initialPath)
|
|
const captureOn = writable<boolean>(false)
|
|
const showCaptureHint = writable<boolean | undefined>(undefined)
|
|
const flowInputEditorStateStore = writable<FlowInputEditorState>({
|
|
selectedTab: undefined,
|
|
editPanelSize: 0,
|
|
payloadData: undefined
|
|
})
|
|
|
|
const stepsInputArgs = new StepsInputArgs()
|
|
|
|
function select(selectedId: string) {
|
|
selectionManager.selectId(selectedId)
|
|
}
|
|
|
|
let insertButtonOpen = writable<boolean>(false)
|
|
let modulesTestStates = new ModulesTestStates()
|
|
let outputPickerOpenFns: Record<string, () => void> = $state({})
|
|
let flowEditor: FlowEditor | undefined = $state(undefined)
|
|
|
|
setContext<FlowEditorContext>('FlowEditorContext', {
|
|
selectionManager,
|
|
currentEditor: writable(undefined),
|
|
previewArgs: previewArgsStore,
|
|
scriptEditorDrawer,
|
|
moving,
|
|
history,
|
|
flowStateStore,
|
|
flowStore,
|
|
pathStore,
|
|
stepsInputArgs,
|
|
saveDraft,
|
|
initialPathStore,
|
|
fakeInitialPath,
|
|
flowInputsStore: writable<FlowInput>({}),
|
|
customUi,
|
|
insertButtonOpen,
|
|
executionCount: writable(0),
|
|
flowInputEditorState: flowInputEditorStateStore,
|
|
modulesTestStates,
|
|
outputPickerOpenFns
|
|
})
|
|
|
|
// Set up NoteEditor context for note editing capabilities
|
|
const noteEditor = new NoteEditor(flowStore, () => {
|
|
// Enable notes display when a note is created
|
|
flowEditor?.enableNotes?.()
|
|
})
|
|
setNoteEditorContext(noteEditor)
|
|
|
|
setContext(
|
|
'FlowGraphAssetContext',
|
|
initFlowGraphAssetsCtx({ getModules: () => flowStore.val.value.modules })
|
|
)
|
|
|
|
// Add triggers context store
|
|
const triggersState = $state(
|
|
new Triggers(
|
|
[
|
|
{ type: 'webhook', path: '', isDraft: false },
|
|
{ type: 'default_email', path: '', isDraft: false },
|
|
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
|
|
],
|
|
selectedTriggerIndexFromUrl,
|
|
saveSessionDraft
|
|
)
|
|
)
|
|
|
|
setContext<TriggerContext>('TriggerContext', {
|
|
triggersCount,
|
|
simplifiedPoll,
|
|
showCaptureHint,
|
|
triggersState
|
|
})
|
|
|
|
export async function loadTriggers() {
|
|
if (initialPath == '') return
|
|
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
|
workspace: $workspaceStore!,
|
|
path: initialPath
|
|
})
|
|
|
|
// Initialize triggers using utility function
|
|
await triggersState.fetchTriggers(
|
|
triggersCount,
|
|
$workspaceStore,
|
|
initialPath,
|
|
true,
|
|
$primaryScheduleStore,
|
|
$userStore
|
|
)
|
|
|
|
if (savedFlow && savedFlow.draft) {
|
|
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
|
|
}
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
let classes = event.target?.['className']
|
|
if (
|
|
(typeof classes === 'string' && classes.includes('inputarea')) ||
|
|
['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName!)
|
|
) {
|
|
return
|
|
}
|
|
|
|
switch (event.key) {
|
|
case 'Z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
flowStore.val = redo(history)
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 'z':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
flowStore.val = undo(history, flowStore.val)
|
|
selectionManager.selectId('Input')
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 's':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
saveDraft()
|
|
event.preventDefault()
|
|
}
|
|
break
|
|
case 'ArrowDown': {
|
|
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
|
|
let ids = generateIds()
|
|
let idx = ids.indexOf(selectedIdStore!)
|
|
if (idx > -1 && idx < ids.length - 1) {
|
|
selectionManager.selectId(ids[idx + 1])
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'ArrowUp': {
|
|
if (!$insertButtonOpen && !flowPreviewButtons?.getPreviewOpen()) {
|
|
let ids = generateIds()
|
|
let idx = ids.indexOf(selectedIdStore!)
|
|
if (idx > 0 && idx < ids.length) {
|
|
selectionManager.selectId(ids[idx - 1])
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateIds() {
|
|
return [
|
|
'settings-metadata',
|
|
'constants',
|
|
'preprocessor',
|
|
...dfsApply(flowStore.val.value.modules, (module) => module.id)
|
|
]
|
|
}
|
|
|
|
const dropdownItems: Array<{
|
|
label: string
|
|
onClick: () => void
|
|
}> = []
|
|
|
|
if (customUi.topBar?.extraDeployOptions != false) {
|
|
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
|
|
dropdownItems.push({
|
|
label: 'Exit & see details',
|
|
onClick: () => onDetails?.({ path: $pathStore })
|
|
})
|
|
}
|
|
|
|
if (!newFlow) {
|
|
dropdownItems.push({
|
|
label: 'Fork',
|
|
onClick: () => window.open(`/flows/add?template=${initialPath}`)
|
|
})
|
|
}
|
|
}
|
|
|
|
let flowCopilotContext: FlowCopilotContext = $state({
|
|
shouldUpdatePropertyType: writable<{
|
|
[key: string]: 'static' | 'javascript' | undefined
|
|
}>({}),
|
|
exprsToSet: writable<{
|
|
[key: string]: InputTransform | any | undefined
|
|
}>({}),
|
|
generatedExprs: writable<{
|
|
[key: string]: string | undefined
|
|
}>({}),
|
|
stepInputsLoading: writable<boolean>(false)
|
|
})
|
|
|
|
setContext('FlowCopilotContext', flowCopilotContext)
|
|
|
|
let renderCount = $state(0)
|
|
let flowTutorials: FlowTutorials | undefined = $state(undefined)
|
|
|
|
let jsonViewerDrawer: Drawer | undefined = $state(undefined)
|
|
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
|
|
let flowHistory: FlowHistory | undefined = $state(undefined)
|
|
|
|
export function triggerTutorial() {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const tutorial = urlParams.get('tutorial')
|
|
|
|
if (tutorial) {
|
|
flowTutorials?.runTutorialById(tutorial)
|
|
}
|
|
}
|
|
|
|
let moreItems: {
|
|
displayName: string
|
|
icon: ComponentType<Icon>
|
|
action: () => void
|
|
disabled?: boolean
|
|
}[] = $state([])
|
|
|
|
function onCustomUiChange(
|
|
customUi: FlowBuilderWhitelabelCustomUi | undefined,
|
|
hasAiDiff: boolean
|
|
) {
|
|
moreItems = [
|
|
...(customUi?.topBar?.history != false
|
|
? [
|
|
{
|
|
displayName: 'Deployment History',
|
|
icon: HistoryIcon,
|
|
action: () => {
|
|
flowHistory?.open()
|
|
},
|
|
disabled: newFlow
|
|
}
|
|
]
|
|
: []),
|
|
...(customUi?.topBar?.export != false
|
|
? [
|
|
{
|
|
displayName: 'Export',
|
|
icon: FileJson,
|
|
action: () => jsonViewerDrawer?.openDrawer()
|
|
},
|
|
{
|
|
displayName: 'Edit in YAML',
|
|
icon: FileJson,
|
|
action: () => yamlEditorDrawer?.openDrawer(),
|
|
disabled: hasAiDiff
|
|
}
|
|
]
|
|
: []),
|
|
...(customUi?.topBar?.settings != false
|
|
? [
|
|
{
|
|
displayName: 'Flow settings',
|
|
icon: Settings,
|
|
action: () => {
|
|
select('settings-metadata')
|
|
}
|
|
}
|
|
]
|
|
: [])
|
|
]
|
|
}
|
|
|
|
function handleDeployTrigger(trigger: Trigger) {
|
|
const { id, path, type } = trigger
|
|
//Update the saved flow to remove the draft trigger that is deployed
|
|
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
|
|
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
|
|
(t) => t.id !== id || t.path !== path || t.type !== type
|
|
)
|
|
savedFlow.draft.draft_triggers =
|
|
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
|
}
|
|
}
|
|
|
|
let forceTestTab: Record<string, boolean> = $state({})
|
|
let highlightArg: Record<string, string | undefined> = $state({})
|
|
|
|
$effect.pre(() => {
|
|
initialPathStore.set(initialPath)
|
|
})
|
|
$effect.pre(() => {
|
|
setContext('customUi', customUi)
|
|
})
|
|
$effect.pre(() => {
|
|
if (flowStore.val || selectedIdStore) {
|
|
readFieldsRecursively(flowStore.val)
|
|
untrack(() => saveSessionDraft())
|
|
}
|
|
})
|
|
$effect.pre(() => {
|
|
initialPath && ($pathStore = initialPath)
|
|
})
|
|
$effect.pre(() => {
|
|
selectedId && untrack(() => select(selectedId))
|
|
})
|
|
$effect.pre(() => {
|
|
initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers())
|
|
})
|
|
$effect.pre(() => {
|
|
const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
|
|
customUi && untrack(() => onCustomUiChange(customUi, hasAiDiff))
|
|
})
|
|
|
|
export async function loadFlowState() {
|
|
await stepHistoryLoader.loadIndividualStepsStates(
|
|
flowStore.val as Flow,
|
|
flowStateStore,
|
|
$workspaceStore!,
|
|
$initialPathStore,
|
|
$pathStore
|
|
)
|
|
}
|
|
|
|
let stepHistoryLoader = new StepHistoryLoader(
|
|
loadedFromHistoryFromUrl?.stepsState ?? {},
|
|
loadedFromHistoryFromUrl?.flowJobInitial,
|
|
saveSessionDraft,
|
|
noInitial
|
|
)
|
|
setStepHistoryLoaderContext(stepHistoryLoader)
|
|
|
|
export function setLoadedFromHistory(
|
|
loadedFromHistoryUrl:
|
|
| {
|
|
flowJobInitial: boolean | undefined
|
|
stepsState: Record<string, stepState>
|
|
}
|
|
| undefined
|
|
) {
|
|
if (!loadedFromHistoryUrl) {
|
|
return
|
|
}
|
|
|
|
stepHistoryLoader.setFlowJobInitial(loadedFromHistoryUrl.flowJobInitial)
|
|
stepHistoryLoader.stepStates = loadedFromHistoryUrl.stepsState
|
|
}
|
|
|
|
function onJobDone() {
|
|
if (!job) {
|
|
return
|
|
}
|
|
// job was running and is now stopped
|
|
if (!flowPreviewButtons?.getPreviewOpen()) {
|
|
if (
|
|
job.type === 'CompletedJob' &&
|
|
job.success &&
|
|
flowPreviewButtons?.getPreviewMode() === 'whole'
|
|
) {
|
|
if (flowEditor?.isNodeVisible('Result') && selectedIdStore !== 'Result') {
|
|
outputPickerOpenFns['Result']?.()
|
|
}
|
|
} else {
|
|
// Find last module with a job in flow_status
|
|
const lastModuleWithJob = job.flow_status?.modules
|
|
?.slice()
|
|
.reverse()
|
|
.find((module) => 'job' in module)
|
|
if (
|
|
lastModuleWithJob &&
|
|
lastModuleWithJob.id &&
|
|
flowEditor?.isNodeVisible(lastModuleWithJob.id)
|
|
) {
|
|
outputPickerOpenFns[lastModuleWithJob.id]?.()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let localModuleStates: Record<string, GraphModuleState> = $state({})
|
|
let suspendStatus: StateStore<Record<string, { job: Job; nb: number }>> = $state({ val: {} })
|
|
|
|
const flowHasChanged = $derived(flowPreviewContent?.flowHasChanged())
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onKeyDown} />
|
|
|
|
{@render children?.()}
|
|
|
|
<DeployOverrideConfirmationModal
|
|
{deployedBy}
|
|
{confirmCallback}
|
|
bind:open
|
|
{diffDrawer}
|
|
bind:deployedValue
|
|
currentValue={flowStore.val}
|
|
/>
|
|
|
|
<DraftTriggersConfirmationModal
|
|
bind:open={draftTriggersModalOpen}
|
|
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
|
|
isFlow={true}
|
|
on:canceled={() => {
|
|
draftTriggersModalOpen = false
|
|
}}
|
|
on:confirmed={handleDraftTriggersConfirmed}
|
|
/>
|
|
|
|
<AIChangesWarningModal bind:open={aiChangesWarningOpen} onConfirm={aiChangesConfirmCallback} />
|
|
|
|
{#key renderCount}
|
|
{#if !$userStore?.operator}
|
|
{#if $pathStore}
|
|
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
|
|
{/if}
|
|
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
|
|
<FlowImportExportMenu bind:drawer={jsonViewerDrawer} />
|
|
<ScriptEditorDrawer bind:this={$scriptEditorDrawer} />
|
|
|
|
<div class="flex flex-col flex-1 h-screen">
|
|
<!-- Nav between steps-->
|
|
<div
|
|
class="justify-between flex flex-row items-center pl-2.5 pr-6 space-x-4 scrollbar-hidden overflow-x-auto max-h-12 h-full relative"
|
|
>
|
|
<div class="flex w-full max-w-md gap-4 items-center">
|
|
<Summary
|
|
disabled={customUi?.topBar?.editableSummary == false}
|
|
bind:value={flowStore.val.summary}
|
|
/>
|
|
<UndoRedo
|
|
undoProps={{ disabled: $history.index === 0 }}
|
|
redoProps={{ disabled: $history.index === $history.history.length - 1 }}
|
|
on:undo={() => {
|
|
const currentModules = flowStore.val?.value?.modules
|
|
// console.log('undo before', flowStore.val, JSON.stringify(flowStore.val, null, 2))
|
|
flowStore.val = undo(history, flowStore.val)
|
|
// console.log('undo after', flowStore.val, JSON.stringify(flowStore.val, null, 2))
|
|
|
|
const newModules = flowStore.val?.value?.modules
|
|
const restoredModules = newModules?.filter(
|
|
(node) => !currentModules?.some((currentNode) => currentNode?.id === node?.id)
|
|
)
|
|
|
|
for (const mod of restoredModules) {
|
|
if (mod) {
|
|
try {
|
|
loadFlowModuleState(mod).then((state) => (flowStateStore.val[mod.id] = state))
|
|
} catch (e) {
|
|
console.error('Error loading state for restored node', e)
|
|
}
|
|
}
|
|
}
|
|
|
|
selectionManager.selectId('Input')
|
|
}}
|
|
on:redo={() => {
|
|
flowStore.val = redo(history)
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div class="gap-4 flex-row hidden md:flex w-full whitespace-nowrap max-w-md">
|
|
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
|
|
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
|
|
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
|
|
<Button
|
|
btnClasses="hidden lg:inline-flex"
|
|
startIcon={{ icon: Calendar }}
|
|
variant="subtle"
|
|
size="xs"
|
|
on:click={async () => {
|
|
select('Trigger')
|
|
const selected = primaryScheduleIndex ?? scheduleIndex
|
|
if (selected) {
|
|
triggersState.selectedTriggerIndex = selected
|
|
}
|
|
}}
|
|
>
|
|
{triggersState.triggers[primaryScheduleIndex]?.draftConfig?.schedule ??
|
|
triggersState.triggers[primaryScheduleIndex]?.lightConfig?.schedule ??
|
|
''}
|
|
</Button>
|
|
{/if}
|
|
|
|
{#if customUi?.topBar?.path != false}
|
|
<div class="flex justify-start items-center w-full">
|
|
<button
|
|
onclick={async () => {
|
|
select('settings-metadata')
|
|
document.getElementById('path')?.focus()
|
|
}}
|
|
>
|
|
<Badge
|
|
color="gray"
|
|
class="text-primary rounded-r-none border border-r-0 {inputSizeClasses.md}"
|
|
>
|
|
<Pen size={12} class="mr-2" /> Path
|
|
</Badge>
|
|
</button>
|
|
<input
|
|
type="text"
|
|
readonly
|
|
value={$pathStore && $pathStore != '' ? $pathStore : 'Choose a path'}
|
|
class="font-mono !text-2xs !min-w-[96px] !max-w-[300px] !w-full !h-[28px] !my-0 !py-0 !border-l-0 cursor-default !rounded-l-none {inputSizeClasses.md}"
|
|
onfocus={({ currentTarget }) => {
|
|
currentTarget.select()
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
{#if $enterpriseLicense && !newFlow}
|
|
<Awareness />
|
|
{/if}
|
|
<div>
|
|
{#if moreItems?.length > 0}
|
|
<Dropdown items={moreItems} />
|
|
{/if}
|
|
</div>
|
|
<FlowEditorTutorial />
|
|
{#if customUi?.topBar?.diff != false}
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="md"
|
|
on:click={async () => {
|
|
if (!savedFlow) {
|
|
return
|
|
}
|
|
|
|
await syncWithDeployed()
|
|
|
|
const currentDraftTriggers = structuredClone(
|
|
triggersState.getDraftTriggersSnapshot()
|
|
)
|
|
|
|
diffDrawer?.openDrawer()
|
|
const currentFlow = flowStore.val
|
|
diffDrawer?.setDiff({
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? savedFlow,
|
|
draft: savedFlow?.draft,
|
|
current: {
|
|
...currentFlow,
|
|
path: $pathStore,
|
|
draft_triggers: currentDraftTriggers
|
|
}
|
|
})
|
|
}}
|
|
disabled={!savedFlow}
|
|
startIcon={{ icon: DiffIcon }}
|
|
>
|
|
Diff
|
|
</Button>
|
|
{/if}
|
|
<FlowPreviewButtons
|
|
on:openTriggers={(e) => {
|
|
select('Trigger')
|
|
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
|
|
captureOn.set(true)
|
|
showCaptureHint.set(true)
|
|
}}
|
|
{onJobDone}
|
|
bind:localModuleStates
|
|
bind:this={flowPreviewButtons}
|
|
{loading}
|
|
onRunPreview={() => {
|
|
// Reset manually edited args inputs when running a preview
|
|
stepsInputArgs.resetManuallyEditedArgs()
|
|
modulesTestStates.hideJobsInGraph()
|
|
localModuleStates = {}
|
|
showJobStatus = true
|
|
}}
|
|
/>
|
|
<Button
|
|
loading={loadingDraft}
|
|
unifiedSize="md"
|
|
variant="accent"
|
|
startIcon={{ icon: Save }}
|
|
on:click={() => saveDraft()}
|
|
disabled={(!newFlow && !savedFlow) || loading}
|
|
shortCut={{ key: 'S' }}
|
|
>
|
|
Draft
|
|
</Button>
|
|
|
|
<DeployButton
|
|
on:save={async ({ detail }) => await handleSaveFlow(detail)}
|
|
{loading}
|
|
{loadingSave}
|
|
{newFlow}
|
|
{dropdownItems}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<!-- metadata -->
|
|
{#if flowStateStore.val}
|
|
<FlowEditor
|
|
bind:this={flowEditor}
|
|
{disabledFlowInputs}
|
|
disableAi={disableAi || customUi?.stepInputs?.ai == false}
|
|
disableSettings={customUi?.settingsPanel === false}
|
|
{loading}
|
|
on:reload={() => {
|
|
renderCount += 1
|
|
}}
|
|
{newFlow}
|
|
on:applyArgs={(ev) => {
|
|
if (ev.detail.kind === 'preprocessor') {
|
|
stepsInputArgs.setStepArgs('preprocessor', ev.detail.args ?? {})
|
|
selectionManager.selectId('preprocessor')
|
|
}
|
|
}}
|
|
on:testWithArgs={(e) => {
|
|
previewArgsStore.val = JSON.parse(JSON.stringify(e.detail))
|
|
flowPreviewButtons?.openPreview(true)
|
|
}}
|
|
onTestUpTo={(id) => {
|
|
flowPreviewButtons?.testUpTo(id)
|
|
}}
|
|
{savedFlow}
|
|
onDeployTrigger={handleDeployTrigger}
|
|
onEditInput={(moduleId, key) => {
|
|
selectionManager.selectId(moduleId)
|
|
// Use new prop-based system
|
|
forceTestTab[moduleId] = true
|
|
highlightArg[moduleId] = key
|
|
// Reset the force flag after a short delay to allow re-triggering
|
|
setTimeout(() => {
|
|
forceTestTab[moduleId] = false
|
|
highlightArg[moduleId] = undefined
|
|
}, 500)
|
|
}}
|
|
{forceTestTab}
|
|
{highlightArg}
|
|
aiChatOpen={aiChatManager.open}
|
|
showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false}
|
|
toggleAiChat={() => aiChatManager.toggleOpen()}
|
|
onOpenPreview={flowPreviewButtons?.openPreview}
|
|
localModuleStates={showJobStatus ? localModuleStates : {}}
|
|
{showJobStatus}
|
|
testModuleStates={modulesTestStates}
|
|
isOwner={flowPreviewContent?.getIsOwner()}
|
|
onTestFlow={flowPreviewButtons?.runPreview}
|
|
isRunning={flowPreviewContent?.getIsRunning()}
|
|
onCancelTestFlow={flowPreviewContent?.cancelTest}
|
|
onHideJobStatus={() => {
|
|
modulesTestStates.hideJobsInGraph()
|
|
showJobStatus = false
|
|
}}
|
|
{job}
|
|
{suspendStatus}
|
|
onDelete={(id) => {
|
|
delete localModuleStates[id]
|
|
delete modulesTestStates.states[id]
|
|
}}
|
|
{flowHasChanged}
|
|
previewOpen={flowPreviewButtons?.getPreviewOpen()}
|
|
/>
|
|
{:else}
|
|
<CenteredPage>Loading...</CenteredPage>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
Flow Builder not available to operators
|
|
{/if}
|
|
{/key}
|
|
|
|
<FlowTutorials
|
|
bind:this={flowTutorials}
|
|
on:reload={() => {
|
|
renderCount += 1
|
|
}}
|
|
/>
|
|
|
|
<FlowAssetsHandler
|
|
modules={flowStore.val.value.modules}
|
|
enableParser
|
|
enableDbExplore
|
|
enablePathScriptAndFlowAssets
|
|
/>
|