bunnyquery 1.8.1 → 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 +183 -12
- package/dist/engine.cjs +201 -4
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +98 -2
- package/dist/engine.d.ts +98 -2
- package/dist/engine.mjs +183 -5
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/download_encoding.ts +281 -0
- package/src/engine/errors.ts +34 -0
- package/src/engine/index.ts +3 -0
- package/src/engine/requests.ts +4 -1
- package/src/engine/session.ts +25 -3
package/package.json
CHANGED
|
@@ -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/index.ts
CHANGED
|
@@ -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';
|
package/src/engine/requests.ts
CHANGED
|
@@ -232,7 +232,10 @@ export type CallClaudeWithMcpParams = {
|
|
|
232
232
|
onResponse?: (res: any) => void;
|
|
233
233
|
onError?: (err: any) => void;
|
|
234
234
|
};
|
|
235
|
-
|
|
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,
|
package/src/engine/session.ts
CHANGED
|
@@ -479,7 +479,7 @@ export class ChatSession {
|
|
|
479
479
|
// the pending bubble in this same cache entry when the reply lands.
|
|
480
480
|
var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
|
|
481
481
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
482
|
-
!m.isCancelled && !m.isBackgroundTask;
|
|
482
|
+
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
483
483
|
});
|
|
484
484
|
var offBounded = buildBoundedChatMessages({
|
|
485
485
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
@@ -505,7 +505,7 @@ export class ChatSession {
|
|
|
505
505
|
if (isQueuedSend) {
|
|
506
506
|
var resolvedHistory = this.state.messages.filter(function (m) {
|
|
507
507
|
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
508
|
-
!m.isCancelled && !m.isBackgroundTask;
|
|
508
|
+
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
509
509
|
});
|
|
510
510
|
var boundedQ = buildBoundedChatMessages({
|
|
511
511
|
platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
|
|
@@ -557,7 +557,29 @@ export class ChatSession {
|
|
|
557
557
|
this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
|
|
558
558
|
this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
|
|
559
559
|
|
|
560
|
-
|
|
560
|
+
// Same filter as the offChat and isQueuedSend paths above. It must drop the
|
|
561
|
+
// pending flags too: the `isPending` placeholder pushed two lines up is the
|
|
562
|
+
// in-flight "Thinking..." bubble, and leaving it in sent a trailing
|
|
563
|
+
// `{role:'assistant', content:''}` upstream on EVERY immediate send. That is
|
|
564
|
+
// a last-assistant-turn prefill, which the Claude platform rejects outright,
|
|
565
|
+
// and on both platforms it made the history end on an assistant turn, so
|
|
566
|
+
// prepareOpenAIMessages / prepareClaudeMessages bailed at their
|
|
567
|
+
// `last.role !== 'user'` guard and silently stopped converting attached
|
|
568
|
+
// images into image blocks.
|
|
569
|
+
//
|
|
570
|
+
// `isError` is dropped in all three paths for a different reason: those
|
|
571
|
+
// bubbles are written by THIS client, never by the model. Every site that
|
|
572
|
+
// sets `isError: true` fills content from getErrorMessage() or the literal
|
|
573
|
+
// 'Request was cancelled.', so filtering them discards no model-authored
|
|
574
|
+
// text. Keeping them handed the model a turn it never produced ("The AI
|
|
575
|
+
// provider is temporarily unreachable..."), attributed to itself, because the
|
|
576
|
+
// flag is stripped when buildBoundedChatMessages maps down to {role,content}.
|
|
577
|
+
// The cost is that a retry now follows an unanswered question with no stated
|
|
578
|
+
// reason, which is a true account of what happened rather than a false one.
|
|
579
|
+
var historyForLlm = this.state.messages.filter(function (m) {
|
|
580
|
+
return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
|
|
581
|
+
!m.isCancelled && !m.isBackgroundTask && !m.isError;
|
|
582
|
+
});
|
|
561
583
|
if (llmComposed !== composed) {
|
|
562
584
|
for (var li = historyForLlm.length - 1; li >= 0; li--) {
|
|
563
585
|
if (historyForLlm[li].role === 'user' && historyForLlm[li].content === composed) {
|