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,474 @@
1
+ /**
2
+ * Token usage panel: LLM name, tokens in/out/cached, and a drill-down bar
3
+ * chart -- years, click a year for its months, click a month for its days.
4
+ * The server hands back one row per calendar day (store.usageRollup());
5
+ * everything coarser than a day is folded client-side from that, since a
6
+ * year of daily rows is cheap to carry but expensive to re-derive per click.
7
+ */
8
+ import { $, el } from '../core/dom.js';
9
+ import { api } from '../core/api.js';
10
+
11
+ let days = []; // raw daily rows from /api/usage
12
+ let path = []; // drill-down breadcrumb, e.g. ['2026'] or ['2026', '2026-03']
13
+ let statistics = null;
14
+ // The top-level filter: '' is everything, else JSON of { model, provider? }.
15
+ let selected = '';
16
+ let targets = [];
17
+ let usageRequest = 0;
18
+
19
+ const fmt = (n) => n.toLocaleString();
20
+
21
+ // Assigned the first time each model name is seen and kept for the rest of
22
+ // the session, so a model's color stays the same whether you're looking at
23
+ // "all time" or drilled into a single day -- switching levels would
24
+ // otherwise reshuffle which color means which model.
25
+ const MODEL_PALETTE = ['--accent', '--tool', '--err', '--muted', '--faint'];
26
+ const modelColors = new Map();
27
+ function colorForModel(model) {
28
+ if (!modelColors.has(model)) modelColors.set(model, MODEL_PALETTE[modelColors.size % MODEL_PALETTE.length]);
29
+ return `var(${modelColors.get(model)})`;
30
+ }
31
+
32
+ function sumModels(rows) {
33
+ const totals = new Map();
34
+ for (const row of rows) {
35
+ for (const m of row.models) {
36
+ if (!totals.has(m.model)) totals.set(m.model, { model: m.model, in: 0, out: 0, cached: 0 });
37
+ const t = totals.get(m.model);
38
+ t.in += m.in; t.out += m.out; t.cached += m.cached;
39
+ }
40
+ }
41
+ return [...totals.values()].sort((a, b) => b.in + b.out - (a.in + a.out));
42
+ }
43
+
44
+ /** Groups daily rows by year, or by month within a chosen year. */
45
+ function bucket(rows, level) {
46
+ const groups = new Map();
47
+ for (const row of rows) {
48
+ const key = level === 'year' ? row.day.slice(0, 4) : row.day.slice(0, 7);
49
+ if (!groups.has(key)) groups.set(key, []);
50
+ groups.get(key).push(row);
51
+ }
52
+ return [...groups.entries()]
53
+ .map(([key, rows]) => {
54
+ const models = sumModels(rows);
55
+ const total = models.reduce((n, m) => n + m.in + m.out, 0);
56
+ return { key, rows, models, total };
57
+ })
58
+ .sort((a, b) => a.key.localeCompare(b.key));
59
+ }
60
+
61
+ function render() {
62
+ renderCostStatistics(statistics);
63
+ renderModelFilter();
64
+ if (!days.length) renderDistribution([]);
65
+ if (!days.length) {
66
+ $('usageStats').innerHTML = '<div class="empty">no usage recorded yet</div>';
67
+ $('usageModels').innerHTML = '';
68
+ $('usageBars').innerHTML = '';
69
+ $('usageRange').textContent = '';
70
+ return;
71
+ }
72
+
73
+ const level = path.length === 0 ? 'year' : path.length === 1 ? 'month' : 'day';
74
+ const scoped = (path.length === 0 ? days : days.filter((d) => d.day.startsWith(path[path.length - 1])))
75
+ .map((d) => {
76
+ const models = d.models;
77
+ return { ...d, models, in: models.reduce((n,m) => n + m.in, 0), out: models.reduce((n,m) => n + m.out, 0) };
78
+ });
79
+ renderDistribution(scoped);
80
+
81
+ const models = sumModels(scoped);
82
+ renderTiles(models);
83
+ renderModelSplit(models);
84
+ renderBreadcrumb();
85
+ renderBars(scoped, level, models);
86
+ }
87
+
88
+ /** Stat tiles: totals for whatever is currently in view. */
89
+ function renderTiles(models) {
90
+ const totalIn = models.reduce((n, m) => n + m.in, 0);
91
+ const totalOut = models.reduce((n, m) => n + m.out, 0);
92
+ const totalCached = models.reduce((n, m) => n + m.cached, 0);
93
+
94
+ const stats = $('usageStats');
95
+ stats.innerHTML = '';
96
+ const tile = (label, value) => {
97
+ const box = el('div');
98
+ const l = el('label');
99
+ l.textContent = label;
100
+ const v = el('div', 'usage-stat-value');
101
+ v.textContent = value;
102
+ box.append(l, v);
103
+ return box;
104
+ };
105
+ stats.append(
106
+ tile('tokens in', fmt(totalIn)),
107
+ tile('tokens out', fmt(totalOut)),
108
+ tile('cached', fmt(totalCached))
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Per-model breakdown, as its own list rather than crammed into a stat
114
+ * tile: a model name plus three counts doesn't fit next to "584,746" at
115
+ * the same width, and comma-joining every model into one string just wraps
116
+ * wherever it wraps, with no way to see a single model's in/out split.
117
+ */
118
+ function renderModelSplit(models) {
119
+ const modelsBox = $('usageModels');
120
+ modelsBox.innerHTML = '';
121
+ if (models.length) {
122
+ // `cached` is a subset of `in` (a cache hit still counts as an input
123
+ // token), not a third bucket alongside it -- the API reports it that way
124
+ // too. So each bar is fresh-in + cached-in + out, in that stacking order.
125
+ // The bar itself is always full width: how one model's total compares to
126
+ // another's is already in the numbers (and in the totals below), and
127
+ // scaling bar length to that on top just makes small models unreadably
128
+ // thin. What the bar is for here is one model's own in/cached/out split.
129
+ for (const m of models) {
130
+ const row = el('div', 'usage-model-row');
131
+ const name = el('span', 'usage-model-name');
132
+ name.textContent = m.model;
133
+ name.title = m.model;
134
+ const total = m.in + m.out;
135
+ const cachedIn = Math.min(m.cached, m.in);
136
+ const freshIn = m.in - cachedIn;
137
+ const track = el('span', 'usage-model-track');
138
+ const bar = el('span', 'usage-model-bar');
139
+ for (const [cls, count] of [['fresh', freshIn], ['cached', cachedIn], ['out', m.out]]) {
140
+ if (!count) continue;
141
+ const seg = el('span', `usage-model-seg ${cls}`);
142
+ seg.style.width = `${(count / total) * 100}%`;
143
+ bar.appendChild(seg);
144
+ }
145
+ track.appendChild(bar);
146
+ const counts = el('span', 'usage-model-counts');
147
+ counts.textContent = `${fmt(m.in)} in · ${fmt(m.out)} out${m.cached ? ` · ${fmt(m.cached)} cached` : ''}`;
148
+ row.append(name, track, counts);
149
+ modelsBox.appendChild(row);
150
+ }
151
+ const legend = el('div', 'usage-model-legend');
152
+ for (const [cls, label] of [['fresh', 'in'], ['cached', 'cached'], ['out', 'out']]) {
153
+ const item = el('span', 'usage-model-legend-item');
154
+ item.append(el('span', `usage-model-swatch ${cls}`), document.createTextNode(label));
155
+ legend.appendChild(item);
156
+ }
157
+ modelsBox.appendChild(legend);
158
+ }
159
+ }
160
+
161
+ function renderBreadcrumb() {
162
+ const crumbs = ['all time', ...path];
163
+ $('usageRange').innerHTML = '';
164
+ crumbs.forEach((label, i) => {
165
+ if (i > 0) $('usageRange').appendChild(document.createTextNode(' / '));
166
+ const a = el('a');
167
+ a.href = '#';
168
+ a.textContent = label;
169
+ a.onclick = (e) => { e.preventDefault(); path = path.slice(0, i); render(); };
170
+ $('usageRange').appendChild(a);
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Bars: years, or months in a year, or individual days. This is the one
176
+ * place bar *length* means "more tokens than that other bar" -- so this is
177
+ * also where the per-model split belongs, as colored segments inside each
178
+ * bar, rather than on the fixed-width rows above.
179
+ */
180
+ function renderBars(scoped, level, models) {
181
+ const box = $('usageBars');
182
+ box.innerHTML = '';
183
+ const buckets = level === 'day'
184
+ ? scoped.map((d) => ({ key: d.day, rows: [d], models: sumModels([d]), total: d.in + d.out })).sort((a, b) => a.key.localeCompare(b.key))
185
+ : bucket(scoped, level);
186
+ if (!buckets.length) {
187
+ box.innerHTML = '<div class="empty">nothing here</div>';
188
+ return;
189
+ }
190
+ const max = Math.max(...buckets.map((b) => b.total), 1);
191
+ for (const b of buckets) {
192
+ const drillable = path.length < 3;
193
+ const row = el(drillable ? 'button' : 'div', 'usage-bar-row');
194
+ if (drillable) row.type = 'button';
195
+ const label = el('span', 'usage-bar-label');
196
+ label.textContent = level === 'day' ? b.key.slice(8) : (level === 'year' ? b.key : b.key.slice(5));
197
+ const track = el('span', 'usage-bar-track');
198
+ const fill = el('span', 'usage-bar-fill');
199
+ fill.style.width = `${Math.max((b.total / max) * 100, 2)}%`;
200
+ for (const m of b.models) {
201
+ if (!m.in && !m.out) continue;
202
+ const seg = el('span', 'usage-bar-seg');
203
+ seg.style.width = `${((m.in + m.out) / b.total) * 100}%`;
204
+ seg.style.background = colorForModel(m.model);
205
+ fill.appendChild(seg);
206
+ }
207
+ track.appendChild(fill);
208
+ const count = el('span', 'usage-bar-count');
209
+ count.textContent = fmt(b.total);
210
+ row.append(label, track, count);
211
+ if (drillable) {
212
+ row.classList.add('clickable');
213
+ row.setAttribute('aria-label', `Show ${b.key} usage details`);
214
+ row.onclick = () => { path = [...path, b.key]; render(); };
215
+ }
216
+ box.appendChild(row);
217
+ }
218
+ if (models.length > 1) {
219
+ const legend = el('div', 'usage-model-legend');
220
+ for (const m of models) {
221
+ const item = el('span', 'usage-model-legend-item');
222
+ const swatch = el('span', 'usage-model-swatch');
223
+ swatch.style.background = colorForModel(m.model);
224
+ item.append(swatch, document.createTextNode(m.model));
225
+ legend.appendChild(item);
226
+ }
227
+ box.appendChild(legend);
228
+ }
229
+ }
230
+
231
+ const costFmt = (value) => value == null ? 'not reported' : `$${Number(value).toFixed(6)}`;
232
+
233
+ // Ordered like the server's MISS: the first is the one that's ours to fix.
234
+ const MISS_LABELS = [
235
+ ['prefix', 'prefix changed', 'we altered something already sent; worth investigating'],
236
+ ['switch', 'provider switched', 'a different upstream served it, with its own cache'],
237
+ ['expired', 'expired', 'more than 5 minutes idle since the previous request'],
238
+ ['stub', 'result stubbed', 'a large tool result switched to its stub after its turn, as planned'],
239
+ ['provider', 'provider-side', 'same prefix, same upstream, in time, and it still missed']
240
+ ];
241
+ const missTotal = (cache) => Object.values(cache.misses || {}).reduce((n, v) => n + v, 0);
242
+
243
+ /** The cache misses section: how many, and one bar per cause. */
244
+ function renderMisses(cache) {
245
+ const box = $('statisticsMisses');
246
+ const head = $('statisticsMissesSummary');
247
+ if (!box || !head) return;
248
+ box.replaceChildren();
249
+ if (!cache?.judged) {
250
+ head.textContent = '';
251
+ box.appendChild(el('div', 'empty')).textContent = 'No requests to compare yet: a chat needs two rounds from a provider that reports cache figures.';
252
+ return;
253
+ }
254
+ head.textContent = `${fmt(missTotal(cache))} of ${fmt(cache.judged)} comparable requests missed`;
255
+ const max = Math.max(...MISS_LABELS.map(([k]) => cache.misses[k] || 0), 1);
256
+ for (const [key, label, hint] of MISS_LABELS) {
257
+ const n = cache.misses[key] || 0;
258
+ const row = el('div', 'statistics-tool-row');
259
+ const name = el('span', 'statistics-tool-name'); name.textContent = label;
260
+ const track = el('span', 'statistics-tool-track');
261
+ const bar = el('span', 'statistics-tool-bar');
262
+ bar.style.width = `${(n / max) * 100}%`;
263
+ track.appendChild(bar);
264
+ const count = el('span', 'statistics-tool-count'); count.textContent = fmt(n);
265
+ row.append(name, track, count);
266
+ const why = el('div', 'statistics-miss-hint'); why.textContent = hint;
267
+ box.append(row, why);
268
+ }
269
+ }
270
+
271
+ function renderCostStatistics(stats) {
272
+ const summary = $('statisticsSummary');
273
+ const coverage = $('statisticsCoverage');
274
+ const modelsBox = $('statisticsCosts');
275
+ const toolsBox = $('statisticsTools');
276
+ if (!summary || !coverage || !modelsBox) return;
277
+ summary.replaceChildren(); modelsBox.replaceChildren();
278
+ if (toolsBox) toolsBox.replaceChildren();
279
+ renderMisses(stats?.rounds ? stats.cache : null);
280
+ if (!stats || !stats.rounds) {
281
+ summary.appendChild(el('div', 'empty')).textContent = 'No model usage recorded yet.';
282
+ coverage.textContent = '';
283
+ if (toolsBox) toolsBox.appendChild(el('div', 'empty')).textContent = 'No tool calls recorded yet.';
284
+ return;
285
+ }
286
+ // `sub` is a short second line under the value; `hint` is the hover detail,
287
+ // so the explanations stay available without a paragraph under the tiles.
288
+ const tile = (label, value, sub = '', hint = '') => {
289
+ const box = el('div');
290
+ const name = el('label'); name.textContent = label;
291
+ const amount = el('div', 'usage-stat-value'); amount.textContent = value;
292
+ box.append(name, amount);
293
+ if (sub) box.appendChild(el('div', 'usage-stat-sub')).textContent = sub;
294
+ if (hint) box.title = hint;
295
+ return box;
296
+ };
297
+ const cache = stats.cache || {};
298
+ const pct = (share) => `${(share * 100).toFixed(1)}%`;
299
+ const rates = (cache.rates || []).map((r) => `${r.model}${r.provider ? ` @ ${r.provider}` : ''}: `
300
+ + `$${(r.full * 1e6).toFixed(3)}/M full, $${(r.cache * 1e6).toFixed(3)}/M cached`).join('\n');
301
+ summary.append(
302
+ tile('cache saved', costFmt(cache.saved),
303
+ cache.savedShare == null ? '' : `${pct(cache.savedShare)} off the bill`,
304
+ 'Share of what these requests would have cost with no cache. Where the provider does not report it, '
305
+ + 'it is worked out from the input cost it charged at different cache ratios.' + (rates ? `\n\n${rates}` : '')),
306
+ tile('cache hit share', cache.hitShare == null ? '—' : pct(cache.hitShare),
307
+ cache.reportedRounds ? `${fmt(cache.cached)} of ${fmt(cache.input)} input` : '',
308
+ 'Input tokens read from cache, over requests whose provider reports cache figures.'),
309
+ tile('reported cost', costFmt(stats.reportedCost)),
310
+ tile('average model request', costFmt(stats.averageRoundCost)),
311
+ tile('average complete answer', costFmt(stats.averageAnswerCost), '', 'All model requests of the answer, tool-work rounds included.'),
312
+ tile('average answer tokens (all rounds)', stats.averageAnswerTokens == null ? '—' : Math.round(stats.averageAnswerTokens).toLocaleString()),
313
+ tile('average rounds per answer', stats.averageRoundsPerAnswer == null ? '—' : stats.averageRoundsPerAnswer.toFixed(1))
314
+ );
315
+ // Only what's off: full coverage needs no sentence.
316
+ const notes = [];
317
+ if (stats.pricedRounds < stats.rounds) notes.push(`${fmt(stats.pricedRounds)} of ${fmt(stats.rounds)} requests reported cost`);
318
+ if (stats.pricedAnswers < stats.completedAnswers) notes.push(`${fmt(stats.pricedAnswers)} of ${fmt(stats.completedAnswers)} answers fully priced`);
319
+ if (!cache.reportedRounds) notes.push('no provider has reported cache figures yet');
320
+ if (stats.auxiliary?.requests) notes.push(`conversation summaries: ${fmt(stats.auxiliary.requests)} requests, ${costFmt(stats.auxiliary.cost)}, not included above`);
321
+ coverage.textContent = notes.join(' · ');
322
+ for (const model of stats.models || []) {
323
+ const row = el('div', 'statistics-cost-row');
324
+ const name = el('span'); name.className = 'usage-model-name'; name.textContent = model.model; name.title = model.model;
325
+ const amount = el('span'); amount.textContent = `${costFmt(model.cost)} · ${model.pricedRounds}/${model.rounds} priced requests`;
326
+ row.append(name, amount); modelsBox.appendChild(row);
327
+ }
328
+ if (toolsBox) {
329
+ if (!stats.tools || !stats.tools.length) {
330
+ toolsBox.appendChild(el('div', 'empty')).textContent = 'No tool calls recorded yet.';
331
+ } else {
332
+ const maxCalls = Math.max(...stats.tools.map((t) => t.calls));
333
+ for (const tool of stats.tools) {
334
+ const row = el('div', 'statistics-tool-row');
335
+ const name = el('span', 'statistics-tool-name'); name.textContent = tool.name; name.title = tool.name;
336
+ const track = el('span', 'statistics-tool-track');
337
+ const bar = el('span', 'statistics-tool-bar');
338
+ bar.style.width = `${(tool.calls / maxCalls) * 100}%`;
339
+ track.appendChild(bar);
340
+ const count = el('span', 'statistics-tool-count'); count.textContent = `${tool.calls.toLocaleString()} call${tool.calls === 1 ? '' : 's'}`;
341
+ row.append(name, track, count); toolsBox.appendChild(row);
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ export async function loadUsage() {
348
+ const request = ++usageRequest;
349
+ let data;
350
+ try {
351
+ const f = selected ? JSON.parse(selected) : {};
352
+ const q = new URLSearchParams();
353
+ if (f.model) q.set('model', f.model);
354
+ if (f.provider != null) q.set('provider', f.provider);
355
+ data = await api.get(`/api/usage${q.size ? `?${q}` : ''}`);
356
+ } catch (err) {
357
+ if (request !== usageRequest) return;
358
+ const msg = Object.assign(el('div', 'empty'), { textContent: `Couldn't load usage (${err.message}).` });
359
+ $('usageStats').replaceChildren(msg);
360
+ $('usageModels').replaceChildren();
361
+ $('usageBars').replaceChildren();
362
+ return;
363
+ }
364
+ if (request !== usageRequest) return;
365
+ days = data.days || [];
366
+ statistics = data.statistics || null;
367
+ path = [];
368
+ targets = data.statistics?.targets || [];
369
+ render();
370
+ }
371
+
372
+
373
+ const CATEGORY_LABELS = {
374
+ operator: 'Operator / system prompt', harness: 'TinyWebUI harness', mcp_instructions: 'MCP guidance', checkpoint: 'Conversation summary',
375
+ tool_schemas: 'Tool schemas', user_messages: 'User messages (including document notes)',
376
+ assistant_history: 'Assistant history', tool_history: 'Tool-call history', tool_results: 'Tool results',
377
+ reasoning_history: 'Reasoning resent on wire', image_allowance: 'Image allowance', attachments_other: 'Other attachments',
378
+ system_other: 'Other system content', reasoning: 'Reasoning / thinking', tool_generation: 'Tool-call generation',
379
+ intermediate_text: 'Intermediate assistant text', final_text: 'Final visible answer', provider_delta: 'Other / provider delta'
380
+ };
381
+ /**
382
+ * One filter for the whole panel. A model served by several upstreams also
383
+ * gets one entry per upstream: each has its own prices and its own cache, so
384
+ * mixed together neither the saving nor the misses say much.
385
+ */
386
+ function renderModelFilter() {
387
+ const box = $('statisticsFilter');
388
+ if (!box) return;
389
+ box.replaceChildren();
390
+ const select = el('select'); select.setAttribute('aria-label', 'Statistics for model');
391
+ const add = (value, text) => { const o = el('option'); o.value = value; o.textContent = text; select.append(o); };
392
+ add('', 'All models');
393
+ for (const t of targets) {
394
+ add(JSON.stringify({ model: t.model }), t.providers.length > 1 ? `${t.model} (all providers)` : t.model);
395
+ if (t.providers.length > 1) for (const p of t.providers) add(JSON.stringify({ model: t.model, provider: p }), `${t.model} @ ${p}`);
396
+ }
397
+ // A choice that no longer exists (data changed underneath) would show blank.
398
+ if (![...select.options].some((o) => o.value === selected)) selected = '';
399
+ select.value = selected;
400
+ select.onchange = () => { selected = select.value; loadUsage(); };
401
+ box.append(select);
402
+ }
403
+ function renderDistribution(rows) {
404
+ const box = $('usageDistribution');
405
+ if (!box) return;
406
+ box.replaceChildren();
407
+ const models = rows.flatMap((d) => d.models);
408
+ const snapshots = models.map((m) => m.attribution).filter(Boolean);
409
+ const requests = snapshots.reduce((n, s) => n + s.requests, 0);
410
+ const note = el('p', 'usage-attribution-note');
411
+ note.textContent = requests
412
+ ? `${fmt(requests)} of ${fmt(models.reduce((n,m) => n + (m.requests || 0), 0))} requests have saved attribution. Provider totals are authoritative; ~ component counts are character-based estimates. Cache reads are a subset of input and cannot be assigned to individual components.`
413
+ : 'No saved token attribution in this range. New requests will record it; historical prompts are not reconstructed.';
414
+ box.append(note);
415
+ if (!requests) return;
416
+ for (const side of ['input', 'output']) {
417
+ const panel = el('section', 'usage-composition');
418
+ const heading = el('h3'); heading.textContent = side === 'input' ? 'Input composition' : 'Output composition'; panel.append(heading);
419
+ const buckets = new Map();
420
+ let total = 0, reported = 0;
421
+ for (const snapshot of snapshots) {
422
+ total += snapshot[side].total; reported += snapshot[side].reported;
423
+ for (const b of snapshot[side].buckets) {
424
+ const key = JSON.stringify([b.category, b.source]);
425
+ const item = buckets.get(key) || { category: b.category, source: b.source, tokens: 0 };
426
+ item.tokens += b.tokens; buckets.set(key, item);
427
+ }
428
+ }
429
+ const complete = reported === requests;
430
+ const summary = el('p', 'usage-attribution-note');
431
+ summary.textContent = `${fmt(total)} provider-reported tokens across ${fmt(reported)}/${fmt(requests)} requests.`;
432
+ if (side === 'input') summary.textContent += ` ${fmt(snapshots.reduce((n,s) => n + s.cached, 0))} cached input tokens reported.`;
433
+ panel.append(summary);
434
+ const table = el('table', 'usage-composition-table');
435
+ for (const b of buckets.values()) {
436
+ const row = el('tr');
437
+ const label = el('td'); label.textContent = CATEGORY_LABELS[b.category] || b.category;
438
+ const value = el('td'); value.textContent = `${b.source === 'provider' ? '' : '~'}${fmt(b.tokens)} (${b.source === 'provider' ? 'provider' : 'estimated'})`;
439
+ const share = el('td'); share.textContent = complete && total > 0 ? `${(b.tokens / total * 100).toFixed(1)}%` : '—';
440
+ row.append(label, value, share); table.append(row);
441
+ }
442
+ // A signed provider delta is intentionally not hidden or scaled away. A
443
+ // negative delta means the estimates exceed the provider's token total.
444
+ const hasNegative = [...buckets.values()].some((b) => b.tokens < 0);
445
+ if (complete && total > 0 && !hasNegative) {
446
+ const track = el('div', 'usage-composition-track');
447
+ let i = 0;
448
+ for (const b of buckets.values()) {
449
+ if (!b.tokens) continue;
450
+ const seg = el('span'); seg.style.width = `${b.tokens / total * 100}%`;
451
+ seg.style.background = `var(${MODEL_PALETTE[i++ % MODEL_PALETTE.length]})`;
452
+ seg.title = `${CATEGORY_LABELS[b.category] || b.category}: ${fmt(b.tokens)}`;
453
+ track.append(seg);
454
+ }
455
+ panel.append(track);
456
+ }
457
+ panel.append(table);
458
+ if (hasNegative) {
459
+ const warning = el('p', 'usage-attribution-note'); warning.textContent = 'Estimates exceed provider totals in some categories. The signed delta shows the difference; components have not been rescaled.'; panel.append(warning);
460
+ }
461
+ box.append(panel);
462
+ }
463
+ const detail = el('details'); const title = el('summary'); title.textContent = 'Tool / MCP overhead by server'; detail.append(title);
464
+ const grouped = new Map();
465
+ for (const snapshot of snapshots) for (const b of snapshot.input.buckets) {
466
+ if (!b.capability) continue;
467
+ const key = `${b.capability} · ${CATEGORY_LABELS[b.category] || b.category}`;
468
+ grouped.set(key, (grouped.get(key) || 0) + b.tokens);
469
+ }
470
+ for (const [label, count] of grouped) {
471
+ const row = el('p', 'usage-attribution-note'); row.textContent = `${label}: ~${fmt(count)} estimated tokens`; detail.append(row);
472
+ }
473
+ box.append(detail);
474
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The inline note that tells the model a document's id ("read_document" needs
3
+ * it). It lives in the stored message text, so the page strips it back out to
4
+ * draw a chip instead. One module for both directions: the server writes the
5
+ * note, the transcript reads it, and they cannot drift apart.
6
+ */
7
+
8
+ export function attachmentNote(doc) {
9
+ return `\n\n[Attached document: "${doc.filename}" (id: ${doc.id}, ${doc.char_len.toLocaleString('en-US')} chars). Use read_document to search or read it.]`;
10
+ }
11
+
12
+ // Only notes at the very end count: the server appends them after the user's
13
+ // text, so one the user typed mid-message is left alone.
14
+ const NOTES_AT_END = /(?:\n\n\[Attached document: ".*?" \(id: ([\w.-]+), [\d,]+ chars\)\. Use read_document to search or read it\.\])+$/;
15
+ const ONE_NOTE = /\[Attached document: ".*?" \(id: ([\w.-]+), /g;
16
+
17
+ /** A stored message's text without its notes, and the document ids they named. */
18
+ export function splitAttachmentNotes(content) {
19
+ const text = String(content ?? '');
20
+ const tail = NOTES_AT_END.exec(text);
21
+ if (!tail) return { text, ids: [] };
22
+ return { text: text.slice(0, tail.index), ids: [...tail[0].matchAll(ONE_NOTE)].map((m) => m[1]) };
23
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The events a running turn streams to the browser, as `data: {"type": ...}`
3
+ * lines. Shared by both sides: the server imports this file to emit them
4
+ * (src/chat/runs.js, llm.js, tool_executor.js, agent.js) and the page imports
5
+ * it to handle them (stream.js). It lives in public/shared/ because that is the one
6
+ ^ * place both can load. A test checks that stream.js handles every one.
7
+ */
8
+ export const EVENT = Object.freeze({
9
+ CHAT: 'chat', // the run's chat id and title, first on every stream
10
+ USER: 'user', // a user message the run added (queued follow-up, steering)
11
+ QUEUE: 'queue', // what is waiting to be sent into this chat
12
+ NEXT_RUN: 'next_run', // a queued follow-up is starting as a new run
13
+ REASONING: 'reasoning', // a chunk of the model's thinking
14
+ TEXT: 'text', // a chunk of the model's answer
15
+ TOOL_CALL: 'tool_call', // the model called a tool
16
+ TOOL_RESULT: 'tool_result', // that call's result
17
+ APPROVAL: 'approval', // a call waits for the user's allow / deny
18
+ APPROVAL_DONE: 'approval_done', // the user decided
19
+ QUESTION: 'question', // ask_user put a question to the user
20
+ QUESTION_DONE: 'question_done', // it was answered, skipped, timed out or cancelled
21
+ TASKS: 'tasks', // the chat's checklist changed
22
+ COMPACTED: 'compacted', // older context was compacted this turn
23
+ USAGE: 'usage', // token usage for one model round
24
+ NOTICE: 'notice', // something worth telling the user, not an error
25
+ ERROR: 'error', // the run failed or was stopped
26
+ DONE: 'done' // the turn finished
27
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Every feature a role can be granted (access.roles.*.features). Shared by
3
+ * both sides: the server checks them on each API route (src/routes/*,
4
+ * src/access/policy.js) and the page hides what the viewer cannot use
5
+ * (core/access.js, chat/composer.js, app.js).
6
+ */
7
+ export const FEATURE = Object.freeze({
8
+ CHAT: 'chat',
9
+ ATTACHMENTS: 'attachments',
10
+ IMAGES: 'images',
11
+ SEARCH: 'search',
12
+ FOLDERS: 'folders',
13
+ AUTOMATIONS: 'automations',
14
+ STATISTICS: 'statistics',
15
+ MODEL_PICKER: 'model-picker',
16
+ TOOLS: 'tools',
17
+ SETTINGS: 'settings',
18
+ MCP: 'mcp',
19
+ ADMIN: 'admin',
20
+ OVERSIGHT: 'oversight'
21
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Where a stored message came from, when it was not typed by the user. The
3
+ * server stamps it (src/automations/scheduler.js) and the page draws such a
4
+ * message as what it is (chat/transcript.js), not as a user bubble.
5
+ */
6
+ export const ORIGIN = Object.freeze({
7
+ AUTOMATION: 'automation' // a scheduled or manually triggered automation run
8
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reading a provider's usage object. Shared by both sides so the transcript's
3
+ * per-turn line and the Statistics panel count the same tokens.
4
+ *
5
+ * Nothing here is standardised; each provider spells it differently, and some
6
+ * report no cache figures at all -- then a hit is invisible, not absent. So
7
+ * every reader returns null for "not reported" and the caller picks a default.
8
+ * OpenAI / Gemini / OpenRouter : prompt_tokens_details.cached_tokens
9
+ * OpenAI Responses : input_tokens_details.cached_tokens
10
+ * Anthropic : cache_read_input_tokens (+ creation, billed extra)
11
+ * DeepSeek : prompt_cache_hit_tokens / prompt_cache_miss_tokens
12
+ */
13
+
14
+ const num = (v) => {
15
+ if (v == null) return null;
16
+ const n = Number(v);
17
+ return Number.isFinite(n) && n >= 0 ? n : null;
18
+ };
19
+
20
+ export const inputTokens = (u) => num(u?.prompt_tokens ?? u?.input_tokens);
21
+ export const outputTokens = (u) => num(u?.completion_tokens ?? u?.output_tokens);
22
+
23
+ export const cacheReads = (u) => num(
24
+ u?.prompt_tokens_details?.cached_tokens
25
+ ?? u?.input_tokens_details?.cached_tokens
26
+ ?? u?.cache_read_input_tokens
27
+ ?? u?.prompt_cache_hit_tokens
28
+ ?? u?.cached_tokens
29
+ );
30
+
31
+ export const cacheWrites = (u) => num(
32
+ u?.cache_creation_input_tokens
33
+ ?? u?.cache_write_tokens
34
+ ?? u?.prompt_tokens_details?.cache_write_tokens
35
+ ?? u?.prompt_tokens_details?.cache_creation_tokens
36
+ ?? u?.input_tokens_details?.cache_write_tokens
37
+ );
38
+
39
+ /** Folds one or more raw usage objects into the numbers a turn displays. */
40
+ export function tally(raws) {
41
+ const t = { in: 0, out: 0, cached: 0, written: 0, discount: 0, reported: false, raw: raws, provider: null, providers: [] };
42
+ for (const u of raws) {
43
+ if (u.provider) {
44
+ t.provider = u.provider;
45
+ // Distinct upstreams, in the order they served. A turn split across two
46
+ // of them cannot cache between rounds, and summing would otherwise hide
47
+ // that behind whichever one happened to serve last.
48
+ if (!t.providers.includes(u.provider)) t.providers.push(u.provider);
49
+ }
50
+ t.in += inputTokens(u) ?? 0;
51
+ t.out += outputTokens(u) ?? 0;
52
+ t.cached += cacheReads(u) ?? 0;
53
+ t.written += cacheWrites(u) ?? 0;
54
+ t.discount += Number(u.cache_discount || 0);
55
+ // "The provider said zero" is not "the provider said nothing".
56
+ if (u.prompt_tokens_details || u.input_tokens_details || cacheReads(u) !== null || cacheWrites(u) !== null) t.reported = true;
57
+ }
58
+ return t;
59
+ }