bunnyquery 1.8.0 → 1.8.2

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.0",
3
+ "version": "1.8.2",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The `ai_agent` service option, parsed and serialized in ONE place.
3
+ *
4
+ * Stored on the skapi service record as up to three '#'-delimited segments:
5
+ *
6
+ * none AI chat disabled
7
+ * claude platform chosen, no model saved yet
8
+ * claude#claude-sonnet-4-6 platform + model
9
+ * claude#claude-sonnet-4-6#400000 platform + model + context-window override
10
+ *
11
+ * The third segment is new and optional, so every value written before it
12
+ * existed parses unchanged with `contextWindow: null`. Nothing writes a third
13
+ * segment without a model, because the window is meaningless without one.
14
+ *
15
+ * This lived as four separate copies (agent.vue, dbfile.vue, service.vue, and
16
+ * the widget's index.js), which is how the format drifts. New callers should
17
+ * import from here rather than re-deriving the split.
18
+ */
19
+
20
+ export type AiAgentPlatform = 'claude' | 'openai' | null;
21
+
22
+ export type ParsedAiAgent = {
23
+ /** null when unset or explicitly 'none'. */
24
+ platform: AiAgentPlatform;
25
+ /** '' when no model has been saved. */
26
+ model: string;
27
+ /** Per-project context-window override in tokens, or null to use the model's. */
28
+ contextWindow: number | null;
29
+ /** True when a real platform is configured (i.e. not unset and not 'none'). */
30
+ hasPlatform: boolean;
31
+ };
32
+
33
+ function normalizePlatform(raw: string): AiAgentPlatform {
34
+ var p = (raw || '').trim().toLowerCase();
35
+ return p === 'claude' || p === 'openai' ? p : null;
36
+ }
37
+
38
+ export function parseAiAgentValue(value: string | null | undefined): ParsedAiAgent {
39
+ var raw = (value || '').trim();
40
+ if (!raw || raw.toLowerCase() === 'none') {
41
+ return { platform: null, model: '', contextWindow: null, hasPlatform: false };
42
+ }
43
+
44
+ // Split into at most three parts so a '#' inside a model id (none today, but
45
+ // the delimiter should not be load-bearing on the tail) cannot shift fields.
46
+ var firstHash = raw.indexOf('#');
47
+ if (firstHash === -1) {
48
+ var only = normalizePlatform(raw);
49
+ return { platform: only, model: '', contextWindow: null, hasPlatform: !!only };
50
+ }
51
+
52
+ var platform = normalizePlatform(raw.slice(0, firstHash));
53
+ var rest = raw.slice(firstHash + 1);
54
+ var secondHash = rest.indexOf('#');
55
+ var model = (secondHash === -1 ? rest : rest.slice(0, secondHash)).trim();
56
+ var windowRaw = secondHash === -1 ? '' : rest.slice(secondHash + 1).trim();
57
+
58
+ var parsedWindow = windowRaw ? Number(windowRaw) : NaN;
59
+ var contextWindow = Number.isFinite(parsedWindow) && parsedWindow > 0
60
+ ? Math.floor(parsedWindow)
61
+ : null;
62
+
63
+ return { platform: platform, model: model, contextWindow: contextWindow, hasPlatform: !!platform };
64
+ }
65
+
66
+ export function buildAiAgentValue(
67
+ platform: string | null | undefined,
68
+ model?: string | null,
69
+ contextWindow?: number | null,
70
+ ): string {
71
+ var p = (platform || '').trim().toLowerCase();
72
+ if (!p || p === 'none') return 'none';
73
+
74
+ var m = (model || '').trim();
75
+ if (!m) return p;
76
+
77
+ var n = Number(contextWindow);
78
+ if (!Number.isFinite(n) || n <= 0) return p + '#' + m;
79
+ return p + '#' + m + '#' + Math.floor(n);
80
+ }
@@ -7,15 +7,76 @@
7
7
  import { sanitizeAttachmentLinksForHistory } from './links';
8
8
 
9
9
  export var CONTEXT_WINDOW_DEFAULT: Record<string, number> = { claude: 200000, openai: 128000 };
10
+ // Exact model ids first, then family keys (see getContextWindow: a suffixed id
11
+ // falls back to its family rather than to the platform default). Claude figures
12
+ // come from Anthropic's published model table; `max_input_tokens` from
13
+ // /v1/models overrides all of this at runtime when a listing has been seen.
14
+ // 'claude-opus-4-7' read 200000 here, which was wrong by 5x, and the default
15
+ // Claude model 'claude-sonnet-4-6' was absent entirely so it fell through to
16
+ // CONTEXT_WINDOW_DEFAULT.claude. Neither was observable, because the history
17
+ // budget and the Claude per-request cap below bind long before the window does.
10
18
  export var CONTEXT_WINDOW_BY_MODEL: Record<string, number> = {
11
- 'claude-opus-4-7': 200000, 'claude-sonnet-4': 200000, 'gpt-5.4': 128000,
19
+ // exact ids
20
+ 'claude-opus-5': 1000000, 'claude-opus-4-8': 1000000, 'claude-opus-4-7': 1000000,
21
+ 'claude-sonnet-5': 1000000, 'claude-sonnet-4-6': 1000000, 'claude-sonnet-4': 200000,
22
+ 'claude-haiku-4-5': 200000, 'gpt-5.4': 128000, 'gpt-5.6-luna': 128000,
23
+ // family keys
24
+ 'claude-opus': 1000000, 'claude-sonnet': 1000000, 'claude-haiku': 200000,
25
+ 'gpt-5.6': 128000, 'gpt-5': 128000,
12
26
  };
27
+
28
+ // Context windows reported by a provider's own models listing, keyed by model id.
29
+ // Anthropic's GET /v1/models returns `max_input_tokens` per model, which is the
30
+ // authoritative context window; OpenAI's listing carries no equivalent field, so
31
+ // OpenAI models resolve from the table above. Populated by
32
+ // registerModelContextWindows() when a client fetches its model list.
33
+ var apiReportedContextWindows: Record<string, number> = {};
34
+
35
+ /**
36
+ * Record context windows from a provider models listing. Accepts the raw list
37
+ * items and reads `max_input_tokens` (Anthropic); items without it are skipped,
38
+ * so passing an OpenAI listing is a no-op rather than an error.
39
+ */
40
+ export function registerModelContextWindows(models: Array<{ id?: string; max_input_tokens?: number }> | null | undefined): void {
41
+ if (!Array.isArray(models)) return;
42
+ for (var i = 0; i < models.length; i++) {
43
+ var m = models[i];
44
+ var id = (m && m.id ? String(m.id) : '').trim().toLowerCase();
45
+ var reported = m ? Number(m.max_input_tokens) : NaN;
46
+ if (id && Number.isFinite(reported) && reported > 0) {
47
+ apiReportedContextWindows[id] = Math.floor(reported);
48
+ }
49
+ }
50
+ }
51
+
52
+ /** Per-project override, keyed by service id. Set from the project settings. */
53
+ var projectContextWindows: Record<string, number> = {};
54
+
55
+ export function setProjectContextWindow(serviceId: string, tokens: number | null | undefined): void {
56
+ var key = (serviceId || '').trim();
57
+ if (!key) return;
58
+ var n = Number(tokens);
59
+ if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
60
+ else delete projectContextWindows[key];
61
+ }
62
+
63
+ export function getProjectContextWindow(serviceId: string): number | null {
64
+ var key = (serviceId || '').trim();
65
+ return key && projectContextWindows[key] ? projectContextWindows[key] : null;
66
+ }
13
67
  export var OUTPUT_TOKEN_RESERVE = 22000;
14
68
  export var TOOL_AND_RESPONSE_BUFFER = 4000;
15
69
  export var MIN_INPUT_TOKEN_BUDGET = 8000;
16
70
  export var CLAUDE_PER_REQUEST_INPUT_CAP = 28000;
17
71
  export var MAX_HISTORY_MESSAGES = 20;
18
72
  export var HISTORY_TOKEN_BUDGET = 8000;
73
+ // Ratios that scale the two ceilings above off the resolved context window.
74
+ // Calibrated to reproduce the previous fixed values at the default windows:
75
+ // claude 200000 -> cap 27840 (was 28000, so the 28000 floor holds), and
76
+ // openai 128000 -> history 8160 (~the previous 8000). Both are floored at the
77
+ // old constants, so these can only ever raise a budget, never lower one.
78
+ export var CLAUDE_INPUT_CAP_RATIO = 0.16;
79
+ export var HISTORY_BUDGET_RATIO = 0.08;
19
80
 
20
81
  export function estimateTextTokens(text: string): number {
21
82
  return Math.ceil((text || '').length / 3);
@@ -25,9 +86,34 @@ export function estimateMessageTokens(msg: { role: string; content: string }): n
25
86
  return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
26
87
  }
27
88
 
28
- export function getContextWindow(platform: string, model?: string): number {
89
+ /**
90
+ * Resolve a model's context window, most specific source first:
91
+ * 1. per-project override (project settings)
92
+ * 2. the provider's own models listing (Anthropic `max_input_tokens`)
93
+ * 3. an exact entry in CONTEXT_WINDOW_BY_MODEL
94
+ * 4. a family entry, by dropping trailing '-' segments off the id
95
+ * 5. the platform default
96
+ *
97
+ * Step 4 is why a new or suffixed id no longer drops straight to the platform
98
+ * default: 'gpt-5.6-luna' resolves via 'gpt-5.6', and a dated Claude snapshot
99
+ * such as 'claude-opus-4-7-20260101' resolves via 'claude-opus-4-7'. The walk
100
+ * stops at the first hit, so a more specific entry always wins over its family.
101
+ */
102
+ export function getContextWindow(platform: string, model?: string, serviceId?: string): number {
103
+ var override = serviceId ? getProjectContextWindow(serviceId) : null;
104
+ if (override) return override;
105
+
29
106
  var normalized = (model || '').trim().toLowerCase();
30
- if (normalized && CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
107
+ if (normalized) {
108
+ if (apiReportedContextWindows[normalized]) return apiReportedContextWindows[normalized];
109
+ if (CONTEXT_WINDOW_BY_MODEL[normalized]) return CONTEXT_WINDOW_BY_MODEL[normalized];
110
+
111
+ var parts = normalized.split('-');
112
+ for (var end = parts.length - 1; end > 0; end--) {
113
+ var family = parts.slice(0, end).join('-');
114
+ if (CONTEXT_WINDOW_BY_MODEL[family]) return CONTEXT_WINDOW_BY_MODEL[family];
115
+ }
116
+ }
31
117
  return CONTEXT_WINDOW_DEFAULT[platform];
32
118
  }
33
119
 
@@ -46,14 +132,34 @@ export type BoundedChatOptions = {
46
132
  };
47
133
 
48
134
  export function buildBoundedChatMessages(options: BoundedChatOptions) {
49
- var contextWindow = getContextWindow(options.platform, options.model);
135
+ var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
50
136
  var contextBasedBudget = Math.max(MIN_INPUT_TOKEN_BUDGET,
51
137
  contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER);
138
+ // Scaling is gated on an EXPLICIT per-project window set from project
139
+ // settings. Without one, the fixed ceilings apply exactly as before, so every
140
+ // existing project keeps byte-identical behavior and nobody's token spend
141
+ // moves because a table value was corrected. With one, the Claude per-request
142
+ // cap and the history budget scale off the window, so raising it genuinely
143
+ // sends more history instead of being absorbed by a hardcoded ceiling.
144
+ // Both derive from contextBasedBudget (pre-Claude-cap) so the two platforms
145
+ // scale symmetrically rather than the Claude cap compounding the ratio down.
146
+ var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
147
+ var claudeInputCap = scaled
148
+ ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO))
149
+ : CLAUDE_PER_REQUEST_INPUT_CAP;
52
150
  var availableInputBudget = options.platform === 'claude'
53
- ? Math.min(contextBasedBudget, CLAUDE_PER_REQUEST_INPUT_CAP) : contextBasedBudget;
151
+ ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
54
152
  var systemCost = estimateTextTokens(options.systemPrompt) + 12;
55
- var budgetForHistory = Math.max(1000, Math.min(HISTORY_TOKEN_BUDGET, availableInputBudget - systemCost));
56
- var windowed = options.history.slice(-MAX_HISTORY_MESSAGES);
153
+ var historyAllowance = scaled
154
+ ? Math.max(HISTORY_TOKEN_BUDGET, Math.round(contextBasedBudget * HISTORY_BUDGET_RATIO))
155
+ : HISTORY_TOKEN_BUDGET;
156
+ var budgetForHistory = Math.max(1000, Math.min(historyAllowance, availableInputBudget - systemCost));
157
+ // The message count scales alongside the token budget; otherwise 20 messages
158
+ // is a second ceiling that swallows the extra budget on a raised window.
159
+ var maxHistoryMessages = scaled
160
+ ? Math.max(MAX_HISTORY_MESSAGES, Math.round(MAX_HISTORY_MESSAGES * (budgetForHistory / HISTORY_TOKEN_BUDGET)))
161
+ : MAX_HISTORY_MESSAGES;
162
+ var windowed = options.history.slice(-maxHistoryMessages);
57
163
  var latestIndex = windowed.length - 1;
58
164
  var trimmed = windowed.map(function (m, i) {
59
165
  if (i === latestIndex) return m;
@@ -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
 
@@ -40,6 +40,46 @@ export function extractLastUserTextFromRequest(requestBody: any): string {
40
40
  return '';
41
41
  }
42
42
 
43
+ /** The two openings an indexing prompt can have. A bg-queue item that starts with
44
+ * neither is an ordinary chat that happened to be routed onto that queue. */
45
+ export function isIndexingRequestText(userText: any): boolean {
46
+ if (typeof userText !== 'string') return false;
47
+ return userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0;
48
+ }
49
+
50
+ export type IndexingRequestRef = {
51
+ name: string;
52
+ path?: string;
53
+ mime?: string;
54
+ size?: number;
55
+ /** A CONTINUE pass rather than the run's first. */
56
+ continued: boolean;
57
+ };
58
+
59
+ /**
60
+ * The file an indexing prompt is about, read back out of the prompt itself.
61
+ *
62
+ * The prompt is the only description of the pass that survives on the server, so
63
+ * this is how BOTH a history rebuild and a worker-minted pass the client never
64
+ * dispatched (ChatSession._adoptWorkerIndexingPasses) recover the file. Shared so
65
+ * the two produce the same `_indexFile`, which is what makes them group together.
66
+ */
67
+ export function parseIndexingRequestText(userText: any): IndexingRequestRef | null {
68
+ if (typeof userText !== 'string' || !userText) return null;
69
+ var nameMatch = userText.match(/^- name: (.+)$/m);
70
+ if (!nameMatch) return null;
71
+ var mimeMatch = userText.match(/^- mime type: (.+)$/m);
72
+ var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
73
+ var pathMatch = userText.match(/^- storage path: (.+)$/m);
74
+ return {
75
+ name: nameMatch[1].trim(),
76
+ path: pathMatch ? pathMatch[1].trim() : undefined,
77
+ mime: mimeMatch ? mimeMatch[1].trim() : undefined,
78
+ size: sizeMatch ? Number(sizeMatch[1]) : undefined,
79
+ continued: userText.indexOf('CONTINUE indexing') === 0,
80
+ };
81
+ }
82
+
43
83
  export type MapHistoryOptions = {
44
84
  clearedAt: number;
45
85
  serviceId: string;
@@ -78,31 +118,21 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
78
118
  // alongside the formatted label so grouping never has to parse it back.
79
119
  var indexFile: any = undefined;
80
120
  if (item._isBgTask) {
81
- var nameMatch = userText.match(/^- name: (.+)$/m);
82
- if (nameMatch) {
83
- var mimeMatch = userText.match(/^- mime type: (.+)$/m);
84
- var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
85
- var pathMatch = userText.match(/^- storage path: (.+)$/m);
121
+ var ref = parseIndexingRequestText(userText);
122
+ if (ref) {
86
123
  // A CONTINUE pass ("CONTINUE indexing …") gets the compact
87
124
  // continuation label; a first pass ("A new file …") gets the full
88
125
  // one. Mirrors agent.vue's mapHistoryListToMessages so a big file's
89
126
  // windows read as progress, not the same task repeating.
90
- var isContinuePass = userText.indexOf('CONTINUE indexing') === 0;
91
127
  displayContent = opts.formatIndexingLabel(
92
- nameMatch[1].trim(),
93
- mimeMatch ? mimeMatch[1].trim() : '',
94
- sizeMatch ? Number(sizeMatch[1]) : null,
95
- pathMatch ? pathMatch[1].trim() : undefined,
128
+ ref.name,
129
+ ref.mime || '',
130
+ typeof ref.size === 'number' ? ref.size : null,
131
+ ref.path,
96
132
  false,
97
- isContinuePass
133
+ ref.continued
98
134
  );
99
- indexFile = {
100
- name: nameMatch[1].trim(),
101
- path: pathMatch ? pathMatch[1].trim() : undefined,
102
- mime: mimeMatch ? mimeMatch[1].trim() : undefined,
103
- size: sizeMatch ? Number(sizeMatch[1]) : undefined,
104
- continued: isContinuePass,
105
- };
135
+ indexFile = ref;
106
136
  } else {
107
137
  displayContent = userText;
108
138
  }