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, }; }