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.
Files changed (2) hide show
  1. package/bunnyquery.js +275 -9
  2. 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
- bubble.textContent = msg.content || '';
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
- const res = await getChatHistory(
1095
- this.skapi,
1096
- { service: this.serviceId, owner: this.ownerId, platform: this.platform },
1097
- { ascending: false }
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 result = this.platform === 'claude'
1378
- ? await buildClaudeRequest(this.skapi, args)
1379
- : await buildOpenAIRequest(this.skapi, args);
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];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "BunnyQuery AI chat client",
5
5
  "main": "bunnyquery.js",
6
6
  "files": [