bunnyquery 1.8.2 → 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 +1859 -310
- package/dist/engine.cjs +1503 -188
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +906 -37
- package/dist/engine.d.ts +906 -37
- package/dist/engine.mjs +1480 -189
- 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 +77 -3
- package/src/engine/image_preview.ts +0 -0
- package/src/engine/index.ts +13 -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/office.ts +25 -8
- package/src/engine/prompts/chat_system_prompt.ts +24 -13
- 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 +302 -14
- package/src/engine/session.ts +1424 -114
- package/src/engine/viewport_fill.ts +51 -4
- package/styles/chat.css +108 -2
package/src/engine/session.ts
CHANGED
|
@@ -23,12 +23,14 @@ 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,
|
|
31
|
-
|
|
32
|
+
MAX_CONCURRENT_BG_POLLS,
|
|
33
|
+
bgIndexingQueueName,
|
|
32
34
|
isBgIndexingQueue,
|
|
33
35
|
ANTHROPIC_MESSAGES_API_URL,
|
|
34
36
|
OPENAI_RESPONSES_API_URL,
|
|
@@ -39,10 +41,11 @@ 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';
|
|
45
|
-
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
48
|
+
import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
|
|
46
49
|
import type { IndexingGroup } from './indexing_groups';
|
|
47
50
|
|
|
48
51
|
function sleep(ms: number): Promise<void> {
|
|
@@ -53,12 +56,63 @@ 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
|
|
59
73
|
// that is still alive. Fixed and finite: this must never become a poll.
|
|
60
74
|
const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
|
|
61
75
|
|
|
76
|
+
// awaitIndexingDrained: how often it re-asks the background-indexing queue, and
|
|
77
|
+
// how many consecutive empty answers it needs before believing the chains are
|
|
78
|
+
// over. More than one for the same reason the adopt ladder above looks twice —
|
|
79
|
+
// pass N+1 is written just after pass N resolves, so a single look lands in that
|
|
80
|
+
// gap and reports an idle queue for a file that is still being read.
|
|
81
|
+
//
|
|
82
|
+
// Two cadences: while work is visibly running there is nothing to be gained by
|
|
83
|
+
// asking often (an indexing pass takes tens of seconds, and a big file can hold
|
|
84
|
+
// the queue for many minutes), but once the queue looks empty the confirming
|
|
85
|
+
// look is all that stands between the user and their answer.
|
|
86
|
+
const INDEXING_DRAIN_BUSY_POLL_MS = 8000;
|
|
87
|
+
const INDEXING_DRAIN_CONFIRM_POLL_MS = 3000;
|
|
88
|
+
const INDEXING_DRAIN_IDLE_LOOKS = 2;
|
|
89
|
+
// Floor on the whole wait. The status index is eventually consistent, so the
|
|
90
|
+
// pass this turn's own upload just enqueued can be missing from the first looks —
|
|
91
|
+
// releasing on those would send the chat ahead of the very files it is asking
|
|
92
|
+
// about. Costs nothing in the normal case, where the queue reports work
|
|
93
|
+
// immediately and the wait is far longer than this anyway.
|
|
94
|
+
const INDEXING_DRAIN_MIN_MS = 8000;
|
|
95
|
+
// Ceiling on that wait. Past it the turn is sent regardless: an answer computed
|
|
96
|
+
// against a partly-indexed file is a poor outcome, but a question that is never
|
|
97
|
+
// asked at all because one chain wedged server-side is a worse one.
|
|
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;
|
|
115
|
+
|
|
62
116
|
// requestAnimationFrame / high-res clock, reached through globalThis so the
|
|
63
117
|
// engine stays DOM-free at the type level (and degrades gracefully in non-DOM
|
|
64
118
|
// / test environments where these globals are absent).
|
|
@@ -107,6 +161,49 @@ export class ChatSession {
|
|
|
107
161
|
private _pauseReasons: Set<string>;
|
|
108
162
|
private _resuming: boolean;
|
|
109
163
|
private _lidSeq: number;
|
|
164
|
+
private _stageSeq: number;
|
|
165
|
+
/** How many attachment-upload batches are running. uploadingAttachments is a
|
|
166
|
+
* single flag but batches overlap (the composer stays live, so the user can
|
|
167
|
+
* send a second one while the first uploads), and a nested finish must not
|
|
168
|
+
* clear the flag out from under the batch still running. */
|
|
169
|
+
private _uploadBatches: number;
|
|
170
|
+
/** Indexing requests whose ack has not come back yet. Until it does the item
|
|
171
|
+
* is not on the server's queue, so awaitIndexingDrained cannot see it — and
|
|
172
|
+
* would read the gap between "pass N settled" and "pass N+1 accepted" as the
|
|
173
|
+
* file being finished. */
|
|
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 };
|
|
110
207
|
|
|
111
208
|
constructor(host: ChatHost) {
|
|
112
209
|
this.host = host;
|
|
@@ -123,6 +220,9 @@ export class ChatSession {
|
|
|
123
220
|
historyStartKeyHistory: [],
|
|
124
221
|
historyRequestToken: 0,
|
|
125
222
|
gateRefreshToken: 0,
|
|
223
|
+
liveIndexKeys: {},
|
|
224
|
+
liveIndexChecked: false,
|
|
225
|
+
stoppedIndexIds: {},
|
|
126
226
|
};
|
|
127
227
|
this.bgTaskQueue = [];
|
|
128
228
|
this.cancelledServerIds = new Set();
|
|
@@ -133,6 +233,340 @@ export class ChatSession {
|
|
|
133
233
|
this._pauseReasons = new Set();
|
|
134
234
|
this._resuming = false;
|
|
135
235
|
this._lidSeq = 0;
|
|
236
|
+
this._stageSeq = 0;
|
|
237
|
+
this._uploadBatches = 0;
|
|
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();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
|
|
531
|
+
* live work from the moment it is sent, not from the moment it is acked. */
|
|
532
|
+
trackIndexDispatch<T>(p: Promise<T>): Promise<T> {
|
|
533
|
+
var self = this;
|
|
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.
|
|
545
|
+
var release = function () { self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1); };
|
|
546
|
+
return p.then(function (v) { release(); return v; }, function (e) { release(); throw e; });
|
|
547
|
+
}
|
|
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
|
+
}
|
|
136
570
|
}
|
|
137
571
|
|
|
138
572
|
/**
|
|
@@ -153,6 +587,19 @@ export class ChatSession {
|
|
|
153
587
|
return p;
|
|
154
588
|
}
|
|
155
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
|
+
|
|
156
603
|
/**
|
|
157
604
|
* Stop and forget one item's poll. Used after a cancel: the row is either gone
|
|
158
605
|
* (cancelled while queued) or flagged cancelled (cancelled while running), so
|
|
@@ -237,8 +684,8 @@ export class ChatSession {
|
|
|
237
684
|
|
|
238
685
|
getHistoryCacheKey(): string {
|
|
239
686
|
var id = this.host.getIdentity();
|
|
240
|
-
if (!id.
|
|
241
|
-
return id.
|
|
687
|
+
if (!id.projectId || id.platform === 'none') return '';
|
|
688
|
+
return id.projectId + '#' + id.platform;
|
|
242
689
|
}
|
|
243
690
|
|
|
244
691
|
updateHistoryCache(): void {
|
|
@@ -252,6 +699,21 @@ export class ChatSession {
|
|
|
252
699
|
// replay on every later visit to B. Bubbles with no _ownerKey (server
|
|
253
700
|
// history, bg tasks) are always kept. Single pass: this runs on the
|
|
254
701
|
// typewriter hot path.
|
|
702
|
+
//
|
|
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.
|
|
255
717
|
this.aiChatHistoryCache[key] = {
|
|
256
718
|
messages: this.state.messages.filter(function (m) {
|
|
257
719
|
return m._ownerKey === undefined || m._ownerKey === key;
|
|
@@ -307,6 +769,11 @@ export class ChatSession {
|
|
|
307
769
|
for (var j = 0; j < msgs.length; j++) {
|
|
308
770
|
var u = msgs[j];
|
|
309
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;
|
|
310
777
|
if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
|
|
311
778
|
if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
|
|
312
779
|
var settled: ChatMessage = { role: 'user', content: u.content };
|
|
@@ -324,21 +791,21 @@ export class ChatSession {
|
|
|
324
791
|
}
|
|
325
792
|
|
|
326
793
|
/**
|
|
327
|
-
*
|
|
794
|
+
* projectId/owner are passed explicitly by every caller: a request can be
|
|
328
795
|
* dispatched after the user moved to another project, and re-reading the live
|
|
329
796
|
* identity here would silently send the turn to THAT project instead of the
|
|
330
797
|
* one it was composed for. Falls back to the live read only when a caller
|
|
331
798
|
* omits them.
|
|
332
799
|
*/
|
|
333
|
-
private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any,
|
|
334
|
-
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) {
|
|
335
802
|
var id = this.host.getIdentity();
|
|
336
|
-
if (
|
|
803
|
+
if (projectId === undefined) projectId = id.projectId;
|
|
337
804
|
if (owner === undefined) owner = id.owner;
|
|
338
805
|
}
|
|
339
806
|
return platform === 'openai'
|
|
340
|
-
? callOpenAIWithPublicMcp(prompt,
|
|
341
|
-
: callClaudeWithPublicMcp(prompt,
|
|
807
|
+
? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls)
|
|
808
|
+
: callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
|
|
342
809
|
}
|
|
343
810
|
|
|
344
811
|
dispatchAgentRequest(params: any) {
|
|
@@ -352,7 +819,7 @@ export class ChatSession {
|
|
|
352
819
|
var dispatchItemId: string | undefined;
|
|
353
820
|
var sendAndPoll = function () {
|
|
354
821
|
return Promise.resolve(
|
|
355
|
-
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)
|
|
356
823
|
).then(function (initial: any) {
|
|
357
824
|
if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
|
|
358
825
|
if (initial.id) {
|
|
@@ -432,12 +899,294 @@ export class ChatSession {
|
|
|
432
899
|
return run;
|
|
433
900
|
}
|
|
434
901
|
|
|
902
|
+
/**
|
|
903
|
+
* Put a turn on screen the INSTANT the user hits Send, before its attachments
|
|
904
|
+
* have finished uploading. Uploads run in the background now (the composer is
|
|
905
|
+
* cleared and stays usable), so without a staged bubble the message would
|
|
906
|
+
* appear only once its files were up — below anything the user sent in the
|
|
907
|
+
* meantime, in an order that never matches what they typed.
|
|
908
|
+
*
|
|
909
|
+
* Staged bubbles carry _useBgQueue because that is where a turn with
|
|
910
|
+
* attachments ultimately dispatches (behind its own indexing tasks). That flag
|
|
911
|
+
* is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
|
|
912
|
+
* them: those advance the SERVER queue, and a staged turn has no server
|
|
913
|
+
* request behind it yet.
|
|
914
|
+
*
|
|
915
|
+
* Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
|
|
916
|
+
*/
|
|
917
|
+
stageOutgoingMessage(displayText: string): string {
|
|
918
|
+
this._stageSeq += 1;
|
|
919
|
+
var stageId = 'stg_' + this._stageSeq;
|
|
920
|
+
var key = this.getHistoryCacheKey();
|
|
921
|
+
var staged: ChatMessage = {
|
|
922
|
+
role: 'user', content: displayText,
|
|
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(),
|
|
931
|
+
_useBgQueue: true, _stageId: stageId, _ts: wallClockNow(),
|
|
932
|
+
};
|
|
933
|
+
if (key) staged._ownerKey = key;
|
|
934
|
+
this._liveStages[stageId] = true;
|
|
935
|
+
this.state.messages.push(staged);
|
|
936
|
+
this.host.notify(); this.host.scrollToBottom(true);
|
|
937
|
+
return stageId;
|
|
938
|
+
}
|
|
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
|
+
|
|
975
|
+
private _stageIndex(list: ChatMessage[], stageId?: string): number {
|
|
976
|
+
if (!stageId) return -1;
|
|
977
|
+
for (var i = 0; i < list.length; i++) {
|
|
978
|
+
if (list[i] && list[i]._stageId === stageId) return i;
|
|
979
|
+
}
|
|
980
|
+
return -1;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
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.
|
|
988
|
+
*
|
|
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.
|
|
992
|
+
*/
|
|
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();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
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 {
|
|
1015
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
1016
|
+
if (idx === -1) return;
|
|
1017
|
+
var ex = this.state.messages[idx];
|
|
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
|
+
});
|
|
1022
|
+
this.host.notify();
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Resolves once this project's background-indexing queue has nothing left to
|
|
1027
|
+
* run, so a chat enqueued right after it is genuinely last.
|
|
1028
|
+
*
|
|
1029
|
+
* Sending the chat as soon as the uploads finish is not enough, which is the
|
|
1030
|
+
* whole reason this exists: indexing a file is a CHAIN, and each pass is only
|
|
1031
|
+
* enqueued once the previous one lands (the client mints CONTINUE passes for
|
|
1032
|
+
* text/grid files, the worker mints them for PDFs and windowed reads). Every
|
|
1033
|
+
* one of those passes therefore queues up BEHIND a chat sent at upload time,
|
|
1034
|
+
* and the model answers from a file it has only partly read.
|
|
1035
|
+
*
|
|
1036
|
+
* The queue is read from the server's status index rather than from
|
|
1037
|
+
* bgTaskQueue: that mirror holds only what this client dispatched or adopted,
|
|
1038
|
+
* and it stops being maintained once the view unmounts. An empty answer has to
|
|
1039
|
+
* repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
|
|
1040
|
+
* that fails counts as busy, so a dropped request delays the turn instead of
|
|
1041
|
+
* releasing it early.
|
|
1042
|
+
*
|
|
1043
|
+
* Reads the identity PINNED at Send time, never a live one: the user may be in
|
|
1044
|
+
* another project by now, and this must keep asking about the one they sent
|
|
1045
|
+
* from.
|
|
1046
|
+
*/
|
|
1047
|
+
awaitIndexingDrained(identity: ChatIdentity): Promise<'drained' | 'timedout' | 'skipped'> {
|
|
1048
|
+
var self = this;
|
|
1049
|
+
var svcId = identity && identity.projectId;
|
|
1050
|
+
var platform = identity && identity.platform;
|
|
1051
|
+
if (!svcId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve('skipped' as const);
|
|
1052
|
+
var owner = identity.owner;
|
|
1053
|
+
var queue = bgIndexingQueueName(identity.userId, svcId);
|
|
1054
|
+
var startedAt = nowMs();
|
|
1055
|
+
var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
|
|
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.
|
|
1061
|
+
var ask = function (status: 'pending' | 'running') {
|
|
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
|
+
});
|
|
1077
|
+
};
|
|
1078
|
+
// Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
|
|
1079
|
+
// are not work this turn has to wait behind — the server runs the queue in
|
|
1080
|
+
// order, so being enqueued after them is enough.
|
|
1081
|
+
var hasLiveIndexing = function (res: any): boolean {
|
|
1082
|
+
var list = res && Array.isArray(res.list) ? res.list : [];
|
|
1083
|
+
for (var i = 0; i < list.length; i++) {
|
|
1084
|
+
var item = list[i];
|
|
1085
|
+
if (!item || (item.status !== 'pending' && item.status !== 'running')) continue;
|
|
1086
|
+
if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
|
|
1087
|
+
}
|
|
1088
|
+
return false;
|
|
1089
|
+
};
|
|
1090
|
+
return new Promise(function (resolve) {
|
|
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)));
|
|
1130
|
+
};
|
|
1131
|
+
var look = function () {
|
|
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; }
|
|
1137
|
+
// A pass whose ack is still in flight is not on the queue yet, so no
|
|
1138
|
+
// look can see it.
|
|
1139
|
+
if (self._indexDispatchesInFlight > 0) { idleLooks = 0; again(); return; }
|
|
1140
|
+
inFlight = true;
|
|
1141
|
+
Promise.all([ask('running'), ask('pending')]).then(function (res: any[]) {
|
|
1142
|
+
inFlight = false;
|
|
1143
|
+
var unknown = res[0] === null || res[1] === null;
|
|
1144
|
+
if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
|
|
1145
|
+
else idleLooks += 1;
|
|
1146
|
+
if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
|
|
1147
|
+
finish('drained'); return;
|
|
1148
|
+
}
|
|
1149
|
+
again();
|
|
1150
|
+
}, function () { inFlight = false; idleLooks = 0; again(); });
|
|
1151
|
+
};
|
|
1152
|
+
self._drainNudges.push(nudge);
|
|
1153
|
+
look();
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Abandon a staged turn — its uploads failed outright, so nothing will be
|
|
1159
|
+
* dispatched. The bubble stays (the user's text is not silently thrown away)
|
|
1160
|
+
* but settles into a plain, non-pending message; the caller reports the
|
|
1161
|
+
* failure separately.
|
|
1162
|
+
*/
|
|
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];
|
|
1167
|
+
var idx = this._stageIndex(this.state.messages, stageId);
|
|
1168
|
+
if (idx === -1) return;
|
|
1169
|
+
var ex = this.state.messages[idx];
|
|
1170
|
+
var settled: ChatMessage = { role: 'user', content: ex.content };
|
|
1171
|
+
if (ex._ownerKey !== undefined) settled._ownerKey = ex._ownerKey;
|
|
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;
|
|
1175
|
+
this.state.messages[idx] = settled;
|
|
1176
|
+
this.host.notify(); this.updateHistoryCache();
|
|
1177
|
+
}
|
|
1178
|
+
|
|
435
1179
|
// composed = clean display text; composedForLlm carries office-extraction
|
|
436
1180
|
// placeholders for the provider only. useBgQueue routes a post-attachment turn
|
|
437
1181
|
// onto the "-bg" queue so it runs after indexing.
|
|
438
1182
|
dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
|
|
439
1183
|
var self = this;
|
|
440
|
-
|
|
1184
|
+
// This turn may already have a bubble on screen, staged at Send time while
|
|
1185
|
+
// its attachments uploaded. Every exit from here has to account for it:
|
|
1186
|
+
// dispatching replaces it in place (so it keeps the position the user sent
|
|
1187
|
+
// it in), and bailing settles it (so it never sits uploading forever).
|
|
1188
|
+
var stageId = pinned ? pinned.stageId : undefined;
|
|
1189
|
+
if (!composed) { if (stageId) this.settleStagedMessage(stageId); return; }
|
|
441
1190
|
// A send can be dispatched LONG after the user hit Send (attachment
|
|
442
1191
|
// uploads are awaited first), by which time the live identity may have
|
|
443
1192
|
// moved to another project. The caller pins the identity + system prompt
|
|
@@ -445,7 +1194,10 @@ export class ChatSession {
|
|
|
445
1194
|
// question was actually asked of. Falls back to the live read when the
|
|
446
1195
|
// caller doesn't pin (the widget, which has only one project anyway).
|
|
447
1196
|
var id = pinned ? pinned.identity : this.host.getIdentity();
|
|
448
|
-
if (id.platform === 'none') return;
|
|
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];
|
|
449
1201
|
|
|
450
1202
|
var llmComposed = composedForLlm || composed;
|
|
451
1203
|
|
|
@@ -453,8 +1205,8 @@ export class ChatSession {
|
|
|
453
1205
|
// bubble is stamped with it so a project switch (which flips
|
|
454
1206
|
// getIdentity()/getHistoryCacheKey() to the new project) can't
|
|
455
1207
|
// misattribute this turn's bubbles to that project.
|
|
456
|
-
// (platform === 'none' already returned above, so
|
|
457
|
-
var key = !id.
|
|
1208
|
+
// (platform === 'none' already returned above, so projectId is the only gate)
|
|
1209
|
+
var key = !id.projectId ? '' : id.projectId + '#' + id.platform;
|
|
458
1210
|
// True when the pinned chat is NOT the one currently on screen. Then
|
|
459
1211
|
// state.messages belongs to a different project and MUST NOT be touched:
|
|
460
1212
|
// the turn is staged in the pinned chat's cache instead and shows up when
|
|
@@ -468,8 +1220,14 @@ export class ChatSession {
|
|
|
468
1220
|
var aiPlatform = id.platform;
|
|
469
1221
|
var aiModel = id.model || undefined;
|
|
470
1222
|
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
471
|
-
var userId = id.userId || id.
|
|
472
|
-
|
|
1223
|
+
var userId = id.userId || id.projectId;
|
|
1224
|
+
// Same string the indexing passes are enqueued under (bgIndexingQueueName),
|
|
1225
|
+
// which is the whole reason this turn ends up behind them: the backend runs
|
|
1226
|
+
// different queue names in parallel and only serialises a shared one.
|
|
1227
|
+
var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
|
|
1228
|
+
// _stageIndex is -1 when nothing was staged, or when the staged bubble is
|
|
1229
|
+
// gone (a remount rebuilds the list from the cache, which never holds staged
|
|
1230
|
+
// bubbles); either way the branches below fall back to appending.
|
|
473
1231
|
|
|
474
1232
|
if (offChat) {
|
|
475
1233
|
// Stage the turn in the pinned chat's cache and dispatch. The
|
|
@@ -482,20 +1240,46 @@ export class ChatSession {
|
|
|
482
1240
|
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
483
1241
|
});
|
|
484
1242
|
var offBounded = buildBoundedChatMessages({
|
|
485
|
-
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt,
|
|
1243
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
486
1244
|
history: offHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
487
1245
|
});
|
|
488
1246
|
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
1247
|
+
var offUser: ChatMessage = { role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() };
|
|
1248
|
+
// The user hit Send here, then navigated away before the uploads
|
|
1249
|
+
// finished. The staged bubble belongs to THAT chat, not the one now on
|
|
1250
|
+
// screen, and the turn it stood in for is being cached below — so drop
|
|
1251
|
+
// it from the live list instead of leaving a bubble that uploads
|
|
1252
|
+
// forever in a project it does not belong to.
|
|
1253
|
+
var offStage = this._stageIndex(this.state.messages, stageId);
|
|
1254
|
+
if (offStage !== -1) {
|
|
1255
|
+
if (this.state.messages[offStage]._ts !== undefined) offUser._ts = this.state.messages[offStage]._ts;
|
|
1256
|
+
this.state.messages.splice(offStage, 1);
|
|
1257
|
+
this.host.notify();
|
|
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
|
+
}
|
|
489
1273
|
this.aiChatHistoryCache[key] = {
|
|
490
|
-
messages:
|
|
491
|
-
|
|
1274
|
+
messages: offCached.concat([
|
|
1275
|
+
offUser,
|
|
492
1276
|
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
493
1277
|
]),
|
|
494
1278
|
endOfList: offExisting.endOfList,
|
|
495
1279
|
startKeyHistory: offExisting.startKeyHistory,
|
|
496
1280
|
};
|
|
497
1281
|
this.dispatchAgentRequest({
|
|
498
|
-
key: key,
|
|
1282
|
+
key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
499
1283
|
systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
|
|
500
1284
|
extractContent: extractContent, fileUrls: fileUrls,
|
|
501
1285
|
});
|
|
@@ -508,28 +1292,53 @@ export class ChatSession {
|
|
|
508
1292
|
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
509
1293
|
});
|
|
510
1294
|
var boundedQ = buildBoundedChatMessages({
|
|
511
|
-
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt,
|
|
1295
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
512
1296
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
513
1297
|
});
|
|
514
|
-
|
|
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() };
|
|
515
1302
|
if (key) queuedBubble._ownerKey = key;
|
|
516
1303
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
517
|
-
this.state.messages
|
|
1304
|
+
var qStage = this._stageIndex(this.state.messages, stageId);
|
|
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];
|
|
1312
|
+
// Keep the send time the user saw, not the upload's finish time.
|
|
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);
|
|
1320
|
+
} else {
|
|
1321
|
+
this.state.messages.push(queuedBubble);
|
|
1322
|
+
}
|
|
518
1323
|
this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
|
|
519
1324
|
|
|
520
1325
|
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
521
|
-
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))
|
|
522
1327
|
.then(function (result: any) {
|
|
523
1328
|
// Only ack a bubble that belongs to THIS chat — the search is
|
|
524
1329
|
// positional, so on another project it would stamp this turn's
|
|
525
1330
|
// _serverItemId onto that project's unrelated in-flight bubble.
|
|
1331
|
+
// Staged bubbles (_stageId) are skipped for the same reason: they
|
|
1332
|
+
// sit EARLIER in the list and match this shape exactly, so a turn
|
|
1333
|
+
// whose files are still uploading would take the ack — and the
|
|
1334
|
+
// server id — belonging to the turn that actually just sent.
|
|
526
1335
|
var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
|
|
527
1336
|
return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
|
|
528
|
-
(m._ownerKey === undefined || m._ownerKey === capturedKey);
|
|
1337
|
+
!m._stageId && (m._ownerKey === undefined || m._ownerKey === capturedKey);
|
|
529
1338
|
});
|
|
530
1339
|
var serverId = result && typeof result.id === 'string' ? result.id : undefined;
|
|
531
1340
|
if (sendingIdx >= 0) {
|
|
532
|
-
var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
|
|
1341
|
+
var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
|
|
533
1342
|
if (serverId) upd._serverItemId = serverId;
|
|
534
1343
|
self.state.messages[sendingIdx] = upd; self.host.notify();
|
|
535
1344
|
}
|
|
@@ -553,8 +1362,26 @@ export class ChatSession {
|
|
|
553
1362
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
554
1363
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
555
1364
|
// request was still in flight.
|
|
556
|
-
|
|
557
|
-
|
|
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 } : {}) };
|
|
1370
|
+
var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) };
|
|
1371
|
+
var iStage = this._stageIndex(this.state.messages, stageId);
|
|
1372
|
+
if (iStage !== -1) {
|
|
1373
|
+
// Replace IN PLACE — see the queued branch above for why nothing moves.
|
|
1374
|
+
var iEx = this.state.messages[iStage];
|
|
1375
|
+
// Keep the send time the user saw, not the upload's finish time.
|
|
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);
|
|
1381
|
+
} else {
|
|
1382
|
+
this.state.messages.push(immediateUser);
|
|
1383
|
+
this.state.messages.push(immediatePlaceholder);
|
|
1384
|
+
}
|
|
558
1385
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
559
1386
|
|
|
560
1387
|
// Same filter as the offChat and isQueuedSend paths above. It must drop the
|
|
@@ -576,24 +1403,24 @@ export class ChatSession {
|
|
|
576
1403
|
// flag is stripped when buildBoundedChatMessages maps down to {role,content}.
|
|
577
1404
|
// The cost is that a retry now follows an unanswered question with no stated
|
|
578
1405
|
// reason, which is a true account of what happened rather than a false one.
|
|
1406
|
+
//
|
|
1407
|
+
// The turn being sent is appended LAST rather than left wherever its bubble
|
|
1408
|
+
// sits. A staged turn keeps the position it was sent in, which is NOT the
|
|
1409
|
+
// end of the list once a message sent after it (while its files uploaded)
|
|
1410
|
+
// has already been answered — and a history that ends on an assistant turn
|
|
1411
|
+
// hits exactly the `last.role !== 'user'` bail described above.
|
|
579
1412
|
var historyForLlm = this.state.messages.filter(function (m) {
|
|
1413
|
+
if (m === immediateUser) return false;
|
|
580
1414
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
581
1415
|
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
582
1416
|
});
|
|
583
|
-
|
|
584
|
-
for (var li = historyForLlm.length - 1; li >= 0; li--) {
|
|
585
|
-
if (historyForLlm[li].role === 'user' && historyForLlm[li].content === composed) {
|
|
586
|
-
historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
|
|
587
|
-
break;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
1417
|
+
historyForLlm.push({ role: 'user', content: llmComposed });
|
|
591
1418
|
var bounded = buildBoundedChatMessages({
|
|
592
|
-
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt,
|
|
1419
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
593
1420
|
history: historyForLlm,
|
|
594
1421
|
});
|
|
595
1422
|
var run = this.dispatchAgentRequest({
|
|
596
|
-
key: key,
|
|
1423
|
+
key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
597
1424
|
systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
|
|
598
1425
|
extractContent: extractContent, fileUrls: fileUrls,
|
|
599
1426
|
});
|
|
@@ -656,6 +1483,14 @@ export class ChatSession {
|
|
|
656
1483
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
657
1484
|
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
658
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;
|
|
659
1494
|
this.state.messages[nextIdx] = promoted;
|
|
660
1495
|
// Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
|
|
661
1496
|
// (mirrors promoteNextBgQueuedToRunning). Without it, when this promoted turn
|
|
@@ -672,6 +1507,36 @@ export class ChatSession {
|
|
|
672
1507
|
this.host.notify();
|
|
673
1508
|
}
|
|
674
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
|
+
|
|
675
1540
|
resolveQueuedUserBubble(serverId?: string): number | undefined {
|
|
676
1541
|
// The two fallbacks below match by POSITION, not identity, so they must
|
|
677
1542
|
// never consider a bubble stamped for a different chat.
|
|
@@ -699,9 +1564,7 @@ export class ChatSession {
|
|
|
699
1564
|
if (userIdx >= 0) {
|
|
700
1565
|
var ex = this.state.messages[userIdx];
|
|
701
1566
|
this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
|
|
702
|
-
var thIdx = this.
|
|
703
|
-
return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
704
|
-
});
|
|
1567
|
+
var thIdx = this._ownThinkingIndex(userIdx, serverId);
|
|
705
1568
|
if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
|
|
706
1569
|
}
|
|
707
1570
|
this.promoteNextQueuedToRunning();
|
|
@@ -713,11 +1576,12 @@ export class ChatSession {
|
|
|
713
1576
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
714
1577
|
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
715
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;
|
|
716
1582
|
this.state.messages[userIdx] = repl;
|
|
717
1583
|
}
|
|
718
|
-
var thinkingIdx = userIdx
|
|
719
|
-
? this.state.messages.findIndex(function (m, i) { return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask; })
|
|
720
|
-
: -1;
|
|
1584
|
+
var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
|
|
721
1585
|
return thinkingIdx !== -1 ? thinkingIdx : (userIdx >= 0 ? userIdx + 1 : -1);
|
|
722
1586
|
}
|
|
723
1587
|
|
|
@@ -725,7 +1589,17 @@ export class ChatSession {
|
|
|
725
1589
|
// Error/direct replies land here rather than through the typewriter, so this
|
|
726
1590
|
// is where they pick up their display time.
|
|
727
1591
|
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
|
|
728
|
-
|
|
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;
|
|
729
1603
|
else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
|
|
730
1604
|
else this.state.messages.push(msg);
|
|
731
1605
|
}
|
|
@@ -832,12 +1706,30 @@ export class ChatSession {
|
|
|
832
1706
|
var platform = id.platform;
|
|
833
1707
|
if (platform !== 'claude' && platform !== 'openai') return;
|
|
834
1708
|
var url = platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
835
|
-
var queueBase = id.userId || id.
|
|
836
|
-
var queue = (msg.isBackgroundTask || msg._useBgQueue) ? queueBase
|
|
837
|
-
|
|
1709
|
+
var queueBase = id.userId || id.projectId;
|
|
1710
|
+
var queue = (msg.isBackgroundTask || msg._useBgQueue) ? bgIndexingQueueName(queueBase) : queueBase;
|
|
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
|
+
}
|
|
838
1730
|
this.host.notify();
|
|
839
1731
|
Promise.resolve(this.host.cancelRequest({
|
|
840
|
-
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,
|
|
841
1733
|
})).then(function (result: any) {
|
|
842
1734
|
if (result && result.removed) {
|
|
843
1735
|
self.cancelledServerIds.add(serverId as string);
|
|
@@ -898,7 +1790,10 @@ export class ChatSession {
|
|
|
898
1790
|
* 2. the file is remembered in cancelledIndexKeys, so the client-driven
|
|
899
1791
|
* resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
|
|
900
1792
|
* 3. any of its passes still sitting in bgTaskQueue is dropped by the next
|
|
901
|
-
* 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.
|
|
902
1797
|
*
|
|
903
1798
|
* Records already written by the passes that DID run are kept — this stops the
|
|
904
1799
|
* work, it does not undo it.
|
|
@@ -910,6 +1805,51 @@ export class ChatSession {
|
|
|
910
1805
|
// _indexKeyOf scopes a queued task's.
|
|
911
1806
|
var scoped = this.getHistoryCacheKey() + '|' + group.key;
|
|
912
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);
|
|
913
1853
|
var ids = group.cancellableIds || [];
|
|
914
1854
|
if (!ids.length) { this.host.notify(); return; }
|
|
915
1855
|
ids.forEach(function (serverId) {
|
|
@@ -1083,23 +2023,77 @@ export class ChatSession {
|
|
|
1083
2023
|
return this.enqueueTypewrite(pendingIdx, latest.content, lid);
|
|
1084
2024
|
}
|
|
1085
2025
|
|
|
1086
|
-
// Remove
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
//
|
|
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.
|
|
1096
2050
|
_removeStrayPendingAssistants(): void {
|
|
1097
2051
|
for (var k = this.state.messages.length - 1; k >= 0; k--) {
|
|
1098
2052
|
var m = this.state.messages[k];
|
|
1099
|
-
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);
|
|
1100
2056
|
}
|
|
1101
2057
|
}
|
|
1102
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
|
+
|
|
1103
2097
|
// Drop the pending flags on the resolved turn's USER bubble (preserving its
|
|
1104
2098
|
// content + background-task marker). Needed because a bg "Indexing:" turn's user
|
|
1105
2099
|
// bubble carries isPendingInProcess; leaving it set keeps the bubble visually
|
|
@@ -1151,6 +2145,11 @@ export class ChatSession {
|
|
|
1151
2145
|
var indexRef = this._indexRefOfItem(itemId);
|
|
1152
2146
|
this.applyHistoryItemResolution(itemId, response, platform);
|
|
1153
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();
|
|
1154
2153
|
// A worker-driven chain has no client-side record of its next pass, so a
|
|
1155
2154
|
// settling pass is the only moment there is to go looking for one.
|
|
1156
2155
|
if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
|
|
@@ -1168,13 +2167,85 @@ export class ChatSession {
|
|
|
1168
2167
|
return null;
|
|
1169
2168
|
}
|
|
1170
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
|
+
|
|
1171
2225
|
applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
|
|
1172
2226
|
this.historyItemPolls.delete(itemId);
|
|
2227
|
+
if (this._isCancelledPollResult(response)) {
|
|
2228
|
+
this._settleCancelledItem(itemId);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
1173
2231
|
var isErr = isErrorResponseBody(response);
|
|
1174
2232
|
var answer = isErr ? getErrorMessage(response)
|
|
1175
2233
|
: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
|
|
1176
|
-
//
|
|
1177
|
-
|
|
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
|
+
};
|
|
1178
2249
|
var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
|
|
1179
2250
|
if (idx !== -1) {
|
|
1180
2251
|
// A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
|
|
@@ -1205,7 +2276,7 @@ export class ChatSession {
|
|
|
1205
2276
|
// history, and ran a per-frame scroll for content nobody can see.
|
|
1206
2277
|
// Write it straight in; expanding the row then shows it complete.
|
|
1207
2278
|
if (wasBgTask) {
|
|
1208
|
-
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 } : {}) };
|
|
1209
2280
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
1210
2281
|
}
|
|
1211
2282
|
var lid = this._newLocalId();
|
|
@@ -1237,7 +2308,7 @@ export class ChatSession {
|
|
|
1237
2308
|
// Same as above: a collapsed row's reply is not on screen, so revealing it
|
|
1238
2309
|
// character by character only blocks the queue the visible reply needs.
|
|
1239
2310
|
if (ex.isBackgroundTask) {
|
|
1240
|
-
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 } : {}) });
|
|
1241
2312
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
1242
2313
|
}
|
|
1243
2314
|
var lid2 = this._newLocalId();
|
|
@@ -1256,7 +2327,7 @@ export class ChatSession {
|
|
|
1256
2327
|
if (!entry) return '';
|
|
1257
2328
|
var file = entry.storagePath || entry.filename;
|
|
1258
2329
|
if (!file) return '';
|
|
1259
|
-
return entry.
|
|
2330
|
+
return entry.projectId + '#' + entry.platform + '|' + file;
|
|
1260
2331
|
}
|
|
1261
2332
|
|
|
1262
2333
|
/**
|
|
@@ -1267,14 +2338,38 @@ export class ChatSession {
|
|
|
1267
2338
|
* path, and without this an earlier cancel would silently kill every future
|
|
1268
2339
|
* index of the same path. A continuation of a stopped file is dropped instead,
|
|
1269
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.
|
|
1270
2351
|
*/
|
|
1271
2352
|
private _applyIndexCancellations(): void {
|
|
1272
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
|
+
});
|
|
1273
2364
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
1274
2365
|
var entry = this.bgTaskQueue[i];
|
|
1275
2366
|
var key = this._indexKeyOf(entry);
|
|
1276
2367
|
if (!key || !this.cancelledIndexKeys.has(key)) continue;
|
|
1277
|
-
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;
|
|
1278
2373
|
this.bgTaskQueue.splice(i, 1);
|
|
1279
2374
|
this._stopPoll(entry.id);
|
|
1280
2375
|
this._cancelServerItem(entry.id);
|
|
@@ -1361,10 +2456,10 @@ export class ChatSession {
|
|
|
1361
2456
|
if (this._adoptingWorkerPasses) return;
|
|
1362
2457
|
var id = this.host.getIdentity();
|
|
1363
2458
|
var platform = id.platform;
|
|
1364
|
-
if (!id.
|
|
2459
|
+
if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return;
|
|
1365
2460
|
if (this.isPollingPaused() || !this.host.isViewMounted()) return;
|
|
1366
|
-
var svcId = id.
|
|
1367
|
-
var queue = (id.userId
|
|
2461
|
+
var svcId = id.projectId, owner = id.owner;
|
|
2462
|
+
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
1368
2463
|
var ask = function (status: 'pending' | 'running') {
|
|
1369
2464
|
return Promise.resolve(getChatHistory(
|
|
1370
2465
|
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
@@ -1377,8 +2472,16 @@ export class ChatSession {
|
|
|
1377
2472
|
// The chat may have changed under the query; adopting into another
|
|
1378
2473
|
// project's session is the cross-project bubble leak all over again.
|
|
1379
2474
|
var now = self.host.getIdentity();
|
|
1380
|
-
if (now.
|
|
2475
|
+
if (now.projectId !== svcId || now.platform !== platform) return;
|
|
1381
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);
|
|
1382
2485
|
var adoptedIds: string[] = [];
|
|
1383
2486
|
for (var ri = 0; ri < results.length; ri++) {
|
|
1384
2487
|
var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
|
|
@@ -1402,10 +2505,16 @@ export class ChatSession {
|
|
|
1402
2505
|
// empty queue for a chain that is very much alive — and with no pass
|
|
1403
2506
|
// left to settle, nothing would ever ask again. Look once or twice more
|
|
1404
2507
|
// before believing the file is finished.
|
|
1405
|
-
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
|
+
}
|
|
1406
2515
|
setTimeout(function () {
|
|
1407
2516
|
var later = self.host.getIdentity();
|
|
1408
|
-
if (later.
|
|
2517
|
+
if (later.projectId !== svcId || later.platform !== platform) return;
|
|
1409
2518
|
if (self.isPollingPaused() || !self.host.isViewMounted()) return;
|
|
1410
2519
|
self._adoptWorkerIndexingPasses(attempt + 1);
|
|
1411
2520
|
}, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
|
|
@@ -1460,7 +2569,7 @@ export class ChatSession {
|
|
|
1460
2569
|
// chain's passes — which is the cap that stops it running forever.
|
|
1461
2570
|
if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
|
|
1462
2571
|
this.bgTaskQueue.push({
|
|
1463
|
-
|
|
2572
|
+
projectId: svcId,
|
|
1464
2573
|
platform: platform,
|
|
1465
2574
|
id: item.id,
|
|
1466
2575
|
filename: ref.name,
|
|
@@ -1491,8 +2600,8 @@ export class ChatSession {
|
|
|
1491
2600
|
var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1492
2601
|
Promise.resolve(this.host.cancelRequest({
|
|
1493
2602
|
url: url, method: 'POST', id: serverId,
|
|
1494
|
-
queue: (id.userId
|
|
1495
|
-
service: id.
|
|
2603
|
+
queue: bgIndexingQueueName(id.userId, id.projectId),
|
|
2604
|
+
service: id.projectId, owner: id.owner,
|
|
1496
2605
|
})).catch(function () { /* the pass may already have finished; nothing to do */ });
|
|
1497
2606
|
}
|
|
1498
2607
|
|
|
@@ -1500,7 +2609,7 @@ export class ChatSession {
|
|
|
1500
2609
|
drainBgTaskQueue(): void {
|
|
1501
2610
|
var self = this;
|
|
1502
2611
|
var id = this.host.getIdentity();
|
|
1503
|
-
var svcId = id.
|
|
2612
|
+
var svcId = id.projectId, plat = id.platform;
|
|
1504
2613
|
if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
|
|
1505
2614
|
// Before anything is surfaced: drop continuations of files the user stopped
|
|
1506
2615
|
// (and let a fresh first pass lift the stop), then cancel any worker-queued
|
|
@@ -1521,11 +2630,20 @@ export class ChatSession {
|
|
|
1521
2630
|
});
|
|
1522
2631
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
1523
2632
|
var e = this.bgTaskQueue[i];
|
|
1524
|
-
if (e.
|
|
2633
|
+
if (e.projectId !== svcId || e.platform !== plat) continue;
|
|
1525
2634
|
if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
|
|
1526
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;
|
|
1527
2645
|
this.bgTaskQueue.forEach(function (entry) {
|
|
1528
|
-
if (entry.
|
|
2646
|
+
if (entry.projectId !== svcId || entry.platform !== plat) return;
|
|
1529
2647
|
// Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
|
|
1530
2648
|
// already exists may still need a poll — that is exactly the state a paused
|
|
1531
2649
|
// drain leaves behind, and returning early here stranded it as a permanent
|
|
@@ -1549,14 +2667,50 @@ export class ChatSession {
|
|
|
1549
2667
|
},
|
|
1550
2668
|
};
|
|
1551
2669
|
if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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);
|
|
1555
2700
|
}
|
|
1556
2701
|
presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
|
|
1557
|
-
|
|
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;
|
|
1558
2711
|
}
|
|
1559
|
-
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--;
|
|
1560
2714
|
var capturedId = entry.id, capturedPlat = plat;
|
|
1561
2715
|
var capturedEntry = entry;
|
|
1562
2716
|
var wasStopped = false;
|
|
@@ -1593,15 +2747,39 @@ export class ChatSession {
|
|
|
1593
2747
|
}
|
|
1594
2748
|
}
|
|
1595
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
|
+
}
|
|
1596
2760
|
}).then(function () {
|
|
1597
2761
|
// Keep the queue entry when the poll was merely stopped, or resuming
|
|
1598
2762
|
// would have nothing left to re-attach to.
|
|
1599
2763
|
if (wasStopped) return;
|
|
1600
2764
|
var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
|
|
1601
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();
|
|
1602
2773
|
});
|
|
1603
2774
|
}
|
|
1604
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
|
+
}
|
|
1605
2783
|
this.promoteNextBgQueuedToRunning();
|
|
1606
2784
|
}
|
|
1607
2785
|
|
|
@@ -1619,13 +2797,21 @@ export class ChatSession {
|
|
|
1619
2797
|
// as well would now double-index every window.
|
|
1620
2798
|
maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
|
|
1621
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(); };
|
|
1622
2808
|
try {
|
|
1623
2809
|
if (!entry || !entry.storagePath) return;
|
|
1624
2810
|
// The user stopped this file from its collapsed row. Dispatching the next
|
|
1625
2811
|
// pass here is exactly what "stop" has to prevent — the cancelled pass
|
|
1626
2812
|
// settles, and without this the chain simply carries on.
|
|
1627
2813
|
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
1628
|
-
if (!isPagedReadFile(entry.filename, entry.mime)) return;
|
|
2814
|
+
if (!isPagedReadFile(entry.filename, entry.mime)) { endOfClientChain(); return; }
|
|
1629
2815
|
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
|
|
1630
2816
|
// When windowed indexing is on, the WORKER drives the text/grid loop too. The
|
|
1631
2817
|
// client MUST NOT also resume, or two drivers each enqueue a continuation per
|
|
@@ -1633,19 +2819,27 @@ export class ChatSession {
|
|
|
1633
2819
|
// PDFs early-return above. Gated on the flag so the old client-driven path is
|
|
1634
2820
|
// untouched when windowing is off.
|
|
1635
2821
|
if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
|
|
1636
|
-
if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
|
|
2822
|
+
if (isErrorResponseBody(response)) { endOfClientChain(); return; } // a failed pass is not "incomplete"
|
|
1637
2823
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
1638
|
-
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
|
|
2824
|
+
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) { endOfClientChain(); return; } // fully indexed
|
|
1639
2825
|
var pass = (entry.resumePass || 0) + 1;
|
|
1640
|
-
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
|
|
1641
2827
|
var id = this.host.getIdentity();
|
|
1642
|
-
if (!id || id.platform === 'none' || id.
|
|
1643
|
-
|
|
2828
|
+
if (!id || id.platform === 'none' || id.projectId !== entry.projectId) return;
|
|
2829
|
+
// Counted as live work from here, not from the ack: awaitIndexingDrained
|
|
2830
|
+
// asks the SERVER what is queued, and this pass is not queued until the
|
|
2831
|
+
// call below returns. Without it a chat can slip in between two passes.
|
|
2832
|
+
this.trackIndexDispatch(notifyAgentContinueIndexing({
|
|
1644
2833
|
platform: id.platform as 'claude' | 'openai',
|
|
1645
2834
|
model: id.model,
|
|
1646
|
-
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,
|
|
1647
2841
|
owner: id.owner,
|
|
1648
|
-
userId: id.userId || id.
|
|
2842
|
+
userId: id.userId || id.projectId,
|
|
1649
2843
|
serviceName: id.serviceName,
|
|
1650
2844
|
serviceDescription: id.serviceDescription,
|
|
1651
2845
|
attachment: {
|
|
@@ -1658,15 +2852,22 @@ export class ChatSession {
|
|
|
1658
2852
|
}).then(function (ack: any) {
|
|
1659
2853
|
if (ack && typeof ack.id === 'string') {
|
|
1660
2854
|
self.bgTaskQueue.push({
|
|
1661
|
-
|
|
2855
|
+
projectId: id.projectId, platform: id.platform as 'claude' | 'openai', id: ack.id,
|
|
1662
2856
|
filename: entry.filename, storagePath: entry.storagePath,
|
|
1663
2857
|
isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
|
|
1664
2858
|
status: ack.status === 'running' ? 'running' : 'pending',
|
|
1665
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).
|
|
1666
2867
|
});
|
|
1667
2868
|
self.drainBgTaskQueue();
|
|
1668
2869
|
}
|
|
1669
|
-
}, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
|
|
2870
|
+
}, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); }));
|
|
1670
2871
|
} catch (e) { /* best-effort: resume must never break bg-task resolution */ }
|
|
1671
2872
|
}
|
|
1672
2873
|
|
|
@@ -1683,21 +2884,29 @@ export class ChatSession {
|
|
|
1683
2884
|
// request is built from. The rescue below compares against this rather
|
|
1684
2885
|
// than a live getHistoryCacheKey(), so a project switch mid-fetch can't
|
|
1685
2886
|
// make another chat's in-flight bubbles look local.
|
|
1686
|
-
var loadKey = (!id.
|
|
2887
|
+
var loadKey = (!id.projectId || id.platform === 'none') ? '' : id.projectId + '#' + id.platform;
|
|
1687
2888
|
if (token === undefined) token = this.state.gateRefreshToken;
|
|
1688
|
-
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) {
|
|
1689
2890
|
return Promise.resolve();
|
|
1690
2891
|
}
|
|
1691
2892
|
this.state.historyRequestToken = token;
|
|
1692
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
|
+
}
|
|
1693
2902
|
if (fetchMore) this.state.loadingOlderHistory = true;
|
|
1694
2903
|
this.host.notify(); // surface "Fetching history..." while it loads
|
|
1695
2904
|
var platform = id.platform as 'claude' | 'openai';
|
|
1696
|
-
var
|
|
2905
|
+
var projectId = id.projectId, owner = id.owner;
|
|
1697
2906
|
var options: any = { fetchMore: fetchMore };
|
|
1698
2907
|
if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
|
|
1699
2908
|
|
|
1700
|
-
var fetchHistory = function () { return getChatHistory({ service:
|
|
2909
|
+
var fetchHistory = function () { return getChatHistory({ service: projectId, owner: owner, platform: platform }, options); };
|
|
1701
2910
|
|
|
1702
2911
|
return Promise.resolve().then(fetchHistory).catch(function (err: any) {
|
|
1703
2912
|
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
@@ -1717,7 +2926,7 @@ export class ChatSession {
|
|
|
1717
2926
|
});
|
|
1718
2927
|
var mapped = mapHistoryListToMessages(list, platform, {
|
|
1719
2928
|
clearedAt: self.host.getClearedAt(),
|
|
1720
|
-
|
|
2929
|
+
projectId: id.projectId,
|
|
1721
2930
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
1722
2931
|
}).messages;
|
|
1723
2932
|
|
|
@@ -1763,6 +2972,13 @@ export class ChatSession {
|
|
|
1763
2972
|
if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
|
|
1764
2973
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
1765
2974
|
if (!mm._serverItemId) {
|
|
2975
|
+
// A staged turn (files still uploading) has no server request
|
|
2976
|
+
// yet, so nothing in `mapped` can stand for it — rescue it
|
|
2977
|
+
// unconditionally. The mappedHasPendingAssistant skip below is
|
|
2978
|
+
// about a turn the server ALREADY has; applying it here would
|
|
2979
|
+
// delete the user's message mid-upload whenever some other
|
|
2980
|
+
// turn happened to be in flight.
|
|
2981
|
+
if (mm._stageId) { rescued.push(mm); continue; }
|
|
1766
2982
|
if (mappedHasPendingAssistant) continue;
|
|
1767
2983
|
if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
|
|
1768
2984
|
else if (self.state.sending && mm.role === 'user') {
|
|
@@ -1856,6 +3072,25 @@ export class ChatSession {
|
|
|
1856
3072
|
self.host.notify();
|
|
1857
3073
|
|
|
1858
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
|
+
}
|
|
1859
3094
|
chatList.forEach(function (item: any) {
|
|
1860
3095
|
if (item.status !== 'running' && item.status !== 'pending') return;
|
|
1861
3096
|
if (!item.poll || !item.id) return;
|
|
@@ -1884,6 +3119,9 @@ export class ChatSession {
|
|
|
1884
3119
|
// Background indexing polls are suppressed while paused; foreground
|
|
1885
3120
|
// replies the user is waiting on are not.
|
|
1886
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;
|
|
1887
3125
|
var capturedId = item.id;
|
|
1888
3126
|
var pp = item.poll({
|
|
1889
3127
|
latency: POLL_INTERVAL,
|
|
@@ -1939,6 +3177,11 @@ export class ChatSession {
|
|
|
1939
3177
|
self.drainBgTaskQueue();
|
|
1940
3178
|
}
|
|
1941
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
|
+
|
|
1942
3185
|
// Sticky, NOT forcing: this runs after every first-page load, including
|
|
1943
3186
|
// the one resumePolling fires on visibilitychange. Forcing yanked a
|
|
1944
3187
|
// reader who had scrolled up back to the bottom. On a genuine mount the
|
|
@@ -1965,7 +3208,7 @@ export class ChatSession {
|
|
|
1965
3208
|
// Upload one attachment (a file = 1 member, a folder = N) to db storage and
|
|
1966
3209
|
// queue indexing per member. The bytes I/O + chip rendering go through host
|
|
1967
3210
|
// hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
|
|
1968
|
-
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 }>> {
|
|
1969
3212
|
var self = this;
|
|
1970
3213
|
var id = this.host.getIdentity();
|
|
1971
3214
|
att.status = 'uploading'; att.progress = 0; att.errorMessage = '';
|
|
@@ -2007,7 +3250,16 @@ export class ChatSession {
|
|
|
2007
3250
|
var isExists = code === 'EXISTS' || (msg && /exist/i.test(msg));
|
|
2008
3251
|
if (!isExists) throw err; // a member upload failed → whole attachment fails (red)
|
|
2009
3252
|
return self.host.promptOverwrite(member.file.name).then(function (choice) {
|
|
2010
|
-
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
|
+
}
|
|
2011
3263
|
if (choice === 'skip') { skipped = true; return; } // leave it untouched; no upload/index
|
|
2012
3264
|
hadExists = true; existedBefore = true; // keep it; Reindex
|
|
2013
3265
|
});
|
|
@@ -2027,21 +3279,53 @@ export class ChatSession {
|
|
|
2027
3279
|
// enqueued. Best-effort + optional-hook guarded: a missing record,
|
|
2028
3280
|
// a permission error, or a host without the hook must not block
|
|
2029
3281
|
// indexing.
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
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
|
+
});
|
|
2033
3313
|
// Run a client-side attachment parser (e.g. .hwp) if one matches; its
|
|
2034
3314
|
// output is inlined into the indexing request (falls back to office
|
|
2035
3315
|
// extraction / web_fetch when no parser matches or it yields nothing).
|
|
2036
3316
|
return preIndex.then(function () {
|
|
2037
3317
|
return parseAttachmentContent(member.file, member.file.name, mime || undefined);
|
|
2038
3318
|
}).then(function (parsedContent: string | null) {
|
|
2039
|
-
|
|
3319
|
+
if (alreadyIndexing) return;
|
|
3320
|
+
// Tracked so a chat waiting on awaitIndexingDrained cannot be sent
|
|
3321
|
+
// in the window between this call and the queue accepting it.
|
|
3322
|
+
return self.trackIndexDispatch(notifyAgentSaveAttachment({
|
|
2040
3323
|
platform: id.platform as 'claude' | 'openai',
|
|
2041
3324
|
model: id.model,
|
|
2042
|
-
service: id.
|
|
3325
|
+
service: id.projectId,
|
|
3326
|
+
publicProjectId: id.publicProjectId,
|
|
2043
3327
|
owner: id.owner,
|
|
2044
|
-
userId: id.userId || id.
|
|
3328
|
+
userId: id.userId || id.projectId,
|
|
2045
3329
|
serviceName: id.serviceName,
|
|
2046
3330
|
serviceDescription: id.serviceDescription,
|
|
2047
3331
|
attachment: {
|
|
@@ -2052,7 +3336,7 @@ export class ChatSession {
|
|
|
2052
3336
|
}).then(function (ack: any) {
|
|
2053
3337
|
if (ack && typeof ack.id === 'string') {
|
|
2054
3338
|
self.bgTaskQueue.push({
|
|
2055
|
-
|
|
3339
|
+
projectId: id.projectId, platform: id.platform as 'claude' | 'openai', id: ack.id,
|
|
2056
3340
|
filename: member.file.name,
|
|
2057
3341
|
storagePath: member.storagePath,
|
|
2058
3342
|
isReindex: hadExists,
|
|
@@ -2060,18 +3344,26 @@ export class ChatSession {
|
|
|
2060
3344
|
size: member.file.size,
|
|
2061
3345
|
status: ack.status === 'running' ? 'running' : 'pending',
|
|
2062
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,
|
|
2063
3351
|
});
|
|
2064
3352
|
self.drainBgTaskQueue(); // surface "Indexing: <file>" as soon as THIS file uploads
|
|
2065
3353
|
}
|
|
2066
3354
|
}, function (e: any) {
|
|
2067
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);
|
|
2068
3360
|
anyIndexFailed = true; // uploaded but not indexed → yellow
|
|
2069
3361
|
// Record the first index error's code/message for the report dialog.
|
|
2070
3362
|
if (!att.errorCode && !att.errorDetail) {
|
|
2071
3363
|
att.errorCode = (e && (e.code || (e.body && e.body.code))) || '';
|
|
2072
3364
|
att.errorDetail = (e && (e.message || (e.body && e.body.message))) || (typeof e === 'string' ? e : '');
|
|
2073
3365
|
}
|
|
2074
|
-
});
|
|
3366
|
+
}));
|
|
2075
3367
|
});
|
|
2076
3368
|
});
|
|
2077
3369
|
});
|
|
@@ -2088,14 +3380,28 @@ export class ChatSession {
|
|
|
2088
3380
|
|
|
2089
3381
|
// Upload all not-yet-done attachments sequentially. Resolves to the full
|
|
2090
3382
|
// list of { name, url, storagePath } for composing the chat message.
|
|
2091
|
-
|
|
3383
|
+
//
|
|
3384
|
+
// `batchId` scopes the run to the chips stamped with it at Send time. The
|
|
3385
|
+
// composer stays live during an upload, so by the time this runs the
|
|
3386
|
+
// attachment list can already hold chips the user picked for the NEXT
|
|
3387
|
+
// message — uploading those here would attach them to the wrong turn, and
|
|
3388
|
+
// collecting the previous batch's finished urls would attach files the user
|
|
3389
|
+
// already sent. Omitted (no batch) means every chip, the old behavior.
|
|
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 }>> {
|
|
2092
3395
|
var self = this;
|
|
2093
3396
|
this.host.resetOverwriteBatch();
|
|
3397
|
+
this._uploadBatches += 1;
|
|
2094
3398
|
this.state.uploadingAttachments = true;
|
|
2095
3399
|
this.host.updateComposerControls();
|
|
2096
3400
|
this.host.renderAttachmentChips();
|
|
2097
3401
|
var collected: Array<{ name: string; url: string; storagePath?: string }> = [];
|
|
2098
|
-
var snapshot = this.state.attachments.
|
|
3402
|
+
var snapshot = this.state.attachments.filter(function (a: any) {
|
|
3403
|
+
return batchId ? a._batchId === batchId : true;
|
|
3404
|
+
});
|
|
2099
3405
|
var chain: Promise<any> = Promise.resolve();
|
|
2100
3406
|
snapshot.forEach(function (att: any) {
|
|
2101
3407
|
chain = chain.then(function () {
|
|
@@ -2107,7 +3413,7 @@ export class ChatSession {
|
|
|
2107
3413
|
}
|
|
2108
3414
|
if (att.uploadedUrl) { collected.push({ name: att.name, url: att.uploadedUrl, storagePath: att.storagePath }); return; }
|
|
2109
3415
|
}
|
|
2110
|
-
return self.uploadSingleAttachment(att).then(function (us) {
|
|
3416
|
+
return self.uploadSingleAttachment(att, stageId).then(function (us) {
|
|
2111
3417
|
collected.push.apply(collected, us);
|
|
2112
3418
|
}).catch(function (err: any) {
|
|
2113
3419
|
var removed = !self.state.attachments.some(function (a: any) { return a.id === att.id; });
|
|
@@ -2123,7 +3429,11 @@ export class ChatSession {
|
|
|
2123
3429
|
});
|
|
2124
3430
|
});
|
|
2125
3431
|
var done = function () {
|
|
2126
|
-
self.
|
|
3432
|
+
self._uploadBatches = Math.max(0, self._uploadBatches - 1);
|
|
3433
|
+
// Only the LAST batch standing clears the flag — a second send made
|
|
3434
|
+
// while this one was uploading is still running.
|
|
3435
|
+
self.state.uploadingAttachments = self._uploadBatches > 0;
|
|
3436
|
+
self.host.updateComposerControls(); self.host.renderAttachmentChips();
|
|
2127
3437
|
return collected;
|
|
2128
3438
|
};
|
|
2129
3439
|
return chain.then(done, done);
|