bunnyquery 1.4.5 → 1.5.0
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 +81 -0
- package/bunnyquery.js +171 -80
- package/dist/engine.cjs +133 -75
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.mjs +133 -75
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/session.ts +167 -70
- package/src/widget.css +81 -0
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;
|
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
var
|
|
514
|
-
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;
|
|
515
553
|
var fenceRegex = /```[\w.-]+\.[a-zA-Z0-9]+\n[\s\S]*?```/g;
|
|
516
|
-
while ((m = fenceRegex.exec(fullText)) !== null)
|
|
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 ((
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
var
|
|
527
|
-
var
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
var
|
|
546
|
-
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);
|
|
547
627
|
}
|
|
548
|
-
|
|
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
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
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({
|
package/src/widget.css
CHANGED
|
@@ -157,6 +157,87 @@
|
|
|
157
157
|
to { transform: rotate(360deg); }
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/* jumping ASCII bunny — full-area "loading/fetching" indicator (page/gate loads,
|
|
161
|
+
initial history fetch, settings panel). Ported from www.bunnyquery.com
|
|
162
|
+
bunnyLoader.vue. Two <pre> frames toggle + hop across the track and flip. */
|
|
163
|
+
.bq-bunny-loader {
|
|
164
|
+
display: flex;
|
|
165
|
+
flex-direction: column;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
gap: 0.75rem;
|
|
169
|
+
}
|
|
170
|
+
.bq-bunny-loader--overlay {
|
|
171
|
+
position: absolute;
|
|
172
|
+
inset: 0;
|
|
173
|
+
pointer-events: none;
|
|
174
|
+
z-index: 4;
|
|
175
|
+
}
|
|
176
|
+
.bq-bunny-stage {
|
|
177
|
+
display: inline-block;
|
|
178
|
+
position: relative;
|
|
179
|
+
text-align: left;
|
|
180
|
+
}
|
|
181
|
+
.bq-bunny-track {
|
|
182
|
+
position: relative;
|
|
183
|
+
height: 3.6rem;
|
|
184
|
+
width: 100%;
|
|
185
|
+
animation: bq-bunny-move 9s steps(12) infinite;
|
|
186
|
+
}
|
|
187
|
+
.bq-bunny-dir {
|
|
188
|
+
display: inline-block;
|
|
189
|
+
position: absolute;
|
|
190
|
+
bottom: 0;
|
|
191
|
+
left: 0;
|
|
192
|
+
animation: bq-bunny-flip 9s steps(1) infinite;
|
|
193
|
+
}
|
|
194
|
+
.bq-frame {
|
|
195
|
+
margin: 0;
|
|
196
|
+
/* Pin a Latin monospace font. Without it the <pre> falls back to the UA
|
|
197
|
+
`monospace` keyword, which on Korean/Japanese systems can resolve to a
|
|
198
|
+
CJK font that renders U+005C (backslash) as ₩ / ¥ — wrecking the art. */
|
|
199
|
+
font-family: var(--bq-mono);
|
|
200
|
+
font-size: 1rem;
|
|
201
|
+
line-height: 1.2;
|
|
202
|
+
white-space: pre;
|
|
203
|
+
color: var(--bq-ink);
|
|
204
|
+
}
|
|
205
|
+
.bq-frame-a {
|
|
206
|
+
animation: bq-bunny-toggle-a 1s steps(1) infinite;
|
|
207
|
+
}
|
|
208
|
+
.bq-frame-b {
|
|
209
|
+
animation: bq-bunny-toggle-b 1s steps(1) infinite;
|
|
210
|
+
opacity: 0;
|
|
211
|
+
}
|
|
212
|
+
.bq-bunny-loader__label {
|
|
213
|
+
color: var(--bq-ink);
|
|
214
|
+
font-family: var(--bq-mono);
|
|
215
|
+
font-size: 0.83rem;
|
|
216
|
+
}
|
|
217
|
+
@keyframes bq-bunny-move {
|
|
218
|
+
0% { transform: translateX(-11ch); }
|
|
219
|
+
25% { transform: translateX(4ch); }
|
|
220
|
+
37.5% { transform: translateX(4ch); }
|
|
221
|
+
50% { transform: translateX(4ch); }
|
|
222
|
+
75% { transform: translateX(-11ch); }
|
|
223
|
+
87.5% { transform: translateX(-11ch); }
|
|
224
|
+
100% { transform: translateX(-11ch); }
|
|
225
|
+
}
|
|
226
|
+
@keyframes bq-bunny-flip {
|
|
227
|
+
0% { transform: scaleX(1); }
|
|
228
|
+
50% { transform: scaleX(-1); }
|
|
229
|
+
}
|
|
230
|
+
@keyframes bq-bunny-toggle-a {
|
|
231
|
+
0% { opacity: 1; }
|
|
232
|
+
25% { opacity: 0; }
|
|
233
|
+
100% { opacity: 1; }
|
|
234
|
+
}
|
|
235
|
+
@keyframes bq-bunny-toggle-b {
|
|
236
|
+
0% { opacity: 0; }
|
|
237
|
+
25% { opacity: 1; }
|
|
238
|
+
100% { opacity: 0; }
|
|
239
|
+
}
|
|
240
|
+
|
|
160
241
|
/* ============================================================================
|
|
161
242
|
* AUTH VIEWS (login / signup / forgot / verify / settings)
|
|
162
243
|
* ==========================================================================*/
|