dsh-token-total 1.0.7 → 1.0.9
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/README.md +5 -2
- package/lib/client.js +678 -127
- package/lib/index.js +281 -4
- package/lib/types/client/ContextWindowPanel.d.ts +18 -0
- package/lib/types/client/SessionUsageView.d.ts +3 -2
- package/lib/types/client/context-window-core.d.ts +49 -0
- package/lib/types/client/session-usage-locales.d.ts +28 -0
- package/lib/types/context-window.d.ts +36 -0
- package/lib/types/store.d.ts +8 -1
- package/lib/types/wire.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/ContextWindowPanel.module.css +77 -0
- package/src/client/ContextWindowPanel.tsx +222 -0
- package/src/client/SessionUsageView.module.css +89 -64
- package/src/client/SessionUsageView.tsx +30 -26
- package/src/client/context-window-core.ts +137 -0
- package/src/client/session-usage-locales.ts +36 -0
- package/src/collector.ts +4 -1
- package/src/context-window.ts +302 -0
- package/src/session-detail.ts +3 -2
- package/src/store.ts +9 -1
- package/src/wire.ts +53 -0
package/lib/index.js
CHANGED
|
@@ -772,8 +772,16 @@ var UsageStore = class {
|
|
|
772
772
|
/** Fold ONLY the failure count of a sample into the day and hour rows. The
|
|
773
773
|
* failure upgrade pass uses this: its replay walks logs the token passes
|
|
774
774
|
* already consumed, so the tokens and the request marker must not be added
|
|
775
|
-
* a second time — only the failed-call counter may move.
|
|
775
|
+
* a second time — only the failed-call counter may move.
|
|
776
|
+
*
|
|
777
|
+
* A sample WITHOUT the `failed` marker counts NOTHING here. The pass folds
|
|
778
|
+
* whole session logs, which are mostly successful calls: passing every
|
|
779
|
+
* folded sample through a write that hardcodes `failed: true` would count
|
|
780
|
+
* each call's request marker AND token sample as two extra "failures" —
|
|
781
|
+
* exactly how stores upgrading through v1.0.2–v1.0.8 ended up with
|
|
782
|
+
* failures ≈ 2 × requests on every pre-upgrade day ("失败的比总的都多"). */
|
|
776
783
|
async recordFailure(sample) {
|
|
784
|
+
if (!sample.failed) return;
|
|
777
785
|
await this.record({
|
|
778
786
|
day: sample.day,
|
|
779
787
|
hour: sample.hour,
|
|
@@ -1316,7 +1324,7 @@ var UsageCollector = class UsageCollector {
|
|
|
1316
1324
|
const concurrency = this.options.backfillConcurrency ?? 4;
|
|
1317
1325
|
let next = 0;
|
|
1318
1326
|
const completed = [];
|
|
1319
|
-
const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : target === "failures" ? (sample) => this.store.recordFailure(sample) : (sample) => this.store.record(sample);
|
|
1327
|
+
const writeSample = target === "hours" ? (sample) => this.store.recordHours(sample) : target === "failures" ? (sample) => sample.failed ? this.store.recordFailure(sample) : Promise.resolve() : (sample) => this.store.record(sample);
|
|
1320
1328
|
const flushCompleted = async () => {
|
|
1321
1329
|
if (completed.length === 0) return;
|
|
1322
1330
|
const batch = completed.splice(0, completed.length);
|
|
@@ -1518,6 +1526,273 @@ async function resolveModelNames(modelRefs, llm) {
|
|
|
1518
1526
|
return names;
|
|
1519
1527
|
}
|
|
1520
1528
|
//#endregion
|
|
1529
|
+
//#region src/context-window.ts
|
|
1530
|
+
/** ASCII-ish characters per token (the host estimator's ratio). */
|
|
1531
|
+
const ASCII_CHARS_PER_TOKEN = 4;
|
|
1532
|
+
/** Wide (CJK, kana, hangul) characters per token: these bill ~1 token/char. */
|
|
1533
|
+
const WIDE_CHARS_PER_TOKEN = 1.5;
|
|
1534
|
+
/** A supplementary-plane emoji is worth a few wide characters. */
|
|
1535
|
+
const EMOJI_TOKEN_EQUIVALENT = 3;
|
|
1536
|
+
/** Serialized JSON punctuation/keys cost a token or two per schema node. */
|
|
1537
|
+
const TOOL_SCHEMA_OVERHEAD_TOKENS = 8;
|
|
1538
|
+
/** Per-message framing the provider bills on top of the content. */
|
|
1539
|
+
const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
1540
|
+
/** A rendered image is billed in tiles, not characters. */
|
|
1541
|
+
const IMAGE_TOKENS = 1100;
|
|
1542
|
+
/** A category keeps its biggest rows until this share of its tokens is covered. */
|
|
1543
|
+
const KEEP_SHARE = .85;
|
|
1544
|
+
/** Hard cap on rows kept per category before the rest folds into one block. */
|
|
1545
|
+
const MAX_ROWS_PER_CATEGORY = 10;
|
|
1546
|
+
/** Tool names whose results are file payloads (they group by path). */
|
|
1547
|
+
const FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
1548
|
+
"read",
|
|
1549
|
+
"write",
|
|
1550
|
+
"edit",
|
|
1551
|
+
"multiedit",
|
|
1552
|
+
"notebook_read",
|
|
1553
|
+
"notebook_edit"
|
|
1554
|
+
]);
|
|
1555
|
+
/** Legend/treemap order; also the tie-break for equal-sized rows. */
|
|
1556
|
+
const CATEGORY_ORDER = [
|
|
1557
|
+
"system",
|
|
1558
|
+
"tools",
|
|
1559
|
+
"memory",
|
|
1560
|
+
"files",
|
|
1561
|
+
"results",
|
|
1562
|
+
"conversation"
|
|
1563
|
+
];
|
|
1564
|
+
/** Approximate token count of one string (see the header comment). */
|
|
1565
|
+
function estimateTokens(text) {
|
|
1566
|
+
if (text === "") return 0;
|
|
1567
|
+
let ascii = 0;
|
|
1568
|
+
let wide = 0;
|
|
1569
|
+
for (const ch of text) {
|
|
1570
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
1571
|
+
if (code >= 126976 && code <= 129791) wide += EMOJI_TOKEN_EQUIVALENT;
|
|
1572
|
+
else if (code < 11904) ascii++;
|
|
1573
|
+
else wide++;
|
|
1574
|
+
}
|
|
1575
|
+
return Math.ceil(ascii / ASCII_CHARS_PER_TOKEN + wide / WIDE_CHARS_PER_TOKEN);
|
|
1576
|
+
}
|
|
1577
|
+
/** Approximate tokens of any JSON-ish value, counting its strings only. */
|
|
1578
|
+
function valueTokens(value) {
|
|
1579
|
+
if (typeof value === "string") return estimateTokens(value);
|
|
1580
|
+
if (typeof value === "number" || typeof value === "boolean") return 1;
|
|
1581
|
+
if (Array.isArray(value)) return value.reduce((sum, item) => sum + valueTokens(item), 0);
|
|
1582
|
+
if (value !== null && typeof value === "object") {
|
|
1583
|
+
let sum = 0;
|
|
1584
|
+
for (const [key, item] of Object.entries(value)) {
|
|
1585
|
+
if (key === "replayState" || key === "thinkingSignature" || key === "textSignature") continue;
|
|
1586
|
+
sum += valueTokens(item);
|
|
1587
|
+
}
|
|
1588
|
+
return sum;
|
|
1589
|
+
}
|
|
1590
|
+
return 0;
|
|
1591
|
+
}
|
|
1592
|
+
/** Approximate tokens of one replayed message (content + framing). */
|
|
1593
|
+
function messageTokens(message) {
|
|
1594
|
+
const content = message?.content;
|
|
1595
|
+
const blocks = Array.isArray(content) ? content : content === void 0 ? [] : [content];
|
|
1596
|
+
let sum = MESSAGE_OVERHEAD_TOKENS;
|
|
1597
|
+
for (const block of blocks) {
|
|
1598
|
+
const kind = block?.type;
|
|
1599
|
+
if (kind === "image" || kind === "image-url") {
|
|
1600
|
+
sum += IMAGE_TOKENS;
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
if (kind === "text" && typeof block.text === "string") {
|
|
1604
|
+
sum += valueTokens(block.text);
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
sum += valueTokens(block);
|
|
1608
|
+
}
|
|
1609
|
+
return sum;
|
|
1610
|
+
}
|
|
1611
|
+
/** Prompt tokens one answer actually sent, as the provider billed it: its total
|
|
1612
|
+
* minus the output it generated, or the uncached plus cached input when the
|
|
1613
|
+
* provider reported no total. Null when there is no usage at all. */
|
|
1614
|
+
function promptTokensOf(usage) {
|
|
1615
|
+
const record = usage;
|
|
1616
|
+
const total = Number(record?.totalTokens) || 0;
|
|
1617
|
+
const output = Number(record?.outputTokens) || 0;
|
|
1618
|
+
if (total > output) return total - output;
|
|
1619
|
+
const input = Number(record?.inputTokens) || 0;
|
|
1620
|
+
const cached = Number(record?.cacheReadTokens) || 0;
|
|
1621
|
+
return input + cached > 0 ? input + cached : null;
|
|
1622
|
+
}
|
|
1623
|
+
/** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
|
|
1624
|
+
* never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
|
|
1625
|
+
* a session with 200 tool calls still yields a readable treemap. */
|
|
1626
|
+
function foldDrafts(drafts) {
|
|
1627
|
+
const items = [];
|
|
1628
|
+
for (const category of CATEGORY_ORDER) {
|
|
1629
|
+
const rows = drafts.filter((row) => row.category === category).sort((a, b) => b.tokens - a.tokens);
|
|
1630
|
+
if (rows.length === 0) continue;
|
|
1631
|
+
const total = rows.reduce((sum, row) => sum + row.tokens, 0);
|
|
1632
|
+
const kept = [];
|
|
1633
|
+
let covered = 0;
|
|
1634
|
+
for (const row of rows) {
|
|
1635
|
+
if (kept.length >= MAX_ROWS_PER_CATEGORY) break;
|
|
1636
|
+
if (kept.length > 0 && covered >= total * KEEP_SHARE) break;
|
|
1637
|
+
kept.push(row);
|
|
1638
|
+
covered += row.tokens;
|
|
1639
|
+
}
|
|
1640
|
+
const folded = rows.slice(kept.length);
|
|
1641
|
+
for (const row of kept) items.push({
|
|
1642
|
+
key: `${category}:${row.kind ?? row.label}`,
|
|
1643
|
+
category,
|
|
1644
|
+
...row.kind === void 0 ? {} : { kind: row.kind },
|
|
1645
|
+
label: row.label,
|
|
1646
|
+
count: row.count,
|
|
1647
|
+
tokens: row.tokens
|
|
1648
|
+
});
|
|
1649
|
+
if (folded.length > 0) items.push({
|
|
1650
|
+
key: `${category}:rest`,
|
|
1651
|
+
category,
|
|
1652
|
+
kind: "rest",
|
|
1653
|
+
label: "",
|
|
1654
|
+
count: folded.length,
|
|
1655
|
+
tokens: folded.reduce((sum, row) => sum + row.tokens, 0)
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
return items.sort((a, b) => b.tokens - a.tokens || CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category));
|
|
1659
|
+
}
|
|
1660
|
+
/** Build the context-window snapshot from one session's own events. Returns
|
|
1661
|
+
* null for an empty log so the panel can hide itself. */
|
|
1662
|
+
function buildSessionContextWindow(events) {
|
|
1663
|
+
if (events.length === 0) return null;
|
|
1664
|
+
const ordered = [...events].sort((a, b) => a.seq - b.seq);
|
|
1665
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
1666
|
+
const summaries = [];
|
|
1667
|
+
const callNames = /* @__PURE__ */ new Map();
|
|
1668
|
+
let tools = [];
|
|
1669
|
+
let headerRoute = null;
|
|
1670
|
+
let route = null;
|
|
1671
|
+
let cache = null;
|
|
1672
|
+
/** What the newest answer actually sent, which is what the window holds now. */
|
|
1673
|
+
let billed = null;
|
|
1674
|
+
for (const event of ordered) {
|
|
1675
|
+
const data = event.data ?? {};
|
|
1676
|
+
if (event.type === "compaction/prune" || event.type === "compaction/summary") {
|
|
1677
|
+
for (const seq of Array.isArray(data.shadowedSeqs) ? data.shadowedSeqs : []) shadowed.add(Number(seq));
|
|
1678
|
+
const range = data.shadowedRange;
|
|
1679
|
+
if (typeof range?.start === "number" && typeof range?.end === "number") for (let seq = range.start; seq <= range.end; seq++) shadowed.add(seq);
|
|
1680
|
+
if (event.type === "compaction/summary") {
|
|
1681
|
+
summaries.push({
|
|
1682
|
+
seq: event.seq,
|
|
1683
|
+
tokens: messageTokens({ content: data.summary })
|
|
1684
|
+
});
|
|
1685
|
+
const usage = data.usage;
|
|
1686
|
+
if (usage) {
|
|
1687
|
+
cache = {
|
|
1688
|
+
hit: Number(usage.cacheReadTokens) || 0,
|
|
1689
|
+
miss: (Number(usage.inputTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
|
|
1690
|
+
};
|
|
1691
|
+
const summaryBilled = promptTokensOf(usage);
|
|
1692
|
+
if (summaryBilled !== null) billed = summaryBilled;
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
if (event.type === "tool/call" && typeof data.callId === "string") {
|
|
1698
|
+
callNames.set(data.callId, String(data.name ?? ""));
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
if (event.type === "request/header") {
|
|
1702
|
+
const header = data.header ?? {};
|
|
1703
|
+
if (Array.isArray(header.tools)) tools = header.tools;
|
|
1704
|
+
const config = header.config ?? {};
|
|
1705
|
+
if (typeof config.provider === "string" || typeof config.model === "string") headerRoute = {
|
|
1706
|
+
provider: String(config.provider ?? ""),
|
|
1707
|
+
model: String(config.model ?? "")
|
|
1708
|
+
};
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
if (event.type === "request/context") {
|
|
1712
|
+
route = {
|
|
1713
|
+
provider: String(data.provider ?? ""),
|
|
1714
|
+
model: String(data.model ?? ""),
|
|
1715
|
+
contextWindow: typeof data.contextWindow === "number" && data.contextWindow > 0 ? data.contextWindow : null
|
|
1716
|
+
};
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
if (event.type === "assistant/message" && data.usage) {
|
|
1720
|
+
const usage = data.usage;
|
|
1721
|
+
cache = {
|
|
1722
|
+
hit: Number(usage.cacheReadTokens) || 0,
|
|
1723
|
+
miss: (Number(usage.inputTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
|
|
1724
|
+
};
|
|
1725
|
+
billed = promptTokensOf(usage);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
const drafts = /* @__PURE__ */ new Map();
|
|
1729
|
+
const add = (category, label, tokens, kind) => {
|
|
1730
|
+
const key = `${category}\u0000${kind ?? ""}\u0000${label}`;
|
|
1731
|
+
const draft = drafts.get(key);
|
|
1732
|
+
if (draft === void 0) drafts.set(key, {
|
|
1733
|
+
category,
|
|
1734
|
+
...kind === void 0 ? {} : { kind },
|
|
1735
|
+
label,
|
|
1736
|
+
count: 1,
|
|
1737
|
+
tokens
|
|
1738
|
+
});
|
|
1739
|
+
else {
|
|
1740
|
+
draft.count++;
|
|
1741
|
+
draft.tokens += tokens;
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
for (const event of ordered) {
|
|
1745
|
+
if (shadowed.has(event.seq)) continue;
|
|
1746
|
+
const data = event.data ?? {};
|
|
1747
|
+
switch (event.type) {
|
|
1748
|
+
case "system/message": {
|
|
1749
|
+
const kind = String(data.message?.source?.kind ?? data.source?.kind ?? "system-prompt");
|
|
1750
|
+
add(kind === "system-prompt" ? "system" : "memory", "", messageTokens(data.message ?? { content: data.content }), kind);
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
case "user/message": {
|
|
1754
|
+
const kind = String(data.source?.kind ?? data.message?.source?.kind ?? "user");
|
|
1755
|
+
const message = data.message ?? { content: data.content };
|
|
1756
|
+
if (kind === "user") add("conversation", "", messageTokens(message), "user");
|
|
1757
|
+
else add("memory", "", messageTokens(message), kind);
|
|
1758
|
+
break;
|
|
1759
|
+
}
|
|
1760
|
+
case "assistant/message":
|
|
1761
|
+
add("conversation", "", messageTokens(data.message ?? { content: data.content }), "assistant");
|
|
1762
|
+
break;
|
|
1763
|
+
case "tool/result": {
|
|
1764
|
+
const message = data.message ?? {};
|
|
1765
|
+
const callId = String(message.toolCallId ?? message.source?.callId ?? "");
|
|
1766
|
+
const name = callNames.get(callId) ?? "";
|
|
1767
|
+
const path = typeof data.meta?.path === "string" ? data.meta.path : "";
|
|
1768
|
+
const tokens = messageTokens(message);
|
|
1769
|
+
if (path !== "" && (FILE_TOOLS.has(name) || name === "")) add("files", path, tokens);
|
|
1770
|
+
else add("results", name === "" ? "tool" : name, tokens);
|
|
1771
|
+
break;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
for (const summary of summaries) if (summary.tokens > 0) add("memory", "", summary.tokens, "compaction-summary");
|
|
1776
|
+
for (const tool of tools) {
|
|
1777
|
+
const record = tool;
|
|
1778
|
+
const name = String(record?.name ?? "");
|
|
1779
|
+
if (name === "") continue;
|
|
1780
|
+
add("tools", name, estimateTokens(name) + valueTokens(record?.description) + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS);
|
|
1781
|
+
}
|
|
1782
|
+
const items = foldDrafts([...drafts.values()]);
|
|
1783
|
+
return {
|
|
1784
|
+
totalTokens: items.reduce((sum, item) => sum + item.tokens, 0),
|
|
1785
|
+
contextWindow: route?.contextWindow ?? null,
|
|
1786
|
+
provider: route?.provider || headerRoute?.provider || "",
|
|
1787
|
+
model: route?.model || headerRoute?.model || "",
|
|
1788
|
+
cacheHitTokens: cache?.hit ?? 0,
|
|
1789
|
+
cacheMissTokens: cache?.miss ?? 0,
|
|
1790
|
+
cacheKnown: cache !== null,
|
|
1791
|
+
promptTokens: billed,
|
|
1792
|
+
items
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
//#endregion
|
|
1521
1796
|
//#region src/session-detail.ts
|
|
1522
1797
|
const zero = () => ({
|
|
1523
1798
|
uncachedInputTokens: 0,
|
|
@@ -1844,7 +2119,8 @@ async function one(p, id, header, live) {
|
|
|
1844
2119
|
records,
|
|
1845
2120
|
requests,
|
|
1846
2121
|
averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
|
|
1847
|
-
tokensPerSecond: decodeRateOf(records)
|
|
2122
|
+
tokensPerSecond: decodeRateOf(records),
|
|
2123
|
+
context: buildSessionContextWindow(own)
|
|
1848
2124
|
};
|
|
1849
2125
|
}
|
|
1850
2126
|
async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
|
|
@@ -1943,7 +2219,8 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
|
|
|
1943
2219
|
};
|
|
1944
2220
|
}),
|
|
1945
2221
|
records,
|
|
1946
|
-
routes: [...routeMap.values()]
|
|
2222
|
+
routes: [...routeMap.values()],
|
|
2223
|
+
context: parts[0]?.context ?? null
|
|
1947
2224
|
};
|
|
1948
2225
|
}
|
|
1949
2226
|
//#endregion
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SessionContextCategory, SessionContextWindow } from '../wire.ts';
|
|
2
|
+
import type { SessionUsageKey } from './session-usage-locales.ts';
|
|
3
|
+
type T = (key: SessionUsageKey, params?: Record<string, string>) => string;
|
|
4
|
+
/** The context-window panel above the Usage cards, in magpie's shape: the used
|
|
5
|
+
* total and the model's window on the left, the newest answer's prompt-cache
|
|
6
|
+
* split on the right, a treemap of what occupies the window below (with a
|
|
7
|
+
* hover card per tile), and one legend row naming the six buckets with their
|
|
8
|
+
* sizes. Every figure is an estimate, so the panel never claims an exact
|
|
9
|
+
* count — and the live pill stamps when the estimate was last refreshed. */
|
|
10
|
+
export declare function ContextWindowPanel({ context, t, updatedAt }: {
|
|
11
|
+
context: SessionContextWindow;
|
|
12
|
+
t: T;
|
|
13
|
+
/** Wall-clock ms of the last successful read, when the host reports one. */
|
|
14
|
+
updatedAt?: number | null;
|
|
15
|
+
}): JSX.Element;
|
|
16
|
+
/** Legend order shared with the CSS tone classes (kept exported for tests). */
|
|
17
|
+
export declare const CONTEXT_LEGEND_ORDER: readonly SessionContextCategory[];
|
|
18
|
+
export {};
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
2
|
import type { SessionUsageDetail } from '../wire.ts';
|
|
3
3
|
import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts';
|
|
4
|
-
type T = (key: SessionUsageKey) => string;
|
|
4
|
+
type T = (key: SessionUsageKey, params?: Record<string, string>) => string;
|
|
5
5
|
export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & {
|
|
6
6
|
sessionId: string;
|
|
7
7
|
};
|
|
8
8
|
/** Pure presentation export also used by offline visual regression fixtures. */
|
|
9
|
-
export declare function SessionUsageContent({ detail, t }: {
|
|
9
|
+
export declare function SessionUsageContent({ detail, t, updatedAt }: {
|
|
10
10
|
detail: SessionUsageDetail;
|
|
11
11
|
t: T;
|
|
12
|
+
updatedAt?: number | null;
|
|
12
13
|
}): JSX.Element;
|
|
13
14
|
export declare function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element;
|
|
14
15
|
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers behind the context-window panel: the treemap squarifier, the
|
|
3
|
+
* per-category roll-up the legend prints, and the occupancy reading. Kept
|
|
4
|
+
* separate from the component so both are testable without a DOM.
|
|
5
|
+
*/
|
|
6
|
+
import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts';
|
|
7
|
+
/** Legend order, also the color assignment order (see the CSS module). */
|
|
8
|
+
export declare const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[];
|
|
9
|
+
/** One positioned treemap block in container pixels. */
|
|
10
|
+
export interface TreemapBox {
|
|
11
|
+
key: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
/** Per-category token totals in legend order, empty categories dropped. */
|
|
18
|
+
export declare function contextCategoryTotals(items: readonly SessionContextItem[]): Array<{
|
|
19
|
+
key: SessionContextCategory;
|
|
20
|
+
tokens: number;
|
|
21
|
+
}>;
|
|
22
|
+
/** Window occupancy in whole percent, or null when the host never reported the
|
|
23
|
+
* model's window (the panel then says "unknown" instead of guessing one). */
|
|
24
|
+
export declare function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null;
|
|
25
|
+
/** How much the replay estimate may drift from the billed prompt before the
|
|
26
|
+
* panel stops treating them as the same number (rounding, framing tokens). */
|
|
27
|
+
export declare const CONTEXT_ESTIMATE_TOLERANCE = 0.15;
|
|
28
|
+
/** One reading of the snapshot for the headline: the billed prompt when the log
|
|
29
|
+
* has one (exact), else the replay estimate. `estimateDiffers` marks the case
|
|
30
|
+
* where the log carries text the model was never billed for — an upstream
|
|
31
|
+
* compression plugin, say — so the panel can explain a treemap that sums
|
|
32
|
+
* higher than the total. */
|
|
33
|
+
export interface ContextReading {
|
|
34
|
+
usedTokens: number;
|
|
35
|
+
percent: number | null;
|
|
36
|
+
estimateTokens: number;
|
|
37
|
+
estimateDiffers: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function contextReading(context: SessionContextWindow): ContextReading;
|
|
40
|
+
/**
|
|
41
|
+
* Squarified treemap (Bruls, Huizing & van Wijk): tile `width`×`height` with
|
|
42
|
+
* one block per positive weight, proportional to its tokens and as close to
|
|
43
|
+
* square as the greedy row packing allows. Blocks come back in the input's
|
|
44
|
+
* descending-weight order, covering the whole area.
|
|
45
|
+
*/
|
|
46
|
+
export declare function treemapLayout(rows: ReadonlyArray<{
|
|
47
|
+
key: string;
|
|
48
|
+
tokens: number;
|
|
49
|
+
}>, width: number, height: number): TreemapBox[];
|
|
@@ -59,6 +59,34 @@ export declare const usageZh: {
|
|
|
59
59
|
live: string;
|
|
60
60
|
asOf: string;
|
|
61
61
|
refresh: string;
|
|
62
|
+
contextTitle: string;
|
|
63
|
+
contextUsed: string;
|
|
64
|
+
contextLive: string;
|
|
65
|
+
contextWindowKnown: string;
|
|
66
|
+
contextWindowUnknown: string;
|
|
67
|
+
contextCache: string;
|
|
68
|
+
contextCacheHit: string;
|
|
69
|
+
contextCacheMiss: string;
|
|
70
|
+
contextCachePending: string;
|
|
71
|
+
contextSystem: string;
|
|
72
|
+
contextTools: string;
|
|
73
|
+
contextMemory: string;
|
|
74
|
+
contextFiles: string;
|
|
75
|
+
contextResults: string;
|
|
76
|
+
contextConversation: string;
|
|
77
|
+
contextKindSystemPrompt: string;
|
|
78
|
+
contextKindRuntimeContext: string;
|
|
79
|
+
contextKindSkillCatalog: string;
|
|
80
|
+
contextKindTimeContext: string;
|
|
81
|
+
contextKindCompaction: string;
|
|
82
|
+
contextKindUser: string;
|
|
83
|
+
contextKindAssistant: string;
|
|
84
|
+
contextKindRest: string;
|
|
85
|
+
contextTimes: string;
|
|
86
|
+
contextEmpty: string;
|
|
87
|
+
contextTipShare: string;
|
|
88
|
+
contextTipWindow: string;
|
|
89
|
+
contextEstimateNote: string;
|
|
62
90
|
};
|
|
63
91
|
export type SessionUsageKey = keyof typeof usageZh;
|
|
64
92
|
export declare const usageEn: Record<SessionUsageKey, string>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-window composition: what the session's NEXT provider call carries.
|
|
3
|
+
*
|
|
4
|
+
* The panel above the Usage cards answers "what is inside the window right
|
|
5
|
+
* now", which is not what the log accumulated. So this walks one session's own
|
|
6
|
+
* events (fork-inherited ones never reach here) and rebuilds the message list a
|
|
7
|
+
* live request would replay:
|
|
8
|
+
* - `compaction/prune` / `compaction/summary` shadowed sequences drop out — the
|
|
9
|
+
* compacted turn is gone from the request, not merely folded in the store;
|
|
10
|
+
* - a compaction summary stands in for the range it replaced, as injected
|
|
11
|
+
* memory rather than conversation (the model never wrote it);
|
|
12
|
+
* - the tool catalogue is the NEWEST `request/header`'s list, since that is the
|
|
13
|
+
* set the next call will send;
|
|
14
|
+
* - file reads group by path and the remaining tool results by tool name, so a
|
|
15
|
+
* session that ran `pwsh` sixty times shows one block with its call count.
|
|
16
|
+
*
|
|
17
|
+
* Token counts are ESTIMATES from character counts: the host's own estimator
|
|
18
|
+
* bills ASCII at four characters per token, while CJK text bills closer to one
|
|
19
|
+
* token per character, so both rates are applied per character class and every
|
|
20
|
+
* figure is presented as approximate. Exact counts are unavailable here — the
|
|
21
|
+
* provider only reports a whole-prompt number after the call — but that billed
|
|
22
|
+
* number IS exact for the request it answered, so it ships alongside the
|
|
23
|
+
* estimate (`promptTokens`) and the panel prints it as the used total whenever
|
|
24
|
+
* the log has one. The two disagree when something upstream rewrites the prompt
|
|
25
|
+
* (a compression plugin, a gateway that trims history), because the stored log
|
|
26
|
+
* then holds more text than the model was ever billed for.
|
|
27
|
+
*/
|
|
28
|
+
import type { UsageSessionEvent } from './context-types.ts';
|
|
29
|
+
import type { SessionContextWindow } from './wire.ts';
|
|
30
|
+
/** Approximate token count of one string (see the header comment). */
|
|
31
|
+
export declare function estimateTokens(text: string): number;
|
|
32
|
+
/** Approximate tokens of one replayed message (content + framing). */
|
|
33
|
+
export declare function messageTokens(message: unknown): number;
|
|
34
|
+
/** Build the context-window snapshot from one session's own events. Returns
|
|
35
|
+
* null for an empty log so the panel can hide itself. */
|
|
36
|
+
export declare function buildSessionContextWindow(events: readonly UsageSessionEvent[]): SessionContextWindow | null;
|
package/lib/types/store.d.ts
CHANGED
|
@@ -228,7 +228,14 @@ export declare class UsageStore {
|
|
|
228
228
|
/** Fold ONLY the failure count of a sample into the day and hour rows. The
|
|
229
229
|
* failure upgrade pass uses this: its replay walks logs the token passes
|
|
230
230
|
* already consumed, so the tokens and the request marker must not be added
|
|
231
|
-
* a second time — only the failed-call counter may move.
|
|
231
|
+
* a second time — only the failed-call counter may move.
|
|
232
|
+
*
|
|
233
|
+
* A sample WITHOUT the `failed` marker counts NOTHING here. The pass folds
|
|
234
|
+
* whole session logs, which are mostly successful calls: passing every
|
|
235
|
+
* folded sample through a write that hardcodes `failed: true` would count
|
|
236
|
+
* each call's request marker AND token sample as two extra "failures" —
|
|
237
|
+
* exactly how stores upgrading through v1.0.2–v1.0.8 ended up with
|
|
238
|
+
* failures ≈ 2 × requests on every pre-upgrade day ("失败的比总的都多"). */
|
|
232
239
|
recordFailure(sample: UsageSample): Promise<void>;
|
|
233
240
|
/** The row identity a sample lands on: turn markers use the synthetic
|
|
234
241
|
* (turns) model, unlabelled calls the (unknown) one. */
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -188,6 +188,56 @@ export interface SessionUsageDetail {
|
|
|
188
188
|
model: string;
|
|
189
189
|
status: 'idle' | 'running' | 'completed' | 'failed';
|
|
190
190
|
}>;
|
|
191
|
+
/** The root session's live context composition, rendered above the cards.
|
|
192
|
+
* Null when the log has no readable events (or on an older host). */
|
|
193
|
+
context?: SessionContextWindow | null;
|
|
194
|
+
}
|
|
195
|
+
/** The six buckets the context-window panel paints, in legend order. They are
|
|
196
|
+
* the buckets a reader thinks in: the harness prompt, the tool catalogue, the
|
|
197
|
+
* injected memory (runtime context, skill catalog, compaction summaries), the
|
|
198
|
+
* files the agent read, the remaining tool results, and the conversation. */
|
|
199
|
+
export type SessionContextCategory = 'system' | 'tools' | 'memory' | 'files' | 'results' | 'conversation';
|
|
200
|
+
/** One block of the context-window treemap: a tool, one file path, one folded
|
|
201
|
+
* group of the same tool, or one message bucket. `tokens` is an ESTIMATE from
|
|
202
|
+
* character counts (see context-window.ts), so the panel renders it as ~N. */
|
|
203
|
+
export interface SessionContextItem {
|
|
204
|
+
/** Row key, unique inside one snapshot. */
|
|
205
|
+
key: string;
|
|
206
|
+
category: SessionContextCategory;
|
|
207
|
+
/** What to print: a tool name, a file path, or (for `kind`-bearing rows) the
|
|
208
|
+
* localized kind label the panel substitutes. */
|
|
209
|
+
label: string;
|
|
210
|
+
/** Machine kind the panel localizes ("system-prompt", "user", "rest", …).
|
|
211
|
+
* Absent for rows whose label is itself the data (tool names, paths). */
|
|
212
|
+
kind?: string;
|
|
213
|
+
/** How many log entries folded into this row (messages, tool calls). */
|
|
214
|
+
count: number;
|
|
215
|
+
tokens: number;
|
|
216
|
+
}
|
|
217
|
+
/** The context-window snapshot: what the session's next provider call would
|
|
218
|
+
* carry, reconstructed from its own log (pruned events dropped, a compaction
|
|
219
|
+
* summary standing in for the range it replaced, the newest request's tool
|
|
220
|
+
* catalogue). Null when the session has no readable events yet. */
|
|
221
|
+
export interface SessionContextWindow {
|
|
222
|
+
/** Sum of every item's estimated tokens. */
|
|
223
|
+
totalTokens: number;
|
|
224
|
+
/** The model's window from the newest `request/context`, or null when the
|
|
225
|
+
* host route never reported one (the panel then says "unknown"). */
|
|
226
|
+
contextWindow: number | null;
|
|
227
|
+
provider: string;
|
|
228
|
+
model: string;
|
|
229
|
+
/** Prompt-cache split of the newest answer: cache reads vs new prompt tokens. */
|
|
230
|
+
cacheHitTokens: number;
|
|
231
|
+
cacheMissTokens: number;
|
|
232
|
+
/** Whether any answer reported usage (the cache bar needs one to draw). */
|
|
233
|
+
cacheKnown: boolean;
|
|
234
|
+
/** Prompt tokens the provider billed on the newest answer
|
|
235
|
+
* (`totalTokens − outputTokens`, or uncached + cached input): the real
|
|
236
|
+
* occupancy. Upstream prompt compression can make it smaller than
|
|
237
|
+
* `totalTokens`, which merely replays the stored log. */
|
|
238
|
+
promptTokens: number | null;
|
|
239
|
+
/** Treemap blocks, largest first. */
|
|
240
|
+
items: SessionContextItem[];
|
|
191
241
|
}
|
|
192
242
|
/** The backfill (historical session scan) progress state. */
|
|
193
243
|
export interface BackfillStatus {
|
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* The context-window panel: magpie's shape on this page's surfaces — a header
|
|
2
|
+
* with the model and a live pill, the used total beside the newest answer's
|
|
3
|
+
* prompt-cache split, a treemap of what occupies the window, and one legend row
|
|
4
|
+
* naming the six buckets. Category colors live as custom properties on the tone
|
|
5
|
+
* classes so the tile and the legend dot share one palette. */
|
|
6
|
+
.panel { background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin:0 0 16px; overflow:hidden; }
|
|
7
|
+
.head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 16px 0; }
|
|
8
|
+
.headCollapsed { padding-bottom:12px; }
|
|
9
|
+
.toggle { display:flex; align-items:center; gap:6px; min-width:0; background:none; border:0; padding:0; font:inherit; color:#394354; cursor:pointer; }
|
|
10
|
+
.toggle h2 { font-size:13px; font-weight:600; margin:0; white-space:nowrap; }
|
|
11
|
+
.chevron { flex:none; color:#99a1af; transition:transform .2s; }
|
|
12
|
+
.chevronOpen { transform:rotate(90deg); }
|
|
13
|
+
.headRight { display:flex; align-items:center; gap:8px; min-width:0; }
|
|
14
|
+
.model { min-width:0; max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; color:#8b93a4; }
|
|
15
|
+
.livePill { display:inline-flex; align-items:center; gap:4px; flex:none; padding:1px 8px 1px 6px; border-radius:999px; background:#e9f8f1; color:#128a63; font-size:10px; font-weight:500; }
|
|
16
|
+
.liveDot { width:5px; height:5px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 2px #d6f2e5; }
|
|
17
|
+
.liveTime { font-variant-numeric:tabular-nums; opacity:.85; }
|
|
18
|
+
.toggle:focus-visible { outline:2px solid #2ab1c6; outline-offset:2px; }
|
|
19
|
+
.body { padding:10px 16px 14px; }
|
|
20
|
+
.body[hidden] { display:none; }
|
|
21
|
+
.summary { display:grid; grid-template-columns:minmax(0,1fr) minmax(180px,260px); align-items:end; gap:18px; padding-bottom:12px; }
|
|
22
|
+
.used { display:flex; flex-direction:column; gap:1px; min-width:0; }
|
|
23
|
+
.label { font-size:11px; color:#8790a3; }
|
|
24
|
+
.total { font-size:28px; font-weight:650; line-height:1.15; letter-spacing:-.6px; color:#1b2130; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
|
25
|
+
.total small { margin-left:4px; font-size:11px; font-weight:500; letter-spacing:0; color:#98a0ae; }
|
|
26
|
+
.window { font-size:11px; color:#939aab; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
27
|
+
.cache { display:flex; flex-direction:column; gap:6px; min-width:0; }
|
|
28
|
+
.cacheBar { display:flex; width:100%; height:5px; border-radius:999px; overflow:hidden; background:#eef0f4; }
|
|
29
|
+
.cacheHit { height:100%; background:#2ec4a0; }
|
|
30
|
+
.cacheMiss { height:100%; background:#dfe3ea; }
|
|
31
|
+
.cacheEmpty { display:block; width:100%; height:100%; background:repeating-linear-gradient(90deg,#e6e9ef 0 3px,transparent 3px 7px); }
|
|
32
|
+
.cacheLegend { display:flex; flex-wrap:wrap; gap:2px 10px; font-size:10px; color:#8b93a4; font-variant-numeric:tabular-nums; }
|
|
33
|
+
.cacheLegend i { display:inline-block; width:6px; height:6px; margin-right:4px; border-radius:50%; }
|
|
34
|
+
.cacheHitDot { background:#2ec4a0; }
|
|
35
|
+
.cacheMissDot { background:#dfe3ea; }
|
|
36
|
+
.cachePending { color:#9aa3b3; }
|
|
37
|
+
.note { margin:0 0 10px; padding:5px 8px; border:1px solid #f2e4c6; border-radius:6px; background:#fdf7ea; font-size:10.5px; line-height:1.5; color:#8a6a2a; font-variant-numeric:tabular-nums; }
|
|
38
|
+
.map { position:relative; height:268px; border-radius:9px; overflow:hidden; }
|
|
39
|
+
/* Slots glide into their new geometry when a refresh re-writes the estimate,
|
|
40
|
+
* so a change in the data reads as motion instead of a silent repaint. */
|
|
41
|
+
.slot { position:absolute; box-sizing:border-box; padding:1.5px; transition:left .35s, top .35s, width .35s, height .35s; }
|
|
42
|
+
.tile { display:flex; flex-direction:column; gap:1px; box-sizing:border-box; width:100%; height:100%; padding:5px 6px; border:1px solid var(--ctx-line); border-radius:7px; background:var(--ctx-soft); color:var(--ctx-ink); overflow:hidden; }
|
|
43
|
+
.tileLabel { font-size:11px; font-weight:600; line-height:1.25; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
44
|
+
.tileSize { font-size:10px; opacity:.72; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-variant-numeric:tabular-nums; }
|
|
45
|
+
/* The hover card: exact figures a tile has no room to print. */
|
|
46
|
+
.tip { position:absolute; z-index:2; box-sizing:border-box; width:220px; padding:8px 10px; border:1px solid #e0e4ec; border-radius:8px; background:#fff; box-shadow:0 8px 20px rgba(23,32,54,.12); font-size:11px; line-height:1.55; pointer-events:none; }
|
|
47
|
+
.tipName { font-weight:600; color:#1b2130; overflow-wrap:anywhere; }
|
|
48
|
+
.tipMeta { color:#6f7889; font-variant-numeric:tabular-nums; }
|
|
49
|
+
.tipShare { color:#4a5567; font-variant-numeric:tabular-nums; }
|
|
50
|
+
.legend { display:flex; flex-wrap:wrap; gap:5px 14px; list-style:none; margin:0; padding:10px 0 0; border-top:1px solid #f2f4f8; margin-top:10px; font-size:11px; color:#6f7889; }
|
|
51
|
+
.legend li { display:flex; align-items:center; gap:5px; }
|
|
52
|
+
.legendName { white-space:nowrap; }
|
|
53
|
+
.legendValue { color:#98a0ae; font-variant-numeric:tabular-nums; }
|
|
54
|
+
.swatch { display:inline-block; flex:none; box-sizing:border-box; width:9px; height:9px; border-radius:3px; background:var(--ctx-soft); background:color-mix(in srgb, var(--ctx) 50%, var(--ctx-soft)); border:1px solid var(--ctx-line); }
|
|
55
|
+
.empty { margin:0; padding:24px 0; text-align:center; font-size:12px; color:#9aa2b1; }
|
|
56
|
+
/* Category palette: `--ctx` is the accent tint, `--ctx-soft` the tile fill (and
|
|
57
|
+
* the legend swatch, so the dots match the tiles exactly), `--ctx-ink` the tile
|
|
58
|
+
* text, `--ctx-line` its hairline border. */
|
|
59
|
+
.toneSystem { --ctx:#a346e6; --ctx-soft:#f6ecfd; --ctx-ink:#6c2e9e; --ctx-line:#ecdafa; }
|
|
60
|
+
.toneTools { --ctx:#4f79f6; --ctx-soft:#eaf0fe; --ctx-ink:#3a5aa8; --ctx-line:#d9e3fb; }
|
|
61
|
+
.toneMemory { --ctx:#8fb21e; --ctx-soft:#f1f5d8; --ctx-ink:#5a7012; --ctx-line:#dfe8bd; }
|
|
62
|
+
.toneFiles { --ctx:#18a97b; --ctx-soft:#e6f6ee; --ctx-ink:#1c7a56; --ctx-line:#d2ecdf; }
|
|
63
|
+
.toneResults { --ctx:#e8853f; --ctx-soft:#fdeee3; --ctx-ink:#a35d24; --ctx-line:#f7ddc8; }
|
|
64
|
+
.toneConversation { --ctx:#c2327f; --ctx-soft:#fbe9f3; --ctx-ink:#8c2259; --ctx-line:#f2d3e4; }
|
|
65
|
+
@media (max-width:700px) {
|
|
66
|
+
.head { padding:11px 12px 0; }
|
|
67
|
+
.headCollapsed { padding-bottom:11px; }
|
|
68
|
+
.body { padding:9px 12px 12px; }
|
|
69
|
+
.summary { grid-template-columns:minmax(0,1fr); gap:10px; }
|
|
70
|
+
.total { font-size:24px; }
|
|
71
|
+
.map { height:220px; }
|
|
72
|
+
.model { display:none; }
|
|
73
|
+
}
|
|
74
|
+
@media (prefers-reduced-motion:reduce) {
|
|
75
|
+
.chevron { transition:none; }
|
|
76
|
+
.slot { transition:none; }
|
|
77
|
+
}
|