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/client/client.js
CHANGED
|
@@ -25,6 +25,7 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
var PAIRING_PATH = '/mini-remote/pairing';
|
|
26
26
|
var TOKEN_PATH = '/mini-remote/token';
|
|
27
27
|
var TUNNEL_PATH = '/mini-remote/tunnel';
|
|
28
|
+
var SERVE_PATH = '/mini-remote/serve';
|
|
28
29
|
|
|
29
30
|
// 颜色走 DSH 主题变量(真名前缀是 --dsw-alias-,深浅色自动跟随)。
|
|
30
31
|
// 每个变量都带一个浅色兜底值,变量缺失时界面仍然可读。
|
|
@@ -73,6 +74,11 @@ window.__ModuleLoader__.load({
|
|
|
73
74
|
var pwdErrState = useState(null);
|
|
74
75
|
var pwdError = pwdErrState[0];
|
|
75
76
|
var setPwdError = pwdErrState[1];
|
|
77
|
+
// HTTPS 开关的失败信息单独存一份(不是复用上面的 failure):它要多带一条
|
|
78
|
+
// 「点这里去开启」的链接,一条字符串装不下。
|
|
79
|
+
var serveErrState = useState(null);
|
|
80
|
+
var serveError = serveErrState[0];
|
|
81
|
+
var setServeError = serveErrState[1];
|
|
76
82
|
|
|
77
83
|
// 同源 GET,不带任何 token:这台电脑自己读得到,别人的电脑读不到。
|
|
78
84
|
useEffect(function () {
|
|
@@ -180,6 +186,57 @@ window.__ModuleLoader__.load({
|
|
|
180
186
|
.then(function () { setBusy(false); });
|
|
181
187
|
};
|
|
182
188
|
|
|
189
|
+
/**
|
|
190
|
+
* 开/关 Tailscale 的 HTTPS 地址。
|
|
191
|
+
*
|
|
192
|
+
* 失败时**不复用上面那个 failure**:这条要多带一条「点这里去开启」的链接,
|
|
193
|
+
* 一条字符串装不下。而那条链接恰恰是整个功能里门槛最高的一步——tailnet
|
|
194
|
+
* 后台的一个一次性开关。Tailscale 官方把它印在报错里了,原样递给用户就行。
|
|
195
|
+
*
|
|
196
|
+
* 成功与否的判据是响应体里有没有 `reason`:serve 失败那条路带它,
|
|
197
|
+
* 配对信息那条路不带。配对信息本身也可能是 `ok:false`(比如一条地址都没有),
|
|
198
|
+
* 那种情况照旧交给 setInfo,让面板自己把它画成错误块。
|
|
199
|
+
*/
|
|
200
|
+
var toggleServe = function (enabled) {
|
|
201
|
+
if (busy) return;
|
|
202
|
+
setBusy(true);
|
|
203
|
+
setServeError(null);
|
|
204
|
+
fetch(SERVE_PATH, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'content-type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({ enabled: enabled }),
|
|
208
|
+
})
|
|
209
|
+
.then(function (res) {
|
|
210
|
+
return res.json().then(
|
|
211
|
+
function (body) { return { status: res.status, body: body }; },
|
|
212
|
+
function () { return { status: res.status, body: null }; },
|
|
213
|
+
);
|
|
214
|
+
})
|
|
215
|
+
.then(function (r) {
|
|
216
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
217
|
+
if (!b) {
|
|
218
|
+
setServeError({
|
|
219
|
+
error: '切换 HTTPS 地址失败:服务返回了 HTTP ' + r.status + '。',
|
|
220
|
+
enableLink: null,
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (b.reason) {
|
|
225
|
+
setServeError({ error: b.error || '开 HTTPS 地址失败了。', enableLink: b.enableLink || null });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
setInfo(b);
|
|
229
|
+
setFailure(null);
|
|
230
|
+
})
|
|
231
|
+
.catch(function (err) {
|
|
232
|
+
setServeError({
|
|
233
|
+
error: '切换 HTTPS 地址失败:' + ((err && err.message) || String(err)),
|
|
234
|
+
enableLink: null,
|
|
235
|
+
});
|
|
236
|
+
})
|
|
237
|
+
.then(function () { setBusy(false); });
|
|
238
|
+
};
|
|
239
|
+
|
|
183
240
|
return h('div', { style: styles.card },
|
|
184
241
|
h('div', { style: styles.title }, '手机遥控'),
|
|
185
242
|
h('div', { style: styles.muted }, '手机扫码就能用,不用输密码——密码已经编在二维码里了。'),
|
|
@@ -192,11 +249,20 @@ window.__ModuleLoader__.load({
|
|
|
192
249
|
busy: busy,
|
|
193
250
|
pwdError: pwdError,
|
|
194
251
|
savePassword: savePassword,
|
|
252
|
+
}, {
|
|
253
|
+
// **摊平再传**。serveError 自己是 `{error, enableLink}`,如果直接写成
|
|
254
|
+
// `{error: serveError}`,serveBlock 里的 `err.error` 拿到的就是整个对象,
|
|
255
|
+
// 而 `h('div', {}, 对象)` 渲染出来是一个**空**的 div——错误文字一个字都不
|
|
256
|
+
// 显示,界面上看上去只是「点了没反应」。2026-09-24 就是这么踩进去的:
|
|
257
|
+
// 状态和渲染全是对的,只有最后一步取错了字段。
|
|
258
|
+
error: serveError ? serveError.error : null,
|
|
259
|
+
enableLink: serveError ? serveError.enableLink : null,
|
|
260
|
+
toggle: toggleServe,
|
|
195
261
|
}),
|
|
196
262
|
);
|
|
197
263
|
}
|
|
198
264
|
|
|
199
|
-
function content(info, failure, copied, copy, busy, toggle, pwd) {
|
|
265
|
+
function content(info, failure, copied, copy, busy, toggle, pwd, serve) {
|
|
200
266
|
if (failure) return h('div', { style: styles.error }, failure);
|
|
201
267
|
if (!info) return h('div', { style: styles.loading }, '正在读取配对信息…');
|
|
202
268
|
|
|
@@ -220,9 +286,86 @@ window.__ModuleLoader__.load({
|
|
|
220
286
|
// 出错时也要把开关露出来:不然「隧道起不来」会让整个面板变成一条报错,
|
|
221
287
|
// 用户连关掉它的地方都找不到。
|
|
222
288
|
if (info.tunnel) blocks.push(tunnelBlock(info.tunnel, busy, toggle));
|
|
289
|
+
// HTTPS 那一块:**不管配对信息成功还是失败都要出现**。失败时尤其要出现——
|
|
290
|
+
// tailnet 没开 Serve 的时候,用户需要的正是那条开启链接,而那时候面板上
|
|
291
|
+
// 其它东西多半也在报错,正好是他最需要指路的时候。
|
|
292
|
+
if (info.serve || serve.error) blocks.push(serveBlock(info.serve, serve, busy));
|
|
223
293
|
return h('div', null, blocks);
|
|
224
294
|
}
|
|
225
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Tailscale 的 HTTPS 地址这一块。
|
|
298
|
+
*
|
|
299
|
+
* 为什么值得单独占一块:手机端有三样东西被明文 http 挡着——完成后提醒、
|
|
300
|
+
* 语音输入、剪贴板的完整能力。浏览器只在加密连接上才给用。
|
|
301
|
+
* 开了这个,Tailscale 那条路上就多出一个 https:// 地址,那三样才有得谈。
|
|
302
|
+
*
|
|
303
|
+
* **它不是第四条路**:同一台电脑、同一个服务、同一条 Tailscale 通道,
|
|
304
|
+
* 只是把连接换成加密的。上面那条明文的照旧能用,也照旧显示。
|
|
305
|
+
*/
|
|
306
|
+
function serveBlock(st, err, busy) {
|
|
307
|
+
var on = Boolean(st && st.on);
|
|
308
|
+
var installed = !st || st.installed !== false;
|
|
309
|
+
var other = st && st.urlOfOtherPort ? st.urlOfOtherPort : null;
|
|
310
|
+
var needsLogin = Boolean(st && st.needsLogin);
|
|
311
|
+
// 服务端已经算好了该说哪句话,界面照说就行。
|
|
312
|
+
var stateError = st && st.error ? st.error : null;
|
|
313
|
+
var status;
|
|
314
|
+
if (!installed) {
|
|
315
|
+
status = '这台电脑上没装 Tailscale,所以开不了。上面那条「在外面用(Tailscale)」里有下载链接。';
|
|
316
|
+
} else if (on) {
|
|
317
|
+
status = '已开启。上面那条「Tailscale(加密)」就是它——出门在外用它,地址不用写端口。';
|
|
318
|
+
} else if (needsLogin) {
|
|
319
|
+
// 这一步的下一步动作和「没开」完全不同:他得先去登录,登录之后那个地址才会存在。
|
|
320
|
+
// 合成一句「没开」,一个还没登录的人会反复点开关,怎么点都没反应。
|
|
321
|
+
status = 'Tailscale 装了,但这个账号还没登录。先在电脑上登录一次——手机要连的那个地址,是登录之后才有的。';
|
|
322
|
+
} else if (stateError) {
|
|
323
|
+
status = '问不出来 Tailscale 现在是什么状态。';
|
|
324
|
+
} else if (other) {
|
|
325
|
+
// 不能含糊地说「没开」:用户会以为是自己这台电脑不支持。
|
|
326
|
+
status = 'Tailscale 的 serve 配着,但它指的是别的端口,不是这个插件。想给手机用的话,把下面这个开关打开。';
|
|
327
|
+
} else {
|
|
328
|
+
status = '没开。开了之后 Tailscale 那条路上会多一个 https:// 地址——手机浏览器只在加密连接上才肯给用通知和麦克风。'
|
|
329
|
+
+ '第一次开要在浏览器里确认一下(Tailscale 后台的一个开关),插件代不了你点。';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 两条链接都是 Tailscale 官方给的,原样递过去。这是整个功能里门槛最高的两步:
|
|
333
|
+
// 一次是登录,一次是给 tailnet 开 Serve。自己写一句「请到后台开启」等于把门槛加回去。
|
|
334
|
+
var loginUrl = needsLogin && st.loginUrl ? st.loginUrl : null;
|
|
335
|
+
var enableUrl = (err && err.enableLink) || (st && st.enableLink) || null;
|
|
336
|
+
var shownError = (err && err.error) || stateError;
|
|
337
|
+
|
|
338
|
+
return h('div', { key: 'serve', style: styles.block },
|
|
339
|
+
h('div', { style: styles.entryTitle }, 'HTTPS 地址(Tailscale)'),
|
|
340
|
+
h('div', { style: styles.muted }, '和上面那条 Tailscale 是同一台电脑,区别只在连接加不加密。地址好记,也不用写端口。'),
|
|
341
|
+
h('label', { style: styles.toggleRow },
|
|
342
|
+
h('input', {
|
|
343
|
+
type: 'checkbox',
|
|
344
|
+
checked: on,
|
|
345
|
+
disabled: Boolean(busy) || !installed || needsLogin,
|
|
346
|
+
onChange: function (e) { err.toggle(e.target.checked); },
|
|
347
|
+
}),
|
|
348
|
+
h('span', null, on ? '已开启' : '开启'),
|
|
349
|
+
),
|
|
350
|
+
h('div', { style: shownError ? styles.detail : styles.muted }, status),
|
|
351
|
+
loginUrl ? h('a', {
|
|
352
|
+
href: loginUrl,
|
|
353
|
+
target: '_blank',
|
|
354
|
+
rel: 'noreferrer',
|
|
355
|
+
style: styles.link,
|
|
356
|
+
}, '点这里去登录(Tailscale 官方页面)') : null,
|
|
357
|
+
enableUrl ? h('a', {
|
|
358
|
+
href: enableUrl,
|
|
359
|
+
target: '_blank',
|
|
360
|
+
rel: 'noreferrer',
|
|
361
|
+
style: styles.link,
|
|
362
|
+
}, '点这里去开启(Tailscale 官方页面)') : null,
|
|
363
|
+
// 失败原文照登,别吞。这条路上最可能的失败是「tailnet 还没开 Serve」,
|
|
364
|
+
// 而那种情况 Tailscale 会印出一条开启链接——上面那条链接才是用户要的东西。
|
|
365
|
+
err && err.error ? h('div', { style: styles.error }, err.error) : null,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
226
369
|
/**
|
|
227
370
|
* 没探到 Tailscale 时的那一行:说清楚缺什么、去哪儿拿。
|
|
228
371
|
*
|
package/lib/browse.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 电脑目录浏览,给手机挑工作区用。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么自己用 node:fs 实现,而不是用 DSH 的目录选择器
|
|
5
|
+
*
|
|
6
|
+
* DSH 有 `@deepseek-ai/dsh-host-directory-picker`,能力是分叉的:一种是 native
|
|
7
|
+
* (在**电脑屏幕上**弹系统选择框),一种是 browse(纯数据,什么都不往电脑屏幕上画)。
|
|
8
|
+
* 哪个生效由 `directory-picker-auto` 开机时判:
|
|
9
|
+
*
|
|
10
|
+
* if (facts.bindHost !== "127.0.0.1") return "browse";
|
|
11
|
+
* if (facts.ssh) return "browse";
|
|
12
|
+
* if (platform === "darwin" || platform === "win32") return "native";
|
|
13
|
+
*
|
|
14
|
+
* 也就是说:**Windows + 只绑回环地址(大多数人的默认配置)→ 判成 native**。
|
|
15
|
+
* 而 native 模式下 `list` / `createDirectory` 这类动词是**直接被拒绝**的,不是降级
|
|
16
|
+
* 模拟(那套设计明确说 refused verbs are refused, not approximated)。
|
|
17
|
+
* 结果就是:用户坐在电脑前,手机上点「新建工作区」,电脑屏幕上弹出一个框——
|
|
18
|
+
* 而他正在外面用手机。这条路走不通。
|
|
19
|
+
*
|
|
20
|
+
* 所以我们自己读目录。理由不是「更简单」,是**跟绑定地址无关、行为可预测**:
|
|
21
|
+
* 不管 DSH 绑回环还是绑局域网、不管有没有走 SSH,手机看到的都是同一份数据。
|
|
22
|
+
*
|
|
23
|
+
* ## 边界
|
|
24
|
+
*
|
|
25
|
+
* 只列**目录**,绝不列文件、绝不读文件内容。手机上要的是「挑一个文件夹」,
|
|
26
|
+
* 文件既是噪音也是不必要的暴露。
|
|
27
|
+
*
|
|
28
|
+
* ## 关于「手机传来的路径不可信」
|
|
29
|
+
*
|
|
30
|
+
* 手机是网络对面来的输入,所以这里对路径只做两件事:**规范化**(`resolve` 掉
|
|
31
|
+
* `..`、`.`)和**拒绝畸形输入**(空字节、非字符串)。不做白名单式的根限制,
|
|
32
|
+
* 因为「挑一个项目文件夹」本来就要能走到任意盘;真正的闸门是那道 token,
|
|
33
|
+
* 它才是「谁在跟我说话」的判据。这里防的是**畸形路径导致的意外文件操作**,
|
|
34
|
+
* 不是防已通过认证的用户去访问自己的电脑。
|
|
35
|
+
*/
|
|
36
|
+
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
37
|
+
import { homedir } from 'node:os'
|
|
38
|
+
import { join, parse, resolve, sep } from 'node:path'
|
|
39
|
+
|
|
40
|
+
/** 一次最多列多少个子目录。手机上再多也划不动,而且超大目录读起来很慢。 */
|
|
41
|
+
export const MAX_DIRS = 300
|
|
42
|
+
|
|
43
|
+
/** 「最近用过的目录」最多给几个。 */
|
|
44
|
+
export const MAX_RECENT = 8
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 目录名合不合法。口径照 DSH 自己的校验来,只多一条空字节:
|
|
48
|
+
* 空字节在 Windows 上会让底层调用抛一个很难看的错,早点挡住。
|
|
49
|
+
*
|
|
50
|
+
* 注意这里挡的是**名字**(单个目录名),不是路径——路径是另一回事,
|
|
51
|
+
* 它要允许 `I:\a\b` 这种带分隔符的形态。
|
|
52
|
+
*/
|
|
53
|
+
export function validDirName(name) {
|
|
54
|
+
return typeof name === 'string'
|
|
55
|
+
&& name.trim() !== ''
|
|
56
|
+
&& name !== '.'
|
|
57
|
+
&& name !== '..'
|
|
58
|
+
&& !/[/\\]/.test(name)
|
|
59
|
+
&& !name.includes('\0')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 规范化手机传来的路径。
|
|
64
|
+
*
|
|
65
|
+
* `resolve` 会把 `..` 和 `.` 算掉,所以 `I:\a\..\..\Windows` 会变成 `I:\Windows`——
|
|
66
|
+
* 这是**故意的**:我们不去猜用户想干什么,只保证拿去读的是一条干净的绝对路径。
|
|
67
|
+
* 畸形输入(不是字符串、空字节)直接拒绝,不试着修补。
|
|
68
|
+
*/
|
|
69
|
+
export function normalizePath(input) {
|
|
70
|
+
if (typeof input !== 'string') return null
|
|
71
|
+
const trimmed = input.trim()
|
|
72
|
+
if (trimmed === '' || trimmed.includes('\0')) return null
|
|
73
|
+
return resolve(trimmed)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 从根到目标的那一串祖先,做面包屑用。
|
|
78
|
+
*
|
|
79
|
+
* 纯字符串运算,不碰磁盘:面包屑要在目录读失败时照样画得出来,
|
|
80
|
+
* 不然用户会卡在一个空白页面上,连退都退不回去。
|
|
81
|
+
*/
|
|
82
|
+
export function ancestorsOf(full) {
|
|
83
|
+
const { root } = parse(full)
|
|
84
|
+
const out = [{ name: root, path: root }]
|
|
85
|
+
let cur = root
|
|
86
|
+
for (const part of full.slice(root.length).split(sep)) {
|
|
87
|
+
if (!part) continue
|
|
88
|
+
cur = join(cur, part)
|
|
89
|
+
out.push({ name: part, path: cur })
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 盘符列表(Windows)。
|
|
96
|
+
*
|
|
97
|
+
* 用 A–Z 逐个 stat 探,不 spawn 任何进程:探测本身很便宜,而且结果缓存一次——
|
|
98
|
+
* 盘符在一个会话里不会变,没必要每次开面板都探 26 次。
|
|
99
|
+
* 非 Windows 上只有一个根 `/`。
|
|
100
|
+
*/
|
|
101
|
+
let drivesCache = null
|
|
102
|
+
async function drivesOf() {
|
|
103
|
+
if (process.platform !== 'win32') return [sep]
|
|
104
|
+
if (drivesCache) return drivesCache
|
|
105
|
+
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
|
106
|
+
const found = await Promise.all(letters.map(async (letter) => {
|
|
107
|
+
const root = letter + ':' + sep
|
|
108
|
+
try {
|
|
109
|
+
const info = await stat(root)
|
|
110
|
+
return info.isDirectory() ? root : null
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// 没这个盘、或者是个空的光驱——都当作不存在。
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}))
|
|
116
|
+
drivesCache = found.filter(Boolean)
|
|
117
|
+
return drivesCache
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 常用位置。手机打开「新建工作区」时看到的第一屏。
|
|
122
|
+
*
|
|
123
|
+
* 起点为什么要给这些、而不是直接甩盘符:这个插件的门槛要低到非技术用户能走通。
|
|
124
|
+
* 从 `C:\` 一层层点进去找一个项目文件夹,是要人自己知道路径长什么样;
|
|
125
|
+
* 而「主目录 / 盘符 / 最近用过的」这三样,大多数时候点两下就到地方了。
|
|
126
|
+
*
|
|
127
|
+
* `recent` 由调用方给(我们拿的是已登记工作区的路径)——那些正是「他用过的目录」,
|
|
128
|
+
* 而且不用我们另外记一份「最近打开过什么」的状态。
|
|
129
|
+
*/
|
|
130
|
+
export async function listRoots({ recent = [] } = {}) {
|
|
131
|
+
const home = homedir()
|
|
132
|
+
const seen = new Set([home.toLowerCase()])
|
|
133
|
+
const recentOut = []
|
|
134
|
+
for (const p of recent) {
|
|
135
|
+
if (typeof p !== 'string' || !p) continue
|
|
136
|
+
const key = p.toLowerCase()
|
|
137
|
+
if (seen.has(key)) continue
|
|
138
|
+
seen.add(key)
|
|
139
|
+
recentOut.push(p)
|
|
140
|
+
if (recentOut.length >= MAX_RECENT) break
|
|
141
|
+
}
|
|
142
|
+
return { home, drives: await drivesOf(), recent: recentOut }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 列一个目录下的**子目录**。
|
|
147
|
+
*
|
|
148
|
+
* 读不出来时**如实抛错**,不吞掉当作空目录:空目录和「没有权限读」在界面上
|
|
149
|
+
* 长得一模一样,而后者用户需要知道(他可能是选错了地方,也可能是权限问题)。
|
|
150
|
+
*/
|
|
151
|
+
export async function listDirectory(input) {
|
|
152
|
+
const full = normalizePath(input)
|
|
153
|
+
if (!full) return { ok: false, reason: 'bad-path' }
|
|
154
|
+
|
|
155
|
+
let entries
|
|
156
|
+
try {
|
|
157
|
+
entries = await readdir(full, { withFileTypes: true })
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return { ok: false, reason: 'unreadable', error: String(err?.code ?? err?.message ?? err) }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const dirs = []
|
|
163
|
+
const links = []
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
if (entry.isDirectory()) dirs.push(entry.name)
|
|
166
|
+
// 符号链接 / 目录联接自己不说自己通向哪儿,得问一次 stat。
|
|
167
|
+
// 只对链接做这一步:普通条目已经在 dirent 里带类型了,不必每条都 stat。
|
|
168
|
+
else if (entry.isSymbolicLink()) links.push(entry.name)
|
|
169
|
+
}
|
|
170
|
+
for (const name of links) {
|
|
171
|
+
try {
|
|
172
|
+
const info = await stat(join(full, name))
|
|
173
|
+
if (info.isDirectory()) dirs.push(name)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// 断掉的链接:跳过,不是错误。
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 排序要**不区分大小写**:Windows 的文件名本来就不区分大小写,
|
|
180
|
+
// 按码点排会把所有大写开头的目录全排到前面,看起来像乱的。
|
|
181
|
+
dirs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
|
182
|
+
|
|
183
|
+
const truncated = dirs.length > MAX_DIRS
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
path: full,
|
|
187
|
+
ancestors: ancestorsOf(full),
|
|
188
|
+
dirs: dirs.slice(0, MAX_DIRS).map((name) => ({ name, path: join(full, name) })),
|
|
189
|
+
total: dirs.length,
|
|
190
|
+
truncated,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 在一个目录下新建一个子目录。
|
|
196
|
+
*
|
|
197
|
+
* **`name` 必须是单个合法目录名**(见 validDirName),不接受路径:手机传来的东西
|
|
198
|
+
* 当不可信处理,一个带 `..` 或分隔符的「名字」能让新建落到别的地方去。
|
|
199
|
+
*
|
|
200
|
+
* 不用 `recursive: true`:父目录必须已经存在,那是调用方选中的地方。
|
|
201
|
+
* 加了 recursive 反而会把「父目录没了」这种真实错误悄悄糊过去。
|
|
202
|
+
*/
|
|
203
|
+
export async function makeDirectory(parentInput, name) {
|
|
204
|
+
if (!validDirName(name)) return { ok: false, reason: 'bad-name' }
|
|
205
|
+
const parent = normalizePath(parentInput)
|
|
206
|
+
if (!parent) return { ok: false, reason: 'bad-path' }
|
|
207
|
+
|
|
208
|
+
const target = join(parent, name)
|
|
209
|
+
try {
|
|
210
|
+
await mkdir(target)
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (err?.code === 'EEXIST') return { ok: false, reason: 'exists' }
|
|
213
|
+
return { ok: false, reason: 'failed', error: String(err?.code ?? err?.message ?? err) }
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, path: target }
|
|
216
|
+
}
|
package/lib/commands.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 斜杠指令:手机作为一个「发起指令的界面」,问 DSH 自己的指令账本。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不是自己实现一套:指令是别的插件注册到宿主上的(`ctx.commands.register`),
|
|
5
|
+
* 手机端不该也不可能重写它们。DSH 把「注册 / 列出 / 执行」放在 `ctx.commands` 服务里,
|
|
6
|
+
* 电脑上的网页端走的是同一个服务(它经过 remote 那一层),所以这里**照抄 DSH 的语义**,
|
|
7
|
+
* 只是换了调用它的地方——手机上装了新插件带来的指令,会自动出现在手机列表里。
|
|
8
|
+
*
|
|
9
|
+
* 这个文件里放的都是**纯逻辑**(`test/commands.test.mjs` 直接测),
|
|
10
|
+
* 真正碰服务的地方在 index.js。
|
|
11
|
+
*
|
|
12
|
+
* 三条来自 DSH 文档与类型定义的事实(不要自己发明):
|
|
13
|
+
* ① 指令行的语法:**第 0 个字节必须是斜杠**,接着是小写名字(字母、数字、`_`、`-`),
|
|
14
|
+
* 再往后要么结束、要么是空白;名字之后的每个字节(**包括那个分隔空格**)都是
|
|
15
|
+
* `rawInput`,由指令自己解释。不满足这个形状、或者名字不认识的,**拒绝,不当成提示词**。
|
|
16
|
+
* ② 执行会往会话记录里追加一对日志事件:`command/run`(`commandId`、`name`、`args`)
|
|
17
|
+
* 和 `command/done`(`commandId`、`kind`、`text`),靠 `commandId` 配对。
|
|
18
|
+
* 指令**不产生模型消息**——所以它既不该长成用户气泡,也不该被当成 AI 的回答。
|
|
19
|
+
* ③ 描述符(`list()` 的返回)是「给发现界面看的」:`name`、`description`,
|
|
20
|
+
* 外加可选的 `input.hint`(要参数时给用户看的一句话)和 `input.attachments`。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* DSH 自己的指令行正则——**逐字照抄** dsh-commands 里 `parseCommand` 用的那条
|
|
25
|
+
* (`/^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u`)。
|
|
26
|
+
*
|
|
27
|
+
* 照抄而不是"写个差不多的":名字必须以**小写字母**开头(`/9x`、`/_x` 都不是指令),
|
|
28
|
+
* 后面的空白集是 `\t\n\r ` 这四个(不是 `\s`)。差一个字符,手机和电脑就会对
|
|
29
|
+
* "这行算不算指令"给出不同答案,而这种分歧极难被发现。
|
|
30
|
+
*/
|
|
31
|
+
const COMMAND_RE = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 按 DSH 的语法拆一行指令。
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} line
|
|
37
|
+
* @returns {{name:string, rawInput:string}|null} 不是指令行 → null
|
|
38
|
+
*/
|
|
39
|
+
export function parseSlashLine(line) {
|
|
40
|
+
if (typeof line !== 'string') return null
|
|
41
|
+
const m = COMMAND_RE.exec(line)
|
|
42
|
+
if (!m) return null
|
|
43
|
+
// 名字之后**原样**保留,包括那个分隔空格——这是 DSH 的口径(`rawInput` 含前导空白),
|
|
44
|
+
// 指令自己拥有这段语法,我们不许替它 trim。
|
|
45
|
+
return { name: m[1], rawInput: line.slice(m[0].length) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 电脑网页端专有、宿主**根本没有**注册的两条指令。
|
|
50
|
+
*
|
|
51
|
+
* 这不是我们编的:`/model` 和 `/file` 是浏览器端自己的贡献(`ctx.commandUi.register`),
|
|
52
|
+
* 宿主目录里没有它们(2026-09-25 查证)。手机上打这两条,`find()` 必然落空——
|
|
53
|
+
* 如果只说一句「没有这条指令」,用户会以为是自己打错了,可电脑上明明有。
|
|
54
|
+
*/
|
|
55
|
+
const BROWSER_ONLY = {
|
|
56
|
+
model: '这是电脑网页端专有的指令。手机上是点顶栏那一行换模型。',
|
|
57
|
+
file: '这是电脑网页端专有的指令。手机上用输入框旁边的回形针传文件。',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 这行连指令的形状都不是(比如 `/9x`、`/Model`——DSH 的名字必须以小写字母开头)。 */
|
|
61
|
+
export function invalidCommandMessage() {
|
|
62
|
+
return '这不是一条指令。指令要以 / 开头,名字只能用小写字母、数字、- 和 _。'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 形状对,但账本里没有这个名字。 */
|
|
66
|
+
export function unknownCommandMessage(name) {
|
|
67
|
+
const special = BROWSER_ONLY[name]
|
|
68
|
+
if (special) return `/${name} 不是这台电脑上的指令:${special}`
|
|
69
|
+
return `没有 /${name} 这条指令。`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把 `list()` 给的描述符翻成手机要的那几样,并按名字排序。
|
|
74
|
+
*
|
|
75
|
+
* 只搬手机用得上的字段:多搬字段等于替未来的自己许下承诺,DSH 那边加字段就会漂。
|
|
76
|
+
*
|
|
77
|
+
* @param {unknown} descriptors
|
|
78
|
+
*/
|
|
79
|
+
export function describeCommands(descriptors) {
|
|
80
|
+
const rows = []
|
|
81
|
+
for (const d of Array.isArray(descriptors) ? descriptors : []) {
|
|
82
|
+
const name = typeof d?.name === 'string' ? d.name : ''
|
|
83
|
+
if (!name) continue
|
|
84
|
+
const hint = typeof d?.input?.hint === 'string' && d.input.hint ? d.input.hint : null
|
|
85
|
+
rows.push({
|
|
86
|
+
name,
|
|
87
|
+
description: typeof d?.description === 'string' ? d.description : '',
|
|
88
|
+
hint,
|
|
89
|
+
// 「这条指令收不收附件」。手机这一版不带附件执行,但把实情告诉用户,
|
|
90
|
+
// 总比让他对着一条声明了要附件的指令发呆强。
|
|
91
|
+
attachments: d?.input?.attachments === true,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
95
|
+
return rows
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `command/run` → 一条「正在执行」的记录。
|
|
100
|
+
*
|
|
101
|
+
* 时间戳用**事件自己的 `time`**,不用 `Date.now()`:手机上那份历史和硬盘重放出来的
|
|
102
|
+
* 那份要靠时间戳合成(见 store.js 的 mergeHistory),两边用同一个钟才不会接重。
|
|
103
|
+
*/
|
|
104
|
+
export function commandRow(event) {
|
|
105
|
+
const data = event?.data
|
|
106
|
+
if (!data || typeof data.commandId !== 'string' || typeof data.name !== 'string') return null
|
|
107
|
+
return {
|
|
108
|
+
role: 'command',
|
|
109
|
+
commandId: data.commandId,
|
|
110
|
+
name: data.name,
|
|
111
|
+
args: typeof data.args === 'string' ? data.args : '',
|
|
112
|
+
kind: 'running',
|
|
113
|
+
text: null,
|
|
114
|
+
timestamp: typeof event?.time === 'number' ? event.time : null,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `command/done` → 用来给上面那条收尾的那几个字段。 */
|
|
119
|
+
export function commandPatch(event) {
|
|
120
|
+
const data = event?.data
|
|
121
|
+
if (!data || typeof data.commandId !== 'string') return null
|
|
122
|
+
return {
|
|
123
|
+
commandId: data.commandId,
|
|
124
|
+
kind: data.kind === 'error' ? 'error' : 'success',
|
|
125
|
+
text: typeof data.text === 'string' && data.text ? data.text : null,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 把两个指令事件折进一串记录里——**重放和实时共用这一个映射**。
|
|
131
|
+
*
|
|
132
|
+
* 和 events.js 的提取器是同一个道理:两条路各写一套配对逻辑的话,
|
|
133
|
+
* 迟早一边显示「执行中」、一边显示结果,或者干脆配对错行。
|
|
134
|
+
*
|
|
135
|
+
* @param {Array<object>} rows 就地修改
|
|
136
|
+
* @param {{type?:string, time?:number, data?:any}} event
|
|
137
|
+
* @returns {boolean} 这个事件是不是指令事件(是就表示已被处理)
|
|
138
|
+
*/
|
|
139
|
+
export function foldCommandEvent(rows, event) {
|
|
140
|
+
if (event?.type === 'command/run') {
|
|
141
|
+
const row = commandRow(event)
|
|
142
|
+
if (row) rows.push(row)
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
if (event?.type === 'command/done') {
|
|
146
|
+
const patch = commandPatch(event)
|
|
147
|
+
if (!patch) return true
|
|
148
|
+
// 从后往前找:一次会话里同时跑两条同名指令也可能,认 commandId 才稳。
|
|
149
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
150
|
+
if (rows[i]?.role === 'command' && rows[i].commandId === patch.commandId) {
|
|
151
|
+
rows[i].kind = patch.kind
|
|
152
|
+
rows[i].text = patch.text
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// 只看见 done、没看见 run(窗口从中间截断、或者插件是中途起来的):
|
|
157
|
+
// 补一条没有名字的记录,**不能丢**——用户会看到结果却不知道是哪条指令。
|
|
158
|
+
// name 留空,界面按「指令」显示即可。这条只在重放里可能出现。
|
|
159
|
+
rows.push({
|
|
160
|
+
role: 'command', commandId: patch.commandId, name: '', args: '',
|
|
161
|
+
kind: patch.kind, text: patch.text, timestamp: typeof event?.time === 'number' ? event.time : null,
|
|
162
|
+
})
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把一个会话**已经跑完的历史**重放成手机上要显示的那一串。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:手机能点开**全部**会话(一个工作区里常有上百条),而插件手里那份
|
|
5
|
+
* 记录只有「它在场时看见过的事件」——重启前跑过的、在电脑上开的会话,点进去是空的。
|
|
6
|
+
* 用户 2026-09-25 提的要求是:点开一个会话,就该看到它之前的每一轮。
|
|
7
|
+
*
|
|
8
|
+
* 数据来源是 DSH 自己的会话记录(`sessionQuery.readSession`,见 index.js),
|
|
9
|
+
* 这里只负责把那一串原始事件**重放**成聊天记录。
|
|
10
|
+
*
|
|
11
|
+
* **关键:提取器必须和实时那条路是同一个**(`events.js` 的 `createTurnTracker`)。
|
|
12
|
+
* 两条路各写一套判断的话,「哪条才算最终回答」迟早会走散——一边显示旁白、
|
|
13
|
+
* 一边显示回答,用户看到的就是同一个会话在两个地方长得不一样。
|
|
14
|
+
*/
|
|
15
|
+
import { createTurnTracker } from './events.js'
|
|
16
|
+
import { foldCommandEvent } from './commands.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array<{type:string, time?:number, data?:any}>} events DSH 的原始事件,**按发生顺序**。
|
|
20
|
+
* @returns {Array<{role:'user'|'assistant'|'command', text?:string|null, timestamp:number|null,
|
|
21
|
+
* id?:string|null, reason?:string, interrupted?:boolean,
|
|
22
|
+
* commandId?:string, name?:string, args?:string, kind?:string}>}
|
|
23
|
+
*/
|
|
24
|
+
export function replayHistory(events) {
|
|
25
|
+
const tracker = createTurnTracker()
|
|
26
|
+
const out = []
|
|
27
|
+
for (const event of events ?? []) {
|
|
28
|
+
// 斜杠指令(`command/run` / `command/done`)不是「一轮」,提取器不认它——
|
|
29
|
+
// 可它确实写在会话记录里,电脑端也确实画在正文里。用和实时那条路**同一个**
|
|
30
|
+
// 折叠函数(lib/commands.js)接住,两边才不会一个显示「执行中」、一个显示结果。
|
|
31
|
+
if (foldCommandEvent(out, event)) continue
|
|
32
|
+
// 会话 id 在这里没有意义:一个会话的日志只会喂给一个 tracker,
|
|
33
|
+
// 传个固定值就行,不必把 id 一路带进来。
|
|
34
|
+
const action = tracker.feed('replay', event)
|
|
35
|
+
if (!action) continue
|
|
36
|
+
const timestamp = typeof event?.time === 'number' ? event.time : null
|
|
37
|
+
if (action.kind === 'user') {
|
|
38
|
+
out.push({ role: 'user', text: action.text, id: action.id ?? null, timestamp })
|
|
39
|
+
} else {
|
|
40
|
+
out.push({
|
|
41
|
+
role: 'assistant',
|
|
42
|
+
text: action.text,
|
|
43
|
+
timestamp,
|
|
44
|
+
reason: action.reason,
|
|
45
|
+
interrupted: action.interrupted === true,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 从重放出来的记录里取最后一条最终回复,做成单帧模式要的那个形状。
|
|
54
|
+
*
|
|
55
|
+
* 返回 null 表示「这个会话还没有过最终回复」——那时单帧模式该显示空,
|
|
56
|
+
* **不能拿用户自己那句话顶上**,也不能拿旁白顶上。
|
|
57
|
+
*/
|
|
58
|
+
export function lastReply(entries, sessionId) {
|
|
59
|
+
for (let i = (entries?.length ?? 0) - 1; i >= 0; i--) {
|
|
60
|
+
const m = entries[i]
|
|
61
|
+
if (m.role !== 'assistant') continue
|
|
62
|
+
return {
|
|
63
|
+
text: m.text,
|
|
64
|
+
sessionId,
|
|
65
|
+
title: '',
|
|
66
|
+
timestamp: m.timestamp,
|
|
67
|
+
reason: m.reason,
|
|
68
|
+
interrupted: m.interrupted === true,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|