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/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
- // ----- 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;
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 normalizeBareUrl = (url) => {
75
- if (!url) return '';
76
- let out = url;
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
- // Trim sentence punctuation that often trails pasted URLs.
79
- out = out.replace(/[.,;:!?]+$/, '');
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
- // Remove unmatched closing wrappers but preserve balanced pairs.
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
- out = out.slice(0, -1);
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
- 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
189
+ // HTML-escape a string for use as element text or attribute value.
190
+ const escapeAttr = (s) => String(s).replace(/[&<>"']/g, (ch) => ({
191
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
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
- // 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
- }
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
- // 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
- });
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
- last = lm.index + lm[0].length;
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
- 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
- }
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 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) {
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
- if (msgKey) _bareFenceNameCounters.set(msgKey, bareCounters);
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
- // 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
- }
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
- // Parse links in remaining text after last fence
303
- let tail = content.slice(lastFenceEnd);
304
- if (tail) {
305
- parseMsgLinks(tail, lastFenceEnd, parts);
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: 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);
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 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
- }
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
- return parts;
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
- // 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);
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
- 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
- }
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
- if (stickToBottom) {
1258
- box.scrollTop = box.scrollHeight;
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 can read.
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 path = (first && (first.path || first.url || first.key)) || a.name;
1669
- const url = await this.skapi.getTemporaryUrl({
1670
- request: 'get-db',
1671
- path,
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
- // Replace the trailing pending assistant with the result.
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 = responseText || '(no response)';
2610
+ last.content = '';
2611
+ idx = this.messages.length - 1;
1827
2612
  } else {
1828
- this.messages.push({ role: 'assistant', content: responseText || '(no response)' });
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];