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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -0,0 +1,281 @@
1
+ /**
2
+ * How a text file the chat offers as a download is encoded and labelled, so that
3
+ * whatever opens it reads the characters correctly, in any language.
4
+ *
5
+ * When the model answers with a fenced ```name.ext block, the client turns that
6
+ * block into a Blob and an <a download>. We always write UTF-8, but several very
7
+ * common consumers do not assume UTF-8 when nothing in the file says so, and fall
8
+ * back to the reader's local ANSI codepage: CP949 in Korea, CP932 in Japan,
9
+ * CP936/CP950 in China and Taiwan, CP1251 for Cyrillic. The file is valid and
10
+ * every non-ASCII character still opens as mojibake.
11
+ *
12
+ * There is no single fix, because the way a file declares "this is UTF-8" is a
13
+ * property of the FORMAT:
14
+ *
15
+ * - a spreadsheet (csv/tsv) and a Windows text editor read a BOM;
16
+ * - HTML is opened from disk with no HTTP headers, so only an in-document
17
+ * <meta charset> survives;
18
+ * - XML carries its encoding in its declaration, and a WRONG declaration makes
19
+ * a conforming parser fail outright;
20
+ * - RTF is 7-bit, so non-ASCII has to be escaped into \uNNNN?;
21
+ * - JSON, JSONL and YAML are UTF-8 by specification, and a BOM BREAKS them.
22
+ *
23
+ * Anything unrecognised is left byte-for-byte alone: an unknown extension is far
24
+ * more likely to be machine-parsed, where an uninvited BOM is a new bug, than to
25
+ * be opened by a legacy editor.
26
+ *
27
+ * MIRROR of skapi-mcp/download-encoding.js, which does the same job for files the
28
+ * server publishes (writeReport / exportRecordsToFile). A file the user gets from
29
+ * a fenced block and the same file published as a download must behave
30
+ * identically, so the two have to change together.
31
+ */
32
+
33
+ export const BOM = '';
34
+
35
+ /** Files a spreadsheet or a Windows text editor opens directly. */
36
+ export const BOM_EXTS = new Set(['csv', 'tsv', 'tab', 'txt', 'text', 'log']);
37
+
38
+ /** Read from disk with no HTTP headers, so the declaration must be in the file. */
39
+ export const HTML_EXTS = new Set(['html', 'htm', 'xhtml']);
40
+ export const XML_EXTS = new Set(['xml', 'svg', 'rss', 'atom', 'xsl', 'xslt', 'plist', 'kml']);
41
+ export const RTF_EXTS = new Set(['rtf']);
42
+
43
+ /** Content types by extension. Every text family carries an explicit charset. */
44
+ export const EXT_CONTENT_TYPES: Record<string, string> = {
45
+ csv: 'text/csv; charset=utf-8',
46
+ tsv: 'text/tab-separated-values; charset=utf-8',
47
+ tab: 'text/tab-separated-values; charset=utf-8',
48
+ txt: 'text/plain; charset=utf-8',
49
+ text: 'text/plain; charset=utf-8',
50
+ log: 'text/plain; charset=utf-8',
51
+ md: 'text/markdown; charset=utf-8',
52
+ markdown: 'text/markdown; charset=utf-8',
53
+ json: 'application/json; charset=utf-8',
54
+ jsonl: 'application/x-ndjson; charset=utf-8',
55
+ ndjson: 'application/x-ndjson; charset=utf-8',
56
+ geojson: 'application/geo+json; charset=utf-8',
57
+ yaml: 'text/yaml; charset=utf-8',
58
+ yml: 'text/yaml; charset=utf-8',
59
+ toml: 'text/plain; charset=utf-8',
60
+ ini: 'text/plain; charset=utf-8',
61
+ sql: 'text/plain; charset=utf-8',
62
+ html: 'text/html; charset=utf-8',
63
+ htm: 'text/html; charset=utf-8',
64
+ xhtml: 'application/xhtml+xml; charset=utf-8',
65
+ xml: 'application/xml; charset=utf-8',
66
+ svg: 'image/svg+xml; charset=utf-8',
67
+ css: 'text/css; charset=utf-8',
68
+ js: 'text/javascript; charset=utf-8',
69
+ ts: 'text/plain; charset=utf-8',
70
+ py: 'text/x-python; charset=utf-8',
71
+ sh: 'text/x-shellscript; charset=utf-8',
72
+ srt: 'application/x-subrip; charset=utf-8',
73
+ vtt: 'text/vtt; charset=utf-8',
74
+ ics: 'text/calendar; charset=utf-8',
75
+ vcf: 'text/vcard; charset=utf-8',
76
+ // RTF is 7-bit ASCII by specification, so it takes no charset parameter.
77
+ rtf: 'application/rtf',
78
+ // Binary types the model can only ever REFERENCE, never author in a fence, but
79
+ // which keep the type sensible if one ever shows up.
80
+ pdf: 'application/pdf',
81
+ png: 'image/png',
82
+ jpg: 'image/jpeg',
83
+ jpeg: 'image/jpeg',
84
+ gif: 'image/gif',
85
+ webp: 'image/webp',
86
+ };
87
+
88
+ export type EncodingClass = 'bom' | 'html' | 'xml' | 'rtf' | 'none';
89
+
90
+ export function normalizeExt(ext: string | null | undefined): string {
91
+ return String(ext || '').trim().replace(/^\./, '').toLowerCase();
92
+ }
93
+
94
+ /** Extension of a filename, '' when it has none. */
95
+ export function extOf(filename: string | null | undefined): string {
96
+ const name = String(filename || '');
97
+ const dot = name.lastIndexOf('.');
98
+ return dot > 0 ? normalizeExt(name.slice(dot + 1)) : '';
99
+ }
100
+
101
+ /** Which encoding declaration this format understands. */
102
+ export function encodingClassForExt(ext: string | null | undefined): EncodingClass {
103
+ const e = normalizeExt(ext);
104
+ if (BOM_EXTS.has(e)) return 'bom';
105
+ if (HTML_EXTS.has(e)) return 'html';
106
+ if (XML_EXTS.has(e)) return 'xml';
107
+ if (RTF_EXTS.has(e)) return 'rtf';
108
+ return 'none';
109
+ }
110
+
111
+ /** True when a file with this extension must be written BOM-first. */
112
+ export function needsBomForExt(ext: string | null | undefined): boolean {
113
+ return encodingClassForExt(ext) === 'bom';
114
+ }
115
+
116
+ /**
117
+ * Content type to declare. Everything textual carries an explicit charset:
118
+ * without one the receiving end guesses, and it guesses the local codepage.
119
+ */
120
+ export function contentTypeForExt(
121
+ ext: string | null | undefined,
122
+ fallback = 'text/plain; charset=utf-8',
123
+ ): string {
124
+ return EXT_CONTENT_TYPES[normalizeExt(ext)] || fallback;
125
+ }
126
+
127
+ export function hasBom(text: string): boolean {
128
+ return typeof text === 'string' && text.charCodeAt(0) === 0xfeff;
129
+ }
130
+
131
+ // --- HTML -------------------------------------------------------------------
132
+ // Only the document head is inspected. A charset written further down is not one
133
+ // a browser would act on anyway (the spec only honours a declaration in the first
134
+ // 1024 bytes), and scanning the whole body would start matching <meta> tags that
135
+ // a document about HTML is merely quoting.
136
+ export const HTML_HEAD_WINDOW = 4096;
137
+ const META_CHARSET_RE = /<meta[^>]+charset\s*=\s*["']?\s*([a-z0-9_-]+)/i;
138
+ const META_HTTP_EQUIV_RE = /<meta[^>]+http-equiv\s*=\s*["']?content-type["']?[^>]*>/i;
139
+
140
+ /**
141
+ * Make an HTML document state its own encoding. Downloaded HTML is opened from
142
+ * disk, where the Content-Type we set no longer exists, so a document with no
143
+ * <meta charset> is decoded with the browser's locale default.
144
+ */
145
+ export function ensureHtmlCharset(text: string): string {
146
+ const src = String(text == null ? '' : text);
147
+ const head = src.slice(0, HTML_HEAD_WINDOW);
148
+
149
+ const declared = META_CHARSET_RE.exec(head);
150
+ if (declared) {
151
+ // A declaration naming anything other than UTF-8 is actively wrong: the
152
+ // bytes underneath it are always UTF-8. Correct it in place rather than
153
+ // adding a second, contradictory one.
154
+ if (declared[1].toLowerCase().replace(/[^a-z0-9]/g, '') === 'utf8') return src;
155
+ const start = declared.index + declared[0].length - declared[1].length;
156
+ return src.slice(0, start) + 'utf-8' + src.slice(start + declared[1].length);
157
+ }
158
+ // An http-equiv Content-Type with no charset= is still a declaration the
159
+ // browser will use; replace the whole tag with the modern short form.
160
+ const httpEquiv = META_HTTP_EQUIV_RE.exec(head);
161
+ if (httpEquiv) {
162
+ return src.slice(0, httpEquiv.index)
163
+ + '<meta charset="utf-8">'
164
+ + src.slice(httpEquiv.index + httpEquiv[0].length);
165
+ }
166
+
167
+ const tag = '<meta charset="utf-8">';
168
+ // As early as the document allows: inside <head> if there is one, else right
169
+ // after <html>, else at the very top (a fragment is still parsed head-first).
170
+ const headOpen = /<head[^>]*>/i.exec(head);
171
+ if (headOpen) {
172
+ const at = headOpen.index + headOpen[0].length;
173
+ return src.slice(0, at) + '\n' + tag + src.slice(at);
174
+ }
175
+ const htmlOpen = /<html[^>]*>/i.exec(head);
176
+ if (htmlOpen) {
177
+ const at = htmlOpen.index + htmlOpen[0].length;
178
+ return src.slice(0, at) + '\n<head>' + tag + '</head>' + src.slice(at);
179
+ }
180
+ const doctype = /<!doctype[^>]*>/i.exec(head);
181
+ if (doctype) {
182
+ const at = doctype.index + doctype[0].length;
183
+ return src.slice(0, at) + '\n' + tag + src.slice(at);
184
+ }
185
+ return tag + '\n' + src;
186
+ }
187
+
188
+ // --- XML ---------------------------------------------------------------------
189
+ const XML_DECL_RE = /^\s*<\?xml\s[^?]*\?>/i;
190
+
191
+ /**
192
+ * Correct an XML declaration that names the wrong encoding.
193
+ *
194
+ * A MISSING declaration is left alone on purpose: XML with none is UTF-8 by
195
+ * specification, so every conforming parser already gets it right. A declaration
196
+ * naming EUC-KR over UTF-8 bytes, on the other hand, makes a parser fail outright.
197
+ */
198
+ export function ensureXmlEncoding(text: string): string {
199
+ const src = String(text == null ? '' : text);
200
+ const decl = XML_DECL_RE.exec(src);
201
+ if (!decl) return src;
202
+
203
+ const found = /encoding\s*=\s*["']([^"']*)["']/i.exec(decl[0]);
204
+ if (!found) return src; // declaration without an encoding is UTF-8 by spec
205
+ if (found[1].toLowerCase().replace(/[^a-z0-9]/g, '') === 'utf8') return src;
206
+
207
+ const fixedDecl = decl[0].slice(0, found.index)
208
+ + found[0].replace(found[1], 'UTF-8')
209
+ + decl[0].slice(found.index + found[0].length);
210
+ return fixedDecl + src.slice(decl[0].length);
211
+ }
212
+
213
+ // --- RTF ----------------------------------------------------------------------
214
+ const RTF_SIGNATURE_RE = /^[\s]*\{\\rtf/i;
215
+
216
+ /** True when the body really is RTF rather than text merely named .rtf. */
217
+ export function looksLikeRtf(text: string): boolean {
218
+ return RTF_SIGNATURE_RE.test(String(text == null ? '' : text));
219
+ }
220
+
221
+ /**
222
+ * Escape every non-ASCII character into RTF's \uNNNN? form.
223
+ *
224
+ * RTF is 7-bit: a literal UTF-8 byte in the body is read through the codepage the
225
+ * header declares, which is how Korean, Japanese and Cyrillic RTF turns to
226
+ * mojibake in Word. \uNNNN? is codepage-independent.
227
+ *
228
+ * ASCII is never touched, which matters: backslashes and braces in an RTF body
229
+ * are control syntax, and "escaping" them would destroy the document. \u takes a
230
+ * SIGNED 16-bit value, so anything above 0x7FFF is emitted negative, and astral
231
+ * characters are emitted as their two surrogates.
232
+ */
233
+ export function escapeRtfNonAscii(text: string): string {
234
+ const src = String(text == null ? '' : text);
235
+ let out = '';
236
+ let plainFrom = 0;
237
+ for (let i = 0; i < src.length; i++) {
238
+ const code = src.charCodeAt(i); // UTF-16 unit: surrogates arrive separately
239
+ if (code < 0x80) continue;
240
+ out += src.slice(plainFrom, i);
241
+ out += `\\u${code > 32767 ? code - 65536 : code}?`;
242
+ plainFrom = i + 1;
243
+ }
244
+ return plainFrom === 0 ? src : out + src.slice(plainFrom);
245
+ }
246
+
247
+ /** Apply the format's encoding declaration to a whole document. */
248
+ export function applyEncodingDeclaration(text: string, ext: string | null | undefined): string {
249
+ const src = String(text == null ? '' : text);
250
+ switch (encodingClassForExt(ext)) {
251
+ case 'bom':
252
+ // Never a second BOM: two U+FEFF show as a visible in the first cell.
253
+ return hasBom(src) ? src : BOM + src;
254
+ case 'html':
255
+ return ensureHtmlCharset(src);
256
+ case 'xml':
257
+ return ensureXmlEncoding(src);
258
+ case 'rtf':
259
+ // Text merely NAMED .rtf is opened by Word as plain text, where a BOM is
260
+ // what makes it read UTF-8. Escaping it would show literal \u escapes.
261
+ return looksLikeRtf(src) ? escapeRtfNonAscii(src) : (hasBom(src) ? src : BOM + src);
262
+ default:
263
+ return src;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Everything a client needs to turn a fenced ```name.ext block into a download:
269
+ * the exact text to put in the Blob and the type to give it.
270
+ */
271
+ export function prepareDownloadText(
272
+ filename: string,
273
+ body: string,
274
+ ): { ext: string; text: string; contentType: string } {
275
+ const ext = extOf(filename);
276
+ return {
277
+ ext,
278
+ text: applyEncodingDeclaration(body, ext),
279
+ contentType: contentTypeForExt(ext),
280
+ };
281
+ }
@@ -3,6 +3,23 @@
3
3
  * agent.vue / bunnyquery chatbox so both consumers share one implementation.
4
4
  */
5
5
 
6
+ // Plain-language text per upstream status, for the case below where the provider
7
+ // gave us no readable message of its own.
8
+ var STATUS_MESSAGE: { [code: string]: string } = {
9
+ '408': 'The AI provider timed out before it started.',
10
+ '409': 'The AI provider rejected the request as conflicting.',
11
+ '413': 'The request was too large for the AI provider.',
12
+ '429': 'The AI provider is rate limiting requests right now.',
13
+ '500': 'The AI provider hit an internal error.',
14
+ '502': 'The AI provider is temporarily unreachable.',
15
+ '503': 'The AI provider is temporarily unavailable.',
16
+ '504': 'The AI provider timed out.',
17
+ };
18
+
19
+ function isTransientStatus(status: number): boolean {
20
+ return status === 408 || status === 425 || status === 429 || status >= 500;
21
+ }
22
+
6
23
  export function getErrorMessage(input: any): string {
7
24
  if (!input) return 'Something went wrong.';
8
25
  if (typeof input === 'string') return input;
@@ -10,6 +27,23 @@ export function getErrorMessage(input: any): string {
10
27
  if (input.body && input.body.error && input.body.error.message) return input.body.error.message;
11
28
  if (input.body && typeof input.body.message === 'string') return input.body.message;
12
29
  if (input.message) return input.message;
30
+
31
+ // Every branch above assumes the provider answered with JSON. It does not
32
+ // always: a gateway failure in front of the model API (Cloudflare's "502 Bad
33
+ // gateway" page, an ALB error page) arrives as an HTML STRING in `body`, so
34
+ // `body.error` / `body.message` are undefined on it and the user used to get a
35
+ // bare 'Something went wrong.' with no way to tell a provider outage from a
36
+ // bug in their own data. The status code is the one thing we always have, so
37
+ // say what it means and whether retrying is worth it.
38
+ var status = typeof input.status_code === 'number' ? input.status_code
39
+ : typeof input.status === 'number' ? input.status : 0;
40
+ if (status) {
41
+ var text = STATUS_MESSAGE[String(status)]
42
+ || (status >= 500 ? 'The AI provider returned a server error.'
43
+ : 'The AI provider rejected the request.');
44
+ return text + ' (error ' + status + ')'
45
+ + (isTransientStatus(status) ? ' This is usually temporary, please try again.' : '');
46
+ }
13
47
  return 'Something went wrong.';
14
48
  }
15
49
 
@@ -28,6 +28,11 @@ export interface ChatIdentity {
28
28
  export interface PinnedDispatchContext {
29
29
  identity: ChatIdentity;
30
30
  systemPrompt: string;
31
+ /** Id returned by stageOutgoingMessage. The turn's bubble is already on
32
+ * screen (staged while its attachments upload), so dispatchComposedMessage
33
+ * REPLACES that bubble in place instead of pushing a second one at the
34
+ * bottom — the message keeps the position it was sent in. */
35
+ stageId?: string;
31
36
  }
32
37
 
33
38
  /**
@@ -63,6 +68,17 @@ export interface ChatMessage {
63
68
  /** Set on background-indexing REQUEST bubbles only (see IndexingFileRef). */
64
69
  _indexFile?: IndexingFileRef;
65
70
  _useBgQueue?: boolean;
71
+ /** Local id of a turn STAGED at Send time while its attachments upload. The
72
+ * bubble exists before any server request does, so it is never matched by
73
+ * _serverItemId and is never promoted/cancelled by the queue machinery —
74
+ * dispatchComposedMessage consumes it (pinned.stageId) when the turn is
75
+ * finally sent. Staged bubbles are deliberately kept OUT of the history
76
+ * cache: an unmount kills the upload that would resolve them, so a cached
77
+ * copy would replay as a bubble that uploads forever. */
78
+ _stageId?: string;
79
+ /** True on a staged bubble while its files are still uploading (renders
80
+ * "(Uploading files...)" instead of "(In queue)"). */
81
+ isUploadingAttachments?: boolean;
66
82
  _serverItemId?: string;
67
83
  _localId?: string;
68
84
  _cancelling?: boolean;
@@ -45,6 +45,9 @@ export * from './prompts';
45
45
  // normalization, and history mapping — shared so both consumers stay identical.
46
46
  export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
47
47
  export * from './budget';
48
+ // Per-format UTF-8 declaration for files offered as a download. Shared so a fenced
49
+ // block and a server-published file open identically in Excel, Word and a browser.
50
+ export * from './download_encoding';
48
51
  export * from './links';
49
52
  export * from './time';
50
53
  export * from './ai_agent';
@@ -95,6 +98,7 @@ export {
95
98
  // constants
96
99
  POLL_INTERVAL,
97
100
  BG_INDEXING_QUEUE_SUFFIX,
101
+ bgIndexingQueueName,
98
102
  isBgIndexingQueue,
99
103
  MCP_NAME,
100
104
  DEFAULT_CLAUDE_MODEL,
@@ -233,11 +233,12 @@ export function composeUserMessage(
233
233
  attachmentUrls: Array<{ name: string; url: string; storagePath?: string }>,
234
234
  ): ComposedUserMessage {
235
235
  let composed = text;
236
+ let composedForLlm = composed;
236
237
  if (attachmentUrls.length > 0) {
237
238
  const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
238
239
  composed = `${text}\n\nAttached files:\n${lines.join('\n')}`;
240
+ composedForLlm = composed;
239
241
  }
240
- let composedForLlm = composed;
241
242
  let extractContent: ExtractDirective[] | undefined;
242
243
  let fileUrls: FileUrlDirective[] | undefined;
243
244
  if (attachmentUrls.length > 0) {
@@ -251,17 +252,33 @@ export function composeUserMessage(
251
252
  return `===== ${u.name} =====\n----- BEGIN FILE CONTENT -----\n${placeholder}\n----- END FILE CONTENT -----`;
252
253
  });
253
254
  extractContent = directives;
255
+ // Built on composedForLlm, not composed: the link block above may
256
+ // already carry the model-bound urls.
254
257
  composedForLlm =
255
- `${composed}\n\nExtracted content of attached office files ` +
258
+ `${composedForLlm}\n\nExtracted content of attached office files ` +
256
259
  `(read inline below; do NOT fetch their URLs):\n\n` +
257
260
  sections.join('\n\n');
258
261
  }
259
- // Files the model fetches by url (NOT server-extractable: PDFs, images) get
260
- // a re-mint directive so the worker swaps the baked long-lived CDN url for a
261
- // fresh short-lived one at send time. Extractable files are inlined as text,
262
- // so their url is never fetched — no directive needed. A blank url (nothing
263
- // to match/replace) is skipped.
264
- const urlFiles = attachmentUrls.filter((u) => u.url && !isServerExtractable(u.name));
262
+ // Files the model fetches by url (NOT server-extractable: PDFs, images)
263
+ // CAN be flagged for the worker to re-mint just before the upstream call,
264
+ // so a request that waited in the queue never hands over a stale link.
265
+ //
266
+ // It is off, and must stay off until the worker mints a url that answers
267
+ // HEAD. What it mints today is an S3 SigV4 query presign, and a presign is
268
+ // bound to the ONE method it was signed for: `get_object`. HEAD the same
269
+ // url and S3 rejects the signature with 403 — measured, and the CDN url it
270
+ // replaces answers both. OpenAI probes a file url before downloading it,
271
+ // so from the day this went live EVERY chat carrying an image or PDF came
272
+ // back "Error while downloading file. Upstream status code: 403." — 100%
273
+ // of the requests that carried the directive, none of the ones that did
274
+ // not. Sending no directive leaves the CDN url in place, which is what
275
+ // worked before and answers HEAD; the drain gate (awaitIndexingDrained)
276
+ // covers the staleness this was meant to solve, since the turn is now
277
+ // dispatched only once the queue is empty rather than sitting in it.
278
+ const WORKER_URL_REMINT_ENABLED = false;
279
+ const urlFiles = WORKER_URL_REMINT_ENABLED
280
+ ? attachmentUrls.filter((u) => u.url && !isServerExtractable(u.name))
281
+ : [];
265
282
  if (urlFiles.length > 0) {
266
283
  fileUrls = urlFiles.map((u) => ({ path: u.storagePath || u.name, url: u.url }));
267
284
  }
@@ -33,6 +33,7 @@ File attachments: When a user message contains an "Attached files:" section with
33
33
  - 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.
34
34
  - 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.
35
35
  - 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.
36
+ 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 «PHOTO A88» 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.
36
37
  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 — 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 — 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.
37
38
  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 — the file paths are indexed in the database and are always reachable through it.
38
39
  File generation: When the user asks you to generate a file — or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown — 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 — never base64 or any other encoding. Example for CSV:
@@ -232,7 +232,10 @@ export type CallClaudeWithMcpParams = {
232
232
  onResponse?: (res: any) => void;
233
233
  onError?: (err: any) => void;
234
234
  };
235
- export const POLL_INTERVAL = 1500;
235
+ // Poll cadence for every client-secret request the chat waits on. Shared by the
236
+ // engine's own poll sites and imported by agent.vue; the widget carries its own
237
+ // copy in src/index.js that must be kept in step.
238
+ export const POLL_INTERVAL = 3000;
236
239
  export async function callClaudeWithMcp({
237
240
  prompt,
238
241
  messages,
@@ -448,7 +451,17 @@ export type AttachmentSaveInfo = {
448
451
  model?: string;
449
452
  service: string;
450
453
  owner: string;
451
- userId?: string;
454
+ /**
455
+ * Queue base for this indexing pass: "<userId>-bg". REQUIRED, and it must be
456
+ * the SAME value the chat turn uses (ChatSession.dispatchComposedMessage's
457
+ * `id.userId || id.serviceId`) — the backend serialises requests that share a
458
+ * queue name and runs different ones IN PARALLEL, so a pass enqueued under a
459
+ * different base does not hold the chat back at all. It was optional once,
460
+ * defaulting to `service`; the chatbox omitted it, and its files were indexed
461
+ * on "<serviceId>-bg" while its question ran on "<userId>-bg" — the question
462
+ * was answered from a file nothing had read yet. Pass `userId || serviceId`.
463
+ */
464
+ userId: string;
452
465
  serviceName?: string;
453
466
  serviceDescription?: string;
454
467
  attachment: {
@@ -596,7 +609,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
596
609
  const imageDetail = getOpenAIImageDetail(resolvedModel);
597
610
  return clientSecretRequest({
598
611
  clientSecretName: 'openai',
599
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
612
+ queue: bgIndexingQueueName(info.userId, service),
600
613
  service,
601
614
  owner,
602
615
  ...pollOpt(),
@@ -643,7 +656,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
643
656
  const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
644
657
  return clientSecretRequest({
645
658
  clientSecretName: 'claude',
646
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
659
+ queue: bgIndexingQueueName(info.userId, service),
647
660
  service,
648
661
  owner,
649
662
  ...pollOpt(),
@@ -785,6 +798,17 @@ export async function listOpenAIModels(service: string, owner: string) {
785
798
  // so the chat-history BETWEEN query never includes bg-queue items. '-' (45) works.
786
799
  export const BG_INDEXING_QUEUE_SUFFIX = '-bg';
787
800
 
801
+ /**
802
+ * The one place the background-indexing queue name is spelled out. The backend
803
+ * serialises requests sharing a queue name and runs different names in PARALLEL,
804
+ * so every indexing pass AND the chat turn that must wait behind them have to
805
+ * resolve to the identical string — see AttachmentSaveInfo.userId for what
806
+ * happens when they do not.
807
+ */
808
+ export function bgIndexingQueueName(userId?: string, service?: string): string {
809
+ return (userId || service || '') + BG_INDEXING_QUEUE_SUFFIX;
810
+ }
811
+
788
812
  /**
789
813
  * True when a request belongs to the background-indexing queue.
790
814
  *