bunnyquery 1.0.5 → 1.0.7
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 +158 -47
- package/package.json +1 -1
package/bunnyquery.js
CHANGED
|
@@ -34,9 +34,12 @@
|
|
|
34
34
|
|
|
35
35
|
// ---- MCP OAuth constants (mirrors src/code/mcp_oauth.ts) ---------------
|
|
36
36
|
// The embed has no build-time env, so we always point at the production
|
|
37
|
-
// MCP host. Override BunnyQuery.MCP_BASE_URL before init() to swap
|
|
37
|
+
// MCP host. Override BunnyQuery.MCP_BASE_URL before init() to swap, or
|
|
38
|
+
// pass `true` as the third arg to BunnyQuery.init() to target the dev
|
|
39
|
+
// host (mcp-dev.broadwayinc.computer).
|
|
38
40
|
const DEFAULTS = {
|
|
39
41
|
MCP_BASE_URL: 'https://mcp.broadwayinc.computer',
|
|
42
|
+
MCP_DEV_BASE_URL: 'https://mcp-dev.broadwayinc.computer',
|
|
40
43
|
MCP_NAME: 'BunnyQuery',
|
|
41
44
|
DEFAULT_CLAUDE_MODEL: 'claude-sonnet-4-6',
|
|
42
45
|
DEFAULT_OPENAI_MODEL: 'gpt-5.4',
|
|
@@ -58,6 +61,11 @@
|
|
|
58
61
|
const TOKEN_KEY = STORAGE_PREFIX + 'mcp_token';
|
|
59
62
|
const STATE_KEY = STORAGE_PREFIX + 'mcp_state';
|
|
60
63
|
|
|
64
|
+
// Resolve the active MCP base URL each time it's needed so a runtime
|
|
65
|
+
// override of `BunnyQuery.MCP_BASE_URL` (e.g. via the `dev` flag on
|
|
66
|
+
// init()) is reflected in subsequent send/auth calls.
|
|
67
|
+
const _mcpBaseUrl = () => (global.BunnyQuery && global.BunnyQuery.MCP_BASE_URL) || DEFAULTS.MCP_BASE_URL;
|
|
68
|
+
|
|
61
69
|
// ----- helpers ----------------------------------------------------------
|
|
62
70
|
const $ = (tag, attrs, children) => {
|
|
63
71
|
const el = document.createElement(tag);
|
|
@@ -376,7 +384,7 @@
|
|
|
376
384
|
{
|
|
377
385
|
type: 'url',
|
|
378
386
|
name: DEFAULTS.MCP_NAME,
|
|
379
|
-
url:
|
|
387
|
+
url: _mcpBaseUrl(),
|
|
380
388
|
authorization_token: '$ACCESS_TOKEN',
|
|
381
389
|
},
|
|
382
390
|
],
|
|
@@ -422,7 +430,7 @@
|
|
|
422
430
|
{
|
|
423
431
|
type: 'mcp',
|
|
424
432
|
server_label: DEFAULTS.MCP_NAME,
|
|
425
|
-
server_url:
|
|
433
|
+
server_url: _mcpBaseUrl(),
|
|
426
434
|
require_approval: 'never',
|
|
427
435
|
headers: { Authorization: 'Bearer $ACCESS_TOKEN' },
|
|
428
436
|
},
|
|
@@ -484,6 +492,50 @@
|
|
|
484
492
|
return 'Request failed. Please try again.';
|
|
485
493
|
}
|
|
486
494
|
|
|
495
|
+
// Detect MCP / upstream "your access token is no longer valid" failures
|
|
496
|
+
// so we can transparently re-mint the MCP token from the live skapi
|
|
497
|
+
// session and retry the call once. Patterns observed in the wild:
|
|
498
|
+
// - skapi-js inside the MCP server throws "Token has expired"
|
|
499
|
+
// (surfaces here as part of err.message or response body text)
|
|
500
|
+
// - MCP returns OAuth-style error codes: invalid_token / expired_token
|
|
501
|
+
// - Proxy wraps it with INVALID_REQUEST + 401
|
|
502
|
+
function _isAuthExpiredError(input) {
|
|
503
|
+
if (!input) return false;
|
|
504
|
+
const blobs = [];
|
|
505
|
+
const push = (v) => { if (typeof v === 'string' && v) blobs.push(v); };
|
|
506
|
+
if (typeof input === 'string') push(input);
|
|
507
|
+
else {
|
|
508
|
+
push(input.message);
|
|
509
|
+
push(input.code);
|
|
510
|
+
if (input.error) {
|
|
511
|
+
push(input.error.message);
|
|
512
|
+
push(input.error.code);
|
|
513
|
+
push(input.error.type);
|
|
514
|
+
}
|
|
515
|
+
if (input.body) {
|
|
516
|
+
push(input.body.message);
|
|
517
|
+
if (input.body.error) {
|
|
518
|
+
push(input.body.error.message);
|
|
519
|
+
push(input.body.error.code);
|
|
520
|
+
push(input.body.error.type);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (typeof input.status === 'number' && input.status === 401) return true;
|
|
524
|
+
if (typeof input.status_code === 'number' && input.status_code === 401) return true;
|
|
525
|
+
}
|
|
526
|
+
const hay = blobs.join(' | ').toLowerCase();
|
|
527
|
+
if (!hay) return false;
|
|
528
|
+
return (
|
|
529
|
+
hay.includes('token has expired') ||
|
|
530
|
+
hay.includes('token is expired') ||
|
|
531
|
+
hay.includes('expired_token') ||
|
|
532
|
+
hay.includes('invalid_token') ||
|
|
533
|
+
hay.includes('unauthorized') ||
|
|
534
|
+
hay.includes('not authorized') ||
|
|
535
|
+
(hay.includes('invalid_request') && hay.includes('token'))
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
487
539
|
// Mirror of agent.vue's isErrorResponseBody — detects provider/proxy
|
|
488
540
|
// error payloads that should render as a red error bubble even when
|
|
489
541
|
// no exception was thrown.
|
|
@@ -576,7 +628,7 @@
|
|
|
576
628
|
|
|
577
629
|
this.readOnly = !!(info.opt && info.opt.freeze_database);
|
|
578
630
|
|
|
579
|
-
this.oauth = new McpOAuth(
|
|
631
|
+
this.oauth = new McpOAuth(_mcpBaseUrl());
|
|
580
632
|
|
|
581
633
|
this.messages = [];
|
|
582
634
|
this.attachments = [];
|
|
@@ -585,6 +637,7 @@
|
|
|
585
637
|
this.sending = false;
|
|
586
638
|
this.uploading = false;
|
|
587
639
|
this.pendingTimer = null;
|
|
640
|
+
this._pollingPending = false;
|
|
588
641
|
this._loadingOlder = false;
|
|
589
642
|
|
|
590
643
|
this.refs = {};
|
|
@@ -593,11 +646,17 @@
|
|
|
593
646
|
this._bootstrap();
|
|
594
647
|
}
|
|
595
648
|
|
|
596
|
-
static async init(skapi, elementId) {
|
|
649
|
+
static async init(skapi, elementId, dev) {
|
|
597
650
|
const container = typeof elementId === 'string'
|
|
598
651
|
? document.getElementById(elementId)
|
|
599
652
|
: elementId;
|
|
600
653
|
if (!container) throw new Error(`BunnyQuery: container "${elementId}" not found`);
|
|
654
|
+
// When `dev` is truthy, route every MCP call (Claude tool URL,
|
|
655
|
+
// OpenAI tool URL, and OAuth) to the dev host. Setting the
|
|
656
|
+
// static here makes the change visible to any subsequent
|
|
657
|
+
// BunnyQuery instance on the page; pass `false` (or omit) to
|
|
658
|
+
// pin back to production.
|
|
659
|
+
BunnyQuery.MCP_BASE_URL = dev ? DEFAULTS.MCP_DEV_BASE_URL : DEFAULTS.MCP_BASE_URL;
|
|
601
660
|
const info = await skapi.__connection;
|
|
602
661
|
console.log('[BunnyQuery] initializing with info', info);
|
|
603
662
|
return new BunnyQuery(skapi, info, container);
|
|
@@ -1075,12 +1134,24 @@
|
|
|
1075
1134
|
|
|
1076
1135
|
// ---- history ------------------------------------------------------
|
|
1077
1136
|
async _loadFirstHistoryPage() {
|
|
1137
|
+
const fetchPage = () => getChatHistory(
|
|
1138
|
+
this.skapi,
|
|
1139
|
+
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1140
|
+
{ ascending: false }
|
|
1141
|
+
);
|
|
1078
1142
|
try {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1143
|
+
let res;
|
|
1144
|
+
try {
|
|
1145
|
+
res = await fetchPage();
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
if (_isAuthExpiredError(err)) {
|
|
1148
|
+
this.oauth.clearToken();
|
|
1149
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
1150
|
+
res = await fetchPage();
|
|
1151
|
+
} else {
|
|
1152
|
+
throw err;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1084
1155
|
this.startKeyHistory = res && res.startKeyHistory;
|
|
1085
1156
|
const list = this._filterByClearHorizon((res && res.list) || []);
|
|
1086
1157
|
// If the clear horizon has filtered the entire first page,
|
|
@@ -1124,46 +1195,57 @@
|
|
|
1124
1195
|
}
|
|
1125
1196
|
}
|
|
1126
1197
|
|
|
1198
|
+
// Polling state machine. Invariant: at most ONE of the following
|
|
1199
|
+
// is true at any moment — (a) `pendingTimer` is armed, or (b)
|
|
1200
|
+
// `_pollingPending` is true (fetch in flight). Any call to
|
|
1201
|
+
// `_schedulePendingPoll` while either is true is a no-op; the
|
|
1202
|
+
// in-flight cycle's `finally` will continue the loop.
|
|
1203
|
+
//
|
|
1204
|
+
// This replaces an earlier clear-and-rearm pattern that, under
|
|
1205
|
+
// overlapping callers (`_sendMessage`, `_loadFirstHistoryPage`,
|
|
1206
|
+
// and the poll's own `finally`), could leave two timers
|
|
1207
|
+
// concurrently armed. Each tick's `finally` then scheduled
|
|
1208
|
+
// another, and the effective polling rate kept compounding —
|
|
1209
|
+
// the chat looked like it was polling faster and faster.
|
|
1127
1210
|
_schedulePendingPoll() {
|
|
1128
|
-
if (this.pendingTimer)
|
|
1129
|
-
if (!this._anyPending()) return;
|
|
1130
|
-
// Bail if a poll is already in flight — its `finally` will
|
|
1131
|
-
// schedule the next one. Without this guard, any caller that
|
|
1132
|
-
// fires while we're awaiting `getChatHistory` (e.g. another
|
|
1133
|
-
// _sendMessage, _loadFirstHistoryPage, or our own previous
|
|
1134
|
-
// tick whose timer already elapsed) would spawn a second
|
|
1135
|
-
// concurrent poller, and each subsequent cycle would compound
|
|
1136
|
-
// — making the polling appear faster and faster.
|
|
1211
|
+
if (this.pendingTimer != null) return;
|
|
1137
1212
|
if (this._pollingPending) return;
|
|
1138
|
-
this.
|
|
1213
|
+
if (!this._anyPending()) return;
|
|
1214
|
+
this.pendingTimer = setTimeout(() => {
|
|
1139
1215
|
this.pendingTimer = null;
|
|
1140
|
-
this.
|
|
1141
|
-
try {
|
|
1142
|
-
const res = await getChatHistory(
|
|
1143
|
-
this.skapi,
|
|
1144
|
-
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1145
|
-
{ ascending: false }
|
|
1146
|
-
);
|
|
1147
|
-
const fresh = mapHistoryToMessages(
|
|
1148
|
-
this._filterByClearHorizon((res && res.list) || []),
|
|
1149
|
-
this.platform
|
|
1150
|
-
);
|
|
1151
|
-
// Keep older messages whose ids are not on the freshly
|
|
1152
|
-
// fetched first page. The fresh page is appended last so
|
|
1153
|
-
// it always reflects the latest server state.
|
|
1154
|
-
const freshIds = new Set(fresh.filter((m) => m.id).map((m) => m.id));
|
|
1155
|
-
const older = this.messages.filter((m) => m.id && !freshIds.has(m.id));
|
|
1156
|
-
this.messages = older.concat(fresh);
|
|
1157
|
-
this._renderMessages();
|
|
1158
|
-
} catch (err) {
|
|
1159
|
-
console.error('[BunnyQuery] pending poll failed', err);
|
|
1160
|
-
} finally {
|
|
1161
|
-
this._pollingPending = false;
|
|
1162
|
-
this._schedulePendingPoll();
|
|
1163
|
-
}
|
|
1216
|
+
this._runPendingPoll();
|
|
1164
1217
|
}, DEFAULTS.PENDING_POLL_INTERVAL_MS);
|
|
1165
1218
|
}
|
|
1166
1219
|
|
|
1220
|
+
async _runPendingPoll() {
|
|
1221
|
+
// Defensive: if somehow re-entered, drop the duplicate.
|
|
1222
|
+
if (this._pollingPending) return;
|
|
1223
|
+
this._pollingPending = true;
|
|
1224
|
+
try {
|
|
1225
|
+
const res = await getChatHistory(
|
|
1226
|
+
this.skapi,
|
|
1227
|
+
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1228
|
+
{ ascending: false }
|
|
1229
|
+
);
|
|
1230
|
+
const fresh = mapHistoryToMessages(
|
|
1231
|
+
this._filterByClearHorizon((res && res.list) || []),
|
|
1232
|
+
this.platform
|
|
1233
|
+
);
|
|
1234
|
+
// Keep older messages whose ids are not on the freshly
|
|
1235
|
+
// fetched first page. The fresh page is appended last so
|
|
1236
|
+
// it always reflects the latest server state.
|
|
1237
|
+
const freshIds = new Set(fresh.filter((m) => m.id).map((m) => m.id));
|
|
1238
|
+
const older = this.messages.filter((m) => m.id && !freshIds.has(m.id));
|
|
1239
|
+
this.messages = older.concat(fresh);
|
|
1240
|
+
this._renderMessages();
|
|
1241
|
+
} catch (err) {
|
|
1242
|
+
console.error('[BunnyQuery] pending poll failed', err);
|
|
1243
|
+
} finally {
|
|
1244
|
+
this._pollingPending = false;
|
|
1245
|
+
this._schedulePendingPoll();
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1167
1249
|
// ---- attachments --------------------------------------------------
|
|
1168
1250
|
_onAttachFromInput(e) {
|
|
1169
1251
|
const files = Array.from(e.target.files || []);
|
|
@@ -1348,13 +1430,42 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
|
|
|
1348
1430
|
model: this.model || undefined,
|
|
1349
1431
|
};
|
|
1350
1432
|
|
|
1351
|
-
const
|
|
1352
|
-
?
|
|
1353
|
-
:
|
|
1433
|
+
const argsBuilder = () => (this.platform === 'claude'
|
|
1434
|
+
? buildClaudeRequest(this.skapi, args)
|
|
1435
|
+
: buildOpenAIRequest(this.skapi, args));
|
|
1436
|
+
|
|
1437
|
+
let result;
|
|
1438
|
+
try {
|
|
1439
|
+
result = await argsBuilder();
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (_isAuthExpiredError(err)) {
|
|
1442
|
+
// The MCP-side skapi session expired. Re-mint a fresh
|
|
1443
|
+
// MCP token bundle from the live host skapi session
|
|
1444
|
+
// (skapi-js auto-refreshes its own tokens), then retry
|
|
1445
|
+
// the call once.
|
|
1446
|
+
try {
|
|
1447
|
+
this.oauth.clearToken();
|
|
1448
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
1449
|
+
} catch (reauthErr) {
|
|
1450
|
+
throw reauthErr;
|
|
1451
|
+
}
|
|
1452
|
+
result = await argsBuilder();
|
|
1453
|
+
} else {
|
|
1454
|
+
throw err;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1354
1457
|
|
|
1355
1458
|
// The proxy may resolve with an error-shaped body instead of
|
|
1356
1459
|
// throwing; surface that as an error bubble with the
|
|
1357
1460
|
// upstream message rather than dropping it.
|
|
1461
|
+
if (isErrorResponseBody(result) && _isAuthExpiredError(result)) {
|
|
1462
|
+
try {
|
|
1463
|
+
this.oauth.clearToken();
|
|
1464
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
1465
|
+
result = await argsBuilder();
|
|
1466
|
+
} catch (_reauthErr) { /* fall through to normal error rendering */ }
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1358
1469
|
if (isErrorResponseBody(result)) {
|
|
1359
1470
|
const errMsg = getErrorMessage(result);
|
|
1360
1471
|
const last = this.messages[this.messages.length - 1];
|