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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -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
- var onThisVisibleChat = self.host.isViewMounted() && self.getHistoryCacheKey() === params.key;
149
- if (onThisVisibleChat) {
150
- // The chatbox is showing THIS chat: its typewriteLatestReply will
151
- // swap the "Thinking..." bubble in state.messages and re-snapshot
152
- // the cache. Just append the reply for it to pick up.
153
- self.aiChatHistoryCache[params.key] = {
154
- messages: existing.messages.concat([reply]),
155
- endOfList: existing.endOfList,
156
- startKeyHistory: existing.startKeyHistory,
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
- // Resolved while this chat is NOT the visible view (the chatbox
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
- this.state.messages.splice(nextIdx + 1, 0, { role: 'assistant', content: '', isPending: true });
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
- var TICK_MS = 16, charsPerTick = 3, FENCE_REVEAL_MS = 200;
495
- var fenceTicks = Math.max(1, Math.floor(FENCE_REVEAL_MS / TICK_MS));
496
- var fenceRegions: Array<{ start: number; end: number }> = [], m;
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) fenceRegions.push({ start: m.index, end: m.index + m[0].length });
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 ((lm = linkRegex.exec(fullText)) !== null) linkRegions.push({ start: lm.index, end: lm.index + lm[0].length });
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
- return (function loop(): Promise<void> {
506
- if (self.state.typingAbort || i >= fullText.length) return Promise.resolve();
507
- var step = charsPerTick;
508
- var region = fenceRegions.find(function (r) { return i >= r.start && i < r.end; });
509
- var linkRegion = linkRegions.find(function (r) { return i >= r.start && i < r.end; });
510
- if (region) step = Math.max(charsPerTick, Math.ceil((region.end - i) / fenceTicks));
511
- else if (linkRegion) step = Math.max(charsPerTick, linkRegion.end - i);
512
- else {
513
- var nextLink = linkRegions.find(function (r) { return i < r.start && i + step > r.start; });
514
- if (nextLink) step = nextLink.end - i;
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
- i = Math.min(fullText.length, i + step);
517
- var currentIdx = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
518
- if (currentIdx === -1) return Promise.resolve();
519
- var target = self.state.messages[currentIdx];
520
- if (!target) return Promise.resolve();
521
- target.content = fullText.slice(0, i);
522
- self.host.refreshMessageBubble(currentIdx);
523
- return Promise.resolve(self.host.scrollToBottomIfSticky()).then(function () { return sleep(TICK_MS); }).then(loop);
524
- })().then(function () {
525
- if (!self.state.typingAbort) {
526
- var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
527
- var t = fi !== -1 ? self.state.messages[fi] : self.state.messages[idx];
528
- if (t) { t.content = fullText; self.host.refreshMessageBubble(fi !== -1 ? fi : idx); }
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
- self.state.typing = false;
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
- // running OR pending: a live dispatchAgentRequest already polls this
832
- // item; attaching a second (history) poll would double the interval
833
- // fetch on a remount (skapi item.poll() loops survive unmount —
834
- // they're uncancellable, so the dispatch poll is still running).
835
- if ((item.status === 'running' || item.status === 'pending') && self.pendingAgentRequests[self.getHistoryCacheKey()]) return;
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({