@oceanum/oceanumlab 4.6.1 → 4.7.0
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/DatameshUI.d.ts +10 -0
- package/lib/DatameshUI.js +20 -2
- package/lib/DatameshWidget.d.ts +11 -1
- package/lib/DatameshWidget.js +116 -43
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/chatRouter.d.ts +5 -1
- package/lib/chatRouter.js +27 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +15 -0
- package/lib/datameshUiUrl.js +35 -0
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.js +28 -7
- package/package.json +1 -1
- package/schema/datamesh-connect.json +12 -0
- package/src/DatameshUI.tsx +21 -2
- package/src/DatameshWidget.tsx +200 -54
- package/src/__tests__/DatameshWidget.spec.tsx +380 -0
- package/src/__tests__/aiBackend.spec.ts +272 -0
- package/src/__tests__/chatRouter.spec.ts +259 -17
- package/src/__tests__/datameshUiUrl.spec.ts +154 -0
- package/src/aiBackend.ts +150 -0
- package/src/chatRouter.ts +36 -8
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +44 -0
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +38 -10
package/lib/DatameshUI.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
3
3
|
export declare class DatameshUI extends ReactWidget {
|
|
4
|
+
private _url;
|
|
5
|
+
/** `url` is the `datameshUiUrl` setting. */
|
|
6
|
+
constructor(_url: string);
|
|
7
|
+
/**
|
|
8
|
+
* Point the panel at the `datameshUiUrl` setting's new value. The iframe
|
|
9
|
+
* reloads only if its address actually changes.
|
|
10
|
+
*/
|
|
11
|
+
set url(url: string);
|
|
12
|
+
/** The iframe's window, for telling its messages apart; `null` if none. */
|
|
13
|
+
get frame(): Window | null;
|
|
4
14
|
render(): React.ReactElement;
|
|
5
15
|
}
|
package/lib/DatameshUI.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
3
|
-
import {
|
|
3
|
+
import { datameshUiSrc } from './datameshUiUrl';
|
|
4
4
|
export class DatameshUI extends ReactWidget {
|
|
5
|
+
/** `url` is the `datameshUiUrl` setting. */
|
|
6
|
+
constructor(_url) {
|
|
7
|
+
super();
|
|
8
|
+
this._url = _url;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Point the panel at the `datameshUiUrl` setting's new value. The iframe
|
|
12
|
+
* reloads only if its address actually changes.
|
|
13
|
+
*/
|
|
14
|
+
set url(url) {
|
|
15
|
+
this._url = url;
|
|
16
|
+
this.update();
|
|
17
|
+
}
|
|
18
|
+
/** The iframe's window, for telling its messages apart; `null` if none. */
|
|
19
|
+
get frame() {
|
|
20
|
+
var _a, _b;
|
|
21
|
+
return (_b = (_a = this.node.querySelector('iframe')) === null || _a === void 0 ? void 0 : _a.contentWindow) !== null && _b !== void 0 ? _b : null;
|
|
22
|
+
}
|
|
5
23
|
render() {
|
|
6
|
-
return (React.createElement("iframe", { src:
|
|
24
|
+
return (React.createElement("iframe", { src: datameshUiSrc(this._url), className: "datamesh-iframe" }));
|
|
7
25
|
}
|
|
8
26
|
}
|
package/lib/DatameshWidget.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
|
2
2
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
3
3
|
import { LabIcon } from '@jupyterlab/ui-components';
|
|
4
4
|
import { CommandRegistry } from '@lumino/commands';
|
|
5
|
-
import { Signal } from '@lumino/signaling';
|
|
5
|
+
import { ISignal, Signal } from '@lumino/signaling';
|
|
6
6
|
import { Widget } from '@lumino/widgets';
|
|
7
7
|
import React from 'react';
|
|
8
8
|
export interface IDatasource {
|
|
@@ -40,6 +40,16 @@ export interface IDatameshWidgetProps {
|
|
|
40
40
|
name: string;
|
|
41
41
|
icon: LabIcon;
|
|
42
42
|
openDatameshUI: any;
|
|
43
|
+
/** The `datameshUiUrl` setting. */
|
|
44
|
+
datameshUiUrl: () => string;
|
|
45
|
+
/** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
|
|
46
|
+
datameshUiFrame: () => Window | null;
|
|
47
|
+
/** The `datameshToken` setting. */
|
|
48
|
+
datameshToken: () => string;
|
|
49
|
+
/** The `aiBackendUrl` setting. */
|
|
50
|
+
aiBackendUrl: () => string;
|
|
51
|
+
/** Emitted when the settings change. */
|
|
52
|
+
settingsChanged: ISignal<unknown, void>;
|
|
43
53
|
commands: CommandRegistry;
|
|
44
54
|
getCurrentWidget: () => Widget;
|
|
45
55
|
}
|
package/lib/DatameshWidget.js
CHANGED
|
@@ -9,7 +9,8 @@ import { Signal } from '@lumino/signaling';
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import { marked } from 'marked';
|
|
11
11
|
import { DatasourceItem } from './DatasourceItem';
|
|
12
|
-
import {
|
|
12
|
+
import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
|
|
13
|
+
import { isDatameshUiMessage } from './datameshUiUrl';
|
|
13
14
|
import { describeProgress, onProgress } from './progress';
|
|
14
15
|
// Configure marked for safe rendering
|
|
15
16
|
marked.setOptions({
|
|
@@ -283,30 +284,71 @@ class DatameshWorkspaceDisplay extends React.Component {
|
|
|
283
284
|
});
|
|
284
285
|
}
|
|
285
286
|
}
|
|
287
|
+
function readAiAccess(commands, settings) {
|
|
288
|
+
const datameshToken = settings.datameshToken();
|
|
289
|
+
return {
|
|
290
|
+
source: aiCredentialSource(datameshToken, commands),
|
|
291
|
+
pasted: pastedToken(datameshToken),
|
|
292
|
+
canSignIn: canSignIn(commands),
|
|
293
|
+
backend: aiBackendUrl(settings.aiBackendUrl())
|
|
294
|
+
};
|
|
295
|
+
}
|
|
286
296
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
297
|
+
* How the sidebar can reach Oceanum AI, read again whenever the settings or the
|
|
298
|
+
* commands change: the host calls `notifyCommandChanged` when the user signs
|
|
299
|
+
* in or out.
|
|
300
|
+
*
|
|
301
|
+
* Only the registry's queries are used, never `execute`, and nothing runs on a
|
|
302
|
+
* timer: every executed command closes JupyterLab's command palette.
|
|
289
303
|
*/
|
|
290
|
-
function
|
|
291
|
-
const [
|
|
304
|
+
function useAiAccess(commands, settings) {
|
|
305
|
+
const [access, setAccess] = React.useState(() => readAiAccess(commands, settings));
|
|
292
306
|
React.useEffect(() => {
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
307
|
+
const update = () => {
|
|
308
|
+
const next = readAiAccess(commands, settings);
|
|
309
|
+
// Same content keeps the same object, so nothing downstream re-runs.
|
|
310
|
+
setAccess(prev => JSON.stringify(prev) === JSON.stringify(next) ? prev : next);
|
|
311
|
+
};
|
|
312
|
+
// Anything that changed between the first render and now.
|
|
313
|
+
update();
|
|
314
|
+
commands.commandChanged.connect(update);
|
|
315
|
+
settings.settingsChanged.connect(update);
|
|
316
|
+
return () => {
|
|
317
|
+
commands.commandChanged.disconnect(update);
|
|
318
|
+
settings.settingsChanged.disconnect(update);
|
|
319
|
+
};
|
|
320
|
+
}, [commands, settings]);
|
|
321
|
+
return access;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Shows a message prompting the user to sign in to Oceanum.io, or to configure
|
|
325
|
+
* their Datamesh token where the site has no sign-in. Only renders when there
|
|
326
|
+
* is no credential.
|
|
327
|
+
*/
|
|
328
|
+
function TokenConfigMessage({ commands, settings }) {
|
|
329
|
+
const access = useAiAccess(commands, settings);
|
|
330
|
+
if (access.source) {
|
|
298
331
|
return null;
|
|
299
332
|
}
|
|
333
|
+
const openSettings = () => commands.execute('settingeditor:open', { query: 'Oceanum' });
|
|
334
|
+
if (access.canSignIn) {
|
|
335
|
+
const signIn = () => commands.execute(SIGN_IN_COMMAND).catch(() => {
|
|
336
|
+
console.warn('Oceanum AI: could not start signing in to Oceanum.io.');
|
|
337
|
+
});
|
|
338
|
+
return (React.createElement("div", { className: "oceanum-token-config" },
|
|
339
|
+
React.createElement("a", { onClick: () => void signIn() }, "Sign in to Oceanum.io"),
|
|
340
|
+
" to use Oceanum AI, or set a ",
|
|
341
|
+
React.createElement("a", { onClick: openSettings }, "Datamesh token"),
|
|
342
|
+
"."));
|
|
343
|
+
}
|
|
300
344
|
return (React.createElement("div", { className: "oceanum-token-config" },
|
|
301
|
-
"Set your",
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
' ',
|
|
305
|
-
"to enable Oceanum.oi services",
|
|
345
|
+
"Set your ",
|
|
346
|
+
React.createElement("a", { onClick: openSettings }, "Datamesh token"),
|
|
347
|
+
" to enable Oceanum.io services",
|
|
306
348
|
' ',
|
|
307
349
|
React.createElement("a", { href: "https://home.oceanum.io/account", target: "_blank", rel: "noopener noreferrer" }, "Get token here")));
|
|
308
350
|
}
|
|
309
|
-
function AIChatPanel({ commands }) {
|
|
351
|
+
function AIChatPanel({ commands, settings }) {
|
|
310
352
|
const [messages, setMessages] = React.useState([]);
|
|
311
353
|
const [input, setInput] = React.useState('');
|
|
312
354
|
const [loading, setLoading] = React.useState(false);
|
|
@@ -321,49 +363,75 @@ function AIChatPanel({ commands }) {
|
|
|
321
363
|
const [history, setHistory] = React.useState([]);
|
|
322
364
|
const [historyIndex, setHistoryIndex] = React.useState(-1);
|
|
323
365
|
const [tempInput, setTempInput] = React.useState('');
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
const
|
|
366
|
+
// Which credential and address the chat has, as the settings and the host
|
|
367
|
+
// report them.
|
|
368
|
+
const access = useAiAccess(commands, settings);
|
|
369
|
+
// What the capabilities depend on: which credential, the pasted token and
|
|
370
|
+
// the address. Not the sign-in token itself: it refreshes, and finding it out
|
|
371
|
+
// runs the host's command.
|
|
372
|
+
const capabilitiesKey = access.source
|
|
373
|
+
? JSON.stringify([access.source, access.pasted, access.backend])
|
|
374
|
+
: '';
|
|
327
375
|
// Check if code capability is enabled (null = loading/unknown)
|
|
328
376
|
const [codeEnabled, setCodeEnabled] = React.useState(null);
|
|
329
|
-
//
|
|
377
|
+
// The sidebar's one pending access-token request, shared rather than
|
|
378
|
+
// repeated if the capabilities are asked for again before it answers.
|
|
379
|
+
const tokenRequest = React.useRef(null);
|
|
380
|
+
// Fetch capabilities when the credential or the address changes
|
|
330
381
|
React.useEffect(() => {
|
|
331
|
-
|
|
332
|
-
const currentToken = window.datameshToken || '';
|
|
333
|
-
if (currentToken !== token) {
|
|
334
|
-
setToken(currentToken);
|
|
335
|
-
}
|
|
336
|
-
};
|
|
337
|
-
const interval = setInterval(checkToken, 1000);
|
|
338
|
-
return () => clearInterval(interval);
|
|
339
|
-
}, [token]);
|
|
340
|
-
// Fetch capabilities when token changes
|
|
341
|
-
React.useEffect(() => {
|
|
342
|
-
if (!token) {
|
|
382
|
+
if (!access.source) {
|
|
343
383
|
setCodeEnabled(null);
|
|
344
384
|
return;
|
|
345
385
|
}
|
|
386
|
+
// An answer for a credential that has since changed is dropped.
|
|
387
|
+
let current = true;
|
|
388
|
+
const signInTokenOnce = () => {
|
|
389
|
+
if (!tokenRequest.current) {
|
|
390
|
+
tokenRequest.current = signInToken(commands)().finally(() => {
|
|
391
|
+
tokenRequest.current = null;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return tokenRequest.current;
|
|
395
|
+
};
|
|
346
396
|
const fetchCapabilities = async () => {
|
|
397
|
+
const credential = await resolveAiCredential(access.pasted, signInTokenOnce);
|
|
398
|
+
if (!current) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!credential) {
|
|
402
|
+
// The host says signed in but has no token to give: leave it to a
|
|
403
|
+
// request to report, rather than hide the chat.
|
|
404
|
+
setCodeEnabled(null);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
347
407
|
try {
|
|
348
|
-
const response = await fetch(`${
|
|
349
|
-
headers:
|
|
350
|
-
'X-Datamesh-Token': token
|
|
351
|
-
}
|
|
408
|
+
const response = await fetch(`${access.backend}/api/capabilities`, {
|
|
409
|
+
headers: credential.headers
|
|
352
410
|
});
|
|
411
|
+
if (!current) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
353
414
|
if (response.ok) {
|
|
354
415
|
const data = await response.json();
|
|
355
|
-
|
|
416
|
+
if (current) {
|
|
417
|
+
setCodeEnabled(data.code === true);
|
|
418
|
+
}
|
|
356
419
|
}
|
|
357
420
|
else {
|
|
358
421
|
setCodeEnabled(false);
|
|
359
422
|
}
|
|
360
423
|
}
|
|
361
424
|
catch (_a) {
|
|
362
|
-
|
|
425
|
+
if (current) {
|
|
426
|
+
setCodeEnabled(false);
|
|
427
|
+
}
|
|
363
428
|
}
|
|
364
429
|
};
|
|
365
430
|
void fetchCapabilities();
|
|
366
|
-
|
|
431
|
+
return () => {
|
|
432
|
+
current = false;
|
|
433
|
+
};
|
|
434
|
+
}, [capabilitiesKey]);
|
|
367
435
|
// Auto-scroll to bottom when messages change
|
|
368
436
|
React.useEffect(() => {
|
|
369
437
|
var _a;
|
|
@@ -500,8 +568,8 @@ function AIChatPanel({ commands }) {
|
|
|
500
568
|
return;
|
|
501
569
|
}
|
|
502
570
|
};
|
|
503
|
-
// Hide AI panel if no
|
|
504
|
-
if (!
|
|
571
|
+
// Hide AI panel if no credential or code capability is not enabled
|
|
572
|
+
if (!access.source || codeEnabled === false) {
|
|
505
573
|
return null;
|
|
506
574
|
}
|
|
507
575
|
return (React.createElement("div", { className: "oceanum-ai-chat" },
|
|
@@ -541,6 +609,11 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
541
609
|
window.addEventListener('message', this.receiveIFrameMessage.bind(this), false);
|
|
542
610
|
}
|
|
543
611
|
receiveIFrameMessage(event) {
|
|
612
|
+
// Any page or frame can post to this window; only the Datamesh UI panel
|
|
613
|
+
// may change the workspace.
|
|
614
|
+
if (!isDatameshUiMessage(event, this.props.datameshUiUrl(), this.props.datameshUiFrame())) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
544
617
|
if (event.data && event.data.action === 'workspace-modify') {
|
|
545
618
|
this.datameshWorkspaceSpec = {
|
|
546
619
|
id: event.data.id,
|
|
@@ -565,8 +638,8 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
565
638
|
React.createElement("div", { className: "open-datamesh-ui", onClick: this.props.openDatameshUI, title: "Open Datamesh UI" }, React.createElement(addIcon.react, { height: "20px", verticalAlign: "middle" }))),
|
|
566
639
|
React.createElement("div", { className: "datamesh-connect-workspace" },
|
|
567
640
|
React.createElement(UseSignal, { signal: this.renderSignal, initialArgs: null }, (_, datameshWorkspace) => this.renderDisplay(datameshWorkspace)),
|
|
568
|
-
React.createElement(TokenConfigMessage, { commands: this.props.commands })),
|
|
641
|
+
React.createElement(TokenConfigMessage, { commands: this.props.commands, settings: this.props })),
|
|
569
642
|
React.createElement("div", { className: "datamesh-connect-divider" }),
|
|
570
|
-
React.createElement(AIChatPanel, { commands: this.props.commands })));
|
|
643
|
+
React.createElement(AIChatPanel, { commands: this.props.commands, settings: this.props })));
|
|
571
644
|
}
|
|
572
645
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Oceanum AI backend is, and what a request to it is signed with.
|
|
3
|
+
*
|
|
4
|
+
* The backend accepts either a Datamesh token (`X-Datamesh-Token`) or an
|
|
5
|
+
* Oceanum.io access token (`Authorization: Bearer`). A token pasted into the
|
|
6
|
+
* settings is explicit, so it wins; otherwise a host that signs users in to
|
|
7
|
+
* Oceanum.io -- Oceanum Notebook -- lends its sign-in through its commands.
|
|
8
|
+
* Plain JupyterLab has no such commands and needs the pasted token.
|
|
9
|
+
*
|
|
10
|
+
* Whether the user is signed in, or can sign in, is read with the registry's
|
|
11
|
+
* queries (`isVisible`, `isEnabled`), never by running a command: every
|
|
12
|
+
* `execute` emits `commandExecuted`, which closes JupyterLab's command palette.
|
|
13
|
+
* The access-token command is run only when a token is actually needed.
|
|
14
|
+
*/
|
|
15
|
+
import type { CommandRegistry } from '@lumino/commands';
|
|
16
|
+
/**
|
|
17
|
+
* The host's command answering with the current Oceanum.io access token (a
|
|
18
|
+
* JWT), or null when signed out. The token is cached and refreshed by the
|
|
19
|
+
* host, so it is asked for on every use rather than kept.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ACCESS_TOKEN_COMMAND = "oceanum-auth:access-token";
|
|
22
|
+
/**
|
|
23
|
+
* The host's command that starts signing in to Oceanum.io. Enabled only where
|
|
24
|
+
* sign-in is configured for the site.
|
|
25
|
+
*/
|
|
26
|
+
export declare const SIGN_IN_COMMAND = "oceanum-auth:sign-in";
|
|
27
|
+
/** The host's command that signs out. Visible only while signed in. */
|
|
28
|
+
export declare const SIGN_OUT_COMMAND = "oceanum-auth:sign-out";
|
|
29
|
+
/** The part of the command registry the sign-in is reached through. */
|
|
30
|
+
export type AuthCommands = Pick<CommandRegistry, 'hasCommand' | 'execute' | 'isEnabled' | 'isVisible'>;
|
|
31
|
+
export type AiCredentialSource = 'datamesh-token' | 'sign-in';
|
|
32
|
+
export interface IAiCredential {
|
|
33
|
+
/** Which credential it is: the 401 message differs. */
|
|
34
|
+
source: AiCredentialSource;
|
|
35
|
+
headers: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The backend address for an `aiBackendUrl` setting value, without a trailing
|
|
39
|
+
* slash so a path can be appended. An invalid value falls back to the default.
|
|
40
|
+
*/
|
|
41
|
+
export declare function aiBackendUrl(value: string | null | undefined): string;
|
|
42
|
+
/** A `datameshToken` setting value, trimmed; '' when there is none. */
|
|
43
|
+
export declare function pastedToken(datameshToken: unknown): string;
|
|
44
|
+
/** Whether the host can sign the user in to Oceanum.io on this site. */
|
|
45
|
+
export declare function canSignIn(commands: AuthCommands): boolean;
|
|
46
|
+
/** Whether the host says the user is signed in to Oceanum.io. */
|
|
47
|
+
export declare function isSignedIn(commands: AuthCommands): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Which credential a request would carry, without fetching it: runs no
|
|
50
|
+
* command, so it is safe to ask whenever something changes.
|
|
51
|
+
*/
|
|
52
|
+
export declare function aiCredentialSource(datameshToken: unknown, commands: AuthCommands): AiCredentialSource | null;
|
|
53
|
+
/**
|
|
54
|
+
* Asks the host for its Oceanum.io access token; null without the command.
|
|
55
|
+
* This runs a command: call it only when a token is needed for a request.
|
|
56
|
+
*/
|
|
57
|
+
export declare function signInToken(commands: AuthCommands): () => Promise<unknown>;
|
|
58
|
+
/**
|
|
59
|
+
* The credential for a request: the pasted Datamesh token if there is one,
|
|
60
|
+
* otherwise the host's sign-in, otherwise null.
|
|
61
|
+
*
|
|
62
|
+
* @param datameshToken The `datameshToken` setting value.
|
|
63
|
+
* @param getSignInToken Asked only without a pasted token. Anything but a
|
|
64
|
+
* non-empty string -- including a rejection -- counts as signed out.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveAiCredential(datameshToken: unknown, getSignInToken: () => Promise<unknown>): Promise<IAiCredential | null>;
|
package/lib/aiBackend.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { OCEANUM_AI_BACKEND_URL } from './constants';
|
|
2
|
+
import { validHttpUrl } from './httpUrl';
|
|
3
|
+
/**
|
|
4
|
+
* The host's command answering with the current Oceanum.io access token (a
|
|
5
|
+
* JWT), or null when signed out. The token is cached and refreshed by the
|
|
6
|
+
* host, so it is asked for on every use rather than kept.
|
|
7
|
+
*/
|
|
8
|
+
export const ACCESS_TOKEN_COMMAND = 'oceanum-auth:access-token';
|
|
9
|
+
/**
|
|
10
|
+
* The host's command that starts signing in to Oceanum.io. Enabled only where
|
|
11
|
+
* sign-in is configured for the site.
|
|
12
|
+
*/
|
|
13
|
+
export const SIGN_IN_COMMAND = 'oceanum-auth:sign-in';
|
|
14
|
+
/** The host's command that signs out. Visible only while signed in. */
|
|
15
|
+
export const SIGN_OUT_COMMAND = 'oceanum-auth:sign-out';
|
|
16
|
+
/**
|
|
17
|
+
* The backend address for an `aiBackendUrl` setting value, without a trailing
|
|
18
|
+
* slash so a path can be appended. An invalid value falls back to the default.
|
|
19
|
+
*/
|
|
20
|
+
export function aiBackendUrl(value) {
|
|
21
|
+
const url = validHttpUrl(value, OCEANUM_AI_BACKEND_URL);
|
|
22
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
|
23
|
+
}
|
|
24
|
+
/** A `datameshToken` setting value, trimmed; '' when there is none. */
|
|
25
|
+
export function pastedToken(datameshToken) {
|
|
26
|
+
return typeof datameshToken === 'string' ? datameshToken.trim() : '';
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Asks a query of the host's command, treating a missing command, or a query
|
|
30
|
+
* that throws, as false. Queries never emit `commandExecuted`.
|
|
31
|
+
*/
|
|
32
|
+
function query(commands, id, which) {
|
|
33
|
+
try {
|
|
34
|
+
return commands.hasCommand(id) && commands[which](id);
|
|
35
|
+
}
|
|
36
|
+
catch (_a) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Whether the host can sign the user in to Oceanum.io on this site. */
|
|
41
|
+
export function canSignIn(commands) {
|
|
42
|
+
return query(commands, SIGN_IN_COMMAND, 'isEnabled');
|
|
43
|
+
}
|
|
44
|
+
/** Whether the host says the user is signed in to Oceanum.io. */
|
|
45
|
+
export function isSignedIn(commands) {
|
|
46
|
+
return query(commands, SIGN_OUT_COMMAND, 'isVisible');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Which credential a request would carry, without fetching it: runs no
|
|
50
|
+
* command, so it is safe to ask whenever something changes.
|
|
51
|
+
*/
|
|
52
|
+
export function aiCredentialSource(datameshToken, commands) {
|
|
53
|
+
if (pastedToken(datameshToken)) {
|
|
54
|
+
return 'datamesh-token';
|
|
55
|
+
}
|
|
56
|
+
if (commands.hasCommand(ACCESS_TOKEN_COMMAND) && isSignedIn(commands)) {
|
|
57
|
+
return 'sign-in';
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Asks the host for its Oceanum.io access token; null without the command.
|
|
63
|
+
* This runs a command: call it only when a token is needed for a request.
|
|
64
|
+
*/
|
|
65
|
+
export function signInToken(commands) {
|
|
66
|
+
return async () => commands.hasCommand(ACCESS_TOKEN_COMMAND)
|
|
67
|
+
? commands.execute(ACCESS_TOKEN_COMMAND)
|
|
68
|
+
: null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The credential for a request: the pasted Datamesh token if there is one,
|
|
72
|
+
* otherwise the host's sign-in, otherwise null.
|
|
73
|
+
*
|
|
74
|
+
* @param datameshToken The `datameshToken` setting value.
|
|
75
|
+
* @param getSignInToken Asked only without a pasted token. Anything but a
|
|
76
|
+
* non-empty string -- including a rejection -- counts as signed out.
|
|
77
|
+
*/
|
|
78
|
+
export async function resolveAiCredential(datameshToken, getSignInToken) {
|
|
79
|
+
const pasted = pastedToken(datameshToken);
|
|
80
|
+
if (pasted) {
|
|
81
|
+
return {
|
|
82
|
+
source: 'datamesh-token',
|
|
83
|
+
headers: { 'X-Datamesh-Token': pasted }
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
let token;
|
|
87
|
+
try {
|
|
88
|
+
token = await getSignInToken();
|
|
89
|
+
}
|
|
90
|
+
catch (_a) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const signedIn = typeof token === 'string' ? token.trim() : '';
|
|
94
|
+
if (!signedIn) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
source: 'sign-in',
|
|
99
|
+
headers: { Authorization: `Bearer ${signedIn}` }
|
|
100
|
+
};
|
|
101
|
+
}
|
package/lib/chatRouter.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
2
|
+
import { type AuthCommands } from './aiBackend';
|
|
2
3
|
import type { Progress } from './progress';
|
|
3
4
|
import type { ObservedRun } from './notebookRun';
|
|
4
5
|
import type { INotebookSnapshot } from './notebookContext';
|
|
@@ -36,14 +37,17 @@ export declare class ChatRouterError extends Error {
|
|
|
36
37
|
}
|
|
37
38
|
export declare class ChatRouter {
|
|
38
39
|
private _settings;
|
|
40
|
+
private _commands;
|
|
39
41
|
private _notebook;
|
|
40
42
|
/**
|
|
43
|
+
* @param _commands Where the host's Oceanum.io sign-in is asked for, when
|
|
44
|
+
* there is no pasted Datamesh token.
|
|
41
45
|
* @param _notebook What the conversation's notebook holds -- the one its
|
|
42
46
|
* answers are placed in -- or null if there is nothing to send. Asked on
|
|
43
47
|
* every request, so it follows the conversation rather than whichever tab
|
|
44
48
|
* happens to be active.
|
|
45
49
|
*/
|
|
46
|
-
constructor(_settings: ISettingRegistry.ISettings, _notebook: () => Promise<INotebookSnapshot | null>);
|
|
50
|
+
constructor(_settings: ISettingRegistry.ISettings, _commands: AuthCommands, _notebook: () => Promise<INotebookSnapshot | null>);
|
|
47
51
|
route(prompt: string, chatHistory?: ChatMessage[], signal?: AbortSignal, onProgress?: (progress: Progress) => void): Promise<RouteResult>;
|
|
48
52
|
/**
|
|
49
53
|
* The notebook half of the execute loop: the code ran in the user's own
|
package/lib/chatRouter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { aiBackendUrl, canSignIn, resolveAiCredential, signInToken } from './aiBackend';
|
|
2
2
|
import { readSse } from './sse';
|
|
3
3
|
import { formatNotebookCells } from './notebookContext';
|
|
4
4
|
export class ChatRouterError extends Error {
|
|
@@ -31,13 +31,16 @@ function asOceanumResponse(data) {
|
|
|
31
31
|
}
|
|
32
32
|
export class ChatRouter {
|
|
33
33
|
/**
|
|
34
|
+
* @param _commands Where the host's Oceanum.io sign-in is asked for, when
|
|
35
|
+
* there is no pasted Datamesh token.
|
|
34
36
|
* @param _notebook What the conversation's notebook holds -- the one its
|
|
35
37
|
* answers are placed in -- or null if there is nothing to send. Asked on
|
|
36
38
|
* every request, so it follows the conversation rather than whichever tab
|
|
37
39
|
* happens to be active.
|
|
38
40
|
*/
|
|
39
|
-
constructor(_settings, _notebook) {
|
|
41
|
+
constructor(_settings, _commands, _notebook) {
|
|
40
42
|
this._settings = _settings;
|
|
43
|
+
this._commands = _commands;
|
|
41
44
|
this._notebook = _notebook;
|
|
42
45
|
}
|
|
43
46
|
async route(prompt, chatHistory = [], signal, onProgress) {
|
|
@@ -99,17 +102,22 @@ export class ChatRouter {
|
|
|
99
102
|
}
|
|
100
103
|
async _send(path, payload, signal, onProgress) {
|
|
101
104
|
var _a;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
// Resolved per request, not once: the sign-in token refreshes, and the
|
|
106
|
+
// user can sign in, sign out or paste a token between two requests.
|
|
107
|
+
const credential = await resolveAiCredential(this._settings.get('datameshToken').composite, signInToken(this._commands));
|
|
108
|
+
if (!credential) {
|
|
109
|
+
throw new ChatRouterError(canSignIn(this._commands)
|
|
110
|
+
? 'Not signed in. Sign in to Oceanum.io to use Oceanum AI, or set a Datamesh token in Settings → Oceanum.io.'
|
|
111
|
+
: 'Datamesh token not configured. Set your token in Settings → Oceanum.io.');
|
|
105
112
|
}
|
|
113
|
+
const backend = aiBackendUrl(this._settings.get('aiBackendUrl').composite);
|
|
106
114
|
let response;
|
|
107
115
|
try {
|
|
108
|
-
response = await fetch(`${
|
|
116
|
+
response = await fetch(`${backend}${path}`, {
|
|
109
117
|
method: 'POST',
|
|
110
118
|
headers: {
|
|
111
119
|
'Content-Type': 'application/json',
|
|
112
|
-
|
|
120
|
+
...credential.headers,
|
|
113
121
|
// Ask for the streamed form only when someone is listening. The
|
|
114
122
|
// server requires the media type to be NAMED -- a wildcard never
|
|
115
123
|
// selects it -- so omitting this is what keeps the plain JSON path
|
|
@@ -126,10 +134,20 @@ export class ChatRouter {
|
|
|
126
134
|
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
|
|
127
135
|
throw err;
|
|
128
136
|
}
|
|
129
|
-
throw new ChatRouterError(`Could not reach Oceanum AI backend at ${
|
|
137
|
+
throw new ChatRouterError(`Could not reach Oceanum AI backend at ${backend}. Is it running?`);
|
|
130
138
|
}
|
|
131
139
|
if (response.status === 401) {
|
|
132
|
-
|
|
140
|
+
let message = 'Invalid or expired Datamesh token.';
|
|
141
|
+
if (credential.source === 'sign-in') {
|
|
142
|
+
message =
|
|
143
|
+
'Your Oceanum.io sign-in was not accepted. Sign in to Oceanum.io again.';
|
|
144
|
+
}
|
|
145
|
+
else if (canSignIn(this._commands)) {
|
|
146
|
+
// A pasted token wins over the sign-in, so say how to fall back to it.
|
|
147
|
+
message +=
|
|
148
|
+
' Update it, or clear it to use your Oceanum.io sign-in, in Settings → Oceanum.io.';
|
|
149
|
+
}
|
|
150
|
+
throw new ChatRouterError(message, 401);
|
|
133
151
|
}
|
|
134
152
|
if (!response.ok) {
|
|
135
153
|
let detail = '';
|
package/lib/constants.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Build-time constants for Oceanum AI integration.
|
|
3
3
|
* These values are compiled into the extension.
|
|
4
4
|
*/
|
|
5
|
-
/** URL of the Oceanum AI backend service */
|
|
5
|
+
/** Default URL of the Oceanum AI backend service (the `aiBackendUrl` setting) */
|
|
6
6
|
export declare const OCEANUM_AI_BACKEND_URL = "https://ai.oceanum.io";
|
|
7
7
|
/** URL of the Datamesh UI embedded in the Datamesh panel */
|
|
8
8
|
export declare const DATAMESH_UI_SERVICE = "https://ui.datamesh.oceanum.io";
|
package/lib/constants.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Build-time constants for Oceanum AI integration.
|
|
3
3
|
* These values are compiled into the extension.
|
|
4
4
|
*/
|
|
5
|
-
/** URL of the Oceanum AI backend service */
|
|
5
|
+
/** Default URL of the Oceanum AI backend service (the `aiBackendUrl` setting) */
|
|
6
6
|
export const OCEANUM_AI_BACKEND_URL = 'https://ai.oceanum.io';
|
|
7
7
|
/** URL of the Datamesh UI embedded in the Datamesh panel */
|
|
8
8
|
export const DATAMESH_UI_SERVICE = 'https://ui.datamesh.oceanum.io';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The Datamesh UI address to use for a `datameshUiUrl` setting value. */
|
|
2
|
+
export declare function validDatameshUiUrl(url: string | null | undefined): URL;
|
|
3
|
+
/**
|
|
4
|
+
* The panel's iframe `src`: the Datamesh UI in embed mode, which hides the
|
|
5
|
+
* Oceanum.io navigator. Any query the setting already has is kept.
|
|
6
|
+
*/
|
|
7
|
+
export declare function datameshUiSrc(url: string | null | undefined): string;
|
|
8
|
+
/**
|
|
9
|
+
* Whether a `message` event was posted by the Datamesh UI panel.
|
|
10
|
+
*
|
|
11
|
+
* Any page or frame can post to this window, so a message counts only when it
|
|
12
|
+
* comes from the panel's iframe (`frame`, its `contentWindow`; `null` when the
|
|
13
|
+
* panel is not open) and from the configured Datamesh UI's origin.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isDatameshUiMessage(event: Pick<MessageEvent, 'origin' | 'source'>, url: string | null | undefined, frame: Window | null): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Datamesh UI panel's address, and which messages may come from it.
|
|
3
|
+
*
|
|
4
|
+
* The address is a setting (`datameshUiUrl`), so a deployment can point the
|
|
5
|
+
* panel at another Datamesh UI -- a dev one, say. A setting is typed in by
|
|
6
|
+
* hand, so anything that is not an http(s) URL falls back to the default
|
|
7
|
+
* rather than ending up in an iframe's `src`.
|
|
8
|
+
*/
|
|
9
|
+
import { DATAMESH_UI_SERVICE } from './constants';
|
|
10
|
+
import { validHttpUrl } from './httpUrl';
|
|
11
|
+
/** The Datamesh UI address to use for a `datameshUiUrl` setting value. */
|
|
12
|
+
export function validDatameshUiUrl(url) {
|
|
13
|
+
return validHttpUrl(url, DATAMESH_UI_SERVICE);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The panel's iframe `src`: the Datamesh UI in embed mode, which hides the
|
|
17
|
+
* Oceanum.io navigator. Any query the setting already has is kept.
|
|
18
|
+
*/
|
|
19
|
+
export function datameshUiSrc(url) {
|
|
20
|
+
const src = validDatameshUiUrl(url);
|
|
21
|
+
src.searchParams.set('embed', '1');
|
|
22
|
+
return src.toString();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether a `message` event was posted by the Datamesh UI panel.
|
|
26
|
+
*
|
|
27
|
+
* Any page or frame can post to this window, so a message counts only when it
|
|
28
|
+
* comes from the panel's iframe (`frame`, its `contentWindow`; `null` when the
|
|
29
|
+
* panel is not open) and from the configured Datamesh UI's origin.
|
|
30
|
+
*/
|
|
31
|
+
export function isDatameshUiMessage(event, url, frame) {
|
|
32
|
+
return (!!frame &&
|
|
33
|
+
event.source === frame &&
|
|
34
|
+
event.origin === validDatameshUiUrl(url).origin);
|
|
35
|
+
}
|
package/lib/httpUrl.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An address typed into a setting, checked before anything uses it.
|
|
3
|
+
*
|
|
4
|
+
* A setting is typed in by hand, so anything that is not an http(s) URL falls
|
|
5
|
+
* back to the default rather than ending up in an iframe's `src` or a
|
|
6
|
+
* request's address.
|
|
7
|
+
*/
|
|
8
|
+
/** `value` as an http(s) URL, or `fallback` when it is empty or not one. */
|
|
9
|
+
export declare function validHttpUrl(value: string | null | undefined, fallback: string): URL;
|