bunnyquery 1.8.5 → 1.8.8
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 +1 -1
- package/bunnyquery.css +168 -26
- package/bunnyquery.js +1429 -118
- package/dist/engine.cjs +1019 -79
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +507 -164
- package/dist/engine.d.ts +507 -164
- package/dist/engine.mjs +1003 -80
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +207 -68
- package/src/engine/config.ts +48 -0
- package/src/engine/errors.ts +38 -0
- package/src/engine/history.ts +546 -6
- package/src/engine/host.ts +7 -0
- package/src/engine/index.ts +15 -1
- package/src/engine/indexing_groups.ts +248 -3
- package/src/engine/office.ts +24 -1
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/requests.ts +165 -12
- package/src/engine/session.ts +544 -35
- package/src/widget.css +54 -18
- package/styles/chat.css +114 -8
package/src/engine/session.ts
CHANGED
|
@@ -32,17 +32,21 @@ import {
|
|
|
32
32
|
MAX_CONCURRENT_BG_POLLS,
|
|
33
33
|
bgIndexingQueueName,
|
|
34
34
|
isBgIndexingQueue,
|
|
35
|
+
buildHistoryItemFullId,
|
|
35
36
|
ANTHROPIC_MESSAGES_API_URL,
|
|
36
37
|
OPENAI_RESPONSES_API_URL,
|
|
38
|
+
upsertIndexRunRecordSafe,
|
|
39
|
+
type IndexRunStatus,
|
|
40
|
+
type IndexRunPatch,
|
|
37
41
|
type BgTaskEntry,
|
|
38
42
|
} from './requests';
|
|
39
43
|
import { isPagedReadFile, isImageVisionFile, isWindowedReadFile } from './office';
|
|
40
|
-
import { windowedIndexingEnabled } from './config';
|
|
44
|
+
import { windowedIndexingEnabled, chatEngineConfig } from './config';
|
|
41
45
|
import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
|
|
42
46
|
import { buildBoundedChatMessages } from './budget';
|
|
43
|
-
import { createInlineLinkRegex } from './links';
|
|
47
|
+
import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
|
|
44
48
|
import { markImagePreviewStale } from './image_preview';
|
|
45
|
-
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
|
|
49
|
+
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory } from './history';
|
|
46
50
|
import { wallClockNow } from './time';
|
|
47
51
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
48
52
|
import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
|
|
@@ -92,6 +96,11 @@ const INDEXING_DRAIN_IDLE_LOOKS = 2;
|
|
|
92
96
|
// about. Costs nothing in the normal case, where the queue reports work
|
|
93
97
|
// immediately and the wait is far longer than this anyway.
|
|
94
98
|
const INDEXING_DRAIN_MIN_MS = 8000;
|
|
99
|
+
|
|
100
|
+
// Ownership sequence for state.bgHistoryLoading (see the deferred-batch handler
|
|
101
|
+
// in loadHistory): the latest minted batch owns the flag; older handlers
|
|
102
|
+
// settling late must neither clear nor keep it.
|
|
103
|
+
let _bgHistoryBatchSeq = 0;
|
|
95
104
|
// Ceiling on that wait. Past it the turn is sent regardless: an answer computed
|
|
96
105
|
// against a partly-indexed file is a poor outcome, but a question that is never
|
|
97
106
|
// asked at all because one chain wedged server-side is a worse one.
|
|
@@ -216,6 +225,9 @@ export class ChatSession {
|
|
|
216
225
|
typingAbort: false,
|
|
217
226
|
loadingHistory: false,
|
|
218
227
|
loadingOlderHistory: false,
|
|
228
|
+
// A deferred bg stub batch (first-paint split) is still in flight; the
|
|
229
|
+
// views show a small 'loading indexing history' hint while true.
|
|
230
|
+
bgHistoryLoading: false,
|
|
219
231
|
historyEndOfList: false,
|
|
220
232
|
historyStartKeyHistory: [],
|
|
221
233
|
historyRequestToken: 0,
|
|
@@ -384,11 +396,13 @@ export class ChatSession {
|
|
|
384
396
|
return Promise.resolve();
|
|
385
397
|
}
|
|
386
398
|
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
399
|
+
// Snapshot reader: a young shared answer (this session's own adopt
|
|
400
|
+
// ladder, or the db-files page's probe) is as good as a fresh one.
|
|
387
401
|
var ask = function (status: 'pending' | 'running') {
|
|
388
|
-
return Promise.resolve(
|
|
389
|
-
{ service: id.projectId, owner: id.owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
390
|
-
{
|
|
391
|
-
)).catch(function () { return null; });
|
|
402
|
+
return Promise.resolve(probeBgQueue(
|
|
403
|
+
{ service: id.projectId, owner: id.owner, platform: platform as 'claude' | 'openai', queue: queue, status: status, limit: WORKER_PASS_ADOPT_LIMIT },
|
|
404
|
+
{ maxAgeMs: BG_PROBE_TTL_MS },
|
|
405
|
+
)).then(function (entry) { return entry.result; }).catch(function () { return null; });
|
|
392
406
|
};
|
|
393
407
|
return Promise.all([ask('pending'), ask('running')]).then(function (results) {
|
|
394
408
|
if (results[0] === null || results[1] === null) return;
|
|
@@ -500,7 +514,13 @@ export class ChatSession {
|
|
|
500
514
|
* instead of merely unconfirmed.
|
|
501
515
|
*/
|
|
502
516
|
refreshLiveIndexState(): void {
|
|
503
|
-
this.
|
|
517
|
+
// PASSIVE: this is the first-page/resume refresh, not a settle. One
|
|
518
|
+
// fresh look (two status queries) always happens; the 2s/6s ladder
|
|
519
|
+
// climb is reserved for when there is EVIDENCE of live work — the
|
|
520
|
+
// pass-boundary gap the ladder exists for presumes a chain was alive,
|
|
521
|
+
// and on an idle tab return the climb was three round trips of asking
|
|
522
|
+
// a question whose answer had not changed.
|
|
523
|
+
this._adoptWorkerIndexingPasses(0, true);
|
|
504
524
|
}
|
|
505
525
|
|
|
506
526
|
/** Forget what we know about which files are indexing — but ONLY when the
|
|
@@ -688,6 +708,79 @@ export class ChatSession {
|
|
|
688
708
|
return id.projectId + '#' + id.platform;
|
|
689
709
|
}
|
|
690
710
|
|
|
711
|
+
// ─── compact-stub hydration ─────────────────────────────────────────────
|
|
712
|
+
// Split-fetch bg pages arrive as label stubs (no bodies). When the user
|
|
713
|
+
// expands a row, the real reply text is fetched per item (csr-poll point
|
|
714
|
+
// lookup) and MEMOIZED per chat: every later remap (first-page refresh,
|
|
715
|
+
// queue-detect tick, cache restore) re-applies the memo, so a hydrated
|
|
716
|
+
// bubble can never silently revert to its 200-char head.
|
|
717
|
+
private _hydratedBodies: { [chatKey: string]: { [itemId: string]: string } } = {};
|
|
718
|
+
private _hydratingItems: { [key: string]: boolean } = {};
|
|
719
|
+
|
|
720
|
+
/** Re-apply memoized hydrated texts onto freshly-mapped messages. Both
|
|
721
|
+
* clients call this right after their mapper runs (loadHistory does it
|
|
722
|
+
* internally); it mutates the given array's items in place. */
|
|
723
|
+
applyHydratedBodies(messages: ChatMessage[]): void {
|
|
724
|
+
var key = this.getHistoryCacheKey();
|
|
725
|
+
var memo = key ? this._hydratedBodies[key] : null;
|
|
726
|
+
if (!memo) return;
|
|
727
|
+
var id = this.host.getIdentity();
|
|
728
|
+
for (var i = 0; i < messages.length; i++) {
|
|
729
|
+
var m: any = messages[i];
|
|
730
|
+
if (!m || !m._compact || m.role !== 'assistant' || !m._serverItemId) continue;
|
|
731
|
+
var text = memo[m._serverItemId];
|
|
732
|
+
if (typeof text !== 'string') continue;
|
|
733
|
+
m.content = sanitizeAttachmentLinksForHistory(text, id.projectId, true) || EMPTY_INDEXING_REPLY;
|
|
734
|
+
delete m._compact;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Fetch the real response bodies for compact history stubs (one csr-poll
|
|
739
|
+
* point lookup per item id), memoize, and swap them into the live list.
|
|
740
|
+
* Best-effort: a failed lookup leaves the stub (its head + fallback line
|
|
741
|
+
* still render) and a later expand retries. */
|
|
742
|
+
hydrateCompactItems(itemIds: string[]): Promise<void> {
|
|
743
|
+
var self = this;
|
|
744
|
+
var lookup = chatEngineConfig().csrHistoryItemLookup;
|
|
745
|
+
if (!lookup || !itemIds || !itemIds.length) return Promise.resolve();
|
|
746
|
+
var id = this.host.getIdentity();
|
|
747
|
+
var platform = id.platform;
|
|
748
|
+
if (!id.projectId || (platform !== 'claude' && platform !== 'openai')) return Promise.resolve();
|
|
749
|
+
var chatKey = this.getHistoryCacheKey();
|
|
750
|
+
if (!chatKey) return Promise.resolve();
|
|
751
|
+
var jobs = itemIds.map(function (itemId) {
|
|
752
|
+
if (!itemId) return Promise.resolve();
|
|
753
|
+
var already = self._hydratedBodies[chatKey] && self._hydratedBodies[chatKey][itemId] !== undefined;
|
|
754
|
+
var inflightKey = chatKey + '|' + itemId;
|
|
755
|
+
if (already || self._hydratingItems[inflightKey]) return Promise.resolve();
|
|
756
|
+
self._hydratingItems[inflightKey] = true;
|
|
757
|
+
return Promise.resolve(lookup(buildHistoryItemFullId(platform as 'claude' | 'openai', id.projectId, itemId), id.projectId, id.owner)).then(function (body: any) {
|
|
758
|
+
// Chat may have switched while the lookup flew; the memo is
|
|
759
|
+
// per-chat so record under the key we started with, and only
|
|
760
|
+
// touch the live list if it is still that chat's.
|
|
761
|
+
var text = ((platform === 'openai' ? extractOpenAIText(body) : extractClaudeText(body)) || '').trim();
|
|
762
|
+
if (text.indexOf(INDEXING_COMPLETE_MARKER) !== -1) text = text.split(INDEXING_COMPLETE_MARKER).join('').trim();
|
|
763
|
+
if (!self._hydratedBodies[chatKey]) self._hydratedBodies[chatKey] = {};
|
|
764
|
+
self._hydratedBodies[chatKey][itemId] = text;
|
|
765
|
+
if (self.getHistoryCacheKey() !== chatKey) return;
|
|
766
|
+
for (var i = 0; i < self.state.messages.length; i++) {
|
|
767
|
+
var m: any = self.state.messages[i];
|
|
768
|
+
if (m && m._compact && m.role === 'assistant' && m._serverItemId === itemId) {
|
|
769
|
+
m.content = sanitizeAttachmentLinksForHistory(text, id.projectId, true) || EMPTY_INDEXING_REPLY;
|
|
770
|
+
delete m._compact;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}).catch(function () { /* stub stays; expand retries */ }).then(function () {
|
|
774
|
+
delete self._hydratingItems[inflightKey];
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
return Promise.all(jobs).then(function () {
|
|
778
|
+
if (self.getHistoryCacheKey() !== chatKey) return;
|
|
779
|
+
self.host.notify();
|
|
780
|
+
self.updateHistoryCache();
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
691
784
|
updateHistoryCache(): void {
|
|
692
785
|
var key = this.getHistoryCacheKey();
|
|
693
786
|
if (!key) return;
|
|
@@ -1069,10 +1162,13 @@ export class ChatSession {
|
|
|
1069
1162
|
res(v);
|
|
1070
1163
|
};
|
|
1071
1164
|
bail = setTimeout(function () { settle(null); }, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1165
|
+
// maxAgeMs 0: this loop is WAITING for the queue to change, so a
|
|
1166
|
+
// cached answer is useless to it — but its fresh answers write
|
|
1167
|
+
// through into the shared probe memo for the snapshot readers.
|
|
1168
|
+
Promise.resolve(probeBgQueue(
|
|
1169
|
+
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status, limit: WORKER_PASS_ADOPT_LIMIT },
|
|
1170
|
+
{ maxAgeMs: 0 },
|
|
1171
|
+
)).then(function (entry: any) { settle(entry.result); }, function () { settle(null); });
|
|
1076
1172
|
});
|
|
1077
1173
|
};
|
|
1078
1174
|
// Ordinary chats are routed onto this queue too (_isOnBgQueue), and those
|
|
@@ -1837,6 +1933,32 @@ export class ChatSession {
|
|
|
1837
1933
|
if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
|
|
1838
1934
|
});
|
|
1839
1935
|
this.state.stoppedIndexIds = stoppedIds;
|
|
1936
|
+
// Close the durable run:: record too — inside this !finished guard on
|
|
1937
|
+
// purpose, for the same reason as stoppedIndexIds: a stop that landed
|
|
1938
|
+
// after the run was over must not relabel an indexed file as cancelled.
|
|
1939
|
+
// Path over key: `key` degrades to a bare filename for path-less compact
|
|
1940
|
+
// stubs, and run:: is keyed by storage path only — no derivable path
|
|
1941
|
+
// means no record to close (restored stubs of old runs), which is fine.
|
|
1942
|
+
var runPath = group.path || '';
|
|
1943
|
+
if (!runPath) {
|
|
1944
|
+
(group.members || []).some(function (m) {
|
|
1945
|
+
var p = m && m.msg && m.msg._indexFile && m.msg._indexFile.path;
|
|
1946
|
+
if (p) { runPath = p; return true; }
|
|
1947
|
+
return false;
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
if (!runPath) {
|
|
1951
|
+
this.bgTaskQueue.some(function (e) {
|
|
1952
|
+
if (e && e.storagePath && self._indexKeyOf(e) === scoped) { runPath = e.storagePath; return true; }
|
|
1953
|
+
return false;
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
if (runPath) {
|
|
1957
|
+
var ident = this.host.getIdentity();
|
|
1958
|
+
if (ident && ident.projectId) {
|
|
1959
|
+
upsertIndexRunRecordSafe(ident.projectId, runPath, { status: 'cancelled', finished: Date.now() });
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1840
1962
|
}
|
|
1841
1963
|
// Ask the QUEUE what else is live for this file, and cancel that too.
|
|
1842
1964
|
//
|
|
@@ -2449,7 +2571,7 @@ export class ChatSession {
|
|
|
2449
2571
|
* shape that previously looped fetchHistoryPage after an already-DONE index.
|
|
2450
2572
|
*/
|
|
2451
2573
|
private _adoptingWorkerPasses = false;
|
|
2452
|
-
private _adoptWorkerIndexingPasses(attempt: number): void {
|
|
2574
|
+
private _adoptWorkerIndexingPasses(attempt: number, passive?: boolean): void {
|
|
2453
2575
|
var self = this;
|
|
2454
2576
|
// One in flight at a time. The query is queue-WIDE, so a second file's pass
|
|
2455
2577
|
// settling in the same moment is covered by the query already running.
|
|
@@ -2460,11 +2582,15 @@ export class ChatSession {
|
|
|
2460
2582
|
if (this.isPollingPaused() || !this.host.isViewMounted()) return;
|
|
2461
2583
|
var svcId = id.projectId, owner = id.owner;
|
|
2462
2584
|
var queue = bgIndexingQueueName(id.userId, id.projectId);
|
|
2585
|
+
// maxAgeMs 0: adoption exists to notice passes the worker JUST minted,
|
|
2586
|
+
// so it must always look fresh — but its answers write through into the
|
|
2587
|
+
// shared probe memo, which is what keeps the snapshot readers (and the
|
|
2588
|
+
// db-files page's badge probe) from re-asking seconds later.
|
|
2463
2589
|
var ask = function (status: 'pending' | 'running') {
|
|
2464
|
-
return Promise.resolve(
|
|
2465
|
-
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
|
|
2466
|
-
{
|
|
2467
|
-
)).catch(function () { return null; });
|
|
2590
|
+
return Promise.resolve(probeBgQueue(
|
|
2591
|
+
{ service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status, limit: WORKER_PASS_ADOPT_LIMIT },
|
|
2592
|
+
{ maxAgeMs: 0 },
|
|
2593
|
+
)).then(function (entry) { return entry.result; }).catch(function () { return null; });
|
|
2468
2594
|
};
|
|
2469
2595
|
this._adoptingWorkerPasses = true;
|
|
2470
2596
|
Promise.all([ask('running'), ask('pending')]).then(function (results: any[]) {
|
|
@@ -2505,6 +2631,15 @@ export class ChatSession {
|
|
|
2505
2631
|
// empty queue for a chain that is very much alive — and with no pass
|
|
2506
2632
|
// left to settle, nothing would ever ask again. Look once or twice more
|
|
2507
2633
|
// before believing the file is finished.
|
|
2634
|
+
//
|
|
2635
|
+
// PASSIVE calls (first-page/resume refresh) climb only on EVIDENCE of
|
|
2636
|
+
// live work — a queued local entry for this project, a live key the
|
|
2637
|
+
// look above just recorded, or an attached poll. The gap rationale
|
|
2638
|
+
// presumes a chain was alive; with nothing suggesting one, the climb
|
|
2639
|
+
// re-asked an unchanged question three more times on every tab return.
|
|
2640
|
+
// (No nudge here either: a send waiting on indexing implies local
|
|
2641
|
+
// entries, which IS evidence, so the passive stop never starves it.)
|
|
2642
|
+
if (passive && !self._hasLiveIndexEvidence(svcId)) return;
|
|
2508
2643
|
if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
|
|
2509
2644
|
// The ladder is the only thing that can tell a worker-driven chain has
|
|
2510
2645
|
// really ended, and it just did. This is the earliest honest moment to
|
|
@@ -2516,11 +2651,29 @@ export class ChatSession {
|
|
|
2516
2651
|
var later = self.host.getIdentity();
|
|
2517
2652
|
if (later.projectId !== svcId || later.platform !== platform) return;
|
|
2518
2653
|
if (self.isPollingPaused() || !self.host.isViewMounted()) return;
|
|
2519
|
-
self._adoptWorkerIndexingPasses(attempt + 1);
|
|
2654
|
+
self._adoptWorkerIndexingPasses(attempt + 1, passive);
|
|
2520
2655
|
}, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
|
|
2521
2656
|
}, function () { self._adoptingWorkerPasses = false; });
|
|
2522
2657
|
}
|
|
2523
2658
|
|
|
2659
|
+
/** Anything at all suggesting THIS project's indexing may be live: a queued
|
|
2660
|
+
* local entry, a recorded live key (the adopt look just wrote them), or an
|
|
2661
|
+
* attached poll. Gates the passive adopt ladder's climb. */
|
|
2662
|
+
private _hasLiveIndexEvidence(svcId: string): boolean {
|
|
2663
|
+
for (var i = 0; i < this.bgTaskQueue.length; i++) {
|
|
2664
|
+
var e = this.bgTaskQueue[i];
|
|
2665
|
+
if (e && e.projectId === svcId) return true;
|
|
2666
|
+
}
|
|
2667
|
+
var keys = this.state.liveIndexKeys || {};
|
|
2668
|
+
for (var k in keys) { if (keys[k]) return true; }
|
|
2669
|
+
// BG polls only: a pending ORDINARY chat turn also holds a poll, and
|
|
2670
|
+
// counting it made every tab return during a conversation climb the
|
|
2671
|
+
// full passive ladder for indexing that does not exist.
|
|
2672
|
+
var found = false;
|
|
2673
|
+
this.historyItemPolls.forEach(function (h: any) { if (h && h.kind === 'bg') found = true; });
|
|
2674
|
+
return found;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2524
2677
|
/** Any of these ids still queued or still polled, i.e. surviving work. */
|
|
2525
2678
|
private _isTrackingAny(ids: string[]): boolean {
|
|
2526
2679
|
for (var i = 0; i < ids.length; i++) {
|
|
@@ -2631,7 +2784,16 @@ export class ChatSession {
|
|
|
2631
2784
|
for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
|
|
2632
2785
|
var e = this.bgTaskQueue[i];
|
|
2633
2786
|
if (e.projectId !== svcId || e.platform !== plat) continue;
|
|
2634
|
-
if (presentIds[e.id] && !pendingIds[e.id])
|
|
2787
|
+
if (presentIds[e.id] && !pendingIds[e.id]) {
|
|
2788
|
+
// Settled while nobody was polling (hidden tab, dead poll): the
|
|
2789
|
+
// answer reached the chat as HISTORY, so no settle handler ever
|
|
2790
|
+
// ran for this entry — maybeResumeIndexing's flips included. For
|
|
2791
|
+
// a single-pass file that settle IS the whole run; close its
|
|
2792
|
+
// records off the settled bubbles' own flags before the entry
|
|
2793
|
+
// goes. (8 of 10 files in a real batch hit exactly this.)
|
|
2794
|
+
this._flipRunFromSettledEntry(e);
|
|
2795
|
+
this.bgTaskQueue.splice(i, 1);
|
|
2796
|
+
}
|
|
2635
2797
|
}
|
|
2636
2798
|
// Poll budget for this drain (see MAX_CONCURRENT_BG_POLLS). bgTaskQueue is
|
|
2637
2799
|
// in push order, i.e. oldest first, which is also the order the server
|
|
@@ -2755,6 +2917,13 @@ export class ChatSession {
|
|
|
2755
2917
|
// looks into the window where the new pass is not yet in the status
|
|
2756
2918
|
// index, which is the same way a nudge on work STARTING went wrong.
|
|
2757
2919
|
if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
|
|
2920
|
+
// Also close the run record: for a CLIENT-driven file a failed
|
|
2921
|
+
// pass ends the chain on every path, and a removed row reads as
|
|
2922
|
+
// a cancel. Worker-driven files are excluded for the same reason
|
|
2923
|
+
// as the nudge — this catch fires on dropped polls too, and the
|
|
2924
|
+
// worker may be writing the next pass regardless.
|
|
2925
|
+
if (isNotExists) self._flipRunRecord(capturedEntry, 'cancelled');
|
|
2926
|
+
else self._flipRunRecord(capturedEntry, 'error', self._runErrorText(err));
|
|
2758
2927
|
self._nudgeIndexingDrain();
|
|
2759
2928
|
}
|
|
2760
2929
|
}).then(function () {
|
|
@@ -2795,6 +2964,76 @@ export class ChatSession {
|
|
|
2795
2964
|
// memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
|
|
2796
2965
|
// completion, which on an 88-page file happened at page 15. Continuing to dispatch here
|
|
2797
2966
|
// as well would now double-index every window.
|
|
2967
|
+
/** Fire the consumer's done::-marker hook for a run whose completion this
|
|
2968
|
+
* client knows DETERMINISTICALLY (see the two call sites in
|
|
2969
|
+
* maybeResumeIndexing). Best-effort by contract; identity-checked so a
|
|
2970
|
+
* project switch mid-settle cannot stamp the wrong service. */
|
|
2971
|
+
_mintDoneMarker(entry: BgTaskEntry): void {
|
|
2972
|
+
try {
|
|
2973
|
+
var mint = chatEngineConfig().mintIndexDoneMarker;
|
|
2974
|
+
if (!mint || !entry || !entry.storagePath || !entry.projectId) return;
|
|
2975
|
+
var id = this.host.getIdentity();
|
|
2976
|
+
if (!id || id.projectId !== entry.projectId) return;
|
|
2977
|
+
mint({ service: entry.projectId, storagePath: entry.storagePath });
|
|
2978
|
+
} catch (_e) { /* best-effort */ }
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
/** Short, storable form of an error body for the run:: record. */
|
|
2982
|
+
_runErrorText(response: any): string {
|
|
2983
|
+
var msg = '';
|
|
2984
|
+
try { msg = String(getErrorMessage(response) || ''); } catch (_e) { }
|
|
2985
|
+
msg = msg.replace(/\s+/g, ' ').trim();
|
|
2986
|
+
return msg ? msg.slice(0, 300) : 'Indexing failed.';
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/** Close the records of a run whose pass settled OFF-POLL — the answer came
|
|
2990
|
+
* back as history (hidden tab, dead poll, resume refetch), so none of the
|
|
2991
|
+
* poll-side settle handlers ran. Only for SINGLE-PASS files, where one
|
|
2992
|
+
* settled pass is deterministically the whole run (the same contract as
|
|
2993
|
+
* maybeResumeIndexing's single-pass branch); paged files stay with their
|
|
2994
|
+
* drivers. Outcome is read from the settled bubbles' own flags, which is
|
|
2995
|
+
* all the history mapping left us. Best-effort and idempotent throughout. */
|
|
2996
|
+
_flipRunFromSettledEntry(entry: BgTaskEntry): void {
|
|
2997
|
+
try {
|
|
2998
|
+
if (!entry || !entry.storagePath || !entry.id || !entry.projectId) return;
|
|
2999
|
+
if (isPagedReadFile(entry.filename, entry.mime)) return;
|
|
3000
|
+
// A stopped run's verdict belongs to the stop funnel — never let a
|
|
3001
|
+
// late clean settle of its pass relabel 'cancelled' as 'done'.
|
|
3002
|
+
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
3003
|
+
if (this.state.stoppedIndexIds[entry.id]) return;
|
|
3004
|
+
var userMsg: any = null, replyMsg: any = null;
|
|
3005
|
+
this.state.messages.forEach(function (m: any) {
|
|
3006
|
+
if (m._serverItemId !== entry.id) return;
|
|
3007
|
+
if (m.role === 'user') { if (!userMsg) userMsg = m; }
|
|
3008
|
+
else if (!replyMsg) replyMsg = m;
|
|
3009
|
+
});
|
|
3010
|
+
if ((userMsg && userMsg.isCancelled) || (replyMsg && replyMsg.isCancelled)) {
|
|
3011
|
+
this._flipRunRecord(entry, 'cancelled');
|
|
3012
|
+
} else if (replyMsg && replyMsg.isError) {
|
|
3013
|
+
var errText = typeof replyMsg.content === 'string'
|
|
3014
|
+
? replyMsg.content.replace(/\s+/g, ' ').trim().slice(0, 300) : '';
|
|
3015
|
+
this._flipRunRecord(entry, 'error', errText || 'Indexing failed.');
|
|
3016
|
+
} else if (replyMsg) {
|
|
3017
|
+
this._mintDoneMarker(entry);
|
|
3018
|
+
this._flipRunRecord(entry, 'done');
|
|
3019
|
+
}
|
|
3020
|
+
// No reply bubble at all: nothing to judge from — leave the record.
|
|
3021
|
+
} catch (_e) { /* best-effort */ }
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
/** Close the durable run:: record for an ending THIS client observed.
|
|
3025
|
+
* service comes from the ENTRY, not the current identity: unlike the done::
|
|
3026
|
+
* mint above, a status flip must land even if the user switched projects
|
|
3027
|
+
* mid-settle — otherwise the record lies 'working' forever. Best-effort
|
|
3028
|
+
* through upsertIndexRunRecordSafe; the consumer's precedence guard keeps
|
|
3029
|
+
* repeats and races harmless. */
|
|
3030
|
+
_flipRunRecord(entry: BgTaskEntry, status: IndexRunStatus, error?: string): void {
|
|
3031
|
+
if (!entry || !entry.storagePath || !entry.projectId) return;
|
|
3032
|
+
var patch: IndexRunPatch = { status: status, finished: Date.now() };
|
|
3033
|
+
if (error) patch.error = error;
|
|
3034
|
+
upsertIndexRunRecordSafe(entry.projectId, entry.storagePath, patch);
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2798
3037
|
maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
|
|
2799
3038
|
var self = this;
|
|
2800
3039
|
// This client is the ONLY driver of the chains that reach the returns below,
|
|
@@ -2811,7 +3050,31 @@ export class ChatSession {
|
|
|
2811
3050
|
// pass here is exactly what "stop" has to prevent — the cancelled pass
|
|
2812
3051
|
// settles, and without this the chain simply carries on.
|
|
2813
3052
|
if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
|
|
2814
|
-
if (!isPagedReadFile(entry.filename, entry.mime)) {
|
|
3053
|
+
if (!isPagedReadFile(entry.filename, entry.mime)) {
|
|
3054
|
+
// Single-pass file: one settled pass IS the whole job. This is
|
|
3055
|
+
// deterministic knowledge (this client is the only dispatcher),
|
|
3056
|
+
// so record the durable done:: marker — single-pass runs never
|
|
3057
|
+
// get one from the worker. Errors don't count as completion
|
|
3058
|
+
// (this branch sits above the error check on purpose: the chain
|
|
3059
|
+
// is over either way, but only success gets a marker), and
|
|
3060
|
+
// neither does a CANCELLED poll shape — a row stopped from
|
|
3061
|
+
// another tab/device settles through here as {status:
|
|
3062
|
+
// 'cancelled', queue fields} with no provider payload, and
|
|
3063
|
+
// cancelledIndexKeys only records stops issued from THIS tab.
|
|
3064
|
+
if (!isErrorResponseBody(response) && !this._isCancelledPollResult(response)) {
|
|
3065
|
+
this._mintDoneMarker(entry);
|
|
3066
|
+
this._flipRunRecord(entry, 'done');
|
|
3067
|
+
} else if (this._isCancelledPollResult(response)) {
|
|
3068
|
+
// Stopped elsewhere (another tab/device): the stopping side
|
|
3069
|
+
// usually wrote 'cancelled' already; writing it again is a
|
|
3070
|
+
// same-value terminal update, and it closes the record when
|
|
3071
|
+
// the stopping tab died before its write landed.
|
|
3072
|
+
this._flipRunRecord(entry, 'cancelled');
|
|
3073
|
+
} else {
|
|
3074
|
+
this._flipRunRecord(entry, 'error', this._runErrorText(response));
|
|
3075
|
+
}
|
|
3076
|
+
endOfClientChain(); return;
|
|
3077
|
+
}
|
|
2815
3078
|
if (isImageVisionFile(entry.filename, entry.mime)) return; // worker owns this loop (PDF vision)
|
|
2816
3079
|
// When windowed indexing is on, the WORKER drives the text/grid loop too. The
|
|
2817
3080
|
// client MUST NOT also resume, or two drivers each enqueue a continuation per
|
|
@@ -2819,13 +3082,41 @@ export class ChatSession {
|
|
|
2819
3082
|
// PDFs early-return above. Gated on the flag so the old client-driven path is
|
|
2820
3083
|
// untouched when windowing is off.
|
|
2821
3084
|
if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
|
|
2822
|
-
if (isErrorResponseBody(response)) {
|
|
3085
|
+
if (isErrorResponseBody(response)) {
|
|
3086
|
+
// A failed pass is not "incomplete" — the client-driven chain is over.
|
|
3087
|
+
this._flipRunRecord(entry, 'error', this._runErrorText(response));
|
|
3088
|
+
endOfClientChain(); return;
|
|
3089
|
+
}
|
|
2823
3090
|
var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
|
|
2824
|
-
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
|
|
3091
|
+
if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
|
|
3092
|
+
// Client-driven chain, and the model's reply carried the
|
|
3093
|
+
// completion token — the same signal the worker requires before
|
|
3094
|
+
// minting for its own chains. Deterministic here: this client
|
|
3095
|
+
// is the only dispatcher and it is deciding, right now, that no
|
|
3096
|
+
// further pass will run.
|
|
3097
|
+
this._mintDoneMarker(entry);
|
|
3098
|
+
this._flipRunRecord(entry, 'done');
|
|
3099
|
+
endOfClientChain(); return; // fully indexed
|
|
3100
|
+
}
|
|
2825
3101
|
var pass = (entry.resumePass || 0) + 1;
|
|
2826
|
-
if (pass > MAX_INDEXING_RESUME_PASSES) {
|
|
3102
|
+
if (pass > MAX_INDEXING_RESUME_PASSES) {
|
|
3103
|
+
// Gave up after the cap: the file is only partially indexed, and no
|
|
3104
|
+
// further pass will ever run. 'error' rather than 'done' — the badge
|
|
3105
|
+
// must not claim a file the chain abandoned.
|
|
3106
|
+
this._flipRunRecord(entry, 'error', 'Stopped after ' + MAX_INDEXING_RESUME_PASSES + ' passes without finishing.');
|
|
3107
|
+
endOfClientChain(); return;
|
|
3108
|
+
}
|
|
2827
3109
|
var id = this.host.getIdentity();
|
|
2828
|
-
if (!id || id.platform === 'none' || id.projectId !== entry.projectId)
|
|
3110
|
+
if (!id || id.platform === 'none' || id.projectId !== entry.projectId) {
|
|
3111
|
+
// This client is the only driver of this chain and it just declined
|
|
3112
|
+
// to dispatch the next pass — the chain is over, as an ending, not a
|
|
3113
|
+
// pause: the queue entry is spliced on settle and nothing revisits
|
|
3114
|
+
// it. Close the record (it writes with entry.projectId, so it lands
|
|
3115
|
+
// after a project switch) or it lies 'working' for the stale window.
|
|
3116
|
+
this._flipRunRecord(entry, 'error', 'Indexing stopped: the session or project changed before the file finished.');
|
|
3117
|
+
endOfClientChain();
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
2829
3120
|
// Counted as live work from here, not from the ack: awaitIndexingDrained
|
|
2830
3121
|
// asks the SERVER what is queued, and this pass is not queued until the
|
|
2831
3122
|
// call below returns. Without it a chat can slip in between two passes.
|
|
@@ -2905,8 +3196,15 @@ export class ChatSession {
|
|
|
2905
3196
|
var projectId = id.projectId, owner = id.owner;
|
|
2906
3197
|
var options: any = { fetchMore: fetchMore };
|
|
2907
3198
|
if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
|
|
3199
|
+
// First page: paint the CONVERSATION immediately and let the indexing
|
|
3200
|
+
// stubs arrive as a deferred batch (merged below when it lands).
|
|
3201
|
+
if (!fetchMore) options.deferBg = true;
|
|
2908
3202
|
|
|
2909
|
-
|
|
3203
|
+
// Split fetch v2: surface (full bodies, bg queue excluded) + bg compact
|
|
3204
|
+
// stubs, tiled to the surface page's time window with module-level
|
|
3205
|
+
// state (buffering + retry safety) — see getSplitChatHistory. Returns
|
|
3206
|
+
// the exact single-fetch shape; startKeyHistory is bookkeeping only.
|
|
3207
|
+
var fetchHistory = function () { return getSplitChatHistory({ service: projectId, owner: owner, platform: platform, userId: id.userId }, options); };
|
|
2910
3208
|
|
|
2911
3209
|
return Promise.resolve().then(fetchHistory).catch(function (err: any) {
|
|
2912
3210
|
if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
|
|
@@ -2916,7 +3214,11 @@ export class ChatSession {
|
|
|
2916
3214
|
var chatList = history && Array.isArray(history.list) ? history.list : [];
|
|
2917
3215
|
chatList.forEach(function (item: any) {
|
|
2918
3216
|
if (isBgIndexingQueue(item.queue_name)) {
|
|
2919
|
-
|
|
3217
|
+
// Compact stubs carry the label as request_text — the body
|
|
3218
|
+
// never left the server — so classify from whichever form
|
|
3219
|
+
// this item arrived in.
|
|
3220
|
+
var clsText = item.compact ? item.request_text : extractLastUserTextFromRequest(item.request_body);
|
|
3221
|
+
if (isIndexingRequestText(clsText)) item._isBgTask = true;
|
|
2920
3222
|
else item._isOnBgQueue = true;
|
|
2921
3223
|
}
|
|
2922
3224
|
});
|
|
@@ -2929,17 +3231,77 @@ export class ChatSession {
|
|
|
2929
3231
|
projectId: id.projectId,
|
|
2930
3232
|
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
2931
3233
|
}).messages;
|
|
3234
|
+
// Re-apply any previously-hydrated compact bodies: a refresh maps
|
|
3235
|
+
// fresh stubs, and without this a bubble the user already expanded
|
|
3236
|
+
// would silently revert to its 200-char head.
|
|
3237
|
+
self.applyHydratedBodies(mapped);
|
|
2932
3238
|
|
|
2933
3239
|
// Set when a first-page refresh re-prepended already-loaded older pages,
|
|
2934
3240
|
// so the cursor reset below knows not to rewind to page 1's boundary.
|
|
2935
3241
|
var keptOlderPages = false;
|
|
3242
|
+
// The empty-page-1 keep below was taken ONLY on the strength of a
|
|
3243
|
+
// deferred batch still flying — if that batch then proves the chat
|
|
3244
|
+
// empty server-side, the kept screen must be let go (see the batch
|
|
3245
|
+
// handler), or an emptied transcript is indelible.
|
|
3246
|
+
var keptScreenAwaitingBg = false;
|
|
2936
3247
|
|
|
2937
3248
|
if (fetchMore) {
|
|
2938
|
-
|
|
3249
|
+
// STRIP-THEN-MERGE keyed on SERVER ITEM ID. Incoming bubbles are
|
|
3250
|
+
// the fresher server state (queued→running→settled transitions),
|
|
3251
|
+
// so existing copies of incoming id|role bubbles are REMOVED first
|
|
3252
|
+
// (in-flight cancel flags carried over), then the page merges by
|
|
3253
|
+
// item id — ids are creation-ordered and BOTH lists are already
|
|
3254
|
+
// id-sorted, unlike _ts (assistant _ts is `updated`, which any
|
|
3255
|
+
// long-running turn makes non-monotonic and would misorder the
|
|
3256
|
+
// walk). Id-less bubbles on screen (staged/local, always the tail)
|
|
3257
|
+
// end the merge region; id-less incoming sorts first (oldest).
|
|
3258
|
+
var incomingKeys: any = {};
|
|
3259
|
+
mapped.forEach(function (m: any) { if (m._serverItemId) incomingKeys[m._serverItemId + '|' + m.role] = m; });
|
|
3260
|
+
var existing = self.state.messages.filter(function (m: any) {
|
|
3261
|
+
if (!m._serverItemId) return true;
|
|
3262
|
+
var inc = incomingKeys[m._serverItemId + '|' + m.role];
|
|
3263
|
+
if (!inc) return true;
|
|
3264
|
+
if (m._cancelling) inc._cancelling = m._cancelling;
|
|
3265
|
+
if (m._cancelError) inc._cancelError = m._cancelError;
|
|
3266
|
+
return false;
|
|
3267
|
+
});
|
|
3268
|
+
var mergedList: ChatMessage[] = [];
|
|
3269
|
+
var pi = 0, ei = 0;
|
|
3270
|
+
while (pi < mapped.length && ei < existing.length) {
|
|
3271
|
+
var pm: any = mapped[pi], em: any = existing[ei];
|
|
3272
|
+
var eid = em._serverItemId;
|
|
3273
|
+
if (typeof eid !== 'string') break; // staged/local tail
|
|
3274
|
+
var pid = pm._serverItemId;
|
|
3275
|
+
if (typeof pid !== 'string' || pid <= eid) { mergedList.push(pm); pi++; }
|
|
3276
|
+
else { mergedList.push(em); ei++; }
|
|
3277
|
+
}
|
|
3278
|
+
while (pi < mapped.length) mergedList.push(mapped[pi++]);
|
|
3279
|
+
while (ei < existing.length) mergedList.push(existing[ei++]);
|
|
3280
|
+
self.state.messages = mergedList;
|
|
3281
|
+
} else if (!mapped.length && history && (history.endOfList === false || (history as any).bgPending)
|
|
3282
|
+
&& self.state.messages.some(function (m: any) { return m._ownerKey === undefined || m._ownerKey === loadKey; })) {
|
|
3283
|
+
// Empty first page that cannot judge the chat empty: either
|
|
3284
|
+
// empty-but-not-end (a pure-bg band past every hop cap), or an
|
|
3285
|
+
// empty DEFERRED page whose bg batch is still flying — a chat
|
|
3286
|
+
// whose whole conversation rides the bg queue has a genuinely
|
|
3287
|
+
// empty surface (endOfList true on a head refresh), and the
|
|
3288
|
+
// batch carries everything. REPLACING with it would blank the
|
|
3289
|
+
// chat for the batch's flight time — the greeting flashed over
|
|
3290
|
+
// real messages on every tab return. Keep what is on screen.
|
|
3291
|
+
// Kept ONLY on the batch's word: the batch handler revisits.
|
|
3292
|
+
if (history.endOfList !== false) keptScreenAwaitingBg = true;
|
|
2939
3293
|
} else {
|
|
2940
3294
|
if (self.state.typing) self.state.typingAbort = true;
|
|
2941
3295
|
var serverIds: any = {};
|
|
2942
3296
|
mapped.forEach(function (m: any) { if (m._serverItemId) serverIds[m._serverItemId] = 1; });
|
|
3297
|
+
// The retention boundary must be the SURFACE frontier: page 1 also
|
|
3298
|
+
// emits bg stubs reaching arbitrarily deep, and keying retention on
|
|
3299
|
+
// a weeks-old stub id would wipe (or gap) the scrolled-in pages.
|
|
3300
|
+
var surfaceOldestId: string | undefined = undefined;
|
|
3301
|
+
mapped.forEach(function (m: any) {
|
|
3302
|
+
if (typeof m._serverItemId !== 'string' || m._fromBgChain) return;
|
|
3303
|
+
if (surfaceOldestId === undefined || m._serverItemId < surfaceOldestId) surfaceOldestId = m._serverItemId;
|
|
3304
|
+
});
|
|
2943
3305
|
var locallyCancelled: any = {};
|
|
2944
3306
|
self.state.messages.forEach(function (m) { if (m.isCancelled && m._serverItemId) locallyCancelled[m._serverItemId] = m; });
|
|
2945
3307
|
// A cancel the server has not acknowledged yet. Without carrying these
|
|
@@ -3009,7 +3371,13 @@ export class ChatSession {
|
|
|
3009
3371
|
var sharesPage1 = self.state.messages.some(function (m) {
|
|
3010
3372
|
return typeof m._serverItemId === 'string' && !!serverIds[m._serverItemId as string];
|
|
3011
3373
|
});
|
|
3012
|
-
var
|
|
3374
|
+
var deferredBg = !!(history && (history as any).bgPending);
|
|
3375
|
+
// Boundary for retention: the SURFACE frontier when page 1 has any
|
|
3376
|
+
// surface item (bg stubs can reach weeks deeper and would otherwise
|
|
3377
|
+
// wipe or gap the scrolled-in pages); whole-page oldest only as the
|
|
3378
|
+
// fallback for a surface-empty page.
|
|
3379
|
+
var retainBoundary: string | undefined = surfaceOldestId !== undefined ? surfaceOldestId : oldestInPage1;
|
|
3380
|
+
var retainedOlder: ChatMessage[] = (!sharesPage1 || retainBoundary === undefined) ? [] : self.state.messages.filter(function (m) {
|
|
3013
3381
|
// Settled server history only; in-flight bubbles are the rescue
|
|
3014
3382
|
// loop's job below and would otherwise be kept twice.
|
|
3015
3383
|
if (typeof m._serverItemId !== 'string') return false;
|
|
@@ -3017,10 +3385,49 @@ export class ChatSession {
|
|
|
3017
3385
|
// project's bubbles onto this chat (the cross-project leak fix).
|
|
3018
3386
|
// Compare against the snapshotted loadKey, never a live read.
|
|
3019
3387
|
if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return false;
|
|
3020
|
-
|
|
3388
|
+
// While a deferred stub batch is in flight, KEEP already-loaded
|
|
3389
|
+
// indexing bubbles wherever they sit — the surface-only page has
|
|
3390
|
+
// none, and dropping them here would flicker every row until the
|
|
3391
|
+
// batch lands (its apply strips+re-merges them by id anyway).
|
|
3392
|
+
if (deferredBg && m.isBackgroundTask) return true;
|
|
3393
|
+
// Bg-chain bubbles already on screen stay put regardless of the
|
|
3394
|
+
// boundary — their ids interleave with deep history and the strip
|
|
3395
|
+
// in later merges replaces them when fresher copies arrive.
|
|
3396
|
+
if (m._fromBgChain) return true;
|
|
3397
|
+
return (m._serverItemId as string) < (retainBoundary as string);
|
|
3398
|
+
});
|
|
3399
|
+
// Prepend only the STRICTLY-older bubbles. Retained bg bubbles whose
|
|
3400
|
+
// ids sit inside page 1's own range are ID-MERGED into it instead —
|
|
3401
|
+
// blanket prepending hoisted every indexing row above the whole
|
|
3402
|
+
// conversation, and after a head refresh the single-page batch
|
|
3403
|
+
// never re-placed the deep ones, so they sat hoisted for good.
|
|
3404
|
+
var prependOlder: ChatMessage[] = [];
|
|
3405
|
+
var interleave: ChatMessage[] = [];
|
|
3406
|
+
retainedOlder.forEach(function (m) {
|
|
3407
|
+
var sid = m._serverItemId as string;
|
|
3408
|
+
if (serverIds[sid]) return; // page 1 carries a fresher copy
|
|
3409
|
+
if (retainBoundary !== undefined && sid < (retainBoundary as string)) prependOlder.push(m);
|
|
3410
|
+
else interleave.push(m);
|
|
3021
3411
|
});
|
|
3022
|
-
|
|
3023
|
-
|
|
3412
|
+
var page1: ChatMessage[] = mapped;
|
|
3413
|
+
if (interleave.length) {
|
|
3414
|
+
// Both sides are id-sorted oldest-first (retention preserves the
|
|
3415
|
+
// on-screen order; the mapper emits oldest-first).
|
|
3416
|
+
var mergedP: ChatMessage[] = [];
|
|
3417
|
+
var ii2 = 0, mi2 = 0;
|
|
3418
|
+
while (ii2 < interleave.length && mi2 < mapped.length) {
|
|
3419
|
+
var iv: any = interleave[ii2], mv: any = mapped[mi2];
|
|
3420
|
+
var mid2 = typeof mv._serverItemId === 'string' ? mv._serverItemId : undefined;
|
|
3421
|
+
if (mid2 === undefined) break; // defensive: id-less tail
|
|
3422
|
+
if ((iv._serverItemId as string) <= mid2) { mergedP.push(iv); ii2++; }
|
|
3423
|
+
else { mergedP.push(mv); mi2++; }
|
|
3424
|
+
}
|
|
3425
|
+
while (ii2 < interleave.length) mergedP.push(interleave[ii2++]);
|
|
3426
|
+
while (mi2 < mapped.length) mergedP.push(mapped[mi2++]);
|
|
3427
|
+
page1 = mergedP;
|
|
3428
|
+
}
|
|
3429
|
+
keptOlderPages = prependOlder.length > 0 || interleave.length > 0;
|
|
3430
|
+
self.state.messages = prependOlder.length ? prependOlder.concat(page1) : page1;
|
|
3024
3431
|
rescued.forEach(function (m) { self.state.messages.push(m); });
|
|
3025
3432
|
if (Object.keys(locallyCancelled).length) {
|
|
3026
3433
|
for (var ci = 0; ci < self.state.messages.length; ci++) {
|
|
@@ -3060,9 +3467,16 @@ export class ChatSession {
|
|
|
3060
3467
|
self.state.historyEndOfList = !!(history && history.endOfList);
|
|
3061
3468
|
self.state.historyStartKeyHistory = history && Array.isArray(history.startKeyHistory) ? history.startKeyHistory : [];
|
|
3062
3469
|
var clearedAt = self.host.getClearedAt();
|
|
3063
|
-
if (clearedAt
|
|
3064
|
-
|
|
3065
|
-
|
|
3470
|
+
if (clearedAt) {
|
|
3471
|
+
// SURFACE items only: page 1 also carries bg stubs reaching far
|
|
3472
|
+
// past the horizon, and reading their age as "the frontier is
|
|
3473
|
+
// pre-horizon" falsely ended the pager with post-horizon surface
|
|
3474
|
+
// pages still unfetched.
|
|
3475
|
+
var surfaceItems = chatList.filter(function (it: any) { return !(it && it._fromBgChain); });
|
|
3476
|
+
if (surfaceItems.length > 0) {
|
|
3477
|
+
var oldestUpdated = Number(surfaceItems[surfaceItems.length - 1] && surfaceItems[surfaceItems.length - 1].updated);
|
|
3478
|
+
if (isFinite(oldestUpdated) && oldestUpdated <= clearedAt) self.state.historyEndOfList = true;
|
|
3479
|
+
}
|
|
3066
3480
|
}
|
|
3067
3481
|
}
|
|
3068
3482
|
// Clear loading flags BEFORE this render so the final paint is
|
|
@@ -3071,6 +3485,96 @@ export class ChatSession {
|
|
|
3071
3485
|
self.updateHistoryCache();
|
|
3072
3486
|
self.host.notify();
|
|
3073
3487
|
|
|
3488
|
+
// ── deferred indexing-stub batch (first-paint split) ──────────────
|
|
3489
|
+
// The conversation is already on screen; when the stub batch lands it
|
|
3490
|
+
// is classified/mapped like any page and STRIP-THEN-MERGED: existing
|
|
3491
|
+
// copies of incoming ids are removed first (retained rows may sit at
|
|
3492
|
+
// provisional positions), then the batch merges by timestamp.
|
|
3493
|
+
var bgPending = !fetchMore && history && (history as any).bgPending;
|
|
3494
|
+
if (bgPending) {
|
|
3495
|
+
// Ownership id for the shared loading flag: a same-token refetch can
|
|
3496
|
+
// mint a SECOND bgPending, and the older handler settling later must
|
|
3497
|
+
// not clear (or keep) the flag the newer batch owns.
|
|
3498
|
+
var batchId = ++_bgHistoryBatchSeq;
|
|
3499
|
+
// SILENT unless this is the chat's genuine FIRST PAINT. Two quiet
|
|
3500
|
+
// cases: an ended chain's head refresh (endOfList already true),
|
|
3501
|
+
// and a MID-WALK tab return — the walk restarts for cursor
|
|
3502
|
+
// safety, but the conversation is already on screen and flashing
|
|
3503
|
+
// "Loading indexing history" over it on every return was the
|
|
3504
|
+
// reported bug. Only a never-walked chat announces the batch.
|
|
3505
|
+
if (history.endOfList !== true && (history as any).firstLoad === true) {
|
|
3506
|
+
self.state.bgHistoryLoading = true;
|
|
3507
|
+
self.host.notify();
|
|
3508
|
+
}
|
|
3509
|
+
var releaseBgFlag = function () {
|
|
3510
|
+
if (_bgHistoryBatchSeq === batchId) self.state.bgHistoryLoading = false;
|
|
3511
|
+
};
|
|
3512
|
+
(bgPending as Promise<any>).then(function (batch: any) {
|
|
3513
|
+
if (token !== self.state.gateRefreshToken) { releaseBgFlag(); return; }
|
|
3514
|
+
var bList: any[] = (batch && Array.isArray(batch.list)) ? batch.list : [];
|
|
3515
|
+
bList.forEach(function (item: any) {
|
|
3516
|
+
if (isBgIndexingQueue(item.queue_name)) {
|
|
3517
|
+
var t = item.compact ? item.request_text : extractLastUserTextFromRequest(item.request_body);
|
|
3518
|
+
if (isIndexingRequestText(t)) item._isBgTask = true;
|
|
3519
|
+
else item._isOnBgQueue = true;
|
|
3520
|
+
}
|
|
3521
|
+
});
|
|
3522
|
+
var sorted = bList.sort(function (a: any, b: any) {
|
|
3523
|
+
var ai = typeof a.id === 'string' ? a.id : '', bi = typeof b.id === 'string' ? b.id : '';
|
|
3524
|
+
return ai > bi ? -1 : (ai < bi ? 1 : 0);
|
|
3525
|
+
});
|
|
3526
|
+
var m2 = mapHistoryListToMessages(sorted, platform, {
|
|
3527
|
+
clearedAt: self.host.getClearedAt(),
|
|
3528
|
+
projectId: id.projectId,
|
|
3529
|
+
formatIndexingLabel: self.host.formatIndexingLabel,
|
|
3530
|
+
}).messages;
|
|
3531
|
+
self.applyHydratedBodies(m2);
|
|
3532
|
+
if (keptScreenAwaitingBg && !m2.length && batch && batch.endOfList === true) {
|
|
3533
|
+
// The empty page 1 was kept ONLY on this batch's word — and it
|
|
3534
|
+
// just proved the chat empty server-side (an emptied/cleared
|
|
3535
|
+
// transcript). Let the kept screen go: drop this chat's settled
|
|
3536
|
+
// server bubbles, keep staged and in-flight local work and any
|
|
3537
|
+
// other chat's strays for their own reconcilers, and remember
|
|
3538
|
+
// the end. Without this the stale transcript was indelible and
|
|
3539
|
+
// even re-persisted itself into the history cache.
|
|
3540
|
+
self.state.messages = self.state.messages.filter(function (m: any) {
|
|
3541
|
+
if (typeof m._serverItemId !== 'string') return true;
|
|
3542
|
+
if (m._ownerKey !== undefined && m._ownerKey !== loadKey) return true;
|
|
3543
|
+
return false;
|
|
3544
|
+
});
|
|
3545
|
+
self.state.historyEndOfList = true;
|
|
3546
|
+
releaseBgFlag();
|
|
3547
|
+
self.updateHistoryCache();
|
|
3548
|
+
self.host.notify();
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
var incoming: any = {};
|
|
3552
|
+
m2.forEach(function (m: any) { if (m._serverItemId) incoming[m._serverItemId + '|' + m.role] = true; });
|
|
3553
|
+
var baseList = self.state.messages.filter(function (m: any) { return !(m._serverItemId && incoming[m._serverItemId + '|' + m.role]); });
|
|
3554
|
+
var mergedList: ChatMessage[] = [];
|
|
3555
|
+
var pi2 = 0, ei2 = 0;
|
|
3556
|
+
while (pi2 < m2.length && ei2 < baseList.length) {
|
|
3557
|
+
var pm2: any = m2[pi2], em2: any = baseList[ei2];
|
|
3558
|
+
var eid2 = em2._serverItemId;
|
|
3559
|
+
if (typeof eid2 !== 'string') break; // staged/local tail
|
|
3560
|
+
var pid2 = pm2._serverItemId;
|
|
3561
|
+
if (typeof pid2 !== 'string' || pid2 <= eid2) { mergedList.push(pm2); pi2++; }
|
|
3562
|
+
else { mergedList.push(em2); ei2++; }
|
|
3563
|
+
}
|
|
3564
|
+
while (pi2 < m2.length) mergedList.push(m2[pi2++]);
|
|
3565
|
+
while (ei2 < baseList.length) mergedList.push(baseList[ei2++]);
|
|
3566
|
+
self.state.messages = mergedList;
|
|
3567
|
+
if (batch && batch.endOfList === true) self.state.historyEndOfList = true;
|
|
3568
|
+
releaseBgFlag();
|
|
3569
|
+
self.updateHistoryCache();
|
|
3570
|
+
self.host.notify();
|
|
3571
|
+
}, function () {
|
|
3572
|
+
// Stubs missing until the next refresh re-asks; never fatal.
|
|
3573
|
+
releaseBgFlag();
|
|
3574
|
+
self.host.notify();
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3074
3578
|
if (!fetchMore) {
|
|
3075
3579
|
// Ration BACKGROUND polls here exactly as the drain does (see
|
|
3076
3580
|
// MAX_CONCURRENT_BG_POLLS); foreground items — a reply the user is
|
|
@@ -3211,7 +3715,12 @@ export class ChatSession {
|
|
|
3211
3715
|
uploadSingleAttachment(att: any, stageId?: string): Promise<Array<{ name: string; url: string; storagePath: string }>> {
|
|
3212
3716
|
var self = this;
|
|
3213
3717
|
var id = this.host.getIdentity();
|
|
3214
|
-
|
|
3718
|
+
// progress stays null until the first byte-progress event. Every upload
|
|
3719
|
+
// opens with a get-signed-url round trip that reports nothing, so a chip
|
|
3720
|
+
// initialised to 0 rendered an empty, motionless bar for the whole
|
|
3721
|
+
// preparing phase and read as hung. null is the signal for "preparing"
|
|
3722
|
+
// (barber pole); a number means bytes are actually moving (real bar).
|
|
3723
|
+
att.status = 'uploading'; att.progress = null; att.errorMessage = '';
|
|
3215
3724
|
att.errorCode = ''; att.errorDetail = ''; // clear any prior failure (retry)
|
|
3216
3725
|
this.host.renderAttachmentChips();
|
|
3217
3726
|
var members = (att.kind === 'folder')
|