bunnyquery 1.0.0
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/README.md +164 -0
- package/bunnyquery.css +796 -0
- package/bunnyquery.js +1369 -0
- package/package.json +23 -0
package/bunnyquery.js
ADDED
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BunnyQuery embeddable client.
|
|
3
|
+
*
|
|
4
|
+
* Vanilla JS port of the .bq-chat element from
|
|
5
|
+
* www.skapi.com/src/views/service/agent.vue. Differences from the host app:
|
|
6
|
+
*
|
|
7
|
+
* - No AI platform / model selectors. The project owner pre-selects them
|
|
8
|
+
* in the project settings page; the embed reads `info.ai_agent` and
|
|
9
|
+
* uses it as-is.
|
|
10
|
+
* - Shows a login form when there is no logged-in user. On successful
|
|
11
|
+
* login the client kicks off the MCP OAuth flow (RFC 7591 + PKCE) and
|
|
12
|
+
* persists the resulting access token in localStorage. On the OAuth
|
|
13
|
+
* redirect back the client picks the code+state out of the URL and
|
|
14
|
+
* finishes the exchange transparently before rendering the chat.
|
|
15
|
+
* - Hides the attach button when `info.freeze_database === true`.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* <script src="https://cdn.jsdelivr.net/npm/skapi-js@latest/dist/skapi.js"></script>
|
|
19
|
+
* <script src="bunnyquery.js"></script>
|
|
20
|
+
* <script type="module">
|
|
21
|
+
* const skapi = new Skapi("<service>", "<owner>");
|
|
22
|
+
* const bq = await BunnyQuery.init(skapi, "bq-client");
|
|
23
|
+
* </script>
|
|
24
|
+
*/
|
|
25
|
+
(function (global) {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
// Capture the script element at parse time so we can resolve a sibling
|
|
29
|
+
// `bunnyquery.css` URL even when the script is hosted on a CDN. The
|
|
30
|
+
// consumer can override this by setting `BunnyQuery.STYLESHEET_URL`
|
|
31
|
+
// before calling `BunnyQuery.init()`.
|
|
32
|
+
const _CURRENT_SCRIPT_SRC =
|
|
33
|
+
(typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
|
|
34
|
+
|
|
35
|
+
// ---- MCP OAuth constants (mirrors src/code/mcp_oauth.ts) ---------------
|
|
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.
|
|
38
|
+
const DEFAULTS = {
|
|
39
|
+
MCP_BASE_URL: 'https://mcp-dev.broadwayinc.computer',
|
|
40
|
+
MCP_NAME: 'BunnyQuery',
|
|
41
|
+
DEFAULT_CLAUDE_MODEL: 'claude-sonnet-4-6',
|
|
42
|
+
DEFAULT_OPENAI_MODEL: 'gpt-5.4',
|
|
43
|
+
ANTHROPIC_MESSAGES_API_URL: 'https://api.anthropic.com/v1/messages',
|
|
44
|
+
ANTHROPIC_VERSION: '2023-06-01',
|
|
45
|
+
ANTHROPIC_BETA_HEADER:
|
|
46
|
+
'mcp-client-2025-11-20,web-fetch-2025-09-10,prompt-caching-2024-07-31',
|
|
47
|
+
OPENAI_RESPONSES_API_URL: 'https://api.openai.com/v1/responses',
|
|
48
|
+
MAX_TOKENS: 25000,
|
|
49
|
+
POLL_INTERVAL: 1500,
|
|
50
|
+
PENDING_POLL_INTERVAL_MS: 4000,
|
|
51
|
+
ATTACHMENT_URL_EXPIRES_SECONDS: 600, // 10 min
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// localStorage keys are namespaced by origin so multiple embeds on the
|
|
55
|
+
// same machine do not stomp each other's tokens.
|
|
56
|
+
const STORAGE_PREFIX = 'bq_embed_v1:';
|
|
57
|
+
const CLIENT_KEY = STORAGE_PREFIX + 'mcp_client';
|
|
58
|
+
const TOKEN_KEY = STORAGE_PREFIX + 'mcp_token';
|
|
59
|
+
const STATE_KEY = STORAGE_PREFIX + 'mcp_state';
|
|
60
|
+
|
|
61
|
+
// ----- helpers ----------------------------------------------------------
|
|
62
|
+
const $ = (tag, attrs, children) => {
|
|
63
|
+
const el = document.createElement(tag);
|
|
64
|
+
if (attrs) {
|
|
65
|
+
for (const k in attrs) {
|
|
66
|
+
if (k === 'class') el.className = attrs[k];
|
|
67
|
+
else if (k === 'style') Object.assign(el.style, attrs[k]);
|
|
68
|
+
else if (k === 'dataset') Object.assign(el.dataset, attrs[k]);
|
|
69
|
+
else if (k.startsWith('on') && typeof attrs[k] === 'function') {
|
|
70
|
+
el.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
|
|
71
|
+
} else if (attrs[k] != null && attrs[k] !== false) {
|
|
72
|
+
el.setAttribute(k, attrs[k]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (children != null) {
|
|
77
|
+
(Array.isArray(children) ? children : [children]).forEach((c) => {
|
|
78
|
+
if (c == null || c === false) return;
|
|
79
|
+
el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return el;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const safeJSONParse = (raw) => {
|
|
86
|
+
try { return raw ? JSON.parse(raw) : null; } catch { return null; }
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ----- PKCE helpers -----------------------------------------------------
|
|
90
|
+
const b64url = (bytes) => {
|
|
91
|
+
let s = '';
|
|
92
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
|
93
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
94
|
+
};
|
|
95
|
+
const randomBytes = (n) => {
|
|
96
|
+
const b = new Uint8Array(n);
|
|
97
|
+
crypto.getRandomValues(b);
|
|
98
|
+
return b;
|
|
99
|
+
};
|
|
100
|
+
const codeChallengeFor = async (verifier) => {
|
|
101
|
+
if (crypto && crypto.subtle && crypto.subtle.digest) {
|
|
102
|
+
try {
|
|
103
|
+
const data = new TextEncoder().encode(verifier);
|
|
104
|
+
const hash = await crypto.subtle.digest('SHA-256', data);
|
|
105
|
+
return { challenge: b64url(new Uint8Array(hash)), method: 'S256' };
|
|
106
|
+
} catch (_) { /* fall through */ }
|
|
107
|
+
}
|
|
108
|
+
return { challenge: verifier, method: 'plain' };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ===== MCP OAuth ========================================================
|
|
112
|
+
class McpOAuth {
|
|
113
|
+
constructor(baseUrl) {
|
|
114
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get redirectUri() {
|
|
118
|
+
// Strip any oauth params so the registered URI is stable across
|
|
119
|
+
// login attempts on the same page.
|
|
120
|
+
const u = new URL(window.location.href);
|
|
121
|
+
u.search = '';
|
|
122
|
+
u.hash = '';
|
|
123
|
+
return u.toString();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getStoredClient() {
|
|
127
|
+
return safeJSONParse(localStorage.getItem(CLIENT_KEY));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
getStoredToken() {
|
|
131
|
+
return safeJSONParse(localStorage.getItem(TOKEN_KEY));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
clearToken() {
|
|
135
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async register(force = false) {
|
|
139
|
+
// The dynamically registered client is keyed by its redirect_uri
|
|
140
|
+
// AND the MCP host - swapping MCP_BASE_URL must trigger a fresh
|
|
141
|
+
// registration since the old client_id is unknown to the new host.
|
|
142
|
+
const existing = this.getStoredClient();
|
|
143
|
+
if (
|
|
144
|
+
!force
|
|
145
|
+
&& existing
|
|
146
|
+
&& existing.redirect_uri === this.redirectUri
|
|
147
|
+
&& existing.base_url === this.baseUrl
|
|
148
|
+
) return existing;
|
|
149
|
+
|
|
150
|
+
const res = await fetch(`${this.baseUrl}/oauth/register`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
client_name: 'bunnyquery-embed',
|
|
155
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
156
|
+
response_types: ['code'],
|
|
157
|
+
redirect_uris: [this.redirectUri],
|
|
158
|
+
token_endpoint_auth_method: 'client_secret_basic',
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
throw new Error(`MCP /oauth/register failed: ${res.status} ${await res.text().catch(() => '')}`);
|
|
163
|
+
}
|
|
164
|
+
const json = await res.json();
|
|
165
|
+
if (!json.client_id) throw new Error('MCP register missing client_id');
|
|
166
|
+
const stored = {
|
|
167
|
+
...json,
|
|
168
|
+
redirect_uri: this.redirectUri,
|
|
169
|
+
base_url: this.baseUrl,
|
|
170
|
+
registered_at: Date.now(),
|
|
171
|
+
};
|
|
172
|
+
localStorage.setItem(CLIENT_KEY, JSON.stringify(stored));
|
|
173
|
+
return stored;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async startAuthorize() {
|
|
177
|
+
const client = await this.register();
|
|
178
|
+
const codeVerifier = b64url(randomBytes(32));
|
|
179
|
+
const { challenge, method } = await codeChallengeFor(codeVerifier);
|
|
180
|
+
const state = b64url(randomBytes(16));
|
|
181
|
+
sessionStorage.setItem(
|
|
182
|
+
STATE_KEY,
|
|
183
|
+
JSON.stringify({ state, codeVerifier, returnTo: window.location.href })
|
|
184
|
+
);
|
|
185
|
+
const params = new URLSearchParams({
|
|
186
|
+
response_type: 'code',
|
|
187
|
+
client_id: client.client_id,
|
|
188
|
+
redirect_uri: this.redirectUri,
|
|
189
|
+
state,
|
|
190
|
+
code_challenge: challenge,
|
|
191
|
+
code_challenge_method: method,
|
|
192
|
+
});
|
|
193
|
+
window.location.href = `${this.baseUrl}/oauth/authorize?${params.toString()}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Direct token handoff: hand the host-service skapi session to MCP
|
|
197
|
+
// without bouncing through the SKAPI_LOGIN_PAGE redirect chain. The
|
|
198
|
+
// server creates an authorization code from the supplied id_token,
|
|
199
|
+
// we exchange it for an MCP access_token via the standard
|
|
200
|
+
// /oauth/token endpoint, and persist it. No URL navigation occurs.
|
|
201
|
+
async exchangeSession(skapiSession) {
|
|
202
|
+
if (!skapiSession || !skapiSession.idToken || !skapiSession.accessToken) {
|
|
203
|
+
throw new Error('Missing skapi session tokens; user is not logged in');
|
|
204
|
+
}
|
|
205
|
+
let client = await this.register();
|
|
206
|
+
|
|
207
|
+
const buildExchangeBody = (clientId) => JSON.stringify({
|
|
208
|
+
client_id: clientId,
|
|
209
|
+
redirect_uri: this.redirectUri,
|
|
210
|
+
id_token: skapiSession.idToken.jwtToken,
|
|
211
|
+
access_token: skapiSession.accessToken.jwtToken,
|
|
212
|
+
refresh_token:
|
|
213
|
+
(skapiSession.refreshToken && skapiSession.refreshToken.token) || '',
|
|
214
|
+
});
|
|
215
|
+
const postExchange = (clientId) => fetch(`${this.baseUrl}/oauth/session-exchange`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: buildExchangeBody(clientId),
|
|
219
|
+
});
|
|
220
|
+
let exchangeRes = await postExchange(client.client_id);
|
|
221
|
+
// Stale client_id (MCP host swapped, server lost registrations,
|
|
222
|
+
// etc.): force a fresh /oauth/register and retry once.
|
|
223
|
+
if (exchangeRes.status === 401) {
|
|
224
|
+
client = await this.register(true);
|
|
225
|
+
exchangeRes = await postExchange(client.client_id);
|
|
226
|
+
}
|
|
227
|
+
if (!exchangeRes.ok) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`MCP /oauth/session-exchange failed: ${exchangeRes.status} ${await exchangeRes
|
|
230
|
+
.text()
|
|
231
|
+
.catch(() => '')}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const exchangeText = await exchangeRes.text();
|
|
235
|
+
if (!exchangeText) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
'MCP /oauth/session-exchange returned an empty body. The MCP server at ' +
|
|
238
|
+
this.baseUrl +
|
|
239
|
+
' likely does not have the /oauth/session-exchange endpoint deployed.'
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
let exchangeJson;
|
|
243
|
+
try { exchangeJson = JSON.parse(exchangeText); }
|
|
244
|
+
catch (_) {
|
|
245
|
+
throw new Error('MCP /oauth/session-exchange returned non-JSON: ' + exchangeText.slice(0, 200));
|
|
246
|
+
}
|
|
247
|
+
if (!exchangeJson || !exchangeJson.code) {
|
|
248
|
+
throw new Error('MCP session-exchange response missing code');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const body = new URLSearchParams({
|
|
252
|
+
grant_type: 'authorization_code',
|
|
253
|
+
code: exchangeJson.code,
|
|
254
|
+
redirect_uri: this.redirectUri,
|
|
255
|
+
client_id: client.client_id,
|
|
256
|
+
});
|
|
257
|
+
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
258
|
+
if (client.client_secret) {
|
|
259
|
+
headers.Authorization =
|
|
260
|
+
'Basic ' + btoa(`${client.client_id}:${client.client_secret}`);
|
|
261
|
+
}
|
|
262
|
+
const tokenRes = await fetch(`${this.baseUrl}/oauth/token`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
headers,
|
|
265
|
+
body: body.toString(),
|
|
266
|
+
});
|
|
267
|
+
if (!tokenRes.ok) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`MCP /oauth/token failed: ${tokenRes.status} ${await tokenRes
|
|
270
|
+
.text()
|
|
271
|
+
.catch(() => '')}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const json = await tokenRes.json();
|
|
275
|
+
if (!json.access_token) throw new Error('MCP token response missing access_token');
|
|
276
|
+
const token = {
|
|
277
|
+
...json,
|
|
278
|
+
expires_at:
|
|
279
|
+
typeof json.expires_in === 'number'
|
|
280
|
+
? Date.now() + json.expires_in * 1000
|
|
281
|
+
: undefined,
|
|
282
|
+
};
|
|
283
|
+
localStorage.setItem(TOKEN_KEY, JSON.stringify(token));
|
|
284
|
+
return token;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Detect whether the current URL contains a successful MCP OAuth
|
|
288
|
+
// callback whose state matches a flow we started on this device.
|
|
289
|
+
isCallbackInUrl() {
|
|
290
|
+
const sp = new URLSearchParams(window.location.search);
|
|
291
|
+
const code = sp.get('code');
|
|
292
|
+
const state = sp.get('state');
|
|
293
|
+
if (!code || !state) return false;
|
|
294
|
+
const stored = safeJSONParse(sessionStorage.getItem(STATE_KEY));
|
|
295
|
+
return !!(stored && stored.state === state);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async completeFromUrl() {
|
|
299
|
+
const sp = new URLSearchParams(window.location.search);
|
|
300
|
+
const code = sp.get('code');
|
|
301
|
+
const state = sp.get('state');
|
|
302
|
+
const stored = safeJSONParse(sessionStorage.getItem(STATE_KEY));
|
|
303
|
+
sessionStorage.removeItem(STATE_KEY);
|
|
304
|
+
if (!stored || stored.state !== state) {
|
|
305
|
+
throw new Error('MCP OAuth state mismatch');
|
|
306
|
+
}
|
|
307
|
+
const client = this.getStoredClient();
|
|
308
|
+
if (!client) throw new Error('No registered MCP client');
|
|
309
|
+
|
|
310
|
+
const body = new URLSearchParams({
|
|
311
|
+
grant_type: 'authorization_code',
|
|
312
|
+
code,
|
|
313
|
+
redirect_uri: this.redirectUri,
|
|
314
|
+
code_verifier: stored.codeVerifier,
|
|
315
|
+
client_id: client.client_id,
|
|
316
|
+
});
|
|
317
|
+
const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
318
|
+
if (client.client_secret) {
|
|
319
|
+
headers.Authorization = 'Basic ' + btoa(`${client.client_id}:${client.client_secret}`);
|
|
320
|
+
}
|
|
321
|
+
const res = await fetch(`${this.baseUrl}/oauth/token`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers,
|
|
324
|
+
body: body.toString(),
|
|
325
|
+
});
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
throw new Error(`MCP /oauth/token failed: ${res.status} ${await res.text().catch(() => '')}`);
|
|
328
|
+
}
|
|
329
|
+
const json = await res.json();
|
|
330
|
+
if (!json.access_token) throw new Error('MCP token response missing access_token');
|
|
331
|
+
const token = {
|
|
332
|
+
...json,
|
|
333
|
+
expires_at:
|
|
334
|
+
typeof json.expires_in === 'number'
|
|
335
|
+
? Date.now() + json.expires_in * 1000
|
|
336
|
+
: undefined,
|
|
337
|
+
};
|
|
338
|
+
localStorage.setItem(TOKEN_KEY, JSON.stringify(token));
|
|
339
|
+
|
|
340
|
+
// Clean the OAuth params out of the URL so reloads do not retry.
|
|
341
|
+
const url = new URL(window.location.href);
|
|
342
|
+
url.searchParams.delete('code');
|
|
343
|
+
url.searchParams.delete('state');
|
|
344
|
+
history.replaceState({}, '', url.toString());
|
|
345
|
+
return token;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ===== AI agent calls (mirrors src/code/ai_agent.ts) ====================
|
|
350
|
+
function buildClaudeRequest(skapi, { service, owner, prompt, messages, system, model }) {
|
|
351
|
+
const msgList = messages && messages.length
|
|
352
|
+
? messages
|
|
353
|
+
: [{ role: 'user', content: prompt }];
|
|
354
|
+
return skapi.clientSecretRequest({
|
|
355
|
+
clientSecretName: 'claude',
|
|
356
|
+
poll: DEFAULTS.POLL_INTERVAL,
|
|
357
|
+
queue: service,
|
|
358
|
+
service,
|
|
359
|
+
owner,
|
|
360
|
+
url: DEFAULTS.ANTHROPIC_MESSAGES_API_URL,
|
|
361
|
+
method: 'POST',
|
|
362
|
+
headers: {
|
|
363
|
+
'content-type': 'application/json',
|
|
364
|
+
'x-api-key': '$CLIENT_SECRET',
|
|
365
|
+
'anthropic-version': DEFAULTS.ANTHROPIC_VERSION,
|
|
366
|
+
'anthropic-beta': DEFAULTS.ANTHROPIC_BETA_HEADER,
|
|
367
|
+
},
|
|
368
|
+
data: {
|
|
369
|
+
model: model || DEFAULTS.DEFAULT_CLAUDE_MODEL,
|
|
370
|
+
max_tokens: DEFAULTS.MAX_TOKENS,
|
|
371
|
+
...(system
|
|
372
|
+
? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] }
|
|
373
|
+
: {}),
|
|
374
|
+
messages: msgList,
|
|
375
|
+
mcp_servers: [
|
|
376
|
+
{
|
|
377
|
+
type: 'url',
|
|
378
|
+
name: DEFAULTS.MCP_NAME,
|
|
379
|
+
url: DEFAULTS.MCP_BASE_URL,
|
|
380
|
+
authorization_token: '$ACCESS_TOKEN',
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
tools: [
|
|
384
|
+
{ type: 'mcp_toolset', mcp_server_name: DEFAULTS.MCP_NAME },
|
|
385
|
+
{
|
|
386
|
+
type: 'web_fetch_20250910',
|
|
387
|
+
name: 'web_fetch',
|
|
388
|
+
max_uses: 40,
|
|
389
|
+
citations: { enabled: true },
|
|
390
|
+
max_content_tokens: 200000,
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildOpenAIRequest(skapi, { service, owner, prompt, messages, system, model }) {
|
|
398
|
+
const msgList = messages && messages.length
|
|
399
|
+
? messages
|
|
400
|
+
: [{ role: 'user', content: prompt }];
|
|
401
|
+
const input = [
|
|
402
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
403
|
+
...msgList.map((m) => ({ role: m.role, content: m.content })),
|
|
404
|
+
];
|
|
405
|
+
return skapi.clientSecretRequest({
|
|
406
|
+
clientSecretName: 'openai',
|
|
407
|
+
poll: DEFAULTS.POLL_INTERVAL,
|
|
408
|
+
queue: service,
|
|
409
|
+
service,
|
|
410
|
+
owner,
|
|
411
|
+
url: DEFAULTS.OPENAI_RESPONSES_API_URL,
|
|
412
|
+
method: 'POST',
|
|
413
|
+
headers: {
|
|
414
|
+
'content-type': 'application/json',
|
|
415
|
+
Authorization: 'Bearer $CLIENT_SECRET',
|
|
416
|
+
},
|
|
417
|
+
data: {
|
|
418
|
+
model: model || DEFAULTS.DEFAULT_OPENAI_MODEL,
|
|
419
|
+
max_output_tokens: DEFAULTS.MAX_TOKENS,
|
|
420
|
+
input,
|
|
421
|
+
tools: [
|
|
422
|
+
{
|
|
423
|
+
type: 'mcp',
|
|
424
|
+
server_label: DEFAULTS.MCP_NAME,
|
|
425
|
+
server_url: DEFAULTS.MCP_BASE_URL,
|
|
426
|
+
require_approval: 'never',
|
|
427
|
+
headers: { Authorization: 'Bearer $ACCESS_TOKEN' },
|
|
428
|
+
},
|
|
429
|
+
{ type: 'web_search', external_web_access: true },
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function getChatHistory(skapi, { service, owner, platform }, fetchOptions) {
|
|
436
|
+
const url =
|
|
437
|
+
platform === 'claude'
|
|
438
|
+
? DEFAULTS.ANTHROPIC_MESSAGES_API_URL
|
|
439
|
+
: DEFAULTS.OPENAI_RESPONSES_API_URL;
|
|
440
|
+
return skapi.clientSecretRequestHistory(
|
|
441
|
+
{ url, method: 'POST', service, owner, queue: service, poll: DEFAULTS.POLL_INTERVAL },
|
|
442
|
+
Object.assign({ ascending: false }, fetchOptions || {})
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function extractClaudeText(response) {
|
|
447
|
+
if (!response || !Array.isArray(response.content)) return '';
|
|
448
|
+
return response.content
|
|
449
|
+
.filter((b) => b && b.type === 'text')
|
|
450
|
+
.map((b) => b.text || '')
|
|
451
|
+
.join('\n');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function extractOpenAIText(response) {
|
|
455
|
+
if (!response) return '';
|
|
456
|
+
if (typeof response.output_text === 'string' && response.output_text.length) {
|
|
457
|
+
return response.output_text;
|
|
458
|
+
}
|
|
459
|
+
if (Array.isArray(response.output)) {
|
|
460
|
+
const txt = response.output
|
|
461
|
+
.flatMap((it) => (it && it.content) || [])
|
|
462
|
+
.filter((p) => p && p.type === 'output_text')
|
|
463
|
+
.map((p) => p.text || '')
|
|
464
|
+
.join('\n')
|
|
465
|
+
.trim();
|
|
466
|
+
if (txt) return txt;
|
|
467
|
+
}
|
|
468
|
+
const c =
|
|
469
|
+
response.choices && response.choices[0] && response.choices[0].message && response.choices[0].message.content;
|
|
470
|
+
return typeof c === 'string' ? c : '';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Walk the persisted history list returned by clientSecretRequestHistory
|
|
474
|
+
// and turn it into a flat user/assistant list ordered oldest -> newest.
|
|
475
|
+
function mapHistoryToMessages(list, platform) {
|
|
476
|
+
const out = [];
|
|
477
|
+
// History pages return newest first; iterate in reverse so the chat
|
|
478
|
+
// log reads naturally top-to-bottom.
|
|
479
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
480
|
+
const item = list[i];
|
|
481
|
+
const req = item && item.request_body;
|
|
482
|
+
const res = item && item.response_body;
|
|
483
|
+
|
|
484
|
+
// The user prompt is the LAST message of the request (Claude
|
|
485
|
+
// appends history before the new prompt; OpenAI uses `input`).
|
|
486
|
+
let userTurn = '';
|
|
487
|
+
const reqMsgs = (req && (req.messages || req.input)) || [];
|
|
488
|
+
for (let j = reqMsgs.length - 1; j >= 0; j--) {
|
|
489
|
+
const m = reqMsgs[j];
|
|
490
|
+
if (m && m.role === 'user') {
|
|
491
|
+
userTurn = typeof m.content === 'string'
|
|
492
|
+
? m.content
|
|
493
|
+
: Array.isArray(m.content)
|
|
494
|
+
? m.content.map((p) => (p && (p.text || p.input_text)) || '').join('')
|
|
495
|
+
: '';
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (userTurn) out.push({ role: 'user', content: userTurn, id: item.id });
|
|
500
|
+
|
|
501
|
+
if (item.status === 'pending') {
|
|
502
|
+
out.push({ role: 'assistant', content: '', id: item.id, isPending: true });
|
|
503
|
+
} else if (item.status === 'failed' || (res && res.error)) {
|
|
504
|
+
out.push({
|
|
505
|
+
role: 'assistant',
|
|
506
|
+
content: (res && res.error && res.error.message) || 'Request failed.',
|
|
507
|
+
id: item.id,
|
|
508
|
+
isError: true,
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
const text = platform === 'claude' ? extractClaudeText(res) : extractOpenAIText(res);
|
|
512
|
+
out.push({ role: 'assistant', content: text, id: item.id });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ===== Embed UI =========================================================
|
|
519
|
+
class BunnyQuery {
|
|
520
|
+
constructor(skapi, info, container) {
|
|
521
|
+
this.skapi = skapi;
|
|
522
|
+
this.info = info;
|
|
523
|
+
this.container = container;
|
|
524
|
+
this.container.classList.add('bq-agent');
|
|
525
|
+
|
|
526
|
+
// The Connection payload from skapi.__connection does NOT carry
|
|
527
|
+
// service/owner ids - those live on the skapi instance itself
|
|
528
|
+
// (see skapi-js/src/Types.ts -> Connection). The freeze flag is
|
|
529
|
+
// nested under `opt`.
|
|
530
|
+
this.serviceId = skapi.service;
|
|
531
|
+
this.ownerId = skapi.owner;
|
|
532
|
+
this.projectName = info.service_name || '';
|
|
533
|
+
this.projectDescription = info.service_description || '';
|
|
534
|
+
|
|
535
|
+
const ai = String(info.ai_agent || '').split('#');
|
|
536
|
+
this.platform = (ai[0] || '').toLowerCase(); // 'claude' | 'openai'
|
|
537
|
+
this.model = ai[1] || '';
|
|
538
|
+
|
|
539
|
+
this.readOnly = !!(info.opt && info.opt.freeze_database);
|
|
540
|
+
|
|
541
|
+
this.oauth = new McpOAuth(BunnyQuery.MCP_BASE_URL || DEFAULTS.MCP_BASE_URL);
|
|
542
|
+
|
|
543
|
+
this.messages = [];
|
|
544
|
+
this.attachments = [];
|
|
545
|
+
this.startKeyHistory = null;
|
|
546
|
+
this.endOfList = false;
|
|
547
|
+
this.sending = false;
|
|
548
|
+
this.uploading = false;
|
|
549
|
+
this.pendingTimer = null;
|
|
550
|
+
this._loadingOlder = false;
|
|
551
|
+
|
|
552
|
+
this.refs = {};
|
|
553
|
+
|
|
554
|
+
this._injectStylesheetOnce();
|
|
555
|
+
this._bootstrap();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
static async init(skapi, elementId) {
|
|
559
|
+
const container = typeof elementId === 'string'
|
|
560
|
+
? document.getElementById(elementId)
|
|
561
|
+
: elementId;
|
|
562
|
+
if (!container) throw new Error(`BunnyQuery: container "${elementId}" not found`);
|
|
563
|
+
const info = await skapi.__connection;
|
|
564
|
+
console.log('[BunnyQuery] initializing with info', info);
|
|
565
|
+
return new BunnyQuery(skapi, info, container);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---- bootstrap order ----------------------------------------------
|
|
569
|
+
async _bootstrap() {
|
|
570
|
+
// 1. Validate that the project has a configured AI platform.
|
|
571
|
+
if (!this.platform) {
|
|
572
|
+
this._renderFatal('This project does not have an AI agent configured yet. Set one in your project settings.');
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 2. If we just came back from the MCP authorize redirect,
|
|
577
|
+
// finish the token exchange before doing anything else.
|
|
578
|
+
try {
|
|
579
|
+
if (this.oauth.isCallbackInUrl()) {
|
|
580
|
+
this._renderLoading(''); //Finalizing authorization
|
|
581
|
+
await this.oauth.completeFromUrl();
|
|
582
|
+
}
|
|
583
|
+
} catch (err) {
|
|
584
|
+
this._renderFatal('MCP authorization failed: ' + (err && err.message ? err.message : err));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 3. Decide whether to show the login form or the chat.
|
|
589
|
+
await this._refreshGate();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async _refreshGate() {
|
|
593
|
+
let profile = null;
|
|
594
|
+
try {
|
|
595
|
+
profile = await this.skapi.getProfile();
|
|
596
|
+
} catch (_) { profile = null; }
|
|
597
|
+
|
|
598
|
+
if (!profile) {
|
|
599
|
+
this._renderLogin();
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!this.oauth.getStoredToken()) {
|
|
603
|
+
// Logged into the host service but MCP has not seen this
|
|
604
|
+
// session yet. Hand the skapi tokens to MCP directly - no
|
|
605
|
+
// redirect chain through bunnyquery.com.
|
|
606
|
+
this._renderLoading(''); //Authorizing MCP server
|
|
607
|
+
try {
|
|
608
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
this._renderFatal('MCP authorization failed: ' + (err && err.message ? err.message : err));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
this._renderChat();
|
|
615
|
+
await this._loadFirstHistoryPage();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ---- rendering ----------------------------------------------------
|
|
619
|
+
_clear() {
|
|
620
|
+
while (this.container.firstChild) this.container.removeChild(this.container.firstChild);
|
|
621
|
+
this.refs = {};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
_renderLoading(label) {
|
|
625
|
+
this._clear();
|
|
626
|
+
this.container.appendChild(
|
|
627
|
+
$('div', { class: 'bq-gate-loading' }, [
|
|
628
|
+
$('span', { class: 'bq-loader' }, label || ''),
|
|
629
|
+
])
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_renderFatal(msg) {
|
|
634
|
+
this._clear();
|
|
635
|
+
this.container.appendChild(
|
|
636
|
+
$('div', { class: 'bq-apikey-overlay' }, [
|
|
637
|
+
$('div', { class: 'bq-apikey-overlay-inner' }, [
|
|
638
|
+
$('p', { class: 'bq-apikey-error' }, msg),
|
|
639
|
+
]),
|
|
640
|
+
])
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
_renderLogin() {
|
|
645
|
+
this._clear();
|
|
646
|
+
const errLabel = $('p', { class: 'bq-apikey-error', style: { display: 'none' } });
|
|
647
|
+
const userInput = $('input', {
|
|
648
|
+
type: 'text',
|
|
649
|
+
class: 'bq-input',
|
|
650
|
+
placeholder: 'Email or username',
|
|
651
|
+
autocomplete: 'username',
|
|
652
|
+
});
|
|
653
|
+
const passInput = $('input', {
|
|
654
|
+
type: 'password',
|
|
655
|
+
class: 'bq-input',
|
|
656
|
+
placeholder: 'Password',
|
|
657
|
+
autocomplete: 'current-password',
|
|
658
|
+
});
|
|
659
|
+
const submitBtn = $('button', { type: 'submit', class: 'btn bq-login-submit' }, [
|
|
660
|
+
$('span', { class: 'bq-login-submit-label' }, 'Login'),
|
|
661
|
+
]);
|
|
662
|
+
|
|
663
|
+
const form = $('form', {
|
|
664
|
+
class: 'bq-login-form',
|
|
665
|
+
onsubmit: async (e) => {
|
|
666
|
+
e.preventDefault();
|
|
667
|
+
errLabel.style.display = 'none';
|
|
668
|
+
submitBtn.disabled = true;
|
|
669
|
+
submitBtn.classList.add('is-loading');
|
|
670
|
+
try {
|
|
671
|
+
await this.skapi.login({
|
|
672
|
+
email: userInput.value.trim(),
|
|
673
|
+
password: passInput.value,
|
|
674
|
+
});
|
|
675
|
+
// Successful host-service login -> hand the skapi
|
|
676
|
+
// session straight to MCP (no redirect round-trip).
|
|
677
|
+
await this.oauth.exchangeSession(this.skapi.session);
|
|
678
|
+
await this._refreshGate();
|
|
679
|
+
} catch (err) {
|
|
680
|
+
errLabel.textContent = (err && err.message) || String(err);
|
|
681
|
+
errLabel.style.display = '';
|
|
682
|
+
submitBtn.disabled = false;
|
|
683
|
+
submitBtn.classList.remove('is-loading');
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
}, [
|
|
687
|
+
errLabel,
|
|
688
|
+
userInput,
|
|
689
|
+
passInput,
|
|
690
|
+
submitBtn,
|
|
691
|
+
]);
|
|
692
|
+
|
|
693
|
+
this.container.appendChild(
|
|
694
|
+
$('div', { class: 'bq-apikey-overlay' }, [
|
|
695
|
+
$('div', { class: 'bq-apikey-overlay-inner' }, [form]),
|
|
696
|
+
])
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
_renderChat() {
|
|
701
|
+
this._clear();
|
|
702
|
+
|
|
703
|
+
// Outer shell mirrors agent.vue: .bq-meta wraps a section title
|
|
704
|
+
// (with the project name on the left and the clear / logout
|
|
705
|
+
// icons on the right) and the chat surface below it.
|
|
706
|
+
const meta = $('div', { class: 'bq-meta' });
|
|
707
|
+
|
|
708
|
+
const titleText = this.projectName
|
|
709
|
+
? this.projectName
|
|
710
|
+
: 'BunnyQuery';
|
|
711
|
+
const titleLeft = $('div', { class: 'bq-title-left' }, [
|
|
712
|
+
$('h2', null, titleText),
|
|
713
|
+
]);
|
|
714
|
+
|
|
715
|
+
const clearIcon = $('button', {
|
|
716
|
+
type: 'button',
|
|
717
|
+
class: 'bq-text-btn bq-text-btn--danger',
|
|
718
|
+
title: 'Clear chat history',
|
|
719
|
+
'aria-label': 'Clear chat history',
|
|
720
|
+
onclick: () => this._onClearHistoryClick(),
|
|
721
|
+
}, 'Clear');
|
|
722
|
+
|
|
723
|
+
const logoutIcon = $('button', {
|
|
724
|
+
type: 'button',
|
|
725
|
+
class: 'bq-text-btn',
|
|
726
|
+
title: 'Logout',
|
|
727
|
+
'aria-label': 'Logout',
|
|
728
|
+
onclick: () => this._logout(),
|
|
729
|
+
}, 'Logout');
|
|
730
|
+
|
|
731
|
+
const titleRight = $('div', { class: 'bq-title-right' }, [
|
|
732
|
+
clearIcon,
|
|
733
|
+
logoutIcon,
|
|
734
|
+
]);
|
|
735
|
+
|
|
736
|
+
const titleRow = $('div', { class: 'bq-title-row' }, [titleLeft, titleRight]);
|
|
737
|
+
const sectionTitle = $('div', { class: 'bq-section-title' }, [titleRow]);
|
|
738
|
+
|
|
739
|
+
const chat = $('div', { class: 'bq-chat' });
|
|
740
|
+
const messagesBox = $('div', {
|
|
741
|
+
class: 'bq-messages',
|
|
742
|
+
onscroll: () => this._onHistoryScroll(),
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Input row
|
|
746
|
+
const attachInput = $('input', {
|
|
747
|
+
type: 'file',
|
|
748
|
+
class: 'bq-attach-input',
|
|
749
|
+
multiple: true,
|
|
750
|
+
onchange: (e) => this._onAttachFromInput(e),
|
|
751
|
+
});
|
|
752
|
+
const textarea = $('textarea', {
|
|
753
|
+
class: 'bq-input',
|
|
754
|
+
rows: '1',
|
|
755
|
+
placeholder: 'Ask anything about the project...',
|
|
756
|
+
oninput: (e) => this._autoGrow(e.target),
|
|
757
|
+
onkeydown: (e) => {
|
|
758
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
759
|
+
e.preventDefault();
|
|
760
|
+
this._sendMessage();
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
const sendBtn = $('button', { type: 'submit', class: 'btn' }, 'Send');
|
|
765
|
+
|
|
766
|
+
const inputWrap = $('div', {
|
|
767
|
+
class: 'bq-input-wrap no-attach',
|
|
768
|
+
}, [
|
|
769
|
+
attachInput,
|
|
770
|
+
textarea,
|
|
771
|
+
]);
|
|
772
|
+
const attachmentsBox = $('div', { class: 'bq-attachments', style: { display: 'none' } });
|
|
773
|
+
const inputRow = $('form', {
|
|
774
|
+
class: 'bq-input-row',
|
|
775
|
+
onsubmit: (e) => { e.preventDefault(); this._sendMessage(); },
|
|
776
|
+
}, [attachmentsBox, inputWrap, sendBtn]);
|
|
777
|
+
|
|
778
|
+
chat.appendChild(messagesBox);
|
|
779
|
+
chat.appendChild(inputRow);
|
|
780
|
+
|
|
781
|
+
meta.appendChild(sectionTitle);
|
|
782
|
+
meta.appendChild(chat);
|
|
783
|
+
this.container.appendChild(meta);
|
|
784
|
+
|
|
785
|
+
this.refs = {
|
|
786
|
+
meta,
|
|
787
|
+
chat,
|
|
788
|
+
messagesBox,
|
|
789
|
+
textarea,
|
|
790
|
+
sendBtn,
|
|
791
|
+
attachInput,
|
|
792
|
+
attachmentsBox,
|
|
793
|
+
clearIcon,
|
|
794
|
+
logoutIcon,
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
this._renderMessages();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
_renderMessages() {
|
|
801
|
+
const box = this.refs.messagesBox;
|
|
802
|
+
if (!box) return;
|
|
803
|
+
const stickToBottom =
|
|
804
|
+
box.scrollHeight - box.scrollTop - box.clientHeight < 80;
|
|
805
|
+
box.innerHTML = '';
|
|
806
|
+
|
|
807
|
+
if (!this.messages.length) {
|
|
808
|
+
box.appendChild(
|
|
809
|
+
$('div', { class: 'bq-message is-assistant bq-empty-greeting' }, [
|
|
810
|
+
$('div', { class: 'bq-bubble' },
|
|
811
|
+
`Hi! Ask me anything about ${this.projectName || 'the project'}.`
|
|
812
|
+
),
|
|
813
|
+
])
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
this.messages.forEach((msg) => {
|
|
818
|
+
const bubble = $('div', { class: 'bq-bubble' });
|
|
819
|
+
if (msg.isPending) {
|
|
820
|
+
bubble.appendChild($('span', { class: 'bq-loader' }, 'Thinking'));
|
|
821
|
+
} else {
|
|
822
|
+
bubble.textContent = msg.content || '';
|
|
823
|
+
}
|
|
824
|
+
const wrapper = $('div', {
|
|
825
|
+
class:
|
|
826
|
+
'bq-message ' +
|
|
827
|
+
(msg.role === 'user' ? 'is-user' : 'is-assistant') +
|
|
828
|
+
(msg.isError ? ' is-error' : ''),
|
|
829
|
+
}, [bubble]);
|
|
830
|
+
box.appendChild(wrapper);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
if (stickToBottom) {
|
|
834
|
+
box.scrollTop = box.scrollHeight;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Hide the clear-history icon when there's nothing to clear.
|
|
838
|
+
// Busy-state styling is handled in _updateInputDisabled() so it
|
|
839
|
+
// stays in sync with the textarea/sendBtn after _sendMessage()
|
|
840
|
+
// finishes (which only calls _updateInputDisabled, not
|
|
841
|
+
// _renderMessages).
|
|
842
|
+
if (this.refs.clearIcon) {
|
|
843
|
+
this.refs.clearIcon.style.display = this.messages.length ? '' : 'none';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
this._updateInputDisabled();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
_updateInputDisabled() {
|
|
850
|
+
const busy = this.sending || this.uploading || this._anyPending();
|
|
851
|
+
if (this.refs.textarea) {
|
|
852
|
+
this.refs.textarea.disabled = busy;
|
|
853
|
+
this.refs.textarea.placeholder = busy
|
|
854
|
+
? 'Request in process. Please wait...'
|
|
855
|
+
: 'Ask anything about the project...';
|
|
856
|
+
}
|
|
857
|
+
if (this.refs.sendBtn) this.refs.sendBtn.disabled = busy;
|
|
858
|
+
if (this.refs.attachBtn) this.refs.attachBtn.disabled = busy;
|
|
859
|
+
if (this.refs.clearIcon) this.refs.clearIcon.classList.toggle('disabled', busy);
|
|
860
|
+
if (this.refs.logoutIcon) this.refs.logoutIcon.classList.toggle('disabled', busy);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
_autoGrow(el) {
|
|
864
|
+
el.style.height = 'auto';
|
|
865
|
+
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
_anyPending() {
|
|
869
|
+
return this.messages.some((m) => m.isPending);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ---- clear history (soft delete via localStorage horizon) -------
|
|
873
|
+
// Mirrors the strategy in agent.vue: persist a per-(service,platform)
|
|
874
|
+
// `clearedAt` ms timestamp and drop any history entry whose `updated`
|
|
875
|
+
// field is at-or-before it. The server keeps the records.
|
|
876
|
+
_clearHistoryStorageKey() {
|
|
877
|
+
if (!this.serviceId || !this.platform) return '';
|
|
878
|
+
return `agent.clearedAt:${this.serviceId}#${this.platform}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
_getClearedAt() {
|
|
882
|
+
const key = this._clearHistoryStorageKey();
|
|
883
|
+
if (!key) return 0;
|
|
884
|
+
try {
|
|
885
|
+
const raw = window.localStorage.getItem(key);
|
|
886
|
+
const value = raw ? Number(raw) : 0;
|
|
887
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
888
|
+
} catch { return 0; }
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
_setClearedAt(ts) {
|
|
892
|
+
const key = this._clearHistoryStorageKey();
|
|
893
|
+
if (!key) return;
|
|
894
|
+
try { window.localStorage.setItem(key, String(ts)); } catch { }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
_filterByClearHorizon(list) {
|
|
898
|
+
const clearedAt = this._getClearedAt();
|
|
899
|
+
if (!clearedAt) return list;
|
|
900
|
+
return list.filter((item) => {
|
|
901
|
+
const updated = Number(item && item.updated);
|
|
902
|
+
return Number.isFinite(updated) && updated > clearedAt;
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
_onClearHistoryClick() {
|
|
907
|
+
const busy = this.sending || this.uploading || this._anyPending();
|
|
908
|
+
if (busy || !this.messages.length) return;
|
|
909
|
+
this._openClearHistoryModal();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
_openClearHistoryModal() {
|
|
913
|
+
// Tear down any previous modal node first.
|
|
914
|
+
if (this._clearModal) {
|
|
915
|
+
this._clearModal.remove();
|
|
916
|
+
this._clearModal = null;
|
|
917
|
+
}
|
|
918
|
+
const close = () => {
|
|
919
|
+
if (this._clearModal) {
|
|
920
|
+
this._clearModal.remove();
|
|
921
|
+
this._clearModal = null;
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const cancelBtn = $('button', {
|
|
926
|
+
class: 'bq-modal-delete-btn bq-modal-delete-btn--cancel',
|
|
927
|
+
type: 'button',
|
|
928
|
+
onclick: close,
|
|
929
|
+
}, 'Cancel');
|
|
930
|
+
|
|
931
|
+
const confirmBtn = $('button', {
|
|
932
|
+
class: 'bq-modal-delete-btn bq-modal-delete-btn--delete',
|
|
933
|
+
type: 'button',
|
|
934
|
+
onclick: () => {
|
|
935
|
+
this._setClearedAt(Date.now());
|
|
936
|
+
this.messages = [];
|
|
937
|
+
this.startKeyHistory = null;
|
|
938
|
+
this.endOfList = true;
|
|
939
|
+
this._renderMessages();
|
|
940
|
+
close();
|
|
941
|
+
},
|
|
942
|
+
}, 'Clear');
|
|
943
|
+
|
|
944
|
+
const modal = $('div', {
|
|
945
|
+
class: 'bq-modal-backdrop',
|
|
946
|
+
onclick: (e) => { if (e.target === modal) close(); },
|
|
947
|
+
}, [
|
|
948
|
+
$('div', { class: 'bq-modal-delete' }, [
|
|
949
|
+
$('div', { class: 'bq-modal-delete-header' }, [
|
|
950
|
+
$('span', null, 'Clear chat history'),
|
|
951
|
+
]),
|
|
952
|
+
$('div', { class: 'bq-modal-delete-body' }, [
|
|
953
|
+
$('p', null, 'Hide all previous messages from this chat?'),
|
|
954
|
+
$('p', { class: 'bq-modal-delete-warn' },
|
|
955
|
+
"New messages will appear normally. Existing history will be hidden from view and from the AI's context, but the records remain on the server."
|
|
956
|
+
),
|
|
957
|
+
]),
|
|
958
|
+
$('div', { class: 'bq-modal-delete-footer' }, [cancelBtn, confirmBtn]),
|
|
959
|
+
]),
|
|
960
|
+
]);
|
|
961
|
+
|
|
962
|
+
this._clearModal = modal;
|
|
963
|
+
document.body.appendChild(modal);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async _logout() {
|
|
967
|
+
const busy = this.sending || this.uploading || this._anyPending();
|
|
968
|
+
if (busy) return;
|
|
969
|
+
this._openLogoutModal();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
_openLogoutModal() {
|
|
973
|
+
// Tear down any previous logout modal node first.
|
|
974
|
+
if (this._logoutModal) {
|
|
975
|
+
this._logoutModal.remove();
|
|
976
|
+
this._logoutModal = null;
|
|
977
|
+
}
|
|
978
|
+
const close = () => {
|
|
979
|
+
if (this._logoutModal) {
|
|
980
|
+
this._logoutModal.remove();
|
|
981
|
+
this._logoutModal = null;
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const cancelBtn = $('button', {
|
|
986
|
+
class: 'bq-modal-delete-btn bq-modal-delete-btn--cancel',
|
|
987
|
+
type: 'button',
|
|
988
|
+
onclick: close,
|
|
989
|
+
}, 'Cancel');
|
|
990
|
+
|
|
991
|
+
const confirmBtn = $('button', {
|
|
992
|
+
class: 'bq-modal-delete-btn bq-modal-delete-btn--delete',
|
|
993
|
+
type: 'button',
|
|
994
|
+
onclick: async () => {
|
|
995
|
+
close();
|
|
996
|
+
await this._performLogout();
|
|
997
|
+
},
|
|
998
|
+
}, 'Logout');
|
|
999
|
+
|
|
1000
|
+
const modal = $('div', {
|
|
1001
|
+
class: 'bq-modal-backdrop',
|
|
1002
|
+
onclick: (e) => { if (e.target === modal) close(); },
|
|
1003
|
+
}, [
|
|
1004
|
+
$('div', { class: 'bq-modal-delete' }, [
|
|
1005
|
+
$('div', { class: 'bq-modal-delete-header' }, [
|
|
1006
|
+
$('span', null, 'Logout'),
|
|
1007
|
+
]),
|
|
1008
|
+
$('div', { class: 'bq-modal-delete-body' }, [
|
|
1009
|
+
$('p', null, 'Log out of this chat?'),
|
|
1010
|
+
$('p', { class: 'bq-modal-delete-warn' },
|
|
1011
|
+
'You will need to sign in again to continue the conversation. Existing history is preserved on the server.'
|
|
1012
|
+
),
|
|
1013
|
+
]),
|
|
1014
|
+
$('div', { class: 'bq-modal-delete-footer' }, [cancelBtn, confirmBtn]),
|
|
1015
|
+
]),
|
|
1016
|
+
]);
|
|
1017
|
+
|
|
1018
|
+
this._logoutModal = modal;
|
|
1019
|
+
document.body.appendChild(modal);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async _performLogout() {
|
|
1023
|
+
// Stop any in-flight pending poll so the chat doesn't try to
|
|
1024
|
+
// refetch history right after we tear it down.
|
|
1025
|
+
if (this.pendingTimer) {
|
|
1026
|
+
clearTimeout(this.pendingTimer);
|
|
1027
|
+
this.pendingTimer = null;
|
|
1028
|
+
}
|
|
1029
|
+
this.oauth.clearToken();
|
|
1030
|
+
try { await this.skapi.logout(); } catch (_) { }
|
|
1031
|
+
this.messages = [];
|
|
1032
|
+
this.attachments = [];
|
|
1033
|
+
this.startKeyHistory = null;
|
|
1034
|
+
this.endOfList = false;
|
|
1035
|
+
this._renderLogin();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// ---- history ------------------------------------------------------
|
|
1039
|
+
async _loadFirstHistoryPage() {
|
|
1040
|
+
try {
|
|
1041
|
+
const res = await getChatHistory(
|
|
1042
|
+
this.skapi,
|
|
1043
|
+
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1044
|
+
{ ascending: false }
|
|
1045
|
+
);
|
|
1046
|
+
this.startKeyHistory = res && res.startKeyHistory;
|
|
1047
|
+
const list = this._filterByClearHorizon((res && res.list) || []);
|
|
1048
|
+
// If the clear horizon has filtered the entire first page,
|
|
1049
|
+
// there are no older entries the user is allowed to see.
|
|
1050
|
+
this.endOfList = !!(res && res.endOfList) || (this._getClearedAt() > 0 && list.length === 0);
|
|
1051
|
+
this.messages = mapHistoryToMessages(list, this.platform);
|
|
1052
|
+
this._renderMessages();
|
|
1053
|
+
this._schedulePendingPoll();
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
console.error('[BunnyQuery] history load failed', err);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async _onHistoryScroll() {
|
|
1060
|
+
const box = this.refs.messagesBox;
|
|
1061
|
+
if (!box || this.endOfList || this._loadingOlder) return;
|
|
1062
|
+
if (box.scrollTop > 80) return;
|
|
1063
|
+
this._loadingOlder = true;
|
|
1064
|
+
try {
|
|
1065
|
+
const res = await getChatHistory(
|
|
1066
|
+
this.skapi,
|
|
1067
|
+
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1068
|
+
{ ascending: false, fetchMore: true, startKeyHistory: this.startKeyHistory }
|
|
1069
|
+
);
|
|
1070
|
+
this.startKeyHistory = res && res.startKeyHistory;
|
|
1071
|
+
const olderList = this._filterByClearHorizon((res && res.list) || []);
|
|
1072
|
+
const clearedAt = this._getClearedAt();
|
|
1073
|
+
// Once we hit a page where every entry is at-or-before the
|
|
1074
|
+
// horizon we know we won't surface anything older.
|
|
1075
|
+
const allFiltered = clearedAt > 0 &&
|
|
1076
|
+
olderList.length === 0 &&
|
|
1077
|
+
Array.isArray(res && res.list) && res.list.length > 0;
|
|
1078
|
+
this.endOfList = !!(res && res.endOfList) || allFiltered;
|
|
1079
|
+
const older = mapHistoryToMessages(olderList, this.platform);
|
|
1080
|
+
this.messages = older.concat(this.messages);
|
|
1081
|
+
this._renderMessages();
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
console.error('[BunnyQuery] older history failed', err);
|
|
1084
|
+
} finally {
|
|
1085
|
+
this._loadingOlder = false;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
_schedulePendingPoll() {
|
|
1090
|
+
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
1091
|
+
if (!this._anyPending()) return;
|
|
1092
|
+
this.pendingTimer = setTimeout(async () => {
|
|
1093
|
+
try {
|
|
1094
|
+
const res = await getChatHistory(
|
|
1095
|
+
this.skapi,
|
|
1096
|
+
{ service: this.serviceId, owner: this.ownerId, platform: this.platform },
|
|
1097
|
+
{ ascending: false }
|
|
1098
|
+
);
|
|
1099
|
+
const fresh = mapHistoryToMessages(
|
|
1100
|
+
this._filterByClearHorizon((res && res.list) || []),
|
|
1101
|
+
this.platform
|
|
1102
|
+
);
|
|
1103
|
+
// Keep older messages whose ids are not on the freshly
|
|
1104
|
+
// fetched first page. The fresh page is appended last so
|
|
1105
|
+
// it always reflects the latest server state.
|
|
1106
|
+
const freshIds = new Set(fresh.filter((m) => m.id).map((m) => m.id));
|
|
1107
|
+
const older = this.messages.filter((m) => m.id && !freshIds.has(m.id));
|
|
1108
|
+
this.messages = older.concat(fresh);
|
|
1109
|
+
this._renderMessages();
|
|
1110
|
+
} catch (err) {
|
|
1111
|
+
console.error('[BunnyQuery] pending poll failed', err);
|
|
1112
|
+
} finally {
|
|
1113
|
+
this._schedulePendingPoll();
|
|
1114
|
+
}
|
|
1115
|
+
}, DEFAULTS.PENDING_POLL_INTERVAL_MS);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ---- attachments --------------------------------------------------
|
|
1119
|
+
_onAttachFromInput(e) {
|
|
1120
|
+
const files = Array.from(e.target.files || []);
|
|
1121
|
+
e.target.value = '';
|
|
1122
|
+
files.forEach((f) => {
|
|
1123
|
+
this.attachments.push({
|
|
1124
|
+
id: 'att_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8),
|
|
1125
|
+
file: f,
|
|
1126
|
+
name: f.name,
|
|
1127
|
+
size: f.size,
|
|
1128
|
+
status: 'pending',
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
this._renderAttachments();
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
_renderAttachments() {
|
|
1135
|
+
const box = this.refs.attachmentsBox;
|
|
1136
|
+
if (!box) return;
|
|
1137
|
+
box.innerHTML = '';
|
|
1138
|
+
if (!this.attachments.length) {
|
|
1139
|
+
box.style.display = 'none';
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
box.style.display = '';
|
|
1143
|
+
this.attachments.forEach((a) => {
|
|
1144
|
+
const chip = $('div', {
|
|
1145
|
+
class:
|
|
1146
|
+
'bq-attachment' +
|
|
1147
|
+
(a.status === 'uploading' ? ' is-uploading' : '') +
|
|
1148
|
+
(a.status === 'done' ? ' is-done' : '') +
|
|
1149
|
+
(a.status === 'error' ? ' is-error' : ''),
|
|
1150
|
+
}, [
|
|
1151
|
+
$('span', { class: 'bq-attachment-name' }, a.name),
|
|
1152
|
+
a.status === 'uploading' && a.progress != null
|
|
1153
|
+
? $('span', { class: 'bq-attachment-meta' }, ` ${a.progress}%`)
|
|
1154
|
+
: null,
|
|
1155
|
+
a.status === 'error'
|
|
1156
|
+
? $('span', { class: 'bq-attachment-meta' }, ' failed')
|
|
1157
|
+
: null,
|
|
1158
|
+
a.status !== 'done'
|
|
1159
|
+
? $('button', {
|
|
1160
|
+
type: 'button',
|
|
1161
|
+
class: 'bq-attachment-remove',
|
|
1162
|
+
onclick: (e) => {
|
|
1163
|
+
e.stopPropagation();
|
|
1164
|
+
this.attachments = this.attachments.filter((x) => x.id !== a.id);
|
|
1165
|
+
this._renderAttachments();
|
|
1166
|
+
},
|
|
1167
|
+
}, '×')
|
|
1168
|
+
: null,
|
|
1169
|
+
]);
|
|
1170
|
+
box.appendChild(chip);
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async _uploadAllAttachments() {
|
|
1175
|
+
if (!this.attachments.length) return [];
|
|
1176
|
+
this.uploading = true;
|
|
1177
|
+
this._updateInputDisabled();
|
|
1178
|
+
const uploaded = [];
|
|
1179
|
+
try {
|
|
1180
|
+
for (const a of this.attachments) {
|
|
1181
|
+
if (a.status === 'done') {
|
|
1182
|
+
if (a.uploadedUrl) uploaded.push({ name: a.name, url: a.uploadedUrl });
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
a.status = 'uploading';
|
|
1186
|
+
a.progress = 0;
|
|
1187
|
+
this._renderAttachments();
|
|
1188
|
+
try {
|
|
1189
|
+
const form = new FormData();
|
|
1190
|
+
form.append(a.name, a.file);
|
|
1191
|
+
const res = await this.skapi.uploadHostFiles(form, {
|
|
1192
|
+
request: 'post-db',
|
|
1193
|
+
progress: (p) => {
|
|
1194
|
+
if (p && typeof p.progress === 'number') {
|
|
1195
|
+
a.progress = Math.round(p.progress);
|
|
1196
|
+
this._renderAttachments();
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
});
|
|
1200
|
+
// After upload, fetch a temporary URL the AI can read.
|
|
1201
|
+
const completed = (res && (res.completed || res)) || [];
|
|
1202
|
+
const first = Array.isArray(completed) ? completed[0] : completed;
|
|
1203
|
+
const path = (first && (first.path || first.url || first.key)) || a.name;
|
|
1204
|
+
const url = await this.skapi.getTemporaryUrl({
|
|
1205
|
+
request: 'get-db',
|
|
1206
|
+
path,
|
|
1207
|
+
expires: DEFAULTS.ATTACHMENT_URL_EXPIRES_SECONDS,
|
|
1208
|
+
generate_temporary_cdn_url: true,
|
|
1209
|
+
});
|
|
1210
|
+
a.uploadedUrl = typeof url === 'string' ? url : (url && (url.url || url.cdn_url));
|
|
1211
|
+
a.status = 'done';
|
|
1212
|
+
uploaded.push({ name: a.name, url: a.uploadedUrl });
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
a.status = 'error';
|
|
1215
|
+
console.error('[BunnyQuery] attachment upload failed', err);
|
|
1216
|
+
}
|
|
1217
|
+
this._renderAttachments();
|
|
1218
|
+
}
|
|
1219
|
+
} finally {
|
|
1220
|
+
this.uploading = false;
|
|
1221
|
+
this._updateInputDisabled();
|
|
1222
|
+
}
|
|
1223
|
+
return uploaded;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// ---- send ---------------------------------------------------------
|
|
1227
|
+
_buildSystemPrompt() {
|
|
1228
|
+
let p = `You are the AI assistant for project "${this.projectName || this.serviceId}".`;
|
|
1229
|
+
if (this.projectDescription) {
|
|
1230
|
+
p += `\nProject description: """${this.projectDescription}"""`;
|
|
1231
|
+
}
|
|
1232
|
+
return p;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async _sendMessage() {
|
|
1236
|
+
if (this.sending || this.uploading || this._anyPending()) return;
|
|
1237
|
+
const text = (this.refs.textarea.value || '').trim();
|
|
1238
|
+
if (!text && !this.attachments.length) return;
|
|
1239
|
+
|
|
1240
|
+
this.sending = true;
|
|
1241
|
+
this._updateInputDisabled();
|
|
1242
|
+
try {
|
|
1243
|
+
let uploaded = [];
|
|
1244
|
+
if (this.attachments.length) {
|
|
1245
|
+
uploaded = await this._uploadAllAttachments();
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
let composed = text;
|
|
1249
|
+
if (uploaded.length) {
|
|
1250
|
+
composed +=
|
|
1251
|
+
(composed ? '\n\n' : '') +
|
|
1252
|
+
'Attached files:\n' +
|
|
1253
|
+
uploaded.map((u) => `- [${u.name}](${u.url})`).join('\n');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Build full message history to send (keep last 20 turns).
|
|
1257
|
+
const history = this.messages
|
|
1258
|
+
.filter((m) => !m.isPending && !m.isError && (m.content || '').length)
|
|
1259
|
+
.map((m) => ({ role: m.role, content: m.content }))
|
|
1260
|
+
.slice(-19);
|
|
1261
|
+
history.push({ role: 'user', content: composed });
|
|
1262
|
+
|
|
1263
|
+
// Optimistically render the user turn + a pending assistant.
|
|
1264
|
+
this.messages.push({ role: 'user', content: composed });
|
|
1265
|
+
this.messages.push({ role: 'assistant', content: '', isPending: true });
|
|
1266
|
+
this._renderMessages();
|
|
1267
|
+
if (this.refs.textarea) {
|
|
1268
|
+
this.refs.textarea.value = '';
|
|
1269
|
+
this._autoGrow(this.refs.textarea);
|
|
1270
|
+
}
|
|
1271
|
+
this.attachments = [];
|
|
1272
|
+
this._renderAttachments();
|
|
1273
|
+
|
|
1274
|
+
const args = {
|
|
1275
|
+
service: this.serviceId,
|
|
1276
|
+
owner: this.ownerId,
|
|
1277
|
+
prompt: composed,
|
|
1278
|
+
messages: history,
|
|
1279
|
+
system: this._buildSystemPrompt(),
|
|
1280
|
+
model: this.model || undefined,
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
const result = this.platform === 'claude'
|
|
1284
|
+
? await buildClaudeRequest(this.skapi, args)
|
|
1285
|
+
: await buildOpenAIRequest(this.skapi, args);
|
|
1286
|
+
|
|
1287
|
+
const responseText = this.platform === 'claude'
|
|
1288
|
+
? extractClaudeText(result)
|
|
1289
|
+
: extractOpenAIText(result);
|
|
1290
|
+
|
|
1291
|
+
// Replace the trailing pending assistant with the result.
|
|
1292
|
+
const last = this.messages[this.messages.length - 1];
|
|
1293
|
+
if (last && last.isPending) {
|
|
1294
|
+
last.isPending = false;
|
|
1295
|
+
last.content = responseText || '(no response)';
|
|
1296
|
+
} else {
|
|
1297
|
+
this.messages.push({ role: 'assistant', content: responseText || '(no response)' });
|
|
1298
|
+
}
|
|
1299
|
+
this._renderMessages();
|
|
1300
|
+
this._schedulePendingPoll();
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
const last = this.messages[this.messages.length - 1];
|
|
1303
|
+
const errMsg = (err && err.message) || String(err);
|
|
1304
|
+
if (last && last.isPending) {
|
|
1305
|
+
last.isPending = false;
|
|
1306
|
+
last.isError = true;
|
|
1307
|
+
last.content = errMsg;
|
|
1308
|
+
} else {
|
|
1309
|
+
this.messages.push({ role: 'assistant', content: errMsg, isError: true });
|
|
1310
|
+
}
|
|
1311
|
+
this._renderMessages();
|
|
1312
|
+
} finally {
|
|
1313
|
+
this.sending = false;
|
|
1314
|
+
this._updateInputDisabled();
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// ---- styles -------------------------------------------------------
|
|
1319
|
+
// Inject a sibling `bunnyquery.css` once per document. We resolve
|
|
1320
|
+
// the URL relative to the script's `src` so the embed can be served
|
|
1321
|
+
// from any CDN without code changes. Override by setting
|
|
1322
|
+
// `BunnyQuery.STYLESHEET_URL` before calling `init()`.
|
|
1323
|
+
_injectStylesheetOnce() {
|
|
1324
|
+
const existing = document.getElementById('bq-embed-styles');
|
|
1325
|
+
if (existing) {
|
|
1326
|
+
// Stylesheet was already injected by a previous instance.
|
|
1327
|
+
// If it finished loading the container is already visible;
|
|
1328
|
+
// if it is still downloading keep this container hidden and
|
|
1329
|
+
// reveal it on the same load/error events.
|
|
1330
|
+
if (!existing._bqLoaded) {
|
|
1331
|
+
this.container.style.visibility = 'hidden';
|
|
1332
|
+
const reveal = () => { this.container.style.visibility = ''; };
|
|
1333
|
+
existing.addEventListener('load', reveal, { once: true });
|
|
1334
|
+
existing.addEventListener('error', reveal, { once: true });
|
|
1335
|
+
}
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
let href = BunnyQuery.STYLESHEET_URL || '';
|
|
1340
|
+
if (!href && _CURRENT_SCRIPT_SRC) {
|
|
1341
|
+
href = _CURRENT_SCRIPT_SRC.replace(/\.js(\?[^#]*)?(#.*)?$/i, '.css$1$2');
|
|
1342
|
+
if (href === _CURRENT_SCRIPT_SRC) {
|
|
1343
|
+
// Script URL had no `.js` suffix (rare); fall back to
|
|
1344
|
+
// a sibling file at the same path.
|
|
1345
|
+
href = _CURRENT_SCRIPT_SRC.replace(/[^/]*$/, 'bunnyquery.css');
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (!href) href = 'bunnyquery.css';
|
|
1349
|
+
|
|
1350
|
+
this.container.style.visibility = 'hidden';
|
|
1351
|
+
const reveal = () => {
|
|
1352
|
+
link._bqLoaded = true;
|
|
1353
|
+
this.container.style.visibility = '';
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
const link = document.createElement('link');
|
|
1357
|
+
link.id = 'bq-embed-styles';
|
|
1358
|
+
link.rel = 'stylesheet';
|
|
1359
|
+
link.href = href;
|
|
1360
|
+
link.addEventListener('load', reveal, { once: true });
|
|
1361
|
+
link.addEventListener('error', reveal, { once: true });
|
|
1362
|
+
document.head.appendChild(link);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
BunnyQuery.MCP_BASE_URL = DEFAULTS.MCP_BASE_URL;
|
|
1367
|
+
|
|
1368
|
+
global.BunnyQuery = BunnyQuery;
|
|
1369
|
+
})(typeof window !== 'undefined' ? window : this);
|