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/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);