bunnyquery 1.9.7 → 1.10.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
@@ -27,7 +27,8 @@ you can build your own chat UI on top of it. See
27
27
  prompt when an upload hits a file that already exists (skip / reindex only /
28
28
  overwrite, with "apply to all remaining"). Images are read with vision/OCR,
29
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
30
+ to page images, emails are read as their headers, body and attachment text,
31
+ and everything else extractable is inlined as text. See
31
32
  [Supported file types](#supported-file-types).
32
33
  - **Background indexing**: an uploaded file is indexed in the background,
33
34
  across as many passes as it takes. A file's passes collapse into a single
@@ -124,6 +125,9 @@ Mounts the widget. Returns the `BunnyQuery` object.
124
125
  | `hostDomain` | `string` | `null` | db-CDN host for temporary file URLs. Defaults to `skapi.app` (dev) / `skapi.com` (prod). |
125
126
  | `attachmentParsers` | `array` | `null` | Client-side attachment parsers. See [Attachment parser plugins](#attachment-parser-plugins). |
126
127
  | `windowedIndexing` | `boolean` | `true` | Server-driven windowed indexing for text and grid files (see [file types](#supported-file-types)). Pass `false` to fall back to agent-driven paging, which keeps the traversal inside the model's turn budget and the tab open. |
128
+ | `allowAnonymous` | `boolean` | `null` | Open the chat with no login for visitors without an account. `null` follows the project's own "Allow anonymous users" setting (`getConnectionInfo().conf.require_login`); `true`/`false` pins it. |
129
+ | `liveStreaming` | `boolean` | `false` | Paint a chat answer into its bubble as it arrives, instead of at the end. A **request**, not a switch: the widget honours it only when your page's `skapi-js` actually carries skapi's half of the stream flag (it checks for `clientSecretRequestStream` and `clientSecretRequestFinalize`), and otherwise warns once and falls back to buffered replies. An older SDK silently drops the flag, which would leave the destination streaming SSE into a buffered row that reads back empty. It still also needs a polling worker that relays the response bytes, which the widget cannot check, so leave it off until the region you talk to is deployed. |
130
+ | `liveStreamingRealtime` | `boolean` | `false` | Deliver streamed chunks over skapi's websocket instead of waiting for the next poll tick. Requires `liveStreaming`. Off unless you ask for it: skapi's `joinRealtime` **replaces** the connection's group, so for the length of a turn it takes the room out from under whatever else your app uses realtime for. Purely an accelerator; with it off the reply still streams, on the poll's cadence. |
127
131
 
128
132
  ### Methods
129
133
 
@@ -158,9 +162,11 @@ configure.
158
162
  An attachment is used in two places, and they take different routes:
159
163
 
160
164
  - **In the chat message.** Extractable files are inlined as text; anything else
161
- (PDFs, images) is handed over as a temporary link, which the proxy worker
162
- re-mints just before the upstream call so a queued message can never hand the
163
- model a stale URL.
165
+ (PDFs, images) is handed over as a temporary link. Server-side re-minting of
166
+ chat links is deliberately off (an S3 presign is signed for GET only and 403s
167
+ the HEAD probe OpenAI sends before downloading), so the CDN link is left in
168
+ place; the turn is instead dispatched only once the indexing queue has drained,
169
+ which is what keeps the link fresh.
164
170
  - **In background indexing**, where the file is read in full and saved into the
165
171
  project's knowledge. This is the path with the window and page loops below.
166
172
 
@@ -182,10 +188,12 @@ may have expired).
182
188
  `.pdf`
183
189
 
184
190
  PDF text layers are often absent or unreliable, so a PDF is indexed **visually**:
185
- the proxy worker renders a window of pages (5 at a time) to images and injects
186
- them as image blocks in the indexing message. Tool-result images render on
187
- neither provider, which is why the pages have to be in the message itself. That
188
- makes scanned PDFs work as well as digital ones.
191
+ the proxy worker renders a window of pages to images and injects them as image
192
+ blocks in the indexing message. The window is five pages on Claude and on the
193
+ OpenAI models that accept full-resolution images, and two on OpenAI's
194
+ downsampled and nano tiers. Tool-result images render on neither provider, which
195
+ is why the pages have to be in the message itself. That makes scanned PDFs work
196
+ as well as digital ones.
189
197
 
190
198
  The worker advances the window itself, off its renderer's true page count, and
191
199
  enqueues the next pass. Indexing a long document therefore does not depend on
@@ -195,9 +203,15 @@ declaring itself finished.
195
203
  ### 3. Large documents, spreadsheets & data: read window by window
196
204
 
197
205
  ```
198
- .xls .xlsx .xlsm .ods grids (rows plus embedded photos)
206
+ .xls .xlsx .xlsm grids: sheet-by-sheet row windows, plus embedded photos
207
+ .ods OpenDocument sheets: character windows, plus photos
199
208
  .csv .tsv .tab row-bounded windows with absolute row numbers
200
- .docx .pptx documents
209
+ .doc .docx .docm word processor documents
210
+ .ppt .pptx .pptm slide decks
211
+ .hwp .hwpx Hancom word processor
212
+ .odt .odp OpenDocument text and slides
213
+ .epub .rtf .html .htm other long-form documents
214
+ .eml email: headers, body, attachment text
201
215
  .txt .md .markdown .log plain text
202
216
  .json .jsonl .ndjson .xml .yaml .yml
203
217
  ```
@@ -224,13 +238,19 @@ The skapi proxy downloads the file, extracts its text **server-side**, and
224
238
  inlines that text into the request, so the model reads it directly with no
225
239
  fetching. This keeps indexing consistent across model providers.
226
240
 
227
- **Office & e-book** (binary/zip, parsed; includes legacy binary `.doc`/`.xls`/`.ppt`
228
- and the macro-enabled `.docm`/`.xlsm`/`.pptm`):
241
+ **Office, e-book & email** (binary/zip/MIME, parsed; includes legacy binary
242
+ `.doc`/`.xls`/`.ppt` and the macro-enabled `.docm`/`.xlsm`/`.pptm`):
229
243
  `.doc` · `.docx` · `.docm` · `.xls` · `.xlsx` · `.xlsm` · `.ppt` · `.pptx` · `.pptm`
230
- · `.hwp` · `.hwpx` · `.ods` · `.odt` · `.odp` · `.epub`
244
+ · `.hwp` · `.hwpx` · `.ods` · `.odt` · `.odp` · `.epub` · `.eml`
245
+
246
+ An `.eml` email yields its header block, its body and the text of every attached
247
+ document (spreadsheet, document, csv, calendar, the text layer of a PDF) inline;
248
+ pictures attached to or embedded in it are extracted into `__MEDIA__` like the
249
+ pictures in any other document, and every other attachment is listed by name
250
+ only, never saved as a separate file.
231
251
 
232
252
  **Text, data, markup & source code** (decoded as text; `.html`/`.htm` have their
233
- tags stripped):
253
+ tags stripped and `.rtf` is parsed, control words and non-text groups discarded):
234
254
 
235
255
  ```
236
256
  .csv .tsv .tab .txt .text .log .md .markdown .rst .json .ndjson .jsonl .geojson
@@ -243,10 +263,18 @@ Plus a **MIME fallback**: any file whose content type is text-like (`text/*`,
243
263
  `application/json`, `application/xml`, `*+json`, `*+xml`, `*+yaml`, …) is decoded
244
264
  even when its extension isn't in the list above.
245
265
 
246
- Encoding is auto-detected: UTF-8 (BOM-aware), then CP949/EUC-KR (Korean), then
247
- Latin-1. Extracted text is capped at **200,000 characters**; longer files are
248
- truncated with a `...[truncated for length; original N characters]` marker. The
249
- formats listed in section 3 are windowed precisely so they never hit that cap.
266
+ Encoding is auto-detected: a UTF-32 or UTF-16 BOM is taken as definitive,
267
+ otherwise UTF-8 (BOM-aware), CP949/EUC-KR (Korean) and Latin-1 are all decoded
268
+ and scored, and the one producing the least mojibake wins. It is a scoring pass,
269
+ not a first-that-succeeds ladder, so one stray byte in a clean Korean file no
270
+ longer dumps the whole file into Latin-1. Extracted text is capped at **200,000
271
+ characters**; longer files are truncated with a `...[truncated for length;
272
+ showing the first 200000 of N characters. To read and index the WHOLE file, call
273
+ the readFileContent tool with this file's storage path; it returns the file
274
+ window by window (with images for scanned/photo content).]` marker. (The separate
275
+ client-side parser-plugin cap uses the shorter `...[truncated for length;
276
+ original N characters]` marker.) The formats listed in section 3 are windowed
277
+ precisely so they never hit that cap.
250
278
 
251
279
  Note the overlap between sections 3 and 4 is deliberate: a `.docx` or a `.csv`
252
280
  is windowed when it is indexed, and extracted whole when it rides along in a
@@ -288,7 +316,7 @@ display.
288
316
 
289
317
  By default the chat agent reads images with vision/OCR, renders PDF pages to
290
318
  images, reads large documents and spreadsheets window by window, and extracts
291
- Office/OpenDocument/EPUB and text/data/code files on the server. See
319
+ Office/OpenDocument/EPUB/email and text/data/code files on the server. See
292
320
  [Supported file types](#supported-file-types). For any format read by **none**
293
321
  of these (e.g. a proprietary binary format), register a **parser plugin**: it
294
322
  runs in the browser, turns the uploaded file into text (or an HTML string), and
@@ -428,6 +456,15 @@ helpers. See the `.d.ts` shipped with `bunnyquery/engine`.
428
456
  | `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). |
429
457
  | `attachmentParsers` | `array?` | Client-side attachment parsers, registered at configure time. More can be added later with `registerAttachmentParser()`. See [Attachment parser plugins](#attachment-parser-plugins). |
430
458
  | `windowedIndexing` | `boolean?` | Opt in to **server-driven** windowed indexing for text and grid files (see [file types](#supported-file-types)). Off by default in the engine; the widget passes it as `true`. The deployed skapi workers support it; only leave it off against a self-hosted worker that does not yet strip the `_skapi_window` directive, where it would reach the provider as an unknown body field and fail the call terminally with no retry. |
459
+ | `liveStreaming` | `boolean?` | Opt in to **live streaming** of chat turns. Off by default, and the backend ships first: a streamed row settles with a status and NO body (the answer was the stream), so against a worker that does not relay, the turn reads back empty. Pair it with `clientSecretRequestFinalize` and `clientSecretRequestStream`, and gate it on `skapiSupportsStreaming(skapi)`. |
460
+ | `clientSecretRequestFinalize` | `function?` | `skapi.clientSecretRequestFinalize`, bound to your Skapi instance. Stores the version of a streamed turn that history keeps (the engine sends the assembled provider body, so it reads back exactly like a buffered turn) and releases that request's chunks. Without it a streamed turn is never finalized and its row stays empty. |
461
+ | `clientSecretRequestStream` | `function?` | `skapi.clientSecretRequestStream`, bound to your Skapi instance. The **second half of the durability guarantee**: a row that settles while no poll is attached (closed tab, discarded background tab, slept device) is never finalized, so its answer stays in the chunk store and its history row is terminal and empty. Given the request id this drains that turn's chunks in one pass; the engine parses them exactly as it parses a live stream and finalizes what it read, so each row is recovered at most once. Without it the engine mints no recovery marker at all and behaves as it did before streaming. |
462
+ | `onLiveStreamUpdate` | `function?` | Observation hook for a streaming turn (`{ serverItemId, ownerKey, phase, text, thinkingText, toolNames, complete, errored }`). The engine already paints the answer text itself, so this is only for affordances it does not decide the presentation of. Never throw from it. |
463
+ | `liveStreamingRealtime` | `boolean?` | Push relayed chunks over skapi's websocket as well. Requires `liveStreaming`. Off by default: `joinRealtime` replaces the connection's group for the length of a turn, so only a host that owns its skapi instance should opt in. |
464
+ | `streamRecovery` | `boolean?` | Set `false` to force the read-back of already-streamed turns **off**, even though the chunk reader is injected. There is no need to set it to turn recovery on: injecting `clientSecretRequestStream` is what arms it. |
465
+ | `mintIndexDoneMarker` | `function?` | Write the durable "indexing finished" marker (`done::<path>`, reference `src::<path>`, table `__INDEXING__`) for the runs this client knows are complete. Best-effort, must never throw. Without it the engine falls back to inference. |
466
+ | `upsertIndexRunRecord` | `function?` | Create-or-update the per-file run record (`run::<path>`, reference `src::<path>`, table `__INDEXING__`), which is what lets chat rows and files-page badges paint without scanning background history. You implement the upsert (the records API has none) and the status precedence: `'working'` must never overwrite a terminal status. Without it the engine uses the legacy scan/probe path. |
467
+ | `csrHistoryItemLookup` | `function?` | Single-item `csr-poll` point lookup, used by `ChatSession.hydrateCompactItems` to fetch a compact history stub's real body when an indexing row is expanded. Without it stubs keep their server-extracted heads. |
431
468
 
432
469
  ### Display and paging helpers
433
470
 
@@ -480,9 +517,10 @@ boot-time fallback for when the silent path cannot refresh.
480
517
  `height: 100dvh`) or it will collapse.
481
518
  - File and folder uploads are stored in your Skapi project's database storage and
482
519
  served from a temporary db-CDN URL (`hostDomain`); links in chat refresh on expiry.
483
- Links a queued message carries are re-minted server-side immediately before the
484
- upstream call, so a message that waits in the queue never hands the model a dead
485
- URL.
520
+ Links a background **indexing** pass carries are re-minted server-side
521
+ immediately before the upstream call (`_skapi_file_urls`), so a pass that waits
522
+ days behind a bulk upload never hands the model a dead URL. Chat-message links
523
+ are not re-minted; a chat turn waits for the indexing queue to drain instead.
486
524
  - The number of files attachable to a single message is capped, and beyond a
487
525
  point the chips collapse into a "...(n) more" pill rather than being rendered.
488
526
  Very large batches belong on a dedicated upload page, not the chat composer.
package/bunnyquery.css CHANGED
@@ -664,23 +664,42 @@
664
664
  background: var(--bq-paper);
665
665
  }
666
666
 
667
+ /* ZERO HEIGHT IN FLOW, DELIBERATELY. Mirrors agent.vue, where the reasoning is
668
+ written out in full: this bar mounts when a history fetch starts and unmounts
669
+ when the page lands, both while the reader sits at the top of the list, and as
670
+ an in-flow sticky element it moved every row by its own height each time
671
+ (measured in Chrome with overflow-anchor:none). The outer box now contributes
672
+ nothing to layout and the inner strip overflows it, so the bar still sits
673
+ pinned at the top of the viewport while mounting it moves nothing. */
667
674
  .bq-history-loading {
668
675
  position: sticky;
669
676
  top: 0;
670
677
  z-index: 1;
678
+ height: 0;
679
+ padding: 0;
680
+ overflow: visible;
681
+ pointer-events: none;
682
+ }
683
+ .bq-history-loading-inner {
671
684
  display: flex;
685
+ align-items: center;
672
686
  justify-content: center;
673
687
  gap: 0.1em;
674
688
  padding: 0.5rem 0 1rem;
675
689
  font-size: 0.8rem;
676
690
  color: var(--bq-muted);
677
- background: var(--bq-paper);
691
+ background: linear-gradient(to bottom, var(--bq-paper) 70%, transparent);
678
692
  }
679
- /* initial first-page load: center it in the (empty) messages area */
693
+ /* initial first-page load: center it in the (empty) messages area. A full-box
694
+ overlay rather than a top strip, so it takes its height back - and it replaces
695
+ an EMPTY messages area, so it has nothing to move. */
680
696
  .bq-history-loading.is-initial {
681
697
  position: absolute;
682
698
  inset: 0;
699
+ height: auto;
700
+ display: flex;
683
701
  align-items: center;
702
+ justify-content: center;
684
703
  padding: 0;
685
704
  }
686
705
 
@@ -1236,6 +1255,38 @@
1236
1255
  .bq-cancel-queue-btn:hover:not(.is-disabled) { background: var(--bq-warning-bg); color: var(--bq-warning); }
1237
1256
  .bq-cancel-queue-btn.is-disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
1238
1257
 
1258
+ /* ---- unfinalized streamed answer (ask for it) ----------------------------
1259
+ A turn whose answer was streamed and never finalized keeps its bytes in the
1260
+ chunk store, and this bubble is what the reader gets when nothing is currently
1261
+ fetching them: the per-load recovery cap left this one behind, or a read failed.
1262
+ It is deliberately NOT the danger red of an error bubble - nothing is broken and
1263
+ nothing is lost, the answer is simply not here yet - and deliberately not a
1264
+ spinner, which would promise an arrival nobody has scheduled. Shared by both
1265
+ chatboxes (this file is bunnyquery/styles/chat.css, which agent.vue imports). */
1266
+ .bq-stream-recover-note {
1267
+ display: block;
1268
+ font-size: 0.72rem;
1269
+ color: var(--bq-muted);
1270
+ font-style: italic;
1271
+ }
1272
+ .bq-bubble.is-stream-failed .bq-stream-recover-note { color: var(--bq-warning); }
1273
+ .bq-stream-recover-btn {
1274
+ display: inline-block;
1275
+ margin-top: 0.4rem;
1276
+ padding: 0.18rem 0.55rem;
1277
+ min-height: 0;
1278
+ border: 1px solid var(--bq-line);
1279
+ background: transparent;
1280
+ color: inherit;
1281
+ font-size: 0.72rem;
1282
+ line-height: 1.3;
1283
+ cursor: pointer;
1284
+ box-shadow: none;
1285
+ border-radius: 0;
1286
+ }
1287
+ .bq-stream-recover-btn:hover { background: rgba(127, 127, 127, 0.12); }
1288
+ .bq-bubble.is-stream-failed .bq-stream-recover-btn { border-color: var(--bq-warning-border); color: var(--bq-warning); }
1289
+
1239
1290
  /* ---- collapsed background-indexing group ---------------------------------*/
1240
1291
  /* One file's many indexing passes (first pass + every CONTINUE pass, each with
1241
1292
  a request AND a response bubble) render as a single status row instead of