@retrivora-ai/rag-engine 2.3.0 → 2.3.2

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.
Files changed (41) hide show
  1. package/dist/{LicenseValidator-CENvo9o2.d.mts → BatchProcessor-7yV-UCHW.d.mts} +142 -3
  2. package/dist/{LicenseValidator-CsjJp2PP.d.ts → BatchProcessor-BfzuU4cK.d.ts} +142 -3
  3. package/dist/handlers/index.d.mts +1 -1
  4. package/dist/handlers/index.d.ts +1 -1
  5. package/dist/handlers/index.js +881 -331
  6. package/dist/handlers/index.mjs +881 -331
  7. package/dist/index-DR_O_B-W.d.ts +394 -0
  8. package/dist/index-fnpaCuma.d.mts +394 -0
  9. package/dist/index.css +58 -0
  10. package/dist/index.d.mts +63 -3
  11. package/dist/index.d.ts +63 -3
  12. package/dist/index.js +454 -36
  13. package/dist/index.mjs +453 -38
  14. package/dist/server.d.mts +35 -73
  15. package/dist/server.d.ts +35 -73
  16. package/dist/server.js +911 -345
  17. package/dist/server.mjs +911 -345
  18. package/package.json +3 -2
  19. package/src/components/ChatWidget.tsx +149 -48
  20. package/src/components/ChatWindow.tsx +52 -9
  21. package/src/components/CodeViewer.tsx +1 -1
  22. package/src/core/BatchProcessor.ts +42 -4
  23. package/src/core/CircuitBreaker.ts +118 -0
  24. package/src/core/DatabaseStorage.ts +55 -24
  25. package/src/core/FreeTierLimitsGuard.ts +281 -0
  26. package/src/core/LangChainAgent.ts +0 -2
  27. package/src/core/LicenseValidator.ts +66 -2
  28. package/src/core/LicenseVerifier.ts +31 -3
  29. package/src/core/VectorPlugin.ts +30 -0
  30. package/src/handlers/index.ts +491 -36
  31. package/src/index.css +58 -0
  32. package/src/index.ts +4 -0
  33. package/src/providers/vectordb/MilvusProvider.ts +0 -1
  34. package/src/providers/vectordb/MongoDBProvider.ts +0 -1
  35. package/src/providers/vectordb/MultiTablePostgresProvider.ts +2 -2
  36. package/src/providers/vectordb/RedisProvider.ts +0 -1
  37. package/src/providers/vectordb/WeaviateProvider.ts +0 -1
  38. package/src/rag/LlamaIndexIngestor.ts +0 -1
  39. package/src/server.ts +1 -0
  40. package/dist/index-BPJ3KDYI.d.ts +0 -195
  41. package/dist/index-Dmq5lH0j.d.mts +0 -195
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrivora-ai/rag-engine",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
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",
@@ -79,6 +79,7 @@
79
79
  "build": "npm run build:pkg",
80
80
  "build:pkg": "npx @tailwindcss/cli -i src/tailwind.css -o src/index.css && tsup src/index.ts src/handlers/index.ts src/server.ts --format cjs,esm --dts --clean --no-splitting --tsconfig tsconfig.build.json --external react,react-dom,next,mongodb,pg,openai,@anthropic-ai/sdk,@pinecone-database/pinecone,axios,lucide-react,mammoth,pdf-parse,xlsx,react-markdown,remark-gfm,next-themes,langchain,@langchain/core,@langchain/openai,llamaindex --inject-style && npx @tailwindcss/cli -i src/tailwind.css -o dist/index.css",
81
81
  "lint": "eslint",
82
+ "typecheck": "tsc --noEmit -p tsconfig.build.json",
82
83
  "clean": "rm -rf dist"
83
84
  },
84
85
  "peerDependencies": {
@@ -135,4 +136,4 @@
135
136
  "tsup": "^8.5.1",
136
137
  "typescript": "^5"
137
138
  }
138
- }
139
+ }
@@ -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,8 +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);
59
-
60
- if (ui.showWidget === false) return null;
80
+ const [hasMountValidationFailed, setHasMountValidationFailed] = useState(false);
61
81
 
62
82
  const positionClass =
63
83
  position === 'bottom-left'
@@ -69,26 +89,75 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
69
89
  ? 'bottom-20 left-6'
70
90
  : 'bottom-20 right-6';
71
91
 
72
- const handleOpen = async () => {
92
+ /**
93
+ * Resolves the active license key from all possible sources
94
+ * (explicit prop → headers → environment variables).
95
+ */
96
+ const resolveLicenseKey = useCallback((): string => {
97
+ const getHeader = (name: string) => {
98
+ if (!headers) return undefined;
99
+ const target = name.toLowerCase();
100
+ for (const [k, v] of Object.entries(headers)) {
101
+ if (k.toLowerCase() === target) return v;
102
+ }
103
+ return undefined;
104
+ };
105
+
106
+ return (
107
+ licenseKeyProp ||
108
+ getHeader('x-license-key') ||
109
+ getHeader('authorization')?.replace(/^Bearer\s+/i, '') ||
110
+ (typeof process !== 'undefined'
111
+ ? (process.env.NEXT_PUBLIC_RETRIVORA_LICENSE_KEY || process.env.RETRIVORA_LICENSE_KEY)
112
+ : '') ||
113
+ ''
114
+ );
115
+ }, [licenseKeyProp, headers]);
116
+
117
+ /**
118
+ * Proactively validate the license on mount.
119
+ * If validation fails, mark the widget locked so the FAB button appears
120
+ * disabled and we don't waste a network call on every subsequent click.
121
+ */
122
+ useEffect(() => {
123
+ let isMounted = true;
124
+ async function validateOnMount() {
125
+ try {
126
+ const resolvedLicenseKey = resolveLicenseKey();
127
+ await LicenseValidator.getInstance().validate({
128
+ licenseKey: resolvedLicenseKey,
129
+ projectId,
130
+ retrivoraApiBase,
131
+ headers,
132
+ });
133
+ if (isMounted) {
134
+ setHasMountValidationFailed(false);
135
+ setWidgetError(null);
136
+ }
137
+ } catch (err: any) {
138
+ if (isMounted) {
139
+ setHasMountValidationFailed(true);
140
+ setWidgetError(err?.message || 'Access denied.');
141
+ }
142
+ }
143
+ }
144
+ validateOnMount();
145
+ return () => { isMounted = false; };
146
+ }, [resolveLicenseKey, projectId, retrivoraApiBase, headers]);
147
+
148
+ /**
149
+ * Validates the license key and opens the chat window.
150
+ * If the license was already marked invalid on mount we immediately
151
+ * re-display the error toast without re-validating.
152
+ */
153
+ const handleOpen = useCallback(async () => {
154
+ if (hasMountValidationFailed && widgetError) {
155
+ setWidgetError(widgetError);
156
+ return;
157
+ }
73
158
  setIsValidating(true);
74
159
  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
- '';
160
+ const resolvedLicenseKey = resolveLicenseKey();
92
161
 
93
162
  await LicenseValidator.getInstance().validate({
94
163
  licenseKey: resolvedLicenseKey,
@@ -98,15 +167,20 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
98
167
  });
99
168
 
100
169
  setWidgetError(null);
170
+ setHasMountValidationFailed(false);
101
171
  setIsOpen(true);
102
172
  setHasUnread(false);
103
173
  } catch (err: any) {
174
+ setHasMountValidationFailed(true);
104
175
  setWidgetError(err?.message || 'Access denied.');
105
- setIsOpen(false); // DO NOT OPEN CHAT WIDGET
176
+ setIsOpen(false);
106
177
  } finally {
107
178
  setIsValidating(false);
108
179
  }
109
- };
180
+ }, [hasMountValidationFailed, widgetError, resolveLicenseKey, projectId, retrivoraApiBase, headers]);
181
+
182
+ /** True when the license has been proven invalid and the widget must be locked. */
183
+ const isWidgetLocked = hasMountValidationFailed || isValidating;
110
184
 
111
185
  const handleResizeStart = (e: React.MouseEvent) => {
112
186
  e.preventDefault();
@@ -157,6 +231,8 @@ export function ChatWidget({ position = 'bottom-right', onAddToCart, apiUrl, ret
157
231
 
158
232
  const isResized = dimensions.width !== DEFAULT_DIMENSIONS.width || dimensions.height !== DEFAULT_DIMENSIONS.height;
159
233
 
234
+ if (ui.showWidget === false) return null;
235
+
160
236
  return (
161
237
  <>
162
238
  <style dangerouslySetInnerHTML={{ __html: GEMINI_STYLES }} />
@@ -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;
@@ -199,7 +240,6 @@ export function ChatWindow({
199
240
  };
200
241
 
201
242
  useEffect(() => {
202
- // eslint-disable-next-line react-hooks/set-state-in-effect
203
243
  setMounted(true);
204
244
  }, []);
205
245
 
@@ -361,7 +401,8 @@ export function ChatWindow({
361
401
  <button
362
402
  onClick={clearHistory}
363
403
  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"
404
+ disabled={isDisabled}
405
+ 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
406
  >
366
407
  <Trash2 className="w-3.5 h-3.5" />
367
408
  </button>
@@ -524,18 +565,20 @@ export function ChatWindow({
524
565
  {/* ── Input ── */}
525
566
  <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
567
 
527
- {/* Dynamic Suggestions */}
568
+ {/* Dynamic Suggestions — hidden/blocked when license/version error */}
528
569
  {(suggestions.length > 0 || isSuggesting) && !isLoading && (
529
570
  <div className="mb-3 flex flex-wrap gap-2 animate-in fade-in slide-in-from-bottom-2 duration-300">
530
571
  {suggestions.map((suggestion) => (
531
572
  <button
532
573
  key={suggestion}
533
574
  onClick={() => {
575
+ if (isDisabled) return;
534
576
  setInput(suggestion);
535
577
  setSuggestions([]);
536
578
  inputRef.current?.focus();
537
579
  }}
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"
580
+ disabled={isDisabled}
581
+ 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
582
  >
540
583
  <Sparkles className="w-3 h-3 text-amber-400" />
541
584
  {suggestion}
@@ -564,7 +607,7 @@ export function ChatWindow({
564
607
  onKeyDown={handleKeyDown}
565
608
  placeholder={ui.placeholder}
566
609
  rows={1}
567
- disabled={isLoading || !!licenseError || !!versionError}
610
+ disabled={isLoading || isDisabled}
568
611
  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
612
  style={{ scrollbarWidth: 'none' }}
570
613
  />
@@ -573,7 +616,7 @@ export function ChatWindow({
573
616
  <button
574
617
  type="button"
575
618
  onClick={toggleListening}
576
- disabled={isLoading || !!licenseError || !!versionError}
619
+ disabled={isLoading || isDisabled}
577
620
  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
621
  ? 'bg-rose-500 text-white animate-pulse shadow-md shadow-rose-500/20'
579
622
  : 'text-slate-400 dark:text-white/40 hover:bg-slate-200 dark:hover:bg-white/10'
@@ -587,8 +630,8 @@ export function ChatWindow({
587
630
  {ui.allowUpload !== false && (
588
631
  <button
589
632
  type="button"
590
- onClick={() => setIsUploadModalOpen(true)}
591
- disabled={isLoading || !!licenseError || !!versionError}
633
+ onClick={() => { if (!isDisabled) setIsUploadModalOpen(true); }}
634
+ disabled={isLoading || isDisabled}
592
635
  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
636
  title="Upload Document"
594
637
  >
@@ -607,7 +650,7 @@ export function ChatWindow({
607
650
  ) : (
608
651
  <button
609
652
  onClick={sendMessage}
610
- disabled={!input.trim() || !!licenseError || !!versionError}
653
+ disabled={!input.trim() || isDisabled}
611
654
  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
655
  style={{ background: `linear-gradient(135deg, ${ui.primaryColor}, ${ui.accentColor})` }}
613
656
  title="Send message"
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { Check, Copy } from 'lucide-react';
5
- import { Snippet } from '@/app/types';
5
+ import type { Snippet } from '../app/types';
6
6
 
7
7
  export function CodeViewer({ snippet }: { snippet: Snippet }) {
8
8
  const [copied, setCopied] = React.useState(false);
@@ -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
+ }