bunnyquery 1.8.3 → 1.8.4
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 +38 -39
- package/bunnyquery.css +108 -2
- package/bunnyquery.js +1530 -268
- package/dist/engine.cjs +1276 -209
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +812 -53
- package/dist/engine.d.ts +812 -53
- package/dist/engine.mjs +1254 -210
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +11 -11
- package/src/engine/history.ts +23 -6
- package/src/engine/host.ts +63 -5
- package/src/engine/image_preview.ts +0 -0
- package/src/engine/index.ts +12 -0
- package/src/engine/indexing_groups.ts +323 -6
- package/src/engine/link_markup.ts +124 -0
- package/src/engine/links.ts +159 -26
- package/src/engine/prompts/chat_system_prompt.ts +24 -14
- package/src/engine/prompts/indexing_system_prompt.ts +19 -11
- package/src/engine/prompts/indexing_user_message.ts +32 -22
- package/src/engine/requests.ts +281 -14
- package/src/engine/session.ts +1158 -143
- package/src/engine/viewport_fill.ts +51 -4
- package/styles/chat.css +108 -2
package/src/engine/session.ts
CHANGED
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
notifyAgentSaveAttachment,
|
|
24
24
|
notifyAgentContinueIndexing,
|
|
25
25
|
INDEXING_COMPLETE_MARKER,
|
|
26
|
+
EMPTY_INDEXING_REPLY,
|
|
26
27
|
MAX_INDEXING_RESUME_PASSES,
|
|
27
28
|
extractClaudeText,
|
|
28
29
|
extractOpenAIText,
|
|
29
30
|
getChatHistory,
|
|
30
31
|
POLL_INTERVAL,
|
|
32
|
+
MAX_CONCURRENT_BG_POLLS,
|
|
31
33
|
bgIndexingQueueName,
|
|
32
34
|
isBgIndexingQueue,
|
|
33
35
|
ANTHROPIC_MESSAGES_API_URL,
|
|
@@ -39,6 +41,7 @@ import { windowedIndexingEnabled } from './config';
|
|
|
39
41
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
|
|
40
42
|
import { buildBoundedChatMessages } from './budget';
|
|
41
43
|
import { createInlineLinkRegex } from './links';
|
|
44
|
+
import { markImagePreviewStale } from './image_preview';
|
|
42
45
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
|
|
43
46
|
import { wallClockNow } from './time';
|
|
44
47
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
@@ -53,6 +56,17 @@ function sleep(ms: number): Promise<void> {
|
|
|
53
56
|
// queue is FIFO per user, so a healthy chain has one running plus at most a
|
|
54
57
|
// couple queued; the cap only bounds a pathological backlog.
|
|
55
58
|
const WORKER_PASS_ADOPT_LIMIT = 20;
|
|
59
|
+
// How stale the "which files are indexing" snapshot may be when it is used to
|
|
60
|
+
// refuse a DUPLICATE index dispatch. Short, because the answer decides whether a
|
|
61
|
+
// file gets indexed at all; long enough that one upload batch asks once, not once
|
|
62
|
+
// per file.
|
|
63
|
+
const LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5000;
|
|
64
|
+
// How long a client holds a file's indexing slot from the moment it decides to
|
|
65
|
+
// dispatch. It only has to outlive the gap between that decision and the queue
|
|
66
|
+
// admitting the pass (a request, plus the status index catching up); after that
|
|
67
|
+
// the bgTaskQueue entry and the pass's own bubble carry the fact. Bounded so a
|
|
68
|
+
// dispatch that died without releasing cannot lock a file out for the session.
|
|
69
|
+
const INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1000;
|
|
56
70
|
// Delays before each look (the first is immediate). More than one because the
|
|
57
71
|
// worker writes the next pass's row a few milliseconds AFTER it resolves the
|
|
58
72
|
// current one, so a look that wins that race sees an empty queue for a chain
|
|
@@ -82,6 +96,22 @@ const INDEXING_DRAIN_MIN_MS = 8000;
|
|
|
82
96
|
// against a partly-indexed file is a poor outcome, but a question that is never
|
|
83
97
|
// asked at all because one chain wedged server-side is a worse one.
|
|
84
98
|
const INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
99
|
+
// A look that never comes back would hang the whole wait forever — there is no
|
|
100
|
+
// pending timer to fall back on, so the turn is never sent and its bubble never
|
|
101
|
+
// stops saying "(Indexing files...)". A look that outlives this is abandoned and
|
|
102
|
+
// counted as UNKNOWN, i.e. as busy, which costs one more cycle and nothing else.
|
|
103
|
+
//
|
|
104
|
+
// Set well above any plausible slow answer rather than just above a fast one. An
|
|
105
|
+
// abandoned look counts as busy and there is no way back: an index that reliably
|
|
106
|
+
// answers slower than this can never produce two agreeing idle looks, so the turn
|
|
107
|
+
// would sit at "(Indexing files...)" until the 15-minute ceiling. The cost of
|
|
108
|
+
// being generous is one extra cycle in the case this is meant to catch, which by
|
|
109
|
+
// definition never returns at all.
|
|
110
|
+
const INDEXING_DRAIN_LOOK_TIMEOUT_MS = 45000;
|
|
111
|
+
// Closest a nudged look may follow the previous one. Bounds the extra request
|
|
112
|
+
// rate when a bulk upload finishes many chains at once; the unnudged cadence is
|
|
113
|
+
// one pair of requests per INDEXING_DRAIN_BUSY_POLL_MS.
|
|
114
|
+
const INDEXING_DRAIN_NUDGE_MIN_GAP_MS = 1500;
|
|
85
115
|
|
|
86
116
|
// requestAnimationFrame / high-res clock, reached through globalThis so the
|
|
87
117
|
// engine stays DOM-free at the type level (and degrades gracefully in non-DOM
|
|
@@ -142,6 +172,38 @@ export class ChatSession {
|
|
|
142
172
|
* would read the gap between "pass N settled" and "pass N+1 accepted" as the
|
|
143
173
|
* file being finished. */
|
|
144
174
|
private _indexDispatchesInFlight: number;
|
|
175
|
+
/** Live awaitIndexingDrained waiters, one callback each. A nudge only pulls that
|
|
176
|
+
* waiter's NEXT look forward; it can never make one conclude anything, so a
|
|
177
|
+
* wrong nudge costs one pair of requests and the look reports busy. Overlapping
|
|
178
|
+
* waiters are normal — the composer stays live, so a second send can be
|
|
179
|
+
* uploading while the first waits. */
|
|
180
|
+
private _drainNudges: Array<() => void>;
|
|
181
|
+
/** Stages whose upload/dispatch chain is still running in THIS page. Lives and
|
|
182
|
+
* dies with those chains, so it is what tells a staged bubble restored from the
|
|
183
|
+
* history cache whether anything is still working on it (see
|
|
184
|
+
* settleDeadStagedMessages). Today the cache dies with the page too and every
|
|
185
|
+
* restored stage is live; this stays correct if that ever changes. */
|
|
186
|
+
private _liveStages: { [stageId: string]: boolean };
|
|
187
|
+
/** Files the SERVER currently has unresolved indexing work for, by the same key
|
|
188
|
+
* a collapsed row uses (storage path, else filename), and whether we have asked
|
|
189
|
+
* even once for this chat.
|
|
190
|
+
*
|
|
191
|
+
* This is the only thing that can tell a WORKER-driven run (a PDF's page loop, a
|
|
192
|
+
* windowed read) that it is over. Those chains are advanced inside the worker off
|
|
193
|
+
* the renderer's page count; the client sees passes appear and settle and can
|
|
194
|
+
* never tell "between passes" from "finished" by looking at them. Asking the
|
|
195
|
+
* queue is how it finds out. Until it has asked, `checked` is false and the view
|
|
196
|
+
* says "still working", which is the honest reading of not knowing — and the one
|
|
197
|
+
* that does not repeat the bug where a row claimed "Indexed" mid-run. */
|
|
198
|
+
/** The chat the live-index snapshot (state.liveIndexKeys) was taken for, so a
|
|
199
|
+
* project switch drops it. */
|
|
200
|
+
private _liveIndexKey: string;
|
|
201
|
+
/** When the snapshot was last published (wall clock ms), so a caller that needs
|
|
202
|
+
* a CURRENT answer can tell whether to re-ask. 0 = never. */
|
|
203
|
+
private _liveIndexAt: number;
|
|
204
|
+
/** Files this client has an index dispatch in flight for, by scoped path ->
|
|
205
|
+
* wall clock. See claimIndexRun. */
|
|
206
|
+
private _indexClaims: { [scopedPath: string]: number };
|
|
145
207
|
|
|
146
208
|
constructor(host: ChatHost) {
|
|
147
209
|
this.host = host;
|
|
@@ -158,6 +220,9 @@ export class ChatSession {
|
|
|
158
220
|
historyStartKeyHistory: [],
|
|
159
221
|
historyRequestToken: 0,
|
|
160
222
|
gateRefreshToken: 0,
|
|
223
|
+
liveIndexKeys: {},
|
|
224
|
+
liveIndexChecked: false,
|
|
225
|
+
stoppedIndexIds: {},
|
|
161
226
|
};
|
|
162
227
|
this.bgTaskQueue = [];
|
|
163
228
|
this.cancelledServerIds = new Set();
|
|
@@ -171,6 +236,295 @@ export class ChatSession {
|
|
|
171
236
|
this._stageSeq = 0;
|
|
172
237
|
this._uploadBatches = 0;
|
|
173
238
|
this._indexDispatchesInFlight = 0;
|
|
239
|
+
this._drainNudges = [];
|
|
240
|
+
this._liveStages = {};
|
|
241
|
+
this._liveIndexKey = '';
|
|
242
|
+
this._liveIndexAt = 0;
|
|
243
|
+
this._indexClaims = {};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What the display layer needs to decide whether a run is finished. `keys` holds
|
|
247
|
+
* every file the server still has indexing work for; `checked` is false until the
|
|
248
|
+
* first answer for this chat, and false means "we do not know yet". */
|
|
249
|
+
getLiveIndexState(): { keys: { [fileKey: string]: boolean }; checked: boolean } {
|
|
250
|
+
return { keys: this.state.liveIndexKeys, checked: this.state.liveIndexChecked };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Passes that were on a row when the user stopped it, so the display layer can
|
|
254
|
+
* still tell that this run was stopped once the stop has left no other trace.
|
|
255
|
+
* See cancelIndexingGroup, which fills it, and buildChatDisplayList, which is
|
|
256
|
+
* the only reader. */
|
|
257
|
+
getStoppedIndexIds(): { [serverItemId: string]: boolean } {
|
|
258
|
+
return this.state.stoppedIndexIds;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Is this file ALREADY being indexed by this client?
|
|
263
|
+
*
|
|
264
|
+
* One live run per file, and the reason is what a second one looks like: the
|
|
265
|
+
* conversation grows a SECOND collapsed row for the same file (a run is opened
|
|
266
|
+
* by every FIRST pass, so two of them are two rows), the same document is read
|
|
267
|
+
* twice at full provider cost, and the two chains fight over the same records —
|
|
268
|
+
* the delete-then-repost that starts run 2 wipes what run 1 has saved so far.
|
|
269
|
+
*
|
|
270
|
+
* Asked of this client's own live work, so it cannot be wrong in the dangerous
|
|
271
|
+
* direction: a queued/running pass keeps its bgTaskQueue entry until its bubble
|
|
272
|
+
* settles, and a settled run answers false, which is what a genuine later
|
|
273
|
+
* re-index needs.
|
|
274
|
+
*
|
|
275
|
+
* The retry that made this necessary: a chip whose INDEX request failed is
|
|
276
|
+
* handed back to the composer to be retried on the next send, and an index
|
|
277
|
+
* request can fail from the client's side (a lost ack, an expired token on the
|
|
278
|
+
* response) while the server has already queued the pass. The retry then indexes
|
|
279
|
+
* a file that was never not being indexed.
|
|
280
|
+
*/
|
|
281
|
+
hasLiveIndexRun(storagePath?: string): boolean {
|
|
282
|
+
if (!storagePath) return false;
|
|
283
|
+
// A dispatch this client has STARTED but not yet heard back about. Nothing
|
|
284
|
+
// else can see one: a bgTaskQueue entry exists only once the ack lands, the
|
|
285
|
+
// bubble only once the drain runs after that, and the server's own status
|
|
286
|
+
// index is eventually consistent — the adopt ladder retries at 0/2s/6s
|
|
287
|
+
// precisely because a row created seconds ago can be missing from it. So
|
|
288
|
+
// between deciding to index a file and the queue admitting it, EVERY source
|
|
289
|
+
// of truth says "not indexing", and a second dispatch in that window is
|
|
290
|
+
// exactly the duplicate this guard exists to stop. Observed live: two first
|
|
291
|
+
// passes four seconds apart, two collapsed rows.
|
|
292
|
+
var claimed = this._indexClaims[this._indexClaimKey(storagePath)];
|
|
293
|
+
if (claimed && nowMs() - claimed < INDEX_DISPATCH_CLAIM_MS) return true;
|
|
294
|
+
var id = this.host.getIdentity();
|
|
295
|
+
for (var i = 0; i < this.bgTaskQueue.length; i++) {
|
|
296
|
+
var e = this.bgTaskQueue[i];
|
|
297
|
+
if (e && e.storagePath === storagePath && e.projectId === id.projectId && e.platform === id.platform) return true;
|
|
298
|
+
}
|
|
299
|
+
return this.state.messages.some(function (m) {
|
|
300
|
+
if (!m.isBackgroundTask || m.role !== 'user' || m.isCancelled) return false;
|
|
301
|
+
if (!(m.isPendingQueued || m.isPendingInProcess || m.isSendingToServer)) return false;
|
|
302
|
+
return !!m._indexFile && m._indexFile.path === storagePath;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Storage paths are project-relative, and one ChatSession serves every
|
|
307
|
+
* project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
|
|
308
|
+
private _indexClaimKey(storagePath: string): string {
|
|
309
|
+
return this.getHistoryCacheKey() + '|' + storagePath;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Take this file's indexing slot, or report that someone already has it.
|
|
314
|
+
*
|
|
315
|
+
* The check-and-CLAIM is what makes it safe against a second caller arriving
|
|
316
|
+
* mid-flight: the claim is written SYNCHRONOUSLY, before the first await, so a
|
|
317
|
+
* concurrent caller sees it even though no request has completed and no queue
|
|
318
|
+
* has admitted anything. Ask-then-dispatch could not do that — every source it
|
|
319
|
+
* consults only learns about a dispatch after the ack.
|
|
320
|
+
*
|
|
321
|
+
* Returns true when the caller owns the slot and should dispatch. A caller that
|
|
322
|
+
* then fails to dispatch MUST releaseIndexRun, or the file waits out the claim
|
|
323
|
+
* (a few minutes) before it can be retried.
|
|
324
|
+
*/
|
|
325
|
+
claimIndexRun(storagePath?: string): Promise<boolean> {
|
|
326
|
+
var self = this;
|
|
327
|
+
if (!storagePath) return Promise.resolve(true);
|
|
328
|
+
if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(false);
|
|
329
|
+
this._indexClaims[this._indexClaimKey(storagePath)] = nowMs();
|
|
330
|
+
return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS)
|
|
331
|
+
.then(function () {
|
|
332
|
+
if (!self.state.liveIndexKeys[storagePath]) return true;
|
|
333
|
+
self.releaseIndexRun(storagePath);
|
|
334
|
+
return false;
|
|
335
|
+
})
|
|
336
|
+
.catch(function () { return true; });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Give the slot back — the dispatch failed, or was abandoned. */
|
|
340
|
+
releaseIndexRun(storagePath?: string): void {
|
|
341
|
+
if (storagePath) delete this._indexClaims[this._indexClaimKey(storagePath)];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The same question, asked of the SERVER when this page cannot answer it.
|
|
346
|
+
*
|
|
347
|
+
* hasLiveIndexRun only knows what this page did. That is not enough for the
|
|
348
|
+
* case duplicates actually come from: the first run was started before a
|
|
349
|
+
* reload, or in another tab, or its bubble has since been paged out of the
|
|
350
|
+
* loaded window — and then the retry finds nothing locally and starts a second
|
|
351
|
+
* run of a file that is still being indexed. The queue is the one place that
|
|
352
|
+
* knows, and it is already asked for exactly this list.
|
|
353
|
+
*
|
|
354
|
+
* Only a POSITIVE answer is used. Absence proves nothing here (the query is
|
|
355
|
+
* capped, and `liveIndexChecked` records that), so an unanswerable question
|
|
356
|
+
* falls back to dispatching — the cost of a wrong "no" is the duplicate this
|
|
357
|
+
* exists to prevent, and the cost of a wrong "yes" is a file that never gets
|
|
358
|
+
* indexed at all. Only one of those is recoverable by the user.
|
|
359
|
+
*/
|
|
360
|
+
isIndexRunLive(storagePath?: string): Promise<boolean> {
|
|
361
|
+
var self = this;
|
|
362
|
+
if (!storagePath) return Promise.resolve(false);
|
|
363
|
+
if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(true);
|
|
364
|
+
return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS)
|
|
365
|
+
.then(function () { return !!self.state.liveIndexKeys[storagePath]; })
|
|
366
|
+
.catch(function () { return false; });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Re-ask the queue which files are still being indexed, unless the answer we
|
|
370
|
+
* have is younger than `maxAgeMs`. Shared by every caller that needs a current
|
|
371
|
+
* one; the display layer's own refresh path is the adopt ladder. */
|
|
372
|
+
private _refreshLiveIndexKeys(maxAgeMs: number): Promise<void> {
|
|
373
|
+
var self = this;
|
|
374
|
+
var id = this.host.getIdentity();
|
|
375
|
+
var platform = id.platform;
|
|
376
|
+
if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve();
|
|
377
|
+
// The chat this query is FOR, snapshotted before the round trip. The record
|
|
378
|
+
// below is gated on it still being the chat on screen: the adopt ladder makes
|
|
379
|
+
// the same resolve-time identity check, and without it a project switch inside
|
|
380
|
+
// this query's RTT published chat A's queue answer as chat B's snapshot —
|
|
381
|
+
// where absence reads as a green "Indexed" for files B is indexing right now.
|
|
382
|
+
var askedKey = this.getHistoryCacheKey();
|
|
383
|
+
if (this._liveIndexKey === askedKey && nowMs() - this._liveIndexAt < maxAgeMs) {
|
|
384
|
+
return Promise.resolve();
|
|
385
|
+
}
|
|
386
|
+
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
387
|
+
var ask = function (status: 'pending' | 'running') {
|
|
388
|
+
return Promise.resolve(getChatHistory(
|
|
389
|
+
{ service: id.projectId, owner: id.owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
390
|
+
{ limit: WORKER_PASS_ADOPT_LIMIT },
|
|
391
|
+
)).catch(function () { return null; });
|
|
392
|
+
};
|
|
393
|
+
return Promise.all([ask('pending'), ask('running')]).then(function (results) {
|
|
394
|
+
if (results[0] === null || results[1] === null) return;
|
|
395
|
+
if (self.getHistoryCacheKey() !== askedKey) return;
|
|
396
|
+
self._liveIndexKey = askedKey;
|
|
397
|
+
self._recordLiveIndexKeys(results);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Replace the live-index snapshot from a queue query's raw items.
|
|
403
|
+
*
|
|
404
|
+
* Whole-snapshot, never incremental: the query returns everything unresolved on
|
|
405
|
+
* the queue, so a file MISSING from it is precisely the fact we are after. Merging
|
|
406
|
+
* would make a finished file impossible to observe.
|
|
407
|
+
*/
|
|
408
|
+
private _recordLiveIndexKeys(lists: any[]): void {
|
|
409
|
+
var next: { [k: string]: boolean } = {};
|
|
410
|
+
// A page that came back FULL is not a whole-queue answer: the query is capped
|
|
411
|
+
// and ordered newest-first, so a bulk upload of 40 files returns the 20 newest
|
|
412
|
+
// and omits the 20 about to run. Absence would then read as "finished" for
|
|
413
|
+
// exactly the files that have not been touched yet. Report not-knowing instead.
|
|
414
|
+
var truncated = false;
|
|
415
|
+
// Locally settled already. The adopt path refuses these items for the same
|
|
416
|
+
// reason and says why: the status index is eventually consistent, so the pass
|
|
417
|
+
// that just finished can still come back as running. Counting it as live work
|
|
418
|
+
// leaves a finished file spinning with nothing left to ever settle and correct
|
|
419
|
+
// it — the two readings of one payload must not disagree.
|
|
420
|
+
var settledIds: { [id: string]: boolean } = {};
|
|
421
|
+
this.state.messages.forEach(function (m) {
|
|
422
|
+
if (!m._serverItemId) return;
|
|
423
|
+
if (m.isPending || m.isPendingInProcess || m.isPendingQueued) return;
|
|
424
|
+
settledIds[m._serverItemId] = true;
|
|
425
|
+
});
|
|
426
|
+
for (var li = 0; li < lists.length; li++) {
|
|
427
|
+
var list = lists[li] && Array.isArray(lists[li].list) ? lists[li].list : [];
|
|
428
|
+
if (list.length >= WORKER_PASS_ADOPT_LIMIT) truncated = true;
|
|
429
|
+
for (var i = 0; i < list.length; i++) {
|
|
430
|
+
var item = list[i];
|
|
431
|
+
if (!item || (item.status !== 'pending' && item.status !== 'running')) continue;
|
|
432
|
+
if (item.id && settledIds[item.id]) continue;
|
|
433
|
+
var text = extractLastUserTextFromRequest(item.request_body);
|
|
434
|
+
if (!isIndexingRequestText(text)) continue; // an ordinary chat on this queue
|
|
435
|
+
var ref = parseIndexingRequestText(text);
|
|
436
|
+
if (!ref) continue;
|
|
437
|
+
// BOTH, not `path || name`. A row's key is normally the storage path,
|
|
438
|
+
// but buildChatDisplayList falls back to the filename when the pass it
|
|
439
|
+
// opened the run with carried no path (an old cached bubble, a label
|
|
440
|
+
// with no link). A key recorded one way and looked up the other misses,
|
|
441
|
+
// and a miss reads as FINISHED — the one direction that must never
|
|
442
|
+
// happen by accident. Recording both can only over-match, which shows a
|
|
443
|
+
// loader on a file that is done: visibly cautious instead of silently
|
|
444
|
+
// wrong.
|
|
445
|
+
if (ref.path) next[ref.path] = true;
|
|
446
|
+
if (ref.name) next[ref.name] = true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
var nowChecked = !truncated;
|
|
450
|
+
var was = this.state.liveIndexKeys, changed = this.state.liveIndexChecked !== nowChecked;
|
|
451
|
+
if (!changed) {
|
|
452
|
+
for (var k in next) if (!was[k]) { changed = true; break; }
|
|
453
|
+
if (!changed) for (var k2 in was) if (!next[k2]) { changed = true; break; }
|
|
454
|
+
}
|
|
455
|
+
this.state.liveIndexKeys = next;
|
|
456
|
+
this.state.liveIndexChecked = nowChecked;
|
|
457
|
+
this._liveIndexAt = nowMs();
|
|
458
|
+
// Claim the snapshot for the CURRENT chat. Both callers verify identity before
|
|
459
|
+
// recording, so this is always true - but only _refreshLiveIndexKeys used to say
|
|
460
|
+
// so, and the adopt ladder (the path every plain page load actually seeds
|
|
461
|
+
// through) never did. _liveIndexKey then stayed '', and the first-page reset in
|
|
462
|
+
// loadHistory ('loadKey !== _liveIndexKey') fired on EVERY load - including every
|
|
463
|
+
// tab refocus - wiping checked/keys and flipping every settled indexing row back
|
|
464
|
+
// to the grey "checking status" until the queue answered again. With the claim,
|
|
465
|
+
// that reset fires only on a genuine project/platform switch; a refocus keeps
|
|
466
|
+
// showing the last known state (green/yellow) and the re-poll that follows
|
|
467
|
+
// replaces the snapshot atomically when its answer lands.
|
|
468
|
+
this._liveIndexKey = this.getHistoryCacheKey();
|
|
469
|
+
if (changed) this.host.notify();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Forget the snapshot: it describes ONE chat's queue, and the answer for the
|
|
473
|
+
* project the user just switched to is unknown until it is asked for again. */
|
|
474
|
+
private _resetLiveIndexKeys(): void {
|
|
475
|
+
this.state.liveIndexKeys = {};
|
|
476
|
+
this.state.liveIndexChecked = false;
|
|
477
|
+
this._liveIndexAt = 0;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Ask the queue what is still indexing, once, for the chat that is on screen.
|
|
482
|
+
*
|
|
483
|
+
* Seeds the snapshot on a history load. Without it a reloaded chat has no way to
|
|
484
|
+
* learn that a run it can see is over: the adopt ladder that normally answers this
|
|
485
|
+
* only fires when a pass SETTLES, and after a reload there is no pass left to
|
|
486
|
+
* settle — so every finished worker-driven row would spin forever.
|
|
487
|
+
*
|
|
488
|
+
* Best-effort: a failure leaves `checked` false, which reads as "still working"
|
|
489
|
+
* rather than as a false all-clear.
|
|
490
|
+
*
|
|
491
|
+
* Delegates to the adopt ladder rather than asking once. A single empty look is
|
|
492
|
+
* exactly what that ladder exists to distrust — the worker writes pass N+1 a few
|
|
493
|
+
* milliseconds AFTER flipping pass N to resolved, so a query landing in that gap
|
|
494
|
+
* sees an empty queue for a chain that is very much alive. One look would turn
|
|
495
|
+
* that into a confident "Indexed" with a green check, on the one scenario this
|
|
496
|
+
* whole feature is for, and nothing would ever re-ask: the ladder is normally
|
|
497
|
+
* triggered by a pass SETTLING, and after a reload there is no pass left to
|
|
498
|
+
* settle. The ladder re-asks at 0/2s/6s, records each answer, and as a bonus
|
|
499
|
+
* adopts and polls any live pass it finds, which makes the row genuinely active
|
|
500
|
+
* instead of merely unconfirmed.
|
|
501
|
+
*/
|
|
502
|
+
refreshLiveIndexState(): void {
|
|
503
|
+
this._adoptWorkerIndexingPasses(0);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Forget what we know about which files are indexing — but ONLY when the
|
|
507
|
+
* snapshot was taken for a different chat than the one on screen now. For a
|
|
508
|
+
* consumer whose history loading is its own fork and so never reaches
|
|
509
|
+
* loadHistory's reset — a snapshot describes ONE chat's queue, and carrying it
|
|
510
|
+
* into another project would let a row there claim to be finished on someone
|
|
511
|
+
* else's evidence.
|
|
512
|
+
*
|
|
513
|
+
* Conditional for the same reason loadHistory's own reset is (the
|
|
514
|
+
* `loadKey !== _liveIndexKey` gate): the view calls this on every mount, and
|
|
515
|
+
* an unconditional wipe turned every re-entry to the chat into a grey
|
|
516
|
+
* "Checking status:" sweep across rows whose state was already known. A
|
|
517
|
+
* RE-entry keeps showing the last answer (green/yellow) while the first-page
|
|
518
|
+
* refresh re-asks quietly; only a genuine project/platform switch starts from
|
|
519
|
+
* "not known yet". Claiming `_liveIndexKey` here (before any answer) is the
|
|
520
|
+
* same fudge loadHistory makes: it marks WHOSE chat the empty snapshot is
|
|
521
|
+
* for, so repeated calls do not re-wipe, and _recordLiveIndexKeys re-claims
|
|
522
|
+
* it when the real answer lands. */
|
|
523
|
+
resetLiveIndexState(): void {
|
|
524
|
+
var key = this.getHistoryCacheKey();
|
|
525
|
+
if (key === this._liveIndexKey) return;
|
|
526
|
+
this._liveIndexKey = key;
|
|
527
|
+
this._resetLiveIndexKeys();
|
|
174
528
|
}
|
|
175
529
|
|
|
176
530
|
/** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
|
|
@@ -178,10 +532,43 @@ export class ChatSession {
|
|
|
178
532
|
trackIndexDispatch<T>(p: Promise<T>): Promise<T> {
|
|
179
533
|
var self = this;
|
|
180
534
|
this._indexDispatchesInFlight += 1;
|
|
535
|
+
// Deliberately does NOT nudge the drain when the count reaches zero. That
|
|
536
|
+
// moment is a pass being ACCEPTED — new work ENTERING the queue — not work
|
|
537
|
+
// ending. Nudging there pulled a waiting turn's two confirming looks into a
|
|
538
|
+
// fixed ~4.5s window after the ack, which is inside the several seconds a
|
|
539
|
+
// worker-minted pass can take to show up in the status index (the same
|
|
540
|
+
// staleness WORKER_PASS_ADOPT_ATTEMPTS exists for). It turned a rare unlucky
|
|
541
|
+
// alignment into a reliable one: the turn dispatched ahead of its own file's
|
|
542
|
+
// remaining passes and the model answered from a partly-read file. There was
|
|
543
|
+
// nothing to gain either — the pass it signals is work, so the pulled-forward
|
|
544
|
+
// look can only report busy, or lie.
|
|
181
545
|
var release = function () { self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1); };
|
|
182
546
|
return p.then(function (v) { release(); return v; }, function (e) { release(); throw e; });
|
|
183
547
|
}
|
|
184
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Something just happened that plausibly ENDED indexing work, so let any waiting
|
|
551
|
+
* turn look now instead of sitting out the rest of its busy interval.
|
|
552
|
+
*
|
|
553
|
+
* A nudge changes only WHEN a look happens, never what it concludes: the two
|
|
554
|
+
* agreeing idle looks, the confirm gap between them, "a failed look counts as
|
|
555
|
+
* busy" and the minimum wait are all untouched. That is why it is safe to fire
|
|
556
|
+
* from places that are merely good guesses.
|
|
557
|
+
*
|
|
558
|
+
* Fired from end-of-chain points ONLY: the adopt ladder giving up, a resume
|
|
559
|
+
* declining to continue, a pass failing. Not from every settling pass (one nudge
|
|
560
|
+
* per pass per file for the whole run), and not from an indexing request being
|
|
561
|
+
* accepted — see the note in trackIndexDispatch for why that one is actively
|
|
562
|
+
* harmful rather than merely wasteful.
|
|
563
|
+
*/
|
|
564
|
+
private _nudgeIndexingDrain(): void {
|
|
565
|
+
if (!this._drainNudges.length) return;
|
|
566
|
+
var list = this._drainNudges.slice(); // a nudge may de-register itself
|
|
567
|
+
for (var i = 0; i < list.length; i++) {
|
|
568
|
+
try { list[i](); } catch (e) { /* best-effort: never break the caller */ }
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
185
572
|
/**
|
|
186
573
|
* Register a live poll so (a) a remount dedupes against it instead of stacking a
|
|
187
574
|
* SECOND poll on the same item, and (b) pausePolling can stop it.
|
|
@@ -200,6 +587,19 @@ export class ChatSession {
|
|
|
200
587
|
return p;
|
|
201
588
|
}
|
|
202
589
|
|
|
590
|
+
/** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
|
|
591
|
+
* Counts the registry rather than a separate tally so it cannot drift: every
|
|
592
|
+
* attach goes through _trackPoll and every detach deletes the entry. Note an
|
|
593
|
+
* entry left behind by pausePolling on an older skapi-js (no stop handle)
|
|
594
|
+
* still counts, which is correct — that poll really is still running. */
|
|
595
|
+
private _countBgPolls(): number {
|
|
596
|
+
var n = 0;
|
|
597
|
+
this.historyItemPolls.forEach(function (handle) {
|
|
598
|
+
if (handle && handle.kind === 'bg') n++;
|
|
599
|
+
});
|
|
600
|
+
return n;
|
|
601
|
+
}
|
|
602
|
+
|
|
203
603
|
/**
|
|
204
604
|
* Stop and forget one item's poll. Used after a cancel: the row is either gone
|
|
205
605
|
* (cancelled while queued) or flagged cancelled (cancelled while running), so
|
|
@@ -284,8 +684,8 @@ export class ChatSession {
|
|
|
284
684
|
|
|
285
685
|
getHistoryCacheKey(): string {
|
|
286
686
|
var id = this.host.getIdentity();
|
|
287
|
-
if (!id.
|
|
288
|
-
return id.
|
|
687
|
+
if (!id.projectId || id.platform === 'none') return '';
|
|
688
|
+
return id.projectId + '#' + id.platform;
|
|
289
689
|
}
|
|
290
690
|
|
|
291
691
|
updateHistoryCache(): void {
|
|
@@ -300,14 +700,22 @@ export class ChatSession {
|
|
|
300
700
|
// history, bg tasks) are always kept. Single pass: this runs on the
|
|
301
701
|
// typewriter hot path.
|
|
302
702
|
//
|
|
303
|
-
// Staged bubbles (_stageId)
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
703
|
+
// Staged bubbles (_stageId) ARE cached, deliberately. They used to be dropped
|
|
704
|
+
// on the theory that a reload kills the upload but not the cache, leaving a
|
|
705
|
+
// message that uploads forever — but this cache is an in-memory field on a
|
|
706
|
+
// singleton created at module load, so a reload takes it and the upload chain
|
|
707
|
+
// together and that state is unreachable. What dropping them DID cost was
|
|
708
|
+
// real: state.messages is shared across projects, so both clients filter it by
|
|
709
|
+
// _ownerKey on every chat switch, and with no cached copy the bubble was
|
|
710
|
+
// destroyed outright. A user who sent a question with a file and then looked at
|
|
711
|
+
// another project lost the question for the whole indexing wait and got it back
|
|
712
|
+
// at the bottom minutes later.
|
|
713
|
+
//
|
|
714
|
+
// isLiveStage + settleDeadStagedMessages keep the original concern honest if
|
|
715
|
+
// this cache is ever persisted: a restored stage whose upload no longer exists
|
|
716
|
+
// settles into a plain message instead of uploading forever.
|
|
308
717
|
this.aiChatHistoryCache[key] = {
|
|
309
718
|
messages: this.state.messages.filter(function (m) {
|
|
310
|
-
if (m._stageId) return false;
|
|
311
719
|
return m._ownerKey === undefined || m._ownerKey === key;
|
|
312
720
|
}),
|
|
313
721
|
endOfList: this.state.historyEndOfList,
|
|
@@ -361,6 +769,11 @@ export class ChatSession {
|
|
|
361
769
|
for (var j = 0; j < msgs.length; j++) {
|
|
362
770
|
var u = msgs[j];
|
|
363
771
|
if (!u || u.role !== 'user' || u.isBackgroundTask) continue;
|
|
772
|
+
// A STAGED bubble matches this shape exactly — pending, no server id, so the
|
|
773
|
+
// id test below cannot rule it out — and sits EARLIER in the list than the
|
|
774
|
+
// turn actually resolving. Settling it would strip its _stageId and its
|
|
775
|
+
// upload state, silently killing a turn whose files are still going up.
|
|
776
|
+
if (u._stageId) continue;
|
|
364
777
|
if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
|
|
365
778
|
if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
|
|
366
779
|
var settled: ChatMessage = { role: 'user', content: u.content };
|
|
@@ -378,21 +791,21 @@ export class ChatSession {
|
|
|
378
791
|
}
|
|
379
792
|
|
|
380
793
|
/**
|
|
381
|
-
*
|
|
794
|
+
* projectId/owner are passed explicitly by every caller: a request can be
|
|
382
795
|
* dispatched after the user moved to another project, and re-reading the live
|
|
383
796
|
* identity here would silently send the turn to THAT project instead of the
|
|
384
797
|
* one it was composed for. Falls back to the live read only when a caller
|
|
385
798
|
* omits them.
|
|
386
799
|
*/
|
|
387
|
-
private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any,
|
|
388
|
-
if (
|
|
800
|
+
private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, projectId?: string, owner?: string) {
|
|
801
|
+
if (projectId === undefined || owner === undefined) {
|
|
389
802
|
var id = this.host.getIdentity();
|
|
390
|
-
if (
|
|
803
|
+
if (projectId === undefined) projectId = id.projectId;
|
|
391
804
|
if (owner === undefined) owner = id.owner;
|
|
392
805
|
}
|
|
393
806
|
return platform === 'openai'
|
|
394
|
-
? callOpenAIWithPublicMcp(prompt,
|
|
395
|
-
: callClaudeWithPublicMcp(prompt,
|
|
807
|
+
? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls)
|
|
808
|
+
: callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
|
|
396
809
|
}
|
|
397
810
|
|
|
398
811
|
dispatchAgentRequest(params: any) {
|
|
@@ -406,7 +819,7 @@ export class ChatSession {
|
|
|
406
819
|
var dispatchItemId: string | undefined;
|
|
407
820
|
var sendAndPoll = function () {
|
|
408
821
|
return Promise.resolve(
|
|
409
|
-
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.
|
|
822
|
+
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.projectId, params.owner)
|
|
410
823
|
).then(function (initial: any) {
|
|
411
824
|
if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
|
|
412
825
|
if (initial.id) {
|
|
@@ -508,14 +921,57 @@ export class ChatSession {
|
|
|
508
921
|
var staged: ChatMessage = {
|
|
509
922
|
role: 'user', content: displayText,
|
|
510
923
|
isPendingQueued: true, isUploadingAttachments: true, isSendingToServer: true,
|
|
924
|
+
_dimSending: true,
|
|
925
|
+
// A staged bubble has no server id for minutes, and its indexing rows are
|
|
926
|
+
// now inserted ABOVE it — so its array index moves. Both views fall back to
|
|
927
|
+
// the index when a bubble has no id, which would re-key (and in Vue, remount)
|
|
928
|
+
// this bubble on every file, restarting its transition and losing it as a
|
|
929
|
+
// scroll anchor. A local id it keeps for its whole life fixes both.
|
|
930
|
+
_localId: this._newLocalId(),
|
|
511
931
|
_useBgQueue: true, _stageId: stageId, _ts: wallClockNow(),
|
|
512
932
|
};
|
|
513
933
|
if (key) staged._ownerKey = key;
|
|
934
|
+
this._liveStages[stageId] = true;
|
|
514
935
|
this.state.messages.push(staged);
|
|
515
936
|
this.host.notify(); this.host.scrollToBottom(true);
|
|
516
937
|
return stageId;
|
|
517
938
|
}
|
|
518
939
|
|
|
940
|
+
/** Is anything in this page still uploading/dispatching for this stage? */
|
|
941
|
+
isLiveStage(stageId?: string): boolean {
|
|
942
|
+
return !!stageId && !!this._liveStages[stageId];
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Settle any staged bubble in `list` whose chain no longer exists, and return the
|
|
947
|
+
* list (a new array only if something changed).
|
|
948
|
+
*
|
|
949
|
+
* The caller is a cache restore. A staged bubble is the one kind of message whose
|
|
950
|
+
* resolution lives entirely in page memory — no server request stands behind it
|
|
951
|
+
* yet — so a copy that outlives its upload would render "(Uploading files...)"
|
|
952
|
+
* forever with nothing left to finish it. Today nothing can: this cache dies with
|
|
953
|
+
* the page, so every restored stage is still live and this is a no-op. It exists
|
|
954
|
+
* so that stops being a silent assumption.
|
|
955
|
+
*/
|
|
956
|
+
settleDeadStagedMessages(list: ChatMessage[]): ChatMessage[] {
|
|
957
|
+
if (!Array.isArray(list)) return list;
|
|
958
|
+
var self = this;
|
|
959
|
+
var dead = false;
|
|
960
|
+
for (var i = 0; i < list.length; i++) {
|
|
961
|
+
var m = list[i];
|
|
962
|
+
if (m && m._stageId && !self._liveStages[m._stageId]) { dead = true; break; }
|
|
963
|
+
}
|
|
964
|
+
if (!dead) return list;
|
|
965
|
+
return list.map(function (m) {
|
|
966
|
+
if (!m || !m._stageId || self._liveStages[m._stageId]) return m;
|
|
967
|
+
var settled: ChatMessage = { role: 'user', content: m.content };
|
|
968
|
+
if (m._ownerKey !== undefined) settled._ownerKey = m._ownerKey;
|
|
969
|
+
if (m._ts !== undefined) settled._ts = m._ts;
|
|
970
|
+
if (m._localId !== undefined) settled._localId = m._localId;
|
|
971
|
+
return settled;
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
519
975
|
private _stageIndex(list: ChatMessage[], stageId?: string): number {
|
|
520
976
|
if (!stageId) return -1;
|
|
521
977
|
for (var i = 0; i < list.length; i++) {
|
|
@@ -525,34 +981,44 @@ export class ChatSession {
|
|
|
525
981
|
}
|
|
526
982
|
|
|
527
983
|
/**
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
* are injected as each file's pass starts, after the bubble was staged.
|
|
984
|
+
* Staged turn, phase 2: its files are up and it is now waiting for the whole
|
|
985
|
+
* indexing chain behind them. Swaps "(Uploading files...)" for
|
|
986
|
+
* "(Indexing files...)"; the bubble stays dimmed, because from the user's side
|
|
987
|
+
* nothing has been handed over yet.
|
|
533
988
|
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
989
|
+
* It deliberately does NOT say "(In queue)" here. The turn is not queued behind
|
|
990
|
+
* anything the server knows about yet — it is waiting on work that can run for
|
|
991
|
+
* minutes — and claiming otherwise is what made the wait look like a stall.
|
|
537
992
|
*/
|
|
538
|
-
|
|
539
|
-
var
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
993
|
+
markStagedMessageIndexing(stageId: string): void {
|
|
994
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
995
|
+
if (idx === -1) return;
|
|
996
|
+
var ex = this.state.messages[idx];
|
|
997
|
+
if (!ex.isUploadingAttachments) return;
|
|
998
|
+
this.state.messages[idx] = Object.assign({}, ex, {
|
|
999
|
+
isUploadingAttachments: false, isAwaitingIndexing: true,
|
|
1000
|
+
});
|
|
1001
|
+
this.host.notify();
|
|
546
1002
|
}
|
|
547
1003
|
|
|
548
|
-
/**
|
|
549
|
-
*
|
|
550
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Staged turn, phase 3: the last of its files has finished indexing, so the turn
|
|
1006
|
+
* is genuinely just queued now. Full opacity + "(In queue)".
|
|
1007
|
+
*
|
|
1008
|
+
* Clears the PRESENTATIONAL _dimSending only; isSendingToServer stays set until
|
|
1009
|
+
* the server actually acks (it is the token that ack matches on). Called by the
|
|
1010
|
+
* clients the instant awaitIndexingDrained resolves, i.e. immediately before the
|
|
1011
|
+
* dispatch that replaces this bubble — dispatchComposedMessage carries the
|
|
1012
|
+
* cleared flag onto the replacement so the turn does not blink back to dimmed.
|
|
1013
|
+
*/
|
|
1014
|
+
markStagedMessageReady(stageId: string): void {
|
|
551
1015
|
var idx = this._stageIndex(this.state.messages, stageId);
|
|
552
1016
|
if (idx === -1) return;
|
|
553
1017
|
var ex = this.state.messages[idx];
|
|
554
|
-
if (!ex.isUploadingAttachments) return;
|
|
555
|
-
this.state.messages[idx] = Object.assign({}, ex, {
|
|
1018
|
+
if (!ex.isAwaitingIndexing && !ex._dimSending && !ex.isUploadingAttachments) return;
|
|
1019
|
+
this.state.messages[idx] = Object.assign({}, ex, {
|
|
1020
|
+
isUploadingAttachments: false, isAwaitingIndexing: false, _dimSending: false,
|
|
1021
|
+
});
|
|
556
1022
|
this.host.notify();
|
|
557
1023
|
}
|
|
558
1024
|
|
|
@@ -580,7 +1046,7 @@ export class ChatSession {
|
|
|
580
1046
|
*/
|
|
581
1047
|
awaitIndexingDrained(identity: ChatIdentity): Promise<'drained' | 'timedout' | 'skipped'> {
|
|
582
1048
|
var self = this;
|
|
583
|
-
var svcId = identity && identity.
|
|
1049
|
+
var svcId = identity && identity.projectId;
|
|
584
1050
|
var platform = identity && identity.platform;
|
|
585
1051
|
if (!svcId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve('skipped' as const);
|
|
586
1052
|
var owner = identity.owner;
|
|
@@ -588,11 +1054,26 @@ export class ChatSession {
|
|
|
588
1054
|
var startedAt = nowMs();
|
|
589
1055
|
var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
|
|
590
1056
|
var idleLooks = 0;
|
|
1057
|
+
// null = "could not find out", which the loop below counts as busy. The race
|
|
1058
|
+
// is what makes the timeout necessary rather than tidy: a getChatHistory that
|
|
1059
|
+
// never settles leaves no pending timer behind, so the whole wait — and the
|
|
1060
|
+
// turn, and its bubble — would hang on it indefinitely.
|
|
591
1061
|
var ask = function (status: 'pending' | 'running') {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1062
|
+
var answered = false;
|
|
1063
|
+
return new Promise<any>(function (res) {
|
|
1064
|
+
var bail: any = null;
|
|
1065
|
+
var settle = function (v: any) {
|
|
1066
|
+
if (answered) return;
|
|
1067
|
+
answered = true;
|
|
1068
|
+
if (bail) { clearTimeout(bail); bail = null; }
|
|
1069
|
+
res(v);
|
|
1070
|
+
};
|
|
1071
|
+
bail = setTimeout(function () { settle(null); }, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
|
|
1072
|
+
Promise.resolve(getChatHistory(
|
|
1073
|
+
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
1074
|
+
{ limit: WORKER_PASS_ADOPT_LIMIT },
|
|
1075
|
+
)).then(function (r: any) { settle(r); }, function () { settle(null); });
|
|
1076
|
+
});
|
|
596
1077
|
};
|
|
597
1078
|
// Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
|
|
598
1079
|
// are not work this turn has to wait behind — the server runs the queue in
|
|
@@ -607,24 +1088,68 @@ export class ChatSession {
|
|
|
607
1088
|
return false;
|
|
608
1089
|
};
|
|
609
1090
|
return new Promise(function (resolve) {
|
|
610
|
-
var
|
|
611
|
-
|
|
1091
|
+
var timer: any = null;
|
|
1092
|
+
var lastLookAt = -Infinity;
|
|
1093
|
+
var nudgedThisInterval = false;
|
|
1094
|
+
// A look's queries are OUT. Nothing may start a second one alongside it:
|
|
1095
|
+
// two overlapping looks share idleLooks, so both coming back idle would
|
|
1096
|
+
// count as the two agreeing looks WITHOUT the confirm gap between them —
|
|
1097
|
+
// the one guard that stands between the user and an answer computed from a
|
|
1098
|
+
// half-indexed file.
|
|
1099
|
+
var inFlight = false;
|
|
1100
|
+
var finish = function (v: 'drained' | 'timedout') {
|
|
1101
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
1102
|
+
// De-register on BOTH exits or the waiter leaks a closure per send, and
|
|
1103
|
+
// a later settle re-arms a timer for a turn that has already gone out.
|
|
1104
|
+
var ni = self._drainNudges.indexOf(nudge);
|
|
1105
|
+
if (ni !== -1) self._drainNudges.splice(ni, 1);
|
|
1106
|
+
resolve(v);
|
|
1107
|
+
};
|
|
1108
|
+
var again = function (ms?: number) {
|
|
1109
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
1110
|
+
var wait = ms == null ? (idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS) : ms;
|
|
1111
|
+
timer = setTimeout(look, wait);
|
|
1112
|
+
};
|
|
1113
|
+
// Indexing just ended somewhere (see _nudgeIndexingDrain): ask now rather
|
|
1114
|
+
// than waiting out the rest of the busy interval, which is what made the
|
|
1115
|
+
// turn sit dimmed for another ten seconds after its files were visibly done.
|
|
1116
|
+
var nudge = function () {
|
|
1117
|
+
// Already on the confirm cadence — the two agreeing looks and the gap
|
|
1118
|
+
// between them are the guard, and pulling the second one forward is
|
|
1119
|
+
// exactly what must not happen.
|
|
1120
|
+
if (idleLooks > 0) return;
|
|
1121
|
+
if (inFlight) return; // a look is already asking; nothing to pull forward
|
|
1122
|
+
if (nudgedThisInterval) return; // one pull-forward per interval
|
|
1123
|
+
// A look now would short-circuit on this anyway. Nothing re-fires when
|
|
1124
|
+
// the count reaches zero, deliberately (see trackIndexDispatch): the
|
|
1125
|
+
// cost is up to one busy interval of extra dim, and the alternative is
|
|
1126
|
+
// a nudge fired by work STARTING, which can release a turn early.
|
|
1127
|
+
if (self._indexDispatchesInFlight > 0) return;
|
|
1128
|
+
nudgedThisInterval = true;
|
|
1129
|
+
again(Math.max(0, INDEXING_DRAIN_NUDGE_MIN_GAP_MS - (nowMs() - lastLookAt)));
|
|
612
1130
|
};
|
|
613
1131
|
var look = function () {
|
|
614
|
-
|
|
1132
|
+
timer = null;
|
|
1133
|
+
// Belt to the nudge's brace: never two sets of queries out at once.
|
|
1134
|
+
if (inFlight) return;
|
|
1135
|
+
lastLookAt = nowMs(); nudgedThisInterval = false;
|
|
1136
|
+
if (nowMs() >= deadline) { finish('timedout'); return; }
|
|
615
1137
|
// A pass whose ack is still in flight is not on the queue yet, so no
|
|
616
1138
|
// look can see it.
|
|
617
1139
|
if (self._indexDispatchesInFlight > 0) { idleLooks = 0; again(); return; }
|
|
1140
|
+
inFlight = true;
|
|
618
1141
|
Promise.all([ask('running'), ask('pending')]).then(function (res: any[]) {
|
|
1142
|
+
inFlight = false;
|
|
619
1143
|
var unknown = res[0] === null || res[1] === null;
|
|
620
1144
|
if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
|
|
621
1145
|
else idleLooks += 1;
|
|
622
1146
|
if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
|
|
623
|
-
|
|
1147
|
+
finish('drained'); return;
|
|
624
1148
|
}
|
|
625
1149
|
again();
|
|
626
|
-
}, function () { idleLooks = 0; again(); });
|
|
1150
|
+
}, function () { inFlight = false; idleLooks = 0; again(); });
|
|
627
1151
|
};
|
|
1152
|
+
self._drainNudges.push(nudge);
|
|
628
1153
|
look();
|
|
629
1154
|
});
|
|
630
1155
|
}
|
|
@@ -636,12 +1161,17 @@ export class ChatSession {
|
|
|
636
1161
|
* failure separately.
|
|
637
1162
|
*/
|
|
638
1163
|
settleStagedMessage(stageId: string): void {
|
|
1164
|
+
// Retired even when the bubble is not on screen (another project is): the
|
|
1165
|
+
// chain is over either way, and a cached copy must not read as still working.
|
|
1166
|
+
delete this._liveStages[stageId];
|
|
639
1167
|
var idx = this._stageIndex(this.state.messages, stageId);
|
|
640
1168
|
if (idx === -1) return;
|
|
641
1169
|
var ex = this.state.messages[idx];
|
|
642
1170
|
var settled: ChatMessage = { role: 'user', content: ex.content };
|
|
643
1171
|
if (ex._ownerKey !== undefined) settled._ownerKey = ex._ownerKey;
|
|
644
1172
|
if (ex._ts !== undefined) settled._ts = ex._ts;
|
|
1173
|
+
// Same key across the settle: it has no server id and never will.
|
|
1174
|
+
if (ex._localId !== undefined) settled._localId = ex._localId;
|
|
645
1175
|
this.state.messages[idx] = settled;
|
|
646
1176
|
this.host.notify(); this.updateHistoryCache();
|
|
647
1177
|
}
|
|
@@ -665,6 +1195,9 @@ export class ChatSession {
|
|
|
665
1195
|
// caller doesn't pin (the widget, which has only one project anyway).
|
|
666
1196
|
var id = pinned ? pinned.identity : this.host.getIdentity();
|
|
667
1197
|
if (id.platform === 'none') { if (stageId) this.settleStagedMessage(stageId); return; }
|
|
1198
|
+
// Past every bail: this turn is going out, so its stage is over whichever
|
|
1199
|
+
// branch below takes it, and whether or not the bubble is still on screen.
|
|
1200
|
+
if (stageId) delete this._liveStages[stageId];
|
|
668
1201
|
|
|
669
1202
|
var llmComposed = composedForLlm || composed;
|
|
670
1203
|
|
|
@@ -672,8 +1205,8 @@ export class ChatSession {
|
|
|
672
1205
|
// bubble is stamped with it so a project switch (which flips
|
|
673
1206
|
// getIdentity()/getHistoryCacheKey() to the new project) can't
|
|
674
1207
|
// misattribute this turn's bubbles to that project.
|
|
675
|
-
// (platform === 'none' already returned above, so
|
|
676
|
-
var key = !id.
|
|
1208
|
+
// (platform === 'none' already returned above, so projectId is the only gate)
|
|
1209
|
+
var key = !id.projectId ? '' : id.projectId + '#' + id.platform;
|
|
677
1210
|
// True when the pinned chat is NOT the one currently on screen. Then
|
|
678
1211
|
// state.messages belongs to a different project and MUST NOT be touched:
|
|
679
1212
|
// the turn is staged in the pinned chat's cache instead and shows up when
|
|
@@ -687,7 +1220,7 @@ export class ChatSession {
|
|
|
687
1220
|
var aiPlatform = id.platform;
|
|
688
1221
|
var aiModel = id.model || undefined;
|
|
689
1222
|
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
690
|
-
var userId = id.userId || id.
|
|
1223
|
+
var userId = id.userId || id.projectId;
|
|
691
1224
|
// Same string the indexing passes are enqueued under (bgIndexingQueueName),
|
|
692
1225
|
// which is the whole reason this turn ends up behind them: the backend runs
|
|
693
1226
|
// different queue names in parallel and only serialises a shared one.
|
|
@@ -707,7 +1240,7 @@ export class ChatSession {
|
|
|
707
1240
|
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
708
1241
|
});
|
|
709
1242
|
var offBounded = buildBoundedChatMessages({
|
|
710
|
-
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt,
|
|
1243
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
711
1244
|
history: offHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
712
1245
|
});
|
|
713
1246
|
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
@@ -723,8 +1256,22 @@ export class ChatSession {
|
|
|
723
1256
|
this.state.messages.splice(offStage, 1);
|
|
724
1257
|
this.host.notify();
|
|
725
1258
|
}
|
|
1259
|
+
// The cached copy of that same staged bubble has to go with it. offUser is
|
|
1260
|
+
// the turn it stood in for, appended just below; leaving both would show the
|
|
1261
|
+
// question twice on the next visit, permanently — the cache is restored
|
|
1262
|
+
// verbatim. (Its _ts is recovered above only when the live bubble is still
|
|
1263
|
+
// there; take it from the cached copy otherwise, so the turn keeps the time
|
|
1264
|
+
// the user sent it at rather than the upload's finish time.)
|
|
1265
|
+
var offCached = offExisting.messages;
|
|
1266
|
+
if (stageId) {
|
|
1267
|
+
offCached = offCached.filter(function (m) {
|
|
1268
|
+
if (m._stageId !== stageId) return true;
|
|
1269
|
+
if (offStage === -1 && m._ts !== undefined) offUser._ts = m._ts;
|
|
1270
|
+
return false;
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
726
1273
|
this.aiChatHistoryCache[key] = {
|
|
727
|
-
messages:
|
|
1274
|
+
messages: offCached.concat([
|
|
728
1275
|
offUser,
|
|
729
1276
|
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
730
1277
|
]),
|
|
@@ -732,7 +1279,7 @@ export class ChatSession {
|
|
|
732
1279
|
startKeyHistory: offExisting.startKeyHistory,
|
|
733
1280
|
};
|
|
734
1281
|
this.dispatchAgentRequest({
|
|
735
|
-
key: key,
|
|
1282
|
+
key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
736
1283
|
systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
|
|
737
1284
|
extractContent: extractContent, fileUrls: fileUrls,
|
|
738
1285
|
});
|
|
@@ -745,30 +1292,38 @@ export class ChatSession {
|
|
|
745
1292
|
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
746
1293
|
});
|
|
747
1294
|
var boundedQ = buildBoundedChatMessages({
|
|
748
|
-
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt,
|
|
1295
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
749
1296
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
750
1297
|
});
|
|
751
|
-
|
|
1298
|
+
// _localId for the same reason as immediateUser below: a queued turn can be
|
|
1299
|
+
// renumbered by indexing rows spliced in above it, and an id-less bubble is
|
|
1300
|
+
// keyed by index. Overwritten by the staged bubble's own id when replacing one.
|
|
1301
|
+
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _dimSending: true, _localId: this._newLocalId(), _ts: wallClockNow() };
|
|
752
1302
|
if (key) queuedBubble._ownerKey = key;
|
|
753
1303
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
754
1304
|
var qStage = this._stageIndex(this.state.messages, stageId);
|
|
755
1305
|
if (qStage !== -1) {
|
|
1306
|
+
// Replace IN PLACE. The turn already sits below its own indexing rows —
|
|
1307
|
+
// drainBgTaskQueue inserted each of them directly above this bubble as
|
|
1308
|
+
// the file's pass started — so there is nothing left to reorder, and the
|
|
1309
|
+
// row order the reader has been looking at all along is the same one the
|
|
1310
|
+
// server will report on the next load.
|
|
1311
|
+
var qEx = this.state.messages[qStage];
|
|
756
1312
|
// Keep the send time the user saw, not the upload's finish time.
|
|
757
|
-
if (
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
1313
|
+
if (qEx._ts !== undefined) queuedBubble._ts = qEx._ts;
|
|
1314
|
+
// This turn waited out its uploads AND its whole indexing chain in full
|
|
1315
|
+
// view; markStagedMessageReady un-dimmed it when that finished. Re-dimming
|
|
1316
|
+
// it now for the one remaining request would read as the wait restarting.
|
|
1317
|
+
if (qEx._dimSending === false) queuedBubble._dimSending = false;
|
|
1318
|
+
if (qEx._localId) queuedBubble._localId = qEx._localId;
|
|
1319
|
+
this.state.messages.splice(qStage, 1, queuedBubble);
|
|
765
1320
|
} else {
|
|
766
1321
|
this.state.messages.push(queuedBubble);
|
|
767
1322
|
}
|
|
768
1323
|
this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
|
|
769
1324
|
|
|
770
1325
|
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
771
|
-
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.
|
|
1326
|
+
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner))
|
|
772
1327
|
.then(function (result: any) {
|
|
773
1328
|
// Only ack a bubble that belongs to THIS chat — the search is
|
|
774
1329
|
// positional, so on another project it would stamp this turn's
|
|
@@ -783,7 +1338,7 @@ export class ChatSession {
|
|
|
783
1338
|
});
|
|
784
1339
|
var serverId = result && typeof result.id === 'string' ? result.id : undefined;
|
|
785
1340
|
if (sendingIdx >= 0) {
|
|
786
|
-
var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
|
|
1341
|
+
var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
|
|
787
1342
|
if (serverId) upd._serverItemId = serverId;
|
|
788
1343
|
self.state.messages[sendingIdx] = upd; self.host.notify();
|
|
789
1344
|
}
|
|
@@ -807,19 +1362,22 @@ export class ChatSession {
|
|
|
807
1362
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
808
1363
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
809
1364
|
// request was still in flight.
|
|
810
|
-
|
|
1365
|
+
// _localId on the user bubble too, not just the staged one it may replace: an
|
|
1366
|
+
// ordinary turn sent while another is staged sits BELOW that turn, so every
|
|
1367
|
+
// indexing row spliced in above renumbers it — and a view that keys an id-less
|
|
1368
|
+
// bubble by index would tear it down and rebuild it on every uploaded file.
|
|
1369
|
+
var immediateUser: ChatMessage = { role: 'user', content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) };
|
|
811
1370
|
var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
|
|
812
1371
|
var iStage = this._stageIndex(this.state.messages, stageId);
|
|
813
1372
|
if (iStage !== -1) {
|
|
1373
|
+
// Replace IN PLACE — see the queued branch above for why nothing moves.
|
|
1374
|
+
var iEx = this.state.messages[iStage];
|
|
814
1375
|
// Keep the send time the user saw, not the upload's finish time.
|
|
815
|
-
if (
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
this.state.messages.splice(iStage, 1);
|
|
821
|
-
this.state.messages.splice(iTarget, 0, immediateUser, immediatePlaceholder);
|
|
822
|
-
}
|
|
1376
|
+
if (iEx._ts !== undefined) immediateUser._ts = iEx._ts;
|
|
1377
|
+
// Same id across the swap, so the row is not re-keyed (and in Vue, remounted)
|
|
1378
|
+
// at the very moment the turn goes out.
|
|
1379
|
+
if (iEx._localId) immediateUser._localId = iEx._localId;
|
|
1380
|
+
this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
|
|
823
1381
|
} else {
|
|
824
1382
|
this.state.messages.push(immediateUser);
|
|
825
1383
|
this.state.messages.push(immediatePlaceholder);
|
|
@@ -858,11 +1416,11 @@ export class ChatSession {
|
|
|
858
1416
|
});
|
|
859
1417
|
historyForLlm.push({ role: 'user', content: llmComposed });
|
|
860
1418
|
var bounded = buildBoundedChatMessages({
|
|
861
|
-
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt,
|
|
1419
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
862
1420
|
history: historyForLlm,
|
|
863
1421
|
});
|
|
864
1422
|
var run = this.dispatchAgentRequest({
|
|
865
|
-
key: key,
|
|
1423
|
+
key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
866
1424
|
systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
|
|
867
1425
|
extractContent: extractContent, fileUrls: fileUrls,
|
|
868
1426
|
});
|
|
@@ -925,6 +1483,14 @@ export class ChatSession {
|
|
|
925
1483
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
926
1484
|
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
927
1485
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
1486
|
+
// Carried alongside isSendingToServer, never derived from it: an attachment
|
|
1487
|
+
// turn promoted to running while its ack is still out was already un-dimmed
|
|
1488
|
+
// by markStagedMessageReady and must stay that way.
|
|
1489
|
+
if (existing._dimSending) promoted._dimSending = true;
|
|
1490
|
+
// A turn promoted BEFORE its ack has no server id, so this is the only stable
|
|
1491
|
+
// render key it has; dropping it here re-keyed the bubble by index and undid
|
|
1492
|
+
// the reason it was minted (rows spliced in above renumber it).
|
|
1493
|
+
if (existing._localId !== undefined) promoted._localId = existing._localId;
|
|
928
1494
|
this.state.messages[nextIdx] = promoted;
|
|
929
1495
|
// Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
|
|
930
1496
|
// (mirrors promoteNextBgQueuedToRunning). Without it, when this promoted turn
|
|
@@ -941,6 +1507,36 @@ export class ChatSession {
|
|
|
941
1507
|
this.host.notify();
|
|
942
1508
|
}
|
|
943
1509
|
|
|
1510
|
+
/**
|
|
1511
|
+
* The "Thinking..." placeholder belonging to the user bubble at `userIdx`, or -1.
|
|
1512
|
+
*
|
|
1513
|
+
* Every path that creates one puts it IMMEDIATELY after its user bubble
|
|
1514
|
+
* (promoteNextQueuedToRunning, the immediate-send pair, applyHistoryItemResolution),
|
|
1515
|
+
* so ownership is adjacency — modulo background bubbles, which get spliced in
|
|
1516
|
+
* around them. Taking the first pending assistant ANYWHERE below instead was a
|
|
1517
|
+
* hijack: a turn sent with attachments never gets a placeholder of its own
|
|
1518
|
+
* (promoteNextQueuedToRunning skips _useBgQueue turns) and now keeps the position
|
|
1519
|
+
* it was sent in, so an ordinary turn sent while its files indexed sits BELOW it
|
|
1520
|
+
* with a placeholder of its own — and the attachment turn's answer was rendered
|
|
1521
|
+
* as the answer to that unrelated question.
|
|
1522
|
+
*/
|
|
1523
|
+
private _ownThinkingIndex(userIdx: number, serverId?: string): number {
|
|
1524
|
+
if (userIdx < 0) return -1;
|
|
1525
|
+
for (var i = userIdx + 1; i < this.state.messages.length; i++) {
|
|
1526
|
+
var m = this.state.messages[i];
|
|
1527
|
+
if (!m) return -1;
|
|
1528
|
+
if (m.isBackgroundTask) continue; // an indexing row spliced between the pair
|
|
1529
|
+
if (m.isPending && m.role === 'assistant') {
|
|
1530
|
+
// A placeholder stamped for a DIFFERENT request is not this turn's, even
|
|
1531
|
+
// adjacent: ids are authoritative wherever both sides have one.
|
|
1532
|
+
if (serverId && m._serverItemId && m._serverItemId !== serverId) return -1;
|
|
1533
|
+
return i;
|
|
1534
|
+
}
|
|
1535
|
+
return -1; // the next real turn starts here; this one has no placeholder
|
|
1536
|
+
}
|
|
1537
|
+
return -1;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
944
1540
|
resolveQueuedUserBubble(serverId?: string): number | undefined {
|
|
945
1541
|
// The two fallbacks below match by POSITION, not identity, so they must
|
|
946
1542
|
// never consider a bubble stamped for a different chat.
|
|
@@ -968,9 +1564,7 @@ export class ChatSession {
|
|
|
968
1564
|
if (userIdx >= 0) {
|
|
969
1565
|
var ex = this.state.messages[userIdx];
|
|
970
1566
|
this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
|
|
971
|
-
var thIdx = this.
|
|
972
|
-
return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
973
|
-
});
|
|
1567
|
+
var thIdx = this._ownThinkingIndex(userIdx, serverId);
|
|
974
1568
|
if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
|
|
975
1569
|
}
|
|
976
1570
|
this.promoteNextQueuedToRunning();
|
|
@@ -982,11 +1576,12 @@ export class ChatSession {
|
|
|
982
1576
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
983
1577
|
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
984
1578
|
if (exist._ts !== undefined) repl._ts = exist._ts;
|
|
1579
|
+
// Kept so the bubble is not re-keyed (and in Vue, remounted) at the moment
|
|
1580
|
+
// its answer arrives; the views prefer _serverItemId, this is the fallback.
|
|
1581
|
+
if (exist._localId !== undefined) repl._localId = exist._localId;
|
|
985
1582
|
this.state.messages[userIdx] = repl;
|
|
986
1583
|
}
|
|
987
|
-
var thinkingIdx = userIdx
|
|
988
|
-
? this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; })
|
|
989
|
-
: -1;
|
|
1584
|
+
var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
|
|
990
1585
|
return thinkingIdx !== -1 ? thinkingIdx : (userIdx >= 0 ? userIdx + 1 : -1);
|
|
991
1586
|
}
|
|
992
1587
|
|
|
@@ -994,7 +1589,17 @@ export class ChatSession {
|
|
|
994
1589
|
// Error/direct replies land here rather than through the typewriter, so this
|
|
995
1590
|
// is where they pick up their display time.
|
|
996
1591
|
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
|
|
997
|
-
|
|
1592
|
+
// Overwrite only a placeholder that genuinely belongs to the turn above it.
|
|
1593
|
+
// Two things now sit at this index that must never be replaced by a chat
|
|
1594
|
+
// answer: a background "Indexing:" placeholder (a file's rows are inserted
|
|
1595
|
+
// directly above the turn they belong to, so one can land here) — replacing it
|
|
1596
|
+
// deletes a live pass from its collapsed row — and another request's
|
|
1597
|
+
// placeholder, which resolveQueuedUserBubble deliberately declined to claim.
|
|
1598
|
+
// Anything else is spliced in beside it.
|
|
1599
|
+
var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : undefined;
|
|
1600
|
+
var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask &&
|
|
1601
|
+
this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
|
|
1602
|
+
if (replaceable) this.state.messages[targetIdx] = msg;
|
|
998
1603
|
else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
|
|
999
1604
|
else this.state.messages.push(msg);
|
|
1000
1605
|
}
|
|
@@ -1101,12 +1706,30 @@ export class ChatSession {
|
|
|
1101
1706
|
var platform = id.platform;
|
|
1102
1707
|
if (platform !== 'claude' && platform !== 'openai') return;
|
|
1103
1708
|
var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1104
|
-
var queueBase = id.userId || id.
|
|
1709
|
+
var queueBase = id.userId || id.projectId;
|
|
1105
1710
|
var queue = (msg.isBackgroundTask || msg._useBgQueue) ? bgIndexingQueueName(queueBase) : queueBase;
|
|
1106
|
-
|
|
1711
|
+
// `idx` is the index the VIEW rendered this bubble at, and the list can have
|
|
1712
|
+
// moved under it since: indexing rows are inserted above the turn they belong
|
|
1713
|
+
// to (drainBgTaskQueue), so a pass that starts between the last render and the
|
|
1714
|
+
// click shifts every bubble below it down. Writing blind at a stale index
|
|
1715
|
+
// overwrites an unrelated message with a copy of this one. The server id is the
|
|
1716
|
+
// identity — cancelQueuedMessage already refuses to run without one.
|
|
1717
|
+
// The role test belongs on BOTH arms: a request bubble and its reply share one
|
|
1718
|
+
// _serverItemId, so an id-only fast path would paint "Stopping..." on the
|
|
1719
|
+
// reply whenever the list shifted UP by one instead (a stray placeholder
|
|
1720
|
+
// swept, a sibling pass cancelled, a NOT_EXISTS splice).
|
|
1721
|
+
var at = (this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === msg.role)
|
|
1722
|
+
? idx
|
|
1723
|
+
: this.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.role === msg.role; });
|
|
1724
|
+
// Gone from the list entirely (a history refetch rebuilt it): skip the paint —
|
|
1725
|
+
// writing at the rendered index would clobber a stranger — but still SEND the
|
|
1726
|
+
// cancel. The user asked for it, and the server request is the part that matters.
|
|
1727
|
+
if (at !== -1) {
|
|
1728
|
+
this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: undefined });
|
|
1729
|
+
}
|
|
1107
1730
|
this.host.notify();
|
|
1108
1731
|
Promise.resolve(this.host.cancelRequest({
|
|
1109
|
-
url: url, method: 'POST', id: serverId, queue: queue, service: id.
|
|
1732
|
+
url: url, method: 'POST', id: serverId, queue: queue, service: id.projectId, owner: id.owner,
|
|
1110
1733
|
})).then(function (result: any) {
|
|
1111
1734
|
if (result && result.removed) {
|
|
1112
1735
|
self.cancelledServerIds.add(serverId as string);
|
|
@@ -1167,7 +1790,10 @@ export class ChatSession {
|
|
|
1167
1790
|
* 2. the file is remembered in cancelledIndexKeys, so the client-driven
|
|
1168
1791
|
* resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
|
|
1169
1792
|
* 3. any of its passes still sitting in bgTaskQueue is dropped by the next
|
|
1170
|
-
* drain rather than surfacing a fresh "Indexing…" bubble
|
|
1793
|
+
* drain rather than surfacing a fresh "Indexing…" bubble; and
|
|
1794
|
+
* 4. the RUN is remembered (state.stoppedIndexIds), because none of the above
|
|
1795
|
+
* necessarily leaves a mark on the conversation — see below — and without
|
|
1796
|
+
* it the collapsed row reported the stopped file as finished.
|
|
1171
1797
|
*
|
|
1172
1798
|
* Records already written by the passes that DID run are kept — this stops the
|
|
1173
1799
|
* work, it does not undo it.
|
|
@@ -1179,6 +1805,51 @@ export class ChatSession {
|
|
|
1179
1805
|
// _indexKeyOf scopes a queued task's.
|
|
1180
1806
|
var scoped = this.getHistoryCacheKey() + '|' + group.key;
|
|
1181
1807
|
this.cancelledIndexKeys.add(scoped);
|
|
1808
|
+
// Remember WHICH RUN was stopped, by the ids of the passes it is made of.
|
|
1809
|
+
// Everything else here stops the work without leaving any mark on the
|
|
1810
|
+
// conversation: a running pass cannot be un-run (it finishes and writes an
|
|
1811
|
+
// ordinary answer), and a queued continuation is dropped before it is ever
|
|
1812
|
+
// surfaced — so a stopped run's newest bubble is routinely a successful pass,
|
|
1813
|
+
// and the row read "Indexed" over a file the user had just stopped. Ids, not
|
|
1814
|
+
// the file key, so a re-upload or Reindex of the same path is a new run with
|
|
1815
|
+
// new ids and starts clean; and on the reactive state so the row updates the
|
|
1816
|
+
// moment this is recorded, since a stop with nothing left to cancel changes
|
|
1817
|
+
// no message at all.
|
|
1818
|
+
//
|
|
1819
|
+
// Not for a run that is already OVER, though — you cannot stop what has
|
|
1820
|
+
// finished, and marking it stopped would relabel a fully-indexed file as
|
|
1821
|
+
// cancelled on the strength of a click that arrived too late. (`finished` is
|
|
1822
|
+
// only ever positively established: "still running" and "we have not found
|
|
1823
|
+
// out" both read as false here, so the doubt goes to recording the stop.)
|
|
1824
|
+
if (!group.finished) {
|
|
1825
|
+
var stoppedIds: { [id: string]: boolean } = {};
|
|
1826
|
+
for (var sk in this.state.stoppedIndexIds) stoppedIds[sk] = true;
|
|
1827
|
+
(group.members || []).forEach(function (m) {
|
|
1828
|
+
var sid = m && m.msg && m.msg._serverItemId;
|
|
1829
|
+
if (sid) stoppedIds[sid] = true;
|
|
1830
|
+
});
|
|
1831
|
+
// The queue's own view of this file, which is not always the row's: an
|
|
1832
|
+
// entry can be waiting for its bubble, and the FIRST pass's entry outlives
|
|
1833
|
+
// the moment its bubble settles. _applyIndexCancellations reads this to
|
|
1834
|
+
// tell a pass that existed when the user hit Stop from a genuinely new
|
|
1835
|
+
// indexing request, and only the latter is allowed to lift the stop.
|
|
1836
|
+
this.bgTaskQueue.forEach(function (e) {
|
|
1837
|
+
if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
|
|
1838
|
+
});
|
|
1839
|
+
this.state.stoppedIndexIds = stoppedIds;
|
|
1840
|
+
}
|
|
1841
|
+
// Ask the QUEUE what else is live for this file, and cancel that too.
|
|
1842
|
+
//
|
|
1843
|
+
// The ids above are the passes THIS client knows about, and for a
|
|
1844
|
+
// worker-driven file that is routinely none of them: the worker mints the
|
|
1845
|
+
// next window itself and the client only learns its id on a later look. A
|
|
1846
|
+
// stop that reaches no server row at all is a stop that dies with the tab —
|
|
1847
|
+
// the chain keeps running, and the only record of the stop is this page's
|
|
1848
|
+
// memory. The adopt ladder is the existing machinery for exactly this
|
|
1849
|
+
// question; anything it turns up for a stopped file is dropped and
|
|
1850
|
+
// server-cancelled by _applyIndexCancellations on the next drain, which is
|
|
1851
|
+
// also what makes the worker stop enqueueing windows.
|
|
1852
|
+
this._adoptWorkerIndexingPasses(0);
|
|
1182
1853
|
var ids = group.cancellableIds || [];
|
|
1183
1854
|
if (!ids.length) { this.host.notify(); return; }
|
|
1184
1855
|
ids.forEach(function (serverId) {
|
|
@@ -1352,23 +2023,77 @@ export class ChatSession {
|
|
|
1352
2023
|
return this.enqueueTypewrite(pendingIdx, latest.content, lid);
|
|
1353
2024
|
}
|
|
1354
2025
|
|
|
1355
|
-
// Remove
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
2026
|
+
// Remove leftover non-background pending ("Thinking…") assistant bubbles: the
|
|
2027
|
+
// duplicate that appears when a concurrent history refetch re-maps the still-
|
|
2028
|
+
// "running" turn into a pending placeholder (with a real _serverItemId) while the
|
|
2029
|
+
// local pending bubble (no _serverItemId) is rescued and re-appended (see the
|
|
2030
|
+
// loadHistory rescue below), and the orphan a resolve leaves when it splices its
|
|
2031
|
+
// reply beside a placeholder instead of into it. Each resolve path only replaces
|
|
2032
|
+
// ONE pending bubble, so without this a stray "Thinking…" survives forever next to
|
|
2033
|
+
// the reply. MUST run AFTER the resolved bubble has been made non-pending and
|
|
2034
|
+
// BEFORE promoteNext*() (which only adds a Thinking once none remains).
|
|
2035
|
+
//
|
|
2036
|
+
// It used to take EVERY one, on the premise that there is at most one at a time
|
|
2037
|
+
// because promoteNext* refuses to add a second. That premise never covered the
|
|
2038
|
+
// immediate-send path, which creates its pair directly — and a turn sent with
|
|
2039
|
+
// attachments does not block the composer and resolves on its own queue, so an
|
|
2040
|
+
// ordinary question asked while files index is in flight, with a placeholder of
|
|
2041
|
+
// its own, exactly when the attachment turn resolves. Sweeping it left that
|
|
2042
|
+
// question with no spinner and, worse, nowhere for its answer to land:
|
|
2043
|
+
// typewriteLatestReply bails when there is no pending assistant, so the reply
|
|
2044
|
+
// reached the cache and never the screen.
|
|
2045
|
+
//
|
|
2046
|
+
// The discriminator is the owning USER bubble. A live immediate send's user bubble
|
|
2047
|
+
// carries NO pending flags (its in-flight-ness lives in state.sending), while every
|
|
2048
|
+
// duplicate this sweep is for belongs to a user bubble that is still pending — and
|
|
2049
|
+
// an orphan has no user bubble above it at all.
|
|
1365
2050
|
_removeStrayPendingAssistants(): void {
|
|
1366
2051
|
for (var k = this.state.messages.length - 1; k >= 0; k--) {
|
|
1367
2052
|
var m = this.state.messages[k];
|
|
1368
|
-
if (m.isPending
|
|
2053
|
+
if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
|
|
2054
|
+
if (this._isLiveImmediatePlaceholder(k)) continue;
|
|
2055
|
+
this.state.messages.splice(k, 1);
|
|
1369
2056
|
}
|
|
1370
2057
|
}
|
|
1371
2058
|
|
|
2059
|
+
/** Index of the USER bubble the message at `idx` belongs to — the nearest one
|
|
2060
|
+
* above it, stepping over background bubbles (a file's indexing rows are
|
|
2061
|
+
* inserted between turns). -1 when the nearest thing above is not a user turn,
|
|
2062
|
+
* which for a placeholder means it is an orphan. */
|
|
2063
|
+
private _owningUserIndex(idx: number): number {
|
|
2064
|
+
for (var j = idx - 1; j >= 0; j--) {
|
|
2065
|
+
var p = this.state.messages[j];
|
|
2066
|
+
if (!p) return -1;
|
|
2067
|
+
if (p.isBackgroundTask) continue;
|
|
2068
|
+
return p.role === 'user' ? j : -1;
|
|
2069
|
+
}
|
|
2070
|
+
return -1;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/** The bubble at `idx` is the "Thinking…" of a DIFFERENT turn that is still
|
|
2074
|
+
* waiting for its answer, so the sweep above must leave it alone. */
|
|
2075
|
+
private _isLiveImmediatePlaceholder(idx: number): boolean {
|
|
2076
|
+
var ui = this._owningUserIndex(idx);
|
|
2077
|
+
if (ui === -1) return false; // orphan
|
|
2078
|
+
var p = this.state.messages[ui];
|
|
2079
|
+
// Its own turn, still unanswered and not one of the pending duplicates: a live
|
|
2080
|
+
// immediate send's user bubble carries no pending flags at all.
|
|
2081
|
+
return !p.isPending && !p.isPendingQueued && !p.isPendingInProcess &&
|
|
2082
|
+
!p.isPendingOlder && !p.isSendingToServer && !p.isCancelled;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/** A pending assistant at `idx` is the placeholder OF the turn above it, so a
|
|
2086
|
+
* reply may take its slot. Every path that makes one copies the parent's
|
|
2087
|
+
* _serverItemId (or neither has one yet), so a mismatch means the slot belongs to
|
|
2088
|
+
* some other request and the reply must be spliced in beside it, not on top. */
|
|
2089
|
+
private _isOwnPlaceholderOf(idx: number, userIdx: number): boolean {
|
|
2090
|
+
if (userIdx === -1) return false;
|
|
2091
|
+
var ph = this.state.messages[idx], u = this.state.messages[userIdx];
|
|
2092
|
+
if (!ph || !u) return false;
|
|
2093
|
+
if (ph._serverItemId === undefined || u._serverItemId === undefined) return true;
|
|
2094
|
+
return ph._serverItemId === u._serverItemId;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
1372
2097
|
// Drop the pending flags on the resolved turn's USER bubble (preserving its
|
|
1373
2098
|
// content + background-task marker). Needed because a bg "Indexing:" turn's user
|
|
1374
2099
|
// bubble carries isPendingInProcess; leaving it set keeps the bubble visually
|
|
@@ -1420,6 +2145,11 @@ export class ChatSession {
|
|
|
1420
2145
|
var indexRef = this._indexRefOfItem(itemId);
|
|
1421
2146
|
this.applyHistoryItemResolution(itemId, response, platform);
|
|
1422
2147
|
this.promoteNextBgQueuedToRunning();
|
|
2148
|
+
// applyHistoryItemResolution just released this item's poll slot. Under
|
|
2149
|
+
// MAX_CONCURRENT_BG_POLLS that slot is what the next-oldest unpolled entry
|
|
2150
|
+
// is waiting for, and every settling turn funnels through here — including
|
|
2151
|
+
// the history poll, whose resolution has no other path back to the drain.
|
|
2152
|
+
this.drainBgTaskQueue();
|
|
1423
2153
|
// A worker-driven chain has no client-side record of its next pass, so a
|
|
1424
2154
|
// settling pass is the only moment there is to go looking for one.
|
|
1425
2155
|
if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
|
|
@@ -1437,13 +2167,85 @@ export class ChatSession {
|
|
|
1437
2167
|
return null;
|
|
1438
2168
|
}
|
|
1439
2169
|
|
|
2170
|
+
/**
|
|
2171
|
+
* Settle a turn the server reports as cancelled: the request bubble goes to its
|
|
2172
|
+
* cancelled form and the "Thinking..." placeholder goes away. The same shape
|
|
2173
|
+
* cancelQueuedMessage produces locally, so a cancel this client made and one it
|
|
2174
|
+
* merely found out about render identically — and an indexing pass keeps the
|
|
2175
|
+
* markers that hold it in its file's collapsed row.
|
|
2176
|
+
*/
|
|
2177
|
+
private _settleCancelledItem(itemId: string): void {
|
|
2178
|
+
var uIdx = this.state.messages.findIndex(function (m) {
|
|
2179
|
+
return m.role === 'user' && m._serverItemId === itemId && !m.isCancelled;
|
|
2180
|
+
});
|
|
2181
|
+
if (uIdx !== -1) {
|
|
2182
|
+
var u = this.state.messages[uIdx];
|
|
2183
|
+
var cancelled: ChatMessage = { role: 'user', content: u.content, isCancelled: true, _serverItemId: itemId };
|
|
2184
|
+
if (u.isBackgroundTask) cancelled.isBackgroundTask = true;
|
|
2185
|
+
if (u._indexFile) cancelled._indexFile = u._indexFile;
|
|
2186
|
+
if (u._useBgQueue) cancelled._useBgQueue = true;
|
|
2187
|
+
if (u._ownerKey !== undefined) cancelled._ownerKey = u._ownerKey;
|
|
2188
|
+
if (u._ts !== undefined) cancelled._ts = u._ts;
|
|
2189
|
+
this.state.messages[uIdx] = cancelled;
|
|
2190
|
+
}
|
|
2191
|
+
var pIdx = this.state.messages.findIndex(function (m) {
|
|
2192
|
+
return m.isPending && m.role === 'assistant' && m._serverItemId === itemId;
|
|
2193
|
+
});
|
|
2194
|
+
if (pIdx !== -1) this.state.messages.splice(pIdx, 1);
|
|
2195
|
+
this.cancelledServerIds.delete(itemId);
|
|
2196
|
+
this._removeStrayPendingAssistants();
|
|
2197
|
+
this.host.notify();
|
|
2198
|
+
this.updateHistoryCache();
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* A poll that came back saying the request was CANCELLED, rather than with an
|
|
2203
|
+
* answer.
|
|
2204
|
+
*
|
|
2205
|
+
* The server keeps a cancelled request as a terminal row instead of deleting it
|
|
2206
|
+
* (that row is the durable record of the stop, and the chat history it belongs
|
|
2207
|
+
* to), so a poll still running when the cancel lands now RESOLVES on it. It used
|
|
2208
|
+
* to reject with NOT_EXISTS, and the resolution path below reads a status object
|
|
2209
|
+
* as an answer with no text — which would stamp "No text response received from
|
|
2210
|
+
* AI provider" over a turn the user had just stopped.
|
|
2211
|
+
*
|
|
2212
|
+
* Reachable whenever the poll was not stopped by whoever cancelled: another tab,
|
|
2213
|
+
* another device, or the row being cancelled server-side by the file's own stop.
|
|
2214
|
+
*/
|
|
2215
|
+
private _isCancelledPollResult(response: any): boolean {
|
|
2216
|
+
if (!response || typeof response !== 'object' || response.status !== 'cancelled') return false;
|
|
2217
|
+
// The POLL's own shape (id + queue fields, no provider payload), not a
|
|
2218
|
+
// provider body that happens to carry a `status` — OpenAI's Responses API
|
|
2219
|
+
// has a "cancelled" status of its own, and that one is an answer to render,
|
|
2220
|
+
// not a row that was removed from the queue.
|
|
2221
|
+
if (response.content !== undefined || response.output !== undefined) return false;
|
|
2222
|
+
return response.queue_name !== undefined || response.in_queue !== undefined;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1440
2225
|
applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
|
|
1441
2226
|
this.historyItemPolls.delete(itemId);
|
|
2227
|
+
if (this._isCancelledPollResult(response)) {
|
|
2228
|
+
this._settleCancelledItem(itemId);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
1442
2231
|
var isErr = isErrorResponseBody(response);
|
|
1443
2232
|
var answer = isErr ? getErrorMessage(response)
|
|
1444
2233
|
: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
|
|
1445
|
-
//
|
|
1446
|
-
|
|
2234
|
+
// Record the marker BEFORE hiding it from the displayed summary: the display
|
|
2235
|
+
// layer needs it as a structured fact, and searching the shown text for it
|
|
2236
|
+
// afterwards would find nothing. Mirrors history.ts, so a run reads the same
|
|
2237
|
+
// live and after a reload.
|
|
2238
|
+
//
|
|
2239
|
+
// The STRIP happens in the background branches only, not here. It used to run
|
|
2240
|
+
// on every answer, so an ordinary chat reply that merely mentioned the token
|
|
2241
|
+
// had it silently cut out of the user's text (leaving a double space) — and
|
|
2242
|
+
// the history mappers never did that, so the same reply read differently
|
|
2243
|
+
// before and after a reload. Only an INDEXING pass has a protocol token to
|
|
2244
|
+
// hide.
|
|
2245
|
+
var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
|
|
2246
|
+
var stripMarker = function (t: string): string {
|
|
2247
|
+
return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join('').trim() : t;
|
|
2248
|
+
};
|
|
1447
2249
|
var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
|
|
1448
2250
|
if (idx !== -1) {
|
|
1449
2251
|
// A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
|
|
@@ -1474,7 +2276,7 @@ export class ChatSession {
|
|
|
1474
2276
|
// history, and ran a per-frame scroll for content nobody can see.
|
|
1475
2277
|
// Write it straight in; expanding the row then shows it complete.
|
|
1476
2278
|
if (wasBgTask) {
|
|
1477
|
-
this.state.messages[idx] = { role: 'assistant', content: text, isBackgroundTask: true, _serverItemId: itemId };
|
|
2279
|
+
this.state.messages[idx] = { role: 'assistant', content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...(reportedComplete ? { _indexComplete: true } : {}) };
|
|
1478
2280
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
1479
2281
|
}
|
|
1480
2282
|
var lid = this._newLocalId();
|
|
@@ -1506,7 +2308,7 @@ export class ChatSession {
|
|
|
1506
2308
|
// Same as above: a collapsed row's reply is not on screen, so revealing it
|
|
1507
2309
|
// character by character only blocks the queue the visible reply needs.
|
|
1508
2310
|
if (ex.isBackgroundTask) {
|
|
1509
|
-
this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: text2, isBackgroundTask: true, _serverItemId: itemId });
|
|
2311
|
+
this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: stripMarker(text2) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...(reportedComplete ? { _indexComplete: true } : {}) });
|
|
1510
2312
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
1511
2313
|
}
|
|
1512
2314
|
var lid2 = this._newLocalId();
|
|
@@ -1525,7 +2327,7 @@ export class ChatSession {
|
|
|
1525
2327
|
if (!entry) return '';
|
|
1526
2328
|
var file = entry.storagePath || entry.filename;
|
|
1527
2329
|
if (!file) return '';
|
|
1528
|
-
return entry.
|
|
2330
|
+
return entry.projectId + '#' + entry.platform + '|' + file;
|
|
1529
2331
|
}
|
|
1530
2332
|
|
|
1531
2333
|
/**
|
|
@@ -1536,14 +2338,38 @@ export class ChatSession {
|
|
|
1536
2338
|
* path, and without this an earlier cancel would silently kill every future
|
|
1537
2339
|
* index of the same path. A continuation of a stopped file is dropped instead,
|
|
1538
2340
|
* covering the pass that was dispatched in the moment before the cancel landed.
|
|
2341
|
+
*
|
|
2342
|
+
* "Fresh" is the load-bearing word, and it used to be missing. A run's OWN first
|
|
2343
|
+
* pass sits in this queue for as long as it runs (entries are only dropped once
|
|
2344
|
+
* their bubble settles), so stopping a file during its first pass — which is
|
|
2345
|
+
* exactly when a user who has just uploaded it does — met that first-pass entry
|
|
2346
|
+
* on the very next drain and lifted the stop the user had just asked for. The
|
|
2347
|
+
* chain then carried on, one worker-minted window after another, with nothing
|
|
2348
|
+
* client-side left to suppress it. The ids recorded at stop time are what tells
|
|
2349
|
+
* the two apart: a pass that was already there when the user hit Stop cannot be
|
|
2350
|
+
* the new request that lifts it.
|
|
1539
2351
|
*/
|
|
1540
2352
|
private _applyIndexCancellations(): void {
|
|
1541
2353
|
if (!this.cancelledIndexKeys.size) return;
|
|
2354
|
+
// Passes already on screen. Dropping the queue entry for one of those kills
|
|
2355
|
+
// its poll and leaves the bubble pending forever — a row stuck on "Indexing"
|
|
2356
|
+
// with no way to ever settle. _sweepCancelledIndexing (which runs straight
|
|
2357
|
+
// after this, on the same drain) cancels those properly, rebuilding the bubble
|
|
2358
|
+
// as cancelled; the splice below is only right for an entry with no bubble yet.
|
|
2359
|
+
var surfaced: { [id: string]: boolean } = {};
|
|
2360
|
+
this.state.messages.forEach(function (m) {
|
|
2361
|
+
if (!m._serverItemId) return;
|
|
2362
|
+
if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
|
|
2363
|
+
});
|
|
1542
2364
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
1543
2365
|
var entry = this.bgTaskQueue[i];
|
|
1544
2366
|
var key = this._indexKeyOf(entry);
|
|
1545
2367
|
if (!key || !this.cancelledIndexKeys.has(key)) continue;
|
|
1546
|
-
if (!entry.resumePass
|
|
2368
|
+
if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
|
|
2369
|
+
this.cancelledIndexKeys.delete(key);
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
if (surfaced[entry.id]) continue;
|
|
1547
2373
|
this.bgTaskQueue.splice(i, 1);
|
|
1548
2374
|
this._stopPoll(entry.id);
|
|
1549
2375
|
this._cancelServerItem(entry.id);
|
|
@@ -1630,10 +2456,10 @@ export class ChatSession {
|
|
|
1630
2456
|
if (this._adoptingWorkerPasses) return;
|
|
1631
2457
|
var id = this.host.getIdentity();
|
|
1632
2458
|
var platform = id.platform;
|
|
1633
|
-
if (!id.
|
|
2459
|
+
if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return;
|
|
1634
2460
|
if (this.isPollingPaused() || !this.host.isViewMounted()) return;
|
|
1635
|
-
var svcId = id.
|
|
1636
|
-
var queue = bgIndexingQueueName(id.userId, id.
|
|
2461
|
+
var svcId = id.projectId, owner = id.owner;
|
|
2462
|
+
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
1637
2463
|
var ask = function (status: 'pending' | 'running') {
|
|
1638
2464
|
return Promise.resolve(getChatHistory(
|
|
1639
2465
|
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
@@ -1646,8 +2472,16 @@ export class ChatSession {
|
|
|
1646
2472
|
// The chat may have changed under the query; adopting into another
|
|
1647
2473
|
// project's session is the cross-project bubble leak all over again.
|
|
1648
2474
|
var now = self.host.getIdentity();
|
|
1649
|
-
if (now.
|
|
2475
|
+
if (now.projectId !== svcId || now.platform !== platform) return;
|
|
1650
2476
|
if (!self.host.isViewMounted()) return;
|
|
2477
|
+
// Free: this is already the whole-queue snapshot the display layer needs to
|
|
2478
|
+
// tell a worker-driven run's "between passes" from its "finished".
|
|
2479
|
+
//
|
|
2480
|
+
// A null is a FAILED query, and the adoption loop below reads it as an empty
|
|
2481
|
+
// list — harmless there (nothing to adopt) but not here: recording it would
|
|
2482
|
+
// publish "the queue holds nothing" on the strength of a request that never
|
|
2483
|
+
// answered, which reads as finished. Not knowing is the honest state.
|
|
2484
|
+
if (results[0] !== null && results[1] !== null) self._recordLiveIndexKeys(results);
|
|
1651
2485
|
var adoptedIds: string[] = [];
|
|
1652
2486
|
for (var ri = 0; ri < results.length; ri++) {
|
|
1653
2487
|
var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
|
|
@@ -1671,10 +2505,16 @@ export class ChatSession {
|
|
|
1671
2505
|
// empty queue for a chain that is very much alive — and with no pass
|
|
1672
2506
|
// left to settle, nothing would ever ask again. Look once or twice more
|
|
1673
2507
|
// before believing the file is finished.
|
|
1674
|
-
if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length)
|
|
2508
|
+
if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
|
|
2509
|
+
// The ladder is the only thing that can tell a worker-driven chain has
|
|
2510
|
+
// really ended, and it just did. This is the earliest honest moment to
|
|
2511
|
+
// let a turn waiting on these files stop waiting.
|
|
2512
|
+
self._nudgeIndexingDrain();
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
1675
2515
|
setTimeout(function () {
|
|
1676
2516
|
var later = self.host.getIdentity();
|
|
1677
|
-
if (later.
|
|
2517
|
+
if (later.projectId !== svcId || later.platform !== platform) return;
|
|
1678
2518
|
if (self.isPollingPaused() || !self.host.isViewMounted()) return;
|
|
1679
2519
|
self._adoptWorkerIndexingPasses(attempt + 1);
|
|
1680
2520
|
}, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
|
|
@@ -1729,7 +2569,7 @@ export class ChatSession {
|
|
|
1729
2569
|
// chain's passes — which is the cap that stops it running forever.
|
|
1730
2570
|
if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
|
|
1731
2571
|
this.bgTaskQueue.push({
|
|
1732
|
-
|
|
2572
|
+
projectId: svcId,
|
|
1733
2573
|
platform: platform,
|
|
1734
2574
|
id: item.id,
|
|
1735
2575
|
filename: ref.name,
|
|
@@ -1760,8 +2600,8 @@ export class ChatSession {
|
|
|
1760
2600
|
var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1761
2601
|
Promise.resolve(this.host.cancelRequest({
|
|
1762
2602
|
url: url, method: 'POST', id: serverId,
|
|
1763
|
-
queue: bgIndexingQueueName(id.userId, id.
|
|
1764
|
-
service: id.
|
|
2603
|
+
queue: bgIndexingQueueName(id.userId, id.projectId),
|
|
2604
|
+
service: id.projectId, owner: id.owner,
|
|
1765
2605
|
})).catch(function () { /* the pass may already have finished; nothing to do */ });
|
|
1766
2606
|
}
|
|
1767
2607
|
|
|
@@ -1769,7 +2609,7 @@ export class ChatSession {
|
|
|
1769
2609
|
drainBgTaskQueue(): void {
|
|
1770
2610
|
var self = this;
|
|
1771
2611
|
var id = this.host.getIdentity();
|
|
1772
|
-
var svcId = id.
|
|
2612
|
+
var svcId = id.projectId, plat = id.platform;
|
|
1773
2613
|
if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
|
|
1774
2614
|
// Before anything is surfaced: drop continuations of files the user stopped
|
|
1775
2615
|
// (and let a fresh first pass lift the stop), then cancel any worker-queued
|
|
@@ -1790,11 +2630,20 @@ export class ChatSession {
|
|
|
1790
2630
|
});
|
|
1791
2631
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
1792
2632
|
var e = this.bgTaskQueue[i];
|
|
1793
|
-
if (e.
|
|
2633
|
+
if (e.projectId !== svcId || e.platform !== plat) continue;
|
|
1794
2634
|
if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
|
|
1795
2635
|
}
|
|
2636
|
+
// Poll budget for this drain (see MAX_CONCURRENT_BG_POLLS). bgTaskQueue is
|
|
2637
|
+
// in push order, i.e. oldest first, which is also the order the server
|
|
2638
|
+
// settles them in — so spending the budget from the front spends it on the
|
|
2639
|
+
// only entries that can resolve next. Bubbles are still injected for EVERY
|
|
2640
|
+
// entry; only the polling is rationed, and an unpolled entry picks up a
|
|
2641
|
+
// poll on the drain that follows the next resolution.
|
|
2642
|
+
var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
|
|
2643
|
+
// Set by the injection branch; drives the single render/cache/scroll below.
|
|
2644
|
+
var injectedAny = false;
|
|
1796
2645
|
this.bgTaskQueue.forEach(function (entry) {
|
|
1797
|
-
if (entry.
|
|
2646
|
+
if (entry.projectId !== svcId || entry.platform !== plat) return;
|
|
1798
2647
|
// Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
|
|
1799
2648
|
// already exists may still need a poll — that is exactly the state a paused
|
|
1800
2649
|
// drain leaves behind, and returning early here stranded it as a permanent
|
|
@@ -1818,14 +2667,50 @@ export class ChatSession {
|
|
|
1818
2667
|
},
|
|
1819
2668
|
};
|
|
1820
2669
|
if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
2670
|
+
// Directly ABOVE the chat turn these files were attached to, when that turn
|
|
2671
|
+
// is still on screen as a staged bubble. The row then appears where it
|
|
2672
|
+
// belongs from the start — right before the message the files came with,
|
|
2673
|
+
// which is also the order the server reports once the turn is sent (its
|
|
2674
|
+
// request id is newer than every pass it waits for). Appending instead put
|
|
2675
|
+
// the row BELOW the message and left the turn to be moved down past it at
|
|
2676
|
+
// dispatch, minutes later: a swap under the reader, at the one moment they
|
|
2677
|
+
// were watching for the turn to go out.
|
|
2678
|
+
//
|
|
2679
|
+
// -1 (no stage id, or a staged bubble already replaced/rebuilt away) appends,
|
|
2680
|
+
// which is the old behaviour and the right one for work with no chat turn
|
|
2681
|
+
// behind it: the dbfile page, an attachment-only send, a worker-adopted pass.
|
|
2682
|
+
// Resolved PER ENTRY: one drain can inject several passes, and a hoisted
|
|
2683
|
+
// index would insert each one before the last and reverse them.
|
|
2684
|
+
var stageAt = self._stageIndex(self.state.messages, entry.stageId);
|
|
2685
|
+
var runningBubble: ChatMessage | null = isRunning
|
|
2686
|
+
? { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id }
|
|
2687
|
+
: null;
|
|
2688
|
+
if (stageAt === -1) {
|
|
2689
|
+
self.state.messages.push(userBubble);
|
|
2690
|
+
if (runningBubble) self.state.messages.push(runningBubble);
|
|
2691
|
+
} else if (runningBubble) {
|
|
2692
|
+
// One splice, so the request and its placeholder stay ADJACENT: an
|
|
2693
|
+
// id-less response bubble is attributed to the message immediately
|
|
2694
|
+
// above it (indexing_groups) and replies are spliced at userIdx + 1.
|
|
2695
|
+
self.state.messages.splice(stageAt, 0, userBubble, runningBubble);
|
|
2696
|
+
} else {
|
|
2697
|
+
// AT the staged bubble, so this pass lands after any pass already
|
|
2698
|
+
// inserted for the same turn — files keep their upload order.
|
|
2699
|
+
self.state.messages.splice(stageAt, 0, userBubble);
|
|
1824
2700
|
}
|
|
1825
2701
|
presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
|
|
1826
|
-
|
|
2702
|
+
// Render/cache/scroll are hoisted OUT of this loop (see injectedAny below).
|
|
2703
|
+
// They used to run per injected entry, and updateHistoryCache filters the
|
|
2704
|
+
// whole of state.messages into a fresh array each call — so a bulk upload
|
|
2705
|
+
// injecting one bubble per file did O(files x messages) element visits and
|
|
2706
|
+
// left one throwaway array per file for the GC. In the widget it was worse
|
|
2707
|
+
// still: its notify() is a full DOM re-render of the message list. The end
|
|
2708
|
+
// state is identical either way; only the discarded intermediate renders
|
|
2709
|
+
// are gone.
|
|
2710
|
+
injectedAny = true;
|
|
1827
2711
|
}
|
|
1828
|
-
if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
|
|
2712
|
+
if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
|
|
2713
|
+
bgPollBudget--;
|
|
1829
2714
|
var capturedId = entry.id, capturedPlat = plat;
|
|
1830
2715
|
var capturedEntry = entry;
|
|
1831
2716
|
var wasStopped = false;
|
|
@@ -1862,15 +2747,39 @@ export class ChatSession {
|
|
|
1862
2747
|
}
|
|
1863
2748
|
}
|
|
1864
2749
|
self.host.notify(); self.updateHistoryCache();
|
|
2750
|
+
// A pass that FAILED dispatches no continuation on any path, so a
|
|
2751
|
+
// CLIENT-driven file is finished either way — let a turn waiting on it
|
|
2752
|
+
// look now. Not for a worker-driven one: this catch also fires on a
|
|
2753
|
+
// dropped poll, and the worker may have resolved that pass and written
|
|
2754
|
+
// the next one regardless. Nudging there would pull the two confirming
|
|
2755
|
+
// looks into the window where the new pass is not yet in the status
|
|
2756
|
+
// index, which is the same way a nudge on work STARTING went wrong.
|
|
2757
|
+
if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
|
|
2758
|
+
self._nudgeIndexingDrain();
|
|
2759
|
+
}
|
|
1865
2760
|
}).then(function () {
|
|
1866
2761
|
// Keep the queue entry when the poll was merely stopped, or resuming
|
|
1867
2762
|
// would have nothing left to re-attach to.
|
|
1868
2763
|
if (wasStopped) return;
|
|
1869
2764
|
var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
|
|
1870
2765
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
2766
|
+
// This resolution freed a slot in the MAX_CONCURRENT_BG_POLLS budget,
|
|
2767
|
+
// so hand it to the next-oldest unpolled entry. Load-bearing under the
|
|
2768
|
+
// cap: with thousands of entries queued, only the handful holding
|
|
2769
|
+
// polls can ever settle, and without this re-drain the remaining
|
|
2770
|
+
// entries would sit unpolled forever. Not left to a host-side watcher
|
|
2771
|
+
// on bgTaskQueue — agent.vue has one, the widget does not.
|
|
2772
|
+
self.drainBgTaskQueue();
|
|
1871
2773
|
});
|
|
1872
2774
|
}
|
|
1873
2775
|
});
|
|
2776
|
+
// One render, one cache write, one scroll for the whole drain — however many
|
|
2777
|
+
// bubbles it injected. See the injectedAny comment above.
|
|
2778
|
+
if (injectedAny) {
|
|
2779
|
+
this.host.notify();
|
|
2780
|
+
this.updateHistoryCache();
|
|
2781
|
+
this.host.scrollToBottomIfSticky(false);
|
|
2782
|
+
}
|
|
1874
2783
|
this.promoteNextBgQueuedToRunning();
|
|
1875
2784
|
}
|
|
1876
2785
|
|
|
@@ -1888,13 +2797,21 @@ export class ChatSession {
|
|
|
1888
2797
|
// as well would now double-index every window.
|
|
1889
2798
|
maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
|
|
1890
2799
|
var self = this;
|
|
2800
|
+
// This client is the ONLY driver of the chains that reach the returns below,
|
|
2801
|
+
// so its decision not to continue one is authoritative and immediate — the
|
|
2802
|
+
// earliest honest "these files are done" a waiting turn can get. Used only to
|
|
2803
|
+
// make awaitIndexingDrained look sooner; it still has to agree twice.
|
|
2804
|
+
// NOT called for the worker-driven returns further down (the worker may be
|
|
2805
|
+
// about to write the next pass) or for a stopped file (its queued passes are
|
|
2806
|
+
// still being cancelled).
|
|
2807
|
+
var endOfClientChain = function () { self._nudgeIndexingDrain(); };
|
|
1891
2808
|
try {
|
|
1892
2809
|
if (!entry || !entry.storagePath) return;
|
|
1893
2810
|
// The user stopped this file from its collapsed row. Dispatching the next
|
|
1894
2811
|
// pass here is exactly what "stop" has to prevent — the cancelled pass
|
|
1895
2812
|
// settles, and without this the chain simply carries on.
|
|
1896
2813
|
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
1897
|
-
if (!isPagedReadFile(entry.filename, entry.mime)) return;
|
|
2814
|
+
if (!isPagedReadFile(entry.filename, entry.mime)) { endOfClientChain(); return; }
|
|
1898
2815
|
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
|
|
1899
2816
|
// When windowed indexing is on, the WORKER drives the text/grid loop too. The
|
|
1900
2817
|
// client MUST NOT also resume, or two drivers each enqueue a continuation per
|
|
@@ -1902,22 +2819,27 @@ export class ChatSession {
|
|
|
1902
2819
|
// PDFs early-return above. Gated on the flag so the old client-driven path is
|
|
1903
2820
|
// untouched when windowing is off.
|
|
1904
2821
|
if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
|
|
1905
|
-
if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
|
|
2822
|
+
if (isErrorResponseBody(response)) { endOfClientChain(); return; } // a failed pass is not "incomplete"
|
|
1906
2823
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
1907
|
-
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
|
|
2824
|
+
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) { endOfClientChain(); return; } // fully indexed
|
|
1908
2825
|
var pass = (entry.resumePass || 0) + 1;
|
|
1909
|
-
if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
|
|
2826
|
+
if (pass > MAX_INDEXING_RESUME_PASSES) { endOfClientChain(); return; } // give up after the cap
|
|
1910
2827
|
var id = this.host.getIdentity();
|
|
1911
|
-
if (!id || id.platform === 'none' || id.
|
|
2828
|
+
if (!id || id.platform === 'none' || id.projectId !== entry.projectId) return;
|
|
1912
2829
|
// Counted as live work from here, not from the ack: awaitIndexingDrained
|
|
1913
2830
|
// asks the SERVER what is queued, and this pass is not queued until the
|
|
1914
2831
|
// call below returns. Without it a chat can slip in between two passes.
|
|
1915
2832
|
this.trackIndexDispatch(notifyAgentContinueIndexing({
|
|
1916
2833
|
platform: id.platform as 'claude' | 'openai',
|
|
1917
2834
|
model: id.model,
|
|
1918
|
-
service: id.
|
|
2835
|
+
service: id.projectId,
|
|
2836
|
+
// Without this the resume pass rebuilds its system prompt from the RAW
|
|
2837
|
+
// regional id (requests.ts falls back to `service`), and the model copies
|
|
2838
|
+
// that id verbatim into project_id tool calls, which the MCP schema
|
|
2839
|
+
// pattern rejects - the whole continue pass saves nothing.
|
|
2840
|
+
publicProjectId: id.publicProjectId,
|
|
1919
2841
|
owner: id.owner,
|
|
1920
|
-
userId: id.userId || id.
|
|
2842
|
+
userId: id.userId || id.projectId,
|
|
1921
2843
|
serviceName: id.serviceName,
|
|
1922
2844
|
serviceDescription: id.serviceDescription,
|
|
1923
2845
|
attachment: {
|
|
@@ -1930,11 +2852,18 @@ export class ChatSession {
|
|
|
1930
2852
|
}).then(function (ack: any) {
|
|
1931
2853
|
if (ack && typeof ack.id === 'string') {
|
|
1932
2854
|
self.bgTaskQueue.push({
|
|
1933
|
-
|
|
2855
|
+
projectId: id.projectId, platform: id.platform as 'claude' | 'openai', id: ack.id,
|
|
1934
2856
|
filename: entry.filename, storagePath: entry.storagePath,
|
|
1935
2857
|
isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
|
|
1936
2858
|
status: ack.status === 'running' ? 'running' : 'pending',
|
|
1937
2859
|
poll: ack.poll, resumePass: pass,
|
|
2860
|
+
// Deliberately NOT stamped with entry.stageId. Only a batch's FIRST
|
|
2861
|
+
// pass anchors to the turn; a continuation appends, which is the
|
|
2862
|
+
// order the server queued it in and therefore the order
|
|
2863
|
+
// promoteNextBgQueuedToRunning should spin it in. It costs nothing
|
|
2864
|
+
// on screen: a continuation is folded into the run whose row
|
|
2865
|
+
// already sits above the turn, and renders nothing at its own
|
|
2866
|
+
// index (indexing_groups anchors a run at its FIRST loaded pass).
|
|
1938
2867
|
});
|
|
1939
2868
|
self.drainBgTaskQueue();
|
|
1940
2869
|
}
|
|
@@ -1955,21 +2884,29 @@ export class ChatSession {
|
|
|
1955
2884
|
// request is built from. The rescue below compares against this rather
|
|
1956
2885
|
// than a live getHistoryCacheKey(), so a project switch mid-fetch can't
|
|
1957
2886
|
// make another chat's in-flight bubbles look local.
|
|
1958
|
-
var loadKey = (!id.
|
|
2887
|
+
var loadKey = (!id.projectId || id.platform === 'none') ? '' : id.projectId + '#' + id.platform;
|
|
1959
2888
|
if (token === undefined) token = this.state.gateRefreshToken;
|
|
1960
|
-
if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.
|
|
2889
|
+
if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.projectId) {
|
|
1961
2890
|
return Promise.resolve();
|
|
1962
2891
|
}
|
|
1963
2892
|
this.state.historyRequestToken = token;
|
|
1964
2893
|
this.state.loadingHistory = true;
|
|
2894
|
+
// A first-page load is the one moment the chat may have changed under us. The
|
|
2895
|
+
// old snapshot describes the previous project's queue; keeping it would let a
|
|
2896
|
+
// row here claim it was finished on evidence from somewhere else. Re-seeded
|
|
2897
|
+
// when this load lands.
|
|
2898
|
+
if (!fetchMore && loadKey !== this._liveIndexKey) {
|
|
2899
|
+
this._liveIndexKey = loadKey;
|
|
2900
|
+
this._resetLiveIndexKeys();
|
|
2901
|
+
}
|
|
1965
2902
|
if (fetchMore) this.state.loadingOlderHistory = true;
|
|
1966
2903
|
this.host.notify(); // surface "Fetching history..." while it loads
|
|
1967
2904
|
var platform = id.platform as 'claude' | 'openai';
|
|
1968
|
-
var
|
|
2905
|
+
var projectId = id.projectId, owner = id.owner;
|
|
1969
2906
|
var options: any = { fetchMore: fetchMore };
|
|
1970
2907
|
if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
|
|
1971
2908
|
|
|
1972
|
-
var fetchHistory = function () { return getChatHistory({ service:
|
|
2909
|
+
var fetchHistory = function () { return getChatHistory({ service: projectId, owner: owner, platform: platform }, options); };
|
|
1973
2910
|
|
|
1974
2911
|
return Promise.resolve().then(fetchHistory).catch(function (err: any) {
|
|
1975
2912
|
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
@@ -1989,7 +2926,7 @@ export class ChatSession {
|
|
|
1989
2926
|
});
|
|
1990
2927
|
var mapped = mapHistoryListToMessages(list, platform, {
|
|
1991
2928
|
clearedAt: self.host.getClearedAt(),
|
|
1992
|
-
|
|
2929
|
+
projectId: id.projectId,
|
|
1993
2930
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
1994
2931
|
}).messages;
|
|
1995
2932
|
|
|
@@ -2135,6 +3072,25 @@ export class ChatSession {
|
|
|
2135
3072
|
self.host.notify();
|
|
2136
3073
|
|
|
2137
3074
|
if (!fetchMore) {
|
|
3075
|
+
// Ration BACKGROUND polls here exactly as the drain does (see
|
|
3076
|
+
// MAX_CONCURRENT_BG_POLLS); foreground items — a reply the user is
|
|
3077
|
+
// waiting on — are never rationed. The allow-set is taken from the
|
|
3078
|
+
// OLDEST bg items: ids sort with newest largest (the same ordering the
|
|
3079
|
+
// older-page merge above relies on) and the server settles a queue
|
|
3080
|
+
// FIFO, so the oldest are the only ones that can resolve next. Taking
|
|
3081
|
+
// the newest instead would wedge the batch — they cannot settle until
|
|
3082
|
+
// everything ahead of them has, and nothing ahead would hold a poll.
|
|
3083
|
+
var bgAllow: { [id: string]: boolean } = {};
|
|
3084
|
+
var bgHistBudget = MAX_CONCURRENT_BG_POLLS - self._countBgPolls();
|
|
3085
|
+
if (bgHistBudget > 0) {
|
|
3086
|
+
var bgIds = chatList.filter(function (it: any) {
|
|
3087
|
+
if (it.status !== 'running' && it.status !== 'pending') return false;
|
|
3088
|
+
if (!it.poll || !it.id) return false;
|
|
3089
|
+
if (!(it._isBgTask || it._isOnBgQueue)) return false;
|
|
3090
|
+
return !self.historyItemPolls.has(it.id);
|
|
3091
|
+
}).map(function (it: any) { return it.id as string; }).sort();
|
|
3092
|
+
for (var ba = 0; ba < bgIds.length && ba < bgHistBudget; ba++) bgAllow[bgIds[ba]] = true;
|
|
3093
|
+
}
|
|
2138
3094
|
chatList.forEach(function (item: any) {
|
|
2139
3095
|
if (item.status !== 'running' && item.status !== 'pending') return;
|
|
2140
3096
|
if (!item.poll || !item.id) return;
|
|
@@ -2163,6 +3119,9 @@ export class ChatSession {
|
|
|
2163
3119
|
// Background indexing polls are suppressed while paused; foreground
|
|
2164
3120
|
// replies the user is waiting on are not.
|
|
2165
3121
|
if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
|
|
3122
|
+
// Over the bg poll budget: leave this one unpolled. The drain picks
|
|
3123
|
+
// it up once an attached poll settles and frees a slot.
|
|
3124
|
+
if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
|
|
2166
3125
|
var capturedId = item.id;
|
|
2167
3126
|
var pp = item.poll({
|
|
2168
3127
|
latency: POLL_INTERVAL,
|
|
@@ -2218,6 +3177,11 @@ export class ChatSession {
|
|
|
2218
3177
|
self.drainBgTaskQueue();
|
|
2219
3178
|
}
|
|
2220
3179
|
|
|
3180
|
+
// Learn which files the queue is still working on. Only on a FIRST page:
|
|
3181
|
+
// this describes the whole queue, not the page, so paging older history
|
|
3182
|
+
// would re-ask for an answer that has not changed.
|
|
3183
|
+
if (!fetchMore) self.refreshLiveIndexState();
|
|
3184
|
+
|
|
2221
3185
|
// Sticky, NOT forcing: this runs after every first-page load, including
|
|
2222
3186
|
// the one resumePolling fires on visibilitychange. Forcing yanked a
|
|
2223
3187
|
// reader who had scrolled up back to the bottom. On a genuine mount the
|
|
@@ -2244,7 +3208,7 @@ export class ChatSession {
|
|
|
2244
3208
|
// Upload one attachment (a file = 1 member, a folder = N) to db storage and
|
|
2245
3209
|
// queue indexing per member. The bytes I/O + chip rendering go through host
|
|
2246
3210
|
// hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
|
|
2247
|
-
uploadSingleAttachment(att: any): Promise<Array<{ name: string; url: string; storagePath: string }>> {
|
|
3211
|
+
uploadSingleAttachment(att: any, stageId?: string): Promise<Array<{ name: string; url: string; storagePath: string }>> {
|
|
2248
3212
|
var self = this;
|
|
2249
3213
|
var id = this.host.getIdentity();
|
|
2250
3214
|
att.status = 'uploading'; att.progress = 0; att.errorMessage = '';
|
|
@@ -2286,7 +3250,16 @@ export class ChatSession {
|
|
|
2286
3250
|
var isExists = code === 'EXISTS' || (msg && /exist/i.test(msg));
|
|
2287
3251
|
if (!isExists) throw err; // a member upload failed → whole attachment fails (red)
|
|
2288
3252
|
return self.host.promptOverwrite(member.file.name).then(function (choice) {
|
|
2289
|
-
if (choice === 'overwrite') {
|
|
3253
|
+
if (choice === 'overwrite') {
|
|
3254
|
+
existedBefore = true;
|
|
3255
|
+
// The bytes at this path are about to change while the
|
|
3256
|
+
// path itself does not, so the browser-cached mint for
|
|
3257
|
+
// it would keep handing back the old signed url and the
|
|
3258
|
+
// old cached body. This is the one place that knows,
|
|
3259
|
+
// so it marks the path for a refreshed mint.
|
|
3260
|
+
markImagePreviewStale(self.host.getIdentity().projectId || 'default', member.storagePath);
|
|
3261
|
+
return doMemberUpload(false); // replace the existing file
|
|
3262
|
+
}
|
|
2290
3263
|
if (choice === 'skip') { skipped = true; return; } // leave it untouched; no upload/index
|
|
2291
3264
|
hadExists = true; existedBefore = true; // keep it; Reindex
|
|
2292
3265
|
});
|
|
@@ -2306,23 +3279,53 @@ export class ChatSession {
|
|
|
2306
3279
|
// enqueued. Best-effort + optional-hook guarded: a missing record,
|
|
2307
3280
|
// a permission error, or a host without the hook must not block
|
|
2308
3281
|
// indexing.
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
3282
|
+
// Then CREATE the file record before any pass is enqueued. Ordering matters: the
|
|
3283
|
+
// delete above wipes it (and cascades to its rows), so creating first would just
|
|
3284
|
+
// delete what we made. Every pass references "src::<path>", and a pass is a
|
|
3285
|
+
// separate model turn that cannot know whether an earlier one created it, so
|
|
3286
|
+
// guaranteeing it here is what stops the backend rejecting whole windows with
|
|
3287
|
+
// NOT_EXISTS on reference.unique_id.
|
|
3288
|
+
// Already being indexed: a second run would open a second collapsed row
|
|
3289
|
+
// for the same file and re-read the whole document. Decided BEFORE the
|
|
3290
|
+
// delete-then-repost, which would otherwise wipe the records the LIVE
|
|
3291
|
+
// run has already written and then not re-index them. Asked of the
|
|
3292
|
+
// QUEUE, not just this page — see isIndexRunLive.
|
|
3293
|
+
var alreadyIndexing = false;
|
|
3294
|
+
var preIndex = self.claimIndexRun(member.storagePath).then(function (claimed) {
|
|
3295
|
+
alreadyIndexing = !claimed;
|
|
3296
|
+
if (alreadyIndexing) {
|
|
3297
|
+
console.log('[chat-engine] skipping a duplicate index request for', member.storagePath);
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
if (existedBefore && typeof self.host.deleteExistingFileRecord === 'function') {
|
|
3301
|
+
return Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function () { });
|
|
3302
|
+
}
|
|
3303
|
+
});
|
|
3304
|
+
preIndex = preIndex.then(function () {
|
|
3305
|
+
if (alreadyIndexing) return;
|
|
3306
|
+
if (typeof self.host.ensureFileIndexRecord !== 'function') return;
|
|
3307
|
+
return Promise.resolve(self.host.ensureFileIndexRecord(member.storagePath, {
|
|
3308
|
+
name: member.file.name,
|
|
3309
|
+
mime: mime || undefined,
|
|
3310
|
+
size: member.file.size,
|
|
3311
|
+
})).catch(function () { });
|
|
3312
|
+
});
|
|
2312
3313
|
// Run a client-side attachment parser (e.g. .hwp) if one matches; its
|
|
2313
3314
|
// output is inlined into the indexing request (falls back to office
|
|
2314
3315
|
// extraction / web_fetch when no parser matches or it yields nothing).
|
|
2315
3316
|
return preIndex.then(function () {
|
|
2316
3317
|
return parseAttachmentContent(member.file, member.file.name, mime || undefined);
|
|
2317
3318
|
}).then(function (parsedContent: string | null) {
|
|
3319
|
+
if (alreadyIndexing) return;
|
|
2318
3320
|
// Tracked so a chat waiting on awaitIndexingDrained cannot be sent
|
|
2319
3321
|
// in the window between this call and the queue accepting it.
|
|
2320
3322
|
return self.trackIndexDispatch(notifyAgentSaveAttachment({
|
|
2321
3323
|
platform: id.platform as 'claude' | 'openai',
|
|
2322
3324
|
model: id.model,
|
|
2323
|
-
service: id.
|
|
3325
|
+
service: id.projectId,
|
|
3326
|
+
publicProjectId: id.publicProjectId,
|
|
2324
3327
|
owner: id.owner,
|
|
2325
|
-
userId: id.userId || id.
|
|
3328
|
+
userId: id.userId || id.projectId,
|
|
2326
3329
|
serviceName: id.serviceName,
|
|
2327
3330
|
serviceDescription: id.serviceDescription,
|
|
2328
3331
|
attachment: {
|
|
@@ -2333,7 +3336,7 @@ export class ChatSession {
|
|
|
2333
3336
|
}).then(function (ack: any) {
|
|
2334
3337
|
if (ack && typeof ack.id === 'string') {
|
|
2335
3338
|
self.bgTaskQueue.push({
|
|
2336
|
-
|
|
3339
|
+
projectId: id.projectId, platform: id.platform as 'claude' | 'openai', id: ack.id,
|
|
2337
3340
|
filename: member.file.name,
|
|
2338
3341
|
storagePath: member.storagePath,
|
|
2339
3342
|
isReindex: hadExists,
|
|
@@ -2341,11 +3344,19 @@ export class ChatSession {
|
|
|
2341
3344
|
size: member.file.size,
|
|
2342
3345
|
status: ack.status === 'running' ? 'running' : 'pending',
|
|
2343
3346
|
poll: ack.poll,
|
|
3347
|
+
// Puts this file's row directly above the chat turn it was
|
|
3348
|
+
// attached to (drainBgTaskQueue). Undefined for an
|
|
3349
|
+
// attachment-only send, which appends.
|
|
3350
|
+
stageId: stageId,
|
|
2344
3351
|
});
|
|
2345
3352
|
self.drainBgTaskQueue(); // surface "Indexing: <file>" as soon as THIS file uploads
|
|
2346
3353
|
}
|
|
2347
3354
|
}, function (e: any) {
|
|
2348
3355
|
console.error('[chat-engine] indexing request failed', e);
|
|
3356
|
+
// Nothing was queued, so hand the slot back: the retry (a later
|
|
3357
|
+
// send of the same chip) must not be refused by this client's own
|
|
3358
|
+
// claim. See claimIndexRun.
|
|
3359
|
+
self.releaseIndexRun(member.storagePath);
|
|
2349
3360
|
anyIndexFailed = true; // uploaded but not indexed → yellow
|
|
2350
3361
|
// Record the first index error's code/message for the report dialog.
|
|
2351
3362
|
if (!att.errorCode && !att.errorDetail) {
|
|
@@ -2376,7 +3387,11 @@ export class ChatSession {
|
|
|
2376
3387
|
// message — uploading those here would attach them to the wrong turn, and
|
|
2377
3388
|
// collecting the previous batch's finished urls would attach files the user
|
|
2378
3389
|
// already sent. Omitted (no batch) means every chip, the old behavior.
|
|
2379
|
-
|
|
3390
|
+
//
|
|
3391
|
+
// `stageId` is the turn these chips were attached to, carried onto every indexing
|
|
3392
|
+
// task so its collapsed row renders directly ABOVE that turn's bubble (see
|
|
3393
|
+
// BgTaskEntry.stageId). Omitted for an attachment-only send, which has no turn.
|
|
3394
|
+
uploadPendingAttachments(batchId?: string, stageId?: string): Promise<Array<{ name: string; url: string; storagePath?: string }>> {
|
|
2380
3395
|
var self = this;
|
|
2381
3396
|
this.host.resetOverwriteBatch();
|
|
2382
3397
|
this._uploadBatches += 1;
|
|
@@ -2398,7 +3413,7 @@ export class ChatSession {
|
|
|
2398
3413
|
}
|
|
2399
3414
|
if (att.uploadedUrl) { collected.push({ name: att.name, url: att.uploadedUrl, storagePath: att.storagePath }); return; }
|
|
2400
3415
|
}
|
|
2401
|
-
return self.uploadSingleAttachment(att).then(function (us) {
|
|
3416
|
+
return self.uploadSingleAttachment(att, stageId).then(function (us) {
|
|
2402
3417
|
collected.push.apply(collected, us);
|
|
2403
3418
|
}).catch(function (err: any) {
|
|
2404
3419
|
var removed = !self.state.attachments.some(function (a: any) { return a.id === att.id; });
|