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
JavaScript
"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;