@retrivora-ai/rag-engine 2.2.9 → 2.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "2.2.9",
3
+ "version": "2.3.1",
4
4
  "description": "Retrivora AI is a plug-and-play AI engine for RAG chat experiences — generic vector DB + LLM provider, embeddable or standalone.",
5
5
  "author": "Abhinav Alkuchi",
6
6
  "license": "UNLICENSED",
@@ -1,13 +1,21 @@
1
- import React, { useState } from 'react';
2
- import { MessageSquare, X, AlertTriangle } from 'lucide-react';
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { MessageSquare, X, AlertTriangle, Lock } from 'lucide-react';
3
3
  import { ChatWindow } from './ChatWindow';
4
4
  import { useConfig } from './ConfigProvider';
5
5
  import { ChatWidgetProps } from '../types';
6
6
  import { SDK_VERSION } from '../version';
7
- import { LicenseValidator } from '../core/LicenseValidator';
7
+ import { LicenseValidator, LicenseValidationResponse } from '../core/LicenseValidator';
8
8
 
9
+ /**
10
+ * Default widget dimensions (width x height) in pixels.
11
+ * Applied when the widget first opens and when the user resets sizing.
12
+ */
9
13
  const DEFAULT_DIMENSIONS = { width: 380, height: 600 };
10
14
 
15
+ /**
16
+ * Keyframes and CSS custom properties for the Gemini-style
17
+ * shape-shifting floating button background.
18
+ */
11
19
  const GEMINI_STYLES = `
12
20
  @keyframes shapeshift {
13
21
  0% { clip-path: var(--circle); rotate: 0turn; }
@@ -33,11 +41,6 @@ const GEMINI_STYLES = `
33
41
  100% { translate: -75% -75%; }
34
42
  }
35
43
 
36
- @keyframes gradientMove {
37
- 0% { translate: 0 0; }
38
- 100% { translate: -75% -75%; }
39
- }
40
-
41
44
  :root {
42
45
  --flower: shape(evenodd from 17.9665% 82.0335%, curve by -12.349% -32.0335% with -13.239% -5.129% / -18.021% -15.402%, curve by -0.0275% -22.203% with -3.1825% -9.331% / -3.074% -16.6605%, curve by 12.3765% -9.8305% with 2.3835% -4.3365% / 6.565% -7.579%, curve by 32.0335% -12.349% with 5.129% -13.239% / 15.402% -18.021%, curve by 20.4535% -0.8665% with 8.3805% -2.858% / 15.1465% -3.062%, curve by 11.58% 13.2155% with 5.225% 2.161% / 9.0355% 6.6475%, curve by 12.349% 32.0335% with 13.239% 5.129% / 18.021% 15.402%, curve by 0.5715% 21.1275% with 2.9805% 8.7395% / 3.0745% 15.723%, curve by -12.9205% 10.906% with -2.26% 4.88% / -6.638% 8.472%, curve by -32.0335% 12.349% with -5.129% 13.239% / -15.402% 18.021%, curve by -21.1215% 0.5745% with -8.736% 2.9795% / -15.718% 3.0745%, curve by -10.912% -12.9235% with -4.883% -2.2595% / -8.477% -6.6385%, close);
43
46
  --hexagon: shape(evenodd from 6.47% 67.001%, curve by 0% -34.002% with -1.1735% -7.7% / -1.1735% -26.302%, curve by 7.0415% -12.1965% with 0.7075% -4.641% / 3.3765% -9.2635%, curve by 29.447% -17.001% with 6.0815% -4.8665% / 22.192% -14.1675%, curve by 14.083% 0% with 4.3725% -1.708% / 9.7105% -1.708%, curve by 29.447% 17.001% with 7.255% 2.8335% / 23.3655% 12.1345%, curve by 7.0415% 12.1965% with 3.665% 2.933% / 6.334% 7.5555%, curve by 0% 34.002% with 1.1735% 7.7% / 1.1735% 26.302%, curve by -7.0415% 12.1965% with -0.7075% 4.641% / -3.3765% 9.2635%, curve by -29.447% 17.001% with -6.0815% 4.8665% / -22.192% 14.1675%, curve by -14.083% 0% with -4.3725% 1.708% / -9.7105% 1.708%, curve by -29.447% -17.001% with -7.255% -2.8335% / -23.3655% -12.1345%, curve by -7.0415% -12.1965% with -3.665% -2.933% / -6.334% -7.5555%, close);
@@ -46,6 +49,24 @@ const GEMINI_STYLES = `
46
49
  }
47
50
  `;
48
51
 
52
+ /**
53
+ * ChatWidget — Floating Action Button (FAB) that toggles the chat window.
54
+ *
55
+ * Responsibilities:
56
+ * - Validates the Retrivora license key BEFORE allowing the chat to open
57
+ * - Proactively validates on mount to visually lock the widget when no
58
+ * valid license is present (TERMINATED / SUSPENDED / missing)
59
+ * - Manages window resize, maximize, and visual positioning
60
+ * - Shows an error toast when license validation fails on user attempt
61
+ *
62
+ * License key resolution order (highest to lowest priority):
63
+ * 1. Explicit `licenseKey` prop
64
+ * 2. `x-license-key` header (from `headers` prop)
65
+ * 3. `Authorization: Bearer <key>` header (from `headers` prop)
66
+ * 4. Environment variables `NEXT_PUBLIC_RETRIVORA_LICENSE_KEY` / `RETRIVORA_LICENSE_KEY`
67
+ *
68
+ * @param props - Widget position, callbacks, API overrides, and license overrides
69
+ */
49
70
  export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, retrivoraApiBase, licenseKey: licenseKeyProp, headers }: ChatWidgetProps) {
50
71
  const { ui, projectId } = useConfig();
51
72
  const [isOpen, setIsOpen] = useState(false);
@@ -56,6 +77,7 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
56
77
  const [prevDimensions, setPrevDimensions] = useState(DEFAULT_DIMENSIONS);
57
78
  const [widgetError, setWidgetError] = useState<string | null>(null);
58
79
  const [isValidating, setIsValidating] = useState(false);
80
+ const [hasMountValidationFailed, setHasMountValidationFailed] = useState(false);
59
81
 
60
82
  if (ui.showWidget === false) return null;
61
83
 
@@ -69,26 +91,75 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
69
91
  ? 'bottom-20 left-6'
70
92
  : 'bottom-20 right-6';
71
93
 
72
- const handleOpen = async () => {
94
+ /**
95
+ * Resolves the active license key from all possible sources
96
+ * (explicit prop → headers → environment variables).
97
+ */
98
+ const resolveLicenseKey = useCallback((): string => {
99
+ const getHeader = (name: string) => {
100
+ if (!headers) return undefined;
101
+ const target = name.toLowerCase();
102
+ for (const [k, v] of Object.entries(headers)) {
103
+ if (k.toLowerCase() === target) return v;
104
+ }
105
+ return undefined;
106
+ };
107
+
108
+ return (
109
+ licenseKeyProp ||
110
+ getHeader('x-license-key') ||
111
+ getHeader('authorization')?.replace(/^Bearer\s+/i, '') ||
112
+ (typeof process !== 'undefined'
113
+ ? (process.env.NEXT_PUBLIC_RETRIVORA_LICENSE_KEY || process.env.RETRIVORA_LICENSE_KEY)
114
+ : '') ||
115
+ ''
116
+ );
117
+ }, [licenseKeyProp, headers]);
118
+
119
+ /**
120
+ * Proactively validate the license on mount.
121
+ * If validation fails, mark the widget locked so the FAB button appears
122
+ * disabled and we don't waste a network call on every subsequent click.
123
+ */
124
+ useEffect(() => {
125
+ let isMounted = true;
126
+ async function validateOnMount() {
127
+ try {
128
+ const resolvedLicenseKey = resolveLicenseKey();
129
+ await LicenseValidator.getInstance().validate({
130
+ licenseKey: resolvedLicenseKey,
131
+ projectId,
132
+ retrivoraApiBase,
133
+ headers,
134
+ });
135
+ if (isMounted) {
136
+ setHasMountValidationFailed(false);
137
+ setWidgetError(null);
138
+ }
139
+ } catch (err: any) {
140
+ if (isMounted) {
141
+ setHasMountValidationFailed(true);
142
+ setWidgetError(err?.message || 'Access denied.');
143
+ }
144
+ }
145
+ }
146
+ validateOnMount();
147
+ return () => { isMounted = false; };
148
+ }, [resolveLicenseKey, projectId, retrivoraApiBase, headers]);
149
+
150
+ /**
151
+ * Validates the license key and opens the chat window.
152
+ * If the license was already marked invalid on mount we immediately
153
+ * re-display the error toast without re-validating.
154
+ */
155
+ const handleOpen = useCallback(async () => {
156
+ if (hasMountValidationFailed && widgetError) {
157
+ setWidgetError(widgetError);
158
+ return;
159
+ }
73
160
  setIsValidating(true);
74
161
  try {
75
- const getHeader = (name: string) => {
76
- if (!headers) return undefined;
77
- const target = name.toLowerCase();
78
- for (const [k, v] of Object.entries(headers)) {
79
- if (k.toLowerCase() === target) return v;
80
- }
81
- return undefined;
82
- };
83
-
84
- const resolvedLicenseKey =
85
- licenseKeyProp ||
86
- getHeader('x-license-key') ||
87
- getHeader('authorization')?.replace(/^Bearer\s+/i, '') ||
88
- (typeof process !== 'undefined'
89
- ? (process.env.NEXT_PUBLIC_RETRIVORA_LICENSE_KEY || process.env.RETRIVORA_LICENSE_KEY)
90
- : '') ||
91
- '';
162
+ const resolvedLicenseKey = resolveLicenseKey();
92
163
 
93
164
  await LicenseValidator.getInstance().validate({
94
165
  licenseKey: resolvedLicenseKey,
@@ -98,15 +169,20 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
98
169
  });
99
170
 
100
171
  setWidgetError(null);
172
+ setHasMountValidationFailed(false);
101
173
  setIsOpen(true);
102
174
  setHasUnread(false);
103
175
  } catch (err: any) {
176
+ setHasMountValidationFailed(true);
104
177
  setWidgetError(err?.message || 'Access denied.');
105
- setIsOpen(false); // DO NOT OPEN CHAT WIDGET
178
+ setIsOpen(false);
106
179
  } finally {
107
180
  setIsValidating(false);
108
181
  }
109
- };
182
+ }, [hasMountValidationFailed, widgetError, resolveLicenseKey, projectId, retrivoraApiBase, headers]);
183
+
184
+ /** True when the license has been proven invalid and the widget must be locked. */
185
+ const isWidgetLocked = hasMountValidationFailed || isValidating;
110
186
 
111
187
  const handleResizeStart = (e: React.MouseEvent) => {
112
188
  e.preventDefault();
@@ -224,40 +300,65 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
224
300
  {/* ── Floating Action Button ── */}
225
301
  <button
226
302
  onClick={isOpen ? () => setIsOpen(false) : handleOpen}
227
- className={`group fixed z-[9999] w-14 h-14 drop-shadow-2xl flex items-center justify-center transition-all duration-300 cursor-pointer hover:scale-110 active:scale-95 ${positionClass}`}
228
- aria-label="Open chat"
303
+ disabled={isWidgetLocked && !isOpen}
304
+ aria-label={isWidgetLocked ? 'Chat unavailable - invalid or missing license key' : (isOpen ? 'Close chat' : 'Open chat')}
305
+ aria-disabled={isWidgetLocked && !isOpen}
306
+ className={`group fixed z-[9999] w-14 h-14 drop-shadow-2xl flex items-center justify-center transition-all duration-300 ${positionClass} ${
307
+ isWidgetLocked && !isOpen
308
+ ? 'grayscale opacity-60 cursor-not-allowed'
309
+ : 'cursor-pointer hover:scale-110 active:scale-95'
310
+ }`}
229
311
  >
230
- {/* Animated Background */}
312
+ {/* Animated Background (disabled when locked) */}
231
313
  <div
232
- className="absolute inset-0 transition-all duration-300 overflow-hidden rounded-full group-hover:rounded-none group-hover:animate-[shapeshift_5s_ease-in-out_infinite_forwards]"
314
+ className={`absolute inset-0 transition-all duration-300 overflow-hidden rounded-full ${
315
+ !(isWidgetLocked && !isOpen) && 'group-hover:rounded-none group-hover:animate-[shapeshift_5s_ease-in-out_infinite_forwards]'
316
+ }`}
317
+ style={{
318
+ background: isWidgetLocked && !isOpen
319
+ ? 'linear-gradient(135deg, #64748b, #475569)'
320
+ : undefined,
321
+ }}
233
322
  >
234
- <div
235
- className="absolute top-0 left-0 w-[400%] h-[400%] transition-transform duration-300 group-hover:animate-[gradientMove_3.5s_ease-in-out_infinite_alternate]"
236
- style={{
237
- background: `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor}, ${ui.primaryColor}, ${ui.accentColor})`
238
- }}
239
- />
323
+ {!(isWidgetLocked && !isOpen) && (
324
+ <div
325
+ className="absolute top-0 left-0 w-[400%] h-[400%] transition-transform duration-300 group-hover:animate-[gradientMove_3.5s_ease-in-out_infinite_alternate]"
326
+ style={{
327
+ background: `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor}, ${ui.primaryColor}, ${ui.accentColor})`
328
+ }}
329
+ />
330
+ )}
240
331
  </div>
241
332
 
242
- {/* Ripple ring */}
243
- <span
244
- className="absolute inset-0 rounded-full animate-ping opacity-20 pointer-events-none"
245
- style={{ background: ui.primaryColor }}
246
- />
333
+ {/* Ripple ring (hidden when locked) */}
334
+ {!(isWidgetLocked && !isOpen) && (
335
+ <span
336
+ className="absolute inset-0 rounded-full animate-ping opacity-20 pointer-events-none"
337
+ style={{ background: ui.primaryColor }}
338
+ />
339
+ )}
247
340
 
248
341
  {/* Icon Layer */}
249
342
  <span
250
- className={`relative z-10 transition-transform duration-300 ${isOpen ? 'rotate-90 scale-90' : 'rotate-0 scale-100'}`}
343
+ className={`relative z-10 transition-transform duration-300 ${
344
+ isOpen
345
+ ? 'rotate-90 scale-90'
346
+ : isWidgetLocked
347
+ ? 'scale-90'
348
+ : 'rotate-0 scale-100'
349
+ }`}
251
350
  >
252
351
  {isOpen ? (
253
352
  <X className="w-6 h-6 text-white" />
353
+ ) : isWidgetLocked ? (
354
+ <Lock className="w-6 h-6 text-white" />
254
355
  ) : (
255
356
  <MessageSquare className="w-6 h-6 text-white" />
256
357
  )}
257
358
  </span>
258
359
 
259
- {/* Unread badge */}
260
- {hasUnread && !isOpen && (
360
+ {/* Unread badge (hidden when locked or open) */}
361
+ {hasUnread && !isOpen && !isWidgetLocked && (
261
362
  <span className="absolute top-0 right-0 w-4 h-4 bg-rose-500 rounded-full border-2 border-white flex items-center justify-center text-[9px] font-bold text-white">
262
363
  1
263
364
  </span>
@@ -1,5 +1,18 @@
1
1
  'use client';
2
2
 
3
+ /**
4
+ * ChatWindow — The primary chat interface container component.
5
+ *
6
+ * Renders a fully functional chat experience including:
7
+ * - Header with branding, online indicator, and window controls (clear/resize/maximize/close)
8
+ * - Scrollable message area with SSE-streamed assistant replies, sources, and welcome state
9
+ * - Input area with textarea, voice input, document upload, auto-suggestions, and send controls
10
+ * - Mandatory license validation on mount (shows a blocking error overlay if invalid / terminated)
11
+ *
12
+ * All chat-send-side inputs (textarea, voice, upload, suggestion chips, send button) are
13
+ * automatically disabled when the license validation fails or SDK version is unsupported.
14
+ */
15
+
3
16
  import React, { useState, useRef, useEffect, useCallback } from 'react';
4
17
  import {
5
18
  Bot,
@@ -25,6 +38,7 @@ import { ChatWindowProps } from '../types';
25
38
  import { SDK_VERSION } from '../version';
26
39
  import { LicenseValidator } from '../core/LicenseValidator';
27
40
 
41
+ /** Browser Web Speech API extended event shape for continuous transcript delivery. */
28
42
  interface SpeechRecognitionEvent {
29
43
  resultIndex: number;
30
44
  results: {
@@ -36,10 +50,12 @@ interface SpeechRecognitionEvent {
36
50
  };
37
51
  }
38
52
 
53
+ /** Browser Web Speech API error event shape. */
39
54
  interface SpeechRecognitionErrorEvent {
40
55
  error: string;
41
56
  }
42
57
 
58
+ /** Browser Web Speech API controller interface. */
43
59
  interface ISpeechRecognition {
44
60
  continuous: boolean;
45
61
  interimResults: boolean;
@@ -50,11 +66,30 @@ interface ISpeechRecognition {
50
66
  stop: () => void;
51
67
  }
52
68
 
69
+ /** Augmented Window global that surfaces both prefixed and unprefixed SpeechRecognition constructors. */
53
70
  interface WindowWithSpeech extends Window {
54
71
  SpeechRecognition?: new () => ISpeechRecognition;
55
72
  webkitSpeechRecognition?: new () => ISpeechRecognition;
56
73
  }
57
74
 
75
+ /**
76
+ * ChatWindow component.
77
+ *
78
+ * @param className - Additional CSS class for the outer wrapper.
79
+ * @param style - Inline styles for the outer wrapper.
80
+ * @param onClose - Callback for the close (X) button (widget mode only).
81
+ * @param showClose - Whether to render the close button.
82
+ * @param onResizeStart - Callback fired when user begins dragging the resize handle.
83
+ * @param onResetResize - Callback fired when the reset-size button is clicked.
84
+ * @param isResized - True if the window has been resized away from defaults.
85
+ * @param onMaximize - Callback for the maximize/minimize toggle button.
86
+ * @param isMaximized - True if the window is currently maximized.
87
+ * @param onAddToCart - Callback fired when user clicks "Add to Cart" on a product card.
88
+ * @param apiUrl - Override URL for the chat endpoint (bypasses retrivoraApiBase).
89
+ * @param retrivoraApiBase - Base URL for Retrivora catch-all endpoints (chat/suggestions/history/etc).
90
+ * @param licenseKey - Override license key (highest priority; falls back to headers/env).
91
+ * @param headers - Custom headers forwarded with every network request.
92
+ */
58
93
  export function ChatWindow({
59
94
  className = '',
60
95
  style,
@@ -95,6 +130,12 @@ export function ChatWindow({
95
130
  const [versionError, setVersionError] = useState<string | null>(null);
96
131
  const recognitionRef = useRef<ISpeechRecognition | null>(null);
97
132
 
133
+ /**
134
+ * True when all chat-send interactions must be blocked
135
+ * (license validation failed or SDK version is no longer supported).
136
+ */
137
+ const isDisabled = !!licenseError || !!versionError;
138
+
98
139
  // Validate server license and SDK version on ChatWindow session initialization
99
140
  useEffect(() => {
100
141
  let isMounted = true;
@@ -361,7 +402,8 @@ export function ChatWindow({
361
402
  <button
362
403
  onClick={clearHistory}
363
404
  title="Clear conversation"
364
- className="w-7 h-7 rounded-md flex items-center justify-center text-slate-400 hover:text-rose-500 dark:text-white/40 dark:hover:text-rose-400 hover:bg-white dark:hover:bg-white/10 transition-all cursor-pointer"
405
+ disabled={isDisabled}
406
+ className="w-7 h-7 rounded-md flex items-center justify-center text-slate-400 hover:text-rose-500 dark:text-white/40 dark:hover:text-rose-400 hover:bg-white dark:hover:bg-white/10 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-slate-400 dark:disabled:hover:text-white/40"
365
407
  >
366
408
  <Trash2 className="w-3.5 h-3.5" />
367
409
  </button>
@@ -524,18 +566,20 @@ export function ChatWindow({
524
566
  {/* ── Input ── */}
525
567
  <div className={`px-4 pb-4 pt-2 border-t border-slate-200 dark:border-white/10 ${isGlass ? 'bg-transparent' : 'bg-white dark:bg-[#0f0f1a]'}`}>
526
568
 
527
- {/* Dynamic Suggestions */}
569
+ {/* Dynamic Suggestions — hidden/blocked when license/version error */}
528
570
  {(suggestions.length > 0 || isSuggesting) && !isLoading && (
529
571
  <div className="mb-3 flex flex-wrap gap-2 animate-in fade-in slide-in-from-bottom-2 duration-300">
530
572
  {suggestions.map((suggestion) => (
531
573
  <button
532
574
  key={suggestion}
533
575
  onClick={() => {
576
+ if (isDisabled) return;
534
577
  setInput(suggestion);
535
578
  setSuggestions([]);
536
579
  inputRef.current?.focus();
537
580
  }}
538
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-600 dark:text-white/60 hover:text-slate-900 dark:hover:text-white/90 hover:border-slate-300 dark:hover:border-white/20 transition-all shadow-sm cursor-pointer group"
581
+ disabled={isDisabled}
582
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-slate-600 dark:text-white/60 hover:text-slate-900 dark:hover:text-white/90 hover:border-slate-300 dark:hover:border-white/20 transition-all shadow-sm cursor-pointer group disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-slate-600 dark:disabled:hover:text-white/60"
539
583
  >
540
584
  <Sparkles className="w-3 h-3 text-amber-400" />
541
585
  {suggestion}
@@ -564,7 +608,7 @@ export function ChatWindow({
564
608
  onKeyDown={handleKeyDown}
565
609
  placeholder={ui.placeholder}
566
610
  rows={1}
567
- disabled={isLoading || !!licenseError || !!versionError}
611
+ disabled={isLoading || isDisabled}
568
612
  className="flex-1 bg-transparent text-slate-900 dark:text-white/90 placeholder-slate-400 dark:placeholder-white/30 text-sm resize-none outline-none py-1.5 px-2 max-h-32 leading-relaxed disabled:opacity-50"
569
613
  style={{ scrollbarWidth: 'none' }}
570
614
  />
@@ -573,7 +617,7 @@ export function ChatWindow({
573
617
  <button
574
618
  type="button"
575
619
  onClick={toggleListening}
576
- disabled={isLoading || !!licenseError || !!versionError}
620
+ disabled={isLoading || isDisabled}
577
621
  className={`flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:active:scale-100 ${isListening
578
622
  ? 'bg-rose-500 text-white animate-pulse shadow-md shadow-rose-500/20'
579
623
  : 'text-slate-400 dark:text-white/40 hover:bg-slate-200 dark:hover:bg-white/10'
@@ -587,8 +631,8 @@ export function ChatWindow({
587
631
  {ui.allowUpload !== false && (
588
632
  <button
589
633
  type="button"
590
- onClick={() => setIsUploadModalOpen(true)}
591
- disabled={isLoading || !!licenseError || !!versionError}
634
+ onClick={() => { if (!isDisabled) setIsUploadModalOpen(true); }}
635
+ disabled={isLoading || isDisabled}
592
636
  className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center transition-all hover:scale-105 active:scale-95 text-slate-400 dark:text-white/40 hover:bg-slate-200 dark:hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
593
637
  title="Upload Document"
594
638
  >
@@ -607,7 +651,7 @@ export function ChatWindow({
607
651
  ) : (
608
652
  <button
609
653
  onClick={sendMessage}
610
- disabled={!input.trim() || !!licenseError || !!versionError}
654
+ disabled={!input.trim() || isDisabled}
611
655
  className={`flex-shrink-0 w-9 h-9 flex items-center justify-center transition-all shadow-md active:scale-95 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:active:scale-100 ${ui.borderRadius === 'full' ? 'rounded-full' : 'rounded-xl'}`}
612
656
  style={{ background: `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor})` }}
613
657
  title="Send message"
@@ -5,6 +5,8 @@
5
5
  * configurable batch sizing for efficient bulk operations.
6
6
  */
7
7
 
8
+ import { CircuitBreaker } from './CircuitBreaker';
9
+
8
10
  export interface BatchOptions {
9
11
  /** Maximum number of items per batch */
10
12
  batchSize?: number;
@@ -30,7 +32,7 @@ export interface BatchResult<T> {
30
32
  /**
31
33
  * Determines if an error is transient (should retry)
32
34
  */
33
- function isTransientError(error: unknown): boolean {
35
+ export function isTransientError(error: unknown): boolean {
34
36
  if (!(error instanceof Error)) return false;
35
37
 
36
38
  const message = error.message.toLowerCase();
@@ -86,6 +88,42 @@ function sleep(ms: number): Promise<void> {
86
88
  }
87
89
 
88
90
  export class BatchProcessor {
91
+ private static readonly cb = new CircuitBreaker({
92
+ failureThreshold: 5,
93
+ resetTimeoutMs: 30_000,
94
+ halfOpenMaxCalls: 1,
95
+ });
96
+
97
+ /**
98
+ * Execute a processor call through the circuit breaker.
99
+ * Only transient failures count toward opening the circuit.
100
+ */
101
+ private static async executeWithCircuitBreaker<T>(processor: () => Promise<T>): Promise<T> {
102
+ if (!this.cb.canExecute()) {
103
+ const retryAfterSec = Math.ceil(
104
+ Math.max(0, 30_000 - (Date.now() - (this.cb as any).lastFailureAt || 0)) / 1000
105
+ );
106
+ throw new Error(
107
+ `[BatchProcessor] Circuit breaker is open. Try again in ${retryAfterSec}s.`
108
+ );
109
+ }
110
+
111
+ if ((this.cb as any).state === 'half-open') {
112
+ (this.cb as any).halfOpenCalls++;
113
+ }
114
+
115
+ try {
116
+ const result = await processor();
117
+ this.cb.record(true);
118
+ return result;
119
+ } catch (err) {
120
+ if (isTransientError(err)) {
121
+ this.cb.record(false);
122
+ }
123
+ throw err;
124
+ }
125
+ }
126
+
89
127
  /**
90
128
  * Processes an array of items in configurable batches with retry logic.
91
129
  *
@@ -137,7 +175,7 @@ export class BatchProcessor {
137
175
 
138
176
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
139
177
  try {
140
- const result = await processor(batch);
178
+ const result = await BatchProcessor.executeWithCircuitBreaker(() => processor(batch));
141
179
  results.push(result);
142
180
  success = true;
143
181
  break;
@@ -212,7 +250,7 @@ export class BatchProcessor {
212
250
 
213
251
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
214
252
  try {
215
- const result = await processor(item);
253
+ const result = await BatchProcessor.executeWithCircuitBreaker(() => processor(item));
216
254
  results.push(result);
217
255
  success = true;
218
256
  break;
@@ -311,7 +349,7 @@ export class BatchProcessor {
311
349
 
312
350
  const chunkPromises = chunk.map(async ({ item, originalIndex }) => {
313
351
  try {
314
- const result = await processor(item);
352
+ const result = await BatchProcessor.executeWithCircuitBreaker(() => processor(item));
315
353
  return { success: true, result, index: originalIndex };
316
354
  } catch (err) {
317
355
  const error = err instanceof Error ? err : new Error(String(err));
@@ -0,0 +1,118 @@
1
+ type CircuitState = 'closed' | 'open' | 'half-open';
2
+
3
+ export interface CircuitBreakerOptions {
4
+ failureThreshold?: number;
5
+ resetTimeoutMs?: number;
6
+ halfOpenMaxCalls?: number;
7
+ }
8
+
9
+ export class CircuitBreaker {
10
+ private state: CircuitState = 'closed';
11
+ private failureCount = 0;
12
+ private successCount = 0;
13
+ private lastFailureAt = 0;
14
+ private halfOpenCalls = 0;
15
+
16
+ private readonly failureThreshold: number;
17
+ private readonly resetTimeoutMs: number;
18
+ private readonly halfOpenMaxCalls: number;
19
+
20
+ constructor(options: CircuitBreakerOptions = {}) {
21
+ this.failureThreshold = options.failureThreshold ?? 5;
22
+ this.resetTimeoutMs = options.resetTimeoutMs ?? 30_000;
23
+ this.halfOpenMaxCalls = options.halfOpenMaxCalls ?? 1;
24
+ }
25
+
26
+ record(success: boolean): void {
27
+ const now = Date.now();
28
+
29
+ if (success) {
30
+ this.successCount++;
31
+ if (this.state === 'half-open') {
32
+ this.state = 'closed';
33
+ this.failureCount = 0;
34
+ this.halfOpenCalls = 0;
35
+ this.successCount = 1;
36
+ } else if (this.state === 'closed') {
37
+ if (this.successCount >= this.failureThreshold) {
38
+ this.failureCount = Math.max(0, this.failureCount - 1);
39
+ this.successCount = 0;
40
+ }
41
+ }
42
+ } else {
43
+ this.failureCount++;
44
+ this.lastFailureAt = now;
45
+ this.successCount = 0;
46
+ if (this.state === 'half-open') {
47
+ this.state = 'open';
48
+ this.halfOpenCalls = 0;
49
+ } else if (this.state === 'closed' && this.failureCount >= this.failureThreshold) {
50
+ this.state = 'open';
51
+ }
52
+ }
53
+ }
54
+
55
+ canExecute(): boolean {
56
+ const now = Date.now();
57
+
58
+ if (this.state === 'closed') {
59
+ return true;
60
+ }
61
+
62
+ if (this.state === 'open') {
63
+ if (now - this.lastFailureAt >= this.resetTimeoutMs) {
64
+ this.state = 'half-open';
65
+ this.halfOpenCalls = 0;
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ if (this.state === 'half-open') {
72
+ return this.halfOpenCalls < this.halfOpenMaxCalls;
73
+ }
74
+
75
+ return true;
76
+ }
77
+
78
+ async wrap<T>(fn: () => Promise<T>): Promise<T> {
79
+ if (!this.canExecute()) {
80
+ const retryAfterSec = Math.ceil(
81
+ Math.max(0, this.resetTimeoutMs - (Date.now() - this.lastFailureAt)) / 1000
82
+ );
83
+ throw new Error(
84
+ `Circuit breaker is open. Try again in ${retryAfterSec}s. ` +
85
+ `Failed ${this.failureCount}/${this.failureThreshold} consecutive calls.`
86
+ );
87
+ }
88
+
89
+ if (this.state === 'half-open') {
90
+ this.halfOpenCalls++;
91
+ }
92
+
93
+ try {
94
+ const result = await fn();
95
+ this.record(true);
96
+ return result;
97
+ } catch (err) {
98
+ this.record(false);
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ getState(): CircuitState {
104
+ return this.state;
105
+ }
106
+
107
+ getFailureCount(): number {
108
+ return this.failureCount;
109
+ }
110
+
111
+ reset(): void {
112
+ this.state = 'closed';
113
+ this.failureCount = 0;
114
+ this.successCount = 0;
115
+ this.lastFailureAt = 0;
116
+ this.halfOpenCalls = 0;
117
+ }
118
+ }