dsh-mini-remote 0.1.0 → 0.1.4
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 +82 -0
- package/README.en.md +160 -0
- package/README.md +81 -59
- package/client/client.js +148 -1
- package/lib/art/work-9-talking.webp +0 -0
- package/lib/browse.js +216 -0
- package/lib/commands.js +166 -0
- package/lib/events.js +52 -0
- package/lib/history.js +72 -0
- package/lib/index.js +801 -12
- package/lib/log-tail.js +235 -0
- package/lib/page.html +1699 -36
- package/lib/page.js +21 -0
- package/lib/pairing.js +110 -14
- package/lib/permissions.js +165 -0
- package/lib/serve.js +365 -0
- package/lib/server.js +465 -6
- package/lib/store.js +191 -8
- package/lib/sw.js +39 -0
- package/lib/tree.js +145 -3
- package/package.json +15 -6
- package/README.zh.md +0 -138
- package/client/verify.mjs +0 -514
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 } from './page.js'
|
|
16
|
+
import { renderPage, readArt, readSw } from './page.js'
|
|
17
17
|
|
|
18
18
|
const COOKIE = 'dsh_mini_token'
|
|
19
19
|
|
|
@@ -143,19 +143,60 @@ export function sendJson(res, status, payload) {
|
|
|
143
143
|
* @param {{port:number, defaultMode:string}} opts.config
|
|
144
144
|
* @param {string[]} opts.bindAddresses 要监听的地址;第一个应是 127.0.0.1
|
|
145
145
|
* @param {string} opts.token
|
|
146
|
+
*/
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// 插件主体取用「推提问给手机」「手机还在不在」这两个能力的地方。
|
|
149
|
+
//
|
|
150
|
+
// **为什么不靠 createMiniServer 的回执**:实测那句 `mini.server = createMiniServer(…)`
|
|
151
|
+
// 拿到的凭据兑现出来是 `undefined`(仪表原样打出来的),而这两个能力确实写在了函数
|
|
152
|
+
// 末尾的返回对象里。回执为什么是空的还没查清,但插件真正需要的只是这两个功能能用——
|
|
153
|
+
// 所以改成服务启动时**主动把能力放到这个固定位置**,插件直接来取,不经过回执那一道。
|
|
154
|
+
// 这样无论回执是空的还是满的,功能都不受影响。
|
|
155
|
+
//
|
|
156
|
+
// 做成模块级单例:一个进程里只会起一个遥控服务(测试里会起多个,后起的覆盖先起的;
|
|
157
|
+
// 有一条用例会临时替换这两个字段来验提问钩子,用完就还回去)。
|
|
158
|
+
export const miniControl = {
|
|
159
|
+
askPhone: null,
|
|
160
|
+
hasPhone: null,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {object} opts
|
|
146
165
|
* @param {() => string | null} [opts.tunnelHost] 当前公网域名(没开隧道时返回 null),只用于限速分桶
|
|
147
166
|
* @param {(text:string)=>Promise<{ok:boolean, error?:string, sessionId?:string}>} opts.onInstruction
|
|
167
|
+
* @param {()=>({ok:boolean, error?:string, commands?:object[]})} [opts.onCommands] 列这台电脑上这个会话能用的斜杠指令
|
|
168
|
+
* @param {(line:string)=>Promise<{ok:boolean, error?:string}>} [opts.onCommand] 执行一条斜杠指令
|
|
148
169
|
* @param {()=>Promise<{ok:boolean, error?:string, sessionId?:string}>|{ok:boolean, error?:string, sessionId?:string}} opts.onStop
|
|
149
170
|
* @param {(id:string)=>{ok:boolean, error?:string, sessionId?:string}} opts.onUnqueue
|
|
150
171
|
* @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void}>}
|
|
151
172
|
*/
|
|
152
|
-
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onStop, onUnqueue, onUpload, log, tree, build }) {
|
|
173
|
+
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build }) {
|
|
153
174
|
/** @type {Set<import('node:http').ServerResponse>} */
|
|
154
175
|
const clients = new Set()
|
|
155
176
|
|
|
156
177
|
// 没传 tree(测试里就是这样,headless 组合里也拿不到那两个服务)时给个空壳,
|
|
157
178
|
// 导航栏显示「取不到」,遥控本身照常。
|
|
158
|
-
|
|
179
|
+
// `createSession` 的空壳回 `no-controller`,跟真服务缺位时走同一条错误路径——
|
|
180
|
+
// 这样「没这个能力」只有一种说法,不用分两处处理。
|
|
181
|
+
const nav = tree ?? {
|
|
182
|
+
listWorkspaces: async () => [],
|
|
183
|
+
listSessionsOf: async () => null,
|
|
184
|
+
createSession: async () => ({ ok: false, reason: 'no-controller' }),
|
|
185
|
+
createWorkspace: async () => ({ ok: false, reason: 'no-registry' }),
|
|
186
|
+
permissions: async () => ({ ok: false, reason: 'no-service' }),
|
|
187
|
+
setPermission: async () => ({ ok: false, reason: 'no-service' }),
|
|
188
|
+
// 模型那两个的**空壳**:测试里没有真的 DSH,照上面的样式如实说"没这个能力"。
|
|
189
|
+
modelCatalog: async () => ({ ok: false, reason: 'unavailable' }),
|
|
190
|
+
selectModel: async () => ({ ok: false, reason: 'unavailable' }),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 目录浏览的空壳。它**只依赖 node:fs**,不像 tree 那样依赖 DSH 的服务,
|
|
194
|
+
// 所以正常情况下永远用真的那个;这个空壳是给测试用的(测试不该真去读盘)。
|
|
195
|
+
const files = browse ?? {
|
|
196
|
+
listRoots: async () => ({ home: '', drives: [], recent: [] }),
|
|
197
|
+
listDirectory: async () => ({ ok: false, reason: 'unavailable' }),
|
|
198
|
+
makeDirectory: async () => ({ ok: false, reason: 'unavailable' }),
|
|
199
|
+
}
|
|
159
200
|
|
|
160
201
|
function broadcast(event, data) {
|
|
161
202
|
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
@@ -168,6 +209,71 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
168
209
|
}
|
|
169
210
|
}
|
|
170
211
|
|
|
212
|
+
// ---- 手机上答题 ------------------------------------------------------
|
|
213
|
+
//
|
|
214
|
+
// **这两样必须放在这一层**(和 `clients`、`broadcast` 同一层,全程只建一次)。
|
|
215
|
+
// 我第一版把它们写在了下面路由那一堆里——那是「每来一个请求就执行一次」的地方,
|
|
216
|
+
// 于是推送提问时放进去的清单、和收答案时去读的清单**是两个不同的清单**,
|
|
217
|
+
// 答案永远对不上、功能静默失效。放错一层就会这样,所以挪回来。
|
|
218
|
+
const waitingQuestions = new Map()
|
|
219
|
+
let questionSeq = 0
|
|
220
|
+
|
|
221
|
+
/** 手机上有没有人在看。**这就是「手机还在不在」那条判据**,不倒计时。 */
|
|
222
|
+
function hasPhone() {
|
|
223
|
+
return clients.size > 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 把这个提问推到手机上,等它答。
|
|
228
|
+
* @returns 答案对象;手机没了就返回 null,调用方据此**把问题还给电脑**。
|
|
229
|
+
*/
|
|
230
|
+
function askPhone(questions, agentId) {
|
|
231
|
+
const id = 'q' + (++questionSeq)
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
let watch = null
|
|
234
|
+
const settle = (value) => {
|
|
235
|
+
if (!waitingQuestions.has(id)) return
|
|
236
|
+
waitingQuestions.delete(id)
|
|
237
|
+
if (watch) clearInterval(watch)
|
|
238
|
+
resolve(value)
|
|
239
|
+
}
|
|
240
|
+
// 题目本身也存进来(不只是 settle):手机中途重连上来时要能补发给它,见 replayQuestions。
|
|
241
|
+
waitingQuestions.set(id, { settle, agentId, questions })
|
|
242
|
+
broadcast('question', { id, agentId, questions })
|
|
243
|
+
// 用户选的是「一直等,不还回电脑」——但那不等于「手机都没了还等」。
|
|
244
|
+
// 手机一旦全断开(锁屏、关了页面、网断),立刻收摊还给电脑,否则会永远卡住。
|
|
245
|
+
watch = setInterval(() => {
|
|
246
|
+
if (clients.size === 0) settle(null)
|
|
247
|
+
}, 2000)
|
|
248
|
+
})
|
|
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
|
+
|
|
272
|
+
// 把这两个能力放到插件够得着的固定位置(见文件上方 `miniControl` 的说明)。
|
|
273
|
+
// 放在这里是因为它俩都已定义(函数声明本来也会提升),且不受下面那堆路由逻辑影响。
|
|
274
|
+
miniControl.hasPhone = hasPhone
|
|
275
|
+
miniControl.askPhone = askPhone
|
|
276
|
+
|
|
171
277
|
function isAuthorized(req, url) {
|
|
172
278
|
const fromQuery = url.searchParams.get('token')
|
|
173
279
|
const fromCookie = parseCookies(req.headers.cookie)[COOKIE]
|
|
@@ -288,6 +394,38 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
288
394
|
return
|
|
289
395
|
}
|
|
290
396
|
|
|
397
|
+
// ---- service worker -------------------------------------------------
|
|
398
|
+
// 它只为系统通知而存在(见 lib/sw.js)。
|
|
399
|
+
//
|
|
400
|
+
// **这一条不鉴权,是故意的。** 别处都鉴权,这里不行,理由是时序:service worker
|
|
401
|
+
// 必须在页面加载时就注册,而 token 要等用户过了门禁页才知道(从门禁页手输进来
|
|
402
|
+
// 时连 cookie 都没有,见上面立绘那段)。要是在这儿卡一道 token,那所有手输 token
|
|
403
|
+
// 的用户都会注册失败——而失败只在浏览器控制台里报,表现就是「开关开了但永远收不到
|
|
404
|
+
// 通知」,最难查的那种。
|
|
405
|
+
//
|
|
406
|
+
// 代价是零:这个文件里没有 token、没有用户数据、对所有人都是同一份。
|
|
407
|
+
//
|
|
408
|
+
// **`Service-Worker-Allowed: /` 这一行不能省。** 页面在 `/mini`,不在 `/mini/`
|
|
409
|
+
// 底下,而这个文件从 `/mini/sw.js` 发出去,默认作用域只到 `/mini/`——够不着那个
|
|
410
|
+
// 页面,注册会直接失败。放开到根,它才管得着。
|
|
411
|
+
if (req.method === 'GET' && path === '/mini/sw.js') {
|
|
412
|
+
const src = readSw()
|
|
413
|
+
if (!src) {
|
|
414
|
+
sendJson(res, 404, { error: 'not found' })
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const buf = Buffer.from(src, 'utf8')
|
|
418
|
+
res.writeHead(200, {
|
|
419
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
420
|
+
'Content-Length': buf.length,
|
|
421
|
+
'Service-Worker-Allowed': '/',
|
|
422
|
+
// 地址里带着构建指纹,内容不会在同一个地址下变化,可以放心长缓存。
|
|
423
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
424
|
+
})
|
|
425
|
+
res.end(buf)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
291
429
|
// ---- 其余一律要鉴权 -------------------------------------------------
|
|
292
430
|
if (!path.startsWith('/mini/api/')) {
|
|
293
431
|
sendJson(res, 404, { error: 'not found' })
|
|
@@ -311,6 +449,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
311
449
|
})
|
|
312
450
|
res.write(': connected\n\n')
|
|
313
451
|
clients.add(res)
|
|
452
|
+
// 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
|
|
453
|
+
// 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
|
|
454
|
+
console.log(`[dsh-mini-remote] 手机连上了,现在连着 ${clients.size} 台`)
|
|
314
455
|
|
|
315
456
|
const beat = setInterval(() => {
|
|
316
457
|
try {
|
|
@@ -325,9 +466,21 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
325
466
|
// 连上就先补一次全量状态,手机刷新后不用再单独拉一次。
|
|
326
467
|
res.write(`event: state\ndata: ${JSON.stringify(store.snapshot())}\n\n`)
|
|
327
468
|
|
|
469
|
+
// 接着把还在等手机回答的题补给它——重连上来的那一下最容易错过(见 replayQuestions)。
|
|
470
|
+
replayQuestions(res)
|
|
471
|
+
|
|
472
|
+
// 顺带把这个会话的完整历史准备好:手机上「刷新一下就只剩重启之后的事」
|
|
473
|
+
// 就是这么来的(手里那份只有插件在场时看见的)。读完会再广播一份快照。
|
|
474
|
+
onSessionOpened?.(store.snapshot().boundSessionId)
|
|
475
|
+
|
|
476
|
+
// 断开只记一次:req 和 res 各会 close 一次,不挡一下就会打两遍(含义却相反)。
|
|
477
|
+
let said = false
|
|
328
478
|
const cleanup = () => {
|
|
479
|
+
if (said) return
|
|
480
|
+
said = true
|
|
329
481
|
clearInterval(beat)
|
|
330
482
|
clients.delete(res)
|
|
483
|
+
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`)
|
|
331
484
|
}
|
|
332
485
|
req.on('close', cleanup)
|
|
333
486
|
res.on('close', cleanup)
|
|
@@ -335,13 +488,28 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
335
488
|
}
|
|
336
489
|
|
|
337
490
|
if (req.method === 'GET' && path === '/mini/api/state') {
|
|
338
|
-
|
|
491
|
+
const snap = store.snapshot()
|
|
492
|
+
// 页面刷新、重新打开:也该看到当前会话的完整历史,而不是只有重启之后的事。
|
|
493
|
+
onSessionOpened?.(snap.boundSessionId)
|
|
494
|
+
sendJson(res, 200, snap)
|
|
339
495
|
return
|
|
340
496
|
}
|
|
341
497
|
|
|
342
498
|
// 构建指纹。重启期间旧进程还在应答,这个接口用来确认「跑的是不是新代码」。
|
|
499
|
+
//
|
|
500
|
+
// `canCreateSession` 是**运行时**事实,不是配置推断:它问的是「这一刻 DSH 真的
|
|
501
|
+
// 把会话服务给我们了吗」。配置里挂了 `dsh-api-session-controller` 只说明它该在,
|
|
502
|
+
// 而服务可能因为组合不同、启动顺序、加载失败而没来——那种情况下手机上的 +
|
|
503
|
+
// 会一直报「没提供这个能力」,光看代码是查不出来的。放这儿就能一条命令问清楚。
|
|
343
504
|
if (req.method === 'GET' && path === '/mini/api/version') {
|
|
344
|
-
|
|
505
|
+
let canCreateSession = false
|
|
506
|
+
try {
|
|
507
|
+
canCreateSession = nav.canCreateSession?.() === true
|
|
508
|
+
} catch (err) {
|
|
509
|
+
// 探测本身不该把接口带崩:问不到就当没有。
|
|
510
|
+
canCreateSession = false
|
|
511
|
+
}
|
|
512
|
+
sendJson(res, 200, { ok: true, build: build ?? 'unknown', canCreateSession })
|
|
345
513
|
return
|
|
346
514
|
}
|
|
347
515
|
|
|
@@ -357,13 +525,34 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
357
525
|
return
|
|
358
526
|
}
|
|
359
527
|
|
|
528
|
+
// ---- 斜杠指令列表 ----------------------------------------------------
|
|
529
|
+
// 手机在输入框里打 `/` 时要显示的那一份。**名单来自 DSH 自己的指令账本**,
|
|
530
|
+
// 所以电脑上新装一个带指令的插件,手机上刷新就有了,不用改这个插件。
|
|
531
|
+
//
|
|
532
|
+
// 拿不到的时候如实说为什么(会话没在跑 / 这台电脑没有指令服务),
|
|
533
|
+
// 让手机显示那句话——**不能回一个空列表**:空列表的意思是「一条指令都没有」,
|
|
534
|
+
// 那是另一回事,用户会以为自己的 `/compact` 打错了。
|
|
535
|
+
if (req.method === 'GET' && path === '/mini/api/commands') {
|
|
536
|
+
if (!onCommands) {
|
|
537
|
+
sendJson(res, 503, { ok: false, error: '这台电脑上的 DSH 没提供指令。' })
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
const result = onCommands()
|
|
541
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
|
|
360
545
|
// 左侧导航栏:先列工作区(便宜),展开某个再取它的会话(要读日志折标题,贵)。
|
|
361
546
|
// `refresh=1` 是导航栏那个 ⟳ 按钮用的:绕过服务端缓存,真去读一遍。
|
|
362
547
|
if (req.method === 'GET' && path === '/mini/api/workspaces') {
|
|
363
548
|
const runningIds = new Set(
|
|
364
549
|
(store.snapshot().sessions ?? []).filter((s) => s.running).map((s) => s.id),
|
|
365
550
|
)
|
|
366
|
-
|
|
551
|
+
// includeEmpty:手机能自己新建工作区了,刚建好的那个本来就是空的。
|
|
552
|
+
// 不列出来的话,建完它当场消失,用户只会以为没建成。
|
|
553
|
+
const workspaces = await nav.listWorkspaces(
|
|
554
|
+
runningIds, url.searchParams.get('refresh') === '1', true,
|
|
555
|
+
)
|
|
367
556
|
sendJson(res, 200, { ok: true, workspaces })
|
|
368
557
|
return
|
|
369
558
|
}
|
|
@@ -385,6 +574,147 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
385
574
|
return
|
|
386
575
|
}
|
|
387
576
|
|
|
577
|
+
// ---- 权限档位(作用在当前绑定的那个会话上)--------------------------
|
|
578
|
+
// 拿不到服务时**如实说没这个能力**,手机据此整块不显示。
|
|
579
|
+
// 不伪装成「只有一档」——那会让用户以为自己的权限被降过。
|
|
580
|
+
if (req.method === 'GET' && path === '/mini/api/permissions') {
|
|
581
|
+
const boundSessionId = store.snapshot().boundSessionId
|
|
582
|
+
const result = await nav.permissions(boundSessionId)
|
|
583
|
+
sendJson(res, 200, { boundSessionId, ...result })
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---- 改模型 / 改思考强度 ---------------------------------------------
|
|
588
|
+
// 作用在当前绑定的那个会话上,和权限档位同一个口径。
|
|
589
|
+
//
|
|
590
|
+
// 强度**可以不传**——那表示「用这个模型自己的默认」。我们不替它猜一个值:
|
|
591
|
+
// 猜错了用户会以为那是自己选的,而真正生效的是别的档位。
|
|
592
|
+
//
|
|
593
|
+
// 强度和模型必须**一起**交给 DSH:`ModelSelection` 里强度是模型选择的一部分,
|
|
594
|
+
// 不是一项独立设置。分两次改会出现「模型换了、强度还停在旧模型那一档」的中间态。
|
|
595
|
+
if (req.method === 'POST' && path === '/mini/api/set-model') {
|
|
596
|
+
const boundSessionId = store.snapshot().boundSessionId
|
|
597
|
+
const body = await readBody(req)
|
|
598
|
+
const result = await nav.selectModel(boundSessionId, {
|
|
599
|
+
provider: body?.provider,
|
|
600
|
+
model: body?.model,
|
|
601
|
+
...(body?.reasoningEffort ? { reasoningEffort: body.reasoningEffort } : {}),
|
|
602
|
+
})
|
|
603
|
+
if (!result.ok) {
|
|
604
|
+
sendJson(res, 503, { ok: false, error: '这台电脑上的 DSH 没提供改模型的能力。' })
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
// 把改完的值就手记到这个会话上。
|
|
608
|
+
//
|
|
609
|
+
// 为什么非记不可:DSH 那边要等这个会话**下一次发请求**才会把新模型报出来
|
|
610
|
+
// (request/header 事件),中间这段时间插件手里还是旧值。不补这一笔,用户
|
|
611
|
+
// 改完切走再切回来就会看到旧模型——那是他自己刚改的,等于界面自己打自己的脸。
|
|
612
|
+
const sel = result.selected
|
|
613
|
+
if (sel && typeof sel.model === 'string') {
|
|
614
|
+
store.touchSession(boundSessionId, {
|
|
615
|
+
provider: typeof sel.provider === 'string' ? sel.provider : null,
|
|
616
|
+
model: sel.model,
|
|
617
|
+
reasoningEffort: typeof sel.reasoningEffort === 'string' ? sel.reasoningEffort : null,
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
sendJson(res, 200, { ok: true, boundSessionId, selected: result.selected })
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- 模型与思考强度(作用在当前绑定的那个会话上)----------------------
|
|
625
|
+
// 目录里带着每个模型**自己声明**支持哪几档强度(`reasoning`)。手机照着它长界面:
|
|
626
|
+
// 不支持调强度的模型,那一行就不出现——**不给一个拖了没反应的滑块**。
|
|
627
|
+
//
|
|
628
|
+
// 目录是全局的,选中的那个是会话级的,所以这里两个都给出去:清单 + 当前绑定的那个。
|
|
629
|
+
// 拿不到能力时如实说没这个能力(503),和权限档位同一个口径。
|
|
630
|
+
if (req.method === 'GET' && path === '/mini/api/models') {
|
|
631
|
+
const boundSessionId = store.snapshot().boundSessionId
|
|
632
|
+
const result = await nav.modelCatalog(boundSessionId)
|
|
633
|
+
if (!result.ok) {
|
|
634
|
+
sendJson(res, 503, { ok: false, error: '这台电脑上的 DSH 没提供模型目录。' })
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
// 「这个会话现在用哪个」以 **DSH 那边**为准:它读的是会话日志里最后那条请求配置,
|
|
638
|
+
// 重启过、插件没看见过的会话一样问得出来。
|
|
639
|
+
//
|
|
640
|
+
// 为什么要改:原来只从插件自己记的那份里取(靠 request/header 事件一点点攒),
|
|
641
|
+
// 没跑过的会话就是空的。手机上一版又给空值兜了「目录默认值」,于是把**不知道**
|
|
642
|
+
// 显示成了「上次选过的那个模型」——用户切过去看到的是一个像模像样的错答案。
|
|
643
|
+
// 现在问不到就如实给 null,怎么讲由界面决定,反正不编。
|
|
644
|
+
const asked = nav.sessionModel ? await nav.sessionModel(boundSessionId) : { ok: false }
|
|
645
|
+
const remembered = store.snapshot().sessions?.find((s) => s.id === boundSessionId) ?? null
|
|
646
|
+
const current = asked.ok
|
|
647
|
+
? asked.model
|
|
648
|
+
: (remembered && remembered.model
|
|
649
|
+
? {
|
|
650
|
+
provider: remembered.provider ?? null,
|
|
651
|
+
model: remembered.model,
|
|
652
|
+
reasoningEffort: remembered.reasoningEffort ?? null,
|
|
653
|
+
}
|
|
654
|
+
: null)
|
|
655
|
+
sendJson(res, 200, { ok: true, boundSessionId, current, ...result.catalog })
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (req.method === 'POST' && path === '/mini/api/answer') {
|
|
660
|
+
const body = await readBody(req)
|
|
661
|
+
const waiting = waitingQuestions.get(body?.id)
|
|
662
|
+
if (!waiting) {
|
|
663
|
+
// 409 而不是 200:多半是这个提问早就结束了,如实说,别装作收下了。
|
|
664
|
+
// 落一句痕:这是「手机答了、电脑没动静」唯一能查到的线索——手机那边原来
|
|
665
|
+
// 把失败咽掉了,插件这边再不留痕,下次还是只能靠猜(2026-09-27)。
|
|
666
|
+
console.log('[dsh-mini-remote] 手机上答晚了:这道题已经交回电脑(' + String(body?.id) + '),没接')
|
|
667
|
+
sendJson(res, 409, { ok: false, error: '这个提问已经结束了。' })
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
const answers = Array.isArray(body?.answers) ? body.answers : null
|
|
671
|
+
if (!answers) {
|
|
672
|
+
sendJson(res, 400, { ok: false, error: '答案格式不对。' })
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
waiting.settle({ answers })
|
|
676
|
+
sendJson(res, 200, { ok: true })
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---- 目录浏览(挑工作区用)------------------------------------------
|
|
681
|
+
// 只列目录,绝不列文件、绝不读内容。手机上要的是「挑一个文件夹」。
|
|
682
|
+
if (req.method === 'GET' && path === '/mini/api/browse/roots') {
|
|
683
|
+
// 「最近用过的目录」直接拿已登记工作区的路径:那些正是他用过的地方,
|
|
684
|
+
// 不用我们另外记一份「最近打开过什么」的状态。
|
|
685
|
+
const recent = (nav.listWorkspaces ? await nav.listWorkspaces(new Set(), false) : [])
|
|
686
|
+
.map((w) => w.path)
|
|
687
|
+
.filter(Boolean)
|
|
688
|
+
sendJson(res, 200, { ok: true, ...(await files.listRoots({ recent })) })
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (req.method === 'GET' && path === '/mini/api/browse') {
|
|
693
|
+
const asked = url.searchParams.get('path')
|
|
694
|
+
if (!asked) {
|
|
695
|
+
sendJson(res, 400, { ok: false, error: '要带 path 参数。' })
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
const result = await files.listDirectory(asked)
|
|
699
|
+
if (!result.ok) {
|
|
700
|
+
// 读不出来时如实说,**不当作空目录**:空目录和「没有权限」在界面上
|
|
701
|
+
// 长得一模一样,而后者用户需要知道。
|
|
702
|
+
const known = {
|
|
703
|
+
'bad-path': [400, '这个路径不合法。'],
|
|
704
|
+
unreadable: [404, '读不到这个目录,可能是不存在或者没有权限。'],
|
|
705
|
+
// 空壳才会走到这儿(生产里 browse 一直传得进来)。口径跟其它
|
|
706
|
+
// 「没这个能力」统一成 503,不混进 500 里。
|
|
707
|
+
unavailable: [503, '这台电脑上的 DSH 没提供目录浏览。'],
|
|
708
|
+
}
|
|
709
|
+
const [status, message] = known[result.reason]
|
|
710
|
+
?? [500, `读目录失败:${result.error ?? '原因未知'}`]
|
|
711
|
+
sendJson(res, status, { ok: false, error: message })
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
sendJson(res, 200, result)
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
388
718
|
// ---- 上传文件 -------------------------------------------------------
|
|
389
719
|
// 这一段**必须排在下面那个 readBody 前面**:readBody 既只认 JSON,又把请求体
|
|
390
720
|
// 限死在 64 KB(MAX_BODY)。手机拍一张照片就有好几兆,走它必然失败,而且失败得
|
|
@@ -445,6 +775,25 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
445
775
|
return
|
|
446
776
|
}
|
|
447
777
|
|
|
778
|
+
if (path === '/mini/api/command') {
|
|
779
|
+
// 执行一条斜杠指令。**结果不在这条响应里**——指令跑完会把
|
|
780
|
+
// `command/run` / `command/done` 两条事件写进会话记录,手机上那条「指令行」
|
|
781
|
+
// 是跟着事件流出现的(和电脑端同一份账)。这里只负责「收下并开跑」,
|
|
782
|
+
// 或者「不收,并说明为什么」。
|
|
783
|
+
const line = typeof body.line === 'string' ? body.line : ''
|
|
784
|
+
if (!line.trim()) {
|
|
785
|
+
sendJson(res, 400, { ok: false, error: '指令为空。' })
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
if (!onCommand) {
|
|
789
|
+
sendJson(res, 503, { ok: false, error: '这台电脑上的 DSH 没提供指令。' })
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
const result = await onCommand(line)
|
|
793
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
|
|
448
797
|
if (path === '/mini/api/stop') {
|
|
449
798
|
// 停的是「手机正在遥控的那个会话」,和 send 同一套口径——
|
|
450
799
|
// 手机上按的停止,不该停掉你在电脑上另开的那个任务。
|
|
@@ -466,6 +815,109 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
466
815
|
return
|
|
467
816
|
}
|
|
468
817
|
|
|
818
|
+
// 在一个已有的工作区里新建会话,建完立刻把手机绑过去。
|
|
819
|
+
//
|
|
820
|
+
// 为什么排在这么靠后、而不是跟上面那条 GET 挨着:这两条都要读请求体,
|
|
821
|
+
// 而 `readBody` 在后面才调(上面那条 GET 不用读,所以能排在前面)。
|
|
822
|
+
// 同一个路径上 GET 是「列出来」、POST 是「建一个」——同一条资源的两种动作。
|
|
823
|
+
//
|
|
824
|
+
// 建完顺手 bind + 广播:手机发一次请求就完成了「建 + 切过去」,
|
|
825
|
+
// 不用再补一次 bind,也就不会出现「建好了但没切过去」这种半截状态。
|
|
826
|
+
if (req.method === 'POST' && wsSessions) {
|
|
827
|
+
const created = await nav.createSession(decodeURIComponent(wsSessions[1]))
|
|
828
|
+
if (!created.ok) {
|
|
829
|
+
const known = {
|
|
830
|
+
'no-controller': [503, '这台电脑上的 DSH 没提供新建会话的能力。'],
|
|
831
|
+
'no-workspace': [404, '没有这个工作区。'],
|
|
832
|
+
}
|
|
833
|
+
const [status, message] = known[created.reason]
|
|
834
|
+
?? [500, `新建会话失败:${created.error ?? '原因未知'}`]
|
|
835
|
+
sendJson(res, status, { ok: false, error: message })
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
store.bind(created.sessionId)
|
|
839
|
+
const snap = store.snapshot()
|
|
840
|
+
broadcast('state', snap)
|
|
841
|
+
sendJson(res, 200, {
|
|
842
|
+
ok: true,
|
|
843
|
+
sessionId: created.sessionId,
|
|
844
|
+
workspaceTitle: created.workspaceTitle,
|
|
845
|
+
boundSessionId: created.sessionId,
|
|
846
|
+
state: snap,
|
|
847
|
+
})
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// 切权限档位。作用在当前绑定的那个会话上——手机只绑一个会话,
|
|
852
|
+
// 所以「当前会话」和「绑定会话」是同一件事。
|
|
853
|
+
if (path === '/mini/api/permissions') {
|
|
854
|
+
const boundSessionId = store.snapshot().boundSessionId
|
|
855
|
+
const done = await nav.setPermission(
|
|
856
|
+
boundSessionId, typeof body.name === 'string' ? body.name : '',
|
|
857
|
+
)
|
|
858
|
+
if (!done.ok) {
|
|
859
|
+
const known = {
|
|
860
|
+
'no-service': [503, '这台电脑上的 DSH 没提供权限档位。'],
|
|
861
|
+
// 409 而不是 400:请求本身没问题,是当前状态不满足——
|
|
862
|
+
// 得先挑一个会话。
|
|
863
|
+
'no-session': [409, '还没绑定会话,先挑一个会话再切权限。'],
|
|
864
|
+
'unknown-preset': [400, '没有这个权限档位。'],
|
|
865
|
+
}
|
|
866
|
+
const [status, message] = known[done.reason]
|
|
867
|
+
?? [500, `切换失败:${done.error ?? '原因未知'}`]
|
|
868
|
+
sendJson(res, status, { ok: false, error: message })
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
// 切完把最新状态回过去:手机据此更新高亮,不用再补一趟请求。
|
|
872
|
+
const after = await nav.permissions(boundSessionId)
|
|
873
|
+
sendJson(res, 200, { ...after, ok: true, name: done.name, boundSessionId })
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 把一个目录登记成工作区。
|
|
878
|
+
// 跟上面那条 GET 同一条路径:GET 是「列出来」,POST 是「登记一个」。
|
|
879
|
+
if (path === '/mini/api/workspaces') {
|
|
880
|
+
const target = typeof body.path === 'string' ? body.path : ''
|
|
881
|
+
const made = await nav.createWorkspace(target)
|
|
882
|
+
if (!made.ok) {
|
|
883
|
+
const known = {
|
|
884
|
+
'no-registry': [503, '这台电脑上的 DSH 没提供工作区服务。'],
|
|
885
|
+
'bad-path': [400, '没给路径。'],
|
|
886
|
+
}
|
|
887
|
+
const [status, message] = known[made.reason]
|
|
888
|
+
?? [400, `登记不了这个目录:${made.error ?? '原因未知'}`]
|
|
889
|
+
sendJson(res, status, { ok: false, error: message })
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
// 登记完立刻把最新的工作区列表带回去:手机拿到就能直接画出来,
|
|
893
|
+
// 不用再补一趟请求,也就不会出现「建好了但列表里还没有」。
|
|
894
|
+
const workspaces = await nav.listWorkspaces(new Set(), true, true)
|
|
895
|
+
sendJson(res, 200, {
|
|
896
|
+
ok: true, created: made.created, workspace: made.workspace, workspaces,
|
|
897
|
+
})
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// 在选中的目录下新建一个子目录。
|
|
902
|
+
// `name` 必须是**单个目录名**,不接受路径——手机是网络对面来的输入,
|
|
903
|
+
// 一个带 `..` 或分隔符的「名字」能让新建落到别的地方去。
|
|
904
|
+
if (path === '/mini/api/browse/mkdir') {
|
|
905
|
+
const result = await files.makeDirectory(body.path, body.name)
|
|
906
|
+
if (!result.ok) {
|
|
907
|
+
const known = {
|
|
908
|
+
'bad-name': [400, '文件夹名不合法:不能为空、不能带斜杠,也不能是 . 或 ..'],
|
|
909
|
+
'bad-path': [400, '这个路径不合法。'],
|
|
910
|
+
exists: [409, '这个名字已经有一个文件夹了。'],
|
|
911
|
+
}
|
|
912
|
+
const [status, message] = known[result.reason]
|
|
913
|
+
?? [500, `建文件夹失败:${result.error ?? '原因未知'}`]
|
|
914
|
+
sendJson(res, status, { ok: false, error: message })
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
sendJson(res, 200, result)
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
|
|
469
921
|
if (path === '/mini/api/bind') {
|
|
470
922
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
471
923
|
if (!sessionId) {
|
|
@@ -473,6 +925,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
473
925
|
return
|
|
474
926
|
}
|
|
475
927
|
store.bind(sessionId)
|
|
928
|
+
// 切过去的这个会话,完整历史该读就读(异步补,读完再广播一份快照)。
|
|
929
|
+
// 这一下是「点开工作区里的会话,能看到它之前的每一轮」能成立的关键。
|
|
930
|
+
onSessionOpened?.(sessionId)
|
|
476
931
|
const snap = store.snapshot()
|
|
477
932
|
broadcast('state', snap)
|
|
478
933
|
// 顺手把快照放回响应里。手机拿到就能立刻渲染,不用等 SSE 那一趟;
|
|
@@ -586,5 +1041,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
586
1041
|
server.closeAllConnections?.()
|
|
587
1042
|
})))
|
|
588
1043
|
},
|
|
1044
|
+
// 给插件主体用:把提问推给手机、以及「手机还在不在」。
|
|
1045
|
+
// 两个都挂在返回对象上,是因为提问是插件主体接到的,而「连着的手机」在这边。
|
|
1046
|
+
askPhone,
|
|
1047
|
+
hasPhone,
|
|
589
1048
|
}
|
|
590
1049
|
}
|