pi-metrics 0.6.0 → 0.7.1
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 +28 -9
- package/README.zh-CN.md +29 -10
- package/SKILL.md +6 -3
- package/locales/index.json +16 -4
- package/package.json +2 -2
- package/src/config.ts +15 -3
- package/src/format-utils.ts +90 -11
- package/src/index.ts +132 -59
- package/src/notice.ts +3 -3
- package/src/run-summary.ts +214 -0
- package/src/tps.ts +108 -53
- package/src/turn-elapsed.ts +60 -14
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# pi-metrics
|
|
2
2
|
|
|
3
|
-
Session metrics for the [Pi coding agent](https://github.com/earendil-works/pi): a live elapsed timer
|
|
3
|
+
Session metrics for the [Pi coding agent](https://github.com/earendil-works/pi): a live elapsed timer and token-generation telemetry, with two display timings.
|
|
4
4
|
|
|
5
5
|
[中文文档](./README.zh-CN.md)
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- While the agent is working, the spinner shows the **total elapsed time since you sent the message** (for example `⏱ 47s`). It keeps counting across turns instead of resetting per turn.
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- Telemetry is persisted as `tps` custom session entries and restored after session resume or `/tree` navigation.
|
|
14
|
-
- Metrics are exposed through session entries and notifications. Use `/config:metrics` to open the TUI settings menu, or
|
|
10
|
+
- **Display timing** decides when metric lines appear:
|
|
11
|
+
- `on-stop` (default): the transcript stays quiet during the run. When the agent fully settles (`agent_settled` — including auto-retries, compaction continuations, or Esc interruption), one summary line reports the total elapsed time, blended TPS, TTFT, summed in/out tokens, stalls, and blended cost.
|
|
12
|
+
- `live`: one line per turn, right when the turn ends (the line already carries the turn duration, so no separate elapsed notice is emitted). A multi-turn run also gets a final `⏱ <duration>` line.
|
|
13
|
+
- Telemetry for every turn is persisted as `tps` custom session entries in both modes, and restored after session resume or `/tree` navigation.
|
|
14
|
+
- Metrics are exposed through session entries and notifications. Use `/config:metrics` to open the TUI settings menu, or `/config:metrics enable|disable|live|on-stop|reset` to change a setting directly.
|
|
15
15
|
|
|
16
16
|
## Configuration
|
|
17
17
|
|
|
@@ -19,13 +19,29 @@ The configuration file is `<pi-agent-dir>/extensions/pi-metrics/config.json`:
|
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
22
|
-
"enabled": true
|
|
22
|
+
"enabled": true,
|
|
23
|
+
"display": "on-stop"
|
|
23
24
|
}
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
| Field | Values | Default | Meaning |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `enabled` | `true` / `false` | `true` | Master switch. `false` registers no metric handlers at all. |
|
|
30
|
+
| `display` | `"on-stop"` / `"live"` | `"on-stop"` | When metric lines appear: one summary line after the run stops, or one line per turn. |
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
Use `/config:metrics` for the interactive menu (each selection is saved and the menu reopens until you pick **Done**), or the direct form to change one field. Run `/reload` after editing the file by hand.
|
|
33
|
+
|
|
34
|
+
## How the summary line is computed
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
⏱ 2m 14.3s · TPS 62.4 tok/s · TTFT 1.2s · in 48.2K · out 12.7K · $0.42/M
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- The leading **`⏱`** value measures from the moment you submit the message to `agent_settled` — the same clock the spinner has been showing, so the number matches what you watched.
|
|
41
|
+
- **TPS** is weighted by output tokens (total output ÷ summed generation time), so a short turn cannot skew the run average.
|
|
42
|
+
- **TTFT** is the first measurable value in the run: how long until the first token appeared.
|
|
43
|
+
- **in/out** are summed over all turns; **stall** appears only when a stall was detected.
|
|
44
|
+
- **Cost rate** is derived from what was actually billed (`billed` cost when a provider reports it, otherwise list price) per million tokens.
|
|
29
45
|
|
|
30
46
|
## Install
|
|
31
47
|
|
|
@@ -36,9 +52,12 @@ pi install npm:pi-metrics
|
|
|
36
52
|
## How it works
|
|
37
53
|
|
|
38
54
|
- The total timer starts on the `input` event (the moment you submit a message) and ends on `agent_settled`, so multi-turn tool calls, automatic retries, and queued continuations are all covered. Steer/follow-up messages sent mid-run do not reset the start point.
|
|
55
|
+
- `on-stop` mode accumulates each finished turn into a run accumulator that keeps only the aggregated values (no per-turn records), then emits a single line at `agent_settled`. A late billed-cost event recomputes and re-emits that line instead of adding a per-turn line.
|
|
39
56
|
- In non-TUI mode (rpc/print) the timer and notifications are disabled.
|
|
40
57
|
- The shared Neuralwatt cost listener is unsubscribed during `session_shutdown`, and deferred rehydration notifications are cancelled during reload/session changes.
|
|
41
58
|
|
|
59
|
+
The TPS implementation is maintained in this package. Remove the standalone `npm:@monotykamary/pi-tps` entry from Pi settings before enabling this package, otherwise both extensions will record duplicate `tps` entries and notifications.
|
|
60
|
+
|
|
42
61
|
## Localization
|
|
43
62
|
|
|
44
63
|
All user-facing text is provided in `zh-CN` and `en-US` through `pi-extensions-i18n`.
|
package/README.zh-CN.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# pi-metrics
|
|
2
2
|
|
|
3
|
-
面向 [Pi coding agent](https://github.com/earendil-works/pi)
|
|
3
|
+
面向 [Pi coding agent](https://github.com/earendil-works/pi) 的会话指标扩展:实时耗时计时 + token 生成遥测,支持两种显示时机。
|
|
4
4
|
|
|
5
5
|
[English](./README.md)
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
9
|
- 工作期间 spinner 显示**从发出消息起的全程耗时**(如 `⏱ 47s`),跨轮次持续累加,不再每轮回零。
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- Metrics 通过 session entry 和通知提供。使用 `/config:metrics` 打开 TUI
|
|
10
|
+
- **显示时机**决定指标行什么时候出现:
|
|
11
|
+
- `on-stop`(默认):运行过程中对话区保持干净;AI 完全停止(`agent_settled`,覆盖自动重试、compaction 续跑以及 Esc 中断)后只出一行汇总:总耗时、混合 TPS、TTFT、in/out 合计、stall 和综合费率。
|
|
12
|
+
- `live`:每轮结束时立刻出一行(该行已包含本轮耗时,不再单独发耗时提示);整段超过一轮时再补一行 `⏱ <耗时>`。
|
|
13
|
+
- 两种模式都会把每轮遥测写进 `tps` custom session entry,恢复 session 或 `/tree` 后照常恢复显示。
|
|
14
|
+
- Metrics 通过 session entry 和通知提供。使用 `/config:metrics` 打开 TUI 配置菜单,或用 `/config:metrics enable|disable|live|on-stop|reset` 直接改一项。
|
|
15
15
|
|
|
16
16
|
## 配置
|
|
17
17
|
|
|
@@ -19,15 +19,29 @@
|
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
22
|
-
"enabled": true
|
|
22
|
+
"enabled": true,
|
|
23
|
+
"display": "on-stop"
|
|
23
24
|
}
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
| 字段 | 取值 | 默认 | 含义 |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `enabled` | `true` / `false` | `true` | 总开关;`false` 时完全不注册指标处理器。 |
|
|
30
|
+
| `display` | `"on-stop"` / `"live"` | `"on-stop"` | 指标行何时出现:整段停下后汇总一行,还是每轮一行。 |
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
用 `/config:metrics` 打开交互菜单(每次选择都会保存并重新打开菜单,选「完成」退出),或用带参数的形式改单个字段;手动修改文件后执行 `/reload`。可参考 [`config.example.json`](./config.example.json)。
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
## 汇总行是怎么算的
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
⏱ 2m 14.3s · TPS 62.4 tok/s · TTFT 1.2s · in 48.2K · out 12.7K · $0.42/M
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- 开头的 **`⏱`** 数值从你发出消息算到 `agent_settled`,与 spinner 一直在显示的是同一个时钟,数字对得上。
|
|
41
|
+
- **TPS** 按输出 token 加权(总输出量 ÷ 生成时间之和),单轮输出很少也不会把整段均值带偏。
|
|
42
|
+
- **TTFT** 取本段第一个可测值,也就是「多久看到第一个字」。
|
|
43
|
+
- **in/out** 是各轮求和;**stall** 只在检测到停顿且时长大于 0 时出现。
|
|
44
|
+
- **费率**用真正计费的金额(provider 报账单时用实际账单,否则用列表价)折算每百万 token。
|
|
31
45
|
|
|
32
46
|
## 安装
|
|
33
47
|
|
|
@@ -38,9 +52,14 @@ pi install npm:pi-metrics
|
|
|
38
52
|
## 实现说明
|
|
39
53
|
|
|
40
54
|
- 总耗时以 `input` 事件(用户提交消息的时刻)为起点、`agent_settled` 为终点,因此多轮工具调用、自动重试和队列续跑都计入同一次总耗时;运行中发送的 steer/followUp 消息不会重置起点。
|
|
55
|
+
- `on-stop` 模式把每轮结束的指标累加进一个运行累加器(只保留聚合量,不囤各轮原始记录),在 `agent_settled` 时合成一行;账单迟到时重算并重发这一行,而不是补一条单轮行。
|
|
41
56
|
- 非 TUI 模式(rpc/print)下不启动定时器、不发送通知。
|
|
42
57
|
- Neuralwatt 成本监听器会在 `session_shutdown` 时取消订阅,恢复通知的延迟定时器也会在 reload/session 切换时清理。
|
|
43
58
|
|
|
59
|
+
## 从 pi-tps 迁移
|
|
60
|
+
|
|
61
|
+
TPS 实现现在由本包维护。启用本包前,请从 Pi 配置中移除独立的 `npm:@monotykamary/pi-tps`,否则两个扩展会重复写入 `tps` 条目并重复通知。
|
|
62
|
+
|
|
44
63
|
## 国际化
|
|
45
64
|
|
|
46
65
|
所有面向用户的文案均通过 `pi-extensions-i18n` 提供 `zh-CN` 和 `en-US` 双语。
|
package/SKILL.md
CHANGED
|
@@ -5,14 +5,17 @@ description: "启用与排查 pi-metrics 的耗时、TPS、TTFT、token 和成
|
|
|
5
5
|
|
|
6
6
|
# 配置 pi-metrics
|
|
7
7
|
|
|
8
|
-
配置文件为 `<Pi agent 目录>/extensions/pi-metrics/config.json
|
|
8
|
+
配置文件为 `<Pi agent 目录>/extensions/pi-metrics/config.json`,字段有 `enabled`(默认 `true`)和 `display`(`"on-stop"` 默认 / `"live"`)。可以使用 `/config:metrics` 打开 TUI 菜单(每次选择都会保存并重新打开,选「完成」退出),或用 `/config:metrics enable|disable|live|on-stop|reset` 直接改一项。手动修改配置文件后执行 `/reload`。
|
|
9
|
+
|
|
10
|
+
`display` 决定指标行何时出现:`on-stop` 只在整段运行停下后出一行汇总(总耗时 · 混合 TPS · TTFT · in/out · stall · 费率),`live` 每轮结束就出一行。spinner 上的实时耗时两种模式都有。
|
|
9
11
|
|
|
10
12
|
## 诊断与修改
|
|
11
13
|
|
|
12
14
|
1. 确认 `npm:pi-metrics` 已安装且扩展资源已启用。
|
|
13
15
|
2. 若同时启用了 `npm:@monotykamary/pi-tps`,先让用户选择保留一个;两者会重复写入 `tps` session entry 和通知。
|
|
14
|
-
3.
|
|
16
|
+
3. 只有在 `live` 模式才应该看到每轮指标行;`on-stop` 模式下每轮都没有提示属于预期行为,不要当成 bug。
|
|
17
|
+
4. 文案语言由 `pi-extensions-i18n` 控制。
|
|
15
18
|
|
|
16
19
|
## 验证
|
|
17
20
|
|
|
18
|
-
在 TUI
|
|
21
|
+
在 TUI 中完成一次真实模型回合:两种模式都应看到 working spinner 计时;`on-stop` 模式在停下后只出现一行汇总,`live` 模式每轮一行、多轮时另有一条 `⏱ <耗时>`(只有图标和数值,不带「总耗时」字样)。RPC/print 模式不会启动 UI 定时器或通知;provider 未返回 usage 时部分指标不可用。真实模型回合属于 E2E,执行前遵守当前任务授权边界;未运行时明确报告 `NOT_RUN`。
|
package/locales/index.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"en-US": "⏱ {value}"
|
|
5
5
|
},
|
|
6
6
|
"elapsedTotal": {
|
|
7
|
-
"zh-CN": "⏱
|
|
8
|
-
"en-US": "⏱
|
|
7
|
+
"zh-CN": "⏱ {value}",
|
|
8
|
+
"en-US": "⏱ {value}"
|
|
9
9
|
},
|
|
10
10
|
"tpsValue": {
|
|
11
11
|
"zh-CN": "TPS {value} tok/s",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"en-US": "Configure metrics display"
|
|
41
41
|
},
|
|
42
42
|
"configCommandUsage": {
|
|
43
|
-
"zh-CN": "用法:/config:metrics(打开配置菜单)、enable、disable 或 reset",
|
|
44
|
-
"en-US": "Usage: /config:metrics (open settings), enable, disable, or reset"
|
|
43
|
+
"zh-CN": "用法:/config:metrics(打开配置菜单)、enable、disable、live、on-stop 或 reset",
|
|
44
|
+
"en-US": "Usage: /config:metrics (open settings), enable, disable, live, on-stop, or reset"
|
|
45
45
|
},
|
|
46
46
|
"configMenuTitle": {
|
|
47
47
|
"zh-CN": "指标配置",
|
|
@@ -55,6 +55,18 @@
|
|
|
55
55
|
"zh-CN": "禁用指标:{value}",
|
|
56
56
|
"en-US": "Metrics disabled: {value}"
|
|
57
57
|
},
|
|
58
|
+
"configDisplay": {
|
|
59
|
+
"zh-CN": "显示时机:{value}",
|
|
60
|
+
"en-US": "Display: {value}"
|
|
61
|
+
},
|
|
62
|
+
"configDisplayLive": {
|
|
63
|
+
"zh-CN": "每轮实时一行",
|
|
64
|
+
"en-US": "one line per turn"
|
|
65
|
+
},
|
|
66
|
+
"configDisplayOnStop": {
|
|
67
|
+
"zh-CN": "停下后汇总一行",
|
|
68
|
+
"en-US": "one summary line when stopped"
|
|
69
|
+
},
|
|
58
70
|
"configOn": {
|
|
59
71
|
"zh-CN": "开",
|
|
60
72
|
"en-US": "on"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-metrics",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Pi metrics extension: elapsed-time HUD and resilient TPS telemetry",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"@earendil-works/pi-tui": ">=0.80.0"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"pi-extensions-i18n": "^0.
|
|
65
|
+
"pi-extensions-i18n": "^0.8.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@earendil-works/pi-ai": "0.85.1",
|
package/src/config.ts
CHANGED
|
@@ -8,11 +8,17 @@ const CONFIG_FILE_NAME = "config.json";
|
|
|
8
8
|
const UTF8_ENCODING = "utf8";
|
|
9
9
|
const FILE_NOT_FOUND_CODE = "ENOENT";
|
|
10
10
|
|
|
11
|
+
/** 指标显示时机:live 每轮一行;on-stop 只在整段运行停下后汇总一行。 */
|
|
12
|
+
export type MetricsDisplay = "live" | "on-stop";
|
|
13
|
+
|
|
14
|
+
const DISPLAY_VALUES: readonly MetricsDisplay[] = ["live", "on-stop"];
|
|
15
|
+
|
|
11
16
|
export interface MetricsConfig {
|
|
12
17
|
enabled: boolean;
|
|
18
|
+
display: MetricsDisplay;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
export const DEFAULT_METRICS_CONFIG: MetricsConfig = { enabled: true };
|
|
21
|
+
export const DEFAULT_METRICS_CONFIG: MetricsConfig = { enabled: true, display: "on-stop" };
|
|
16
22
|
|
|
17
23
|
/** 返回 pi-metrics 配置文件路径。 */
|
|
18
24
|
export function configPath(agentDir = getAgentDir()): string {
|
|
@@ -26,12 +32,18 @@ export function parseConfig(value: unknown): MetricsConfig {
|
|
|
26
32
|
}
|
|
27
33
|
const raw = value as Record<string, unknown>;
|
|
28
34
|
for (const key of Object.keys(raw)) {
|
|
29
|
-
if (key !== "enabled") throw new Error(`unknown configuration field: ${key}`);
|
|
35
|
+
if (key !== "enabled" && key !== "display") throw new Error(`unknown configuration field: ${key}`);
|
|
30
36
|
}
|
|
31
37
|
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") {
|
|
32
38
|
throw new Error("enabled must be a boolean");
|
|
33
39
|
}
|
|
34
|
-
|
|
40
|
+
if (raw.display !== undefined && !DISPLAY_VALUES.includes(raw.display as MetricsDisplay)) {
|
|
41
|
+
throw new Error(`display must be one of: ${DISPLAY_VALUES.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
enabled: (raw.enabled as boolean | undefined) ?? DEFAULT_METRICS_CONFIG.enabled,
|
|
45
|
+
display: (raw.display as MetricsDisplay | undefined) ?? DEFAULT_METRICS_CONFIG.display,
|
|
46
|
+
};
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
/** 读取配置文件;缺少文件时使用默认启用状态。 */
|
package/src/format-utils.ts
CHANGED
|
@@ -1,23 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 指标格式化纯函数
|
|
3
3
|
*
|
|
4
|
-
* 抽离为独立模块便于单元测试(仓库门禁要求测试确定性、不依赖 Pi
|
|
4
|
+
* 抽离为独立模块便于单元测试(仓库门禁要求测试确定性、不依赖 Pi 运行时),
|
|
5
|
+
* 同时供 turn-elapsed、tps 和 run-summary 共用,避免循环依赖。
|
|
5
6
|
*/
|
|
6
7
|
|
|
8
|
+
const MS_PER_SECOND = 1_000;
|
|
9
|
+
const SECOND = 1;
|
|
10
|
+
const MINUTE = 60 * SECOND;
|
|
11
|
+
const HOUR = 60 * MINUTE;
|
|
12
|
+
const DAY = 24 * HOUR;
|
|
13
|
+
const WEEK = 7 * DAY;
|
|
14
|
+
const MONTH = 30 * DAY;
|
|
15
|
+
const YEAR = 365 * DAY;
|
|
16
|
+
|
|
17
|
+
const THOUSAND = 1_000;
|
|
18
|
+
const MILLION = 1_000_000;
|
|
19
|
+
const BILLION = 1_000_000_000;
|
|
20
|
+
|
|
21
|
+
const TOKEN_DECIMAL_PLACES = 1;
|
|
22
|
+
/** 整数值的 1 位小数形式;命中时改回整数显示(2K 而不是 2.0K)。 */
|
|
23
|
+
const ZERO_DECIMAL_SUFFIX = ".0";
|
|
24
|
+
const DURATION_DECIMAL_PLACES = 1;
|
|
25
|
+
const RATE_DECIMAL_PLACES = 2;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 耗时单位表:label、该单位秒数、以及只显示一个单位时要补的次一级单位。
|
|
29
|
+
* 年和月跳过「周」,直接补「天」,因为「1y 0w」没有信息量。
|
|
30
|
+
*/
|
|
31
|
+
const DURATION_UNITS: ReadonlyArray<readonly [string, number, string | null]> = [
|
|
32
|
+
["y", YEAR, "d"],
|
|
33
|
+
["mo", MONTH, "d"],
|
|
34
|
+
["w", WEEK, "d"],
|
|
35
|
+
["d", DAY, "h"],
|
|
36
|
+
["h", HOUR, "m"],
|
|
37
|
+
["m", MINUTE, "s"],
|
|
38
|
+
["s", SECOND, null],
|
|
39
|
+
];
|
|
40
|
+
|
|
7
41
|
/** working 期间:紧凑格式(秒级 / 分秒) */
|
|
8
42
|
export function formatTick(ms: number): string {
|
|
9
|
-
const totalSec = Math.floor(ms /
|
|
10
|
-
if (totalSec <
|
|
11
|
-
const m = Math.floor(totalSec /
|
|
12
|
-
const s = totalSec %
|
|
43
|
+
const totalSec = Math.floor(ms / MS_PER_SECOND);
|
|
44
|
+
if (totalSec < MINUTE) return `${totalSec}s`;
|
|
45
|
+
const m = Math.floor(totalSec / MINUTE);
|
|
46
|
+
const s = totalSec % MINUTE;
|
|
13
47
|
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
|
14
48
|
}
|
|
15
49
|
|
|
16
50
|
/** turn_end / agent_settled:精确格式(1 位小数) */
|
|
17
51
|
export function formatDone(ms: number): string {
|
|
18
|
-
const sec = ms /
|
|
19
|
-
if (sec <
|
|
20
|
-
const m = Math.floor(sec /
|
|
21
|
-
const s = sec - m *
|
|
22
|
-
return `${m}m ${s.toFixed(
|
|
52
|
+
const sec = ms / MS_PER_SECOND;
|
|
53
|
+
if (sec < MINUTE) return `${sec.toFixed(DURATION_DECIMAL_PLACES)}s`;
|
|
54
|
+
const m = Math.floor(sec / MINUTE);
|
|
55
|
+
const s = sec - m * MINUTE;
|
|
56
|
+
return `${m}m ${s.toFixed(DURATION_DECIMAL_PLACES)}s`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** token 数的紧凑显示:1.2K / 2M / 1.5B。 */
|
|
60
|
+
export function formatNumber(num: number): string {
|
|
61
|
+
if (num < THOUSAND) return String(num);
|
|
62
|
+
|
|
63
|
+
const [value, suffix] = num >= BILLION
|
|
64
|
+
? [num / BILLION, "B"]
|
|
65
|
+
: num >= MILLION
|
|
66
|
+
? [num / MILLION, "M"]
|
|
67
|
+
: [num / THOUSAND, "K"];
|
|
68
|
+
const formatted = value.toFixed(TOKEN_DECIMAL_PLACES);
|
|
69
|
+
return formatted.endsWith(ZERO_DECIMAL_SUFFIX) ? `${value.toFixed(0)}${suffix}` : `${formatted}${suffix}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 耗时的宽松显示:最多两个单位(如 1m 0s、1mo 0d)。 */
|
|
73
|
+
export function formatDuration(totalSeconds: number): string {
|
|
74
|
+
if (totalSeconds < MINUTE) return `${totalSeconds.toFixed(DURATION_DECIMAL_PLACES)}s`;
|
|
75
|
+
|
|
76
|
+
const parts: Array<{ value: number; label: string }> = [];
|
|
77
|
+
let remaining = Math.round(totalSeconds);
|
|
78
|
+
|
|
79
|
+
for (const [label, seconds] of DURATION_UNITS) {
|
|
80
|
+
if (remaining >= seconds) {
|
|
81
|
+
parts.push({ value: Math.floor(remaining / seconds), label });
|
|
82
|
+
remaining %= seconds;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (parts.length === 1) {
|
|
87
|
+
const companion = DURATION_UNITS.find(([label]) => label === parts[0].label)?.[2];
|
|
88
|
+
if (companion) parts.push({ value: 0, label: companion });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parts.slice(0, 2).map(({ value, label }) => `${value}${label}`).join(" ");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 由成本(美元)和 token 数折算每百万 token 的费率;数据不可用时返回 null。 */
|
|
95
|
+
export function computeRateUsdPerM(costUsd: number | null, totalTokens: number): number | null {
|
|
96
|
+
if (costUsd === null || !Number.isFinite(costUsd) || costUsd < 0) return null;
|
|
97
|
+
if (!Number.isFinite(totalTokens) || totalTokens <= 0) return null;
|
|
98
|
+
const rate = costUsd / (totalTokens / MILLION);
|
|
99
|
+
if (!Number.isFinite(rate) || rate < 0) return null;
|
|
100
|
+
const scale = 10 ** RATE_DECIMAL_PLACES;
|
|
101
|
+
return Math.round(rate * scale) / scale;
|
|
23
102
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,49 +5,143 @@ import type {
|
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { createTranslator, installNoticeRenderer, loadCatalog, notifyWithSource } from "pi-extensions-i18n";
|
|
7
7
|
import { NOTICE_SOURCE } from "./notice.ts";
|
|
8
|
-
import turnElapsed from "./turn-elapsed.ts";
|
|
8
|
+
import turnElapsed, { createElapsedTracker } from "./turn-elapsed.ts";
|
|
9
9
|
import tps from "./tps.ts";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
configPath,
|
|
12
|
+
DEFAULT_METRICS_CONFIG,
|
|
13
|
+
loadConfig,
|
|
14
|
+
saveConfig,
|
|
15
|
+
type MetricsConfig,
|
|
16
|
+
type MetricsDisplay,
|
|
17
|
+
} from "./config.ts";
|
|
11
18
|
|
|
12
19
|
const messages = loadCatalog(new URL("../locales/index.json", import.meta.url));
|
|
13
20
|
const i18n = createTranslator(messages);
|
|
14
21
|
const CONFIG_COMMAND_ALIASES = ["config:metrics", "metrics-config", "pi-metrics-config"] as const;
|
|
15
22
|
const CONFIG_RESET_COMMAND = "reset";
|
|
16
|
-
const CONFIG_CHOICE = { enabled: 0, disabled: 1 } as const;
|
|
17
23
|
const ENABLE_COMMAND = "enable";
|
|
18
24
|
const DISABLE_COMMAND = "disable";
|
|
25
|
+
const DISPLAY_LIVE_COMMAND = "live";
|
|
26
|
+
const DISPLAY_ON_STOP_COMMAND = "on-stop";
|
|
19
27
|
const NOTICE_WARNING = "warning" as const;
|
|
20
28
|
const NOTICE_INFO = "info" as const;
|
|
21
29
|
const NOTICE_ERROR = "error" as const;
|
|
30
|
+
const DISPLAY_FLIP: Record<MetricsDisplay, MetricsDisplay> = {
|
|
31
|
+
[DISPLAY_LIVE_COMMAND]: DISPLAY_ON_STOP_COMMAND,
|
|
32
|
+
[DISPLAY_ON_STOP_COMMAND]: DISPLAY_LIVE_COMMAND,
|
|
33
|
+
};
|
|
22
34
|
|
|
23
|
-
/**
|
|
35
|
+
/** 命令参数到配置片段的映射:直接改配置时要覆盖的字段。 */
|
|
36
|
+
const COMMAND_CONFIG_PATCH: ReadonlyMap<string, Partial<MetricsConfig>> = new Map([
|
|
37
|
+
[ENABLE_COMMAND, { enabled: true }],
|
|
38
|
+
[DISABLE_COMMAND, { enabled: false }],
|
|
39
|
+
[DISPLAY_LIVE_COMMAND, { display: "live" }],
|
|
40
|
+
[DISPLAY_ON_STOP_COMMAND, { display: "on-stop" }],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** 配置值对应的菜单文案。 */
|
|
44
|
+
function displayLabel(display: MetricsDisplay): string {
|
|
45
|
+
return i18n.t(display === "on-stop" ? "configDisplayOnStop" : "configDisplayLive");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 保存配置并提示结果;写盘失败时只提示不抛出,返回是否成功。 */
|
|
49
|
+
function persistConfig(ctx: ExtensionCommandContext, config: MetricsConfig): boolean {
|
|
50
|
+
try {
|
|
51
|
+
const path = saveConfig(config);
|
|
52
|
+
notifyWithSource({ ctx, source: NOTICE_SOURCE, level: NOTICE_INFO, message: i18n.t("configCommandSaved", { path }) });
|
|
53
|
+
return true;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
notifyWithSource({
|
|
56
|
+
ctx,
|
|
57
|
+
source: NOTICE_SOURCE,
|
|
58
|
+
level: NOTICE_ERROR,
|
|
59
|
+
message: i18n.t("configCommandInvalid", {
|
|
60
|
+
error: error instanceof Error ? error.message : String(error),
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 提示无效配置并返回 false,供读取配置失败时复用。 */
|
|
68
|
+
function reportConfigError(ctx: ExtensionCommandContext, error: unknown): false {
|
|
69
|
+
notifyWithSource({
|
|
70
|
+
ctx,
|
|
71
|
+
source: NOTICE_SOURCE,
|
|
72
|
+
level: NOTICE_ERROR,
|
|
73
|
+
message: i18n.t("configCommandInvalid", {
|
|
74
|
+
error: error instanceof Error ? error.message : String(error),
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 弹出配置菜单,返回用户改动后的配置。
|
|
82
|
+
* 只调用 TUI 选择,不写盘:选择「完成」或按 Esc 时返回 null,由调用方结束编辑;
|
|
83
|
+
* 返回的配置还需要调用方自己落盘。
|
|
84
|
+
*/
|
|
85
|
+
async function promptConfigChange(
|
|
86
|
+
ctx: ExtensionCommandContext,
|
|
87
|
+
current: MetricsConfig,
|
|
88
|
+
): Promise<MetricsConfig | null> {
|
|
89
|
+
const enabledChoice = i18n.t("configEnabled", { value: i18n.t(current.enabled ? "configOn" : "configOff") });
|
|
90
|
+
const displayChoice = i18n.t("configDisplay", { value: displayLabel(current.display) });
|
|
91
|
+
const doneChoice = i18n.t("configDone");
|
|
92
|
+
const selected = await ctx.ui.select(i18n.t("configMenuTitle"), [enabledChoice, displayChoice, doneChoice]);
|
|
93
|
+
if (selected === undefined || selected === doneChoice) return null;
|
|
94
|
+
return selected === enabledChoice
|
|
95
|
+
? { ...current, enabled: !current.enabled }
|
|
96
|
+
: { ...current, display: DISPLAY_FLIP[current.display] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 取命令参数对应的配置补丁;`reset` 用整份默认配置覆盖当前值,未知参数返回 null。 */
|
|
100
|
+
function configPatchFor(value: string): Partial<MetricsConfig> | null {
|
|
101
|
+
if (value === CONFIG_RESET_COMMAND) return { ...DEFAULT_METRICS_CONFIG };
|
|
102
|
+
return COMMAND_CONFIG_PATCH.get(value) ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 应用直接参数形式的配置命令:读当前配置、叠加一个补丁后写回。
|
|
107
|
+
* 未知参数只提示用法;写盘失败由 persistConfig 自己提示,本函数不抛出。
|
|
108
|
+
*/
|
|
109
|
+
function applyCommandSetting(value: string, ctx: ExtensionCommandContext): void {
|
|
110
|
+
const patch = configPatchFor(value);
|
|
111
|
+
if (patch === null) {
|
|
112
|
+
notifyWithSource({ ctx, source: NOTICE_SOURCE, level: NOTICE_WARNING, message: i18n.t("configCommandUsage") });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let current: MetricsConfig;
|
|
116
|
+
try {
|
|
117
|
+
current = loadConfig();
|
|
118
|
+
} catch (error) {
|
|
119
|
+
reportConfigError(ctx, error);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
persistConfig(ctx, { ...current, ...patch });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 注册配置命令,通过 TUI 循环编辑,或直接用参数改一项。 */
|
|
24
126
|
function registerConfigCommand(pi: ExtensionAPI): void {
|
|
25
127
|
const command = {
|
|
26
128
|
description: i18n.t("configCommandDescription"),
|
|
27
|
-
/** Completes the supported
|
|
28
|
-
getArgumentCompletions: () => [
|
|
29
|
-
|
|
129
|
+
/** Completes the supported direct settings. */
|
|
130
|
+
getArgumentCompletions: () => [
|
|
131
|
+
{ value: CONFIG_RESET_COMMAND, label: CONFIG_RESET_COMMAND },
|
|
132
|
+
{ value: ENABLE_COMMAND, label: ENABLE_COMMAND },
|
|
133
|
+
{ value: DISABLE_COMMAND, label: DISABLE_COMMAND },
|
|
134
|
+
{ value: DISPLAY_LIVE_COMMAND, label: DISPLAY_LIVE_COMMAND },
|
|
135
|
+
{ value: DISPLAY_ON_STOP_COMMAND, label: DISPLAY_ON_STOP_COMMAND },
|
|
136
|
+
],
|
|
137
|
+
/**
|
|
138
|
+
* 处理命令:带参数时直改一项,不带参数时进入 TUI 菜单循环;
|
|
139
|
+
* 写盘和提示都是副作用,配置改动需要 /reload 才生效。
|
|
140
|
+
*/
|
|
30
141
|
handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
31
142
|
const value = args.trim();
|
|
32
|
-
if (value
|
|
33
|
-
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
if (value === CONFIG_RESET_COMMAND || value === ENABLE_COMMAND || value === DISABLE_COMMAND) {
|
|
37
|
-
try {
|
|
38
|
-
const config = parseConfig(value === CONFIG_RESET_COMMAND ? {} : { enabled: value === ENABLE_COMMAND });
|
|
39
|
-
const path = saveConfig(config);
|
|
40
|
-
notifyWithSource({ ctx, source: NOTICE_SOURCE, level: NOTICE_INFO, message: i18n.t("configCommandSaved", { path }) });
|
|
41
|
-
} catch (error) {
|
|
42
|
-
notifyWithSource({
|
|
43
|
-
ctx,
|
|
44
|
-
source: NOTICE_SOURCE,
|
|
45
|
-
level: NOTICE_ERROR,
|
|
46
|
-
message: i18n.t("configCommandInvalid", {
|
|
47
|
-
error: error instanceof Error ? error.message : String(error),
|
|
48
|
-
}),
|
|
49
|
-
});
|
|
50
|
-
}
|
|
143
|
+
if (value) {
|
|
144
|
+
applyCommandSetting(value, ctx);
|
|
51
145
|
return;
|
|
52
146
|
}
|
|
53
147
|
if (!ctx.hasUI) {
|
|
@@ -58,38 +152,14 @@ function registerConfigCommand(pi: ExtensionAPI): void {
|
|
|
58
152
|
try {
|
|
59
153
|
current = loadConfig();
|
|
60
154
|
} catch (error) {
|
|
61
|
-
|
|
62
|
-
ctx,
|
|
63
|
-
source: NOTICE_SOURCE,
|
|
64
|
-
level: NOTICE_ERROR,
|
|
65
|
-
message: i18n.t("configCommandInvalid", {
|
|
66
|
-
error: error instanceof Error ? error.message : String(error),
|
|
67
|
-
}),
|
|
68
|
-
});
|
|
155
|
+
reportConfigError(ctx, error);
|
|
69
156
|
return;
|
|
70
157
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
disabledChoice,
|
|
77
|
-
doneChoice,
|
|
78
|
-
]);
|
|
79
|
-
if (selected === undefined || selected === doneChoice) return;
|
|
80
|
-
const choice = selected === enabledChoice ? CONFIG_CHOICE.enabled : CONFIG_CHOICE.disabled;
|
|
81
|
-
try {
|
|
82
|
-
const path = saveConfig({ enabled: choice === CONFIG_CHOICE.enabled });
|
|
83
|
-
notifyWithSource({ ctx, source: NOTICE_SOURCE, level: NOTICE_INFO, message: i18n.t("configCommandSaved", { path }) });
|
|
84
|
-
} catch (error) {
|
|
85
|
-
notifyWithSource({
|
|
86
|
-
ctx,
|
|
87
|
-
source: NOTICE_SOURCE,
|
|
88
|
-
level: NOTICE_ERROR,
|
|
89
|
-
message: i18n.t("configCommandInvalid", {
|
|
90
|
-
error: error instanceof Error ? error.message : String(error),
|
|
91
|
-
}),
|
|
92
|
-
});
|
|
158
|
+
// 每选一项就落盘并重新打开菜单,直到用户选「完成」或按 Esc。
|
|
159
|
+
for (;;) {
|
|
160
|
+
const next = await promptConfigChange(ctx, current);
|
|
161
|
+
if (next === null) return;
|
|
162
|
+
if (persistConfig(ctx, next)) current = next;
|
|
93
163
|
}
|
|
94
164
|
},
|
|
95
165
|
};
|
|
@@ -106,7 +176,7 @@ export default function piHud(pi: ExtensionAPI): void {
|
|
|
106
176
|
try {
|
|
107
177
|
config = loadConfig();
|
|
108
178
|
} catch (error) {
|
|
109
|
-
config = {
|
|
179
|
+
config = { ...DEFAULT_METRICS_CONFIG };
|
|
110
180
|
configError = error;
|
|
111
181
|
}
|
|
112
182
|
if (configError !== undefined) {
|
|
@@ -123,8 +193,10 @@ export default function piHud(pi: ExtensionAPI): void {
|
|
|
123
193
|
});
|
|
124
194
|
}
|
|
125
195
|
if (!config.enabled) return;
|
|
126
|
-
|
|
127
|
-
|
|
196
|
+
// 两个模块共用同一个运行时钟:on-stop 汇总行里的整段耗时和 spinner 显示的是同一段。
|
|
197
|
+
const tracker = createElapsedTracker();
|
|
198
|
+
turnElapsed(pi, { tracker, display: config.display });
|
|
199
|
+
tps(pi, { tracker, display: config.display });
|
|
128
200
|
}
|
|
129
201
|
|
|
130
202
|
export { configPath, loadConfig, parseConfig, saveConfig } from "./config.ts";
|
|
@@ -132,4 +204,5 @@ export type { MetricsConfig } from "./config.ts";
|
|
|
132
204
|
export { default as turnElapsed } from "./turn-elapsed.ts";
|
|
133
205
|
export { default as tps } from "./tps.ts";
|
|
134
206
|
export * from "./format-utils.ts";
|
|
207
|
+
export * from "./run-summary.ts";
|
|
135
208
|
export * from "./tps.ts";
|
package/src/notice.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* 所有 notifyWithSource 调用共用同一份标签与颜色,避免两个入口不一致。
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { NOTICE_TAG_COLOR, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
|
|
7
7
|
|
|
8
8
|
/** 本扩展的提示标签;短且唯一,便于在会话里定位来源。 */
|
|
9
9
|
export const NOTICE_TAG = "metrics";
|
|
10
|
-
/**
|
|
11
|
-
export const NOTICE_COLOR: NoticeColor =
|
|
10
|
+
/** 提示标签颜色:所有扩展统一用弱化色,来源靠 tag 文本区分,不靠颜色。 */
|
|
11
|
+
export const NOTICE_COLOR: NoticeColor = NOTICE_TAG_COLOR;
|
|
12
12
|
/** 本扩展的提示来源。 */
|
|
13
13
|
export const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 整段运行的指标汇总
|
|
3
|
+
*
|
|
4
|
+
* 只在 `display: "on-stop"`(默认)下使用:一次运行里的每一轮指标边跑边累加,
|
|
5
|
+
* 等 AI 完全停下(agent_settled)再合成一行,避免多轮工具调用时对话区被刷屏。
|
|
6
|
+
* 累加器只保留聚合量(各字段求和 + 第一个 TTFT),不囤各轮原始记录;
|
|
7
|
+
* 聚合与格式化都是纯逻辑,便于不依赖 Pi 运行时做单元测试。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { computeRateUsdPerM, formatDone, formatDuration, formatNumber } from "./format-utils.ts";
|
|
11
|
+
import { i18n } from "./i18n.ts";
|
|
12
|
+
|
|
13
|
+
const TPS_DECIMAL_PLACES = 1;
|
|
14
|
+
const RATE_DECIMAL_PLACES = 2;
|
|
15
|
+
const MS_PER_SECOND = 1_000;
|
|
16
|
+
|
|
17
|
+
/** 汇总需要的一轮指标;TurnTelemetry 结构上满足这个形状。 */
|
|
18
|
+
export interface SummaryTurn {
|
|
19
|
+
tokens: {
|
|
20
|
+
input: number;
|
|
21
|
+
output: number;
|
|
22
|
+
cacheRead: number;
|
|
23
|
+
cacheWrite: number;
|
|
24
|
+
total: number;
|
|
25
|
+
};
|
|
26
|
+
timing: {
|
|
27
|
+
/** 该轮从开场到第一个 token 的耗时(毫秒)。 */
|
|
28
|
+
ttftMs: number | null;
|
|
29
|
+
stallMs: number;
|
|
30
|
+
stallCount: number;
|
|
31
|
+
};
|
|
32
|
+
/** 该轮 TPS;null 表示样本不足、无法测得。 */
|
|
33
|
+
tps: number | null;
|
|
34
|
+
/** 该轮列表价成本明细;null 表示 provider 没返回成本。 */
|
|
35
|
+
cost: {
|
|
36
|
+
input: number;
|
|
37
|
+
output: number;
|
|
38
|
+
cacheRead: number;
|
|
39
|
+
cacheWrite: number;
|
|
40
|
+
total: number;
|
|
41
|
+
} | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 一整段运行的汇总结果。 */
|
|
45
|
+
export interface RunSummary {
|
|
46
|
+
/** 参与汇总的轮数。 */
|
|
47
|
+
turns: number;
|
|
48
|
+
tokens: {
|
|
49
|
+
input: number;
|
|
50
|
+
output: number;
|
|
51
|
+
cacheRead: number;
|
|
52
|
+
cacheWrite: number;
|
|
53
|
+
total: number;
|
|
54
|
+
};
|
|
55
|
+
/** 按输出 token 加权的混合 TPS;没有可用样本时为 null。 */
|
|
56
|
+
tps: number | null;
|
|
57
|
+
/** 本段第一个可测的 TTFT(毫秒),即用户等到第一个 token 的时间;没有样本时为 null。 */
|
|
58
|
+
ttftMs: number | null;
|
|
59
|
+
stallMs: number;
|
|
60
|
+
stallCount: number;
|
|
61
|
+
/** 各轮列表价成本之和;全部缺失时为 null。 */
|
|
62
|
+
cost: {
|
|
63
|
+
input: number;
|
|
64
|
+
output: number;
|
|
65
|
+
cacheRead: number;
|
|
66
|
+
cacheWrite: number;
|
|
67
|
+
total: number;
|
|
68
|
+
} | null;
|
|
69
|
+
/** 整段每百万 token 的费率;数据不足时为 null。 */
|
|
70
|
+
rateUsdPerMTokens: number | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 整段运行的增量累加器。
|
|
75
|
+
*
|
|
76
|
+
* - 内存只随聚合字段数量增长,不随轮数增长:轮数再多也只留下各字段的求和值。
|
|
77
|
+
* - TPS 按输出 token 加权:整段输出量 ÷ 各轮生成时间之和,不会因为某一轮输出很少就失真。
|
|
78
|
+
* 单轮生成时间由该轮的 `output / tps` 反推,省得再存一份中间量。
|
|
79
|
+
* - TTFT 取本段第一个可测值:用户感知的是「多久看到第一个字」。
|
|
80
|
+
* - 费率只用真正计费的金额折算,避免把列表价和实际账单混在一起。
|
|
81
|
+
*/
|
|
82
|
+
export interface RunAccumulator {
|
|
83
|
+
/** 累加一轮指标;effectiveCostUsd 是该轮实际计费金额(billed 优先,其次列表价)。 */
|
|
84
|
+
add(turn: SummaryTurn, effectiveCostUsd?: number | null): void;
|
|
85
|
+
/** 替换某一轮迟到到达的计费金额;返回是否真的改变了汇总数据。 */
|
|
86
|
+
replaceBilledCost(previous: number | null, next: number): boolean;
|
|
87
|
+
/** 汇总当前已累加的数据;一轮都没累加过时返回 null。 */
|
|
88
|
+
summarize(): RunSummary | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 是否是可用的计费金额(有限、非负)。 */
|
|
92
|
+
function isUsableCost(value: number | null | undefined): value is number {
|
|
93
|
+
return value !== undefined && value !== null && Number.isFinite(value) && value >= 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 造一个空的整段运行累加器。 */
|
|
97
|
+
export function createRunAccumulator(): RunAccumulator {
|
|
98
|
+
let turns = 0;
|
|
99
|
+
let input = 0;
|
|
100
|
+
let output = 0;
|
|
101
|
+
let cacheRead = 0;
|
|
102
|
+
let cacheWrite = 0;
|
|
103
|
+
let total = 0;
|
|
104
|
+
let stallMs = 0;
|
|
105
|
+
let stallCount = 0;
|
|
106
|
+
let ttftMs: number | null = null;
|
|
107
|
+
let costInput = 0;
|
|
108
|
+
let costOutput = 0;
|
|
109
|
+
let costCacheRead = 0;
|
|
110
|
+
let costCacheWrite = 0;
|
|
111
|
+
let costTotal = 0;
|
|
112
|
+
let hasCost = false;
|
|
113
|
+
let billedTotal = 0;
|
|
114
|
+
let hasBilled = false;
|
|
115
|
+
let measuredOutput = 0;
|
|
116
|
+
let generationMs = 0;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
/**
|
|
120
|
+
* 累加一轮指标:更新各字段求和、第一个可测 TTFT,以及按输出 token 加权的生成时间。
|
|
121
|
+
* 副作用:turns 递增;进第一个非空 TTFT 后不再被后续轮覆盖。
|
|
122
|
+
*/
|
|
123
|
+
add(turn: SummaryTurn, effectiveCostUsd: number | null = null): void {
|
|
124
|
+
turns += 1;
|
|
125
|
+
input += turn.tokens.input;
|
|
126
|
+
output += turn.tokens.output;
|
|
127
|
+
cacheRead += turn.tokens.cacheRead;
|
|
128
|
+
cacheWrite += turn.tokens.cacheWrite;
|
|
129
|
+
total += turn.tokens.total;
|
|
130
|
+
stallMs += turn.timing.stallMs;
|
|
131
|
+
stallCount += turn.timing.stallCount;
|
|
132
|
+
if (ttftMs === null && turn.timing.ttftMs !== null) ttftMs = turn.timing.ttftMs;
|
|
133
|
+
if (turn.cost) {
|
|
134
|
+
costInput += turn.cost.input;
|
|
135
|
+
costOutput += turn.cost.output;
|
|
136
|
+
costCacheRead += turn.cost.cacheRead;
|
|
137
|
+
costCacheWrite += turn.cost.cacheWrite;
|
|
138
|
+
costTotal += turn.cost.total;
|
|
139
|
+
hasCost = true;
|
|
140
|
+
}
|
|
141
|
+
if (isUsableCost(effectiveCostUsd)) {
|
|
142
|
+
billedTotal += effectiveCostUsd;
|
|
143
|
+
hasBilled = true;
|
|
144
|
+
}
|
|
145
|
+
if (turn.tps !== null && turn.tps > 0 && turn.tokens.output > 0) {
|
|
146
|
+
measuredOutput += turn.tokens.output;
|
|
147
|
+
generationMs += (turn.tokens.output / turn.tps) * MS_PER_SECOND;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* 用迟到的实际账单替换某一轮先前的计费金额。
|
|
152
|
+
* 副作用:billedTotal 先减旧值再加新值,并把 hasBilled 置为 true;
|
|
153
|
+
* 旧值等于新值时直接返回 false,让调用方跳过重新汇总和重发提示。
|
|
154
|
+
*/
|
|
155
|
+
replaceBilledCost(previous: number | null, next: number): boolean {
|
|
156
|
+
const previousCounted = isUsableCost(previous);
|
|
157
|
+
if (previousCounted && previous === next) return false;
|
|
158
|
+
if (previousCounted) billedTotal -= previous;
|
|
159
|
+
billedTotal += next;
|
|
160
|
+
hasBilled = true;
|
|
161
|
+
return true;
|
|
162
|
+
},
|
|
163
|
+
/**
|
|
164
|
+
* 汇总已累加的数据:算出加权 TPS 与整段费率。
|
|
165
|
+
* 一轮都没累加过时返回 null,避免上层发出不带任何指标的空行。
|
|
166
|
+
*/
|
|
167
|
+
summarize(): RunSummary | null {
|
|
168
|
+
if (turns === 0) return null;
|
|
169
|
+
const scale = 10 ** TPS_DECIMAL_PLACES;
|
|
170
|
+
const blendedTps = measuredOutput > 0 && generationMs > 0
|
|
171
|
+
? Math.round((measuredOutput / (generationMs / MS_PER_SECOND)) * scale) / scale
|
|
172
|
+
: null;
|
|
173
|
+
const effectiveCost = hasBilled ? billedTotal : hasCost ? costTotal : null;
|
|
174
|
+
return {
|
|
175
|
+
turns,
|
|
176
|
+
tokens: { input, output, cacheRead, cacheWrite, total },
|
|
177
|
+
tps: blendedTps,
|
|
178
|
+
ttftMs,
|
|
179
|
+
stallMs,
|
|
180
|
+
stallCount,
|
|
181
|
+
cost: hasCost
|
|
182
|
+
? { input: costInput, output: costOutput, cacheRead: costCacheRead, cacheWrite: costCacheWrite, total: costTotal }
|
|
183
|
+
: null,
|
|
184
|
+
rateUsdPerMTokens: computeRateUsdPerM(effectiveCost, total),
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 合成一行整段汇总:总耗时 · TPS · TTFT · in · out · stall · 费率。 */
|
|
191
|
+
export function composeRunSummary(summary: RunSummary, elapsedMs: number | null): string {
|
|
192
|
+
const parts: string[] = [];
|
|
193
|
+
if (elapsedMs !== null && elapsedMs > 0) {
|
|
194
|
+
parts.push(i18n.t("elapsedTotal", { value: formatDone(elapsedMs) }));
|
|
195
|
+
}
|
|
196
|
+
parts.push(summary.tps === null
|
|
197
|
+
? i18n.t("tpsUnknown")
|
|
198
|
+
: i18n.t("tpsValue", { value: summary.tps.toFixed(TPS_DECIMAL_PLACES) }));
|
|
199
|
+
if (summary.ttftMs !== null) {
|
|
200
|
+
parts.push(i18n.t("tpsTtft", { value: formatDuration(summary.ttftMs / MS_PER_SECOND) }));
|
|
201
|
+
}
|
|
202
|
+
parts.push(i18n.t("tpsInput", { value: formatNumber(summary.tokens.input) }));
|
|
203
|
+
parts.push(i18n.t("tpsOutput", { value: formatNumber(summary.tokens.output) }));
|
|
204
|
+
if (summary.stallMs > 0) {
|
|
205
|
+
parts.push(i18n.t("tpsStall", {
|
|
206
|
+
value: formatDuration(summary.stallMs / MS_PER_SECOND),
|
|
207
|
+
count: summary.stallCount,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
if (summary.rateUsdPerMTokens !== null) {
|
|
211
|
+
parts.push(i18n.t("tpsRate", { value: summary.rateUsdPerMTokens.toFixed(RATE_DECIMAL_PLACES) }));
|
|
212
|
+
}
|
|
213
|
+
return parts.join(" · ");
|
|
214
|
+
}
|
package/src/tps.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This is the TPS portion of pi-tps, maintained inside pi-metrics so the
|
|
5
5
|
* elapsed-time HUD and generation telemetry share one lifecycle.
|
|
6
|
+
*
|
|
7
|
+
* 两种显示时机:
|
|
8
|
+
* - `live`:每轮结束即出一行指标。
|
|
9
|
+
* - `on-stop`(默认):每轮只记 `tps` session entry 和事件,不动对话区;等整段运行
|
|
10
|
+
* `agent_settled` 后把各轮合成一行汇总(总耗时、混合 TPS、TTFT、in/out、成本)。
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { performance } from "node:perf_hooks";
|
|
@@ -12,8 +17,12 @@ import type {
|
|
|
12
17
|
ExtensionContext,
|
|
13
18
|
} from "@earendil-works/pi-coding-agent";
|
|
14
19
|
import { notifyWithSource } from "pi-extensions-i18n";
|
|
20
|
+
import { DEFAULT_METRICS_CONFIG, type MetricsDisplay } from "./config.ts";
|
|
21
|
+
import { computeRateUsdPerM, formatDuration, formatNumber } from "./format-utils.ts";
|
|
15
22
|
import { i18n } from "./i18n.ts";
|
|
16
23
|
import { NOTICE_SOURCE } from "./notice.ts";
|
|
24
|
+
import { composeRunSummary, createRunAccumulator, type RunAccumulator } from "./run-summary.ts";
|
|
25
|
+
import type { ElapsedTracker } from "./turn-elapsed.ts";
|
|
17
26
|
|
|
18
27
|
interface TurnStartEvent {
|
|
19
28
|
type: "turn_start";
|
|
@@ -108,58 +117,6 @@ interface SessionEntryLike {
|
|
|
108
117
|
const STALL_THRESHOLD_MS = 500;
|
|
109
118
|
const NEURALWATT_ENERGY_EVENT = "neuralwatt:turn-energy";
|
|
110
119
|
|
|
111
|
-
export function formatNumber(num: number): string {
|
|
112
|
-
if (num < 1_000) return String(num);
|
|
113
|
-
|
|
114
|
-
const [value, suffix] = num >= 1_000_000_000
|
|
115
|
-
? [num / 1_000_000_000, "B"]
|
|
116
|
-
: num >= 1_000_000
|
|
117
|
-
? [num / 1_000_000, "M"]
|
|
118
|
-
: [num / 1_000, "K"];
|
|
119
|
-
const formatted = value.toFixed(1);
|
|
120
|
-
return formatted.endsWith(".0") ? `${value.toFixed(0)}${suffix}` : `${formatted}${suffix}`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function formatDuration(totalSeconds: number): string {
|
|
124
|
-
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
|
|
125
|
-
|
|
126
|
-
const units = [
|
|
127
|
-
["y", 365 * 24 * 60 * 60],
|
|
128
|
-
["mo", 30 * 24 * 60 * 60],
|
|
129
|
-
["w", 7 * 24 * 60 * 60],
|
|
130
|
-
["d", 24 * 60 * 60],
|
|
131
|
-
["h", 60 * 60],
|
|
132
|
-
["m", 60],
|
|
133
|
-
["s", 1],
|
|
134
|
-
] as const;
|
|
135
|
-
const parts: Array<{ value: number; label: string }> = [];
|
|
136
|
-
let remaining = Math.round(totalSeconds);
|
|
137
|
-
|
|
138
|
-
for (const [label, seconds] of units) {
|
|
139
|
-
if (remaining >= seconds) {
|
|
140
|
-
parts.push({ value: Math.floor(remaining / seconds), label });
|
|
141
|
-
remaining %= seconds;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (parts.length === 1) {
|
|
146
|
-
const first = units.findIndex(([label]) => label === parts[0].label);
|
|
147
|
-
let next = first + 1;
|
|
148
|
-
if (parts[0].label === "mo") next++;
|
|
149
|
-
if (parts[0].label === "y") next += 2;
|
|
150
|
-
if (next < units.length) parts.push({ value: 0, label: units[next][0] });
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return parts.slice(0, 2).map(({ value, label }) => `${value}${label}`).join(" ");
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function computeRateUsdPerM(costUsd: number | null, totalTokens: number): number | null {
|
|
157
|
-
if (costUsd === null || !Number.isFinite(costUsd) || costUsd < 0) return null;
|
|
158
|
-
if (!Number.isFinite(totalTokens) || totalTokens <= 0) return null;
|
|
159
|
-
const rate = costUsd / (totalTokens / 1_000_000);
|
|
160
|
-
return Number.isFinite(rate) && rate >= 0 ? Math.round(rate * 100) / 100 : null;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
120
|
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
|
164
121
|
if (!message || typeof message !== "object") return false;
|
|
165
122
|
const candidate = message as Record<string, unknown>;
|
|
@@ -342,7 +299,33 @@ function restoreTPSNotification(
|
|
|
342
299
|
}
|
|
343
300
|
}
|
|
344
301
|
|
|
345
|
-
|
|
302
|
+
/** `on-stop` 模式下与汇总相关的运行状态。 */
|
|
303
|
+
interface SummaryState {
|
|
304
|
+
/** 本段运行的增量累加器;只保留聚合量,不囤各轮原始记录。 */
|
|
305
|
+
accumulator: RunAccumulator;
|
|
306
|
+
/** 发提示用的上下文。 */
|
|
307
|
+
ctx: ExtensionContext;
|
|
308
|
+
/** 汇总行里的整段耗时(毫秒);运行时钟没数据时为 null。 */
|
|
309
|
+
elapsedMs: number | null;
|
|
310
|
+
/** 最后累加的一轮下标;没有轮时为 null。 */
|
|
311
|
+
turnIndex: number | null;
|
|
312
|
+
/** 最后一轮已计入的计费金额,替换旧值时要用它扣减。 */
|
|
313
|
+
effectiveCostUsd: number | null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** tps 模块的可注入依赖:显示时机,以及 on-stop 模式下共用的运行时钟。 */
|
|
317
|
+
export interface TpsOptions {
|
|
318
|
+
/** 显示时机;`live` 每轮一行,`on-stop` 只在整段停下后汇总一行。 */
|
|
319
|
+
display?: MetricsDisplay;
|
|
320
|
+
/** 共享的运行时钟;只在 `on-stop` 模式下用于汇总行的总耗时。 */
|
|
321
|
+
tracker?: ElapsedTracker;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 注册 TPS 指标事件:按 `display` 决定每轮实时出一行,还是整段停下后汇总出一行。
|
|
326
|
+
*/
|
|
327
|
+
export default function tpsExtension(pi: ExtensionAPI, options: TpsOptions = {}): void {
|
|
328
|
+
const display = options.display ?? DEFAULT_METRICS_CONFIG.display;
|
|
346
329
|
let currentTiming: TurnTiming | null = null;
|
|
347
330
|
let pendingNeuralwattBilledCost: { turnIndex: number; costUsd: number } | null = null;
|
|
348
331
|
let lastCommittedTurn: {
|
|
@@ -351,6 +334,12 @@ export default function tpsExtension(pi: ExtensionAPI): void {
|
|
|
351
334
|
billedApplied: boolean;
|
|
352
335
|
ctx: ExtensionContext;
|
|
353
336
|
} | null = null;
|
|
337
|
+
/** `on-stop` 模式下本段运行的累加器,agent_settled 时出一个汇总行。 */
|
|
338
|
+
let runAccumulator: RunAccumulator = createRunAccumulator();
|
|
339
|
+
/** 本段运行最后累加的那一轮;只有它能收到迟到的账单。 */
|
|
340
|
+
let lastRunTurn: { turnIndex: number; effectiveCostUsd: number | null } | null = null;
|
|
341
|
+
/** 最近一次已经发出的汇总;账单迟到时用它重算并重发。 */
|
|
342
|
+
let lastSummary: SummaryState | null = null;
|
|
354
343
|
const tpsCaps = new Map<string, number>();
|
|
355
344
|
const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
356
345
|
let unsubscribeNeuralwatt: (() => void) | undefined;
|
|
@@ -359,10 +348,34 @@ export default function tpsExtension(pi: ExtensionAPI): void {
|
|
|
359
348
|
currentTiming = null;
|
|
360
349
|
pendingNeuralwattBilledCost = null;
|
|
361
350
|
lastCommittedTurn = null;
|
|
351
|
+
runAccumulator = createRunAccumulator();
|
|
352
|
+
lastRunTurn = null;
|
|
353
|
+
lastSummary = null;
|
|
362
354
|
for (const timer of restoreTimers) clearTimeout(timer);
|
|
363
355
|
restoreTimers.clear();
|
|
364
356
|
};
|
|
365
357
|
|
|
358
|
+
/**
|
|
359
|
+
* 账单在汇总之后才到达时修正已发的那一行:
|
|
360
|
+
* 只可能落在本段运行的最后一轮,因此用累加器替换该轮的计费金额并重发一行更正,
|
|
361
|
+
* 不重发单轮指标;金额没变化时跳过重发。
|
|
362
|
+
*/
|
|
363
|
+
const applyLateBilledCost = (turnIndex: number, costUsd: number): void => {
|
|
364
|
+
const summary = lastSummary;
|
|
365
|
+
// 只有已发出的那一行里的最后一轮可能收到迟到账单,其他轮一律忽略。
|
|
366
|
+
if (!summary || summary.turnIndex !== turnIndex) return;
|
|
367
|
+
if (!summary.accumulator.replaceBilledCost(summary.effectiveCostUsd, costUsd)) return;
|
|
368
|
+
summary.effectiveCostUsd = costUsd;
|
|
369
|
+
const aggregate = summary.accumulator.summarize();
|
|
370
|
+
if (aggregate === null || !summary.ctx.hasUI) return;
|
|
371
|
+
notifyWithSource({
|
|
372
|
+
ctx: summary.ctx,
|
|
373
|
+
source: NOTICE_SOURCE,
|
|
374
|
+
level: "info",
|
|
375
|
+
message: composeRunSummary(aggregate, summary.elapsedMs),
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
366
379
|
const scheduleRestore = (callback: () => void) => {
|
|
367
380
|
const timer = setTimeout(() => {
|
|
368
381
|
restoreTimers.delete(timer);
|
|
@@ -394,6 +407,10 @@ export default function tpsExtension(pi: ExtensionAPI): void {
|
|
|
394
407
|
committed.telemetry = corrected;
|
|
395
408
|
pi.appendEntry("tps", corrected);
|
|
396
409
|
pi.events?.emit("tps:telemetry", corrected);
|
|
410
|
+
if (display === "on-stop") {
|
|
411
|
+
applyLateBilledCost(committed.turnIndex, costUsd);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
397
414
|
if (committed.ctx.hasUI) notifyWithSource({ ctx: committed.ctx, source: NOTICE_SOURCE, level: "info", message: composeDisplayString(corrected) });
|
|
398
415
|
});
|
|
399
416
|
|
|
@@ -411,6 +428,9 @@ export default function tpsExtension(pi: ExtensionAPI): void {
|
|
|
411
428
|
pi.on("session_tree", (_event: SessionTreeEvent, ctx) => {
|
|
412
429
|
pendingNeuralwattBilledCost = null;
|
|
413
430
|
lastCommittedTurn = null;
|
|
431
|
+
runAccumulator = createRunAccumulator();
|
|
432
|
+
lastRunTurn = null;
|
|
433
|
+
lastSummary = null;
|
|
414
434
|
restoreTPSNotification(ctx, scheduleRestore);
|
|
415
435
|
});
|
|
416
436
|
|
|
@@ -515,7 +535,42 @@ export default function tpsExtension(pi: ExtensionAPI): void {
|
|
|
515
535
|
};
|
|
516
536
|
pi.appendEntry("tps", telemetry);
|
|
517
537
|
pi.events?.emit("tps:telemetry", telemetry);
|
|
538
|
+
if (display === "on-stop") {
|
|
539
|
+
// 先累加:整段停下后只出一行汇总,多轮工具调用不会把对话区刷满。
|
|
540
|
+
const effectiveCostUsd = billedCost ?? telemetry.cost?.total ?? null;
|
|
541
|
+
runAccumulator.add(telemetry, effectiveCostUsd);
|
|
542
|
+
lastRunTurn = { turnIndex: event.turnIndex, effectiveCostUsd };
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
518
545
|
if (ctx.hasUI) notifyWithSource({ ctx, source: NOTICE_SOURCE, level: "info", message: composeDisplayString(telemetry) });
|
|
519
546
|
});
|
|
520
547
|
|
|
548
|
+
pi.on("agent_settled", (_event, ctx: ExtensionContext) => {
|
|
549
|
+
if (display !== "on-stop") return;
|
|
550
|
+
// 运行时钟由本模块结算(live 模式下由 turn-elapsed 结算):先取数再复位,
|
|
551
|
+
// 汇总行里的总耗时就是 spinner 一直在显示的那一段。
|
|
552
|
+
const settlement = options.tracker?.currentRun();
|
|
553
|
+
options.tracker?.resetRun();
|
|
554
|
+
const aggregate = runAccumulator.summarize();
|
|
555
|
+
if (aggregate === null) return;
|
|
556
|
+
const elapsedMs = settlement && settlement.elapsedMs > 0 ? settlement.elapsedMs : null;
|
|
557
|
+
lastSummary = {
|
|
558
|
+
accumulator: runAccumulator,
|
|
559
|
+
ctx,
|
|
560
|
+
elapsedMs,
|
|
561
|
+
turnIndex: lastRunTurn?.turnIndex ?? null,
|
|
562
|
+
effectiveCostUsd: lastRunTurn?.effectiveCostUsd ?? null,
|
|
563
|
+
};
|
|
564
|
+
// 本段运行已经结算:换一个空累加器,下一段运行从零开始。
|
|
565
|
+
runAccumulator = createRunAccumulator();
|
|
566
|
+
lastRunTurn = null;
|
|
567
|
+
if (!ctx.hasUI) return;
|
|
568
|
+
notifyWithSource({
|
|
569
|
+
ctx,
|
|
570
|
+
source: NOTICE_SOURCE,
|
|
571
|
+
level: "info",
|
|
572
|
+
message: composeRunSummary(aggregate, elapsedMs),
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
521
576
|
}
|
package/src/turn-elapsed.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - working 期间:实时更新 spinner 文字,显示从用户发出消息起的全程耗时(如 "⏱ 47s"),
|
|
5
5
|
* 跨轮不归零 —— 用户等待时最关心的是"一共等了多久"
|
|
6
|
-
* - agent 完全停止时(agent_settled
|
|
7
|
-
*
|
|
6
|
+
* - agent 完全停止时(agent_settled):`display: "live"` 下补一条总耗时;`display: "on-stop"`
|
|
7
|
+
* 下不再单独发,总耗时由 tps 的整段汇总行带上,避免同一段运行冒两条提示
|
|
8
8
|
*
|
|
9
9
|
* 关于「本轮耗时」:它已经包含在 tps 的那条指标提示里(TPS/TTFT/耗时/tokens 一行),
|
|
10
10
|
* 所以这里不再单独发一条,避免同一轮冒两条指标提示。
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { notifyWithSource } from "pi-extensions-i18n";
|
|
25
|
+
import { DEFAULT_METRICS_CONFIG, type MetricsDisplay } from "./config.ts";
|
|
25
26
|
import { formatDone, formatTick } from "./format-utils.ts";
|
|
26
27
|
import { i18n } from "./i18n.ts";
|
|
27
28
|
import { NOTICE_SOURCE } from "./notice.ts";
|
|
@@ -31,6 +32,14 @@ const TICK_MS = 1000;
|
|
|
31
32
|
/** 至少跑满两轮才值得单独报一次总耗时;单轮的总耗时是噪声。 */
|
|
32
33
|
const MIN_TURNS_FOR_TOTAL = 2;
|
|
33
34
|
|
|
35
|
+
/** 一次运行停下时结算出来的原始数据。 */
|
|
36
|
+
export interface RunSettlement {
|
|
37
|
+
/** 从用户发出消息到 AI 停下的整段耗时(毫秒)。 */
|
|
38
|
+
elapsedMs: number;
|
|
39
|
+
/** 本段运行真正跑完的轮数。 */
|
|
40
|
+
turns: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
/** 计时状态:与 Pi 事件解耦,便于直接测试判定规则。 */
|
|
35
44
|
export interface ElapsedTracker {
|
|
36
45
|
/** 用户发出消息(或没有 input 事件时的兜底)开始记一段运行;运行中重复调用不改起点。 */
|
|
@@ -41,8 +50,10 @@ export interface ElapsedTracker {
|
|
|
41
50
|
startTurn(): void;
|
|
42
51
|
/** 一轮结束:累计轮次并清掉本轮状态。 */
|
|
43
52
|
endTurn(): void;
|
|
44
|
-
/**
|
|
45
|
-
|
|
53
|
+
/** 本段运行的结算视图(只读,不复位);没有进行中的运行返回 undefined。 */
|
|
54
|
+
currentRun(): RunSettlement | undefined;
|
|
55
|
+
/** 复位运行状态(一段运行结算完毕后由结算方调用)。 */
|
|
56
|
+
resetRun(): void;
|
|
46
57
|
/** 清掉本轮起点(agent_end 用)。 */
|
|
47
58
|
clearTurn(): void;
|
|
48
59
|
}
|
|
@@ -78,15 +89,17 @@ export function createElapsedTracker(now: () => number = () => Date.now()): Elap
|
|
|
78
89
|
if (inTurn) turnCount += 1;
|
|
79
90
|
inTurn = false;
|
|
80
91
|
},
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
/** 本段运行的结算视图(只读);没有进行中的运行返回 undefined。 */
|
|
93
|
+
currentRun(): RunSettlement | undefined {
|
|
94
|
+
if (!running) return undefined;
|
|
95
|
+
return { elapsedMs: now() - runStartTime, turns: turnCount };
|
|
96
|
+
},
|
|
97
|
+
/** 复位运行状态(一段运行结算完毕后由结算方调用)。 */
|
|
98
|
+
resetRun(): void {
|
|
85
99
|
running = false;
|
|
86
100
|
inTurn = false;
|
|
87
101
|
runStartTime = 0;
|
|
88
102
|
turnCount = 0;
|
|
89
|
-
return elapsed > 0 && turns >= MIN_TURNS_FOR_TOTAL ? elapsed : undefined;
|
|
90
103
|
},
|
|
91
104
|
/** 清掉本轮状态(agent_end 用)。 */
|
|
92
105
|
clearTurn(): void {
|
|
@@ -95,10 +108,37 @@ export function createElapsedTracker(now: () => number = () => Date.now()): Elap
|
|
|
95
108
|
};
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
/** `live` 模式下是否值得单独报总耗时:跑满两轮且有实际耗时。 */
|
|
112
|
+
export function shouldReportTotalRun(settlement: RunSettlement): boolean {
|
|
113
|
+
return settlement.elapsedMs > 0 && settlement.turns >= MIN_TURNS_FOR_TOTAL;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 耗时模块的可注入依赖:与 tps 共用同一个运行时钟,保证两处耗时一致。 */
|
|
117
|
+
export interface TurnElapsedOptions {
|
|
118
|
+
/** 共享的计时状态;不传就自己造一个(便于单独使用和测试)。 */
|
|
119
|
+
tracker?: ElapsedTracker;
|
|
120
|
+
/** 显示时机;只有 `live` 才在这里补总耗时提示。 */
|
|
121
|
+
display?: MetricsDisplay;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 注册耗时事件:working 期间刷新 spinner,并(live 模式下)在停下时补总耗时提示。
|
|
126
|
+
*
|
|
127
|
+
* 运行时钟可以由外部注入:`on-stop` 模式下由 tps 结算同一个 tracker,保证汇总行的
|
|
128
|
+
* 总耗时与 spinner 显示的是同一段运行。
|
|
129
|
+
*/
|
|
130
|
+
export default function (pi: ExtensionAPI, options: TurnElapsedOptions = {}) {
|
|
131
|
+
const tracker = options.tracker ?? createElapsedTracker();
|
|
132
|
+
const display = options.display ?? DEFAULT_METRICS_CONFIG.display;
|
|
100
133
|
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
|
101
134
|
|
|
135
|
+
/** live 模式的结算入口:读运行数据后复位,只在本模块负责结算时调用。 */
|
|
136
|
+
const settleRun = (): RunSettlement | undefined => {
|
|
137
|
+
const settlement = tracker.currentRun();
|
|
138
|
+
tracker.resetRun();
|
|
139
|
+
return settlement;
|
|
140
|
+
};
|
|
141
|
+
|
|
102
142
|
const stopTick = () => {
|
|
103
143
|
if (tickHandle !== null) {
|
|
104
144
|
clearInterval(tickHandle);
|
|
@@ -150,12 +190,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
150
190
|
|
|
151
191
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
152
192
|
stopTick();
|
|
153
|
-
|
|
193
|
+
// 运行时钟只有一个结算者:live 模式在这里结算,on-stop 模式由 tps 结算(汇总行要同时带上耗时)。
|
|
194
|
+
const settlement = display === "live" ? settleRun() : undefined;
|
|
154
195
|
if (!ctx.hasUI) return;
|
|
155
196
|
|
|
156
197
|
ctx.ui.setWorkingMessage(undefined);
|
|
157
|
-
if (
|
|
158
|
-
notifyWithSource({
|
|
198
|
+
if (settlement !== undefined && shouldReportTotalRun(settlement)) {
|
|
199
|
+
notifyWithSource({
|
|
200
|
+
ctx,
|
|
201
|
+
source: NOTICE_SOURCE,
|
|
202
|
+
level: "info",
|
|
203
|
+
message: i18n.t("elapsedTotal", { value: formatDone(settlement.elapsedMs) }),
|
|
204
|
+
});
|
|
159
205
|
}
|
|
160
206
|
});
|
|
161
207
|
}
|