bunnyquery 1.14.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,7 +140,8 @@ Mounts the widget. Returns the `BunnyQuery` object.
140
140
  | `attachmentParsers` | `array` | `null` | Client-side attachment parsers. See [Attachment parser plugins](#attachment-parser-plugins). |
141
141
  | `windowedIndexing` | `boolean` | `true` | Server-driven windowed indexing for text and grid files (see [file types](#supported-file-types)). Pass `false` to fall back to agent-driven paging, which keeps the traversal inside the model's turn budget and the tab open. |
142
142
  | `allowAnonymous` | `boolean` | `null` | Open the chat with no login for visitors without an account. `null` follows the project's own "Allow anonymous users" setting (`getConnectionInfo().conf.require_login`); `true`/`false` pins it. |
143
- | `webAccess` | `boolean` | `null` | Whether the chat's answers may reach the open web (Claude `web_fetch`, OpenAI `web_search`). `null` follows the project's own **Web access for answers** switch on its Project Settings page (the `web_access` key of the project's `bq::settings` record), which is **off** unless the owner turned it on: answers then come only from the project's files and database. `true`/`false` pins it for this page, both ways. Resolution order: this option, then the project setting, then off. Indexing is not affected. |
143
+ | `webAccess` | `boolean` | `null` | Whether the chat's answers may reach the open web (Claude `web_fetch`, OpenAI `web_search`). The project's own **Web access for answers** switch on its Project Settings page (the `web_access` key of the project's `bq::settings` record, **off** unless the owner turned it on) is the **ceiling**, and this option may only restrict below it: `effective = projectSetting && webAccess !== false`. `null` or `true` follows the project; `false` turns the web off for this page whatever the project says; a value above the project setting has no effect. With the web off, answers come only from the project's files and database. Indexing is not affected. |
144
+ | `skipDuplicateFiles` | `boolean` | `null` | Whether a chat attachment the signed-in user **already uploaded** to this project (same stored file name, same size in bytes, same uploader, found from the file's `src::` record) is skipped: it is neither uploaded nor indexed again, the chip reads "already uploaded", and the message uses the stored copy's url. The project's **Skip duplicate files** setting (the `skip_duplicate_files` key of `bq::settings`, **on** unless the owner turned it off) is the **ceiling**, and this option may only restrict below it: `effective = projectSetting && skipDuplicateFiles !== false`. `null` or `true` follows the project; `false` lets this page re-upload duplicates; a value above the project setting has no effect. The check never blocks an upload: a failed lookup uploads as usual. |
144
145
  | `liveStreaming` | `boolean` | `true` | Paint a chat answer into its bubble as it arrives, instead of at the end. On by default; pass `false` for buffered replies. Still a **request**, not a switch: the widget honours it only when your page's `skapi-js` actually carries skapi's half of the stream flag (it checks for `forwardRequestStream` and `forwardRequestFinalize`, or their older names `clientSecretRequestStream` and `clientSecretRequestFinalize` on an earlier `skapi-js`), and otherwise warns once and falls back to buffered replies. An older SDK silently drops the flag, which would leave the destination streaming SSE into a buffered row that reads back empty. It also needs a polling worker that relays the response bytes, which every deployed region now has; against a self-hosted worker that does not, pass `false`. |
145
146
  | `liveStreamingRealtime` | `boolean` | `false` | Deliver streamed chunks over skapi's websocket instead of waiting for the next poll tick. Requires `liveStreaming`. Off unless you ask for it: skapi's `joinRealtime` **replaces** the connection's group, so for the length of a turn it takes the room out from under whatever else your app uses realtime for. Purely an accelerator; with it off the reply still streams, on the poll's cadence. |
146
147
 
@@ -492,7 +493,8 @@ helpers. See the `.d.ts` shipped with `bunnyquery/engine`.
492
493
  | `attachmentParsers` | `array?` | Client-side attachment parsers, registered at configure time. More can be added later with `registerAttachmentParser()`. See [Attachment parser plugins](#attachment-parser-plugins). |
493
494
  | `windowedIndexing` | `boolean?` | Opt in to **server-driven** windowed indexing for text and grid files (see [file types](#supported-file-types)). Off by default in the engine; the widget passes it as `true`. The deployed skapi workers support it; only leave it off against a self-hosted worker that does not yet strip the `_skapi_window` directive, where it would reach the provider as an unknown body field and fail the call terminally with no retry. |
494
495
  | `liveStreaming` | `boolean?` | Opt in to **live streaming** of chat turns. Off by default in the engine (the widget passes it as `true`), and the backend ships first: a streamed row settles with a status and NO body (the answer was the stream), so against a worker that does not relay, the turn reads back empty. Pair it with `forwardRequestFinalize` and `forwardRequestStream`, and gate it on `skapiSupportsStreaming(skapi)`. |
495
- | `webAccess` | `boolean?` | Whether a chat turn may reach the open web: Claude's `web_fetch` and OpenAI's `web_search` are attached to a chat request only when this resolves to `true`. Leave it out to follow the project's own `web_access` setting (the `bq::settings` record, read through `configureProjectSettings`), which is **off** unless the project owner turned it on; a boolean here overrides the project for your host, both ways. `webAccessEnabled(serviceId)` and `resolveWebAccess(serviceId)` answer the resolved switch (sync from the cache, or awaiting the settings fetch). Indexing passes keep their tools regardless. Pass the same value to `buildChatSystemPrompt({ webAccess })` so the prompt matches the tool list. |
496
+ | `webAccess` | `boolean?` | Whether a chat turn may reach the open web: Claude's `web_fetch` and OpenAI's `web_search` are attached to a chat request only when this resolves to `true`. The project's own `web_access` setting (the `bq::settings` record, read through `configureProjectSettings`; **off** unless the project owner turned it on) is the **ceiling** and this option may only restrict below it: `effective = projectSetting && webAccess !== false`. Leave it out or pass `true` to follow the project; `false` turns the web off for your host; a value above the project setting has no effect. `webAccessEnabled(serviceId)` and `resolveWebAccess(serviceId, override?)` answer the resolved switch (sync from the cache, or awaiting the settings fetch; `override` is a per-call host value that restricts the same way). Indexing passes keep their tools regardless. Pass the same value to `buildChatSystemPrompt({ webAccess })` so the prompt matches the tool list. |
497
+ | `skipDuplicateFiles` | `boolean?` | Whether an attachment the signed-in user already uploaded to the project (same stored name, same size, same uploader) is skipped by `ChatSession`'s upload path: not uploaded, not indexed, the turn using the stored copy's url. The project's `skip_duplicate_files` setting (**on** unless the owner turned it off) is the **ceiling** and this option may only restrict: `effective = projectSetting && skipDuplicateFiles !== false`. The check runs only when the host implements the optional `findDuplicateUpload(file)` hook; `findDuplicateUpload(skapi, { name, size }, userId, { service? })` from the barrel is the ready-made implementation (an ends-with `getUniqueId` lookup narrowed to `src::<name>` and `src::.../<name>`, then the first record whose `data.size_bytes` and `user_id` match; it never throws, a failed lookup answers `null` and the file uploads as usual). `skipDuplicateFilesEnabled(serviceId)` and `resolveSkipDuplicateFiles(serviceId)` answer the resolved switch. |
496
498
  | `forwardRequestFinalize` | `function?` | `skapi.forwardRequestFinalize`, bound to your Skapi instance. Stores the version of a streamed turn that history keeps (the engine sends the assembled provider body, so it reads back exactly like a buffered turn) and releases that request's chunks. Without it a streamed turn is never finalized and its row stays empty. |
497
499
  | `forwardRequestStream` | `function?` | `skapi.forwardRequestStream`, bound to your Skapi instance. The **second half of the durability guarantee**: a row that settles while no poll is attached (closed tab, discarded background tab, slept device) is never finalized, so its answer stays in the chunk store and its history row is terminal and empty. Given the request id this drains that turn's chunks in one pass; the engine parses them exactly as it parses a live stream and finalizes what it read, so each row is recovered at most once. Without it the engine mints no recovery marker at all and behaves as it did before streaming. |
498
500
  | `onLiveStreamUpdate` | `function?` | Observation hook for a streaming turn (`{ serverItemId, ownerKey, phase, text, thinkingText, toolNames, complete, errored }`). The engine already paints the answer text itself, so this is only for affordances it does not decide the presentation of. Never throw from it. |
package/bunnyquery.js CHANGED
@@ -61,6 +61,13 @@
61
61
  function webAccessFrom(data) {
62
62
  return data ? normalizeWebAccess(data.web_access) : DEFAULT_WEB_ACCESS;
63
63
  }
64
+ var DEFAULT_SKIP_DUPLICATE_FILES = true;
65
+ function normalizeSkipDuplicateFiles(value) {
66
+ return value !== false;
67
+ }
68
+ function skipDuplicateFilesFrom(data) {
69
+ return data ? normalizeSkipDuplicateFiles(data.skip_duplicate_files) : DEFAULT_SKIP_DUPLICATE_FILES;
70
+ }
64
71
  var CHAT_GREETING_MAX_LENGTH = 400;
65
72
  function normalizeChatGreeting(value) {
66
73
  if (typeof value !== "string") return "";
@@ -203,16 +210,25 @@
203
210
  function liveStreamingEnabled() {
204
211
  return _config?.liveStreaming === true;
205
212
  }
213
+ function underCeiling(projectSetting, option, override) {
214
+ if (!projectSetting) return false;
215
+ if (option === false) return false;
216
+ if (override === false) return false;
217
+ return true;
218
+ }
206
219
  function webAccessEnabled(service) {
207
220
  const opt = _config ? _config.webAccess : void 0;
208
- if (typeof opt === "boolean") return opt;
209
- return webAccessFrom(service ? cachedProjectSettings(service) : null);
221
+ return underCeiling(webAccessFrom(service ? cachedProjectSettings(service) : null), opt);
210
222
  }
211
- function resolveWebAccess(service) {
223
+ function resolveWebAccess(service, override) {
212
224
  const opt = _config ? _config.webAccess : void 0;
213
- if (typeof opt === "boolean") return Promise.resolve(opt);
214
- if (!service) return Promise.resolve(false);
215
- return loadProjectSettings(service).then(webAccessFrom, () => false);
225
+ if (!service) return Promise.resolve(underCeiling(webAccessFrom(null), opt, override));
226
+ return loadProjectSettings(service).then(webAccessFrom, () => false).then((setting) => underCeiling(setting, opt, override));
227
+ }
228
+ function resolveSkipDuplicateFiles(service) {
229
+ const opt = _config ? _config.skipDuplicateFiles : void 0;
230
+ if (!service) return Promise.resolve(underCeiling(skipDuplicateFilesFrom(null), opt));
231
+ return loadProjectSettings(service).then(skipDuplicateFilesFrom, () => skipDuplicateFilesFrom(null)).then((setting) => underCeiling(setting, opt));
216
232
  }
217
233
  function streamRecoveryEnabled() {
218
234
  if (!_config) return false;
@@ -228,6 +244,54 @@
228
244
  return p === void 0 ? {} : { poll: p };
229
245
  }
230
246
 
247
+ // src/engine/duplicates.ts
248
+ var DUPLICATE_LOOKUP_MAX_CANDIDATES = 25;
249
+ var SRC_PREFIX = "src::";
250
+ function isDuplicateCandidateId(uniqueId, name) {
251
+ if (typeof uniqueId !== "string" || !name) return false;
252
+ if (uniqueId.indexOf(SRC_PREFIX) !== 0) return false;
253
+ if (uniqueId === SRC_PREFIX + name) return true;
254
+ const tail = "/" + name;
255
+ return uniqueId.length > SRC_PREFIX.length + tail.length && uniqueId.slice(-tail.length) === tail;
256
+ }
257
+ function listOf(res) {
258
+ if (!res) return [];
259
+ if (Array.isArray(res)) return res;
260
+ return Array.isArray(res.list) ? res.list : [];
261
+ }
262
+ async function findDuplicateUpload(skapi, file, userId, opts) {
263
+ try {
264
+ if (!skapi || typeof skapi.getUniqueId !== "function" || typeof skapi.getRecords !== "function") return null;
265
+ const name = file && typeof file.name === "string" ? file.name : "";
266
+ const size = file && typeof file.size === "number" ? file.size : NaN;
267
+ if (!name || !(size >= 0) || !userId) return null;
268
+ const service = opts && opts.service ? { service: opts.service } : {};
269
+ const found = listOf(await skapi.getUniqueId(Object.assign({ unique_id: name, condition: "<=" }, service)));
270
+ const ids = [];
271
+ for (const item of found) {
272
+ const id = item && typeof item === "object" ? item.unique_id : item;
273
+ if (isDuplicateCandidateId(id, name) && ids.indexOf(id) === -1) ids.push(id);
274
+ if (ids.length >= DUPLICATE_LOOKUP_MAX_CANDIDATES) break;
275
+ }
276
+ for (const uniqueId of ids) {
277
+ let record = null;
278
+ try {
279
+ record = listOf(await skapi.getRecords(Object.assign({ unique_id: uniqueId }, service)))[0] || null;
280
+ } catch {
281
+ continue;
282
+ }
283
+ if (!record || typeof record !== "object") continue;
284
+ const data = record.data && typeof record.data === "object" ? record.data : null;
285
+ if (!data || data.size_bytes !== size) continue;
286
+ if (record.user_id !== userId) continue;
287
+ return { storagePath: uniqueId.slice(SRC_PREFIX.length), uniqueId, record };
288
+ }
289
+ return null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
231
295
  // src/engine/office.ts
232
296
  var OFFICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
233
297
  "doc",
@@ -637,6 +701,10 @@ Records for the earlier pages are ALREADY saved (they reference "${src}"). The N
637
701
  ` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
638
702
  `;
639
703
  }
704
+ var PAGE_TEXT_TABLE = "page_texts";
705
+ var PAGE_TEXT_RULE = `PAGE TEXT for pages WITHOUT a text layer: for EVERY page whose label (its first label, when the page is split into strips) carries NO "Embedded text layer" block, ALSO post exactly ONE record in table "${PAGE_TEXT_TABLE}" with data {"page": <its document page number from the label, 1-based>, "page_text": "<the page's complete text exactly as printed, verbatim, in reading order, nothing summarised or left out>"}, reference "src::" + the storage path above, in addition to the records above. A page WITH an embedded text layer gets NO such record: its text is already stored.
706
+
707
+ `;
640
708
  function buildRenderDatafy(placeholder) {
641
709
  return `
642
710
  ${placeholder}
@@ -647,7 +715,7 @@ Each image is preceded by a label giving its DOCUMENT PAGE number. That label is
647
715
 
648
716
  Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample of the first few - if a page has more rows than you can save comfortably, still save them all rather than summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the exact text and should be preferred over reading the pixels, with the image used for layout, tables, stamps and handwriting.
649
717
 
650
- Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
718
+ ` + PAGE_TEXT_RULE + `Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
651
719
  }
652
720
  function buildIndexingWindowMessage(attachment, placeholder, isContinuation, positionLabel) {
653
721
  const src = `src::${attachment.storagePath}`;
@@ -1151,7 +1219,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1151
1219
  var FILE_KEYS = ["path", "storage_path", "storagePath", "file", "file_path", "filePath"];
1152
1220
  var TABLE_KEYS = ["table_name", "tableName"];
1153
1221
  var GROUP_KEYS = ["access_group", "accessGroup"];
1154
- var SRC_PREFIX = "src::";
1222
+ var SRC_PREFIX2 = "src::";
1155
1223
  var RESULT_WALK_MAX_NODES = 400;
1156
1224
  var RESULT_WALK_MAX_DEPTH = 5;
1157
1225
  function isWebToolName(name) {
@@ -1180,8 +1248,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1180
1248
  }
1181
1249
  function pathFromSrcId(value) {
1182
1250
  if (typeof value !== "string") return null;
1183
- if (value.indexOf(SRC_PREFIX) !== 0) return null;
1184
- var rest = value.slice(SRC_PREFIX.length).trim();
1251
+ if (value.indexOf(SRC_PREFIX2) !== 0) return null;
1252
+ var rest = value.slice(SRC_PREFIX2.length).trim();
1185
1253
  return rest ? rest : null;
1186
1254
  }
1187
1255
  function parseJsonLoose(value) {
@@ -1380,6 +1448,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1380
1448
  "claude-haiku-4-5": 2e5,
1381
1449
  "claude-3-5-sonnet": 2e5,
1382
1450
  // openai, exact ids
1451
+ // gpt-6.1: OpenAI's listing carries no window; taken as the gpt-6 family's until a
1452
+ // published figure says otherwise (2026-10-12, the default model).
1453
+ "gpt-6.1-sol": 105e4,
1383
1454
  "gpt-6-astra": 105e4,
1384
1455
  "gpt-6-sol": 105e4,
1385
1456
  "gpt-6-luna": 105e4,
@@ -1399,6 +1470,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1399
1470
  "claude-opus": 1e6,
1400
1471
  "claude-sonnet": 1e6,
1401
1472
  "claude-haiku": 2e5,
1473
+ "gpt-6.1": 105e4,
1402
1474
  "gpt-6": 105e4,
1403
1475
  "gpt-5.6": 105e4,
1404
1476
  "gpt-5": 128e3
@@ -1416,6 +1488,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1416
1488
  "claude-haiku-4-5": 64e3,
1417
1489
  "claude-3-5-sonnet": 8e3,
1418
1490
  // openai
1491
+ "gpt-6.1-sol": 128e3,
1419
1492
  "gpt-6-astra": 128e3,
1420
1493
  "gpt-6-sol": 128e3,
1421
1494
  "gpt-6-luna": 128e3,
@@ -1435,6 +1508,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1435
1508
  "claude-opus": 128e3,
1436
1509
  "claude-sonnet": 64e3,
1437
1510
  "claude-haiku": 64e3,
1511
+ "gpt-6.1": 128e3,
1438
1512
  "gpt-6": 128e3,
1439
1513
  "gpt-5.6": 128e3,
1440
1514
  "gpt-5": 128e3
@@ -2602,8 +2676,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2602
2676
  return [openaiWebSearchTool()] ;
2603
2677
  };
2604
2678
  var MCP_NAME = "BunnyQuery";
2605
- var DEFAULT_CLAUDE_MODEL = "claude-sonnet-5";
2606
- var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
2679
+ var DEFAULT_CLAUDE_MODEL = "claude-sonnet-5-5";
2680
+ var DEFAULT_OPENAI_MODEL = "gpt-6.1-sol";
2607
2681
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
2608
2682
  function withMcpParams(base, params) {
2609
2683
  if (!base) return base;
@@ -2805,7 +2879,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2805
2879
  fileUrls,
2806
2880
  webAccess
2807
2881
  }) {
2808
- const web = typeof webAccess === "boolean" ? webAccess : await resolveWebAccess(service);
2882
+ const web = await resolveWebAccess(service, typeof webAccess === "boolean" ? webAccess : void 0);
2809
2883
  const mcpServerDefinition = {
2810
2884
  type: "url",
2811
2885
  name: mcpServer.name,
@@ -8335,11 +8409,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8335
8409
  if (!total) return Promise.reject(new Error("Empty attachment"));
8336
8410
  var urls = [];
8337
8411
  var anyIndexFailed = false;
8412
+ var reusedCount = 0;
8413
+ att.reused = false;
8338
8414
  var chain = Promise.resolve();
8415
+ var findDuplicate = function(member) {
8416
+ if (typeof self.host.findDuplicateUpload !== "function") return Promise.resolve(null);
8417
+ var storedName = String(member.storagePath || "").split("/").filter(Boolean).pop() || member.file.name;
8418
+ return resolveSkipDuplicateFiles(id.projectId).then(function(on) {
8419
+ if (!on) return null;
8420
+ return Promise.resolve(self.host.findDuplicateUpload({ name: storedName, size: member.file.size })).then(function(hit) {
8421
+ return hit && typeof hit.storagePath === "string" && hit.storagePath ? hit.storagePath : null;
8422
+ });
8423
+ }).catch(function() {
8424
+ return null;
8425
+ });
8426
+ };
8339
8427
  members.forEach(function(member, idx) {
8340
8428
  chain = chain.then(function() {
8341
8429
  var hadExists = false;
8342
8430
  var skipped = false;
8431
+ var duplicateOf = null;
8343
8432
  var existedBefore = false;
8344
8433
  var onProg = function(p) {
8345
8434
  if (p && p.total) {
@@ -8358,7 +8447,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8358
8447
  }
8359
8448
  });
8360
8449
  };
8361
- return doMemberUpload(true).catch(function(err) {
8450
+ return findDuplicate(member).then(function(dup) {
8451
+ if (dup) {
8452
+ duplicateOf = dup;
8453
+ console.log("[chat-engine] skipping an upload the user already has:", member.storagePath, "->", dup);
8454
+ return;
8455
+ }
8456
+ return doMemberUpload(true);
8457
+ }).catch(function(err) {
8362
8458
  var code = err && (err.code || err.body && err.body.code);
8363
8459
  var msg = err && (err.message || err.body && err.body.message || (typeof err === "string" ? err : ""));
8364
8460
  var isExists = code === "EXISTS" || msg && /exist/i.test(msg);
@@ -8378,9 +8474,20 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8378
8474
  });
8379
8475
  }).then(function() {
8380
8476
  if (skipped) return;
8381
- return self.host.getTemporaryUrl(member.storagePath);
8477
+ return self.host.getTemporaryUrl(duplicateOf || member.storagePath);
8382
8478
  }).then(function(url) {
8383
8479
  if (skipped) return;
8480
+ if (duplicateOf) {
8481
+ urls.push({ name: member.relPath, url, storagePath: duplicateOf });
8482
+ reusedCount += 1;
8483
+ if (att.kind !== "folder") {
8484
+ att.uploadedUrl = url;
8485
+ att.storagePath = duplicateOf;
8486
+ att.reused = true;
8487
+ } else att.reusedCount = reusedCount;
8488
+ self.host.renderAttachmentChips();
8489
+ return;
8490
+ }
8384
8491
  urls.push({ name: member.relPath, url, storagePath: member.storagePath });
8385
8492
  if (att.kind !== "folder") {
8386
8493
  att.uploadedUrl = url;
@@ -9012,7 +9119,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9012
9119
  (function() {
9013
9120
  var MCP_PROD = "https://mcp.broadwayinc.computer";
9014
9121
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
9015
- var BQ_VERSION = "1.14.0" ;
9122
+ var BQ_VERSION = "1.15.1" ;
9016
9123
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
9017
9124
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
9018
9125
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -10852,6 +10959,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10852
10959
  uploadAccessGroup: function(path) {
10853
10960
  return resolveUploadAccessGroup(path);
10854
10961
  },
10962
+ // "Already uploaded by this user?" from the src:: file records. The engine
10963
+ // asks only while the skip-duplicates switch is on; null means upload.
10964
+ findDuplicateUpload: function(file) {
10965
+ var uid = S.user && S.user.user_id;
10966
+ if (!uid) return Promise.resolve(null);
10967
+ return findDuplicateUpload(S.skapi, file, uid, { service: S.projectId });
10968
+ },
10855
10969
  storagePathFor: function(relPath) {
10856
10970
  return attachmentStoragePath(relPath);
10857
10971
  },
@@ -11833,7 +11947,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
11833
11947
  });
11834
11948
  chip.appendChild(h("span", { class: "bq-attachment-icon", html: isFolder ? FOLDER_ICON_SVG : FILE_ICON_SVG }));
11835
11949
  chip.appendChild(h("span", { class: "bq-attachment-name", text: att.name, title: att.name }));
11836
- var meta = att.status === "error" ? "(Failed)" : att.status === "indexError" ? "(Error)" : preparing ? "Preparing" : finalizing ? "Finalizing" : att.status === "uploading" ? att.progress + "%" : isFolder ? "(" + (att.files ? att.files.length : 0) + ")" : formatBytes(att.file ? att.file.size : att.size);
11950
+ var meta = att.status === "error" ? "(Failed)" : att.status === "indexError" ? "(Error)" : preparing ? "Preparing" : finalizing ? "Finalizing" : att.status === "uploading" ? att.progress + "%" : isFolder ? "(" + (att.files ? att.files.length : 0) + ")" : att.reused ? "already uploaded" : formatBytes(att.file ? att.file.size : att.size);
11837
11951
  chip.appendChild(h("span", { class: "bq-attachment-meta", text: meta }));
11838
11952
  if (clickable) chip.appendChild(h("span", { class: "bq-attachment-arrow", text: "\u2197" }));
11839
11953
  if (att.status !== "uploading" && att.status !== "done") {
@@ -13657,6 +13771,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
13657
13771
  // true/false pins it for this page, both ways. Read at
13658
13772
  // configureChatEngine time and when each chat request is built.
13659
13773
  webAccess: null,
13774
+ // Whether a chat attachment the signed-in user ALREADY uploaded to this
13775
+ // project (same file name, same size, same uploader) is skipped: not
13776
+ // uploaded again, not indexed again, the message using the stored
13777
+ // copy. null = follow the project's own "Skip duplicate files" setting
13778
+ // (`skip_duplicate_files` in the bq::settings record), which is ON
13779
+ // unless the owner turned it off. The project setting is the ceiling:
13780
+ // false here restricts below it for this page, true has no effect on
13781
+ // a project that turned it off.
13782
+ skipDuplicateFiles: null,
13660
13783
  // Server-driven windowed indexing; read at configureChatEngine time.
13661
13784
  // Listed here so the defaults object is the full opt surface.
13662
13785
  windowedIndexing: true,
@@ -13738,6 +13861,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
13738
13861
  // chat request (off unless the owner turned it on). Passing the null
13739
13862
  // default through would read as a decision.
13740
13863
  webAccess: typeof S.opts.webAccess === "boolean" ? S.opts.webAccess : void 0,
13864
+ // Skip duplicate uploads. Same hand-over rule: only a pinned boolean is
13865
+ // passed, and the project's setting is the ceiling the engine applies.
13866
+ skipDuplicateFiles: typeof S.opts.skipDuplicateFiles === "boolean" ? S.opts.skipDuplicateFiles : void 0,
13741
13867
  // ---- live streaming (mirrored in agent.vue's ai_agent.ts) --------
13742
13868
  // On by default since every region's polling worker relays the
13743
13869
  // response bytes. It used to be off for the shipping-order reason