@pindai-ai/chat-widget 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/dist/pindai-chat-widget.css +1 -0
- package/dist/pindai-chat-widget.js +54 -0
- package/dist/pindai-chat-widget.js.map +1 -0
- package/dist/vite.svg +1 -0
- package/package.json +61 -0
- package/src/counter.js +9 -0
- package/src/i18n.js +174 -0
- package/src/javascript.svg +1 -0
- package/src/main.js +796 -0
- package/src/style.css +747 -0
package/src/main.js
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import './style.css';
|
|
2
|
+
import { I18n } from './i18n.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pindai Chat Widget - Modern, Accessible, Indonesian-focused
|
|
6
|
+
* Version 2.0.0
|
|
7
|
+
*/
|
|
8
|
+
class PindaiChatWidget {
|
|
9
|
+
constructor(options) {
|
|
10
|
+
// Backward compatibility: support both webhookUrl and n8nUrl
|
|
11
|
+
const apiEndpoint = options.webhookUrl || options.n8nUrl;
|
|
12
|
+
|
|
13
|
+
if (!apiEndpoint) {
|
|
14
|
+
throw new Error('PindaiChatWidget: "webhookUrl" option is required.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Core configuration
|
|
18
|
+
this.webhookUrl = apiEndpoint;
|
|
19
|
+
this.mode = options.mode || 'widget';
|
|
20
|
+
|
|
21
|
+
// Internationalization
|
|
22
|
+
this.locale = options.locale || 'id'; // Default to Indonesian
|
|
23
|
+
this.i18n = new I18n(this.locale);
|
|
24
|
+
|
|
25
|
+
// UI customization
|
|
26
|
+
this.title = options.title || this.i18n.t('title');
|
|
27
|
+
this.initialMessage = options.initialMessage || this.i18n.t('initialMessage');
|
|
28
|
+
this.launcherIconUrl = options.launcherIconUrl || this.getDefaultIcon();
|
|
29
|
+
|
|
30
|
+
// Branding
|
|
31
|
+
this.logoUrl = options.logoUrl || 'https://pindai.ai/logo.png';
|
|
32
|
+
this.showLogo = options.showLogo !== false;
|
|
33
|
+
this.launcherColor = options.launcherColor || '#0066FF';
|
|
34
|
+
this.sendButtonColor = options.sendButtonColor || '#0066FF';
|
|
35
|
+
this.accentColor = options.accentColor || '#00C896';
|
|
36
|
+
|
|
37
|
+
// File upload configuration
|
|
38
|
+
this.enableFileUpload = options.enableFileUpload !== false;
|
|
39
|
+
this.allowedFileTypes = options.allowedFileTypes || [
|
|
40
|
+
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
|
41
|
+
'application/pdf',
|
|
42
|
+
'application/msword',
|
|
43
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
44
|
+
'application/vnd.ms-excel',
|
|
45
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
46
|
+
];
|
|
47
|
+
this.maxFileSize = options.maxFileSize || 10 * 1024 * 1024; // 10MB
|
|
48
|
+
this.maxFiles = options.maxFiles || 5;
|
|
49
|
+
this.uploadedFiles = [];
|
|
50
|
+
|
|
51
|
+
// Notifications
|
|
52
|
+
this.enableNotifications = options.enableNotifications !== false;
|
|
53
|
+
this.enableSound = options.enableSound === true;
|
|
54
|
+
this.unreadCount = 0;
|
|
55
|
+
|
|
56
|
+
// Quick replies
|
|
57
|
+
this.showQuickReplies = options.showQuickReplies !== false;
|
|
58
|
+
this.quickReplies = options.quickReplies || [
|
|
59
|
+
this.i18n.t('quickReply1'),
|
|
60
|
+
this.i18n.t('quickReply2'),
|
|
61
|
+
this.i18n.t('quickReply3'),
|
|
62
|
+
this.i18n.t('quickReply4')
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// Message history
|
|
66
|
+
this.enableHistory = options.enableHistory !== false;
|
|
67
|
+
this.maxHistoryItems = options.maxHistoryItems || 50;
|
|
68
|
+
this.historyKey = `pindai-chat-history-${this.webhookUrl}`;
|
|
69
|
+
this.stateKey = `pindai-chat-state-${this.webhookUrl}`;
|
|
70
|
+
|
|
71
|
+
// Error handling & retry logic
|
|
72
|
+
this.maxRetries = options.maxRetries || 3;
|
|
73
|
+
this.retryDelay = options.retryDelay || 1000;
|
|
74
|
+
this.requestTimeout = options.requestTimeout || 30000;
|
|
75
|
+
|
|
76
|
+
// Rate limiting
|
|
77
|
+
this.rateLimit = options.rateLimit || 5;
|
|
78
|
+
this.rateLimitWindow = options.rateLimitWindow || 60000;
|
|
79
|
+
this.messageTimes = [];
|
|
80
|
+
|
|
81
|
+
// DOM references
|
|
82
|
+
this.container = null;
|
|
83
|
+
this.launcher = null;
|
|
84
|
+
this.chatWindow = null;
|
|
85
|
+
this.messageList = null;
|
|
86
|
+
this.input = null;
|
|
87
|
+
this.button = null;
|
|
88
|
+
this.closeButton = null;
|
|
89
|
+
|
|
90
|
+
// State
|
|
91
|
+
this.sessionId = `web-session-${Date.now()}-${Math.random()}`;
|
|
92
|
+
this.isLoading = false;
|
|
93
|
+
this.isOpen = false;
|
|
94
|
+
this.isOnline = navigator.onLine;
|
|
95
|
+
|
|
96
|
+
// Initialize
|
|
97
|
+
this.loadState();
|
|
98
|
+
this.setupOfflineDetection();
|
|
99
|
+
|
|
100
|
+
if (this.mode === 'fullscreen') {
|
|
101
|
+
this.initChatWindow();
|
|
102
|
+
} else {
|
|
103
|
+
this.initLauncher();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Initialize launcher button
|
|
109
|
+
*/
|
|
110
|
+
initLauncher() {
|
|
111
|
+
this.launcher = document.createElement('div');
|
|
112
|
+
this.launcher.className = 'n8n-chat-launcher';
|
|
113
|
+
this.launcher.style.backgroundColor = this.launcherColor;
|
|
114
|
+
this.launcher.setAttribute('role', 'button');
|
|
115
|
+
this.launcher.setAttribute('aria-label', this.i18n.t('ariaOpenChat'));
|
|
116
|
+
this.launcher.setAttribute('tabindex', '0');
|
|
117
|
+
this.launcher.innerHTML = `
|
|
118
|
+
<img src="${this.launcherIconUrl}" alt="">
|
|
119
|
+
<span class="n8n-chat-unread-badge" style="display: none;">0</span>
|
|
120
|
+
`;
|
|
121
|
+
document.body.appendChild(this.launcher);
|
|
122
|
+
|
|
123
|
+
this.launcher.addEventListener('click', () => this.toggleChatWindow());
|
|
124
|
+
this.launcher.addEventListener('keydown', (e) => {
|
|
125
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
this.toggleChatWindow();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Initialize chat window
|
|
134
|
+
*/
|
|
135
|
+
initChatWindow() {
|
|
136
|
+
this.container = document.createElement('div');
|
|
137
|
+
this.container.className = `n8n-chat-widget ${this.mode === 'fullscreen' ? 'n8n-chat-widget--fullscreen' : ''}`;
|
|
138
|
+
this.container.setAttribute('role', 'dialog');
|
|
139
|
+
this.container.setAttribute('aria-modal', 'true');
|
|
140
|
+
this.container.setAttribute('aria-label', this.title);
|
|
141
|
+
|
|
142
|
+
this.container.innerHTML = `
|
|
143
|
+
<div class="n8n-chat-header">
|
|
144
|
+
<div class="n8n-chat-header-content">
|
|
145
|
+
${this.showLogo ? `<img src="${this.logoUrl}" alt="Pindai Logo" class="n8n-chat-logo">` : ''}
|
|
146
|
+
<span class="n8n-chat-title">${this.title}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<button class="n8n-chat-close-btn" aria-label="${this.i18n.t('ariaCloseChat')}">×</button>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="n8n-chat-messages" role="log" aria-live="polite" aria-atomic="false"></div>
|
|
151
|
+
<div class="n8n-chat-watermark">
|
|
152
|
+
<span>Powered by</span>
|
|
153
|
+
<a href="https://pindai.ai" target="_blank" rel="noopener noreferrer">Pindai.ai</a>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="n8n-chat-input-area">
|
|
156
|
+
${this.enableFileUpload ? `
|
|
157
|
+
<label class="n8n-chat-file-upload-btn" aria-label="${this.i18n.t('ariaUploadFile')}">
|
|
158
|
+
<input type="file" multiple accept="${this.allowedFileTypes.join(',')}" hidden>
|
|
159
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
160
|
+
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
|
161
|
+
</svg>
|
|
162
|
+
</label>
|
|
163
|
+
` : ''}
|
|
164
|
+
<input type="text" placeholder="${this.i18n.t('placeholder')}" aria-label="${this.i18n.t('ariaMessageInput')}" />
|
|
165
|
+
<button class="n8n-chat-send-btn" style="background-color: ${this.sendButtonColor}" aria-label="${this.i18n.t('ariaSendMessage')}">
|
|
166
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
167
|
+
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
168
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
169
|
+
</svg>
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
${this.enableFileUpload ? '<div class="n8n-chat-file-preview" style="display: none;"></div>' : ''}
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
if (this.mode === 'widget') {
|
|
176
|
+
document.body.appendChild(this.container);
|
|
177
|
+
} else {
|
|
178
|
+
document.body.innerHTML = '';
|
|
179
|
+
document.body.appendChild(this.container);
|
|
180
|
+
document.body.style.margin = '0';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Get DOM references
|
|
184
|
+
this.messageList = this.container.querySelector('.n8n-chat-messages');
|
|
185
|
+
this.input = this.container.querySelector('input[type="text"]');
|
|
186
|
+
this.button = this.container.querySelector('.n8n-chat-send-btn');
|
|
187
|
+
this.closeButton = this.container.querySelector('.n8n-chat-close-btn');
|
|
188
|
+
|
|
189
|
+
// Event listeners
|
|
190
|
+
this.button.addEventListener('click', (e) => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
this.sendMessage();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this.input.addEventListener('keypress', (e) => {
|
|
196
|
+
if (e.key === 'Enter') {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
this.sendMessage();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (this.mode === 'fullscreen') {
|
|
203
|
+
this.closeButton.style.display = 'none';
|
|
204
|
+
} else {
|
|
205
|
+
this.closeButton.addEventListener('click', () => this.toggleChatWindow());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// File upload handler
|
|
209
|
+
if (this.enableFileUpload) {
|
|
210
|
+
const fileInput = this.container.querySelector('input[type="file"]');
|
|
211
|
+
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Setup keyboard navigation
|
|
215
|
+
this.setupKeyboardNavigation();
|
|
216
|
+
|
|
217
|
+
// Load history and show initial message
|
|
218
|
+
this.loadHistory();
|
|
219
|
+
if (this.messageList.children.length === 0) {
|
|
220
|
+
this.addMessage(this.initialMessage, 'ai');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Toggle chat window open/close
|
|
226
|
+
*/
|
|
227
|
+
toggleChatWindow() {
|
|
228
|
+
if (!this.isOpen) {
|
|
229
|
+
if (!this.container) {
|
|
230
|
+
this.initChatWindow();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setTimeout(() => {
|
|
234
|
+
this.container.classList.add('n8n-chat-widget--open');
|
|
235
|
+
if (this.launcher) {
|
|
236
|
+
this.launcher.classList.add('n8n-chat-launcher--hidden');
|
|
237
|
+
}
|
|
238
|
+
this.input.focus();
|
|
239
|
+
this.clearUnreadCount();
|
|
240
|
+
}, 10);
|
|
241
|
+
} else {
|
|
242
|
+
this.container.classList.remove('n8n-chat-widget--open');
|
|
243
|
+
if (this.launcher) {
|
|
244
|
+
this.launcher.classList.remove('n8n-chat-launcher--hidden');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
this.isOpen = !this.isOpen;
|
|
248
|
+
this.saveState();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get default chat icon
|
|
253
|
+
*/
|
|
254
|
+
getDefaultIcon() {
|
|
255
|
+
const svgIcon = `
|
|
256
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
257
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
|
258
|
+
</svg>
|
|
259
|
+
`;
|
|
260
|
+
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgIcon)}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Format timestamp for display
|
|
265
|
+
*/
|
|
266
|
+
formatTimestamp(date) {
|
|
267
|
+
const now = new Date();
|
|
268
|
+
const diff = now - date;
|
|
269
|
+
|
|
270
|
+
if (diff < 60000) {
|
|
271
|
+
return this.i18n.t('justNow');
|
|
272
|
+
}
|
|
273
|
+
if (diff < 3600000) {
|
|
274
|
+
const minutes = Math.floor(diff / 60000);
|
|
275
|
+
return this.i18n.t('minutesAgo', { minutes });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return date.toLocaleTimeString(this.locale === 'id' ? 'id-ID' : 'en-US', {
|
|
279
|
+
hour: '2-digit',
|
|
280
|
+
minute: '2-digit'
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Add message to chat
|
|
286
|
+
*/
|
|
287
|
+
addMessage(text, sender, timestamp = new Date()) {
|
|
288
|
+
const messageBubble = document.createElement('div');
|
|
289
|
+
messageBubble.className = `n8n-chat-bubble n8n-chat-${sender}-message`;
|
|
290
|
+
|
|
291
|
+
const textNode = document.createElement('div');
|
|
292
|
+
textNode.className = 'n8n-chat-message-text';
|
|
293
|
+
textNode.textContent = text;
|
|
294
|
+
|
|
295
|
+
const timeNode = document.createElement('div');
|
|
296
|
+
timeNode.className = 'n8n-chat-message-timestamp';
|
|
297
|
+
timeNode.textContent = this.formatTimestamp(timestamp);
|
|
298
|
+
timeNode.setAttribute('data-timestamp', timestamp.toISOString());
|
|
299
|
+
|
|
300
|
+
messageBubble.appendChild(textNode);
|
|
301
|
+
messageBubble.appendChild(timeNode);
|
|
302
|
+
this.messageList.appendChild(messageBubble);
|
|
303
|
+
|
|
304
|
+
this.messageList.scrollTop = this.messageList.scrollHeight;
|
|
305
|
+
|
|
306
|
+
// Save to history
|
|
307
|
+
this.saveToHistory(text, sender, timestamp);
|
|
308
|
+
|
|
309
|
+
// Increment unread if chat closed and AI message
|
|
310
|
+
if (!this.isOpen && sender === 'ai') {
|
|
311
|
+
this.incrementUnread();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Show/hide typing indicator
|
|
317
|
+
*/
|
|
318
|
+
showTypingIndicator(show) {
|
|
319
|
+
let indicator = this.messageList.querySelector('.n8n-chat-typing-indicator');
|
|
320
|
+
if (show) {
|
|
321
|
+
if (!indicator) {
|
|
322
|
+
indicator = document.createElement('div');
|
|
323
|
+
indicator.className = 'n8n-chat-bubble n8n-chat-ai-message n8n-chat-typing-indicator';
|
|
324
|
+
indicator.innerHTML = '<span></span><span></span><span></span>';
|
|
325
|
+
indicator.setAttribute('aria-label', this.i18n.t('typingIndicator'));
|
|
326
|
+
this.messageList.appendChild(indicator);
|
|
327
|
+
this.messageList.scrollTop = this.messageList.scrollHeight;
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
if (indicator) {
|
|
331
|
+
indicator.remove();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Handle file selection
|
|
338
|
+
*/
|
|
339
|
+
handleFileSelect(event) {
|
|
340
|
+
const files = Array.from(event.target.files);
|
|
341
|
+
|
|
342
|
+
files.forEach(file => {
|
|
343
|
+
// Check file type
|
|
344
|
+
if (!this.allowedFileTypes.includes(file.type)) {
|
|
345
|
+
this.addMessage(
|
|
346
|
+
this.i18n.t('fileTypeNotSupported', { filename: file.name }),
|
|
347
|
+
'ai'
|
|
348
|
+
);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Check file size
|
|
353
|
+
if (file.size > this.maxFileSize) {
|
|
354
|
+
const maxSizeMB = this.maxFileSize / 1024 / 1024;
|
|
355
|
+
this.addMessage(
|
|
356
|
+
this.i18n.t('fileTooLarge', { filename: file.name, maxSize: maxSizeMB }),
|
|
357
|
+
'ai'
|
|
358
|
+
);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Check max files
|
|
363
|
+
if (this.uploadedFiles.length >= this.maxFiles) {
|
|
364
|
+
this.addMessage(
|
|
365
|
+
this.i18n.t('maxFilesExceeded', { maxFiles: this.maxFiles }),
|
|
366
|
+
'ai'
|
|
367
|
+
);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.uploadedFiles.push(file);
|
|
372
|
+
this.renderFilePreview(file);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
event.target.value = ''; // Reset input
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Render file preview
|
|
380
|
+
*/
|
|
381
|
+
renderFilePreview(file) {
|
|
382
|
+
const preview = this.container.querySelector('.n8n-chat-file-preview');
|
|
383
|
+
if (!preview) return;
|
|
384
|
+
|
|
385
|
+
preview.style.display = 'flex';
|
|
386
|
+
|
|
387
|
+
const fileItem = document.createElement('div');
|
|
388
|
+
fileItem.className = 'n8n-chat-file-item';
|
|
389
|
+
fileItem.innerHTML = `
|
|
390
|
+
<span class="n8n-chat-file-name">${file.name}</span>
|
|
391
|
+
<button class="n8n-chat-file-remove" data-file="${file.name}" aria-label="${this.i18n.t('ariaRemoveFile')}">×</button>
|
|
392
|
+
`;
|
|
393
|
+
|
|
394
|
+
const removeBtn = fileItem.querySelector('.n8n-chat-file-remove');
|
|
395
|
+
removeBtn.addEventListener('click', () => {
|
|
396
|
+
this.uploadedFiles = this.uploadedFiles.filter(f => f.name !== file.name);
|
|
397
|
+
fileItem.remove();
|
|
398
|
+
if (this.uploadedFiles.length === 0) {
|
|
399
|
+
preview.style.display = 'none';
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
preview.appendChild(fileItem);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Send message with files
|
|
408
|
+
*/
|
|
409
|
+
async sendMessage() {
|
|
410
|
+
const messageText = this.input.value.trim();
|
|
411
|
+
if ((!messageText && this.uploadedFiles.length === 0) || this.isLoading) return;
|
|
412
|
+
|
|
413
|
+
// Check rate limit
|
|
414
|
+
try {
|
|
415
|
+
this.checkRateLimit();
|
|
416
|
+
} catch (error) {
|
|
417
|
+
this.addMessage(error.message, 'ai');
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Check online status
|
|
422
|
+
if (!this.isOnline) {
|
|
423
|
+
this.addMessage(this.i18n.t('connectionLost'), 'ai');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this.isLoading = true;
|
|
428
|
+
this.button.disabled = true;
|
|
429
|
+
this.input.disabled = true;
|
|
430
|
+
|
|
431
|
+
if (messageText) {
|
|
432
|
+
this.addMessage(messageText, 'user');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.input.value = '';
|
|
436
|
+
this.showTypingIndicator(true);
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const response = await this.sendMessageWithRetry(messageText, this.uploadedFiles);
|
|
440
|
+
this.addMessage(response, 'ai');
|
|
441
|
+
|
|
442
|
+
// Show quick replies after AI response
|
|
443
|
+
if (this.showQuickReplies && this.quickReplies.length > 0) {
|
|
444
|
+
this.renderQuickReplies();
|
|
445
|
+
}
|
|
446
|
+
} catch (error) {
|
|
447
|
+
const errorMessage = this.getErrorMessage(error);
|
|
448
|
+
this.addMessage(errorMessage, 'ai');
|
|
449
|
+
} finally {
|
|
450
|
+
this.isLoading = false;
|
|
451
|
+
this.button.disabled = false;
|
|
452
|
+
this.input.disabled = false;
|
|
453
|
+
this.showTypingIndicator(false);
|
|
454
|
+
this.input.focus();
|
|
455
|
+
|
|
456
|
+
// Clear uploaded files
|
|
457
|
+
if (this.uploadedFiles.length > 0) {
|
|
458
|
+
this.uploadedFiles = [];
|
|
459
|
+
const preview = this.container.querySelector('.n8n-chat-file-preview');
|
|
460
|
+
if (preview) {
|
|
461
|
+
preview.innerHTML = '';
|
|
462
|
+
preview.style.display = 'none';
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Send message with retry logic
|
|
470
|
+
*/
|
|
471
|
+
async sendMessageWithRetry(messageText, files = [], retryCount = 0) {
|
|
472
|
+
try {
|
|
473
|
+
const controller = new AbortController();
|
|
474
|
+
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
475
|
+
|
|
476
|
+
const formData = new FormData();
|
|
477
|
+
formData.append('sessionId', this.sessionId);
|
|
478
|
+
formData.append('message', messageText);
|
|
479
|
+
|
|
480
|
+
// Append files
|
|
481
|
+
files.forEach((file, index) => {
|
|
482
|
+
formData.append(`file${index}`, file);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const response = await fetch(this.webhookUrl, {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
body: formData,
|
|
488
|
+
signal: controller.signal
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
clearTimeout(timeoutId);
|
|
492
|
+
|
|
493
|
+
if (!response.ok) {
|
|
494
|
+
// Retry on 5xx errors
|
|
495
|
+
if (response.status >= 500 && retryCount < this.maxRetries) {
|
|
496
|
+
await this.delay(this.retryDelay * (retryCount + 1));
|
|
497
|
+
return this.sendMessageWithRetry(messageText, files, retryCount + 1);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const errorData = await response.json().catch(() => ({}));
|
|
501
|
+
throw new Error(errorData.message || `Network error: ${response.statusText}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const data = await response.json();
|
|
505
|
+
if (!data.response) {
|
|
506
|
+
throw new Error(this.i18n.t('errorInvalidResponse'));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return data.response;
|
|
510
|
+
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (error.name === 'AbortError') {
|
|
513
|
+
throw new Error(this.i18n.t('errorTimeout'));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Retry on network errors
|
|
517
|
+
if (error.message.includes('NetworkError') && retryCount < this.maxRetries) {
|
|
518
|
+
await this.delay(this.retryDelay * (retryCount + 1));
|
|
519
|
+
return this.sendMessageWithRetry(messageText, files, retryCount + 1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Delay helper
|
|
528
|
+
*/
|
|
529
|
+
delay(ms) {
|
|
530
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Get user-friendly error message
|
|
535
|
+
*/
|
|
536
|
+
getErrorMessage(error) {
|
|
537
|
+
if (error.message.includes('timeout') || error.message.includes('Timeout')) {
|
|
538
|
+
return this.i18n.t('errorTimeout');
|
|
539
|
+
}
|
|
540
|
+
if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
|
|
541
|
+
return this.i18n.t('errorNetwork');
|
|
542
|
+
}
|
|
543
|
+
if (error.message.includes('500') || error.message.includes('503')) {
|
|
544
|
+
return this.i18n.t('errorServer');
|
|
545
|
+
}
|
|
546
|
+
return this.i18n.t('errorGeneric');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Check rate limit
|
|
551
|
+
*/
|
|
552
|
+
checkRateLimit() {
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
this.messageTimes = this.messageTimes.filter(
|
|
555
|
+
time => now - time < this.rateLimitWindow
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
if (this.messageTimes.length >= this.rateLimit) {
|
|
559
|
+
const oldestTime = this.messageTimes[0];
|
|
560
|
+
const waitTime = Math.ceil((this.rateLimitWindow - (now - oldestTime)) / 1000);
|
|
561
|
+
throw new Error(this.i18n.t('errorRateLimit', { seconds: waitTime }));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.messageTimes.push(now);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Render quick reply buttons
|
|
569
|
+
*/
|
|
570
|
+
renderQuickReplies(replies = this.quickReplies) {
|
|
571
|
+
if (!this.showQuickReplies || replies.length === 0) return;
|
|
572
|
+
|
|
573
|
+
// Remove existing quick replies
|
|
574
|
+
const existingReplies = this.messageList.querySelector('.n8n-chat-quick-replies');
|
|
575
|
+
if (existingReplies) existingReplies.remove();
|
|
576
|
+
|
|
577
|
+
const repliesContainer = document.createElement('div');
|
|
578
|
+
repliesContainer.className = 'n8n-chat-quick-replies';
|
|
579
|
+
|
|
580
|
+
replies.forEach(reply => {
|
|
581
|
+
const button = document.createElement('button');
|
|
582
|
+
button.className = 'n8n-chat-quick-reply-btn';
|
|
583
|
+
button.textContent = reply;
|
|
584
|
+
button.addEventListener('click', () => {
|
|
585
|
+
this.input.value = reply;
|
|
586
|
+
this.sendMessage();
|
|
587
|
+
repliesContainer.remove();
|
|
588
|
+
});
|
|
589
|
+
repliesContainer.appendChild(button);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
this.messageList.appendChild(repliesContainer);
|
|
593
|
+
this.messageList.scrollTop = this.messageList.scrollHeight;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Notification badge management
|
|
598
|
+
*/
|
|
599
|
+
incrementUnread() {
|
|
600
|
+
if (!this.isOpen) {
|
|
601
|
+
this.unreadCount++;
|
|
602
|
+
this.updateUnreadBadge();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
updateUnreadBadge() {
|
|
607
|
+
if (!this.launcher) return;
|
|
608
|
+
const badge = this.launcher.querySelector('.n8n-chat-unread-badge');
|
|
609
|
+
if (badge) {
|
|
610
|
+
badge.textContent = this.unreadCount;
|
|
611
|
+
badge.style.display = this.unreadCount > 0 ? 'flex' : 'none';
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
clearUnreadCount() {
|
|
616
|
+
this.unreadCount = 0;
|
|
617
|
+
this.updateUnreadBadge();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Message history persistence
|
|
622
|
+
*/
|
|
623
|
+
loadHistory() {
|
|
624
|
+
if (!this.enableHistory) return;
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
const stored = localStorage.getItem(this.historyKey);
|
|
628
|
+
if (!stored) return;
|
|
629
|
+
|
|
630
|
+
const history = JSON.parse(stored);
|
|
631
|
+
history.forEach(item => {
|
|
632
|
+
this.addMessageWithoutSaving(item.text, item.sender, new Date(item.timestamp));
|
|
633
|
+
});
|
|
634
|
+
} catch (error) {
|
|
635
|
+
console.warn('Failed to load chat history:', error);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
addMessageWithoutSaving(text, sender, timestamp) {
|
|
640
|
+
const messageBubble = document.createElement('div');
|
|
641
|
+
messageBubble.className = `n8n-chat-bubble n8n-chat-${sender}-message`;
|
|
642
|
+
|
|
643
|
+
const textNode = document.createElement('div');
|
|
644
|
+
textNode.className = 'n8n-chat-message-text';
|
|
645
|
+
textNode.textContent = text;
|
|
646
|
+
|
|
647
|
+
const timeNode = document.createElement('div');
|
|
648
|
+
timeNode.className = 'n8n-chat-message-timestamp';
|
|
649
|
+
timeNode.textContent = this.formatTimestamp(timestamp);
|
|
650
|
+
timeNode.setAttribute('data-timestamp', timestamp.toISOString());
|
|
651
|
+
|
|
652
|
+
messageBubble.appendChild(textNode);
|
|
653
|
+
messageBubble.appendChild(timeNode);
|
|
654
|
+
this.messageList.appendChild(messageBubble);
|
|
655
|
+
|
|
656
|
+
this.messageList.scrollTop = this.messageList.scrollHeight;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
saveToHistory(text, sender, timestamp = new Date()) {
|
|
660
|
+
if (!this.enableHistory) return;
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
const stored = localStorage.getItem(this.historyKey);
|
|
664
|
+
let history = stored ? JSON.parse(stored) : [];
|
|
665
|
+
|
|
666
|
+
history.push({
|
|
667
|
+
text,
|
|
668
|
+
sender,
|
|
669
|
+
timestamp: timestamp.toISOString()
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// Keep only last N messages
|
|
673
|
+
if (history.length > this.maxHistoryItems) {
|
|
674
|
+
history = history.slice(-this.maxHistoryItems);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
localStorage.setItem(this.historyKey, JSON.stringify(history));
|
|
678
|
+
} catch (error) {
|
|
679
|
+
console.warn('Failed to save chat history:', error);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* State persistence
|
|
685
|
+
*/
|
|
686
|
+
loadState() {
|
|
687
|
+
try {
|
|
688
|
+
const stored = localStorage.getItem(this.stateKey);
|
|
689
|
+
if (stored) {
|
|
690
|
+
// State loaded but not used for auto-open
|
|
691
|
+
// User needs to explicitly open the chat
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.warn('Failed to load chat state:', error);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
saveState() {
|
|
699
|
+
try {
|
|
700
|
+
localStorage.setItem(this.stateKey, JSON.stringify({
|
|
701
|
+
isOpen: this.isOpen,
|
|
702
|
+
timestamp: new Date().toISOString()
|
|
703
|
+
}));
|
|
704
|
+
} catch (error) {
|
|
705
|
+
console.warn('Failed to save chat state:', error);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Offline detection
|
|
711
|
+
*/
|
|
712
|
+
setupOfflineDetection() {
|
|
713
|
+
window.addEventListener('online', () => {
|
|
714
|
+
this.isOnline = true;
|
|
715
|
+
this.updateOnlineStatus();
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
window.addEventListener('offline', () => {
|
|
719
|
+
this.isOnline = false;
|
|
720
|
+
this.updateOnlineStatus();
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
updateOnlineStatus() {
|
|
725
|
+
if (!this.container) return;
|
|
726
|
+
|
|
727
|
+
const statusBar = this.container.querySelector('.n8n-chat-offline-indicator');
|
|
728
|
+
if (!this.isOnline && !statusBar) {
|
|
729
|
+
const indicator = document.createElement('div');
|
|
730
|
+
indicator.className = 'n8n-chat-offline-indicator';
|
|
731
|
+
indicator.innerHTML = `
|
|
732
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
733
|
+
<line x1="1" y1="1" x2="23" y2="23"></line>
|
|
734
|
+
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path>
|
|
735
|
+
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path>
|
|
736
|
+
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path>
|
|
737
|
+
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path>
|
|
738
|
+
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
|
739
|
+
<line x1="12" y1="20" x2="12.01" y2="20"></line>
|
|
740
|
+
</svg>
|
|
741
|
+
<span>${this.i18n.t('offline')}</span>
|
|
742
|
+
`;
|
|
743
|
+
this.container.insertBefore(indicator, this.messageList);
|
|
744
|
+
} else if (this.isOnline && statusBar) {
|
|
745
|
+
statusBar.remove();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Update button state
|
|
749
|
+
if (this.button) {
|
|
750
|
+
this.button.disabled = !this.isOnline || this.isLoading;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Keyboard navigation setup
|
|
756
|
+
*/
|
|
757
|
+
setupKeyboardNavigation() {
|
|
758
|
+
// ESC to close widget
|
|
759
|
+
document.addEventListener('keydown', (e) => {
|
|
760
|
+
if (e.key === 'Escape' && this.isOpen && this.mode === 'widget') {
|
|
761
|
+
this.toggleChatWindow();
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Tab trap within modal
|
|
766
|
+
this.container.addEventListener('keydown', (e) => {
|
|
767
|
+
if (e.key === 'Tab') {
|
|
768
|
+
const focusableElements = this.container.querySelectorAll(
|
|
769
|
+
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
770
|
+
);
|
|
771
|
+
const firstElement = focusableElements[0];
|
|
772
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
773
|
+
|
|
774
|
+
if (e.shiftKey && document.activeElement === firstElement) {
|
|
775
|
+
e.preventDefault();
|
|
776
|
+
lastElement.focus();
|
|
777
|
+
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
firstElement.focus();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Export both class names for backward compatibility
|
|
787
|
+
window.PindaiChatWidget = {
|
|
788
|
+
init: (options) => {
|
|
789
|
+
if (!document.querySelector('.n8n-chat-widget') && !document.querySelector('.n8n-chat-launcher')) {
|
|
790
|
+
return new PindaiChatWidget(options);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// Backward compatibility with old name
|
|
796
|
+
window.N8nChatWidget = window.PindaiChatWidget;
|