@retrivora-ai/rag-engine 2.3.0 → 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/dist/{LicenseValidator-CENvo9o2.d.mts → BatchProcessor-7yV-UCHW.d.mts} +142 -3
- package/dist/{LicenseValidator-CsjJp2PP.d.ts → BatchProcessor-BfzuU4cK.d.ts} +142 -3
- package/dist/handlers/index.d.mts +1 -1
- package/dist/handlers/index.d.ts +1 -1
- package/dist/handlers/index.js +880 -327
- package/dist/handlers/index.mjs +880 -327
- package/dist/index-DR_O_B-W.d.ts +394 -0
- package/dist/index-fnpaCuma.d.mts +394 -0
- package/dist/index.css +58 -0
- package/dist/index.d.mts +63 -3
- package/dist/index.d.ts +63 -3
- package/dist/index.js +452 -35
- package/dist/index.mjs +451 -37
- package/dist/server.d.mts +35 -73
- package/dist/server.d.ts +35 -73
- package/dist/server.js +910 -341
- package/dist/server.mjs +910 -341
- package/package.json +1 -1
- package/src/components/ChatWidget.tsx +147 -46
- package/src/components/ChatWindow.tsx +52 -8
- package/src/core/BatchProcessor.ts +42 -4
- package/src/core/CircuitBreaker.ts +118 -0
- package/src/core/DatabaseStorage.ts +55 -24
- package/src/core/FreeTierLimitsGuard.ts +281 -0
- package/src/core/LicenseValidator.ts +66 -2
- package/src/core/LicenseVerifier.ts +31 -3
- package/src/core/VectorPlugin.ts +30 -0
- package/src/handlers/index.ts +491 -36
- package/src/index.css +58 -0
- package/src/index.ts +4 -0
- package/src/server.ts +1 -0
- package/dist/index-BPJ3KDYI.d.ts +0 -195
- 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.
|
|
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
|
-
|
|
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
|
|
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);
|
|
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
|
-
|
|
228
|
-
aria-label=
|
|
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=
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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() ||
|
|
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
|
+
}
|