// Панель «Токены» для веб-интерфейса OpenCode 2: состав контекста текущей сессии и расход // токенов (сессия / проект / все проекты). Вставляется прокси server.mjs, данные — /__ctx/api/*. // // Встраивается в интерфейс OpenCode, а не поверх него: // - кнопка с % контекста — в верхнюю панель, рядом с кнопкой боковой панели (Toggle review); // - вкладка «Токены» — в боковую панель рядом с Review / Context; её содержимое накрывает // область вкладок, пока она выбрана. // Разметку кнопки и вкладки повторяем за OpenCode (data-component / data-slot), чтобы // подхватились его стили. Интерфейс — Solid и перерисовывается, поэтому наши узлы // проверяются и при необходимости вставляются заново по таймеру. ;(() => { if (window.__ctxPanel) return window.__ctxPanel = true const SCOPE_KEY = "ctx-panel-scope" const TOOLS_KEY = "ctx-panel-tools-open" const RANGE_KEY = "ctx-panel-range" const MODE_KEY = "ctx-panel-chart-mode" const COLLAPSED_KEY = "ctx-panel-collapsed" const TAB = "ctx-tokens" const store = { get: (k, d) => { try { return localStorage.getItem(k) ?? d } catch { return d } }, set: (k, v) => { try { localStorage.setItem(k, v) } catch {} }, } const COLORS = { system: "#94a3b8", user: "#3b82f6", assistant: "#10b981", reasoning: "#a855f7", summary: "#f59e0b", } COLORS.tools = "#f97316" // График расхода: стопка из трёх рядов, снизу вверх. Цвета — категориальная палитра // (синий / оранжевый / бирюзовый), отдельные ступени для светлой и тёмной темы const PALETTE = [ { light: "#2a78d6", dark: "#3987e5" }, { light: "#eb6834", dark: "#d95926" }, { light: "#1baf7a", dark: "#199e70" }, { light: "#eda100", dark: "#c98500" }, { light: "#e87ba4", dark: "#d55181" }, ] const SERIES = [ { label: "Cache", ...PALETTE[0], v: (b) => b.cacheRead + b.cacheWrite }, { label: "Input", ...PALETTE[1], v: (b) => b.input }, { label: "Output", ...PALETTE[2], v: (b) => b.output + b.reasoning }, ] const OTHER = "__other" const RANGES = [ ["today", "сегодня"], ["week", "7 дней"], ["month", "30 дней"], ["all", "всё время"], ] const fmt = (n) => { if (n == null) return "—" const a = Math.abs(n) if (a >= 1e9) return (n / 1e9).toFixed(2) + "B" if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 1 : 2) + "M" if (a >= 1e3) return (n / 1e3).toFixed(a >= 1e4 ? 0 : 1) + "k" return String(n) } const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0) const money = (n) => (n ? "$" + n.toFixed(n < 1 ? 3 : 2) : "—") const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]) const STYLE = ` :host { display: block; position: absolute; left: 0; right: 0; bottom: 0; z-index: 20; background: var(--v2-background-bg-base, #fff); color: var(--v2-text-text-base, #111); font-size: 13px; line-height: 1.5; } * { box-sizing: border-box; } .body { position: absolute; inset: 0; overflow-y: auto; padding: 16px 20px 24px; } section { padding: 0 0 20px; } h3 { margin: 0 0 8px; font-size: 13px; font-weight: 400; color: var(--v2-text-text-muted, #6b7280); display: flex; align-items: center; gap: 6px; } h3 .sp { flex: 1; } .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; margin-bottom: 22px; } .facts .l { color: var(--v2-text-text-muted, #6b7280); } .facts .v { font-weight: 500; font-variant-numeric: tabular-nums; } .bar { height: 8px; border-radius: 4px; background: var(--v2-background-bg-layer-01, #eee); overflow: hidden; display: flex; margin: 0 0 10px; } .bar > i { display: block; height: 100%; } table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } td, th { padding: 3px 0; text-align: right; white-space: nowrap; } th { color: var(--v2-text-text-muted, #6b7280); font-weight: 400; } td:first-child, th:first-child { text-align: left; white-space: normal; padding-right: 8px; } td.m { color: var(--v2-text-text-faint, #9ca3af); width: 44px; } tr.sum td { border-top: 1px solid var(--v2-border-border-base, #e5e7eb); font-weight: 500; padding-top: 6px; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 8px; } tr.group { cursor: pointer; user-select: none; } tr.group:hover td { color: var(--v2-text-text-contrast, #000); } .chev { color: var(--v2-text-text-faint, #9ca3af); margin-left: 6px; font-size: 11px; } h3[data-collapse] { cursor: pointer; user-select: none; } h3[data-collapse]:hover { color: var(--v2-text-text-base, #111); } h3[data-collapse] .chev { margin: 0; width: 10px; } section.folded { padding-bottom: 8px; } section.folded h3 { margin-bottom: 0; } tr.sub td { color: var(--v2-text-text-muted, #6b7280); padding: 2px 0; } tr.sub td:first-child { padding-left: 16px; } .note { color: var(--v2-text-text-faint, #9ca3af); font-size: 12px; margin-top: 8px; } .seg { display: inline-flex; border: 1px solid var(--v2-border-border-base, #e5e7eb); border-radius: 6px; overflow: hidden; } .seg button { font: inherit; font-size: 12px; border: none; background: none; padding: 1px 8px; cursor: pointer; color: var(--v2-text-text-muted, #6b7280); } .seg button.on { background: var(--v2-background-bg-layer-01, #f3f4f6); color: var(--v2-text-text-base, #111); } .row { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin: 0 0 8px; } .muted { color: var(--v2-text-text-muted, #6b7280); } .chart .limit { stroke: var(--v2-text-text-faint, #9ca3af); stroke-width: 1; stroke-dasharray: 4 3; } .chart .cmark { stroke: var(--v2-text-text-muted, #6b7280); stroke-width: 1; stroke-dasharray: 2 2; } .chart[data-chart="ctx"] .hit.on { fill: transparent; } .chart .cross { stroke: var(--v2-text-text-faint, #9ca3af); stroke-width: 1; pointer-events: none; } .legend .dash { display: inline-block; width: 14px; height: 0; border-top: 1px dashed var(--v2-text-text-faint, #9ca3af); vertical-align: middle; margin-right: 6px; } .legend .dash.v { width: 0; height: 10px; border-top: none; border-left: 1px dashed var(--v2-text-text-muted, #6b7280); margin: 0 8px 0 4px; } table.calls { table-layout: fixed; } .ell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } table.cmp td:first-child { white-space: nowrap; } .err.inline, .ell .err { font-size: 12px; } .legend { display: flex; gap: 14px; flex-wrap: wrap; color: var(--v2-text-text-muted, #6b7280); font-size: 12px; margin: 0 0 4px; } .legend .dot { margin-right: 6px; } .chart { position: relative; margin: 0 0 12px; } .chart svg { display: block; width: 100%; } .chart text { font-size: 10px; fill: var(--v2-text-text-faint, #9ca3af); font-variant-numeric: tabular-nums; } .chart .grid { stroke: var(--v2-border-border-base, #e5e7eb); stroke-width: 1; } .chart .hit { fill: transparent; } .chart .hit.on { fill: var(--v2-background-bg-layer-01, #f3f4f6); fill-opacity: .6; } .tip { position: absolute; top: 0; z-index: 1; pointer-events: none; padding: 6px 8px; border-radius: 6px; font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; background: var(--v2-background-bg-base, #fff); border: 1px solid var(--v2-border-border-base, #e5e7eb); box-shadow: 0 2px 8px rgba(0, 0, 0, .12); } .tip b { font-weight: 500; } .tip td { padding: 0 0 0 12px; } .tip td:first-child { padding: 0; } .empty { color: var(--v2-text-text-muted, #6b7280); padding: 32px 0; text-align: center; } .err { color: #dc2626; }` // --- состояние ----------------------------------------------------------------------- let scope = store.get(SCOPE_KEY, "project") let toolsOpen = store.get(TOOLS_KEY, "0") === "1" let range = store.get(RANGE_KEY, "week") let chartMode = store.get(MODE_KEY, "type") // Свёрнутые разделы — по ключу раздела let collapsed = new Set() try { collapsed = new Set(JSON.parse(store.get(COLLAPSED_KEY, "[]"))) } catch {} let hover = {} // { id графика, i } под курсором let active = false // выбрана ли наша вкладка let openedPanel = false // боковую панель открыли мы — мы её и закроем let sessionID let last let unselected = [] // родные вкладки, с которых мы сняли выделение const overlay = document.createElement("div") overlay.id = "ctx-tokens-overlay" const root = overlay.attachShadow({ mode: "open" }) root.innerHTML = `
Откройте сессию
` root.addEventListener("click", (e) => { if (e.target.closest?.('[data-toggle="tools"]')) { toolsOpen = !toolsOpen store.set(TOOLS_KEY, toolsOpen ? "1" : "0") if (last) render(last) return } const r = e.target.closest?.("[data-range]") if (r) { range = r.dataset.range store.set(RANGE_KEY, range) hover = {} if (last) render(last) return } const md = e.target.closest?.("[data-mode]") if (md) { chartMode = md.dataset.mode store.set(MODE_KEY, chartMode) if (last) render(last) return } const b = e.target.closest?.("[data-scope]") if (!b) { // Заголовок раздела сворачивает/разворачивает его (кнопки в заголовке — сами по себе) const h = !e.target.closest?.("button") && e.target.closest?.("[data-collapse]") if (!h) return const k = h.dataset.collapse if (!collapsed.delete(k)) collapsed.add(k) store.set(COLLAPSED_KEY, JSON.stringify([...collapsed])) if (last) render(last) return } scope = b.dataset.scope store.set(SCOPE_KEY, scope) if (last) render(last) }) // Подсказка графика обновляется без полной перерисовки; после перерисовки (раз в 3 с) — восстанавливается root.addEventListener("mousemove", (e) => { const h = e.target.closest?.("[data-i]") const id = h?.closest(".chart")?.dataset.chart const i = h ? Number(h.dataset.i) : -1 if (id === hover.id && i === hover.i) return hover = h ? { id, i } : {} showTip() }) overlay.addEventListener("mouseleave", () => { hover = {} showTip() }) // --- поиск узлов OpenCode ------------------------------------------------------------ // Кнопки — в шапке сессии ([data-session-title]), рядом с родными «Toggle session context» // и «Session details» const reviewToggle = () => document.querySelector('button[aria-controls="review-panel"]') const buttonBar = () => document.querySelector("[data-session-title] .shrink-0.flex.items-center.gap-2") const reviewPanel = () => document.getElementById("review-panel") const panelOpen = () => { const p = reviewPanel() return !!p && p.getAttribute("aria-hidden") !== "true" && reviewToggle()?.getAttribute("aria-expanded") !== "false" } const tablist = () => reviewPanel()?.querySelector('[role="tablist"]') const nativeTabs = () => [...(tablist()?.querySelectorAll('[role="tab"][data-slot="tabs-trigger"]') ?? [])].filter((t) => t.dataset.value !== TAB) // --- кнопка в верхней панели --------------------------------------------------------- const btnWrap = document.createElement("div") btnWrap.className = "shrink-0" btnWrap.dataset.component = "tooltip-v2-trigger" btnWrap.innerHTML = `` const btn = btnWrap.querySelector("button") const btnProgress = btnWrap.querySelectorAll("circle")[1] const btnValue = btnWrap.querySelector(".ctx-v") btn.addEventListener("click", () => (active && panelOpen() ? close() : open())) function setButton(ratio, text) { btnValue.textContent = text btnProgress.setAttribute("stroke-dashoffset", String(39.27 * (1 - Math.min(1, Math.max(0, ratio || 0))))) } // --- вкладка в боковой панели -------------------------------------------------------- const tabWrap = document.createElement("div") tabWrap.dataset.slot = "tabs-trigger-wrapper" tabWrap.dataset.value = TAB tabWrap.innerHTML = `` const tab = tabWrap.querySelector("button") tab.addEventListener("click", () => select(true)) function select(on) { if (on === active) return sync() active = on if (on) { // Снимаем выделение с родной вкладки; вернём его, когда пользователь кликнет любую из них unselected = nativeTabs().filter((t) => t.hasAttribute("data-selected")) for (const t of unselected) { t.removeAttribute("data-selected") t.removeAttribute("data-highlighted") t.setAttribute("aria-selected", "false") } loop() } else { for (const t of unselected) { if (!t.isConnected) continue t.setAttribute("data-selected", "") t.setAttribute("aria-selected", "true") } unselected = [] openedPanel = false } sync() } // Клик по родной вкладке — наша гаснет. capture + pointerdown: успеть до обработчика OpenCode document.addEventListener( "pointerdown", (e) => { if (!active) return const t = e.target.closest?.('[role="tab"]') if (t && t.dataset.value !== TAB && tablist()?.contains(t)) select(false) }, true, ) async function open() { if (!panelOpen()) { reviewToggle()?.click() openedPanel = true for (let i = 0; i < 40 && !tablist(); i++) await new Promise((r) => setTimeout(r, 50)) } sync() select(true) } function close() { const mine = openedPanel select(false) if (mine && panelOpen()) reviewToggle()?.click() } // --- синхронизация с DOM OpenCode ---------------------------------------------------- function sync() { const bar = buttonBar() if (bar && sessionID && btnWrap.parentElement !== bar) bar.prepend(btnWrap) if (!sessionID && btnWrap.isConnected) btnWrap.remove() const list = tablist() if (list) { const wrappers = list.querySelectorAll(`[data-slot="tabs-trigger-wrapper"]:not([data-value="${TAB}"])`) const after = wrappers[wrappers.length - 1] if (after && after.nextSibling !== tabWrap) after.after(tabWrap) } if (active && !panelOpen()) select(false) if (active) btn.setAttribute("data-state", "pressed") else btn.removeAttribute("data-state") tab.setAttribute("aria-selected", String(active)) tab.toggleAttribute("data-selected", active) tab.tabIndex = active ? 0 : -1 const tabsRoot = list?.closest('[data-component="tabs"]') if (active && tabsRoot) { if (getComputedStyle(tabsRoot).position === "static") tabsRoot.style.position = "relative" if (overlay.parentElement !== tabsRoot) tabsRoot.append(overlay) const barEl = list.parentElement overlay.style.top = `${barEl.offsetTop + barEl.offsetHeight}px` } else if (overlay.isConnected) overlay.remove() } // --- данные и отрисовка -------------------------------------------------------------- const currentSession = () => location.pathname.match(/ses_[A-Za-z0-9]+/)?.[0] async function load() { const s = await fetch(`/__ctx/api/session/${sessionID}`).then((r) => r.json()) if (s.error) return { error: s.error } if (!active) return { s } const g = await fetch(`/__ctx/api/global?project=${encodeURIComponent(s.session.projectID)}`).then((r) => r.json()) return { s, g } } function spendRows(cols) { const rows = [ ["Input", "input"], ["Cache read", "cacheRead"], ["Cache write", "cacheWrite"], ["Output", "output"], ["Reasoning", "reasoning"], ] const used = rows.filter(([, k]) => cols.some((c) => c.t[k])) return `${cols.map((c) => ``).join("")} ${used.map(([l, k]) => `${cols.map((c) => ``).join("")}`).join("")} ${cols.map((c) => ``).join("")} ${cols.some((c) => c.t.cost) ? `${cols.map((c) => ``).join("")}` : ""} ${cols.some((c) => c.t.steps) ? `${cols.map((c) => ``).join("")}` : ""}
${esc(c.label)}
${l}${fmt(c.t[k])}
Итого${fmt(c.t.total)}
Стоимость${money(c.t.cost)}
Шагов модели${c.t.steps ? fmt(c.t.steps) : "—"}
` } const pad2 = (n) => String(n).padStart(2, "0") const MONTHS = ["янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"] function bucketLabel(t, unit, long) { const d = new Date(t) if (unit === "hour") return long ? `${pad2(d.getHours())}:00–${pad2(d.getHours() + 1)}:00` : pad2(d.getHours()) if (!long) return `${pad2(d.getDate())}.${pad2(d.getMonth() + 1)}` const day = `${d.getDate()} ${MONTHS[d.getMonth()]}` if (unit !== "week") return day const e = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 6) return `${day} – ${e.getDate()} ${MONTHS[e.getMonth()]}` } // «21 окт 14:05», а для сегодняшнего дня — только время function when(t) { if (!t) return "—" const d = new Date(t) const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}` return d.toDateString() === new Date().toDateString() ? hm : `${d.getDate()} ${MONTHS[d.getMonth()]} ${hm}` } const sec = (v) => (v == null ? "—" : v < 10 ? v.toFixed(1) + " с" : v < 120 ? Math.round(v) + " с" : `${Math.floor(v / 60)} мин ${pad2(Math.round(v % 60))} с`) const rate = (v) => (v == null ? "—" : v >= 1000 ? fmt(Math.round(v)) : v.toFixed(v < 10 ? 1 : 0)) const shortModel = (m) => (m === OTHER ? "другие" : m.split("/").slice(1).join("/") || m) // Тему OpenCode можно выбрать вручную, поэтому смотрим на фон страницы, а не на prefers-color-scheme function isDark() { const m = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g) if (!m || m[3] === "0") return matchMedia("(prefers-color-scheme: dark)").matches const [r, g, b] = m.map(Number) return 0.299 * r + 0.587 * g + 0.114 * b < 128 } const color = (s, dark) => (dark ? s.dark : s.light) // Верх шкалы: 1 / 1.5 / 2 / 2.5 / 3 / 4 / 5 / 6 / 8 × 10^n — мелкие шаги, чтобы график не сплющивало function niceMax(v) { if (v <= 0) return 1 const p = 10 ** Math.floor(Math.log10(v)) return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].find((k) => k * p >= v) * p } // Ширина подписи шрифтом осей графика (10px, шрифт OpenCode) const measure = document.createElement("canvas").getContext("2d") function textWidth(s) { measure.font = `10px ${getComputedStyle(document.body).fontFamily}` return measure.measureText(s).width } // Общая геометрия: ось Y с тремя делениями, слева под подписи ровно столько места, сколько нужно function frame(max, H) { const W = Math.max(200, (root.querySelector(".body")?.clientWidth || 360) - 40) const T = 6 // сверху — место под верхнюю подпись оси Y const plotH = H - 16 // снизу — подписи оси X const ticks = [0, 0.5, 1].map((f) => ({ v: max * f, label: f ? fmt(max * f).replace(/\.0(?=\D|$)/, "") : "0" })) const L = Math.ceil(Math.max(...ticks.map((t) => textWidth(t.label)))) + 8 const y = (v) => plotH - (v / max) * (plotH - T) let grid = "" for (const t of ticks) { const yy = Math.round(y(t.v)) + 0.5 grid += `${t.label}` } return { W, H, T, L, plotH, y, grid } } // Подписи X: не больше ~6, первая и последняя — всегда function xLabels(f, n, slot, label) { const every = Math.ceil(n / 6) let xs = "" for (let i = 0; i < n; i++) { const lastI = i === n - 1 if (!lastI && (i % every || (i && n - 1 - i < every / 2))) continue const [x, anchor] = !i ? [f.L, "start"] : lastI ? [f.W, "end"] : [f.L + slot * (i + 0.5), "middle"] xs += `${label(i)}` } return xs } // --- подсказки графиков: hover = { id графика, i — столбик/шаг } ------------------------ // Перерисовка раз в 3 с заменяет DOM — подсказка восстанавливается по hover в конце render() const charts = {} // id → { tip(i) → html } const hitRect = (id, i, x, f, w) => `` function showTip() { for (const c of root.querySelectorAll(".chart")) { const on = c.dataset.chart === hover.id for (const h of c.querySelectorAll(".hit")) h.classList.toggle("on", on && Number(h.dataset.i) === hover.i) const cross = c.querySelector(".cross") const tip = c.querySelector(".tip") const html = on ? charts[hover.id]?.tip(hover.i) : undefined if (!html) { tip.hidden = true if (cross) cross.style.display = "none" continue } tip.innerHTML = html tip.hidden = false // Справа от столбика, а если не влезает — слева const box = c.getBoundingClientRect() const hit = c.querySelector(`.hit[data-i="${hover.i}"]`).getBoundingClientRect() const mid = hit.left - box.left + hit.width / 2 const left = mid + 12 + tip.offsetWidth > box.width ? mid - 12 - tip.offsetWidth : mid + 12 tip.style.left = `${Math.max(0, left)}px` if (cross) { const svg = c.querySelector("svg") const k = svg.viewBox.baseVal.width / svg.getBoundingClientRect().width cross.setAttribute("x1", mid * k) cross.setAttribute("x2", mid * k) cross.style.display = "" } } } const dotHtml = (c) => `` const legendHtml = (series, dark) => `
${series.map((s) => `${dotHtml(color(s, dark))}${esc(s.label)}`).join("")}
` // --- график расхода: столбики со стопкой рядов ------------------------------------------ function barChart(id, ser, series) { if (!ser?.buckets.some((b) => b.total)) { delete charts[id] return `
Нет расхода за период
` } const dark = isDark() const { buckets, unit } = ser const f = frame(niceMax(Math.max(...buckets.map((b) => series.reduce((a, s) => a + s.v(b), 0)))), 140) const n = buckets.length const slot = (f.W - f.L) / n const gap = n > 40 ? 1 : 2 const bw = Math.max(1, slot - gap) let bars = "" buckets.forEach((b, i) => { const x = f.L + slot * i + gap / 2 bars += hitRect(id, i, f.L + slot * i, f, slot) const segs = series.map((s) => ({ s, v: s.v(b) })).filter((x) => x.v > 0) let acc = 0 segs.forEach(({ s, v }, j) => { const y0 = f.y(acc) acc += v const y1 = f.y(acc) // Между сегментами — зазор 1px; верх столбика скруглён, низ стоит на оси ровно const h = y0 - y1 - (j ? 1 : 0) if (h <= 0) return const r = j === segs.length - 1 ? Math.min(2, bw / 2, h / 2) : 0 bars += `` }) }) charts[id] = { tip: (i) => { const b = buckets[i] if (!b) return return `${bucketLabel(b.t, unit, true)} ${[...series].reverse().filter((s) => s.v(b)).map((s) => ``).join("")} ${b.cost ? `` : ""} ${b.steps ? `` : ""}
${dotHtml(color(s, dark))}${esc(s.label)}${fmt(s.v(b))}
Итого${fmt(b.total)}
Стоимость${money(b.cost)}
Шагов модели${fmt(b.steps)}
` }, } const what = unit === "hour" ? "часам" : unit === "week" ? "неделям" : "дням" return `${legendHtml(series, dark)}
${f.grid}${bars}${xLabels(f, n, slot, (i) => bucketLabel(buckets[i].t, unit))}
` } // Ряды для столбиков: по типу токенов или по моделям (топ-4 по всем проектам + «другие»). // Цвет модели — по её месту в общем топе за всё время, а не в выбранном периоде: он не скачет function spendSeries(g) { if (chartMode !== "models") return SERIES const top = g.models.slice(0, 4).map((m) => m.model) return [ ...top.map((m, i) => ({ label: shortModel(m), ...PALETTE[i], v: (b) => b.models?.[m] ?? 0 })), { label: "другие", ...PALETTE[4], v: (b) => Object.entries(b.models ?? {}).reduce((a, [k, v]) => (top.includes(k) ? a : a + v), 0) }, ] } // --- рост контекста по шагам сессии ----------------------------------------------------- function contextChart(s) { const id = "ctx" const steps = s.steps ?? [] if (steps.length < 2) { delete charts[id] return `
Мало шагов для графика
` } const dark = isDark() const limit = s.model.limit const f = frame(niceMax(Math.max(limit ?? 0, ...steps.map((p) => p.total))), 150) const n = steps.length const slot = (f.W - f.L) / n const x = (i) => f.L + slot * (i + 0.5) const line = color(PALETTE[0], dark) const pts = steps.map((p, i) => `${x(i).toFixed(1)},${f.y(p.total).toFixed(1)}`) let body = `` body += `` if (limit) { const ly = Math.round(f.y(limit)) + 0.5 body += `лимит ${fmt(limit)}` } // compaction — вертикальная черта между шагами for (const c of s.compactions ?? []) { if (c.at <= 0 || c.at >= n) continue const cx = f.L + slot * c.at body += `` } let hits = "" for (let i = 0; i < n; i++) hits += hitRect(id, i, f.L + slot * i, f, slot) charts[id] = { tip: (i) => { const p = steps[i] if (!p) return const c = (s.compactions ?? []).find((c) => c.at === i) return `Шаг ${i + 1} · ${when(p.t)} ${i ? `` : ""} ${p.model && p.model !== s.model.modelID ? `` : ""} ${c ? `` : ""}
${dotHtml(line)}Контекст${fmt(p.total)}${limit ? ` · ${pct(p.total, limit)}%` : ""}
Output${fmt(p.output)}
Прирост${p.total >= steps[i - 1].total ? "+" : "−"}${fmt(Math.abs(p.total - steps[i - 1].total))}
Модель${esc(p.model)}
Перед шагомcompaction
` }, } return `
${dotHtml(line)}токенов в запросе${limit ? `лимит модели` : ""}${ s.compactions?.length ? `compaction` : "" }
${f.grid}${body}${hits}${xLabels(f, n, slot, (i) => i + 1)}
` } function compactionsHtml(list) { if (!list?.length) return `
Compaction в этой сессии не было.
` return ` ${list .map( (c) => ``, ) .join("")}
compactionдопослесводказаняло
${when(c.t)} ${c.auto ? "авто" : "вручную"} ${fmt(c.before)}${c.done ? fmt(c.after) : "идёт…"}${c.done ? fmt(c.summary) : "—"}${sec(c.sec)}
«До» — последний запрос перед сжатием, «после» — первый запрос после него, «сводка» — сколько токенов написала модель.
` } function callsHtml(calls, base) { if (!calls?.length) return `
В контексте нет вызовов тулов.
` return ` ${calls .map( (c) => ``, ) .join("")}
${esc(c.tool)} ${esc(c.title)}${c.error ? ` ошибка` : ""}
${fmt(c.tokens)}${pct(c.tokens, base)}%
Вход + вывод вызова, ≈ символы/4. Вывод, который OpenCode уже вычистил из контекста (prune), не считается.
` } function speedHtml(sp) { if (!sp) return `
Нет данных о времени шагов.
` return `
Генерация
${rate(sp.decodeTps)} ток/с
Первый токен, в среднем
${sec(sp.firstTokenSec)}
Prefill
${rate(sp.prefillTps)} ток/с
Модель на шаг, в среднем
${sec(sp.stepSec)}
По ${fmt(sp.steps)} шагам. Генерация — output + reasoning за время потока. Первый токен — от отправки запроса до начала потока: очередь и prefill. Prefill — input без кэша за это же время (по нему видно, работает ли кэш префикса во vLLM). Выполнение тулов не считается.
` } function render(data) { last = data const body = root.querySelector(".body") if (!data?.s) { setButton(0, "—") body.innerHTML = data?.error ? `
${esc(data.error)}
` : `
Откройте сессию
` return } const { s, g } = data const ctx = s.context const limit = s.model.limit setButton(limit ? ctx.total / limit : 0, limit ? `${pct(ctx.total, limit)}%` : fmt(ctx.total)) if (!active || !g) return // Все тулы — одной строкой «Тулы», по клику раскрывается список по отдельным тулам const cats = [] let tools for (const c of ctx.categories) { if (c.kind !== "tool") cats.push({ ...c, color: COLORS[c.kind] }) else (tools ??= { key: "tools", kind: "tools", label: "Тулы", color: COLORS.tools, tokens: 0, items: [] }).items.push(c) } if (tools) { tools.tokens = tools.items.reduce((a, c) => a + c.tokens, 0) tools.label = `Тулы (${tools.items.length})` cats.push(tools) cats.sort((a, b) => b.tokens - a.tokens) } const base = Math.max(ctx.total, ctx.estimated) || 1 const facts = `
В контексте
${fmt(ctx.total)}${limit ? ` из ${fmt(limit)} · ${pct(ctx.total, limit)}%` : ""}
Модель
${esc(s.model.modelID ?? "—")}
Сообщений в контексте
${ctx.messages}${ctx.compacted ? " (после compaction)" : ""}
Шагов модели
${fmt(s.spend.self.steps)}
` const ctxHtml = ctx.total || ctx.estimated ? `
${cats.map((c) => ``).join("")}
${cats .map((c) => c.kind !== "tools" ? `` : `` + (toolsOpen ? c.items.map((t) => ``).join("") : ""), ) .join("")}
${esc(c.label)}${fmt(c.tokens)}${pct(c.tokens, base)}%
${esc(c.label)}${toolsOpen ? "▾" : "▸"}${fmt(c.tokens)}${pct(c.tokens, base)}%
${esc(t.label)}${fmt(t.tokens)}${pct(t.tokens, base)}%
≈ оценка по символам/4. «Остаток» — системный промпт, AGENTS.md, схемы тулов и MCP, плюс погрешность оценки.
` : `
В сессии ещё нет ответов модели
` const sessCols = [{ label: "сессия", t: s.spend.self }] if (s.spend.subagents) sessCols.push({ label: `+ сабагенты (${s.spend.subagents})`, t: s.spend.withSubagents }) const gs = scope === "all" ? g.all : g.project const gCols = RANGES.map(([k, label]) => ({ label, t: gs[k] })) const gSeries = (scope === "all" ? g.series?.all : g.series?.project)?.[range] const seg = (attr, items, cur) => `${items.map(([k, l]) => ``).join("")}` // Раздел со сворачиваемым заголовком; содержимое свёрнутого не строим. extra — справа в заголовке const block = (key, title, content, extra = "") => { const folded = collapsed.has(key) return `

${folded ? "▸" : "▾"}${title}${ extra ? `${extra}` : "" }

${folded ? "" : content()}
` } body.innerHTML = ` ${facts} ${block("context", "Состав контекста", () => ctxHtml)} ${block("growth", "Рост контекста по шагам", () => `${contextChart(s)}${compactionsHtml(s.compactions)}`)} ${block("calls", "Самые тяжёлые вызовы в контексте", () => callsHtml(ctx.topCalls, base))} ${block("session", "Расход за сессию", () => spendRows(sessCols))} ${block("speed", "Скорость модели в сессии", () => speedHtml(s.speed))} ${block( "spend", "Расход", () => `
${seg("range", RANGES, range)}${seg("mode", [["type", "по типу"], ["models", "по моделям"]], chartMode)}
${barChart("spend", gSeries, spendSeries(g))} ${spendRows(gCols)}
Сессий: ${scope === "all" ? g.allSessions : g.projectSessions} (включая сабагентов). Input без кэша считается целиком на каждом шаге — у vLLM-моделей это основная статья.
`, seg("scope", [["project", "проект"], ["all", "все проекты"]], scope === "all" ? "all" : "project"), )} ${g.models.length ? block("models", "Модели (все проекты, всё время)", () => ` ${g.models.map((m) => ``).join("")}
inputoutputток/сна шаг
${esc(shortModel(m.model))}${fmt(m.input + m.cacheRead + m.cacheWrite)}${fmt(m.output + m.reasoning)}${rate(m.speed?.decodeTps)}${sec(m.speed?.stepSec)}
Input — вместе с кэшем. Ток/с — скорость генерации, «на шаг» — среднее время модели на шаг, как в разделе выше.
`) : ""}` showTip() } let busy = false async function tick() { const id = currentSession() if (id !== sessionID) { sessionID = id last = undefined if (!id && active) select(false) } sync() if (!sessionID) return render(undefined) if (busy || (document.hidden && last)) return busy = true try { render(await load()) } catch (e) { render({ error: `Нет данных: ${e.message}` }) } finally { busy = false } } // Данные: часто, пока вкладка выбрана, иначе — только для % на кнопке let timer function loop() { clearTimeout(timer) tick() timer = setTimeout(loop, active ? 3000 : 10000) } // DOM и URL меняются без перезагрузки — проверяем их чаще, чем данные let lastPath = location.pathname setInterval(() => { if (location.pathname !== lastPath) { lastPath = location.pathname return loop() } sync() }, 500) document.addEventListener("visibilitychange", () => !document.hidden && loop()) const start = () => loop() document.body ? start() : addEventListener("DOMContentLoaded", start) })()