@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.
- package/LICENSE +29 -0
- package/README.md +116 -0
- package/lib/DatameshUI.d.ts +5 -0
- package/lib/DatameshUI.js +8 -0
- package/lib/DatameshUI.js.map +1 -0
- package/lib/DatameshWidget.d.ts +59 -0
- package/lib/DatameshWidget.js +573 -0
- package/lib/DatameshWidget.js.map +1 -0
- package/lib/DatasourceItem.d.ts +12 -0
- package/lib/DatasourceItem.js +47 -0
- package/lib/DatasourceItem.js.map +1 -0
- package/lib/aiLoop.d.ts +60 -0
- package/lib/aiLoop.js +85 -0
- package/lib/aiLoop.js.map +1 -0
- package/lib/chatRouter.d.ts +64 -0
- package/lib/chatRouter.js +225 -0
- package/lib/chatRouter.js.map +1 -0
- package/lib/constants.d.ts +14 -0
- package/lib/constants.js +15 -0
- package/lib/constants.js.map +1 -0
- package/lib/conversationPin.d.ts +68 -0
- package/lib/conversationPin.js +120 -0
- package/lib/conversationPin.js.map +1 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/handler.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +273 -0
- package/lib/index.js.map +1 -0
- package/lib/kernelHandoff.d.ts +61 -0
- package/lib/kernelHandoff.js +236 -0
- package/lib/kernelHandoff.js.map +1 -0
- package/lib/notebookContext.d.ts +48 -0
- package/lib/notebookContext.js +118 -0
- package/lib/notebookContext.js.map +1 -0
- package/lib/notebookHost.d.ts +6 -0
- package/lib/notebookHost.js +72 -0
- package/lib/notebookHost.js.map +1 -0
- package/lib/notebookRun.d.ts +37 -0
- package/lib/notebookRun.js +70 -0
- package/lib/notebookRun.js.map +1 -0
- package/lib/progress.d.ts +43 -0
- package/lib/progress.js +103 -0
- package/lib/progress.js.map +1 -0
- package/lib/sse.d.ts +26 -0
- package/lib/sse.js +112 -0
- package/lib/sse.js.map +1 -0
- package/package.json +151 -0
- package/schema/datamesh-connect.json +35 -0
- package/src/DatameshUI.tsx +15 -0
- package/src/DatameshWidget.tsx +909 -0
- package/src/DatasourceItem.tsx +124 -0
- package/src/__tests__/aiLoop.spec.ts +304 -0
- package/src/__tests__/chatRouter.spec.ts +315 -0
- package/src/__tests__/conversationPin.spec.ts +236 -0
- package/src/__tests__/kernelHandoff.spec.ts +106 -0
- package/src/__tests__/notebookContext.spec.ts +97 -0
- package/src/__tests__/notebookHost.spec.ts +195 -0
- package/src/__tests__/notebookRun.spec.ts +95 -0
- package/src/__tests__/notebookSnapshot.spec.ts +62 -0
- package/src/__tests__/oceanumlab.spec.ts +9 -0
- package/src/__tests__/sse.spec.ts +304 -0
- package/src/aiLoop.ts +140 -0
- package/src/chatRouter.ts +329 -0
- package/src/constants.ts +16 -0
- package/src/conversationPin.ts +148 -0
- package/src/global.d.ts +5 -0
- package/src/handler.ts +46 -0
- package/src/index.ts +327 -0
- package/src/kernelHandoff.ts +284 -0
- package/src/notebookContext.ts +144 -0
- package/src/notebookHost.ts +97 -0
- package/src/notebookRun.ts +104 -0
- package/src/progress.ts +121 -0
- package/src/sse.ts +127 -0
- package/src/svg.d.ts +4 -0
- package/style/base.css +5 -0
- package/style/icons/oceanum-dark.svg +1 -0
- package/style/icons/oceanum-light.svg +1 -0
- package/style/icons/oceanum.png +0 -0
- package/style/icons/oceanum.svg +1 -0
- package/style/index.css +477 -0
- package/style/index.js +1 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin,
|
|
4
|
+
ILayoutRestorer,
|
|
5
|
+
ILabStatus
|
|
6
|
+
} from '@jupyterlab/application';
|
|
7
|
+
import { ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
8
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
9
|
+
import { find } from '@lumino/algorithm';
|
|
10
|
+
import { Widget } from '@lumino/widgets';
|
|
11
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
12
|
+
import { IStateDB } from '@jupyterlab/statedb';
|
|
13
|
+
import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
|
14
|
+
import { DatameshConnectWidget } from './DatameshWidget';
|
|
15
|
+
import { DatameshUI } from './DatameshUI';
|
|
16
|
+
import { requestAPI } from './handler';
|
|
17
|
+
import { ChatRouter, ChatRouterError, ChatMessage } from './chatRouter';
|
|
18
|
+
import { reporterFor } from './progress';
|
|
19
|
+
import { ConversationPin } from './conversationPin';
|
|
20
|
+
import { snapshotFromIpynb, snapshotOf } from './notebookContext';
|
|
21
|
+
import { notebookHost } from './notebookHost';
|
|
22
|
+
import { KernelHandoff } from './kernelHandoff';
|
|
23
|
+
import { runChatLoop, STOPPED } from './aiLoop';
|
|
24
|
+
import { MAX_OBSERVE_ROUNDS } from './constants';
|
|
25
|
+
|
|
26
|
+
import '../style/index.css';
|
|
27
|
+
|
|
28
|
+
import oceanumSvg from '../style/icons/oceanum.svg';
|
|
29
|
+
|
|
30
|
+
// Theme-adaptive icon using currentColor (auto-adapts to light/dark themes)
|
|
31
|
+
export const oceanumIcon = new LabIcon({
|
|
32
|
+
name: 'oceanum:icon',
|
|
33
|
+
svgstr: oceanumSvg
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const PLUGIN_ID = '@oceanum/oceanumlab:datamesh-connect';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Initialization data for the extension.
|
|
40
|
+
*/
|
|
41
|
+
export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
|
|
42
|
+
id: PLUGIN_ID,
|
|
43
|
+
autoStart: true,
|
|
44
|
+
requires: [
|
|
45
|
+
ICommandPalette,
|
|
46
|
+
ILayoutRestorer,
|
|
47
|
+
ILabStatus,
|
|
48
|
+
ISettingRegistry,
|
|
49
|
+
IStateDB
|
|
50
|
+
],
|
|
51
|
+
activate: (
|
|
52
|
+
app: JupyterFrontEnd,
|
|
53
|
+
palette: ICommandPalette,
|
|
54
|
+
restorer: ILayoutRestorer,
|
|
55
|
+
status: ILabStatus,
|
|
56
|
+
settingRegistry: ISettingRegistry
|
|
57
|
+
) => {
|
|
58
|
+
console.log('Oceanum datamesh connect extension is loaded');
|
|
59
|
+
|
|
60
|
+
//Try to get the datamesh token from the settings
|
|
61
|
+
const updateSettings = (set: ISettingRegistry.ISettings) => {
|
|
62
|
+
const token = set.get('datameshToken');
|
|
63
|
+
if (token && token.user) {
|
|
64
|
+
window.datameshToken = token.user as string;
|
|
65
|
+
requestAPI('env/', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: JSON.stringify({ DATAMESH_TOKEN: token.user })
|
|
68
|
+
}).then(res => console.log(res));
|
|
69
|
+
}
|
|
70
|
+
window.injectToken = set.get('injectToken').user as boolean;
|
|
71
|
+
};
|
|
72
|
+
//Try to get the datamesh token from the envars
|
|
73
|
+
|
|
74
|
+
Promise.all([app.restored, settingRegistry.load(PLUGIN_ID)])
|
|
75
|
+
.then(([, setting]) => {
|
|
76
|
+
// Read the settings
|
|
77
|
+
updateSettings(setting);
|
|
78
|
+
|
|
79
|
+
// Listen for your plugin setting changes using Signal
|
|
80
|
+
setting.changed.connect(updateSettings);
|
|
81
|
+
})
|
|
82
|
+
.catch(reason => {
|
|
83
|
+
console.error(
|
|
84
|
+
`Something went wrong when reading the Oceanumlab settings.\n${reason}`
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const getCurrentWidget = (): Widget => {
|
|
89
|
+
return app.shell!.currentWidget!;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const openDatameshUI = (event: any): void => {
|
|
93
|
+
const widgetId = 'datamesh-ui';
|
|
94
|
+
const openWidget = find(
|
|
95
|
+
app.shell.widgets('main'),
|
|
96
|
+
(widget: Widget, index: number) => {
|
|
97
|
+
return widget.id === widgetId;
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
if (openWidget) {
|
|
101
|
+
app.shell.activateById(widgetId);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const datameshUIWidget = new DatameshUI();
|
|
106
|
+
datameshUIWidget.title.label = 'Oceanum Datamesh';
|
|
107
|
+
datameshUIWidget.id = widgetId;
|
|
108
|
+
datameshUIWidget.title.closable = true;
|
|
109
|
+
datameshUIWidget.title.icon = oceanumIcon;
|
|
110
|
+
datameshUIWidget.addClass('datamesh-ui');
|
|
111
|
+
app.shell.add(datameshUIWidget, 'main');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const datameshConnectWidget = new DatameshConnectWidget({
|
|
115
|
+
app,
|
|
116
|
+
name: 'Datamesh Connect',
|
|
117
|
+
icon: oceanumIcon,
|
|
118
|
+
openDatameshUI: openDatameshUI,
|
|
119
|
+
commands: app.commands,
|
|
120
|
+
getCurrentWidget
|
|
121
|
+
});
|
|
122
|
+
datameshConnectWidget.id = 'datamesh-connect';
|
|
123
|
+
datameshConnectWidget.title.icon = oceanumIcon;
|
|
124
|
+
datameshConnectWidget.title.caption = 'Datamesh Connect';
|
|
125
|
+
|
|
126
|
+
restorer.add(datameshConnectWidget, 'datamesh-connect');
|
|
127
|
+
|
|
128
|
+
// Rank has been chosen somewhat arbitrarily to give priority to the running
|
|
129
|
+
// sessions widget in the sidebar.
|
|
130
|
+
app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
|
|
131
|
+
|
|
132
|
+
app.commands.addCommand('datamesh-ui:open', {
|
|
133
|
+
execute: (args: any) => {
|
|
134
|
+
openDatameshUI(args);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Second plugin: Oceanum AI chat integration.
|
|
142
|
+
* Wires ChatRouter → KernelHandoff and exposes the
|
|
143
|
+
* `oceanum-ai:submit-prompt` command.
|
|
144
|
+
*/
|
|
145
|
+
export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
|
|
146
|
+
id: '@oceanum/oceanumlab:ai-chat',
|
|
147
|
+
autoStart: true,
|
|
148
|
+
requires: [INotebookTracker, ISettingRegistry],
|
|
149
|
+
activate: (
|
|
150
|
+
app: JupyterFrontEnd,
|
|
151
|
+
notebookTracker: INotebookTracker,
|
|
152
|
+
settingRegistry: ISettingRegistry
|
|
153
|
+
) => {
|
|
154
|
+
console.log('Oceanum AI chat extension is loaded');
|
|
155
|
+
|
|
156
|
+
const SETTINGS_ID = '@oceanum/oceanumlab:datamesh-connect';
|
|
157
|
+
|
|
158
|
+
settingRegistry
|
|
159
|
+
.load(SETTINGS_ID)
|
|
160
|
+
.then(settings => {
|
|
161
|
+
// The notebook the current conversation lives in: what the agent is
|
|
162
|
+
// shown, and where its answers go.
|
|
163
|
+
const pin = new ConversationPin(notebookHost(app, notebookTracker));
|
|
164
|
+
|
|
165
|
+
// What a request carries from the conversation's notebook. An open
|
|
166
|
+
// notebook is read as it stands; a closed one is read from its file
|
|
167
|
+
// rather than opened again, which is left for an answer that has
|
|
168
|
+
// cells to place.
|
|
169
|
+
// The panel the current request read its selected cell from, if the
|
|
170
|
+
// notebook was open: a code answer may replace that cell only there.
|
|
171
|
+
let readFrom: NotebookPanel | null = null;
|
|
172
|
+
const router = new ChatRouter(settings, async () => {
|
|
173
|
+
const open = await pin.current();
|
|
174
|
+
readFrom = open;
|
|
175
|
+
if (open) {
|
|
176
|
+
return snapshotOf(open.content);
|
|
177
|
+
}
|
|
178
|
+
const path = pin.path();
|
|
179
|
+
if (!path) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const file = await app.serviceManager.contents.get(path, {
|
|
184
|
+
content: true
|
|
185
|
+
});
|
|
186
|
+
return snapshotFromIpynb(file.content);
|
|
187
|
+
} catch {
|
|
188
|
+
// Gone: an answer's cells will go into a new notebook instead.
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// Asked only when an answer has blocks to place: brings the
|
|
193
|
+
// conversation's notebook to the front, opening it again if closed.
|
|
194
|
+
const handoff = new KernelHandoff(
|
|
195
|
+
() => pin.show(),
|
|
196
|
+
() => readFrom
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// The run in flight, if any. Command args must be JSON, so a signal
|
|
200
|
+
// cannot be passed in; Stop is a second command that reaches it here.
|
|
201
|
+
let current: AbortController | null = null;
|
|
202
|
+
|
|
203
|
+
app.commands.addCommand('oceanum-ai:stop', {
|
|
204
|
+
label: 'Stop the current Oceanum AI response',
|
|
205
|
+
execute: () => {
|
|
206
|
+
current?.abort();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
app.commands.addCommand('oceanum-ai:new-chat', {
|
|
211
|
+
label: 'Start a new Oceanum AI chat',
|
|
212
|
+
execute: () => {
|
|
213
|
+
// The run in flight belongs to the conversation being thrown away:
|
|
214
|
+
// stop it placing anything more. The panel discards its result.
|
|
215
|
+
// It stops being the current run now, not once it has finished
|
|
216
|
+
// unwinding, so it cannot report progress into the new chat.
|
|
217
|
+
const run = current;
|
|
218
|
+
current = null;
|
|
219
|
+
run?.abort();
|
|
220
|
+
return pin.start();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
app.commands.addCommand('oceanum-ai:chat-context', {
|
|
225
|
+
label: 'The notebook the current Oceanum AI chat is about',
|
|
226
|
+
// Starts the conversation if nothing has -- so the first message of
|
|
227
|
+
// one nobody started with New chat pins the same way -- and reports
|
|
228
|
+
// its notebook either way.
|
|
229
|
+
execute: () => pin.ensure()
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
app.commands.addCommand('oceanum-ai:submit-prompt', {
|
|
233
|
+
label: 'Submit prompt to Oceanum AI',
|
|
234
|
+
execute: async (args: Record<string, unknown>) => {
|
|
235
|
+
const prompt = args['prompt'] as string | undefined;
|
|
236
|
+
const chatHistory = (args['chatHistory'] as ChatMessage[]) ?? [];
|
|
237
|
+
if (!prompt) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// A caller that skipped 'oceanum-ai:chat-context' still gets a
|
|
241
|
+
// conversation pinned the same way.
|
|
242
|
+
await pin.ensure();
|
|
243
|
+
// Read per prompt, not once at load, so a settings change applies
|
|
244
|
+
// to the next question without a reload.
|
|
245
|
+
const autoRunCode = settings.get('autoRunCode')
|
|
246
|
+
.composite as boolean;
|
|
247
|
+
const iterate = settings.get('iterate').composite as boolean;
|
|
248
|
+
|
|
249
|
+
current?.abort();
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
current = controller;
|
|
252
|
+
// This run's progress, heard only while it is the current run.
|
|
253
|
+
const report = reporterFor(() => current === controller);
|
|
254
|
+
try {
|
|
255
|
+
// Cleared in the `finally` below however this ends, so the last
|
|
256
|
+
// phase does not sit on screen after the answer has arrived --
|
|
257
|
+
// or after Stop.
|
|
258
|
+
return await runChatLoop(
|
|
259
|
+
prompt,
|
|
260
|
+
chatHistory,
|
|
261
|
+
{
|
|
262
|
+
route: (p, h, signal) => router.route(p, h, signal, report),
|
|
263
|
+
observe: (p, h, runs, signal) => {
|
|
264
|
+
// The agent's turn again: the notebook's "Running the
|
|
265
|
+
// code…" must not stay up while the agent reads what the
|
|
266
|
+
// code printed.
|
|
267
|
+
report({ phase: 'interpreting' });
|
|
268
|
+
return router.observe(p, h, runs, signal, report);
|
|
269
|
+
},
|
|
270
|
+
place: (response, opts) => {
|
|
271
|
+
// The notebook's turn, not the agent's. Without this the
|
|
272
|
+
// last phase the AGENT reported stays on screen while a
|
|
273
|
+
// cell is running, so the user is told the agent is
|
|
274
|
+
// reading dataset details when what is actually happening
|
|
275
|
+
// is their own code executing. That is a worse claim than
|
|
276
|
+
// the "Thinking…" it replaced, which was vague rather than
|
|
277
|
+
// wrong. And "running" only when there is code that will.
|
|
278
|
+
if (response.blocks.length > 0) {
|
|
279
|
+
const runsCode =
|
|
280
|
+
autoRunCode &&
|
|
281
|
+
response.blocks.some(
|
|
282
|
+
block => block.type === 'code' && block.content.trim()
|
|
283
|
+
);
|
|
284
|
+
report({ phase: runsCode ? 'running' : 'placing' });
|
|
285
|
+
}
|
|
286
|
+
return handoff.inject(response, opts);
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
autoRunCode,
|
|
291
|
+
iterate,
|
|
292
|
+
maxRounds: MAX_OBSERVE_ROUNDS,
|
|
293
|
+
signal: controller.signal
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// Anything that failed because the user pressed Stop is not an
|
|
298
|
+
// error to them: the aborted fetch, or its body read.
|
|
299
|
+
if (controller.signal.aborted) {
|
|
300
|
+
return STOPPED;
|
|
301
|
+
}
|
|
302
|
+
if (err instanceof ChatRouterError) {
|
|
303
|
+
Notification.error(err.message, { autoClose: 5000 });
|
|
304
|
+
} else {
|
|
305
|
+
console.error('Oceanum AI: unexpected error', err);
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
// However this ended -- answered, failed, or stopped -- the
|
|
309
|
+
// agent is no longer doing anything, so the last phase must not
|
|
310
|
+
// sit on screen claiming otherwise.
|
|
311
|
+
report(null);
|
|
312
|
+
if (current === controller) {
|
|
313
|
+
current = null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
})
|
|
319
|
+
.catch(reason => {
|
|
320
|
+
console.error(
|
|
321
|
+
`Oceanum AI: could not load settings from ${SETTINGS_ID}.\n${reason}`
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export default [datamesh_connect_extension, oceanum_ai_extension];
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { NotebookActions, NotebookPanel } from '@jupyterlab/notebook';
|
|
2
|
+
import { CodeCell } from '@jupyterlab/cells';
|
|
3
|
+
import type { ISessionContext } from '@jupyterlab/apputils';
|
|
4
|
+
import type * as nbformat from '@jupyterlab/nbformat';
|
|
5
|
+
import { OceanumResponse } from './chatRouter';
|
|
6
|
+
import { harvestOutputs, ObservedRun, RunOutcome } from './notebookRun';
|
|
7
|
+
import type { PlacedResponse } from './aiLoop';
|
|
8
|
+
|
|
9
|
+
export interface InjectOptions {
|
|
10
|
+
/** If true, replace the selected code cell instead of inserting a new one */
|
|
11
|
+
replaceCodeCell?: boolean;
|
|
12
|
+
/** Run each code cell as it is placed. A user setting, not a build constant. */
|
|
13
|
+
autoRun?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Stop. Once aborted, no further block is placed or run, and a cell that is
|
|
16
|
+
* executing is interrupted -- the kernel is the half of a round that an
|
|
17
|
+
* aborted fetch cannot reach.
|
|
18
|
+
*/
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Longest wait for a starting kernel before the cell is reported as not run. */
|
|
23
|
+
const KERNEL_START_WAIT_MS = 10000;
|
|
24
|
+
|
|
25
|
+
export class KernelHandoff {
|
|
26
|
+
/**
|
|
27
|
+
* @param _target The conversation's notebook, brought to the front -- and
|
|
28
|
+
* opened again first, if it was closed. Asked only when there is
|
|
29
|
+
* something to place, so an answer with no blocks leaves the tabs alone.
|
|
30
|
+
* @param _readFrom The panel the request read its selected cell from, if
|
|
31
|
+
* any. A code answer replaces the selected cell only in that same panel:
|
|
32
|
+
* one opened since has some other cell selected, and replacing it would
|
|
33
|
+
* overwrite the user's work.
|
|
34
|
+
*/
|
|
35
|
+
constructor(
|
|
36
|
+
private _target: () => Promise<NotebookPanel | null>,
|
|
37
|
+
private _readFrom: () => NotebookPanel | null
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Place every block the response carries, in order, and return the message
|
|
42
|
+
* for the chat window.
|
|
43
|
+
*
|
|
44
|
+
* This used to switch on `response.type`, because a response was exactly one
|
|
45
|
+
* of text, code or markdown. It can now carry any number of blocks in any
|
|
46
|
+
* combination (OCE-173) — a markdown table describing a dataset followed by
|
|
47
|
+
* the query that produced it is one response, not two — so the branch became
|
|
48
|
+
* a loop. No blocks is the commonest case and still touches no notebook.
|
|
49
|
+
*/
|
|
50
|
+
async inject(
|
|
51
|
+
response: OceanumResponse,
|
|
52
|
+
options: InjectOptions = {}
|
|
53
|
+
): Promise<PlacedResponse> {
|
|
54
|
+
const runs: ObservedRun[] = [];
|
|
55
|
+
let halted = false;
|
|
56
|
+
if (response.blocks.length === 0) {
|
|
57
|
+
// Nothing to place — just the message for the chat window.
|
|
58
|
+
return { message: response.message, runs };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every answer goes into the conversation's notebook, whichever tab is in
|
|
62
|
+
// front, and that notebook is brought to the front so the user sees it.
|
|
63
|
+
const notebookPanel = await this._target();
|
|
64
|
+
if (!notebookPanel) {
|
|
65
|
+
return {
|
|
66
|
+
message: response.message + '\n\n(Could not open a notebook)',
|
|
67
|
+
runs
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const notebook = notebookPanel.content;
|
|
72
|
+
const { sessionContext } = notebookPanel;
|
|
73
|
+
|
|
74
|
+
// The user selected ONE cell, so only the first code block can replace it;
|
|
75
|
+
// everything after is inserted below in order. Captured before the loop
|
|
76
|
+
// because inserting moves `activeCell`.
|
|
77
|
+
const selected = notebook.activeCell;
|
|
78
|
+
let replaceTarget =
|
|
79
|
+
options.replaceCodeCell &&
|
|
80
|
+
notebookPanel === this._readFrom() &&
|
|
81
|
+
selected instanceof CodeCell
|
|
82
|
+
? selected
|
|
83
|
+
: null;
|
|
84
|
+
|
|
85
|
+
for (const block of response.blocks) {
|
|
86
|
+
if (options.signal?.aborted) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (block.type === 'code') {
|
|
90
|
+
let cell: CodeCell | null = null;
|
|
91
|
+
// The target can be gone by now: a markdown block placed before this
|
|
92
|
+
// one yielded while it rendered, and the user deleted the cell.
|
|
93
|
+
if (replaceTarget && !replaceTarget.isDisposed) {
|
|
94
|
+
replaceTarget.model.sharedModel.setSource(block.content);
|
|
95
|
+
cell = replaceTarget;
|
|
96
|
+
replaceTarget = null;
|
|
97
|
+
} else {
|
|
98
|
+
NotebookActions.insertBelow(notebook);
|
|
99
|
+
// `insertBelow` makes whatever the user's default cell type is;
|
|
100
|
+
// code goes in a code cell regardless.
|
|
101
|
+
if (
|
|
102
|
+
notebook.activeCell &&
|
|
103
|
+
!(notebook.activeCell instanceof CodeCell)
|
|
104
|
+
) {
|
|
105
|
+
NotebookActions.changeCellType(notebook, 'code');
|
|
106
|
+
}
|
|
107
|
+
const newCell = notebook.activeCell;
|
|
108
|
+
if (!newCell) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
newCell.model.sharedModel.setSource(block.content);
|
|
112
|
+
cell = newCell instanceof CodeCell ? newCell : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Run THIS cell, not the selection: the cell just written need not be
|
|
116
|
+
// the active one (a markdown block placed before it moved the
|
|
117
|
+
// selection), and the user may have several cells selected. Reading
|
|
118
|
+
// the outputs afterwards is what makes the iterate workflow possible
|
|
119
|
+
// -- it is the only place the kernel's answer can be seen. A blank
|
|
120
|
+
// block is placed but not run: `CodeCell.execute` would send nothing
|
|
121
|
+
// and the empty result would read as a kernel failure.
|
|
122
|
+
if (options.autoRun && cell && block.content.trim()) {
|
|
123
|
+
const { outcome, executed } = await this._run(
|
|
124
|
+
notebookPanel,
|
|
125
|
+
cell,
|
|
126
|
+
options.signal
|
|
127
|
+
);
|
|
128
|
+
runs.push({
|
|
129
|
+
code: block.content,
|
|
130
|
+
message: response.message,
|
|
131
|
+
...outcome
|
|
132
|
+
});
|
|
133
|
+
if (!executed) {
|
|
134
|
+
halted = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
NotebookActions.insertBelow(notebook);
|
|
139
|
+
if (!notebook.activeCell) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Convert to markdown cell and populate
|
|
144
|
+
NotebookActions.changeCellType(notebook, 'markdown');
|
|
145
|
+
const mdCell = notebook.activeCell;
|
|
146
|
+
if (mdCell) {
|
|
147
|
+
mdCell.model.sharedModel.setSource(block.content);
|
|
148
|
+
// Render the markdown cell
|
|
149
|
+
await NotebookActions.run(notebook, sessionContext);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { message: response.message, runs, halted };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Execute one cell and report what happened, and whether it happened at all.
|
|
159
|
+
*
|
|
160
|
+
* `runCells` resolves false for a kernel error (the traceback is in the
|
|
161
|
+
* outputs) and for some of the ways a cell can fail to execute at all
|
|
162
|
+
* (pending input, interrupted); it resolves TRUE with nothing run when there
|
|
163
|
+
* is no kernel, when the kernel is terminating, or when the kernel is still
|
|
164
|
+
* starting (`CodeCell.execute` bails silently on a session without a
|
|
165
|
+
* kernel); and it rejects when the kernel is dead. None of the did-not-run
|
|
166
|
+
* paths touch the outputs, so a replaced cell would still show its previous
|
|
167
|
+
* run; they are cleared first so that whatever is there afterwards came from
|
|
168
|
+
* this run. The execution count is cleared with them and only a kernel
|
|
169
|
+
* reply sets it again, so a null count afterwards means nothing ran,
|
|
170
|
+
* whatever `runCells` resolved to; that is reported as an error rather than
|
|
171
|
+
* as a clean, silent run.
|
|
172
|
+
*
|
|
173
|
+
* Pending input is refused BEFORE clearing: clearing the outputs of a cell
|
|
174
|
+
* that is waiting on `input()` disposes the prompt widget, and the kernel
|
|
175
|
+
* then waits forever for an answer nobody can give.
|
|
176
|
+
*/
|
|
177
|
+
private async _run(
|
|
178
|
+
notebookPanel: NotebookPanel,
|
|
179
|
+
cell: CodeCell,
|
|
180
|
+
signal?: AbortSignal
|
|
181
|
+
): Promise<{ outcome: RunOutcome; executed: boolean }> {
|
|
182
|
+
const { content: notebook, sessionContext } = notebookPanel;
|
|
183
|
+
if (sessionContext.kernelDisplayStatus === 'initializing') {
|
|
184
|
+
// A kernel is starting (for a notebook this call just created, say).
|
|
185
|
+
// Bounded: `ready` never resolves if the kernel ends up needing to be
|
|
186
|
+
// selected by hand.
|
|
187
|
+
await waitForReady(sessionContext, signal);
|
|
188
|
+
if (signal?.aborted) {
|
|
189
|
+
return didNotRun('stopped before the kernel was ready');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Closing the notebook, or deleting the cell, disposes it and nulls its
|
|
193
|
+
// model; every access below would throw and take the whole reply with it.
|
|
194
|
+
if (cell.isDisposed) {
|
|
195
|
+
return didNotRun('the cell was deleted or its notebook closed');
|
|
196
|
+
}
|
|
197
|
+
if (sessionContext.pendingInput) {
|
|
198
|
+
return didNotRun(
|
|
199
|
+
'the kernel is waiting for input that must be answered first'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const interrupt = () => {
|
|
203
|
+
void sessionContext.session?.kernel?.interrupt().catch(() => {
|
|
204
|
+
// Already dead or gone; there is nothing left to interrupt.
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
cell.model.sharedModel.transact(() => {
|
|
208
|
+
cell.model.clearExecution();
|
|
209
|
+
}, false);
|
|
210
|
+
signal?.addEventListener('abort', interrupt, { once: true });
|
|
211
|
+
let ran: boolean;
|
|
212
|
+
let failure: string | null = null;
|
|
213
|
+
try {
|
|
214
|
+
ran = await NotebookActions.runCells(notebook, [cell], sessionContext);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// A dead kernel rejects rather than resolving false. Report it like any
|
|
217
|
+
// other did-not-run so the rounds already placed keep their messages,
|
|
218
|
+
// and log it: a rejection can also be a fault inside JupyterLab's
|
|
219
|
+
// executor, and the chat line alone would hide that.
|
|
220
|
+
console.error('Oceanum AI: cell execution failed', err);
|
|
221
|
+
ran = false;
|
|
222
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
223
|
+
} finally {
|
|
224
|
+
signal?.removeEventListener('abort', interrupt);
|
|
225
|
+
}
|
|
226
|
+
if (cell.isDisposed) {
|
|
227
|
+
return didNotRun('the cell was deleted or its notebook closed');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Only stream and error outputs are wanted; serialising a rendered plot
|
|
231
|
+
// or DataFrame just to discard it is not.
|
|
232
|
+
const outputs: nbformat.IOutput[] = [];
|
|
233
|
+
const model = cell.model.outputs;
|
|
234
|
+
for (let i = 0; i < model.length; i++) {
|
|
235
|
+
const item = model.get(i);
|
|
236
|
+
if (item.type === 'stream' || item.type === 'error') {
|
|
237
|
+
outputs.push(item.toJSON());
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const outcome = harvestOutputs(outputs);
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
(!ran || cell.model.executionCount === null) &&
|
|
244
|
+
outcome.status === 'ok'
|
|
245
|
+
) {
|
|
246
|
+
return didNotRun(
|
|
247
|
+
failure ?? 'no kernel available yet, or the execution was interrupted'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return { outcome, executed: true };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Resolve when the session context is ready, when Stop is pressed, or after
|
|
256
|
+
* `KERNEL_START_WAIT_MS`, whichever comes first. Stop is raced too so the
|
|
257
|
+
* button answers at once rather than after the kernel, or the timeout.
|
|
258
|
+
*/
|
|
259
|
+
function waitForReady(
|
|
260
|
+
sessionContext: ISessionContext,
|
|
261
|
+
signal?: AbortSignal
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
return new Promise<void>(resolve => {
|
|
264
|
+
const done = () => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
signal?.removeEventListener('abort', done);
|
|
267
|
+
resolve();
|
|
268
|
+
};
|
|
269
|
+
const timer = setTimeout(done, KERNEL_START_WAIT_MS);
|
|
270
|
+
void sessionContext.ready.then(done);
|
|
271
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function didNotRun(reason: string): { outcome: RunOutcome; executed: false } {
|
|
276
|
+
return {
|
|
277
|
+
outcome: {
|
|
278
|
+
status: 'error',
|
|
279
|
+
stdout: '',
|
|
280
|
+
error: `The cell did not execute (${reason}).`
|
|
281
|
+
},
|
|
282
|
+
executed: false
|
|
283
|
+
};
|
|
284
|
+
}
|