Spaces:
Sleeping
Sleeping
| 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, | |
| }; | |
| } | |