bunnyquery 1.4.5 → 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.5",
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;
@@ -158,42 +170,35 @@ export class ChatSession {
158
170
  var existing = self.aiChatHistoryCache[params.key] || { messages: [], endOfList: false, startKeyHistory: [] };
159
171
  var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
160
172
 
161
- var onThisVisibleChat = self.host.isViewMounted() && self.getHistoryCacheKey() === params.key;
162
- if (onThisVisibleChat) {
163
- // The chatbox is showing THIS chat: its typewriteLatestReply will
164
- // swap the "Thinking..." bubble in state.messages and re-snapshot
165
- // the cache. Just append the reply for it to pick up.
166
- self.aiChatHistoryCache[params.key] = {
167
- messages: existing.messages.concat([reply]),
168
- endOfList: existing.endOfList,
169
- startKeyHistory: existing.startKeyHistory,
170
- };
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;
171
194
  } else {
172
- // Resolved while this chat is NOT the visible view (the chatbox
173
- // unmounted, or the user moved to another project). No view
174
- // typewriter will run, so REPLACE the trailing pending
175
- // "Thinking..." bubble in the cache with the answer rather than
176
- // appending a duplicate — otherwise a remount renders a stuck
177
- // "Thinking..." (whose bubble was never resolved). The next
178
- // fresh history fetch reconciles it either way.
179
- var msgs = existing.messages.slice();
180
- var idx = -1;
181
- for (var i = msgs.length - 1; i >= 0; i--) {
182
- var m = msgs[i];
183
- if (m && m.isPending && m.role === 'assistant' && !m.isBackgroundTask) { idx = i; break; }
184
- }
185
- if (idx !== -1) {
186
- reply._serverItemId = msgs[idx]._serverItemId;
187
- msgs[idx] = reply;
188
- } else {
189
- msgs.push(reply);
190
- }
191
- self.aiChatHistoryCache[params.key] = {
192
- messages: msgs,
193
- endOfList: existing.endOfList,
194
- startKeyHistory: existing.startKeyHistory,
195
- };
195
+ msgs.push(reply);
196
196
  }
197
+ self.aiChatHistoryCache[params.key] = {
198
+ messages: msgs,
199
+ endOfList: existing.endOfList,
200
+ startKeyHistory: existing.startKeyHistory,
201
+ };
197
202
  return result;
198
203
  });
199
204
  this.pendingAgentRequests[params.key] = run;
@@ -334,7 +339,17 @@ export class ChatSession {
334
339
  if (existing._serverItemId !== undefined) promoted._serverItemId = existing._serverItemId;
335
340
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
336
341
  this.state.messages[nextIdx] = promoted;
337
- 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);
338
353
  this.host.notify();
339
354
  }
340
355
 
@@ -506,46 +521,112 @@ export class ChatSession {
506
521
  }
507
522
 
508
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.
509
539
  typewriteIntoIndex(idx: number, fullText: string, localId?: string): Promise<void> {
510
540
  var self = this;
511
541
  if (!fullText) return Promise.resolve();
512
- var TICK_MS = 16, charsPerTick = 3, FENCE_REVEAL_MS = 200;
513
- var fenceTicks = Math.max(1, Math.floor(FENCE_REVEAL_MS / TICK_MS));
514
- 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;
515
553
  var fenceRegex = /```[\w.-]+\.[a-zA-Z0-9]+\n[\s\S]*?```/g;
516
- while ((m = fenceRegex.exec(fullText)) !== null) fenceRegions.push({ start: m.index, end: m.index + m[0].length });
517
- 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 });
518
555
  var linkRegex = createInlineLinkRegex();
519
- 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; });
520
558
 
521
559
  this.state.typing = true; this.state.typingAbort = false;
522
560
  var i = 0;
523
- return (function loop(): Promise<void> {
524
- if (self.state.typingAbort || i >= fullText.length) return Promise.resolve();
525
- var step = charsPerTick;
526
- var region = fenceRegions.find(function (r) { return i >= r.start && i < r.end; });
527
- var linkRegion = linkRegions.find(function (r) { return i >= r.start && i < r.end; });
528
- if (region) step = Math.max(charsPerTick, Math.ceil((region.end - i) / fenceTicks));
529
- else if (linkRegion) step = Math.max(charsPerTick, linkRegion.end - i);
530
- else {
531
- var nextLink = linkRegions.find(function (r) { return i < r.start && i + step > r.start; });
532
- 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();
533
587
  }
534
- i = Math.min(fullText.length, i + step);
535
- var currentIdx = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
536
- if (currentIdx === -1) return Promise.resolve();
537
- var target = self.state.messages[currentIdx];
538
- if (!target) return Promise.resolve();
539
- target.content = fullText.slice(0, i);
540
- self.host.refreshMessageBubble(currentIdx);
541
- return Promise.resolve(self.host.scrollToBottomIfSticky()).then(function () { return sleep(TICK_MS); }).then(loop);
542
- })().then(function () {
543
- if (!self.state.typingAbort) {
544
- var fi = localId ? self.state.messages.findIndex(function (mm) { return mm._localId === localId; }) : idx;
545
- var t = fi !== -1 ? self.state.messages[fi] : self.state.messages[idx];
546
- 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);
547
627
  }
548
- self.state.typing = false;
628
+ if (isHidden()) { finish(); return; }
629
+ nextFrame(frame);
549
630
  });
550
631
  }
551
632
 
@@ -845,12 +926,28 @@ export class ChatSession {
845
926
  chatList.forEach(function (item: any) {
846
927
  if (item.status !== 'running' && item.status !== 'pending') return;
847
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.
848
940
  if (self.historyItemPolls.has(item.id)) return;
849
- // running OR pending: a live dispatchAgentRequest already polls this
850
- // item; attaching a second (history) poll would double the interval
851
- // fetch on a remount (skapi item.poll() loops survive unmount —
852
- // they're uncancellable, so the dispatch poll is still running).
853
- 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;
854
951
  self.historyItemPolls.set(item.id, true);
855
952
  var capturedId = item.id;
856
953
  var pp = item.poll({