bunnyquery 1.6.0 → 1.7.0
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 +157 -29
- package/bunnyquery.css +122 -0
- package/bunnyquery.js +1400 -195
- package/dist/engine.cjs +1119 -117
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +517 -12
- package/dist/engine.d.ts +517 -12
- package/dist/engine.mjs +1103 -118
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +19 -0
- package/src/engine/history.ts +20 -2
- package/src/engine/host.ts +47 -1
- package/src/engine/index.ts +25 -1
- package/src/engine/indexing_groups.ts +350 -0
- package/src/engine/links.ts +309 -9
- package/src/engine/office.ts +53 -5
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/prompts/index.ts +3 -0
- package/src/engine/prompts/indexing_user_message.ts +110 -29
- package/src/engine/requests.ts +107 -20
- package/src/engine/session.ts +741 -82
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +122 -0
package/src/engine/session.ts
CHANGED
|
@@ -24,23 +24,25 @@ 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, isWindowedReadFile } from './office';
|
|
38
|
+
import { windowedIndexingEnabled } from './config';
|
|
38
39
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
|
|
39
40
|
import { buildBoundedChatMessages } from './budget';
|
|
40
41
|
import { createInlineLinkRegex } from './links';
|
|
41
42
|
import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
|
|
42
43
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
43
|
-
import type { ChatHost, ChatState, ChatMessage } from './host';
|
|
44
|
+
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
45
|
+
import type { IndexingGroup } from './indexing_groups';
|
|
44
46
|
|
|
45
47
|
function sleep(ms: number): Promise<void> {
|
|
46
48
|
return new Promise(function (r) { setTimeout(r, ms); });
|
|
@@ -58,14 +60,41 @@ function nextFrame(cb: (t: number) => void): void {
|
|
|
58
60
|
setTimeout(function () { cb(nowMs()); }, 16);
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/** A live poll registered in ChatSession.historyItemPolls. */
|
|
64
|
+
export type PollHandle = {
|
|
65
|
+
/** 'bg' = background indexing, pausable. 'fg' = a reply the user is waiting on. */
|
|
66
|
+
kind: 'fg' | 'bg';
|
|
67
|
+
/** Absent on an older skapi-js that cannot stop an attached poll. */
|
|
68
|
+
stop?: () => void;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when a poll result came from stopPolling rather than the server. Duck-typed on
|
|
73
|
+
* purpose — see the note above about not importing skapi-js here.
|
|
74
|
+
*/
|
|
75
|
+
function isPollStopped(res: any): boolean {
|
|
76
|
+
return !!res && typeof res === 'object' && res.status === 'stopped';
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
export class ChatSession {
|
|
62
80
|
host: ChatHost;
|
|
63
81
|
state: ChatState;
|
|
64
82
|
bgTaskQueue: BgTaskEntry[];
|
|
65
83
|
cancelledServerIds: Set<string>;
|
|
84
|
+
/** Files whose indexing the user stopped, keyed exactly as buildChatDisplayList
|
|
85
|
+
* keys a group (storage path, else filename). Cancelling one pass is not
|
|
86
|
+
* enough on its own: the client dispatches CONTINUE passes for paged files, so
|
|
87
|
+
* without this the next pass is enqueued the moment the cancelled one settles.
|
|
88
|
+
* Cleared when a FIRST pass for the same file is queued again (a re-upload or a
|
|
89
|
+
* Reindex from the file manager), so stopping a file never poisons it. */
|
|
90
|
+
cancelledIndexKeys: Set<string>;
|
|
66
91
|
pendingAgentRequests: Record<string, Promise<any>>;
|
|
67
92
|
aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
|
|
68
|
-
historyItemPolls: Map<string,
|
|
93
|
+
historyItemPolls: Map<string, PollHandle>;
|
|
94
|
+
/** Non-empty while polling is paused; keyed by reason so overlapping causes
|
|
95
|
+
* (view detached AND tab hidden) do not resume each other prematurely. */
|
|
96
|
+
private _pauseReasons: Set<string>;
|
|
97
|
+
private _resuming: boolean;
|
|
69
98
|
private _lidSeq: number;
|
|
70
99
|
|
|
71
100
|
constructor(host: ChatHost) {
|
|
@@ -86,12 +115,110 @@ export class ChatSession {
|
|
|
86
115
|
};
|
|
87
116
|
this.bgTaskQueue = [];
|
|
88
117
|
this.cancelledServerIds = new Set();
|
|
118
|
+
this.cancelledIndexKeys = new Set();
|
|
89
119
|
this.pendingAgentRequests = {};
|
|
90
120
|
this.aiChatHistoryCache = {};
|
|
91
121
|
this.historyItemPolls = new Map();
|
|
122
|
+
this._pauseReasons = new Set();
|
|
123
|
+
this._resuming = false;
|
|
92
124
|
this._lidSeq = 0;
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Register a live poll so (a) a remount dedupes against it instead of stacking a
|
|
129
|
+
* SECOND poll on the same item, and (b) pausePolling can stop it.
|
|
130
|
+
*
|
|
131
|
+
* `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
|
|
132
|
+
* poll simply cannot be stopped and is left running — see pausePolling.
|
|
133
|
+
*/
|
|
134
|
+
private _trackPoll(id: string, kind: 'fg' | 'bg', p: any): any {
|
|
135
|
+
var stop = p && typeof p.stop === 'function' ? p.stop.bind(p) : undefined;
|
|
136
|
+
if (!stop) {
|
|
137
|
+
// The SDK could not give us a handle. Almost always means an older skapi-js
|
|
138
|
+
// is loaded (or a stale bundler dep cache) — the poll will be unstoppable.
|
|
139
|
+
console.debug('[chat-engine] poll has no stop handle', { id: id, kind: kind });
|
|
140
|
+
}
|
|
141
|
+
this.historyItemPolls.set(id, { kind: kind, stop: stop });
|
|
142
|
+
return p;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Stop and forget one item's poll. Used after a cancel: the row is either gone
|
|
147
|
+
* (cancelled while queued) or flagged cancelled (cancelled while running), so
|
|
148
|
+
* asking about it again only burns requests. Safe when no poll is attached, and
|
|
149
|
+
* safe on an older skapi-js with no stop handle (the entry is then LEFT in the
|
|
150
|
+
* map so a later drain cannot stack a second, unstoppable poll on the id).
|
|
151
|
+
*/
|
|
152
|
+
private _stopPoll(id: string): void {
|
|
153
|
+
var handle = this.historyItemPolls.get(id);
|
|
154
|
+
if (!handle) return;
|
|
155
|
+
if (typeof handle.stop !== 'function') return;
|
|
156
|
+
try { handle.stop(); } catch (e) { /* best effort */ }
|
|
157
|
+
this.historyItemPolls.delete(id);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True while any pause reason is active. */
|
|
161
|
+
isPollingPaused(): boolean {
|
|
162
|
+
return this._pauseReasons.size > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stop BACKGROUND polling until resumePolling. Foreground polls (a reply the user is
|
|
167
|
+
* waiting on) keep running deliberately: their results must still land in the history
|
|
168
|
+
* cache so resumePendingRequest can render them on return, otherwise a user who sends
|
|
169
|
+
* a message then navigates away comes back to a permanently stuck "Thinking...".
|
|
170
|
+
*
|
|
171
|
+
* Server-side work is untouched; this only stops asking about it. That is safe for
|
|
172
|
+
* document indexing because the worker drives that loop itself.
|
|
173
|
+
*/
|
|
174
|
+
pausePolling(reason: string): void {
|
|
175
|
+
this._pauseReasons.add(reason || 'paused');
|
|
176
|
+
var self = this;
|
|
177
|
+
var stopped: string[] = [];
|
|
178
|
+
this.historyItemPolls.forEach(function (handle, id) {
|
|
179
|
+
if (!handle || handle.kind !== 'bg') return;
|
|
180
|
+
// No stop available (older SDK): LEAVE the entry in place. Deleting it would
|
|
181
|
+
// let a later drain attach a second, uncancellable poll on the same item.
|
|
182
|
+
if (typeof handle.stop !== 'function') return;
|
|
183
|
+
try { handle.stop(); } catch (e) { /* best effort */ }
|
|
184
|
+
stopped.push(id);
|
|
185
|
+
});
|
|
186
|
+
// Delete only what we actually stopped, one at a time. NEVER clear() the map:
|
|
187
|
+
// wholesale clearing is what previously let loadHistory attach a duplicate poll
|
|
188
|
+
// on a live item, producing duplicate replies and stranded "Thinking" bubbles.
|
|
189
|
+
stopped.forEach(function (id) { self.historyItemPolls.delete(id); });
|
|
190
|
+
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Lift a pause reason WITHOUT running the reconcile. For a caller that is about to
|
|
195
|
+
* reload history anyway (a view remounting), letting resumePolling also reconcile
|
|
196
|
+
* would race that load and can double-attach.
|
|
197
|
+
*/
|
|
198
|
+
clearPauseReason(reason: string): void {
|
|
199
|
+
this._pauseReasons.delete(reason || 'paused');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Clear a pause reason and, once none remain, re-attach polling and reconcile.
|
|
204
|
+
* Deliberately does NOT touch gateRefreshToken: bumping it would silently discard
|
|
205
|
+
* the results of anything still in flight across the pause.
|
|
206
|
+
*/
|
|
207
|
+
resumePolling(reason: string): Promise<void> {
|
|
208
|
+
this._pauseReasons.delete(reason || 'paused');
|
|
209
|
+
if (this._pauseReasons.size > 0 || this._resuming) return Promise.resolve();
|
|
210
|
+
if (!this.host.isViewMounted || !this.host.isViewMounted()) return Promise.resolve();
|
|
211
|
+
var self = this;
|
|
212
|
+
this._resuming = true;
|
|
213
|
+
return Promise.resolve()
|
|
214
|
+
.then(function () {
|
|
215
|
+
self.drainBgTaskQueue();
|
|
216
|
+
return self.loadHistory(false, self.state.gateRefreshToken);
|
|
217
|
+
})
|
|
218
|
+
.catch(function (e: any) { console.error('[chat-engine] resume polling failed', e); })
|
|
219
|
+
.then(function () { self._resuming = false; });
|
|
220
|
+
}
|
|
221
|
+
|
|
95
222
|
private _newLocalId(): string {
|
|
96
223
|
this._lidSeq += 1;
|
|
97
224
|
return 'lid_' + this._lidSeq;
|
|
@@ -106,18 +233,101 @@ export class ChatSession {
|
|
|
106
233
|
updateHistoryCache(): void {
|
|
107
234
|
var key = this.getHistoryCacheKey();
|
|
108
235
|
if (!key) return;
|
|
236
|
+
// Never persist another chat's in-flight bubbles under THIS key. The
|
|
237
|
+
// dashboard shares one ChatSession across projects, so a turn dispatched
|
|
238
|
+
// in project A can still be sitting in state.messages while the identity
|
|
239
|
+
// (and therefore `key`) already points at project B — without this filter
|
|
240
|
+
// A's user + "Thinking..." bubbles get written into B's cache entry and
|
|
241
|
+
// replay on every later visit to B. Bubbles with no _ownerKey (server
|
|
242
|
+
// history, bg tasks) are always kept. Single pass: this runs on the
|
|
243
|
+
// typewriter hot path.
|
|
109
244
|
this.aiChatHistoryCache[key] = {
|
|
110
|
-
messages: this.state.messages.
|
|
245
|
+
messages: this.state.messages.filter(function (m) {
|
|
246
|
+
return m._ownerKey === undefined || m._ownerKey === key;
|
|
247
|
+
}),
|
|
111
248
|
endOfList: this.state.historyEndOfList,
|
|
112
249
|
startKeyHistory: this.state.historyStartKeyHistory.slice(),
|
|
113
250
|
};
|
|
114
251
|
}
|
|
115
252
|
|
|
116
|
-
|
|
117
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Land a resolved reply in the history cache of a chat that is NOT currently
|
|
255
|
+
* visible, without touching state.messages. Mirrors the cache-only path in
|
|
256
|
+
* dispatchAgentRequest: REPLACE the trailing pending "Thinking..." bubble
|
|
257
|
+
* (append only when there is none), and settle the matching pending user
|
|
258
|
+
* bubble, so the cached copy never keeps a stuck "Thinking..." that a later
|
|
259
|
+
* cache-first load would re-render forever.
|
|
260
|
+
*/
|
|
261
|
+
private _applyReplyToCache(key: string, reply: ChatMessage, serverId?: string): void {
|
|
262
|
+
if (!key) return;
|
|
263
|
+
var existing = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
264
|
+
var msgs = existing.messages.slice();
|
|
265
|
+
|
|
266
|
+
var thIdx = -1;
|
|
267
|
+
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
268
|
+
var m = msgs[i];
|
|
269
|
+
if (!m || !m.isPending || m.role !== 'assistant' || m.isBackgroundTask) continue;
|
|
270
|
+
if (serverId && m._serverItemId && m._serverItemId !== serverId) continue;
|
|
271
|
+
thIdx = i; break;
|
|
272
|
+
}
|
|
273
|
+
if (thIdx !== -1) {
|
|
274
|
+
if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
|
|
275
|
+
msgs[thIdx] = reply;
|
|
276
|
+
} else {
|
|
277
|
+
// No pending placeholder left to replace. Before appending, check whether
|
|
278
|
+
// this turn is ALREADY in the cache — a history fetch may have mapped the
|
|
279
|
+
// resolved turn in while the reply was in flight. Appending blindly is how
|
|
280
|
+
// one turn ended up rendered twice (the duplicated question+answer): the
|
|
281
|
+
// cache is restored verbatim on the next mount, so a duplicate written
|
|
282
|
+
// here survives every later visit.
|
|
283
|
+
var dupIdx = -1;
|
|
284
|
+
if (serverId) {
|
|
285
|
+
for (var d = msgs.length - 1; d >= 0; d--) {
|
|
286
|
+
var dm = msgs[d];
|
|
287
|
+
if (dm && dm.role === 'assistant' && dm._serverItemId === serverId) { dupIdx = d; break; }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (dupIdx !== -1) msgs[dupIdx] = reply;
|
|
291
|
+
else msgs.push(reply);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Settle the user bubble this turn belongs to (first pending one, or the
|
|
295
|
+
// one carrying serverId) so it stops rendering as still in flight.
|
|
296
|
+
for (var j = 0; j < msgs.length; j++) {
|
|
297
|
+
var u = msgs[j];
|
|
298
|
+
if (!u || u.role !== 'user' || u.isBackgroundTask) continue;
|
|
299
|
+
if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
|
|
300
|
+
if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
|
|
301
|
+
var settled: ChatMessage = { role: 'user', content: u.content };
|
|
302
|
+
if (u._serverItemId !== undefined) settled._serverItemId = u._serverItemId;
|
|
303
|
+
if (u._ownerKey !== undefined) settled._ownerKey = u._ownerKey;
|
|
304
|
+
msgs[j] = settled;
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.aiChatHistoryCache[key] = {
|
|
309
|
+
messages: msgs,
|
|
310
|
+
endOfList: existing.endOfList,
|
|
311
|
+
startKeyHistory: existing.startKeyHistory,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* serviceId/owner are passed explicitly by every caller: a request can be
|
|
317
|
+
* dispatched after the user moved to another project, and re-reading the live
|
|
318
|
+
* identity here would silently send the turn to THAT project instead of the
|
|
319
|
+
* one it was composed for. Falls back to the live read only when a caller
|
|
320
|
+
* omits them.
|
|
321
|
+
*/
|
|
322
|
+
private _callProviderFor(platform: string, prompt: string, messages: any, system: string, model: string | undefined, userId: string, extractContent: any, fileUrls?: any, serviceId?: string, owner?: string) {
|
|
323
|
+
if (serviceId === undefined || owner === undefined) {
|
|
324
|
+
var id = this.host.getIdentity();
|
|
325
|
+
if (serviceId === undefined) serviceId = id.serviceId;
|
|
326
|
+
if (owner === undefined) owner = id.owner;
|
|
327
|
+
}
|
|
118
328
|
return platform === 'openai'
|
|
119
|
-
? callOpenAIWithPublicMcp(prompt,
|
|
120
|
-
: callClaudeWithPublicMcp(prompt,
|
|
329
|
+
? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls)
|
|
330
|
+
: callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
|
|
121
331
|
}
|
|
122
332
|
|
|
123
333
|
dispatchAgentRequest(params: any) {
|
|
@@ -131,15 +341,16 @@ export class ChatSession {
|
|
|
131
341
|
var dispatchItemId: string | undefined;
|
|
132
342
|
var sendAndPoll = function () {
|
|
133
343
|
return Promise.resolve(
|
|
134
|
-
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls)
|
|
344
|
+
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
|
|
135
345
|
).then(function (initial: any) {
|
|
136
346
|
if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
|
|
137
347
|
if (initial.id) {
|
|
138
348
|
if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
|
|
139
349
|
dispatchItemId = initial.id;
|
|
140
|
-
self.historyItemPolls.set(initial.id, true);
|
|
141
350
|
}
|
|
142
|
-
|
|
351
|
+
var dp = initial.poll({ latency: POLL_INTERVAL });
|
|
352
|
+
if (initial.id) self._trackPoll(initial.id, 'fg', dp);
|
|
353
|
+
return dp;
|
|
143
354
|
}
|
|
144
355
|
return initial;
|
|
145
356
|
});
|
|
@@ -213,24 +424,73 @@ export class ChatSession {
|
|
|
213
424
|
// composed = clean display text; composedForLlm carries office-extraction
|
|
214
425
|
// placeholders for the provider only. useBgQueue routes a post-attachment turn
|
|
215
426
|
// onto the "-bg" queue so it runs after indexing.
|
|
216
|
-
dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any): void {
|
|
427
|
+
dispatchComposedMessage(composed: string, useBgQueue?: boolean, composedForLlm?: string, extractContent?: any, fileUrls?: any, pinned?: PinnedDispatchContext): void {
|
|
217
428
|
var self = this;
|
|
218
429
|
if (!composed) return;
|
|
219
|
-
|
|
430
|
+
// A send can be dispatched LONG after the user hit Send (attachment
|
|
431
|
+
// uploads are awaited first), by which time the live identity may have
|
|
432
|
+
// moved to another project. The caller pins the identity + system prompt
|
|
433
|
+
// it captured at Send time so the request still goes to the project the
|
|
434
|
+
// question was actually asked of. Falls back to the live read when the
|
|
435
|
+
// caller doesn't pin (the widget, which has only one project anyway).
|
|
436
|
+
var id = pinned ? pinned.identity : this.host.getIdentity();
|
|
220
437
|
if (id.platform === 'none') return;
|
|
221
438
|
|
|
222
439
|
var llmComposed = composedForLlm || composed;
|
|
223
440
|
|
|
224
|
-
|
|
441
|
+
// Cache key of the chat this turn belongs to. Every locally-created
|
|
442
|
+
// bubble is stamped with it so a project switch (which flips
|
|
443
|
+
// getIdentity()/getHistoryCacheKey() to the new project) can't
|
|
444
|
+
// misattribute this turn's bubbles to that project.
|
|
445
|
+
// (platform === 'none' already returned above, so serviceId is the only gate)
|
|
446
|
+
var key = !id.serviceId ? '' : id.serviceId + '#' + id.platform;
|
|
447
|
+
// True when the pinned chat is NOT the one currently on screen. Then
|
|
448
|
+
// state.messages belongs to a different project and MUST NOT be touched:
|
|
449
|
+
// the turn is staged in the pinned chat's cache instead and shows up when
|
|
450
|
+
// the user navigates back to it.
|
|
451
|
+
var offChat = !!key && key !== this.getHistoryCacheKey();
|
|
452
|
+
|
|
453
|
+
var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function (m) {
|
|
225
454
|
return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
|
|
226
|
-
});
|
|
455
|
+
}));
|
|
227
456
|
|
|
228
457
|
var aiPlatform = id.platform;
|
|
229
458
|
var aiModel = id.model || undefined;
|
|
230
|
-
var systemPrompt = this.host.buildSystemPrompt();
|
|
459
|
+
var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
|
|
231
460
|
var userId = id.userId || id.serviceId;
|
|
232
461
|
var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
|
|
233
462
|
|
|
463
|
+
if (offChat) {
|
|
464
|
+
// Stage the turn in the pinned chat's cache and dispatch. The
|
|
465
|
+
// client-side queue can't be consulted (its state is the other
|
|
466
|
+
// project's), but the SERVER serializes per queue name, so ordering
|
|
467
|
+
// within the pinned chat still holds. dispatchAgentRequest replaces
|
|
468
|
+
// the pending bubble in this same cache entry when the reply lands.
|
|
469
|
+
var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
|
|
470
|
+
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
471
|
+
!m.isCancelled && !m.isBackgroundTask;
|
|
472
|
+
});
|
|
473
|
+
var offBounded = buildBoundedChatMessages({
|
|
474
|
+
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
475
|
+
history: offHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
476
|
+
});
|
|
477
|
+
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
478
|
+
this.aiChatHistoryCache[key] = {
|
|
479
|
+
messages: offExisting.messages.concat([
|
|
480
|
+
{ role: 'user', content: composed, _ownerKey: key },
|
|
481
|
+
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
482
|
+
]),
|
|
483
|
+
endOfList: offExisting.endOfList,
|
|
484
|
+
startKeyHistory: offExisting.startKeyHistory,
|
|
485
|
+
};
|
|
486
|
+
this.dispatchAgentRequest({
|
|
487
|
+
key: key, serviceId: id.serviceId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
488
|
+
systemPrompt: systemPrompt, text: composed, boundedMessages: offBounded.messages, userId: chatQueue,
|
|
489
|
+
extractContent: extractContent, fileUrls: fileUrls,
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
234
494
|
if (isQueuedSend) {
|
|
235
495
|
var resolvedHistory = this.state.messages.filter(function (m) {
|
|
236
496
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
@@ -241,15 +501,20 @@ export class ChatSession {
|
|
|
241
501
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
242
502
|
});
|
|
243
503
|
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
|
|
504
|
+
if (key) queuedBubble._ownerKey = key;
|
|
244
505
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
245
506
|
this.state.messages.push(queuedBubble);
|
|
246
507
|
this.host.notify(); this.updateHistoryCache(); this.host.scrollToBottom(true);
|
|
247
508
|
|
|
248
|
-
var capturedComposed = composed, capturedPlatform = aiPlatform;
|
|
249
|
-
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls))
|
|
509
|
+
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
510
|
+
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner))
|
|
250
511
|
.then(function (result: any) {
|
|
251
|
-
|
|
252
|
-
|
|
512
|
+
// Only ack a bubble that belongs to THIS chat — the search is
|
|
513
|
+
// positional, so on another project it would stamp this turn's
|
|
514
|
+
// _serverItemId onto that project's unrelated in-flight bubble.
|
|
515
|
+
var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function (m) {
|
|
516
|
+
return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user' &&
|
|
517
|
+
(m._ownerKey === undefined || m._ownerKey === capturedKey);
|
|
253
518
|
});
|
|
254
519
|
var serverId = result && typeof result.id === 'string' ? result.id : undefined;
|
|
255
520
|
if (sendingIdx >= 0) {
|
|
@@ -260,14 +525,15 @@ export class ChatSession {
|
|
|
260
525
|
if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
|
|
261
526
|
// Track this queued item's poll so a remount/refetch dedups
|
|
262
527
|
// against it instead of attaching a duplicate history poll.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
.
|
|
528
|
+
var qp = result.poll({ latency: POLL_INTERVAL });
|
|
529
|
+
if (serverId) self._trackPoll(serverId, 'fg', qp);
|
|
530
|
+
return qp
|
|
531
|
+
.then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
|
|
532
|
+
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId, capturedKey); });
|
|
267
533
|
}
|
|
268
|
-
return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
|
|
534
|
+
return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
|
|
269
535
|
})
|
|
270
|
-
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined); });
|
|
536
|
+
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, undefined, capturedKey); });
|
|
271
537
|
return;
|
|
272
538
|
}
|
|
273
539
|
|
|
@@ -276,11 +542,10 @@ export class ChatSession {
|
|
|
276
542
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
277
543
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
278
544
|
// 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 });
|
|
545
|
+
this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
|
|
546
|
+
this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
|
|
281
547
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
282
548
|
|
|
283
|
-
var key = this.getHistoryCacheKey();
|
|
284
549
|
var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
|
|
285
550
|
if (llmComposed !== composed) {
|
|
286
551
|
for (var li = historyForLlm.length - 1; li >= 0; li--) {
|
|
@@ -310,9 +575,17 @@ export class ChatSession {
|
|
|
310
575
|
// already replaced the pending bubble with the answer IN THE CACHE, so a
|
|
311
576
|
// later loadChatHistory renders it.
|
|
312
577
|
Promise.resolve(run).catch(function () { }).then(function () {
|
|
313
|
-
|
|
578
|
+
// Clear `sending` UNCONDITIONALLY. It is session-global (it gates
|
|
579
|
+
// isQueuedSend above, plus the platform/model pickers in the view), so
|
|
580
|
+
// leaving it set when the user navigated to another project wedged
|
|
581
|
+
// every subsequent send in EVERY project onto the queued path and kept
|
|
582
|
+
// the view's user-bubble rescue arm armed forever. Only the RENDER of
|
|
583
|
+
// the reply stays gated on still showing this chat — when it isn't,
|
|
584
|
+
// dispatchAgentRequest has already written the answer into the cache
|
|
585
|
+
// under `key`, so a later loadChatHistory renders it.
|
|
314
586
|
self.state.sending = false;
|
|
315
|
-
|
|
587
|
+
if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
|
|
588
|
+
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
|
|
316
589
|
});
|
|
317
590
|
}
|
|
318
591
|
|
|
@@ -324,10 +597,13 @@ export class ChatSession {
|
|
|
324
597
|
if (nextIdx === -1) return;
|
|
325
598
|
var existing = this.state.messages[nextIdx];
|
|
326
599
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
|
|
600
|
+
if (existing._indexFile) promoted._indexFile = existing._indexFile;
|
|
327
601
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
602
|
+
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
328
603
|
this.state.messages[nextIdx] = promoted;
|
|
329
604
|
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true };
|
|
330
605
|
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
606
|
+
if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
|
|
331
607
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
332
608
|
this.host.notify();
|
|
333
609
|
}
|
|
@@ -341,7 +617,9 @@ export class ChatSession {
|
|
|
341
617
|
var existing = this.state.messages[nextIdx];
|
|
342
618
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
|
|
343
619
|
if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
|
|
620
|
+
if (existing._indexFile) promoted._indexFile = existing._indexFile;
|
|
344
621
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
622
|
+
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
345
623
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
346
624
|
this.state.messages[nextIdx] = promoted;
|
|
347
625
|
// Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
|
|
@@ -354,11 +632,16 @@ export class ChatSession {
|
|
|
354
632
|
// unaffected.
|
|
355
633
|
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
|
|
356
634
|
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
635
|
+
if (existing._ownerKey !== undefined) placeholder._ownerKey = existing._ownerKey;
|
|
357
636
|
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
358
637
|
this.host.notify();
|
|
359
638
|
}
|
|
360
639
|
|
|
361
640
|
resolveQueuedUserBubble(serverId?: string): number | undefined {
|
|
641
|
+
// The two fallbacks below match by POSITION, not identity, so they must
|
|
642
|
+
// never consider a bubble stamped for a different chat.
|
|
643
|
+
var liveKey = this.getHistoryCacheKey();
|
|
644
|
+
var isLocal = function (m: ChatMessage) { return m._ownerKey === undefined || m._ownerKey === liveKey; };
|
|
362
645
|
var userIdx = -1;
|
|
363
646
|
if (serverId) {
|
|
364
647
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -368,19 +651,19 @@ export class ChatSession {
|
|
|
368
651
|
}
|
|
369
652
|
if (userIdx === -1) {
|
|
370
653
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
371
|
-
return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
|
|
654
|
+
return m.isPendingInProcess && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
|
|
372
655
|
});
|
|
373
656
|
}
|
|
374
657
|
if (userIdx === -1) {
|
|
375
658
|
userIdx = this.state.messages.findIndex(function (m) {
|
|
376
|
-
return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue;
|
|
659
|
+
return m.isPendingQueued && m.role === 'user' && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
|
|
377
660
|
});
|
|
378
661
|
}
|
|
379
662
|
if (serverId && this.cancelledServerIds.has(serverId)) {
|
|
380
663
|
this.cancelledServerIds.delete(serverId);
|
|
381
664
|
if (userIdx >= 0) {
|
|
382
665
|
var ex = this.state.messages[userIdx];
|
|
383
|
-
this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
|
|
666
|
+
this.state.messages[userIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...(ex._ownerKey !== undefined ? { _ownerKey: ex._ownerKey } : {}) };
|
|
384
667
|
var thIdx = this.state.messages.findIndex(function (m, i) {
|
|
385
668
|
return i > userIdx && m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
386
669
|
});
|
|
@@ -393,6 +676,7 @@ export class ChatSession {
|
|
|
393
676
|
var exist = this.state.messages[userIdx];
|
|
394
677
|
var repl: ChatMessage = { role: 'user', content: exist.content };
|
|
395
678
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
679
|
+
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
396
680
|
this.state.messages[userIdx] = repl;
|
|
397
681
|
}
|
|
398
682
|
var thinkingIdx = userIdx >= 0
|
|
@@ -407,8 +691,21 @@ export class ChatSession {
|
|
|
407
691
|
else this.state.messages.push(msg);
|
|
408
692
|
}
|
|
409
693
|
|
|
410
|
-
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
|
|
694
|
+
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
|
|
411
695
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
696
|
+
// This turn resolved while a DIFFERENT chat is on screen (the user moved to
|
|
697
|
+
// another project mid-flight). state.messages now belongs to that chat, and
|
|
698
|
+
// resolveQueuedUserBubble's positional fallbacks would happily hijack ITS
|
|
699
|
+
// pending bubble — or, finding nothing, push this answer onto its list.
|
|
700
|
+
// Settle in the owning chat's cache instead and leave the view untouched.
|
|
701
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
702
|
+
var offReply: ChatMessage = isErrorResponseBody(response)
|
|
703
|
+
? { role: 'assistant', content: getErrorMessage(response), isError: true }
|
|
704
|
+
: { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
|
|
705
|
+
this._applyReplyToCache(ownerKey, offReply, serverId);
|
|
706
|
+
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
412
709
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
413
710
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
414
711
|
if (isErrorResponseBody(response)) {
|
|
@@ -433,12 +730,24 @@ export class ChatSession {
|
|
|
433
730
|
this.promoteNextQueuedToRunning();
|
|
434
731
|
this.updateHistoryCache();
|
|
435
732
|
this.host.notify();
|
|
436
|
-
|
|
733
|
+
// ARRIVAL, not a user action: only follow if the user is still pinned to
|
|
734
|
+
// the bottom. Scrolled-up readers keep their position.
|
|
735
|
+
this.host.scrollToBottomIfSticky(true);
|
|
437
736
|
}
|
|
438
737
|
|
|
439
|
-
onQueuedSendError(_composed: string, err: any, serverId?: string): void {
|
|
738
|
+
onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
|
|
440
739
|
var self = this;
|
|
441
740
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
741
|
+
// Off-chat resolution — see onQueuedSendResponse. Settle in the owning
|
|
742
|
+
// chat's cache rather than mutating whatever chat is on screen now.
|
|
743
|
+
if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
|
|
744
|
+
var isGone = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
745
|
+
this._applyReplyToCache(ownerKey, isGone
|
|
746
|
+
? { role: 'assistant', content: 'Request was cancelled.', isError: true }
|
|
747
|
+
: { role: 'assistant', content: getErrorMessage(err), isError: true }, serverId);
|
|
748
|
+
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
442
751
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
443
752
|
if (isNotExists) {
|
|
444
753
|
var userIdx = serverId
|
|
@@ -466,14 +775,14 @@ export class ChatSession {
|
|
|
466
775
|
}
|
|
467
776
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
468
777
|
this._removeStrayPendingAssistants();
|
|
469
|
-
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.
|
|
778
|
+
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
|
|
470
779
|
return;
|
|
471
780
|
}
|
|
472
781
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
473
782
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
474
783
|
this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
|
|
475
784
|
this._removeStrayPendingAssistants();
|
|
476
|
-
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.
|
|
785
|
+
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
|
|
477
786
|
}
|
|
478
787
|
|
|
479
788
|
cancelQueuedMessage(msg: ChatMessage, idx: number): void {
|
|
@@ -493,13 +802,25 @@ export class ChatSession {
|
|
|
493
802
|
})).then(function (result: any) {
|
|
494
803
|
if (result && result.removed) {
|
|
495
804
|
self.cancelledServerIds.add(serverId as string);
|
|
805
|
+
self._stopPoll(serverId as string);
|
|
496
806
|
var qi = self.bgTaskQueue.findIndex(function (e) { return e.id === serverId; });
|
|
497
807
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
498
808
|
var removeIdx = self.state.messages.findIndex(function (m) {
|
|
499
809
|
return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
|
|
500
810
|
});
|
|
501
811
|
if (removeIdx !== -1) {
|
|
502
|
-
|
|
812
|
+
// Carry the background/file markers onto the rebuild. Dropping them
|
|
813
|
+
// took a cancelled INDEXING pass out of its file's collapsed row
|
|
814
|
+
// (buildChatDisplayList only groups isBackgroundTask bubbles), so the
|
|
815
|
+
// row stayed "Indexing…" forever while a bare "Indexing: file" bubble
|
|
816
|
+
// appeared beside it.
|
|
817
|
+
var wasMsg = self.state.messages[removeIdx];
|
|
818
|
+
var cancelledMsg: ChatMessage = { role: 'user', content: wasMsg.content, isCancelled: true, _serverItemId: serverId };
|
|
819
|
+
if (wasMsg.isBackgroundTask) cancelledMsg.isBackgroundTask = true;
|
|
820
|
+
if (wasMsg._indexFile) cancelledMsg._indexFile = wasMsg._indexFile;
|
|
821
|
+
if (wasMsg._useBgQueue) cancelledMsg._useBgQueue = true;
|
|
822
|
+
if (wasMsg._ownerKey !== undefined) cancelledMsg._ownerKey = wasMsg._ownerKey;
|
|
823
|
+
self.state.messages[removeIdx] = cancelledMsg;
|
|
503
824
|
var thById = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant'; });
|
|
504
825
|
if (thById !== -1) self.state.messages.splice(thById, 1);
|
|
505
826
|
else {
|
|
@@ -525,6 +846,47 @@ export class ChatSession {
|
|
|
525
846
|
});
|
|
526
847
|
}
|
|
527
848
|
|
|
849
|
+
/**
|
|
850
|
+
* Stop indexing a file, from its collapsed row — every pass at once, not just
|
|
851
|
+
* the bubble the user happens to see.
|
|
852
|
+
*
|
|
853
|
+
* A big file is indexed as a CHAIN of passes, so cancelling only the live one
|
|
854
|
+
* accomplishes nothing: the next pass is dispatched as soon as it settles.
|
|
855
|
+
* Three things end the chain:
|
|
856
|
+
* 1. every queued/running pass of this file is cancelled server-side
|
|
857
|
+
* (csr-cancel deletes a queued row and flags a running one "cancelled",
|
|
858
|
+
* which is also the worker's gate for NOT enqueueing the next window);
|
|
859
|
+
* 2. the file is remembered in cancelledIndexKeys, so the client-driven
|
|
860
|
+
* resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
|
|
861
|
+
* 3. any of its passes still sitting in bgTaskQueue is dropped by the next
|
|
862
|
+
* drain rather than surfacing a fresh "Indexing…" bubble.
|
|
863
|
+
*
|
|
864
|
+
* Records already written by the passes that DID run are kept — this stops the
|
|
865
|
+
* work, it does not undo it.
|
|
866
|
+
*/
|
|
867
|
+
cancelIndexingGroup(group: IndexingGroup): void {
|
|
868
|
+
var self = this;
|
|
869
|
+
if (!group || !group.key) return;
|
|
870
|
+
// The group belongs to the chat on screen, so scope its key the same way
|
|
871
|
+
// _indexKeyOf scopes a queued task's.
|
|
872
|
+
var scoped = this.getHistoryCacheKey() + '|' + group.key;
|
|
873
|
+
this.cancelledIndexKeys.add(scoped);
|
|
874
|
+
var ids = group.cancellableIds || [];
|
|
875
|
+
if (!ids.length) { this.host.notify(); return; }
|
|
876
|
+
ids.forEach(function (serverId) {
|
|
877
|
+
// Address the message by IDENTITY, not by the index captured when the
|
|
878
|
+
// display list was built: an earlier cancel in this same loop may already
|
|
879
|
+
// have spliced the array (a group can have more than one live pass), and a
|
|
880
|
+
// stale index would mark the WRONG bubble as cancelling.
|
|
881
|
+
var idx = self.state.messages.findIndex(function (m) {
|
|
882
|
+
return m._serverItemId === serverId && m.role === 'user' &&
|
|
883
|
+
(m.isPendingQueued || m.isPendingInProcess);
|
|
884
|
+
});
|
|
885
|
+
if (idx === -1) return;
|
|
886
|
+
self.cancelQueuedMessage(self.state.messages[idx], idx);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
528
890
|
// --- typewriter -------------------------------------------------------
|
|
529
891
|
// Reveal `fullText` into a message bubble at a constant wall-clock RATE
|
|
530
892
|
// (chars/second) driven by requestAnimationFrame, rather than a fixed number
|
|
@@ -706,6 +1068,9 @@ export class ChatSession {
|
|
|
706
1068
|
var u = this.state.messages[uIdx];
|
|
707
1069
|
var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
|
|
708
1070
|
if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
|
|
1071
|
+
// Carry the file ref: it is what keeps this pass in its file's collapsed
|
|
1072
|
+
// row (the label parser is only a fallback for older cached bubbles).
|
|
1073
|
+
if (u._indexFile) cleaned._indexFile = u._indexFile;
|
|
709
1074
|
this.state.messages[uIdx] = cleaned;
|
|
710
1075
|
}
|
|
711
1076
|
|
|
@@ -722,11 +1087,11 @@ export class ChatSession {
|
|
|
722
1087
|
if (!pending) return Promise.resolve();
|
|
723
1088
|
if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
|
|
724
1089
|
this.state.sending = true;
|
|
725
|
-
this.host.
|
|
1090
|
+
this.host.scrollToBottomIfSticky(true);
|
|
726
1091
|
return Promise.resolve(pending).catch(function () { }).then(function () {
|
|
727
1092
|
if (token !== self.state.gateRefreshToken) return;
|
|
728
1093
|
self.state.sending = false;
|
|
729
|
-
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.
|
|
1094
|
+
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
|
|
730
1095
|
});
|
|
731
1096
|
}
|
|
732
1097
|
|
|
@@ -750,13 +1115,35 @@ export class ChatSession {
|
|
|
750
1115
|
// bubble stuck pending — and drainBgTaskQueue then never clears its queue
|
|
751
1116
|
// entry (its stillPending check stays true). Un-pend it here too.
|
|
752
1117
|
this._clearPendingUserBubble(itemId);
|
|
1118
|
+
// Carry isBackgroundTask onto the REPLACEMENT. These rebuilds are fresh
|
|
1119
|
+
// object literals, so anything not copied is lost — and dropping this flag
|
|
1120
|
+
// made a pass that resolved LIVE fall out of its file's collapsed row
|
|
1121
|
+
// (buildChatDisplayList only considers isBackgroundTask messages), while
|
|
1122
|
+
// the same pass rebuilt from history stayed grouped. That mismatch is the
|
|
1123
|
+
// "new indexing response renders outside the group" bug.
|
|
1124
|
+
var wasBgTask = !!this.state.messages[idx].isBackgroundTask;
|
|
753
1125
|
if (isErr) {
|
|
754
1126
|
this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
|
|
1127
|
+
if (wasBgTask) this.state.messages[idx].isBackgroundTask = true;
|
|
1128
|
+
this.host.notify(); this.updateHistoryCache(); return;
|
|
1129
|
+
}
|
|
1130
|
+
var text = answer || 'No text response received from AI provider.';
|
|
1131
|
+
// A background-indexing reply is INSIDE a collapsed row: nothing of it is
|
|
1132
|
+
// on screen. Typewriting it anyway put it on the one shared, serial
|
|
1133
|
+
// typewriter queue, so the user's own next reply — which IS on screen —
|
|
1134
|
+
// lost its "Thinking..." spinner and sat as an EMPTY bubble for however
|
|
1135
|
+
// long the invisible indexing summary took to reveal (seconds per pass,
|
|
1136
|
+
// additive across files). It also held state.typing true for the whole
|
|
1137
|
+
// time, which disables the platform/model pickers and no-ops Clear
|
|
1138
|
+
// history, and ran a per-frame scroll for content nobody can see.
|
|
1139
|
+
// Write it straight in; expanding the row then shows it complete.
|
|
1140
|
+
if (wasBgTask) {
|
|
1141
|
+
this.state.messages[idx] = { role: 'assistant', content: text, isBackgroundTask: true, _serverItemId: itemId };
|
|
755
1142
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
756
1143
|
}
|
|
757
1144
|
var lid = this._newLocalId();
|
|
758
1145
|
this.state.messages[idx] = { role: 'assistant', content: '', _localId: lid, _serverItemId: itemId };
|
|
759
|
-
this.host.notify(); this.enqueueTypewrite(idx,
|
|
1146
|
+
this.host.notify(); this.enqueueTypewrite(idx, text, lid);
|
|
760
1147
|
this.updateHistoryCache(); return;
|
|
761
1148
|
}
|
|
762
1149
|
var userIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -764,23 +1151,125 @@ export class ChatSession {
|
|
|
764
1151
|
});
|
|
765
1152
|
if (userIdx === -1) return;
|
|
766
1153
|
var ex = this.state.messages[userIdx];
|
|
767
|
-
|
|
1154
|
+
// Same carry-over as above, plus _indexFile / _useBgQueue: this rebuild is
|
|
1155
|
+
// what keeps an indexing REQUEST bubble in its file's collapsed row (and a
|
|
1156
|
+
// bg-queued chat cancelling against the right queue) once the pass settles.
|
|
1157
|
+
var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
|
|
1158
|
+
if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
|
|
1159
|
+
if (ex._indexFile) settledUser._indexFile = ex._indexFile;
|
|
1160
|
+
if (ex._useBgQueue) settledUser._useBgQueue = true;
|
|
1161
|
+
this.state.messages[userIdx] = settledUser;
|
|
768
1162
|
if (isErr) {
|
|
769
|
-
|
|
1163
|
+
var errReply: ChatMessage = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
|
|
1164
|
+
if (ex.isBackgroundTask) errReply.isBackgroundTask = true;
|
|
1165
|
+
this.state.messages.splice(userIdx + 1, 0, errReply);
|
|
1166
|
+
this.host.notify(); this.updateHistoryCache(); return;
|
|
1167
|
+
}
|
|
1168
|
+
var text2 = answer || 'No text response received from AI provider.';
|
|
1169
|
+
// Same as above: a collapsed row's reply is not on screen, so revealing it
|
|
1170
|
+
// character by character only blocks the queue the visible reply needs.
|
|
1171
|
+
if (ex.isBackgroundTask) {
|
|
1172
|
+
this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: text2, isBackgroundTask: true, _serverItemId: itemId });
|
|
770
1173
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
771
1174
|
}
|
|
772
1175
|
var lid2 = this._newLocalId();
|
|
773
|
-
|
|
774
|
-
this.
|
|
1176
|
+
var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
|
|
1177
|
+
this.state.messages.splice(userIdx + 1, 0, reply);
|
|
1178
|
+
this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
|
|
775
1179
|
this.updateHistoryCache();
|
|
776
1180
|
}
|
|
777
1181
|
|
|
1182
|
+
/** How a bg task maps onto a collapsed row: the row's own key (storage path
|
|
1183
|
+
* when known, else the filename), scoped to the chat it belongs to. A storage
|
|
1184
|
+
* path is project-relative ("report.xlsx"), and ONE ChatSession serves every
|
|
1185
|
+
* project — unscoped, stopping a file in one project would silently suppress
|
|
1186
|
+
* the same filename's continuations in another. */
|
|
1187
|
+
private _indexKeyOf(entry: BgTaskEntry): string {
|
|
1188
|
+
if (!entry) return '';
|
|
1189
|
+
var file = entry.storagePath || entry.filename;
|
|
1190
|
+
if (!file) return '';
|
|
1191
|
+
return entry.serviceId + '#' + entry.platform + '|' + file;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Reconcile the bg queue with the files the user has stopped.
|
|
1196
|
+
*
|
|
1197
|
+
* A FIRST pass (no resumePass) is a fresh indexing request — a re-upload, or a
|
|
1198
|
+
* Reindex from the file manager — so it LIFTS the stop: the key is a storage
|
|
1199
|
+
* path, and without this an earlier cancel would silently kill every future
|
|
1200
|
+
* index of the same path. A continuation of a stopped file is dropped instead,
|
|
1201
|
+
* covering the pass that was dispatched in the moment before the cancel landed.
|
|
1202
|
+
*/
|
|
1203
|
+
private _applyIndexCancellations(): void {
|
|
1204
|
+
if (!this.cancelledIndexKeys.size) return;
|
|
1205
|
+
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
1206
|
+
var entry = this.bgTaskQueue[i];
|
|
1207
|
+
var key = this._indexKeyOf(entry);
|
|
1208
|
+
if (!key || !this.cancelledIndexKeys.has(key)) continue;
|
|
1209
|
+
if (!entry.resumePass) { this.cancelledIndexKeys.delete(key); continue; }
|
|
1210
|
+
this.bgTaskQueue.splice(i, 1);
|
|
1211
|
+
this._stopPoll(entry.id);
|
|
1212
|
+
this._cancelServerItem(entry.id);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Cancel any live pass of a stopped file that turned up on its own.
|
|
1218
|
+
*
|
|
1219
|
+
* The client is not the only thing that continues a file: for PDFs and (when
|
|
1220
|
+
* windowed indexing is on) text/grid files the WORKER enqueues the next window
|
|
1221
|
+
* itself, and that pass reaches the chat through the history poll, never
|
|
1222
|
+
* through bgTaskQueue. The worker's own gate stops the chain when the running
|
|
1223
|
+
* row is cancelled — but if the row had already finished when the user hit
|
|
1224
|
+
* stop, the next window was queued a moment earlier and still arrives. Stop it
|
|
1225
|
+
* here rather than making the user hit stop again.
|
|
1226
|
+
*
|
|
1227
|
+
* Runs from drainBgTaskQueue, which both clients call after a history load.
|
|
1228
|
+
*/
|
|
1229
|
+
private _sweepCancelledIndexing(): void {
|
|
1230
|
+
if (!this.cancelledIndexKeys.size) return;
|
|
1231
|
+
var self = this;
|
|
1232
|
+
var chatKey = this.getHistoryCacheKey();
|
|
1233
|
+
var targets: { msg: ChatMessage; idx: number }[] = [];
|
|
1234
|
+
this.state.messages.forEach(function (m, i) {
|
|
1235
|
+
if (!m.isBackgroundTask || m.role !== 'user' || !m._serverItemId) return;
|
|
1236
|
+
if (m._cancelling || m.isSendingToServer) return;
|
|
1237
|
+
if (!(m.isPendingQueued || m.isPendingInProcess)) return;
|
|
1238
|
+
var ref = m._indexFile;
|
|
1239
|
+
var file = ref && (ref.path || ref.name);
|
|
1240
|
+
if (!file || !self.cancelledIndexKeys.has(chatKey + '|' + file)) return;
|
|
1241
|
+
targets.push({ msg: m, idx: i });
|
|
1242
|
+
});
|
|
1243
|
+
targets.forEach(function (t) {
|
|
1244
|
+
var idx = self.state.messages.indexOf(t.msg);
|
|
1245
|
+
self.cancelQueuedMessage(t.msg, idx === -1 ? t.idx : idx);
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Best-effort server-side cancel of a bg-queue item that has no bubble (so
|
|
1250
|
+
* cancelQueuedMessage, which drives one, has nothing to act on). */
|
|
1251
|
+
private _cancelServerItem(serverId: string): void {
|
|
1252
|
+
var id = this.host.getIdentity();
|
|
1253
|
+
if (!serverId || (id.platform !== 'claude' && id.platform !== 'openai')) return;
|
|
1254
|
+
var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1255
|
+
Promise.resolve(this.host.cancelRequest({
|
|
1256
|
+
url: url, method: 'POST', id: serverId,
|
|
1257
|
+
queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
|
|
1258
|
+
service: id.serviceId, owner: id.owner,
|
|
1259
|
+
})).catch(function () { /* the pass may already have finished; nothing to do */ });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
778
1262
|
// Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
|
|
779
1263
|
drainBgTaskQueue(): void {
|
|
780
1264
|
var self = this;
|
|
781
1265
|
var id = this.host.getIdentity();
|
|
782
1266
|
var svcId = id.serviceId, plat = id.platform;
|
|
783
1267
|
if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
|
|
1268
|
+
// Before anything is surfaced: drop continuations of files the user stopped
|
|
1269
|
+
// (and let a fresh first pass lift the stop), then cancel any worker-queued
|
|
1270
|
+
// pass of a stopped file that reached the chat through history.
|
|
1271
|
+
this._applyIndexCancellations();
|
|
1272
|
+
this._sweepCancelledIndexing();
|
|
784
1273
|
// Index messages by _serverItemId ONCE, so the per-entry presence/pending
|
|
785
1274
|
// checks below are O(1) instead of a full messages.some() scan each. With a
|
|
786
1275
|
// large bg queue (bulk uploads) the old nested scan was O(queue x messages)
|
|
@@ -800,33 +1289,77 @@ export class ChatSession {
|
|
|
800
1289
|
}
|
|
801
1290
|
this.bgTaskQueue.forEach(function (entry) {
|
|
802
1291
|
if (entry.serviceId !== svcId || entry.platform !== plat) return;
|
|
803
|
-
|
|
1292
|
+
// Bubble injection and poll attachment are INDEPENDENT. An entry whose bubble
|
|
1293
|
+
// already exists may still need a poll — that is exactly the state a paused
|
|
1294
|
+
// drain leaves behind, and returning early here stranded it as a permanent
|
|
1295
|
+
// "Thinking..." once polling resumed.
|
|
1296
|
+
if (!presentIds[entry.id]) {
|
|
804
1297
|
var isRunning = entry.status === 'running';
|
|
805
|
-
var userBubble: ChatMessage = {
|
|
1298
|
+
var userBubble: ChatMessage = {
|
|
1299
|
+
role: 'user',
|
|
1300
|
+
content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
|
|
1301
|
+
isBackgroundTask: true,
|
|
1302
|
+
_serverItemId: entry.id,
|
|
1303
|
+
// Structured ref so this live pass groups with the same file's passes
|
|
1304
|
+
// rebuilt from history (see indexing_groups.buildChatDisplayList).
|
|
1305
|
+
_indexFile: {
|
|
1306
|
+
name: entry.filename,
|
|
1307
|
+
path: entry.storagePath,
|
|
1308
|
+
mime: entry.mime,
|
|
1309
|
+
size: entry.size,
|
|
1310
|
+
isReindex: !!entry.isReindex,
|
|
1311
|
+
continued: !!entry.resumePass,
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
806
1314
|
if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
|
|
807
1315
|
self.state.messages.push(userBubble);
|
|
808
1316
|
if (isRunning) {
|
|
809
1317
|
self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
|
|
810
1318
|
}
|
|
811
1319
|
presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
|
|
812
|
-
self.host.notify(); self.updateHistoryCache(); self.host.
|
|
813
|
-
|
|
814
|
-
|
|
1320
|
+
self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottomIfSticky(false);
|
|
1321
|
+
}
|
|
1322
|
+
if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
|
|
815
1323
|
var capturedId = entry.id, capturedPlat = plat;
|
|
816
1324
|
var capturedEntry = entry;
|
|
817
|
-
|
|
1325
|
+
var wasStopped = false;
|
|
1326
|
+
var bp = entry.poll({ latency: POLL_INTERVAL });
|
|
1327
|
+
self._trackPoll(entry.id, 'bg', bp);
|
|
1328
|
+
bp.then(function (response: any) {
|
|
1329
|
+
// A stopped poll is not a result: leave the bubble and the queue entry
|
|
1330
|
+
// exactly as they were so resumePolling can re-attach.
|
|
1331
|
+
if (isPollStopped(response)) { wasStopped = true; return; }
|
|
818
1332
|
self.handleHistoryItemResolution(capturedId, response, capturedPlat);
|
|
819
1333
|
self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
|
|
820
1334
|
}).catch(function (err: any) {
|
|
821
1335
|
self.historyItemPolls.delete(capturedId);
|
|
822
1336
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
1337
|
+
// Settle the REQUEST bubble first, unconditionally. Only a RUNNING
|
|
1338
|
+
// pass gets an assistant placeholder, so for a queued one the lookup
|
|
1339
|
+
// below finds nothing and every branch was skipped — leaving the
|
|
1340
|
+
// request bubble isPendingQueued with its queue entry about to be
|
|
1341
|
+
// spliced away, i.e. no poll left to ever clear it. One such bubble
|
|
1342
|
+
// pins the whole collapsed row to "active": a spinner and a Stop
|
|
1343
|
+
// button against a dead id, until the page is reloaded.
|
|
1344
|
+
self._clearPendingUserBubble(capturedId);
|
|
823
1345
|
var bi = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
|
|
824
1346
|
if (bi !== -1) {
|
|
825
1347
|
if (isNotExists) self.state.messages.splice(bi, 1);
|
|
826
1348
|
else self.state.messages[bi] = { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
|
|
827
|
-
|
|
1349
|
+
} else if (!isNotExists) {
|
|
1350
|
+
// No placeholder to turn into the error (a queued pass). Put one
|
|
1351
|
+
// after the request bubble so the row reports the failure instead
|
|
1352
|
+
// of quietly settling to "Indexed".
|
|
1353
|
+
var ui = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId; });
|
|
1354
|
+
if (ui !== -1) {
|
|
1355
|
+
self.state.messages.splice(ui + 1, 0, { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId });
|
|
1356
|
+
}
|
|
828
1357
|
}
|
|
1358
|
+
self.host.notify(); self.updateHistoryCache();
|
|
829
1359
|
}).then(function () {
|
|
1360
|
+
// Keep the queue entry when the poll was merely stopped, or resuming
|
|
1361
|
+
// would have nothing left to re-attach to.
|
|
1362
|
+
if (wasStopped) return;
|
|
830
1363
|
var qi = self.bgTaskQueue.findIndex(function (q) { return q.id === capturedId; });
|
|
831
1364
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
832
1365
|
});
|
|
@@ -836,30 +1369,40 @@ export class ChatSession {
|
|
|
836
1369
|
}
|
|
837
1370
|
|
|
838
1371
|
// Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
|
|
839
|
-
//
|
|
1372
|
+
// text) finished WITHOUT the completion marker, the agent ran out of room before reading
|
|
840
1373
|
// the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
|
|
841
1374
|
// already-saved records leave off. Additive + guarded so it never loops forever and
|
|
842
1375
|
// never breaks the resolution path.
|
|
1376
|
+
//
|
|
1377
|
+
// VISION files (PDFs rendered to page images) are NOT resumed here: the proxy worker
|
|
1378
|
+
// advances their page window itself, off the renderer's true page count. Driving them
|
|
1379
|
+
// from the browser is what used to lose pages on long documents - the chain lived in tab
|
|
1380
|
+
// memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
|
|
1381
|
+
// completion, which on an 88-page file happened at page 15. Continuing to dispatch here
|
|
1382
|
+
// as well would now double-index every window.
|
|
843
1383
|
maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
|
|
844
1384
|
var self = this;
|
|
845
1385
|
try {
|
|
846
1386
|
if (!entry || !entry.storagePath) return;
|
|
1387
|
+
// The user stopped this file from its collapsed row. Dispatching the next
|
|
1388
|
+
// pass here is exactly what "stop" has to prevent — the cancelled pass
|
|
1389
|
+
// settles, and without this the chain simply carries on.
|
|
1390
|
+
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
847
1391
|
if (!isPagedReadFile(entry.filename, entry.mime)) return;
|
|
1392
|
+
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
|
|
1393
|
+
// When windowed indexing is on, the WORKER drives the text/grid loop too. The
|
|
1394
|
+
// client MUST NOT also resume, or two drivers each enqueue a continuation per
|
|
1395
|
+
// pass and the chain FORKS - duplicate records, runaway passes. Same reason
|
|
1396
|
+
// PDFs early-return above. Gated on the flag so the old client-driven path is
|
|
1397
|
+
// untouched when windowing is off.
|
|
1398
|
+
if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
|
|
848
1399
|
if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
|
|
849
1400
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
850
1401
|
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
|
|
851
1402
|
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
|
|
1403
|
+
if (pass > MAX_INDEXING_RESUME_PASSES) return; // give up after the cap
|
|
857
1404
|
var id = this.host.getIdentity();
|
|
858
1405
|
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
1406
|
notifyAgentContinueIndexing({
|
|
864
1407
|
platform: id.platform as 'claude' | 'openai',
|
|
865
1408
|
model: id.model,
|
|
@@ -868,7 +1411,6 @@ export class ChatSession {
|
|
|
868
1411
|
userId: id.userId || id.serviceId,
|
|
869
1412
|
serviceName: id.serviceName,
|
|
870
1413
|
serviceDescription: id.serviceDescription,
|
|
871
|
-
renderFrom: renderFrom,
|
|
872
1414
|
attachment: {
|
|
873
1415
|
name: entry.filename,
|
|
874
1416
|
storagePath: entry.storagePath,
|
|
@@ -900,6 +1442,11 @@ export class ChatSession {
|
|
|
900
1442
|
loadHistory(fetchMore?: boolean, token?: number): Promise<void> {
|
|
901
1443
|
var self = this;
|
|
902
1444
|
var id = this.host.getIdentity();
|
|
1445
|
+
// Key this fetch is FOR, snapshotted from the same identity read the
|
|
1446
|
+
// request is built from. The rescue below compares against this rather
|
|
1447
|
+
// than a live getHistoryCacheKey(), so a project switch mid-fetch can't
|
|
1448
|
+
// make another chat's in-flight bubbles look local.
|
|
1449
|
+
var loadKey = (!id.serviceId || id.platform === 'none') ? '' : id.serviceId + '#' + id.platform;
|
|
903
1450
|
if (token === undefined) token = this.state.gateRefreshToken;
|
|
904
1451
|
if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.serviceId) {
|
|
905
1452
|
return Promise.resolve();
|
|
@@ -922,9 +1469,9 @@ export class ChatSession {
|
|
|
922
1469
|
if (token !== self.state.gateRefreshToken) return;
|
|
923
1470
|
var chatList = history && Array.isArray(history.list) ? history.list : [];
|
|
924
1471
|
chatList.forEach(function (item: any) {
|
|
925
|
-
if (
|
|
1472
|
+
if (isBgIndexingQueue(item.queue_name)) {
|
|
926
1473
|
var userText = extractLastUserTextFromRequest(item.request_body);
|
|
927
|
-
if (typeof userText === 'string' && userText.indexOf('A new file has just been uploaded') === 0) item._isBgTask = true;
|
|
1474
|
+
if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
|
|
928
1475
|
else item._isOnBgQueue = true;
|
|
929
1476
|
}
|
|
930
1477
|
});
|
|
@@ -938,6 +1485,10 @@ export class ChatSession {
|
|
|
938
1485
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
939
1486
|
}).messages;
|
|
940
1487
|
|
|
1488
|
+
// Set when a first-page refresh re-prepended already-loaded older pages,
|
|
1489
|
+
// so the cursor reset below knows not to rewind to page 1's boundary.
|
|
1490
|
+
var keptOlderPages = false;
|
|
1491
|
+
|
|
941
1492
|
if (fetchMore) {
|
|
942
1493
|
self.state.messages = mapped.concat(self.state.messages);
|
|
943
1494
|
} else {
|
|
@@ -945,7 +1496,17 @@ export class ChatSession {
|
|
|
945
1496
|
var serverIds: any = {};
|
|
946
1497
|
mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
|
|
947
1498
|
var locallyCancelled: any = {};
|
|
948
|
-
self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] =
|
|
1499
|
+
self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m; });
|
|
1500
|
+
// A cancel the server has not acknowledged yet. Without carrying these
|
|
1501
|
+
// across the replace, a collapsed row mid-cancel drops its "Stopping..."
|
|
1502
|
+
// state and re-offers Stop; the second click cancels an id that is
|
|
1503
|
+
// already gone and writes a bogus "Could not remove from queue." onto a
|
|
1504
|
+
// run that WAS stopped successfully.
|
|
1505
|
+
var inFlightCancel: any = {};
|
|
1506
|
+
self.state.messages.forEach(function (m) {
|
|
1507
|
+
if (!m._serverItemId) return;
|
|
1508
|
+
if (m._cancelling || m._cancelError) inFlightCancel[m._serverItemId] = m;
|
|
1509
|
+
});
|
|
949
1510
|
// If the freshly-mapped server list ALREADY shows this in-flight turn
|
|
950
1511
|
// as a pending placeholder (a non-bg pending assistant, which carries
|
|
951
1512
|
// a real _serverItemId), re-pushing the local no-_serverItemId
|
|
@@ -960,6 +1521,10 @@ export class ChatSession {
|
|
|
960
1521
|
for (var ri = 0; ri < self.state.messages.length; ri++) {
|
|
961
1522
|
var mm = self.state.messages[ri];
|
|
962
1523
|
if (mm.isBackgroundTask) continue;
|
|
1524
|
+
// Belongs to a different chat (another project, or another
|
|
1525
|
+
// platform on this one) — it must not be carried onto THIS
|
|
1526
|
+
// chat's freshly-fetched history.
|
|
1527
|
+
if (mm._ownerKey !== undefined && mm._ownerKey !== loadKey) continue;
|
|
963
1528
|
if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
|
|
964
1529
|
if (!mm._serverItemId) {
|
|
965
1530
|
if (mappedHasPendingAssistant) continue;
|
|
@@ -970,25 +1535,83 @@ export class ChatSession {
|
|
|
970
1535
|
}
|
|
971
1536
|
}
|
|
972
1537
|
}
|
|
973
|
-
|
|
1538
|
+
// PRESERVE already-loaded older pages. `mapped` is only page 1, so a
|
|
1539
|
+
// blind replace threw away every page the user had scrolled in. This
|
|
1540
|
+
// path runs from resumePolling (visibilitychange), so merely leaving
|
|
1541
|
+
// the tab and coming back wiped the whole scrolled-in history.
|
|
1542
|
+
//
|
|
1543
|
+
// The list is oldest-first (fetchMore PREPENDS), and ids sort with
|
|
1544
|
+
// newest largest, so anything already in the list whose _serverItemId
|
|
1545
|
+
// sorts BELOW page 1's oldest id is an older page. Those are disjoint
|
|
1546
|
+
// from `mapped` (strict <), so re-prepending cannot duplicate.
|
|
1547
|
+
var oldestInPage1: string | undefined = undefined;
|
|
1548
|
+
mapped.forEach(function (m: any) {
|
|
1549
|
+
var sid = m._serverItemId;
|
|
1550
|
+
if (typeof sid !== 'string') return;
|
|
1551
|
+
if (oldestInPage1 === undefined || sid < (oldestInPage1 as string)) oldestInPage1 = sid;
|
|
1552
|
+
});
|
|
1553
|
+
// Only merge when the current list is genuinely a continuation of THIS
|
|
1554
|
+
// page 1 (shares at least one id with it). A project switch, a cleared
|
|
1555
|
+
// horizon, or a wholly-new page means the head is unrelated history and
|
|
1556
|
+
// must be replaced, not spliced.
|
|
1557
|
+
var sharesPage1 = self.state.messages.some(function (m) {
|
|
1558
|
+
return typeof m._serverItemId === 'string' && !!serverIds[m._serverItemId as string];
|
|
1559
|
+
});
|
|
1560
|
+
var retainedOlder: ChatMessage[] = (!sharesPage1 || oldestInPage1 === undefined) ? [] : self.state.messages.filter(function (m) {
|
|
1561
|
+
// Settled server history only; in-flight bubbles are the rescue
|
|
1562
|
+
// loop's job below and would otherwise be kept twice.
|
|
1563
|
+
if (typeof m._serverItemId !== 'string') return false;
|
|
1564
|
+
// Same ownership predicate as the rescue loop: never carry another
|
|
1565
|
+
// project's bubbles onto this chat (the cross-project leak fix).
|
|
1566
|
+
// Compare against the snapshotted loadKey, never a live read.
|
|
1567
|
+
if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return false;
|
|
1568
|
+
return (m._serverItemId as string) < (oldestInPage1 as string);
|
|
1569
|
+
});
|
|
1570
|
+
keptOlderPages = retainedOlder.length > 0;
|
|
1571
|
+
self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
|
|
974
1572
|
rescued.forEach(function (m) { self.state.messages.push(m); });
|
|
975
1573
|
if (Object.keys(locallyCancelled).length) {
|
|
976
1574
|
for (var ci = 0; ci < self.state.messages.length; ci++) {
|
|
977
1575
|
var c = self.state.messages[ci];
|
|
978
1576
|
if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
|
|
979
|
-
|
|
1577
|
+
// Rebuild FROM the mapped bubble rather than as a bare literal:
|
|
1578
|
+
// dropping isBackgroundTask / _indexFile here ejected a stopped
|
|
1579
|
+
// indexing pass from its file's collapsed row, where it then rendered
|
|
1580
|
+
// as a standalone "Indexing: <file>" chat bubble and left the row
|
|
1581
|
+
// recomputing its passes as if that one had never happened.
|
|
1582
|
+
self.state.messages[ci] = {
|
|
1583
|
+
role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId,
|
|
1584
|
+
isBackgroundTask: c.isBackgroundTask, _indexFile: c._indexFile,
|
|
1585
|
+
_useBgQueue: c._useBgQueue, _ownerKey: c._ownerKey,
|
|
1586
|
+
};
|
|
980
1587
|
if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
|
|
981
1588
|
self.state.messages.splice(ci + 1, 1);
|
|
982
1589
|
}
|
|
983
1590
|
}
|
|
984
1591
|
}
|
|
1592
|
+
// Re-stamp a cancel that is still in flight (see inFlightCancel above).
|
|
1593
|
+
// Only onto a bubble the server still reports as pending — once it has
|
|
1594
|
+
// settled the flags describe nothing the user can act on.
|
|
1595
|
+
for (var fi = 0; fi < self.state.messages.length; fi++) {
|
|
1596
|
+
var fm = self.state.messages[fi];
|
|
1597
|
+
var was = fm._serverItemId && inFlightCancel[fm._serverItemId];
|
|
1598
|
+
if (!was || fm.isCancelled) continue;
|
|
1599
|
+
if (!(fm.isPendingQueued || fm.isPendingInProcess || fm.isPending)) continue;
|
|
1600
|
+
if (was._cancelling) fm._cancelling = true;
|
|
1601
|
+
if (was._cancelError) fm._cancelError = was._cancelError;
|
|
1602
|
+
}
|
|
985
1603
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
if (
|
|
990
|
-
|
|
991
|
-
|
|
1604
|
+
// Only adopt page 1's cursor when page 1 IS the whole list. If older pages
|
|
1605
|
+
// survived the merge, rewinding here would make the next scroll-up
|
|
1606
|
+
// re-fetch page 2 (already on screen) and duplicate it.
|
|
1607
|
+
if (!keptOlderPages) {
|
|
1608
|
+
self.state.historyEndOfList = !!(history && history.endOfList);
|
|
1609
|
+
self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
|
|
1610
|
+
var clearedAt = self.host.getClearedAt();
|
|
1611
|
+
if (clearedAt && chatList.length > 0) {
|
|
1612
|
+
var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
|
|
1613
|
+
if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
|
|
1614
|
+
}
|
|
992
1615
|
}
|
|
993
1616
|
// Clear loading flags BEFORE this render so the final paint is
|
|
994
1617
|
// indicator-free (and the view's scroll-restore math sees matching heights).
|
|
@@ -1022,22 +1645,42 @@ export class ChatSession {
|
|
|
1022
1645
|
// dispatch never polls, so they MUST still get their poll — skipping
|
|
1023
1646
|
// them was the stranded-"Thinking" bug this guard must not revive.
|
|
1024
1647
|
if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
|
|
1025
|
-
|
|
1648
|
+
// Background indexing polls are suppressed while paused; foreground
|
|
1649
|
+
// replies the user is waiting on are not.
|
|
1650
|
+
if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
|
|
1026
1651
|
var capturedId = item.id;
|
|
1027
1652
|
var pp = item.poll({
|
|
1028
1653
|
latency: POLL_INTERVAL,
|
|
1029
|
-
onResponse: function (response: any) { self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
1654
|
+
onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
1030
1655
|
onError: function (err: any) {
|
|
1031
1656
|
self.historyItemPolls.delete(capturedId);
|
|
1032
1657
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
1033
1658
|
var aIdx = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
|
|
1034
1659
|
if (isNotExists) {
|
|
1035
|
-
var
|
|
1660
|
+
var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
|
|
1661
|
+
// Decide bg-ness from whichever bubble actually exists. A
|
|
1662
|
+
// QUEUED pass has no assistant placeholder (history.ts emits
|
|
1663
|
+
// none), so reading it off the placeholder alone classified
|
|
1664
|
+
// every queued indexing pass as foreground: the rebuild below
|
|
1665
|
+
// then dropped isBackgroundTask/_indexFile, ejecting the pass
|
|
1666
|
+
// from its collapsed row as a raw "Indexing: <file>" chat
|
|
1667
|
+
// bubble, and promoteNextQueuedToRunning span up a "Thinking..."
|
|
1668
|
+
// on an unrelated queued FOREGROUND message.
|
|
1669
|
+
var isBg = (aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask)
|
|
1670
|
+
|| (uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask);
|
|
1036
1671
|
if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
|
|
1037
1672
|
if (!isBg) {
|
|
1038
|
-
var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
|
|
1039
1673
|
if (uIdx !== -1) { var ex = self.state.messages[uIdx]; self.state.messages[uIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId }; }
|
|
1040
1674
|
self.cancelledServerIds.delete(capturedId); self.promoteNextQueuedToRunning();
|
|
1675
|
+
} else if (uIdx !== -1) {
|
|
1676
|
+
// Same carry-over every other rebuild does, so the pass stays
|
|
1677
|
+
// a member of its file's row while reading as cancelled.
|
|
1678
|
+
var bex = self.state.messages[uIdx];
|
|
1679
|
+
var bcancelled: ChatMessage = { role: 'user', content: bex.content, isCancelled: true, isBackgroundTask: true, _serverItemId: bex._serverItemId };
|
|
1680
|
+
if (bex._indexFile) bcancelled._indexFile = bex._indexFile;
|
|
1681
|
+
if (bex._useBgQueue) bcancelled._useBgQueue = true;
|
|
1682
|
+
self.state.messages[uIdx] = bcancelled;
|
|
1683
|
+
self.promoteNextBgQueuedToRunning();
|
|
1041
1684
|
}
|
|
1042
1685
|
self.host.notify(); self.updateHistoryCache(); return;
|
|
1043
1686
|
}
|
|
@@ -1049,12 +1692,22 @@ export class ChatSession {
|
|
|
1049
1692
|
}
|
|
1050
1693
|
},
|
|
1051
1694
|
});
|
|
1695
|
+
// Anything on the BACKGROUND queue is pausable, not just items whose
|
|
1696
|
+
// prompt text we recognise as an indexing task. _isBgTask vs
|
|
1697
|
+
// _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
|
|
1698
|
+
// chat bubble); keying the pause off _isBgTask alone left every
|
|
1699
|
+
// bg-queue item we could not text-match polling forever.
|
|
1700
|
+
self._trackPoll(capturedId, (item._isBgTask || item._isOnBgQueue) ? 'bg' : 'fg', pp);
|
|
1052
1701
|
if (pp && pp.catch) pp.catch(function () { });
|
|
1053
1702
|
});
|
|
1054
1703
|
self.drainBgTaskQueue();
|
|
1055
1704
|
}
|
|
1056
1705
|
|
|
1057
|
-
|
|
1706
|
+
// Sticky, NOT forcing: this runs after every first-page load, including
|
|
1707
|
+
// the one resumePolling fires on visibilitychange. Forcing yanked a
|
|
1708
|
+
// reader who had scrolled up back to the bottom. On a genuine mount the
|
|
1709
|
+
// user is still pinned, so this behaves identically there.
|
|
1710
|
+
if (!fetchMore) return self.host.scrollToBottomIfSticky();
|
|
1058
1711
|
}).catch(function (err: any) {
|
|
1059
1712
|
console.warn('[chat-engine] getChatHistory failed', err);
|
|
1060
1713
|
}).then(function () {
|
|
@@ -1063,6 +1716,12 @@ export class ChatSession {
|
|
|
1063
1716
|
self.state.loadingHistory = false; self.state.loadingOlderHistory = false;
|
|
1064
1717
|
if (wasLoading) self.host.notify();
|
|
1065
1718
|
}
|
|
1719
|
+
// Every first-page load ends here — mount, resumePolling, a cache-restore
|
|
1720
|
+
// refresh — and every one of them can leave a box too short to scroll,
|
|
1721
|
+
// which is the only trigger there is for reaching page 2. The view (which
|
|
1722
|
+
// alone can measure) pages its way out of it. Fired after the flags are
|
|
1723
|
+
// cleared so the fill loop does not see a request still in flight.
|
|
1724
|
+
if (self.host.onHistoryLoaded) self.host.onHistoryLoaded(!!fetchMore, token as number);
|
|
1066
1725
|
});
|
|
1067
1726
|
}
|
|
1068
1727
|
|