mirror of
https://git.sync.wtf/troed/oc-ls-stats.git
synced 2026-09-26 11:45:42 +03:00
tui: add V2 setup() implementation, dual V1/V2 export; bump 1.4.0
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@troed/oc-ls-stats",
|
||||
"version": "1.3.1",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"tui.tsx",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextRenderable } from "@opentui/core"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { onCleanup, createSignal } from "solid-js"
|
||||
import { backoffDelayMs, processPoll, resolveServerUrls, selectSessionUrls } from "./src/stats.ts"
|
||||
|
||||
type StreamSample = {
|
||||
@@ -333,9 +333,274 @@ const tui: TuiPlugin = async (api, options) => {
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2 (opencode >= 2.0) implementation
|
||||
//
|
||||
// V2 TUI plugin system replaces the V1 tui(api) function with
|
||||
// setup(context): the readout registers through context.ui.slot on
|
||||
// prompt.footer.status (the V2 successor of session_prompt_right), events
|
||||
// arrive through context.data.on, and provider/session state comes from
|
||||
// context.data.location / context.data.session. The V1 tui() above is kept
|
||||
// untouched; both implementations are exposed from the same default export
|
||||
// (V1 calls tui, V2 calls setup) and share the tracker helpers from
|
||||
// src/stats.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const v2Setup = async (context: any) => {
|
||||
const location = context.location ?? context.data.location.default()
|
||||
const themeMuted = context.theme?.text?.muted
|
||||
|
||||
const tracker: TrackerState = {
|
||||
streamSamplesBySession: {},
|
||||
messageTimingByID: {},
|
||||
lastPrefillRate: 0,
|
||||
lastGeneratedTps: 0,
|
||||
isPrefilling: false,
|
||||
prefillSlotId: null,
|
||||
prefillCapturedTokens: null,
|
||||
prefillStartAt: 0,
|
||||
isGenerating: false,
|
||||
generateSlotId: null,
|
||||
generatePrevNd: 0,
|
||||
generateStartAt: 0,
|
||||
prevNdBySlot: {},
|
||||
prevPromptTokensBySlot: {},
|
||||
failure: null,
|
||||
}
|
||||
|
||||
// part id -> part type, learned from message.part.updated; used to skip
|
||||
// non-text/reasoning deltas (e.g. tool output) like the V1 part lookup
|
||||
const partTypeByID = new Map<string, string>()
|
||||
|
||||
function getStatusText() {
|
||||
const s = tracker
|
||||
if (s.failure) {
|
||||
return s.failure
|
||||
}
|
||||
const pp = s.isPrefilling && s.lastPrefillRate > 0 ? formatPps(s.lastPrefillRate) : undefined
|
||||
const tps = s.isGenerating && s.lastGeneratedTps > 0 ? formatTps(s.lastGeneratedTps) : undefined
|
||||
let result = "- tps (TG)"
|
||||
if (s.isPrefilling && pp) {
|
||||
result = `${pp} tps (PP)`
|
||||
} else if (s.isGenerating && tps) {
|
||||
result = `${tps} tps (TG)`
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const [statusText, setStatusText] = createSignal<string>("- tps (TG)")
|
||||
|
||||
const bump = () => {
|
||||
setStatusText(getStatusText())
|
||||
}
|
||||
|
||||
const pruneSamples = (now = Date.now()) => {
|
||||
let changed = false
|
||||
for (const [sessionID, samples] of Object.entries(tracker.streamSamplesBySession)) {
|
||||
const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
|
||||
if (next.length !== samples.length) {
|
||||
changed = true
|
||||
if (next.length > 0) tracker.streamSamplesBySession[sessionID] = next
|
||||
else delete tracker.streamSamplesBySession[sessionID]
|
||||
}
|
||||
}
|
||||
if (changed) bump()
|
||||
}
|
||||
|
||||
const clearLiveSamples = (sessionID: string) => {
|
||||
if (!tracker.streamSamplesBySession[sessionID]?.length) return
|
||||
delete tracker.streamSamplesBySession[sessionID]
|
||||
bump()
|
||||
}
|
||||
|
||||
const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
|
||||
const now = sample.at
|
||||
tracker.streamSamplesBySession[sessionID] = [
|
||||
...(tracker.streamSamplesBySession[sessionID] ?? []).filter((item) => now - item.at <= STREAM_WINDOW_MS),
|
||||
sample,
|
||||
]
|
||||
const timing = tracker.messageTimingByID[messageID]
|
||||
if (timing) {
|
||||
tracker.messageTimingByID[messageID] = timing.firstTokenAt
|
||||
? { ...timing, lastTokenAt: now }
|
||||
: {
|
||||
...timing,
|
||||
firstResponseAt: timing.firstResponseAt ?? now,
|
||||
firstTokenAt: now,
|
||||
lastTokenAt: now,
|
||||
}
|
||||
}
|
||||
bump()
|
||||
}
|
||||
|
||||
let llamaServerUrls: string[] = []
|
||||
let providerList: unknown[] = []
|
||||
const backoffByUrl = new Map<string, { failures: number; nextRetryAt: number }>()
|
||||
|
||||
const currentSessionID = (): string | undefined => {
|
||||
const route = context.ui.router.current?.()
|
||||
if (route?.sessionID) return route.sessionID
|
||||
const loc = context.location ?? context.data.location.default()
|
||||
return loc?.sessionID
|
||||
}
|
||||
|
||||
const pollMetrics = async () => {
|
||||
if (!llamaServerUrls.length) return
|
||||
|
||||
let model: string | undefined
|
||||
let sessionProviderId: string | undefined
|
||||
const routeSessionID = currentSessionID()
|
||||
if (routeSessionID) {
|
||||
const session = context.data.session.get(routeSessionID)
|
||||
if (session?.model?.id) {
|
||||
model = session.model.id
|
||||
}
|
||||
sessionProviderId = session?.model?.providerID
|
||||
}
|
||||
|
||||
const activeUrls = selectSessionUrls(llamaServerUrls, providerList, sessionProviderId)
|
||||
if (!activeUrls.length) {
|
||||
tracker.failure = "n/a"
|
||||
bump()
|
||||
return
|
||||
}
|
||||
for (const baseUrl of activeUrls) {
|
||||
const now = Date.now()
|
||||
const backoff = backoffByUrl.get(baseUrl)
|
||||
if (backoff && now < backoff.nextRetryAt) {
|
||||
tracker.failure = "n/a"
|
||||
bump()
|
||||
continue
|
||||
}
|
||||
|
||||
const slots = await fetchSlots(baseUrl, model)
|
||||
if (slots == null) {
|
||||
const failures = (backoff?.failures ?? 0) + 1
|
||||
const delay = backoffDelayMs(failures, BACKOFF_BASE_MS, BACKOFF_MAX_MS)
|
||||
const jitter = Math.floor(Math.random() * (delay / 4))
|
||||
backoffByUrl.set(baseUrl, {
|
||||
failures,
|
||||
nextRetryAt: Date.now() + Math.min(delay + jitter, BACKOFF_MAX_MS),
|
||||
})
|
||||
tracker.failure = "n/a"
|
||||
bump()
|
||||
continue
|
||||
}
|
||||
|
||||
backoffByUrl.delete(baseUrl)
|
||||
const slotList = Array.isArray(slots) ? slots : Object.values(slots)
|
||||
if (slotList.length === 0) {
|
||||
tracker.failure = "n/a"
|
||||
bump()
|
||||
continue
|
||||
}
|
||||
tracker.failure = null
|
||||
bump()
|
||||
|
||||
processPoll(slotList, tracker)
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
const propsOf = (evt: any) => evt?.data ?? evt?.properties ?? {}
|
||||
|
||||
const offDelta = context.data.on("message.part.delta", (evt: any) => {
|
||||
const p = propsOf(evt)
|
||||
if (p.field !== "text") return
|
||||
const partType = partTypeByID.get(p.partID)
|
||||
if (partType && partType !== "text" && partType !== "reasoning") return
|
||||
appendSample(p.sessionID, p.messageID, {
|
||||
at: Date.now(),
|
||||
tokens: estimateStreamTokens(String(p.delta ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
const offMessage = context.data.on("message.updated", (evt: any) => {
|
||||
const p = propsOf(evt)
|
||||
if (p.info?.role !== "assistant") return
|
||||
|
||||
if (!p.info?.time?.completed) {
|
||||
const existing = tracker.messageTimingByID[p.info.id]
|
||||
tracker.messageTimingByID[p.info.id] = {
|
||||
sessionID: p.sessionID,
|
||||
requestStartAt: p.info.time.created,
|
||||
firstResponseAt: existing?.firstResponseAt,
|
||||
firstTokenAt: existing?.firstTokenAt,
|
||||
lastTokenAt: existing?.lastTokenAt,
|
||||
}
|
||||
bump()
|
||||
return
|
||||
}
|
||||
|
||||
delete tracker.messageTimingByID[p.info.id]
|
||||
pruneSamples(p.info?.time?.completed)
|
||||
bump()
|
||||
})
|
||||
|
||||
const offPart = context.data.on("message.part.updated", (evt: any) => {
|
||||
const p = propsOf(evt)
|
||||
const part = p.part
|
||||
if (!part?.id) return
|
||||
if (typeof part.type === "string") partTypeByID.set(part.id, part.type)
|
||||
if (part.type !== "tool") return
|
||||
if (
|
||||
part.state?.status === "running" ||
|
||||
part.state?.status === "completed" ||
|
||||
part.state?.status === "error"
|
||||
) {
|
||||
clearLiveSamples(p.sessionID)
|
||||
}
|
||||
const messageID = part.messageID
|
||||
if (!messageID) return
|
||||
const timing = tracker.messageTimingByID[messageID]
|
||||
if (!timing) return
|
||||
if (part.state?.status === "pending") {
|
||||
tracker.messageTimingByID[messageID] = {
|
||||
...timing,
|
||||
firstResponseAt: timing.firstResponseAt ?? p.time,
|
||||
}
|
||||
bump()
|
||||
return
|
||||
}
|
||||
if (part.state?.status === "running") bump()
|
||||
})
|
||||
|
||||
const timer = setInterval(() => {
|
||||
pruneSamples()
|
||||
}, 1000)
|
||||
|
||||
const slotTimer = setInterval(() => {
|
||||
void pollMetrics()
|
||||
}, SLOT_POLL_MS)
|
||||
|
||||
try {
|
||||
await context.data.location.provider.sync(location)
|
||||
providerList = context.data.location.provider.list(location) ?? []
|
||||
llamaServerUrls = resolveServerUrls(context.options, providerList, undefined)
|
||||
} catch (e) {
|
||||
console.error("[oc-ls-stats] resolveServerUrls error:", e)
|
||||
llamaServerUrls = ["http://localhost:8080"]
|
||||
}
|
||||
|
||||
const unregisterSlot = context.ui.slot({
|
||||
append: "prompt.footer.status",
|
||||
render: () => <text fg={themeMuted}>{statusText()}</text>,
|
||||
})
|
||||
|
||||
return () => {
|
||||
offDelta()
|
||||
offMessage()
|
||||
offPart()
|
||||
clearInterval(timer)
|
||||
clearInterval(slotTimer)
|
||||
if (typeof unregisterSlot === "function") unregisterSlot()
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
id: "@troed/oc-ls-stats",
|
||||
tui,
|
||||
setup: v2Setup,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
|
||||
Reference in New Issue
Block a user