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.
 
 

300 lines
9.0 KiB

import { NodeType, WorkflowNode, WorkflowNodeData } from "@/types";
import { WorkflowEdge } from "@/types/workflow";
import { createDefaultNodeData } from "@/store/utils/nodeDefaults";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { wouldCreateCycle } from "@/store/utils/executionUtils";
import {
areChatHandlesCompatible,
isChatNodeType,
isKnownChatHandle,
sanitizeChatNodeData,
} from "./nodeSchema";
/**
* Edit operation types for workflow modifications.
* Each operation represents a single atomic change to the workflow.
*/
export type EditOperation =
| {
type: "addNode";
nodeType: NodeType;
nodeId?: string;
position?: { x: number; y: number };
data?: Record<string, unknown>;
}
| { type: "removeNode"; nodeId: string }
| {
type: "updateNode";
nodeId: string;
position?: { x: number; y: number };
data: Record<string, unknown>;
}
| {
type: "addEdge";
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
}
| { type: "removeEdge"; edgeId: string };
/**
* Result of applying edit operations to the workflow.
*/
export interface ApplyEditResult {
nodes: WorkflowNode[];
edges: WorkflowEdge[];
applied: number;
skipped: string[];
}
/**
* Applies a batch of edit operations to the current workflow state.
* Uses immutable updates (single pass, not individual setState calls).
* Invalid operations are skipped with reasons tracked.
*
* @param operations - List of edit operations to apply
* @param storeState - Current workflow state (nodes and edges)
* @returns Updated nodes, edges, count of applied operations, and skipped operations with reasons
*/
export function applyEditOperations(
operations: EditOperation[],
storeState: { nodes: WorkflowNode[]; edges: WorkflowEdge[] }
): ApplyEditResult {
let nodes = [...storeState.nodes];
let edges = [...storeState.edges];
const skipped: string[] = [];
let applied = 0;
for (const [index, operation] of operations.entries()) {
switch (operation.type) {
case "addNode": {
if (!isChatNodeType(operation.nodeType)) {
skipped.push(`addNode: node type "${operation.nodeType}" is not supported`);
break;
}
const requestedNodeId =
typeof operation.nodeId === "string" && operation.nodeId.trim()
? operation.nodeId.trim()
: undefined;
const nodeId = requestedNodeId ?? `${operation.nodeType}-ai-${Date.now()}-${index}`;
if (nodes.some((node) => node.id === nodeId)) {
skipped.push(`addNode: node "${nodeId}" already exists`);
break;
}
// Get default position and data
const rawPosition = operation.position ?? { x: 200, y: 200 };
const position =
Number.isFinite(rawPosition.x) && Number.isFinite(rawPosition.y)
? rawPosition
: { x: 200, y: 200 };
const defaultData = createDefaultNodeData(operation.nodeType);
const dimensions = defaultNodeDimensions[operation.nodeType];
const safeData = sanitizeChatNodeData(operation.nodeType, operation.data);
// Merge provided data with defaults
const nodeData = {
...defaultData,
...safeData,
} as WorkflowNodeData;
// Create new node
const newNode: WorkflowNode = {
id: nodeId,
type: operation.nodeType,
position,
data: nodeData,
style: { width: dimensions.width, height: dimensions.height },
};
nodes.push(newNode);
applied++;
break;
}
case "removeNode": {
const nodeExists = nodes.find((n) => n.id === operation.nodeId);
if (!nodeExists) {
skipped.push(
`removeNode: node "${operation.nodeId}" not found`
);
break;
}
// Remove node and its connected edges
nodes = nodes.filter((n) => n.id !== operation.nodeId);
edges = edges.filter(
(e) => e.source !== operation.nodeId && e.target !== operation.nodeId
);
applied++;
break;
}
case "updateNode": {
const nodeIndex = nodes.findIndex((n) => n.id === operation.nodeId);
if (nodeIndex === -1) {
skipped.push(
`updateNode: node "${operation.nodeId}" not found`
);
break;
}
const node = nodes[nodeIndex];
const rawPosition =
operation.position ??
(operation.data.position as { x: number; y: number } | undefined);
const hasValidPosition =
rawPosition &&
Number.isFinite(rawPosition.x) &&
Number.isFinite(rawPosition.y);
const safeData = sanitizeChatNodeData(node.type, operation.data);
if (Object.keys(safeData).length === 0 && !hasValidPosition) {
skipped.push(
`updateNode: no editable fields provided for node "${operation.nodeId}"`
);
break;
}
console.log("Updating node", operation.nodeId, "with data", safeData);
// Update node data immutably
nodes = nodes.map((n) =>
n.id === operation.nodeId
? {
...n,
position: hasValidPosition ? rawPosition : n.position,
data: {
...n.data,
...safeData,
} as WorkflowNodeData,
}
: n
);
applied++;
break;
}
case "addEdge": {
// Validate source and target nodes exist
const sourceExists = nodes.find((n) => n.id === operation.source);
const targetExists = nodes.find((n) => n.id === operation.target);
if (!sourceExists) {
skipped.push(
`addEdge: source node "${operation.source}" not found`
);
break;
}
if (!targetExists) {
skipped.push(
`addEdge: target node "${operation.target}" not found`
);
break;
}
if (
!isKnownChatHandle(sourceExists.type, "output", operation.sourceHandle) ||
!isKnownChatHandle(targetExists.type, "input", operation.targetHandle)
) {
skipped.push(
`addEdge: handle is not valid for "${operation.source}" or "${operation.target}"`
);
break;
}
if (!areChatHandlesCompatible(operation.sourceHandle, operation.targetHandle)) {
skipped.push(
`addEdge: handle types do not match for "${operation.source}" and "${operation.target}"`
);
break;
}
if (wouldCreateCycle(operation.source, operation.target, edges)) {
skipped.push(
`addEdge: connection from "${operation.source}" to "${operation.target}" would create a cycle`
);
break;
}
const duplicateEdge = edges.some(
(edge) =>
edge.source === operation.source &&
edge.target === operation.target &&
(edge.sourceHandle || undefined) === operation.sourceHandle &&
(edge.targetHandle || undefined) === operation.targetHandle
);
if (duplicateEdge) {
skipped.push(
`addEdge: connection from "${operation.source}" to "${operation.target}" already exists`
);
break;
}
// Generate edge ID
const handleSuffix = operation.sourceHandle
? `-${operation.sourceHandle}`
: "";
const edgeId = `edge-ai-${operation.source}-${operation.target}${handleSuffix}`;
// Create new edge
const newEdge: WorkflowEdge = {
id: edgeId,
source: operation.source,
target: operation.target,
sourceHandle: operation.sourceHandle,
targetHandle: operation.targetHandle,
};
edges.push(newEdge);
applied++;
break;
}
case "removeEdge": {
const edgeExists = edges.find((e) => e.id === operation.edgeId);
if (!edgeExists) {
skipped.push(
`removeEdge: edge "${operation.edgeId}" not found`
);
break;
}
edges = edges.filter((e) => e.id !== operation.edgeId);
applied++;
break;
}
}
}
return {
nodes,
edges,
applied,
skipped,
};
}
/**
* Generates a human-readable summary of what operations were applied.
*
* @param operations - List of edit operations
* @returns Human-readable summary string
*/
export function narrateOperations(operations: EditOperation[]): string {
const narratives = operations.map((op) => {
switch (op.type) {
case "addNode":
return `Added a ${op.nodeType} node`;
case "removeNode":
return `Removed node ${op.nodeId}`;
case "updateNode":
return `Updated ${op.nodeId} settings`;
case "addEdge":
return `Connected ${op.source} to ${op.target}`;
case "removeEdge":
return `Removed connection ${op.edgeId}`;
}
});
return narratives.join("\n");
}