token-trace-manager 0.7.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.
@@ -0,0 +1,407 @@
1
+ <!doctype html>
2
+ <html lang="pt-BR">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ttm — traces</title>
7
+ <style>
8
+ :root {
9
+ --page: #0d0d0d; --surface: #1a1a19; --surface-2: #211f1e;
10
+ --ink: #f2f1ec; --ink-2: #c3c2b7; --muted: #898781;
11
+ --grid: #2c2c2a; --baseline: #383835; --border: rgba(255,255,255,0.09);
12
+ --accent: #3987e5; --accent-dim: #86b6ef; --accent-wash: rgba(57,135,229,0.13);
13
+ --warning: #fab219; --good: #0ca30c; --err: #e66767;
14
+ --mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, monospace;
15
+ --sans: system-ui, -apple-system, "Segoe UI", sans-serif;
16
+ --c-anthropic: #3987e5; --c-openai: #199e70; --c-gemini: #c98500; --c-openrouter: #9085e9; --c-outro: #898781;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ html { color-scheme: dark; }
20
+ body {
21
+ margin: 0;
22
+ background:
23
+ repeating-linear-gradient(0deg, rgba(255,255,255,0.012) 0 1px, transparent 1px 3px),
24
+ radial-gradient(1200px 400px at 50% -140px, rgba(57,135,229,0.07), transparent 70%),
25
+ var(--page);
26
+ color: var(--ink); font-family: var(--sans); font-size: 13px; line-height: 1.45;
27
+ }
28
+ .wrap { max-width: 1360px; margin: 0 auto; padding: 0 28px 56px; }
29
+ .topbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
30
+ .brand { display: flex; align-items: baseline; gap: 10px; }
31
+ .brand .mark { font-family: var(--mono); font-weight: 700; font-size: 15px; color: var(--ink); }
32
+ .brand .mark::before { content: "▮▯▮ "; color: var(--accent); letter-spacing: -0.12em; margin-right: 2px; }
33
+ .nav { display: flex; gap: 4px; }
34
+ .nav a { color: var(--muted); text-decoration: none; font-size: 12px; padding: 5px 12px; border-radius: 6px; }
35
+ .nav a:hover { color: var(--ink); background: rgba(255,255,255,0.05); }
36
+ .nav a.active { color: var(--accent-dim); background: var(--accent-wash); font-weight: 600; }
37
+
38
+ .filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
39
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; background: var(--surface); }
40
+ .seg a { color: var(--ink-2); text-decoration: none; font-size: 12px; padding: 6px 13px; border-right: 1px solid var(--border); }
41
+ .seg a:last-child { border-right: 0; }
42
+ .seg a:hover { background: rgba(255,255,255,0.05); color: var(--ink); }
43
+ .seg a.active { background: var(--accent-wash); color: var(--accent-dim); font-weight: 600; }
44
+ select, .filters input {
45
+ background: var(--surface); border: 1px solid var(--border); border-radius: 7px;
46
+ color: var(--ink); padding: 6px 10px; font-size: 12px; font-family: var(--sans);
47
+ }
48
+ select:focus, .filters input:focus { outline: none; border-color: var(--accent); }
49
+ .count { color: var(--muted); font-size: 11px; font-family: var(--mono); margin-left: auto; }
50
+
51
+ details.session {
52
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
53
+ margin-bottom: 10px; overflow: hidden;
54
+ }
55
+ details.session[open] { border-color: rgba(57,135,229,0.4); }
56
+ summary {
57
+ list-style: none; cursor: pointer; padding: 12px 16px;
58
+ display: grid; grid-template-columns: 16px minmax(120px, 1fr) 110px 150px 90px 170px 90px 110px;
59
+ gap: 12px; align-items: center;
60
+ }
61
+ summary::-webkit-details-marker { display: none; }
62
+ summary:hover { background: rgba(255,255,255,0.028); }
63
+ .tw { color: var(--muted); transition: transform 0.15s ease; font-size: 10px; }
64
+ details[open] .tw { transform: rotate(90deg); }
65
+ .s-label { font-family: var(--mono); font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
66
+ .s-label .none { color: var(--muted); font-weight: 400; }
67
+ .s-meta { color: var(--muted); font-size: 11px; white-space: nowrap; }
68
+ .s-num { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 12px; text-align: right; white-space: nowrap; }
69
+ .s-num .dim, .dim { color: var(--muted); }
70
+ .provider { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
71
+ .provider .dot { width: 7px; height: 7px; border-radius: 50%; }
72
+
73
+ .msgs { border-top: 1px solid var(--grid); padding: 4px 16px 12px; overflow-x: auto; }
74
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
75
+ th, td { text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--grid); white-space: nowrap; }
76
+ tbody tr:last-child td { border-bottom: 0; }
77
+ th { color: var(--muted); font-weight: 500; font-size: 10px; text-transform: uppercase; letter-spacing: 0.11em; }
78
+ th:first-child, td:first-child { text-align: left; }
79
+ tr.call:hover td { background: rgba(255,255,255,0.028); }
80
+ .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
81
+ .flow { font-family: var(--mono); }
82
+ .flow .arr { color: var(--muted); margin: 0 4px; }
83
+ .pid { color: var(--muted); font-family: var(--mono); font-size: 10.5px; cursor: copy; }
84
+ .pid:hover { color: var(--accent-dim); }
85
+ .st { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--good); }
86
+ .st.err { background: var(--err); }
87
+ .empty { color: var(--muted); padding: 24px 4px; }
88
+ .note { color: var(--muted); font-size: 11px; margin: 10px 0 0; }
89
+
90
+ .view-btn {
91
+ background: none; border: 1px solid var(--border); border-radius: 5px;
92
+ color: var(--ink-2); cursor: pointer; font-size: 10.5px; padding: 3px 9px; font-family: var(--sans);
93
+ }
94
+ .view-btn:hover { border-color: var(--accent); color: var(--accent-dim); }
95
+ tr.payload-row td { background: var(--surface-2); padding: 0; }
96
+ .payload {
97
+ padding: 12px 16px; max-height: 460px; overflow: auto;
98
+ font-family: var(--mono); font-size: 11.5px; line-height: 1.55; text-align: left; white-space: pre-wrap; word-break: break-word;
99
+ }
100
+ .payload h4 { margin: 10px 0 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent-dim); font-family: var(--sans); }
101
+ .payload h4:first-child { margin-top: 0; }
102
+ .payload .role { color: var(--warning); }
103
+ .payload .plain { color: var(--ink-2); }
104
+ #tip {
105
+ position: fixed; display: none; z-index: 10; pointer-events: none;
106
+ background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
107
+ padding: 9px 12px; font-size: 11.5px; max-width: 380px;
108
+ box-shadow: 0 6px 24px rgba(0,0,0,0.55); font-family: var(--mono);
109
+ }
110
+ #tip .tv { font-weight: 700; font-size: 12.5px; color: var(--ink); }
111
+ #tip .tl { color: var(--muted); margin-top: 2px; }
112
+ </style>
113
+ </head>
114
+ <body>
115
+ <div class="wrap">
116
+ <div class="topbar">
117
+ <div class="brand"><span class="mark">ttm</span></div>
118
+ <nav class="nav">
119
+ <a href="/dashboard">Visão geral</a>
120
+ <a href="/traces" class="active">Traces</a>
121
+ <a href="/config">Config</a>
122
+ </nav>
123
+ </div>
124
+
125
+ <div class="filters" id="filters">
126
+ <select id="f-target"><option value="">provedor: todos</option></select>
127
+ <select id="f-model"><option value="">modelo: todos</option></select>
128
+ <select id="f-client"><option value="">cliente: todos</option></select>
129
+ <input type="search" id="f-label" placeholder="tarefa (ex: PROJ-123)…" style="width:180px" />
130
+ <span class="count" id="count"></span>
131
+ </div>
132
+
133
+ <div id="sessions"></div>
134
+
135
+ <div id="tip"></div>
136
+ </div>
137
+
138
+ <script id="report-data" type="application/json">"__REPORT_DATA__"</script>
139
+ <script>
140
+ const data = JSON.parse(document.getElementById('report-data').textContent)
141
+
142
+ function esc(s) {
143
+ return String(s).replace(/[&<>"']/g, c =>
144
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
145
+ )
146
+ }
147
+ const nf = new Intl.NumberFormat('pt-BR')
148
+ function compact(n) {
149
+ n = n || 0
150
+ if (n >= 1e9) return (n / 1e9).toFixed(2).replace('.', ',') + 'B'
151
+ if (n >= 1e6) return (n / 1e6).toFixed(2).replace('.', ',') + 'M'
152
+ if (n >= 1e3) return (n / 1e3).toFixed(1).replace('.', ',') + 'k'
153
+ return nf.format(n)
154
+ }
155
+ const usd = n => '$' + (n || 0).toFixed(2)
156
+ const hm = ts => new Date(ts).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
157
+ const dm = ts => new Date(ts).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })
158
+ const dur = ms => (ms < 60000 ? Math.round(ms / 1000) + 's' : Math.round(ms / 60000) + 'min')
159
+ const PC = { anthropic: 'var(--c-anthropic)', openai: 'var(--c-openai)', gemini: 'var(--c-gemini)', openrouter: 'var(--c-openrouter)' }
160
+ const pcolor = t => PC[t] || 'var(--c-outro)'
161
+
162
+ // ---- filtros (recarregam a página — o servidor refaz o recorte) ----
163
+ const params = new URLSearchParams(location.search)
164
+ const since = data.since || '24h'
165
+ const seg = document.createElement('div')
166
+ seg.className = 'seg'
167
+ for (const [val, text] of [['24h', 'Hoje'], ['7d', '7 dias'], ['30d', '30 dias'], ['all', 'Tudo']]) {
168
+ const a = document.createElement('a')
169
+ const p = new URLSearchParams(params)
170
+ p.set('since', val)
171
+ a.href = '?' + p.toString()
172
+ a.textContent = text
173
+ if (val === since) a.className = 'active'
174
+ seg.appendChild(a)
175
+ }
176
+ document.getElementById('filters').prepend(seg)
177
+
178
+ function fillSelect(id, options, param) {
179
+ const el = document.getElementById(id)
180
+ for (const o of options) {
181
+ const opt = document.createElement('option')
182
+ opt.value = o
183
+ opt.textContent = o
184
+ if (data.filters[param] === o) opt.selected = true
185
+ el.appendChild(opt)
186
+ }
187
+ el.addEventListener('change', () => {
188
+ const p = new URLSearchParams(location.search)
189
+ if (el.value) p.set(param, el.value)
190
+ else p.delete(param)
191
+ p.set('since', since)
192
+ location.search = p.toString()
193
+ })
194
+ }
195
+ fillSelect('f-target', data.filterOptions.targets, 'target')
196
+ fillSelect('f-model', data.filterOptions.models, 'model')
197
+ fillSelect('f-client', data.filterOptions.clients, 'client')
198
+ const fLabel = document.getElementById('f-label')
199
+ fLabel.value = data.filters.label ?? ''
200
+ fLabel.addEventListener('keydown', e => {
201
+ if (e.key !== 'Enter') return
202
+ const p = new URLSearchParams(location.search)
203
+ if (fLabel.value.trim()) p.set('label', fLabel.value.trim().toUpperCase())
204
+ else p.delete('label')
205
+ p.set('since', since)
206
+ location.search = p.toString()
207
+ })
208
+ document.getElementById('count').textContent =
209
+ `${data.sessionCount} sessões · ${data.callCount} mensagens no recorte` +
210
+ (data.truncated ? ` (mostrando ${data.sessions.length} sessões / ${data.callsShown} msgs)` : '')
211
+
212
+ // ---- tooltip (recibo do custo) ----
213
+ const tip = document.getElementById('tip')
214
+ function showTip(ev, lines) {
215
+ tip.replaceChildren(
216
+ ...lines.map(([cls, text]) => {
217
+ const d = document.createElement('div')
218
+ d.className = cls
219
+ d.textContent = text
220
+ return d
221
+ }),
222
+ )
223
+ tip.style.display = 'block'
224
+ const pad = 14
225
+ tip.style.left = Math.min(ev.clientX + pad, window.innerWidth - tip.offsetWidth - pad) + 'px'
226
+ tip.style.top = Math.min(ev.clientY + pad, window.innerHeight - tip.offsetHeight - pad) + 'px'
227
+ }
228
+ const hideTip = () => (tip.style.display = 'none')
229
+
230
+ // ---- sessões ----
231
+ const root = document.getElementById('sessions')
232
+ if (!data.sessions.length) {
233
+ root.innerHTML = '<div class="empty">Nenhuma sessão no recorte. Ajuste os filtros ou o período.</div>'
234
+ }
235
+ const callIndex = new Map() // "sid:idx" -> call
236
+ root.innerHTML = data.sessions
237
+ .map(s => {
238
+ const providers = s.targets
239
+ .map(t => `<span class="provider"><span class="dot" style="background:${pcolor(t)}"></span>${esc(t)}</span>`)
240
+ .join(' ')
241
+ const rows = s.calls
242
+ .map((c, j) => {
243
+ callIndex.set(`${s.id}:${j}`, c)
244
+ const lidos = c.input + c.cacheCreate + c.cacheRead
245
+ return `<tr class="call" data-k="${s.id}:${j}">
246
+ <td style="width:16px"><span class="st${c.status && c.status >= 400 ? ' err' : ''}"></span></td>
247
+ <td class="mono dim" style="font-size:11px">${hm(c.ts)}</td>
248
+ <td class="mono dim" style="font-size:11px">${esc(c.model ?? '?')}</td>
249
+ <td class="mono">${compact(c.input)}</td>
250
+ <td class="mono">${compact(c.cacheCreate)}${c.cacheCreate1h ? ' <span class="dim">1h</span>' : ''}</td>
251
+ <td class="mono">${compact(c.cacheRead)}</td>
252
+ <td class="mono" style="font-weight:600">${compact(c.output)}</td>
253
+ <td class="mono">${c.cost === null || c.cost === undefined ? '<span class="dim">s/ preço</span>' : usd(c.cost)}</td>
254
+ <td class="mono dim">${c.durationMs ? (c.durationMs / 1000).toFixed(1) + 's' : '—'}</td>
255
+ <td class="pid" data-pid="${esc(c.providerId ?? '')}" title="clique para copiar o id">${esc(c.providerId ?? '—')}</td>
256
+ <td>${c.payload ? `<button class="view-btn" data-id="${c.id}">conteúdo</button>` : '<span class="dim" style="font-size:10px">—</span>'}</td>
257
+ </tr>`
258
+ })
259
+ .join('')
260
+ return `<details class="session" ${data.sessions.length <= 3 ? 'open' : ''}>
261
+ <summary>
262
+ <span class="tw">▶</span>
263
+ <span class="s-label">${s.label ? esc(s.label) : '<span class="none">sem label</span>'}</span>
264
+ <span class="s-meta">${esc(s.client)}${s.repo ? ' · ' + esc(s.repo) + (s.branch ? '@' + esc(s.branch) : '') : ''}</span>
265
+ <span class="s-meta">${dm(s.start)} ${hm(s.start)} · ${dur(s.durationMs)}</span>
266
+ <span class="s-num">${nf.format(s.calls.length)} <span class="dim">msg</span></span>
267
+ <span class="s-num" title="${nf.format(s.tokensIn)} lidos / ${nf.format(s.tokensOut)} escritos">${compact(s.tokensIn)} <span class="dim">→</span> ${compact(s.tokensOut)}</span>
268
+ <span class="s-num">${usd(s.cost)}${s.costKnown ? '' : '*'}</span>
269
+ <span style="text-align:right">${providers}</span>
270
+ </summary>
271
+ <div class="msgs">
272
+ <table>
273
+ <thead><tr><th></th><th>Hora</th><th>Modelo</th><th>Entrada</th><th>Cache esc.</th><th>Cache lido</th><th>Saída</th><th>Custo</th><th>Dur.</th><th>ID no provedor</th><th></th></tr></thead>
274
+ <tbody>${rows}</tbody>
275
+ </table>
276
+ </div>
277
+ </details>`
278
+ })
279
+ .join('')
280
+ if (data.truncated) {
281
+ const n = document.createElement('div')
282
+ n.className = 'note'
283
+ n.textContent = `mostrando as ${data.sessions.length} sessões mais recentes — refine os filtros para ver o resto`
284
+ root.appendChild(n)
285
+ }
286
+
287
+ // recibo do custo no hover da mensagem
288
+ root.addEventListener('pointermove', e => {
289
+ const tr = e.target.closest('tr.call')
290
+ if (!tr) return hideTip()
291
+ const c = callIndex.get(tr.dataset.k)
292
+ if (!c) return hideTip()
293
+ const lines = [
294
+ ['tv', c.cost === null || c.cost === undefined ? 'custo indisponível (modelo sem preço)' : usd(c.cost)],
295
+ ['tl', (c.model ?? '?') + ' · ' + (c.client ?? '')],
296
+ ]
297
+ if (c.rates) {
298
+ const r = c.rates
299
+ const c1h = Math.min(c.cacheCreate1h, c.cacheCreate)
300
+ const c5m = c.cacheCreate - c1h
301
+ for (const [tok, rate, name] of [
302
+ [c.input, r.input, 'entrada'],
303
+ [c.cacheRead, r.cacheRead, 'cache lido'],
304
+ [c5m, r.cacheWrite5m, 'cache escrito 5m'],
305
+ [c1h, r.cacheWrite1h, 'cache escrito 1h'],
306
+ [c.output, r.output, 'saída'],
307
+ ]) {
308
+ if (tok > 0) lines.push(['tl', `${nf.format(tok)} × $${rate}/M = $${((tok / 1e6) * rate).toFixed(6)} (${name})`])
309
+ }
310
+ lines.push(['tl', `tabela de preços de ${r.pricingDate}`])
311
+ } else {
312
+ lines.push(['tl', 'evento anterior ao recibo de taxas — sem decomposição'])
313
+ }
314
+ showTip(e, lines)
315
+ })
316
+ root.addEventListener('pointerleave', hideTip)
317
+
318
+ // "conteúdo": abre o payload da chamada (request/response) inline
319
+ function textOf(content) {
320
+ // content de mensagens pode ser string ou array de blocos
321
+ if (typeof content === 'string') return content
322
+ if (Array.isArray(content)) {
323
+ return content
324
+ .map(b => {
325
+ if (typeof b === 'string') return b
326
+ if (b?.type === 'text') return b.text ?? ''
327
+ if (b?.type === 'tool_result') return '[resultado de ferramenta]'
328
+ if (b?.type === 'tool_use') return `[chamada de ferramenta: ${b.name ?? '?'}]`
329
+ if (b?.type === 'image' || b?.type === 'input_image') return '[imagem]'
330
+ return `[${b?.type ?? 'bloco'}]`
331
+ })
332
+ .join('\n')
333
+ }
334
+ return content == null ? '' : JSON.stringify(content)
335
+ }
336
+
337
+ function renderPayload(container, p) {
338
+ const frag = document.createDocumentFragment()
339
+ const addH = txt => { const h = document.createElement('h4'); h.textContent = txt; frag.appendChild(h) }
340
+ const addP = (txt, cls) => {
341
+ const div = document.createElement('div')
342
+ if (cls) div.className = cls
343
+ div.textContent = txt
344
+ frag.appendChild(div)
345
+ }
346
+ const req = p.request ?? {}
347
+ if (req._truncado) addP(`request grande demais para armazenar (${req._bytes} bytes)`, 'plain')
348
+ const sys = textOf(req.system ?? (Array.isArray(req.input) ? null : undefined) ?? '')
349
+ if (sys) { addH('sistema'); addP(sys.length > 4000 ? sys.slice(0, 4000) + ' […]' : sys, 'plain') }
350
+ const msgs = req.messages ?? req.input ?? req.contents ?? []
351
+ if (Array.isArray(msgs) && msgs.length) {
352
+ addH(`mensagens (${msgs.length})`)
353
+ for (const m of msgs.slice(-30)) {
354
+ const role = m?.role ?? 'user'
355
+ const body = textOf(m?.content ?? m?.parts ?? m)
356
+ const div = document.createElement('div')
357
+ const r = document.createElement('span'); r.className = 'role'; r.textContent = role + ': '
358
+ div.appendChild(r); div.appendChild(document.createTextNode(body.length > 1500 ? body.slice(0, 1500) + ' […]' : body))
359
+ frag.appendChild(div)
360
+ }
361
+ if (msgs.length > 30) addP(`… ${msgs.length - 30} mensagens anteriores omitidas da visualização`, 'plain')
362
+ }
363
+ addH('resposta')
364
+ addP(p.response?.text ?? '(sem texto — só chamadas de ferramenta ou vazio)', 'plain')
365
+ container.replaceChildren(frag)
366
+ }
367
+
368
+ root.addEventListener('click', async e => {
369
+ const btn = e.target.closest('.view-btn')
370
+ if (!btn) return
371
+ const tr = btn.closest('tr')
372
+ const existing = tr.nextElementSibling
373
+ if (existing?.classList.contains('payload-row')) { existing.remove(); return }
374
+ const row = document.createElement('tr')
375
+ row.className = 'payload-row'
376
+ const td = document.createElement('td')
377
+ td.colSpan = tr.children.length
378
+ const box = document.createElement('div')
379
+ box.className = 'payload'
380
+ box.textContent = 'carregando…'
381
+ td.appendChild(box); row.appendChild(td)
382
+ tr.after(row)
383
+ try {
384
+ const res = await fetch(`/api/payload?id=${btn.dataset.id}`)
385
+ if (!res.ok) throw new Error((await res.json()).error)
386
+ renderPayload(box, await res.json())
387
+ } catch (err) {
388
+ box.textContent = 'erro: ' + err.message
389
+ }
390
+ })
391
+
392
+ // clique no id copia (para reconciliar com transcript/Console)
393
+ root.addEventListener('click', async e => {
394
+ const pid = e.target.closest('.pid')
395
+ if (!pid || !pid.dataset.pid) return
396
+ try {
397
+ await navigator.clipboard.writeText(pid.dataset.pid)
398
+ const old = pid.textContent
399
+ pid.textContent = 'copiado ✓'
400
+ setTimeout(() => (pid.textContent = old), 1200)
401
+ } catch {
402
+ /* clipboard indisponível */
403
+ }
404
+ })
405
+ </script>
406
+ </body>
407
+ </html>