@oceanum/oceanumlab 4.6.2 → 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.
@@ -24,6 +24,12 @@
24
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
25
  "default": "https://ui.datamesh.oceanum.io"
26
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
+ },
27
33
  "autoRunCode": {
28
34
  "type": "boolean",
29
35
  "title": "Auto-run code",
@@ -14,14 +14,23 @@ 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';
25
34
  import { isDatameshUiMessage } from './datameshUiUrl';
26
35
  import { describeProgress, onProgress, type Progress } from './progress';
27
36
 
@@ -460,38 +469,122 @@ class DatameshWorkspaceDisplay extends React.Component<IDatameshWorkspaceProps>
460
469
  }
461
470
  }
462
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
+
463
513
  /**
464
- * Shows a message prompting user to configure their Datamesh token.
465
- * Only renders when no token is set.
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
+
550
+ /**
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.
466
554
  */
467
555
  function TokenConfigMessage({
468
- commands
556
+ commands,
557
+ settings
469
558
  }: {
470
559
  commands: CommandRegistry;
560
+ settings: IAiSettings;
471
561
  }): React.ReactElement | null {
472
- const [hasToken, setHasToken] = React.useState(!!window.datameshToken);
473
-
474
- React.useEffect(() => {
475
- const checkToken = () => setHasToken(!!window.datameshToken);
476
- const interval = setInterval(checkToken, 1000);
477
- return () => clearInterval(interval);
478
- }, []);
562
+ const access = useAiAccess(commands, settings);
479
563
 
480
- if (hasToken) {
564
+ if (access.source) {
481
565
  return null;
482
566
  }
483
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
+
484
584
  return (
485
585
  <div className="oceanum-token-config">
486
- Set your{' '}
487
- <a
488
- onClick={() =>
489
- commands.execute('settingeditor:open', { query: 'Oceanum' })
490
- }
491
- >
492
- Datamesh token
493
- </a>{' '}
494
- to enable Oceanum.oi services{' '}
586
+ Set your <a onClick={openSettings}>Datamesh token</a> to enable Oceanum.io
587
+ services{' '}
495
588
  <a
496
589
  href="https://home.oceanum.io/account"
497
590
  target="_blank"
@@ -510,10 +603,12 @@ interface IChatMessage {
510
603
 
511
604
  interface IAIChatPanelProps {
512
605
  commands: CommandRegistry;
606
+ settings: IAiSettings;
513
607
  }
514
608
 
515
609
  function AIChatPanel({
516
- commands
610
+ commands,
611
+ settings
517
612
  }: IAIChatPanelProps): React.ReactElement | null {
518
613
  const [messages, setMessages] = React.useState<IChatMessage[]>([]);
519
614
  const [input, setInput] = React.useState('');
@@ -532,55 +627,81 @@ function AIChatPanel({
532
627
  const [historyIndex, setHistoryIndex] = React.useState(-1);
533
628
  const [tempInput, setTempInput] = React.useState('');
534
629
 
535
- // Track the actual token value to detect changes
536
- const [token, setToken] = React.useState(window.datameshToken || '');
537
- 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
+ : '';
538
639
 
539
640
  // Check if code capability is enabled (null = loading/unknown)
540
641
  const [codeEnabled, setCodeEnabled] = React.useState<boolean | null>(null);
541
642
 
542
- // Re-check token periodically (settings may change)
543
- React.useEffect(() => {
544
- const checkToken = () => {
545
- const currentToken = window.datameshToken || '';
546
- if (currentToken !== token) {
547
- setToken(currentToken);
548
- }
549
- };
550
- const interval = setInterval(checkToken, 1000);
551
- return () => clearInterval(interval);
552
- }, [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);
553
646
 
554
- // Fetch capabilities when token changes
647
+ // Fetch capabilities when the credential or the address changes
555
648
  React.useEffect(() => {
556
- if (!token) {
649
+ if (!access.source) {
557
650
  setCodeEnabled(null);
558
651
  return;
559
652
  }
560
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
+ };
561
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
+ }
562
678
  try {
563
- const response = await fetch(
564
- `${OCEANUM_AI_BACKEND_URL}/api/capabilities`,
565
- {
566
- headers: {
567
- 'X-Datamesh-Token': token
568
- }
569
- }
570
- );
679
+ const response = await fetch(`${access.backend}/api/capabilities`, {
680
+ headers: credential.headers
681
+ });
682
+ if (!current) {
683
+ return;
684
+ }
571
685
  if (response.ok) {
572
686
  const data = await response.json();
573
- setCodeEnabled(data.code === true);
687
+ if (current) {
688
+ setCodeEnabled(data.code === true);
689
+ }
574
690
  } else {
575
691
  setCodeEnabled(false);
576
692
  }
577
693
  } catch {
578
- setCodeEnabled(false);
694
+ if (current) {
695
+ setCodeEnabled(false);
696
+ }
579
697
  }
580
698
  };
581
699
 
582
700
  void fetchCapabilities();
583
- }, [token]);
701
+ return () => {
702
+ current = false;
703
+ };
704
+ }, [capabilitiesKey]);
584
705
 
585
706
  // Auto-scroll to bottom when messages change
586
707
  React.useEffect(() => {
@@ -722,8 +843,8 @@ function AIChatPanel({
722
843
  }
723
844
  };
724
845
 
725
- // Hide AI panel if no token or code capability is not enabled
726
- if (!hasToken || codeEnabled === false) {
846
+ // Hide AI panel if no credential or code capability is not enabled
847
+ if (!access.source || codeEnabled === false) {
727
848
  return null;
728
849
  }
729
850
 
@@ -818,6 +939,12 @@ export interface IDatameshWidgetProps {
818
939
  datameshUiUrl: () => string;
819
940
  /** The Datamesh UI panel's iframe window; `null` when the panel is closed. */
820
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>;
821
948
  commands: CommandRegistry;
822
949
  getCurrentWidget: () => Widget;
823
950
  }
@@ -915,10 +1042,13 @@ export class DatameshConnectWidget extends ReactWidget {
915
1042
  this.renderDisplay(datameshWorkspace)
916
1043
  }
917
1044
  </UseSignal>
918
- <TokenConfigMessage commands={this.props.commands} />
1045
+ <TokenConfigMessage
1046
+ commands={this.props.commands}
1047
+ settings={this.props}
1048
+ />
919
1049
  </div>
920
1050
  <div className="datamesh-connect-divider" />
921
- <AIChatPanel commands={this.props.commands} />
1051
+ <AIChatPanel commands={this.props.commands} settings={this.props} />
922
1052
  </div>
923
1053
  );
924
1054
  }