You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

2164 lines
70 KiB

import { create, StateCreator } from "zustand";
import { useShallow } from "zustand/shallow";
import { temporal, TemporalState } from "zundo";
import {
Connection,
EdgeChange,
NodeChange,
addEdge,
applyNodeChanges,
applyEdgeChanges,
XYPosition,
} from "@xyflow/react";
import {
WorkflowNode,
WorkflowEdge,
NodeType,
NanoBananaNodeData,
OutputGalleryNodeData,
WorkflowNodeData,
ImageHistoryItem,
NodeGroup,
GroupColor,
ProviderType,
ProviderSettings,
RecentModel,
CanvasNavigationSettings,
} from "@/types";
import { useToast } from "@/components/Toast";
import { logger } from "@/utils/logger";
import { externalizeWorkflowImages, hydrateWorkflowImages } from "@/utils/imageStorage";
import { EditOperation, applyEditOperations as executeEditOps } from "@/lib/chat/editOperations";
import {
loadSaveConfigs,
saveSaveConfig,
loadWorkflowCostData,
saveWorkflowCostData,
getProviderSettings,
saveProviderSettings,
defaultProviderSettings,
getRecentModels,
saveRecentModels,
MAX_RECENT_MODELS,
generateWorkflowId,
getCanvasNavigationSettings,
saveCanvasNavigationSettings,
} from "./utils/localStorage";
import { partializeForUndo, undoStateEquality } from "./undoUtils";
import {
createDefaultNodeData,
defaultNodeDimensions,
GROUP_COLORS,
GROUP_COLOR_ORDER,
} from "./utils/nodeDefaults";
import {
CONCURRENCY_SETTINGS_KEY,
loadConcurrencySetting,
saveConcurrencySetting,
groupNodesByLevel,
chunk,
clearNodeImageRefs,
} from "./utils/executionUtils";
import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs";
import {
executeAnnotation,
executeArray,
executePrompt,
executePromptConstructor,
executeOutput,
executeOutputGallery,
executeImageCompare,
executeNanoBanana,
executeGenerateVideo,
executeGenerate3D,
executeGenerateAudio,
executeLlmGenerate,
executeSplitGrid,
executeVideoStitch,
executeEaseCurve,
executeVideoTrim,
executeVideoFrameGrab,
executeGlbViewer,
} from "./execution";
import type { NodeExecutionContext } from "./execution";
export type { LevelGroup } from "./utils/executionUtils";
export { CONCURRENCY_SETTINGS_KEY } from "./utils/executionUtils";
function saveLogSession(): void {
const session = logger.getCurrentSession();
if (session) {
session.endTime = new Date().toISOString();
fetch('/api/logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session }),
}).catch((err) => {
console.error('Failed to save log session:', err);
});
}
}
export type EdgeStyle = "angular" | "curved";
function buildConnectionEdgeData(
connection: Connection,
nodes: WorkflowNode[],
edges: WorkflowEdge[]
): Record<string, unknown> {
const baseData: Record<string, unknown> = { createdAt: Date.now() };
const sourceNode = nodes.find((n) => n.id === connection.source);
// Array node uses a single output handle; assign each edge a stable item index.
if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") {
const sourceData = sourceNode.data as Record<string, unknown>;
const selectedIndex = sourceData.selectedOutputIndex;
const outputItems = Array.isArray(sourceData.outputItems) ? sourceData.outputItems : [];
const outputCount = outputItems.length;
if (
typeof selectedIndex === "number" &&
Number.isInteger(selectedIndex) &&
selectedIndex >= 0 &&
(outputCount === 0 || selectedIndex < outputCount)
) {
baseData.arrayItemIndex = selectedIndex;
return baseData;
}
if (outputCount > 0) {
const existingArrayEdges = edges.filter(
(e) => e.source === connection.source && (e.sourceHandle || "text") === "text"
);
const lastEdge = existingArrayEdges.reduce<WorkflowEdge | null>((latest, edge) => {
if (!latest) return edge;
const latestTime = (latest.data as Record<string, unknown> | undefined)?.createdAt;
const edgeTime = (edge.data as Record<string, unknown> | undefined)?.createdAt;
return (typeof edgeTime === "number" && typeof latestTime === "number" && edgeTime > latestTime) ? edge : latest;
}, null);
const lastIndex = (lastEdge?.data as Record<string, unknown> | undefined)?.arrayItemIndex;
const startIndex = typeof lastIndex === "number" && Number.isInteger(lastIndex) && lastIndex >= 0
? lastIndex + 1
: existingArrayEdges.length;
baseData.arrayItemIndex = startIndex % outputCount;
} else {
baseData.arrayItemIndex = 0;
}
}
return baseData;
}
// Workflow file format
export interface WorkflowFile {
version: 1;
id?: string; // Optional for backward compatibility with old/shared workflows
name: string;
directoryPath?: string; // Embedded save path so image hydration works on import
nodes: WorkflowNode[];
edges: WorkflowEdge[];
edgeStyle: EdgeStyle;
groups?: Record<string, NodeGroup>; // Optional for backward compatibility
}
// Clipboard data structure for copy/paste
interface ClipboardData {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
interface WorkflowStore {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
edgeStyle: EdgeStyle;
clipboard: ClipboardData | null;
groups: Record<string, NodeGroup>;
// Settings
setEdgeStyle: (style: EdgeStyle) => void;
// Node operations
addNode: (type: NodeType, position: XYPosition, initialData?: Partial<WorkflowNodeData>) => string;
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void;
removeNode: (nodeId: string) => void;
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => void;
// Edge operations
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => void;
onConnect: (connection: Connection, edgeDataOverrides?: Record<string, unknown>) => void;
addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => void;
removeEdge: (edgeId: string) => void;
toggleEdgePause: (edgeId: string) => void;
// Copy/Paste operations
copySelectedNodes: () => void;
pasteNodes: (offset?: XYPosition) => void;
clearClipboard: () => void;
// Group operations
createGroup: (nodeIds: string[]) => string;
deleteGroup: (groupId: string) => void;
addNodesToGroup: (nodeIds: string[], groupId: string) => void;
removeNodesFromGroup: (nodeIds: string[]) => void;
updateGroup: (groupId: string, updates: Partial<NodeGroup>) => void;
toggleGroupLock: (groupId: string) => void;
moveGroupNodes: (groupId: string, delta: { x: number; y: number }) => void;
setNodeGroupId: (nodeId: string, groupId: string | undefined) => void;
// UI State
openModalCount: number;
isModalOpen: boolean;
showQuickstart: boolean;
incrementModalCount: () => void;
decrementModalCount: () => void;
setShowQuickstart: (show: boolean) => void;
// Execution
isRunning: boolean;
currentNodeIds: string[]; // Changed from currentNodeId for parallel execution
pausedAtNodeId: string | null;
maxConcurrentCalls: number; // Configurable concurrency limit (1-10)
_abortController: AbortController | null; // Internal: for cancellation
_buildExecutionContext: (node: WorkflowNode, signal?: AbortSignal) => NodeExecutionContext;
executeWorkflow: (startFromNodeId?: string) => Promise<void>;
regenerateNode: (nodeId: string) => Promise<void>;
executeSelectedNodes: (nodeIds: string[]) => Promise<void>;
stopWorkflow: () => void;
setMaxConcurrentCalls: (value: number) => void;
// Save/Load
saveWorkflow: (name?: string) => void;
loadWorkflow: (workflow: WorkflowFile, workflowPath?: string, options?: { preserveSnapshot?: boolean }) => Promise<void>;
clearWorkflow: () => void;
// Helpers
getNodeById: (id: string) => WorkflowNode | undefined;
getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; model3d: string | null; text: string | null; dynamicInputs: Record<string, string | string[]>; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null } | null };
validateWorkflow: () => { valid: boolean; errors: string[] };
// Global Image History
globalImageHistory: ImageHistoryItem[];
addToGlobalHistory: (item: Omit<ImageHistoryItem, "id">) => void;
clearGlobalHistory: () => void;
// Auto-save state
workflowId: string | null;
workflowName: string | null;
saveDirectoryPath: string | null;
generationsPath: string | null;
lastSavedAt: number | null;
hasUnsavedChanges: boolean;
autoSaveEnabled: boolean;
isSaving: boolean;
useExternalImageStorage: boolean; // Store images as separate files vs embedded base64
imageRefBasePath: string | null; // Directory from which current imageRefs are valid
// Auto-save actions
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath?: string | null) => void;
setWorkflowName: (name: string) => void;
setGenerationsPath: (path: string | null) => void;
setAutoSaveEnabled: (enabled: boolean) => void;
setUseExternalImageStorage: (enabled: boolean) => void;
markAsUnsaved: () => void;
saveToFile: () => Promise<boolean>;
saveAsFile: (name: string) => Promise<boolean>;
initializeAutoSave: () => void;
cleanupAutoSave: () => void;
// Cost tracking state
incurredCost: number;
// Cost tracking actions
addIncurredCost: (cost: number) => void;
resetIncurredCost: () => void;
loadIncurredCost: (workflowId: string) => void;
saveIncurredCost: () => void;
// Provider settings state
providerSettings: ProviderSettings;
// Provider settings actions
updateProviderSettings: (settings: ProviderSettings) => void;
updateProviderApiKey: (providerId: ProviderType, apiKey: string | null) => void;
toggleProvider: (providerId: ProviderType, enabled: boolean) => void;
// Model search dialog state
modelSearchOpen: boolean;
modelSearchProvider: ProviderType | null;
// Keyboard shortcuts dialog state
shortcutsDialogOpen: boolean;
setShortcutsDialogOpen: (open: boolean) => void;
// Model search dialog actions
setModelSearchOpen: (open: boolean, provider?: ProviderType | null) => void;
// Recent models state
recentModels: RecentModel[];
// Recent models actions
trackModelUsage: (model: { provider: ProviderType; modelId: string; displayName: string }) => void;
// Comment navigation state
viewedCommentNodeIds: Set<string>;
navigationTarget: { nodeId: string; timestamp: number } | null;
focusedCommentNodeId: string | null;
// Comment navigation actions
getNodesWithComments: () => WorkflowNode[];
getUnviewedCommentCount: () => number;
markCommentViewed: (nodeId: string) => void;
setNavigationTarget: (nodeId: string | null) => void;
setFocusedCommentNodeId: (nodeId: string | null) => void;
resetViewedComments: () => void;
// AI change snapshot state
previousWorkflowSnapshot: {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
groups: Record<string, NodeGroup>;
edgeStyle: EdgeStyle;
} | null;
manualChangeCount: number;
// AI change snapshot actions
captureSnapshot: () => void;
revertToSnapshot: () => void;
clearSnapshot: () => void;
incrementManualChangeCount: () => void;
applyEditOperations: (operations: EditOperation[]) => { applied: number; skipped: string[] };
// Canvas navigation settings state
canvasNavigationSettings: CanvasNavigationSettings;
// Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => void;
// Undo/Redo helper actions
_nudgeForSnapshot: () => void;
}
// Extend with TemporalState for undo/redo functionality
export type WorkflowStoreWithTemporal = WorkflowStore & TemporalState<WorkflowStore>;
let nodeIdCounter = 0;
let groupIdCounter = 0;
let autoSaveIntervalId: ReturnType<typeof setInterval> | null = null;
// Track pending save-generation syncs to ensure IDs are resolved before workflow save
const pendingImageSyncs = new Map<string, Promise<void>>();
// Wait for all pending image syncs to complete (with timeout to prevent infinite hangs)
async function waitForPendingImageSyncs(timeout: number = 60000): Promise<void> {
if (pendingImageSyncs.size === 0) return;
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(() => {
console.warn(`Pending image syncs timed out after ${timeout}ms, continuing with save`);
resolve();
}, timeout);
});
try {
await Promise.race([
Promise.all(pendingImageSyncs.values()),
timeoutPromise,
]);
} finally {
clearTimeout(timeoutId!);
}
}
// Re-export for backward compatibility
export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage";
export { GROUP_COLORS } from "./utils/nodeDefaults";
// Store implementation with temporal middleware for undo/redo
const workflowStoreImpl: StateCreator<WorkflowStore, [["temporal", unknown]], []> = (set, get) => ({
nodes: [],
edges: [],
edgeStyle: "curved" as EdgeStyle,
clipboard: null,
groups: {},
openModalCount: 0,
isModalOpen: false,
showQuickstart: true,
isRunning: false,
currentNodeIds: [], // Changed from currentNodeId for parallel execution
pausedAtNodeId: null,
maxConcurrentCalls: loadConcurrencySetting(), // Default 3, configurable 1-10
_abortController: null, // Internal: for cancellation
globalImageHistory: [],
// Auto-save initial state
workflowId: null,
workflowName: null,
saveDirectoryPath: null,
generationsPath: null,
lastSavedAt: null,
hasUnsavedChanges: false,
autoSaveEnabled: true,
isSaving: false,
useExternalImageStorage: true, // Default: store images as separate files
imageRefBasePath: null, // Directory from which current imageRefs are valid
// Cost tracking initial state
incurredCost: 0,
// Provider settings initial state
providerSettings: getProviderSettings(),
// Model search dialog initial state
modelSearchOpen: false,
modelSearchProvider: null,
// Keyboard shortcuts dialog initial state
shortcutsDialogOpen: false,
// Recent models initial state
recentModels: getRecentModels(),
// Comment navigation initial state
viewedCommentNodeIds: new Set<string>(),
navigationTarget: null,
focusedCommentNodeId: null,
// AI change snapshot initial state
previousWorkflowSnapshot: null,
manualChangeCount: 0,
// Canvas navigation settings initial state
canvasNavigationSettings: getCanvasNavigationSettings(),
setEdgeStyle: (style: EdgeStyle) => {
set({ edgeStyle: style });
},
incrementModalCount: () => {
set((state) => {
const newCount = state.openModalCount + 1;
return { openModalCount: newCount, isModalOpen: newCount > 0 };
});
},
decrementModalCount: () => {
set((state) => {
const newCount = Math.max(0, state.openModalCount - 1);
return { openModalCount: newCount, isModalOpen: newCount > 0 };
});
},
setShowQuickstart: (show: boolean) => {
set({ showQuickstart: show });
},
addNode: (type: NodeType, position: XYPosition, initialData?: Partial<WorkflowNodeData>) => {
const id = `${type}-${++nodeIdCounter}`;
const { width, height } = defaultNodeDimensions[type];
// Merge default data with initialData if provided
const defaultData = createDefaultNodeData(type);
const nodeData = initialData
? ({ ...defaultData, ...initialData } as WorkflowNodeData)
: defaultData;
const newNode: WorkflowNode = {
id,
type,
position,
data: nodeData,
style: { width, height },
};
set((state) => ({
nodes: [...state.nodes, newNode],
hasUnsavedChanges: true,
}));
get().incrementManualChangeCount();
return id;
},
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => {
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === nodeId
? { ...node, data: { ...node.data, ...data } as WorkflowNodeData }
: node
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
},
removeNode: (nodeId: string) => {
set((state) => ({
nodes: state.nodes.filter((node) => node.id !== nodeId),
edges: state.edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
),
hasUnsavedChanges: true,
}));
get().incrementManualChangeCount();
},
onNodesChange: (changes: NodeChange<WorkflowNode>[]) => {
// Only mark as unsaved for meaningful changes (not selection changes)
const hasMeaningfulChange = changes.some(
(c) => c.type !== "select" && c.type !== "dimensions"
);
// Track manual changes only for remove operations (not position/selection/dimensions)
const hasRemoveChange = changes.some((c) => c.type === "remove");
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
...(hasMeaningfulChange ? { hasUnsavedChanges: true } : {}),
}));
if (hasRemoveChange) {
get().incrementManualChangeCount();
}
},
onEdgesChange: (changes: EdgeChange<WorkflowEdge>[]) => {
// Only mark as unsaved for meaningful changes (not selection changes)
const hasMeaningfulChange = changes.some((c) => c.type !== "select");
// Track manual changes only for remove operations (not selection)
const hasRemoveChange = changes.some((c) => c.type === "remove");
set((state) => ({
edges: applyEdgeChanges(changes, state.edges),
...(hasMeaningfulChange ? { hasUnsavedChanges: true } : {}),
}));
if (hasRemoveChange) {
get().incrementManualChangeCount();
}
},
onConnect: (connection: Connection, edgeDataOverrides?: Record<string, unknown>) => {
set((state) => {
const baseData = buildConnectionEdgeData(connection, state.nodes, state.edges);
const newEdge = {
...connection,
id: `edge-${connection.source}-${connection.target}-${connection.sourceHandle || "default"}-${connection.targetHandle || "default"}`,
data: edgeDataOverrides ? { ...baseData, ...edgeDataOverrides } : baseData,
};
// Cast needed: React Flow's Edge<T> types data as T | undefined, but addEdge expects data to be defined
return {
edges: addEdge(newEdge, state.edges as never) as WorkflowEdge[],
hasUnsavedChanges: true,
};
});
get().incrementManualChangeCount();
},
addEdgeWithType: (connection: Connection, edgeType: string, edgeDataOverrides?: Record<string, unknown>) => {
set((state) => {
const baseData = buildConnectionEdgeData(connection, state.nodes, state.edges);
const newEdge = {
...connection,
id: `edge-${connection.source}-${connection.target}-${connection.sourceHandle || "default"}-${connection.targetHandle || "default"}`,
type: edgeType,
data: edgeDataOverrides ? { ...baseData, ...edgeDataOverrides } : baseData,
};
return {
edges: addEdge(newEdge, state.edges as never) as WorkflowEdge[],
hasUnsavedChanges: true,
};
});
},
removeEdge: (edgeId: string) => {
set((state) => ({
edges: state.edges.filter((edge) => edge.id !== edgeId),
hasUnsavedChanges: true,
}));
get().incrementManualChangeCount();
},
toggleEdgePause: (edgeId: string) => {
set((state) => ({
edges: state.edges.map((edge) =>
edge.id === edgeId
? { ...edge, data: { ...edge.data, hasPause: !edge.data?.hasPause } }
: edge
),
hasUnsavedChanges: true,
}));
},
copySelectedNodes: () => {
const { nodes, edges } = get();
const selectedNodes = nodes.filter((node) => node.selected);
if (selectedNodes.length === 0) return;
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
// Copy edges that connect selected nodes to each other
const connectedEdges = edges.filter(
(edge) => selectedNodeIds.has(edge.source) && selectedNodeIds.has(edge.target)
);
// Deep clone the nodes and edges to avoid reference issues
const clonedNodes = JSON.parse(JSON.stringify(selectedNodes)) as WorkflowNode[];
const clonedEdges = JSON.parse(JSON.stringify(connectedEdges)) as WorkflowEdge[];
set({ clipboard: { nodes: clonedNodes, edges: clonedEdges } });
},
pasteNodes: (offset: XYPosition = { x: 50, y: 50 }) => {
const { clipboard, nodes, edges } = get();
if (!clipboard || clipboard.nodes.length === 0) return;
// Create a mapping from old node IDs to new node IDs
const idMapping = new Map<string, string>();
// Generate new IDs for all pasted nodes
clipboard.nodes.forEach((node) => {
const newId = `${node.type}-${++nodeIdCounter}`;
idMapping.set(node.id, newId);
});
// Create new nodes with updated IDs and offset positions
const newNodes: WorkflowNode[] = clipboard.nodes.map((node) => ({
...node,
id: idMapping.get(node.id)!,
position: {
x: node.position.x + offset.x,
y: node.position.y + offset.y,
},
selected: true, // Select newly pasted nodes
data: JSON.parse(JSON.stringify(node.data)),
}));
// Create new edges with updated source/target IDs
const newEdges: WorkflowEdge[] = clipboard.edges.map((edge) => ({
...edge,
id: `edge-${idMapping.get(edge.source)}-${idMapping.get(edge.target)}-${edge.sourceHandle || "default"}-${edge.targetHandle || "default"}`,
source: idMapping.get(edge.source)!,
target: idMapping.get(edge.target)!,
}));
// Deselect existing nodes and add new ones
const updatedNodes = nodes.map((node) => ({
...node,
selected: false,
}));
set({
nodes: [...updatedNodes, ...newNodes] as WorkflowNode[],
edges: [...edges, ...newEdges],
hasUnsavedChanges: true,
});
// Fix React Flow selection race condition: After paste, React Flow's internal
// reconciliation may fire onNodesChange with stale selection state that re-selects
// original nodes. Schedule an explicit selection correction after reconciliation.
const newNodeIdSet = new Set(newNodes.map(n => n.id));
requestAnimationFrame(() => {
const currentNodes = get().nodes;
const selectionChanges: NodeChange<WorkflowNode>[] = currentNodes.map(n => ({
type: 'select' as const,
id: n.id,
selected: newNodeIdSet.has(n.id),
}));
get().onNodesChange(selectionChanges);
});
},
clearClipboard: () => {
set({ clipboard: null });
},
// Group operations
createGroup: (nodeIds: string[]) => {
const { nodes, groups } = get();
if (nodeIds.length === 0) return "";
// Get the nodes to group
const nodesToGroup = nodes.filter((n) => nodeIds.includes(n.id));
if (nodesToGroup.length === 0) return "";
// Calculate bounding box of selected nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodesToGroup.forEach((node) => {
// Use measured dimensions (actual rendered size) first, then style, then type-specific defaults
const defaults = defaultNodeDimensions[node.type as NodeType] || { width: 300, height: 280 };
const width = node.measured?.width || (node.style?.width as number) || defaults.width;
const height = node.measured?.height || (node.style?.height as number) || defaults.height;
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + width);
maxY = Math.max(maxY, node.position.y + height);
});
// Add padding around nodes
const padding = 20;
const headerHeight = 32; // Match HEADER_HEIGHT in GroupsOverlay
// Find next available color
const usedColors = new Set(Object.values(groups).map((g) => g.color));
let color: GroupColor = "neutral";
for (const c of GROUP_COLOR_ORDER) {
if (!usedColors.has(c)) {
color = c;
break;
}
}
// Generate ID and name
const id = `group-${++groupIdCounter}`;
const groupNumber = Object.keys(groups).length + 1;
const name = `Group ${groupNumber}`;
const newGroup: NodeGroup = {
id,
name,
color,
position: {
x: minX - padding,
y: minY - padding - headerHeight
},
size: {
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2 + headerHeight,
},
};
// Update nodes with groupId and add group
set((state) => ({
nodes: state.nodes.map((node) =>
nodeIds.includes(node.id) ? { ...node, groupId: id } : node
) as WorkflowNode[],
groups: { ...state.groups, [id]: newGroup },
hasUnsavedChanges: true,
}));
return id;
},
deleteGroup: (groupId: string) => {
set((state) => {
const { [groupId]: _, ...remainingGroups } = state.groups;
return {
nodes: state.nodes.map((node) =>
node.groupId === groupId ? { ...node, groupId: undefined } : node
) as WorkflowNode[],
groups: remainingGroups,
hasUnsavedChanges: true,
};
});
},
addNodesToGroup: (nodeIds: string[], groupId: string) => {
set((state) => ({
nodes: state.nodes.map((node) =>
nodeIds.includes(node.id) ? { ...node, groupId } : node
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
},
removeNodesFromGroup: (nodeIds: string[]) => {
set((state) => ({
nodes: state.nodes.map((node) =>
nodeIds.includes(node.id) ? { ...node, groupId: undefined } : node
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
},
updateGroup: (groupId: string, updates: Partial<NodeGroup>) => {
set((state) => ({
groups: {
...state.groups,
[groupId]: { ...state.groups[groupId], ...updates },
},
hasUnsavedChanges: true,
}));
},
toggleGroupLock: (groupId: string) => {
set((state) => ({
groups: {
...state.groups,
[groupId]: {
...state.groups[groupId],
locked: !state.groups[groupId].locked,
},
},
hasUnsavedChanges: true,
}));
},
moveGroupNodes: (groupId: string, delta: { x: number; y: number }) => {
set((state) => ({
nodes: state.nodes.map((node) =>
node.groupId === groupId
? {
...node,
position: {
x: node.position.x + delta.x,
y: node.position.y + delta.y,
},
}
: node
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
},
setNodeGroupId: (nodeId: string, groupId: string | undefined) => {
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === nodeId ? { ...node, groupId } : node
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
},
getNodeById: (id: string) => {
return get().nodes.find((node) => node.id === id);
},
getConnectedInputs: (nodeId: string) => {
const { edges, nodes } = get();
return getConnectedInputsPure(nodeId, nodes, edges);
},
validateWorkflow: () => {
const { nodes, edges } = get();
return validateWorkflowPure(nodes, edges);
},
_buildExecutionContext: (node: WorkflowNode, signal?: AbortSignal): NodeExecutionContext => ({
node,
getConnectedInputs: get().getConnectedInputs,
updateNodeData: get().updateNodeData,
getFreshNode: (id: string) => get().nodes.find((n) => n.id === id),
getEdges: () => get().edges,
getNodes: () => get().nodes,
signal,
providerSettings: get().providerSettings,
addIncurredCost: (cost: number) => get().addIncurredCost(cost),
addToGlobalHistory: (item) => get().addToGlobalHistory(item),
generationsPath: get().generationsPath,
saveDirectoryPath: get().saveDirectoryPath,
trackSaveGeneration: (key: string, promise: Promise<void>) => {
pendingImageSyncs.set(key, promise);
promise.finally(() => pendingImageSyncs.delete(key));
},
appendOutputGalleryImage: (targetId: string, image: string) => {
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === targetId && n.type === "outputGallery"
? { ...n, data: { ...n.data, images: [image, ...((n.data as OutputGalleryNodeData).images || [])] } as WorkflowNodeData }
: n
) as WorkflowNode[],
hasUnsavedChanges: true,
}));
},
get: get as () => unknown,
}),
executeWorkflow: async (startFromNodeId?: string) => {
const { nodes, edges, groups, isRunning, maxConcurrentCalls } = get();
if (isRunning) {
logger.warn('workflow.start', 'Workflow already running, ignoring execution request');
return;
}
// Create AbortController for this execution run
const abortController = new AbortController();
const isResuming = startFromNodeId === get().pausedAtNodeId;
set({ isRunning: true, pausedAtNodeId: null, currentNodeIds: [], _abortController: abortController });
// Start logging session
await logger.startSession();
logger.info('workflow.start', 'Workflow execution started', {
nodeCount: nodes.length,
edgeCount: edges.length,
startFromNodeId,
isResuming,
maxConcurrentCalls,
});
// Group nodes by level for parallel execution
const levels = groupNodesByLevel(nodes, edges);
// Find starting level if startFromNodeId specified
let startLevel = 0;
if (startFromNodeId) {
const foundLevel = levels.findIndex((l) => l.nodeIds.includes(startFromNodeId));
if (foundLevel !== -1) startLevel = foundLevel;
}
// Helper to execute a single node - returns true if successful, throws on error
const executeSingleNode = async (node: WorkflowNode, signal: AbortSignal): Promise<void> => {
// Check for abort before starting
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
// Check for pause edges on incoming connections (skip if resuming from this exact node)
const isResumingThisNode = isResuming && node.id === startFromNodeId;
if (!isResumingThisNode) {
const incomingEdges = edges.filter((e) => e.target === node.id);
const pauseEdge = incomingEdges.find((e) => e.data?.hasPause);
if (pauseEdge) {
logger.info('workflow.end', 'Workflow paused at node', {
nodeId: node.id,
nodeType: node.type,
});
set({ pausedAtNodeId: node.id });
useToast.getState().show("Workflow paused - click Run to continue", "warning");
// Signal to stop the entire workflow — outer loop handles isRunning/session cleanup
abortController.abort();
return;
}
}
// Check if node is in a locked group - if so, skip execution
const nodeGroup = node.groupId ? groups[node.groupId] : null;
if (nodeGroup?.locked) {
logger.info('node.execution', `Skipping node in locked group`, {
nodeId: node.id,
nodeType: node.type,
groupId: node.groupId,
groupName: nodeGroup.name,
});
return; // Skip this node but continue with others
}
logger.info('node.execution', `Executing ${node.type} node`, {
nodeId: node.id,
nodeType: node.type,
});
const executionCtx = get()._buildExecutionContext(node, signal);
switch (node.type) {
case "imageInput":
// Data source node - no execution needed
break;
case "audioInput": {
// If audio is connected from upstream, use it (connection wins over upload)
const audioInputs = get().getConnectedInputs(node.id);
if (audioInputs.audio.length > 0 && audioInputs.audio[0]) {
get().updateNodeData(node.id, { audioFile: audioInputs.audio[0] });
}
break;
}
case "glbViewer":
await executeGlbViewer(executionCtx);
break;
case "annotation":
await executeAnnotation(executionCtx);
break;
case "prompt":
await executePrompt(executionCtx);
break;
case "array":
await executeArray(executionCtx);
break;
case "promptConstructor":
await executePromptConstructor(executionCtx);
break;
case "nanoBanana":
await executeNanoBanana(executionCtx);
break;
case "generateVideo":
await executeGenerateVideo(executionCtx);
break;
case "generate3d":
await executeGenerate3D(executionCtx);
break;
case "generateAudio":
await executeGenerateAudio(executionCtx);
break;
case "llmGenerate":
await executeLlmGenerate(executionCtx);
break;
case "splitGrid":
await executeSplitGrid(executionCtx);
break;
case "output":
await executeOutput(executionCtx);
break;
case "outputGallery":
await executeOutputGallery(executionCtx);
break;
case "imageCompare":
await executeImageCompare(executionCtx);
break;
case "videoStitch":
await executeVideoStitch(executionCtx);
break;
case "easeCurve":
await executeEaseCurve(executionCtx);
break;
case "videoTrim":
await executeVideoTrim(executionCtx);
break;
case "videoFrameGrab":
await executeVideoFrameGrab(executionCtx);
break;
}
}; // End of executeSingleNode helper
try {
// Execute levels sequentially, but nodes within each level in parallel
for (let levelIdx = startLevel; levelIdx < levels.length; levelIdx++) {
// Check if execution was stopped
if (abortController.signal.aborted || !get().isRunning) break;
const level = levels[levelIdx];
const levelNodes = level.nodeIds
.map((id) => nodes.find((n) => n.id === id))
.filter((n): n is WorkflowNode => n !== undefined);
if (levelNodes.length === 0) continue;
// Execute nodes in batches respecting concurrency limit
const batches = chunk(levelNodes, maxConcurrentCalls);
for (const batch of batches) {
if (abortController.signal.aborted || !get().isRunning) break;
// Update currentNodeIds to show which nodes are executing
const batchIds = batch.map((n) => n.id);
set({ currentNodeIds: batchIds });
logger.info('node.execution', `Executing level ${levelIdx} batch`, {
level: levelIdx,
nodeCount: batch.length,
nodeIds: batchIds,
});
// Execute batch in parallel
const results = await Promise.allSettled(
batch.map((node) => executeSingleNode(node, abortController.signal))
);
// Check for failures with node context (fail-fast behavior)
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (r.status === 'rejected' &&
!(r.reason instanceof DOMException && r.reason.name === 'AbortError')) {
const failedNode = batch[i];
logger.error('workflow.error', 'Node execution failed in parallel batch', {
level: levelIdx,
nodeId: failedNode.id,
nodeType: failedNode.type,
error: r.reason instanceof Error ? r.reason.message : String(r.reason),
});
abortController.abort();
throw r.reason;
}
}
}
}
// Check if we completed or were aborted
if (!abortController.signal.aborted && get().isRunning) {
logger.info('workflow.end', 'Workflow execution completed successfully');
}
set({ isRunning: false, currentNodeIds: [], _abortController: null });
saveLogSession();
await logger.endSession();
} catch (error) {
// Handle AbortError gracefully (user cancelled)
if (error instanceof DOMException && error.name === 'AbortError') {
logger.info('workflow.end', 'Workflow execution cancelled by user');
} else {
logger.error('workflow.error', 'Workflow execution failed', {}, error instanceof Error ? error : undefined);
// Show error toast for the failed node
useToast.getState().show(
`Workflow failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
"error"
);
}
set({ isRunning: false, currentNodeIds: [], _abortController: null });
saveLogSession();
await logger.endSession();
}
},
stopWorkflow: () => {
// Abort any in-flight requests
const controller = get()._abortController;
if (controller) {
controller.abort("user-cancelled");
}
set({ isRunning: false, currentNodeIds: [], _abortController: null });
},
setMaxConcurrentCalls: (value: number) => {
const clamped = Math.max(1, Math.min(10, value));
saveConcurrencySetting(clamped);
set({ maxConcurrentCalls: clamped });
},
regenerateNode: async (nodeId: string) => {
const { nodes, updateNodeData, isRunning } = get();
if (isRunning) {
logger.warn('node.execution', 'Cannot regenerate node, workflow already running', { nodeId });
return;
}
const node = nodes.find((n) => n.id === nodeId);
if (!node) {
logger.warn('node.error', 'Node not found for regeneration', { nodeId });
return;
}
set({ isRunning: true, currentNodeIds: [nodeId] });
await logger.startSession();
logger.info('node.execution', 'Regenerating node', {
nodeId,
nodeType: node.type,
});
try {
const executionCtx = get()._buildExecutionContext(node);
const regenOptions = { useStoredFallback: true };
if (node.type === "nanoBanana") {
await executeNanoBanana(executionCtx, regenOptions);
} else if (node.type === "array") {
await executeArray(executionCtx);
} else if (node.type === "llmGenerate") {
await executeLlmGenerate(executionCtx, regenOptions);
} else if (node.type === "generateVideo") {
await executeGenerateVideo(executionCtx, regenOptions);
} else if (node.type === "generate3d") {
await executeGenerate3D(executionCtx, regenOptions);
} else if (node.type === "generateAudio") {
await executeGenerateAudio(executionCtx, regenOptions);
} else if (node.type === "splitGrid") {
await executeSplitGrid(executionCtx);
} else if (node.type === "videoStitch") {
await executeVideoStitch(executionCtx);
set({ isRunning: false, currentNodeIds: [] });
await logger.endSession();
return;
} else if (node.type === "easeCurve") {
await executeEaseCurve(executionCtx);
set({ isRunning: false, currentNodeIds: [] });
await logger.endSession();
return;
} else if (node.type === "videoTrim") {
await executeVideoTrim(executionCtx);
set({ isRunning: false, currentNodeIds: [] });
await logger.endSession();
return;
} else if (node.type === "videoFrameGrab") {
await executeVideoFrameGrab(executionCtx);
set({ isRunning: false, currentNodeIds: [] });
await logger.endSession();
return;
}
// After regeneration, execute directly connected downstream consumer nodes
// (e.g. glbViewer needs to fetch+load 3D model from upstream nanoBanana)
const { edges: currentEdges } = get();
const downstreamEdges = currentEdges.filter(e => e.source === nodeId);
for (const edge of downstreamEdges) {
const targetNode = get().nodes.find(n => n.id === edge.target);
if (!targetNode) continue;
const targetCtx = get()._buildExecutionContext(targetNode);
switch (targetNode.type) {
case "glbViewer":
await executeGlbViewer(targetCtx);
break;
case "output":
await executeOutput(targetCtx);
break;
case "outputGallery":
await executeOutputGallery(targetCtx);
break;
case "imageCompare":
await executeImageCompare(targetCtx);
break;
}
}
logger.info('node.execution', 'Node regeneration completed successfully', { nodeId });
set({ isRunning: false, currentNodeIds: [] });
saveLogSession();
await logger.endSession();
} catch (error) {
logger.error('node.error', 'Node regeneration failed', {
nodeId,
}, error instanceof Error ? error : undefined);
updateNodeData(nodeId, {
status: "error",
error: error instanceof Error ? error.message : "Regeneration failed",
});
set({ isRunning: false, currentNodeIds: [] });
saveLogSession();
await logger.endSession();
}
},
executeSelectedNodes: async (nodeIds: string[]) => {
const { nodes, edges, isRunning, maxConcurrentCalls } = get();
if (isRunning) {
logger.warn('node.execution', 'Cannot execute nodes, workflow already running');
return;
}
if (nodeIds.length === 0) {
logger.warn('node.execution', 'No nodes provided for execution');
return;
}
// Filter to valid nodes
const selectedSet = new Set(nodeIds);
const nodesToExecute = nodeIds
.map((id) => nodes.find((n) => n.id === id))
.filter((n): n is WorkflowNode => n !== undefined);
if (nodesToExecute.length === 0) {
logger.warn('node.execution', 'No valid nodes found for execution');
return;
}
// Create AbortController for this execution run
const abortController = new AbortController();
set({ isRunning: true, currentNodeIds: nodeIds, _abortController: abortController });
await logger.startSession();
logger.info('node.execution', 'Executing selected nodes', {
nodeCount: nodesToExecute.length,
nodeIds,
});
// Helper to execute a single node
const executeNode = async (node: WorkflowNode, signal: AbortSignal) => {
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
logger.info('node.execution', `Executing ${node.type} node`, {
nodeId: node.id,
nodeType: node.type,
});
const executionCtx = get()._buildExecutionContext(node, signal);
const regenOptions = { useStoredFallback: true };
switch (node.type) {
case "imageInput":
case "audioInput":
// Data source nodes - no execution needed
break;
case "glbViewer":
await executeGlbViewer(executionCtx);
break;
case "annotation":
await executeAnnotation(executionCtx);
break;
case "prompt":
await executePrompt(executionCtx);
break;
case "array":
await executeArray(executionCtx);
break;
case "promptConstructor":
await executePromptConstructor(executionCtx);
break;
case "nanoBanana":
await executeNanoBanana(executionCtx, regenOptions);
break;
case "generateVideo":
await executeGenerateVideo(executionCtx, regenOptions);
break;
case "generate3d":
await executeGenerate3D(executionCtx, regenOptions);
break;
case "llmGenerate":
await executeLlmGenerate(executionCtx, regenOptions);
break;
case "generateAudio":
await executeGenerateAudio(executionCtx, regenOptions);
break;
case "splitGrid":
await executeSplitGrid(executionCtx);
break;
case "output":
await executeOutput(executionCtx);
break;
case "outputGallery":
await executeOutputGallery(executionCtx);
break;
case "imageCompare":
await executeImageCompare(executionCtx);
break;
case "videoStitch":
await executeVideoStitch(executionCtx);
break;
case "easeCurve":
await executeEaseCurve(executionCtx);
break;
case "videoTrim":
await executeVideoTrim(executionCtx);
break;
case "videoFrameGrab":
await executeVideoFrameGrab(executionCtx);
break;
}
};
try {
// Filter edges to only those within the selected set for topological sort
const selectedEdges = edges.filter(
(e) => selectedSet.has(e.source) && selectedSet.has(e.target)
);
// Group selected nodes by dependency level for ordered execution
const levels = groupNodesByLevel(nodesToExecute, selectedEdges);
// Execute levels sequentially, nodes within each level in parallel batches
for (const level of levels) {
if (abortController.signal.aborted || !get().isRunning) break;
const levelNodes = level.nodeIds
.map((id) => nodesToExecute.find((n) => n.id === id))
.filter((n): n is WorkflowNode => n !== undefined);
if (levelNodes.length === 0) continue;
const batches = chunk(levelNodes, maxConcurrentCalls);
for (const batch of batches) {
if (abortController.signal.aborted || !get().isRunning) break;
const batchIds = batch.map((n) => n.id);
set({ currentNodeIds: batchIds });
logger.info('node.execution', `Executing batch of selected nodes`, {
level: level.level,
nodeCount: batch.length,
nodeIds: batchIds,
});
const results = await Promise.allSettled(
batch.map((node) => executeNode(node, abortController.signal))
);
// Check for failures, filtering out AbortErrors
const failed = results.find(
(r): r is PromiseRejectedResult =>
r.status === 'rejected' &&
!(r.reason instanceof DOMException && r.reason.name === 'AbortError')
);
if (failed) {
logger.error('node.error', 'Node execution failed in batch', {
level: level.level,
error: failed.reason instanceof Error ? failed.reason.message : String(failed.reason),
});
abortController.abort();
throw failed.reason;
}
}
}
// Propagate to downstream consumer nodes not in the selected set
if (!abortController.signal.aborted && get().isRunning) {
const { edges: currentEdges } = get();
const propagated = new Set<string>();
for (const nodeId of nodeIds) {
const downstreamEdges = currentEdges.filter(e => e.source === nodeId);
for (const edge of downstreamEdges) {
if (selectedSet.has(edge.target) || propagated.has(edge.target)) continue;
const targetNode = get().nodes.find(n => n.id === edge.target);
if (!targetNode) continue;
const targetCtx = get()._buildExecutionContext(targetNode);
switch (targetNode.type) {
case "glbViewer":
await executeGlbViewer(targetCtx);
propagated.add(edge.target);
break;
case "output":
await executeOutput(targetCtx);
propagated.add(edge.target);
break;
case "outputGallery":
await executeOutputGallery(targetCtx);
propagated.add(edge.target);
break;
case "imageCompare":
await executeImageCompare(targetCtx);
propagated.add(edge.target);
break;
}
}
}
}
logger.info('node.execution', 'Selected nodes execution completed successfully');
set({ isRunning: false, currentNodeIds: [], _abortController: null });
saveLogSession();
await logger.endSession();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
logger.info('node.execution', 'Selected nodes execution cancelled by user');
} else {
logger.error('node.error', 'Selected nodes execution failed', {}, error instanceof Error ? error : undefined);
useToast.getState().show(
`Execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
"error"
);
}
set({ isRunning: false, currentNodeIds: [], _abortController: null });
saveLogSession();
await logger.endSession();
}
},
saveWorkflow: (name?: string) => {
const { nodes, edges, edgeStyle, groups } = get();
const workflow: WorkflowFile = {
version: 1,
name: name || `workflow-${new Date().toISOString().slice(0, 10)}`,
// Strip selected property - selection is transient UI state and should not be persisted
nodes: nodes.map(({ selected, ...rest }) => rest),
edges,
edgeStyle,
groups: groups && Object.keys(groups).length > 0 ? groups : undefined,
};
const json = JSON.stringify(workflow, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${workflow.name}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
loadWorkflow: async (workflow: WorkflowFile, workflowPath?: string, options?: { preserveSnapshot?: boolean }) => {
// Update nodeIdCounter to avoid ID collisions
const maxNodeId = workflow.nodes.reduce((max, node) => {
const match = node.id.match(/-(\d+)$/);
if (match) {
return Math.max(max, parseInt(match[1], 10));
}
return max;
}, 0);
nodeIdCounter = maxNodeId;
// Update groupIdCounter to avoid ID collisions
const maxGroupId = Object.keys(workflow.groups || {}).reduce((max, id) => {
const match = id.match(/-(\d+)$/);
if (match) {
return Math.max(max, parseInt(match[1], 10));
}
return max;
}, 0);
groupIdCounter = maxGroupId;
// Migrate legacy nanoBanana nodes: derive selectedModel from model field if missing
workflow.nodes = workflow.nodes.map((node) => {
if (node.type === "nanoBanana") {
const data = node.data as NanoBananaNodeData;
if (data.model && !data.selectedModel) {
const displayName = data.model === "nano-banana" ? "Nano Banana" : "Nano Banana Pro";
return {
...node,
data: {
...data,
selectedModel: {
provider: "gemini" as ProviderType,
modelId: data.model,
displayName,
},
},
};
}
}
return node;
}) as WorkflowNode[];
// Migrate legacy indexed handle IDs on edges targeting nanoBanana nodes.
// GenerateImageNode always renders "image"/"text" handles (not "image-0"/"text-0"),
// so edges saved with the old indexed format cause React Flow error #008.
const nanoBananaNodeIds = new Set(
workflow.nodes.filter((n) => n.type === "nanoBanana").map((n) => n.id)
);
workflow.edges = workflow.edges.map((edge) => {
if (!nanoBananaNodeIds.has(edge.target)) return edge;
const th = edge.targetHandle;
if (th === "image-0" || th === "text-0") {
const baseHandle = th === "image-0" ? "image" : "text";
return {
...edge,
targetHandle: baseHandle,
id: `edge-${edge.source}-${edge.target}-${edge.sourceHandle || "default"}-${baseHandle}`,
};
}
return edge;
});
// Deduplicate edges by ID (keep the last occurrence, which is the most recent)
const edgeById = new Map<string, WorkflowEdge>();
for (const edge of workflow.edges) {
edgeById.set(edge.id, edge);
}
if (edgeById.size < workflow.edges.length) {
workflow.edges = Array.from(edgeById.values());
}
// Look up saved config from localStorage (only if workflow has an ID)
const configs = loadSaveConfigs();
const savedConfig = workflow.id ? configs[workflow.id] : null;
// Determine the workflow directory path (passed in, from saved config, or embedded in workflow)
const directoryPath = workflowPath || savedConfig?.directoryPath || workflow.directoryPath;
// Hydrate images if we have a directory path and the workflow has image refs
let hydratedWorkflow = workflow;
if (directoryPath) {
try {
hydratedWorkflow = await hydrateWorkflowImages(workflow, directoryPath);
} catch (error) {
console.error("Failed to hydrate workflow images:", error);
// Continue with original workflow if hydration fails
}
}
// Load cost data for this workflow
const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null;
set({
// Clear selected state - selection should not be persisted across sessions
// Also validate position to ensure coordinates are finite numbers
nodes: hydratedWorkflow.nodes.map(node => ({
...node,
selected: false,
position: {
x: isFinite(node.position?.x) ? node.position.x : 0,
y: isFinite(node.position?.y) ? node.position.y : 0,
},
})),
edges: hydratedWorkflow.edges,
edgeStyle: hydratedWorkflow.edgeStyle || "angular",
groups: hydratedWorkflow.groups || {},
isRunning: false,
currentNodeIds: [],
// Restore workflow ID and paths from localStorage if available
workflowId: workflow.id || null,
workflowName: workflow.name,
saveDirectoryPath: directoryPath || null,
generationsPath: savedConfig?.generationsPath || null,
lastSavedAt: savedConfig?.lastSavedAt || null,
hasUnsavedChanges: false,
// Restore cost data
incurredCost: costData?.incurredCost || 0,
// Track where imageRefs are valid from
imageRefBasePath: directoryPath || null,
// Restore image storage setting (default to true for backwards compatibility)
useExternalImageStorage: savedConfig?.useExternalImageStorage ?? true,
// Reset viewed comments when loading new workflow
viewedCommentNodeIds: new Set<string>(),
});
// Clear snapshot unless explicitly preserving (e.g., AI workflow generation)
if (!options?.preserveSnapshot) {
get().clearSnapshot();
}
},
clearWorkflow: () => {
set({
nodes: [],
edges: [],
groups: {},
isRunning: false,
currentNodeIds: [],
// Reset auto-save state when clearing workflow
workflowId: null,
workflowName: null,
saveDirectoryPath: null,
generationsPath: null,
lastSavedAt: null,
hasUnsavedChanges: false,
// Reset cost tracking
incurredCost: 0,
// Reset imageRef tracking
imageRefBasePath: null,
// Reset viewed comments when clearing workflow
viewedCommentNodeIds: new Set<string>(),
});
get().clearSnapshot();
},
addToGlobalHistory: (item: Omit<ImageHistoryItem, "id">) => {
const newItem: ImageHistoryItem = {
...item,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
set((state) => ({
globalImageHistory: [newItem, ...state.globalImageHistory],
}));
},
clearGlobalHistory: () => {
set({ globalImageHistory: [] });
},
// Auto-save actions
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath?: string | null) => {
// Auto-derive generationsPath: use provided value, fall back to existing, then auto-derive
const currentGenPath = get().generationsPath;
const derivedGenerationsPath = generationsPath ?? currentGenPath ?? `${path}/generations`;
set({
workflowId: id,
workflowName: name,
saveDirectoryPath: path,
generationsPath: derivedGenerationsPath,
});
},
setWorkflowName: (name: string) => {
set({
workflowName: name,
hasUnsavedChanges: true,
});
},
setGenerationsPath: (path: string | null) => {
set({
generationsPath: path,
});
},
setAutoSaveEnabled: (enabled: boolean) => {
set({ autoSaveEnabled: enabled });
},
setUseExternalImageStorage: (enabled: boolean) => {
set({ useExternalImageStorage: enabled });
},
markAsUnsaved: () => {
set({ hasUnsavedChanges: true });
},
saveToFile: async () => {
let {
nodes,
edges,
edgeStyle,
groups,
workflowId,
workflowName,
saveDirectoryPath,
useExternalImageStorage,
imageRefBasePath,
} = get();
if (!workflowId || !workflowName || !saveDirectoryPath) {
return false;
}
set({ isSaving: true });
try {
// Wait for any pending image/video saves to complete so their IDs are synced
// This prevents saving workflows with temporary IDs that don't match saved files
await waitForPendingImageSyncs();
// Re-fetch nodes after waiting, as imageHistory IDs may have been updated
let currentNodes = get().nodes;
// Check if any nodes have existing image refs
// This helps detect "save to new directory" when imageRefBasePath wasn't set
// (e.g., workflow loaded from file dialog without directory context)
const hasExistingRefs = currentNodes.some(node => {
const data = node.data as Record<string, unknown>;
return data.imageRef || data.outputImageRef || data.sourceImageRef || data.inputImageRefs;
});
// If saving to a different directory than where refs point, clear refs
// so images will be re-saved to the new location
const isNewDirectory = useExternalImageStorage && (
// Case 1: Known different directory
(imageRefBasePath !== null && imageRefBasePath !== saveDirectoryPath) ||
// Case 2: Has refs but unknown where they came from - treat as new directory to be safe
(imageRefBasePath === null && hasExistingRefs)
);
if (isNewDirectory) {
// Generate new workflow ID for the duplicate - prevents localStorage collision
// This ensures the new project has independent config and preserves the original
const newWorkflowId = generateWorkflowId();
workflowId = newWorkflowId;
// Clear refs so images get saved to new location
currentNodes = clearNodeImageRefs(currentNodes);
set({
nodes: currentNodes,
workflowId: newWorkflowId,
});
}
let workflow: WorkflowFile = {
version: 1,
id: workflowId,
name: workflowName,
directoryPath: saveDirectoryPath,
nodes: currentNodes,
edges,
edgeStyle,
groups: groups && Object.keys(groups).length > 0 ? groups : undefined,
};
// If external image storage is enabled, externalize images before saving
if (useExternalImageStorage) {
workflow = await externalizeWorkflowImages(workflow, saveDirectoryPath);
}
const response = await fetch("/api/workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
directoryPath: saveDirectoryPath,
filename: workflowName,
workflow,
}),
});
const result = await response.json();
if (result.success) {
const timestamp = Date.now();
// If we externalized images, update store nodes with the refs
// This prevents duplicate images on subsequent saves
if (useExternalImageStorage && workflow.nodes !== currentNodes) {
// Merge refs from externalized nodes into current nodes (keeping image data)
const nodesWithRefs = currentNodes.map((node, index) => {
const externalizedNode = workflow.nodes[index];
if (!externalizedNode || node.id !== externalizedNode.id) {
return node; // Safety check - nodes should match
}
// Copy refs from externalized node while keeping current image data
// Use type assertion to access ref fields that may exist on various node types
const mergedData = { ...node.data } as Record<string, unknown>;
const extData = externalizedNode.data as Record<string, unknown>;
// Copy ref fields based on node type
if (extData.imageRef && typeof extData.imageRef === 'string') {
mergedData.imageRef = extData.imageRef;
}
if (extData.sourceImageRef && typeof extData.sourceImageRef === 'string') {
mergedData.sourceImageRef = extData.sourceImageRef;
}
if (extData.outputImageRef && typeof extData.outputImageRef === 'string') {
mergedData.outputImageRef = extData.outputImageRef;
}
if (extData.inputImageRefs && Array.isArray(extData.inputImageRefs)) {
mergedData.inputImageRefs = extData.inputImageRefs;
}
return { ...node, data: mergedData as WorkflowNodeData } as WorkflowNode;
});
set({
nodes: nodesWithRefs,
lastSavedAt: timestamp,
hasUnsavedChanges: false,
// Update imageRefBasePath to reflect new save location
imageRefBasePath: saveDirectoryPath,
});
} else {
set({
lastSavedAt: timestamp,
hasUnsavedChanges: false,
// Update imageRefBasePath to reflect save location
imageRefBasePath: useExternalImageStorage ? saveDirectoryPath : null,
});
}
// Update localStorage
saveSaveConfig({
workflowId,
name: workflowName,
directoryPath: saveDirectoryPath,
generationsPath: get().generationsPath,
lastSavedAt: timestamp,
useExternalImageStorage,
});
return true;
} else {
useToast.getState().show(`Auto-save failed: ${result.error}`, "error");
return false;
}
} catch (error) {
useToast
.getState()
.show(
`Auto-save failed: ${error instanceof Error ? error.message : "Unknown error"}`,
"error"
);
return false;
} finally {
set({ isSaving: false });
}
},
saveAsFile: async (name: string) => {
const trimmedName = name.trim();
if (!trimmedName) {
return false;
}
const { saveDirectoryPath, workflowId: prevId, workflowName: prevName } = get();
if (!saveDirectoryPath) {
return false;
}
// Save As creates another workflow JSON in the same project folder.
const newWorkflowId = generateWorkflowId();
set({
workflowId: newWorkflowId,
workflowName: trimmedName,
hasUnsavedChanges: true,
});
const success = await get().saveToFile();
if (!success) {
// Rollback to previous identity on failure
set({ workflowId: prevId, workflowName: prevName });
}
return success;
},
initializeAutoSave: () => {
if (autoSaveIntervalId) return;
autoSaveIntervalId = setInterval(async () => {
const state = get();
if (
state.autoSaveEnabled &&
state.hasUnsavedChanges &&
state.workflowId &&
state.workflowName &&
state.saveDirectoryPath &&
!state.isSaving
) {
await state.saveToFile();
}
}, 90 * 1000); // 90 seconds
},
cleanupAutoSave: () => {
if (autoSaveIntervalId) {
clearInterval(autoSaveIntervalId);
autoSaveIntervalId = null;
}
},
// Cost tracking actions
addIncurredCost: (cost: number) => {
set((state) => ({ incurredCost: state.incurredCost + cost }));
get().saveIncurredCost();
},
resetIncurredCost: () => {
set({ incurredCost: 0 });
get().saveIncurredCost();
},
loadIncurredCost: (workflowId: string) => {
const data = loadWorkflowCostData(workflowId);
set({ incurredCost: data?.incurredCost || 0 });
},
saveIncurredCost: () => {
const { workflowId, incurredCost } = get();
if (!workflowId) return;
saveWorkflowCostData({
workflowId,
incurredCost,
lastUpdated: Date.now(),
});
},
// Provider settings actions
updateProviderSettings: (settings: ProviderSettings) => {
set({ providerSettings: settings });
saveProviderSettings(settings);
},
updateProviderApiKey: (providerId: ProviderType, apiKey: string | null) => {
const { providerSettings } = get();
const updated: ProviderSettings = {
providers: {
...providerSettings.providers,
[providerId]: {
...providerSettings.providers[providerId],
apiKey,
},
},
};
set({ providerSettings: updated });
saveProviderSettings(updated);
},
toggleProvider: (providerId: ProviderType, enabled: boolean) => {
const { providerSettings } = get();
const updated: ProviderSettings = {
providers: {
...providerSettings.providers,
[providerId]: {
...providerSettings.providers[providerId],
enabled,
},
},
};
set({ providerSettings: updated });
saveProviderSettings(updated);
},
// Keyboard shortcuts dialog actions
setShortcutsDialogOpen: (open: boolean) => {
set({ shortcutsDialogOpen: open });
},
// Model search dialog actions
setModelSearchOpen: (open: boolean, provider?: ProviderType | null) => {
set({
modelSearchOpen: open,
modelSearchProvider: provider ?? null,
});
},
trackModelUsage: (model: { provider: ProviderType; modelId: string; displayName: string }) => {
const current = get().recentModels;
// Remove existing entry for same modelId if present
const filtered = current.filter((m) => m.modelId !== model.modelId);
// Prepend new entry with current timestamp
const updated: RecentModel[] = [
{
provider: model.provider,
modelId: model.modelId,
displayName: model.displayName,
timestamp: Date.now(),
},
...filtered,
].slice(0, MAX_RECENT_MODELS);
// Save to localStorage and update state
saveRecentModels(updated);
set({ recentModels: updated });
},
// Comment navigation actions
getNodesWithComments: () => {
const { nodes } = get();
// Filter nodes that have comments
const nodesWithComments = nodes.filter((node) => {
const data = node.data as { comment?: string };
return data.comment && data.comment.trim().length > 0;
});
// Sort by position: top-to-bottom (Y), then left-to-right (X)
// Use 50px threshold for row grouping
const ROW_THRESHOLD = 50;
return nodesWithComments.sort((a, b) => {
// Check if nodes are in the same "row" (within threshold)
const yDiff = a.position.y - b.position.y;
if (Math.abs(yDiff) <= ROW_THRESHOLD) {
// Same row, sort by X position
return a.position.x - b.position.x;
}
// Different rows, sort by Y position
return yDiff;
});
},
getUnviewedCommentCount: () => {
const { viewedCommentNodeIds } = get();
const nodesWithComments = get().getNodesWithComments();
return nodesWithComments.filter((node) => !viewedCommentNodeIds.has(node.id)).length;
},
markCommentViewed: (nodeId: string) => {
set((state) => {
const newViewedSet = new Set(state.viewedCommentNodeIds);
newViewedSet.add(nodeId);
return { viewedCommentNodeIds: newViewedSet };
});
},
setNavigationTarget: (nodeId: string | null) => {
if (nodeId === null) {
set({ navigationTarget: null });
} else {
// Use timestamp to ensure each navigation triggers a new effect even if same node
set({ navigationTarget: { nodeId, timestamp: Date.now() } });
// Also focus the comment tooltip on the target node
set({ focusedCommentNodeId: nodeId });
}
},
setFocusedCommentNodeId: (nodeId: string | null) => {
set({ focusedCommentNodeId: nodeId });
},
resetViewedComments: () => {
set({ viewedCommentNodeIds: new Set<string>() });
},
// AI change snapshot actions
captureSnapshot: () => {
const state = get();
// Deep copy the current workflow state to avoid reference sharing
const snapshot = {
nodes: JSON.parse(JSON.stringify(state.nodes)),
edges: JSON.parse(JSON.stringify(state.edges)),
groups: JSON.parse(JSON.stringify(state.groups)),
edgeStyle: state.edgeStyle,
};
set({
previousWorkflowSnapshot: snapshot,
manualChangeCount: 0,
});
},
revertToSnapshot: () => {
const state = get();
if (state.previousWorkflowSnapshot) {
set({
nodes: state.previousWorkflowSnapshot.nodes,
edges: state.previousWorkflowSnapshot.edges,
groups: state.previousWorkflowSnapshot.groups,
edgeStyle: state.previousWorkflowSnapshot.edgeStyle,
previousWorkflowSnapshot: null,
manualChangeCount: 0,
hasUnsavedChanges: true,
});
}
},
clearSnapshot: () => {
set({
previousWorkflowSnapshot: null,
manualChangeCount: 0,
});
},
incrementManualChangeCount: () => {
const state = get();
const newCount = state.manualChangeCount + 1;
// Automatically clear snapshot after 3 manual changes
if (newCount >= 3) {
set({
previousWorkflowSnapshot: null,
manualChangeCount: 0,
});
} else {
set({ manualChangeCount: newCount });
}
},
applyEditOperations: (operations) => {
const state = get();
const result = executeEditOps(operations, {
nodes: state.nodes,
edges: state.edges,
});
set({
nodes: result.nodes,
edges: result.edges,
hasUnsavedChanges: true,
});
return { applied: result.applied, skipped: result.skipped };
},
// Canvas navigation settings actions
updateCanvasNavigationSettings: (settings: CanvasNavigationSettings) => {
set({ canvasNavigationSettings: settings });
saveCanvasNavigationSettings(settings);
},
// Undo/Redo helper actions
_nudgeForSnapshot: () => {
set((state) => ({ nodes: state.nodes }));
},
});
export const useWorkflowStore = create<WorkflowStore>()(
temporal(
workflowStoreImpl,
{
partialize: partializeForUndo as any,
limit: 20,
equality: undoStateEquality as any,
}
)
);
/**
* Stable hook for provider API keys.
*
* Returns individual primitive values for each provider's API key.
* Uses shallow equality comparison to prevent re-renders when the
* providerSettings object reference changes but the actual key values
* don't change.
*
* This prevents unnecessary re-fetches of /api/models when multiple
* node instances subscribe to provider settings.
*/
export function useProviderApiKeys() {
return useWorkflowStore(
useShallow((state) => ({
replicateApiKey: state.providerSettings.providers.replicate?.apiKey ?? null,
falApiKey: state.providerSettings.providers.fal?.apiKey ?? null,
kieApiKey: state.providerSettings.providers.kie?.apiKey ?? null,
wavespeedApiKey: state.providerSettings.providers.wavespeed?.apiKey ?? null,
// Provider enabled states (for conditional UI)
replicateEnabled: state.providerSettings.providers.replicate?.enabled ?? false,
kieEnabled: state.providerSettings.providers.kie?.enabled ?? false,
}))
);
}