@astermind/cybernetic-chatbot-client 2.2.36 → 2.2.48

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.
@@ -7,13 +7,14 @@
7
7
  // src/ApiClient.ts
8
8
  // HTTP client for AsterMind backend API
9
9
  /**
10
- * Map backend source format to client Source interface
10
+ * Map backend source format to client Source interface.
11
+ * Handles both canonical (title/score) and legacy (heading/no-score) field names.
11
12
  */
12
13
  function mapSource(raw) {
13
14
  return {
14
- title: raw.title,
15
- snippet: raw.content,
16
- relevance: raw.score,
15
+ title: raw.title || raw.heading || 'Document',
16
+ snippet: raw.content || '',
17
+ relevance: raw.score ?? 0,
17
18
  documentId: raw.documentId,
18
19
  fullContent: raw.fullContent,
19
20
  downloadUrl: raw.downloadUrl,
@@ -51,9 +52,10 @@
51
52
  throw new Error(error.message || `HTTP ${response.status}: ${response.statusText}`);
52
53
  }
53
54
  const data = await response.json();
54
- // Map backend source format to client Source interface
55
+ // Normalize response: accept both 'reply' (canonical) and 'answer' (legacy)
55
56
  return {
56
57
  ...data,
58
+ reply: data.reply || data.answer || '',
57
59
  sources: (data.sources || []).map(mapSource)
58
60
  };
59
61
  }
@@ -129,7 +131,8 @@
129
131
  }
130
132
  }
131
133
  /**
132
- * Get general documents for caching
134
+ * Get general documents for caching.
135
+ * Normalizes field names from both canonical (title) and legacy (name) formats.
133
136
  */
134
137
  async getGeneralDocs(since) {
135
138
  const params = new URLSearchParams();
@@ -146,10 +149,16 @@
146
149
  throw new Error(`HTTP ${response.status}`);
147
150
  }
148
151
  const data = await response.json();
149
- return data.documents;
152
+ // Normalize: accept both 'title' (canonical) and 'name' (legacy on-prem)
153
+ return (data.documents || []).map((doc) => ({
154
+ id: doc.id,
155
+ title: doc.title || doc.name || 'Untitled',
156
+ updatedAt: doc.updatedAt || doc.updated_at || new Date().toISOString(),
157
+ }));
150
158
  }
151
159
  /**
152
- * Get API status, quota, and system settings
160
+ * Get API status, quota, and system settings.
161
+ * Normalizes response from both canonical and legacy backend formats.
153
162
  */
154
163
  async getStatus() {
155
164
  const response = await fetch(`${this.baseUrl}/api/external/status`, {
@@ -160,7 +169,38 @@
160
169
  if (!response.ok) {
161
170
  throw new Error(`HTTP ${response.status}`);
162
171
  }
163
- return response.json();
172
+ const data = await response.json();
173
+ // Normalize: accept both canonical (apiKey) and legacy (key) structures
174
+ const apiKeyData = data.apiKey || data.key || {};
175
+ const scopes = apiKeyData.scopes || apiKeyData.permissions || ['chat'];
176
+ // Normalize quota: accept both canonical (perMinute/perDay) and legacy structures
177
+ const quota = data.quota || {};
178
+ const perMinute = quota.perMinute || {
179
+ limit: apiKeyData.rateLimit?.limit || quota.rateLimitPerMinute || 30,
180
+ remaining: apiKeyData.rateLimit?.remaining ?? quota.rateLimitPerMinute ?? 30,
181
+ };
182
+ const perDay = quota.perDay || {
183
+ limit: quota.rateLimitPerDay || perMinute.limit * 60 * 24,
184
+ remaining: quota.rateLimitPerDay || perMinute.limit * 60 * 24,
185
+ };
186
+ // Normalize systemSettings: accept both canonical and legacy (system) structures
187
+ const settings = data.systemSettings || data.system || {};
188
+ const cacheRetentionHours = settings.cacheRetentionHours ??
189
+ (settings.cacheRetentionDays ? settings.cacheRetentionDays * 24 : undefined);
190
+ return {
191
+ status: data.status || 'active',
192
+ apiKey: {
193
+ id: apiKeyData.id || '',
194
+ scopes,
195
+ },
196
+ quota: { perMinute, perDay },
197
+ systemSettings: {
198
+ cacheRetentionHours: cacheRetentionHours ?? 720,
199
+ maintenanceMode: settings.maintenanceMode ?? false,
200
+ maintenanceMessage: settings.maintenanceMessage,
201
+ forceOfflineClients: settings.forceOfflineClients ?? false,
202
+ },
203
+ };
164
204
  }
165
205
  /**
166
206
  * Health check (no auth required)
@@ -1016,6 +1056,595 @@
1016
1056
  }
1017
1057
  }
1018
1058
 
1059
+ // src/WebSocketTransport.ts
1060
+ // WebSocket transport for streaming chat via SaaS WebSocket API (chatws.astermind.ai)
1061
+ /**
1062
+ * WebSocket transport for streaming chat.
1063
+ *
1064
+ * Maps the chat-handler Lambda WebSocket protocol to the client's StreamCallbacks:
1065
+ * Server typing → ignored (UI has its own indicator)
1066
+ * Server chunk → onToken(content)
1067
+ * Server done → onSources(sources) + onComplete(response)
1068
+ * Server error → onError(error)
1069
+ *
1070
+ * Connection is lazy — established on the first chatStream() call.
1071
+ * Reuses the same connection across multiple messages.
1072
+ * Reconnects with exponential backoff on close/error.
1073
+ */
1074
+ class WebSocketTransport {
1075
+ constructor(wsUrl, apiKey, options) {
1076
+ this.ws = null;
1077
+ this.state = 'disconnected';
1078
+ this.reconnectAttempts = 0;
1079
+ this.connectionPromise = null;
1080
+ // Active streaming request tracking
1081
+ this.activeCallbacks = null;
1082
+ this.activeFullText = '';
1083
+ // Cleanup handler reference
1084
+ this.beforeUnloadHandler = null;
1085
+ // Promise callbacks for the active stream
1086
+ this._resolveStream = null;
1087
+ this._rejectStream = null;
1088
+ this.wsUrl = wsUrl;
1089
+ this.apiKey = apiKey;
1090
+ this.maxReconnectAttempts = options?.maxReconnectAttempts ?? 3;
1091
+ this.reconnectDelay = options?.reconnectDelay ?? 1000;
1092
+ this.connectionTimeout = options?.connectionTimeout ?? 10000;
1093
+ }
1094
+ /**
1095
+ * Current transport state
1096
+ */
1097
+ getState() {
1098
+ return this.state;
1099
+ }
1100
+ /**
1101
+ * Whether the WebSocket is currently connected
1102
+ */
1103
+ isConnected() {
1104
+ return this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN;
1105
+ }
1106
+ /**
1107
+ * Connect to the WebSocket endpoint.
1108
+ * Appends the API key as a query parameter for authentication.
1109
+ * Returns a promise that resolves when the connection is open.
1110
+ */
1111
+ connect() {
1112
+ // Already connected
1113
+ if (this.isConnected()) {
1114
+ return Promise.resolve();
1115
+ }
1116
+ // Connection already in progress
1117
+ if (this.connectionPromise) {
1118
+ return this.connectionPromise;
1119
+ }
1120
+ this.state = 'connecting';
1121
+ this.connectionPromise = new Promise((resolve, reject) => {
1122
+ try {
1123
+ // Build URL with API key
1124
+ const separator = this.wsUrl.includes('?') ? '&' : '?';
1125
+ const fullUrl = `${this.wsUrl}${separator}apiKey=${encodeURIComponent(this.apiKey)}`;
1126
+ const ws = new WebSocket(fullUrl);
1127
+ this.ws = ws;
1128
+ // Connection timeout
1129
+ const timeout = setTimeout(() => {
1130
+ if (ws.readyState !== WebSocket.OPEN) {
1131
+ ws.close();
1132
+ this.state = 'error';
1133
+ this.connectionPromise = null;
1134
+ reject(new Error('WebSocket connection timed out'));
1135
+ }
1136
+ }, this.connectionTimeout);
1137
+ ws.onopen = () => {
1138
+ clearTimeout(timeout);
1139
+ this.state = 'connected';
1140
+ this.reconnectAttempts = 0;
1141
+ this.connectionPromise = null;
1142
+ this.registerCleanup();
1143
+ resolve();
1144
+ };
1145
+ ws.onmessage = (event) => {
1146
+ this.handleMessage(event);
1147
+ };
1148
+ ws.onerror = () => {
1149
+ clearTimeout(timeout);
1150
+ this.state = 'error';
1151
+ this.connectionPromise = null;
1152
+ // The close event fires after error, which will trigger reconnect
1153
+ };
1154
+ ws.onclose = (event) => {
1155
+ clearTimeout(timeout);
1156
+ const wasConnected = this.state === 'connected';
1157
+ this.state = 'disconnected';
1158
+ this.connectionPromise = null;
1159
+ this.unregisterCleanup();
1160
+ // If we had an active request, report the error
1161
+ if (this.activeCallbacks) {
1162
+ this.activeCallbacks.onError?.({
1163
+ code: 'WS_ERROR',
1164
+ message: event.reason || `WebSocket closed (code: ${event.code})`
1165
+ });
1166
+ this.clearActiveRequest();
1167
+ }
1168
+ // Reject if we were trying to connect
1169
+ if (!wasConnected) {
1170
+ reject(new Error(event.reason || `WebSocket closed (code: ${event.code})`));
1171
+ }
1172
+ };
1173
+ }
1174
+ catch (error) {
1175
+ this.state = 'error';
1176
+ this.connectionPromise = null;
1177
+ reject(error);
1178
+ }
1179
+ });
1180
+ return this.connectionPromise;
1181
+ }
1182
+ /**
1183
+ * Stream a chat message over WebSocket.
1184
+ *
1185
+ * Sends: { type: "sendMessage", message, sessionId?, wordLimit?, context? }
1186
+ * Receives: typing → chunk × N → done (with sources and metadata)
1187
+ */
1188
+ async chatStream(message, options) {
1189
+ const { sessionId, context, wordLimit, onToken, onSources, onComplete, onError } = options;
1190
+ // Ensure connection is established (lazy connect)
1191
+ try {
1192
+ await this.connect();
1193
+ }
1194
+ catch (error) {
1195
+ onError?.({
1196
+ code: 'WS_ERROR',
1197
+ message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`
1198
+ });
1199
+ throw error;
1200
+ }
1201
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1202
+ const err = { code: 'WS_ERROR', message: 'WebSocket not connected' };
1203
+ onError?.(err);
1204
+ throw new Error(err.message);
1205
+ }
1206
+ // Set up active request tracking
1207
+ this.activeCallbacks = { onToken, onSources, onComplete, onError };
1208
+ this.activeFullText = '';
1209
+ this.activeSessionId = sessionId;
1210
+ // Build and send the message
1211
+ const payload = {
1212
+ type: 'sendMessage',
1213
+ message,
1214
+ };
1215
+ if (sessionId)
1216
+ payload.sessionId = sessionId;
1217
+ if (wordLimit)
1218
+ payload.wordLimit = wordLimit;
1219
+ if (context)
1220
+ payload.context = context;
1221
+ // Return a promise that resolves when done/error is received
1222
+ return new Promise((resolve, reject) => {
1223
+ // Store resolve/reject so handleMessage can call them
1224
+ this._resolveStream = resolve;
1225
+ this._rejectStream = reject;
1226
+ this.ws.send(JSON.stringify(payload));
1227
+ });
1228
+ }
1229
+ /**
1230
+ * Disconnect and clean up the WebSocket connection
1231
+ */
1232
+ disconnect() {
1233
+ this.unregisterCleanup();
1234
+ if (this.ws) {
1235
+ // Remove handlers to avoid triggering reconnect
1236
+ this.ws.onclose = null;
1237
+ this.ws.onerror = null;
1238
+ this.ws.onmessage = null;
1239
+ this.ws.close();
1240
+ this.ws = null;
1241
+ }
1242
+ this.state = 'disconnected';
1243
+ this.connectionPromise = null;
1244
+ this.clearActiveRequest();
1245
+ }
1246
+ /**
1247
+ * Attempt reconnection with exponential backoff.
1248
+ * Used internally after connection loss.
1249
+ */
1250
+ async reconnect() {
1251
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
1252
+ this.state = 'error';
1253
+ throw new Error(`Max reconnection attempts (${this.maxReconnectAttempts}) exceeded`);
1254
+ }
1255
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
1256
+ this.reconnectAttempts++;
1257
+ console.log(`[Cybernetic WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
1258
+ await new Promise(resolve => setTimeout(resolve, delay));
1259
+ // Ensure old connection is cleaned up
1260
+ if (this.ws) {
1261
+ this.ws.onclose = null;
1262
+ this.ws.onerror = null;
1263
+ this.ws.close();
1264
+ this.ws = null;
1265
+ }
1266
+ return this.connect();
1267
+ }
1268
+ // ==================== INTERNAL ====================
1269
+ /**
1270
+ * Handle incoming WebSocket messages from the chat-handler Lambda.
1271
+ *
1272
+ * Protocol:
1273
+ * { type: 'typing', status: boolean } → ignored
1274
+ * { type: 'chunk', content: string } → onToken(content)
1275
+ * { type: 'done', sessionId, sources, metadata, messageId } → onSources + onComplete
1276
+ * { type: 'error', error: string } → onError
1277
+ */
1278
+ handleMessage(event) {
1279
+ if (!this.activeCallbacks)
1280
+ return;
1281
+ try {
1282
+ const data = JSON.parse(event.data);
1283
+ switch (data.type) {
1284
+ case 'typing':
1285
+ // Ignored — the chatbot-template manages its own typing indicator
1286
+ break;
1287
+ case 'chunk':
1288
+ if (data.content) {
1289
+ this.activeFullText += data.content;
1290
+ this.activeCallbacks.onToken?.(data.content);
1291
+ }
1292
+ break;
1293
+ case 'done': {
1294
+ // Map sources from chat-handler format to client Source format
1295
+ const sources = this.mapSources(data.sources || []);
1296
+ // Emit sources
1297
+ this.activeCallbacks.onSources?.(sources);
1298
+ // Build complete response
1299
+ const response = {
1300
+ reply: this.activeFullText,
1301
+ confidence: 'high',
1302
+ sources,
1303
+ offline: false,
1304
+ sessionId: data.sessionId || this.activeSessionId,
1305
+ };
1306
+ this.activeCallbacks.onComplete?.(response);
1307
+ this._resolveStream?.();
1308
+ this.clearActiveRequest();
1309
+ break;
1310
+ }
1311
+ case 'error': {
1312
+ const err = {
1313
+ code: 'WS_ERROR',
1314
+ message: data.error || 'Unknown WebSocket error'
1315
+ };
1316
+ this.activeCallbacks.onError?.(err);
1317
+ this._rejectStream?.(new Error(err.message));
1318
+ this.clearActiveRequest();
1319
+ break;
1320
+ }
1321
+ default:
1322
+ // Unknown message type — log and ignore
1323
+ console.warn('[Cybernetic WS] Unknown message type:', data.type);
1324
+ break;
1325
+ }
1326
+ }
1327
+ catch (error) {
1328
+ console.error('[Cybernetic WS] Failed to parse message:', error, event.data);
1329
+ }
1330
+ }
1331
+ /**
1332
+ * Map sources from the chat-handler WebSocket format to the client Source interface.
1333
+ *
1334
+ * Chat-handler sends: { heading, content, score, documentId, documentName }
1335
+ * Client expects: { title, snippet, relevance, documentId, documentName }
1336
+ */
1337
+ mapSources(wsSources) {
1338
+ if (!Array.isArray(wsSources))
1339
+ return [];
1340
+ return wsSources.map((s) => ({
1341
+ title: s.title || s.heading || s.documentName || 'Document',
1342
+ snippet: s.content || '',
1343
+ relevance: s.score ?? 0,
1344
+ documentId: s.documentId,
1345
+ documentName: s.documentName,
1346
+ sourceType: s.sourceType || 'document',
1347
+ chunkId: s.chunkId,
1348
+ }));
1349
+ }
1350
+ /**
1351
+ * Clear active request state after completion or error
1352
+ */
1353
+ clearActiveRequest() {
1354
+ this.activeCallbacks = null;
1355
+ this.activeFullText = '';
1356
+ this._resolveStream = null;
1357
+ this._rejectStream = null;
1358
+ }
1359
+ /**
1360
+ * Register a beforeunload handler to close the WebSocket on page unload
1361
+ */
1362
+ registerCleanup() {
1363
+ if (typeof window !== 'undefined' && !this.beforeUnloadHandler) {
1364
+ this.beforeUnloadHandler = () => this.disconnect();
1365
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
1366
+ }
1367
+ }
1368
+ /**
1369
+ * Remove the beforeunload handler
1370
+ */
1371
+ unregisterCleanup() {
1372
+ if (typeof window !== 'undefined' && this.beforeUnloadHandler) {
1373
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
1374
+ this.beforeUnloadHandler = null;
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ // src/config.ts
1380
+ // Configuration loading and validation
1381
+ /** Default API URL when not specified */
1382
+ const DEFAULT_API_URL = 'https://api.astermind.ai';
1383
+ /**
1384
+ * Derive WebSocket URL from API URL for known SaaS domains.
1385
+ * Maps REST API domains to their WebSocket counterparts:
1386
+ * chatapi.astermind.ai → wss://chatws.astermind.ai
1387
+ * chatapi-dev.astermind.ai → wss://chatws-dev.astermind.ai
1388
+ * api.astermind.ai → wss://chatws.astermind.ai
1389
+ *
1390
+ * Returns undefined for non-SaaS (on-prem) URLs.
1391
+ */
1392
+ function deriveWsUrl(apiUrl) {
1393
+ try {
1394
+ const url = new URL(apiUrl);
1395
+ // chatapi[-env].astermind.ai → chatws[-env].astermind.ai
1396
+ const match = url.hostname.match(/^chatapi(-[\w]+)?\.astermind\.ai$/);
1397
+ if (match) {
1398
+ const envSuffix = match[1] || '';
1399
+ return `wss://chatws${envSuffix}.astermind.ai`;
1400
+ }
1401
+ // api.astermind.ai → chatws.astermind.ai (default SaaS)
1402
+ if (url.hostname === 'api.astermind.ai') {
1403
+ return 'wss://chatws.astermind.ai';
1404
+ }
1405
+ }
1406
+ catch {
1407
+ // Invalid URL, no WS derivation
1408
+ }
1409
+ return undefined;
1410
+ }
1411
+ /**
1412
+ * Try to load wsUrl from environment or globals.
1413
+ * Returns the first found wsUrl or undefined.
1414
+ */
1415
+ function loadWsUrl() {
1416
+ // Vite env var
1417
+ try {
1418
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1419
+ const importMeta = globalThis.import?.meta?.env;
1420
+ if (importMeta?.VITE_ASTERMIND_RAG_WS_URL) {
1421
+ return importMeta.VITE_ASTERMIND_RAG_WS_URL;
1422
+ }
1423
+ }
1424
+ catch { /* not available */ }
1425
+ // Node.js / CRA env var
1426
+ if (typeof process !== 'undefined' && process.env) {
1427
+ if (process.env.ASTERMIND_RAG_WS_URL)
1428
+ return process.env.ASTERMIND_RAG_WS_URL;
1429
+ if (process.env.REACT_APP_ASTERMIND_RAG_WS_URL)
1430
+ return process.env.REACT_APP_ASTERMIND_RAG_WS_URL;
1431
+ }
1432
+ // SSR-injected config
1433
+ if (typeof window !== 'undefined') {
1434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1435
+ const injected = window.__ASTERMIND_CONFIG__;
1436
+ if (injected?.wsUrl)
1437
+ return injected.wsUrl;
1438
+ // Global config object
1439
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1440
+ const globalConfig = window.astermindConfig;
1441
+ if (globalConfig?.wsUrl)
1442
+ return globalConfig.wsUrl;
1443
+ }
1444
+ // Script data attribute
1445
+ if (typeof document !== 'undefined') {
1446
+ const script = document.querySelector('script[data-astermind-ws-url]');
1447
+ if (script) {
1448
+ const wsUrl = script.getAttribute('data-astermind-ws-url');
1449
+ if (wsUrl)
1450
+ return wsUrl;
1451
+ }
1452
+ }
1453
+ return undefined;
1454
+ }
1455
+ /**
1456
+ * Resolve the WebSocket URL for a given config.
1457
+ * Priority: explicit wsUrl → env var → auto-derived from apiUrl
1458
+ */
1459
+ function resolveWsUrl(config) {
1460
+ // Explicit wsUrl takes priority
1461
+ if (config.wsUrl)
1462
+ return config.wsUrl;
1463
+ // Don't auto-detect if transport is forced to REST
1464
+ if (config.transport === 'rest')
1465
+ return undefined;
1466
+ // Try environment / globals
1467
+ const envWsUrl = loadWsUrl();
1468
+ if (envWsUrl)
1469
+ return envWsUrl;
1470
+ // Auto-derive from apiUrl for known SaaS domains
1471
+ return deriveWsUrl(config.apiUrl);
1472
+ }
1473
+ /**
1474
+ * Validate configuration
1475
+ */
1476
+ function validateConfig(config) {
1477
+ if (!config || typeof config !== 'object') {
1478
+ throw new Error('Config must be an object');
1479
+ }
1480
+ const c = config;
1481
+ if (!c.apiUrl || typeof c.apiUrl !== 'string') {
1482
+ throw new Error('apiUrl is required and must be a string');
1483
+ }
1484
+ if (!c.apiKey || typeof c.apiKey !== 'string') {
1485
+ throw new Error('apiKey is required and must be a string');
1486
+ }
1487
+ if (!c.apiKey.startsWith('am_')) {
1488
+ throw new Error('apiKey must start with "am_"');
1489
+ }
1490
+ return true;
1491
+ }
1492
+ /**
1493
+ * Load config from Node.js process.env (for bundlers that replace process.env)
1494
+ */
1495
+ function loadFromProcessEnv() {
1496
+ // Check if process.env exists (Node.js or bundler-injected)
1497
+ if (typeof process === 'undefined' || !process.env) {
1498
+ return null;
1499
+ }
1500
+ // Check for ASTERMIND_RAG_* env vars (non-prefixed, for Node.js/server environments)
1501
+ const apiKey = process.env.ASTERMIND_RAG_API_KEY;
1502
+ const apiUrl = process.env.ASTERMIND_RAG_API_SERVER_URL;
1503
+ if (apiKey) {
1504
+ return {
1505
+ apiKey,
1506
+ apiUrl: apiUrl || DEFAULT_API_URL,
1507
+ _source: 'env'
1508
+ };
1509
+ }
1510
+ // Check for CRA-style REACT_APP_* env vars
1511
+ const craApiKey = process.env.REACT_APP_ASTERMIND_RAG_API_KEY;
1512
+ const craApiUrl = process.env.REACT_APP_ASTERMIND_RAG_API_SERVER_URL;
1513
+ if (craApiKey) {
1514
+ return {
1515
+ apiKey: craApiKey,
1516
+ apiUrl: craApiUrl || DEFAULT_API_URL,
1517
+ _source: 'env'
1518
+ };
1519
+ }
1520
+ return null;
1521
+ }
1522
+ /**
1523
+ * Load config from Vite's import.meta.env (browser environment)
1524
+ * Note: This only works at build time when Vite replaces the variables
1525
+ */
1526
+ function loadFromViteEnv() {
1527
+ // Check for window.__ASTERMIND_CONFIG__ (SSR-injected config)
1528
+ if (typeof window !== 'undefined') {
1529
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1530
+ const injected = window.__ASTERMIND_CONFIG__;
1531
+ if (injected && typeof injected === 'object' && injected.apiKey) {
1532
+ return {
1533
+ ...injected,
1534
+ apiUrl: injected.apiUrl || DEFAULT_API_URL,
1535
+ _source: 'vite'
1536
+ };
1537
+ }
1538
+ }
1539
+ // Check for Vite's import.meta.env (replaced at build time)
1540
+ // Note: TypeScript doesn't know about import.meta.env, so we use a try-catch
1541
+ try {
1542
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1543
+ const importMeta = globalThis.import?.meta?.env;
1544
+ if (importMeta) {
1545
+ const apiKey = importMeta.VITE_ASTERMIND_RAG_API_KEY;
1546
+ const apiUrl = importMeta.VITE_ASTERMIND_RAG_API_SERVER_URL;
1547
+ if (apiKey) {
1548
+ return {
1549
+ apiKey,
1550
+ apiUrl: apiUrl || DEFAULT_API_URL,
1551
+ _source: 'vite'
1552
+ };
1553
+ }
1554
+ }
1555
+ }
1556
+ catch {
1557
+ // import.meta not available in this environment
1558
+ }
1559
+ return null;
1560
+ }
1561
+ /**
1562
+ * Load config from window.astermindConfig global object
1563
+ */
1564
+ function loadFromGlobalObject() {
1565
+ if (typeof window === 'undefined') {
1566
+ return null;
1567
+ }
1568
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1569
+ const globalConfig = window.astermindConfig;
1570
+ if (globalConfig && typeof globalConfig === 'object' && globalConfig.apiKey) {
1571
+ return {
1572
+ ...globalConfig,
1573
+ apiUrl: globalConfig.apiUrl || DEFAULT_API_URL,
1574
+ _source: 'window'
1575
+ };
1576
+ }
1577
+ return null;
1578
+ }
1579
+ /**
1580
+ * Load config from script tag data attributes
1581
+ */
1582
+ function loadFromScriptAttributes() {
1583
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
1584
+ return null;
1585
+ }
1586
+ const script = document.querySelector('script[data-astermind-key]');
1587
+ if (script) {
1588
+ const apiKey = script.getAttribute('data-astermind-key');
1589
+ if (apiKey) {
1590
+ return {
1591
+ apiKey,
1592
+ apiUrl: script.getAttribute('data-astermind-url') || DEFAULT_API_URL,
1593
+ _source: 'data-attr'
1594
+ };
1595
+ }
1596
+ }
1597
+ return null;
1598
+ }
1599
+ /**
1600
+ * Load config using priority-based fallback chain:
1601
+ * 1. Environment variables (process.env - for bundlers/Node.js)
1602
+ * 2. Vite environment variables (import.meta.env or window.__ASTERMIND_CONFIG__)
1603
+ * 3. Global object (window.astermindConfig)
1604
+ * 4. Script data attributes (data-astermind-key, data-astermind-url)
1605
+ *
1606
+ * @param options - Configuration options
1607
+ * @param options.throwOnMissingKey - If true (default), throws when no API key found. If false, returns null and logs a warning.
1608
+ * @returns Configuration object or null if not found and throwOnMissingKey is false
1609
+ */
1610
+ function loadConfig(options = {}) {
1611
+ const { throwOnMissingKey = true } = options;
1612
+ // Priority 1: Environment variables (Node.js/bundler)
1613
+ const envConfig = loadFromProcessEnv();
1614
+ if (envConfig) {
1615
+ return envConfig;
1616
+ }
1617
+ // Priority 2: Vite environment variables / SSR-injected config
1618
+ const viteConfig = loadFromViteEnv();
1619
+ if (viteConfig) {
1620
+ return viteConfig;
1621
+ }
1622
+ // Priority 3: Global object (window.astermindConfig)
1623
+ const globalConfig = loadFromGlobalObject();
1624
+ if (globalConfig) {
1625
+ validateConfig(globalConfig);
1626
+ return globalConfig;
1627
+ }
1628
+ // Priority 4: Script data attributes
1629
+ const scriptConfig = loadFromScriptAttributes();
1630
+ if (scriptConfig) {
1631
+ return scriptConfig;
1632
+ }
1633
+ // No config found
1634
+ if (throwOnMissingKey) {
1635
+ throw new Error('AsterMind API key is required. Configure using one of these methods:\n' +
1636
+ ' 1. Set VITE_ASTERMIND_RAG_API_KEY environment variable (Vite)\n' +
1637
+ ' 2. Set REACT_APP_ASTERMIND_RAG_API_KEY environment variable (CRA)\n' +
1638
+ ' 3. Set window.astermindConfig = { apiKey: "am_...", apiUrl: "..." }\n' +
1639
+ ' 4. Add data-astermind-key attribute to your script tag\n' +
1640
+ ' 5. Pass apiKey directly to createClient() or CyberneticClient constructor');
1641
+ }
1642
+ else {
1643
+ console.warn('[AsterMind] No API key found. Chatbot will not function until configured.');
1644
+ return null;
1645
+ }
1646
+ }
1647
+
1019
1648
  // src/license/base64url.ts
1020
1649
  // Base64URL encoding/decoding utilities for JWT handling
1021
1650
  /**
@@ -1651,6 +2280,7 @@ LJ5AZXvOhHaXdHzMuYKX5BpK4w7TqbPvJ6QPvKmLKvHh1VKcUJ6mJQgJJw==
1651
2280
  */
1652
2281
  class CyberneticClient {
1653
2282
  constructor(config) {
2283
+ this.wsTransport = null;
1654
2284
  this.status = 'connecting';
1655
2285
  this.lastError = null;
1656
2286
  // Maintenance mode tracking (ADR-200)
@@ -1665,10 +2295,15 @@ LJ5AZXvOhHaXdHzMuYKX5BpK4w7TqbPvJ6QPvKmLKvHh1VKcUJ6mJQgJJw==
1665
2295
  this.omegaRAG = null;
1666
2296
  // Track if offline warning has been shown
1667
2297
  this.offlineWarningShown = false;
2298
+ // Resolve WebSocket URL (explicit → env → auto-derived from apiUrl)
2299
+ const wsUrl = resolveWsUrl(config);
2300
+ const transport = config.transport ?? 'auto';
1668
2301
  // Apply defaults
1669
2302
  this.config = {
1670
2303
  apiUrl: config.apiUrl,
1671
2304
  apiKey: config.apiKey,
2305
+ wsUrl,
2306
+ transport,
1672
2307
  fallback: {
1673
2308
  enabled: config.fallback?.enabled ?? true,
1674
2309
  cacheMaxAge: config.fallback?.cacheMaxAge ?? 86400000, // 24 hours
@@ -1711,6 +2346,11 @@ LJ5AZXvOhHaXdHzMuYKX5BpK4w7TqbPvJ6QPvKmLKvHh1VKcUJ6mJQgJJw==
1711
2346
  }).catch(() => {
1712
2347
  // License verification errors are handled internally
1713
2348
  });
2349
+ // Initialize WebSocket transport if configured and not forced to REST
2350
+ if (wsUrl && transport !== 'rest' && typeof WebSocket !== 'undefined') {
2351
+ this.wsTransport = new WebSocketTransport(wsUrl, config.apiKey, config.websocket);
2352
+ console.log(`[Cybernetic] WebSocket transport enabled: ${wsUrl}`);
2353
+ }
1714
2354
  // Monitor connection status
1715
2355
  this.monitorConnection();
1716
2356
  // Pre-cache documents on init if enabled
@@ -2089,6 +2729,60 @@ LJ5AZXvOhHaXdHzMuYKX5BpK4w7TqbPvJ6QPvKmLKvHh1VKcUJ6mJQgJJw==
2089
2729
  callbacks.onComplete?.(response);
2090
2730
  return;
2091
2731
  }
2732
+ // Try WebSocket transport first (if available and not forced to REST)
2733
+ if (this.wsTransport && this.config.transport !== 'rest') {
2734
+ try {
2735
+ await this.wsTransport.chatStream(message, {
2736
+ sessionId: options?.sessionId,
2737
+ context: options?.context,
2738
+ onToken: callbacks.onToken,
2739
+ onSources: callbacks.onSources,
2740
+ onComplete: (response) => {
2741
+ this.setStatus('online');
2742
+ // Process through license manager
2743
+ const processedReply = this.licenseManager.processResponse(response.reply);
2744
+ callbacks.onComplete?.({
2745
+ ...response,
2746
+ reply: processedReply
2747
+ });
2748
+ },
2749
+ onError: (error) => {
2750
+ // In 'auto' mode, fall back to SSE on WS error
2751
+ if (this.config.transport === 'auto') {
2752
+ console.warn('[Cybernetic] WebSocket error, falling back to SSE:', error.message);
2753
+ this.streamViaSSE(message, callbacks, options);
2754
+ }
2755
+ else {
2756
+ // 'websocket' mode — no fallback
2757
+ const normalizedError = this.normalizeError(error);
2758
+ this.config.onError(normalizedError);
2759
+ callbacks.onError?.(normalizedError);
2760
+ }
2761
+ }
2762
+ });
2763
+ return;
2764
+ }
2765
+ catch (error) {
2766
+ if (this.config.transport === 'websocket') {
2767
+ // Forced WebSocket mode — don't fall back
2768
+ const normalizedError = this.normalizeError(error);
2769
+ this.lastError = normalizedError;
2770
+ this.config.onError(normalizedError);
2771
+ callbacks.onError?.(normalizedError);
2772
+ return;
2773
+ }
2774
+ // 'auto' mode: fall through to SSE
2775
+ console.warn('[Cybernetic] WebSocket connect failed, using SSE fallback');
2776
+ }
2777
+ }
2778
+ // REST+SSE path (on-prem, or fallback from WebSocket)
2779
+ await this.streamViaSSE(message, callbacks, options);
2780
+ }
2781
+ /**
2782
+ * Stream chat via REST+SSE (original transport).
2783
+ * Used as the primary transport for on-prem, or as fallback for SaaS WebSocket failures.
2784
+ */
2785
+ async streamViaSSE(message, callbacks, options) {
2092
2786
  try {
2093
2787
  await this.apiClient.chatStream(message, {
2094
2788
  sessionId: options?.sessionId,
@@ -2172,6 +2866,16 @@ LJ5AZXvOhHaXdHzMuYKX5BpK4w7TqbPvJ6QPvKmLKvHh1VKcUJ6mJQgJJw==
2172
2866
  await this.cache.clear();
2173
2867
  this.localRAG.reset();
2174
2868
  }
2869
+ /**
2870
+ * Clean up all resources (WebSocket connection, caches, event listeners).
2871
+ * Call this when the client is no longer needed to prevent memory leaks.
2872
+ */
2873
+ destroy() {
2874
+ if (this.wsTransport) {
2875
+ this.wsTransport.disconnect();
2876
+ this.wsTransport = null;
2877
+ }
2878
+ }
2175
2879
  /**
2176
2880
  * Manually check if backend is reachable
2177
2881
  */
@@ -2482,185 +3186,6 @@ LJ5AZXvOhHaXdHzMuYKX5BpK4w7TqbPvJ6QPvKmLKvHh1VKcUJ6mJQgJJw==
2482
3186
  }
2483
3187
  }
2484
3188
 
2485
- // src/config.ts
2486
- // Configuration loading and validation
2487
- /** Default API URL when not specified */
2488
- const DEFAULT_API_URL = 'https://api.astermind.ai';
2489
- /**
2490
- * Validate configuration
2491
- */
2492
- function validateConfig(config) {
2493
- if (!config || typeof config !== 'object') {
2494
- throw new Error('Config must be an object');
2495
- }
2496
- const c = config;
2497
- if (!c.apiUrl || typeof c.apiUrl !== 'string') {
2498
- throw new Error('apiUrl is required and must be a string');
2499
- }
2500
- if (!c.apiKey || typeof c.apiKey !== 'string') {
2501
- throw new Error('apiKey is required and must be a string');
2502
- }
2503
- if (!c.apiKey.startsWith('am_')) {
2504
- throw new Error('apiKey must start with "am_"');
2505
- }
2506
- return true;
2507
- }
2508
- /**
2509
- * Load config from Node.js process.env (for bundlers that replace process.env)
2510
- */
2511
- function loadFromProcessEnv() {
2512
- // Check if process.env exists (Node.js or bundler-injected)
2513
- if (typeof process === 'undefined' || !process.env) {
2514
- return null;
2515
- }
2516
- // Check for ASTERMIND_RAG_* env vars (non-prefixed, for Node.js/server environments)
2517
- const apiKey = process.env.ASTERMIND_RAG_API_KEY;
2518
- const apiUrl = process.env.ASTERMIND_RAG_API_SERVER_URL;
2519
- if (apiKey) {
2520
- return {
2521
- apiKey,
2522
- apiUrl: apiUrl || DEFAULT_API_URL,
2523
- _source: 'env'
2524
- };
2525
- }
2526
- // Check for CRA-style REACT_APP_* env vars
2527
- const craApiKey = process.env.REACT_APP_ASTERMIND_RAG_API_KEY;
2528
- const craApiUrl = process.env.REACT_APP_ASTERMIND_RAG_API_SERVER_URL;
2529
- if (craApiKey) {
2530
- return {
2531
- apiKey: craApiKey,
2532
- apiUrl: craApiUrl || DEFAULT_API_URL,
2533
- _source: 'env'
2534
- };
2535
- }
2536
- return null;
2537
- }
2538
- /**
2539
- * Load config from Vite's import.meta.env (browser environment)
2540
- * Note: This only works at build time when Vite replaces the variables
2541
- */
2542
- function loadFromViteEnv() {
2543
- // Check for window.__ASTERMIND_CONFIG__ (SSR-injected config)
2544
- if (typeof window !== 'undefined') {
2545
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2546
- const injected = window.__ASTERMIND_CONFIG__;
2547
- if (injected && typeof injected === 'object' && injected.apiKey) {
2548
- return {
2549
- ...injected,
2550
- apiUrl: injected.apiUrl || DEFAULT_API_URL,
2551
- _source: 'vite'
2552
- };
2553
- }
2554
- }
2555
- // Check for Vite's import.meta.env (replaced at build time)
2556
- // Note: TypeScript doesn't know about import.meta.env, so we use a try-catch
2557
- try {
2558
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2559
- const importMeta = globalThis.import?.meta?.env;
2560
- if (importMeta) {
2561
- const apiKey = importMeta.VITE_ASTERMIND_RAG_API_KEY;
2562
- const apiUrl = importMeta.VITE_ASTERMIND_RAG_API_SERVER_URL;
2563
- if (apiKey) {
2564
- return {
2565
- apiKey,
2566
- apiUrl: apiUrl || DEFAULT_API_URL,
2567
- _source: 'vite'
2568
- };
2569
- }
2570
- }
2571
- }
2572
- catch {
2573
- // import.meta not available in this environment
2574
- }
2575
- return null;
2576
- }
2577
- /**
2578
- * Load config from window.astermindConfig global object
2579
- */
2580
- function loadFromGlobalObject() {
2581
- if (typeof window === 'undefined') {
2582
- return null;
2583
- }
2584
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2585
- const globalConfig = window.astermindConfig;
2586
- if (globalConfig && typeof globalConfig === 'object' && globalConfig.apiKey) {
2587
- return {
2588
- ...globalConfig,
2589
- apiUrl: globalConfig.apiUrl || DEFAULT_API_URL,
2590
- _source: 'window'
2591
- };
2592
- }
2593
- return null;
2594
- }
2595
- /**
2596
- * Load config from script tag data attributes
2597
- */
2598
- function loadFromScriptAttributes() {
2599
- if (typeof window === 'undefined' || typeof document === 'undefined') {
2600
- return null;
2601
- }
2602
- const script = document.querySelector('script[data-astermind-key]');
2603
- if (script) {
2604
- const apiKey = script.getAttribute('data-astermind-key');
2605
- if (apiKey) {
2606
- return {
2607
- apiKey,
2608
- apiUrl: script.getAttribute('data-astermind-url') || DEFAULT_API_URL,
2609
- _source: 'data-attr'
2610
- };
2611
- }
2612
- }
2613
- return null;
2614
- }
2615
- /**
2616
- * Load config using priority-based fallback chain:
2617
- * 1. Environment variables (process.env - for bundlers/Node.js)
2618
- * 2. Vite environment variables (import.meta.env or window.__ASTERMIND_CONFIG__)
2619
- * 3. Global object (window.astermindConfig)
2620
- * 4. Script data attributes (data-astermind-key, data-astermind-url)
2621
- *
2622
- * @param options - Configuration options
2623
- * @param options.throwOnMissingKey - If true (default), throws when no API key found. If false, returns null and logs a warning.
2624
- * @returns Configuration object or null if not found and throwOnMissingKey is false
2625
- */
2626
- function loadConfig(options = {}) {
2627
- const { throwOnMissingKey = true } = options;
2628
- // Priority 1: Environment variables (Node.js/bundler)
2629
- const envConfig = loadFromProcessEnv();
2630
- if (envConfig) {
2631
- return envConfig;
2632
- }
2633
- // Priority 2: Vite environment variables / SSR-injected config
2634
- const viteConfig = loadFromViteEnv();
2635
- if (viteConfig) {
2636
- return viteConfig;
2637
- }
2638
- // Priority 3: Global object (window.astermindConfig)
2639
- const globalConfig = loadFromGlobalObject();
2640
- if (globalConfig) {
2641
- validateConfig(globalConfig);
2642
- return globalConfig;
2643
- }
2644
- // Priority 4: Script data attributes
2645
- const scriptConfig = loadFromScriptAttributes();
2646
- if (scriptConfig) {
2647
- return scriptConfig;
2648
- }
2649
- // No config found
2650
- if (throwOnMissingKey) {
2651
- throw new Error('AsterMind API key is required. Configure using one of these methods:\n' +
2652
- ' 1. Set VITE_ASTERMIND_RAG_API_KEY environment variable (Vite)\n' +
2653
- ' 2. Set REACT_APP_ASTERMIND_RAG_API_KEY environment variable (CRA)\n' +
2654
- ' 3. Set window.astermindConfig = { apiKey: "am_...", apiUrl: "..." }\n' +
2655
- ' 4. Add data-astermind-key attribute to your script tag\n' +
2656
- ' 5. Pass apiKey directly to createClient() or CyberneticClient constructor');
2657
- }
2658
- else {
2659
- console.warn('[AsterMind] No API key found. Chatbot will not function until configured.');
2660
- return null;
2661
- }
2662
- }
2663
-
2664
3189
  // src/agentic/SiteMapDiscovery.ts
2665
3190
  // Multi-source sitemap discovery and merging for agentic navigation
2666
3191
  // Zero-config mode: automatically discovers routes on any site