@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.
Files changed (62) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +24 -2
  5. package/lib/DatameshWidget.js +171 -45
  6. package/lib/StoredNotebooks.d.ts +16 -0
  7. package/lib/StoredNotebooks.js +84 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +66 -0
  11. package/lib/aiBackend.js +101 -0
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +253 -0
  20. package/lib/auth/tokens.d.ts +95 -0
  21. package/lib/auth/tokens.js +9 -0
  22. package/lib/chatRouter.d.ts +17 -1
  23. package/lib/chatRouter.js +34 -9
  24. package/lib/constants.d.ts +1 -1
  25. package/lib/constants.js +1 -1
  26. package/lib/datameshUiUrl.d.ts +2 -2
  27. package/lib/datameshUiUrl.js +8 -16
  28. package/lib/httpUrl.d.ts +9 -0
  29. package/lib/httpUrl.js +20 -0
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +67 -15
  32. package/lib/specStore.d.ts +37 -0
  33. package/lib/specStore.js +57 -0
  34. package/package.json +9 -1
  35. package/schema/datamesh-connect.json +3 -6
  36. package/src/DatameshUI.tsx +1 -10
  37. package/src/DatameshWidget.tsx +294 -59
  38. package/src/StoredNotebooks.tsx +166 -0
  39. package/src/Tabs.tsx +102 -0
  40. package/src/__tests__/DatameshWidget.spec.tsx +393 -0
  41. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  42. package/src/__tests__/Tabs.spec.tsx +168 -0
  43. package/src/__tests__/aiBackend.spec.ts +271 -0
  44. package/src/__tests__/chatRouter.spec.ts +253 -17
  45. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  46. package/src/__tests__/pluginArity.spec.ts +24 -0
  47. package/src/__tests__/serverAuth.spec.ts +277 -0
  48. package/src/__tests__/settingsSchema.spec.ts +73 -0
  49. package/src/__tests__/specStore.spec.ts +126 -0
  50. package/src/aiBackend.ts +150 -0
  51. package/src/auth/accountWidget.ts +113 -0
  52. package/src/auth/claims.ts +30 -0
  53. package/src/auth/plugin.ts +155 -0
  54. package/src/auth/serverAuth.ts +341 -0
  55. package/src/auth/tokens.ts +104 -0
  56. package/src/chatRouter.ts +41 -9
  57. package/src/constants.ts +1 -1
  58. package/src/datameshUiUrl.ts +8 -15
  59. package/src/httpUrl.ts +23 -0
  60. package/src/index.ts +99 -33
  61. package/src/specStore.ts +82 -0
  62. package/style/index.css +158 -0
package/lib/index.js CHANGED
@@ -2,6 +2,7 @@ import { ILayoutRestorer, ILabStatus } from '@jupyterlab/application';
2
2
  import { ICommandPalette, Notification } from '@jupyterlab/apputils';
3
3
  import { LabIcon } from '@jupyterlab/ui-components';
4
4
  import { find } from '@lumino/algorithm';
5
+ import { Signal } from '@lumino/signaling';
5
6
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
6
7
  import { IStateDB } from '@jupyterlab/statedb';
7
8
  import { INotebookTracker } from '@jupyterlab/notebook';
@@ -11,11 +12,13 @@ import { requestAPI } from './handler';
11
12
  import { ChatRouter, ChatRouterError } from './chatRouter';
12
13
  import { reporterFor } from './progress';
13
14
  import { ConversationPin } from './conversationPin';
15
+ import { deviceAuthPlugin } from './auth/plugin';
16
+ import { IOceanumAuth } from './auth/tokens';
14
17
  import { snapshotFromIpynb, snapshotOf } from './notebookContext';
15
18
  import { notebookHost } from './notebookHost';
16
19
  import { KernelHandoff } from './kernelHandoff';
17
20
  import { runChatLoop, STOPPED } from './aiLoop';
18
- import { DATAMESH_UI_SERVICE, MAX_OBSERVE_ROUNDS } from './constants';
21
+ import { DATAMESH_UI_SERVICE, MAX_OBSERVE_ROUNDS, OCEANUM_AI_BACKEND_URL } from './constants';
19
22
  import '../style/index.css';
20
23
  import oceanumSvg from '../style/icons/oceanum.svg';
21
24
  // Theme-adaptive icon using currentColor (auto-adapts to light/dark themes)
@@ -37,10 +40,22 @@ export const datamesh_connect_extension = {
37
40
  ISettingRegistry,
38
41
  IStateDB
39
42
  ],
40
- activate: (app, palette, restorer, status, settingRegistry) => {
43
+ // Optional so the panel still loads on a host with no Oceanum environment; the
44
+ // Notebooks tab then explains why it is empty instead of the panel failing to start.
45
+ optional: [IOceanumAuth],
46
+ activate: (app, palette, restorer, status, settingRegistry, stateDB, auth) => {
47
+ var _a, _b, _c, _d;
41
48
  console.log('Oceanum datamesh connect extension is loaded');
42
- // The `datameshUiUrl` setting; the default until the settings load.
43
- let datameshUiUrl = DATAMESH_UI_SERVICE;
49
+ // Where the Datamesh UI and Oceanum AI live is the deployment's business, so it comes
50
+ // from the sign-in environment (the Jupyter server's configuration, or the notebook
51
+ // site's) and falls back to production. These were user settings until 4.7.1; a
52
+ // user-editable service address is somewhere to send the user's credential.
53
+ const datameshUiUrl = (_b = (_a = auth === null || auth === void 0 ? void 0 : auth.urls) === null || _a === void 0 ? void 0 : _a.datameshUi) !== null && _b !== void 0 ? _b : DATAMESH_UI_SERVICE;
54
+ const aiBackendUrl = (_d = (_c = auth === null || auth === void 0 ? void 0 : auth.urls) === null || _c === void 0 ? void 0 : _c.ai) !== null && _d !== void 0 ? _d : OCEANUM_AI_BACKEND_URL;
55
+ // The `datameshToken` setting for the AI chat; empty until the settings load.
56
+ let datameshTokenSetting = '';
57
+ // Tells the sidebar the settings above have changed.
58
+ const settingsChanged = new Signal(app);
44
59
  const findDatameshUI = () => find(app.shell.widgets('main'), (widget) => widget.id === 'datamesh-ui');
45
60
  //Try to get the datamesh token from the settings
46
61
  const updateSettings = (set) => {
@@ -53,12 +68,8 @@ export const datamesh_connect_extension = {
53
68
  }).then(res => console.log(res));
54
69
  }
55
70
  window.injectToken = set.get('injectToken').user;
56
- // An open Datamesh UI panel follows a change of address.
57
- datameshUiUrl = set.get('datameshUiUrl').composite;
58
- const datameshUI = findDatameshUI();
59
- if (datameshUI) {
60
- datameshUI.url = datameshUiUrl;
61
- }
71
+ datameshTokenSetting = set.get('datameshToken').composite;
72
+ settingsChanged.emit();
62
73
  };
63
74
  //Try to get the datamesh token from the envars
64
75
  Promise.all([app.restored, settingRegistry.load(PLUGIN_ID)])
@@ -95,15 +106,45 @@ export const datamesh_connect_extension = {
95
106
  openDatameshUI: openDatameshUI,
96
107
  datameshUiUrl: () => datameshUiUrl,
97
108
  datameshUiFrame: () => { var _a, _b; return (_b = (_a = findDatameshUI()) === null || _a === void 0 ? void 0 : _a.frame) !== null && _b !== void 0 ? _b : null; },
109
+ datameshToken: () => datameshTokenSetting,
110
+ aiBackendUrl: () => aiBackendUrl,
111
+ settingsChanged,
98
112
  commands: app.commands,
99
- getCurrentWidget
113
+ getCurrentWidget,
114
+ auth
115
+ // No `openStoredNotebook`: nothing can open a spec store record by id yet.
116
+ // share-oceanum has `oceanum-share:open`, but it takes no arguments — it opens
117
+ // its own picker — so there is nothing to hand an id to. Opening arrives with
118
+ // the drive in OCE-182, where the contents API makes it the ordinary path.
119
+ // Until then the list reads rather than offering a click that does nothing.
100
120
  });
101
121
  datameshConnectWidget.id = 'datamesh-connect';
102
122
  datameshConnectWidget.title.icon = oceanumIcon;
103
123
  datameshConnectWidget.title.caption = 'Datamesh Connect';
104
124
  restorer.add(datameshConnectWidget, 'datamesh-connect');
125
+ // The layout restorer restores the widget's place in the shell, not fields on it,
126
+ // so the chosen tab is kept here. Restoring is best effort: a missing or unreadable
127
+ // entry just leaves the default.
128
+ const TAB_STATE_KEY = `${PLUGIN_ID}:tab`;
129
+ void stateDB
130
+ .fetch(TAB_STATE_KEY)
131
+ .then(value => {
132
+ if (typeof value === 'string') {
133
+ datameshConnectWidget.selectTab(value);
134
+ }
135
+ })
136
+ .catch(() => undefined);
137
+ datameshConnectWidget.tabChanged.connect((_, id) => {
138
+ void stateDB.save(TAB_STATE_KEY, id);
139
+ });
105
140
  // Rank has been chosen somewhat arbitrarily to give priority to the running
106
141
  // sessions widget in the sidebar.
142
+ //
143
+ // Oceanum Notebook wants this panel first instead, but that is a property of that
144
+ // distribution rather than of the extension: in a plain JupyterLab the file browser
145
+ // is the primary navigation surface and displacing it would be a regression. A
146
+ // distribution moves it with JupyterLab's own shell user-layout settings, keyed on
147
+ // this widget's id, rather than this extension hard-coding one host's preference.
107
148
  app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
108
149
  app.commands.addCommand('datamesh-ui:open', {
109
150
  execute: (args) => {
@@ -121,7 +162,8 @@ export const oceanum_ai_extension = {
121
162
  id: '@oceanum/oceanumlab:ai-chat',
122
163
  autoStart: true,
123
164
  requires: [INotebookTracker, ISettingRegistry],
124
- activate: (app, notebookTracker, settingRegistry) => {
165
+ optional: [IOceanumAuth],
166
+ activate: (app, notebookTracker, settingRegistry, auth) => {
125
167
  console.log('Oceanum AI chat extension is loaded');
126
168
  const SETTINGS_ID = '@oceanum/oceanumlab:datamesh-connect';
127
169
  settingRegistry
@@ -137,7 +179,7 @@ export const oceanum_ai_extension = {
137
179
  // The panel the current request read its selected cell from, if the
138
180
  // notebook was open: a code answer may replace that cell only there.
139
181
  let readFrom = null;
140
- const router = new ChatRouter(settings, async () => {
182
+ const router = new ChatRouter(settings, app.commands, async () => {
141
183
  const open = await pin.current();
142
184
  readFrom = open;
143
185
  if (open) {
@@ -157,7 +199,7 @@ export const oceanum_ai_extension = {
157
199
  // Gone: an answer's cells will go into a new notebook instead.
158
200
  return null;
159
201
  }
160
- });
202
+ }, () => { var _a, _b; return (_b = (_a = auth === null || auth === void 0 ? void 0 : auth.urls) === null || _a === void 0 ? void 0 : _a.ai) !== null && _b !== void 0 ? _b : ''; });
161
203
  // Asked only when an answer has blocks to place: brings the
162
204
  // conversation's notebook to the front, opening it again if closed.
163
205
  const handoff = new KernelHandoff(() => pin.show(), () => readFrom);
@@ -277,4 +319,14 @@ export const oceanum_ai_extension = {
277
319
  });
278
320
  }
279
321
  };
280
- export default [datamesh_connect_extension, oceanum_ai_extension];
322
+ /**
323
+ * The sign-in contract, exported so another extension can provide or consume it: a Lumino
324
+ * token is matched by object identity, so everyone has to import this one object, and
325
+ * JupyterLab shares `@oceanum/oceanumlab` between extensions as a singleton module.
326
+ */
327
+ export * from './auth/tokens';
328
+ export default [
329
+ deviceAuthPlugin,
330
+ datamesh_connect_extension,
331
+ oceanum_ai_extension
332
+ ];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reading the Oceanum spec store's `notebook` records.
3
+ *
4
+ * Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
5
+ * share-oceanum package has a fuller client (create, update, permissions), and when
6
+ * sharing moves here the two should become one rather than sitting side by side.
7
+ */
8
+ /** A spec store list entry. The list omits the notebook body. */
9
+ export interface ISpecSummary {
10
+ id: string;
11
+ name: string;
12
+ description: string | null;
13
+ /** Set when the record is created and never updated, so this is a creation time. */
14
+ modified: string;
15
+ /** Null unless the caller created the record: the store hides other creators. */
16
+ creator: string | null;
17
+ }
18
+ /**
19
+ * Every notebook the signed-in user may read: their own and those shared with them.
20
+ *
21
+ * The store answers an unauthenticated caller with an empty list rather than an error,
22
+ * so a missing token is indistinguishable from an empty account — callers should check
23
+ * they are signed in before showing "no notebooks".
24
+ */
25
+ export declare function listNotebooks(specsUrl: string, token: string, fetcher?: typeof fetch): Promise<ISpecSummary[]>;
26
+ /**
27
+ * Split a listing into the caller's own notebooks and the rest, newest first.
28
+ *
29
+ * The store only sends `creator` on records the caller created, so anything else it
30
+ * returns is shared with them — directly, or because it is public. Addresses are
31
+ * compared case-insensitively: the store matches entities as case-sensitive globs, so
32
+ * the two sides can differ in case.
33
+ */
34
+ export declare function partitionNotebooks(items: readonly ISpecSummary[], email: string | null): {
35
+ mine: ISpecSummary[];
36
+ shared: ISpecSummary[];
37
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reading the Oceanum spec store's `notebook` records.
3
+ *
4
+ * Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
5
+ * share-oceanum package has a fuller client (create, update, permissions), and when
6
+ * sharing moves here the two should become one rather than sitting side by side.
7
+ */
8
+ function isSummary(value) {
9
+ if (typeof value !== 'object' || value === null) {
10
+ return false;
11
+ }
12
+ const v = value;
13
+ return (typeof v.id === 'string' &&
14
+ typeof v.name === 'string' &&
15
+ typeof v.modified === 'string');
16
+ }
17
+ /**
18
+ * Every notebook the signed-in user may read: their own and those shared with them.
19
+ *
20
+ * The store answers an unauthenticated caller with an empty list rather than an error,
21
+ * so a missing token is indistinguishable from an empty account — callers should check
22
+ * they are signed in before showing "no notebooks".
23
+ */
24
+ export async function listNotebooks(specsUrl, token, fetcher = fetch) {
25
+ const url = `${specsUrl.replace(/\/+$/, '')}/specs/notebook`;
26
+ const response = await fetcher(url, {
27
+ headers: { Authorization: `Bearer ${token}` }
28
+ });
29
+ if (!response.ok) {
30
+ // The store answers a bad or expired token with 400, and 403 both for no access
31
+ // and for a record that does not exist.
32
+ throw new Error(`Oceanum.io returned HTTP ${response.status}.`);
33
+ }
34
+ const data = await response.json();
35
+ if (!Array.isArray(data)) {
36
+ throw new Error('Oceanum.io returned an unexpected response.');
37
+ }
38
+ return data.filter(isSummary);
39
+ }
40
+ /**
41
+ * Split a listing into the caller's own notebooks and the rest, newest first.
42
+ *
43
+ * The store only sends `creator` on records the caller created, so anything else it
44
+ * returns is shared with them — directly, or because it is public. Addresses are
45
+ * compared case-insensitively: the store matches entities as case-sensitive globs, so
46
+ * the two sides can differ in case.
47
+ */
48
+ export function partitionNotebooks(items, email) {
49
+ var _a;
50
+ const me = (_a = email === null || email === void 0 ? void 0 : email.toLowerCase()) !== null && _a !== void 0 ? _a : null;
51
+ const sorted = [...items].sort((a, b) => b.modified.localeCompare(a.modified));
52
+ const isMine = (item) => { var _a; return me !== null && ((_a = item.creator) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === me; };
53
+ return {
54
+ mine: sorted.filter(isMine),
55
+ shared: sorted.filter(item => !isMine(item))
56
+ };
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.6.2",
3
+ "version": "4.7.1",
4
4
  "description": "A Jupyterlab extension to interact with the Oceanum.io platform",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -60,8 +60,16 @@
60
60
  "@jupyterlab/application": "^4.6.3",
61
61
  "@jupyterlab/apputils": "^4.7.3",
62
62
  "@jupyterlab/cells": "^4.6.3",
63
+ "@jupyterlab/console": "^4.6.3",
64
+ "@jupyterlab/coreutils": "^6.6.3",
63
65
  "@jupyterlab/notebook": "^4.6.3",
66
+ "@jupyterlab/services": "^7.6.3",
64
67
  "@jupyterlab/settingregistry": "^4.6.3",
68
+ "@lumino/commands": "^2.3.3",
69
+ "@lumino/coreutils": "^2.2.2",
70
+ "@lumino/disposable": "^2.1.5",
71
+ "@lumino/signaling": "^2.1.5",
72
+ "@lumino/widgets": "^2.8.0",
65
73
  "@phosphor-icons/react": "^2.1.10",
66
74
  "marked": "^18.0.13"
67
75
  },
@@ -18,12 +18,6 @@
18
18
  "description": "Inject Datamesh token into code.",
19
19
  "default": false
20
20
  },
21
- "datameshUiUrl": {
22
- "type": "string",
23
- "title": "Datamesh UI URL",
24
- "description": "Address of the Datamesh UI shown in the Datamesh panel. Use the address it is finally served from: the panel only accepts workspace updates from this address's origin, so an address that redirects to another origin will not update the workspace.",
25
- "default": "https://ui.datamesh.oceanum.io"
26
- },
27
21
  "autoRunCode": {
28
22
  "type": "boolean",
29
23
  "title": "Auto-run code",
@@ -37,5 +31,8 @@
37
31
  "default": false
38
32
  }
39
33
  },
34
+ "patternProperties": {
35
+ "^(datameshUiUrl|aiBackendUrl)$": {}
36
+ },
40
37
  "additionalProperties": false
41
38
  }
@@ -3,20 +3,11 @@ import { ReactWidget } from '@jupyterlab/apputils';
3
3
  import { datameshUiSrc } from './datameshUiUrl';
4
4
 
5
5
  export class DatameshUI extends ReactWidget {
6
- /** `url` is the `datameshUiUrl` setting. */
6
+ /** `url` is the deployment's Datamesh UI address (the sign-in environment's). */
7
7
  constructor(private _url: string) {
8
8
  super();
9
9
  }
10
10
 
11
- /**
12
- * Point the panel at the `datameshUiUrl` setting's new value. The iframe
13
- * reloads only if its address actually changes.
14
- */
15
- set url(url: string) {
16
- this._url = url;
17
- this.update();
18
- }
19
-
20
11
  /** The iframe's window, for telling its messages apart; `null` if none. */
21
12
  get frame(): Window | null {
22
13
  return this.node.querySelector('iframe')?.contentWindow ?? null;