@datalayer/agent-runtimes 1.0.17 → 1.0.19

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/lib/AgentNode.js CHANGED
@@ -5,10 +5,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  */
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
7
  import { useQueryClient } from '@tanstack/react-query';
8
- import { Box, DatalayerLogoText, DatalayerThemeProvider, getLogoColors, setupPrimerPortals, themeConfigs, } from '@datalayer/primer-addons';
8
+ import { DatalayerLogoText, DatalayerThemeProvider, getLogoColors, setupPrimerPortals, themeConfigs, } from '@datalayer/primer-addons';
9
9
  import { AppearanceControlsWithStore } from '@datalayer/primer-addons/lib/components/appearance';
10
- import { ActionList, ActionMenu, Avatar, Button, FormControl, Heading, Label, PageHeader, PageLayout, Text, } from '@primer/react';
11
- import { CommentIcon, HomeIcon, KeyAsteriskIcon, LockIcon, MoonIcon, PeopleIcon, PersonIcon, GearIcon, SignInIcon, SignOutIcon, } from '@primer/octicons-react';
10
+ import { ActionList, ActionMenu, Avatar, Box, Button, FormControl, Heading, Label, PageHeader, PageLayout, Text, } from '@primer/react';
11
+ import { CommentIcon, HomeIcon, KeyAsteriskIcon, LockIcon, MoonIcon, PeopleIcon, PersonIcon, GearIcon, RocketIcon, SignOutIcon, } from '@primer/octicons-react';
12
12
  import { SignInSimple } from '@datalayer/core/lib/views/iam';
13
13
  import { UserBadge } from '@datalayer/core/lib/views/profile';
14
14
  import { useSimpleAuthStore } from '@datalayer/core/lib/views/otel';
@@ -16,12 +16,101 @@ import { useIAMStore } from '@datalayer/core/lib/state';
16
16
  import { BillingEntitySelect, } from '@datalayer/core/lib/components/billing';
17
17
  import { ShareAccessComponent } from '@datalayer/core/lib/components/sharing';
18
18
  import { useAgentNodeThemeStore } from './agent-node/themeStore';
19
+ import { AgentNodeGallery } from './agent-node/AgentNodeGallery';
19
20
  import { Chat } from './chat';
21
+ import { DatalayerCollaborationProvider } from './collaboration';
20
22
  import '../style/primer-primitives.css';
21
23
  setupPrimerPortals();
22
24
  const AGENT_RUNTIMES_BASE_URL = (import.meta.env.VITE_DATALAYER_AGENT_RUNTIMES_URL ||
23
25
  import.meta.env.VITE_BASE_URL ||
24
26
  window.location.origin).replace(/\/$/, '');
27
+ /**
28
+ * LOCAL Jupyter sandbox endpoint shared by the node's agent and the chat's
29
+ * ephemeral notebook/document surfaces. When set (node-local dev, injected via
30
+ * `VITE_JUPYTER_SANDBOX_URL`), the surfaces bind their kernel straight to this
31
+ * Jupyter server — no proxy or tunnel — exactly like `NotebookAgentExample`.
32
+ * Parsed once into a clean base URL + token for `ServerConnection`.
33
+ */
34
+ const LOCAL_JUPYTER_SANDBOX = (() => {
35
+ const raw = String(import.meta.env.VITE_JUPYTER_SANDBOX_URL || '').trim();
36
+ if (!raw) {
37
+ return undefined;
38
+ }
39
+ try {
40
+ const url = new URL(raw);
41
+ const token = url.searchParams.get('token') || undefined;
42
+ url.searchParams.delete('token');
43
+ const baseUrl = `${url.origin}${url.pathname}`.replace(/\/$/, '');
44
+ return { baseUrl, token };
45
+ }
46
+ catch {
47
+ return { baseUrl: raw.replace(/\/$/, ''), token: undefined };
48
+ }
49
+ })();
50
+ const DEFAULT_DATALAYER_URL = 'https://prod1.datalayer.run';
51
+ /**
52
+ * localStorage key recording that the user explicitly signed out. When set, the
53
+ * UI must not silently re-authenticate from the env-supplied DATALAYER_API_KEY
54
+ * via /auth/bootstrap, so the sign-out survives a page refresh.
55
+ */
56
+ const AUTO_BOOTSTRAP_DISABLED_KEY = 'agent-node-auto-bootstrap-disabled';
57
+ /** Persist (or clear) the signed-out intent in localStorage. */
58
+ function setAutoBootstrapDisabled(disabled) {
59
+ try {
60
+ if (disabled) {
61
+ window.localStorage.setItem(AUTO_BOOTSTRAP_DISABLED_KEY, 'true');
62
+ }
63
+ else {
64
+ window.localStorage.removeItem(AUTO_BOOTSTRAP_DISABLED_KEY);
65
+ }
66
+ }
67
+ catch {
68
+ // Ignore storage failures (private mode, disabled storage, etc.).
69
+ }
70
+ }
71
+ /**
72
+ * Read a service URL from the server-injected `datalayer-config-data` script
73
+ * tag. The agent-runtimes Python server injects this tag (populated from the
74
+ * `DATALAYER_*_URL` environment variables, e.g. from `plane local`) when it
75
+ * serves the built agent pages. Reading it at runtime lets the node target the
76
+ * configured services (IAM, runtimes, ...) instead of the build-time-baked
77
+ * `VITE_DATALAYER_URL`, which defaults to production because `make build` runs
78
+ * without the local environment.
79
+ */
80
+ const getConfigUrlFromDocument = (...keys) => {
81
+ if (typeof document === 'undefined') {
82
+ return undefined;
83
+ }
84
+ const el = document.getElementById('datalayer-config-data');
85
+ if (!el?.textContent) {
86
+ return undefined;
87
+ }
88
+ try {
89
+ const config = JSON.parse(el.textContent);
90
+ for (const key of keys) {
91
+ const value = config?.[key];
92
+ if (typeof value === 'string' && value.trim()) {
93
+ return value.replace(/\/$/, '');
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // Ignore malformed config; fall back to env / default.
99
+ }
100
+ return undefined;
101
+ };
102
+ /** Resolve the base Datalayer URL: injected config → VITE env → production. */
103
+ const resolveDatalayerUrl = () => getConfigUrlFromDocument('datalayerUrl', 'iamUrl') ||
104
+ import.meta.env?.VITE_DATALAYER_URL ||
105
+ DEFAULT_DATALAYER_URL;
106
+ /** Resolve the runtimes URL: injected config → VITE env → base Datalayer URL. */
107
+ const resolveRuntimesUrl = () => getConfigUrlFromDocument('runtimesUrl') ||
108
+ import.meta.env?.VITE_DATALAYER_RUNTIMES_URL ||
109
+ resolveDatalayerUrl();
110
+ /** Resolve the spacer URL: injected config → VITE env → base Datalayer URL. */
111
+ const resolveSpacerUrl = () => getConfigUrlFromDocument('spacerUrl') ||
112
+ import.meta.env?.VITE_DATALAYER_SPACER_URL ||
113
+ resolveDatalayerUrl();
25
114
  const MODE_CARDS = [
26
115
  {
27
116
  mode: 'private',
@@ -52,7 +141,7 @@ const DEFAULT_CONFIGURATION = {
52
141
  * but assembled exclusively from core building blocks so agent-runtimes does
53
142
  * not pull in the `ui` package.
54
143
  */
55
- function AgentNodeProfileView({ token }) {
144
+ function AgentNodeProfileView({ token, onTokenExpired, }) {
56
145
  const user = useIAMStore(state => state.user);
57
146
  const display = useMemo(() => {
58
147
  if (!user)
@@ -92,7 +181,7 @@ function AgentNodeProfileView({ token }) {
92
181
  display: 'flex',
93
182
  flexDirection: 'column',
94
183
  gap: 3,
95
- }, children: !token ? (_jsx(Text, { sx: { color: 'fg.muted' }, children: "Sign in to view your profile." })) : !display ? (_jsx(Text, { sx: { color: 'fg.muted' }, children: "Loading profile\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Heading, { sx: { fontSize: 2, mb: 2 }, children: "Identity" }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 3 }, children: [display.avatarUrl ? (_jsx(Avatar, { src: display.avatarUrl, size: 72, alt: display.displayName })) : (_jsx(Box, { sx: {
184
+ }, children: !token ? (_jsx(Text, { sx: { color: 'fg.muted' }, children: "Sign in to view your profile." })) : !display ? (_jsx(Text, { sx: { color: 'fg.muted' }, children: "Loading profile\u2026" })) : (_jsxs(_Fragment, { children: [_jsx(Heading, { sx: { fontSize: 2, mb: 2 }, children: "Identity" }), _jsx(Box, { sx: { textAlign: 'left' }, children: _jsx(UserBadge, { token: token, variant: "small", onTokenExpired: onTokenExpired }) }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 3 }, children: [display.avatarUrl ? (_jsx(Avatar, { src: display.avatarUrl, size: 72, alt: display.displayName })) : (_jsx(Box, { sx: {
96
185
  width: 72,
97
186
  height: 72,
98
187
  borderRadius: '50%',
@@ -112,6 +201,7 @@ function AgentNodeProfileView({ token }) {
112
201
  export function AgentNode() {
113
202
  const { token, setAuth, clearAuth } = useSimpleAuthStore();
114
203
  const tokenForCore = token ?? undefined;
204
+ const signInLoginUrl = `${resolveDatalayerUrl()}/api/iam/v1/login`;
115
205
  const queryClient = useQueryClient();
116
206
  const iamUser = useIAMStore(state => state.user);
117
207
  const { colorMode, theme: themeVariant } = useAgentNodeThemeStore();
@@ -127,14 +217,230 @@ export function AgentNode() {
127
217
  : 'light';
128
218
  const authGradient = cfg.cardGradient[resolvedMode];
129
219
  const [step, setStep] = useState('auth');
130
- const [disableAutoBootstrap, setDisableAutoBootstrap] = useState(false);
220
+ const [selectedAgentId, setSelectedAgentId] = useState('default');
221
+ // Persisted across refreshes: once the user explicitly signs out we must
222
+ // not silently re-authenticate them from the env-supplied DATALAYER_API_KEY
223
+ // via /auth/bootstrap. Initialise from localStorage so the intent survives a
224
+ // page reload (React state alone resets to false on refresh).
225
+ const [disableAutoBootstrap, setDisableAutoBootstrap] = useState(() => {
226
+ try {
227
+ return (window.localStorage.getItem(AUTO_BOOTSTRAP_DISABLED_KEY) === 'true');
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ });
131
233
  const [configuration, setConfiguration] = useState(DEFAULT_CONFIGURATION);
132
234
  const [, setSelectedBillingEntity] = useState(undefined);
133
235
  const [isSaving, setIsSaving] = useState(false);
134
236
  const [error, setError] = useState(null);
237
+ // ── Ephemeral notebook RTC (shared room with the SaaS UI) ───────────────
238
+ // The node provisions a spacer notebook room on registration and stores its
239
+ // uid in the configuration. When present, both the node-local chat and the
240
+ // SaaS gallery view join that room so the agent's notebook edits transit via
241
+ // RTC instead of the tunnel. Absent (e.g. before sign-in) the ephemeral
242
+ // notebook stays in-memory, unchanged.
243
+ const ephemeralCollaborationDocumentId = token && configuration.collaboration_notebook_uid
244
+ ? configuration.collaboration_notebook_uid
245
+ : undefined;
246
+ const ephemeralNotebookCollaborationProvider = useMemo(() => {
247
+ if (!ephemeralCollaborationDocumentId || !token) {
248
+ return undefined;
249
+ }
250
+ const spacerUrl = resolveSpacerUrl();
251
+ return new DatalayerCollaborationProvider({
252
+ datalayerUrl: spacerUrl,
253
+ token,
254
+ });
255
+ }, [ephemeralCollaborationDocumentId, token]);
256
+ // ── Ephemeral document RTC (shared Lexical/Loro room with the SaaS UI) ────
257
+ // The node provisions a spacer *lexical* room (distinct from the notebook
258
+ // room) on registration and stores its uid as `collaboration_document_uid`.
259
+ // Both peers join that Loro room over the spacer lexical WebSocket. The token
260
+ // is embedded in the ws URL (createWebsocketProvider forwards it as a query
261
+ // param) so the spacer authenticates the connection.
262
+ const collaborationDocumentUser = useIAMStore(state => state.user);
263
+ const ephemeralDocumentCollaboration = useMemo(() => {
264
+ const documentRoomId = configuration.collaboration_document_uid;
265
+ if (!token || !documentRoomId) {
266
+ return undefined;
267
+ }
268
+ const spacerUrl = resolveSpacerUrl();
269
+ const wsSpacer = String(spacerUrl).replace(/^http/, 'ws');
270
+ const websocketUrl = `${wsSpacer}/api/spacer/v1/lexical/ws?token=${encodeURIComponent(token)}`;
271
+ const u = collaborationDocumentUser;
272
+ return {
273
+ websocketUrl,
274
+ roomId: documentRoomId,
275
+ identity: u
276
+ ? {
277
+ userId: u.uid,
278
+ handle: u.handle,
279
+ displayName: u.displayName,
280
+ initials: u.initials,
281
+ avatarUrl: u.avatarUrl,
282
+ }
283
+ : undefined,
284
+ };
285
+ }, [configuration.collaboration_document_uid, token, collaborationDocumentUser]);
135
286
  const [inferenceProvider, setInferenceProvider] = useState('datalayer');
136
287
  const [inferenceModels, setInferenceModels] = useState([]);
137
288
  const [inferenceDefaultModel, setInferenceDefaultModel] = useState(null);
289
+ const [configurationLoaded, setConfigurationLoaded] = useState(false);
290
+ const chatRestoreAttemptRef = useRef('');
291
+ const hasActiveAgent = Boolean(String(configuration.active_agent_id || '').trim());
292
+ // ── Local sandbox runtime override for the ephemeral surfaces ─────────────
293
+ // In the node-local webapp the agent's Jupyter sandbox is a LOCAL server, not
294
+ // a Kubernetes pod resolvable by `pod_name`, so the ephemeral notebook and
295
+ // document surfaces cannot bind their kernel through the runtimes pod lookup.
296
+ // Instead we read the live sandbox Jupyter endpoint from `/health/startup`
297
+ // and pass it as an explicit override so the surfaces connect a service
298
+ // manager straight to the sandbox kernel. This is what makes the surfaces
299
+ // actually render and the chat header's kernel indicator reflect the sandbox
300
+ // kernel (rather than the browser/Pyodide placeholder).
301
+ const [sandboxRuntimeOverride, setSandboxRuntimeOverride] = useState(undefined);
302
+ // Environment name shown in the chat header's kernel indicator details, so
303
+ // it reads e.g. "Local Agent Sandbox (python3)" instead of the indicator's
304
+ // generic "browser-runtime" placeholder once the local sandbox is bound.
305
+ const [sandboxEnvironmentName, setSandboxEnvironmentName] = useState(undefined);
306
+ useEffect(() => {
307
+ const selected = String(selectedAgentId || '').trim();
308
+ if (step !== 'chat' || !selected || selected === 'default') {
309
+ setSandboxRuntimeOverride(undefined);
310
+ setSandboxEnvironmentName(undefined);
311
+ return;
312
+ }
313
+ // Fast path: in node-local dev the agent's Jupyter sandbox is a LOCAL
314
+ // server whose URL/token are known up front (VITE_JUPYTER_SANDBOX_URL).
315
+ // Bind the ephemeral surfaces straight to it — no /health/startup polling,
316
+ // no proxy, no tunnel — mirroring NotebookAgentExample.
317
+ if (LOCAL_JUPYTER_SANDBOX?.baseUrl) {
318
+ setSandboxEnvironmentName('Local Agent Sandbox');
319
+ setSandboxRuntimeOverride(prev => {
320
+ if (prev?.baseUrl === LOCAL_JUPYTER_SANDBOX.baseUrl &&
321
+ (prev?.token || '') === (LOCAL_JUPYTER_SANDBOX.token || '')) {
322
+ return prev;
323
+ }
324
+ return {
325
+ baseUrl: LOCAL_JUPYTER_SANDBOX.baseUrl,
326
+ token: LOCAL_JUPYTER_SANDBOX.token || undefined,
327
+ podName: 'agent-node-sandbox',
328
+ };
329
+ });
330
+ return;
331
+ }
332
+ let cancelled = false;
333
+ let intervalId = null;
334
+ const stopPolling = () => {
335
+ if (intervalId !== null) {
336
+ window.clearInterval(intervalId);
337
+ intervalId = null;
338
+ }
339
+ };
340
+ const poll = async () => {
341
+ try {
342
+ const resp = await fetch(`${AGENT_RUNTIMES_BASE_URL}/health/startup`);
343
+ if (!resp.ok) {
344
+ return;
345
+ }
346
+ const payload = await resp.json();
347
+ const sandbox = payload?.sandbox;
348
+ const jupyterUrl = String(sandbox?.jupyter_url || '').trim();
349
+ if (sandbox?.variant !== 'jupyter' || !jupyterUrl) {
350
+ return;
351
+ }
352
+ const jupyterToken = String(sandbox?.jupyter_token || '').trim();
353
+ if (cancelled) {
354
+ return;
355
+ }
356
+ const kernelName = String(sandbox?.kernel_name || '').trim();
357
+ setSandboxEnvironmentName(kernelName
358
+ ? `Local Agent Sandbox (${kernelName})`
359
+ : 'Local Agent Sandbox');
360
+ setSandboxRuntimeOverride(prev => {
361
+ if (prev?.baseUrl === jupyterUrl &&
362
+ (prev?.token || '') === jupyterToken) {
363
+ return prev;
364
+ }
365
+ return {
366
+ baseUrl: jupyterUrl,
367
+ token: jupyterToken || undefined,
368
+ podName: 'agent-node-sandbox',
369
+ };
370
+ });
371
+ // Endpoint resolved — stop the fast poll.
372
+ stopPolling();
373
+ }
374
+ catch {
375
+ // Sandbox not ready yet; keep polling.
376
+ }
377
+ };
378
+ void poll();
379
+ intervalId = window.setInterval(() => {
380
+ void poll();
381
+ }, 2000);
382
+ return () => {
383
+ cancelled = true;
384
+ stopPolling();
385
+ };
386
+ }, [step, selectedAgentId]);
387
+ // ── Agent spec info for the chat header (title/description/suggestions) ──
388
+ // Mirrors the `ui` runtime chat view: the chat header shows the agent spec's
389
+ // display name and welcome message, and the empty state offers the spec's
390
+ // suggested prompts. The node-local agent is launched from a library spec
391
+ // whose id is used as the agent id (see AgentNodeGallery `launch`), so the
392
+ // spec is looked up by that same id via `GET /api/v1/agents/library/{id}`.
393
+ const [agentSpecInfo, setAgentSpecInfo] = useState(null);
394
+ useEffect(() => {
395
+ const agentId = String(selectedAgentId || '').trim();
396
+ if (step !== 'chat' || !agentId || agentId === 'default') {
397
+ setAgentSpecInfo(null);
398
+ return;
399
+ }
400
+ let cancelled = false;
401
+ (async () => {
402
+ try {
403
+ const resp = await fetch(`${AGENT_RUNTIMES_BASE_URL}/api/v1/agents/library/${encodeURIComponent(agentId)}`, {
404
+ headers: token ? { Authorization: `Bearer ${token}` } : undefined,
405
+ });
406
+ if (!resp.ok) {
407
+ if (!cancelled) {
408
+ setAgentSpecInfo(null);
409
+ }
410
+ return;
411
+ }
412
+ const spec = await resp.json().catch(() => null);
413
+ if (cancelled || !spec) {
414
+ return;
415
+ }
416
+ setAgentSpecInfo({
417
+ name: typeof spec.name === 'string' ? spec.name : undefined,
418
+ description: typeof spec.description === 'string' ? spec.description : undefined,
419
+ welcomeMessage: typeof spec.welcomeMessage === 'string'
420
+ ? spec.welcomeMessage
421
+ : typeof spec.welcome_message === 'string'
422
+ ? spec.welcome_message
423
+ : undefined,
424
+ suggestions: Array.isArray(spec.suggestions)
425
+ ? spec.suggestions.filter((s) => typeof s === 'string')
426
+ : undefined,
427
+ });
428
+ }
429
+ catch {
430
+ if (!cancelled) {
431
+ setAgentSpecInfo(null);
432
+ }
433
+ }
434
+ })();
435
+ return () => {
436
+ cancelled = true;
437
+ };
438
+ }, [step, selectedAgentId, token]);
439
+ const chatTitle = agentSpecInfo?.name || 'Agent Node Chat';
440
+ const chatDescription = agentSpecInfo?.welcomeMessage || agentSpecInfo?.description || 'Node-local chat';
441
+ const chatSuggestions = agentSpecInfo?.suggestions && agentSpecInfo.suggestions.length > 0
442
+ ? agentSpecInfo.suggestions.map(s => ({ title: s, message: s }))
443
+ : undefined;
138
444
  const [banner, setBanner] = useState(null);
139
445
  const bannerTimerRef = useRef(null);
140
446
  const showBanner = useCallback((kind, message) => {
@@ -158,9 +464,7 @@ export function AgentNode() {
158
464
  };
159
465
  }, []);
160
466
  const pushCredentials = useCallback((authToken, payloadToken = authToken) => {
161
- const datalayerUrl = import.meta.env?.VITE_DATALAYER_URL ||
162
- 'https://prod1.datalayer.run';
163
- const runtimesUrl = import.meta.env?.VITE_DATALAYER_RUNTIMES_URL || datalayerUrl;
467
+ const runtimesUrl = resolveRuntimesUrl();
164
468
  const headers = {
165
469
  'Content-Type': 'application/json',
166
470
  };
@@ -213,6 +517,7 @@ export function AgentNode() {
213
517
  try {
214
518
  const response = await fetch(`${AGENT_RUNTIMES_BASE_URL}/api/v1/agent-node/configuration`);
215
519
  if (!response.ok) {
520
+ setConfigurationLoaded(true);
216
521
  return;
217
522
  }
218
523
  const payload = await response.json();
@@ -221,10 +526,16 @@ export function AgentNode() {
221
526
  ...(payload?.configuration || {}),
222
527
  };
223
528
  setConfiguration(loadedConfiguration);
529
+ if (loadedConfiguration.active_agent_id) {
530
+ setSelectedAgentId(loadedConfiguration.active_agent_id);
531
+ }
224
532
  }
225
533
  catch {
226
534
  // Ignore initial-load failures in local development.
227
535
  }
536
+ finally {
537
+ setConfigurationLoaded(true);
538
+ }
228
539
  };
229
540
  load();
230
541
  }, []);
@@ -319,7 +630,7 @@ export function AgentNode() {
319
630
  }
320
631
  if (data?.has_key && data?.token) {
321
632
  setAuth(String(data.token), String(data.handle || 'api-key-user'));
322
- setStep('config');
633
+ setStep('gallery');
323
634
  }
324
635
  }
325
636
  catch {
@@ -343,10 +654,10 @@ export function AgentNode() {
343
654
  }, [token, pushCredentials]);
344
655
  useEffect(() => {
345
656
  import('@datalayer/core/lib/state').then(({ iamStore, coreStore }) => {
346
- const datalayerUrl = import.meta.env?.VITE_DATALAYER_URL ||
347
- 'https://prod1.datalayer.run';
348
- const runtimesUrl = import.meta.env?.VITE_DATALAYER_RUNTIMES_URL || datalayerUrl;
349
- const aiInferenceUrl = import.meta.env?.VITE_DATALAYER_AI_INFERENCE_URL ||
657
+ const datalayerUrl = resolveDatalayerUrl();
658
+ const runtimesUrl = resolveRuntimesUrl();
659
+ const aiInferenceUrl = getConfigUrlFromDocument('aiInferenceUrl') ||
660
+ import.meta.env?.VITE_DATALAYER_AI_INFERENCE_URL ||
350
661
  datalayerUrl;
351
662
  // Seed all per-service URLs to match the main UI login behavior.
352
663
  const coreApi = coreStore.getState();
@@ -387,11 +698,60 @@ export function AgentNode() {
387
698
  setStep('auth');
388
699
  }
389
700
  }, [token, step]);
701
+ useEffect(() => {
702
+ if (!token || !configurationLoaded) {
703
+ return;
704
+ }
705
+ const activeAgentId = (configuration.active_agent_id || '').trim();
706
+ if (!activeAgentId) {
707
+ return;
708
+ }
709
+ const restoreKey = `${activeAgentId}`;
710
+ if (chatRestoreAttemptRef.current === restoreKey) {
711
+ return;
712
+ }
713
+ chatRestoreAttemptRef.current = restoreKey;
714
+ let cancelled = false;
715
+ (async () => {
716
+ try {
717
+ const response = await fetch(`${AGENT_RUNTIMES_BASE_URL}/api/v1/agents`, {
718
+ headers: {
719
+ Authorization: `Bearer ${token}`,
720
+ },
721
+ });
722
+ if (!response.ok) {
723
+ return;
724
+ }
725
+ const payload = await response.json().catch(() => null);
726
+ if (cancelled) {
727
+ return;
728
+ }
729
+ const agents = Array.isArray(payload)
730
+ ? payload
731
+ : payload?.agents || payload?.items || [];
732
+ const running = agents.some(agent => {
733
+ const id = String(agent?.agent_id || agent?.id || '').trim();
734
+ return id === activeAgentId;
735
+ });
736
+ if (running) {
737
+ setSelectedAgentId(activeAgentId);
738
+ setConfiguration(prev => prev.mode === 'private' ? prev : { ...prev, mode: 'private' });
739
+ setStep('chat');
740
+ }
741
+ }
742
+ catch {
743
+ // Best-effort restore only.
744
+ }
745
+ })();
746
+ return () => {
747
+ cancelled = true;
748
+ };
749
+ }, [token, configurationLoaded, configuration.active_agent_id]);
390
750
  useEffect(() => {
391
751
  // If a persisted session is already authenticated on first load,
392
- // skip the auth screen and land on the main configuration view.
752
+ // skip the auth screen and land on the agents gallery.
393
753
  if (token && step === 'auth') {
394
- setStep('config');
754
+ setStep('gallery');
395
755
  }
396
756
  }, [token, step]);
397
757
  useEffect(() => {
@@ -399,16 +759,21 @@ export function AgentNode() {
399
759
  setStep('config');
400
760
  }
401
761
  }, [step, configuration.mode]);
762
+ useEffect(() => {
763
+ if (step === 'chat' && !hasActiveAgent) {
764
+ setStep('gallery');
765
+ }
766
+ }, [step, hasActiveAgent]);
402
767
  const handleSignIn = (newToken, handle) => {
768
+ setAutoBootstrapDisabled(false);
403
769
  setDisableAutoBootstrap(false);
404
770
  setAuth(newToken, handle);
405
- setStep('config');
771
+ setStep('gallery');
406
772
  };
407
773
  // API keys are exchanged for a session token before login so billing and
408
774
  // plans endpoints (/api/iam/v1/plans/*) resolve the correct paid plan.
409
775
  const handleApiKeySignIn = async (apiKey) => {
410
- const datalayerUrl = import.meta.env?.VITE_DATALAYER_URL ||
411
- 'https://prod1.datalayer.run';
776
+ const datalayerUrl = resolveDatalayerUrl();
412
777
  try {
413
778
  const resp = await fetch(`${datalayerUrl}/api/iam/v1/login`, {
414
779
  method: 'POST',
@@ -428,6 +793,9 @@ export function AgentNode() {
428
793
  }
429
794
  };
430
795
  const handleSignOut = () => {
796
+ // Persist the signed-out intent so a refresh does not re-bootstrap the
797
+ // session from the env-supplied DATALAYER_API_KEY.
798
+ setAutoBootstrapDisabled(true);
431
799
  setDisableAutoBootstrap(true);
432
800
  // Clear backend fallback credentials while still authenticated.
433
801
  void pushCredentials(token, null).catch(() => {
@@ -467,9 +835,12 @@ export function AgentNode() {
467
835
  ...(payload?.configuration || {}),
468
836
  };
469
837
  setConfiguration(saved);
838
+ if (saved.active_agent_id) {
839
+ setSelectedAgentId(saved.active_agent_id);
840
+ }
470
841
  showBanner('success', 'Agent Node configuration saved.');
471
842
  if (saved.mode === 'private') {
472
- setStep('chat');
843
+ setStep('gallery');
473
844
  }
474
845
  }
475
846
  catch (reason) {
@@ -486,8 +857,11 @@ export function AgentNode() {
486
857
  return true;
487
858
  if (!token)
488
859
  return false;
489
- if (nextStep === 'chat')
490
- return configuration.mode === 'private';
860
+ if (nextStep === 'gallery')
861
+ return true;
862
+ if (nextStep === 'chat') {
863
+ return configuration.mode === 'private' && hasActiveAgent;
864
+ }
491
865
  return true;
492
866
  };
493
867
  const StepEntry = ({ entryStep, label, leadingVisual, }) => {
@@ -508,21 +882,13 @@ export function AgentNode() {
508
882
  minHeight: '100vh',
509
883
  bg: 'canvas.default',
510
884
  color: 'fg.default',
511
- }, children: _jsxs(PageLayout, { containerWidth: "full", padding: "normal", sx: { bg: 'canvas.default', color: 'fg.default' }, style: { minHeight: '100vh', overflow: 'visible' }, children: [_jsx(PageLayout.Header, { children: _jsx(PageHeader, { children: _jsx(PageHeader.Actions, { children: _jsxs(Box, { sx: {
512
- display: 'flex',
513
- alignItems: 'center',
514
- gap: 3,
515
- fontSize: 2,
516
- lineHeight: '22px',
517
- }, children: [_jsx(Box, { sx: { display: 'flex', alignItems: 'center', gap: 2 }, children: token && (_jsxs(_Fragment, { children: [_jsx(StepEntry, { entryStep: "chat", label: "Chat", leadingVisual: CommentIcon }), _jsx(StepEntry, { entryStep: "config", label: "Configuration", leadingVisual: GearIcon })] })) }), _jsx(AppearanceControlsWithStore, { useStore: useAgentNodeThemeStore }), !token ? (_jsx(Button, { size: "small", variant: "invisible", onClick: () => setStep('auth'), leadingVisual: SignInIcon, sx: { color: 'fg.muted' }, children: "Sign in" })) : (_jsxs(Box, { sx: {
518
- display: 'inline-flex',
519
- alignItems: 'center',
520
- gap: 1,
521
- }, children: [_jsx(Box, { sx: {
522
- textAlign: 'left',
523
- display: 'inline-flex',
524
- alignItems: 'center',
525
- }, children: _jsx(UserBadge, { token: token, variant: "small", onTokenExpired: handleSignOut }) }), _jsx(StepEntry, { entryStep: "profile", label: "Profile", leadingVisual: PersonIcon }), _jsx(Button, { size: "small", variant: "invisible", onClick: handleSignOut, leadingVisual: SignOutIcon, sx: { color: 'fg.default' }, children: "Sign out" })] })), _jsx(Box, { as: "a", href: "https://datalayer.ai", target: "_blank", rel: "noopener noreferrer", "aria-label": "Open Datalayer website", sx: { display: 'inline-flex', alignItems: 'center' }, children: _jsx(DatalayerLogoText, { size: 24, variant: themeVariant, colorMode: colorMode, primaryColor: logoColors.primary, secondaryColor: logoColors.secondary, textColor: logoColors.textColor, primaryGradient: logoColors.primaryGradient, secondaryGradient: logoColors.secondaryGradient, gradient: true }) })] }) }) }) }), _jsxs(PageLayout.Content, { children: [_jsx(Box, { "aria-live": "polite", sx: {
885
+ }, children: _jsxs(PageLayout, { containerWidth: "full", padding: "normal", sx: { bg: 'canvas.default', color: 'fg.default' }, style: { minHeight: '100vh', overflow: 'visible' }, children: [_jsx(PageLayout.Header, { children: _jsxs(PageHeader, { children: [_jsx(PageHeader.TitleArea, { children: _jsx(PageHeader.Title, { children: _jsx(Box, { as: "a", href: "https://datalayer.ai", target: "_blank", rel: "noopener noreferrer", "aria-label": "Open Datalayer website", sx: { display: 'inline-flex', alignItems: 'center' }, children: _jsx(DatalayerLogoText, { size: 24, inverse: true, variant: themeVariant, colorMode: colorMode, primaryColor: logoColors.primary, secondaryColor: logoColors.secondary, textColor: logoColors.textColor, primaryGradient: logoColors.primaryGradient, secondaryGradient: logoColors.secondaryGradient, gradient: true }) }) }) }), _jsx(PageHeader.Actions, { children: _jsxs(Box, { sx: {
886
+ display: 'flex',
887
+ alignItems: 'center',
888
+ gap: 2,
889
+ fontSize: 2,
890
+ lineHeight: '22px',
891
+ }, children: [token && (_jsxs(_Fragment, { children: [_jsx(StepEntry, { entryStep: "chat", label: "Chat", leadingVisual: CommentIcon }), _jsx(StepEntry, { entryStep: "gallery", label: "Agents", leadingVisual: RocketIcon }), _jsx(StepEntry, { entryStep: "config", label: "Configuration", leadingVisual: GearIcon })] })), !token ? null : (_jsxs(_Fragment, { children: [_jsx(StepEntry, { entryStep: "profile", label: "Profile", leadingVisual: PersonIcon }), _jsx(Button, { size: "small", variant: "invisible", onClick: handleSignOut, leadingVisual: SignOutIcon, sx: { color: 'fg.default' }, children: "Sign out" })] })), _jsx(AppearanceControlsWithStore, { useStore: useAgentNodeThemeStore })] }) })] }) }), _jsxs(PageLayout.Content, { children: [_jsx(Box, { "aria-live": "polite", sx: {
526
892
  position: 'fixed',
527
893
  top: 0,
528
894
  left: 0,
@@ -560,7 +926,7 @@ export function AgentNode() {
560
926
  ? 'opacity 600ms ease, transform 700ms cubic-bezier(0.16, 1, 0.3, 1)'
561
927
  : 'opacity 250ms ease, transform 350ms cubic-bezier(0.16, 1, 0.3, 1)',
562
928
  textAlign: 'center',
563
- }, children: _jsx(Text, { sx: { fontSize: 3, fontWeight: 'bold' }, children: banner?.message ?? '' }) }) }), _jsxs(Box, { sx: { mb: 3 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 2, mb: 1 }, children: [_jsx(Box, { sx: { color: 'fg.muted', display: 'inline-flex' }, children: _jsx(KeyAsteriskIcon, { size: 18 }) }), _jsx(Heading, { sx: { fontSize: 3, m: 0 }, children: "Agent Node" })] }), _jsx(Text, { sx: { color: 'fg.muted' }, children: "Authenticate, configure Private/Shared/Sleep mode, then chat from this node." })] }), step === 'auth' && (_jsx(Box, { sx: {
929
+ }, children: _jsx(Text, { sx: { fontSize: 3, fontWeight: 'bold' }, children: banner?.message ?? '' }) }) }), !token && (_jsxs(Box, { sx: { mb: 3 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 2, mb: 1 }, children: [_jsx(Box, { sx: { color: 'fg.muted', display: 'inline-flex' }, children: _jsx(KeyAsteriskIcon, { size: 18 }) }), _jsx(Heading, { sx: { fontSize: 3, m: 0 }, children: "Agent Node" })] }), _jsx(Text, { sx: { color: 'fg.muted' }, children: "Authenticate, configure Private/Shared/Sleep mode, then chat from this node." })] })), step === 'auth' && (_jsx(Box, { sx: {
564
930
  border: '1px solid',
565
931
  borderColor: 'border.default',
566
932
  borderTop: '3px solid',
@@ -582,13 +948,17 @@ export function AgentNode() {
582
948
  '& > div > div': {
583
949
  width: '100%',
584
950
  bg: 'canvas.default',
951
+ border: '1px solid',
585
952
  borderColor: `${cfg.brandColor}66`,
586
953
  boxShadow: `0 0 0 1px ${cfg.brandColor}2B`,
954
+ borderRadius: 2,
955
+ px: [3, 4],
956
+ py: [3, 4],
587
957
  },
588
958
  '& h2, & h3': {
589
959
  color: cfg.brandColor,
590
960
  },
591
- }, children: _jsx(SignInSimple, { onSignIn: handleSignIn, onApiKeySignIn: handleApiKeySignIn, title: "Agent Node", description: "Sign in to configure node settings and run authenticated chat sessions.", leadingIcon: _jsx(Box, { sx: { color: cfg.brandColor, display: 'inline-flex' }, children: _jsx(HomeIcon, { size: 24 }) }) }) }) })), step === 'config' && (_jsxs(Box, { sx: {
961
+ }, children: _jsx(SignInSimple, { loginUrl: signInLoginUrl, onSignIn: handleSignIn, onApiKeySignIn: handleApiKeySignIn, title: "Agent Node", description: "Sign in to configure node settings and run authenticated chat sessions.", leadingIcon: _jsx(Box, { sx: { color: cfg.brandColor, display: 'inline-flex' }, children: _jsx(HomeIcon, { size: 24 }) }) }) }) })), step === 'config' && (_jsxs(Box, { sx: {
592
962
  border: '1px solid',
593
963
  borderColor: 'border.default',
594
964
  borderRadius: 2,
@@ -667,11 +1037,40 @@ export function AgentNode() {
667
1037
  display: 'flex',
668
1038
  flexDirection: 'column',
669
1039
  gap: 2,
670
- }, children: [_jsx(Heading, { sx: { fontSize: 2, m: 0 }, children: "Share" }), _jsx(ShareAccessComponent, { isOpen: true, displayMode: "inline", requestUrl: `${AGENT_RUNTIMES_BASE_URL}/api/v1/agent-node/sharing`, resourceLabel: "Agent Node", resourceName: "this Agent Node", onClose: handleSharingInlineClose })] })] }), error && _jsx(Text, { sx: { color: 'danger.fg' }, children: error }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "primary", onClick: saveConfiguration, disabled: isSaving, children: isSaving ? 'Saving...' : 'Save' }) })] })), step === 'profile' && _jsx(AgentNodeProfileView, { token: token }), step === 'chat' && (_jsx(Box, { sx: {
1040
+ }, children: [_jsx(Heading, { sx: { fontSize: 2, m: 0 }, children: "Share" }), _jsx(ShareAccessComponent, { isOpen: true, displayMode: "inline", requestUrl: `${AGENT_RUNTIMES_BASE_URL}/api/v1/agent-node/sharing`, resourceLabel: "Agent Node", resourceName: "this Agent Node", onClose: handleSharingInlineClose })] })] }), error && _jsx(Text, { sx: { color: 'danger.fg' }, children: error }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "primary", onClick: saveConfiguration, disabled: isSaving, children: isSaving ? 'Saving...' : 'Save' }) })] })), step === 'profile' && (_jsx(AgentNodeProfileView, { token: token, onTokenExpired: handleSignOut })), step === 'gallery' && (_jsx(AgentNodeGallery, { baseUrl: AGENT_RUNTIMES_BASE_URL, token: token, activeAgentId: configuration.active_agent_id, onLaunchError: message => {
1041
+ showBanner('error', message);
1042
+ }, onLaunched: agentId => {
1043
+ setSelectedAgentId(agentId);
1044
+ const nextConfiguration = {
1045
+ ...configuration,
1046
+ active_agent_id: agentId,
1047
+ mode: 'private',
1048
+ };
1049
+ setConfiguration(nextConfiguration);
1050
+ // Persist mode=private so Chat stays enabled and the node
1051
+ // advertises the active agent state to the sync loop.
1052
+ void fetch(`${AGENT_RUNTIMES_BASE_URL}/api/v1/agent-node/configuration`, {
1053
+ method: 'POST',
1054
+ headers: {
1055
+ 'Content-Type': 'application/json',
1056
+ },
1057
+ body: JSON.stringify(nextConfiguration),
1058
+ }).catch(() => {
1059
+ // Best-effort persistence; local state already switched.
1060
+ });
1061
+ setStep('chat');
1062
+ }, onTerminated: () => {
1063
+ setSelectedAgentId('default');
1064
+ setConfiguration(prev => ({
1065
+ ...prev,
1066
+ active_agent_id: undefined,
1067
+ }));
1068
+ setStep('gallery');
1069
+ } })), step === 'chat' && (_jsx(Box, { sx: {
671
1070
  border: '1px solid',
672
1071
  borderColor: 'border.default',
673
1072
  borderRadius: 2,
674
1073
  overflow: 'hidden',
675
- }, children: _jsx(Chat, { protocol: "ag-ui", baseUrl: AGENT_RUNTIMES_BASE_URL, agentId: "default", title: "Agent Node Chat", placeholder: "Send a message...", description: "Node-local chat", showHeader: true, height: '70vh', showModelSelector: true, showToolsMenu: true, showSkillsMenu: true, showTokenUsage: true, showInformation: true, autoFocus: true, runtimeId: "default", historyEndpoint: `${AGENT_RUNTIMES_BASE_URL}/api/v1/history` }) }))] })] }) }) }));
1074
+ }, children: _jsx(Chat, { protocol: "ag-ui", baseUrl: AGENT_RUNTIMES_BASE_URL, agentId: selectedAgentId, title: chatTitle, placeholder: "Send a message...", description: chatDescription, suggestions: chatSuggestions, submitOnSuggestionClick: true, showHeader: true, height: '70vh', showModelSelector: true, showToolsMenu: true, showSkillsMenu: true, showTokenUsage: true, showInformation: true, autoFocus: true, enableEphemeralNotebook: true, enableEphemeralDocument: true, initialEphemeralSurfaceMode: "notebook", runtimeId: selectedAgentId, ephemeralRuntimeOverride: sandboxRuntimeOverride, kernelEnvironmentName: sandboxEnvironmentName, ephemeralNotebookCollaborationProvider: ephemeralNotebookCollaborationProvider, ephemeralNotebookCollaborationDocumentId: ephemeralCollaborationDocumentId, ephemeralDocumentCollaboration: ephemeralDocumentCollaboration, historyEndpoint: `${AGENT_RUNTIMES_BASE_URL}/api/v1/history` }) }))] })] }) }) }));
676
1075
  }
677
1076
  export default AgentNode;