@dijkspicy/opencode-stats-reporter 0.1.1 → 0.1.3
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 +2 -2
- package/contrib/opencode-session-reporter/README.md +20 -5
- package/contrib/opencode-session-reporter/package.json +1 -1
- package/contrib/opencode-session-reporter/reporter.test.ts +190 -0
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +115 -17
- package/package.json +1 -1
- package/panel/css/style.css +45 -4
- package/panel/index.html +1 -1
- package/panel/js/format.js +10 -0
- package/panel/js/sessions.js +80 -11
- package/src/cli.js +25 -6
- package/src/config.js +6 -1
- package/src/store.js +26 -3
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ npm i -g @dijkspicy/opencode-stats-reporter
|
|
|
23
23
|
opencode-stats-reporter web start # 打开 http://127.0.0.1:8787
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
- **无需单独填网关和 Key**:默认自动从 opencode 配置读取——取所选模型对应 provider 的 `options.baseURL` / `options.apiKey`(Key 不在配置里时回退到 opencode 凭据存储 `auth.json`);也可显式覆盖:`opencode-stats-reporter config set --gateway <url> --key <key
|
|
26
|
+
- **无需单独填网关和 Key**:默认自动从 opencode 配置读取——取所选模型对应 provider 的 `options.baseURL` / `options.apiKey`(Key 不在配置里时回退到 opencode 凭据存储 `auth.json`);也可显式覆盖:`opencode-stats-reporter config set --gateway <url> --key <key> [--endpoint <url>]`,或环境变量 `OPENCODE_STATS_GATEWAY` / `OPENCODE_STATS_API_KEY`(优先级:本工具配置 > 环境变量 > opencode 推导);
|
|
27
27
|
|
|
28
28
|
- 本地服务同时做三件事:托管静态面板、把 `/v1/usage/*` 与 `/v1/models` **反代**到你的网关(Key 只保存在服务端)、并在本地 SQLite 存储会话用量;
|
|
29
29
|
- 浏览器只与 `localhost` 通信,因此**没有 CORS 问题,上游 Key 也不会进入浏览器**;
|
|
@@ -31,7 +31,7 @@ opencode-stats-reporter web start # 打开 http://127.0.0.1:8787
|
|
|
31
31
|
```bash
|
|
32
32
|
opencode plugin @dijkspicy/opencode-stats-reporter -g
|
|
33
33
|
```
|
|
34
|
-
离线/本地文件方式作为替代:`opencode-stats-reporter plugin install
|
|
34
|
+
离线/本地文件方式作为替代:`opencode-stats-reporter plugin install`。插件**零配置**:启动服务后插件自动上报(`web start` 会把实际端点写入 `config.json`,插件未设环境变量时也生效;如需覆盖可用 `config set --endpoint <url>` 或环境变量 `SESSION_USAGE_ENDPOINT`。本地模式无需 `SESSION_USAGE_API_KEY`,且不要用 `--pure` 启动);
|
|
35
35
|
- 默认仅监听 `127.0.0.1`;数据落在 `~/.local/share/opencode-stats-reporter/`,不会上传第三方;
|
|
36
36
|
- **托管模式**:`opencode-stats-reporter web start --mode gateway` 时,会话接口要求网关注入的身份(`X-Forward-Consumer`),并按消费者做租户隔离;本地模式则为单一本地用户;
|
|
37
37
|
- 要求 Node ≥ 24(使用内置 `node:sqlite`,零外部依赖)。
|
|
@@ -22,11 +22,23 @@ cp session-usage-reporter.ts .opencode/plugins/
|
|
|
22
22
|
- 注意:以 `--pure` 启动 opencode 会禁用全部外部插件(包括本插件)。
|
|
23
23
|
- 一个 opencode server 实例加载一份插件实例;插件按事件里的 `sessionID` 处理对应会话,无需多份。
|
|
24
24
|
|
|
25
|
+
## 零配置:上报端点解析
|
|
26
|
+
|
|
27
|
+
插件**始终激活**,无需任何环境变量。上报端点按以下优先级解析(从高到低):
|
|
28
|
+
|
|
29
|
+
1. 环境变量 `SESSION_USAGE_ENDPOINT`(trim 后非空即用;保留为显式覆盖)
|
|
30
|
+
2. 本工具配置文件 `<configDir>/config.json` 的 `reportEndpoint` 字段(须是合法 http(s) URL)
|
|
31
|
+
3. 内置默认值 `http://127.0.0.1:8787/v1/session-usage/report`
|
|
32
|
+
|
|
33
|
+
其中 `configDir` 为 `MONITOR_CONFIG_DIR` 或 `$XDG_CONFIG_HOME/opencode-stats-reporter`(与 `src/paths.js` 一致)。配套 CLI 的 `web start` 启动后会自动把实际监听端口的端点(`http://127.0.0.1:<port>/v1/session-usage/report`)写入 `config.json` 的 `reportEndpoint`,因此**启动服务 + 安装插件即可,无需任何手动配置**。
|
|
34
|
+
|
|
35
|
+
config.json 不存在、JSON 非法、字段缺失或 URL 非法时一律静默回退到下一优先级,解析过程绝不抛错。插件初始化成功后会通过 `app.log` 输出一条 info 日志(含最终端点与来源 `env/config/default`),便于排查「装了插件却没上报」的问题。
|
|
36
|
+
|
|
25
37
|
## 环境变量
|
|
26
38
|
|
|
27
39
|
| 变量 | 必填 | 说明 |
|
|
28
40
|
| --- | --- | --- |
|
|
29
|
-
| `SESSION_USAGE_ENDPOINT` |
|
|
41
|
+
| `SESSION_USAGE_ENDPOINT` | 否(显式覆盖,优先级最高) | 上报端点 URL。插件对它发 `POST`,`content-type: application/json`,body 见下。不设置时走 `config.json` 的 `reportEndpoint`,再退回内置默认值。 |
|
|
30
42
|
| `SESSION_USAGE_API_KEY` | 否 | 上报请求的网关 Key(插件发 `Authorization: Bearer <key>`)。网关 key-auth 通过后注入 `X-Forward-Consumer`,服务端据此确定租户。未配置则不带鉴权头。 |
|
|
31
43
|
|
|
32
44
|
> **身份来源**:载荷本身**不含**任何消费者身份字段。若 `SESSION_USAGE_ENDPOINT` 指向网关后的服务,必须配 `SESSION_USAGE_API_KEY`,否则请求会被网关 401 拒绝。
|
|
@@ -36,6 +48,7 @@ cp session-usage-reporter.ts .opencode/plugins/
|
|
|
36
48
|
## 行为
|
|
37
49
|
|
|
38
50
|
- **触发时机**:bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`(两种空闲信号都处理)。
|
|
51
|
+
- **初始化可观测**:插件加载即解析端点并通过 `app.log` 记录 info 日志(端点 + 来源);上报失败仅在**每轮连续失败的首 次**记录日志(成功后重置),服务端未运行时不会刷屏。
|
|
39
52
|
- **采集**:经 SDK 读取 `session.messages`(仅统计 `role === "assistant"` 且 `info.time.completed` 已设置的**已完成**消息,天然去重不双计),再经 `session.children` 递归子会话。
|
|
40
53
|
- **父子独立**:每个会话(含子代理会话)各自生成一条快照;**父会话快照绝不并入子会话用量**。
|
|
41
54
|
- **口径原样分列**:`tokens_input / tokens_output / tokens_reasoning / tokens_cache_read / tokens_cache_write` 直接取自 `info.tokens`,**客户端不做任何相加或换算**(opencode 语义:`output` 不含 `reasoning`,`input` 不含 `cache.read`;`cost` 恒为 0,不采信、不上报)。合计由服务端派生。
|
|
@@ -52,7 +65,7 @@ cp session-usage-reporter.ts .opencode/plugins/
|
|
|
52
65
|
{
|
|
53
66
|
"schema_version": 1,
|
|
54
67
|
"install_id": "0b3f7e6a-....",
|
|
55
|
-
"reporter": { "client": "opencode", "version": "0.1.
|
|
68
|
+
"reporter": { "client": "opencode", "version": "0.1.3" },
|
|
56
69
|
"sessions": [
|
|
57
70
|
{
|
|
58
71
|
"session_id": "ses_...",
|
|
@@ -66,13 +79,15 @@ cp session-usage-reporter.ts .opencode/plugins/
|
|
|
66
79
|
"tokens_cache_write": 1320,
|
|
67
80
|
"message_count": 12,
|
|
68
81
|
"session_created_at": 1759000000000,
|
|
82
|
+
"session_title": "每30s自动刷新功能",
|
|
83
|
+
"session_directory": "/opt/projects/BlueRegionUsage",
|
|
69
84
|
"last_activity_at": 1759003600123
|
|
70
85
|
}
|
|
71
86
|
]
|
|
72
87
|
}
|
|
73
88
|
```
|
|
74
89
|
|
|
75
|
-
`model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null
|
|
90
|
+
`model` 取该会话最后一条已完成助手消息的 `providerID/modelID`;`agent` 取其 `mode`(无则为 `null`)。`session_title` / `session_directory` 取自**同一次** `session.get` 调用(不新增 API 请求),取不到时为 `null`(不是空字符串)。载荷中**不存在** consumer/identity 类字段。
|
|
76
91
|
|
|
77
92
|
## 测试
|
|
78
93
|
|
|
@@ -80,13 +95,13 @@ cp session-usage-reporter.ts .opencode/plugins/
|
|
|
80
95
|
bun test reporter.test.ts # 或 bun run reporter.test.ts
|
|
81
96
|
```
|
|
82
97
|
|
|
83
|
-
自包含 mock 测试(mock client + mock fetch),无网络、无需真实 opencode:覆盖已完成消息过滤、父子独立计量、载荷结构与无身份字段、队列上限丢最旧、失败批次保留 + 退避调度、成功清空队列、dispose 成功/失败路径、退避曲线、install_id
|
|
98
|
+
自包含 mock 测试(mock client + mock fetch),无网络、无需真实 opencode:覆盖已完成消息过滤、父子独立计量、载荷结构与无身份字段、队列上限丢最旧、失败批次保留 + 退避调度、成功清空队列、dispose 成功/失败路径、退避曲线、install_id 稳定性、**端点解析优先级(env / config / default 全分支)与未设环境变量时插件仍激活**。
|
|
84
99
|
|
|
85
100
|
## 局限
|
|
86
101
|
|
|
87
102
|
- **队列在内存**:进程崩溃(非正常 dispose 退出)会丢失未上报的积压;队列满后丢最旧。
|
|
88
103
|
- **会话级快照**:无 per-message / 按模型 / 按时间细分;一个会话只记录最后一条消息的 model/agent,多模型会话归属到最后的模型。
|
|
89
104
|
- **消息被压缩/删除**时 `message_count` 可能回退,revision 随之回退——依赖服务端单调守卫保留较高修订。
|
|
90
|
-
-
|
|
105
|
+
- **不携带身份与鉴权**:载荷无凭据;`SESSION_USAGE_API_KEY` 未配置时请求不带鉴权头,完全依赖网关注入消费者身份(与设计 D4/D9 一致);端点本身须由网关/服务端做租户隔离与限流。默认端点指向本机 `127.0.0.1`,服务未启动时数据仅进本地队列(有界、退避重试)。
|
|
91
106
|
- **与 opencode 版本耦合**:消息/事件/插件 loader 形状按 1.18.33 核实,字段均为防御式读取;升级 opencode 后建议重跑测试。
|
|
92
107
|
- 每台机器一个 `install_id`;同一消费者跨机器的会话默认按 `install_id` 分列。
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
createQueue,
|
|
21
21
|
createReporterCore,
|
|
22
22
|
loadOrCreateInstallId,
|
|
23
|
+
resolveReportEndpoint,
|
|
24
|
+
SessionUsageReporter,
|
|
23
25
|
} from "./session-usage-reporter.ts"
|
|
24
26
|
import type {
|
|
25
27
|
ReporterClient,
|
|
@@ -75,12 +77,25 @@ const assistantMessage = (over: Partial<NonNullable<UsageMessage["info"]>> = {})
|
|
|
75
77
|
const makeClient = (
|
|
76
78
|
messages: Record<string, UsageMessage[]>,
|
|
77
79
|
children: Record<string, string[]> = {},
|
|
80
|
+
/** session.get 的返回元数据;不传则 client 不含 session.get */
|
|
81
|
+
meta?: { created?: number | null; title?: string | null; directory?: string | null; throws?: boolean },
|
|
78
82
|
): ReporterClient => ({
|
|
79
83
|
session: {
|
|
80
84
|
messages: async (args: { path: { id: string } }) => ({ data: messages[args.path.id] ?? [] }),
|
|
81
85
|
children: async (args: { path: { id: string } }) => ({
|
|
82
86
|
data: (children[args.path.id] ?? []).map((id) => ({ id })),
|
|
83
87
|
}),
|
|
88
|
+
...(meta
|
|
89
|
+
? {
|
|
90
|
+
get: meta.throws
|
|
91
|
+
? async () => {
|
|
92
|
+
throw new Error("boom: session.get failed")
|
|
93
|
+
}
|
|
94
|
+
: async () => ({
|
|
95
|
+
data: { time: { created: meta.created ?? 1_700_000_000_000 }, title: meta.title ?? null, directory: meta.directory ?? null },
|
|
96
|
+
}),
|
|
97
|
+
}
|
|
98
|
+
: {}),
|
|
84
99
|
},
|
|
85
100
|
})
|
|
86
101
|
|
|
@@ -120,6 +135,8 @@ const snap = (sessionID: string, over: Partial<SessionSnapshot> = {}): SessionSn
|
|
|
120
135
|
tokens_cache_write: 0,
|
|
121
136
|
message_count: 1,
|
|
122
137
|
session_created_at: null,
|
|
138
|
+
session_title: null,
|
|
139
|
+
session_directory: null,
|
|
123
140
|
last_activity_at: 1_000 + snapSeq,
|
|
124
141
|
...over,
|
|
125
142
|
}
|
|
@@ -134,7 +151,9 @@ const SESSION_KEYS = [
|
|
|
134
151
|
"model",
|
|
135
152
|
"revision",
|
|
136
153
|
"session_created_at",
|
|
154
|
+
"session_directory",
|
|
137
155
|
"session_id",
|
|
156
|
+
"session_title",
|
|
138
157
|
"tokens_cache_read",
|
|
139
158
|
"tokens_cache_write",
|
|
140
159
|
"tokens_input",
|
|
@@ -364,6 +383,177 @@ await runTest("loadOrCreateInstallId 首次生成、之后稳定复用同一 UUI
|
|
|
364
383
|
}
|
|
365
384
|
})
|
|
366
385
|
|
|
386
|
+
await runTest("resolveReportEndpoint: env 存在时优先于 config 与默认值", () => {
|
|
387
|
+
const r = resolveReportEndpoint({
|
|
388
|
+
env: { SESSION_USAGE_ENDPOINT: "http://env.example:1/report" },
|
|
389
|
+
configFile: "/nonexistent/config.json",
|
|
390
|
+
readFileSyncFn: () => JSON.stringify({ reportEndpoint: "http://cfg.example:2/report" }),
|
|
391
|
+
})
|
|
392
|
+
assertEqual(r.endpoint, "http://env.example:1/report", "endpoint")
|
|
393
|
+
assertEqual(r.source, "env", "source")
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
await runTest("resolveReportEndpoint: env 为空/空白时回退 config(并 trim)", () => {
|
|
397
|
+
const empty = resolveReportEndpoint({
|
|
398
|
+
env: {},
|
|
399
|
+
configFile: "/nonexistent/config.json",
|
|
400
|
+
readFileSyncFn: () => JSON.stringify({ reportEndpoint: "http://cfg.example:2/report" }),
|
|
401
|
+
})
|
|
402
|
+
assertEqual(empty.endpoint, "http://cfg.example:2/report", "空 env → config")
|
|
403
|
+
assertEqual(empty.source, "config", "source")
|
|
404
|
+
|
|
405
|
+
const blank = resolveReportEndpoint({
|
|
406
|
+
env: { SESSION_USAGE_ENDPOINT: " " },
|
|
407
|
+
configFile: "/nonexistent/config.json",
|
|
408
|
+
readFileSyncFn: () => JSON.stringify({ reportEndpoint: " http://cfg.example:2/report " }),
|
|
409
|
+
})
|
|
410
|
+
assertEqual(blank.endpoint, "http://cfg.example:2/report", "空白 env → config(值已 trim)")
|
|
411
|
+
assertEqual(blank.source, "config", "source")
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await runTest("resolveReportEndpoint: config 缺失/JSON 非法/reportEndpoint 非法 → 回退默认值", () => {
|
|
415
|
+
const env = {}
|
|
416
|
+
const missing = resolveReportEndpoint({
|
|
417
|
+
env,
|
|
418
|
+
configFile: "/nonexistent/config.json",
|
|
419
|
+
readFileSyncFn: () => {
|
|
420
|
+
throw new Error("ENOENT")
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
assertEqual(missing.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "config 缺失 → 默认")
|
|
424
|
+
assertEqual(missing.source, "default", "source")
|
|
425
|
+
|
|
426
|
+
const badJson = resolveReportEndpoint({ env, configFile: "/x", readFileSyncFn: () => "{not json" })
|
|
427
|
+
assertEqual(badJson.source, "default", "JSON 非法 → 默认")
|
|
428
|
+
|
|
429
|
+
const badUrl = resolveReportEndpoint({
|
|
430
|
+
env,
|
|
431
|
+
configFile: "/x",
|
|
432
|
+
readFileSyncFn: () => JSON.stringify({ reportEndpoint: "not-a-url" }),
|
|
433
|
+
})
|
|
434
|
+
assertEqual(badUrl.source, "default", "非法 URL → 默认")
|
|
435
|
+
|
|
436
|
+
const wrongScheme = resolveReportEndpoint({
|
|
437
|
+
env,
|
|
438
|
+
configFile: "/x",
|
|
439
|
+
readFileSyncFn: () => JSON.stringify({ reportEndpoint: "ftp://example.com/report" }),
|
|
440
|
+
})
|
|
441
|
+
assertEqual(wrongScheme.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "非 http(s) → 默认")
|
|
442
|
+
|
|
443
|
+
const wrongType = resolveReportEndpoint({
|
|
444
|
+
env,
|
|
445
|
+
configFile: "/x",
|
|
446
|
+
readFileSyncFn: () => JSON.stringify({ reportEndpoint: 123 }),
|
|
447
|
+
})
|
|
448
|
+
assertEqual(wrongType.source, "default", "非字符串字段 → 默认")
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
await runTest("resolveReportEndpoint: 三者皆缺 → 内置默认值", () => {
|
|
452
|
+
const r = resolveReportEndpoint({
|
|
453
|
+
env: {},
|
|
454
|
+
configFile: "/nonexistent/config.json",
|
|
455
|
+
readFileSyncFn: () => {
|
|
456
|
+
throw new Error("ENOENT")
|
|
457
|
+
},
|
|
458
|
+
})
|
|
459
|
+
assertEqual(r.endpoint, "http://127.0.0.1:8787/v1/session-usage/report", "默认端点")
|
|
460
|
+
assertEqual(r.source, "default", "来源")
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
await runTest("插件未设 SESSION_USAGE_ENDPOINT 时依然激活(返回 event/dispose 并输出端点日志)", async () => {
|
|
464
|
+
const savedEndpoint = process.env.SESSION_USAGE_ENDPOINT
|
|
465
|
+
const savedConfigDir = process.env.MONITOR_CONFIG_DIR
|
|
466
|
+
delete process.env.SESSION_USAGE_ENDPOINT
|
|
467
|
+
// 指向不存在的配置目录 → config 分支静默回退默认值,测试不依赖真实 HOME
|
|
468
|
+
process.env.MONITOR_CONFIG_DIR = join(tmpdir(), `sur-resolve-${randomUUID()}`)
|
|
469
|
+
const logs: Array<{ body?: { level?: string; service?: string; message?: string } }> = []
|
|
470
|
+
try {
|
|
471
|
+
const fakeCtx = {
|
|
472
|
+
client: {
|
|
473
|
+
app: {
|
|
474
|
+
log: (args: { body?: { level?: string; service?: string; message?: string } }) => {
|
|
475
|
+
logs.push(args)
|
|
476
|
+
return Promise.resolve()
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
session: makeClient({}).session,
|
|
480
|
+
},
|
|
481
|
+
directory: "/tmp/fake-project",
|
|
482
|
+
} as unknown as Parameters<typeof SessionUsageReporter>[0]
|
|
483
|
+
const hooks = (await SessionUsageReporter(fakeCtx)) as Record<string, unknown>
|
|
484
|
+
assert(typeof hooks.event === "function", "不再惰性:应返回 event hook")
|
|
485
|
+
assert(typeof hooks.dispose === "function", "不再惰性:应返回 dispose hook")
|
|
486
|
+
const initLog = logs.map((l) => l.body?.message ?? "").find((m) => m.includes("端点"))
|
|
487
|
+
assert(
|
|
488
|
+
!!initLog &&
|
|
489
|
+
initLog.includes("http://127.0.0.1:8787/v1/session-usage/report") &&
|
|
490
|
+
initLog.includes("default"),
|
|
491
|
+
`初始化日志应包含端点与来源,实际: ${initLog}`,
|
|
492
|
+
)
|
|
493
|
+
} finally {
|
|
494
|
+
if (savedEndpoint === undefined) delete process.env.SESSION_USAGE_ENDPOINT
|
|
495
|
+
else process.env.SESSION_USAGE_ENDPOINT = savedEndpoint
|
|
496
|
+
if (savedConfigDir === undefined) delete process.env.MONITOR_CONFIG_DIR
|
|
497
|
+
else process.env.MONITOR_CONFIG_DIR = savedConfigDir
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
await runTest("session.get 返回 title/directory → 快照与载荷带上,且复用同一次调用", async () => {
|
|
502
|
+
const client = makeClient(
|
|
503
|
+
{ ses_meta: [assistantMessage()] },
|
|
504
|
+
{},
|
|
505
|
+
{ created: 1_759_000_000_000, title: "每30s自动刷新功能", directory: "/opt/projects/BlueRegionUsage" },
|
|
506
|
+
)
|
|
507
|
+
let getCalls = 0
|
|
508
|
+
const origGet = client.session.get!
|
|
509
|
+
client.session.get = (async (args: { path: { id: string } }) => {
|
|
510
|
+
getCalls += 1
|
|
511
|
+
return origGet(args)
|
|
512
|
+
}) as typeof client.session.get
|
|
513
|
+
|
|
514
|
+
const { fetchFn, calls } = makeFetch("ok")
|
|
515
|
+
const { core } = makeCore(client, { endpoint: "http://e", fetchFn })
|
|
516
|
+
await core.collectSession("ses_meta")
|
|
517
|
+
|
|
518
|
+
const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_meta")
|
|
519
|
+
assert(!!snapshot, "应产出快照")
|
|
520
|
+
assertEqual(snapshot!.session_title, "每30s自动刷新功能", "快照 title")
|
|
521
|
+
assertEqual(snapshot!.session_directory, "/opt/projects/BlueRegionUsage", "快照 directory")
|
|
522
|
+
assertEqual(snapshot!.session_created_at, 1_759_000_000_000, "快照 created")
|
|
523
|
+
assertEqual(getCalls, 1, "单会话恰好一次 session.get(不新增 API 请求)")
|
|
524
|
+
|
|
525
|
+
// 载荷层面
|
|
526
|
+
const q = createQueue(50)
|
|
527
|
+
q.push(snapshot!)
|
|
528
|
+
const core2 = makeCore(makeClient({}), { endpoint: "http://e", queue: q, fetchFn }).core
|
|
529
|
+
await core2.flushOnce()
|
|
530
|
+
const body = JSON.parse(calls[0]!.init.body ?? "{}")
|
|
531
|
+
assertEqual(body.sessions[0].session_title, "每30s自动刷新功能", "载荷 title")
|
|
532
|
+
assertEqual(body.sessions[0].session_directory, "/opt/projects/BlueRegionUsage", "载荷 directory")
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
await runTest("session.get 抛错 → title/directory/created 为 null 而非崩溃", async () => {
|
|
536
|
+
const client = makeClient({ ses_err: [assistantMessage()] }, {}, { throws: true })
|
|
537
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
538
|
+
await core.collectSession("ses_err")
|
|
539
|
+
const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_err")
|
|
540
|
+
assert(!!snapshot, "采集主流程不受影响,快照仍产生")
|
|
541
|
+
assertEqual(snapshot!.session_title, null, "title → null")
|
|
542
|
+
assertEqual(snapshot!.session_directory, null, "directory → null")
|
|
543
|
+
assertEqual(snapshot!.session_created_at, null, "created → null")
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
await runTest("session.get 不存在 → 三个元数据字段均为 null", async () => {
|
|
547
|
+
const client = makeClient({ ses_noget: [assistantMessage()] }) // 无 session.get
|
|
548
|
+
const { core } = makeCore(client, { endpoint: "http://e" })
|
|
549
|
+
await core.collectSession("ses_noget")
|
|
550
|
+
const snapshot = core.queue.drainAll().find((s) => s.session_id === "ses_noget")
|
|
551
|
+
assert(!!snapshot, "应产出快照")
|
|
552
|
+
assertEqual(snapshot!.session_title, null, "title → null")
|
|
553
|
+
assertEqual(snapshot!.session_directory, null, "directory → null")
|
|
554
|
+
assertEqual(snapshot!.session_created_at, null, "created → null")
|
|
555
|
+
})
|
|
556
|
+
|
|
367
557
|
// ---------------------------------------------------------------------------
|
|
368
558
|
// 汇总
|
|
369
559
|
// ---------------------------------------------------------------------------
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
|
|
8
8
|
* - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
|
|
9
9
|
* 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
|
|
10
|
-
* - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST
|
|
10
|
+
* - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到解析出的上报端点
|
|
11
|
+
* (零配置解析:环境变量 SESSION_USAGE_ENDPOINT > <configDir>/config.json 的 reportEndpoint > 内置默认值)。
|
|
11
12
|
* - 有界内存队列(默认 500 条快照,溢出丢最旧)+ 指数退避(带抖动)+ AbortSignal.timeout 超时。
|
|
12
13
|
* - `dispose` 钩子在进程退出时以约 3 秒预算尽力刷新队列,超时即放弃,绝不阻塞退出。
|
|
13
14
|
* - 载荷不含任何消费者身份字段;身份完全由网关(key-auth 注入 X-Forward-Consumer)决定。
|
|
@@ -26,11 +27,13 @@
|
|
|
26
27
|
* 绝不能抛错、返回 null 或 undefined(否则 `V.event?.` 会读到空值导致崩溃)。
|
|
27
28
|
*
|
|
28
29
|
* 安装:复制本文件到 ~/.config/opencode/plugins/(全局)或 <项目>/.opencode/plugins/(项目级)。
|
|
29
|
-
*
|
|
30
|
+
* 启用:零配置即生效(插件始终激活),初始化时会通过 app.log 输出最终端点与来源;
|
|
31
|
+
* 如需覆盖端点,可设置环境变量 SESSION_USAGE_ENDPOINT 或用配套 CLI 写 config.json 的 reportEndpoint。
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
34
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
33
35
|
import { randomUUID } from "node:crypto"
|
|
36
|
+
import { readFileSync } from "node:fs"
|
|
34
37
|
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
35
38
|
import { homedir } from "node:os"
|
|
36
39
|
import { dirname, join } from "node:path"
|
|
@@ -40,7 +43,7 @@ import { dirname, join } from "node:path"
|
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
41
44
|
|
|
42
45
|
const SCHEMA_VERSION = 1
|
|
43
|
-
const PLUGIN_VERSION = "0.1.
|
|
46
|
+
const PLUGIN_VERSION = "0.1.3"
|
|
44
47
|
/** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
|
|
45
48
|
const QUEUE_CAP = 500
|
|
46
49
|
/** 单次上报请求超时(AbortSignal.timeout) */
|
|
@@ -59,6 +62,20 @@ const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode
|
|
|
59
62
|
|
|
60
63
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
61
64
|
|
|
65
|
+
/** 零配置默认上报端点(与本地 `web start` 的默认端口一致) */
|
|
66
|
+
const DEFAULT_REPORT_ENDPOINT = "http://127.0.0.1:8787/v1/session-usage/report"
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 本工具配置目录:必须与 src/paths.js 的 configDir() 完全一致。
|
|
70
|
+
* 本文件保持独立可复制,故此处自带一份等价实现(不 import src/)。
|
|
71
|
+
*/
|
|
72
|
+
const configFilePath = (): string =>
|
|
73
|
+
join(
|
|
74
|
+
process.env.MONITOR_CONFIG_DIR ||
|
|
75
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode-stats-reporter"),
|
|
76
|
+
"config.json",
|
|
77
|
+
)
|
|
78
|
+
|
|
62
79
|
// ---------------------------------------------------------------------------
|
|
63
80
|
// 类型(type-only 导出在运行时会被擦除,不影响 loader 的「导出必须全是函数」校验)
|
|
64
81
|
// ---------------------------------------------------------------------------
|
|
@@ -94,6 +111,8 @@ export type SessionSnapshot = {
|
|
|
94
111
|
tokens_cache_write: number
|
|
95
112
|
message_count: number
|
|
96
113
|
session_created_at: number | null
|
|
114
|
+
session_title: string | null
|
|
115
|
+
session_directory: string | null
|
|
97
116
|
last_activity_at: number | null
|
|
98
117
|
}
|
|
99
118
|
|
|
@@ -113,6 +132,13 @@ export type ReportPayload = {
|
|
|
113
132
|
sessions: SessionSnapshot[]
|
|
114
133
|
}
|
|
115
134
|
|
|
135
|
+
export type ReportEndpointSource = "env" | "config" | "default"
|
|
136
|
+
|
|
137
|
+
export type ResolvedReportEndpoint = {
|
|
138
|
+
endpoint: string
|
|
139
|
+
source: ReportEndpointSource
|
|
140
|
+
}
|
|
141
|
+
|
|
116
142
|
/** 本插件实际用到的 opencode SDK client 最小形状(便于 mock 测试) */
|
|
117
143
|
export type ReporterClient = {
|
|
118
144
|
session: {
|
|
@@ -232,6 +258,8 @@ const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage
|
|
|
232
258
|
tokens_cache_write: cacheWrite,
|
|
233
259
|
message_count: count,
|
|
234
260
|
session_created_at: null, // 由采集层补充
|
|
261
|
+
session_title: null, // 由采集层补充(session.get)
|
|
262
|
+
session_directory: null, // 由采集层补充(session.get)
|
|
235
263
|
last_activity_at: lastActivity,
|
|
236
264
|
}
|
|
237
265
|
}
|
|
@@ -340,6 +368,51 @@ const loadOrCreateInstallIdImpl = async (
|
|
|
340
368
|
return id
|
|
341
369
|
}
|
|
342
370
|
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// 零配置端点解析:env > config.json(reportEndpoint) > 内置默认值;绝不抛错
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
const isValidHttpUrl = (value: string): boolean => {
|
|
376
|
+
try {
|
|
377
|
+
const parsed = new URL(value)
|
|
378
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
|
379
|
+
} catch {
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 解析上报端点(优先级从高到低):
|
|
386
|
+
* 1. 环境变量 SESSION_USAGE_ENDPOINT(trim 后非空即用,显式覆盖)
|
|
387
|
+
* 2. 本工具配置文件 <configDir>/config.json 的 reportEndpoint 字段(须为合法 http(s) URL)
|
|
388
|
+
* 3. 内置默认值 http://127.0.0.1:8787/v1/session-usage/report
|
|
389
|
+
* config.json 不存在 / JSON 非法 / 字段缺失 / URL 非法 → 一律静默回退到下一优先级。
|
|
390
|
+
* 依赖全部可注入(env / configFile / readFileSyncFn),便于单测且不碰真实环境。
|
|
391
|
+
*/
|
|
392
|
+
const resolveReportEndpointImpl = (
|
|
393
|
+
options: {
|
|
394
|
+
env?: Record<string, string | undefined>
|
|
395
|
+
configFile?: string
|
|
396
|
+
readFileSyncFn?: (file: string, encoding: "utf8") => string
|
|
397
|
+
} = {},
|
|
398
|
+
): ResolvedReportEndpoint => {
|
|
399
|
+
const env = options.env ?? process.env
|
|
400
|
+
const fromEnv = (env.SESSION_USAGE_ENDPOINT ?? "").trim()
|
|
401
|
+
if (fromEnv) return { endpoint: fromEnv, source: "env" }
|
|
402
|
+
|
|
403
|
+
const configFile = options.configFile ?? configFilePath()
|
|
404
|
+
const readFileSyncFn = options.readFileSyncFn ?? ((file: string) => readFileSync(file, "utf8"))
|
|
405
|
+
try {
|
|
406
|
+
const parsed = JSON.parse(readFileSyncFn(configFile, "utf8")) as { reportEndpoint?: unknown } | null
|
|
407
|
+
const candidate = typeof parsed?.reportEndpoint === "string" ? parsed.reportEndpoint.trim() : ""
|
|
408
|
+
if (candidate && isValidHttpUrl(candidate)) return { endpoint: candidate, source: "config" }
|
|
409
|
+
} catch {
|
|
410
|
+
// 文件不存在 / 不可读 / JSON 非法:静默回退到默认值
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return { endpoint: DEFAULT_REPORT_ENDPOINT, source: "default" }
|
|
414
|
+
}
|
|
415
|
+
|
|
343
416
|
// ---------------------------------------------------------------------------
|
|
344
417
|
// 核心:采集 + 非阻塞队列 + 退避重试 + dispose 限时刷新
|
|
345
418
|
// ---------------------------------------------------------------------------
|
|
@@ -365,6 +438,7 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
365
438
|
let flushCancel: (() => void) | null = null
|
|
366
439
|
let retryCancel: (() => void) | null = null
|
|
367
440
|
let retryAttempt = 0
|
|
441
|
+
let failureLogged = false
|
|
368
442
|
const inFlight = new Set<string>()
|
|
369
443
|
|
|
370
444
|
const send = async (snapshots: readonly SessionSnapshot[], timeoutMs: number): Promise<void> => {
|
|
@@ -392,13 +466,18 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
392
466
|
try {
|
|
393
467
|
await send(batch, requestTimeoutMs)
|
|
394
468
|
retryAttempt = 0
|
|
469
|
+
failureLogged = false // 成功即重置:下一轮失败重新记录首次
|
|
395
470
|
return true
|
|
396
471
|
} catch (error) {
|
|
397
472
|
queue.restore(batch)
|
|
398
473
|
retryAttempt += 1
|
|
399
474
|
// 第 1 次失败 → 基础 1s,此后指数翻倍(2s、4s…),封顶 5 分钟,均带抖动
|
|
400
475
|
const delay = computeBackoffMsImpl(Math.max(0, retryAttempt - 1), random())
|
|
401
|
-
|
|
476
|
+
// 降噪:连续失败只记录首轮,避免服务端未运行时刷屏
|
|
477
|
+
if (!failureLogged) {
|
|
478
|
+
failureLogged = true
|
|
479
|
+
log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
|
|
480
|
+
}
|
|
402
481
|
if (!retryCancel) {
|
|
403
482
|
retryCancel = scheduler(() => {
|
|
404
483
|
retryCancel = null
|
|
@@ -438,16 +517,29 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
438
517
|
return ids
|
|
439
518
|
}
|
|
440
519
|
|
|
441
|
-
const
|
|
520
|
+
const readSessionMeta = async (
|
|
521
|
+
sessionID: string,
|
|
522
|
+
): Promise<{ created: number | null; title: string | null; directory: string | null }> => {
|
|
442
523
|
try {
|
|
443
524
|
const getSession = options.client.session.get
|
|
444
|
-
if (typeof getSession !== "function") return null
|
|
525
|
+
if (typeof getSession !== "function") return { created: null, title: null, directory: null }
|
|
445
526
|
const response = await getSession.call(options.client.session, { path: { id: sessionID }, query: query() })
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
527
|
+
const data = (
|
|
528
|
+
response as
|
|
529
|
+
| { data?: { time?: { created?: unknown }; title?: unknown; directory?: unknown } }
|
|
530
|
+
| undefined
|
|
531
|
+
)?.data
|
|
532
|
+
const created = data?.time?.created
|
|
533
|
+
const title = data?.title
|
|
534
|
+
const directory = data?.directory
|
|
535
|
+
return {
|
|
536
|
+
created: typeof created === "number" ? created : null,
|
|
537
|
+
title: typeof title === "string" && title ? title : null,
|
|
538
|
+
directory: typeof directory === "string" && directory ? directory : null,
|
|
539
|
+
}
|
|
449
540
|
} catch {
|
|
450
|
-
|
|
541
|
+
// session.get 抛错 / 形状异常:元数据缺省为 null,绝不影响采集主流程
|
|
542
|
+
return { created: null, title: null, directory: null }
|
|
451
543
|
}
|
|
452
544
|
}
|
|
453
545
|
|
|
@@ -462,7 +554,11 @@ const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
|
462
554
|
visited.add(sessionID)
|
|
463
555
|
const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
|
|
464
556
|
if (snapshot) {
|
|
465
|
-
|
|
557
|
+
// 复用同一次 session.get:created / title / directory 一并取得,不新增 API 请求
|
|
558
|
+
const meta = await readSessionMeta(sessionID)
|
|
559
|
+
snapshot.session_created_at = meta.created
|
|
560
|
+
snapshot.session_title = meta.title
|
|
561
|
+
snapshot.session_directory = meta.directory
|
|
466
562
|
out.push(snapshot)
|
|
467
563
|
}
|
|
468
564
|
for (const childID of await readChildIDs(sessionID)) {
|
|
@@ -544,17 +640,15 @@ export const buildPayload = pluginSafe(buildPayloadImpl)
|
|
|
544
640
|
export const createQueue = pluginSafe(createQueueImpl)
|
|
545
641
|
export const computeBackoffMs = pluginSafe(computeBackoffMsImpl)
|
|
546
642
|
export const loadOrCreateInstallId = pluginSafe(loadOrCreateInstallIdImpl)
|
|
643
|
+
export const resolveReportEndpoint = pluginSafe(resolveReportEndpointImpl)
|
|
547
644
|
export const createReporterCore = pluginSafe(createReporterCoreImpl)
|
|
548
645
|
|
|
549
646
|
/**
|
|
550
|
-
*
|
|
551
|
-
*
|
|
647
|
+
* 插件入口:零配置,始终激活。
|
|
648
|
+
* 端点按 env > config.json > 默认值 解析;初始化成功后通过 app.log 输出端点与来源,便于排查。
|
|
552
649
|
*/
|
|
553
650
|
export const SessionUsageReporter: Plugin = async (ctx) => {
|
|
554
651
|
try {
|
|
555
|
-
const endpoint = (process.env.SESSION_USAGE_ENDPOINT ?? "").trim() || undefined
|
|
556
|
-
if (!endpoint) return {}
|
|
557
|
-
|
|
558
652
|
const log = (message: string, error?: unknown): void => {
|
|
559
653
|
try {
|
|
560
654
|
const detail = error instanceof Error ? `${error.name}: ${error.message}` : error === undefined ? "" : String(error)
|
|
@@ -573,17 +667,21 @@ export const SessionUsageReporter: Plugin = async (ctx) => {
|
|
|
573
667
|
}
|
|
574
668
|
}
|
|
575
669
|
|
|
670
|
+
const resolved = resolveReportEndpointImpl()
|
|
576
671
|
const installId = await loadOrCreateInstallIdImpl()
|
|
577
672
|
const core = createReporterCoreImpl({
|
|
578
673
|
client: ctx.client as unknown as ReporterClient,
|
|
579
674
|
directory: ctx.directory,
|
|
580
|
-
endpoint,
|
|
675
|
+
endpoint: resolved.endpoint,
|
|
581
676
|
installId,
|
|
582
677
|
// 上报请求的网关 Key(APIG key-auth 后注入 X-Forward-Consumer);未配置则不带鉴权头
|
|
583
678
|
authToken: (process.env.SESSION_USAGE_API_KEY ?? "").trim() || undefined,
|
|
584
679
|
log,
|
|
585
680
|
})
|
|
586
681
|
|
|
682
|
+
// 可观测性:明确告知最终端点与来源(排查「装了插件却没上报」的关键线索)
|
|
683
|
+
log(`session-usage: 已启用会话用量上报,端点 ${resolved.endpoint}(来源: ${resolved.source})`)
|
|
684
|
+
|
|
587
685
|
return {
|
|
588
686
|
event: async (input: { event?: { type?: unknown; properties?: unknown } }) => {
|
|
589
687
|
try {
|
package/package.json
CHANGED
package/panel/css/style.css
CHANGED
|
@@ -527,22 +527,63 @@ body {
|
|
|
527
527
|
#session-table tbody tr:hover { background: rgba(127,127,127,.06); }
|
|
528
528
|
#session-table tbody tr:last-child td { border-bottom: none; }
|
|
529
529
|
|
|
530
|
-
/*
|
|
531
|
-
.sess-
|
|
530
|
+
/* 会话首列:两行结构(主行标题 / 次行目录+ID),次要信息弱化不喧宾夺主 */
|
|
531
|
+
.sess-title-line { min-width: 0; }
|
|
532
|
+
.sess-title {
|
|
532
533
|
display: inline-block;
|
|
534
|
+
max-width: 340px;
|
|
535
|
+
overflow: hidden;
|
|
536
|
+
text-overflow: ellipsis;
|
|
537
|
+
white-space: nowrap;
|
|
538
|
+
vertical-align: bottom;
|
|
539
|
+
font-weight: 600;
|
|
540
|
+
cursor: pointer;
|
|
541
|
+
}
|
|
542
|
+
.sess-title:hover { color: var(--accent-hover); text-decoration: underline; }
|
|
543
|
+
.sess-sub-line {
|
|
544
|
+
display: flex;
|
|
545
|
+
align-items: center;
|
|
546
|
+
gap: 6px;
|
|
547
|
+
margin-top: 2px;
|
|
548
|
+
font-size: 11px;
|
|
549
|
+
color: var(--text-secondary);
|
|
550
|
+
}
|
|
551
|
+
.sess-sub-sep { flex: none; }
|
|
552
|
+
.sess-dir {
|
|
533
553
|
max-width: 200px;
|
|
534
554
|
overflow: hidden;
|
|
535
555
|
text-overflow: ellipsis;
|
|
536
556
|
white-space: nowrap;
|
|
557
|
+
font-size: 11px;
|
|
558
|
+
cursor: default;
|
|
559
|
+
}
|
|
560
|
+
@media (max-width: 767px) {
|
|
561
|
+
.sess-title { max-width: 180px; }
|
|
562
|
+
.sess-dir { max-width: 120px; }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/* 会话 ID:默认弱化(有标题时),等宽头尾截断,完整值在 title,点击进详情 */
|
|
566
|
+
.sess-id-btn {
|
|
567
|
+
display: inline-block;
|
|
568
|
+
max-width: 130px;
|
|
569
|
+
overflow: hidden;
|
|
570
|
+
text-overflow: ellipsis;
|
|
571
|
+
white-space: nowrap;
|
|
537
572
|
vertical-align: bottom;
|
|
538
573
|
padding: 0;
|
|
539
574
|
border: none;
|
|
540
575
|
background: transparent;
|
|
541
|
-
color: var(--
|
|
576
|
+
color: var(--text-secondary);
|
|
542
577
|
font-family: Consolas, monospace;
|
|
543
|
-
font-size:
|
|
578
|
+
font-size: 11px;
|
|
544
579
|
cursor: pointer;
|
|
545
580
|
}
|
|
581
|
+
/* 无标题时的降级主标识:恢复高亮,保证一眼可点 */
|
|
582
|
+
.sess-id-btn.is-primary {
|
|
583
|
+
max-width: 200px;
|
|
584
|
+
color: var(--accent);
|
|
585
|
+
font-size: 12px;
|
|
586
|
+
}
|
|
546
587
|
.sess-id-btn:hover { color: var(--accent-hover); text-decoration: underline; }
|
|
547
588
|
.sess-id-btn:focus-visible { outline: 1px solid var(--accent); }
|
|
548
589
|
|
package/panel/index.html
CHANGED
package/panel/js/format.js
CHANGED
|
@@ -106,6 +106,15 @@
|
|
|
106
106
|
' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** 毫秒时间戳 -> 'MM-DD HH:MM'(当年,会话列表"最后活动"列);跨年回退完整格式,完整值另放 title */
|
|
110
|
+
function shortDateTime(ms) {
|
|
111
|
+
if (ms === null || ms === undefined || !isFinite(Number(ms))) return '--';
|
|
112
|
+
var d = new Date(Number(ms));
|
|
113
|
+
if (d.getFullYear() !== new Date().getFullYear()) return dateTime(ms);
|
|
114
|
+
function p(x) { return (x < 10 ? '0' : '') + x; }
|
|
115
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/**
|
|
110
119
|
* 环比变化(比值):当前值与上一周期值比较,返回 { text, dir }
|
|
111
120
|
* dir: 1 升 / -1 降 / 0 持平;基准为 0 或空值时返回 null(不展示)
|
|
@@ -149,6 +158,7 @@
|
|
|
149
158
|
todayStr: todayStr,
|
|
150
159
|
hourLabel: hourLabel,
|
|
151
160
|
dateTime: dateTime,
|
|
161
|
+
shortDateTime: shortDateTime,
|
|
152
162
|
pctChange: pctChange,
|
|
153
163
|
ppChange: ppChange,
|
|
154
164
|
maskKey: maskKey
|
package/panel/js/sessions.js
CHANGED
|
@@ -196,18 +196,84 @@
|
|
|
196
196
|
st.rows.forEach(function (r) { tbody.appendChild(sessionRow(r)); });
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/** 空值守卫:非字符串或空白串返回 null,避免把 null/undefined/空串渲染到页面 */
|
|
200
|
+
function nonEmpty(v) {
|
|
201
|
+
return (typeof v === 'string' && v.trim()) ? v.trim() : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 会话 ID 头尾截断:ses_k2X9fjQ3mT…89xQ,头尾是最有辨识度的部分;完整值在 title 与详情 */
|
|
205
|
+
function shortId(id) {
|
|
206
|
+
return id.length > 20 ? id.slice(0, 14) + '…' + id.slice(-4) : id;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 目录压缩:取末两级,如 /opt/projects/BlueRegionUsage -> …/projects/BlueRegionUsage;完整路径放 title */
|
|
210
|
+
function shortDir(dir) {
|
|
211
|
+
var parts = String(dir).replace(/\\/g, '/').split('/').filter(function (s) { return s !== ''; });
|
|
212
|
+
if (!parts.length) return dir;
|
|
213
|
+
if (parts.length <= 2) return '/' + parts.join('/');
|
|
214
|
+
return '…/' + parts.slice(-2).join('/');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 会话 ID 按钮:等宽展示(头尾截断),完整值放 title,点击进详情;primary = 无标题时作为降级主标识 */
|
|
218
|
+
function idButton(sid, primary) {
|
|
219
|
+
var b = document.createElement('button');
|
|
220
|
+
b.type = 'button';
|
|
221
|
+
b.className = 'sess-id-btn mono' + (primary ? ' is-primary' : '');
|
|
222
|
+
b.textContent = shortId(sid);
|
|
223
|
+
b.title = sid + '\n点击查看详情';
|
|
224
|
+
b.addEventListener('click', function () { openDetail(sid); });
|
|
225
|
+
return b;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 首列「会话」:两行结构
|
|
230
|
+
* 主行:会话标题(加粗);无标题时降级为会话 ID(等宽、高亮、可点)
|
|
231
|
+
* 次行:项目目录(末两级压缩,悬浮显全路径)· 会话 ID(弱化;ID 已作主标识时不重复)
|
|
232
|
+
*/
|
|
199
233
|
function sessionRow(r) {
|
|
200
234
|
var tr = document.createElement('tr');
|
|
201
|
-
|
|
202
|
-
var
|
|
203
|
-
var
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
235
|
+
var title = nonEmpty(r.session_title);
|
|
236
|
+
var dir = nonEmpty(r.session_directory);
|
|
237
|
+
var sid = nonEmpty(r.session_id) || '--';
|
|
238
|
+
|
|
239
|
+
var cell = document.createElement('td');
|
|
240
|
+
cell.className = 'sess-cell';
|
|
241
|
+
|
|
242
|
+
var line1 = document.createElement('div');
|
|
243
|
+
line1.className = 'sess-title-line';
|
|
244
|
+
if (title) {
|
|
245
|
+
var t = document.createElement('span');
|
|
246
|
+
t.className = 'sess-title';
|
|
247
|
+
t.textContent = title;
|
|
248
|
+
t.title = title + '\n点击查看详情';
|
|
249
|
+
t.addEventListener('click', function () { openDetail(sid); });
|
|
250
|
+
line1.appendChild(t);
|
|
251
|
+
} else {
|
|
252
|
+
line1.appendChild(idButton(sid, true));
|
|
253
|
+
}
|
|
254
|
+
cell.appendChild(line1);
|
|
255
|
+
|
|
256
|
+
var line2 = document.createElement('div');
|
|
257
|
+
line2.className = 'sess-sub-line';
|
|
258
|
+
if (dir) {
|
|
259
|
+
var d = document.createElement('span');
|
|
260
|
+
d.className = 'sess-dir mono';
|
|
261
|
+
d.textContent = shortDir(dir);
|
|
262
|
+
d.title = '目录:' + dir;
|
|
263
|
+
line2.appendChild(d);
|
|
264
|
+
}
|
|
265
|
+
if (title) {
|
|
266
|
+
if (dir) {
|
|
267
|
+
var sep = document.createElement('span');
|
|
268
|
+
sep.className = 'sess-sub-sep';
|
|
269
|
+
sep.textContent = '·';
|
|
270
|
+
line2.appendChild(sep);
|
|
271
|
+
}
|
|
272
|
+
line2.appendChild(idButton(sid, false));
|
|
273
|
+
}
|
|
274
|
+
if (line2.childNodes.length) cell.appendChild(line2);
|
|
275
|
+
|
|
276
|
+
tr.appendChild(cell);
|
|
211
277
|
|
|
212
278
|
tr.appendChild(td(r.model || '--'));
|
|
213
279
|
tr.appendChild(td(r.agent || '--'));
|
|
@@ -217,7 +283,8 @@
|
|
|
217
283
|
tr.appendChild(td(F.fullNumber(r.tokens_cache_read), 'num'));
|
|
218
284
|
tr.appendChild(td(F.fullNumber(r.tokens_cache_write), 'num'));
|
|
219
285
|
tr.appendChild(td(F.fullNumber(r.message_count), 'num'));
|
|
220
|
-
|
|
286
|
+
var lastFull = F.dateTime(r.last_activity_at);
|
|
287
|
+
tr.appendChild(td(F.shortDateTime(r.last_activity_at), null, lastFull === '--' ? '' : lastFull));
|
|
221
288
|
return tr;
|
|
222
289
|
}
|
|
223
290
|
|
|
@@ -312,6 +379,8 @@
|
|
|
312
379
|
]));
|
|
313
380
|
|
|
314
381
|
body.appendChild(group('会话信息', [
|
|
382
|
+
item('会话标题', nonEmpty(d.session_title) || '--'),
|
|
383
|
+
item('项目目录', nonEmpty(d.session_directory) || '--', true),
|
|
315
384
|
item('模型', d.model || '--'),
|
|
316
385
|
item('代理', d.agent || '--'),
|
|
317
386
|
item('安装标识', d.install_id || '--', true),
|
package/src/cli.js
CHANGED
|
@@ -42,11 +42,12 @@ function parseFlags(argv) {
|
|
|
42
42
|
const HELP = `opencode-stats-reporter — 本地一体化 MaaS 用量看板
|
|
43
43
|
|
|
44
44
|
用法:
|
|
45
|
-
opencode-stats-reporter config set --gateway <url> --key <key>
|
|
45
|
+
opencode-stats-reporter config set --gateway <url> --key <key> [--endpoint <url>]
|
|
46
|
+
配置上游网关、Key 与插件上报端点
|
|
46
47
|
opencode-stats-reporter config get 查看配置(Key 脱敏)
|
|
47
48
|
opencode-stats-reporter web start [--port 8787] [--host 127.0.0.1] [--open]
|
|
48
|
-
|
|
49
|
-
opencode-stats-reporter plugin install [--port 8787] 安装 opencode
|
|
49
|
+
启动本地服务并托管面板(自动写入插件上报端点 reportEndpoint)
|
|
50
|
+
opencode-stats-reporter plugin install [--port 8787] 安装 opencode 上报插件(零配置,默认上报本机服务)
|
|
50
51
|
|
|
51
52
|
选项:
|
|
52
53
|
-h, --help 显示帮助
|
|
@@ -57,15 +58,17 @@ function cmdConfig(argv) {
|
|
|
57
58
|
const sub = argv[0]
|
|
58
59
|
const flags = parseFlags(argv.slice(1))
|
|
59
60
|
if (sub === "set") {
|
|
60
|
-
const cfg = setConfig({ gateway: flags.gateway, key: flags.key })
|
|
61
|
+
const cfg = setConfig({ gateway: flags.gateway, key: flags.key, endpoint: flags.endpoint })
|
|
61
62
|
console.log("已写入配置: " + (cfg.gatewayBaseUrl || "(未设置)"))
|
|
62
63
|
console.log("API Key: " + maskKey(cfg.apiKey))
|
|
64
|
+
console.log("reportEndpoint: " + (cfg.reportEndpoint || "(未设置)"))
|
|
63
65
|
return
|
|
64
66
|
}
|
|
65
67
|
if (sub === "get") {
|
|
66
68
|
const cfg = readConfig()
|
|
67
69
|
console.log("显式配置 gatewayBaseUrl: " + (cfg.gatewayBaseUrl || "(未设置)"))
|
|
68
70
|
console.log("显式配置 apiKey: " + maskKey(cfg.apiKey))
|
|
71
|
+
console.log("显式配置 reportEndpoint: " + (cfg.reportEndpoint || "(未设置)"))
|
|
69
72
|
const r = resolveGateway({ fileConfig: cfg })
|
|
70
73
|
if (r.ok) {
|
|
71
74
|
console.log("生效网关: " + r.config.gatewayBaseUrl + "(来源: " + r.source + (r.providerId ? ",provider=" + r.providerId : "") + ")")
|
|
@@ -107,11 +110,25 @@ async function cmdWeb(argv) {
|
|
|
107
110
|
server.listen(port, host, resolve)
|
|
108
111
|
})
|
|
109
112
|
|
|
110
|
-
const
|
|
113
|
+
const address = server.address()
|
|
114
|
+
const actualPort = address && typeof address === "object" ? address.port : port
|
|
115
|
+
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`
|
|
111
116
|
console.log("本地看板已启动: " + url)
|
|
112
117
|
console.log("上游网关: " + cfg.gatewayBaseUrl + "(来源: " + resolved.source +
|
|
113
118
|
(resolved.providerId ? ",provider=" + resolved.providerId : "") + ")")
|
|
114
119
|
console.log("数据文件: " + dbFile())
|
|
120
|
+
|
|
121
|
+
// 零配置上报:把插件上报端点写入 config.json(插件解析顺序 env > reportEndpoint > 默认值)。
|
|
122
|
+
// 即使 --host 0.0.0.0 也写 127.0.0.1(插件默认同机);写入失败只告警,不影响服务启动。
|
|
123
|
+
const reportEndpoint = `http://127.0.0.1:${actualPort}/v1/session-usage/report`
|
|
124
|
+
try {
|
|
125
|
+
setConfig({ endpoint: reportEndpoint })
|
|
126
|
+
console.log("插件上报端点: " + reportEndpoint + "(已写入 config.json,插件零配置自动上报)")
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn("警告: 未能写入插件上报端点 reportEndpoint(不影响服务运行): " +
|
|
129
|
+
(error && error.message ? error.message : String(error)))
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
if (!loopback) console.warn("警告: 正在监听非回环地址 " + host + ",本机网络内可访问,请自行确认风险")
|
|
116
133
|
if (flags.open) openBrowser(url)
|
|
117
134
|
|
|
@@ -121,6 +138,7 @@ async function cmdWeb(argv) {
|
|
|
121
138
|
server.close(() => process.exit(0))
|
|
122
139
|
setTimeout(() => process.exit(0), 1000).unref()
|
|
123
140
|
})
|
|
141
|
+
return server
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
function cmdPlugin(argv) {
|
|
@@ -145,7 +163,8 @@ function cmdPlugin(argv) {
|
|
|
145
163
|
console.log("推荐用 npm 方式安装(可随 npm 更新、支持 -f 覆盖):")
|
|
146
164
|
console.log(" opencode plugin @dijkspicy/opencode-stats-reporter -g")
|
|
147
165
|
console.log("")
|
|
148
|
-
console.log("
|
|
166
|
+
console.log("以上为离线/本地文件安装方式。插件零配置:默认上报本机服务(web start 会把实际端点自动写入 config.json)。")
|
|
167
|
+
console.log("如需显式覆盖端点,可在 opencode 进程环境中设置:")
|
|
149
168
|
console.log(" SESSION_USAGE_ENDPOINT=http://127.0.0.1:" + port + "/v1/session-usage/report")
|
|
150
169
|
console.log("(本地模式无需 SESSION_USAGE_API_KEY;请勿使用 --pure 启动 opencode)")
|
|
151
170
|
}
|
package/src/config.js
CHANGED
|
@@ -34,7 +34,7 @@ function validUrl(url) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function setConfig({ gateway, key }) {
|
|
37
|
+
export function setConfig({ gateway, key, endpoint }) {
|
|
38
38
|
const cfg = readConfig()
|
|
39
39
|
if (gateway !== undefined) {
|
|
40
40
|
const g = String(gateway).trim().replace(/\/+$/, "")
|
|
@@ -46,6 +46,11 @@ export function setConfig({ gateway, key }) {
|
|
|
46
46
|
if (!k) throw new Error("key 不能为空")
|
|
47
47
|
cfg.apiKey = k
|
|
48
48
|
}
|
|
49
|
+
if (endpoint !== undefined) {
|
|
50
|
+
const e = String(endpoint).trim()
|
|
51
|
+
if (!validUrl(e)) throw new Error("endpoint 必须是合法的 http(s) URL")
|
|
52
|
+
cfg.reportEndpoint = e
|
|
53
|
+
}
|
|
49
54
|
return writeConfig(cfg)
|
|
50
55
|
}
|
|
51
56
|
|
package/src/store.js
CHANGED
|
@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS session_usage (
|
|
|
29
29
|
client_reported_at INTEGER,
|
|
30
30
|
first_seen_at INTEGER NOT NULL,
|
|
31
31
|
last_reported_at INTEGER NOT NULL,
|
|
32
|
+
session_title TEXT,
|
|
33
|
+
session_directory TEXT,
|
|
32
34
|
PRIMARY KEY (consumer_id, install_id, session_id)
|
|
33
35
|
);
|
|
34
36
|
CREATE INDEX IF NOT EXISTS idx_su_consumer_activity ON session_usage(consumer_id, last_activity_at DESC);
|
|
@@ -89,6 +91,8 @@ export function sanitizeRecord(raw) {
|
|
|
89
91
|
last_activity_at: int(raw.last_activity_at),
|
|
90
92
|
client_reported_at: optInt(raw.client_reported_at),
|
|
91
93
|
client_session_header: str(raw.client_session_header),
|
|
94
|
+
session_title: str(raw.session_title, 300),
|
|
95
|
+
session_directory: str(raw.session_directory, 512),
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -109,6 +113,23 @@ export class Store {
|
|
|
109
113
|
|
|
110
114
|
migrate() {
|
|
111
115
|
this.db.exec(SCHEMA)
|
|
116
|
+
this.ensureColumns()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 幂等迁移:为已存在的旧库补齐后增列。
|
|
121
|
+
* SCHEMA 只在表不存在时建表;旧库需用 PRAGMA table_info 检测缺列后 ALTER TABLE ADD COLUMN
|
|
122
|
+
* (可空 TEXT 列,不写默认值 → 旧行自动为 NULL,既有数据不受影响;重复调用安全)。
|
|
123
|
+
*/
|
|
124
|
+
ensureColumns() {
|
|
125
|
+
const existing = new Set(
|
|
126
|
+
this.db.prepare("PRAGMA table_info(session_usage)").all().map((r) => r.name),
|
|
127
|
+
)
|
|
128
|
+
for (const [name, type] of [["session_title", "TEXT"], ["session_directory", "TEXT"]]) {
|
|
129
|
+
if (!existing.has(name)) {
|
|
130
|
+
this.db.exec(`ALTER TABLE session_usage ADD COLUMN ${name} ${type}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
112
133
|
}
|
|
113
134
|
|
|
114
135
|
indexes() {
|
|
@@ -134,13 +155,13 @@ export class Store {
|
|
|
134
155
|
(consumer_id, install_id, session_id, source, schema_version, reporter_version, client_session_header,
|
|
135
156
|
model, agent, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
|
136
157
|
cost, message_count, revision, session_created_at, last_activity_at, client_reported_at,
|
|
137
|
-
first_seen_at, last_reported_at)
|
|
138
|
-
VALUES (
|
|
158
|
+
first_seen_at, last_reported_at, session_title, session_directory)
|
|
159
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
139
160
|
const update = this.db.prepare(`UPDATE session_usage SET
|
|
140
161
|
source=?, schema_version=?, reporter_version=?, client_session_header=?, model=?, agent=?,
|
|
141
162
|
tokens_input=?, tokens_output=?, tokens_reasoning=?, tokens_cache_read=?, tokens_cache_write=?,
|
|
142
163
|
cost=?, message_count=?, revision=?, session_created_at=?, last_activity_at=?, client_reported_at=?,
|
|
143
|
-
last_reported_at=?
|
|
164
|
+
last_reported_at=?, session_title=?, session_directory=?
|
|
144
165
|
WHERE consumer_id=? AND install_id=? AND session_id=?`)
|
|
145
166
|
|
|
146
167
|
for (const r of records) {
|
|
@@ -152,6 +173,7 @@ export class Store {
|
|
|
152
173
|
consumerId, r.install_id, r.session_id, source, schemaVersion, reporterVersion, r.client_session_header,
|
|
153
174
|
r.model, r.agent, r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
|
|
154
175
|
r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts, ts,
|
|
176
|
+
r.session_title, r.session_directory,
|
|
155
177
|
)
|
|
156
178
|
} else if (r.revision > prev.revision || (r.revision === prev.revision && r.last_activity_at > prev.last_activity_at)) {
|
|
157
179
|
status = "upserted"
|
|
@@ -159,6 +181,7 @@ export class Store {
|
|
|
159
181
|
source, schemaVersion, reporterVersion, r.client_session_header, r.model, r.agent,
|
|
160
182
|
r.tokens_input, r.tokens_output, r.tokens_reasoning, r.tokens_cache_read, r.tokens_cache_write,
|
|
161
183
|
r.cost, r.message_count, r.revision, r.session_created_at, r.last_activity_at, r.client_reported_at, ts,
|
|
184
|
+
r.session_title, r.session_directory,
|
|
162
185
|
consumerId, r.install_id, r.session_id,
|
|
163
186
|
)
|
|
164
187
|
} else if (r.revision === prev.revision && r.last_activity_at === prev.last_activity_at) {
|