bunnyquery 1.8.13 → 1.8.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bunnyquery.css +8 -0
- package/bunnyquery.js +718 -104
- package/dist/engine.cjs +606 -52
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +544 -212
- package/dist/engine.d.ts +544 -212
- package/dist/engine.mjs +602 -53
- package/dist/engine.mjs.map +1 -1
- package/package.json +3 -2
- package/src/engine/history.ts +64 -0
- package/src/engine/host.ts +14 -0
- package/src/engine/image_preview.ts +0 -0
- package/src/engine/index.ts +17 -0
- package/src/engine/link_markup.ts +9 -1
- package/src/engine/links.ts +31 -3
- package/src/engine/scroll_anchor.ts +684 -0
- package/src/engine/session.ts +313 -61
- package/src/widget.css +8 -0
package/src/engine/session.ts
CHANGED
|
@@ -46,7 +46,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
|
|
|
46
46
|
import { buildBoundedChatMessages } from './budget';
|
|
47
47
|
import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
|
|
48
48
|
import { markImagePreviewStale } from './image_preview';
|
|
49
|
-
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory } from './history';
|
|
49
|
+
import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
|
|
50
50
|
import { wallClockNow } from './time';
|
|
51
51
|
import { parseAttachmentContent } from './attachment_parsers';
|
|
52
52
|
import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
|
|
@@ -87,6 +87,13 @@ const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
|
|
|
87
87
|
// asking often (an indexing pass takes tens of seconds, and a big file can hold
|
|
88
88
|
// the queue for many minutes), but once the queue looks empty the confirming
|
|
89
89
|
// look is all that stands between the user and their answer.
|
|
90
|
+
// Early point-lookups for a foreground reply, before the flat POLL_INTERVAL tick lands.
|
|
91
|
+
// Widening on purpose: the first two catch a short reply (a greeting, a one-line answer) that
|
|
92
|
+
// would otherwise idle until 3s, and after ~1.7s a reply is long enough that the 3s interval is
|
|
93
|
+
// no longer the dominant cost, so probing further would just add requests. Three probes is at
|
|
94
|
+
// most three extra point lookups per turn, and none of them fire once the reply has landed.
|
|
95
|
+
const EARLY_PROBE_SCHEDULE_MS = [400, 900, 1700];
|
|
96
|
+
|
|
90
97
|
const INDEXING_DRAIN_BUSY_POLL_MS = 8000;
|
|
91
98
|
const INDEXING_DRAIN_CONFIRM_POLL_MS = 3000;
|
|
92
99
|
const INDEXING_DRAIN_IDLE_LOOKS = 2;
|
|
@@ -595,7 +602,96 @@ export class ChatSession {
|
|
|
595
602
|
*
|
|
596
603
|
* `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
|
|
597
604
|
* poll simply cannot be stopped and is left running — see pausePolling.
|
|
605
|
+
*
|
|
606
|
+
* (This block documents _trackPoll, further down. The two methods below sit between it
|
|
607
|
+
* and its subject.)
|
|
598
608
|
*/
|
|
609
|
+
/**
|
|
610
|
+
* Foreground poll with an early-probe race.
|
|
611
|
+
*
|
|
612
|
+
* skapi's poll() is a bare setInterval(fn, latency) with NO check at t=0, so the earliest a
|
|
613
|
+
* reply can be observed is one full POLL_INTERVAL (3s) after dispatch. For a long generation
|
|
614
|
+
* that granularity is free. For a SHORT one it is nearly pure dead time: a greeting that the
|
|
615
|
+
* provider finishes in 1s still waits until the 3s tick, which measured as a large share of a
|
|
616
|
+
* 5s "yo" round trip.
|
|
617
|
+
*
|
|
618
|
+
* So keep the 3s interval as the steady state, and additionally point-look-up the item a few
|
|
619
|
+
* times early, on a widening schedule. Whichever answers first wins and the other is stopped.
|
|
620
|
+
* The probe uses the csrHistoryItemLookup hook both clients already implement; without it this
|
|
621
|
+
* degrades to exactly the old behaviour.
|
|
622
|
+
*
|
|
623
|
+
* FOREGROUND ONLY. Background indexing polls keep the flat cadence: nobody is watching them,
|
|
624
|
+
* and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
|
|
625
|
+
* the request budget the cap exists to protect.
|
|
626
|
+
*/
|
|
627
|
+
attachForegroundPoll(source: any, itemId: string, opts?: any): any {
|
|
628
|
+
return this._fgPollWithEarlyProbe(source, itemId, opts);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private _fgPollWithEarlyProbe(source: any, itemId: string, opts?: any): any {
|
|
632
|
+
var self = this;
|
|
633
|
+
// Callbacks ride along on the interval path exactly as before; the probe path below
|
|
634
|
+
// fires onResponse itself, because a caller that only reacts through the callback
|
|
635
|
+
// (the history drain does) would otherwise never learn the probe won.
|
|
636
|
+
var base = source.poll(Object.assign({ latency: POLL_INTERVAL }, opts || {}));
|
|
637
|
+
|
|
638
|
+
var lookup = chatEngineConfig().csrHistoryItemLookup;
|
|
639
|
+
var ident = this.host.getIdentity();
|
|
640
|
+
var platform = ident && ident.platform;
|
|
641
|
+
if (!lookup || !itemId || !ident || !ident.projectId
|
|
642
|
+
|| (platform !== 'claude' && platform !== 'openai')) {
|
|
643
|
+
return base;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
var settled = false;
|
|
647
|
+
var timers: any[] = [];
|
|
648
|
+
var clearProbes = function () {
|
|
649
|
+
for (var i = 0; i < timers.length; i++) clearTimeout(timers[i]);
|
|
650
|
+
timers = [];
|
|
651
|
+
};
|
|
652
|
+
var stopBase = base && typeof base.stop === 'function' ? base.stop.bind(base) : null;
|
|
653
|
+
|
|
654
|
+
var raced: any = new Promise(function (resolve, reject) {
|
|
655
|
+
base.then(function (res: any) {
|
|
656
|
+
if (settled) return;
|
|
657
|
+
settled = true; clearProbes(); resolve(res);
|
|
658
|
+
}, function (err: any) {
|
|
659
|
+
if (settled) return;
|
|
660
|
+
settled = true; clearProbes(); reject(err);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
var fullId = buildHistoryItemFullId(platform as 'claude' | 'openai', ident.projectId, itemId);
|
|
664
|
+
EARLY_PROBE_SCHEDULE_MS.forEach(function (delay) {
|
|
665
|
+
timers.push(setTimeout(function () {
|
|
666
|
+
if (settled) return;
|
|
667
|
+
Promise.resolve(lookup!(fullId, ident.projectId, ident.owner)).then(function (body: any) {
|
|
668
|
+
if (settled || !body || typeof body !== 'object') return;
|
|
669
|
+
// Still working: leave it to the next probe or the interval.
|
|
670
|
+
if (body.status === 'pending' || body.status === 'running') return;
|
|
671
|
+
// A stop is not a result; the normal stop path handles that.
|
|
672
|
+
if (isPollStopped(body)) return;
|
|
673
|
+
settled = true;
|
|
674
|
+
clearProbes();
|
|
675
|
+
// The interval would otherwise keep firing against a finished item.
|
|
676
|
+
if (stopBase) { try { stopBase(); } catch (e) { /* already gone */ } }
|
|
677
|
+
if (opts && typeof opts.onResponse === 'function') {
|
|
678
|
+
try { opts.onResponse(body); } catch (e) { /* caller's problem, not the poll's */ }
|
|
679
|
+
}
|
|
680
|
+
resolve(body);
|
|
681
|
+
}, function () { /* probe failures are not errors: the interval is the source of truth */ });
|
|
682
|
+
}, delay));
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// _trackPoll and the cancel path both reach for .stop, so the wrapper has to carry it.
|
|
687
|
+
raced.stop = function () {
|
|
688
|
+
settled = true;
|
|
689
|
+
clearProbes();
|
|
690
|
+
if (stopBase) stopBase();
|
|
691
|
+
};
|
|
692
|
+
return raced;
|
|
693
|
+
}
|
|
694
|
+
|
|
599
695
|
private _trackPoll(id: string, kind: 'fg' | 'bg', p: any): any {
|
|
600
696
|
var stop = p && typeof p.stop === 'function' ? p.stop.bind(p) : undefined;
|
|
601
697
|
if (!stop) {
|
|
@@ -816,6 +912,73 @@ export class ChatSession {
|
|
|
816
912
|
};
|
|
817
913
|
}
|
|
818
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Give the immediate-send pair the server's id for their turn, the moment the
|
|
917
|
+
* dispatch learns it.
|
|
918
|
+
*
|
|
919
|
+
* WHY THIS EXISTS. An immediate send pushes its user bubble and its
|
|
920
|
+
* "Thinking..." placeholder locally, and until now neither ever carried a
|
|
921
|
+
* _serverItemId — only the QUEUED path stamped one, off its ack. So for the
|
|
922
|
+
* whole life of the turn there was no way to tell the local copy and the
|
|
923
|
+
* server's copy of the SAME turn apart, and the history merge fell back to a
|
|
924
|
+
* heuristic: rescue the local pair unless the freshly-fetched page happens to
|
|
925
|
+
* contain a pending assistant.
|
|
926
|
+
*
|
|
927
|
+
* That heuristic has a hole exactly one poll interval wide. The server settles
|
|
928
|
+
* the request; for up to POLL_INTERVAL the client has not noticed, so
|
|
929
|
+
* `state.sending` is still true and the local pair is still on screen — while a
|
|
930
|
+
* history fetch issued in that window returns the turn ALREADY SETTLED, with no
|
|
931
|
+
* pending assistant in it. The rescue then re-appends the local pair below the
|
|
932
|
+
* server's copy (the question, twice), and when the poll finally resolves,
|
|
933
|
+
* typewriteLatestReply writes the answer into the rescued placeholder because it
|
|
934
|
+
* is the only pending assistant left (the answer, twice). updateHistoryCache
|
|
935
|
+
* persists the result, so it survives every later visit.
|
|
936
|
+
*
|
|
937
|
+
* Navigating away while waiting and coming back is what lands a fetch in that
|
|
938
|
+
* window: a remount runs refreshGate -> a fresh first page, at an arbitrary
|
|
939
|
+
* moment relative to the 3s poll.
|
|
940
|
+
*
|
|
941
|
+
* With the id on the bubbles, both clients' rescue loops skip them through the
|
|
942
|
+
* dedup they already have (`_serverItemId is in this page`), the reply the
|
|
943
|
+
* dispatch caches inherits the id too, and nothing needs a new special case.
|
|
944
|
+
*
|
|
945
|
+
* Matched by _localId, never by index: a file's indexing rows are spliced in
|
|
946
|
+
* above these bubbles while the request is in flight.
|
|
947
|
+
*/
|
|
948
|
+
private _stampTurnWithItemId(key: string, userLid: string | undefined, placeholderLid: string | undefined, itemId: string): void {
|
|
949
|
+
// placeholderLid is undefined for a QUEUED send: that path has no
|
|
950
|
+
// "Thinking..." bubble of its own until promoteNextQueuedToRunning makes one,
|
|
951
|
+
// and that copies the user bubble's id itself.
|
|
952
|
+
if (!itemId) return;
|
|
953
|
+
var changed = false;
|
|
954
|
+
var stamp = function (list: ChatMessage[]) {
|
|
955
|
+
var hit = false;
|
|
956
|
+
for (var i = 0; i < list.length; i++) {
|
|
957
|
+
var m = list[i];
|
|
958
|
+
if (!m || !m._localId) continue;
|
|
959
|
+
if (m._localId !== userLid && m._localId !== placeholderLid) continue;
|
|
960
|
+
if (m._serverItemId === itemId) continue;
|
|
961
|
+
list[i] = Object.assign({}, m, { _serverItemId: itemId });
|
|
962
|
+
hit = true;
|
|
963
|
+
}
|
|
964
|
+
return hit;
|
|
965
|
+
};
|
|
966
|
+
changed = stamp(this.state.messages);
|
|
967
|
+
// And in the cache, which is what a remount renders BEFORE the fetch lands.
|
|
968
|
+
// A cached copy still missing the id would be rescued by the very merge this
|
|
969
|
+
// is here to satisfy.
|
|
970
|
+
var cached = key ? this.aiChatHistoryCache[key] : undefined;
|
|
971
|
+
if (cached) {
|
|
972
|
+
var msgs = cached.messages.slice();
|
|
973
|
+
if (stamp(msgs)) {
|
|
974
|
+
this.aiChatHistoryCache[key] = {
|
|
975
|
+
messages: msgs, endOfList: cached.endOfList, startKeyHistory: cached.startKeyHistory,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (changed) this.host.notify();
|
|
980
|
+
}
|
|
981
|
+
|
|
819
982
|
/**
|
|
820
983
|
* Land a resolved reply in the history cache of a chat that is NOT currently
|
|
821
984
|
* visible, without touching state.messages. Mirrors the cache-only path in
|
|
@@ -918,8 +1081,12 @@ export class ChatSession {
|
|
|
918
1081
|
if (initial.id) {
|
|
919
1082
|
if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
|
|
920
1083
|
dispatchItemId = initial.id;
|
|
1084
|
+
// Hand the id to the caller so the bubbles already on screen can
|
|
1085
|
+
// carry it. An auth-refresh retry re-enters here with a NEW id,
|
|
1086
|
+
// so this reports every time, not once.
|
|
1087
|
+
if (typeof params.onItemId === 'function') params.onItemId(initial.id);
|
|
921
1088
|
}
|
|
922
|
-
var dp =
|
|
1089
|
+
var dp = self._fgPollWithEarlyProbe(initial, initial.id);
|
|
923
1090
|
if (initial.id) self._trackPoll(initial.id, 'fg', dp);
|
|
924
1091
|
return dp;
|
|
925
1092
|
}
|
|
@@ -954,38 +1121,30 @@ export class ChatSession {
|
|
|
954
1121
|
// if the chatbox unmounted mid-request.
|
|
955
1122
|
delete self.pendingAgentRequests[params.key];
|
|
956
1123
|
if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
|
|
957
|
-
var existing = self.aiChatHistoryCache[params.key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
958
1124
|
var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
|
|
1125
|
+
if (dispatchItemId) reply._serverItemId = dispatchItemId;
|
|
959
1126
|
|
|
960
1127
|
// REPLACE the trailing pending "Thinking..." bubble in the cache with
|
|
961
|
-
// the answer
|
|
962
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
} else {
|
|
982
|
-
msgs.push(reply);
|
|
983
|
-
}
|
|
984
|
-
self.aiChatHistoryCache[params.key] = {
|
|
985
|
-
messages: msgs,
|
|
986
|
-
endOfList: existing.endOfList,
|
|
987
|
-
startKeyHistory: existing.startKeyHistory,
|
|
988
|
-
};
|
|
1128
|
+
// the answer, regardless of whether this chat is the currently-visible
|
|
1129
|
+
// view. The cache must NEVER retain a pending "Thinking..." bubble: even
|
|
1130
|
+
// when the chatbox is showing this chat, typewriteLatestReply swaps the
|
|
1131
|
+
// bubble only in state.messages and NEVER re-snapshots the cache — so
|
|
1132
|
+
// leaving a pending copy here would have a later cache-first remount
|
|
1133
|
+
// re-render that "Thinking..." forever. typewriteLatestReply still finds
|
|
1134
|
+
// this reply (it reads the latest non-pending assistant from the cache),
|
|
1135
|
+
// so replacing is correct for the visible case too.
|
|
1136
|
+
//
|
|
1137
|
+
// _applyReplyToCache rather than an inline copy of it. This used to be
|
|
1138
|
+
// its own loop with the same replace-or-append shape MINUS the
|
|
1139
|
+
// dedup-before-append its twin already had, and the append is reachable:
|
|
1140
|
+
// once the local pair carries its server id (_stampTurnWithItemId), a
|
|
1141
|
+
// first-page fetch that lands after the server settles the item
|
|
1142
|
+
// correctly drops both local copies and re-snapshots a cache with no
|
|
1143
|
+
// pending placeholder left to replace. The blind push then wrote the
|
|
1144
|
+
// answer in a SECOND time, with no id on it, and the cache is rendered
|
|
1145
|
+
// verbatim on the next mount — so the fix that stopped the turn
|
|
1146
|
+
// duplicating on screen would have moved the duplicate into the cache.
|
|
1147
|
+
self._applyReplyToCache(params.key, reply, dispatchItemId);
|
|
989
1148
|
return result;
|
|
990
1149
|
});
|
|
991
1150
|
this.pendingAgentRequests[params.key] = run;
|
|
@@ -1416,9 +1575,10 @@ export class ChatSession {
|
|
|
1416
1575
|
} else {
|
|
1417
1576
|
this.state.messages.push(queuedBubble);
|
|
1418
1577
|
}
|
|
1419
|
-
this.host.notify(); this.updateHistoryCache(); this.
|
|
1578
|
+
this.host.notify(); this.updateHistoryCache(); this.scrollForDispatch(stageId);
|
|
1420
1579
|
|
|
1421
1580
|
var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
|
|
1581
|
+
var capturedQueuedLid = queuedBubble._localId;
|
|
1422
1582
|
Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner))
|
|
1423
1583
|
.then(function (result: any) {
|
|
1424
1584
|
// Only ack a bubble that belongs to THIS chat — the search is
|
|
@@ -1438,10 +1598,19 @@ export class ChatSession {
|
|
|
1438
1598
|
if (serverId) upd._serverItemId = serverId;
|
|
1439
1599
|
self.state.messages[sendingIdx] = upd; self.host.notify();
|
|
1440
1600
|
}
|
|
1601
|
+
// And into the CACHE, which the block above never touched: a remount
|
|
1602
|
+
// renders the cache first, so an unstamped copy there is a bubble the
|
|
1603
|
+
// first-page merge cannot recognise as the turn the server just sent
|
|
1604
|
+
// back — which is how a queued turn came back doubled. Keyed to
|
|
1605
|
+
// capturedKey and matched by _localId, so it lands correctly even
|
|
1606
|
+
// when the user has already moved to another project (plain
|
|
1607
|
+
// updateHistoryCache writes under the LIVE key, which by then is a
|
|
1608
|
+
// different chat).
|
|
1609
|
+
if (serverId) self._stampTurnWithItemId(capturedKey, capturedQueuedLid, undefined, serverId);
|
|
1441
1610
|
if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
|
|
1442
1611
|
// Track this queued item's poll so a remount/refetch dedups
|
|
1443
1612
|
// against it instead of attaching a duplicate history poll.
|
|
1444
|
-
var qp =
|
|
1613
|
+
var qp = self._fgPollWithEarlyProbe(result, serverId);
|
|
1445
1614
|
if (serverId) self._trackPoll(serverId, 'fg', qp);
|
|
1446
1615
|
return qp
|
|
1447
1616
|
.then(function (res: any) { if (isPollStopped(res)) return; return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey); })
|
|
@@ -1463,7 +1632,10 @@ export class ChatSession {
|
|
|
1463
1632
|
// indexing row spliced in above renumbers it — and a view that keys an id-less
|
|
1464
1633
|
// bubble by index would tear it down and rebuild it on every uploaded file.
|
|
1465
1634
|
var immediateUser: ChatMessage = { role: 'user', content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...(key ? { _ownerKey: key } : {}) };
|
|
1466
|
-
|
|
1635
|
+
// _localId on the placeholder too. It is how the server item id finds these
|
|
1636
|
+
// two bubbles again once the dispatch reports it (see onItemId below), and
|
|
1637
|
+
// indexing rows spliced in above move both of them, so an index will not do.
|
|
1638
|
+
var immediatePlaceholder: ChatMessage = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true, _localId: this._newLocalId(), ...(key ? { _ownerKey: key } : {}) };
|
|
1467
1639
|
var iStage = this._stageIndex(this.state.messages, stageId);
|
|
1468
1640
|
if (iStage !== -1) {
|
|
1469
1641
|
// Replace IN PLACE — see the queued branch above for why nothing moves.
|
|
@@ -1478,7 +1650,7 @@ export class ChatSession {
|
|
|
1478
1650
|
this.state.messages.push(immediateUser);
|
|
1479
1651
|
this.state.messages.push(immediatePlaceholder);
|
|
1480
1652
|
}
|
|
1481
|
-
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.
|
|
1653
|
+
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.scrollForDispatch(stageId);
|
|
1482
1654
|
|
|
1483
1655
|
// Same filter as the offChat and isQueuedSend paths above. It must drop the
|
|
1484
1656
|
// pending flags too: the `isPending` placeholder pushed two lines up is the
|
|
@@ -1515,10 +1687,17 @@ export class ChatSession {
|
|
|
1515
1687
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, projectId: id.projectId,
|
|
1516
1688
|
history: historyForLlm,
|
|
1517
1689
|
});
|
|
1690
|
+
var immediateUserLid = immediateUser._localId, immediatePlaceholderLid = immediatePlaceholder._localId;
|
|
1518
1691
|
var run = this.dispatchAgentRequest({
|
|
1519
1692
|
key: key, projectId: id.projectId, owner: id.owner, aiPlatform: aiPlatform, aiModel: aiModel,
|
|
1520
1693
|
systemPrompt: systemPrompt, text: composed, boundedMessages: bounded.messages, userId: chatQueue,
|
|
1521
1694
|
extractContent: extractContent, fileUrls: fileUrls,
|
|
1695
|
+
// THE fix for a turn rendering twice after navigate-away-and-back. Until
|
|
1696
|
+
// this existed the immediate-send pair carried no server id for its whole
|
|
1697
|
+
// life (only the QUEUED path stamped one, off its ack), so nothing could
|
|
1698
|
+
// tell the local copy and the server's copy of the same turn apart. See
|
|
1699
|
+
// _stampTurnWithItemId.
|
|
1700
|
+
onItemId: function (itemId: string) { self._stampTurnWithItemId(key, immediateUserLid, immediatePlaceholderLid, itemId); },
|
|
1522
1701
|
});
|
|
1523
1702
|
// Render the reply into the "Thinking..." bubble whenever the chatbox is
|
|
1524
1703
|
// CURRENTLY showing this chat — even after an unmount/remount. The old
|
|
@@ -1545,6 +1724,29 @@ export class ChatSession {
|
|
|
1545
1724
|
});
|
|
1546
1725
|
}
|
|
1547
1726
|
|
|
1727
|
+
/**
|
|
1728
|
+
* Scroll for a dispatch that is going out NOW, but never for one arriving late.
|
|
1729
|
+
*
|
|
1730
|
+
* A turn with attachments does not dispatch when the user hits Send: it waits out
|
|
1731
|
+
* its uploads and then its whole indexing chain, which is minutes
|
|
1732
|
+
* (awaitIndexingDrained). By then the reader has very often scrolled up into
|
|
1733
|
+
* history to pass the time, and the forcing scrollToBottom yanked them out of it
|
|
1734
|
+
* — and worse, force-pinned stickToBottom, which no-ops every method on the
|
|
1735
|
+
* scroll anchor and re-arms the queue-detect poll whose only bail is
|
|
1736
|
+
* !stickToBottom.
|
|
1737
|
+
*
|
|
1738
|
+
* `stageId` is the exact marker for that case: only the attachment path ever
|
|
1739
|
+
* produces one. The gesture itself was already paid for at stage time, where
|
|
1740
|
+
* stageOutgoingMessage forces the scroll while the user is still looking at the
|
|
1741
|
+
* composer. Deliberately NOT gated on "did we find the staged bubble" — a remount
|
|
1742
|
+
* rebuilds from the cache and the turn is appended rather than replaced, which is
|
|
1743
|
+
* just as late and just as unrequested.
|
|
1744
|
+
*/
|
|
1745
|
+
private scrollForDispatch(stageId?: string): void {
|
|
1746
|
+
if (stageId) this.host.scrollToBottomIfSticky(true);
|
|
1747
|
+
else this.host.scrollToBottom(true);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1548
1750
|
promoteNextBgQueuedToRunning(): void {
|
|
1549
1751
|
if (this.state.messages.some(function (m) { return m.isPending && m.role === 'assistant' && m.isBackgroundTask; })) return;
|
|
1550
1752
|
var nextIdx = this.state.messages.findIndex(function (m) {
|
|
@@ -1700,6 +1902,31 @@ export class ChatSession {
|
|
|
1700
1902
|
else this.state.messages.push(msg);
|
|
1701
1903
|
}
|
|
1702
1904
|
|
|
1905
|
+
/**
|
|
1906
|
+
* The server's OWN copy of this turn is already on screen.
|
|
1907
|
+
*
|
|
1908
|
+
* A first-page fetch can land between the server settling the item and this
|
|
1909
|
+
* poll's tick, and now that the local bubbles carry the item id
|
|
1910
|
+
* (_stampTurnWithItemId) the rescue correctly drops them and renders the
|
|
1911
|
+
* server's settled pair instead. There is then nothing left to resolve: the
|
|
1912
|
+
* -1 fallback would push the answer in a SECOND time at the bottom of the list,
|
|
1913
|
+
* and the positional fallbacks would hijack some other turn's bubble.
|
|
1914
|
+
*
|
|
1915
|
+
* The USER bubble counts, not just a settled assistant: an item whose answer is
|
|
1916
|
+
* empty produces no assistant bubble at all in the mapper, and that variant
|
|
1917
|
+
* would otherwise still bottom-push "No text response received...". While a turn
|
|
1918
|
+
* is genuinely live its user bubble is always pending (the queued branch sets
|
|
1919
|
+
* isPendingQueued, promoteNextQueuedToRunning sets isPendingInProcess), so this
|
|
1920
|
+
* cannot fire early.
|
|
1921
|
+
*/
|
|
1922
|
+
private _turnAlreadyRendered(serverId?: string): boolean {
|
|
1923
|
+
if (!serverId) return false;
|
|
1924
|
+
return this.state.messages.some(function (m) {
|
|
1925
|
+
return m._serverItemId === serverId &&
|
|
1926
|
+
!m.isPending && !m.isPendingQueued && !m.isPendingInProcess;
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1703
1930
|
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string, ownerKey?: string): void {
|
|
1704
1931
|
if (serverId) this.historyItemPolls.delete(serverId);
|
|
1705
1932
|
// This turn resolved while a DIFFERENT chat is on screen (the user moved to
|
|
@@ -1715,6 +1942,10 @@ export class ChatSession {
|
|
|
1715
1942
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
1716
1943
|
return;
|
|
1717
1944
|
}
|
|
1945
|
+
if (this._turnAlreadyRendered(serverId)) {
|
|
1946
|
+
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
1947
|
+
this.host.notify(); this.updateHistoryCache(); return;
|
|
1948
|
+
}
|
|
1718
1949
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
1719
1950
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
1720
1951
|
if (isErrorResponseBody(response)) {
|
|
@@ -1757,6 +1988,14 @@ export class ChatSession {
|
|
|
1757
1988
|
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
1758
1989
|
return;
|
|
1759
1990
|
}
|
|
1991
|
+
// Same reason as onQueuedSendResponse: with the server's settled copy already
|
|
1992
|
+
// on screen there is no pending bubble left that belongs to this turn, and
|
|
1993
|
+
// every fallback below would write onto some other turn's — or push a second
|
|
1994
|
+
// bubble at the bottom.
|
|
1995
|
+
if (this._turnAlreadyRendered(serverId)) {
|
|
1996
|
+
if (serverId) this.cancelledServerIds.delete(serverId);
|
|
1997
|
+
this.host.notify(); this.updateHistoryCache(); return;
|
|
1998
|
+
}
|
|
1760
1999
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
1761
2000
|
if (isNotExists) {
|
|
1762
2001
|
var userIdx = serverId
|
|
@@ -3324,30 +3563,18 @@ export class ChatSession {
|
|
|
3324
3563
|
var mappedHasPendingAssistant = mapped.some(function (m: any) {
|
|
3325
3564
|
return m.isPending && m.role === 'assistant' && !m.isBackgroundTask;
|
|
3326
3565
|
});
|
|
3566
|
+
// One rule, shared with agent.vue's own fetchHistoryPage — see
|
|
3567
|
+
// shouldRescueInFlightMessage for what it decides and why.
|
|
3327
3568
|
var rescued: ChatMessage[] = [];
|
|
3328
3569
|
for (var ri = 0; ri < self.state.messages.length; ri++) {
|
|
3329
3570
|
var mm = self.state.messages[ri];
|
|
3330
|
-
if (mm
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
// A staged turn (files still uploading) has no server request
|
|
3338
|
-
// yet, so nothing in `mapped` can stand for it — rescue it
|
|
3339
|
-
// unconditionally. The mappedHasPendingAssistant skip below is
|
|
3340
|
-
// about a turn the server ALREADY has; applying it here would
|
|
3341
|
-
// delete the user's message mid-upload whenever some other
|
|
3342
|
-
// turn happened to be in flight.
|
|
3343
|
-
if (mm._stageId) { rescued.push(mm); continue; }
|
|
3344
|
-
if (mappedHasPendingAssistant) continue;
|
|
3345
|
-
if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
|
|
3346
|
-
else if (self.state.sending && mm.role === 'user') {
|
|
3347
|
-
var next = self.state.messages[ri + 1];
|
|
3348
|
-
if (next && !next.isBackgroundTask && next.isPending && !next._serverItemId) rescued.push(mm);
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3571
|
+
if (shouldRescueInFlightMessage(mm, {
|
|
3572
|
+
hasServerId: function (sid) { return !!serverIds[sid]; },
|
|
3573
|
+
pageHasPendingAssistant: mappedHasPendingAssistant,
|
|
3574
|
+
sending: !!self.state.sending,
|
|
3575
|
+
next: self.state.messages[ri + 1],
|
|
3576
|
+
loadKey: loadKey,
|
|
3577
|
+
})) rescued.push(mm);
|
|
3351
3578
|
}
|
|
3352
3579
|
// PRESERVE already-loaded older pages. `mapped` is only page 1, so a
|
|
3353
3580
|
// blind replace threw away every page the user had scrolled in. This
|
|
@@ -3428,7 +3655,15 @@ export class ChatSession {
|
|
|
3428
3655
|
}
|
|
3429
3656
|
keptOlderPages = prependOlder.length > 0 || interleave.length > 0;
|
|
3430
3657
|
self.state.messages = prependOlder.length ? prependOlder.concat(page1) : page1;
|
|
3431
|
-
rescued.forEach(function (m) {
|
|
3658
|
+
rescued.forEach(function (m) {
|
|
3659
|
+
// Rescue can now carry a bubble that has a server id, and the
|
|
3660
|
+
// retained-older merge above carries ids too. One turn must not
|
|
3661
|
+
// arrive down both routes.
|
|
3662
|
+
if (m._serverItemId && self.state.messages.some(function (x) {
|
|
3663
|
+
return x._serverItemId === m._serverItemId && x.role === m.role;
|
|
3664
|
+
})) return;
|
|
3665
|
+
self.state.messages.push(m);
|
|
3666
|
+
});
|
|
3432
3667
|
if (Object.keys(locallyCancelled).length) {
|
|
3433
3668
|
for (var ci = 0; ci < self.state.messages.length; ci++) {
|
|
3434
3669
|
var c = self.state.messages[ci];
|
|
@@ -3568,6 +3803,12 @@ export class ChatSession {
|
|
|
3568
3803
|
releaseBgFlag();
|
|
3569
3804
|
self.updateHistoryCache();
|
|
3570
3805
|
self.host.notify();
|
|
3806
|
+
// The other half of the refresh. Everything this batch brought in
|
|
3807
|
+
// sits ABOVE or AMONG the newest turn, so a reader pinned to the
|
|
3808
|
+
// bottom was left stranded above it by exactly the batch's height,
|
|
3809
|
+
// with nothing to put them back (every scroll-anchor method no-ops
|
|
3810
|
+
// while pinned, and this handler ended here).
|
|
3811
|
+
if (self.host.settleScroll) self.host.settleScroll();
|
|
3571
3812
|
}, function () {
|
|
3572
3813
|
// Stubs missing until the next refresh re-asks; never fatal.
|
|
3573
3814
|
releaseBgFlag();
|
|
@@ -3627,8 +3868,12 @@ export class ChatSession {
|
|
|
3627
3868
|
// it up once an attached poll settles and frees a slot.
|
|
3628
3869
|
if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
|
|
3629
3870
|
var capturedId = item.id;
|
|
3630
|
-
var
|
|
3631
|
-
|
|
3871
|
+
var isBg = !!(item._isBgTask || item._isOnBgQueue);
|
|
3872
|
+
// A foreground item here is a reply the user is still waiting on (a reload or a tab
|
|
3873
|
+
// return re-attaches it), so it gets the same early probe as a fresh send. Background
|
|
3874
|
+
// items keep the flat cadence: nobody is watching, and they are what the
|
|
3875
|
+
// MAX_CONCURRENT_BG_POLLS budget exists to protect.
|
|
3876
|
+
var pollOpts = {
|
|
3632
3877
|
onResponse: function (response: any) { if (isPollStopped(response)) return; self.handleHistoryItemResolution(capturedId, response, platform); },
|
|
3633
3878
|
onError: function (err: any) {
|
|
3634
3879
|
self.historyItemPolls.delete(capturedId);
|
|
@@ -3669,7 +3914,10 @@ export class ChatSession {
|
|
|
3669
3914
|
self.host.notify(); self.updateHistoryCache();
|
|
3670
3915
|
}
|
|
3671
3916
|
},
|
|
3672
|
-
}
|
|
3917
|
+
};
|
|
3918
|
+
var pp = isBg
|
|
3919
|
+
? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts))
|
|
3920
|
+
: self._fgPollWithEarlyProbe(item, capturedId, pollOpts);
|
|
3673
3921
|
// Anything on the BACKGROUND queue is pausable, not just items whose
|
|
3674
3922
|
// prompt text we recognise as an indexing task. _isBgTask vs
|
|
3675
3923
|
// _isOnBgQueue is a DISPLAY distinction ("Indexing: file" vs a normal
|
|
@@ -3690,7 +3938,11 @@ export class ChatSession {
|
|
|
3690
3938
|
// the one resumePolling fires on visibilitychange. Forcing yanked a
|
|
3691
3939
|
// reader who had scrolled up back to the bottom. On a genuine mount the
|
|
3692
3940
|
// user is still pinned, so this behaves identically there.
|
|
3693
|
-
|
|
3941
|
+
//
|
|
3942
|
+
// settleScroll when the host has one: it makes the same decision, and it
|
|
3943
|
+
// is also called again once the deferred bg batch merges, so a pinned
|
|
3944
|
+
// reader lands on the REAL bottom instead of the surface page's.
|
|
3945
|
+
if (!fetchMore) return self.host.settleScroll ? self.host.settleScroll() : self.host.scrollToBottomIfSticky();
|
|
3694
3946
|
}).catch(function (err: any) {
|
|
3695
3947
|
console.warn('[chat-engine] getChatHistory failed', err);
|
|
3696
3948
|
}).then(function () {
|
package/src/widget.css
CHANGED
|
@@ -645,6 +645,14 @@
|
|
|
645
645
|
position: relative;
|
|
646
646
|
overflow-y: auto;
|
|
647
647
|
overflow-x: hidden;
|
|
648
|
+
/* Exactly ONE compensator. Blink and Gecko run their own scroll anchoring on
|
|
649
|
+
this scroller, and it fires at the very layout the JS anchor's absorb()
|
|
650
|
+
forces when it reads offsetHeight - so an image growing above the fold was
|
|
651
|
+
corrected by the browser and then paid for a second time in JS. Measured on
|
|
652
|
+
one background re-render: 6224px of jump with both live, 0 with this.
|
|
653
|
+
Deleting absorb() instead is not an option (15000px of drift on WebKit,
|
|
654
|
+
which has no native anchoring at all). See engine/scroll_anchor.ts. */
|
|
655
|
+
overflow-anchor: none;
|
|
648
656
|
flex: 1;
|
|
649
657
|
min-height: 0;
|
|
650
658
|
padding: 0.25rem 1.25rem;
|