token-stats-timer 1.0.18 → 1.1.2
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.en.md +149 -0
- package/README.md +29 -1
- package/index.ts +11 -0
- package/notify.ts +35 -19
- package/package.json +11 -3
- package/step-timer.ts +2 -1
- package/thinking-memory.ts +157 -0
- package/token-stats.ts +108 -93
- package/user-language.ts +85 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# token-stats-timer
|
|
2
|
+
|
|
3
|
+
> [简体中文](README.md) | English (current)
|
|
4
|
+
|
|
5
|
+
A modified version of `@liziy/token-stats`.
|
|
6
|
+
|
|
7
|
+
One extension, one footer — run timing plus token usage/quota monitoring in a single plugin.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
Footer, top line (left-aligned):
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
↑12k ↓3.4k CH87% ⚡77.7 t/s 5.3%/1.0M | 5h: 87% W: 92% ⏱ 2h 15m
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `prev` / `max` —— duration of the previous run / longest run in this session branch (with a 15-char prompt preview)
|
|
18
|
+
- `↑ ↓ Σ CH` —— cumulative input / output / total / cache hit rate
|
|
19
|
+
- `⚡` —— live speed (2s rolling window; falls back to the average speed while idle)
|
|
20
|
+
- Context usage (style configurable)
|
|
21
|
+
- `5h: W: ⏱` —— quota remaining (built-in plans for MiniMax / GLM / Kimi / DeepSeek / OpenCode Go; enable per provider in `/stats`)
|
|
22
|
+
|
|
23
|
+
Footer, bottom line: cwd + git branch + statuses from other extensions.
|
|
24
|
+
|
|
25
|
+
## Per-model thinking level memory (thinking-memory)
|
|
26
|
+
|
|
27
|
+
No manual setup command: when you **manually switch the thinking level, it is recorded automatically** as the default for the current model, and restored on `session_start` / model switch. Inspired by the per-model preference mechanism of `@tifan/pi-preferred-thinking`, but with your actual switching behavior as the memory source (enabled by default):
|
|
28
|
+
|
|
29
|
+
- Manually change the thinking level (keybinding / `/thinking` / settings UI) → recorded for the current model
|
|
30
|
+
- `session_start` / `model_select` → the remembered level for that model is applied automatically
|
|
31
|
+
- Level changes caused by the extension's own `setThinkingLevel` or by model-switch clamping (e.g. a model that doesn't support `max`) are never misrecorded
|
|
32
|
+
|
|
33
|
+
- `/auto-remember-thinking-level` — no arg shows status; `on` / `off` enable or disable (enabled by default)
|
|
34
|
+
|
|
35
|
+
Config: `~/.pi/agent/extensions/token-stats/auto-remember-thinking-level.json`
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"enabled": true,
|
|
40
|
+
"levels": {
|
|
41
|
+
"opencode-go/deepseek-v4-flash": "max"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> If the original `@tifan/pi-preferred-thinking` is also installed, its session_start auto-apply overrides this feature and vice versa — install only one of them.
|
|
47
|
+
|
|
48
|
+
## macOS completion notifications
|
|
49
|
+
|
|
50
|
+
A system notification is posted when each run (agent task) finishes, distinguishing two outcomes:
|
|
51
|
+
|
|
52
|
+
- ✅ pi task finished — normal completion (with duration)
|
|
53
|
+
- ⏹ pi aborted — aborted via Esc
|
|
54
|
+
|
|
55
|
+
A separate 👋 notification on session close (optional).
|
|
56
|
+
|
|
57
|
+
### Delivery channels (automatic fallback, best available wins)
|
|
58
|
+
|
|
59
|
+
1. **OSC terminal protocol** — writes an escape sequence to the terminal; the **terminal app itself** shows the notification, so the sender is the terminal (no osascript needed):
|
|
60
|
+
- **iTerm2 → OSC 9** (`ESC]9;content`, the notification sequence from iTerm2's official docs, **verified working**)
|
|
61
|
+
- Ghostty / WezTerm / Hyper / rxvt-unicode → OSC 777 (`ESC]777;notify;title;content`)
|
|
62
|
+
> ⚠️ Note: OSC 777 is silently ignored on iTerm2 3.5.x (supported since 3.6.9). The official pi example (notify.ts) only sends OSC 777, which does nothing on iTerm2 — this plugin selects the sequence per terminal.
|
|
63
|
+
2. **terminal-notifier** — used automatically once `brew install terminal-notifier` is done; most reliable at the system level (`-sender` sets ownership, `-group` dedupes).
|
|
64
|
+
3. **osascript** — last-resort fallback (when no terminal protocol is available).
|
|
65
|
+
|
|
66
|
+
> Why not pure osascript: since macOS Sequoia, notifications are attributed to the *calling process*. pi is a node process that cannot be authorized in System Settings, so the notification is silently dropped (the script exits 0 but no banner appears). OSC sequences re-attribute the notification to the terminal app, which can be authorized and shows banners normally.
|
|
67
|
+
|
|
68
|
+
### Configuration
|
|
69
|
+
|
|
70
|
+
Config file: `~/.pi/agent/extensions/token-stats/notify-config.json` (defaults apply if missing)
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"enabled": true,
|
|
75
|
+
"minDurationSec": 0,
|
|
76
|
+
"sound": "Glass",
|
|
77
|
+
"onSuccess": true,
|
|
78
|
+
"onFailure": true,
|
|
79
|
+
"onAbort": true,
|
|
80
|
+
"onSessionEnd": true
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `enabled` — master switch (also togglable via `/notify on|off`)
|
|
85
|
+
- `minDurationSec` — skip notifications for runs shorter than this many seconds (e.g. 30 avoids noise from instant replies)
|
|
86
|
+
- `sound` — notification sound (built-in macOS sounds: Glass/Ping/Sosumi/Hero/Funk…; `""` for silent; note only the osascript/terminal-notifier channels support sound — OSC 777 has no sound parameter)
|
|
87
|
+
- `onSuccess/onFailure/onAbort/onSessionEnd` — per-category switches
|
|
88
|
+
|
|
89
|
+
`/notify` — no arg shows current status; `on`/`off` toggle; `test` immediately sends a test notification to verify the channel.
|
|
90
|
+
|
|
91
|
+
## Run timing (step-timer)
|
|
92
|
+
|
|
93
|
+
- While working: the spinner text shows `Working... 01:02` (elapsed time, refreshed every second)
|
|
94
|
+
- On completion: an entry is appended at the end of the session showing the total duration:
|
|
95
|
+
|
|
96
|
+
`Total time 01:23`
|
|
97
|
+
|
|
98
|
+
Persisted via `appendEntry`, kept out of the LLM context, and still visible after `/resume`.
|
|
99
|
+
|
|
100
|
+
No separate switch — always on with the package; timing semantics match the run timer (one run = first `agent_start` → `agent_settled`, including retries, compaction recovery and queued prompts).
|
|
101
|
+
|
|
102
|
+
## Commands
|
|
103
|
+
|
|
104
|
+
- `/stats` — no arg shows **today's token stats** (same as `/stats day`)
|
|
105
|
+
- `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` — stats queries
|
|
106
|
+
- `/stats limit` — quota plan config (pick/disable the plan shown for the current provider; picking GLM asks whether to configure team credentials)
|
|
107
|
+
- `/stats config` — display style / display items / quota refresh interval / GLM team credentials
|
|
108
|
+
- `/notify [on|off|test]` — notifications toggle / test (no arg shows status)
|
|
109
|
+
- `/auto-remember-thinking-level [on|off]` — thinking-level memory toggle (no arg shows status)
|
|
110
|
+
|
|
111
|
+
## GLM Team Plan
|
|
112
|
+
|
|
113
|
+
Personal and team plans share `GET /api/monitor/usage/quota/limit`; the only difference is the request headers — the team plan requires the `Bigmodel-Organization` / `Bigmodel-Project` headers plus `?type=2` (api_key + organization ID + project ID, all three required; the team tier only exists on the China endpoint `open.bigmodel.cn`).
|
|
114
|
+
|
|
115
|
+
This plugin uses the team query **only when both the organization ID and the project ID are configured**, otherwise it falls back to the personal query:
|
|
116
|
+
|
|
117
|
+
- After enabling the GLM plan (`/stats limit` → GLM), a team-credential prompt appears automatically — "✏️ Configure / Edit" or "Skip"
|
|
118
|
+
- Change or clear it any time via `/stats config` → "GLM team credentials"
|
|
119
|
+
- Credentials are stored in the `teamCredential` field of `~/.pi/agent/extensions/token-stats/config.json`:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"providerPlans": { "zai-coding-cn": "glm" },
|
|
124
|
+
"teamCredential": { "organization": "your-org-id", "project": "your-project-id" },
|
|
125
|
+
"ttl": 60
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
> Organization ID / Project ID can be found on the "Team Coding Plan" page of the GLM Coding Plan team console; if you already configure them as environment variables for Claude Code / Cursor or similar tools, you can also put the same values into config.json directly.
|
|
130
|
+
|
|
131
|
+
## OpenCode Go balance
|
|
132
|
+
|
|
133
|
+
The OpenCode Go subscription (`opencode-go` provider, baseUrl `https://opencode.ai/zen/go/v1`) uses the official quota endpoint `GET /zen/go/v1/usage` (`Authorization: Bearer <key>` — the `key` of `opencode-go` in auth.json or the `OPENCODE_API_KEY` env var), returning the used percentage of three rolling windows: 5 hours / week / month.
|
|
134
|
+
|
|
135
|
+
The footer shows `5h: X% W: Y% M: Z% ⏱ ...` (remaining = 100 − used); each of the last three items can be toggled via `/stats config` → Display items → "5h quota / Week quota / Month quota / Refresh time".
|
|
136
|
+
|
|
137
|
+
## Compatibility with the original packages
|
|
138
|
+
|
|
139
|
+
- Display/quota config reuses `~/.pi/agent/extensions/token-stats/` (existing token-stats config takes effect as-is)
|
|
140
|
+
- Stats logs reuse `~/.pi/agent/extensions/token-stats-logs/` (historical data queryable via `/stats`)
|
|
141
|
+
|
|
142
|
+
## Installation (replaces the original packages)
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pi remove npm:@liziy/token-stats
|
|
146
|
+
pi install npm:token-stats-timer
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Run `/reload` or restart pi to activate.
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# token-stats-timer
|
|
2
2
|
|
|
3
|
+
> [English](README.en.md) | 简体中文(当前)
|
|
4
|
+
|
|
3
5
|
`@liziy/token-stats` 的修改版本。
|
|
4
6
|
|
|
5
7
|
一个扩展、一个 footer,同时提供 run 计时与 token 用量/配额监控。
|
|
@@ -20,6 +22,31 @@ Footer 上行(左对齐):
|
|
|
20
22
|
|
|
21
23
|
Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
22
24
|
|
|
25
|
+
## 按模型自动记忆思考强度(thinking-memory)
|
|
26
|
+
|
|
27
|
+
不用命令手动设置,**手动切换思考强度时自动记录**为当前模型的默认级别,
|
|
28
|
+
会话启动 / 切换模型时自动恢复。参考 `@tifan/pi-preferred-thinking` 的按模型偏好机制,
|
|
29
|
+
改为以实际切换行为作为记忆来源(默认开启):
|
|
30
|
+
|
|
31
|
+
- 手动切换 thinking level(快捷键 / /thinking / 设置界面)→ 自动记录到当前模型
|
|
32
|
+
- `session_start` / `model_select` → 自动应用该模型记忆的级别
|
|
33
|
+
- 自身 `setThinkingLevel` 与模型切换引发的级别变化(如不支持 max 被 clamp)不会误记录
|
|
34
|
+
|
|
35
|
+
- `/auto-remember-thinking-level` —— 无参查看状态;`on` / `off` 启用或禁用(默认开启)
|
|
36
|
+
|
|
37
|
+
配置:`~/.pi/agent/extensions/token-stats/auto-remember-thinking-level.json`
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"enabled": true,
|
|
42
|
+
"levels": {
|
|
43
|
+
"opencode-go/deepseek-v4-flash": "max"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> 若同时安装原 `@tifan/pi-preferred-thinking`,其 session_start 自动应用会与本功能互相覆盖,建议二选一。
|
|
49
|
+
|
|
23
50
|
## macOS 完成通知
|
|
24
51
|
|
|
25
52
|
每次 run(agent 任务)结束后弹系统通知,区分两种结果:
|
|
@@ -81,6 +108,7 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
81
108
|
- `/stats limit` —— 套餐配置(为当前 provider 选择/关闭配额套餐;选 GLM 后会继续询问是否配置团队套餐凭证)
|
|
82
109
|
- `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间 / GLM 团队凭证
|
|
83
110
|
- `/notify [on|off|test]` —— 通知开关 / 测试(无参查看状态)
|
|
111
|
+
- `/auto-remember-thinking-level [on|off]` —— 自动记忆思考强度开关(无参查看状态)
|
|
84
112
|
|
|
85
113
|
## GLM 团队套餐(Team Plan)
|
|
86
114
|
|
|
@@ -88,7 +116,7 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
88
116
|
|
|
89
117
|
本插件在**组织 ID 与项目 ID 都配置**时才走团队查询,否则回退个人版查询:
|
|
90
118
|
|
|
91
|
-
- 启用 GLM 套餐后(`/stats` 选 GLM)会自动弹出团队凭证配置询问,可「✏️ 配置/修改」或「跳过」
|
|
119
|
+
- 启用 GLM 套餐后(`/stats limit` 选 GLM)会自动弹出团队凭证配置询问,可「✏️ 配置/修改」或「跳过」
|
|
92
120
|
- 随时可通过 `/stats config` → 「GLM 团队凭证」修改或清除
|
|
93
121
|
- 凭证保存在 `~/.pi/agent/extensions/token-stats/config.json` 的 `teamCredential` 字段:
|
|
94
122
|
|
package/index.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// 模块划分:
|
|
11
11
|
// run-timer.ts —— run 计时状态机 + session 持久化(原 pi-run-timer)
|
|
12
12
|
// token-stats.ts —— token 统计 + 套餐配额 + JSONL 日志 + /stats(原 token-stats)
|
|
13
|
+
// notify.ts —— macOS 完成通知(成功/失败/中止)
|
|
14
|
+
// step-timer.ts —— 任务计时(Working 实时耗时 + 总耗时汇总)
|
|
15
|
+
// thinking-memory.ts —— 按模型自动记忆思考强度(手动切换时记录,默认开启)
|
|
13
16
|
//
|
|
14
17
|
// 与两个原包的兼容性:
|
|
15
18
|
// - 配置沿用 ~/.pi/agent/extensions/token-stats/{config.json,display-config.json}
|
|
@@ -21,6 +24,8 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
21
24
|
import { createTokenStats, formatUserPath, type SharedState } from "./token-stats.ts";
|
|
22
25
|
import { createNotifier } from "./notify.ts";
|
|
23
26
|
import { createStepTimer } from "./step-timer.ts";
|
|
27
|
+
import { createThinkingMemory } from "./thinking-memory.ts";
|
|
28
|
+
import { createUserLanguage } from "./user-language.ts";
|
|
24
29
|
|
|
25
30
|
const shared: SharedState = {
|
|
26
31
|
sessionActive: false,
|
|
@@ -29,10 +34,16 @@ const shared: SharedState = {
|
|
|
29
34
|
|
|
30
35
|
export default function runTokenStatsExtension(pi: ExtensionAPI) {
|
|
31
36
|
const stats = createTokenStats(pi, shared);
|
|
37
|
+
|
|
38
|
+
// 语言判断最先初始化:其他模块的文案随用户语言切换
|
|
39
|
+
createUserLanguage(pi);
|
|
40
|
+
|
|
32
41
|
// macOS 完成通知(成功/失败/中止),配置见 notify-config.json
|
|
33
42
|
createNotifier(pi);
|
|
34
43
|
// 每步耗时:Thinking.../Working... 实时耗时 + 每 turn/总耗时会话摘要
|
|
35
44
|
createStepTimer(pi);
|
|
45
|
+
// 按模型自动记忆思考强度(/auto-remember-thinking-level),默认开启
|
|
46
|
+
createThinkingMemory(pi);
|
|
36
47
|
|
|
37
48
|
pi.on("session_start", (_event, ctx) => {
|
|
38
49
|
shared.sessionActive = true;
|
package/notify.ts
CHANGED
|
@@ -28,8 +28,7 @@
|
|
|
28
28
|
// 便于排查"没弹通知"是触发问题还是投递问题。
|
|
29
29
|
|
|
30
30
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
32
|
-
import {
|
|
31
|
+
import { spawn, spawnSync } from "node:child_process";import {
|
|
33
32
|
appendFileSync,
|
|
34
33
|
existsSync,
|
|
35
34
|
mkdirSync,
|
|
@@ -38,6 +37,7 @@ import {
|
|
|
38
37
|
} from "node:fs";
|
|
39
38
|
import { homedir } from "node:os";
|
|
40
39
|
import { join } from "node:path";
|
|
40
|
+
import { t } from "./user-language.ts";
|
|
41
41
|
|
|
42
42
|
const CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
|
|
43
43
|
const CONFIG_FILE = join(CONFIG_DIR, "notify-config.json");
|
|
@@ -238,15 +238,16 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
238
238
|
if (runAborted) {
|
|
239
239
|
if (config.onAbort) {
|
|
240
240
|
notify(
|
|
241
|
-
"⏹ pi 已中止",
|
|
242
|
-
`耗时:${dur}
|
|
241
|
+
t("⏹ pi 已中止", "⏹ pi aborted"),
|
|
242
|
+
t(`耗时:${dur}`, `Duration: ${dur}`) +
|
|
243
|
+
`\n${truncate(t("执行被中止", "Execution aborted"))}`,
|
|
243
244
|
config.sound,
|
|
244
245
|
);
|
|
245
246
|
}
|
|
246
247
|
} else if (config.onSuccess) {
|
|
247
248
|
notify(
|
|
248
|
-
"✅ pi 执行完成",
|
|
249
|
-
`耗时:${dur}`,
|
|
249
|
+
t("✅ pi 执行完成", "✅ pi task finished"),
|
|
250
|
+
t(`耗时:${dur}`, `Duration: ${dur}`),
|
|
250
251
|
config.sound,
|
|
251
252
|
);
|
|
252
253
|
}
|
|
@@ -258,42 +259,57 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
258
259
|
// 会话关闭时提示(可选)
|
|
259
260
|
pi.on("session_shutdown", () => {
|
|
260
261
|
if (config.enabled && config.onSessionEnd) {
|
|
261
|
-
notify("👋 pi 会话结束", "session 已关闭", config.sound);
|
|
262
|
+
notify(t("👋 pi 会话结束", "👋 pi session ended"), t("session 已关闭", "Session closed"), config.sound);
|
|
262
263
|
}
|
|
263
264
|
});
|
|
264
265
|
|
|
265
266
|
// ── /notify 命令 ─────────────────────────────────────
|
|
266
267
|
pi.registerCommand("notify", {
|
|
267
|
-
description:
|
|
268
|
+
description: t(
|
|
269
|
+
"macOS 完成通知: on | off | status | test(详细配置见 notify-config.json)",
|
|
270
|
+
"macOS completion notifications: on | off | status | test (see notify-config.json)",
|
|
271
|
+
),
|
|
268
272
|
handler: async (args, ctx) => {
|
|
269
273
|
const arg = args.trim();
|
|
270
274
|
if (arg === "on") {
|
|
271
275
|
config = { ...config, enabled: true };
|
|
272
276
|
saveConfig(config);
|
|
273
|
-
ctx.ui.notify("完成通知已开启", "info");
|
|
277
|
+
ctx.ui.notify(t("完成通知已开启", "Completion notifications enabled"), "info");
|
|
274
278
|
} else if (arg === "off") {
|
|
275
279
|
config = { ...config, enabled: false };
|
|
276
280
|
saveConfig(config);
|
|
277
|
-
ctx.ui.notify("完成通知已关闭", "info");
|
|
281
|
+
ctx.ui.notify(t("完成通知已关闭", "Completion notifications disabled"), "info");
|
|
278
282
|
} else if (arg === "test") {
|
|
279
283
|
// 立即发一条测试通知,验证当前投递通道是否可达
|
|
280
|
-
const title = "🔔 pi 通知测试";
|
|
284
|
+
const title = t("🔔 pi 通知测试", "🔔 pi notification test");
|
|
281
285
|
const channel = isITerm2()
|
|
282
|
-
? "osc9 终端协议"
|
|
286
|
+
? t("osc9 终端协议", "osc9 terminal protocol")
|
|
283
287
|
: supportsOSC777()
|
|
284
|
-
? "osc777 终端协议"
|
|
288
|
+
? t("osc777 终端协议", "osc777 terminal protocol")
|
|
285
289
|
: existsSync("/opt/homebrew/bin/terminal-notifier") ||
|
|
286
290
|
existsSync("/usr/local/bin/terminal-notifier")
|
|
287
291
|
? "terminal-notifier"
|
|
288
292
|
: "osascript";
|
|
289
|
-
notify(
|
|
290
|
-
|
|
293
|
+
notify(
|
|
294
|
+
title,
|
|
295
|
+
t(`投递通道:${channel}\n时间:${new Date().toLocaleTimeString()}`,
|
|
296
|
+
`Channel: ${channel}\nTime: ${new Date().toLocaleTimeString()}`),
|
|
297
|
+
config.sound,
|
|
298
|
+
);
|
|
299
|
+
ctx.ui.notify(t("测试通知已发送,请查看系统通知栏", "Test notification sent, check Notification Center"), "info");
|
|
291
300
|
} else {
|
|
292
|
-
const sound = config.sound
|
|
301
|
+
const sound = config.sound
|
|
302
|
+
? t(`, 声音=${config.sound}`, `, sound=${config.sound}`)
|
|
303
|
+
: t(", 静音", ", silent");
|
|
293
304
|
ctx.ui.notify(
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
305
|
+
t(
|
|
306
|
+
`完成通知: ${config.enabled ? "开" : "关"}, 最短时长=${config.minDurationSec}s, ` +
|
|
307
|
+
`成功=${config.onSuccess ? "开" : "关"}, ` +
|
|
308
|
+
`中止=${config.onAbort ? "开" : "关"}, 会话结束=${config.onSessionEnd ? "开" : "关"}${sound}`,
|
|
309
|
+
`Notifications: ${config.enabled ? "on" : "off"}, min duration=${config.minDurationSec}s, ` +
|
|
310
|
+
`success=${config.onSuccess ? "on" : "off"}, ` +
|
|
311
|
+
`abort=${config.onAbort ? "on" : "off"}, session end=${config.onSessionEnd ? "on" : "off"}${sound}`,
|
|
312
|
+
),
|
|
297
313
|
"info",
|
|
298
314
|
);
|
|
299
315
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-stats-timer",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "pi extension to display token usage and time spend.",
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "pi extension to display token usage and time spend. Persist preferred thinking level per model in pi",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension"
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
"token-stats.ts",
|
|
17
17
|
"notify.ts",
|
|
18
18
|
"step-timer.ts",
|
|
19
|
-
"
|
|
19
|
+
"thinking-memory.ts",
|
|
20
|
+
"user-language.ts",
|
|
21
|
+
"README.md",
|
|
22
|
+
"README.en.md"
|
|
20
23
|
],
|
|
21
24
|
"scripts": {
|
|
22
25
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
@@ -26,6 +29,11 @@
|
|
|
26
29
|
"./index.ts"
|
|
27
30
|
]
|
|
28
31
|
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/i7guokui/token-stats-timer.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/i7guokui/token-stats-timer/tree/main#readme",
|
|
29
37
|
"peerDependencies": {
|
|
30
38
|
"@earendil-works/pi-ai": "*",
|
|
31
39
|
"@earendil-works/pi-coding-agent": "*",
|
package/step-timer.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import { t } from "./user-language.ts";
|
|
13
14
|
|
|
14
15
|
/** 一次 run 的总耗时汇总(appendEntry "timing-final") */
|
|
15
16
|
export interface FinalTimingData {
|
|
@@ -95,7 +96,7 @@ export function createStepTimer(pi: ExtensionAPI): void {
|
|
|
95
96
|
pi.registerEntryRenderer<FinalTimingData>(FINAL_TYPE, (entry, _opts, theme) => {
|
|
96
97
|
const d = entry.data;
|
|
97
98
|
if (!d) return undefined;
|
|
98
|
-
const title = theme.fg("accent", "总耗时");
|
|
99
|
+
const title = theme.fg("accent", t("总耗时", "Total time"));
|
|
99
100
|
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
100
101
|
box.addChild(new Text(`${title} ${formatDuration(d.totalMs)}`, 0, 0));
|
|
101
102
|
return box;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// thinking-memory 模块 —— 按模型自动记忆思考强度
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// 行为(默认开启,/auto-remember-thinking-level on|off 切换):
|
|
4
|
+
// - 用户手动切换 thinking level 时,自动记录为当前模型的默认级别
|
|
5
|
+
// - session_start / model_select 时自动应用该模型记忆的级别
|
|
6
|
+
// - 扩展自身 setThinkingLevel 与模型切换引起的级别变化不会被误记录
|
|
7
|
+
//
|
|
8
|
+
// 配置:~/.pi/agent/extensions/token-stats/auto-remember-thinking-level.json
|
|
9
|
+
// {
|
|
10
|
+
// "enabled": true,
|
|
11
|
+
// "levels": { "opencode-go/deepseek-v4-flash": "max" }
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// 参考 @tifan/pi-preferred-thinking(手动设置命令)改为自动记忆模式:不提供
|
|
15
|
+
// 单独设置命令,以用户的实际切换行为作为记忆来源。
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { t } from "./user-language.ts";
|
|
22
|
+
|
|
23
|
+
type ThinkingLevel = Parameters<ExtensionAPI["setThinkingLevel"]>[0];
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
|
|
26
|
+
const CONFIG_FILE = join(CONFIG_DIR, "auto-remember-thinking-level.json");
|
|
27
|
+
|
|
28
|
+
const VALID_LEVELS = new Set<ThinkingLevel>([
|
|
29
|
+
"off",
|
|
30
|
+
"minimal",
|
|
31
|
+
"low",
|
|
32
|
+
"medium",
|
|
33
|
+
"high",
|
|
34
|
+
"xhigh",
|
|
35
|
+
"max",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
interface ThinkingMemoryConfig {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
levels: Record<string, ThinkingLevel>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_CONFIG: ThinkingMemoryConfig = { enabled: true, levels: {} };
|
|
44
|
+
|
|
45
|
+
/** 判定「手动切换」的窗口:距模型切换多少毫秒内的级别变化视为切换引起 */
|
|
46
|
+
const MODEL_SWITCH_WINDOW_MS = 200;
|
|
47
|
+
/** 自身 apply 引起的级别变化要在多少毫秒内匹配上才算(防止残留标志误吞手动切换) */
|
|
48
|
+
const APPLY_WINDOW_MS = 100;
|
|
49
|
+
|
|
50
|
+
function loadConfig(): ThinkingMemoryConfig {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG, levels: {} };
|
|
53
|
+
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as unknown;
|
|
54
|
+
const cfg = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : {};
|
|
55
|
+
const levels: Record<string, ThinkingLevel> = {};
|
|
56
|
+
const configured = cfg.levels;
|
|
57
|
+
if (configured && typeof configured === "object" && !Array.isArray(configured)) {
|
|
58
|
+
for (const [modelKey, level] of Object.entries(configured)) {
|
|
59
|
+
if (typeof level === "string" && VALID_LEVELS.has(level as ThinkingLevel)) {
|
|
60
|
+
levels[modelKey] = level as ThinkingLevel;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { enabled: cfg.enabled !== false, levels };
|
|
65
|
+
} catch {
|
|
66
|
+
return { ...DEFAULT_CONFIG, levels: {} };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function saveConfig(cfg: ThinkingMemoryConfig): void {
|
|
71
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
72
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createThinkingMemory(pi: ExtensionAPI): void {
|
|
76
|
+
let cfg = loadConfig();
|
|
77
|
+
|
|
78
|
+
// 自身 apply 的记录:apply 时间 + 目标级别,事件匹配且窗口内则视为自身引起
|
|
79
|
+
let lastApply: { at: number; level: ThinkingLevel } | null = null;
|
|
80
|
+
// 最近一次模型切换时间戳(判断级别变化是否由切模型/clamp 引起)
|
|
81
|
+
let lastModelSwitchAt = 0;
|
|
82
|
+
|
|
83
|
+
/** 应用当前模型的记忆级别(自身 setThinkingLevel 不会触发误记录) */
|
|
84
|
+
function applyForModel(ctx: ExtensionContext): void {
|
|
85
|
+
if (!cfg.enabled) return;
|
|
86
|
+
if (!ctx.model) return;
|
|
87
|
+
const modelKey = `${ctx.model.provider}/${ctx.model.id}`;
|
|
88
|
+
const level = cfg.levels[modelKey];
|
|
89
|
+
if (!level) return;
|
|
90
|
+
if (pi.getThinkingLevel() === level) return;
|
|
91
|
+
|
|
92
|
+
lastApply = { at: Date.now(), level };
|
|
93
|
+
pi.setThinkingLevel(level);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pi.registerCommand("auto-remember-thinking-level", {
|
|
97
|
+
description: t(
|
|
98
|
+
"自动记忆思考强度: on | off(无参查看状态)",
|
|
99
|
+
"Auto-remember thinking level: on | off (no arg shows status)",
|
|
100
|
+
),
|
|
101
|
+
handler: (args, ctx) => {
|
|
102
|
+
const arg = args.trim();
|
|
103
|
+
if (arg === "on" || arg === "off") {
|
|
104
|
+
cfg = { ...cfg, enabled: arg === "on" };
|
|
105
|
+
saveConfig(cfg);
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
t(
|
|
108
|
+
`自动记忆思考强度已${arg === "on" ? "开启" : "关闭"}`,
|
|
109
|
+
`Auto-remember thinking level ${arg === "on" ? "enabled" : "disabled"}`,
|
|
110
|
+
),
|
|
111
|
+
"info",
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
ctx.ui.notify(
|
|
115
|
+
t(
|
|
116
|
+
`自动记忆思考强度: ${cfg.enabled ? "开" : "关"}`,
|
|
117
|
+
`Auto-remember thinking level: ${cfg.enabled ? "on" : "off"}`,
|
|
118
|
+
),
|
|
119
|
+
"info",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("session_start", (_event, ctx) => {
|
|
126
|
+
applyForModel(ctx);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
pi.on("model_select", (_event, ctx) => {
|
|
130
|
+
lastModelSwitchAt = Date.now();
|
|
131
|
+
applyForModel(ctx);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
pi.on("thinking_level_select", (event, ctx) => {
|
|
135
|
+
if (!cfg.enabled) return;
|
|
136
|
+
if (!ctx.model) return;
|
|
137
|
+
const level = event.level as ThinkingLevel;
|
|
138
|
+
if (!VALID_LEVELS.has(level)) return;
|
|
139
|
+
|
|
140
|
+
// 自身 apply(session_start / model_select 恢复记忆)引起的变化:忽略
|
|
141
|
+
if (lastApply && Date.now() - lastApply.at < APPLY_WINDOW_MS && level === lastApply.level) {
|
|
142
|
+
lastApply = null;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const modelKey = `${ctx.model.provider}/${ctx.model.id}`;
|
|
147
|
+
// 模型切换时 level 变化先于 model_select 发出,故延迟确认:
|
|
148
|
+
// 窗口内 lastModelSwitchAt 发生变化(发生了模型切换)则丢弃,防止误记录
|
|
149
|
+
const switchedAtSnapshot = lastModelSwitchAt;
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
if (lastModelSwitchAt !== switchedAtSnapshot) return;
|
|
152
|
+
if (cfg.levels[modelKey] === level) return;
|
|
153
|
+
cfg = { ...cfg, levels: { ...cfg.levels, [modelKey]: level } };
|
|
154
|
+
saveConfig(cfg);
|
|
155
|
+
}, MODEL_SWITCH_WINDOW_MS);
|
|
156
|
+
});
|
|
157
|
+
}
|
package/token-stats.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { t } from "./user-language.ts";
|
|
29
30
|
|
|
30
31
|
// ── 共享状态(由 index.ts 注入,footer 渲染与模块解耦)──
|
|
31
32
|
|
|
@@ -301,7 +302,7 @@ interface ModelAgg {
|
|
|
301
302
|
|
|
302
303
|
/** 按模型生成 markdown 用量表(模型行按总token降序,末尾合计) */
|
|
303
304
|
function renderModelBreakdown(records: RawRecord[]): string[] {
|
|
304
|
-
if (records.length === 0) return ["", "> 按模型:该范围暂无明细数据"];
|
|
305
|
+
if (records.length === 0) return ["", t("> 按模型:该范围暂无明细数据", "> By model: no detail data in this range")];
|
|
305
306
|
|
|
306
307
|
const byModel = new Map<string, ModelAgg>();
|
|
307
308
|
for (const r of records) {
|
|
@@ -357,14 +358,14 @@ function renderModelBreakdown(records: RawRecord[]): string[] {
|
|
|
357
358
|
|
|
358
359
|
return [
|
|
359
360
|
"",
|
|
360
|
-
"
|
|
361
|
+
"**" + t("按模型", "By model") + "**",
|
|
361
362
|
...renderTable(
|
|
362
|
-
["模型", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
363
|
+
[t("模型", "Model"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
|
|
363
364
|
body,
|
|
364
365
|
{
|
|
365
366
|
aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
|
|
366
367
|
totalRow: [
|
|
367
|
-
"合计",
|
|
368
|
+
t("合计", "Total"),
|
|
368
369
|
String(total.count),
|
|
369
370
|
formatTokens(total.input),
|
|
370
371
|
formatTokens(total.cacheRead),
|
|
@@ -486,7 +487,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
486
487
|
});
|
|
487
488
|
const data = await r.json();
|
|
488
489
|
if (data.base_resp?.status_code === 0) return data;
|
|
489
|
-
throw new Error(data.base_resp?.status_msg || "MiniMax 返回错误");
|
|
490
|
+
throw new Error(data.base_resp?.status_msg || t("MiniMax 返回错误", "MiniMax returned an error"));
|
|
490
491
|
},
|
|
491
492
|
format: (data: any) => {
|
|
492
493
|
const models = data.model_remains || [];
|
|
@@ -536,7 +537,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
536
537
|
headers,
|
|
537
538
|
signal: AbortSignal.timeout(5000),
|
|
538
539
|
});
|
|
539
|
-
if (!r.ok) throw new Error("GLM 配额查询 HTTP " + r.status);
|
|
540
|
+
if (!r.ok) throw new Error(t("GLM 配额查询 HTTP " + r.status, "GLM quota query HTTP " + r.status));
|
|
540
541
|
return await r.json();
|
|
541
542
|
},
|
|
542
543
|
format: (data: any) => {
|
|
@@ -607,7 +608,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
607
608
|
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
608
609
|
signal: AbortSignal.timeout(5000),
|
|
609
610
|
});
|
|
610
|
-
if (!r.ok) throw new Error("Kimi 配额查询 HTTP " + r.status);
|
|
611
|
+
if (!r.ok) throw new Error(t("Kimi 配额查询 HTTP " + r.status, "Kimi quota query HTTP " + r.status));
|
|
611
612
|
return await r.json();
|
|
612
613
|
},
|
|
613
614
|
format: (data: any) => {
|
|
@@ -658,7 +659,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
658
659
|
headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
|
|
659
660
|
signal: AbortSignal.timeout(5000),
|
|
660
661
|
});
|
|
661
|
-
if (!r.ok) throw new Error("DeepSeek 配额查询 HTTP " + r.status);
|
|
662
|
+
if (!r.ok) throw new Error(t("DeepSeek 配额查询 HTTP " + r.status, "DeepSeek quota query HTTP " + r.status));
|
|
662
663
|
return await r.json();
|
|
663
664
|
},
|
|
664
665
|
format: (data: any) => {
|
|
@@ -690,7 +691,7 @@ const BUILTIN_PLANS: TokenPlan[] = [
|
|
|
690
691
|
},
|
|
691
692
|
signal: AbortSignal.timeout(5000),
|
|
692
693
|
});
|
|
693
|
-
if (!r.ok) throw new Error("OpenCode Go 配额查询 HTTP " + r.status);
|
|
694
|
+
if (!r.ok) throw new Error(t("OpenCode Go 配额查询 HTTP " + r.status, "OpenCode Go quota query HTTP " + r.status));
|
|
694
695
|
return await r.json();
|
|
695
696
|
},
|
|
696
697
|
format: (data: any) => {
|
|
@@ -1383,19 +1384,22 @@ export function createTokenStats(
|
|
|
1383
1384
|
* 把 quotaState.error 格式化为人类可读提示。
|
|
1384
1385
|
*/
|
|
1385
1386
|
function formatQuotaError(state: QuotaDisplayState | null | undefined): string {
|
|
1386
|
-
if (!state || !state.error) return "未知错误";
|
|
1387
|
+
if (!state || !state.error) return t("未知错误", "Unknown error");
|
|
1387
1388
|
const e = state.error;
|
|
1388
1389
|
switch (e.kind) {
|
|
1389
1390
|
case "no_plan":
|
|
1390
|
-
return "该 provider 未配置套餐";
|
|
1391
|
+
return t("该 provider 未配置套餐", "No quota plan configured for this provider");
|
|
1391
1392
|
case "key_missing":
|
|
1392
|
-
return
|
|
1393
|
+
return t(
|
|
1394
|
+
`未设置环境变量 ${e.envVar} 或 ~/.pi/agent/auth.json 中 ${e.provider} 的 key 字段`,
|
|
1395
|
+
`Missing env var ${e.envVar} or key field for ${e.provider} in ~/.pi/agent/auth.json`,
|
|
1396
|
+
);
|
|
1393
1397
|
case "api_error":
|
|
1394
|
-
return `API
|
|
1398
|
+
return `API ${t("返回错误", "error")}: ${e.message}`;
|
|
1395
1399
|
case "network_error":
|
|
1396
|
-
return
|
|
1400
|
+
return `${t("网络/超时", "Network timeout")}: ${e.message}`;
|
|
1397
1401
|
case "no_data":
|
|
1398
|
-
return "接口返回无数据";
|
|
1402
|
+
return t("接口返回无数据", "API returned no data");
|
|
1399
1403
|
}
|
|
1400
1404
|
}
|
|
1401
1405
|
|
|
@@ -1501,16 +1505,22 @@ export function createTokenStats(
|
|
|
1501
1505
|
const curLabel =
|
|
1502
1506
|
cur?.organization && cur?.project
|
|
1503
1507
|
? `${cur.organization} / ${cur.project}`
|
|
1504
|
-
: "未配置(按个人版查询)";
|
|
1508
|
+
: t("未配置(按个人版查询)", "not configured (personal query)");
|
|
1509
|
+
const prompts = [t("✏️ 配置/修改", "✏️ Configure / Edit"), t("跳过", "Skip")];
|
|
1505
1510
|
const choice = await ctx.ui.select(
|
|
1506
|
-
|
|
1507
|
-
|
|
1511
|
+
t(
|
|
1512
|
+
`GLM 团队套餐凭证?当前:${curLabel}\n填写组织 ID + 项目 ID(二者齐全才走团队查询 ?type=2),跳过则维持个人版查询`,
|
|
1513
|
+
`GLM team plan credentials? Current: ${curLabel}\nEnter organization ID + project ID (team query ?type=2 needs both), skip to keep personal query`,
|
|
1514
|
+
),
|
|
1515
|
+
prompts,
|
|
1508
1516
|
);
|
|
1509
|
-
if (!choice || choice ===
|
|
1517
|
+
if (!choice || choice === prompts[1]) {
|
|
1510
1518
|
await forceRefreshQuota(ctx);
|
|
1511
1519
|
const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
|
|
1512
1520
|
ctx.ui.notify(
|
|
1513
|
-
quotaState?.error
|
|
1521
|
+
quotaState?.error
|
|
1522
|
+
? t(`GLM 配额查询失败:${errMsg}`, `GLM quota query failed: ${errMsg}`)
|
|
1523
|
+
: t("GLM 配额已启用(个人版查询)", "GLM quota enabled (personal query)"),
|
|
1514
1524
|
"info",
|
|
1515
1525
|
);
|
|
1516
1526
|
return;
|
|
@@ -1521,7 +1531,7 @@ export function createTokenStats(
|
|
|
1521
1531
|
const org = organization?.trim() ?? "";
|
|
1522
1532
|
const proj = project?.trim() ?? "";
|
|
1523
1533
|
if (!org || !proj) {
|
|
1524
|
-
ctx.ui.notify("组织/项目 ID 不能为空,团队凭证未保存", "warning");
|
|
1534
|
+
ctx.ui.notify(t("组织/项目 ID 不能为空,团队凭证未保存", "Organization/Project ID cannot be empty, credentials not saved"), "warning");
|
|
1525
1535
|
return;
|
|
1526
1536
|
}
|
|
1527
1537
|
|
|
@@ -1534,7 +1544,9 @@ export function createTokenStats(
|
|
|
1534
1544
|
await forceRefreshQuota(ctx);
|
|
1535
1545
|
const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
|
|
1536
1546
|
ctx.ui.notify(
|
|
1537
|
-
quotaState?.error
|
|
1547
|
+
quotaState?.error
|
|
1548
|
+
? t(`GLM 团队配额查询失败:${errMsg}`, `GLM team quota query failed: ${errMsg}`)
|
|
1549
|
+
: t("GLM 团队套餐配额已启用", "GLM team quota enabled"),
|
|
1538
1550
|
"info",
|
|
1539
1551
|
);
|
|
1540
1552
|
}
|
|
@@ -1563,15 +1575,15 @@ export function createTokenStats(
|
|
|
1563
1575
|
const cacheHitRate = weightedCacheHitRate(d);
|
|
1564
1576
|
|
|
1565
1577
|
return renderTable(
|
|
1566
|
-
["指标", "数值"],
|
|
1578
|
+
[t("指标", "Metric"), t("数值", "Value")],
|
|
1567
1579
|
[
|
|
1568
|
-
["对话次数", String(d.count)],
|
|
1569
|
-
["新增输入", `${formatTokens(d.sumInput)}
|
|
1570
|
-
["缓存输入", formatTokens(d.sumCacheRead)],
|
|
1571
|
-
["总输出", `${formatTokens(d.sumOutput)}
|
|
1572
|
-
["总token", `${formatTokens(totalPrompt)}
|
|
1573
|
-
["缓存命中率", `${cacheHitRate.toFixed(1)}%`],
|
|
1574
|
-
["平均速率", `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
|
|
1580
|
+
[t("对话次数", "Sessions"), String(d.count)],
|
|
1581
|
+
[t("新增输入", "New input"), `${formatTokens(d.sumInput)}(${t("平均", "avg")} ${formatTokens(avgInput)}/次,${t("未命中缓存", "uncached")})`],
|
|
1582
|
+
[t("缓存输入", "Cached input"), formatTokens(d.sumCacheRead)],
|
|
1583
|
+
[t("总输出", "Total output"), `${formatTokens(d.sumOutput)}(${t("平均", "avg")} ${formatTokens(avgOutput)}/次)`],
|
|
1584
|
+
[t("总token", "Total tokens"), `${formatTokens(totalPrompt)}(${t("新增 + 缓存", "new + cached")})`],
|
|
1585
|
+
[t("缓存命中率", "Cache hit rate"), `${cacheHitRate.toFixed(1)}%`],
|
|
1586
|
+
[t("平均速率", "Avg speed"), `${(d.sumTokensPerSec / d.count).toFixed(1)} t/s`],
|
|
1575
1587
|
],
|
|
1576
1588
|
);
|
|
1577
1589
|
}
|
|
@@ -1602,13 +1614,13 @@ export function createTokenStats(
|
|
|
1602
1614
|
const daily = records.find((r) => r.date === date) || null;
|
|
1603
1615
|
|
|
1604
1616
|
if (!daily) {
|
|
1605
|
-
ctx.ui.notify(`${date} 暂无统计数据`, "info");
|
|
1617
|
+
ctx.ui.notify(t(`${date} 暂无统计数据`, `No stats for ${date}`), "info");
|
|
1606
1618
|
return;
|
|
1607
1619
|
}
|
|
1608
1620
|
|
|
1609
1621
|
await showStats(
|
|
1610
1622
|
[...renderDaySummary(daily), ...renderModelBreakdown(await readRawRecordsForDates([date]))],
|
|
1611
|
-
`Token 统计 | ${date}`,
|
|
1623
|
+
t(`Token 统计 | ${date}`, `Token stats | ${date}`),
|
|
1612
1624
|
ctx,
|
|
1613
1625
|
);
|
|
1614
1626
|
}
|
|
@@ -1625,14 +1637,14 @@ export function createTokenStats(
|
|
|
1625
1637
|
}
|
|
1626
1638
|
|
|
1627
1639
|
if (records.length === 0) {
|
|
1628
|
-
ctx.ui.notify(`${date} 暂无按小时统计`, "info");
|
|
1640
|
+
ctx.ui.notify(t(`${date} 暂无按小时统计`, `No hourly stats for ${date}`), "info");
|
|
1629
1641
|
return;
|
|
1630
1642
|
}
|
|
1631
1643
|
|
|
1632
1644
|
records.sort((a, b) => a.hour - b.hour);
|
|
1633
1645
|
|
|
1634
1646
|
const lines = renderTable(
|
|
1635
|
-
["时间", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
1647
|
+
[t("时间", "Time"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
|
|
1636
1648
|
records.map((r) => {
|
|
1637
1649
|
const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1638
1650
|
return [
|
|
@@ -1674,12 +1686,12 @@ export function createTokenStats(
|
|
|
1674
1686
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
1675
1687
|
|
|
1676
1688
|
if (weekRecords.length === 0) {
|
|
1677
|
-
ctx.ui.notify("本周暂无统计数据", "info");
|
|
1689
|
+
ctx.ui.notify(t("本周暂无统计数据", "No stats for this week"), "info");
|
|
1678
1690
|
return;
|
|
1679
1691
|
}
|
|
1680
1692
|
|
|
1681
1693
|
const lines = renderTable(
|
|
1682
|
-
["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
1694
|
+
[t("日期", "Date"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
|
|
1683
1695
|
weekRecords.map((r) => {
|
|
1684
1696
|
const totalPrompt = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1685
1697
|
return [
|
|
@@ -1700,7 +1712,7 @@ export function createTokenStats(
|
|
|
1700
1712
|
|
|
1701
1713
|
await showStats(
|
|
1702
1714
|
[...lines, ...renderModelBreakdown(await readRawRecordsInRange(sevenDaysAgo, today))],
|
|
1703
|
-
"本周每天汇总",
|
|
1715
|
+
t("本周每天汇总", "Week summary by day"),
|
|
1704
1716
|
ctx,
|
|
1705
1717
|
);
|
|
1706
1718
|
}
|
|
@@ -1726,7 +1738,7 @@ export function createTokenStats(
|
|
|
1726
1738
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
1727
1739
|
|
|
1728
1740
|
if (monthRecords.length === 0) {
|
|
1729
|
-
ctx.ui.notify(`${month} 暂无统计数据`, "info");
|
|
1741
|
+
ctx.ui.notify(t(`${month} 暂无统计数据`, `No stats for ${month}`), "info");
|
|
1730
1742
|
return;
|
|
1731
1743
|
}
|
|
1732
1744
|
|
|
@@ -1747,7 +1759,7 @@ export function createTokenStats(
|
|
|
1747
1759
|
const cacheHitRate = weightedCacheHitRate(total);
|
|
1748
1760
|
|
|
1749
1761
|
const lines = renderTable(
|
|
1750
|
-
["日期", "次数", "新增输入", "缓存输入", "输出", "总token", "命中率", "速率"],
|
|
1762
|
+
[t("日期", "Date"), t("次数", "Count"), t("新增输入", "New input"), t("缓存输入", "Cached input"), t("输出", "Output"), t("总token", "Total tokens"), t("命中率", "Hit rate"), t("速率", "Speed")],
|
|
1751
1763
|
monthRecords.map((r) => {
|
|
1752
1764
|
const tp = r.sumInput + r.sumCacheRead + r.sumCacheWrite;
|
|
1753
1765
|
return [
|
|
@@ -1764,7 +1776,7 @@ export function createTokenStats(
|
|
|
1764
1776
|
{
|
|
1765
1777
|
aligns: ["left", "right", "right", "right", "right", "right", "right", "right"],
|
|
1766
1778
|
totalRow: [
|
|
1767
|
-
"合计",
|
|
1779
|
+
t("合计", "Total"),
|
|
1768
1780
|
String(total.count),
|
|
1769
1781
|
formatTokens(total.sumInput),
|
|
1770
1782
|
formatTokens(total.sumCacheRead),
|
|
@@ -2007,7 +2019,10 @@ export function createTokenStats(
|
|
|
2007
2019
|
// ── /stats 命令 ─────────────────────────────────────
|
|
2008
2020
|
|
|
2009
2021
|
pi.registerCommand("stats", {
|
|
2010
|
-
description:
|
|
2022
|
+
description: t(
|
|
2023
|
+
"Token 统计 (day | hour | week | month | config | limit) 无参默认显示当天统计;limit 进入套餐配置",
|
|
2024
|
+
"Token stats (day | hour | week | month | config | limit) No arg shows today; limit enters quota plan config",
|
|
2025
|
+
),
|
|
2011
2026
|
handler: async (args, ctx) => {
|
|
2012
2027
|
const arg = args.trim();
|
|
2013
2028
|
|
|
@@ -2021,19 +2036,19 @@ export function createTokenStats(
|
|
|
2021
2036
|
if (arg === "limit") {
|
|
2022
2037
|
const provider = ctx.model?.provider;
|
|
2023
2038
|
if (!provider) {
|
|
2024
|
-
ctx.ui.notify("无法获取当前供应商,请先切换对话", "warning");
|
|
2039
|
+
ctx.ui.notify(t("无法获取当前供应商,请先切换对话", "Cannot get current provider, switch conversation first"), "warning");
|
|
2025
2040
|
return;
|
|
2026
2041
|
}
|
|
2027
2042
|
// 套餐用量选择菜单
|
|
2028
|
-
const options = ["关闭", ...BUILTIN_PLANS.map(p => p.name)];
|
|
2043
|
+
const options = [t("关闭", "Off"), ...BUILTIN_PLANS.map(p => p.name)];
|
|
2029
2044
|
const choice = await ctx.ui.select(
|
|
2030
|
-
"选择 " + provider + " 要显示配额的套餐(选中后退出)",
|
|
2045
|
+
t("选择 " + provider + " 要显示配额的套餐(选中后退出)", "Select quota plan to show for " + provider + " (select to exit)"),
|
|
2031
2046
|
options,
|
|
2032
2047
|
);
|
|
2033
2048
|
|
|
2034
2049
|
const defaults: TokenConfig = { providerPlans: {}, ttl: 60 };
|
|
2035
2050
|
|
|
2036
|
-
if (!choice || choice ===
|
|
2051
|
+
if (!choice || choice === options[0]) {
|
|
2037
2052
|
tokenConfig = tokenConfig
|
|
2038
2053
|
? { ...tokenConfig, providerPlans: { ...tokenConfig.providerPlans, [provider]: null } }
|
|
2039
2054
|
: { ...defaults, providerPlans: { [provider]: null } };
|
|
@@ -2049,7 +2064,7 @@ export function createTokenStats(
|
|
|
2049
2064
|
shared.requestRender?.();
|
|
2050
2065
|
}, (tokenConfig?.ttl || 60) * 1000);
|
|
2051
2066
|
shared.requestRender?.();
|
|
2052
|
-
ctx.ui.notify(provider + " 的套餐用量已关闭", "info");
|
|
2067
|
+
ctx.ui.notify(t(provider + " 的套餐用量已关闭", "Quota display for " + provider + " is off"), "info");
|
|
2053
2068
|
return;
|
|
2054
2069
|
}
|
|
2055
2070
|
const plan = BUILTIN_PLANS.find(p => p.name === choice);
|
|
@@ -2077,50 +2092,52 @@ export function createTokenStats(
|
|
|
2077
2092
|
if (quotaState?.error) {
|
|
2078
2093
|
// 仅当 quotaState 带有 error 字段时(key 缺失 / API 错误 / 网络错误 / 无数据)才提示"查询失败"
|
|
2079
2094
|
const errMsg = formatQuotaError(quotaState);
|
|
2080
|
-
ctx.ui.notify(`${plan.name} 配额查询失败:${errMsg}`, "info");
|
|
2095
|
+
ctx.ui.notify(t(`${plan.name} 配额查询失败:${errMsg}`, `${plan.name} quota query failed: ${errMsg}`), "info");
|
|
2081
2096
|
} else {
|
|
2082
|
-
ctx.ui.notify(plan.name + " 配额已启用", "info");
|
|
2097
|
+
ctx.ui.notify(t(plan.name + " 配额已启用", plan.name + " quota enabled"), "info");
|
|
2083
2098
|
}
|
|
2084
2099
|
}
|
|
2085
2100
|
return;
|
|
2086
2101
|
}
|
|
2087
2102
|
|
|
2088
2103
|
if (arg === "config") {
|
|
2089
|
-
const
|
|
2090
|
-
"显示样式",
|
|
2091
|
-
"显示内容",
|
|
2092
|
-
|
|
2093
|
-
"GLM 团队凭证",
|
|
2094
|
-
]
|
|
2104
|
+
const cfgOpts = [
|
|
2105
|
+
t("显示样式", "Display style"),
|
|
2106
|
+
t("显示内容", "Display items"),
|
|
2107
|
+
t(`刷新时间 (当前 ${tokenConfig?.ttl || 60}s)`, `Refresh interval (current ${tokenConfig?.ttl || 60}s)`),
|
|
2108
|
+
t("GLM 团队凭证", "GLM team credentials"),
|
|
2109
|
+
];
|
|
2110
|
+
const subChoice = await ctx.ui.select(t("配置", "Settings"), cfgOpts);
|
|
2095
2111
|
if (!subChoice) return;
|
|
2096
2112
|
|
|
2097
|
-
if (subChoice ===
|
|
2113
|
+
if (subChoice === cfgOpts[3]) {
|
|
2098
2114
|
const cur = tokenConfig?.teamCredential;
|
|
2099
2115
|
const label =
|
|
2100
2116
|
cur?.organization && cur?.project
|
|
2101
2117
|
? `${cur.organization} / ${cur.project}`
|
|
2102
|
-
: "未配置";
|
|
2103
|
-
const
|
|
2104
|
-
"✏️ 配置/修改",
|
|
2105
|
-
"清除",
|
|
2106
|
-
"返回",
|
|
2107
|
-
]
|
|
2108
|
-
|
|
2109
|
-
if (action ===
|
|
2118
|
+
: t("未配置", "not configured");
|
|
2119
|
+
const actions = [
|
|
2120
|
+
t("✏️ 配置/修改", "✏️ Configure / Edit"),
|
|
2121
|
+
t("清除", "Clear"),
|
|
2122
|
+
t("返回", "Back"),
|
|
2123
|
+
];
|
|
2124
|
+
const action = await ctx.ui.select(t("GLM 团队凭证(当前: " + label + ")", "GLM team credentials (current: " + label + ")"), actions);
|
|
2125
|
+
if (!action || action === actions[2]) return;
|
|
2126
|
+
if (action === actions[1]) {
|
|
2110
2127
|
tokenConfig = {
|
|
2111
2128
|
...baseTokenConfig(),
|
|
2112
2129
|
teamCredential: { organization: "", project: "" },
|
|
2113
2130
|
};
|
|
2114
2131
|
await saveTokenConfig(tokenConfig);
|
|
2115
2132
|
await forceRefreshQuota(ctx);
|
|
2116
|
-
ctx.ui.notify("GLM 团队凭证已清除(恢复个人版查询)", "info");
|
|
2133
|
+
ctx.ui.notify(t("GLM 团队凭证已清除(恢复个人版查询)", "GLM team credentials cleared (back to personal query)"), "info");
|
|
2117
2134
|
} else {
|
|
2118
2135
|
const organization = await ctx.ui.input("组织 ID (Organization)", cur?.organization ?? "");
|
|
2119
2136
|
const project = await ctx.ui.input("项目 ID (Project)", cur?.project ?? "");
|
|
2120
2137
|
const org = organization?.trim() ?? "";
|
|
2121
2138
|
const proj = project?.trim() ?? "";
|
|
2122
2139
|
if (!org || !proj) {
|
|
2123
|
-
ctx.ui.notify("组织/项目 ID 不能为空,未保存", "warning");
|
|
2140
|
+
ctx.ui.notify(t("组织/项目 ID 不能为空,未保存", "Organization/Project ID cannot be empty, not saved"), "warning");
|
|
2124
2141
|
return;
|
|
2125
2142
|
}
|
|
2126
2143
|
tokenConfig = {
|
|
@@ -2131,22 +2148,20 @@ export function createTokenStats(
|
|
|
2131
2148
|
await forceRefreshQuota(ctx);
|
|
2132
2149
|
const errMsg = quotaState?.error ? formatQuotaError(quotaState) : "";
|
|
2133
2150
|
if (quotaState?.error) {
|
|
2134
|
-
ctx.ui.notify(`GLM 团队配额查询失败:${errMsg}`, "info");
|
|
2151
|
+
ctx.ui.notify(t(`GLM 团队配额查询失败:${errMsg}`, `GLM team quota query failed: ${errMsg}`), "info");
|
|
2135
2152
|
} else {
|
|
2136
|
-
ctx.ui.notify("GLM 团队凭证已保存(团队查询已生效)", "info");
|
|
2153
|
+
ctx.ui.notify(t("GLM 团队凭证已保存(团队查询已生效)", "GLM team credentials saved (team query active)"), "info");
|
|
2137
2154
|
}
|
|
2138
2155
|
}
|
|
2139
2156
|
return;
|
|
2140
2157
|
}
|
|
2141
2158
|
|
|
2142
|
-
if (subChoice ===
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
"⚡ 速率样式",
|
|
2146
|
-
]);
|
|
2159
|
+
if (subChoice === cfgOpts[0]) {
|
|
2160
|
+
const catOpts = [t("上下文样式", "Context style"), t("⚡ 速率样式", "⚡ Speed style")];
|
|
2161
|
+
const catChoice = await ctx.ui.select(t("选择要配置的样式类别", "Select style category to configure"), catOpts);
|
|
2147
2162
|
if (!catChoice) return;
|
|
2148
2163
|
|
|
2149
|
-
if (catChoice ===
|
|
2164
|
+
if (catChoice === catOpts[0]) {
|
|
2150
2165
|
const items: { label: string; value: ContextStyle; preview: string }[] = [
|
|
2151
2166
|
{ label: "pct-window", value: "pct-window", preview: `5.3%/1.0M` },
|
|
2152
2167
|
{ label: "used-window", value: "used-window", preview: `256k/1.0M` },
|
|
@@ -2155,7 +2170,7 @@ export function createTokenStats(
|
|
|
2155
2170
|
{ label: "bar", value: "bar", preview: `[██░░░░░░] 25%` },
|
|
2156
2171
|
];
|
|
2157
2172
|
const choice = await ctx.ui.select(
|
|
2158
|
-
"上下文样式(当前: " + displayConfig.contextStyle + ")",
|
|
2173
|
+
t("上下文样式(当前: " + displayConfig.contextStyle + ")", "Context style (current: " + displayConfig.contextStyle + ")"),
|
|
2159
2174
|
items.map(i =>
|
|
2160
2175
|
(displayConfig.contextStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview
|
|
2161
2176
|
),
|
|
@@ -2175,10 +2190,10 @@ export function createTokenStats(
|
|
|
2175
2190
|
{ label: "t/s", value: "t/s", preview: `⚡77.7 t/s` },
|
|
2176
2191
|
{ label: "tok/s", value: "tok/s", preview: `⚡77.7 tok/s` },
|
|
2177
2192
|
{ label: "T/s", value: "T/s", preview: `⚡77.7 T/s` },
|
|
2178
|
-
{ label: "live@速率", value: "liveAt", preview: `⚡1.2k@77.7` },
|
|
2193
|
+
{ label: t("live@速率", "live@rate"), value: "liveAt", preview: `⚡1.2k@77.7` },
|
|
2179
2194
|
];
|
|
2180
2195
|
const choice = await ctx.ui.select(
|
|
2181
|
-
"⚡ 速率样式(当前: " + displayConfig.speedStyle + ")",
|
|
2196
|
+
t("⚡ 速率样式(当前: " + displayConfig.speedStyle + ")", "⚡ Speed style (current: " + displayConfig.speedStyle + ")"),
|
|
2182
2197
|
items.map(i =>
|
|
2183
2198
|
(displayConfig.speedStyle === i.value ? "● " : "○ ") + i.label + " " + i.preview
|
|
2184
2199
|
),
|
|
@@ -2194,24 +2209,24 @@ export function createTokenStats(
|
|
|
2194
2209
|
}
|
|
2195
2210
|
}
|
|
2196
2211
|
}
|
|
2197
|
-
ctx.ui.notify("显示样式已保存", "info");
|
|
2198
|
-
} else if (subChoice ===
|
|
2212
|
+
ctx.ui.notify(t("显示样式已保存", "Display style saved"), "info");
|
|
2213
|
+
} else if (subChoice === cfgOpts[1]) {
|
|
2199
2214
|
const itemLabels: DisplayKey[] = [
|
|
2200
2215
|
"input", "output", "totalTokens", "cacheHit", "speed", "context",
|
|
2201
2216
|
"quota5h", "quotaWeek", "quotaMonth", "quotaClock",
|
|
2202
2217
|
];
|
|
2203
2218
|
const itemNames: Record<DisplayKey, string> = {
|
|
2204
|
-
input: "输入", output: "输出", totalTokens: "总token",
|
|
2205
|
-
cacheHit: "缓存命中", speed: "速度", context: "容量",
|
|
2206
|
-
quota5h: "5h额度", quotaWeek: "周额度", quotaMonth: "月额度", quotaClock: "刷新时间",
|
|
2219
|
+
input: t("输入", "Input"), output: t("输出", "Output"), totalTokens: t("总token", "Total tokens"),
|
|
2220
|
+
cacheHit: t("缓存命中", "Cache hit"), speed: t("速度", "Speed"), context: t("容量", "Context"),
|
|
2221
|
+
quota5h: t("5h额度", "5h quota"), quotaWeek: t("周额度", "Week quota"), quotaMonth: t("月额度", "Month quota"), quotaClock: t("刷新时间", "Refresh time"),
|
|
2207
2222
|
};
|
|
2208
2223
|
while (true) {
|
|
2209
2224
|
const options = itemLabels.map(k =>
|
|
2210
2225
|
`${displayConfig.items[k] ? "✅" : "⬜"} ${itemNames[k]}`,
|
|
2211
2226
|
);
|
|
2212
|
-
options.push("🔙 完成");
|
|
2213
|
-
const choice = await ctx.ui.select("选择要切换显示的项目", options);
|
|
2214
|
-
if (!choice || choice ===
|
|
2227
|
+
options.push(t("🔙 完成", "🔙 Done"));
|
|
2228
|
+
const choice = await ctx.ui.select(t("选择要切换显示的项目", "Select items to toggle"), options);
|
|
2229
|
+
if (!choice || choice === options[options.length - 1]) break;
|
|
2215
2230
|
const idx = options.indexOf(choice);
|
|
2216
2231
|
if (idx >= 0 && idx < itemLabels.length) {
|
|
2217
2232
|
const key = itemLabels[idx];
|
|
@@ -2223,13 +2238,13 @@ export function createTokenStats(
|
|
|
2223
2238
|
shared.requestRender?.();
|
|
2224
2239
|
}
|
|
2225
2240
|
}
|
|
2226
|
-
ctx.ui.notify("状态栏显示配置已保存", "info");
|
|
2227
|
-
} else if (subChoice ===
|
|
2228
|
-
const input = await ctx.ui.input("输入刷新间隔(秒)", String(tokenConfig?.ttl || 60));
|
|
2241
|
+
ctx.ui.notify(t("状态栏显示配置已保存", "Status bar display config saved"), "info");
|
|
2242
|
+
} else if (subChoice === cfgOpts[2]) {
|
|
2243
|
+
const input = await ctx.ui.input(t("输入刷新间隔(秒)", "Refresh interval in seconds"), String(tokenConfig?.ttl || 60));
|
|
2229
2244
|
if (input) {
|
|
2230
2245
|
const sec = parseInt(input, 10);
|
|
2231
2246
|
if (Number.isNaN(sec) || sec < 10) {
|
|
2232
|
-
ctx.ui.notify("刷新时间必须 >= 10 秒", "warning");
|
|
2247
|
+
ctx.ui.notify(t("刷新时间必须 >= 10 秒", "Refresh interval must be >= 10s"), "warning");
|
|
2233
2248
|
} else {
|
|
2234
2249
|
tokenConfig = tokenConfig
|
|
2235
2250
|
? { ...tokenConfig, ttl: sec }
|
|
@@ -2244,7 +2259,7 @@ export function createTokenStats(
|
|
|
2244
2259
|
} catch { /* ctx 已失效(session 被替换),忽略 */ }
|
|
2245
2260
|
shared.requestRender?.();
|
|
2246
2261
|
}, sec * 1000);
|
|
2247
|
-
ctx.ui.notify("刷新时间已设为 " + sec + " 秒", "info");
|
|
2262
|
+
ctx.ui.notify(t("刷新时间已设为 " + sec + " 秒", "Refresh interval set to " + sec + "s"), "info");
|
|
2248
2263
|
}
|
|
2249
2264
|
}
|
|
2250
2265
|
}
|
|
@@ -2258,7 +2273,7 @@ export function createTokenStats(
|
|
|
2258
2273
|
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
2259
2274
|
await showDay(date, ctx);
|
|
2260
2275
|
} else {
|
|
2261
|
-
ctx.ui.notify("用法: /stats day YYYY-MM-DD", "warning");
|
|
2276
|
+
ctx.ui.notify(t("用法: /stats day YYYY-MM-DD", "Usage: /stats day YYYY-MM-DD"), "warning");
|
|
2262
2277
|
}
|
|
2263
2278
|
} else if (arg === "hour") {
|
|
2264
2279
|
await showHourly(getDateStr(), ctx);
|
|
@@ -2267,7 +2282,7 @@ export function createTokenStats(
|
|
|
2267
2282
|
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
2268
2283
|
await showHourly(date, ctx);
|
|
2269
2284
|
} else {
|
|
2270
|
-
ctx.ui.notify("用法: /stats hour YYYY-MM-DD", "warning");
|
|
2285
|
+
ctx.ui.notify(t("用法: /stats hour YYYY-MM-DD", "Usage: /stats hour YYYY-MM-DD"), "warning");
|
|
2271
2286
|
}
|
|
2272
2287
|
} else if (arg === "week") {
|
|
2273
2288
|
await showWeek(ctx);
|
|
@@ -2278,11 +2293,11 @@ export function createTokenStats(
|
|
|
2278
2293
|
if (/^\d{4}-\d{2}$/.test(ms)) {
|
|
2279
2294
|
await showMonth(ms, ctx);
|
|
2280
2295
|
} else {
|
|
2281
|
-
ctx.ui.notify("用法: /stats month YYYY-MM", "warning");
|
|
2296
|
+
ctx.ui.notify(t("用法: /stats month YYYY-MM", "Usage: /stats month YYYY-MM"), "warning");
|
|
2282
2297
|
}
|
|
2283
2298
|
} else {
|
|
2284
2299
|
ctx.ui.notify(
|
|
2285
|
-
"用法: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]",
|
|
2300
|
+
t("用法: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]", "Usage: /stats [day [date] | hour [date] | week | month [YYYY-MM] | config]"),
|
|
2286
2301
|
"warning",
|
|
2287
2302
|
);
|
|
2288
2303
|
}
|
package/user-language.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// user-language 模块 —— 自动判断用户中英文,输出文案随语言切换
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// pi 没有内置的用户语言信息(无 locale/language API/settings 字段),
|
|
4
|
+
// 采用启发式判断:
|
|
5
|
+
// 1. LANG 环境变量初始化(zh* → 中文,其余 → 英文)
|
|
6
|
+
// 2. 监听 message_start 的 user 消息,滑动窗口(最近 10 条)统计
|
|
7
|
+
// 每条消息的 CJK 汉字占比:≥50% 消息判定为中文 → 总语言切到中文
|
|
8
|
+
// 判错时窗口会随后续消息自动纠偏;样本为零时保持 LANG 初始猜测。
|
|
9
|
+
//
|
|
10
|
+
// 使用:文案处用 t("中文", "English") 取当前语言版本。
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
/** 滑动窗口大小(用户消息条数) */
|
|
15
|
+
const WINDOW_SIZE = 10;
|
|
16
|
+
/** 单条消息汉字占(汉字+英文字母)比例 ≥ 该值时判定为中文 */
|
|
17
|
+
const CJK_RATIO_THRESHOLD = 0.2;
|
|
18
|
+
/** 窗口内中文消息占比 ≥ 50% 时总语言取中文 */
|
|
19
|
+
const ZH_MAJORITY = 0.5;
|
|
20
|
+
|
|
21
|
+
let current: "zh" | "en" = "en";
|
|
22
|
+
let samples: ("zh" | "en")[] = [];
|
|
23
|
+
|
|
24
|
+
/** LANG 环境变量初始猜测:zh_* → 中文,其余 → 英文 */
|
|
25
|
+
function guessFromLang(): "zh" | "en" {
|
|
26
|
+
const lang = (process.env.LANG || "").trim();
|
|
27
|
+
return /^zh/i.test(lang) ? "zh" : "en";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 单条文本的语言判定:按 CJK 汉字与英文字母的相对占比。
|
|
32
|
+
* 无有效特征(空/纯数字/纯标点/纯符号)时返回 undefined(不参与统计)。
|
|
33
|
+
*/
|
|
34
|
+
export function detectLanguage(text: string): "zh" | "en" | undefined {
|
|
35
|
+
const cjk = (text.match(/[\u4e00-\u9fff]/g) ?? []).length;
|
|
36
|
+
const asciiLetters = (text.match(/[A-Za-z]/g) ?? []).length;
|
|
37
|
+
const meaningful = cjk + asciiLetters;
|
|
38
|
+
if (meaningful === 0) return undefined;
|
|
39
|
+
return cjk / meaningful >= CJK_RATIO_THRESHOLD ? "zh" : "en";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 当前推断的用户语言 */
|
|
43
|
+
export function getUserLanguage(): "zh" | "en" {
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 按当前语言取文案 */
|
|
48
|
+
export function t(zh: string, en: string): string {
|
|
49
|
+
return current === "zh" ? zh : en;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 从 message.content 提取纯文本(兼容 string 与 content 块数组) */
|
|
53
|
+
function contentText(content: unknown): string {
|
|
54
|
+
if (typeof content === "string") return content;
|
|
55
|
+
if (Array.isArray(content)) {
|
|
56
|
+
return content
|
|
57
|
+
.filter(
|
|
58
|
+
(c): c is { type: string; text: string } =>
|
|
59
|
+
!!c && typeof c === "object" &&
|
|
60
|
+
(c as { type?: unknown }).type === "text" &&
|
|
61
|
+
typeof (c as { text?: unknown }).text === "string",
|
|
62
|
+
)
|
|
63
|
+
.map((c) => c.text)
|
|
64
|
+
.join(" ");
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createUserLanguage(pi: ExtensionAPI): void {
|
|
70
|
+
current = guessFromLang();
|
|
71
|
+
samples = [];
|
|
72
|
+
|
|
73
|
+
pi.on("message_start", (event) => {
|
|
74
|
+
const msg = event.message;
|
|
75
|
+
if (!msg || msg.role !== "user") return;
|
|
76
|
+
const lang = detectLanguage(contentText(msg.content));
|
|
77
|
+
if (!lang) return;
|
|
78
|
+
|
|
79
|
+
samples.push(lang);
|
|
80
|
+
if (samples.length > WINDOW_SIZE) samples.shift();
|
|
81
|
+
|
|
82
|
+
const zhCount = samples.filter((s) => s === "zh").length;
|
|
83
|
+
current = zhCount / samples.length >= ZH_MAJORITY ? "zh" : "en";
|
|
84
|
+
});
|
|
85
|
+
}
|