bunnyquery 1.4.4 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bunnyquery.js +144 -77
- package/dist/engine.cjs +143 -75
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.mjs +143 -75
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/session.ts +185 -70
package/package.json
CHANGED
package/src/engine/session.ts
CHANGED
|
@@ -41,6 +41,18 @@ function sleep(ms: number): Promise<void> {
|
|
|
41
41
|
return new Promise(function (r) { setTimeout(r, ms); });
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// requestAnimationFrame / high-res clock, reached through globalThis so the
|
|
45
|
+
// engine stays DOM-free at the type level (and degrades gracefully in non-DOM
|
|
46
|
+
// / test environments where these globals are absent).
|
|
47
|
+
var _g: any = typeof globalThis !== 'undefined' ? globalThis : {};
|
|
48
|
+
function nowMs(): number {
|
|
49
|
+
return (_g.performance && typeof _g.performance.now === 'function') ? _g.performance.now() : Date.now();
|
|
50
|
+
}
|
|
51
|
+
function nextFrame(cb: (t: number) => void): void {
|
|
52
|
+
if (typeof _g.requestAnimationFrame === 'function') { _g.requestAnimationFrame(cb); return; }
|
|
53
|
+
setTimeout(function () { cb(nowMs()); }, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
44
56
|
export class ChatSession {
|
|
45
57
|
host: ChatHost;
|
|
46
58
|
state: ChatState;
|
|
@@ -105,11 +117,23 @@ export class ChatSession {
|
|
|
105
117
|
|
|
106
118
|
dispatchAgentRequest(params: any) {
|
|
107
119
|
var self = this;
|
|
120
|
+
// Id of the in-flight item this dispatch polls. Recorded in
|
|
121
|
+
// historyItemPolls (set below, deleted when the dispatch settles) so a
|
|
122
|
+
// remount / history refetch dedups against THIS poll instead of stacking
|
|
123
|
+
// a duplicate history poll on the same item. (The cacheKey guard alone
|
|
124
|
+
// stops covering an item once pendingAgentRequests clears — e.g. a queued
|
|
125
|
+
// message still in flight after the immediate one resolved.)
|
|
126
|
+
var dispatchItemId: string | undefined;
|
|
108
127
|
var sendAndPoll = function () {
|
|
109
128
|
return Promise.resolve(
|
|
110
129
|
self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent)
|
|
111
130
|
).then(function (initial: any) {
|
|
112
131
|
if (initial && initial.poll && (initial.status === 'pending' || initial.status === 'running')) {
|
|
132
|
+
if (initial.id) {
|
|
133
|
+
if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
|
|
134
|
+
dispatchItemId = initial.id;
|
|
135
|
+
self.historyItemPolls.set(initial.id, true);
|
|
136
|
+
}
|
|
113
137
|
return initial.poll({ latency: POLL_INTERVAL });
|
|
114
138
|
}
|
|
115
139
|
return initial;
|
|
@@ -142,45 +166,39 @@ export class ChatSession {
|
|
|
142
166
|
// independently of the view lifecycle, so a reply is captured even
|
|
143
167
|
// if the chatbox unmounted mid-request.
|
|
144
168
|
delete self.pendingAgentRequests[params.key];
|
|
169
|
+
if (dispatchItemId) self.historyItemPolls.delete(dispatchItemId);
|
|
145
170
|
var existing = self.aiChatHistoryCache[params.key] || { messages: [], endOfList: false, startKeyHistory: [] };
|
|
146
171
|
var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
|
|
147
172
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
// REPLACE the trailing pending "Thinking..." bubble in the cache with
|
|
174
|
+
// the answer (falling back to append when none is present), regardless
|
|
175
|
+
// of whether this chat is the currently-visible view. The cache must
|
|
176
|
+
// NEVER retain a pending "Thinking..." bubble: even when the chatbox is
|
|
177
|
+
// showing this chat, typewriteLatestReply swaps the bubble only in
|
|
178
|
+
// state.messages and NEVER re-snapshots the cache — so appending a
|
|
179
|
+
// duplicate here would leave the cached copy stuck pending, and a later
|
|
180
|
+
// cache-first remount (agent.vue's loadChatHistory) would re-render that
|
|
181
|
+
// "Thinking..." forever. typewriteLatestReply still finds this reply (it
|
|
182
|
+
// reads the latest non-pending assistant from the cache), so replacing is
|
|
183
|
+
// correct for the visible case too. The next fresh history fetch
|
|
184
|
+
// reconciles it either way.
|
|
185
|
+
var msgs = existing.messages.slice();
|
|
186
|
+
var idx = -1;
|
|
187
|
+
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
188
|
+
var m = msgs[i];
|
|
189
|
+
if (m && m.isPending && m.role === 'assistant' && !m.isBackgroundTask) { idx = i; break; }
|
|
190
|
+
}
|
|
191
|
+
if (idx !== -1) {
|
|
192
|
+
reply._serverItemId = msgs[idx]._serverItemId;
|
|
193
|
+
msgs[idx] = reply;
|
|
158
194
|
} else {
|
|
159
|
-
|
|
160
|
-
// unmounted, or the user moved to another project). No view
|
|
161
|
-
// typewriter will run, so REPLACE the trailing pending
|
|
162
|
-
// "Thinking..." bubble in the cache with the answer rather than
|
|
163
|
-
// appending a duplicate — otherwise a remount renders a stuck
|
|
164
|
-
// "Thinking..." (whose bubble was never resolved). The next
|
|
165
|
-
// fresh history fetch reconciles it either way.
|
|
166
|
-
var msgs = existing.messages.slice();
|
|
167
|
-
var idx = -1;
|
|
168
|
-
for (var i = msgs.length - 1; i >= 0; i--) {
|
|
169
|
-
var m = msgs[i];
|
|
170
|
-
if (m && m.isPending && m.role === 'assistant' && !m.isBackgroundTask) { idx = i; break; }
|
|
171
|
-
}
|
|
172
|
-
if (idx !== -1) {
|
|
173
|
-
reply._serverItemId = msgs[idx]._serverItemId;
|
|
174
|
-
msgs[idx] = reply;
|
|
175
|
-
} else {
|
|
176
|
-
msgs.push(reply);
|
|
177
|
-
}
|
|
178
|
-
self.aiChatHistoryCache[params.key] = {
|
|
179
|
-
messages: msgs,
|
|
180
|
-
endOfList: existing.endOfList,
|
|
181
|
-
startKeyHistory: existing.startKeyHistory,
|
|
182
|
-
};
|
|
195
|
+
msgs.push(reply);
|
|
183
196
|
}
|
|
197
|
+
self.aiChatHistoryCache[params.key] = {
|
|
198
|
+
messages: msgs,
|
|
199
|
+
endOfList: existing.endOfList,
|
|
200
|
+
startKeyHistory: existing.startKeyHistory,
|
|
201
|
+
};
|
|
184
202
|
return result;
|
|
185
203
|
});
|
|
186
204
|
this.pendingAgentRequests[params.key] = run;
|
|
@@ -235,6 +253,9 @@ export class ChatSession {
|
|
|
235
253
|
self.state.messages[sendingIdx] = upd; self.host.notify();
|
|
236
254
|
}
|
|
237
255
|
if (result && result.poll && (result.status === 'pending' || result.status === 'running')) {
|
|
256
|
+
// Track this queued item's poll so a remount/refetch dedups
|
|
257
|
+
// against it instead of attaching a duplicate history poll.
|
|
258
|
+
if (serverId) self.historyItemPolls.set(serverId, true);
|
|
238
259
|
return result.poll({ latency: POLL_INTERVAL })
|
|
239
260
|
.then(function (res: any) { return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId); })
|
|
240
261
|
.catch(function (err: any) { return self.onQueuedSendError(capturedComposed, err, serverId); });
|
|
@@ -318,7 +339,17 @@ export class ChatSession {
|
|
|
318
339
|
if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
|
|
319
340
|
if (existing.isSendingToServer) promoted.isSendingToServer = true;
|
|
320
341
|
this.state.messages[nextIdx] = promoted;
|
|
321
|
-
|
|
342
|
+
// Carry the promoted turn's _serverItemId onto the "Thinking..." placeholder
|
|
343
|
+
// (mirrors promoteNextBgQueuedToRunning). Without it, when this promoted turn
|
|
344
|
+
// is resolved via the history-poll path — applyHistoryItemResolution, which
|
|
345
|
+
// matches the pending assistant by _serverItemId — the placeholder is not
|
|
346
|
+
// found, so the reply is spliced in BESIDE it and the "Thinking..." is
|
|
347
|
+
// stranded forever (e.g. after a reload, or when a cancel advances the queue).
|
|
348
|
+
// onQueuedSendResponse still finds it by position, so the queued-send path is
|
|
349
|
+
// unaffected.
|
|
350
|
+
var placeholder: ChatMessage = { role: 'assistant', content: '', isPending: true };
|
|
351
|
+
if (existing._serverItemId !== undefined) placeholder._serverItemId = existing._serverItemId;
|
|
352
|
+
this.state.messages.splice(nextIdx + 1, 0, placeholder);
|
|
322
353
|
this.host.notify();
|
|
323
354
|
}
|
|
324
355
|
|
|
@@ -372,6 +403,7 @@ export class ChatSession {
|
|
|
372
403
|
}
|
|
373
404
|
|
|
374
405
|
onQueuedSendResponse(_composed: string, response: any, platform: string, serverId?: string): void {
|
|
406
|
+
if (serverId) this.historyItemPolls.delete(serverId);
|
|
375
407
|
var targetIdx = this.resolveQueuedUserBubble(serverId);
|
|
376
408
|
if (targetIdx === undefined) { this.host.notify(); this.updateHistoryCache(); return; }
|
|
377
409
|
if (isErrorResponseBody(response)) {
|
|
@@ -401,6 +433,7 @@ export class ChatSession {
|
|
|
401
433
|
|
|
402
434
|
onQueuedSendError(_composed: string, err: any, serverId?: string): void {
|
|
403
435
|
var self = this;
|
|
436
|
+
if (serverId) this.historyItemPolls.delete(serverId);
|
|
404
437
|
var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
|
|
405
438
|
if (isNotExists) {
|
|
406
439
|
var userIdx = serverId
|
|
@@ -488,46 +521,112 @@ export class ChatSession {
|
|
|
488
521
|
}
|
|
489
522
|
|
|
490
523
|
// --- typewriter -------------------------------------------------------
|
|
524
|
+
// Reveal `fullText` into a message bubble at a constant wall-clock RATE
|
|
525
|
+
// (chars/second) driven by requestAnimationFrame, rather than a fixed number
|
|
526
|
+
// of characters per fixed-delay tick. This is what keeps typing smooth and
|
|
527
|
+
// cheap on slow machines:
|
|
528
|
+
//
|
|
529
|
+
// * Each frame reveals `elapsed_ms * CHARS_PER_SEC` characters, so the
|
|
530
|
+
// visual speed is the same regardless of how long a frame actually took.
|
|
531
|
+
// * As the bubble's markdown grows, each re-render gets more expensive, so
|
|
532
|
+
// frames get longer — which makes each frame reveal MORE characters and
|
|
533
|
+
// therefore do FEWER, larger renders. That converts the old O(n^2)
|
|
534
|
+
// "re-render the whole growing string once per 3 characters" (which got
|
|
535
|
+
// slower and slower and pegged the CPU) into roughly O(n): the number of
|
|
536
|
+
// renders self-throttles to what the machine can actually paint.
|
|
537
|
+
// * rAF paces us to the browser's paint cycle and pauses in background
|
|
538
|
+
// tabs, so we never queue work faster than it can be drawn.
|
|
491
539
|
typewriteIntoIndex(idx: number, fullText: string, localId?: string): Promise<void> {
|
|
492
540
|
var self = this;
|
|
493
541
|
if (!fullText) return Promise.resolve();
|
|
494
|
-
|
|
495
|
-
var
|
|
496
|
-
var
|
|
542
|
+
|
|
543
|
+
var CHARS_PER_SEC = 300; // reveal rate (was ~188 = 3 chars / 16ms) — a bit faster
|
|
544
|
+
var MIN_STEP = 1; // always make progress, even on a sub-ms frame
|
|
545
|
+
var MAX_FRAME_MS = 1000; // cap catch-up after a long stall / backgrounded tab
|
|
546
|
+
|
|
547
|
+
// Atomic regions that must never be left half-revealed: file fences
|
|
548
|
+
// (shown as a download chip / hidden as "[generating …]" while unclosed —
|
|
549
|
+
// and potentially huge, so revealed in one jump) and inline links
|
|
550
|
+
// (a partial link is broken markdown). If a frame's reveal boundary lands
|
|
551
|
+
// inside one, snap it to the region end so the chip/link appears whole.
|
|
552
|
+
var regions: Array<{ start: number; end: number }> = [], m;
|
|
497
553
|
var fenceRegex = /```[\w.-]+\.[a-zA-Z0-9]+\n[\s\S]*?```/g;
|
|
498
|
-
while ((m = fenceRegex.exec(fullText)) !== null)
|
|
499
|
-
var linkRegions: Array<{ start: number; end: number }> = [], lm;
|
|
554
|
+
while ((m = fenceRegex.exec(fullText)) !== null) regions.push({ start: m.index, end: m.index + m[0].length });
|
|
500
555
|
var linkRegex = createInlineLinkRegex();
|
|
501
|
-
while ((
|
|
556
|
+
while ((m = linkRegex.exec(fullText)) !== null) regions.push({ start: m.index, end: m.index + m[0].length });
|
|
557
|
+
regions.sort(function (a, b) { return a.start - b.start; });
|
|
502
558
|
|
|
503
559
|
this.state.typing = true; this.state.typingAbort = false;
|
|
504
560
|
var i = 0;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
var
|
|
509
|
-
var
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
561
|
+
var last = nowMs();
|
|
562
|
+
|
|
563
|
+
return new Promise<void>(function (resolve) {
|
|
564
|
+
var done = false;
|
|
565
|
+
var doc: any = _g.document;
|
|
566
|
+
function isHidden(): boolean { return !!(doc && doc.hidden); }
|
|
567
|
+
function cleanup(): void {
|
|
568
|
+
if (doc && doc.removeEventListener) doc.removeEventListener('visibilitychange', onVisibility);
|
|
569
|
+
}
|
|
570
|
+
function finish(): void {
|
|
571
|
+
if (done) return;
|
|
572
|
+
done = true;
|
|
573
|
+
cleanup();
|
|
574
|
+
if (!self.state.typingAbort) {
|
|
575
|
+
// Re-find the bubble by localId and, if it's gone, bail WITHOUT
|
|
576
|
+
// writing — never fall back to the numeric idx, which a concurrent
|
|
577
|
+
// array mutation may have repurposed for an unrelated message
|
|
578
|
+
// (mirrors frame()'s currentIdx===-1 bail below).
|
|
579
|
+
var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
|
|
580
|
+
if (fi !== -1) {
|
|
581
|
+
var t = self.state.messages[fi];
|
|
582
|
+
if (t) { t.content = fullText; self.host.refreshMessageBubble(fi); }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
self.state.typing = false;
|
|
586
|
+
resolve();
|
|
515
587
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
var
|
|
528
|
-
if (
|
|
588
|
+
// requestAnimationFrame is PAUSED in a backgrounded tab (setTimeout only
|
|
589
|
+
// throttles), so an rAF-driven reveal would freeze there — leaving a blank
|
|
590
|
+
// bubble and stalling the sequential enqueueTypewrite queue until refocus.
|
|
591
|
+
// When the page is (or becomes) hidden, skip the animation and show the
|
|
592
|
+
// full text immediately so the queue keeps draining.
|
|
593
|
+
function onVisibility(): void { if (isHidden()) finish(); }
|
|
594
|
+
if (doc && doc.addEventListener) doc.addEventListener('visibilitychange', onVisibility);
|
|
595
|
+
function frame(t: number): void {
|
|
596
|
+
if (done) return;
|
|
597
|
+
if (self.state.typingAbort || i >= fullText.length || isHidden()) { finish(); return; }
|
|
598
|
+
|
|
599
|
+
var dt = t - last; last = t;
|
|
600
|
+
if (!(dt > 0)) dt = 16; // first frame / clock glitch
|
|
601
|
+
if (dt > MAX_FRAME_MS) dt = MAX_FRAME_MS;
|
|
602
|
+
var step = Math.round(dt * CHARS_PER_SEC / 1000);
|
|
603
|
+
if (step < MIN_STEP) step = MIN_STEP;
|
|
604
|
+
var next = Math.min(fullText.length, i + step);
|
|
605
|
+
|
|
606
|
+
// Extend past any atomic region the newly-revealed span (i, next]
|
|
607
|
+
// cuts into. Iterate to convergence so back-to-back regions (e.g. a
|
|
608
|
+
// link right after a fence) are all revealed whole in one frame.
|
|
609
|
+
for (var changed = true; changed;) {
|
|
610
|
+
changed = false;
|
|
611
|
+
for (var k = 0; k < regions.length; k++) {
|
|
612
|
+
var r = regions[k];
|
|
613
|
+
if (next > r.start && i < r.end && r.end > next) { next = r.end; changed = true; }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (next > fullText.length) next = fullText.length;
|
|
617
|
+
i = next;
|
|
618
|
+
|
|
619
|
+
var currentIdx = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
|
|
620
|
+
if (currentIdx === -1) { finish(); return; }
|
|
621
|
+
var target = self.state.messages[currentIdx];
|
|
622
|
+
if (!target) { finish(); return; }
|
|
623
|
+
target.content = fullText.slice(0, i);
|
|
624
|
+
self.host.refreshMessageBubble(currentIdx);
|
|
625
|
+
self.host.scrollToBottomIfSticky();
|
|
626
|
+
nextFrame(frame);
|
|
529
627
|
}
|
|
530
|
-
|
|
628
|
+
if (isHidden()) { finish(); return; }
|
|
629
|
+
nextFrame(frame);
|
|
531
630
|
});
|
|
532
631
|
}
|
|
533
632
|
|
|
@@ -827,12 +926,28 @@ export class ChatSession {
|
|
|
827
926
|
chatList.forEach(function (item: any) {
|
|
828
927
|
if (item.status !== 'running' && item.status !== 'pending') return;
|
|
829
928
|
if (!item.poll || !item.id) return;
|
|
929
|
+
// Every live poll registers its item id in historyItemPolls — an
|
|
930
|
+
// immediate dispatch (dispatchAgentRequest ~123), a queued send
|
|
931
|
+
// (~253), a bg task (drain), or an earlier history poll — so the
|
|
932
|
+
// has() check below already skips exactly the items a live poll
|
|
933
|
+
// covers (including the one the current dispatch is polling). Do NOT
|
|
934
|
+
// additionally skip every OTHER running/pending item just because
|
|
935
|
+
// some immediate dispatch is in flight: that dispatch polls only ITS
|
|
936
|
+
// item, so blanket-skipping stranded a concurrent "-bg" indexing item
|
|
937
|
+
// (a separate server queue runs in parallel) — or any item whose own
|
|
938
|
+
// poll had since died — with no live poller, leaving a "Thinking..."
|
|
939
|
+
// that never clears even after the server settled everything.
|
|
830
940
|
if (self.historyItemPolls.has(item.id)) return;
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
|
|
941
|
+
// An in-flight immediate dispatch polls its own MAIN-QUEUE item but
|
|
942
|
+
// registers that id in historyItemPolls only AFTER its provider POST
|
|
943
|
+
// returns; in the sliver before that, a remount can surface the item
|
|
944
|
+
// here still unregistered. Skip re-polling MAIN-QUEUE items while a
|
|
945
|
+
// dispatch is in flight so we don't stack a 2nd poll on the item the
|
|
946
|
+
// dispatch is about to poll. Bg-queue items (_isBgTask indexing tasks
|
|
947
|
+
// and _isOnBgQueue chats) run on a SEPARATE queue the immediate
|
|
948
|
+
// dispatch never polls, so they MUST still get their poll — skipping
|
|
949
|
+
// them was the stranded-"Thinking" bug this guard must not revive.
|
|
950
|
+
if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
|
|
836
951
|
self.historyItemPolls.set(item.id, true);
|
|
837
952
|
var capturedId = item.id;
|
|
838
953
|
var pp = item.poll({
|