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 +51 -0
- package/bunnyquery.js +344 -10
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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
|
-
$('
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|