@robosystems/core 0.11.2 → 0.11.3
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/auth-components/AuthProvider.js +166 -27
- package/auth-components/SessionWarningDialog.js +2 -2
- package/auth-components/SignInForm.d.ts +2 -1
- package/auth-components/SignInForm.js +8 -2
- package/auth-components/SignUpForm.js +4 -1
- package/auth-core/client.d.ts +8 -1
- package/auth-core/client.js +142 -100
- package/auth-core/sso.d.ts +4 -9
- package/auth-core/sso.js +18 -58
- package/auth-core/token-storage.d.ts +12 -0
- package/auth-core/token-storage.js +42 -16
- package/auth-core/types.d.ts +1 -0
- package/components/console/ConsoleContent.js +30 -4
- package/components/console/graphAwareConfig.js +27 -17
- package/components/repositories/ActiveSubscriptions.js +18 -6
- package/components/repositories/BrowseRepositories.js +17 -5
- package/components/search/SearchContent.js +30 -4
- package/contexts/graph-context.js +23 -13
- package/hooks/use-streaming-query.js +43 -28
- package/index.d.ts +5 -2
- package/index.js +6 -2
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/sdk-errors.d.ts +79 -0
- package/lib/sdk-errors.js +148 -0
- package/package.json +1 -1
- package/task-monitoring/hooks.js +23 -13
- package/task-monitoring/operationHooks.d.ts +21 -2
- package/task-monitoring/operationHooks.js +67 -17
- package/task-monitoring/operationMonitor.d.ts +10 -1
- package/task-monitoring/operationMonitor.js +109 -57
- package/task-monitoring/taskMonitor.d.ts +15 -1
- package/task-monitoring/taskMonitor.js +120 -73
- package/task-monitoring/types.d.ts +2 -1
|
@@ -7,6 +7,24 @@
|
|
|
7
7
|
const TOKEN_KEY = 'robosystems_jwt_token';
|
|
8
8
|
const TOKEN_EXPIRY_KEY = 'robosystems_jwt_expiry';
|
|
9
9
|
const TOKEN_THRESHOLD_KEY = 'robosystems_jwt_threshold';
|
|
10
|
+
/**
|
|
11
|
+
* How long past its local expiry a token is kept. The API renews a
|
|
12
|
+
* recently-expired token at `/v1/auth/refresh` within this window, so the
|
|
13
|
+
* token must survive locally until then — for the refresh call only. Data
|
|
14
|
+
* calls stop sending it at expiry.
|
|
15
|
+
*/
|
|
16
|
+
export const TOKEN_REFRESH_GRACE_MS = 5 * 60 * 1000;
|
|
17
|
+
function readExpiry() {
|
|
18
|
+
const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
19
|
+
if (!expiryStr)
|
|
20
|
+
return null;
|
|
21
|
+
const expiry = parseInt(expiryStr, 10);
|
|
22
|
+
return Number.isFinite(expiry) ? expiry : null;
|
|
23
|
+
}
|
|
24
|
+
/** Past expiry *and* the refresh grace: nothing can use it any more. */
|
|
25
|
+
function isPastGrace(expiry) {
|
|
26
|
+
return expiry !== null && Date.now() > expiry + TOKEN_REFRESH_GRACE_MS;
|
|
27
|
+
}
|
|
10
28
|
/**
|
|
11
29
|
* Store JWT token in localStorage
|
|
12
30
|
* @param token - The JWT token from login response
|
|
@@ -38,16 +56,15 @@ export function getToken() {
|
|
|
38
56
|
return null;
|
|
39
57
|
try {
|
|
40
58
|
const token = localStorage.getItem(TOKEN_KEY);
|
|
41
|
-
const expiryStr = localStorage.getItem(TOKEN_EXPIRY_KEY);
|
|
42
59
|
if (!token)
|
|
43
60
|
return null;
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
// An expired token is not sent on data calls, but it is kept until the
|
|
62
|
+
// refresh grace runs out so `getRefreshToken` can still renew it.
|
|
63
|
+
const expiry = readExpiry();
|
|
64
|
+
if (expiry !== null && Date.now() > expiry) {
|
|
65
|
+
if (isPastGrace(expiry))
|
|
48
66
|
clearToken();
|
|
49
|
-
|
|
50
|
-
}
|
|
67
|
+
return null;
|
|
51
68
|
}
|
|
52
69
|
return token;
|
|
53
70
|
}
|
|
@@ -152,20 +169,29 @@ export function handleAuthResponse(response) {
|
|
|
152
169
|
* @returns Valid token or null if expired
|
|
153
170
|
*/
|
|
154
171
|
export async function getValidToken() {
|
|
155
|
-
|
|
156
|
-
|
|
172
|
+
return getToken();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The token to present to `/v1/auth/refresh`: the stored token while it is
|
|
176
|
+
* unexpired or within the server's refresh grace after expiry, else null.
|
|
177
|
+
*/
|
|
178
|
+
export function getRefreshToken() {
|
|
179
|
+
if (typeof window === 'undefined')
|
|
157
180
|
return null;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
// Token is expired, clear it and let AuthProvider handle re-auth
|
|
181
|
+
try {
|
|
182
|
+
const token = localStorage.getItem(TOKEN_KEY);
|
|
183
|
+
if (!token)
|
|
184
|
+
return null;
|
|
185
|
+
if (isPastGrace(readExpiry())) {
|
|
164
186
|
clearToken();
|
|
165
187
|
return null;
|
|
166
188
|
}
|
|
189
|
+
return token;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
console.error('[TokenStorage] Failed to retrieve refresh token:', error);
|
|
193
|
+
return null;
|
|
167
194
|
}
|
|
168
|
-
return token;
|
|
169
195
|
}
|
|
170
196
|
/**
|
|
171
197
|
* Security event logging (for monitoring)
|
package/auth-core/types.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ export interface AuthContextType {
|
|
|
104
104
|
register: (email: string, password: string, name?: string) => Promise<AuthUser>;
|
|
105
105
|
logout: (reason?: string, options?: {
|
|
106
106
|
redirectTo?: string;
|
|
107
|
+
skipServerLogout?: boolean;
|
|
107
108
|
}) => Promise<void>;
|
|
108
109
|
refreshUser: () => Promise<AuthUser | null>;
|
|
109
110
|
refreshSession: (force?: boolean) => Promise<void>;
|
|
@@ -61,6 +61,14 @@ export function ConsoleContent({ config }) {
|
|
|
61
61
|
const { state: graphState } = useGraphContext();
|
|
62
62
|
const graphId = graphState.currentGraphId;
|
|
63
63
|
const streamingQuery = useStreamingQuery();
|
|
64
|
+
// The graph the console is on right now, readable after an await: an
|
|
65
|
+
// answer for a graph the user has since left must not be printed (or
|
|
66
|
+
// recorded as history) under the new graph.
|
|
67
|
+
const currentGraphRef = useRef(graphId);
|
|
68
|
+
currentGraphRef.current = graphId;
|
|
69
|
+
// Moves on at every graph switch, so a switch away and back (A → B → A)
|
|
70
|
+
// still retires a request made before it.
|
|
71
|
+
const graphEpochRef = useRef(0);
|
|
64
72
|
// Terminal state
|
|
65
73
|
const [terminalMessages, setTerminalMessages] = useState([]);
|
|
66
74
|
const [commandInput, setCommandInput] = useState('');
|
|
@@ -190,16 +198,21 @@ export function ConsoleContent({ config }) {
|
|
|
190
198
|
if (!graphId)
|
|
191
199
|
return;
|
|
192
200
|
if (previousGraphId.current && previousGraphId.current !== graphId) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
201
|
+
const fromGraphId = previousGraphId.current;
|
|
202
|
+
previousGraphId.current = graphId;
|
|
203
|
+
graphEpochRef.current++;
|
|
204
|
+
// Stop any stream still running for the previous graph; its rows must
|
|
205
|
+
// not land under the new graph's banner.
|
|
206
|
+
streamingQuery.cancelQuery();
|
|
207
|
+
operatorProgressMessageId.current = null;
|
|
208
|
+
setOperatorProgress({ isRunning: false, message: '' });
|
|
196
209
|
setTerminalMessages([]);
|
|
197
210
|
// A follow-up on the new graph must not resolve against the previous
|
|
198
211
|
// graph's answers.
|
|
199
212
|
conversationRef.current = [];
|
|
200
213
|
setCurrentQueryStartTime(null);
|
|
201
214
|
addSystemMessage(`═══════════════════════════════════════════════════════════════\n` +
|
|
202
|
-
`${config.welcome.contextLabel} context changed: ${
|
|
215
|
+
`${config.welcome.contextLabel} context changed: ${fromGraphId} → ${graphId}\n` +
|
|
203
216
|
`═══════════════════════════════════════════════════════════════\n\n` +
|
|
204
217
|
`Console has been reset for the new ${config.welcome.contextLabel.toLowerCase()} context.\n` +
|
|
205
218
|
`All queries will now execute against: ${graphId}\n\n` +
|
|
@@ -322,6 +335,10 @@ export function ConsoleContent({ config }) {
|
|
|
322
335
|
return;
|
|
323
336
|
}
|
|
324
337
|
const startTime = Date.now();
|
|
338
|
+
const requestGraphId = graphId;
|
|
339
|
+
const requestEpoch = graphEpochRef.current;
|
|
340
|
+
const isStale = () => currentGraphRef.current !== requestGraphId ||
|
|
341
|
+
graphEpochRef.current !== requestEpoch;
|
|
325
342
|
try {
|
|
326
343
|
const { clients } = await import('@robosystems/client/clients');
|
|
327
344
|
const history = conversationRef.current;
|
|
@@ -331,6 +348,8 @@ export function ConsoleContent({ config }) {
|
|
|
331
348
|
mode: 'standard' }, (history.length > 0 ? { history } : {})), {
|
|
332
349
|
mode: 'auto',
|
|
333
350
|
onProgress: (message, percentage) => {
|
|
351
|
+
if (isStale())
|
|
352
|
+
return;
|
|
334
353
|
setOperatorProgress({
|
|
335
354
|
isRunning: true,
|
|
336
355
|
message,
|
|
@@ -338,6 +357,11 @@ export function ConsoleContent({ config }) {
|
|
|
338
357
|
});
|
|
339
358
|
},
|
|
340
359
|
});
|
|
360
|
+
// The user switched graphs while the operator ran: the reset already
|
|
361
|
+
// cleared the console, so drop the answer rather than print it (and
|
|
362
|
+
// record it as history) under the new graph.
|
|
363
|
+
if (isStale())
|
|
364
|
+
return;
|
|
341
365
|
setOperatorProgress({ isRunning: false, message: '' });
|
|
342
366
|
// The API reports operator failures (credit pre-flight, timeouts,
|
|
343
367
|
// unbilled calls) as HTTP 200 with `error_details` set and an
|
|
@@ -419,6 +443,8 @@ export function ConsoleContent({ config }) {
|
|
|
419
443
|
}
|
|
420
444
|
}
|
|
421
445
|
catch (error) {
|
|
446
|
+
if (isStale())
|
|
447
|
+
return;
|
|
422
448
|
setOperatorProgress({ isRunning: false, message: '' });
|
|
423
449
|
const errorMessage = error.message ||
|
|
424
450
|
((_a = error.data) === null || _a === void 0 ? void 0 : _a.detail) ||
|
|
@@ -206,9 +206,11 @@ LIMIT 20`,
|
|
|
206
206
|
};
|
|
207
207
|
// ── RoboInvestor entity graph (portfolios / positions / securities) ──────────
|
|
208
208
|
// Data: Entity -> Portfolio -> Position -> Security, and Entity -> Security
|
|
209
|
-
// (issuance). Position carries cost_basis and
|
|
210
|
-
// allocation are computed in Cypher.
|
|
211
|
-
//
|
|
209
|
+
// (issuance). Position carries cost_basis, current_value and currency, so
|
|
210
|
+
// gains and allocation are computed in Cypher. Sums count only marked
|
|
211
|
+
// positions (current_value set) and never add across currencies.
|
|
212
|
+
// Security.ticker, Trade, Benchmark and MarketData exist in the schema but
|
|
213
|
+
// aren't populated yet — keep examples off them.
|
|
212
214
|
const ROBOINVESTOR_EXAMPLE_SET = {
|
|
213
215
|
subtitle: 'AI analyst for your investment portfolios',
|
|
214
216
|
description: 'Ask in plain English and an AI analyst queries your portfolios, positions, and securities — holdings, cost basis vs. current value, allocation, and unrealized gains. Every answer comes back with the data and the Cypher behind it.',
|
|
@@ -221,47 +223,55 @@ const ROBOINVESTOR_EXAMPLE_SET = {
|
|
|
221
223
|
directQueryExamples: [
|
|
222
224
|
'MATCH (p:Portfolio) RETURN p.name, p.strategy, p.base_currency',
|
|
223
225
|
'MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position) RETURN p.name, count(pos) AS positions ORDER BY positions DESC',
|
|
224
|
-
'MATCH (s:Security) RETURN s.
|
|
226
|
+
'MATCH (s:Security) RETURN s.name, s.security_type, s.security_subtype LIMIT 20',
|
|
225
227
|
],
|
|
226
228
|
sampleQueries: [
|
|
227
229
|
{
|
|
228
230
|
name: 'Portfolio value',
|
|
229
231
|
query: `MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position)
|
|
232
|
+
WHERE pos.current_value IS NOT NULL
|
|
230
233
|
RETURN p.name,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
sum(pos.current_value) AS total_value
|
|
234
|
-
|
|
234
|
+
pos.currency AS currency,
|
|
235
|
+
count(pos) AS marked_positions,
|
|
236
|
+
sum(pos.current_value) AS total_value,
|
|
237
|
+
count(pos.cost_basis) AS positions_with_cost,
|
|
238
|
+
sum(pos.cost_basis) AS total_cost
|
|
239
|
+
ORDER BY p.name, currency`,
|
|
235
240
|
},
|
|
236
241
|
{
|
|
237
242
|
name: 'Holdings by current value',
|
|
238
243
|
query: `MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position)-[:POSITION_IN_SECURITY]->(s:Security)
|
|
239
|
-
|
|
240
|
-
|
|
244
|
+
WHERE pos.current_value IS NOT NULL
|
|
245
|
+
RETURN p.name, s.name, pos.quantity, pos.currency AS currency,
|
|
246
|
+
pos.cost_basis, pos.current_value
|
|
247
|
+
ORDER BY currency, pos.current_value DESC
|
|
241
248
|
LIMIT 25`,
|
|
242
249
|
},
|
|
243
250
|
{
|
|
244
251
|
name: 'Unrealized gains',
|
|
245
252
|
query: `MATCH (p:Portfolio)-[:PORTFOLIO_HAS_POSITION]->(pos:Position)-[:POSITION_IN_SECURITY]->(s:Security)
|
|
246
253
|
WHERE pos.cost_basis IS NOT NULL AND pos.current_value IS NOT NULL
|
|
247
|
-
RETURN
|
|
254
|
+
RETURN p.name, s.name, pos.currency AS currency,
|
|
255
|
+
pos.cost_basis, pos.current_value,
|
|
248
256
|
pos.current_value - pos.cost_basis AS unrealized_gain
|
|
249
|
-
ORDER BY unrealized_gain DESC
|
|
257
|
+
ORDER BY currency, unrealized_gain DESC
|
|
250
258
|
LIMIT 20`,
|
|
251
259
|
},
|
|
252
260
|
{
|
|
253
261
|
name: 'Allocation by security type',
|
|
254
262
|
query: `MATCH (pos:Position)-[:POSITION_IN_SECURITY]->(s:Security)
|
|
255
|
-
|
|
256
|
-
|
|
263
|
+
WHERE pos.current_value IS NOT NULL
|
|
264
|
+
RETURN pos.currency AS currency,
|
|
265
|
+
s.security_type,
|
|
266
|
+
count(pos) AS marked_positions,
|
|
257
267
|
sum(pos.current_value) AS total_value
|
|
258
|
-
ORDER BY total_value DESC`,
|
|
268
|
+
ORDER BY currency, total_value DESC`,
|
|
259
269
|
},
|
|
260
270
|
{
|
|
261
271
|
name: 'Securities issued by entity',
|
|
262
272
|
query: `MATCH (e:Entity)-[:ENTITY_ISSUES_SECURITY]->(s:Security)
|
|
263
|
-
RETURN e.name, s.
|
|
264
|
-
ORDER BY s.
|
|
273
|
+
RETURN e.name, s.name, s.security_type, s.security_subtype
|
|
274
|
+
ORDER BY e.name, s.name
|
|
265
275
|
LIMIT 25`,
|
|
266
276
|
},
|
|
267
277
|
],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import * as SDK from '@robosystems/client';
|
|
4
4
|
import { Badge, Button, Card, Spinner } from 'flowbite-react';
|
|
5
5
|
import { useCallback, useEffect, useState } from 'react';
|
|
@@ -9,9 +9,13 @@ import { useGraphContext } from '../../contexts/graph-context';
|
|
|
9
9
|
import { useOrg } from '../../contexts/org-context';
|
|
10
10
|
import { useServiceOfferings } from '../../contexts/service-offerings-context';
|
|
11
11
|
import { useToast } from '../../hooks/use-toast';
|
|
12
|
+
import { unwrapSdk } from '../../lib/sdk-errors';
|
|
12
13
|
export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStarted, onBackups, onBilling, onBrowse, onCancel, emptyState, }) {
|
|
13
14
|
const [userSubscriptions, setUserSubscriptions] = useState([]);
|
|
14
15
|
const [loading, setLoading] = useState(true);
|
|
16
|
+
// A failed subscription read must not render as "no subscriptions": that
|
|
17
|
+
// offers Subscribe on repositories the org already holds.
|
|
18
|
+
const [loadError, setLoadError] = useState(null);
|
|
15
19
|
const { showError, ToastContainer } = useToast();
|
|
16
20
|
const { currentOrg } = useOrg();
|
|
17
21
|
const { offerings, isLoading: offeringsLoading } = useServiceOfferings();
|
|
@@ -23,11 +27,12 @@ export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStart
|
|
|
23
27
|
}
|
|
24
28
|
try {
|
|
25
29
|
setLoading(true);
|
|
26
|
-
|
|
30
|
+
setLoadError(null);
|
|
31
|
+
const subscriptions = unwrapSdk(await SDK.listOrgSubscriptions({
|
|
27
32
|
path: { org_id: currentOrg.id },
|
|
28
|
-
});
|
|
29
|
-
if (
|
|
30
|
-
const repositorySubscriptions = (
|
|
33
|
+
}));
|
|
34
|
+
if (subscriptions) {
|
|
35
|
+
const repositorySubscriptions = (subscriptions || []).filter((sub) => sub.resource_type === 'repository');
|
|
31
36
|
setUserSubscriptions(repositorySubscriptions);
|
|
32
37
|
}
|
|
33
38
|
else {
|
|
@@ -36,7 +41,11 @@ export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStart
|
|
|
36
41
|
}
|
|
37
42
|
catch (error) {
|
|
38
43
|
console.error('Failed to load user subscriptions:', error);
|
|
39
|
-
|
|
44
|
+
const message = error instanceof Error && error.message
|
|
45
|
+
? `Failed to load subscriptions: ${error.message}`
|
|
46
|
+
: 'Failed to load subscriptions';
|
|
47
|
+
setLoadError(message);
|
|
48
|
+
showError(message);
|
|
40
49
|
}
|
|
41
50
|
finally {
|
|
42
51
|
setLoading(false);
|
|
@@ -48,6 +57,9 @@ export function ActiveSubscriptions({ onOpenConsole, onOpenUsage, onGettingStart
|
|
|
48
57
|
if (loading || offeringsLoading) {
|
|
49
58
|
return (_jsx("div", { className: "flex min-h-[400px] items-center justify-center", children: _jsx(Spinner, { size: "xl" }) }));
|
|
50
59
|
}
|
|
60
|
+
if (loadError) {
|
|
61
|
+
return (_jsxs("div", { className: "space-y-6", children: [_jsx(ToastContainer, {}), _jsx(Card, { children: _jsxs("div", { className: "flex flex-col items-center gap-4 py-8 text-center", children: [_jsx("p", { className: "text-zinc-700 dark:text-zinc-300", children: loadError }), _jsx(Button, { color: "gray", onClick: () => loadData(), children: "Try again" })] }) })] }));
|
|
62
|
+
}
|
|
51
63
|
// Treat a period-end-canceled sub as "still active" until its access
|
|
52
64
|
// window actually closes. Otherwise the row would disappear from this
|
|
53
65
|
// view the moment the user clicks Cancel — even though the modal
|
|
@@ -9,10 +9,14 @@ import { useGraphContext } from '../../contexts/graph-context';
|
|
|
9
9
|
import { useOrg } from '../../contexts/org-context';
|
|
10
10
|
import { useServiceOfferings } from '../../contexts/service-offerings-context';
|
|
11
11
|
import { useToast } from '../../hooks/use-toast';
|
|
12
|
+
import { unwrapSdk } from '../../lib/sdk-errors';
|
|
12
13
|
import { useRepositorySubscription } from '../../task-monitoring/operationHooks';
|
|
13
14
|
export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHeader = true, showInfoCard = true, }) {
|
|
14
15
|
const [userSubscriptions, setUserSubscriptions] = useState([]);
|
|
15
16
|
const [loading, setLoading] = useState(true);
|
|
17
|
+
// A failed subscription read must not render as "no subscriptions": that
|
|
18
|
+
// offers Subscribe on repositories the org already holds.
|
|
19
|
+
const [loadError, setLoadError] = useState(null);
|
|
16
20
|
const [subscribing, setSubscribing] = useState(null);
|
|
17
21
|
const [changingPlan, setChangingPlan] = useState(false);
|
|
18
22
|
const [planChangeModal, setPlanChangeModal] = useState(null);
|
|
@@ -28,12 +32,13 @@ export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHead
|
|
|
28
32
|
}
|
|
29
33
|
try {
|
|
30
34
|
setLoading(true);
|
|
35
|
+
setLoadError(null);
|
|
31
36
|
// Load org subscriptions and filter for repositories
|
|
32
|
-
const
|
|
37
|
+
const subscriptions = unwrapSdk(await SDK.listOrgSubscriptions({
|
|
33
38
|
path: { org_id: currentOrg.id },
|
|
34
|
-
});
|
|
35
|
-
if (
|
|
36
|
-
const repositorySubscriptions = (
|
|
39
|
+
}));
|
|
40
|
+
if (subscriptions) {
|
|
41
|
+
const repositorySubscriptions = (subscriptions || []).filter((sub) => sub.resource_type === 'repository');
|
|
37
42
|
setUserSubscriptions(repositorySubscriptions);
|
|
38
43
|
}
|
|
39
44
|
else {
|
|
@@ -42,7 +47,11 @@ export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHead
|
|
|
42
47
|
}
|
|
43
48
|
catch (error) {
|
|
44
49
|
console.error('Failed to load user subscriptions:', error);
|
|
45
|
-
|
|
50
|
+
const message = error instanceof Error && error.message
|
|
51
|
+
? `Failed to load subscriptions: ${error.message}`
|
|
52
|
+
: 'Failed to load subscriptions';
|
|
53
|
+
setLoadError(message);
|
|
54
|
+
showError(message);
|
|
46
55
|
}
|
|
47
56
|
finally {
|
|
48
57
|
setLoading(false);
|
|
@@ -131,6 +140,9 @@ export function BrowseRepositories({ onSubscribed, onViewSubscriptions, showHead
|
|
|
131
140
|
if (loading || offeringsLoading) {
|
|
132
141
|
return (_jsx("div", { className: "flex min-h-[400px] items-center justify-center", children: _jsx(Spinner, { size: "xl" }) }));
|
|
133
142
|
}
|
|
143
|
+
if (loadError) {
|
|
144
|
+
return (_jsxs("div", { className: "space-y-6", children: [_jsx(ToastContainer, {}), _jsx(Card, { children: _jsxs("div", { className: "flex flex-col items-center gap-4 py-8 text-center", children: [_jsx("p", { className: "text-zinc-700 dark:text-zinc-300", children: loadError }), _jsx(Button, { color: "gray", onClick: () => loadData(), children: "Try again" })] }) })] }));
|
|
145
|
+
}
|
|
134
146
|
// Check if user has any active subscriptions
|
|
135
147
|
const hasActiveSubscriptions = userSubscriptions.some((sub) => sub.status === 'active');
|
|
136
148
|
return (_jsxs("div", { className: "space-y-6", children: [_jsx(ToastContainer, {}), showHeader && (_jsxs("div", { className: "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3 sm:gap-4", children: [_jsx("div", { className: "from-primary-500 to-secondary-600 shrink-0 rounded-lg bg-gradient-to-br p-2.5 sm:p-3", children: _jsx(HiGlobeAlt, { className: "h-6 w-6 text-white sm:h-8 sm:w-8" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "font-heading text-2xl font-bold break-words text-zinc-900 sm:text-3xl dark:text-zinc-100", children: "Browse Repositories" }), _jsx("p", { className: "mt-1 text-sm text-zinc-500 dark:text-zinc-400", children: "Subscribe to read-only graph databases containing curated data" })] })] }), hasActiveSubscriptions && onViewSubscriptions && (_jsx(Button, { onClick: onViewSubscriptions, color: "gray", children: "View My Subscriptions" }))] })), showInfoCard && (_jsx(Card, { children: _jsxs("div", { className: "flex items-start gap-4", children: [_jsx("div", { className: "bg-primary-100 dark:bg-primary-900/30 rounded-lg p-3", children: _jsx(HiInformationCircle, { className: "text-primary-600 dark:text-primary-400 h-6 w-6" }) }), _jsxs("div", { className: "flex-1", children: [_jsx("h3", { className: "font-heading mb-3 text-lg font-semibold text-zinc-900 dark:text-white", children: "How Shared Repositories Work" }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-2 lg:grid-cols-4", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Subscribe to gain read-only access to curated graph databases" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Credits are used for AI agent calls - queries are unlimited" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Generate API keys to access repositories via MCP tools in AI apps" })] }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "bg-primary-500 mt-1 h-2 w-2 rounded-full" }), _jsx("span", { className: "text-sm text-zinc-600 dark:text-zinc-400", children: "Different tiers offer varying credit limits and features" })] })] })] })] }) })), (offerings === null || offerings === void 0 ? void 0 : offerings.repositoryPlans) &&
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as SDK from '@robosystems/client';
|
|
4
4
|
import { Alert, Card, Select, Spinner, TextInput, ToggleSwitch, } from 'flowbite-react';
|
|
5
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
6
6
|
import { HiChevronDown, HiChevronUp, HiSearch } from 'react-icons/hi';
|
|
7
7
|
import { useIsRepository } from '../../components/RepositoryGuard';
|
|
8
8
|
import { useGraphContext } from '../../contexts';
|
|
@@ -38,6 +38,10 @@ export function SearchContent({ config }) {
|
|
|
38
38
|
const [expandedDocId, setExpandedDocId] = useState(null);
|
|
39
39
|
const [sectionContent, setSectionContent] = useState(null);
|
|
40
40
|
const [sectionLoading, setSectionLoading] = useState(false);
|
|
41
|
+
// Request counters: a response is applied only if no newer search (or
|
|
42
|
+
// section read), and no graph switch, has happened since it was sent.
|
|
43
|
+
const searchSeq = useRef(0);
|
|
44
|
+
const sectionSeq = useRef(0);
|
|
41
45
|
// Filters visibility
|
|
42
46
|
const [showFilters, setShowFilters] = useState((_b = config.showFilters) !== null && _b !== void 0 ? _b : false);
|
|
43
47
|
const filters = (_c = config.filters) !== null && _c !== void 0 ? _c : { sourceType: true };
|
|
@@ -47,16 +51,24 @@ export function SearchContent({ config }) {
|
|
|
47
51
|
setDocCount(null);
|
|
48
52
|
return;
|
|
49
53
|
}
|
|
54
|
+
let cancelled = false;
|
|
50
55
|
SDK.listDocuments({ path: { graph_id: graphId } })
|
|
51
56
|
.then((res) => {
|
|
52
|
-
if (res.data) {
|
|
57
|
+
if (!cancelled && res.data) {
|
|
53
58
|
setDocCount(res.data.total);
|
|
54
59
|
}
|
|
55
60
|
})
|
|
56
61
|
.catch(() => { });
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
};
|
|
57
65
|
}, [graphId, isRepository]);
|
|
58
66
|
// Reset when graph changes
|
|
59
67
|
useEffect(() => {
|
|
68
|
+
searchSeq.current++;
|
|
69
|
+
sectionSeq.current++;
|
|
70
|
+
setLoading(false);
|
|
71
|
+
setSectionLoading(false);
|
|
60
72
|
setResults([]);
|
|
61
73
|
setTotal(0);
|
|
62
74
|
setOffset(0);
|
|
@@ -68,6 +80,8 @@ export function SearchContent({ config }) {
|
|
|
68
80
|
const handleSearch = useCallback(async (newOffset = 0) => {
|
|
69
81
|
if (!graphId || !query.trim())
|
|
70
82
|
return;
|
|
83
|
+
const seq = ++searchSeq.current;
|
|
84
|
+
sectionSeq.current++;
|
|
71
85
|
setLoading(true);
|
|
72
86
|
setError(null);
|
|
73
87
|
setExpandedDocId(null);
|
|
@@ -95,6 +109,8 @@ export function SearchContent({ config }) {
|
|
|
95
109
|
path: { graph_id: graphId },
|
|
96
110
|
body: body,
|
|
97
111
|
});
|
|
112
|
+
if (seq !== searchSeq.current)
|
|
113
|
+
return;
|
|
98
114
|
if (res.data) {
|
|
99
115
|
const data = res.data;
|
|
100
116
|
setResults(data.hits);
|
|
@@ -107,16 +123,21 @@ export function SearchContent({ config }) {
|
|
|
107
123
|
}
|
|
108
124
|
}
|
|
109
125
|
catch (_a) {
|
|
126
|
+
if (seq !== searchSeq.current)
|
|
127
|
+
return;
|
|
110
128
|
setError('An error occurred while searching.');
|
|
111
129
|
}
|
|
112
130
|
finally {
|
|
113
|
-
|
|
131
|
+
if (seq === searchSeq.current)
|
|
132
|
+
setLoading(false);
|
|
114
133
|
}
|
|
115
134
|
}, [graphId, query, sourceType, entity, formType, fiscalYear, semantic]);
|
|
116
135
|
const handleExpand = async (docId) => {
|
|
136
|
+
const seq = ++sectionSeq.current;
|
|
117
137
|
if (expandedDocId === docId) {
|
|
118
138
|
setExpandedDocId(null);
|
|
119
139
|
setSectionContent(null);
|
|
140
|
+
setSectionLoading(false);
|
|
120
141
|
return;
|
|
121
142
|
}
|
|
122
143
|
if (!graphId)
|
|
@@ -128,15 +149,20 @@ export function SearchContent({ config }) {
|
|
|
128
149
|
const res = await SDK.getDocumentSection({
|
|
129
150
|
path: { graph_id: graphId, document_id: docId },
|
|
130
151
|
});
|
|
152
|
+
if (seq !== sectionSeq.current)
|
|
153
|
+
return;
|
|
131
154
|
if (res.data) {
|
|
132
155
|
setSectionContent(res.data);
|
|
133
156
|
}
|
|
134
157
|
}
|
|
135
158
|
catch (_a) {
|
|
159
|
+
if (seq !== sectionSeq.current)
|
|
160
|
+
return;
|
|
136
161
|
setSectionContent(null);
|
|
137
162
|
}
|
|
138
163
|
finally {
|
|
139
|
-
|
|
164
|
+
if (seq === sectionSeq.current)
|
|
165
|
+
setSectionLoading(false);
|
|
140
166
|
}
|
|
141
167
|
};
|
|
142
168
|
const hasFilters = filters.entity ||
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import * as SDK from '@robosystems/client';
|
|
4
|
-
import { createContext, useCallback, useContext, useEffect, useState, } from 'react';
|
|
4
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
|
|
5
|
+
import { unwrapSdk } from '../lib/sdk-errors';
|
|
5
6
|
// Factory function to create a typed graph context
|
|
6
7
|
export function createGraphContext() {
|
|
7
8
|
return createContext(null);
|
|
@@ -15,6 +16,9 @@ export function createGraphProvider(Context) {
|
|
|
15
16
|
isLoading: false,
|
|
16
17
|
error: null,
|
|
17
18
|
});
|
|
19
|
+
// Latest graph list, readable synchronously from callbacks
|
|
20
|
+
const graphsRef = useRef(baseState.graphs);
|
|
21
|
+
graphsRef.current = baseState.graphs;
|
|
18
22
|
// Transform state if needed
|
|
19
23
|
const state = transformState ? transformState(baseState) : baseState;
|
|
20
24
|
// Load all user graphs
|
|
@@ -22,8 +26,9 @@ export function createGraphProvider(Context) {
|
|
|
22
26
|
setBaseState((prev) => (Object.assign(Object.assign({}, prev), { isLoading: true, error: null })));
|
|
23
27
|
try {
|
|
24
28
|
const response = await SDK.getGraphs();
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
const data = unwrapSdk(response);
|
|
30
|
+
if (data) {
|
|
31
|
+
const graphsData = data;
|
|
27
32
|
let graphs = graphsData.graphs || [];
|
|
28
33
|
// Apply optional filter
|
|
29
34
|
if (graphFilter) {
|
|
@@ -35,6 +40,9 @@ export function createGraphProvider(Context) {
|
|
|
35
40
|
const validSelectedId = selectedGraphId && graphIds.includes(selectedGraphId)
|
|
36
41
|
? selectedGraphId
|
|
37
42
|
: null;
|
|
43
|
+
// Visible to setCurrentGraph before the next render: a caller that
|
|
44
|
+
// awaits refreshGraphs() and then selects the new graph must find it.
|
|
45
|
+
graphsRef.current = graphs;
|
|
38
46
|
setBaseState((prev) => {
|
|
39
47
|
// Determine which graph should be selected
|
|
40
48
|
let newCurrentGraphId = validSelectedId || (graphs.length > 0 ? graphs[0].graphId : null);
|
|
@@ -51,6 +59,9 @@ export function createGraphProvider(Context) {
|
|
|
51
59
|
return Object.assign(Object.assign({}, prev), { graphs, currentGraphId: newCurrentGraphId, isLoading: false });
|
|
52
60
|
});
|
|
53
61
|
}
|
|
62
|
+
else {
|
|
63
|
+
throw new Error('Empty response from the graph list endpoint');
|
|
64
|
+
}
|
|
54
65
|
}
|
|
55
66
|
catch (error) {
|
|
56
67
|
console.error('Failed to load graphs:', error);
|
|
@@ -60,21 +71,20 @@ export function createGraphProvider(Context) {
|
|
|
60
71
|
// Set current graph with persistence
|
|
61
72
|
const setCurrentGraph = useCallback(async (graphId) => {
|
|
62
73
|
try {
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
74
|
+
// Decide from the current graph list, not inside a state updater:
|
|
75
|
+
// React may defer the updater, which left the flag unset and
|
|
76
|
+
// skipped the API call.
|
|
77
|
+
const graph = graphsRef.current.find((g) => g.graphId === graphId);
|
|
78
|
+
// Only treat as a user graph if found and NOT a repository
|
|
79
|
+
const isUserGraph = graph ? !graph.isRepository : false;
|
|
80
|
+
setBaseState((prev) => (Object.assign(Object.assign({}, prev), { currentGraphId: graphId })));
|
|
71
81
|
// Only call selectGraph API for user graphs, not repositories
|
|
72
82
|
// Repositories can be accessed but not "selected" in the backend
|
|
73
83
|
// If graph is not found in local state, skip the API call (likely a repository)
|
|
74
84
|
if (isUserGraph) {
|
|
75
|
-
await SDK.selectGraph({
|
|
85
|
+
unwrapSdk(await SDK.selectGraph({
|
|
76
86
|
path: { graph_id: graphId },
|
|
77
|
-
});
|
|
87
|
+
}));
|
|
78
88
|
}
|
|
79
89
|
}
|
|
80
90
|
catch (error) {
|