bunnyquery 1.6.2 → 1.8.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 +134 -0
- package/bunnyquery.js +1057 -120
- package/dist/engine.cjs +784 -42
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +455 -4
- package/dist/engine.d.ts +455 -4
- package/dist/engine.mjs +769 -43
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +30 -2
- package/src/engine/host.ts +34 -1
- package/src/engine/index.ts +25 -1
- package/src/engine/indexing_groups.ts +389 -0
- package/src/engine/links.ts +343 -9
- package/src/engine/prompts/chat_system_prompt.ts +4 -1
- package/src/engine/prompts/indexing_user_message.ts +4 -0
- package/src/engine/requests.ts +1 -1
- package/src/engine/session.ts +412 -33
- package/src/engine/time.ts +34 -0
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +134 -0
package/src/engine/session.ts
CHANGED
|
@@ -34,13 +34,16 @@ import {
|
|
|
34
34
|
OPENAI_RESPONSES_API_URL,
|
|
35
35
|
type BgTaskEntry,
|
|
36
36
|
} from './requests';
|
|
37
|
-
import { isPagedReadFile, isImageVisionFile } from './office';
|
|
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';
|
|
43
|
+
import { wallClockNow } from './time';
|
|
42
44
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
43
45
|
import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
|
|
46
|
+
import type { IndexingGroup } from './indexing_groups';
|
|
44
47
|
|
|
45
48
|
function sleep(ms: number): Promise<void> {
|
|
46
49
|
return new Promise(function (r) { setTimeout(r, ms); });
|
|
@@ -79,6 +82,13 @@ export class ChatSession {
|
|
|
79
82
|
state: ChatState;
|
|
80
83
|
bgTaskQueue: BgTaskEntry[];
|
|
81
84
|
cancelledServerIds: Set<string>;
|
|
85
|
+
/** Files whose indexing the user stopped, keyed exactly as buildChatDisplayList
|
|
86
|
+
* keys a group (storage path, else filename). Cancelling one pass is not
|
|
87
|
+
* enough on its own: the client dispatches CONTINUE passes for paged files, so
|
|
88
|
+
* without this the next pass is enqueued the moment the cancelled one settles.
|
|
89
|
+
* Cleared when a FIRST pass for the same file is queued again (a re-upload or a
|
|
90
|
+
* Reindex from the file manager), so stopping a file never poisons it. */
|
|
91
|
+
cancelledIndexKeys: Set<string>;
|
|
82
92
|
pendingAgentRequests: Record<string, Promise<any>>;
|
|
83
93
|
aiChatHistoryCache: Record<string, { messages: ChatMessage[]; endOfList: boolean; startKeyHistory: string[] }>;
|
|
84
94
|
historyItemPolls: Map<string, PollHandle>;
|
|
@@ -106,6 +116,7 @@ export class ChatSession {
|
|
|
106
116
|
};
|
|
107
117
|
this.bgTaskQueue = [];
|
|
108
118
|
this.cancelledServerIds = new Set();
|
|
119
|
+
this.cancelledIndexKeys = new Set();
|
|
109
120
|
this.pendingAgentRequests = {};
|
|
110
121
|
this.aiChatHistoryCache = {};
|
|
111
122
|
this.historyItemPolls = new Map();
|
|
@@ -132,6 +143,21 @@ export class ChatSession {
|
|
|
132
143
|
return p;
|
|
133
144
|
}
|
|
134
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Stop and forget one item's poll. Used after a cancel: the row is either gone
|
|
148
|
+
* (cancelled while queued) or flagged cancelled (cancelled while running), so
|
|
149
|
+
* asking about it again only burns requests. Safe when no poll is attached, and
|
|
150
|
+
* safe on an older skapi-js with no stop handle (the entry is then LEFT in the
|
|
151
|
+
* map so a later drain cannot stack a second, unstoppable poll on the id).
|
|
152
|
+
*/
|
|
153
|
+
private _stopPoll(id: string): void {
|
|
154
|
+
var handle = this.historyItemPolls.get(id);
|
|
155
|
+
if (!handle) return;
|
|
156
|
+
if (typeof handle.stop !== 'function') return;
|
|
157
|
+
try { handle.stop(); } catch (e) { /* best effort */ }
|
|
158
|
+
this.historyItemPolls.delete(id);
|
|
159
|
+
}
|
|
160
|
+
|
|
135
161
|
/** True while any pause reason is active. */
|
|
136
162
|
isPollingPaused(): boolean {
|
|
137
163
|
return this._pauseReasons.size > 0;
|
|
@@ -249,7 +275,21 @@ export class ChatSession {
|
|
|
249
275
|
if (reply._serverItemId === undefined && msgs[thIdx]._serverItemId !== undefined) reply._serverItemId = msgs[thIdx]._serverItemId;
|
|
250
276
|
msgs[thIdx] = reply;
|
|
251
277
|
} else {
|
|
252
|
-
|
|
278
|
+
// No pending placeholder left to replace. Before appending, check whether
|
|
279
|
+
// this turn is ALREADY in the cache — a history fetch may have mapped the
|
|
280
|
+
// resolved turn in while the reply was in flight. Appending blindly is how
|
|
281
|
+
// one turn ended up rendered twice (the duplicated question+answer): the
|
|
282
|
+
// cache is restored verbatim on the next mount, so a duplicate written
|
|
283
|
+
// here survives every later visit.
|
|
284
|
+
var dupIdx = -1;
|
|
285
|
+
if (serverId) {
|
|
286
|
+
for (var d = msgs.length - 1; d >= 0; d--) {
|
|
287
|
+
var dm = msgs[d];
|
|
288
|
+
if (dm && dm.role === 'assistant' && dm._serverItemId === serverId) { dupIdx = d; break; }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (dupIdx !== -1) msgs[dupIdx] = reply;
|
|
292
|
+
else msgs.push(reply);
|
|
253
293
|
}
|
|
254
294
|
|
|
255
295
|
// Settle the user bubble this turn belongs to (first pending one, or the
|
|
@@ -438,7 +478,7 @@ export class ChatSession {
|
|
|
438
478
|
var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
439
479
|
this.aiChatHistoryCache[key] = {
|
|
440
480
|
messages: offExisting.messages.concat([
|
|
441
|
-
{ role: 'user', content: composed, _ownerKey: key },
|
|
481
|
+
{ role: 'user', content: composed, _ownerKey: key, _ts: wallClockNow() },
|
|
442
482
|
{ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _ownerKey: key },
|
|
443
483
|
]),
|
|
444
484
|
endOfList: offExisting.endOfList,
|
|
@@ -461,7 +501,7 @@ export class ChatSession {
|
|
|
461
501
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
462
502
|
history: resolvedHistory.concat([{ role: 'user', content: llmComposed }]),
|
|
463
503
|
});
|
|
464
|
-
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true };
|
|
504
|
+
var queuedBubble: ChatMessage = { role: 'user', content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
|
|
465
505
|
if (key) queuedBubble._ownerKey = key;
|
|
466
506
|
if (useBgQueue) queuedBubble._useBgQueue = true;
|
|
467
507
|
this.state.messages.push(queuedBubble);
|
|
@@ -503,7 +543,7 @@ export class ChatSession {
|
|
|
503
543
|
// view unmount), then rendered from the cache via typewriteLatestReply. A
|
|
504
544
|
// later resumePendingRequest() re-renders it if the view remounted while the
|
|
505
545
|
// request was still in flight.
|
|
506
|
-
this.state.messages.push({ role: 'user', content: composed, ...(key ? { _ownerKey: key } : {}) });
|
|
546
|
+
this.state.messages.push({ role: 'user', content: composed, _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) });
|
|
507
547
|
this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
|
|
508
548
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
509
549
|
|
|
@@ -546,7 +586,7 @@ export class ChatSession {
|
|
|
546
586
|
// under `key`, so a later loadChatHistory renders it.
|
|
547
587
|
self.state.sending = false;
|
|
548
588
|
if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
|
|
549
|
-
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.
|
|
589
|
+
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
|
|
550
590
|
});
|
|
551
591
|
}
|
|
552
592
|
|
|
@@ -558,6 +598,8 @@ export class ChatSession {
|
|
|
558
598
|
if (nextIdx === -1) return;
|
|
559
599
|
var existing = this.state.messages[nextIdx];
|
|
560
600
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
|
|
601
|
+
if (existing._indexFile) promoted._indexFile = existing._indexFile;
|
|
602
|
+
if (existing._ts !== undefined) promoted._ts = existing._ts;
|
|
561
603
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
562
604
|
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
563
605
|
this.state.messages[nextIdx] = promoted;
|
|
@@ -577,6 +619,8 @@ export class ChatSession {
|
|
|
577
619
|
var existing = this.state.messages[nextIdx];
|
|
578
620
|
var promoted: ChatMessage = { role: 'user', content: existing.content, isPendingInProcess: true };
|
|
579
621
|
if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
|
|
622
|
+
if (existing._indexFile) promoted._indexFile = existing._indexFile;
|
|
623
|
+
if (existing._ts !== undefined) promoted._ts = existing._ts;
|
|
580
624
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
581
625
|
if (existing._ownerKey !== undefined) promoted._ownerKey = existing._ownerKey;
|
|
582
626
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
@@ -636,6 +680,7 @@ export class ChatSession {
|
|
|
636
680
|
var repl: ChatMessage = { role: 'user', content: exist.content };
|
|
637
681
|
if (exist._serverItemId !== undefined) repl._serverItemId = exist._serverItemId;
|
|
638
682
|
if (exist._ownerKey !== undefined) repl._ownerKey = exist._ownerKey;
|
|
683
|
+
if (exist._ts !== undefined) repl._ts = exist._ts;
|
|
639
684
|
this.state.messages[userIdx] = repl;
|
|
640
685
|
}
|
|
641
686
|
var thinkingIdx = userIdx >= 0
|
|
@@ -645,6 +690,9 @@ export class ChatSession {
|
|
|
645
690
|
}
|
|
646
691
|
|
|
647
692
|
insertAtTarget(msg: ChatMessage, targetIdx: number): void {
|
|
693
|
+
// Error/direct replies land here rather than through the typewriter, so this
|
|
694
|
+
// is where they pick up their display time.
|
|
695
|
+
if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
|
|
648
696
|
if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
|
|
649
697
|
else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
|
|
650
698
|
else this.state.messages.push(msg);
|
|
@@ -689,7 +737,9 @@ export class ChatSession {
|
|
|
689
737
|
this.promoteNextQueuedToRunning();
|
|
690
738
|
this.updateHistoryCache();
|
|
691
739
|
this.host.notify();
|
|
692
|
-
|
|
740
|
+
// ARRIVAL, not a user action: only follow if the user is still pinned to
|
|
741
|
+
// the bottom. Scrolled-up readers keep their position.
|
|
742
|
+
this.host.scrollToBottomIfSticky(true);
|
|
693
743
|
}
|
|
694
744
|
|
|
695
745
|
onQueuedSendError(_composed: string, err: any, serverId?: string, ownerKey?: string): void {
|
|
@@ -732,14 +782,14 @@ export class ChatSession {
|
|
|
732
782
|
}
|
|
733
783
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
734
784
|
this._removeStrayPendingAssistants();
|
|
735
|
-
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.
|
|
785
|
+
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
|
|
736
786
|
return;
|
|
737
787
|
}
|
|
738
788
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
739
789
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
740
790
|
this.insertAtTarget({ role: 'assistant', content: getErrorMessage(err), isError: true }, targetIdx);
|
|
741
791
|
this._removeStrayPendingAssistants();
|
|
742
|
-
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.
|
|
792
|
+
this.promoteNextQueuedToRunning(); this.updateHistoryCache(); this.host.notify(); this.host.scrollToBottomIfSticky(true);
|
|
743
793
|
}
|
|
744
794
|
|
|
745
795
|
cancelQueuedMessage(msg: ChatMessage, idx: number): void {
|
|
@@ -759,13 +809,25 @@ export class ChatSession {
|
|
|
759
809
|
})).then(function (result: any) {
|
|
760
810
|
if (result && result.removed) {
|
|
761
811
|
self.cancelledServerIds.add(serverId as string);
|
|
812
|
+
self._stopPoll(serverId as string);
|
|
762
813
|
var qi = self.bgTaskQueue.findIndex(function (e) { return e.id === serverId; });
|
|
763
814
|
if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
|
|
764
815
|
var removeIdx = self.state.messages.findIndex(function (m) {
|
|
765
816
|
return m._serverItemId === serverId && (m.isPendingQueued || m.isPendingInProcess) && m.role === 'user';
|
|
766
817
|
});
|
|
767
818
|
if (removeIdx !== -1) {
|
|
768
|
-
|
|
819
|
+
// Carry the background/file markers onto the rebuild. Dropping them
|
|
820
|
+
// took a cancelled INDEXING pass out of its file's collapsed row
|
|
821
|
+
// (buildChatDisplayList only groups isBackgroundTask bubbles), so the
|
|
822
|
+
// row stayed "Indexing…" forever while a bare "Indexing: file" bubble
|
|
823
|
+
// appeared beside it.
|
|
824
|
+
var wasMsg = self.state.messages[removeIdx];
|
|
825
|
+
var cancelledMsg: ChatMessage = { role: 'user', content: wasMsg.content, isCancelled: true, _serverItemId: serverId };
|
|
826
|
+
if (wasMsg.isBackgroundTask) cancelledMsg.isBackgroundTask = true;
|
|
827
|
+
if (wasMsg._indexFile) cancelledMsg._indexFile = wasMsg._indexFile;
|
|
828
|
+
if (wasMsg._useBgQueue) cancelledMsg._useBgQueue = true;
|
|
829
|
+
if (wasMsg._ownerKey !== undefined) cancelledMsg._ownerKey = wasMsg._ownerKey;
|
|
830
|
+
self.state.messages[removeIdx] = cancelledMsg;
|
|
769
831
|
var thById = self.state.messages.findIndex(function (m) { return m._serverItemId === serverId && m.isPending && m.role === 'assistant'; });
|
|
770
832
|
if (thById !== -1) self.state.messages.splice(thById, 1);
|
|
771
833
|
else {
|
|
@@ -791,6 +853,47 @@ export class ChatSession {
|
|
|
791
853
|
});
|
|
792
854
|
}
|
|
793
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Stop indexing a file, from its collapsed row — every pass at once, not just
|
|
858
|
+
* the bubble the user happens to see.
|
|
859
|
+
*
|
|
860
|
+
* A big file is indexed as a CHAIN of passes, so cancelling only the live one
|
|
861
|
+
* accomplishes nothing: the next pass is dispatched as soon as it settles.
|
|
862
|
+
* Three things end the chain:
|
|
863
|
+
* 1. every queued/running pass of this file is cancelled server-side
|
|
864
|
+
* (csr-cancel deletes a queued row and flags a running one "cancelled",
|
|
865
|
+
* which is also the worker's gate for NOT enqueueing the next window);
|
|
866
|
+
* 2. the file is remembered in cancelledIndexKeys, so the client-driven
|
|
867
|
+
* resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
|
|
868
|
+
* 3. any of its passes still sitting in bgTaskQueue is dropped by the next
|
|
869
|
+
* drain rather than surfacing a fresh "Indexing…" bubble.
|
|
870
|
+
*
|
|
871
|
+
* Records already written by the passes that DID run are kept — this stops the
|
|
872
|
+
* work, it does not undo it.
|
|
873
|
+
*/
|
|
874
|
+
cancelIndexingGroup(group: IndexingGroup): void {
|
|
875
|
+
var self = this;
|
|
876
|
+
if (!group || !group.key) return;
|
|
877
|
+
// The group belongs to the chat on screen, so scope its key the same way
|
|
878
|
+
// _indexKeyOf scopes a queued task's.
|
|
879
|
+
var scoped = this.getHistoryCacheKey() + '|' + group.key;
|
|
880
|
+
this.cancelledIndexKeys.add(scoped);
|
|
881
|
+
var ids = group.cancellableIds || [];
|
|
882
|
+
if (!ids.length) { this.host.notify(); return; }
|
|
883
|
+
ids.forEach(function (serverId) {
|
|
884
|
+
// Address the message by IDENTITY, not by the index captured when the
|
|
885
|
+
// display list was built: an earlier cancel in this same loop may already
|
|
886
|
+
// have spliced the array (a group can have more than one live pass), and a
|
|
887
|
+
// stale index would mark the WRONG bubble as cancelling.
|
|
888
|
+
var idx = self.state.messages.findIndex(function (m) {
|
|
889
|
+
return m._serverItemId === serverId && m.role === 'user' &&
|
|
890
|
+
(m.isPendingQueued || m.isPendingInProcess);
|
|
891
|
+
});
|
|
892
|
+
if (idx === -1) return;
|
|
893
|
+
self.cancelQueuedMessage(self.state.messages[idx], idx);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
794
897
|
// --- typewriter -------------------------------------------------------
|
|
795
898
|
// Reveal `fullText` into a message bubble at a constant wall-clock RATE
|
|
796
899
|
// (chars/second) driven by requestAnimationFrame, rather than a fixed number
|
|
@@ -904,6 +1007,12 @@ export class ChatSession {
|
|
|
904
1007
|
private typewriterQueue: Promise<any> = Promise.resolve();
|
|
905
1008
|
enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any> {
|
|
906
1009
|
var self = this;
|
|
1010
|
+
// Stamp the reply bubble's display time as it starts revealing. This is the
|
|
1011
|
+
// single chokepoint every typed reply (immediate, queued, and bg-resolution)
|
|
1012
|
+
// passes through, so it is where a live reply gets its "when it arrived"
|
|
1013
|
+
// timestamp; a history reload later replaces it with the server `updated`.
|
|
1014
|
+
var target = this.state.messages[idx];
|
|
1015
|
+
if (target && target._ts === undefined) target._ts = wallClockNow();
|
|
907
1016
|
this.typewriterQueue = this.typewriterQueue.then(function () { return self.typewriteIntoIndex(idx, fullText, localId); });
|
|
908
1017
|
return this.typewriterQueue;
|
|
909
1018
|
}
|
|
@@ -972,6 +1081,10 @@ export class ChatSession {
|
|
|
972
1081
|
var u = this.state.messages[uIdx];
|
|
973
1082
|
var cleaned: ChatMessage = { role: 'user', content: u.content, _serverItemId: itemId };
|
|
974
1083
|
if (u.isBackgroundTask) cleaned.isBackgroundTask = true;
|
|
1084
|
+
if (u._ts !== undefined) cleaned._ts = u._ts;
|
|
1085
|
+
// Carry the file ref: it is what keeps this pass in its file's collapsed
|
|
1086
|
+
// row (the label parser is only a fallback for older cached bubbles).
|
|
1087
|
+
if (u._indexFile) cleaned._indexFile = u._indexFile;
|
|
975
1088
|
this.state.messages[uIdx] = cleaned;
|
|
976
1089
|
}
|
|
977
1090
|
|
|
@@ -988,11 +1101,11 @@ export class ChatSession {
|
|
|
988
1101
|
if (!pending) return Promise.resolve();
|
|
989
1102
|
if (this.state.messages.some(function (m) { return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue; })) return Promise.resolve();
|
|
990
1103
|
this.state.sending = true;
|
|
991
|
-
this.host.
|
|
1104
|
+
this.host.scrollToBottomIfSticky(true);
|
|
992
1105
|
return Promise.resolve(pending).catch(function () { }).then(function () {
|
|
993
1106
|
if (token !== self.state.gateRefreshToken) return;
|
|
994
1107
|
self.state.sending = false;
|
|
995
|
-
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.
|
|
1108
|
+
return Promise.resolve(self.typewriteLatestReply(key)).then(function () { self.host.scrollToBottomIfSticky(true); });
|
|
996
1109
|
});
|
|
997
1110
|
}
|
|
998
1111
|
|
|
@@ -1016,13 +1129,35 @@ export class ChatSession {
|
|
|
1016
1129
|
// bubble stuck pending — and drainBgTaskQueue then never clears its queue
|
|
1017
1130
|
// entry (its stillPending check stays true). Un-pend it here too.
|
|
1018
1131
|
this._clearPendingUserBubble(itemId);
|
|
1132
|
+
// Carry isBackgroundTask onto the REPLACEMENT. These rebuilds are fresh
|
|
1133
|
+
// object literals, so anything not copied is lost — and dropping this flag
|
|
1134
|
+
// made a pass that resolved LIVE fall out of its file's collapsed row
|
|
1135
|
+
// (buildChatDisplayList only considers isBackgroundTask messages), while
|
|
1136
|
+
// the same pass rebuilt from history stayed grouped. That mismatch is the
|
|
1137
|
+
// "new indexing response renders outside the group" bug.
|
|
1138
|
+
var wasBgTask = !!this.state.messages[idx].isBackgroundTask;
|
|
1019
1139
|
if (isErr) {
|
|
1020
1140
|
this.state.messages[idx] = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
|
|
1141
|
+
if (wasBgTask) this.state.messages[idx].isBackgroundTask = true;
|
|
1142
|
+
this.host.notify(); this.updateHistoryCache(); return;
|
|
1143
|
+
}
|
|
1144
|
+
var text = answer || 'No text response received from AI provider.';
|
|
1145
|
+
// A background-indexing reply is INSIDE a collapsed row: nothing of it is
|
|
1146
|
+
// on screen. Typewriting it anyway put it on the one shared, serial
|
|
1147
|
+
// typewriter queue, so the user's own next reply — which IS on screen —
|
|
1148
|
+
// lost its "Thinking..." spinner and sat as an EMPTY bubble for however
|
|
1149
|
+
// long the invisible indexing summary took to reveal (seconds per pass,
|
|
1150
|
+
// additive across files). It also held state.typing true for the whole
|
|
1151
|
+
// time, which disables the platform/model pickers and no-ops Clear
|
|
1152
|
+
// history, and ran a per-frame scroll for content nobody can see.
|
|
1153
|
+
// Write it straight in; expanding the row then shows it complete.
|
|
1154
|
+
if (wasBgTask) {
|
|
1155
|
+
this.state.messages[idx] = { role: 'assistant', content: text, isBackgroundTask: true, _serverItemId: itemId };
|
|
1021
1156
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
1022
1157
|
}
|
|
1023
1158
|
var lid = this._newLocalId();
|
|
1024
1159
|
this.state.messages[idx] = { role: 'assistant', content: '', _localId: lid, _serverItemId: itemId };
|
|
1025
|
-
this.host.notify(); this.enqueueTypewrite(idx,
|
|
1160
|
+
this.host.notify(); this.enqueueTypewrite(idx, text, lid);
|
|
1026
1161
|
this.updateHistoryCache(); return;
|
|
1027
1162
|
}
|
|
1028
1163
|
var userIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -1030,23 +1165,126 @@ export class ChatSession {
|
|
|
1030
1165
|
});
|
|
1031
1166
|
if (userIdx === -1) return;
|
|
1032
1167
|
var ex = this.state.messages[userIdx];
|
|
1033
|
-
|
|
1168
|
+
// Same carry-over as above, plus _indexFile / _useBgQueue: this rebuild is
|
|
1169
|
+
// what keeps an indexing REQUEST bubble in its file's collapsed row (and a
|
|
1170
|
+
// bg-queued chat cancelling against the right queue) once the pass settles.
|
|
1171
|
+
var settledUser: ChatMessage = { role: 'user', content: ex.content, _serverItemId: itemId };
|
|
1172
|
+
if (ex.isBackgroundTask) settledUser.isBackgroundTask = true;
|
|
1173
|
+
if (ex._ts !== undefined) settledUser._ts = ex._ts;
|
|
1174
|
+
if (ex._indexFile) settledUser._indexFile = ex._indexFile;
|
|
1175
|
+
if (ex._useBgQueue) settledUser._useBgQueue = true;
|
|
1176
|
+
this.state.messages[userIdx] = settledUser;
|
|
1034
1177
|
if (isErr) {
|
|
1035
|
-
|
|
1178
|
+
var errReply: ChatMessage = { role: 'assistant', content: answer, isError: true, _serverItemId: itemId };
|
|
1179
|
+
if (ex.isBackgroundTask) errReply.isBackgroundTask = true;
|
|
1180
|
+
this.state.messages.splice(userIdx + 1, 0, errReply);
|
|
1181
|
+
this.host.notify(); this.updateHistoryCache(); return;
|
|
1182
|
+
}
|
|
1183
|
+
var text2 = answer || 'No text response received from AI provider.';
|
|
1184
|
+
// Same as above: a collapsed row's reply is not on screen, so revealing it
|
|
1185
|
+
// character by character only blocks the queue the visible reply needs.
|
|
1186
|
+
if (ex.isBackgroundTask) {
|
|
1187
|
+
this.state.messages.splice(userIdx + 1, 0, { role: 'assistant', content: text2, isBackgroundTask: true, _serverItemId: itemId });
|
|
1036
1188
|
this.host.notify(); this.updateHistoryCache(); return;
|
|
1037
1189
|
}
|
|
1038
1190
|
var lid2 = this._newLocalId();
|
|
1039
|
-
|
|
1040
|
-
this.
|
|
1191
|
+
var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
|
|
1192
|
+
this.state.messages.splice(userIdx + 1, 0, reply);
|
|
1193
|
+
this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
|
|
1041
1194
|
this.updateHistoryCache();
|
|
1042
1195
|
}
|
|
1043
1196
|
|
|
1197
|
+
/** How a bg task maps onto a collapsed row: the row's own key (storage path
|
|
1198
|
+
* when known, else the filename), scoped to the chat it belongs to. A storage
|
|
1199
|
+
* path is project-relative ("report.xlsx"), and ONE ChatSession serves every
|
|
1200
|
+
* project — unscoped, stopping a file in one project would silently suppress
|
|
1201
|
+
* the same filename's continuations in another. */
|
|
1202
|
+
private _indexKeyOf(entry: BgTaskEntry): string {
|
|
1203
|
+
if (!entry) return '';
|
|
1204
|
+
var file = entry.storagePath || entry.filename;
|
|
1205
|
+
if (!file) return '';
|
|
1206
|
+
return entry.serviceId + '#' + entry.platform + '|' + file;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Reconcile the bg queue with the files the user has stopped.
|
|
1211
|
+
*
|
|
1212
|
+
* A FIRST pass (no resumePass) is a fresh indexing request — a re-upload, or a
|
|
1213
|
+
* Reindex from the file manager — so it LIFTS the stop: the key is a storage
|
|
1214
|
+
* path, and without this an earlier cancel would silently kill every future
|
|
1215
|
+
* index of the same path. A continuation of a stopped file is dropped instead,
|
|
1216
|
+
* covering the pass that was dispatched in the moment before the cancel landed.
|
|
1217
|
+
*/
|
|
1218
|
+
private _applyIndexCancellations(): void {
|
|
1219
|
+
if (!this.cancelledIndexKeys.size) return;
|
|
1220
|
+
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
1221
|
+
var entry = this.bgTaskQueue[i];
|
|
1222
|
+
var key = this._indexKeyOf(entry);
|
|
1223
|
+
if (!key || !this.cancelledIndexKeys.has(key)) continue;
|
|
1224
|
+
if (!entry.resumePass) { this.cancelledIndexKeys.delete(key); continue; }
|
|
1225
|
+
this.bgTaskQueue.splice(i, 1);
|
|
1226
|
+
this._stopPoll(entry.id);
|
|
1227
|
+
this._cancelServerItem(entry.id);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Cancel any live pass of a stopped file that turned up on its own.
|
|
1233
|
+
*
|
|
1234
|
+
* The client is not the only thing that continues a file: for PDFs and (when
|
|
1235
|
+
* windowed indexing is on) text/grid files the WORKER enqueues the next window
|
|
1236
|
+
* itself, and that pass reaches the chat through the history poll, never
|
|
1237
|
+
* through bgTaskQueue. The worker's own gate stops the chain when the running
|
|
1238
|
+
* row is cancelled — but if the row had already finished when the user hit
|
|
1239
|
+
* stop, the next window was queued a moment earlier and still arrives. Stop it
|
|
1240
|
+
* here rather than making the user hit stop again.
|
|
1241
|
+
*
|
|
1242
|
+
* Runs from drainBgTaskQueue, which both clients call after a history load.
|
|
1243
|
+
*/
|
|
1244
|
+
private _sweepCancelledIndexing(): void {
|
|
1245
|
+
if (!this.cancelledIndexKeys.size) return;
|
|
1246
|
+
var self = this;
|
|
1247
|
+
var chatKey = this.getHistoryCacheKey();
|
|
1248
|
+
var targets: { msg: ChatMessage; idx: number }[] = [];
|
|
1249
|
+
this.state.messages.forEach(function (m, i) {
|
|
1250
|
+
if (!m.isBackgroundTask || m.role !== 'user' || !m._serverItemId) return;
|
|
1251
|
+
if (m._cancelling || m.isSendingToServer) return;
|
|
1252
|
+
if (!(m.isPendingQueued || m.isPendingInProcess)) return;
|
|
1253
|
+
var ref = m._indexFile;
|
|
1254
|
+
var file = ref && (ref.path || ref.name);
|
|
1255
|
+
if (!file || !self.cancelledIndexKeys.has(chatKey + '|' + file)) return;
|
|
1256
|
+
targets.push({ msg: m, idx: i });
|
|
1257
|
+
});
|
|
1258
|
+
targets.forEach(function (t) {
|
|
1259
|
+
var idx = self.state.messages.indexOf(t.msg);
|
|
1260
|
+
self.cancelQueuedMessage(t.msg, idx === -1 ? t.idx : idx);
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Best-effort server-side cancel of a bg-queue item that has no bubble (so
|
|
1265
|
+
* cancelQueuedMessage, which drives one, has nothing to act on). */
|
|
1266
|
+
private _cancelServerItem(serverId: string): void {
|
|
1267
|
+
var id = this.host.getIdentity();
|
|
1268
|
+
if (!serverId || (id.platform !== 'claude' && id.platform !== 'openai')) return;
|
|
1269
|
+
var url = id.platform === 'claude' ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
|
|
1270
|
+
Promise.resolve(this.host.cancelRequest({
|
|
1271
|
+
url: url, method: 'POST', id: serverId,
|
|
1272
|
+
queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
|
|
1273
|
+
service: id.serviceId, owner: id.owner,
|
|
1274
|
+
})).catch(function () { /* the pass may already have finished; nothing to do */ });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1044
1277
|
// Inject "Indexing: <file>" bubbles for queued bg tasks + attach their polls.
|
|
1045
1278
|
drainBgTaskQueue(): void {
|
|
1046
1279
|
var self = this;
|
|
1047
1280
|
var id = this.host.getIdentity();
|
|
1048
1281
|
var svcId = id.serviceId, plat = id.platform;
|
|
1049
1282
|
if (!svcId || plat === 'none' || !this.host.isViewMounted()) return;
|
|
1283
|
+
// Before anything is surfaced: drop continuations of files the user stopped
|
|
1284
|
+
// (and let a fresh first pass lift the stop), then cancel any worker-queued
|
|
1285
|
+
// pass of a stopped file that reached the chat through history.
|
|
1286
|
+
this._applyIndexCancellations();
|
|
1287
|
+
this._sweepCancelledIndexing();
|
|
1050
1288
|
// Index messages by _serverItemId ONCE, so the per-entry presence/pending
|
|
1051
1289
|
// checks below are O(1) instead of a full messages.some() scan each. With a
|
|
1052
1290
|
// large bg queue (bulk uploads) the old nested scan was O(queue x messages)
|
|
@@ -1072,14 +1310,29 @@ export class ChatSession {
|
|
|
1072
1310
|
// "Thinking..." once polling resumed.
|
|
1073
1311
|
if (!presentIds[entry.id]) {
|
|
1074
1312
|
var isRunning = entry.status === 'running';
|
|
1075
|
-
var userBubble: ChatMessage = {
|
|
1313
|
+
var userBubble: ChatMessage = {
|
|
1314
|
+
role: 'user',
|
|
1315
|
+
content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
|
|
1316
|
+
isBackgroundTask: true,
|
|
1317
|
+
_serverItemId: entry.id,
|
|
1318
|
+
// Structured ref so this live pass groups with the same file's passes
|
|
1319
|
+
// rebuilt from history (see indexing_groups.buildChatDisplayList).
|
|
1320
|
+
_indexFile: {
|
|
1321
|
+
name: entry.filename,
|
|
1322
|
+
path: entry.storagePath,
|
|
1323
|
+
mime: entry.mime,
|
|
1324
|
+
size: entry.size,
|
|
1325
|
+
isReindex: !!entry.isReindex,
|
|
1326
|
+
continued: !!entry.resumePass,
|
|
1327
|
+
},
|
|
1328
|
+
};
|
|
1076
1329
|
if (isRunning) userBubble.isPendingInProcess = true; else userBubble.isPendingQueued = true;
|
|
1077
1330
|
self.state.messages.push(userBubble);
|
|
1078
1331
|
if (isRunning) {
|
|
1079
1332
|
self.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
|
|
1080
1333
|
}
|
|
1081
1334
|
presentIds[entry.id] = true; // keep the index consistent with the pushed bubbles
|
|
1082
|
-
self.host.notify(); self.updateHistoryCache(); self.host.
|
|
1335
|
+
self.host.notify(); self.updateHistoryCache(); self.host.scrollToBottomIfSticky(false);
|
|
1083
1336
|
}
|
|
1084
1337
|
if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
|
|
1085
1338
|
var capturedId = entry.id, capturedPlat = plat;
|
|
@@ -1096,12 +1349,28 @@ export class ChatSession {
|
|
|
1096
1349
|
}).catch(function (err: any) {
|
|
1097
1350
|
self.historyItemPolls.delete(capturedId);
|
|
1098
1351
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
1352
|
+
// Settle the REQUEST bubble first, unconditionally. Only a RUNNING
|
|
1353
|
+
// pass gets an assistant placeholder, so for a queued one the lookup
|
|
1354
|
+
// below finds nothing and every branch was skipped — leaving the
|
|
1355
|
+
// request bubble isPendingQueued with its queue entry about to be
|
|
1356
|
+
// spliced away, i.e. no poll left to ever clear it. One such bubble
|
|
1357
|
+
// pins the whole collapsed row to "active": a spinner and a Stop
|
|
1358
|
+
// button against a dead id, until the page is reloaded.
|
|
1359
|
+
self._clearPendingUserBubble(capturedId);
|
|
1099
1360
|
var bi = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
|
|
1100
1361
|
if (bi !== -1) {
|
|
1101
1362
|
if (isNotExists) self.state.messages.splice(bi, 1);
|
|
1102
1363
|
else self.state.messages[bi] = { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId };
|
|
1103
|
-
|
|
1364
|
+
} else if (!isNotExists) {
|
|
1365
|
+
// No placeholder to turn into the error (a queued pass). Put one
|
|
1366
|
+
// after the request bubble so the row reports the failure instead
|
|
1367
|
+
// of quietly settling to "Indexed".
|
|
1368
|
+
var ui = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId; });
|
|
1369
|
+
if (ui !== -1) {
|
|
1370
|
+
self.state.messages.splice(ui + 1, 0, { role: 'assistant', content: getErrorMessage(err), isError: true, isBackgroundTask: true, _serverItemId: capturedId });
|
|
1371
|
+
}
|
|
1104
1372
|
}
|
|
1373
|
+
self.host.notify(); self.updateHistoryCache();
|
|
1105
1374
|
}).then(function () {
|
|
1106
1375
|
// Keep the queue entry when the poll was merely stopped, or resuming
|
|
1107
1376
|
// would have nothing left to re-attach to.
|
|
@@ -1130,8 +1399,18 @@ export class ChatSession {
|
|
|
1130
1399
|
var self = this;
|
|
1131
1400
|
try {
|
|
1132
1401
|
if (!entry || !entry.storagePath) return;
|
|
1402
|
+
// The user stopped this file from its collapsed row. Dispatching the next
|
|
1403
|
+
// pass here is exactly what "stop" has to prevent — the cancelled pass
|
|
1404
|
+
// settles, and without this the chain simply carries on.
|
|
1405
|
+
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
1133
1406
|
if (!isPagedReadFile(entry.filename, entry.mime)) return;
|
|
1134
|
-
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop
|
|
1407
|
+
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
|
|
1408
|
+
// When windowed indexing is on, the WORKER drives the text/grid loop too. The
|
|
1409
|
+
// client MUST NOT also resume, or two drivers each enqueue a continuation per
|
|
1410
|
+
// pass and the chain FORKS - duplicate records, runaway passes. Same reason
|
|
1411
|
+
// PDFs early-return above. Gated on the flag so the old client-driven path is
|
|
1412
|
+
// untouched when windowing is off.
|
|
1413
|
+
if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
|
|
1135
1414
|
if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
|
|
1136
1415
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
1137
1416
|
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
|
|
@@ -1221,6 +1500,10 @@ export class ChatSession {
|
|
|
1221
1500
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
1222
1501
|
}).messages;
|
|
1223
1502
|
|
|
1503
|
+
// Set when a first-page refresh re-prepended already-loaded older pages,
|
|
1504
|
+
// so the cursor reset below knows not to rewind to page 1's boundary.
|
|
1505
|
+
var keptOlderPages = false;
|
|
1506
|
+
|
|
1224
1507
|
if (fetchMore) {
|
|
1225
1508
|
self.state.messages = mapped.concat(self.state.messages);
|
|
1226
1509
|
} else {
|
|
@@ -1228,7 +1511,17 @@ export class ChatSession {
|
|
|
1228
1511
|
var serverIds: any = {};
|
|
1229
1512
|
mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
|
|
1230
1513
|
var locallyCancelled: any = {};
|
|
1231
|
-
self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] =
|
|
1514
|
+
self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m; });
|
|
1515
|
+
// A cancel the server has not acknowledged yet. Without carrying these
|
|
1516
|
+
// across the replace, a collapsed row mid-cancel drops its "Stopping..."
|
|
1517
|
+
// state and re-offers Stop; the second click cancels an id that is
|
|
1518
|
+
// already gone and writes a bogus "Could not remove from queue." onto a
|
|
1519
|
+
// run that WAS stopped successfully.
|
|
1520
|
+
var inFlightCancel: any = {};
|
|
1521
|
+
self.state.messages.forEach(function (m) {
|
|
1522
|
+
if (!m._serverItemId) return;
|
|
1523
|
+
if (m._cancelling || m._cancelError) inFlightCancel[m._serverItemId] = m;
|
|
1524
|
+
});
|
|
1232
1525
|
// If the freshly-mapped server list ALREADY shows this in-flight turn
|
|
1233
1526
|
// as a pending placeholder (a non-bg pending assistant, which carries
|
|
1234
1527
|
// a real _serverItemId), re-pushing the local no-_serverItemId
|
|
@@ -1257,25 +1550,83 @@ export class ChatSession {
|
|
|
1257
1550
|
}
|
|
1258
1551
|
}
|
|
1259
1552
|
}
|
|
1260
|
-
|
|
1553
|
+
// PRESERVE already-loaded older pages. `mapped` is only page 1, so a
|
|
1554
|
+
// blind replace threw away every page the user had scrolled in. This
|
|
1555
|
+
// path runs from resumePolling (visibilitychange), so merely leaving
|
|
1556
|
+
// the tab and coming back wiped the whole scrolled-in history.
|
|
1557
|
+
//
|
|
1558
|
+
// The list is oldest-first (fetchMore PREPENDS), and ids sort with
|
|
1559
|
+
// newest largest, so anything already in the list whose _serverItemId
|
|
1560
|
+
// sorts BELOW page 1's oldest id is an older page. Those are disjoint
|
|
1561
|
+
// from `mapped` (strict <), so re-prepending cannot duplicate.
|
|
1562
|
+
var oldestInPage1: string | undefined = undefined;
|
|
1563
|
+
mapped.forEach(function (m: any) {
|
|
1564
|
+
var sid = m._serverItemId;
|
|
1565
|
+
if (typeof sid !== 'string') return;
|
|
1566
|
+
if (oldestInPage1 === undefined || sid < (oldestInPage1 as string)) oldestInPage1 = sid;
|
|
1567
|
+
});
|
|
1568
|
+
// Only merge when the current list is genuinely a continuation of THIS
|
|
1569
|
+
// page 1 (shares at least one id with it). A project switch, a cleared
|
|
1570
|
+
// horizon, or a wholly-new page means the head is unrelated history and
|
|
1571
|
+
// must be replaced, not spliced.
|
|
1572
|
+
var sharesPage1 = self.state.messages.some(function (m) {
|
|
1573
|
+
return typeof m._serverItemId === 'string' && !!serverIds[m._serverItemId as string];
|
|
1574
|
+
});
|
|
1575
|
+
var retainedOlder: ChatMessage[] = (!sharesPage1 || oldestInPage1 === undefined) ? [] : self.state.messages.filter(function (m) {
|
|
1576
|
+
// Settled server history only; in-flight bubbles are the rescue
|
|
1577
|
+
// loop's job below and would otherwise be kept twice.
|
|
1578
|
+
if (typeof m._serverItemId !== 'string') return false;
|
|
1579
|
+
// Same ownership predicate as the rescue loop: never carry another
|
|
1580
|
+
// project's bubbles onto this chat (the cross-project leak fix).
|
|
1581
|
+
// Compare against the snapshotted loadKey, never a live read.
|
|
1582
|
+
if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return false;
|
|
1583
|
+
return (m._serverItemId as string) < (oldestInPage1 as string);
|
|
1584
|
+
});
|
|
1585
|
+
keptOlderPages = retainedOlder.length > 0;
|
|
1586
|
+
self.state.messages = keptOlderPages ? retainedOlder.concat(mapped) : mapped;
|
|
1261
1587
|
rescued.forEach(function (m) { self.state.messages.push(m); });
|
|
1262
1588
|
if (Object.keys(locallyCancelled).length) {
|
|
1263
1589
|
for (var ci = 0; ci < self.state.messages.length; ci++) {
|
|
1264
1590
|
var c = self.state.messages[ci];
|
|
1265
1591
|
if (!c._serverItemId || !locallyCancelled[c._serverItemId] || c.isCancelled) continue;
|
|
1266
|
-
|
|
1592
|
+
// Rebuild FROM the mapped bubble rather than as a bare literal:
|
|
1593
|
+
// dropping isBackgroundTask / _indexFile here ejected a stopped
|
|
1594
|
+
// indexing pass from its file's collapsed row, where it then rendered
|
|
1595
|
+
// as a standalone "Indexing: <file>" chat bubble and left the row
|
|
1596
|
+
// recomputing its passes as if that one had never happened.
|
|
1597
|
+
self.state.messages[ci] = {
|
|
1598
|
+
role: 'user', content: c.content, isCancelled: true, _serverItemId: c._serverItemId,
|
|
1599
|
+
isBackgroundTask: c.isBackgroundTask, _indexFile: c._indexFile,
|
|
1600
|
+
_useBgQueue: c._useBgQueue, _ownerKey: c._ownerKey,
|
|
1601
|
+
};
|
|
1267
1602
|
if (ci + 1 < self.state.messages.length && self.state.messages[ci + 1].isPending && self.state.messages[ci + 1]._serverItemId === c._serverItemId) {
|
|
1268
1603
|
self.state.messages.splice(ci + 1, 1);
|
|
1269
1604
|
}
|
|
1270
1605
|
}
|
|
1271
1606
|
}
|
|
1607
|
+
// Re-stamp a cancel that is still in flight (see inFlightCancel above).
|
|
1608
|
+
// Only onto a bubble the server still reports as pending — once it has
|
|
1609
|
+
// settled the flags describe nothing the user can act on.
|
|
1610
|
+
for (var fi = 0; fi < self.state.messages.length; fi++) {
|
|
1611
|
+
var fm = self.state.messages[fi];
|
|
1612
|
+
var was = fm._serverItemId && inFlightCancel[fm._serverItemId];
|
|
1613
|
+
if (!was || fm.isCancelled) continue;
|
|
1614
|
+
if (!(fm.isPendingQueued || fm.isPendingInProcess || fm.isPending)) continue;
|
|
1615
|
+
if (was._cancelling) fm._cancelling = true;
|
|
1616
|
+
if (was._cancelError) fm._cancelError = was._cancelError;
|
|
1617
|
+
}
|
|
1272
1618
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
if (
|
|
1277
|
-
|
|
1278
|
-
|
|
1619
|
+
// Only adopt page 1's cursor when page 1 IS the whole list. If older pages
|
|
1620
|
+
// survived the merge, rewinding here would make the next scroll-up
|
|
1621
|
+
// re-fetch page 2 (already on screen) and duplicate it.
|
|
1622
|
+
if (!keptOlderPages) {
|
|
1623
|
+
self.state.historyEndOfList = !!(history && history.endOfList);
|
|
1624
|
+
self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
|
|
1625
|
+
var clearedAt = self.host.getClearedAt();
|
|
1626
|
+
if (clearedAt && chatList.length > 0) {
|
|
1627
|
+
var oldestUpdated = Number(chatList[chatList.length - 1] && chatList[chatList.length - 1].updated);
|
|
1628
|
+
if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
|
|
1629
|
+
}
|
|
1279
1630
|
}
|
|
1280
1631
|
// Clear loading flags BEFORE this render so the final paint is
|
|
1281
1632
|
// indicator-free (and the view's scroll-restore math sees matching heights).
|
|
@@ -1321,12 +1672,30 @@ export class ChatSession {
|
|
|
1321
1672
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
1322
1673
|
var aIdx = self.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === capturedId; });
|
|
1323
1674
|
if (isNotExists) {
|
|
1324
|
-
var
|
|
1675
|
+
var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
|
|
1676
|
+
// Decide bg-ness from whichever bubble actually exists. A
|
|
1677
|
+
// QUEUED pass has no assistant placeholder (history.ts emits
|
|
1678
|
+
// none), so reading it off the placeholder alone classified
|
|
1679
|
+
// every queued indexing pass as foreground: the rebuild below
|
|
1680
|
+
// then dropped isBackgroundTask/_indexFile, ejecting the pass
|
|
1681
|
+
// from its collapsed row as a raw "Indexing: <file>" chat
|
|
1682
|
+
// bubble, and promoteNextQueuedToRunning span up a "Thinking..."
|
|
1683
|
+
// on an unrelated queued FOREGROUND message.
|
|
1684
|
+
var isBg = (aIdx !== -1 && !!self.state.messages[aIdx].isBackgroundTask)
|
|
1685
|
+
|| (uIdx !== -1 && !!self.state.messages[uIdx].isBackgroundTask);
|
|
1325
1686
|
if (aIdx !== -1) self.state.messages.splice(aIdx, 1);
|
|
1326
1687
|
if (!isBg) {
|
|
1327
|
-
var uIdx = self.state.messages.findIndex(function (m) { return m.role === 'user' && m._serverItemId === capturedId && !m.isCancelled; });
|
|
1328
1688
|
if (uIdx !== -1) { var ex = self.state.messages[uIdx]; self.state.messages[uIdx] = { role: 'user', content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId }; }
|
|
1329
1689
|
self.cancelledServerIds.delete(capturedId); self.promoteNextQueuedToRunning();
|
|
1690
|
+
} else if (uIdx !== -1) {
|
|
1691
|
+
// Same carry-over every other rebuild does, so the pass stays
|
|
1692
|
+
// a member of its file's row while reading as cancelled.
|
|
1693
|
+
var bex = self.state.messages[uIdx];
|
|
1694
|
+
var bcancelled: ChatMessage = { role: 'user', content: bex.content, isCancelled: true, isBackgroundTask: true, _serverItemId: bex._serverItemId };
|
|
1695
|
+
if (bex._indexFile) bcancelled._indexFile = bex._indexFile;
|
|
1696
|
+
if (bex._useBgQueue) bcancelled._useBgQueue = true;
|
|
1697
|
+
self.state.messages[uIdx] = bcancelled;
|
|
1698
|
+
self.promoteNextBgQueuedToRunning();
|
|
1330
1699
|
}
|
|
1331
1700
|
self.host.notify(); self.updateHistoryCache(); return;
|
|
1332
1701
|
}
|
|
@@ -1349,7 +1718,11 @@ export class ChatSession {
|
|
|
1349
1718
|
self.drainBgTaskQueue();
|
|
1350
1719
|
}
|
|
1351
1720
|
|
|
1352
|
-
|
|
1721
|
+
// Sticky, NOT forcing: this runs after every first-page load, including
|
|
1722
|
+
// the one resumePolling fires on visibilitychange. Forcing yanked a
|
|
1723
|
+
// reader who had scrolled up back to the bottom. On a genuine mount the
|
|
1724
|
+
// user is still pinned, so this behaves identically there.
|
|
1725
|
+
if (!fetchMore) return self.host.scrollToBottomIfSticky();
|
|
1353
1726
|
}).catch(function (err: any) {
|
|
1354
1727
|
console.warn('[chat-engine] getChatHistory failed', err);
|
|
1355
1728
|
}).then(function () {
|
|
@@ -1358,6 +1731,12 @@ export class ChatSession {
|
|
|
1358
1731
|
self.state.loadingHistory = false; self.state.loadingOlderHistory = false;
|
|
1359
1732
|
if (wasLoading) self.host.notify();
|
|
1360
1733
|
}
|
|
1734
|
+
// Every first-page load ends here — mount, resumePolling, a cache-restore
|
|
1735
|
+
// refresh — and every one of them can leave a box too short to scroll,
|
|
1736
|
+
// which is the only trigger there is for reaching page 2. The view (which
|
|
1737
|
+
// alone can measure) pages its way out of it. Fired after the flags are
|
|
1738
|
+
// cleared so the fill loop does not see a request still in flight.
|
|
1739
|
+
if (self.host.onHistoryLoaded) self.host.onHistoryLoaded(!!fetchMore, token as number);
|
|
1361
1740
|
});
|
|
1362
1741
|
}
|
|
1363
1742
|
|