commit 1af3ff9d1052762150e5d10e536e8e844ccfc9ad Author: raptorggi Date: Thu Oct 29 21:24:45 2026 -0700 Панель «Токены» для веб-интерфейса 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`. diff --git a/context-panel.v2.ts b/context-panel.v2.ts new file mode 100644 index 0000000..11335bf --- /dev/null +++ b/context-panel.v2.ts @@ -0,0 +1,62 @@ +/** + * context-panel для OpenCode 2: панель «Токены» в веб-интерфейсе — без отдельного процесса. + * + * Плагин поднимает внутри процесса OpenCode тот же прокси, что server.mjs (startPanel): он + * слушает CTX_PANEL_LISTEN (4196), пропускает всё на CTX_PANEL_TARGET (4197, порт самого + * `opencode serve`) и вставляет в страницы panel.js. Встроить панель прямо в порт OpenCode + * нельзя: веб-интерфейс зашит в бинарник, а свои HTTP-маршруты плагину добавить нечем. + * + * Что учтено (проверено на 2.1.3): + * - плагин грузится лениво — при первом запросе к API проекта, а не при старте `serve`. + * Поэтому обёртка после старта сама дёргает API (bin/opencode-web), иначе на 4196 пусто; + * - модуль импортируется заново для каждого проекта — прокси один на процесс (globalThis); + * - при dispose прокси не закрывается: браузер ходит через него, и без него OpenCode не + * получил бы запрос, который загрузит плагин снова. Живёт, пока жив процесс. + * + * Выключить: OPENCODE_CONTEXT_PANEL=0. База — $XDG_DATA_HOME/opencode/opencode.db или CTX_PANEL_DB. + * Подключение: файл в /opencode/plugins/ — bin/opencode-web делает ссылку сам. + */ + +import { appendFile, mkdir } from "node:fs/promises" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { realpathSync } from "node:fs" +import { fileURLToPath, pathToFileURL } from "node:url" + +const LOG_DIR = join(process.env.XDG_DATA_HOME || join(homedir(), ".local/share"), "opencode/log") +const LOG_FILE = join(LOG_DIR, "context-panel.log") + +async function log(line: string) { + try { + await mkdir(LOG_DIR, { recursive: true }) + await appendFile(LOG_FILE, `${new Date().toISOString()} ${line}\n`) + } catch {} +} + +const KEY = Symbol.for("opencode-plugins.context-panel") +const state = globalThis as { [KEY]?: Promise } + +function start() { + // Файл подключён ссылкой из plugins/ — server.mjs ищем рядом с настоящим файлом + const here = dirname(realpathSync(fileURLToPath(import.meta.url))) + const opts = { + listen: process.env.CTX_PANEL_LISTEN ?? 4196, + target: process.env.CTX_PANEL_TARGET ?? 4197, + ...(process.env.CTX_PANEL_DB ? { db: process.env.CTX_PANEL_DB } : {}), + } + return import(pathToFileURL(join(here, "server.mjs")).href) + .then((m) => m.startPanel(opts)) + .then( + () => log(`listening ${opts.listen} → ${opts.target}`), + (e) => log(`не запустилась: ${e?.code ?? e?.message ?? e}`), + ) +} + +export default { + id: "context-panel", + setup: async () => { + if (process.env.OPENCODE_CONTEXT_PANEL === "0") return + state[KEY] ??= start() + await state[KEY] + }, +} diff --git a/panel.js b/panel.js new file mode 100644 index 0000000..6d85878 --- /dev/null +++ b/panel.js @@ -0,0 +1,772 @@ +// Панель «Токены» для веб-интерфейса 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) +})() diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..d7fe16d --- /dev/null +++ b/server.mjs @@ -0,0 +1,559 @@ +#!/usr/bin/env node +// Прокси перед `opencode serve` (OpenCode 2): всё пропускает как есть (HTTP, SSE, WebSocket), а +// в HTML-страницы вставляет ' + +// Веб-интерфейс ставит service worker (Workbox), который кэширует index.html и потом отдаёт +// страницу из кэша мимо прокси — без панели. Вместо /sw.js отдаём «гасящий»: чистит кэши, +// снимает регистрацию и один раз перезагружает вкладки (только если было что чистить — +// иначе страница, которая регистрирует /sw.js при каждой загрузке, перезагружалась бы по кругу) +const KILL_SW = `self.addEventListener("install", () => self.skipWaiting()) +self.addEventListener("activate", (e) => e.waitUntil((async () => { + const keys = await caches.keys() + await Promise.all(keys.map((k) => caches.delete(k))) + await self.registration.unregister() + if (keys.length) for (const c of await self.clients.matchAll({ type: "window" })) c.navigate(c.url) +})())) +` + +// --- база -------------------------------------------------------------------------------- + +let db +function getDb() { + if (!db) db = openDatabase(DB_PATH) + return db +} + +const parse = (s) => { + try { + return JSON.parse(s) + } catch { + return undefined + } +} + +// Та же оценка, что у самого OpenCode (Token.estimate): символы / 4 +const estimate = (s) => (s ? Math.round(String(s).length / 4) : 0) + +function zeroTokens() { + return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0, steps: 0 } +} + +function addTokens(acc, t, cost) { + if (!t) return acc + acc.input += t.input || 0 + acc.output += t.output || 0 + acc.reasoning += t.reasoning || 0 + acc.cacheRead += t.cache?.read || 0 + acc.cacheWrite += t.cache?.write || 0 + acc.total += (t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0) + acc.cost += cost || 0 + acc.steps += 1 + return acc +} + +function sessionRowTokens(r) { + const t = zeroTokens() + t.input = r.tokens_input + t.output = r.tokens_output + t.reasoning = r.tokens_reasoning + t.cacheRead = r.tokens_cache_read + t.cacheWrite = r.tokens_cache_write + t.total = t.input + t.output + t.reasoning + t.cacheRead + t.cacheWrite + t.cost = r.cost + return t +} + +function toolLabel(name) { + return name?.includes("_") && !name.startsWith("todo") ? name.replace(/_/, ": ") : name +} + +// Токенов в запросе к модели — так же, как считает OpenCode +const reqTotal = (t) => (t ? (t.input || 0) + (t.output || 0) + (t.reasoning || 0) + (t.cache?.read || 0) + (t.cache?.write || 0) : 0) + +// --- скорость модели --------------------------------------------------------------------- +// Один шаг = одно сообщение ассистента: start — начало потока (до него — ожидание и prefill), +// end — конец потока (выполнение тулов в генерацию не входит). + +function zeroSpeed() { + return { steps: 0, out: 0, decodeMs: 0, prompt: 0, waitMs: 0, modelMs: 0 } +} +// x — { start, end } шага, info — данные сообщения ассистента +function addSpeed(acc, x, info) { + const created = info.time?.created + if (!x?.start || !x.end || !created || !info.time?.completed || info.summary) return acc + const decode = x.end - x.start + const wait = x.start - created + if (decode <= 0 || wait < 0) return acc + acc.steps += 1 + acc.out += (info.tokens?.output || 0) + (info.tokens?.reasoning || 0) + acc.decodeMs += decode + acc.prompt += (info.tokens?.input || 0) + (info.tokens?.cache?.write || 0) + acc.waitMs += wait + acc.modelMs += x.end - created + return acc +} +function speedOut(a) { + if (!a.steps) return undefined + return { + steps: a.steps, + decodeTps: a.out / (a.decodeMs / 1000), // генерация, ток/с + prefillTps: a.waitMs ? a.prompt / (a.waitMs / 1000) : undefined, // prompt без кэша / ожидание первого токена + firstTokenSec: a.waitMs / a.steps / 1000, // среднее ожидание первого токена + stepSec: a.modelMs / a.steps / 1000, // среднее время модели на шаг (без выполнения тулов) + } +} + +// --- сессия ------------------------------------------------------------------------------- +// Схема: session_v2 (суммы токенов по сессии) и session_message (type + data JSON). Один шаг +// модели = одно сообщение `assistant`: content[] — reasoning / text / tool (вход и вывод тула +// внутри), tokens, time.created / streamed / completed. Compaction — сообщение `compaction` +// (reason auto/manual, status, summary, tokens самого запроса на сводку). + +// API v2 — с паролем браузера (тем же заголовком Authorization) +async function apiV2(pathname, auth) { + const res = await fetch(`http://${HOST}:${TARGET}${pathname}`, { headers: auth ? { authorization: auth } : {} }) + if (!res.ok) throw new Error(`${pathname}: ${res.status}`) + return (await res.json()).data +} + +// Пускаем в /__ctx/* только с паролем, который принимает сам OpenCode. Удачные — кэшируем +const authOk = new Map() // заголовок → до какого времени верим +async function authorizedV2(auth) { + if (!auth) return false + if ((authOk.get(auth) ?? 0) > Date.now()) return true + const res = await fetch(`http://${HOST}:${TARGET}/api/session/active`, { headers: { authorization: auth } }).catch(() => undefined) + if (res?.ok) authOk.set(auth, Date.now() + 60_000) + return !!res?.ok +} + +const limitCacheV2 = new Map() +async function contextLimitV2(directory, providerID, modelID, auth) { + let entry = limitCacheV2.get(directory) + if (!entry || Date.now() - entry.at > 60_000) { + const models = await apiV2(`/api/model?location[directory]=${encodeURIComponent(directory)}`, auth).catch(() => undefined) + entry = { at: Date.now(), models } + limitCacheV2.set(directory, entry) + } + const m = entry.models?.find((x) => x.providerID === providerID && (x.modelID ?? x.id) === modelID) + return m?.limit?.context || undefined +} + +// Начало потока — первая часть ответа, конец — time.streamed (иначе последняя часть / запуск тула) +function stepTimingV2(info) { + let start = Infinity + let end = info.time?.streamed ?? 0 + for (const c of info.content ?? []) { + if (c.time?.created) start = Math.min(start, c.time.created) + if (!info.time?.streamed) end = Math.max(end, c.type === "tool" ? (c.time?.created ?? 0) : (c.time?.completed ?? 0)) + } + return start < Infinity && end ? { start, end } : undefined +} + +const contentText = (content) => + Array.isArray(content) ? content.map((c) => (c.type === "text" ? c.text : JSON.stringify(c).slice(0, 4000))).join("\n") : String(content ?? "") + +// Что вызывали: для Code Mode (execute) — какие MCP-серверы в скрипте +function callTitleV2(c) { + const i = c.state?.input ?? {} + if (typeof i === "string") return i.slice(0, 200) + if (c.name === "execute" && typeof i.code === "string") { + const used = [...new Set([...i.code.matchAll(/tools\.([\w-]+)\.([\w-]+)/g)].map((m) => `${m[1]}.${m[2]}`))] + return used.length ? used.join(", ") : i.code.split("\n").find((l) => l.trim())?.trim() ?? "" + } + return String(i.description ?? i.command ?? i.path ?? i.filePath ?? i.pattern ?? i.url ?? i.query ?? i.id ?? "").slice(0, 200) +} + +async function sessionStatsV2(id, auth) { + const d = getDb() + const s = d.prepare("select * from session_v2 where id = ?").get(id) + if (!s) return { error: "session not found" } + + const kids = d + .prepare( + `with recursive k(id) as (select id from session_v2 where parent_id = ? + union all select s.id from session_v2 s join k on s.parent_id = k.id) + select coalesce(sum(tokens_input),0) tokens_input, coalesce(sum(tokens_output),0) tokens_output, + coalesce(sum(tokens_reasoning),0) tokens_reasoning, coalesce(sum(tokens_cache_read),0) tokens_cache_read, + coalesce(sum(tokens_cache_write),0) tokens_cache_write, coalesce(sum(cost),0) cost, count(*) n + from session_v2 where id in (select id from k)`, + ) + .get(id) + + const msgs = d + .prepare("select id, type, time_created t, data from session_message where session_id = ? order by seq") + .all(id) + .map((r) => ({ id: r.id, type: r.type, t: r.t, info: parse(r.data) ?? {} })) + + const self = zeroTokens() + const speed = zeroSpeed() + const steps = [] + const compactions = [] + msgs.forEach((m, i) => { + if (m.type === "assistant") { + addTokens(self, m.info.tokens, m.info.cost) + addSpeed(speed, stepTimingV2(m.info), m.info) + const t = m.info.tokens + if (reqTotal(t)) steps.push({ t: m.info.time?.created, total: reqTotal(t), output: (t.output || 0) + (t.reasoning || 0), model: m.info.model?.id }) + } else if (m.type === "compaction") { + // Запрос на сводку — тоже расход + addTokens(self, m.info.tokens, m.info.cost) + const next = msgs.slice(i + 1).find((x) => x.type !== "compaction") + const after = msgs.slice(i + 1).find((x) => x.type === "assistant" && reqTotal(x.info.tokens)) + const done = m.info.status === "completed" + compactions.push({ + at: steps.length, + t: m.info.time?.created ?? m.t, + auto: m.info.reason === "auto", + done, + failed: m.info.status === "failed", + before: steps.at(-1)?.total ?? 0, + after: after ? reqTotal(after.info.tokens) : undefined, + summary: done ? (m.info.tokens?.output || 0) + (m.info.tokens?.reasoning || 0) || estimate(m.info.summary) : undefined, + sec: done && next ? (next.t - (m.info.time?.created ?? m.t)) / 1000 : undefined, + }) + } + }) + + // Что сейчас в контексте — спрашиваем сам OpenCode; без него — всё после последней compaction + let ctx = await apiV2(`/api/session/${id}/context`, auth).catch(() => undefined) + if (!ctx) { + const cut = msgs.map((m) => m.type === "compaction" && m.info.status === "completed").lastIndexOf(true) + ctx = msgs.slice(Math.max(0, cut)).map((m) => ({ ...m.info, id: m.id, type: m.type })) + } + const compacted = ctx.some((m) => m.type === "compaction") + + const cats = new Map() + const calls = [] + const add = (key, label, kind, n) => { + if (!n) return + const c = cats.get(key) ?? { key, label, kind, tokens: 0 } + c.tokens += n + cats.set(key, c) + } + for (const m of ctx) { + if (m.type === "user") add("user", "Сообщения пользователя", "user", estimate(m.text) + estimate(JSON.stringify(m.files ?? []).slice(0, 4000))) + else if (m.type === "compaction") add("summary", "Сводка compaction", "summary", estimate(m.summary)) + else if (m.type === "skill") add("skill", "Скилы", "skill", estimate(m.text)) + else if (m.type === "shell") add("tool:shell!", "shell (пользователь)", "tool", estimate(m.command) + estimate(contentText(m.output))) + else if (m.type === "synthetic" || m.type === "system") add("system-msg", "Служебные сообщения", "system", estimate(m.text)) + else if (m.type === "assistant") { + for (const c of m.content ?? []) { + if (c.type === "text") add("assistant", "Ответы ассистента", "assistant", estimate(c.text)) + else if (c.type === "reasoning") add("reasoning", "Reasoning", "reasoning", estimate(c.text)) + else if (c.type === "tool") { + const st = c.state ?? {} + const n = estimate(typeof st.input === "string" ? st.input : JSON.stringify(st.input ?? {})) + estimate(contentText(st.content)) + add(`tool:${c.name}`, toolLabel(c.name), "tool", n) + calls.push({ tool: toolLabel(c.name), title: callTitleV2(c), tokens: n, error: st.status === "error", t: c.time?.created }) + } + } + } + } + + const last = [...ctx].reverse().find((m) => m.type === "assistant" && (m.tokens?.input || 0) + (m.tokens?.cache?.read || 0) > 0) + const total = reqTotal(last?.tokens) + const estimated = [...cats.values()].reduce((a, c) => a + c.tokens, 0) + if (total > estimated) cats.set("system", { key: "system", label: "Система + схемы тулов (остаток)", kind: "system", tokens: total - estimated }) + + const sm = parse(s.model) ?? {} + const lastAny = [...msgs].reverse().find((m) => m.type === "assistant") + const providerID = last?.model?.providerID ?? lastAny?.info.model?.providerID ?? sm.providerID + const modelID = last?.model?.id ?? lastAny?.info.model?.id ?? sm.id ?? sm.model + const limit = providerID && modelID ? await contextLimitV2(s.directory, providerID, modelID, auth) : undefined + + const withKids = sessionRowTokens(s) + const kt = sessionRowTokens(kids) + for (const k of ["input", "output", "reasoning", "cacheRead", "cacheWrite", "total", "cost"]) withKids[k] += kt[k] + + return { + session: { id: s.id, title: s.title, directory: s.directory, projectID: s.project_id, parentID: s.parent_id }, + model: { providerID, modelID, limit }, + context: { + total, + estimated, + compacted, + messages: ctx.filter((m) => !["idle", "agent-switched", "model-switched", "location-switched"].includes(m.type)).length, + categories: [...cats.values()].sort((a, b) => b.tokens - a.tokens), + topCalls: calls.sort((a, b) => b.tokens - a.tokens).slice(0, 10), + }, + steps, + compactions, + speed: speedOut(speed), + spend: { self, withSubagents: withKids, subagents: kids.n }, + } +} + +// Для общего расхода — без разбора content целиком: всё нужное достаёт SQLite +function globalRowsV2() { + return getDb() + .prepare( + `select m.id id, s.project_id pid, m.time_created t, m.type type, + json_extract(m.data, '$.tokens') tokens, json_extract(m.data, '$.cost') cost, + json_extract(m.data, '$.time.created') created, json_extract(m.data, '$.time.completed') completed, + json_extract(m.data, '$.time.streamed') streamed, + json_extract(m.data, '$.model.providerID') provider, json_extract(m.data, '$.model.id') model, + (select min(json_extract(value, '$.time.created')) from json_each(m.data, '$.content')) first + from session_message m join session_v2 s on s.id = m.session_id + where m.type in ('assistant', 'compaction')`, + ) + .all() + .map((r) => ({ + id: r.id, + pid: r.pid, + t: r.t, + tokens: parse(r.tokens), + cost: r.cost, + time: { created: r.created, completed: r.completed }, + summary: r.type === "compaction", + model: r.model && `${r.provider}/${r.model}`, + x: r.first && r.streamed ? { start: r.first, end: r.streamed } : {}, + })) +} + +let globalCache +function globalStats(projectID) { + if (!globalCache || Date.now() - globalCache.at > 15_000) { + const sessions = getDb().prepare("select project_id pid, count(*) n from session_v2 group by project_id").all() + globalCache = { at: Date.now(), rows: globalRowsV2(), sessions } + } + const { rows, sessions } = globalCache + + const midnight = new Date() + midnight.setHours(0, 0, 0, 0) + const daysAgo = (n) => new Date(midnight.getFullYear(), midnight.getMonth(), midnight.getDate() - n).getTime() + const windows = { today: midnight.getTime(), week: daysAgo(6), month: daysAgo(29), all: 0 } + + const scope = (filter) => { + const out = {} + for (const [k, since] of Object.entries(windows)) { + out[k] = rows.filter((r) => r.t >= since && filter(r)).reduce((a, r) => addTokens(a, r.tokens, r.cost), zeroTokens()) + } + return out + } + + // Ряды для графика: сегодня — по часам, 7/30 дней — по дням, всё время — по дням или + // по неделям, если дней больше 90. Границы — по местному времени (Date сам учтёт DST) + const first = rows.reduce((a, r) => Math.min(a, r.t), Date.now()) + const firstDay = new Date(first) + firstDay.setHours(0, 0, 0, 0) + const spanDays = Math.round((midnight - firstDay) / 86_400_000) + 1 + const step = spanDays > 90 ? 7 : 1 + const starts = { + today: Array.from({ length: 24 }, (_, h) => new Date(midnight.getFullYear(), midnight.getMonth(), midnight.getDate(), h).getTime()), + week: Array.from({ length: 7 }, (_, i) => daysAgo(6 - i)), + month: Array.from({ length: 30 }, (_, i) => daysAgo(29 - i)), + all: Array.from({ length: Math.ceil(spanDays / step) }, (_, i) => daysAgo(spanDays - 1 - i * step)), + } + const series = (filter) => { + const out = {} + for (const [k, st] of Object.entries(starts)) { + const buckets = st.map((t) => ({ t, ...zeroTokens(), models: {} })) + for (const r of rows) { + if (r.t < st[0] || !filter(r)) continue + let lo = 0 + let hi = st.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (st[mid] <= r.t) lo = mid + else hi = mid - 1 + } + addTokens(buckets[lo], r.tokens, r.cost) + const key = r.model ?? "?" + buckets[lo].models[key] = (buckets[lo].models[key] ?? 0) + reqTotal(r.tokens) + } + out[k] = { unit: k === "today" ? "hour" : k === "all" && step === 7 ? "week" : "day", buckets } + } + return out + } + const models = new Map() + const speeds = new Map() + for (const r of rows) { + if (!r.model) continue + models.set(r.model, addTokens(models.get(r.model) ?? zeroTokens(), r.tokens, r.cost)) + speeds.set(r.model, addSpeed(speeds.get(r.model) ?? zeroSpeed(), r.x, r)) + } + return { + project: projectID ? scope((r) => r.pid === projectID) : undefined, + projectSessions: sessions.find((x) => x.pid === projectID)?.n ?? 0, + all: scope(() => true), + series: { + project: projectID ? series((r) => r.pid === projectID) : undefined, + all: series(() => true), + }, + allSessions: sessions.reduce((a, x) => a + x.n, 0), + models: [...models.entries()] + .map(([model, t]) => ({ model, ...t, speed: speedOut(speeds.get(model)) })) + .sort((a, b) => b.total - a.total) + .slice(0, 6), + } +} + +// --- HTTP -------------------------------------------------------------------------------- + +function sendJson(res, status, body) { + res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }) + res.end(JSON.stringify(body)) +} + +async function handleOwn(req, res) { + const url = new URL(req.url, `http://${HOST}`) + if (url.pathname === "/__ctx/panel.js") { + res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }) + return res.end(fs.readFileSync(path.join(HERE, "panel.js"))) + } + try { + const auth = req.headers.authorization + if (!(await authorizedV2(auth))) { + res.writeHead(401, { "www-authenticate": 'Basic realm="Secure Area"', "content-type": "text/plain; charset=utf-8" }) + return res.end("Unauthorized\n") + } + let m + if ((m = url.pathname.match(/^\/__ctx\/api\/session\/([\w-]+)$/))) + return sendJson(res, 200, await sessionStatsV2(m[1], auth)) + if (url.pathname === "/__ctx/api/global") return sendJson(res, 200, globalStats(url.searchParams.get("project"))) + } catch (e) { + return sendJson(res, 500, { error: String(e?.message ?? e) }) + } + sendJson(res, 404, { error: "not found" }) +} + +function proxy(req, res) { + if (req.method === "GET" && new URL(req.url, `http://${HOST}`).pathname === "/sw.js") { + res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }) + return res.end(KILL_SW) + } + const headers = { ...req.headers, host: `${HOST}:${TARGET}` } + // HTML правим на лету — просим ответы без сжатия (loopback, трафик не важен). Не только при + // Accept: text/html: страницу может запросить и fetch() (service worker, предзагрузка) + if (req.method === "GET") delete headers["accept-encoding"] + + const up = http.request({ host: HOST, port: TARGET, method: req.method, path: req.url, headers }, (ur) => { + const type = ur.headers["content-type"] ?? "" + if (!type.includes("text/html") || ur.headers["content-encoding"]) { + res.writeHead(ur.statusCode, ur.headers) + res.flushHeaders() + return ur.pipe(res) + } + const chunks = [] + ur.on("data", (c) => chunks.push(c)) + ur.on("end", () => { + let html = Buffer.concat(chunks).toString("utf8") + html = html.includes("") ? html.replace("", `${PANEL_TAG}`) : html + PANEL_TAG + const out = Buffer.from(html, "utf8") + const h = { ...ur.headers, "content-length": out.length } + delete h["transfer-encoding"] + res.writeHead(ur.statusCode, h) + res.end(out) + }) + }) + up.on("error", (e) => { + if (res.headersSent) return res.destroy() + res.writeHead(502, { "content-type": "text/plain; charset=utf-8" }) + res.end(`OpenCode на ${HOST}:${TARGET} недоступен: ${e.message}\n`) + }) + // Клиент ушёл (закрыл вкладку, оборвал SSE) — рвём и запрос к OpenCode + res.on("close", () => { + if (!res.writableFinished) up.destroy() + }) + req.pipe(up) +} + +// WebSocket (терминал и пр.): после рукопожатия — просто труба TCP ↔ TCP +function upgrade(req, socket, head) { + const up = net.connect(TARGET, HOST, () => { + const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`] + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const [k, v] = [req.rawHeaders[i], req.rawHeaders[i + 1]] + lines.push(`${k}: ${k.toLowerCase() === "host" ? `${HOST}:${TARGET}` : v}`) + } + up.write(lines.join("\r\n") + "\r\n\r\n") + if (head?.length) up.write(head) + socket.pipe(up).pipe(socket) + }) + const close = () => { + up.destroy() + socket.destroy() + } + up.on("error", close) + socket.on("error", close) +} + +// Поднимает прокси; промис — когда слушает порт (или с ошибкой, например EADDRINUSE) +export function startPanel({ listen = DEFAULTS.listen, target = DEFAULTS.target, db: dbPath = DEFAULTS.db } = {}) { + LISTEN = Number(listen) + TARGET = Number(target) + DB_PATH = dbPath + const server = http.createServer((req, res) => { + if (req.url.startsWith("/__ctx/")) return void handleOwn(req, res) + proxy(req, res) + }) + server.on("upgrade", upgrade) + return new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(LISTEN, HOST, () => { + server.off("error", reject) + console.log(`context-panel: http://${HOST}:${LISTEN}/ → OpenCode ${HOST}:${TARGET}, БД ${DB_PATH}`) + resolve(server) + }) + }) +} + +// Запуск из командной строки: node server.mjs … +const direct = (() => { + try { + return fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) + } catch { + return false + } +})() +if (direct) { + startPanel({ listen: arg("listen", DEFAULTS.listen), target: arg("target", DEFAULTS.target), db: arg("db", DEFAULTS.db) }).catch((e) => { + console.error(`context-panel: ${e.message}`) + process.exit(1) + }) +}