@lengduan/dsh-usage-stats 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +264 -0
- package/lib/index.js +477 -0
- package/package.json +46 -0
- package/scripts/verify.mjs +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 soon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @lengduan/dsh-usage-stats
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 的**用量统计**插件(只统计用量,不做费用计算)。
|
|
4
|
+
|
|
5
|
+
## 它统计什么
|
|
6
|
+
|
|
7
|
+
每次模型调用的四类 token:**输入(未命中)/ 输出 / 缓存命中 / 缓存写入**,以及四者之和「总量」,
|
|
8
|
+
按 **天 × 服务商 × 模型** 分桶,落在本地 SQLite 账本里。
|
|
9
|
+
|
|
10
|
+
## 数据来源(两路实时 + 一路回溯)
|
|
11
|
+
|
|
12
|
+
| 来源 | 覆盖 | 落库 kind |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| `session/event` → `assistant/message.usage` | 主对话的每次模型调用(权威、幂等、带 seq) | `chat` |
|
|
15
|
+
| `llm/stream`(仅 `purpose` 非空) | 压缩、摘要、标题生成等 **DSH 内部调用** | `internal` |
|
|
16
|
+
| 启动回溯:`sessionPersistence` 逐个读历史会话 | 插件安装之前的全部对话调用 | `chat` |
|
|
17
|
+
|
|
18
|
+
- 内部调用不写进会话日志,**只能实时捕获**,所以 `internal` 自插件安装之日起才有数据。
|
|
19
|
+
- 对话调用可用 `sessionPersistence` 服务回溯补齐历史(增量,按 `revision` + `next_seq` 水位)。
|
|
20
|
+
- 两路使用同一主键 `(session_id, record_key)`,`INSERT OR IGNORE` 天然去重,不会双计。
|
|
21
|
+
|
|
22
|
+
## 存储
|
|
23
|
+
|
|
24
|
+
`$DSH_HOME/storages/usage-stats/usage.db`(默认 `~/.dsh/storages/usage-stats/usage.db`),`node:sqlite` 内置零依赖,WAL 模式。
|
|
25
|
+
|
|
26
|
+
**账本是权威事实源,不是会话日志的索引**——删除会话(或会话日志被清理)不会影响已记账的数据。
|
|
27
|
+
|
|
28
|
+
## 界面
|
|
29
|
+
|
|
30
|
+
在会话视图顶部新增「用量统计」tab:时间范围(今日 / 本周 / 上周 / 全部 / 指定日期)、
|
|
31
|
+
口径切换(对话 / 内部 / 全部),表格按服务商分组列出各模型的输入 / 输出 / 缓存命中 / 缓存读写 / 总量。
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
从 npm 装(已发布包):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dsh plugin --profile web add @lengduan/dsh-usage-stats
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
从源码装(本地开发调试):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
git clone https://github.com/lengduan/dsh-usage-stats
|
|
45
|
+
cd dsh-usage-stats
|
|
46
|
+
dsh plugin --profile web add .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
装完需要重启 dsh web 服务。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# dsh-usage-stats — bundle patch layer.
|
|
2
|
+
#
|
|
3
|
+
# 安装到 DSH profile 后,loader 会把这一行挂到根 entry 列表上。
|
|
4
|
+
# inject 必须声明:等到 sessionPersistence(回溯历史会话)与 webServer
|
|
5
|
+
# (注册 /usage/api 与前端取数)就绪后再激活 apply。
|
|
6
|
+
- insert:
|
|
7
|
+
- id: usage-stats
|
|
8
|
+
name: '@lengduan/dsh-usage-stats'
|
|
9
|
+
inject:
|
|
10
|
+
- sessionPersistence
|
|
11
|
+
- webServer
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-stats — CLIENT half(浏览器端 bundle,由宿主 __ModuleLoader__ 加载)。
|
|
3
|
+
*
|
|
4
|
+
* 在会话视图顶部注册「用量统计」tab,渲染按服务商分组、按模型展开的用量表:
|
|
5
|
+
* 模型 | 输入 | 输出 | 缓存命中 | 缓存读写 | 总量
|
|
6
|
+
* 列结构与 PI 的用量统计报表保持一致(总量 = 四类之和)。
|
|
7
|
+
*/
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: "dsh-usage-stats",
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
const module = { exports: {} };
|
|
12
|
+
const exports = module.exports;
|
|
13
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
14
|
+
|
|
15
|
+
const React = require("react");
|
|
16
|
+
const el = React.createElement;
|
|
17
|
+
|
|
18
|
+
// ── 文案(中文为键,英文为值;跟随宿主语言) ──────────────────────────
|
|
19
|
+
const EN = {
|
|
20
|
+
"用量统计": "Usage Stats",
|
|
21
|
+
"记录每一次模型调用的 token 用量": "Token usage for every model call",
|
|
22
|
+
"今日": "Today", "本周": "This week", "上周": "Last week", "全部": "All", "指定日期": "Pick a date",
|
|
23
|
+
"口径": "Scope", "对话": "Chat", "内部": "Internal",
|
|
24
|
+
"刷新": "Refresh", "正在加载…": "Loading…", "暂无记录": "No records",
|
|
25
|
+
"模型": "Model", "输入": "Input", "输出": "Output",
|
|
26
|
+
"缓存命中": "Cache hit", "缓存读写": "Cache write", "总量": "Total",
|
|
27
|
+
"总 Token": "Total tokens",
|
|
28
|
+
"内部调用自插件安装之日起统计,无法回溯历史": "Internal calls are counted from install time; history cannot be backfilled",
|
|
29
|
+
"账本": "Ledger", "条记录": "records", "会话": "sessions",
|
|
30
|
+
};
|
|
31
|
+
let LANG = (() => {
|
|
32
|
+
try { return String(navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en"; } catch (e) { return "zh"; }
|
|
33
|
+
})();
|
|
34
|
+
const t = (s) => (LANG === "en" ? EN[s] || s : s);
|
|
35
|
+
|
|
36
|
+
// ── 样式 ──────────────────────────────────────────────────────────────
|
|
37
|
+
const st = {
|
|
38
|
+
root: { display: "flex", flexDirection: "column", gap: 12, padding: "16px 20px", width: "100%", boxSizing: "border-box", minWidth: 0 },
|
|
39
|
+
head: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" },
|
|
40
|
+
title: { fontSize: "1.15em", fontWeight: 600 },
|
|
41
|
+
sub: { fontSize: "0.85em", opacity: 0.6, marginTop: 2 },
|
|
42
|
+
bar: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" },
|
|
43
|
+
seg: { display: "inline-flex", border: "1px solid rgba(128,128,128,.35)", borderRadius: 6, overflow: "hidden" },
|
|
44
|
+
segBtn: { border: 0, background: "transparent", padding: "4px 10px", fontSize: "0.88em", cursor: "pointer", color: "inherit" },
|
|
45
|
+
segOn: { border: 0, background: "rgba(90,140,255,.22)", padding: "4px 10px", fontSize: "0.88em", cursor: "pointer", color: "inherit", fontWeight: 600 },
|
|
46
|
+
btn: { border: "1px solid rgba(128,128,128,.35)", background: "transparent", borderRadius: 6, padding: "4px 10px", fontSize: "0.88em", cursor: "pointer", color: "inherit" },
|
|
47
|
+
date: { border: "1px solid rgba(128,128,128,.35)", background: "transparent", borderRadius: 6, padding: "3px 8px", fontSize: "0.88em", color: "inherit" },
|
|
48
|
+
hint: { fontSize: "0.82em", opacity: 0.6 },
|
|
49
|
+
// 宽表必须可横向滚动,窄屏下列不被挤压
|
|
50
|
+
tableWrap: { width: "100%", overflowX: "auto", border: "1px solid rgba(128,128,128,.25)", borderRadius: 8 },
|
|
51
|
+
table: { width: "100%", minWidth: 620, borderCollapse: "collapse", fontSize: "0.9em" },
|
|
52
|
+
thName: { textAlign: "left", padding: "8px 12px", fontWeight: 600, fontSize: "0.85em", opacity: 0.75, borderBottom: "1px solid rgba(128,128,128,.3)", whiteSpace: "nowrap" },
|
|
53
|
+
thNum: { textAlign: "right", padding: "8px 12px", fontWeight: 600, fontSize: "0.85em", opacity: 0.75, borderBottom: "1px solid rgba(128,128,128,.3)", whiteSpace: "nowrap" },
|
|
54
|
+
tdName: { padding: "7px 12px", borderBottom: "1px solid rgba(128,128,128,.15)" },
|
|
55
|
+
tdNum: { padding: "7px 12px", textAlign: "right", borderBottom: "1px solid rgba(128,128,128,.15)", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" },
|
|
56
|
+
provRow: { background: "rgba(128,128,128,.08)", cursor: "pointer", fontWeight: 600 },
|
|
57
|
+
modelName: { paddingLeft: 28, opacity: 0.9 },
|
|
58
|
+
totalRow: { fontWeight: 700, borderTop: "2px solid rgba(128,128,128,.45)" },
|
|
59
|
+
empty: { padding: "24px 12px", textAlign: "center", opacity: 0.6, fontSize: "0.9em" },
|
|
60
|
+
err: { padding: "10px 12px", borderRadius: 6, background: "rgba(220,60,60,.12)", fontSize: "0.88em" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── 避让宿主的对话宽度把手 ────────────────────────────────────────────
|
|
64
|
+
// 宿主在对话正文两侧各放一条 40px 的 col-resize 把手(ui-conversation
|
|
65
|
+
// ConversationRoot,z-index 8),只在对话视图下有意义,且仅对带 composer
|
|
66
|
+
// overlay 的视图自我隐藏。本页铺满对话列,把手会盖住页内按钮并抢走
|
|
67
|
+
// pointerdown。抬本页层级能盖住把手,但底部输入框只有 z-index 7,本页反
|
|
68
|
+
// 而会压住输入框;因此改为在用量页挂载期间隐藏把手——切回对话 tab 时本页
|
|
69
|
+
// 卸载,body:has 失配,把手自动恢复。
|
|
70
|
+
if (typeof document !== "undefined" && !document.getElementById("dsh-usage-stats-handle-fix")) {
|
|
71
|
+
const style = document.createElement("style");
|
|
72
|
+
style.id = "dsh-usage-stats-handle-fix";
|
|
73
|
+
style.textContent = "body:has([data-usage-stats]) [data-width-handle]{display:none}";
|
|
74
|
+
document.head.append(style);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── 工具 ──────────────────────────────────────────────────────────────
|
|
78
|
+
/** KM 格式化:>=1e6 用 M,>=1e3 用 K,两位小数(与 PI 报表一致)。 */
|
|
79
|
+
function kmb(n) {
|
|
80
|
+
const v = Number(n) || 0;
|
|
81
|
+
if (v >= 1e6) return (v / 1e6).toFixed(2) + "M";
|
|
82
|
+
if (v >= 1e3) return (v / 1e3).toFixed(2) + "K";
|
|
83
|
+
return String(v);
|
|
84
|
+
}
|
|
85
|
+
/** 精确值:千分位分隔,用于 hover 提示。 */
|
|
86
|
+
function exact(n) {
|
|
87
|
+
return String(Math.round(Number(n) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
88
|
+
}
|
|
89
|
+
function api(payload) {
|
|
90
|
+
return fetch("/usage/api", {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify(payload),
|
|
94
|
+
}).then((r) => r.json());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── 用量表 ────────────────────────────────────────────────────────────
|
|
98
|
+
function StatsTable(props) {
|
|
99
|
+
const rows = props.rows || [];
|
|
100
|
+
const [collapsed, setCollapsed] = React.useState({});
|
|
101
|
+
|
|
102
|
+
const visible = React.useMemo(() => {
|
|
103
|
+
const out = [];
|
|
104
|
+
let hide = false;
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
if (row.level === "provider") {
|
|
107
|
+
hide = !!collapsed[row.name];
|
|
108
|
+
out.push(row);
|
|
109
|
+
} else if (row.level === "model") {
|
|
110
|
+
if (!hide) out.push(row);
|
|
111
|
+
} else {
|
|
112
|
+
out.push(row);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}, [rows, collapsed]);
|
|
117
|
+
|
|
118
|
+
const cells = (row) => [
|
|
119
|
+
el("td", { key: "n", style: Object.assign({}, st.tdName, row.level === "model" ? st.modelName : null) },
|
|
120
|
+
row.level === "model" ? "└─ " + row.name : row.name),
|
|
121
|
+
el("td", { key: "i", style: st.tdNum, title: exact(row.input) }, kmb(row.input)),
|
|
122
|
+
el("td", { key: "o", style: st.tdNum, title: exact(row.output) }, kmb(row.output)),
|
|
123
|
+
el("td", { key: "r", style: st.tdNum, title: exact(row.cacheRead) }, kmb(row.cacheRead)),
|
|
124
|
+
el("td", { key: "w", style: st.tdNum, title: exact(row.cacheWrite) }, kmb(row.cacheWrite)),
|
|
125
|
+
el("td", { key: "t", style: st.tdNum, title: exact(row.total) }, kmb(row.total)),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return el("div", { style: st.tableWrap },
|
|
129
|
+
el("table", { style: st.table },
|
|
130
|
+
el("thead", null, el("tr", null,
|
|
131
|
+
el("th", { style: st.thName }, t("模型")),
|
|
132
|
+
el("th", { style: st.thNum }, t("输入")),
|
|
133
|
+
el("th", { style: st.thNum }, t("输出")),
|
|
134
|
+
el("th", { style: st.thNum }, t("缓存命中")),
|
|
135
|
+
el("th", { style: st.thNum }, t("缓存读写")),
|
|
136
|
+
el("th", { style: st.thNum }, t("总量")),
|
|
137
|
+
)),
|
|
138
|
+
el("tbody", null, visible.map((row, idx) => {
|
|
139
|
+
const style = row.level === "provider" ? st.provRow : row.level === "total" ? st.totalRow : null;
|
|
140
|
+
const onClick = row.level === "provider"
|
|
141
|
+
? () => setCollapsed((prev) => Object.assign({}, prev, { [row.name]: !prev[row.name] }))
|
|
142
|
+
: undefined;
|
|
143
|
+
return el("tr", {
|
|
144
|
+
key: row.level + ":" + (row.provider || "") + ":" + row.name + ":" + idx,
|
|
145
|
+
style,
|
|
146
|
+
onClick,
|
|
147
|
+
},
|
|
148
|
+
row.level === "provider"
|
|
149
|
+
? [
|
|
150
|
+
el("td", { key: "n", style: st.tdName }, (collapsed[row.name] ? "▸ " : "▾ ") + row.name + " · " + row.messages + " " + t("条记录")),
|
|
151
|
+
el("td", { key: "i", style: st.tdNum, title: exact(row.input) }, kmb(row.input)),
|
|
152
|
+
el("td", { key: "o", style: st.tdNum, title: exact(row.output) }, kmb(row.output)),
|
|
153
|
+
el("td", { key: "r", style: st.tdNum, title: exact(row.cacheRead) }, kmb(row.cacheRead)),
|
|
154
|
+
el("td", { key: "w", style: st.tdNum, title: exact(row.cacheWrite) }, kmb(row.cacheWrite)),
|
|
155
|
+
el("td", { key: "t", style: st.tdNum, title: exact(row.total) }, kmb(row.total)),
|
|
156
|
+
]
|
|
157
|
+
: cells(row));
|
|
158
|
+
})),
|
|
159
|
+
));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── 主面板 ────────────────────────────────────────────────────────────
|
|
163
|
+
const RANGES = [
|
|
164
|
+
{ k: "today", label: "今日" },
|
|
165
|
+
{ k: "week", label: "本周" },
|
|
166
|
+
{ k: "lastweek", label: "上周" },
|
|
167
|
+
{ k: "all", label: "全部" },
|
|
168
|
+
{ k: "date", label: "指定日期" },
|
|
169
|
+
];
|
|
170
|
+
const KINDS = [
|
|
171
|
+
{ k: "all", label: "全部" },
|
|
172
|
+
{ k: "chat", label: "对话" },
|
|
173
|
+
{ k: "internal", label: "内部" },
|
|
174
|
+
];
|
|
175
|
+
|
|
176
|
+
function UsageStatsPanel() {
|
|
177
|
+
const [range, setRange] = React.useState("today");
|
|
178
|
+
const [kind, setKind] = React.useState("all");
|
|
179
|
+
const [date, setDate] = React.useState("");
|
|
180
|
+
const [state, setState] = React.useState({ status: "loading", data: null, error: "" });
|
|
181
|
+
|
|
182
|
+
const load = React.useCallback(() => {
|
|
183
|
+
setState((prev) => ({ status: "loading", data: prev.data, error: "" }));
|
|
184
|
+
api({ action: "summary", range, kind, date: range === "date" && date ? date : undefined })
|
|
185
|
+
.then((res) => {
|
|
186
|
+
if (res && res.ok) setState({ status: "done", data: res, error: "" });
|
|
187
|
+
else setState({ status: "error", data: null, error: (res && res.error) || t("暂无记录") });
|
|
188
|
+
})
|
|
189
|
+
.catch((e) => setState({ status: "error", data: null, error: String((e && e.message) || e) }));
|
|
190
|
+
}, [range, kind, date]);
|
|
191
|
+
|
|
192
|
+
React.useEffect(() => { load(); }, [load]);
|
|
193
|
+
|
|
194
|
+
const data = state.data || {};
|
|
195
|
+
const rows = data.rows || [];
|
|
196
|
+
const hasData = rows.length > 1 || (data.messages || 0) > 0;
|
|
197
|
+
|
|
198
|
+
return el("div", { style: st.root, "data-usage-stats": "" },
|
|
199
|
+
el("div", { style: st.head },
|
|
200
|
+
el("div", null,
|
|
201
|
+
el("div", { style: st.title }, t("用量统计")),
|
|
202
|
+
el("div", { style: st.sub }, data.label || t("记录每一次模型调用的 token 用量")),
|
|
203
|
+
),
|
|
204
|
+
el("div", { style: st.bar },
|
|
205
|
+
el("button", { style: st.btn, onClick: load }, t("刷新")),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
|
|
209
|
+
el("div", { style: st.bar },
|
|
210
|
+
el("span", { style: st.hint }, t("口径") + ":"),
|
|
211
|
+
el("div", { style: st.seg }, KINDS.map((item) =>
|
|
212
|
+
el("button", { key: item.k, style: kind === item.k ? st.segOn : st.segBtn, onClick: () => setKind(item.k) }, t(item.label)))),
|
|
213
|
+
el("span", { style: { width: 8 } }),
|
|
214
|
+
el("div", { style: st.seg }, RANGES.map((item) =>
|
|
215
|
+
el("button", { key: item.k, style: range === item.k ? st.segOn : st.segBtn, onClick: () => setRange(item.k) }, t(item.label)))),
|
|
216
|
+
range === "date"
|
|
217
|
+
? el("input", { type: "date", style: st.date, value: date, onChange: (e) => setDate(e.target.value) })
|
|
218
|
+
: null,
|
|
219
|
+
),
|
|
220
|
+
|
|
221
|
+
kind !== "chat"
|
|
222
|
+
? el("div", { style: st.hint }, t("内部调用自插件安装之日起统计,无法回溯历史"))
|
|
223
|
+
: null,
|
|
224
|
+
|
|
225
|
+
state.error
|
|
226
|
+
? el("div", { style: st.err }, state.error)
|
|
227
|
+
: null,
|
|
228
|
+
|
|
229
|
+
state.status === "loading" && !hasData
|
|
230
|
+
? el("div", { style: st.empty }, t("正在加载…"))
|
|
231
|
+
: hasData
|
|
232
|
+
? el(StatsTable, { rows })
|
|
233
|
+
: el("div", { style: st.empty }, t("暂无记录")),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── 插件注册 ──────────────────────────────────────────────────────────
|
|
238
|
+
const inject = ["slots"];
|
|
239
|
+
|
|
240
|
+
function apply(ctx) {
|
|
241
|
+
const slots = ctx.get("slots");
|
|
242
|
+
if (slots === undefined) return;
|
|
243
|
+
|
|
244
|
+
const locale = ctx.get("locale");
|
|
245
|
+
try {
|
|
246
|
+
if (locale && typeof locale.getLocale === "function") {
|
|
247
|
+
const snap = locale.getLocale();
|
|
248
|
+
LANG = snap && snap.active === "zh" ? "zh" : "en";
|
|
249
|
+
}
|
|
250
|
+
} catch (e) { /* 语言服务不可读时沿用浏览器语言 */ }
|
|
251
|
+
|
|
252
|
+
slots.inject("conversation.view", () => slots.register(
|
|
253
|
+
{ name: "conversation.view", id: "usage-stats", order: 25, label: () => t("用量统计") },
|
|
254
|
+
UsageStatsPanel,
|
|
255
|
+
));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
exports.inject = inject;
|
|
259
|
+
exports.apply = apply;
|
|
260
|
+
|
|
261
|
+
// __ModuleLoader__ 用 factory 的返回值作为模块导出,漏掉它会拿到 undefined。
|
|
262
|
+
return module.exports;
|
|
263
|
+
},
|
|
264
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-stats — HOST half.
|
|
3
|
+
*
|
|
4
|
+
* 只做用量统计,不做费用计算。统计每次模型调用的四类 token
|
|
5
|
+
* (输入未命中 / 输出 / 缓存命中 / 缓存写入)与总量,按 天 × 服务商 × 模型 落库。
|
|
6
|
+
*
|
|
7
|
+
* 三条数据来源:
|
|
8
|
+
* 1. `session/event` → `assistant/message.usage`:主对话的权威事实,kind='chat'。
|
|
9
|
+
* 事件是 post-commit 广播且不重放,带 seq / messageId,天然幂等。
|
|
10
|
+
* 2. `llm/stream`(仅 purpose 非空):压缩、摘要、标题生成等 DSH 内部调用,
|
|
11
|
+
* kind='internal'。这类调用不写进会话日志,只能实时捕获。
|
|
12
|
+
* 3. 启动回溯:读 `sessionPersistence` 里的历史会话,补齐插件安装前的对话调用。
|
|
13
|
+
*
|
|
14
|
+
* 三路共用主键 (session_id, record_key),靠 INSERT OR IGNORE 去重。
|
|
15
|
+
* 账本是权威事实源,不依赖会话文件;删除会话不影响已记账数据。
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import { mkdirSync } from 'node:fs'
|
|
20
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
21
|
+
|
|
22
|
+
const pad2 = (n) => (n < 10 ? '0' : '') + n
|
|
23
|
+
|
|
24
|
+
/** 本地日历日 YYYY-MM-DD(与 DSH / 官方账单的本地口径一致)。 */
|
|
25
|
+
function dayKey(ts) {
|
|
26
|
+
const d = new Date(ts)
|
|
27
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 本地时区当天 00:00 的毫秒时间戳。 */
|
|
31
|
+
function startOfDay(d) {
|
|
32
|
+
const x = new Date(d.getTime())
|
|
33
|
+
x.setHours(0, 0, 0, 0)
|
|
34
|
+
return x
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 把一条 provider usage 归一成账本的四类桶 + 总量。
|
|
41
|
+
* `totalTokens` 由 provider 给出时优先采用;缺失时四类相加。
|
|
42
|
+
*/
|
|
43
|
+
function buckets(u) {
|
|
44
|
+
const input = num(u?.inputTokens)
|
|
45
|
+
const output = num(u?.outputTokens)
|
|
46
|
+
const cacheRead = num(u?.cacheReadTokens)
|
|
47
|
+
const cacheWrite = num(u?.cacheWriteTokens)
|
|
48
|
+
const total = typeof u?.totalTokens === 'number' && Number.isFinite(u.totalTokens)
|
|
49
|
+
? u.totalTokens
|
|
50
|
+
: input + output + cacheRead + cacheWrite
|
|
51
|
+
return { input, output, cacheRead, cacheWrite, total }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 账本文件:$DSH_HOME/storages/usage-stats/usage.db。 */
|
|
55
|
+
function resolveDbPath() {
|
|
56
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
57
|
+
return path.join(home, 'storages', 'usage-stats', 'usage.db')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SCHEMA = `
|
|
61
|
+
CREATE TABLE IF NOT EXISTS usage_calls (
|
|
62
|
+
session_id TEXT NOT NULL,
|
|
63
|
+
record_key TEXT NOT NULL,
|
|
64
|
+
kind TEXT NOT NULL,
|
|
65
|
+
time INTEGER NOT NULL,
|
|
66
|
+
day TEXT NOT NULL,
|
|
67
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
68
|
+
model TEXT NOT NULL DEFAULT '',
|
|
69
|
+
purpose TEXT NOT NULL DEFAULT '',
|
|
70
|
+
input INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
output INTEGER NOT NULL DEFAULT 0,
|
|
72
|
+
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
cache_write INTEGER NOT NULL DEFAULT 0,
|
|
74
|
+
total INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
PRIMARY KEY (session_id, record_key)
|
|
76
|
+
);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_calls_day ON usage_calls (day);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_calls_day_kind ON usage_calls (day, kind);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_calls_group ON usage_calls (kind, provider, model);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS scan_state (
|
|
82
|
+
session_id TEXT PRIMARY KEY,
|
|
83
|
+
revision TEXT NOT NULL DEFAULT '',
|
|
84
|
+
next_seq INTEGER NOT NULL DEFAULT 0,
|
|
85
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
86
|
+
);
|
|
87
|
+
`
|
|
88
|
+
|
|
89
|
+
/** 用量账本:一个 SQLite 文件,WAL 模式,逐条 INSERT OR IGNORE。 */
|
|
90
|
+
class Ledger {
|
|
91
|
+
constructor(dbPath) {
|
|
92
|
+
mkdirSync(path.dirname(dbPath), { recursive: true })
|
|
93
|
+
this.dbPath = dbPath
|
|
94
|
+
this.db = new DatabaseSync(dbPath)
|
|
95
|
+
this.db.exec('PRAGMA journal_mode = WAL')
|
|
96
|
+
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
97
|
+
this.db.exec(SCHEMA)
|
|
98
|
+
this.insert = this.db.prepare(`
|
|
99
|
+
INSERT OR IGNORE INTO usage_calls
|
|
100
|
+
(session_id, record_key, kind, time, day, provider, model, purpose,
|
|
101
|
+
input, output, cache_read, cache_write, total)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
|
+
`)
|
|
104
|
+
this.getState = this.db.prepare('SELECT revision, next_seq FROM scan_state WHERE session_id = ?')
|
|
105
|
+
this.putState = this.db.prepare(`
|
|
106
|
+
INSERT INTO scan_state (session_id, revision, next_seq, updated_at) VALUES (?, ?, ?, ?)
|
|
107
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
108
|
+
revision = excluded.revision, next_seq = excluded.next_seq, updated_at = excluded.updated_at
|
|
109
|
+
`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 记一笔;主键冲突(同一 session 的同一条消息)直接忽略。 */
|
|
113
|
+
add(rec) {
|
|
114
|
+
this.insert.run(
|
|
115
|
+
String(rec.sessionId || ''), String(rec.recordKey || ''), String(rec.kind || 'chat'),
|
|
116
|
+
Math.round(num(rec.time)), dayKey(num(rec.time)),
|
|
117
|
+
String(rec.provider || ''), String(rec.model || ''), String(rec.purpose || ''),
|
|
118
|
+
Math.round(num(rec.input)), Math.round(num(rec.output)),
|
|
119
|
+
Math.round(num(rec.cacheRead)), Math.round(num(rec.cacheWrite)), Math.round(num(rec.total)),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
scanState(sessionId) {
|
|
124
|
+
const row = this.getState.get(String(sessionId))
|
|
125
|
+
if (!row) return null
|
|
126
|
+
return { revision: String(row.revision || ''), nextSeq: Number(row.next_seq) || 0 }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setScanState(sessionId, revision, nextSeq) {
|
|
130
|
+
this.putState.run(String(sessionId), String(revision || ''), Math.round(num(nextSeq)), Date.now())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 按范围聚合,返回扁平行数组(provider 汇总行 → 其下模型行 → 总计行),
|
|
135
|
+
* 结构与 PI 的用量统计报表一致,前端可直接映射成表格行。
|
|
136
|
+
*/
|
|
137
|
+
summary({ from, to, kind }) {
|
|
138
|
+
const conds = []
|
|
139
|
+
const params = []
|
|
140
|
+
if (from) { conds.push('day >= ?'); params.push(from) }
|
|
141
|
+
if (to) { conds.push('day <= ?'); params.push(to) }
|
|
142
|
+
if (kind === 'chat' || kind === 'internal') { conds.push('kind = ?'); params.push(kind) }
|
|
143
|
+
const where = conds.length ? ' WHERE ' + conds.join(' AND ') : ''
|
|
144
|
+
const groups = this.db.prepare(`
|
|
145
|
+
SELECT provider, model,
|
|
146
|
+
SUM(input) AS input, SUM(output) AS output,
|
|
147
|
+
SUM(cache_read) AS cacheRead, SUM(cache_write) AS cacheWrite,
|
|
148
|
+
SUM(total) AS total, COUNT(*) AS messages
|
|
149
|
+
FROM usage_calls${where}
|
|
150
|
+
GROUP BY provider, model
|
|
151
|
+
`).all(...params)
|
|
152
|
+
|
|
153
|
+
const zero = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, messages: 0 })
|
|
154
|
+
const addInto = (dst, src) => {
|
|
155
|
+
dst.input += src.input; dst.output += src.output
|
|
156
|
+
dst.cacheRead += src.cacheRead; dst.cacheWrite += src.cacheWrite
|
|
157
|
+
dst.total += src.total; dst.messages += src.messages
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const byProvider = new Map()
|
|
161
|
+
const total = zero()
|
|
162
|
+
for (const g of groups) {
|
|
163
|
+
const provider = String(g.provider || '(未知服务商)')
|
|
164
|
+
const row = {
|
|
165
|
+
name: String(g.model || 'unknown'),
|
|
166
|
+
input: num(g.input), output: num(g.output),
|
|
167
|
+
cacheRead: num(g.cacheRead), cacheWrite: num(g.cacheWrite),
|
|
168
|
+
total: num(g.total), messages: num(g.messages),
|
|
169
|
+
}
|
|
170
|
+
let bucket = byProvider.get(provider)
|
|
171
|
+
if (!bucket) {
|
|
172
|
+
bucket = { name: provider, sum: zero(), models: [] }
|
|
173
|
+
byProvider.set(provider, bucket)
|
|
174
|
+
}
|
|
175
|
+
bucket.models.push(row)
|
|
176
|
+
addInto(bucket.sum, row)
|
|
177
|
+
addInto(total, row)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const providers = [...byProvider.values()].sort((a, b) => b.sum.total - a.sum.total)
|
|
181
|
+
const rows = []
|
|
182
|
+
for (const p of providers) {
|
|
183
|
+
rows.push({ level: 'provider', name: p.name, ...p.sum })
|
|
184
|
+
for (const m of p.models.sort((a, b) => b.total - a.total)) {
|
|
185
|
+
rows.push({ level: 'model', name: m.name, provider: p.name, ...m })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
rows.push({ level: 'total', name: '总 Token', ...total })
|
|
189
|
+
|
|
190
|
+
return { rows, total, messages: total.messages, providers: providers.length }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 账本概况:给界面显示数据量与覆盖范围。 */
|
|
194
|
+
status() {
|
|
195
|
+
const row = this.db.prepare(`
|
|
196
|
+
SELECT COUNT(*) AS calls,
|
|
197
|
+
MIN(day) AS firstDay, MAX(day) AS lastDay,
|
|
198
|
+
SUM(CASE WHEN kind = 'chat' THEN 1 ELSE 0 END) AS chatCalls,
|
|
199
|
+
SUM(CASE WHEN kind = 'internal' THEN 1 ELSE 0 END) AS internalCalls
|
|
200
|
+
FROM usage_calls
|
|
201
|
+
`).get()
|
|
202
|
+
let sessions = 0
|
|
203
|
+
try { sessions = Number(this.db.prepare('SELECT COUNT(*) AS n FROM scan_state').get()?.n) || 0 } catch (e) { sessions = 0 }
|
|
204
|
+
return {
|
|
205
|
+
dbPath: this.dbPath,
|
|
206
|
+
calls: num(row?.calls),
|
|
207
|
+
chatCalls: num(row?.chatCalls),
|
|
208
|
+
internalCalls: num(row?.internalCalls),
|
|
209
|
+
firstDay: row?.firstDay || null,
|
|
210
|
+
lastDay: row?.lastDay || null,
|
|
211
|
+
sessionsTracked: sessions,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
close() {
|
|
216
|
+
try { this.db.close() } catch (e) { /* 关闭失败不影响退出 */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 把请求体读成 JSON(解析失败按空对象处理)。 */
|
|
221
|
+
function readBody(req) {
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
let text = ''
|
|
224
|
+
req.on('data', (c) => { text += c })
|
|
225
|
+
req.on('end', () => { try { resolve(JSON.parse(text)) } catch (e) { resolve({}) } })
|
|
226
|
+
req.on('error', () => resolve({}))
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sendJson(res, obj) {
|
|
231
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
232
|
+
res.end(JSON.stringify(obj))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 把请求的 range 参数换算成 [from, to] 的本地日历日闭区间。
|
|
237
|
+
* 周区间以周一为起点,与 PI 的用量报表口径保持一致。
|
|
238
|
+
*/
|
|
239
|
+
function resolveRange(range, dateArg) {
|
|
240
|
+
const today = dayKey(Date.now())
|
|
241
|
+
if (range === 'all') return { from: null, to: null, label: '全部' }
|
|
242
|
+
|
|
243
|
+
if (range === 'date') {
|
|
244
|
+
const d = String(dateArg || '')
|
|
245
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return { from: today, to: today, label: `今日 ${today}` }
|
|
246
|
+
return { from: d, to: d, label: d }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (range === 'week') {
|
|
250
|
+
const base = startOfDay(new Date())
|
|
251
|
+
const weekday = (base.getDay() + 6) % 7 // 周一 = 0
|
|
252
|
+
const start = new Date(base.getTime() - weekday * 86400000)
|
|
253
|
+
const from = dayKey(start.getTime())
|
|
254
|
+
return { from, to: today, label: `本周(${from} 起)` }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (range === 'lastweek') {
|
|
258
|
+
const base = startOfDay(new Date())
|
|
259
|
+
const weekday = (base.getDay() + 6) % 7
|
|
260
|
+
const thisStart = new Date(base.getTime() - weekday * 86400000)
|
|
261
|
+
const lastStart = new Date(thisStart.getTime() - 7 * 86400000)
|
|
262
|
+
const lastEnd = new Date(thisStart.getTime() - 1)
|
|
263
|
+
const from = dayKey(lastStart.getTime())
|
|
264
|
+
const to = dayKey(lastEnd.getTime())
|
|
265
|
+
return { from, to, label: `上周(${from} ~ ${to})` }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { from: today, to: today, label: `今日 ${today}` }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export default {
|
|
272
|
+
inject: ['sessionPersistence', 'webServer'],
|
|
273
|
+
apply(ctx) {
|
|
274
|
+
const warn = (message) => { try { ctx.logger?.warn?.(`[dsh-usage-stats] ${message}`) } catch (e) { /* 日志不可用则静默 */ } }
|
|
275
|
+
|
|
276
|
+
let ledger = null
|
|
277
|
+
try {
|
|
278
|
+
ledger = new Ledger(resolveDbPath())
|
|
279
|
+
} catch (error) {
|
|
280
|
+
warn(`账本打开失败:${error instanceof Error ? error.message : String(error)}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const currentAgent = () => {
|
|
284
|
+
try {
|
|
285
|
+
const agents = ctx.get('agents')
|
|
286
|
+
if (agents && typeof agents.currentInitiator === 'function') return agents.currentInitiator()
|
|
287
|
+
} catch (e) { /* agents 服务可选 */ }
|
|
288
|
+
return undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 会话 → 最近一次请求头里的 provider/model;message.source 缺失时兜底。
|
|
292
|
+
const headers = new Map()
|
|
293
|
+
const rememberHeader = (sessionId, header) => {
|
|
294
|
+
if (!header || typeof header.provider !== 'string' || typeof header.model !== 'string') return
|
|
295
|
+
if (!headers.has(sessionId) && headers.size >= 500) {
|
|
296
|
+
const oldest = headers.keys().next().value
|
|
297
|
+
if (oldest !== undefined) headers.delete(oldest)
|
|
298
|
+
}
|
|
299
|
+
headers.set(sessionId, { provider: header.provider, model: header.model })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (ledger) {
|
|
303
|
+
// ── 路 1:会话事件(主对话,权威事实) ─────────────────────────────
|
|
304
|
+
try {
|
|
305
|
+
ctx.on('session/event', (session, event) => {
|
|
306
|
+
try {
|
|
307
|
+
const sessionId = String(session?.id ?? '')
|
|
308
|
+
if (event?.type === 'request/header') {
|
|
309
|
+
rememberHeader(sessionId, event.data?.header?.config)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (event?.type !== 'assistant/message') return
|
|
313
|
+
const usage = event.data?.usage
|
|
314
|
+
if (!usage) return
|
|
315
|
+
const source = event.data?.message?.source
|
|
316
|
+
const header = headers.get(sessionId)
|
|
317
|
+
ledger.add({
|
|
318
|
+
sessionId,
|
|
319
|
+
recordKey: String(event.data?.message?.id ?? `seq-${event.seq}`),
|
|
320
|
+
kind: 'chat',
|
|
321
|
+
time: num(event.time),
|
|
322
|
+
provider: (typeof source?.provider === 'string' && source.provider) || header?.provider || '',
|
|
323
|
+
model: (typeof source?.model === 'string' && source.model) || header?.model || 'unknown',
|
|
324
|
+
purpose: '',
|
|
325
|
+
...buckets(usage),
|
|
326
|
+
})
|
|
327
|
+
} catch (error) {
|
|
328
|
+
warn(`记录会话事件失败:${error instanceof Error ? error.message : String(error)}`)
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
} catch (error) {
|
|
332
|
+
warn(`订阅 session/event 失败:${error instanceof Error ? error.message : String(error)}`)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── 路 2:LLM 流(只记 purpose 非空的内部调用) ─────────────────────
|
|
336
|
+
// 主对话不在这里记账,直接透传原始流,零额外开销。
|
|
337
|
+
try {
|
|
338
|
+
ctx.on('llm/stream', (options, next) => {
|
|
339
|
+
const source = next()
|
|
340
|
+
const purpose = options?.purpose ? String(options.purpose) : ''
|
|
341
|
+
if (!purpose) return source
|
|
342
|
+
|
|
343
|
+
const provider = String(options?.provider || '')
|
|
344
|
+
const model = String(options?.model || '')
|
|
345
|
+
const startedAt = Date.now()
|
|
346
|
+
let sessionId = options?.sessionId ? String(options.sessionId) : ''
|
|
347
|
+
let usage = null
|
|
348
|
+
|
|
349
|
+
async function* observe() {
|
|
350
|
+
try {
|
|
351
|
+
for await (const chunk of source) {
|
|
352
|
+
if (chunk?.type === 'usage' && chunk.usage) usage = chunk.usage
|
|
353
|
+
yield chunk
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
try {
|
|
357
|
+
if (!sessionId) {
|
|
358
|
+
const agent = currentAgent()
|
|
359
|
+
if (agent?.session?.id) sessionId = String(agent.session.id)
|
|
360
|
+
}
|
|
361
|
+
if (usage) {
|
|
362
|
+
ledger.add({
|
|
363
|
+
sessionId: sessionId || '(内部)',
|
|
364
|
+
recordKey: `internal:${purpose}:${startedAt}`,
|
|
365
|
+
kind: 'internal',
|
|
366
|
+
time: startedAt,
|
|
367
|
+
provider, model, purpose,
|
|
368
|
+
...buckets(usage),
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
} catch (error) {
|
|
372
|
+
warn(`记录内部调用失败:${error instanceof Error ? error.message : String(error)}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return observe()
|
|
378
|
+
})
|
|
379
|
+
} catch (error) {
|
|
380
|
+
warn(`订阅 llm/stream 失败:${error instanceof Error ? error.message : String(error)}`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── 路 3:历史回溯(补齐插件安装前的对话调用) ──────────────────────
|
|
384
|
+
// 内部调用不在会话日志里,无法回溯,只能靠路 2 实时捕获。
|
|
385
|
+
const backfill = async () => {
|
|
386
|
+
const persistence = ctx.get('sessionPersistence')
|
|
387
|
+
if (!persistence || typeof persistence.list !== 'function') return
|
|
388
|
+
let snapshots = []
|
|
389
|
+
try {
|
|
390
|
+
snapshots = await persistence.list()
|
|
391
|
+
} catch (error) {
|
|
392
|
+
warn(`列出会话失败:${error instanceof Error ? error.message : String(error)}`)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
let scanned = 0
|
|
396
|
+
let added = 0
|
|
397
|
+
for (const snapshot of snapshots) {
|
|
398
|
+
const sessionId = String(snapshot?.header?.id ?? '')
|
|
399
|
+
if (!sessionId) continue
|
|
400
|
+
const revision = String(snapshot?.revision ?? '')
|
|
401
|
+
const state = ledger.scanState(sessionId)
|
|
402
|
+
if (state && revision && state.revision === revision) continue
|
|
403
|
+
|
|
404
|
+
let handle = null
|
|
405
|
+
try {
|
|
406
|
+
handle = await persistence.open(sessionId, 'read')
|
|
407
|
+
const fromSeq = state ? state.nextSeq : 0
|
|
408
|
+
const { events } = await handle.read(fromSeq)
|
|
409
|
+
let maxSeq = fromSeq - 1
|
|
410
|
+
for (const event of events) {
|
|
411
|
+
if (typeof event?.seq === 'number' && event.seq > maxSeq) maxSeq = event.seq
|
|
412
|
+
if (event?.type !== 'assistant/message') continue
|
|
413
|
+
const usage = event.data?.usage
|
|
414
|
+
if (!usage) continue
|
|
415
|
+
const source = event.data?.message?.source
|
|
416
|
+
ledger.add({
|
|
417
|
+
sessionId,
|
|
418
|
+
recordKey: String(event.data?.message?.id ?? `seq-${event.seq}`),
|
|
419
|
+
kind: 'chat',
|
|
420
|
+
time: num(event.time),
|
|
421
|
+
provider: typeof source?.provider === 'string' ? source.provider : '',
|
|
422
|
+
model: typeof source?.model === 'string' ? source.model : 'unknown',
|
|
423
|
+
purpose: '',
|
|
424
|
+
...buckets(usage),
|
|
425
|
+
})
|
|
426
|
+
added += 1
|
|
427
|
+
}
|
|
428
|
+
ledger.setScanState(sessionId, revision, maxSeq + 1)
|
|
429
|
+
scanned += 1
|
|
430
|
+
} catch (error) {
|
|
431
|
+
warn(`回溯会话 ${sessionId} 失败:${error instanceof Error ? error.message : String(error)}`)
|
|
432
|
+
} finally {
|
|
433
|
+
try { await handle?.close?.() } catch (e) { /* 句柄已关闭 */ }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (scanned > 0) warn(`历史回溯完成:扫描 ${scanned} 个会话,补录 ${added} 条用量`)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 异步跑,不阻塞插件激活与 web 服务启动。
|
|
440
|
+
Promise.resolve().then(backfill).catch((error) => {
|
|
441
|
+
warn(`历史回溯异常:${error instanceof Error ? error.message : String(error)}`)
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── API ───────────────────────────────────────────────────────────────
|
|
446
|
+
const route = (body) => {
|
|
447
|
+
if (!ledger) return { ok: false, error: '账本不可用(SQLite 打开失败)' }
|
|
448
|
+
const action = body?.action ? String(body.action) : 'summary'
|
|
449
|
+
if (action === 'status') return { ok: true, ...ledger.status() }
|
|
450
|
+
if (action === 'summary') {
|
|
451
|
+
const kind = ['chat', 'internal', 'all'].includes(body?.kind) ? String(body.kind) : 'all'
|
|
452
|
+
const range = resolveRange(body?.range, body?.date)
|
|
453
|
+
return { ok: true, label: range.label, range: { from: range.from, to: range.to }, kind, ...ledger.summary({ ...range, kind }) }
|
|
454
|
+
}
|
|
455
|
+
return { ok: false, error: `未知操作:${action}` }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const webServer = ctx.get('webServer')
|
|
459
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
460
|
+
ctx.effect(() => webServer.register({
|
|
461
|
+
kind: 'exact',
|
|
462
|
+
path: '/usage/api',
|
|
463
|
+
handler: async (req, res) => {
|
|
464
|
+
try {
|
|
465
|
+
sendJson(res, route(await readBody(req)))
|
|
466
|
+
} catch (error) {
|
|
467
|
+
sendJson(res, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
}), 'dsh-usage-stats: /usage/api')
|
|
471
|
+
} else {
|
|
472
|
+
warn('webServer 不可用,/usage/api 未注册')
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
ctx.effect(() => () => { ledger?.close() }, 'dsh-usage-stats: 关闭账本')
|
|
476
|
+
},
|
|
477
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lengduan/dsh-usage-stats",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH 用量统计插件:SQLite 本地账本,按天 / 按模型统计输入、输出、缓存命中、总计;区分对话调用与内部调用,只统计用量不做费用计算。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/lengduan/dsh-usage-stats.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./lib/index.js",
|
|
14
|
+
"./client": "./lib/client.js",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.5"
|
|
19
|
+
},
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
},
|
|
24
|
+
"client": {
|
|
25
|
+
"platform": "web",
|
|
26
|
+
"inject": [
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"verify": "node scripts/verify.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"lib",
|
|
36
|
+
"scripts",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自检:在装载进 profile 之前验证两半都能被宿主正常加载。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖两类曾经真实翻车的问题:
|
|
5
|
+
* 1. host 半:ESM 能否 import、是否导出 inject / apply。
|
|
6
|
+
* 2. client 半:factory 是否 return module.exports(漏掉会让 boot 收到 undefined,
|
|
7
|
+
* 插件静默失效,而 dsh web 本身仍能正常启动)。
|
|
8
|
+
* 另外模拟一次 apply,确认 tab 真的注册到了 conversation.view。
|
|
9
|
+
*
|
|
10
|
+
* 用法:node scripts/verify.mjs
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import vm from 'node:vm'
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
let failed = 0
|
|
19
|
+
|
|
20
|
+
function check(label, ok, detail = '') {
|
|
21
|
+
console.log(`${ok ? ' [ok]' : ' [FAIL]'} ${label}${detail ? ' — ' + detail : ''}`)
|
|
22
|
+
if (!ok) failed += 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── host 半 ───────────────────────────────────────────────────────────────
|
|
26
|
+
console.log('host 半 lib/index.js')
|
|
27
|
+
const host = (await import(pathToFileURL(path.join(root, 'lib', 'index.js')).href)).default
|
|
28
|
+
check('可 import 且 default 导出对象', !!host && typeof host === 'object')
|
|
29
|
+
check('apply 是函数', typeof host?.apply === 'function')
|
|
30
|
+
check('inject 是数组', Array.isArray(host?.inject), JSON.stringify(host?.inject))
|
|
31
|
+
|
|
32
|
+
// ── client 半 ─────────────────────────────────────────────────────────────
|
|
33
|
+
console.log('client 半 lib/client.js')
|
|
34
|
+
const code = fs.readFileSync(path.join(root, 'lib', 'client.js'), 'utf8')
|
|
35
|
+
let definition = null
|
|
36
|
+
const sandbox = {
|
|
37
|
+
window: { __ModuleLoader__: { load: (def) => { definition = def } } },
|
|
38
|
+
console,
|
|
39
|
+
}
|
|
40
|
+
vm.createContext(sandbox)
|
|
41
|
+
try {
|
|
42
|
+
vm.runInContext(code, sandbox)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
check('脚本可执行', false, String(error?.message ?? error))
|
|
45
|
+
}
|
|
46
|
+
check('调用 __ModuleLoader__.load 注册了模块', !!definition)
|
|
47
|
+
check('id 与包名一致', definition?.id === 'dsh-usage-stats', String(definition?.id))
|
|
48
|
+
|
|
49
|
+
const React = {
|
|
50
|
+
createElement: () => null,
|
|
51
|
+
useState: () => [null, () => {}],
|
|
52
|
+
useEffect: () => {},
|
|
53
|
+
useMemo: (fn) => fn(),
|
|
54
|
+
useCallback: (fn) => fn,
|
|
55
|
+
}
|
|
56
|
+
const fakeRequire = (name) => {
|
|
57
|
+
if (name === 'react') return React
|
|
58
|
+
throw new Error(`未声明的外部模块:${name}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let exported = null
|
|
62
|
+
try {
|
|
63
|
+
exported = definition.factory(fakeRequire)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
check('factory 可调用', false, String(error?.message ?? error))
|
|
66
|
+
}
|
|
67
|
+
// 这一条就是防"忘记 return module.exports"的回归
|
|
68
|
+
check('factory 返回模块对象(不是 undefined)', exported !== null && typeof exported === 'object')
|
|
69
|
+
check('导出 apply', typeof exported?.apply === 'function')
|
|
70
|
+
check('导出 inject', Array.isArray(exported?.inject))
|
|
71
|
+
|
|
72
|
+
// 模拟宿主 ctx,确认 apply 真的注册了 tab
|
|
73
|
+
let registered = null
|
|
74
|
+
const ctx = {
|
|
75
|
+
get(name) {
|
|
76
|
+
if (name === 'slots') {
|
|
77
|
+
return {
|
|
78
|
+
inject: (_slotName, fn) => fn(),
|
|
79
|
+
register: (options, Component) => { registered = { options, Component }; return () => {} },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
exported?.apply?.(ctx)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
check('apply 可执行', false, String(error?.message ?? error))
|
|
89
|
+
}
|
|
90
|
+
check('注册到 conversation.view', registered?.options?.name === 'conversation.view')
|
|
91
|
+
check('tab id 正确', registered?.options?.id === 'usage-stats', String(registered?.options?.id))
|
|
92
|
+
check('tab label 可用', typeof registered?.options?.label === 'function' && !!registered.options.label())
|
|
93
|
+
|
|
94
|
+
console.log(failed === 0 ? '\n自检通过。' : `\n自检失败:${failed} 项。`)
|
|
95
|
+
process.exit(failed === 0 ? 0 : 1)
|