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/bunnyquery.js +455 -43
- package/dist/engine.cjs +489 -33
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +232 -3
- package/dist/engine.d.ts +232 -3
- package/dist/engine.mjs +462 -34
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/ai_agent.ts +80 -0
- package/src/engine/budget.ts +113 -7
- package/src/engine/download_encoding.ts +281 -0
- package/src/engine/errors.ts +34 -0
- package/src/engine/history.ts +48 -18
- package/src/engine/index.ts +11 -0
- package/src/engine/requests.ts +15 -3
- package/src/engine/session.ts +227 -6
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/engine/budget.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
151
|
+
? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
|
|
54
152
|
var systemCost = estimateTextTokens(options.systemPrompt) + 12;
|
|
55
|
-
var
|
|
56
|
-
|
|
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
|
+
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -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
|
|
package/src/engine/history.ts
CHANGED
|
@@ -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
|
|
82
|
-
if (
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
128
|
+
ref.name,
|
|
129
|
+
ref.mime || '',
|
|
130
|
+
typeof ref.size === 'number' ? ref.size : null,
|
|
131
|
+
ref.path,
|
|
96
132
|
false,
|
|
97
|
-
|
|
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
|
}
|