ask-junkie 1.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.
@@ -0,0 +1,611 @@
1
+ /**
2
+ * Chat Widget UI Component
3
+ * Renders and manages the chatbot interface
4
+ */
5
+
6
+ import { escapeHtml, formatBotResponse, createElement } from '../utils/DOMUtils.js';
7
+
8
+ export class ChatWidget {
9
+ constructor(config) {
10
+ this.config = config;
11
+ this.container = null;
12
+ this.messagesContainer = null;
13
+ this.input = null;
14
+ this.isOpen = false;
15
+ this.isDragging = false;
16
+ this.dragOffset = { x: 0, y: 0 };
17
+ }
18
+
19
+ /**
20
+ * Render the chat widget to the DOM
21
+ */
22
+ render() {
23
+ // Create container
24
+ this.container = createElement('div', {
25
+ id: 'ask-junkie-widget',
26
+ className: 'ask-junkie-container'
27
+ });
28
+
29
+ // Apply position
30
+ this._applyPosition();
31
+
32
+ // Create toggle button
33
+ const toggleBtn = this._createToggleButton();
34
+ this.container.appendChild(toggleBtn);
35
+
36
+ // Create chat window
37
+ const chatWindow = this._createChatWindow();
38
+ this.container.appendChild(chatWindow);
39
+
40
+ // Add to DOM
41
+ document.body.appendChild(this.container);
42
+
43
+ // Apply theme colors
44
+ this._applyTheme();
45
+
46
+ // Load existing chat history
47
+ if (this.config.chatHistory?.length > 0) {
48
+ this._loadHistory(this.config.chatHistory);
49
+ }
50
+
51
+ // Set up event listeners
52
+ this._setupEventListeners();
53
+
54
+ // Enable dragging if configured
55
+ if (this.config.draggable) {
56
+ this._enableDragging(toggleBtn);
57
+ }
58
+
59
+ // Enable resizing if configured
60
+ if (this.config.resizable) {
61
+ this._enableResizing();
62
+ }
63
+
64
+ // Open on load if configured
65
+ if (this.config.openOnLoad) {
66
+ setTimeout(() => this.open(), 500);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Create the toggle button
72
+ */
73
+ _createToggleButton() {
74
+ const btn = createElement('button', {
75
+ id: 'ask-junkie-toggle',
76
+ className: 'ask-junkie-toggle',
77
+ 'aria-label': 'Toggle Chat'
78
+ });
79
+
80
+ // Default icon URL (hosted on jsDelivr CDN from npm package)
81
+ const defaultIconUrl = 'https://cdn.jsdelivr.net/npm/ask-junkie-sdk@latest/dist/junkie-icon.png';
82
+ const iconUrl = this.config.toggleIcon || defaultIconUrl;
83
+
84
+ btn.innerHTML = `
85
+ <img class="icon-chat" src="${iconUrl}" alt="Chat" onerror="this.style.display='none';this.nextElementSibling.style.display='block';">
86
+ <svg class="icon-chat-fallback" viewBox="0 0 24 24" width="28" height="28" fill="white" style="display:none">
87
+ <path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/>
88
+ </svg>
89
+ <svg class="icon-close" viewBox="0 0 24 24" width="28" height="28" fill="white" style="display:none">
90
+ <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
91
+ </svg>
92
+ `;
93
+
94
+ return btn;
95
+ }
96
+
97
+ /**
98
+ * Create the chat window
99
+ */
100
+ _createChatWindow() {
101
+ const window = createElement('div', {
102
+ id: 'ask-junkie-window',
103
+ className: 'ask-junkie-window'
104
+ });
105
+
106
+ window.style.display = 'none';
107
+
108
+ // Header
109
+ const header = createElement('div', { className: 'ask-junkie-header' });
110
+ const defaultAvatarUrl = 'https://cdn.jsdelivr.net/npm/ask-junkie-sdk@latest/dist/junkie-icon.png';
111
+ const avatarUrl = this.config.botAvatar || defaultAvatarUrl;
112
+ header.innerHTML = `
113
+ <div class="ask-junkie-avatar">
114
+ <img src="${escapeHtml(avatarUrl)}" alt="Bot">
115
+ </div>
116
+ <div class="ask-junkie-info">
117
+ <span class="ask-junkie-name">${escapeHtml(this.config.botName)}</span>
118
+ <span class="ask-junkie-status">Online • Ready to help</span>
119
+ </div>
120
+ <button class="ask-junkie-clear" title="Clear chat" style="display:none">
121
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
122
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
123
+ </svg>
124
+ </button>
125
+ `;
126
+ window.appendChild(header);
127
+
128
+ // Messages container
129
+ this.messagesContainer = createElement('div', {
130
+ id: 'ask-junkie-messages',
131
+ className: 'ask-junkie-messages'
132
+ });
133
+
134
+ // Add welcome message
135
+ const welcomeMsg = createElement('div', {
136
+ className: 'ask-junkie-message bot welcome'
137
+ });
138
+ welcomeMsg.innerHTML = `<div class="message-content">${escapeHtml(this.config.welcomeMessage)}</div>`;
139
+ this.messagesContainer.appendChild(welcomeMsg);
140
+
141
+ window.appendChild(this.messagesContainer);
142
+
143
+ // Input area
144
+ const inputArea = createElement('div', { className: 'ask-junkie-input-area' });
145
+
146
+ // Suggestions (animated or static based on config)
147
+ if (this.config.suggestions?.length > 0) {
148
+ if (this.config.animatedSuggestions) {
149
+ // Animated marquee suggestions
150
+ const suggestionsContainer = createElement('div', { className: 'ask-junkie-suggestions animated' });
151
+ const marqueeWrapper = createElement('div', { className: 'ask-junkie-marquee-wrapper' });
152
+ const marqueeContent = createElement('div', { className: 'ask-junkie-marquee-content' });
153
+
154
+ // Double the suggestions for seamless loop
155
+ const allSuggestions = [...this.config.suggestions, ...this.config.suggestions];
156
+
157
+ allSuggestions.forEach(text => {
158
+ const chip = createElement('button', {
159
+ className: 'ask-junkie-chip',
160
+ onClick: () => {
161
+ this.input.value = text;
162
+ this.input.focus();
163
+ }
164
+ }, [text]);
165
+ marqueeContent.appendChild(chip);
166
+ });
167
+
168
+ marqueeWrapper.appendChild(marqueeContent);
169
+ suggestionsContainer.appendChild(marqueeWrapper);
170
+
171
+ // Pause animation on hover
172
+ marqueeWrapper.addEventListener('mouseenter', () => {
173
+ marqueeContent.style.animationPlayState = 'paused';
174
+ });
175
+ marqueeWrapper.addEventListener('mouseleave', () => {
176
+ marqueeContent.style.animationPlayState = 'running';
177
+ });
178
+
179
+ inputArea.appendChild(suggestionsContainer);
180
+ } else {
181
+ // Static suggestions
182
+ const suggestionsContainer = createElement('div', { className: 'ask-junkie-suggestions' });
183
+ this.config.suggestions.forEach(text => {
184
+ const chip = createElement('button', {
185
+ className: 'ask-junkie-chip',
186
+ onClick: () => {
187
+ this.input.value = text;
188
+ this.input.focus();
189
+ }
190
+ }, [text]);
191
+ suggestionsContainer.appendChild(chip);
192
+ });
193
+ inputArea.appendChild(suggestionsContainer);
194
+ }
195
+ }
196
+
197
+ // Input row
198
+ const inputRow = createElement('div', { className: 'ask-junkie-input-row' });
199
+
200
+ this.input = createElement('input', {
201
+ type: 'text',
202
+ id: 'ask-junkie-input',
203
+ placeholder: 'Type your message...',
204
+ autocomplete: 'off'
205
+ });
206
+ inputRow.appendChild(this.input);
207
+
208
+ // Voice input button (if enabled)
209
+ if (this.config.voiceInput && 'webkitSpeechRecognition' in window) {
210
+ const micBtn = createElement('button', {
211
+ className: 'ask-junkie-mic',
212
+ title: 'Voice input',
213
+ onClick: () => this._startVoiceInput()
214
+ });
215
+ micBtn.innerHTML = `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>`;
216
+ inputRow.appendChild(micBtn);
217
+ }
218
+
219
+ // Send button
220
+ const sendBtn = createElement('button', {
221
+ className: 'ask-junkie-send',
222
+ onClick: () => this._sendMessage()
223
+ });
224
+ sendBtn.innerHTML = `<svg viewBox="0 0 24 24" width="20" height="20" fill="white"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>`;
225
+ inputRow.appendChild(sendBtn);
226
+
227
+ inputArea.appendChild(inputRow);
228
+ window.appendChild(inputArea);
229
+
230
+ return window;
231
+ }
232
+
233
+ /**
234
+ * Set up event listeners
235
+ */
236
+ _setupEventListeners() {
237
+ const toggleBtn = this.container.querySelector('#ask-junkie-toggle');
238
+ const clearBtn = this.container.querySelector('.ask-junkie-clear');
239
+
240
+ // Toggle button
241
+ toggleBtn.addEventListener('click', () => {
242
+ if (!this.isDragging) {
243
+ this.toggle();
244
+ }
245
+ });
246
+
247
+ // Input enter key
248
+ this.input.addEventListener('keypress', (e) => {
249
+ if (e.key === 'Enter') {
250
+ this._sendMessage();
251
+ }
252
+ });
253
+
254
+ // Clear button
255
+ if (clearBtn) {
256
+ clearBtn.addEventListener('click', (e) => {
257
+ e.stopPropagation();
258
+ if (confirm('Clear all chat history?')) {
259
+ this._clearMessages();
260
+ if (this.config.onClearHistory) {
261
+ this.config.onClearHistory();
262
+ }
263
+ }
264
+ });
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Apply position from config
270
+ */
271
+ _applyPosition() {
272
+ const pos = this.config.position || 'bottom-right';
273
+ const [vertical, horizontal] = pos.split('-');
274
+
275
+ this.container.style[vertical] = '24px';
276
+ this.container.style[horizontal] = '24px';
277
+ this.container.setAttribute('data-position', pos);
278
+ }
279
+
280
+ /**
281
+ * Apply theme colors
282
+ */
283
+ _applyTheme() {
284
+ const theme = this.config.theme;
285
+ let gradient;
286
+
287
+ if (theme.mode === 'solid') {
288
+ gradient = theme.primary;
289
+ } else {
290
+ gradient = `linear-gradient(135deg, ${theme.primary}, ${theme.secondary})`;
291
+ }
292
+
293
+ // Apply to toggle button and header
294
+ const toggleBtn = this.container.querySelector('.ask-junkie-toggle');
295
+ const header = this.container.querySelector('.ask-junkie-header');
296
+ const sendBtn = this.container.querySelector('.ask-junkie-send');
297
+
298
+ if (toggleBtn) toggleBtn.style.background = gradient;
299
+ if (header) header.style.background = gradient;
300
+ if (sendBtn) sendBtn.style.background = gradient;
301
+
302
+ // Set CSS variable for consistency
303
+ this.container.style.setProperty('--ask-junkie-primary', theme.primary);
304
+ this.container.style.setProperty('--ask-junkie-secondary', theme.secondary);
305
+ }
306
+
307
+ /**
308
+ * Send a message
309
+ */
310
+ _sendMessage() {
311
+ const message = this.input.value.trim();
312
+ if (!message) return;
313
+
314
+ // Add user message to UI
315
+ this.addMessage(message, 'user');
316
+ this.input.value = '';
317
+
318
+ // Update clear button visibility
319
+ this._updateClearButton();
320
+
321
+ // Trigger callback
322
+ if (this.config.onSendMessage) {
323
+ this.config.onSendMessage(message);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Add a message to the chat
329
+ */
330
+ addMessage(text, sender = 'bot') {
331
+ const msgDiv = createElement('div', {
332
+ className: `ask-junkie-message ${sender}`
333
+ });
334
+
335
+ const content = createElement('div', { className: 'message-content' });
336
+
337
+ if (sender === 'bot') {
338
+ content.innerHTML = formatBotResponse(text);
339
+ } else {
340
+ content.textContent = text;
341
+ }
342
+
343
+ msgDiv.appendChild(content);
344
+ this.messagesContainer.appendChild(msgDiv);
345
+ this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
346
+
347
+ this._updateClearButton();
348
+ }
349
+
350
+ /**
351
+ * Show typing indicator
352
+ */
353
+ showTyping() {
354
+ const typing = createElement('div', {
355
+ className: 'ask-junkie-message bot typing',
356
+ id: 'ask-junkie-typing'
357
+ });
358
+ typing.innerHTML = `
359
+ <div class="message-content">
360
+ <div class="typing-dots">
361
+ <span></span><span></span><span></span>
362
+ </div>
363
+ </div>
364
+ `;
365
+ this.messagesContainer.appendChild(typing);
366
+ this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
367
+ }
368
+
369
+ /**
370
+ * Hide typing indicator
371
+ */
372
+ hideTyping() {
373
+ const typing = document.getElementById('ask-junkie-typing');
374
+ if (typing) typing.remove();
375
+ }
376
+
377
+ /**
378
+ * Load chat history
379
+ */
380
+ _loadHistory(history) {
381
+ history.forEach(msg => {
382
+ const sender = msg.role === 'assistant' ? 'bot' : 'user';
383
+ this.addMessage(msg.content, sender);
384
+ });
385
+ this._updateClearButton();
386
+ }
387
+
388
+ /**
389
+ * Clear messages
390
+ */
391
+ _clearMessages() {
392
+ const messages = this.messagesContainer.querySelectorAll('.ask-junkie-message:not(.welcome)');
393
+ messages.forEach(m => m.remove());
394
+ this._updateClearButton();
395
+ }
396
+
397
+ /**
398
+ * Update clear button visibility
399
+ */
400
+ _updateClearButton() {
401
+ const clearBtn = this.container.querySelector('.ask-junkie-clear');
402
+ const hasMessages = this.messagesContainer.querySelectorAll('.ask-junkie-message:not(.welcome)').length > 0;
403
+ if (clearBtn) {
404
+ clearBtn.style.display = hasMessages ? 'flex' : 'none';
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Start voice input
410
+ */
411
+ _startVoiceInput() {
412
+ if (!('webkitSpeechRecognition' in window)) return;
413
+
414
+ const recognition = new webkitSpeechRecognition();
415
+ recognition.continuous = false;
416
+ recognition.interimResults = false;
417
+
418
+ const micBtn = this.container.querySelector('.ask-junkie-mic');
419
+ if (micBtn) micBtn.classList.add('listening');
420
+
421
+ recognition.onresult = (event) => {
422
+ const transcript = event.results[0][0].transcript;
423
+ this.input.value = transcript;
424
+ this.input.focus();
425
+ };
426
+
427
+ recognition.onend = () => {
428
+ if (micBtn) micBtn.classList.remove('listening');
429
+ };
430
+
431
+ recognition.start();
432
+ }
433
+
434
+ /**
435
+ * Enable dragging
436
+ */
437
+ _enableDragging(toggleBtn) {
438
+ let startX, startY, startLeft, startTop;
439
+
440
+ const onMouseDown = (e) => {
441
+ if (this.isOpen) return;
442
+
443
+ startX = e.clientX || e.touches?.[0]?.clientX;
444
+ startY = e.clientY || e.touches?.[0]?.clientY;
445
+
446
+ const rect = this.container.getBoundingClientRect();
447
+ startLeft = rect.left;
448
+ startTop = rect.top;
449
+
450
+ document.addEventListener('mousemove', onMouseMove);
451
+ document.addEventListener('mouseup', onMouseUp);
452
+ document.addEventListener('touchmove', onMouseMove);
453
+ document.addEventListener('touchend', onMouseUp);
454
+ };
455
+
456
+ const onMouseMove = (e) => {
457
+ const clientX = e.clientX || e.touches?.[0]?.clientX;
458
+ const clientY = e.clientY || e.touches?.[0]?.clientY;
459
+
460
+ const deltaX = clientX - startX;
461
+ const deltaY = clientY - startY;
462
+
463
+ if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
464
+ this.isDragging = true;
465
+ }
466
+
467
+ const newLeft = Math.max(0, Math.min(startLeft + deltaX, window.innerWidth - 70));
468
+ const newTop = Math.max(0, Math.min(startTop + deltaY, window.innerHeight - 70));
469
+
470
+ this.container.style.left = newLeft + 'px';
471
+ this.container.style.top = newTop + 'px';
472
+ this.container.style.right = 'auto';
473
+ this.container.style.bottom = 'auto';
474
+ };
475
+
476
+ const onMouseUp = () => {
477
+ document.removeEventListener('mousemove', onMouseMove);
478
+ document.removeEventListener('mouseup', onMouseUp);
479
+ document.removeEventListener('touchmove', onMouseMove);
480
+ document.removeEventListener('touchend', onMouseUp);
481
+
482
+ setTimeout(() => { this.isDragging = false; }, 100);
483
+ };
484
+
485
+ toggleBtn.addEventListener('mousedown', onMouseDown);
486
+ toggleBtn.addEventListener('touchstart', onMouseDown);
487
+ }
488
+
489
+ /**
490
+ * Enable resizing of chat window
491
+ */
492
+ _enableResizing() {
493
+ const chatWindow = this.container.querySelector('.ask-junkie-window');
494
+ if (!chatWindow) return;
495
+
496
+ // Create resize handle
497
+ const resizeHandle = createElement('div', { className: 'ask-junkie-resize-handle' });
498
+ resizeHandle.innerHTML = `<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M22,22H20V20H22V22M22,18H20V16H22V18M18,22H16V20H18V22M18,18H16V16H18V18M14,22H12V20H14V22M22,14H20V12H22V14Z"/></svg>`;
499
+ chatWindow.appendChild(resizeHandle);
500
+
501
+ // Load saved size from localStorage
502
+ const savedSize = localStorage.getItem('ask_junkie_chat_size');
503
+ if (savedSize) {
504
+ try {
505
+ const { width, height } = JSON.parse(savedSize);
506
+ chatWindow.style.width = width + 'px';
507
+ chatWindow.style.height = height + 'px';
508
+ } catch (e) {
509
+ // Ignore parse errors
510
+ }
511
+ }
512
+
513
+ let isResizing = false;
514
+ let startX, startY, startWidth, startHeight;
515
+
516
+ const onMouseDown = (e) => {
517
+ e.preventDefault();
518
+ e.stopPropagation();
519
+ isResizing = true;
520
+
521
+ startX = e.clientX || e.touches?.[0]?.clientX;
522
+ startY = e.clientY || e.touches?.[0]?.clientY;
523
+ startWidth = chatWindow.offsetWidth;
524
+ startHeight = chatWindow.offsetHeight;
525
+
526
+ document.addEventListener('mousemove', onMouseMove);
527
+ document.addEventListener('mouseup', onMouseUp);
528
+ document.addEventListener('touchmove', onMouseMove);
529
+ document.addEventListener('touchend', onMouseUp);
530
+ };
531
+
532
+ const onMouseMove = (e) => {
533
+ if (!isResizing) return;
534
+
535
+ const clientX = e.clientX || e.touches?.[0]?.clientX;
536
+ const clientY = e.clientY || e.touches?.[0]?.clientY;
537
+
538
+ // Calculate new dimensions (resizing from top-left corner)
539
+ const deltaX = startX - clientX;
540
+ const deltaY = startY - clientY;
541
+
542
+ const newWidth = Math.max(280, Math.min(startWidth + deltaX, window.innerWidth - 40));
543
+ const newHeight = Math.max(350, Math.min(startHeight + deltaY, window.innerHeight - 100));
544
+
545
+ chatWindow.style.width = newWidth + 'px';
546
+ chatWindow.style.height = newHeight + 'px';
547
+ };
548
+
549
+ const onMouseUp = () => {
550
+ if (isResizing) {
551
+ // Save size to localStorage
552
+ localStorage.setItem('ask_junkie_chat_size', JSON.stringify({
553
+ width: chatWindow.offsetWidth,
554
+ height: chatWindow.offsetHeight
555
+ }));
556
+ }
557
+ isResizing = false;
558
+ document.removeEventListener('mousemove', onMouseMove);
559
+ document.removeEventListener('mouseup', onMouseUp);
560
+ document.removeEventListener('touchmove', onMouseMove);
561
+ document.removeEventListener('touchend', onMouseUp);
562
+ };
563
+
564
+ resizeHandle.addEventListener('mousedown', onMouseDown);
565
+ resizeHandle.addEventListener('touchstart', onMouseDown);
566
+ }
567
+
568
+ // ===== PUBLIC METHODS =====
569
+
570
+ open() {
571
+ this.isOpen = true;
572
+ const window = this.container.querySelector('.ask-junkie-window');
573
+ const chatIcon = this.container.querySelector('.icon-chat');
574
+ const closeIcon = this.container.querySelector('.icon-close');
575
+
576
+ window.style.display = 'flex';
577
+ chatIcon.style.display = 'none';
578
+ closeIcon.style.display = 'block';
579
+ this.input.focus();
580
+
581
+ if (this.config.onOpen) this.config.onOpen();
582
+ }
583
+
584
+ close() {
585
+ this.isOpen = false;
586
+ const window = this.container.querySelector('.ask-junkie-window');
587
+ const chatIcon = this.container.querySelector('.icon-chat');
588
+ const closeIcon = this.container.querySelector('.icon-close');
589
+
590
+ window.style.display = 'none';
591
+ chatIcon.style.display = 'block';
592
+ closeIcon.style.display = 'none';
593
+
594
+ if (this.config.onClose) this.config.onClose();
595
+ }
596
+
597
+ toggle() {
598
+ if (this.isOpen) {
599
+ this.close();
600
+ } else {
601
+ this.open();
602
+ }
603
+ }
604
+
605
+ destroy() {
606
+ if (this.container) {
607
+ this.container.remove();
608
+ this.container = null;
609
+ }
610
+ }
611
+ }