-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfloating-chat.js
More file actions
4055 lines (3626 loc) · 133 KB
/
Copy pathfloating-chat.js
File metadata and controls
4055 lines (3626 loc) · 133 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Universal Chat Widget - Works with any OpenAI-compatible API
(function() {
"use strict";
// ============================================================================
// TYPE DEFINITIONS & CONSTANTS
// ============================================================================
/**
* @typedef {Object} ChatOptions
* Defaults are defined in _normalizeOptions().
* @property {string} [title] - Chat window title
* @property {string} [welcomeMessage] - Initial greeting message
* @property {string} [placeholder] - Input placeholder text
* @property {string} [language] - UI language: "en" | "de" (default: "en")
* @property {string} [headerSubtitle] - Subtitle text shown below title in header (supports markdown links)
* @property {string} [position] - Widget position: "bottom-right" | "bottom-left" | "top-right" | "top-left"
* @property {string} apiEndpoint - API endpoint URL for chat requests (required)
* @property {string} [configUrl] - URL to fetch widget config from (colors, title, etc). If unreachable, widget stays hidden.
* @property {string} [model] - AI model name
* @property {string} [titleBackgroundColor] - Header background color
* @property {string} [titleFontColor] - Header text color
* @property {string} [assistantColor] - Assistant message bubble color
* @property {string} [assistantFontColor] - Assistant text color
* @property {number} [assistantMessageOpacity] - Assistant bubble opacity (0.0–1.0)
* @property {string} [userColor] - User message bubble color
* @property {string} [userFontColor] - User text color
* @property {number} [userMessageOpacity] - User bubble opacity (0.0–1.0)
* @property {string} [chatBackground] - Chat window background color
* @property {string} [stampColor] - Timestamp and badge color
* @property {string} [codeBackgroundColor] - Code block background color
* @property {number} [codeOpacity] - Code block opacity (0.0–1.0)
* @property {string} [codeTextColor] - Code text color
* @property {string} [subtitleColor] - Subtitle text color (falls back to titleFontColor)
* @property {number} [subtitleFontSize] - Subtitle font size in rem (0.5–0.8, default 0.65)
* @property {string} [windowBorderColor] - Window frame border color
* @property {string} [headerBorderColor] - Header bottom border color
* @property {string} [assistantBubbleBorderColor] - Assistant message bubble border color
* @property {string} [userBubbleBorderColor] - User message bubble border color
* @property {string} [inputBorderColor] - Input area border color
* @property {string} [codeBorderColor] - Code block border color
* @property {string} [citationBorderColor] - Citation/reference border color
* @property {string} [buttonIconColor] - Button icon color
* @property {string} [scrollbarColor] - Scrollbar color
* @property {string} [inputTextColor] - Input text color
* @property {number} [inputAreaOpacity] - Input area opacity (0.0–1.0)
* @property {number} [buttonShadowIntensity] - Button shadow intensity (0.0–1.0)
* @property {number} [windowShadowIntensity] - Window/content shadow intensity (0.0–1.0)
* @property {string} [buttonInactiveColor] - Button background when chat is closed
* @property {string} [buttonActiveColor] - Button background when chat is open
* @property {string} [buttonContent] - Button content when inactive (emoji or short text, max 10 chars)
* @property {string} [buttonIconUrl] - URL or data URL for custom button icon (overrides buttonContent)
* @property {boolean} [startOpen] - Auto-open chat on load
* @property {number} [buttonSize] - Chat button size in pixels
* @property {number} [windowWidth] - Chat window width in pixels
* @property {number} [windowHeight] - Chat window height in pixels
* @property {number} [inputShadowIntensity] - Input area shadow intensity (0.0–1.0, falls back to windowShadowIntensity)
* @property {string|HTMLElement} [container] - CSS selector or element for inline mode mount target (default: script's parent element)
* @property {boolean} [showModelInfo] - Display model name in UI
* @property {number} [maxHistoryTokens] - Token budget for conversation history
* @property {number} [alwaysKeepRecentMessages] - Recent messages to keep uncompressed
* @property {number} [maxHistoryMessages] - Maximum stored messages
* @property {boolean} [debug] - Enable debug logging
*/
/**
* @typedef {Object} ChatMessage
* @property {"user"|"assistant"|"system"} role - Message sender role
* @property {string} content - Message content
*/
/**
* @typedef {Object} SourceData
* @property {Object} source - Source information
* @property {string} source.name - Source document name
* @property {string} [source.description] - Source description
* @property {Object<string, string>|string[]} [document] - Document content by citation number
* @property {Object<string, Object>} [metadata] - Metadata by citation number
*/
/**
* Timing constants for animations and delays (in milliseconds)
*/
const TIMINGS = {
FOCUS_DELAY: 100, // Delay before focusing input
DEBOUNCE_INPUT: 100, // Input debounce for auto-resize
IOS_KEYBOARD_DELAY: 300, // Delay for iOS keyboard animations
START_OPEN_DELAY: 1000, // Delay before auto-opening chat
HIGHLIGHT_DURATION: 2000, // Duration for citation highlight
COPY_SUCCESS_DURATION: 2000, // Duration for "copied" indicator
PREVIEW_TIMEOUT: 5000, // Message preview display time
PULSE_ANIMATION: 1500, // Pulse animation duration
REQUEST_TIMEOUT: 60000, // Default request timeout
WELCOME_DELAY_MIN: 1000, // Min delay before welcome message
WELCOME_DELAY_RANGE: 2000, // Random range added to welcome delay
};
/**
* Size constants for UI elements (in pixels)
*/
const SIZES = {
BUTTON_SIZE: 60, // Default chat button size (px)
WINDOW_WIDTH: 450, // Default window width (px)
WINDOW_HEIGHT: 800, // Default window height (px)
MOBILE_BREAKPOINT: 768, // Mobile/desktop breakpoint (px)
MOBILE_PADDING_BOTTOM: 180, // Mobile keyboard padding (px)
SCROLLBAR_WIDTH: 6, // Scrollbar width (px)
INPUT_MAX_HEIGHT: 100, // Max input field height (px)
MAX_BORDER_RADIUS: 12, // Max border radius (px)
WIDGET_MARGIN: 20, // Widget margin from viewport edge (px)
};
/**
* Limit constants for messages, history, and content lengths
*/
const LIMITS = {
MAX_MESSAGE_LENGTH: 2000, // Max characters per message
MAX_HISTORY_MESSAGES: 100, // Hard limit on stored messages
MAX_HISTORY_TOKENS: 8000, // Token budget for API context
ALWAYS_KEEP_RECENT: 10, // Recent messages never compressed
SOURCE_NAME_LENGTH: 500, // Max source name length
SOURCE_DESC_LENGTH: 1000, // Max source description length
SNIPPET_LENGTH: 200, // Citation snippet length
COMPRESSED_MSG_LENGTH: 200, // Compressed message length
USER_MSG_LENGTH: 500, // Compressed user message length
MAX_HEADINGS_LENGTH: 100, // Max heading string length
MIN_CITATION_LENGTH: 15, // Min citation text length
MODEL_NAME_LENGTH: 50, // Max model name length
CHARS_PER_TOKEN: 4, // Approximate chars per token
};
/**
* Cached date/time formatter for _formatTime (avoids creating Intl object per call)
*/
const _timeFormatter = new Intl.DateTimeFormat([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
/**
* Pre-compiled regex for CSS named color validation (used by ChatValidators.validateColor)
*/
const NAMED_COLORS_RE =
/^(transparent|currentColor|aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgr[ae]y|darkgreen|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategr[ae]y|darkturquoise|darkviolet|deeppink|deepskyblue|dimgr[ae]y|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gr[ae]y|green|greenyellow|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgr[ae]y|lightgreen|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategr[ae]y|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|rebeccapurple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategr[ae]y|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen)$/i;
/**
* Translation dictionaries for supported languages
*/
const TRANSLATIONS = {
en: {
title: "Course Assistant",
welcomeMessage: "Hello! How can I help you today?",
placeholder: "Type your question...",
send: "Send",
clearChat: "Clear chat",
clearChatHistory: "Clear chat history",
minimize: "Minimize",
closeChat: "Close chat",
copyCode: "Copy code",
references: "References:",
retry: "↻ Retry",
retrying: "Retrying...",
retryAriaLabel: "Retry sending message",
senderYou: "You",
senderAssistant: "Assistant",
assistantTyping: "Assistant is typing",
errorNetwork: "🔌 Connection lost. Check your internet and try again.",
errorTimeout: "Request timed out. The server took too long to respond.",
errorRateLimit: "Too many requests. Please wait a moment and try again.",
errorServer: "Server error. The service is temporarily unavailable.",
errorAuth: "Authentication error. Please check your API configuration.",
errorClient: "❌ Invalid request. Please try again.",
errorUnknown: "Something went wrong. Please try again.",
errorStreamLost: "Connection lost during response.",
charLimit: `Maximum ${LIMITS.MAX_MESSAGE_LENGTH} characters`,
keyboardHints: "Keyboard shortcuts: Enter to send, Shift+Enter for new line, Escape to close chat",
inputAriaLabel: "Type your message. Press Enter to send, Shift+Enter for new line",
sendAriaLabel: "Send message (Enter)",
openChat: "Open chat (Enter)",
closeChat: "Close chat (Escape)",
openChatAriaLabel: "Open chat. Press Enter to open, Escape to close",
closeChatAriaLabel: "Close chat. Press Escape or Enter to close",
chatConversation: "Chat conversation",
unreadMessages: "unread",
newMessageAnnouncement: "New message from assistant",
messageFrom: "Message from",
sentAt: "sent at",
chat: "Chat",
privateMode: "Private Mode",
privatePlaceholder: "Type a private message...",
privateModeOn: "Private mode is on",
privateModeOff: "Private mode is off",
},
de: {
title: "Kurs-Assistent",
welcomeMessage: "Hallo! Wie kann ich Ihnen helfen?",
placeholder: "Ihre Frage eingeben...",
send: "Senden",
clearChat: "Chat löschen",
clearChatHistory: "Chatverlauf löschen",
minimize: "Minimieren",
closeChat: "Chat schließen",
copyCode: "Code kopieren",
references: "Referenzen:",
retry: "↻ Erneut versuchen",
retrying: "Wird wiederholt...",
retryAriaLabel: "Nachricht erneut senden",
senderYou: "Sie",
senderAssistant: "Assistent",
assistantTyping: "Assistent schreibt",
errorNetwork: "🔌 Verbindung unterbrochen. Überprüfen Sie Ihre Internetverbindung.",
errorTimeout: "Zeitüberschreitung. Der Server hat zu lange gebraucht.",
errorRateLimit: "Zu viele Anfragen. Bitte warten Sie einen Moment.",
errorServer: "Serverfehler. Der Dienst ist vorübergehend nicht verfügbar.",
errorAuth: "Authentifizierungsfehler. Bitte überprüfen Sie Ihre API-Konfiguration.",
errorClient: "❌ Ungültige Anfrage. Bitte versuchen Sie es erneut.",
errorUnknown: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
errorStreamLost: "Verbindung während der Antwort verloren.",
charLimit: `Maximal ${LIMITS.MAX_MESSAGE_LENGTH} Zeichen`,
keyboardHints: "Tastenkürzel: Enter zum Senden, Umschalt+Enter für neue Zeile, Escape zum Schließen",
inputAriaLabel: "Nachricht eingeben. Enter zum Senden, Umschalt+Enter für neue Zeile",
sendAriaLabel: "Nachricht senden (Enter)",
openChat: "Chat öffnen (Enter)",
closeChat: "Chat schließen (Escape)",
openChatAriaLabel: "Chat öffnen. Enter zum Öffnen, Escape zum Schließen",
closeChatAriaLabel: "Chat schließen. Escape oder Enter zum Schließen",
chatConversation: "Chat-Unterhaltung",
unreadMessages: "ungelesen",
newMessageAnnouncement: "Neue Nachricht vom Assistenten",
messageFrom: "Nachricht von",
sentAt: "gesendet um",
chat: "Chat",
privateMode: "Privater Modus",
privatePlaceholder: "Private Nachricht eingeben...",
privateModeOn: "Privater Modus ist aktiv",
privateModeOff: "Privater Modus ist deaktiviert",
},
};
/**
* Looks up a translation string by key for the given language.
* Falls back to English, then returns the key itself.
*/
function _t(lang, key) {
return TRANSLATIONS[lang]?.[key] || TRANSLATIONS.en[key] || key;
}
// ============================================================================
// UTILITY CLASSES
// ============================================================================
/**
* Simple event bus for decoupling components
*/
class EventBus {
constructor() {
this._events = {};
}
on(event, handler) {
if (!this._events[event]) this._events[event] = [];
this._events[event].push(handler);
}
emit(event, data) {
if (!this._events[event]) return;
this._events[event].forEach((handler) => handler(data));
}
off(event, handler) {
if (!this._events[event]) return;
this._events[event] = this._events[event].filter((h) => h !== handler);
}
clear() {
this._events = {};
}
}
/**
* Input validation and sanitization utilities
*/
class ChatValidators {
/**
* Validates API endpoint URL for security
*/
static validateApiEndpoint(endpoint) {
if (!endpoint) return null;
try {
const url = new URL(endpoint);
if (!["https:", "http:"].includes(url.protocol)) {
console.warn("Chat Widget: API endpoint - Protocol not allowed");
return null;
}
return endpoint;
} catch (e) {
console.warn("Chat Widget: API endpoint - Invalid URL format");
return null;
}
}
/**
* Validates and sanitizes AI model name
*/
static validateModel(model) {
if (!model || typeof model !== "string") {
console.warn("Chat Widget: Model name - Expected string");
return null;
}
if (!/^[a-zA-Z0-9\-_.:]+$/.test(model)) {
console.warn("Chat Widget: Model name - Pattern validation failed");
return null;
}
return model.substring(0, LIMITS.MODEL_NAME_LENGTH);
}
/**
* Validates and sanitizes citation source data
*/
static validateSources(sources) {
if (!Array.isArray(sources)) return [];
return sources
.filter((sourceData) => {
if (!sourceData || typeof sourceData !== "object") return false;
if (sourceData.source && typeof sourceData.source === "object") {
if (
sourceData.source.name &&
typeof sourceData.source.name !== "string"
)
return false;
if (
sourceData.source.description &&
typeof sourceData.source.description !== "string"
)
return false;
}
if (sourceData.document && typeof sourceData.document !== "object")
return false;
if (sourceData.metadata && typeof sourceData.metadata !== "object")
return false;
return true;
})
.map((sourceData) => {
const sanitized = {};
if (sourceData.source && typeof sourceData.source === "object") {
sanitized.source = {
name: String(sourceData.source.name || "").substring(
0,
LIMITS.SOURCE_NAME_LENGTH,
),
description: sourceData.source.description
? String(sourceData.source.description).substring(
0,
LIMITS.SOURCE_DESC_LENGTH,
)
: "",
};
if (
sourceData.source.url &&
typeof sourceData.source.url === "string"
) {
sanitized.source.url = sourceData.source.url.substring(0, 2000);
}
}
if (sourceData.document) {
sanitized.document = sourceData.document;
}
if (sourceData.metadata) {
sanitized.metadata = sourceData.metadata;
}
return sanitized;
});
}
/**
* Validates a CSS color value against known safe formats.
* Allows hex, rgb/rgba, hsl/hsla, and named CSS colors.
* Rejects values containing CSS injection characters.
*/
static validateColor(color) {
if (!color || typeof color !== "string") return null;
const c = color.trim();
// Allow hex colors: #rgb, #rrggbb, #rrggbbaa
if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(c)) return c;
// Allow rgb/rgba/hsl/hsla with strict format validation
if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+))?\s*\)$/i.test(c)) return c;
if (/^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+))?\s*\)$/i.test(c)) return c;
// Use CSS.supports for named colors and other valid CSS color values
if (typeof CSS !== "undefined" && CSS.supports && CSS.supports("color", c)) return c;
// Fallback for environments without CSS.supports
if (NAMED_COLORS_RE.test(c)) return c;
console.warn(
"Chat Widget: Color value rejected - unrecognized format:",
c,
);
return null;
}
/**
* Escapes HTML special characters
*/
static escapeHtml(unsafe) {
if (typeof unsafe !== "string") return "";
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
/**
* Color utility functions
*/
class ColorUtils {
/**
* Converts hex color to rgba format with opacity
*/
static hexToRgba(hex, opacity) {
if (!hex || !hex.startsWith("#")) {
return ColorUtils.getColorWithOpacity(hex, opacity);
}
let h = hex.replace("#", "");
if (h.length === 3) {
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
} else if (h.length === 8) {
h = h.slice(0, 6);
}
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
/**
* Creates color-mix CSS value with opacity
*/
static getColorWithOpacity(color, opacity) {
const transparentPercent = (1 - opacity) * 100;
return `color-mix(in srgb, ${color}, transparent ${transparentPercent}%)`;
}
/**
* Scales shadow opacity by intensity multiplier
*/
static scaledShadow(shadow, intensity) {
return shadow.replace(
/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/,
(_, r, g, b, a) =>
`rgba(${r}, ${g}, ${b}, ${(parseFloat(a) * intensity).toFixed(3)})`,
);
}
/**
* Computes WCAG 2.1 contrast ratio between two hex colors.
* Returns null if either color is not a valid hex format.
*/
static contrastRatio(hex1, hex2) {
const parse = (hex) => {
if (!hex || !hex.startsWith('#')) return null;
const m = hex.replace("#", "").match(/^([0-9a-f]{6}|[0-9a-f]{3})$/i);
if (!m) return null;
let h = m[1];
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
const r = parseInt(h.slice(0, 2), 16) / 255;
const g = parseInt(h.slice(2, 4), 16) / 255;
const b = parseInt(h.slice(4, 6), 16) / 255;
const ch = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
};
const c1 = parse(hex1);
const c2 = parse(hex2);
if (c1 == null || c2 == null) return null;
const [a, b] = c1 > c2 ? [c1, c2] : [c2, c1];
return (a + 0.05) / (b + 0.05);
}
/**
* Warns in the console if configured foreground/background color pairs
* do not meet WCAG AA (4.5:1 for body text). Developer-facing; does not
* block rendering.
*/
static warnLowContrast(options) {
const pairs = [
["Title", options.titleFontColor, options.titleBackgroundColor],
["Assistant message", options.assistantFontColor, options.assistantColor],
["User message", options.userFontColor, options.userColor],
["Input", options.inputTextColor, options.chatBackground],
];
for (const [label, fg, bg] of pairs) {
if (!fg || !bg) continue;
const ratio = ColorUtils.contrastRatio(fg, bg);
if (ratio != null && ratio < 4.5) {
// eslint-disable-next-line no-console
console.warn(
`[ChatWidget] ${label} contrast ${ratio.toFixed(2)}:1 fails WCAG 2.1 AA (4.5:1 required for body text). fg=${fg} bg=${bg}`,
);
}
}
}
}
// ============================================================================
// UTILITIES
// ============================================================================
/**
* Renders simple markdown to HTML (only [text](url) links with https).
* Escapes HTML first to prevent XSS, then converts markdown links.
*/
function _renderSimpleMarkdown(text) {
const escaped = ChatValidators.escapeHtml(text);
return escaped.replace(
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);
}
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
/**
* Centralized state management with persistence
*/
class ChatState {
constructor(options = {}) {
this._state = {
isOpen: false,
history: [],
unreadCount: 0,
hasInteracted: false,
traceId: null,
sessionId: null,
lastFailedMessage: null,
isSending: false,
privateMode: false,
};
this._listeners = new Set();
this._options = options;
this._storageKey = this._options._instanceId
? `universalChatState_${this._options._instanceId}`
: "universalChatState";
}
get(key) {
return this._state[key];
}
getAll() {
return { ...this._state };
}
update(updates) {
const oldState = { ...this._state };
this._state = { ...this._state, ...updates };
this._notifyListeners(oldState, this._state);
}
subscribe(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
_notifyListeners(oldState, newState) {
this._listeners.forEach((listener) => listener(newState, oldState));
}
/**
* Saves state to sessionStorage
*/
save() {
const stateToSave = {
history: this._state.history,
hasInteracted: this._state.hasInteracted,
traceId: this._state.traceId,
sessionId: this._state.sessionId,
privateMode: this._state.privateMode,
};
if (this._options.debug) {
console.log("Client saving state with traceId:", this._state.traceId, "sessionId:", this._state.sessionId);
}
sessionStorage.setItem(this._storageKey, JSON.stringify(stateToSave));
}
/**
* Restores state from sessionStorage
*/
restore() {
const saved = sessionStorage.getItem(this._storageKey);
if (saved) {
try {
const state = JSON.parse(saved);
this.update({
history: state.history || [],
hasInteracted: state.hasInteracted || false,
traceId: state.traceId || null,
sessionId: state.sessionId || null,
privateMode: state.privateMode || false,
});
if (this._options.debug) {
console.log(
"Client restored traceId from sessionStorage:",
this._state.traceId,
"sessionId:",
this._state.sessionId,
);
}
return true;
} catch (e) {
console.warn(
"Chat Widget: Failed to restore state, clearing corrupted data:",
e.message,
);
sessionStorage.removeItem(this._storageKey);
return false;
}
}
return false;
}
/**
* Optimizes conversation history for API requests using token-aware sliding window
*/
optimizeHistory() {
const history = this._state.history;
if (history.length === 0) return [];
const recentCount = Math.min(
this._options.alwaysKeepRecentMessages || LIMITS.ALWAYS_KEEP_RECENT,
history.length,
);
const recentMessages = history.slice(-recentCount);
const olderMessages = history.slice(0, -recentCount);
let tokenCount = recentMessages.reduce(
(sum, msg) => sum + this._estimateTokens(msg.content),
0,
);
const maxTokens =
this._options.maxHistoryTokens || LIMITS.MAX_HISTORY_TOKENS;
if (tokenCount < maxTokens && olderMessages.length === 0) {
return history;
}
const optimized = [...recentMessages];
for (let i = olderMessages.length - 1; i >= 0; i--) {
const compressed = this._compressMessage(olderMessages[i]);
const compressedTokens = this._estimateTokens(compressed.content);
if (tokenCount + compressedTokens <= maxTokens) {
optimized.unshift(compressed);
tokenCount += compressedTokens;
} else {
break;
}
}
if (this._options.debug) {
console.log(
`History optimized: ${history.length} → ${optimized.length} messages (~${tokenCount} tokens)`,
);
}
return optimized;
}
/**
* Trims history to maximum message count
*/
trimHistory() {
const maxMessages =
this._options.maxHistoryMessages || LIMITS.MAX_HISTORY_MESSAGES;
if (this._state.history.length > maxMessages) {
const removed = this._state.history.length - maxMessages;
this.update({
history: this._state.history.slice(-maxMessages),
});
if (this._options.debug) {
console.log(`History trimmed: removed ${removed} oldest messages`);
}
}
}
/**
* Estimates token count for text
*/
_estimateTokens(text) {
if (!text || typeof text !== "string") return 0;
return Math.ceil(text.length / LIMITS.CHARS_PER_TOKEN);
}
/**
* Compresses a message for history
*/
_compressMessage(message) {
if (message.role === "user") {
return {
role: "user",
content: message.content.substring(0, LIMITS.USER_MSG_LENGTH),
};
} else {
const content = message.content
.replace(/```[\s\S]*?```/g, "[code]")
.replace(/\[(\d+)\]/g, "")
.replace(/[#*_]/g, "")
.trim();
const firstSentence = content.match(/^[^.!?]+[.!?]/);
const compressed = firstSentence
? firstSentence[0]
: content.substring(0, LIMITS.COMPRESSED_MSG_LENGTH);
return {
role: "assistant",
content: compressed + (compressed.length < content.length ? "..." : ""),
};
}
}
}
// ============================================================================
// API LAYER
// ============================================================================
/**
* Handles all network communication with API
*/
class ChatAPI {
constructor(endpoint, model, options = {}) {
this.endpoint = endpoint;
this.model = model;
this.debug = options.debug || false;
this.timeout = options.timeout || TIMINGS.REQUEST_TIMEOUT;
this.lang = options.language || "en";
this._currentController = null;
}
/**
* Builds the request body for chat API calls
*/
_buildBody(message, history, traceId, sessionId, privateMode, stream = false) {
const body = { message, history, model: this.model, traceId };
if (stream) body.stream = true;
if (sessionId) body.sessionId = sessionId;
if (privateMode) body.privateMode = true;
return body;
}
/**
* Cancels any in-flight request
*/
cancel() {
if (this._currentController) {
this._currentController.abort();
this._currentController = null;
}
}
/**
* Sends message to API and returns response
*/
async sendMessage(message, history, traceId, sessionId, privateMode) {
this.cancel();
const controller = new AbortController();
this._currentController = controller;
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
if (this.debug) {
console.log("Client sending traceId:", traceId, "sessionId:", sessionId);
}
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this._buildBody(message, history, traceId, sessionId, privateMode)),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
throw this._createError(data.error || "Request failed", response);
}
return this._extractResponseData(data);
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
const abortError = new Error("Request timed out or was cancelled");
abortError.errorInfo = {
type: "timeout",
message: "Request timed out. The server took too long to respond.",
};
throw abortError;
}
throw error;
} finally {
if (this._currentController === controller) {
this._currentController = null;
}
}
}
/**
* Sends message via streaming SSE and dispatches events via callbacks
*/
async sendMessageStreaming(message, history, traceId, sessionId, privateMode, callbacks) {
this.cancel();
const controller = new AbortController();
this._currentController = controller;
// First-byte timeout: abort if no data arrives within timeout period
let firstByteReceived = false;
const timeoutId = setTimeout(() => {
if (!firstByteReceived) controller.abort();
}, this.timeout);
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this._buildBody(message, history, traceId, sessionId, privateMode, true)),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
let data = {};
try { data = await response.json(); } catch (_) {}
throw this._createError(data.error || "Request failed", response);
}
// Read SSE stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
firstByteReceived = true;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop(); // keep incomplete last line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("event:")) {
currentEvent = trimmed.slice(6).trim();
} else if (trimmed.startsWith("data:")) {
const dataStr = trimmed.slice(5).trim();
if (dataStr === "[DONE]") continue;
try {
const parsed = JSON.parse(dataStr);
const eventName = currentEvent || "message";
if (eventName === "delta" && parsed.content) {
callbacks.onDelta?.(parsed.content);
} else if (eventName === "sources" && parsed.sources) {
callbacks.onSources?.(parsed.sources);
} else if (eventName === "done") {
callbacks.onDone?.(parsed);
} else if (eventName === "error") {
callbacks.onError?.(new Error(parsed.error || "Stream error"));
}
} catch (_) {
// Skip malformed JSON lines
}
currentEvent = null;
} else if (trimmed === "") {
currentEvent = null;
}
}
}
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
const abortError = new Error("Request timed out or was cancelled");
abortError.errorInfo = {
type: "timeout",
message: "Request timed out. The server took too long to respond.",
};
callbacks.onError?.(abortError);
return;
}
callbacks.onError?.(error);
} finally {
if (this._currentController === controller) {
this._currentController = null;
}
}
}
/**
* Extracts content and sources from API response
*/
_extractResponseData(data) {
const content = data.choices?.[0]?.message?.content || data.response;
// Extract sources from various possible paths
let sources = [];
if (data.source?.sources) {
sources = Object.values(data.source.sources);
} else if (data.sources) {
sources = Array.isArray(data.sources)
? data.sources
: Object.values(data.sources);
} else if (data.context?.sources) {
sources = Object.values(data.context.sources);
} else if (data.choices?.[0]?.message?.sources) {
sources = Object.values(data.choices[0].message.sources);
}
if (this.debug) {
console.log("API Response:", data);
console.log("Sources found:", sources);
console.log("Content:", content);
const citationMatches = content.match(/\[(\d+)\]/g);
console.log("Citation numbers found in content:", citationMatches);
}
return {
content,
sources: ChatValidators.validateSources(sources),
traceId: data.traceId,
sessionId: data.sessionId,
model: data.model,
};
}
/**
* Creates error with type detection
*/
_createError(message, response) {
const error = new Error(message);
error.response = response;
error.errorInfo = this._detectErrorType(error, response);
return error;
}
/**
* Detects and categorizes error type
*/
_detectErrorType(error, response) {
const lang = this.lang;
if (error.name === "TypeError" && error.message.includes("fetch")) {
return { type: "network", message: _t(lang, "errorNetwork") };
}
if (error.name === "AbortError") {
return { type: "timeout", message: _t(lang, "errorTimeout") };
}
if (response) {
if (response.status === 429) {
return { type: "ratelimit", message: _t(lang, "errorRateLimit") };
}
if (response.status >= 500) {
return { type: "server", message: _t(lang, "errorServer") };
}
if (response.status === 401 || response.status === 403) {
return { type: "auth", message: _t(lang, "errorAuth") };
}
if (response.status >= 400) {
return { type: "client", message: _t(lang, "errorClient") };
}
}
return { type: "unknown", message: _t(lang, "errorUnknown") };
}