@cognitiondesk/widget 1.1.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/src/widget.js ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * CognitionDesk Chat Widget — vanilla JS core
3
+ *
4
+ * Usage:
5
+ * import CognitionDeskWidget from '@cognitiondesk/widget';
6
+ * const widget = new CognitionDeskWidget({ apiKey: 'xxx', assistantId: 'yyy' });
7
+ * widget.mount(); // mounts into document.body
8
+ *
9
+ * Or use the global UMD build:
10
+ * CognitionDesk.init({ apiKey: 'xxx' });
11
+ */
12
+
13
+ const DEFAULT_BACKEND = 'https://mounaji-backendv3.onrender.com'; // overrideable
14
+
15
+ const STYLES = `
16
+ :host {
17
+ all: initial;
18
+ font-family: system-ui, sans-serif;
19
+ --cd-offset: clamp(12px, 4vw, 24px);
20
+ --cd-launcher-size: clamp(56px, 14vw, 64px);
21
+ --cd-launcher-gap: 12px;
22
+ --cd-viewport-height: 100vh;
23
+ --cd-safe-top: env(safe-area-inset-top, 0px);
24
+ --cd-safe-right: env(safe-area-inset-right, 0px);
25
+ --cd-safe-bottom: env(safe-area-inset-bottom, 0px);
26
+ --cd-safe-left: env(safe-area-inset-left, 0px);
27
+ }
28
+
29
+ .cd-root {
30
+ position: fixed;
31
+ inset: 0;
32
+ pointer-events: none;
33
+ z-index: 2147483000;
34
+ }
35
+
36
+ .cd-root > * {
37
+ pointer-events: auto;
38
+ }
39
+
40
+ .cd-widget-btn {
41
+ position: fixed;
42
+ bottom: calc(var(--cd-offset) + var(--cd-safe-bottom));
43
+ right: calc(var(--cd-offset) + var(--cd-safe-right));
44
+ z-index: 2147483647;
45
+ width: var(--cd-launcher-size);
46
+ height: var(--cd-launcher-size);
47
+ border-radius: 50%;
48
+ background: var(--cd-primary, #2563eb);
49
+ color: #fff;
50
+ border: none;
51
+ cursor: pointer;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ box-shadow: 0 4px 20px rgba(37,99,235,.45);
56
+ transition: transform .2s, box-shadow .2s;
57
+ touch-action: manipulation;
58
+ }
59
+ .cd-widget-btn:hover { transform: scale(1.08); box-shadow: 0 6px 24px rgba(37,99,235,.55); }
60
+ .cd-widget-btn svg { width: 26px; height: 26px; }
61
+
62
+ .cd-panel {
63
+ position: fixed;
64
+ bottom: calc(var(--cd-offset) + var(--cd-safe-bottom) + var(--cd-launcher-size) + var(--cd-launcher-gap));
65
+ right: calc(var(--cd-offset) + var(--cd-safe-right));
66
+ z-index: 2147483646;
67
+ width: min(380px, calc(100vw - (var(--cd-offset) * 2) - var(--cd-safe-left) - var(--cd-safe-right)));
68
+ height: min(560px, calc(var(--cd-viewport-height) - (var(--cd-offset) * 2) - var(--cd-safe-top) - var(--cd-safe-bottom) - var(--cd-launcher-size) - var(--cd-launcher-gap)));
69
+ max-height: calc(var(--cd-viewport-height) - (var(--cd-offset) * 2) - var(--cd-safe-top) - var(--cd-safe-bottom));
70
+ background: #fff;
71
+ border-radius: 16px;
72
+ box-shadow: 0 12px 40px rgba(0,0,0,.18);
73
+ display: flex;
74
+ flex-direction: column;
75
+ overflow: hidden;
76
+ transform: scale(.96) translateY(8px);
77
+ opacity: 0;
78
+ pointer-events: none;
79
+ transition: opacity .2s, transform .2s;
80
+ overscroll-behavior: contain;
81
+ }
82
+ .cd-panel.open {
83
+ transform: scale(1) translateY(0);
84
+ opacity: 1;
85
+ pointer-events: all;
86
+ }
87
+
88
+ .cd-header {
89
+ background: var(--cd-primary, #2563eb);
90
+ color: #fff;
91
+ padding: 14px 16px;
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 10px;
95
+ flex-shrink: 0;
96
+ }
97
+ .cd-header-avatar {
98
+ width: 36px; height: 36px; border-radius: 50%;
99
+ background: rgba(255,255,255,.25);
100
+ display: flex; align-items: center; justify-content: center;
101
+ font-size: 18px;
102
+ }
103
+ .cd-header-info { flex: 1; min-width: 0; }
104
+ .cd-header-name { font-size: 14px; font-weight: 600; }
105
+ .cd-header-status { font-size: 11px; opacity: .85; display: flex; align-items: center; gap: 4px; }
106
+ .cd-status-dot { width: 7px; height: 7px; border-radius: 50%; background: #4ade80; }
107
+ .cd-close-btn {
108
+ background: none; border: none; color: rgba(255,255,255,.75);
109
+ cursor: pointer; padding: 4px; border-radius: 6px;
110
+ display: flex; align-items: center; justify-content: center;
111
+ }
112
+ .cd-close-btn:hover { color: #fff; background: rgba(255,255,255,.15); }
113
+ .cd-close-btn svg { width: 18px; height: 18px; }
114
+
115
+ .cd-messages {
116
+ flex: 1;
117
+ overflow-y: auto;
118
+ padding: 16px;
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 12px;
122
+ background: #f8fafc;
123
+ }
124
+ .cd-msg {
125
+ max-width: 78%;
126
+ padding: 10px 13px;
127
+ border-radius: 14px;
128
+ font-size: 13.5px;
129
+ line-height: 1.5;
130
+ word-break: break-word;
131
+ }
132
+ .cd-msg.user {
133
+ align-self: flex-end;
134
+ background: var(--cd-primary, #2563eb);
135
+ color: #fff;
136
+ border-bottom-right-radius: 4px;
137
+ }
138
+ .cd-msg.assistant {
139
+ align-self: flex-start;
140
+ background: #fff;
141
+ color: #1e293b;
142
+ border-bottom-left-radius: 4px;
143
+ box-shadow: 0 1px 4px rgba(0,0,0,.08);
144
+ }
145
+ .cd-msg.error {
146
+ align-self: flex-start;
147
+ background: #fef2f2;
148
+ color: #b91c1c;
149
+ border-bottom-left-radius: 4px;
150
+ }
151
+ .cd-typing { display: flex; gap: 4px; align-items: center; padding: 4px 0; }
152
+ .cd-typing span {
153
+ width: 7px; height: 7px; background: #94a3b8; border-radius: 50%;
154
+ animation: cd-bounce .9s infinite;
155
+ }
156
+ .cd-typing span:nth-child(2) { animation-delay: .15s; }
157
+ .cd-typing span:nth-child(3) { animation-delay: .3s; }
158
+ @keyframes cd-bounce {
159
+ 0%,60%,100% { transform: translateY(0); }
160
+ 30% { transform: translateY(-5px); }
161
+ }
162
+
163
+ .cd-input-area {
164
+ border-top: 1px solid #e2e8f0;
165
+ padding: 12px;
166
+ padding-bottom: calc(12px + (var(--cd-safe-bottom) * 0.35));
167
+ display: flex;
168
+ gap: 8px;
169
+ align-items: flex-end;
170
+ background: #fff;
171
+ flex-shrink: 0;
172
+ }
173
+ .cd-textarea {
174
+ flex: 1;
175
+ border: 1px solid #e2e8f0;
176
+ border-radius: 10px;
177
+ padding: 9px 12px;
178
+ font-size: 13.5px;
179
+ font-family: inherit;
180
+ resize: none;
181
+ outline: none;
182
+ max-height: 120px;
183
+ line-height: 1.5;
184
+ background: #f8fafc;
185
+ color: #1e293b;
186
+ transition: border-color .15s;
187
+ }
188
+ .cd-textarea:focus { border-color: var(--cd-primary, #2563eb); background: #fff; }
189
+ .cd-send-btn {
190
+ width: 36px; height: 36px;
191
+ background: var(--cd-primary, #2563eb);
192
+ color: #fff;
193
+ border: none;
194
+ border-radius: 10px;
195
+ cursor: pointer;
196
+ display: flex; align-items: center; justify-content: center;
197
+ flex-shrink: 0;
198
+ transition: opacity .15s;
199
+ }
200
+ .cd-send-btn:disabled { opacity: .45; cursor: not-allowed; }
201
+ .cd-send-btn svg { width: 16px; height: 16px; }
202
+
203
+ .cd-powered {
204
+ text-align: center;
205
+ font-size: 10px;
206
+ color: #94a3b8;
207
+ padding: 6px 0;
208
+ background: #fff;
209
+ flex-shrink: 0;
210
+ }
211
+ .cd-powered a { color: #94a3b8; text-decoration: none; }
212
+ .cd-powered a:hover { color: #64748b; }
213
+
214
+ /* Dark theme */
215
+ .cd-dark .cd-panel { background: #1e293b; }
216
+ .cd-dark .cd-messages { background: #0f172a; }
217
+ .cd-dark .cd-msg.assistant { background: #1e293b; color: #f1f5f9; box-shadow: 0 1px 4px rgba(0,0,0,.3); }
218
+ .cd-dark .cd-input-area { background: #1e293b; border-color: #334155; }
219
+ .cd-dark .cd-textarea { background: #0f172a; border-color: #334155; color: #f1f5f9; }
220
+ .cd-dark .cd-textarea:focus { border-color: var(--cd-primary, #3b82f6); }
221
+ .cd-dark .cd-powered { background: #1e293b; }
222
+
223
+ @media (max-width: 640px) {
224
+ .cd-panel {
225
+ left: calc(var(--cd-offset) + var(--cd-safe-left));
226
+ right: calc(var(--cd-offset) + var(--cd-safe-right));
227
+ width: auto;
228
+ height: min(540px, calc(var(--cd-viewport-height) - (var(--cd-offset) * 2) - var(--cd-safe-top) - var(--cd-safe-bottom) - 12px));
229
+ }
230
+ }
231
+
232
+ @media (max-width: 480px) {
233
+ .cd-panel {
234
+ top: var(--cd-safe-top);
235
+ right: 0;
236
+ bottom: 0;
237
+ left: 0;
238
+ width: auto;
239
+ height: calc(var(--cd-viewport-height) - var(--cd-safe-top));
240
+ max-height: calc(var(--cd-viewport-height) - var(--cd-safe-top));
241
+ border-radius: 18px 18px 0 0;
242
+ }
243
+
244
+ .cd-header {
245
+ padding: 16px;
246
+ }
247
+
248
+ .cd-messages {
249
+ padding: 14px;
250
+ }
251
+
252
+ .cd-input-area {
253
+ padding: 10px;
254
+ padding-bottom: calc(10px + var(--cd-safe-bottom));
255
+ }
256
+
257
+ .cd-textarea {
258
+ font-size: 16px;
259
+ }
260
+ }
261
+ `;
262
+
263
+ const ICONS = {
264
+ chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
265
+ close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
266
+ send: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`,
267
+ };
268
+
269
+ function generateSessionId() {
270
+ return 'cd_' + Math.random().toString(36).slice(2) + Date.now().toString(36);
271
+ }
272
+
273
+ export default class CognitionDeskWidget {
274
+ constructor(config = {}) {
275
+ if (!config.apiKey) throw new Error('[CognitionDesk] apiKey is required');
276
+
277
+ this._cfg = {
278
+ apiKey: config.apiKey,
279
+ widgetId: config.widgetId || null,
280
+ assistantId: config.assistantId || null,
281
+ backendUrl: config.backendUrl || DEFAULT_BACKEND,
282
+ primaryColor: config.primaryColor || '#2563eb',
283
+ theme: config.theme || 'light', // 'light' | 'dark' | 'auto'
284
+ botName: config.botName || 'AI Assistant',
285
+ botEmoji: config.botEmoji || '🤖',
286
+ welcomeMessage: config.welcomeMessage || 'Hello! How can I help you today?',
287
+ placeholder: config.placeholder || 'Type a message…',
288
+ position: config.position || 'bottom-right',
289
+ streaming: config.streaming !== false, // default: true
290
+ };
291
+
292
+ this._sessionId = generateSessionId();
293
+ this._messages = []; // { role: 'user'|'assistant', content: string }[]
294
+ this._open = false;
295
+ this._loading = false;
296
+ this._container = null;
297
+ this._shadow = null;
298
+ this._panel = null;
299
+ this._messagesEl = null;
300
+ this._textarea = null;
301
+ this._sendBtn = null;
302
+ this._viewportHandler = null;
303
+ }
304
+
305
+ // ── Public API ──────────────────────────────────────────────────────────────
306
+
307
+ /**
308
+ * Mount the widget.
309
+ * If `widgetId` was provided, fetches the server-side config first (async).
310
+ * Returns a Promise so callers can await full initialisation.
311
+ */
312
+ mount(target) {
313
+ const _mount = () => {
314
+ const host = target || document.body;
315
+ this._container = document.createElement('div');
316
+ this._container.setAttribute('data-cognitiondesk', '');
317
+ host.appendChild(this._container);
318
+
319
+ this._shadow = this._container.attachShadow({ mode: 'open' });
320
+ const style = document.createElement('style');
321
+ style.textContent = STYLES;
322
+ this._shadow.appendChild(style);
323
+
324
+ this._buildDOM();
325
+ this._applyTheme();
326
+ this._syncViewportMetrics();
327
+ this._bindViewportMetrics();
328
+ this._bindEvents();
329
+
330
+ if (this._cfg.welcomeMessage) {
331
+ this._appendMessage('assistant', this._cfg.welcomeMessage, false);
332
+ }
333
+ };
334
+
335
+ if (this._cfg.widgetId) {
336
+ return this._fetchWidgetConfig()
337
+ .then(() => _mount())
338
+ .catch(() => _mount()); // fallback to inline config on network error
339
+ }
340
+
341
+ _mount();
342
+ return Promise.resolve(this);
343
+ }
344
+
345
+ /**
346
+ * Fetch public widget config from the platform and merge into this._cfg.
347
+ * Only fields not already overridden by the constructor are applied.
348
+ */
349
+ async _fetchWidgetConfig() {
350
+ const url = `${this._cfg.backendUrl}/platforms/web-widget/public/${this._cfg.widgetId}`;
351
+ try {
352
+ const res = await fetch(url);
353
+ if (!res.ok) return;
354
+ const { config } = await res.json();
355
+ if (!config) return;
356
+ // Merge — explicit constructor overrides take precedence
357
+ if (config.botName && !this._userSet('botName')) this._cfg.botName = config.botName;
358
+ if (config.welcomeMessage && !this._userSet('welcomeMessage')) this._cfg.welcomeMessage = config.welcomeMessage;
359
+ if (config.primaryColor && !this._userSet('primaryColor')) this._cfg.primaryColor = config.primaryColor;
360
+ if (config.placeholder && !this._userSet('placeholder')) this._cfg.placeholder = config.placeholder;
361
+ if (config.assistantId && !this._cfg.assistantId) this._cfg.assistantId = config.assistantId;
362
+ if (config.avatar?.value && !this._userSet('botEmoji')) this._cfg.botEmoji = config.avatar.value;
363
+ } catch {
364
+ // Silently ignore — widget falls back to inline config
365
+ }
366
+ }
367
+
368
+ // eslint-disable-next-line no-unused-vars
369
+ _userSet(_key) {
370
+ // In production use, explicit constructor props shadow defaults.
371
+ // This is a simple passthrough — override in subclasses if needed.
372
+ return false;
373
+ }
374
+
375
+ unmount() {
376
+ this._unbindViewportMetrics();
377
+ if (this._container) {
378
+ this._container.remove();
379
+ this._container = null;
380
+ }
381
+ }
382
+
383
+ open() {
384
+ this._open = true;
385
+ this._syncViewportMetrics();
386
+ this._panel?.classList.add('open');
387
+ this._textarea?.focus();
388
+ }
389
+
390
+ close() {
391
+ this._open = false;
392
+ this._panel?.classList.remove('open');
393
+ }
394
+
395
+ toggle() {
396
+ this._open ? this.close() : this.open();
397
+ }
398
+
399
+ clearHistory() {
400
+ this._messages = [];
401
+ if (this._messagesEl) this._messagesEl.innerHTML = '';
402
+ if (this._cfg.welcomeMessage) this._appendMessage('assistant', this._cfg.welcomeMessage);
403
+ }
404
+
405
+ // ── DOM ─────────────────────────────────────────────────────────────────────
406
+
407
+ _buildDOM() {
408
+ const root = document.createElement('div');
409
+ root.className = 'cd-root';
410
+
411
+ // Floating toggle button
412
+ const btn = document.createElement('button');
413
+ btn.className = 'cd-widget-btn';
414
+ btn.innerHTML = ICONS.chat;
415
+ btn.setAttribute('aria-label', 'Open chat');
416
+ btn.style.setProperty('--cd-primary', this._cfg.primaryColor);
417
+ this._toggleBtn = btn;
418
+
419
+ // Panel
420
+ const panel = document.createElement('div');
421
+ panel.className = 'cd-panel';
422
+ this._panel = panel;
423
+
424
+ // Header
425
+ const header = document.createElement('div');
426
+ header.className = 'cd-header';
427
+ header.innerHTML = `
428
+ <div class="cd-header-avatar">${this._cfg.botEmoji}</div>
429
+ <div class="cd-header-info">
430
+ <div class="cd-header-name">${this._escHtml(this._cfg.botName)}</div>
431
+ <div class="cd-header-status">
432
+ <span class="cd-status-dot"></span> Online
433
+ </div>
434
+ </div>
435
+ `;
436
+ const closeBtn = document.createElement('button');
437
+ closeBtn.className = 'cd-close-btn';
438
+ closeBtn.innerHTML = ICONS.close;
439
+ closeBtn.setAttribute('aria-label', 'Close chat');
440
+ header.appendChild(closeBtn);
441
+ this._closeBtn = closeBtn;
442
+
443
+ // Messages
444
+ const messages = document.createElement('div');
445
+ messages.className = 'cd-messages';
446
+ messages.setAttribute('role', 'log');
447
+ messages.setAttribute('aria-live', 'polite');
448
+ this._messagesEl = messages;
449
+
450
+ // Input area
451
+ const inputArea = document.createElement('div');
452
+ inputArea.className = 'cd-input-area';
453
+
454
+ const textarea = document.createElement('textarea');
455
+ textarea.className = 'cd-textarea';
456
+ textarea.rows = 1;
457
+ textarea.placeholder = this._cfg.placeholder;
458
+ this._textarea = textarea;
459
+
460
+ const sendBtn = document.createElement('button');
461
+ sendBtn.className = 'cd-send-btn';
462
+ sendBtn.innerHTML = ICONS.send;
463
+ sendBtn.setAttribute('aria-label', 'Send');
464
+ this._sendBtn = sendBtn;
465
+
466
+ inputArea.appendChild(textarea);
467
+ inputArea.appendChild(sendBtn);
468
+
469
+ // Powered-by
470
+ const powered = document.createElement('div');
471
+ powered.className = 'cd-powered';
472
+ powered.innerHTML = `Powered by <a href="https://cognitiondesk.com" target="_blank" rel="noopener">CognitionDesk</a>`;
473
+
474
+ panel.appendChild(header);
475
+ panel.appendChild(messages);
476
+ panel.appendChild(inputArea);
477
+ panel.appendChild(powered);
478
+
479
+ root.appendChild(btn);
480
+ root.appendChild(panel);
481
+ this._shadow.appendChild(root);
482
+ this._root = root;
483
+ }
484
+
485
+ _applyTheme() {
486
+ const theme = this._cfg.theme === 'auto'
487
+ ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
488
+ : this._cfg.theme;
489
+ if (theme === 'dark') this._root?.classList.add('cd-dark');
490
+ this._root?.style.setProperty('--cd-primary', this._cfg.primaryColor);
491
+ this._panel?.style.setProperty('--cd-primary', this._cfg.primaryColor);
492
+ }
493
+
494
+ // ── Events ──────────────────────────────────────────────────────────────────
495
+
496
+ _bindEvents() {
497
+ this._toggleBtn.addEventListener('click', () => this.toggle());
498
+ this._closeBtn.addEventListener('click', () => this.close());
499
+ this._sendBtn.addEventListener('click', () => this._sendMessage());
500
+
501
+ this._textarea.addEventListener('keydown', e => {
502
+ if (e.key === 'Enter' && !e.shiftKey) {
503
+ e.preventDefault();
504
+ this._sendMessage();
505
+ }
506
+ });
507
+
508
+ // Auto-grow textarea
509
+ this._textarea.addEventListener('input', () => {
510
+ this._textarea.style.height = 'auto';
511
+ this._textarea.style.height = Math.min(this._textarea.scrollHeight, 120) + 'px';
512
+ });
513
+
514
+ this._textarea.addEventListener('focus', () => {
515
+ this._syncViewportMetrics();
516
+ });
517
+ }
518
+
519
+ _bindViewportMetrics() {
520
+ if (this._viewportHandler) return;
521
+
522
+ this._viewportHandler = () => this._syncViewportMetrics();
523
+
524
+ window.addEventListener('resize', this._viewportHandler, { passive: true });
525
+ window.addEventListener('orientationchange', this._viewportHandler);
526
+
527
+ if (window.visualViewport) {
528
+ window.visualViewport.addEventListener('resize', this._viewportHandler);
529
+ window.visualViewport.addEventListener('scroll', this._viewportHandler);
530
+ }
531
+ }
532
+
533
+ _unbindViewportMetrics() {
534
+ if (!this._viewportHandler) return;
535
+
536
+ window.removeEventListener('resize', this._viewportHandler);
537
+ window.removeEventListener('orientationchange', this._viewportHandler);
538
+
539
+ if (window.visualViewport) {
540
+ window.visualViewport.removeEventListener('resize', this._viewportHandler);
541
+ window.visualViewport.removeEventListener('scroll', this._viewportHandler);
542
+ }
543
+
544
+ this._viewportHandler = null;
545
+ }
546
+
547
+ _syncViewportMetrics() {
548
+ const target = this._root || this._container;
549
+ if (!target) return;
550
+
551
+ const visualViewport = window.visualViewport;
552
+ const viewportHeight = visualViewport ? visualViewport.height : window.innerHeight;
553
+
554
+ target.style.setProperty('--cd-viewport-height', `${Math.round(viewportHeight)}px`);
555
+ }
556
+
557
+ // ── Chat ────────────────────────────────────────────────────────────────────
558
+
559
+ async _sendMessage() {
560
+ const text = this._textarea.value.trim();
561
+ if (!text || this._loading) return;
562
+
563
+ this._textarea.value = '';
564
+ this._textarea.style.height = 'auto';
565
+ this._loading = true;
566
+ this._sendBtn.disabled = true;
567
+
568
+ this._messages.push({ role: 'user', content: text });
569
+ this._appendMessage('user', text, false);
570
+
571
+ try {
572
+ if (this._cfg.streaming) {
573
+ await this._callApiStream();
574
+ } else {
575
+ const typingEl = this._showTyping();
576
+ const response = await this._callApi();
577
+ this._removeTyping(typingEl);
578
+ this._messages.push({ role: 'assistant', content: response });
579
+ this._appendMessage('assistant', response, true);
580
+ }
581
+ } catch (err) {
582
+ this._appendMessage('error', 'Sorry, something went wrong. Please try again.', false);
583
+ console.error('[CognitionDesk]', err);
584
+ } finally {
585
+ this._loading = false;
586
+ this._sendBtn.disabled = false;
587
+ this._textarea.focus();
588
+ }
589
+ }
590
+
591
+ _buildRequestBody() {
592
+ return {
593
+ messages: this._messages,
594
+ assistantId: this._cfg.assistantId || undefined,
595
+ userContext: {
596
+ sessionId: this._sessionId,
597
+ assistantId: this._cfg.assistantId || undefined,
598
+ widgetId: this._cfg.widgetId || undefined,
599
+ platform: 'cognitiondesk-widget',
600
+ },
601
+ };
602
+ }
603
+
604
+ /** Streaming path — uses SSE endpoint */
605
+ async _callApiStream() {
606
+ const endpoint = `${this._cfg.backendUrl}/chat-apiKeyAuth/stream`;
607
+
608
+ const res = await fetch(endpoint, {
609
+ method: 'POST',
610
+ headers: { 'Content-Type': 'application/json', 'x-api-key': this._cfg.apiKey },
611
+ body: JSON.stringify({ ...this._buildRequestBody(), stream: true }),
612
+ });
613
+
614
+ if (!res.ok) {
615
+ const errData = await res.json().catch(() => ({}));
616
+ throw new Error(errData.message || `HTTP ${res.status}`);
617
+ }
618
+
619
+ // Create an empty assistant bubble to stream into
620
+ const bubble = document.createElement('div');
621
+ bubble.className = 'cd-msg assistant';
622
+ bubble.innerHTML = `<div class="cd-typing"><span></span><span></span><span></span></div>`;
623
+ this._messagesEl.appendChild(bubble);
624
+ this._scrollToBottom();
625
+
626
+ const reader = res.body.getReader();
627
+ const decoder = new TextDecoder();
628
+ let fullText = '';
629
+ let started = false;
630
+
631
+ try {
632
+ while (true) {
633
+ const { done, value } = await reader.read();
634
+ if (done) break;
635
+
636
+ const chunk = decoder.decode(value, { stream: true });
637
+ for (const line of chunk.split('\n')) {
638
+ if (!line.startsWith('data: ')) continue;
639
+ const raw = line.slice(6).trim();
640
+ if (raw === '[DONE]') break;
641
+
642
+ let parsed;
643
+ try { parsed = JSON.parse(raw); } catch { continue; }
644
+
645
+ if (parsed.type === 'content' && parsed.data) {
646
+ if (!started) {
647
+ bubble.innerHTML = ''; // remove typing dots
648
+ started = true;
649
+ }
650
+ fullText += parsed.data;
651
+ bubble.innerHTML = this._renderMarkdown(fullText);
652
+ this._scrollToBottom();
653
+ }
654
+ }
655
+ }
656
+ } finally {
657
+ reader.releaseLock();
658
+ }
659
+
660
+ if (!started) bubble.innerHTML = this._renderMarkdown('No response');
661
+ if (fullText) this._messages.push({ role: 'assistant', content: fullText });
662
+ }
663
+
664
+ /** Non-streaming fallback path */
665
+ async _callApi() {
666
+ const endpoint = `${this._cfg.backendUrl}/chat-apiKeyAuth/chat`;
667
+
668
+ const res = await fetch(endpoint, {
669
+ method: 'POST',
670
+ headers: { 'Content-Type': 'application/json', 'x-api-key': this._cfg.apiKey },
671
+ body: JSON.stringify(this._buildRequestBody()),
672
+ });
673
+
674
+ if (!res.ok) {
675
+ const errData = await res.json().catch(() => ({}));
676
+ throw new Error(errData.message || `HTTP ${res.status}`);
677
+ }
678
+
679
+ const data = await res.json();
680
+ return (
681
+ data.response ||
682
+ data.message ||
683
+ data.content ||
684
+ data.choices?.[0]?.message?.content ||
685
+ 'No response'
686
+ );
687
+ }
688
+
689
+ // ── DOM helpers ─────────────────────────────────────────────────────────────
690
+
691
+ _appendMessage(role, content, isMarkdown = true) {
692
+ const el = document.createElement('div');
693
+ el.className = `cd-msg ${role}`;
694
+ if (isMarkdown && role !== 'user') {
695
+ el.innerHTML = this._renderMarkdown(content);
696
+ } else {
697
+ el.textContent = content;
698
+ }
699
+ this._messagesEl.appendChild(el);
700
+ this._scrollToBottom();
701
+ }
702
+
703
+ /** Minimal, safe markdown → HTML renderer (no dependencies). */
704
+ _renderMarkdown(text) {
705
+ if (!text) return '';
706
+ let html = this._escHtml(text);
707
+
708
+ // Code blocks (```...```)
709
+ html = html.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) =>
710
+ `<pre style="background:#0f172a;color:#e2e8f0;padding:10px;border-radius:6px;font-size:12px;overflow-x:auto;margin:6px 0;white-space:pre-wrap">${code.trim()}</pre>`
711
+ );
712
+ // Inline code
713
+ html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,.08);padding:1px 5px;border-radius:4px;font-size:.9em">$1</code>');
714
+ // Bold
715
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
716
+ html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
717
+ // Italic
718
+ html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
719
+ html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
720
+ // Links
721
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
722
+ '<a href="$2" target="_blank" rel="noopener" style="color:var(--cd-primary,#2563eb)">$1</a>'
723
+ );
724
+ // Newlines
725
+ html = html.replace(/\n/g, '<br>');
726
+
727
+ return html;
728
+ }
729
+
730
+ _showTyping() {
731
+ const el = document.createElement('div');
732
+ el.className = 'cd-msg assistant';
733
+ el.innerHTML = `<div class="cd-typing"><span></span><span></span><span></span></div>`;
734
+ this._messagesEl.appendChild(el);
735
+ this._scrollToBottom();
736
+ return el;
737
+ }
738
+
739
+ _removeTyping(el) {
740
+ el?.remove();
741
+ }
742
+
743
+ _scrollToBottom() {
744
+ if (this._messagesEl) {
745
+ this._messagesEl.scrollTop = this._messagesEl.scrollHeight;
746
+ }
747
+ }
748
+
749
+ _escHtml(str) {
750
+ return String(str).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
751
+ }
752
+ }