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/README.md +89 -60
- package/bunnyquery.css +419 -58
- package/bunnyquery.js +974 -240
- package/package.json +1 -1
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
|
-
// -----
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
const
|
|
69
|
+
// ----- markdown loader (lazy) ------------------------------------------
|
|
70
|
+
// Pulled from CDN on first use. While loading, parseMsgParts renders a
|
|
71
|
+
// plain-text fallback; once `marked` resolves the instance re-renders.
|
|
72
|
+
const MARKED_CDN_URL = 'https://cdn.jsdelivr.net/npm/marked@15.0.4/marked.min.js';
|
|
73
|
+
let _markedReadyPromise = null;
|
|
74
|
+
const _getMarked = () => {
|
|
75
|
+
if (typeof global.marked !== 'undefined' && global.marked && global.marked.parse) {
|
|
76
|
+
return Promise.resolve(global.marked);
|
|
77
|
+
}
|
|
78
|
+
if (_markedReadyPromise) return _markedReadyPromise;
|
|
79
|
+
_markedReadyPromise = new Promise((resolve, reject) => {
|
|
80
|
+
const existing = document.querySelector('script[data-bq-marked]');
|
|
81
|
+
if (existing) {
|
|
82
|
+
existing.addEventListener('load', () => resolve(global.marked), { once: true });
|
|
83
|
+
existing.addEventListener('error', reject, { once: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const s = document.createElement('script');
|
|
87
|
+
s.src = MARKED_CDN_URL;
|
|
88
|
+
s.async = true;
|
|
89
|
+
s.dataset.bqMarked = '1';
|
|
90
|
+
s.addEventListener('load', () => resolve(global.marked), { once: true });
|
|
91
|
+
s.addEventListener('error', reject, { once: true });
|
|
92
|
+
document.head.appendChild(s);
|
|
93
|
+
}).catch((err) => {
|
|
94
|
+
console.error('[BunnyQuery] failed to load marked', err);
|
|
95
|
+
_markedReadyPromise = null;
|
|
96
|
+
return null;
|
|
97
|
+
});
|
|
98
|
+
return _markedReadyPromise;
|
|
99
|
+
};
|
|
100
|
+
// Eager kick-off so the first message render almost always has marked
|
|
101
|
+
// ready by the time history finishes loading.
|
|
102
|
+
if (typeof window !== 'undefined') {
|
|
103
|
+
try { _getMarked(); } catch (_) { /* noop */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ----- link parsing helpers (mirrors agent.vue) ------------------------
|
|
107
|
+
// createInlineLinkRegex groups (in order):
|
|
108
|
+
// 1: `src::<token>` — model-emitted reference to a db file (path or URL)
|
|
109
|
+
// 2: markdown link label where href IS an http(s) URL
|
|
110
|
+
// 3: markdown link href (URL)
|
|
111
|
+
// 4: markdown link label where href is NOT a URL (storage path)
|
|
112
|
+
// 5: markdown link path (non-URL) — treated like `src::<path>`
|
|
113
|
+
// 6: bare http(s) URL
|
|
114
|
+
const createInlineLinkRegex = () =>
|
|
115
|
+
/src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
|
|
116
|
+
|
|
117
|
+
const EXPIRED_ATTACHMENT_URL_HOST = '_expired_.url';
|
|
118
|
+
const EXPIRED_ATTACHMENT_URL_ORIGIN = `https://${EXPIRED_ATTACHMENT_URL_HOST}`;
|
|
119
|
+
|
|
120
|
+
const _safeDecode = (v) => {
|
|
121
|
+
try { return decodeURIComponent(v); } catch { return v; }
|
|
122
|
+
};
|
|
73
123
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
const _encodePathSegments = (p) =>
|
|
125
|
+
p.split('/').filter(Boolean).map((s) => encodeURIComponent(s)).join('/');
|
|
126
|
+
|
|
127
|
+
const normalizeAttachmentPathCandidate = (value) =>
|
|
128
|
+
_safeDecode((value || '').trim())
|
|
129
|
+
.replace(/\\/g, '/')
|
|
130
|
+
.replace(/^\/+/, '')
|
|
131
|
+
.replace(/\/+/g, '/');
|
|
132
|
+
|
|
133
|
+
const extractRemotePathFromAttachmentHref = (href, serviceId) => {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = new URL(href);
|
|
136
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
137
|
+
const path = normalizeAttachmentPathCandidate(parsed.pathname || '');
|
|
138
|
+
const segs = path.split('/').filter(Boolean);
|
|
139
|
+
if (!segs.length) return null;
|
|
140
|
+
const HEX_HASH_RE = /^[a-f0-9]{32,}$/i;
|
|
141
|
+
let start = 0;
|
|
142
|
+
while (start < segs.length) {
|
|
143
|
+
const seg = segs[start];
|
|
144
|
+
if (seg === serviceId || HEX_HASH_RE.test(seg)) { start++; continue; }
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const real = segs.slice(start).join('/');
|
|
148
|
+
return real || null;
|
|
149
|
+
} catch { return null; }
|
|
150
|
+
};
|
|
77
151
|
|
|
78
|
-
|
|
79
|
-
|
|
152
|
+
const getExpiredAttachmentVisiblePath = (remotePath, fallback = 'file') => {
|
|
153
|
+
const n = normalizeAttachmentPathCandidate(remotePath);
|
|
154
|
+
if (n) return n;
|
|
155
|
+
return normalizeAttachmentPathCandidate(fallback) || 'file';
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const buildDisplayExpiredAttachmentHref = (remotePath, fallback = 'file') => {
|
|
159
|
+
const visible = getExpiredAttachmentVisiblePath(remotePath, fallback);
|
|
160
|
+
return `${EXPIRED_ATTACHMENT_URL_ORIGIN}/${_encodePathSegments(visible)}`;
|
|
161
|
+
};
|
|
80
162
|
|
|
81
|
-
|
|
163
|
+
const LINK_LABEL_MAX_DISPLAY_CHARS = 32;
|
|
164
|
+
const truncateLabelForDisplay = (label) => {
|
|
165
|
+
if (!label) return label;
|
|
166
|
+
if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
|
|
167
|
+
return '\u2026' + label.slice(label.length - (LINK_LABEL_MAX_DISPLAY_CHARS - 1));
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const normalizeTrailingInlineToken = (value) => {
|
|
171
|
+
if (!value) return value;
|
|
172
|
+
let out = value;
|
|
173
|
+
out = out.replace(/[.,;:!?]+$/, '');
|
|
82
174
|
const trimUnmatched = (openCh, closeCh) => {
|
|
83
175
|
while (out.endsWith(closeCh)) {
|
|
84
176
|
const openCount = (out.match(new RegExp('\\' + openCh, 'g')) || []).length;
|
|
85
177
|
const closeCount = (out.match(new RegExp('\\' + closeCh, 'g')) || []).length;
|
|
86
|
-
if (closeCount > openCount)
|
|
87
|
-
|
|
88
|
-
} else {
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
178
|
+
if (closeCount > openCount) out = out.slice(0, -1);
|
|
179
|
+
else break;
|
|
91
180
|
}
|
|
92
181
|
};
|
|
93
|
-
|
|
94
182
|
trimUnmatched('(', ')');
|
|
95
183
|
trimUnmatched('[', ']');
|
|
96
184
|
trimUnmatched('{', '}');
|
|
97
|
-
|
|
185
|
+
out = out.replace(/[`'"*>]+$/, '');
|
|
98
186
|
return out;
|
|
99
187
|
};
|
|
100
188
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (cleaned.length <= maxLen) return cleaned;
|
|
112
|
-
return cleaned.slice(0, maxLen - 1) + '\u2026'; // ellipsis
|
|
189
|
+
// HTML-escape a string for use as element text or attribute value.
|
|
190
|
+
const escapeAttr = (s) => String(s).replace(/[&<>"']/g, (ch) => ({
|
|
191
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
192
|
+
}[ch]));
|
|
193
|
+
|
|
194
|
+
// Render an extracted file fence as an `<a>` HTML string. Embedded into
|
|
195
|
+
// the markdown source via placeholder; marked passes raw HTML through.
|
|
196
|
+
const fileToAnchorHtml = (filename, href) => {
|
|
197
|
+
const text = '\u2197 ' + filename;
|
|
198
|
+
return `<a class="bq-file-download" href="${escapeAttr(href)}" target="_blank" rel="noopener noreferrer" download="${escapeAttr(filename)}">${escapeAttr(text)}</a>`;
|
|
113
199
|
};
|
|
114
200
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
201
|
+
// Render an extracted link (link/src::/path/url) as an `<a>` HTML string
|
|
202
|
+
// carrying data-bq-* attributes that the bubble's click delegation reads
|
|
203
|
+
// to dispatch expired-link refresh.
|
|
204
|
+
const linkToAnchorHtml = (link, ctx) => {
|
|
205
|
+
const refreshing = !!(ctx && ctx.refreshingSet && ctx.refreshingSet.has(link.expiredHref || link.href));
|
|
206
|
+
const cls = ['bq-link-button'];
|
|
207
|
+
if (link.expired) cls.push('is-expired');
|
|
208
|
+
if (refreshing) cls.push('is-refreshing');
|
|
209
|
+
const labelText = '\u2197 ' + link.label + (refreshing ? ' (fetching...)' : '');
|
|
210
|
+
const attrs = [
|
|
211
|
+
`class="${cls.join(' ')}"`,
|
|
212
|
+
`href="${escapeAttr(link.href)}"`,
|
|
213
|
+
`target="_blank"`,
|
|
214
|
+
`rel="noopener noreferrer"`,
|
|
215
|
+
`title="${escapeAttr(link.fullLabel || link.label)}"`,
|
|
216
|
+
`download="${escapeAttr(link.fullLabel || link.label)}"`,
|
|
217
|
+
`data-bq-link="1"`,
|
|
218
|
+
];
|
|
219
|
+
if (link.expired) attrs.push(`data-bq-expired="1"`);
|
|
220
|
+
if (link.expiredHref) attrs.push(`data-bq-expired-href="${escapeAttr(link.expiredHref)}"`);
|
|
221
|
+
if (link.remotePath) attrs.push(`data-bq-remote-path="${escapeAttr(link.remotePath)}"`);
|
|
222
|
+
if (link.originalHref) attrs.push(`data-bq-original-href="${escapeAttr(link.originalHref)}"`);
|
|
223
|
+
if (link.fullLabel) attrs.push(`data-bq-full-label="${escapeAttr(link.fullLabel)}"`);
|
|
224
|
+
return `<a ${attrs.join(' ')}>${escapeAttr(labelText)}</a>`;
|
|
225
|
+
};
|
|
129
226
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
227
|
+
// Resolve a regex match from createInlineLinkRegex into a link object,
|
|
228
|
+
// or null if it can't be parsed. ctx provides `serviceId` (for CDN-prefix
|
|
229
|
+
// stripping) and `refreshedMap` (cached fresh hrefs keyed by expiredHref).
|
|
230
|
+
const buildLinkPartFromGroups = (full, g1, g2, g3, g4, g5, g6, ctx) => {
|
|
231
|
+
const serviceId = ctx && ctx.serviceId;
|
|
232
|
+
const refreshedMap = (ctx && ctx.refreshedMap) || {};
|
|
233
|
+
if (g1) {
|
|
234
|
+
const rawPath = normalizeTrailingInlineToken(g1);
|
|
235
|
+
const consumed = 'src::' + rawPath;
|
|
236
|
+
const tail = full.slice(consumed.length);
|
|
237
|
+
const isUrl = /^https?:\/\//i.test(rawPath);
|
|
238
|
+
if (isUrl && /^https:\/\//i.test(rawPath)) {
|
|
239
|
+
return {
|
|
240
|
+
part: {
|
|
241
|
+
label: truncateLabelForDisplay(rawPath),
|
|
242
|
+
fullLabel: rawPath,
|
|
243
|
+
href: rawPath,
|
|
244
|
+
expired: false,
|
|
245
|
+
},
|
|
246
|
+
tail,
|
|
247
|
+
};
|
|
149
248
|
}
|
|
150
|
-
|
|
151
|
-
|
|
249
|
+
const remotePath = isUrl
|
|
250
|
+
? (extractRemotePathFromAttachmentHref(rawPath, serviceId) || normalizeAttachmentPathCandidate(rawPath))
|
|
251
|
+
: normalizeAttachmentPathCandidate(rawPath);
|
|
252
|
+
if (!remotePath) return null;
|
|
253
|
+
const expiredHref = buildDisplayExpiredAttachmentHref(remotePath, remotePath);
|
|
254
|
+
const cached = refreshedMap[expiredHref];
|
|
255
|
+
const originalHref = isUrl ? rawPath : undefined;
|
|
256
|
+
return {
|
|
257
|
+
part: {
|
|
258
|
+
label: truncateLabelForDisplay(remotePath),
|
|
259
|
+
fullLabel: remotePath,
|
|
260
|
+
href: cached || expiredHref,
|
|
261
|
+
expired: !cached,
|
|
262
|
+
expiredHref,
|
|
263
|
+
remotePath,
|
|
264
|
+
originalHref,
|
|
265
|
+
},
|
|
266
|
+
tail,
|
|
267
|
+
};
|
|
152
268
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
269
|
+
if (g4 && g5) {
|
|
270
|
+
const rawPath = g5;
|
|
271
|
+
const remotePath = normalizeAttachmentPathCandidate(rawPath);
|
|
272
|
+
if (!remotePath) return null;
|
|
273
|
+
const expiredHref = buildDisplayExpiredAttachmentHref(remotePath, remotePath);
|
|
274
|
+
const cached = refreshedMap[expiredHref];
|
|
275
|
+
return {
|
|
276
|
+
part: {
|
|
277
|
+
label: truncateLabelForDisplay(g4),
|
|
278
|
+
fullLabel: g4,
|
|
279
|
+
href: cached || expiredHref,
|
|
280
|
+
expired: !cached,
|
|
281
|
+
expiredHref,
|
|
282
|
+
remotePath,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
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
|
-
//
|
|
201
|
-
|
|
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
|
-
//
|
|
233
|
-
// a single
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (!content) return
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
bubble.appendChild(a);
|
|
1233
|
-
} else if (part.type === 'link') {
|
|
1234
|
-
const a = $('a', {
|
|
1235
|
-
class: 'bq-link-button',
|
|
1236
|
-
href: part.href,
|
|
1237
|
-
target: '_blank',
|
|
1238
|
-
rel: 'noopener noreferrer',
|
|
1239
|
-
title: part.href,
|
|
1240
|
-
}, '\u2197 ' + part.label);
|
|
1241
|
-
bubble.appendChild(a);
|
|
1242
|
-
} else {
|
|
1243
|
-
bubble.appendChild(document.createTextNode(part.text));
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
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
|
|
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
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
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
|
-
|
|
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 =
|
|
2566
|
+
last.content = '';
|
|
2567
|
+
idx = this.messages.length - 1;
|
|
1836
2568
|
} else {
|
|
1837
|
-
this.messages.push({ role: 'assistant', content:
|
|
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];
|