dsh-token-total 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -47,6 +47,20 @@ function modelNameOf(modelRef) {
47
47
  if (i > 0) return modelRef.slice(i + 1);
48
48
  return modelRef;
49
49
  }
50
+ /** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
51
+ * the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
52
+ * bottom bar shows. Rows without a decode window (failed attempts, cancelled
53
+ * steps, running requests) are excluded, so the card, the turn summaries and
54
+ * the bottom bar all report one speed. */
55
+ function decodeRateOf(rows) {
56
+ let ms = 0;
57
+ let tokens = 0;
58
+ for (const row of rows) if (typeof row.decodeMs === "number" && Number.isFinite(row.decodeMs)) {
59
+ ms += row.decodeMs;
60
+ tokens += row.totals.outputTokens;
61
+ }
62
+ return ms > 0 ? tokens / (ms / 1e3) : null;
63
+ }
50
64
  /** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
51
65
  * do not distinguish models, but versions, punctuation and suffixes do:
52
66
  * gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
@@ -1411,6 +1425,9 @@ var UsageCollector = class UsageCollector {
1411
1425
  //#region src/model-names.ts
1412
1426
  const LOOKUP_CONCURRENCY = 4;
1413
1427
  const LOOKUP_TIMEOUT_MS = 2e3;
1428
+ /** Raised when a lookup's own timer fires — i.e. the shared phase budget is
1429
+ * spent. Workers stop on this instead of trusting the wall clock again. */
1430
+ var LookupTimeoutError = class extends Error {};
1414
1431
  /** Bound metadata queries even if an adapter fails to honor cancellation. */
1415
1432
  async function lookup(read, timeoutMs) {
1416
1433
  const controller = new AbortController();
@@ -1419,7 +1436,7 @@ async function lookup(read, timeoutMs) {
1419
1436
  return await Promise.race([Promise.resolve().then(() => read(controller.signal)), new Promise((_, reject) => {
1420
1437
  timer = setTimeout(() => {
1421
1438
  controller.abort();
1422
- reject(/* @__PURE__ */ new Error("model display-name lookup timed out"));
1439
+ reject(new LookupTimeoutError("model display-name lookup timed out"));
1423
1440
  }, timeoutMs);
1424
1441
  })]);
1425
1442
  } finally {
@@ -1470,7 +1487,9 @@ async function resolveModelNames(modelRefs, llm) {
1470
1487
  try {
1471
1488
  const info = await lookup((signal) => llm.resolveModelInfo(providerOf(ref), modelNameOf(ref), signal), remaining);
1472
1489
  if (info.name?.trim()) names.set(ref, info.name);
1473
- } catch {}
1490
+ } catch (error) {
1491
+ if (error instanceof LookupTimeoutError) break;
1492
+ }
1474
1493
  }
1475
1494
  }));
1476
1495
  }
@@ -1490,7 +1509,9 @@ async function resolveModelNames(modelRefs, llm) {
1490
1509
  const name = byId.get(modelNameOf(ref));
1491
1510
  if (name?.trim()) names.set(ref, name);
1492
1511
  }
1493
- } catch {}
1512
+ } catch (error) {
1513
+ if (error instanceof LookupTimeoutError) break;
1514
+ }
1494
1515
  }
1495
1516
  }));
1496
1517
  }
@@ -1519,9 +1540,6 @@ function usageOf(x) {
1519
1540
  cacheWriteTokens: Number(u?.cacheWriteTokens) || 0
1520
1541
  };
1521
1542
  }
1522
- function positive(u) {
1523
- return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0;
1524
- }
1525
1543
  function routeOf(x) {
1526
1544
  const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x;
1527
1545
  return {
@@ -1529,19 +1547,65 @@ function routeOf(x) {
1529
1547
  model: typeof source?.model === "string" ? source.model : ""
1530
1548
  };
1531
1549
  }
1550
+ /** Whether one stream chunk carries the model's first output token. Mirrors
1551
+ * `isTokenDelta` in @deepseek-ai/dsh-llm so the decode window starts on the
1552
+ * same chunk the official `sessionStats` projection starts it on. */
1553
+ function isTokenDelta(chunk) {
1554
+ switch (chunk?.type) {
1555
+ case "text-delta":
1556
+ case "reasoning-delta": return chunk.text !== "";
1557
+ case "tool-call-delta": return chunk.argumentsDelta !== "" || chunk.name !== void 0;
1558
+ default: return false;
1559
+ }
1560
+ }
1561
+ /** Reconstructed time of the first token in one packed delta run (mirrors
1562
+ * `runFirstTokenTime`): a name-bearing tool-call run starts at its first
1563
+ * member, every other run at its first non-empty fragment. */
1564
+ function runFirstTokenTime(run) {
1565
+ if (run?.type === "tool-call-chunks" && run.name !== void 0) return run.time0;
1566
+ const fragments = run?.type === "tool-call-chunks" ? run.args : run?.texts;
1567
+ if (!Array.isArray(fragments)) return void 0;
1568
+ let time = Number(run.time0);
1569
+ for (let i = 0; i < fragments.length; i++) {
1570
+ if (i > 0) time += Number(run.dt?.[i - 1]) || 0;
1571
+ if (fragments[i] !== "") return time;
1572
+ }
1573
+ }
1574
+ /** Time of the first token in one compact attempt stream (mirrors
1575
+ * `assistantStreamFirstTokenTime`). */
1576
+ function streamFirstTokenTime(stream) {
1577
+ for (const r of Array.isArray(stream) ? stream : []) {
1578
+ const t = r?.type === "chunk" ? isTokenDelta(r.chunk) ? r.time : void 0 : runFirstTokenTime(r);
1579
+ if (typeof t === "number" && Number.isFinite(t)) return t;
1580
+ }
1581
+ }
1582
+ /** Time of the first token carried by one live `assistant/chunk` payload. */
1583
+ function chunkFirstTokenTime(data, fallback) {
1584
+ const c = data?.chunk;
1585
+ if (!c) return void 0;
1586
+ const timed = c.chunk && typeof c.time === "number" ? c : null;
1587
+ if (!isTokenDelta(timed ? timed.chunk : c)) return void 0;
1588
+ return timed ? timed.time : typeof c.time === "number" ? c.time : fallback;
1589
+ }
1532
1590
  function streamUsage(data) {
1533
- let firstTime;
1534
1591
  let usage;
1535
- for (const r of Array.isArray(data?.stream) ? data.stream : []) {
1536
- const t = typeof r?.time === "number" ? r.time : void 0;
1537
- if (firstTime === void 0 && t !== void 0 && (r?.type === "chunk" || r?.chunk?.type === "text" || r?.chunk?.type === "content")) firstTime = t;
1538
- if (r?.type === "chunk" && r?.chunk?.type === "usage") usage = usageOf(r.chunk);
1592
+ let raw;
1593
+ for (const r of Array.isArray(data?.stream) ? data.stream : []) if (r?.type === "chunk" && r?.chunk?.type === "usage") {
1594
+ usage = usageOf(r.chunk);
1595
+ raw = r.chunk.usage ?? r.chunk;
1539
1596
  }
1540
1597
  return {
1541
1598
  usage,
1542
- firstTime
1599
+ raw
1543
1600
  };
1544
1601
  }
1602
+ /** Provider-reported completion tokens, or null when unreported or invalid:
1603
+ * an invalid count must not enter the decode quotient (mirrors
1604
+ * `usageOutputTokens` in the sessionStats projection). */
1605
+ function usageOutputTokens(x) {
1606
+ const value = (x?.usage ?? x)?.outputTokens;
1607
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
1608
+ }
1545
1609
  function headerOf$1(item) {
1546
1610
  const x = item;
1547
1611
  if (x?.header?.id && typeof x.header.id === "string") return x.header;
@@ -1607,6 +1671,8 @@ async function one(p, id, header, live) {
1607
1671
  if (label && title) break;
1608
1672
  }
1609
1673
  const name = label || title || nameOf(header, id);
1674
+ const stepTokens = /* @__PURE__ */ new Map();
1675
+ const closedSteps = /* @__PURE__ */ new Set();
1610
1676
  for (const ev of own) {
1611
1677
  const d = ev.data ?? {};
1612
1678
  const turn = Number.isFinite(d.turn) ? d.turn : 0;
@@ -1665,6 +1731,7 @@ async function one(p, id, header, live) {
1665
1731
  status: "running",
1666
1732
  totals: zero(),
1667
1733
  firstTokenMs: null,
1734
+ decodeMs: null,
1668
1735
  tokensPerSecond: null,
1669
1736
  turnName: "",
1670
1737
  effort,
@@ -1675,7 +1742,8 @@ async function one(p, id, header, live) {
1675
1742
  byKey.set(key, rec);
1676
1743
  }
1677
1744
  } else if (ev.type === "assistant/chunk" || ev.type === "assistant/message" || ev.type === "assistant/attempt") {
1678
- rec = byKey.get(`${turn}:${step}`);
1745
+ const key = `${turn}:${step}`;
1746
+ rec = byKey.get(key);
1679
1747
  if (!rec) {
1680
1748
  rec = {
1681
1749
  id: `${id}:${turn}:${step}:${ordinal++}`,
@@ -1690,6 +1758,7 @@ async function one(p, id, header, live) {
1690
1758
  status: "running",
1691
1759
  totals: zero(),
1692
1760
  firstTokenMs: null,
1761
+ decodeMs: null,
1693
1762
  tokensPerSecond: null,
1694
1763
  turnName: "",
1695
1764
  effort,
@@ -1697,7 +1766,7 @@ async function one(p, id, header, live) {
1697
1766
  _lastUsage: zero()
1698
1767
  };
1699
1768
  records.push(rec);
1700
- byKey.set(`${turn}:${step}`, rec);
1769
+ byKey.set(key, rec);
1701
1770
  }
1702
1771
  if (r.provider) rec.provider = r.provider;
1703
1772
  if (r.model) rec.model = r.model;
@@ -1706,17 +1775,22 @@ async function one(p, id, header, live) {
1706
1775
  if (u) {
1707
1776
  rec._lastUsage = u;
1708
1777
  rec.totals = u;
1709
- if (positive(u) && rec.firstTokenMs === null) {
1710
- const rawFirst = su.firstTime ?? (typeof d.chunk?.time === "number" ? d.chunk.time : ev.time);
1711
- rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt);
1712
- }
1778
+ }
1779
+ const tokenAt = streamFirstTokenTime(d.stream) ?? chunkFirstTokenTime(d, ev.time);
1780
+ if (tokenAt !== void 0) {
1781
+ if (!stepTokens.has(key)) stepTokens.set(key, tokenAt);
1782
+ if (rec.firstTokenMs === null) rec.firstTokenMs = Math.max(0, tokenAt - rec.startedAt);
1713
1783
  }
1714
1784
  if (ev.type !== "assistant/chunk") {
1715
1785
  rec.finishedAt = ev.time;
1716
1786
  rec.status = ev.type === "assistant/attempt" ? "failed" : "completed";
1787
+ const stepFirst = stepTokens.get(key);
1788
+ if (rec.status === "completed" && !closedSteps.has(key) && stepFirst !== void 0 && usageOutputTokens(d.usage) !== null) {
1789
+ closedSteps.add(key);
1790
+ rec.decodeMs = Math.max(0, rec.finishedAt - stepFirst);
1791
+ if (rec.decodeMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (rec.decodeMs / 1e3);
1792
+ }
1717
1793
  }
1718
- const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt);
1719
- if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1e3);
1720
1794
  } else if (ev.type === "turn/end") {
1721
1795
  const recs = records.filter((x) => x.turn === turn && x.status === "running");
1722
1796
  for (const x of recs) {
@@ -1738,6 +1812,7 @@ async function one(p, id, header, live) {
1738
1812
  status: "completed",
1739
1813
  totals: u,
1740
1814
  firstTokenMs: null,
1815
+ decodeMs: null,
1741
1816
  tokensPerSecond: null,
1742
1817
  turnName: "",
1743
1818
  effort: "",
@@ -1755,9 +1830,7 @@ async function one(p, id, header, live) {
1755
1830
  });
1756
1831
  }
1757
1832
  const requests = records.length;
1758
- const firsts = records.map((x) => x.firstTokenMs).filter((x) => x !== null);
1759
- const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0);
1760
- const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0);
1833
+ const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
1761
1834
  for (const x of records) {
1762
1835
  x.turnName = turnTopics.get(x.turn) ?? "";
1763
1836
  delete x._attempt;
@@ -1771,7 +1844,7 @@ async function one(p, id, header, live) {
1771
1844
  records,
1772
1845
  requests,
1773
1846
  averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1774
- tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1e3) : null
1847
+ tokensPerSecond: decodeRateOf(records)
1775
1848
  };
1776
1849
  }
1777
1850
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
@@ -1793,7 +1866,6 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1793
1866
  }
1794
1867
  const all = [rootId, ...ids];
1795
1868
  const parts = await Promise.all(all.map((id) => one(persistence, id, headers.find((h) => h.id === id), live.get(id))));
1796
- const root = parts[0];
1797
1869
  const totals = zero();
1798
1870
  const models = /* @__PURE__ */ new Map();
1799
1871
  const records = parts.flatMap((x) => x.records);
@@ -1852,12 +1924,13 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
1852
1924
  if (resolved) return resolved;
1853
1925
  }
1854
1926
  };
1927
+ const firsts = records.filter((x) => x.status === "completed").map((x) => x.firstTokenMs).filter((x) => x !== null);
1855
1928
  return {
1856
1929
  sessionId: rootId,
1857
1930
  totals,
1858
1931
  requests: parts.reduce((n, p) => n + p.requests, 0),
1859
- averageFirstTokenMs: root.averageFirstTokenMs,
1860
- tokensPerSecond: root.tokensPerSecond,
1932
+ averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
1933
+ tokensPerSecond: decodeRateOf(records),
1861
1934
  subagents,
1862
1935
  models: [...models.entries()].map(([model, x]) => {
1863
1936
  const name = displayNameOf(model, x.providers);
@@ -7,7 +7,7 @@
7
7
  * from the shared zero-dependency module so the host and client halves share
8
8
  * one implementation.
9
9
  */
10
- export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts';
10
+ export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween, decodeRateOf } from '../shared.ts';
11
11
  /** Exact token counts with thousands separators — no 万/亿/k/M units, the
12
12
  * user reads the precise number (long values shrink to fit via FitText).
13
13
  * Compact units stay on the axis labels and card sub-values only. */
@@ -56,6 +56,9 @@ export declare const usageZh: {
56
56
  avgCache: string;
57
57
  avgRate: string;
58
58
  inProgress: string;
59
+ live: string;
60
+ asOf: string;
61
+ refresh: string;
59
62
  };
60
63
  export type SessionUsageKey = keyof typeof usageZh;
61
64
  export declare const usageEn: Record<SessionUsageKey, string>;
@@ -4,5 +4,8 @@ export declare function useSessionDetail(sessionId: string): {
4
4
  detail: SessionUsageDetail | null;
5
5
  loading: boolean;
6
6
  error: boolean;
7
- retry: () => void;
7
+ /** Manual refresh: runs now when idle, or right after an in-flight poll lands. */
8
+ refresh: () => void;
9
+ /** Wall-clock time of the last successful read, for the "as of" stamp. */
10
+ updatedAt: number | null;
8
11
  };
@@ -18,6 +18,21 @@ export declare function providerOf(modelRef: string): string;
18
18
  /** The bare model id/name after a provider prefix. This does NOT resolve the
19
19
  * human-readable display name; the host model metadata does that. */
20
20
  export declare function modelNameOf(modelRef: string): string;
21
+ /** The decode accounting one request row must expose: the wall window from the
22
+ * step's first token to its assembled assistant message, plus the output tokens
23
+ * that window produced. */
24
+ export interface DecodeRowLike {
25
+ decodeMs: number | null;
26
+ totals: {
27
+ outputTokens: number;
28
+ };
29
+ }
30
+ /** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
31
+ * the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
32
+ * bottom bar shows. Rows without a decode window (failed attempts, cancelled
33
+ * steps, running requests) are excluded, so the card, the turn summaries and
34
+ * the bottom bar all report one speed. */
35
+ export declare function decodeRateOf(rows: readonly DecodeRowLike[]): number | null;
21
36
  /** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
22
37
  * do not distinguish models, but versions, punctuation and suffixes do:
23
38
  * gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
@@ -172,6 +172,11 @@ export interface SessionUsageDetail {
172
172
  status: 'running' | 'completed' | 'failed' | 'interrupted';
173
173
  totals: SessionUsageTotals;
174
174
  firstTokenMs: number | null;
175
+ /** Wall window from this step's first token to the assembled assistant
176
+ * message — the same decode window the `sessionStats` projection counts
177
+ * (decodeMs). Null when the request never assembled a message or reported
178
+ * no usage, so it stays out of every throughput quotient. */
179
+ decodeMs: number | null;
175
180
  tokensPerSecond: number | null;
176
181
  turnName: string;
177
182
  effort: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -1,26 +1,35 @@
1
1
  /* A light, self-contained Usage surface. Tables fit the available width:
2
2
  * no horizontal scroll containers, fixed column contracts, titles for names.
3
- * Stat cards follow the reference dashboard: tinted left edge + icon chip,
4
- * label, big value, small subtitle. Request log folds by conversation turn. */
5
- .scroll { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }
3
+ * Six stat cards sit in one row (≤1050px: 3-up, ≤700px: 2-up) as quiet white
4
+ * surfaces on a pale canvas: no accent edges or watermarks, a flat tone chip
5
+ * leading each label, hierarchy from the type scale and a hairline above the
6
+ * footnote. Request log folds by conversation turn. */
7
+ .scroll { height:100%; min-height:0; overflow-y:auto; background:#f6f7f9; color:#252936; color-scheme:light; container-type:inline-size; }
6
8
  .page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:13px; }
7
9
  .page * { box-sizing:border-box; }
10
+ /* Freshness header: live badge + last-read stamp on the left, manual refresh
11
+ * pinned to the right edge. */
12
+ .head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 12px; }
13
+ .headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:#8790a3; }
14
+ .liveDot { width:6px; height:6px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 3px #e5f6f0; flex-shrink:0; }
15
+ .asOf { color:#9aa3b3; font-variant-numeric:tabular-nums; white-space:nowrap; }
16
+ .refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color:#6f7a8d; background:#fff; border:1px solid #e4e7ee; border-radius:8px; padding:5px 11px; font:inherit; font-size:11px; cursor:pointer; }
17
+ .refresh:hover { color:#279cb6; border-color:#cbd3e0; }
18
+ .refresh:disabled { opacity:.55; cursor:default; }
8
19
  .scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
9
- .stats { display:grid; gap:12px; margin-bottom:18px; }
10
- .statRow { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
11
- .stat { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }
12
- .statHead { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
13
- .statIcon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }
14
- .statMark { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }
15
- .statLabel { font-size:12px; color:#7d8495; }
16
- .statValue { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }
17
- .statSub { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
20
+ .stats { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:8px; margin-bottom:16px; }
21
+ .stat { min-width:0; position:relative; background:#fff; border:1px solid #e7eaf0; border-radius:12px; padding:12px 14px 11px; transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
22
+ .stat:hover { border-color:#d9dfe9; box-shadow:0 14px 30px -22px rgba(23,32,54,.32); transform:translateY(-1px); }
23
+ .statHead { display:flex; align-items:center; gap:5px; margin-bottom:6px; }
24
+ .statIcon { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:6px; color:var(--tone); background:var(--tone-soft); flex-shrink:0; }
25
+ .statLabel { font-size:12px; font-weight:500; color:#7b8494; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
26
+ .statValue { display:block; font-size:21px; font-weight:650; letter-spacing:-0.4px; color:#1b2130; white-space:nowrap; font-variant-numeric:tabular-nums; }
27
+ .statSub { display:block; margin-top:8px; padding-top:7px; border-top:1px solid #f2f4f8; font-size:11px; color:#939aab; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18
28
  .toneBlue { --tone:#4f79f6; --tone-soft:#eaf0fe; }
19
29
  .tonePurple { --tone:#8b5cf6; --tone-soft:#f1ebfe; }
20
30
  .toneGreen { --tone:#18a97b; --tone-soft:#e5f6f0; }
21
31
  .toneOrange { --tone:#e8892c; --tone-soft:#fcf1e4; }
22
32
  .toneTeal { --tone:#16a4b8; --tone-soft:#e4f5f8; }
23
- .tonePink { --tone:#d9538c; --tone-soft:#fbeaf1; }
24
33
  .card { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }
25
34
  .cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }
26
35
  .cardHead h2 { font-size:14px; font-weight:600; margin:0; }
@@ -57,7 +66,7 @@
57
66
  .empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
58
67
  .action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
59
68
  .action:hover { color:#279cb6; }
60
- .action:focus-visible,.turnHead:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
69
+ .action:focus-visible,.turnHead:focus-visible,.refresh:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
61
70
  .turnList { padding:0 14px 14px; }
62
71
  .turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
63
72
  .turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }
@@ -93,15 +102,15 @@
93
102
  .error { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
94
103
  @container (max-width:1050px) {
95
104
  .mid { grid-template-columns:minmax(0,1fr); }
96
- .statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
97
- .statValue { font-size:23px; }
105
+ .stats { grid-template-columns:repeat(3,minmax(0,1fr)); }
98
106
  .table { font-size:12px; }
99
107
  .childName { width:34%; }
100
108
  }
101
109
  @container (max-width:700px) {
102
110
  .page { padding:18px 14px 24px; }
103
- .stat { padding:12px 12px 11px; }
104
- .statValue { font-size:20px; }
111
+ .stats { grid-template-columns:repeat(2,minmax(0,1fr)); }
112
+ .stat { padding:10px 12px 9px; }
113
+ .statValue { font-size:18px; }
105
114
  .table { font-size:10px; }
106
115
  .table th,.table td { padding:10px 3px; }
107
116
  .table th:first-child,.table td:first-child { padding-left:10px; }
@@ -119,4 +128,6 @@
119
128
  }
120
129
  @media (prefers-reduced-motion:reduce) {
121
130
  .chevron { transition:none; }
131
+ .stat { transition:none; }
132
+ .stat:hover { transform:none; }
122
133
  }
@@ -2,7 +2,7 @@ import { useState } from 'react'
2
2
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
3
3
  import type { SessionUsageDetail, SessionUsageTotals } from '../wire.ts'
4
4
  import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
5
- import { formatCompact, formatTokens, normalizeModelName } from './format.ts'
5
+ import { formatCompact, formatTokens, normalizeModelName, decodeRateOf } from './format.ts'
6
6
  import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
7
7
  import { useSessionDetail } from './use-session-detail.ts'
8
8
  import { ModelLogo } from './ModelLogo.tsx'
@@ -45,19 +45,15 @@ const icons = {
45
45
  speed: 'M13 2 4 14h7l-1 8 9-12h-7l1-8Z',
46
46
  input: 'M12 3v12M7 10l5 5 5-5M4 21h16',
47
47
  output: 'M12 21V9M7 14l5-5 5 5M4 3h16',
48
- read: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7ZM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
49
- write: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5Z',
50
48
  }
51
49
 
52
50
  function Stat({ tone, name, label, value, sub, title }: { tone: string; name: keyof typeof icons; label: string; value: string | JSX.Element; sub?: string; title?: string }): JSX.Element {
53
51
  const icon = icons[name]
54
52
  return <article className={`${css.stat} ${tone}`}>
55
- {/* Oversized gradient watermark filling the card's empty right side. */}
56
- <svg className={css.statMark} viewBox="0 0 24 24" aria-hidden="true">
57
- <defs><linearGradient id={`usage-mark-${name}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity="0.3" /><stop offset="1" stopColor="currentColor" stopOpacity="0.03" /></linearGradient></defs>
58
- <path d={icon} fill={`url(#usage-mark-${name})`} stroke={`url(#usage-mark-${name})`} strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
59
- </svg>
60
- <div className={css.statHead}><span className={css.statIcon}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span><span className={css.statLabel}>{label}</span></div>
53
+ <div className={css.statHead}>
54
+ <span className={css.statIcon}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span>
55
+ <span className={css.statLabel}>{label}</span>
56
+ </div>
61
57
  <strong className={css.statValue} title={title}>{value}</strong>
62
58
  {sub !== undefined && <span className={css.statSub}>{sub}</span>}
63
59
  </article>
@@ -68,18 +64,12 @@ function Metrics({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
68
64
  const failed = detail.records.filter(row => row.status === 'failed').length
69
65
  const failRate = detail.requests > 0 ? (failed / detail.requests * 100).toFixed(1) : '0.0'
70
66
  return <div className={css.stats}>
71
- <div className={css.statRow}>
72
- <Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
73
- <Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
74
- <Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
75
- <Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
76
- </div>
77
- <div className={css.statRow}>
78
- <Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
79
- <Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
80
- <Stat tone={css.tonePurple} name="read" label={t('readFull')} value={formatCompact(detail.totals.cacheReadTokens)} title={formatTokens(detail.totals.cacheReadTokens)} sub={formatTokens(detail.totals.cacheReadTokens)} />
81
- <Stat tone={css.tonePink} name="write" label={t('writeFull')} value={formatCompact(detail.totals.cacheWriteTokens)} title={formatTokens(detail.totals.cacheWriteTokens)} sub={formatTokens(detail.totals.cacheWriteTokens)} />
82
- </div>
67
+ <Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
68
+ <Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
69
+ <Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
70
+ <Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
71
+ <Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
72
+ <Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
83
73
  </div>
84
74
  }
85
75
 
@@ -150,14 +140,22 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
150
140
  }
151
141
  group.rows.push(record)
152
142
  }
143
+ // Newest turn on top: groups order by the time of their most recent request,
144
+ // not by first appearance, so the live work leads and finished history trails
145
+ // (records arrive root-session first, subagents after, regardless of time).
146
+ const latest = (group: { rows: Row[] }): number => group.rows.reduce((max, row) => Math.max(max, row.startedAt), 0)
147
+ groups.sort((a, b) => latest(b) - latest(a))
153
148
  return <section className={css.card}><header className={css.cardHead}><h2>{t('records')} <span className={css.count}>{detail.records.length}</span></h2><button type="button" className={css.action} onClick={toggleAll}>{t(allOpen ? 'collapse' : 'expand')}</button></header>
154
149
  <div className={css.turnList}>{groups.map(group => {
155
150
  const open = allOpen !== opened.has(group.key)
156
151
  const totals = sum(group.rows)
157
152
  const billed = billedTotal(totals)
158
153
  const running = group.rows.filter(row => row.status === 'running').length
159
- const rates = group.rows.map(row => row.tokensPerSecond).filter((n): n is number => n !== null)
160
- const avgRate = rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : null
154
+ // Same decode based rate as the card and the composer bottom bar: the
155
+ // turn's output tokens over its summed decode windows, never the mean of
156
+ // independently rounded per-request rates.
157
+ const rate = decodeRateOf(group.rows)
158
+ const avgRate = rate === null ? null : Math.round(rate)
161
159
  return <article key={group.key} className={css.turn}>
162
160
  <button type="button" className={css.turnHead} onClick={() => toggle(group.key)} aria-expanded={open} aria-controls={`usage-turn-${encodeURIComponent(group.key)}`}>
163
161
  <svg className={`${css.chevron} ${open ? css.rotated : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
@@ -172,7 +170,9 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
172
170
  </span>
173
171
  </button>
174
172
  <div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
175
- {group.rows.map(record => {
173
+ {/* Newest first inside and out: every turn lists its newest request
174
+ first, and turn groups themselves lead with the latest turn. */}
175
+ {group.rows.slice().reverse().map(record => {
176
176
  const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
177
177
  const billed = billedTotal(record.totals)
178
178
  const hit = cacheHitPercentOf(record.totals)
@@ -189,8 +189,8 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
189
189
  <span className={`${css.statusDot} ${record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : record.status === 'interrupted' ? css.muted : css.good}`} />
190
190
  <span className={css.recordModel} title={`${record.provider} / ${record.model}`}><ModelLogo provider={record.provider} model={record.model} className={css.modelLogo} />{record.model || '—'}</span>
191
191
  <span className={css.routeText} title={record.provider}>{providerNames.get(record.provider) || record.provider || '—'} · {modelShort(record.model) || '—'}</span>
192
- {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
193
192
  {record.effort ? <span className={css.effort}>· {record.effort}</span> : null}
193
+ {statusText !== '' && <span className={record.status === 'running' ? css.live : record.status === 'failed' ? css.bad : css.muted}>{statusText}</span>}
194
194
  </div>
195
195
  {record.finishedAt !== null && <div className={css.recordMeta}>{meta.map((item, i) => <span key={i} title={item.title}>{i > 0 ? ' · ' : ''}{item.text}</span>)}</div>}
196
196
  </div>
@@ -207,10 +207,24 @@ export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail;
207
207
  }
208
208
 
209
209
  export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
210
- const { detail, loading, error, retry } = useSessionDetail(sessionId)
210
+ const { detail, loading, error, refresh, updatedAt } = useSessionDetail(sessionId)
211
211
  return <div className={css.scroll} data-dsh-session-usage><div className={css.page}>
212
+ {/* Freshness header: the view polls continuously, so it always shows a live
213
+ badge with the time of the last successful read, plus a manual refresh
214
+ that refetches immediately. */}
215
+ <header className={css.head}>
216
+ <span className={css.headStatus}>
217
+ <span className={css.liveDot} aria-hidden="true" />
218
+ <span>{t('live')}</span>
219
+ {updatedAt !== null && <span className={css.asOf}>{t('asOf')} {clock(updatedAt)}</span>}
220
+ </span>
221
+ <button type="button" className={css.refresh} onClick={refresh} disabled={loading}>
222
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /></svg>
223
+ {t('refresh')}
224
+ </button>
225
+ </header>
212
226
  {loading && <p className={css.empty} role="status">{t('loading')}</p>}
213
- {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={retry}>{t('retry')}</button></div>}
227
+ {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={refresh}>{t('retry')}</button></div>}
214
228
  {detail && <SessionUsageContent detail={detail} t={t} />}
215
229
  </div></div>
216
230
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  // Shared with the host half (src/shared.ts has no Node/DOM types, so the
12
12
  // client bundle may import it at runtime).
13
- export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween } from '../shared.ts'
13
+ export { providerOf, modelNameOf, normalizeModelName, daysInRange as daysBetween, decodeRateOf } from '../shared.ts'
14
14
 
15
15
  /** Exact token counts with thousands separators — no 万/亿/k/M units, the
16
16
  * user reads the precise number (long values shrink to fit via FitText).
@@ -15,6 +15,7 @@ export const usageZh = {
15
15
  provider: '供应商', status: '状态', exact: '完整 token 数',
16
16
  answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
17
17
  cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
18
+ live: '实时更新', asOf: '统计截至', refresh: '刷新',
18
19
  }
19
20
  export type SessionUsageKey = keyof typeof usageZh
20
21
  export const usageEn: Record<SessionUsageKey, string> = {
@@ -32,6 +33,7 @@ export const usageEn: Record<SessionUsageKey, string> = {
32
33
  provider: 'Provider', status: 'Status', exact: 'Exact token count',
33
34
  answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
34
35
  cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
36
+ live: 'Live', asOf: 'as of', refresh: 'Refresh',
35
37
  }
36
38
  export const usageZhTW: Record<SessionUsageKey, string> = {
37
39
  ...usageZh,
@@ -47,4 +49,5 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
47
49
  main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
48
50
  answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
49
51
  cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
52
+ live: '即時更新', asOf: '統計截至', refresh: '重新整理',
50
53
  }