compressed-context 0.0.0-stage → 0.1.0

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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +64 -6
  4. package/src/compression/active-summary-cache.ts +50 -0
  5. package/src/compression/adopt.ts +57 -0
  6. package/src/compression/auto.ts +372 -0
  7. package/src/compression/babel.ts +85 -0
  8. package/src/compression/block.ts +25 -0
  9. package/src/compression/cache-history.ts +75 -0
  10. package/src/compression/compaction.ts +189 -0
  11. package/src/compression/decompress-tool.ts +74 -0
  12. package/src/compression/decompress.ts +292 -0
  13. package/src/compression/deleted-sessions.ts +24 -0
  14. package/src/compression/distill.ts +220 -0
  15. package/src/compression/events.ts +275 -0
  16. package/src/compression/markdown.ts +53 -0
  17. package/src/compression/normalize.ts +114 -0
  18. package/src/compression/render.ts +70 -0
  19. package/src/compression/schema.ts +84 -0
  20. package/src/compression/select.ts +123 -0
  21. package/src/compression/session-message.ts +112 -0
  22. package/src/compression/settings-rpc.ts +111 -0
  23. package/src/compression/settings.ts +175 -0
  24. package/src/compression/state.ts +248 -0
  25. package/src/compression/summarize.ts +595 -0
  26. package/src/compression/summary.ts +73 -0
  27. package/src/compression/tool.ts +142 -0
  28. package/src/compression/transcript-store.ts +258 -0
  29. package/src/compression/transcript.ts +63 -0
  30. package/src/compression/validate.ts +81 -0
  31. package/src/i18n/en.ts +93 -0
  32. package/src/i18n/index.ts +28 -0
  33. package/src/i18n/types.ts +89 -0
  34. package/src/i18n/zh.ts +93 -0
  35. package/src/index.ts +108 -0
  36. package/src/shared/blocks.ts +17 -0
  37. package/src/shared/cache.ts +199 -0
  38. package/src/shared/format.ts +23 -0
  39. package/src/shared/media.ts +184 -0
  40. package/src/shared/parse.ts +25 -0
  41. package/src/shared/parts.ts +72 -0
  42. package/src/shared/rpc.ts +145 -0
  43. package/src/shared/scan.ts +32 -0
  44. package/src/shared/settings.ts +471 -0
  45. package/src/shared/stats.ts +142 -0
  46. package/src/shared/tokens.ts +259 -0
  47. package/src/tui/chart/cache-chart.ts +237 -0
  48. package/src/tui/chart/color.ts +58 -0
  49. package/src/tui/index.tsx +111 -0
  50. package/src/tui/panel/cache.tsx +203 -0
  51. package/src/tui/panel/data.ts +105 -0
  52. package/src/tui/panel/index.tsx +176 -0
  53. package/src/tui/panel/summaries.tsx +192 -0
  54. package/src/tui/settings/dialogs.tsx +215 -0
  55. package/src/tui/settings/options.ts +238 -0
  56. package/src/tui/settings.tsx +418 -0
  57. package/src/tui/types.ts +4 -0
@@ -0,0 +1,89 @@
1
+ import type { CategoryKey } from "../shared/stats.ts"
2
+
3
+ /**
4
+ * 面板文案。
5
+ *
6
+ * 源码里只引用键名,所有用户可见文字都来自语言文件。
7
+ */
8
+ export interface Messages {
9
+ readonly title: string
10
+ readonly labels: Readonly<Record<CategoryKey, string>>
11
+ readonly approximate: string
12
+ readonly terminal: string
13
+ readonly cacheTitle: string
14
+ readonly cacheChart: string
15
+ readonly cacheLog: string
16
+ readonly cacheInput: string
17
+ readonly cacheOutput: string
18
+ readonly cacheCached: string
19
+ readonly cacheHit: string
20
+ readonly summariesTitle: string
21
+ readonly noData: string
22
+ readonly command: {
23
+ readonly title: string
24
+ readonly description: string
25
+ readonly group: string
26
+ }
27
+ readonly settings: {
28
+ readonly title: string
29
+ readonly show: string
30
+ readonly limit: string
31
+ readonly limitAuto: string
32
+ readonly limitCustom: string
33
+ readonly limitHint: string
34
+ readonly limitInvalid: string
35
+ readonly limitPlaceholder: string
36
+ readonly limitPreview: (value: string) => string
37
+ readonly limitCapped: (requested: string, actual: string) => string
38
+ readonly threshold: string
39
+ readonly thresholdHint: string
40
+ readonly thresholdInvalid: string
41
+ readonly thresholdPreview: (value: string) => string
42
+ readonly nudgeGrowth: string
43
+ readonly nudgeGrowthHint: string
44
+ readonly nudgeGrowthInvalid: string
45
+ readonly summaryBlockPercent: string
46
+ readonly summaryBlockPercentHint: string
47
+ readonly summaryTotalPercent: string
48
+ readonly summaryTotalPercentHint: string
49
+ readonly summaryPercentInvalid: string
50
+ readonly windowKnown: (value: string) => string
51
+ readonly windowUnknown: string
52
+ readonly summaryLanguage: string
53
+ readonly summaryFormat: string
54
+ readonly summaryFormatJson: string
55
+ readonly summaryFormatMarkdown: string
56
+ readonly summaryFormatBabel: string
57
+ readonly autoCompress: string
58
+ readonly enableCompressTool: string
59
+ readonly enableDecompressTool: string
60
+ readonly showTimeline: string
61
+ readonly showBlockTokens: string
62
+ readonly showCacheChart: string
63
+ readonly showCacheLog: string
64
+ readonly showSummaries: string
65
+ readonly categories: {
66
+ readonly compress: string
67
+ readonly summary: string
68
+ readonly panel: string
69
+ readonly decompress: string
70
+ readonly interface: string
71
+ }
72
+ readonly decompressInlineMax: string
73
+ readonly decompressInlineMaxHint: string
74
+ readonly decompressInlineMaxInvalid: string
75
+ readonly decompressTempCap: string
76
+ readonly decompressTempCapHint: string
77
+ readonly decompressTempCapInvalid: string
78
+ readonly decompressShowTokens: string
79
+ readonly submit: string
80
+ readonly language: string
81
+ readonly auto: string
82
+ readonly on: string
83
+ readonly off: string
84
+ }
85
+ readonly languages: {
86
+ readonly zh: string
87
+ readonly en: string
88
+ }
89
+ }
package/src/i18n/zh.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Messages } from "./types.ts"
2
+
3
+ /** 中文文案。标签控制在 4 个字以内,侧栏窄也能放下。 */
4
+ export const zh: Messages = {
5
+ title: "上下文构成",
6
+ labels: {
7
+ system: "系统",
8
+ tools: "工具定义",
9
+ tool: "工具结果",
10
+ text: "文本",
11
+ recent: "最近保留",
12
+ summaries: "摘要",
13
+ media: "媒体",
14
+ },
15
+ approximate: "≈",
16
+ terminal: "上下文无法继续压缩,请开新会话或使用原生压缩",
17
+ cacheTitle: "缓存记录",
18
+ cacheChart: "折线图",
19
+ cacheLog: "日志",
20
+ cacheInput: "输入",
21
+ cacheOutput: "输出",
22
+ cacheCached: "缓存",
23
+ cacheHit: "命中",
24
+ summariesTitle: "摘要块",
25
+ noData: "—",
26
+ command: {
27
+ title: "压缩上下文设置",
28
+ description: "打开插件设置",
29
+ group: "插件",
30
+ },
31
+ settings: {
32
+ title: "压缩上下文插件设置",
33
+ show: "显示面板",
34
+ limit: "上下文上限",
35
+ limitAuto: "模型原生",
36
+ limitCustom: "自定义上限…",
37
+ limitHint: "填的是上限;实际窗口取「设置值」和「模型原生窗口」中较小的那个。纯数字或带 k/m 后缀",
38
+ limitInvalid: "请输入有效的 token 数",
39
+ limitPlaceholder: "200k",
40
+ limitPreview: (value) => `= ${value} tokens`,
41
+ limitCapped: (requested, actual) => `${requested}(模型上限 ${actual})`,
42
+ threshold: "压缩阈值",
43
+ thresholdHint: "输入 60 ~ 95 之间的整数(百分比)",
44
+ thresholdInvalid: "阈值必须在 60 ~ 95 之间",
45
+ thresholdPreview: (value) => `= ${value}%`,
46
+ nudgeGrowth: "压缩提醒间隔",
47
+ nudgeGrowthHint: "上下文每增长这么多 token 提醒模型可主动压缩;0 关闭",
48
+ nudgeGrowthInvalid: "请输入 0,或 1k ~ 200k",
49
+ summaryBlockPercent: "单条摘要上限",
50
+ summaryBlockPercentHint: "单条摘要最多占窗口的百分比;可输入 10% 或 20k",
51
+ summaryTotalPercent: "全部摘要上限",
52
+ summaryTotalPercentHint: "所有摘要加起来最多占窗口的百分比;可输入 50% 或 100k",
53
+ summaryPercentInvalid: "请输入 1% ~ 90%,或一个能换算成百分比的 token 数",
54
+ windowKnown: (value) => `当前窗口:${value}`,
55
+ windowUnknown: "当前窗口未知(先跑一次模型请求)",
56
+ summaryLanguage: "摘要语言",
57
+ summaryFormat: "摘要格式",
58
+ summaryFormatJson: "JSON(结构化)",
59
+ summaryFormatMarkdown: "Markdown(自然语言)",
60
+ summaryFormatBabel: "Babel(模型间高密度)",
61
+ autoCompress: "自动压缩",
62
+ enableCompressTool: "compress 工具",
63
+ enableDecompressTool: "decompress 工具",
64
+ showTimeline: "块头时间线",
65
+ showBlockTokens: "块头 token 估算",
66
+ showCacheChart: "显示缓存折线图",
67
+ showCacheLog: "显示缓存日志",
68
+ showSummaries: "显示摘要块",
69
+ categories: {
70
+ compress: "压缩",
71
+ summary: "摘要",
72
+ panel: "面板",
73
+ decompress: "解压",
74
+ interface: "界面",
75
+ },
76
+ decompressInlineMax: "解压内联上限",
77
+ decompressInlineMaxHint: "超过这个字符数就写成临时文件;输入纯数字或带 k/m 后缀",
78
+ decompressInlineMaxInvalid: "请输入 1k ~ 1m 之间的字符数",
79
+ decompressTempCap: "解压临时文件上限",
80
+ decompressTempCapHint: "最多保留多少个解压临时文件",
81
+ decompressTempCapInvalid: "请输入 1 ~ 1000 之间的整数",
82
+ decompressShowTokens: "解压返回 token 估算",
83
+ submit: "提交",
84
+ language: "语言",
85
+ auto: "自动",
86
+ on: "开",
87
+ off: "关",
88
+ },
89
+ languages: {
90
+ zh: "中文",
91
+ en: "English",
92
+ },
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ import { maybeAutoCompress, maybeAutoCompressStep, maybeNudge } from "./compression/auto.ts"
4
+ import { recordCacheHistoryStep } from "./compression/cache-history.ts"
5
+ import { adoptNativeCheckpoint } from "./compression/adopt.ts"
6
+ import { handleCompaction } from "./compression/compaction.ts"
7
+ import { reconcileAll, startSessionEvents } from "./compression/events.ts"
8
+ import { registerSettingsRpc, type Disposable } from "./compression/settings-rpc.ts"
9
+ import { loadState } from "./compression/state.ts"
10
+ import { registerCompressTool } from "./compression/tool.ts"
11
+ import { registerDecompressTool } from "./compression/decompress-tool.ts"
12
+ import { createStats, mergeMetadata, shouldPublish, type ContextStats } from "./shared/stats.ts"
13
+ import { estimateCategories } from "./shared/tokens.ts"
14
+
15
+ export default Plugin.define({
16
+ id: "compressed-context",
17
+
18
+ /**
19
+ * 输入:OpenCode 注入的插件上下文 ctx。
20
+ * 输出:注册 compress 工具、设置 RPC、context / compaction 钩子与事件订阅。
21
+ * 预期:块链通过 compaction 落盘;失败不破坏历史。
22
+ */
23
+ async setup(ctx) {
24
+ const published = new Map<string, ContextStats>()
25
+ const disposables: Disposable[] = []
26
+
27
+ /** 输入:sessionID;输出:无;预期:会话空闲/删除时清掉进程内统计缓存。 */
28
+ const forgetMemory = (sessionID: string): void => {
29
+ published.delete(sessionID)
30
+ }
31
+
32
+ const publishStats = async (sessionID: string, stats: ContextStats): Promise<void> => {
33
+ const session = await ctx.session.get({ sessionID })
34
+ await ctx.session.update({
35
+ sessionID,
36
+ metadata: mergeMetadata(session.metadata, stats),
37
+ })
38
+ }
39
+
40
+ disposables.push(await registerCompressTool(ctx))
41
+ disposables.push(await registerDecompressTool(ctx))
42
+ disposables.push(await registerSettingsRpc(ctx))
43
+ const stopEvents = startSessionEvents(ctx, {
44
+ onSessionIdle: forgetMemory,
45
+ onSessionDeleted: forgetMemory,
46
+ onStepEnded: ({ sessionID, messageID, tokens }) =>
47
+ void (async () => {
48
+ await recordCacheHistoryStep(ctx, sessionID, { messageID, tokens })
49
+ await maybeAutoCompressStep({ ctx, sessionID, tokens })
50
+ })(),
51
+ })
52
+ void reconcileAll(ctx)
53
+
54
+ disposables.push(
55
+ await ctx.session.hook("context", async (event) => {
56
+ await adoptNativeCheckpoint(ctx, event.sessionID, event.messages)
57
+
58
+ await maybeNudge({
59
+ ctx,
60
+ sessionID: event.sessionID,
61
+ messages: event.messages,
62
+ system: event.system,
63
+ tools: event.tools,
64
+ model: event.model,
65
+ })
66
+
67
+ const stats = createStats(
68
+ estimateCategories({
69
+ system: event.system,
70
+ tools: event.tools,
71
+ messages: event.messages,
72
+ }),
73
+ Date.now(),
74
+ )
75
+ if (shouldPublish(published.get(event.sessionID), stats)) {
76
+ published.set(event.sessionID, stats)
77
+ void publishStats(event.sessionID, stats).catch(() => undefined)
78
+ }
79
+
80
+ await maybeAutoCompress({
81
+ ctx,
82
+ sessionID: event.sessionID,
83
+ messages: event.messages,
84
+ system: event.system,
85
+ tools: event.tools,
86
+ model: event.model,
87
+ })
88
+ }),
89
+ )
90
+
91
+ disposables.push(
92
+ await ctx.session.hook("compaction", (event) => handleCompaction(ctx, event)),
93
+ )
94
+
95
+ // 终止后拒绝一切新输入:把真实原因直接抛给宿主,TUI 会显示这条错误。
96
+ disposables.push(
97
+ await ctx.session.hook("prompt", async (event) => {
98
+ const state = await loadState(ctx.storage, event.sessionID)
99
+ if (state.terminal !== undefined) throw new Error(state.terminal)
100
+ }),
101
+ )
102
+
103
+ return () => {
104
+ stopEvents()
105
+ for (const disposable of disposables) void disposable.dispose()
106
+ }
107
+ },
108
+ })
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 摘要块的跨进程 DTO。
3
+ *
4
+ * 服务端把 active 链渲染成 body,TUI 只负责展示。
5
+ */
6
+ export interface SummaryBlock {
7
+ readonly id: string
8
+ readonly tier: 1 | 2 | 3
9
+ /** 摘要格式;Babel 正文不能按 Markdown 渲染。 */
10
+ readonly format: "json" | "markdown" | "babel"
11
+ readonly title: string
12
+ readonly at: number
13
+ readonly tokensBefore: number
14
+ readonly tokensAfter: number
15
+ readonly sourceBlockIds?: readonly string[]
16
+ readonly body: string
17
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * 缓存命中历史的跨进程载体。
3
+ *
4
+ * 服务端插件在 session.idle 时把最近 10 条 assistant 消息的 usage 追加到
5
+ * storage(`cache:history:<sessionID>`);TUI 通过 RPC 读取。
6
+ * 本模块只放纯函数:采样、追加、校验、排序,方便单测。
7
+ */
8
+
9
+ /** 每个会话一份,key 里带 sessionID,父/子会话互不影响。 */
10
+ export const CACHE_HISTORY_PREFIX = "cache:history:"
11
+
12
+ /** 只保留最近 10 次请求。 */
13
+ export const CACHE_HISTORY_LIMIT = 10
14
+
15
+ /** 一条缓存命中样本;hitRate 是 0..100,向下截断到两位小数。 */
16
+ export interface CacheSample {
17
+ readonly messageID: string
18
+ readonly at: number
19
+ readonly input: number
20
+ readonly output: number
21
+ readonly cached: number
22
+ readonly hitRate: number
23
+ }
24
+
25
+ /** storage 里的持久化结构。 */
26
+ export interface CacheHistory {
27
+ readonly v: 1
28
+ readonly samples: readonly CacheSample[]
29
+ }
30
+
31
+ export const EMPTY_CACHE_HISTORY: CacheHistory = { v: 1, samples: [] }
32
+
33
+ /**
34
+ * 输入:总输入 token + 命中 token。
35
+ * 输出:0..100 的百分比,向下截断到两位小数。
36
+ * 预期:不四舍五入;99.999% 显示 99.99,不会变成 100.00。
37
+ */
38
+ export function hitRatePercent(input: number, cached: number): number {
39
+ if (input <= 0) return 0
40
+ return Math.floor(Math.min(1, cached / input) * 10_000) / 100
41
+ }
42
+
43
+ /** 输入:命中率;输出:固定两位小数的百分比文本(不含 `%`)。 */
44
+ export function formatHitRate(hitRate: number): string {
45
+ return hitRate.toFixed(2)
46
+ }
47
+
48
+ /** 输入:sessionID;输出:该会话的 storage key。 */
49
+ export function cacheHistoryKey(sessionID: string): string {
50
+ return CACHE_HISTORY_PREFIX + sessionID
51
+ }
52
+
53
+ /**
54
+ * 输入:消息(结构最小化,便于测试替身)。
55
+ * 输出:assistant 且带 tokens 时返回样本,否则 undefined。
56
+ */
57
+ export interface CacheMessage {
58
+ readonly id: string
59
+ readonly type: string
60
+ readonly time?: { readonly created?: number }
61
+ readonly tokens?: {
62
+ readonly input: number
63
+ readonly output: number
64
+ readonly reasoning: number
65
+ readonly cache: { readonly read: number; readonly write: number }
66
+ }
67
+ }
68
+
69
+ /** 输入:消息 + 缺时间时的回退值;输出:样本或 undefined。 */
70
+ export function sampleFromMessage(message: CacheMessage, fallbackAt: number): CacheSample | undefined {
71
+ if (message.type !== "assistant") return undefined
72
+ const tokens = message.tokens
73
+ if (tokens === undefined) return undefined
74
+ const input = tokens.input + tokens.cache.read + tokens.cache.write
75
+ const output = tokens.output + tokens.reasoning
76
+ const cached = tokens.cache.read
77
+ return {
78
+ messageID: message.id,
79
+ at: message.time?.created ?? fallbackAt,
80
+ input,
81
+ output,
82
+ cached,
83
+ hitRate: hitRatePercent(input, cached),
84
+ }
85
+ }
86
+
87
+ /** 输入:样本列表;输出:按时间升序、去重、只留最新 10 条。 */
88
+ function normalizeSamples(samples: readonly CacheSample[]): readonly CacheSample[] {
89
+ const unique = new Map<string, CacheSample>()
90
+ for (const sample of samples) {
91
+ // 同一条消息重复出现时保留第一次记录,避免重试把同一条刷成多条。
92
+ if (!unique.has(sample.messageID)) unique.set(sample.messageID, sample)
93
+ }
94
+ return [...unique.values()]
95
+ .sort((left, right) => left.at - right.at || left.messageID.localeCompare(right.messageID))
96
+ .slice(-CACHE_HISTORY_LIMIT)
97
+ }
98
+
99
+ /**
100
+ * 输入:已有历史 + 当前可见消息。
101
+ * 输出:追加未记录过的 assistant 样本后的新历史。
102
+ * 预期:按 messageID 去重;按时间排序;只保留最新 10 条。
103
+ */
104
+ export function appendCacheSamples(
105
+ existing: readonly CacheSample[],
106
+ messages: readonly CacheMessage[],
107
+ now: number = Date.now(),
108
+ ): readonly CacheSample[] {
109
+ const known = new Set(existing.map((sample) => sample.messageID))
110
+ const added: CacheSample[] = []
111
+ for (const message of messages) {
112
+ if (known.has(message.id)) continue
113
+ const sample = sampleFromMessage(message, now)
114
+ if (sample === undefined) continue
115
+ known.add(sample.messageID)
116
+ added.push(sample)
117
+ }
118
+ return normalizeSamples([...existing, ...added])
119
+ }
120
+
121
+ /**
122
+ * 输入:已有历史 + 单个 step 的 assistant usage。
123
+ * 输出:追加该 step 样本后的新历史。
124
+ * 预期:tool loop 里 context/usage.updated 拿不到最新 step usage,
125
+ * `session.step.ended` 事件直接携带 tokens,这里按 messageID 去重落一条。
126
+ */
127
+ export function appendCacheSampleFromStep(
128
+ existing: readonly CacheSample[],
129
+ messageID: string,
130
+ tokens: {
131
+ readonly input: number
132
+ readonly output: number
133
+ readonly reasoning: number
134
+ readonly cache: { readonly read: number; readonly write: number }
135
+ },
136
+ fallbackAt: number = Date.now(),
137
+ ): readonly CacheSample[] {
138
+ return appendCacheSamples(existing, [{ id: messageID, type: "assistant", tokens }], fallbackAt)
139
+ }
140
+
141
+ /** 输入:任意 storage 值;输出:合法历史;损坏时返回空历史,绝不抛错。hitRate 按 input/cached 重算。 */
142
+ export function parseCacheHistory(value: unknown): CacheHistory {
143
+ if (typeof value !== "object" || value === null) return EMPTY_CACHE_HISTORY
144
+ const record = value as Record<string, unknown>
145
+ if (record["v"] !== 1 || !Array.isArray(record["samples"])) return EMPTY_CACHE_HISTORY
146
+ const samples: CacheSample[] = []
147
+ for (const item of record["samples"]) {
148
+ if (typeof item !== "object" || item === null) continue
149
+ const sample = item as Record<string, unknown>
150
+ const messageID = sample["messageID"]
151
+ const at = sample["at"]
152
+ const input = sample["input"]
153
+ const output = sample["output"]
154
+ const cached = sample["cached"]
155
+ if (typeof messageID !== "string" || messageID.length === 0) continue
156
+ if (
157
+ typeof at !== "number" ||
158
+ !Number.isFinite(at) ||
159
+ at < 0 ||
160
+ typeof input !== "number" ||
161
+ !Number.isFinite(input) ||
162
+ input < 0 ||
163
+ typeof output !== "number" ||
164
+ !Number.isFinite(output) ||
165
+ output < 0 ||
166
+ typeof cached !== "number" ||
167
+ !Number.isFinite(cached) ||
168
+ cached < 0
169
+ ) {
170
+ continue
171
+ }
172
+ samples.push({
173
+ messageID,
174
+ at,
175
+ input,
176
+ output,
177
+ cached,
178
+ hitRate: hitRatePercent(input, cached),
179
+ })
180
+ }
181
+ return { v: 1, samples: normalizeSamples(samples) }
182
+ }
183
+
184
+ /** 输入:两条历史;输出:样本是否完全一致;用于避免无变化的重复写盘。 */
185
+ export function cacheHistoryEquals(left: readonly CacheSample[], right: readonly CacheSample[]): boolean {
186
+ if (left.length !== right.length) return false
187
+ return left.every((sample, index) => {
188
+ const other = right[index]
189
+ if (other === undefined) return false
190
+ return (
191
+ sample.messageID === other.messageID &&
192
+ sample.at === other.at &&
193
+ sample.input === other.input &&
194
+ sample.output === other.output &&
195
+ sample.cached === other.cached &&
196
+ sample.hitRate === other.hitRate
197
+ )
198
+ })
199
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 面板数字格式化。
3
+ *
4
+ * 输入:token 数量(非负整数)。
5
+ * 输出:紧凑字符串:<1000 原样、<10000 一位小数 k、<1e6 取整 k、
6
+ * <1e7 一位小数 M、更大取整 M。
7
+ * 预期:侧栏一行内放得下。
8
+ */
9
+ export function formatTokens(count: number): string {
10
+ if (!Number.isFinite(count) || count <= 0) return "0"
11
+ if (count < 1_000) return count.toString()
12
+ if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`
13
+ if (count < 1_000_000) return `${Math.round(count / 1_000).toString()}k`
14
+ if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`
15
+ return `${Math.round(count / 1_000_000).toString()}M`
16
+ }
17
+
18
+ /** 输入:毫秒时间戳;输出:本地时间 `YYYY/MM/DD HH:mm`;预期:固定格式,不依赖 locale。 */
19
+ export function formatTimestamp(at: number): string {
20
+ const date = new Date(at)
21
+ const pad = (value: number): string => value.toString().padStart(2, "0")
22
+ return `${date.getFullYear().toString()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
23
+ }