nodes-ui-flow / src /lib /nodeRegistry.js
markitzeroo
Deploy updated nodes UI flow
1dd9186
import {
answerFromContext,
classifyRequest,
extractMemoryFieldRequest,
paraphraseText,
requestLLM,
} from './api.js';
import { createBrowserId } from './ids.js';
import TextFlowNode from '../nodes/TextFlowNode.jsx';
import RequestFlowNode from '../nodes/RequestFlowNode.jsx';
import ClassifierFlowNode from '../nodes/ClassifierFlowNode.jsx';
import SemanticBranchFlowNode from '../nodes/SemanticBranchFlowNode.jsx';
import ScriptFlowNode from '../nodes/ScriptFlowNode.jsx';
import DialogFlowNode from '../nodes/DialogFlowNode.jsx';
import StartFlowNode from '../nodes/StartFlowNode.jsx';
import RoleUpdateFlowNode from '../nodes/RoleUpdateFlowNode.jsx';
import QuestionFlowNode from '../nodes/QuestionFlowNode.jsx';
import AssistantMessageFlowNode from '../nodes/AssistantMessageFlowNode.jsx';
import SaveMemoryFlowNode from '../nodes/SaveMemoryFlowNode.jsx';
import KnowledgeAnswerFlowNode from '../nodes/KnowledgeAnswerFlowNode.jsx';
import CounterFlowNode from '../nodes/CounterFlowNode.jsx';
import WaitFlowNode from '../nodes/WaitFlowNode.jsx';
import RestartFlowNode from '../nodes/RestartFlowNode.jsx';
import RandomListFlowNode from '../nodes/RandomListFlowNode.jsx';
import IfElseFlowNode from '../nodes/IfElseFlowNode.jsx';
import SendWebSocketFlowNode from '../nodes/SendWebSocketFlowNode.jsx';
import JsonParserFlowNode from '../nodes/JsonParserFlowNode.jsx';
import ComponentFlowNode from '../nodes/ComponentFlowNode.jsx';
import ComponentInputFlowNode from '../nodes/ComponentInputFlowNode.jsx';
import ComponentOutputFlowNode from '../nodes/ComponentOutputFlowNode.jsx';
function toNumber(value, fallback) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function createRuntime(type) {
return {
type,
status: 'idle',
error: '',
inputs: {},
outputs: {},
};
}
function attachRuntime(type, data) {
return {
...data,
runtime: createRuntime(type),
};
}
function getExternalHandleId(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 getExternalHandleLabel(node, fallbackLabel, handleId) {
const rawLabel = node?.data?.externalLabel;
if (typeof rawLabel === 'string' && rawLabel.trim()) {
return rawLabel.trim();
}
return handleId || fallbackLabel;
}
function getComponentBoundaryHandles(subgraph, boundaryType, fallbackId, fallbackLabel) {
const boundaryNodes = (subgraph?.nodes || []).filter((node) => node.type === boundaryType);
if (boundaryNodes.length === 0) {
return [{ id: fallbackId, label: fallbackLabel, dataType: '*' }];
}
return boundaryNodes.map((node, index) => {
const handleId = getExternalHandleId(node, fallbackId, index);
return {
id: handleId,
label: getExternalHandleLabel(node, fallbackLabel, handleId),
dataType: '*',
};
});
}
function ensureScriptEntries(entries) {
if (Array.isArray(entries) && entries.length > 0) {
return entries
.filter((entry) => entry && (entry.kind === 'user' || entry.kind === 'character'))
.map((entry, index) => ({
id: entry.id || `${entry.kind}-${index}`,
kind: entry.kind,
}));
}
return [];
}
function ensureConditions(conditions) {
if (Array.isArray(conditions) && conditions.length > 0) {
return conditions.map((condition, index) => ({
id: condition.id || `condition-${index}`,
keyword: condition.keyword || '',
}));
}
return [
{
id: 'condition-0',
keyword: '',
},
];
}
function ensureChoices(choices) {
if (Array.isArray(choices) && choices.length > 0) {
return choices.map((choice, index) => ({
id: choice.id || `choice-${index}`,
label: choice.label || choice.keyword || '',
}));
}
return [
{
id: 'choice-0',
label: '',
},
];
}
function ensureJsonExtracts(extracts) {
if (Array.isArray(extracts) && extracts.length > 0) {
return extracts.map((extract, index) => ({
id: extract.id || `extract-${index}`,
label: extract.label || `result ${index}`,
path: extract.path || '',
fields: extract.fields || '',
}));
}
return [
{
id: 'extract-0',
label: 'items',
path: 'data.filter_group.items',
fields: 'key, name',
},
];
}
function normalizeChoiceText(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/^["'«]+|["'»]+$/g, '');
}
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 applyAssistantRole(systemPrompt, assistantRole) {
const role = String(assistantRole || '').trim();
if (!role) {
return systemPrompt || '';
}
return `${role}\n\nОбщие правила остаются неизменными: отвечай кратко, естественно, одним живым абзацем, без Markdown, списков, заголовков и нумерации.\n\n${systemPrompt || ''}`.trim();
}
function renderWebSocketMessageTemplate(text, inputText, memory = {}) {
return String(text || '').replace(/\{([a-zA-Z0-9_.-]+)\}/g, (match, key) => {
if (key === 'text' || key === 'input') {
return inputText === undefined || inputText === null ? '' : String(inputText);
}
const value = memory[key];
return value === undefined || value === null ? match : String(value);
});
}
function getLastDialogTurn(messages) {
if (!Array.isArray(messages) || messages.length === 0) {
return {
question: '',
answer: '',
};
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type !== 'user' || !message.text) {
continue;
}
const previousAssistantMessage = messages
.slice(0, index)
.reverse()
.find((candidate) => candidate?.type === 'character' && candidate.text);
return {
question: previousAssistantMessage?.text || '',
answer: message.text,
};
}
return {
question: '',
answer: '',
};
}
function validateJsonLikeMessage(message) {
const trimmedMessage = String(message || '').trim();
if (!trimmedMessage || (!trimmedMessage.startsWith('{') && !trimmedMessage.startsWith('['))) {
return;
}
JSON.parse(trimmedMessage);
}
function parseJsonPath(path) {
return String(path || '')
.trim()
.split('.')
.flatMap((part) => {
const tokens = [];
const pattern = /([^[\]]+)|\[(\d+|\*)?\]/g;
let match;
while ((match = pattern.exec(part)) !== null) {
if (match[1]) {
tokens.push(match[1]);
} else if (match[2] === undefined || match[2] === '*') {
tokens.push('*');
} else {
tokens.push(Number(match[2]));
}
}
return tokens;
})
.filter((token) => token !== '');
}
function readJsonPath(value, path) {
const tokens = parseJsonPath(path);
function read(current, remainingTokens) {
if (remainingTokens.length === 0) {
return current;
}
if (current === null || current === undefined) {
return undefined;
}
const [token, ...rest] = remainingTokens;
if (token === '*') {
if (!Array.isArray(current)) {
return undefined;
}
return current.map((item) => read(item, rest)).filter((item) => item !== undefined);
}
return read(current[token], rest);
}
return read(value, tokens);
}
function parseFieldList(fields) {
return String(fields || '')
.split(',')
.map((field) => field.trim())
.filter(Boolean);
}
function pickJsonFields(value, fields) {
const fieldList = parseFieldList(fields);
if (fieldList.length === 0) {
return value;
}
const pickOne = (item) =>
Object.fromEntries(
fieldList.map((field) => {
const outputKey = field.includes('.') ? field.split('.').pop() : field;
return [outputKey, readJsonPath(item, field) ?? null];
}),
);
if (Array.isArray(value)) {
return value.map((item) => pickOne(item));
}
if (value && typeof value === 'object') {
return pickOne(value);
}
return value;
}
function formatJsonParserOutput(value) {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value, null, 2);
}
function sendWebSocketMessage(url, message) {
return new Promise((resolve, reject) => {
if (typeof WebSocket === 'undefined') {
reject(new Error('WebSocket is not available in this browser.'));
return;
}
if (!url || !url.trim()) {
reject(new Error('WebSocket URL is empty.'));
return;
}
const targetUrl = url.trim();
if (!/^wss?:\/\//i.test(targetUrl)) {
reject(new Error(`WebSocket URL must start with ws:// or wss://. Received: ${targetUrl}`));
return;
}
try {
validateJsonLikeMessage(message);
} catch (error) {
reject(new Error(`Message body is not valid JSON: ${error.message}`));
return;
}
let socket;
let settled = false;
let messageSent = false;
let sendTimeoutId = null;
let serverReply = '';
const timeoutId = window.setTimeout(() => {
if (settled) {
return;
}
settled = true;
if (socket) {
socket.close();
}
reject(new Error(messageSent ? 'WebSocket timed out waiting for reply.' : 'WebSocket connection timed out.'));
}, 15000);
const clearTimers = () => {
window.clearTimeout(timeoutId);
if (sendTimeoutId) {
window.clearTimeout(sendTimeoutId);
}
};
try {
socket = new WebSocket(targetUrl);
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
return;
}
socket.addEventListener('open', () => {
if (settled) {
return;
}
sendTimeoutId = window.setTimeout(() => {
if (settled) {
return;
}
try {
socket.send(message);
messageSent = true;
} catch (error) {
settled = true;
clearTimers();
reject(error);
return;
}
}, 120);
}, { once: true });
socket.addEventListener('message', (event) => {
serverReply = typeof event.data === 'string' ? event.data : '[binary message]';
if (!messageSent || settled) {
return;
}
settled = true;
clearTimers();
socket.close();
resolve({ reply: serverReply });
});
socket.addEventListener('error', () => {
if (settled) {
return;
}
settled = true;
clearTimers();
reject(new Error(`WebSocket connection failed for ${targetUrl}. If the browser console shows 403, that server rejected the handshake before the message body was sent.`));
}, { once: true });
socket.addEventListener('close', (event) => {
if (settled) {
return;
}
settled = true;
clearTimers();
if (messageSent) {
if (serverReply) {
resolve({ reply: serverReply });
return;
}
reject(new Error(`WebSocket closed without reply. Code: ${event.code || 'unknown'}.`));
return;
}
reject(new Error(`WebSocket closed before message was sent to ${targetUrl}. Code: ${event.code || 'unknown'}.`));
}, { once: true });
});
}
function buildScriptTextOutput(messages, outgoingEdges, nodeLookup) {
const scriptEdges = outgoingEdges.filter((edge) => edge.sourceHandle === 'script-text');
if (scriptEdges.length === 0) {
return messages;
}
const firstEdge = scriptEdges[0];
const targetNode = nodeLookup.get(firstEdge.target);
if (targetNode?.type === 'basic/text') {
const lastMessage = messages[messages.length - 1];
return lastMessage ? lastMessage.text : '';
}
return messages;
}
function appendCharacterMessage(dialogIn, text) {
const message = String(text || '').trim();
return message ? [...dialogIn, { type: 'character', text: message }] : dialogIn;
}
export const COMPONENT_NODE_TYPE = 'system/component';
export const COMPONENT_INPUT_TYPE = 'system/component-input';
export const COMPONENT_OUTPUT_TYPE = 'system/component-output';
export const NODE_DEFINITIONS = {
'basic/start': {
title: 'Начало',
description: 'Явная точка входа сценария.',
accent: 'var(--accent-mint)',
component: StartFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/start', {
title: 'Начало',
width: 260,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/start', {
title: data.title || 'Начало',
width: toNumber(data.width, 260),
});
},
getHandles() {
return {
inputs: [],
outputs: [{ id: 'dialog', label: 'start', dataType: 'object' }],
};
},
async execute() {
return {
outputs: {
dialog: [],
},
runtime: {
started: true,
},
};
},
},
'basic/update-role': {
title: 'Обновить роль',
description: 'Меняет роль ассистента до конца текущей сессии или до следующей такой ноды.',
accent: 'var(--accent-violet)',
component: RoleUpdateFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/update-role', {
title: 'Обновить роль',
role: overrides.role || 'Ты компьютерный ИИ-ассистент. Отвечай кратко, естественно и без списков.',
width: 380,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/update-role', {
title: data.title || 'Обновить роль',
role: typeof data.role === 'string' ? data.role : '',
width: toNumber(data.width, 380),
});
},
getHandles() {
return {
inputs: [{ id: 'dialog-in', label: 'dialog in', dataType: 'object' }],
outputs: [{ id: 'dialog', label: 'dialog', dataType: 'object' }],
};
},
async execute({ node, inputs }) {
const role = String(node.data.role || '').trim();
const dialogIn = Array.isArray(inputs['dialog-in']) ? inputs['dialog-in'] : [];
return {
outputs: {
dialog: dialogIn,
},
runtime: {
assistantRole: role,
},
assistantRolePatch: role,
};
},
},
'basic/text': {
title: 'Текст',
description: 'Редактируемый текстовый блок с пресетами.',
accent: 'var(--accent-amber)',
component: TextFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/text', {
title: 'Текст',
text: '',
width: 400,
height: 210,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/text', {
title: data.title || 'Текст',
text: data.text || '',
width: toNumber(data.width, 400),
height: toNumber(data.height, 210),
});
},
getHandles() {
return {
inputs: [{ id: 'text', label: 'text', dataType: 'string' }],
outputs: [{ id: 'text', label: 'text', dataType: 'string' }],
};
},
async execute({ node, inputs }) {
const inputText = inputs.text;
const outputText = inputText !== undefined && inputText !== null ? inputText : node.data.text || '';
return {
outputs: {
text: outputText,
},
runtime: {
inputText,
outputText,
},
};
},
},
'basic/request': {
title: 'Запрос к LLM',
description: 'Отправляет system/user prompt в FastAPI.',
accent: 'var(--accent-mint)',
component: RequestFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/request', {
title: 'Запрос к LLM',
width: 320,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/request', {
title: data.title || 'Запрос к LLM',
width: toNumber(data.width, 320),
});
},
getHandles() {
return {
inputs: [
{ id: 'system', label: 'System', dataType: 'string' },
{ id: 'user', label: 'User', dataType: 'string' },
],
outputs: [{ id: 'response', label: 'response', dataType: 'string' }],
};
},
async execute({ backendUrl, llmProvider, llmSessionId, node, inputs, assistantRole, testRun }) {
const system = inputs.system || '';
const user = inputs.user || '';
if (!system && !user) {
return {
outputs: { response: '' },
runtime: {
response: '',
},
};
}
const response = await requestLLM(
backendUrl,
applyAssistantRole(system, assistantRole),
user,
testRun,
node.id,
llmProvider,
llmSessionId,
);
return {
outputs: {
response,
},
runtime: {
response,
},
};
},
},
'basic/classifier': {
title: 'Классификатор',
description: 'Сопоставляет ответ с набором вариантов.',
accent: 'var(--accent-coral)',
component: ClassifierFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/classifier', {
title: 'Классификатор',
options: 'яблоки, апельсины, арбузы',
width: 320,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/classifier', {
title: data.title || 'Классификатор',
options: typeof data.options === 'string' ? data.options : 'яблоки, апельсины, арбузы',
width: toNumber(data.width, 320),
});
},
getHandles() {
return {
inputs: [
{ id: 'turn', label: 'turn', dataType: 'object' },
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'question', label: 'question', dataType: 'string' },
{ id: 'answer', label: 'answer', dataType: 'string' },
],
outputs: [{ id: 'result', label: 'result', dataType: 'string' }],
};
},
async execute({ backendUrl, llmProvider, llmSessionId, node, inputs, testRun }) {
const question = inputs.question || '';
const answer = inputs.answer || '';
const options = node.data.options || '';
if (!answer || !options) {
return {
outputs: {
result: '',
},
runtime: {
result: '',
},
};
}
const result = await classifyRequest(backendUrl, question, answer, options, testRun, node.id, llmProvider, llmSessionId);
return {
outputs: {
result,
},
runtime: {
result,
},
};
},
},
'basic/semantic-branch': {
title: 'Смысловое ветвление',
description: 'LLM выбирает одну ветку по смыслу ответа.',
accent: 'var(--accent-rose)',
component: SemanticBranchFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/semantic-branch', {
title: 'Смысловое ветвление',
choices: ensureChoices(overrides.choices || [
{ id: 'choice-0', label: 'вариант 1' },
{ id: 'choice-1', label: 'вариант 2' },
]),
retryOnUnclear: overrides.retryOnUnclear ?? true,
retryQuestion: overrides.retryQuestion || 'Не смогла уверенно понять ответ. Пожалуйста, ответьте ближе к одному из вариантов.',
retryParaphrase: Boolean(overrides.retryParaphrase),
width: 360,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/semantic-branch', {
title: data.title || 'Смысловое ветвление',
choices: ensureChoices(data.choices || data.conditions),
retryOnUnclear: data.retryOnUnclear ?? true,
retryQuestion: typeof data.retryQuestion === 'string'
? data.retryQuestion
: 'Не смогла уверенно понять ответ. Пожалуйста, ответьте ближе к одному из вариантов.',
retryParaphrase: Boolean(data.retryParaphrase),
width: toNumber(data.width, 360),
});
},
getHandles(data) {
return {
inputs: [
{ id: 'turn', label: 'turn', dataType: 'object' },
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'question', label: 'question', dataType: 'string' },
{ id: 'answer', label: 'answer', dataType: 'string' },
],
outputs: [
...ensureChoices(data.choices).map((choice, index) => ({
id: choice.id,
label: choice.label || `choice ${index}`,
dataType: 'object',
})),
{ id: 'unclear', label: 'unclear', dataType: 'object' },
],
};
},
async execute({ backendUrl, llmProvider, llmSessionId, node, inputs, testRun }) {
const turn = inputs.turn && typeof inputs.turn === 'object' && !Array.isArray(inputs.turn) ? inputs.turn : {};
const question = inputs.question || turn.question || '';
const answer = inputs.answer || turn.answer || '';
const branchPayload = Array.isArray(inputs['dialog-in'])
? inputs['dialog-in']
: Array.isArray(inputs.turn)
? inputs.turn
: Array.isArray(turn.dialog)
? turn.dialog
: true;
const choices = ensureChoices(node.data.choices).filter((choice) => choice.label.trim());
const outputs = Object.fromEntries([
...ensureChoices(node.data.choices).map((choice) => [choice.id, null]),
['unclear', null],
]);
if (!answer || choices.length === 0) {
outputs.unclear = branchPayload;
return {
outputs,
runtime: {
result: 'unclear',
matchId: 'unclear',
},
};
}
const result = await classifyRequest(
backendUrl,
question,
answer,
`${choices.map((choice) => choice.label).join('\n')}\n`,
testRun,
node.id,
llmProvider,
llmSessionId,
);
const normalizedResult = normalizeChoiceText(result);
const matchedChoice = choices.find((choice) => normalizeChoiceText(choice.label) === normalizedResult);
if (matchedChoice) {
outputs[matchedChoice.id] = branchPayload;
} else {
outputs.unclear = branchPayload;
}
return {
outputs,
runtime: {
result,
matchId: matchedChoice ? matchedChoice.id : 'unclear',
},
};
},
},
'basic/script': {
title: 'Скрипт',
description: 'Собирает диалог и опционально отдаёт последний текст.',
accent: 'var(--accent-gold)',
component: ScriptFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/script', {
title: 'Скрипт',
entries: [],
hasScriptOutput: false,
width: 320,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/script', {
title: data.title || 'Скрипт',
entries: ensureScriptEntries(data.entries),
hasScriptOutput: Boolean(data.hasScriptOutput),
width: toNumber(data.width, 320),
});
},
getHandles(data) {
const inputs = [{ id: 'script-in', label: 'script in', dataType: 'object' }];
const typeCounts = {
user: 0,
character: 0,
};
ensureScriptEntries(data.entries).forEach((entry) => {
const currentIndex = typeCounts[entry.kind];
typeCounts[entry.kind] += 1;
inputs.push({
id: entry.id,
label: `${entry.kind} ${currentIndex}`,
dataType: 'string',
});
});
const outputs = [{ id: 'dialog', label: 'dialog', dataType: 'object' }];
if (data.hasScriptOutput) {
outputs.push({ id: 'script-text', label: 'script / text', dataType: '*' });
}
return { inputs, outputs };
},
async execute({ node, inputs, outgoingEdges, nodeLookup }) {
const scriptIn = inputs['script-in'];
const scriptInConnected = Object.prototype.hasOwnProperty.call(inputs, 'script-in');
if (scriptInConnected && !scriptIn) {
return {
outputs: {
dialog: [],
...(node.data.hasScriptOutput ? { 'script-text': null } : {}),
},
runtime: {
messages: [],
scriptOutput: null,
},
};
}
const messages = [];
if (Array.isArray(scriptIn)) {
messages.push(...scriptIn);
}
node.data.entries.forEach((entry) => {
const value = inputs[entry.id];
if (!value) {
return;
}
if (Array.isArray(value)) {
messages.push(...value);
return;
}
messages.push({
type: entry.kind,
text: value,
});
});
const outputs = {
dialog: messages,
};
let scriptOutput = null;
if (node.data.hasScriptOutput) {
scriptOutput = buildScriptTextOutput(messages, outgoingEdges, nodeLookup);
outputs['script-text'] = scriptOutput;
}
return {
outputs,
runtime: {
messages,
scriptOutput,
},
};
},
},
'basic/dialog': {
title: 'Диалог',
description: 'Показывает собранные сообщения после запуска.',
accent: 'var(--accent-violet)',
component: DialogFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/dialog', {
title: 'Диалог',
width: 420,
height: 280,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/dialog', {
title: data.title || 'Диалог',
width: toNumber(data.width, 420),
height: toNumber(data.height, 280),
});
},
getHandles() {
return {
inputs: [{ id: 'dialog', label: 'dialog', dataType: 'object' }],
outputs: [],
};
},
async execute({ inputs }) {
const messages = Array.isArray(inputs.dialog) ? inputs.dialog : [];
return {
outputs: {},
runtime: {
messages,
cleared: false,
},
};
},
},
'basic/assistant-message': {
title: 'Реплика ассистента',
description: 'Показывает реплику ассистента в интерактивном диалоге без ожидания ответа.',
accent: 'var(--accent-mint)',
component: AssistantMessageFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/assistant-message', {
title: 'Реплика ассистента',
text: 'Очень приятно, {name}.',
paraphrase: Boolean(overrides.paraphrase),
width: 380,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/assistant-message', {
title: data.title || 'Реплика ассистента',
text: data.text || '',
paraphrase: Boolean(data.paraphrase),
width: toNumber(data.width, 380),
});
},
getHandles() {
return {
inputs: [
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'text', label: 'text', dataType: 'string' },
],
outputs: [{ id: 'dialog', label: 'dialog', dataType: 'object' }],
};
},
async execute({ backendUrl, llmProvider, llmSessionId, node, inputs, memory, assistantRole, testRun }) {
const text = inputs.text || node.data.text || '';
const renderedMessage = renderMemoryTemplate(text, memory);
const message = node.data.paraphrase
? await paraphraseText(backendUrl, renderedMessage, 'message', testRun, node.id, llmProvider, llmSessionId, assistantRole)
: renderedMessage;
const dialogIn = Array.isArray(inputs['dialog-in']) ? inputs['dialog-in'] : [];
const messages = appendCharacterMessage(dialogIn, message);
return {
outputs: {
dialog: messages,
},
runtime: {
message,
messages,
assistantMessage: message,
},
};
},
},
'basic/save-memory': {
title: 'Сохранить память',
description: 'Извлекает значение из ответа и сохраняет в память прогона.',
accent: 'var(--accent-gold)',
component: SaveMemoryFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/save-memory', {
title: 'Сохранить память',
key: 'name',
instruction: 'Извлеки имя пользователя',
retryOnUnclear: overrides.retryOnUnclear ?? true,
retryQuestion: overrides.retryQuestion || 'Не смогла уверенно распознать нужное значение. Пожалуйста, уточните ответ.',
retryParaphrase: Boolean(overrides.retryParaphrase),
width: 360,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/save-memory', {
title: data.title || 'Сохранить память',
key: typeof data.key === 'string' ? data.key : 'value',
instruction: data.instruction || '',
retryOnUnclear: data.retryOnUnclear ?? true,
retryQuestion: typeof data.retryQuestion === 'string'
? data.retryQuestion
: 'Не смогла уверенно распознать нужное значение. Пожалуйста, уточните ответ.',
retryParaphrase: Boolean(data.retryParaphrase),
width: toNumber(data.width, 360),
});
},
getHandles() {
return {
inputs: [
{ id: 'turn', label: 'turn', dataType: 'object' },
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'question', label: 'question', dataType: 'string' },
{ id: 'answer', label: 'answer', dataType: 'string' },
{ id: 'text', label: 'text', dataType: 'string' },
],
outputs: [
{ id: 'dialog', label: 'dialog', dataType: 'object' },
{ id: 'value', label: 'value', dataType: 'string' },
{ id: 'unclear', label: 'unclear', dataType: 'object' },
],
};
},
async execute({ backendUrl, llmProvider, llmSessionId, node, inputs, testRun }) {
const turn = inputs.turn && typeof inputs.turn === 'object' && !Array.isArray(inputs.turn) ? inputs.turn : {};
const key = String(node.data.key || '').trim();
const dialogIn = Array.isArray(inputs['dialog-in'])
? inputs['dialog-in']
: Array.isArray(inputs.turn)
? inputs.turn
: Array.isArray(turn.dialog)
? turn.dialog
: [];
const latestDialogTurn = getLastDialogTurn(dialogIn);
const answer = inputs.answer || latestDialogTurn.answer || turn.answer || '';
const question = inputs.question || latestDialogTurn.question || turn.question || '';
const inputText = inputs.text || '';
const instruction = String(node.data.instruction || '').replace(/\{text\}/g, inputText);
const unclearOutputs = {
dialog: null,
value: '',
unclear: dialogIn,
};
if (!key || !answer) {
return {
outputs: unclearOutputs,
runtime: {
result: 'unclear',
matchId: 'unclear',
value: '',
},
};
}
const response = await extractMemoryFieldRequest(
backendUrl,
answer,
key,
instruction,
question,
'русский',
testRun,
node.id,
llmProvider,
llmSessionId,
);
const value = response.value === null || response.value === undefined ? '' : String(response.value).trim();
if (!value) {
return {
outputs: unclearOutputs,
runtime: {
key,
value: '',
result: 'unclear',
matchId: 'unclear',
instruction,
inputText,
},
};
}
const memoryPatch = { [key]: value };
return {
outputs: {
dialog: dialogIn,
value,
unclear: null,
},
runtime: {
key,
value,
result: 'saved',
matchId: 'value',
instruction,
inputText,
memoryPatch,
},
memoryPatch,
};
},
},
'basic/knowledge-answer': {
title: 'Ответ по знаниям',
description: 'Отвечает на вопрос пользователя по загруженному файлу или RAG-заглушке.',
accent: 'var(--accent-violet)',
component: KnowledgeAnswerFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/knowledge-answer', {
title: 'Ответ по знаниям',
source: 'uploaded',
contextPath: '',
originalPath: '',
contextFilename: '',
contextCharacters: 0,
width: 380,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/knowledge-answer', {
title: data.title || 'Ответ по знаниям',
source: data.source || 'uploaded',
contextPath: data.contextPath || data.context_path || '',
originalPath: data.originalPath || data.original_path || '',
contextFilename: data.contextFilename || data.filename || '',
contextCharacters: toNumber(data.contextCharacters || data.characters, 0),
width: toNumber(data.width, 380),
});
},
getHandles() {
return {
inputs: [
{ id: 'turn', label: 'turn', dataType: 'object' },
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'question', label: 'question', dataType: 'string' },
],
outputs: [{ id: 'dialog', label: 'dialog', dataType: 'object' }],
};
},
async execute({ backendUrl, llmProvider, llmSessionId, node, inputs, memory, assistantRole, knowledgeContextId, testRun }) {
const turn = inputs.turn && typeof inputs.turn === 'object' ? inputs.turn : {};
const question = renderMemoryTemplate(inputs.question || turn.answer || '', memory || {});
const dialogIn = Array.isArray(inputs['dialog-in'])
? inputs['dialog-in']
: Array.isArray(turn.dialog)
? turn.dialog
: [];
if (!question) {
return {
outputs: {
dialog: dialogIn,
},
runtime: {
answer: '',
assistantMessage: '',
messages: dialogIn,
},
};
}
const answer = await answerFromContext(
backendUrl,
question,
knowledgeContextId,
node.data.source || 'uploaded',
'русский',
node.data.contextPath || '',
testRun,
node.id,
llmProvider,
llmSessionId,
assistantRole,
);
const messages = appendCharacterMessage(dialogIn, answer);
return {
outputs: {
dialog: messages,
},
runtime: {
answer,
assistantMessage: answer,
messages,
},
};
},
},
'basic/counter': {
title: 'Счетчик повторов',
description: 'Считает проходы и переключает поток на done после лимита.',
accent: 'var(--accent-sky)',
component: CounterFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/counter', {
title: 'Счетчик повторов',
key: overrides.key || 'counter',
limit: Math.max(1, Math.floor(toNumber(overrides.limit, 3))),
width: 340,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/counter', {
title: data.title || 'Счетчик повторов',
key: data.key || 'counter',
limit: Math.max(1, Math.floor(toNumber(data.limit, 3))),
width: toNumber(data.width, 340),
});
},
getHandles() {
return {
inputs: [{ id: 'dialog-in', label: 'dialog in', dataType: 'object' }],
outputs: [
{ id: 'continue', label: 'continue', dataType: 'object' },
{ id: 'done', label: 'done', dataType: 'object' },
],
};
},
async execute({ node, inputs, memory }) {
const key = String(node.data.key || 'counter').trim() || 'counter';
const limit = Math.max(1, Math.floor(toNumber(node.data.limit, 3)));
const previous = Number(memory?.[key]);
const count = (Number.isFinite(previous) ? previous : 0) + 1;
const matchId = count >= limit ? 'done' : 'continue';
const payload = Array.isArray(inputs['dialog-in']) ? inputs['dialog-in'] : true;
return {
outputs: {
continue: matchId === 'continue' ? payload : null,
done: matchId === 'done' ? payload : null,
},
runtime: {
key,
count,
limit,
matchId,
},
memoryPatch: {
[key]: count,
},
};
},
},
'basic/wait': {
title: 'Ожидание',
description: 'Задерживает проход сценария на заданное число миллисекунд.',
accent: 'var(--accent-violet)',
component: WaitFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/wait', {
title: 'Ожидание',
ms: Math.max(0, Math.floor(toNumber(overrides.ms, 1000))),
width: 300,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/wait', {
title: data.title || 'Ожидание',
ms: Math.max(0, Math.floor(toNumber(data.ms, 1000))),
width: toNumber(data.width, 300),
});
},
getHandles() {
return {
inputs: [{ id: 'dialog-in', label: 'dialog in', dataType: 'object' }],
outputs: [{ id: 'dialog', label: 'dialog', dataType: 'object' }],
};
},
async execute({ node, inputs }) {
const ms = Math.max(0, Math.floor(toNumber(node.data.ms, 1000)));
if (ms > 0) {
await new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
const dialogIn = Array.isArray(inputs['dialog-in']) ? inputs['dialog-in'] : [];
return {
outputs: {
dialog: dialogIn,
},
runtime: {
waitedMs: ms,
},
};
},
},
'basic/restart': {
title: 'Перезапуск',
description: 'Возвращает интерактивный сценарий к началу.',
accent: 'var(--accent-coral)',
component: RestartFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/restart', {
title: 'Перезапуск',
width: 280,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/restart', {
title: data.title || 'Перезапуск',
width: toNumber(data.width, 280),
});
},
getHandles() {
return {
inputs: [{ id: 'dialog-in', label: 'dialog in', dataType: 'object' }],
outputs: [],
};
},
async execute() {
return {
outputs: {},
runtime: {
restart: true,
},
runtimeAction: {
type: 'restart',
},
};
},
},
'basic/question': {
title: 'Вопрос',
description: 'Пауза интерактивного прогона: вопрос ассистента и ответ пользователя.',
accent: 'var(--accent-sky)',
component: QuestionFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/question', {
title: 'Вопрос',
question: 'Как я могу к вам обращаться?',
paraphrase: Boolean(overrides.paraphrase),
width: 360,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/question', {
title: data.title || 'Вопрос',
question: data.question || '',
paraphrase: Boolean(data.paraphrase),
width: toNumber(data.width, 360),
});
},
getHandles() {
return {
inputs: [
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'question', label: 'question', dataType: 'string' },
],
outputs: [
{ id: 'answer', label: 'answer', dataType: 'string' },
{ id: 'question', label: 'question', dataType: 'string' },
{ id: 'turn', label: 'turn', dataType: 'object' },
{ id: 'dialog', label: 'dialog', dataType: 'object' },
],
};
},
},
'basic/randomlist': {
title: 'Случайный список',
description: 'Выбирает случайную строку из списка в одинарных кавычках.',
accent: 'var(--accent-sea)',
component: RandomListFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/randomlist', {
title: 'Случайный список',
width: 260,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/randomlist', {
title: data.title || 'Случайный список',
width: toNumber(data.width, 260),
});
},
getHandles() {
return {
inputs: [{ id: 'text', label: 'text', dataType: 'string' }],
outputs: [{ id: 'text', label: 'text', dataType: 'string' }],
};
},
async execute({ inputs }) {
const input = inputs.text;
if (!input || !input.trim()) {
return {
outputs: {
text: '',
},
runtime: {
selected: '',
},
};
}
const regex = /'([^']*)'/g;
const items = [];
let match;
while ((match = regex.exec(input)) !== null) {
items.push(match[1]);
}
if (items.length === 0) {
return {
outputs: {
text: '',
},
runtime: {
selected: '',
},
};
}
const selected = items[Math.floor(Math.random() * items.length)];
return {
outputs: {
text: selected,
},
runtime: {
selected,
},
};
},
},
'basic/send-websocket': {
title: 'Отправить WebSocket',
description: 'Подставляет входной текст в WebSocket URL и открывает соединение.',
accent: 'var(--accent-sea)',
component: SendWebSocketFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/send-websocket', {
title: 'Отправить WebSocket',
url: 'ws://localhost:8765',
messageTemplate: '{text}',
width: 360,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/send-websocket', {
title: data.title || 'Отправить WebSocket',
url: data.url || 'ws://localhost:8765',
messageTemplate: data.messageTemplate || data.urlTemplate || '{text}',
width: toNumber(data.width, 360),
});
},
getHandles() {
return {
inputs: [
{ id: 'dialog-in', label: 'dialog in', dataType: 'object' },
{ id: 'text', label: 'text', dataType: 'string' },
],
outputs: [
{ id: 'dialog', label: 'dialog', dataType: 'object' },
{ id: 'text', label: 'text', dataType: 'string' },
],
};
},
async execute({ node, inputs, memory }) {
const inputText = inputs.text || '';
const dialogIn = Array.isArray(inputs['dialog-in']) ? inputs['dialog-in'] : [];
const url = node.data.url || '';
const message = renderWebSocketMessageTemplate(node.data.messageTemplate || '', inputText, memory || {});
const sendResult = await sendWebSocketMessage(url, message);
return {
outputs: {
dialog: dialogIn,
text: sendResult.reply || '',
},
runtime: {
inputText,
dialog: dialogIn,
url,
message,
reply: sendResult.reply || '',
},
};
},
},
'basic/json-parser': {
title: 'JSON-парсер',
description: 'Извлекает значения или списки из JSON по настраиваемым путям.',
accent: 'var(--accent-violet)',
component: JsonParserFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/json-parser', {
title: 'JSON-парсер',
extracts: ensureJsonExtracts(overrides.extracts),
width: 420,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/json-parser', {
title: data.title || 'JSON-парсер',
extracts: ensureJsonExtracts(data.extracts),
width: toNumber(data.width, 420),
});
},
getHandles(data) {
return {
inputs: [{ id: 'json', label: 'json', dataType: 'string' }],
outputs: ensureJsonExtracts(data.extracts).map((extract, index) => ({
id: extract.id,
label: extract.label || `result ${index}`,
dataType: 'string',
})),
};
},
async execute({ node, inputs }) {
const rawJson = inputs.json || '';
const extracts = ensureJsonExtracts(node.data.extracts);
const outputs = Object.fromEntries(extracts.map((extract) => [extract.id, '']));
if (!rawJson || !String(rawJson).trim()) {
return {
outputs,
runtime: {
values: outputs,
},
};
}
let parsed;
try {
parsed = JSON.parse(rawJson);
} catch (error) {
throw new Error(`Invalid JSON: ${error.message}`);
}
extracts.forEach((extract) => {
const value = readJsonPath(parsed, extract.path);
const pickedValue = pickJsonFields(value, extract.fields);
outputs[extract.id] = formatJsonParserOutput(pickedValue);
});
return {
outputs,
runtime: {
values: outputs,
},
};
},
},
'basic/ifelse': {
title: 'Если / Иначе',
description: 'Открывает один из выходов при точном совпадении строки.',
accent: 'var(--accent-rose)',
component: IfElseFlowNode,
createData(overrides = {}) {
return attachRuntime('basic/ifelse', {
title: 'Если / Иначе',
conditions: ensureConditions(overrides.conditions),
width: 360,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime('basic/ifelse', {
title: data.title || 'Если / Иначе',
conditions: ensureConditions(data.conditions),
width: toNumber(data.width, 360),
});
},
getHandles(data) {
return {
inputs: [{ id: 'input', label: 'input', dataType: 'string' }],
outputs: ensureConditions(data.conditions).map((condition, index) => ({
id: condition.id,
label: `script ${index}`,
dataType: 'object',
})),
};
},
async execute({ node, inputs }) {
const inputValue = inputs.input;
const outputs = {};
let matchId = null;
node.data.conditions.forEach((condition) => {
outputs[condition.id] = null;
});
if (!inputValue) {
return {
outputs,
runtime: {
inputValue: '',
matchId: null,
},
};
}
const normalizedInput = inputValue.trim().toLowerCase();
node.data.conditions.some((condition) => {
if (condition.keyword && normalizedInput === condition.keyword.trim().toLowerCase()) {
outputs[condition.id] = true;
matchId = condition.id;
return true;
}
return false;
});
return {
outputs,
runtime: {
inputValue,
matchId,
},
};
},
},
[COMPONENT_INPUT_TYPE]: {
title: 'Вход компонента',
description: 'Внутренний вход компонента.',
accent: 'var(--accent-sky)',
component: ComponentInputFlowNode,
createData(overrides = {}) {
return attachRuntime(COMPONENT_INPUT_TYPE, {
title: 'Вход компонента',
width: 240,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime(COMPONENT_INPUT_TYPE, {
title: data.title || 'Вход компонента',
externalHandleId: typeof data.externalHandleId === 'string' ? data.externalHandleId : '',
externalLabel: typeof data.externalLabel === 'string' ? data.externalLabel : '',
width: toNumber(data.width, 240),
});
},
getHandles(data = {}) {
return {
inputs: [],
outputs: [{ id: 'output', label: data.externalLabel || 'output', dataType: '*' }],
};
},
async execute({ externalInputValue, externalInputValues, node }) {
const externalHandleId = node?.data?.externalHandleId;
const hasMappedValue =
externalInputValues &&
typeof externalInputValues === 'object' &&
externalHandleId &&
Object.prototype.hasOwnProperty.call(externalInputValues, externalHandleId);
const value = hasMappedValue ? externalInputValues[externalHandleId] : externalInputValue ?? null;
return {
outputs: {
output: value,
},
runtime: {
value,
},
};
},
},
[COMPONENT_OUTPUT_TYPE]: {
title: 'Выход компонента',
description: 'Внутренний выход компонента.',
accent: 'var(--accent-coral)',
component: ComponentOutputFlowNode,
createData(overrides = {}) {
return attachRuntime(COMPONENT_OUTPUT_TYPE, {
title: 'Выход компонента',
width: 240,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime(COMPONENT_OUTPUT_TYPE, {
title: data.title || 'Выход компонента',
externalHandleId: typeof data.externalHandleId === 'string' ? data.externalHandleId : '',
externalLabel: typeof data.externalLabel === 'string' ? data.externalLabel : '',
width: toNumber(data.width, 240),
});
},
getHandles(data = {}) {
return {
inputs: [{ id: 'input', label: data.externalLabel || 'input', dataType: '*' }],
outputs: [],
};
},
async execute({ inputs }) {
const value = inputs.input ?? null;
return {
outputs: {},
runtime: {
value,
},
};
},
},
[COMPONENT_NODE_TYPE]: {
title: 'Компонент',
description: 'Сворачиваемый вложенный граф.',
accent: 'var(--accent-violet)',
component: ComponentFlowNode,
createData(overrides = {}) {
return attachRuntime(COMPONENT_NODE_TYPE, {
title: 'Компонент',
description: '',
libraryId: '',
subgraph: createComponentSubgraph(),
width: 360,
...overrides,
});
},
hydrateData(data = {}) {
return attachRuntime(COMPONENT_NODE_TYPE, {
title: typeof data.title === 'string' ? data.title : 'Компонент',
description: data.description || '',
libraryId: typeof data.libraryId === 'string' ? data.libraryId : '',
subgraph: hydrateSubgraph(data.subgraph),
width: toNumber(data.width, 360),
});
},
getHandles(data = {}) {
return {
inputs: getComponentBoundaryHandles(data.subgraph, COMPONENT_INPUT_TYPE, 'input', 'input'),
outputs: getComponentBoundaryHandles(data.subgraph, COMPONENT_OUTPUT_TYPE, 'output', 'output'),
};
},
},
};
export const NODE_TYPE_ORDER = [
'basic/start',
'basic/update-role',
'basic/question',
'basic/assistant-message',
'basic/save-memory',
'basic/semantic-branch',
'basic/knowledge-answer',
'basic/counter',
'basic/wait',
'basic/restart',
'basic/dialog',
'basic/text',
'basic/request',
'basic/classifier',
'basic/randomlist',
'basic/send-websocket',
'basic/json-parser',
'basic/ifelse',
];
export const nodeTypes = Object.freeze(
Object.fromEntries(
Object.entries(NODE_DEFINITIONS).map(([type, definition]) => [type, definition.component]),
),
);
export function getNodeDefinition(type) {
return NODE_DEFINITIONS[type];
}
export function isComponentNodeType(type) {
return type === COMPONENT_NODE_TYPE;
}
export function isComponentBoundaryType(type) {
return type === COMPONENT_INPUT_TYPE || type === COMPONENT_OUTPUT_TYPE;
}
export function isSystemNodeType(type) {
return typeof type === 'string' && type.startsWith('system/');
}
export function listNodeDefinitions() {
return NODE_TYPE_ORDER.map((type) => ({
type,
title: NODE_DEFINITIONS[type].title,
description: NODE_DEFINITIONS[type].description,
}));
}
export function getNodeHandles(type, data) {
const definition = getNodeDefinition(type);
return definition ? definition.getHandles(data) : { inputs: [], outputs: [] };
}
export function getNodeAccent(type) {
return NODE_DEFINITIONS[type]?.accent || 'var(--accent-mint)';
}
export function buildNodeStyle(type, data, existingStyle = {}) {
const width = data?.width || NODE_DEFINITIONS[type]?.createData?.().width || 320;
return {
...existingStyle,
width,
};
}
function hydrateSubgraph(graph) {
if (!graph || typeof graph !== 'object') {
return createComponentSubgraph();
}
return {
nodes: (graph.nodes || []).map((node) => hydrateNode(node)),
edges: (graph.edges || []).map((edge) => ({
...edge,
id: String(edge.id),
source: String(edge.source),
target: String(edge.target),
})),
viewport: graph.viewport || null,
};
}
export function hydrateNode(node) {
const definition = getNodeDefinition(node.type);
if (!definition) {
return {
...node,
id: String(node.id),
position: node.position || { x: 0, y: 0 },
data: {
...(node.data || {}),
runtime: createRuntime(node.type),
},
dragHandle: '.node-shell__header',
};
}
const data = definition.hydrateData(node.data || {});
const runtime = {
...(data.runtime || createRuntime(node.type)),
...((node.data && node.data.runtime) || {}),
};
return {
...node,
id: String(node.id),
position: node.position || { x: 0, y: 0 },
data: {
...data,
runtime,
},
style: buildNodeStyle(node.type, data, node.style),
dragHandle: '.node-shell__header',
};
}
export function createNodeInstance(type, position = { x: 0, y: 0 }, overrides = {}) {
const definition = getNodeDefinition(type);
if (!definition) {
throw new Error(`Unknown node type: ${type}`);
}
const data = definition.createData(overrides.data || {});
return hydrateNode({
id: overrides.id || createBrowserId('node'),
type,
position,
data,
style: overrides.style || {},
});
}
export function stripRuntimeFromNode(node) {
const { runtime, ...persistedData } = node.data || {};
if (isComponentNodeType(node.type)) {
persistedData.subgraph = {
nodes: (persistedData.subgraph?.nodes || []).map((subgraphNode) => stripRuntimeFromNode(subgraphNode)),
edges: (persistedData.subgraph?.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: persistedData.subgraph?.viewport || null,
};
}
return {
...node,
data: persistedData,
style: buildNodeStyle(node.type, persistedData, node.style),
};
}
export function createComponentSubgraph() {
return {
nodes: [
createNodeInstance(COMPONENT_INPUT_TYPE, { x: 40, y: 120 }, {
data: {
externalHandleId: 'input',
externalLabel: 'input',
},
}),
createNodeInstance(COMPONENT_OUTPUT_TYPE, { x: 520, y: 120 }, {
data: {
externalHandleId: 'output',
externalLabel: 'output',
},
}),
],
edges: [],
viewport: null,
};
}