bunnyquery 1.0.10 → 1.1.2

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,124 +66,270 @@
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
+ };
161
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
+ };
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) ---------------------------
165
- // Bare language tags we treat as downloadable files. Maps to the
166
- // extension used in the synthesized filename. Mirrors the file types the
167
- // host agent (agent.vue) is known to emit as ```filename.ext fences.
168
- const FENCE_LANGUAGE_EXTENSIONS = {
169
- html: 'html',
170
- htm: 'html',
171
- csv: 'csv',
172
- tsv: 'tsv',
173
- json: 'json',
174
- xml: 'xml',
175
- svg: 'svg',
176
- md: 'md',
177
- markdown: 'md',
178
- yaml: 'yaml',
179
- yml: 'yml',
180
- txt: 'txt',
181
- sql: 'sql',
182
- css: 'css',
183
- };
323
+ // Only `\`\`\`filename.ext\n...\n\`\`\`` fences become downloadable file
324
+ // anchors (matches the host system prompt in agent.vue). Bare-language
325
+ // fences like \`\`\`html / \`\`\`csv render as normal code blocks.
184
326
 
327
+ // Minimal MIME table for the file extensions the host agent is known to
328
+ // emit. Anything else falls back to text/plain so the browser still
329
+ // downloads the blob as a text file.
185
330
  const FENCE_MIME_TYPES = {
186
331
  html: 'text/html',
332
+ htm: 'text/html',
187
333
  csv: 'text/csv',
188
334
  tsv: 'text/tab-separated-values',
189
335
  json: 'application/json',
@@ -197,22 +343,9 @@
197
343
  css: 'text/css',
198
344
  };
199
345
 
200
- // Closed fences with `filename.ext` after the opening backticks.
201
- const FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n([\s\S]*?)```/g;
202
- // Closed fences whose info-string is one of the known language tags
203
- // above. The model often emits ```html ... ``` or ```csv ... ``` instead
204
- // of a full filename — surface those as downloadable files too.
205
- const FENCE_LANG_RE = new RegExp(
206
- '```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n([\\s\\S]*?)```',
207
- 'gi'
208
- );
209
- // Open (still-streaming) fences for either pattern, used to suppress the
210
- // raw fence text mid-typewriter.
346
+ // Open (still-streaming) filename fence, used to suppress the raw fence
347
+ // text mid-typewriter.
211
348
  const OPEN_FENCE_FILENAME_RE = /```([\w.-]+\.[a-zA-Z0-9]+)\n?/;
212
- const OPEN_FENCE_LANG_RE = new RegExp(
213
- '```(' + Object.keys(FENCE_LANGUAGE_EXTENSIONS).join('|') + ')[ \\t]*\\n?',
214
- 'i'
215
- );
216
349
 
217
350
  // Cache blob URLs keyed by (filename + content) so re-renders don't leak
218
351
  // a fresh ObjectURL on every tick of streaming text.
@@ -229,104 +362,101 @@
229
362
  return href;
230
363
  };
231
364
 
232
- // Counter used to mint stable filenames for bare-language fences within
233
- // a single rendered message ("download.html", "download-2.html", ...).
234
- // Keyed by message identity so re-renders of the same message reuse the
235
- // same names (which lets the blob cache hit).
236
- const _bareFenceNameCounters = new WeakMap();
237
- const _bareFilenameFor = (msgKey, ext, occurrence) => {
238
- const base = 'download';
239
- return occurrence === 0 ? `${base}.${ext}` : `${base}-${occurrence + 1}.${ext}`;
240
- };
241
-
242
- // Split message content into a sequence of {type:'text'|'file'} parts,
243
- // mirroring agent.vue's parseMsgParts behavior for fenced code blocks.
244
- // Bare-language fences (```html, ```csv, ...) are also lifted into
245
- // downloadable file parts with a synthesized filename.
246
- const parseMsgParts = (content, msgKey, isTyping) => {
247
- const parts = [];
248
- if (!content) return parts;
249
-
250
- // First pass: extract file fences (existing logic)
251
- const fenceParts = [];
252
- const fenceMatches = [];
253
- FENCE_FILENAME_RE.lastIndex = 0;
254
- let mm;
255
- while ((mm = FENCE_FILENAME_RE.exec(content)) !== null) {
256
- fenceMatches.push({ index: mm.index, length: mm[0].length, filename: mm[1], body: mm[2], kind: 'filename' });
257
- }
258
- FENCE_LANG_RE.lastIndex = 0;
259
- let bareCounters = _bareFenceNameCounters.get(msgKey);
260
- if (!bareCounters) {
261
- bareCounters = {};
262
- if (msgKey) _bareFenceNameCounters.set(msgKey, bareCounters);
263
- }
264
- const bareSeen = {};
265
- while ((mm = FENCE_LANG_RE.exec(content)) !== null) {
266
- const lang = mm[1].toLowerCase();
267
- const ext = FENCE_LANGUAGE_EXTENSIONS[lang];
268
- const occurrence = bareSeen[ext] || 0;
269
- bareSeen[ext] = occurrence + 1;
270
- fenceMatches.push({
271
- index: mm.index,
272
- length: mm[0].length,
273
- filename: _bareFilenameFor(msgKey, ext, occurrence),
274
- body: mm[2],
275
- kind: 'lang',
276
- });
277
- }
278
- fenceMatches.sort((a, b) => a.index - b.index || (a.kind === 'filename' ? -1 : 1));
279
- const dedupFences = [];
280
- for (const m of fenceMatches) {
281
- if (dedupFences.length && dedupFences[dedupFences.length - 1].index === m.index) continue;
282
- dedupFences.push(m);
365
+ // Split message content into renderable parts. When `marked` is loaded,
366
+ // returns a single `{type:'html', html}` part containing the full
367
+ // markdown-rendered bubble (with file/link anchors spliced back in).
368
+ // When marked is not yet loaded, returns `{type:'fallback', text}` so the
369
+ // bubble can render plain text until marked finishes loading.
370
+ //
371
+ // Mirrors agent.vue's parseMsgParts:
372
+ // 1. extract closed file fences `\`\`\`filename.ext\n...\`\`\`` file anchors
373
+ // 2. mid-typewriter unclosed file fence → `[generating filename...]`
374
+ // 3. mask inline `\`code\`` spans so links inside aren't extracted
375
+ // 4. extract inline links (src::, [label](url), [label](path), bare URL)
376
+ // via createInlineLinkRegex link anchors with data-bq-* attrs
377
+ // 5. restore code masks
378
+ // 6. marked.parse(working, { gfm:true, breaks:true })
379
+ // 7. splice placeholders back as raw anchor HTML
380
+ const parseMsgParts = (content, msgKey, ctx) => {
381
+ if (!content) return [];
382
+ ctx = ctx || {};
383
+ const marked = (typeof global.marked !== 'undefined' && global.marked && global.marked.parse) ? global.marked : null;
384
+
385
+ // Always extract files/links so the fallback text branch can still
386
+ // render placeholders if we hit it. But the placeholder strategy
387
+ // only works when marked is available; otherwise just hand the raw
388
+ // content back as a plain-text fallback (marked load triggers a
389
+ // re-render).
390
+ if (!marked) {
391
+ // Trigger lazy load if not already in flight, then re-render
392
+ // once it resolves. The instance hooks this callback in via
393
+ // `ctx.onMarkedReady`.
394
+ if (ctx.onMarkedReady) {
395
+ _getMarked().then((m) => { if (m) ctx.onMarkedReady(); });
396
+ }
397
+ return [{ type: 'fallback', text: content }];
283
398
  }
284
399
 
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);
400
+ const placeholderHtml = [];
401
+ const PH = (idx) => `\uE000BQ${idx}\uE001`;
402
+ const push = (anchorHtml) => {
403
+ const idx = placeholderHtml.length;
404
+ placeholderHtml.push(anchorHtml);
405
+ return PH(idx);
406
+ };
407
+
408
+ // 1. Closed filename fences: ```filename.ext\n...\n```
409
+ let working = content.replace(
410
+ /```([\w.-]+\.[a-zA-Z0-9]+)\n([\s\S]*?)```/g,
411
+ (_full, filename, body) =>
412
+ push(fileToAnchorHtml(filename, _getOrCreateFileHref(filename, body))),
413
+ );
414
+
415
+ // 2. Mid-typewriter unclosed fence → hide raw streaming source.
416
+ if (ctx.isTyping) {
417
+ const open = working.match(OPEN_FENCE_FILENAME_RE);
418
+ if (open && typeof open.index === 'number') {
419
+ const dots = '.'.repeat(((ctx.dotTick || 0) % 3) + 1);
420
+ working = working.slice(0, open.index) + `\n[generating ${open[1]}${dots}]`;
292
421
  }
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
422
  }
301
423
 
302
- // Parse links in remaining text after last fence
303
- let tail = content.slice(lastFenceEnd);
304
- if (tail) {
305
- parseMsgLinks(tail, lastFenceEnd, parts);
306
- }
424
+ // 3. Mask inline code spans so links inside backticks survive.
425
+ const codeMasks = [];
426
+ working = working.replace(/`[^`\n]+`/g, (m) => {
427
+ const idx = codeMasks.length;
428
+ codeMasks.push(m);
429
+ return `\uE002C${idx}\uE003`;
430
+ });
307
431
 
308
- // Mid-typewriter: hide raw source for a still-open fence so the user
309
- // doesn't watch literal code stream by character-by-character.
310
- if (isTyping && tail) {
311
- const fnOpen = tail.match(OPEN_FENCE_FILENAME_RE);
312
- const langOpen = tail.match(OPEN_FENCE_LANG_RE);
313
- const open = fnOpen && (!langOpen || fnOpen.index <= langOpen.index)
314
- ? { match: fnOpen, label: fnOpen[1] }
315
- : langOpen
316
- ? { match: langOpen, label: 'download.' + FENCE_LANGUAGE_EXTENSIONS[langOpen[1].toLowerCase()] }
317
- : null;
318
- if (open) {
319
- const before = tail.slice(0, open.match.index);
320
- if (before && parts.length && parts[parts.length - 1].type === 'text') {
321
- parts[parts.length - 1].text += '\n[generating ' + open.label + '...]';
322
- } else if (before) {
323
- parts.push({ type: 'text', text: before + '\n[generating ' + open.label + '...]' });
324
- } else {
325
- parts.push({ type: 'text', text: '\n[generating ' + open.label + '...]' });
326
- }
327
- }
432
+ // 4. Extract inline links/sources via createInlineLinkRegex.
433
+ const linkRe = createInlineLinkRegex();
434
+ working = working.replace(linkRe, (full, g1, g2, g3, g4, g5, g6) => {
435
+ const built = buildLinkPartFromGroups(full, g1, g2, g3, g4, g5, g6, ctx);
436
+ if (!built) return full;
437
+ const placeholder = push(linkToAnchorHtml(built.part, ctx));
438
+ return placeholder + (built.tail || '');
439
+ });
440
+
441
+ // 5. Restore code spans for marked to format.
442
+ working = working.replace(/\uE002C(\d+)\uE003/g, (_m, idx) => codeMasks[Number(idx)] || '');
443
+
444
+ // 6. Markdown HTML.
445
+ let html;
446
+ try {
447
+ html = marked.parse(working, { gfm: true, breaks: true, async: false });
448
+ } catch (err) {
449
+ console.error('[BunnyQuery] marked.parse failed', err);
450
+ html = escapeAttr(working);
328
451
  }
329
- return parts;
452
+
453
+ // 7. Splice anchor placeholders back.
454
+ const finalHtml = html.replace(
455
+ /\uE000BQ(\d+)\uE001/g,
456
+ (_m, idx) => placeholderHtml[Number(idx)] || '',
457
+ );
458
+
459
+ return [{ type: 'html', html: finalHtml }];
330
460
  };
331
461
 
332
462
  // ----- helpers ----------------------------------------------------------
@@ -908,6 +1038,29 @@
908
1038
 
909
1039
  this.refs = {};
910
1040
 
1041
+ // Cache of expired-href -> fresh signed URL. Populated on
1042
+ // demand the first time a user clicks a `[label](path)` or
1043
+ // `src::token` source link, so subsequent clicks (and reflows
1044
+ // that re-render the bubble) reuse the same signed URL.
1045
+ this._refreshedLinkMap = Object.create(null);
1046
+
1047
+ // Set of expired-hrefs currently in flight to getTemporaryUrl,
1048
+ // used to render "(fetching...)" labels and dedupe parallel
1049
+ // clicks on the same anchor.
1050
+ this._refreshingLinkSet = new Set();
1051
+ this._refreshingLinkPromises = new Map();
1052
+
1053
+ // Animated dots for typewriter [generating filename...] labels.
1054
+ this._dotTick = 0;
1055
+ this._dotTickTimer = null;
1056
+
1057
+ // Typewriter state. While `typing` is true the latest assistant
1058
+ // bubble streams character-by-character; while `typingAbort` is
1059
+ // true any active typewriter unwinds to the full text.
1060
+ this.typing = false;
1061
+ this._typingAbort = false;
1062
+ this._typewriterQueue = Promise.resolve();
1063
+
911
1064
  this._injectStylesheetOnce();
912
1065
  this._bootstrap();
913
1066
  }
@@ -968,6 +1121,11 @@
968
1121
  profile = await this.skapi.getProfile();
969
1122
  } catch (_) { profile = null; }
970
1123
 
1124
+ // Cache for the settings modal so it can show the current email
1125
+ // and decide whether to expose the password form (openid users
1126
+ // can't change passwords through Cognito).
1127
+ this.profile = profile;
1128
+
971
1129
  if (!profile) {
972
1130
  this._renderLogin();
973
1131
  return;
@@ -1014,14 +1172,20 @@
1014
1172
  );
1015
1173
  }
1016
1174
 
1017
- _renderLogin() {
1175
+ _renderLogin(prefill) {
1018
1176
  this._clear();
1019
1177
  const errLabel = $('p', { class: 'bq-apikey-error', style: { display: 'none' } });
1178
+ const noteLabel = $('p', { class: 'bq-login-note', style: { display: 'none' } });
1179
+ if (prefill && prefill.notice) {
1180
+ noteLabel.textContent = prefill.notice;
1181
+ noteLabel.style.display = '';
1182
+ }
1020
1183
  const userInput = $('input', {
1021
1184
  type: 'text',
1022
1185
  class: 'bq-input',
1023
1186
  placeholder: 'Email or username',
1024
1187
  autocomplete: 'username',
1188
+ value: (prefill && prefill.email) || '',
1025
1189
  });
1026
1190
  const passInput = $('input', {
1027
1191
  type: 'password',
@@ -1033,6 +1197,12 @@
1033
1197
  $('span', { class: 'bq-login-submit-label' }, 'Login'),
1034
1198
  ]);
1035
1199
 
1200
+ const forgotLink = $('button', {
1201
+ type: 'button',
1202
+ class: 'bq-login-link',
1203
+ onclick: () => this._renderForgotPassword({ email: userInput.value.trim() }),
1204
+ }, 'Forgot password?');
1205
+
1036
1206
  const form = $('form', {
1037
1207
  class: 'bq-login-form',
1038
1208
  onsubmit: async (e) => {
@@ -1057,10 +1227,148 @@
1057
1227
  }
1058
1228
  },
1059
1229
  }, [
1230
+ noteLabel,
1060
1231
  errLabel,
1061
1232
  userInput,
1062
1233
  passInput,
1063
1234
  submitBtn,
1235
+ $('div', { class: 'bq-login-actions' }, [forgotLink]),
1236
+ ]);
1237
+
1238
+ this.container.appendChild(
1239
+ $('div', { class: 'bq-apikey-overlay' }, [
1240
+ $('div', { class: 'bq-apikey-overlay-inner' }, [form]),
1241
+ ])
1242
+ );
1243
+ }
1244
+
1245
+ _renderForgotPassword(prefill) {
1246
+ this._clear();
1247
+ const initialEmail = (prefill && prefill.email) || '';
1248
+
1249
+ const errLabel = $('p', { class: 'bq-apikey-error', style: { display: 'none' } });
1250
+ const okLabel = $('p', { class: 'bq-login-note', style: { display: 'none' } });
1251
+ const setMsg = (text, ok) => {
1252
+ errLabel.style.display = 'none';
1253
+ okLabel.style.display = 'none';
1254
+ if (!text) return;
1255
+ const node = ok ? okLabel : errLabel;
1256
+ node.textContent = text;
1257
+ node.style.display = '';
1258
+ };
1259
+
1260
+ const title = $('p', { class: 'bq-login-title' }, 'Reset password');
1261
+
1262
+ // ----- Step 1: email -----------------------------------------
1263
+ const emailInput = $('input', {
1264
+ type: 'email',
1265
+ class: 'bq-input',
1266
+ placeholder: 'Email',
1267
+ autocomplete: 'username',
1268
+ value: initialEmail,
1269
+ required: true,
1270
+ });
1271
+ const sendBtn = $('button', { type: 'submit', class: 'btn bq-login-submit' }, [
1272
+ $('span', { class: 'bq-login-submit-label' }, 'Send code'),
1273
+ ]);
1274
+ const sendLabel = sendBtn.querySelector('.bq-login-submit-label');
1275
+
1276
+ // ----- Step 2: code + new password (hidden until step 1 OK) --
1277
+ const codeInput = $('input', {
1278
+ type: 'text',
1279
+ class: 'bq-input',
1280
+ placeholder: 'Verification code',
1281
+ autocomplete: 'one-time-code',
1282
+ inputmode: 'numeric',
1283
+ });
1284
+ const newPwInput = $('input', {
1285
+ type: 'password',
1286
+ class: 'bq-input',
1287
+ placeholder: 'New password',
1288
+ autocomplete: 'new-password',
1289
+ minlength: '6',
1290
+ });
1291
+ const confirmPwInput = $('input', {
1292
+ type: 'password',
1293
+ class: 'bq-input',
1294
+ placeholder: 'Confirm new password',
1295
+ autocomplete: 'new-password',
1296
+ minlength: '6',
1297
+ });
1298
+ const resetBtn = $('button', { type: 'button', class: 'btn bq-login-submit' }, [
1299
+ $('span', { class: 'bq-login-submit-label' }, 'Reset password'),
1300
+ ]);
1301
+ const step2 = $('div', { class: 'bq-login-step', style: { display: 'none' } }, [
1302
+ codeInput,
1303
+ newPwInput,
1304
+ confirmPwInput,
1305
+ resetBtn,
1306
+ ]);
1307
+
1308
+ resetBtn.onclick = async () => {
1309
+ setMsg('');
1310
+ const code = codeInput.value.trim();
1311
+ const np = newPwInput.value;
1312
+ const cp = confirmPwInput.value;
1313
+ if (!code) { setMsg('Enter the verification code.', false); return; }
1314
+ if (!np || np.length < 6) { setMsg('New password must be at least 6 characters.', false); return; }
1315
+ if (np !== cp) { setMsg('New password and confirmation do not match.', false); return; }
1316
+ resetBtn.disabled = true;
1317
+ resetBtn.classList.add('is-loading');
1318
+ try {
1319
+ await this.skapi.resetPassword({
1320
+ email: emailInput.value.trim(),
1321
+ code,
1322
+ new_password: np,
1323
+ });
1324
+ this._renderLogin({
1325
+ email: emailInput.value.trim(),
1326
+ notice: 'Password reset. Sign in with your new password.',
1327
+ });
1328
+ } catch (err) {
1329
+ setMsg((err && err.message) || String(err), false);
1330
+ resetBtn.disabled = false;
1331
+ resetBtn.classList.remove('is-loading');
1332
+ }
1333
+ };
1334
+
1335
+ const backLink = $('button', {
1336
+ type: 'button',
1337
+ class: 'bq-login-link',
1338
+ onclick: () => this._renderLogin({ email: emailInput.value.trim() }),
1339
+ }, 'Back to login');
1340
+
1341
+ const form = $('form', {
1342
+ class: 'bq-login-form',
1343
+ onsubmit: async (e) => {
1344
+ e.preventDefault();
1345
+ // Step 1 only: request the code.
1346
+ const email = emailInput.value.trim();
1347
+ if (!email) { setMsg('Enter your account email.', false); return; }
1348
+ setMsg('');
1349
+ sendBtn.disabled = true;
1350
+ sendBtn.classList.add('is-loading');
1351
+ try {
1352
+ await this.skapi.forgotPassword({ email });
1353
+ sendLabel.textContent = 'Resend code';
1354
+ step2.style.display = '';
1355
+ codeInput.focus();
1356
+ setMsg('Verification code sent. Check your inbox.', true);
1357
+ } catch (err) {
1358
+ setMsg((err && err.message) || String(err), false);
1359
+ } finally {
1360
+ sendBtn.disabled = false;
1361
+ sendBtn.classList.remove('is-loading');
1362
+ }
1363
+ },
1364
+ }, [
1365
+ title,
1366
+ okLabel,
1367
+ errLabel,
1368
+ emailInput,
1369
+ sendBtn,
1370
+ step2,
1371
+ $('div', { class: 'bq-login-actions' }, [backLink]),
1064
1372
  ]);
1065
1373
 
1066
1374
  this.container.appendChild(
@@ -1105,6 +1413,14 @@
1105
1413
  onclick: () => this._onClearHistoryClick(),
1106
1414
  }, 'Clear');
1107
1415
 
1416
+ const settingsIcon = $('button', {
1417
+ type: 'button',
1418
+ class: 'bq-text-btn',
1419
+ title: 'Account settings',
1420
+ 'aria-label': 'Account settings',
1421
+ onclick: () => this._openSettingsModal(),
1422
+ }, 'Settings');
1423
+
1108
1424
  const logoutIcon = $('button', {
1109
1425
  type: 'button',
1110
1426
  class: 'bq-text-btn',
@@ -1115,6 +1431,7 @@
1115
1431
 
1116
1432
  const titleRight = $('div', { class: 'bq-title-right' }, [
1117
1433
  clearIcon,
1434
+ settingsIcon,
1118
1435
  logoutIcon,
1119
1436
  ]);
1120
1437
 
@@ -1212,37 +1529,31 @@
1212
1529
  if (msg.isPending) {
1213
1530
  bubble.appendChild($('span', { class: 'bq-loader' }, 'Thinking'));
1214
1531
  } 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);
1532
+ // Full markdown rendering via marked + placeholder
1533
+ // substitution that preserves the bq-file-download /
1534
+ // bq-link-button anchors with their data-bq-* attrs.
1535
+ // A single delegated click handler dispatches expired-
1536
+ // link refresh.
1537
+ const parts = parseMsgParts(msg.content || '', msg, {
1538
+ serviceId: this.serviceId,
1539
+ refreshedMap: this._refreshedLinkMap,
1540
+ refreshingSet: this._refreshingLinkSet,
1541
+ isTyping: !!this.typing,
1542
+ dotTick: this._dotTick,
1543
+ onMarkedReady: () => this._renderMessages(),
1544
+ });
1220
1545
  if (!parts.length) {
1221
1546
  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
- }
1547
+ } else if (parts.length === 1 && parts[0].type === 'html') {
1548
+ const md = document.createElement('div');
1549
+ md.className = 'bq-md';
1550
+ md.innerHTML = parts[0].html;
1551
+ md.addEventListener('click', (e) => this._onBubbleLinkClick(e));
1552
+ bubble.appendChild(md);
1553
+ } else if (parts.length === 1 && parts[0].type === 'fallback') {
1554
+ // marked not loaded yet — show plain text; will
1555
+ // re-render once `_getMarked()` resolves.
1556
+ bubble.textContent = parts[0].text;
1246
1557
  }
1247
1558
  }
1248
1559
  const wrapper = $('div', {
@@ -1280,7 +1591,7 @@
1280
1591
  }
1281
1592
 
1282
1593
  _updateInputDisabled() {
1283
- const busy = this.sending || this.uploading || this._anyPending() || this._loadingFirstHistory;
1594
+ const busy = this.sending || this.uploading || this.typing || this._anyPending() || this._loadingFirstHistory;
1284
1595
  if (this.refs.textarea) {
1285
1596
  this.refs.textarea.disabled = busy;
1286
1597
  this.refs.textarea.placeholder = busy
@@ -1302,6 +1613,170 @@
1302
1613
  return this.messages.some((m) => m.isPending);
1303
1614
  }
1304
1615
 
1616
+ // ---- expired-link refresh + bubble click delegation ----------------
1617
+ // Click handler attached once per rendered bubble. Finds the closest
1618
+ // `a[data-bq-link]` ancestor; if the anchor is marked expired (i.e.
1619
+ // a source-path link the user has not refreshed yet), preventDefault
1620
+ // and round-trip through `getTemporaryUrl` to obtain a fresh signed
1621
+ // URL, then open it in a new tab. Cached on the instance so repeat
1622
+ // clicks (and re-renders) reuse the same URL.
1623
+ _onBubbleLinkClick(e) {
1624
+ const target = e.target;
1625
+ if (!target || !target.closest) return;
1626
+ const anchor = target.closest('a[data-bq-link]');
1627
+ if (!anchor) return;
1628
+ if (anchor.dataset.bqExpired !== '1') return; // normal link
1629
+
1630
+ const expiredHref = anchor.dataset.bqExpiredHref || anchor.getAttribute('href') || '';
1631
+ const cached = this._refreshedLinkMap[expiredHref];
1632
+ if (cached) {
1633
+ anchor.href = cached;
1634
+ anchor.classList.remove('is-expired');
1635
+ return; // let the click proceed naturally
1636
+ }
1637
+
1638
+ e.preventDefault();
1639
+ const remotePath = anchor.dataset.bqRemotePath || '';
1640
+ const originalHref = anchor.dataset.bqOriginalHref || '';
1641
+ this._fetchFreshHrefForExpiredLink(expiredHref, remotePath, originalHref)
1642
+ .then((fresh) => {
1643
+ if (!fresh) return;
1644
+ anchor.href = fresh;
1645
+ anchor.classList.remove('is-expired');
1646
+ anchor.classList.remove('is-refreshing');
1647
+ // Re-render so the cached URL replaces all other live
1648
+ // copies of the same expired anchor in the chat.
1649
+ this._renderMessages();
1650
+ // Open in new tab — preserves the user's click intent.
1651
+ window.open(fresh, '_blank', 'noopener,noreferrer');
1652
+ })
1653
+ .catch((err) => {
1654
+ console.error('[BunnyQuery] failed to refresh expired link', err);
1655
+ alert('Failed to refresh this expired link. Please try again.');
1656
+ });
1657
+ }
1658
+
1659
+ async _fetchFreshHrefForExpiredLink(expiredHref, remotePath, originalHref) {
1660
+ const cached = this._refreshedLinkMap[expiredHref];
1661
+ if (cached) return cached;
1662
+ const inFlight = this._refreshingLinkPromises.get(expiredHref);
1663
+ if (inFlight) return inFlight;
1664
+
1665
+ this._refreshingLinkSet.add(expiredHref);
1666
+ this._renderMessages();
1667
+
1668
+ const run = async () => {
1669
+ // Prefer the original full URL captured at parse time. The
1670
+ // user-facing Skapi.getFile() needs a real http(s) URL to a
1671
+ // skapi CDN endpoint; it cannot resolve bare storage paths.
1672
+ const sourceUrl = (originalHref && /^https?:\/\//i.test(originalHref))
1673
+ ? originalHref
1674
+ : null;
1675
+ if (!sourceUrl) {
1676
+ throw new Error('Unable to refresh this expired attachment link: missing source URL.');
1677
+ }
1678
+ const res = await this.skapi.getFile(sourceUrl, {
1679
+ dataType: 'endpoint',
1680
+ expires: DEFAULTS.ATTACHMENT_URL_EXPIRES_SECONDS,
1681
+ });
1682
+ const fresh = typeof res === 'string' ? res : (res && (res.url || res.cdn_url));
1683
+ if (!fresh) throw new Error('Empty signed URL response.');
1684
+ this._refreshedLinkMap[expiredHref] = fresh;
1685
+ return fresh;
1686
+ };
1687
+ const p = run().finally(() => {
1688
+ this._refreshingLinkSet.delete(expiredHref);
1689
+ this._refreshingLinkPromises.delete(expiredHref);
1690
+ });
1691
+ this._refreshingLinkPromises.set(expiredHref, p);
1692
+ return p;
1693
+ }
1694
+
1695
+ // ---- typewriter ----------------------------------------------------
1696
+ // Reveal `fullText` into messages[idx].content character-by-character.
1697
+ // Fast-forwards through fenced file blocks (rendered as a
1698
+ // [generating filename...] placeholder anyway) and inline link
1699
+ // tokens (kept atomic so half-rendered `[label]` doesn't show up).
1700
+ async _typewriteIntoIndex(idx, fullText) {
1701
+ if (!fullText) return;
1702
+ const TICK_MS = 16;
1703
+ const charsPerTick = 3;
1704
+ const FENCE_REVEAL_MS = 200;
1705
+ const fenceTicks = Math.max(1, Math.floor(FENCE_REVEAL_MS / TICK_MS));
1706
+
1707
+ const fenceRegions = [];
1708
+ const fenceRegex = /```[\w.-]+\.[a-zA-Z0-9]+\n[\s\S]*?```/g;
1709
+ let fm;
1710
+ while ((fm = fenceRegex.exec(fullText)) !== null) {
1711
+ fenceRegions.push({ start: fm.index, end: fm.index + fm[0].length });
1712
+ }
1713
+
1714
+ const linkRegions = [];
1715
+ const linkRegex = createInlineLinkRegex();
1716
+ let lm;
1717
+ while ((lm = linkRegex.exec(fullText)) !== null) {
1718
+ linkRegions.push({ start: lm.index, end: lm.index + lm[0].length });
1719
+ }
1720
+
1721
+ // Start the dotTick animation while typewriting.
1722
+ this._startDotTick();
1723
+ this.typing = true;
1724
+ this._typingAbort = false;
1725
+
1726
+ try {
1727
+ let i = 0;
1728
+ while (i < fullText.length) {
1729
+ if (this._typingAbort) break;
1730
+ let step = charsPerTick;
1731
+ const region = fenceRegions.find((r) => i >= r.start && i < r.end);
1732
+ const linkRegion = linkRegions.find((r) => i >= r.start && i < r.end);
1733
+ if (region) {
1734
+ step = Math.max(charsPerTick, Math.ceil((region.end - i) / fenceTicks));
1735
+ } else if (linkRegion) {
1736
+ step = Math.max(charsPerTick, linkRegion.end - i);
1737
+ } else {
1738
+ const next = linkRegions.find((r) => i < r.start && i + step > r.start);
1739
+ if (next) step = next.end - i;
1740
+ }
1741
+ i = Math.min(fullText.length, i + step);
1742
+ const target = this.messages[idx];
1743
+ if (!target) break;
1744
+ target.content = fullText.slice(0, i);
1745
+ this._renderMessages();
1746
+ await new Promise((r) => setTimeout(r, TICK_MS));
1747
+ }
1748
+ } finally {
1749
+ const target = this.messages[idx];
1750
+ if (target) target.content = fullText;
1751
+ this.typing = false;
1752
+ this._stopDotTick();
1753
+ this._renderMessages();
1754
+ }
1755
+ }
1756
+
1757
+ _enqueueTypewrite(idx, fullText) {
1758
+ this._typewriterQueue = this._typewriterQueue.then(() => this._typewriteIntoIndex(idx, fullText));
1759
+ return this._typewriterQueue;
1760
+ }
1761
+
1762
+ _startDotTick() {
1763
+ if (this._dotTickTimer) return;
1764
+ this._dotTickTimer = setInterval(() => {
1765
+ this._dotTick = (this._dotTick + 1) % 3;
1766
+ // No full re-render here — the dots only matter while a
1767
+ // fence is open mid-typewriter, which the next typewriter
1768
+ // tick will repaint anyway.
1769
+ }, 400);
1770
+ }
1771
+
1772
+ _stopDotTick() {
1773
+ if (this._dotTickTimer) {
1774
+ clearInterval(this._dotTickTimer);
1775
+ this._dotTickTimer = null;
1776
+ }
1777
+ this._dotTick = 0;
1778
+ }
1779
+
1305
1780
  // ---- clear history (soft delete via localStorage horizon) -------
1306
1781
  // Mirrors the strategy in agent.vue: persist a per-(service,platform)
1307
1782
  // `clearedAt` ms timestamp and drop any history entry whose `updated`
@@ -1337,7 +1812,7 @@
1337
1812
  }
1338
1813
 
1339
1814
  _onClearHistoryClick() {
1340
- const busy = this.sending || this.uploading || this._anyPending();
1815
+ const busy = this.sending || this.uploading || this.typing || this._anyPending();
1341
1816
  if (busy || !this.messages.length) return;
1342
1817
  this._openClearHistoryModal();
1343
1818
  }
@@ -1374,6 +1849,13 @@
1374
1849
  },
1375
1850
  }, 'Clear');
1376
1851
 
1852
+ const closeBtn = $('button', {
1853
+ type: 'button',
1854
+ class: 'bq-modal-close',
1855
+ 'aria-label': 'Close',
1856
+ onclick: close,
1857
+ }, '×');
1858
+
1377
1859
  const modal = $('div', {
1378
1860
  class: 'bq-modal-backdrop',
1379
1861
  onclick: (e) => { if (e.target === modal) close(); },
@@ -1381,6 +1863,7 @@
1381
1863
  $('div', { class: 'bq-modal-delete' }, [
1382
1864
  $('div', { class: 'bq-modal-delete-header' }, [
1383
1865
  $('span', null, 'Clear chat history'),
1866
+ closeBtn,
1384
1867
  ]),
1385
1868
  $('div', { class: 'bq-modal-delete-body' }, [
1386
1869
  $('p', null, 'Hide all previous messages from this chat?'),
@@ -1397,7 +1880,7 @@
1397
1880
  }
1398
1881
 
1399
1882
  async _logout() {
1400
- const busy = this.sending || this.uploading || this._anyPending();
1883
+ const busy = this.sending || this.uploading || this.typing || this._anyPending();
1401
1884
  if (busy) return;
1402
1885
  this._openLogoutModal();
1403
1886
  }
@@ -1430,6 +1913,13 @@
1430
1913
  },
1431
1914
  }, 'Logout');
1432
1915
 
1916
+ const closeBtn = $('button', {
1917
+ type: 'button',
1918
+ class: 'bq-modal-close',
1919
+ 'aria-label': 'Close',
1920
+ onclick: close,
1921
+ }, '×');
1922
+
1433
1923
  const modal = $('div', {
1434
1924
  class: 'bq-modal-backdrop',
1435
1925
  onclick: (e) => { if (e.target === modal) close(); },
@@ -1437,6 +1927,7 @@
1437
1927
  $('div', { class: 'bq-modal-delete' }, [
1438
1928
  $('div', { class: 'bq-modal-delete-header' }, [
1439
1929
  $('span', null, 'Logout'),
1930
+ closeBtn,
1440
1931
  ]),
1441
1932
  $('div', { class: 'bq-modal-delete-body' }, [
1442
1933
  $('p', null, 'Log out of this chat?'),
@@ -1452,6 +1943,238 @@
1452
1943
  document.body.appendChild(modal);
1453
1944
  }
1454
1945
 
1946
+ _openSettingsModal() {
1947
+ // Tear down any previous settings modal node first.
1948
+ if (this._settingsModal) {
1949
+ this._settingsModal.remove();
1950
+ this._settingsModal = null;
1951
+ }
1952
+
1953
+ const profile = this.profile || {};
1954
+ const isOpenId = !!profile.openid_id;
1955
+
1956
+ const close = () => {
1957
+ if (this._settingsModal) {
1958
+ this._settingsModal.remove();
1959
+ this._settingsModal = null;
1960
+ }
1961
+ };
1962
+
1963
+ // ----- helpers ------------------------------------------------
1964
+ const mkMsg = () => $('p', { class: 'bq-modal-msg', style: { display: 'none' } });
1965
+ const setMsg = (node, text, ok) => {
1966
+ node.textContent = text || '';
1967
+ node.className = 'bq-modal-msg ' + (ok ? 'bq-modal-msg--ok' : 'bq-modal-msg--err');
1968
+ node.style.display = text ? '' : 'none';
1969
+ };
1970
+ const lockBtn = (btn, locked, label) => {
1971
+ btn.disabled = !!locked;
1972
+ if (locked) btn.classList.add('is-loading');
1973
+ else btn.classList.remove('is-loading');
1974
+ if (label != null) btn.textContent = label;
1975
+ };
1976
+
1977
+ // ----- Email section -----------------------------------------
1978
+ const emailValueEl = $('span', { class: 'bq-modal-value' }, profile.email || '—');
1979
+ const verifiedBadge = $('span', {
1980
+ class: 'bq-modal-badge ' + (profile.email_verified ? 'bq-modal-badge--ok' : 'bq-modal-badge--warn'),
1981
+ }, profile.email_verified ? 'verified' : 'unverified');
1982
+
1983
+ const emailMsg = mkMsg();
1984
+ const verifyCodeInput = $('input', {
1985
+ type: 'text',
1986
+ class: 'bq-settings-input',
1987
+ placeholder: 'Verification code',
1988
+ autocomplete: 'one-time-code',
1989
+ inputmode: 'numeric',
1990
+ style: { display: profile.email_verified ? 'none' : '' },
1991
+ });
1992
+ const sendCodeBtn = $('button', {
1993
+ type: 'button',
1994
+ class: 'bq-settings-btn',
1995
+ style: { display: profile.email_verified ? 'none' : '' },
1996
+ onclick: async () => {
1997
+ setMsg(emailMsg, '', true);
1998
+ lockBtn(sendCodeBtn, true, 'Sending…');
1999
+ try {
2000
+ // verifyEmail() with no args asks Cognito to send the
2001
+ // verification code to the pending email address.
2002
+ await this.skapi.verifyEmail();
2003
+ setMsg(emailMsg, 'Verification code sent. Check your inbox.', true);
2004
+ lockBtn(sendCodeBtn, false, 'Resend code');
2005
+ } catch (err) {
2006
+ setMsg(emailMsg, (err && err.message) || String(err), false);
2007
+ lockBtn(sendCodeBtn, false, 'Send code');
2008
+ }
2009
+ },
2010
+ }, 'Send code');
2011
+ const confirmCodeBtn = $('button', {
2012
+ type: 'button',
2013
+ class: 'bq-settings-btn bq-settings-btn--primary',
2014
+ style: { display: profile.email_verified ? 'none' : '' },
2015
+ onclick: async () => {
2016
+ const code = verifyCodeInput.value.trim();
2017
+ if (!code) {
2018
+ setMsg(emailMsg, 'Enter the verification code first.', false);
2019
+ return;
2020
+ }
2021
+ setMsg(emailMsg, '', true);
2022
+ lockBtn(confirmCodeBtn, true, 'Verifying…');
2023
+ try {
2024
+ await this.skapi.verifyEmail({ code });
2025
+ if (this.profile) this.profile.email_verified = true;
2026
+ setMsg(emailMsg, 'Email verified.', true);
2027
+ verifiedBadge.textContent = 'verified';
2028
+ verifiedBadge.className = 'bq-modal-badge bq-modal-badge--ok';
2029
+ verifyCodeInput.style.display = 'none';
2030
+ sendCodeBtn.style.display = 'none';
2031
+ confirmCodeBtn.style.display = 'none';
2032
+ lockBtn(confirmCodeBtn, false, 'Verify');
2033
+ } catch (err) {
2034
+ setMsg(emailMsg, (err && err.message) || String(err), false);
2035
+ lockBtn(confirmCodeBtn, false, 'Verify');
2036
+ }
2037
+ },
2038
+ }, 'Verify');
2039
+
2040
+ const newEmailInput = $('input', {
2041
+ type: 'email',
2042
+ class: 'bq-settings-input',
2043
+ placeholder: 'New email address',
2044
+ autocomplete: 'email',
2045
+ required: true,
2046
+ });
2047
+ const changeEmailMsg = mkMsg();
2048
+ const changeEmailBtn = $('button', {
2049
+ type: 'submit',
2050
+ class: 'bq-settings-btn bq-settings-btn--primary',
2051
+ }, 'Change email');
2052
+ const changeEmailForm = $('form', {
2053
+ class: 'bq-modal-form',
2054
+ onsubmit: async (e) => {
2055
+ e.preventDefault();
2056
+ const next = newEmailInput.value.trim();
2057
+ if (!next) return;
2058
+ setMsg(changeEmailMsg, '', true);
2059
+ lockBtn(changeEmailBtn, true, 'Saving…');
2060
+ try {
2061
+ await this.skapi.updateProfile({ email: next });
2062
+ if (this.profile) {
2063
+ this.profile.email = next;
2064
+ this.profile.email_verified = false;
2065
+ }
2066
+ emailValueEl.textContent = next;
2067
+ verifiedBadge.textContent = 'unverified';
2068
+ verifiedBadge.className = 'bq-modal-badge bq-modal-badge--warn';
2069
+ verifyCodeInput.style.display = '';
2070
+ sendCodeBtn.style.display = '';
2071
+ confirmCodeBtn.style.display = '';
2072
+ newEmailInput.value = '';
2073
+ setMsg(changeEmailMsg, 'Email updated. Use "Send code" above to verify the new address.', true);
2074
+ lockBtn(changeEmailBtn, false, 'Change email');
2075
+ } catch (err) {
2076
+ setMsg(changeEmailMsg, (err && err.message) || String(err), false);
2077
+ lockBtn(changeEmailBtn, false, 'Change email');
2078
+ }
2079
+ },
2080
+ }, [
2081
+ newEmailInput,
2082
+ $('div', { class: 'bq-modal-form-actions' }, [changeEmailBtn]),
2083
+ changeEmailMsg,
2084
+ ]);
2085
+
2086
+ const emailSection = $('section', { class: 'bq-modal-section' }, [
2087
+ $('h3', { class: 'bq-modal-section-title' }, 'Email'),
2088
+ $('div', { class: 'bq-modal-row' }, [emailValueEl, verifiedBadge]),
2089
+ $('div', { class: 'bq-modal-verify-row' }, [
2090
+ verifyCodeInput,
2091
+ sendCodeBtn,
2092
+ confirmCodeBtn,
2093
+ ]),
2094
+ emailMsg,
2095
+ changeEmailForm,
2096
+ ]);
2097
+
2098
+ // ----- Password section --------------------------------------
2099
+ let passwordSection = null;
2100
+ if (!isOpenId) {
2101
+ const currPw = $('input', { type: 'password', class: 'bq-settings-input', placeholder: 'Current password', autocomplete: 'current-password', required: true });
2102
+ const newPw = $('input', { type: 'password', class: 'bq-settings-input', placeholder: 'New password', autocomplete: 'new-password', minlength: '6', required: true });
2103
+ const confirmPw = $('input', { type: 'password', class: 'bq-settings-input', placeholder: 'Confirm new password', autocomplete: 'new-password', minlength: '6', required: true });
2104
+ const pwMsg = mkMsg();
2105
+ const pwBtn = $('button', {
2106
+ type: 'submit',
2107
+ class: 'bq-settings-btn bq-settings-btn--primary',
2108
+ }, 'Change password');
2109
+
2110
+ const pwForm = $('form', {
2111
+ class: 'bq-modal-form',
2112
+ onsubmit: async (e) => {
2113
+ e.preventDefault();
2114
+ setMsg(pwMsg, '', true);
2115
+ if (newPw.value !== confirmPw.value) {
2116
+ setMsg(pwMsg, 'New password and confirmation do not match.', false);
2117
+ return;
2118
+ }
2119
+ lockBtn(pwBtn, true, 'Saving…');
2120
+ try {
2121
+ await this.skapi.changePassword({
2122
+ current_password: currPw.value,
2123
+ new_password: newPw.value,
2124
+ });
2125
+ currPw.value = '';
2126
+ newPw.value = '';
2127
+ confirmPw.value = '';
2128
+ setMsg(pwMsg, 'Password changed.', true);
2129
+ lockBtn(pwBtn, false, 'Change password');
2130
+ } catch (err) {
2131
+ setMsg(pwMsg, (err && err.message) || String(err), false);
2132
+ lockBtn(pwBtn, false, 'Change password');
2133
+ }
2134
+ },
2135
+ }, [
2136
+ currPw,
2137
+ newPw,
2138
+ confirmPw,
2139
+ $('div', { class: 'bq-modal-form-actions' }, [pwBtn]),
2140
+ pwMsg,
2141
+ ]);
2142
+
2143
+ passwordSection = $('section', { class: 'bq-modal-section' }, [
2144
+ $('h3', { class: 'bq-modal-section-title' }, 'Password'),
2145
+ pwForm,
2146
+ ]);
2147
+ }
2148
+
2149
+ // ----- Modal shell -------------------------------------------
2150
+ const closeBtn = $('button', {
2151
+ type: 'button',
2152
+ class: 'bq-modal-close',
2153
+ 'aria-label': 'Close settings',
2154
+ onclick: close,
2155
+ }, '×');
2156
+
2157
+ const header = $('div', { class: 'bq-modal-header' }, [
2158
+ $('span', { class: 'bq-modal-title' }, 'Account Settings'),
2159
+ closeBtn,
2160
+ ]);
2161
+
2162
+ const body = $('div', { class: 'bq-modal-body' }, [
2163
+ emailSection,
2164
+ passwordSection,
2165
+ ].filter(Boolean));
2166
+
2167
+ const card = $('div', { class: 'bq-modal-card' }, [header, body]);
2168
+
2169
+ const modal = $('div', {
2170
+ class: 'bq-modal-backdrop',
2171
+ onclick: (e) => { if (e.target === modal) close(); },
2172
+ }, [card]);
2173
+
2174
+ this._settingsModal = modal;
2175
+ document.body.appendChild(modal);
2176
+ }
2177
+
1455
2178
  async _performLogout() {
1456
2179
  // Stop any in-flight pending poll so the chat doesn't try to
1457
2180
  // refetch history right after we tear it down.
@@ -1671,15 +2394,19 @@
1671
2394
  }
1672
2395
  },
1673
2396
  });
1674
- // After upload, fetch a temporary URL the AI can read.
2397
+ // After upload, fetch a temporary signed URL the AI
2398
+ // can read. uploadHostFiles returns an object whose
2399
+ // `url` is a full https endpoint; getFile() turns it
2400
+ // into a short-lived signed CDN URL.
1675
2401
  const completed = (res && (res.completed || res)) || [];
1676
2402
  const first = Array.isArray(completed) ? completed[0] : completed;
1677
- const path = (first && (first.path || first.url || first.key)) || a.name;
1678
- const url = await this.skapi.getTemporaryUrl({
1679
- request: 'get-db',
1680
- path,
2403
+ const sourceUrl = first && (first.url || first.path || first.key);
2404
+ if (!sourceUrl || !/^https?:\/\//i.test(sourceUrl)) {
2405
+ throw new Error('Upload response missing a usable URL.');
2406
+ }
2407
+ const url = await this.skapi.getFile(sourceUrl, {
2408
+ dataType: 'endpoint',
1681
2409
  expires: DEFAULTS.ATTACHMENT_URL_EXPIRES_SECONDS,
1682
- generate_temporary_cdn_url: true,
1683
2410
  });
1684
2411
  a.uploadedUrl = typeof url === 'string' ? url : (url && (url.url || url.cdn_url));
1685
2412
  a.status = 'done';
@@ -1726,7 +2453,7 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
1726
2453
  }
1727
2454
 
1728
2455
  async _sendMessage() {
1729
- if (this.sending || this.uploading || this._anyPending()) return;
2456
+ if (this.sending || this.uploading || this.typing || this._anyPending()) return;
1730
2457
  const text = (this.refs.textarea.value || '').trim();
1731
2458
  if (!text && !this.attachments.length) return;
1732
2459
 
@@ -1828,15 +2555,22 @@ The same pattern applies to other formats: \`\`\`my-data.json, \`\`\`index.html,
1828
2555
  ? extractClaudeText(result)
1829
2556
  : extractOpenAIText(result);
1830
2557
 
1831
- // Replace the trailing pending assistant with the result.
2558
+ const finalText = responseText || '(no response)';
2559
+ // Replace the trailing pending assistant with an empty
2560
+ // shell, then stream the full response in via typewriter.
2561
+ // Mirrors agent.vue's typewriteLatestReply flow.
1832
2562
  const last = this.messages[this.messages.length - 1];
2563
+ let idx;
1833
2564
  if (last && last.isPending) {
1834
2565
  last.isPending = false;
1835
- last.content = responseText || '(no response)';
2566
+ last.content = '';
2567
+ idx = this.messages.length - 1;
1836
2568
  } else {
1837
- this.messages.push({ role: 'assistant', content: responseText || '(no response)' });
2569
+ this.messages.push({ role: 'assistant', content: '' });
2570
+ idx = this.messages.length - 1;
1838
2571
  }
1839
2572
  this._renderMessages();
2573
+ this._enqueueTypewrite(idx, finalText);
1840
2574
  this._schedulePendingPoll();
1841
2575
  } catch (err) {
1842
2576
  const last = this.messages[this.messages.length - 1];