nodes-ui-flow / src /lib /workflow.js
markitzeroo
Deploy updated nodes UI flow
1dd9186
import { addEdge, MarkerType } from '@xyflow/react';
import defaultWorkflowData from '../../workflows/rwb1_workflow.json';
import { paraphraseText } from './api.js';
import { normalizeComponentTemplates } from './componentLibrary.js';
import { createBrowserId } from './ids.js';
import {
COMPONENT_INPUT_TYPE,
COMPONENT_NODE_TYPE,
COMPONENT_OUTPUT_TYPE,
createComponentSubgraph,
createNodeInstance,
getNodeDefinition,
getNodeHandles,
hydrateNode,
isComponentBoundaryType,
isComponentNodeType,
stripRuntimeFromNode,
} from './nodeRegistry.js';
export const FLOW_FORMAT = 'nodes-ui-flow';
export const FLOW_VERSION = 2;
export const AUTOSAVE_KEY = 'workflow_autosave_react_flow_v3';
export const LEGACY_AUTOSAVE_KEY = 'workflow_autosave';
export const DEFAULT_VIEWPORT = {
x: 0,
y: 0,
zoom: 1,
};
export const DEFAULT_ASSISTANT_ROLE =
'Ты компьютерный ИИ-ассистент. Отвечай кратко, естественно, одним живым абзацем, без Markdown, списков, заголовков и нумерации.';
function normalizeEdge(edge) {
return {
...edge,
id: String(edge.id),
source: String(edge.source),
target: String(edge.target),
type: edge.type === 'smoothstep' ? 'default' : edge.type || 'default',
markerEnd: edge.markerEnd || { type: MarkerType.ArrowClosed },
};
}
function sanitizeViewport(viewport) {
return viewport && typeof viewport === 'object' ? viewport : null;
}
export function hydrateGraph(graph) {
return {
nodes: (graph?.nodes || []).map((node) => hydrateNode(node)),
edges: (graph?.edges || []).map((edge) => normalizeEdge(edge)),
viewport: sanitizeViewport(graph?.viewport),
};
}
function serializeGraph(graph) {
return {
nodes: (graph?.nodes || []).map((node) => stripRuntimeFromNode(node)),
edges: (graph?.edges || []).map((edge) => ({
id: edge.id,
source: edge.source,
sourceHandle: edge.sourceHandle || null,
target: edge.target,
targetHandle: edge.targetHandle || null,
type: edge.type === 'smoothstep' ? 'default' : edge.type || 'default',
})),
viewport: sanitizeViewport(graph?.viewport),
};
}
export function createWorkflowDocument(rootGraph, componentTemplates = [], settings = {}) {
return {
format: FLOW_FORMAT,
version: FLOW_VERSION,
rootGraph: hydrateGraph(rootGraph),
componentTemplates: normalizeComponentTemplates(componentTemplates),
settings: settings && typeof settings === 'object' ? settings : {},
};
}
export function getGraphAtPath(document, path = []) {
let graph = document.rootGraph;
for (const nodeId of path) {
const node = graph.nodes.find((candidate) => candidate.id === nodeId && isComponentNodeType(candidate.type));
if (!node) {
break;
}
graph = node.data.subgraph || createComponentSubgraph();
}
return graph;
}
export function updateGraphAtPath(document, path, updater) {
function updateGraph(graph, remainingPath) {
if (remainingPath.length === 0) {
return hydrateGraph(updater(graph));
}
const [currentNodeId, ...rest] = remainingPath;
return hydrateGraph({
...graph,
nodes: graph.nodes.map((node) => {
if (node.id !== currentNodeId || !isComponentNodeType(node.type)) {
return node;
}
const nextSubgraph = updateGraph(node.data.subgraph || createComponentSubgraph(), rest);
return hydrateNode({
...node,
data: {
...node.data,
subgraph: nextSubgraph,
},
});
}),
});
}
return {
...document,
rootGraph: updateGraph(document.rootGraph, path),
};
}
export function createBlankWorkflow() {
const startNode = createNodeInstance('basic/start', { x: 40, y: 80 });
const questionNode = createNodeInstance('basic/question', { x: 360, y: 80 });
const dialogNode = createNodeInstance('basic/dialog', { x: 760, y: 80 });
return createWorkflowDocument({
nodes: [startNode, questionNode, dialogNode],
edges: [
{
id: createBrowserId('edge'),
source: startNode.id,
sourceHandle: 'dialog',
target: questionNode.id,
targetHandle: 'dialog-in',
type: 'default',
markerEnd: { type: MarkerType.ArrowClosed },
},
{
id: createBrowserId('edge'),
source: questionNode.id,
sourceHandle: 'dialog',
target: dialogNode.id,
targetHandle: 'dialog',
type: 'default',
markerEnd: { type: MarkerType.ArrowClosed },
},
],
viewport: null,
});
}
export function createDefaultWorkflow() {
return parseWorkflowData(defaultWorkflowData);
}
function convertLegacyNode(legacyNode) {
const type = legacyNode.type;
const data = {};
if (type === 'basic/text') {
data.text = legacyNode.properties?.text || '';
data.width = legacyNode.size?.[0];
data.height = legacyNode.size?.[1];
}
if (type === 'basic/classifier') {
data.options = legacyNode.properties?.options || '';
data.width = legacyNode.size?.[0];
}
if (type === 'basic/request' || type === 'basic/randomlist') {
data.width = legacyNode.size?.[0];
}
if (type === 'basic/dialog') {
data.width = legacyNode.size?.[0];
data.height = legacyNode.size?.[1];
}
if (type === 'basic/script') {
data.width = legacyNode.size?.[0];
data.hasScriptOutput =
Boolean(legacyNode.properties?.hasScriptOutput) ||
Boolean((legacyNode.outputs || []).some((output) => output.name?.toLowerCase().includes('script')));
data.entries = (legacyNode.inputs || [])
.slice(1)
.filter((input) => input?.name?.startsWith('user') || input?.name?.startsWith('character'))
.map((input, index) => ({
id: `${input.name?.startsWith('user') ? 'user' : 'character'}-${index}`,
kind: input.name?.startsWith('user') ? 'user' : 'character',
}));
}
if (type === 'basic/ifelse') {
data.width = legacyNode.size?.[0];
data.conditions = (legacyNode.properties?.conditions || [{ keyword: '' }]).map((condition, index) => ({
id: `condition-${index}`,
keyword: condition.keyword || '',
}));
}
return hydrateNode({
id: String(legacyNode.id),
type,
position: {
x: legacyNode.pos?.[0] || 0,
y: legacyNode.pos?.[1] || 0,
},
data,
});
}
function convertLiteGraphWorkflow(data) {
const supportedNodes = (data.nodes || []).filter((node) => getNodeDefinition(node.type));
const convertedNodes = supportedNodes.map(convertLegacyNode);
const nodeLookup = new Map(convertedNodes.map((node) => [String(node.id), node]));
const edges = (data.links || [])
.filter(Boolean)
.map((link) => {
const [legacyId, originId, originSlot, targetId, targetSlot] = link;
const sourceNode = nodeLookup.get(String(originId));
const targetNode = nodeLookup.get(String(targetId));
if (!sourceNode || !targetNode) {
return null;
}
const sourceHandles = getNodeHandles(sourceNode.type, sourceNode.data).outputs;
const targetHandles = getNodeHandles(targetNode.type, targetNode.data).inputs;
const sourceHandle = sourceHandles[originSlot]?.id || sourceHandles[0]?.id || null;
const targetHandle = targetHandles[targetSlot]?.id || targetHandles[0]?.id || null;
return normalizeEdge({
id: String(legacyId),
source: sourceNode.id,
sourceHandle,
target: targetNode.id,
targetHandle,
});
})
.filter(Boolean);
return {
nodes: convertedNodes,
edges,
viewport: null,
};
}
export function parseWorkflowData(data) {
if (!data || typeof data !== 'object') {
throw new Error('Invalid workflow file.');
}
if (data.format === FLOW_FORMAT && data.rootGraph) {
return createWorkflowDocument(data.rootGraph, data.componentTemplates || [], data.settings || {});
}
if (data.format === FLOW_FORMAT && Array.isArray(data.nodes) && Array.isArray(data.edges)) {
return createWorkflowDocument({
nodes: data.nodes,
edges: data.edges,
viewport: data.viewport || null,
}, data.componentTemplates || [], data.settings || {});
}
if (Array.isArray(data.nodes) && Array.isArray(data.links)) {
return createWorkflowDocument(convertLiteGraphWorkflow(data));
}
throw new Error('Unsupported workflow format.');
}
export function serializeWorkflow(document) {
return {
format: FLOW_FORMAT,
version: FLOW_VERSION,
rootGraph: serializeGraph(document.rootGraph),
componentTemplates: normalizeComponentTemplates(document.componentTemplates || []),
settings: document.settings && typeof document.settings === 'object' ? document.settings : {},
};
}
function buildEdgeDefaults(connection) {
return {
...connection,
id: createBrowserId('edge'),
type: 'default',
markerEnd: { type: MarkerType.ArrowClosed },
};
}
function hasPath(sourceId, targetId, edges, visited = new Set()) {
if (sourceId === targetId) {
return true;
}
if (visited.has(sourceId)) {
return false;
}
visited.add(sourceId);
return edges
.filter((edge) => edge.source === sourceId)
.some((edge) => hasPath(edge.target, targetId, edges, visited));
}
export function isValidConnection(connection, nodes, edges) {
if (!connection.source || !connection.target) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const sourceNode = nodes.find((node) => node.id === connection.source);
const targetNode = nodes.find((node) => node.id === connection.target);
if (!sourceNode || !targetNode) {
return false;
}
const sourceHandles = getNodeHandles(sourceNode.type, sourceNode.data).outputs;
const targetHandles = getNodeHandles(targetNode.type, targetNode.data).inputs;
const sourceHandle = sourceHandles.find((handle) => handle.id === connection.sourceHandle);
const targetHandle = targetHandles.find((handle) => handle.id === connection.targetHandle);
if (!sourceHandle || !targetHandle) {
return false;
}
const sameConnection = edges.some(
(edge) =>
edge.source === connection.source &&
edge.sourceHandle === connection.sourceHandle &&
edge.target === connection.target &&
edge.targetHandle === connection.targetHandle,
);
if (sameConnection) {
return false;
}
const outputType = sourceHandle.dataType;
const inputType = targetHandle.dataType;
const typesCompatible = outputType === '*' || inputType === '*' || outputType === inputType;
if (!typesCompatible) {
return false;
}
return true;
}
export function connectEdge(connection, edges) {
return addEdge(buildEdgeDefaults(connection), edges);
}
function getNodeSize(node) {
return {
width: node.style?.width || node.data?.width || 260,
height: node.data?.height || 180,
};
}
function getFirstOutputHandleId(node) {
return getNodeHandles(node.type, node.data).outputs[0]?.id || 'output';
}
function getHandleLabel(node, direction, handleId, fallback) {
const handles = getNodeHandles(node.type, node.data)[direction] || [];
const handle = handles.find((candidate) => candidate.id === handleId) || handles[0];
return handle?.label || handleId || fallback;
}
function createComponentHandleId(baseId, index, total) {
return total === 1 ? baseId : `${baseId}-${index}`;
}
function getComponentBoundaryExternalHandleId(node, fallbackId, index = 0) {
const rawHandleId = node?.data?.externalHandleId;
if (typeof rawHandleId === 'string' && rawHandleId.trim()) {
return rawHandleId.trim();
}
return index === 0 ? fallbackId : `${fallbackId}-${index}`;
}
function collectComponentOutputs(graph) {
const outputNodes = (graph.nodes || []).filter((node) => node.type === COMPONENT_OUTPUT_TYPE);
if (outputNodes.length === 0) {
return {
outputs: { output: null },
lastOutput: null,
};
}
const outputs = Object.fromEntries(
outputNodes.map((node, index) => [
getComponentBoundaryExternalHandleId(node, 'output', index),
node.data?.runtime?.value ?? null,
]),
);
const values = Object.values(outputs);
return {
outputs,
lastOutput: values.length === 1 ? values[0] : outputs,
};
}
function cloneEdgeForSubgraph(edge) {
return normalizeEdge({
id: createBrowserId('edge'),
source: edge.source,
sourceHandle: edge.sourceHandle || null,
target: edge.target,
targetHandle: edge.targetHandle || null,
type: edge.type === 'smoothstep' ? 'default' : edge.type || 'default',
});
}
function createExternalEdge(source, sourceHandle, target, targetHandle) {
return normalizeEdge({
id: createBrowserId('edge'),
source,
sourceHandle,
target,
targetHandle,
type: 'default',
});
}
export function createComponentFromSelection(graph) {
const selectedNodes = graph.nodes.filter((node) => node.selected);
if (selectedNodes.length === 0) {
return { error: 'Select at least one node to create a component.' };
}
if (selectedNodes.some((node) => isComponentBoundaryType(node.type))) {
return { error: 'Component Input/Output nodes cannot be grouped into another component.' };
}
const selectedIds = new Set(selectedNodes.map((node) => node.id));
const internalEdges = graph.edges.filter(
(edge) => selectedIds.has(edge.source) && selectedIds.has(edge.target),
);
const incomingExternalEdges = graph.edges.filter(
(edge) => !selectedIds.has(edge.source) && selectedIds.has(edge.target),
);
const outgoingExternalEdges = graph.edges.filter(
(edge) => selectedIds.has(edge.source) && !selectedIds.has(edge.target),
);
const bounds = selectedNodes.reduce(
(acc, node) => {
const { width, height } = getNodeSize(node);
return {
minX: Math.min(acc.minX, node.position.x),
minY: Math.min(acc.minY, node.position.y),
maxX: Math.max(acc.maxX, node.position.x + width),
maxY: Math.max(acc.maxY, node.position.y + height),
};
},
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
},
);
const offsetX = 180 - bounds.minX;
const offsetY = 60 - bounds.minY;
const nodeLookup = new Map(graph.nodes.map((node) => [node.id, node]));
const outputX = Math.max(520, bounds.maxX - bounds.minX + 240);
const portY = (index) => 80 + index * 120;
const inputPorts =
incomingExternalEdges.length > 0
? incomingExternalEdges.map((edge, index) => {
const targetNode = nodeLookup.get(edge.target);
const label = targetNode
? getHandleLabel(targetNode, 'inputs', edge.targetHandle || null, edge.targetHandle || 'input')
: edge.targetHandle || 'input';
const externalHandleId = createComponentHandleId('input', index, incomingExternalEdges.length);
return {
edge,
externalHandleId,
node: createNodeInstance(COMPONENT_INPUT_TYPE, { x: 30, y: portY(index) }, {
data: {
title: `Input: ${label}`,
externalHandleId,
externalLabel: label,
},
}),
};
})
: [
{
edge: null,
externalHandleId: 'input',
node: createNodeInstance(COMPONENT_INPUT_TYPE, { x: 30, y: 110 }, {
data: {
externalHandleId: 'input',
externalLabel: 'input',
},
}),
},
];
const outputPorts =
outgoingExternalEdges.length > 0
? outgoingExternalEdges.map((edge, index) => {
const sourceNode = nodeLookup.get(edge.source);
const label = sourceNode
? getHandleLabel(sourceNode, 'outputs', edge.sourceHandle || null, edge.sourceHandle || 'output')
: edge.sourceHandle || 'output';
const externalHandleId = createComponentHandleId('output', index, outgoingExternalEdges.length);
return {
edge,
externalHandleId,
node: createNodeInstance(COMPONENT_OUTPUT_TYPE, { x: outputX, y: portY(index) }, {
data: {
title: `Output: ${label}`,
externalHandleId,
externalLabel: label,
},
}),
};
})
: [
{
edge: null,
externalHandleId: 'output',
node: createNodeInstance(COMPONENT_OUTPUT_TYPE, { x: outputX, y: 110 }, {
data: {
externalHandleId: 'output',
externalLabel: 'output',
},
}),
},
];
const normalizedSelectedNodes = selectedNodes.map((node) =>
hydrateNode({
...node,
selected: false,
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
}),
);
const subgraphEdges = internalEdges.map((edge) => cloneEdgeForSubgraph(edge));
inputPorts.forEach((port) => {
if (!port.edge) {
return;
}
subgraphEdges.push(
createExternalEdge(port.node.id, 'output', port.edge.target, port.edge.targetHandle || null),
);
});
if (outgoingExternalEdges.length > 0) {
outputPorts.forEach((port) => {
subgraphEdges.push(
createExternalEdge(port.edge.source, port.edge.sourceHandle || null, port.node.id, 'input'),
);
});
} else {
const sinkNodes = normalizedSelectedNodes.filter((node) => {
if (isComponentBoundaryType(node.type)) {
return false;
}
const hasOutgoingInternalEdge = internalEdges.some((edge) => edge.source === node.id);
const hasOutputs = getNodeHandles(node.type, node.data).outputs.length > 0;
return !hasOutgoingInternalEdge && hasOutputs;
});
if (sinkNodes.length === 1) {
subgraphEdges.push(
createExternalEdge(
sinkNodes[0].id,
getFirstOutputHandleId(sinkNodes[0]),
outputPorts[0].node.id,
'input',
),
);
}
}
const componentNode = createNodeInstance(
COMPONENT_NODE_TYPE,
{ x: bounds.minX, y: bounds.minY },
{
data: {
title: 'Component',
description: '',
subgraph: {
nodes: [
...inputPorts.map((port) => port.node),
...normalizedSelectedNodes,
...outputPorts.map((port) => port.node),
],
edges: subgraphEdges,
viewport: null,
},
},
},
);
const remainingNodes = graph.nodes
.filter((node) => !selectedIds.has(node.id))
.map((node) => ({ ...node, selected: false }));
const remainingEdges = graph.edges.filter(
(edge) => !selectedIds.has(edge.source) && !selectedIds.has(edge.target),
);
const replacementEdges = [...remainingEdges];
inputPorts.forEach((port) => {
if (!port.edge) {
return;
}
replacementEdges.push(
createExternalEdge(
port.edge.source,
port.edge.sourceHandle || null,
componentNode.id,
port.externalHandleId,
),
);
});
outputPorts.forEach((port) => {
if (!port.edge) {
return;
}
replacementEdges.push(
createExternalEdge(
componentNode.id,
port.externalHandleId,
port.edge.target,
port.edge.targetHandle || null,
),
);
});
return {
graph: hydrateGraph({
...graph,
nodes: [...remainingNodes, { ...componentNode, selected: true }],
edges: replacementEdges,
}),
componentNodeId: componentNode.id,
};
}
function buildIncomingEdgeMap(edges) {
const incoming = new Map();
const outgoing = new Map();
edges.forEach((edge) => {
if (!incoming.has(edge.target)) {
incoming.set(edge.target, []);
}
if (!outgoing.has(edge.source)) {
outgoing.set(edge.source, []);
}
incoming.get(edge.target).push(edge);
outgoing.get(edge.source).push(edge);
});
return { incoming, outgoing };
}
function splitFeedbackEdges(edges) {
const acyclicEdges = [];
const feedbackEdges = [];
edges.forEach((edge) => {
if (hasPath(edge.target, edge.source, acyclicEdges)) {
feedbackEdges.push(edge);
} else {
acyclicEdges.push(edge);
}
});
return { acyclicEdges, feedbackEdges };
}
function topologicalSort(nodes, incomingMap) {
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
const visited = new Set();
const visiting = new Set();
const sorted = [];
function visit(node) {
if (visited.has(node.id)) {
return;
}
if (visiting.has(node.id)) {
throw new Error('Workflow contains a circular dependency.');
}
visiting.add(node.id);
const incomingEdges = incomingMap.get(node.id) || [];
incomingEdges.forEach((edge) => {
const sourceNode = nodeLookup.get(edge.source);
if (sourceNode) {
visit(sourceNode);
}
});
visiting.delete(node.id);
visited.add(node.id);
sorted.push(node);
}
nodes.forEach((node) => visit(node));
return sorted;
}
function collectReachableNodeIds(startIds, edges) {
const reachable = new Set(startIds);
const outgoingBySource = new Map();
edges.forEach((edge) => {
outgoingBySource.set(edge.source, [...(outgoingBySource.get(edge.source) || []), edge]);
});
const queue = [...startIds];
while (queue.length > 0) {
const nodeId = queue.shift();
(outgoingBySource.get(nodeId) || []).forEach((edge) => {
if (!reachable.has(edge.target)) {
reachable.add(edge.target);
queue.push(edge.target);
}
});
}
return reachable;
}
function includeInputDependencyNodeIds(nodeIds, edges) {
const required = new Set(nodeIds);
const incomingByTarget = new Map();
edges.forEach((edge) => {
incomingByTarget.set(edge.target, [...(incomingByTarget.get(edge.target) || []), edge]);
});
const queue = [...nodeIds];
while (queue.length > 0) {
const nodeId = queue.shift();
(incomingByTarget.get(nodeId) || []).forEach((edge) => {
if (!required.has(edge.source)) {
required.add(edge.source);
queue.push(edge.source);
}
});
}
return required;
}
function getRuntimeOrder(nodes, edges, incomingMap) {
const sortedNodes = topologicalSort(nodes, incomingMap);
const startIds = nodes
.filter((node) => node.type === 'basic/start')
.map((node) => node.id);
if (startIds.length === 0) {
return sortedNodes;
}
const reachable = collectReachableNodeIds(startIds, edges);
const required = includeInputDependencyNodeIds(reachable, edges);
return sortedNodes.filter((node) => required.has(node.id));
}
function prepareGraphForRun(graph) {
return hydrateGraph({
...graph,
nodes: graph.nodes.map((node) =>
hydrateNode({
...node,
data: {
...node.data,
runtime: {
type: node.type,
status: 'idle',
error: '',
inputs: {},
outputs: {},
...(node.type === 'basic/dialog'
? {
messages: [],
cleared: true,
}
: {}),
},
},
}),
),
});
}
function updateNodeInGraph(graph, nodeId, updater) {
return hydrateGraph({
...graph,
nodes: graph.nodes.map((node) => {
if (node.id !== nodeId) {
return node;
}
return hydrateNode(updater(node));
}),
});
}
function updateNodeRuntime(graph, nodeId, runtimePatch) {
return updateNodeInGraph(graph, nodeId, (node) => ({
...node,
data: {
...node.data,
runtime: {
...(node.data.runtime || {}),
...runtimePatch,
},
},
}));
}
function collectInputsForNode(incomingEdges, outputsByNode) {
const valuesByHandle = {};
(incomingEdges || []).forEach((edge) => {
const sourceOutputs = outputsByNode.get(edge.source) || {};
const handleId = edge.targetHandle || 'input';
const value = sourceOutputs[edge.sourceHandle];
valuesByHandle[handleId] = [...(valuesByHandle[handleId] || []), value];
});
return Object.fromEntries(
Object.entries(valuesByHandle).map(([handleId, values]) => [
handleId,
values.find((value) => value !== null && value !== undefined) ?? values[0],
]),
);
}
function hasBlockedIncomingValue(incomingEdges, inputValues) {
const targetHandles = new Set((incomingEdges || []).map((edge) => edge.targetHandle || 'input'));
return [...targetHandles].some((handleId) => inputValues[handleId] === null || inputValues[handleId] === undefined);
}
function renderMemoryTemplate(text, memory = {}) {
return String(text || '').replace(/\{([a-zA-Z0-9_.-]+)\}/g, (match, key) => {
const value = memory[key];
return value === undefined || value === null || value === '' ? match : String(value);
});
}
function buildQuestionResult(node, inputValues, answer, memory = {}, questionOverride = '') {
const question = questionOverride || renderMemoryTemplate(inputValues.question || node.data.question || '', memory);
const dialogIn = Array.isArray(inputValues['dialog-in']) ? inputValues['dialog-in'] : [];
const messages = [
...dialogIn,
...(String(question || '').trim() ? [{ type: 'character', text: question }] : []),
{
type: 'user',
text: answer,
},
];
return {
outputs: {
answer,
question,
dialog: messages,
turn: {
answer,
question,
dialog: messages,
},
},
runtime: {
question,
answer,
messages,
},
};
}
function getFeedbackJump(runState, sourceNodeId, outputs = {}) {
const feedbackEdges = runState.feedbackOutgoing?.get(sourceNodeId) || [];
for (const edge of feedbackEdges) {
const outputValue = outputs[edge.sourceHandle];
if (outputValue === null || outputValue === undefined) {
continue;
}
const targetIndex = runState.order.indexOf(edge.target);
if (targetIndex === -1) {
continue;
}
return {
edge,
targetIndex,
outputValue,
};
}
return null;
}
export function createInteractiveWorkflowRun(graph) {
const workingGraph = prepareGraphForRun(graph);
const { acyclicEdges, feedbackEdges } = splitFeedbackEdges(workingGraph.edges);
const { incoming, outgoing } = buildIncomingEdgeMap(acyclicEdges);
const { outgoing: feedbackOutgoing } = buildIncomingEdgeMap(feedbackEdges);
const sortedNodes = getRuntimeOrder(workingGraph.nodes, workingGraph.edges, incoming);
return {
graph: workingGraph,
incoming,
outgoing,
feedbackOutgoing,
order: sortedNodes.map((node) => node.id),
cursor: 0,
outputsByNode: new Map(),
inputOverridesByNode: new Map(),
memory: {},
assistantRole: DEFAULT_ASSISTANT_ROLE,
retryCountsByNode: new Map(),
componentRunsByNode: new Map(),
pendingQuestion: null,
completed: false,
};
}
function wait(ms) {
if (!ms) {
return Promise.resolve();
}
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
export async function continueInteractiveWorkflowRun(runState, options) {
const {
backendUrl,
llmProvider = 'ollama',
llmSessionId = '',
userAnswer = null,
externalInputValue = null,
externalInputValues = null,
knowledgeContextId = '',
testRun = null,
onGraphChange = null,
onEvent = null,
animate = true,
} = options;
let workingGraph = runState.graph;
if (onGraphChange) {
onGraphChange(workingGraph);
}
if (runState.pendingQuestion) {
if (runState.pendingQuestion.componentNodeId) {
const componentNodeId = runState.pendingQuestion.componentNodeId;
const componentNode = workingGraph.nodes.find(
(candidate) => candidate.id === componentNodeId && isComponentNodeType(candidate.type),
);
const componentRun = runState.componentRunsByNode?.get(componentNodeId);
const componentInputValues = runState.pendingQuestion.componentInputValues || {};
if (!componentNode || !componentRun) {
throw new Error('Pending component question was not found.');
}
componentRun.memory = runState.memory || {};
componentRun.assistantRole = runState.assistantRole || DEFAULT_ASSISTANT_ROLE;
const componentResult = await continueInteractiveWorkflowRun(componentRun, {
backendUrl,
llmProvider,
llmSessionId,
userAnswer,
externalInputValue: componentInputValues.input ?? null,
externalInputValues: componentInputValues,
knowledgeContextId,
testRun,
animate: false,
onGraphChange: (nextSubgraph) => {
workingGraph = updateNodeInGraph(workingGraph, componentNodeId, (currentNode) => ({
...currentNode,
data: {
...currentNode.data,
subgraph: nextSubgraph,
},
}));
if (onGraphChange) {
onGraphChange(workingGraph);
}
},
onEvent,
});
runState.memory = componentResult.state.memory || runState.memory || {};
runState.assistantRole = componentResult.state.assistantRole || runState.assistantRole || DEFAULT_ASSISTANT_ROLE;
runState.componentRunsByNode.set(componentNodeId, componentResult.state);
workingGraph = updateNodeInGraph(workingGraph, componentNodeId, (currentNode) => ({
...currentNode,
data: {
...currentNode.data,
subgraph: componentResult.state.graph,
},
}));
if (componentResult.status === 'paused') {
const pendingQuestion = {
...componentResult.pendingQuestion,
componentNodeId,
componentInputValues,
};
runState.pendingQuestion = pendingQuestion;
workingGraph = updateNodeRuntime(workingGraph, componentNodeId, {
status: 'waiting',
error: '',
inputs: componentInputValues,
outputs: {},
});
if (onGraphChange) {
onGraphChange(workingGraph);
}
return {
status: 'paused',
state: {
...runState,
graph: workingGraph,
pendingQuestion,
},
pendingQuestion,
};
}
if (componentResult.status === 'restart') {
runState.pendingQuestion = null;
return {
status: 'restart',
state: {
...runState,
graph: workingGraph,
pendingQuestion: null,
},
pendingQuestion: null,
};
}
const componentOutputs = collectComponentOutputs(componentResult.state.graph);
runState.outputsByNode.set(componentNodeId, componentOutputs.outputs);
runState.componentRunsByNode.delete(componentNodeId);
runState.pendingQuestion = null;
runState.cursor += 1;
workingGraph = updateNodeRuntime(workingGraph, componentNodeId, {
status: 'success',
error: '',
inputs: componentInputValues,
outputs: componentOutputs.outputs,
lastOutput: componentOutputs.lastOutput,
});
if (onGraphChange) {
onGraphChange(workingGraph);
}
} else {
if (userAnswer === null || userAnswer === undefined || String(userAnswer).trim() === '') {
return {
status: 'paused',
state: {
...runState,
graph: workingGraph,
},
pendingQuestion: runState.pendingQuestion,
};
}
const pendingNode = workingGraph.nodes.find((candidate) => candidate.id === runState.pendingQuestion.nodeId);
if (!pendingNode) {
throw new Error('Pending question node was not found.');
}
const answer = String(userAnswer).trim();
if (runState.pendingQuestion.retryForNodeId) {
const retryDialog = [
...(Array.isArray(runState.pendingQuestion.inputs?.['dialog-in'])
? runState.pendingQuestion.inputs['dialog-in']
: []),
...(String(runState.pendingQuestion.question || '').trim()
? [{ type: 'character', text: runState.pendingQuestion.question }]
: []),
{
type: 'user',
text: answer,
},
];
runState.inputOverridesByNode.set(runState.pendingQuestion.retryForNodeId, {
answer,
question: runState.pendingQuestion.question,
'dialog-in': retryDialog,
});
if (onEvent) {
onEvent({
type: 'user-answer',
nodeId: pendingNode.id,
answer,
});
}
workingGraph = updateNodeRuntime(workingGraph, pendingNode.id, {
status: 'running',
error: '',
});
runState.pendingQuestion = null;
if (onGraphChange) {
onGraphChange(workingGraph);
}
} else {
const result = buildQuestionResult(
pendingNode,
runState.pendingQuestion.inputs,
answer,
runState.memory || {},
runState.pendingQuestion.question,
);
runState.outputsByNode.set(pendingNode.id, result.outputs);
workingGraph = updateNodeRuntime(workingGraph, pendingNode.id, {
status: 'success',
error: '',
inputs: runState.pendingQuestion.inputs,
outputs: result.outputs,
...result.runtime,
});
if (onEvent) {
onEvent({
type: 'user-answer',
nodeId: pendingNode.id,
answer,
});
}
runState.pendingQuestion = null;
runState.cursor += 1;
if (onGraphChange) {
onGraphChange(workingGraph);
}
}
}
}
while (runState.cursor < runState.order.length) {
const nodeId = runState.order[runState.cursor];
const node = workingGraph.nodes.find((candidate) => candidate.id === nodeId);
if (!node) {
runState.cursor += 1;
continue;
}
const definition = getNodeDefinition(node.type);
const incomingEdges = runState.incoming.get(node.id) || [];
const inputValues = {
...collectInputsForNode(incomingEdges, runState.outputsByNode),
...(runState.inputOverridesByNode.get(node.id) || {}),
};
if (incomingEdges.length > 0 && hasBlockedIncomingValue(incomingEdges, inputValues)) {
runState.outputsByNode.set(node.id, {});
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'skipped',
error: '',
inputs: inputValues,
outputs: {},
});
if (onEvent) {
onEvent({
type: 'skip',
nodeId: node.id,
title: node.data?.title || node.type,
});
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
runState.cursor += 1;
continue;
}
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'running',
error: '',
inputs: inputValues,
});
if (onEvent) {
onEvent({
type: 'node-start',
nodeId: node.id,
title: node.data?.title || node.type,
});
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
await wait(animate ? 80 : 0);
if (node.type === 'basic/question') {
const renderedQuestion = renderMemoryTemplate(inputValues.question || node.data.question || '', runState.memory || {});
const question = node.data?.paraphrase
? await paraphraseText(backendUrl, renderedQuestion, 'question', testRun, node.id, llmProvider, llmSessionId, runState.assistantRole || DEFAULT_ASSISTANT_ROLE)
: renderedQuestion;
const pendingQuestion = {
nodeId: node.id,
question,
inputs: inputValues,
};
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'waiting',
error: '',
inputs: inputValues,
outputs: {},
question,
});
if (onEvent) {
onEvent({
type: 'assistant-question',
nodeId: node.id,
question,
});
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
return {
status: 'paused',
state: {
...runState,
graph: workingGraph,
pendingQuestion,
},
pendingQuestion,
};
}
try {
let result;
if (isComponentNodeType(node.type)) {
const componentSubgraph = node.data.subgraph || createComponentSubgraph();
const componentRun = createInteractiveWorkflowRun(componentSubgraph);
componentRun.memory = runState.memory || {};
componentRun.assistantRole = runState.assistantRole || DEFAULT_ASSISTANT_ROLE;
runState.componentRunsByNode.set(node.id, componentRun);
const subgraphResult = await continueInteractiveWorkflowRun(componentRun, {
backendUrl,
llmProvider,
llmSessionId,
externalInputValue: inputValues.input ?? null,
externalInputValues: inputValues,
knowledgeContextId,
testRun,
animate: false,
onGraphChange: (nextSubgraph) => {
workingGraph = updateNodeInGraph(workingGraph, node.id, (currentNode) => ({
...currentNode,
data: {
...currentNode.data,
subgraph: nextSubgraph,
},
}));
if (onGraphChange) {
onGraphChange(workingGraph);
}
},
onEvent,
});
runState.memory = subgraphResult.state.memory || runState.memory || {};
runState.assistantRole = subgraphResult.state.assistantRole || runState.assistantRole || DEFAULT_ASSISTANT_ROLE;
runState.componentRunsByNode.set(node.id, subgraphResult.state);
if (subgraphResult.status === 'paused') {
const pendingQuestion = {
...subgraphResult.pendingQuestion,
componentNodeId: node.id,
componentInputValues: inputValues,
};
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'waiting',
error: '',
inputs: inputValues,
outputs: {},
});
if (onGraphChange) {
onGraphChange(workingGraph);
}
return {
status: 'paused',
state: {
...runState,
graph: workingGraph,
pendingQuestion,
},
pendingQuestion,
};
}
if (subgraphResult.status === 'restart') {
return {
status: 'restart',
state: {
...runState,
graph: workingGraph,
pendingQuestion: null,
},
pendingQuestion: null,
};
}
const componentResult = collectComponentOutputs(subgraphResult.state.graph);
runState.componentRunsByNode.delete(node.id);
result = {
outputs: componentResult.outputs,
runtime: {
lastOutput: componentResult.lastOutput,
assistantRole: runState.assistantRole || DEFAULT_ASSISTANT_ROLE,
},
subgraph: subgraphResult.graph,
};
} else if (definition?.execute) {
const nodeLookup = new Map(workingGraph.nodes.map((candidate) => [candidate.id, candidate]));
result = await definition.execute({
node: nodeLookup.get(node.id),
inputs: inputValues,
backendUrl,
llmProvider,
llmSessionId,
externalInputValue,
externalInputValues,
memory: runState.memory || {},
assistantRole: runState.assistantRole || DEFAULT_ASSISTANT_ROLE,
knowledgeContextId,
testRun,
connectedOutputHandles: new Set((runState.outgoing.get(node.id) || []).map((edge) => edge.sourceHandle)),
outgoingEdges: runState.outgoing.get(node.id) || [],
nodeLookup,
});
} else {
result = {
outputs: {},
runtime: {},
};
}
if (result.subgraph) {
workingGraph = updateNodeInGraph(workingGraph, node.id, (currentNode) => ({
...currentNode,
data: {
...currentNode.data,
subgraph: result.subgraph,
},
}));
}
if (result.runtimeAction?.type === 'restart') {
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'success',
error: '',
inputs: inputValues,
outputs: result.outputs || {},
...(result.runtime || {}),
});
if (onEvent) {
onEvent({
type: 'restart',
nodeId: node.id,
title: node.data?.title || node.type,
});
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
return {
status: 'restart',
state: {
...runState,
graph: workingGraph,
pendingQuestion: null,
},
pendingQuestion: null,
};
}
const canRetryUnclear =
(node.type === 'basic/semantic-branch' || node.type === 'basic/save-memory') &&
result.runtime?.matchId === 'unclear' &&
node.data?.retryOnUnclear !== false;
if (canRetryUnclear) {
const retryCount = runState.retryCountsByNode.get(node.id) || 0;
const defaultRetryQuestion = 'Не смогла уверенно понять ответ. Пожалуйста, ответьте ближе к одному из вариантов.';
const rawRetryQuestion = typeof node.data?.retryQuestion === 'string'
? node.data.retryQuestion
: defaultRetryQuestion;
const retryQuestionTemplate = renderMemoryTemplate(
rawRetryQuestion,
runState.memory || {},
);
const renderedRetryQuestion = node.type === 'basic/save-memory'
? retryQuestionTemplate.replace(/\{text\}/g, inputValues.text || '')
: retryQuestionTemplate;
runState.retryCountsByNode.set(node.id, retryCount + 1);
const retryQuestion = node.data?.retryParaphrase
? await paraphraseText(backendUrl, renderedRetryQuestion, 'question', testRun, node.id, llmProvider, llmSessionId, runState.assistantRole || DEFAULT_ASSISTANT_ROLE)
: renderedRetryQuestion;
const pendingQuestion = {
nodeId: node.id,
retryForNodeId: node.id,
question: retryQuestion,
inputs: inputValues,
};
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'waiting',
error: '',
inputs: inputValues,
outputs: {},
result: 'unclear',
matchId: 'unclear',
retryCount: retryCount + 1,
});
if (onEvent) {
onEvent({
type: 'assistant-question',
nodeId: node.id,
question: retryQuestion,
});
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
return {
status: 'paused',
state: {
...runState,
graph: workingGraph,
pendingQuestion,
},
pendingQuestion,
};
}
runState.outputsByNode.set(node.id, result.outputs || {});
runState.inputOverridesByNode.delete(node.id);
if (result.memoryPatch && typeof result.memoryPatch === 'object') {
runState.memory = {
...(runState.memory || {}),
...result.memoryPatch,
};
}
if (typeof result.assistantRolePatch === 'string') {
runState.assistantRole = result.assistantRolePatch || DEFAULT_ASSISTANT_ROLE;
}
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'success',
error: '',
inputs: inputValues,
outputs: result.outputs || {},
...(result.runtime || {}),
assistantRole: runState.assistantRole || DEFAULT_ASSISTANT_ROLE,
});
if (onEvent) {
onEvent({
type: 'node-success',
nodeId: node.id,
title: node.data?.title || node.type,
});
if (result.runtime?.assistantMessage) {
onEvent({
type: 'assistant-message',
nodeId: node.id,
message: result.runtime.assistantMessage,
});
}
if (result.memoryPatch && typeof result.memoryPatch === 'object') {
onEvent({
type: 'memory-update',
nodeId: node.id,
memoryPatch: result.memoryPatch,
});
}
if (typeof result.assistantRolePatch === 'string') {
onEvent({
type: 'assistant-role-update',
nodeId: node.id,
assistantRole: runState.assistantRole || DEFAULT_ASSISTANT_ROLE,
});
}
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
const feedbackJump = getFeedbackJump(runState, node.id, result.outputs || {});
if (feedbackJump) {
const currentOverrides = runState.inputOverridesByNode.get(feedbackJump.edge.target) || {};
runState.inputOverridesByNode.set(feedbackJump.edge.target, {
...currentOverrides,
[feedbackJump.edge.targetHandle || 'input']: feedbackJump.outputValue,
});
runState.cursor = feedbackJump.targetIndex;
await wait(animate ? 24 : 0);
continue;
}
} catch (error) {
runState.outputsByNode.set(node.id, {});
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'error',
error: error instanceof Error ? error.message : String(error),
inputs: inputValues,
outputs: {},
});
if (onEvent) {
onEvent({
type: 'node-error',
nodeId: node.id,
title: node.data?.title || node.type,
error: error instanceof Error ? error.message : String(error),
});
}
if (onGraphChange) {
onGraphChange(workingGraph);
}
}
runState.cursor += 1;
await wait(animate ? 24 : 0);
}
return {
status: 'complete',
state: {
...runState,
graph: workingGraph,
completed: true,
pendingQuestion: null,
},
pendingQuestion: null,
};
}
async function executeGraph(graph, options) {
const {
backendUrl,
llmProvider = 'ollama',
llmSessionId = '',
externalInputValue = null,
externalInputValues = null,
onGraphChange = null,
animate = false,
} = options;
let workingGraph = prepareGraphForRun(graph);
const { acyclicEdges } = splitFeedbackEdges(workingGraph.edges);
const { incoming, outgoing } = buildIncomingEdgeMap(acyclicEdges);
const sortedNodes = getRuntimeOrder(workingGraph.nodes, workingGraph.edges, incoming);
const outputsByNode = new Map();
if (onGraphChange) {
onGraphChange(workingGraph);
}
for (const node of sortedNodes) {
const definition = getNodeDefinition(node.type);
const inputValues = collectInputsForNode(incoming.get(node.id), outputsByNode);
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'running',
error: '',
inputs: inputValues,
});
if (onGraphChange) {
onGraphChange(workingGraph);
}
await wait(animate ? 80 : 0);
try {
let result;
if (isComponentNodeType(node.type)) {
const componentSubgraph = node.data.subgraph || createComponentSubgraph();
const subgraphResult = await executeGraph(componentSubgraph, {
backendUrl,
llmProvider,
llmSessionId,
externalInputValue: inputValues.input ?? null,
externalInputValues: inputValues,
animate: false,
});
const componentResult = collectComponentOutputs(subgraphResult.graph);
result = {
outputs: componentResult.outputs,
runtime: {
lastOutput: componentResult.lastOutput,
},
subgraph: subgraphResult.graph,
};
} else if (definition?.execute) {
const nodeLookup = new Map(workingGraph.nodes.map((candidate) => [candidate.id, candidate]));
result = await definition.execute({
node: nodeLookup.get(node.id),
inputs: inputValues,
backendUrl,
llmProvider,
llmSessionId,
externalInputValue,
externalInputValues,
connectedOutputHandles: new Set((outgoing.get(node.id) || []).map((edge) => edge.sourceHandle)),
outgoingEdges: outgoing.get(node.id) || [],
nodeLookup,
});
} else {
result = {
outputs: {},
runtime: {},
};
}
if (result.subgraph) {
workingGraph = updateNodeInGraph(workingGraph, node.id, (currentNode) => ({
...currentNode,
data: {
...currentNode.data,
subgraph: result.subgraph,
},
}));
}
outputsByNode.set(node.id, result.outputs || {});
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'success',
error: '',
inputs: inputValues,
outputs: result.outputs || {},
...(result.runtime || {}),
});
if (onGraphChange) {
onGraphChange(workingGraph);
}
} catch (error) {
outputsByNode.set(node.id, {});
workingGraph = updateNodeRuntime(workingGraph, node.id, {
status: 'error',
error: error instanceof Error ? error.message : String(error),
inputs: inputValues,
outputs: {},
});
if (onGraphChange) {
onGraphChange(workingGraph);
}
}
await wait(animate ? 24 : 0);
}
return {
graph: workingGraph,
order: sortedNodes.map((node) => node.id),
};
}
export async function executeWorkflow({ graph, backendUrl, llmProvider = 'ollama', llmSessionId = '', onGraphChange }) {
return executeGraph(graph, {
backendUrl,
llmProvider,
llmSessionId,
onGraphChange,
animate: true,
});
}