bunnyquery 1.8.2 → 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:
@@ -1403,7 +1405,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1403
1405
  const imageDetail = getOpenAIImageDetail(resolvedModel2);
1404
1406
  return clientSecretRequest({
1405
1407
  clientSecretName: "openai",
1406
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1408
+ queue: bgIndexingQueueName(info.userId, service),
1407
1409
  service,
1408
1410
  owner,
1409
1411
  ...pollOpt(),
@@ -1447,7 +1449,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1447
1449
  const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
1448
1450
  return clientSecretRequest({
1449
1451
  clientSecretName: "claude",
1450
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1452
+ queue: bgIndexingQueueName(info.userId, service),
1451
1453
  service,
1452
1454
  owner,
1453
1455
  ...pollOpt(),
@@ -1536,6 +1538,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1536
1538
  return "";
1537
1539
  }
1538
1540
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1541
+ function bgIndexingQueueName(userId, service) {
1542
+ return (userId || service || "") + BG_INDEXING_QUEUE_SUFFIX;
1543
+ }
1539
1544
  function isBgIndexingQueue(queueName) {
1540
1545
  if (typeof queueName !== "string" || !queueName) return false;
1541
1546
  const prefix = queueName.split("|")[0];
@@ -1775,6 +1780,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1775
1780
  // src/engine/session.ts
1776
1781
  var WORKER_PASS_ADOPT_LIMIT = 20;
1777
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;
1778
1788
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1779
1789
  function nowMs() {
1780
1790
  return _g.performance && typeof _g.performance.now === "function" ? _g.performance.now() : Date.now();
@@ -1847,6 +1857,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1847
1857
  this._pauseReasons = /* @__PURE__ */ new Set();
1848
1858
  this._resuming = false;
1849
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
+ });
1850
1879
  }
1851
1880
  /**
1852
1881
  * Register a live poll so (a) a remount dedupes against it instead of stacking a
@@ -1952,6 +1981,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1952
1981
  if (!key) return;
1953
1982
  this.aiChatHistoryCache[key] = {
1954
1983
  messages: this.state.messages.filter(function(m) {
1984
+ if (m._stageId) return false;
1955
1985
  return m._ownerKey === void 0 || m._ownerKey === key;
1956
1986
  }),
1957
1987
  endOfList: this.state.historyEndOfList,
@@ -2091,14 +2121,189 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2091
2121
  this.pendingAgentRequests[params.key] = run;
2092
2122
  return run;
2093
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
+ }
2094
2292
  // composed = clean display text; composedForLlm carries office-extraction
2095
2293
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
2096
2294
  // onto the "-bg" queue so it runs after indexing.
2097
2295
  dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
2098
2296
  var self = this;
2099
- if (!composed) return;
2297
+ var stageId = pinned ? pinned.stageId : void 0;
2298
+ if (!composed) {
2299
+ if (stageId) this.settleStagedMessage(stageId);
2300
+ return;
2301
+ }
2100
2302
  var id = pinned ? pinned.identity : this.host.getIdentity();
2101
- if (id.platform === "none") return;
2303
+ if (id.platform === "none") {
2304
+ if (stageId) this.settleStagedMessage(stageId);
2305
+ return;
2306
+ }
2102
2307
  var llmComposed = composedForLlm || composed;
2103
2308
  var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
2104
2309
  var offChat = !!key && key !== this.getHistoryCacheKey();
@@ -2109,7 +2314,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2109
2314
  var aiModel = id.model || void 0;
2110
2315
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
2111
2316
  var userId = id.userId || id.serviceId;
2112
- var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
2317
+ var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
2113
2318
  if (offChat) {
2114
2319
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
2115
2320
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
@@ -2122,9 +2327,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2122
2327
  history: offHistory.concat([{ role: "user", content: llmComposed }])
2123
2328
  });
2124
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
+ }
2125
2337
  this.aiChatHistoryCache[key] = {
2126
2338
  messages: offExisting.messages.concat([
2127
- { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() },
2339
+ offUser,
2128
2340
  { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
2129
2341
  ]),
2130
2342
  endOfList: offExisting.endOfList,
@@ -2159,14 +2371,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2159
2371
  var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
2160
2372
  if (key) queuedBubble._ownerKey = key;
2161
2373
  if (useBgQueue) queuedBubble._useBgQueue = true;
2162
- 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
+ }
2163
2387
  this.host.notify();
2164
2388
  this.updateHistoryCache();
2165
2389
  this.host.scrollToBottom(true);
2166
2390
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
2167
2391
  Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
2168
2392
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
2169
- 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);
2170
2394
  });
2171
2395
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
2172
2396
  if (sendingIdx >= 0) {
@@ -2191,23 +2415,31 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2191
2415
  });
2192
2416
  return;
2193
2417
  }
2194
- this.state.messages.push({ role: "user", content: composed, _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} });
2195
- 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
+ }
2196
2434
  this.host.notify();
2197
2435
  this.updateHistoryCache();
2198
2436
  this.state.sending = true;
2199
2437
  this.host.scrollToBottom(true);
2200
2438
  var historyForLlm = this.state.messages.filter(function(m) {
2439
+ if (m === immediateUser) return false;
2201
2440
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
2202
2441
  });
2203
- if (llmComposed !== composed) {
2204
- for (var li = historyForLlm.length - 1; li >= 0; li--) {
2205
- if (historyForLlm[li].role === "user" && historyForLlm[li].content === composed) {
2206
- historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
2207
- break;
2208
- }
2209
- }
2210
- }
2442
+ historyForLlm.push({ role: "user", content: llmComposed });
2211
2443
  var bounded = buildBoundedChatMessages({
2212
2444
  platform: aiPlatform,
2213
2445
  model: aiModel,
@@ -2448,7 +2680,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2448
2680
  if (platform !== "claude" && platform !== "openai") return;
2449
2681
  var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2450
2682
  var queueBase = id.userId || id.serviceId;
2451
- var queue = msg.isBackgroundTask || msg._useBgQueue ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
2683
+ var queue = msg.isBackgroundTask || msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
2452
2684
  this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: void 0 });
2453
2685
  this.host.notify();
2454
2686
  Promise.resolve(this.host.cancelRequest({
@@ -2934,7 +3166,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2934
3166
  if (!id.serviceId || platform !== "claude" && platform !== "openai") return;
2935
3167
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
2936
3168
  var svcId = id.serviceId, owner = id.owner;
2937
- var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
3169
+ var queue = bgIndexingQueueName(id.userId, id.serviceId);
2938
3170
  var ask = function(status) {
2939
3171
  return Promise.resolve(getChatHistory(
2940
3172
  { service: svcId, owner, platform, queue, status },
@@ -3036,7 +3268,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3036
3268
  url,
3037
3269
  method: "POST",
3038
3270
  id: serverId,
3039
- queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
3271
+ queue: bgIndexingQueueName(id.userId, id.serviceId),
3040
3272
  service: id.serviceId,
3041
3273
  owner: id.owner
3042
3274
  })).catch(function() {
@@ -3165,7 +3397,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3165
3397
  if (pass > MAX_INDEXING_RESUME_PASSES) return;
3166
3398
  var id = this.host.getIdentity();
3167
3399
  if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
3168
- notifyAgentContinueIndexing({
3400
+ this.trackIndexDispatch(notifyAgentContinueIndexing({
3169
3401
  platform: id.platform,
3170
3402
  model: id.model,
3171
3403
  service: id.serviceId,
@@ -3199,7 +3431,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3199
3431
  }
3200
3432
  }, function(e) {
3201
3433
  console.error("[chat-engine] resume-indexing dispatch failed", e);
3202
- });
3434
+ }));
3203
3435
  } catch (e) {
3204
3436
  }
3205
3437
  }
@@ -3277,6 +3509,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3277
3509
  if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
3278
3510
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
3279
3511
  if (!mm._serverItemId) {
3512
+ if (mm._stageId) {
3513
+ rescued.push(mm);
3514
+ continue;
3515
+ }
3280
3516
  if (mappedHasPendingAssistant) continue;
3281
3517
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
3282
3518
  else if (self.state.sending && mm.role === "user") {
@@ -3496,7 +3732,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3496
3732
  return preIndex.then(function() {
3497
3733
  return parseAttachmentContent(member.file, member.file.name, mime || void 0);
3498
3734
  }).then(function(parsedContent) {
3499
- return notifyAgentSaveAttachment({
3735
+ return self.trackIndexDispatch(notifyAgentSaveAttachment({
3500
3736
  platform: id.platform,
3501
3737
  model: id.model,
3502
3738
  service: id.serviceId,
@@ -3535,7 +3771,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3535
3771
  att.errorCode = e && (e.code || e.body && e.body.code) || "";
3536
3772
  att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
3537
3773
  }
3538
- });
3774
+ }));
3539
3775
  });
3540
3776
  });
3541
3777
  });
@@ -3554,14 +3790,24 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3554
3790
  }
3555
3791
  // Upload all not-yet-done attachments sequentially. Resolves to the full
3556
3792
  // list of { name, url, storagePath } for composing the chat message.
3557
- 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) {
3558
3801
  var self = this;
3559
3802
  this.host.resetOverwriteBatch();
3803
+ this._uploadBatches += 1;
3560
3804
  this.state.uploadingAttachments = true;
3561
3805
  this.host.updateComposerControls();
3562
3806
  this.host.renderAttachmentChips();
3563
3807
  var collected = [];
3564
- var snapshot = this.state.attachments.slice();
3808
+ var snapshot = this.state.attachments.filter(function(a) {
3809
+ return batchId ? a._batchId === batchId : true;
3810
+ });
3565
3811
  var chain = Promise.resolve();
3566
3812
  snapshot.forEach(function(att) {
3567
3813
  chain = chain.then(function() {
@@ -3597,7 +3843,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3597
3843
  });
3598
3844
  });
3599
3845
  var done = function() {
3600
- self.state.uploadingAttachments = false;
3846
+ self._uploadBatches = Math.max(0, self._uploadBatches - 1);
3847
+ self.state.uploadingAttachments = self._uploadBatches > 0;
3601
3848
  self.host.updateComposerControls();
3602
3849
  self.host.renderAttachmentChips();
3603
3850
  return collected;
@@ -3807,7 +4054,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3807
4054
  (function() {
3808
4055
  var MCP_PROD = "https://mcp.broadwayinc.computer";
3809
4056
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
3810
- var BQ_VERSION = "1.8.2" ;
4057
+ var BQ_VERSION = "1.8.3" ;
3811
4058
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
3812
4059
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
3813
4060
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -5435,17 +5682,20 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5435
5682
  var refreshingLinkPromises = /* @__PURE__ */ new Map();
5436
5683
  var fileBlobCache = /* @__PURE__ */ new Map();
5437
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
+ }
5438
5696
  var session = new ChatSession({
5439
5697
  getIdentity: function() {
5440
- return {
5441
- serviceId: S.serviceId,
5442
- owner: S.owner,
5443
- userId: S.user && S.user.user_id || S.serviceId,
5444
- platform: S.aiPlatform,
5445
- model: S.aiModel || void 0,
5446
- serviceName: S.serviceName,
5447
- serviceDescription: S.serviceDescription
5448
- };
5698
+ return currentIdentity();
5449
5699
  },
5450
5700
  buildSystemPrompt: function() {
5451
5701
  return buildSystemPrompt();
@@ -5615,13 +5865,56 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5615
5865
  return false;
5616
5866
  });
5617
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
+ }
5618
5911
  function sendMessage() {
5619
5912
  var inputEl = CS.messagesBox && CS.messagesBox.parentNode && CS.messagesBox.parentNode.querySelector(".bq-input");
5620
5913
  var text = (inputEl ? inputEl.value : "").trim();
5621
- var hasAttachments = CS.attachments.length > 0;
5914
+ var batchAttachments = composerAttachments();
5915
+ var hasAttachments = batchAttachments.length > 0;
5622
5916
  if (!text && !hasAttachments) return;
5623
5917
  if (!chatEnabled() || S.aiPlatform === "none") return;
5624
- if (CS.uploadingAttachments) return;
5625
5918
  recomputeAttachmentWarning();
5626
5919
  if (CS.attachmentWarning) {
5627
5920
  renderAttachmentChips();
@@ -5636,25 +5929,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5636
5929
  session.dispatchComposedMessage(text, false);
5637
5930
  return;
5638
5931
  }
5639
- var bgBefore = bgTaskQueue.length;
5640
- session.uploadPendingAttachments().then(function(attachmentUrls) {
5641
- var hasNewIndexing = bgTaskQueue.length > bgBefore;
5642
- var failureGroups = groupAttachmentFailures(CS.attachments);
5643
- clearSuccessfulAttachments();
5644
- if (text) {
5645
- var c = composeUserMessage(text, attachmentUrls);
5646
- session.dispatchComposedMessage(c.composed, hasNewIndexing, c.composedForLlm, c.extractContent, c.fileUrls);
5647
- }
5648
- if (failureGroups.length) showUploadErrorReport(failureGroups);
5649
- }).catch(function(err) {
5650
- console.error("[bunnyquery] attachment upload failed", err);
5651
- CS.uploadingAttachments = false;
5652
- updateComposerControls();
5653
- renderAttachmentChips();
5654
- CS.messages.push({ role: "assistant", content: "Something went wrong while uploading attachments. " + (err && err.message || ""), isError: true });
5655
- renderMessages();
5656
- scrollToBottom(true);
5932
+ attachmentBatchSeq += 1;
5933
+ var batchId = "batch_" + attachmentBatchSeq + "_" + Date.now();
5934
+ batchAttachments.forEach(function(a) {
5935
+ a._batchId = batchId;
5657
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 });
5658
5947
  }
5659
5948
  function scrollToBottom(smooth) {
5660
5949
  return raf2().then(function() {
@@ -5908,9 +6197,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5908
6197
  if (IMAGE_EXTENSION_RE.test(name) || type.indexOf("image/") === 0) return ESTIMATED_IMAGE_TOKENS;
5909
6198
  return 0;
5910
6199
  }
6200
+ function composerAttachments() {
6201
+ return CS.attachments.filter(function(a) {
6202
+ return !a._batchId;
6203
+ });
6204
+ }
5911
6205
  function attachmentsTokenEstimate() {
5912
6206
  var total = 0;
5913
- CS.attachments.forEach(function(a) {
6207
+ composerAttachments().forEach(function(a) {
5914
6208
  if (a.kind === "folder") {
5915
6209
  (a.files || []).forEach(function(f) {
5916
6210
  total += estimateFileTokenCost(f.file);
@@ -5921,7 +6215,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5921
6215
  }
5922
6216
  function attachmentFileCount() {
5923
6217
  var n = 0;
5924
- CS.attachments.forEach(function(a) {
6218
+ composerAttachments().forEach(function(a) {
5925
6219
  n += a.kind === "folder" ? a.files ? a.files.length : 0 : 1;
5926
6220
  });
5927
6221
  return n;
@@ -6145,27 +6439,22 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6145
6439
  scheduleAttachmentOverflowRecompute();
6146
6440
  }
6147
6441
  function clearAttachments() {
6148
- CS.attachments.forEach(function(a) {
6149
- if (a._abort) {
6150
- try {
6151
- a._abort();
6152
- } catch (e) {
6153
- }
6154
- }
6442
+ CS.attachments = CS.attachments.filter(function(a) {
6443
+ return !!a._batchId;
6155
6444
  });
6156
- CS.attachments = [];
6157
6445
  CS.attachmentWarning = "";
6158
6446
  CS.attachmentCapNotice = "";
6159
6447
  renderAttachmentChips();
6160
6448
  updateComposerControls();
6161
6449
  scheduleAttachmentOverflowRecompute();
6162
6450
  }
6163
- function clearSuccessfulAttachments() {
6451
+ function clearSuccessfulAttachments(batchId) {
6164
6452
  CS.attachments = CS.attachments.filter(function(a) {
6165
- return a.status === "error" || a.status === "indexError";
6166
- });
6167
- CS.attachments.forEach(function(a) {
6453
+ if (a._batchId !== batchId) return true;
6454
+ if (a.status !== "error" && a.status !== "indexError") return false;
6168
6455
  a._abort = null;
6456
+ delete a._batchId;
6457
+ return true;
6169
6458
  });
6170
6459
  CS.attachmentCapNotice = "";
6171
6460
  recomputeAttachmentWarning();
@@ -6230,7 +6519,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6230
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);
6231
6520
  chip.appendChild(h("span", { class: "bq-attachment-meta", text: meta }));
6232
6521
  if (clickable) chip.appendChild(h("span", { class: "bq-attachment-arrow", text: "\u2197" }));
6233
- if (!CS.uploadingAttachments && att.status !== "done") {
6522
+ if (att.status !== "uploading" && att.status !== "done") {
6234
6523
  var rm = h("button", { class: "bq-attachment-remove", type: "button", title: "Remove", text: "\xD7" });
6235
6524
  rm.addEventListener("click", function(e) {
6236
6525
  e.stopPropagation();
@@ -6250,23 +6539,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6250
6539
  title: moreNames.join("\n")
6251
6540
  });
6252
6541
  moreChip.appendChild(h("span", { class: "bq-attachment-name", text: "\u2026(" + hidden.length + ") more" }));
6253
- if (!CS.uploadingAttachments) {
6254
- var moreRm = h("button", {
6255
- class: "bq-attachment-remove",
6256
- type: "button",
6257
- title: "Remove these " + hidden.length,
6258
- text: "\xD7"
6259
- });
6260
- moreRm.addEventListener("click", function(e) {
6261
- e.stopPropagation();
6262
- removeAttachments(hidden.map(function(a) {
6263
- return a.id;
6264
- }));
6265
- });
6266
- moreChip.appendChild(moreRm);
6267
- }
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);
6268
6555
  row.appendChild(moreChip);
6269
- } else if (!CS.uploadingAttachments && CS.attachments.length >= 2) {
6556
+ } else if (composerAttachments().length >= 2) {
6270
6557
  var removeAll = h("button", {
6271
6558
  class: "bq-attachment-remove-all",
6272
6559
  type: "button",
@@ -6286,10 +6573,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6286
6573
  return ag < 99;
6287
6574
  }
6288
6575
  function updateComposerControls() {
6289
- var uploading = CS.uploadingAttachments;
6290
- if (CS.attachBtnEl) CS.attachBtnEl.disabled = uploading;
6291
- if (CS.inputEl) CS.inputEl.disabled = uploading;
6292
- 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;
6293
6579
  }
6294
6580
  function onAttachInputChange(inputEl) {
6295
6581
  if (inputEl && inputEl.files && inputEl.files.length) addFilesToAttachments(inputEl.files);
@@ -6629,7 +6915,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6629
6915
  var md = h("div", { class: "bq-md", translate: "no", html: parseMsgPartsHtml(msg.content) });
6630
6916
  md.addEventListener("click", onBubbleLinkClick);
6631
6917
  bubble.appendChild(md);
6632
- 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)" }));
6633
6920
  if (msg.isCancelled) bubble.appendChild(h("span", { class: "bq-cancel-error", text: "(cancelled)" }));
6634
6921
  if (msg._cancelError) bubble.appendChild(h("span", { class: "bq-cancel-error", text: msg._cancelError }));
6635
6922
  var ts = formatChatTimestamp(msg._ts);