@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,144 @@
1
+ import type { Notebook } from '@jupyterlab/notebook';
2
+
3
+ /**
4
+ * A notebook cell reduced to what the chat context needs.
5
+ */
6
+ export interface IContextCell {
7
+ kind: 'code' | 'markdown';
8
+ source: string;
9
+ }
10
+
11
+ /** What a request carries from the conversation's notebook. */
12
+ export interface INotebookSnapshot {
13
+ cells: IContextCell[];
14
+ /** The selected cell, which a code answer may replace. None when closed. */
15
+ selected: { source: string; isCode: boolean } | null;
16
+ }
17
+
18
+ // Mirrors oceanum-ai's app/config.py. Past either limit the server rejects the
19
+ // whole request, so the chat fails outright rather than losing some context.
20
+ export const MAX_NOTEBOOK_CELLS = 500;
21
+ export const MAX_CELL_CHARS = 100000;
22
+
23
+ // The server also caps the WHOLE request body at 2 MiB (MAX_REQUEST_BYTES),
24
+ // and that body carries chat history and, on follow-up rounds, run outputs as
25
+ // well as the cells. Markdown may take the cells up to half of it.
26
+ export const MAX_CELLS_BYTES = 1024 * 1024;
27
+
28
+ // An inline image: the one thing in markdown that is routinely huge. Any media
29
+ // type, any `;param` or `;param=value` (charset, base64, utf8), then the data
30
+ // up to the first space, bracket or quote.
31
+ const DATA_URI =
32
+ /data:[\w.+-]+\/[\w.+-]+(?:;[\w.+-]+(?:=[\w.+-]+)?)*,[^\s)"']+/g;
33
+
34
+ /** An open notebook's code and markdown cells, and its selected cell. */
35
+ export function snapshotOf(notebook: Notebook): INotebookSnapshot {
36
+ const cells: IContextCell[] = [];
37
+ for (const cell of notebook.widgets) {
38
+ const type = cell.model.type;
39
+ if (type === 'code' || type === 'markdown') {
40
+ cells.push({ kind: type, source: cell.model.sharedModel.getSource() });
41
+ }
42
+ }
43
+ const active = notebook.activeCell;
44
+ return {
45
+ cells,
46
+ selected: active
47
+ ? {
48
+ source: active.model.sharedModel.getSource(),
49
+ isCode: active.model.type === 'code'
50
+ }
51
+ : null
52
+ };
53
+ }
54
+
55
+ /**
56
+ * A closed notebook's cells, read from its file.
57
+ *
58
+ * Read rather than opened again: re-opening the tab is for an answer with
59
+ * cells to place, not for asking a question. A closed notebook has no
60
+ * selected cell. Anything unexpected in the file reads as no cells rather
61
+ * than an error -- context is best-effort.
62
+ */
63
+ export function snapshotFromIpynb(content: unknown): INotebookSnapshot {
64
+ const raw = (content as { cells?: unknown } | null)?.cells;
65
+ const cells: IContextCell[] = [];
66
+ if (Array.isArray(raw)) {
67
+ for (const cell of raw) {
68
+ const { cell_type: type, source } = (cell ?? {}) as {
69
+ cell_type?: unknown;
70
+ source?: unknown;
71
+ };
72
+ if (type !== 'code' && type !== 'markdown') {
73
+ continue;
74
+ }
75
+ // nbformat allows the source as one string or as a list of lines.
76
+ const text = Array.isArray(source)
77
+ ? source.join('')
78
+ : typeof source === 'string'
79
+ ? source
80
+ : '';
81
+ cells.push({ kind: type, source: text });
82
+ }
83
+ }
84
+ return { cells, selected: null };
85
+ }
86
+
87
+ /**
88
+ * The notebook as `notebookCells`: every non-empty code and markdown cell, in
89
+ * order.
90
+ *
91
+ * The server renders all of them inside ONE ```python block, each headed
92
+ * `# Cell N`, so markdown sent raw would read there as broken code. It goes as
93
+ * comment lines under a jupytext-style `# %% [markdown]` marker instead: still
94
+ * valid Python, and unambiguous to the model.
95
+ *
96
+ * Markdown is also what can push a request past the server's limits, and those
97
+ * reject the whole request -- costing the user the chat for the sake of some
98
+ * prose. So markdown gives way first: inline images are dropped from it, an
99
+ * oversized markdown cell is left out, and when the markdown would take the
100
+ * cells past the server's cell count or past MAX_CELLS_BYTES, only the code is
101
+ * sent -- which is what was sent before markdown was included.
102
+ */
103
+ export function formatNotebookCells(cells: readonly IContextCell[]): string[] {
104
+ const code: string[] = [];
105
+ const all: string[] = [];
106
+ for (const cell of cells) {
107
+ if (!cell.source.trim()) {
108
+ continue;
109
+ }
110
+ if (cell.kind === 'code') {
111
+ code.push(cell.source);
112
+ all.push(cell.source);
113
+ continue;
114
+ }
115
+ const markdown = asMarkdownComment(cell.source);
116
+ if (markdown.length <= MAX_CELL_CHARS) {
117
+ all.push(markdown);
118
+ }
119
+ }
120
+ if (all.length > MAX_NOTEBOOK_CELLS || totalBytes(all) > MAX_CELLS_BYTES) {
121
+ return code;
122
+ }
123
+ return all;
124
+ }
125
+
126
+ function asMarkdownComment(source: string): string {
127
+ const lines = source
128
+ .replace(DATA_URI, 'data:…')
129
+ .split(/\r?\n/)
130
+ .map(line => (line ? `# ${line}` : '#'));
131
+ return ['# %% [markdown]', ...lines].join('\n');
132
+ }
133
+
134
+ /** UTF-8 size, which is what the server's byte limit counts. */
135
+ function totalBytes(texts: readonly string[]): number {
136
+ let bytes = 0;
137
+ for (const text of texts) {
138
+ for (const char of text) {
139
+ const point = char.codePointAt(0) ?? 0;
140
+ bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4;
141
+ }
142
+ }
143
+ return bytes;
144
+ }
@@ -0,0 +1,97 @@
1
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import type { INotebookTracker } from '@jupyterlab/notebook';
3
+ import { NotebookPanel } from '@jupyterlab/notebook';
4
+ import type { INotebookHost } from './conversationPin';
5
+
6
+ // Longest wait for a notebook to open, be created, or load. The pin takes one
7
+ // step at a time, so a step that never finished would stall every chat.
8
+ export const NOTEBOOK_WAIT_MS = 15000;
9
+
10
+ /** `promise`, or null if it fails or has not settled within `ms`. */
11
+ function within<T>(promise: Promise<T>, ms: number): Promise<T | null> {
12
+ return new Promise<T | null>(resolve => {
13
+ const timer = setTimeout(() => resolve(null), ms);
14
+ promise.then(
15
+ value => {
16
+ clearTimeout(timer);
17
+ resolve(value);
18
+ },
19
+ () => {
20
+ clearTimeout(timer);
21
+ resolve(null);
22
+ }
23
+ );
24
+ });
25
+ }
26
+
27
+ /**
28
+ * The notebook a command opened, once its contents have loaded; null if the
29
+ * command opened something else, or either step failed or took over `ms`.
30
+ *
31
+ * `docmanager:open` and `notebook:create-new` hand back the panel before the
32
+ * file has been read, and reading it REPLACES every cell: an answer placed
33
+ * before then is wiped when the file arrives.
34
+ */
35
+ async function loaded(
36
+ opened: Promise<unknown>,
37
+ ms: number
38
+ ): Promise<NotebookPanel | null> {
39
+ const widget = await within(opened, ms);
40
+ if (!(widget instanceof NotebookPanel)) {
41
+ return null;
42
+ }
43
+ return within(
44
+ widget.context.ready.then(() => widget),
45
+ ms
46
+ );
47
+ }
48
+
49
+ /** JupyterLab as ConversationPin sees it. */
50
+ export function notebookHost(
51
+ app: JupyterFrontEnd,
52
+ tracker: INotebookTracker,
53
+ waitMs = NOTEBOOK_WAIT_MS
54
+ ): INotebookHost {
55
+ return {
56
+ // `app.shell` tracks only main-area tabs, so its current widget is the
57
+ // active tab even while focus is in the sidebar.
58
+ activeTab: () => {
59
+ const widget = app.shell.currentWidget;
60
+ return widget && tracker.has(widget) ? (widget as NotebookPanel) : null;
61
+ },
62
+ // A new Python 3 notebook, made the active tab. `notebook:create-new`
63
+ // returns the panel it opens; anything else is a failure. Taking the next
64
+ // notebook to appear instead could take one the user opened meanwhile.
65
+ create: async () => {
66
+ const panel = await loaded(
67
+ app.commands.execute('notebook:create-new', { kernelName: 'python3' }),
68
+ waitMs
69
+ );
70
+ if (panel) {
71
+ app.shell.activateById(panel.id);
72
+ }
73
+ return panel;
74
+ },
75
+ // Null when the file has been deleted, or has gone some other way: a new
76
+ // notebook then takes over.
77
+ reopen: async path => {
78
+ // Opened again by the user already: theirs, selection and all.
79
+ const open = tracker.find(panel => panel.context.path === path);
80
+ if (open) {
81
+ return loaded(Promise.resolve(open), waitMs);
82
+ }
83
+ const panel = await loaded(
84
+ app.commands.execute('docmanager:open', { path }),
85
+ waitMs
86
+ );
87
+ if (panel) {
88
+ // It opens with its first cell selected, which nobody chose, and
89
+ // answers go below the selected cell: above all the user's work.
90
+ // Select the last cell, so they go at the end.
91
+ panel.content.activeCellIndex = panel.content.widgets.length - 1;
92
+ }
93
+ return panel;
94
+ },
95
+ activate: panel => app.shell.activateById(panel.id)
96
+ };
97
+ }
@@ -0,0 +1,104 @@
1
+ import type * as nbformat from '@jupyterlab/nbformat';
2
+
3
+ /**
4
+ * What one code cell did when it ran, in the shape `/api/chat/observe` takes.
5
+ *
6
+ * The notebook counterpart of the sandbox's ExecResult. `message` is the
7
+ * agent's own explanation for the response that carried this code, so a
8
+ * capped final answer can say what the whole chain was doing.
9
+ */
10
+ export interface ObservedRun {
11
+ code: string;
12
+ status: 'ok' | 'error';
13
+ stdout: string;
14
+ error: string | null;
15
+ message: string;
16
+ }
17
+
18
+ /** The part of a run the kernel decides; what `harvestOutputs` reads off it. */
19
+ export type RunOutcome = Pick<ObservedRun, 'status' | 'stdout' | 'error'>;
20
+
21
+ /**
22
+ * Most text kept per field. The server reads only the tail of stdout and a
23
+ * bounded traceback (OBSERVATION_STDOUT_CHARS), and every observe request
24
+ * re-sends every run so far, so anything past this is bytes shipped for
25
+ * nothing -- a cell printing in a loop can produce megabytes.
26
+ */
27
+ export const MAX_OBSERVED_CHARS = 8000;
28
+
29
+ // CSI (colour, cursor, including private-mode `?25l` from progress bars) and
30
+ // OSC (titles, hyperlinks) sequences, per ECMA-48. An OSC body cannot contain
31
+ // ESC, so the body class excludes it: otherwise an ST-terminated OSC followed
32
+ // later by a BEL-terminated one would swallow the text between them.
33
+ const ANSI =
34
+ // eslint-disable-next-line no-control-regex
35
+ /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
36
+
37
+ /** Kernel tracebacks are coloured for a terminal; the model does not need that. */
38
+ export function stripAnsi(text: string): string {
39
+ return text.replace(ANSI, '');
40
+ }
41
+
42
+ /** Keep the end: that is where the summary line, or the raised error, is. */
43
+ function tail(text: string): string {
44
+ if (text.length <= MAX_OBSERVED_CHARS) {
45
+ return text;
46
+ }
47
+ return `…[${text.length - MAX_OBSERVED_CHARS} chars truncated]\n${text.slice(
48
+ -MAX_OBSERVED_CHARS
49
+ )}`;
50
+ }
51
+
52
+ function asText(value: nbformat.MultilineString | undefined): string {
53
+ if (value === undefined) {
54
+ return '';
55
+ }
56
+ return Array.isArray(value) ? value.join('') : value;
57
+ }
58
+
59
+ /**
60
+ * Reduce a cell's outputs to what the observation prompt needs.
61
+ *
62
+ * Stream stdout is concatenated in order. An `error` output makes the run an
63
+ * error and its traceback the error text; the kernel's stderr stream is folded
64
+ * in after it, because warnings there are often the useful part of a failure.
65
+ * Rendered outputs (execute_result, display_data) are deliberately NOT sent:
66
+ * a DataFrame repr is kilobytes of dataset-derived text, and the agent gets
67
+ * the printed summary the code chose to make instead.
68
+ */
69
+ export function harvestOutputs(
70
+ outputs: readonly nbformat.IOutput[]
71
+ ): RunOutcome {
72
+ const stdout: string[] = [];
73
+ const stderr: string[] = [];
74
+ let error: string | null = null;
75
+
76
+ for (const output of outputs) {
77
+ if (nbformatIsStream(output)) {
78
+ (output.name === 'stderr' ? stderr : stdout).push(asText(output.text));
79
+ } else if (nbformatIsError(output)) {
80
+ const traceback = stripAnsi(output.traceback.join('\n'));
81
+ error = `${output.ename}: ${output.evalue}\n${traceback}`.trim();
82
+ }
83
+ }
84
+
85
+ if (error !== null && stderr.length > 0) {
86
+ error = `${error}\n${stripAnsi(stderr.join(''))}`.trim();
87
+ }
88
+
89
+ return {
90
+ status: error === null ? 'ok' : 'error',
91
+ stdout: tail(stripAnsi(stdout.join(''))),
92
+ error: error === null ? null : tail(error)
93
+ };
94
+ }
95
+
96
+ function nbformatIsStream(
97
+ output: nbformat.IOutput
98
+ ): output is nbformat.IStream {
99
+ return output.output_type === 'stream';
100
+ }
101
+
102
+ function nbformatIsError(output: nbformat.IOutput): output is nbformat.IError {
103
+ return output.output_type === 'error';
104
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * What the agent is doing right now, from the extension host to the widget
3
+ * (OCE-175).
4
+ *
5
+ * A channel is needed because the widget does not call the router: it issues
6
+ * the `oceanum-ai:submit-prompt` COMMAND, and command arguments must be JSON,
7
+ * so a callback cannot cross. The Stop button has the same constraint and
8
+ * solves it the same way -- an AbortController held in the plugin's closure.
9
+ *
10
+ * A module singleton rather than a JupyterLab `Signal`, because a Signal wants
11
+ * an owner object and there is no natural one here: the producer is a command
12
+ * handler and the consumer is a React component that mounts and unmounts
13
+ * independently of it.
14
+ */
15
+
16
+ /** A phase the backend named, and the tool it is using if that is the phase. */
17
+ export interface Progress {
18
+ phase: string;
19
+ tool?: string;
20
+ }
21
+
22
+ type Listener = (progress: Progress | null) => void;
23
+
24
+ const listeners = new Set<Listener>();
25
+
26
+ /** Subscribe. Returns the unsubscribe, for a React effect's cleanup. */
27
+ export function onProgress(listener: Listener): () => void {
28
+ listeners.add(listener);
29
+ return () => {
30
+ listeners.delete(listener);
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Publish. `null` means "no longer working", which the widget uses to fall
36
+ * back to its own wording rather than leaving the last phase on screen after
37
+ * the answer has arrived.
38
+ *
39
+ * A listener that throws must not stop the others hearing, nor break the
40
+ * request: this is decoration, and a broken indicator is not worth failing an
41
+ * answer over.
42
+ */
43
+ export function reportProgress(progress: Progress | null): void {
44
+ for (const listener of listeners) {
45
+ try {
46
+ listener(progress);
47
+ } catch (err) {
48
+ console.error('Oceanum AI: a progress listener threw', err);
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * A reporter for one run: it publishes only while `isCurrent()` holds.
55
+ *
56
+ * A run that New chat or a newer prompt replaced is aborted, but it can take a
57
+ * while to unwind -- a cell that ignores the interrupt keeps it in its handoff
58
+ * -- and whatever it reports on the way out, a phase or its "no longer
59
+ * working", would land on the new run's progress line.
60
+ */
61
+ export function reporterFor(
62
+ isCurrent: () => boolean
63
+ ): (progress: Progress | null) => void {
64
+ return progress => {
65
+ if (isCurrent()) {
66
+ reportProgress(progress);
67
+ }
68
+ };
69
+ }
70
+
71
+ /**
72
+ * What each phase is, in the user's terms.
73
+ *
74
+ * An unlisted phase or tool falls back to something generic rather than
75
+ * showing its internal name: a new server-side tool should read as progress,
76
+ * not as a leak of what the agent is made of. And no phase at all reads as
77
+ * "Thinking…", which is what this said before there was anything better to
78
+ * say -- so a backend that does not stream, or a proxy that buffered the
79
+ * stream away, degrades to exactly the old behaviour rather than to a blank.
80
+ */
81
+ const TOOL_LABELS: Record<string, string> = {
82
+ search_catalog: 'Searching the catalogue…',
83
+ get_datasource_info: 'Reading dataset details…',
84
+ save_memory: 'Saving a note…'
85
+ };
86
+
87
+ const PHASE_LABELS: Record<string, string> = {
88
+ generating: 'Thinking…',
89
+ interpreting: 'Reading the result…',
90
+ // Reported by the client, not the server: these two are the notebook's turn.
91
+ // Everything else here names something the agent is doing, and saying so
92
+ // while a cell runs would be actively false rather than merely vague.
93
+ running: 'Running the code…',
94
+ placing: 'Adding the code to the notebook…'
95
+ };
96
+
97
+ export function describeProgress(progress: Progress | null): string {
98
+ if (!progress) {
99
+ return 'Thinking…';
100
+ }
101
+ if (progress.phase === 'tool') {
102
+ return labelFor(TOOL_LABELS, progress.tool) ?? 'Looking something up…';
103
+ }
104
+ return labelFor(PHASE_LABELS, progress.phase) ?? 'Working…';
105
+ }
106
+
107
+ /**
108
+ * The label `labels` itself gives `name`, if any. Only its own entries: the
109
+ * name comes off the stream -- a tool name is whatever the model called -- and
110
+ * `__proto__` or `toString` must not reach into Object.prototype. That hands
111
+ * React an object or a function to render, and an object breaks the panel.
112
+ */
113
+ function labelFor(
114
+ labels: Record<string, string>,
115
+ name: string | undefined
116
+ ): string | undefined {
117
+ return name !== undefined &&
118
+ Object.prototype.hasOwnProperty.call(labels, name)
119
+ ? labels[name]
120
+ : undefined;
121
+ }
package/src/sse.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Reading a Server-Sent Events body (OCE-175).
3
+ *
4
+ * Small and local rather than a dependency: SSE is a line format, the backend
5
+ * emits one `data:` line per event, and a library would bring a parser for the
6
+ * multi-line and retry-directive cases this stream does not use.
7
+ *
8
+ * Note it is NOT `EventSource`. That only issues GETs, and these are POSTs with
9
+ * a body and an auth header, which is why every client of this API hand-rolls
10
+ * the read.
11
+ */
12
+
13
+ /** One frame off the wire. `data` is raw; the caller parses it. */
14
+ export interface SseEvent {
15
+ event: string;
16
+ data: string;
17
+ id?: string;
18
+ }
19
+
20
+ /**
21
+ * Yield each event as it arrives.
22
+ *
23
+ * Decodes with `stream: true` so a multi-byte character split across two
24
+ * network chunks is reassembled rather than mangled -- a UTF-8 sequence has no
25
+ * obligation to arrive whole, and a dataset name with an accent in it is the
26
+ * kind of thing that finds this bug in production rather than in a test.
27
+ */
28
+ export async function* readSse(
29
+ body: ReadableStream<Uint8Array>,
30
+ signal?: AbortSignal
31
+ ): AsyncGenerator<SseEvent> {
32
+ const reader = body.getReader();
33
+ const decoder = new TextDecoder();
34
+ let buffer = '';
35
+
36
+ /** Every complete frame currently in the buffer. Events are separated by a
37
+ * blank line; anything after the last one is partial and stays put. */
38
+ function* drain(): Generator<SseEvent> {
39
+ let split = buffer.indexOf('\n\n');
40
+ while (split !== -1) {
41
+ const frame = buffer.slice(0, split);
42
+ buffer = buffer.slice(split + 2);
43
+ const parsed = parseFrame(frame);
44
+ if (parsed) {
45
+ yield parsed;
46
+ }
47
+ split = buffer.indexOf('\n\n');
48
+ }
49
+ }
50
+
51
+ try {
52
+ while (true) {
53
+ if (signal?.aborted) {
54
+ return;
55
+ }
56
+ const { done, value } = await reader.read();
57
+ if (done) {
58
+ // Flush the deferred `\r`, if any: no further chunk is coming, so it
59
+ // cannot be half of a CRLF and IS a line ending. Without this a stream
60
+ // using bare CR loses its final frame -- the terminator never forms.
61
+ buffer = normaliseNewlines(buffer, true);
62
+ yield* drain();
63
+ break;
64
+ }
65
+ buffer = normaliseNewlines(
66
+ buffer + decoder.decode(value, { stream: true })
67
+ );
68
+ yield* drain();
69
+ }
70
+ } finally {
71
+ // Releases the reader's lock whether the consumer finished, threw, or
72
+ // stopped early -- a `break` in a for-await runs this. It does not cancel
73
+ // the body: the backend ends the stream after `done` or `error`, and Stop's
74
+ // abort closes the connection itself.
75
+ reader.releaseLock();
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Collapse CRLF and bare CR to LF, so the rest of the parser can look for a
81
+ * single line ending.
82
+ *
83
+ * The spec allows all three, and our own backend sends LF -- but "our backend
84
+ * sends LF" is a statement about one hop. Anything between it and the browser
85
+ * may rewrite line endings, and the failure if it does is total rather than
86
+ * partial: the frame separator `\n\n` never matches `\r\n\r\n`, so ZERO events
87
+ * parse, the stream looks empty, and the caller reports the response as
88
+ * incomplete. Measured before this existed: 0 events from a well-formed CRLF
89
+ * stream.
90
+ *
91
+ * The trailing `\r` is held back, and that is the whole subtlety. A chunk may
92
+ * end mid-CRLF, with the `\n` arriving next; converting that `\r` to `\n` now
93
+ * and meeting its partner later would manufacture a blank line -- a frame
94
+ * boundary that was never sent, splitting one event into two malformed ones.
95
+ * So it waits for the next chunk, exactly as a split multi-byte character does.
96
+ */
97
+ function normaliseNewlines(buffer: string, final = false): string {
98
+ const held = !final && buffer.endsWith('\r') ? '\r' : '';
99
+ const body = held ? buffer.slice(0, -1) : buffer;
100
+ return body.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + held;
101
+ }
102
+
103
+ function parseFrame(frame: string): SseEvent | null {
104
+ let event = 'message';
105
+ let id: string | undefined;
106
+ const data: string[] = [];
107
+
108
+ for (const line of frame.split('\n')) {
109
+ // A comment. The backend sends these as keepalives during a long wait, and
110
+ // they carry nothing.
111
+ if (line.startsWith(':')) {
112
+ continue;
113
+ }
114
+ if (line.startsWith('event:')) {
115
+ event = line.slice('event:'.length).trim();
116
+ } else if (line.startsWith('data:')) {
117
+ data.push(line.slice('data:'.length).trim());
118
+ } else if (line.startsWith('id:')) {
119
+ id = line.slice('id:'.length).trim();
120
+ }
121
+ }
122
+
123
+ if (data.length === 0) {
124
+ return null;
125
+ }
126
+ return { event, data: data.join('\n'), id };
127
+ }
package/src/svg.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module '*.svg' {
2
+ const image: string;
3
+ export default image;
4
+ }
package/style/base.css ADDED
@@ -0,0 +1,5 @@
1
+ /*
2
+ See the JupyterLab Developer Guide for useful CSS Patterns:
3
+
4
+ https://jupyterlab.readthedocs.io/en/stable/developer/css.html
5
+ */
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76.5997 76.5918"><g id="Components"><g id="_x32_9414058-301b-4d05-be55-13a1e0a9aa73_x40_13"><path d="M50.9806,74.333c-3.3558,1.2907-7.8269,2.2589-12.7127,2.2589s-9.293-.9681-12.6489-2.2589c1.0973-.5806,2.2585-1.1615,3.4204-1.6779,2.9036-1.2903,5.9372-2.3877,9.2285-2.3877s6.3886,1.0973,9.2923,2.3877c1.1611.5164,2.3231,1.0973,3.4204,1.6779Z" style="fill:#aadde1;"/><path d="M38.268,0C17.1658,0,0,17.1654,0,38.3322c0,.9032.0638,1.8713.1284,2.7748.1292,2.2585.4522,4.3881,1.0335,6.5178,1.6133.7098,3.162,1.2257,4.6461,1.6133.9028.258,1.8071.3872,2.6453.3872,1.6133.1288,3.162,0,4.7753-.3872,2.453-.581,5.0337-1.6779,7.6151-2.9686,2.5814-1.2261,5.3559-2.7748,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2907,5.5497,2.8394,8.1957,4.0656,2.5807,1.2907,5.0983,2.3877,7.6144,2.9686,1.6133.3872,3.2266.516,4.8399.3872.839-.0646,1.7425-.1938,2.5814-.3872,1.4841-.3876,3.0336-.9035,4.6469-1.6133.5152-2.1297.9028-4.2593,1.0319-6.5178.0646-.9035.1292-1.8717.1292-2.7748C76.5997,17.1654,59.4339,0,38.268,0Z" style="fill:#aadde1;"/><path d="M72.8563,54.7153c-1.1611,2.5169-2.5807,4.8399-4.3232,6.9696-1.7425.1934-3.4204.1288-5.1629-.2584-2.5161-.6452-5.0337-1.6779-7.6144-2.9682-2.646-1.2907-5.3567-2.7748-8.1957-4.0656-2.9036-1.2907-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0969-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7748-8.1957,4.0656-2.5814,1.2903-5.1621,2.3231-7.6151,2.9682-1.7425.3872-3.4204.4518-5.1621.2584-1.7425-2.1297-3.2266-4.4527-4.3885-6.9696.7105.1934,1.4195.3872,2.1301.5806,2.4515.581,4.9045.581,7.4206,0,2.453-.5806,5.0337-1.6133,7.6151-2.9036,2.5814-1.2907,5.3559-2.8394,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2261,5.5497,2.7748,8.1957,4.0656,2.5807,1.2903,5.0983,2.3231,7.6144,2.9036,2.4523.581,4.9045.581,7.4214,0,.6452-.1288,1.3557-.3226,2.0647-.5806Z" style="fill:#aadde1;"/><path d="M63.1119,67.428c-1.7425,1.5491-3.678,2.904-5.7427,4.001-.5168-.258-1.0973-.516-1.6133-.7744-2.646-1.2903-5.3567-2.7748-8.1957-4.0656-2.9036-1.2903-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0973-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7752-8.1957,4.0656-.516.2584-1.0973.5164-1.6133.7744-2.0647-1.0969-4.001-2.4519-5.7435-4.001,2.3877-.5806,4.8399-1.6775,7.3568-2.8394,2.5814-1.2903,5.3559-2.8394,8.1957-4.1298,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2903,5.5497,2.8394,8.1957,4.1298,2.5161,1.1619,4.9045,2.2589,7.356,2.8394Z" style="fill:#aadde1;"/></g></g></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76.5997 76.5918"><g id="Components"><g id="_x32_9414058-301b-4d05-be55-13a1e0a9aa73_x40_1"><path d="M50.9806,74.333c-3.3558,1.2907-7.8269,2.2589-12.7127,2.2589s-9.293-.9681-12.6489-2.2589c1.0973-.5806,2.2585-1.1615,3.4204-1.6779,2.9036-1.2903,5.9372-2.3877,9.2285-2.3877s6.3886,1.0973,9.2923,2.3877c1.1611.5164,2.3231,1.0973,3.4204,1.6779Z" style="fill:#0b2d40;"/><path d="M38.268,0C17.1658,0,0,17.1654,0,38.3322c0,.9032.0638,1.8713.1284,2.7748.1292,2.2585.4522,4.3881,1.0335,6.5178,1.6133.7098,3.162,1.2257,4.6461,1.6133.9028.258,1.8071.3872,2.6453.3872,1.6133.1288,3.162,0,4.7753-.3872,2.453-.581,5.0337-1.6779,7.6151-2.9686,2.5814-1.2261,5.3559-2.7748,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2907,5.5497,2.8394,8.1957,4.0656,2.5807,1.2907,5.0983,2.3877,7.6144,2.9686,1.6133.3872,3.2266.516,4.8399.3872.839-.0646,1.7425-.1938,2.5814-.3872,1.4841-.3876,3.0336-.9035,4.6469-1.6133.5152-2.1297.9028-4.2593,1.0319-6.5178.0646-.9035.1292-1.8717.1292-2.7748C76.5997,17.1654,59.4339,0,38.268,0Z" style="fill:#0b2d40;"/><path d="M72.8563,54.7153c-1.1611,2.5169-2.5807,4.8399-4.3232,6.9696-1.7425.1934-3.4204.1288-5.1629-.2584-2.5161-.6452-5.0337-1.6779-7.6144-2.9682-2.646-1.2907-5.3567-2.7748-8.1957-4.0656-2.9036-1.2907-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0969-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7748-8.1957,4.0656-2.5814,1.2903-5.1621,2.3231-7.6151,2.9682-1.7425.3872-3.4204.4518-5.1621.2584-1.7425-2.1297-3.2266-4.4527-4.3885-6.9696.7105.1934,1.4195.3872,2.1301.5806,2.4515.581,4.9045.581,7.4206,0,2.453-.5806,5.0337-1.6133,7.6151-2.9036,2.5814-1.2907,5.3559-2.8394,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2261,5.5497,2.7748,8.1957,4.0656,2.5807,1.2903,5.0983,2.3231,7.6144,2.9036,2.4523.581,4.9045.581,7.4214,0,.6452-.1288,1.3557-.3226,2.0647-.5806Z" style="fill:#0b2d40;"/><path d="M63.1119,67.428c-1.7425,1.5491-3.678,2.904-5.7427,4.001-.5168-.258-1.0973-.516-1.6133-.7744-2.646-1.2903-5.3567-2.7748-8.1957-4.0656-2.9036-1.2903-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0973-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7752-8.1957,4.0656-.516.2584-1.0973.5164-1.6133.7744-2.0647-1.0969-4.001-2.4519-5.7435-4.001,2.3877-.5806,4.8399-1.6775,7.3568-2.8394,2.5814-1.2903,5.3559-2.8394,8.1957-4.1298,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2903,5.5497,2.8394,8.1957,4.1298,2.5161,1.1619,4.9045,2.2589,7.356,2.8394Z" style="fill:#0b2d40;"/></g></g></svg>
Binary file
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8"?><svg id="Layer_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76.5997 76.5918"><g id="Components"><g id="_x32_9414058-301b-4d05-be55-13a1e0a9aa73_x40_1"><path d="M50.9806,74.333c-3.3558,1.2907-7.8269,2.2589-12.7127,2.2589s-9.293-.9681-12.6489-2.2589c1.0973-.5806,2.2585-1.1615,3.4204-1.6779,2.9036-1.2903,5.9372-2.3877,9.2285-2.3877s6.3886,1.0973,9.2923,2.3877c1.1611.5164,2.3231,1.0973,3.4204,1.6779Z" fill="currentColor"/><path d="M38.268,0C17.1658,0,0,17.1654,0,38.3322c0,.9032.0638,1.8713.1284,2.7748.1292,2.2585.4522,4.3881,1.0335,6.5178,1.6133.7098,3.162,1.2257,4.6461,1.6133.9028.258,1.8071.3872,2.6453.3872,1.6133.1288,3.162,0,4.7753-.3872,2.453-.581,5.0337-1.6779,7.6151-2.9686,2.5814-1.2261,5.3559-2.7748,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2907,5.5497,2.8394,8.1957,4.0656,2.5807,1.2907,5.0983,2.3877,7.6144,2.9686,1.6133.3872,3.2266.516,4.8399.3872.839-.0646,1.7425-.1938,2.5814-.3872,1.4841-.3876,3.0336-.9035,4.6469-1.6133.5152-2.1297.9028-4.2593,1.0319-6.5178.0646-.9035.1292-1.8717.1292-2.7748C76.5997,17.1654,59.4339,0,38.268,0Z" fill="currentColor"/><path d="M72.8563,54.7153c-1.1611,2.5169-2.5807,4.8399-4.3232,6.9696-1.7425.1934-3.4204.1288-5.1629-.2584-2.5161-.6452-5.0337-1.6779-7.6144-2.9682-2.646-1.2907-5.3567-2.7748-8.1957-4.0656-2.9036-1.2907-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0969-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7748-8.1957,4.0656-2.5814,1.2903-5.1621,2.3231-7.6151,2.9682-1.7425.3872-3.4204.4518-5.1621.2584-1.7425-2.1297-3.2266-4.4527-4.3885-6.9696.7105.1934,1.4195.3872,2.1301.5806,2.4515.581,4.9045.581,7.4206,0,2.453-.5806,5.0337-1.6133,7.6151-2.9036,2.5814-1.2907,5.3559-2.8394,8.1957-4.0656,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2261,5.5497,2.7748,8.1957,4.0656,2.5807,1.2903,5.0983,2.3231,7.6144,2.9036,2.4523.581,4.9045.581,7.4214,0,.6452-.1288,1.3557-.3226,2.0647-.5806Z" fill="currentColor"/><path d="M63.1119,67.428c-1.7425,1.5491-3.678,2.904-5.7427,4.001-.5168-.258-1.0973-.516-1.6133-.7744-2.646-1.2903-5.3567-2.7748-8.1957-4.0656-2.9036-1.2903-6.0011-2.3877-9.2923-2.3877s-6.3248,1.0973-9.2285,2.3877c-2.8398,1.2907-5.6143,2.7752-8.1957,4.0656-.516.2584-1.0973.5164-1.6133.7744-2.0647-1.0969-4.001-2.4519-5.7435-4.001,2.3877-.5806,4.8399-1.6775,7.3568-2.8394,2.5814-1.2903,5.3559-2.8394,8.1957-4.1298,2.9036-1.2907,5.9372-2.3877,9.2285-2.3877s6.3886,1.0969,9.2923,2.3877c2.839,1.2903,5.5497,2.8394,8.1957,4.1298,2.5161,1.1619,4.9045,2.2589,7.356,2.8394Z" fill="currentColor"/></g></g></svg>