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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunnyquery",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.15",
|
|
4
4
|
"description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
|
|
5
5
|
"main": "bunnyquery.js",
|
|
6
6
|
"exports": {
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsup && node scripts/build-css.mjs",
|
|
34
34
|
"build:css": "node scripts/build-css.mjs",
|
|
35
|
-
"dev": "npx bns port=3333"
|
|
35
|
+
"dev": "npx bns port=3333",
|
|
36
|
+
"test": "for f in ./tests/*.cjs; do echo \"--- $f\"; node \"$f\" || exit 1; done"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"basic-node-server": "^1.1.1"
|
package/src/engine/history.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { extractClaudeText, extractOpenAIText, INDEXING_COMPLETE_MARKER, EMPTY_INDEXING_REPLY, getChatHistory, bgIndexingQueueName } from './requests';
|
|
8
8
|
import { isErrorResponseBody, getErrorMessage } from './errors';
|
|
9
9
|
import { sanitizeAttachmentLinksForHistory } from './links';
|
|
10
|
+
import type { ChatMessage } from './host';
|
|
10
11
|
|
|
11
12
|
export function filterListByClearHorizon(list: any[], clearedAt: number): any[] {
|
|
12
13
|
if (!clearedAt) return list;
|
|
@@ -732,3 +733,66 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
|
|
|
732
733
|
}
|
|
733
734
|
return { messages: mapped, runningItemIds: runningItemIds };
|
|
734
735
|
}
|
|
736
|
+
|
|
737
|
+
/* ---- rescuing in-flight bubbles across a first-page refetch ---------------
|
|
738
|
+
*
|
|
739
|
+
* A first-page fetch REPLACES the message list, and the list may hold bubbles the
|
|
740
|
+
* server does not know about yet: a turn whose request is still in flight, a
|
|
741
|
+
* staged turn whose files are still uploading, a queued turn waiting for its ack.
|
|
742
|
+
* Those have to survive the replace, and nothing else may.
|
|
743
|
+
*
|
|
744
|
+
* The rule lived twice — agent.vue's own fetchHistoryPage and the engine's
|
|
745
|
+
* loadHistory — and the two had to be edited in lockstep to stay honest. It is one
|
|
746
|
+
* function now, because the failure mode when they drift is a turn rendered twice
|
|
747
|
+
* and then PERSISTED into the history cache, where it survives every later visit.
|
|
748
|
+
*/
|
|
749
|
+
|
|
750
|
+
export interface RescueDecisionContext {
|
|
751
|
+
/** Is this `_serverItemId` in the page that was just fetched? */
|
|
752
|
+
hasServerId: (id: string) => boolean;
|
|
753
|
+
/**
|
|
754
|
+
* The fetched page already shows a non-background pending assistant.
|
|
755
|
+
*
|
|
756
|
+
* Only meaningful for a bubble with NO server id, where it is the sole
|
|
757
|
+
* available answer to "is this turn already represented?". For a bubble that
|
|
758
|
+
* HAS one, hasServerId answers exactly the same question exactly, and applying
|
|
759
|
+
* this on top of it would drop an in-flight turn whose server copy simply is
|
|
760
|
+
* not in the page that was fetched.
|
|
761
|
+
*/
|
|
762
|
+
pageHasPendingAssistant: boolean;
|
|
763
|
+
/** state.sending: an immediate send is in flight for this chat. */
|
|
764
|
+
sending: boolean;
|
|
765
|
+
/** The bubble directly after this one in the local list. */
|
|
766
|
+
next?: ChatMessage | null;
|
|
767
|
+
/** The chat this fetch is FOR. A bubble stamped for another must not cross. */
|
|
768
|
+
loadKey?: string;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function shouldRescueInFlightMessage(m: ChatMessage, ctx: RescueDecisionContext): boolean {
|
|
772
|
+
if (!m) return false;
|
|
773
|
+
// Background indexing bubbles come back through their own merge.
|
|
774
|
+
if (m.isBackgroundTask) return false;
|
|
775
|
+
// Never carry another project's (or another platform's) bubbles onto this chat.
|
|
776
|
+
if (m._ownerKey !== undefined && ctx.loadKey !== undefined && m._ownerKey !== ctx.loadKey) return false;
|
|
777
|
+
// The page carries a fresher copy of this exact turn.
|
|
778
|
+
if (m._serverItemId && ctx.hasServerId(m._serverItemId)) return false;
|
|
779
|
+
// A staged turn has no server request yet, so nothing in the page can stand for
|
|
780
|
+
// it. Unconditional: applying the pending-assistant test here would delete the
|
|
781
|
+
// user's message mid-upload whenever some other turn happened to be in flight.
|
|
782
|
+
if (m._stageId) return true;
|
|
783
|
+
if (!m._serverItemId && ctx.pageHasPendingAssistant) return false;
|
|
784
|
+
// In flight by its own flags, id or no id. The immediate-send pair is stamped
|
|
785
|
+
// with its server id as soon as the dispatch reports one, and that id is there
|
|
786
|
+
// to let the check above recognise the server's copy — not to disqualify the
|
|
787
|
+
// bubble from being kept when there is no such copy.
|
|
788
|
+
if (m.isSendingToServer || m.isPendingQueued || m.isPendingInProcess || m.isPending) return true;
|
|
789
|
+
// An immediate-send user bubble carries no flags of its own (its in-flight-ness
|
|
790
|
+
// lives in state.sending); what identifies it is its own unanswered placeholder
|
|
791
|
+
// directly below it.
|
|
792
|
+
if (ctx.sending && m.role === 'user') {
|
|
793
|
+
var next = ctx.next;
|
|
794
|
+
if (!next || next.isBackgroundTask || !next.isPending) return false;
|
|
795
|
+
return next._serverItemId === undefined || next._serverItemId === m._serverItemId;
|
|
796
|
+
}
|
|
797
|
+
return false;
|
|
798
|
+
}
|
package/src/engine/host.ts
CHANGED
|
@@ -194,6 +194,20 @@ export interface ChatHost {
|
|
|
194
194
|
* that cannot scroll has no way to reach page 2 (see viewport_fill). Only
|
|
195
195
|
* the view can measure that, which is why the engine merely announces it. */
|
|
196
196
|
onHistoryLoaded?(fetchMore: boolean, token: number): void;
|
|
197
|
+
/**
|
|
198
|
+
* A list refresh just changed heights: put the reader back where they were.
|
|
199
|
+
*
|
|
200
|
+
* Called at BOTH moments a first-page refresh moves things — the surface page
|
|
201
|
+
* landing, and the deferred background-indexing batch merging on top of it a
|
|
202
|
+
* round trip later — because leaving a wrong position on screen between the two
|
|
203
|
+
* is what reads as "the scroll jumped, then travelled somewhere else".
|
|
204
|
+
*
|
|
205
|
+
* The view owns the decision (it is the only side that can measure): pinned to
|
|
206
|
+
* the bottom means the bottom AFTER the batch merged, anywhere else means the
|
|
207
|
+
* exact line the reader was on. Falls back to scrollToBottomIfSticky when a host
|
|
208
|
+
* does not implement it, which is the old behaviour.
|
|
209
|
+
*/
|
|
210
|
+
settleScroll?(): void;
|
|
197
211
|
|
|
198
212
|
// --- skapi surface beyond configureChatEngine() ---
|
|
199
213
|
cancelRequest(opts: {
|
|
Binary file
|
package/src/engine/index.ts
CHANGED
|
@@ -68,6 +68,11 @@ export {
|
|
|
68
68
|
// read it the same way or the two will not group together.
|
|
69
69
|
isIndexingRequestText,
|
|
70
70
|
parseIndexingRequestText,
|
|
71
|
+
// One rule for which locally-pushed bubbles survive a first-page refetch.
|
|
72
|
+
// Shared because the failure mode when the two clients drift is a turn
|
|
73
|
+
// rendered twice and then persisted into the history cache.
|
|
74
|
+
shouldRescueInFlightMessage,
|
|
75
|
+
type RescueDecisionContext,
|
|
71
76
|
// One bounded look at the bg-indexing queue: which files still have a live
|
|
72
77
|
// pass. The dbfile browser's "indexed" badge uses this so a file only goes
|
|
73
78
|
// green once the run is confirmed over, not when its src:: record appears.
|
|
@@ -82,6 +87,18 @@ export {
|
|
|
82
87
|
// a box too short to scroll strands the user on page 1 — the normal state once a
|
|
83
88
|
// page of history collapses into one indexing row. Shared so both chatboxes page
|
|
84
89
|
// their way out of it identically.
|
|
90
|
+
// Holding the reader's place while the list mutates underneath them. Shared so
|
|
91
|
+
// an older page, an indexing row, a re-parsed chip and a decoded image preview
|
|
92
|
+
// are all absorbed the same way in both chatboxes.
|
|
93
|
+
export {
|
|
94
|
+
createScrollAnchor,
|
|
95
|
+
type ScrollAnchor,
|
|
96
|
+
type ScrollAnchorOptions,
|
|
97
|
+
type RowAnchor,
|
|
98
|
+
type AnchorBoxEl,
|
|
99
|
+
type AnchorRowEl,
|
|
100
|
+
} from './scroll_anchor';
|
|
101
|
+
|
|
85
102
|
export {
|
|
86
103
|
fillHistoryViewport,
|
|
87
104
|
createHistoryFiller,
|
|
@@ -113,7 +113,15 @@ export function renderInlineLinkHtml(link: RenderableInlineLink, opts?: InlineLi
|
|
|
113
113
|
'<img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '"' +
|
|
114
114
|
' data-bq-img-path="' + escapeInlineHtml(link.remotePath || '') + '"' +
|
|
115
115
|
' data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : '') + '"' +
|
|
116
|
-
|
|
116
|
+
// decoding="async" but NOT loading="lazy". Lazy guarantees the bytes arrive
|
|
117
|
+
// exactly when the image is near the viewport, which is the one case the
|
|
118
|
+
// scroll anchor deliberately declines to compensate for (growth at or below
|
|
119
|
+
// the fold happened on screen, under a line the reader is looking at) — so it
|
|
120
|
+
// shoved up to 320px of text under their eyes on every scroll toward it. It
|
|
121
|
+
// saved no mint either: hydration mints for every preview in the DOM
|
|
122
|
+
// regardless of viewport. It was also what turned the widget's per-notify
|
|
123
|
+
// teardown into a 13x amplifier (6224px vs 450px measured).
|
|
124
|
+
' decoding="async">' +
|
|
117
125
|
// Minting the url is a network round trip before the image even starts
|
|
118
126
|
// downloading, so the wait is real and needs a state. Inline load, so the
|
|
119
127
|
// dot trail, never the jumping bunny. CSS hides it the moment the <img>
|
package/src/engine/links.ts
CHANGED
|
@@ -656,12 +656,37 @@ export function classifyInlineLink(
|
|
|
656
656
|
* the placeholder href), so marking writes one key and the lookup tries all of
|
|
657
657
|
* them.
|
|
658
658
|
*/
|
|
659
|
+
/**
|
|
660
|
+
* Unicode form is not stable across the places a storage path travels through.
|
|
661
|
+
*
|
|
662
|
+
* macOS hands the browser a DECOMPOSED (NFD) filename, so a Korean name like
|
|
663
|
+
* 운전면허-김대현.jpg arrives as 24 codepoints where the composed (NFC) form is 12.
|
|
664
|
+
* Nothing in this engine normalized either way, so the SAME file could be keyed under
|
|
665
|
+
* two different strings depending on which path it travelled: a mark left by a failed
|
|
666
|
+
* mint under one form would never be cleared by a successful load under the other, and
|
|
667
|
+
* the chip stayed greyed out as "(unavailable)" forever.
|
|
668
|
+
*
|
|
669
|
+
* NFC is the canonical choice: it is what the Unicode standard recommends for
|
|
670
|
+
* interchange, and it is the shorter, more common form on the wire.
|
|
671
|
+
*/
|
|
672
|
+
export function canonicalizePathForm(value: string): string {
|
|
673
|
+
if (!value) return value;
|
|
674
|
+
try { return value.normalize('NFC'); } catch (e) { return value; }
|
|
675
|
+
}
|
|
676
|
+
|
|
659
677
|
export function linkUnavailableKeyForPath(remotePath: string): string {
|
|
660
|
-
|
|
678
|
+
// Canonicalized so the NFC and NFD spellings of one file share ONE key.
|
|
679
|
+
return 'path:' + canonicalizePathForm(remotePath || '');
|
|
661
680
|
}
|
|
662
681
|
|
|
663
682
|
export function linkUnavailableKeyForHref(href: string): string {
|
|
664
|
-
|
|
683
|
+
// An `_expired_.url` placeholder carries the storage path percent-encoded, so NFC and
|
|
684
|
+
// NFD spellings of one file produce two different href strings and therefore two
|
|
685
|
+
// different keys. Route those through the path key instead, so a file has ONE key
|
|
686
|
+
// however it is spelled and whichever carrier it arrived on.
|
|
687
|
+
var carried = readExpiredAttachmentHref(href);
|
|
688
|
+
if (carried) return linkUnavailableKeyForPath(carried);
|
|
689
|
+
return 'href:' + canonicalizePathForm(href || '');
|
|
665
690
|
}
|
|
666
691
|
|
|
667
692
|
/**
|
|
@@ -676,10 +701,13 @@ export function linkUnavailableKeyForHref(href: string): string {
|
|
|
676
701
|
*/
|
|
677
702
|
export function linkUnavailableKeysForPath(remotePath: string): string[] {
|
|
678
703
|
if (!remotePath) return [];
|
|
679
|
-
|
|
704
|
+
var keys = [
|
|
680
705
|
linkUnavailableKeyForPath(remotePath),
|
|
681
706
|
linkUnavailableKeyForHref(buildDisplayExpiredAttachmentHref(remotePath)),
|
|
682
707
|
];
|
|
708
|
+
// Both now canonicalize to the same key for a placeholder href, so drop the duplicate
|
|
709
|
+
// rather than marking and clearing the same entry twice.
|
|
710
|
+
return keys.filter(function (k, i) { return keys.indexOf(k) === i; });
|
|
683
711
|
}
|
|
684
712
|
|
|
685
713
|
export function isLinkUnavailable(
|