@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
|
@@ -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
|
+
}
|