@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/httpUrl.js ADDED
@@ -0,0 +1,20 @@
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 function validHttpUrl(value, fallback) {
10
+ try {
11
+ const parsed = new URL((value !== null && value !== void 0 ? value : '').trim());
12
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
13
+ return parsed;
14
+ }
15
+ }
16
+ catch (_a) {
17
+ // Empty or not a URL: the default below.
18
+ }
19
+ return new URL(fallback);
20
+ }
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';
@@ -15,7 +16,7 @@ import { snapshotFromIpynb, snapshotOf } from './notebookContext';
15
16
  import { notebookHost } from './notebookHost';
16
17
  import { KernelHandoff } from './kernelHandoff';
17
18
  import { runChatLoop, STOPPED } from './aiLoop';
18
- import { MAX_OBSERVE_ROUNDS } from './constants';
19
+ import { DATAMESH_UI_SERVICE, MAX_OBSERVE_ROUNDS, OCEANUM_AI_BACKEND_URL } from './constants';
19
20
  import '../style/index.css';
20
21
  import oceanumSvg from '../style/icons/oceanum.svg';
21
22
  // Theme-adaptive icon using currentColor (auto-adapts to light/dark themes)
@@ -39,6 +40,15 @@ export const datamesh_connect_extension = {
39
40
  ],
40
41
  activate: (app, palette, restorer, status, settingRegistry) => {
41
42
  console.log('Oceanum datamesh connect extension is loaded');
43
+ // The `datameshUiUrl` setting; the default until the settings load.
44
+ let datameshUiUrl = DATAMESH_UI_SERVICE;
45
+ // The `aiBackendUrl` and `datameshToken` settings for the AI chat; the
46
+ // defaults until the settings load.
47
+ let aiBackendUrl = OCEANUM_AI_BACKEND_URL;
48
+ let datameshTokenSetting = '';
49
+ // Tells the sidebar the settings above have changed.
50
+ const settingsChanged = new Signal(app);
51
+ const findDatameshUI = () => find(app.shell.widgets('main'), (widget) => widget.id === 'datamesh-ui');
42
52
  //Try to get the datamesh token from the settings
43
53
  const updateSettings = (set) => {
44
54
  const token = set.get('datameshToken');
@@ -50,6 +60,15 @@ export const datamesh_connect_extension = {
50
60
  }).then(res => console.log(res));
51
61
  }
52
62
  window.injectToken = set.get('injectToken').user;
63
+ aiBackendUrl = set.get('aiBackendUrl').composite;
64
+ datameshTokenSetting = set.get('datameshToken').composite;
65
+ // An open Datamesh UI panel follows a change of address.
66
+ datameshUiUrl = set.get('datameshUiUrl').composite;
67
+ const datameshUI = findDatameshUI();
68
+ if (datameshUI) {
69
+ datameshUI.url = datameshUiUrl;
70
+ }
71
+ settingsChanged.emit();
53
72
  };
54
73
  //Try to get the datamesh token from the envars
55
74
  Promise.all([app.restored, settingRegistry.load(PLUGIN_ID)])
@@ -67,14 +86,11 @@ export const datamesh_connect_extension = {
67
86
  };
68
87
  const openDatameshUI = (event) => {
69
88
  const widgetId = 'datamesh-ui';
70
- const openWidget = find(app.shell.widgets('main'), (widget, index) => {
71
- return widget.id === widgetId;
72
- });
73
- if (openWidget) {
89
+ if (findDatameshUI()) {
74
90
  app.shell.activateById(widgetId);
75
91
  return;
76
92
  }
77
- const datameshUIWidget = new DatameshUI();
93
+ const datameshUIWidget = new DatameshUI(datameshUiUrl);
78
94
  datameshUIWidget.title.label = 'Oceanum Datamesh';
79
95
  datameshUIWidget.id = widgetId;
80
96
  datameshUIWidget.title.closable = true;
@@ -87,6 +103,11 @@ export const datamesh_connect_extension = {
87
103
  name: 'Datamesh Connect',
88
104
  icon: oceanumIcon,
89
105
  openDatameshUI: openDatameshUI,
106
+ datameshUiUrl: () => datameshUiUrl,
107
+ datameshUiFrame: () => { var _a, _b; return (_b = (_a = findDatameshUI()) === null || _a === void 0 ? void 0 : _a.frame) !== null && _b !== void 0 ? _b : null; },
108
+ datameshToken: () => datameshTokenSetting,
109
+ aiBackendUrl: () => aiBackendUrl,
110
+ settingsChanged,
90
111
  commands: app.commands,
91
112
  getCurrentWidget
92
113
  });
@@ -129,7 +150,7 @@ export const oceanum_ai_extension = {
129
150
  // The panel the current request read its selected cell from, if the
130
151
  // notebook was open: a code answer may replace that cell only there.
131
152
  let readFrom = null;
132
- const router = new ChatRouter(settings, async () => {
153
+ const router = new ChatRouter(settings, app.commands, async () => {
133
154
  const open = await pin.current();
134
155
  readFrom = open;
135
156
  if (open) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oceanum/oceanumlab",
3
- "version": "4.6.1",
3
+ "version": "4.7.0",
4
4
  "description": "A Jupyterlab extension to interact with the Oceanum.io platform",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -18,6 +18,18 @@
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
+ "aiBackendUrl": {
28
+ "type": "string",
29
+ "title": "Oceanum AI URL",
30
+ "description": "Address of the Oceanum AI backend used by the AI chat.",
31
+ "default": "https://ai.oceanum.io"
32
+ },
21
33
  "autoRunCode": {
22
34
  "type": "boolean",
23
35
  "title": "Auto-run code",
@@ -1,12 +1,31 @@
1
1
  import React from 'react';
2
2
  import { ReactWidget } from '@jupyterlab/apputils';
3
- import { DATAMESH_UI_SERVICE } from './constants';
3
+ import { datameshUiSrc } from './datameshUiUrl';
4
4
 
5
5
  export class DatameshUI extends ReactWidget {
6
+ /** `url` is the `datameshUiUrl` setting. */
7
+ constructor(private _url: string) {
8
+ super();
9
+ }
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
+ /** The iframe's window, for telling its messages apart; `null` if none. */
21
+ get frame(): Window | null {
22
+ return this.node.querySelector('iframe')?.contentWindow ?? null;
23
+ }
24
+
6
25
  render(): React.ReactElement {
7
26
  return (
8
27
  <iframe
9
- src={`${DATAMESH_UI_SERVICE}`}
28
+ src={datameshUiSrc(this._url)}
10
29
  className="datamesh-iframe"
11
30
  ></iframe>
12
31
  );
@@ -14,14 +14,24 @@ import { LabIcon, addIcon } from '@jupyterlab/ui-components';
14
14
  import { CommandRegistry } from '@lumino/commands';
15
15
  import { MimeData, ReadonlyJSONArray } from '@lumino/coreutils';
16
16
  import { Drag } from '@lumino/dragdrop';
17
- import { Signal } from '@lumino/signaling';
17
+ import { ISignal, Signal } from '@lumino/signaling';
18
18
  import { Widget } from '@lumino/widgets';
19
19
 
20
20
  import React from 'react';
21
21
  import { marked } from 'marked';
22
22
 
23
23
  import { DatasourceItem } from './DatasourceItem';
24
- import { OCEANUM_AI_BACKEND_URL } from './constants';
24
+ import {
25
+ SIGN_IN_COMMAND,
26
+ aiBackendUrl,
27
+ aiCredentialSource,
28
+ canSignIn,
29
+ pastedToken,
30
+ resolveAiCredential,
31
+ signInToken,
32
+ type AiCredentialSource
33
+ } from './aiBackend';
34
+ import { isDatameshUiMessage } from './datameshUiUrl';
25
35
  import { describeProgress, onProgress, type Progress } from './progress';
26
36
 
27
37
  // Configure marked for safe rendering
@@ -459,38 +469,122 @@ class DatameshWorkspaceDisplay extends React.Component<IDatameshWorkspaceProps>
459
469
  }
460
470
  }
461
471
 
472
+ /** The settings the sidebar reaches Oceanum AI with. */
473
+ interface IAiSettings {
474
+ /**
475
+ * The `datameshToken` setting: the same value a chat request is signed with.
476
+ * Not `window.datameshToken`, which keeps a token after it is cleared.
477
+ */
478
+ datameshToken: () => string;
479
+ /** The `aiBackendUrl` setting. */
480
+ aiBackendUrl: () => string;
481
+ /** Emitted when the settings change. */
482
+ settingsChanged: ISignal<unknown, void>;
483
+ }
484
+
485
+ /**
486
+ * How the sidebar can reach Oceanum AI right now. Holds no sign-in token:
487
+ * finding one out means running the host's command.
488
+ */
489
+ interface IAiAccess {
490
+ /** Which credential a request would carry; null when there is none. */
491
+ source: AiCredentialSource | null;
492
+ /** The pasted Datamesh token, trimmed; '' when there is none. */
493
+ pasted: string;
494
+ /** Whether the host can sign the user in to Oceanum.io on this site. */
495
+ canSignIn: boolean;
496
+ /** The backend address, from the `aiBackendUrl` setting. */
497
+ backend: string;
498
+ }
499
+
500
+ function readAiAccess(
501
+ commands: CommandRegistry,
502
+ settings: IAiSettings
503
+ ): IAiAccess {
504
+ const datameshToken = settings.datameshToken();
505
+ return {
506
+ source: aiCredentialSource(datameshToken, commands),
507
+ pasted: pastedToken(datameshToken),
508
+ canSignIn: canSignIn(commands),
509
+ backend: aiBackendUrl(settings.aiBackendUrl())
510
+ };
511
+ }
512
+
513
+ /**
514
+ * How the sidebar can reach Oceanum AI, read again whenever the settings or the
515
+ * commands change: the host calls `notifyCommandChanged` when the user signs
516
+ * in or out.
517
+ *
518
+ * Only the registry's queries are used, never `execute`, and nothing runs on a
519
+ * timer: every executed command closes JupyterLab's command palette.
520
+ */
521
+ function useAiAccess(
522
+ commands: CommandRegistry,
523
+ settings: IAiSettings
524
+ ): IAiAccess {
525
+ const [access, setAccess] = React.useState(() =>
526
+ readAiAccess(commands, settings)
527
+ );
528
+
529
+ React.useEffect(() => {
530
+ const update = () => {
531
+ const next = readAiAccess(commands, settings);
532
+ // Same content keeps the same object, so nothing downstream re-runs.
533
+ setAccess(prev =>
534
+ JSON.stringify(prev) === JSON.stringify(next) ? prev : next
535
+ );
536
+ };
537
+ // Anything that changed between the first render and now.
538
+ update();
539
+ commands.commandChanged.connect(update);
540
+ settings.settingsChanged.connect(update);
541
+ return () => {
542
+ commands.commandChanged.disconnect(update);
543
+ settings.settingsChanged.disconnect(update);
544
+ };
545
+ }, [commands, settings]);
546
+
547
+ return access;
548
+ }
549
+
462
550
  /**
463
- * Shows a message prompting user to configure their Datamesh token.
464
- * Only renders when no token is set.
551
+ * Shows a message prompting the user to sign in to Oceanum.io, or to configure
552
+ * their Datamesh token where the site has no sign-in. Only renders when there
553
+ * is no credential.
465
554
  */
466
555
  function TokenConfigMessage({
467
- commands
556
+ commands,
557
+ settings
468
558
  }: {
469
559
  commands: CommandRegistry;
560
+ settings: IAiSettings;
470
561
  }): React.ReactElement | null {
471
- const [hasToken, setHasToken] = React.useState(!!window.datameshToken);
562
+ const access = useAiAccess(commands, settings);
472
563
 
473
- React.useEffect(() => {
474
- const checkToken = () => setHasToken(!!window.datameshToken);
475
- const interval = setInterval(checkToken, 1000);
476
- return () => clearInterval(interval);
477
- }, []);
478
-
479
- if (hasToken) {
564
+ if (access.source) {
480
565
  return null;
481
566
  }
482
567
 
568
+ const openSettings = () =>
569
+ commands.execute('settingeditor:open', { query: 'Oceanum' });
570
+
571
+ if (access.canSignIn) {
572
+ const signIn = () =>
573
+ commands.execute(SIGN_IN_COMMAND).catch(() => {
574
+ console.warn('Oceanum AI: could not start signing in to Oceanum.io.');
575
+ });
576
+ return (
577
+ <div className="oceanum-token-config">
578
+ <a onClick={() => void signIn()}>Sign in to Oceanum.io</a> to use
579
+ Oceanum AI, or set a <a onClick={openSettings}>Datamesh token</a>.
580
+ </div>
581
+ );
582
+ }
583
+
483
584
  return (
484
585
  <div className="oceanum-token-config">
485
- Set your{' '}
486
- <a
487
- onClick={() =>
488
- commands.execute('settingeditor:open', { query: 'Oceanum' })
489
- }
490
- >
491
- Datamesh token
492
- </a>{' '}
493
- to enable Oceanum.oi services{' '}
586
+ Set your <a onClick={openSettings}>Datamesh token</a> to enable Oceanum.io
587
+ services{' '}
494
588
  <a
495
589
  href="https://home.oceanum.io/account"
496
590
  target="_blank"
@@ -509,10 +603,12 @@ interface IChatMessage {
509
603
 
510
604
  interface IAIChatPanelProps {
511
605
  commands: CommandRegistry;
606
+ settings: IAiSettings;
512
607
  }
513
608
 
514
609
  function AIChatPanel({
515
- commands
610
+ commands,
611
+ settings
516
612
  }: IAIChatPanelProps): React.ReactElement | null {
517
613
  const [messages, setMessages] = React.useState<IChatMessage[]>([]);
518
614
  const [input, setInput] = React.useState('');
@@ -531,55 +627,81 @@ function AIChatPanel({
531
627
  const [historyIndex, setHistoryIndex] = React.useState(-1);
532
628
  const [tempInput, setTempInput] = React.useState('');
533
629
 
534
- // Track the actual token value to detect changes
535
- const [token, setToken] = React.useState(window.datameshToken || '');
536
- const hasToken = !!token;
630
+ // Which credential and address the chat has, as the settings and the host
631
+ // report them.
632
+ const access = useAiAccess(commands, settings);
633
+ // What the capabilities depend on: which credential, the pasted token and
634
+ // the address. Not the sign-in token itself: it refreshes, and finding it out
635
+ // runs the host's command.
636
+ const capabilitiesKey = access.source
637
+ ? JSON.stringify([access.source, access.pasted, access.backend])
638
+ : '';
537
639
 
538
640
  // Check if code capability is enabled (null = loading/unknown)
539
641
  const [codeEnabled, setCodeEnabled] = React.useState<boolean | null>(null);
540
642
 
541
- // Re-check token periodically (settings may change)
542
- React.useEffect(() => {
543
- const checkToken = () => {
544
- const currentToken = window.datameshToken || '';
545
- if (currentToken !== token) {
546
- setToken(currentToken);
547
- }
548
- };
549
- const interval = setInterval(checkToken, 1000);
550
- return () => clearInterval(interval);
551
- }, [token]);
643
+ // The sidebar's one pending access-token request, shared rather than
644
+ // repeated if the capabilities are asked for again before it answers.
645
+ const tokenRequest = React.useRef<Promise<unknown> | null>(null);
552
646
 
553
- // Fetch capabilities when token changes
647
+ // Fetch capabilities when the credential or the address changes
554
648
  React.useEffect(() => {
555
- if (!token) {
649
+ if (!access.source) {
556
650
  setCodeEnabled(null);
557
651
  return;
558
652
  }
559
653
 
654
+ // An answer for a credential that has since changed is dropped.
655
+ let current = true;
656
+ const signInTokenOnce = (): Promise<unknown> => {
657
+ if (!tokenRequest.current) {
658
+ tokenRequest.current = signInToken(commands)().finally(() => {
659
+ tokenRequest.current = null;
660
+ });
661
+ }
662
+ return tokenRequest.current;
663
+ };
560
664
  const fetchCapabilities = async () => {
665
+ const credential = await resolveAiCredential(
666
+ access.pasted,
667
+ signInTokenOnce
668
+ );
669
+ if (!current) {
670
+ return;
671
+ }
672
+ if (!credential) {
673
+ // The host says signed in but has no token to give: leave it to a
674
+ // request to report, rather than hide the chat.
675
+ setCodeEnabled(null);
676
+ return;
677
+ }
561
678
  try {
562
- const response = await fetch(
563
- `${OCEANUM_AI_BACKEND_URL}/api/capabilities`,
564
- {
565
- headers: {
566
- 'X-Datamesh-Token': token
567
- }
568
- }
569
- );
679
+ const response = await fetch(`${access.backend}/api/capabilities`, {
680
+ headers: credential.headers
681
+ });
682
+ if (!current) {
683
+ return;
684
+ }
570
685
  if (response.ok) {
571
686
  const data = await response.json();
572
- setCodeEnabled(data.code === true);
687
+ if (current) {
688
+ setCodeEnabled(data.code === true);
689
+ }
573
690
  } else {
574
691
  setCodeEnabled(false);
575
692
  }
576
693
  } catch {
577
- setCodeEnabled(false);
694
+ if (current) {
695
+ setCodeEnabled(false);
696
+ }
578
697
  }
579
698
  };
580
699
 
581
700
  void fetchCapabilities();
582
- }, [token]);
701
+ return () => {
702
+ current = false;
703
+ };
704
+ }, [capabilitiesKey]);
583
705
 
584
706
  // Auto-scroll to bottom when messages change
585
707
  React.useEffect(() => {
@@ -721,8 +843,8 @@ function AIChatPanel({
721
843
  }
722
844
  };
723
845
 
724
- // Hide AI panel if no token or code capability is not enabled
725
- if (!hasToken || codeEnabled === false) {
846
+ // Hide AI panel if no credential or code capability is not enabled
847
+ if (!access.source || codeEnabled === false) {
726
848
  return null;
727
849
  }
728
850
 
@@ -813,6 +935,16 @@ export interface IDatameshWidgetProps {
813
935
  name: string;
814
936
  icon: LabIcon;
815
937
  openDatameshUI: any;
938
+ /** The `datameshUiUrl` setting. */
939
+ datameshUiUrl: () => string;
940
+ /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
941
+ datameshUiFrame: () => Window | null;
942
+ /** The `datameshToken` setting. */
943
+ datameshToken: () => string;
944
+ /** The `aiBackendUrl` setting. */
945
+ aiBackendUrl: () => string;
946
+ /** Emitted when the settings change. */
947
+ settingsChanged: ISignal<unknown, void>;
816
948
  commands: CommandRegistry;
817
949
  getCurrentWidget: () => Widget;
818
950
  }
@@ -841,6 +973,17 @@ export class DatameshConnectWidget extends ReactWidget {
841
973
  }
842
974
 
843
975
  receiveIFrameMessage(event: MessageEvent): void {
976
+ // Any page or frame can post to this window; only the Datamesh UI panel
977
+ // may change the workspace.
978
+ if (
979
+ !isDatameshUiMessage(
980
+ event,
981
+ this.props.datameshUiUrl(),
982
+ this.props.datameshUiFrame()
983
+ )
984
+ ) {
985
+ return;
986
+ }
844
987
  if (event.data && event.data.action === 'workspace-modify') {
845
988
  this.datameshWorkspaceSpec = {
846
989
  id: event.data.id,
@@ -899,10 +1042,13 @@ export class DatameshConnectWidget extends ReactWidget {
899
1042
  this.renderDisplay(datameshWorkspace)
900
1043
  }
901
1044
  </UseSignal>
902
- <TokenConfigMessage commands={this.props.commands} />
1045
+ <TokenConfigMessage
1046
+ commands={this.props.commands}
1047
+ settings={this.props}
1048
+ />
903
1049
  </div>
904
1050
  <div className="datamesh-connect-divider" />
905
- <AIChatPanel commands={this.props.commands} />
1051
+ <AIChatPanel commands={this.props.commands} settings={this.props} />
906
1052
  </div>
907
1053
  );
908
1054
  }