UNPKG

n8n-nodes-agent-chat-interface

Version:

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

663 lines (662 loc) 31.5 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.ChatInterfaceWebhookNode = void 0; 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 ChatInterfaceWebhookNode { constructor() { this.description = { displayName: 'Chat Interface Webhook', name: 'chatInterfaceWebhook', icon: 'file:chatInterface.svg', group: ['trigger'], version: 1, description: 'Webhook that serves a customizable React chat interface with automatic response handling', defaults: { name: 'Chat Interface Webhook', }, inputs: [], outputs: [], webhooks: [ { name: 'default', httpMethod: 'GET', responseMode: 'onReceived', path: 'chat-interface', }, { name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'chat-interface', }, ], properties: [ // Webhook Configuration { displayName: 'Webhook Path', name: 'path', type: 'string', default: 'chat-interface', placeholder: 'chat-interface', description: 'The path for the webhook URL', required: true, }, // Basic Configuration { displayName: 'Chat API URL', name: 'chatApiUrl', type: 'string', default: 'https://api.example.com/chat', description: 'API URL for the chat backend', required: true, }, { displayName: 'Chat Title', name: 'chatTitle', type: 'string', default: 'AI Assistant Chat', description: 'Title displayed in the chat interface', }, { 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', }, { 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', 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', placeholder: 'Add Color', type: 'fixedCollection', default: {}, typeOptions: { multipleValues: false, }, options: [ { name: 'colors', displayName: 'Colors', values: [ { displayName: 'Background', name: 'background', type: 'string', default: '', placeholder: '0 0% 100%', description: 'HSL color values (e.g., "220 98% 61%")', }, { displayName: 'Foreground', name: 'foreground', type: 'string', default: '', placeholder: '0 0% 3.9%', }, { displayName: 'Card', name: 'card', type: 'string', default: '', placeholder: '0 0% 100%', }, { displayName: 'Card Foreground', name: 'cardForeground', type: 'string', default: '', placeholder: '0 0% 3.9%', }, { displayName: 'Primary', name: 'primary', type: 'string', default: '', placeholder: '220 98% 61%', }, { displayName: 'Primary Foreground', name: 'primaryForeground', type: 'string', default: '', placeholder: '0 0% 98%', }, { displayName: 'Secondary', name: 'secondary', type: 'string', default: '', placeholder: '0 0% 96.1%', }, { displayName: 'Secondary Foreground', name: 'secondaryForeground', type: 'string', default: '', placeholder: '0 0% 9%', }, { displayName: 'Muted', name: 'muted', type: 'string', default: '', placeholder: '0 0% 96.1%', }, { displayName: 'Muted Foreground', name: 'mutedForeground', type: 'string', default: '', placeholder: '0 0% 45.1%', }, { displayName: 'Border', name: 'border', type: 'string', default: '', placeholder: '0 0% 89.8%', }, { displayName: 'Input', name: 'input', type: 'string', default: '', placeholder: '0 0% 89.8%', }, ], }, ], description: 'Customize light theme colors using HSL format', }, // Dark Theme Colors { displayName: 'Dark Theme Colors', name: 'darkThemeColors', placeholder: 'Add Color', type: 'fixedCollection', default: {}, typeOptions: { multipleValues: false, }, options: [ { name: 'colors', displayName: 'Colors', values: [ { displayName: 'Background', name: 'background', type: 'string', default: '', placeholder: '0 0% 3.9%', description: 'HSL color values (e.g., "220 98% 61%")', }, { displayName: 'Foreground', name: 'foreground', type: 'string', default: '', placeholder: '0 0% 98%', }, { displayName: 'Card', name: 'card', type: 'string', default: '', placeholder: '0 0% 3.9%', }, { displayName: 'Card Foreground', name: 'cardForeground', type: 'string', default: '', placeholder: '0 0% 98%', }, { displayName: 'Primary', name: 'primary', type: 'string', default: '', placeholder: '220 98% 61%', }, { displayName: 'Primary Foreground', name: 'primaryForeground', type: 'string', default: '', placeholder: '0 0% 98%', }, { displayName: 'Secondary', name: 'secondary', type: 'string', default: '', placeholder: '0 0% 14.9%', }, { displayName: 'Secondary Foreground', name: 'secondaryForeground', type: 'string', default: '', placeholder: '0 0% 98%', }, { displayName: 'Muted', name: 'muted', type: 'string', default: '', placeholder: '0 0% 14.9%', }, { displayName: 'Muted Foreground', name: 'mutedForeground', type: 'string', default: '', placeholder: '0 0% 63.9%', }, { displayName: 'Border', name: 'border', type: 'string', default: '', placeholder: '0 0% 14.9%', }, { displayName: 'Input', name: 'input', type: 'string', default: '', placeholder: '0 0% 14.9%', }, ], }, ], description: 'Customize dark theme colors using HSL format', }, ], }; } async webhook() { const req = this.getRequestObject(); const query = this.getQueryData(); const headers = this.getHeaderData(); const body = this.getBodyData(); try { // Get parameters from node configuration const chatApiUrl = this.getNodeParameter('chatApiUrl', 'https://api.example.com/chat'); const chatTitle = this.getNodeParameter('chatTitle', 'AI Assistant Chat'); const agentSid = this.getNodeParameter('agentSid', ''); const customFavicon = this.getNodeParameter('customFavicon', ''); const welcomeMessage = this.getNodeParameter('welcomeMessage', ''); const footnoteEnabled = this.getNodeParameter('footnoteEnabled', false); const agentMessageFootnote = this.getNodeParameter('agentMessageFootnote', ''); const footnoteTitle = this.getNodeParameter('footnoteTitle', 'Disclaimer'); const footnoteCollapsible = this.getNodeParameter('footnoteCollapsible', true); const footnoteShowTiming = this.getNodeParameter('footnoteShowTiming', 'after_response'); const feedbackEnabled = this.getNodeParameter('feedbackEnabled', false); const thumbsUpUrl = this.getNodeParameter('thumbsUpUrl', ''); const thumbsDownUrl = this.getNodeParameter('thumbsDownUrl', ''); const lightThemeColors = this.getNodeParameter('lightThemeColors', {}); const darkThemeColors = this.getNodeParameter('darkThemeColors', {}); // Extract theme colors (handle fixedCollection structure) const lightColors = lightThemeColors.colors || {}; const darkColors = darkThemeColors.colors || {}; // Get the request path from query parameter, defaulting to "/" const requestPath = query.path || '/'; // Build webhook URL dynamically from the current request const webhookUrl = `${req.protocol}://${req.get('host')}${req.originalUrl.split('?')[0]}`; // 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: lightColors.background, VITE_THEME_FOREGROUND: lightColors.foreground, VITE_THEME_CARD: lightColors.card, VITE_THEME_CARD_FOREGROUND: lightColors.cardForeground, VITE_THEME_PRIMARY: lightColors.primary, VITE_THEME_PRIMARY_FOREGROUND: lightColors.primaryForeground, VITE_THEME_SECONDARY: lightColors.secondary, VITE_THEME_SECONDARY_FOREGROUND: lightColors.secondaryForeground, VITE_THEME_MUTED: lightColors.muted, VITE_THEME_MUTED_FOREGROUND: lightColors.mutedForeground, VITE_THEME_BORDER: lightColors.border, VITE_THEME_INPUT: lightColors.input, // Dark theme VITE_THEME_DARK_BACKGROUND: darkColors.background, VITE_THEME_DARK_FOREGROUND: darkColors.foreground, VITE_THEME_DARK_CARD: darkColors.card, VITE_THEME_DARK_CARD_FOREGROUND: darkColors.cardForeground, VITE_THEME_DARK_PRIMARY: darkColors.primary, VITE_THEME_DARK_PRIMARY_FOREGROUND: darkColors.primaryForeground, VITE_THEME_DARK_SECONDARY: darkColors.secondary, VITE_THEME_DARK_SECONDARY_FOREGROUND: darkColors.secondaryForeground, VITE_THEME_DARK_MUTED: darkColors.muted, VITE_THEME_DARK_MUTED_FOREGROUND: darkColors.mutedForeground, VITE_THEME_DARK_BORDER: darkColors.border, VITE_THEME_DARK_INPUT: darkColors.input, }; // Get pre-built assets path const builder = new ChatInterfaceBuilder_1.ChatInterfaceBuilder(); const assetsPath = builder.getAssetsPath(); // Serve the static content with theme injection const nodeInstance = new ChatInterfaceWebhookNode(); const staticResponse = await nodeInstance.serveStaticContent(requestPath, assetsPath, themeConfig, webhookUrl); // Process the response based on content type (similar to your Switch node logic) if (staticResponse.contentType.includes('text/html')) { // For HTML content, decode base64 and return as text const htmlContent = Buffer.from(staticResponse.content, 'base64').toString('utf8'); return { workflowData: [[]], webhookResponse: { body: htmlContent, headers: { 'Content-Type': staticResponse.contentType, 'Pragma': 'no-cache', 'Expires': '0', 'timestamp': Date.now().toString(), ...staticResponse.headers, }, statusCode: staticResponse.statusCode, }, }; } else { // For non-HTML content (CSS, JS, images), return as binary const binaryData = Buffer.from(staticResponse.content, 'base64'); return { workflowData: [[]], webhookResponse: { body: binaryData, headers: { 'Content-Type': staticResponse.contentType, 'timestamp': Date.now().toString(), ...staticResponse.headers, }, statusCode: staticResponse.statusCode, }, }; } } catch (error) { return { workflowData: [[]], webhookResponse: { body: 'Internal Server Error', headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-cache', }, statusCode: 500, }, }; } } 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.ChatInterfaceWebhookNode = ChatInterfaceWebhookNode;