UNPKG

n8n-nodes-agent-chat-interface

Version:

N8N custom node that serves a React chat interface as static content

600 lines (599 loc) 28.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ChatInterfaceNode = void 0; const n8n_workflow_1 = require("n8n-workflow"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const crypto = __importStar(require("crypto")); const mime = __importStar(require("mime-types")); const ChatInterfaceBuilder_1 = require("./ChatInterfaceBuilder"); const RuntimeThemeInjector_1 = require("./RuntimeThemeInjector"); class ChatInterfaceNode { constructor() { this.description = { displayName: 'Chat Interface', name: 'chatInterface', icon: 'file:chatInterface.svg', group: ['output'], version: 1, description: 'Serves a customizable React chat interface as static content', defaults: { name: 'Chat Interface', }, inputs: ["main" /* NodeConnectionType.Main */], outputs: ["main" /* NodeConnectionType.Main */], properties: [ // Basic Configuration { displayName: 'Request Path', name: 'requestPath', type: 'string', default: '/', placeholder: '/', description: 'The path to serve (e.g., /, /assets/style.css)', }, { displayName: 'Chat API URL', name: 'chatApiUrl', type: 'string', default: 'https://api.example.com/chat', description: 'API URL for the chat backend', }, { displayName: 'Chat Title', name: 'chatTitle', type: 'string', default: 'AI Assistant Chat', description: 'Title displayed in the chat interface', }, { displayName: 'Webhook URL', name: 'webhookUrl', type: 'string', default: '', placeholder: 'https://n8n.example.com/webhook/your-webhook-id', description: 'Full webhook URL for static file references. All paths will be converted to webhook calls with ?path= query parameter.', }, { displayName: 'Agent SID', name: 'agentSid', type: 'string', default: '', placeholder: 'user@company.com', description: 'Optional user identifier (e.g., email, username, or ID) to include in chat API requests', }, { displayName: 'Custom Favicon', name: 'customFavicon', type: 'string', default: '', placeholder: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...', description: 'Custom favicon as base64 encoded PNG image (e.g., data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...)', }, { displayName: 'Welcome Message', name: 'welcomeMessage', type: 'string', default: '', placeholder: '{"output":"Welcome! How can I help you today?","options":["Get started","Learn more"],"disableInput":true}', description: 'Optional welcome message as ChatResponse JSON. Automatically displayed when user opens chat. Supports all ChatResponse features: output, options, references, disableInput, showFootnote.', }, // Footnote Configuration { displayName: 'Enable Agent Message Footnote', name: 'footnoteEnabled', type: 'boolean', default: false, description: 'Enable footnote display under agent messages', }, { displayName: 'Agent Message Footnote', name: 'agentMessageFootnote', type: 'string', default: '', placeholder: 'This response was generated by AI. Please verify important information.', description: 'Optional footnote message to display under every agent message (supports markdown)', displayOptions: { show: { footnoteEnabled: [true], }, }, }, { displayName: 'Footnote Title', name: 'footnoteTitle', type: 'string', default: 'Disclaimer', placeholder: 'Disclaimer', description: 'Title to display above the footnote content', displayOptions: { show: { footnoteEnabled: [true], }, }, }, { displayName: 'Footnote Collapsible', name: 'footnoteCollapsible', type: 'boolean', default: true, description: 'Allow users to expand/collapse the footnote content', displayOptions: { show: { footnoteEnabled: [true], }, }, }, { displayName: 'Show Footnote', name: 'footnoteShowTiming', type: 'options', default: 'after_response', options: [ { name: 'Immediately', value: 'immediately', description: 'Show footnote as soon as message appears', }, { name: 'After Response Received', value: 'after_response', description: 'Show footnote only after the AI response is fully received', }, { name: 'Let Agent Decide', value: 'let_agent_decide', description: 'Show footnote only when API response includes showFootnote: true', }, ], description: 'When to display the footnote in the message timeline', displayOptions: { show: { footnoteEnabled: [true], }, }, }, // Feedback Configuration { displayName: 'Enable Feedback', name: 'feedbackEnabled', type: 'boolean', default: false, description: 'Enable thumbs up/down feedback icons for AI responses', }, { displayName: 'Thumbs Up Feedback URL', name: 'thumbsUpUrl', type: 'string', default: '', placeholder: 'https://api.example.com/feedback/positive', description: 'URL to POST positive feedback data', displayOptions: { show: { feedbackEnabled: [true], }, }, }, { displayName: 'Thumbs Down Feedback URL', name: 'thumbsDownUrl', type: 'string', default: '', placeholder: 'https://api.example.com/feedback/negative', description: 'URL to POST negative feedback data', displayOptions: { show: { feedbackEnabled: [true], }, }, }, // Light Theme Colors { displayName: 'Light Theme Colors', name: 'lightThemeColors', type: 'collection', placeholder: 'Add Color', default: {}, options: [ { displayName: 'Background', name: 'background', type: 'string', default: '0 0% 100%', description: 'Background color in HSL format (without hsl() wrapper)', }, { displayName: 'Foreground', name: 'foreground', type: 'string', default: '0 0% 3.9%', description: 'Text color in HSL format', }, { displayName: 'Card', name: 'card', type: 'string', default: '0 0% 100%', description: 'Card background color in HSL format', }, { displayName: 'Card Foreground', name: 'cardForeground', type: 'string', default: '0 0% 3.9%', description: 'Card text color in HSL format', }, { displayName: 'Primary', name: 'primary', type: 'string', default: '220 98% 61%', description: 'Primary color for buttons and accents in HSL format', }, { displayName: 'Primary Foreground', name: 'primaryForeground', type: 'string', default: '0 0% 98%', description: 'Primary text color in HSL format', }, { displayName: 'Secondary', name: 'secondary', type: 'string', default: '0 0% 96.1%', description: 'Secondary color in HSL format', }, { displayName: 'Secondary Foreground', name: 'secondaryForeground', type: 'string', default: '0 0% 9%', description: 'Secondary text color in HSL format', }, { displayName: 'Muted', name: 'muted', type: 'string', default: '0 0% 96.1%', description: 'Muted color in HSL format', }, { displayName: 'Muted Foreground', name: 'mutedForeground', type: 'string', default: '0 0% 45.1%', description: 'Muted text color in HSL format', }, { displayName: 'Border', name: 'border', type: 'string', default: '0 0% 89.8%', description: 'Border color in HSL format', }, { displayName: 'Input', name: 'input', type: 'string', default: '0 0% 89.8%', description: 'Input field color in HSL format', }, ], }, // Dark Theme Colors { displayName: 'Dark Theme Colors', name: 'darkThemeColors', type: 'collection', placeholder: 'Add Color', default: {}, options: [ { displayName: 'Background', name: 'background', type: 'string', default: '0 0% 3.9%', description: 'Dark background color in HSL format', }, { displayName: 'Foreground', name: 'foreground', type: 'string', default: '0 0% 98%', description: 'Dark text color in HSL format', }, { displayName: 'Card', name: 'card', type: 'string', default: '0 0% 3.9%', description: 'Dark card background color in HSL format', }, { displayName: 'Card Foreground', name: 'cardForeground', type: 'string', default: '0 0% 98%', description: 'Dark card text color in HSL format', }, { displayName: 'Primary', name: 'primary', type: 'string', default: '220 98% 61%', description: 'Dark primary color in HSL format', }, { displayName: 'Primary Foreground', name: 'primaryForeground', type: 'string', default: '0 0% 9%', description: 'Dark primary text color in HSL format', }, { displayName: 'Secondary', name: 'secondary', type: 'string', default: '0 0% 14.9%', description: 'Dark secondary color in HSL format', }, { displayName: 'Secondary Foreground', name: 'secondaryForeground', type: 'string', default: '0 0% 98%', description: 'Dark secondary text color in HSL format', }, { displayName: 'Muted', name: 'muted', type: 'string', default: '0 0% 14.9%', description: 'Dark muted color in HSL format', }, { displayName: 'Muted Foreground', name: 'mutedForeground', type: 'string', default: '0 0% 63.9%', description: 'Dark muted text color in HSL format', }, { displayName: 'Border', name: 'border', type: 'string', default: '0 0% 14.9%', description: 'Dark border color in HSL format', }, { displayName: 'Input', name: 'input', type: 'string', default: '0 0% 14.9%', description: 'Dark input field color in HSL format', }, ], }, ], }; } async execute() { const items = this.getInputData(); const returnData = []; for (let i = 0; i < items.length; i++) { try { // Get parameters from node configuration const requestPath = this.getNodeParameter('requestPath', i, '/'); const chatApiUrl = this.getNodeParameter('chatApiUrl', i, 'https://api.example.com/chat'); const chatTitle = this.getNodeParameter('chatTitle', i, 'AI Assistant Chat'); const webhookUrl = this.getNodeParameter('webhookUrl', i, ''); const agentSid = this.getNodeParameter('agentSid', i, ''); const customFavicon = this.getNodeParameter('customFavicon', i, ''); const welcomeMessage = this.getNodeParameter('welcomeMessage', i, ''); const footnoteEnabled = this.getNodeParameter('footnoteEnabled', i, false); const agentMessageFootnote = this.getNodeParameter('agentMessageFootnote', i, ''); const footnoteTitle = this.getNodeParameter('footnoteTitle', i, 'Disclaimer'); const footnoteCollapsible = this.getNodeParameter('footnoteCollapsible', i, true); const footnoteShowTiming = this.getNodeParameter('footnoteShowTiming', i, 'after_response'); const feedbackEnabled = this.getNodeParameter('feedbackEnabled', i, false); const thumbsUpUrl = this.getNodeParameter('thumbsUpUrl', i, ''); const thumbsDownUrl = this.getNodeParameter('thumbsDownUrl', i, ''); const lightThemeColors = this.getNodeParameter('lightThemeColors', i, {}); const darkThemeColors = this.getNodeParameter('darkThemeColors', i, {}); // Build the theme configuration const themeConfig = { VITE_CHAT_API_URL: chatApiUrl, VITE_CHAT_TITLE: chatTitle, VITE_AGENT_SID: agentSid, VITE_CUSTOM_FAVICON: customFavicon, VITE_WELCOME_MESSAGE: welcomeMessage, // Feedback configuration VITE_FEEDBACK_ENABLED: feedbackEnabled.toString(), VITE_FEEDBACK_THUMBS_UP_URL: thumbsUpUrl, VITE_FEEDBACK_THUMBS_DOWN_URL: thumbsDownUrl, // Footnote configuration VITE_AGENT_MESSAGE_FOOTNOTE: agentMessageFootnote, VITE_FOOTNOTE_ENABLED: footnoteEnabled.toString(), VITE_FOOTNOTE_TITLE: footnoteTitle, VITE_FOOTNOTE_COLLAPSIBLE: footnoteCollapsible.toString(), VITE_FOOTNOTE_SHOW_TIMING: footnoteShowTiming, // Light theme VITE_THEME_BACKGROUND: lightThemeColors.background, VITE_THEME_FOREGROUND: lightThemeColors.foreground, VITE_THEME_CARD: lightThemeColors.card, VITE_THEME_CARD_FOREGROUND: lightThemeColors.cardForeground, VITE_THEME_PRIMARY: lightThemeColors.primary, VITE_THEME_PRIMARY_FOREGROUND: lightThemeColors.primaryForeground, VITE_THEME_SECONDARY: lightThemeColors.secondary, VITE_THEME_SECONDARY_FOREGROUND: lightThemeColors.secondaryForeground, VITE_THEME_MUTED: lightThemeColors.muted, VITE_THEME_MUTED_FOREGROUND: lightThemeColors.mutedForeground, VITE_THEME_BORDER: lightThemeColors.border, VITE_THEME_INPUT: lightThemeColors.input, // Dark theme VITE_THEME_DARK_BACKGROUND: darkThemeColors.background, VITE_THEME_DARK_FOREGROUND: darkThemeColors.foreground, VITE_THEME_DARK_CARD: darkThemeColors.card, VITE_THEME_DARK_CARD_FOREGROUND: darkThemeColors.cardForeground, VITE_THEME_DARK_PRIMARY: darkThemeColors.primary, VITE_THEME_DARK_PRIMARY_FOREGROUND: darkThemeColors.primaryForeground, VITE_THEME_DARK_SECONDARY: darkThemeColors.secondary, VITE_THEME_DARK_SECONDARY_FOREGROUND: darkThemeColors.secondaryForeground, VITE_THEME_DARK_MUTED: darkThemeColors.muted, VITE_THEME_DARK_MUTED_FOREGROUND: darkThemeColors.mutedForeground, VITE_THEME_DARK_BORDER: darkThemeColors.border, VITE_THEME_DARK_INPUT: darkThemeColors.input, }; // Get pre-built assets path const builder = new ChatInterfaceBuilder_1.ChatInterfaceBuilder(); const assetsPath = builder.getAssetsPath(); // Serve the requested path with runtime theme injection const chatInterfaceNode = new ChatInterfaceNode(); const response = await chatInterfaceNode.serveStaticContent(requestPath, assetsPath, themeConfig, webhookUrl); returnData.push({ json: response, pairedItem: { item: i }, }); } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: error.message, statusCode: 500, }, pairedItem: { item: i }, }); } else { throw new n8n_workflow_1.NodeOperationError(this.getNode(), error, { itemIndex: i }); } } } return [returnData]; } async serveStaticContent(requestPath, assetsPath, themeConfig, webhookUrl = '') { let filePath; let statusCode = 200; // Handle root path and SPA routing if (requestPath === '/' || !requestPath.includes('.')) { filePath = path.join(assetsPath, 'index.html'); } else { filePath = path.join(assetsPath, requestPath); } // Check if file exists if (!fs.existsSync(filePath)) { // For SPA, serve index.html for non-asset routes if (!requestPath.includes('.')) { filePath = path.join(assetsPath, 'index.html'); } else { statusCode = 404; return { statusCode, content: Buffer.from('Not Found').toString('base64'), contentType: 'text/plain', headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', }, }; } } // Read the file let fileContent = fs.readFileSync(filePath); const contentType = mime.lookup(filePath) || 'application/octet-stream'; // Generate configuration hash for cache busting const configHash = crypto .createHash('md5') .update(JSON.stringify(themeConfig)) .digest('hex') .substring(0, 8); // Current timestamp for cache busting const timestamp = Date.now(); // For HTML files, inject runtime theme configuration and webhook URLs if (contentType === 'text/html') { let htmlContent = fileContent.toString('utf8'); // Add cache-busting meta tags to the HTML head const cacheBustingMeta = ` <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> <meta http-equiv="Pragma" content="no-cache"> <meta http-equiv="Expires" content="0"> <meta name="config-hash" content="${configHash}"> <meta name="build-timestamp" content="${timestamp}">`; htmlContent = htmlContent.replace('</head>', `${cacheBustingMeta}\n</head>`); htmlContent = RuntimeThemeInjector_1.RuntimeThemeInjector.injectFullConfig(htmlContent, themeConfig); htmlContent = RuntimeThemeInjector_1.RuntimeThemeInjector.injectWebhookUrl(htmlContent, webhookUrl, '.html'); fileContent = Buffer.from(htmlContent, 'utf8'); } // For CSS and JS files, inject webhook URLs else if (contentType === 'text/css' || contentType === 'application/javascript') { let textContent = fileContent.toString('utf8'); const fileExtension = contentType === 'text/css' ? '.css' : '.js'; textContent = RuntimeThemeInjector_1.RuntimeThemeInjector.injectWebhookUrl(textContent, webhookUrl, fileExtension); fileContent = Buffer.from(textContent, 'utf8'); } // Add charset=utf-8 for text-based content types let finalContentType = contentType; if (contentType.startsWith('text/') || contentType === 'application/javascript' || contentType === 'application/json') { finalContentType = contentType + '; charset=utf-8'; } // Generate unique values for each request to break any potential caching const uniqueId = Math.random().toString(36).substring(2, 15); // Determine cache headers based on content type const cacheHeaders = contentType === 'text/html' ? { // HTML: No caching to ensure fresh configuration 'Cache-Control': 'no-cache, no-store, must-revalidate, private', 'Pragma': 'no-cache', 'Expires': '0', 'ETag': `"${configHash}-${timestamp}-${uniqueId}"`, 'X-Config-Hash': configHash, 'X-Build-Timestamp': timestamp.toString(), 'X-Request-Id': uniqueId, } : { // Static assets: Short cache with revalidation 'Cache-Control': 'public, max-age=60, must-revalidate', 'ETag': `"${configHash}-asset-${uniqueId}"`, 'X-Request-Id': uniqueId, }; return { statusCode, content: fileContent.toString('base64'), contentType: finalContentType, headers: cacheHeaders, // Add unique identifier to the response object itself to break n8n caching _cacheBreaker: `${timestamp}-${uniqueId}-${configHash}`, _configHash: configHash, _buildTimestamp: timestamp, }; } } exports.ChatInterfaceNode = ChatInterfaceNode;