mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
515 lines
15 KiB
TypeScript
515 lines
15 KiB
TypeScript
import { Edge } from 'reactflow'
|
|
import { create } from 'zustand'
|
|
import { devtools } from 'zustand/middleware'
|
|
import { getBlock } from '@/blocks'
|
|
import { resolveOutputType } from '@/blocks/utils'
|
|
import { WorkflowStoreWithHistory, pushHistory, withHistory } from '../middleware'
|
|
import { saveWorkflowState } from '../persistence'
|
|
import { useWorkflowRegistry } from '../registry/store'
|
|
import { useSubBlockStore } from '../subblock/store'
|
|
import { workflowSync } from '../sync'
|
|
import { mergeSubblockState } from '../utils'
|
|
import { Loop, Position, SubBlockState } from './types'
|
|
import { detectCycle } from './utils'
|
|
|
|
const initialState = {
|
|
blocks: {},
|
|
edges: [],
|
|
loops: {},
|
|
lastSaved: undefined,
|
|
isDeployed: false,
|
|
deployedAt: undefined,
|
|
history: {
|
|
past: [],
|
|
present: {
|
|
state: { blocks: {}, edges: [], loops: {}, isDeployed: false },
|
|
timestamp: Date.now(),
|
|
action: 'Initial state',
|
|
subblockValues: {},
|
|
},
|
|
future: [],
|
|
},
|
|
}
|
|
|
|
export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
|
devtools(
|
|
withHistory((set, get) => ({
|
|
...initialState,
|
|
undo: () => {},
|
|
redo: () => {},
|
|
canUndo: () => false,
|
|
canRedo: () => false,
|
|
revertToHistoryState: () => {},
|
|
|
|
addBlock: (id: string, type: string, name: string, position: Position) => {
|
|
const blockConfig = getBlock(type)
|
|
if (!blockConfig) return
|
|
|
|
const subBlocks: Record<string, SubBlockState> = {}
|
|
blockConfig.subBlocks.forEach((subBlock) => {
|
|
const subBlockId = subBlock.id
|
|
subBlocks[subBlockId] = {
|
|
id: subBlockId,
|
|
type: subBlock.type,
|
|
value: null,
|
|
}
|
|
})
|
|
|
|
const outputs = resolveOutputType(blockConfig.outputs, subBlocks)
|
|
|
|
const newState = {
|
|
blocks: {
|
|
...get().blocks,
|
|
[id]: {
|
|
id,
|
|
type,
|
|
name,
|
|
position,
|
|
subBlocks,
|
|
outputs,
|
|
enabled: true,
|
|
horizontalHandles: true,
|
|
isWide: false,
|
|
height: 0,
|
|
},
|
|
},
|
|
edges: [...get().edges],
|
|
loops: { ...get().loops },
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, `Add ${type} block`)
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
updateBlockPosition: (id: string, position: Position) => {
|
|
set((state) => ({
|
|
blocks: {
|
|
...state.blocks,
|
|
[id]: {
|
|
...state.blocks[id],
|
|
position,
|
|
},
|
|
},
|
|
edges: [...state.edges],
|
|
}))
|
|
get().updateLastSaved()
|
|
|
|
// No sync here as this is a frequent operation during dragging
|
|
},
|
|
|
|
removeBlock: (id: string) => {
|
|
// First, clean up any subblock values for this block
|
|
const subBlockStore = useSubBlockStore.getState()
|
|
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
|
|
|
const newState = {
|
|
blocks: { ...get().blocks },
|
|
edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id),
|
|
loops: { ...get().loops },
|
|
}
|
|
|
|
// Clean up subblock values before removing the block
|
|
if (activeWorkflowId) {
|
|
const updatedWorkflowValues = {
|
|
...(subBlockStore.workflowValues[activeWorkflowId] || {}),
|
|
}
|
|
delete updatedWorkflowValues[id]
|
|
|
|
// Update subblock store
|
|
useSubBlockStore.setState((state) => ({
|
|
workflowValues: {
|
|
...state.workflowValues,
|
|
[activeWorkflowId]: updatedWorkflowValues,
|
|
},
|
|
}))
|
|
}
|
|
|
|
// Clean up loops
|
|
Object.entries(newState.loops).forEach(([loopId, loop]) => {
|
|
if (loop.nodes.includes(id)) {
|
|
if (loop.nodes.length <= 2) {
|
|
delete newState.loops[loopId]
|
|
} else {
|
|
newState.loops[loopId] = {
|
|
...loop,
|
|
nodes: loop.nodes.filter((nodeId) => nodeId !== id),
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
// Delete the block last
|
|
delete newState.blocks[id]
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, 'Remove block')
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
addEdge: (edge: Edge) => {
|
|
// Check for duplicate connections
|
|
const isDuplicate = get().edges.some(
|
|
(existingEdge) =>
|
|
existingEdge.source === edge.source &&
|
|
existingEdge.target === edge.target &&
|
|
existingEdge.sourceHandle === edge.sourceHandle &&
|
|
existingEdge.targetHandle === edge.targetHandle
|
|
)
|
|
|
|
// If it's a duplicate connection, return early without adding the edge
|
|
if (isDuplicate) {
|
|
return
|
|
}
|
|
|
|
const newEdge = {
|
|
id: edge.id || crypto.randomUUID(),
|
|
source: edge.source,
|
|
target: edge.target,
|
|
sourceHandle: edge.sourceHandle,
|
|
targetHandle: edge.targetHandle,
|
|
}
|
|
|
|
const newEdges = [...get().edges, newEdge]
|
|
|
|
// Recalculate all loops after adding the edge
|
|
const newLoops: Record<string, Loop> = {}
|
|
const processedPaths = new Set<string>()
|
|
|
|
// Check for cycles from each node
|
|
const nodes = new Set(newEdges.map((e) => e.source))
|
|
nodes.forEach((node) => {
|
|
const { paths } = detectCycle(newEdges, node)
|
|
paths.forEach((path) => {
|
|
// Create a canonical path representation for deduplication
|
|
const canonicalPath = [...path].sort().join(',')
|
|
if (!processedPaths.has(canonicalPath)) {
|
|
const loopId = crypto.randomUUID()
|
|
newLoops[loopId] = {
|
|
id: loopId,
|
|
nodes: path,
|
|
maxIterations: 5,
|
|
minIterations: 0,
|
|
}
|
|
processedPaths.add(canonicalPath)
|
|
}
|
|
})
|
|
})
|
|
|
|
const newState = {
|
|
blocks: { ...get().blocks },
|
|
edges: newEdges,
|
|
loops: newLoops,
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, 'Add connection')
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
removeEdge: (edgeId: string) => {
|
|
const newEdges = get().edges.filter((edge) => edge.id !== edgeId)
|
|
|
|
// Recalculate all loops after edge removal
|
|
const newLoops: Record<string, Loop> = {}
|
|
const processedPaths = new Set<string>()
|
|
|
|
// Check for cycles from each node
|
|
const nodes = new Set(newEdges.map((e) => e.source))
|
|
nodes.forEach((node) => {
|
|
const { paths } = detectCycle(newEdges, node)
|
|
paths.forEach((path) => {
|
|
// Create a canonical path representation for deduplication
|
|
const canonicalPath = [...path].sort().join(',')
|
|
if (!processedPaths.has(canonicalPath)) {
|
|
const loopId = crypto.randomUUID()
|
|
newLoops[loopId] = {
|
|
id: loopId,
|
|
nodes: path,
|
|
maxIterations: 5,
|
|
minIterations: 0,
|
|
}
|
|
processedPaths.add(canonicalPath)
|
|
}
|
|
})
|
|
})
|
|
|
|
const newState = {
|
|
blocks: { ...get().blocks },
|
|
edges: newEdges,
|
|
loops: newLoops,
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, 'Remove connection')
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
clear: () => {
|
|
const newState = {
|
|
blocks: {},
|
|
edges: [],
|
|
loops: {},
|
|
history: {
|
|
past: [],
|
|
present: {
|
|
state: {
|
|
blocks: {},
|
|
edges: [],
|
|
loops: {},
|
|
},
|
|
timestamp: Date.now(),
|
|
action: 'Initial state',
|
|
subblockValues: {},
|
|
},
|
|
future: [],
|
|
},
|
|
lastSaved: Date.now(),
|
|
}
|
|
set(newState)
|
|
workflowSync.sync()
|
|
|
|
return newState
|
|
},
|
|
|
|
updateLastSaved: () => {
|
|
set({ lastSaved: Date.now() })
|
|
|
|
// Save current state to localStorage
|
|
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
|
if (activeWorkflowId) {
|
|
const currentState = get()
|
|
saveWorkflowState(activeWorkflowId, {
|
|
blocks: currentState.blocks,
|
|
edges: currentState.edges,
|
|
loops: currentState.loops,
|
|
history: currentState.history,
|
|
isDeployed: currentState.isDeployed,
|
|
deployedAt: currentState.deployedAt,
|
|
lastSaved: Date.now(),
|
|
})
|
|
|
|
// Note: Scheduling changes are automatically handled by the workflowSync
|
|
// When the workflow is synced to the database, the sync system checks if
|
|
// the starter block has scheduling enabled and updates or cancels the
|
|
// schedule accordingly.
|
|
}
|
|
},
|
|
|
|
toggleBlockEnabled: (id: string) => {
|
|
const newState = {
|
|
blocks: {
|
|
...get().blocks,
|
|
[id]: {
|
|
...get().blocks[id],
|
|
enabled: !get().blocks[id].enabled,
|
|
},
|
|
},
|
|
edges: [...get().edges],
|
|
}
|
|
|
|
set(newState)
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
duplicateBlock: (id: string) => {
|
|
const block = get().blocks[id]
|
|
if (!block) return
|
|
|
|
const newId = crypto.randomUUID()
|
|
const offsetPosition = {
|
|
x: block.position.x + 250,
|
|
y: block.position.y + 20,
|
|
}
|
|
|
|
// More efficient name handling
|
|
const match = block.name.match(/(.*?)(\d+)?$/)
|
|
const newName =
|
|
match && match[2] ? `${match[1]}${parseInt(match[2]) + 1}` : `${block.name} 1`
|
|
|
|
// Get merged state to capture current subblock values
|
|
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
|
|
|
|
// Create new subblocks with merged values
|
|
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
|
|
(acc, [subId, subBlock]) => ({
|
|
...acc,
|
|
[subId]: {
|
|
...subBlock,
|
|
value: JSON.parse(JSON.stringify(subBlock.value)),
|
|
},
|
|
}),
|
|
{}
|
|
)
|
|
|
|
const newState = {
|
|
blocks: {
|
|
...get().blocks,
|
|
[newId]: {
|
|
...block,
|
|
id: newId,
|
|
name: newName,
|
|
position: offsetPosition,
|
|
subBlocks: newSubBlocks,
|
|
},
|
|
},
|
|
edges: [...get().edges],
|
|
loops: { ...get().loops },
|
|
}
|
|
|
|
// Update the subblock store with the duplicated values
|
|
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
|
if (activeWorkflowId) {
|
|
const subBlockValues =
|
|
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
|
|
useSubBlockStore.setState((state) => ({
|
|
workflowValues: {
|
|
...state.workflowValues,
|
|
[activeWorkflowId]: {
|
|
...state.workflowValues[activeWorkflowId],
|
|
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
|
|
},
|
|
},
|
|
}))
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, `Duplicate ${block.type} block`)
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
toggleBlockHandles: (id: string) => {
|
|
const newState = {
|
|
blocks: {
|
|
...get().blocks,
|
|
[id]: {
|
|
...get().blocks[id],
|
|
horizontalHandles: !get().blocks[id].horizontalHandles,
|
|
},
|
|
},
|
|
edges: [...get().edges],
|
|
}
|
|
|
|
set(newState)
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
updateBlockName: (id: string, name: string) => {
|
|
const newState = {
|
|
blocks: {
|
|
...get().blocks,
|
|
[id]: {
|
|
...get().blocks[id],
|
|
name,
|
|
},
|
|
},
|
|
edges: [...get().edges],
|
|
loops: { ...get().loops },
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, `${name} block name updated`)
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
toggleBlockWide: (id: string) => {
|
|
set((state) => ({
|
|
blocks: {
|
|
...state.blocks,
|
|
[id]: {
|
|
...state.blocks[id],
|
|
isWide: !state.blocks[id].isWide,
|
|
},
|
|
},
|
|
edges: [...state.edges],
|
|
loops: { ...get().loops },
|
|
}))
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
updateBlockHeight: (id: string, height: number) => {
|
|
set((state) => ({
|
|
blocks: {
|
|
...state.blocks,
|
|
[id]: {
|
|
...state.blocks[id],
|
|
height,
|
|
},
|
|
},
|
|
edges: [...state.edges],
|
|
}))
|
|
get().updateLastSaved()
|
|
},
|
|
|
|
updateLoopMaxIterations: (loopId: string, maxIterations: number) => {
|
|
const newState = {
|
|
blocks: { ...get().blocks },
|
|
edges: [...get().edges],
|
|
loops: {
|
|
...get().loops,
|
|
[loopId]: {
|
|
...get().loops[loopId],
|
|
maxIterations: Math.max(1, Math.min(50, maxIterations)), // Clamp between 1-50
|
|
},
|
|
},
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, 'Update loop max iterations')
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
|
|
updateLoopMinIterations: (loopId: string, minIterations: number) => {
|
|
const newState = {
|
|
blocks: { ...get().blocks },
|
|
edges: [...get().edges],
|
|
loops: {
|
|
...get().loops,
|
|
[loopId]: {
|
|
...get().loops[loopId],
|
|
minIterations: Math.max(
|
|
0,
|
|
Math.min(get().loops[loopId].maxIterations, minIterations)
|
|
), // Clamp between 0 and maxIterations
|
|
},
|
|
},
|
|
}
|
|
|
|
set(newState)
|
|
pushHistory(set, get, newState, 'Update loop min iterations')
|
|
get().updateLastSaved()
|
|
},
|
|
|
|
triggerUpdate: () => {
|
|
set((state) => ({
|
|
...state,
|
|
lastUpdate: Date.now(),
|
|
}))
|
|
},
|
|
|
|
setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => {
|
|
const newState = {
|
|
...get(),
|
|
isDeployed,
|
|
deployedAt: deployedAt || (isDeployed ? new Date() : undefined),
|
|
}
|
|
|
|
set(newState)
|
|
get().updateLastSaved()
|
|
workflowSync.sync()
|
|
},
|
|
})),
|
|
{ name: 'workflow-store' }
|
|
)
|
|
)
|