bunnyquery 1.6.0 → 1.6.2
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/bunnyquery.js +406 -83
- package/dist/engine.cjs +398 -81
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +132 -9
- package/dist/engine.d.ts +132 -9
- package/dist/engine.mjs +395 -82
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +19 -0
- package/src/engine/host.ts +19 -0
- package/src/engine/index.ts +2 -1
- package/src/engine/office.ts +53 -5
- package/src/engine/prompts/index.ts +3 -0
- package/src/engine/prompts/indexing_user_message.ts +106 -29
- package/src/engine/requests.ts +106 -19
- package/src/engine/session.ts +350 -55
package/src/engine/session.ts
CHANGED
|
@@ -24,23 +24,23 @@ import {
|
|
|
24
24
|
notifyAgentContinueIndexing,
|
|
25
25
|
INDEXING_COMPLETE_MARKER,
|
|
26
26
|
MAX_INDEXING_RESUME_PASSES,
|
|
27
|
-
MAX_VISION_RESUME_PASSES,
|
|
28
27
|
extractClaudeText,
|
|
29
28
|
extractOpenAIText,
|
|
30
29
|
getChatHistory,
|
|
31
30
|
POLL_INTERVAL,
|
|
32
31
|
BG_INDEXING_QUEUE_SUFFIX,
|
|
32
|
+
isBgIndexingQueue,
|
|
33
33
|
ANTHROPIC_MESSAGES_API_URL,
|
|
34
34
|
OPENAI_RESPONSES_API_URL,
|
|
35
35
|
type BgTaskEntry,
|
|
36
36
|
} from './requests';
|
|
37
|
-
import { isPagedReadFile, isImageVisionFile
|
|
37
|
+
import { isPagedReadFile, isImageVisionFile } from './office';
|
|
38
38
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
|
|
39
39
|
import { buildBoundedChatMessages } from './budget';
|
|
40
40
|
import { createInlineLinkRegex } from './links';
|
|
41
41
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
|
|
42
42
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
43
|
-
import type { ChatHost, ChatState, ChatMessage } from './host';
|
|
43
|
+
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
44
44
|
|
|
45
45
|
function sleep(ms: number): Promise<void> {
|
|
46
46
|
return new Promise(function (r) { setTimeout(r, ms); });
|
|
@@ -58,6 +58,22 @@ function nextFrame(cb: (t: number) => void): void {
|
|
|
58
58
|
setTimeout(function () { cb(nowMs()); }, 16);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** A live poll registered in ChatSession.historyItemPolls. */
|
|
62
|
+
export type PollHandle = {
|
|
63
|
+
/** 'bg' = background indexing, pausable. 'fg' = a reply the user is waiting on. */
|
|
64
|
+
kind: 'fg' | 'bg';
|
|
65
|
+
/** Absent on an older skapi-js that cannot stop an attached poll. */
|
|
66
|
+
stop?: () => void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when a poll result came from stopPolling rather than the server. Duck-typed on
|
|
71
|
+
* purpose — see the note above about not importing skapi-js here.
|
|
72
|
+
*/
|
|
73
|
+
function isPollStopped(res: any): boolean {
|
|
74
|
+
return !!res && typeof res === 'object' && res.status === 'stopped';
|
|
75
|
+
}
|
|
76
|
+
|
|
61
77
|
export class ChatSession {
|
|
62
78
|
host: ChatHost;
|
|
63
79
|
state: ChatState;
|
|
@@ -65,7 +81,11 @@ export class ChatSession {
|
|
|
65
81
|
cancelledServerIds: Set<string>;
|
|
66
82
|
pendingAgentRequests: Record<string, Promise<any>>;
|
|
67
83
|
aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
|
|
68
|
-
historyItemPolls: Map<string,
|
|
84
|
+
historyItemPolls: Map<string, PollHandle>;
|
|
85
|
+
/** Non-empty while polling is paused; keyed by reason so overlapping causes
|
|
86
|
+
* (view detached AND tab hidden) do not resume each other prematurely. */
|
|
87
|
+
private _pauseReasons: Set<string>;
|
|
88
|
+
private _resuming: boolean;
|
|
69
89
|
private _lidSeq: number;
|
|
70
90
|
|
|
71
91
|
constructor(host: ChatHost) {
|
|
@@ -89,9 +109,91 @@ export class ChatSession {
|
|
|
89
109
|
this.pendingAgentRequests = {};
|
|
90
110
|
this.aiChatHistoryCache = {};
|
|
91
111
|
this.historyItemPolls = new Map();
|
|
112
|
+
this._pauseReasons = new Set();
|
|
113
|
+
this._resuming = false;
|
|
92
114
|
this._lidSeq = 0;
|
|
93
115
|
}
|
|
94
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Register a live poll so (a) a remount dedupes against it instead of stacking a
|
|
119
|
+
* SECOND poll on the same item, and (b) pausePolling can stop it.
|
|
120
|
+
*
|
|
121
|
+
* `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
|
|
122
|
+
* poll simply cannot be stopped and is left running — see pausePolling.
|
|
123
|
+
*/
|
|
124
|
+
private _trackPoll(id: string, kind: 'fg' | 'bg', p: any): any {
|
|
125
|
+
var stop = p && typeof p.stop === 'function' ? p.stop.bind(p) : undefined;
|
|
126
|
+
if (!stop) {
|
|
127
|
+
// The SDK could not give us a handle. Almost always means an older skapi-js
|
|
128
|
+
// is loaded (or a stale bundler dep cache) — the poll will be unstoppable.
|
|
129
|
+
console.debug('[chat-engine] poll has no stop handle', { id: id, kind: kind });
|
|
130
|
+
}
|
|
131
|
+
this.historyItemPolls.set(id, { kind: kind, stop: stop });
|
|
132
|
+
return p;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True while any pause reason is active. */
|
|
136
|
+
isPollingPaused(): boolean {
|
|
137
|
+
return this._pauseReasons.size > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Stop BACKGROUND polling until resumePolling. Foreground polls (a reply the user is
|
|
142
|
+
* waiting on) keep running deliberately: their results must still land in the history
|
|
143
|
+
* cache so resumePendingRequest can render them on return, otherwise a user who sends
|
|
144
|
+
* a message then navigates away comes back to a permanently stuck "Thinking...".
|
|
145
|
+
*
|
|
146
|
+
* Server-side work is untouched; this only stops asking about it. That is safe for
|
|
147
|
+
* document indexing because the worker drives that loop itself.
|
|
148
|
+
*/
|
|
149
|
+
pausePolling(reason: string): void {
|
|
150
|
+
this._pauseReasons.add(reason || 'paused');
|
|
151
|
+
var self = this;
|
|
152
|
+
var stopped: string[] = [];
|
|
153
|
+
this.historyItemPolls.forEach(function (handle, id) {
|
|
154
|
+
if (!handle || handle.kind !== 'bg') return;
|
|
155
|
+
// No stop available (older SDK): LEAVE the entry in place. Deleting it would
|
|
156
|
+
// let a later drain attach a second, uncancellable poll on the same item.
|
|
157
|
+
if (typeof handle.stop !== 'function') return;
|
|
158
|
+
try { handle.stop(); } catch (e) { /* best effort */ }
|
|
159
|
+
stopped.push(id);
|
|
160
|
+
});
|
|
161
|
+
// Delete only what we actually stopped, one at a time. NEVER clear() the map:
|
|
162
|
+
// wholesale clearing is what previously let loadHistory attach a duplicate poll
|
|
163
|
+
// on a live item, producing duplicate replies and stranded "Thinking" bubbles.
|
|
164
|
+
stopped.forEach(function (id) { self.historyItemPolls.delete(id); });
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Lift a pause reason WITHOUT running the reconcile. For a caller that is about to
|
|
170
|
+
* reload history anyway (a view remounting), letting resumePolling also reconcile
|
|
171
|
+
* would race that load and can double-attach.
|
|
172
|
+
*/
|
|
173
|
+
clearPauseReason(reason: string): void {
|
|
174
|
+
this._pauseReasons.delete(reason || 'paused');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Clear a pause reason and, once none remain, re-attach polling and reconcile.
|
|
179
|
+
* Deliberately does NOT touch gateRefreshToken: bumping it would silently discard
|
|
180
|
+
* the results of anything still in flight across the pause.
|
|
181
|
+
*/
|
|
182
|
+
resumePolling(reason: string): Promise<void> {
|
|
183
|
+
this._pauseReasons.delete(reason || 'paused');
|
|
184
|
+
if (this._pauseReasons.size > 0 || this._resuming) return Promise.resolve();
|
|
185
|
+
if (!this.host.isViewMounted || !this.host.isViewMounted()) return Promise.resolve();
|
|
186
|
+
var self = this;
|
|
187
|
+
this._resuming = true;
|
|
188
|
+
return Promise.resolve()
|
|
189
|
+
.then(function () {
|
|
190
|
+
self.drainBgTaskQueue();
|
|
191
|
+
return self.loadHistory(false, self.state.gateRefreshToken);
|
|
192
|
+
})
|
|
193
|
+
.catch(function (e: any) { console.error('[chat-engine] resume polling failed', e); })
|
|
194
|
+
.then(function () { self._resuming = false; });
|
|
195
|
+
}
|
|
196
|
+
|
|
95
197
|
private _newLocalId(): string {
|
|
96
198
|
this._lidSeq += 1;
|
|
97
199
|
return 'lid_' + this._lidSeq;
|
|
@@ -106,18 +208,87 @@ export class ChatSession {
|
|
|
106
208
|
updateHistoryCache(): void {
|
|
107
209
|
var key = this.getHistoryCacheKey();
|
|
108
210
|
if (!key) return;
|
|
211
|
+
// Never persist another chat's in-flight bubbles under THIS key. The
|
|
212
|
+
// dashboard shares one ChatSession across projects, so a turn dispatched
|
|
213
|
+
// in project A can still be sitting in state.messages while the identity
|
|
214
|
+
// (and therefore `key`) already points at project B — without this filter
|
|
215
|
+
// A's user + "Thinking..." bubbles get written into B's cache entry and
|
|
216
|
+
// replay on every later visit to B. Bubbles with no _ownerKey (server
|
|
217
|
+
// history, bg tasks) are always kept. Single pass: this runs on the
|
|
218
|
+
// typewriter hot path.
|
|
109
219
|
this.aiChatHistoryCache[key] = {
|
|
110
|
-
messages: this.state.messages.
|
|
220
|
+
messages: this.state.messages.filter(function (m) {
|
|
221
|
+
return m._ownerKey === undefined || m._ownerKey === key;
|
|
222
|
+
}),
|
|
111
223
|
endOfList: this.state.historyEndOfList,
|
|
112
224
|
startKeyHistory: this.state.historyStartKeyHistory.slice(),
|
|
113
225
|
};
|
|
114
226
|
}
|
|
115
227
|
|
|
116
|
-
|
|
117
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Land a resolved reply in the history cache of a chat that is NOT currently
|
|
230
|
+
* visible, without touching state.messages. Mirrors the cache-only path in
|
|
231
|
+
* dispatchAgentRequest: REPLACE the trailing pending "Thinking..." bubble
|
|
232
|
+
* (append only when there is none), and settle the matching pending user
|
|
233
|
+
* bubble, so the cached copy never keeps a stuck "Thinking..." that a later
|
|
234
|
+
* cache-first load would re-render forever.
|
|
235
|
+
*/
|
|
236
|
+
private _applyReplyToCache(key: string, reply: ChatMessage, serverId?: string): void {
|
|
237
|
+
if (!key) return;
|
|
238
|
+
var existing = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
239
|
+
var msgs = existing.messages.slice();
|
|
240
|
+
|
|
241
|
+
var thIdx = -1;
|
|
242
|
+
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
243
|
+
var m = msgs[i];
|
|
244
|
+
if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
|
|
245
|
+
if (serverId && m._serverItemId && m._serverItemId !== serverId) continue;
|
|
246
|
+
thIdx = i; break;
|
|
247
|
+
}
|
|
248
|
+
if (thIdx !== -1) {
|
|
249
|
+
if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
|
|
250
|
+
msgs[thIdx] = reply;
|
|
251
|
+
} else {
|
|
252
|
+
msgs.push(reply);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Settle the user bubble this turn belongs to (first pending one, or the
|
|
256
|
+
// one carrying serverId) so it stops rendering as still in flight.
|
|
257
|
+
for (var j = 0; j < msgs.length; j++) {
|
|
258
|
+
var u = msgs[j];
|
|
259
|
+
if (!u || u.role !== 'user' || u.isBackgroundTask) continue;
|
|
260
|
+
if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
|
|
261
|
+
if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
|
|
262
|
+
var settled: ChatMessage = { role: 'user', content: u.content };
|
|
263
|
+
if (u._serverItemId !== undefined) settled._serverItemId = u._serverItemId;
|
|
264
|
+
if (u._ownerKey !== undefined) settled._ownerKey = u._ownerKey;
|
|
265
|
+
msgs[j] = settled;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.aiChatHistoryCache[key] = {
|
|
270
|
+
messages: msgs,
|
|
271
|
+
endOfList: existing.endOfList,
|
|
272
|
+
startKeyHistory: existing.startKeyHistory,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* serviceId/owner are passed explicitly by every caller: a request can be
|
|
278
|
+
* dispatched after the user moved to another project, and re-reading the live
|
|
279
|
+
* identity here would silently send the turn to THAT project instead of the
|
|
280
|
+
* one it was composed for. Falls back to the live read only when a caller
|
|
281
|
+
* omits them.
|
|
282
|
+
*/
|
|
283
|
+
private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, serviceId?: string, owner?: string) {
|
|
284
|
+
if (serviceId === undefined || owner === undefined) {
|
|
285
|
+
var id = this.host.getIdentity();
|
|
286
|
+
if (serviceId === undefined) serviceId = id.serviceId;
|
|
287
|
+
if (owner === undefined) owner = id.owner;
|
|
288
|
+
}
|
|
118
289
|
return platform === 'openai'
|
|
119
|
-
? callOpenAIWithPublicMcp(prompt,
|
|
120
|
-
: callClaudeWithPublicMcp(prompt,
|
|
290
|
+
? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
|
|
291
|
+
: callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
|
|
121
292
|
}
|
|
122
293
|
|
|
123
294
|
dispatchAgentRequest(params: any) {
|
|
@@ -131,15 +302,16 @@ export class ChatSession {
|
|
|
131
302
|
var dispatchItemId: string | undefined;
|
|
132
303
|
var sendAndPoll = function () {
|
|
133
304
|
return Promise.resolve(
|
|
134
|
-
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls)
|
|
305
|
+
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
|
|
135
306
|
).then(function (initial: any) {
|
|
136
307
|
if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
|
|
137
308
|
if (initial.id) {
|
|
138
309
|
if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
|
|
139
310
|
dispatchItemId = initial.id;
|
|
140
|
-
self.historyItemPolls.set(initial.id, true);
|
|
141
311
|
}
|
|
142
|
-
|
|
312
|
+
var dp = initial.poll({ latency: POLL_INTERVAL });
|
|
313
|
+
if (initial.id) self._trackPoll(initial.id, 'fg', dp);
|
|
314
|
+
return dp;
|
|
143
315
|
}
|
|
144
316
|
return initial;
|
|
145
317
|
});
|
|
@@ -213,24 +385,73 @@ export class ChatSession {
|
|
|
213
385
|
// composed = clean display text; composedForLlm carries office-extraction
|
|
214
386
|
// placeholders for the provider only. useBgQueue routes a post-attachment turn
|
|
215
387
|
// onto the "-bg" queue so it runs after indexing.
|
|
216
|
-
dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any): void {
|
|
388
|
+
dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
|
|
217
389
|
var self = this;
|
|
218
390
|
if (!composed) return;
|
|
219
|
-
|
|
391
|
+
// A send can be dispatched LONG after the user hit Send (attachment
|
|
392
|
+
// uploads are awaited first), by which time the live identity may have
|
|
393
|
+
// moved to another project. The caller pins the identity + system prompt
|
|
394
|
+
// it captured at Send time so the request still goes to the project the
|
|
395
|
+
// question was actually asked of. Falls back to the live read when the
|
|
396
|
+
// caller doesn't pin (the widget, which has only one project anyway).
|
|
397
|
+
var id = pinned ? pinned.identity : this.host.getIdentity();
|
|
220
398
|
if (id.platform === 'none') return;
|
|
221
399
|
|
|
222
400
|
var llmComposed = composedForLlm || composed;
|
|
223
401
|
|
|
224
|
-
|
|
402
|
+
// Cache key of the chat this turn belongs to. Every locally-created
|
|
403
|
+
// bubble is stamped with it so a project switch (which flips
|
|
404
|
+
// getIdentity()/getHistoryCacheKey() to the new project) can't
|
|
405
|
+
// misattribute this turn's bubbles to that project.
|
|
406
|
+
// (platform === 'none' already returned above, so serviceId is the only gate)
|
|
407
|
+
var key = !id.serviceId ? '' : id.serviceId + '#' + id.platform;
|
|
408
|
+
// True when the pinned chat is NOT the one currently on screen. Then
|
|
409
|
+
// state.messages belongs to a different project and MUST NOT be touched:
|
|
410
|
+
// the turn is staged in the pinned chat's cache instead and shows up when
|
|
411
|
+
// the user navigates back to it.
|
|
412
|
+
var offChat = !!key && key !== this.getHistoryCacheKey();
|
|
413
|
+
|
|
414
|
+
var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function (m) {
|
|
225
415
|
return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
|
|
226
|
-
});
|
|
416
|
+
}));
|
|
227
417
|
|
|
228
418
|
var aiPlatform = id.platform;
|
|
229
419
|
var aiModel = id.model || undefined;
|
|
230
|
-
var systemPrompt = this.host.buildSystemPrompt();
|
|
420
|
+
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
231
421
|
var userId = id.userId || id.serviceId;
|
|
232
422
|
var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
|
|
233
423
|
|
|
424
|
+
if (offChat) {
|
|
425
|
+
// Stage the turn in the pinned chat's cache and dispatch. The
|
|
426
|
+
// client-side queue can't be consulted (its state is the other
|
|
427
|
+
// project's), but the SERVER serializes per queue name, so ordering
|
|
428
|
+
// within the pinned chat still holds. dispatchAgentRequest replaces
|
|
429
|
+
// the pending bubble in this same cache entry when the reply lands.
|
|
430
|
+
var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
|
|
431
|
+
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
432
|
+
!m.isCancelled && !m.isBackgroundTask;
|
|
433
|
+
});
|
|
434
|
+
var offBounded = buildBoundedChatMessages({
|
|
435
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
436
|
+
history: offHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
437
|
+
});
|
|
438
|
+
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
439
|
+
this.aiChatHistoryCache[key] = {
|
|
440
|
+
messages: offExisting.messages.concat([
|
|
441
|
+
{ role: 'user', content: composed, _ownerKey: key },
|
|
442
|
+
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
443
|
+
]),
|
|
444
|
+
endOfList: offExisting.endOfList,
|
|
445
|
+
startKeyHistory: offExisting.startKeyHistory,
|
|
446
|
+
};
|
|
447
|
+
this.dispatchAgentRequest({
|
|
448
|
+
key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
449
|
+
systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
|
|
450
|
+
extractContent: extractContent, fileUrls: fileUrls,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
234
455
|
if (isQueuedSend) {
|
|
235
456
|
var resolvedHistory = this.state.messages.filter(function (m) {
|
|
236
457
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
@@ -241,15 +462,20 @@ export class ChatSession {
|
|
|
241
462
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
242
463
|
});
|
|
243
464
|
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
|
|
465
|
+
if (key) queuedBubble._ownerKey = key;
|
|
244
466
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
245
467
|
this.state.messages.push(queuedBubble);
|
|
246
468
|
this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
|
|
247
469
|
|
|
248
|
-
var capturedComposed = composed, capturedPlatform = aiPlatform;
|
|
249
|
-
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls))
|
|
470
|
+
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
471
|
+
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner))
|
|
250
472
|
.then(function (result: any) {
|
|
251
|
-
|
|
252
|
-
|
|
473
|
+
// Only ack a bubble that belongs to THIS chat — the search is
|
|
474
|
+
// positional, so on another project it would stamp this turn's
|
|
475
|
+
// _serverItemId onto that project's unrelated in-flight bubble.
|
|
476
|
+
var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
|
|
477
|
+
return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
|
|
478
|
+
(m._ownerKey === undefined || m._ownerKey === capturedKey);
|
|
253
479
|
});
|
|
254
480
|
var serverId = result && typeof result.id === 'string' ? result.id : undefined;
|
|
255
481
|
if (sendingIdx >= 0) {
|
|
@@ -260,14 +486,15 @@ export class ChatSession {
|
|
|
260
486
|
if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
|
|
261
487
|
// Track this queued item's poll so a remount/refetch dedups
|
|
262
488
|
// against it instead of attaching a duplicate history poll.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.
|
|
489
|
+
var qp = result.poll({ latency: POLL_INTERVAL });
|
|
490
|
+
if (serverId) self._trackPoll(serverId, 'fg', qp);
|
|
491
|
+
return qp
|
|
492
|
+
.then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
|
|
493
|
+
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId, capturedKey); });
|
|
267
494
|
}
|
|
268
|
-
return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
|
|
495
|
+
return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
|
|
269
496
|
})
|
|
270
|
-
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
|
|
497
|
+
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined, capturedKey); });
|
|
271
498
|
return;
|
|
272
499
|
}
|
|
273
500
|
|
|
@@ -276,11 +503,10 @@ export class ChatSession {
|
|
|
276
503
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
277
504
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
278
505
|
// request was still in flight.
|
|
279
|
-
this.state.messages.push({ role: 'user', content: composed });
|
|
280
|
-
this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true });
|
|
506
|
+
this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
|
|
507
|
+
this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
|
|
281
508
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
282
509
|
|
|
283
|
-
var key = this.getHistoryCacheKey();
|
|
284
510
|
var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
|
|
285
511
|
if (llmComposed !== composed) {
|
|
286
512
|
for (var li = historyForLlm.length - 1; li >= 0; li--) {
|
|
@@ -310,8 +536,16 @@ export class ChatSession {
|
|
|
310
536
|
// already replaced the pending bubble with the answer IN THE CACHE, so a
|
|
311
537
|
// later loadChatHistory renders it.
|
|
312
538
|
Promise.resolve(run).catch(function () { }).then(function () {
|
|
313
|
-
|
|
539
|
+
// Clear `sending` UNCONDITIONALLY. It is session-global (it gates
|
|
540
|
+
// isQueuedSend above, plus the platform/model pickers in the view), so
|
|
541
|
+
// leaving it set when the user navigated to another project wedged
|
|
542
|
+
// every subsequent send in EVERY project onto the queued path and kept
|
|
543
|
+
// the view's user-bubble rescue arm armed forever. Only the RENDER of
|
|
544
|
+
// the reply stays gated on still showing this chat — when it isn't,
|
|
545
|
+
// dispatchAgentRequest has already written the answer into the cache
|
|
546
|
+
// under `key`, so a later loadChatHistory renders it.
|
|
314
547
|
self.state.sending = false;
|
|
548
|
+
if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
|
|
315
549
|
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
|
|
316
550
|
});
|
|
317
551
|
}
|
|
@@ -325,9 +559,11 @@ export class ChatSession {
|
|
|
325
559
|
var existing = this.state.messages[nextIdx];
|
|
326
560
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
|
|
327
561
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
562
|
+
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
328
563
|
this.state.messages[nextIdx] = promoted;
|
|
329
564
|
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
|
|
330
565
|
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
566
|
+
if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
|
|
331
567
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
332
568
|
this.host.notify();
|
|
333
569
|
}
|
|
@@ -342,6 +578,7 @@ export class ChatSession {
|
|
|
342
578
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
|
|
343
579
|
if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
|
|
344
580
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
581
|
+
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
345
582
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
346
583
|
this.state.messages[nextIdx] = promoted;
|
|
347
584
|
// Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
|
|
@@ -354,11 +591,16 @@ export class ChatSession {
|
|
|
354
591
|
// unaffected.
|
|
355
592
|
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
|
|
356
593
|
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
594
|
+
if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
|
|
357
595
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
358
596
|
this.host.notify();
|
|
359
597
|
}
|
|
360
598
|
|
|
361
599
|
resolveQueuedUserBubble(serverId?: string): number | undefined {
|
|
600
|
+
// The two fallbacks below match by POSITION, not identity, so they must
|
|
601
|
+
// never consider a bubble stamped for a different chat.
|
|
602
|
+
var liveKey = this.getHistoryCacheKey();
|
|
603
|
+
var isLocal = function (m: ChatMessage) { return m._ownerKey === undefined || m._ownerKey === liveKey; };
|
|
362
604
|
var userIdx = -1;
|
|
363
605
|
if (serverId) {
|
|
364
606
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -368,19 +610,19 @@ export class ChatSession {
|
|
|
368
610
|
}
|
|
369
611
|
if (userIdx === -1) {
|
|
370
612
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
371
|
-
return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
|
|
613
|
+
return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
|
|
372
614
|
});
|
|
373
615
|
}
|
|
374
616
|
if (userIdx === -1) {
|
|
375
617
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
376
|
-
return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
|
|
618
|
+
return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
|
|
377
619
|
});
|
|
378
620
|
}
|
|
379
621
|
if (serverId && this.cancelledServerIds.has(serverId)) {
|
|
380
622
|
this.cancelledServerIds.delete(serverId);
|
|
381
623
|
if (userIdx >= 0) {
|
|
382
624
|
var ex = this.state.messages[userIdx];
|
|
383
|
-
this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
|
|
625
|
+
this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
|
|
384
626
|
var thIdx = this.state.messages.findIndex(function (m, i) {
|
|
385
627
|
return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
386
628
|
});
|
|
@@ -393,6 +635,7 @@ export class ChatSession {
|
|
|
393
635
|
var exist = this.state.messages[userIdx];
|
|
394
636
|
var repl: ChatMessage = { role: 'user', content: exist.content };
|
|
395
637
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
638
|
+
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
396
639
|
this.state.messages[userIdx] = repl;
|
|
397
640
|
}
|
|
398
641
|
var thinkingIdx = userIdx >= 0
|
|
@@ -407,8 +650,21 @@ export class ChatSession {
|
|
|
407
650
|
else this.state.messages.push(msg);
|
|
408
651
|
}
|
|
409
652
|
|
|
410
|
-
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
|
|
653
|
+
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
|
|
411
654
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
655
|
+
// This turn resolved while a DIFFERENT chat is on screen (the user moved to
|
|
656
|
+
// another project mid-flight). state.messages now belongs to that chat, and
|
|
657
|
+
// resolveQueuedUserBubble's positional fallbacks would happily hijack ITS
|
|
658
|
+
// pending bubble — or, finding nothing, push this answer onto its list.
|
|
659
|
+
// Settle in the owning chat's cache instead and leave the view untouched.
|
|
660
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
661
|
+
var offReply: ChatMessage = isErrorResponseBody(response)
|
|
662
|
+
? { role: 'assistant', content: getErrorMessage(response), isError: true }
|
|
663
|
+
: { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
|
|
664
|
+
this._applyReplyToCache(ownerKey, offReply, serverId);
|
|
665
|
+
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
412
668
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
413
669
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
414
670
|
if (isErrorResponseBody(response)) {
|
|
@@ -436,9 +692,19 @@ export class ChatSession {
|
|
|
436
692
|
this.host.scrollToBottom(true);
|
|
437
693
|
}
|
|
438
694
|
|
|
439
|
-
onQueuedSendError(_composed: string, err: any, serverId?: string): void {
|
|
695
|
+
onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
|
|
440
696
|
var self = this;
|
|
441
697
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
698
|
+
// Off-chat resolution — see onQueuedSendResponse. Settle in the owning
|
|
699
|
+
// chat's cache rather than mutating whatever chat is on screen now.
|
|
700
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
701
|
+
var isGone = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
702
|
+
this._applyReplyToCache(ownerKey, isGone
|
|
703
|
+
? { role: 'assistant', content: 'Request was cancelled.', isError: true }
|
|
704
|
+
: { role: 'assistant', content: getErrorMessage(err), isError: true }, serverId);
|
|
705
|
+
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
442
708
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
443
709
|
if (isNotExists) {
|
|
444
710
|
var userIdx = serverId
|
|
@@ -800,7 +1066,11 @@ export class ChatSession {
|
|
|
800
1066
|
}
|
|
801
1067
|
this.bgTaskQueue.forEach(function (entry) {
|
|
802
1068
|
if (entry.serviceId !== svcId || entry.platform !== plat) return;
|
|
803
|
-
|
|
1069
|
+
// Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
|
|
1070
|
+
// already exists may still need a poll — that is exactly the state a paused
|
|
1071
|
+
// drain leaves behind, and returning early here stranded it as a permanent
|
|
1072
|
+
// "Thinking..." once polling resumed.
|
|
1073
|
+
if (!presentIds[entry.id]) {
|
|
804
1074
|
var isRunning = entry.status === 'running';
|
|
805
1075
|
var userBubble: ChatMessage = { role: 'user', content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
|
|
806
1076
|
if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
|
|
@@ -810,11 +1080,17 @@ export class ChatSession {
|
|
|
810
1080
|
}
|
|
811
1081
|
presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
|
|
812
1082
|
self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
|
|
813
|
-
|
|
814
|
-
|
|
1083
|
+
}
|
|
1084
|
+
if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
|
|
815
1085
|
var capturedId = entry.id, capturedPlat = plat;
|
|
816
1086
|
var capturedEntry = entry;
|
|
817
|
-
|
|
1087
|
+
var wasStopped = false;
|
|
1088
|
+
var bp = entry.poll({ latency: POLL_INTERVAL });
|
|
1089
|
+
self._trackPoll(entry.id, 'bg', bp);
|
|
1090
|
+
bp.then(function (response: any) {
|
|
1091
|
+
// A stopped poll is not a result: leave the bubble and the queue entry
|
|
1092
|
+
// exactly as they were so resumePolling can re-attach.
|
|
1093
|
+
if (isPollStopped(response)) { wasStopped = true; return; }
|
|
818
1094
|
self.handleHistoryItemResolution(capturedId, response, capturedPlat);
|
|
819
1095
|
self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
|
|
820
1096
|
}).catch(function (err: any) {
|
|
@@ -827,6 +1103,9 @@ export class ChatSession {
|
|
|
827
1103
|
self.host.notify(); self.updateHistoryCache();
|
|
828
1104
|
}
|
|
829
1105
|
}).then(function () {
|
|
1106
|
+
// Keep the queue entry when the poll was merely stopped, or resuming
|
|
1107
|
+
// would have nothing left to re-attach to.
|
|
1108
|
+
if (wasStopped) return;
|
|
830
1109
|
var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
|
|
831
1110
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
832
1111
|
});
|
|
@@ -836,30 +1115,30 @@ export class ChatSession {
|
|
|
836
1115
|
}
|
|
837
1116
|
|
|
838
1117
|
// Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
|
|
839
|
-
//
|
|
1118
|
+
// text) finished WITHOUT the completion marker, the agent ran out of room before reading
|
|
840
1119
|
// the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
|
|
841
1120
|
// already-saved records leave off. Additive + guarded so it never loops forever and
|
|
842
1121
|
// never breaks the resolution path.
|
|
1122
|
+
//
|
|
1123
|
+
// VISION files (PDFs rendered to page images) are NOT resumed here: the proxy worker
|
|
1124
|
+
// advances their page window itself, off the renderer's true page count. Driving them
|
|
1125
|
+
// from the browser is what used to lose pages on long documents - the chain lived in tab
|
|
1126
|
+
// memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
|
|
1127
|
+
// completion, which on an 88-page file happened at page 15. Continuing to dispatch here
|
|
1128
|
+
// as well would now double-index every window.
|
|
843
1129
|
maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
|
|
844
1130
|
var self = this;
|
|
845
1131
|
try {
|
|
846
1132
|
if (!entry || !entry.storagePath) return;
|
|
847
1133
|
if (!isPagedReadFile(entry.filename, entry.mime)) return;
|
|
1134
|
+
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
|
|
848
1135
|
if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
|
|
849
1136
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
850
1137
|
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
|
|
851
1138
|
var pass = (entry.resumePass || 0) + 1;
|
|
852
|
-
|
|
853
|
-
// higher cap than the text/grid path (which reads many windows per pass).
|
|
854
|
-
var isVision = isImageVisionFile(entry.filename, entry.mime);
|
|
855
|
-
var maxPasses = isVision ? MAX_VISION_RESUME_PASSES : MAX_INDEXING_RESUME_PASSES;
|
|
856
|
-
if (pass > maxPasses) return; // give up after the cap
|
|
1139
|
+
if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
|
|
857
1140
|
var id = this.host.getIdentity();
|
|
858
1141
|
if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
|
|
859
|
-
// VISION files (PDFs) resume by ADVANCING the rendered page window: pass N reads
|
|
860
|
-
// pages [N*WINDOW, (N+1)*WINDOW). Other paged files resume via a derived cursor
|
|
861
|
-
// (the continue message tells the agent to resume from the furthest saved record).
|
|
862
|
-
var renderFrom = isVision ? pass * RENDER_PAGES_PER_WINDOW : undefined;
|
|
863
1142
|
notifyAgentContinueIndexing({
|
|
864
1143
|
platform: id.platform as 'claude' | 'openai',
|
|
865
1144
|
model: id.model,
|
|
@@ -868,7 +1147,6 @@ export class ChatSession {
|
|
|
868
1147
|
userId: id.userId || id.serviceId,
|
|
869
1148
|
serviceName: id.serviceName,
|
|
870
1149
|
serviceDescription: id.serviceDescription,
|
|
871
|
-
renderFrom: renderFrom,
|
|
872
1150
|
attachment: {
|
|
873
1151
|
name: entry.filename,
|
|
874
1152
|
storagePath: entry.storagePath,
|
|
@@ -900,6 +1178,11 @@ export class ChatSession {
|
|
|
900
1178
|
loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
|
|
901
1179
|
var self = this;
|
|
902
1180
|
var id = this.host.getIdentity();
|
|
1181
|
+
// Key this fetch is FOR, snapshotted from the same identity read the
|
|
1182
|
+
// request is built from. The rescue below compares against this rather
|
|
1183
|
+
// than a live getHistoryCacheKey(), so a project switch mid-fetch can't
|
|
1184
|
+
// make another chat's in-flight bubbles look local.
|
|
1185
|
+
var loadKey = (!id.serviceId || id.platform === 'none') ? '' : id.serviceId + '#' + id.platform;
|
|
903
1186
|
if (token === undefined) token = this.state.gateRefreshToken;
|
|
904
1187
|
if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
|
|
905
1188
|
return Promise.resolve();
|
|
@@ -922,9 +1205,9 @@ export class ChatSession {
|
|
|
922
1205
|
if (token !== self.state.gateRefreshToken) return;
|
|
923
1206
|
var chatList = history && Array.isArray(history.list) ? history.list : [];
|
|
924
1207
|
chatList.forEach(function (item: any) {
|
|
925
|
-
if (
|
|
1208
|
+
if (isBgIndexingQueue(item.queue_name)) {
|
|
926
1209
|
var userText = extractLastUserTextFromRequest(item.request_body);
|
|
927
|
-
if (typeof userText === 'string' && userText.indexOf('A new file has just been uploaded') === 0) item._isBgTask = true;
|
|
1210
|
+
if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
|
|
928
1211
|
else item._isOnBgQueue = true;
|
|
929
1212
|
}
|
|
930
1213
|
});
|
|
@@ -960,6 +1243,10 @@ export class ChatSession {
|
|
|
960
1243
|
for (var ri = 0; ri < self.state.messages.length; ri++) {
|
|
961
1244
|
var mm = self.state.messages[ri];
|
|
962
1245
|
if (mm.isBackgroundTask) continue;
|
|
1246
|
+
// Belongs to a different chat (another project, or another
|
|
1247
|
+
// platform on this one) — it must not be carried onto THIS
|
|
1248
|
+
// chat's freshly-fetched history.
|
|
1249
|
+
if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
|
|
963
1250
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
964
1251
|
if (!mm._serverItemId) {
|
|
965
1252
|
if (mappedHasPendingAssistant) continue;
|
|
@@ -1022,11 +1309,13 @@ export class ChatSession {
|
|
|
1022
1309
|
// dispatch never polls, so they MUST still get their poll — skipping
|
|
1023
1310
|
// them was the stranded-"Thinking" bug this guard must not revive.
|
|
1024
1311
|
if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
|
|
1025
|
-
|
|
1312
|
+
// Background indexing polls are suppressed while paused; foreground
|
|
1313
|
+
// replies the user is waiting on are not.
|
|
1314
|
+
if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
|
|
1026
1315
|
var capturedId = item.id;
|
|
1027
1316
|
var pp = item.poll({
|
|
1028
1317
|
latency: POLL_INTERVAL,
|
|
1029
|
-
onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
1318
|
+
onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
1030
1319
|
onError: function (err: any) {
|
|
1031
1320
|
self.historyItemPolls.delete(capturedId);
|
|
1032
1321
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
@@ -1049,6 +1338,12 @@ export class ChatSession {
|
|
|
1049
1338
|
}
|
|
1050
1339
|
},
|
|
1051
1340
|
});
|
|
1341
|
+
// Anything on the BACKGROUND queue is pausable, not just items whose
|
|
1342
|
+
// prompt text we recognise as an indexing task. _isBgTask vs
|
|
1343
|
+
// _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
|
|
1344
|
+
// chat bubble); keying the pause off _isBgTask alone left every
|
|
1345
|
+
// bg-queue item we could not text-match polling forever.
|
|
1346
|
+
self._trackPoll(capturedId, (item._isBgTask || item._isOnBgQueue) ? 'bg' : 'fg', pp);
|
|
1052
1347
|
if (pp && pp.catch) pp.catch(function () { });
|
|
1053
1348
|
});
|
|
1054
1349
|
self.drainBgTaskQueue();
|