Files
k.denisov 1af3ff9d10 Панель «Токены» для веб-интерфейса OpenCode 2 как плагин
Прокси (server.mjs) вставляет panel.js в страницы веб-интерфейса и отдаёт
/__ctx/api/* из opencode.db. Теперь он поднимается плагином
context-panel.v2.ts внутри процесса OpenCode, а не отдельным Node-процессом.

- server.mjs: экспортирует startPanel(); SQLite — bun:sqlite под Bun,
  node:sqlite под Node; ручной запуск `node server.mjs …` сохранён.
- context-panel.v2.ts: один прокси на процесс (globalThis), при dispose
  не закрывается; порты из CTX_PANEL_LISTEN / CTX_PANEL_TARGET,
  выключение — OPENCODE_CONTEXT_PANEL=0, журнал — log/context-panel.log.

Плагины грузятся лениво (при первом запросе к API проекта), поэтому
обёртка должна сама дёрнуть API после старта `opencode serve`.
2026-10-29 21:24:45 -07:00

773 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Панель «Токены» для веб-интерфейса 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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 = `<style>${STYLE}</style><div class="body"><div class="empty">Откройте сессию</div></div>`
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 = `<button type="button" aria-label="Контекст и токены" title="Контекст и токены"
data-component="icon-button-v2" data-size="large" data-variant="ghost-muted"
style="width:auto;padding:0 8px;display:inline-flex;align-items:center;gap:6px;font-size:12px;font-variant-numeric:tabular-nums">
<!-- Цвета кружка — в атрибутах: родные стили на него могут не подействовать -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" data-component="progress-circle-v2" style="transform:rotate(-90deg)">
<circle cx="7" cy="7" r="6.25" stroke-width="1.5" data-slot="progress-circle-v2-background" stroke="currentColor" stroke-opacity=".25"></circle>
<circle cx="7" cy="7" r="6.25" stroke-width="1.5" data-slot="progress-circle-v2-progress" stroke="currentColor" stroke-dasharray="39.27" stroke-dashoffset="39.27"></circle>
</svg><span class="ctx-v">…</span></button>`
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 = `<button role="tab" type="button" dir="auto" data-slot="tabs-trigger" data-value="${TAB}"
data-orientation="horizontal" aria-selected="false" tabindex="-1">Токены</button>`
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 `<table><tr><th></th>${cols.map((c) => `<th>${esc(c.label)}</th>`).join("")}</tr>
${used.map(([l, k]) => `<tr><td>${l}</td>${cols.map((c) => `<td>${fmt(c.t[k])}</td>`).join("")}</tr>`).join("")}
<tr class="sum"><td>Итого</td>${cols.map((c) => `<td>${fmt(c.t.total)}</td>`).join("")}</tr>
${cols.some((c) => c.t.cost) ? `<tr><td>Стоимость</td>${cols.map((c) => `<td>${money(c.t.cost)}</td>`).join("")}</tr>` : ""}
${cols.some((c) => c.t.steps) ? `<tr><td>Шагов модели</td>${cols.map((c) => `<td>${c.t.steps ? fmt(c.t.steps) : "—"}</td>`).join("")}</tr>` : ""}
</table>`
}
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 += `<line class="grid" x1="${L}" x2="${W}" y1="${yy}" y2="${yy}"/><text x="${L - 6}" y="${yy + 3}" text-anchor="end">${t.label}</text>`
}
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 += `<text x="${x}" y="${f.H - 3}" text-anchor="${anchor}">${label(i)}</text>`
}
return xs
}
// --- подсказки графиков: hover = { id графика, i — столбик/шаг } ------------------------
// Перерисовка раз в 3 с заменяет DOM — подсказка восстанавливается по hover в конце render()
const charts = {} // id → { tip(i) → html }
const hitRect = (id, i, x, f, w) =>
`<rect class="hit${hover.id === id && hover.i === i ? " on" : ""}" data-i="${i}" x="${x}" y="${f.T}" width="${w}" height="${f.plotH - f.T}"/>`
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) => `<span class="dot" style="background:${c}"></span>`
const legendHtml = (series, dark) => `<div class="legend">${series.map((s) => `<span>${dotHtml(color(s, dark))}${esc(s.label)}</span>`).join("")}</div>`
// --- график расхода: столбики со стопкой рядов ------------------------------------------
function barChart(id, ser, series) {
if (!ser?.buckets.some((b) => b.total)) {
delete charts[id]
return `<div class="empty">Нет расхода за период</div>`
}
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 += `<path data-i="${i}" fill="${color(s, dark)}" d="M${x},${y0 - (j ? 1 : 0)}V${y1 + r}q0,-${r} ${r},-${r}h${bw - 2 * r}q${r},0 ${r},${r}V${y0 - (j ? 1 : 0)}Z"/>`
})
})
charts[id] = {
tip: (i) => {
const b = buckets[i]
if (!b) return
return `<b>${bucketLabel(b.t, unit, true)}</b><table>
${[...series].reverse().filter((s) => s.v(b)).map((s) => `<tr><td>${dotHtml(color(s, dark))}${esc(s.label)}</td><td>${fmt(s.v(b))}</td></tr>`).join("")}
<tr><td>Итого</td><td><b>${fmt(b.total)}</b></td></tr>
${b.cost ? `<tr><td>Стоимость</td><td>${money(b.cost)}</td></tr>` : ""}
${b.steps ? `<tr><td>Шагов модели</td><td>${fmt(b.steps)}</td></tr>` : ""}</table>`
},
}
const what = unit === "hour" ? "часам" : unit === "week" ? "неделям" : "дням"
return `${legendHtml(series, dark)}<div class="chart" data-chart="${id}">
<svg viewBox="0 0 ${f.W} ${f.H}" height="${f.H}" role="img" aria-label="Расход токенов по ${what}">${f.grid}${bars}${xLabels(f, n, slot, (i) => bucketLabel(buckets[i].t, unit))}</svg>
<div class="tip" hidden></div></div>`
}
// Ряды для столбиков: по типу токенов или по моделям (топ-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 `<div class="empty">Мало шагов для графика</div>`
}
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 = `<path d="M${x(0)},${f.y(0)}L${pts.join("L")}L${x(n - 1)},${f.y(0)}Z" fill="${line}" fill-opacity=".12"/>`
body += `<path d="M${pts.join("L")}" fill="none" stroke="${line}" stroke-width="2" stroke-linejoin="round"/>`
if (limit) {
const ly = Math.round(f.y(limit)) + 0.5
body += `<line class="limit" x1="${f.L}" x2="${f.W}" y1="${ly}" y2="${ly}"/><text x="${f.W}" y="${ly - 3}" text-anchor="end">лимит ${fmt(limit)}</text>`
}
// compaction — вертикальная черта между шагами
for (const c of s.compactions ?? []) {
if (c.at <= 0 || c.at >= n) continue
const cx = f.L + slot * c.at
body += `<line class="cmark" x1="${cx}" x2="${cx}" y1="${f.T}" y2="${f.plotH}"/>`
}
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 `<b>Шаг ${i + 1}</b> · ${when(p.t)}<table>
<tr><td>${dotHtml(line)}Контекст</td><td><b>${fmt(p.total)}</b>${limit ? ` · ${pct(p.total, limit)}%` : ""}</td></tr>
<tr><td>Output</td><td>${fmt(p.output)}</td></tr>
${i ? `<tr><td>Прирост</td><td>${p.total >= steps[i - 1].total ? "+" : "−"}${fmt(Math.abs(p.total - steps[i - 1].total))}</td></tr>` : ""}
${p.model && p.model !== s.model.modelID ? `<tr><td>Модель</td><td>${esc(p.model)}</td></tr>` : ""}
${c ? `<tr><td>Перед шагом</td><td>compaction</td></tr>` : ""}</table>`
},
}
return `<div class="legend"><span>${dotHtml(line)}токенов в запросе</span>${limit ? `<span><span class="dash"></span>лимит модели</span>` : ""}${
s.compactions?.length ? `<span><span class="dash v"></span>compaction</span>` : ""
}</div>
<div class="chart" data-chart="${id}"><svg viewBox="0 0 ${f.W} ${f.H}" height="${f.H}" role="img" aria-label="Рост контекста по шагам">
${f.grid}${body}<line class="cross" x1="0" x2="0" y1="${f.T}" y2="${f.plotH}" style="display:none"/>${hits}${xLabels(f, n, slot, (i) => i + 1)}</svg>
<div class="tip" hidden></div></div>`
}
function compactionsHtml(list) {
if (!list?.length) return `<div class="note">Compaction в этой сессии не было.</div>`
return `<table class="cmp"><tr><th>compaction</th><th>до</th><th>после</th><th>сводка</th><th>заняло</th></tr>
${list
.map(
(c) => `<tr><td>${when(c.t)} <span class="muted">${c.auto ? "авто" : "вручную"}</span></td>
<td>${fmt(c.before)}</td><td>${c.done ? fmt(c.after) : "идёт…"}</td><td>${c.done ? fmt(c.summary) : "—"}</td><td>${sec(c.sec)}</td></tr>`,
)
.join("")}</table>
<div class="note">«До» — последний запрос перед сжатием, «после» — первый запрос после него, «сводка» — сколько токенов написала модель.</div>`
}
function callsHtml(calls, base) {
if (!calls?.length) return `<div class="note">В контексте нет вызовов тулов.</div>`
return `<table class="calls"><colgroup><col><col style="width:56px"><col style="width:44px"></colgroup>
${calls
.map(
(c) => `<tr><td><div class="ell" title="${esc(c.tool)}: ${esc(c.title)}"><span class="muted">${esc(c.tool)}</span> ${esc(c.title)}${c.error ? ` <span class="err">ошибка</span>` : ""}</div></td>
<td>${fmt(c.tokens)}</td><td class="m">${pct(c.tokens, base)}%</td></tr>`,
)
.join("")}</table>
<div class="note">Вход + вывод вызова, ≈ символы/4. Вывод, который OpenCode уже вычистил из контекста (prune), не считается.</div>`
}
function speedHtml(sp) {
if (!sp) return `<div class="note">Нет данных о времени шагов.</div>`
return `<div class="facts">
<div><div class="l">Генерация</div><div class="v">${rate(sp.decodeTps)} ток/с</div></div>
<div><div class="l">Первый токен, в среднем</div><div class="v">${sec(sp.firstTokenSec)}</div></div>
<div><div class="l">Prefill</div><div class="v">${rate(sp.prefillTps)} ток/с</div></div>
<div><div class="l">Модель на шаг, в среднем</div><div class="v">${sec(sp.stepSec)}</div></div>
</div>
<div class="note">По ${fmt(sp.steps)} шагам. Генерация — output + reasoning за время потока. Первый токен — от отправки запроса до начала
потока: очередь и prefill. Prefill — input без кэша за это же время (по нему видно, работает ли кэш префикса во vLLM).
Выполнение тулов не считается.</div>`
}
function render(data) {
last = data
const body = root.querySelector(".body")
if (!data?.s) {
setButton(0, "—")
body.innerHTML = data?.error ? `<div class="empty err">${esc(data.error)}</div>` : `<div class="empty">Откройте сессию</div>`
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 = `<div class="facts">
<div><div class="l">В контексте</div><div class="v">${fmt(ctx.total)}${limit ? ` из ${fmt(limit)} · ${pct(ctx.total, limit)}%` : ""}</div></div>
<div><div class="l">Модель</div><div class="v">${esc(s.model.modelID ?? "—")}</div></div>
<div><div class="l">Сообщений в контексте</div><div class="v">${ctx.messages}${ctx.compacted ? " (после compaction)" : ""}</div></div>
<div><div class="l">Шагов модели</div><div class="v">${fmt(s.spend.self.steps)}</div></div>
</div>`
const ctxHtml = ctx.total || ctx.estimated
? `<div class="bar">${cats.map((c) => `<i title="${esc(c.label)}: ${fmt(c.tokens)}" style="width:${(c.tokens / base) * 100}%;background:${c.color}"></i>`).join("")}</div>
<table>${cats
.map((c) =>
c.kind !== "tools"
? `<tr><td><span class="dot" style="background:${c.color}"></span>${esc(c.label)}</td><td>${fmt(c.tokens)}</td><td class="m">${pct(c.tokens, base)}%</td></tr>`
: `<tr class="group" data-toggle="tools"><td><span class="dot" style="background:${c.color}"></span>${esc(c.label)}<span class="chev">${toolsOpen ? "▾" : "▸"}</span></td><td>${fmt(c.tokens)}</td><td class="m">${pct(c.tokens, base)}%</td></tr>` +
(toolsOpen
? c.items.map((t) => `<tr class="sub"><td>${esc(t.label)}</td><td>${fmt(t.tokens)}</td><td class="m">${pct(t.tokens, base)}%</td></tr>`).join("")
: ""),
)
.join("")}</table>
<div class="note">≈ оценка по символам/4. «Остаток» — системный промпт, AGENTS.md, схемы тулов и MCP, плюс погрешность оценки.</div>`
: `<div class="empty">В сессии ещё нет ответов модели</div>`
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) => `<span class="seg">${items.map(([k, l]) => `<button data-${attr}="${k}" class="${cur === k ? "on" : ""}">${l}</button>`).join("")}</span>`
// Раздел со сворачиваемым заголовком; содержимое свёрнутого не строим. extra — справа в заголовке
const block = (key, title, content, extra = "") => {
const folded = collapsed.has(key)
return `<section class="${folded ? "folded" : ""}"><h3 data-collapse="${key}"><span class="chev">${folded ? "▸" : "▾"}</span>${title}${
extra ? `<span class="sp"></span>${extra}` : ""
}</h3>${folded ? "" : content()}</section>`
}
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",
"Расход",
() => `<div class="row">${seg("range", RANGES, range)}${seg("mode", [["type", "по типу"], ["models", "по моделям"]], chartMode)}</div>
${barChart("spend", gSeries, spendSeries(g))}
${spendRows(gCols)}
<div class="note">Сессий: ${scope === "all" ? g.allSessions : g.projectSessions} (включая сабагентов).
Input без кэша считается целиком на каждом шаге — у vLLM-моделей это основная статья.</div>`,
seg("scope", [["project", "проект"], ["all", "все проекты"]], scope === "all" ? "all" : "project"),
)}
${g.models.length ? block("models", "Модели (все проекты, всё время)", () => `<table>
<tr><th></th><th>input</th><th>output</th><th>ток/с</th><th>на шаг</th></tr>
${g.models.map((m) => `<tr><td>${esc(shortModel(m.model))}</td><td>${fmt(m.input + m.cacheRead + m.cacheWrite)}</td><td>${fmt(m.output + m.reasoning)}</td><td>${rate(m.speed?.decodeTps)}</td><td>${sec(m.speed?.stepSec)}</td></tr>`).join("")}
</table><div class="note">Input — вместе с кэшем. Ток/с — скорость генерации, «на шаг» — среднее время модели на шаг, как в разделе выше.</div>`) : ""}`
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)
})()