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,1847 @@
1
+ /**
2
+ * DOM Utility Functions
3
+ */
4
+
5
+ /**
6
+ * Escape HTML to prevent XSS
7
+ */
8
+ function escapeHtml(text) {
9
+ const div = document.createElement('div');
10
+ div.textContent = text;
11
+ return div.innerHTML;
12
+ }
13
+
14
+ /**
15
+ * Create an element with attributes and children
16
+ */
17
+ function createElement(tag, attributes = {}, children = []) {
18
+ const element = document.createElement(tag);
19
+
20
+ Object.entries(attributes).forEach(([key, value]) => {
21
+ if (key === 'className') {
22
+ element.className = value;
23
+ } else if (key === 'style' && typeof value === 'object') {
24
+ Object.assign(element.style, value);
25
+ } else if (key.startsWith('on') && typeof value === 'function') {
26
+ element.addEventListener(key.substring(2).toLowerCase(), value);
27
+ } else {
28
+ element.setAttribute(key, value);
29
+ }
30
+ });
31
+
32
+ children.forEach(child => {
33
+ if (typeof child === 'string') {
34
+ element.appendChild(document.createTextNode(child));
35
+ } else if (child instanceof Node) {
36
+ element.appendChild(child);
37
+ }
38
+ });
39
+
40
+ return element;
41
+ }
42
+
43
+ /**
44
+ * Parse markdown-style links [text](url) to HTML
45
+ */
46
+ function parseLinks(text) {
47
+ return text.replace(
48
+ /\[([^\]]+)\]\(([^)]+)\)/g,
49
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Parse markdown bold **text** to HTML
55
+ */
56
+ function parseBold(text) {
57
+ return text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
58
+ }
59
+
60
+ /**
61
+ * Format bot response with basic markdown
62
+ */
63
+ function formatBotResponse(text) {
64
+ let formatted = escapeHtml(text);
65
+ formatted = parseLinks(formatted);
66
+ formatted = parseBold(formatted);
67
+ formatted = formatted.replace(/\n/g, '<br>');
68
+ return formatted;
69
+ }
70
+
71
+ /**
72
+ * Chat Widget UI Component
73
+ * Renders and manages the chatbot interface
74
+ */
75
+
76
+
77
+ class ChatWidget {
78
+ constructor(config) {
79
+ this.config = config;
80
+ this.container = null;
81
+ this.messagesContainer = null;
82
+ this.input = null;
83
+ this.isOpen = false;
84
+ this.isDragging = false;
85
+ this.dragOffset = { x: 0, y: 0 };
86
+ }
87
+
88
+ /**
89
+ * Render the chat widget to the DOM
90
+ */
91
+ render() {
92
+ // Create container
93
+ this.container = createElement('div', {
94
+ id: 'ask-junkie-widget',
95
+ className: 'ask-junkie-container'
96
+ });
97
+
98
+ // Apply position
99
+ this._applyPosition();
100
+
101
+ // Create toggle button
102
+ const toggleBtn = this._createToggleButton();
103
+ this.container.appendChild(toggleBtn);
104
+
105
+ // Create chat window
106
+ const chatWindow = this._createChatWindow();
107
+ this.container.appendChild(chatWindow);
108
+
109
+ // Add to DOM
110
+ document.body.appendChild(this.container);
111
+
112
+ // Apply theme colors
113
+ this._applyTheme();
114
+
115
+ // Load existing chat history
116
+ if (this.config.chatHistory?.length > 0) {
117
+ this._loadHistory(this.config.chatHistory);
118
+ }
119
+
120
+ // Set up event listeners
121
+ this._setupEventListeners();
122
+
123
+ // Enable dragging if configured
124
+ if (this.config.draggable) {
125
+ this._enableDragging(toggleBtn);
126
+ }
127
+
128
+ // Enable resizing if configured
129
+ if (this.config.resizable) {
130
+ this._enableResizing();
131
+ }
132
+
133
+ // Open on load if configured
134
+ if (this.config.openOnLoad) {
135
+ setTimeout(() => this.open(), 500);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Create the toggle button
141
+ */
142
+ _createToggleButton() {
143
+ const btn = createElement('button', {
144
+ id: 'ask-junkie-toggle',
145
+ className: 'ask-junkie-toggle',
146
+ 'aria-label': 'Toggle Chat'
147
+ });
148
+
149
+ // Default icon URL (hosted on jsDelivr CDN from npm package)
150
+ const defaultIconUrl = 'https://cdn.jsdelivr.net/npm/ask-junkie-sdk@latest/dist/junkie-icon.png';
151
+ const iconUrl = this.config.toggleIcon || defaultIconUrl;
152
+
153
+ btn.innerHTML = `
154
+ <img class="icon-chat" src="${iconUrl}" alt="Chat" onerror="this.style.display='none';this.nextElementSibling.style.display='block';">
155
+ <svg class="icon-chat-fallback" viewBox="0 0 24 24" width="28" height="28" fill="white" style="display:none">
156
+ <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"/>
157
+ </svg>
158
+ <svg class="icon-close" viewBox="0 0 24 24" width="28" height="28" fill="white" style="display:none">
159
+ <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"/>
160
+ </svg>
161
+ `;
162
+
163
+ return btn;
164
+ }
165
+
166
+ /**
167
+ * Create the chat window
168
+ */
169
+ _createChatWindow() {
170
+ const window = createElement('div', {
171
+ id: 'ask-junkie-window',
172
+ className: 'ask-junkie-window'
173
+ });
174
+
175
+ window.style.display = 'none';
176
+
177
+ // Header
178
+ const header = createElement('div', { className: 'ask-junkie-header' });
179
+ const defaultAvatarUrl = 'https://cdn.jsdelivr.net/npm/ask-junkie-sdk@latest/dist/junkie-icon.png';
180
+ const avatarUrl = this.config.botAvatar || defaultAvatarUrl;
181
+ header.innerHTML = `
182
+ <div class="ask-junkie-avatar">
183
+ <img src="${escapeHtml(avatarUrl)}" alt="Bot">
184
+ </div>
185
+ <div class="ask-junkie-info">
186
+ <span class="ask-junkie-name">${escapeHtml(this.config.botName)}</span>
187
+ <span class="ask-junkie-status">Online • Ready to help</span>
188
+ </div>
189
+ <button class="ask-junkie-clear" title="Clear chat" style="display:none">
190
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
191
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
192
+ </svg>
193
+ </button>
194
+ `;
195
+ window.appendChild(header);
196
+
197
+ // Messages container
198
+ this.messagesContainer = createElement('div', {
199
+ id: 'ask-junkie-messages',
200
+ className: 'ask-junkie-messages'
201
+ });
202
+
203
+ // Add welcome message
204
+ const welcomeMsg = createElement('div', {
205
+ className: 'ask-junkie-message bot welcome'
206
+ });
207
+ welcomeMsg.innerHTML = `<div class="message-content">${escapeHtml(this.config.welcomeMessage)}</div>`;
208
+ this.messagesContainer.appendChild(welcomeMsg);
209
+
210
+ window.appendChild(this.messagesContainer);
211
+
212
+ // Input area
213
+ const inputArea = createElement('div', { className: 'ask-junkie-input-area' });
214
+
215
+ // Suggestions (animated or static based on config)
216
+ if (this.config.suggestions?.length > 0) {
217
+ if (this.config.animatedSuggestions) {
218
+ // Animated marquee suggestions
219
+ const suggestionsContainer = createElement('div', { className: 'ask-junkie-suggestions animated' });
220
+ const marqueeWrapper = createElement('div', { className: 'ask-junkie-marquee-wrapper' });
221
+ const marqueeContent = createElement('div', { className: 'ask-junkie-marquee-content' });
222
+
223
+ // Double the suggestions for seamless loop
224
+ const allSuggestions = [...this.config.suggestions, ...this.config.suggestions];
225
+
226
+ allSuggestions.forEach(text => {
227
+ const chip = createElement('button', {
228
+ className: 'ask-junkie-chip',
229
+ onClick: () => {
230
+ this.input.value = text;
231
+ this.input.focus();
232
+ }
233
+ }, [text]);
234
+ marqueeContent.appendChild(chip);
235
+ });
236
+
237
+ marqueeWrapper.appendChild(marqueeContent);
238
+ suggestionsContainer.appendChild(marqueeWrapper);
239
+
240
+ // Pause animation on hover
241
+ marqueeWrapper.addEventListener('mouseenter', () => {
242
+ marqueeContent.style.animationPlayState = 'paused';
243
+ });
244
+ marqueeWrapper.addEventListener('mouseleave', () => {
245
+ marqueeContent.style.animationPlayState = 'running';
246
+ });
247
+
248
+ inputArea.appendChild(suggestionsContainer);
249
+ } else {
250
+ // Static suggestions
251
+ const suggestionsContainer = createElement('div', { className: 'ask-junkie-suggestions' });
252
+ this.config.suggestions.forEach(text => {
253
+ const chip = createElement('button', {
254
+ className: 'ask-junkie-chip',
255
+ onClick: () => {
256
+ this.input.value = text;
257
+ this.input.focus();
258
+ }
259
+ }, [text]);
260
+ suggestionsContainer.appendChild(chip);
261
+ });
262
+ inputArea.appendChild(suggestionsContainer);
263
+ }
264
+ }
265
+
266
+ // Input row
267
+ const inputRow = createElement('div', { className: 'ask-junkie-input-row' });
268
+
269
+ this.input = createElement('input', {
270
+ type: 'text',
271
+ id: 'ask-junkie-input',
272
+ placeholder: 'Type your message...',
273
+ autocomplete: 'off'
274
+ });
275
+ inputRow.appendChild(this.input);
276
+
277
+ // Voice input button (if enabled)
278
+ if (this.config.voiceInput && 'webkitSpeechRecognition' in window) {
279
+ const micBtn = createElement('button', {
280
+ className: 'ask-junkie-mic',
281
+ title: 'Voice input',
282
+ onClick: () => this._startVoiceInput()
283
+ });
284
+ 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>`;
285
+ inputRow.appendChild(micBtn);
286
+ }
287
+
288
+ // Send button
289
+ const sendBtn = createElement('button', {
290
+ className: 'ask-junkie-send',
291
+ onClick: () => this._sendMessage()
292
+ });
293
+ 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>`;
294
+ inputRow.appendChild(sendBtn);
295
+
296
+ inputArea.appendChild(inputRow);
297
+ window.appendChild(inputArea);
298
+
299
+ return window;
300
+ }
301
+
302
+ /**
303
+ * Set up event listeners
304
+ */
305
+ _setupEventListeners() {
306
+ const toggleBtn = this.container.querySelector('#ask-junkie-toggle');
307
+ const clearBtn = this.container.querySelector('.ask-junkie-clear');
308
+
309
+ // Toggle button
310
+ toggleBtn.addEventListener('click', () => {
311
+ if (!this.isDragging) {
312
+ this.toggle();
313
+ }
314
+ });
315
+
316
+ // Input enter key
317
+ this.input.addEventListener('keypress', (e) => {
318
+ if (e.key === 'Enter') {
319
+ this._sendMessage();
320
+ }
321
+ });
322
+
323
+ // Clear button
324
+ if (clearBtn) {
325
+ clearBtn.addEventListener('click', (e) => {
326
+ e.stopPropagation();
327
+ if (confirm('Clear all chat history?')) {
328
+ this._clearMessages();
329
+ if (this.config.onClearHistory) {
330
+ this.config.onClearHistory();
331
+ }
332
+ }
333
+ });
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Apply position from config
339
+ */
340
+ _applyPosition() {
341
+ const pos = this.config.position || 'bottom-right';
342
+ const [vertical, horizontal] = pos.split('-');
343
+
344
+ this.container.style[vertical] = '24px';
345
+ this.container.style[horizontal] = '24px';
346
+ this.container.setAttribute('data-position', pos);
347
+ }
348
+
349
+ /**
350
+ * Apply theme colors
351
+ */
352
+ _applyTheme() {
353
+ const theme = this.config.theme;
354
+ let gradient;
355
+
356
+ if (theme.mode === 'solid') {
357
+ gradient = theme.primary;
358
+ } else {
359
+ gradient = `linear-gradient(135deg, ${theme.primary}, ${theme.secondary})`;
360
+ }
361
+
362
+ // Apply to toggle button and header
363
+ const toggleBtn = this.container.querySelector('.ask-junkie-toggle');
364
+ const header = this.container.querySelector('.ask-junkie-header');
365
+ const sendBtn = this.container.querySelector('.ask-junkie-send');
366
+
367
+ if (toggleBtn) toggleBtn.style.background = gradient;
368
+ if (header) header.style.background = gradient;
369
+ if (sendBtn) sendBtn.style.background = gradient;
370
+
371
+ // Set CSS variable for consistency
372
+ this.container.style.setProperty('--ask-junkie-primary', theme.primary);
373
+ this.container.style.setProperty('--ask-junkie-secondary', theme.secondary);
374
+ }
375
+
376
+ /**
377
+ * Send a message
378
+ */
379
+ _sendMessage() {
380
+ const message = this.input.value.trim();
381
+ if (!message) return;
382
+
383
+ // Add user message to UI
384
+ this.addMessage(message, 'user');
385
+ this.input.value = '';
386
+
387
+ // Update clear button visibility
388
+ this._updateClearButton();
389
+
390
+ // Trigger callback
391
+ if (this.config.onSendMessage) {
392
+ this.config.onSendMessage(message);
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Add a message to the chat
398
+ */
399
+ addMessage(text, sender = 'bot') {
400
+ const msgDiv = createElement('div', {
401
+ className: `ask-junkie-message ${sender}`
402
+ });
403
+
404
+ const content = createElement('div', { className: 'message-content' });
405
+
406
+ if (sender === 'bot') {
407
+ content.innerHTML = formatBotResponse(text);
408
+ } else {
409
+ content.textContent = text;
410
+ }
411
+
412
+ msgDiv.appendChild(content);
413
+ this.messagesContainer.appendChild(msgDiv);
414
+ this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
415
+
416
+ this._updateClearButton();
417
+ }
418
+
419
+ /**
420
+ * Show typing indicator
421
+ */
422
+ showTyping() {
423
+ const typing = createElement('div', {
424
+ className: 'ask-junkie-message bot typing',
425
+ id: 'ask-junkie-typing'
426
+ });
427
+ typing.innerHTML = `
428
+ <div class="message-content">
429
+ <div class="typing-dots">
430
+ <span></span><span></span><span></span>
431
+ </div>
432
+ </div>
433
+ `;
434
+ this.messagesContainer.appendChild(typing);
435
+ this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
436
+ }
437
+
438
+ /**
439
+ * Hide typing indicator
440
+ */
441
+ hideTyping() {
442
+ const typing = document.getElementById('ask-junkie-typing');
443
+ if (typing) typing.remove();
444
+ }
445
+
446
+ /**
447
+ * Load chat history
448
+ */
449
+ _loadHistory(history) {
450
+ history.forEach(msg => {
451
+ const sender = msg.role === 'assistant' ? 'bot' : 'user';
452
+ this.addMessage(msg.content, sender);
453
+ });
454
+ this._updateClearButton();
455
+ }
456
+
457
+ /**
458
+ * Clear messages
459
+ */
460
+ _clearMessages() {
461
+ const messages = this.messagesContainer.querySelectorAll('.ask-junkie-message:not(.welcome)');
462
+ messages.forEach(m => m.remove());
463
+ this._updateClearButton();
464
+ }
465
+
466
+ /**
467
+ * Update clear button visibility
468
+ */
469
+ _updateClearButton() {
470
+ const clearBtn = this.container.querySelector('.ask-junkie-clear');
471
+ const hasMessages = this.messagesContainer.querySelectorAll('.ask-junkie-message:not(.welcome)').length > 0;
472
+ if (clearBtn) {
473
+ clearBtn.style.display = hasMessages ? 'flex' : 'none';
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Start voice input
479
+ */
480
+ _startVoiceInput() {
481
+ if (!('webkitSpeechRecognition' in window)) return;
482
+
483
+ const recognition = new webkitSpeechRecognition();
484
+ recognition.continuous = false;
485
+ recognition.interimResults = false;
486
+
487
+ const micBtn = this.container.querySelector('.ask-junkie-mic');
488
+ if (micBtn) micBtn.classList.add('listening');
489
+
490
+ recognition.onresult = (event) => {
491
+ const transcript = event.results[0][0].transcript;
492
+ this.input.value = transcript;
493
+ this.input.focus();
494
+ };
495
+
496
+ recognition.onend = () => {
497
+ if (micBtn) micBtn.classList.remove('listening');
498
+ };
499
+
500
+ recognition.start();
501
+ }
502
+
503
+ /**
504
+ * Enable dragging
505
+ */
506
+ _enableDragging(toggleBtn) {
507
+ let startX, startY, startLeft, startTop;
508
+
509
+ const onMouseDown = (e) => {
510
+ if (this.isOpen) return;
511
+
512
+ startX = e.clientX || e.touches?.[0]?.clientX;
513
+ startY = e.clientY || e.touches?.[0]?.clientY;
514
+
515
+ const rect = this.container.getBoundingClientRect();
516
+ startLeft = rect.left;
517
+ startTop = rect.top;
518
+
519
+ document.addEventListener('mousemove', onMouseMove);
520
+ document.addEventListener('mouseup', onMouseUp);
521
+ document.addEventListener('touchmove', onMouseMove);
522
+ document.addEventListener('touchend', onMouseUp);
523
+ };
524
+
525
+ const onMouseMove = (e) => {
526
+ const clientX = e.clientX || e.touches?.[0]?.clientX;
527
+ const clientY = e.clientY || e.touches?.[0]?.clientY;
528
+
529
+ const deltaX = clientX - startX;
530
+ const deltaY = clientY - startY;
531
+
532
+ if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
533
+ this.isDragging = true;
534
+ }
535
+
536
+ const newLeft = Math.max(0, Math.min(startLeft + deltaX, window.innerWidth - 70));
537
+ const newTop = Math.max(0, Math.min(startTop + deltaY, window.innerHeight - 70));
538
+
539
+ this.container.style.left = newLeft + 'px';
540
+ this.container.style.top = newTop + 'px';
541
+ this.container.style.right = 'auto';
542
+ this.container.style.bottom = 'auto';
543
+ };
544
+
545
+ const onMouseUp = () => {
546
+ document.removeEventListener('mousemove', onMouseMove);
547
+ document.removeEventListener('mouseup', onMouseUp);
548
+ document.removeEventListener('touchmove', onMouseMove);
549
+ document.removeEventListener('touchend', onMouseUp);
550
+
551
+ setTimeout(() => { this.isDragging = false; }, 100);
552
+ };
553
+
554
+ toggleBtn.addEventListener('mousedown', onMouseDown);
555
+ toggleBtn.addEventListener('touchstart', onMouseDown);
556
+ }
557
+
558
+ /**
559
+ * Enable resizing of chat window
560
+ */
561
+ _enableResizing() {
562
+ const chatWindow = this.container.querySelector('.ask-junkie-window');
563
+ if (!chatWindow) return;
564
+
565
+ // Create resize handle
566
+ const resizeHandle = createElement('div', { className: 'ask-junkie-resize-handle' });
567
+ resizeHandle.innerHTML = `<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M22,22H20V20H22V22M22,18H20V16H22V18M18,22H16V20H18V22M18,18H16V16H18V18M14,22H12V20H14V22M22,14H20V12H22V14Z"/></svg>`;
568
+ chatWindow.appendChild(resizeHandle);
569
+
570
+ // Load saved size from localStorage
571
+ const savedSize = localStorage.getItem('ask_junkie_chat_size');
572
+ if (savedSize) {
573
+ try {
574
+ const { width, height } = JSON.parse(savedSize);
575
+ chatWindow.style.width = width + 'px';
576
+ chatWindow.style.height = height + 'px';
577
+ } catch (e) {
578
+ // Ignore parse errors
579
+ }
580
+ }
581
+
582
+ let isResizing = false;
583
+ let startX, startY, startWidth, startHeight;
584
+
585
+ const onMouseDown = (e) => {
586
+ e.preventDefault();
587
+ e.stopPropagation();
588
+ isResizing = true;
589
+
590
+ startX = e.clientX || e.touches?.[0]?.clientX;
591
+ startY = e.clientY || e.touches?.[0]?.clientY;
592
+ startWidth = chatWindow.offsetWidth;
593
+ startHeight = chatWindow.offsetHeight;
594
+
595
+ document.addEventListener('mousemove', onMouseMove);
596
+ document.addEventListener('mouseup', onMouseUp);
597
+ document.addEventListener('touchmove', onMouseMove);
598
+ document.addEventListener('touchend', onMouseUp);
599
+ };
600
+
601
+ const onMouseMove = (e) => {
602
+ if (!isResizing) return;
603
+
604
+ const clientX = e.clientX || e.touches?.[0]?.clientX;
605
+ const clientY = e.clientY || e.touches?.[0]?.clientY;
606
+
607
+ // Calculate new dimensions (resizing from top-left corner)
608
+ const deltaX = startX - clientX;
609
+ const deltaY = startY - clientY;
610
+
611
+ const newWidth = Math.max(280, Math.min(startWidth + deltaX, window.innerWidth - 40));
612
+ const newHeight = Math.max(350, Math.min(startHeight + deltaY, window.innerHeight - 100));
613
+
614
+ chatWindow.style.width = newWidth + 'px';
615
+ chatWindow.style.height = newHeight + 'px';
616
+ };
617
+
618
+ const onMouseUp = () => {
619
+ if (isResizing) {
620
+ // Save size to localStorage
621
+ localStorage.setItem('ask_junkie_chat_size', JSON.stringify({
622
+ width: chatWindow.offsetWidth,
623
+ height: chatWindow.offsetHeight
624
+ }));
625
+ }
626
+ isResizing = false;
627
+ document.removeEventListener('mousemove', onMouseMove);
628
+ document.removeEventListener('mouseup', onMouseUp);
629
+ document.removeEventListener('touchmove', onMouseMove);
630
+ document.removeEventListener('touchend', onMouseUp);
631
+ };
632
+
633
+ resizeHandle.addEventListener('mousedown', onMouseDown);
634
+ resizeHandle.addEventListener('touchstart', onMouseDown);
635
+ }
636
+
637
+ // ===== PUBLIC METHODS =====
638
+
639
+ open() {
640
+ this.isOpen = true;
641
+ const window = this.container.querySelector('.ask-junkie-window');
642
+ const chatIcon = this.container.querySelector('.icon-chat');
643
+ const closeIcon = this.container.querySelector('.icon-close');
644
+
645
+ window.style.display = 'flex';
646
+ chatIcon.style.display = 'none';
647
+ closeIcon.style.display = 'block';
648
+ this.input.focus();
649
+
650
+ if (this.config.onOpen) this.config.onOpen();
651
+ }
652
+
653
+ close() {
654
+ this.isOpen = false;
655
+ const window = this.container.querySelector('.ask-junkie-window');
656
+ const chatIcon = this.container.querySelector('.icon-chat');
657
+ const closeIcon = this.container.querySelector('.icon-close');
658
+
659
+ window.style.display = 'none';
660
+ chatIcon.style.display = 'block';
661
+ closeIcon.style.display = 'none';
662
+
663
+ if (this.config.onClose) this.config.onClose();
664
+ }
665
+
666
+ toggle() {
667
+ if (this.isOpen) {
668
+ this.close();
669
+ } else {
670
+ this.open();
671
+ }
672
+ }
673
+
674
+ destroy() {
675
+ if (this.container) {
676
+ this.container.remove();
677
+ this.container = null;
678
+ }
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Base AI Provider Interface
684
+ * All AI providers extend this class
685
+ */
686
+
687
+ class BaseProvider {
688
+ constructor(apiKey, model = null, proxyUrl = null) {
689
+ this.apiKey = apiKey;
690
+ this.model = model || this.getDefaultModel();
691
+ this.proxyUrl = proxyUrl;
692
+ }
693
+
694
+ /**
695
+ * Get the default model for this provider
696
+ */
697
+ getDefaultModel() {
698
+ throw new Error('getDefaultModel must be implemented by subclass');
699
+ }
700
+
701
+ /**
702
+ * Get the API endpoint URL
703
+ */
704
+ getEndpoint() {
705
+ throw new Error('getEndpoint must be implemented by subclass');
706
+ }
707
+
708
+ /**
709
+ * Send a message to the AI provider
710
+ * @param {string} message - User message
711
+ * @param {string} context - System context/prompt
712
+ * @param {Array} history - Conversation history
713
+ * @returns {Promise<string>} AI response
714
+ */
715
+ async sendMessage(message, context, history = []) {
716
+ throw new Error('sendMessage must be implemented by subclass');
717
+ }
718
+
719
+ /**
720
+ * Make HTTP request (supports proxy mode)
721
+ */
722
+ async _fetch(url, options) {
723
+ this.proxyUrl || url;
724
+
725
+ if (this.proxyUrl) {
726
+ // Proxy mode - send the original URL and options to proxy
727
+ return fetch(this.proxyUrl, {
728
+ method: 'POST',
729
+ headers: { 'Content-Type': 'application/json' },
730
+ body: JSON.stringify({
731
+ targetUrl: url,
732
+ ...options
733
+ })
734
+ });
735
+ }
736
+
737
+ return fetch(url, options);
738
+ }
739
+
740
+ /**
741
+ * Format conversation history for chat API
742
+ */
743
+ _formatHistory(history) {
744
+ return history.map(msg => ({
745
+ role: msg.role === 'assistant' ? 'assistant' : 'user',
746
+ content: msg.content
747
+ }));
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Groq AI Provider
753
+ * Fast LLM inference using Groq Cloud
754
+ */
755
+
756
+
757
+ class GroqProvider extends BaseProvider {
758
+ getDefaultModel() {
759
+ return 'openai/gpt-oss-120b';
760
+ }
761
+
762
+ getEndpoint() {
763
+ return 'https://api.groq.com/openai/v1/chat/completions';
764
+ }
765
+
766
+ async sendMessage(message, context, history = []) {
767
+ const messages = [
768
+ { role: 'system', content: context },
769
+ ...this._formatHistory(history),
770
+ { role: 'user', content: message }
771
+ ];
772
+
773
+ const response = await this._fetch(this.getEndpoint(), {
774
+ method: 'POST',
775
+ headers: {
776
+ 'Content-Type': 'application/json',
777
+ 'Authorization': `Bearer ${this.apiKey}`
778
+ },
779
+ body: JSON.stringify({
780
+ model: this.model,
781
+ messages: messages,
782
+ temperature: 0.7,
783
+ max_tokens: 1000
784
+ })
785
+ });
786
+
787
+ if (!response.ok) {
788
+ const error = await response.json().catch(() => ({}));
789
+ throw new Error(error.error?.message || `Groq API error: ${response.status}`);
790
+ }
791
+
792
+ const data = await response.json();
793
+
794
+ if (data.choices?.[0]?.message?.content) {
795
+ return data.choices[0].message.content;
796
+ }
797
+
798
+ throw new Error('Invalid response from Groq API');
799
+ }
800
+ }
801
+
802
+ /**
803
+ * Google Gemini AI Provider
804
+ */
805
+
806
+
807
+ class GeminiProvider extends BaseProvider {
808
+ getDefaultModel() {
809
+ return 'gemini-1.5-flash';
810
+ }
811
+
812
+ getEndpoint() {
813
+ return `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent`;
814
+ }
815
+
816
+ async sendMessage(message, context, history = []) {
817
+ // Build conversation text
818
+ let fullPrompt = context + '\n\n';
819
+
820
+ history.forEach(msg => {
821
+ const role = msg.role === 'user' ? 'User' : 'Assistant';
822
+ fullPrompt += `${role}: ${msg.content}\n`;
823
+ });
824
+
825
+ fullPrompt += `User: ${message}`;
826
+
827
+ const url = `${this.getEndpoint()}?key=${this.apiKey}`;
828
+
829
+ const response = await this._fetch(url, {
830
+ method: 'POST',
831
+ headers: {
832
+ 'Content-Type': 'application/json'
833
+ },
834
+ body: JSON.stringify({
835
+ contents: [{
836
+ parts: [{ text: fullPrompt }]
837
+ }],
838
+ generationConfig: {
839
+ temperature: 0.7,
840
+ maxOutputTokens: 1000
841
+ }
842
+ })
843
+ });
844
+
845
+ if (!response.ok) {
846
+ const error = await response.json().catch(() => ({}));
847
+ throw new Error(error.error?.message || `Gemini API error: ${response.status}`);
848
+ }
849
+
850
+ const data = await response.json();
851
+
852
+ if (data.candidates?.[0]?.content?.parts?.[0]?.text) {
853
+ return data.candidates[0].content.parts[0].text;
854
+ }
855
+
856
+ throw new Error('Invalid response from Gemini API');
857
+ }
858
+ }
859
+
860
+ /**
861
+ * OpenAI Provider
862
+ * GPT-3.5 and GPT-4 models
863
+ */
864
+
865
+
866
+ class OpenAIProvider extends BaseProvider {
867
+ getDefaultModel() {
868
+ return 'gpt-3.5-turbo';
869
+ }
870
+
871
+ getEndpoint() {
872
+ return 'https://api.openai.com/v1/chat/completions';
873
+ }
874
+
875
+ async sendMessage(message, context, history = []) {
876
+ const messages = [
877
+ { role: 'system', content: context },
878
+ ...this._formatHistory(history),
879
+ { role: 'user', content: message }
880
+ ];
881
+
882
+ const response = await this._fetch(this.getEndpoint(), {
883
+ method: 'POST',
884
+ headers: {
885
+ 'Content-Type': 'application/json',
886
+ 'Authorization': `Bearer ${this.apiKey}`
887
+ },
888
+ body: JSON.stringify({
889
+ model: this.model,
890
+ messages: messages,
891
+ temperature: 0.7,
892
+ max_tokens: 1000
893
+ })
894
+ });
895
+
896
+ if (!response.ok) {
897
+ const error = await response.json().catch(() => ({}));
898
+ throw new Error(error.error?.message || `OpenAI API error: ${response.status}`);
899
+ }
900
+
901
+ const data = await response.json();
902
+
903
+ if (data.choices?.[0]?.message?.content) {
904
+ return data.choices[0].message.content;
905
+ }
906
+
907
+ throw new Error('Invalid response from OpenAI API');
908
+ }
909
+ }
910
+
911
+ /**
912
+ * OpenRouter Provider
913
+ * Access multiple AI models through one API
914
+ */
915
+
916
+
917
+ class OpenRouterProvider extends BaseProvider {
918
+ getDefaultModel() {
919
+ return 'meta-llama/llama-3-8b-instruct';
920
+ }
921
+
922
+ getEndpoint() {
923
+ return 'https://openrouter.ai/api/v1/chat/completions';
924
+ }
925
+
926
+ async sendMessage(message, context, history = []) {
927
+ const messages = [
928
+ { role: 'system', content: context },
929
+ ...this._formatHistory(history),
930
+ { role: 'user', content: message }
931
+ ];
932
+
933
+ const response = await this._fetch(this.getEndpoint(), {
934
+ method: 'POST',
935
+ headers: {
936
+ 'Content-Type': 'application/json',
937
+ 'Authorization': `Bearer ${this.apiKey}`,
938
+ 'HTTP-Referer': window.location.origin,
939
+ 'X-Title': document.title || 'Ask Junkie Chatbot'
940
+ },
941
+ body: JSON.stringify({
942
+ model: this.model,
943
+ messages: messages,
944
+ temperature: 0.7,
945
+ max_tokens: 1000
946
+ })
947
+ });
948
+
949
+ if (!response.ok) {
950
+ const error = await response.json().catch(() => ({}));
951
+ throw new Error(error.error?.message || `OpenRouter API error: ${response.status}`);
952
+ }
953
+
954
+ const data = await response.json();
955
+
956
+ if (data.choices?.[0]?.message?.content) {
957
+ return data.choices[0].message.content;
958
+ }
959
+
960
+ throw new Error('Invalid response from OpenRouter API');
961
+ }
962
+ }
963
+
964
+ /**
965
+ * AI Provider Factory
966
+ * Creates the appropriate AI provider based on configuration
967
+ */
968
+
969
+
970
+ class AIProviderFactory {
971
+ static providers = {
972
+ groq: GroqProvider,
973
+ gemini: GeminiProvider,
974
+ openai: OpenAIProvider,
975
+ openrouter: OpenRouterProvider
976
+ };
977
+
978
+ /**
979
+ * Create an AI provider instance
980
+ * @param {string} providerName - Provider name (groq, gemini, openai, openrouter)
981
+ * @param {string} apiKey - API key for the provider
982
+ * @param {string|null} model - Optional model override
983
+ * @param {string|null} proxyUrl - Optional proxy URL
984
+ */
985
+ static create(providerName, apiKey, model = null, proxyUrl = null) {
986
+ const Provider = AIProviderFactory.providers[providerName.toLowerCase()];
987
+
988
+ if (!Provider) {
989
+ console.warn(`[AskJunkie] Unknown provider "${providerName}", falling back to Groq`);
990
+ return new GroqProvider(apiKey, model, proxyUrl);
991
+ }
992
+
993
+ return new Provider(apiKey, model, proxyUrl);
994
+ }
995
+
996
+ /**
997
+ * Get list of supported providers
998
+ */
999
+ static getSupportedProviders() {
1000
+ return Object.keys(AIProviderFactory.providers);
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Firebase Analytics Logger
1006
+ * Logs chat interactions to Firebase Firestore for centralized analytics
1007
+ */
1008
+
1009
+ class FirebaseLogger {
1010
+ static FIREBASE_API_KEY = 'AIzaSyDPDWA7s6dauaPup9DFz0hMrQvDYPtEWwo';
1011
+ static FIREBASE_PROJECT_ID = 'ai-chatbot-fe4a2';
1012
+
1013
+ constructor(config = {}) {
1014
+ this.enabled = config.enabled !== false;
1015
+ this.siteId = config.siteId || this._getSiteId();
1016
+ this.userId = config.userId || null; // User ID from sdkKeys lookup
1017
+ this.registered = false;
1018
+ }
1019
+
1020
+ /**
1021
+ * Get a sanitized site ID from hostname
1022
+ */
1023
+ _getSiteId() {
1024
+ return window.location.hostname.replace(/[^a-zA-Z0-9.-]/g, '_');
1025
+ }
1026
+
1027
+ /**
1028
+ * Get Firestore REST API endpoint
1029
+ */
1030
+ _getEndpoint(path) {
1031
+ return `https://firestore.googleapis.com/v1/projects/${FirebaseLogger.FIREBASE_PROJECT_ID}/databases/(default)/documents/${path}?key=${FirebaseLogger.FIREBASE_API_KEY}`;
1032
+ }
1033
+
1034
+ /**
1035
+ * Get the base path for this API key's data
1036
+ * Uses new nested structure: users/{userId}/apiKeys/{keyId}
1037
+ */
1038
+ _getBasePath() {
1039
+ if (this.userId && this.siteId) {
1040
+ return `users/${this.userId}/apiKeys/${this.siteId}`;
1041
+ }
1042
+ // Fallback to old structure for backwards compatibility
1043
+ return `sites/${this.siteId}`;
1044
+ }
1045
+
1046
+ /**
1047
+ * Register or update site analytics info
1048
+ * For new user-based path, we SKIP this to avoid overwriting dashboard data
1049
+ * Only used for legacy sites/ path
1050
+ */
1051
+ async registerSite() {
1052
+ if (this.registered) return;
1053
+
1054
+ // Skip registration for new user-based path - dashboard already created the document
1055
+ // Only the chats subcollection should be written to
1056
+ if (this.userId && this.siteId) {
1057
+ console.log('[AskJunkie] Skipping site registration for SDK-managed key');
1058
+ this.registered = true;
1059
+ return;
1060
+ }
1061
+
1062
+ // Legacy path: sites/{siteId} - still needs registration
1063
+ try {
1064
+ const url = this._getEndpoint(this._getBasePath());
1065
+
1066
+ await fetch(url, {
1067
+ method: 'PATCH',
1068
+ headers: { 'Content-Type': 'application/json' },
1069
+ body: JSON.stringify({
1070
+ fields: {
1071
+ domain: { stringValue: window.location.hostname },
1072
+ site_name: { stringValue: document.title || 'Unknown' },
1073
+ site_url: { stringValue: window.location.origin },
1074
+ sdk_version: { stringValue: '1.1.6' },
1075
+ platform: { stringValue: 'JavaScript SDK' },
1076
+ last_active: { stringValue: new Date().toISOString() }
1077
+ }
1078
+ })
1079
+ });
1080
+
1081
+ this.registered = true;
1082
+ } catch (error) {
1083
+ console.warn('[AskJunkie] Failed to register site:', error);
1084
+ }
1085
+ }
1086
+
1087
+ /**
1088
+ * Log a chat interaction
1089
+ */
1090
+ async logChat(data) {
1091
+ if (!this.enabled) return;
1092
+
1093
+ // Ensure site is registered first
1094
+ await this.registerSite();
1095
+
1096
+ try {
1097
+ const url = this._getEndpoint(`${this._getBasePath()}/chats`);
1098
+
1099
+ await fetch(url, {
1100
+ method: 'POST',
1101
+ headers: { 'Content-Type': 'application/json' },
1102
+ body: JSON.stringify({
1103
+ fields: {
1104
+ session_id: { stringValue: data.sessionId || 'unknown' },
1105
+ user_message: { stringValue: data.userMessage || '' },
1106
+ ai_response: { stringValue: data.aiResponse || '' },
1107
+ page_url: { stringValue: data.pageUrl || window.location.href },
1108
+ timestamp: { stringValue: new Date().toISOString() },
1109
+ user_agent: { stringValue: navigator.userAgent || '' }
1110
+ }
1111
+ })
1112
+ });
1113
+ } catch (error) {
1114
+ console.warn('[AskJunkie] Failed to log chat:', error);
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * Storage Manager
1121
+ * Handles localStorage operations with prefix and session management
1122
+ */
1123
+
1124
+ class StorageManager {
1125
+ constructor(prefix = 'ask_junkie_') {
1126
+ this.prefix = prefix;
1127
+ this.sessionId = null;
1128
+ }
1129
+
1130
+ /**
1131
+ * Get a value from storage
1132
+ */
1133
+ get(key) {
1134
+ try {
1135
+ const item = localStorage.getItem(this.prefix + key);
1136
+ return item ? JSON.parse(item) : null;
1137
+ } catch (e) {
1138
+ console.warn('[AskJunkie] Storage get error:', e);
1139
+ return null;
1140
+ }
1141
+ }
1142
+
1143
+ /**
1144
+ * Set a value in storage
1145
+ */
1146
+ set(key, value) {
1147
+ try {
1148
+ localStorage.setItem(this.prefix + key, JSON.stringify(value));
1149
+ return true;
1150
+ } catch (e) {
1151
+ console.warn('[AskJunkie] Storage set error:', e);
1152
+ return false;
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Remove a value from storage
1158
+ */
1159
+ remove(key) {
1160
+ try {
1161
+ localStorage.removeItem(this.prefix + key);
1162
+ } catch (e) {
1163
+ console.warn('[AskJunkie] Storage remove error:', e);
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * Get or create a unique session ID
1169
+ */
1170
+ getSessionId() {
1171
+ if (this.sessionId) return this.sessionId;
1172
+
1173
+ this.sessionId = this.get('session_id');
1174
+
1175
+ if (!this.sessionId) {
1176
+ this.sessionId = 'sess_' +
1177
+ Math.random().toString(36).substring(2, 10) +
1178
+ '_' + Date.now().toString(36);
1179
+ this.set('session_id', this.sessionId);
1180
+ }
1181
+
1182
+ return this.sessionId;
1183
+ }
1184
+
1185
+ /**
1186
+ * Check if storage is available
1187
+ */
1188
+ isAvailable() {
1189
+ try {
1190
+ const test = '__storage_test__';
1191
+ localStorage.setItem(test, test);
1192
+ localStorage.removeItem(test);
1193
+ return true;
1194
+ } catch (e) {
1195
+ return false;
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ /**
1201
+ * Simple Event Emitter for SDK events
1202
+ */
1203
+
1204
+ class EventEmitter {
1205
+ constructor() {
1206
+ this.events = {};
1207
+ }
1208
+
1209
+ /**
1210
+ * Subscribe to an event
1211
+ */
1212
+ on(event, callback) {
1213
+ if (!this.events[event]) {
1214
+ this.events[event] = [];
1215
+ }
1216
+ this.events[event].push(callback);
1217
+ return () => this.off(event, callback);
1218
+ }
1219
+
1220
+ /**
1221
+ * Unsubscribe from an event
1222
+ */
1223
+ off(event, callback) {
1224
+ if (!this.events[event]) return;
1225
+ this.events[event] = this.events[event].filter(cb => cb !== callback);
1226
+ }
1227
+
1228
+ /**
1229
+ * Emit an event
1230
+ */
1231
+ emit(event, data) {
1232
+ if (!this.events[event]) return;
1233
+ this.events[event].forEach(callback => {
1234
+ try {
1235
+ callback(data);
1236
+ } catch (error) {
1237
+ console.error(`[AskJunkie] Event handler error for "${event}":`, error);
1238
+ }
1239
+ });
1240
+ }
1241
+
1242
+ /**
1243
+ * Subscribe to an event once
1244
+ */
1245
+ once(event, callback) {
1246
+ const unsubscribe = this.on(event, (data) => {
1247
+ unsubscribe();
1248
+ callback(data);
1249
+ });
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * Ask Junkie SDK - Main SDK Class
1255
+ * Handles initialization, configuration, and orchestration
1256
+ */
1257
+
1258
+
1259
+ class AskJunkie {
1260
+ static instance = null;
1261
+ static VERSION = '1.1.1';
1262
+
1263
+ constructor() {
1264
+ this.config = null;
1265
+ this.widget = null;
1266
+ this.aiProvider = null;
1267
+ this.analytics = null;
1268
+ this.storage = null;
1269
+ this.events = new EventEmitter();
1270
+ this.chatHistory = [];
1271
+ this.isInitialized = false;
1272
+ this.siteId = null;
1273
+ }
1274
+
1275
+ /**
1276
+ * Initialize the SDK with configuration
1277
+ * @param {Object} config - Configuration options
1278
+ */
1279
+ static async init(config = {}) {
1280
+ if (AskJunkie.instance && AskJunkie.instance.isInitialized) {
1281
+ console.warn('[AskJunkie] Already initialized. Call destroy() first to reinitialize.');
1282
+ return AskJunkie.instance;
1283
+ }
1284
+
1285
+ AskJunkie.instance = new AskJunkie();
1286
+
1287
+ // Check if using sdkKey mode (fetches settings from dashboard)
1288
+ if (config.sdkKey) {
1289
+ await AskJunkie.instance._initializeWithSdkKey(config.sdkKey, config);
1290
+ } else {
1291
+ AskJunkie.instance._initialize(config);
1292
+ }
1293
+
1294
+ return AskJunkie.instance;
1295
+ }
1296
+
1297
+ /**
1298
+ * Initialize using SDK key - fetches settings from Firebase dashboard
1299
+ */
1300
+ async _initializeWithSdkKey(sdkKey, overrides = {}) {
1301
+ console.log('[AskJunkie] Initializing with SDK key...');
1302
+
1303
+ try {
1304
+ // Fetch settings from Firebase
1305
+ const settings = await this._fetchSettingsFromDashboard(sdkKey);
1306
+
1307
+ if (!settings) {
1308
+ console.error('[AskJunkie] Invalid SDK key or site not found.');
1309
+ return;
1310
+ }
1311
+
1312
+ // Merge fetched settings with any overrides
1313
+ const config = {
1314
+ apiKey: settings.providerApiKey,
1315
+ provider: settings.provider,
1316
+ model: settings.model,
1317
+ botName: settings.botName,
1318
+ welcomeMessage: settings.welcomeMessage,
1319
+ suggestions: settings.suggestions,
1320
+ animatedSuggestions: settings.animatedSuggestions,
1321
+ position: settings.position,
1322
+ draggable: settings.draggable,
1323
+ resizable: settings.resizable,
1324
+ voiceInput: settings.speechToText, // Map speechToText to voiceInput
1325
+ enabled: settings.enabled,
1326
+ theme: settings.theme,
1327
+ context: settings.context,
1328
+ analytics: {
1329
+ enabled: true,
1330
+ siteId: this.siteId,
1331
+ userId: this.userId // Pass userId for new nested path
1332
+ },
1333
+ ...overrides,
1334
+ _sdkKey: sdkKey
1335
+ };
1336
+
1337
+ this._initialize(config);
1338
+
1339
+ } catch (error) {
1340
+ console.error('[AskJunkie] Error fetching settings:', error);
1341
+ if (overrides.onError) {
1342
+ overrides.onError(error);
1343
+ }
1344
+ }
1345
+ }
1346
+
1347
+ /**
1348
+ * Fetch settings from Firebase dashboard
1349
+ */
1350
+ async _fetchSettingsFromDashboard(sdkKey) {
1351
+ const FIREBASE_PROJECT = 'ai-chatbot-fe4a2';
1352
+ const API_URL = `https://firestore.googleapis.com/v1/projects/${FIREBASE_PROJECT}/databases/(default)/documents`;
1353
+
1354
+ try {
1355
+ // Step 1: Get the key lookup from sdkKeys collection
1356
+ // Document ID is the SDK key itself
1357
+ const lookupUrl = `${API_URL}/sdkKeys/${sdkKey}`;
1358
+ const lookupResponse = await fetch(lookupUrl);
1359
+
1360
+ if (!lookupResponse.ok) {
1361
+ console.error('[AskJunkie] SDK key not found in lookup');
1362
+ return null;
1363
+ }
1364
+
1365
+ const lookupData = await lookupResponse.json();
1366
+
1367
+ if (!lookupData.fields) {
1368
+ console.error('[AskJunkie] SDK key not found');
1369
+ return null;
1370
+ }
1371
+
1372
+ const userId = lookupData.fields.userId.stringValue;
1373
+ const keyId = lookupData.fields.keyId.stringValue;
1374
+ const status = lookupData.fields.status?.stringValue;
1375
+
1376
+ this.siteId = keyId;
1377
+ this.userId = userId;
1378
+
1379
+ // Check if key is active
1380
+ if (status !== 'active') {
1381
+ console.error('[AskJunkie] SDK key is not active');
1382
+ return null;
1383
+ }
1384
+
1385
+ // Step 2: Fetch the full API key document with settings
1386
+ const keyUrl = `${API_URL}/users/${userId}/apiKeys/${keyId}`;
1387
+ const keyResponse = await fetch(keyUrl);
1388
+
1389
+ if (!keyResponse.ok) {
1390
+ console.error('[AskJunkie] Could not fetch API key details');
1391
+ return null;
1392
+ }
1393
+
1394
+ const keyData = await keyResponse.json();
1395
+
1396
+ if (!keyData.fields) {
1397
+ console.error('[AskJunkie] API key document not found');
1398
+ return null;
1399
+ }
1400
+
1401
+ // Settings are embedded in the API key document
1402
+ if (!keyData.fields.settings) {
1403
+ console.warn('[AskJunkie] No settings configured in dashboard. Using defaults.');
1404
+ return this._getDefaultSettings();
1405
+ }
1406
+
1407
+ // Parse Firestore format to plain object
1408
+ return this._parseFirestoreSettings(keyData.fields.settings.mapValue.fields);
1409
+
1410
+ } catch (error) {
1411
+ console.error('[AskJunkie] Error fetching from Firebase:', error);
1412
+ return null;
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Get default settings for SDK
1418
+ */
1419
+ _getDefaultSettings() {
1420
+ return {
1421
+ enabled: true,
1422
+ botName: 'AI Assistant',
1423
+ welcomeMessage: "Hi! 👋 I'm your AI assistant. How can I help you today?",
1424
+ suggestions: [],
1425
+ animatedSuggestions: true,
1426
+ speechToText: true,
1427
+ provider: 'groq',
1428
+ providerApiKey: '', // User must provide in overrides
1429
+ model: 'llama-3.3-70b-versatile',
1430
+ position: 'bottom-right',
1431
+ draggable: true,
1432
+ resizable: false,
1433
+ theme: {
1434
+ mode: 'gradient',
1435
+ preset: 1,
1436
+ primary: '#6366f1',
1437
+ secondary: '#ec4899'
1438
+ },
1439
+ context: {
1440
+ siteName: document.title || 'Website',
1441
+ siteDescription: '',
1442
+ customInfo: '',
1443
+ restrictions: ''
1444
+ }
1445
+ };
1446
+ }
1447
+
1448
+ /**
1449
+ * Parse Firestore document format to plain object
1450
+ */
1451
+ _parseFirestoreSettings(fields) {
1452
+ const getString = (field) => field?.stringValue || '';
1453
+ const getNumber = (field) => field?.integerValue || field?.doubleValue || 0;
1454
+ const getBool = (field) => field?.booleanValue || false;
1455
+ const getArray = (field) => {
1456
+ if (!field?.arrayValue?.values) return [];
1457
+ return field.arrayValue.values.map(v => v.stringValue || '');
1458
+ };
1459
+
1460
+ const themeFields = fields.theme?.mapValue?.fields || {};
1461
+ const contextFields = fields.context?.mapValue?.fields || {};
1462
+
1463
+ return {
1464
+ enabled: fields.enabled?.booleanValue !== false, // Default true if not set
1465
+ botName: getString(fields.botName) || 'AI Assistant',
1466
+ welcomeMessage: getString(fields.welcomeMessage) || "Hi! 👋 I'm your AI assistant.",
1467
+ suggestions: getArray(fields.suggestions),
1468
+ animatedSuggestions: fields.animatedSuggestions?.booleanValue !== false, // Default true
1469
+ speechToText: fields.speechToText?.booleanValue !== false, // Default true
1470
+ provider: getString(fields.provider) || 'groq',
1471
+ providerApiKey: getString(fields.providerApiKey),
1472
+ model: getString(fields.model),
1473
+ position: getString(fields.position) || 'bottom-right',
1474
+ draggable: getBool(fields.draggable),
1475
+ resizable: getBool(fields.resizable),
1476
+ theme: {
1477
+ mode: getString(themeFields.mode) || 'gradient',
1478
+ preset: getNumber(themeFields.preset) || 1,
1479
+ primary: getString(themeFields.primary) || '#6366f1',
1480
+ secondary: getString(themeFields.secondary) || '#ec4899'
1481
+ },
1482
+ context: {
1483
+ siteName: getString(contextFields.siteName),
1484
+ siteDescription: getString(contextFields.siteDescription),
1485
+ customInfo: getString(contextFields.customInfo),
1486
+ restrictions: getString(contextFields.restrictions)
1487
+ }
1488
+ };
1489
+ }
1490
+
1491
+ /**
1492
+ * Internal initialization logic
1493
+ */
1494
+ _initialize(config) {
1495
+ // Merge with defaults
1496
+ this.config = this._mergeConfig(config);
1497
+
1498
+ // Check if chatbot is enabled
1499
+ if (this.config.enabled === false) {
1500
+ console.log('[AskJunkie] Chatbot is disabled in settings. Widget will not render.');
1501
+ this.isInitialized = true;
1502
+ return;
1503
+ }
1504
+
1505
+ // Validate required config
1506
+ if (!this.config.apiKey && !this.config._sdkKey) {
1507
+ console.error('[AskJunkie] API key or SDK key is required.');
1508
+ return;
1509
+ }
1510
+
1511
+ // Initialize storage manager
1512
+ this.storage = new StorageManager(this.config.storagePrefix);
1513
+
1514
+ // Load chat history from storage
1515
+ this.chatHistory = this.storage.get('chat_history') || [];
1516
+
1517
+ // Initialize AI provider
1518
+ this.aiProvider = AIProviderFactory.create(
1519
+ this.config.provider,
1520
+ this.config.apiKey,
1521
+ this.config.model,
1522
+ this.config.proxyUrl
1523
+ );
1524
+
1525
+ // Initialize Firebase analytics (if enabled)
1526
+ if (this.config.analytics.enabled) {
1527
+ this.analytics = new FirebaseLogger(this.config.analytics);
1528
+ }
1529
+
1530
+ // Create and render the chat widget
1531
+ this.widget = new ChatWidget({
1532
+ ...this.config,
1533
+ onSendMessage: (message) => this._handleUserMessage(message),
1534
+ onClearHistory: () => this._clearHistory(),
1535
+ chatHistory: this.chatHistory
1536
+ });
1537
+
1538
+ this.widget.render();
1539
+
1540
+ this.isInitialized = true;
1541
+ this.events.emit('ready');
1542
+
1543
+ if (this.config.onReady) {
1544
+ this.config.onReady();
1545
+ }
1546
+
1547
+ console.log(`[AskJunkie] SDK v${AskJunkie.VERSION} initialized successfully`);
1548
+ }
1549
+
1550
+ /**
1551
+ * Merge user config with defaults
1552
+ */
1553
+ _mergeConfig(userConfig) {
1554
+ const defaults = {
1555
+ // Required
1556
+ apiKey: null,
1557
+
1558
+ // Master enable flag
1559
+ enabled: true,
1560
+
1561
+ // AI Provider
1562
+ provider: 'groq',
1563
+ model: null, // Will use provider default
1564
+
1565
+ // Appearance
1566
+ botName: 'AI Assistant',
1567
+ botAvatar: null, // URL to custom avatar
1568
+ welcomeMessage: "Hi! 👋 I'm your AI assistant. How can I help you today?",
1569
+ position: 'bottom-right',
1570
+ theme: {
1571
+ mode: 'gradient',
1572
+ preset: 1,
1573
+ primary: '#6366f1',
1574
+ secondary: '#ec4899'
1575
+ },
1576
+
1577
+ // Behavior
1578
+ draggable: false,
1579
+ resizable: false,
1580
+ persistChat: true,
1581
+ voiceInput: true,
1582
+ suggestions: [],
1583
+ animatedSuggestions: true,
1584
+ openOnLoad: false,
1585
+
1586
+ // Context for AI
1587
+ context: {
1588
+ siteName: document.title || 'Website',
1589
+ siteDescription: '',
1590
+ customInfo: '',
1591
+ restrictions: ''
1592
+ },
1593
+
1594
+ // Analytics
1595
+ analytics: {
1596
+ enabled: true,
1597
+ siteId: window.location.hostname.replace(/[^a-zA-Z0-9.-]/g, '_')
1598
+ },
1599
+
1600
+ // Proxy mode (optional)
1601
+ proxyUrl: null,
1602
+
1603
+ // Storage prefix
1604
+ storagePrefix: 'ask_junkie_',
1605
+
1606
+ // Event callbacks
1607
+ onReady: null,
1608
+ onMessage: null,
1609
+ onOpen: null,
1610
+ onClose: null,
1611
+ onError: null
1612
+ };
1613
+
1614
+ // Deep merge theme object
1615
+ const theme = { ...defaults.theme, ...(userConfig.theme || {}) };
1616
+ const context = { ...defaults.context, ...(userConfig.context || {}) };
1617
+ const analytics = { ...defaults.analytics, ...(userConfig.analytics || {}) };
1618
+
1619
+ return {
1620
+ ...defaults,
1621
+ ...userConfig,
1622
+ theme,
1623
+ context,
1624
+ analytics
1625
+ };
1626
+ }
1627
+
1628
+ /**
1629
+ * Handle user message submission
1630
+ */
1631
+ async _handleUserMessage(message) {
1632
+ if (!message.trim()) return;
1633
+
1634
+ // Add user message to history
1635
+ this.chatHistory.push({
1636
+ role: 'user',
1637
+ content: message,
1638
+ timestamp: new Date().toISOString()
1639
+ });
1640
+
1641
+ // Show typing indicator
1642
+ this.widget.showTyping();
1643
+
1644
+ try {
1645
+ // Build context string
1646
+ const contextString = this._buildContext();
1647
+
1648
+ // Get AI response
1649
+ const response = await this.aiProvider.sendMessage(
1650
+ message,
1651
+ contextString,
1652
+ this.chatHistory.slice(-10) // Send last 10 messages for context
1653
+ );
1654
+
1655
+ // Add bot response to history
1656
+ this.chatHistory.push({
1657
+ role: 'assistant',
1658
+ content: response,
1659
+ timestamp: new Date().toISOString()
1660
+ });
1661
+
1662
+ // Display response
1663
+ this.widget.hideTyping();
1664
+ this.widget.addMessage(response, 'bot');
1665
+
1666
+ // Save to storage
1667
+ if (this.config.persistChat) {
1668
+ this.storage.set('chat_history', this.chatHistory.slice(-50));
1669
+ }
1670
+
1671
+ // Log to analytics
1672
+ if (this.analytics) {
1673
+ this.analytics.logChat({
1674
+ userMessage: message,
1675
+ aiResponse: response,
1676
+ pageUrl: window.location.href,
1677
+ sessionId: this.storage.getSessionId()
1678
+ });
1679
+ }
1680
+
1681
+ // Fire callback
1682
+ if (this.config.onMessage) {
1683
+ this.config.onMessage(response, true);
1684
+ }
1685
+
1686
+ this.events.emit('message', { message: response, isBot: true });
1687
+
1688
+ } catch (error) {
1689
+ this.widget.hideTyping();
1690
+ const errorMessage = 'Sorry, I encountered an error. Please try again.';
1691
+ this.widget.addMessage(errorMessage, 'bot');
1692
+
1693
+ console.error('[AskJunkie] Error:', error);
1694
+
1695
+ if (this.config.onError) {
1696
+ this.config.onError(error);
1697
+ }
1698
+
1699
+ this.events.emit('error', error);
1700
+ }
1701
+ }
1702
+
1703
+ /**
1704
+ * Build AI context from configuration
1705
+ */
1706
+ _buildContext() {
1707
+ const ctx = this.config.context;
1708
+ let context = '';
1709
+
1710
+ context += `You are "${this.config.botName}", a friendly and helpful AI assistant for "${ctx.siteName}". `;
1711
+ context += `Your personality is warm, professional, and knowledgeable. `;
1712
+
1713
+ context += `IMPORTANT IDENTITY RULES: `;
1714
+ context += `1. Your name is ${this.config.botName}. `;
1715
+ context += `2. If asked who created you, politely say you're an AI assistant designed to help with website inquiries. `;
1716
+ context += `3. Stay focused on helping users with questions about this website. `;
1717
+
1718
+ if (ctx.siteDescription) {
1719
+ context += `\n\nABOUT THIS WEBSITE: ${ctx.siteDescription} `;
1720
+ }
1721
+
1722
+ if (ctx.customInfo) {
1723
+ context += `\n\nADDITIONAL INFORMATION: ${ctx.customInfo} `;
1724
+ }
1725
+
1726
+ if (ctx.restrictions) {
1727
+ context += `\n\n🚫 RESTRICTIONS (follow strictly): ${ctx.restrictions} `;
1728
+ }
1729
+
1730
+ context += `\n\nRESPONSE FORMATTING: `;
1731
+ context += `1. Be professional and courteous. `;
1732
+ context += `2. Use clear paragraphs with line breaks. `;
1733
+ context += `3. For lists, use numbered points or bullets. `;
1734
+ context += `4. For links, use markdown format: [Link Text](URL). `;
1735
+ context += `5. Use **bold** for important terms. `;
1736
+ context += `6. Keep responses helpful but concise. `;
1737
+
1738
+ return context;
1739
+ }
1740
+
1741
+ /**
1742
+ * Clear chat history
1743
+ */
1744
+ _clearHistory() {
1745
+ this.chatHistory = [];
1746
+ this.storage.remove('chat_history');
1747
+ this.events.emit('clear');
1748
+ }
1749
+
1750
+ // ===== PUBLIC API =====
1751
+
1752
+ /**
1753
+ * Open the chat widget
1754
+ */
1755
+ static open() {
1756
+ if (AskJunkie.instance?.widget) {
1757
+ AskJunkie.instance.widget.open();
1758
+ }
1759
+ }
1760
+
1761
+ /**
1762
+ * Close the chat widget
1763
+ */
1764
+ static close() {
1765
+ if (AskJunkie.instance?.widget) {
1766
+ AskJunkie.instance.widget.close();
1767
+ }
1768
+ }
1769
+
1770
+ /**
1771
+ * Toggle the chat widget
1772
+ */
1773
+ static toggle() {
1774
+ if (AskJunkie.instance?.widget) {
1775
+ AskJunkie.instance.widget.toggle();
1776
+ }
1777
+ }
1778
+
1779
+ /**
1780
+ * Send a message programmatically
1781
+ */
1782
+ static sendMessage(text) {
1783
+ if (AskJunkie.instance) {
1784
+ AskJunkie.instance.widget.addMessage(text, 'user');
1785
+ AskJunkie.instance._handleUserMessage(text);
1786
+ }
1787
+ }
1788
+
1789
+ /**
1790
+ * Update context dynamically
1791
+ */
1792
+ static setContext(newContext) {
1793
+ if (AskJunkie.instance) {
1794
+ AskJunkie.instance.config.context = {
1795
+ ...AskJunkie.instance.config.context,
1796
+ ...newContext
1797
+ };
1798
+ }
1799
+ }
1800
+
1801
+ /**
1802
+ * Subscribe to events
1803
+ */
1804
+ static on(event, callback) {
1805
+ if (AskJunkie.instance) {
1806
+ AskJunkie.instance.events.on(event, callback);
1807
+ }
1808
+ }
1809
+
1810
+ /**
1811
+ * Destroy the SDK instance
1812
+ */
1813
+ static destroy() {
1814
+ if (AskJunkie.instance) {
1815
+ if (AskJunkie.instance.widget) {
1816
+ AskJunkie.instance.widget.destroy();
1817
+ }
1818
+ AskJunkie.instance.isInitialized = false;
1819
+ AskJunkie.instance = null;
1820
+ console.log('[AskJunkie] Destroyed');
1821
+ }
1822
+ }
1823
+
1824
+ /**
1825
+ * Get SDK version
1826
+ */
1827
+ static getVersion() {
1828
+ return AskJunkie.VERSION;
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * Ask Junkie SDK - Main Entry Point
1834
+ * Universal AI Chatbot for any website
1835
+ *
1836
+ * @author Junkies Coder
1837
+ * @version 1.0.0
1838
+ */
1839
+
1840
+
1841
+ // Auto-attach to window for script tag usage
1842
+ if (typeof window !== 'undefined') {
1843
+ window.AskJunkie = AskJunkie;
1844
+ }
1845
+
1846
+ export { AskJunkie, AskJunkie as default };
1847
+ //# sourceMappingURL=ask-junkie.esm.js.map