feat(ui): context stats board above the current composition
This commit is contained in:
1 parent
ee0e3b2a4f
commit
b9853494d7
2 files changed
+97
-2
No files matched your search
+24
-2
@@ -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)')
|
||||
@@ -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; }',
|
||||
|
||||
Reference in new issue
Block a user