UNPKG

n8n-nodes-roundrobin

Version:

n8n node to store and retrieve messages in a round-robin fashion, particularly for LLM conversation loops with multiple personas

1,056 lines (1,055 loc) 104 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RoundRobin = void 0; const n8n_workflow_1 = require("n8n-workflow"); const ExternalStorage_1 = require("./ExternalStorage"); function calculateRoundCount(messages, spotCount) { if (!messages.length || !spotCount) return 0; const messagesBySpot = {}; messages.forEach(msg => { if (messagesBySpot[msg.spotIndex] === undefined) { messagesBySpot[msg.spotIndex] = 0; } messagesBySpot[msg.spotIndex]++; }); const spotsWithMessages = Object.keys(messagesBySpot).length; if (spotsWithMessages < spotCount) return 0; const minCount = Math.min(...Object.values(messagesBySpot)); return minCount; } function hasReachedRoundLimit(messages, spotCount, maxRounds) { if (maxRounds <= 0) return false; const currentRounds = calculateRoundCount(messages, spotCount); return currentRounds >= maxRounds; } class RoundRobin { constructor() { this.description = { displayName: 'Round Robin', name: 'roundRobin', icon: 'file:roundrobin.svg', group: ['transform'], version: 1, subtitle: '={{ $parameter["mode"] }}', description: 'Manage conversational loops between multiple participants for LLM workflows', defaults: { name: 'Round Robin', color: '#ff9900', }, inputs: ['main'], outputs: ['main'], properties: [ { displayName: 'Operation Mode', name: 'mode', type: 'options', options: [ { name: 'Setup Conversation', value: 'setup', description: 'Initialize a new conversation with empty state', }, { name: 'Run Conversation', value: 'run', description: 'Process conversation turns and manage round tracking', }, { name: 'Store Message', value: 'store', description: 'Add a new message to the conversation', }, { name: 'Retrieve Conversation', value: 'retrieve', description: 'Get the stored conversation history', }, { name: 'Recall Memory', value: 'recall', description: 'Get the raw memory items in their current state', }, { name: 'Clear Conversation', value: 'clear', description: 'Reset the conversation history', }, ], default: 'store', description: 'Select the operation you want to perform', }, { displayName: 'Binary Input Property', name: 'binaryInputProperty', type: 'string', default: 'data', displayOptions: { show: { mode: ['retrieve', 'clear', 'store', 'run'], }, }, description: 'Name of the binary property containing the conversation data', placeholder: 'data', hint: 'This must match the binary output property name from previous Round Robin nodes' }, { displayName: 'Conversation ID', name: 'storageId', type: 'string', default: '', displayOptions: { show: { mode: ['store', 'retrieve', 'clear'], }, }, description: 'Optional: Use a consistent ID to maintain multiple separate conversations in the same workflow', placeholder: 'my-support-chat', hint: 'Leave empty to use workflow ID as default (most common scenario)' }, { displayName: 'Conversation Setup', name: 'conversationSetupHeading', type: 'notice', default: 'Define the participants and structure of your conversation loop', displayOptions: { show: { mode: ['store'], }, }, }, { displayName: 'Number of Participants', name: 'spotCount', type: 'number', default: 3, required: true, displayOptions: { show: { mode: ['store'], }, }, description: 'How many different roles or participants in this conversation', hint: 'For a typical AI chat, use 3 for User+Assistant+System' }, { displayName: 'Maximum Conversation Rounds', name: 'maxRounds', type: 'number', default: 0, displayOptions: { show: { mode: ['store'], }, }, description: 'Optional: Limit the number of full conversation loops (0 = unlimited)', hint: 'Each round consists of one message from each participant' }, { displayName: 'Participant Roles', name: 'roles', placeholder: 'Add Role', type: 'fixedCollection', typeOptions: { multipleValues: true, sortable: true, }, default: { values: [ { name: 'User', description: 'The human user in the conversation', color: '#6E9BF7', isEnabled: true }, { name: 'Assistant', description: 'The AI assistant in the conversation', color: '#9E78FF', isEnabled: true }, { name: 'System', description: 'System instructions for the AI model', color: '#FF9900', isEnabled: true } ] }, displayOptions: { show: { mode: ['store'], }, }, description: 'Define the roles for each participant in the conversation', options: [ { name: 'values', displayName: 'Roles', values: [ { displayName: 'Role Name', name: 'name', type: 'string', default: '', description: 'Name of this participant (e.g., User, Assistant, System)', required: true, placeholder: 'Assistant' }, { displayName: 'Description', name: 'description', type: 'string', typeOptions: { rows: 2, }, default: '', description: 'Optional description of this role', placeholder: 'The AI helper that responds to user queries' }, { displayName: 'Color', name: 'color', type: 'color', default: '#ff9900', description: 'Color for visual identification in the workflow', }, { displayName: 'System Prompt Template', name: 'systemPrompt', type: 'string', typeOptions: { rows: 3, }, default: '', description: 'Optional system prompt to guide this role (most useful for System role)', placeholder: 'You are a helpful AI assistant that responds concisely.' }, { displayName: 'Enabled', name: 'isEnabled', type: 'boolean', default: true, description: 'Whether this role is active in the conversation', }, ], }, ], }, { displayName: 'Current Participant', name: 'spotIndex', type: 'number', default: 0, displayOptions: { show: { mode: ['store'], }, }, description: 'Which participant is sending this message (0-based index)', required: true, hint: '0 = first role, 1 = second role, etc.' }, { displayName: 'Message Content Field', name: 'inputField', type: 'string', default: 'output', displayOptions: { show: { mode: ['store'], }, }, description: 'Name of the input field containing the message to store', required: true, placeholder: 'output', hint: 'Usually "output" from AI nodes or "message" from user inputs' }, { displayName: 'Usage Example', name: 'storeExample', type: 'notice', default: '<b>Typical ChatGPT Conversation Setup:</b><br>• User (index 0): Human inputs<br>• Assistant (index 1): AI responses<br>• System (index 2): Instructions to guide the AI', displayOptions: { show: { mode: ['store'], }, } }, { displayName: 'Conversation Execution', name: 'runHeading', type: 'notice', default: 'Process and manage conversation turns by participant role', displayOptions: { show: { mode: ['run'], }, }, }, { displayName: 'Current Role Index', name: 'currentRoleIndex', type: 'number', default: 0, displayOptions: { show: { mode: ['run'], }, }, description: 'Index of the current role taking a turn (0-based)', hint: 'Typically passed from previous node or setup' }, { displayName: 'Message Content Field', name: 'inputField', type: 'string', default: 'output', displayOptions: { show: { mode: ['run', 'store'], }, }, description: 'Name of the input field containing the message to include', required: true, placeholder: 'output', hint: 'Usually "output" from AI nodes or "message" from user inputs' }, { displayName: 'Auto Advance Turn', name: 'autoAdvance', type: 'boolean', default: true, displayOptions: { show: { mode: ['run'], }, }, description: 'Whether to automatically advance to the next participant after storing', }, { displayName: 'Track Rounds', name: 'trackRounds', type: 'boolean', default: true, displayOptions: { show: { mode: ['run'], }, }, description: 'Whether to track and enforce round limits', }, { displayName: 'Continue Until Complete', name: 'continueUntilComplete', type: 'boolean', default: true, displayOptions: { show: { mode: ['run'], }, }, description: 'Whether to continue conversation until max rounds is reached', hint: 'Set to false to run one turn at a time' }, { displayName: 'Output Configuration', name: 'retrieveHeading', type: 'notice', default: 'Configure how the conversation history should be formatted', displayOptions: { show: { mode: ['retrieve'], }, }, }, { displayName: 'Output Format', name: 'outputFormat', type: 'options', options: [ { name: 'Conversation History for LLM', value: 'conversationHistory', description: 'Format suitable for sending directly to AI models', }, { name: 'Message Array', value: 'array', description: 'Simple array of all messages with role and content', }, { name: 'Grouped by Role', value: 'object', description: 'Messages organized by participant role', }, ], default: 'conversationHistory', displayOptions: { show: { mode: ['retrieve'], }, }, description: 'How to structure the output data', }, { displayName: 'LLM Platform', name: 'llmPlatform', type: 'options', options: [ { name: 'OpenAI (ChatGPT)', value: 'openai', description: 'Format compatible with OpenAI models (GPT-3.5, GPT-4, etc.)', }, { name: 'Anthropic (Claude)', value: 'anthropic', description: 'Format for Anthropic Claude models', }, { name: 'Google (Gemini)', value: 'google', description: 'Format for Google Gemini models', }, { name: 'Generic', value: 'generic', description: 'Basic format compatible with most LLMs', }, ], default: 'openai', displayOptions: { show: { mode: ['retrieve'], outputFormat: ['conversationHistory'], }, }, description: 'Which AI model provider this conversation will be sent to', }, { displayName: 'Include System Instructions', name: 'includeSystemPrompt', type: 'boolean', default: true, displayOptions: { show: { mode: ['retrieve'], outputFormat: ['conversationHistory'], }, }, description: 'Whether to include system role messages in the conversation history', }, { displayName: 'System Instructions Position', name: 'systemPromptPosition', type: 'options', options: [ { name: 'Start of Conversation', value: 'start', description: 'Place system instructions at the beginning (recommended)', }, { name: 'End of Conversation', value: 'end', description: 'Place system instructions at the end', }, ], default: 'start', displayOptions: { show: { mode: ['retrieve'], outputFormat: ['conversationHistory'], includeSystemPrompt: [true], }, }, description: 'Where to position system instructions in the conversation', }, { displayName: 'Simplified Output', name: 'simplifyOutput', type: 'boolean', default: true, displayOptions: { show: { mode: ['retrieve'], }, }, description: 'Whether to provide clean output with just essential fields (recommended)' }, { displayName: 'Maximum Messages', name: 'maxMessages', type: 'number', default: 0, displayOptions: { show: { mode: ['retrieve'], }, }, description: 'Maximum number of recent messages to include (0 = all messages)', hint: 'Useful for limiting token usage with large conversation histories' }, { displayName: 'Conversation Setup', name: 'setupOptions', type: 'notice', default: 'Set up a new multi-participant conversation with all participants sharing the same conversation history.', displayOptions: { show: { mode: ['setup'], }, }, }, { displayName: 'Auto-Detect Input Format', name: 'autoDetectInput', type: 'boolean', default: true, displayOptions: { show: { mode: ['setup'], }, }, description: 'Whether to automatically detect and use formats from the input data (recommended)', hint: 'The node will look for conversation_id, RR_title, roles, participants, etc. in the input data', }, { displayName: 'Conversation ID', name: 'conversationId', type: 'string', default: '={{ $json.conversation_id || $json.output?.conversation_id || "" }}', displayOptions: { show: { mode: ['setup'], }, }, description: 'Unique identifier for this conversation', placeholder: 'lobby-maintenance-123', }, { displayName: 'Conversation Title', name: 'conversationTitle', type: 'string', default: '={{ $json.RR_title || $json.output?.RR_title || "" }}', displayOptions: { show: { mode: ['setup'], }, }, description: 'Title for this conversation', placeholder: 'Meeting between CEO and Janitor', }, { displayName: 'Number of Participants', name: 'spotCount', type: 'number', displayOptions: { show: { mode: ['setup', 'store'], }, }, default: '={{ $json.number_of_participants || $json.output?.number_of_participants || 2 }}', required: true, description: 'How many conversation spots/roles to reserve', hint: 'This is the number of distinct speakers in the conversation' }, { displayName: 'Maximum Rounds', name: 'maxRounds', type: 'number', default: '={{ $json.total_rounds || $json.output?.total_rounds || 5 }}', displayOptions: { show: { mode: ['setup'], }, }, description: 'Maximum number of full conversation rounds to perform', hint: 'A round consists of one message from each participant' }, { displayName: 'Conversation Context', name: 'conversationContext', type: 'string', typeOptions: { rows: 4 }, default: '={{ $json.conversation_context || $json.output?.conversation_context || "" }}', displayOptions: { show: { mode: ['setup'], }, }, description: 'Initial context or instructions for this conversation', placeholder: 'The CEO and Janitor need to discuss cleanliness issues in the lobby area...' }, { displayName: 'Talking Points', name: 'talkingPoints', type: 'string', typeOptions: { rows: 4 }, default: '={{ Array.isArray($json.talking_points || $json.output?.talking_points) ? ($json.talking_points || $json.output?.talking_points).join("\\n") : "" }}', displayOptions: { show: { mode: ['setup'], }, }, description: 'List of topics to be discussed (one per line)', placeholder: 'Lobby cleanliness standards\nScheduling of maintenance\nBudget concerns' }, { displayName: 'Binary Output Property', name: 'binaryOutputProperty', type: 'string', default: 'data', displayOptions: { show: { mode: ['setup', 'store', 'retrieve', 'clear'], }, }, description: 'Name of the binary property where conversation data will be stored', placeholder: 'data', hint: 'Use the same name for all Round Robin nodes in your workflow' }, { displayName: 'Define Roles', name: 'setupRoles', type: 'boolean', displayOptions: { show: { mode: ['setup'], }, }, default: true, description: 'Whether to define custom roles during setup', }, { displayName: 'Use Roles from Input', name: 'useInputRoles', type: 'boolean', displayOptions: { show: { mode: ['setup'], setupRoles: [true], }, }, default: true, description: 'Whether to use role definitions from input data', }, { displayName: 'Input Roles Path', name: 'inputRolesPath', type: 'string', default: 'roles', displayOptions: { show: { mode: ['setup'], setupRoles: [true], useInputRoles: [true], }, }, description: 'Property path to roles array in the input data', placeholder: 'roles', hint: 'For output.roles use "output.roles"' }, { displayName: 'Roles', name: 'roles', placeholder: 'Add Role', type: 'fixedCollection', default: {}, typeOptions: { multipleValues: true, }, displayOptions: { show: { mode: ['setup', 'store'], setupRoles: [true], useInputRoles: [false], }, }, options: [ { name: 'values', displayName: 'Roles', values: [ { displayName: 'Name', name: 'name', type: 'string', default: '', placeholder: 'User', description: 'Name of this role/persona', }, { displayName: 'Description', name: 'description', type: 'string', default: '', placeholder: 'User messages', description: 'Description of this role/persona', }, { displayName: 'Color', name: 'color', type: 'color', default: '#ff6b6b', description: 'Color associated with this role', }, { displayName: 'System Prompt', name: 'systemPrompt', type: 'string', default: '', typeOptions: { rows: 3, }, description: 'System prompt associated with this role (optional)', }, { displayName: 'Is Enabled', name: 'isEnabled', type: 'boolean', default: true, description: 'Whether this role should be included in conversation outputs', }, ], }, ], description: 'Define the different participants in the conversation', }, { displayName: 'Memory Recall', name: 'recallOptions', type: 'notice', default: 'Recall the raw memory state of the conversation to inspect or debug its contents.', displayOptions: { show: { mode: ['recall'], }, }, }, { displayName: 'Binary Input Property', name: 'binaryInputProperty', type: 'string', default: 'data', displayOptions: { show: { mode: ["retrieve", "clear", "store", "run"], }, }, description: 'Name of the binary property containing conversation data', placeholder: 'data', hint: 'This should match the binary output property of the previous RoundRobin node' }, { displayName: 'Include Messages', name: 'includeMessages', type: 'boolean', default: true, displayOptions: { show: { mode: ['recall'], }, }, description: 'Whether to include message history in the recall output', }, { displayName: 'Include Roles', name: 'includeRoles', type: 'boolean', default: true, displayOptions: { show: { mode: ['recall'], }, }, description: 'Whether to include role definitions in the recall output', }, { displayName: 'Include Metadata', name: 'includeMetadata', type: 'boolean', default: true, displayOptions: { show: { mode: ['recall'], }, }, description: 'Whether to include conversation metadata in the recall output', }, { displayName: 'Memory Selection', name: 'recallOptions', type: 'notice', default: 'Select which conversation memory to retrieve.', displayOptions: { show: { mode: ['recall'], }, }, }, { displayName: 'Conversation Memory', name: 'memoryId', type: 'resourceLocator', default: { mode: 'list', value: '' }, required: true, displayOptions: { show: { mode: ['recall'], }, }, description: 'Choose which conversation memory to recall', modes: [ { displayName: 'From List', name: 'list', type: 'list', typeOptions: { searchListMethod: 'searchConversationMemories', searchable: true, }, }, { displayName: 'By ID', name: 'id', type: 'string', validation: [ { type: 'regex', properties: { regex: '[a-zA-Z0-9_-]+', errorMessage: 'Not a valid conversation ID', }, }, ], }, ] }, ], }; } async execute() { var _a; const items = this.getInputData(); const returnData = []; const mode = this.getNodeParameter('mode', 0); try { const nodeName = this.getNode().name; let workflowId = (_a = this.getWorkflow()) === null || _a === void 0 ? void 0 : _a.id; console.log(`[Execution] Raw Workflow ID: ${workflowId}`); if (!workflowId) { console.warn(`[Execution] Warning: Workflow ID is undefined. Falling back to node name ('${nodeName}') for storage key.`); workflowId = nodeName; } else { workflowId = String(workflowId); } const userStorageId = this.getNodeParameter('storageId', 0, ''); if (userStorageId) { console.log(`[Execution] Using user-provided Storage ID: "${userStorageId}" instead of workflow ID`); workflowId = userStorageId; } console.log(`[Execution] Using effective ID for storage: ${workflowId}`); await RoundRobin.handleBinaryStorageExecution(this, items, returnData, mode, workflowId); return [returnData]; } catch (error) { if (error instanceof n8n_workflow_1.NodeOperationError) { throw error; } throw new n8n_workflow_1.NodeOperationError(this.getNode(), error instanceof Error ? error.message : 'An unknown error occurred'); } } static async handleBinaryStorageExecution(executeFunctions, items, returnData, mode, storageId) { var _a, _b, _c, _d, _e, _f; console.log(`[Binary Storage] Using internal binary storage with ID: ${storageId}`); const storageManager = (0, ExternalStorage_1.createStorageManager)(executeFunctions, 'binary', storageId); const binaryOutputProperty = executeFunctions.getNodeParameter('binaryOutputProperty', 0, 'data'); if (mode === 'setup') { try { console.log('[Binary Storage] Setting up new conversation'); let spotCount = executeFunctions.getNodeParameter('spotCount', 0, 3); const maxRounds = executeFunctions.getNodeParameter('maxRounds', 0, 5); const conversationTitle = executeFunctions.getNodeParameter('conversationTitle', 0, ''); const conversationContext = executeFunctions.getNodeParameter('conversationContext', 0, ''); const talkingPointsStr = executeFunctions.getNodeParameter('talkingPoints', 0, ''); const conversationId = executeFunctions.getNodeParameter('conversationId', 0, ''); const autoDetectInput = executeFunctions.getNodeParameter('autoDetectInput', 0, true); const talkingPoints = talkingPointsStr ? talkingPointsStr.split('\n').filter(line => line.trim() !== '') : []; let uniqueConvoId = conversationId; if (!uniqueConvoId) { const timestamp = Date.now(); const date = new Date(timestamp).toISOString().split('T')[0]; const title = conversationTitle ? conversationTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 20) : 'conversation'; uniqueConvoId = `${title}-${date}-${Math.random().toString(36).substring(2, 7)}`; } console.log(`[Binary Storage] Conversation ID: ${uniqueConvoId}`); const setupRoles = executeFunctions.getNodeParameter('setupRoles', 0, true); let roles = getDefaultRoles(); let participants = []; if (setupRoles) { const useInputRoles = executeFunctions.getNodeParameter('useInputRoles', 0, true); if (useInputRoles && autoDetectInput) { const inputRolesPath = executeFunctions.getNodeParameter('inputRolesPath', 0, 'roles'); let inputRoles = []; const possibleRolePaths = [ 'output.roles', 'roles', 'data.roles', 'data.output.roles', 'conversation.roles', 'result.roles', 'results.roles', 'response.roles', 'payload.roles', 'payload.output.roles' ]; let rolesFound = false; console.log(`[Binary Storage] Searching for roles in ${possibleRolePaths.length} possible locations`); for (const path of possibleRolePaths) { const rolesData = getNestedProperty(items[0].json, path); if (rolesData && Array.isArray(rolesData) && rolesData.length > 0) { inputRoles = rolesData; console.log(`[Binary Storage] Found ${inputRoles.length} roles at path: ${path}`); rolesFound = true; break; } } if (!rolesFound && inputRolesPath) { const rolesData = getNestedProperty(items[0].json, inputRolesPath); if (rolesData && Array.isArray(rolesData) && rolesData.length > 0) { inputRoles = rolesData; console.log(`[Binary Storage] Found ${inputRoles.length} roles at user path: ${inputRolesPath}`); rolesFound = true; } } if (!rolesFound && Object.keys(items[0].json).length > 0) { console.log(`[Binary Storage] No roles found in common paths, trying deep search`); const foundRoles = findRolesDeep(items[0].json); if (foundRoles && foundRoles.length > 0) { inputRoles = foundRoles; console.log(`[Binary Storage] Found ${inputRoles.length} roles via deep search`); rolesFound = true; } } if (inputRoles.length > 0) { console.log(`[Binary Storage] Found ${inputRoles.length} roles from input data`); const mappedRoles = inputRoles.map(role => { console.log(`[Binary Storage] Processing role: ${JSON.stringify(role.name || 'unnamed')}`); return { name: role.name || role.role_name || role.roleName || role.title || '', description: role.description || role.desc || role.roleDescription || '', color: role.color || role.roleColor || role.hexColor || '#ff9900', systemPrompt: role.system_prompt || role.systemPrompt || role.prompt || role.instructions || '', isEnabled: role.is_enabled !== undefined ? role.is_enabled : (role.isEnabled !== undefined ? role.isEnabled : (role.enabled !== undefined ? role.enabled : true)), expertise: [] }; }); if (mappedRoles.length > 0) { roles = mappedRoles; const inputSpotCount = getNestedProperty(items[0].json, 'output.number_of_participants') || getNestedProperty(items[0].json, 'number_of_participants'); if (inputSpotCount && typeof inputSpotCount === 'number') { console.log(`[Binary Storage] Using number_of_participants from input: ${inputSpotCount}`); spotCount = inputSpotCount; } else if (mappedRoles.length > spotCount) { console.log(`[Binary Storage] Adjusting spotCount from ${spotCount} to ${mappedRoles.length} to match detected roles`); spotCount = mappedRoles.length; } } else { console.log('[Binary Storage] Input roles were invalid, using defaults'); } } else { console.log(`[Binary Storage] No input roles found, using defaults`); } if (autoDetectInput) { try { const possiblePaths = [ 'output.participants', 'participants', 'data.participants', 'data.output.participants', 'conversation.participants', 'result.participants', 'results.participants', 'response.participants', 'payload.participants', 'payload.output.participants', 'users', 'members', 'attendees' ]; let participantsArray = []; console.log(`[Binary Storage] Searching for participants in ${possiblePaths.length} possible locations`); for (const path of possiblePaths) { const pathData = getNestedProperty(items[0].json, path); if (pathData && Array.isArray(pathData)) { participantsArray = pathData; console.log(`[Binary Storage] Found participants at path: ${path}`); break; } } if (participantsArray.length === 0 && Object.keys(items[0].json).length > 0) { console.log(`[Binary Storage] No participants found in common paths, trying deep search`); const foundParticipants = findParticipantsDeep(items[0].json); if (foundParticipants && foundParticipants.length > 0) { participantsArray = foundParticipants; console.log(`[Binary Storage] Found ${participantsArray.length} participants via deep search`); } } if (participantsArray.length > 0) { console.log(`[Binary Storage] Found ${participantsArray.length} participants in input data`); participants = participantsArray.map(p => p); participants.forEach((p, i) => { console.log(`[Binary Storage] Participant ${i + 1}: role=${p.role}, id=${p.participant_id || p.id}`); }); } } catch (error) { console.log(`[Binary Storage] Error processing participants: ${error.message}`); } } } else { const rolesCollection = executeFunctions.getNodeParameter('roles', 0); const customRoles = processRoles(rolesCollection); if (customRoles.length > 0) { roles = customRoles; console.log(`[Binary Storage] Using ${roles.length} custom defined roles`); } else { console.log('[Binary Storage] No custom roles defined, using defaults'); } } } else { console.log('[Binary Storage] Custom roles disabled, using defaults'); } const storageMetadata = { title: conversationTitle, context: conversationContext, roundCount: 0, maxRounds: maxRounds, lastUpdated: Date.now(), isNewConversation: true, conversationId: uniqueConvoId, talkingPoints: talkingPoints, created: Date.now(), createdBy: executeFunctions.getNode().name,