bunnyquery 1.0.7 → 1.0.9

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.css CHANGED
@@ -143,6 +143,23 @@
143
143
  white-space: nowrap;
144
144
  }
145
145
 
146
+ .bq-ai-status {
147
+ display: inline-flex;
148
+ align-items: center;
149
+ max-width: min(56vw, 26rem);
150
+ padding: 0.12rem 0.45rem;
151
+ border: 1px solid var(--bq-line);
152
+ background: #f7f7f7;
153
+ color: var(--bq-muted);
154
+ font-size: 0.72rem;
155
+ font-weight: 600;
156
+ letter-spacing: 0.01em;
157
+ line-height: 1.25;
158
+ overflow: hidden;
159
+ text-overflow: ellipsis;
160
+ white-space: nowrap;
161
+ }
162
+
146
163
  .bq-title-right {
147
164
  display: flex;
148
165
  align-items: center;
@@ -185,6 +202,40 @@
185
202
  font-size: 0.85rem;
186
203
  }
187
204
 
205
+ .bq-link-button {
206
+ display: inline-flex;
207
+ align-items: center;
208
+ gap: 0.35rem;
209
+ padding: 0.35rem 0.65rem;
210
+ margin: 0.2rem 0.2rem 0.2rem 0;
211
+ border: 1px solid var(--bq-line);
212
+ border-radius: 0.15rem;
213
+ background: var(--bq-paper);
214
+ color: var(--bq-pink);
215
+ font-size: 0.78rem;
216
+ font-weight: 600;
217
+ text-decoration: none;
218
+ white-space: nowrap;
219
+ overflow: hidden;
220
+ text-overflow: ellipsis;
221
+ max-width: 100%;
222
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
223
+ cursor: pointer;
224
+ }
225
+
226
+ .bq-link-button:hover {
227
+ background: var(--bq-hover-bg);
228
+ border-color: var(--bq-pink);
229
+ }
230
+
231
+ .bq-link-button:visited {
232
+ color: #a01848;
233
+ }
234
+
235
+ .bq-link-button:visited:hover {
236
+ color: var(--bq-pink);
237
+ }
238
+
188
239
  .bq-link {
189
240
  color: var(--bq-pink);
190
241
  text-decoration: none;
package/bunnyquery.js CHANGED
@@ -50,7 +50,7 @@
50
50
  OPENAI_RESPONSES_API_URL: 'https://api.openai.com/v1/responses',
51
51
  MAX_TOKENS: 25000,
52
52
  POLL_INTERVAL: 1500,
53
- PENDING_POLL_INTERVAL_MS: 4000,
53
+ PENDING_POLL_INTERVAL_MS: 1000,
54
54
  ATTACHMENT_URL_EXPIRES_SECONDS: 600, // 10 min
55
55
  };
56
56
 
@@ -66,6 +66,269 @@
66
66
  // init()) is reflected in subsequent send/auth calls.
67
67
  const _mcpBaseUrl = () => (global.BunnyQuery && global.BunnyQuery.MCP_BASE_URL) || DEFAULTS.MCP_BASE_URL;
68
68
 
69
+ // ----- link parsing helpers -----
70
+ // Matches markdown links `[label](url)` and bare https URLs.
71
+ // Handles balanced parentheses in URLs (e.g. for filenames like image (1).png).
72
+ const LINK_REGEX = /\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
73
+
74
+ const normalizeBareUrl = (url) => {
75
+ if (!url) return '';
76
+ let out = url;
77
+
78
+ // Trim sentence punctuation that often trails pasted URLs.
79
+ out = out.replace(/[.,;:!?]+$/, '');
80
+
81
+ // Remove unmatched closing wrappers but preserve balanced pairs.
82
+ const trimUnmatched = (openCh, closeCh) => {
83
+ while (out.endsWith(closeCh)) {
84
+ const openCount = (out.match(new RegExp('\\' + openCh, 'g')) || []).length;
85
+ const closeCount = (out.match(new RegExp('\\' + closeCh, 'g')) || []).length;
86
+ if (closeCount > openCount) {
87
+ out = out.slice(0, -1);
88
+ } else {
89
+ break;
90
+ }
91
+ }
92
+ };
93
+
94
+ trimUnmatched('(', ')');
95
+ trimUnmatched('[', ']');
96
+ trimUnmatched('{', '}');
97
+
98
+ return out;
99
+ };
100
+
101
+ const truncateLabel = (label, maxLen = 50, options = {}) => {
102
+ const { stripTrailing = false } = options;
103
+ if (!label) return '';
104
+ const trimmed = label.trim();
105
+ const unwrapped = trimmed
106
+ .replace(/^\*\*(.*)\*\*$/, '$1')
107
+ .replace(/^__(.*)__$/, '$1');
108
+ const cleaned = stripTrailing
109
+ ? unwrapped.replace(/[`'"*\]\>.,;:!?]+$/, '')
110
+ : unwrapped;
111
+ if (cleaned.length <= maxLen) return cleaned;
112
+ return cleaned.slice(0, maxLen - 1) + '\u2026'; // ellipsis
113
+ };
114
+
115
+ // Helper: parse markdown links from a text segment
116
+ const parseMsgLinks = (text, offsetInContent, parts) => {
117
+ LINK_REGEX.lastIndex = 0;
118
+ let last = 0;
119
+ let lm;
120
+ while ((lm = LINK_REGEX.exec(text)) !== null) {
121
+ if (lm.index > last) {
122
+ const prevText = text.slice(last, lm.index);
123
+ if (parts.length && parts[parts.length - 1].type === 'text') {
124
+ parts[parts.length - 1].text += prevText;
125
+ } else {
126
+ parts.push({ type: 'text', text: prevText });
127
+ }
128
+ }
129
+
130
+ // Markdown link: [label](url)
131
+ if (lm[1] && lm[2]) {
132
+ const label = truncateLabel(lm[1]);
133
+ const href = lm[2];
134
+ parts.push({
135
+ type: 'link',
136
+ label: label,
137
+ href: href,
138
+ });
139
+ }
140
+ // Bare URL
141
+ else if (lm[3]) {
142
+ const href = normalizeBareUrl(lm[3]);
143
+ const label = truncateLabel(href);
144
+ parts.push({
145
+ type: 'link',
146
+ label: label,
147
+ href: href,
148
+ });
149
+ }
150
+
151
+ last = lm.index + lm[0].length;
152
+ }
153
+
154
+ if (last < text.length) {
155
+ const tail = text.slice(last);
156
+ if (parts.length && parts[parts.length - 1].type === 'text') {
157
+ parts[parts.length - 1].text += tail;
158
+ } else {
159
+ parts.push({ type: 'text', text: tail });
160
+ }
161
+ }
162
+ };
163
+
164
+ // ----- file-fence helpers (mirrors agent.vue) ---------------------------
165
+ // Bare language tags we treat as downloadable files. Maps to the
166
+ // extension used in the synthesized filename. Mirrors the file types the
167
+ // host agent (agent.vue) is known to emit as ```filename.ext fences.
168
+ const FENCE_LANGUAGE_EXTENSIONS = {
169
+ html: 'html',
170
+ htm: 'html',
171
+ csv: 'csv',
172
+ tsv: 'tsv',
173
+ json: 'json',
174
+ xml: 'xml',
175
+ svg: 'svg',
176
+ md: 'md',
177
+ markdown: 'md',
178
+ yaml: 'yaml',
179
+ yml: 'yml',
180
+ txt: 'txt',
181
+ sql: 'sql',
182
+ css: 'css',
183
+ };
184
+
185
+ const FENCE_MIME_TYPES = {
186
+ html: 'text/html',
187
+ csv: 'text/csv',
188
+ tsv: 'text/tab-separated-values',
189
+ json: 'application/json',
190
+ xml: 'application/xml',
191
+ svg: 'image/svg+xml',
192
+ md: 'text/markdown',
193
+ yaml: 'text/yaml',
194
+ yml: 'text/yaml',
195
+ txt: 'text/plain',
196
+ sql: 'application/sql',
197
+ css: 'text/css',
198
+ };
199
+
200
+ // Closed fences with `filename.ext` after the opening backticks.
201
+ const FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n([\s\S]*?)```/g;
202
+ // Closed fences whose info-string is one of the known language tags
203
+ // above. The model often emits ```html ... ``` or ```csv ... ``` instead
204
+ // of a full filename — surface those as downloadable files too.
205
+ const FENCE_LANG_RE = new RegExp(
206
+ '```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n([\\s\\S]*?)```',
207
+ 'gi'
208
+ );
209
+ // Open (still-streaming) fences for either pattern, used to suppress the
210
+ // raw fence text mid-typewriter.
211
+ const OPEN_FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n?/;
212
+ const OPEN_FENCE_LANG_RE = new RegExp(
213
+ '```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n?',
214
+ 'i'
215
+ );
216
+
217
+ // Cache blob URLs keyed by (filename + content) so re-renders don't leak
218
+ // a fresh ObjectURL on every tick of streaming text.
219
+ const _fileBlobCache = new Map();
220
+ const _getOrCreateFileHref = (filename, body) => {
221
+ const key = filename + '\u0000' + body;
222
+ const existing = _fileBlobCache.get(key);
223
+ if (existing) return existing;
224
+ const ext = (filename.split('.').pop() || '').toLowerCase();
225
+ const type = FENCE_MIME_TYPES[ext] || 'text/plain';
226
+ const blob = new Blob([body], { type });
227
+ const href = URL.createObjectURL(blob);
228
+ _fileBlobCache.set(key, href);
229
+ return href;
230
+ };
231
+
232
+ // Counter used to mint stable filenames for bare-language fences within
233
+ // a single rendered message ("download.html", "download-2.html", ...).
234
+ // Keyed by message identity so re-renders of the same message reuse the
235
+ // same names (which lets the blob cache hit).
236
+ const _bareFenceNameCounters = new WeakMap();
237
+ const _bareFilenameFor = (msgKey, ext, occurrence) => {
238
+ const base = 'download';
239
+ return occurrence === 0 ? `${base}.${ext}` : `${base}-${occurrence + 1}.${ext}`;
240
+ };
241
+
242
+ // Split message content into a sequence of {type:'text'|'file'} parts,
243
+ // mirroring agent.vue's parseMsgParts behavior for fenced code blocks.
244
+ // Bare-language fences (```html, ```csv, ...) are also lifted into
245
+ // downloadable file parts with a synthesized filename.
246
+ const parseMsgParts = (content, msgKey, isTyping) => {
247
+ const parts = [];
248
+ if (!content) return parts;
249
+
250
+ // First pass: extract file fences (existing logic)
251
+ const fenceParts = [];
252
+ const fenceMatches = [];
253
+ FENCE_FILENAME_RE.lastIndex = 0;
254
+ let mm;
255
+ while ((mm = FENCE_FILENAME_RE.exec(content)) !== null) {
256
+ fenceMatches.push({ index: mm.index, length: mm[0].length, filename: mm[1], body: mm[2], kind: 'filename' });
257
+ }
258
+ FENCE_LANG_RE.lastIndex = 0;
259
+ let bareCounters = _bareFenceNameCounters.get(msgKey);
260
+ if (!bareCounters) {
261
+ bareCounters = {};
262
+ if (msgKey) _bareFenceNameCounters.set(msgKey, bareCounters);
263
+ }
264
+ const bareSeen = {};
265
+ while ((mm = FENCE_LANG_RE.exec(content)) !== null) {
266
+ const lang = mm[1].toLowerCase();
267
+ const ext = FENCE_LANGUAGE_EXTENSIONS[lang];
268
+ const occurrence = bareSeen[ext] || 0;
269
+ bareSeen[ext] = occurrence + 1;
270
+ fenceMatches.push({
271
+ index: mm.index,
272
+ length: mm[0].length,
273
+ filename: _bareFilenameFor(msgKey, ext, occurrence),
274
+ body: mm[2],
275
+ kind: 'lang',
276
+ });
277
+ }
278
+ fenceMatches.sort((a, b) => a.index - b.index || (a.kind === 'filename' ? -1 : 1));
279
+ const dedupFences = [];
280
+ for (const m of fenceMatches) {
281
+ if (dedupFences.length && dedupFences[dedupFences.length - 1].index === m.index) continue;
282
+ dedupFences.push(m);
283
+ }
284
+
285
+ // Second pass: extract links from text segments between fences
286
+ let lastFenceEnd = 0;
287
+ for (const fence of dedupFences) {
288
+ // Parse links in text before this fence
289
+ const beforeFence = content.slice(lastFenceEnd, fence.index);
290
+ if (beforeFence) {
291
+ parseMsgLinks(beforeFence, lastFenceEnd, parts);
292
+ }
293
+ // Add the fence
294
+ parts.push({
295
+ type: 'file',
296
+ filename: fence.filename,
297
+ href: _getOrCreateFileHref(fence.filename, fence.body),
298
+ });
299
+ lastFenceEnd = fence.index + fence.length;
300
+ }
301
+
302
+ // Parse links in remaining text after last fence
303
+ let tail = content.slice(lastFenceEnd);
304
+ if (tail) {
305
+ parseMsgLinks(tail, lastFenceEnd, parts);
306
+ }
307
+
308
+ // Mid-typewriter: hide raw source for a still-open fence so the user
309
+ // doesn't watch literal code stream by character-by-character.
310
+ if (isTyping && tail) {
311
+ const fnOpen = tail.match(OPEN_FENCE_FILENAME_RE);
312
+ const langOpen = tail.match(OPEN_FENCE_LANG_RE);
313
+ const open = fnOpen && (!langOpen || fnOpen.index <= langOpen.index)
314
+ ? { match: fnOpen, label: fnOpen[1] }
315
+ : langOpen
316
+ ? { match: langOpen, label: 'download.' + FENCE_LANGUAGE_EXTENSIONS[langOpen[1].toLowerCase()] }
317
+ : null;
318
+ if (open) {
319
+ const before = tail.slice(0, open.match.index);
320
+ if (before && parts.length && parts[parts.length - 1].type === 'text') {
321
+ parts[parts.length - 1].text += '\n[generating ' + open.label + '...]';
322
+ } else if (before) {
323
+ parts.push({ type: 'text', text: before + '\n[generating ' + open.label + '...]' });
324
+ } else {
325
+ parts.push({ type: 'text', text: '\n[generating ' + open.label + '...]' });
326
+ }
327
+ }
328
+ }
329
+ return parts;
330
+ };
331
+
69
332
  // ----- helpers ----------------------------------------------------------
70
333
  const $ = (tag, attrs, children) => {
71
334
  const el = document.createElement(tag);
@@ -359,7 +622,7 @@
359
622
  const msgList = messages && messages.length
360
623
  ? messages
361
624
  : [{ role: 'user', content: prompt }];
362
- return skapi.clientSecretRequest({
625
+ const request = {
363
626
  clientSecretName: 'claude',
364
627
  poll: DEFAULTS.POLL_INTERVAL,
365
628
  queue: service,
@@ -399,7 +662,8 @@
399
662
  },
400
663
  ],
401
664
  },
402
- });
665
+ };
666
+ return skapi.clientSecretRequest(request);
403
667
  }
404
668
 
405
669
  function buildOpenAIRequest(skapi, { service, owner, prompt, messages, system, model }) {
@@ -639,6 +903,8 @@
639
903
  this.pendingTimer = null;
640
904
  this._pollingPending = false;
641
905
  this._loadingOlder = false;
906
+ this._loadingFirstHistory = false;
907
+ this._initialHistoryLoaded = false;
642
908
 
643
909
  this.refs = {};
644
910
 
@@ -664,6 +930,16 @@
664
930
 
665
931
  // ---- bootstrap order ----------------------------------------------
666
932
  async _bootstrap() {
933
+ // 0. Refresh connection info to ensure latest configuration.
934
+ try {
935
+ const info = await this.skapi.getConnectionInfo({ refresh: true });
936
+ const ai = String(info.ai_agent || '').split('#');
937
+ this.platform = (ai[0] || '').toLowerCase(); // 'claude' | 'openai'
938
+ this.model = ai[1] || '';
939
+ } catch (_) {
940
+ // Non-fatal; continue with existing connection info
941
+ }
942
+
667
943
  // 1. Validate that the project has a configured AI platform.
668
944
  if (!this.platform) {
669
945
  this._renderFatal('This project does not have an AI agent configured yet. Please ask the project owner to set up an AI agent in the project settings page.');
@@ -805,8 +1081,20 @@
805
1081
  const titleText = this.projectName
806
1082
  ? this.projectName
807
1083
  : 'BunnyQuery';
1084
+ const platformLabel = this.platform === 'claude'
1085
+ ? 'Claude'
1086
+ : this.platform === 'openai'
1087
+ ? 'OpenAI'
1088
+ : 'Unknown';
1089
+ const modelLabel = this.model || (
1090
+ this.platform === 'claude'
1091
+ ? DEFAULTS.DEFAULT_CLAUDE_MODEL
1092
+ : this.platform === 'openai'
1093
+ ? DEFAULTS.DEFAULT_OPENAI_MODEL
1094
+ : 'N/A'
1095
+ );
808
1096
  const titleLeft = $('div', { class: 'bq-title-left' }, [
809
- $('h2', null, titleText),
1097
+ $('span', { class: 'bq-ai-status', title: `AI: ${platformLabel} / ${modelLabel}` }, `${platformLabel} · ${modelLabel}`),
810
1098
  ]);
811
1099
 
812
1100
  const clearIcon = $('button', {
@@ -849,7 +1137,7 @@
849
1137
  const textarea = $('textarea', {
850
1138
  class: 'bq-input',
851
1139
  rows: '1',
852
- placeholder: 'Ask anything about the project...',
1140
+ placeholder: `Ask anything about ${this.projectName || 'the project'}...`,
853
1141
  oninput: (e) => this._autoGrow(e.target),
854
1142
  onkeydown: (e) => {
855
1143
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -901,7 +1189,15 @@
901
1189
  box.scrollHeight - box.scrollTop - box.clientHeight < 80;
902
1190
  box.innerHTML = '';
903
1191
 
904
- if (!this.messages.length) {
1192
+ if (!this._initialHistoryLoaded) {
1193
+ box.appendChild(
1194
+ $('div', { class: 'bq-message is-assistant bq-empty-greeting' }, [
1195
+ $('div', { class: 'bq-bubble' }, [
1196
+ $('span', { class: 'bq-loader' }, 'Loading chat history'),
1197
+ ]),
1198
+ ])
1199
+ );
1200
+ } else if (!this.messages.length) {
905
1201
  box.appendChild(
906
1202
  $('div', { class: 'bq-message is-assistant bq-empty-greeting' }, [
907
1203
  $('div', { class: 'bq-bubble' },
@@ -916,7 +1212,38 @@
916
1212
  if (msg.isPending) {
917
1213
  bubble.appendChild($('span', { class: 'bq-loader' }, 'Thinking'));
918
1214
  } else {
919
- bubble.textContent = msg.content || '';
1215
+ // Render fenced file blocks (```filename.ext``` or
1216
+ // ```html / ```csv / etc.) as inline download links;
1217
+ // everything else stays plain text. Mirrors the
1218
+ // behavior of agent.vue's parseMsgParts.
1219
+ const parts = parseMsgParts(msg.content || '', msg, !!this.typing);
1220
+ if (!parts.length) {
1221
+ bubble.textContent = msg.content || '';
1222
+ } else {
1223
+ for (const part of parts) {
1224
+ if (part.type === 'file') {
1225
+ const a = $('a', {
1226
+ class: 'bq-file-download',
1227
+ href: part.href,
1228
+ target: '_blank',
1229
+ rel: 'noopener noreferrer',
1230
+ download: part.filename,
1231
+ }, '\u2197 ' + part.filename);
1232
+ bubble.appendChild(a);
1233
+ } else if (part.type === 'link') {
1234
+ const a = $('a', {
1235
+ class: 'bq-link-button',
1236
+ href: part.href,
1237
+ target: '_blank',
1238
+ rel: 'noopener noreferrer',
1239
+ title: part.href,
1240
+ }, '\u2197 ' + part.label);
1241
+ bubble.appendChild(a);
1242
+ } else {
1243
+ bubble.appendChild(document.createTextNode(part.text));
1244
+ }
1245
+ }
1246
+ }
920
1247
  }
921
1248
  const wrapper = $('div', {
922
1249
  class:
@@ -944,12 +1271,12 @@
944
1271
  }
945
1272
 
946
1273
  _updateInputDisabled() {
947
- const busy = this.sending || this.uploading || this._anyPending();
1274
+ const busy = this.sending || this.uploading || this._anyPending() || this._loadingFirstHistory;
948
1275
  if (this.refs.textarea) {
949
1276
  this.refs.textarea.disabled = busy;
950
1277
  this.refs.textarea.placeholder = busy
951
1278
  ? 'Request in process. Please wait...'
952
- : 'Ask anything about the project...';
1279
+ : `Ask anything about ${this.projectName || 'the project'}...`;
953
1280
  }
954
1281
  if (this.refs.sendBtn) this.refs.sendBtn.disabled = busy;
955
1282
  if (this.refs.attachBtn) this.refs.attachBtn.disabled = busy;
@@ -1134,6 +1461,10 @@
1134
1461
 
1135
1462
  // ---- history ------------------------------------------------------
1136
1463
  async _loadFirstHistoryPage() {
1464
+ this._loadingFirstHistory = true;
1465
+ this._initialHistoryLoaded = false;
1466
+ this._renderMessages();
1467
+
1137
1468
  const fetchPage = () => getChatHistory(
1138
1469
  this.skapi,
1139
1470
  { service: this.serviceId, owner: this.ownerId, platform: this.platform },
@@ -1158,10 +1489,13 @@
1158
1489
  // there are no older entries the user is allowed to see.
1159
1490
  this.endOfList = !!(res && res.endOfList) || (this._getClearedAt() > 0 && list.length === 0);
1160
1491
  this.messages = mapHistoryToMessages(list, this.platform);
1161
- this._renderMessages();
1162
1492
  this._schedulePendingPoll();
1163
1493
  } catch (err) {
1164
1494
  console.error('[BunnyQuery] history load failed', err);
1495
+ } finally {
1496
+ this._loadingFirstHistory = false;
1497
+ this._initialHistoryLoaded = true;
1498
+ this._renderMessages();
1165
1499
  }
1166
1500
  }
1167
1501
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "BunnyQuery AI chat client",
5
5
  "main": "bunnyquery.js",
6
6
  "files": [