@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,304 @@
1
+ import { readSse } from '../sse';
2
+ import {
3
+ describeProgress,
4
+ onProgress,
5
+ reporterFor,
6
+ type Progress
7
+ } from '../progress';
8
+
9
+ /**
10
+ * A stand-in for the browser's ReadableStream, because jsdom has none.
11
+ *
12
+ * `readSse` uses exactly three things from a body -- `getReader()`, then
13
+ * `read()` and `releaseLock()` on the reader -- so this supplies those and
14
+ * nothing else. Building it here rather than reaching for a polyfill also
15
+ * gives the tests what they actually need: control of where one chunk ends and
16
+ * the next begins, which is the thing the network decides and the parser has
17
+ * to survive.
18
+ */
19
+ function chunkedStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
20
+ let index = 0;
21
+ return {
22
+ getReader: () => ({
23
+ read: async (): Promise<{ done: boolean; value?: Uint8Array }> =>
24
+ index < chunks.length
25
+ ? { done: false, value: chunks[index++] }
26
+ : { done: true, value: undefined },
27
+ releaseLock: (): void => undefined
28
+ })
29
+ } as unknown as ReadableStream<Uint8Array>;
30
+ }
31
+
32
+ function streamOf(...chunks: string[]): ReadableStream<Uint8Array> {
33
+ const encoder = new TextEncoder();
34
+ return chunkedStream(chunks.map(chunk => encoder.encode(chunk)));
35
+ }
36
+
37
+ async function collect(
38
+ stream: ReadableStream<Uint8Array>,
39
+ signal?: AbortSignal
40
+ ): Promise<Array<{ event: string; data: string }>> {
41
+ const out: Array<{ event: string; data: string }> = [];
42
+ for await (const event of readSse(stream, signal)) {
43
+ out.push({ event: event.event, data: event.data });
44
+ }
45
+ return out;
46
+ }
47
+
48
+ describe('readSse', () => {
49
+ it('reads one event per frame', async () => {
50
+ const events = await collect(
51
+ streamOf(
52
+ 'event: status\ndata: {"phase":"generating"}\n\n',
53
+ 'event: done\ndata: {"message":"hi"}\n\n'
54
+ )
55
+ );
56
+
57
+ expect(events).toEqual([
58
+ { event: 'status', data: '{"phase":"generating"}' },
59
+ { event: 'done', data: '{"message":"hi"}' }
60
+ ]);
61
+ });
62
+
63
+ it('reassembles a frame split across chunks', async () => {
64
+ // The network splits wherever it likes; a frame is not a packet.
65
+ const events = await collect(
66
+ streamOf('event: sta', 'tus\ndata: {"pha', 'se":"generating"}\n\n')
67
+ );
68
+
69
+ expect(events).toEqual([
70
+ { event: 'status', data: '{"phase":"generating"}' }
71
+ ]);
72
+ });
73
+
74
+ it('reassembles a multi-byte character split across chunks', async () => {
75
+ // A UTF-8 sequence has no obligation to arrive whole. Decoding each chunk
76
+ // independently mangles it, and a dataset name with an accent is what
77
+ // finds that in production rather than in a test.
78
+ const payload = '{"tool":"café"}';
79
+ const bytes = new TextEncoder().encode(
80
+ `event: status\ndata: ${payload}\n\n`
81
+ );
82
+ const split = bytes.indexOf(0xc3); // the first byte of "é"
83
+
84
+ const events = await collect(
85
+ chunkedStream([bytes.slice(0, split + 1), bytes.slice(split + 1)])
86
+ );
87
+
88
+ expect(events).toEqual([{ event: 'status', data: payload }]);
89
+ });
90
+
91
+ it('ignores keepalive comments', async () => {
92
+ // The backend sends these during a long wait. They carry nothing, and a
93
+ // client that treated one as an event would show a blank status.
94
+ const events = await collect(
95
+ streamOf(': keepalive\n\n', 'event: done\ndata: {"message":"hi"}\n\n')
96
+ );
97
+
98
+ expect(events).toEqual([{ event: 'done', data: '{"message":"hi"}' }]);
99
+ });
100
+
101
+ it('drops a frame with no data rather than yielding an empty one', async () => {
102
+ const events = await collect(
103
+ streamOf('event: status\n\n', 'event: done\ndata: {}\n\n')
104
+ );
105
+
106
+ expect(events).toEqual([{ event: 'done', data: '{}' }]);
107
+ });
108
+
109
+ it('stops when the signal is aborted', async () => {
110
+ // Stop must end the read, not merely stop the caller looking at it.
111
+ const controller = new AbortController();
112
+ controller.abort();
113
+
114
+ const events = await collect(
115
+ streamOf('event: done\ndata: {"message":"hi"}\n\n'),
116
+ controller.signal
117
+ );
118
+
119
+ expect(events).toEqual([]);
120
+ });
121
+ });
122
+
123
+ describe('describeProgress', () => {
124
+ it('names the lookup the agent stopped to do', () => {
125
+ expect(describeProgress({ phase: 'tool', tool: 'search_catalog' })).toBe(
126
+ 'Searching the catalogue…'
127
+ );
128
+ });
129
+
130
+ it('falls back to Thinking when there is no progress yet', () => {
131
+ // A backend that does not stream, or a proxy that buffered the stream
132
+ // away, degrades to exactly what this said before.
133
+ expect(describeProgress(null)).toBe('Thinking…');
134
+ });
135
+
136
+ it('does not leak the internal name of a tool it has not been taught', () => {
137
+ expect(
138
+ describeProgress({ phase: 'tool', tool: 'some_new_internal_tool' })
139
+ ).toBe('Looking something up…');
140
+ });
141
+
142
+ it('reads a new phase as progress rather than as a blank', () => {
143
+ expect(describeProgress({ phase: 'something_new' })).toBe('Working…');
144
+ });
145
+
146
+ it('gives a name that is also a built-in property the generic label', () => {
147
+ // Names come off the stream. `__proto__` must not come back as
148
+ // Object.prototype -- React cannot render an object -- nor `toString` as a
149
+ // function.
150
+ for (const name of [
151
+ '__proto__',
152
+ 'toString',
153
+ 'constructor',
154
+ 'hasOwnProperty'
155
+ ]) {
156
+ expect(describeProgress({ phase: 'tool', tool: name })).toBe(
157
+ 'Looking something up…'
158
+ );
159
+ expect(describeProgress({ phase: name })).toBe('Working…');
160
+ }
161
+ });
162
+ });
163
+
164
+ describe('phases the client reports about itself', () => {
165
+ // Everything else names something the AGENT is doing. These two are the
166
+ // notebook's turn, and they exist because the last agent phase used to stay
167
+ // on screen while a cell ran -- telling the user the agent was reading
168
+ // dataset details when their own code was executing. That is a worse claim
169
+ // than the vague "Thinking…" it replaced.
170
+
171
+ it('says the code is running', () => {
172
+ expect(describeProgress({ phase: 'running' })).toBe('Running the code…');
173
+ });
174
+
175
+ it('says the code is being placed when auto-run is off', () => {
176
+ expect(describeProgress({ phase: 'placing' })).toBe(
177
+ 'Adding the code to the notebook…'
178
+ );
179
+ });
180
+
181
+ it('never claims the agent is working while the notebook is', () => {
182
+ // The specific regression: a phase the client reports must not fall
183
+ // through to wording about the agent.
184
+ for (const phase of ['running', 'placing']) {
185
+ expect(describeProgress({ phase })).not.toBe('Thinking…');
186
+ expect(describeProgress({ phase })).not.toBe('Working…');
187
+ }
188
+ });
189
+ });
190
+
191
+ describe('line endings other than LF', () => {
192
+ // The spec allows CRLF, bare CR and LF. Our backend sends LF, but that is a
193
+ // statement about one hop -- anything between it and the browser may rewrite
194
+ // them, and the failure if it does is TOTAL rather than partial: the frame
195
+ // separator never matches, zero events parse, and the caller reports the
196
+ // response as incomplete. Measured before this was handled: 0 events from a
197
+ // well-formed CRLF stream.
198
+
199
+ it('reads a CRLF stream', () => {
200
+ return expect(
201
+ collect(
202
+ streamOf(
203
+ 'event: status\r\ndata: {"phase":"generating"}\r\n\r\n',
204
+ 'event: done\r\ndata: {"message":"hi"}\r\n\r\n'
205
+ )
206
+ )
207
+ ).resolves.toEqual([
208
+ { event: 'status', data: '{"phase":"generating"}' },
209
+ { event: 'done', data: '{"message":"hi"}' }
210
+ ]);
211
+ });
212
+
213
+ it('reads a bare-CR stream', async () => {
214
+ const events = await collect(
215
+ streamOf('event: done\rdata: {"message":"hi"}\r\r')
216
+ );
217
+
218
+ expect(events).toEqual([{ event: 'done', data: '{"message":"hi"}' }]);
219
+ });
220
+
221
+ it('does not invent a frame when a CRLF is split across chunks', async () => {
222
+ // The subtlety the fix exists for. A chunk ending in `\r` whose `\n`
223
+ // arrives next must not be converted early: doing so manufactures a blank
224
+ // line, and a blank line is a frame boundary, so one event becomes two
225
+ // malformed ones.
226
+ const events = await collect(
227
+ streamOf('event: done\r\ndata: {"message":"hi"}\r', '\n\r\n')
228
+ );
229
+
230
+ expect(events).toEqual([{ event: 'done', data: '{"message":"hi"}' }]);
231
+ });
232
+
233
+ it('leaves no carriage return in the payload', async () => {
234
+ // A stray `\r` on the end of the data would break JSON.parse for the
235
+ // caller, which is the same silent failure one layer along.
236
+ const events = await collect(
237
+ streamOf('event: done\r\ndata: {"message":"hi"}\r\n\r\n')
238
+ );
239
+
240
+ expect(events[0].data).not.toContain('\r');
241
+ expect(() => JSON.parse(events[0].data)).not.toThrow();
242
+ });
243
+ });
244
+
245
+ describe('Stop while a read is in flight', () => {
246
+ it('ends the read with the abort, having yielded what arrived first', async () => {
247
+ // The already-aborted case above is the easy one. The real Stop lands while
248
+ // a read is pending, and fetch fails that read.
249
+ const controller = new AbortController();
250
+ let reads = 0;
251
+ const body = {
252
+ getReader: () => ({
253
+ read: (): Promise<{ done: boolean; value?: Uint8Array }> => {
254
+ reads += 1;
255
+ if (reads === 1) {
256
+ return Promise.resolve({
257
+ done: false,
258
+ value: new TextEncoder().encode(
259
+ 'event: status\ndata: {"phase":"generating"}\n\n'
260
+ )
261
+ });
262
+ }
263
+ return new Promise((_ok, fail) => {
264
+ controller.signal.addEventListener('abort', () =>
265
+ fail(new DOMException('Aborted', 'AbortError'))
266
+ );
267
+ });
268
+ },
269
+ releaseLock: (): void => undefined
270
+ })
271
+ } as unknown as ReadableStream<Uint8Array>;
272
+
273
+ const seen: string[] = [];
274
+ const reading = (async () => {
275
+ for await (const event of readSse(body, controller.signal)) {
276
+ seen.push(event.event);
277
+ }
278
+ })();
279
+ await new Promise(resolve => setTimeout(resolve, 0));
280
+ controller.abort();
281
+
282
+ await expect(reading).rejects.toMatchObject({ name: 'AbortError' });
283
+ expect(seen).toEqual(['status']);
284
+ });
285
+ });
286
+
287
+ describe('reporterFor', () => {
288
+ it('speaks only while its run is the current one', () => {
289
+ // A replaced run can take a while to unwind; nothing it reports on the way
290
+ // out may land on the new run's progress line -- not even its "done".
291
+ let current = true;
292
+ const heard: Array<Progress | null> = [];
293
+ const unsubscribe = onProgress(progress => heard.push(progress));
294
+ const report = reporterFor(() => current);
295
+
296
+ report({ phase: 'generating' });
297
+ current = false;
298
+ report({ phase: 'running' });
299
+ report(null);
300
+ unsubscribe();
301
+
302
+ expect(heard).toEqual([{ phase: 'generating' }]);
303
+ });
304
+ });
package/src/aiLoop.ts ADDED
@@ -0,0 +1,140 @@
1
+ import type { ChatMessage, OceanumResponse } from './chatRouter';
2
+ import type { ObservedRun } from './notebookRun';
3
+
4
+ /** What `inject` reports back: the chat text, and what ran if anything did. */
5
+ export interface PlacedResponse {
6
+ message: string;
7
+ runs: ObservedRun[];
8
+ /**
9
+ * A code cell could not be executed at all (no kernel, pending input, the
10
+ * notebook closed, Stop). Its run says so; observing it would only bring
11
+ * back more code that cannot run either.
12
+ */
13
+ halted?: boolean;
14
+ }
15
+
16
+ /** The pieces the loop needs, injected so it can be tested without a notebook. */
17
+ export interface LoopDeps {
18
+ route(
19
+ prompt: string,
20
+ history: ChatMessage[],
21
+ signal: AbortSignal
22
+ ): Promise<{
23
+ response: OceanumResponse;
24
+ hasCodeCellSelected: boolean;
25
+ }>;
26
+ observe(
27
+ prompt: string,
28
+ history: ChatMessage[],
29
+ runs: ObservedRun[],
30
+ signal: AbortSignal
31
+ ): Promise<OceanumResponse>;
32
+ place(
33
+ response: OceanumResponse,
34
+ options: { replaceCodeCell: boolean; autoRun: boolean; signal: AbortSignal }
35
+ ): Promise<PlacedResponse>;
36
+ }
37
+
38
+ export interface LoopOptions {
39
+ /** Run code cells as they are placed. Workflow 1; required for 2. */
40
+ autoRunCode: boolean;
41
+ /** Send each run's output back and place what comes next. Workflow 2. */
42
+ iterate: boolean;
43
+ /** Safety net on observe requests; see `runChatLoop`. */
44
+ maxRounds: number;
45
+ signal: AbortSignal;
46
+ }
47
+
48
+ export const STOPPED = 'Stopped.';
49
+
50
+ /**
51
+ * One prompt, start to finish, under the two switches.
52
+ *
53
+ * The loop lives here rather than on the server because the CLIENT owns the
54
+ * kernel: only it can run a cell and read what came back. The server sees one
55
+ * turn at a time through `/api/chat/observe`.
56
+ *
57
+ * autoRunCode off -> place blocks, stop. (workflow 3)
58
+ * autoRunCode on, iterate off -> place, run, stop. (workflow 1)
59
+ * autoRunCode on, iterate on -> place, run, observe, repeat. (workflow 2)
60
+ *
61
+ * `iterate` without `autoRunCode` is inert: nothing ran, so there is nothing
62
+ * to observe. That falls out of the data (no runs) rather than a guard.
63
+ *
64
+ * Rounds end when nothing ran (the agent answered without code, so it is
65
+ * done), or on Stop. The server enforces its own EXECUTE_MAX_ROUNDS: at the
66
+ * cap it still answers, explaining the last run with any code stripped, and
67
+ * that code-free answer ends the loop here. `maxRounds` is therefore only a
68
+ * safety net against a server that keeps sending code, checked AFTER the
69
+ * observe so the server's explanation turn is always requested. Every round's
70
+ * message is kept: a chain that took three steps should read as three steps,
71
+ * not as the last one.
72
+ */
73
+ export async function runChatLoop(
74
+ prompt: string,
75
+ history: ChatMessage[],
76
+ deps: LoopDeps,
77
+ options: LoopOptions
78
+ ): Promise<string> {
79
+ const { signal } = options;
80
+ const messages: string[] = [];
81
+ const runs: ObservedRun[] = [];
82
+
83
+ let response: OceanumResponse;
84
+ let replaceCodeCell: boolean;
85
+ try {
86
+ const first = await deps.route(prompt, history, signal);
87
+ response = first.response;
88
+ replaceCodeCell = first.hasCodeCellSelected;
89
+ } catch (err) {
90
+ if (signal.aborted) {
91
+ return STOPPED;
92
+ }
93
+ throw err;
94
+ }
95
+ if (signal.aborted) {
96
+ return STOPPED;
97
+ }
98
+
99
+ for (let round = 1; ; round++) {
100
+ const placed = await deps.place(response, {
101
+ replaceCodeCell,
102
+ autoRun: options.autoRunCode,
103
+ signal
104
+ });
105
+ messages.push(placed.message);
106
+ runs.push(...placed.runs);
107
+ replaceCodeCell = false;
108
+
109
+ if (signal.aborted) {
110
+ messages.push(STOPPED);
111
+ break;
112
+ }
113
+ if (!options.iterate || placed.runs.length === 0 || placed.halted) {
114
+ // Nothing ran this round, or nothing could, so there is nothing to
115
+ // observe.
116
+ break;
117
+ }
118
+ if (round > options.maxRounds) {
119
+ break;
120
+ }
121
+
122
+ try {
123
+ response = await deps.observe(prompt, history, runs, signal);
124
+ } catch (err) {
125
+ // Stop pressed while the request was in flight. What was already placed
126
+ // and explained stays in the chat; only the next step is dropped.
127
+ if (signal.aborted) {
128
+ messages.push(STOPPED);
129
+ break;
130
+ }
131
+ throw err;
132
+ }
133
+ if (signal.aborted) {
134
+ messages.push(STOPPED);
135
+ break;
136
+ }
137
+ }
138
+
139
+ return messages.filter(m => m.trim().length > 0).join('\n\n');
140
+ }