dsh-remote-plugin 0.5.6-rc.1 → 0.5.7-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,45 @@
1
+ # dsh-remote-plugin
2
+
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
4
+
5
+ **English** · [中文](README.md)
6
+
7
+ The DSH bundle plugin for DSH Remote: registers a native entry in DSH's left sidebar and opens an admin drawer from the right. The plugin **bundles the gateway and auto-starts/stops it with DSH** (standalone systemd unit); the drawer shows the token, host IP and device monitor. Works with the [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) Android app for remote control and file transfer (`/fs/list`, `/fs/file`, `/fs/upload`). The panel ships four themes (Deep Space / Sunset / Elbphilharmonie / Prairie Tower) — light / dark / neutral, following the system preference automatically.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ dsh plugin --profile web add dsh-remote-plugin
13
+ # or pin a version
14
+ dsh plugin --profile web add dsh-remote-plugin@0.5.0
15
+ ```
16
+
17
+ Restart DSH Web and hard-refresh (Ctrl+F5); the app icon entry appears at the bottom of the left sidebar.
18
+
19
+ git source install (equivalent):
20
+
21
+ ```sh
22
+ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packages/plugin"
23
+ ```
24
+
25
+ ## Gateway
26
+
27
+ - Auto-start by default: on DSH startup or drawer refresh, the plugin launches the built-in `gateway.cjs` (`0.0.0.0:8787`), independent of the DSH process; Linux uses a standalone systemd unit, and environments without systemd (e.g. macOS) automatically fall back to a resident child process.
28
+ - On/off intent persists in `~/.dsh-remote/gateway.enabled`; it can be stopped/started from the drawer.
29
+ - Token lives in `~/.dsh-remote/token` (auto-generated on first run, reused and never overwritten), shown in the drawer and copyable; supports **QR pairing** and **one-click rotation**.
30
+ - Env var `DSH_REMOTE_AUTOSTART=0` disables auto management.
31
+ - File endpoints: `/fs/list` (list directory), `/fs/file` (download with Range support), `/fs/upload` (chunked resume with pause/cancel, SHA-256 verified before writing to disk); default root is `~`, and `DSH_REMOTE_FS_ROOT` opens multiple roots (`:`-separated).
32
+
33
+ ## Mobile App
34
+
35
+ The app is embedded in the plugin package (`apk/dsh-remote.apk`) — install the plugin and you have it, no GitHub needed:
36
+
37
+ - Tap **QR code** in the desktop drawer → scan it with the phone to open the admin page → download the app from that page
38
+ - Or open `http://<gateway-IP>:8787` in a browser to download
39
+ - App updates are pushed by the gateway too (`update.json` relative path), never touching GitHub
40
+
41
+ The app has built-in multi-server latency switching and offline chat history caching; downloaded files are stored in the system `Download/dsh-remote` folder.
42
+
43
+ ## License
44
+
45
+ MIT
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
4
4
 
5
- DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App 实现手机远程操控与文件互传(`/fs/list`、`/fs/file`、`/fs/upload`)。
5
+ **中文** · [English](README.en.md)
6
+
7
+ DSH Remote 的 DSH bundle 插件:在 DSH 左侧原生边栏注册入口,点击从右侧滑出管理抽屉;插件**内置网关程序并随 DSH 自动启停**(独立 systemd 单元),抽屉直显令牌、主机 IP 与设备监控,配合 [dsh-Remote](https://github.com/Blank-not-black/dsh-Remote) 的 Android App 实现手机远程操控与文件互传(`/fs/list`、`/fs/file`、`/fs/upload`)。面板内置四套皮肤(深空 / 落日 / 易北爱乐厅 / 草原孤塔),亮暗中性三档随系统偏好自动切换。
6
8
 
7
9
  ## 安装
8
10
 
@@ -22,7 +24,7 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
22
24
 
23
25
  ## 网关
24
26
 
25
- - 默认**自动启动**:DSH 启动或抽屉刷新时,插件会拉起内置 `gateway.cjs`(`0.0.0.0:8787`),独立于 DSH 进程。
27
+ - 默认**自动启动**:DSH 启动或抽屉刷新时,插件会拉起内置 `gateway.cjs`(`0.0.0.0:8787`),独立于 DSH 进程;Linux 使用独立 systemd 单元,macOS 等无 systemd 环境自动回退为常驻子进程。
26
28
  - 开关持久化在 `~/.dsh-remote/gateway.enabled`;抽屉内可停止/启动。
27
29
  - 令牌在 `~/.dsh-remote/token`(首次自动生成,重复使用不覆盖),抽屉里显示并可复制;支持**二维码扫码配对**与**一键轮换**。
28
30
  - 环境变量 `DSH_REMOTE_AUTOSTART=0` 可关闭自动管理。
@@ -30,7 +32,13 @@ dsh plugin --profile web add "github:Blank-not-black/dsh-Remote#main&path:/packa
30
32
 
31
33
  ## 手机 App
32
34
 
33
- [Releases](https://github.com/Blank-not-black/dsh-Remote/releases/latest) 下载 `dsh-remote.apk`。推荐在抽屉点「二维码」,App「设置 → 扫码连接」直接配对;也可手动添加多个服务器地址(局域网 + Tailscale),点「测速」自动选最快。聊天记录会本地缓存,断网也能看历史;下载的文件统一放系统「下载/dsh-remote」。
35
+ App 已随插件包内嵌(`apk/dsh-remote.apk`),装好插件即可获取,无需上 GitHub:
36
+
37
+ - 桌面抽屉点「二维码」→ 手机扫码打开管理页 → 页面提供 App 下载
38
+ - 或浏览器直接访问 `http://<网关IP>:8787` 下载
39
+ - App 更新同样走网关自动推送(`update.json` 相对路径),全程不绕 GitHub
40
+
41
+ App 内置多服务器测速切换与聊天记录离线缓存,下载文件统一放系统「下载/dsh-remote」。
34
42
 
35
43
  ## License
36
44
 
Binary file
@@ -0,0 +1,413 @@
1
+ /* DSH Remote 统计核心 —— 零依赖
2
+ *
3
+ * 事件来源:
4
+ * - 插件进程实时监听 DSH 'session/event' (assistant/message + usage) -> POST /stats/ingest
5
+ * - 网关扫描 ~/.dsh/sessions 下的 session.jsonl.zstd 历史回填(启动全量 + 定期增量)
6
+ *
7
+ * 聚合:
8
+ * time(UTC 毫秒) -> Asia/Shanghai(固定 UTC+8, 无夏令时) -> 日 × 小时 × 模型
9
+ * 时段: 高峰 9:00-12:00 / 14:00-18:00(含起点, 不含终点), 其余空闲
10
+ * 四桶: input(未缓存输入) / cacheRead(缓存命中读) / cacheWrite(缓存写) / output(输出)
11
+ *
12
+ * 存储:
13
+ * ~/.dsh-remote/stats/days/YYYY-MM-DD.json 按天一个文件, 内含 hours
14
+ * ~/.dsh-remote/stats/cursors.json 每个 session 已处理的最大 seq(幂等游标)
15
+ *
16
+ * 费用: 固定价格表(元 / 百万 tokens), v2 将改为可配置。
17
+ */
18
+ 'use strict'
19
+
20
+ const fs = require('node:fs')
21
+ const path = require('node:path')
22
+ const os = require('node:os')
23
+ const { spawn } = require('node:child_process')
24
+ const readline = require('node:readline')
25
+
26
+ // ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
27
+ // 时段判定: 北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点)
28
+ const PEAK_HOURS = [[9, 12], [14, 18]]
29
+ const PRICES = {
30
+ 'deepseek-v4-flash': {
31
+ inputCacheHit: { peak: 0.10, off: 0.05 },
32
+ inputMiss: { peak: 3.0, off: 1.5 },
33
+ output: { peak: 9.0, off: 4.5 },
34
+ },
35
+ 'deepseek-v4-pro': {
36
+ inputCacheHit: { peak: 0.30, off: 0.15 },
37
+ inputMiss: { peak: 9.0, off: 4.5 },
38
+ output: { peak: 27.0, off: 13.5 },
39
+ },
40
+ }
41
+ // 缓存写入按未命中输入价计费(DeepSeek 缓存写入以未命中输入计费)
42
+ const BUCKET_PRICE_KEY = {
43
+ input: 'inputMiss',
44
+ cacheRead: 'inputCacheHit',
45
+ cacheWrite: 'inputMiss',
46
+ output: 'output',
47
+ }
48
+
49
+ const BJ_OFFSET_MS = 8 * 3600 * 1000
50
+ const DAYS_DIR = 'days'
51
+ const CURSORS_FILE = 'cursors.json'
52
+
53
+ function pad2(n) { return String(n).padStart(2, '0') }
54
+
55
+ /** UTC 毫秒 -> 北京小时(0-23)。固定 UTC+8, 不随服务器本地时区。 */
56
+ function beijingHour(timeMs) {
57
+ return new Date(timeMs + BJ_OFFSET_MS).getUTCHours()
58
+ }
59
+
60
+ /** UTC 毫秒 -> 北京自然日 'YYYY-MM-DD'。 */
61
+ function beijingDate(timeMs) {
62
+ const d = new Date(timeMs + BJ_OFFSET_MS)
63
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
64
+ }
65
+
66
+ /** 北京小时 -> 时段。 */
67
+ function periodOfHour(hour) {
68
+ for (const [start, end] of PEAK_HOURS) {
69
+ if (hour >= start && hour < end) return 'peak'
70
+ }
71
+ return 'off'
72
+ }
73
+
74
+ /** 时段单价; 未知模型返回全 0(统计照记, 费用为 0)。 */
75
+ function pricesFor(model) {
76
+ return PRICES[model] || null
77
+ }
78
+
79
+ function emptyBucket() {
80
+ return { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, cost: 0 }
81
+ }
82
+
83
+ function addTokens(bucket, key, tokens) {
84
+ if (typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0) {
85
+ bucket[key] += tokens
86
+ }
87
+ return bucket
88
+ }
89
+
90
+ /** 把一个 usage 事件累计进 bucket(含费用)。 */
91
+ function addUsage(bucket, model, period, usage) {
92
+ const prices = pricesFor(model)
93
+ const usageObj = usage || {}
94
+ for (const key of ['input', 'cacheRead', 'cacheWrite', 'output']) {
95
+ const tokens = usageObj[key]
96
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) continue
97
+ bucket[key] += tokens
98
+ if (prices) {
99
+ const priceKey = BUCKET_PRICE_KEY[key]
100
+ bucket.cost += tokens / 1e6 * prices[priceKey][period]
101
+ }
102
+ }
103
+ return bucket
104
+ }
105
+
106
+ function mergeBucket(dst, src) {
107
+ for (const key of ['input', 'cacheRead', 'cacheWrite', 'output', 'cost']) {
108
+ dst[key] += src[key]
109
+ }
110
+ return dst
111
+ }
112
+
113
+ /** 时段事件 -> 日/小时/模型聚合的 key。 */
114
+ function eventKey(timeMs) {
115
+ return { date: beijingDate(timeMs), hour: beijingHour(timeMs), period: periodOfHour(beijingHour(timeMs)) }
116
+ }
117
+
118
+ /** 事件里的模型: 优先 message.source.model。 */
119
+ function eventModel(event) {
120
+ try {
121
+ const m = event?.data?.message?.source?.model
122
+ if (typeof m === 'string' && m) return m
123
+ } catch {}
124
+ return ''
125
+ }
126
+
127
+ function normalizeUsage(event) {
128
+ const u = event?.data?.usage
129
+ if (!u || typeof u !== 'object') return null
130
+ return {
131
+ input: typeof u.inputTokens === 'number' && Number.isFinite(u.inputTokens) ? u.inputTokens : 0,
132
+ cacheRead: typeof u.cacheReadTokens === 'number' && Number.isFinite(u.cacheReadTokens) ? u.cacheReadTokens : 0,
133
+ cacheWrite: typeof u.cacheWriteTokens === 'number' && Number.isFinite(u.cacheWriteTokens) ? u.cacheWriteTokens : 0,
134
+ output: typeof u.outputTokens === 'number' && Number.isFinite(u.outputTokens) ? u.outputTokens : 0,
135
+ }
136
+ }
137
+
138
+ /** 汇总一天(hours) -> peak/off/total。 */
139
+ function summarizeDay(day) {
140
+ const peak = emptyBucket()
141
+ const off = emptyBucket()
142
+ for (const hourStr of Object.keys(day?.hours || {})) {
143
+ const hour = Number(hourStr)
144
+ const target = periodOfHour(hour) === 'peak' ? peak : off
145
+ for (const model of Object.keys(day.hours[hourStr] || {})) {
146
+ mergeBucket(target, day.hours[hourStr][model])
147
+ }
148
+ }
149
+ const total = emptyBucket()
150
+ mergeBucket(total, peak)
151
+ mergeBucket(total, off)
152
+ return { peak, off, total }
153
+ }
154
+
155
+ function dayTotals(day) {
156
+ const s = summarizeDay(day)
157
+ return s
158
+ }
159
+
160
+ function tokenKeyName(key) {
161
+ return { input: 'input', cacheRead: 'cacheRead', cacheWrite: 'cacheWrite', output: 'output' }[key] || key
162
+ }
163
+
164
+ /** 统计存储: 单文件按天 + 游标。写操作由网关单进程调用, 内部用同步队列串行化。 */
165
+ class StatsStore {
166
+ constructor(dir) {
167
+ this.dir = dir || path.join(os.homedir(), '.dsh-remote', 'stats')
168
+ this.daysDir = path.join(this.dir, DAYS_DIR)
169
+ this.cursorsFile = path.join(this.dir, CURSORS_FILE)
170
+ this.cursors = null
171
+ this.queue = Promise.resolve()
172
+ fs.mkdirSync(this.daysDir, { recursive: true })
173
+ }
174
+
175
+ _dayFile(date) { return path.join(this.daysDir, `${date}.json`) }
176
+
177
+ _loadDay(date) {
178
+ try {
179
+ return JSON.parse(fs.readFileSync(this._dayFile(date), 'utf8'))
180
+ } catch {
181
+ return { date, hours: {} }
182
+ }
183
+ }
184
+
185
+ _saveDay(day) {
186
+ const file = this._dayFile(day.date)
187
+ const tmp = file + '.tmp'
188
+ fs.mkdirSync(path.dirname(file), { recursive: true })
189
+ fs.writeFileSync(tmp, JSON.stringify(day))
190
+ fs.renameSync(tmp, file)
191
+ }
192
+
193
+ _loadCursors() {
194
+ if (this.cursors) return this.cursors
195
+ try {
196
+ this.cursors = JSON.parse(fs.readFileSync(this.cursorsFile, 'utf8'))
197
+ } catch {
198
+ this.cursors = {}
199
+ }
200
+ return this.cursors
201
+ }
202
+
203
+ _saveCursors() {
204
+ const tmp = this.cursorsFile + '.tmp'
205
+ fs.mkdirSync(path.dirname(this.cursorsFile), { recursive: true })
206
+ fs.writeFileSync(tmp, JSON.stringify(this.cursors))
207
+ fs.renameSync(tmp, this.cursorsFile)
208
+ }
209
+
210
+ _cursor(sessionId) {
211
+ const c = this._loadCursors()
212
+ const cur = c[sessionId]
213
+ if (cur && typeof cur.lastSeq === 'number') return cur
214
+ return null
215
+ }
216
+
217
+ _setCursor(sessionId, lastSeq) {
218
+ const c = this._loadCursors()
219
+ c[sessionId] = { lastSeq, updatedAt: Date.now() }
220
+ this._saveCursors()
221
+ }
222
+
223
+ /** 在串行队列里执行统计写操作。 */
224
+ _enqueue(fn) {
225
+ const run = this.queue.then(fn)
226
+ this.queue = run.catch(() => {})
227
+ return run
228
+ }
229
+
230
+ /**
231
+ * 处理单个 session 事件(已过滤为 assistant/message 且 usage 有效)。
232
+ * - seq <= cursor: 重复, 跳过
233
+ * - seq === cursor+1: 正常聚合
234
+ * - seq > cursor+1: gap, 不聚合, 等待扫描补漏
235
+ */
236
+ processEvent(sessionId, event, fallbackModel) {
237
+ const seq = typeof event.seq === 'number' ? event.seq : -1
238
+ const time = typeof event.time === 'number' ? event.time : Date.now()
239
+ const usage = normalizeUsage(event)
240
+ if (!usage || seq < 0) return { processed: false, gap: false, skip: true }
241
+ const cur = this._cursor(sessionId)
242
+ const lastSeq = cur ? cur.lastSeq : -1
243
+ if (seq <= lastSeq) return { processed: false, gap: false, skip: true }
244
+ if (seq > lastSeq + 1) return { processed: false, gap: true, skip: true }
245
+
246
+ const model = eventModel(event) || fallbackModel || 'unknown'
247
+ const { date, hour, period } = eventKey(time)
248
+ const day = this._loadDay(date)
249
+ const hourBucket = day.hours[hour] || (day.hours[hour] = {})
250
+ const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
251
+ addUsage(modelBucket, model, period, usage)
252
+ this._saveDay(day)
253
+ this._setCursor(sessionId, seq)
254
+ return { processed: true, gap: false, skip: false }
255
+ }
256
+
257
+ /** 串行化版本(HTTP ingest 用)。 */
258
+ ingestEvent(sessionId, event, fallbackModel) {
259
+ return this._enqueue(() => this.processEvent(sessionId, event, fallbackModel))
260
+ }
261
+
262
+ /**
263
+ * 扫描一个 zstd 会话文件, 从游标后顺序处理。返回处理的事件数。
264
+ * 用系统 zstd 命令解压(项目约束: 不新增 npm 运行时依赖; Windows 无 zstd 时跳过)。
265
+ */
266
+ scanFile(file, onProgress) {
267
+ return new Promise((resolvePromise) => {
268
+ const sessionId = path.basename(path.dirname(file))
269
+ const cur = this._cursor(sessionId)
270
+ const startSeq = cur ? cur.lastSeq + 1 : 0
271
+ let lastSeq = cur ? cur.lastSeq : -1
272
+ let processed = 0
273
+ let currentModel = ''
274
+ let headerParsed = false
275
+
276
+ const zstd = spawn('zstd', ['-dc', file], { stdio: ['ignore', 'pipe', 'ignore'] })
277
+ const rl = readline.createInterface({ input: zstd.stdout })
278
+
279
+ zstd.on('error', (err) => {
280
+ if (err.code === 'ENOENT') {
281
+ console.warn(`[stats] 未找到 zstd 命令, 跳过历史回填: ${file}`)
282
+ } else {
283
+ console.warn(`[stats] zstd 解压失败 ${file}: ${err.message}`)
284
+ }
285
+ rl.close()
286
+ resolvePromise({ sessionId, processed, skipped: 0, error: err.code || err.message })
287
+ })
288
+
289
+ rl.on('line', (line) => {
290
+ let event
291
+ try {
292
+ event = JSON.parse(line)
293
+ } catch {
294
+ return
295
+ }
296
+ // 首行是 session header, 没有 seq
297
+ if (!headerParsed) {
298
+ headerParsed = true
299
+ return
300
+ }
301
+ if (typeof event.seq !== 'number') return
302
+ if (event.seq <= lastSeq) return
303
+ if (event.seq > lastSeq + 1) {
304
+ // 日志理论上是连续 seq; 出现空洞时以文件为准继续顺序推进(seq 游标按文件顺序)
305
+ }
306
+ // 跟踪当前模型配置: request/header 与 request/context 都可能带模型
307
+ try {
308
+ if (event.type === 'request/header' && event.data?.config?.model) currentModel = event.data.config.model
309
+ if (event.type === 'request/context' && event.data?.model) currentModel = event.data.model
310
+ } catch {}
311
+ if (event.type === 'assistant/message') {
312
+ const usage = normalizeUsage(event)
313
+ if (usage) {
314
+ const model = eventModel(event) || currentModel || 'unknown'
315
+ const { date, hour, period } = eventKey(event.time)
316
+ const day = this._loadDay(date)
317
+ const hourBucket = day.hours[hour] || (day.hours[hour] = {})
318
+ const modelBucket = hourBucket[model] || (hourBucket[model] = emptyBucket())
319
+ addUsage(modelBucket, model, period, usage)
320
+ this._saveDay(day)
321
+ processed++
322
+ }
323
+ }
324
+ lastSeq = event.seq
325
+ })
326
+
327
+ rl.on('close', () => {
328
+ if (lastSeq >= 0) {
329
+ this._setCursor(sessionId, lastSeq)
330
+ }
331
+ if (onProgress) onProgress({ sessionId, processed })
332
+ resolvePromise({ sessionId, processed })
333
+ })
334
+ })
335
+ }
336
+
337
+ /** 扫描 ~/.dsh/sessions 下全部 session.jsonl.zstd。 */
338
+ async scanAll(sessionsRoot, onProgress) {
339
+ const root = sessionsRoot || path.join(os.homedir(), '.dsh', 'sessions')
340
+ let files = []
341
+ try {
342
+ const walk = (dir) => {
343
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
344
+ if (ent.isDirectory()) walk(path.join(dir, ent.name))
345
+ else if (ent.name === 'session.jsonl.zstd') files.push(path.join(dir, ent.name))
346
+ }
347
+ }
348
+ walk(root)
349
+ } catch (err) {
350
+ console.warn('[stats] 扫描会话目录失败: ' + (err.message || err))
351
+ return { files: 0, processed: 0 }
352
+ }
353
+ let processed = 0
354
+ // 串行扫描, 避免大量并发 zstd 子进程
355
+ for (const file of files) {
356
+ const out = await this.scanFile(file, onProgress)
357
+ processed += out.processed || 0
358
+ }
359
+ return { files: files.length, processed }
360
+ }
361
+
362
+ summary(days) {
363
+ const n = Math.max(1, Math.min(Number(days) || 7, 90))
364
+ const out = []
365
+ for (let i = n - 1; i >= 0; i--) {
366
+ const d = new Date(Date.now() + BJ_OFFSET_MS)
367
+ d.setUTCDate(d.getUTCDate() - i)
368
+ const date = `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
369
+ const day = this._loadDay(date)
370
+ const s = dayTotals(day)
371
+ const byModel = {}
372
+ for (const hourStr of Object.keys(day.hours || {})) {
373
+ const period = periodOfHour(Number(hourStr))
374
+ for (const [model, bucket] of Object.entries(day.hours[hourStr])) {
375
+ const m = byModel[model] || (byModel[model] = { peak: emptyBucket(), off: emptyBucket(), total: emptyBucket() })
376
+ mergeBucket(m[period], bucket)
377
+ mergeBucket(m.total, bucket)
378
+ }
379
+ }
380
+ out.push({ date, ...s, byModel })
381
+ }
382
+ return out
383
+ }
384
+
385
+ detail(date) {
386
+ const day = this._loadDay(date)
387
+ const hours = []
388
+ for (let hour = 0; hour < 24; hour++) {
389
+ const models = day.hours[hour] || {}
390
+ const total = emptyBucket()
391
+ for (const bucket of Object.values(models)) mergeBucket(total, bucket)
392
+ hours.push({ hour, period: periodOfHour(hour), models, total })
393
+ }
394
+ return { date, hours }
395
+ }
396
+ }
397
+
398
+ module.exports = {
399
+ BJ_OFFSET_MS,
400
+ PEAK_HOURS,
401
+ PRICES,
402
+ beijingHour,
403
+ beijingDate,
404
+ periodOfHour,
405
+ emptyBucket,
406
+ addUsage,
407
+ summarizeDay,
408
+ dayTotals,
409
+ eventKey,
410
+ eventModel,
411
+ normalizeUsage,
412
+ StatsStore,
413
+ }
package/gateway.cjs CHANGED
@@ -30,6 +30,15 @@ const path = require('node:path')
30
30
  const os = require('node:os')
31
31
  const crypto = require('node:crypto')
32
32
 
33
+ let statsCore = null
34
+ let statsStore = null
35
+ try {
36
+ statsCore = require('./gateway-stats.cjs')
37
+ statsStore = new statsCore.StatsStore()
38
+ } catch (err) {
39
+ console.warn('[stats] 统计模块初始化失败, 统计 API 将不可用: ' + (err?.message || err))
40
+ }
41
+
33
42
  const ROOT = __dirname
34
43
  const PUBLIC_DIR = path.join(ROOT, 'public')
35
44
  const PORT = Number(process.env.PORT) || 8787
@@ -393,6 +402,97 @@ function cors(res) {
393
402
  res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS')
394
403
  }
395
404
 
405
+ // ---------- 统计 API ----------
406
+ let statsScanning = false
407
+ async function scanStatsOnce(delay) {
408
+ if (statsScanning || !statsStore) return
409
+ statsScanning = true
410
+ if (delay) await new Promise(r => setTimeout(r, delay))
411
+ try {
412
+ const out = await statsStore.scanAll()
413
+ if (out.files) console.log(`[stats] 历史回填扫描完成: ${out.processed} 个新事件 (${out.files} 个会话文件)`)
414
+ } catch (err) {
415
+ console.warn('[stats] 历史回填扫描失败: ' + (err?.message || err))
416
+ } finally {
417
+ statsScanning = false
418
+ }
419
+ }
420
+
421
+ function serveStats(req, res, url) {
422
+ cors(res)
423
+ if (req.method === 'OPTIONS') {
424
+ res.writeHead(204)
425
+ res.end()
426
+ return
427
+ }
428
+ if (!statsStore) {
429
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' })
430
+ res.end(JSON.stringify({ error: 'stats unavailable' }))
431
+ return
432
+ }
433
+ if (!authorized(req, url)) {
434
+ authFailures++
435
+ touchDevice(req, { failedAuth: true })
436
+ res.writeHead(401, { 'content-type': 'application/json; charset=utf-8' })
437
+ res.end(JSON.stringify({ error: 'unauthorized' }))
438
+ return
439
+ }
440
+ touchDevice(req)
441
+ const pathname = url.pathname
442
+
443
+ if (pathname === '/stats/summary' && req.method === 'GET') {
444
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
445
+ res.end(JSON.stringify({ ok: true, days: statsStore.summary(url.searchParams.get('days')) }))
446
+ return
447
+ }
448
+
449
+ if (pathname === '/stats/detail' && req.method === 'GET') {
450
+ const date = url.searchParams.get('date') || statsCore.beijingDate(Date.now())
451
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
452
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
453
+ res.end(JSON.stringify({ error: 'invalid date', expect: 'YYYY-MM-DD' }))
454
+ return
455
+ }
456
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
457
+ res.end(JSON.stringify({ ok: true, ...statsStore.detail(date) }))
458
+ return
459
+ }
460
+
461
+ if (pathname === '/stats/ingest' && req.method === 'POST') {
462
+ let body = ''
463
+ req.on('data', c => { body += c; if (body.length > 256 * 1024) req.destroy() })
464
+ req.on('end', () => {
465
+ let payload
466
+ try {
467
+ payload = JSON.parse(body || '{}')
468
+ } catch {
469
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
470
+ res.end(JSON.stringify({ error: 'invalid json' }))
471
+ return
472
+ }
473
+ const sessionId = payload.sessionId
474
+ const event = payload.event
475
+ if (!sessionId || !event || typeof event !== 'object') {
476
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
477
+ res.end(JSON.stringify({ error: 'sessionId 与 event 必填' }))
478
+ return
479
+ }
480
+ statsStore.ingestEvent(sessionId, event, payload.fallbackModel).then((out) => {
481
+ if (out.gap) scanStatsOnce(3000)
482
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
483
+ res.end(JSON.stringify({ ok: true, ...out }))
484
+ }).catch((err) => {
485
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
486
+ res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
487
+ })
488
+ })
489
+ return
490
+ }
491
+
492
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
493
+ res.end(JSON.stringify({ error: 'not found' }))
494
+ }
495
+
396
496
  // ---------- 静态文件 ----------
397
497
  function serveStatic(req, res, url) {
398
498
  if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -1328,6 +1428,7 @@ const server = http.createServer((req, res) => {
1328
1428
  const url = new URL(req.url, 'http://dsh-remote.local')
1329
1429
  if (url.pathname === '/fs' || url.pathname.startsWith('/fs/')) return serveFs(req, res, url)
1330
1430
  if (url.pathname.startsWith('/admin/api')) return serveAdminApi(req, res, url)
1431
+ if (url.pathname.startsWith('/stats')) return serveStats(req, res, url)
1331
1432
  if (url.pathname.startsWith('/api/')) return proxyApi(req, res, url)
1332
1433
  if (url.pathname === '/health') return serveHealth(res)
1333
1434
  touchDevice(req)
@@ -1452,4 +1553,7 @@ server.listen(PORT, HOST, () => {
1452
1553
  // 启动 8 秒后首查, 之后每 6 小时查一次 GitHub/镜像最新版
1453
1554
  setTimeout(() => checkForUpdates(false), 8000)
1454
1555
  setInterval(() => checkForUpdates(false), UPDATE_INTERVAL_MS)
1556
+ // 统计回填: 启动 2 秒后全量扫描一次, 之后每 5 分钟增量扫描(seq 游标保证幂等)
1557
+ scanStatsOnce(2000)
1558
+ setInterval(() => scanStatsOnce(0), 5 * 60 * 1000)
1455
1559
  })
package/index.mjs CHANGED
@@ -238,6 +238,52 @@ async function stopGateway() {
238
238
  }
239
239
  }
240
240
 
241
+ // ---------- 统计事件投递(实时 assistant/message + usage -> 网关 /stats/ingest) ----------
242
+ // 网关未运行时静默失败: 网关启动后会按 seq 游标扫描 session.jsonl.zstd 补齐。
243
+ const statsQueues = new Map() // sessionId -> Promise 串行队列(保证 seq 顺序)
244
+
245
+ function statsSend(session, event) {
246
+ if (event.type !== 'assistant/message') return
247
+ const usage = event.data?.usage
248
+ if (!usage || typeof usage !== 'object') return
249
+ const token = gatewayToken()
250
+ if (!token) return
251
+ let fallbackModel = ''
252
+ try { fallbackModel = session.requestContext?.()?.model || '' } catch {}
253
+ const payload = {
254
+ sessionId: session.id,
255
+ fallbackModel,
256
+ event: {
257
+ type: 'assistant/message',
258
+ seq: event.seq,
259
+ time: event.time,
260
+ data: {
261
+ usage: {
262
+ inputTokens: usage.inputTokens,
263
+ outputTokens: usage.outputTokens,
264
+ cacheReadTokens: usage.cacheReadTokens,
265
+ cacheWriteTokens: usage.cacheWriteTokens,
266
+ },
267
+ message: { source: { model: event.data?.message?.source?.model || '' } },
268
+ },
269
+ },
270
+ }
271
+ const prev = statsQueues.get(session.id) || Promise.resolve()
272
+ const next = prev.then(async () => {
273
+ try {
274
+ await fetch(`${GATEWAY_BASE}/stats/ingest`, {
275
+ method: 'POST',
276
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
277
+ body: JSON.stringify(payload),
278
+ signal: AbortSignal.timeout(2000),
279
+ })
280
+ } catch {}
281
+ }).finally(() => {
282
+ if (statsQueues.get(session.id) === next) statsQueues.delete(session.id)
283
+ })
284
+ statsQueues.set(session.id, next)
285
+ }
286
+
241
287
  async function resolveFile(pathname) {
242
288
  let abs = targetPath(pathname)
243
289
  if (abs === null) return null
@@ -284,6 +330,19 @@ async function serveStatic(req, res) {
284
330
  return
285
331
  }
286
332
 
333
+ // 统计面板数据: 代理到本地网关 /stats/*(网关未运行则返回 502)
334
+ if (pathname === `${MOUNT}/admin/api/stats/summary` || pathname === `${MOUNT}/admin/api/stats/detail`) {
335
+ const query = new URL(req.url ?? '/', 'http://x').search
336
+ const sub = pathname.slice(`${MOUNT}/admin/api/stats`.length)
337
+ const proxied = await proxyGateway(`/stats${sub}${query}`, 'GET', '')
338
+ if (proxied !== null) {
339
+ sendJson(res, proxied.status, proxied.json)
340
+ } else {
341
+ sendJson(res, 502, { ok: false, error: '本地网关不可用, Token 统计需要 8787 网关运行' })
342
+ }
343
+ return
344
+ }
345
+
287
346
  // 管理控制台数据: 优先代理本地网关(设备监控/更新检查完整), 网关不可用回退插件状态
288
347
  if (pathname === `${MOUNT}/admin/api/state`) {
289
348
  void ensureGateway() // 自愈: 开关为 on 而网关没起来时, 后台拉起, 下个轮询即可见网关
@@ -389,6 +448,10 @@ export function apply(ctx) {
389
448
  path: MOUNT,
390
449
  handler: serveStatic,
391
450
  }), 'dsh-remote: /remote route')
451
+ // 实时统计: 监听 DSH 会话事件流, 把带 usage 的 assistant/message 投递到网关聚合
452
+ ctx.on('session/event', (session, event) => {
453
+ statsSend(session, event)
454
+ })
392
455
  // DSH 启动/重启后自愈: 用户没关过网关就自动拉起(默认开, DSH_REMOTE_AUTOSTART=0 关闭)
393
456
  void ensureGateway()
394
457
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-plugin",
3
- "version": "0.5.6-rc.1",
3
+ "version": "0.5.7-rc.1",
4
4
  "description": "DSH Remote 官方 bundle 插件:DSH 左侧原生边栏入口 + 右侧抽屉内嵌管理控制台;内置网关随 DSH 自动启停(systemd 独立单元),抽屉直显令牌与设备监控;网关提供 /fs/* 文件传输端点(列表/断点下载/上传),配合 Android App 远程操控会话/审批/提问/goal 与文件互传(多服务器测速切换、聊天记录离线缓存)。",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -14,9 +14,11 @@
14
14
  "index.mjs",
15
15
  "client.js",
16
16
  "gateway.cjs",
17
+ "gateway-stats.cjs",
17
18
  "public",
18
19
  "apk",
19
- "cordis.patch.yml"
20
+ "cordis.patch.yml",
21
+ "*.md"
20
22
  ],
21
23
  "keywords": [
22
24
  "dsh-plugin",
package/public/admin.html CHANGED
@@ -18,6 +18,17 @@
18
18
  .stat-card .v { font-size: 20px; font-weight: 700; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
19
19
  .stat-card .k { font-size: 11px; color: var(--dsr-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
20
20
  .stat-card.ok .v { color: var(--dsr-success); } .stat-card.warn .v { color: var(--dsr-warning); }
21
+ .stats-dash { margin-bottom: 14px; }
22
+ .stats-dash .stat-grid { margin-bottom: 8px; }
23
+ .stats-chart { display: flex; align-items: flex-end; gap: 8px; height: 150px; padding: 12px 14px 10px; background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; overflow-y: hidden; }
24
+ .stats-bar { flex: 1; min-width: 38px; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 5px; }
25
+ .stats-bar .bars { width: 100%; max-width: 46px; height: 100%; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 6px 6px 0 0; overflow: hidden; background: var(--dsr-accent-soft); }
26
+ .stats-bar .seg { width: 100%; }
27
+ .stats-bar .seg.peak { background: var(--dsr-accent-strong); }
28
+ .stats-bar .seg.off { background: var(--dsr-accent-2); }
29
+ .stats-bar .lbl { font-size: 10px; color: var(--dsr-muted); white-space: nowrap; }
30
+ .stats-bar .val { font-size: 10px; color: var(--dsr-text); white-space: nowrap; }
31
+ .stats-empty { color: var(--dsr-muted); text-align: center; padding: 20px 0; font-size: 12px; }
21
32
  .table-wrap { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius); overflow-x: auto; }
22
33
  table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 0; }
23
34
  th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--dsr-line); vertical-align: top; }
@@ -144,6 +155,12 @@
144
155
 
145
156
  <div class="stat-grid" id="stats"></div>
146
157
 
158
+ <div class="stats-dash">
159
+ <div class="section-head"><span data-i18n="stats.title">Token 统计</span><span id="stats-sub" class="muted"></span></div>
160
+ <div class="stat-grid" id="stats-cards"></div>
161
+ <div id="stats-chart" class="stats-chart"></div>
162
+ </div>
163
+
147
164
  <div class="section-head"><span data-i18n="devices">已连接设备</span><span id="device-summary" class="muted"></span></div>
148
165
  <div class="table-wrap">
149
166
  <table>
@@ -209,6 +226,17 @@
209
226
  'stat.reachable': '可达', 'stat.unreachable': '不可达', 'stat.dshUpstream': 'DSH 上游 {url}',
210
227
  'stat.devicesOnline': '设备在线 / 累计', 'stat.totalRequests': '总请求数', 'stat.authFailures': '认证失败',
211
228
  'stat.uptime': '运行时长 · {host}:{port}',
229
+ 'stats.title': 'Token 统计',
230
+ 'stats.todayTokens': '今日 Token',
231
+ 'stats.todayCost': '今日费用',
232
+ 'stats.peakShare': '高峰占比',
233
+ 'stats.input': '未缓存输入',
234
+ 'stats.cacheRead': '缓存命中',
235
+ 'stats.cacheWrite': '缓存写入',
236
+ 'stats.output': '输出',
237
+ 'stats.peak': '高峰', 'stats.off': '空闲',
238
+ 'stats.days': '近 {n} 天',
239
+ 'stats.gatewayDown': '统计需要 8787 网关运行', 'stats.empty': '暂无统计,产生会话后自动聚合',
212
240
  'unit.sec': ' 秒', 'unit.min': ' 分钟', 'unit.hour': ' 小时 ', 'unit.minShort': ' 分', 'unit.day': ' 天 ',
213
241
  'device.installedNotRunning': '网关已安装 · 当前未运行', 'device.noGatewayBinary': '未检测到网关程序',
214
242
  'device.ipRefresh': '{n} 个 IP · 每 5 秒刷新',
@@ -269,6 +297,17 @@
269
297
  'stat.reachable': 'Reachable', 'stat.unreachable': 'Unreachable', 'stat.dshUpstream': 'DSH upstream {url}',
270
298
  'stat.devicesOnline': 'Devices online / total', 'stat.totalRequests': 'Total requests', 'stat.authFailures': 'Auth failures',
271
299
  'stat.uptime': 'Uptime · {host}:{port}',
300
+ 'stats.title': 'Token stats',
301
+ 'stats.todayTokens': 'Tokens today',
302
+ 'stats.todayCost': 'Cost today',
303
+ 'stats.peakShare': 'Peak share',
304
+ 'stats.input': 'Uncached input',
305
+ 'stats.cacheRead': 'Cache read',
306
+ 'stats.cacheWrite': 'Cache write',
307
+ 'stats.output': 'Output',
308
+ 'stats.peak': 'Peak', 'stats.off': 'Off-peak',
309
+ 'stats.days': 'Last {n} days',
310
+ 'stats.gatewayDown': 'Stats require the gateway on 8787', 'stats.empty': 'No stats yet — they aggregate as sessions happen',
272
311
  'unit.sec': 's', 'unit.min': 'min', 'unit.hour': 'h ', 'unit.minShort': 'm', 'unit.day': 'd ',
273
312
  'device.installedNotRunning': 'Gateway installed · not running', 'device.noGatewayBinary': 'Gateway binary not found',
274
313
  'device.ipRefresh': '{n} IPs · refreshed every 5s',
package/public/admin.js CHANGED
@@ -25,6 +25,81 @@ let shownToken = token
25
25
  let lastState = null
26
26
  let qrShown = false
27
27
 
28
+ const STATS_API = pluginMode ? API + '/stats' : '/stats'
29
+ let statsTimer = null
30
+
31
+ function fmtTokens(n) {
32
+ n = Number(n) || 0
33
+ if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + 'M'
34
+ if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + 'K'
35
+ return String(Math.round(n))
36
+ }
37
+
38
+ function fmtCost(n) {
39
+ return '¥' + (Number(n) || 0).toFixed(2)
40
+ }
41
+
42
+ function bucketTokens(b) {
43
+ return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0)
44
+ }
45
+
46
+ async function loadStats() {
47
+ if (!token && !pluginMode) return
48
+ try {
49
+ const res = await fetch(`${STATS_API}/summary?days=7`, {
50
+ headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
51
+ })
52
+ if (!res.ok) {
53
+ if (res.status === 401) return
54
+ throw new Error('HTTP ' + res.status)
55
+ }
56
+ const json = await res.json()
57
+ renderStats(json.days || [])
58
+ } catch (e) {
59
+ $('stats-cards').innerHTML = ''
60
+ $('stats-chart').innerHTML = `<div class="stats-empty">${t('stats.gatewayDown')}</div>`
61
+ $('stats-sub').textContent = ''
62
+ }
63
+ }
64
+
65
+ function renderStats(days) {
66
+ if (!days.length) {
67
+ $('stats-cards').innerHTML = ''
68
+ $('stats-chart').innerHTML = `<div class="stats-empty">${t('stats.empty')}</div>`
69
+ $('stats-sub').textContent = ''
70
+ return
71
+ }
72
+ const today = days[days.length - 1]
73
+ const totalTokens = bucketTokens(today.total)
74
+ const peakCost = today.peak.cost || 0
75
+ const offCost = today.off.cost || 0
76
+ const totalCost = peakCost + offCost
77
+ const peakShare = totalCost > 0 ? Math.round(peakCost / totalCost * 100) : 0
78
+ $('stats-cards').innerHTML = `
79
+ <div class="stat-card"><div class="v">${fmtTokens(totalTokens)}</div><div class="k">${t('stats.todayTokens')} · ${t('stats.input')} ${fmtTokens(today.total.input)} · ${t('stats.cacheRead')} ${fmtTokens(today.total.cacheRead)} · ${t('stats.cacheWrite')} ${fmtTokens(today.total.cacheWrite)} · ${t('stats.output')} ${fmtTokens(today.total.output)}</div></div>
80
+ <div class="stat-card"><div class="v">${fmtCost(totalCost)}</div><div class="k">${t('stats.todayCost')} · ${t('stats.peak')} ${fmtCost(peakCost)} / ${t('stats.off')} ${fmtCost(offCost)}</div></div>
81
+ <div class="stat-card ${peakShare >= 50 ? 'warn' : 'ok'}"><div class="v">${peakShare}%</div><div class="k">${t('stats.peakShare')} · ${t('stats.days', { n: days.length })}</div></div>`
82
+ $('stats-sub').textContent = today.date
83
+
84
+ // 近 7 日柱状图: 每根按费用堆叠高峰/空闲, 高度按费用相对比例
85
+ const maxCost = Math.max(...days.map(d => (d.total.cost || 0)), 0.0001)
86
+ $('stats-chart').innerHTML = days.map(d => {
87
+ const cost = d.total.cost || 0
88
+ const peakH = Math.max(2, Math.round((d.peak.cost || 0) / maxCost * 100))
89
+ const offH = Math.max(0, Math.round((d.off.cost || 0) / maxCost * 100))
90
+ const total = peakH + offH
91
+ const label = d.date.slice(5)
92
+ return `<div class="stats-bar" title="${d.date} · ${t('stats.peak')} ${fmtCost(d.peak.cost)} · ${t('stats.off')} ${fmtCost(d.off.cost)} · tokens ${fmtTokens(bucketTokens(d.total))}">
93
+ <div class="bars" style="height:${Math.max(total, 2)}%">
94
+ <div class="seg peak" style="height:${peakH}%"></div>
95
+ <div class="seg off" style="height:${offH}%"></div>
96
+ </div>
97
+ <div class="val">${cost > 0 ? fmtCost(cost) : ''}</div>
98
+ <div class="lbl">${label}</div>
99
+ </div>`
100
+ }).join('')
101
+ }
102
+
28
103
  function toast(text, kind = '') {
29
104
  const el = $('toast')
30
105
  el.textContent = text
@@ -227,7 +302,10 @@ function enter() {
227
302
  history.replaceState(null, '', location.pathname)
228
303
  showMain()
229
304
  loadState()
305
+ loadStats()
230
306
  timer = setInterval(loadState, 5000)
307
+ if (statsTimer) clearInterval(statsTimer)
308
+ statsTimer = setInterval(loadStats, 30000)
231
309
  }
232
310
 
233
311
  function showMain() {
@@ -239,6 +317,8 @@ function logout() {
239
317
  token = ''
240
318
  store.del('dshAdminToken')
241
319
  clearInterval(timer)
320
+ if (statsTimer) clearInterval(statsTimer)
321
+ statsTimer = null
242
322
  $('main-view').classList.add('hidden')
243
323
  $('login-view').classList.remove('hidden')
244
324
  $('conn-badge').textContent = t('unauth')
@@ -394,7 +474,10 @@ function start(showLogin) {
394
474
  }
395
475
  showMain()
396
476
  loadState()
477
+ loadStats()
397
478
  timer = setInterval(loadState, 5000)
479
+ if (statsTimer) clearInterval(statsTimer)
480
+ statsTimer = setInterval(loadStats, 30000)
398
481
  }
399
482
 
400
483
  if (pluginMode) {
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.5.6-rc.1",
2
+ "version": "0.5.7-rc.1",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "releasedAt": "2026-08-16T15:29:43.309Z",
5
- "notes": "修复:APK 内置版本文件滞后导致“已装新版本仍提示更新”;检查更新弹窗只显示版本号,完整说明在设置页展开查看"
4
+ "releasedAt": "2026-08-17T05:34:41.185Z",
5
+ "notes": "新增:Token 统计 v1 — 管理页展示今日四桶/费用/高峰占比与近 7 日柱状图,按北京时间高峰计费;历史会话自动回填。"
6
6
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.5.6-rc.1"
2
+ "version": "0.5.7-rc.1"
3
3
  }