@lengduan/dsh-usage-stats 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -24
- package/lib/client.js +11 -3
- package/lib/index.js +3 -4
- package/package.json +9 -1
- package/scripts/verify.mjs +7 -3
package/README.md
CHANGED
|
@@ -1,44 +1,57 @@
|
|
|
1
1
|
# @lengduan/dsh-usage-stats
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness
|
|
3
|
+
DeepSeek Harness 的**用量统计**插件。每次模型调用的四类 token 落进本地 SQLite 账本,按 天 × 服务商 × 模型 分桶。只统计用量,不做费用计算。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
按 **天 × 服务商 × 模型** 分桶,落在本地 SQLite 账本里。
|
|
7
|
+
**解决什么:** DSH 本身不保留跨会话的用量视图,会话一删历史消耗就查不到。本插件把每次模型调用记进一个独立账本,删会话、清会话日志都不影响已记账的数据。
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
**适合谁:** 想按天或按模型核对 token 消耗,并且需要把「主对话」与「DSH 内部调用(压缩、摘要、标题生成)」分开看的人。
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
| --- | --- | --- |
|
|
14
|
-
| `session/event` → `assistant/message.usage` | 主对话的每次模型调用(权威、幂等、带 seq) | `chat` |
|
|
15
|
-
| `llm/stream`(仅 `purpose` 非空) | 压缩、摘要、标题生成等 **DSH 内部调用** | `internal` |
|
|
16
|
-
| 启动回溯:`sessionPersistence` 逐个读历史会话 | 插件安装之前的全部对话调用 | `chat` |
|
|
11
|
+
**不适合:** 要算钱(这里只有 token,不乘单价);要跨机器汇总(账本是单机 `$DSH_HOME` 下的一个文件);TUI / headless(没有 Web 的 slot 可挂)。
|
|
17
12
|
|
|
18
|
-
|
|
19
|
-
- 对话调用可用 `sessionPersistence` 服务回溯补齐历史(增量,按 `revision` + `next_seq` 水位)。
|
|
20
|
-
- 两路使用同一主键 `(session_id, record_key)`,`INSERT OR IGNORE` 天然去重,不会双计。
|
|
13
|
+
## Compatibility
|
|
21
14
|
|
|
22
|
-
|
|
15
|
+
| 项 | 声明 |
|
|
16
|
+
|---|---|
|
|
17
|
+
| DSH 形态 | Web GUI profile(`dsh web` / `--profile web`) |
|
|
18
|
+
| 运行环境 | Node.js ≥ 22.5(依赖内置 `node:sqlite`);Web 端需要会话视图提供 `conversation.view` 扩展点 |
|
|
19
|
+
| 宿主服务 | `sessionPersistence`、`webServer`,以及 `session/event`、`llm/stream` 事件 |
|
|
20
|
+
| 最后本机验证 | 2026-09-12,Windows,DSH mainline 本机构建 + web profile:tab 正常渲染、账本落库、按天 / 按模型聚合正确 |
|
|
21
|
+
| 未声称 | 未做跨平台与跨 DSH 版本矩阵;DSH mainline 的 DOM / class 漂移可能影响前端表格样式 |
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
## Install / Uninstall
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
从 npm 安装:
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
```sh
|
|
28
|
+
dsh plugin --profile web add @lengduan/dsh-usage-stats
|
|
29
|
+
```
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
口径切换(对话 / 内部 / 全部),表格按服务商分组列出各模型的输入 / 输出 / 缓存命中 / 缓存读写 / 总量。
|
|
31
|
+
升级:
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
```sh
|
|
34
|
+
dsh plugin --profile web update @lengduan/dsh-usage-stats
|
|
35
|
+
```
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
**禁用(不删依赖,账本保留)**:在 web profile 的 `cordis.patch.yml` 里把该行覆盖为 `disabled: true`:
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
- id: usage-stats
|
|
41
|
+
disabled: true
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**彻底移除**:
|
|
36
45
|
|
|
37
46
|
```sh
|
|
38
|
-
dsh plugin --profile web
|
|
47
|
+
dsh plugin --profile web remove @lengduan/dsh-usage-stats
|
|
39
48
|
```
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
移除不会删除账本。要清空历史,手动删 `$DSH_HOME/storages/usage-stats/`(默认 `~/.dsh/storages/usage-stats/`),该操作不可恢复。
|
|
51
|
+
|
|
52
|
+
装完或改完 patch 都需要重启 `dsh web`。
|
|
53
|
+
|
|
54
|
+
从源码安装(开发调试):
|
|
42
55
|
|
|
43
56
|
```sh
|
|
44
57
|
git clone https://github.com/lengduan/dsh-usage-stats
|
|
@@ -46,4 +59,83 @@ cd dsh-usage-stats
|
|
|
46
59
|
dsh plugin --profile web add .
|
|
47
60
|
```
|
|
48
61
|
|
|
49
|
-
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
1. 安装并重启 `dsh web`,打开任意一个已有对话的会话。
|
|
65
|
+
2. 会话视图顶部出现「用量统计」tab,切过去;默认是「今日 · 全部口径」的表格。
|
|
66
|
+
3. 用时间范围(今日 / 本周 / 上周 / 全部 / 指定日期)和口径(全部 / 对话 / 内部)筛选;点服务商行可折叠其下的模型明细,点「刷新」重新取数。
|
|
67
|
+
|
|
68
|
+
最小验收:在任意会话发一句话触发一次模型调用,回到「用量统计」tab 点「刷新」,当天应出现该次调用的记录(输入 / 输出 / 缓存命中 / 缓存读写 / 总量)。
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
无配置项、无环境变量、无密钥。
|
|
73
|
+
|
|
74
|
+
唯一的隐式输入是 `DSH_HOME`(决定账本目录),沿用 DSH 自身取值。外观与表结构写死在 `lib/client.js` 里。
|
|
75
|
+
|
|
76
|
+
## Permissions & data
|
|
77
|
+
|
|
78
|
+
| 面 | 行为 |
|
|
79
|
+
|---|---|
|
|
80
|
+
| 网络 | 无出网请求。前端只 POST 本机 `/usage/api` |
|
|
81
|
+
| 文件系统 | 只读写 `$DSH_HOME/storages/usage-stats/usage.db`(含 WAL 伴生文件);回溯历史时经 `sessionPersistence` 服务读会话日志 |
|
|
82
|
+
| 凭据 / 会话内容 | 不读 token、密钥、消息正文;只取用量数字与 session id、provider、model、时间、purpose |
|
|
83
|
+
| 宿主服务 | 订阅 `session/event` 与 `llm/stream`,注册 `/usage/api`,注入 `sessionPersistence` / `webServer` |
|
|
84
|
+
| 数据去向 | 全部留在本机账本,不外发、不写第三方 |
|
|
85
|
+
|
|
86
|
+
**统计口径**:输入(未命中)/ 输出 / 缓存命中 / 缓存写入 / 总量;provider 给出 `totalTokens` 时优先采用,否则四类相加。
|
|
87
|
+
|
|
88
|
+
**三路数据来源**:
|
|
89
|
+
|
|
90
|
+
| 来源 | 覆盖 | kind |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `session/event` → `assistant/message.usage` | 主对话的每次模型调用(权威、幂等、带 seq) | `chat` |
|
|
93
|
+
| `llm/stream`(仅 `purpose` 非空) | 压缩、摘要、标题生成等 DSH 内部调用 | `internal` |
|
|
94
|
+
| 启动回溯:`sessionPersistence` 逐个读历史会话 | 插件安装之前的对话调用 | `chat` |
|
|
95
|
+
|
|
96
|
+
内部调用不写进会话日志,**只能实时捕获**,所以 `internal` 自插件安装之日起才有数据。回溯是增量的,按 `revision` + `next_seq` 水位推进;两路共用主键 `(session_id, record_key)`,`INSERT OR IGNORE` 天然去重,不会双计。
|
|
97
|
+
|
|
98
|
+
**账本是权威事实源,不是会话日志的索引** —— 删会话或清理会话日志都不会改变已记账的数据。
|
|
99
|
+
|
|
100
|
+
## Troubleshooting
|
|
101
|
+
|
|
102
|
+
| 现象 | 处理 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| 会话顶部没有「用量统计」tab | 确认装的是 **web** profile 且已重启 `dsh web`;看浏览器控制台是否报 `client-modules: bundle … loaded without registering "<包名>"` —— 这类报错说明 client bundle 注册的 id 与包名不一致 |
|
|
105
|
+
| tab 在,但一直「暂无记录」 | 先发一次消息触发调用;选「内部」口径时需要插件已运行过内部调用才有数据 |
|
|
106
|
+
| 数字比预期少 | 检查口径是否停在「对话」;历史回溯只在插件启动时跑一次,装完请重启 |
|
|
107
|
+
| 启动报 SQLite / 权限错误 | 确认 `$DSH_HOME/storages/usage-stats/` 可写;目录被占用或磁盘满会让账本打不开,此时接口返回 `账本不可用` |
|
|
108
|
+
| 用量页里的按钮点不动 | 对话区两侧的宽度把手(40px 的 col-resize 条)会压在本页上抢走 pointerdown;本插件在用量页挂载期间会隐藏这两个把手,若仍复现请提 issue |
|
|
109
|
+
| 卸载后 tab 还在 | 硬刷新页面;确认 profile 的 `dsh.profile.bundles` 已不再列出该包 |
|
|
110
|
+
|
|
111
|
+
日志:`dsh web` 的终端输出(插件只经 `ctx.logger.warn` 输出告警)+ 浏览器控制台。插件不写独立日志文件。
|
|
112
|
+
|
|
113
|
+
回滚:`dsh plugin --profile web remove @lengduan/dsh-usage-stats` 后重启;账本文件可先行备份。
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
git clone https://github.com/lengduan/dsh-usage-stats
|
|
119
|
+
cd dsh-usage-stats
|
|
120
|
+
node scripts/verify.mjs # 装载前自检
|
|
121
|
+
dsh plugin --profile web add . # 以本仓目录联调
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
无构建步骤,`lib/` 就是发布产物:
|
|
125
|
+
|
|
126
|
+
| 文件 | 角色 |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `lib/index.js` | host 半:账本、订阅、`/usage/api` |
|
|
129
|
+
| `lib/client.js` | 浏览器 bundle:`conversation.view` 的「用量统计」tab |
|
|
130
|
+
| `cordis.patch.yml` | bundle 层,按**包名**插入插件行 |
|
|
131
|
+
| `scripts/verify.mjs` | 自检:host 半可 import 并导出 `inject`/`apply`;client 半的 `factory` 返回模块对象、bundle id 与 package.json 的 `name` 一致、确实注册到 `conversation.view` |
|
|
132
|
+
|
|
133
|
+
改完 host 半需重启 `dsh web`,只改 client 半刷新页面即可。提交前请跑 `node scripts/verify.mjs`,它是 CI 之外唯一的门禁。
|
|
134
|
+
|
|
135
|
+
贡献:最小 diff。Issue / PR 请写清 DSH 版本或 mainline commit、本插件 commit、操作系统。
|
|
136
|
+
|
|
137
|
+
## License & security
|
|
138
|
+
|
|
139
|
+
- 许可证:[MIT](LICENSE)
|
|
140
|
+
- 安全问题:不要在公开 issue 里贴密钥或会话内容。请用 GitHub 的 Private vulnerability reporting(若已开启),或只描述复现步骤的私密渠道联系维护者。
|
|
141
|
+
- 本插件不处理任何凭据;若发现发布产物里出现外链或凭证,视为供应链问题,按上面的私密渠道反馈。
|
package/lib/client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 列结构与 PI 的用量统计报表保持一致(总量 = 四类之和)。
|
|
7
7
|
*/
|
|
8
8
|
window.__ModuleLoader__.load({
|
|
9
|
-
id: "dsh-usage-stats",
|
|
9
|
+
id: "@lengduan/dsh-usage-stats",
|
|
10
10
|
factory: (require) => {
|
|
11
11
|
const module = { exports: {} };
|
|
12
12
|
const exports = module.exports;
|
|
@@ -27,6 +27,7 @@ window.__ModuleLoader__.load({
|
|
|
27
27
|
"总 Token": "Total tokens",
|
|
28
28
|
"内部调用自插件安装之日起统计,无法回溯历史": "Internal calls are counted from install time; history cannot be backfilled",
|
|
29
29
|
"账本": "Ledger", "条记录": "records", "会话": "sessions",
|
|
30
|
+
"仅统计当前 DSH 实例(本 home);同一账号在其他实例(独立 DSH_HOME)的消耗不在此列。": "Counts this DSH instance (current home) only; usage under the same account in other instances (separate DSH_HOME) is not included.",
|
|
30
31
|
};
|
|
31
32
|
let LANG = (() => {
|
|
32
33
|
try { return String(navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en"; } catch (e) { return "zh"; }
|
|
@@ -67,11 +68,14 @@ window.__ModuleLoader__.load({
|
|
|
67
68
|
// pointerdown。抬本页层级能盖住把手,但底部输入框只有 z-index 7,本页反
|
|
68
69
|
// 而会压住输入框;因此改为在用量页挂载期间隐藏把手——切回对话 tab 时本页
|
|
69
70
|
// 卸载,body:has 失配,把手自动恢复。
|
|
70
|
-
|
|
71
|
+
/** 注入把手隐藏样式;随 ctx.effect 在插件卸载时撤回。 */
|
|
72
|
+
function avoidWidthHandles(ctx) {
|
|
73
|
+
if (typeof document === "undefined") return;
|
|
71
74
|
const style = document.createElement("style");
|
|
72
|
-
style.
|
|
75
|
+
style.dataset.usageStatsStyle = "width-handle-fix";
|
|
73
76
|
style.textContent = "body:has([data-usage-stats]) [data-width-handle]{display:none}";
|
|
74
77
|
document.head.append(style);
|
|
78
|
+
ctx.effect(() => () => { style.remove() });
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
// ── 工具 ──────────────────────────────────────────────────────────────
|
|
@@ -222,6 +226,8 @@ window.__ModuleLoader__.load({
|
|
|
222
226
|
? el("div", { style: st.hint }, t("内部调用自插件安装之日起统计,无法回溯历史"))
|
|
223
227
|
: null,
|
|
224
228
|
|
|
229
|
+
el("div", { style: st.hint }, t("仅统计当前 DSH 实例(本 home);同一账号在其他实例(独立 DSH_HOME)的消耗不在此列。")),
|
|
230
|
+
|
|
225
231
|
state.error
|
|
226
232
|
? el("div", { style: st.err }, state.error)
|
|
227
233
|
: null,
|
|
@@ -238,6 +244,8 @@ window.__ModuleLoader__.load({
|
|
|
238
244
|
const inject = ["slots"];
|
|
239
245
|
|
|
240
246
|
function apply(ctx) {
|
|
247
|
+
avoidWidthHandles(ctx);
|
|
248
|
+
|
|
241
249
|
const slots = ctx.get("slots");
|
|
242
250
|
if (slots === undefined) return;
|
|
243
251
|
|
package/lib/index.js
CHANGED
|
@@ -268,9 +268,9 @@ function resolveRange(range, dateArg) {
|
|
|
268
268
|
return { from: today, to: today, label: `今日 ${today}` }
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
export
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
export const inject = ['sessionPersistence', 'webServer']
|
|
272
|
+
|
|
273
|
+
export function apply(ctx) {
|
|
274
274
|
const warn = (message) => { try { ctx.logger?.warn?.(`[dsh-usage-stats] ${message}`) } catch (e) { /* 日志不可用则静默 */ } }
|
|
275
275
|
|
|
276
276
|
let ledger = null
|
|
@@ -473,5 +473,4 @@ export default {
|
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
ctx.effect(() => () => { ledger?.close() }, 'dsh-usage-stats: 关闭账本')
|
|
476
|
-
},
|
|
477
476
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lengduan/dsh-usage-stats",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "DSH 用量统计插件:SQLite 本地账本,按天 / 按模型统计输入、输出、缓存命中、总计;区分对话调用与内部调用,只统计用量不做费用计算。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"deepseek-harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"usage-stats",
|
|
10
|
+
"token-usage",
|
|
11
|
+
"sqlite"
|
|
12
|
+
],
|
|
5
13
|
"license": "MIT",
|
|
6
14
|
"repository": {
|
|
7
15
|
"type": "git",
|
package/scripts/verify.mjs
CHANGED
|
@@ -24,13 +24,17 @@ function check(label, ok, detail = '') {
|
|
|
24
24
|
|
|
25
25
|
// ── host 半 ───────────────────────────────────────────────────────────────
|
|
26
26
|
console.log('host 半 lib/index.js')
|
|
27
|
-
const
|
|
28
|
-
|
|
27
|
+
const hostModule = await import(pathToFileURL(path.join(root, 'lib', 'index.js')).href)
|
|
28
|
+
// 官方 bundle 用 named export(export const inject / export function apply),
|
|
29
|
+
// default 对象形式也受支持;两种形状都接受,避免自检锁死其中一种。
|
|
30
|
+
const host = hostModule.default ?? hostModule
|
|
31
|
+
check('可 import 且导出插件', !!host && typeof host === 'object')
|
|
29
32
|
check('apply 是函数', typeof host?.apply === 'function')
|
|
30
33
|
check('inject 是数组', Array.isArray(host?.inject), JSON.stringify(host?.inject))
|
|
31
34
|
|
|
32
35
|
// ── client 半 ─────────────────────────────────────────────────────────────
|
|
33
36
|
console.log('client 半 lib/client.js')
|
|
37
|
+
const pkgName = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name
|
|
34
38
|
const code = fs.readFileSync(path.join(root, 'lib', 'client.js'), 'utf8')
|
|
35
39
|
let definition = null
|
|
36
40
|
const sandbox = {
|
|
@@ -44,7 +48,7 @@ try {
|
|
|
44
48
|
check('脚本可执行', false, String(error?.message ?? error))
|
|
45
49
|
}
|
|
46
50
|
check('调用 __ModuleLoader__.load 注册了模块', !!definition)
|
|
47
|
-
check('id
|
|
51
|
+
check('bundle id 与包名一致(宿主按包名校验注册)', definition?.id === pkgName, `${String(definition?.id)} vs ${pkgName}`)
|
|
48
52
|
|
|
49
53
|
const React = {
|
|
50
54
|
createElement: () => null,
|