bunnyquery 1.9.7 → 1.9.10
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/README.md +5 -0
- package/bunnyquery.css +53 -2
- package/bunnyquery.js +1822 -177
- package/dist/engine.cjs +1746 -114
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +1271 -6
- package/dist/engine.d.ts +1271 -6
- package/dist/engine.mjs +1709 -115
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +243 -0
- package/src/engine/errors.ts +87 -1
- package/src/engine/history.ts +113 -2
- package/src/engine/host.ts +85 -0
- package/src/engine/index.ts +108 -2
- package/src/engine/project_settings.ts +303 -0
- package/src/engine/prompts/chat_system_prompt.ts +15 -3
- package/src/engine/requests.ts +130 -1
- package/src/engine/session.ts +1553 -29
- package/src/engine/sse.ts +1054 -0
- package/src/widget.css +21 -2
- package/styles/chat.css +32 -0
package/src/engine/session.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
extractOpenAIText,
|
|
30
30
|
getChatHistory,
|
|
31
31
|
POLL_INTERVAL,
|
|
32
|
+
STREAM_POLL_INTERVAL,
|
|
32
33
|
MAX_CONCURRENT_BG_POLLS,
|
|
33
34
|
bgIndexingQueueName,
|
|
34
35
|
isBgIndexingQueue,
|
|
@@ -41,12 +42,15 @@ import {
|
|
|
41
42
|
type BgTaskEntry,
|
|
42
43
|
} from './requests';
|
|
43
44
|
import { isPagedReadFile, isImageVisionFile, isWindowedReadFile } from './office';
|
|
44
|
-
import { windowedIndexingEnabled, chatEngineConfig } from './config';
|
|
45
|
-
|
|
45
|
+
import { windowedIndexingEnabled, liveStreamingEnabled, streamRecoveryEnabled, chatEngineConfig } from './config';
|
|
46
|
+
// The wire-format knowledge. skapi relays the provider's bytes without reading them,
|
|
47
|
+
// so the session hands them straight to this parser and never inspects a frame itself.
|
|
48
|
+
import { createSseParser, type SseParser } from './sse';
|
|
49
|
+
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage, isCsrStatusEnvelope } from './errors';
|
|
46
50
|
import { buildBoundedChatMessages } from './budget';
|
|
47
51
|
import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
|
|
48
52
|
import { markImagePreviewStale } from './image_preview';
|
|
49
|
-
import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
|
|
53
|
+
import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage, adoptLocalAnswerIntoPage } from './history';
|
|
50
54
|
import { wallClockNow } from './time';
|
|
51
55
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
52
56
|
import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
|
|
@@ -157,6 +161,385 @@ function isPollStopped(res: any): boolean {
|
|
|
157
161
|
return !!res && typeof res === 'object' && res.status === 'stopped';
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
|
|
165
|
+
/* ── live streaming: what is safe to show, and where to resume ──────────────
|
|
166
|
+
*
|
|
167
|
+
* A streamed answer is rendered while it is still GROWING, which is a different
|
|
168
|
+
* problem from the typewriter's. The typewriter already holds the whole string and
|
|
169
|
+
* only has to avoid stopping inside a region it knows the bounds of. Here the end
|
|
170
|
+
* of the string is not the end of the answer: a `[` may be the start of a link
|
|
171
|
+
* whose url has not arrived, and three backticks may be the start of a file fence
|
|
172
|
+
* whose whole point is that it renders as a download chip rather than as prose.
|
|
173
|
+
*
|
|
174
|
+
* So the rule is the mirror image of the typewriter's: instead of extending the
|
|
175
|
+
* reveal past an atomic region, hold it BACK to before whatever the next bytes
|
|
176
|
+
* could still turn into one. The tail is only ever a paint or two behind, and the
|
|
177
|
+
* settle re-renders the authoritative text in full anyway, so nothing is lost by
|
|
178
|
+
* being late and quite a lot is lost by being early (a chip minted from half a
|
|
179
|
+
* url, a fence opener shown as three literal backticks, a link whose href changes
|
|
180
|
+
* under the reader's pointer).
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
/** How far back from the end an unclosed '[' is still read as "a link about to
|
|
184
|
+
* complete". Beyond this it is ordinary prose that happens to contain a bracket,
|
|
185
|
+
* and treating it as a pending link would freeze the reveal for the rest of the
|
|
186
|
+
* answer: "see [the appendix" never closes, and without a window the reader would
|
|
187
|
+
* watch a bubble that stopped growing at the bracket until the turn settled. */
|
|
188
|
+
const LIVE_PENDING_LINK_WINDOW = 512;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* How many unfinalized streamed turns ONE history load reads back out of the chunk
|
|
192
|
+
* store on its own.
|
|
193
|
+
*
|
|
194
|
+
* Each read drains a whole turn's chunks, so this is a real request budget, not a
|
|
195
|
+
* render one. Two, newest first, because that is the shape the case actually has:
|
|
196
|
+
* a turn is left unfinalized by the tab going away mid-answer, and a user does that
|
|
197
|
+
* to the turn they were watching, not to twenty of them. A page holding more keeps
|
|
198
|
+
* the rest marked and picks them up on the next load (each recovery finalizes what
|
|
199
|
+
* it read, so the backlog strictly shrinks), or the host offers them on demand
|
|
200
|
+
* through recoverStreamedAnswer().
|
|
201
|
+
*/
|
|
202
|
+
const STREAM_RECOVERY_PER_LOAD = 2;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The prefix of a still-arriving answer that is safe to render as markdown.
|
|
206
|
+
*
|
|
207
|
+
* Four cuts, each taking the earliest position that could still change meaning:
|
|
208
|
+
* 1. an UNCLOSED ``` fence (odd number of markers) - everything from its opener;
|
|
209
|
+
* 2. an UNCLOSED inline link on the last line - `[label` with no `]`, or
|
|
210
|
+
* `[label](url` with no `)`, from its `[`;
|
|
211
|
+
* 3. a trailing bare url or `src::` token, from its first character, because a
|
|
212
|
+
* link is minted from whatever is there and a growing url means a chip whose
|
|
213
|
+
* href changes on every paint;
|
|
214
|
+
* 4. an unclosed inline-code span on the last line (odd backtick count).
|
|
215
|
+
*
|
|
216
|
+
* Deliberately NOT covered: emphasis markers, half-written table rows and list
|
|
217
|
+
* bullets. Those degrade to a flicker of STYLING, which self-corrects on the next
|
|
218
|
+
* paint; the four above degrade to a wrong link, a wrong chip, or prose shown where
|
|
219
|
+
* a fence was meant, none of which the reader can tell from the real thing.
|
|
220
|
+
*/
|
|
221
|
+
export function liveSafePrefix(text: string): string {
|
|
222
|
+
if (!text) return '';
|
|
223
|
+
var cut = text.length;
|
|
224
|
+
|
|
225
|
+
// 1. Fence markers come in pairs. An odd count means the last one opened a block
|
|
226
|
+
// that has not closed, and a file fence in particular is the download-chip
|
|
227
|
+
// syntax: shown half-arrived it is three backticks and a filename in prose.
|
|
228
|
+
var fenceAt = -1, fences = 0, from = 0, hit;
|
|
229
|
+
for (;;) {
|
|
230
|
+
hit = text.indexOf('```', from);
|
|
231
|
+
if (hit === -1) break;
|
|
232
|
+
fences++; fenceAt = hit; from = hit + 3;
|
|
233
|
+
}
|
|
234
|
+
if (fences % 2 === 1 && fenceAt !== -1) cut = fenceAt;
|
|
235
|
+
|
|
236
|
+
// Rules 2 to 4 look at the last line of what SURVIVED rule 1: an inline link and
|
|
237
|
+
// an inline-code span cannot span a newline (createInlineLinkRegex's label class
|
|
238
|
+
// excludes it), so the last line is the whole of what can still be growing.
|
|
239
|
+
var head = text.slice(0, cut);
|
|
240
|
+
var lineStart = head.lastIndexOf('\n') + 1;
|
|
241
|
+
var line = head.slice(lineStart);
|
|
242
|
+
|
|
243
|
+
// 2. An inline link whose url has not arrived. Windowed (see the constant): a
|
|
244
|
+
// bracket far behind the write head belongs to prose, not to a pending link.
|
|
245
|
+
var open = line.lastIndexOf('[');
|
|
246
|
+
if (open !== -1 && (line.length - open) <= LIVE_PENDING_LINK_WINDOW) {
|
|
247
|
+
var rest = line.slice(open);
|
|
248
|
+
var close = rest.indexOf(']');
|
|
249
|
+
if (close === -1) {
|
|
250
|
+
cut = lineStart + open;
|
|
251
|
+
} else if (rest.charAt(close + 1) === '(' && rest.indexOf(')', close + 1) === -1) {
|
|
252
|
+
cut = lineStart + open;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 3. A trailing bare url / src:: token. Only once it is RECOGNISABLE as one: the
|
|
257
|
+
// few characters of a half-written scheme ("https:/") render as plain text and
|
|
258
|
+
// linkify nothing, and the monotonic guard in the painter means the reveal
|
|
259
|
+
// simply stops there until the whole url has landed rather than retreating.
|
|
260
|
+
var tokStart = line.length;
|
|
261
|
+
while (tokStart > 0 && !/\s/.test(line.charAt(tokStart - 1))) tokStart--;
|
|
262
|
+
var tok = line.slice(tokStart);
|
|
263
|
+
if (tok && /^(?:https?:\/\/|src::)/i.test(tok)) {
|
|
264
|
+
var tokCut = lineStart + tokStart;
|
|
265
|
+
if (tokCut < cut) cut = tokCut;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 4. An unclosed inline-code span. Skipped on a line carrying a fence marker,
|
|
269
|
+
// where the backticks are rule 1's business: a CLOSING fence line ("```") has
|
|
270
|
+
// an odd count of its own, and cutting there would hide the very marker that
|
|
271
|
+
// closes the block and leave an open fence on screen.
|
|
272
|
+
if (line.indexOf('```') === -1) {
|
|
273
|
+
var ticks = 0, lastTick = -1;
|
|
274
|
+
for (var i = 0; i < line.length; i++) {
|
|
275
|
+
if (line.charAt(i) === '`') { ticks++; lastTick = i; }
|
|
276
|
+
}
|
|
277
|
+
if (ticks % 2 === 1 && lastTick !== -1) {
|
|
278
|
+
var tickCut = lineStart + lastTick;
|
|
279
|
+
if (tickCut < cut) cut = tickCut;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (cut >= text.length) return text;
|
|
284
|
+
if (cut < 0) cut = 0;
|
|
285
|
+
return text.slice(0, cut);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Length of the shared leading run of two strings, never splitting a surrogate
|
|
289
|
+
* pair: a resume index landing between the halves of an astral character would
|
|
290
|
+
* paint a lone surrogate, which renders as a replacement glyph. */
|
|
291
|
+
function commonPrefixLength(a: string, b: string): number {
|
|
292
|
+
var n = Math.min(a.length, b.length), i = 0;
|
|
293
|
+
while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
|
|
294
|
+
if (i > 0) {
|
|
295
|
+
var prev = a.charCodeAt(i - 1);
|
|
296
|
+
if (prev >= 0xd800 && prev <= 0xdbff) i--;
|
|
297
|
+
}
|
|
298
|
+
return i;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Where the typewriter should START revealing `fullText`, given what a live stream
|
|
303
|
+
* has already painted into the bubble.
|
|
304
|
+
*
|
|
305
|
+
* The point is that the settle must not replay an answer the reader has already
|
|
306
|
+
* watched arrive: the authoritative text REPLACES the live text (it is the only
|
|
307
|
+
* source of truth), but the characters the two agree on are already on screen and
|
|
308
|
+
* retyping them from zero is the one thing that would make streaming look worse
|
|
309
|
+
* than not streaming.
|
|
310
|
+
*
|
|
311
|
+
* `regions` are the typewriter's own atomic regions. A resume index landing inside
|
|
312
|
+
* one is pushed FORWARD to its end rather than back to its start: forward reveals
|
|
313
|
+
* the link or fence whole, which is the policy those regions exist to enforce, and
|
|
314
|
+
* backward would make the bubble shrink at the exact moment the answer settles.
|
|
315
|
+
*
|
|
316
|
+
* LEADING WHITESPACE IS NORMALISED FIRST, and that is not a nicety. The two strings
|
|
317
|
+
* come from two places that disagree about it by design: the painter writes the
|
|
318
|
+
* parser's `text` UNTRIMMED (currentText says why: trimming a render feed would
|
|
319
|
+
* remove a leading newline and then hand it back when the next delta lands), while
|
|
320
|
+
* every settle path trims, exactly as it trims a buffered answer. And the extractor
|
|
321
|
+
* joins text blocks with '\n', so a model that opens an empty text block before its
|
|
322
|
+
* first tool call, which Claude routinely does, produces a painted answer starting
|
|
323
|
+
* with a newline the authoritative one does not have. Compared raw, the two agree on
|
|
324
|
+
* NOTHING (their first characters differ), the resume index is 0, and the reader
|
|
325
|
+
* watches the entire answer they just read be retyped from zero. Which is the one
|
|
326
|
+
* thing streaming was supposed to stop happening.
|
|
327
|
+
*/
|
|
328
|
+
export function typewriterResumeIndex(
|
|
329
|
+
painted: string,
|
|
330
|
+
fullText: string,
|
|
331
|
+
regions: Array<{ start: number; end: number }>,
|
|
332
|
+
): number {
|
|
333
|
+
if (!painted || !fullText) return 0;
|
|
334
|
+
// Only when the authoritative text has none of its own: that is the case where
|
|
335
|
+
// the difference can only be the trim, so dropping it aligns the two. If
|
|
336
|
+
// fullText DOES start with whitespace (a caller that passes untrimmed text) the
|
|
337
|
+
// two are already in the same frame and cutting painted would misalign them.
|
|
338
|
+
if (/^\s/.test(painted) && !/^\s/.test(fullText)) {
|
|
339
|
+
painted = painted.replace(/^\s+/, '');
|
|
340
|
+
if (!painted) return 0;
|
|
341
|
+
}
|
|
342
|
+
var i = commonPrefixLength(painted, fullText);
|
|
343
|
+
if (i <= 0) return 0;
|
|
344
|
+
if (i >= fullText.length) return fullText.length;
|
|
345
|
+
for (var changed = true; changed;) {
|
|
346
|
+
changed = false;
|
|
347
|
+
for (var k = 0; k < regions.length; k++) {
|
|
348
|
+
var r = regions[k];
|
|
349
|
+
if (i > r.start && i < r.end) { i = r.end; changed = true; }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return i > fullText.length ? fullText.length : i;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* THE KEEP POLICY, in one place, for every path that can reach csr-finalize.
|
|
357
|
+
*
|
|
358
|
+
* WHY IT IS A FUNCTION AND NOT A LINE IN EACH CALLER. Finalizing does two things in
|
|
359
|
+
* one call: it stores what you hand it as the row's permanent answer, and it
|
|
360
|
+
* DELETES the chunks it was assembled from. Chunks are the only copy of a streamed
|
|
361
|
+
* answer until that call, and there is no way to release them without also storing
|
|
362
|
+
* something, so "may this be kept?" is the single decision that separates a
|
|
363
|
+
* recoverable turn from a permanently truncated one. It was answered in two places
|
|
364
|
+
* that then disagreed: the live settle refused to finalize a failed or cancelled
|
|
365
|
+
* turn (its partial text is the only copy there is, and both ways of releasing it
|
|
366
|
+
* cost something real), while the recovery path computed the same question from
|
|
367
|
+
* parse completeness ALONE - so recovering a failed row finalized it and released
|
|
368
|
+
* exactly the chunks the live policy exists to keep. Two halves of one fix, pulling
|
|
369
|
+
* opposite ways. One predicate, consulted by both, is the fix for that.
|
|
370
|
+
*
|
|
371
|
+
* The three terms, and what each of them is protecting:
|
|
372
|
+
*
|
|
373
|
+
* THE ROW'S OWN STATUS wins over anything the bytes say. 'failed' means the
|
|
374
|
+
* destination's account of the turn is the error, not the text that arrived
|
|
375
|
+
* before it; 'cancelled' means the user's Stop said to discard the half answer,
|
|
376
|
+
* so writing it into history as the kept version resurrects exactly what the stop
|
|
377
|
+
* was for; 'stopped' is a poll that was ended, which says nothing about the turn
|
|
378
|
+
* at all. Pass undefined when the status is genuinely not known (the caller is
|
|
379
|
+
* looking only at bytes); pass the status whenever there is one, because a caller
|
|
380
|
+
* that omits a status it HAS is asking the wrong question.
|
|
381
|
+
*
|
|
382
|
+
* `errored` covers the same refusal expressed by the bytes rather than by the
|
|
383
|
+
* row: an `error` frame, a response.failed, a terminal Response with an error
|
|
384
|
+
* payload. See sse.ts's answerComplete for why a terminal event is not the same
|
|
385
|
+
* claim as a finished answer.
|
|
386
|
+
*
|
|
387
|
+
* `answerComplete` (NOT `complete`) is the completeness half. A degraded chunk
|
|
388
|
+
* read - the poller degrades to "no chunks this tick, more=true" on any transient
|
|
389
|
+
* chunk-table error, and caps one read at 500k characters - hands a settle a
|
|
390
|
+
* stream that stopped mid-answer while the ROW settles 'resolved' on top of it,
|
|
391
|
+
* because the row's status describes the destination's request and not our read
|
|
392
|
+
* of it. Anything short of a finished answer leaves the chunks exactly where they
|
|
393
|
+
* are, which is what they are for: the turn stays re-readable through
|
|
394
|
+
* clientSecretRequestStream and a later load recovers it in full.
|
|
395
|
+
*
|
|
396
|
+
* `unframed` is the one exception to needing a terminal event, and it is not a
|
|
397
|
+
* loophole: bytes that were never SSE carry no events at all and none is ever
|
|
398
|
+
* coming, so there it IS the row's status that says the response finished - which
|
|
399
|
+
* is why this is only ever reached with a 'resolved' row or with no status to
|
|
400
|
+
* contradict it.
|
|
401
|
+
*
|
|
402
|
+
* Exported so the two clients cannot answer it a third way.
|
|
403
|
+
*/
|
|
404
|
+
export function mayKeepStreamedAnswer(snap: any, rowStatus?: string | null): boolean {
|
|
405
|
+
// An absent status is "not known", not "not resolved": a caller reading bytes
|
|
406
|
+
// alone has no row to consult. A status that IS present must be the resolved one.
|
|
407
|
+
if (rowStatus !== undefined && rowStatus !== null && rowStatus !== '' && rowStatus !== 'resolved') return false;
|
|
408
|
+
if (!snap || typeof snap !== 'object') return false;
|
|
409
|
+
if (snap.errored) return false;
|
|
410
|
+
if (snap.answerComplete) return true;
|
|
411
|
+
if (snap.unframed) return true;
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* WHAT A VIEW SHOULD DRAW FOR A TURN WHOSE ANSWER IS STILL IN THE CHUNK STORE.
|
|
417
|
+
*
|
|
418
|
+
* One predicate, on the barrel, because the alternative is each client deciding
|
|
419
|
+
* for itself when a spinner is honest - and the two clients have forked on
|
|
420
|
+
* smaller things than this. Returns:
|
|
421
|
+
*
|
|
422
|
+
* '' not this state at all. Either the bubble is not marked, or it HAS
|
|
423
|
+
* content (the merge adopted a local answer onto it, or a recovery
|
|
424
|
+
* wrote a truncated one in), in which case there is text to render
|
|
425
|
+
* and the recovery, if any, is a background correction the reader
|
|
426
|
+
* does not need to be told about.
|
|
427
|
+
* 'active' a chunk read is in flight or queued. Draw the loader: this is the
|
|
428
|
+
* only phase in which something really is coming.
|
|
429
|
+
* 'failed' the last read failed. Draw the failure and an ask-again control.
|
|
430
|
+
* 'idle' marked, and nothing is fetching it. Draw an ask-for-it control.
|
|
431
|
+
*
|
|
432
|
+
* THE FAILURE THIS EXISTS TO STOP. Recovery is capped at STREAM_RECOVERY_PER_LOAD
|
|
433
|
+
* per history load, so on a page holding several unfinalized turns the third and
|
|
434
|
+
* later ones are marked and queued for nobody; a failed read likewise leaves the
|
|
435
|
+
* marker on deliberately (it is the only thing keeping the answer reachable) with
|
|
436
|
+
* no attempt behind it. Both used to take the same branch as a live pending turn,
|
|
437
|
+
* so those bubbles spun forever with nothing driving them and no way for the
|
|
438
|
+
* reader to resolve them - while the answer sat in the chunk table the whole time,
|
|
439
|
+
* one recoverStreamedAnswer() call away.
|
|
440
|
+
*
|
|
441
|
+
* A bubble with no `_serverItemId` returns '' on purpose: there is no id to hand
|
|
442
|
+
* recoverStreamedAnswer, so an affordance would be a button that cannot work.
|
|
443
|
+
* Unreachable today (the mapper only ever marks a row it has an id for), stated so
|
|
444
|
+
* that it stays unreachable rather than becoming a dead control.
|
|
445
|
+
*/
|
|
446
|
+
export function streamRecoveryPhase(msg: any): '' | 'active' | 'failed' | 'idle' {
|
|
447
|
+
if (!msg || !msg._streamPending || msg.content || !msg._serverItemId) return '';
|
|
448
|
+
if (msg._streamRecovery === 'active') return 'active';
|
|
449
|
+
if (msg._streamRecovery === 'failed') return 'failed';
|
|
450
|
+
return 'idle';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The words for the two phases a reader has to act on. Here rather than in each
|
|
455
|
+
* client for the same reason as the phase itself: two clients wording the same
|
|
456
|
+
* state differently is how one of them ends up saying something untrue.
|
|
457
|
+
*
|
|
458
|
+
* Neither string claims the answer is lost. It is not: the row is unfinalized, so
|
|
459
|
+
* the chunks are retained until somebody finalizes them, and that is exactly why
|
|
460
|
+
* asking again is worth offering.
|
|
461
|
+
*/
|
|
462
|
+
export function streamRecoveryLabels(phase: string): { note: string; action: string } {
|
|
463
|
+
if (phase === 'failed') {
|
|
464
|
+
return { note: 'Could not load this answer.', action: 'Try again' };
|
|
465
|
+
}
|
|
466
|
+
return { note: 'This answer was not saved with the conversation.', action: 'Load answer' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* isCsrStatusEnvelope - the predicate that tells a STREAMED turn apart at settle,
|
|
470
|
+
* because a streamed row stores nothing and hands back { id, status, in_queue, ... }
|
|
471
|
+
* instead - now lives in errors.ts and is imported above.
|
|
472
|
+
*
|
|
473
|
+
* It moved because it was written twice: once here for the settle, and (implicitly,
|
|
474
|
+
* one level too shallow) in the error readers. A streamed FAILURE is that same
|
|
475
|
+
* envelope with the provider's error nested inside it, so the two had to agree on
|
|
476
|
+
* what an envelope is before the error readers could see through one. See
|
|
477
|
+
* csrEnvelopeError. */
|
|
478
|
+
|
|
479
|
+
/** Floor on the gap between two live paints of the same bubble. The transport
|
|
480
|
+
* already paces the answer at about one write per second, so this is not the
|
|
481
|
+
* rhythm - it is what stops a BURST (a poll tick carrying a dozen chunks, or a
|
|
482
|
+
* re-attach replaying the whole answer at once) from turning into a dozen
|
|
483
|
+
* re-renders of the same bubble in the same frame. */
|
|
484
|
+
const LIVE_PAINT_MIN_MS = 250;
|
|
485
|
+
/** Largest growth, in characters, that a single live paint will ANIMATE rather than
|
|
486
|
+
* land whole. The transport paces at about one chunk a second and a model writes
|
|
487
|
+
* roughly 160 characters in that time, so an ordinary arrival is far under this and
|
|
488
|
+
* types. A replay (reopening a chat, recovering an unfinalized turn) delivers the
|
|
489
|
+
* whole answer in one feed and is far over it, so it lands at once instead of
|
|
490
|
+
* retyping a finished message at the reader. */
|
|
491
|
+
const LIVE_TYPE_MAX_STEP = 1200;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The identity a streamed turn was DISPATCHED under, pinned by the caller.
|
|
495
|
+
*
|
|
496
|
+
* Same reason _callProviderFor takes projectId/owner explicitly: a turn can be
|
|
497
|
+
* acked after the user has moved to another project or platform, and a live
|
|
498
|
+
* getIdentity() read at that moment describes where the user is now, not where the
|
|
499
|
+
* turn came from. Every field optional so a caller can pin what it knows and let
|
|
500
|
+
* the rest fall back to the live read.
|
|
501
|
+
*/
|
|
502
|
+
export type StreamDispatchContext = {
|
|
503
|
+
platform?: string;
|
|
504
|
+
projectId?: string;
|
|
505
|
+
owner?: string;
|
|
506
|
+
/** History cache key (chatCacheKey) of the chat the turn belongs to. */
|
|
507
|
+
ownerKey?: string;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/** One in-flight streamed turn. Keyed by server item id in ChatSession.liveStreams. */
|
|
511
|
+
type LiveStreamState = {
|
|
512
|
+
/** Server item id: the turn's identity everywhere, including on its bubbles. */
|
|
513
|
+
id: string;
|
|
514
|
+
/** History cache key the turn belongs to. Painting is skipped while another
|
|
515
|
+
* chat is on screen; parsing and finalizing are not. */
|
|
516
|
+
ownerKey: string;
|
|
517
|
+
platform: 'claude' | 'openai';
|
|
518
|
+
projectId: string;
|
|
519
|
+
owner: string;
|
|
520
|
+
parser: SseParser;
|
|
521
|
+
/** What is currently on screen, so a paint can be skipped when the safe prefix
|
|
522
|
+
* has not grown (see the monotonic guard in _paintLiveStream). */
|
|
523
|
+
painted: string;
|
|
524
|
+
/** A first paint has happened, so the one notify() has been spent. */
|
|
525
|
+
started: boolean;
|
|
526
|
+
/** At least one chunk arrived. False means the row never streamed, so there is
|
|
527
|
+
* nothing to assemble and nothing to finalize. */
|
|
528
|
+
fed: boolean;
|
|
529
|
+
ended: boolean;
|
|
530
|
+
timer: any;
|
|
531
|
+
lastPaintAt: number;
|
|
532
|
+
/** The assembled provider body, built once at end(). */
|
|
533
|
+
finalBody: any;
|
|
534
|
+
/** csr-finalize has been fired for this turn. Guards the double settle
|
|
535
|
+
* (onResponse and the promise) from storing the same body twice. */
|
|
536
|
+
finalized?: boolean;
|
|
537
|
+
/** How many chunks each of skapi's two transports carried FIRST. Counted, never
|
|
538
|
+
* acted on: both deliver into the same sink by design, so this is the only way
|
|
539
|
+
* a host can tell a websocket-delivered answer from a polled one. */
|
|
540
|
+
transport: { socket: number; poll: number };
|
|
541
|
+
};
|
|
542
|
+
|
|
160
543
|
export class ChatSession {
|
|
161
544
|
host: ChatHost;
|
|
162
545
|
state: ChatState;
|
|
@@ -625,12 +1008,77 @@ export class ChatSession {
|
|
|
625
1008
|
* and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
|
|
626
1009
|
* the request budget the cap exists to protect.
|
|
627
1010
|
*/
|
|
628
|
-
attachForegroundPoll(source: any, itemId: string, opts?: any): any {
|
|
629
|
-
return this._fgPollWithEarlyProbe(source, itemId, opts);
|
|
1011
|
+
attachForegroundPoll(source: any, itemId: string, opts?: any, ctx?: StreamDispatchContext): any {
|
|
1012
|
+
return this._fgPollWithEarlyProbe(source, itemId, opts, ctx);
|
|
630
1013
|
}
|
|
631
1014
|
|
|
632
|
-
private _fgPollWithEarlyProbe(source: any, itemId: string, opts?: any): any {
|
|
1015
|
+
private _fgPollWithEarlyProbe(source: any, itemId: string, opts?: any, ctx?: StreamDispatchContext): any {
|
|
633
1016
|
var self = this;
|
|
1017
|
+
|
|
1018
|
+
// LIVE STREAMING takes over the whole method when it is on, rather than adding
|
|
1019
|
+
// a branch to the probe race, and both halves of that are deliberate.
|
|
1020
|
+
//
|
|
1021
|
+
// It attaches a sink to EVERY foreground poll, not only the one a fresh
|
|
1022
|
+
// dispatch makes: skapi's reader sends `since: 0` on its first tick, so a poll
|
|
1023
|
+
// re-attached after a reload or a tab return REPLAYS the turn from its first
|
|
1024
|
+
// byte. That is the only recovery a streamed turn has, because its row holds a
|
|
1025
|
+
// status and no body until csr-finalize stores one - attaching without a sink
|
|
1026
|
+
// settles it on that envelope and the answer is gone for good. A row that never
|
|
1027
|
+
// streamed simply hands back no chunks, and the substitution below is then a
|
|
1028
|
+
// no-op, so the cost of asking is one unused cursor.
|
|
1029
|
+
//
|
|
1030
|
+
// And it SKIPS the early-probe ladder, because a point lookup cannot answer for
|
|
1031
|
+
// a streamed row (there is no stored body to look up) yet would settle the race
|
|
1032
|
+
// on that empty envelope and stop the poll - killing the delivery of the very
|
|
1033
|
+
// answer it was trying to fetch early. What replaces it is the cadence: a
|
|
1034
|
+
// streaming poll's tick IS the delivery, so it runs at STREAM_POLL_INTERVAL.
|
|
1035
|
+
// A foreground row that turns out not to have streamed loses the 400ms probe
|
|
1036
|
+
// and gets a 1s interval instead of a 3s one, which is the trade.
|
|
1037
|
+
var live = this._beginLiveStream(itemId, ctx);
|
|
1038
|
+
if (live) {
|
|
1039
|
+
var inner = opts || {};
|
|
1040
|
+
var callerResponse = typeof inner.onResponse === 'function' ? inner.onResponse : null;
|
|
1041
|
+
var callerError = typeof inner.onError === 'function' ? inner.onError : null;
|
|
1042
|
+
// Both wrappers are needed and neither is redundant: a poll attached to a
|
|
1043
|
+
// HISTORY ITEM honours the onResponse passed here (that is how the re-attach
|
|
1044
|
+
// path resolves a turn), while a poll attached to a fresh DISPATCH ack takes
|
|
1045
|
+
// its callbacks from the original clientSecretRequest and reports only
|
|
1046
|
+
// through the promise. Substituting in one place would leave the other
|
|
1047
|
+
// reading the envelope.
|
|
1048
|
+
var streamOpts = Object.assign({}, inner, {
|
|
1049
|
+
onStream: function (chunk: string, _seq: number, via?: 'socket' | 'poll') {
|
|
1050
|
+
self._feedLiveStream(live, chunk, via);
|
|
1051
|
+
},
|
|
1052
|
+
onResponse: function (res: any) {
|
|
1053
|
+
var effective = res;
|
|
1054
|
+
if (isPollStopped(res)) self._closeLiveStream(live, false);
|
|
1055
|
+
else effective = self._settleLiveStream(live, res);
|
|
1056
|
+
if (callerResponse) callerResponse(effective);
|
|
1057
|
+
},
|
|
1058
|
+
onError: function (err: any) {
|
|
1059
|
+
self._closeLiveStream(live, false);
|
|
1060
|
+
if (callerError) callerError(err);
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
var lp = source.poll(Object.assign({ latency: STREAM_POLL_INTERVAL }, streamOpts));
|
|
1064
|
+
var stopLp = lp && typeof lp.stop === 'function' ? lp.stop.bind(lp) : null;
|
|
1065
|
+
var wrapped: any = Promise.resolve(lp).then(function (res: any) {
|
|
1066
|
+
// A stop is not a result: the turn may still be running server side, so
|
|
1067
|
+
// the stream is discarded rather than assembled and finalized.
|
|
1068
|
+
if (isPollStopped(res)) { self._closeLiveStream(live, false); return res; }
|
|
1069
|
+
return self._settleLiveStream(live, res);
|
|
1070
|
+
}, function (err: any) {
|
|
1071
|
+
self._closeLiveStream(live, false);
|
|
1072
|
+
throw err;
|
|
1073
|
+
});
|
|
1074
|
+
// _trackPoll and the cancel path both reach for .stop, so the wrapper carries it.
|
|
1075
|
+
wrapped.stop = function () {
|
|
1076
|
+
self._closeLiveStream(live, false);
|
|
1077
|
+
if (stopLp) stopLp();
|
|
1078
|
+
};
|
|
1079
|
+
return wrapped;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
634
1082
|
// Callbacks ride along on the interval path exactly as before; the probe path below
|
|
635
1083
|
// fires onResponse itself, because a caller that only reacts through the callback
|
|
636
1084
|
// (the history drain does) would otherwise never learn the probe won.
|
|
@@ -717,6 +1165,975 @@ export class ChatSession {
|
|
|
717
1165
|
return n;
|
|
718
1166
|
}
|
|
719
1167
|
|
|
1168
|
+
// --- live streaming ----------------------------------------------------
|
|
1169
|
+
//
|
|
1170
|
+
// A streamed turn's answer NEVER reaches the polling row: the relay appends the
|
|
1171
|
+
// destination's raw bytes to a chunk table and the row settles with a status and
|
|
1172
|
+
// nothing else. So for a streamed turn this parser is not a nicety that makes the
|
|
1173
|
+
// wait prettier, it is the only place the answer exists until csr-finalize stores
|
|
1174
|
+
// one. Three things follow, and all three are load-bearing:
|
|
1175
|
+
//
|
|
1176
|
+
// 1. EVERY foreground poll gets a sink while streaming is on, not just the one
|
|
1177
|
+
// the dispatch attaches. A tab return, a reload, a resumePolling all
|
|
1178
|
+
// re-attach a poll to a still-running item, and skapi's reader sends
|
|
1179
|
+
// `since: 0` on its first tick, so a fresh sink REPLAYS the whole stream from
|
|
1180
|
+
// the beginning. Attaching without one settles that turn on an envelope and
|
|
1181
|
+
// the user's answer is gone.
|
|
1182
|
+
// 2. The parser is keyed by SERVER ITEM ID, and so is the bubble it paints into.
|
|
1183
|
+
// A history refetch replaces the local pending bubble with the server's copy
|
|
1184
|
+
// of the same turn; that copy carries the same _serverItemId, so the next
|
|
1185
|
+
// paint finds it and carries on. Nothing has to be rescued and nothing can be
|
|
1186
|
+
// painted twice.
|
|
1187
|
+
// 3. The stream is never the source of truth. At settle the parser's ASSEMBLED
|
|
1188
|
+
// body (byte equivalent to what a buffered call returns) goes through the
|
|
1189
|
+
// same extractClaudeText / extractOpenAIText the buffered path uses, and a
|
|
1190
|
+
// row that does hold a stored body wins outright.
|
|
1191
|
+
//
|
|
1192
|
+
// Background polls never get a sink, and that is safe because nothing on the bg
|
|
1193
|
+
// queue ever streams: an indexing pass must not (the worker READS its reply), and
|
|
1194
|
+
// a chat turn sent with attachments is deliberately left buffered for exactly the
|
|
1195
|
+
// reason point 1 gives, since the re-attach loop would poll it as a background
|
|
1196
|
+
// item and hand it no reader. See chatStreamWiring. A sink there would also spend
|
|
1197
|
+
// the request budget MAX_CONCURRENT_BG_POLLS exists to protect.
|
|
1198
|
+
|
|
1199
|
+
/** Live streams by server item id. One per in-flight streamed turn. */
|
|
1200
|
+
private liveStreams: { [itemId: string]: LiveStreamState } = {};
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Open (or re-open) the live stream for `itemId`, or null when this poll must
|
|
1204
|
+
* not carry one.
|
|
1205
|
+
*
|
|
1206
|
+
* Re-entrant on purpose: an auth-refresh retry re-dispatches the SAME turn under
|
|
1207
|
+
* a NEW id, and a re-attach after a tab return replays an existing id from seq 0.
|
|
1208
|
+
* Either way the bytes about to arrive are a whole stream, so an existing entry
|
|
1209
|
+
* is discarded and a fresh parser takes its place - feeding a replay into the old
|
|
1210
|
+
* parser would concatenate the answer with itself.
|
|
1211
|
+
*
|
|
1212
|
+
* `ctx` IS THE TURN'S OWN IDENTITY, and every caller that has one passes it.
|
|
1213
|
+
* This used to read the LIVE getIdentity(), which is a bug of exactly the kind
|
|
1214
|
+
* _callProviderFor documents and threads its own parameters to avoid: the user
|
|
1215
|
+
* hits Send, then switches project or platform inside the ack round trip, and the
|
|
1216
|
+
* stream that opens for the OLD turn is stamped with the NEW identity. What that
|
|
1217
|
+
* costs is not cosmetic - `platform` picks which url csr-finalize is addressed
|
|
1218
|
+
* with and which extractor reads the assembled body, `projectId`/`owner` scope
|
|
1219
|
+
* the finalize itself, and `ownerKey` decides which chat the answer is painted
|
|
1220
|
+
* into. Get them from the live read at the wrong moment and the turn is finalized
|
|
1221
|
+
* against the wrong service (so its answer is never stored), parsed with the
|
|
1222
|
+
* wrong provider's extractor, or painted into a conversation it does not belong
|
|
1223
|
+
* to. The live read stays only as the fallback for a caller with nothing pinned.
|
|
1224
|
+
*/
|
|
1225
|
+
private _beginLiveStream(itemId: string, ctx?: StreamDispatchContext): LiveStreamState | null {
|
|
1226
|
+
if (!liveStreamingEnabled()) return null;
|
|
1227
|
+
if (!itemId) {
|
|
1228
|
+
// A streamed turn with no id is unreadable: the id is what the cursor polls,
|
|
1229
|
+
// what the bubble is found by, and what csr-finalize addresses. skapi stamps
|
|
1230
|
+
// one on every queued ack, so this should be unreachable - and if it is ever
|
|
1231
|
+
// reached the turn's answer is genuinely lost, which is worth a line in the
|
|
1232
|
+
// console rather than a silent empty bubble.
|
|
1233
|
+
console.warn('[chat-engine] live streaming is on but the dispatch reported no item id');
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
// Pinned values win over the live read, field by field: a caller may know the
|
|
1237
|
+
// platform the turn was sent on without knowing which chat key it belongs to.
|
|
1238
|
+
var pinnedPlatform = ctx && (ctx.platform === 'claude' || ctx.platform === 'openai') ? ctx.platform : undefined;
|
|
1239
|
+
var ident = (pinnedPlatform && ctx && ctx.projectId !== undefined && ctx.owner !== undefined && ctx.ownerKey !== undefined)
|
|
1240
|
+
? null : this.host.getIdentity();
|
|
1241
|
+
var platform = pinnedPlatform || (ident ? ident.platform : undefined);
|
|
1242
|
+
if (platform !== 'claude' && platform !== 'openai') return null;
|
|
1243
|
+
var projectId = ctx && ctx.projectId !== undefined ? ctx.projectId : (ident ? ident.projectId : '');
|
|
1244
|
+
var owner = ctx && ctx.owner !== undefined ? ctx.owner : (ident ? ident.owner : '');
|
|
1245
|
+
var ownerKey = ctx && ctx.ownerKey !== undefined ? ctx.ownerKey : this.getHistoryCacheKey();
|
|
1246
|
+
var prev = this.liveStreams[itemId];
|
|
1247
|
+
if (prev) this._closeLiveStream(prev, false);
|
|
1248
|
+
var st: LiveStreamState = {
|
|
1249
|
+
id: itemId,
|
|
1250
|
+
ownerKey: ownerKey,
|
|
1251
|
+
platform: platform,
|
|
1252
|
+
projectId: projectId,
|
|
1253
|
+
owner: owner,
|
|
1254
|
+
parser: createSseParser(),
|
|
1255
|
+
painted: '',
|
|
1256
|
+
started: false,
|
|
1257
|
+
fed: false,
|
|
1258
|
+
ended: false,
|
|
1259
|
+
timer: null,
|
|
1260
|
+
lastPaintAt: 0,
|
|
1261
|
+
finalBody: null,
|
|
1262
|
+
transport: { socket: 0, poll: 0 },
|
|
1263
|
+
};
|
|
1264
|
+
this.liveStreams[itemId] = st;
|
|
1265
|
+
return st;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** The chunk sink handed to skapi's poll. Raw relayed text, in order, never parsed
|
|
1269
|
+
* here: the parser owns the grammar and this owns the pacing. */
|
|
1270
|
+
private _feedLiveStream(st: LiveStreamState, chunk: string, via?: 'socket' | 'poll'): void {
|
|
1271
|
+
if (st.ended || typeof chunk !== 'string' || !chunk) return;
|
|
1272
|
+
st.fed = true;
|
|
1273
|
+
// Counted before the early-outs below, so a chunk that only joins an already
|
|
1274
|
+
// scheduled paint still tells the host which transport brought it.
|
|
1275
|
+
if (via === 'socket') st.transport.socket++;
|
|
1276
|
+
else if (via === 'poll') st.transport.poll++;
|
|
1277
|
+
st.parser.feed(chunk);
|
|
1278
|
+
if (st.timer) return; // a paint is already scheduled; this chunk joins it
|
|
1279
|
+
var self = this;
|
|
1280
|
+
// One paint per burst, floored at LIVE_PAINT_MIN_MS. A poll tick can deliver a
|
|
1281
|
+
// dozen chunks at once (and a replay delivers the whole answer at once), and
|
|
1282
|
+
// painting per chunk is exactly the 60-renders-a-second the per-bubble refresh
|
|
1283
|
+
// exists to avoid. The transport already paces at about 1/s, so this floor is
|
|
1284
|
+
// only there to survive a burst, not to set the rhythm.
|
|
1285
|
+
// The FIRST paint is immediate: the floor is there to survive a burst, and
|
|
1286
|
+
// spending it on the opening tokens would delay the one moment the whole
|
|
1287
|
+
// feature is for. (It also cannot be measured from lastPaintAt = 0, since
|
|
1288
|
+
// nowMs() is time since page load and is itself small early on.)
|
|
1289
|
+
var wait = st.lastPaintAt ? Math.max(0, LIVE_PAINT_MIN_MS - (nowMs() - st.lastPaintAt)) : 0;
|
|
1290
|
+
st.timer = setTimeout(function () { st.timer = null; self._paintLiveStream(st); }, wait);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Write the safe prefix of the answer so far into the turn's bubble.
|
|
1295
|
+
*
|
|
1296
|
+
* notify() is spent EXACTLY ONCE per turn, on the first paint, because that is a
|
|
1297
|
+
* state change the per-bubble refresh cannot express: the bubble stops being a
|
|
1298
|
+
* "Thinking..." spinner and becomes text. Every paint after it goes through
|
|
1299
|
+
* refreshMessageBubble, which is what keeps a growing answer from rebuilding the
|
|
1300
|
+
* whole display list once a second.
|
|
1301
|
+
*/
|
|
1302
|
+
private _paintLiveStream(st: LiveStreamState): void {
|
|
1303
|
+
if (st.ended) return;
|
|
1304
|
+
st.lastPaintAt = nowMs();
|
|
1305
|
+
// Another project (or another platform) is on screen. The turn keeps parsing -
|
|
1306
|
+
// its answer is still being assembled and will still be finalized - but nothing
|
|
1307
|
+
// is painted, because the index would point into a different chat's list.
|
|
1308
|
+
if (this.getHistoryCacheKey() !== st.ownerKey) return;
|
|
1309
|
+
var idx = this._liveTargetIndex(st.id);
|
|
1310
|
+
if (idx === -1) return;
|
|
1311
|
+
var msg = this.state.messages[idx];
|
|
1312
|
+
if (!msg) return;
|
|
1313
|
+
var snap = st.parser.snapshot();
|
|
1314
|
+
var next = liveSafePrefix(snap.text);
|
|
1315
|
+
// MONOTONIC, and this is the guard that makes liveSafePrefix's cuts safe to
|
|
1316
|
+
// make: a cut can shorten the safe prefix (a '[' arrives, a url starts), and
|
|
1317
|
+
// repainting shorter would have the answer visibly retreat. What was painted
|
|
1318
|
+
// was safe when it was painted, so it stays until there is MORE to show.
|
|
1319
|
+
if (next.length <= st.painted.length) return;
|
|
1320
|
+
var prev = st.painted;
|
|
1321
|
+
st.painted = next;
|
|
1322
|
+
|
|
1323
|
+
// TYPE THE NEW TEXT IN, DO NOT JUMP TO IT.
|
|
1324
|
+
//
|
|
1325
|
+
// This used to assign msg.content directly, which is why a streamed answer
|
|
1326
|
+
// arrived as a series of lumps: the transport paces at about one chunk a
|
|
1327
|
+
// second, so a whole second of text appeared at once, and the only animation a
|
|
1328
|
+
// reader saw was the settle typewriting whatever was left. The point of
|
|
1329
|
+
// streaming is that the answer looks like it is being written, so each arrival
|
|
1330
|
+
// is revealed at the same rate the settle uses. enqueueTypewrite is sequential,
|
|
1331
|
+
// so consecutive chunks queue behind one another and read as one continuous
|
|
1332
|
+
// stream rather than racing each other into the same bubble.
|
|
1333
|
+
//
|
|
1334
|
+
// EXCEPT WHEN THE TEXT ARRIVES ALL AT ONCE, which is the replay path: reopening
|
|
1335
|
+
// a chat, or recovering an unfinalized turn, feeds the entire answer in one go.
|
|
1336
|
+
// Animating that would slowly retype an old message every time it is opened,
|
|
1337
|
+
// which is not a stream, it is a delay. So a jump larger than a live burst
|
|
1338
|
+
// could plausibly be lands whole, exactly as it did before.
|
|
1339
|
+
// SIZE DECIDES, NOT WHICH PAINT IT IS. The first paint of a live turn is a small
|
|
1340
|
+
// step from empty and should type, which is the moment the whole feature exists
|
|
1341
|
+
// for. The first paint of a REPLAY is the entire answer from empty and must not:
|
|
1342
|
+
// gating on "is this the first paint" gets both of those wrong, because they are
|
|
1343
|
+
// the same paint. Only the size tells them apart.
|
|
1344
|
+
var grew = next.length - prev.length;
|
|
1345
|
+
var animate = grew > 0 && grew <= LIVE_TYPE_MAX_STEP;
|
|
1346
|
+
|
|
1347
|
+
if (animate) {
|
|
1348
|
+
// IDENTITY, NOT INDEX. typewriteIntoIndex re-finds its bubble by _localId
|
|
1349
|
+
// and falls back to the raw index when there is none, and that fallback is
|
|
1350
|
+
// unsafe here: a live reveal is in flight for seconds, and anything that
|
|
1351
|
+
// REPLACES the bubble meanwhile (a recovery landing the whole answer, a
|
|
1352
|
+
// history page swapping the list) leaves the stale animation to write its
|
|
1353
|
+
// older text into whatever now occupies that index. Observed: a recovered
|
|
1354
|
+
// answer being overwritten, a character at a time, by the truncation it had
|
|
1355
|
+
// just replaced. Minting an id here restores the bail the re-find exists to
|
|
1356
|
+
// provide, so a superseded reveal writes nothing.
|
|
1357
|
+
if (!msg._localId) msg._localId = this._newLocalId();
|
|
1358
|
+
// Left at its previous text and typed forward from there. The bubble is
|
|
1359
|
+
// never blanked: on a first paint prev is empty anyway, and on a later one
|
|
1360
|
+
// blanking would flash the answer away and retype it.
|
|
1361
|
+
if (!msg._streaming) { msg._streaming = true; this.host.notify(); }
|
|
1362
|
+
this.enqueueTypewrite(idx, next, msg._localId, prev);
|
|
1363
|
+
} else {
|
|
1364
|
+
msg.content = next;
|
|
1365
|
+
if (!msg._streaming) { msg._streaming = true; this.host.notify(); }
|
|
1366
|
+
else this.host.refreshMessageBubble(idx);
|
|
1367
|
+
}
|
|
1368
|
+
// ARRIVAL, not a user action: follow only a reader who is still pinned.
|
|
1369
|
+
this.host.scrollToBottomIfSticky();
|
|
1370
|
+
this._reportLiveStream(st, st.started ? 'update' : 'start', snap, next);
|
|
1371
|
+
st.started = true;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** The bubble a live stream paints into: the turn's pending assistant placeholder,
|
|
1375
|
+
* found by server item id. Not by _localId, deliberately - a history refetch
|
|
1376
|
+
* replaces the local copy with the server's, and only the id survives that. */
|
|
1377
|
+
private _liveTargetIndex(itemId: string): number {
|
|
1378
|
+
return this.state.messages.findIndex(function (m) {
|
|
1379
|
+
return !!m && m.role === 'assistant' && !m.isBackgroundTask &&
|
|
1380
|
+
m._serverItemId === itemId && (!!m.isPending || !!m._streaming);
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** Hand the host its optional observation update. Guarded: this runs on the paint
|
|
1385
|
+
* path, and a throwing hook must not cost the user the rest of their answer. */
|
|
1386
|
+
private _reportLiveStream(st: LiveStreamState, phase: 'start' | 'update' | 'end', snap: any, text: string): void {
|
|
1387
|
+
var hook = chatEngineConfig().onLiveStreamUpdate;
|
|
1388
|
+
if (!hook) return;
|
|
1389
|
+
try {
|
|
1390
|
+
hook({
|
|
1391
|
+
serverItemId: st.id, ownerKey: st.ownerKey, phase: phase, text: text,
|
|
1392
|
+
thinkingText: (snap && snap.thinkingText) || '',
|
|
1393
|
+
toolNames: (snap && snap.toolNames) ? snap.toolNames.slice() : [],
|
|
1394
|
+
complete: !!(snap && snap.complete),
|
|
1395
|
+
// Reported alongside `complete`, never instead of it: a host drawing
|
|
1396
|
+
// "still arriving" wants complete, a host drawing "this answer is
|
|
1397
|
+
// partial" wants this one, and an `error` frame is the case where the
|
|
1398
|
+
// two disagree. See sse.ts answerComplete.
|
|
1399
|
+
answerComplete: !!(snap && snap.answerComplete),
|
|
1400
|
+
errored: !!(snap && snap.errored),
|
|
1401
|
+
transport: { socket: st.transport.socket, poll: st.transport.poll },
|
|
1402
|
+
});
|
|
1403
|
+
} catch (e) { console.warn('[chat-engine] onLiveStreamUpdate threw', e); }
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Stop painting and (when the turn really ended) assemble the body. `finished`
|
|
1407
|
+
* is false for a stream being discarded rather than settled: a retry replacing
|
|
1408
|
+
* it, or a stop, neither of which has an answer to assemble. */
|
|
1409
|
+
private _closeLiveStream(st: LiveStreamState, finished: boolean): void {
|
|
1410
|
+
// A settle arrives TWICE (the poll's onResponse, then the promise it resolves),
|
|
1411
|
+
// so everything that must happen once hangs off this rather than off `ended`
|
|
1412
|
+
// being read after it is set.
|
|
1413
|
+
var first = !st.ended;
|
|
1414
|
+
if (st.timer) { clearTimeout(st.timer); st.timer = null; }
|
|
1415
|
+
if (first) {
|
|
1416
|
+
st.ended = true;
|
|
1417
|
+
if (finished && st.fed) {
|
|
1418
|
+
st.parser.end();
|
|
1419
|
+
st.finalBody = st.parser.finalBody();
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (this.liveStreams[st.id] === st) delete this.liveStreams[st.id];
|
|
1423
|
+
// Told BEFORE the ownerKey check below, and only when a 'start' was reported:
|
|
1424
|
+
// a host that drew a "thinking..." or "querying..." row off this stream has to
|
|
1425
|
+
// learn it is over even when the reader has moved to another chat, and telling
|
|
1426
|
+
// it about an end it never saw begin would be noise.
|
|
1427
|
+
if (first && st.started) this._reportLiveStream(st, 'end', st.parser.snapshot(), '');
|
|
1428
|
+
// The bubble stops being a live one from here. Its painted content is left
|
|
1429
|
+
// alone ON PURPOSE: it is what the typewriter resumes from a moment later, and
|
|
1430
|
+
// blanking it would put the answer the reader just watched arrive back to zero.
|
|
1431
|
+
if (this.getHistoryCacheKey() !== st.ownerKey) return;
|
|
1432
|
+
var idx = this._liveTargetIndex(st.id);
|
|
1433
|
+
if (idx !== -1 && this.state.messages[idx] && this.state.messages[idx]._streaming) {
|
|
1434
|
+
this.state.messages[idx]._streaming = false;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Settle a streamed turn: end the parse, decide the body the rest of the session
|
|
1440
|
+
* will read, and release the chunks.
|
|
1441
|
+
*
|
|
1442
|
+
* The substitution is one-directional and never a merge. A response that is a
|
|
1443
|
+
* real stored body (a buffered turn, or a streamed one somebody already
|
|
1444
|
+
* finalized) is returned untouched, because that is the destination's own answer
|
|
1445
|
+
* and the stream is not entitled to overwrite it. Only a STATUS ENVELOPE - the
|
|
1446
|
+
* shape a streamed row settles as, having stored nothing - is replaced, and then
|
|
1447
|
+
* by the assembled body, which every caller downstream reads with the same
|
|
1448
|
+
* extractor it uses for a buffered reply. Idempotent, because it is reached both
|
|
1449
|
+
* through the poll's onResponse and through the promise it resolves.
|
|
1450
|
+
*/
|
|
1451
|
+
private _settleLiveStream(st: LiveStreamState, response: any): any {
|
|
1452
|
+
this._closeLiveStream(st, true);
|
|
1453
|
+
if (!isCsrStatusEnvelope(response)) return response;
|
|
1454
|
+
// RESOLVED only, and this is not caution, it is correctness. 'cancelled' is the
|
|
1455
|
+
// envelope the user's own Stop produces, and _isCancelledPollResult downstream
|
|
1456
|
+
// is what settles the turn as stopped: hand it an assembled body instead and a
|
|
1457
|
+
// cancelled turn renders the half answer the stop was meant to discard. Any
|
|
1458
|
+
// other status ('failed' with the destination's error, or a shape this does not
|
|
1459
|
+
// know) is likewise the authoritative account of the turn, and the stream is
|
|
1460
|
+
// never entitled to overwrite one.
|
|
1461
|
+
if (response.status !== 'resolved') return response;
|
|
1462
|
+
// MARKED BEFORE THE finalBody BAIL, and the order is the whole of the fix for
|
|
1463
|
+
// a settle that read NOTHING. A poll whose chunk reads degraded for its entire
|
|
1464
|
+
// life (the poller returns "no chunks this tick, more=true" on any transient
|
|
1465
|
+
// chunk-table error) settles on a resolved row with nothing fed and nothing
|
|
1466
|
+
// assembled. The turn then renders "No text response received from AI
|
|
1467
|
+
// provider" and, with the note skipped, that sentence is adopted over the
|
|
1468
|
+
// row's own empty copy on the next history load and clears the marker that
|
|
1469
|
+
// would have gone back for the answer: unreachable, with every byte of it
|
|
1470
|
+
// still in the chunk table. A turn that painted nothing is EXACTLY as
|
|
1471
|
+
// recoverable as one that was never polled at all - same row, same chunks,
|
|
1472
|
+
// same reason - so it leaves the same note and gets the same marker back.
|
|
1473
|
+
if (!this._mayFinalize(st)) this._rec().incomplete[st.id] = true;
|
|
1474
|
+
if (st.finalBody == null) return response;
|
|
1475
|
+
// An INCOMPLETE parse is still the best answer there is right now - it is what
|
|
1476
|
+
// the reader has been watching arrive - so it is handed downstream and shown.
|
|
1477
|
+
// What it is not is FINISHED, and the difference has to outlive this call:
|
|
1478
|
+
// without the note, the next history refetch would meet a bubble with text in
|
|
1479
|
+
// it, adopt that text over the row's own empty copy, and clear the very marker
|
|
1480
|
+
// that would have gone back for the rest. So the id is remembered, the chunks
|
|
1481
|
+
// are left alone (see _finalizeStreamedTurn), and the recovery replaces this
|
|
1482
|
+
// text with the whole answer on the next load. The note itself is taken above,
|
|
1483
|
+
// before the bail, because a settle that assembled NOTHING needs it just as
|
|
1484
|
+
// badly and used to return before reaching it.
|
|
1485
|
+
this._finalizeStreamedTurn(st);
|
|
1486
|
+
return st.finalBody;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* May this parse be STORED as the turn's permanent answer?
|
|
1491
|
+
*
|
|
1492
|
+
* THE FAILURE THIS PREVENTS. Finalizing does two things at once: it stores what
|
|
1493
|
+
* you give it as the row's result, and it DELETES the chunks it was assembled
|
|
1494
|
+
* from. So finalizing a truncated parse is not a cosmetic loss, it is the
|
|
1495
|
+
* permanent one: the truncation becomes the stored answer and the only copy of
|
|
1496
|
+
* the missing part is deleted in the same call. And a truncated parse is a shape
|
|
1497
|
+
* this repo has already paid for - a degraded chunk read (the poller degrades to
|
|
1498
|
+
* "no chunks this tick, more=true" on any transient chunk-table error, and caps
|
|
1499
|
+
* a long answer at 500k characters per response) can hand the settle a stream
|
|
1500
|
+
* that stopped mid-answer. The row can settle 'resolved' on top of that, because
|
|
1501
|
+
* the ROW's status describes the destination's request, not the client's read of
|
|
1502
|
+
* it.
|
|
1503
|
+
*
|
|
1504
|
+
* THE POLICY ITSELF IS mayKeepStreamedAnswer (top of this file), shared with the
|
|
1505
|
+
* recovery path so the two cannot drift apart again - they did, and the drift was
|
|
1506
|
+
* silent: the live settle refused a failed turn while the recovery finalized one.
|
|
1507
|
+
* What is local to this method is only the two things the free function cannot
|
|
1508
|
+
* know: that there is an assembled body at all, and that this call site is
|
|
1509
|
+
* reached only on a row that settled 'resolved' (the caller returns before it
|
|
1510
|
+
* otherwise), which is the status it therefore states.
|
|
1511
|
+
*
|
|
1512
|
+
* The test the policy applies is deliberately NOT `complete`: a terminal event
|
|
1513
|
+
* arrived and the answer finished are two claims, and an `error` frame satisfies
|
|
1514
|
+
* the first while truncating the second. See sse.ts's answerComplete.
|
|
1515
|
+
*/
|
|
1516
|
+
private _mayFinalize(st: LiveStreamState): boolean {
|
|
1517
|
+
if (st.finalBody == null) return false;
|
|
1518
|
+
return mayKeepStreamedAnswer(st.parser.snapshot(), 'resolved');
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Store the assembled body as the version history keeps, which is also what
|
|
1523
|
+
* releases this request's chunks.
|
|
1524
|
+
*
|
|
1525
|
+
* The ASSEMBLED BODY and not the extracted text, because the row is read back by
|
|
1526
|
+
* mapHistoryListToMessages through extractClaudeText / extractOpenAIText: storing
|
|
1527
|
+
* the provider's own document is what makes a streamed turn indistinguishable
|
|
1528
|
+
* from a buffered one on the next load, with no branch anywhere in the mapper.
|
|
1529
|
+
*
|
|
1530
|
+
* BEST EFFORT, and loudly so: the answer is already on screen and already in the
|
|
1531
|
+
* history cache by the time this fires. A failure costs the chunks (they stay,
|
|
1532
|
+
* and the turn stays re-readable) and a row that reads back empty, never the
|
|
1533
|
+
* user's answer in front of them.
|
|
1534
|
+
*
|
|
1535
|
+
* WHAT IS DELIBERATELY NEVER FINALIZED, because finalize is also the only way to
|
|
1536
|
+
* release chunks and it is tempting to reach for it as a cleanup:
|
|
1537
|
+
*
|
|
1538
|
+
* - an INCOMPLETE parse (see _mayFinalize). Storing a truncation makes it
|
|
1539
|
+
* permanent AND deletes the part that was missing from it. A stream killed by
|
|
1540
|
+
* an `error` frame is one of these however terminal it looks: the frame ends
|
|
1541
|
+
* the stream, so `complete` is true, while the text is only what arrived
|
|
1542
|
+
* before the error. That is why the gate reads answerComplete.
|
|
1543
|
+
* - a FAILED turn. Its chunks hold the part of the answer that did arrive,
|
|
1544
|
+
* which is the only copy of that text there is, and the two ways to release
|
|
1545
|
+
* them both cost something real: storing the partial makes a truncated answer
|
|
1546
|
+
* the turn's permanent history AND masks the failure on read (csr-poll hands
|
|
1547
|
+
* back a finalized body before it ever looks at the row's error, so the turn
|
|
1548
|
+
* would read back as a clean short answer), while storing the error throws
|
|
1549
|
+
* the partial away outright. Keeping them costs storage on rows that produced
|
|
1550
|
+
* bytes and then failed, which is rare - a failure before the first byte (a
|
|
1551
|
+
* wrong API key, the common case) has no chunks to keep - and the poller
|
|
1552
|
+
* hands those chunks back alongside the error on every later read, so nothing
|
|
1553
|
+
* is stranded, only retained. Retention is the honest trade here; deletion is
|
|
1554
|
+
* not reversible.
|
|
1555
|
+
* - a CANCELLED turn, for the same reason plus one: the user's Stop means the
|
|
1556
|
+
* half answer is to be discarded, so writing it into history as the kept
|
|
1557
|
+
* version would resurrect exactly what the stop was for.
|
|
1558
|
+
*/
|
|
1559
|
+
private _finalizeStreamedTurn(st: LiveStreamState): void {
|
|
1560
|
+
if (st.finalized) return;
|
|
1561
|
+
if (!this._mayFinalize(st)) return;
|
|
1562
|
+
var fin = chatEngineConfig().clientSecretRequestFinalize;
|
|
1563
|
+
if (!fin || st.finalBody == null) return;
|
|
1564
|
+
st.finalized = true;
|
|
1565
|
+
var url = st.platform === 'openai' ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
|
|
1566
|
+
try {
|
|
1567
|
+
Promise.resolve(fin(st.id, st.finalBody, {
|
|
1568
|
+
url: url, method: 'POST', service: st.projectId, owner: st.owner,
|
|
1569
|
+
})).catch(function (err: any) {
|
|
1570
|
+
console.warn('[chat-engine] clientSecretRequestFinalize failed', err);
|
|
1571
|
+
});
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
console.warn('[chat-engine] clientSecretRequestFinalize threw', e);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** Painted-but-unsettled live text on a bubble, for the typewriter to resume from.
|
|
1578
|
+
* A pending assistant placeholder is created with content '' by every path that
|
|
1579
|
+
* makes one, so non-empty content on one can only have been painted here. */
|
|
1580
|
+
private _paintedTextAt(idx: number): string {
|
|
1581
|
+
var m = idx >= 0 ? this.state.messages[idx] : undefined;
|
|
1582
|
+
if (!m || m.role !== 'assistant' || typeof m.content !== 'string') return '';
|
|
1583
|
+
return m.content;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// --- recovering a streamed turn nobody finalized ------------------------
|
|
1587
|
+
//
|
|
1588
|
+
// ONE QUESTION, TWO BUGS. A streamed row's answer is not on the row: it is in the
|
|
1589
|
+
// chunk store until csr-finalize copies a version onto it. So a history page can
|
|
1590
|
+
// carry a row that is TERMINAL AND EMPTY, and everything downstream has to know
|
|
1591
|
+
// what that means. Read as "this turn answered nothing" it produces two separate
|
|
1592
|
+
// disasters that look unrelated:
|
|
1593
|
+
//
|
|
1594
|
+
// 1. A row that settled while no poll was attached (closed tab, discarded
|
|
1595
|
+
// background tab, slept device) is never finalized, so it is terminal and
|
|
1596
|
+
// empty FOREVER and the mapper emitted no assistant bubble for it. The
|
|
1597
|
+
// answer is gone from the conversation with every byte of it still stored.
|
|
1598
|
+
// 2. A first-page refetch landing between the row going 'resolved' and finalize
|
|
1599
|
+
// storing the body sees the same terminal-and-empty row for a turn that is
|
|
1600
|
+
// on screen right now, and the merge - believing the server - drops the
|
|
1601
|
+
// local bubble holding the answer. Reachable on every single streamed turn,
|
|
1602
|
+
// since the window is a poll interval plus a round trip and a refetch fires
|
|
1603
|
+
// on visibilitychange.
|
|
1604
|
+
//
|
|
1605
|
+
// Both are the same question: what should the merge believe when the server copy
|
|
1606
|
+
// is authoritative but empty? The answer is that such a copy is UNKNOWN, not
|
|
1607
|
+
// empty (mapHistoryListToMessages marks it `_streamPending`), and one rule covers
|
|
1608
|
+
// both cases: AN UNKNOWN ANSWER NEVER OVERWRITES A KNOWN ONE, AND AN UNKNOWN ONE
|
|
1609
|
+
// LEFT OVER IS RESOLVED BY READING THE CHUNKS BACK. Bug 2 falls out of the first
|
|
1610
|
+
// half (_adoptLocalAnswers, below), bug 1 out of the second.
|
|
1611
|
+
//
|
|
1612
|
+
// WHAT THE RECOVERY COSTS, and how that is bounded. A replay is a full read of
|
|
1613
|
+
// one turn's chunks - the SDK pages internally until the store is drained, so it
|
|
1614
|
+
// is one round trip for a short answer and a handful for a long one. A history
|
|
1615
|
+
// page could in principle hold several such rows, so:
|
|
1616
|
+
// * it NEVER blocks the load. It is scheduled after the page has been rendered
|
|
1617
|
+
// and runs on its own, and the bubble it will fill is already on screen.
|
|
1618
|
+
// * it is SERIAL. One replay at a time, so a page holding five of them spends
|
|
1619
|
+
// one connection, not five, and the newest turn (the one the reader is
|
|
1620
|
+
// looking at) is filled first.
|
|
1621
|
+
// * it is CAPPED per load (STREAM_RECOVERY_PER_LOAD), newest first. Older ones
|
|
1622
|
+
// keep their marker and are recovered on a later load, or on demand through
|
|
1623
|
+
// the public recoverStreamedAnswer().
|
|
1624
|
+
// * it is ONCE PER ROW, ever: a successful recovery FINALIZES what it read, so
|
|
1625
|
+
// the row gains a stored body and the next load sees an ordinary turn. Even
|
|
1626
|
+
// when finalizing is impossible (no hook, a failed call), the id is
|
|
1627
|
+
// remembered for the session so a re-render cannot loop on it.
|
|
1628
|
+
|
|
1629
|
+
private _streamRecovery?: {
|
|
1630
|
+
/** Streams whose parse ended without a terminal event, so the text handed
|
|
1631
|
+
* downstream is not known to be the whole answer. See _settleLiveStream. */
|
|
1632
|
+
incomplete: { [id: string]: true };
|
|
1633
|
+
/** Rows this session has already tried to read back, so a repeated history
|
|
1634
|
+
* load (every visibilitychange fires one) cannot re-read the same chunks
|
|
1635
|
+
* forever. Deliberately NOT consulted by a user-driven retry: see the
|
|
1636
|
+
* `manual` argument to _readBackStreamedTurn. */
|
|
1637
|
+
attempted: { [id: string]: true };
|
|
1638
|
+
/** Rows whose read is in flight RIGHT NOW. Separate from `attempted`, which
|
|
1639
|
+
* outlives the request and survives a success: this one is the "something is
|
|
1640
|
+
* driving that bubble" fact the view renders its loader from, so it has to
|
|
1641
|
+
* come off however the read ends. */
|
|
1642
|
+
inflight: { [id: string]: true };
|
|
1643
|
+
/** Rows whose last read FAILED. The marker stays on for these (the answer is
|
|
1644
|
+
* still reachable), so without this the view cannot tell "not tried yet"
|
|
1645
|
+
* from "tried and could not read it" and has to word them the same. */
|
|
1646
|
+
failed: { [id: string]: true };
|
|
1647
|
+
queue: Array<{ id: string; ownerKey: string; platform: 'claude' | 'openai'; projectId: string; owner: string }>;
|
|
1648
|
+
running: boolean;
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
/** The recovery bookkeeping, created on first touch.
|
|
1652
|
+
*
|
|
1653
|
+
* LAZY, not constructor-initialised, and for a concrete reason: ChatSession is
|
|
1654
|
+
* also built with Object.create(ChatSession.prototype) by the engine's own test
|
|
1655
|
+
* harnesses, which drive one method against a hand-built state rather than a
|
|
1656
|
+
* whole session. A field only the constructor creates is undefined there, and
|
|
1657
|
+
* the method that reaches for it throws, turning a test of the settle into a
|
|
1658
|
+
* crash about bookkeeping. */
|
|
1659
|
+
private _rec() {
|
|
1660
|
+
if (!this._streamRecovery) this._streamRecovery = { incomplete: {}, attempted: {}, inflight: {}, failed: {}, queue: [], running: false };
|
|
1661
|
+
return this._streamRecovery;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Put this session's fetching state onto the turn's bubble, so a view can tell a
|
|
1666
|
+
* loader that means something from one that means nothing.
|
|
1667
|
+
*
|
|
1668
|
+
* ONLY EVER ONTO A STILL-MARKED BUBBLE. Once `_streamPending` is off the turn has
|
|
1669
|
+
* an answer (or was proven to have none) and this says nothing about it; writing
|
|
1670
|
+
* it there would leave a stale 'active' on a settled bubble forever.
|
|
1671
|
+
*
|
|
1672
|
+
* host.notify() is what redraws the widget, whose renderer is imperative. It is a
|
|
1673
|
+
* no-op in agent.vue, whose state is a Vue reactive() - the property write above
|
|
1674
|
+
* is what redraws there. Both are covered by doing both, and neither is a
|
|
1675
|
+
* substitute for the other.
|
|
1676
|
+
*/
|
|
1677
|
+
private _markRecoveryPhase(itemId: string, phase: 'active' | 'failed' | null): void {
|
|
1678
|
+
var changed = false;
|
|
1679
|
+
for (var i = 0; i < this.state.messages.length; i++) {
|
|
1680
|
+
var m: any = this.state.messages[i];
|
|
1681
|
+
if (!m || m.role !== 'assistant' || m._serverItemId !== itemId || !m._streamPending) continue;
|
|
1682
|
+
var next = phase === null ? undefined : phase;
|
|
1683
|
+
if (m._streamRecovery === next) continue;
|
|
1684
|
+
if (next === undefined) delete m._streamRecovery; else m._streamRecovery = next;
|
|
1685
|
+
changed = true;
|
|
1686
|
+
}
|
|
1687
|
+
if (changed) this.host.notify();
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* Let LOCAL answers survive a freshly-mapped page whose copies of them are
|
|
1692
|
+
* authoritative-but-empty. Call with the page BEFORE it replaces or merges into
|
|
1693
|
+
* state.messages; mutates the page's bubbles in place.
|
|
1694
|
+
*
|
|
1695
|
+
* The adoption itself is history.ts's adoptLocalAnswerIntoPage (shared, so the
|
|
1696
|
+
* clients' own mappers cannot fork it). What lives here is the one thing the
|
|
1697
|
+
* pure function cannot know: whether the local text is the WHOLE answer. Text
|
|
1698
|
+
* left by a stream that ended without a terminal event is not, so that bubble
|
|
1699
|
+
* keeps its marker and gets read back even though it has content - otherwise a
|
|
1700
|
+
* truncated answer would adopt itself over the row and never be corrected.
|
|
1701
|
+
*/
|
|
1702
|
+
private _adoptLocalAnswers(mapped: ChatMessage[], loadKey?: string): void {
|
|
1703
|
+
if (!mapped || !mapped.length) return;
|
|
1704
|
+
var pendingIncoming: ChatMessage[] = [];
|
|
1705
|
+
for (var i = 0; i < mapped.length; i++) {
|
|
1706
|
+
if (mapped[i] && (mapped[i] as any)._streamPending) pendingIncoming.push(mapped[i]);
|
|
1707
|
+
}
|
|
1708
|
+
if (!pendingIncoming.length) return;
|
|
1709
|
+
var locals: { [key: string]: ChatMessage } = {};
|
|
1710
|
+
for (var j = 0; j < this.state.messages.length; j++) {
|
|
1711
|
+
var lm = this.state.messages[j];
|
|
1712
|
+
if (!lm || lm.role !== 'assistant' || !lm._serverItemId) continue;
|
|
1713
|
+
// The same ownership predicate every other merge step uses, against the
|
|
1714
|
+
// load's SNAPSHOTTED key rather than a live read: a project switch mid-fetch
|
|
1715
|
+
// leaves another chat's transcript in state.messages, and ids are unique per
|
|
1716
|
+
// row, but a bubble stamped for another chat must never cross into this one.
|
|
1717
|
+
if (lm._ownerKey !== undefined && loadKey !== undefined && lm._ownerKey !== loadKey) continue;
|
|
1718
|
+
// FIRST wins: a page can only hold one assistant bubble per turn, and if the
|
|
1719
|
+
// local list somehow holds two the older one is the one the merge would
|
|
1720
|
+
// have kept.
|
|
1721
|
+
if (locals[lm._serverItemId] === undefined) locals[lm._serverItemId] = lm;
|
|
1722
|
+
}
|
|
1723
|
+
for (var k = 0; k < pendingIncoming.length; k++) {
|
|
1724
|
+
var inc = pendingIncoming[k];
|
|
1725
|
+
var id = inc._serverItemId;
|
|
1726
|
+
if (!id) continue;
|
|
1727
|
+
var local = locals[id];
|
|
1728
|
+
if (!local) continue;
|
|
1729
|
+
if (!adoptLocalAnswerIntoPage(inc, local)) continue;
|
|
1730
|
+
// Adopted text that is not known to be complete keeps the marker: the read
|
|
1731
|
+
// back is what turns it into the whole answer.
|
|
1732
|
+
if (this._rec().incomplete[id]) inc._streamPending = true;
|
|
1733
|
+
}
|
|
1734
|
+
// CARRY THIS SESSION'S FETCHING STATE ONTO THE FRESH PAGE. `_streamRecovery`
|
|
1735
|
+
// describes the client, not the turn, so the mapper cannot know it and a
|
|
1736
|
+
// freshly mapped bubble arrives without it - which would render a read that is
|
|
1737
|
+
// in flight right now as "nothing is fetching this, press to load", and then
|
|
1738
|
+
// flip back a second later when the read lands. Every load path runs this
|
|
1739
|
+
// (the engine's loadHistory and agent.vue's fork both call it, on the page,
|
|
1740
|
+
// before it merges), so this is where the page learns what is already running.
|
|
1741
|
+
for (var p = 0; p < pendingIncoming.length; p++) {
|
|
1742
|
+
var pi: any = pendingIncoming[p];
|
|
1743
|
+
if (!pi._streamPending || !pi._serverItemId) continue;
|
|
1744
|
+
var phase = this._recoveryPhaseFor(pi._serverItemId);
|
|
1745
|
+
if (phase === null) delete pi._streamRecovery; else pi._streamRecovery = phase;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* This session's fetching state for one turn, from the bookkeeping rather than
|
|
1751
|
+
* from any bubble. A queued entry counts as 'active': it is committed to be read,
|
|
1752
|
+
* serially, and the reader has no way to tell "being read" from "next in line"
|
|
1753
|
+
* apart from the wait.
|
|
1754
|
+
*/
|
|
1755
|
+
private _recoveryPhaseFor(itemId: string): 'active' | 'failed' | null {
|
|
1756
|
+
var rec = this._rec();
|
|
1757
|
+
if (rec.inflight[itemId]) return 'active';
|
|
1758
|
+
for (var i = 0; i < rec.queue.length; i++) if (rec.queue[i].id === itemId) return 'active';
|
|
1759
|
+
if (rec.failed[itemId]) return 'failed';
|
|
1760
|
+
return null;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* PUBLIC DELEGATE, for a client that maps and merges its own history page.
|
|
1765
|
+
*
|
|
1766
|
+
* agent.vue keeps a forked mapper and a forked first-page merge (its mount path
|
|
1767
|
+
* runs them, while resumePolling routes through loadHistory below), so both
|
|
1768
|
+
* paths are live for the SAME row inside one component. Adoption is part of the
|
|
1769
|
+
* merge contract, not an optional extra: without it that fork erases a streamed
|
|
1770
|
+
* answer off the screen on every turn, which is the whole of MAJOR 3.
|
|
1771
|
+
*
|
|
1772
|
+
* Exposed rather than reimplemented because the rule needs the session's own
|
|
1773
|
+
* `incomplete` set, which the pure helper (history.ts adoptLocalAnswerIntoPage)
|
|
1774
|
+
* cannot see. A client that reached for the helper alone would adopt a TRUNCATED
|
|
1775
|
+
* answer over the row and clear the marker that would have gone back for the
|
|
1776
|
+
* rest - a fork that reads as correct and loses text.
|
|
1777
|
+
*
|
|
1778
|
+
* Call it exactly where loadHistory does: on the freshly mapped page, after
|
|
1779
|
+
* applyHydratedBodies and BEFORE the page replaces or merges into state.messages.
|
|
1780
|
+
*/
|
|
1781
|
+
adoptLocalAnswers(mapped: ChatMessage[], loadKey?: string): void {
|
|
1782
|
+
this._adoptLocalAnswers(mapped, loadKey);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Queue the on-screen turns whose answer is only in the chunk store, newest
|
|
1787
|
+
* first, and start draining. Never blocks and never throws.
|
|
1788
|
+
*
|
|
1789
|
+
* `ownerKey` is the chat the queue entries belong to, snapshotted by the caller:
|
|
1790
|
+
* a recovery that lands after the user has moved on writes into that chat's
|
|
1791
|
+
* cache, never into whatever list is on screen by then.
|
|
1792
|
+
*/
|
|
1793
|
+
private _scheduleStreamRecovery(ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string): void {
|
|
1794
|
+
if (!streamRecoveryEnabled()) return;
|
|
1795
|
+
var rec = this._rec();
|
|
1796
|
+
var wanted: string[] = [];
|
|
1797
|
+
for (var i = this.state.messages.length - 1; i >= 0; i--) {
|
|
1798
|
+
var m = this.state.messages[i];
|
|
1799
|
+
if (!m || m.role !== 'assistant' || !(m as any)._streamPending || !m._serverItemId) continue;
|
|
1800
|
+
var id = m._serverItemId;
|
|
1801
|
+
if (rec.attempted[id]) continue;
|
|
1802
|
+
// A turn with a live stream attached is being delivered right now; reading
|
|
1803
|
+
// its chunks in parallel would spend a second full read to arrive at the
|
|
1804
|
+
// same answer the poll is already assembling.
|
|
1805
|
+
if (this.liveStreams[id]) continue;
|
|
1806
|
+
if (rec.queue.some(function (e) { return e.id === id; })) continue;
|
|
1807
|
+
wanted.push(id);
|
|
1808
|
+
if (wanted.length >= STREAM_RECOVERY_PER_LOAD) break;
|
|
1809
|
+
}
|
|
1810
|
+
if (!wanted.length) return;
|
|
1811
|
+
for (var w = 0; w < wanted.length; w++) {
|
|
1812
|
+
rec.queue.push({ id: wanted[w], ownerKey: ownerKey, platform: platform, projectId: projectId, owner: owner });
|
|
1813
|
+
// Marked as the queue is built, NOT as each read starts. Everything above
|
|
1814
|
+
// the cap is committed from this moment and its bubble may honestly spin;
|
|
1815
|
+
// everything the cap left behind is marked and has nobody, and its bubble
|
|
1816
|
+
// must say so rather than spin on a promise this scheduler never made.
|
|
1817
|
+
this._markRecoveryPhase(wanted[w], 'active');
|
|
1818
|
+
}
|
|
1819
|
+
this._drainStreamRecovery();
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* PUBLIC DELEGATE, the other half of what a forked history path needs.
|
|
1824
|
+
*
|
|
1825
|
+
* Same reason as adoptLocalAnswers: agent.vue's mount path never calls
|
|
1826
|
+
* loadHistory, so without this its pages would MARK unfinalized streamed turns
|
|
1827
|
+
* and then never read them back - CRITICAL 1 left unfixed on the client's
|
|
1828
|
+
* primary path, with the marker making it look handled.
|
|
1829
|
+
*
|
|
1830
|
+
* Takes the load's SNAPSHOTTED identity rather than reading it live, and that is
|
|
1831
|
+
* the reason this exists instead of the caller looping over recoverStreamedAnswer:
|
|
1832
|
+
* that one reads getIdentity() at call time (right, for an on-demand affordance
|
|
1833
|
+
* the user just clicked), which after a project switch racing the load would
|
|
1834
|
+
* finalize the turn against the project they switched TO. Call it AFTER the page
|
|
1835
|
+
* is rendered and the loading flags are cleared - it must never hold up the
|
|
1836
|
+
* conversation it belongs to.
|
|
1837
|
+
*/
|
|
1838
|
+
scheduleStreamRecovery(ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string): void {
|
|
1839
|
+
this._scheduleStreamRecovery(ownerKey, platform, projectId, owner);
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/** Serial drain of the recovery queue. Each entry is one full chunk read. */
|
|
1843
|
+
private _drainStreamRecovery(): void {
|
|
1844
|
+
var rec = this._rec();
|
|
1845
|
+
if (rec.running) return;
|
|
1846
|
+
var next = rec.queue.shift();
|
|
1847
|
+
if (!next) return;
|
|
1848
|
+
rec.running = true;
|
|
1849
|
+
var self = this;
|
|
1850
|
+
this._readBackStreamedTurn(next.id, next.ownerKey, next.platform, next.projectId, next.owner)
|
|
1851
|
+
.catch(function () { /* every failure is already handled inside */ })
|
|
1852
|
+
.then(function () {
|
|
1853
|
+
self._rec().running = false;
|
|
1854
|
+
self._drainStreamRecovery();
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Read one unfinalized streamed turn back out of the chunk store and put its
|
|
1860
|
+
* answer where the turn's answer belongs.
|
|
1861
|
+
*
|
|
1862
|
+
* Public because the cap above is deliberately small: a host that wants to offer
|
|
1863
|
+
* "load the rest" on an older recoverable turn calls this with its
|
|
1864
|
+
* `_serverItemId`, and gets the same path the automatic recovery uses. Safe to
|
|
1865
|
+
* call for an id that turns out not to be recoverable, and safe to call twice -
|
|
1866
|
+
* a second call while the first is still in flight is a no-op.
|
|
1867
|
+
*
|
|
1868
|
+
* THIS IS THE USER ASKING, and that is why it passes `manual`. The automatic
|
|
1869
|
+
* recovery refuses a row it has already tried, so that a re-render, or the
|
|
1870
|
+
* history load that every visibilitychange fires, cannot loop on the same
|
|
1871
|
+
* chunks. A click is neither of those: it is one bounded request that a person
|
|
1872
|
+
* asked for, and applying the loop guard to it made the affordance a button that
|
|
1873
|
+
* silently did nothing for exactly the rows most likely to have it - every row
|
|
1874
|
+
* an earlier read touched and could not settle.
|
|
1875
|
+
*/
|
|
1876
|
+
recoverStreamedAnswer(itemId: string): Promise<void> {
|
|
1877
|
+
if (!itemId) return Promise.resolve();
|
|
1878
|
+
var id = this.host.getIdentity();
|
|
1879
|
+
var platform = id && id.platform === 'openai' ? 'openai' : 'claude';
|
|
1880
|
+
return this._readBackStreamedTurn(itemId, this.getHistoryCacheKey(), platform as 'claude' | 'openai', id ? id.projectId : '', id ? id.owner : '', true);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
private _readBackStreamedTurn(itemId: string, ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string, manual?: boolean): Promise<void> {
|
|
1884
|
+
var cfg = chatEngineConfig();
|
|
1885
|
+
var read = cfg.clientSecretRequestStream;
|
|
1886
|
+
if (!read || !itemId) return Promise.resolve();
|
|
1887
|
+
// IN FLIGHT is the only refusal a user-driven retry accepts, and it is about
|
|
1888
|
+
// this request rather than about the history of the row: two reads of the same
|
|
1889
|
+
// chunks at once spend a second full read to arrive at the same answer.
|
|
1890
|
+
if (this._rec().inflight[itemId]) return Promise.resolve();
|
|
1891
|
+
if (!manual && this._rec().attempted[itemId]) {
|
|
1892
|
+
// Refused, so nothing is fetching this after all. The scheduler filters
|
|
1893
|
+
// attempted ids out before it queues them, so reaching here means the row
|
|
1894
|
+
// was attempted AFTER being queued and its bubble is already wearing the
|
|
1895
|
+
// 'active' it was promised - which nothing would ever take off again.
|
|
1896
|
+
this._markRecoveryPhase(itemId, null);
|
|
1897
|
+
return Promise.resolve();
|
|
1898
|
+
}
|
|
1899
|
+
// Marked BEFORE the request, not after: a second load firing while this one is
|
|
1900
|
+
// in flight must not start a second read of the same chunks.
|
|
1901
|
+
this._rec().attempted[itemId] = true;
|
|
1902
|
+
this._rec().inflight[itemId] = true;
|
|
1903
|
+
// A retry is not still-failed. Cleared before the request so the bubble stops
|
|
1904
|
+
// offering "try again" the instant it is being tried.
|
|
1905
|
+
delete this._rec().failed[itemId];
|
|
1906
|
+
this._markRecoveryPhase(itemId, 'active');
|
|
1907
|
+
var self = this;
|
|
1908
|
+
var url = platform === 'openai' ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
|
|
1909
|
+
// A parser of its own, fed from seq 0. Deliberately NOT registered in
|
|
1910
|
+
// liveStreams: this turn is over, nothing will paint into it, and a live
|
|
1911
|
+
// stream entry would make _beginLiveStream discard a genuinely live one that
|
|
1912
|
+
// happened to share the id after an auth-refresh retry.
|
|
1913
|
+
var parser = createSseParser();
|
|
1914
|
+
var fed = false;
|
|
1915
|
+
return Promise.resolve(read(itemId, {
|
|
1916
|
+
url: url, method: 'POST', service: projectId, owner: owner, since: 0,
|
|
1917
|
+
onStream: function (chunk: string) {
|
|
1918
|
+
if (typeof chunk !== 'string' || !chunk) return;
|
|
1919
|
+
fed = true;
|
|
1920
|
+
parser.feed(chunk);
|
|
1921
|
+
},
|
|
1922
|
+
})).then(function (res: any) {
|
|
1923
|
+
// A STOPPED READ IS NOT AN ANSWER, and it is not an envelope either, which
|
|
1924
|
+
// is exactly how it used to be mistaken for one. skapi's stop resolves the
|
|
1925
|
+
// read with a frozen { id, status: 'stopped' } - no in_queue, so
|
|
1926
|
+
// isCsrStatusEnvelope says no - and "not an envelope" was taken to mean
|
|
1927
|
+
// "the stored body". That object extracts to no text, so the turn was read
|
|
1928
|
+
// as "there was nothing here", its bubble was DELETED and its marker with
|
|
1929
|
+
// it, and the answer became unreachable because a read was cancelled. A
|
|
1930
|
+
// stop says nothing whatsoever about the turn: the chunks are untouched and
|
|
1931
|
+
// the row is still unfinalized, so the only correct move is to change
|
|
1932
|
+
// nothing and stay recoverable. The attempt is forgotten so the next
|
|
1933
|
+
// history load can try again.
|
|
1934
|
+
if (isPollStopped(res)) {
|
|
1935
|
+
delete self._rec().attempted[itemId];
|
|
1936
|
+
delete self._rec().inflight[itemId];
|
|
1937
|
+
// Back to 'idle', not to 'failed': nothing failed and nothing is coming.
|
|
1938
|
+
// The bubble stops spinning and starts offering to be asked again, which
|
|
1939
|
+
// is the truthful account of a read somebody stopped.
|
|
1940
|
+
self._markRecoveryPhase(itemId, null);
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
// The SDK resolves with a STORED BODY when the row turned out to be
|
|
1944
|
+
// finalized after all (somebody else's tab got there first), and with a
|
|
1945
|
+
// status envelope when it replayed chunks. Both are usable: the stored body
|
|
1946
|
+
// is the answer, the replay's assembled body is the answer.
|
|
1947
|
+
var envelope = isCsrStatusEnvelope(res);
|
|
1948
|
+
var body: any = null;
|
|
1949
|
+
if (res && !envelope) {
|
|
1950
|
+
body = res;
|
|
1951
|
+
} else if (fed) {
|
|
1952
|
+
parser.end();
|
|
1953
|
+
body = parser.finalBody();
|
|
1954
|
+
}
|
|
1955
|
+
var snap = parser.snapshot();
|
|
1956
|
+
// A body that came off the ROW is already stored, so re-storing it would be
|
|
1957
|
+
// a round trip that changes nothing; only an assembled one is new.
|
|
1958
|
+
var fromRow = !!(res && !envelope);
|
|
1959
|
+
// THE SAME KEEP POLICY THE LIVE SETTLE USES, not a second computation of
|
|
1960
|
+
// it. This used to ask parse completeness alone, so a row the live path
|
|
1961
|
+
// would never have finalized - one the destination FAILED, one killed by an
|
|
1962
|
+
// error frame - was finalized here instead, releasing precisely the chunks
|
|
1963
|
+
// that policy exists to keep. The row's own status is handed over with the
|
|
1964
|
+
// bytes: a replay of a failed row comes back as a 'failed' envelope, and
|
|
1965
|
+
// that is the authoritative account of the turn.
|
|
1966
|
+
var rowStatus = envelope && typeof res.status === 'string' ? res.status : undefined;
|
|
1967
|
+
// A DEGRADED READ IS NOT A FACT ABOUT THE TURN. csr-poll answers `more: true`
|
|
1968
|
+
// when a cap, or a transient chunk-table error, stopped the read short of the
|
|
1969
|
+
// end of the partition, which is the throttle shape this repo has already paid
|
|
1970
|
+
// for once. What came back is then a prefix of the answer, or nothing at all,
|
|
1971
|
+
// and neither says anything about what the turn actually contains. Treated as
|
|
1972
|
+
// an answer it is silent data loss twice over: an empty degraded read spliced
|
|
1973
|
+
// the bubble out as "this turn was empty", and a partial one cleared the
|
|
1974
|
+
// marker, so the truncation was adopted as the turn's answer and nothing ever
|
|
1975
|
+
// went back for the rest. `store` is already safe here (an unterminated parse
|
|
1976
|
+
// fails mayKeepStreamedAnswer), so this only has to keep the turn RECOVERABLE.
|
|
1977
|
+
var degraded = !!(envelope && res && res.more === true);
|
|
1978
|
+
var store = !fromRow && !degraded && body != null && mayKeepStreamedAnswer(snap, rowStatus);
|
|
1979
|
+
// The read is over however it settles below. Cleared BEFORE the apply, which
|
|
1980
|
+
// usually replaces the bubble outright: leaving it set would strand 'active'
|
|
1981
|
+
// on the one case the apply cannot touch (the reader has moved to another
|
|
1982
|
+
// chat), and that bubble would spin on its owner's next visit.
|
|
1983
|
+
delete self._rec().inflight[itemId];
|
|
1984
|
+
delete self._rec().failed[itemId];
|
|
1985
|
+
self._markRecoveryPhase(itemId, null);
|
|
1986
|
+
if (degraded) {
|
|
1987
|
+
// Forget the attempt so the next history load queues this row again,
|
|
1988
|
+
// exactly as the read-failed arm below does. Same reason: nothing
|
|
1989
|
+
// conclusive is known, so nothing may be made permanent.
|
|
1990
|
+
delete self._rec().attempted[itemId];
|
|
1991
|
+
}
|
|
1992
|
+
self._applyRecoveredAnswer(itemId, ownerKey, platform, projectId, owner, body, store, degraded);
|
|
1993
|
+
}, function (err: any) {
|
|
1994
|
+
// THE READ ITSELF FAILED, which is a different fact from "I read it and
|
|
1995
|
+
// there was nothing there", and the two must not settle the same way. This
|
|
1996
|
+
// used to clear the marker, which left the bubble empty on screen with
|
|
1997
|
+
// nothing on it for the recovery to find again: the answer was unreachable
|
|
1998
|
+
// for the rest of the session because one request 500'd. Nothing is known,
|
|
1999
|
+
// so nothing is changed - the CHUNKS are untouched, the row is still
|
|
2000
|
+
// unfinalized, the marker stays on and keeps rendering as "being fetched",
|
|
2001
|
+
// and the attempt is forgotten so the next history load queues it again.
|
|
2002
|
+
// (The "there was nothing there" case is _applyRecoveredAnswer's, and it
|
|
2003
|
+
// still drops the bubble, because an empty row really does mean an empty
|
|
2004
|
+
// turn.)
|
|
2005
|
+
//
|
|
2006
|
+
// What DID change is who is fetching it: nobody. Recorded so the view can
|
|
2007
|
+
// say "could not load this answer, try again" instead of drawing the same
|
|
2008
|
+
// loader a live turn draws - a spinner with nothing behind it is a promise
|
|
2009
|
+
// the client cannot keep, and this one used to be kept up for the rest of
|
|
2010
|
+
// the session.
|
|
2011
|
+
console.warn('[chat-engine] could not read back a streamed turn', itemId, err);
|
|
2012
|
+
delete self._rec().attempted[itemId];
|
|
2013
|
+
delete self._rec().inflight[itemId];
|
|
2014
|
+
self._rec().failed[itemId] = true;
|
|
2015
|
+
self._markRecoveryPhase(itemId, 'failed');
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* Write a recovered answer into the turn's bubble (or into the owning chat's
|
|
2021
|
+
* cache when the reader has moved on), then store it as the version history
|
|
2022
|
+
* keeps.
|
|
2023
|
+
*
|
|
2024
|
+
* FINALIZING IS WHAT MAKES THIS RUN ONCE. It copies the answer onto the row and
|
|
2025
|
+
* releases the chunks, so the next load reads an ordinary turn and no recovery is
|
|
2026
|
+
* scheduled for it ever again, by anyone, in any tab. `store` is the caller's
|
|
2027
|
+
* decision and carries two gates at once: mayKeepStreamedAnswer, the SAME keep
|
|
2028
|
+
* policy the live settle applies (an incomplete, errored or failed read is shown
|
|
2029
|
+
* but never stored, because storing it would make the truncation permanent and
|
|
2030
|
+
* delete the part that was missing), and whether the body is new at all (one
|
|
2031
|
+
* that came off the row is already stored).
|
|
2032
|
+
*/
|
|
2033
|
+
private _applyRecoveredAnswer(
|
|
2034
|
+
itemId: string, ownerKey: string, platform: 'claude' | 'openai',
|
|
2035
|
+
projectId: string, owner: string, body: any, store: boolean, degraded?: boolean,
|
|
2036
|
+
): void {
|
|
2037
|
+
var text = '';
|
|
2038
|
+
var isErr = isErrorResponseBody(body);
|
|
2039
|
+
if (body != null && !isErr) {
|
|
2040
|
+
text = ((platform === 'openai' ? extractOpenAIText(body) : extractClaudeText(body)) || '').trim();
|
|
2041
|
+
}
|
|
2042
|
+
if (!text && !isErr) {
|
|
2043
|
+
if (degraded) {
|
|
2044
|
+
// The read was cut short, so "no text" is a fact about the READ and not
|
|
2045
|
+
// about the turn. Dropping the bubble here would delete an answer whose
|
|
2046
|
+
// every byte is still sitting in the chunk table. Change nothing and stay
|
|
2047
|
+
// recoverable, exactly as a read that threw does.
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
// Nothing there. Either the row never streamed after all, or its chunks are
|
|
2051
|
+
// genuinely empty. Drop the marker and let the bubble go, which leaves the
|
|
2052
|
+
// list looking exactly as it did before any of this existed.
|
|
2053
|
+
this._clearStreamPendingMark(itemId, ownerKey, true);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
var reply: ChatMessage = isErr
|
|
2057
|
+
? { role: 'assistant', content: getErrorMessage(body), isError: true, _serverItemId: itemId }
|
|
2058
|
+
: { role: 'assistant', content: text, _serverItemId: itemId };
|
|
2059
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
2060
|
+
// The reader is in another chat. Settle it where it belongs and touch
|
|
2061
|
+
// nothing on screen - state.messages is a different conversation's list.
|
|
2062
|
+
this._applyReplyToCache(ownerKey, reply, itemId);
|
|
2063
|
+
} else {
|
|
2064
|
+
var idx = -1;
|
|
2065
|
+
for (var i = 0; i < this.state.messages.length; i++) {
|
|
2066
|
+
var m = this.state.messages[i];
|
|
2067
|
+
if (m && m.role === 'assistant' && m._serverItemId === itemId) { idx = i; break; }
|
|
2068
|
+
}
|
|
2069
|
+
if (idx === -1) {
|
|
2070
|
+
// The bubble is gone (a clear, a project switch that replaced the list).
|
|
2071
|
+
// The cache is still the right place for it.
|
|
2072
|
+
this._applyReplyToCache(ownerKey, reply, itemId);
|
|
2073
|
+
} else {
|
|
2074
|
+
var prev = this.state.messages[idx];
|
|
2075
|
+
if (prev._ts !== undefined) reply._ts = prev._ts;
|
|
2076
|
+
if (prev._ownerKey !== undefined) reply._ownerKey = prev._ownerKey;
|
|
2077
|
+
this.state.messages[idx] = reply;
|
|
2078
|
+
this.updateHistoryCache();
|
|
2079
|
+
this.host.notify();
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
// The marker comes off for an INCOMPLETE read, but NOT for a DEGRADED one, and
|
|
2083
|
+
// the difference is which side the incompleteness is on. A stream that genuinely
|
|
2084
|
+
// died mid-answer has chunks that will be incomplete forever, so leaving the
|
|
2085
|
+
// marker on would re-read the whole thing on every tab return to arrive at the
|
|
2086
|
+
// same partial: what the reader has is what there is. A DEGRADED read is the
|
|
2087
|
+
// other case entirely, the READER stopped early and the rest of the answer is
|
|
2088
|
+
// still in the chunk table, so clearing here would show a truncation, let
|
|
2089
|
+
// _adoptLocalAnswers carry it forward as the turn's answer, and leave nothing
|
|
2090
|
+
// to go back for the missing part.
|
|
2091
|
+
if (!degraded) delete this._rec().incomplete[itemId];
|
|
2092
|
+
if (!store || body == null || isErr) return;
|
|
2093
|
+
var fin = chatEngineConfig().clientSecretRequestFinalize;
|
|
2094
|
+
if (!fin) return;
|
|
2095
|
+
var url = platform === 'openai' ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
|
|
2096
|
+
try {
|
|
2097
|
+
Promise.resolve(fin(itemId, body, { url: url, method: 'POST', service: projectId, owner: owner }))
|
|
2098
|
+
.catch(function (err: any) {
|
|
2099
|
+
// The answer is on screen and in the cache; only the chunks are left
|
|
2100
|
+
// behind, and the row stays re-readable, which is what they are for.
|
|
2101
|
+
console.warn('[chat-engine] finalize of a recovered turn failed', err);
|
|
2102
|
+
});
|
|
2103
|
+
} catch (e) {
|
|
2104
|
+
console.warn('[chat-engine] finalize of a recovered turn threw', e);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Take the "answer is elsewhere" marker off a turn once it is settled one way or
|
|
2110
|
+
* the other. `drop` removes an assistant bubble that turned out to have no answer
|
|
2111
|
+
* at all, which restores exactly the list the mapper used to produce for such a
|
|
2112
|
+
* row (none), rather than leaving a permanently empty bubble behind.
|
|
2113
|
+
*
|
|
2114
|
+
* ONLY EVER CALLED FOR A TURN THAT WAS ACTUALLY READ. The marker is the one thing
|
|
2115
|
+
* that keeps an unrecovered answer reachable, so it comes off only on the strength
|
|
2116
|
+
* of an answer (the recovery wrote one) or of a read that came back empty. A read
|
|
2117
|
+
* that FAILED, or one that was STOPPED, knows neither, and taking the marker off
|
|
2118
|
+
* on either of those is how a bubble ends up empty forever with its answer still
|
|
2119
|
+
* in the chunk table. `drop` is likewise never passed for a bubble that HAS
|
|
2120
|
+
* content: an empty row is an empty turn, a failed read is not.
|
|
2121
|
+
*/
|
|
2122
|
+
private _clearStreamPendingMark(itemId: string, ownerKey: string, drop: boolean): void {
|
|
2123
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) return;
|
|
2124
|
+
var changed = false;
|
|
2125
|
+
for (var i = this.state.messages.length - 1; i >= 0; i--) {
|
|
2126
|
+
var m = this.state.messages[i];
|
|
2127
|
+
if (!m || m.role !== 'assistant' || m._serverItemId !== itemId) continue;
|
|
2128
|
+
if (!(m as any)._streamPending) continue;
|
|
2129
|
+
if (drop && !m.content) { this.state.messages.splice(i, 1); changed = true; continue; }
|
|
2130
|
+
(m as any)._streamPending = false;
|
|
2131
|
+
changed = true;
|
|
2132
|
+
}
|
|
2133
|
+
if (changed) { this.updateHistoryCache(); this.host.notify(); }
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
|
|
720
2137
|
/**
|
|
721
2138
|
* Stop and forget one item's poll. Used after a cancel: the row is either gone
|
|
722
2139
|
* (cancelled while queued) or flagged cancelled (cancelled while running), so
|
|
@@ -1107,7 +2524,13 @@ export class ChatSession {
|
|
|
1107
2524
|
// so this reports every time, not once.
|
|
1108
2525
|
if (typeof params.onItemId === 'function') params.onItemId(initial.id);
|
|
1109
2526
|
}
|
|
1110
|
-
|
|
2527
|
+
// The turn's OWN identity, not a live read: this dispatch already
|
|
2528
|
+
// pinned projectId/owner (see _callProviderFor) and the ack can land
|
|
2529
|
+
// after the user has moved elsewhere. params.key is that chat's
|
|
2530
|
+
// history cache key, which is what the painter compares against.
|
|
2531
|
+
var dp = self._fgPollWithEarlyProbe(initial, initial.id, undefined, {
|
|
2532
|
+
platform: params.aiPlatform, projectId: params.projectId, owner: params.owner, ownerKey: params.key,
|
|
2533
|
+
});
|
|
1111
2534
|
if (initial.id) self._trackPoll(initial.id, 'fg', dp);
|
|
1112
2535
|
return dp;
|
|
1113
2536
|
}
|
|
@@ -1631,7 +3054,13 @@ export class ChatSession {
|
|
|
1631
3054
|
if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
|
|
1632
3055
|
// Track this queued item's poll so a remount/refetch dedups
|
|
1633
3056
|
// against it instead of attaching a duplicate history poll.
|
|
1634
|
-
|
|
3057
|
+
// Pinned exactly like the request itself (id.projectId/id.owner were
|
|
3058
|
+
// snapshotted before the send, capturedKey before the ack): the user
|
|
3059
|
+
// can switch project inside this round trip, and a live identity read
|
|
3060
|
+
// here would finalize this turn against the project they switched TO.
|
|
3061
|
+
var qp = self._fgPollWithEarlyProbe(result, serverId, undefined, {
|
|
3062
|
+
platform: capturedPlatform, projectId: id.projectId, owner: id.owner, ownerKey: capturedKey,
|
|
3063
|
+
});
|
|
1635
3064
|
if (serverId) self._trackPoll(serverId, 'fg', qp);
|
|
1636
3065
|
return qp
|
|
1637
3066
|
.then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
|
|
@@ -1976,8 +3405,19 @@ export class ChatSession {
|
|
|
1976
3405
|
answer = (answer || '').trim() || 'No text response received from AI provider.';
|
|
1977
3406
|
var lid = this._newLocalId();
|
|
1978
3407
|
if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
|
|
1979
|
-
|
|
1980
|
-
|
|
3408
|
+
// Same resume as the immediate-send path: keep what the stream painted
|
|
3409
|
+
// and let the typewriter carry on from where the two texts diverge.
|
|
3410
|
+
var qPainted = this._paintedTextAt(targetIdx);
|
|
3411
|
+
// Same identity carry-over as the immediate-send settle above, for the same
|
|
3412
|
+
// reason: a rebuilt bubble with no `_serverItemId` is invisible to
|
|
3413
|
+
// _adoptLocalAnswers, so a refetch in the finalize window adopts the row's
|
|
3414
|
+
// empty answer over what the reader already read.
|
|
3415
|
+
var prevQ: any = this.state.messages[targetIdx] || {};
|
|
3416
|
+
var qSettled: any = { role: 'assistant', content: qPainted, _localId: lid };
|
|
3417
|
+
if (prevQ._serverItemId) qSettled._serverItemId = prevQ._serverItemId;
|
|
3418
|
+
if (prevQ._ownerKey) qSettled._ownerKey = prevQ._ownerKey;
|
|
3419
|
+
this.state.messages[targetIdx] = qSettled;
|
|
3420
|
+
this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid, qPainted);
|
|
1981
3421
|
} else if (targetIdx >= 0) {
|
|
1982
3422
|
this.state.messages.splice(targetIdx, 0, { role: 'assistant', content: '', _localId: lid });
|
|
1983
3423
|
this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
|
|
@@ -2265,7 +3705,14 @@ export class ChatSession {
|
|
|
2265
3705
|
// renders self-throttles to what the machine can actually paint.
|
|
2266
3706
|
// * rAF paces us to the browser's paint cycle and pauses in background
|
|
2267
3707
|
// tabs, so we never queue work faster than it can be drawn.
|
|
2268
|
-
|
|
3708
|
+
//
|
|
3709
|
+
// `paintedText` is what a LIVE STREAM already put in this bubble. The reveal
|
|
3710
|
+
// starts from the point the two texts stop agreeing rather than from zero: the
|
|
3711
|
+
// authoritative answer still replaces the live one character for character (it is
|
|
3712
|
+
// the only source of truth, and this method writes fullText and nothing else), but
|
|
3713
|
+
// retyping a paragraph the reader has just watched arrive is the one thing that
|
|
3714
|
+
// would make a streamed turn look worse than an unstreamed one.
|
|
3715
|
+
typewriteIntoIndex(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<void> {
|
|
2269
3716
|
var self = this;
|
|
2270
3717
|
if (!fullText) return Promise.resolve();
|
|
2271
3718
|
|
|
@@ -2286,7 +3733,10 @@ export class ChatSession {
|
|
|
2286
3733
|
regions.sort(function (a, b) { return a.start - b.start; });
|
|
2287
3734
|
|
|
2288
3735
|
this.state.typing = true; this.state.typingAbort = false;
|
|
2289
|
-
|
|
3736
|
+
// Resume point, computed against the SAME regions the reveal below honours, so
|
|
3737
|
+
// a resume landing inside a link or a fence starts at that region's end and the
|
|
3738
|
+
// region is never on screen half-revealed.
|
|
3739
|
+
var i = paintedText ? typewriterResumeIndex(paintedText, fullText, regions) : 0;
|
|
2290
3740
|
var last = nowMs();
|
|
2291
3741
|
|
|
2292
3742
|
return new Promise<void>(function (resolve) {
|
|
@@ -2300,16 +3750,31 @@ export class ChatSession {
|
|
|
2300
3750
|
if (done) return;
|
|
2301
3751
|
done = true;
|
|
2302
3752
|
cleanup();
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
3753
|
+
// THE AUTHORITATIVE TEXT ALWAYS WINS, ABORT OR NOT. This write used to
|
|
3754
|
+
// be skipped on abort, which is a rule that made sense when the bubble
|
|
3755
|
+
// held a PREFIX of fullText: stopping early left the reader with less
|
|
3756
|
+
// of the right answer, and the list was about to be replaced anyway.
|
|
3757
|
+
// Live streaming broke that premise. The bubble now starts out holding
|
|
3758
|
+
// the text the STREAM painted, which is a different string from the
|
|
3759
|
+
// authoritative one (the parser's untrimmed render feed, cut at a safe
|
|
3760
|
+
// reveal boundary, possibly a truncation of a degraded read), and
|
|
3761
|
+
// skipping the write leaves THAT as the turn's rendered answer. An
|
|
3762
|
+
// abort is a reason to stop animating; it is never a reason to keep the
|
|
3763
|
+
// provisional text over the settled one.
|
|
3764
|
+
//
|
|
3765
|
+
// Re-find the bubble by localId and, if it's gone, bail WITHOUT
|
|
3766
|
+
// writing, and never fall back to the numeric idx, which a concurrent
|
|
3767
|
+
// array mutation may have repurposed for an unrelated message
|
|
3768
|
+
// (mirrors frame()'s currentIdx===-1 bail below). That bail is what
|
|
3769
|
+
// keeps this safe on the two paths that abort: a history page that
|
|
3770
|
+
// REPLACED the list has already dropped this bubble (and any copy of it
|
|
3771
|
+
// the merge adopted carries the same _localId, which is precisely the
|
|
3772
|
+
// bubble that should receive the answer), and a view teardown has no
|
|
3773
|
+
// bubble on screen to disturb.
|
|
3774
|
+
var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
|
|
3775
|
+
if (fi !== -1) {
|
|
3776
|
+
var t = self.state.messages[fi];
|
|
3777
|
+
if (t) { t.content = fullText; self.host.refreshMessageBubble(fi); }
|
|
2313
3778
|
}
|
|
2314
3779
|
self.state.typing = false;
|
|
2315
3780
|
resolve();
|
|
@@ -2360,7 +3825,7 @@ export class ChatSession {
|
|
|
2360
3825
|
}
|
|
2361
3826
|
|
|
2362
3827
|
private typewriterQueue: Promise<any> = Promise.resolve();
|
|
2363
|
-
enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
|
|
3828
|
+
enqueueTypewrite(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<any> {
|
|
2364
3829
|
var self = this;
|
|
2365
3830
|
// Stamp the reply bubble's display time as it starts revealing. This is the
|
|
2366
3831
|
// single chokepoint every typed reply (immediate, queued, and bg-resolution)
|
|
@@ -2368,7 +3833,14 @@ export class ChatSession {
|
|
|
2368
3833
|
// timestamp; a history reload later replaces it with the server `updated`.
|
|
2369
3834
|
var target = this.state.messages[idx];
|
|
2370
3835
|
if (target && target._ts === undefined) target._ts = wallClockNow();
|
|
2371
|
-
|
|
3836
|
+
// SELF-HEALING, because this is now reached from the live paint path too. The
|
|
3837
|
+
// queue is a class field, so it is only initialised by the constructor; any
|
|
3838
|
+
// caller holding a ChatSession built another way (the engine tests drive
|
|
3839
|
+
// Object.create(ChatSession.prototype) deliberately, to exercise one method
|
|
3840
|
+
// without a host) previously never touched it, and now does. An uninitialised
|
|
3841
|
+
// queue would throw here and take the whole answer down over bookkeeping.
|
|
3842
|
+
if (!this.typewriterQueue) this.typewriterQueue = Promise.resolve();
|
|
3843
|
+
this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId, paintedText); });
|
|
2372
3844
|
return this.typewriterQueue;
|
|
2373
3845
|
}
|
|
2374
3846
|
|
|
@@ -2398,12 +3870,32 @@ export class ChatSession {
|
|
|
2398
3870
|
this.promoteNextQueuedToRunning();
|
|
2399
3871
|
return Promise.resolve();
|
|
2400
3872
|
}
|
|
3873
|
+
// Whatever the live stream painted into this bubble stays on screen across the
|
|
3874
|
+
// swap and becomes the typewriter's starting point. Blanking it here would be a
|
|
3875
|
+
// visible reset of an answer the reader already read: the bubble would go empty
|
|
3876
|
+
// and retype itself, and worse, it can sit empty for a while, because
|
|
3877
|
+
// enqueueTypewrite lands BEHIND any typewriter still running.
|
|
3878
|
+
var painted = this._paintedTextAt(pendingIdx);
|
|
2401
3879
|
var lid = this._newLocalId();
|
|
2402
|
-
|
|
3880
|
+
// CARRY THE IDENTITY FIELDS ACROSS THE REBUILD. This replaces the bubble object
|
|
3881
|
+
// wholesale, and a fresh literal has neither `_serverItemId` nor `_ownerKey`. That
|
|
3882
|
+
// is not cosmetic on a streamed turn: `_adoptLocalAnswers` finds the local donor
|
|
3883
|
+
// for an incoming server copy BY `_serverItemId`, and a streamed row settles with
|
|
3884
|
+
// no body of its own, so a first-page refetch landing between the settle and
|
|
3885
|
+
// csr-finalize finds no donor, adopts the server's empty answer over the text the
|
|
3886
|
+
// reader is looking at, and the answer disappears off the screen. That race is the
|
|
3887
|
+
// whole reason adoptLocalAnswerIntoPage exists, and dropping the id here is what
|
|
3888
|
+
// re-opened it. `_ownerKey` goes with it so the settled bubble stays stamped to
|
|
3889
|
+
// this chat instead of waiting for the next history load to restamp it.
|
|
3890
|
+
var prevSettled: any = this.state.messages[pendingIdx] || {};
|
|
3891
|
+
var settled: any = { role: 'assistant', content: painted, isPending: false, _localId: lid };
|
|
3892
|
+
if (prevSettled._serverItemId) settled._serverItemId = prevSettled._serverItemId;
|
|
3893
|
+
if (prevSettled._ownerKey) settled._ownerKey = prevSettled._ownerKey;
|
|
3894
|
+
this.state.messages[pendingIdx] = settled;
|
|
2403
3895
|
this._removeStrayPendingAssistants();
|
|
2404
3896
|
this.host.notify();
|
|
2405
3897
|
this.promoteNextQueuedToRunning();
|
|
2406
|
-
return this.enqueueTypewrite(pendingIdx, latest.content, lid);
|
|
3898
|
+
return this.enqueueTypewrite(pendingIdx, latest.content, lid, painted);
|
|
2407
3899
|
}
|
|
2408
3900
|
|
|
2409
3901
|
// Remove leftover non-background pending ("Thinking…") assistant bubbles: the
|
|
@@ -2434,6 +3926,13 @@ export class ChatSession {
|
|
|
2434
3926
|
for (var k = this.state.messages.length - 1; k >= 0; k--) {
|
|
2435
3927
|
var m = this.state.messages[k];
|
|
2436
3928
|
if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
|
|
3929
|
+
// A bubble with a live stream running into it is never a stray, whatever its
|
|
3930
|
+
// user bubble looks like: deleting it would leave the stream painting into
|
|
3931
|
+
// nothing and throw away the answer already on screen. (A QUEUED turn's user
|
|
3932
|
+
// bubble is still pending while it streams, so _isLiveImmediatePlaceholder
|
|
3933
|
+
// below does not cover this one.) _streaming is cleared at settle, so this
|
|
3934
|
+
// never keeps a bubble the sweep should have taken.
|
|
3935
|
+
if (m._streaming) continue;
|
|
2437
3936
|
if (this._isLiveImmediatePlaceholder(k)) continue;
|
|
2438
3937
|
this.state.messages.splice(k, 1);
|
|
2439
3938
|
}
|
|
@@ -2662,9 +4161,13 @@ export class ChatSession {
|
|
|
2662
4161
|
this.state.messages[idx] = { role: 'assistant', content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...(reportedComplete ? { _indexComplete: true } : {}) };
|
|
2663
4162
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
2664
4163
|
}
|
|
4164
|
+
// Same live-stream resume as the two send paths: a streamed turn re-attached
|
|
4165
|
+
// after a reload settles HERE, and its bubble is already carrying the text
|
|
4166
|
+
// the reader watched arrive.
|
|
4167
|
+
var hPainted = this._paintedTextAt(idx);
|
|
2665
4168
|
var lid = this._newLocalId();
|
|
2666
|
-
this.state.messages[idx] = { role: 'assistant', content:
|
|
2667
|
-
this.host.notify(); this.enqueueTypewrite(idx, text, lid);
|
|
4169
|
+
this.state.messages[idx] = { role: 'assistant', content: hPainted, _localId: lid, _serverItemId: itemId };
|
|
4170
|
+
this.host.notify(); this.enqueueTypewrite(idx, text, lid, hPainted);
|
|
2668
4171
|
this.updateHistoryCache(); return;
|
|
2669
4172
|
}
|
|
2670
4173
|
var userIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -3509,6 +5012,14 @@ export class ChatSession {
|
|
|
3509
5012
|
// fresh stubs, and without this a bubble the user already expanded
|
|
3510
5013
|
// would silently revert to its 200-char head.
|
|
3511
5014
|
self.applyHydratedBodies(mapped);
|
|
5015
|
+
// AN UNKNOWN ANSWER NEVER OVERWRITES A KNOWN ONE. Before either merge
|
|
5016
|
+
// below, any page bubble whose row is terminal-but-empty takes the answer
|
|
5017
|
+
// the local list already holds for that turn (and its live state, if the
|
|
5018
|
+
// turn is still running). Without this, a refetch landing in the window
|
|
5019
|
+
// between a streamed row resolving and its finalize storing the body erases
|
|
5020
|
+
// the answer from the screen, on every streamed turn, since a refetch
|
|
5021
|
+
// fires on visibilitychange. See _adoptLocalAnswers.
|
|
5022
|
+
self._adoptLocalAnswers(mapped, loadKey);
|
|
3512
5023
|
|
|
3513
5024
|
// Set when a first-page refresh re-prepended already-loaded older pages,
|
|
3514
5025
|
// so the cursor reset below knows not to rewind to page 1's boundary.
|
|
@@ -3755,6 +5266,13 @@ export class ChatSession {
|
|
|
3755
5266
|
self.updateHistoryCache();
|
|
3756
5267
|
self.host.notify();
|
|
3757
5268
|
|
|
5269
|
+
// AND AN UNKNOWN ANSWER LEFT OVER IS RESOLVED BY READING THE CHUNKS BACK.
|
|
5270
|
+
// Fired AFTER the page is rendered and the flags are cleared, never before:
|
|
5271
|
+
// a streamed turn nobody finalized is a turn whose answer is sitting in the
|
|
5272
|
+
// chunk store, and going to fetch it must not hold up the conversation it
|
|
5273
|
+
// belongs to. Bounded and serial: see _scheduleStreamRecovery.
|
|
5274
|
+
self._scheduleStreamRecovery(loadKey, platform, projectId, owner);
|
|
5275
|
+
|
|
3758
5276
|
// ── deferred indexing-stub batch (first-paint split) ──────────────
|
|
3759
5277
|
// The conversation is already on screen; when the stub batch lands it
|
|
3760
5278
|
// is classified/mapped like any page and STRIP-THEN-MERGED: existing
|
|
@@ -3953,7 +5471,13 @@ export class ChatSession {
|
|
|
3953
5471
|
};
|
|
3954
5472
|
var pp = isBg
|
|
3955
5473
|
? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts))
|
|
3956
|
-
|
|
5474
|
+
// Pinned to the chat this LOAD is for, snapshotted at its start
|
|
5475
|
+
// (loadKey/platform/projectId/owner), never a live read: a load can
|
|
5476
|
+
// still be attaching polls after the user has moved on, and a stream
|
|
5477
|
+
// stamped with where they moved to would paint into that chat.
|
|
5478
|
+
: self._fgPollWithEarlyProbe(item, capturedId, pollOpts, {
|
|
5479
|
+
platform: platform, projectId: projectId, owner: owner, ownerKey: loadKey,
|
|
5480
|
+
});
|
|
3957
5481
|
// Anything on the BACKGROUND queue is pausable, not just items whose
|
|
3958
5482
|
// prompt text we recognise as an indexing task. _isBgTask vs
|
|
3959
5483
|
// _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
|