@datalayer/core 1.2.22 → 1.2.24

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/README.md CHANGED
@@ -100,10 +100,11 @@ Use `agent-runtimes` for runtime workloads (`RuntimeClient`, `@datalayer`, snaps
100
100
 
101
101
  ## Examples
102
102
 
103
- Examples have moved to Agent Runtimes:
103
+ The [examples](./examples/README.md) show the Datalayer Client at work:
104
104
 
105
- - [Agent Runtimes examples README](https://github.com/datalayer/agent-runtimes/blob/main/examples/README.md)
106
- - [Agent Runtimes examples directory](https://github.com/datalayer/agent-runtimes/tree/main/examples)
105
+ - [OpenTelemetry observability](./examples/otel/README.md): instrument Datalayer usage with OpenTelemetry traces and metrics, with a small FastAPI service and a React UI to visualize them (`make` targets in [examples/otel](./examples/otel)).
106
+
107
+ For agents, chats and sandboxes built on the client, see the [Agent Runtimes examples](https://github.com/datalayer/agent-runtimes/tree/main/examples).
107
108
 
108
109
  ## Platform Integration
109
110
 
@@ -1,4 +1,3 @@
1
- import { AxiosRequestConfig } from 'axios';
2
1
  /**
3
2
  * Error wrapper for failed HTTP responses.
4
3
  * Includes response details, warnings, errors, and tracebacks.
@@ -44,15 +43,15 @@ export declare class RunResponseError extends Error {
44
43
  }
45
44
  /**
46
45
  * Error wrapper for network failures.
47
- * Thrown when HTTP request fails due to connectivity issues.
46
+ * Thrown when `fetch` could not reach the server at all.
48
47
  */
49
48
  export declare class NetworkError extends TypeError {
50
49
  /**
51
- * Creates a NetworkError from the original TypeError.
50
+ * Creates a NetworkError from the error `fetch` rejected with.
52
51
  *
53
52
  * @param original - The original network error
54
53
  */
55
- constructor(original: TypeError);
54
+ constructor(original: Error);
56
55
  }
57
56
  /**
58
57
  * Thrown when a request is made with a JWT that has already expired.
@@ -85,6 +84,15 @@ export declare class TokenExpiredError extends Error {
85
84
  readonly url: string;
86
85
  constructor(url: string, expiredAt: number);
87
86
  }
87
+ /**
88
+ * How the response body is read.
89
+ *
90
+ * `json` (the default) parses the body as JSON and falls back to the text
91
+ * when it does not parse, so an empty body or a plain-text answer still
92
+ * arrives as data rather than an error. `stream` hands back the body's
93
+ * `ReadableStream` unread.
94
+ */
95
+ export type DatalayerAPIResponseType = 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream';
88
96
  /**
89
97
  * Options for Datalayer API requests.
90
98
  */
@@ -93,7 +101,7 @@ export interface IRequestDatalayerAPIOptions {
93
101
  url: string;
94
102
  /** HTTP method (GET, POST, PUT, DELETE, etc.) */
95
103
  method?: string;
96
- /** Request body (JSON object or FormData) */
104
+ /** Request body (JSON object, FormData, string, or raw bytes) */
97
105
  body?: any;
98
106
  /** Custom HTTP headers */
99
107
  headers?: Record<string, string>;
@@ -101,8 +109,8 @@ export interface IRequestDatalayerAPIOptions {
101
109
  token?: string;
102
110
  /** AbortSignal for request cancellation */
103
111
  signal?: AbortSignal;
104
- /** Axios response representation for binary or streaming transports. */
105
- responseType?: AxiosRequestConfig['responseType'];
112
+ /** How to read the body; `json` when omitted. */
113
+ responseType?: DatalayerAPIResponseType;
106
114
  }
107
115
  export interface IDatalayerAPIResponse<T> {
108
116
  data: T;
@@ -118,6 +126,7 @@ export interface IDatalayerAPIResponse<T> {
118
126
  * @returns Promise resolving to response data
119
127
  * @throws {NetworkError} On network failures
120
128
  * @throws {RunResponseError} On HTTP error responses
129
+ * @throws {TokenExpiredError} On a 401 explained by an expired token
121
130
  *
122
131
  * @example
123
132
  * ```typescript
@@ -9,7 +9,6 @@
9
9
  * @module api/DatalayerApi
10
10
  */
11
11
  import { URLExt } from '@jupyterlab/coreutils';
12
- import axios from 'axios';
13
12
  import { sleep } from '../utils/Sleep.js';
14
13
  import { getJwtExpiryMs, isJwtExpired } from '../utils/Jwt.js';
15
14
  function isFormDataBody(body) {
@@ -106,11 +105,11 @@ export class RunResponseError extends Error {
106
105
  }
107
106
  /**
108
107
  * Error wrapper for network failures.
109
- * Thrown when HTTP request fails due to connectivity issues.
108
+ * Thrown when `fetch` could not reach the server at all.
110
109
  */
111
110
  export class NetworkError extends TypeError {
112
111
  /**
113
- * Creates a NetworkError from the original TypeError.
112
+ * Creates a NetworkError from the error `fetch` rejected with.
114
113
  *
115
114
  * @param original - The original network error
116
115
  */
@@ -189,6 +188,80 @@ function throwIfTokenExpired(status, token, url) {
189
188
  }
190
189
  throw new TokenExpiredError(url, getJwtExpiryMs(token) ?? Date.now());
191
190
  }
191
+ /**
192
+ * Bodies `fetch` sends as they are. Everything else is a JSON value and is
193
+ * stringified.
194
+ */
195
+ function isRawBody(body) {
196
+ return (typeof body === 'string' ||
197
+ (typeof Blob !== 'undefined' && body instanceof Blob) ||
198
+ body instanceof ArrayBuffer ||
199
+ ArrayBuffer.isView(body) ||
200
+ (typeof URLSearchParams !== 'undefined' &&
201
+ body instanceof URLSearchParams) ||
202
+ (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream));
203
+ }
204
+ /**
205
+ * The response headers as a plain record, keyed in lower case the way
206
+ * `Headers` reports them.
207
+ */
208
+ function headersToRecord(headers) {
209
+ const record = {};
210
+ headers.forEach((value, name) => {
211
+ record[name] = value;
212
+ });
213
+ return record;
214
+ }
215
+ /**
216
+ * Reads the body the way `responseType` asks for.
217
+ */
218
+ async function readBody(response, responseType) {
219
+ switch (responseType) {
220
+ case 'text':
221
+ return response.text();
222
+ case 'blob':
223
+ return response.blob();
224
+ case 'arraybuffer':
225
+ return response.arrayBuffer();
226
+ case 'stream':
227
+ return response.body;
228
+ default: {
229
+ const text = await response.text();
230
+ try {
231
+ return JSON.parse(text);
232
+ }
233
+ catch {
234
+ return text;
235
+ }
236
+ }
237
+ }
238
+ }
239
+ /**
240
+ * Sends one request and returns the response when the server accepted it.
241
+ *
242
+ * A failure to reach the server at all arrives as `NetworkError`; an abort
243
+ * through the caller's signal is rethrown as it came, since the caller asked
244
+ * for it. A non-2xx answer becomes `TokenExpiredError` when a 401 is explained
245
+ * by the token we sent, and `RunResponseError` otherwise.
246
+ */
247
+ async function send(url, init, token) {
248
+ let response;
249
+ try {
250
+ response = await fetch(url, init);
251
+ }
252
+ catch (error) {
253
+ // By name: a DOMException is not an `Error` in every realm.
254
+ if (error?.name === 'AbortError') {
255
+ throw error;
256
+ }
257
+ throw new NetworkError(error instanceof Error ? error : new TypeError(String(error)));
258
+ }
259
+ if (!response.ok) {
260
+ throwIfTokenExpired(response.status, token, url);
261
+ throw await RunResponseError.create(response);
262
+ }
263
+ return response;
264
+ }
192
265
  /**
193
266
  * Makes authenticated HTTP requests to Datalayer APIs.
194
267
  * Handles JSON and FormData, includes auth headers, and manages redirects.
@@ -197,6 +270,7 @@ function throwIfTokenExpired(status, token, url) {
197
270
  * @returns Promise resolving to response data
198
271
  * @throws {NetworkError} On network failures
199
272
  * @throws {RunResponseError} On HTTP error responses
273
+ * @throws {TokenExpiredError} On a 401 explained by an expired token
200
274
  *
201
275
  * @example
202
276
  * ```typescript
@@ -208,147 +282,109 @@ function throwIfTokenExpired(status, token, url) {
208
282
  * ```
209
283
  */
210
284
  export async function requestDatalayerAPIWithResponse({ url, method, body, token, signal, responseType, headers = {}, }) {
211
- // Handle FormData differently from JSON
212
- const isFormData = isFormDataBody(body);
213
- // Prepare axios config
214
- const axiosConfig = {
215
- url,
216
- method: (method ?? 'GET'),
217
- headers: { ...headers },
218
- withCredentials: true, // equivalent to credentials: 'include'
219
- signal,
220
- responseType,
221
- // CORS mode is handled automatically by axios
222
- // Cache control headers
223
- };
224
- // In Vitest+jsdom, axios may pick the XHR adapter and fail with browser-like
225
- // network restrictions. Force the fetch adapter for integration reliability.
226
- if (typeof process !== 'undefined' && process.env.VITEST) {
227
- axiosConfig.adapter = 'fetch';
228
- }
229
- // Add cache control headers only for GET requests (equivalent to cache: 'no-store')
230
- if (method === 'GET' || !method) {
231
- if (!axiosConfig.headers['Cache-Control']) {
232
- axiosConfig.headers['Cache-Control'] =
233
- 'no-store, no-cache, must-revalidate';
285
+ const requestMethod = method ?? 'GET';
286
+ const requestHeaders = new Headers(headers);
287
+ // A GET must not be answered from a cache (the equivalent of
288
+ // `cache: 'no-store'`, said in headers so proxies hear it too).
289
+ if (requestMethod === 'GET') {
290
+ if (!requestHeaders.has('Cache-Control')) {
291
+ requestHeaders.set('Cache-Control', 'no-store, no-cache, must-revalidate');
234
292
  }
235
- if (!axiosConfig.headers['Pragma']) {
236
- axiosConfig.headers['Pragma'] = 'no-cache';
293
+ if (!requestHeaders.has('Pragma')) {
294
+ requestHeaders.set('Pragma', 'no-cache');
237
295
  }
238
296
  }
239
297
  if (token) {
240
- axiosConfig.headers['Authorization'] = `Bearer ${token}`;
298
+ requestHeaders.set('Authorization', `Bearer ${token}`);
241
299
  }
242
- if (isFormData) {
243
- // For FormData: let axios handle Content-Type automatically
244
- axiosConfig.data = body;
245
- // Don't set Content-Type - axios will set multipart/form-data with boundary
246
- if (!axiosConfig.headers['Accept']) {
247
- axiosConfig.headers['Accept'] = 'application/json';
248
- }
300
+ if (!requestHeaders.has('Accept')) {
301
+ requestHeaders.set('Accept', 'application/json');
302
+ }
303
+ let requestBody;
304
+ if (isFormDataBody(body)) {
305
+ // No Content-Type: the browser sets multipart/form-data with its
306
+ // boundary.
307
+ requestBody = body;
249
308
  }
250
309
  else {
251
- // For regular JSON requests
252
- if (!axiosConfig.headers['Accept']) {
253
- axiosConfig.headers['Accept'] = 'application/json';
310
+ if (!requestHeaders.has('Content-Type')) {
311
+ requestHeaders.set('Content-Type', 'application/json');
254
312
  }
255
- if (!axiosConfig.headers['Content-Type']) {
256
- axiosConfig.headers['Content-Type'] = 'application/json';
313
+ if (body === undefined) {
314
+ requestBody = undefined;
257
315
  }
258
- axiosConfig.data = body;
259
- }
260
- try {
261
- const response = await axios(axiosConfig);
262
- if (response.status < 300) {
263
- // Handle redirections if needed.
264
- if (response.status === 202 && response.headers.location) {
265
- const data = await handleAxiosRedirection(response, axiosConfig);
266
- return {
267
- data: data,
268
- status: response.status,
269
- statusText: response.statusText,
270
- headers: { ...response.headers },
271
- };
272
- }
316
+ else if (isRawBody(body)) {
317
+ requestBody = body;
273
318
  }
274
319
  else {
275
- throwIfTokenExpired(response.status, token, url);
276
- const adaptedResponse = {
277
- ok: false,
278
- status: response.status,
279
- statusText: response.statusText,
280
- json: async () => response?.data,
281
- text: async () => JSON.stringify(response?.data),
282
- };
283
- throw await RunResponseError.create(adaptedResponse);
320
+ requestBody = JSON.stringify(body);
284
321
  }
285
- return {
286
- data: response.data,
287
- status: response.status,
288
- statusText: response.statusText,
289
- headers: { ...response.headers },
290
- };
291
322
  }
292
- catch (error) {
293
- if (axios.isAxiosError(error)) {
294
- if (error.response) {
295
- throwIfTokenExpired(error.response.status, token, url);
296
- // Convert axios error to our RunResponseError format
297
- const adaptedResponse = {
298
- ok: false,
299
- status: error.response.status,
300
- statusText: error.response.statusText,
301
- json: async () => error.response?.data,
302
- text: async () => JSON.stringify(error.response?.data),
303
- };
304
- throw await RunResponseError.create(adaptedResponse);
305
- }
306
- throw new NetworkError(error);
307
- }
308
- throw error;
323
+ const init = {
324
+ method: requestMethod,
325
+ headers: requestHeaders,
326
+ body: requestBody,
327
+ // Sends the browser's cookies along, cross-origin included.
328
+ credentials: 'include',
329
+ signal,
330
+ };
331
+ let response = await send(url, init, token);
332
+ // A 202 with a Location is the server saying the work is still running
333
+ // there: poll it until it is done.
334
+ if (response.status === 202 && response.headers.get('location')) {
335
+ response = await followAsyncRedirect(response, init, url, token);
309
336
  }
337
+ return {
338
+ data: (await readBody(response, responseType)),
339
+ status: response.status,
340
+ statusText: response.statusText,
341
+ headers: headersToRecord(response.headers),
342
+ };
310
343
  }
311
344
  export async function requestDatalayerAPI(options) {
312
345
  return (await requestDatalayerAPIWithResponse(options)).data;
313
346
  }
314
- async function handleAxiosRedirection(response, originalConfig) {
315
- let redirect = response.headers.location;
316
- if (redirect) {
317
- const baseUrl = originalConfig.url ?? '';
318
- const normalizedRedirect = String(redirect).replace(/^([a-z][a-z0-9+.-]*):\/(?!\/)/i, '$1://');
319
- try {
320
- const resolved = new URL(normalizedRedirect, baseUrl);
321
- const base = new URL(baseUrl, typeof window !== 'undefined' ? window.location.origin : undefined);
322
- // If a proxy emits an http Location for the same host while the
323
- // original request is https, force https to avoid mixed-content errors
324
- // that browsers often report as CORS/network failures.
325
- if (base.protocol === 'https:' &&
326
- resolved.protocol === 'http:' &&
327
- resolved.hostname === base.hostname) {
328
- resolved.protocol = 'https:';
329
- if (resolved.port === '80') {
330
- resolved.port = '';
331
- }
347
+ /**
348
+ * Resolves the Location of a 202 against the request's URL.
349
+ *
350
+ * A proxy that emits an http Location for the same host while the original
351
+ * request was https gets it forced back to https, since the browser would
352
+ * refuse the mixed content and report it as a CORS or network failure.
353
+ */
354
+ function resolveLocation(location, baseUrl) {
355
+ const normalized = location.replace(/^([a-z][a-z0-9+.-]*):\/(?!\/)/i, '$1://');
356
+ try {
357
+ const resolved = new URL(normalized, baseUrl);
358
+ const base = new URL(baseUrl, typeof window !== 'undefined' ? window.location.origin : undefined);
359
+ if (base.protocol === 'https:' &&
360
+ resolved.protocol === 'http:' &&
361
+ resolved.hostname === base.hostname) {
362
+ resolved.protocol = 'https:';
363
+ if (resolved.port === '80') {
364
+ resolved.port = '';
332
365
  }
333
- redirect = resolved.toString();
334
- }
335
- catch {
336
- const parsedURL = URLExt.parse(baseUrl);
337
- const fallbackBase = parsedURL.protocol + '//' + parsedURL.hostname;
338
- redirect = URLExt.join(fallbackBase, normalizedRedirect);
339
366
  }
367
+ return resolved.toString();
368
+ }
369
+ catch {
370
+ const parsedURL = URLExt.parse(baseUrl);
371
+ const fallbackBase = parsedURL.protocol + '//' + parsedURL.hostname;
372
+ return URLExt.join(fallbackBase, normalized);
340
373
  }
374
+ }
375
+ /**
376
+ * Polls the Location of a 202 with GETs, backing off from a second and
377
+ * doubling, until the answer is no longer a 202. The polls carry the
378
+ * original headers and credentials, never the body.
379
+ */
380
+ async function followAsyncRedirect(response, init, url, token) {
381
+ const location = resolveLocation(response.headers.get('location'), url);
382
+ const pollInit = { ...init, method: 'GET', body: undefined };
341
383
  let sleepTimeout = 1000;
342
- while (response.status === 202 && redirect) {
384
+ while (response.status === 202) {
343
385
  await sleep(sleepTimeout);
344
386
  sleepTimeout *= 2;
345
- const redirectConfig = {
346
- ...originalConfig,
347
- url: redirect,
348
- method: 'GET',
349
- data: undefined, // Don't send body on redirect
350
- };
351
- response = await axios(redirectConfig);
387
+ response = await send(location, pollInit, token);
352
388
  }
353
- return response.data;
389
+ return response;
354
390
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export { requestDatalayerAPI, requestDatalayerAPIWithResponse, RunResponseError, NetworkError, TokenExpiredError, } from './DatalayerApi';
10
10
  export type { IRequestDatalayerAPIOptions } from './DatalayerApi';
11
- export type { IDatalayerAPIResponse } from './DatalayerApi';
11
+ export type { IDatalayerAPIResponse, DatalayerAPIResponseType, } from './DatalayerApi';
12
12
  export * as iam from './iam';
13
13
  export * as contents from './contents';
14
14
  export * as evals from './evals';
@@ -0,0 +1,13 @@
1
+ export type CopyToClipboardButtonProps = {
2
+ value: string;
3
+ ariaLabel?: string;
4
+ successText?: string;
5
+ errorText?: string;
6
+ direction?: 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se';
7
+ disabled?: boolean;
8
+ size?: 'small' | 'medium';
9
+ variant?: 'default' | 'invisible' | 'primary' | 'danger';
10
+ onResult?: (copied: boolean) => void;
11
+ };
12
+ export declare function CopyToClipboardButton({ value, ariaLabel, successText, errorText, direction, disabled, size, variant, onResult, }: CopyToClipboardButtonProps): import("react").JSX.Element;
13
+ export default CopyToClipboardButton;
@@ -0,0 +1,73 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2023-2025 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * A button that copies a value, its tooltip saying what it copies, then
8
+ * whether it did.
9
+ *
10
+ * @module components/buttons/CopyToClipboardButton
11
+ */
12
+ import { useMemo, useState } from 'react';
13
+ import { IconButton, Tooltip } from '@primer/react';
14
+ import { CopyIcon } from '@primer/octicons-react';
15
+ const ellipsize = (value, max = 96) => {
16
+ if (!value) {
17
+ return '';
18
+ }
19
+ if (value.length <= max) {
20
+ return value;
21
+ }
22
+ const head = Math.floor((max - 1) / 2);
23
+ const tail = max - head - 1;
24
+ return `${value.slice(0, head)}…${value.slice(-tail)}`;
25
+ };
26
+ export function CopyToClipboardButton({ value, ariaLabel = 'Copy to clipboard', successText = 'Copied', errorText = 'Unable to copy', direction = 'n', disabled = false, size = 'small', variant = 'invisible', onResult, }) {
27
+ const [status, setStatus] = useState('idle');
28
+ const hasValue = Boolean(value && value.trim());
29
+ const tooltip = useMemo(() => {
30
+ if (status === 'success') {
31
+ return successText;
32
+ }
33
+ if (status === 'error') {
34
+ return errorText;
35
+ }
36
+ return hasValue ? `Copy: ${ellipsize(value)}` : 'Nothing to copy';
37
+ }, [status, successText, errorText, hasValue, value]);
38
+ const handleClick = async () => {
39
+ if (!hasValue || disabled) {
40
+ return;
41
+ }
42
+ try {
43
+ if (!navigator?.clipboard?.writeText) {
44
+ throw new Error('Clipboard API unavailable');
45
+ }
46
+ await navigator.clipboard.writeText(value);
47
+ setStatus('success');
48
+ onResult?.(true);
49
+ window.setTimeout(() => setStatus('idle'), 1500);
50
+ }
51
+ catch {
52
+ setStatus('error');
53
+ onResult?.(false);
54
+ window.setTimeout(() => setStatus('idle'), 1800);
55
+ }
56
+ };
57
+ const isDisabled = disabled || !hasValue;
58
+ const button = (_jsx(IconButton, { "aria-label": ariaLabel, icon: CopyIcon, size: size, variant: variant, disabled: isDisabled, onClick: handleClick }));
59
+ /*
60
+ * A disabled button wears no tooltip.
61
+ *
62
+ * `Tooltip` only takes interactive content — `button:not([disabled])` — and
63
+ * throws on anything else, which is what a disabled button is. Nothing is
64
+ * lost by leaving it out: a disabled element fires no pointer event, so the
65
+ * tooltip would never have shown. `IconButton` does the same with the one
66
+ * it renders of its own.
67
+ */
68
+ if (isDisabled) {
69
+ return button;
70
+ }
71
+ return (_jsx(Tooltip, { text: tooltip, direction: direction, children: button }));
72
+ }
73
+ export default CopyToClipboardButton;
@@ -1,3 +1,4 @@
1
+ export * from './CopyToClipboardButton';
1
2
  export * from './DownloadCSVButton';
2
3
  export * from './DownloadJsonButton';
3
4
  export * from './LongActionButton';
@@ -2,6 +2,7 @@
2
2
  * Copyright (c) 2023-2025 Datalayer, Inc.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
+ export * from './CopyToClipboardButton.js';
5
6
  export * from './DownloadCSVButton.js';
6
7
  export * from './DownloadJsonButton.js';
7
8
  export * from './LongActionButton.js';
@@ -0,0 +1,2 @@
1
+ /** The value a deck's `model_s` holds, or `undefined` when it holds none. */
2
+ export declare const parseDeckSpec: (text: unknown) => unknown;
@@ -0,0 +1,22 @@
1
+ /*
2
+ * Copyright (c) 2023-2025 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * A deck's specification, as Spacer serves it: YAML.
7
+ *
8
+ * `GET /decks/{id}` inlines the specification as `model_s`, the text Spacer
9
+ * stored — YAML, or JSON for a deck stored before the specification was
10
+ * YAML, which the same parser reads (JSON is YAML). Saving sends the parsed
11
+ * value back (`PUT /decks/{id}/model`), and Spacer writes it as YAML.
12
+ *
13
+ * @module hooks/deckSpec
14
+ */
15
+ import { parse } from 'yaml';
16
+ /** The value a deck's `model_s` holds, or `undefined` when it holds none. */
17
+ export const parseDeckSpec = (text) => {
18
+ if (typeof text !== 'string' || !text.trim()) {
19
+ return undefined;
20
+ }
21
+ return parse(text);
22
+ };
@@ -38,6 +38,27 @@ type IAnySpace = any;
38
38
  export type CacheProps = {
39
39
  loginRoute?: string;
40
40
  };
41
+ /** A deck's video, as Spacer keeps it with the deck (`GET /decks/{id}/video`). */
42
+ export type SavedDeckVideo = {
43
+ /** Signed: plays it for `expiresIn` seconds. */
44
+ url: string;
45
+ /** Signed: downloads it as `<deck name>.mp4`. */
46
+ downloadUrl: string;
47
+ /** Signed: its first frame, a PNG. */
48
+ posterUrl: string;
49
+ /** Its captions, as WebVTT text. */
50
+ captions: string;
51
+ width: number;
52
+ height: number;
53
+ bytes: number;
54
+ madeAt: string;
55
+ madeBy: string;
56
+ /** When the deck was last edited, if it says. */
57
+ editedAt: string;
58
+ /** The deck has been edited since the video was made. */
59
+ stale: boolean;
60
+ expiresIn: number;
61
+ };
41
62
  export type ISearchOpts = {
42
63
  q: string;
43
64
  types: string[];
@@ -282,6 +303,8 @@ export declare const queryKeys: {
282
303
  readonly detail: (id: string) => readonly ["decks", "detail", string];
283
304
  readonly bySpace: (spaceId: string) => readonly ["decks", "space", string];
284
305
  readonly model: (deckId: string) => readonly ["decks", "detail", string, "model"];
306
+ /** Under the detail: saving the deck refreshes whether it is stale. */
307
+ readonly video: (deckId: string) => readonly ["decks", "detail", string, "video"];
285
308
  };
286
309
  readonly cells: {
287
310
  readonly all: () => readonly ["cells"];
@@ -803,6 +826,15 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
803
826
  spec: unknown;
804
827
  }, unknown>;
805
828
  useCloneDeck: () => import("@tanstack/react-query").UseMutationResult<any, Error, string, unknown>;
829
+ useSavedDeckVideo: (deckId: string) => import("@tanstack/react-query").UseQueryResult<SavedDeckVideo | null, Error>;
830
+ useSaveDeckVideo: () => import("@tanstack/react-query").UseMutationResult<SavedDeckVideo, Error, {
831
+ deckId: string;
832
+ video: Blob;
833
+ captions: string;
834
+ poster: Blob;
835
+ spec: unknown;
836
+ }, unknown>;
837
+ useDeleteDeckVideo: () => import("@tanstack/react-query").UseMutationResult<any, Error, string, unknown>;
806
838
  useDocument: (documentId: string) => import("@tanstack/react-query").UseQueryResult<any, Error>;
807
839
  useDocumentsBySpace: (spaceId: string) => import("@tanstack/react-query").UseQueryResult<any, Error>;
808
840
  useCreateDocument: () => import("@tanstack/react-query").UseMutationResult<any, Error, {
@@ -958,6 +990,12 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
958
990
  data?: string;
959
991
  }, unknown>;
960
992
  useResetItemImage: () => import("@tanstack/react-query").UseMutationResult<any, Error, string, unknown>;
993
+ useSetItemVideo: () => import("@tanstack/react-query").UseMutationResult<any, Error, {
994
+ itemId: string;
995
+ video: Blob;
996
+ captions: string;
997
+ poster: Blob;
998
+ }, unknown>;
961
999
  useCountItemReuse: () => import("@tanstack/react-query").UseMutationResult<any, Error, {
962
1000
  itemId: string;
963
1001
  kind: "clone" | "instantiate";
@@ -975,6 +1013,14 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
975
1013
  ref?: string;
976
1014
  data?: string;
977
1015
  };
1016
+ /**
1017
+ * Publish a deck *as a video*: the video saved with it in its space
1018
+ * becomes the published deck's video — its page plays it and its
1019
+ * shared link announces it (`og:video`). The library copies it in
1020
+ * the same request that publishes, and refuses before publishing a
1021
+ * deck that has none.
1022
+ */
1023
+ video?: boolean;
978
1024
  /**
979
1025
  * The publisher's own words for their work, which is what somebody
980
1026
  * else will search for.
@@ -1415,6 +1461,8 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
1415
1461
  readonly detail: (id: string) => readonly ["decks", "detail", string];
1416
1462
  readonly bySpace: (spaceId: string) => readonly ["decks", "space", string];
1417
1463
  readonly model: (deckId: string) => readonly ["decks", "detail", string, "model"];
1464
+ /** Under the detail: saving the deck refreshes whether it is stale. */
1465
+ readonly video: (deckId: string) => readonly ["decks", "detail", string, "video"];
1418
1466
  };
1419
1467
  readonly cells: {
1420
1468
  readonly all: () => readonly ["cells"];
@@ -39,6 +39,7 @@
39
39
  import { useQuery, useQueries, useMutation, useQueryClient, } from '@tanstack/react-query';
40
40
  import { BOOTSTRAP_USER_ONBOARDING, asContact, asInvite, asOrganization, asSecret, asTeam, asToken, asUser, } from '../models/index.js';
41
41
  import { asSpace } from './cacheConverters.js';
42
+ import { parseDeckSpec } from './deckSpec.js';
42
43
  import { libraryAppFieldsOf } from './libraryApp.js';
43
44
  import { unlinkIAMProvider } from './unlinkIAMProvider.js';
44
45
  import { useCoreStore, useIAMStore, profileStore } from '../state/index.js';
@@ -368,6 +369,8 @@ export const queryKeys = {
368
369
  detail: (id) => [...queryKeys.decks.details(), id],
369
370
  bySpace: (spaceId) => [...queryKeys.decks.all(), 'space', spaceId],
370
371
  model: (deckId) => [...queryKeys.decks.detail(deckId), 'model'],
372
+ /** Under the detail: saving the deck refreshes whether it is stale. */
373
+ video: (deckId) => [...queryKeys.decks.detail(deckId), 'video'],
371
374
  },
372
375
  // Cells
373
376
  cells: {
@@ -721,6 +724,10 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
721
724
  meta.publisher = raw.publisher;
722
725
  if (raw.image !== undefined)
723
726
  meta.image = raw.image;
727
+ // A deck's video, made by its owner: `url`, `captionsUrl`, `posterUrl`,
728
+ // `width`, `height`. Absent when it has none.
729
+ if (raw.video)
730
+ meta.video = raw.video;
724
731
  /*
725
732
  * Where the artifact can be opened for editing.
726
733
  *
@@ -1074,7 +1081,8 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
1074
1081
  name: raw.name_t,
1075
1082
  description: raw.description_t,
1076
1083
  tags: Array.isArray(raw.tags_ss) ? raw.tags_ss : [],
1077
- spec: raw.model_s ? JSON.parse(raw.model_s) : undefined,
1084
+ // YAML, as Spacer stores it (a deck stored as JSON reads the same).
1085
+ spec: parseDeckSpec(raw.model_s),
1078
1086
  public: raw.is_public_b ?? false,
1079
1087
  creationDate: new Date(raw.creation_ts_dt),
1080
1088
  lastUpdateDate: raw.last_update_ts_dt
@@ -2628,6 +2636,77 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
2628
2636
  },
2629
2637
  });
2630
2638
  };
2639
+ /**
2640
+ * The video saved with a deck in its space, or `null` when it has none.
2641
+ *
2642
+ * Signed URLs that read it for a few hours (`url`, `posterUrl`, and
2643
+ * `downloadUrl`, an attachment named after the deck), its captions inline
2644
+ * (WebVTT), its size, when it was made, and `stale` — the deck has been
2645
+ * edited since: its spec is not the one the video was made from.
2646
+ */
2647
+ const useSavedDeckVideo = (deckId) => {
2648
+ return useQuery({
2649
+ queryKey: queryKeys.decks.video(deckId),
2650
+ queryFn: async () => {
2651
+ const resp = await requestDatalayer({
2652
+ url: `${configuration.spacerUrl}/api/spacer/v1/decks/${encodeURIComponent(deckId)}/video`,
2653
+ method: 'GET',
2654
+ });
2655
+ if (!resp.success) {
2656
+ throw new Error(resp.message || 'Failed to read the deck video');
2657
+ }
2658
+ return (resp.video ?? null);
2659
+ },
2660
+ ...DEFAULT_QUERY_OPTIONS,
2661
+ enabled: !!deckId,
2662
+ });
2663
+ };
2664
+ /**
2665
+ * Keep the video the deck engine made of a deck with the deck, in its
2666
+ * space, replacing the one it had: the MP4, its WebVTT captions, its
2667
+ * poster, and the spec it was made from.
2668
+ */
2669
+ const useSaveDeckVideo = () => {
2670
+ return useMutation({
2671
+ mutationFn: async ({ deckId, video, captions, poster, spec, }) => {
2672
+ const body = new FormData();
2673
+ body.append('video', video, 'video.mp4');
2674
+ body.append('captions', new Blob([captions], { type: 'text/vtt' }), 'captions.vtt');
2675
+ body.append('poster', poster, 'poster.png');
2676
+ body.append('spec', JSON.stringify(spec));
2677
+ const resp = await requestDatalayer({
2678
+ url: `${configuration.spacerUrl}/api/spacer/v1/decks/${encodeURIComponent(deckId)}/video`,
2679
+ method: 'PUT',
2680
+ body,
2681
+ });
2682
+ if (!resp.success) {
2683
+ throw new Error(resp.message || 'Failed to save the video');
2684
+ }
2685
+ return resp.video;
2686
+ },
2687
+ onSuccess: (video, { deckId }) => {
2688
+ queryClient.setQueryData(queryKeys.decks.video(deckId), video);
2689
+ },
2690
+ });
2691
+ };
2692
+ /** The deck without its saved video. */
2693
+ const useDeleteDeckVideo = () => {
2694
+ return useMutation({
2695
+ mutationFn: async (deckId) => {
2696
+ const resp = await requestDatalayer({
2697
+ url: `${configuration.spacerUrl}/api/spacer/v1/decks/${encodeURIComponent(deckId)}/video`,
2698
+ method: 'DELETE',
2699
+ });
2700
+ if (!resp.success) {
2701
+ throw new Error(resp.message || 'Failed to remove the video');
2702
+ }
2703
+ return resp;
2704
+ },
2705
+ onSuccess: (_, deckId) => {
2706
+ queryClient.setQueryData(queryKeys.decks.video(deckId), null);
2707
+ },
2708
+ });
2709
+ };
2631
2710
  /**
2632
2711
  * Clone notebook
2633
2712
  */
@@ -6915,6 +6994,31 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
6915
6994
  onSuccess: invalidateArtifactVisibility,
6916
6995
  });
6917
6996
  };
6997
+ /**
6998
+ * A published deck's video, made in its owner's browser by the deck
6999
+ * engine: the MP4, its WebVTT captions and its poster (the first slide).
7000
+ * Making it again replaces it.
7001
+ */
7002
+ const useSetItemVideo = () => {
7003
+ return useMutation({
7004
+ mutationFn: async ({ itemId, video, captions, poster, }) => {
7005
+ const body = new FormData();
7006
+ body.append('video', video, 'video.mp4');
7007
+ body.append('captions', new Blob([captions], { type: 'text/vtt' }), 'captions.vtt');
7008
+ body.append('poster', poster, 'poster.png');
7009
+ const resp = await requestDatalayer({
7010
+ url: `${configuration.libraryUrl}/api/library/v1/items/${encodeURIComponent(itemId)}/video`,
7011
+ method: 'PUT',
7012
+ body,
7013
+ });
7014
+ if (!resp.success) {
7015
+ throw new Error(resp.message || 'Failed to set the video');
7016
+ }
7017
+ return resp.video;
7018
+ },
7019
+ onSuccess: invalidateArtifactVisibility,
7020
+ });
7021
+ };
6918
7022
  /** Publish a public artifact natively to LinkedIn through Library's saga. */
6919
7023
  const usePublishLinkedInShare = () => {
6920
7024
  return useMutation({
@@ -7131,7 +7235,7 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
7131
7235
  const usePublishArtifact = () => {
7132
7236
  const setImage = useSetItemImage();
7133
7237
  return useMutation({
7134
- mutationFn: async ({ itemId, image, tags, description, }) => {
7238
+ mutationFn: async ({ itemId, image, tags, description, video, }) => {
7135
7239
  /*
7136
7240
  * Publish first, then dress it.
7137
7241
  *
@@ -7156,6 +7260,7 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
7156
7260
  // words must not be able to clear them by omission.
7157
7261
  ...(tags === undefined ? {} : { tags }),
7158
7262
  ...(description === undefined ? {} : { description }),
7263
+ ...(video ? { video: true } : {}),
7159
7264
  },
7160
7265
  });
7161
7266
  if (!resp.success) {
@@ -8584,6 +8689,9 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
8584
8689
  useUpdateDeck,
8585
8690
  useUpdateDeckModel,
8586
8691
  useCloneDeck,
8692
+ useSavedDeckVideo,
8693
+ useSaveDeckVideo,
8694
+ useDeleteDeckVideo,
8587
8695
  // Documents
8588
8696
  useDocument,
8589
8697
  useDocumentsBySpace,
@@ -8655,6 +8763,7 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
8655
8763
  useSetFeaturedRank,
8656
8764
  useSetItemImage,
8657
8765
  useResetItemImage,
8766
+ useSetItemVideo,
8658
8767
  useCountItemReuse,
8659
8768
  useSyncArtifactCatalogue,
8660
8769
  usePublishArtifact,
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Posts a form to `url` through the API transport.
3
+ *
4
+ * `progress` is 0 while the upload is in flight and 100 once the server has
5
+ * answered: `fetch` reports no upload progress, so there is nothing in
6
+ * between to show.
7
+ */
1
8
  export declare const useUploadForm: (url: string) => {
2
9
  uploadAndSubmit: (formData: FormData) => Promise<any>;
3
10
  isSuccess: boolean;
@@ -3,16 +3,20 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  import { useState } from 'react';
6
- import axios from 'axios';
7
- // import useRun from "./useRun.js";
6
+ import { requestDatalayerAPI } from '../api/DatalayerApi.js';
8
7
  import { useIAMStore } from '../state/index.js';
9
- // TODO reuse useRun hook.
8
+ /**
9
+ * Posts a form to `url` through the API transport.
10
+ *
11
+ * `progress` is 0 while the upload is in flight and 100 once the server has
12
+ * answered: `fetch` reports no upload progress, so there is nothing in
13
+ * between to show.
14
+ */
10
15
  export const useUploadForm = (url) => {
11
16
  const [isSuccess, setIsSuccess] = useState(false);
12
17
  const [isLoading, setIsLoading] = useState(false);
13
18
  const [progress, setProgress] = useState(0);
14
19
  const { token } = useIAMStore();
15
- // const { requestRun } = useRun();
16
20
  const reset = () => {
17
21
  setIsSuccess(false);
18
22
  setIsLoading(false);
@@ -21,19 +25,11 @@ export const useUploadForm = (url) => {
21
25
  const uploadAndSubmit = async (formData) => {
22
26
  setIsLoading(true);
23
27
  try {
24
- const { data } = await axios.post(url, formData, {
25
- headers: {
26
- 'Content-Type': 'multipart/form-data',
27
- Authorization: `Bearer ${token}`,
28
- },
29
- onUploadProgress: progressEvent => {
30
- const progress = (progressEvent.loaded / progressEvent.total) * 50;
31
- setProgress(progress);
32
- },
33
- onDownloadProgress: progressEvent => {
34
- const progress = 50 + (progressEvent.loaded / progressEvent.total) * 50;
35
- setProgress(progress);
36
- },
28
+ const data = await requestDatalayerAPI({
29
+ url,
30
+ method: 'POST',
31
+ body: formData,
32
+ token,
37
33
  });
38
34
  setProgress(100);
39
35
  await new Promise(resolve => {
package/lib/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export * from './navigation';
5
5
  export * from './hooks';
6
6
  export { requestDatalayerAPI, requestDatalayerAPIWithResponse, RunResponseError, NetworkError, } from './api/DatalayerApi';
7
7
  export type { IRequestDatalayerAPIOptions } from './api/DatalayerApi';
8
- export type { IDatalayerAPIResponse } from './api/DatalayerApi';
8
+ export type { IDatalayerAPIResponse, DatalayerAPIResponseType, } from './api/DatalayerApi';
9
9
  export { API_BASE_PATHS } from './api/constants';
10
10
  export * as iamApi from './api/iam';
11
11
  export * as contentsApi from './api/contents';
@@ -30,7 +30,7 @@ export function setOtelOnUnauthorized(cb) {
30
30
  }
31
31
  /**
32
32
  * Lightweight fetch helper for OTEL API calls.
33
- * Uses plain `fetch` to avoid pulling in `@jupyterlab/coreutils` / axios.
33
+ * Uses plain `fetch` to avoid pulling in `@jupyterlab/coreutils`.
34
34
  *
35
35
  * If the response is **401 Unauthorized** and a global `onUnauthorized`
36
36
  * handler has been registered via {@link setOtelOnUnauthorized}, the
@@ -301,8 +301,22 @@ export function viewForRoute(route, current) {
301
301
  if (path === '/apps' || path.startsWith('/apps/')) {
302
302
  return 'apps';
303
303
  }
304
+ // A space's decks and teaching pages are drawn beside the Agentify sidebar:
305
+ // filed under the Platform, the Platform button came back to a deck with
306
+ // the Agentify sidebar beside it (reported 23 October 2026).
307
+ if (AGENTIFY_SPACE_PAGE.test(path) || AGENTIFY_ACCOUNT_PAGE.test(path)) {
308
+ return 'agentify';
309
+ }
304
310
  return 'home';
305
311
  }
312
+ /**
313
+ * `/<account>/<space>/<kind>[/…]` for the kinds a space shows beside the
314
+ * Agentify sidebar — decks, courses and what a course is made of. The
315
+ * documentation (`/docs/…`) is never an account.
316
+ */
317
+ const AGENTIFY_SPACE_PAGE = /^\/(?!docs\/)[^/]+\/[^/]+\/(?:decks|courses|course|lessons|exercises|assignments)(?:\/|$)/;
318
+ /** `/<account>/courses`: an account's courses, beside the Agentify sidebar. */
319
+ const AGENTIFY_ACCOUNT_PAGE = /^\/(?!docs\/)[^/]+\/courses$/;
306
320
  /** Whether a page may be the one a shell returns to. */
307
321
  function isRouteOfView(route, view) {
308
322
  return (typeof route === 'string' &&
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Who an account is, to be read: its avatar, then its username, display
3
+ * name, initials, origin and identifier, the identifier with a button that
4
+ * copies it.
5
+ *
6
+ * It is given the identity and fetches nothing, so any host draws it from
7
+ * what it already holds: the web's profile page beside its form, VS Code's
8
+ * Profile view from its signed-in user.
9
+ *
10
+ * @module views/profile/UserIdentity
11
+ */
12
+ import type { JSX } from 'react';
13
+ /** What {@link UserIdentity} shows of an account. */
14
+ export interface UserIdentityProfile {
15
+ /** The account's identifier. */
16
+ id?: string;
17
+ username: string;
18
+ display_name: string;
19
+ initials: string;
20
+ /** Its photograph, when it has one. */
21
+ avatar_url?: string;
22
+ /** Its chosen picture, which wins over the photograph. */
23
+ avatar_icon?: string;
24
+ /** Where the account comes from (the identity provider). */
25
+ origin?: string;
26
+ }
27
+ export interface UserIdentityProps {
28
+ identity: UserIdentityProfile;
29
+ /** Whether the identifier is shown. @default true */
30
+ displayId?: boolean;
31
+ /**
32
+ * Whether the display name and the initials are shown. They are computed
33
+ * from the first and the last name, so a host showing the form that edits
34
+ * those states them there instead. @default true
35
+ */
36
+ displayComputed?: boolean;
37
+ /** Rendered under the avatar, typically the button changing it. */
38
+ avatarAction?: JSX.Element;
39
+ }
40
+ export declare const UserIdentity: ({ identity, displayId, displayComputed, avatarAction, }: UserIdentityProps) => JSX.Element;
41
+ export default UserIdentity;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from '@primer/react';
3
+ import { Box } from '@datalayer/primer-addons';
4
+ import { UserAvatar } from '../../components/avatars/index.js';
5
+ import { CopyToClipboardButton } from '../../components/buttons/index.js';
6
+ /** One row of the list: its label, then its value. */
7
+ const Row = ({ label, children, }) => (_jsxs(_Fragment, { children: [_jsx(Box, { p: 1, children: _jsx(Text, { style: { fontWeight: 'bold' }, children: label }) }), _jsx(Box, { p: 1, display: "flex", alignItems: "center", gap: 1, children: typeof children === 'string' ? _jsx(Text, { children: children }) : children })] }));
8
+ export const UserIdentity = ({ identity, displayId = true, displayComputed = true, avatarAction, }) => (_jsxs(Box, { children: [_jsxs(Box, { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 2, children: [_jsx(UserAvatar, { avatarUrl: identity.avatar_url, avatar: identity.avatar_icon, size: 100 }), avatarAction] }), _jsxs(Box, { display: "grid", gridTemplateColumns: "1fr 2fr", rowGap: 3, columnGap: 3, maxWidth: 500, mt: 3, children: [_jsx(Row, { label: "Username", children: identity.username }), displayComputed && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Display name", children: identity.display_name }), _jsx(Row, { label: "Initials", children: identity.initials })] })), identity.origin && _jsx(Row, { label: "Origin", children: identity.origin }), displayId && identity.id && (_jsx(Row, { label: "UID", children: _jsxs(_Fragment, { children: [_jsx(Text, { children: identity.id }), _jsx(CopyToClipboardButton, { value: identity.id, ariaLabel: "Copy the identifier of the account" })] }) }))] })] }));
9
+ export default UserIdentity;
@@ -1,2 +1,4 @@
1
1
  export { UserBadge } from './UserBadge';
2
2
  export type { UserBadgeProps } from './UserBadge';
3
+ export { UserIdentity } from './UserIdentity';
4
+ export type { UserIdentityProfile, UserIdentityProps } from './UserIdentity';
@@ -3,3 +3,4 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
  export { UserBadge } from './UserBadge.js';
6
+ export { UserIdentity } from './UserIdentity.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/core",
3
- "version": "1.2.22",
3
+ "version": "1.2.24",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",
@@ -148,7 +148,6 @@
148
148
  "@tanstack/react-query": "^5.90.6",
149
149
  "@toon-format/toon": "^1.3.0",
150
150
  "ansi-to-html": "^0.7.2",
151
- "axios": "^1.7.7",
152
151
  "boring-avatars": "^2.0.1",
153
152
  "date-fns": "^2.29.3",
154
153
  "deepmerge": "^4.3.1",
@@ -178,6 +177,7 @@
178
177
  "usehooks-ts": "3.1.1",
179
178
  "uuid": "^13.0.0",
180
179
  "validator": "^13.7.0",
180
+ "yaml": "^2.9.1",
181
181
  "zod": "^4.1.13",
182
182
  "zod-to-json-schema": "^3.25.0",
183
183
  "zustand": "^4.4.1"