Панель «Токены» для веб-интерфейса 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:
+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