bunnyquery 1.0.8 → 1.0.10

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,101 @@
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
+
69
164
  // ----- file-fence helpers (mirrors agent.vue) ---------------------------
70
165
  // Bare language tags we treat as downloadable files. Maps to the
71
166
  // extension used in the synthesized filename. Mirrors the file types the
@@ -152,12 +247,13 @@
152
247
  const parts = [];
153
248
  if (!content) return parts;
154
249
 
155
- // Combine matches from both fence regexes, sorted by position.
156
- const matches = [];
250
+ // First pass: extract file fences (existing logic)
251
+ const fenceParts = [];
252
+ const fenceMatches = [];
157
253
  FENCE_FILENAME_RE.lastIndex = 0;
158
254
  let mm;
159
255
  while ((mm = FENCE_FILENAME_RE.exec(content)) !== null) {
160
- matches.push({ index: mm.index, length: mm[0].length, filename: mm[1], body: mm[2], kind: 'filename' });
256
+ fenceMatches.push({ index: mm.index, length: mm[0].length, filename: mm[1], body: mm[2], kind: 'filename' });
161
257
  }
162
258
  FENCE_LANG_RE.lastIndex = 0;
163
259
  let bareCounters = _bareFenceNameCounters.get(msgKey);
@@ -171,7 +267,7 @@
171
267
  const ext = FENCE_LANGUAGE_EXTENSIONS[lang];
172
268
  const occurrence = bareSeen[ext] || 0;
173
269
  bareSeen[ext] = occurrence + 1;
174
- matches.push({
270
+ fenceMatches.push({
175
271
  index: mm.index,
176
272
  length: mm[0].length,
177
273
  filename: _bareFilenameFor(msgKey, ext, occurrence),
@@ -179,30 +275,35 @@
179
275
  kind: 'lang',
180
276
  });
181
277
  }
182
- // If both regexes matched the same span (a `filename.ext` fence is
183
- // also a bare-language fence when the language coincides), prefer
184
- // the filename match.
185
- matches.sort((a, b) => a.index - b.index || (a.kind === 'filename' ? -1 : 1));
186
- const dedup = [];
187
- for (const m of matches) {
188
- if (dedup.length && dedup[dedup.length - 1].index === m.index) continue;
189
- dedup.push(m);
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);
190
283
  }
191
284
 
192
- let last = 0;
193
- for (const m of dedup) {
194
- if (m.index > last) {
195
- parts.push({ type: 'text', text: content.slice(last, m.index) });
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);
196
292
  }
293
+ // Add the fence
197
294
  parts.push({
198
295
  type: 'file',
199
- filename: m.filename,
200
- href: _getOrCreateFileHref(m.filename, m.body),
296
+ filename: fence.filename,
297
+ href: _getOrCreateFileHref(fence.filename, fence.body),
201
298
  });
202
- last = m.index + m.length;
299
+ lastFenceEnd = fence.index + fence.length;
203
300
  }
204
301
 
205
- let tail = content.slice(last);
302
+ // Parse links in remaining text after last fence
303
+ let tail = content.slice(lastFenceEnd);
304
+ if (tail) {
305
+ parseMsgLinks(tail, lastFenceEnd, parts);
306
+ }
206
307
 
207
308
  // Mid-typewriter: hide raw source for a still-open fence so the user
208
309
  // doesn't watch literal code stream by character-by-character.
@@ -216,12 +317,15 @@
216
317
  : null;
217
318
  if (open) {
218
319
  const before = tail.slice(0, open.match.index);
219
- if (before) parts.push({ type: 'text', text: before });
220
- parts.push({ type: 'text', text: `\n[generating ${open.label}...]` });
221
- tail = '';
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
+ }
222
327
  }
223
328
  }
224
- if (tail) parts.push({ type: 'text', text: tail });
225
329
  return parts;
226
330
  };
227
331
 
@@ -518,7 +622,7 @@
518
622
  const msgList = messages && messages.length
519
623
  ? messages
520
624
  : [{ role: 'user', content: prompt }];
521
- return skapi.clientSecretRequest({
625
+ const request = {
522
626
  clientSecretName: 'claude',
523
627
  poll: DEFAULTS.POLL_INTERVAL,
524
628
  queue: service,
@@ -558,7 +662,8 @@
558
662
  },
559
663
  ],
560
664
  },
561
- });
665
+ };
666
+ return skapi.clientSecretRequest(request);
562
667
  }
563
668
 
564
669
  function buildOpenAIRequest(skapi, { service, owner, prompt, messages, system, model }) {
@@ -798,6 +903,8 @@
798
903
  this.pendingTimer = null;
799
904
  this._pollingPending = false;
800
905
  this._loadingOlder = false;
906
+ this._loadingFirstHistory = false;
907
+ this._initialHistoryLoaded = false;
801
908
 
802
909
  this.refs = {};
803
910
 
@@ -823,6 +930,16 @@
823
930
 
824
931
  // ---- bootstrap order ----------------------------------------------
825
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
+
826
943
  // 1. Validate that the project has a configured AI platform.
827
944
  if (!this.platform) {
828
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.');
@@ -964,8 +1081,20 @@
964
1081
  const titleText = this.projectName
965
1082
  ? this.projectName
966
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
+ );
967
1096
  const titleLeft = $('div', { class: 'bq-title-left' }, [
968
- $('h2', null, titleText),
1097
+ $('span', { class: 'bq-ai-status', title: `AI: ${platformLabel} / ${modelLabel}` }, `${platformLabel} · ${modelLabel}`),
969
1098
  ]);
970
1099
 
971
1100
  const clearIcon = $('button', {
@@ -1008,7 +1137,7 @@
1008
1137
  const textarea = $('textarea', {
1009
1138
  class: 'bq-input',
1010
1139
  rows: '1',
1011
- placeholder: 'Ask anything about the project...',
1140
+ placeholder: `Ask anything about ${this.projectName || 'the project'}...`,
1012
1141
  oninput: (e) => this._autoGrow(e.target),
1013
1142
  onkeydown: (e) => {
1014
1143
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -1060,7 +1189,15 @@
1060
1189
  box.scrollHeight - box.scrollTop - box.clientHeight < 80;
1061
1190
  box.innerHTML = '';
1062
1191
 
1063
- 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) {
1064
1201
  box.appendChild(
1065
1202
  $('div', { class: 'bq-message is-assistant bq-empty-greeting' }, [
1066
1203
  $('div', { class: 'bq-bubble' },
@@ -1093,6 +1230,15 @@
1093
1230
  download: part.filename,
1094
1231
  }, '\u2197 ' + part.filename);
1095
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);
1096
1242
  } else {
1097
1243
  bubble.appendChild(document.createTextNode(part.text));
1098
1244
  }
@@ -1108,8 +1254,17 @@
1108
1254
  box.appendChild(wrapper);
1109
1255
  });
1110
1256
 
1111
- if (stickToBottom) {
1112
- box.scrollTop = box.scrollHeight;
1257
+
1258
+ // Scroll to bottom if stickToBottom, or if the last message is a resolved assistant response
1259
+ const lastMsg = this.messages[this.messages.length - 1];
1260
+ if (
1261
+ stickToBottom ||
1262
+ (lastMsg && lastMsg.role === 'assistant' && !lastMsg.isPending && !lastMsg.isError)
1263
+ ) {
1264
+ // Use setTimeout to ensure DOM is updated before scrolling
1265
+ setTimeout(() => {
1266
+ box.scrollTop = box.scrollHeight;
1267
+ }, 0);
1113
1268
  }
1114
1269
 
1115
1270
  // Hide the clear-history icon when there's nothing to clear.
@@ -1125,12 +1280,12 @@
1125
1280
  }
1126
1281
 
1127
1282
  _updateInputDisabled() {
1128
- const busy = this.sending || this.uploading || this._anyPending();
1283
+ const busy = this.sending || this.uploading || this._anyPending() || this._loadingFirstHistory;
1129
1284
  if (this.refs.textarea) {
1130
1285
  this.refs.textarea.disabled = busy;
1131
1286
  this.refs.textarea.placeholder = busy
1132
1287
  ? 'Request in process. Please wait...'
1133
- : 'Ask anything about the project...';
1288
+ : `Ask anything about ${this.projectName || 'the project'}...`;
1134
1289
  }
1135
1290
  if (this.refs.sendBtn) this.refs.sendBtn.disabled = busy;
1136
1291
  if (this.refs.attachBtn) this.refs.attachBtn.disabled = busy;
@@ -1315,6 +1470,10 @@
1315
1470
 
1316
1471
  // ---- history ------------------------------------------------------
1317
1472
  async _loadFirstHistoryPage() {
1473
+ this._loadingFirstHistory = true;
1474
+ this._initialHistoryLoaded = false;
1475
+ this._renderMessages();
1476
+
1318
1477
  const fetchPage = () => getChatHistory(
1319
1478
  this.skapi,
1320
1479
  { service: this.serviceId, owner: this.ownerId, platform: this.platform },
@@ -1339,10 +1498,13 @@
1339
1498
  // there are no older entries the user is allowed to see.
1340
1499
  this.endOfList = !!(res && res.endOfList) || (this._getClearedAt() > 0 && list.length === 0);
1341
1500
  this.messages = mapHistoryToMessages(list, this.platform);
1342
- this._renderMessages();
1343
1501
  this._schedulePendingPoll();
1344
1502
  } catch (err) {
1345
1503
  console.error('[BunnyQuery] history load failed', err);
1504
+ } finally {
1505
+ this._loadingFirstHistory = false;
1506
+ this._initialHistoryLoaded = true;
1507
+ this._renderMessages();
1346
1508
  }
1347
1509
  }
1348
1510
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "BunnyQuery AI chat client",
5
5
  "main": "bunnyquery.js",
6
6
  "files": [