@oceanum/oceanumlab 4.6.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.
Files changed (83) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +116 -0
  3. package/lib/DatameshUI.d.ts +5 -0
  4. package/lib/DatameshUI.js +8 -0
  5. package/lib/DatameshUI.js.map +1 -0
  6. package/lib/DatameshWidget.d.ts +59 -0
  7. package/lib/DatameshWidget.js +573 -0
  8. package/lib/DatameshWidget.js.map +1 -0
  9. package/lib/DatasourceItem.d.ts +12 -0
  10. package/lib/DatasourceItem.js +47 -0
  11. package/lib/DatasourceItem.js.map +1 -0
  12. package/lib/aiLoop.d.ts +60 -0
  13. package/lib/aiLoop.js +85 -0
  14. package/lib/aiLoop.js.map +1 -0
  15. package/lib/chatRouter.d.ts +64 -0
  16. package/lib/chatRouter.js +225 -0
  17. package/lib/chatRouter.js.map +1 -0
  18. package/lib/constants.d.ts +14 -0
  19. package/lib/constants.js +15 -0
  20. package/lib/constants.js.map +1 -0
  21. package/lib/conversationPin.d.ts +68 -0
  22. package/lib/conversationPin.js +120 -0
  23. package/lib/conversationPin.js.map +1 -0
  24. package/lib/handler.d.ts +8 -0
  25. package/lib/handler.js +36 -0
  26. package/lib/handler.js.map +1 -0
  27. package/lib/index.d.ts +16 -0
  28. package/lib/index.js +273 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/kernelHandoff.d.ts +61 -0
  31. package/lib/kernelHandoff.js +236 -0
  32. package/lib/kernelHandoff.js.map +1 -0
  33. package/lib/notebookContext.d.ts +48 -0
  34. package/lib/notebookContext.js +118 -0
  35. package/lib/notebookContext.js.map +1 -0
  36. package/lib/notebookHost.d.ts +6 -0
  37. package/lib/notebookHost.js +72 -0
  38. package/lib/notebookHost.js.map +1 -0
  39. package/lib/notebookRun.d.ts +37 -0
  40. package/lib/notebookRun.js +70 -0
  41. package/lib/notebookRun.js.map +1 -0
  42. package/lib/progress.d.ts +43 -0
  43. package/lib/progress.js +103 -0
  44. package/lib/progress.js.map +1 -0
  45. package/lib/sse.d.ts +26 -0
  46. package/lib/sse.js +112 -0
  47. package/lib/sse.js.map +1 -0
  48. package/package.json +151 -0
  49. package/schema/datamesh-connect.json +35 -0
  50. package/src/DatameshUI.tsx +15 -0
  51. package/src/DatameshWidget.tsx +909 -0
  52. package/src/DatasourceItem.tsx +124 -0
  53. package/src/__tests__/aiLoop.spec.ts +304 -0
  54. package/src/__tests__/chatRouter.spec.ts +315 -0
  55. package/src/__tests__/conversationPin.spec.ts +236 -0
  56. package/src/__tests__/kernelHandoff.spec.ts +106 -0
  57. package/src/__tests__/notebookContext.spec.ts +97 -0
  58. package/src/__tests__/notebookHost.spec.ts +195 -0
  59. package/src/__tests__/notebookRun.spec.ts +95 -0
  60. package/src/__tests__/notebookSnapshot.spec.ts +62 -0
  61. package/src/__tests__/oceanumlab.spec.ts +9 -0
  62. package/src/__tests__/sse.spec.ts +304 -0
  63. package/src/aiLoop.ts +140 -0
  64. package/src/chatRouter.ts +329 -0
  65. package/src/constants.ts +16 -0
  66. package/src/conversationPin.ts +148 -0
  67. package/src/global.d.ts +5 -0
  68. package/src/handler.ts +46 -0
  69. package/src/index.ts +327 -0
  70. package/src/kernelHandoff.ts +284 -0
  71. package/src/notebookContext.ts +144 -0
  72. package/src/notebookHost.ts +97 -0
  73. package/src/notebookRun.ts +104 -0
  74. package/src/progress.ts +121 -0
  75. package/src/sse.ts +127 -0
  76. package/src/svg.d.ts +4 -0
  77. package/style/base.css +5 -0
  78. package/style/icons/oceanum-dark.svg +1 -0
  79. package/style/icons/oceanum-light.svg +1 -0
  80. package/style/icons/oceanum.png +0 -0
  81. package/style/icons/oceanum.svg +1 -0
  82. package/style/index.css +477 -0
  83. package/style/index.js +1 -0
@@ -0,0 +1,909 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import {
3
+ Dialog,
4
+ ReactWidget,
5
+ UseSignal,
6
+ showDialog
7
+ } from '@jupyterlab/apputils';
8
+ import { CodeCell } from '@jupyterlab/cells';
9
+ import { CodeEditor } from '@jupyterlab/codeeditor';
10
+ import * as nbformat from '@jupyterlab/nbformat';
11
+ import { Notebook, NotebookPanel } from '@jupyterlab/notebook';
12
+ import { CodeCellModel } from '@jupyterlab/cells';
13
+ import { LabIcon, addIcon } from '@jupyterlab/ui-components';
14
+ import { CommandRegistry } from '@lumino/commands';
15
+ import { MimeData, ReadonlyJSONArray } from '@lumino/coreutils';
16
+ import { Drag } from '@lumino/dragdrop';
17
+ import { Signal } from '@lumino/signaling';
18
+ import { Widget } from '@lumino/widgets';
19
+
20
+ import React from 'react';
21
+ import { marked } from 'marked';
22
+
23
+ import { DatasourceItem } from './DatasourceItem';
24
+ import { OCEANUM_AI_BACKEND_URL } from './constants';
25
+ import { describeProgress, onProgress, type Progress } from './progress';
26
+
27
+ // Configure marked for safe rendering
28
+ marked.setOptions({
29
+ breaks: true,
30
+ gfm: true
31
+ });
32
+
33
+ /**
34
+ * Renders markdown content safely using dangerouslySetInnerHTML.
35
+ */
36
+ function MarkdownContent({ content }: { content: string }): React.ReactElement {
37
+ const html = React.useMemo(() => {
38
+ try {
39
+ return marked.parse(content) as string;
40
+ } catch {
41
+ return content;
42
+ }
43
+ }, [content]);
44
+
45
+ return (
46
+ <div
47
+ className="oceanum-ai-chat-content oceanum-ai-markdown"
48
+ dangerouslySetInnerHTML={{ __html: html }}
49
+ />
50
+ );
51
+ }
52
+
53
+ const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
54
+
55
+ const DRAG_IMAGE = new Image();
56
+ DRAG_IMAGE.src =
57
+ 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iIzAwMDAwMCIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik02OS4xMiw5NC4xNSwyOC41LDEyOGw0MC42MiwzMy44NWE4LDgsMCwxLDEtMTAuMjQsMTIuMjlsLTQ4LTQwYTgsOCwwLDAsMSwwLTEyLjI5bDQ4LTQwYTgsOCwwLDAsMSwxMC4yNCwxMi4zWm0xNzYsMjcuNy00OC00MGE4LDgsMCwxLDAtMTAuMjQsMTIuM0wyMjcuNSwxMjhsLTQwLjYyLDMzLjg1YTgsOCwwLDEsMCwxMC4yNCwxMi4yOWw0OC00MGE4LDgsMCwwLDAsMC0xMi4yOVpNMTYyLjczLDMyLjQ4YTgsOCwwLDAsMC0xMC4yNSw0Ljc5bC02NCwxNzZhOCw4LDAsMCwwLDQuNzksMTAuMjZBOC4xNCw4LjE0LDAsMCwwLDk2LDIyNGE4LDgsMCwwLDAsNy41Mi01LjI3bDY0LTE3NkE4LDgsMCwwLDAsMTYyLjczLDMyLjQ4WiI+PC9wYXRoPjwvc3ZnPg==';
58
+
59
+ const datameshToken = (notebook: Notebook): string => {
60
+ const datameshTokenInjected =
61
+ notebook &&
62
+ notebook.widgets.find(cell => {
63
+ if (cell.editor) {
64
+ const line = cell.editor.getLine(0);
65
+ if (line && line.indexOf('DATAMESH_TOKEN=') >= 0) {
66
+ return true;
67
+ }
68
+ }
69
+ });
70
+ if (!datameshTokenInjected) {
71
+ return `DATAMESH_TOKEN='${window.datameshToken}';`;
72
+ } else {
73
+ return '';
74
+ }
75
+ };
76
+
77
+ const datasourceCode = (
78
+ datasource: IDatasource,
79
+ notebook: Notebook,
80
+ icell: number
81
+ ): string => {
82
+ const datameshImport =
83
+ notebook &&
84
+ notebook.widgets.find(cell => {
85
+ if (cell.editor) {
86
+ let found = false;
87
+ for (let i = 0; i < icell; i++) {
88
+ const line = cell.editor.getLine(i);
89
+ if (
90
+ line &&
91
+ line.indexOf('from oceanum.datamesh import Connector') >= 0
92
+ ) {
93
+ found = true;
94
+ break;
95
+ }
96
+ }
97
+ return found;
98
+ }
99
+ });
100
+ let datasourceStr = (datasource.label || datasource.datasource).replace(
101
+ /[\s-.]/g,
102
+ '_'
103
+ );
104
+ const tokenString = window.injectToken ? 'token=DATAMESH_TOKEN' : '';
105
+ if (
106
+ datasource.variables ||
107
+ datasource.geofilter ||
108
+ datasource.timefilter ||
109
+ datasource.spatialref
110
+ ) {
111
+ datasourceStr += `=datamesh.query(${JSON.stringify(
112
+ datasource,
113
+ null,
114
+ ' '
115
+ ).replace(/null/g, 'None')})`;
116
+ } else {
117
+ datasourceStr += `=datamesh.load_datasource('${datasource.datasource}')`;
118
+ }
119
+ if (!datameshImport) {
120
+ datasourceStr =
121
+ 'from oceanum.datamesh import Connector' +
122
+ '\n' +
123
+ '#Put your datamesh token in the Jupyterlab settings, or as argument in the constructor below' +
124
+ '\n' +
125
+ `datamesh=Connector(${tokenString})` +
126
+ '\n' +
127
+ datasourceStr;
128
+ }
129
+ return datasourceStr;
130
+ };
131
+
132
+ export interface IDatasource {
133
+ id: string;
134
+ label: string;
135
+ datasource: string;
136
+ description: string;
137
+ variables?: string[];
138
+ geofilter?: Record<string, any>;
139
+ timefilter: any;
140
+ spatialref: string;
141
+ }
142
+
143
+ export interface IWorkspaceSpec {
144
+ id: string;
145
+ name: string;
146
+ data: IDatasource[];
147
+ }
148
+
149
+ export interface IDatameshWorkspaceProps {
150
+ spec: IWorkspaceSpec;
151
+ openDatameshUI: (args: any) => void;
152
+ getCurrentWidget: () => Widget;
153
+ shell: JupyterFrontEnd.IShell;
154
+ }
155
+
156
+ export interface IDatasourceActionButton {
157
+ title: string;
158
+ icon: LabIcon;
159
+ feedback?: string;
160
+ onClick: () => void;
161
+ }
162
+
163
+ class DatameshWorkspaceDisplay extends React.Component<IDatameshWorkspaceProps> {
164
+ constructor(props: IDatameshWorkspaceProps) {
165
+ super(props);
166
+ this._drag = null;
167
+ this._dragData = null;
168
+ this.handleDragMove = this.handleDragMove.bind(this);
169
+ this._evtMouseUp = this._evtMouseUp.bind(this);
170
+ }
171
+
172
+ private _drag: Drag | null;
173
+ private _dragData: {
174
+ pressX: number;
175
+ pressY: number;
176
+ dragImage: HTMLElement;
177
+ } | null;
178
+
179
+ render(): React.ReactElement {
180
+ return (
181
+ <div className={'datamesh-workspace-display'}>
182
+ {this.props.spec && (
183
+ <div>
184
+ <span className="datamesh-workspace-name">
185
+ {this.props.spec.name}
186
+ </span>
187
+ <hr></hr>
188
+ {this.props.spec.data.map(datasource => (
189
+ // Render display of a code datasource
190
+ <div
191
+ key={datasource.id}
192
+ data-item-id={datasource.id}
193
+ className={'datasource-item'}
194
+ >
195
+ <DatasourceItem
196
+ datasource={datasource}
197
+ insertDatasource={this.insertDatameshConnect}
198
+ onMouseDown={(event: any): void => {
199
+ this.handleDragSnippet(event, datasource);
200
+ }}
201
+ />
202
+ </div>
203
+ ))}
204
+ </div>
205
+ )}
206
+ </div>
207
+ );
208
+ }
209
+
210
+ private injectToken = (notebookWidget: NotebookPanel): void => {
211
+ const notebookContent = notebookWidget.content as Notebook;
212
+ const datameshTokenInject = datameshToken(notebookContent);
213
+ //const codeCell=new CodeCellModel({});
214
+ if (datameshTokenInject) {
215
+ // const tokenCell = notebookContent.contentFactory.createCodeCell({
216
+ // model: codeCell,
217
+ // rendermime: notebookContent.rendermime,
218
+ // contentFactory: notebookContent.contentFactory,
219
+ // })
220
+ // tokenCell.model.sharedModel.setSource(datameshTokenInject);
221
+ notebookContent.model?.sharedModel.insertCell(0, {
222
+ cell_type: 'code',
223
+ source: datameshTokenInject,
224
+ metadata: {}
225
+ });
226
+ // CodeCell.execute(
227
+ // notebookContent.widgets[0] as CodeCell,
228
+ // notebookWidget.sessionContext
229
+ // );
230
+ }
231
+ };
232
+
233
+ // Handle code datasource insert into an editor
234
+ private insertDatameshConnect = async (
235
+ datasource: IDatasource
236
+ ): Promise<void> => {
237
+ const widget: Widget = this.props.getCurrentWidget();
238
+ if (widget instanceof NotebookPanel) {
239
+ const notebookWidget = widget as NotebookPanel;
240
+ const notebookContent = notebookWidget.content as Notebook;
241
+ const notebookCell = notebookContent.activeCell;
242
+ const notebookCellIndex = notebookContent.activeCellIndex;
243
+ const notebookCellEditor = notebookCell.editor;
244
+
245
+ const datasourceStr = datasourceCode(
246
+ datasource,
247
+ notebookContent,
248
+ notebookCellIndex
249
+ );
250
+ if (notebookCellEditor) {
251
+ if (notebookCell instanceof CodeCell) {
252
+ this.verifyLanguageAndInsert(
253
+ datasourceStr,
254
+ 'python',
255
+ notebookCellEditor
256
+ );
257
+ } else {
258
+ notebookCellEditor.replaceSelection(datasourceStr);
259
+ }
260
+ if (window.injectToken) {
261
+ this.injectToken(notebookWidget);
262
+ }
263
+ } else {
264
+ this.showErrDialog(
265
+ 'Datamesh datasource insert failed: Please select code cell'
266
+ );
267
+ }
268
+ }
269
+ };
270
+
271
+ // Handle language compatibility between code datasource and editor
272
+ private verifyLanguageAndInsert = async (
273
+ datasourceStr: string,
274
+ editorLanguage: string,
275
+ editor: CodeEditor.IEditor
276
+ ): Promise<void> => {
277
+ if (editor && editorLanguage && 'python' !== editorLanguage.toLowerCase()) {
278
+ const result = await this.showWarnDialog(editorLanguage);
279
+ if (result.button.accept) {
280
+ editor.replaceSelection(datasourceStr);
281
+ }
282
+ } else {
283
+ // Language match or editorLanguage is unavailable
284
+ editor?.replaceSelection(datasourceStr);
285
+ }
286
+ };
287
+
288
+ // Display warning dialog when inserting a code datasource incompatible with editor's language
289
+ private showWarnDialog = async (
290
+ editorLanguage: string
291
+ ): Promise<Dialog.IResult<string>> => {
292
+ return showDialog({
293
+ title: 'Warning',
294
+ body: `Datamesh connector is incompatible with ${editorLanguage}. Continue?`,
295
+ buttons: [Dialog.cancelButton(), Dialog.okButton()]
296
+ });
297
+ };
298
+
299
+ // Display error dialog when inserting a code datasource into unsupported widget (i.e. not an editor)
300
+ private showErrDialog = (errMsg: string): Promise<Dialog.IResult<string>> => {
301
+ return showDialog({
302
+ title: 'Error',
303
+ body: errMsg,
304
+ buttons: [Dialog.okButton()]
305
+ });
306
+ };
307
+
308
+ // Initial setup to handle dragging a code datasource
309
+ private handleDragSnippet(
310
+ event: React.MouseEvent<HTMLDivElement, MouseEvent>,
311
+ datasource: IDatasource
312
+ ): void {
313
+ const { button } = event;
314
+
315
+ // do nothing if left mouse button is clicked
316
+ if (button !== 0) {
317
+ return;
318
+ }
319
+
320
+ this._dragData = {
321
+ pressX: event.clientX,
322
+ pressY: event.clientY,
323
+ dragImage: DRAG_IMAGE
324
+ };
325
+
326
+ const mouseUpListener = (event: MouseEvent): void => {
327
+ this._evtMouseUp(event, datasource, mouseMoveListener);
328
+ };
329
+ const mouseMoveListener = (event: MouseEvent): void => {
330
+ this.handleDragMove(
331
+ event,
332
+ datasource,
333
+ mouseMoveListener,
334
+ mouseUpListener
335
+ );
336
+ };
337
+
338
+ const target = event.target as HTMLElement;
339
+ target.addEventListener('mouseup', mouseUpListener, {
340
+ once: true,
341
+ capture: true
342
+ });
343
+ target.addEventListener('mousemove', mouseMoveListener, true);
344
+
345
+ // since a browser has its own drag'n'drop support for images and some other elements.
346
+ target.ondragstart = (): boolean => false;
347
+ }
348
+
349
+ private _evtMouseUp(
350
+ event: MouseEvent,
351
+ datasource: IDatasource,
352
+ mouseMoveListener: (event: MouseEvent) => void
353
+ ): void {
354
+ event.preventDefault();
355
+ event.stopPropagation();
356
+
357
+ const target = event.target as HTMLElement;
358
+ target.removeEventListener('mousemove', mouseMoveListener, true);
359
+ }
360
+
361
+ private handleDragMove(
362
+ event: MouseEvent,
363
+ datasource: IDatasource,
364
+ mouseMoveListener: (event: MouseEvent) => void,
365
+ mouseUpListener: (event: MouseEvent) => void
366
+ ): void {
367
+ event.preventDefault();
368
+ event.stopPropagation();
369
+
370
+ const data = this._dragData;
371
+
372
+ if (
373
+ data &&
374
+ this.shouldStartDrag(
375
+ data.pressX,
376
+ data.pressY,
377
+ event.clientX,
378
+ event.clientY
379
+ )
380
+ ) {
381
+ // Create drag image
382
+ const element = document.createElement('div');
383
+ element.innerHTML = datasource.description;
384
+ element.classList.add('datasource-drag-image');
385
+ data.dragImage = element;
386
+
387
+ // Remove mouse listeners and start the drag.
388
+ const target = event.target as HTMLElement;
389
+ target.removeEventListener('mousemove', mouseMoveListener, true);
390
+ target.removeEventListener('mouseup', mouseUpListener, true);
391
+
392
+ void this.startDrag(
393
+ data.dragImage,
394
+ datasource,
395
+ event.clientX,
396
+ event.clientY
397
+ );
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Detect if a drag event should be started. This is down if the
403
+ * mouse is moved beyond a certain distance (DRAG_THRESHOLD).
404
+ *
405
+ * @param prevX - X Coordinate of the mouse pointer during the mousedown event
406
+ * @param prevY - Y Coordinate of the mouse pointer during the mousedown event
407
+ * @param nextX - Current X Coordinate of the mouse pointer
408
+ * @param nextY - Current Y Coordinate of the mouse pointer
409
+ */
410
+ private shouldStartDrag(
411
+ prevX: number,
412
+ prevY: number,
413
+ nextX: number,
414
+ nextY: number
415
+ ): boolean {
416
+ const dx = Math.abs(nextX - prevX);
417
+ const dy = Math.abs(nextY - prevY);
418
+ return dx >= 0 || dy >= 5;
419
+ }
420
+
421
+ private async startDrag(
422
+ dragImage: HTMLElement,
423
+ datasource: IDatasource,
424
+ clientX: number,
425
+ clientY: number
426
+ ): Promise<void> {
427
+ const notebookPanel: NotebookPanel =
428
+ this.props.getCurrentWidget() as NotebookPanel;
429
+ const notebookContent = notebookPanel.content as Notebook;
430
+ const codeCell = new CodeCellModel({});
431
+ const cell = notebookContent.contentFactory.createCodeCell({
432
+ model: codeCell,
433
+ rendermime: notebookContent.rendermime,
434
+ contentFactory: notebookContent.contentFactory
435
+ });
436
+ let content = datasourceCode(datasource, notebookContent, 0);
437
+ if (window.injectToken) {
438
+ const notebookPanel = this.props.getCurrentWidget() as NotebookPanel;
439
+ content = datameshToken(notebookPanel.content) + '\n' + content;
440
+ }
441
+ cell.model.sharedModel.setSource(content);
442
+
443
+ this._drag = new Drag({
444
+ mimeData: new MimeData(),
445
+ dragImage: dragImage,
446
+ supportedActions: 'copy-move',
447
+ proposedAction: 'copy',
448
+ source: this
449
+ });
450
+
451
+ const selected: nbformat.ICell[] = [cell.model.toJSON()];
452
+ this._drag.mimeData.setData(JUPYTER_CELL_MIME, selected);
453
+ this._drag.mimeData.setData('text/plain', datasource.description);
454
+
455
+ return this._drag.start(clientX, clientY).then(() => {
456
+ this._drag = null;
457
+ this._dragData = null;
458
+ });
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Shows a message prompting user to configure their Datamesh token.
464
+ * Only renders when no token is set.
465
+ */
466
+ function TokenConfigMessage({
467
+ commands
468
+ }: {
469
+ commands: CommandRegistry;
470
+ }): React.ReactElement | null {
471
+ const [hasToken, setHasToken] = React.useState(!!window.datameshToken);
472
+
473
+ React.useEffect(() => {
474
+ const checkToken = () => setHasToken(!!window.datameshToken);
475
+ const interval = setInterval(checkToken, 1000);
476
+ return () => clearInterval(interval);
477
+ }, []);
478
+
479
+ if (hasToken) {
480
+ return null;
481
+ }
482
+
483
+ return (
484
+ <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{' '}
494
+ <a
495
+ href="https://home.oceanum.io/account"
496
+ target="_blank"
497
+ rel="noopener noreferrer"
498
+ >
499
+ Get token here
500
+ </a>
501
+ </div>
502
+ );
503
+ }
504
+
505
+ interface IChatMessage {
506
+ role: 'user' | 'assistant';
507
+ content: string;
508
+ }
509
+
510
+ interface IAIChatPanelProps {
511
+ commands: CommandRegistry;
512
+ }
513
+
514
+ function AIChatPanel({
515
+ commands
516
+ }: IAIChatPanelProps): React.ReactElement | null {
517
+ const [messages, setMessages] = React.useState<IChatMessage[]>([]);
518
+ const [input, setInput] = React.useState('');
519
+ const [loading, setLoading] = React.useState(false);
520
+ const [progress, setProgress] = React.useState<Progress | null>(null);
521
+
522
+ // What the agent is doing, as the backend reports it. A `/api/chat` call
523
+ // takes around fifty seconds and used to spend all of them showing the same
524
+ // three words, which read as a hang rather than as work.
525
+ React.useEffect(() => onProgress(setProgress), []);
526
+ const [error, setError] = React.useState<string | null>(null);
527
+ const messagesEndRef = React.useRef<HTMLDivElement>(null);
528
+
529
+ // Chat history navigation
530
+ const [history, setHistory] = React.useState<string[]>([]);
531
+ const [historyIndex, setHistoryIndex] = React.useState(-1);
532
+ const [tempInput, setTempInput] = React.useState('');
533
+
534
+ // Track the actual token value to detect changes
535
+ const [token, setToken] = React.useState(window.datameshToken || '');
536
+ const hasToken = !!token;
537
+
538
+ // Check if code capability is enabled (null = loading/unknown)
539
+ const [codeEnabled, setCodeEnabled] = React.useState<boolean | null>(null);
540
+
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]);
552
+
553
+ // Fetch capabilities when token changes
554
+ React.useEffect(() => {
555
+ if (!token) {
556
+ setCodeEnabled(null);
557
+ return;
558
+ }
559
+
560
+ const fetchCapabilities = async () => {
561
+ try {
562
+ const response = await fetch(
563
+ `${OCEANUM_AI_BACKEND_URL}/api/capabilities`,
564
+ {
565
+ headers: {
566
+ 'X-Datamesh-Token': token
567
+ }
568
+ }
569
+ );
570
+ if (response.ok) {
571
+ const data = await response.json();
572
+ setCodeEnabled(data.code === true);
573
+ } else {
574
+ setCodeEnabled(false);
575
+ }
576
+ } catch {
577
+ setCodeEnabled(false);
578
+ }
579
+ };
580
+
581
+ void fetchCapabilities();
582
+ }, [token]);
583
+
584
+ // Auto-scroll to bottom when messages change
585
+ React.useEffect(() => {
586
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
587
+ }, [messages, loading]);
588
+
589
+ // The notebook this conversation is pinned to: undefined until it starts,
590
+ // null when it has none. See 'oceanum-ai:chat-context' in index.ts.
591
+ const [context, setContext] = React.useState<string | null | undefined>(
592
+ undefined
593
+ );
594
+ // Bumped by New chat. A request keeps the value it started with, and drops
595
+ // its result once the value has moved on: the run New chat stopped resolves
596
+ // AFTER the reset -- with "Stopped." or a late answer -- and must neither
597
+ // land in the new conversation nor end `loading` under a newer request.
598
+ const conversation = React.useRef(0);
599
+
600
+ const handleSubmit = async (): Promise<void> => {
601
+ const prompt = input.trim();
602
+ if (!prompt || loading) {
603
+ return;
604
+ }
605
+ const mine = conversation.current;
606
+
607
+ // Add to history
608
+ setHistory(prev => [...prev, prompt]);
609
+ setHistoryIndex(-1);
610
+ setTempInput('');
611
+
612
+ setInput('');
613
+ setError(null);
614
+ setMessages(prev => [...prev, { role: 'user', content: prompt }]);
615
+ setProgress(null);
616
+ setLoading(true);
617
+
618
+ try {
619
+ // Starts the conversation if this is its first message, and reports
620
+ // its notebook's name either way.
621
+ const pinned = await commands.execute('oceanum-ai:chat-context');
622
+ if (mine !== conversation.current) {
623
+ return;
624
+ }
625
+ setContext((pinned as string | null) ?? null);
626
+ // Pass current chat history (before adding the new user message)
627
+ // The user message was already added to state, so we use the messages array directly
628
+ const result = await commands.execute('oceanum-ai:submit-prompt', {
629
+ prompt,
630
+ chatHistory: messages as unknown as ReadonlyJSONArray
631
+ });
632
+ if (mine !== conversation.current) {
633
+ return;
634
+ }
635
+ // Placing the answer can have put it in a new notebook (the old one was
636
+ // deleted), so the line is read again rather than left as it was.
637
+ const after = await commands.execute('oceanum-ai:chat-context');
638
+ if (mine !== conversation.current) {
639
+ return;
640
+ }
641
+ setContext((after as string | null) ?? null);
642
+ const explanation = (result as string) ?? '';
643
+ setMessages(prev => [
644
+ ...prev,
645
+ { role: 'assistant', content: explanation }
646
+ ]);
647
+ } catch (err: any) {
648
+ if (mine === conversation.current) {
649
+ setError(err?.message ?? 'An error occurred');
650
+ }
651
+ } finally {
652
+ if (mine === conversation.current) {
653
+ setLoading(false);
654
+ }
655
+ }
656
+ };
657
+
658
+ // Clear the conversation and start another, pinned to whichever notebook is
659
+ // the active tab now. The prompt history (up/down arrow) is kept: it is
660
+ // input recall, not conversation.
661
+ const handleNewChat = async (): Promise<void> => {
662
+ conversation.current += 1;
663
+ const mine = conversation.current;
664
+ setMessages([]);
665
+ setInput('');
666
+ setError(null);
667
+ setLoading(false);
668
+ // The old run's last phase belongs to the conversation thrown away.
669
+ setProgress(null);
670
+ setHistoryIndex(-1);
671
+ setTempInput('');
672
+ try {
673
+ const pinned = await commands.execute('oceanum-ai:new-chat');
674
+ if (mine === conversation.current) {
675
+ setContext((pinned as string | null) ?? null);
676
+ }
677
+ } catch (err: any) {
678
+ // The chat commands register once settings have loaded, so a click
679
+ // before that has nothing to reach. Say so, rather than leave the old
680
+ // conversation's "Context:" line under a cleared chat.
681
+ if (mine === conversation.current) {
682
+ setContext(undefined);
683
+ setError(err?.message ?? 'Oceanum AI is not ready yet.');
684
+ }
685
+ }
686
+ };
687
+
688
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
689
+ if (e.key === 'Enter' && !e.shiftKey) {
690
+ e.preventDefault();
691
+ void handleSubmit();
692
+ return;
693
+ }
694
+
695
+ // History navigation with up/down arrows
696
+ if (e.key === 'ArrowUp' && history.length > 0) {
697
+ e.preventDefault();
698
+ if (historyIndex === -1) {
699
+ // Save current input before navigating
700
+ setTempInput(input);
701
+ setHistoryIndex(history.length - 1);
702
+ setInput(history[history.length - 1]);
703
+ } else if (historyIndex > 0) {
704
+ setHistoryIndex(historyIndex - 1);
705
+ setInput(history[historyIndex - 1]);
706
+ }
707
+ return;
708
+ }
709
+
710
+ if (e.key === 'ArrowDown' && historyIndex !== -1) {
711
+ e.preventDefault();
712
+ if (historyIndex < history.length - 1) {
713
+ setHistoryIndex(historyIndex + 1);
714
+ setInput(history[historyIndex + 1]);
715
+ } else {
716
+ // Return to current input
717
+ setHistoryIndex(-1);
718
+ setInput(tempInput);
719
+ }
720
+ return;
721
+ }
722
+ };
723
+
724
+ // Hide AI panel if no token or code capability is not enabled
725
+ if (!hasToken || codeEnabled === false) {
726
+ return null;
727
+ }
728
+
729
+ return (
730
+ <div className="oceanum-ai-chat">
731
+ <div className="oceanum-ai-chat-header">
732
+ <span className="oceanum-ai-chat-title">Oceanum AI</span>
733
+ <button
734
+ className="jp-mod-styled oceanum-ai-chat-new"
735
+ onClick={() => void handleNewChat()}
736
+ title="Clear this conversation and start a new one, with the active notebook as its context"
737
+ >
738
+ New chat
739
+ </button>
740
+ </div>
741
+ {context !== undefined && (
742
+ <div className="oceanum-ai-chat-context" title={context ?? undefined}>
743
+ {context === null ? 'No notebook in context' : `Context: ${context}`}
744
+ </div>
745
+ )}
746
+ <div className="oceanum-ai-chat-messages">
747
+ {messages.length === 0 && (
748
+ <div className="oceanum-text-empty">
749
+ Ask Oceanum AI to query and analyse data from Datamesh.
750
+ <br></br>
751
+ Answers go into this chat&apos;s notebook: the one in the active tab
752
+ when the chat starts, or a new one.
753
+ </div>
754
+ )}
755
+ {messages.map((msg, i) => (
756
+ <div
757
+ key={i}
758
+ className={`oceanum-ai-chat-message oceanum-ai-chat-message--${msg.role}`}
759
+ >
760
+ <span className="oceanum-ai-chat-role">
761
+ {msg.role === 'user' ? 'You' : 'AI'}
762
+ </span>
763
+ {msg.role === 'user' ? (
764
+ <pre className="oceanum-ai-chat-content">{msg.content}</pre>
765
+ ) : (
766
+ <MarkdownContent content={msg.content} />
767
+ )}
768
+ </div>
769
+ ))}
770
+ {loading && (
771
+ <div className="oceanum-ai-chat-message oceanum-ai-chat-message--assistant">
772
+ <span className="oceanum-ai-chat-role">AI</span>
773
+ <span className="oceanum-ai-chat-loading">
774
+ {describeProgress(progress)}
775
+ </span>
776
+ </div>
777
+ )}
778
+ {error && <div className="oceanum-ai-chat-error">{error}</div>}
779
+ <div ref={messagesEndRef} />
780
+ </div>
781
+ <div className="oceanum-ai-chat-input-area">
782
+ <textarea
783
+ className="oceanum-ai-chat-input"
784
+ rows={3}
785
+ placeholder="Ask Oceanum AI… (Enter to send, Shift+Enter for newline)"
786
+ value={input}
787
+ onChange={e => setInput(e.target.value)}
788
+ onKeyDown={handleKeyDown}
789
+ disabled={loading}
790
+ />
791
+ <button
792
+ className="jp-mod-styled oceanum-ai-chat-send"
793
+ onClick={() =>
794
+ void (loading
795
+ ? commands.execute('oceanum-ai:stop')
796
+ : handleSubmit())
797
+ }
798
+ disabled={!loading && !input.trim()}
799
+ title={loading ? 'Stop the current response' : undefined}
800
+ >
801
+ {loading ? 'Stop' : 'Send'}
802
+ </button>
803
+ </div>
804
+ </div>
805
+ );
806
+ }
807
+
808
+ /**
809
+ * DatameshConnectWidget props.
810
+ */
811
+ export interface IDatameshWidgetProps {
812
+ app: JupyterFrontEnd;
813
+ name: string;
814
+ icon: LabIcon;
815
+ openDatameshUI: any;
816
+ commands: CommandRegistry;
817
+ getCurrentWidget: () => Widget;
818
+ }
819
+
820
+ /**
821
+ * A widget for Datamesh Connections.
822
+ */
823
+ export class DatameshConnectWidget extends ReactWidget {
824
+ props: IDatameshWidgetProps;
825
+ renderSignal: Signal<this, any>;
826
+ icon: LabIcon;
827
+ openDatameshUI: any;
828
+ datameshWorkspaceSpec: IWorkspaceSpec | null = null;
829
+
830
+ constructor(props: IDatameshWidgetProps) {
831
+ super();
832
+ this.props = props;
833
+ this.renderSignal = new Signal<this, any>(this);
834
+ this.renderDisplay = this.renderDisplay.bind(this);
835
+
836
+ window.addEventListener(
837
+ 'message',
838
+ this.receiveIFrameMessage.bind(this),
839
+ false
840
+ );
841
+ }
842
+
843
+ receiveIFrameMessage(event: MessageEvent): void {
844
+ if (event.data && event.data.action === 'workspace-modify') {
845
+ this.datameshWorkspaceSpec = {
846
+ id: event.data.id,
847
+ name: event.data.name,
848
+ data: event.data.data
849
+ };
850
+ this.renderSignal.emit(this.datameshWorkspaceSpec);
851
+ }
852
+ }
853
+
854
+ renderDisplay(datameshWorkspace: IWorkspaceSpec): React.ReactElement {
855
+ return (
856
+ <>
857
+ {this.datameshWorkspaceSpec ? (
858
+ <DatameshWorkspaceDisplay
859
+ spec={datameshWorkspace}
860
+ openDatameshUI={this.props.openDatameshUI}
861
+ getCurrentWidget={this.props.getCurrentWidget}
862
+ shell={this.props.app.shell}
863
+ />
864
+ ) : (
865
+ <div className="oceanum-text-empty">
866
+ <a onClick={this.props.openDatameshUI}>Open</a> the Oceanum Datamesh
867
+ UI to add datasources.
868
+ </div>
869
+ )}
870
+ </>
871
+ );
872
+ }
873
+
874
+ render(): React.ReactElement {
875
+ return (
876
+ <div className="datamesh-connect">
877
+ <header className="oceanum-sidebar-header">
878
+ <this.props.icon.react
879
+ tag="span"
880
+ width="auto"
881
+ height="20px"
882
+ verticalAlign="middle"
883
+ />
884
+ <span className="oceanum-sidebar-title">Oceanum.io</span>
885
+ </header>
886
+ <div className="datamesh-workspace-header">
887
+ <span>Datamesh Workspace</span>
888
+ <div
889
+ className="open-datamesh-ui"
890
+ onClick={this.props.openDatameshUI}
891
+ title="Open Datamesh UI"
892
+ >
893
+ {<addIcon.react height="20px" verticalAlign="middle" />}
894
+ </div>
895
+ </div>
896
+ <div className="datamesh-connect-workspace">
897
+ <UseSignal signal={this.renderSignal} initialArgs={null}>
898
+ {(_, datameshWorkspace): React.ReactElement =>
899
+ this.renderDisplay(datameshWorkspace)
900
+ }
901
+ </UseSignal>
902
+ <TokenConfigMessage commands={this.props.commands} />
903
+ </div>
904
+ <div className="datamesh-connect-divider" />
905
+ <AIChatPanel commands={this.props.commands} />
906
+ </div>
907
+ );
908
+ }
909
+ }