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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/ttm.mjs +343 -0
- package/commands/config.md +13 -0
- package/commands/dashboard.md +7 -0
- package/commands/link.md +12 -0
- package/commands/setup.md +19 -0
- package/commands/status.md +13 -0
- package/lib/adapters.mjs +428 -0
- package/lib/audit.mjs +238 -0
- package/lib/config.mjs +135 -0
- package/lib/daemon-client.mjs +139 -0
- package/lib/daemon.mjs +614 -0
- package/lib/db.mjs +444 -0
- package/lib/link.mjs +152 -0
- package/lib/mcp-server.mjs +275 -0
- package/lib/otel-export.mjs +122 -0
- package/lib/pricing.mjs +137 -0
- package/lib/report-data.mjs +171 -0
- package/lib/setup-snippets.mjs +201 -0
- package/lib/traces-data.mjs +155 -0
- package/lib/tui-config.mjs +429 -0
- package/package.json +56 -0
- package/templates/config.html +208 -0
- package/templates/dashboard.html +629 -0
- package/templates/traces.html +407 -0
|
@@ -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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>
|