dsh-mini-remote 0.1.0 → 0.1.3
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 +65 -0
- package/README.en.md +154 -0
- package/README.md +75 -59
- package/client/client.js +144 -1
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/history.js +72 -0
- package/lib/index.js +644 -5
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1488 -24
- package/lib/page.js +21 -0
- package/lib/pairing.js +103 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +358 -0
- package/lib/server.js +429 -6
- package/lib/store.js +134 -2
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +6 -5
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
package/lib/index.js
CHANGED
|
@@ -24,17 +24,32 @@ import { randomBytes, randomUUID } from 'node:crypto'
|
|
|
24
24
|
|
|
25
25
|
import { createStore } from './store.js'
|
|
26
26
|
import { createTurnTracker, visibleText } from './events.js'
|
|
27
|
-
import {
|
|
27
|
+
import { replayHistory, lastReply } from './history.js'
|
|
28
|
+
import { readTailEvents } from './log-tail.js'
|
|
29
|
+
import {
|
|
30
|
+
parseSlashLine, describeCommands, commandRow, commandPatch,
|
|
31
|
+
invalidCommandMessage, unknownCommandMessage,
|
|
32
|
+
} from './commands.js'
|
|
33
|
+
import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
|
|
28
34
|
import { sendExternalNotification } from './notify.js'
|
|
29
35
|
import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
|
|
30
36
|
import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
|
|
37
|
+
import { serveState, serveEnable, serveDisable } from './serve.js'
|
|
31
38
|
import { ensureCloudflared } from './cloudflared.js'
|
|
32
39
|
import { startTunnel, TUNNEL_HINT } from './tunnel.js'
|
|
33
|
-
import { listWorkspaces, listSessionsOf } from './tree.js'
|
|
40
|
+
import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt, sessionScale } from './tree.js'
|
|
41
|
+
import { listRoots, listDirectory, makeDirectory } from './browse.js'
|
|
42
|
+
import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
|
|
34
43
|
import { buildId } from './build.js'
|
|
35
44
|
|
|
36
45
|
export const name = 'dsh-mini-remote'
|
|
37
|
-
|
|
46
|
+
// `sessionController` 是 DSH 的宿主会话业务接口(`@deepseek-ai/dsh-api-session-controller`
|
|
47
|
+
// 挂上 Context 的那个),手机端要改每个会话的模型和思考强度就得靠它。
|
|
48
|
+
//
|
|
49
|
+
// **这一行单独加是有意的**:注入清单里写错名字不会报错,它会安静地让整个插件不加载——
|
|
50
|
+
// 那就不是一行功能坏了,是整个遥控器起不来。所以先只加这行、重启,确认插件还活着,
|
|
51
|
+
// 再往上加功能。真出问题也一眼知道是哪儿。
|
|
52
|
+
export const inject = ['agents', 'sessionController']
|
|
38
53
|
|
|
39
54
|
/** 公网隧道起不来时重试几次——向 Cloudflare 注册会偶发超时,实测大约一半概率。 */
|
|
40
55
|
const TUNNEL_ATTEMPTS = 3
|
|
@@ -170,6 +185,7 @@ export function apply(ctx, config = {}) {
|
|
|
170
185
|
const settingsFile = join(dir, 'settings.json')
|
|
171
186
|
const stored = readJson(settingsFile)
|
|
172
187
|
const settings = deepMerge(DEFAULTS, stored ?? {}, config)
|
|
188
|
+
|
|
173
189
|
// 首次启动把默认配置落一份出来,用户才有东西可以改。
|
|
174
190
|
if (stored === null) {
|
|
175
191
|
writeFileSync(settingsFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
@@ -224,6 +240,13 @@ export function apply(ctx, config = {}) {
|
|
|
224
240
|
/** DSH 的附件服务;纯 headless 组合里没有它,所以是可选的。 */
|
|
225
241
|
let attachments = null
|
|
226
242
|
|
|
243
|
+
/**
|
|
244
|
+
* DSH 的斜杠指令账本(`ctx.commands`)。同样是可选的:DSH 明说无界面的组合
|
|
245
|
+
* 不提供这个面(headless / ACP 自动化),所以拿不到时就如实说「这台电脑上的
|
|
246
|
+
* DSH 没提供指令」,而不是给手机一个空名单。
|
|
247
|
+
*/
|
|
248
|
+
let commandService = null
|
|
249
|
+
|
|
227
250
|
/**
|
|
228
251
|
* 正在流出来的那一段。
|
|
229
252
|
*
|
|
@@ -714,6 +737,10 @@ export function apply(ctx, config = {}) {
|
|
|
714
737
|
if (session.header?.origin === 'subagent') return
|
|
715
738
|
const sessionId = session.id
|
|
716
739
|
|
|
740
|
+
// 手机正在看的这个会话:顺手让它的完整历史保持新鲜(一分钟最多重读一次)。
|
|
741
|
+
// 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
|
|
742
|
+
if (sessionId === targetSessionId()) ensureHistory(sessionId)
|
|
743
|
+
|
|
717
744
|
// 会话标题(可选事件,拿到就用,拿不到就用 id 前 8 位兜底)
|
|
718
745
|
if (event.type === 'session/title') {
|
|
719
746
|
const title = event.data?.title ?? event.data?.text
|
|
@@ -723,6 +750,53 @@ export function apply(ctx, config = {}) {
|
|
|
723
750
|
return
|
|
724
751
|
}
|
|
725
752
|
|
|
753
|
+
// 这个会话现在用的是哪个模型、哪一档思考强度。
|
|
754
|
+
//
|
|
755
|
+
// **来源就是这儿**:DSH 每次发请求前都会广播一份 header,里面带着
|
|
756
|
+
// `config: { provider, model, reasoningEffort? }`——和 DSH 接口定义里那个
|
|
757
|
+
// `ModelSelection` 是同一个形状(见 tasks/todo.md 第 32 轮的侦察结论)。
|
|
758
|
+
// 所以读当前值不用另找接口,跟着事件走就行。
|
|
759
|
+
//
|
|
760
|
+
// 记下来给手机端显示用(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
|
|
761
|
+
// 改完之后 DSH 会再发一份新的 header,这里跟着刷新,两边不会走散。
|
|
762
|
+
if (event.type === 'request/header') {
|
|
763
|
+
const cfg = event.data?.header?.config
|
|
764
|
+
if (cfg && typeof cfg.model === 'string') {
|
|
765
|
+
store.touchSession(sessionId, {
|
|
766
|
+
provider: typeof cfg.provider === 'string' ? cfg.provider : null,
|
|
767
|
+
model: cfg.model,
|
|
768
|
+
// 这一项可能是空的:那表示「用 provider 自己的默认」,不是出错
|
|
769
|
+
reasoningEffort: typeof cfg.reasoningEffort === 'string' ? cfg.reasoningEffort : null,
|
|
770
|
+
})
|
|
771
|
+
}
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// 斜杠指令:它**不是一轮对话**,上面那个提取器不认它(会走到 `if (!action) return`
|
|
776
|
+
// 直接丢掉),所以得在这儿单独接住。
|
|
777
|
+
//
|
|
778
|
+
// 为什么值得单独一条路:指令不产生模型消息(DSH 的契约),所以它既不该长成用户
|
|
779
|
+
// 气泡,也不该被单帧模式当成「最新回复」。它是第三种东西——手机上画成一条紧凑的
|
|
780
|
+
// 「指令」行,和电脑端画在正文里是同一个意思。
|
|
781
|
+
//
|
|
782
|
+
// 开始(command/run)和收尾(command/done)靠 commandId 配对。配对逻辑和
|
|
783
|
+
// 「重放硬盘日志」那条路共用 lib/commands.js 里的映射,两边不会走散。
|
|
784
|
+
if (event.type === 'command/run' || event.type === 'command/done') {
|
|
785
|
+
// 手机还没绑会话时,谁先有动静就绑谁(和下面那条同一套口径)。
|
|
786
|
+
if (!store.state.boundSessionId) store.bind(sessionId)
|
|
787
|
+
if (sessionId !== targetSessionId()) return
|
|
788
|
+
if (event.type === 'command/run') {
|
|
789
|
+
const row = commandRow(event)
|
|
790
|
+
if (row) store.pushCommand({ ...row, sessionId })
|
|
791
|
+
} else {
|
|
792
|
+
const patch = commandPatch(event)
|
|
793
|
+
if (patch) store.settleCommand({ sessionId, ...patch })
|
|
794
|
+
}
|
|
795
|
+
store.touchSession(sessionId)
|
|
796
|
+
server?.broadcast('state', store.snapshot())
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
726
800
|
const action = tracker.feed(sessionId, event)
|
|
727
801
|
if (!action) return
|
|
728
802
|
store.touchSession(sessionId)
|
|
@@ -901,6 +975,265 @@ export function apply(ctx, config = {}) {
|
|
|
901
975
|
+ Boolean(treeServices.registry) + ' sessionQuery=' + Boolean(treeServices.query))
|
|
902
976
|
})
|
|
903
977
|
|
|
978
|
+
// ------------------------------------------------------------------
|
|
979
|
+
// 点开一个会话,就把它**完整的历史**读出来(2026-09-25 用户要求)
|
|
980
|
+
//
|
|
981
|
+
// 为什么不能只靠手里那份记录:手机能点开全部会话(一个工作区常有上百条),
|
|
982
|
+
// 而插件手里那份只有「它在场时看见过的事件」——重启前跑过的、在电脑上开的,
|
|
983
|
+
// 点进去是空的。用户要的是:点开就该看到它之前的每一轮。
|
|
984
|
+
//
|
|
985
|
+
// 数据来源是 DSH 自己的会话记录(`sessionQuery.readSession`)。它有个很好的性质:
|
|
986
|
+
// **会话活着就从内存给(不读盘),睡着才去读它的日志**,而且日志是完整的
|
|
987
|
+
// (多帧 zstd 由 DSH 自己解)。所以对正在跑的那个会话,这条路几乎不要钱。
|
|
988
|
+
//
|
|
989
|
+
// 读出来的是原始事件,用 lib/history.js 重放成聊天记录——**和实时那条路
|
|
990
|
+
// 共用同一个提取器**,两条路对「哪条才算最终回答」的判断不会走散。
|
|
991
|
+
// ------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
/** sessionId -> { at, entries, latest }。只活在内存里,不落盘(理由见 store.setReplaySource)。 */
|
|
994
|
+
const historyCache = new Map()
|
|
995
|
+
/** 正在读的那些:用户连点几下也只读一遍。 */
|
|
996
|
+
const historyPending = new Set()
|
|
997
|
+
/** 多久重读一次。会话跑着的时候历史会变长,一分钟够跟上;睡着的不变,重读也只是白读一次。 */
|
|
998
|
+
const HISTORY_TTL_MS = 60000
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* 整份读的两条红线(2026-09-25 事故后加的护栏)。
|
|
1002
|
+
*
|
|
1003
|
+
* 事故:手机绑定的会话是**带 310 个子会话的父会话**(26MB 压缩 / 2.2 万事件),
|
|
1004
|
+
* 开机预热历史时 `readSession` 触发 v3→v4 迁移,把每个子会话整份解出来,
|
|
1005
|
+
* 默认约 4GB 堆 20 秒 OOM。这两条阈值是「手机上宁可少看一点,也别把 DSH 拖死」
|
|
1006
|
+
* 的取舍,都写在这儿方便调。
|
|
1007
|
+
*/
|
|
1008
|
+
const HISTORY_MAX_CHILDREN = 20
|
|
1009
|
+
const HISTORY_MAX_BYTES = 8 * 1024 * 1024
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* 手机端读历史的**上限**(2026-09-25 用户要求:读历史要有上限)。
|
|
1013
|
+
*
|
|
1014
|
+
* 上面两条红线决定「要不要绕开内核那条整份读」,这里三条决定
|
|
1015
|
+
* 「自己读的时候最多读多少、最多给手机多少」。三层**都跟会话总量无关**:
|
|
1016
|
+
* - 窗口:只读日志末尾这么多**压缩**字节;
|
|
1017
|
+
* - 事件:窗口里最多留多少条事件(再向前对齐到一条 user 消息,让重放从一轮的开头起);
|
|
1018
|
+
* - 条目:重放成聊天记录后,最多给手机多少条(≈ 轮数 × 2)。
|
|
1019
|
+
*/
|
|
1020
|
+
// 窗口按「本机最坏的那条日志」实测选的(26MB / 2.2 万事件):16MB 窗口 → 约 1.2 万事件、
|
|
1021
|
+
// 197 条聊天记录、472ms、堆峰 ~400MB;4MB 窗口只有 29 条(太少)。事件上限只作极端兜底。
|
|
1022
|
+
const HISTORY_TAIL_WINDOW_BYTES = 16 * 1024 * 1024
|
|
1023
|
+
const HISTORY_TAIL_MAX_EVENTS = 20000
|
|
1024
|
+
const HISTORY_MAX_ENTRIES = 200
|
|
1025
|
+
|
|
1026
|
+
/** 去 DSH 的会话记录里读一次,重放成聊天记录。读不出来返回 null。 */
|
|
1027
|
+
async function readHistory(sessionId) {
|
|
1028
|
+
const q = treeServices.query
|
|
1029
|
+
if (typeof q?.readSession !== 'function') return null
|
|
1030
|
+
try {
|
|
1031
|
+
const snap = await q.readSession(sessionId)
|
|
1032
|
+
// 子 agent 的会话不进手机——和实时那条路同一条口径(见 session/event 里那句)。
|
|
1033
|
+
if (snap?.session?.origin === 'subagent') return null
|
|
1034
|
+
const events = Array.isArray(snap?.events) ? snap.events : []
|
|
1035
|
+
const entries = replayHistory(events)
|
|
1036
|
+
const capped = capEntries(entries)
|
|
1037
|
+
return {
|
|
1038
|
+
entries: capped.entries,
|
|
1039
|
+
latest: lastReply(capped.entries, sessionId),
|
|
1040
|
+
truncated: capped.dropped > 0,
|
|
1041
|
+
// 说「最近一段」而不是「最近 200 条」:这条记录读完之后,store 还会把
|
|
1042
|
+
// 「刚发生、还没落进日志」的那一两条实时记录接在后面(见 mergeHistory),
|
|
1043
|
+
// 于是手机上真看到的条数可能比这里多一两条。数字对不上就是假话,
|
|
1044
|
+
// 所以这里只声称**读取的上限**,不声称列表长度。
|
|
1045
|
+
note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
|
|
1046
|
+
}
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
// 老日志格式对不上、文件坏了,都会走到这儿。退回「手里那份」,**不编**。
|
|
1049
|
+
console.log('[dsh-mini-remote] 这个会话的完整记录没读出来(' + sessionId + '):'
|
|
1050
|
+
+ (err?.message ?? err))
|
|
1051
|
+
return null
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** 聊天记录条数上限:从**尾部**留(手机上看的是最近发生的事)。 */
|
|
1056
|
+
function capEntries(entries) {
|
|
1057
|
+
if (!Array.isArray(entries) || entries.length <= HISTORY_MAX_ENTRIES) {
|
|
1058
|
+
return { entries, dropped: 0 }
|
|
1059
|
+
}
|
|
1060
|
+
return { entries: entries.slice(-HISTORY_MAX_ENTRIES), dropped: entries.length - HISTORY_MAX_ENTRIES }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* 有上限的读法:只解日志**尾部**的若干 zstd 帧,自己重放(见 lib/log-tail.js)。
|
|
1065
|
+
*
|
|
1066
|
+
* 什么时候用它:会话「大」的时候。那条整份读的路会把整份日志连同**所有直接子会话**
|
|
1067
|
+
* 解出来(v3→v4 迁移要它们),代价一旦付出就收不回来——所以宁可少读一点。
|
|
1068
|
+
* 拿不到就返回 null:由调用方如实说「没读到」,**不编内容**。
|
|
1069
|
+
*/
|
|
1070
|
+
function readTailHistory(sessionId) {
|
|
1071
|
+
let tail
|
|
1072
|
+
try {
|
|
1073
|
+
tail = readTailEvents(sessionId, {
|
|
1074
|
+
windowBytes: HISTORY_TAIL_WINDOW_BYTES,
|
|
1075
|
+
maxEvents: HISTORY_TAIL_MAX_EVENTS,
|
|
1076
|
+
})
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
log.warn(`尾部读失败 ${sessionId}:${err?.message ?? err}`)
|
|
1079
|
+
return null
|
|
1080
|
+
}
|
|
1081
|
+
if (!tail) return null
|
|
1082
|
+
const entries = replayHistory(tail.events)
|
|
1083
|
+
const capped = capEntries(entries)
|
|
1084
|
+
if (!capped.entries.length) return null
|
|
1085
|
+
const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
|
|
1086
|
+
return {
|
|
1087
|
+
entries: capped.entries,
|
|
1088
|
+
latest: lastReply(capped.entries, sessionId),
|
|
1089
|
+
// 老实说清「读到的是不是全部」:窗口把整份都盖住了就不该再说「不是全部」。
|
|
1090
|
+
truncated: tail.truncated,
|
|
1091
|
+
note: tail.truncated
|
|
1092
|
+
? `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`
|
|
1093
|
+
: null,
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* 确保某个会话的完整历史就位。
|
|
1099
|
+
*
|
|
1100
|
+
* 不 await:这是个补数据的后台动作。刚切过去时界面先显示手里那份(或「正在读」),
|
|
1101
|
+
* 读完广播一份新快照,手机上那一串自然就补全了。
|
|
1102
|
+
*/
|
|
1103
|
+
async function ensureHistory(sessionId) {
|
|
1104
|
+
if (!sessionId || historyPending.has(sessionId)) return
|
|
1105
|
+
// 服务没到位(headless 组合)就没得读。**不能装作在读**——那会让手机上一直
|
|
1106
|
+
// 显示「正在读取这个会话的记录…」,而其实谁也没去读。
|
|
1107
|
+
if (typeof treeServices.query?.readSession !== 'function') return
|
|
1108
|
+
const hit = historyCache.get(sessionId)
|
|
1109
|
+
if (hit && Date.now() - hit.at < HISTORY_TTL_MS) return
|
|
1110
|
+
|
|
1111
|
+
// 先占坑:手机显示「正在读」靠的就是这个标记,**必须同步到位**——
|
|
1112
|
+
// 不能等下面那次异步量规模回来(否则刚绑定那一下会显示成空会话)。
|
|
1113
|
+
historyPending.add(sessionId)
|
|
1114
|
+
|
|
1115
|
+
// 读之前先量规模:内核那条整份读会把子会话一起解出来,代价收不回来。
|
|
1116
|
+
// 「大」→ 走有上限的尾部读;「小」→ 仍走内核的完整读(日志权威,会话活着还免读盘)。
|
|
1117
|
+
const scale = await sessionScale({ query: treeServices.query, sessionId })
|
|
1118
|
+
const tooBigForWholeRead =
|
|
1119
|
+
scale.children > HISTORY_MAX_CHILDREN || (scale.bytes ?? 0) > HISTORY_MAX_BYTES
|
|
1120
|
+
let got = null
|
|
1121
|
+
try {
|
|
1122
|
+
if (tooBigForWholeRead) {
|
|
1123
|
+
const size = scale.bytes == null ? '未知' : (scale.bytes / 1048576).toFixed(1) + 'MB'
|
|
1124
|
+
log.info(`规模上限:${sessionId} 改走尾部读(子会话 ${scale.children},日志 ${size})`)
|
|
1125
|
+
got = readTailHistory(sessionId)
|
|
1126
|
+
} else {
|
|
1127
|
+
got = await readHistory(sessionId)
|
|
1128
|
+
}
|
|
1129
|
+
} finally {
|
|
1130
|
+
// 读不出来也记一笔(空结果),免得每次点进来都去读一遍坏文件。
|
|
1131
|
+
if (historyCache.size >= 20) historyCache.clear()
|
|
1132
|
+
historyCache.set(sessionId, {
|
|
1133
|
+
at: Date.now(),
|
|
1134
|
+
entries: got?.entries ?? null,
|
|
1135
|
+
latest: got?.latest ?? null,
|
|
1136
|
+
truncated: got?.truncated === true,
|
|
1137
|
+
note:
|
|
1138
|
+
got?.note ??
|
|
1139
|
+
(tooBigForWholeRead ? '这个会话很大,最近这一段也没读出来' : null),
|
|
1140
|
+
})
|
|
1141
|
+
historyPending.delete(sessionId)
|
|
1142
|
+
server?.broadcast('state', store.snapshot())
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// store 取「完整历史」的地方(它拿不到 ctx,所以由插件注入一个回调)。
|
|
1147
|
+
store.setReplaySource((sessionId) => {
|
|
1148
|
+
const hit = historyCache.get(sessionId)
|
|
1149
|
+
if (hit) {
|
|
1150
|
+
return {
|
|
1151
|
+
entries: hit.entries,
|
|
1152
|
+
latest: hit.latest,
|
|
1153
|
+
pending: false,
|
|
1154
|
+
// 「这不是全部」要和内容一起给出去:手机得说清楚,不能让人以为
|
|
1155
|
+
// 这个会话只有这么点内容(读历史有上限,见 HISTORY_MAX_* 那组常量)。
|
|
1156
|
+
truncated: hit.truncated === true,
|
|
1157
|
+
note: hit.note ?? null,
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (historyPending.has(sessionId)) return { entries: null, latest: null, pending: true }
|
|
1161
|
+
return null
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
// ------------------------------------------------------------------
|
|
1165
|
+
// 手机上答题:把电脑这边的提问推到手机,等它答,再把答案交还给电脑。
|
|
1166
|
+
//
|
|
1167
|
+
// 三条要点,都是踩过或想清楚了才这么写的:
|
|
1168
|
+
//
|
|
1169
|
+
// ① **必须 `{ prepend: true }`**。这条链是「遇到第一个愿意接的人就停」,而电脑上的
|
|
1170
|
+
// 浏览器(经由 `dsh-api-remotes`)本来就接这个提问——排在它后面就永远轮不到我。
|
|
1171
|
+
// 实测过:不带 prepend 时,挂根上、挂到 Agent 范围内,**两处都收不到**。
|
|
1172
|
+
//
|
|
1173
|
+
// ② **不接就让路**(`next()`)。手机没连着、问的不是手机上绑着的那个会话、
|
|
1174
|
+
// 提问是空的——任何一种情况都原样让给浏览器,电脑那边照常弹窗,行为不变。
|
|
1175
|
+
// **插队不是为了抢,是为了有得选。**
|
|
1176
|
+
//
|
|
1177
|
+
// ③ **手机没了就把问题还回去**。用户选的是「一直等,不还回电脑」,但那不等于
|
|
1178
|
+
// 「手机都关了他还等」——那种情况下电脑会永远卡住、而且没人提醒他。所以判据
|
|
1179
|
+
// 不是倒计时,是「手机还在不在」。
|
|
1180
|
+
// ------------------------------------------------------------------
|
|
1181
|
+
|
|
1182
|
+
// **注意这个监听器不是 async,这是刻意的,不是漏写。**
|
|
1183
|
+
//
|
|
1184
|
+
// 顾问指出的关键一条(对照 DSH 自己那个转发给远端的监听器,写法完全一致):
|
|
1185
|
+
// **「接不接」必须在同步的那一瞬间定下来,异步只用来产答案。**
|
|
1186
|
+
// 它的形状是:同步判断 → 不接就同步 `return next()` → 接就同步 `return 一个将来会有答案的东西`。
|
|
1187
|
+
//
|
|
1188
|
+
// 我上一版是先 `await` 拿到服务、再判断、再 `return next()`——**让路的动作发生在异步之后**,
|
|
1189
|
+
// 交不回派发的那个链条,于是表现为"完全没接管",而且一句错都不报。
|
|
1190
|
+
//
|
|
1191
|
+
// 另外一条同样是顾问点出来的:**异步函数抛的错,同步的 try/catch 抓不到**(它是被拒绝的
|
|
1192
|
+
// Promise,不是抛出的异常)。所以我之前说"日志里没报错",**根本不能证明没出错**。
|
|
1193
|
+
ctx.on('user-questions/request', function (request, next) {
|
|
1194
|
+
const agentId = request?.agent?.id ?? null
|
|
1195
|
+
const bound = store.snapshot().boundSessionId
|
|
1196
|
+
const questions = Array.isArray(request?.questions) ? request.questions : []
|
|
1197
|
+
// ——— 同步阶段:只做"接不接"的判断,绝不 await ———
|
|
1198
|
+
// 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
|
|
1199
|
+
// **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
|
|
1200
|
+
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
1201
|
+
if (!agentId || agentId !== bound || questions.length === 0) return next()
|
|
1202
|
+
if (!phoneReady) return next()
|
|
1203
|
+
if (typeof miniControl.askPhone !== 'function') return next()
|
|
1204
|
+
// ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
|
|
1205
|
+
return miniControl.askPhone(questions, agentId).then((answer) => {
|
|
1206
|
+
// 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
|
|
1207
|
+
if (!answer) return next()
|
|
1208
|
+
console.log('[dsh-mini-remote] 手机上答好了,交给电脑')
|
|
1209
|
+
return answer
|
|
1210
|
+
}).catch((err) => {
|
|
1211
|
+
// 我这边出任何问题都不能把提问吞掉,一律让回电脑。
|
|
1212
|
+
console.log('[dsh-mini-remote] 接管提问出错,让回电脑:' + String(err))
|
|
1213
|
+
return next()
|
|
1214
|
+
})
|
|
1215
|
+
}, { prepend: true })
|
|
1216
|
+
|
|
1217
|
+
// ------------------------------------------------------------------
|
|
1218
|
+
// 手机端「新建会话」要用的服务
|
|
1219
|
+
// ------------------------------------------------------------------
|
|
1220
|
+
// **单独 inject,绝不能并进上面那个数组**:`ctx.inject` 是「等齐了才回调」,
|
|
1221
|
+
// 把一个可能不来的服务并进去,连工作区列表都会一起没了——本来只是少个按钮,
|
|
1222
|
+
// 结果整个导航栏都空掉。分开就互不牵连。
|
|
1223
|
+
//
|
|
1224
|
+
// 拿到的是 `sessionController`(`@deepseek-ai/dsh-api-session-controller` 注册的名字),
|
|
1225
|
+
// 就是网页端新建会话走的那条路。它的 `create({ workspaceId })` 内部会拼好会话、
|
|
1226
|
+
// 装模型、挂 preset,最后交给 `ctx.agents.create()`。
|
|
1227
|
+
//
|
|
1228
|
+
// 这行日志是运行时事实的唯一来源:服务到底来没来、`create` 是不是函数,
|
|
1229
|
+
// 「配置里挂了」只是推断,这里打出来的才算数。
|
|
1230
|
+
const canCreate = { controller: null }
|
|
1231
|
+
ctx.inject(['sessionController'], (createCtx) => {
|
|
1232
|
+
canCreate.controller = createCtx.sessionController
|
|
1233
|
+
console.log('[dsh-mini-remote] 新建会话已就绪:sessionController='
|
|
1234
|
+
+ Boolean(canCreate.controller) + ' create=' + typeof canCreate.controller?.create)
|
|
1235
|
+
})
|
|
1236
|
+
|
|
904
1237
|
// ------------------------------------------------------------------
|
|
905
1238
|
// 手机上传文件要用的附件服务
|
|
906
1239
|
// ------------------------------------------------------------------
|
|
@@ -911,6 +1244,85 @@ export function apply(ctx, config = {}) {
|
|
|
911
1244
|
console.log('[dsh-mini-remote] 附件服务已就绪:可以接收手机上传的文件')
|
|
912
1245
|
})
|
|
913
1246
|
|
|
1247
|
+
// ------------------------------------------------------------------
|
|
1248
|
+
// 斜杠指令账本
|
|
1249
|
+
// ------------------------------------------------------------------
|
|
1250
|
+
// 走 ctx.inject、不写进 inject 数组(理由同上)。**指令列表不属于这个插件**:
|
|
1251
|
+
// 它是 DSH 的账本,别的插件往里注册。手机只是多了一个「能发起指令的界面」——
|
|
1252
|
+
// 所以这里不认识任何一条具体指令,也就不会有「手机上少了一条」的问题。
|
|
1253
|
+
ctx.inject(['commands'], (cmdCtx) => {
|
|
1254
|
+
commandService = cmdCtx.commands
|
|
1255
|
+
console.log('[dsh-mini-remote] 指令服务已就绪:手机上可以执行斜杠指令')
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* 手机要的那份指令名单。
|
|
1260
|
+
*
|
|
1261
|
+
* 三种「拿不到」要分开说,因为下一步动作完全不同:没绑会话 / 会话没在跑 /
|
|
1262
|
+
* 这台电脑没有指令服务。合成一句「取不到指令」,用户不知道该去做什么。
|
|
1263
|
+
*/
|
|
1264
|
+
function onCommands() {
|
|
1265
|
+
const sessionId = targetSessionId()
|
|
1266
|
+
if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
|
|
1267
|
+
const agent = ctx.agents.get(sessionId)
|
|
1268
|
+
if (!agent) {
|
|
1269
|
+
return { ok: false, error: '这个会话现在没在跑,指令要先让它跑起来(发一句话)。' }
|
|
1270
|
+
}
|
|
1271
|
+
if (agent.session?.header?.origin === 'subagent') {
|
|
1272
|
+
return { ok: false, error: '这是个子 Agent,手机上用不了它的指令。' }
|
|
1273
|
+
}
|
|
1274
|
+
if (!commandService) return { ok: false, error: '这台电脑上的 DSH 没提供指令。' }
|
|
1275
|
+
try {
|
|
1276
|
+
return { ok: true, commands: describeCommands(commandService.list(agent)) }
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
return { ok: false, error: `取指令列表失败:${err?.message ?? err}` }
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* 执行一条斜杠指令。
|
|
1284
|
+
*
|
|
1285
|
+
* **照抄 DSH 自己的语义,不自己发明**:指令行以 `/` 开头、名字是小写字母打头,
|
|
1286
|
+
* 名字后面原样的一段归指令自己解释(我们连那个分隔空格都不动)。满足形状、
|
|
1287
|
+
* 又确实注册过的,才交给 `execute`;其余一律**拒绝并说明**,绝不退回去当普通消息
|
|
1288
|
+
* 发给模型——那正是「打错的指令白跑一轮模型」的来源。
|
|
1289
|
+
*
|
|
1290
|
+
* 这里**不等它跑完**(`execute` 要到处理器结束才 resolve):指令的进度和结果是通过
|
|
1291
|
+
* 会话事件(command/run、command/done)推到手机上的,和电脑端同一份账。
|
|
1292
|
+
* 等它跑完会让这条 HTTP 请求一直挂着——像 /compact 这种要跑十几秒的指令,
|
|
1293
|
+
* 手机上就成了「点了没反应」。
|
|
1294
|
+
*
|
|
1295
|
+
* 附件**这一版递不进去**:`execute` 要的是 DSH 自己那套上传小票,而手机上传走的是
|
|
1296
|
+
* 另一套存储,两者不通(2026-09-25 查证)。所以这里一个附件都不传——输入框里那些
|
|
1297
|
+
* 附件会原样留着,跟下一条普通消息一起发出去,不会丢。
|
|
1298
|
+
*/
|
|
1299
|
+
async function onCommand(line) {
|
|
1300
|
+
const sessionId = targetSessionId()
|
|
1301
|
+
if (!sessionId) return { ok: false, error: '电脑上还没有可遥控的会话。' }
|
|
1302
|
+
const agent = ctx.agents.get(sessionId)
|
|
1303
|
+
if (!agent) {
|
|
1304
|
+
return { ok: false, error: '这个会话现在没在跑,指令要先让它跑起来(发一句话)。' }
|
|
1305
|
+
}
|
|
1306
|
+
if (agent.session?.header?.origin === 'subagent') {
|
|
1307
|
+
return { ok: false, error: '这是个子 Agent,手机上用不了它的指令。' }
|
|
1308
|
+
}
|
|
1309
|
+
if (!commandService) return { ok: false, error: '这台电脑上的 DSH 没提供指令。' }
|
|
1310
|
+
|
|
1311
|
+
const parsed = parseSlashLine(line)
|
|
1312
|
+
if (!parsed) return { ok: false, error: invalidCommandMessage() }
|
|
1313
|
+
if (!commandService.find(agent, parsed.name)) {
|
|
1314
|
+
return { ok: false, error: unknownCommandMessage(parsed.name) }
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// 不等:结果走事件流。但仍要接住异常,否则会变成没人处理的 Promise 拒绝,
|
|
1318
|
+
// 在这台机器上表现为进程级的告警,而手机上什么也看不到。
|
|
1319
|
+
const signal = new AbortController().signal
|
|
1320
|
+
Promise.resolve(commandService.execute(agent, line, [], signal)).catch((err) => {
|
|
1321
|
+
log.warn(`指令 /${parsed.name} 执行异常:${err?.message ?? err}`)
|
|
1322
|
+
})
|
|
1323
|
+
return { ok: true }
|
|
1324
|
+
}
|
|
1325
|
+
|
|
914
1326
|
/**
|
|
915
1327
|
* 把手机传来的字节交给 DSH 存,换一张小票回来。
|
|
916
1328
|
*
|
|
@@ -957,9 +1369,36 @@ export function apply(ctx, config = {}) {
|
|
|
957
1369
|
return map
|
|
958
1370
|
}
|
|
959
1371
|
|
|
1372
|
+
// 权限档位。跟建会话一样**单独一条 inject**:这个服务依赖 ctx.shell 和
|
|
1373
|
+
// ctx.approval,headless 组合里根本不存在。要是跟别的服务挤在同一条 inject 里,
|
|
1374
|
+
// 它不到场会把那条 inject 上的其它服务一起拖住——本项目在 tree 那三件套上
|
|
1375
|
+
// 踩过这个坑,上面有注释。
|
|
1376
|
+
const presets = { service: null }
|
|
1377
|
+
ctx.inject(['permissionPresets'], (permCtx) => {
|
|
1378
|
+
presets.service = permCtx.permissionPresets
|
|
1379
|
+
console.log('[dsh-mini-remote] 权限档位已就绪:'
|
|
1380
|
+
+ (presetsAvailable(presets.service) ? presets.service.names.join(' / ') : '拿不到'))
|
|
1381
|
+
})
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* 按 id 取会话对象。
|
|
1385
|
+
*
|
|
1386
|
+
* `PermissionPresetService` 的读和写都收**会话对象**而不是 id
|
|
1387
|
+
* (`current(session)` / `set(session, name)`),所以得先拿 agent 再取它的 session。
|
|
1388
|
+
* 拿不到就回 null——手机那边会显示「暂时读不到」,而不是整页崩掉。
|
|
1389
|
+
*/
|
|
1390
|
+
function sessionFor(sessionId) {
|
|
1391
|
+
if (!sessionId) return null
|
|
1392
|
+
try {
|
|
1393
|
+
return ctx.agents?.get(sessionId)?.session ?? null
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
return null
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
960
1399
|
const nav = {
|
|
961
|
-
listWorkspaces: (runningIds, force) =>
|
|
962
|
-
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force }),
|
|
1400
|
+
listWorkspaces: (runningIds, force, includeEmpty) =>
|
|
1401
|
+
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
|
|
963
1402
|
listSessionsOf: (workspaceId, limit, force) =>
|
|
964
1403
|
listSessionsOf({
|
|
965
1404
|
registry: treeServices.registry,
|
|
@@ -970,6 +1409,118 @@ export function apply(ctx, config = {}) {
|
|
|
970
1409
|
limit,
|
|
971
1410
|
force,
|
|
972
1411
|
}),
|
|
1412
|
+
// 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
|
|
1413
|
+
// 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
|
|
1414
|
+
createSession: (workspaceId) =>
|
|
1415
|
+
createSessionIn({
|
|
1416
|
+
controller: canCreate.controller ?? ctx.get('sessionController'),
|
|
1417
|
+
registry: treeServices.registry,
|
|
1418
|
+
workspaceId,
|
|
1419
|
+
}),
|
|
1420
|
+
// 「这一刻到底能不能建会话」。给 /mini/api/version 用:那是个不用动手、
|
|
1421
|
+
// 一条命令就能问的运行时事实,比翻日志快。和 createSession 同一条判据,
|
|
1422
|
+
// 免得出现「探测说能、真按了却不行」。
|
|
1423
|
+
canCreateSession: () =>
|
|
1424
|
+
typeof (canCreate.controller ?? ctx.get('sessionController'))?.create === 'function',
|
|
1425
|
+
// 登记工作区。`workspaceRegistry` 就是上面那三个服务里的 registry,
|
|
1426
|
+
// 已经注入到位了,不用再开一条依赖。
|
|
1427
|
+
createWorkspace: (path) =>
|
|
1428
|
+
createWorkspaceAt({ registry: treeServices.registry, path }),
|
|
1429
|
+
// 权限档位。作用域就是传进来的那个会话——手机只绑一个会话,所以
|
|
1430
|
+
// 「当前会话」和「绑定会话」是同一件事,不用另外记状态。
|
|
1431
|
+
permissions: (sessionId) =>
|
|
1432
|
+
listPresets({ service: presets.service, session: sessionFor(sessionId) }),
|
|
1433
|
+
setPermission: (sessionId, name) =>
|
|
1434
|
+
setPreset({ service: presets.service, session: sessionFor(sessionId), name }),
|
|
1435
|
+
|
|
1436
|
+
// 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
|
|
1437
|
+
// `canCreate.controller` 就是 `ctx.sessionController`——和上面 createSession 同一个东西。
|
|
1438
|
+
//
|
|
1439
|
+
// 有个区别要记牢:**目录是全局的,选中的那个是会话级的**。所以列清单只要问一次,
|
|
1440
|
+
// 「现在用哪个」得按会话问。`ModelSelection` 就是 `{ provider, model, reasoningEffort? }`,
|
|
1441
|
+
// 强度不是单独一项设置,它是模型选择的一部分——这也是为什么两个按钮要一起改。
|
|
1442
|
+
//
|
|
1443
|
+
// 拿不到能力时如实说 `unavailable`,让手机整块不显示。**不假装只有一种模型可选**:
|
|
1444
|
+
// 那会让用户以为自己的模型被换掉了。
|
|
1445
|
+
modelCatalog: async () => {
|
|
1446
|
+
const controller = canCreate.controller ?? ctx.get('sessionController')
|
|
1447
|
+
if (typeof controller?.modelCatalog !== 'function') return { ok: false, reason: 'unavailable' }
|
|
1448
|
+
return { ok: true, catalog: await controller.modelCatalog() }
|
|
1449
|
+
},
|
|
1450
|
+
selectModel: async (sessionId, selection) => {
|
|
1451
|
+
const controller = canCreate.controller ?? ctx.get('sessionController')
|
|
1452
|
+
if (typeof controller?.selectModel !== 'function') return { ok: false, reason: 'unavailable' }
|
|
1453
|
+
const value = await controller.selectModel({ sessionId, ...selection })
|
|
1454
|
+
return { ok: true, selected: value?.selected ?? null }
|
|
1455
|
+
},
|
|
1456
|
+
// 某个会话**现在到底用哪个模型**。
|
|
1457
|
+
//
|
|
1458
|
+
// 为什么非得问 DSH、不能自己记:插件只在会话跑起来时才从事件里看到模型
|
|
1459
|
+
// (request/header),没在插件眼皮底下跑过的会话——比如重启之前用过的那些——
|
|
1460
|
+
// 手里就是空的。上一版给空值兜了一个「目录默认值」,结果把一个不知道的会话
|
|
1461
|
+
// 显示成上次选过的那个模型:看着像对的,其实是编的。**宁可说不知道,也不编。**
|
|
1462
|
+
//
|
|
1463
|
+
// 两条路,**第二条是必须的**:
|
|
1464
|
+
// ① 会话正活着 → 它的 `requestHeader()` 直接给结果,走内存,最快。
|
|
1465
|
+
// ② 会话睡在硬盘上 → 去它的日志里翻最后那条 request/header 事件。
|
|
1466
|
+
//
|
|
1467
|
+
// ② 不是备胎,是主路。手机上能挑的是**全部**会话(这个工作区里就有上百个),
|
|
1468
|
+
// 而「活着的」通常只有开着的两三个——只走 ① 的话,用户切到绝大多数会话都只能
|
|
1469
|
+
// 看到「还没问到」。查证过:147 个会话里只有 3 个是活的。
|
|
1470
|
+
//
|
|
1471
|
+
// 用 `sessionQuery.listEvents`:它的说明是「列出原始日志事件」,并且注明
|
|
1472
|
+
// **优先取活着的、没有就读硬盘**——两件事一个方法全包,不用我自己分支。
|
|
1473
|
+
sessionModel: async (sessionId) => {
|
|
1474
|
+
if (!sessionId) return { ok: false, reason: 'no-id' }
|
|
1475
|
+
const shape = (cfg) => ({
|
|
1476
|
+
provider: typeof cfg.provider === 'string' ? cfg.provider : null,
|
|
1477
|
+
model: cfg.model,
|
|
1478
|
+
reasoningEffort: typeof cfg.reasoningEffort === 'string' ? cfg.reasoningEffort : null,
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
// ① 活着的会话:内存里就有
|
|
1482
|
+
const sessions = ctx.get('sessions')
|
|
1483
|
+
const live = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined
|
|
1484
|
+
const liveCfg = live && typeof live.requestHeader === 'function' ? live.requestHeader()?.config : null
|
|
1485
|
+
if (liveCfg && typeof liveCfg.model === 'string') return { ok: true, model: shape(liveCfg) }
|
|
1486
|
+
|
|
1487
|
+
// ② 睡着的会话:读硬盘上的日志,从后往前找最后那条 request/header。
|
|
1488
|
+
//
|
|
1489
|
+
// **必须用 `readSession`,不能用 `listEvents`。** 后者给的是**元数据**
|
|
1490
|
+
// (sessionId / seq / type / time / surface),**没有 `data`**——而配置恰恰在
|
|
1491
|
+
// `data` 里。这个已经试过一次,读出来是空的,白跑一轮。`readSession` 给的是
|
|
1492
|
+
// 完整的原始事件,`data.header.config` 就在里面。
|
|
1493
|
+
//
|
|
1494
|
+
// 读整个日志是贵的,而手机每 15 秒会来问一次,所以缓存 60 秒:桌面那边改了模型,
|
|
1495
|
+
// 最迟一分钟内在手机上出现。手机自己改的那次走另一条路(那条接口会把新值直接
|
|
1496
|
+
// 写进会话记录),不受这个缓存影响。
|
|
1497
|
+
const cache = treeServices.modelCache || (treeServices.modelCache = new Map())
|
|
1498
|
+
const hit = cache.get(sessionId)
|
|
1499
|
+
if (hit && Date.now() - hit.at < 60000) return hit.value
|
|
1500
|
+
|
|
1501
|
+
const q = treeServices.query
|
|
1502
|
+
let value
|
|
1503
|
+
if (typeof q?.readSession !== 'function') {
|
|
1504
|
+
value = { ok: false, reason: 'unavailable' }
|
|
1505
|
+
} else {
|
|
1506
|
+
try {
|
|
1507
|
+
const snap = await q.readSession(sessionId)
|
|
1508
|
+
const events = Array.isArray(snap?.events) ? snap.events : []
|
|
1509
|
+
value = { ok: false, reason: 'no-header' }
|
|
1510
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
1511
|
+
const e = events[i]
|
|
1512
|
+
if (!e || e.type !== 'request/header') continue
|
|
1513
|
+
const cfg = e.data?.header?.config
|
|
1514
|
+
if (cfg && typeof cfg.model === 'string') { value = { ok: true, model: shape(cfg) }; break }
|
|
1515
|
+
}
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
value = { ok: false, reason: 'read-failed', error: String(err && err.message ? err.message : err) }
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (cache.size > 50) cache.clear()
|
|
1521
|
+
cache.set(sessionId, { at: Date.now(), value })
|
|
1522
|
+
return value
|
|
1523
|
+
},
|
|
973
1524
|
}
|
|
974
1525
|
|
|
975
1526
|
// ------------------------------------------------------------------
|
|
@@ -985,10 +1536,18 @@ export function apply(ctx, config = {}) {
|
|
|
985
1536
|
tunnelHost,
|
|
986
1537
|
log,
|
|
987
1538
|
onInstruction,
|
|
1539
|
+
onCommands,
|
|
1540
|
+
onCommand,
|
|
988
1541
|
onUpload,
|
|
989
1542
|
onStop,
|
|
990
1543
|
onUnqueue,
|
|
1544
|
+
// 手机点开会话(或刷新页面)时,把那条会话的完整历史读出来。
|
|
1545
|
+
onSessionOpened: (sessionId) => ensureHistory(sessionId),
|
|
991
1546
|
tree: nav,
|
|
1547
|
+
// 目录浏览自己读盘(理由见 lib/browse.js 开头:DSH 的目录选择器在
|
|
1548
|
+
// 「Windows + 只绑回环」时会判成 native,那套动词是直接拒绝的,
|
|
1549
|
+
// 会在电脑屏幕上弹框——而用户正在外面用手机)。
|
|
1550
|
+
browse: { listRoots, listDirectory, makeDirectory },
|
|
992
1551
|
build: BUILD,
|
|
993
1552
|
})
|
|
994
1553
|
.then((instance) => {
|
|
@@ -1014,6 +1573,9 @@ export function apply(ctx, config = {}) {
|
|
|
1014
1573
|
}
|
|
1015
1574
|
// 服务起来了才谈得上开隧道:cloudflared 要指向这个端口
|
|
1016
1575
|
if (settings.tunnel?.enabled) bringTunnelUp()
|
|
1576
|
+
// 重启前手机在看哪个会话,就先把它的完整历史补上——用户打开手机时
|
|
1577
|
+
// 该看到的是那个会话的全貌,而不是「重启之后才发生的事」。
|
|
1578
|
+
ensureHistory(store.snapshot().boundSessionId)
|
|
1017
1579
|
})
|
|
1018
1580
|
.catch((err) => {
|
|
1019
1581
|
// 记下来。设置页那边要拿它把「还在启动」和「起不来了」分开说——
|
|
@@ -1084,6 +1646,18 @@ export function apply(ctx, config = {}) {
|
|
|
1084
1646
|
starting: tunnelStarting,
|
|
1085
1647
|
error: tunnelError,
|
|
1086
1648
|
},
|
|
1649
|
+
/**
|
|
1650
|
+
* Tailscale 那条路上的 HTTPS 地址。
|
|
1651
|
+
*
|
|
1652
|
+
* **每次刷新都现问一次**,不缓存:这个状态会被别的东西改掉——用户可能在
|
|
1653
|
+
* 命令行里 `tailscale serve --https=443 off`,也可能 Tailscale 自己升级后
|
|
1654
|
+
* 配置没了。缓存一份就会漂移,而漂移的表现是「面板上写着已开启、手机却打不开」,
|
|
1655
|
+
* 正是 2026-09-22 隧道那次踩过的坑。
|
|
1656
|
+
*
|
|
1657
|
+
* 问的是 `server.port`(**实际**在听的那个),不是配置里的 port——
|
|
1658
|
+
* 端口被占时服务会自己往后让,用配置值会指错。
|
|
1659
|
+
*/
|
|
1660
|
+
serve: await serveState(server.port),
|
|
1087
1661
|
})
|
|
1088
1662
|
return { status: payload.ok ? 200 : 500, body: payload }
|
|
1089
1663
|
}
|
|
@@ -1132,6 +1706,60 @@ export function apply(ctx, config = {}) {
|
|
|
1132
1706
|
},
|
|
1133
1707
|
})
|
|
1134
1708
|
|
|
1709
|
+
/**
|
|
1710
|
+
* 开/关 Tailscale 的 HTTPS 地址。
|
|
1711
|
+
*
|
|
1712
|
+
* 和公网那个开关一样**只允许本机调用**(`localOnly`):它会改这台电脑上
|
|
1713
|
+
* Tailscale 的配置,不该让手机或同网段的别人碰到。
|
|
1714
|
+
*
|
|
1715
|
+
* 失败时把 `enableLink` 一起带回去——那是 tailnet 没开 Serve 时 Tailscale
|
|
1716
|
+
* 官方印出来的开启链接。整个功能里门槛最高的就是这一步,把链接原样递给用户,
|
|
1717
|
+
* 他点一下就完事;自己写一句「请到后台开启」等于把门槛加回去。
|
|
1718
|
+
*/
|
|
1719
|
+
const offServe = webServer.register({
|
|
1720
|
+
kind: 'exact',
|
|
1721
|
+
path: '/mini-remote/serve',
|
|
1722
|
+
handler: async (req, res) => {
|
|
1723
|
+
if (!localOnly(req, res)) return
|
|
1724
|
+
if (req.method !== 'POST') {
|
|
1725
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
1726
|
+
return
|
|
1727
|
+
}
|
|
1728
|
+
let want
|
|
1729
|
+
try {
|
|
1730
|
+
want = await readBody(req)
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
1733
|
+
return
|
|
1734
|
+
}
|
|
1735
|
+
if (!server) {
|
|
1736
|
+
sendJson(res, 503, { ok: false, error: '手机服务还在启动,过几秒再试。' })
|
|
1737
|
+
return
|
|
1738
|
+
}
|
|
1739
|
+
try {
|
|
1740
|
+
const r = want?.enabled ? await serveEnable(server.port) : await serveDisable()
|
|
1741
|
+
if (!r.ok) {
|
|
1742
|
+
sendJson(res, 400, {
|
|
1743
|
+
ok: false,
|
|
1744
|
+
error: r.error,
|
|
1745
|
+
enableLink: r.enableLink ?? null,
|
|
1746
|
+
reason: r.reason ?? 'other',
|
|
1747
|
+
})
|
|
1748
|
+
return
|
|
1749
|
+
}
|
|
1750
|
+
} catch (err) {
|
|
1751
|
+
sendJson(res, 500, { ok: false, error: `切换 HTTPS 地址失败:${err?.message ?? err}` })
|
|
1752
|
+
return
|
|
1753
|
+
}
|
|
1754
|
+
try {
|
|
1755
|
+
const { status, body } = await pairingPayload()
|
|
1756
|
+
sendJson(res, status, body)
|
|
1757
|
+
} catch (err) {
|
|
1758
|
+
sendJson(res, 500, { ok: false, error: `切换之后读不到状态:${err?.message ?? err}` })
|
|
1759
|
+
}
|
|
1760
|
+
},
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1135
1763
|
/**
|
|
1136
1764
|
* 密码最短多少位。
|
|
1137
1765
|
*
|
|
@@ -1198,9 +1826,20 @@ export function apply(ctx, config = {}) {
|
|
|
1198
1826
|
},
|
|
1199
1827
|
})
|
|
1200
1828
|
|
|
1829
|
+
/**
|
|
1830
|
+
* 卸载时把注册过的路由全部注销。
|
|
1831
|
+
*
|
|
1832
|
+
* **每加一条 `webServer.register` 都要往这里补一笔**,否则插件卸载后那条路由
|
|
1833
|
+
* 还挂在服务器上,指向一个已经死掉的闭包——再有人打过来就是莫名其妙的报错。
|
|
1834
|
+
*
|
|
1835
|
+
* 2026-09-24 补:`offToken` 一直是漏的(改密码那条路由注册了却没注销),
|
|
1836
|
+
* 加 HTTPS 这条时对照着数了一遍才发现。四条注册,四条注销,现在对得上。
|
|
1837
|
+
*/
|
|
1201
1838
|
return () => {
|
|
1202
1839
|
offGet?.()
|
|
1203
1840
|
offTunnel?.()
|
|
1841
|
+
offServe?.()
|
|
1842
|
+
offToken?.()
|
|
1204
1843
|
}
|
|
1205
1844
|
})
|
|
1206
1845
|
}
|