@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,97 @@
1
+ import {
2
+ formatNotebookCells,
3
+ IContextCell,
4
+ MAX_CELL_CHARS,
5
+ MAX_CELLS_BYTES,
6
+ MAX_NOTEBOOK_CELLS
7
+ } from '../notebookContext';
8
+
9
+ const code = (source: string): IContextCell => ({ kind: 'code', source });
10
+ const md = (source: string): IContextCell => ({ kind: 'markdown', source });
11
+
12
+ describe('formatNotebookCells', () => {
13
+ it('keeps code verbatim and markdown as comments, in notebook order', () => {
14
+ // The server wraps every cell in one ```python block, so markdown must
15
+ // arrive as something that reads as Python there.
16
+ expect(
17
+ formatNotebookCells([
18
+ code('import xarray as xr'),
19
+ md('## Results\n\nWave height is *high*.'),
20
+ code('ds.plot()')
21
+ ])
22
+ ).toEqual([
23
+ 'import xarray as xr',
24
+ '# %% [markdown]\n# ## Results\n#\n# Wave height is *high*.',
25
+ 'ds.plot()'
26
+ ]);
27
+ });
28
+
29
+ it('skips empty and whitespace-only cells of either kind', () => {
30
+ expect(formatNotebookCells([code(' \n'), md(''), code('x = 1')])).toEqual([
31
+ 'x = 1'
32
+ ]);
33
+ });
34
+
35
+ it('reads CRLF markdown line by line', () => {
36
+ expect(formatNotebookCells([md('a\r\nb')])).toEqual([
37
+ '# %% [markdown]\n# a\n# b'
38
+ ]);
39
+ });
40
+
41
+ it('drops inline image data from markdown but keeps the reference', () => {
42
+ const image = `![plot](data:image/png;base64,${'A'.repeat(5000)}=)`;
43
+ expect(formatNotebookCells([md(`See ${image} above.`)])).toEqual([
44
+ '# %% [markdown]\n# See ![plot](data:…) above.'
45
+ ]);
46
+ });
47
+
48
+ it('drops inline images whatever parameters they carry', () => {
49
+ const charset = `![a](data:image/png;charset=utf-8;base64,${'B'.repeat(300)})`;
50
+ const svg = `<img src="data:image/svg+xml;utf8,%3Csvg%20${'C'.repeat(300)}%3E">`;
51
+ expect(formatNotebookCells([md(`${charset} and ${svg}`)])).toEqual([
52
+ '# %% [markdown]\n# ![a](data:…) and <img src="data:…">'
53
+ ]);
54
+ });
55
+
56
+ it('leaves out a markdown cell the server would reject, keeping the rest', () => {
57
+ // One oversized cell fails the WHOLE request server-side.
58
+ const huge = md('x'.repeat(MAX_CELL_CHARS + 1));
59
+ expect(formatNotebookCells([code('a = 1'), huge, md('ok')])).toEqual([
60
+ 'a = 1',
61
+ '# %% [markdown]\n# ok'
62
+ ]);
63
+ });
64
+
65
+ it('falls back to code only when markdown would exceed the cell limit', () => {
66
+ const cells = Array.from({ length: MAX_NOTEBOOK_CELLS }, (_, i) =>
67
+ i % 2 === 0 ? code(`c${i}`) : md(`m${i}`)
68
+ );
69
+ cells.push(md('one too many'));
70
+ const sent = formatNotebookCells(cells);
71
+ expect(sent).toHaveLength(MAX_NOTEBOOK_CELLS / 2);
72
+ expect(sent.every(s => !s.startsWith('# %% [markdown]'))).toBe(true);
73
+ });
74
+
75
+ it('falls back to code only when markdown would exceed the byte budget', () => {
76
+ // The server caps the whole request at 2 MiB, history and run outputs
77
+ // included. A notebook whose code fits must not be pushed over by prose.
78
+ const codeCells = Array.from({ length: 10 }, (_, i) =>
79
+ code(`x${i} = "${'a'.repeat(60000)}"`)
80
+ );
81
+ const prose = Array.from({ length: 10 }, () => md('b'.repeat(60000)));
82
+ const sent = formatNotebookCells([...codeCells, ...prose]);
83
+ expect(sent).toHaveLength(10);
84
+ expect(sent.every(s => !s.startsWith('# %% [markdown]'))).toBe(true);
85
+ });
86
+
87
+ it('counts the budget in UTF-8 bytes, not characters', () => {
88
+ // 'é' is 2 bytes: in characters these cells are just under the budget, in
89
+ // bytes well over it.
90
+ const half = Math.floor(MAX_CELLS_BYTES / 2) - 100;
91
+ const sent = formatNotebookCells([
92
+ code('é'.repeat(half)),
93
+ md('b'.repeat(half))
94
+ ]);
95
+ expect(sent).toHaveLength(1);
96
+ });
97
+ });
@@ -0,0 +1,195 @@
1
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import type { INotebookTracker } from '@jupyterlab/notebook';
3
+ import { NotebookPanel } from '@jupyterlab/notebook';
4
+ import { notebookHost } from '../notebookHost';
5
+
6
+ // The real module pulls in ES-module UI code that jest cannot load; only the
7
+ // class, for `instanceof`, is needed here.
8
+ jest.mock('@jupyterlab/notebook', () => ({
9
+ NotebookPanel: class NotebookPanel {}
10
+ }));
11
+
12
+ interface IFakeContent {
13
+ widgets: unknown[];
14
+ activeCellIndex: number;
15
+ }
16
+
17
+ /** A notebook panel whose file has not been read yet; `load` reads it. */
18
+ function unloaded(
19
+ id: string,
20
+ cells = 3,
21
+ path = 'work/other.ipynb'
22
+ ): { panel: NotebookPanel; content: IFakeContent; load: () => void } {
23
+ let load: () => void = () => undefined;
24
+ const ready = new Promise<void>(resolve => {
25
+ load = resolve;
26
+ });
27
+ const content: IFakeContent = {
28
+ widgets: new Array(cells).fill(null),
29
+ activeCellIndex: 0
30
+ };
31
+ const panel = Object.create(NotebookPanel.prototype) as NotebookPanel;
32
+ Object.defineProperty(panel, 'id', { value: id });
33
+ Object.defineProperty(panel, 'context', { value: { ready, path } });
34
+ Object.defineProperty(panel, 'content', { value: content });
35
+ return { panel, content, load };
36
+ }
37
+
38
+ function fakeLab(
39
+ execute: (command: string, args: unknown) => Promise<unknown>,
40
+ tracked: NotebookPanel[] = []
41
+ ) {
42
+ const activated: string[] = [];
43
+ const commands = { execute: jest.fn(execute) };
44
+ const shell = {
45
+ currentWidget: null as unknown,
46
+ activateById: (id: string) => {
47
+ activated.push(id);
48
+ }
49
+ };
50
+ const tracker = {
51
+ has: (widget: unknown) => tracked.includes(widget as NotebookPanel),
52
+ find: (test: (panel: NotebookPanel) => boolean) => tracked.find(test)
53
+ };
54
+ return {
55
+ app: { commands, shell } as unknown as JupyterFrontEnd,
56
+ tracker: tracker as unknown as INotebookTracker,
57
+ commands,
58
+ shell,
59
+ activated
60
+ };
61
+ }
62
+
63
+ const flush = (): Promise<void> =>
64
+ new Promise(resolve => setTimeout(resolve, 0));
65
+
66
+ const never = (): Promise<unknown> => new Promise(() => undefined);
67
+
68
+ describe('notebookHost: re-opening', () => {
69
+ it('re-opens a notebook only once its file has been read', async () => {
70
+ // Reading the file replaces every cell, so an answer placed before then
71
+ // would be wiped when it arrived.
72
+ const { panel, load } = unloaded('nb');
73
+ const { app, tracker } = fakeLab(async () => panel);
74
+ let result: NotebookPanel | null | undefined;
75
+ void notebookHost(app, tracker)
76
+ .reopen('work/a.ipynb')
77
+ .then(p => {
78
+ result = p;
79
+ });
80
+
81
+ await flush();
82
+ expect(result).toBeUndefined();
83
+
84
+ load();
85
+ await flush();
86
+ expect(result).toBe(panel);
87
+ });
88
+
89
+ it('selects the last cell, so that answers go at the end', async () => {
90
+ // It opens with its first cell selected, and answers go below the
91
+ // selected cell: above the user's work.
92
+ const { panel, content, load } = unloaded('nb', 30);
93
+ const { app, tracker } = fakeLab(async () => panel);
94
+ load();
95
+
96
+ await notebookHost(app, tracker).reopen('work/a.ipynb');
97
+
98
+ expect(content.activeCellIndex).toBe(29);
99
+ });
100
+
101
+ it('uses a notebook the user has open already, leaving its selection as it is', async () => {
102
+ // They closed the pinned tab and opened the same file again themselves.
103
+ const { panel, content, load } = unloaded('theirs', 30, 'work/a.ipynb');
104
+ content.activeCellIndex = 7;
105
+ const { app, tracker, commands } = fakeLab(async () => null, [panel]);
106
+ load();
107
+
108
+ await expect(
109
+ notebookHost(app, tracker).reopen('work/a.ipynb')
110
+ ).resolves.toBe(panel);
111
+ expect(content.activeCellIndex).toBe(7);
112
+ expect(commands.execute).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('gives up on a notebook that never loads, rather than stalling the chat', async () => {
116
+ const { panel } = unloaded('nb');
117
+ const { app, tracker } = fakeLab(async () => panel);
118
+
119
+ await expect(
120
+ notebookHost(app, tracker, 10).reopen('work/a.ipynb')
121
+ ).resolves.toBeNull();
122
+ });
123
+
124
+ it('gives up on an open that never finishes, rather than stalling the chat', async () => {
125
+ const { app, tracker } = fakeLab(never);
126
+
127
+ await expect(
128
+ notebookHost(app, tracker, 10).reopen('work/a.ipynb')
129
+ ).resolves.toBeNull();
130
+ });
131
+
132
+ it('reports a notebook that cannot be re-opened as none', async () => {
133
+ const { app, tracker } = fakeLab(async () => {
134
+ throw new Error('not found');
135
+ });
136
+
137
+ await expect(
138
+ notebookHost(app, tracker).reopen('work/gone.ipynb')
139
+ ).resolves.toBeNull();
140
+ });
141
+ });
142
+
143
+ describe('notebookHost: creating', () => {
144
+ it('creates a notebook, and brings it to the front once it has loaded', async () => {
145
+ const { panel, load } = unloaded('new');
146
+ const { app, tracker, commands, activated } = fakeLab(async () => panel);
147
+ let result: NotebookPanel | null | undefined;
148
+ void notebookHost(app, tracker)
149
+ .create()
150
+ .then(p => {
151
+ result = p;
152
+ });
153
+
154
+ await flush();
155
+ expect(commands.execute).toHaveBeenCalledWith('notebook:create-new', {
156
+ kernelName: 'python3'
157
+ });
158
+ expect(result).toBeUndefined();
159
+ expect(activated).toEqual([]);
160
+
161
+ load();
162
+ await flush();
163
+ expect(result).toBe(panel);
164
+ expect(activated).toEqual(['new']);
165
+ });
166
+
167
+ it('takes no notebook but the one it created', async () => {
168
+ // Anything else could be a notebook the user opened meanwhile.
169
+ const { app, tracker, activated } = fakeLab(async () => undefined);
170
+
171
+ await expect(notebookHost(app, tracker).create()).resolves.toBeNull();
172
+ expect(activated).toEqual([]);
173
+ });
174
+
175
+ it('gives up on a notebook that is never created', async () => {
176
+ const { app, tracker, activated } = fakeLab(never);
177
+
178
+ await expect(notebookHost(app, tracker, 10).create()).resolves.toBeNull();
179
+ expect(activated).toEqual([]);
180
+ });
181
+ });
182
+
183
+ describe('notebookHost: the active tab', () => {
184
+ it('is the notebook in it, or none when the tab is not a notebook', () => {
185
+ const { panel } = unloaded('nb');
186
+ const { app, tracker, shell } = fakeLab(async () => null, [panel]);
187
+ const host = notebookHost(app, tracker);
188
+
189
+ shell.currentWidget = panel;
190
+ expect(host.activeTab()).toBe(panel);
191
+
192
+ shell.currentWidget = { id: 'terminal-1' };
193
+ expect(host.activeTab()).toBeNull();
194
+ });
195
+ });
@@ -0,0 +1,95 @@
1
+ import { harvestOutputs, stripAnsi, MAX_OBSERVED_CHARS } from '../notebookRun';
2
+ import type * as nbformat from '@jupyterlab/nbformat';
3
+
4
+ const stream = (name: 'stdout' | 'stderr', text: string | string[]) =>
5
+ ({ output_type: 'stream', name, text }) as nbformat.IStream;
6
+
7
+ const error = (ename: string, evalue: string, traceback: string[]) =>
8
+ ({ output_type: 'error', ename, evalue, traceback }) as nbformat.IError;
9
+
10
+ describe('harvestOutputs', () => {
11
+ it('concatenates stdout streams in order and reports ok', () => {
12
+ const out = harvestOutputs([
13
+ stream('stdout', 'rows 240\n'),
14
+ stream('stdout', ['max ', '2.5\n'])
15
+ ]);
16
+ expect(out).toEqual({
17
+ status: 'ok',
18
+ stdout: 'rows 240\nmax 2.5\n',
19
+ error: null
20
+ });
21
+ });
22
+
23
+ it('turns an error output into an error run with the traceback', () => {
24
+ const out = harvestOutputs([
25
+ stream('stdout', 'before\n'),
26
+ error('NameError', "name 'ds' is not defined", [
27
+ '\u001b[0;31mNameError\u001b[0m Traceback',
28
+ "name 'ds' is not defined"
29
+ ])
30
+ ]);
31
+ expect(out.status).toBe('error');
32
+ expect(out.stdout).toBe('before\n');
33
+ expect(out.error).toContain("NameError: name 'ds' is not defined");
34
+ // ANSI colour codes are for a terminal, not for the model.
35
+ expect(out.error).not.toContain('\u001b[');
36
+ expect(out.error).toContain('NameError Traceback');
37
+ });
38
+
39
+ it('folds stderr into the error text when the run failed', () => {
40
+ const out = harvestOutputs([
41
+ stream('stderr', 'FutureWarning: something\n'),
42
+ error('ValueError', 'bad', [])
43
+ ]);
44
+ expect(out.status).toBe('error');
45
+ expect(out.error).toContain('ValueError: bad');
46
+ expect(out.error).toContain('FutureWarning');
47
+ });
48
+
49
+ it('does not send rendered outputs like DataFrame reprs', () => {
50
+ const out = harvestOutputs([
51
+ {
52
+ output_type: 'execute_result',
53
+ data: { 'text/plain': 'huge dataframe repr' },
54
+ metadata: {},
55
+ execution_count: 1
56
+ } as nbformat.IExecuteResult
57
+ ]);
58
+ expect(out).toEqual({ status: 'ok', stdout: '', error: null });
59
+ });
60
+
61
+ it('is ok with no outputs at all', () => {
62
+ expect(harvestOutputs([])).toEqual({
63
+ status: 'ok',
64
+ stdout: '',
65
+ error: null
66
+ });
67
+ });
68
+
69
+ it('keeps only the tail of a huge stdout; the server reads no more', () => {
70
+ const lines = Array.from({ length: 5000 }, (_, i) => `line ${i}\n`);
71
+ const out = harvestOutputs([stream('stdout', lines)]);
72
+ expect(out.stdout.length).toBeLessThan(MAX_OBSERVED_CHARS + 100);
73
+ expect(out.stdout).toMatch(/^…\[\d+ chars truncated\]\n/);
74
+ expect(out.stdout).toMatch(/line 4999\n$/);
75
+ });
76
+ });
77
+
78
+ describe('stripAnsi', () => {
79
+ it('removes colour and cursor codes', () => {
80
+ expect(stripAnsi('\u001b[1;31mred\u001b[0m plain')).toBe('red plain');
81
+ });
82
+
83
+ it('removes private-mode and OSC sequences from progress bars', () => {
84
+ expect(stripAnsi('\u001b[?25l50%\u001b[?25h')).toBe('50%');
85
+ expect(stripAnsi('\u001b]0;title\u0007done')).toBe('done');
86
+ });
87
+
88
+ it('does not swallow text between an ST-terminated and a BEL-terminated OSC', () => {
89
+ expect(
90
+ stripAnsi(
91
+ '\u001b]8;;http://x\u001b\\link\u001b]8;;\u001b\\ then \u001b]0;t\u0007end'
92
+ )
93
+ ).toBe('link then end');
94
+ });
95
+ });
@@ -0,0 +1,62 @@
1
+ import type { Notebook } from '@jupyterlab/notebook';
2
+ import { snapshotFromIpynb, snapshotOf } from '../notebookContext';
3
+
4
+ const cell = (type: string, source: string) => ({
5
+ model: { type, sharedModel: { getSource: () => source } }
6
+ });
7
+
8
+ describe('snapshotOf: an open notebook', () => {
9
+ it('reads its code and markdown cells in order, and its selected cell', () => {
10
+ const selected = cell('code', 'df.head()');
11
+ const notebook = {
12
+ widgets: [
13
+ cell('code', 'x = 1'),
14
+ cell('raw', 'not context'),
15
+ cell('markdown', '## Notes'),
16
+ selected
17
+ ],
18
+ activeCell: selected
19
+ } as unknown as Notebook;
20
+
21
+ expect(snapshotOf(notebook)).toEqual({
22
+ cells: [
23
+ { kind: 'code', source: 'x = 1' },
24
+ { kind: 'markdown', source: '## Notes' },
25
+ { kind: 'code', source: 'df.head()' }
26
+ ],
27
+ selected: { source: 'df.head()', isCode: true }
28
+ });
29
+ });
30
+ });
31
+
32
+ describe('snapshotFromIpynb: a closed notebook, read from its file', () => {
33
+ it('reads code and markdown cells in order, from either form of source', () => {
34
+ // nbformat allows a cell's source as one string or as a list of lines.
35
+ const snapshot = snapshotFromIpynb({
36
+ cells: [
37
+ { cell_type: 'code', source: ['x = 1\n', 'y = 2'] },
38
+ { cell_type: 'raw', source: 'not context' },
39
+ { cell_type: 'markdown', source: '## Notes' }
40
+ ]
41
+ });
42
+
43
+ expect(snapshot).toEqual({
44
+ cells: [
45
+ { kind: 'code', source: 'x = 1\ny = 2' },
46
+ { kind: 'markdown', source: '## Notes' }
47
+ ],
48
+ selected: null
49
+ });
50
+ });
51
+
52
+ it('reads anything unexpected as no cells rather than failing', () => {
53
+ // Context is best-effort: a file that is not quite a notebook must not
54
+ // cost the user the answer.
55
+ for (const content of [null, 'text', {}, { cells: 'no' }]) {
56
+ expect(snapshotFromIpynb(content)).toEqual({ cells: [], selected: null });
57
+ }
58
+ expect(
59
+ snapshotFromIpynb({ cells: [null, { cell_type: 'code' }] }).cells
60
+ ).toEqual([{ kind: 'code', source: '' }]);
61
+ });
62
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
3
+ */
4
+
5
+ describe('@oceanum/oceanumlab', () => {
6
+ it('should be tested', () => {
7
+ expect(1 + 1).toEqual(2);
8
+ });
9
+ });