@oceanum/oceanumlab 4.6.2 → 4.7.1
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 +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +24 -2
- package/lib/DatameshWidget.js +171 -45
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +253 -0
- package/lib/auth/tokens.d.ts +95 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +17 -1
- package/lib/chatRouter.js +34 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +8 -16
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +67 -15
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -6
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +294 -59
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +393 -0
- package/src/__tests__/OceanumPanel.spec.tsx +101 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +271 -0
- package/src/__tests__/chatRouter.spec.ts +253 -17
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -0
- package/src/aiBackend.ts +150 -0
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +41 -9
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +8 -15
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +99 -33
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
package/README.md
CHANGED
|
@@ -19,6 +19,62 @@ To install the extension, execute:
|
|
|
19
19
|
pip install oceanumlab
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
## Oceanum.io sign-in
|
|
23
|
+
|
|
24
|
+
A **Sign in to Oceanum.io** control sits in the top bar. Signing in gives the Notebooks tab
|
|
25
|
+
your stored notebooks and lets Oceanum AI act as you, with no Datamesh token to paste.
|
|
26
|
+
|
|
27
|
+
It uses the OAuth **device grant**, run by oceanumlab's Jupyter server extension: click
|
|
28
|
+
**Sign in**, open the link it shows, check the code matches, and sign in on Oceanum.io as
|
|
29
|
+
usual. The dialog closes by itself when you are done. It works with no configuration, and
|
|
30
|
+
from any address — `localhost` on any port, a JupyterHub, a remote server.
|
|
31
|
+
|
|
32
|
+
That last point is why it works this way. A browser sign-in (a popup, a redirect, the
|
|
33
|
+
Oceanum widget) has to tell Auth0 which page to return to, Auth0 only accepts addresses
|
|
34
|
+
registered in advance, and it cannot wildcard a port or an arbitrary host. A JupyterLab's
|
|
35
|
+
address is not knowable ahead of time, so on anything but Oceanum's own sites a browser
|
|
36
|
+
sign-in fails with "Callback URL mismatch". The device grant has no callback.
|
|
37
|
+
|
|
38
|
+
The refresh token stays in the Jupyter server's memory and never reaches the page or the
|
|
39
|
+
disk, so restarting the server signs you out.
|
|
40
|
+
|
|
41
|
+
### Configuration
|
|
42
|
+
|
|
43
|
+
Sign-in is server configuration, not a frontend setting: which Auth0 tenant a notebook signs
|
|
44
|
+
in to, and which services its tokens are sent to, is the deployment's decision, and a
|
|
45
|
+
user-editable setting could be pointed at someone else's. In `jupyter_server_config.py` —
|
|
46
|
+
`~/.jupyter/` for one user, or `etc/jupyter/` under the environment prefix for everyone
|
|
47
|
+
using it; `jupyter --paths` lists both:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
c.OceanumLab.sign_in = "off" # "device" is the default
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To sign in to a deployment other than Oceanum production, replace the environment whole:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
c.OceanumLab.device_environment = {
|
|
57
|
+
"auth0Domain": "auth.example.org",
|
|
58
|
+
"clientId": "<an Auth0 Native application with the Device Code grant>",
|
|
59
|
+
"audience": "",
|
|
60
|
+
"oceanumDomain": "example.org",
|
|
61
|
+
"urls": {
|
|
62
|
+
"datamesh": "https://datamesh.example.org",
|
|
63
|
+
"specs": "https://specs.example.org",
|
|
64
|
+
"manage": "https://manage.example.org",
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**This file is Python, not JSON** — `True`, not `true`. A syntax error there is quiet and
|
|
70
|
+
expensive: traitlets logs it once at startup and then skips the **whole** file, so the
|
|
71
|
+
setting silently does not apply. Check the server's startup log for
|
|
72
|
+
`oceanumlab: Oceanum.io sign-in by device code`.
|
|
73
|
+
|
|
74
|
+
notebook.oceanum.io is a JupyterLite site with no Jupyter server, so none of this applies
|
|
75
|
+
there. It signs in with the Oceanum widget, from its own extension, and lists
|
|
76
|
+
`@oceanum/oceanumlab:device-auth` under `disabledExtensions`.
|
|
77
|
+
|
|
22
78
|
## Uninstall
|
|
23
79
|
|
|
24
80
|
To remove the extension, execute:
|
|
@@ -31,27 +87,59 @@ pip uninstall oceanumlab
|
|
|
31
87
|
|
|
32
88
|
### Development install
|
|
33
89
|
|
|
34
|
-
|
|
90
|
+
The extension is built with [jupyter-builder](https://github.com/jupyterlab/jupyter-builder),
|
|
91
|
+
which bundles it with rspack. You will need:
|
|
92
|
+
|
|
93
|
+
- Python 3.10 or later
|
|
94
|
+
- Node.js 22.12 or later (or 20.19+), which rspack requires
|
|
35
95
|
|
|
36
|
-
The `jlpm` command is
|
|
37
|
-
|
|
38
|
-
|
|
96
|
+
The `jlpm` command is a pinned version of [yarn](https://yarnpkg.com/) that is
|
|
97
|
+
installed with jupyter-builder. You may use `yarn` or `npm` in lieu of `jlpm` below.
|
|
98
|
+
|
|
99
|
+
Create an environment, either with conda, which also installs Node.js and jupyter-builder:
|
|
39
100
|
|
|
40
101
|
```bash
|
|
41
102
|
# Clone the repo to your local environment
|
|
42
103
|
# Change directory to the oceanumlab directory
|
|
43
|
-
|
|
44
|
-
|
|
104
|
+
conda env create -f environment.yml
|
|
105
|
+
conda activate oceanumlab
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
or with a virtual environment, using your own Node.js:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python -m venv .venv
|
|
112
|
+
source .venv/bin/activate
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then install the extension in development mode:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Install the package in development mode. The dev extra installs jupyter-builder
|
|
119
|
+
# and the test extra installs the server test dependencies. This also enables the
|
|
120
|
+
# server extension.
|
|
121
|
+
pip install -e ".[dev,test]"
|
|
45
122
|
# Link your development version of the extension with JupyterLab
|
|
46
123
|
jupyter-builder develop . --overwrite
|
|
47
|
-
# Rebuild extension
|
|
124
|
+
# Rebuild the extension after making changes
|
|
125
|
+
# Unlike the steps above, which you only do once, do this every time you make a change
|
|
48
126
|
jlpm build
|
|
49
127
|
```
|
|
50
128
|
|
|
129
|
+
Every `pip install -e` puts a copy of the built extension back in place of the link,
|
|
130
|
+
so run `jupyter-builder develop . --overwrite` again after reinstalling.
|
|
131
|
+
|
|
132
|
+
The main build commands are:
|
|
133
|
+
|
|
134
|
+
- `jlpm build`: development build, with source maps
|
|
135
|
+
- `jlpm build:prod`: production build, as used for releases
|
|
136
|
+
- `jlpm clean:all`: remove the built TypeScript, the built extension and the lint caches
|
|
137
|
+
|
|
51
138
|
You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.
|
|
52
139
|
|
|
53
140
|
```bash
|
|
54
141
|
# Watch the source directory in one terminal, automatically rebuilding when needed
|
|
142
|
+
# (runs tsc and jupyter-builder in watch mode)
|
|
55
143
|
jlpm watch
|
|
56
144
|
# Run JupyterLab in another terminal
|
|
57
145
|
jupyter lab
|
|
@@ -71,9 +159,9 @@ jupyter lab build --minimize=False
|
|
|
71
159
|
pip uninstall oceanumlab
|
|
72
160
|
```
|
|
73
161
|
|
|
74
|
-
In development mode, you will also need to remove the symlink created by `jupyter-builder develop`
|
|
162
|
+
In development mode, you will also need to remove the symlink created by the `jupyter-builder develop`
|
|
75
163
|
command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
|
|
76
|
-
folder is located. Then you can remove the symlink named
|
|
164
|
+
folder is located. Then you can remove the symlink named `@oceanum/oceanumlab` within that folder.
|
|
77
165
|
|
|
78
166
|
### Testing the extension
|
|
79
167
|
|
|
@@ -81,10 +169,13 @@ folder is located. Then you can remove the symlink named `oceanumlab` within tha
|
|
|
81
169
|
|
|
82
170
|
This extension is using [Pytest](https://docs.pytest.org/) for Python code testing.
|
|
83
171
|
|
|
84
|
-
|
|
172
|
+
The test dependencies are installed by `pip install -e ".[dev,test]"` above. If you
|
|
173
|
+
installed without the test extra, install them (needed only once) and restore the
|
|
174
|
+
front-end extension link:
|
|
85
175
|
|
|
86
176
|
```sh
|
|
87
|
-
pip install -e ".[test]"
|
|
177
|
+
pip install -e ".[dev,test]"
|
|
178
|
+
jupyter-builder develop . --overwrite
|
|
88
179
|
```
|
|
89
180
|
|
|
90
181
|
To execute them, run:
|
package/lib/DatameshUI.d.ts
CHANGED
|
@@ -2,13 +2,8 @@ import React from 'react';
|
|
|
2
2
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
3
3
|
export declare class DatameshUI extends ReactWidget {
|
|
4
4
|
private _url;
|
|
5
|
-
/** `url` is the
|
|
5
|
+
/** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
|
|
6
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
7
|
/** The iframe's window, for telling its messages apart; `null` if none. */
|
|
13
8
|
get frame(): Window | null;
|
|
14
9
|
render(): React.ReactElement;
|
package/lib/DatameshUI.js
CHANGED
|
@@ -2,19 +2,11 @@ import React from 'react';
|
|
|
2
2
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
3
3
|
import { datameshUiSrc } from './datameshUiUrl';
|
|
4
4
|
export class DatameshUI extends ReactWidget {
|
|
5
|
-
/** `url` is the
|
|
5
|
+
/** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
|
|
6
6
|
constructor(_url) {
|
|
7
7
|
super();
|
|
8
8
|
this._url = _url;
|
|
9
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
10
|
/** The iframe's window, for telling its messages apart; `null` if none. */
|
|
19
11
|
get frame() {
|
|
20
12
|
var _a, _b;
|
package/lib/DatameshWidget.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ 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
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
8
9
|
export interface IDatasource {
|
|
9
10
|
id: string;
|
|
10
11
|
label: string;
|
|
@@ -40,12 +41,22 @@ export interface IDatameshWidgetProps {
|
|
|
40
41
|
name: string;
|
|
41
42
|
icon: LabIcon;
|
|
42
43
|
openDatameshUI: any;
|
|
43
|
-
/** The
|
|
44
|
+
/** The deployment's Datamesh UI address. */
|
|
44
45
|
datameshUiUrl: () => string;
|
|
45
46
|
/** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
|
|
46
47
|
datameshUiFrame: () => Window | null;
|
|
48
|
+
/** The `datameshToken` setting. */
|
|
49
|
+
datameshToken: () => string;
|
|
50
|
+
/** The deployment's Oceanum AI address. */
|
|
51
|
+
aiBackendUrl: () => string;
|
|
52
|
+
/** Emitted when the settings change. */
|
|
53
|
+
settingsChanged: ISignal<unknown, void>;
|
|
47
54
|
commands: CommandRegistry;
|
|
48
55
|
getCurrentWidget: () => Widget;
|
|
56
|
+
/** Oceanum.io sign-in, or null on a host with no Oceanum environment. */
|
|
57
|
+
auth?: IOceanumAuth | null;
|
|
58
|
+
/** Open a stored notebook by spec store id. */
|
|
59
|
+
openStoredNotebook?: (id: string) => void;
|
|
49
60
|
}
|
|
50
61
|
/**
|
|
51
62
|
* A widget for Datamesh Connections.
|
|
@@ -59,5 +70,16 @@ export declare class DatameshConnectWidget extends ReactWidget {
|
|
|
59
70
|
constructor(props: IDatameshWidgetProps);
|
|
60
71
|
receiveIFrameMessage(event: MessageEvent): void;
|
|
61
72
|
renderDisplay(datameshWorkspace: IWorkspaceSpec): React.ReactElement;
|
|
73
|
+
/** The Datamesh workspace tab: what this panel showed above the divider. */
|
|
74
|
+
renderDatamesh(): React.ReactElement;
|
|
62
75
|
render(): React.ReactElement;
|
|
76
|
+
/**
|
|
77
|
+
* The visible tab. The widget only holds it and announces changes through
|
|
78
|
+
* `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
|
|
79
|
+
* restores a widget's place in the shell rather than fields on it.
|
|
80
|
+
*/
|
|
81
|
+
get selectedTab(): string;
|
|
82
|
+
selectTab(id: string): void;
|
|
83
|
+
readonly tabChanged: Signal<this, string>;
|
|
84
|
+
private _selectedTab;
|
|
63
85
|
}
|
package/lib/DatameshWidget.js
CHANGED
|
@@ -9,7 +9,9 @@ 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 { StoredNotebooks } from './StoredNotebooks';
|
|
13
|
+
import { Tabs } from './Tabs';
|
|
14
|
+
import { SIGN_IN_COMMAND, aiBackendUrl, aiCredentialSource, canSignIn, pastedToken, resolveAiCredential, signInToken } from './aiBackend';
|
|
13
15
|
import { isDatameshUiMessage } from './datameshUiUrl';
|
|
14
16
|
import { describeProgress, onProgress } from './progress';
|
|
15
17
|
// Configure marked for safe rendering
|
|
@@ -284,30 +286,73 @@ class DatameshWorkspaceDisplay extends React.Component {
|
|
|
284
286
|
});
|
|
285
287
|
}
|
|
286
288
|
}
|
|
289
|
+
function readAiAccess(commands, settings) {
|
|
290
|
+
const datameshToken = settings.datameshToken();
|
|
291
|
+
return {
|
|
292
|
+
source: aiCredentialSource(datameshToken, commands),
|
|
293
|
+
pasted: pastedToken(datameshToken),
|
|
294
|
+
canSignIn: canSignIn(commands),
|
|
295
|
+
backend: aiBackendUrl(settings.aiBackendUrl())
|
|
296
|
+
};
|
|
297
|
+
}
|
|
287
298
|
/**
|
|
288
|
-
*
|
|
289
|
-
*
|
|
299
|
+
* How the sidebar can reach Oceanum AI, read again whenever the settings or the
|
|
300
|
+
* commands change: the host calls `notifyCommandChanged` when the user signs
|
|
301
|
+
* in or out.
|
|
302
|
+
*
|
|
303
|
+
* Only the registry's queries are used, never `execute`, and nothing runs on a
|
|
304
|
+
* timer: every executed command closes JupyterLab's command palette.
|
|
290
305
|
*/
|
|
291
|
-
function
|
|
292
|
-
const [
|
|
306
|
+
function useAiAccess(commands, settings) {
|
|
307
|
+
const [access, setAccess] = React.useState(() => readAiAccess(commands, settings));
|
|
293
308
|
React.useEffect(() => {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
309
|
+
const update = () => {
|
|
310
|
+
const next = readAiAccess(commands, settings);
|
|
311
|
+
// Same content keeps the same object, so nothing downstream re-runs.
|
|
312
|
+
setAccess(prev => JSON.stringify(prev) === JSON.stringify(next) ? prev : next);
|
|
313
|
+
};
|
|
314
|
+
// Anything that changed between the first render and now.
|
|
315
|
+
update();
|
|
316
|
+
commands.commandChanged.connect(update);
|
|
317
|
+
settings.settingsChanged.connect(update);
|
|
318
|
+
return () => {
|
|
319
|
+
commands.commandChanged.disconnect(update);
|
|
320
|
+
settings.settingsChanged.disconnect(update);
|
|
321
|
+
};
|
|
322
|
+
}, [commands, settings]);
|
|
323
|
+
return access;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Shows a message prompting the user to sign in to Oceanum.io, or to configure
|
|
327
|
+
* their Datamesh token where the site has no sign-in. Only renders when there
|
|
328
|
+
* is no credential.
|
|
329
|
+
*/
|
|
330
|
+
function TokenConfigMessage({ commands, settings }) {
|
|
331
|
+
const access = useAiAccess(commands, settings);
|
|
332
|
+
if (access.source) {
|
|
299
333
|
return null;
|
|
300
334
|
}
|
|
335
|
+
const openSettings = () => commands.execute('settingeditor:open', { query: 'Oceanum' });
|
|
336
|
+
if (access.canSignIn) {
|
|
337
|
+
const signIn = () => commands.execute(SIGN_IN_COMMAND).catch(() => {
|
|
338
|
+
console.warn('Oceanum AI: could not start signing in to Oceanum.io.');
|
|
339
|
+
});
|
|
340
|
+
return (React.createElement("div", { className: "oceanum-token-config" },
|
|
341
|
+
React.createElement("a", { onClick: () => void signIn() }, "Sign in to Oceanum.io"),
|
|
342
|
+
" to use Oceanum AI, or set a ",
|
|
343
|
+
React.createElement("a", { onClick: openSettings }, "Datamesh token"),
|
|
344
|
+
"."));
|
|
345
|
+
}
|
|
301
346
|
return (React.createElement("div", { className: "oceanum-token-config" },
|
|
302
347
|
"Set your",
|
|
303
348
|
' ',
|
|
304
349
|
React.createElement("a", { onClick: () => commands.execute('settingeditor:open', { query: 'Oceanum' }) }, "Datamesh token"),
|
|
305
350
|
' ',
|
|
306
|
-
"to enable Oceanum.
|
|
351
|
+
"to enable Oceanum.io services",
|
|
307
352
|
' ',
|
|
308
353
|
React.createElement("a", { href: "https://home.oceanum.io/account", target: "_blank", rel: "noopener noreferrer" }, "Get token here")));
|
|
309
354
|
}
|
|
310
|
-
function AIChatPanel({ commands }) {
|
|
355
|
+
function AIChatPanel({ commands, settings }) {
|
|
311
356
|
const [messages, setMessages] = React.useState([]);
|
|
312
357
|
const [input, setInput] = React.useState('');
|
|
313
358
|
const [loading, setLoading] = React.useState(false);
|
|
@@ -322,49 +367,75 @@ function AIChatPanel({ commands }) {
|
|
|
322
367
|
const [history, setHistory] = React.useState([]);
|
|
323
368
|
const [historyIndex, setHistoryIndex] = React.useState(-1);
|
|
324
369
|
const [tempInput, setTempInput] = React.useState('');
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
const
|
|
370
|
+
// Which credential and address the chat has, as the settings and the host
|
|
371
|
+
// report them.
|
|
372
|
+
const access = useAiAccess(commands, settings);
|
|
373
|
+
// What the capabilities depend on: which credential, the pasted token and
|
|
374
|
+
// the address. Not the sign-in token itself: it refreshes, and finding it out
|
|
375
|
+
// runs the host's command.
|
|
376
|
+
const capabilitiesKey = access.source
|
|
377
|
+
? JSON.stringify([access.source, access.pasted, access.backend])
|
|
378
|
+
: '';
|
|
328
379
|
// Check if code capability is enabled (null = loading/unknown)
|
|
329
380
|
const [codeEnabled, setCodeEnabled] = React.useState(null);
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (currentToken !== token) {
|
|
335
|
-
setToken(currentToken);
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
const interval = setInterval(checkToken, 1000);
|
|
339
|
-
return () => clearInterval(interval);
|
|
340
|
-
}, [token]);
|
|
341
|
-
// Fetch capabilities when token changes
|
|
381
|
+
// The sidebar's one pending access-token request, shared rather than
|
|
382
|
+
// repeated if the capabilities are asked for again before it answers.
|
|
383
|
+
const tokenRequest = React.useRef(null);
|
|
384
|
+
// Fetch capabilities when the credential or the address changes
|
|
342
385
|
React.useEffect(() => {
|
|
343
|
-
if (!
|
|
386
|
+
if (!access.source) {
|
|
344
387
|
setCodeEnabled(null);
|
|
345
388
|
return;
|
|
346
389
|
}
|
|
390
|
+
// An answer for a credential that has since changed is dropped.
|
|
391
|
+
let current = true;
|
|
392
|
+
const signInTokenOnce = () => {
|
|
393
|
+
if (!tokenRequest.current) {
|
|
394
|
+
tokenRequest.current = signInToken(commands)().finally(() => {
|
|
395
|
+
tokenRequest.current = null;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return tokenRequest.current;
|
|
399
|
+
};
|
|
347
400
|
const fetchCapabilities = async () => {
|
|
401
|
+
const credential = await resolveAiCredential(access.pasted, signInTokenOnce);
|
|
402
|
+
if (!current) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (!credential) {
|
|
406
|
+
// The host says signed in but has no token to give: leave it to a
|
|
407
|
+
// request to report, rather than hide the chat.
|
|
408
|
+
setCodeEnabled(null);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
348
411
|
try {
|
|
349
|
-
const response = await fetch(`${
|
|
350
|
-
headers:
|
|
351
|
-
'X-Datamesh-Token': token
|
|
352
|
-
}
|
|
412
|
+
const response = await fetch(`${access.backend}/api/capabilities`, {
|
|
413
|
+
headers: credential.headers
|
|
353
414
|
});
|
|
415
|
+
if (!current) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
354
418
|
if (response.ok) {
|
|
355
419
|
const data = await response.json();
|
|
356
|
-
|
|
420
|
+
if (current) {
|
|
421
|
+
setCodeEnabled(data.code === true);
|
|
422
|
+
}
|
|
357
423
|
}
|
|
358
424
|
else {
|
|
359
425
|
setCodeEnabled(false);
|
|
360
426
|
}
|
|
361
427
|
}
|
|
362
428
|
catch (_a) {
|
|
363
|
-
|
|
429
|
+
if (current) {
|
|
430
|
+
setCodeEnabled(false);
|
|
431
|
+
}
|
|
364
432
|
}
|
|
365
433
|
};
|
|
366
434
|
void fetchCapabilities();
|
|
367
|
-
|
|
435
|
+
return () => {
|
|
436
|
+
current = false;
|
|
437
|
+
};
|
|
438
|
+
}, [capabilitiesKey]);
|
|
368
439
|
// Auto-scroll to bottom when messages change
|
|
369
440
|
React.useEffect(() => {
|
|
370
441
|
var _a;
|
|
@@ -501,8 +572,8 @@ function AIChatPanel({ commands }) {
|
|
|
501
572
|
return;
|
|
502
573
|
}
|
|
503
574
|
};
|
|
504
|
-
// Hide AI panel if no
|
|
505
|
-
if (!
|
|
575
|
+
// Hide AI panel if no credential or code capability is not enabled
|
|
576
|
+
if (!access.source || codeEnabled === false) {
|
|
506
577
|
return null;
|
|
507
578
|
}
|
|
508
579
|
return (React.createElement("div", { className: "oceanum-ai-chat" },
|
|
@@ -529,6 +600,37 @@ function AIChatPanel({ commands }) {
|
|
|
529
600
|
? commands.execute('oceanum-ai:stop')
|
|
530
601
|
: handleSubmit()), disabled: !loading && !input.trim(), title: loading ? 'Stop the current response' : undefined }, loading ? 'Stop' : 'Send'))));
|
|
531
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* The panel's tabs.
|
|
605
|
+
*
|
|
606
|
+
* A component rather than a list built in `render`, because whether Oceanum AI is
|
|
607
|
+
* reachable is not static: `useAiAccess` re-reads it when the settings change and when
|
|
608
|
+
* the host signals a sign-in or sign-out. An AI tab is only offered when the chat has a
|
|
609
|
+
* credential to work with — otherwise it would open on the same nothing the panel
|
|
610
|
+
* rendered before there were tabs, except now with a label promising otherwise.
|
|
611
|
+
*/
|
|
612
|
+
function PanelTabs({ auth, commands, settings, renderDatamesh, selected, onSelect }) {
|
|
613
|
+
const access = useAiAccess(commands, settings);
|
|
614
|
+
const tabs = [
|
|
615
|
+
{
|
|
616
|
+
id: 'notebooks',
|
|
617
|
+
label: 'Notebooks',
|
|
618
|
+
render: () =>
|
|
619
|
+
// An auth with no environment is a provider that has nothing to sign in to (the
|
|
620
|
+
// server has sign-in turned off). Asking the user to sign in would be a dead end.
|
|
621
|
+
(auth === null || auth === void 0 ? void 0 : auth.environment) ? (React.createElement(StoredNotebooks, { auth: auth })) : (React.createElement("div", { className: "oceanum-text-empty" }, "Oceanum.io sign-in is not configured for this host."))
|
|
622
|
+
},
|
|
623
|
+
{ id: 'datamesh', label: 'Datamesh', render: renderDatamesh }
|
|
624
|
+
];
|
|
625
|
+
if (access.source) {
|
|
626
|
+
tabs.push({
|
|
627
|
+
id: 'ai',
|
|
628
|
+
label: 'Oceanum AI',
|
|
629
|
+
render: () => React.createElement(AIChatPanel, { commands: commands, settings: settings })
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
return React.createElement(Tabs, { tabs: tabs, selected: selected, onSelect: onSelect });
|
|
633
|
+
}
|
|
532
634
|
/**
|
|
533
635
|
* A widget for Datamesh Connections.
|
|
534
636
|
*/
|
|
@@ -536,6 +638,10 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
536
638
|
constructor(props) {
|
|
537
639
|
super();
|
|
538
640
|
this.datameshWorkspaceSpec = null;
|
|
641
|
+
this.tabChanged = new Signal(this);
|
|
642
|
+
// Notebooks first: on notebook.oceanum.io this panel is how a user reaches their
|
|
643
|
+
// work, so it opens there rather than on Datamesh.
|
|
644
|
+
this._selectedTab = 'notebooks';
|
|
539
645
|
this.props = props;
|
|
540
646
|
this.renderSignal = new Signal(this);
|
|
541
647
|
this.renderDisplay = this.renderDisplay.bind(this);
|
|
@@ -561,18 +667,38 @@ export class DatameshConnectWidget extends ReactWidget {
|
|
|
561
667
|
React.createElement("a", { onClick: this.props.openDatameshUI }, "Open"),
|
|
562
668
|
" the Oceanum Datamesh UI to add datasources."))));
|
|
563
669
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
|
|
568
|
-
React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
|
|
670
|
+
/** The Datamesh workspace tab: what this panel showed above the divider. */
|
|
671
|
+
renderDatamesh() {
|
|
672
|
+
return (React.createElement(React.Fragment, null,
|
|
569
673
|
React.createElement("div", { className: "datamesh-workspace-header" },
|
|
570
674
|
React.createElement("span", null, "Datamesh Workspace"),
|
|
571
675
|
React.createElement("div", { className: "open-datamesh-ui", onClick: this.props.openDatameshUI, title: "Open Datamesh UI" }, React.createElement(addIcon.react, { height: "20px", verticalAlign: "middle" }))),
|
|
572
676
|
React.createElement("div", { className: "datamesh-connect-workspace" },
|
|
573
677
|
React.createElement(UseSignal, { signal: this.renderSignal, initialArgs: null }, (_, datameshWorkspace) => this.renderDisplay(datameshWorkspace)),
|
|
574
|
-
React.createElement(TokenConfigMessage, { commands: this.props.commands }))
|
|
575
|
-
|
|
576
|
-
|
|
678
|
+
React.createElement(TokenConfigMessage, { commands: this.props.commands, settings: this.props }))));
|
|
679
|
+
}
|
|
680
|
+
render() {
|
|
681
|
+
return (React.createElement("div", { className: "datamesh-connect" },
|
|
682
|
+
React.createElement("header", { className: "oceanum-sidebar-header" },
|
|
683
|
+
React.createElement(this.props.icon.react, { tag: "span", width: "auto", height: "20px", verticalAlign: "middle" }),
|
|
684
|
+
React.createElement("span", { className: "oceanum-sidebar-title" }, "Oceanum.io")),
|
|
685
|
+
React.createElement(UseSignal, { signal: this.tabChanged, initialArgs: this.selectedTab }, () => {
|
|
686
|
+
var _a;
|
|
687
|
+
return (React.createElement(PanelTabs, { auth: (_a = this.props.auth) !== null && _a !== void 0 ? _a : null, commands: this.props.commands, settings: this.props, renderDatamesh: () => this.renderDatamesh(), selected: this.selectedTab, onSelect: id => this.selectTab(id) }));
|
|
688
|
+
})));
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The visible tab. The widget only holds it and announces changes through
|
|
692
|
+
* `tabChanged`; the plugin persists it in IStateDB, because the layout restorer
|
|
693
|
+
* restores a widget's place in the shell rather than fields on it.
|
|
694
|
+
*/
|
|
695
|
+
get selectedTab() {
|
|
696
|
+
return this._selectedTab;
|
|
697
|
+
}
|
|
698
|
+
selectTab(id) {
|
|
699
|
+
if (id !== this._selectedTab) {
|
|
700
|
+
this._selectedTab = id;
|
|
701
|
+
this.tabChanged.emit(id);
|
|
702
|
+
}
|
|
577
703
|
}
|
|
578
704
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { IOceanumAuth } from './auth/tokens';
|
|
3
|
+
import { ISpecSummary } from './specStore';
|
|
4
|
+
export interface IStoredNotebooksProps {
|
|
5
|
+
auth: IOceanumAuth;
|
|
6
|
+
/**
|
|
7
|
+
* Open a stored notebook, when this host can. Undefined where nothing has registered
|
|
8
|
+
* a way to open one — today that is native JupyterLab, which has no spec store drive
|
|
9
|
+
* until OCE-182. The list then reads rather than offering an action that does nothing.
|
|
10
|
+
*/
|
|
11
|
+
onOpen?: (item: ISpecSummary) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function StoredNotebooks({ auth, onOpen }: IStoredNotebooksProps): React.ReactElement;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { listNotebooks, partitionNotebooks } from './specStore';
|
|
3
|
+
function Section({ title, items, empty, onOpen }) {
|
|
4
|
+
return (React.createElement("div", { className: "oceanum-notebooks-section" },
|
|
5
|
+
React.createElement("div", { className: "oceanum-notebooks-heading" },
|
|
6
|
+
title,
|
|
7
|
+
React.createElement("span", { className: "oceanum-notebooks-count" }, items.length)),
|
|
8
|
+
items.length === 0 ? (React.createElement("div", { className: "oceanum-text-empty" }, empty)) : (React.createElement("ul", { className: "oceanum-notebooks-list" }, items.map(item => {
|
|
9
|
+
var _a, _b;
|
|
10
|
+
return (React.createElement("li", { key: item.id }, onOpen ? (React.createElement("button", { type: "button", className: "oceanum-notebooks-item", onClick: () => onOpen(item), title: (_a = item.description) !== null && _a !== void 0 ? _a : undefined },
|
|
11
|
+
React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled'))) : (React.createElement("div", { className: "oceanum-notebooks-item oceanum-notebooks-item-static", title: (_b = item.description) !== null && _b !== void 0 ? _b : undefined },
|
|
12
|
+
React.createElement("span", { className: "oceanum-notebooks-name" }, item.name || 'Untitled')))));
|
|
13
|
+
})))));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
|
|
17
|
+
*/
|
|
18
|
+
export function StoredNotebooks({ auth, onOpen }) {
|
|
19
|
+
const [load, setLoad] = React.useState({ state: 'loading' });
|
|
20
|
+
const [user, setUser] = React.useState(auth.user);
|
|
21
|
+
// Reload whenever the signed-in user changes, so signing in or out does not leave a
|
|
22
|
+
// stale list from the previous session on screen.
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
const onUser = () => setUser(auth.user);
|
|
25
|
+
auth.userChanged.connect(onUser);
|
|
26
|
+
return () => {
|
|
27
|
+
auth.userChanged.disconnect(onUser);
|
|
28
|
+
};
|
|
29
|
+
}, [auth]);
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
var _a, _b;
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
const specs = (_b = (_a = auth.urls) === null || _a === void 0 ? void 0 : _a.specs) !== null && _b !== void 0 ? _b : null;
|
|
34
|
+
if (!user || !specs) {
|
|
35
|
+
setLoad({ state: 'ready', items: [] });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setLoad({ state: 'loading' });
|
|
39
|
+
void (async () => {
|
|
40
|
+
try {
|
|
41
|
+
const token = await auth.getAccessToken();
|
|
42
|
+
if (cancelled) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!token) {
|
|
46
|
+
setLoad({ state: 'ready', items: [] });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const items = await listNotebooks(specs, token);
|
|
50
|
+
if (!cancelled) {
|
|
51
|
+
setLoad({ state: 'ready', items });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!cancelled) {
|
|
56
|
+
setLoad({
|
|
57
|
+
state: 'error',
|
|
58
|
+
message: error instanceof Error
|
|
59
|
+
? error.message
|
|
60
|
+
: 'Could not reach Oceanum.io.'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
};
|
|
68
|
+
}, [auth, user]);
|
|
69
|
+
if (!user) {
|
|
70
|
+
return (React.createElement("div", { className: "oceanum-text-empty" }, "Sign in to Oceanum.io to see your notebooks."));
|
|
71
|
+
}
|
|
72
|
+
if (load.state === 'loading') {
|
|
73
|
+
return React.createElement("div", { className: "oceanum-text-empty" }, "Loading\u2026");
|
|
74
|
+
}
|
|
75
|
+
if (load.state === 'error') {
|
|
76
|
+
// Never render the sections alongside an error: their empty messages would claim
|
|
77
|
+
// the account has no notebooks when the load simply failed.
|
|
78
|
+
return React.createElement("div", { className: "oceanum-text-error" }, load.message);
|
|
79
|
+
}
|
|
80
|
+
const { mine, shared } = partitionNotebooks(load.items, user.email);
|
|
81
|
+
return (React.createElement("div", { className: "oceanum-notebooks" },
|
|
82
|
+
React.createElement(Section, { title: "My notebooks", items: mine, empty: "You have not saved any notebooks yet.", onOpen: onOpen }),
|
|
83
|
+
React.createElement(Section, { title: "Shared with me", items: shared, empty: "No notebooks have been shared with you.", onOpen: onOpen })));
|
|
84
|
+
}
|