tinywebui 0.1.0

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.
Files changed (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Rendering the transcript: turns, messages, thinking, tool steps, usage
3
+ * lines, notices and errors -- plus replaying a stored history into the same
4
+ * shapes a live stream would have produced.
5
+ */
6
+ import { renderMarkdown } from '../core/md.js';
7
+ import { $, el } from '../core/dom.js';
8
+ import { state } from '../core/state.js';
9
+ import { addQuestion } from './outline.js';
10
+ import { api } from '../core/api.js';
11
+ import { tally as tallyUsage } from '../shared/usage.js';
12
+ import { ORIGIN } from '../shared/origins.js';
13
+ import { splitAttachmentNotes } from '../shared/attachment_note.js';
14
+
15
+ const log = $('log');
16
+ const wrap = $('wrap');
17
+
18
+ let pinned = true;
19
+ let openChat;
20
+
21
+ /** `deps.openChat(id)` reopens the chat after an edit rewinds it. */
22
+ export function initTranscript(deps) {
23
+ ({ openChat } = deps);
24
+ log.addEventListener('scroll', () => {
25
+ pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
26
+ });
27
+ }
28
+
29
+ export function scroll() { if (pinned) log.scrollTop = log.scrollHeight; }
30
+ // Switching chats always lands on the newest message, regardless of whether
31
+ // the chat left open before it had been scrolled up to read older ones.
32
+ export function pinToBottom() { pinned = true; }
33
+ // Height also changes with nobody calling scroll(): images decoding after the
34
+ // last paint, the work collapsing at the end of a turn, the composer growing.
35
+ // Re-pin on any of it, or the answer's tail sits below the fold.
36
+ new ResizeObserver(() => { scroll(); settleResync(); }).observe(wrap);
37
+ new ResizeObserver(() => { scroll(); settleResync(); }).observe(log);
38
+
39
+ // Chrome can keep a stale scroll range for #log after a turn reshapes it (the
40
+ // work collapsing, the last markdown paint): the wheel stops short of the end
41
+ // while scrollTop can still be set past it, until a reload. Flipping overflow
42
+ // off and on for one frame makes it rebuild the range.
43
+ //
44
+ // Once at the end of a turn is not enough: layout keeps moving after it (late
45
+ // markdown paints, images decoding, the task card resizing the log), and any
46
+ // of those can leave the range stale again. So every resize also schedules a
47
+ // resync, debounced so it runs once things go quiet rather than mid-stream.
48
+ let resyncTimer = 0;
49
+ function settleResync() {
50
+ clearTimeout(resyncTimer);
51
+ resyncTimer = setTimeout(resyncScroll, 250);
52
+ }
53
+ function resyncScroll() {
54
+ requestAnimationFrame(() => {
55
+ const top = log.scrollTop;
56
+ log.style.overflowY = 'hidden';
57
+ void log.offsetHeight;
58
+ log.style.overflowY = '';
59
+ log.scrollTop = top;
60
+ scroll();
61
+ });
62
+ }
63
+
64
+ /**
65
+ * One assistant turn: the work that led to the answer, and then the answer.
66
+ *
67
+ * Which prose block is the answer cannot be known while it streams -- narration
68
+ * between two tool calls looks exactly like a conclusion, and the model does
69
+ * not announce which is which. So the newest prose is held as the candidate
70
+ * answer and demoted into the work the moment another thought or tool call
71
+ * follows it. Whatever is still standing when the turn ends is the answer.
72
+ *
73
+ * The work is a disclosure rather than a plain rail: open and naming the step
74
+ * it is on while the turn runs, collapsed behind a one-line recap once there
75
+ * is an answer to read instead.
76
+ */
77
+ export function addTurn() {
78
+ const box = el('div', 'turn');
79
+ wrap.appendChild(box);
80
+
81
+ const work = el('details', 'work live');
82
+ work.open = true;
83
+ const sum = el('summary');
84
+ const act = el('span', 'act');
85
+ const tally = el('span', 'tally');
86
+ sum.append(act, tally);
87
+ const body = el('div', 'work-body');
88
+ work.append(sum, body);
89
+
90
+ const started = Date.now();
91
+ let steps = 0;
92
+ let mounted = false;
93
+ let candidate = null;
94
+ // Every round's raw usage, kept so the turn can be summed at the end. The
95
+ // per-round lines live inside the work, which is collapsed by default; what a
96
+ // multi-round turn actually cost is only visible once they are added up.
97
+ const raws = [];
98
+
99
+ // The work only appears once there is work. A plain answer keeps the shape it
100
+ // always had, with no empty disclosure sitting above it.
101
+ const mount = () => {
102
+ if (!mounted) { box.appendChild(work); mounted = true; }
103
+ return body;
104
+ };
105
+
106
+ // Prose that turned out to be narration joins the work, in the order it was
107
+ // spoken, instead of staying up top competing with the real answer.
108
+ const demote = () => {
109
+ if (!candidate) return;
110
+ mount().appendChild(candidate);
111
+ candidate.classList.add('note');
112
+ candidate.querySelector('.who').textContent = 'note';
113
+ candidate = null;
114
+ };
115
+
116
+ const label = (text) => { act.textContent = text; scroll(); };
117
+ label('starting');
118
+
119
+ return {
120
+ el: box,
121
+ /** Container for thinking and tool steps; mounts the work on first use. */
122
+ work: mount,
123
+ /** Where a usage line goes: with the work when there is any, else inline. */
124
+ meta: () => (mounted ? body : box),
125
+ /** One round's usage: shown where it happened, and banked for the total. */
126
+ usage(raw) {
127
+ raws.push(raw);
128
+ addUsage(tallyUsage([raw]), mounted ? body : box);
129
+ },
130
+ status: label,
131
+ step() {
132
+ steps++;
133
+ tally.textContent = `${steps} step${steps === 1 ? '' : 's'}`;
134
+ },
135
+ /** Something followed the last prose block, so it was never the answer. */
136
+ interrupt: demote,
137
+ prose() {
138
+ demote();
139
+ const a = addAssistant(box);
140
+ candidate = a.node;
141
+ return a;
142
+ },
143
+ finish({ replayed = false } = {}) {
144
+ if (candidate) {
145
+ candidate.classList.add('final');
146
+ candidate.querySelector('.who').textContent = 'answer';
147
+ candidate = null;
148
+ }
149
+ // Under the answer, not inside the work: a turn that replayed a growing
150
+ // prefix across N rounds costs far more than its last round suggests, and
151
+ // the last round is the only number the collapsed view would show. A
152
+ // single round whose line went into the work needs it too, or the
153
+ // collapse hides the turn's only usage.
154
+ const n = raws.length;
155
+ if (n > 1 || (n === 1 && mounted)) {
156
+ const label = n > 1 ? `turn total (${n} rounds)` : 'turn total';
157
+ addUsage(tallyUsage(raws), box, { total: true, label });
158
+ }
159
+ resyncScroll();
160
+ if (!mounted) return;
161
+ work.classList.remove('live');
162
+ work.open = false;
163
+ act.textContent = 'work';
164
+ const bits = [`${steps} step${steps === 1 ? '' : 's'}`];
165
+ // Elapsed time is real only for a turn we watched happen.
166
+ if (!replayed) bits.push(`${((Date.now() - started) / 1000).toFixed(1)}s`);
167
+ tally.textContent = bits.join(' · ');
168
+ }
169
+ };
170
+ }
171
+
172
+ /** The live one-liner for the work summary: which tool, on what. */
173
+ export function statusOf(name, args) {
174
+ if (name === 'ask_user') return 'waiting for your answer';
175
+ const short = name.replace(/^[^_]+__/, '');
176
+ const first = Object.values(args || {})[0];
177
+ if (first == null) return short;
178
+ const arg = String(typeof first === 'object' ? JSON.stringify(first) : first);
179
+ return arg ? `${short} ${arg}` : short;
180
+ }
181
+
182
+ /**
183
+ * The turn an automation run starts with. The model gets the full run message
184
+ * (instructions for an unattended run, the previous result); the reader gets
185
+ * which automation ran and why, with its own prompt folded away. No edit or
186
+ * rewind controls: nobody typed it.
187
+ */
188
+ export function addAutomationRun(origin) {
189
+ const m = el('div', 'msg automation-run');
190
+ const head = el('div', 'automation-run-head');
191
+ const name = el('span', 'automation-run-name');
192
+ name.textContent = origin.name || 'Automation';
193
+ const meta = el('span', 'automation-run-meta');
194
+ meta.textContent = [
195
+ origin.trigger === 'manual' ? 'run manually' : 'scheduled run',
196
+ origin.cron && `${origin.cron}${origin.timezone ? ` (${origin.timezone})` : ''}`
197
+ ].filter(Boolean).join(' · ');
198
+ head.append(name, meta);
199
+ m.appendChild(head);
200
+ if (origin.prompt) {
201
+ const more = el('details', 'automation-run-prompt');
202
+ const summary = el('summary');
203
+ summary.textContent = 'What it was asked to do';
204
+ const body = el('div', 'body');
205
+ body.textContent = origin.prompt;
206
+ more.append(summary, body);
207
+ m.appendChild(more);
208
+ }
209
+ wrap.appendChild(m);
210
+ scroll();
211
+ }
212
+
213
+ export function addUser(text, seq, attachments, images) {
214
+ const m = el('div', 'msg user');
215
+ m.innerHTML = '<div class="who">you</div>';
216
+ const b = el('div', 'body');
217
+ b.textContent = text;
218
+ m.appendChild(b);
219
+
220
+ if (attachments?.length) {
221
+ const box = el('div', 'msg-attachments');
222
+ for (const a of attachments) {
223
+ const chip = el('span', 'attachment-chip');
224
+ chip.textContent = `${a.filename} (${a.char_len.toLocaleString('en-US')} chars)`;
225
+ box.appendChild(chip);
226
+ }
227
+ m.appendChild(box);
228
+ }
229
+
230
+ if (images?.length) {
231
+ const box = el('div', 'msg-images');
232
+ for (const img of images) {
233
+ const thumb = el('img', 'msg-image-thumb');
234
+ // A freshly staged image carries its own base64 payload; one replayed
235
+ // from a reopened chat carries the stored {mime, data} shape instead.
236
+ thumb.src = img.dataBase64
237
+ ? `data:${img.mime};base64,${img.dataBase64}`
238
+ : `data:${img.mime};base64,${img.data}`;
239
+ thumb.alt = img.filename || 'attached image';
240
+ box.appendChild(thumb);
241
+ }
242
+ m.appendChild(box);
243
+ }
244
+
245
+ // A question that is in the store can be rewritten. One that has not been
246
+ // saved yet (the one being sent right now) cannot, so it gets no controls.
247
+ if (seq != null) {
248
+ const bar = el('div', 'msg-tools');
249
+ const edit = el('button', 'linkish');
250
+ edit.type = 'button';
251
+ edit.textContent = 'edit';
252
+ edit.title = 'Rewrite this question and answer it again';
253
+ edit.onclick = () => beginEdit(m, b, text, seq, attachments, images);
254
+
255
+ const again = el('button', 'linkish');
256
+ again.type = 'button';
257
+ again.textContent = 'retry';
258
+ again.title = 'Answer this question again from scratch';
259
+ // Resubmitting the same text at the same point, with every artifact it
260
+ // already had -- retry means "this question again", not "this question
261
+ // stripped of what it was asked with".
262
+ again.onclick = () => rewind({ seq, message: text, ...artifactPayload(attachments, images) });
263
+
264
+ bar.append(edit, again);
265
+ m.appendChild(bar);
266
+ }
267
+
268
+ wrap.appendChild(m);
269
+ addQuestion(m, text);
270
+ scroll();
271
+ }
272
+
273
+ /** The rewind payload for keeping every one of a message's artifacts as-is. */
274
+ function artifactPayload(attachments, images) {
275
+ return {
276
+ documentIds: (attachments || []).map((a) => a.id),
277
+ images: (images || []).map((img) => ({ mime: img.mime, data: img.data ?? img.dataBase64 }))
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Swaps a question for a textarea, in place. Escape or cancel puts it back
283
+ * with nothing changed -- artifact removal here is local-only until save, the
284
+ * same way the textarea's own edits are: nothing is deleted or rewound until
285
+ * "save & resubmit" actually fires.
286
+ */
287
+ function beginEdit(msg, body, text, seq, attachments, images) {
288
+ if (state.busy || msg.querySelector('textarea')) return;
289
+ const box = el('div', 'edit-box');
290
+ const area = el('textarea');
291
+ area.value = text;
292
+ area.rows = Math.min(12, text.split('\n').length + 1);
293
+
294
+ const keptDocs = new Set((attachments || []).map((a) => a.id));
295
+ const keptImages = new Set((images || []).map((_, i) => i));
296
+
297
+ let artifactsBox = null;
298
+ if (attachments?.length || images?.length) {
299
+ artifactsBox = el('div', 'edit-artifacts');
300
+ for (const a of attachments || []) {
301
+ const chip = el('span', 'attachment-chip removable');
302
+ chip.textContent = a.filename;
303
+ chip.title = 'click to drop this document from the resubmitted message';
304
+ chip.onclick = () => {
305
+ if (keptDocs.has(a.id)) { keptDocs.delete(a.id); chip.classList.add('removed'); }
306
+ else { keptDocs.add(a.id); chip.classList.remove('removed'); }
307
+ };
308
+ artifactsBox.appendChild(chip);
309
+ }
310
+ (images || []).forEach((img, i) => {
311
+ const chip = el('span', 'attachment-chip attachment-chip-image removable');
312
+ const thumb = el('img', 'attachment-thumb');
313
+ thumb.src = `data:${img.mime};base64,${img.data ?? img.dataBase64}`;
314
+ thumb.alt = img.filename || 'attached image';
315
+ chip.title = 'click to drop this image from the resubmitted message';
316
+ chip.appendChild(thumb);
317
+ chip.onclick = () => {
318
+ if (keptImages.has(i)) { keptImages.delete(i); chip.classList.add('removed'); }
319
+ else { keptImages.add(i); chip.classList.remove('removed'); }
320
+ };
321
+ artifactsBox.appendChild(chip);
322
+ });
323
+ }
324
+
325
+ const save = el('button', 'primary');
326
+ save.type = 'button';
327
+ save.textContent = 'save & resubmit';
328
+ const cancel = el('button', 'linkish');
329
+ cancel.type = 'button';
330
+ cancel.textContent = 'cancel';
331
+
332
+ const close = () => { box.remove(); body.hidden = false; msg.classList.remove('editing'); };
333
+ cancel.onclick = close;
334
+ save.onclick = () => {
335
+ const next = area.value.trim();
336
+ if (!next) return;
337
+ const docsChanged = keptDocs.size !== (attachments || []).length;
338
+ const imagesChanged = keptImages.size !== (images || []).length;
339
+ if (next === text && !docsChanged && !imagesChanged) return close();
340
+ return rewind({
341
+ seq,
342
+ message: next,
343
+ documentIds: (attachments || []).filter((a) => keptDocs.has(a.id)).map((a) => a.id),
344
+ removeDocumentIds: (attachments || []).filter((a) => !keptDocs.has(a.id)).map((a) => a.id),
345
+ images: (images || [])
346
+ .filter((_, i) => keptImages.has(i))
347
+ .map((img) => ({ mime: img.mime, data: img.data ?? img.dataBase64 }))
348
+ });
349
+ };
350
+ area.onkeydown = (e) => {
351
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
352
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); save.onclick(); }
353
+ };
354
+
355
+ const row = el('div', 'edit-actions');
356
+ row.append(save, cancel);
357
+ if (artifactsBox) box.append(artifactsBox);
358
+ box.append(area, row);
359
+ body.hidden = true;
360
+ msg.classList.add('editing');
361
+ msg.appendChild(box);
362
+ area.focus();
363
+ area.setSelectionRange(area.value.length, area.value.length);
364
+ }
365
+
366
+ /**
367
+ * Rewinds the conversation and runs it forward again.
368
+ *
369
+ * Everything after the rewind point is gone -- there is no second branch kept
370
+ * to switch back to. Reopening the chat is what redraws it: the server has
371
+ * already truncated the transcript, and `openChat` replays what is left and
372
+ * rejoins the run that is now producing the rest.
373
+ */
374
+ async function rewind(payload) {
375
+ if (state.busy || !state.chat.id) return;
376
+ try { await api.post(`/api/chats/${state.chat.id}/edit`, payload); } catch (err) { return addError(err.message); }
377
+ // The run is already going; openChat replays the rewound transcript and
378
+ // attaches to it, exactly as a reload mid-turn would.
379
+ const id = state.chat.id;
380
+ await openChat(id);
381
+ return undefined;
382
+ }
383
+
384
+ function addAssistant(parent) {
385
+ const m = el('div', 'msg assistant');
386
+ m.innerHTML = '<div class="who">assistant</div>';
387
+ const b = el('div', 'md');
388
+ m.appendChild(b);
389
+ (parent || wrap).appendChild(m);
390
+ let raw = '';
391
+ let queued = false;
392
+ const paint = () => { queued = false; b.innerHTML = renderMarkdown(raw); scroll(); };
393
+ return {
394
+ // The turn moves this block between the work and the answer slot, so it
395
+ // hands out its element rather than assuming where it lives.
396
+ node: m,
397
+ // Re-render on a frame rather than per token: markdown is whole-document,
398
+ // and a fast stream would otherwise reparse hundreds of times a second.
399
+ push(delta) {
400
+ raw += delta;
401
+ if (!queued) { queued = true; requestAnimationFrame(paint); }
402
+ },
403
+ set(text) { raw = text; paint(); }
404
+ };
405
+ }
406
+
407
+ export function addThinking(parent) {
408
+ const d = el('details', 'think');
409
+ const s = el('summary');
410
+ const body = el('div', 'think-body');
411
+ d.append(s, body);
412
+ d.open = true;
413
+ (parent || wrap).appendChild(d);
414
+ const started = Date.now();
415
+ let raw = '';
416
+ const label = (done) => {
417
+ s.textContent = done
418
+ ? `thought for ${((Date.now() - started) / 1000).toFixed(1)}s`
419
+ : 'thinking…';
420
+ };
421
+ label(false);
422
+ return {
423
+ push(delta) { raw += delta; body.textContent = raw; body.scrollTop = body.scrollHeight; scroll(); },
424
+ set(text, collapsed) { raw = text; body.textContent = raw; label(true); d.open = !collapsed; },
425
+ // Collapse once the visible answer starts: the reasoning stays one click away.
426
+ done() { if (raw) { label(true); d.open = false; } else d.remove(); }
427
+ };
428
+ }
429
+
430
+ export function addSteps(parent) {
431
+ const box = el('div', 'steps');
432
+ (parent || wrap).appendChild(box);
433
+ const rows = new Map();
434
+ return {
435
+ add(id, name, args) {
436
+ const d = el('details', 'step');
437
+ const s = el('summary');
438
+ const n = el('span', 'name');
439
+ n.textContent = name.replace(/^[^_]+__/, '');
440
+ const a = el('span', 'arg');
441
+ const first = Object.values(args || {})[0];
442
+ a.textContent = first == null ? '' : String(typeof first === 'object' ? JSON.stringify(first) : first);
443
+ const ms = el('span', 'ms');
444
+ ms.textContent = '…';
445
+ s.append(n, a, ms);
446
+ const pre = el('pre');
447
+ pre.textContent = JSON.stringify(args, null, 2);
448
+ d.append(s, pre);
449
+ box.appendChild(d);
450
+ rows.set(id, { d, ms, pre, t0: Date.now(), name, done: false });
451
+ scroll();
452
+ },
453
+ finish(id, result, compacted) {
454
+ const row = rows.get(id);
455
+ if (!row) return;
456
+ row.done = true;
457
+ row.ms.textContent = `${((Date.now() - row.t0) / 1000).toFixed(1)}s`;
458
+ if (/^Error/.test(result)) row.d.classList.add('bad');
459
+ // The transcript always keeps the full output; compaction only shortens
460
+ // what the model is sent, so the badge is the only visible difference.
461
+ if (compacted) row.d.classList.add('compacted');
462
+ row.pre.textContent += '\n\n' + result;
463
+ scroll();
464
+ },
465
+ /**
466
+ * A call held for the user's approval: the row opens with its arguments
467
+ * showing and three buttons under them. `onDecide` sends the answer; the
468
+ * buttons stay until the server confirms it with approval_done, so a
469
+ * second tab watching the same run settles too.
470
+ */
471
+ ask(id, onDecide) {
472
+ const row = rows.get(id);
473
+ if (!row || row.ask) return;
474
+ row.d.open = true;
475
+ row.d.classList.add('waiting');
476
+ row.ms.textContent = 'needs approval';
477
+ const bar = approvalBar(onDecide);
478
+ row.d.appendChild(bar);
479
+ row.ask = bar;
480
+ scroll();
481
+ },
482
+ settle(id, decision) {
483
+ const row = rows.get(id);
484
+ if (!row?.ask) return;
485
+ row.ask.remove();
486
+ row.ask = null;
487
+ row.d.classList.remove('waiting');
488
+ row.d.open = false;
489
+ row.ms.textContent = decision === 'deny' ? 'denied' : '…';
490
+ row.t0 = Date.now();
491
+ },
492
+ /**
493
+ * An ask_user question: the row opens on the question, with any choices,
494
+ * a text box when free text is allowed, "continue without me", and a
495
+ * countdown to the deadline. Like approvals, it stays until the server
496
+ * confirms with question_done, so every tab watching the run settles.
497
+ * `onAnswer({ answer } | { skip: true })` sends it.
498
+ */
499
+ question(id, q, onAnswer) {
500
+ const row = rows.get(id);
501
+ if (!row || row.question) return;
502
+ row.d.open = true;
503
+ row.d.classList.add('waiting');
504
+ row.ms.textContent = 'waiting for you';
505
+ row.question = questionCard(q, onAnswer);
506
+ row.d.appendChild(row.question.box);
507
+ scroll();
508
+ },
509
+ /** The ask_user call still running here: calls are sequential, so at most one. */
510
+ pendingAsk() {
511
+ return [...rows.entries()].find(([, r]) => r.name === 'ask_user' && !r.done)?.[0];
512
+ },
513
+ settleQuestion(id, status) {
514
+ const row = rows.get(id);
515
+ if (!row?.question) return;
516
+ clearInterval(row.question.tick);
517
+ row.question.box.remove();
518
+ row.question = null;
519
+ row.d.classList.remove('waiting');
520
+ row.d.open = false;
521
+ row.ms.textContent = { answered: '…', timeout: 'no answer', skipped: 'skipped', cancelled: 'stopped' }[status] || '…';
522
+ row.t0 = Date.now();
523
+ },
524
+ // Timings are meaningless on a replayed transcript.
525
+ quiet() { for (const r of rows.values()) r.ms.textContent = ''; }
526
+ };
527
+ }
528
+
529
+ /** allow / always / deny under a held tool call. Buttons lock while `onDecide` is in flight. */
530
+ function approvalBar(onDecide) {
531
+ const bar = el('div', 'approve');
532
+ const lock = (on) => { for (const x of bar.querySelectorAll('button')) x.disabled = on; };
533
+ const button = (label, decision, cls) => {
534
+ const b = el('button', cls);
535
+ b.textContent = label;
536
+ b.onclick = () => { lock(true); onDecide(decision).catch(() => lock(false)); };
537
+ return b;
538
+ };
539
+ bar.append(
540
+ button('allow', 'allow', 'primary'),
541
+ button('always allow this tool', 'always'),
542
+ button('deny', 'deny')
543
+ );
544
+ return bar;
545
+ }
546
+
547
+ /**
548
+ * An ask_user card: the question, its choices, a text box when free text is
549
+ * allowed, "continue without me", and a countdown to the deadline. Returns
550
+ * `{ box, tick }`; the caller clears `tick` when the question settles.
551
+ */
552
+ function questionCard(q, onAnswer) {
553
+ const box = el('div', 'ask');
554
+ const text = el('p', 'ask-q');
555
+ text.textContent = q.question;
556
+ box.appendChild(text);
557
+ const lock = (on) => { for (const x of box.querySelectorAll('button, input')) x.disabled = on; };
558
+ const send = (body) => { lock(true); onAnswer(body).catch(() => lock(false)); };
559
+ if (q.choices?.length) {
560
+ const bar = el('div', 'ask-choices');
561
+ for (const c of q.choices) {
562
+ const b = el('button');
563
+ b.textContent = c;
564
+ b.onclick = () => send({ answer: c });
565
+ bar.appendChild(b);
566
+ }
567
+ box.appendChild(bar);
568
+ }
569
+ if (q.allowFreeText) {
570
+ const form = el('form', 'ask-free');
571
+ const input = el('input');
572
+ input.placeholder = q.choices?.length ? 'or type an answer…' : 'your answer…';
573
+ input.setAttribute('aria-label', 'Answer');
574
+ const go = el('button', 'primary');
575
+ go.type = 'submit';
576
+ go.textContent = 'answer';
577
+ form.append(input, go);
578
+ form.onsubmit = (e) => {
579
+ e.preventDefault();
580
+ if (input.value.trim()) send({ answer: input.value.trim() });
581
+ };
582
+ box.appendChild(form);
583
+ setTimeout(() => input.focus(), 0);
584
+ }
585
+ const foot = el('div', 'ask-foot');
586
+ const skip = el('button', 'ask-skip');
587
+ skip.textContent = 'continue without me';
588
+ skip.onclick = () => send({ skip: true });
589
+ const clock = el('span', 'ask-clock');
590
+ foot.append(skip, clock);
591
+ box.appendChild(foot);
592
+ let tick = null;
593
+ if (q.deadline) {
594
+ const paint = () => {
595
+ const left = Math.max(0, Math.ceil((q.deadline - Date.now()) / 1000));
596
+ clock.textContent = `continues on its own in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
597
+ };
598
+ paint();
599
+ tick = setInterval(paint, 1000);
600
+ }
601
+ return { box, tick };
602
+ }
603
+
604
+ const fmt = (n) => n.toLocaleString('en-US');
605
+
606
+ function addUsage(u, parent, { label = null, total = false } = {}) {
607
+ const bits = [];
608
+ if (label) bits.push(label);
609
+ bits.push(`${fmt(u.in)} in`, `${fmt(u.out)} out`);
610
+ if (u.cached) {
611
+ const pct = u.in ? Math.round((u.cached / u.in) * 100) : 0;
612
+ bits.push(`${fmt(u.cached)} cached (${pct}%)`);
613
+ }
614
+ if (u.written) bits.push(`${fmt(u.written)} written`);
615
+ if (u.discount) bits.push(`cache saved ${u.discount.toFixed(4)}`);
616
+ if (!u.cached && !u.written) bits.push(u.reported ? 'no cache hit' : 'cache not reported');
617
+ // Worth a line of its own: a round served by a different upstream has a cold
618
+ // cache through no fault of the prefix, and that is only visible here.
619
+ if (u.providers?.length) bits.push(`via ${u.providers.join(' + ')}`);
620
+ else if (u.provider) bits.push(`via ${u.provider}`);
621
+ const line = el('div', total ? 'usage usage-total' : 'usage');
622
+ line.textContent = bits.join(' / ');
623
+ // The raw usage objects, for when a provider reports something we do not read.
624
+ line.title = JSON.stringify(u.raw, null, 2);
625
+ (parent || wrap).appendChild(line);
626
+ scroll();
627
+ }
628
+
629
+ export function addNotice(text, parent) {
630
+ const n = el('div', 'notice');
631
+ n.textContent = text;
632
+ (parent || wrap).appendChild(n);
633
+ scroll();
634
+ }
635
+
636
+ export function addError(text, parent) {
637
+ const e = el('div', 'err');
638
+ e.textContent = text;
639
+ (parent || wrap).appendChild(e);
640
+ scroll();
641
+ }
642
+
643
+ /** Rebuilds the transcript view from stored messages. */
644
+ export function replay(messages, documents = []) {
645
+ let steps = null;
646
+ let turn = null;
647
+ const groups = [];
648
+ const endTurn = () => { turn?.finish({ replayed: true }); turn = null; steps = null; };
649
+ for (const m of messages) {
650
+ if (m.role === 'user') {
651
+ endTurn();
652
+ if (m.origin?.type === ORIGIN.AUTOMATION) addAutomationRun(m.origin);
653
+ else {
654
+ // The stored text carries the model's "[Attached document ...]" notes;
655
+ // the page shows them as chips, and edit needs them as such to remove one.
656
+ const { text, ids } = splitAttachmentNotes(m.content);
657
+ addUser(text, m.seq, ids.map((id) => documents.find((d) => d.id === id)).filter(Boolean), m.images);
658
+ }
659
+ } else if (m.role === 'assistant') {
660
+ turn ||= addTurn();
661
+ if (m.reasoning) {
662
+ steps = null;
663
+ turn.interrupt();
664
+ addThinking(turn.work()).set(m.reasoning, true);
665
+ }
666
+ if (m.tool_calls?.length) {
667
+ turn.interrupt();
668
+ if (!steps) groups.push((steps = addSteps(turn.work())));
669
+ for (const tc of m.tool_calls) {
670
+ let args = {};
671
+ try { args = JSON.parse(tc.function.arguments || '{}'); } catch { /* keep {} */ }
672
+ steps.add(tc.id, tc.function.name, args);
673
+ turn.step();
674
+ }
675
+ }
676
+ // A whitespace-only content string ("\n\n") rides alongside tool_calls on
677
+ // some backends (DeepSeek/CoreWeave). Treating it as real prose would null
678
+ // `steps` right after creating it, so the tool result that follows never
679
+ // gets attached to its row -- it'd render but never show what it returned.
680
+ if (m.content?.trim()) { steps = null; turn.prose().set(m.content); }
681
+ // Per-round usage is stored on the message now, so a reopened chat still
682
+ // shows what each round cost and how much of it came back from cache.
683
+ if (m.usage) turn.usage(m.usage);
684
+ } else if (m.role === 'tool' && steps) {
685
+ steps.finish(m.tool_call_id, m.content, m.compacted);
686
+ }
687
+ }
688
+ endTurn();
689
+ for (const g of groups) g.quiet();
690
+ scroll();
691
+ }