dsh-mini-remote 0.1.3 → 0.1.5
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 +38 -2
- package/README.en.md +90 -34
- package/README.md +90 -34
- package/client/client.js +5 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/build.js +11 -5
- package/lib/events.js +52 -0
- package/lib/index.js +161 -11
- package/lib/page.html +572 -128
- package/lib/pairing.js +11 -4
- package/lib/serve.js +10 -3
- package/lib/server.js +37 -1
- package/lib/store.js +57 -6
- package/package.json +10 -2
package/lib/pairing.js
CHANGED
|
@@ -16,8 +16,13 @@ const HINTS = {
|
|
|
16
16
|
// 有两条来源,只有 Tailscale 那条依赖手机上的 MagicDNS**——公网隧道用的是
|
|
17
17
|
// Cloudflare 的域名,谁都能解析。所以手机上 Tailscale 的 DNS 设置没开的用户,
|
|
18
18
|
// 想要加密连接应该走这一条,而原来的说明里完全没告诉他这一点。
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
//
|
|
20
|
+
// 2026-09-27 改口径:原来这里写的是「浏览器肯不肯给用通知和麦克风看的就是这个」,
|
|
21
|
+
// 那两样我们**都没有实现**(系统通知 2026-09-25 起入口就是藏着的,麦克风图标也已
|
|
22
|
+
// 移除,两条都有测试钉着"别再被加回来")。拿没实现的功能当理由,是把用户往一个
|
|
23
|
+
// 不存在的东西上引。加密本身成立,就只说加密——**别再改回去**。
|
|
24
|
+
public: '不用装任何东西,出门在外也能用,而且它也是加密的(地址栏上会出现 https 那把锁)。'
|
|
25
|
+
+ '刚打开时等半分钟再扫;地址每次重启 DSH 会换',
|
|
21
26
|
// 这条和上面那条 Tailscale 是**同一台电脑、同一个服务**,区别只在连接加不加密。
|
|
22
27
|
// 说清楚「加密能换来什么」——不然用户看不出两条码为什么要并存。
|
|
23
28
|
//
|
|
@@ -38,8 +43,10 @@ const HINTS = {
|
|
|
38
43
|
// 2026-09-25 用户实测走通了:他手机上装的正是 MeshArc,**换成官方客户端之后
|
|
39
44
|
// 那条加密地址就打开了**。所以这一句要给出真正的解法——换客户端,而不是绕开。
|
|
40
45
|
// 原来只写了「用不了就换别的路」,那是在绕,不是在解决。
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
// 2026-09-27 改口径(原因同上一条 public):不再拿"手机浏览器只在加密连接上才肯给用
|
|
47
|
+
// 通知和麦克风"当理由——那两样都没实现。加密本身是实打实的好处,就只说加密。
|
|
48
|
+
'tailscale-https': '出门在外也能用,而且连接是加密的(地址栏上会出现 https 那把锁)。'
|
|
49
|
+
+ '手机上的 Tailscale 得能解析你这个网络的域名:HarmonyOS 上的第三方'
|
|
43
50
|
+ '客户端(MeshArc)不支持这一点,换成官方客户端就能用。实在换不了,就退到上面那条'
|
|
44
51
|
+ '明文的(它用 IP,不需要解析),或者开公网隧道那条(它也是加密的,不挑手机)',
|
|
45
52
|
}
|
package/lib/serve.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 用 Tailscale 自带的 HTTPS 地址,把本机服务暴露给 tailnet。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 为什么值得做:浏览器只有在安全上下文(https)下才肯把完整能力交出来,明文 http
|
|
5
|
+
* 一律打折——这正是当初做这条路的理由。Tailscale 的 serve 给的是 Let's Encrypt 签的
|
|
6
|
+
* 真证书,浏览器认。
|
|
7
|
+
*
|
|
8
|
+
* **2026-09-27 修正这段自述**:原来这里写的是"三样东西被明文 http 挡着——完成后提醒
|
|
9
|
+
* (系统通知)、语音输入(麦克风)、剪贴板的完整能力"。后来其中两样**都没有真正落地**:
|
|
10
|
+
* 系统通知 2026-09-25 起入口一直藏着(锁屏会冻结后台页面,试到底的结论是这条路走不通),
|
|
11
|
+
* 麦克风图标也已移除,两条都有测试钉着"别再被加回来"。如今真正成立的只有"连接加密"
|
|
12
|
+
* 这一条(剪贴板另有 execCommand 退路,明文下也能用,只是不那么顺)。所以对用户说话时
|
|
13
|
+
* 只讲加密,**别再拿通知和麦克风当理由**(见 lib/pairing.js 里那两条提示的注释)。
|
|
7
14
|
*
|
|
8
15
|
* **这不是新的一条路,是 Tailscale 那条路上的一个升级。** 原来的
|
|
9
16
|
* `http://100.92.105.99:3090` 照旧能用,这里多给一个
|
package/lib/server.js
CHANGED
|
@@ -237,7 +237,8 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
237
237
|
if (watch) clearInterval(watch)
|
|
238
238
|
resolve(value)
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
// 题目本身也存进来(不只是 settle):手机中途重连上来时要能补发给它,见 replayQuestions。
|
|
241
|
+
waitingQuestions.set(id, { settle, agentId, questions })
|
|
241
242
|
broadcast('question', { id, agentId, questions })
|
|
242
243
|
// 用户选的是「一直等,不还回电脑」——但那不等于「手机都没了还等」。
|
|
243
244
|
// 手机一旦全断开(锁屏、关了页面、网断),立刻收摊还给电脑,否则会永远卡住。
|
|
@@ -247,6 +248,27 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
247
248
|
})
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/**
|
|
252
|
+
* 把正在等的题补发给**刚连上来的那一个**连接。
|
|
253
|
+
*
|
|
254
|
+
* 题目只在上面推一次,没有第二次。可手机这一端并不总是接着:
|
|
255
|
+
* 页面正在重连、锁屏醒来刚接上、切了个网络——那一下就错过了,而电脑那边还在等,
|
|
256
|
+
* 手机上却什么都看不见(2026-09-27 用户报的「电脑弹了选项、手机上什么都没有」,
|
|
257
|
+
* 有这一类情形在里面)。所以新连接一进来就把还在等的题发给它。
|
|
258
|
+
*
|
|
259
|
+
* **只发这一个新连接,不重播给所有人**:已经在看的那个手机可能已经勾好了选项,
|
|
260
|
+
* 再推一次会把它的勾清掉。
|
|
261
|
+
*/
|
|
262
|
+
function replayQuestions(res) {
|
|
263
|
+
for (const [id, entry] of waitingQuestions) {
|
|
264
|
+
try {
|
|
265
|
+
res.write(`event: question\ndata: ${JSON.stringify({ id, agentId: entry.agentId, questions: entry.questions })}\n\n`)
|
|
266
|
+
} catch {
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
// 把这两个能力放到插件够得着的固定位置(见文件上方 `miniControl` 的说明)。
|
|
251
273
|
// 放在这里是因为它俩都已定义(函数声明本来也会提升),且不受下面那堆路由逻辑影响。
|
|
252
274
|
miniControl.hasPhone = hasPhone
|
|
@@ -427,6 +449,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
427
449
|
})
|
|
428
450
|
res.write(': connected\n\n')
|
|
429
451
|
clients.add(res)
|
|
452
|
+
// 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
|
|
453
|
+
// 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
|
|
454
|
+
console.log(`[dsh-mini-remote] 手机连上了,现在连着 ${clients.size} 台`)
|
|
430
455
|
|
|
431
456
|
const beat = setInterval(() => {
|
|
432
457
|
try {
|
|
@@ -441,13 +466,21 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
441
466
|
// 连上就先补一次全量状态,手机刷新后不用再单独拉一次。
|
|
442
467
|
res.write(`event: state\ndata: ${JSON.stringify(store.snapshot())}\n\n`)
|
|
443
468
|
|
|
469
|
+
// 接着把还在等手机回答的题补给它——重连上来的那一下最容易错过(见 replayQuestions)。
|
|
470
|
+
replayQuestions(res)
|
|
471
|
+
|
|
444
472
|
// 顺带把这个会话的完整历史准备好:手机上「刷新一下就只剩重启之后的事」
|
|
445
473
|
// 就是这么来的(手里那份只有插件在场时看见的)。读完会再广播一份快照。
|
|
446
474
|
onSessionOpened?.(store.snapshot().boundSessionId)
|
|
447
475
|
|
|
476
|
+
// 断开只记一次:req 和 res 各会 close 一次,不挡一下就会打两遍(含义却相反)。
|
|
477
|
+
let said = false
|
|
448
478
|
const cleanup = () => {
|
|
479
|
+
if (said) return
|
|
480
|
+
said = true
|
|
449
481
|
clearInterval(beat)
|
|
450
482
|
clients.delete(res)
|
|
483
|
+
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`)
|
|
451
484
|
}
|
|
452
485
|
req.on('close', cleanup)
|
|
453
486
|
res.on('close', cleanup)
|
|
@@ -628,6 +661,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
628
661
|
const waiting = waitingQuestions.get(body?.id)
|
|
629
662
|
if (!waiting) {
|
|
630
663
|
// 409 而不是 200:多半是这个提问早就结束了,如实说,别装作收下了。
|
|
664
|
+
// 落一句痕:这是「手机答了、电脑没动静」唯一能查到的线索——手机那边原来
|
|
665
|
+
// 把失败咽掉了,插件这边再不留痕,下次还是只能靠猜(2026-09-27)。
|
|
666
|
+
console.log('[dsh-mini-remote] 手机上答晚了:这道题已经交回电脑(' + String(body?.id) + '),没接')
|
|
631
667
|
sendJson(res, 409, { ok: false, error: '这个提问已经结束了。' })
|
|
632
668
|
return
|
|
633
669
|
}
|
package/lib/store.js
CHANGED
|
@@ -33,6 +33,14 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
33
33
|
* 任何一条广播都能把它补上。
|
|
34
34
|
*/
|
|
35
35
|
liveBySession: {},
|
|
36
|
+
/**
|
|
37
|
+
* 模型此刻的「自言自语」:sessionId -> { text, at }。**同样故意不落盘**。
|
|
38
|
+
*
|
|
39
|
+
* 只给手机上的鲸鱼娘当台词用,和 latest/history 完全分开——它既不是回答,
|
|
40
|
+
* 也不是历史的一部分(历史有 50 条上限,思考会把它挤爆)。一轮结束就清掉,
|
|
41
|
+
* 所以刷新页面看到的是「当前这一句」,而不是上一次的残留。
|
|
42
|
+
*/
|
|
43
|
+
thoughtBySession: {},
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/** 已知会话的元信息:sessionId -> { id, title, running, lastActivity, seq } */
|
|
@@ -170,6 +178,18 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
170
178
|
return sessionId ? (state.liveBySession[sessionId] ?? '') : ''
|
|
171
179
|
}
|
|
172
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 模型此刻的自言自语(鲸鱼娘的台词)。
|
|
183
|
+
*
|
|
184
|
+
* 和 live 分开存,**不是为了整齐**:页面上的鲸鱼娘只要发现 live 非空就会整块让位
|
|
185
|
+
* (那是「正在流出来的回答」的规矩),把思考塞进 live 会让该出现台词的时候鲸鱼娘
|
|
186
|
+
* 直接消失。
|
|
187
|
+
*/
|
|
188
|
+
function thoughtOf(sessionId) {
|
|
189
|
+
const t = sessionId ? state.thoughtBySession[sessionId] : null
|
|
190
|
+
return t ? { text: t.text, at: t.at } : null
|
|
191
|
+
}
|
|
192
|
+
|
|
173
193
|
/**
|
|
174
194
|
* 只保留最近碰过的若干会话的数据,免得这个 JSON 文件无限长大。
|
|
175
195
|
* 被丢掉的会话切过去会显示「还没有回复」——**这比显示别人的回答强**。
|
|
@@ -182,6 +202,7 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
182
202
|
delete state.historyBySession[id]
|
|
183
203
|
delete state.latestBySession[id]
|
|
184
204
|
delete state.liveBySession[id]
|
|
205
|
+
delete state.thoughtBySession[id]
|
|
185
206
|
}
|
|
186
207
|
}
|
|
187
208
|
|
|
@@ -297,24 +318,33 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
297
318
|
* interrupted 是「这一轮被中止了,下面只是已经吐出来的那半句」。
|
|
298
319
|
* 手机要把它标出来——不标的话,按了停止之后冒出来的半句话看着像模型答崩了。
|
|
299
320
|
*/
|
|
300
|
-
pushReply({ text, sessionId, reason, interrupted }) {
|
|
301
|
-
|
|
321
|
+
pushReply({ text, sessionId, reason, interrupted, timestamp }) {
|
|
322
|
+
// 同上:事件带过来的 `time` 优先。合成时两边的钟不一样,同一条回复就会
|
|
323
|
+
// 既从硬盘来一遍、又从手里这份来一遍,手机上显示两遍。
|
|
324
|
+
const at = timestamp ?? Date.now()
|
|
302
325
|
const session = sessions.get(sessionId)
|
|
303
326
|
state.latestBySession[sessionId] = {
|
|
304
|
-
text, sessionId, title: session?.title ?? '', timestamp, reason,
|
|
327
|
+
text, sessionId, title: session?.title ?? '', timestamp: at, reason,
|
|
305
328
|
interrupted: interrupted === true,
|
|
306
329
|
}
|
|
307
330
|
pushHistory(sessionId, {
|
|
308
|
-
role: 'assistant', text, timestamp, sessionId, interrupted: interrupted === true,
|
|
331
|
+
role: 'assistant', text, timestamp: at, sessionId, interrupted: interrupted === true,
|
|
309
332
|
})
|
|
310
333
|
persist()
|
|
311
334
|
},
|
|
312
335
|
|
|
313
336
|
/** 用户(手机上或电脑上)发出了一条指令。 */
|
|
314
|
-
pushUser({ text, sessionId, id }) {
|
|
337
|
+
pushUser({ text, sessionId, id, timestamp }) {
|
|
315
338
|
// id 要留着:排队中的指令靠它和 agent 收件箱里的那一条对上号,
|
|
316
339
|
// 界面上才能标出「这条还在排队」、也才能撤掉它。
|
|
317
|
-
|
|
340
|
+
//
|
|
341
|
+
// 时间戳要是有事件带过来的那个 `time`,就用它,别用 Date.now()——理由和
|
|
342
|
+
// pushCommand 那段一模一样:这一条要和硬盘重放出来的那份按时间戳合成,
|
|
343
|
+
// **两边得是同一个钟**。差一毫秒就够它被当成「新的一条」再接一遍,手机上
|
|
344
|
+
// 同一条指令显示两遍(2026-09-27 用户实机报的,实测两份差 1 毫秒)。
|
|
345
|
+
pushHistory(sessionId, {
|
|
346
|
+
role: 'user', text, timestamp: timestamp ?? Date.now(), sessionId, id,
|
|
347
|
+
})
|
|
318
348
|
persist()
|
|
319
349
|
},
|
|
320
350
|
|
|
@@ -389,6 +419,20 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
389
419
|
else delete state.liveBySession[sessionId]
|
|
390
420
|
},
|
|
391
421
|
|
|
422
|
+
/**
|
|
423
|
+
* 记下模型此刻的自言自语,给鲸鱼娘当台词。传 null 就是「没话说了」,清掉。
|
|
424
|
+
*
|
|
425
|
+
* 同样不 persist():它活不过一轮,写盘只会留下一句没头没尾的旧台词。
|
|
426
|
+
*/
|
|
427
|
+
setThought(sessionId, thought) {
|
|
428
|
+
if (!sessionId) return
|
|
429
|
+
if (thought && thought.text) {
|
|
430
|
+
state.thoughtBySession[sessionId] = { text: thought.text, at: thought.at ?? Date.now() }
|
|
431
|
+
} else {
|
|
432
|
+
delete state.thoughtBySession[sessionId]
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
|
|
392
436
|
/** 手机是否真的在看着——决定要不要额外发系统推送。 */
|
|
393
437
|
isForeground() {
|
|
394
438
|
return state.presence === 'foreground' && Date.now() - state.lastSeen < 60_000
|
|
@@ -436,6 +480,13 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
436
480
|
* 当成答案——真正的答案还是要等不含工具调用的那一步落定(见 events.js)。
|
|
437
481
|
*/
|
|
438
482
|
live: liveOf(target),
|
|
483
|
+
/**
|
|
484
|
+
* 模型此刻的自言自语(鲸鱼娘的气泡台词),没有就是 null。
|
|
485
|
+
*
|
|
486
|
+
* **第三次重申它的边界**:这不是回答、不进历史、不参与「有没有新回复」的
|
|
487
|
+
* 判断。哪怕它错乱到显示了上一句,代价也只是气泡里一句话旧了。
|
|
488
|
+
*/
|
|
489
|
+
thought: thoughtOf(target),
|
|
439
490
|
boundSessionId: state.boundSessionId,
|
|
440
491
|
/**
|
|
441
492
|
* 「正在执行」必须跟着会话走。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mini-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "DSH 极简移动端遥控器:只把「你的指令」和「AI 的最终回复」送到手机,屏蔽全部中间过程。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -36,13 +36,21 @@
|
|
|
36
36
|
"url": "https://github.com/xingzhen199186/dsh-mini-remote/issues"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@deepseek-ai/cordis": "^4.0.2"
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
40
|
+
"@deepseek-ai/dsh": ">=0.1.5-rc.2 <0.2.0",
|
|
41
|
+
"@deepseek-ai/schemastery": "^3.18.3"
|
|
40
42
|
},
|
|
41
43
|
"peerDependenciesMeta": {
|
|
42
44
|
"@deepseek-ai/cordis": {
|
|
43
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@deepseek-ai/dsh": {
|
|
48
|
+
"optional": true
|
|
44
49
|
}
|
|
45
50
|
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@deepseek-ai/schemastery": "^3.18.3"
|
|
53
|
+
},
|
|
46
54
|
"keywords": [
|
|
47
55
|
"deepseek-harness",
|
|
48
56
|
"dsh",
|