bunnyquery 1.0.6 → 1.0.8
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 +275 -9
- package/package.json +1 -1
package/bunnyquery.js
CHANGED
|
@@ -66,6 +66,165 @@
|
|
|
66
66
|
// init()) is reflected in subsequent send/auth calls.
|
|
67
67
|
const _mcpBaseUrl = () => (global.BunnyQuery && global.BunnyQuery.MCP_BASE_URL) || DEFAULTS.MCP_BASE_URL;
|
|
68
68
|
|
|
69
|
+
// ----- file-fence helpers (mirrors agent.vue) ---------------------------
|
|
70
|
+
// Bare language tags we treat as downloadable files. Maps to the
|
|
71
|
+
// extension used in the synthesized filename. Mirrors the file types the
|
|
72
|
+
// host agent (agent.vue) is known to emit as ```filename.ext fences.
|
|
73
|
+
const FENCE_LANGUAGE_EXTENSIONS = {
|
|
74
|
+
html: 'html',
|
|
75
|
+
htm: 'html',
|
|
76
|
+
csv: 'csv',
|
|
77
|
+
tsv: 'tsv',
|
|
78
|
+
json: 'json',
|
|
79
|
+
xml: 'xml',
|
|
80
|
+
svg: 'svg',
|
|
81
|
+
md: 'md',
|
|
82
|
+
markdown: 'md',
|
|
83
|
+
yaml: 'yaml',
|
|
84
|
+
yml: 'yml',
|
|
85
|
+
txt: 'txt',
|
|
86
|
+
sql: 'sql',
|
|
87
|
+
css: 'css',
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const FENCE_MIME_TYPES = {
|
|
91
|
+
html: 'text/html',
|
|
92
|
+
csv: 'text/csv',
|
|
93
|
+
tsv: 'text/tab-separated-values',
|
|
94
|
+
json: 'application/json',
|
|
95
|
+
xml: 'application/xml',
|
|
96
|
+
svg: 'image/svg+xml',
|
|
97
|
+
md: 'text/markdown',
|
|
98
|
+
yaml: 'text/yaml',
|
|
99
|
+
yml: 'text/yaml',
|
|
100
|
+
txt: 'text/plain',
|
|
101
|
+
sql: 'application/sql',
|
|
102
|
+
css: 'text/css',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Closed fences with `filename.ext` after the opening backticks.
|
|
106
|
+
const FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n([\s\S]*?)```/g;
|
|
107
|
+
// Closed fences whose info-string is one of the known language tags
|
|
108
|
+
// above. The model often emits ```html ... ``` or ```csv ... ``` instead
|
|
109
|
+
// of a full filename — surface those as downloadable files too.
|
|
110
|
+
const FENCE_LANG_RE = new RegExp(
|
|
111
|
+
'```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n([\\s\\S]*?)```',
|
|
112
|
+
'gi'
|
|
113
|
+
);
|
|
114
|
+
// Open (still-streaming) fences for either pattern, used to suppress the
|
|
115
|
+
// raw fence text mid-typewriter.
|
|
116
|
+
const OPEN_FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n?/;
|
|
117
|
+
const OPEN_FENCE_LANG_RE = new RegExp(
|
|
118
|
+
'```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n?',
|
|
119
|
+
'i'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Cache blob URLs keyed by (filename + content) so re-renders don't leak
|
|
123
|
+
// a fresh ObjectURL on every tick of streaming text.
|
|
124
|
+
const _fileBlobCache = new Map();
|
|
125
|
+
const _getOrCreateFileHref = (filename, body) => {
|
|
126
|
+
const key = filename + '\u0000' + body;
|
|
127
|
+
const existing = _fileBlobCache.get(key);
|
|
128
|
+
if (existing) return existing;
|
|
129
|
+
const ext = (filename.split('.').pop() || '').toLowerCase();
|
|
130
|
+
const type = FENCE_MIME_TYPES[ext] || 'text/plain';
|
|
131
|
+
const blob = new Blob([body], { type });
|
|
132
|
+
const href = URL.createObjectURL(blob);
|
|
133
|
+
_fileBlobCache.set(key, href);
|
|
134
|
+
return href;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Counter used to mint stable filenames for bare-language fences within
|
|
138
|
+
// a single rendered message ("download.html", "download-2.html", ...).
|
|
139
|
+
// Keyed by message identity so re-renders of the same message reuse the
|
|
140
|
+
// same names (which lets the blob cache hit).
|
|
141
|
+
const _bareFenceNameCounters = new WeakMap();
|
|
142
|
+
const _bareFilenameFor = (msgKey, ext, occurrence) => {
|
|
143
|
+
const base = 'download';
|
|
144
|
+
return occurrence === 0 ? `${base}.${ext}` : `${base}-${occurrence + 1}.${ext}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Split message content into a sequence of {type:'text'|'file'} parts,
|
|
148
|
+
// mirroring agent.vue's parseMsgParts behavior for fenced code blocks.
|
|
149
|
+
// Bare-language fences (```html, ```csv, ...) are also lifted into
|
|
150
|
+
// downloadable file parts with a synthesized filename.
|
|
151
|
+
const parseMsgParts = (content, msgKey, isTyping) => {
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (!content) return parts;
|
|
154
|
+
|
|
155
|
+
// Combine matches from both fence regexes, sorted by position.
|
|
156
|
+
const matches = [];
|
|
157
|
+
FENCE_FILENAME_RE.lastIndex = 0;
|
|
158
|
+
let mm;
|
|
159
|
+
while ((mm = FENCE_FILENAME_RE.exec(content)) !== null) {
|
|
160
|
+
matches.push({ index: mm.index, length: mm[0].length, filename: mm[1], body: mm[2], kind: 'filename' });
|
|
161
|
+
}
|
|
162
|
+
FENCE_LANG_RE.lastIndex = 0;
|
|
163
|
+
let bareCounters = _bareFenceNameCounters.get(msgKey);
|
|
164
|
+
if (!bareCounters) {
|
|
165
|
+
bareCounters = {};
|
|
166
|
+
if (msgKey) _bareFenceNameCounters.set(msgKey, bareCounters);
|
|
167
|
+
}
|
|
168
|
+
const bareSeen = {};
|
|
169
|
+
while ((mm = FENCE_LANG_RE.exec(content)) !== null) {
|
|
170
|
+
const lang = mm[1].toLowerCase();
|
|
171
|
+
const ext = FENCE_LANGUAGE_EXTENSIONS[lang];
|
|
172
|
+
const occurrence = bareSeen[ext] || 0;
|
|
173
|
+
bareSeen[ext] = occurrence + 1;
|
|
174
|
+
matches.push({
|
|
175
|
+
index: mm.index,
|
|
176
|
+
length: mm[0].length,
|
|
177
|
+
filename: _bareFilenameFor(msgKey, ext, occurrence),
|
|
178
|
+
body: mm[2],
|
|
179
|
+
kind: 'lang',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// If both regexes matched the same span (a `filename.ext` fence is
|
|
183
|
+
// also a bare-language fence when the language coincides), prefer
|
|
184
|
+
// the filename match.
|
|
185
|
+
matches.sort((a, b) => a.index - b.index || (a.kind === 'filename' ? -1 : 1));
|
|
186
|
+
const dedup = [];
|
|
187
|
+
for (const m of matches) {
|
|
188
|
+
if (dedup.length && dedup[dedup.length - 1].index === m.index) continue;
|
|
189
|
+
dedup.push(m);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let last = 0;
|
|
193
|
+
for (const m of dedup) {
|
|
194
|
+
if (m.index > last) {
|
|
195
|
+
parts.push({ type: 'text', text: content.slice(last, m.index) });
|
|
196
|
+
}
|
|
197
|
+
parts.push({
|
|
198
|
+
type: 'file',
|
|
199
|
+
filename: m.filename,
|
|
200
|
+
href: _getOrCreateFileHref(m.filename, m.body),
|
|
201
|
+
});
|
|
202
|
+
last = m.index + m.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let tail = content.slice(last);
|
|
206
|
+
|
|
207
|
+
// Mid-typewriter: hide raw source for a still-open fence so the user
|
|
208
|
+
// doesn't watch literal code stream by character-by-character.
|
|
209
|
+
if (isTyping && tail) {
|
|
210
|
+
const fnOpen = tail.match(OPEN_FENCE_FILENAME_RE);
|
|
211
|
+
const langOpen = tail.match(OPEN_FENCE_LANG_RE);
|
|
212
|
+
const open = fnOpen && (!langOpen || fnOpen.index <= langOpen.index)
|
|
213
|
+
? { match: fnOpen, label: fnOpen[1] }
|
|
214
|
+
: langOpen
|
|
215
|
+
? { match: langOpen, label: 'download.' + FENCE_LANGUAGE_EXTENSIONS[langOpen[1].toLowerCase()] }
|
|
216
|
+
: null;
|
|
217
|
+
if (open) {
|
|
218
|
+
const before = tail.slice(0, open.match.index);
|
|
219
|
+
if (before) parts.push({ type: 'text', text: before });
|
|
220
|
+
parts.push({ type: 'text', text: `\n[generating ${open.label}...]` });
|
|
221
|
+
tail = '';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (tail) parts.push({ type: 'text', text: tail });
|
|
225
|
+
return parts;
|
|
226
|
+
};
|
|
227
|
+
|
|
69
228
|
// ----- helpers ----------------------------------------------------------
|
|
70
229
|
const $ = (tag, attrs, children) => {
|
|
71
230
|
const el = document.createElement(tag);
|
|
@@ -492,6 +651,50 @@
|
|
|
492
651
|
return 'Request failed. Please try again.';
|
|
493
652
|
}
|
|
494
653
|
|
|
654
|
+
// Detect MCP / upstream "your access token is no longer valid" failures
|
|
655
|
+
// so we can transparently re-mint the MCP token from the live skapi
|
|
656
|
+
// session and retry the call once. Patterns observed in the wild:
|
|
657
|
+
// - skapi-js inside the MCP server throws "Token has expired"
|
|
658
|
+
// (surfaces here as part of err.message or response body text)
|
|
659
|
+
// - MCP returns OAuth-style error codes: invalid_token / expired_token
|
|
660
|
+
// - Proxy wraps it with INVALID_REQUEST + 401
|
|
661
|
+
function _isAuthExpiredError(input) {
|
|
662
|
+
if (!input) return false;
|
|
663
|
+
const blobs = [];
|
|
664
|
+
const push = (v) => { if (typeof v === 'string' && v) blobs.push(v); };
|
|
665
|
+
if (typeof input === 'string') push(input);
|
|
666
|
+
else {
|
|
667
|
+
push(input.message);
|
|
668
|
+
push(input.code);
|
|
669
|
+
if (input.error) {
|
|
670
|
+
push(input.error.message);
|
|
671
|
+
push(input.error.code);
|
|
672
|
+
push(input.error.type);
|
|
673
|
+
}
|
|
674
|
+
if (input.body) {
|
|
675
|
+
push(input.body.message);
|
|
676
|
+
if (input.body.error) {
|
|
677
|
+
push(input.body.error.message);
|
|
678
|
+
push(input.body.error.code);
|
|
679
|
+
push(input.body.error.type);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (typeof input.status === 'number' && input.status === 401) return true;
|
|
683
|
+
if (typeof input.status_code === 'number' && input.status_code === 401) return true;
|
|
684
|
+
}
|
|
685
|
+
const hay = blobs.join(' | ').toLowerCase();
|
|
686
|
+
if (!hay) return false;
|
|
687
|
+
return (
|
|
688
|
+
hay.includes('token has expired') ||
|
|
689
|
+
hay.includes('token is expired') ||
|
|
690
|
+
hay.includes('expired_token') ||
|
|
691
|
+
hay.includes('invalid_token') ||
|
|
692
|
+
hay.includes('unauthorized') ||
|
|
693
|
+
hay.includes('not authorized') ||
|
|
694
|
+
(hay.includes('invalid_request') && hay.includes('token'))
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
495
698
|
// Mirror of agent.vue's isErrorResponseBody — detects provider/proxy
|
|
496
699
|
// error payloads that should render as a red error bubble even when
|
|
497
700
|
// no exception was thrown.
|
|
@@ -872,7 +1075,29 @@
|
|
|
872
1075
|
if (msg.isPending) {
|
|
873
1076
|
bubble.appendChild($('span', { class: 'bq-loader' }, 'Thinking'));
|
|
874
1077
|
} else {
|
|
875
|
-
|
|
1078
|
+
// Render fenced file blocks (```filename.ext``` or
|
|
1079
|
+
// ```html / ```csv / etc.) as inline download links;
|
|
1080
|
+
// everything else stays plain text. Mirrors the
|
|
1081
|
+
// behavior of agent.vue's parseMsgParts.
|
|
1082
|
+
const parts = parseMsgParts(msg.content || '', msg, !!this.typing);
|
|
1083
|
+
if (!parts.length) {
|
|
1084
|
+
bubble.textContent = msg.content || '';
|
|
1085
|
+
} else {
|
|
1086
|
+
for (const part of parts) {
|
|
1087
|
+
if (part.type === 'file') {
|
|
1088
|
+
const a = $('a', {
|
|
1089
|
+
class: 'bq-file-download',
|
|
1090
|
+
href: part.href,
|
|
1091
|
+
target: '_blank',
|
|
1092
|
+
rel: 'noopener noreferrer',
|
|
1093
|
+
download: part.filename,
|
|
1094
|
+
}, '\u2197 ' + part.filename);
|
|
1095
|
+
bubble.appendChild(a);
|
|
1096
|
+
} else {
|
|
1097
|
+
bubble.appendChild(document.createTextNode(part.text));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
876
1101
|
}
|
|
877
1102
|
const wrapper = $('div', {
|
|
878
1103
|
class:
|
|
@@ -1090,12 +1315,24 @@
|
|
|
1090
1315
|
|
|
1091
1316
|
// ---- history ------------------------------------------------------
|
|
1092
1317
|
async _loadFirstHistoryPage() {
|
|
1318
|
+
const fetchPage = () => getChatHistory(
|
|
1319
|
+
this.skapi,
|
|
1320
|
+
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1321
|
+
{ ascending: false }
|
|
1322
|
+
);
|
|
1093
1323
|
try {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1324
|
+
let res;
|
|
1325
|
+
try {
|
|
1326
|
+
res = await fetchPage();
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
if (_isAuthExpiredError(err)) {
|
|
1329
|
+
this.oauth.clearToken();
|
|
1330
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
1331
|
+
res = await fetchPage();
|
|
1332
|
+
} else {
|
|
1333
|
+
throw err;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1099
1336
|
this.startKeyHistory = res && res.startKeyHistory;
|
|
1100
1337
|
const list = this._filterByClearHorizon((res && res.list) || []);
|
|
1101
1338
|
// If the clear horizon has filtered the entire first page,
|
|
@@ -1374,13 +1611,42 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
|
|
|
1374
1611
|
model: this.model || undefined,
|
|
1375
1612
|
};
|
|
1376
1613
|
|
|
1377
|
-
const
|
|
1378
|
-
?
|
|
1379
|
-
:
|
|
1614
|
+
const argsBuilder = () => (this.platform === 'claude'
|
|
1615
|
+
? buildClaudeRequest(this.skapi, args)
|
|
1616
|
+
: buildOpenAIRequest(this.skapi, args));
|
|
1617
|
+
|
|
1618
|
+
let result;
|
|
1619
|
+
try {
|
|
1620
|
+
result = await argsBuilder();
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
if (_isAuthExpiredError(err)) {
|
|
1623
|
+
// The MCP-side skapi session expired. Re-mint a fresh
|
|
1624
|
+
// MCP token bundle from the live host skapi session
|
|
1625
|
+
// (skapi-js auto-refreshes its own tokens), then retry
|
|
1626
|
+
// the call once.
|
|
1627
|
+
try {
|
|
1628
|
+
this.oauth.clearToken();
|
|
1629
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
1630
|
+
} catch (reauthErr) {
|
|
1631
|
+
throw reauthErr;
|
|
1632
|
+
}
|
|
1633
|
+
result = await argsBuilder();
|
|
1634
|
+
} else {
|
|
1635
|
+
throw err;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1380
1638
|
|
|
1381
1639
|
// The proxy may resolve with an error-shaped body instead of
|
|
1382
1640
|
// throwing; surface that as an error bubble with the
|
|
1383
1641
|
// upstream message rather than dropping it.
|
|
1642
|
+
if (isErrorResponseBody(result) && _isAuthExpiredError(result)) {
|
|
1643
|
+
try {
|
|
1644
|
+
this.oauth.clearToken();
|
|
1645
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
1646
|
+
result = await argsBuilder();
|
|
1647
|
+
} catch (_reauthErr) { /* fall through to normal error rendering */ }
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1384
1650
|
if (isErrorResponseBody(result)) {
|
|
1385
1651
|
const errMsg = getErrorMessage(result);
|
|
1386
1652
|
const last = this.messages[this.messages.length - 1];
|