dsh-mini-remote 0.1.4 → 0.1.6
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/CHANGELOG.md +41 -0
- package/README.en.md +83 -33
- package/README.md +91 -34
- package/lib/art/out-4legs-fix.gif +0 -0
- package/lib/art/out-anim-all.gif +0 -0
- package/lib/art/out-anim-carousel.gif +0 -0
- package/lib/art/out-anim-run-now-real.gif +0 -0
- package/lib/art/out-anim-run-real.gif +0 -0
- package/lib/art/out-anim-run-slow.gif +0 -0
- package/lib/art/out-anim-run.gif +0 -0
- package/lib/art/out-anim-sprint-real.gif +0 -0
- package/lib/art/out-anim-sprint-slow.gif +0 -0
- package/lib/art/out-anim-sprint.gif +0 -0
- package/lib/art/out-sway-sprint.gif +0 -0
- package/lib/art/work-1-ready.webp +0 -0
- package/lib/art/work-2-reading.webp +0 -0
- package/lib/art/work-3-typing.webp +0 -0
- package/lib/art/work-4-checking.webp +0 -0
- package/lib/art/work-5-thinking.webp +0 -0
- package/lib/art/work-6-running.webp +0 -0
- package/lib/art/work-7-waiting.webp +0 -0
- package/lib/art/work-8-sprinting.webp +0 -0
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/build.js +14 -5
- package/lib/index.js +327 -39
- package/lib/log-tail.js +5 -1
- package/lib/page.html +775 -126
- package/lib/page.js +43 -2
- package/lib/server.js +159 -8
- package/lib/title-cache.js +122 -0
- package/lib/tree.js +222 -49
- package/package.json +3 -3
package/lib/page.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* 页面之所以放独立 .html 而不是塞进 JS 模板字符串:省掉满屏的反引号和 ${} 转义,
|
|
5
5
|
* 编辑器也能正常高亮。对外仍然是一个文件。
|
|
6
6
|
*/
|
|
7
|
-
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
8
8
|
import { fileURLToPath } from 'node:url'
|
|
9
|
-
import { dirname, join } from 'node:path'
|
|
9
|
+
import { dirname, extname, isAbsolute, join } from 'node:path'
|
|
10
10
|
|
|
11
11
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
12
12
|
const PAGE_PATH = join(HERE, 'page.html')
|
|
13
13
|
const ART_DIR = join(HERE, 'art')
|
|
14
|
+
// 插件就在仓库里,`lib/` 的上一级即项目根——正文里写相对路径(如 dsh-image-gen/a.png)时按它找。
|
|
15
|
+
const PROJECT_ROOT = join(HERE, '..')
|
|
14
16
|
|
|
15
17
|
let template = null
|
|
16
18
|
|
|
@@ -45,6 +47,45 @@ export function readArt(name) {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** 这条通道只发图片:扩展名决定 Content-Type,也决定放不放行。别的类型一概不发。 */
|
|
51
|
+
const IMAGE_TYPES = {
|
|
52
|
+
'.webp': 'image/webp',
|
|
53
|
+
'.png': 'image/png',
|
|
54
|
+
'.jpg': 'image/jpeg',
|
|
55
|
+
'.jpeg': 'image/jpeg',
|
|
56
|
+
'.gif': 'image/gif',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const IMAGE_MAX = 20 * 1024 * 1024
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 读正文里直接写了一张图的位置时要发的那张图(`` 这种)。
|
|
63
|
+
*
|
|
64
|
+
* 和立绘不一样,这条路上**没有文件名白名单**——正文里写的可以是电脑上任何一张图。
|
|
65
|
+
* 卡的是两点:**只发图片类型**(不然这条通道就成了读文件的口子),以及**体积**。
|
|
66
|
+
* 路径原样按系统解析;相对路径先试项目根、再试 lib/art,都没有就当读不到。
|
|
67
|
+
*
|
|
68
|
+
* 2026-09-28 之前这里只认"把图拷进 lib/art 并写裸名字"一种写法,正文里写全路径就成了
|
|
69
|
+
* 破图标——用户第三次为此来问,这是那时的放宽。
|
|
70
|
+
*/
|
|
71
|
+
export function readImage(raw) {
|
|
72
|
+
if (typeof raw !== 'string') return null
|
|
73
|
+
const p = raw.trim()
|
|
74
|
+
if (!p || p.length > 4096 || p.includes('\0')) return null
|
|
75
|
+
const type = IMAGE_TYPES[extname(p).toLowerCase()]
|
|
76
|
+
if (!type) return null
|
|
77
|
+
const candidates = isAbsolute(p) ? [p] : [p, join(PROJECT_ROOT, p), join(ART_DIR, p)]
|
|
78
|
+
for (const file of candidates) {
|
|
79
|
+
try {
|
|
80
|
+
if (statSync(file).size > IMAGE_MAX) continue
|
|
81
|
+
return { buf: readFileSync(file), type }
|
|
82
|
+
} catch {
|
|
83
|
+
// 这个位置没有,试下一个。
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
const SW_PATH = join(HERE, 'sw.js')
|
|
49
90
|
|
|
50
91
|
let sw = null
|
package/lib/server.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createServer } from 'node:http'
|
|
15
15
|
import { timingSafeEqual } from 'node:crypto'
|
|
16
|
-
import { renderPage, readArt, readSw } from './page.js'
|
|
16
|
+
import { renderPage, readArt, readImage, readSw } from './page.js'
|
|
17
17
|
|
|
18
18
|
const COOKIE = 'dsh_mini_token'
|
|
19
19
|
|
|
@@ -218,9 +218,23 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
218
218
|
const waitingQuestions = new Map()
|
|
219
219
|
let questionSeq = 0
|
|
220
220
|
|
|
221
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* 手机上有没有人在看。**这就是「手机还在不在」那条判据**,不倒计时。
|
|
223
|
+
*
|
|
224
|
+
* **不能只看此刻连着几个。** 手机这一端会来回断(锁屏、切网、页面重连),
|
|
225
|
+
* 2026-09-27 的日志里一分钟能连断几十次;审批恰好落在断开的那一瞬,就被判成
|
|
226
|
+
* "手机不在",于是让给了电脑——用户看到的就是"手机上什么都没弹"(他报的正是这个)。
|
|
227
|
+
* 所以判据改成"此刻连着,**或者最近这段时间露过面**",让抖动不再决定归属。
|
|
228
|
+
* 露面有两个信号:SSE 连着(clients),或者最近来过一次带凭据的请求(notePhoneSeen)。
|
|
229
|
+
* 窗口给 20 秒:够跨过抖动,又不至于让"真的锁屏很久"的手机一直霸着审批。
|
|
230
|
+
*/
|
|
231
|
+
const PHONE_GRACE_MS = 20000
|
|
232
|
+
let phoneSeenAt = 0
|
|
233
|
+
function notePhoneSeen() {
|
|
234
|
+
phoneSeenAt = Date.now()
|
|
235
|
+
}
|
|
222
236
|
function hasPhone() {
|
|
223
|
-
return clients.size > 0
|
|
237
|
+
return clients.size > 0 || (phoneSeenAt > 0 && Date.now() - phoneSeenAt < PHONE_GRACE_MS)
|
|
224
238
|
}
|
|
225
239
|
|
|
226
240
|
/**
|
|
@@ -243,7 +257,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
243
257
|
// 用户选的是「一直等,不还回电脑」——但那不等于「手机都没了还等」。
|
|
244
258
|
// 手机一旦全断开(锁屏、关了页面、网断),立刻收摊还给电脑,否则会永远卡住。
|
|
245
259
|
watch = setInterval(() => {
|
|
246
|
-
if (
|
|
260
|
+
if (!hasPhone()) settle(null)
|
|
247
261
|
}, 2000)
|
|
248
262
|
})
|
|
249
263
|
}
|
|
@@ -269,18 +283,90 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
269
283
|
}
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
// ---- 手机上来拍板审批 --------------------------------------------------
|
|
287
|
+
//
|
|
288
|
+
// 和上面那一套是**同一个形状**(推一次、等、手机没了就还给电脑),所以刻意并排写、
|
|
289
|
+
// 守同样的规矩:清单和函数都留在这一层,不写进下面"每来一个请求执行一次"的地方
|
|
290
|
+
// (那种错 2026-09-27 犯过一次,见上面那段注释)。
|
|
291
|
+
// 唯一不同的是交回来的东西:提问交的是答案,审批交的是**结论**。
|
|
292
|
+
const waitingApprovals = new Map()
|
|
293
|
+
let approvalSeq = 0
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 把一条命令确认推到手机上,等它拍板。
|
|
297
|
+
* @returns 'allowed-once' 或 'rejected';手机没了返回 null,调用方据此还给电脑。
|
|
298
|
+
*/
|
|
299
|
+
function askApproval(req, agentId, command) {
|
|
300
|
+
const id = 'a' + (++approvalSeq)
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
let watch = null
|
|
303
|
+
const settle = (value) => {
|
|
304
|
+
if (!waitingApprovals.has(id)) return
|
|
305
|
+
waitingApprovals.delete(id)
|
|
306
|
+
if (watch) clearInterval(watch)
|
|
307
|
+
resolve(value)
|
|
308
|
+
}
|
|
309
|
+
// 推给手机的就是这几样:工具名 + 为什么来问我。**不带命令正文**——
|
|
310
|
+
// 正文要另外去取,而且那是用户自己的东西,不进日志也不进这条帧。
|
|
311
|
+
const view = {
|
|
312
|
+
id,
|
|
313
|
+
agentId,
|
|
314
|
+
toolName: typeof req?.toolName === 'string' && req.toolName ? req.toolName : '某个工具',
|
|
315
|
+
reason: typeof req?.reason === 'string' ? req.reason : '',
|
|
316
|
+
// 命令原文(取不到就是空串)。**只发给手机,不进日志。**
|
|
317
|
+
command: typeof command === 'string' ? command : '',
|
|
318
|
+
}
|
|
319
|
+
waitingApprovals.set(id, { settle, agentId, view })
|
|
320
|
+
broadcast('approval', view)
|
|
321
|
+
// 和提问同一条判据:**不倒计时,看手机还在不在**。全断开了就立刻收摊还给电脑。
|
|
322
|
+
watch = setInterval(() => {
|
|
323
|
+
if (!hasPhone()) settle(null)
|
|
324
|
+
}, 2000)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 把还在等的审批补发给**刚连上来的那一个**连接(同 replayQuestions 的理由)。 */
|
|
329
|
+
function replayApprovals(res) {
|
|
330
|
+
for (const [, entry] of waitingApprovals) {
|
|
331
|
+
try {
|
|
332
|
+
res.write(`event: approval\ndata: ${JSON.stringify(entry.view)}\n\n`)
|
|
333
|
+
} catch {
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
272
338
|
// 把这两个能力放到插件够得着的固定位置(见文件上方 `miniControl` 的说明)。
|
|
273
339
|
// 放在这里是因为它俩都已定义(函数声明本来也会提升),且不受下面那堆路由逻辑影响。
|
|
274
340
|
miniControl.hasPhone = hasPhone
|
|
275
341
|
miniControl.askPhone = askPhone
|
|
342
|
+
miniControl.askApproval = askApproval
|
|
276
343
|
|
|
344
|
+
/**
|
|
345
|
+
* 把 UA 和来源地址压成一句人看的:设备类型 + 地址。
|
|
346
|
+
*
|
|
347
|
+
* **为什么要它**:SSE 那两行日志原来只说"连上了/断开了",一分钟几十次成对出现时,
|
|
348
|
+
* 分不清是手机在抖、还是电脑上另有一个 /mini 页面也在连(两个都开着就会这样)。
|
|
349
|
+
* 只取设备类型和地址,**不记 token,也不记整个 UA**——够定位就行,多余的不留。
|
|
350
|
+
*/
|
|
351
|
+
function describeClient(ua, ip) {
|
|
352
|
+
const u = String(ua ?? '')
|
|
353
|
+
const kind = /iPhone|iPad/i.test(u) ? 'iOS'
|
|
354
|
+
: /Android/i.test(u) ? 'Android'
|
|
355
|
+
: /Windows/i.test(u) ? 'Windows'
|
|
356
|
+
: (u ? '其他' : '无 UA')
|
|
357
|
+
return kind + (ip ? '@' + String(ip).replace('::ffff:', '') : '')
|
|
358
|
+
}
|
|
277
359
|
function isAuthorized(req, url) {
|
|
278
360
|
const fromQuery = url.searchParams.get('token')
|
|
279
361
|
const fromCookie = parseCookies(req.headers.cookie)[COOKIE]
|
|
280
362
|
const fromHeader = req.headers['x-mini-token']
|
|
281
|
-
|
|
363
|
+
const ok = tokenEquals(fromQuery, token)
|
|
282
364
|
|| tokenEquals(fromCookie, token)
|
|
283
365
|
|| tokenEquals(fromHeader, token)
|
|
366
|
+
// 手机每来一次请求都算"露过面"——这是「手机还在不在」的第二个信号。
|
|
367
|
+
// 只看 SSE 连接数会被抖动骗到(见 hasPhone 上面那段),请求时间戳稳得多。
|
|
368
|
+
if (ok) notePhoneSeen()
|
|
369
|
+
return ok
|
|
284
370
|
}
|
|
285
371
|
|
|
286
372
|
/** 这次请求到底有没有带凭据。没带只是没登录,不算试错。 */
|
|
@@ -379,6 +465,25 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
379
465
|
return
|
|
380
466
|
}
|
|
381
467
|
noteSuccess(req)
|
|
468
|
+
// 取图有两条路,都挂在这个前缀下:
|
|
469
|
+
// ① /mini/art/<名字>.webp —— 立绘。名字受白名单限制,地址里带构建指纹,可以长缓存。
|
|
470
|
+
// ② /mini/art/?p=<路径> —— 正文里直接写了一张图在电脑上的位置(绝对路径,或相对
|
|
471
|
+
// 项目根)。只发图片类型,地址里没有指纹,所以不让浏览器缓存。
|
|
472
|
+
const wanted = url.searchParams.get('p')
|
|
473
|
+
if (wanted) {
|
|
474
|
+
const hit = readImage(wanted)
|
|
475
|
+
if (!hit) {
|
|
476
|
+
sendJson(res, 404, { error: 'not found' })
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
res.writeHead(200, {
|
|
480
|
+
'Content-Type': hit.type,
|
|
481
|
+
'Content-Length': hit.buf.length,
|
|
482
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
483
|
+
})
|
|
484
|
+
res.end(hit.buf)
|
|
485
|
+
return
|
|
486
|
+
}
|
|
382
487
|
const buf = readArt(path.slice('/mini/art/'.length))
|
|
383
488
|
if (!buf) {
|
|
384
489
|
sendJson(res, 404, { error: 'not found' })
|
|
@@ -451,7 +556,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
451
556
|
clients.add(res)
|
|
452
557
|
// 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
|
|
453
558
|
// 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
|
|
454
|
-
|
|
559
|
+
// 还要看**是谁**连的、**活了多久**:2026-09-27 日志里一分钟几十次成对出现,
|
|
560
|
+
// 当时分不清是手机自己抖,还是电脑上另开着一个 /mini 页面在连。
|
|
561
|
+
const who = describeClient(req.headers['user-agent'], req.socket?.remoteAddress)
|
|
562
|
+
const openedAt = Date.now()
|
|
563
|
+
console.log(`[dsh-mini-remote] 手机连上了,现在连着 ${clients.size} 台(${who})`)
|
|
455
564
|
|
|
456
565
|
const beat = setInterval(() => {
|
|
457
566
|
try {
|
|
@@ -468,6 +577,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
468
577
|
|
|
469
578
|
// 接着把还在等手机回答的题补给它——重连上来的那一下最容易错过(见 replayQuestions)。
|
|
470
579
|
replayQuestions(res)
|
|
580
|
+
replayApprovals(res)
|
|
471
581
|
|
|
472
582
|
// 顺带把这个会话的完整历史准备好:手机上「刷新一下就只剩重启之后的事」
|
|
473
583
|
// 就是这么来的(手里那份只有插件在场时看见的)。读完会再广播一份快照。
|
|
@@ -480,7 +590,8 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
480
590
|
said = true
|
|
481
591
|
clearInterval(beat)
|
|
482
592
|
clients.delete(res)
|
|
483
|
-
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`
|
|
593
|
+
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`
|
|
594
|
+
+ `(${who},活了 ${Date.now() - openedAt} ms)`)
|
|
484
595
|
}
|
|
485
596
|
req.on('close', cleanup)
|
|
486
597
|
res.on('close', cleanup)
|
|
@@ -643,6 +754,17 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
643
754
|
// 现在问不到就如实给 null,怎么讲由界面决定,反正不编。
|
|
644
755
|
const asked = nav.sessionModel ? await nav.sessionModel(boundSessionId) : { ok: false }
|
|
645
756
|
const remembered = store.snapshot().sessions?.find((s) => s.id === boundSessionId) ?? null
|
|
757
|
+
/**
|
|
758
|
+
* 手机上要看「上下文用了多少」,这份是分子分母凑齐的一份。
|
|
759
|
+
*
|
|
760
|
+
* 第一来源是内存里那份:会话跑起来时每一步的 usage 都会刷新它,最鲜活。
|
|
761
|
+
* 内存里没有才问日志(`nav.sessionContext`)——**这一条是必须的**:窗口那条
|
|
762
|
+
* 事件一个会话只写一次,插件晚一步起来(重启过、或早就开着的会话)就永远
|
|
763
|
+
* 等不到它,光靠内存会一直空着,用户看到的就是「这个功能没了」
|
|
764
|
+
* (2026-09-28 用户报的)。日志里那份同样是 DSH 记下的事实,不是估的。
|
|
765
|
+
*/
|
|
766
|
+
const context = remembered?.context
|
|
767
|
+
?? (nav.sessionContext ? await nav.sessionContext(boundSessionId) : null)
|
|
646
768
|
const current = asked.ok
|
|
647
769
|
? asked.model
|
|
648
770
|
: (remembered && remembered.model
|
|
@@ -652,7 +774,14 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
652
774
|
reasoningEffort: remembered.reasoningEffort ?? null,
|
|
653
775
|
}
|
|
654
776
|
: null)
|
|
655
|
-
sendJson(res, 200, {
|
|
777
|
+
sendJson(res, 200, {
|
|
778
|
+
ok: true,
|
|
779
|
+
boundSessionId,
|
|
780
|
+
current,
|
|
781
|
+
// { used, window } 或 null。缺任一半就是 null,界面据此整段不显示——不编、不放占位符。
|
|
782
|
+
context,
|
|
783
|
+
...result.catalog,
|
|
784
|
+
})
|
|
656
785
|
return
|
|
657
786
|
}
|
|
658
787
|
|
|
@@ -677,6 +806,28 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
677
806
|
return
|
|
678
807
|
}
|
|
679
808
|
|
|
809
|
+
// ---- 手机上来拍板审批 ------------------------------------------------
|
|
810
|
+
// 交回来的东西和上面的答案不同:这里只要一个结论。措辞照 /mini/api/answer 那一支——
|
|
811
|
+
// 过期了如实说、格式不对就说格式不对,别装作收下了。
|
|
812
|
+
if (req.method === 'POST' && path === '/mini/api/approval') {
|
|
813
|
+
const body = await readBody(req)
|
|
814
|
+
const waiting = waitingApprovals.get(body?.id)
|
|
815
|
+
if (!waiting) {
|
|
816
|
+
console.log('[dsh-mini-remote] 手机上拍晚了:这条审批已经交回电脑(' + String(body?.id) + '),没接')
|
|
817
|
+
sendJson(res, 409, { ok: false, error: '这条审批已经结束了。' })
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
const decision = body?.decision === 'allow'
|
|
821
|
+
? 'allowed-once'
|
|
822
|
+
: (body?.decision === 'deny' ? 'rejected' : null)
|
|
823
|
+
if (!decision) {
|
|
824
|
+
sendJson(res, 400, { ok: false, error: '审批的结论只有同意或拒绝两种。' })
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
waiting.settle(decision)
|
|
828
|
+
sendJson(res, 200, { ok: true })
|
|
829
|
+
return
|
|
830
|
+
}
|
|
680
831
|
// ---- 目录浏览(挑工作区用)------------------------------------------
|
|
681
832
|
// 只列目录,绝不列文件、绝不读内容。手机上要的是「挑一个文件夹」。
|
|
682
833
|
if (req.method === 'GET' && path === '/mini/api/browse/roots') {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话标题的落盘缓存。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它:一批会话的标题要靠**加载整份会话日志**才折得出来(DSH 的
|
|
5
|
+
* readTitleSnapshots 就是逐个 load),本机 1419 个日志共 1.4 GB 全是 zstd 压缩,
|
|
6
|
+
* 冷读一条实测约 5 秒。lib/tree.js 里那份内存缓存只活到 DSH 这次运行结束——
|
|
7
|
+
* 重启就清零,那 1.4 GB 又得重付一遍。而标题这东西定下来基本不变,读过就该长期记住。
|
|
8
|
+
*
|
|
9
|
+
* 三条边界,一条都不能让插件起不来(这个插件的一贯做法是"少了哪样就关掉哪一块,
|
|
10
|
+
* 不整个倒下"):
|
|
11
|
+
* - 文件不在(第一次跑):当空缓存;
|
|
12
|
+
* - 文件坏了(半个 JSON、手改坏的):当空缓存,下次写盘自然把它盖掉;
|
|
13
|
+
* - 写盘写到一半被打断(断电、两个 DSH 实例并存):先写临时文件再改名,
|
|
14
|
+
* 盘上要么是旧的那一份、要么是新的那一份,**永远不会是半截**。
|
|
15
|
+
*
|
|
16
|
+
* 存的是一个 sessionId → { title, at } 的平表:手机要的就是"这条会话叫什么",
|
|
17
|
+
* 别的字段一概不留(会话本身的事实由 DSH 的清单负责,这里只补齐一个缺口)。
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { dirname } from 'node:path'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 标题记多久。
|
|
24
|
+
*
|
|
25
|
+
* 七天:够跨过绝大多数重启(用户不会天天重启 DSH),又不是"永远"——万一哪条会话
|
|
26
|
+
* 后来真的改了标题、而事件流又没让我们看见,过期之后还有机会从日志里重新读一次。
|
|
27
|
+
* 比内存缓存原来那 10 分钟长得多,因为**内存丢了还能重读,落盘丢了就是白读一遍**。
|
|
28
|
+
*/
|
|
29
|
+
export const TITLE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 盘上最多留多少条。本机 1419 个会话,留一倍余量;一条几十字节,满载也就一百多 KB。
|
|
33
|
+
* 超了按"最久没更新"淘汰——和内存那份同一套规矩,只是盘子更大。
|
|
34
|
+
*/
|
|
35
|
+
const MAX_ENTRIES = 3000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 建一份标题的落盘缓存。读盘是**当场同步做一次**(小文件,不值得为它引入异步),
|
|
39
|
+
* 写盘合并到一次(`debounceMs` 之内反复 set 只写最后那一下)——
|
|
40
|
+
* 展开一个工作区可能一口气补上十几条标题,不合并就是十几次写盘。
|
|
41
|
+
*/
|
|
42
|
+
export function createTitleCache({
|
|
43
|
+
file, ttl = TITLE_TTL_MS, max = MAX_ENTRIES, debounceMs = 500, now = Date.now,
|
|
44
|
+
}) {
|
|
45
|
+
/** sessionId -> { title, at }。顺序 = 最近更新的在后,淘汰时从最早的开始。 */
|
|
46
|
+
const rows = new Map()
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 读盘。**任何异常都当"没有缓存"**:文件不在、不是 JSON、结构对不上、
|
|
50
|
+
* 单条字段坏掉——各自跳过,绝不让调用方拿到一个半死的东西。
|
|
51
|
+
*/
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
54
|
+
if (raw && typeof raw === 'object') {
|
|
55
|
+
const at = now()
|
|
56
|
+
for (const [id, row] of Object.entries(raw)) {
|
|
57
|
+
if (!id || !row || typeof row.title !== 'string') continue
|
|
58
|
+
if (typeof row.at !== 'number' || at - row.at > ttl) continue
|
|
59
|
+
rows.set(id, { title: row.title, at: row.at })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// 第一次跑没有这个文件;损坏了也走这条。两种情况都是"从空缓存重来"。
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let timer = null
|
|
67
|
+
let dirty = false
|
|
68
|
+
|
|
69
|
+
function save() {
|
|
70
|
+
timer = null
|
|
71
|
+
if (!dirty) return
|
|
72
|
+
dirty = false
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
75
|
+
const tmp = `${file}.tmp`
|
|
76
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(rows)))
|
|
77
|
+
// 改名是原子的:中途死掉也只留下一个 .tmp,正主那份还是完整的。
|
|
78
|
+
renameSync(tmp, file)
|
|
79
|
+
} catch {
|
|
80
|
+
// 写不进去只影响"下次重启还得重读一遍",不该影响这一次运行。
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function schedule() {
|
|
85
|
+
dirty = true
|
|
86
|
+
if (timer) return
|
|
87
|
+
timer = setTimeout(save, debounceMs)
|
|
88
|
+
// 这个定时器不该拖着进程不让它退出(DSH 关停时也是)。
|
|
89
|
+
if (timer.unref) timer.unref()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
/** 盘上现有的全部条目(给内存缓存首次灌数据用)。过期的当没记过。 */
|
|
94
|
+
all: () => [...rows].filter(([, row]) => now() - row.at <= ttl),
|
|
95
|
+
|
|
96
|
+
/** 取一条。过期的当场丢掉——"没记过"和"记着是空标题"必须分得出来。 */
|
|
97
|
+
get(id) {
|
|
98
|
+
const row = rows.get(id)
|
|
99
|
+
if (!row) return null
|
|
100
|
+
if (now() - row.at > ttl) {
|
|
101
|
+
rows.delete(id)
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
return row.title
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/** 记一条。空标题也记(会话确实没有标题,没理由每次重读一遍)。 */
|
|
108
|
+
set(id, title) {
|
|
109
|
+
if (!id || typeof title !== 'string') return
|
|
110
|
+
rows.delete(id)
|
|
111
|
+
rows.set(id, { title, at: now() })
|
|
112
|
+
while (rows.size > max) rows.delete(rows.keys().next().value)
|
|
113
|
+
schedule()
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/** 立刻写盘。平时走上面那个合并写;测试和收尾时用这个,省得干等。 */
|
|
117
|
+
flush() {
|
|
118
|
+
if (timer) clearTimeout(timer)
|
|
119
|
+
save()
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|