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/client.js +145 -125
- package/lib/index.js +100 -27
- package/lib/types/client/format.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +3 -0
- package/lib/types/client/use-session-detail.d.ts +4 -1
- package/lib/types/shared.d.ts +15 -0
- package/lib/types/wire.d.ts +5 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.module.css +29 -18
- package/src/client/SessionUsageView.tsx +41 -27
- package/src/client/format.ts +1 -1
- package/src/client/session-usage-locales.ts +3 -0
- package/src/client/use-session-detail.ts +16 -7
- package/src/model-names.ts +14 -4
- package/src/session-detail.ts +93 -25
- package/src/shared.ts +23 -0
- package/src/wire.ts +5 -0
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(
|
|
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
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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:
|
|
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:
|
|
1860
|
-
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
|
-
|
|
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
|
};
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -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.
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -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,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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
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:
|
|
10
|
-
.
|
|
11
|
-
.stat {
|
|
12
|
-
.statHead { display:flex; align-items:center; gap:
|
|
13
|
-
.statIcon { display:flex; align-items:center; justify-content:center; width:
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
104
|
-
.
|
|
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
|
-
{
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
{
|
|
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,
|
|
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={
|
|
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
|
}
|
package/src/client/format.ts
CHANGED
|
@@ -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
|
}
|