bunnyquery 1.6.0 → 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 CHANGED
@@ -23,10 +23,16 @@ you can build your own chat UI on top of it. See
23
23
  - **Conversation history** — paginated, with "Fetching history…" indicators on
24
24
  first load and on scroll-up.
25
25
  - **Attachments** — drag-and-drop files and folders, per-file upload status
26
- (uploading / failed / indexed), and overflow collapsing for large batches.
27
- Images are read with vision/OCR, Office/text/code files are extracted
28
- server-side, and PDFs are fetched by the model see
26
+ (uploading / failed / indexed), overflow collapsing for large batches, and a
27
+ prompt when an upload hits a file that already exists (skip / reindex only /
28
+ overwrite, with "apply to all remaining"). Images are read with vision/OCR,
29
+ large documents and spreadsheets are read window by window, PDFs are rendered
30
+ to page images, and everything else extractable is inlined as text. See
29
31
  [Supported file types](#supported-file-types).
32
+ - **Background indexing**: an uploaded file is indexed in the background,
33
+ across as many passes as it takes. A file's passes collapse into a single
34
+ status row in the chat that can be expanded, and stopped: "Stop" cancels every
35
+ queued and running pass at once and ends the continuation chain.
30
36
  - **Attachment parser plugins** — register a client-side parser so the widget
31
37
  extracts text in the browser from formats the model can't otherwise read, and
32
38
  indexes it directly. See [Attachment parser plugins](#attachment-parser-plugins).
@@ -138,15 +144,30 @@ BunnyQuery.logout();
138
144
 
139
145
  > `init()` is idempotent — calling it twice logs a warning and returns the existing
140
146
  > instance rather than re-mounting. On a successful mount it logs its version, e.g.
141
- > `[bunnyquery] v1.3.5`.
147
+ > `[bunnyquery] v1.7.0`.
148
+
149
+ See [HISTORY.md](HISTORY.md) for the release-by-release changelog.
142
150
 
143
151
  ## Supported file types
144
152
 
145
153
  When a user attaches a file, BunnyQuery makes its contents available to the AI
146
- automatically detected by extension (with a MIME-type fallback), nothing to
147
- configure. There are three paths, plus a couple of caveats.
154
+ automatically, detected by extension (with a MIME-type fallback), nothing to
155
+ configure.
156
+
157
+ An attachment is used in two places, and they take different routes:
158
+
159
+ - **In the chat message.** Extractable files are inlined as text; anything else
160
+ (PDFs, images) is handed over as a temporary link, which the proxy worker
161
+ re-mints just before the upstream call so a queued message can never hand the
162
+ model a stale URL.
163
+ - **In background indexing**, where the file is read in full and saved into the
164
+ project's knowledge. This is the path with the window and page loops below.
148
165
 
149
- ### 1. Images read directly by the model (vision + OCR)
166
+ The routes are tried in this order: a [parser plugin](#attachment-parser-plugins),
167
+ then PDF page rendering, then windowed or paged reading, then server-side
168
+ extraction, then a plain link.
169
+
170
+ ### 1. Images: read directly by the model (vision + OCR)
150
171
 
151
172
  `.jpg` · `.jpeg` · `.png` · `.gif` · `.webp`
152
173
 
@@ -155,10 +176,51 @@ picture** and **reads any text in it (OCR)**. Works on both Claude and OpenAI.
155
176
  Only images referenced in the **most recent** message are inlined (older links
156
177
  may have expired).
157
178
 
158
- ### 2. Documents, data & code — extracted on the server (inlined as text)
179
+ ### 2. PDFs: rendered to page images
180
+
181
+ `.pdf`
182
+
183
+ PDF text layers are often absent or unreliable, so a PDF is indexed **visually**:
184
+ the proxy worker renders a window of pages (5 at a time) to images and injects
185
+ them as image blocks in the indexing message. Tool-result images render on
186
+ neither provider, which is why the pages have to be in the message itself. That
187
+ makes scanned PDFs work as well as digital ones.
188
+
189
+ The worker advances the window itself, off its renderer's true page count, and
190
+ enqueues the next pass. Indexing a long document therefore does not depend on
191
+ the browser tab staying open, and does not depend on the model correctly
192
+ declaring itself finished.
193
+
194
+ ### 3. Large documents, spreadsheets & data: read window by window
195
+
196
+ ```
197
+ .xls .xlsx .xlsm .ods grids (rows plus embedded photos)
198
+ .csv .tsv .tab row-bounded windows with absolute row numbers
199
+ .docx .pptx documents
200
+ .txt .md .markdown .log plain text
201
+ .json .jsonl .ndjson .xml .yaml .yml
202
+ ```
203
+
204
+ These are read **one window at a time** and continued until the file is
205
+ exhausted, rather than inlined once. Whole-file extraction is capped at 200,000
206
+ characters, and against real files that cap was discarding most of every large
207
+ upload: a 5MB `.txt` indexed 4.0% of its content, a 4.8MB `.json` 4.2%, a
208
+ 1.9M-character Korean `.txt` 10.5%, a `.docx` 70.6%. Nothing surfaced the loss,
209
+ because the agent received a plausible-looking document with no way to know most
210
+ of it was missing.
211
+
212
+ Two drivers exist for this loop. By default the agent pages the file itself with
213
+ the `readFileContent` tool. With the engine's `windowedIndexing` option enabled,
214
+ the **worker** reads a window per request and continues from the reader's own
215
+ cursor, so the traversal no longer has to fit inside the model's turn budget.
216
+ That option is off by default and must only be turned on against a deployed
217
+ worker (see [Importing the chat engine](#importing-the-chat-engine)); the widget
218
+ does not enable it.
219
+
220
+ ### 4. Everything else extractable: inlined as text server-side
159
221
 
160
222
  The skapi proxy downloads the file, extracts its text **server-side**, and
161
- inlines that text into the request the model reads it directly, with no
223
+ inlines that text into the request, so the model reads it directly with no
162
224
  fetching. This keeps indexing consistent across model providers.
163
225
 
164
226
  **Office & e-book** (binary/zip, parsed):
@@ -178,21 +240,24 @@ Plus a **MIME fallback**: any file whose content type is text-like (`text/*`,
178
240
  `application/json`, `application/xml`, `*+json`, `*+xml`, `*+yaml`, …) is decoded
179
241
  even when its extension isn't in the list above.
180
242
 
181
- Encoding is auto-detected UTF-8 (BOM-aware) CP949/EUC-KR (Korean) → Latin-1.
182
- Extracted text is capped at **200,000 characters**; longer files are truncated
183
- with a `...[truncated for length; original N characters]` marker.
243
+ Encoding is auto-detected: UTF-8 (BOM-aware), then CP949/EUC-KR (Korean), then
244
+ Latin-1. Extracted text is capped at **200,000 characters**; longer files are
245
+ truncated with a `...[truncated for length; original N characters]` marker. The
246
+ formats listed in section 3 are windowed precisely so they never hit that cap.
247
+
248
+ Note the overlap between sections 3 and 4 is deliberate: a `.docx` or a `.csv`
249
+ is windowed when it is indexed, and extracted whole when it rides along in a
250
+ chat message.
184
251
 
185
- ### 3. PDFs & other links — fetched by the model
252
+ ### 5. Anything else: a plain link
186
253
 
187
- `.pdf` (and any file that is neither an image nor server-extractable) is handed
188
- to the model as a temporary link, which it opens with its built-in web tool:
189
- **Claude** via `web_fetch`, **OpenAI** via `web_search` (external web access is
190
- enabled). Both can open and read PDFs, so PDFs work on either provider.
254
+ A file that is none of the above is handed to the model as a temporary link,
255
+ which it opens with its built-in web tool: **Claude** via `web_fetch`, **OpenAI**
256
+ via `web_search` (external web access is enabled).
191
257
 
192
- > A provider's web tool opens document/page-style URLs such as PDFs, but not
193
- > necessarily a bare *data-file* download (e.g. a raw `.csv`/`.tsv` link). That's
194
- > why those data formats are extracted **server-side** (path 2 above) instead of
195
- > being left to the model to fetch.
258
+ > A provider's web tool opens document/page-style URLs, but not necessarily a
259
+ > bare *data-file* download (e.g. a raw `.csv`/`.tsv` link). That is why those
260
+ > data formats are extracted server-side instead of being left to the model.
196
261
 
197
262
  ### Caveats
198
263
 
@@ -205,16 +270,32 @@ enabled). Both can open and read PDFs, so PDFs work on either provider.
205
270
  [Attachment parser plugin](#attachment-parser-plugins) — it runs in the browser
206
271
  and feeds parsed text straight into indexing.
207
272
 
273
+ ### Re-indexing an existing file
274
+
275
+ Uploading over a file that already exists prompts for skip, "reindex only", or
276
+ overwrite. Choosing either of the latter two deletes the file's existing
277
+ `src::<path>` index record first, and the skapi backend cascades that delete to
278
+ the record's reference-linked children, so re-indexing **replaces** the file's
279
+ knowledge rather than duplicating it.
280
+
281
+ ### Filenames
282
+
283
+ Storage keys preserve Unicode letters, digits and spaces, NFC-normalized, so
284
+ Korean, Japanese and accented Latin filenames survive upload intact. Only
285
+ genuinely unsafe characters are replaced. The original name is always kept for
286
+ display.
287
+
208
288
  ## Attachment parser plugins
209
289
 
210
- By default the chat agent reads images with vision/OCR, extracts
211
- Office/OpenDocument/EPUB and text/data/code files on the server, and lets the
212
- model fetch PDFs with its built-in web tool (`web_fetch` on Claude, `web_search`
213
- on OpenAI) — see [Supported file types](#supported-file-types). For any format
214
- read by **none** of these (e.g. a proprietary binary format), register a
215
- **parser plugin**: it runs in the browser, turns the
216
- uploaded file into text (or an HTML string), and the widget sends that content
217
- **inline** for indexing no `web_fetch`, no server extraction for that file.
290
+ By default the chat agent reads images with vision/OCR, renders PDF pages to
291
+ images, reads large documents and spreadsheets window by window, and extracts
292
+ Office/OpenDocument/EPUB and text/data/code files on the server. See
293
+ [Supported file types](#supported-file-types). For any format read by **none**
294
+ of these (e.g. a proprietary binary format), register a **parser plugin**: it
295
+ runs in the browser, turns the uploaded file into text (or an HTML string), and
296
+ the widget sends that content **inline** for indexing. A parser plugin takes
297
+ precedence over every other route, so nothing is fetched or extracted for that
298
+ file.
218
299
 
219
300
  BunnyQuery ships only the **mechanism**. You bring the parsing library (so the
220
301
  widget stays lean and you choose which formats and which library).
@@ -346,6 +427,37 @@ helpers — see the `.d.ts` shipped with `bunnyquery/engine`.
346
427
  | `clientSecretRequestHistory` | `function` | `skapi.clientSecretRequestHistory`, bound to your Skapi instance. **Required.** |
347
428
  | `mcpBaseUrl` | `string` | MCP server base URL (you resolve prod vs dev). **Required.** |
348
429
  | `poll` | `number?` | Value attached as `poll` on every request. Omit it if your `clientSecretRequest` already resolves with the final body; pass `0` for the deployed `skapi-js@latest` (needed for the early ack + a manual `.poll()` handle that powers queued-send cancel — the widget's case). |
430
+ | `attachmentParsers` | `array?` | Client-side attachment parsers, registered at configure time. More can be added later with `registerAttachmentParser()`. See [Attachment parser plugins](#attachment-parser-plugins). |
431
+ | `windowedIndexing` | `boolean?` | Opt in to **server-driven** windowed indexing for text and grid files (see [file types](#supported-file-types)). Off by default and must stay off until the worker that strips the `_skapi_window` directive is deployed: against an older worker the directive reaches the provider as an unknown body field and the call fails terminally with no retry. |
432
+
433
+ ### Display and paging helpers
434
+
435
+ Two shared transforms exist so that a second chat UI behaves identically to the
436
+ widget rather than approximately:
437
+
438
+ - **`buildChatDisplayList`** collapses a file's many background-indexing turns
439
+ into one status row per indexing run, wherever those turns sit in the
440
+ conversation, rendered at that run's newest turn. It is pure and
441
+ view-agnostic; you render the resulting `DisplayEntry` list. Pair it with
442
+ `ChatSession.cancelIndexingGroup(group)` to give the row a working Stop
443
+ button, which cancels every queued and running pass of that file at once and
444
+ ends the continuation chain.
445
+ - **`fillHistoryViewport` / `createHistoryFiller`** keep older history
446
+ reachable. Paging is triggered only by scrolling to the top of the message
447
+ box, so a box too short to scroll has no trigger at all, which is the normal
448
+ state once a page of history collapses into a single indexing row. Implement
449
+ the optional `ChatHost.onHistoryLoaded` hook, measure your own box, and let
450
+ the loop page until the reader genuinely gained reachable content.
451
+
452
+ Other optional `ChatHost` hooks worth implementing: `deleteExistingFileRecord`
453
+ (so a reindex replaces the file's knowledge instead of duplicating it) and
454
+ `promptOverwrite` (the skip / reindex / overwrite prompt).
455
+
456
+ `ChatSession.pausePolling(reason)` and `resumePolling(reason)` stop background
457
+ indexing polls when nobody is looking (a hidden tab, a detached view). Replies
458
+ the user is waiting on keep polling deliberately, so their results still land in
459
+ the cache. Server-side work is untouched either way, so pausing drops traffic,
460
+ never progress.
349
461
 
350
462
  ## OAuth & redirects
351
463
 
@@ -356,11 +468,27 @@ host page** — BunnyQuery reads the `?code=…&state=…` parameters, completes
356
468
  exchange, and cleans them from the URL automatically. No dedicated callback page is
357
469
  needed; just make sure the page that hosts the widget is a stable, reachable URL.
358
470
 
471
+ Once granted, the connection is kept alive **silently**. When the stored grant
472
+ ages out, BunnyQuery refreshes it through the OAuth `refresh_token` flow with no
473
+ redirect, so an embedded widget never yanks the host page away mid-chat. It also
474
+ refreshes on tab focus, because returning to a backgrounded tab after the grant
475
+ expired would otherwise disconnect the next message. The full redirect is only a
476
+ boot-time fallback for when the silent path cannot refresh.
477
+
359
478
  ## Notes
360
479
 
361
480
  - The widget fills its mount element. Give that element a real height (e.g.
362
481
  `height: 100dvh`) or it will collapse.
363
482
  - File and folder uploads are stored in your Skapi project's database storage and
364
483
  served from a temporary db-CDN URL (`hostDomain`); links in chat refresh on expiry.
484
+ Links a queued message carries are re-minted server-side immediately before the
485
+ upstream call, so a message that waits in the queue never hands the model a dead
486
+ URL.
487
+ - The number of files attachable to a single message is capped, and beyond a
488
+ point the chips collapse into a "...(n) more" pill rather than being rendered.
489
+ Very large batches belong on a dedicated upload page, not the chat composer.
490
+ - When your service database is frozen, the attach button and drag-and-drop are
491
+ hidden for non-admin users, mirroring the backend's own upload gate, so there
492
+ is no upload path that fails only at the end.
365
493
  - The agent shown in the header (`BunnyQuery · <project name>`) reflects the project
366
494
  configured for your Skapi service.
package/bunnyquery.css CHANGED
@@ -1095,6 +1095,128 @@
1095
1095
  .bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
1096
1096
  .bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
1097
1097
 
1098
+ /* ---- collapsed background-indexing group ---------------------------------*/
1099
+ /* One file's many indexing passes (first pass + every CONTINUE pass, each with
1100
+ a request AND a response bubble) render as a single status row instead of
1101
+ filling the conversation with the same turn over and over. The row is a full
1102
+ width strip rather than a bubble: it is chat CHROME, not something anyone
1103
+ said. See engine/indexing_groups.ts for the grouping itself. */
1104
+ .bq-index-group {
1105
+ display: block;
1106
+ margin-bottom: 0.5rem;
1107
+ border: 1px solid var(--bq-line);
1108
+ background: rgba(127, 127, 127, 0.05);
1109
+ }
1110
+ .bq-index-group.is-active { border-color: var(--bq-warning-border); background: var(--bq-warning-bg); }
1111
+ .bq-index-group.is-error { border-color: var(--bq-danger); background: var(--bq-danger-bg); }
1112
+
1113
+ .bq-index-head {
1114
+ display: flex;
1115
+ align-items: center;
1116
+ gap: 0.5rem;
1117
+ width: 100%;
1118
+ padding: 0.45rem 0.6rem;
1119
+ border: 0;
1120
+ border-radius: 0;
1121
+ box-shadow: none;
1122
+ background: transparent;
1123
+ color: var(--bq-muted);
1124
+ font: inherit;
1125
+ font-size: 0.78rem;
1126
+ line-height: 1.35;
1127
+ text-align: left;
1128
+ cursor: pointer;
1129
+ min-height: 0;
1130
+ }
1131
+ .bq-index-head:hover { background: rgba(127, 127, 127, 0.08); color: var(--bq-muted); }
1132
+ .bq-index-group.is-active .bq-index-head { color: var(--bq-warning); }
1133
+ .bq-index-group.is-error .bq-index-head { color: var(--bq-danger); }
1134
+
1135
+ /* Status glyph. The active one is a circular-arrow SVG the consumer inlines
1136
+ (never an icon font: agent.vue's Material Symbols build gates glyphs behind
1137
+ an icon_names allowlist, which a shared stylesheet cannot reach). */
1138
+ .bq-index-icon {
1139
+ flex: 0 0 auto;
1140
+ width: 0.95rem;
1141
+ height: 0.95rem;
1142
+ display: inline-flex;
1143
+ align-items: center;
1144
+ justify-content: center;
1145
+ }
1146
+ .bq-index-icon svg { width: 100%; height: 100%; display: block; }
1147
+ .bq-index-group.is-active .bq-index-icon svg { animation: bq-index-spin 1.1s linear infinite; }
1148
+ @keyframes bq-index-spin { to { transform: rotate(360deg); } }
1149
+ @media (prefers-reduced-motion: reduce) {
1150
+ .bq-index-group.is-active .bq-index-icon svg { animation-duration: 3s; }
1151
+ }
1152
+
1153
+ .bq-index-label {
1154
+ flex: 1 1 auto;
1155
+ min-width: 0;
1156
+ overflow: hidden;
1157
+ text-overflow: ellipsis;
1158
+ white-space: nowrap;
1159
+ }
1160
+ .bq-index-label .bq-link-button { max-width: 100%; font-size: inherit; }
1161
+ /* The label runs through the same markdown renderer as a bubble, which wraps it
1162
+ in a block <p>. Inline it so the row stays one line high. */
1163
+ .bq-index-label .bq-md,
1164
+ .bq-index-label .bq-md p { display: inline; margin: 0; }
1165
+ /* Passes LOADED, never a server-side total: history pages newest-first, so any
1166
+ total derived from what is on screen is a lower bound. */
1167
+ .bq-index-count { flex: 0 0 auto; opacity: 0.75; font-size: 0.72rem; }
1168
+ /* Stop indexing. Only ever rendered while a file has a queued/running pass, so
1169
+ it never competes with the row's normal (collapsed, finished) look. Sized off
1170
+ the row's own font so it stays a thin strip, not a chat action button. */
1171
+ .bq-index-cancel {
1172
+ flex: 0 0 auto;
1173
+ padding: 0.1rem 0.4rem;
1174
+ min-height: 0;
1175
+ border: 1px solid currentColor;
1176
+ border-radius: 0;
1177
+ background: transparent;
1178
+ color: inherit;
1179
+ font: inherit;
1180
+ font-size: 0.68rem;
1181
+ line-height: 1.3;
1182
+ letter-spacing: 0.02em;
1183
+ cursor: pointer;
1184
+ box-shadow: none;
1185
+ opacity: 0.8;
1186
+ }
1187
+ .bq-index-cancel:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); opacity: 1; }
1188
+ .bq-index-cancel.is-disabled { opacity: 0.45; cursor: default; pointer-events: none; }
1189
+ .bq-index-chevron {
1190
+ flex: 0 0 auto;
1191
+ font-size: 0.62rem;
1192
+ opacity: 0.7;
1193
+ transition: transform 0.15s ease;
1194
+ }
1195
+ .bq-index-group.is-open .bq-index-chevron { transform: rotate(90deg); }
1196
+
1197
+ /* Expanded: the file's own turns, in order, rendered as ordinary messages
1198
+ directly under the row they collapsed into (they are scattered through the
1199
+ conversation otherwise). Rendered as SIBLINGS of the row, not children, so
1200
+ both consumers keep exactly one copy of the message-bubble markup. */
1201
+ .bq-index-group.is-open { margin-bottom: 0.35rem; }
1202
+ .bq-message.bq-index-pass {
1203
+ margin-bottom: 0.3rem;
1204
+ margin-left: 0.7rem;
1205
+ padding-left: 0.7rem;
1206
+ border-left: 2px solid var(--bq-line);
1207
+ }
1208
+ .bq-message.bq-index-pass .bq-bubble { font-size: 0.78rem; }
1209
+ /* Close the run: the next ordinary message needs its normal breathing room. */
1210
+ .bq-message.bq-index-pass + :not(.bq-index-pass) { margin-top: 0.55rem; }
1211
+ .bq-index-note {
1212
+ padding: 0 0.6rem 0.45rem;
1213
+ font-size: 0.7rem;
1214
+ font-style: italic;
1215
+ color: var(--bq-muted);
1216
+ }
1217
+ /* A cancel that the server refused (the pass had already finished). */
1218
+ .bq-index-note.is-error { color: var(--bq-danger); }
1219
+
1098
1220
  /* ---- in-bubble file / link anchors --------------------------------------*/
1099
1221
  .bq-file-download,
1100
1222
  .bq-link-button {