feat(aichat): simplify flow mode edits (#6981)

* 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>
This commit is contained in:
centdix
2025-12-08 19:08:14 +01:00
committed by GitHub
parent 009e37d380
commit 8e6b519a0d
49 changed files with 7794 additions and 1763 deletions
+14
View File
@@ -19,3 +19,17 @@ When implementing new features in Windmill, follow these best practices:
- Backend (Rust): @backend/rust-best-practices.mdc + @backend/summarized_schema.txt
- Frontend (Svelte 5): @frontend/svelte5-best-practices.mdc
## Querying the Database
To query the database directly, use psql with the following connection string:
```bash
psql postgres://postgres:changeme@localhost:5432/windmill
```
This can be helpful for:
- Inspecting database state during development
- Testing queries before implementing them in Rust
- Debugging data-related issues
-13
View File
@@ -10,16 +10,3 @@
1. Update database schema with migration if necessary
2. Update backend/windmill-api/openapi.yaml after modifying API endpoints
## Querying the Database
To query the database directly, use psql with the following connection string:
```bash
psql postgres://postgres:changeme@localhost:5432/windmill
```
This can be helpful for:
- Inspecting database state during development
- Testing queries before implementing them in Rust
- Debugging data-related issues
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -e
# Script to generate minified OpenFlow JSON for frontend AI system prompt
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_file="${script_dirpath}/../windmill-yaml-validator/src/gen/openflow.json"
output_dirpath="${script_dirpath}/src/lib/components/copilot/chat/flow"
output_file="${output_dirpath}/openFlow.json"
echo "Generating minified OpenFlow JSON..."
# Validate source file exists
if [ ! -f "${source_file}" ]; then
echo "Error: Source file not found: ${source_file}"
echo "Please run windmill-yaml-validator/gen_openflow_schema.sh first"
exit 1
fi
# Create output directory if it doesn't exist
mkdir -p "${output_dirpath}"
# Minify JSON by removing all whitespace
node -e "
const fs = require('fs');
const sourceFile = '${source_file}';
const outputFile = '${output_file}';
try {
const schema = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
// Minify: stringify without spaces
const minified = JSON.stringify(schema);
fs.writeFileSync(outputFile, minified);
const originalSize = fs.statSync(sourceFile).size;
const minifiedSize = fs.statSync(outputFile).size;
const savings = ((originalSize - minifiedSize) / originalSize * 100).toFixed(1);
console.log(' Minified OpenFlow JSON generated successfully');
console.log(' Original size: ' + (originalSize / 1024).toFixed(1) + ' KB');
console.log(' Minified size: ' + (minifiedSize / 1024).toFixed(1) + ' KB');
console.log(' Savings: ' + savings + '%');
console.log(' Output: ' + outputFile);
} catch (e) {
console.error('Error minifying JSON:', e.message);
process.exit(1);
}
"
echo "Done!"
+521 -10
View File
@@ -114,6 +114,7 @@
"@types/vscode": "^1.83.5",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.60.0",
"@vitest/browser-playwright": "^4.0.10",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.13",
"cssnano": "^6.0.1",
@@ -123,6 +124,7 @@
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte": "^2.45.1",
"path-browserify": "^1.0.1",
"playwright": "^1.56.1",
"postcss": "^8.4.49",
"postcss-load-config": "^4.0.1",
"prettier": "^3.1.0",
@@ -145,6 +147,8 @@
"typescript": "^5.5.0",
"vite": "npm:rolldown-vite@7.2.8",
"vite-plugin-mkcert": "^1.17.5",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1",
"yootils": "^0.3.1"
},
"optionalDependencies": {
@@ -2481,6 +2485,53 @@
"node": ">=18"
}
},
"node_modules/@playwright/test/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -3046,6 +3097,17 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -3340,6 +3402,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/diff": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
@@ -3649,6 +3718,178 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
"node_modules/@vitest/browser": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.15.tgz",
"integrity": "sha512-zedtczX688KehaIaAv7m25CeDLb0gBtAOa2Oi1G1cqvSO5aLSVfH6lpZMJLW8BKYuWMxLQc9/5GYoM+jgvGIrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/mocker": "4.0.15",
"@vitest/utils": "4.0.15",
"magic-string": "^0.30.21",
"pixelmatch": "7.1.0",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.0.3",
"ws": "^8.18.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.0.15"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.15.tgz",
"integrity": "sha512-94yVpDbb+ykiT7mK6ToonGnq2GIHEQGBTZTAzGxBGQXcVNCh54YKC2/WkfaDzxy0m6Kgw05kq3FYHKHu+wRdIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "4.0.15",
"@vitest/mocker": "4.0.15",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.0.15"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz",
"integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.15",
"@vitest/utils": "4.0.15",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz",
"integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.15",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz",
"integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz",
"integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.15",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/snapshot": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz",
"integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.15",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/spy": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz",
"integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz",
"integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.15",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vscode/iconv-lite-umd": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.0.tgz",
@@ -3958,6 +4199,16 @@
"node": ">=0.10.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -4497,6 +4748,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz",
"integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5717,6 +5978,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -6197,6 +6465,16 @@
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
"integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8304,9 +8582,9 @@
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -9766,6 +10044,17 @@
"node": ">= 0.4"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/ohash": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz",
@@ -10188,6 +10477,19 @@
"node": ">= 6"
}
},
"node_modules/pixelmatch": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dev": true,
"license": "ISC",
"dependencies": {
"pngjs": "^7.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
@@ -10208,13 +10510,13 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.0"
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
@@ -10227,9 +10529,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -10263,6 +10565,16 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -11842,6 +12154,13 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12044,6 +12363,20 @@
"license": "CC0-1.0",
"peer": true
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -13045,6 +13378,13 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
@@ -13100,6 +13440,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13652,6 +14002,128 @@
}
}
},
"node_modules/vitest": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz",
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.15",
"@vitest/mocker": "4.0.15",
"@vitest/pretty-format": "4.0.15",
"@vitest/runner": "4.0.15",
"@vitest/snapshot": "4.0.15",
"@vitest/spy": "4.0.15",
"@vitest/utils": "4.0.15",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.15",
"@vitest/browser-preview": "4.0.15",
"@vitest/browser-webdriverio": "4.0.15",
"@vitest/ui": "4.0.15",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/vitest-browser-svelte": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-2.0.1.tgz",
"integrity": "sha512-z7GFio7vxaOolY+xwPUMEKuwL4KcPzB8+bepA9F0Phqag/TJ4j7IAGSwm4Y/FBh7KznsP+7aEIllMay0qDpFXw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
"vitest": "^4.0.0"
}
},
"node_modules/vitest/node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest/node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/vscode": {
"name": "@codingame/monaco-vscode-extension-api",
"version": "21.6.0",
@@ -13825,6 +14297,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/windmill-parser-wasm-csharp": {
"version": "1.510.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-csharp/-/windmill-parser-wasm-csharp-1.510.1.tgz",
@@ -14033,6 +14522,28 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
+7 -2
View File
@@ -15,8 +15,9 @@
"generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false",
"generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript",
"pretest": "tsc --incremental -p tests/tsconfig.json",
"test": "playwright test --config=tests-out/playwright.config.js",
"filter-classes": "node filterTailwindClasses.js"
"test": "playwright test --config=tests-out/playwright.config.js && npm run test:unit -- --run",
"filter-classes": "node filterTailwindClasses.js",
"test:unit": "vitest"
},
"devDependencies": {
"@floating-ui/core": "^1.3.1",
@@ -37,6 +38,7 @@
"@types/vscode": "^1.83.5",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.60.0",
"@vitest/browser-playwright": "^4.0.10",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.13",
"cssnano": "^6.0.1",
@@ -46,6 +48,7 @@
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-svelte": "^2.45.1",
"path-browserify": "^1.0.1",
"playwright": "^1.56.1",
"postcss": "^8.4.49",
"postcss-load-config": "^4.0.1",
"prettier": "^3.1.0",
@@ -68,6 +71,8 @@
"typescript": "^5.5.0",
"vite": "npm:rolldown-vite@7.2.8",
"vite-plugin-mkcert": "^1.17.5",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1",
"yootils": "^0.3.1"
},
"overrides": {
@@ -163,7 +163,7 @@
}
function hasAIChanges(): boolean {
return aiChatManager.flowAiChatHelpers?.hasDiff() ?? false
return aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
}
function withAIChangesWarning(callback: () => void) {
@@ -896,7 +896,7 @@
initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers())
})
$effect.pre(() => {
const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasDiff() ?? false
const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false
customUi && untrack(() => onCustomUiChange(customUi, hasAiDiff))
})
@@ -1123,8 +1123,7 @@
)
diffDrawer?.openDrawer()
const currentFlow =
aiChatManager.flowAiChatHelpers?.getPreviewFlow() ?? flowStore.val
const currentFlow = flowStore.val
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
@@ -1,11 +1,9 @@
<script lang="ts">
import type { FlowModule, FlowValue, OpenFlow } from '$lib/gen'
import type { OpenFlow } from '$lib/gen'
import YAML from 'yaml'
import FlowGraphV2 from './graph/FlowGraphV2.svelte'
import { Alert, Button } from './common'
import { buildFlowTimeline, hasInputSchemaChanged } from './flows/flowDiff'
import { dfs } from './flows/dfs'
import DiffDrawer from './DiffDrawer.svelte'
import { computeFlowModuleDiff } from './flows/flowDiff'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
@@ -22,7 +20,6 @@
let { beforeYaml, afterYaml }: Props = $props()
let parseError = $state<string | undefined>(undefined)
let moduleDiffDrawer: DiffDrawer | undefined = $state(undefined)
let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH)
let beforePaneSize = $state(50)
let viewMode = $state<'sidebyside' | 'unified'>('sidebyside')
@@ -53,38 +50,17 @@
}
})
// Detect if input schema has changed
let inputSchemaModified = $derived(hasInputSchemaChanged(beforeFlow, afterFlow))
// Determine if we should render side-by-side or unified (user controlled via toggle)
let isSideBySide = $derived(viewMode === 'sidebyside')
// Build timeline using history-based approach
// In side-by-side view, mark removed modules as 'shadowed' in the After graph
// In unified view, mark removed modules as 'removed' to show them in red
let timeline = $derived.by(() => {
if (!beforeFlow || !afterFlow) return undefined
return buildFlowTimeline(beforeFlow.value, afterFlow.value, {
markRemovedAsShadowed: isSideBySide
})
const { beforeActions } = $derived.by(() => {
if (!beforeFlow || !afterFlow) return { beforeActions: undefined }
return computeFlowModuleDiff(beforeFlow.value, afterFlow.value)
})
// Extract merged flow from timeline
let mergedFlow = $derived(timeline?.mergedFlow)
// Get the unified actions directly from timeline
let unifiedActions = $derived(timeline?.afterActions ?? {})
// Helper to find module by ID in a flow
function getModuleById(flow: FlowValue, moduleId: string): FlowModule | undefined {
const allModules = dfs(flow.modules ?? [], (m) => m)
return (
allModules.find((m) => m?.id === moduleId) ??
(flow.failure_module?.id === moduleId ? flow.failure_module : undefined) ??
(flow.preprocessor_module?.id === moduleId ? flow.preprocessor_module : undefined)
)
}
// Handler for viewport changes - updates shared state for synchronization
function handleViewportChange(viewport: Viewport, isUserInitiated: boolean) {
if (isUserInitiated) {
@@ -92,36 +68,6 @@
}
}
// Callback to show module diff
function handleShowModuleDiff(moduleId: string) {
if (!beforeFlow || !afterFlow) return
// Handle special case for Input schema diff
if (moduleId === 'Input') {
moduleDiffDrawer?.openDrawer()
moduleDiffDrawer?.setDiff({
mode: 'simple',
title: 'Flow Input Schema Diff',
original: { schema: beforeFlow.schema ?? {} },
current: { schema: afterFlow.schema ?? {} }
})
return
}
const beforeModule = getModuleById(beforeFlow.value, moduleId)
const afterModule = getModuleById(afterFlow.value, moduleId)
if (beforeModule && afterModule) {
moduleDiffDrawer?.openDrawer()
moduleDiffDrawer?.setDiff({
mode: 'simple',
title: `Module Diff: ${moduleId}`,
original: beforeModule,
current: afterModule
})
}
}
$effect(() => {
if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) {
viewMode = 'unified'
@@ -198,9 +144,7 @@
preprocessorModule={beforeFlow.value.preprocessor_module}
earlyStop={beforeFlow.value.skip_expr !== undefined}
cache={beforeFlow.value.cache_ttl !== undefined}
moduleActions={timeline?.beforeActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
moduleActions={beforeActions}
notSelectable={true}
insertable={false}
editMode={false}
@@ -223,63 +167,56 @@
<Pane minSize={30} class="flex flex-col h-full">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-hidden">
{#if mergedFlow}
<FlowGraphV2
bind:this={afterGraph}
modules={mergedFlow.modules}
failureModule={mergedFlow.failure_module}
preprocessorModule={mergedFlow.preprocessor_module}
earlyStop={mergedFlow.skip_expr !== undefined}
cache={mergedFlow.cache_ttl !== undefined}
moduleActions={unifiedActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
{/if}
<FlowGraphV2
bind:this={afterGraph}
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
markRemovedAsShadowed={true}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
{sharedViewport}
onViewportChange={handleViewportChange}
>
{#snippet leftHeader()}
<span class="text-sm text-primary">After</span>
{/snippet}
</FlowGraphV2>
</div>
</div>
</Pane>
</Splitpanes>
{:else}
<!-- Unified view for narrow screens - show merged flow with all diff colors -->
{#if mergedFlow}
<div class="h-full overflow-hidden">
<FlowGraphV2
modules={mergedFlow.modules}
failureModule={mergedFlow.failure_module}
preprocessorModule={mergedFlow.preprocessor_module}
earlyStop={mergedFlow.skip_expr !== undefined}
cache={mergedFlow.cache_ttl !== undefined}
moduleActions={unifiedActions}
{inputSchemaModified}
onShowModuleDiff={handleShowModuleDiff}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
/>
</div>
{/if}
<!-- Unified view - uses FlowGraphV2's built-in diff mode -->
<div class="h-full overflow-hidden">
<FlowGraphV2
diffBeforeFlow={beforeFlow}
modules={afterFlow.value.modules}
failureModule={afterFlow.value.failure_module}
preprocessorModule={afterFlow.value.preprocessor_module}
earlyStop={afterFlow.value.skip_expr !== undefined}
cache={afterFlow.value.cache_ttl !== undefined}
currentInputSchema={afterFlow.schema}
notSelectable={true}
insertable={false}
editMode={false}
download={false}
scroll={false}
minHeight={400}
triggerNode={false}
/>
</div>
{/if}
</div>
<!-- Nested DiffDrawer for module-level diffs -->
<DiffDrawer bind:this={moduleDiffDrawer} />
</div>
{:else}
<div class="flex items-center justify-center h-full">
@@ -36,8 +36,6 @@
import FlowHistoryJobPicker from './FlowHistoryJobPicker.svelte'
import type { DurationStatus, GraphModuleState } from './graph'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import FlowChat from './flows/conversations/FlowChat.svelte'
interface Props {
@@ -130,11 +128,11 @@
})
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
const previewFlow = aiChatManager.flowAiChatHelpers?.getPreviewFlow()
const previewFlow = flowStore.val
if (previewMode === 'whole') {
return previewFlow ?? flowStore.val
return previewFlow
} else {
const flow = previewFlow ?? stateSnapshot(flowStore).val
const flow = previewFlow
const idOrders = dfs(flow.value.modules, (x) => x.id)
let upToIndex = idOrders.indexOf(upToId ?? selectionManager.getSelectedId() ?? '')
@@ -334,7 +332,7 @@
</div>
{:else}
<div class="grow justify-center flex flex-row gap-2">
{#if jobId !== undefined && selectedJobStep !== undefined && selectedJobStepIsTopLevel && aiChatManager.flowAiChatHelpers?.getModuleAction(selectedJobStep) !== 'removed'}
{#if jobId !== undefined && selectedJobStep !== undefined && selectedJobStepIsTopLevel}
{#if selectedJobStepType == 'single'}
<Button
unifiedSize="md"
@@ -1,5 +1,5 @@
<script lang="ts">
import { ScriptService, type FlowModule, type JavascriptTransform, type Job } from '$lib/gen'
import { ScriptService, type AiAgent, type FlowModule, type JavascriptTransform, type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { getContext } from 'svelte'
@@ -111,7 +111,7 @@
value: {
type: 'aiagent',
tools: mod.value.type == 'aiagent' ? mod.value.tools : [],
input_transforms: inputTransforms
input_transforms: inputTransforms as AiAgent['input_transforms']
}
}
]
@@ -439,6 +439,7 @@
aiChatManager.scriptEditorApplyCode = undefined
aiChatManager.scriptEditorShowDiffMode = undefined
aiChatManager.scriptEditorOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
})
@@ -212,14 +212,14 @@
Stop
</Button>
</div>
{:else if aiChatManager.flowAiChatHelpers?.hasDiff()}
{:else if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
startIcon={{ icon: CheckIcon }}
size="xs"
variant="default"
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
on:click={() => {
onclick={() => {
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
}}
>
@@ -230,7 +230,7 @@
size="xs"
variant="default"
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
on:click={() => {
onclick={() => {
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
}}
>
@@ -225,7 +225,7 @@ class AIChatManager {
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
const context = this.contextManager.getSelectedContext()
const lang = this.scriptEditorOptions?.lang ?? 'bun'
this.tools = [this.changeModeTool, ...prepareScriptTools(currentModel, lang, context)]
this.tools = [...prepareScriptTools(currentModel, lang, context)]
this.helpers = {
getScriptOptions: () => {
return {
@@ -249,7 +249,7 @@ class AIChatManager {
const customPrompt = getCombinedCustomPrompt(mode)
this.systemMessage = prepareFlowSystemMessage(customPrompt)
this.systemMessage.content = this.NAVIGATION_SYSTEM_PROMPT + this.systemMessage.content
this.tools = [this.changeModeTool, ...flowTools]
this.tools = [...flowTools]
this.helpers = this.flowAiChatHelpers
} else if (mode === AIMode.NAVIGATOR) {
const customPrompt = getCombinedCustomPrompt(mode)
@@ -608,9 +608,8 @@ class AIChatManager {
let snapshot: ExtendedOpenFlow | undefined = undefined
if (this.mode === AIMode.FLOW) {
this.flowAiChatHelpers!.rejectAllModuleActions()
snapshot = this.flowAiChatHelpers!.getFlowAndSelectedId().flow
this.flowAiChatHelpers!.setLastSnapshot(snapshot)
this.flowAiChatHelpers!.setSnapshot(snapshot)
}
this.displayMessages = [
@@ -781,6 +780,18 @@ class AIChatManager {
abortController: this.abortController
})
this.abortController?.abort(cancelReason)
// Mark all tool messages in loading state as canceled
this.displayMessages = this.displayMessages.map((message) => {
if (message.role === 'tool' && message.isLoading) {
return {
...message,
isLoading: false,
content: 'Canceled',
error: 'Canceled'
}
}
return message
})
}
cancelInlineRequest = (reason?: string) => {
@@ -83,7 +83,7 @@
{:else if message.role === 'tool'}
<ToolExecutionDisplay message={message as ToolDisplayMessage} />
{:else}
{message.content}
<span class="whitespace-pre-wrap">{message.content}</span>
{/if}
</div>
{/if}
@@ -60,8 +60,8 @@
<!-- Expanded Content -->
{#if isExpanded}
<div class="p-2 bg-surface space-y-3">
<!-- Parameters Section - only show if we have parameters -->
{#if hasParameters}
<!-- Parameters Section - show if we have parameters, or if confirmation is needed (even with empty params) -->
{#if hasParameters || message.needsConfirmation}
<div class={message.needsConfirmation ? 'opacity-80' : ''}>
<ToolContentDisplay
title="Parameters"
@@ -1,78 +1,39 @@
<script lang="ts">
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, untrack } from 'svelte'
import type { FlowCopilotContext } from '../../flow'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
import { dfs } from '$lib/components/flows/previousResults'
import { dfs as dfsApply } from '$lib/components/flows/dfs'
import { getSubModules } from '$lib/components/flows/flowExplorer'
import type { FlowModule, OpenFlow } from '$lib/gen'
import { getIndexInNestedModules, getNestedModules } from './utils'
import type { AIModuleAction, FlowAIChatHelpers } from './core'
import {
insertNewFailureModule,
insertNewPreprocessorModule
} from '$lib/components/flows/flowStateUtils.svelte'
import type { InputTransform, OpenFlow } from '$lib/gen'
import type { FlowAIChatHelpers } from './core'
import { restoreInlineScriptReferences } from './inlineScriptsUtils'
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
import { aiChatManager } from '../AIChatManager.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { AgentTool } from '$lib/components/flows/agentToolUtils'
import { getSubModules } from '$lib/components/flows/flowExplorer'
import { SPECIAL_MODULE_IDS } from '../shared'
import type { FlowCopilotContext } from '../../flow'
let {
flowModuleSchemaMap
flowModuleSchemaMap,
onTestFlow
}: {
flowModuleSchemaMap: FlowModuleSchemaMap | undefined
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
} = $props()
const { flowStore, flowStateStore, selectionManager, currentEditor } =
const { flowStore, flowStateStore, selectionManager, currentEditor, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
const { exprsToSet } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') ?? {}
let affectedModules: Record<
string,
{
action: AIModuleAction
}
> = $state({})
let lastSnapshot: ExtendedOpenFlow | undefined = $state(undefined)
let previewFlow = $derived.by(() => {
const flow = $state.snapshot(flowStore).val
if (Object.values(affectedModules).some((m) => m.action === 'removed')) {
dfsApply(flow.value.modules, (m, modules) => {
const action = affectedModules[m.id]?.action
if (action === 'removed') {
modules.splice(modules.indexOf(m), 1)
}
})
}
return flow
})
function setModuleStatus(id: string, action: AIModuleAction) {
const existingAction: AIModuleAction | undefined = affectedModules[id]?.action
if (existingAction === 'added' && action === 'modified') {
// means it was added but then edited => keep the action as added
action = 'added'
} else if (existingAction === 'added' && action === 'removed') {
delete affectedModules[id]
deleteStep(id)
return
} else if (existingAction === 'removed' && action === 'added') {
action = 'modified'
}
affectedModules[id] = {
action
}
}
// Get diffManager from the graph
const diffManager = $derived(flowModuleSchemaMap?.getDiffManager())
function getModule(id: string, flow: OpenFlow = flowStore.val) {
if (id === 'preprocessor') {
if (id === SPECIAL_MODULE_IDS.PREPROCESSOR) {
return flow.value.preprocessor_module
} else if (id === 'failure') {
} else if (id === SPECIAL_MODULE_IDS.FAILURE) {
return flow.value.failure_module
} else {
return dfs(id, flow, false)[0]
@@ -88,353 +49,6 @@
selectedId: selectedId
}
},
// flow apply/reject
getPreviewFlow: () => {
return $state.snapshot(previewFlow)
},
hasDiff: () => {
return Object.keys(affectedModules).length > 0
},
acceptAllModuleActions() {
for (const id of Object.keys(affectedModules)) {
this.acceptModuleAction(id)
}
},
rejectAllModuleActions() {
// Do it in reverse to revert nested modules first then parents
const ids = Object.keys(affectedModules)
for (let i = ids.length - 1; i >= 0; i--) {
this.revertModuleAction(ids[i])
}
affectedModules = {}
},
setLastSnapshot: (snapshot) => {
lastSnapshot = snapshot
},
revertToSnapshot: (snapshot?: ExtendedOpenFlow) => {
affectedModules = {}
if (snapshot) {
flowStore.val = snapshot
refreshStateStore(flowStore)
if ($currentEditor) {
const module = getModule($currentEditor.stepId, snapshot)
if (module) {
if ($currentEditor.type === 'script' && module.value.type === 'rawscript') {
$currentEditor.editor.setCode(module.value.content)
} else if ($currentEditor.type === 'iterator' && module.value.type === 'forloopflow') {
$currentEditor.editor.setCode(
module.value.iterator.type === 'javascript' ? module.value.iterator.expr : ''
)
}
}
}
}
},
showModuleDiff(id: string) {
if (!lastSnapshot) {
return
}
const moduleLastSnapshot = id === 'Input' ? lastSnapshot.schema : getModule(id, lastSnapshot)
const currentModule = id === 'Input' ? flowStore.val.schema : getModule(id)
if (moduleLastSnapshot && currentModule) {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
title: `Diff for ${id}`,
original: moduleLastSnapshot,
current: currentModule,
button: {
text: 'Accept',
onClick: () => {
diffDrawer?.closeDrawer()
this.acceptModuleAction(id)
}
}
})
}
},
getModuleAction: (id: string) => {
return affectedModules[id]?.action
},
revertModuleAction: (id: string) => {
{
const action = affectedModules[id]?.action
if (action && lastSnapshot) {
if (id === 'Input') {
flowStore.val.schema = lastSnapshot.schema
} else if (action === 'added') {
deleteStep(id)
} else if (action === 'modified') {
const oldModule = getModule(id, lastSnapshot)
if (!oldModule) {
throw new Error('Module not found')
}
const newModule = getModule(id)
if (!newModule) {
throw new Error('Module not found')
}
// Apply the old code to the editor and hide diff editor if the reverted module is a rawscript
if (
newModule.value.type === 'rawscript' &&
$currentEditor?.type === 'script' &&
$currentEditor.stepId === id
) {
const aiChatEditorHandler = $currentEditor.editor.getAiChatEditorHandler()
if (aiChatEditorHandler) {
aiChatEditorHandler.revertAll({ disableReviewCallback: true })
$currentEditor.hideDiffMode()
}
}
Object.keys(newModule).forEach((k) => delete newModule[k])
Object.assign(newModule, $state.snapshot(oldModule))
}
refreshStateStore(flowStore)
delete affectedModules[id]
}
}
},
acceptModuleAction: (id: string) => {
if (affectedModules[id]?.action === 'removed') {
deleteStep(id)
}
if (
affectedModules[id]?.action === 'modified' &&
$currentEditor &&
$currentEditor.type === 'script' &&
$currentEditor.stepId === id
) {
const aiChatEditorHandler = $currentEditor.editor.getAiChatEditorHandler()
if (aiChatEditorHandler) {
aiChatEditorHandler.keepAll({ disableReviewCallback: true })
}
}
delete affectedModules[id]
},
// ai chat tools
setCode: async (id, code) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type === 'rawscript') {
module.value.content = code
const { input_transforms, schema } = await loadSchemaFromModule(module)
module.value.input_transforms = input_transforms
refreshStateStore(flowStore)
if (flowStateStore.val[id]) {
flowStateStore.val[id].schema = schema
} else {
flowStateStore.val[id] = {
schema
}
}
} else {
throw new Error('Module is not a rawscript or script')
}
if ($currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(code)
}
setModuleStatus(id, 'modified')
},
insertStep: async (location, step) => {
const { index, modules } =
location.type === 'start'
? {
index: -1,
modules: flowStore.val.value.modules
}
: location.type === 'start_inside_forloop'
? {
index: -1,
modules: getNestedModules(flowStore.val, location.inside)
}
: location.type === 'start_inside_branch'
? {
index: -1,
modules: getNestedModules(flowStore.val, location.inside, location.branchIndex)
}
: location.type === 'after'
? getIndexInNestedModules(flowStore.val, location.afterId)
: {
index: -1,
modules: flowStore.val.value.modules
}
const indexToInsertAt = index + 1
let newModules: FlowModule[] | AgentTool[] | undefined = undefined
switch (step.type) {
case 'rawscript': {
const inlineScript = {
language: step.language,
kind: 'script' as const,
subkind: 'flow' as const,
summary: step.summary
}
if (location.type === 'preprocessor') {
await insertNewPreprocessorModule(flowStore, flowStateStore, inlineScript)
} else if (location.type === 'failure') {
await insertNewFailureModule(flowStore, flowStateStore, inlineScript)
} else {
newModules = await flowModuleSchemaMap?.insertNewModuleAtIndex(
modules,
indexToInsertAt,
'script',
undefined,
undefined,
inlineScript
)
}
break
}
case 'script': {
const wsScript = {
path: step.path,
summary: '',
hash: undefined
}
if (location.type === 'preprocessor') {
await insertNewPreprocessorModule(flowStore, flowStateStore, undefined, wsScript)
} else if (location.type === 'failure') {
await insertNewFailureModule(flowStore, flowStateStore, undefined, wsScript)
} else {
newModules = await flowModuleSchemaMap?.insertNewModuleAtIndex(
modules,
indexToInsertAt,
'script',
wsScript
)
}
break
}
case 'forloop':
case 'branchall':
case 'branchone': {
if (location.type === 'preprocessor' || location.type === 'failure') {
throw new Error('Cannot insert a non-script module for preprocessing or error handling')
}
newModules = await flowModuleSchemaMap?.insertNewModuleAtIndex(
modules,
indexToInsertAt,
step.type
)
break
}
default: {
throw new Error('Unknown step type')
}
}
if (location.type === 'preprocessor' || location.type === 'failure') {
refreshStateStore(flowStore)
setModuleStatus(location.type, 'added')
return location.type
} else {
const newModule = newModules?.[indexToInsertAt]
if (!newModule) {
throw new Error('Failed to insert module')
}
if (['branchone', 'branchall'].includes(step.type)) {
await flowModuleSchemaMap?.addBranch(newModule.id)
}
refreshStateStore(flowStore)
setModuleStatus(newModule.id, 'added')
return newModule.id
}
},
removeStep: (id) => {
setModuleStatus(id, 'removed')
},
getStepInputs: async (id) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
const inputs =
module.value.type === 'script' || module.value.type === 'rawscript'
? module.value.input_transforms
: {}
return inputs
},
setStepInputs: async (id, inputs) => {
if (id === 'preprocessor') {
throw new Error('Cannot set inputs for preprocessor')
}
const regex = /\[\[(.+?)\]\]\s*\n([\s\S]*?)(?=\n\[\[|$)/g
const parsedInputs = Array.from(inputs.matchAll(regex)).map((match) => ({
input: match[1],
value: match[2].trim()
}))
if (id === selectedId) {
exprsToSet?.set({})
const argsToUpdate = {}
for (const { input, value } of parsedInputs) {
argsToUpdate[input] = {
type: 'javascript',
expr: value
}
}
exprsToSet?.set(argsToUpdate)
} else {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'script' && module.value.type !== 'rawscript') {
throw new Error('Module is not a script or rawscript')
}
for (const { input, value } of parsedInputs) {
module.value.input_transforms[input] = {
type: 'javascript',
expr: value
}
}
refreshStateStore(flowStore)
}
setModuleStatus(id, 'modified')
},
getFlowInputsSchema: async () => {
return flowStore.val.schema ?? {}
},
setFlowInputsSchema: async (newInputs) => {
flowStore.val.schema = newInputs
setModuleStatus('Input', 'modified')
},
selectStep: (id) => {
selectionManager.selectId(id)
},
getStepCode: (id) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type === 'rawscript') {
return module.value.content
} else {
throw new Error('Module is not a rawscript')
}
},
getModules: (id?: string) => {
if (id) {
const module = getModule(id)
@@ -447,167 +61,215 @@
}
return flowStore.val.value.modules
},
setBranchPredicate: async (id, branchIndex, expression) => {
setSnapshot: (snapshot: ExtendedOpenFlow) => {
diffManager?.setBeforeFlow(snapshot)
},
revertToSnapshot: (snapshot?: ExtendedOpenFlow) => {
if (!diffManager) return
// Pass snapshot to diffManager - use message's snapshot or fall back to beforeFlow
diffManager.revertToSnapshot(flowStore, snapshot)
// Update current editor if needed
const targetSnapshot = snapshot ?? diffManager.beforeFlow
if ($currentEditor && targetSnapshot) {
const module = getModule($currentEditor.stepId, targetSnapshot)
if (module) {
if ($currentEditor.type === 'script' && module.value.type === 'rawscript') {
$currentEditor.editor.setCode(module.value.content)
} else if ($currentEditor.type === 'iterator' && module.value.type === 'forloopflow') {
$currentEditor.editor.setCode(
module.value.iterator.type === 'javascript' ? module.value.iterator.expr : ''
)
}
}
}
},
// ai chat tools
setCode: async (id: string, code: string) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'branchone') {
throw new Error('Module is not a branchall or branchone')
}
const branch = module.value.branches[branchIndex]
if (!branch) {
throw new Error('Branch not found')
}
branch.expr = expression
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
addBranch: async (id) => {
flowModuleSchemaMap?.addBranch(id)
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
removeBranch: async (id, branchIndex) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'branchall' && module.value.type !== 'branchone') {
throw new Error('Module is not a branchall or branchone')
}
// for branch one, we set index + 1 because the removeBranch function assumes the index is shifted by 1 because of the default branch
flowModuleSchemaMap?.removeBranch(
module.id,
module.value.type === 'branchone' ? branchIndex + 1 : branchIndex
)
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
setForLoopIteratorExpression: async (id, expression) => {
if ($currentEditor && $currentEditor.type === 'iterator' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(expression)
} else {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
if (module.value.type === 'rawscript') {
// 1. Take snapshot only if none exists (preserves baseline for cumulative changes)
if (!diffManager?.beforeFlow) {
const snapshot = $state.snapshot(flowStore).val
diffManager?.setBeforeFlow(snapshot)
diffManager?.setEditMode(true)
}
if (module.value.type !== 'forloopflow') {
throw new Error('Module is not a forloopflow')
}
module.value.iterator = { type: 'javascript', expr: expression }
// 2. Apply the code change
module.value.content = code
const { input_transforms, schema } = await loadSchemaFromModule(module)
module.value.input_transforms = input_transforms
refreshStateStore(flowStore)
}
setModuleStatus(id, 'modified')
},
setForLoopOptions: async (id, opts) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'forloopflow') {
throw new Error('Module is not a forloopflow')
}
// Apply skip_failures if provided
if (typeof opts.skip_failures === 'boolean') {
module.value.skip_failures = opts.skip_failures
}
// Apply parallel if provided
if (typeof opts.parallel === 'boolean') {
module.value.parallel = opts.parallel
}
// Handle parallelism
if (opts.parallel === false) {
// If parallel is disabled, clear parallelism
module.value.parallelism = undefined
} else if (opts.parallelism !== undefined) {
if (opts.parallelism === null) {
// Explicitly clear parallelism
module.value.parallelism = undefined
} else if (module.value.parallel || opts.parallel === true) {
// Only set parallelism if parallel is enabled
const n = Math.max(1, Math.floor(Math.abs(opts.parallelism)))
module.value.parallelism = {
type: 'static',
value: n
}
// Update exprsToSet if this module is currently selected
if (id === selectedId && exprsToSet) {
exprsToSet.set(input_transforms)
}
}
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
setModuleControlOptions: async (id, opts) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
// Handle stop_after_if
if (typeof opts.stop_after_if === 'boolean') {
if (opts.stop_after_if === false) {
module.stop_after_if = undefined
if (flowStateStore.val[id]) {
flowStateStore.val[id].schema = schema
} else {
module.stop_after_if = {
expr: opts.stop_after_if_expr ?? '',
skip_if_stopped: opts.stop_after_if
flowStateStore.val[id] = {
schema
}
}
}
// Handle skip_if
if (typeof opts.skip_if === 'boolean') {
if (opts.skip_if === false) {
module.skip_if = undefined
} else {
module.skip_if = {
expr: opts.skip_if_expr ?? ''
// 3. Manually add to moduleActions, preserving existing action types
// Note: currentFlow is auto-synced by FlowGraphV2's effect after refreshStateStore
const currentAction = diffManager?.moduleActions[id]
if (!currentAction) {
diffManager?.setModuleActions({
...diffManager?.moduleActions,
[id]: { action: 'modified', pending: true }
})
}
// If already tracked (e.g., 'added' from setFlowJson), keep that status
} else {
throw new Error('Module is not a rawscript or script')
}
if ($currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(code)
}
},
getFlowInputsSchema: async () => {
return flowStore.val.schema ?? {}
},
updateExprsToSet: (id: string, inputTransforms: Record<string, InputTransform>) => {
if (id === selectedId && exprsToSet) {
exprsToSet.set(inputTransforms)
}
},
// accept/reject operations (via flowDiffManager)
acceptAllModuleActions: () => {
diffManager?.acceptAll(flowStore)
},
rejectAllModuleActions: () => {
diffManager?.rejectAll(flowStore)
},
hasPendingChanges: () => {
return diffManager?.hasPendingChanges ?? false
},
selectStep: (id) => {
selectionManager.selectId(id)
},
testFlow: async (args, conversationId) => {
// Set preview args if provided
if (args) {
previewArgs.val = args
}
// Call the UI test function which opens preview panel
return await onTestFlow?.(conversationId)
},
setFlowJson: async (json: string) => {
try {
// Parse JSON to JavaScript object
const parsed = JSON.parse(json)
// Validate that it has the expected structure
if (!parsed.modules || !Array.isArray(parsed.modules)) {
throw new Error('JSON must contain a "modules" array')
}
// Restore inline script references back to full content
const restoredModules = restoreInlineScriptReferences(parsed.modules)
// Also restore preprocessor and failure modules if they have references
let restoredPreprocessor = parsed.preprocessor_module
if (
restoredPreprocessor?.value?.type === 'rawscript' &&
restoredPreprocessor.value.content
) {
const match = restoredPreprocessor.value.content.match(/^inline_script\.(.+)$/)
if (match) {
// Wrap in array to reuse the restoration function
const restored = restoreInlineScriptReferences([restoredPreprocessor])
restoredPreprocessor = restored[0]
}
}
}
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
let restoredFailure = parsed.failure_module
if (restoredFailure?.value?.type === 'rawscript' && restoredFailure.value.content) {
const match = restoredFailure.value.content.match(/^inline_script\.(.+)$/)
if (match) {
const restored = restoreInlineScriptReferences([restoredFailure])
restoredFailure = restored[0]
}
}
// Take snapshot of current flowStore BEFORE making changes
if (!diffManager?.hasPendingChanges) {
const snapshot = $state.snapshot(flowStore).val
diffManager?.setBeforeFlow(snapshot)
}
// Directly modify flowStore (immediate effect)
flowStore.val.value.modules = restoredModules
if (parsed.preprocessor_module !== undefined) {
flowStore.val.value.preprocessor_module = restoredPreprocessor || undefined
}
if (parsed.failure_module !== undefined) {
flowStore.val.value.failure_module = restoredFailure || undefined
}
// Update schema if provided
if (parsed.schema !== undefined) {
flowStore.val.schema = parsed.schema
}
diffManager?.setEditMode(true)
// Refresh the state store to update UI
// The $effect in FlowGraphV2 will automatically sync currentFlow and currentInputSchema
refreshStateStore(flowStore)
} catch (error) {
console.error('setFlowJson error:', error)
throw new Error(
`Failed to parse or apply JSON: ${error instanceof Error ? error.message : String(error)}`
)
}
}
}
function deleteStep(id: string) {
flowModuleSchemaMap?.selectNextId(id)
if (id === 'preprocessor') {
flowStore.val.value.preprocessor_module = undefined
} else if (id === 'failure') {
flowStore.val.value.failure_module = undefined
} else {
const { modules } = getIndexInNestedModules(flowStore.val, id)
flowModuleSchemaMap?.removeAtId(modules, id)
}
refreshStateStore(flowStore)
}
const allModuleIds = $derived(dfsApply(flowStore.val.value.modules, (m) => m.id))
$effect(() => {
// remove any affected modules that are no longer in the flow
const untrackedAffectedModules = untrack(() => affectedModules)
for (const id of Object.keys(untrackedAffectedModules)) {
if (!allModuleIds.includes(id)) {
delete affectedModules[id]
if (
$currentEditor?.type === 'script' &&
selectedId &&
diffManager?.moduleActions[selectedId]?.pending &&
$currentEditor.editor.getAiChatEditorHandler()
) {
const moduleLastSnapshot = getModule(selectedId, diffManager.beforeFlow)
const content =
moduleLastSnapshot?.value.type === 'rawscript' ? moduleLastSnapshot.value.content : ''
if (content.length > 0) {
untrack(() =>
$currentEditor.editor.reviewAppliedCode(content, {
onFinishedReview: () => {
diffManager?.acceptModule(selectedId, flowStore)
$currentEditor.hideDiffMode()
}
})
)
}
}
})
$effect(() => {
const cleanup = aiChatManager.setFlowHelpers(flowHelpers)
return cleanup
return () => {
cleanup()
}
})
$effect(() => {
@@ -617,39 +279,15 @@
flowStateStore.val,
$currentEditor
)
return cleanup
return () => {
cleanup()
}
})
$effect(() => {
const cleanup = aiChatManager.listenForCurrentEditorChanges($currentEditor)
return cleanup
})
// Automatically show revert review when selecting a rawscript module with pending changes
$effect(() => {
if (
$currentEditor?.type === 'script' &&
selectedId &&
affectedModules[selectedId] &&
$currentEditor.editor.getAiChatEditorHandler()
) {
const moduleLastSnapshot = getModule(selectedId, lastSnapshot)
const content =
moduleLastSnapshot?.value.type === 'rawscript' ? moduleLastSnapshot.value.content : ''
if (content.length > 0) {
untrack(() =>
$currentEditor.editor.reviewAppliedCode(content, {
onFinishedReview: () => {
flowHelpers.acceptModuleAction(selectedId)
$currentEditor.hideDiffMode()
}
})
)
}
return () => {
cleanup()
}
})
let diffDrawer: DiffDrawer | undefined = $state(undefined)
</script>
<DiffDrawer bind:this={diffDrawer} />
@@ -1,63 +0,0 @@
<script module lang="ts">
export const getAiModuleAction = (id: string | undefined) => {
if (!id) return undefined
return aiChatManager.flowAiChatHelpers?.getModuleAction(id)
}
</script>
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import { Check, DiffIcon, X } from 'lucide-svelte'
import { aiChatManager } from '../AIChatManager.svelte'
import type { AIModuleAction } from './core'
let {
id,
action,
placement = 'top'
}: {
id: string | undefined
action: AIModuleAction | undefined
placement?: 'top' | 'bottom'
} = $props()
</script>
{#if action && id}
<div
class={twMerge(
'absolute right-0 left-0 flex flex-row ',
placement === 'top' ? 'top-0 -translate-y-full' : 'bottom-0 translate-y-full',
action === 'modified' ? 'justify-between' : 'justify-end'
)}
>
{#if action === 'modified'}
<button
class="p-1 bg-surface hover:bg-surface-hover rounded-t-md text-3xs font-normal flex flex-row items-center gap-1 text-orange-800 dark:text-orange-400"
onclick={() => {
aiChatManager.flowAiChatHelpers?.showModuleDiff(id)
}}
>
<DiffIcon size={14} /> Diff
</button>
{/if}
<div
class={twMerge(
'flex flex-row bg-surface overflow-hidden',
placement === 'top' ? 'rounded-t-md' : 'rounded-b-md'
)}
>
<button
class="p-1 bg-green-500 text-white hover:bg-green-600 text-3xs font-normal flex flex-row items-center gap-1"
onclick={() => aiChatManager.flowAiChatHelpers?.acceptModuleAction(id)}
>
<Check size={14} /> Accept
</button>
<button
class="p-1 hover:bg-red-500 hover:text-white text-3xs font-normal flex flex-row items-center gap-1"
onclick={() => aiChatManager.flowAiChatHelpers?.revertModuleAction(id)}
>
<X size={14} /> Reject
</button>
</div>
</div>
{/if}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
import type { FlowModule } from '$lib/gen'
/**
* Storage for inline scripts extracted from flow modules.
* Maps module IDs to their rawscript content for token-efficient transmission to AI.
*/
class InlineScriptStore {
private scripts: Map<string, string> = new Map()
clear() {
this.scripts.clear()
}
set(moduleId: string, content: string) {
this.scripts.set(moduleId, content)
}
get(moduleId: string): string | undefined {
return this.scripts.get(moduleId)
}
has(moduleId: string): boolean {
return this.scripts.has(moduleId)
}
getAll(): Record<string, string> {
return Object.fromEntries(this.scripts.entries())
}
}
export const inlineScriptStore = new InlineScriptStore()
/**
* Recursively extracts all rawscript content from flow modules and stores them.
* Replaces the content with references like "inline_script.{module_id}".
*/
export function extractAndReplaceInlineScripts(modules: FlowModule[]): FlowModule[] {
if (!modules || !Array.isArray(modules)) {
return []
}
return modules.map((module) => {
const newModule = { ...module }
if (newModule.value.type === 'rawscript' && newModule.value.content) {
// Store the original content
inlineScriptStore.set(module.id, newModule.value.content)
// Replace with reference
newModule.value = {
...newModule.value,
content: `inline_script.${module.id}`
}
} else if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') {
// Recursively process nested modules in loops
if (newModule.value.modules) {
newModule.value = {
...newModule.value,
modules: extractAndReplaceInlineScripts(newModule.value.modules)
}
}
} else if (newModule.value.type === 'branchone') {
// Process branches and default modules
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? extractAndReplaceInlineScripts(branch.modules) : []
}))
}
}
if (newModule.value.default) {
newModule.value = {
...newModule.value,
default: extractAndReplaceInlineScripts(newModule.value.default)
}
}
} else if (newModule.value.type === 'branchall') {
// Process all branches
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? extractAndReplaceInlineScripts(branch.modules) : []
}))
}
}
} else if (newModule.value.type === 'aiagent') {
// Process AI agent tools
if (newModule.value.tools) {
newModule.value = {
...newModule.value,
tools: newModule.value.tools.map((tool) => {
if (
tool.value &&
'tool_type' in tool.value &&
tool.value.tool_type === 'flowmodule' &&
'type' in tool.value &&
tool.value.type === 'rawscript' &&
'content' in tool.value &&
tool.value.content
) {
inlineScriptStore.set(tool.id, tool.value.content as string)
return {
...tool,
value: {
...tool.value,
content: `inline_script.${tool.id}`
}
}
}
return tool
})
}
}
}
return newModule
})
}
/**
* Recursively restores inline script references back to their full content.
* If content matches pattern "inline_script.{id}", looks up and restores the original.
* If content doesn't match (new/modified script), keeps it as-is.
*/
export function restoreInlineScriptReferences(modules: FlowModule[]): FlowModule[] {
return modules.map((module) => {
const newModule = { ...module }
if (newModule.value.type === 'rawscript' && newModule.value.content) {
const content = newModule.value.content
// Check if it's a reference
const match = content.match(/^inline_script\.(.+)$/)
if (match) {
const moduleId = match[1]
const storedContent = inlineScriptStore.get(moduleId)
if (storedContent !== undefined) {
// Restore original content
newModule.value = {
...newModule.value,
content: storedContent
}
}
// If not found in store, keep the reference as-is (shouldn't happen normally)
}
// If not a reference, it's new/modified content - keep as-is
} else if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') {
// Recursively process nested modules in loops
if (newModule.value.modules) {
newModule.value = {
...newModule.value,
modules: restoreInlineScriptReferences(newModule.value.modules)
}
}
} else if (newModule.value.type === 'branchone') {
// Process branches and default modules
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? restoreInlineScriptReferences(branch.modules) : []
}))
}
}
if (newModule.value.default) {
newModule.value = {
...newModule.value,
default: restoreInlineScriptReferences(newModule.value.default)
}
}
} else if (newModule.value.type === 'branchall') {
// Process all branches
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? restoreInlineScriptReferences(branch.modules) : []
}))
}
}
} else if (newModule.value.type === 'aiagent') {
// Process AI agent tools
if (newModule.value.tools) {
newModule.value = {
...newModule.value,
tools: newModule.value.tools.map((tool) => {
if (
tool.value &&
'tool_type' in tool.value &&
tool.value.tool_type === 'flowmodule' &&
'type' in tool.value &&
tool.value.type === 'rawscript' &&
'content' in tool.value &&
tool.value.content
) {
const content = tool.value.content as string
const match = content.match(/^inline_script\.(.+)$/)
if (match) {
const toolId = match[1]
const storedContent = inlineScriptStore.get(toolId)
if (storedContent !== undefined) {
return {
...tool,
value: {
...tool.value,
content: storedContent
}
}
}
}
}
return tool
})
}
}
}
return newModule
})
}
/**
* Recursively finds any unresolved inline script references in flow modules.
* Returns array of module IDs that still have `inline_script.{id}` patterns.
*/
export function findUnresolvedInlineScriptRefs(modules: FlowModule[]): string[] {
const unresolvedRefs: string[] = []
function checkModule(module: FlowModule) {
if (module.value.type === 'rawscript' && module.value.content) {
const match = module.value.content.match(/^inline_script\.(.+)$/)
if (match) {
unresolvedRefs.push(match[1])
}
} else if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
if (module.value.modules) {
module.value.modules.forEach(checkModule)
}
} else if (module.value.type === 'branchone') {
if (module.value.branches) {
module.value.branches.forEach((branch) => {
branch.modules?.forEach(checkModule)
})
}
if (module.value.default) {
module.value.default.forEach(checkModule)
}
} else if (module.value.type === 'branchall') {
if (module.value.branches) {
module.value.branches.forEach((branch) => {
branch.modules?.forEach(checkModule)
})
}
} else if (module.value.type === 'aiagent') {
// Check AI agent tools
if (module.value.tools) {
for (const tool of module.value.tools) {
if (
tool.value &&
'tool_type' in tool.value &&
tool.value.tool_type === 'flowmodule' &&
'type' in tool.value &&
tool.value.type === 'rawscript' &&
'content' in tool.value &&
tool.value.content
) {
const match = (tool.value.content as string).match(/^inline_script\.(.+)$/)
if (match) {
unresolvedRefs.push(match[1])
}
}
}
}
}
}
modules.forEach(checkModule)
return unresolvedRefs
}
File diff suppressed because one or more lines are too long
@@ -1,17 +1,31 @@
import { dfs } from '$lib/components/flows/previousResults'
import type { OpenFlow } from '$lib/gen'
import type { AIModuleAction } from './core'
import type { FlowModule, OpenFlow } from '$lib/gen'
export function getIndexInNestedModules(flow: OpenFlow, id: string) {
// Helper to find module by ID in a flow
export function getModuleById(flow: OpenFlow, moduleId: string): FlowModule | undefined {
const allModules = dfs(moduleId, flow, false)
return allModules[0]
}
export function getIndexInNestedModules(
flow: OpenFlow,
id: string
): { index: number; modules: FlowModule[] } | null {
const accessingModules = dfs(id, flow, true).reverse()
if (accessingModules.length === 0) {
// Module not found in flow
return null
}
let parent = flow.value.modules
let lastIndex = -1
for (const [ai, am] of accessingModules.entries()) {
const index = parent.findIndex((m) => m.id === am.id)
if (index === -1) {
throw new Error(`Module not found: ${am.id} in ${parent.map((m) => m.id).join(', ')}`)
// Module no longer exists in expected location (may have been deleted with parent)
return null
}
lastIndex = index
@@ -33,18 +47,18 @@ export function getIndexInNestedModules(flow: OpenFlow, id: string) {
b.modules.some((m) => m.id === accessingModules[ai + 1].id)
)
if (branchIdx === -1) {
throw new Error(
`Branch not found: ${am.id} in ${parent[index].value.branches.map((b) => b.modules.map((m) => m.id).join(', ')).join(';')}`
)
// Module no longer exists in branch (may have been deleted)
return null
}
parent = parent[index].value.branches[branchIdx].modules
} else {
throw new Error('Module is not a for loop or branch')
// Unexpected module type in path
return null
}
}
if (lastIndex === -1) {
throw new Error('Module not found, should have been caught earlier')
return null
}
return {
@@ -53,7 +67,11 @@ export function getIndexInNestedModules(flow: OpenFlow, id: string) {
}
}
export function getNestedModules(flow: OpenFlow, id: string, branchIndex?: number) {
const { index, modules } = getIndexInNestedModules(flow, id)
const result = getIndexInNestedModules(flow, id)
if (!result) {
throw new Error(`Module not found: ${id}`)
}
const { index, modules } = result
// we know index is correct because we've already checked it in getIndexInNestedModules
const module = modules[index]
@@ -84,46 +102,716 @@ export function getNestedModules(flow: OpenFlow, id: string, branchIndex?: numbe
}
}
export function aiModuleActionToBgColor(action: AIModuleAction | undefined) {
switch (action) {
case 'modified':
return '!bg-orange-200 dark:!bg-orange-800'
case 'added':
return '!bg-green-200 dark:!bg-green-800'
case 'removed':
return '!bg-red-200/50 dark:!bg-red-800/50'
case 'shadowed':
return '!bg-gray-200/30 dark:!bg-gray-800/30 !opacity-50'
default:
return ''
/**
* Recursively resolves all $ref references in a JSON Schema by inlining them.
* This ensures the schema is fully self-contained for AI providers that don't
* support external references or have strict schema validation (e.g., Google/Gemini).
*
* @param schema - The schema object to resolve
* @param rootSchema - The root schema document containing all definitions
* @param visited - Set of visited $ref paths to prevent infinite recursion
* @returns Fully resolved schema with all $ref references inlined
*/
export function resolveSchemaRefs(schema: any, rootSchema: any, visited = new Set<string>()): any {
if (!schema || typeof schema !== 'object') return schema
// Handle $ref
if (schema.$ref) {
const refPath = schema.$ref.replace('#/', '').split('/')
// Prevent infinite recursion with circular refs
if (visited.has(schema.$ref)) {
return { type: 'object' } // Fallback for circular refs
}
visited.add(schema.$ref)
let resolved = rootSchema
for (const part of refPath) {
resolved = resolved[part]
}
// Recursively resolve the referenced schema
return resolveSchemaRefs(resolved, rootSchema, new Set(visited))
}
}
export function aiModuleActionToBorderColor(action: AIModuleAction | undefined) {
switch (action) {
case 'modified':
return '!border-orange-300 dark:!border-orange-700'
case 'added':
return '!border-green-400 dark:!border-green-700'
case 'removed':
return '!border-red-300 dark:!border-red-700'
case 'shadowed':
return '!border-gray-300 dark:!border-gray-600'
default:
return ''
// Handle arrays
if (Array.isArray(schema)) {
return schema.map((item) => resolveSchemaRefs(item, rootSchema, visited))
}
// Handle objects - recursively process all properties
const result: any = {}
for (const key in schema) {
result[key] = resolveSchemaRefs(schema[key], rootSchema, visited)
}
return result
}
export function aiModuleActionToTextColor(action: AIModuleAction | undefined) {
switch (action) {
case 'modified':
return '!text-orange-800 dark:!text-orange-200'
case 'added':
return '!text-green-800 dark:!text-green-200'
case 'removed':
return '!text-red-800 dark:!text-red-200'
case 'shadowed':
return '!text-gray-600 dark:!text-gray-400'
default:
return ''
/**
* Recursively collects all module IDs from a module and its nested structures
*/
export function collectAllModuleIds(module: FlowModule): string[] {
const ids: string[] = [module.id]
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
if (module.value.modules) {
for (const nested of module.value.modules) {
ids.push(...collectAllModuleIds(nested))
}
}
} else if (module.value.type === 'branchone') {
if (module.value.branches) {
for (const branch of module.value.branches) {
if (branch.modules) {
for (const nested of branch.modules) {
ids.push(...collectAllModuleIds(nested))
}
}
}
}
if (module.value.default) {
for (const nested of module.value.default) {
ids.push(...collectAllModuleIds(nested))
}
}
} else if (module.value.type === 'branchall') {
if (module.value.branches) {
for (const branch of module.value.branches) {
if (branch.modules) {
for (const nested of branch.modules) {
ids.push(...collectAllModuleIds(nested))
}
}
}
}
} else if (module.value.type === 'aiagent') {
if (module.value.tools) {
for (const tool of module.value.tools) {
ids.push(tool.id)
}
}
}
return ids
}
/**
* Recursively finds a module by ID in the flow structure
*/
export function findModuleInFlow(modules: FlowModule[], id: string): FlowModule | undefined {
for (const module of modules) {
if (module.id === id) {
return module
}
// Search in nested structures
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
if (module.value.modules) {
const found = findModuleInFlow(module.value.modules, id)
if (found) return found
}
} else if (module.value.type === 'branchone') {
if (module.value.branches) {
for (const branch of module.value.branches) {
if (branch.modules) {
const found = findModuleInFlow(branch.modules, id)
if (found) return found
}
}
}
if (module.value.default) {
const found = findModuleInFlow(module.value.default, id)
if (found) return found
}
} else if (module.value.type === 'branchall') {
if (module.value.branches) {
for (const branch of module.value.branches) {
if (branch.modules) {
const found = findModuleInFlow(branch.modules, id)
if (found) return found
}
}
}
} else if (module.value.type === 'aiagent') {
// Search in AI agent tools
if (module.value.tools) {
for (const tool of module.value.tools) {
if (tool.id === id) {
// Return a pseudo-FlowModule for compatibility
return { id: tool.id, value: tool.value, summary: tool.summary } as FlowModule
}
}
}
}
}
return undefined
}
/**
* Recursively removes a module by ID from the flow structure
* Returns the updated modules array
*/
export function removeModuleFromFlow(modules: FlowModule[], id: string): FlowModule[] {
const result: FlowModule[] = []
for (const module of modules) {
if (module.id === id) {
// Skip this module (remove it)
continue
}
const newModule = { ...module }
// Recursively remove from nested structures
if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') {
if (newModule.value.modules) {
newModule.value = {
...newModule.value,
modules: removeModuleFromFlow(newModule.value.modules, id)
}
}
} else if (newModule.value.type === 'branchone') {
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? removeModuleFromFlow(branch.modules, id) : []
}))
}
}
if (newModule.value.default) {
newModule.value = {
...newModule.value,
default: removeModuleFromFlow(newModule.value.default, id)
}
}
} else if (newModule.value.type === 'branchall') {
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? removeModuleFromFlow(branch.modules, id) : []
}))
}
}
} else if (newModule.value.type === 'aiagent') {
// Remove tool from AI agent's tools array
if (newModule.value.tools) {
newModule.value = {
...newModule.value,
tools: newModule.value.tools.filter((tool) => tool.id !== id)
}
}
}
result.push(newModule)
}
return result
}
/**
* Recursively removes a branch by index from a branchall/branchone container
* Returns the updated modules array
*/
export function removeBranchFromFlow(
modules: FlowModule[],
containerId: string,
branchIndex: number
): FlowModule[] {
return modules.map((module) => {
if (module.id === containerId) {
if (module.value.type === 'branchall') {
const branches = module.value.branches || []
if (branchIndex < 0 || branchIndex >= branches.length) {
throw new Error(`Branch index ${branchIndex} out of bounds (0-${branches.length - 1})`)
}
return {
...module,
value: {
...module.value,
branches: branches.filter((_, i) => i !== branchIndex)
}
} as FlowModule
}
if (module.value.type === 'branchone') {
const branches = module.value.branches || []
if (branchIndex < 0 || branchIndex >= branches.length) {
throw new Error(`Branch index ${branchIndex} out of bounds (0-${branches.length - 1})`)
}
return {
...module,
value: {
...module.value,
branches: branches.filter((_, i) => i !== branchIndex)
}
} as FlowModule
}
throw new Error(`Module '${containerId}' is not a branchall/branchone container`)
}
// Recursively search nested structures
const newModule = { ...module }
if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') {
if (newModule.value.modules) {
newModule.value = {
...newModule.value,
modules: removeBranchFromFlow(newModule.value.modules, containerId, branchIndex)
}
}
} else if (newModule.value.type === 'branchone') {
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules
? removeBranchFromFlow(branch.modules, containerId, branchIndex)
: []
}))
}
}
if (newModule.value.default) {
newModule.value = {
...newModule.value,
default: removeBranchFromFlow(newModule.value.default, containerId, branchIndex)
}
}
} else if (newModule.value.type === 'branchall') {
if (newModule.value.branches) {
newModule.value = {
...newModule.value,
branches: newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules
? removeBranchFromFlow(branch.modules, containerId, branchIndex)
: []
}))
}
}
}
return newModule
})
}
/**
* Parses a branch path string into navigation components
* Examples: 'branches.0' -> {type: 'branches', index: 0}
* 'default' -> {type: 'default'}
* 'modules' -> {type: 'modules'}
*/
export function parseBranchPath(path: string): { type: string; index?: number } {
if (path === 'default') {
return { type: 'default' }
}
if (path === 'modules') {
return { type: 'modules' }
}
if (path === 'tools') {
return { type: 'tools' }
}
const match = path.match(/^(branches)\.(\d+)$/)
if (match) {
return { type: match[1], index: parseInt(match[2], 10) }
}
throw new Error(`Invalid branch path: ${path}`)
}
/**
* Gets the target array for module insertion based on insideId and branchPath
*/
function getTargetArray(
modules: FlowModule[],
insideId: string,
branchPath: string
): FlowModule[] | undefined {
const container = findModuleInFlow(modules, insideId)
if (!container) {
return undefined
}
const parsed = parseBranchPath(branchPath)
if (container.value.type === 'forloopflow' || container.value.type === 'whileloopflow') {
if (parsed.type === 'modules') {
return container.value.modules || []
}
throw new Error(`Invalid branchPath '${branchPath}' for loop module. Use 'modules'`)
} else if (container.value.type === 'branchone') {
if (parsed.type === 'branches' && parsed.index !== undefined) {
return container.value.branches?.[parsed.index]?.modules
} else if (parsed.type === 'default') {
return container.value.default
}
throw new Error(
`Invalid branchPath '${branchPath}' for branchone module. Use 'branches.N' or 'default'`
)
} else if (container.value.type === 'branchall') {
if (parsed.type === 'branches' && parsed.index !== undefined) {
return container.value.branches?.[parsed.index]?.modules
}
throw new Error(`Invalid branchPath '${branchPath}' for branchall module. Use 'branches.N'`)
} else if (container.value.type === 'aiagent') {
if (parsed.type === 'tools') {
// Return tools array (AgentTool[]), caller handles the different structure
return (container.value.tools as any) || []
}
throw new Error(`Invalid branchPath '${branchPath}' for aiagent module. Use 'tools'`)
}
throw new Error(`Module '${insideId}' is not a container type`)
}
/**
* Updates a nested array within a container module
*/
function updateNestedArray(
module: FlowModule,
branchPath: string,
updatedArray: FlowModule[]
): FlowModule {
const parsed = parseBranchPath(branchPath)
const newModule = { ...module }
if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') {
if (parsed.type === 'modules') {
newModule.value = {
...newModule.value,
modules: updatedArray
}
}
} else if (newModule.value.type === 'branchone') {
if (parsed.type === 'branches' && parsed.index !== undefined && newModule.value.branches) {
const newBranches = [...newModule.value.branches]
newBranches[parsed.index] = {
...newBranches[parsed.index],
modules: updatedArray
}
newModule.value = {
...newModule.value,
branches: newBranches
}
} else if (parsed.type === 'default') {
newModule.value = {
...newModule.value,
default: updatedArray
}
}
} else if (newModule.value.type === 'branchall') {
if (parsed.type === 'branches' && parsed.index !== undefined && newModule.value.branches) {
const newBranches = [...newModule.value.branches]
newBranches[parsed.index] = {
...newBranches[parsed.index],
modules: updatedArray
}
newModule.value = {
...newModule.value,
branches: newBranches
}
}
} else if (newModule.value.type === 'aiagent') {
if (parsed.type === 'tools') {
// Note: updatedArray is actually AgentTool[] when dealing with AI agents
newModule.value = {
...newModule.value,
tools: updatedArray as any
}
}
}
return newModule
}
/**
* Recursively adds a module to the flow structure
*/
export function addModuleToFlow(
modules: FlowModule[],
afterId: string | null,
insideId: string | null,
branchPath: string | null,
newModule: FlowModule
): FlowModule[] {
// Case 1a: Adding a NEW branch to branchall/branchone (insideId set, branchPath null)
if (insideId && branchPath === null) {
return modules.map((module) => {
if (module.id === insideId) {
// Adding a new branch to branchall
if (module.value.type === 'branchall') {
const newBranch = {
summary: (newModule as any).summary || '',
skip_failure: (newModule as any).skip_failure ?? false,
modules: (newModule as any).modules || []
}
return {
...module,
value: {
...module.value,
branches: [...(module.value.branches || []), newBranch]
}
} as FlowModule
}
// Adding a new branch to branchone
if (module.value.type === 'branchone') {
const newBranch = {
summary: (newModule as any).summary || '',
expr: (newModule as any).expr || 'false',
modules: (newModule as any).modules || []
}
return {
...module,
value: {
...module.value,
branches: [...(module.value.branches || []), newBranch]
}
} as FlowModule
}
throw new Error(
`Cannot add branch to module '${insideId}': branchPath=null is only valid for branchall/branchone containers`
)
}
// Recursively search nested structures for the target container
const newModuleCopy = { ...module }
if (
newModuleCopy.value.type === 'forloopflow' ||
newModuleCopy.value.type === 'whileloopflow'
) {
if (newModuleCopy.value.modules) {
newModuleCopy.value = {
...newModuleCopy.value,
modules: addModuleToFlow(
newModuleCopy.value.modules,
afterId,
insideId,
branchPath,
newModule
)
}
}
} else if (newModuleCopy.value.type === 'branchone') {
if (newModuleCopy.value.branches) {
newModuleCopy.value = {
...newModuleCopy.value,
branches: newModuleCopy.value.branches.map((branch) => ({
...branch,
modules: branch.modules
? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule)
: []
}))
}
}
if (newModuleCopy.value.default) {
newModuleCopy.value = {
...newModuleCopy.value,
default: addModuleToFlow(
newModuleCopy.value.default,
afterId,
insideId,
branchPath,
newModule
)
}
}
} else if (newModuleCopy.value.type === 'branchall') {
if (newModuleCopy.value.branches) {
newModuleCopy.value = {
...newModuleCopy.value,
branches: newModuleCopy.value.branches.map((branch) => ({
...branch,
modules: branch.modules
? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule)
: []
}))
}
}
}
return newModuleCopy
})
}
// Case 1b: Adding inside a container (insideId + branchPath both set)
if (insideId && branchPath) {
return modules.map((module) => {
if (module.id === insideId) {
// Special handling for AI agent tools
if (module.value.type === 'aiagent' && branchPath === 'tools') {
// For AI agents, newModule structure is { id, summary, value: { tool_type, ...FlowModuleValue } }
// The value should already include tool_type from the caller
const newTool = {
id: newModule.id,
summary: newModule.summary,
value: newModule.value as any
}
return {
...module,
value: {
...module.value,
tools: [...(module.value.tools || []), newTool]
}
} as FlowModule
}
const targetArray = getTargetArray(modules, insideId, branchPath)
if (!targetArray) {
throw new Error(
`Cannot find target array for insideId '${insideId}' with branchPath '${branchPath}'`
)
}
const updatedArray =
afterId !== null
? addModuleToFlow(targetArray, afterId, null, null, newModule)
: [newModule, ...targetArray] // afterId null = insert at beginning
return updateNestedArray(module, branchPath, updatedArray)
}
// Recursively search nested structures
const newModuleCopy = { ...module }
if (
newModuleCopy.value.type === 'forloopflow' ||
newModuleCopy.value.type === 'whileloopflow'
) {
if (newModuleCopy.value.modules) {
newModuleCopy.value = {
...newModuleCopy.value,
modules: addModuleToFlow(
newModuleCopy.value.modules,
afterId,
insideId,
branchPath,
newModule
)
}
}
} else if (newModuleCopy.value.type === 'branchone') {
if (newModuleCopy.value.branches) {
newModuleCopy.value = {
...newModuleCopy.value,
branches: newModuleCopy.value.branches.map((branch) => ({
...branch,
modules: branch.modules
? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule)
: []
}))
}
}
if (newModuleCopy.value.default) {
newModuleCopy.value = {
...newModuleCopy.value,
default: addModuleToFlow(
newModuleCopy.value.default,
afterId,
insideId,
branchPath,
newModule
)
}
}
} else if (newModuleCopy.value.type === 'branchall') {
if (newModuleCopy.value.branches) {
newModuleCopy.value = {
...newModuleCopy.value,
branches: newModuleCopy.value.branches.map((branch) => ({
...branch,
modules: branch.modules
? addModuleToFlow(branch.modules, afterId, insideId, branchPath, newModule)
: []
}))
}
}
}
return newModuleCopy
})
}
// Case 2: Adding at current level after a specific module
if (afterId !== null) {
const result: FlowModule[] = []
for (const module of modules) {
result.push(module)
if (module.id === afterId) {
result.push(newModule)
}
}
return result
}
// Case 3: afterId is null - insert at the beginning
return [newModule, ...modules]
}
/**
* Recursively replaces a module by ID
*/
export function replaceModuleInFlow(
modules: FlowModule[],
id: string,
newModule: FlowModule
): FlowModule[] {
return modules.map((module) => {
if (module.id === id) {
return { ...newModule, id } // Ensure ID remains the same
}
const newModuleCopy = { ...module }
// Recursively replace in nested structures
if (
newModuleCopy.value.type === 'forloopflow' ||
newModuleCopy.value.type === 'whileloopflow'
) {
if (newModuleCopy.value.modules) {
newModuleCopy.value = {
...newModuleCopy.value,
modules: replaceModuleInFlow(newModuleCopy.value.modules, id, newModule)
}
}
} else if (newModuleCopy.value.type === 'branchone') {
if (newModuleCopy.value.branches) {
newModuleCopy.value = {
...newModuleCopy.value,
branches: newModuleCopy.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? replaceModuleInFlow(branch.modules, id, newModule) : []
}))
}
}
if (newModuleCopy.value.default) {
newModuleCopy.value = {
...newModuleCopy.value,
default: replaceModuleInFlow(newModuleCopy.value.default, id, newModule)
}
}
} else if (newModuleCopy.value.type === 'branchall') {
if (newModuleCopy.value.branches) {
newModuleCopy.value = {
...newModuleCopy.value,
branches: newModuleCopy.value.branches.map((branch) => ({
...branch,
modules: branch.modules ? replaceModuleInFlow(branch.modules, id, newModule) : []
}))
}
}
} else if (newModuleCopy.value.type === 'aiagent') {
// Replace tool in AI agent's tools array
if (newModuleCopy.value.tools) {
newModuleCopy.value = {
...newModuleCopy.value,
tools: newModuleCopy.value.tools.map((tool) =>
tool.id === id
? { id, summary: newModule.summary, value: newModule.value as any }
: tool
)
}
}
}
return newModuleCopy
})
}
@@ -3,6 +3,18 @@ import type {
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam
} from 'openai/resources/chat/completions.mjs'
/**
* Special module IDs used throughout the flow system
*/
export const SPECIAL_MODULE_IDS = {
/** The flow input schema node */
INPUT: 'Input',
/** The preprocessor module that runs before the flow */
PREPROCESSOR: 'preprocessor',
/** The failure handler module */
FAILURE: 'failure'
} as const
import { get } from 'svelte/store'
import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } from './context'
import { workspaceStore } from '$lib/stores'
@@ -11,7 +23,6 @@ import type { FunctionParameters } from 'openai/resources/shared.mjs'
import { z } from 'zod'
import { ScriptService, JobService, type CompletedJob, type FlowModule } from '$lib/gen'
import { scriptLangToEditorLang } from '$lib/scripts'
import YAML from 'yaml'
import { getCurrentModel } from '$lib/aiStore'
// Prettify function for code arguments - extracts and formats code from JSON
@@ -45,9 +56,91 @@ function prettifyCodeArguments(content: string): string {
return codeContent
}
// Prettify function for set_module_code - extracts code from moduleId/code JSON
function prettifySetModuleCode(content: string): string {
let codeContent = content
if (typeof content === 'string' && content.trim().startsWith('{')) {
try {
const parsed = JSON.parse(content)
if (parsed.code) {
codeContent = parsed.code
}
} catch {
// If JSON is incomplete during streaming, try to extract code property manually
const codeMatch = content.match(/"code"\s*:\s*"([\s\S]*?)(?:"\s*}?\s*$|$)/)
if (codeMatch) {
codeContent = codeMatch[1]
}
}
}
// Convert escape sequences
codeContent = codeContent.replace(/\\n/g, '\n')
codeContent = codeContent.replace(/\\t/g, '\t')
codeContent = codeContent.replace(/\\"/g, '"')
codeContent = codeContent.replace(/\\\\/g, '\\')
return codeContent
}
// Prettify function for module value JSON - extracts the 'value' property and formats it
function prettifyModuleValue(content: string): string {
try {
const parsed = JSON.parse(content)
// Extract just the 'value' property (the actual module definition)
if (parsed.value) {
return JSON.stringify(parsed.value, null, 2)
}
return JSON.stringify(parsed, null, 2)
} catch {
// If JSON is incomplete during streaming, try to extract the value property manually
const valueMatch = content.match(/"value"\s*:\s*(\{[\s\S]*)$/)
if (valueMatch) {
let valueContent = valueMatch[1]
// Try to parse and format the extracted value
try {
// Find the matching closing brace for the value object
let braceCount = 0
let endIndex = 0
for (let i = 0; i < valueContent.length; i++) {
if (valueContent[i] === '{') braceCount++
else if (valueContent[i] === '}') braceCount--
if (braceCount === 0) {
endIndex = i + 1
break
}
}
if (endIndex > 0) {
const valueJson = valueContent.substring(0, endIndex)
const parsed = JSON.parse(valueJson)
return JSON.stringify(parsed, null, 2)
}
} catch {
// If parsing fails, just unescape and return the extracted value content
valueContent = valueContent.replace(/\\n/g, '\n')
valueContent = valueContent.replace(/\\t/g, '\t')
valueContent = valueContent.replace(/\\"/g, '"')
valueContent = valueContent.replace(/\\\\/g, '\\')
return valueContent
}
}
// Fallback: just unescape and return
let result = content
result = result.replace(/\\n/g, '\n')
result = result.replace(/\\t/g, '\t')
result = result.replace(/\\"/g, '"')
result = result.replace(/\\\\/g, '\\')
return result
}
}
// Map of tool names to their prettify functions
export const TOOL_PRETTIFY_MAP: Record<string, (content: string) => string> = {
edit_code: prettifyCodeArguments
edit_code: prettifyCodeArguments,
set_module_code: prettifySetModuleCode,
add_module: prettifyModuleValue,
modify_module: prettifyModuleValue
}
export interface ContextStringResult {
@@ -148,7 +241,7 @@ export function applyCodePiecesToFlowModules(
}
}
return YAML.stringify(modifiedModules)
return JSON.stringify(modifiedModules, null, 2)
}
export function buildContextString(selectedContext: ContextElement[]): string {
@@ -363,7 +456,11 @@ export async function processToolCall<T>({
error: 'An error occurred while calling the tool'
})
const errorMessage =
typeof err === 'string' ? err : 'An error occurred while calling the tool'
typeof err === 'object' && 'message' in err
? err.message
: typeof err === 'string'
? err
: 'An error occurred while calling the tool'
result = `Error while calling tool: ${errorMessage}`
}
const toAdd = {
+15 -11
View File
@@ -244,34 +244,38 @@ export async function fetchAvailableModels(
}
export function getModelMaxTokens(provider: AIProvider, model: string) {
if (model.startsWith('gpt-5')) {
if (model.includes('gpt-5')) {
return 128000
} else if ((provider === 'azure_openai' || provider === 'openai') && model.startsWith('o')) {
return 100000
} else if (model.startsWith('claude-sonnet') || model.startsWith('gemini-2.5')) {
} else if (
model.includes('claude-sonnet') ||
model.includes('gemini-2.5') ||
model.includes('claude-haiku')
) {
return 64000
} else if (model.startsWith('gpt-4.1')) {
} else if (model.includes('gpt-4.1')) {
return 32768
} else if (model.startsWith('claude-opus')) {
} else if (model.includes('claude-opus')) {
return 32000
} else if (model.startsWith('gpt-4o') || model.startsWith('codestral')) {
} else if (model.includes('gpt-4o') || model.includes('codestral')) {
return 16384
} else if (model.startsWith('gpt-4-turbo') || model.startsWith('gpt-3.5')) {
} else if (model.includes('gpt-4-turbo') || model.includes('gpt-3.5')) {
return 4096
}
return 8192
}
export function getModelContextWindow(model: string) {
if (model.startsWith('gpt-4.1') || model.startsWith('gemini')) {
if (model.includes('gpt-4.1') || model.includes('gemini')) {
return 1000000
} else if (model.startsWith('gpt-5')) {
} else if (model.includes('gpt-5')) {
return 400000
} else if (model.startsWith('gpt-4o') || model.startsWith('llama-3.3')) {
} else if (model.includes('gpt-4o') || model.includes('llama-3.3')) {
return 128000
} else if (model.startsWith('claude') || model.startsWith('o4-mini') || model.startsWith('o3')) {
} else if (model.includes('claude') || model.includes('o4-mini') || model.includes('o3')) {
return 200000
} else if (model.startsWith('codestral')) {
} else if (model.includes('codestral')) {
return 32000
} else {
return 128000
@@ -129,6 +129,7 @@
onDestroy(() => {
aiChatManager.flowOptions = undefined
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
})
</script>
@@ -213,11 +214,12 @@
{suspendStatus}
onOpenDetails={onOpenPreview}
{previewOpen}
{flowModuleSchemaMap}
/>
{/if}
</Pane>
{#if !disableAi}
<FlowAIChat {flowModuleSchemaMap} />
<FlowAIChat {flowModuleSchemaMap} {onTestFlow} />
{/if}
</Splitpanes>
</div>
@@ -36,6 +36,7 @@
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
onOpenDetails?: () => void
previewOpen?: boolean
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
}
let {
@@ -52,7 +53,8 @@
isOwner,
suspendStatus,
onOpenDetails,
previewOpen = false
previewOpen = false,
flowModuleSchemaMap = undefined
}: Props = $props()
const {
@@ -72,6 +74,7 @@
const { showCaptureHint, triggersState, triggersCount } =
getContext<TriggerContext>('TriggerContext')
function checkDup(modules: FlowModule[]): string | undefined {
let seenModules: string[] = []
for (const m of modules) {
@@ -104,6 +107,7 @@
on:applyArgs
{onTestFlow}
{previewOpen}
{flowModuleSchemaMap}
/>
{:else if selectedId === 'Result'}
<FlowResult {noEditor} {job} {isOwner} {suspendStatus} {onOpenDetails} />
@@ -55,9 +55,16 @@
disabled: boolean
onTestFlow?: (conversationId?: string) => Promise<string | undefined>
previewOpen: boolean
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
}
let { noEditor, disabled, onTestFlow, previewOpen }: Props = $props()
let {
noEditor,
disabled,
onTestFlow,
previewOpen,
flowModuleSchemaMap = undefined
}: Props = $props()
const {
flowStore,
flowStateStore,
@@ -68,6 +75,12 @@
flowInputEditorState
} = getContext<FlowEditorContext>('FlowEditorContext')
// Get diffManager from the graph
const diffManager = $derived(flowModuleSchemaMap?.getDiffManager())
// Use pending schema from diffManager when in diff mode, otherwise use flowStore
const effectiveSchema = $derived(diffManager?.currentInputSchema ?? flowStore.val.schema)
let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled))
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
@@ -447,19 +460,22 @@
value: {
type: 'aiagent',
tools: [],
input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce((accu, key) => {
if (key === 'user_message') {
accu[key] = { type: 'javascript', expr: 'flow_input.user_message' }
} else if (key === 'messages_context_length') {
accu[key] = { type: 'static', value: 10 }
} else {
accu[key] = {
type: 'static',
value: undefined
input_transforms: Object.keys(AI_AGENT_SCHEMA.properties ?? {}).reduce(
(accu, key) => {
if (key === 'user_message') {
accu[key] = { type: 'javascript', expr: 'flow_input.user_message' }
} else if (key === 'messages_context_length') {
accu[key] = { type: 'static', value: 10 }
} else {
accu[key] = {
type: 'static',
value: undefined
}
}
}
return accu
}, {})
return accu
},
{} as AiAgent['input_transforms']
)
}
}
]
@@ -806,7 +822,7 @@
</div>
{:else}
<div class="p-4 border-b">
<FlowInputViewer schema={flowStore.val.schema} />
<FlowInputViewer schema={effectiveSchema} />
</div>
{/if}
</FlowCard>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
import { expect } from 'vitest'
import type {
FlowValue,
FlowModule,
RawScript,
Identity,
ForloopFlow,
WhileloopFlow,
BranchOne,
BranchAll
} from '$lib/gen'
import type { ExtendedOpenFlow } from './types'
import type { StateStore } from '$lib/utils'
// ============================================================================
// Module Creation Helpers
// ============================================================================
/**
* Creates a minimal RawScript module for testing
*/
export function createRawScriptModule(id: string, content: string): FlowModule {
return {
id,
value: {
type: 'rawscript',
content,
language: 'bun',
input_transforms: {}
} as RawScript
}
}
/**
* Creates a minimal Identity module (useful for type-change tests)
*/
export function createIdentityModule(id: string): FlowModule {
return {
id,
value: {
type: 'identity'
} as Identity
}
}
/**
* Creates a ForloopFlow module with nested modules
*/
export function createForloopModule(id: string, nestedModules: FlowModule[]): FlowModule {
return {
id,
value: {
type: 'forloopflow',
modules: nestedModules,
iterator: { type: 'javascript', expr: '[1,2,3]' },
skip_failures: false
} as ForloopFlow
}
}
/**
* Creates a WhileloopFlow module with nested modules
*/
export function createWhileloopModule(id: string, nestedModules: FlowModule[]): FlowModule {
return {
id,
value: {
type: 'whileloopflow',
modules: nestedModules,
skip_failures: false
} as WhileloopFlow
}
}
/**
* Creates a BranchOne module with default and conditional branches
*/
export function createBranchOneModule(
id: string,
defaultModules: FlowModule[],
branches: { expr: string; modules: FlowModule[] }[]
): FlowModule {
return {
id,
value: {
type: 'branchone',
default: defaultModules,
branches: branches.map((b) => ({ expr: b.expr, modules: b.modules }))
} as BranchOne
}
}
/**
* Creates a BranchAll module with parallel branches
*/
export function createBranchAllModule(id: string, branches: { modules: FlowModule[] }[]): FlowModule {
return {
id,
value: {
type: 'branchall',
branches: branches.map((b) => ({ modules: b.modules }))
} as BranchAll
}
}
// ============================================================================
// Flow Creation Helpers
// ============================================================================
/**
* Creates a FlowValue with the given modules
*/
export function createFlow(modules: FlowModule[]): FlowValue {
return { modules }
}
/**
* Creates a FlowValue with optional special modules (failure_module, preprocessor_module)
*/
export function createFlowWithSpecialModules(options: {
modules?: FlowModule[]
failure_module?: FlowModule
preprocessor_module?: FlowModule
}): FlowValue {
return {
modules: options.modules ?? [],
...(options.failure_module && { failure_module: options.failure_module }),
...(options.preprocessor_module && { preprocessor_module: options.preprocessor_module })
}
}
/**
* Wraps a FlowValue in an ExtendedOpenFlow for manager tests
*/
export function createExtendedOpenFlow(
flowValue: FlowValue,
schema?: Record<string, unknown>
): ExtendedOpenFlow {
return { value: flowValue, summary: '', schema }
}
/**
* Creates a minimal StateStore for manager tests
*/
export function createFlowStore(flow: ExtendedOpenFlow): StateStore<ExtendedOpenFlow> {
return { val: flow }
}
// ============================================================================
// Utility Helpers
// ============================================================================
/**
* Deep clones an object via JSON serialization
*/
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
/**
* Asserts that modules appear in the exact order specified.
* Also implicitly verifies the count of modules.
*/
export function expectModuleOrder(modules: FlowModule[], expectedIds: string[]): void {
expect(modules.map((m) => m.id)).toEqual(expectedIds)
}
+438 -58
View File
@@ -1,17 +1,59 @@
import type { FlowModule, FlowValue } from '$lib/gen'
import { dfs } from './dfs'
import { deepEqual } from 'fast-equals'
import type { AIModuleAction } from '../copilot/chat/flow/core'
/** Prefix added to module IDs when the original module coexists with a replacement */
export const DUPLICATE_MODULE_PREFIX = 'old__'
/** Prefix added to new module IDs when restoring original during type change rejection */
export const NEW_MODULE_PREFIX = 'new__'
/**
* Action types for flow module changes during diff tracking
* - added: Module was added to the flow
* - modified: Module content was changed
* - removed: Module was deleted from the flow
* - shadowed: Module is shown as removed (visualization mode)
*/
export type AIModuleAction = 'added' | 'modified' | 'removed' | 'shadowed' | undefined
/**
* Tracks the action performed on a module and whether it requires user approval
*/
export type ModuleActionInfo = {
action: AIModuleAction
/** Whether this change is pending user approval (accept/reject) */
pending: boolean
}
/**
* Normalizes a FlowModule for comparison by removing properties that
* should be ignored when determining if a module has changed.
* Specifically, removes empty `assets` arrays since their presence/absence
* is not a meaningful difference.
*/
function normalizeModuleForComparison(module: FlowModule): FlowModule {
const normalized = { ...module }
if ('value' in normalized && normalized.value && typeof normalized.value === 'object') {
const value = { ...normalized.value } as Record<string, unknown>
// Remove empty assets array - it's not a meaningful difference
if (Array.isArray(value.assets) && value.assets.length === 0) {
delete value.assets
}
normalized.value = value as FlowModule['value']
}
return normalized
}
/**
* The complete diff result with action maps and merged flow
*/
export type FlowTimeline = {
/** Actions for modules in the before flow */
beforeActions: Record<string, AIModuleAction>
beforeActions: Record<string, ModuleActionInfo>
/** Actions for modules in the after flow (adjusted based on display mode) */
afterActions: Record<string, AIModuleAction>
afterActions: Record<string, ModuleActionInfo>
/** The merged flow containing both after modules and removed modules properly nested */
mergedFlow: FlowValue
@@ -29,40 +71,56 @@ export type FlowTimeline = {
*/
export function computeFlowModuleDiff(
beforeFlow: FlowValue,
afterFlow: FlowValue
): { beforeActions: Record<string, AIModuleAction>; afterActions: Record<string, AIModuleAction> } {
const beforeActions: Record<string, AIModuleAction> = {}
const afterActions: Record<string, AIModuleAction> = {}
afterFlow: FlowValue,
options: { markAsPending: boolean } = { markAsPending: false }
): {
beforeActions: Record<string, ModuleActionInfo>
afterActions: Record<string, ModuleActionInfo>
} {
const beforeActions: Record<string, ModuleActionInfo> = {}
const afterActions: Record<string, ModuleActionInfo> = {}
// Get all modules from both flows using dfs
const beforeModules = getAllModulesMap(beforeFlow)
const afterModules = getAllModulesMap(afterFlow)
// Get all modules with their locations from both flows
const beforeModulesWithLoc = getAllModulesWithLocation(beforeFlow)
const afterModulesWithLoc = getAllModulesWithLocation(afterFlow)
// Find all module IDs
const allModuleIds = new Set([...beforeModules.keys(), ...afterModules.keys()])
const allModuleIds = new Set([...beforeModulesWithLoc.keys(), ...afterModulesWithLoc.keys()])
for (const moduleId of allModuleIds) {
const beforeModule = beforeModules.get(moduleId)
const afterModule = afterModules.get(moduleId)
const beforeEntry = beforeModulesWithLoc.get(moduleId)
const afterEntry = afterModulesWithLoc.get(moduleId)
if (!beforeModule && afterModule) {
if (!beforeEntry && afterEntry) {
// Module exists in after but not before -> added
afterActions[moduleId] = 'added'
} else if (beforeModule && !afterModule) {
afterActions[moduleId] = { action: 'added', pending: options.markAsPending }
} else if (beforeEntry && !afterEntry) {
// Module exists in before but not after -> removed
beforeActions[moduleId] = 'removed'
afterActions[moduleId] = 'shadowed'
} else if (beforeModule && afterModule) {
// Module exists in both -> check type and content
const typeChanged = beforeModule.value.type !== afterModule.value.type
if (typeChanged) {
// Type changed -> treat as removed + added
beforeActions[moduleId] = 'removed'
afterActions[moduleId] = 'added'
} else if (!deepEqual(beforeModule, afterModule)) {
// Same type but different content -> modified
beforeActions[moduleId] = 'modified'
afterActions[moduleId] = 'modified'
beforeActions[moduleId] = { action: 'removed', pending: options.markAsPending }
afterActions[moduleId] = { action: 'shadowed', pending: options.markAsPending }
} else if (beforeEntry && afterEntry) {
// Module exists in both -> check location first, then type and content
if (!locationsEqual(beforeEntry.location, afterEntry.location)) {
// Location changed -> treat as removed from old + added at new
beforeActions[moduleId] = { action: 'removed', pending: options.markAsPending }
afterActions[moduleId] = { action: 'added', pending: options.markAsPending }
} else {
// Same location -> check type and content
const typeChanged = beforeEntry.module.value.type !== afterEntry.module.value.type
if (typeChanged) {
// Type changed -> treat as removed + added
beforeActions[moduleId] = { action: 'removed', pending: options.markAsPending }
afterActions[moduleId] = { action: 'added', pending: options.markAsPending }
} else if (
!deepEqual(
normalizeModuleForComparison(beforeEntry.module),
normalizeModuleForComparison(afterEntry.module)
)
) {
// Same type but different content -> modified
beforeActions[moduleId] = { action: 'modified', pending: options.markAsPending }
afterActions[moduleId] = { action: 'modified', pending: options.markAsPending }
}
}
}
}
@@ -97,10 +155,81 @@ function getAllModulesMap(flow: FlowValue): Map<string, FlowModule> {
return moduleMap
}
/**
* Represents a module along with its location in the flow
*/
type ModuleWithLocation = {
module: FlowModule
location: ModuleParentLocation
}
/**
* Helper function to get all modules from a flow as a Map with their locations
*/
function getAllModulesWithLocation(flow: FlowValue): Map<string, ModuleWithLocation> {
const result = new Map<string, ModuleWithLocation>()
const allModules = dfs(flow.modules ?? [], (m) => m)
for (const module of allModules) {
if (module?.id) {
const location = findModuleParent(flow, module.id)
if (location) {
result.set(module.id, { module, location })
}
}
}
// Add special modules
if (flow.failure_module?.id) {
result.set(flow.failure_module.id, {
module: flow.failure_module,
location: { type: 'failure', index: -1 }
})
}
if (flow.preprocessor_module?.id) {
result.set(flow.preprocessor_module.id, {
module: flow.preprocessor_module,
location: { type: 'preprocessor', index: -1 }
})
}
return result
}
/**
* Compares two module locations for equality.
* Two locations are equal if they refer to the same parent container.
* Index within the container is not considered (modules can be reordered).
*/
export function locationsEqual(a: ModuleParentLocation | null, b: ModuleParentLocation | null): boolean {
if (!a || !b) return a === b
if (a.type !== b.type) return false
switch (a.type) {
case 'root':
case 'failure':
case 'preprocessor':
return true // Same type is enough (index doesn't matter for location equality)
case 'forloop':
case 'whileloop':
case 'aiagent':
return a.parentId === (b as typeof a).parentId
case 'branchone-default':
return a.parentId === (b as typeof a).parentId
case 'branchone-branch':
case 'branchall-branch':
return (
a.parentId === (b as typeof a).parentId && a.branchIndex === (b as typeof a).branchIndex
)
default:
return false
}
}
/**
* Represents the parent location of a module
*/
type ModuleParentLocation =
export type ModuleParentLocation =
| { type: 'root'; index: number }
| { type: 'forloop' | 'whileloop'; parentId: string; index: number }
| { type: 'branchone-default'; parentId: string; index: number }
@@ -113,7 +242,7 @@ type ModuleParentLocation =
/**
* Finds the parent location of a module in a flow
*/
function findModuleParent(flow: FlowValue, moduleId: string): ModuleParentLocation | null {
export function findModuleParent(flow: FlowValue, moduleId: string): ModuleParentLocation | null {
// Check special modules
if (flow.failure_module?.id === moduleId) {
return { type: 'failure', index: -1 }
@@ -210,11 +339,62 @@ function cloneModule(module: FlowModule): FlowModule {
}
/**
* Prepends a prefix to a module's ID to avoid collisions
* Prepends a prefix to a module's ID and all nested child module IDs to avoid collisions
*/
function prependModuleId(module: FlowModule, prefix: string): FlowModule {
const newModule = cloneModule(module)
newModule.id = prefix + newModule.id
// Recursively prefix nested module IDs
if (newModule.value.type === 'forloopflow' || newModule.value.type === 'whileloopflow') {
newModule.value.modules = newModule.value.modules.map((m) => prependModuleId(m, prefix))
} else if (newModule.value.type === 'branchone') {
newModule.value.default = newModule.value.default.map((m) => prependModuleId(m, prefix))
newModule.value.branches = newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules.map((m) => prependModuleId(m, prefix))
}))
} else if (newModule.value.type === 'branchall') {
newModule.value.branches = newModule.value.branches.map((branch) => ({
...branch,
modules: branch.modules.map((m) => prependModuleId(m, prefix))
}))
} else if (newModule.value.type === 'aiagent' && newModule.value.tools) {
// Handle aiagent tools - only prefix FlowModule tools, not MCP tools
newModule.value.tools = newModule.value.tools.map((tool) => {
// MCP tools have tool_type: 'mcp', FlowModule tools have tool_type: 'flowmodule' or undefined
if (tool.value.tool_type === 'mcp') {
return tool // MCP tools don't have nested module IDs
}
// For FlowModule tools, prefix the ID and recurse
const prefixedTool = {
...tool,
id: prefix + tool.id
}
// If the tool has nested modules (it's a container type), recurse
const innerValue = tool.value as FlowModule['value']
if (innerValue.type === 'forloopflow' || innerValue.type === 'whileloopflow') {
;(prefixedTool.value as any).modules = (innerValue as any).modules.map((m: FlowModule) =>
prependModuleId(m, prefix)
)
} else if (innerValue.type === 'branchone') {
;(prefixedTool.value as any).default = (innerValue as any).default.map((m: FlowModule) =>
prependModuleId(m, prefix)
)
;(prefixedTool.value as any).branches = (innerValue as any).branches.map((branch: any) => ({
...branch,
modules: branch.modules.map((m: FlowModule) => prependModuleId(m, prefix))
}))
} else if (innerValue.type === 'branchall') {
;(prefixedTool.value as any).branches = (innerValue as any).branches.map((branch: any) => ({
...branch,
modules: branch.modules.map((m: FlowModule) => prependModuleId(m, prefix))
}))
}
return prefixedTool
})
}
return newModule
}
@@ -264,28 +444,122 @@ function getAllModuleIds(flow: FlowValue): Set<string> {
return ids
}
/**
* Scans the merged flow for duplicate IDs and prefixes duplicates with 'old__'.
* This handles the case where a module is moved from one location to another -
* both the old and new versions end up in the merged flow with the same ID.
*/
function fixDuplicateIds(merged: FlowValue, beforeFlow: FlowValue): void {
const seenIds = new Set<string>()
const beforeModulesMap = getAllModulesMap(beforeFlow)
// Process a single module - returns the (possibly prefixed) module
function processModule(module: FlowModule): FlowModule {
let result = module
if (seenIds.has(module.id)) {
// Duplicate found! Check if this one exists in beforeFlow
const beforeModule = beforeModulesMap.get(module.id)
if (beforeModule) {
// This is the "old" version - prefix it and all its children
result = prependModuleId(module, DUPLICATE_MODULE_PREFIX)
}
} else {
seenIds.add(module.id)
}
// Recurse into nested modules (use result which may be prefixed)
processNestedModules(result)
return result
}
// Process nested modules in-place
function processNestedModules(module: FlowModule): void {
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
module.value.modules = module.value.modules.map((m) => processModule(m))
} else if (module.value.type === 'branchone') {
module.value.default = module.value.default.map((m) => processModule(m))
for (const branch of module.value.branches) {
branch.modules = branch.modules.map((m) => processModule(m))
}
} else if (module.value.type === 'branchall') {
for (const branch of module.value.branches) {
branch.modules = branch.modules.map((m) => processModule(m))
}
} else if (module.value.type === 'aiagent' && module.value.tools) {
// For aiagent tools, we need to track IDs of FlowModule tools
for (const tool of module.value.tools) {
if (tool.value.tool_type !== 'mcp') {
if (seenIds.has(tool.id)) {
// Can't easily prefix in-place here, but aiagent tools rarely move
// The main use case is regular modules moving in/out of loops/branches
} else {
seenIds.add(tool.id)
}
}
}
}
}
// Process root modules
if (merged.modules) {
merged.modules = merged.modules.map((m) => processModule(m))
}
// Process special modules
if (merged.failure_module) {
if (seenIds.has(merged.failure_module.id)) {
const beforeModule = beforeModulesMap.get(merged.failure_module.id)
if (beforeModule) {
merged.failure_module = prependModuleId(merged.failure_module, DUPLICATE_MODULE_PREFIX)
}
} else {
seenIds.add(merged.failure_module.id)
}
}
if (merged.preprocessor_module) {
if (seenIds.has(merged.preprocessor_module.id)) {
const beforeModule = beforeModulesMap.get(merged.preprocessor_module.id)
if (beforeModule) {
merged.preprocessor_module = prependModuleId(
merged.preprocessor_module,
DUPLICATE_MODULE_PREFIX
)
}
} else {
seenIds.add(merged.preprocessor_module.id)
}
}
}
/**
* Reconstructs the merged flow with removed modules properly nested
*/
function reconstructMergedFlow(
afterFlow: FlowValue,
beforeFlow: FlowValue,
beforeActions: Record<string, AIModuleAction>
beforeActions: Record<string, ModuleActionInfo>
): FlowValue {
// Deep clone afterFlow to avoid mutation
const merged: FlowValue = JSON.parse(JSON.stringify(afterFlow))
// Get all removed/shadowed modules from beforeFlow
const removedModules = Object.entries(beforeActions)
.filter(([_, action]) => action === 'removed' || action === 'shadowed')
.filter(([_, action]) => action.action === 'removed' || action.action === 'shadowed')
.map(([id]) => id)
// Create a Set for faster lookup
const removedModulesSet = new Set(removedModules)
// Cache beforeFlow modules map and merged IDs to avoid recomputing in the loop
const beforeModulesMap = getAllModulesMap(beforeFlow)
const mergedIds = getAllModuleIds(merged)
// For each removed module, find its parent and insert it
for (const removedId of removedModules) {
const beforeModule = getAllModulesMap(beforeFlow).get(removedId)
const beforeModule = beforeModulesMap.get(removedId)
if (!beforeModule) continue
const parentLocation = findModuleParent(beforeFlow, removedId)
@@ -308,12 +582,14 @@ function reconstructMergedFlow(
// Check for ID collision - this happens when a module type changed
// In this case, the new module is already in the merged flow as 'added'
// We need to prepend "__" to the removed module's ID so both can coexist
const existingIds = getAllModuleIds(merged)
if (existingIds.has(clonedModule.id)) {
clonedModule = prependModuleId(clonedModule, '__')
// We prepend the duplicate prefix to the removed module's ID so both can coexist
if (mergedIds.has(clonedModule.id)) {
clonedModule = prependModuleId(clonedModule, DUPLICATE_MODULE_PREFIX)
}
// Track the newly added module ID
mergedIds.add(clonedModule.id)
// Insert based on parent location
if (parentLocation.type === 'failure') {
merged.failure_module = clonedModule
@@ -335,6 +611,10 @@ function reconstructMergedFlow(
}
}
// Post-process: fix any duplicate IDs that may have been created
// This handles the case where a module moved from one location to another
fixDuplicateIds(merged, beforeFlow)
return merged
}
@@ -389,11 +669,17 @@ function insertIntoNestedParent(
// Find the parent module in merged flow
const parentModule = findModuleById(merged, parentLocation.parentId)
if (!parentModule) return
if (!parentModule) {
console.warn('Parent module not found', parentLocation)
return
}
// Get the before parent to know original ordering
const beforeParent = findModuleById(beforeFlow, parentLocation.parentId)
if (!beforeParent) return
if (!beforeParent) {
console.warn('Before parent module not found', parentLocation)
return
}
// Insert based on type
if (parentLocation.type === 'forloop' && parentModule.value.type === 'forloopflow') {
@@ -430,9 +716,24 @@ function insertIntoNestedParent(
parentLocation.type === 'branchone-branch' &&
parentModule.value.type === 'branchone'
) {
const branch = parentModule.value.branches[parentLocation.branchIndex]
let branch = parentModule.value.branches[parentLocation.branchIndex]
const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex]
// If the branch doesn't exist (entire branch was removed), recreate it from beforeFlow
if (!branch && beforeBranch) {
// Ensure we have enough branch slots
while (parentModule.value.branches.length <= parentLocation.branchIndex) {
parentModule.value.branches.push({ expr: '', modules: [] })
}
// Restore the branch with its original expr but empty modules (we'll add them)
parentModule.value.branches[parentLocation.branchIndex] = {
...beforeBranch,
modules: []
}
branch = parentModule.value.branches[parentLocation.branchIndex]
}
if (branch) {
const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex]
const beforeModules = beforeBranch?.modules ?? []
const insertIndex = findBestInsertPosition(
branch.modules,
@@ -446,9 +747,24 @@ function insertIntoNestedParent(
parentLocation.type === 'branchall-branch' &&
parentModule.value.type === 'branchall'
) {
const branch = parentModule.value.branches[parentLocation.branchIndex]
let branch = parentModule.value.branches[parentLocation.branchIndex]
const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex]
// If the branch doesn't exist (entire branch was removed), recreate it from beforeFlow
if (!branch && beforeBranch) {
// Ensure we have enough branch slots
while (parentModule.value.branches.length <= parentLocation.branchIndex) {
parentModule.value.branches.push({ modules: [] })
}
// Restore the branch with empty modules (we'll add them)
parentModule.value.branches[parentLocation.branchIndex] = {
...beforeBranch,
modules: []
}
branch = parentModule.value.branches[parentLocation.branchIndex]
}
if (branch) {
const beforeBranch = (beforeParent.value as any).branches?.[parentLocation.branchIndex]
const beforeModules = beforeBranch?.modules ?? []
const insertIndex = findBestInsertPosition(
branch.modules,
@@ -483,33 +799,36 @@ function findModuleById(flow: FlowValue, moduleId: string): FlowModule | null {
* Adjusts the after actions based on display mode and adds entries for prefixed IDs
*/
function adjustActionsForDisplay(
afterActions: Record<string, AIModuleAction>,
beforeActions: Record<string, AIModuleAction>,
afterActions: Record<string, ModuleActionInfo>,
beforeActions: Record<string, ModuleActionInfo>,
markRemovedAsShadowed: boolean,
mergedFlow: FlowValue
): Record<string, AIModuleAction> {
const adjusted: Record<string, AIModuleAction> = {}
): Record<string, ModuleActionInfo> {
const adjusted: Record<string, ModuleActionInfo> = {}
// Copy all existing actions
for (const [id, action] of Object.entries(afterActions)) {
if (!markRemovedAsShadowed && action === 'shadowed') {
if (!markRemovedAsShadowed && action.action === 'shadowed') {
// In unified mode, change 'shadowed' to 'removed' for proper coloring
adjusted[id] = 'removed'
adjusted[id] = { action: 'removed', pending: action.pending }
} else {
adjusted[id] = action
}
}
// Add entries for prefixed IDs (modules that had type changes or were removed)
// These are the old versions that got "__" prepended to their ID
// These are the old versions that got the duplicate prefix prepended to their ID
const allMergedIds = getAllModuleIds(mergedFlow)
for (const id of allMergedIds) {
if (id.startsWith('__') && !adjusted[id]) {
if (id.startsWith(DUPLICATE_MODULE_PREFIX) && !adjusted[id]) {
// This is a prefixed ID for a module that was removed
const originalId = id.substring(2)
const originalId = id.substring(DUPLICATE_MODULE_PREFIX.length)
// Check beforeActions to see if this module was removed
if (beforeActions[originalId] === 'removed') {
adjusted[id] = markRemovedAsShadowed ? 'shadowed' : 'removed'
if (beforeActions[originalId]?.action === 'removed') {
adjusted[id] = {
action: markRemovedAsShadowed ? 'shadowed' : 'removed',
pending: beforeActions[originalId].pending
}
}
}
}
@@ -530,10 +849,15 @@ function adjustActionsForDisplay(
export function buildFlowTimeline(
beforeFlow: FlowValue,
afterFlow: FlowValue,
options: { markRemovedAsShadowed: boolean } = { markRemovedAsShadowed: false }
options: { markRemovedAsShadowed: boolean; markAsPending: boolean } = {
markRemovedAsShadowed: false,
markAsPending: false
}
): FlowTimeline {
// Compute the diff between the two flows
const { beforeActions, afterActions } = computeFlowModuleDiff(beforeFlow, afterFlow)
const { beforeActions, afterActions } = computeFlowModuleDiff(beforeFlow, afterFlow, {
markAsPending: options.markAsPending
})
// Reconstruct merged flow with removed modules properly nested
const mergedFlow = reconstructMergedFlow(afterFlow, beforeFlow, beforeActions)
@@ -553,6 +877,62 @@ export function buildFlowTimeline(
}
}
/**
* Inserts a module into a flow at its correct position based on where it was located in the source flow.
* This is useful when restoring a removed module - it finds the correct parent and position.
*
* @param targetFlow - The flow to insert the module into
* @param moduleToInsert - The module to insert
* @param sourceFlow - The flow where the module originally existed (to find parent location and ordering)
* @param moduleId - The ID of the module being inserted
*/
export function insertModuleIntoFlow(
targetFlow: FlowValue,
moduleToInsert: FlowModule,
sourceFlow: FlowValue,
moduleId: string
): void {
const parentLocation = findModuleParent(sourceFlow, moduleId)
if (!parentLocation) return
// Handle special modules
if (parentLocation.type === 'failure') {
targetFlow.failure_module = moduleToInsert
return
}
if (parentLocation.type === 'preprocessor') {
targetFlow.preprocessor_module = moduleToInsert
return
}
// Handle root level modules
if (parentLocation.type === 'root') {
const insertIndex = findBestInsertPosition(
targetFlow.modules ?? [],
sourceFlow.modules ?? [],
parentLocation.index,
moduleId
)
if (!targetFlow.modules) targetFlow.modules = []
targetFlow.modules.splice(insertIndex, 0, moduleToInsert)
return
}
// Handle nested modules
insertIntoNestedParent(targetFlow, parentLocation, moduleToInsert, sourceFlow)
}
/**
* Finds a module by ID anywhere in a flow (including nested modules, failure, and preprocessor)
*
* @param flow - The flow to search in
* @param moduleId - The ID of the module to find
* @returns The module if found, null otherwise
*/
export function findModuleInFlow(flow: FlowValue, moduleId: string): FlowModule | null {
return findModuleById(flow, moduleId)
}
/**
* Checks if the input schema has changed between two flow versions.
* The input schema always exists (even if empty), so we only check for modifications.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,543 @@
/**
* Flow Diff Manager
*
* A reusable store for managing flow diff state, module actions, and accept/reject operations.
* This decouples diff management from specific UI components (like AI chat) and makes it
* available for any use case that needs to track and apply flow changes.
*/
import type { ExtendedOpenFlow } from './types'
import type { FlowModule, FlowValue } from '$lib/gen'
import type { ModuleActionInfo } from './flowDiff'
import {
buildFlowTimeline,
insertModuleIntoFlow,
findModuleParent,
locationsEqual,
DUPLICATE_MODULE_PREFIX,
NEW_MODULE_PREFIX
} from './flowDiff'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { StateStore } from '$lib/utils'
import { getIndexInNestedModules } from '../copilot/chat/flow/utils'
import { dfs } from './previousResults'
import type DiffDrawer from '../DiffDrawer.svelte'
import { SPECIAL_MODULE_IDS } from '../copilot/chat/shared'
export type FlowDiffManager = ReturnType<typeof createFlowDiffManager>
/**
* Options for computing diff
*/
export type ComputeDiffOptions = {
/** Mark all changes as pending (requiring user approval) */
markAsPending?: boolean
/** Mark removed modules as shadowed instead of removed (for visualization) */
markRemovedAsShadowed?: boolean
}
/**
* Creates a flow diff manager instance
*/
function createSkeletonModule(module: FlowModule): FlowModule {
const clone = JSON.parse(JSON.stringify(module))
if (clone.value.type === 'forloopflow' || clone.value.type === 'whileloopflow') {
clone.value.modules = []
} else if (clone.value.type === 'branchone') {
clone.value.default = []
clone.value.branches.forEach((b: any) => (b.modules = []))
} else if (clone.value.type === 'branchall') {
clone.value.branches.forEach((b: any) => (b.modules = []))
} else if (clone.value.type === 'aiagent') {
clone.value.tools = []
}
return clone
}
export function createFlowDiffManager({ testMode = false } = {}) {
// State: snapshot of flow before changes
let beforeFlow = $state<ExtendedOpenFlow | undefined>(undefined)
// State: current flow (after changes)
let currentFlow = $state<FlowValue | undefined>(undefined)
// State: merged flow containing both original and modified/removed modules
let mergedFlow = $state<FlowValue | undefined>(undefined)
// State: current input schema
let currentInputSchema = $state<Record<string, any> | undefined>(undefined)
// State: whether to mark removed modules as shadowed (for side-by-side view)
let markRemovedAsShadowed = $state(false)
// State: whether to allow accepting/rejecting changes to the flow
let editMode = $state(false)
// State: module actions tracking changes (added/modified/removed/shadowed)
let moduleActions = $state<Record<string, ModuleActionInfo>>({})
// Reference to DiffDrawer component for showing module diffs (not reactive)
let diffDrawer: DiffDrawer | undefined = undefined
// Derived: whether there are any pending changes
const hasPendingChanges = $derived(Object.values(moduleActions).some((info) => info.pending))
// Auto-compute diff when beforeFlow or currentFlow changes
$effect(() => {
if (beforeFlow && currentFlow) {
const timeline = buildFlowTimeline(beforeFlow.value, currentFlow, {
markRemovedAsShadowed: markRemovedAsShadowed,
markAsPending: editMode
})
// Store the merged flow for rendering
mergedFlow = timeline.mergedFlow
// Update module actions
const newActions = { ...timeline.afterActions }
// Check for input schema changes
if (beforeFlow.schema && currentInputSchema) {
const schemaChanged =
JSON.stringify(beforeFlow.schema) !== JSON.stringify(currentInputSchema)
if (schemaChanged) {
newActions[SPECIAL_MODULE_IDS.INPUT] = {
action: 'modified',
pending: editMode
}
}
}
updateModuleActions(newActions)
// If no more actions, clear the snapshot (exit diff mode)
if (Object.keys(newActions).length === 0 && !testMode) {
clearSnapshot()
}
} else if (!beforeFlow) {
// Clear module actions and merged flow when no snapshot
mergedFlow = undefined
updateModuleActions({})
}
})
/**
* Helper to update moduleActions and notify listeners
*/
function updateModuleActions(newActions: Record<string, ModuleActionInfo>) {
moduleActions = newActions
}
/**
* Set the before flow snapshot for diff computation
*/
function setBeforeFlow(flow: ExtendedOpenFlow | undefined) {
beforeFlow = flow
}
/**
* Set the current flow state for diff computation
*/
function setCurrentFlow(flow: FlowValue | undefined) {
currentFlow = flow
}
/**
* Set the current input schema for tracking schema changes
*/
function setCurrentInputSchema(schema: Record<string, any> | undefined) {
currentInputSchema = schema
}
/**
* Set whether to mark removed modules as shadowed (for side-by-side view)
*/
function setMarkRemovedAsShadowed(value: boolean) {
markRemovedAsShadowed = value
}
/**
* Set whether to edit the flow
*/
function setEditMode(value: boolean) {
editMode = value
}
/**
* Clear the snapshot and all module actions
*/
function clearSnapshot() {
beforeFlow = undefined
currentFlow = undefined
mergedFlow = undefined
currentInputSchema = undefined
updateModuleActions({})
}
/**
* Set module actions directly (useful when actions are computed elsewhere)
*/
function setModuleActions(actions: Record<string, ModuleActionInfo>) {
updateModuleActions(actions)
}
/**
* Helper to get a module from a flow by ID
*/
function getModuleFromFlow(id: string, flow: ExtendedOpenFlow): FlowModule | undefined {
if (flow.value.preprocessor_module?.id === id) {
return flow.value.preprocessor_module
} else if (flow.value.failure_module?.id === id) {
return flow.value.failure_module
} else {
return dfs(id, flow, false)[0]
}
}
/**
* Internal helper to delete a module from a flow object
* Returns true if the module was found and deleted, false otherwise
*/
function deleteModuleInternal(id: string, flow: ExtendedOpenFlow): boolean {
if (flow.value.preprocessor_module?.id === id) {
flow.value.preprocessor_module = undefined
return true
} else if (flow.value.failure_module?.id === id) {
flow.value.failure_module = undefined
return true
} else {
const result = getIndexInNestedModules(flow, id)
if (!result) {
// Module not found (may have been deleted along with a parent)
return false
}
const { modules } = result
const index = modules.findIndex((m) => m.id === id)
if (index >= 0) {
modules.splice(index, 1)
return true
}
return false
}
}
/**
* Helper to delete a module from the flow
* Returns true if the module was found and deleted, false otherwise
*/
function deleteModuleFromFlow(
id: string,
flowStore: StateStore<ExtendedOpenFlow>,
selectNextIdFn?: (id: string) => void
): boolean {
selectNextIdFn?.(id)
const deleted = deleteModuleInternal(id, flowStore.val)
if (deleted) {
refreshStateStore(flowStore)
}
return deleted
}
/**
* Accept a module action (keep the changes)
* Removes the action from tracking after acceptance
*/
function acceptModule(id: string, flowStore?: StateStore<ExtendedOpenFlow>, asSkeleton = false) {
if (!beforeFlow || !currentFlow) {
console.warn('Cannot accept module without beforeFlow and currentFlow snapshots')
return
}
const info = moduleActions[id]
if (!info) return
const actualId = id.startsWith(DUPLICATE_MODULE_PREFIX)
? id.substring(DUPLICATE_MODULE_PREFIX.length)
: id
if (id === SPECIAL_MODULE_IDS.INPUT) {
// Accept input schema changes: update beforeFlow to match currentInputSchema
if (beforeFlow.schema && currentInputSchema) {
beforeFlow.schema = JSON.parse(JSON.stringify(currentInputSchema))
}
} else if (info.action === 'removed') {
// Removed in after: Remove from beforeFlow
deleteModuleInternal(actualId, beforeFlow)
} else if (info.action === 'added') {
// Added in after: Add to beforeFlow
// Check if parent exists in beforeFlow; if not, recursively accept parent first.
const parentLoc = findModuleParent(currentFlow, actualId)
if (
parentLoc &&
parentLoc.type !== 'root' &&
parentLoc.type !== 'failure' &&
parentLoc.type !== 'preprocessor'
) {
const parentInBefore = getModuleFromFlow(parentLoc.parentId, beforeFlow)
if (!parentInBefore) {
// Parent is missing in beforeFlow. It must be pending acceptance.
// Accept as skeleton to avoid auto-accepting all siblings.
acceptModule(parentLoc.parentId, flowStore, true)
}
}
// Use insertModuleIntoFlow targeting beforeFlow, sourcing position from currentFlow
let module = getModuleFromFlow(actualId, {
value: currentFlow,
summary: ''
} as ExtendedOpenFlow)
if (module) {
// Check if module already exists in beforeFlow (could be a skeleton from earlier acceptance)
const existingModule = getModuleFromFlow(actualId, beforeFlow)
if (existingModule) {
// Module exists in beforeFlow - check if it's in the same location
const beforeLocation = findModuleParent(beforeFlow.value, actualId)
const afterLocation = findModuleParent(currentFlow, actualId)
// Compare locations - if different, this is a move and we need to insert at new location
const sameLocation = locationsEqual(beforeLocation, afterLocation)
if (sameLocation) {
// Module is in the same location, update it in-place
const moduleToApply = asSkeleton ? createSkeletonModule(module) : module
Object.keys(existingModule).forEach((k) => delete (existingModule as any)[k])
Object.assign(existingModule, $state.snapshot(moduleToApply))
} else {
// Module is being moved - insert at new location (the old copy will be removed when old__id is accepted)
const moduleToInsert = asSkeleton ? createSkeletonModule(module) : module
insertModuleIntoFlow(
beforeFlow.value,
$state.snapshot(moduleToInsert),
currentFlow,
actualId
)
}
} else {
// Module doesn't exist, insert it
const moduleToInsert = asSkeleton ? createSkeletonModule(module) : module
insertModuleIntoFlow(
beforeFlow.value,
$state.snapshot(moduleToInsert),
currentFlow,
actualId
)
}
}
} else if (info.action === 'modified') {
// Modified: Apply modifications to beforeFlow module
const beforeModule = getModuleFromFlow(actualId, beforeFlow)
const afterModule = getModuleFromFlow(actualId, {
value: currentFlow,
summary: ''
} as ExtendedOpenFlow)
if (beforeModule && afterModule) {
Object.keys(beforeModule).forEach((k) => delete (beforeModule as any)[k])
Object.assign(beforeModule, $state.snapshot(afterModule))
}
}
}
/**
* Reject a module action (revert the changes)
* Removes the action from tracking after rejection
*/
function rejectModule(id: string, flowStore?: StateStore<ExtendedOpenFlow>) {
if (!beforeFlow) {
throw new Error('Cannot reject module without a beforeFlow snapshot')
}
const actualId = id.startsWith(DUPLICATE_MODULE_PREFIX)
? id.substring(DUPLICATE_MODULE_PREFIX.length)
: id
const info = moduleActions[id]
if (!info) return
// Only perform revert operations if flowStore is provided
if (flowStore) {
if (id === SPECIAL_MODULE_IDS.INPUT) {
// Revert input schema changes
flowStore.val.schema = beforeFlow.schema
currentInputSchema = flowStore.val.schema
} else if (info.action === 'added') {
// Added in after: Remove from flowStore (currentFlow)
// deleteModuleFromFlow handles the case where the module was already deleted (e.g., with its parent)
deleteModuleFromFlow(actualId, flowStore)
} else if (info.action === 'removed') {
// Removed in after: Restore to flowStore (currentFlow)
// Source from beforeFlow
const oldModule = getModuleFromFlow(actualId, beforeFlow)
if (oldModule) {
// For type changes (old__ prefix), rename the new module to avoid ID conflict
if (id.startsWith(DUPLICATE_MODULE_PREFIX)) {
const existingNew = getModuleFromFlow(actualId, flowStore.val)
if (existingNew) {
existingNew.id = `${NEW_MODULE_PREFIX}${actualId}`
}
}
insertModuleIntoFlow(
flowStore.val.value,
$state.snapshot(oldModule),
beforeFlow.value,
actualId
)
}
refreshStateStore(flowStore)
} else if (info.action === 'modified') {
// Modified: Revert modifications in flowStore (currentFlow)
const oldModule = getModuleFromFlow(actualId, beforeFlow)
const newModule = getModuleFromFlow(actualId, flowStore.val)
if (oldModule && newModule) {
Object.keys(newModule).forEach((k) => delete (newModule as any)[k])
Object.assign(newModule, $state.snapshot(oldModule))
}
refreshStateStore(flowStore)
}
currentFlow = flowStore.val.value
}
// Note: The $effect will automatically recompute the diff, clearing the action
// since flowStore (currentFlow) now matches beforeFlow for this module.
}
/**
* Accept all pending module actions
*/
function acceptAll(flowStore?: StateStore<ExtendedOpenFlow>) {
const ids = Object.keys(moduleActions)
for (const id of ids) {
if (moduleActions[id]?.pending) {
acceptModule(id, flowStore)
}
}
}
/**
* Reject all pending module actions (in reverse order for nested modules)
*/
function rejectAll(flowStore?: StateStore<ExtendedOpenFlow>) {
const ids = Object.keys(moduleActions)
// Process in reverse to handle nested modules correctly
for (let i = ids.length - 1; i >= 0; i--) {
if (moduleActions[ids[i]]?.pending) {
rejectModule(ids[i], flowStore)
}
}
}
/**
* Revert the entire flow to the snapshot
* @param flowStore - The flow store to update
* @param snapshot - Optional specific snapshot to revert to (defaults to beforeFlow)
*/
function revertToSnapshot(flowStore: StateStore<ExtendedOpenFlow>, snapshot?: ExtendedOpenFlow) {
const targetSnapshot = snapshot ?? beforeFlow
if (!targetSnapshot) return
flowStore.val = targetSnapshot
refreshStateStore(flowStore)
clearSnapshot()
}
/**
* Set the DiffDrawer instance for showing module diffs
*/
function setDiffDrawer(drawer: DiffDrawer | undefined) {
diffDrawer = drawer
}
/**
* Show diff for a specific module or Input schema
*/
function showModuleDiff(moduleId: string) {
if (!diffDrawer || !beforeFlow) return
if (moduleId === SPECIAL_MODULE_IDS.INPUT) {
// Show input schema diff
diffDrawer.openDrawer()
diffDrawer.setDiff({
mode: 'simple',
title: 'Flow Input Schema Diff',
original: { schema: beforeFlow.schema ?? {} },
current: { schema: currentInputSchema ?? {} }
})
} else {
// Show module diff
const beforeModule = getModuleFromFlow(moduleId, beforeFlow)
// Need to check failure_module and preprocessor_module for currentFlow as well
let afterModule: FlowModule | undefined = undefined
if (currentFlow) {
if (currentFlow.preprocessor_module?.id === moduleId) {
afterModule = currentFlow.preprocessor_module
} else if (currentFlow.failure_module?.id === moduleId) {
afterModule = currentFlow.failure_module
} else {
afterModule = dfs(moduleId, { value: currentFlow, summary: '' }, false)[0]
}
}
if (beforeModule && afterModule) {
diffDrawer.openDrawer()
diffDrawer.setDiff({
mode: 'simple',
title: `Module Diff: ${moduleId}`,
original: beforeModule,
current: afterModule
})
}
}
}
return {
// State accessors
get beforeFlow() {
return beforeFlow
},
get currentFlow() {
return currentFlow
},
get mergedFlow() {
return mergedFlow
},
get moduleActions() {
return moduleActions
},
get hasPendingChanges() {
return hasPendingChanges
},
get currentInputSchema() {
return currentInputSchema
},
get editModeEnabled() {
return editMode
},
// Snapshot management
setBeforeFlow,
setCurrentFlow,
setCurrentInputSchema,
setMarkRemovedAsShadowed,
setEditMode,
clearSnapshot,
// Module actions management
setModuleActions,
// Accept/reject operations
acceptModule,
rejectModule,
acceptAll,
rejectAll,
revertToSnapshot,
// Diff drawer management
setDiffDrawer,
showModuleDiff
}
}
@@ -1,4 +1,5 @@
import type { FlowModule, InputTransform, OpenFlow } from '$lib/gen'
import { isFlowModuleTool } from './agentToolUtils'
type ModuleBranches = FlowModule[][]
@@ -9,6 +10,22 @@ export function getSubModules(flowModule: FlowModule): ModuleBranches {
return flowModule.value.branches.map((branch) => branch.modules)
} else if (flowModule.value.type == 'branchone') {
return [...flowModule.value.branches.map((branch) => branch.modules), flowModule.value.default]
} else if (flowModule.value.type === 'aiagent') {
// Return AI agent tools as pseudo-FlowModules for searching
if (flowModule.value.tools) {
return [
flowModule.value.tools
.filter(isFlowModuleTool)
.map(
(tool) =>
({
id: tool.id,
value: tool.value,
summary: tool.summary
}) as FlowModule
)
]
}
}
return []
}
@@ -165,7 +165,15 @@ export async function createBranchAll(id: string): Promise<[FlowModule, FlowModu
export async function createAiAgent(id: string): Promise<[FlowModule, FlowModuleState]> {
const aiAgentFlowModules: FlowModule = {
id,
value: { type: 'aiagent', tools: [], input_transforms: {} }
value: {
type: 'aiagent',
tools: [],
input_transforms: {
provider: { type: 'static', value: undefined },
output_type: { type: 'static', value: 'text' },
user_message: { type: 'static', value: undefined }
}
}
}
const flowModuleState = await loadFlowModuleState(aiAgentFlowModules)
@@ -5,7 +5,6 @@
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import { cleanFlow } from '../utils.svelte'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
interface Props {
drawer: Drawer | undefined
@@ -15,7 +14,7 @@
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let flow = $derived(aiChatManager.flowAiChatHelpers?.getPreviewFlow() ?? flowStore.val)
let flow = $derived(flowStore.val)
</script>
<Drawer bind:this={drawer} size="800px">
@@ -8,7 +8,6 @@
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import { Play } from 'lucide-svelte'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import type { GraphModuleState } from '$lib/components/graph'
interface Props {
@@ -94,8 +93,7 @@
'Input',
'Trigger'
].includes(upToSelected) ||
upToSelected?.includes('branch') ||
aiChatManager.flowAiChatHelpers?.getModuleAction(upToSelected) === 'removed'
upToSelected?.includes('branch')
)
})
@@ -0,0 +1,64 @@
<script lang="ts">
import { DiffIcon, Check, X } from 'lucide-svelte'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import type { FlowDiffManager } from '../flowDiffManager.svelte'
import type { StateStore } from '$lib/utils'
import type { OpenFlow } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
interface Props {
moduleId: string
moduleAction: ModuleActionInfo | undefined
diffManager: FlowDiffManager | undefined
flowStore: StateStore<OpenFlow> | undefined
placement?: 'top' | 'bottom'
}
let { moduleId, moduleAction, diffManager, flowStore, placement = 'top' }: Props = $props()
</script>
{#if moduleAction && diffManager}
<div
class={twMerge(
'absolute right-0 left-0 flex flex-row z-20',
placement === 'top' ? 'top-0 -translate-y-full' : 'bottom-0 translate-y-full',
moduleAction.action === 'modified' ? 'justify-between' : 'justify-end'
)}
>
{#if moduleAction?.action === 'modified' && diffManager.beforeFlow}
<button
class="p-1 bg-surface hover:bg-surface-hover rounded-t-md text-3xs font-normal flex flex-row items-center gap-1 text-orange-800 dark:text-orange-400"
onclick={() => {
diffManager?.showModuleDiff(moduleId)
}}
>
<DiffIcon size={14} /> Diff
</button>
{/if}
{#if moduleAction?.pending}
<div
class={twMerge(
'flex flex-row bg-surface overflow-hidden',
placement === 'top' ? 'rounded-t-md' : 'rounded-b-md'
)}
>
<button
class="p-1 bg-green-500 text-white hover:bg-green-600 text-3xs font-normal flex flex-row items-center gap-1"
onclick={() => {
if (flowStore) diffManager?.acceptModule(moduleId, flowStore)
}}
>
<Check size={14} /> Accept
</button>
<button
class="p-1 hover:bg-red-500 hover:text-white text-3xs font-normal flex flex-row items-center gap-1"
onclick={() => {
if (flowStore) diffManager?.rejectModule(moduleId, flowStore)
}}
>
<X size={14} /> Reject
</button>
</div>
{/if}
</div>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import type { FlowDiffManager } from '../flowDiffManager.svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { Bug, X } from 'lucide-svelte'
import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte'
@@ -7,22 +8,18 @@
import type { RawScript, ScriptLang } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import ModuleAcceptReject, {
getAiModuleAction
} from '$lib/components/copilot/chat/flow/ModuleAcceptReject.svelte'
import {
aiModuleActionToBgColor,
aiModuleActionToBorderColor,
aiModuleActionToTextColor
} from '$lib/components/copilot/chat/flow/utils'
import Button from '$lib/components/common/button/Button.svelte'
import DiffActionBar from './DiffActionBar.svelte'
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
let {
disableAi,
small
small,
diffManager
}: {
small: boolean
disableAi?: boolean
diffManager?: FlowDiffManager
} = $props()
const dispatch = createEventDispatcher<{
@@ -32,6 +29,14 @@
const { selectionManager, flowStateStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const failureModuleId = $derived(flowStore.val?.value?.failure_module?.id)
const moduleAction = $derived(
failureModuleId ? diffManager?.moduleActions?.[failureModuleId] : undefined
)
const aiColorClasses = $derived(
moduleAction ? getNodeColorClasses(aiActionToNodeState(moduleAction.action), false) : undefined
)
async function insertFailureModule(
inlineScript?: {
language: RawScript['language']
@@ -53,42 +58,43 @@
selectionManager.selectId('failure')
refreshStateStore(flowStore)
}
const action = $derived(getAiModuleAction('failure'))
</script>
{#if flowStore.val?.value?.failure_module}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Button
variant="default"
unifiedSize="sm"
wrapperClasses={twMerge('min-w-36', small ? 'max-w-52' : 'max-w-64')}
btnClasses={twMerge(
aiModuleActionToBgColor(action),
aiModuleActionToBorderColor(action),
aiModuleActionToTextColor(action)
)}
id="flow-editor-error-handler"
selected={selectionManager.getSelectedId()?.includes('failure')}
onClick={() => {
if (flowStore.val?.value?.failure_module) {
selectionManager.selectId('failure')
}
}}
>
<ModuleAcceptReject id="failure" {action} placement="bottom" />
<div class="relative">
<Button
variant="default"
unifiedSize="sm"
wrapperClasses={twMerge('min-w-36', small ? 'max-w-52' : 'max-w-64')}
id="flow-editor-error-handler"
selected={selectionManager.getSelectedId()?.includes('failure')}
onClick={() => {
if (flowStore.val?.value?.failure_module) {
selectionManager.selectId('failure')
}
}}
btnClasses={aiColorClasses?.bg ?? ''}
>
{#if failureModuleId}
<DiffActionBar
moduleId={failureModuleId}
{moduleAction}
{diffManager}
{flowStore}
placement="bottom"
/>
{/if}
<Bug size={14} class="shrink-0" />
<Bug size={14} class="shrink-0" />
<div class="truncate grow min-w-0 text-center text-xs">
{flowStore.val.value.failure_module?.summary ||
(flowStore.val.value.failure_module?.value.type === 'rawscript'
? `${flowStore.val.value.failure_module?.value.language}`
: 'TBD')}
</div>
<div class="truncate grow min-w-0 text-center text-xs">
{flowStore.val.value.failure_module?.summary ||
(flowStore.val.value.failure_module?.value.type === 'rawscript'
? `${flowStore.val.value.failure_module?.value.language}`
: 'TBD')}
</div>
{#if !action}
<button
title="Delete failure script"
type="button"
@@ -100,8 +106,8 @@
>
<X size={12} />
</button>
{/if}
</Button>
</Button>
</div>
{:else}
<!-- Index 0 is used by the tutorial to identify the first "Add step" -->
<InsertModulePopover
@@ -18,7 +18,6 @@
Loader2,
TriangleAlert,
Timer,
DiffIcon,
Maximize2
} from 'lucide-svelte'
import { createEventDispatcher, getContext } from 'svelte'
@@ -37,22 +36,23 @@
import OutputPicker from '$lib/components/flows/propPicker/OutputPicker.svelte'
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import type { FlowState } from '$lib/components/flows/flowState'
import ModuleAcceptReject, {
getAiModuleAction
} from '$lib/components/copilot/chat/flow/ModuleAcceptReject.svelte'
import { Button } from '$lib/components/common'
import ModuleTest from '$lib/components/ModuleTest.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import { aiModuleActionToBgColor } from '$lib/components/copilot/chat/flow/utils'
import type { Job } from '$lib/gen'
import { getNodeColorClasses, type FlowNodeState } from '$lib/components/graph'
import type { AIModuleAction } from '$lib/components/copilot/chat/flow/core'
import {
getNodeColorClasses,
aiActionToNodeState,
type FlowNodeState
} from '$lib/components/graph'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import DiffActionBar from './DiffActionBar.svelte'
import { getGraphContext } from '$lib/components/graph/graphContext'
interface Props {
selected?: boolean
deletable?: boolean
moduleAction: AIModuleAction | undefined
onShowModuleDiff?: (moduleId: string) => void
moduleAction: ModuleActionInfo | undefined
retry?: boolean
cache?: boolean
earlyStop?: boolean
@@ -95,7 +95,6 @@
selected = false,
deletable = false,
moduleAction = undefined,
onShowModuleDiff = undefined,
retry = false,
cache = false,
earlyStop = false,
@@ -127,12 +126,18 @@
maximizeSubflow = undefined
}: Props = $props()
let colorClasses = $derived(getNodeColorClasses(nodeState, selected))
let pickableIds: Record<string, any> | undefined = $state(undefined)
// Execution state takes priority over AI action colors
let effectiveState = $derived(nodeState ?? aiActionToNodeState(moduleAction?.action))
let colorClasses = $derived(getNodeColorClasses(effectiveState, selected))
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const flowInputsStore = flowEditorContext?.flowInputsStore
const flowStore = flowEditorContext?.flowStore
const flowGraphContext = getGraphContext()
const diffManager = flowGraphContext?.diffManager
let pickableIds: Record<string, any> | undefined = $state(undefined)
const dispatch = createEventDispatcher()
@@ -186,8 +191,6 @@
const icon_render = $derived(icon)
const action = $derived(getAiModuleAction(id))
let testRunDropdownOpen = $state(false)
let outputPickerInner: OutputPickerInner | undefined = $state(undefined)
@@ -252,9 +255,9 @@
</Drawer>
{/if}
{#if deletable && id && flowEditorContext?.flowStore && outputPickerVisible}
{@const flowStore = flowEditorContext?.flowStore.val}
{@const mod = flowStore?.value ? dfsPreviousResults(id, flowStore, false)[0] : undefined}
{#if deletable && id && flowStore && outputPickerVisible}
{@const flowStoreVal = flowStore.val}
{@const mod = flowStoreVal?.value ? dfsPreviousResults(id, flowStoreVal, false)[0] : undefined}
{#if mod && flowStateStore?.val?.[id]}
<ModuleTest
bind:this={moduleTest}
@@ -269,13 +272,11 @@
{/if}
<div class="relative">
<!-- TODO: Use existing function to get module color classes instead of using aiModuleActionToBgColor -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-md cursor-pointer max-w-full drop-shadow-base',
deletable || moduleAction ? aiModuleActionToBgColor(moduleAction ?? action) : '',
colorClasses.bg
)}
style="width: 275px; height: 34px;"
@@ -283,21 +284,8 @@
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault((e) => dispatch('pointerdown', e)))}
>
{#if deletable}
<ModuleAcceptReject action={moduleAction ?? action} {id} />
{/if}
{#if moduleAction === 'modified' && onShowModuleDiff && id}
<div class="absolute right-0 left-0 top-0 -translate-y-full flex justify-start z-50">
<Button
class="p-1 bg-surface hover:bg-surface-hover rounded-t-md text-3xs font-normal flex flex-row items-center gap-1 text-orange-800 dark:text-orange-400"
onClick={() => {
onShowModuleDiff?.(id)
}}
startIcon={{ icon: DiffIcon }}
>
Diff
</Button>
</div>
{#if id}
<DiffActionBar moduleId={id} {moduleAction} {diffManager} {flowStore} />
{/if}
<div
class={classNames('absolute z-0 rounded-md outline-offset-0', colorClasses.outline)}
@@ -432,14 +420,12 @@
{/if}
</div>
<div
class={twMerge('flex flex-col w-full', deletable && action === 'removed' ? 'opacity-50' : '')}
>
<div class="flex flex-col w-full">
<FlowModuleSchemaItemViewer
{label}
{path}
{id}
deletable={deletable && !action}
{deletable}
{bold}
bind:editId
{hover}
@@ -488,7 +474,7 @@
{/if}
</div>
{#if deletable && !action}
{#if deletable}
{#if maximizeSubflow !== undefined}
{@render buttonMaximizeSubflow?.()}
{/if}
@@ -291,10 +291,15 @@
let graph: FlowGraphV2 | undefined = $state(undefined)
let noteMode = $state(false)
let diffManager = $derived(getDiffManager())
export function isNodeVisible(nodeId: string): boolean {
return graph?.isNodeVisible(nodeId) ?? false
}
export function getDiffManager() {
return graph?.getDiffManager()
}
export function enableNotes(): void {
graph?.enableNotes?.()
}
@@ -403,6 +408,7 @@
{toggleAiChat}
{noteMode}
{toggleNoteMode}
{diffManager}
/>
</div>
@@ -424,6 +430,8 @@
{noteMode}
notes={flowStore.val.value.notes}
preprocessorModule={flowStore.val.value?.preprocessor_module}
failureModule={flowStore.val.value?.failure_module}
currentInputSchema={flowStore.val.schema}
{selectionManager}
{workspace}
editMode
@@ -1,5 +1,6 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import type { FlowDiffManager } from '../flowDiffManager.svelte'
import { getContext } from 'svelte'
import { Badge } from '$lib/components/common'
import { DollarSign, Settings, StickyNote } from 'lucide-svelte'
@@ -18,6 +19,7 @@
noteMode?: boolean
toggleNoteMode?: () => void
disableAi?: boolean
diffManager?: FlowDiffManager
}
let {
@@ -29,7 +31,8 @@
toggleAiChat,
noteMode,
toggleNoteMode,
disableAi
disableAi,
diffManager
}: Props = $props()
const { selectionManager, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -54,7 +57,7 @@
</Button>
{/if}
<Popover>
<FlowErrorHandlerItem {disableAi} small={smallErrorHandler} on:generateStep />
<FlowErrorHandlerItem {disableAi} small={smallErrorHandler} {diffManager} on:generateStep />
{#snippet text()}
Error Handler
{/snippet}
@@ -15,15 +15,14 @@
import type { FlowEditorContext } from '$lib/components/flows/types'
import { twMerge } from 'tailwind-merge'
import type { FlowNodeState } from '$lib/components/graph'
import type { AIModuleAction } from '$lib/components/copilot/chat/flow/core'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import { getGraphContext } from '$lib/components/graph/graphContext'
interface Props {
moduleId: string
mod: FlowModule
insertable: boolean
moduleAction: AIModuleAction | undefined
onShowModuleDiff?: (moduleId: string) => void
moduleAction: ModuleActionInfo | undefined
annotation?: string | undefined
nodeState?: FlowNodeState
moving?: string | undefined
@@ -57,7 +56,6 @@
mod = $bindable(),
insertable,
moduleAction = undefined,
onShowModuleDiff = undefined,
annotation = undefined,
nodeState,
moving = undefined,
@@ -76,7 +74,8 @@
const { selectionManager } = getGraphContext()
const { flowStore } = getContext<FlowEditorContext | undefined>('FlowEditorContext') || {}
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const { flowStore } = flowEditorContext || {}
const dispatch = createEventDispatcher<{
delete: CustomEvent<MouseEvent>
@@ -122,7 +121,7 @@
{#if duration_ms}
<div
class={twMerge(
'absolute z-10 right-0 -top-4 center-center text-primary text-2xs',
'absolute z-5 right-0 -top-4 center-center text-primary text-2xs',
editMode ? 'text-gray-400 dark:text-gray-500 text-2xs font-normal mr-2 right-16' : ''
)}
>
@@ -159,7 +158,6 @@
deletable={insertable}
{editMode}
{moduleAction}
{onShowModuleDiff}
label={`${
mod.summary || (mod.value.type == 'forloopflow' ? 'For loop' : 'While loop')
} ${mod.value.parallel ? '(parallel)' : ''} ${
@@ -194,7 +192,6 @@
deletable={insertable}
{editMode}
{moduleAction}
{onShowModuleDiff}
on:changeId
on:delete
on:move
@@ -214,7 +211,6 @@
deletable={insertable}
{editMode}
{moduleAction}
{onShowModuleDiff}
on:changeId
on:delete
on:move
@@ -234,7 +230,6 @@
{retries}
{editMode}
{moduleAction}
{onShowModuleDiff}
on:changeId
on:pointerdown={handlePointerDown}
on:delete
@@ -7,13 +7,9 @@
import Popover from '$lib/components/Popover.svelte'
import { fade } from 'svelte/transition'
import { Database, Square } from 'lucide-svelte'
import ModuleAcceptReject, {
getAiModuleAction
} from '$lib/components/copilot/chat/flow/ModuleAcceptReject.svelte'
import { aiModuleActionToBgColor } from '$lib/components/copilot/chat/flow/utils'
import FlowGraphPreviewButton from './FlowGraphPreviewButton.svelte'
import type { Job } from '$lib/gen'
import { getNodeColorClasses } from '$lib/components/graph'
import { getNodeColorClasses, aiActionToNodeState } from '$lib/components/graph'
interface Props {
label?: string | undefined
@@ -58,7 +54,7 @@
cache = false,
earlyStop = false,
editMode = false,
action: actionProp = undefined,
action = undefined,
icon,
onUpdateMock,
onEditInput,
@@ -77,7 +73,6 @@
(nodeKind || (inputJson && Object.keys(inputJson).length > 0)) && editMode
)
let action = $derived(actionProp ?? (label === 'Input' ? getAiModuleAction(label) : undefined))
let hoverButton = $state(false)
const outputType = $derived(
@@ -91,7 +86,9 @@
: undefined
: undefined
)
let colorClasses = $derived(getNodeColorClasses(outputType ?? '_VirtualItem', selected))
// Execution state takes priority over AI action colors, fallback to _VirtualItem
const effectiveState = $derived(outputType ?? aiActionToNodeState(action) ?? '_VirtualItem')
let colorClasses = $derived(getNodeColorClasses(effectiveState, selected))
</script>
<VirtualItemWrapper
@@ -99,14 +96,10 @@
{selectable}
{id}
outputPickerVisible={outputPickerVisible ?? false}
className={editMode ? aiModuleActionToBgColor(action) : ''}
{colorClasses}
on:select
>
{#snippet children({ hover })}
{#if editMode}
<ModuleAcceptReject id="Input" {action} />
{/if}
<div class="flex flex-col w-full">
<div
class="flex flex-row justify-between {colorClasses.outline} {center
@@ -1,7 +1,8 @@
<script lang="ts">
import { FlowService, type FlowModule, type FlowNote, type Job } from '../../gen'
import { FlowService, type FlowModule, type FlowNote, type Job, type OpenFlow } from '../../gen'
import { AI_OR_ASSET_NODE_TYPES, NODE, type GraphModuleState } from '.'
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'
@@ -51,6 +52,7 @@
import type { TriggerContext } from '../triggers'
import { workspaceStore } from '$lib/stores'
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'
@@ -69,7 +71,7 @@
import type { ModulesTestStates } from '../modulesTest.svelte'
import { deepEqual } from 'fast-equals'
import type { AssetWithAltAccessType } from '../assets/lib'
import type { AIModuleAction } from '../copilot/chat/flow/core'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
import { setGraphContext } from './graphContext'
import { computeNoteNodes } from './noteUtils.svelte'
import { Tooltip } from '../meltComponents'
@@ -81,6 +83,9 @@
const triggerContext = getContext<TriggerContext>('TriggerContext')
// Create diffManager instance for this FlowGraphV2
const diffManager = createFlowDiffManager()
let fullWidth = 0
let width = $state(0)
@@ -96,8 +101,7 @@
notSelectable?: boolean
flowModuleStates?: Record<string, GraphModuleState> | undefined
testModuleStates?: ModulesTestStates
moduleActions?: Record<string, AIModuleAction>
inputSchemaModified?: boolean
moduleActions?: Record<string, ModuleActionInfo>
selectionManager?: SelectionManager
path?: string | undefined
newFlow?: boolean
@@ -152,7 +156,6 @@
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
onShowModuleDiff?: (moduleId: string) => void
flowHasChanged?: boolean
exitNoteMode?: () => void
onNotePositionUpdate?: (noteId: string, position: { x: number; y: number }) => void
@@ -160,6 +163,10 @@
sharedViewport?: Viewport
onViewportChange?: (viewport: Viewport, isUserInitiated: boolean) => void
leftHeader?: Snippet
// Diff mode props
diffBeforeFlow?: OpenFlow
currentInputSchema?: Record<string, any>
markRemovedAsShadowed?: boolean
}
let {
@@ -182,7 +189,6 @@
flowModuleStates = undefined,
testModuleStates = undefined,
moduleActions = undefined,
inputSchemaModified = undefined,
selectionManager: selectionManagerProp = undefined,
path = undefined,
newFlow = false,
@@ -207,7 +213,6 @@
onCancelTestFlow = undefined,
onOpenPreview = undefined,
onHideJobStatus = undefined,
onShowModuleDiff = undefined,
individualStepTests = false,
flowJob = undefined,
showJobStatus = false,
@@ -221,6 +226,9 @@
sharedViewport = undefined,
onViewportChange = undefined,
leftHeader = undefined,
diffBeforeFlow = undefined,
currentInputSchema = undefined,
markRemovedAsShadowed = false,
multiSelectEnabled = false
}: Props = $props()
@@ -273,7 +281,8 @@
showAssets,
noteManager,
clearFlowSelection,
yOffset
yOffset,
diffManager
} as any)
if (triggerContext && allowSimplifiedPoll) {
@@ -294,6 +303,7 @@
if (isSimplifiable(modules)) {
triggerContext?.simplifiedPoll?.set(undefined)
}
diffManager.setDiffDrawer(undefined)
})
function onModulesChange(modules: FlowModule[]) {
@@ -454,7 +464,50 @@
}
}
let moduleTracker = new ChangeTracker($state.snapshot(modules))
// 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)
// Initialize moduleTracker with effectiveModules
let moduleTracker = $state(new ChangeTracker<FlowModule[]>([]))
let nodes = $state.raw<Node[]>([])
let edges = $state.raw<Edge[]>([])
@@ -641,6 +694,7 @@
// centerViewport(width)
// })
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
let diffDrawer: DiffDrawer | undefined = $state(undefined)
const flowGraphAssetsCtx = getContext<FlowGraphAssetContext | undefined>('FlowGraphAssetContext')
@@ -648,21 +702,26 @@
allowSimplifiedPoll && modules && untrack(() => onModulesChange(modules ?? []))
})
$effect(() => {
readFieldsRecursively(modules)
untrack(() => moduleTracker.track($state.snapshot(modules)))
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
return graphBuilder(
untrack(() => modules),
untrack(() => effectiveModules),
{
disableAi,
insertable,
flowModuleStates: untrack(() => flowModuleStates),
testModuleStates: untrack(() => testModuleStates),
moduleActions: untrack(() => moduleActions),
inputSchemaModified: untrack(() => inputSchemaModified),
moduleActions: untrack(() => effectiveModuleActions),
selectedId: untrack(() => selectedId),
path,
newFlow,
@@ -677,11 +736,10 @@
suspendStatus,
flowHasChanged,
chatInputEnabled,
onShowModuleDiff: untrack(() => onShowModuleDiff),
additionalAssetsMap: flowGraphAssetsCtx?.val.additionalAssetsMap
},
untrack(() => failureModule),
preprocessorModule,
untrack(() => effectiveFailureModule),
effectivePreprocessorModule,
eventHandler,
success,
$useDataflow,
@@ -805,6 +863,10 @@
viewportSynchronizer?.zoomOut()
}
export function getDiffManager() {
return diffManager
}
export function enableNotes() {
if (!showNotes) {
showNotes = true
@@ -817,6 +879,9 @@
{#if insertable}
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
{/if}
{#if canUseDiffDrawer}
<DiffDrawer bind:this={diffDrawer} />
{/if}
<div
style={`height: ${height}px; max-height: ${maxHeight}px;`}
class="overflow-clip relative"
@@ -7,7 +7,7 @@ import type { GraphModuleState } from './model'
import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib'
import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte'
import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte'
import { type AIModuleAction } from '../copilot/chat/flow/core'
import type { ModuleActionInfo } from '$lib/components/flows/flowDiff'
export type InsertKind =
| 'script'
@@ -128,8 +128,7 @@ export type InputN = {
showJobStatus: boolean
flowHasChanged: boolean
chatInputEnabled: boolean
inputSchemaModified?: boolean
onShowModuleDiff?: (moduleId: string) => void
moduleAction?: ModuleActionInfo
assets?: AssetWithAltAccessType[] | undefined
}
}
@@ -150,8 +149,7 @@ export type ModuleN = {
flowJob: Job | undefined
isOwner: boolean
assets: AssetWithAltAccessType[] | undefined
moduleAction: AIModuleAction | undefined
onShowModuleDiff?: (moduleId: string) => void
moduleAction: ModuleActionInfo | undefined
}
}
@@ -370,8 +368,7 @@ export function graphBuilder(
insertable: boolean
flowModuleStates: Record<string, GraphModuleState> | undefined
testModuleStates: ModulesTestStates | undefined
moduleActions?: Record<string, AIModuleAction>
inputSchemaModified?: boolean
moduleActions?: Record<string, ModuleActionInfo>
selectedId: string | undefined
path: string | undefined
newFlow: boolean
@@ -386,7 +383,6 @@ export function graphBuilder(
suspendStatus: Record<string, { job: Job; nb: number }>
flowHasChanged: boolean
chatInputEnabled: boolean
onShowModuleDiff?: (moduleId: string) => void
additionalAssetsMap?: Record<string, AssetWithAltAccessType[]>
},
failureModule: FlowModule | undefined,
@@ -445,8 +441,7 @@ export function graphBuilder(
isOwner: extra.isOwner,
flowJob: extra.flowJob,
assets: getFlowModuleAssets(module, extra.additionalAssetsMap),
moduleAction: extra.moduleActions?.[module.id],
onShowModuleDiff: extra.onShowModuleDiff
moduleAction: extra.moduleActions?.[module.id]
},
type: 'module',
selectable: true
@@ -566,8 +561,7 @@ export function graphBuilder(
showJobStatus: extra.showJobStatus,
flowHasChanged: extra.flowHasChanged,
chatInputEnabled: extra.chatInputEnabled,
inputSchemaModified: extra.inputSchemaModified,
onShowModuleDiff: extra.onShowModuleDiff,
moduleAction: extra.moduleActions?.['Input'],
...(inputAssets ? { assets: inputAssets } : {})
}
}
@@ -2,6 +2,7 @@ import { getContext, setContext } from 'svelte'
import type { SelectionManager } from './selectionUtils.svelte'
import type { NoteManager } from './noteManager.svelte'
import type { Writable } from 'svelte/store'
import type { FlowDiffManager } from '../flows/flowDiffManager.svelte'
export type GraphContext = {
selectionManager: SelectionManager
@@ -10,6 +11,7 @@ export type GraphContext = {
noteManager?: NoteManager
clearFlowSelection?: () => void
yOffset?: number
diffManager: FlowDiffManager
}
const graphContextKey = 'FlowGraphContext'
@@ -6,11 +6,11 @@
import InsertModulePopover from '$lib/components/flows/map/InsertModulePopover.svelte'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import DiffActionBar from '$lib/components/flows/map/DiffActionBar.svelte'
import { schemaToObject } from '$lib/schema'
import type { Schema } from '$lib/common'
import type { FlowEditorContext } from '$lib/components/flows/types'
import { MessageSquare, DiffIcon } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { MessageSquare } from 'lucide-svelte'
import { getGraphContext } from '../../graphContext'
import FunnelCog from '$lib/components/icons/FunnelCog.svelte'
@@ -20,10 +20,10 @@
let { data }: Props = $props()
const { selectionManager } = getGraphContext()
const { selectionManager, diffManager } = getGraphContext()
const { previewArgs, flowStore } =
getContext<FlowEditorContext | undefined>('FlowEditorContext') || {}
const flowEditorContext = getContext<FlowEditorContext | undefined>('FlowEditorContext')
const { previewArgs, flowStore } = flowEditorContext || {}
let topFlowInput = $derived(
flowStore?.val && previewArgs && flowStore?.val?.schema
@@ -34,17 +34,12 @@
let inputLabel = $derived(data.chatInputEnabled ? 'Chat message' : 'Input')
</script>
{#if data.inputSchemaModified && data.onShowModuleDiff}
<div class="absolute right-0 left-0 top-0 -translate-y-full flex justify-start z-50">
<Button
class="p-1 bg-surface hover:bg-surface-hover rounded-t-md text-3xs font-normal flex flex-row items-center gap-1 text-orange-800 dark:text-orange-400"
onClick={() => {
data.onShowModuleDiff?.('Input')
}}
startIcon={{ icon: DiffIcon }}>Diff</Button
>
</div>
{/if}
<DiffActionBar
moduleId="Input"
moduleAction={data.moduleAction}
{diffManager}
flowStore={flowEditorContext?.flowStore}
/>
<NodeWrapper>
{#snippet children({ darkMode })}
@@ -99,7 +94,7 @@
cache={data.cache}
earlyStop={data.earlyStop}
editMode={data.editMode}
action={data.inputSchemaModified ? 'modified' : undefined}
action={data.moduleAction?.action}
onEditInput={data.eventHandlers.editInput}
onTestFlow={() => {
data.eventHandlers.testFlow()
@@ -56,7 +56,6 @@
insertable={data.insertable}
editMode={data.editMode}
moduleAction={data.moduleAction}
onShowModuleDiff={data.onShowModuleDiff}
annotation={flowJobs &&
(data.module.value.type === 'forloopflow' || data.module.value.type === 'whileloopflow')
? 'Iteration: ' +
+85 -1
View File
@@ -1,4 +1,5 @@
import type { FlowStatusModule } from '$lib/gen'
import type { AIModuleAction } from '$lib/components/flows/flowDiff'
export const NODE = {
width: 275,
@@ -18,7 +19,33 @@ export type FlowNodeColorClasses = {
export const AI_OR_ASSET_NODE_TYPES = ['asset', 'assetsOverflowed', 'newAiTool', 'aiTool']
export type FlowNodeState = FlowStatusModule['type'] | '_VirtualItem' | '_Skipped' | undefined
export type FlowNodeState =
| FlowStatusModule['type']
| '_VirtualItem'
| '_Skipped'
| '_AIAdded'
| '_AIModified'
| '_AIRemoved'
| '_AIShadowed'
| undefined
/**
* Convert AI module action to FlowNodeState
*/
export function aiActionToNodeState(action: AIModuleAction): FlowNodeState {
switch (action) {
case 'added':
return '_AIAdded'
case 'modified':
return '_AIModified'
case 'removed':
return '_AIRemoved'
case 'shadowed':
return '_AIShadowed'
default:
return undefined
}
}
export function getNodeColorClasses(state: FlowNodeState, selected: boolean): FlowNodeColorClasses {
let outlined = ' outline outline-1 active:outline active:outline-1'
@@ -113,6 +140,63 @@ export function getNodeColorClasses(state: FlowNodeState, selected: boolean): Fl
badge: 'bg-purple-200 text-purple-700'
}
},
// AI Module Action states (distinct shades from execution states)
_AIAdded: {
selected: {
bg: 'bg-green-300 dark:bg-green-800',
outline: 'outline-green-600 dark:outline-green-500' + outlined,
text: 'text-green-900 dark:text-green-100',
badge: 'bg-green-200 text-green-800'
},
notSelected: {
bg: 'bg-green-300 dark:bg-green-900',
outline: '',
text: 'text-green-800 dark:text-green-200',
badge: 'bg-green-300 text-green-800'
}
},
_AIModified: {
selected: {
bg: 'bg-orange-300 dark:bg-orange-800',
outline: 'outline-orange-600' + outlined,
text: 'text-orange-900 dark:text-orange-100',
badge: 'bg-orange-200 text-orange-800'
},
notSelected: {
bg: 'bg-orange-300 dark:bg-orange-900',
outline: '',
text: 'text-orange-800 dark:text-orange-200',
badge: 'bg-orange-300 text-orange-800'
}
},
_AIRemoved: {
selected: {
bg: 'bg-red-300/50 dark:bg-red-800/50',
outline: 'outline-red-600' + outlined,
text: 'text-red-900 dark:text-red-100',
badge: 'bg-red-200 text-red-800'
},
notSelected: {
bg: 'bg-red-300/50 dark:bg-red-900/50',
outline: '',
text: 'text-red-800 dark:text-red-200',
badge: 'bg-red-300 text-red-800'
}
},
_AIShadowed: {
selected: {
bg: 'bg-gray-300/30 dark:bg-gray-600/30 opacity-50',
outline: 'outline-gray-500' + outlined,
text: 'text-gray-700 dark:text-gray-300',
badge: 'bg-gray-200 text-gray-700'
},
notSelected: {
bg: 'bg-gray-300/30 dark:bg-gray-700/30 opacity-50',
outline: '',
text: 'text-gray-600 dark:text-gray-400',
badge: 'bg-gray-300 text-gray-700'
}
},
default: defaultStyle
} as Record<
NonNullable<FlowNodeState> | 'default',
+34 -13
View File
@@ -1,3 +1,4 @@
import { playwright } from '@vitest/browser-playwright'
import { sveltekit } from '@sveltejs/kit/vite'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
@@ -32,9 +33,7 @@ const config = {
'public.windmill.xyz'
],
port: 3000,
cors: {
origin: '*'
},
cors: { origin: '*' },
proxy: {
'^/api/w/[^/]+/s3_proxy/.*': {
target: process.env.REMOTE ?? 'https://app.windmill.dev/',
@@ -44,6 +43,7 @@ const config = {
proxy.on('proxyReq', (proxyReq, req, res) => {
// Prevent collapsing slashes during URL normalization
const originalPath = req.url
proxyReq.path = originalPath
})
}
@@ -74,13 +74,9 @@ const config = {
}
}
},
preview: {
port: 3001
},
preview: { port: 3001 },
plugins: [sveltekit(), ...(process.env.HTTPS === 'true' ? [mkcert()] : []), plugin],
define: {
__pkg__: version
},
define: { __pkg__: version },
optimizeDeps: {
include: ['highlight.js', 'highlight.js/lib/core', 'monaco-vim', 'monaco-editor-wrapper'],
exclude: [
@@ -89,9 +85,7 @@ const config = {
'vscode'
]
},
worker: {
format: 'es'
},
worker: { format: 'es' },
resolve: {
alias: {
path: 'path-browserify',
@@ -100,7 +94,34 @@ const config = {
},
dedupe: ['vscode', 'monaco-editor']
},
assetsInclude: ['**/*.wasm']
assetsInclude: ['**/*.wasm'],
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.js',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
},
{
extends: './vite.config.js',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
}
export default config
+157 -5
View File
@@ -22,56 +22,75 @@ components:
schemas:
OpenFlow:
type: object
description: Top-level flow definition containing metadata, configuration, and the flow structure
properties:
summary:
type: string
description: Short description of what this flow does
description:
type: string
description: Detailed documentation for this flow
value:
$ref: "#/components/schemas/FlowValue"
schema:
type: object
description: JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')
required:
- summary
- value
FlowValue:
type: object
description: The flow structure containing modules and optional preprocessor/failure handlers
properties:
modules:
type: array
description: Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch
items:
$ref: "#/components/schemas/FlowModule"
failure_module:
description: Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types
$ref: "#/components/schemas/FlowModule"
preprocessor_module:
description: Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results
$ref: "#/components/schemas/FlowModule"
same_worker:
type: boolean
description: If true, all steps run on the same worker for better performance
concurrent_limit:
type: number
description: Maximum number of concurrent executions of this flow
concurrency_key:
type: string
description: Expression to group concurrent executions (e.g., by user ID)
concurrency_time_window_s:
type: number
description: Time window in seconds for concurrent_limit
debounce_delay_s:
type: number
description: Delay in seconds to debounce flow executions
debounce_key:
type: string
description: Expression to group debounced executions
skip_expr:
type: string
description: JavaScript expression to conditionally skip the entire flow
cache_ttl:
type: number
description: Cache duration in seconds for flow results
cache_ignore_s3_path:
type: boolean
flow_env:
type: object
description: Environment variables available to all steps
additionalProperties:
type: string
priority:
type: number
description: Execution priority (higher numbers run first)
early_return:
type: string
description: JavaScript expression to return early from the flow
chat_input_enabled:
type: boolean
description: Whether this flow accepts chat-style input
@@ -85,28 +104,37 @@ components:
Retry:
type: object
description: Retry configuration for failed module executions
properties:
constant:
type: object
description: Retry with constant delay between attempts
properties:
attempts:
type: integer
description: Number of retry attempts
seconds:
type: integer
description: Seconds to wait between retries
exponential:
type: object
description: Retry with exponential backoff (delay doubles each time)
properties:
attempts:
type: integer
description: Number of retry attempts
multiplier:
type: integer
description: Multiplier for exponential backoff
seconds:
type: integer
minimum: 1
description: Initial delay in seconds
random_factor:
type: integer
minimum: 0
maximum: 100
description: Random jitter percentage (0-100) to avoid thundering herd
retry_if:
$ref: "#/components/schemas/RetryIf"
@@ -170,93 +198,127 @@ components:
RetryIf:
type: object
description: Conditional retry based on error or result
properties:
expr:
type: string
description: JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables
required:
- expr
StopAfterIf:
type: object
description: Early termination condition for a module
properties:
skip_if_stopped:
type: boolean
description: If true, following steps are skipped when this condition triggers
expr:
type: string
description: JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop
error_message:
type: string
description: Custom error message shown when stopping
required:
- expr
FlowModule:
type: object
description: A single step in a flow. Can be a script, subflow, loop, or branch
properties:
id:
type: string
description: Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)
value:
$ref: "#/components/schemas/FlowModuleValue"
stop_after_if:
description: Early termination condition evaluated after this step completes
$ref: "#/components/schemas/StopAfterIf"
stop_after_all_iters_if:
description: For loops only - early termination condition evaluated after all iterations complete
$ref: "#/components/schemas/StopAfterIf"
skip_if:
type: object
description: Conditionally skip this step based on previous results or flow inputs
properties:
expr:
type: string
description: JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'
required:
- expr
sleep:
description: Delay before executing this step (in seconds or as expression)
$ref: "#/components/schemas/InputTransform"
cache_ttl:
type: number
description: Cache duration in seconds for this step's results
cache_ignore_s3_path:
type: boolean
timeout:
description: Maximum execution time in seconds (static value or expression)
$ref: "#/components/schemas/InputTransform"
delete_after_use:
type: boolean
description: If true, this step's result is deleted after use to save memory
summary:
type: string
description: Short description of what this step does
mock:
type: object
description: Mock configuration for testing without executing the actual step
properties:
enabled:
type: boolean
return_value: {}
description: If true, return mock value instead of executing
return_value:
description: Value to return when mocked
suspend:
type: object
description: Configuration for approval/resume steps that wait for user input
properties:
required_events:
type: integer
description: Number of approvals required before continuing
timeout:
type: integer
description: Timeout in seconds before auto-continuing or canceling
resume_form:
type: object
description: Form schema for collecting input when resuming
properties:
schema:
type: object
description: JSON Schema for the resume form
user_auth_required:
type: boolean
description: If true, only authenticated users can approve
user_groups_required:
description: Expression or list of groups that can approve
$ref: "#/components/schemas/InputTransform"
self_approval_disabled:
type: boolean
description: If true, the user who started the flow cannot approve
hide_cancel:
type: boolean
description: If true, hide the cancel button on the approval form
continue_on_disapprove_timeout:
type: boolean
description: If true, continue flow on timeout instead of canceling
priority:
type: number
description: Execution priority for this step (higher numbers run first)
continue_on_error:
type: boolean
description: If true, flow continues even if this step fails
retry:
description: Retry configuration if this step fails
$ref: "#/components/schemas/Retry"
required:
- value
- id
InputTransform:
description: Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs
oneOf:
- $ref: "#/components/schemas/StaticTransform"
- $ref: "#/components/schemas/JavascriptTransform"
@@ -268,8 +330,10 @@ components:
StaticTransform:
type: object
description: Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'
properties:
value: {}
value:
description: The static value. For resources, use format '$res:path/to/resource'
type:
type: string
enum:
@@ -279,9 +343,11 @@ components:
JavascriptTransform:
type: object
description: JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value
properties:
expr:
type: string
description: JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)
type:
type: string
enum:
@@ -291,6 +357,7 @@ components:
- type
FlowModuleValue:
description: The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type
oneOf:
- $ref: "#/components/schemas/RawScript"
- $ref: "#/components/schemas/PathScript"
@@ -316,16 +383,20 @@ components:
RawScript:
type: object
description: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms
properties:
# to be made required once migration is over
input_transforms:
type: object
description: Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments
additionalProperties:
$ref: "#/components/schemas/InputTransform"
content:
type: string
description: The script source code. Should export a 'main' function
language:
type: string
description: Programming language for this script
enum:
- deno
- bun
@@ -345,24 +416,32 @@ components:
# TODO: Add missing languages
path:
type: string
description: Optional path for saving this script
lock:
type: string
description: Lock file content for dependencies
type:
type: string
enum:
- rawscript
tag:
type: string
description: Worker group tag for execution routing
concurrent_limit:
type: number
description: Maximum concurrent executions of this script
concurrency_time_window_s:
type: number
description: Time window for concurrent_limit
custom_concurrency_key:
type: string
description: Custom key for grouping concurrent executions
is_trigger:
type: boolean
description: If true, this script is a trigger that can start the flow
assets:
type: array
description: External resources this script accesses (S3 objects, resources, etc.)
items:
type: object
required:
@@ -371,8 +450,10 @@ components:
properties:
path:
type: string
description: Path to the asset
kind:
type: string
description: Type of asset
enum:
- s3object
- resource
@@ -380,9 +461,11 @@ components:
- datatable
access_type:
type: string
description: Access level for this asset
enum: [r, w, rw]
alt_access_type:
type: string
description: Alternative access level
enum: [r, w, rw]
required:
- type
@@ -392,23 +475,29 @@ components:
PathScript:
type: object
description: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code
properties:
input_transforms:
type: object
description: Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments
additionalProperties:
$ref: "#/components/schemas/InputTransform"
path:
type: string
description: Path to the script in the workspace (e.g., 'f/scripts/send_email')
hash:
type: string
description: Optional specific version hash of the script to use
type:
type: string
enum:
- script
tag_override:
type: string
description: Override the script's default worker group tag
is_trigger:
type: boolean
description: If true, this script is a trigger that can start the flow
required:
- type
- path
@@ -416,13 +505,16 @@ components:
PathFlow:
type: object
description: Reference to an existing flow by path. Use this to call another flow as a subflow
properties:
input_transforms:
type: object
description: Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments
additionalProperties:
$ref: "#/components/schemas/InputTransform"
path:
type: string
description: Path to the flow in the workspace (e.g., 'f/flows/process_user')
type:
type: string
enum:
@@ -434,22 +526,28 @@ components:
ForloopFlow:
type: object
description: Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations
properties:
modules:
type: array
description: Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'
items:
$ref: "#/components/schemas/FlowModule"
iterator:
description: JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'
$ref: "#/components/schemas/InputTransform"
skip_failures:
type: boolean
description: If true, iteration failures don't stop the loop. Failed iterations return null
type:
type: string
enum:
- forloopflow
parallel:
type: boolean
description: If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency
parallelism:
description: Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression
$ref: "#/components/schemas/InputTransform"
squash:
type: boolean
@@ -461,20 +559,25 @@ components:
WhileloopFlow:
type: object
description: Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination
properties:
modules:
type: array
description: Steps to execute in each iteration. Use stop_after_if to control when the loop ends
items:
$ref: "#/components/schemas/FlowModule"
skip_failures:
type: boolean
description: If true, iteration failures don't stop the loop. Failed iterations return null
type:
type: string
enum:
- whileloopflow
parallel:
type: boolean
description: If true, iterations run concurrently (use with caution in while loops)
parallelism:
description: Maximum number of concurrent iterations when parallel=true
$ref: "#/components/schemas/InputTransform"
squash:
type: boolean
@@ -485,18 +588,23 @@ components:
BranchOne:
type: object
description: Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes
properties:
branches:
type: array
description: Array of branches to evaluate in order. The first branch with expr evaluating to true executes
items:
type: object
properties:
summary:
type: string
description: Short description of this branch condition
expr:
type: string
description: JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins
modules:
type: array
description: Steps to execute if this branch's expr is true
items:
$ref: "#/components/schemas/FlowModule"
required:
@@ -504,9 +612,9 @@ components:
- expr
default:
type: array
description: Steps to execute if no branch expressions match
items:
$ref: "#/components/schemas/FlowModule"
required: [modules]
type:
type: string
enum:
@@ -518,18 +626,23 @@ components:
BranchAll:
type: object
description: Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently
properties:
branches:
type: array
description: Array of branches that all execute (either in parallel or sequentially)
items:
type: object
properties:
summary:
type: string
description: Short description of this branch's purpose
skip_failure:
type: boolean
description: If true, failure in this branch doesn't fail the entire flow
modules:
type: array
description: Steps to execute in this branch
items:
$ref: "#/components/schemas/FlowModule"
required:
@@ -540,17 +653,21 @@ components:
- branchall
parallel:
type: boolean
description: If true, all branches execute concurrently. If false, they execute sequentially
required:
- branches
- type
AgentTool:
type: object
description: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool
properties:
id:
type: string
description: Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')
summary:
type: string
description: Short description of what this tool does (shown to the AI)
value:
$ref: "#/components/schemas/ToolValue"
required:
@@ -558,6 +675,7 @@ components:
- value
ToolValue:
description: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference
oneOf:
- $ref: "#/components/schemas/FlowModuleTool"
- $ref: "#/components/schemas/McpToolValue"
@@ -568,6 +686,7 @@ components:
mcp: "#/components/schemas/McpToolValue"
FlowModuleTool:
description: A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module
allOf:
- type: object
properties:
@@ -581,6 +700,7 @@ components:
McpToolValue:
type: object
description: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers
properties:
tool_type:
type: string
@@ -588,12 +708,15 @@ components:
- mcp
resource_path:
type: string
description: Path to the MCP resource/server configuration
include_tools:
type: array
description: Whitelist of specific tools to include from this MCP server
items:
type: string
exclude_tools:
type: array
description: Blacklist of tools to exclude from this MCP server
items:
type: string
required:
@@ -602,13 +725,39 @@ components:
AiAgent:
type: object
description: AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task
properties:
input_transforms:
type: object
additionalProperties:
$ref: "#/components/schemas/InputTransform"
description: Input parameters for the AI agent mapped to their values
properties:
provider:
$ref: "#/components/schemas/InputTransform"
output_type:
$ref: "#/components/schemas/InputTransform"
user_message:
$ref: "#/components/schemas/InputTransform"
system_prompt:
$ref: "#/components/schemas/InputTransform"
streaming:
$ref: "#/components/schemas/InputTransform"
messages_context_length:
$ref: "#/components/schemas/InputTransform"
output_schema:
$ref: "#/components/schemas/InputTransform"
user_images:
$ref: "#/components/schemas/InputTransform"
max_completion_tokens:
$ref: "#/components/schemas/InputTransform"
temperature:
$ref: "#/components/schemas/InputTransform"
required:
- provider
- user_message
- output_type
tools:
type: array
description: Array of tools the agent can use. The agent decides which tools to call based on the task
items:
$ref: "#/components/schemas/AgentTool"
type:
@@ -617,6 +766,7 @@ components:
- aiagent
parallel:
type: boolean
description: If true, the agent can execute multiple tool calls in parallel
required:
- tools
- type
@@ -624,6 +774,7 @@ components:
Identity:
type: object
description: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder
properties:
type:
type: string
@@ -631,6 +782,7 @@ components:
- identity
flow:
type: boolean
description: If true, marks this as a flow identity (special handling)
required:
- type