@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/src/chatRouter.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
- import { OCEANUM_AI_BACKEND_URL } from './constants';
2
+ import {
3
+ aiBackendUrl,
4
+ canSignIn,
5
+ resolveAiCredential,
6
+ signInToken,
7
+ type AuthCommands
8
+ } from './aiBackend';
3
9
  import type { Progress } from './progress';
4
10
  import { readSse } from './sse';
5
11
  import type { ObservedRun } from './notebookRun';
@@ -76,6 +82,8 @@ function asOceanumResponse(data: unknown): OceanumResponse {
76
82
 
77
83
  export class ChatRouter {
78
84
  /**
85
+ * @param _commands Where the host's Oceanum.io sign-in is asked for, when
86
+ * there is no pasted Datamesh token.
79
87
  * @param _notebook What the conversation's notebook holds -- the one its
80
88
  * answers are placed in -- or null if there is nothing to send. Asked on
81
89
  * every request, so it follows the conversation rather than whichever tab
@@ -83,6 +91,7 @@ export class ChatRouter {
83
91
  */
84
92
  constructor(
85
93
  private _settings: ISettingRegistry.ISettings,
94
+ private _commands: AuthCommands,
86
95
  private _notebook: () => Promise<INotebookSnapshot | null>
87
96
  ) {}
88
97
 
@@ -178,20 +187,30 @@ export class ChatRouter {
178
187
  signal?: AbortSignal,
179
188
  onProgress?: (progress: Progress) => void
180
189
  ): Promise<unknown> {
181
- const token = this._settings.get('datameshToken').composite as string;
182
- if (!token) {
190
+ // Resolved per request, not once: the sign-in token refreshes, and the
191
+ // user can sign in, sign out or paste a token between two requests.
192
+ const credential = await resolveAiCredential(
193
+ this._settings.get('datameshToken').composite,
194
+ signInToken(this._commands)
195
+ );
196
+ if (!credential) {
183
197
  throw new ChatRouterError(
184
- 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
198
+ canSignIn(this._commands)
199
+ ? 'Not signed in. Sign in to Oceanum.io to use Oceanum AI, or set a Datamesh token in Settings → Oceanum.io.'
200
+ : 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
185
201
  );
186
202
  }
203
+ const backend = aiBackendUrl(
204
+ this._settings.get('aiBackendUrl').composite as string
205
+ );
187
206
 
188
207
  let response: Response;
189
208
  try {
190
- response = await fetch(`${OCEANUM_AI_BACKEND_URL}${path}`, {
209
+ response = await fetch(`${backend}${path}`, {
191
210
  method: 'POST',
192
211
  headers: {
193
212
  'Content-Type': 'application/json',
194
- 'X-Datamesh-Token': token,
213
+ ...credential.headers,
195
214
  // Ask for the streamed form only when someone is listening. The
196
215
  // server requires the media type to be NAMED -- a wildcard never
197
216
  // selects it -- so omitting this is what keeps the plain JSON path
@@ -208,12 +227,21 @@ export class ChatRouter {
208
227
  throw err;
209
228
  }
210
229
  throw new ChatRouterError(
211
- `Could not reach Oceanum AI backend at ${OCEANUM_AI_BACKEND_URL}. Is it running?`
230
+ `Could not reach Oceanum AI backend at ${backend}. Is it running?`
212
231
  );
213
232
  }
214
233
 
215
234
  if (response.status === 401) {
216
- throw new ChatRouterError('Invalid or expired Datamesh token.', 401);
235
+ let message = 'Invalid or expired Datamesh token.';
236
+ if (credential.source === 'sign-in') {
237
+ message =
238
+ 'Your Oceanum.io sign-in was not accepted. Sign in to Oceanum.io again.';
239
+ } else if (canSignIn(this._commands)) {
240
+ // A pasted token wins over the sign-in, so say how to fall back to it.
241
+ message +=
242
+ ' Update it, or clear it to use your Oceanum.io sign-in, in Settings → Oceanum.io.';
243
+ }
244
+ throw new ChatRouterError(message, 401);
217
245
  }
218
246
 
219
247
  if (!response.ok) {
package/src/constants.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * These values are compiled into the extension.
4
4
  */
5
5
 
6
- /** URL of the Oceanum AI backend service */
6
+ /** Default URL of the Oceanum AI backend service (the `aiBackendUrl` setting) */
7
7
  export const OCEANUM_AI_BACKEND_URL = 'https://ai.oceanum.io';
8
8
 
9
9
  /** URL of the Datamesh UI embedded in the Datamesh panel */
@@ -0,0 +1,44 @@
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
+
12
+ /** The Datamesh UI address to use for a `datameshUiUrl` setting value. */
13
+ export function validDatameshUiUrl(url: string | null | undefined): URL {
14
+ return validHttpUrl(url, DATAMESH_UI_SERVICE);
15
+ }
16
+
17
+ /**
18
+ * The panel's iframe `src`: the Datamesh UI in embed mode, which hides the
19
+ * Oceanum.io navigator. Any query the setting already has is kept.
20
+ */
21
+ export function datameshUiSrc(url: string | null | undefined): string {
22
+ const src = validDatameshUiUrl(url);
23
+ src.searchParams.set('embed', '1');
24
+ return src.toString();
25
+ }
26
+
27
+ /**
28
+ * Whether a `message` event was posted by the Datamesh UI panel.
29
+ *
30
+ * Any page or frame can post to this window, so a message counts only when it
31
+ * comes from the panel's iframe (`frame`, its `contentWindow`; `null` when the
32
+ * panel is not open) and from the configured Datamesh UI's origin.
33
+ */
34
+ export function isDatameshUiMessage(
35
+ event: Pick<MessageEvent, 'origin' | 'source'>,
36
+ url: string | null | undefined,
37
+ frame: Window | null
38
+ ): boolean {
39
+ return (
40
+ !!frame &&
41
+ event.source === frame &&
42
+ event.origin === validDatameshUiUrl(url).origin
43
+ );
44
+ }
package/src/httpUrl.ts ADDED
@@ -0,0 +1,23 @@
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
+
9
+ /** `value` as an http(s) URL, or `fallback` when it is empty or not one. */
10
+ export function validHttpUrl(
11
+ value: string | null | undefined,
12
+ fallback: string
13
+ ): URL {
14
+ try {
15
+ const parsed = new URL((value ?? '').trim());
16
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
17
+ return parsed;
18
+ }
19
+ } catch {
20
+ // Empty or not a URL: the default below.
21
+ }
22
+ return new URL(fallback);
23
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { ICommandPalette, Notification } from '@jupyterlab/apputils';
8
8
  import { LabIcon } from '@jupyterlab/ui-components';
9
9
  import { find } from '@lumino/algorithm';
10
+ import { Signal } from '@lumino/signaling';
10
11
  import { Widget } from '@lumino/widgets';
11
12
  import { ISettingRegistry } from '@jupyterlab/settingregistry';
12
13
  import { IStateDB } from '@jupyterlab/statedb';
@@ -21,7 +22,11 @@ import { snapshotFromIpynb, snapshotOf } from './notebookContext';
21
22
  import { notebookHost } from './notebookHost';
22
23
  import { KernelHandoff } from './kernelHandoff';
23
24
  import { runChatLoop, STOPPED } from './aiLoop';
24
- import { MAX_OBSERVE_ROUNDS } from './constants';
25
+ import {
26
+ DATAMESH_UI_SERVICE,
27
+ MAX_OBSERVE_ROUNDS,
28
+ OCEANUM_AI_BACKEND_URL
29
+ } from './constants';
25
30
 
26
31
  import '../style/index.css';
27
32
 
@@ -57,6 +62,21 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
57
62
  ) => {
58
63
  console.log('Oceanum datamesh connect extension is loaded');
59
64
 
65
+ // The `datameshUiUrl` setting; the default until the settings load.
66
+ let datameshUiUrl = DATAMESH_UI_SERVICE;
67
+ // The `aiBackendUrl` and `datameshToken` settings for the AI chat; the
68
+ // defaults until the settings load.
69
+ let aiBackendUrl = OCEANUM_AI_BACKEND_URL;
70
+ let datameshTokenSetting = '';
71
+ // Tells the sidebar the settings above have changed.
72
+ const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
73
+
74
+ const findDatameshUI = (): DatameshUI | undefined =>
75
+ find(
76
+ app.shell.widgets('main'),
77
+ (widget: Widget) => widget.id === 'datamesh-ui'
78
+ ) as DatameshUI | undefined;
79
+
60
80
  //Try to get the datamesh token from the settings
61
81
  const updateSettings = (set: ISettingRegistry.ISettings) => {
62
82
  const token = set.get('datameshToken');
@@ -68,6 +88,15 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
68
88
  }).then(res => console.log(res));
69
89
  }
70
90
  window.injectToken = set.get('injectToken').user as boolean;
91
+ aiBackendUrl = set.get('aiBackendUrl').composite as string;
92
+ datameshTokenSetting = set.get('datameshToken').composite as string;
93
+ // An open Datamesh UI panel follows a change of address.
94
+ datameshUiUrl = set.get('datameshUiUrl').composite as string;
95
+ const datameshUI = findDatameshUI();
96
+ if (datameshUI) {
97
+ datameshUI.url = datameshUiUrl;
98
+ }
99
+ settingsChanged.emit();
71
100
  };
72
101
  //Try to get the datamesh token from the envars
73
102
 
@@ -91,18 +120,12 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
91
120
 
92
121
  const openDatameshUI = (event: any): void => {
93
122
  const widgetId = 'datamesh-ui';
94
- const openWidget = find(
95
- app.shell.widgets('main'),
96
- (widget: Widget, index: number) => {
97
- return widget.id === widgetId;
98
- }
99
- );
100
- if (openWidget) {
123
+ if (findDatameshUI()) {
101
124
  app.shell.activateById(widgetId);
102
125
  return;
103
126
  }
104
127
 
105
- const datameshUIWidget = new DatameshUI();
128
+ const datameshUIWidget = new DatameshUI(datameshUiUrl);
106
129
  datameshUIWidget.title.label = 'Oceanum Datamesh';
107
130
  datameshUIWidget.id = widgetId;
108
131
  datameshUIWidget.title.closable = true;
@@ -116,6 +139,11 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
116
139
  name: 'Datamesh Connect',
117
140
  icon: oceanumIcon,
118
141
  openDatameshUI: openDatameshUI,
142
+ datameshUiUrl: () => datameshUiUrl,
143
+ datameshUiFrame: () => findDatameshUI()?.frame ?? null,
144
+ datameshToken: () => datameshTokenSetting,
145
+ aiBackendUrl: () => aiBackendUrl,
146
+ settingsChanged,
119
147
  commands: app.commands,
120
148
  getCurrentWidget
121
149
  });
@@ -169,7 +197,7 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
169
197
  // The panel the current request read its selected cell from, if the
170
198
  // notebook was open: a code answer may replace that cell only there.
171
199
  let readFrom: NotebookPanel | null = null;
172
- const router = new ChatRouter(settings, async () => {
200
+ const router = new ChatRouter(settings, app.commands, async () => {
173
201
  const open = await pin.current();
174
202
  readFrom = open;
175
203
  if (open) {