Панель «Токены» для веб-интерфейса 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`.
This commit is contained in:
@@ -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.
|
||||||
|
* Подключение: файл в <config>/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<unknown> }
|
||||||
|
|
||||||
|
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]
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 = `<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)
|
||||||
|
})()
|
||||||
+559
@@ -0,0 +1,559 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Прокси перед `opencode serve` (OpenCode 2): всё пропускает как есть (HTTP, SSE, WebSocket), а
|
||||||
|
// в HTML-страницы вставляет <script src="/__ctx/panel.js"> — выдвижную панель с составом
|
||||||
|
// контекста и расходом токенов. Данные панель берёт из /__ctx/api/*, которые читают opencode.db
|
||||||
|
// (только чтение).
|
||||||
|
//
|
||||||
|
// Обычно запускается внутри OpenCode: плагин context-panel.v2.ts вызывает startPanel(). Вручную:
|
||||||
|
//
|
||||||
|
// node server.mjs --listen 4196 --target 4197 [--db $XDG_DATA_HOME/opencode/opencode.db]
|
||||||
|
//
|
||||||
|
// Веб-интерфейс OpenCode точек расширения для плагинов не имеет (слоты есть только в TUI),
|
||||||
|
// поэтому так. CSP страницы (script-src 'self') пропускает скрипт: он с того же origin.
|
||||||
|
//
|
||||||
|
// OpenCode закрыт паролем (Basic auth): /__ctx/* пускаем с тем же заголовком Authorization, что
|
||||||
|
// принимает сам OpenCode, и с ним же ходим в его API (лимит модели, контекст сессии).
|
||||||
|
|
||||||
|
import http from "node:http"
|
||||||
|
import net from "node:net"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
// SQLite: внутри OpenCode — bun:sqlite, под Node — node:sqlite. API prepare().get/all у них общий
|
||||||
|
const openDatabase = globalThis.Bun
|
||||||
|
? await import("bun:sqlite").then(({ Database }) => (p) => new Database(p, { readonly: true }))
|
||||||
|
: await import("node:sqlite").then(({ DatabaseSync }) => (p) => new DatabaseSync(p, { readOnly: true }))
|
||||||
|
|
||||||
|
function arg(name, fallback) {
|
||||||
|
const i = process.argv.indexOf(`--${name}`)
|
||||||
|
return i > 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOST = "127.0.0.1"
|
||||||
|
const DATA_HOME = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share")
|
||||||
|
const DEFAULTS = {
|
||||||
|
listen: Number(process.env.CTX_PANEL_LISTEN ?? 4196),
|
||||||
|
target: Number(process.env.CTX_PANEL_TARGET ?? 4197),
|
||||||
|
db: path.join(DATA_HOME, "opencode/opencode.db"),
|
||||||
|
}
|
||||||
|
// Задаются в startPanel()
|
||||||
|
let LISTEN = DEFAULTS.listen
|
||||||
|
let TARGET = DEFAULTS.target
|
||||||
|
let DB_PATH = DEFAULTS.db
|
||||||
|
|
||||||
|
const PANEL_TAG = '<script src="/__ctx/panel.js" defer></script>'
|
||||||
|
|
||||||
|
// Веб-интерфейс ставит 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("</head>") ? html.replace("</head>", `${PANEL_TAG}</head>`) : 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user