feat(ui): context stats board above the current composition

This commit is contained in:
bowenliang123 committed 2026-08-15 01:56:25 +08:00
1 parent ee0e3b2a4f
commit b9853494d7
2 files changed
+97 -2

No files matched your search

+24 -2
View File
@@ -153,7 +153,7 @@ const requireStateful = (spec) => {
const m2 = { exports: {} }
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.actual': '· last actual prompt {n}', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}' }
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.actual': '· last actual prompt {n}', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes' }
let viewComponent = null
const fakeCtx2 = {
get: () => undefined,
@@ -259,6 +259,21 @@ assert.equal(turnWidths[1], '14px', 'T2 tick spans one column')
assert.ok(byClass(tree, 'lc-chart-scroll').length === 1, 'scroll container present')
assert.ok(byClass(tree, 'lc-turns').length === 1, 'turn tick row present')
// ---- context stats board: totals over the retained window ----
// fixture: 4 requests (turns 1,1,2,3; totals 100/90/107/83; prompts 95+83000),
// no events yet -> recycled 0, all event counters 0.
const statVals = byClass(tree, 'lc-stat-value').map(n => n.args[2])
assert.equal(statVals.length, 8, 'eight stat cells')
assert.equal(statVals[0], '3', 'turns counted by distinct turn')
assert.equal(statVals[1], '4', 'steps = request count')
assert.equal(statVals[2], '0', 'no events yet -> nothing recycled')
assert.equal(byClass(tree, 'lc-stat-sub')[0].args[2], '0 compactions · 0 prunes', 'recycle sub shows zero counts')
assert.equal(statVals[3], '0', 'no injections yet')
assert.equal(statVals[4], '0', 'no model switches yet')
assert.equal(statVals[5], '≈ 380', 'estimated total sums request totals')
assert.equal(statVals[6], '83.1k', 'actual prompt sums provider-reported prompts (95 + 83000)')
assert.equal(statVals[7], '0', 'no provider output reported')
// ---- hover linking: hovering a trend bar updates the detail below ----
const ctxSlots = hookStates.get(ctxKey) // data(0) error(1) selected(2) hovered(3) tick(4)
const renderView = () => evaluate(viewComponent({ sessionId: 's1' }))
@@ -522,6 +537,13 @@ assert.equal(atLabels[2].args[2], 'Turn 1 · Step 1 → Turn 2 · Step 0', 'cros
assert.equal(atLabels[3].args[2], 'Turn 1 · Step 0→1', 'same-turn boundary compresses to a step range')
assert.equal(atLabels[4].args[2], 'Turn 1 · Step 0 → Turn 2 · Step 0', 'oldest boundary event shows its gap')
// the stats board picks up the event counters and recycled tokens
const statVals2 = byClass(tr, 'lc-stat-value').map(n => n.args[2])
assert.equal(statVals2[2], '−6.1k', 'recycled sums compaction+prune shadowed tokens (900+60+100+5000)')
assert.equal(byClass(tr, 'lc-stat-sub')[0].args[2], '2 compactions · 2 prunes', 'recycle sub counts both kinds')
assert.equal(statVals2[3], '1', 'one injection counted')
assert.equal(statVals2[4], '1', 'one model switch counted')
// the ✂ marker sits on the bar it attaches to and tooltips the event gap
const barMark = byClass(tr, 'lc-bar-marker')
assert.equal(barMark.length, 1, 'one ✂ marker on the attached bar')
@@ -546,4 +568,4 @@ const overviewNum = byClass(tr, 'lc-overview-num')[0]
assert.ok(overviewNum, 'overview number row present')
assert.match(textOf(overviewNum), /· last actual prompt 83\.0k/, 'overview shows the last actual prompt alongside the estimate')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, event range labels, detail marker chip, overview actual)')
console.log('✔ chart render test passed (context stats board, fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, event range labels, detail marker chip, overview actual)')
+73
View File
@@ -151,6 +151,16 @@ const DICT_ZH: Record<string, string> = {
'overview.title': '当前构成',
'overview.ofWindow': 'tokens(约 {p}%)',
'overview.estimate': 'tokens(估算)',
'stats.title': '上下文统计',
'stats.hint': '统计当前保留的历史窗口(与趋势图一致)',
'stats.turns': '轮次', 'stats.steps': '步数',
'stats.recycled': '✂ 回收',
'stats.recycleSub': '压缩 {c} 次 · 剪枝 {p} 次',
'stats.injects': '+ 注入',
'stats.switches': '⇄ 模型切换',
'stats.est': '≈ 累计估算',
'stats.actualPrompt': '实际 prompt',
'stats.output': '输出',
'overview.actual': '· 上次实际 prompt {n}',
'tools.top': '工具定义 Top:',
'tools.more': '等 {n} 个',
@@ -200,6 +210,16 @@ const DICT_EN: Record<string, string> = {
'overview.title': 'Current composition',
'overview.ofWindow': 'tokens (~{p}%)',
'overview.estimate': 'tokens (estimated)',
'stats.title': 'Context stats',
'stats.hint': 'Over the retained history window (same as the History chart)',
'stats.turns': 'Turns', 'stats.steps': 'Steps',
'stats.recycled': '✂ Recycled',
'stats.recycleSub': '{c} compactions · {p} prunes',
'stats.injects': '+ Injections',
'stats.switches': '⇄ Model switches',
'stats.est': '≈ Estimated total',
'stats.actualPrompt': 'actual prompt',
'stats.output': 'output',
'overview.actual': '· last actual prompt {n}',
'tools.top': 'Top tool schemas:',
'tools.more': 'of {n}',
@@ -248,6 +268,7 @@ type Translate = (key: string, params?: Record<string, string | number>) => stri
function fmt(n: number | null | undefined): string {
if (n === undefined || n === null || isNaN(n)) return '—'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(Math.round(n))
}
@@ -675,6 +696,50 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
return null
}
/**
* One-stop session context statistics over the retained history window:
* conversation size (turns/steps), context churn (recycled tokens from
* compactions/prunes, injection and model-switch counts), and volume
* (estimated totals plus provider-reported prompt/output sums).
*/
function StatsBoard(props: { requests: RequestRecord[]; events: ContextEventRecord[] }): ReactNS.ReactElement {
const turns = new Set<number>()
let steps = 0, compactions = 0, prunes = 0, injects = 0, switches = 0
let recycled = 0, est = 0, prompt = 0, output = 0
for (const req of props.requests) {
turns.add(req.turn ?? 0)
steps++
est += req.total
if (typeof req.prompt === 'number') prompt += req.prompt
if (typeof req.output === 'number') output += req.output
}
for (const ev of props.events) {
if (ev.kind === 'compaction') { compactions++; recycled += ev.tokens || 0 }
else if (ev.kind === 'prune') { prunes++; recycled += ev.tokens || 0 }
else if (ev.kind === 'inject') injects++
else if (ev.kind === 'model') switches++
}
const cell = (label: string, value: string, sub?: string) =>
h('div', { className: 'lc-stat' },
h('span', { className: 'lc-stat-label' }, label),
h('b', { className: 'lc-stat-value' }, value),
sub !== undefined ? h('span', { className: 'lc-stat-sub' }, sub) : null)
return h('div', { className: 'lc-card' },
h('div', { className: 'lc-card-title' },
t('stats.title'),
h('span', { className: 'lc-card-sub' }, t('stats.hint'))),
h('div', { className: 'lc-stats' },
cell(t('stats.turns'), fmt(turns.size)),
cell(t('stats.steps'), fmt(steps)),
cell(t('stats.recycled'), recycled > 0 ? '−' + fmt(recycled) : '0',
tr('stats.recycleSub', { c: compactions, p: prunes })),
cell(t('stats.injects'), fmt(injects)),
cell(t('stats.switches'), fmt(switches)),
cell(t('stats.est'), '≈ ' + fmt(est)),
cell(t('stats.actualPrompt'), fmt(prompt)),
cell(t('stats.output'), fmt(output))))
}
function EventList(props: EventListProps): ReactNS.ReactElement {
if (props.events.length === 0) {
return h('div', { className: 'lc-empty' }, t('events.empty'))
@@ -800,6 +865,9 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
return h('div', { className: 'lc-root' },
// ---- session context stats (over the retained window) ----
h(StatsBoard, { requests, events }),
// ---- overview ----
h('div', { className: 'lc-card' },
h('div', { className: 'lc-card-title' },
@@ -884,6 +952,11 @@ const STYLES = [
'.lc-root { padding: 16px 20px 32px; overflow-y: auto; height: 100%; box-sizing: border-box; color: var(--dsw-alias-label-primary); font-size: 13px; }',
'.lc-card { background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }',
'.lc-card-title { font-weight: 600; margin-bottom: 10px; display: flex; align-items: baseline; gap: 8px; }',
'.lc-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); gap: 8px; }',
'.lc-stat { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }',
'.lc-stat-label { color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'.lc-stat-value { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'.lc-stat-sub { color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'.lc-card-sub { font-weight: 400; color: var(--dsw-alias-label-secondary); font-size: 12px; }',
'.lc-gran { margin-left: auto; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 1px; }',
'.lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; }',