bunnyquery 1.5.7 → 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 +522 -65
- package/dist/engine.cjs +516 -63
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +160 -5
- package/dist/engine.d.ts +160 -5
- package/dist/engine.mjs +511 -64
- 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 +78 -5
- package/src/engine/prompts/index.ts +5 -0
- package/src/engine/prompts/indexing_system_prompt.ts +5 -3
- package/src/engine/prompts/indexing_user_message.ts +159 -0
- package/src/engine/requests.ts +170 -25
- package/src/engine/session.ts +402 -42
package/src/engine/session.ts
CHANGED
|
@@ -21,21 +21,26 @@ import {
|
|
|
21
21
|
callClaudeWithPublicMcp,
|
|
22
22
|
callOpenAIWithPublicMcp,
|
|
23
23
|
notifyAgentSaveAttachment,
|
|
24
|
+
notifyAgentContinueIndexing,
|
|
25
|
+
INDEXING_COMPLETE_MARKER,
|
|
26
|
+
MAX_INDEXING_RESUME_PASSES,
|
|
24
27
|
extractClaudeText,
|
|
25
28
|
extractOpenAIText,
|
|
26
29
|
getChatHistory,
|
|
27
30
|
POLL_INTERVAL,
|
|
28
31
|
BG_INDEXING_QUEUE_SUFFIX,
|
|
32
|
+
isBgIndexingQueue,
|
|
29
33
|
ANTHROPIC_MESSAGES_API_URL,
|
|
30
34
|
OPENAI_RESPONSES_API_URL,
|
|
31
35
|
type BgTaskEntry,
|
|
32
36
|
} from './requests';
|
|
37
|
+
import { isPagedReadFile, isImageVisionFile } from './office';
|
|
33
38
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
|
|
34
39
|
import { buildBoundedChatMessages } from './budget';
|
|
35
40
|
import { createInlineLinkRegex } from './links';
|
|
36
41
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
|
|
37
42
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
38
|
-
import type { ChatHost, ChatState, ChatMessage } from './host';
|
|
43
|
+
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
39
44
|
|
|
40
45
|
function sleep(ms: number): Promise<void> {
|
|
41
46
|
return new Promise(function (r) { setTimeout(r, ms); });
|
|
@@ -53,6 +58,22 @@ function nextFrame(cb: (t: number) => void): void {
|
|
|
53
58
|
setTimeout(function () { cb(nowMs()); }, 16);
|
|
54
59
|
}
|
|
55
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
|
+
|
|
56
77
|
export class ChatSession {
|
|
57
78
|
host: ChatHost;
|
|
58
79
|
state: ChatState;
|
|
@@ -60,7 +81,11 @@ export class ChatSession {
|
|
|
60
81
|
cancelledServerIds: Set<string>;
|
|
61
82
|
pendingAgentRequests: Record<string, Promise<any>>;
|
|
62
83
|
aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
|
|
63
|
-
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;
|
|
64
89
|
private _lidSeq: number;
|
|
65
90
|
|
|
66
91
|
constructor(host: ChatHost) {
|
|
@@ -84,9 +109,91 @@ export class ChatSession {
|
|
|
84
109
|
this.pendingAgentRequests = {};
|
|
85
110
|
this.aiChatHistoryCache = {};
|
|
86
111
|
this.historyItemPolls = new Map();
|
|
112
|
+
this._pauseReasons = new Set();
|
|
113
|
+
this._resuming = false;
|
|
87
114
|
this._lidSeq = 0;
|
|
88
115
|
}
|
|
89
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
|
+
|
|
90
197
|
private _newLocalId(): string {
|
|
91
198
|
this._lidSeq += 1;
|
|
92
199
|
return 'lid_' + this._lidSeq;
|
|
@@ -101,18 +208,87 @@ export class ChatSession {
|
|
|
101
208
|
updateHistoryCache(): void {
|
|
102
209
|
var key = this.getHistoryCacheKey();
|
|
103
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.
|
|
104
219
|
this.aiChatHistoryCache[key] = {
|
|
105
|
-
messages: this.state.messages.
|
|
220
|
+
messages: this.state.messages.filter(function (m) {
|
|
221
|
+
return m._ownerKey === undefined || m._ownerKey === key;
|
|
222
|
+
}),
|
|
106
223
|
endOfList: this.state.historyEndOfList,
|
|
107
224
|
startKeyHistory: this.state.historyStartKeyHistory.slice(),
|
|
108
225
|
};
|
|
109
226
|
}
|
|
110
227
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
113
289
|
return platform === 'openai'
|
|
114
|
-
? callOpenAIWithPublicMcp(prompt,
|
|
115
|
-
: callClaudeWithPublicMcp(prompt,
|
|
290
|
+
? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
|
|
291
|
+
: callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
|
|
116
292
|
}
|
|
117
293
|
|
|
118
294
|
dispatchAgentRequest(params: any) {
|
|
@@ -126,15 +302,16 @@ export class ChatSession {
|
|
|
126
302
|
var dispatchItemId: string | undefined;
|
|
127
303
|
var sendAndPoll = function () {
|
|
128
304
|
return Promise.resolve(
|
|
129
|
-
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)
|
|
130
306
|
).then(function (initial: any) {
|
|
131
307
|
if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
|
|
132
308
|
if (initial.id) {
|
|
133
309
|
if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
|
|
134
310
|
dispatchItemId = initial.id;
|
|
135
|
-
self.historyItemPolls.set(initial.id, true);
|
|
136
311
|
}
|
|
137
|
-
|
|
312
|
+
var dp = initial.poll({ latency: POLL_INTERVAL });
|
|
313
|
+
if (initial.id) self._trackPoll(initial.id, 'fg', dp);
|
|
314
|
+
return dp;
|
|
138
315
|
}
|
|
139
316
|
return initial;
|
|
140
317
|
});
|
|
@@ -208,24 +385,73 @@ export class ChatSession {
|
|
|
208
385
|
// composed = clean display text; composedForLlm carries office-extraction
|
|
209
386
|
// placeholders for the provider only. useBgQueue routes a post-attachment turn
|
|
210
387
|
// onto the "-bg" queue so it runs after indexing.
|
|
211
|
-
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 {
|
|
212
389
|
var self = this;
|
|
213
390
|
if (!composed) return;
|
|
214
|
-
|
|
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();
|
|
215
398
|
if (id.platform === 'none') return;
|
|
216
399
|
|
|
217
400
|
var llmComposed = composedForLlm || composed;
|
|
218
401
|
|
|
219
|
-
|
|
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) {
|
|
220
415
|
return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
|
|
221
|
-
});
|
|
416
|
+
}));
|
|
222
417
|
|
|
223
418
|
var aiPlatform = id.platform;
|
|
224
419
|
var aiModel = id.model || undefined;
|
|
225
|
-
var systemPrompt = this.host.buildSystemPrompt();
|
|
420
|
+
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
226
421
|
var userId = id.userId || id.serviceId;
|
|
227
422
|
var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
|
|
228
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
|
+
|
|
229
455
|
if (isQueuedSend) {
|
|
230
456
|
var resolvedHistory = this.state.messages.filter(function (m) {
|
|
231
457
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
@@ -236,15 +462,20 @@ export class ChatSession {
|
|
|
236
462
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
237
463
|
});
|
|
238
464
|
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
|
|
465
|
+
if (key) queuedBubble._ownerKey = key;
|
|
239
466
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
240
467
|
this.state.messages.push(queuedBubble);
|
|
241
468
|
this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
|
|
242
469
|
|
|
243
|
-
var capturedComposed = composed, capturedPlatform = aiPlatform;
|
|
244
|
-
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))
|
|
245
472
|
.then(function (result: any) {
|
|
246
|
-
|
|
247
|
-
|
|
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);
|
|
248
479
|
});
|
|
249
480
|
var serverId = result && typeof result.id === 'string' ? result.id : undefined;
|
|
250
481
|
if (sendingIdx >= 0) {
|
|
@@ -255,14 +486,15 @@ export class ChatSession {
|
|
|
255
486
|
if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
|
|
256
487
|
// Track this queued item's poll so a remount/refetch dedups
|
|
257
488
|
// against it instead of attaching a duplicate history poll.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
.
|
|
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); });
|
|
262
494
|
}
|
|
263
|
-
return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
|
|
495
|
+
return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
|
|
264
496
|
})
|
|
265
|
-
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
|
|
497
|
+
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined, capturedKey); });
|
|
266
498
|
return;
|
|
267
499
|
}
|
|
268
500
|
|
|
@@ -271,11 +503,10 @@ export class ChatSession {
|
|
|
271
503
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
272
504
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
273
505
|
// request was still in flight.
|
|
274
|
-
this.state.messages.push({ role: 'user', content: composed });
|
|
275
|
-
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 } : {}) });
|
|
276
508
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
277
509
|
|
|
278
|
-
var key = this.getHistoryCacheKey();
|
|
279
510
|
var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
|
|
280
511
|
if (llmComposed !== composed) {
|
|
281
512
|
for (var li = historyForLlm.length - 1; li >= 0; li--) {
|
|
@@ -305,8 +536,16 @@ export class ChatSession {
|
|
|
305
536
|
// already replaced the pending bubble with the answer IN THE CACHE, so a
|
|
306
537
|
// later loadChatHistory renders it.
|
|
307
538
|
Promise.resolve(run).catch(function () { }).then(function () {
|
|
308
|
-
|
|
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.
|
|
309
547
|
self.state.sending = false;
|
|
548
|
+
if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
|
|
310
549
|
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottom(true); });
|
|
311
550
|
});
|
|
312
551
|
}
|
|
@@ -320,9 +559,11 @@ export class ChatSession {
|
|
|
320
559
|
var existing = this.state.messages[nextIdx];
|
|
321
560
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
|
|
322
561
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
562
|
+
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
323
563
|
this.state.messages[nextIdx] = promoted;
|
|
324
564
|
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
|
|
325
565
|
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
566
|
+
if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
|
|
326
567
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
327
568
|
this.host.notify();
|
|
328
569
|
}
|
|
@@ -337,6 +578,7 @@ export class ChatSession {
|
|
|
337
578
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
|
|
338
579
|
if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
|
|
339
580
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
581
|
+
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
340
582
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
341
583
|
this.state.messages[nextIdx] = promoted;
|
|
342
584
|
// Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
|
|
@@ -349,11 +591,16 @@ export class ChatSession {
|
|
|
349
591
|
// unaffected.
|
|
350
592
|
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
|
|
351
593
|
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
594
|
+
if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
|
|
352
595
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
353
596
|
this.host.notify();
|
|
354
597
|
}
|
|
355
598
|
|
|
356
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; };
|
|
357
604
|
var userIdx = -1;
|
|
358
605
|
if (serverId) {
|
|
359
606
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -363,19 +610,19 @@ export class ChatSession {
|
|
|
363
610
|
}
|
|
364
611
|
if (userIdx === -1) {
|
|
365
612
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
366
|
-
return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
|
|
613
|
+
return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
|
|
367
614
|
});
|
|
368
615
|
}
|
|
369
616
|
if (userIdx === -1) {
|
|
370
617
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
371
|
-
return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
|
|
618
|
+
return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
|
|
372
619
|
});
|
|
373
620
|
}
|
|
374
621
|
if (serverId && this.cancelledServerIds.has(serverId)) {
|
|
375
622
|
this.cancelledServerIds.delete(serverId);
|
|
376
623
|
if (userIdx >= 0) {
|
|
377
624
|
var ex = this.state.messages[userIdx];
|
|
378
|
-
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 } : {}) };
|
|
379
626
|
var thIdx = this.state.messages.findIndex(function (m, i) {
|
|
380
627
|
return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
381
628
|
});
|
|
@@ -388,6 +635,7 @@ export class ChatSession {
|
|
|
388
635
|
var exist = this.state.messages[userIdx];
|
|
389
636
|
var repl: ChatMessage = { role: 'user', content: exist.content };
|
|
390
637
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
638
|
+
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
391
639
|
this.state.messages[userIdx] = repl;
|
|
392
640
|
}
|
|
393
641
|
var thinkingIdx = userIdx >= 0
|
|
@@ -402,8 +650,21 @@ export class ChatSession {
|
|
|
402
650
|
else this.state.messages.push(msg);
|
|
403
651
|
}
|
|
404
652
|
|
|
405
|
-
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
|
|
653
|
+
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
|
|
406
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
|
+
}
|
|
407
668
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
408
669
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
409
670
|
if (isErrorResponseBody(response)) {
|
|
@@ -431,9 +692,19 @@ export class ChatSession {
|
|
|
431
692
|
this.host.scrollToBottom(true);
|
|
432
693
|
}
|
|
433
694
|
|
|
434
|
-
onQueuedSendError(_composed: string, err: any, serverId?: string): void {
|
|
695
|
+
onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
|
|
435
696
|
var self = this;
|
|
436
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
|
+
}
|
|
437
708
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
438
709
|
if (isNotExists) {
|
|
439
710
|
var userIdx = serverId
|
|
@@ -736,6 +1007,8 @@ export class ChatSession {
|
|
|
736
1007
|
var isErr = isErrorResponseBody(response);
|
|
737
1008
|
var answer = isErr ? getErrorMessage(response)
|
|
738
1009
|
: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
|
|
1010
|
+
// Hide the internal indexing-completion marker from the displayed summary.
|
|
1011
|
+
if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join('').trim();
|
|
739
1012
|
var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
|
|
740
1013
|
if (idx !== -1) {
|
|
741
1014
|
// A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
|
|
@@ -793,7 +1066,11 @@ export class ChatSession {
|
|
|
793
1066
|
}
|
|
794
1067
|
this.bgTaskQueue.forEach(function (entry) {
|
|
795
1068
|
if (entry.serviceId !== svcId || entry.platform !== plat) return;
|
|
796
|
-
|
|
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]) {
|
|
797
1074
|
var isRunning = entry.status === 'running';
|
|
798
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 };
|
|
799
1076
|
if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
|
|
@@ -803,11 +1080,19 @@ export class ChatSession {
|
|
|
803
1080
|
}
|
|
804
1081
|
presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
|
|
805
1082
|
self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottom(false);
|
|
806
|
-
|
|
807
|
-
|
|
1083
|
+
}
|
|
1084
|
+
if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
|
|
808
1085
|
var capturedId = entry.id, capturedPlat = plat;
|
|
809
|
-
|
|
1086
|
+
var capturedEntry = entry;
|
|
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; }
|
|
810
1094
|
self.handleHistoryItemResolution(capturedId, response, capturedPlat);
|
|
1095
|
+
self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
|
|
811
1096
|
}).catch(function (err: any) {
|
|
812
1097
|
self.historyItemPolls.delete(capturedId);
|
|
813
1098
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
@@ -818,6 +1103,9 @@ export class ChatSession {
|
|
|
818
1103
|
self.host.notify(); self.updateHistoryCache();
|
|
819
1104
|
}
|
|
820
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;
|
|
821
1109
|
var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
|
|
822
1110
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
823
1111
|
});
|
|
@@ -826,6 +1114,61 @@ export class ChatSession {
|
|
|
826
1114
|
this.promoteNextBgQueuedToRunning();
|
|
827
1115
|
}
|
|
828
1116
|
|
|
1117
|
+
// Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
|
|
1118
|
+
// text) finished WITHOUT the completion marker, the agent ran out of room before reading
|
|
1119
|
+
// the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
|
|
1120
|
+
// already-saved records leave off. Additive + guarded so it never loops forever and
|
|
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.
|
|
1129
|
+
maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
|
|
1130
|
+
var self = this;
|
|
1131
|
+
try {
|
|
1132
|
+
if (!entry || !entry.storagePath) return;
|
|
1133
|
+
if (!isPagedReadFile(entry.filename, entry.mime)) return;
|
|
1134
|
+
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
|
|
1135
|
+
if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
|
|
1136
|
+
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
1137
|
+
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
|
|
1138
|
+
var pass = (entry.resumePass || 0) + 1;
|
|
1139
|
+
if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
|
|
1140
|
+
var id = this.host.getIdentity();
|
|
1141
|
+
if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
|
|
1142
|
+
notifyAgentContinueIndexing({
|
|
1143
|
+
platform: id.platform as 'claude' | 'openai',
|
|
1144
|
+
model: id.model,
|
|
1145
|
+
service: id.serviceId,
|
|
1146
|
+
owner: id.owner,
|
|
1147
|
+
userId: id.userId || id.serviceId,
|
|
1148
|
+
serviceName: id.serviceName,
|
|
1149
|
+
serviceDescription: id.serviceDescription,
|
|
1150
|
+
attachment: {
|
|
1151
|
+
name: entry.filename,
|
|
1152
|
+
storagePath: entry.storagePath,
|
|
1153
|
+
mime: entry.mime,
|
|
1154
|
+
size: entry.size,
|
|
1155
|
+
url: '',
|
|
1156
|
+
},
|
|
1157
|
+
}).then(function (ack: any) {
|
|
1158
|
+
if (ack && typeof ack.id === 'string') {
|
|
1159
|
+
self.bgTaskQueue.push({
|
|
1160
|
+
serviceId: id.serviceId, platform: id.platform as 'claude' | 'openai', id: ack.id,
|
|
1161
|
+
filename: entry.filename, storagePath: entry.storagePath,
|
|
1162
|
+
isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
|
|
1163
|
+
status: ack.status === 'running' ? 'running' : 'pending',
|
|
1164
|
+
poll: ack.poll, resumePass: pass,
|
|
1165
|
+
});
|
|
1166
|
+
self.drainBgTaskQueue();
|
|
1167
|
+
}
|
|
1168
|
+
}, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
|
|
1169
|
+
} catch (e) { /* best-effort: resume must never break bg-task resolution */ }
|
|
1170
|
+
}
|
|
1171
|
+
|
|
829
1172
|
// --- history fetch + pagination --------------------------------------
|
|
830
1173
|
// Initial load (fetchMore=false) replaces the list (with in-flight rescue +
|
|
831
1174
|
// cancelled-merge) and attaches polls to running/pending items; pagination
|
|
@@ -835,6 +1178,11 @@ export class ChatSession {
|
|
|
835
1178
|
loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
|
|
836
1179
|
var self = this;
|
|
837
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;
|
|
838
1186
|
if (token === undefined) token = this.state.gateRefreshToken;
|
|
839
1187
|
if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
|
|
840
1188
|
return Promise.resolve();
|
|
@@ -857,9 +1205,9 @@ export class ChatSession {
|
|
|
857
1205
|
if (token !== self.state.gateRefreshToken) return;
|
|
858
1206
|
var chatList = history && Array.isArray(history.list) ? history.list : [];
|
|
859
1207
|
chatList.forEach(function (item: any) {
|
|
860
|
-
if (
|
|
1208
|
+
if (isBgIndexingQueue(item.queue_name)) {
|
|
861
1209
|
var userText = extractLastUserTextFromRequest(item.request_body);
|
|
862
|
-
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;
|
|
863
1211
|
else item._isOnBgQueue = true;
|
|
864
1212
|
}
|
|
865
1213
|
});
|
|
@@ -895,6 +1243,10 @@ export class ChatSession {
|
|
|
895
1243
|
for (var ri = 0; ri < self.state.messages.length; ri++) {
|
|
896
1244
|
var mm = self.state.messages[ri];
|
|
897
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;
|
|
898
1250
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
899
1251
|
if (!mm._serverItemId) {
|
|
900
1252
|
if (mappedHasPendingAssistant) continue;
|
|
@@ -957,11 +1309,13 @@ export class ChatSession {
|
|
|
957
1309
|
// dispatch never polls, so they MUST still get their poll — skipping
|
|
958
1310
|
// them was the stranded-"Thinking" bug this guard must not revive.
|
|
959
1311
|
if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
|
|
960
|
-
|
|
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;
|
|
961
1315
|
var capturedId = item.id;
|
|
962
1316
|
var pp = item.poll({
|
|
963
1317
|
latency: POLL_INTERVAL,
|
|
964
|
-
onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
1318
|
+
onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
965
1319
|
onError: function (err: any) {
|
|
966
1320
|
self.historyItemPolls.delete(capturedId);
|
|
967
1321
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
@@ -984,6 +1338,12 @@ export class ChatSession {
|
|
|
984
1338
|
}
|
|
985
1339
|
},
|
|
986
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);
|
|
987
1347
|
if (pp && pp.catch) pp.catch(function () { });
|
|
988
1348
|
});
|
|
989
1349
|
self.drainBgTaskQueue();
|