bunnyquery 1.0.9 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -60
- package/bunnyquery.css +417 -56
- package/bunnyquery.js +980 -193
- package/package.json +1 -1
package/bunnyquery.js
CHANGED
|
@@ -66,99 +66,257 @@
|
|
|
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
|
-
// -----
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
const
|
|
69
|
+
// ----- markdown loader (lazy) ------------------------------------------
|
|
70
|
+
// Pulled from CDN on first use. While loading, parseMsgParts renders a
|
|
71
|
+
// plain-text fallback; once `marked` resolves the instance re-renders.
|
|
72
|
+
const MARKED_CDN_URL = 'https://cdn.jsdelivr.net/npm/marked@15.0.4/marked.min.js';
|
|
73
|
+
let _markedReadyPromise = null;
|
|
74
|
+
const _getMarked = () => {
|
|
75
|
+
if (typeof global.marked !== 'undefined' && global.marked && global.marked.parse) {
|
|
76
|
+
return Promise.resolve(global.marked);
|
|
77
|
+
}
|
|
78
|
+
if (_markedReadyPromise) return _markedReadyPromise;
|
|
79
|
+
_markedReadyPromise = new Promise((resolve, reject) => {
|
|
80
|
+
const existing = document.querySelector('script[data-bq-marked]');
|
|
81
|
+
if (existing) {
|
|
82
|
+
existing.addEventListener('load', () => resolve(global.marked), { once: true });
|
|
83
|
+
existing.addEventListener('error', reject, { once: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const s = document.createElement('script');
|
|
87
|
+
s.src = MARKED_CDN_URL;
|
|
88
|
+
s.async = true;
|
|
89
|
+
s.dataset.bqMarked = '1';
|
|
90
|
+
s.addEventListener('load', () => resolve(global.marked), { once: true });
|
|
91
|
+
s.addEventListener('error', reject, { once: true });
|
|
92
|
+
document.head.appendChild(s);
|
|
93
|
+
}).catch((err) => {
|
|
94
|
+
console.error('[BunnyQuery] failed to load marked', err);
|
|
95
|
+
_markedReadyPromise = null;
|
|
96
|
+
return null;
|
|
97
|
+
});
|
|
98
|
+
return _markedReadyPromise;
|
|
99
|
+
};
|
|
100
|
+
// Eager kick-off so the first message render almost always has marked
|
|
101
|
+
// ready by the time history finishes loading.
|
|
102
|
+
if (typeof window !== 'undefined') {
|
|
103
|
+
try { _getMarked(); } catch (_) { /* noop */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ----- link parsing helpers (mirrors agent.vue) ------------------------
|
|
107
|
+
// createInlineLinkRegex groups (in order):
|
|
108
|
+
// 1: `src::<token>` — model-emitted reference to a db file (path or URL)
|
|
109
|
+
// 2: markdown link label where href IS an http(s) URL
|
|
110
|
+
// 3: markdown link href (URL)
|
|
111
|
+
// 4: markdown link label where href is NOT a URL (storage path)
|
|
112
|
+
// 5: markdown link path (non-URL) — treated like `src::<path>`
|
|
113
|
+
// 6: bare http(s) URL
|
|
114
|
+
const createInlineLinkRegex = () =>
|
|
115
|
+
/src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
|
|
116
|
+
|
|
117
|
+
const EXPIRED_ATTACHMENT_URL_HOST = '_expired_.url';
|
|
118
|
+
const EXPIRED_ATTACHMENT_URL_ORIGIN = `https://${EXPIRED_ATTACHMENT_URL_HOST}`;
|
|
119
|
+
|
|
120
|
+
const _safeDecode = (v) => {
|
|
121
|
+
try { return decodeURIComponent(v); } catch { return v; }
|
|
122
|
+
};
|
|
73
123
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
const _encodePathSegments = (p) =>
|
|
125
|
+
p.split('/').filter(Boolean).map((s) => encodeURIComponent(s)).join('/');
|
|
126
|
+
|
|
127
|
+
const normalizeAttachmentPathCandidate = (value) =>
|
|
128
|
+
_safeDecode((value || '').trim())
|
|
129
|
+
.replace(/\\/g, '/')
|
|
130
|
+
.replace(/^\/+/, '')
|
|
131
|
+
.replace(/\/+/g, '/');
|
|
132
|
+
|
|
133
|
+
const extractRemotePathFromAttachmentHref = (href, serviceId) => {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = new URL(href);
|
|
136
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
137
|
+
const path = normalizeAttachmentPathCandidate(parsed.pathname || '');
|
|
138
|
+
const segs = path.split('/').filter(Boolean);
|
|
139
|
+
if (!segs.length) return null;
|
|
140
|
+
const HEX_HASH_RE = /^[a-f0-9]{32,}$/i;
|
|
141
|
+
let start = 0;
|
|
142
|
+
while (start < segs.length) {
|
|
143
|
+
const seg = segs[start];
|
|
144
|
+
if (seg === serviceId || HEX_HASH_RE.test(seg)) { start++; continue; }
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const real = segs.slice(start).join('/');
|
|
148
|
+
return real || null;
|
|
149
|
+
} catch { return null; }
|
|
150
|
+
};
|
|
77
151
|
|
|
78
|
-
|
|
79
|
-
|
|
152
|
+
const getExpiredAttachmentVisiblePath = (remotePath, fallback = 'file') => {
|
|
153
|
+
const n = normalizeAttachmentPathCandidate(remotePath);
|
|
154
|
+
if (n) return n;
|
|
155
|
+
return normalizeAttachmentPathCandidate(fallback) || 'file';
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const buildDisplayExpiredAttachmentHref = (remotePath, fallback = 'file') => {
|
|
159
|
+
const visible = getExpiredAttachmentVisiblePath(remotePath, fallback);
|
|
160
|
+
return `${EXPIRED_ATTACHMENT_URL_ORIGIN}/${_encodePathSegments(visible)}`;
|
|
161
|
+
};
|
|
80
162
|
|
|
81
|
-
|
|
163
|
+
const LINK_LABEL_MAX_DISPLAY_CHARS = 32;
|
|
164
|
+
const truncateLabelForDisplay = (label) => {
|
|
165
|
+
if (!label) return label;
|
|
166
|
+
if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
|
|
167
|
+
return '\u2026' + label.slice(label.length - (LINK_LABEL_MAX_DISPLAY_CHARS - 1));
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const normalizeTrailingInlineToken = (value) => {
|
|
171
|
+
if (!value) return value;
|
|
172
|
+
let out = value;
|
|
173
|
+
out = out.replace(/[.,;:!?]+$/, '');
|
|
82
174
|
const trimUnmatched = (openCh, closeCh) => {
|
|
83
175
|
while (out.endsWith(closeCh)) {
|
|
84
176
|
const openCount = (out.match(new RegExp('\\' + openCh, 'g')) || []).length;
|
|
85
177
|
const closeCount = (out.match(new RegExp('\\' + closeCh, 'g')) || []).length;
|
|
86
|
-
if (closeCount > openCount)
|
|
87
|
-
|
|
88
|
-
} else {
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
178
|
+
if (closeCount > openCount) out = out.slice(0, -1);
|
|
179
|
+
else break;
|
|
91
180
|
}
|
|
92
181
|
};
|
|
93
|
-
|
|
94
182
|
trimUnmatched('(', ')');
|
|
95
183
|
trimUnmatched('[', ']');
|
|
96
184
|
trimUnmatched('{', '}');
|
|
97
|
-
|
|
185
|
+
out = out.replace(/[`'"*>]+$/, '');
|
|
98
186
|
return out;
|
|
99
187
|
};
|
|
100
188
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (cleaned.length <= maxLen) return cleaned;
|
|
112
|
-
return cleaned.slice(0, maxLen - 1) + '\u2026'; // ellipsis
|
|
189
|
+
// HTML-escape a string for use as element text or attribute value.
|
|
190
|
+
const escapeAttr = (s) => String(s).replace(/[&<>"']/g, (ch) => ({
|
|
191
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
192
|
+
}[ch]));
|
|
193
|
+
|
|
194
|
+
// Render an extracted file fence as an `<a>` HTML string. Embedded into
|
|
195
|
+
// the markdown source via placeholder; marked passes raw HTML through.
|
|
196
|
+
const fileToAnchorHtml = (filename, href) => {
|
|
197
|
+
const text = '\u2197 ' + filename;
|
|
198
|
+
return `<a class="bq-file-download" href="${escapeAttr(href)}" target="_blank" rel="noopener noreferrer" download="${escapeAttr(filename)}">${escapeAttr(text)}</a>`;
|
|
113
199
|
};
|
|
114
200
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
201
|
+
// Render an extracted link (link/src::/path/url) as an `<a>` HTML string
|
|
202
|
+
// carrying data-bq-* attributes that the bubble's click delegation reads
|
|
203
|
+
// to dispatch expired-link refresh.
|
|
204
|
+
const linkToAnchorHtml = (link, ctx) => {
|
|
205
|
+
const refreshing = !!(ctx && ctx.refreshingSet && ctx.refreshingSet.has(link.expiredHref || link.href));
|
|
206
|
+
const cls = ['bq-link-button'];
|
|
207
|
+
if (link.expired) cls.push('is-expired');
|
|
208
|
+
if (refreshing) cls.push('is-refreshing');
|
|
209
|
+
const labelText = '\u2197 ' + link.label + (refreshing ? ' (fetching...)' : '');
|
|
210
|
+
const attrs = [
|
|
211
|
+
`class="${cls.join(' ')}"`,
|
|
212
|
+
`href="${escapeAttr(link.href)}"`,
|
|
213
|
+
`target="_blank"`,
|
|
214
|
+
`rel="noopener noreferrer"`,
|
|
215
|
+
`title="${escapeAttr(link.fullLabel || link.label)}"`,
|
|
216
|
+
`download="${escapeAttr(link.fullLabel || link.label)}"`,
|
|
217
|
+
`data-bq-link="1"`,
|
|
218
|
+
];
|
|
219
|
+
if (link.expired) attrs.push(`data-bq-expired="1"`);
|
|
220
|
+
if (link.expiredHref) attrs.push(`data-bq-expired-href="${escapeAttr(link.expiredHref)}"`);
|
|
221
|
+
if (link.remotePath) attrs.push(`data-bq-remote-path="${escapeAttr(link.remotePath)}"`);
|
|
222
|
+
if (link.originalHref) attrs.push(`data-bq-original-href="${escapeAttr(link.originalHref)}"`);
|
|
223
|
+
if (link.fullLabel) attrs.push(`data-bq-full-label="${escapeAttr(link.fullLabel)}"`);
|
|
224
|
+
return `<a ${attrs.join(' ')}>${escapeAttr(labelText)}</a>`;
|
|
225
|
+
};
|
|
129
226
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
227
|
+
// Resolve a regex match from createInlineLinkRegex into a link object,
|
|
228
|
+
// or null if it can't be parsed. ctx provides `serviceId` (for CDN-prefix
|
|
229
|
+
// stripping) and `refreshedMap` (cached fresh hrefs keyed by expiredHref).
|
|
230
|
+
const buildLinkPartFromGroups = (full, g1, g2, g3, g4, g5, g6, ctx) => {
|
|
231
|
+
const serviceId = ctx && ctx.serviceId;
|
|
232
|
+
const refreshedMap = (ctx && ctx.refreshedMap) || {};
|
|
233
|
+
if (g1) {
|
|
234
|
+
const rawPath = normalizeTrailingInlineToken(g1);
|
|
235
|
+
const consumed = 'src::' + rawPath;
|
|
236
|
+
const tail = full.slice(consumed.length);
|
|
237
|
+
const isUrl = /^https?:\/\//i.test(rawPath);
|
|
238
|
+
if (isUrl && /^https:\/\//i.test(rawPath)) {
|
|
239
|
+
return {
|
|
240
|
+
part: {
|
|
241
|
+
label: truncateLabelForDisplay(rawPath),
|
|
242
|
+
fullLabel: rawPath,
|
|
243
|
+
href: rawPath,
|
|
244
|
+
expired: false,
|
|
245
|
+
},
|
|
246
|
+
tail,
|
|
247
|
+
};
|
|
149
248
|
}
|
|
150
|
-
|
|
151
|
-
|
|
249
|
+
const remotePath = isUrl
|
|
250
|
+
? (extractRemotePathFromAttachmentHref(rawPath, serviceId) || normalizeAttachmentPathCandidate(rawPath))
|
|
251
|
+
: normalizeAttachmentPathCandidate(rawPath);
|
|
252
|
+
if (!remotePath) return null;
|
|
253
|
+
const expiredHref = buildDisplayExpiredAttachmentHref(remotePath, remotePath);
|
|
254
|
+
const cached = refreshedMap[expiredHref];
|
|
255
|
+
const originalHref = isUrl ? rawPath : undefined;
|
|
256
|
+
return {
|
|
257
|
+
part: {
|
|
258
|
+
label: truncateLabelForDisplay(remotePath),
|
|
259
|
+
fullLabel: remotePath,
|
|
260
|
+
href: cached || expiredHref,
|
|
261
|
+
expired: !cached,
|
|
262
|
+
expiredHref,
|
|
263
|
+
remotePath,
|
|
264
|
+
originalHref,
|
|
265
|
+
},
|
|
266
|
+
tail,
|
|
267
|
+
};
|
|
152
268
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
269
|
+
if (g4 && g5) {
|
|
270
|
+
const rawPath = g5;
|
|
271
|
+
const remotePath = normalizeAttachmentPathCandidate(rawPath);
|
|
272
|
+
if (!remotePath) return null;
|
|
273
|
+
const expiredHref = buildDisplayExpiredAttachmentHref(remotePath, remotePath);
|
|
274
|
+
const cached = refreshedMap[expiredHref];
|
|
275
|
+
return {
|
|
276
|
+
part: {
|
|
277
|
+
label: truncateLabelForDisplay(g4),
|
|
278
|
+
fullLabel: g4,
|
|
279
|
+
href: cached || expiredHref,
|
|
280
|
+
expired: !cached,
|
|
281
|
+
expiredHref,
|
|
282
|
+
remotePath,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const originalHref = g3 || g6 || '';
|
|
287
|
+
if (!originalHref) return null;
|
|
288
|
+
if (/^https:\/\//i.test(originalHref)) {
|
|
289
|
+
const plainLabel = g2 || originalHref;
|
|
290
|
+
return {
|
|
291
|
+
part: {
|
|
292
|
+
label: truncateLabelForDisplay(plainLabel),
|
|
293
|
+
fullLabel: plainLabel,
|
|
294
|
+
href: originalHref,
|
|
295
|
+
expired: false,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
161
298
|
}
|
|
299
|
+
const remotePath = extractRemotePathFromAttachmentHref(originalHref, serviceId);
|
|
300
|
+
const fallbackLabel = g2 || originalHref;
|
|
301
|
+
const expiredHref = remotePath
|
|
302
|
+
? buildDisplayExpiredAttachmentHref(remotePath, fallbackLabel)
|
|
303
|
+
: originalHref;
|
|
304
|
+
const cached = refreshedMap[expiredHref];
|
|
305
|
+
const expired = !!remotePath && !cached;
|
|
306
|
+
const fullLabel = remotePath
|
|
307
|
+
? getExpiredAttachmentVisiblePath(remotePath, g2 || originalHref)
|
|
308
|
+
: (g2 || originalHref);
|
|
309
|
+
return {
|
|
310
|
+
part: {
|
|
311
|
+
label: truncateLabelForDisplay(fullLabel),
|
|
312
|
+
fullLabel,
|
|
313
|
+
href: cached || expiredHref,
|
|
314
|
+
expired,
|
|
315
|
+
expiredHref,
|
|
316
|
+
remotePath: remotePath || undefined,
|
|
317
|
+
originalHref,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
162
320
|
};
|
|
163
321
|
|
|
164
322
|
// ----- file-fence helpers (mirrors agent.vue) ---------------------------
|
|
@@ -239,94 +397,126 @@
|
|
|
239
397
|
return occurrence === 0 ? `${base}.${ext}` : `${base}-${occurrence + 1}.${ext}`;
|
|
240
398
|
};
|
|
241
399
|
|
|
242
|
-
// Split message content into
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
400
|
+
// Split message content into renderable parts. When `marked` is loaded,
|
|
401
|
+
// returns a single `{type:'html', html}` part containing the full
|
|
402
|
+
// markdown-rendered bubble (with file/link anchors spliced back in).
|
|
403
|
+
// When marked is not yet loaded, returns `{type:'fallback', text}` so the
|
|
404
|
+
// bubble can render plain text until marked finishes loading.
|
|
405
|
+
//
|
|
406
|
+
// Mirrors agent.vue's parseMsgParts:
|
|
407
|
+
// 1. extract closed file fences `\`\`\`filename.ext\n...\`\`\`` → file anchors
|
|
408
|
+
// 2. mid-typewriter unclosed file fence → `[generating filename...]`
|
|
409
|
+
// 3. mask inline `\`code\`` spans so links inside aren't extracted
|
|
410
|
+
// 4. extract inline links (src::, [label](url), [label](path), bare URL)
|
|
411
|
+
// via createInlineLinkRegex → link anchors with data-bq-* attrs
|
|
412
|
+
// 5. restore code masks
|
|
413
|
+
// 6. marked.parse(working, { gfm:true, breaks:true })
|
|
414
|
+
// 7. splice placeholders back as raw anchor HTML
|
|
415
|
+
const parseMsgParts = (content, msgKey, ctx) => {
|
|
416
|
+
if (!content) return [];
|
|
417
|
+
ctx = ctx || {};
|
|
418
|
+
const marked = (typeof global.marked !== 'undefined' && global.marked && global.marked.parse) ? global.marked : null;
|
|
419
|
+
|
|
420
|
+
// Always extract files/links so the fallback text branch can still
|
|
421
|
+
// render placeholders if we hit it. But the placeholder strategy
|
|
422
|
+
// only works when marked is available; otherwise just hand the raw
|
|
423
|
+
// content back as a plain-text fallback (marked load triggers a
|
|
424
|
+
// re-render).
|
|
425
|
+
if (!marked) {
|
|
426
|
+
// Trigger lazy load if not already in flight, then re-render
|
|
427
|
+
// once it resolves. The instance hooks this callback in via
|
|
428
|
+
// `ctx.onMarkedReady`.
|
|
429
|
+
if (ctx.onMarkedReady) {
|
|
430
|
+
_getMarked().then((m) => { if (m) ctx.onMarkedReady(); });
|
|
431
|
+
}
|
|
432
|
+
return [{ type: 'fallback', text: content }];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const placeholderHtml = [];
|
|
436
|
+
const PH = (idx) => `\uE000BQ${idx}\uE001`;
|
|
437
|
+
const push = (anchorHtml) => {
|
|
438
|
+
const idx = placeholderHtml.length;
|
|
439
|
+
placeholderHtml.push(anchorHtml);
|
|
440
|
+
return PH(idx);
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Bare-fence filename minting state, keyed per-message so re-renders
|
|
444
|
+
// (typewriter ticks) reuse the same synthesized names → blob cache
|
|
445
|
+
// hits instead of leaking ObjectURLs every frame.
|
|
446
|
+
let bareCounters = msgKey ? _bareFenceNameCounters.get(msgKey) : null;
|
|
447
|
+
if (!bareCounters && msgKey) {
|
|
261
448
|
bareCounters = {};
|
|
262
|
-
|
|
449
|
+
_bareFenceNameCounters.set(msgKey, bareCounters);
|
|
263
450
|
}
|
|
264
451
|
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
452
|
|
|
285
|
-
//
|
|
286
|
-
let
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
}
|
|
453
|
+
// 1a. Closed filename fences: ```filename.ext\n...\n```
|
|
454
|
+
let working = content.replace(
|
|
455
|
+
/```([\w.-]+\.[a-zA-Z0-9]+)\n([\s\S]*?)```/g,
|
|
456
|
+
(_full, filename, body) =>
|
|
457
|
+
push(fileToAnchorHtml(filename, _getOrCreateFileHref(filename, body))),
|
|
458
|
+
);
|
|
301
459
|
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
460
|
+
// 1b. Closed bare-language fences: ```html ... ``` etc.
|
|
461
|
+
working = working.replace(FENCE_LANG_RE, (_full, lang, body) => {
|
|
462
|
+
const ext = FENCE_LANGUAGE_EXTENSIONS[lang.toLowerCase()] || 'txt';
|
|
463
|
+
const occurrence = bareSeen[ext] || 0;
|
|
464
|
+
bareSeen[ext] = occurrence + 1;
|
|
465
|
+
const filename = _bareFilenameFor(msgKey, ext, occurrence);
|
|
466
|
+
return push(fileToAnchorHtml(filename, _getOrCreateFileHref(filename, body)));
|
|
467
|
+
});
|
|
307
468
|
|
|
308
|
-
// Mid-typewriter
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
const langOpen = tail.match(OPEN_FENCE_LANG_RE);
|
|
469
|
+
// 2. Mid-typewriter unclosed fence → hide raw streaming source.
|
|
470
|
+
if (ctx.isTyping) {
|
|
471
|
+
const fnOpen = working.match(OPEN_FENCE_FILENAME_RE);
|
|
472
|
+
const langOpen = working.match(OPEN_FENCE_LANG_RE);
|
|
313
473
|
const open = fnOpen && (!langOpen || fnOpen.index <= langOpen.index)
|
|
314
474
|
? { match: fnOpen, label: fnOpen[1] }
|
|
315
475
|
: langOpen
|
|
316
|
-
? { match: langOpen, label: 'download.' + FENCE_LANGUAGE_EXTENSIONS[langOpen[1].toLowerCase()] }
|
|
476
|
+
? { match: langOpen, label: 'download.' + (FENCE_LANGUAGE_EXTENSIONS[langOpen[1].toLowerCase()] || 'txt') }
|
|
317
477
|
: null;
|
|
318
|
-
if (open) {
|
|
319
|
-
const
|
|
320
|
-
|
|
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
|
-
}
|
|
478
|
+
if (open && typeof open.match.index === 'number') {
|
|
479
|
+
const dots = '.'.repeat(((ctx.dotTick || 0) % 3) + 1);
|
|
480
|
+
working = working.slice(0, open.match.index) + `\n[generating ${open.label}${dots}]`;
|
|
327
481
|
}
|
|
328
482
|
}
|
|
329
|
-
|
|
483
|
+
|
|
484
|
+
// 3. Mask inline code spans so links inside backticks survive.
|
|
485
|
+
const codeMasks = [];
|
|
486
|
+
working = working.replace(/`[^`\n]+`/g, (m) => {
|
|
487
|
+
const idx = codeMasks.length;
|
|
488
|
+
codeMasks.push(m);
|
|
489
|
+
return `\uE002C${idx}\uE003`;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// 4. Extract inline links/sources via createInlineLinkRegex.
|
|
493
|
+
const linkRe = createInlineLinkRegex();
|
|
494
|
+
working = working.replace(linkRe, (full, g1, g2, g3, g4, g5, g6) => {
|
|
495
|
+
const built = buildLinkPartFromGroups(full, g1, g2, g3, g4, g5, g6, ctx);
|
|
496
|
+
if (!built) return full;
|
|
497
|
+
const placeholder = push(linkToAnchorHtml(built.part, ctx));
|
|
498
|
+
return placeholder + (built.tail || '');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// 5. Restore code spans for marked to format.
|
|
502
|
+
working = working.replace(/\uE002C(\d+)\uE003/g, (_m, idx) => codeMasks[Number(idx)] || '');
|
|
503
|
+
|
|
504
|
+
// 6. Markdown → HTML.
|
|
505
|
+
let html;
|
|
506
|
+
try {
|
|
507
|
+
html = marked.parse(working, { gfm: true, breaks: true, async: false });
|
|
508
|
+
} catch (err) {
|
|
509
|
+
console.error('[BunnyQuery] marked.parse failed', err);
|
|
510
|
+
html = escapeAttr(working);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 7. Splice anchor placeholders back.
|
|
514
|
+
const finalHtml = html.replace(
|
|
515
|
+
/\uE000BQ(\d+)\uE001/g,
|
|
516
|
+
(_m, idx) => placeholderHtml[Number(idx)] || '',
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
return [{ type: 'html', html: finalHtml }];
|
|
330
520
|
};
|
|
331
521
|
|
|
332
522
|
// ----- helpers ----------------------------------------------------------
|
|
@@ -908,6 +1098,29 @@
|
|
|
908
1098
|
|
|
909
1099
|
this.refs = {};
|
|
910
1100
|
|
|
1101
|
+
// Cache of expired-href -> fresh signed URL. Populated on
|
|
1102
|
+
// demand the first time a user clicks a `[label](path)` or
|
|
1103
|
+
// `src::token` source link, so subsequent clicks (and reflows
|
|
1104
|
+
// that re-render the bubble) reuse the same signed URL.
|
|
1105
|
+
this._refreshedLinkMap = Object.create(null);
|
|
1106
|
+
|
|
1107
|
+
// Set of expired-hrefs currently in flight to getTemporaryUrl,
|
|
1108
|
+
// used to render "(fetching...)" labels and dedupe parallel
|
|
1109
|
+
// clicks on the same anchor.
|
|
1110
|
+
this._refreshingLinkSet = new Set();
|
|
1111
|
+
this._refreshingLinkPromises = new Map();
|
|
1112
|
+
|
|
1113
|
+
// Animated dots for typewriter [generating filename...] labels.
|
|
1114
|
+
this._dotTick = 0;
|
|
1115
|
+
this._dotTickTimer = null;
|
|
1116
|
+
|
|
1117
|
+
// Typewriter state. While `typing` is true the latest assistant
|
|
1118
|
+
// bubble streams character-by-character; while `typingAbort` is
|
|
1119
|
+
// true any active typewriter unwinds to the full text.
|
|
1120
|
+
this.typing = false;
|
|
1121
|
+
this._typingAbort = false;
|
|
1122
|
+
this._typewriterQueue = Promise.resolve();
|
|
1123
|
+
|
|
911
1124
|
this._injectStylesheetOnce();
|
|
912
1125
|
this._bootstrap();
|
|
913
1126
|
}
|
|
@@ -968,6 +1181,11 @@
|
|
|
968
1181
|
profile = await this.skapi.getProfile();
|
|
969
1182
|
} catch (_) { profile = null; }
|
|
970
1183
|
|
|
1184
|
+
// Cache for the settings modal so it can show the current email
|
|
1185
|
+
// and decide whether to expose the password form (openid users
|
|
1186
|
+
// can't change passwords through Cognito).
|
|
1187
|
+
this.profile = profile;
|
|
1188
|
+
|
|
971
1189
|
if (!profile) {
|
|
972
1190
|
this._renderLogin();
|
|
973
1191
|
return;
|
|
@@ -1014,14 +1232,20 @@
|
|
|
1014
1232
|
);
|
|
1015
1233
|
}
|
|
1016
1234
|
|
|
1017
|
-
_renderLogin() {
|
|
1235
|
+
_renderLogin(prefill) {
|
|
1018
1236
|
this._clear();
|
|
1019
1237
|
const errLabel = $('p', { class: 'bq-apikey-error', style: { display: 'none' } });
|
|
1238
|
+
const noteLabel = $('p', { class: 'bq-login-note', style: { display: 'none' } });
|
|
1239
|
+
if (prefill && prefill.notice) {
|
|
1240
|
+
noteLabel.textContent = prefill.notice;
|
|
1241
|
+
noteLabel.style.display = '';
|
|
1242
|
+
}
|
|
1020
1243
|
const userInput = $('input', {
|
|
1021
1244
|
type: 'text',
|
|
1022
1245
|
class: 'bq-input',
|
|
1023
1246
|
placeholder: 'Email or username',
|
|
1024
1247
|
autocomplete: 'username',
|
|
1248
|
+
value: (prefill && prefill.email) || '',
|
|
1025
1249
|
});
|
|
1026
1250
|
const passInput = $('input', {
|
|
1027
1251
|
type: 'password',
|
|
@@ -1033,6 +1257,12 @@
|
|
|
1033
1257
|
$('span', { class: 'bq-login-submit-label' }, 'Login'),
|
|
1034
1258
|
]);
|
|
1035
1259
|
|
|
1260
|
+
const forgotLink = $('button', {
|
|
1261
|
+
type: 'button',
|
|
1262
|
+
class: 'bq-login-link',
|
|
1263
|
+
onclick: () => this._renderForgotPassword({ email: userInput.value.trim() }),
|
|
1264
|
+
}, 'Forgot password?');
|
|
1265
|
+
|
|
1036
1266
|
const form = $('form', {
|
|
1037
1267
|
class: 'bq-login-form',
|
|
1038
1268
|
onsubmit: async (e) => {
|
|
@@ -1057,10 +1287,148 @@
|
|
|
1057
1287
|
}
|
|
1058
1288
|
},
|
|
1059
1289
|
}, [
|
|
1290
|
+
noteLabel,
|
|
1060
1291
|
errLabel,
|
|
1061
1292
|
userInput,
|
|
1062
1293
|
passInput,
|
|
1063
1294
|
submitBtn,
|
|
1295
|
+
$('div', { class: 'bq-login-actions' }, [forgotLink]),
|
|
1296
|
+
]);
|
|
1297
|
+
|
|
1298
|
+
this.container.appendChild(
|
|
1299
|
+
$('div', { class: 'bq-apikey-overlay' }, [
|
|
1300
|
+
$('div', { class: 'bq-apikey-overlay-inner' }, [form]),
|
|
1301
|
+
])
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
_renderForgotPassword(prefill) {
|
|
1306
|
+
this._clear();
|
|
1307
|
+
const initialEmail = (prefill && prefill.email) || '';
|
|
1308
|
+
|
|
1309
|
+
const errLabel = $('p', { class: 'bq-apikey-error', style: { display: 'none' } });
|
|
1310
|
+
const okLabel = $('p', { class: 'bq-login-note', style: { display: 'none' } });
|
|
1311
|
+
const setMsg = (text, ok) => {
|
|
1312
|
+
errLabel.style.display = 'none';
|
|
1313
|
+
okLabel.style.display = 'none';
|
|
1314
|
+
if (!text) return;
|
|
1315
|
+
const node = ok ? okLabel : errLabel;
|
|
1316
|
+
node.textContent = text;
|
|
1317
|
+
node.style.display = '';
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const title = $('p', { class: 'bq-login-title' }, 'Reset password');
|
|
1321
|
+
|
|
1322
|
+
// ----- Step 1: email -----------------------------------------
|
|
1323
|
+
const emailInput = $('input', {
|
|
1324
|
+
type: 'email',
|
|
1325
|
+
class: 'bq-input',
|
|
1326
|
+
placeholder: 'Email',
|
|
1327
|
+
autocomplete: 'username',
|
|
1328
|
+
value: initialEmail,
|
|
1329
|
+
required: true,
|
|
1330
|
+
});
|
|
1331
|
+
const sendBtn = $('button', { type: 'submit', class: 'btn bq-login-submit' }, [
|
|
1332
|
+
$('span', { class: 'bq-login-submit-label' }, 'Send code'),
|
|
1333
|
+
]);
|
|
1334
|
+
const sendLabel = sendBtn.querySelector('.bq-login-submit-label');
|
|
1335
|
+
|
|
1336
|
+
// ----- Step 2: code + new password (hidden until step 1 OK) --
|
|
1337
|
+
const codeInput = $('input', {
|
|
1338
|
+
type: 'text',
|
|
1339
|
+
class: 'bq-input',
|
|
1340
|
+
placeholder: 'Verification code',
|
|
1341
|
+
autocomplete: 'one-time-code',
|
|
1342
|
+
inputmode: 'numeric',
|
|
1343
|
+
});
|
|
1344
|
+
const newPwInput = $('input', {
|
|
1345
|
+
type: 'password',
|
|
1346
|
+
class: 'bq-input',
|
|
1347
|
+
placeholder: 'New password',
|
|
1348
|
+
autocomplete: 'new-password',
|
|
1349
|
+
minlength: '6',
|
|
1350
|
+
});
|
|
1351
|
+
const confirmPwInput = $('input', {
|
|
1352
|
+
type: 'password',
|
|
1353
|
+
class: 'bq-input',
|
|
1354
|
+
placeholder: 'Confirm new password',
|
|
1355
|
+
autocomplete: 'new-password',
|
|
1356
|
+
minlength: '6',
|
|
1357
|
+
});
|
|
1358
|
+
const resetBtn = $('button', { type: 'button', class: 'btn bq-login-submit' }, [
|
|
1359
|
+
$('span', { class: 'bq-login-submit-label' }, 'Reset password'),
|
|
1360
|
+
]);
|
|
1361
|
+
const step2 = $('div', { class: 'bq-login-step', style: { display: 'none' } }, [
|
|
1362
|
+
codeInput,
|
|
1363
|
+
newPwInput,
|
|
1364
|
+
confirmPwInput,
|
|
1365
|
+
resetBtn,
|
|
1366
|
+
]);
|
|
1367
|
+
|
|
1368
|
+
resetBtn.onclick = async () => {
|
|
1369
|
+
setMsg('');
|
|
1370
|
+
const code = codeInput.value.trim();
|
|
1371
|
+
const np = newPwInput.value;
|
|
1372
|
+
const cp = confirmPwInput.value;
|
|
1373
|
+
if (!code) { setMsg('Enter the verification code.', false); return; }
|
|
1374
|
+
if (!np || np.length < 6) { setMsg('New password must be at least 6 characters.', false); return; }
|
|
1375
|
+
if (np !== cp) { setMsg('New password and confirmation do not match.', false); return; }
|
|
1376
|
+
resetBtn.disabled = true;
|
|
1377
|
+
resetBtn.classList.add('is-loading');
|
|
1378
|
+
try {
|
|
1379
|
+
await this.skapi.resetPassword({
|
|
1380
|
+
email: emailInput.value.trim(),
|
|
1381
|
+
code,
|
|
1382
|
+
new_password: np,
|
|
1383
|
+
});
|
|
1384
|
+
this._renderLogin({
|
|
1385
|
+
email: emailInput.value.trim(),
|
|
1386
|
+
notice: 'Password reset. Sign in with your new password.',
|
|
1387
|
+
});
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
setMsg((err && err.message) || String(err), false);
|
|
1390
|
+
resetBtn.disabled = false;
|
|
1391
|
+
resetBtn.classList.remove('is-loading');
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const backLink = $('button', {
|
|
1396
|
+
type: 'button',
|
|
1397
|
+
class: 'bq-login-link',
|
|
1398
|
+
onclick: () => this._renderLogin({ email: emailInput.value.trim() }),
|
|
1399
|
+
}, 'Back to login');
|
|
1400
|
+
|
|
1401
|
+
const form = $('form', {
|
|
1402
|
+
class: 'bq-login-form',
|
|
1403
|
+
onsubmit: async (e) => {
|
|
1404
|
+
e.preventDefault();
|
|
1405
|
+
// Step 1 only: request the code.
|
|
1406
|
+
const email = emailInput.value.trim();
|
|
1407
|
+
if (!email) { setMsg('Enter your account email.', false); return; }
|
|
1408
|
+
setMsg('');
|
|
1409
|
+
sendBtn.disabled = true;
|
|
1410
|
+
sendBtn.classList.add('is-loading');
|
|
1411
|
+
try {
|
|
1412
|
+
await this.skapi.forgotPassword({ email });
|
|
1413
|
+
sendLabel.textContent = 'Resend code';
|
|
1414
|
+
step2.style.display = '';
|
|
1415
|
+
codeInput.focus();
|
|
1416
|
+
setMsg('Verification code sent. Check your inbox.', true);
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
setMsg((err && err.message) || String(err), false);
|
|
1419
|
+
} finally {
|
|
1420
|
+
sendBtn.disabled = false;
|
|
1421
|
+
sendBtn.classList.remove('is-loading');
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
}, [
|
|
1425
|
+
title,
|
|
1426
|
+
okLabel,
|
|
1427
|
+
errLabel,
|
|
1428
|
+
emailInput,
|
|
1429
|
+
sendBtn,
|
|
1430
|
+
step2,
|
|
1431
|
+
$('div', { class: 'bq-login-actions' }, [backLink]),
|
|
1064
1432
|
]);
|
|
1065
1433
|
|
|
1066
1434
|
this.container.appendChild(
|
|
@@ -1105,6 +1473,14 @@
|
|
|
1105
1473
|
onclick: () => this._onClearHistoryClick(),
|
|
1106
1474
|
}, 'Clear');
|
|
1107
1475
|
|
|
1476
|
+
const settingsIcon = $('button', {
|
|
1477
|
+
type: 'button',
|
|
1478
|
+
class: 'bq-text-btn',
|
|
1479
|
+
title: 'Account settings',
|
|
1480
|
+
'aria-label': 'Account settings',
|
|
1481
|
+
onclick: () => this._openSettingsModal(),
|
|
1482
|
+
}, 'Settings');
|
|
1483
|
+
|
|
1108
1484
|
const logoutIcon = $('button', {
|
|
1109
1485
|
type: 'button',
|
|
1110
1486
|
class: 'bq-text-btn',
|
|
@@ -1115,6 +1491,7 @@
|
|
|
1115
1491
|
|
|
1116
1492
|
const titleRight = $('div', { class: 'bq-title-right' }, [
|
|
1117
1493
|
clearIcon,
|
|
1494
|
+
settingsIcon,
|
|
1118
1495
|
logoutIcon,
|
|
1119
1496
|
]);
|
|
1120
1497
|
|
|
@@ -1212,37 +1589,31 @@
|
|
|
1212
1589
|
if (msg.isPending) {
|
|
1213
1590
|
bubble.appendChild($('span', { class: 'bq-loader' }, 'Thinking'));
|
|
1214
1591
|
} else {
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1592
|
+
// Full markdown rendering via marked + placeholder
|
|
1593
|
+
// substitution that preserves the bq-file-download /
|
|
1594
|
+
// bq-link-button anchors with their data-bq-* attrs.
|
|
1595
|
+
// A single delegated click handler dispatches expired-
|
|
1596
|
+
// link refresh.
|
|
1597
|
+
const parts = parseMsgParts(msg.content || '', msg, {
|
|
1598
|
+
serviceId: this.serviceId,
|
|
1599
|
+
refreshedMap: this._refreshedLinkMap,
|
|
1600
|
+
refreshingSet: this._refreshingLinkSet,
|
|
1601
|
+
isTyping: !!this.typing,
|
|
1602
|
+
dotTick: this._dotTick,
|
|
1603
|
+
onMarkedReady: () => this._renderMessages(),
|
|
1604
|
+
});
|
|
1220
1605
|
if (!parts.length) {
|
|
1221
1606
|
bubble.textContent = msg.content || '';
|
|
1222
|
-
} else {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
}
|
|
1607
|
+
} else if (parts.length === 1 && parts[0].type === 'html') {
|
|
1608
|
+
const md = document.createElement('div');
|
|
1609
|
+
md.className = 'bq-md';
|
|
1610
|
+
md.innerHTML = parts[0].html;
|
|
1611
|
+
md.addEventListener('click', (e) => this._onBubbleLinkClick(e));
|
|
1612
|
+
bubble.appendChild(md);
|
|
1613
|
+
} else if (parts.length === 1 && parts[0].type === 'fallback') {
|
|
1614
|
+
// marked not loaded yet — show plain text; will
|
|
1615
|
+
// re-render once `_getMarked()` resolves.
|
|
1616
|
+
bubble.textContent = parts[0].text;
|
|
1246
1617
|
}
|
|
1247
1618
|
}
|
|
1248
1619
|
const wrapper = $('div', {
|
|
@@ -1254,8 +1625,17 @@
|
|
|
1254
1625
|
box.appendChild(wrapper);
|
|
1255
1626
|
});
|
|
1256
1627
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1628
|
+
|
|
1629
|
+
// Scroll to bottom if stickToBottom, or if the last message is a resolved assistant response
|
|
1630
|
+
const lastMsg = this.messages[this.messages.length - 1];
|
|
1631
|
+
if (
|
|
1632
|
+
stickToBottom ||
|
|
1633
|
+
(lastMsg && lastMsg.role === 'assistant' && !lastMsg.isPending && !lastMsg.isError)
|
|
1634
|
+
) {
|
|
1635
|
+
// Use setTimeout to ensure DOM is updated before scrolling
|
|
1636
|
+
setTimeout(() => {
|
|
1637
|
+
box.scrollTop = box.scrollHeight;
|
|
1638
|
+
}, 0);
|
|
1259
1639
|
}
|
|
1260
1640
|
|
|
1261
1641
|
// Hide the clear-history icon when there's nothing to clear.
|
|
@@ -1271,7 +1651,7 @@
|
|
|
1271
1651
|
}
|
|
1272
1652
|
|
|
1273
1653
|
_updateInputDisabled() {
|
|
1274
|
-
const busy = this.sending || this.uploading || this._anyPending() || this._loadingFirstHistory;
|
|
1654
|
+
const busy = this.sending || this.uploading || this.typing || this._anyPending() || this._loadingFirstHistory;
|
|
1275
1655
|
if (this.refs.textarea) {
|
|
1276
1656
|
this.refs.textarea.disabled = busy;
|
|
1277
1657
|
this.refs.textarea.placeholder = busy
|
|
@@ -1293,6 +1673,170 @@
|
|
|
1293
1673
|
return this.messages.some((m) => m.isPending);
|
|
1294
1674
|
}
|
|
1295
1675
|
|
|
1676
|
+
// ---- expired-link refresh + bubble click delegation ----------------
|
|
1677
|
+
// Click handler attached once per rendered bubble. Finds the closest
|
|
1678
|
+
// `a[data-bq-link]` ancestor; if the anchor is marked expired (i.e.
|
|
1679
|
+
// a source-path link the user has not refreshed yet), preventDefault
|
|
1680
|
+
// and round-trip through `getTemporaryUrl` to obtain a fresh signed
|
|
1681
|
+
// URL, then open it in a new tab. Cached on the instance so repeat
|
|
1682
|
+
// clicks (and re-renders) reuse the same URL.
|
|
1683
|
+
_onBubbleLinkClick(e) {
|
|
1684
|
+
const target = e.target;
|
|
1685
|
+
if (!target || !target.closest) return;
|
|
1686
|
+
const anchor = target.closest('a[data-bq-link]');
|
|
1687
|
+
if (!anchor) return;
|
|
1688
|
+
if (anchor.dataset.bqExpired !== '1') return; // normal link
|
|
1689
|
+
|
|
1690
|
+
const expiredHref = anchor.dataset.bqExpiredHref || anchor.getAttribute('href') || '';
|
|
1691
|
+
const cached = this._refreshedLinkMap[expiredHref];
|
|
1692
|
+
if (cached) {
|
|
1693
|
+
anchor.href = cached;
|
|
1694
|
+
anchor.classList.remove('is-expired');
|
|
1695
|
+
return; // let the click proceed naturally
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
e.preventDefault();
|
|
1699
|
+
const remotePath = anchor.dataset.bqRemotePath || '';
|
|
1700
|
+
const originalHref = anchor.dataset.bqOriginalHref || '';
|
|
1701
|
+
this._fetchFreshHrefForExpiredLink(expiredHref, remotePath, originalHref)
|
|
1702
|
+
.then((fresh) => {
|
|
1703
|
+
if (!fresh) return;
|
|
1704
|
+
anchor.href = fresh;
|
|
1705
|
+
anchor.classList.remove('is-expired');
|
|
1706
|
+
anchor.classList.remove('is-refreshing');
|
|
1707
|
+
// Re-render so the cached URL replaces all other live
|
|
1708
|
+
// copies of the same expired anchor in the chat.
|
|
1709
|
+
this._renderMessages();
|
|
1710
|
+
// Open in new tab — preserves the user's click intent.
|
|
1711
|
+
window.open(fresh, '_blank', 'noopener,noreferrer');
|
|
1712
|
+
})
|
|
1713
|
+
.catch((err) => {
|
|
1714
|
+
console.error('[BunnyQuery] failed to refresh expired link', err);
|
|
1715
|
+
alert('Failed to refresh this expired link. Please try again.');
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
async _fetchFreshHrefForExpiredLink(expiredHref, remotePath, originalHref) {
|
|
1720
|
+
const cached = this._refreshedLinkMap[expiredHref];
|
|
1721
|
+
if (cached) return cached;
|
|
1722
|
+
const inFlight = this._refreshingLinkPromises.get(expiredHref);
|
|
1723
|
+
if (inFlight) return inFlight;
|
|
1724
|
+
|
|
1725
|
+
this._refreshingLinkSet.add(expiredHref);
|
|
1726
|
+
this._renderMessages();
|
|
1727
|
+
|
|
1728
|
+
const run = async () => {
|
|
1729
|
+
// Prefer the original full URL captured at parse time. The
|
|
1730
|
+
// user-facing Skapi.getFile() needs a real http(s) URL to a
|
|
1731
|
+
// skapi CDN endpoint; it cannot resolve bare storage paths.
|
|
1732
|
+
const sourceUrl = (originalHref && /^https?:\/\//i.test(originalHref))
|
|
1733
|
+
? originalHref
|
|
1734
|
+
: null;
|
|
1735
|
+
if (!sourceUrl) {
|
|
1736
|
+
throw new Error('Unable to refresh this expired attachment link: missing source URL.');
|
|
1737
|
+
}
|
|
1738
|
+
const res = await this.skapi.getFile(sourceUrl, {
|
|
1739
|
+
dataType: 'endpoint',
|
|
1740
|
+
expires: DEFAULTS.ATTACHMENT_URL_EXPIRES_SECONDS,
|
|
1741
|
+
});
|
|
1742
|
+
const fresh = typeof res === 'string' ? res : (res && (res.url || res.cdn_url));
|
|
1743
|
+
if (!fresh) throw new Error('Empty signed URL response.');
|
|
1744
|
+
this._refreshedLinkMap[expiredHref] = fresh;
|
|
1745
|
+
return fresh;
|
|
1746
|
+
};
|
|
1747
|
+
const p = run().finally(() => {
|
|
1748
|
+
this._refreshingLinkSet.delete(expiredHref);
|
|
1749
|
+
this._refreshingLinkPromises.delete(expiredHref);
|
|
1750
|
+
});
|
|
1751
|
+
this._refreshingLinkPromises.set(expiredHref, p);
|
|
1752
|
+
return p;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// ---- typewriter ----------------------------------------------------
|
|
1756
|
+
// Reveal `fullText` into messages[idx].content character-by-character.
|
|
1757
|
+
// Fast-forwards through fenced file blocks (rendered as a
|
|
1758
|
+
// [generating filename...] placeholder anyway) and inline link
|
|
1759
|
+
// tokens (kept atomic so half-rendered `[label]` doesn't show up).
|
|
1760
|
+
async _typewriteIntoIndex(idx, fullText) {
|
|
1761
|
+
if (!fullText) return;
|
|
1762
|
+
const TICK_MS = 16;
|
|
1763
|
+
const charsPerTick = 3;
|
|
1764
|
+
const FENCE_REVEAL_MS = 200;
|
|
1765
|
+
const fenceTicks = Math.max(1, Math.floor(FENCE_REVEAL_MS / TICK_MS));
|
|
1766
|
+
|
|
1767
|
+
const fenceRegions = [];
|
|
1768
|
+
const fenceRegex = /```[\w.-]+\.[a-zA-Z0-9]+\n[\s\S]*?```/g;
|
|
1769
|
+
let fm;
|
|
1770
|
+
while ((fm = fenceRegex.exec(fullText)) !== null) {
|
|
1771
|
+
fenceRegions.push({ start: fm.index, end: fm.index + fm[0].length });
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const linkRegions = [];
|
|
1775
|
+
const linkRegex = createInlineLinkRegex();
|
|
1776
|
+
let lm;
|
|
1777
|
+
while ((lm = linkRegex.exec(fullText)) !== null) {
|
|
1778
|
+
linkRegions.push({ start: lm.index, end: lm.index + lm[0].length });
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Start the dotTick animation while typewriting.
|
|
1782
|
+
this._startDotTick();
|
|
1783
|
+
this.typing = true;
|
|
1784
|
+
this._typingAbort = false;
|
|
1785
|
+
|
|
1786
|
+
try {
|
|
1787
|
+
let i = 0;
|
|
1788
|
+
while (i < fullText.length) {
|
|
1789
|
+
if (this._typingAbort) break;
|
|
1790
|
+
let step = charsPerTick;
|
|
1791
|
+
const region = fenceRegions.find((r) => i >= r.start && i < r.end);
|
|
1792
|
+
const linkRegion = linkRegions.find((r) => i >= r.start && i < r.end);
|
|
1793
|
+
if (region) {
|
|
1794
|
+
step = Math.max(charsPerTick, Math.ceil((region.end - i) / fenceTicks));
|
|
1795
|
+
} else if (linkRegion) {
|
|
1796
|
+
step = Math.max(charsPerTick, linkRegion.end - i);
|
|
1797
|
+
} else {
|
|
1798
|
+
const next = linkRegions.find((r) => i < r.start && i + step > r.start);
|
|
1799
|
+
if (next) step = next.end - i;
|
|
1800
|
+
}
|
|
1801
|
+
i = Math.min(fullText.length, i + step);
|
|
1802
|
+
const target = this.messages[idx];
|
|
1803
|
+
if (!target) break;
|
|
1804
|
+
target.content = fullText.slice(0, i);
|
|
1805
|
+
this._renderMessages();
|
|
1806
|
+
await new Promise((r) => setTimeout(r, TICK_MS));
|
|
1807
|
+
}
|
|
1808
|
+
} finally {
|
|
1809
|
+
const target = this.messages[idx];
|
|
1810
|
+
if (target) target.content = fullText;
|
|
1811
|
+
this.typing = false;
|
|
1812
|
+
this._stopDotTick();
|
|
1813
|
+
this._renderMessages();
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
_enqueueTypewrite(idx, fullText) {
|
|
1818
|
+
this._typewriterQueue = this._typewriterQueue.then(() => this._typewriteIntoIndex(idx, fullText));
|
|
1819
|
+
return this._typewriterQueue;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
_startDotTick() {
|
|
1823
|
+
if (this._dotTickTimer) return;
|
|
1824
|
+
this._dotTickTimer = setInterval(() => {
|
|
1825
|
+
this._dotTick = (this._dotTick + 1) % 3;
|
|
1826
|
+
// No full re-render here — the dots only matter while a
|
|
1827
|
+
// fence is open mid-typewriter, which the next typewriter
|
|
1828
|
+
// tick will repaint anyway.
|
|
1829
|
+
}, 400);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
_stopDotTick() {
|
|
1833
|
+
if (this._dotTickTimer) {
|
|
1834
|
+
clearInterval(this._dotTickTimer);
|
|
1835
|
+
this._dotTickTimer = null;
|
|
1836
|
+
}
|
|
1837
|
+
this._dotTick = 0;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1296
1840
|
// ---- clear history (soft delete via localStorage horizon) -------
|
|
1297
1841
|
// Mirrors the strategy in agent.vue: persist a per-(service,platform)
|
|
1298
1842
|
// `clearedAt` ms timestamp and drop any history entry whose `updated`
|
|
@@ -1328,7 +1872,7 @@
|
|
|
1328
1872
|
}
|
|
1329
1873
|
|
|
1330
1874
|
_onClearHistoryClick() {
|
|
1331
|
-
const busy = this.sending || this.uploading || this._anyPending();
|
|
1875
|
+
const busy = this.sending || this.uploading || this.typing || this._anyPending();
|
|
1332
1876
|
if (busy || !this.messages.length) return;
|
|
1333
1877
|
this._openClearHistoryModal();
|
|
1334
1878
|
}
|
|
@@ -1388,7 +1932,7 @@
|
|
|
1388
1932
|
}
|
|
1389
1933
|
|
|
1390
1934
|
async _logout() {
|
|
1391
|
-
const busy = this.sending || this.uploading || this._anyPending();
|
|
1935
|
+
const busy = this.sending || this.uploading || this.typing || this._anyPending();
|
|
1392
1936
|
if (busy) return;
|
|
1393
1937
|
this._openLogoutModal();
|
|
1394
1938
|
}
|
|
@@ -1443,6 +1987,238 @@
|
|
|
1443
1987
|
document.body.appendChild(modal);
|
|
1444
1988
|
}
|
|
1445
1989
|
|
|
1990
|
+
_openSettingsModal() {
|
|
1991
|
+
// Tear down any previous settings modal node first.
|
|
1992
|
+
if (this._settingsModal) {
|
|
1993
|
+
this._settingsModal.remove();
|
|
1994
|
+
this._settingsModal = null;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
const profile = this.profile || {};
|
|
1998
|
+
const isOpenId = !!profile.openid_id;
|
|
1999
|
+
|
|
2000
|
+
const close = () => {
|
|
2001
|
+
if (this._settingsModal) {
|
|
2002
|
+
this._settingsModal.remove();
|
|
2003
|
+
this._settingsModal = null;
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
|
|
2007
|
+
// ----- helpers ------------------------------------------------
|
|
2008
|
+
const mkMsg = () => $('p', { class: 'bq-modal-msg', style: { display: 'none' } });
|
|
2009
|
+
const setMsg = (node, text, ok) => {
|
|
2010
|
+
node.textContent = text || '';
|
|
2011
|
+
node.className = 'bq-modal-msg ' + (ok ? 'bq-modal-msg--ok' : 'bq-modal-msg--err');
|
|
2012
|
+
node.style.display = text ? '' : 'none';
|
|
2013
|
+
};
|
|
2014
|
+
const lockBtn = (btn, locked, label) => {
|
|
2015
|
+
btn.disabled = !!locked;
|
|
2016
|
+
if (locked) btn.classList.add('is-loading');
|
|
2017
|
+
else btn.classList.remove('is-loading');
|
|
2018
|
+
if (label != null) btn.textContent = label;
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
// ----- Email section -----------------------------------------
|
|
2022
|
+
const emailValueEl = $('span', { class: 'bq-modal-value' }, profile.email || '—');
|
|
2023
|
+
const verifiedBadge = $('span', {
|
|
2024
|
+
class: 'bq-modal-badge ' + (profile.email_verified ? 'bq-modal-badge--ok' : 'bq-modal-badge--warn'),
|
|
2025
|
+
}, profile.email_verified ? 'verified' : 'unverified');
|
|
2026
|
+
|
|
2027
|
+
const emailMsg = mkMsg();
|
|
2028
|
+
const verifyCodeInput = $('input', {
|
|
2029
|
+
type: 'text',
|
|
2030
|
+
class: 'bq-settings-input',
|
|
2031
|
+
placeholder: 'Verification code',
|
|
2032
|
+
autocomplete: 'one-time-code',
|
|
2033
|
+
inputmode: 'numeric',
|
|
2034
|
+
style: { display: profile.email_verified ? 'none' : '' },
|
|
2035
|
+
});
|
|
2036
|
+
const sendCodeBtn = $('button', {
|
|
2037
|
+
type: 'button',
|
|
2038
|
+
class: 'bq-settings-btn',
|
|
2039
|
+
style: { display: profile.email_verified ? 'none' : '' },
|
|
2040
|
+
onclick: async () => {
|
|
2041
|
+
setMsg(emailMsg, '', true);
|
|
2042
|
+
lockBtn(sendCodeBtn, true, 'Sending…');
|
|
2043
|
+
try {
|
|
2044
|
+
// verifyEmail() with no args asks Cognito to send the
|
|
2045
|
+
// verification code to the pending email address.
|
|
2046
|
+
await this.skapi.verifyEmail();
|
|
2047
|
+
setMsg(emailMsg, 'Verification code sent. Check your inbox.', true);
|
|
2048
|
+
lockBtn(sendCodeBtn, false, 'Resend code');
|
|
2049
|
+
} catch (err) {
|
|
2050
|
+
setMsg(emailMsg, (err && err.message) || String(err), false);
|
|
2051
|
+
lockBtn(sendCodeBtn, false, 'Send code');
|
|
2052
|
+
}
|
|
2053
|
+
},
|
|
2054
|
+
}, 'Send code');
|
|
2055
|
+
const confirmCodeBtn = $('button', {
|
|
2056
|
+
type: 'button',
|
|
2057
|
+
class: 'bq-settings-btn bq-settings-btn--primary',
|
|
2058
|
+
style: { display: profile.email_verified ? 'none' : '' },
|
|
2059
|
+
onclick: async () => {
|
|
2060
|
+
const code = verifyCodeInput.value.trim();
|
|
2061
|
+
if (!code) {
|
|
2062
|
+
setMsg(emailMsg, 'Enter the verification code first.', false);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
setMsg(emailMsg, '', true);
|
|
2066
|
+
lockBtn(confirmCodeBtn, true, 'Verifying…');
|
|
2067
|
+
try {
|
|
2068
|
+
await this.skapi.verifyEmail({ code });
|
|
2069
|
+
if (this.profile) this.profile.email_verified = true;
|
|
2070
|
+
setMsg(emailMsg, 'Email verified.', true);
|
|
2071
|
+
verifiedBadge.textContent = 'verified';
|
|
2072
|
+
verifiedBadge.className = 'bq-modal-badge bq-modal-badge--ok';
|
|
2073
|
+
verifyCodeInput.style.display = 'none';
|
|
2074
|
+
sendCodeBtn.style.display = 'none';
|
|
2075
|
+
confirmCodeBtn.style.display = 'none';
|
|
2076
|
+
lockBtn(confirmCodeBtn, false, 'Verify');
|
|
2077
|
+
} catch (err) {
|
|
2078
|
+
setMsg(emailMsg, (err && err.message) || String(err), false);
|
|
2079
|
+
lockBtn(confirmCodeBtn, false, 'Verify');
|
|
2080
|
+
}
|
|
2081
|
+
},
|
|
2082
|
+
}, 'Verify');
|
|
2083
|
+
|
|
2084
|
+
const newEmailInput = $('input', {
|
|
2085
|
+
type: 'email',
|
|
2086
|
+
class: 'bq-settings-input',
|
|
2087
|
+
placeholder: 'New email address',
|
|
2088
|
+
autocomplete: 'email',
|
|
2089
|
+
required: true,
|
|
2090
|
+
});
|
|
2091
|
+
const changeEmailMsg = mkMsg();
|
|
2092
|
+
const changeEmailBtn = $('button', {
|
|
2093
|
+
type: 'submit',
|
|
2094
|
+
class: 'bq-settings-btn bq-settings-btn--primary',
|
|
2095
|
+
}, 'Change email');
|
|
2096
|
+
const changeEmailForm = $('form', {
|
|
2097
|
+
class: 'bq-modal-form',
|
|
2098
|
+
onsubmit: async (e) => {
|
|
2099
|
+
e.preventDefault();
|
|
2100
|
+
const next = newEmailInput.value.trim();
|
|
2101
|
+
if (!next) return;
|
|
2102
|
+
setMsg(changeEmailMsg, '', true);
|
|
2103
|
+
lockBtn(changeEmailBtn, true, 'Saving…');
|
|
2104
|
+
try {
|
|
2105
|
+
await this.skapi.updateProfile({ email: next });
|
|
2106
|
+
if (this.profile) {
|
|
2107
|
+
this.profile.email = next;
|
|
2108
|
+
this.profile.email_verified = false;
|
|
2109
|
+
}
|
|
2110
|
+
emailValueEl.textContent = next;
|
|
2111
|
+
verifiedBadge.textContent = 'unverified';
|
|
2112
|
+
verifiedBadge.className = 'bq-modal-badge bq-modal-badge--warn';
|
|
2113
|
+
verifyCodeInput.style.display = '';
|
|
2114
|
+
sendCodeBtn.style.display = '';
|
|
2115
|
+
confirmCodeBtn.style.display = '';
|
|
2116
|
+
newEmailInput.value = '';
|
|
2117
|
+
setMsg(changeEmailMsg, 'Email updated. Use "Send code" above to verify the new address.', true);
|
|
2118
|
+
lockBtn(changeEmailBtn, false, 'Change email');
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
setMsg(changeEmailMsg, (err && err.message) || String(err), false);
|
|
2121
|
+
lockBtn(changeEmailBtn, false, 'Change email');
|
|
2122
|
+
}
|
|
2123
|
+
},
|
|
2124
|
+
}, [
|
|
2125
|
+
newEmailInput,
|
|
2126
|
+
$('div', { class: 'bq-modal-form-actions' }, [changeEmailBtn]),
|
|
2127
|
+
changeEmailMsg,
|
|
2128
|
+
]);
|
|
2129
|
+
|
|
2130
|
+
const emailSection = $('section', { class: 'bq-modal-section' }, [
|
|
2131
|
+
$('h3', { class: 'bq-modal-section-title' }, 'Email'),
|
|
2132
|
+
$('div', { class: 'bq-modal-row' }, [emailValueEl, verifiedBadge]),
|
|
2133
|
+
$('div', { class: 'bq-modal-verify-row' }, [
|
|
2134
|
+
verifyCodeInput,
|
|
2135
|
+
sendCodeBtn,
|
|
2136
|
+
confirmCodeBtn,
|
|
2137
|
+
]),
|
|
2138
|
+
emailMsg,
|
|
2139
|
+
changeEmailForm,
|
|
2140
|
+
]);
|
|
2141
|
+
|
|
2142
|
+
// ----- Password section --------------------------------------
|
|
2143
|
+
let passwordSection = null;
|
|
2144
|
+
if (!isOpenId) {
|
|
2145
|
+
const currPw = $('input', { type: 'password', class: 'bq-settings-input', placeholder: 'Current password', autocomplete: 'current-password', required: true });
|
|
2146
|
+
const newPw = $('input', { type: 'password', class: 'bq-settings-input', placeholder: 'New password', autocomplete: 'new-password', minlength: '6', required: true });
|
|
2147
|
+
const confirmPw = $('input', { type: 'password', class: 'bq-settings-input', placeholder: 'Confirm new password', autocomplete: 'new-password', minlength: '6', required: true });
|
|
2148
|
+
const pwMsg = mkMsg();
|
|
2149
|
+
const pwBtn = $('button', {
|
|
2150
|
+
type: 'submit',
|
|
2151
|
+
class: 'bq-settings-btn bq-settings-btn--primary',
|
|
2152
|
+
}, 'Change password');
|
|
2153
|
+
|
|
2154
|
+
const pwForm = $('form', {
|
|
2155
|
+
class: 'bq-modal-form',
|
|
2156
|
+
onsubmit: async (e) => {
|
|
2157
|
+
e.preventDefault();
|
|
2158
|
+
setMsg(pwMsg, '', true);
|
|
2159
|
+
if (newPw.value !== confirmPw.value) {
|
|
2160
|
+
setMsg(pwMsg, 'New password and confirmation do not match.', false);
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
lockBtn(pwBtn, true, 'Saving…');
|
|
2164
|
+
try {
|
|
2165
|
+
await this.skapi.changePassword({
|
|
2166
|
+
current_password: currPw.value,
|
|
2167
|
+
new_password: newPw.value,
|
|
2168
|
+
});
|
|
2169
|
+
currPw.value = '';
|
|
2170
|
+
newPw.value = '';
|
|
2171
|
+
confirmPw.value = '';
|
|
2172
|
+
setMsg(pwMsg, 'Password changed.', true);
|
|
2173
|
+
lockBtn(pwBtn, false, 'Change password');
|
|
2174
|
+
} catch (err) {
|
|
2175
|
+
setMsg(pwMsg, (err && err.message) || String(err), false);
|
|
2176
|
+
lockBtn(pwBtn, false, 'Change password');
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
}, [
|
|
2180
|
+
currPw,
|
|
2181
|
+
newPw,
|
|
2182
|
+
confirmPw,
|
|
2183
|
+
$('div', { class: 'bq-modal-form-actions' }, [pwBtn]),
|
|
2184
|
+
pwMsg,
|
|
2185
|
+
]);
|
|
2186
|
+
|
|
2187
|
+
passwordSection = $('section', { class: 'bq-modal-section' }, [
|
|
2188
|
+
$('h3', { class: 'bq-modal-section-title' }, 'Password'),
|
|
2189
|
+
pwForm,
|
|
2190
|
+
]);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// ----- Modal shell -------------------------------------------
|
|
2194
|
+
const closeBtn = $('button', {
|
|
2195
|
+
type: 'button',
|
|
2196
|
+
class: 'bq-modal-close',
|
|
2197
|
+
'aria-label': 'Close settings',
|
|
2198
|
+
onclick: close,
|
|
2199
|
+
}, '×');
|
|
2200
|
+
|
|
2201
|
+
const header = $('div', { class: 'bq-modal-header' }, [
|
|
2202
|
+
$('span', { class: 'bq-modal-title' }, 'Account Settings'),
|
|
2203
|
+
closeBtn,
|
|
2204
|
+
]);
|
|
2205
|
+
|
|
2206
|
+
const body = $('div', { class: 'bq-modal-body' }, [
|
|
2207
|
+
emailSection,
|
|
2208
|
+
passwordSection,
|
|
2209
|
+
].filter(Boolean));
|
|
2210
|
+
|
|
2211
|
+
const card = $('div', { class: 'bq-modal-card' }, [header, body]);
|
|
2212
|
+
|
|
2213
|
+
const modal = $('div', {
|
|
2214
|
+
class: 'bq-modal-backdrop',
|
|
2215
|
+
onclick: (e) => { if (e.target === modal) close(); },
|
|
2216
|
+
}, [card]);
|
|
2217
|
+
|
|
2218
|
+
this._settingsModal = modal;
|
|
2219
|
+
document.body.appendChild(modal);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
1446
2222
|
async _performLogout() {
|
|
1447
2223
|
// Stop any in-flight pending poll so the chat doesn't try to
|
|
1448
2224
|
// refetch history right after we tear it down.
|
|
@@ -1662,15 +2438,19 @@
|
|
|
1662
2438
|
}
|
|
1663
2439
|
},
|
|
1664
2440
|
});
|
|
1665
|
-
// After upload, fetch a temporary URL the AI
|
|
2441
|
+
// After upload, fetch a temporary signed URL the AI
|
|
2442
|
+
// can read. uploadHostFiles returns an object whose
|
|
2443
|
+
// `url` is a full https endpoint; getFile() turns it
|
|
2444
|
+
// into a short-lived signed CDN URL.
|
|
1666
2445
|
const completed = (res && (res.completed || res)) || [];
|
|
1667
2446
|
const first = Array.isArray(completed) ? completed[0] : completed;
|
|
1668
|
-
const
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
2447
|
+
const sourceUrl = first && (first.url || first.path || first.key);
|
|
2448
|
+
if (!sourceUrl || !/^https?:\/\//i.test(sourceUrl)) {
|
|
2449
|
+
throw new Error('Upload response missing a usable URL.');
|
|
2450
|
+
}
|
|
2451
|
+
const url = await this.skapi.getFile(sourceUrl, {
|
|
2452
|
+
dataType: 'endpoint',
|
|
1672
2453
|
expires: DEFAULTS.ATTACHMENT_URL_EXPIRES_SECONDS,
|
|
1673
|
-
generate_temporary_cdn_url: true,
|
|
1674
2454
|
});
|
|
1675
2455
|
a.uploadedUrl = typeof url === 'string' ? url : (url && (url.url || url.cdn_url));
|
|
1676
2456
|
a.status = 'done';
|
|
@@ -1717,7 +2497,7 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
|
|
|
1717
2497
|
}
|
|
1718
2498
|
|
|
1719
2499
|
async _sendMessage() {
|
|
1720
|
-
if (this.sending || this.uploading || this._anyPending()) return;
|
|
2500
|
+
if (this.sending || this.uploading || this.typing || this._anyPending()) return;
|
|
1721
2501
|
const text = (this.refs.textarea.value || '').trim();
|
|
1722
2502
|
if (!text && !this.attachments.length) return;
|
|
1723
2503
|
|
|
@@ -1819,15 +2599,22 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
|
|
|
1819
2599
|
? extractClaudeText(result)
|
|
1820
2600
|
: extractOpenAIText(result);
|
|
1821
2601
|
|
|
1822
|
-
|
|
2602
|
+
const finalText = responseText || '(no response)';
|
|
2603
|
+
// Replace the trailing pending assistant with an empty
|
|
2604
|
+
// shell, then stream the full response in via typewriter.
|
|
2605
|
+
// Mirrors agent.vue's typewriteLatestReply flow.
|
|
1823
2606
|
const last = this.messages[this.messages.length - 1];
|
|
2607
|
+
let idx;
|
|
1824
2608
|
if (last && last.isPending) {
|
|
1825
2609
|
last.isPending = false;
|
|
1826
|
-
last.content =
|
|
2610
|
+
last.content = '';
|
|
2611
|
+
idx = this.messages.length - 1;
|
|
1827
2612
|
} else {
|
|
1828
|
-
this.messages.push({ role: 'assistant', content:
|
|
2613
|
+
this.messages.push({ role: 'assistant', content: '' });
|
|
2614
|
+
idx = this.messages.length - 1;
|
|
1829
2615
|
}
|
|
1830
2616
|
this._renderMessages();
|
|
2617
|
+
this._enqueueTypewrite(idx, finalText);
|
|
1831
2618
|
this._schedulePendingPoll();
|
|
1832
2619
|
} catch (err) {
|
|
1833
2620
|
const last = this.messages[this.messages.length - 1];
|