bunnyquery 1.6.2 → 1.7.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/README.md +157 -29
- package/bunnyquery.css +122 -0
- package/bunnyquery.js +998 -116
- package/dist/engine.cjs +724 -39
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +386 -4
- package/dist/engine.d.ts +386 -4
- package/dist/engine.mjs +712 -40
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/history.ts +20 -2
- package/src/engine/host.ts +28 -1
- package/src/engine/index.ts +24 -1
- package/src/engine/indexing_groups.ts +350 -0
- package/src/engine/links.ts +309 -9
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/prompts/indexing_user_message.ts +4 -0
- package/src/engine/requests.ts +1 -1
- package/src/engine/session.ts +394 -30
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +122 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep older history REACHABLE by paging until the message box actually gains
|
|
3
|
+
* something to scroll to.
|
|
4
|
+
*
|
|
5
|
+
* Older history is paged in by one trigger only: the user scrolling to the top
|
|
6
|
+
* of the message box. That trigger has two ways to die, and collapsed indexing
|
|
7
|
+
* rows cause both:
|
|
8
|
+
*
|
|
9
|
+
* 1. The box never scrolls. A file's every indexing pass (the first plus every
|
|
10
|
+
* CONTINUE pass, request AND response bubble each) folds into ONE row, so a
|
|
11
|
+
* full history page — twenty-plus messages — can render as a single line.
|
|
12
|
+
* Content shorter than the viewport fires no scroll event, so page 2 is
|
|
13
|
+
* never requested and any conversation the user had before that upload is
|
|
14
|
+
* permanently out of reach.
|
|
15
|
+
* 2. The fetched page adds no height. A page that is entirely the same file's
|
|
16
|
+
* earlier passes joins the collapsed row already on screen and renders
|
|
17
|
+
* nothing new. The user, sitting at scrollTop 0, scrolls up again — and
|
|
18
|
+
* because the position never changed, no further scroll event fires.
|
|
19
|
+
*
|
|
20
|
+
* Both are the same shape: fetch, re-measure, and keep going until the user
|
|
21
|
+
* genuinely gained reachable content, history ran out, or the pager stopped
|
|
22
|
+
* advancing. `isSatisfied` is what differs between the two (can the box scroll
|
|
23
|
+
* at all / did it grow), so the loop below takes it as a predicate.
|
|
24
|
+
*
|
|
25
|
+
* DOM-free like the rest of the engine — the caller supplies the measurement and
|
|
26
|
+
* awaits its own render before measuring, so agent.vue and the widget run the
|
|
27
|
+
* identical loop over their own pagers.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Overflow (px) that counts as "the user can scroll here". Comfortably more
|
|
31
|
+
* than the 60px top threshold that triggers the next page, so a filled box has
|
|
32
|
+
* real room to scroll rather than sitting one pixel from the trigger. */
|
|
33
|
+
export const HISTORY_FILL_SLACK_PX = 64;
|
|
34
|
+
|
|
35
|
+
/** Pages one fill pass will request before giving up. Reached only by a chat
|
|
36
|
+
* whose history really is dozens of pages of one file's indexing passes; the
|
|
37
|
+
* cap exists so a pager that stops advancing can never spin forever. */
|
|
38
|
+
export const MAX_HISTORY_FILL_PAGES = 24;
|
|
39
|
+
|
|
40
|
+
export type FillHistoryViewportOptions = {
|
|
41
|
+
/** The user has reachable content and paging can stop. Called AFTER the
|
|
42
|
+
* caller's own render has settled (nextTick / rAF), since only the caller
|
|
43
|
+
* knows when its view has painted — hence the allowance for a promise. */
|
|
44
|
+
isSatisfied: () => boolean | Promise<boolean>;
|
|
45
|
+
/** All history is loaded — nothing left to page in. */
|
|
46
|
+
isEndOfList: () => boolean;
|
|
47
|
+
/** A history request is already in flight. Waited out, not treated as a stop
|
|
48
|
+
* condition: a background first-page refresh (the queue-detect tick fires one
|
|
49
|
+
* every couple of seconds while a file is indexing) would otherwise swallow
|
|
50
|
+
* the user's scroll-up entirely, and scrolling up again from scrollTop 0
|
|
51
|
+
* produces no second event to retry with. */
|
|
52
|
+
isLoading: () => boolean;
|
|
53
|
+
/** Messages currently loaded. Used to detect a page that added nothing, which
|
|
54
|
+
* means the pager is not advancing and looping would never terminate. */
|
|
55
|
+
messageCount: () => number;
|
|
56
|
+
/** Fetch ONE older page (the caller's own fetchMore path, scroll-restore and
|
|
57
|
+
* all). Return `false` when the request was NOT issued (the caller's own
|
|
58
|
+
* single-flight guard swallowed it) so the loop retries instead of reading
|
|
59
|
+
* the unchanged message count as an exhausted pager. Anything else, including
|
|
60
|
+
* undefined, means it was attempted. */
|
|
61
|
+
fetchOlder: () => Promise<boolean | void | any>;
|
|
62
|
+
/** The chat this fill was started for is gone (project switched, view
|
|
63
|
+
* unmounted, gate token bumped). Checked between pages so a stale fill can
|
|
64
|
+
* never keep paging another chat's history. */
|
|
65
|
+
isStale?: () => boolean;
|
|
66
|
+
maxPages?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** How long to wait out an in-flight history request before giving up on it. */
|
|
70
|
+
const IDLE_WAIT_STEP_MS = 120;
|
|
71
|
+
const IDLE_WAIT_MAX_MS = 15000;
|
|
72
|
+
|
|
73
|
+
/** Resolve once no history request is in flight. False if the wait timed out or
|
|
74
|
+
* the chat went stale, in which case the caller should stop. */
|
|
75
|
+
async function waitForIdle(
|
|
76
|
+
opts: FillHistoryViewportOptions,
|
|
77
|
+
stale: () => boolean,
|
|
78
|
+
): Promise<boolean> {
|
|
79
|
+
var waited = 0;
|
|
80
|
+
while (opts.isLoading()) {
|
|
81
|
+
if (stale() || waited >= IDLE_WAIT_MAX_MS) return false;
|
|
82
|
+
await new Promise(function (r) { setTimeout(r, IDLE_WAIT_STEP_MS); });
|
|
83
|
+
waited += IDLE_WAIT_STEP_MS;
|
|
84
|
+
}
|
|
85
|
+
return !stale();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Page older history until `isSatisfied`, until history runs out, or until the
|
|
90
|
+
* pager stops advancing. Never throws: a failed page ends the fill, and the
|
|
91
|
+
* user's own scrolling remains the fallback trigger.
|
|
92
|
+
*/
|
|
93
|
+
export async function fillHistoryViewport(opts: FillHistoryViewportOptions): Promise<void> {
|
|
94
|
+
var maxPages = typeof opts.maxPages === 'number' ? opts.maxPages : MAX_HISTORY_FILL_PAGES;
|
|
95
|
+
var stale = function () { return !!(opts.isStale && opts.isStale()); };
|
|
96
|
+
var swallowed = 0;
|
|
97
|
+
|
|
98
|
+
for (var page = 0; page < maxPages; page++) {
|
|
99
|
+
if (stale() || opts.isEndOfList()) return;
|
|
100
|
+
if (!(await waitForIdle(opts, stale))) return;
|
|
101
|
+
var satisfied = false;
|
|
102
|
+
try {
|
|
103
|
+
satisfied = !!(await opts.isSatisfied());
|
|
104
|
+
} catch {
|
|
105
|
+
return; // cannot measure (view torn down mid-fill) — stop.
|
|
106
|
+
}
|
|
107
|
+
if (satisfied || stale()) return;
|
|
108
|
+
|
|
109
|
+
// Re-check immediately before dispatching: measuring above yields for a
|
|
110
|
+
// frame, and a background first-page refresh landing in that gap would make
|
|
111
|
+
// the caller's own single-flight guard swallow this request. A swallowed
|
|
112
|
+
// page adds no messages, which used to read as "the pager is exhausted" and
|
|
113
|
+
// abandoned the fill for good.
|
|
114
|
+
if (!(await waitForIdle(opts, stale))) return;
|
|
115
|
+
var before = opts.messageCount();
|
|
116
|
+
var attempted: boolean | void;
|
|
117
|
+
try {
|
|
118
|
+
attempted = await opts.fetchOlder();
|
|
119
|
+
} catch {
|
|
120
|
+
return; // history is optional; the scroll trigger stays as the fallback.
|
|
121
|
+
}
|
|
122
|
+
if (stale()) return;
|
|
123
|
+
if (attempted === false) {
|
|
124
|
+
// The caller reported it never issued the request. Retry it rather than
|
|
125
|
+
// mistaking it for an exhausted pager, but not forever.
|
|
126
|
+
if (++swallowed > 3) return;
|
|
127
|
+
page--;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// The page came back with nothing new. Either the pager is exhausted (and
|
|
131
|
+
// endOfList simply has not been set) or it is stuck; either way another
|
|
132
|
+
// round would request the same page again.
|
|
133
|
+
if (opts.messageCount() <= before) return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One fill loop per view, with predicates COMBINED rather than dropped.
|
|
139
|
+
*
|
|
140
|
+
* Fills come from several places at once — a first page finishing, a window
|
|
141
|
+
* resize, a row being collapsed, and the user's own scroll to the top — and a
|
|
142
|
+
* plain "one at a time, drop the rest" guard picks the wrong winner: a resize
|
|
143
|
+
* fill (satisfied the moment the box can scroll at all) would swallow the user's
|
|
144
|
+
* scroll-up (which needs content specifically ABOVE them), and the scroll-up
|
|
145
|
+
* cannot be retried, because a reader parked at scrollTop 0 produces no further
|
|
146
|
+
* scroll event. Dropping the guard entirely is no better: every frame of a
|
|
147
|
+
* window drag would start its own 24-page loop.
|
|
148
|
+
*
|
|
149
|
+
* So a request that arrives mid-loop ANDs its predicate into the running one:
|
|
150
|
+
* the loop then keeps paging until EVERY caller is satisfied. Predicates that
|
|
151
|
+
* come true are dropped as it goes, so the cost stays flat.
|
|
152
|
+
*/
|
|
153
|
+
export function createHistoryFiller(
|
|
154
|
+
base: Omit<FillHistoryViewportOptions, 'isSatisfied'>,
|
|
155
|
+
): { fill: (isSatisfied: () => boolean | Promise<boolean>) => Promise<void>; isRunning: () => boolean } {
|
|
156
|
+
var pending: Array<() => boolean | Promise<boolean>> = [];
|
|
157
|
+
var running = false;
|
|
158
|
+
|
|
159
|
+
async function allSatisfied(): Promise<boolean> {
|
|
160
|
+
var next: Array<() => boolean | Promise<boolean>> = [];
|
|
161
|
+
for (var i = 0; i < pending.length; i++) {
|
|
162
|
+
if (!(await pending[i]())) next.push(pending[i]);
|
|
163
|
+
}
|
|
164
|
+
pending = next;
|
|
165
|
+
return pending.length === 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
isRunning: function () { return running; },
|
|
170
|
+
fill: function (isSatisfied) {
|
|
171
|
+
pending.push(isSatisfied);
|
|
172
|
+
if (running) return Promise.resolve();
|
|
173
|
+
running = true;
|
|
174
|
+
var done = function () { running = false; pending = []; };
|
|
175
|
+
return fillHistoryViewport({
|
|
176
|
+
isSatisfied: allSatisfied,
|
|
177
|
+
isEndOfList: base.isEndOfList,
|
|
178
|
+
isLoading: base.isLoading,
|
|
179
|
+
messageCount: base.messageCount,
|
|
180
|
+
fetchOlder: base.fetchOlder,
|
|
181
|
+
isStale: base.isStale,
|
|
182
|
+
maxPages: base.maxPages,
|
|
183
|
+
}).then(done, done);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
package/styles/chat.css
CHANGED
|
@@ -99,6 +99,128 @@
|
|
|
99
99
|
.bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
|
|
100
100
|
.bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
101
101
|
|
|
102
|
+
/* ---- collapsed background-indexing group ---------------------------------*/
|
|
103
|
+
/* One file's many indexing passes (first pass + every CONTINUE pass, each with
|
|
104
|
+
a request AND a response bubble) render as a single status row instead of
|
|
105
|
+
filling the conversation with the same turn over and over. The row is a full
|
|
106
|
+
width strip rather than a bubble: it is chat CHROME, not something anyone
|
|
107
|
+
said. See engine/indexing_groups.ts for the grouping itself. */
|
|
108
|
+
.bq-index-group {
|
|
109
|
+
display: block;
|
|
110
|
+
margin-bottom: 0.5rem;
|
|
111
|
+
border: 1px solid var(--bq-line);
|
|
112
|
+
background: rgba(127, 127, 127, 0.05);
|
|
113
|
+
}
|
|
114
|
+
.bq-index-group.is-active { border-color: var(--bq-warning-border); background: var(--bq-warning-bg); }
|
|
115
|
+
.bq-index-group.is-error { border-color: var(--bq-danger); background: var(--bq-danger-bg); }
|
|
116
|
+
|
|
117
|
+
.bq-index-head {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 0.5rem;
|
|
121
|
+
width: 100%;
|
|
122
|
+
padding: 0.45rem 0.6rem;
|
|
123
|
+
border: 0;
|
|
124
|
+
border-radius: 0;
|
|
125
|
+
box-shadow: none;
|
|
126
|
+
background: transparent;
|
|
127
|
+
color: var(--bq-muted);
|
|
128
|
+
font: inherit;
|
|
129
|
+
font-size: 0.78rem;
|
|
130
|
+
line-height: 1.35;
|
|
131
|
+
text-align: left;
|
|
132
|
+
cursor: pointer;
|
|
133
|
+
min-height: 0;
|
|
134
|
+
}
|
|
135
|
+
.bq-index-head:hover { background: rgba(127, 127, 127, 0.08); color: var(--bq-muted); }
|
|
136
|
+
.bq-index-group.is-active .bq-index-head { color: var(--bq-warning); }
|
|
137
|
+
.bq-index-group.is-error .bq-index-head { color: var(--bq-danger); }
|
|
138
|
+
|
|
139
|
+
/* Status glyph. The active one is a circular-arrow SVG the consumer inlines
|
|
140
|
+
(never an icon font: agent.vue's Material Symbols build gates glyphs behind
|
|
141
|
+
an icon_names allowlist, which a shared stylesheet cannot reach). */
|
|
142
|
+
.bq-index-icon {
|
|
143
|
+
flex: 0 0 auto;
|
|
144
|
+
width: 0.95rem;
|
|
145
|
+
height: 0.95rem;
|
|
146
|
+
display: inline-flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
}
|
|
150
|
+
.bq-index-icon svg { width: 100%; height: 100%; display: block; }
|
|
151
|
+
.bq-index-group.is-active .bq-index-icon svg { animation: bq-index-spin 1.1s linear infinite; }
|
|
152
|
+
@keyframes bq-index-spin { to { transform: rotate(360deg); } }
|
|
153
|
+
@media (prefers-reduced-motion: reduce) {
|
|
154
|
+
.bq-index-group.is-active .bq-index-icon svg { animation-duration: 3s; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.bq-index-label {
|
|
158
|
+
flex: 1 1 auto;
|
|
159
|
+
min-width: 0;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
text-overflow: ellipsis;
|
|
162
|
+
white-space: nowrap;
|
|
163
|
+
}
|
|
164
|
+
.bq-index-label .bq-link-button { max-width: 100%; font-size: inherit; }
|
|
165
|
+
/* The label runs through the same markdown renderer as a bubble, which wraps it
|
|
166
|
+
in a block <p>. Inline it so the row stays one line high. */
|
|
167
|
+
.bq-index-label .bq-md,
|
|
168
|
+
.bq-index-label .bq-md p { display: inline; margin: 0; }
|
|
169
|
+
/* Passes LOADED, never a server-side total: history pages newest-first, so any
|
|
170
|
+
total derived from what is on screen is a lower bound. */
|
|
171
|
+
.bq-index-count { flex: 0 0 auto; opacity: 0.75; font-size: 0.72rem; }
|
|
172
|
+
/* Stop indexing. Only ever rendered while a file has a queued/running pass, so
|
|
173
|
+
it never competes with the row's normal (collapsed, finished) look. Sized off
|
|
174
|
+
the row's own font so it stays a thin strip, not a chat action button. */
|
|
175
|
+
.bq-index-cancel {
|
|
176
|
+
flex: 0 0 auto;
|
|
177
|
+
padding: 0.1rem 0.4rem;
|
|
178
|
+
min-height: 0;
|
|
179
|
+
border: 1px solid currentColor;
|
|
180
|
+
border-radius: 0;
|
|
181
|
+
background: transparent;
|
|
182
|
+
color: inherit;
|
|
183
|
+
font: inherit;
|
|
184
|
+
font-size: 0.68rem;
|
|
185
|
+
line-height: 1.3;
|
|
186
|
+
letter-spacing: 0.02em;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
box-shadow: none;
|
|
189
|
+
opacity: 0.8;
|
|
190
|
+
}
|
|
191
|
+
.bq-index-cancel:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); opacity: 1; }
|
|
192
|
+
.bq-index-cancel.is-disabled { opacity: 0.45; cursor: default; pointer-events: none; }
|
|
193
|
+
.bq-index-chevron {
|
|
194
|
+
flex: 0 0 auto;
|
|
195
|
+
font-size: 0.62rem;
|
|
196
|
+
opacity: 0.7;
|
|
197
|
+
transition: transform 0.15s ease;
|
|
198
|
+
}
|
|
199
|
+
.bq-index-group.is-open .bq-index-chevron { transform: rotate(90deg); }
|
|
200
|
+
|
|
201
|
+
/* Expanded: the file's own turns, in order, rendered as ordinary messages
|
|
202
|
+
directly under the row they collapsed into (they are scattered through the
|
|
203
|
+
conversation otherwise). Rendered as SIBLINGS of the row, not children, so
|
|
204
|
+
both consumers keep exactly one copy of the message-bubble markup. */
|
|
205
|
+
.bq-index-group.is-open { margin-bottom: 0.35rem; }
|
|
206
|
+
.bq-message.bq-index-pass {
|
|
207
|
+
margin-bottom: 0.3rem;
|
|
208
|
+
margin-left: 0.7rem;
|
|
209
|
+
padding-left: 0.7rem;
|
|
210
|
+
border-left: 2px solid var(--bq-line);
|
|
211
|
+
}
|
|
212
|
+
.bq-message.bq-index-pass .bq-bubble { font-size: 0.78rem; }
|
|
213
|
+
/* Close the run: the next ordinary message needs its normal breathing room. */
|
|
214
|
+
.bq-message.bq-index-pass + :not(.bq-index-pass) { margin-top: 0.55rem; }
|
|
215
|
+
.bq-index-note {
|
|
216
|
+
padding: 0 0.6rem 0.45rem;
|
|
217
|
+
font-size: 0.7rem;
|
|
218
|
+
font-style: italic;
|
|
219
|
+
color: var(--bq-muted);
|
|
220
|
+
}
|
|
221
|
+
/* A cancel that the server refused (the pass had already finished). */
|
|
222
|
+
.bq-index-note.is-error { color: var(--bq-danger); }
|
|
223
|
+
|
|
102
224
|
/* ---- in-bubble file / link anchors --------------------------------------*/
|
|
103
225
|
.bq-file-download,
|
|
104
226
|
.bq-link-button {
|