bunnyquery 1.8.1 → 1.8.3

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.js CHANGED
@@ -213,14 +213,15 @@
213
213
  }
214
214
  function composeUserMessage(text, attachmentUrls) {
215
215
  let composed = text;
216
+ let composedForLlm = composed;
216
217
  if (attachmentUrls.length > 0) {
217
218
  const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
218
219
  composed = `${text}
219
220
 
220
221
  Attached files:
221
222
  ${lines.join("\n")}`;
223
+ composedForLlm = composed;
222
224
  }
223
- let composedForLlm = composed;
224
225
  let extractContent;
225
226
  let fileUrls;
226
227
  if (attachmentUrls.length > 0) {
@@ -237,13 +238,13 @@ ${placeholder}
237
238
  ----- END FILE CONTENT -----`;
238
239
  });
239
240
  extractContent = directives;
240
- composedForLlm = `${composed}
241
+ composedForLlm = `${composedForLlm}
241
242
 
242
243
  Extracted content of attached office files (read inline below; do NOT fetch their URLs):
243
244
 
244
245
  ` + sections.join("\n\n");
245
246
  }
246
- const urlFiles = attachmentUrls.filter((u) => u.url && !isServerExtractable(u.name));
247
+ const urlFiles = [];
247
248
  if (urlFiles.length > 0) {
248
249
  fileUrls = urlFiles.map((u) => ({ path: u.storagePath || u.name, url: u.url }));
249
250
  }
@@ -287,6 +288,7 @@ File attachments: When a user message contains an "Attached files:" section with
287
288
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
288
289
  - Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
289
290
  - For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
291
+ Stored files and readFileContent: for a file ALREADY in this project's storage, its pages and rows were read at upload time and saved as records, so the database is your best source. Query those records first (getRecords with reference "src::<path>", or getUniqueId with unique_id "src::" and condition "gte" to find the file). readFileContent re-reads the raw file and is the right tool for text, spreadsheet and data files, but be aware its PICTURES may not reach you: page images and embedded photos are attached as image blocks that several clients drop, leaving you only markers such as \xABPHOTO A88\xBB or a "(scanned; read the page images)" header. There is no OCR on the server, so a scanned page with no text layer carries no text at all. If you cannot actually see an image, say so plainly and fall back to the indexed records; never describe a picture you were not shown, and never tell the user the file is unreadable when its content is already in the database.
290
292
  File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix \u2014 do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand \u2014 so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
291
293
  File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records. Present each result as a markdown link as described above. Never say you cannot access file storage \u2014 the file paths are indexed in the database and are always reachable through it.
292
294
  File generation: When the user asks you to generate a file \u2014 or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown \u2014 put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only \u2014 never base64 or any other encoding. Example for CSV:
@@ -433,6 +435,19 @@ Index the REMAINING windows - one record per row/item, looking at any page image
433
435
  }
434
436
 
435
437
  // src/engine/errors.ts
438
+ var STATUS_MESSAGE = {
439
+ "408": "The AI provider timed out before it started.",
440
+ "409": "The AI provider rejected the request as conflicting.",
441
+ "413": "The request was too large for the AI provider.",
442
+ "429": "The AI provider is rate limiting requests right now.",
443
+ "500": "The AI provider hit an internal error.",
444
+ "502": "The AI provider is temporarily unreachable.",
445
+ "503": "The AI provider is temporarily unavailable.",
446
+ "504": "The AI provider timed out."
447
+ };
448
+ function isTransientStatus(status) {
449
+ return status === 408 || status === 425 || status === 429 || status >= 500;
450
+ }
436
451
  function getErrorMessage(input) {
437
452
  if (!input) return "Something went wrong.";
438
453
  if (typeof input === "string") return input;
@@ -440,6 +455,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
440
455
  if (input.body && input.body.error && input.body.error.message) return input.body.error.message;
441
456
  if (input.body && typeof input.body.message === "string") return input.body.message;
442
457
  if (input.message) return input.message;
458
+ var status = typeof input.status_code === "number" ? input.status_code : typeof input.status === "number" ? input.status : 0;
459
+ if (status) {
460
+ var text = STATUS_MESSAGE[String(status)] || (status >= 500 ? "The AI provider returned a server error." : "The AI provider rejected the request.");
461
+ return text + " (error " + status + ")" + (isTransientStatus(status) ? " This is usually temporary, please try again." : "");
462
+ }
443
463
  return "Something went wrong.";
444
464
  }
445
465
  function isErrorResponseBody(response) {
@@ -852,6 +872,163 @@ Index the REMAINING windows - one record per row/item, looking at any page image
852
872
  };
853
873
  }
854
874
 
875
+ // src/engine/download_encoding.ts
876
+ var BOM = "\uFEFF";
877
+ var BOM_EXTS = /* @__PURE__ */ new Set(["csv", "tsv", "tab", "txt", "text", "log"]);
878
+ var HTML_EXTS = /* @__PURE__ */ new Set(["html", "htm", "xhtml"]);
879
+ var XML_EXTS = /* @__PURE__ */ new Set(["xml", "svg", "rss", "atom", "xsl", "xslt", "plist", "kml"]);
880
+ var RTF_EXTS = /* @__PURE__ */ new Set(["rtf"]);
881
+ var EXT_CONTENT_TYPES = {
882
+ csv: "text/csv; charset=utf-8",
883
+ tsv: "text/tab-separated-values; charset=utf-8",
884
+ tab: "text/tab-separated-values; charset=utf-8",
885
+ txt: "text/plain; charset=utf-8",
886
+ text: "text/plain; charset=utf-8",
887
+ log: "text/plain; charset=utf-8",
888
+ md: "text/markdown; charset=utf-8",
889
+ markdown: "text/markdown; charset=utf-8",
890
+ json: "application/json; charset=utf-8",
891
+ jsonl: "application/x-ndjson; charset=utf-8",
892
+ ndjson: "application/x-ndjson; charset=utf-8",
893
+ geojson: "application/geo+json; charset=utf-8",
894
+ yaml: "text/yaml; charset=utf-8",
895
+ yml: "text/yaml; charset=utf-8",
896
+ toml: "text/plain; charset=utf-8",
897
+ ini: "text/plain; charset=utf-8",
898
+ sql: "text/plain; charset=utf-8",
899
+ html: "text/html; charset=utf-8",
900
+ htm: "text/html; charset=utf-8",
901
+ xhtml: "application/xhtml+xml; charset=utf-8",
902
+ xml: "application/xml; charset=utf-8",
903
+ svg: "image/svg+xml; charset=utf-8",
904
+ css: "text/css; charset=utf-8",
905
+ js: "text/javascript; charset=utf-8",
906
+ ts: "text/plain; charset=utf-8",
907
+ py: "text/x-python; charset=utf-8",
908
+ sh: "text/x-shellscript; charset=utf-8",
909
+ srt: "application/x-subrip; charset=utf-8",
910
+ vtt: "text/vtt; charset=utf-8",
911
+ ics: "text/calendar; charset=utf-8",
912
+ vcf: "text/vcard; charset=utf-8",
913
+ // RTF is 7-bit ASCII by specification, so it takes no charset parameter.
914
+ rtf: "application/rtf",
915
+ // Binary types the model can only ever REFERENCE, never author in a fence, but
916
+ // which keep the type sensible if one ever shows up.
917
+ pdf: "application/pdf",
918
+ png: "image/png",
919
+ jpg: "image/jpeg",
920
+ jpeg: "image/jpeg",
921
+ gif: "image/gif",
922
+ webp: "image/webp"
923
+ };
924
+ function normalizeExt(ext) {
925
+ return String(ext || "").trim().replace(/^\./, "").toLowerCase();
926
+ }
927
+ function extOf(filename) {
928
+ const name = String(filename || "");
929
+ const dot = name.lastIndexOf(".");
930
+ return dot > 0 ? normalizeExt(name.slice(dot + 1)) : "";
931
+ }
932
+ function encodingClassForExt(ext) {
933
+ const e = normalizeExt(ext);
934
+ if (BOM_EXTS.has(e)) return "bom";
935
+ if (HTML_EXTS.has(e)) return "html";
936
+ if (XML_EXTS.has(e)) return "xml";
937
+ if (RTF_EXTS.has(e)) return "rtf";
938
+ return "none";
939
+ }
940
+ function contentTypeForExt(ext, fallback = "text/plain; charset=utf-8") {
941
+ return EXT_CONTENT_TYPES[normalizeExt(ext)] || fallback;
942
+ }
943
+ function hasBom(text) {
944
+ return typeof text === "string" && text.charCodeAt(0) === 65279;
945
+ }
946
+ var HTML_HEAD_WINDOW = 4096;
947
+ var META_CHARSET_RE = /<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_-]+)/i;
948
+ var META_HTTP_EQUIV_RE = /<meta[^>]+http-equiv\s*=\s*["']?content-type["']?[^>]*>/i;
949
+ function ensureHtmlCharset(text) {
950
+ const src = String(text == null ? "" : text);
951
+ const head = src.slice(0, HTML_HEAD_WINDOW);
952
+ const declared = META_CHARSET_RE.exec(head);
953
+ if (declared) {
954
+ if (declared[1].toLowerCase().replace(/[^a-z0-9]/g, "") === "utf8") return src;
955
+ const start = declared.index + declared[0].length - declared[1].length;
956
+ return src.slice(0, start) + "utf-8" + src.slice(start + declared[1].length);
957
+ }
958
+ const httpEquiv = META_HTTP_EQUIV_RE.exec(head);
959
+ if (httpEquiv) {
960
+ return src.slice(0, httpEquiv.index) + '<meta charset="utf-8">' + src.slice(httpEquiv.index + httpEquiv[0].length);
961
+ }
962
+ const tag = '<meta charset="utf-8">';
963
+ const headOpen = /<head[^>]*>/i.exec(head);
964
+ if (headOpen) {
965
+ const at = headOpen.index + headOpen[0].length;
966
+ return src.slice(0, at) + "\n" + tag + src.slice(at);
967
+ }
968
+ const htmlOpen = /<html[^>]*>/i.exec(head);
969
+ if (htmlOpen) {
970
+ const at = htmlOpen.index + htmlOpen[0].length;
971
+ return src.slice(0, at) + "\n<head>" + tag + "</head>" + src.slice(at);
972
+ }
973
+ const doctype = /<!doctype[^>]*>/i.exec(head);
974
+ if (doctype) {
975
+ const at = doctype.index + doctype[0].length;
976
+ return src.slice(0, at) + "\n" + tag + src.slice(at);
977
+ }
978
+ return tag + "\n" + src;
979
+ }
980
+ var XML_DECL_RE = /^\s*<\?xml\s[^?]*\?>/i;
981
+ function ensureXmlEncoding(text) {
982
+ const src = String(text == null ? "" : text);
983
+ const decl = XML_DECL_RE.exec(src);
984
+ if (!decl) return src;
985
+ const found = /encoding\s*=\s*["']([^"']*)["']/i.exec(decl[0]);
986
+ if (!found) return src;
987
+ if (found[1].toLowerCase().replace(/[^a-z0-9]/g, "") === "utf8") return src;
988
+ const fixedDecl = decl[0].slice(0, found.index) + found[0].replace(found[1], "UTF-8") + decl[0].slice(found.index + found[0].length);
989
+ return fixedDecl + src.slice(decl[0].length);
990
+ }
991
+ var RTF_SIGNATURE_RE = /^[\s]*\{\\rtf/i;
992
+ function looksLikeRtf(text) {
993
+ return RTF_SIGNATURE_RE.test(String(text == null ? "" : text));
994
+ }
995
+ function escapeRtfNonAscii(text) {
996
+ const src = String(text == null ? "" : text);
997
+ let out = "";
998
+ let plainFrom = 0;
999
+ for (let i = 0; i < src.length; i++) {
1000
+ const code = src.charCodeAt(i);
1001
+ if (code < 128) continue;
1002
+ out += src.slice(plainFrom, i);
1003
+ out += `\\u${code > 32767 ? code - 65536 : code}?`;
1004
+ plainFrom = i + 1;
1005
+ }
1006
+ return plainFrom === 0 ? src : out + src.slice(plainFrom);
1007
+ }
1008
+ function applyEncodingDeclaration(text, ext) {
1009
+ const src = String(text == null ? "" : text);
1010
+ switch (encodingClassForExt(ext)) {
1011
+ case "bom":
1012
+ return hasBom(src) ? src : BOM + src;
1013
+ case "html":
1014
+ return ensureHtmlCharset(src);
1015
+ case "xml":
1016
+ return ensureXmlEncoding(src);
1017
+ case "rtf":
1018
+ return looksLikeRtf(src) ? escapeRtfNonAscii(src) : hasBom(src) ? src : BOM + src;
1019
+ default:
1020
+ return src;
1021
+ }
1022
+ }
1023
+ function prepareDownloadText(filename, body) {
1024
+ const ext = extOf(filename);
1025
+ return {
1026
+ ext,
1027
+ text: applyEncodingDeclaration(body, ext),
1028
+ contentType: contentTypeForExt(ext)
1029
+ };
1030
+ }
1031
+
855
1032
  // src/engine/time.ts
856
1033
  function wallClockNow() {
857
1034
  return Date.now();
@@ -1013,7 +1190,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1013
1190
  return { ...m, content: blocks };
1014
1191
  });
1015
1192
  }
1016
- var POLL_INTERVAL = 1500;
1193
+ var POLL_INTERVAL = 3e3;
1017
1194
  async function callClaudeWithMcp({
1018
1195
  prompt,
1019
1196
  messages,
@@ -1228,7 +1405,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1228
1405
  const imageDetail = getOpenAIImageDetail(resolvedModel2);
1229
1406
  return clientSecretRequest({
1230
1407
  clientSecretName: "openai",
1231
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1408
+ queue: bgIndexingQueueName(info.userId, service),
1232
1409
  service,
1233
1410
  owner,
1234
1411
  ...pollOpt(),
@@ -1272,7 +1449,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1272
1449
  const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
1273
1450
  return clientSecretRequest({
1274
1451
  clientSecretName: "claude",
1275
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1452
+ queue: bgIndexingQueueName(info.userId, service),
1276
1453
  service,
1277
1454
  owner,
1278
1455
  ...pollOpt(),
@@ -1361,6 +1538,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1361
1538
  return "";
1362
1539
  }
1363
1540
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1541
+ function bgIndexingQueueName(userId, service) {
1542
+ return (userId || service || "") + BG_INDEXING_QUEUE_SUFFIX;
1543
+ }
1364
1544
  function isBgIndexingQueue(queueName) {
1365
1545
  if (typeof queueName !== "string" || !queueName) return false;
1366
1546
  const prefix = queueName.split("|")[0];
@@ -1600,6 +1780,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1600
1780
  // src/engine/session.ts
1601
1781
  var WORKER_PASS_ADOPT_LIMIT = 20;
1602
1782
  var WORKER_PASS_ADOPT_ATTEMPTS = [0, 2e3, 6e3];
1783
+ var INDEXING_DRAIN_BUSY_POLL_MS = 8e3;
1784
+ var INDEXING_DRAIN_CONFIRM_POLL_MS = 3e3;
1785
+ var INDEXING_DRAIN_IDLE_LOOKS = 2;
1786
+ var INDEXING_DRAIN_MIN_MS = 8e3;
1787
+ var INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1e3;
1603
1788
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1604
1789
  function nowMs() {
1605
1790
  return _g.performance && typeof _g.performance.now === "function" ? _g.performance.now() : Date.now();
@@ -1672,6 +1857,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1672
1857
  this._pauseReasons = /* @__PURE__ */ new Set();
1673
1858
  this._resuming = false;
1674
1859
  this._lidSeq = 0;
1860
+ this._stageSeq = 0;
1861
+ this._uploadBatches = 0;
1862
+ this._indexDispatchesInFlight = 0;
1863
+ }
1864
+ /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
1865
+ * live work from the moment it is sent, not from the moment it is acked. */
1866
+ trackIndexDispatch(p) {
1867
+ var self = this;
1868
+ this._indexDispatchesInFlight += 1;
1869
+ var release = function() {
1870
+ self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1);
1871
+ };
1872
+ return p.then(function(v) {
1873
+ release();
1874
+ return v;
1875
+ }, function(e) {
1876
+ release();
1877
+ throw e;
1878
+ });
1675
1879
  }
1676
1880
  /**
1677
1881
  * Register a live poll so (a) a remount dedupes against it instead of stacking a
@@ -1777,6 +1981,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1777
1981
  if (!key) return;
1778
1982
  this.aiChatHistoryCache[key] = {
1779
1983
  messages: this.state.messages.filter(function(m) {
1984
+ if (m._stageId) return false;
1780
1985
  return m._ownerKey === void 0 || m._ownerKey === key;
1781
1986
  }),
1782
1987
  endOfList: this.state.historyEndOfList,
@@ -1916,14 +2121,189 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1916
2121
  this.pendingAgentRequests[params.key] = run;
1917
2122
  return run;
1918
2123
  }
2124
+ /**
2125
+ * Put a turn on screen the INSTANT the user hits Send, before its attachments
2126
+ * have finished uploading. Uploads run in the background now (the composer is
2127
+ * cleared and stays usable), so without a staged bubble the message would
2128
+ * appear only once its files were up — below anything the user sent in the
2129
+ * meantime, in an order that never matches what they typed.
2130
+ *
2131
+ * Staged bubbles carry _useBgQueue because that is where a turn with
2132
+ * attachments ultimately dispatches (behind its own indexing tasks). That flag
2133
+ * is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
2134
+ * them: those advance the SERVER queue, and a staged turn has no server
2135
+ * request behind it yet.
2136
+ *
2137
+ * Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
2138
+ */
2139
+ stageOutgoingMessage(displayText) {
2140
+ this._stageSeq += 1;
2141
+ var stageId = "stg_" + this._stageSeq;
2142
+ var key = this.getHistoryCacheKey();
2143
+ var staged = {
2144
+ role: "user",
2145
+ content: displayText,
2146
+ isPendingQueued: true,
2147
+ isUploadingAttachments: true,
2148
+ isSendingToServer: true,
2149
+ _useBgQueue: true,
2150
+ _stageId: stageId,
2151
+ _ts: wallClockNow()
2152
+ };
2153
+ if (key) staged._ownerKey = key;
2154
+ this.state.messages.push(staged);
2155
+ this.host.notify();
2156
+ this.host.scrollToBottom(true);
2157
+ return stageId;
2158
+ }
2159
+ _stageIndex(list, stageId) {
2160
+ if (!stageId) return -1;
2161
+ for (var i = 0; i < list.length; i++) {
2162
+ if (list[i] && list[i]._stageId === stageId) return i;
2163
+ }
2164
+ return -1;
2165
+ }
2166
+ /**
2167
+ * Where a staged turn belongs once it is finally sent: BELOW every indexing
2168
+ * row, because that is the order it ran in and the order the server history
2169
+ * will report on the next load (its request id is newer than every pass it
2170
+ * waited for). While its files were uploading it sat above them — the rows
2171
+ * are injected as each file's pass starts, after the bubble was staged.
2172
+ *
2173
+ * Returns the index to insert at, or -1 to leave the turn where it is. Never
2174
+ * moves a turn UP: a bubble that already sits below the indexing rows (or a
2175
+ * chat with no indexing at all) must not jump backwards over anything.
2176
+ */
2177
+ _settledStagePosition(fromIdx) {
2178
+ var lastBg = -1;
2179
+ for (var i = 0; i < this.state.messages.length; i++) {
2180
+ if (this.state.messages[i] && this.state.messages[i].isBackgroundTask) lastBg = i;
2181
+ }
2182
+ if (lastBg <= fromIdx) return -1;
2183
+ return lastBg;
2184
+ }
2185
+ /** The staged turn's files are up; it is now just waiting its place in the
2186
+ * queue. Drops the "(Uploading files...)" note back to "(In queue)". */
2187
+ markStagedMessageQueued(stageId) {
2188
+ var idx = this._stageIndex(this.state.messages, stageId);
2189
+ if (idx === -1) return;
2190
+ var ex = this.state.messages[idx];
2191
+ if (!ex.isUploadingAttachments) return;
2192
+ this.state.messages[idx] = Object.assign({}, ex, { isUploadingAttachments: false });
2193
+ this.host.notify();
2194
+ }
2195
+ /**
2196
+ * Resolves once this project's background-indexing queue has nothing left to
2197
+ * run, so a chat enqueued right after it is genuinely last.
2198
+ *
2199
+ * Sending the chat as soon as the uploads finish is not enough, which is the
2200
+ * whole reason this exists: indexing a file is a CHAIN, and each pass is only
2201
+ * enqueued once the previous one lands (the client mints CONTINUE passes for
2202
+ * text/grid files, the worker mints them for PDFs and windowed reads). Every
2203
+ * one of those passes therefore queues up BEHIND a chat sent at upload time,
2204
+ * and the model answers from a file it has only partly read.
2205
+ *
2206
+ * The queue is read from the server's status index rather than from
2207
+ * bgTaskQueue: that mirror holds only what this client dispatched or adopted,
2208
+ * and it stops being maintained once the view unmounts. An empty answer has to
2209
+ * repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
2210
+ * that fails counts as busy, so a dropped request delays the turn instead of
2211
+ * releasing it early.
2212
+ *
2213
+ * Reads the identity PINNED at Send time, never a live one: the user may be in
2214
+ * another project by now, and this must keep asking about the one they sent
2215
+ * from.
2216
+ */
2217
+ awaitIndexingDrained(identity) {
2218
+ var self = this;
2219
+ var svcId = identity && identity.serviceId;
2220
+ var platform = identity && identity.platform;
2221
+ if (!svcId || platform !== "claude" && platform !== "openai") return Promise.resolve("skipped");
2222
+ var owner = identity.owner;
2223
+ var queue = bgIndexingQueueName(identity.userId, svcId);
2224
+ var startedAt = nowMs();
2225
+ var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
2226
+ var idleLooks = 0;
2227
+ var ask = function(status) {
2228
+ return Promise.resolve(getChatHistory(
2229
+ { service: svcId, owner, platform, queue, status },
2230
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2231
+ )).catch(function() {
2232
+ return null;
2233
+ });
2234
+ };
2235
+ var hasLiveIndexing = function(res) {
2236
+ var list = res && Array.isArray(res.list) ? res.list : [];
2237
+ for (var i = 0; i < list.length; i++) {
2238
+ var item = list[i];
2239
+ if (!item || item.status !== "pending" && item.status !== "running") continue;
2240
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
2241
+ }
2242
+ return false;
2243
+ };
2244
+ return new Promise(function(resolve) {
2245
+ var again = function() {
2246
+ setTimeout(look, idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS);
2247
+ };
2248
+ var look = function() {
2249
+ if (nowMs() >= deadline) {
2250
+ resolve("timedout");
2251
+ return;
2252
+ }
2253
+ if (self._indexDispatchesInFlight > 0) {
2254
+ idleLooks = 0;
2255
+ again();
2256
+ return;
2257
+ }
2258
+ Promise.all([ask("running"), ask("pending")]).then(function(res) {
2259
+ var unknown = res[0] === null || res[1] === null;
2260
+ if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
2261
+ else idleLooks += 1;
2262
+ if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
2263
+ resolve("drained");
2264
+ return;
2265
+ }
2266
+ again();
2267
+ }, function() {
2268
+ idleLooks = 0;
2269
+ again();
2270
+ });
2271
+ };
2272
+ look();
2273
+ });
2274
+ }
2275
+ /**
2276
+ * Abandon a staged turn — its uploads failed outright, so nothing will be
2277
+ * dispatched. The bubble stays (the user's text is not silently thrown away)
2278
+ * but settles into a plain, non-pending message; the caller reports the
2279
+ * failure separately.
2280
+ */
2281
+ settleStagedMessage(stageId) {
2282
+ var idx = this._stageIndex(this.state.messages, stageId);
2283
+ if (idx === -1) return;
2284
+ var ex = this.state.messages[idx];
2285
+ var settled = { role: "user", content: ex.content };
2286
+ if (ex._ownerKey !== void 0) settled._ownerKey = ex._ownerKey;
2287
+ if (ex._ts !== void 0) settled._ts = ex._ts;
2288
+ this.state.messages[idx] = settled;
2289
+ this.host.notify();
2290
+ this.updateHistoryCache();
2291
+ }
1919
2292
  // composed = clean display text; composedForLlm carries office-extraction
1920
2293
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
1921
2294
  // onto the "-bg" queue so it runs after indexing.
1922
2295
  dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
1923
2296
  var self = this;
1924
- if (!composed) return;
2297
+ var stageId = pinned ? pinned.stageId : void 0;
2298
+ if (!composed) {
2299
+ if (stageId) this.settleStagedMessage(stageId);
2300
+ return;
2301
+ }
1925
2302
  var id = pinned ? pinned.identity : this.host.getIdentity();
1926
- if (id.platform === "none") return;
2303
+ if (id.platform === "none") {
2304
+ if (stageId) this.settleStagedMessage(stageId);
2305
+ return;
2306
+ }
1927
2307
  var llmComposed = composedForLlm || composed;
1928
2308
  var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
1929
2309
  var offChat = !!key && key !== this.getHistoryCacheKey();
@@ -1934,10 +2314,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1934
2314
  var aiModel = id.model || void 0;
1935
2315
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
1936
2316
  var userId = id.userId || id.serviceId;
1937
- var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
2317
+ var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
1938
2318
  if (offChat) {
1939
2319
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
1940
- return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask;
2320
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
1941
2321
  });
1942
2322
  var offBounded = buildBoundedChatMessages({
1943
2323
  platform: aiPlatform,
@@ -1947,9 +2327,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1947
2327
  history: offHistory.concat([{ role: "user", content: llmComposed }])
1948
2328
  });
1949
2329
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
2330
+ var offUser = { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() };
2331
+ var offStage = this._stageIndex(this.state.messages, stageId);
2332
+ if (offStage !== -1) {
2333
+ if (this.state.messages[offStage]._ts !== void 0) offUser._ts = this.state.messages[offStage]._ts;
2334
+ this.state.messages.splice(offStage, 1);
2335
+ this.host.notify();
2336
+ }
1950
2337
  this.aiChatHistoryCache[key] = {
1951
2338
  messages: offExisting.messages.concat([
1952
- { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() },
2339
+ offUser,
1953
2340
  { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
1954
2341
  ]),
1955
2342
  endOfList: offExisting.endOfList,
@@ -1972,7 +2359,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1972
2359
  }
1973
2360
  if (isQueuedSend) {
1974
2361
  var resolvedHistory = this.state.messages.filter(function(m) {
1975
- return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask;
2362
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
1976
2363
  });
1977
2364
  var boundedQ = buildBoundedChatMessages({
1978
2365
  platform: aiPlatform,
@@ -1984,14 +2371,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1984
2371
  var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
1985
2372
  if (key) queuedBubble._ownerKey = key;
1986
2373
  if (useBgQueue) queuedBubble._useBgQueue = true;
1987
- this.state.messages.push(queuedBubble);
2374
+ var qStage = this._stageIndex(this.state.messages, stageId);
2375
+ if (qStage !== -1) {
2376
+ if (this.state.messages[qStage]._ts !== void 0) queuedBubble._ts = this.state.messages[qStage]._ts;
2377
+ var qTarget = this._settledStagePosition(qStage);
2378
+ if (qTarget === -1) {
2379
+ this.state.messages.splice(qStage, 1, queuedBubble);
2380
+ } else {
2381
+ this.state.messages.splice(qStage, 1);
2382
+ this.state.messages.splice(qTarget, 0, queuedBubble);
2383
+ }
2384
+ } else {
2385
+ this.state.messages.push(queuedBubble);
2386
+ }
1988
2387
  this.host.notify();
1989
2388
  this.updateHistoryCache();
1990
2389
  this.host.scrollToBottom(true);
1991
2390
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
1992
2391
  Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
1993
2392
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
1994
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
2393
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && !m._stageId && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
1995
2394
  });
1996
2395
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
1997
2396
  if (sendingIdx >= 0) {
@@ -2016,23 +2415,31 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2016
2415
  });
2017
2416
  return;
2018
2417
  }
2019
- this.state.messages.push({ role: "user", content: composed, _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} });
2020
- this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} });
2418
+ var immediateUser = { role: "user", content: composed, _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
2419
+ var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} };
2420
+ var iStage = this._stageIndex(this.state.messages, stageId);
2421
+ if (iStage !== -1) {
2422
+ if (this.state.messages[iStage]._ts !== void 0) immediateUser._ts = this.state.messages[iStage]._ts;
2423
+ var iTarget = this._settledStagePosition(iStage);
2424
+ if (iTarget === -1) {
2425
+ this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
2426
+ } else {
2427
+ this.state.messages.splice(iStage, 1);
2428
+ this.state.messages.splice(iTarget, 0, immediateUser, immediatePlaceholder);
2429
+ }
2430
+ } else {
2431
+ this.state.messages.push(immediateUser);
2432
+ this.state.messages.push(immediatePlaceholder);
2433
+ }
2021
2434
  this.host.notify();
2022
2435
  this.updateHistoryCache();
2023
2436
  this.state.sending = true;
2024
2437
  this.host.scrollToBottom(true);
2025
2438
  var historyForLlm = this.state.messages.filter(function(m) {
2026
- return !m.isCancelled && !m.isBackgroundTask;
2439
+ if (m === immediateUser) return false;
2440
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
2027
2441
  });
2028
- if (llmComposed !== composed) {
2029
- for (var li = historyForLlm.length - 1; li >= 0; li--) {
2030
- if (historyForLlm[li].role === "user" && historyForLlm[li].content === composed) {
2031
- historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
2032
- break;
2033
- }
2034
- }
2035
- }
2442
+ historyForLlm.push({ role: "user", content: llmComposed });
2036
2443
  var bounded = buildBoundedChatMessages({
2037
2444
  platform: aiPlatform,
2038
2445
  model: aiModel,
@@ -2273,7 +2680,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2273
2680
  if (platform !== "claude" && platform !== "openai") return;
2274
2681
  var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2275
2682
  var queueBase = id.userId || id.serviceId;
2276
- var queue = msg.isBackgroundTask || msg._useBgQueue ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
2683
+ var queue = msg.isBackgroundTask || msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
2277
2684
  this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: void 0 });
2278
2685
  this.host.notify();
2279
2686
  Promise.resolve(this.host.cancelRequest({
@@ -2759,7 +3166,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2759
3166
  if (!id.serviceId || platform !== "claude" && platform !== "openai") return;
2760
3167
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
2761
3168
  var svcId = id.serviceId, owner = id.owner;
2762
- var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
3169
+ var queue = bgIndexingQueueName(id.userId, id.serviceId);
2763
3170
  var ask = function(status) {
2764
3171
  return Promise.resolve(getChatHistory(
2765
3172
  { service: svcId, owner, platform, queue, status },
@@ -2861,7 +3268,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2861
3268
  url,
2862
3269
  method: "POST",
2863
3270
  id: serverId,
2864
- queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
3271
+ queue: bgIndexingQueueName(id.userId, id.serviceId),
2865
3272
  service: id.serviceId,
2866
3273
  owner: id.owner
2867
3274
  })).catch(function() {
@@ -2990,7 +3397,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2990
3397
  if (pass > MAX_INDEXING_RESUME_PASSES) return;
2991
3398
  var id = this.host.getIdentity();
2992
3399
  if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
2993
- notifyAgentContinueIndexing({
3400
+ this.trackIndexDispatch(notifyAgentContinueIndexing({
2994
3401
  platform: id.platform,
2995
3402
  model: id.model,
2996
3403
  service: id.serviceId,
@@ -3024,7 +3431,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3024
3431
  }
3025
3432
  }, function(e) {
3026
3433
  console.error("[chat-engine] resume-indexing dispatch failed", e);
3027
- });
3434
+ }));
3028
3435
  } catch (e) {
3029
3436
  }
3030
3437
  }
@@ -3102,6 +3509,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3102
3509
  if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
3103
3510
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
3104
3511
  if (!mm._serverItemId) {
3512
+ if (mm._stageId) {
3513
+ rescued.push(mm);
3514
+ continue;
3515
+ }
3105
3516
  if (mappedHasPendingAssistant) continue;
3106
3517
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
3107
3518
  else if (self.state.sending && mm.role === "user") {
@@ -3321,7 +3732,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3321
3732
  return preIndex.then(function() {
3322
3733
  return parseAttachmentContent(member.file, member.file.name, mime || void 0);
3323
3734
  }).then(function(parsedContent) {
3324
- return notifyAgentSaveAttachment({
3735
+ return self.trackIndexDispatch(notifyAgentSaveAttachment({
3325
3736
  platform: id.platform,
3326
3737
  model: id.model,
3327
3738
  service: id.serviceId,
@@ -3360,7 +3771,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3360
3771
  att.errorCode = e && (e.code || e.body && e.body.code) || "";
3361
3772
  att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
3362
3773
  }
3363
- });
3774
+ }));
3364
3775
  });
3365
3776
  });
3366
3777
  });
@@ -3379,14 +3790,24 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3379
3790
  }
3380
3791
  // Upload all not-yet-done attachments sequentially. Resolves to the full
3381
3792
  // list of { name, url, storagePath } for composing the chat message.
3382
- uploadPendingAttachments() {
3793
+ //
3794
+ // `batchId` scopes the run to the chips stamped with it at Send time. The
3795
+ // composer stays live during an upload, so by the time this runs the
3796
+ // attachment list can already hold chips the user picked for the NEXT
3797
+ // message — uploading those here would attach them to the wrong turn, and
3798
+ // collecting the previous batch's finished urls would attach files the user
3799
+ // already sent. Omitted (no batch) means every chip, the old behavior.
3800
+ uploadPendingAttachments(batchId) {
3383
3801
  var self = this;
3384
3802
  this.host.resetOverwriteBatch();
3803
+ this._uploadBatches += 1;
3385
3804
  this.state.uploadingAttachments = true;
3386
3805
  this.host.updateComposerControls();
3387
3806
  this.host.renderAttachmentChips();
3388
3807
  var collected = [];
3389
- var snapshot = this.state.attachments.slice();
3808
+ var snapshot = this.state.attachments.filter(function(a) {
3809
+ return batchId ? a._batchId === batchId : true;
3810
+ });
3390
3811
  var chain = Promise.resolve();
3391
3812
  snapshot.forEach(function(att) {
3392
3813
  chain = chain.then(function() {
@@ -3422,7 +3843,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3422
3843
  });
3423
3844
  });
3424
3845
  var done = function() {
3425
- self.state.uploadingAttachments = false;
3846
+ self._uploadBatches = Math.max(0, self._uploadBatches - 1);
3847
+ self.state.uploadingAttachments = self._uploadBatches > 0;
3426
3848
  self.host.updateComposerControls();
3427
3849
  self.host.renderAttachmentChips();
3428
3850
  return collected;
@@ -3632,7 +4054,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3632
4054
  (function() {
3633
4055
  var MCP_PROD = "https://mcp.broadwayinc.computer";
3634
4056
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
3635
- var BQ_VERSION = "1.8.1" ;
4057
+ var BQ_VERSION = "1.8.3" ;
3636
4058
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
3637
4059
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
3638
4060
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -5260,17 +5682,20 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5260
5682
  var refreshingLinkPromises = /* @__PURE__ */ new Map();
5261
5683
  var fileBlobCache = /* @__PURE__ */ new Map();
5262
5684
  var markedReady = null;
5685
+ function currentIdentity() {
5686
+ return {
5687
+ serviceId: S.serviceId,
5688
+ owner: S.owner,
5689
+ userId: S.user && S.user.user_id || S.serviceId,
5690
+ platform: S.aiPlatform,
5691
+ model: S.aiModel || void 0,
5692
+ serviceName: S.serviceName,
5693
+ serviceDescription: S.serviceDescription
5694
+ };
5695
+ }
5263
5696
  var session = new ChatSession({
5264
5697
  getIdentity: function() {
5265
- return {
5266
- serviceId: S.serviceId,
5267
- owner: S.owner,
5268
- userId: S.user && S.user.user_id || S.serviceId,
5269
- platform: S.aiPlatform,
5270
- model: S.aiModel || void 0,
5271
- serviceName: S.serviceName,
5272
- serviceDescription: S.serviceDescription
5273
- };
5698
+ return currentIdentity();
5274
5699
  },
5275
5700
  buildSystemPrompt: function() {
5276
5701
  return buildSystemPrompt();
@@ -5440,13 +5865,56 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5440
5865
  return false;
5441
5866
  });
5442
5867
  }
5868
+ var attachmentUploadChain = Promise.resolve();
5869
+ var attachmentDispatchChain = Promise.resolve();
5870
+ var attachmentBatchSeq = 0;
5871
+ function enqueueAttachmentSend(job) {
5872
+ var uploaded = attachmentUploadChain.catch(function() {
5873
+ }).then(function() {
5874
+ return runAttachmentUpload(job);
5875
+ });
5876
+ attachmentUploadChain = uploaded;
5877
+ attachmentDispatchChain = attachmentDispatchChain.catch(function() {
5878
+ }).then(function() {
5879
+ return uploaded;
5880
+ }).then(function(urls) {
5881
+ return runAttachmentDispatch(job, urls);
5882
+ });
5883
+ }
5884
+ function runAttachmentUpload(job) {
5885
+ return session.uploadPendingAttachments(job.batchId).then(function(attachmentUrls) {
5886
+ var failureGroups = groupAttachmentFailures(CS.attachments.filter(function(a) {
5887
+ return a._batchId === job.batchId;
5888
+ }));
5889
+ clearSuccessfulAttachments(job.batchId);
5890
+ if (failureGroups.length) showUploadErrorReport(failureGroups);
5891
+ return attachmentUrls;
5892
+ }).catch(function(err) {
5893
+ console.error("[bunnyquery] attachment upload failed", err);
5894
+ updateComposerControls();
5895
+ renderAttachmentChips();
5896
+ if (job.stageId) session.settleStagedMessage(job.stageId);
5897
+ CS.messages.push({ role: "assistant", content: "Something went wrong while uploading attachments. " + (err && err.message || ""), isError: true });
5898
+ renderMessages();
5899
+ scrollToBottom(true);
5900
+ return null;
5901
+ });
5902
+ }
5903
+ function runAttachmentDispatch(job, attachmentUrls) {
5904
+ if (!attachmentUrls || !job.text) return Promise.resolve();
5905
+ if (job.stageId) session.markStagedMessageQueued(job.stageId);
5906
+ return session.awaitIndexingDrained(job.pinned.identity).then(function() {
5907
+ var c = composeUserMessage(job.text, attachmentUrls);
5908
+ session.dispatchComposedMessage(c.composed, true, c.composedForLlm, c.extractContent, c.fileUrls, job.pinned);
5909
+ });
5910
+ }
5443
5911
  function sendMessage() {
5444
5912
  var inputEl = CS.messagesBox && CS.messagesBox.parentNode && CS.messagesBox.parentNode.querySelector(".bq-input");
5445
5913
  var text = (inputEl ? inputEl.value : "").trim();
5446
- var hasAttachments = CS.attachments.length > 0;
5914
+ var batchAttachments = composerAttachments();
5915
+ var hasAttachments = batchAttachments.length > 0;
5447
5916
  if (!text && !hasAttachments) return;
5448
5917
  if (!chatEnabled() || S.aiPlatform === "none") return;
5449
- if (CS.uploadingAttachments) return;
5450
5918
  recomputeAttachmentWarning();
5451
5919
  if (CS.attachmentWarning) {
5452
5920
  renderAttachmentChips();
@@ -5461,25 +5929,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5461
5929
  session.dispatchComposedMessage(text, false);
5462
5930
  return;
5463
5931
  }
5464
- var bgBefore = bgTaskQueue.length;
5465
- session.uploadPendingAttachments().then(function(attachmentUrls) {
5466
- var hasNewIndexing = bgTaskQueue.length > bgBefore;
5467
- var failureGroups = groupAttachmentFailures(CS.attachments);
5468
- clearSuccessfulAttachments();
5469
- if (text) {
5470
- var c = composeUserMessage(text, attachmentUrls);
5471
- session.dispatchComposedMessage(c.composed, hasNewIndexing, c.composedForLlm, c.extractContent, c.fileUrls);
5472
- }
5473
- if (failureGroups.length) showUploadErrorReport(failureGroups);
5474
- }).catch(function(err) {
5475
- console.error("[bunnyquery] attachment upload failed", err);
5476
- CS.uploadingAttachments = false;
5477
- updateComposerControls();
5478
- renderAttachmentChips();
5479
- CS.messages.push({ role: "assistant", content: "Something went wrong while uploading attachments. " + (err && err.message || ""), isError: true });
5480
- renderMessages();
5481
- scrollToBottom(true);
5932
+ attachmentBatchSeq += 1;
5933
+ var batchId = "batch_" + attachmentBatchSeq + "_" + Date.now();
5934
+ batchAttachments.forEach(function(a) {
5935
+ a._batchId = batchId;
5482
5936
  });
5937
+ recomputeAttachmentWarning();
5938
+ renderAttachmentChips();
5939
+ updateComposerControls();
5940
+ var stageId = text ? session.stageOutgoingMessage(text) : void 0;
5941
+ var pinned = {
5942
+ identity: currentIdentity(),
5943
+ systemPrompt: buildSystemPrompt(),
5944
+ stageId
5945
+ };
5946
+ enqueueAttachmentSend({ text, batchId, stageId, pinned });
5483
5947
  }
5484
5948
  function scrollToBottom(smooth) {
5485
5949
  return raf2().then(function() {
@@ -5518,13 +5982,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5518
5982
  var key = filename + "\0" + body;
5519
5983
  var existing = fileBlobCache.get(key);
5520
5984
  if (existing) return existing;
5521
- var contentType = mimeGetType(filename) || "text/plain";
5522
- var ext = (String(filename || "").split(".").pop() || "").toLowerCase();
5523
- var isText = /^text\//i.test(contentType) || /application\/(json|xml|csv|yaml|x-yaml|javascript)/i.test(contentType);
5524
- var needsBom = ext === "csv" || ext === "tsv" || ext === "tab";
5525
- var type = isText ? contentType + "; charset=utf-8" : contentType;
5526
- var data = needsBom ? "\uFEFF" + body : body;
5527
- var href = URL.createObjectURL(new Blob([data], { type }));
5985
+ var prepared = prepareDownloadText(filename, body);
5986
+ var type = EXT_CONTENT_TYPES[extOf(filename)] || mimeGetType(filename) || "text/plain; charset=utf-8";
5987
+ var href = URL.createObjectURL(new Blob([prepared.text], { type }));
5528
5988
  fileBlobCache.set(key, href);
5529
5989
  return href;
5530
5990
  }
@@ -5737,9 +6197,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5737
6197
  if (IMAGE_EXTENSION_RE.test(name) || type.indexOf("image/") === 0) return ESTIMATED_IMAGE_TOKENS;
5738
6198
  return 0;
5739
6199
  }
6200
+ function composerAttachments() {
6201
+ return CS.attachments.filter(function(a) {
6202
+ return !a._batchId;
6203
+ });
6204
+ }
5740
6205
  function attachmentsTokenEstimate() {
5741
6206
  var total = 0;
5742
- CS.attachments.forEach(function(a) {
6207
+ composerAttachments().forEach(function(a) {
5743
6208
  if (a.kind === "folder") {
5744
6209
  (a.files || []).forEach(function(f) {
5745
6210
  total += estimateFileTokenCost(f.file);
@@ -5750,7 +6215,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5750
6215
  }
5751
6216
  function attachmentFileCount() {
5752
6217
  var n = 0;
5753
- CS.attachments.forEach(function(a) {
6218
+ composerAttachments().forEach(function(a) {
5754
6219
  n += a.kind === "folder" ? a.files ? a.files.length : 0 : 1;
5755
6220
  });
5756
6221
  return n;
@@ -5974,27 +6439,22 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5974
6439
  scheduleAttachmentOverflowRecompute();
5975
6440
  }
5976
6441
  function clearAttachments() {
5977
- CS.attachments.forEach(function(a) {
5978
- if (a._abort) {
5979
- try {
5980
- a._abort();
5981
- } catch (e) {
5982
- }
5983
- }
6442
+ CS.attachments = CS.attachments.filter(function(a) {
6443
+ return !!a._batchId;
5984
6444
  });
5985
- CS.attachments = [];
5986
6445
  CS.attachmentWarning = "";
5987
6446
  CS.attachmentCapNotice = "";
5988
6447
  renderAttachmentChips();
5989
6448
  updateComposerControls();
5990
6449
  scheduleAttachmentOverflowRecompute();
5991
6450
  }
5992
- function clearSuccessfulAttachments() {
6451
+ function clearSuccessfulAttachments(batchId) {
5993
6452
  CS.attachments = CS.attachments.filter(function(a) {
5994
- return a.status === "error" || a.status === "indexError";
5995
- });
5996
- CS.attachments.forEach(function(a) {
6453
+ if (a._batchId !== batchId) return true;
6454
+ if (a.status !== "error" && a.status !== "indexError") return false;
5997
6455
  a._abort = null;
6456
+ delete a._batchId;
6457
+ return true;
5998
6458
  });
5999
6459
  CS.attachmentCapNotice = "";
6000
6460
  recomputeAttachmentWarning();
@@ -6059,7 +6519,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6059
6519
  var meta = att.status === "error" ? "(Failed)" : att.status === "indexError" ? "(Error)" : att.status === "uploading" ? (att.progress || 0) + "%" : isFolder ? "(" + (att.files ? att.files.length : 0) + ")" : formatBytes(att.file ? att.file.size : att.size);
6060
6520
  chip.appendChild(h("span", { class: "bq-attachment-meta", text: meta }));
6061
6521
  if (clickable) chip.appendChild(h("span", { class: "bq-attachment-arrow", text: "\u2197" }));
6062
- if (!CS.uploadingAttachments && att.status !== "done") {
6522
+ if (att.status !== "uploading" && att.status !== "done") {
6063
6523
  var rm = h("button", { class: "bq-attachment-remove", type: "button", title: "Remove", text: "\xD7" });
6064
6524
  rm.addEventListener("click", function(e) {
6065
6525
  e.stopPropagation();
@@ -6079,23 +6539,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6079
6539
  title: moreNames.join("\n")
6080
6540
  });
6081
6541
  moreChip.appendChild(h("span", { class: "bq-attachment-name", text: "\u2026(" + hidden.length + ") more" }));
6082
- if (!CS.uploadingAttachments) {
6083
- var moreRm = h("button", {
6084
- class: "bq-attachment-remove",
6085
- type: "button",
6086
- title: "Remove these " + hidden.length,
6087
- text: "\xD7"
6088
- });
6089
- moreRm.addEventListener("click", function(e) {
6090
- e.stopPropagation();
6091
- removeAttachments(hidden.map(function(a) {
6092
- return a.id;
6093
- }));
6094
- });
6095
- moreChip.appendChild(moreRm);
6096
- }
6542
+ var moreRm = h("button", {
6543
+ class: "bq-attachment-remove",
6544
+ type: "button",
6545
+ title: "Remove these " + hidden.length,
6546
+ text: "\xD7"
6547
+ });
6548
+ moreRm.addEventListener("click", function(e) {
6549
+ e.stopPropagation();
6550
+ removeAttachments(hidden.map(function(a) {
6551
+ return a.id;
6552
+ }));
6553
+ });
6554
+ moreChip.appendChild(moreRm);
6097
6555
  row.appendChild(moreChip);
6098
- } else if (!CS.uploadingAttachments && CS.attachments.length >= 2) {
6556
+ } else if (composerAttachments().length >= 2) {
6099
6557
  var removeAll = h("button", {
6100
6558
  class: "bq-attachment-remove-all",
6101
6559
  type: "button",
@@ -6115,10 +6573,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6115
6573
  return ag < 99;
6116
6574
  }
6117
6575
  function updateComposerControls() {
6118
- var uploading = CS.uploadingAttachments;
6119
- if (CS.attachBtnEl) CS.attachBtnEl.disabled = uploading;
6120
- if (CS.inputEl) CS.inputEl.disabled = uploading;
6121
- if (CS.sendBtnEl) CS.sendBtnEl.disabled = uploading || !!CS.attachmentWarning;
6576
+ if (CS.attachBtnEl) CS.attachBtnEl.disabled = false;
6577
+ if (CS.inputEl) CS.inputEl.disabled = false;
6578
+ if (CS.sendBtnEl) CS.sendBtnEl.disabled = !!CS.attachmentWarning;
6122
6579
  }
6123
6580
  function onAttachInputChange(inputEl) {
6124
6581
  if (inputEl && inputEl.files && inputEl.files.length) addFilesToAttachments(inputEl.files);
@@ -6458,7 +6915,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6458
6915
  var md = h("div", { class: "bq-md", translate: "no", html: parseMsgPartsHtml(msg.content) });
6459
6916
  md.addEventListener("click", onBubbleLinkClick);
6460
6917
  bubble.appendChild(md);
6461
- if (msg.isPendingQueued) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(In queue)" }));
6918
+ if (msg.isUploadingAttachments) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(Uploading files...)" }));
6919
+ else if (msg.isPendingQueued) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(In queue)" }));
6462
6920
  if (msg.isCancelled) bubble.appendChild(h("span", { class: "bq-cancel-error", text: "(cancelled)" }));
6463
6921
  if (msg._cancelError) bubble.appendChild(h("span", { class: "bq-cancel-error", text: msg._cancelError }));
6464
6922
  var ts = formatChatTimestamp(msg._ts);