dsh-mini-remote 0.1.7 → 0.1.9
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 +49 -10
- package/README.en.md +33 -1
- package/README.md +33 -1
- package/client/client.js +272 -1
- package/lib/commands.js +6 -0
- package/lib/events.js +36 -5
- package/lib/history.js +21 -3
- package/lib/index.js +1323 -19
- package/lib/lark.js +1154 -0
- package/lib/page.html +2658 -136
- package/lib/page.js +17 -3
- package/lib/pairing.js +32 -3
- package/lib/permissions.js +1 -0
- package/lib/server.js +522 -30
- package/lib/session-meta-cache.js +100 -0
- package/lib/store.js +253 -2
- package/lib/subagent-cache.js +63 -0
- package/lib/trajectory.js +570 -0
- package/lib/tree.js +107 -15
- package/package.json +2 -1
package/lib/server.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - 只需要服务端→手机单向推送;手机发指令用普通 POST 即可
|
|
13
13
|
*/
|
|
14
14
|
import { createServer } from 'node:http'
|
|
15
|
-
import { timingSafeEqual } from 'node:crypto'
|
|
15
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
16
16
|
import { renderPage, readArt, readImage, readSw } from './page.js'
|
|
17
17
|
|
|
18
18
|
const COOKIE = 'dsh_mini_token'
|
|
@@ -168,12 +168,101 @@ export const miniControl = {
|
|
|
168
168
|
* @param {(line:string)=>Promise<{ok:boolean, error?:string}>} [opts.onCommand] 执行一条斜杠指令
|
|
169
169
|
* @param {()=>Promise<{ok:boolean, error?:string, sessionId?:string}>|{ok:boolean, error?:string, sessionId?:string}} opts.onStop
|
|
170
170
|
* @param {(id:string)=>{ok:boolean, error?:string, sessionId?:string}} opts.onUnqueue
|
|
171
|
-
* @
|
|
171
|
+
* @param {() => (string|undefined)[]} [opts.imageRoots] 正文里相对路径图的额外候选根
|
|
172
|
+
* (由插件递进「当前已注册工作区的目录」,见 extraImageRoots 那段)
|
|
173
|
+
* @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void, broadcastTrajectory:(payload:object)=>void, broadcastTrajectoryLive:(payload:object)=>void}>}
|
|
172
174
|
*/
|
|
173
|
-
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build }) {
|
|
175
|
+
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots }) {
|
|
174
176
|
/** @type {Set<import('node:http').ServerResponse>} */
|
|
175
177
|
const clients = new Set()
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* 这条连接到底是谁——**订阅要按连接记,就得先有身份**。
|
|
181
|
+
*
|
|
182
|
+
* SSE 那边只有一个 `res`,一次普通的 POST 认不出「我是哪条流」。所以由客户端自己
|
|
183
|
+
* 生成一个随机串,两处都带上:连流时 `?clientId=…`,之后每一次开关订阅也带它。
|
|
184
|
+
* 客户端没报的(比如现在这版手机页面)服务端也给它编一个,只为日志里能对上号——
|
|
185
|
+
* 那种连接**订不了轨迹**,也就不会为这份体积付钱。
|
|
186
|
+
*/
|
|
187
|
+
const clientsById = new Map()
|
|
188
|
+
/**
|
|
189
|
+
* 想要执行轨迹推送的那几条连接。
|
|
190
|
+
*
|
|
191
|
+
* 为什么必须按连接分流:轨迹的体积是回答正文的 **90.7 倍**(单轮最高约 946KB,
|
|
192
|
+
* 单条输出最大 49,930 字符),而现在的 `state` 是「每条广播都发整份快照」。
|
|
193
|
+
* 把它塞进那条路会把手机压垮,所以它走单独的增量帧,并且**只有声明要「完整」模式的
|
|
194
|
+
* 那几台**才推。声明有两条路:
|
|
195
|
+
* ① 连上来时带 `?full=1`——断线重连之后自动重新对齐,不用先握手一次;
|
|
196
|
+
* ② 连上之后 `POST /mini/api/trajectory/subscribe`——切模式时用,不必重连。
|
|
197
|
+
*/
|
|
198
|
+
const trajectorySubscribers = new Set()
|
|
199
|
+
let clientSeq = 0
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 「这一代服务」的编号。进程重启、服务重起都会换一个。
|
|
203
|
+
*
|
|
204
|
+
* 客户端拿它认出「对面换人了」:那种时候帧序号会从头开始,手里的轨迹该整个重拉一次。
|
|
205
|
+
* 光看 seq 是不够的——重启后 seq 也是从 1 开始,看着就像什么都没发生。
|
|
206
|
+
*/
|
|
207
|
+
const EPOCH = randomBytes(4).toString('hex')
|
|
208
|
+
|
|
209
|
+
// 导航栏的数据比普通状态更适合做 stale-while-revalidate:工作区和会话不会因为
|
|
210
|
+
// 用户打开一次菜单就必须重新清点。第一次没有缓存时仍然等待真实结果;之后先回上次
|
|
211
|
+
// 整理好的列表,再在后台刷新。手动点刷新时由请求方显式 force,保持原来的语义。
|
|
212
|
+
const NAV_CACHE_TTL_MS = 30_000
|
|
213
|
+
let navWorkspaceCache = { value: null, at: 0, inflight: null }
|
|
214
|
+
const SUBAGENT_CACHE_TTL_MS = 5_000
|
|
215
|
+
const subagentCaches = new Map()
|
|
216
|
+
function invalidateNavigation() {
|
|
217
|
+
navWorkspaceCache = { value: null, at: 0, inflight: null }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function invalidateSubagents(sessionId) {
|
|
221
|
+
if (sessionId) subagentCaches.delete(sessionId)
|
|
222
|
+
else subagentCaches.clear()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function loadSubagents(sessionId) {
|
|
226
|
+
if (!sessionId) return nav.subagents(sessionId)
|
|
227
|
+
const hit = subagentCaches.get(sessionId)
|
|
228
|
+
if (hit && Date.now() - hit.at < SUBAGENT_CACHE_TTL_MS) return hit.value
|
|
229
|
+
if (hit?.inflight) return hit.inflight
|
|
230
|
+
const inflight = Promise.resolve(nav.subagents(sessionId, { includeMetrics: false, scheduleMetrics: true })).then((value) => {
|
|
231
|
+
subagentCaches.set(sessionId, { value, at: Date.now(), inflight: null })
|
|
232
|
+
return value
|
|
233
|
+
}).finally(() => {
|
|
234
|
+
const current = subagentCaches.get(sessionId)
|
|
235
|
+
if (current?.inflight === inflight) current.inflight = null
|
|
236
|
+
})
|
|
237
|
+
subagentCaches.set(sessionId, { value: hit?.value ?? null, at: hit?.at ?? 0, inflight })
|
|
238
|
+
return hit?.value ?? inflight
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function loadWorkspaces(runningIds, force = false) {
|
|
242
|
+
const now = Date.now()
|
|
243
|
+
const fresh = navWorkspaceCache.value && now - navWorkspaceCache.at < NAV_CACHE_TTL_MS
|
|
244
|
+
if (!force && navWorkspaceCache.value) {
|
|
245
|
+
if (!fresh && !navWorkspaceCache.inflight) {
|
|
246
|
+
navWorkspaceCache.inflight = refreshWorkspaces(runningIds, false)
|
|
247
|
+
.finally(() => { navWorkspaceCache.inflight = null })
|
|
248
|
+
}
|
|
249
|
+
return navWorkspaceCache.value
|
|
250
|
+
}
|
|
251
|
+
if (navWorkspaceCache.inflight) return navWorkspaceCache.inflight
|
|
252
|
+
navWorkspaceCache.inflight = refreshWorkspaces(runningIds, force)
|
|
253
|
+
.finally(() => { navWorkspaceCache.inflight = null })
|
|
254
|
+
return navWorkspaceCache.inflight
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function refreshWorkspaces(runningIds, force) {
|
|
258
|
+
const started = performance.now()
|
|
259
|
+
const value = await nav.listWorkspaces(runningIds, force, true)
|
|
260
|
+
navWorkspaceCache = { value, at: Date.now(), inflight: null }
|
|
261
|
+
const elapsed = Math.round(performance.now() - started)
|
|
262
|
+
log?.info?.(`导航工作区读取 ${elapsed}ms(${value.length} 个)`)
|
|
263
|
+
return value
|
|
264
|
+
}
|
|
265
|
+
|
|
177
266
|
// 没传 tree(测试里就是这样,headless 组合里也拿不到那两个服务)时给个空壳,
|
|
178
267
|
// 导航栏显示「取不到」,遥控本身照常。
|
|
179
268
|
// `createSession` 的空壳回 `no-controller`,跟真服务缺位时走同一条错误路径——
|
|
@@ -188,6 +277,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
188
277
|
// 模型那两个的**空壳**:测试里没有真的 DSH,照上面的样式如实说"没这个能力"。
|
|
189
278
|
modelCatalog: async () => ({ ok: false, reason: 'unavailable' }),
|
|
190
279
|
selectModel: async () => ({ ok: false, reason: 'unavailable' }),
|
|
280
|
+
// 子智能体那四个的空壳,同样如实说"没这个能力",不假装有一份空列表。
|
|
281
|
+
subagents: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
282
|
+
subagentTranscript: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
283
|
+
stopSubagent: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
284
|
+
askSubagent: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
191
285
|
}
|
|
192
286
|
|
|
193
287
|
// 目录浏览的空壳。它**只依赖 node:fs**,不像 tree 那样依赖 DSH 的服务,
|
|
@@ -198,6 +292,39 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
198
292
|
makeDirectory: async () => ({ ok: false, reason: 'unavailable' }),
|
|
199
293
|
}
|
|
200
294
|
|
|
295
|
+
/**
|
|
296
|
+
* 正文里写相对路径的图(``)要多试的几个根:**当前已注册的工作区目录**。
|
|
297
|
+
*
|
|
298
|
+
* 为什么需要它(2026-09-28 实测确诊):正文里的相对路径相对的是**那段对话的工作目录**,
|
|
299
|
+
* 而它可能是别的项目——那次是 `I:\DSH\dsh-jev-ultrafast`,正文写的是
|
|
300
|
+
* `scratch/preview-0.2.3-settings.png`。插件原先只按自己的两个根(仓库根、lib/art)找,
|
|
301
|
+
* 于是 404,手机上就是破图;图就在盘上,接口本身也没毛病。
|
|
302
|
+
*
|
|
303
|
+
* **只放宽「在哪找」。** 仍然要令牌、仍然只发图片扩展名、仍然卡体积、相对写法里的 `..`
|
|
304
|
+
* 仍然被拒(后两条在 page.js 的 readImage 里,这一版一个字没动)。
|
|
305
|
+
* 额外根拿不到(服务还没起来)时退回空列表,也就是原来的行为。
|
|
306
|
+
*
|
|
307
|
+
* 缓存 30 秒:列一遍注册表要遍历所有工作区,而这张表几乎不变;晚半分钟知道新建的工作区
|
|
308
|
+
* 没有影响——图的地址是用户在正文里写的,不会正好卡在那半分钟上。
|
|
309
|
+
*/
|
|
310
|
+
const IMAGE_ROOTS_TTL_MS = 30_000
|
|
311
|
+
let imageRootsCache = { at: 0, list: [] }
|
|
312
|
+
function extraImageRoots() {
|
|
313
|
+
const now = Date.now()
|
|
314
|
+
if (now - imageRootsCache.at < IMAGE_ROOTS_TTL_MS) return imageRootsCache.list
|
|
315
|
+
let list = []
|
|
316
|
+
try {
|
|
317
|
+
const got = imageRoots?.()
|
|
318
|
+
// 去重:工作区里本来就可能包含插件根那个目录,重复只换来几次白跑的系统调用。
|
|
319
|
+
if (Array.isArray(got)) list = [...new Set(got.filter((r) => typeof r === 'string' && r))]
|
|
320
|
+
} catch {
|
|
321
|
+
// `workspaceRegistry.list()` 在服务还没就绪时会抛。少认几张图,不该拖垮整条取图的路。
|
|
322
|
+
list = []
|
|
323
|
+
}
|
|
324
|
+
imageRootsCache = { at: now, list }
|
|
325
|
+
return list
|
|
326
|
+
}
|
|
327
|
+
|
|
201
328
|
function broadcast(event, data) {
|
|
202
329
|
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
203
330
|
for (const res of clients) {
|
|
@@ -209,6 +336,58 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
209
336
|
}
|
|
210
337
|
}
|
|
211
338
|
|
|
339
|
+
/**
|
|
340
|
+
* 只发给「要轨迹」的那几台连接。**别人一个字节都不收**。
|
|
341
|
+
*
|
|
342
|
+
* 帧里只有这一批的变化(新条目 + 改动),不是整份:整份单轮最高约 946KB,而
|
|
343
|
+
* 一次工具调用也就是一条几 KB 的条目。`seq` 让客户端认出漏帧,`epoch` 认出重启。
|
|
344
|
+
*/
|
|
345
|
+
function broadcastTrajectory(payload) {
|
|
346
|
+
if (!trajectorySubscribers.size) return
|
|
347
|
+
const frame = `event: trajectory\ndata: ${JSON.stringify({ epoch: EPOCH, ...payload })}\n\n`
|
|
348
|
+
for (const res of trajectorySubscribers) {
|
|
349
|
+
try {
|
|
350
|
+
res.write(frame)
|
|
351
|
+
} catch {
|
|
352
|
+
trajectorySubscribers.delete(res)
|
|
353
|
+
clients.delete(res)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 轨迹的活片段(2026-10-04 用户解禁「流式不做」,只给完整模式看):模型正在想的
|
|
360
|
+
* 那一句,随打字顺出来。和上面那条增量帧走**同一批连接**(就是完整模式那几条),
|
|
361
|
+
* 但**不占它的 seq/epoch**——它是「还没落定」的影子,落定的字由增量帧接管。
|
|
362
|
+
*/
|
|
363
|
+
function broadcastTrajectoryLive(payload) {
|
|
364
|
+
if (!trajectorySubscribers.size) return
|
|
365
|
+
const frame = `event: trajectory-live\ndata: ${JSON.stringify(payload)}\n\n`
|
|
366
|
+
for (const res of trajectorySubscribers) {
|
|
367
|
+
try {
|
|
368
|
+
res.write(frame)
|
|
369
|
+
} catch {
|
|
370
|
+
trajectorySubscribers.delete(res)
|
|
371
|
+
clients.delete(res)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 按身份名开/关轨迹推送。
|
|
378
|
+
* @returns 点到的连接数。**0 = 那条连接已经不在了**,客户端据此重连一次
|
|
379
|
+
* (不然会出现「以为订上了,其实一条都收不到」这种最难查的静默状态)。
|
|
380
|
+
*/
|
|
381
|
+
function setTrajectorySubscription(clientId, on) {
|
|
382
|
+
const group = clientsById.get(clientId)
|
|
383
|
+
if (!group) return 0
|
|
384
|
+
for (const res of group) {
|
|
385
|
+
if (on) trajectorySubscribers.add(res)
|
|
386
|
+
else trajectorySubscribers.delete(res)
|
|
387
|
+
}
|
|
388
|
+
return group.size
|
|
389
|
+
}
|
|
390
|
+
|
|
212
391
|
// ---- 手机上答题 ------------------------------------------------------
|
|
213
392
|
//
|
|
214
393
|
// **这两样必须放在这一层**(和 `clients`、`broadcast` 同一层,全程只建一次)。
|
|
@@ -237,27 +416,81 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
237
416
|
return clients.size > 0 || (phoneSeenAt > 0 && Date.now() - phoneSeenAt < PHONE_GRACE_MS)
|
|
238
417
|
}
|
|
239
418
|
|
|
419
|
+
/**
|
|
420
|
+
* 「除了手机,还有别人在等这道题吗」——**收摊判据的第二个来源**。
|
|
421
|
+
*
|
|
422
|
+
* 手机全断开就立刻把问题还给电脑(上面那条,防的是永远卡住);可飞书那条路
|
|
423
|
+
* (见 lib/lark.js)也能答同一道题,手机上没人不等于没人答。所以由调用方传一个
|
|
424
|
+
* `keepAlive()` 进来,它说「还挂着」就不收摊。
|
|
425
|
+
*
|
|
426
|
+
* **绝不因为这一句把题卡死**:没传、或者它抛了,一律当「没人在等」——宁可还给电脑,
|
|
427
|
+
* 也不能因为另一条路的内部错误让问题悬在半空。
|
|
428
|
+
*/
|
|
429
|
+
function stillWaited(opts) {
|
|
430
|
+
try {
|
|
431
|
+
return opts?.keepAlive?.() === true
|
|
432
|
+
} catch {
|
|
433
|
+
return false
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 一道题/一条审批刚刚有结果了——**主动告诉手机页**。
|
|
439
|
+
*
|
|
440
|
+
* 为什么必须有这一条:手机和飞书可能同时看着同一道题(用户就在两个界面之间切)。
|
|
441
|
+
* 谁先答谁生效的语义本来就有(后到的那边拿到 `expired`),但那只在**后到的人自己动手**
|
|
442
|
+
* 时才说话。手机页那张卡片是推一次就留在屏幕上的,用户在飞书里答完,手机上那张还挂在
|
|
443
|
+
* 原地——他没法知道这道题已经有人答了,只能盯着它发愣(2026-10-02 真机就是这个现象)。
|
|
444
|
+
*
|
|
445
|
+
* 所以这里用**现成的 SSE 通道**补一条状态:手机页收到就把卡片收起来、换一行说明。
|
|
446
|
+
* 只说「哪一道、谁答的」,不重复推题目本身——两边都不重复打扰。
|
|
447
|
+
*/
|
|
448
|
+
function notifySettled(kind, id, source) {
|
|
449
|
+
broadcast(`${kind}-done`, { id, source: source ?? '' })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 题挂上之后当场告诉调用方它的编号。
|
|
454
|
+
*
|
|
455
|
+
* **飞书那条路就靠这个号对号入座**(用户回的那句话是对哪一道题的答复),交出去的是
|
|
456
|
+
* 这一层内部用的编号(`q1`/`a1` 这种),不是界面上的顺序号。回调抛错只写一行日志,
|
|
457
|
+
* 不影响这道题本身——推不出去的只是「另一个界面也来问」这件事。
|
|
458
|
+
*/
|
|
459
|
+
function notifyPending(opts, id) {
|
|
460
|
+
try {
|
|
461
|
+
opts?.onPending?.(id)
|
|
462
|
+
} catch (err) {
|
|
463
|
+
log?.warn?.(`遥控服务:通知另一条通路失败(${err?.message ?? err})`)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
240
467
|
/**
|
|
241
468
|
* 把这个提问推到手机上,等它答。
|
|
469
|
+
* @param {{keepAlive?: () => boolean, onPending?: (id: string) => void}} [opts]
|
|
470
|
+
* 见上面 stillWaited / notifyPending 两段说明。
|
|
242
471
|
* @returns 答案对象;手机没了就返回 null,调用方据此**把问题还给电脑**。
|
|
243
472
|
*/
|
|
244
|
-
function askPhone(questions, agentId) {
|
|
473
|
+
function askPhone(questions, agentId, opts) {
|
|
245
474
|
const id = 'q' + (++questionSeq)
|
|
246
475
|
return new Promise((resolve) => {
|
|
247
476
|
let watch = null
|
|
248
|
-
const settle = (value) => {
|
|
477
|
+
const settle = (value, from) => {
|
|
249
478
|
if (!waitingQuestions.has(id)) return
|
|
250
479
|
waitingQuestions.delete(id)
|
|
251
480
|
if (watch) clearInterval(watch)
|
|
481
|
+
// 先通知、再落定:谁先答谁生效的语义一个字没动,这里只是**把结果说出去**,
|
|
482
|
+
// 让另一侧那张还挂着的卡片自己收起来(见 notifySettled)。
|
|
483
|
+
notifySettled('question', id, from ?? opts?.source)
|
|
252
484
|
resolve(value)
|
|
253
485
|
}
|
|
254
486
|
// 题目本身也存进来(不只是 settle):手机中途重连上来时要能补发给它,见 replayQuestions。
|
|
255
487
|
waitingQuestions.set(id, { settle, agentId, questions })
|
|
488
|
+
notifyPending(opts, id)
|
|
256
489
|
broadcast('question', { id, agentId, questions })
|
|
257
490
|
// 用户选的是「一直等,不还回电脑」——但那不等于「手机都没了还等」。
|
|
258
491
|
// 手机一旦全断开(锁屏、关了页面、网断),立刻收摊还给电脑,否则会永远卡住。
|
|
259
492
|
watch = setInterval(() => {
|
|
260
|
-
if (!hasPhone()) settle(null)
|
|
493
|
+
if (!hasPhone() && !stillWaited(opts)) settle(null)
|
|
261
494
|
}, 2000)
|
|
262
495
|
})
|
|
263
496
|
}
|
|
@@ -294,16 +527,20 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
294
527
|
|
|
295
528
|
/**
|
|
296
529
|
* 把一条命令确认推到手机上,等它拍板。
|
|
530
|
+
* @param {{keepAlive?: () => boolean, onPending?: (id: string) => void}} [opts]
|
|
531
|
+
* 和提问同一条:飞书那边也能拍这一板(见 lib/lark.js)。
|
|
297
532
|
* @returns 'allowed-once' 或 'rejected';手机没了返回 null,调用方据此还给电脑。
|
|
298
533
|
*/
|
|
299
|
-
function askApproval(req, agentId, command) {
|
|
534
|
+
function askApproval(req, agentId, command, opts) {
|
|
300
535
|
const id = 'a' + (++approvalSeq)
|
|
301
536
|
return new Promise((resolve) => {
|
|
302
537
|
let watch = null
|
|
303
|
-
const settle = (value) => {
|
|
538
|
+
const settle = (value, from) => {
|
|
304
539
|
if (!waitingApprovals.has(id)) return
|
|
305
540
|
waitingApprovals.delete(id)
|
|
306
541
|
if (watch) clearInterval(watch)
|
|
542
|
+
// 和提问那条同一句:通知另一侧那张卡片自己收起来(见 notifySettled)。
|
|
543
|
+
notifySettled('approval', id, from ?? opts?.source)
|
|
307
544
|
resolve(value)
|
|
308
545
|
}
|
|
309
546
|
// 推给手机的就是这几样:工具名 + 为什么来问我。**不带命令正文**——
|
|
@@ -317,10 +554,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
317
554
|
command: typeof command === 'string' ? command : '',
|
|
318
555
|
}
|
|
319
556
|
waitingApprovals.set(id, { settle, agentId, view })
|
|
557
|
+
notifyPending(opts, id)
|
|
320
558
|
broadcast('approval', view)
|
|
321
|
-
//
|
|
559
|
+
// 和提问同一条判据:**不倒计时,看手机还在不在**。全断开了就立刻收摊还给电脑
|
|
560
|
+
// (飞书那边还挂着就除外,见 stillWaited)。
|
|
322
561
|
watch = setInterval(() => {
|
|
323
|
-
if (!hasPhone()) settle(null)
|
|
562
|
+
if (!hasPhone() && !stillWaited(opts)) settle(null)
|
|
324
563
|
}, 2000)
|
|
325
564
|
})
|
|
326
565
|
}
|
|
@@ -335,11 +574,62 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
335
574
|
}
|
|
336
575
|
}
|
|
337
576
|
}
|
|
577
|
+
/**
|
|
578
|
+
* 把一道题的答案交回去。**这是答题的唯一入口**,两个界面都走它:
|
|
579
|
+
* 手机页:POST /mini/api/answer
|
|
580
|
+
* 飞书: miniControl.answerQuestion(见 lib/lark.js 的 onAnswer)
|
|
581
|
+
*
|
|
582
|
+
* 为什么收成一处:手机和飞书可能**同时**看着同一道题(用户就在两个人机界面之间切换),
|
|
583
|
+
* 「谁先答谁生效、另一个自动失效」不该写成两遍——这里只有一个 settle,第二个来的人
|
|
584
|
+
* 自然拿到 `expired`。这是现成的语义,不是为飞书新加的一套。
|
|
585
|
+
*
|
|
586
|
+
* @param {string} id 题号(waitingQuestions 里的那个)
|
|
587
|
+
* @param {{answers?: Array, cancel?: boolean}} input `cancel:true` = 不答:
|
|
588
|
+
* 按 settle(null) 处理,也就是和「手机全断了」**同一条路**——
|
|
589
|
+
* 调用方据此把问题还给电脑(见 lib/index.js 的 .then(answer => !answer → next()))。
|
|
590
|
+
* @param {'phone'|'feishu'} [from] 谁答的。**只用来通知另一侧**(见 notifySettled):
|
|
591
|
+
* 「已在飞书答过」这句话要有出处,猜不得。
|
|
592
|
+
* @returns {{ok: boolean, error?: 'expired'|'bad'}}
|
|
593
|
+
*/
|
|
594
|
+
function answerQuestion(id, input, from) {
|
|
595
|
+
const entry = waitingQuestions.get(id)
|
|
596
|
+
if (!entry) return { ok: false, error: 'expired' }
|
|
597
|
+
if (input?.cancel === true) {
|
|
598
|
+
entry.settle(null, from)
|
|
599
|
+
return { ok: true }
|
|
600
|
+
}
|
|
601
|
+
if (!Array.isArray(input?.answers)) return { ok: false, error: 'bad' }
|
|
602
|
+
entry.settle({ answers: input.answers }, from)
|
|
603
|
+
return { ok: true }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 把一条审批的结论交回去。形状、理由和 answerQuestion 一模一样(见上面那段)——
|
|
608
|
+
* 只是交回去的东西是结论,且**只认那两个值**:一个没见过的结论宁可说 bad,
|
|
609
|
+
* 也不能让电脑收到一个它看不懂的答复。
|
|
610
|
+
* @param {'phone'|'feishu'} [from] 谁拍的板。只用来通知另一侧(同 answerQuestion)。
|
|
611
|
+
*/
|
|
612
|
+
function decideApproval(id, input, from) {
|
|
613
|
+
const entry = waitingApprovals.get(id)
|
|
614
|
+
if (!entry) return { ok: false, error: 'expired' }
|
|
615
|
+
if (input?.cancel === true) {
|
|
616
|
+
entry.settle(null, from)
|
|
617
|
+
return { ok: true }
|
|
618
|
+
}
|
|
619
|
+
if (input?.decision !== 'allowed-once' && input?.decision !== 'rejected') {
|
|
620
|
+
return { ok: false, error: 'bad' }
|
|
621
|
+
}
|
|
622
|
+
entry.settle(input.decision, from)
|
|
623
|
+
return { ok: true }
|
|
624
|
+
}
|
|
625
|
+
|
|
338
626
|
// 把这两个能力放到插件够得着的固定位置(见文件上方 `miniControl` 的说明)。
|
|
339
627
|
// 放在这里是因为它俩都已定义(函数声明本来也会提升),且不受下面那堆路由逻辑影响。
|
|
340
628
|
miniControl.hasPhone = hasPhone
|
|
341
629
|
miniControl.askPhone = askPhone
|
|
342
630
|
miniControl.askApproval = askApproval
|
|
631
|
+
miniControl.answerQuestion = answerQuestion
|
|
632
|
+
miniControl.decideApproval = decideApproval
|
|
343
633
|
|
|
344
634
|
/**
|
|
345
635
|
* 把 UA 和来源地址压成一句人看的:设备类型 + 地址。
|
|
@@ -468,10 +758,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
468
758
|
// 取图有两条路,都挂在这个前缀下:
|
|
469
759
|
// ① /mini/art/<名字>.webp —— 立绘。名字受白名单限制,地址里带构建指纹,可以长缓存。
|
|
470
760
|
// ② /mini/art/?p=<路径> —— 正文里直接写了一张图在电脑上的位置(绝对路径,或相对
|
|
471
|
-
//
|
|
761
|
+
// 项目根/lib/art/已注册的工作区目录)。只发图片类型,地址里没有指纹,所以不
|
|
762
|
+
// 让浏览器缓存。
|
|
472
763
|
const wanted = url.searchParams.get('p')
|
|
473
764
|
if (wanted) {
|
|
474
|
-
const hit = readImage(wanted)
|
|
765
|
+
const hit = readImage(wanted, extraImageRoots())
|
|
475
766
|
if (!hit) {
|
|
476
767
|
sendJson(res, 404, { error: 'not found' })
|
|
477
768
|
return
|
|
@@ -554,6 +845,15 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
554
845
|
})
|
|
555
846
|
res.write(': connected\n\n')
|
|
556
847
|
clients.add(res)
|
|
848
|
+
// 这条连接的身份 + 它要不要轨迹。身份是客户端自己报的随机串(见 clientsById),
|
|
849
|
+
// 没报就编一个:编出来的那种订不了轨迹,也就不用替这份体积付钱。
|
|
850
|
+
const askedClientId = String(url.searchParams.get('clientId') ?? '').slice(0, 64)
|
|
851
|
+
const clientId = askedClientId || `anon${++clientSeq}`
|
|
852
|
+
let sameId = clientsById.get(clientId)
|
|
853
|
+
if (!sameId) clientsById.set(clientId, (sameId = new Set()))
|
|
854
|
+
sameId.add(res)
|
|
855
|
+
// `?full=1` = 「我这一端是完整模式」:连上就订上,重连之后自动重新对齐。
|
|
856
|
+
if (url.searchParams.get('full') === '1') trajectorySubscribers.add(res)
|
|
557
857
|
// 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
|
|
558
858
|
// 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
|
|
559
859
|
// 还要看**是谁**连的、**活了多久**:2026-09-27 日志里一分钟几十次成对出现,
|
|
@@ -590,6 +890,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
590
890
|
said = true
|
|
591
891
|
clearInterval(beat)
|
|
592
892
|
clients.delete(res)
|
|
893
|
+
trajectorySubscribers.delete(res)
|
|
894
|
+
const group = clientsById.get(clientId)
|
|
895
|
+
if (group) {
|
|
896
|
+
group.delete(res)
|
|
897
|
+
if (!group.size) clientsById.delete(clientId)
|
|
898
|
+
}
|
|
593
899
|
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`
|
|
594
900
|
+ `(${who},活了 ${Date.now() - openedAt} ms)`)
|
|
595
901
|
}
|
|
@@ -606,6 +912,58 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
606
912
|
return
|
|
607
913
|
}
|
|
608
914
|
|
|
915
|
+
/**
|
|
916
|
+
* 执行轨迹:手机「完整」模式要的那一组一组过程。
|
|
917
|
+
*
|
|
918
|
+
* **它不在 `state` 快照里**,这是这次改动最要紧的一条:实测轨迹正文是回答正文的
|
|
919
|
+
* 90.7 倍(单轮最高约 946KB),而快照是**每条广播都发整份**的——塞进去等于把
|
|
920
|
+
* 每一次工具调用都变成一次几百 KB 的推送(公网隧道下尤其不可接受)。
|
|
921
|
+
* 所以全量快照照旧,轨迹走增量帧(`event: trajectory`),这份接口只负责
|
|
922
|
+
* 「重连 / 切到完整模式 / 换个会话时,一次把当前这一段补齐」。
|
|
923
|
+
*
|
|
924
|
+
* `turn=N` 只给那一轮:手机切回来时想对齐的是**当前正在跑的那一轮**,不必把
|
|
925
|
+
* 前面一百多条条目再拖一遍。
|
|
926
|
+
*
|
|
927
|
+
* 和 /state 同一条口径:没读过这个会话的记录就先读一次(`onSessionOpened`),
|
|
928
|
+
* 读完会广播 state;手机据此再拉一次,那时 `loading` 就不是 true 了。
|
|
929
|
+
*/
|
|
930
|
+
if (req.method === 'GET' && path === '/mini/api/trajectory') {
|
|
931
|
+
const asked = url.searchParams.get('sessionId')
|
|
932
|
+
const sessionId = asked || store.targetSessionId()
|
|
933
|
+
if (!sessionId) {
|
|
934
|
+
sendJson(res, 200, {
|
|
935
|
+
ok: true, sessionId: null, epoch: EPOCH, seq: 0, turn: null,
|
|
936
|
+
turns: [], truncated: false, note: null, loading: false,
|
|
937
|
+
})
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
let onlyTurn = null
|
|
941
|
+
if (url.searchParams.has('turn')) {
|
|
942
|
+
onlyTurn = Number(url.searchParams.get('turn'))
|
|
943
|
+
if (!Number.isFinite(onlyTurn)) {
|
|
944
|
+
sendJson(res, 400, { ok: false, error: 'turn 要是一个数字。' })
|
|
945
|
+
return
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
onSessionOpened?.(sessionId)
|
|
949
|
+
const view = store.trajectoryOf(sessionId)
|
|
950
|
+
const turns = onlyTurn === null
|
|
951
|
+
? view.turns
|
|
952
|
+
: view.turns.filter((g) => g.turn === onlyTurn)
|
|
953
|
+
sendJson(res, 200, {
|
|
954
|
+
ok: true,
|
|
955
|
+
sessionId,
|
|
956
|
+
epoch: EPOCH,
|
|
957
|
+
seq: view.seq,
|
|
958
|
+
turn: onlyTurn,
|
|
959
|
+
turns,
|
|
960
|
+
truncated: view.truncated,
|
|
961
|
+
note: view.note ?? null,
|
|
962
|
+
loading: view.loading === true,
|
|
963
|
+
})
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
|
|
609
967
|
// 构建指纹。重启期间旧进程还在应答,这个接口用来确认「跑的是不是新代码」。
|
|
610
968
|
//
|
|
611
969
|
// `canCreateSession` 是**运行时**事实,不是配置推断:它问的是「这一刻 DSH 真的
|
|
@@ -620,7 +978,38 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
620
978
|
// 探测本身不该把接口带崩:问不到就当没有。
|
|
621
979
|
canCreateSession = false
|
|
622
980
|
}
|
|
623
|
-
|
|
981
|
+
// 版本号问**正在跑的这只 Host**(见 index.js 的 dshVersion)。
|
|
982
|
+
// 早先这里是读磁盘上的 package.json,桌面端读不到,一直报 unknown。
|
|
983
|
+
const version = (await nav.dshVersion?.()) || 'unknown'
|
|
984
|
+
sendJson(res, 200, { ok: true, build: build ?? 'unknown', dshVersion: version, canCreateSession })
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// 插件列表:手机端「插件」页面用。分三块——
|
|
989
|
+
// bundles :npm 包(官方可装的 + 用户装的第三方)
|
|
990
|
+
// cards :官方那几张**设置卡片**(终端 / Agent 循环 / 子智能体 / 网页搜索)。
|
|
991
|
+
// 它们不是包,是注册了设置页的官方插件,宿主这边只能照 DSH 客户端
|
|
992
|
+
// 源码列一张表 + 按「宿主 served 了那个命名空间没」筛选。
|
|
993
|
+
// entries/presets:**内置插件**(Cordis Loader 的实时条目表),也就是
|
|
994
|
+
// 「全局插件」和「会话插件」两块。2026-10-04 用户指出:内置插件
|
|
995
|
+
// 不能拿 npm 包充数(那只有几个),DSH 是「一切皆插件,无特权核心」。
|
|
996
|
+
// 三块各带各的错:一块读不到不该把另两块也弄没。
|
|
997
|
+
if (req.method === 'GET' && path === '/mini/api/plugins') {
|
|
998
|
+
const [list, inv, official] = await Promise.all([
|
|
999
|
+
nav.pluginList?.() ?? { ok: false, reason: 'unavailable' },
|
|
1000
|
+
nav.pluginInventory?.() ?? { ok: false, reason: 'unavailable' },
|
|
1001
|
+
nav.officialPlugins?.() ?? { ok: false, reason: 'unavailable' },
|
|
1002
|
+
])
|
|
1003
|
+
sendJson(res, 200, {
|
|
1004
|
+
ok: list.ok !== false || inv.ok !== false,
|
|
1005
|
+
bundles: list.bundles ?? [],
|
|
1006
|
+
bundlesError: list.ok === false ? String(list.reason || '读不到') : '',
|
|
1007
|
+
official: official.cards ?? [],
|
|
1008
|
+
officialError: official.ok === false ? String(official.reason || '读不到') : '',
|
|
1009
|
+
entries: inv.entries ?? [],
|
|
1010
|
+
presets: inv.presets ?? [],
|
|
1011
|
+
builtinError: inv.ok === false ? String(inv.reason || '读不到') : '',
|
|
1012
|
+
})
|
|
624
1013
|
return
|
|
625
1014
|
}
|
|
626
1015
|
|
|
@@ -636,6 +1025,43 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
636
1025
|
return
|
|
637
1026
|
}
|
|
638
1027
|
|
|
1028
|
+
// ---- 子智能体 ------------------------------------------------------
|
|
1029
|
+
// 列表取**当前绑定会话**名下那一份(和 send / stop 同一套口径)。
|
|
1030
|
+
// 失败一律 200 + ok:false:这是"这道能力现在没有"或"这台电脑没开这条路",
|
|
1031
|
+
// 不是请求本身有错——用 4xx 会让页面上的重试逻辑把它当网络故障。
|
|
1032
|
+
if (req.method === 'GET' && path === '/mini/api/subagents') {
|
|
1033
|
+
const result = await loadSubagents(store.snapshot().boundSessionId)
|
|
1034
|
+
sendJson(res, 200, result)
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// 某一道子智能体**自己的运行过程**(它自己的会话记录重放出来的聊天记录)。
|
|
1039
|
+
if (req.method === 'GET' && path === '/mini/api/subagent') {
|
|
1040
|
+
const childId = url.searchParams.get('id') ?? ''
|
|
1041
|
+
const result = await nav.subagentTranscript(childId, store.snapshot().boundSessionId)
|
|
1042
|
+
sendJson(res, 200, result)
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ---- 繁忙时的发送行为 ------------------------------------------------
|
|
1047
|
+
// 智能体跑着的时候,你发的那句话是排队等这一轮跑完(queue),还是当场插进去
|
|
1048
|
+
// 让它调整方向(steer)。**PC 上的「设置 → 通用 → 繁忙时的发送行为」就是它**,
|
|
1049
|
+
// 两端共用宿主那一份用户设置文档(用户 2026-10-05 拍板)。
|
|
1050
|
+
//
|
|
1051
|
+
// 读不到就如实说 unavailable,手机那边整条不显示——**不摆一个假的开关**。
|
|
1052
|
+
if (req.method === 'GET' && path === '/mini/api/busy-enter') {
|
|
1053
|
+
const result = await nav.busyEnterSetting()
|
|
1054
|
+
sendJson(res, result.ok ? 200 : 503, result)
|
|
1055
|
+
return
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (req.method === 'POST' && path === '/mini/api/busy-enter') {
|
|
1059
|
+
const body = await readBody(req)
|
|
1060
|
+
const result = await nav.setBusyEnter(body?.value)
|
|
1061
|
+
sendJson(res, result.ok ? 200 : 400, result)
|
|
1062
|
+
return
|
|
1063
|
+
}
|
|
1064
|
+
|
|
639
1065
|
// ---- 斜杠指令列表 ----------------------------------------------------
|
|
640
1066
|
// 手机在输入框里打 `/` 时要显示的那一份。**名单来自 DSH 自己的指令账本**,
|
|
641
1067
|
// 所以电脑上新装一个带指令的插件,手机上刷新就有了,不用改这个插件。
|
|
@@ -661,22 +1087,33 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
661
1087
|
)
|
|
662
1088
|
// includeEmpty:手机能自己新建工作区了,刚建好的那个本来就是空的。
|
|
663
1089
|
// 不列出来的话,建完它当场消失,用户只会以为没建成。
|
|
664
|
-
const workspaces = await
|
|
665
|
-
runningIds, url.searchParams.get('refresh') === '1',
|
|
1090
|
+
const workspaces = await loadWorkspaces(
|
|
1091
|
+
runningIds, url.searchParams.get('refresh') === '1',
|
|
666
1092
|
)
|
|
667
1093
|
sendJson(res, 200, { ok: true, workspaces })
|
|
668
1094
|
return
|
|
669
1095
|
}
|
|
670
1096
|
|
|
1097
|
+
// Agent 模式清单(2026-10-04):手机新建会话时挑模式用。拿不到能力时如实回
|
|
1098
|
+
// unavailable——手机那边会直接建默认模式,**不摆没得选的选择题**。
|
|
1099
|
+
if (path === '/mini/api/agent-presets' && req.method === 'GET') {
|
|
1100
|
+
sendJson(res, 200, await nav.agentPresets?.() ?? { ok: false, reason: 'unavailable' })
|
|
1101
|
+
return
|
|
1102
|
+
}
|
|
1103
|
+
|
|
671
1104
|
const wsSessions = /^\/mini\/api\/workspaces\/([^/]+)\/sessions$/.exec(path)
|
|
672
1105
|
if (req.method === 'GET' && wsSessions) {
|
|
673
1106
|
const asked = Number(url.searchParams.get('limit'))
|
|
674
1107
|
const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, 100) : undefined
|
|
1108
|
+
const workspaceId = decodeURIComponent(wsSessions[1])
|
|
1109
|
+
const started = performance.now()
|
|
675
1110
|
const result = await nav.listSessionsOf(
|
|
676
|
-
|
|
1111
|
+
workspaceId,
|
|
677
1112
|
limit,
|
|
678
1113
|
url.searchParams.get('refresh') === '1',
|
|
1114
|
+
(title) => broadcast('navigation-title', title),
|
|
679
1115
|
)
|
|
1116
|
+
log?.info?.(`导航会话读取 ${Math.round(performance.now() - started)}ms(工作区 ${workspaceId})`)
|
|
680
1117
|
if (!result) {
|
|
681
1118
|
sendJson(res, 404, { ok: false, error: '没有这个工作区。' })
|
|
682
1119
|
return
|
|
@@ -787,8 +1224,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
787
1224
|
|
|
788
1225
|
if (req.method === 'POST' && path === '/mini/api/answer') {
|
|
789
1226
|
const body = await readBody(req)
|
|
790
|
-
|
|
791
|
-
|
|
1227
|
+
// 判据、错误文案都收在 answerQuestion 里——和飞书那条路共用同一处(见上面那段说明)。
|
|
1228
|
+
const out = answerQuestion(body?.id, body, 'phone')
|
|
1229
|
+
if (!out.ok && out.error === 'expired') {
|
|
792
1230
|
// 409 而不是 200:多半是这个提问早就结束了,如实说,别装作收下了。
|
|
793
1231
|
// 落一句痕:这是「手机答了、电脑没动静」唯一能查到的线索——手机那边原来
|
|
794
1232
|
// 把失败咽掉了,插件这边再不留痕,下次还是只能靠猜(2026-09-27)。
|
|
@@ -796,12 +1234,10 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
796
1234
|
sendJson(res, 409, { ok: false, error: '这个提问已经结束了。' })
|
|
797
1235
|
return
|
|
798
1236
|
}
|
|
799
|
-
|
|
800
|
-
if (!answers) {
|
|
1237
|
+
if (!out.ok) {
|
|
801
1238
|
sendJson(res, 400, { ok: false, error: '答案格式不对。' })
|
|
802
1239
|
return
|
|
803
1240
|
}
|
|
804
|
-
waiting.settle({ answers })
|
|
805
1241
|
sendJson(res, 200, { ok: true })
|
|
806
1242
|
return
|
|
807
1243
|
}
|
|
@@ -811,20 +1247,20 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
811
1247
|
// 过期了如实说、格式不对就说格式不对,别装作收下了。
|
|
812
1248
|
if (req.method === 'POST' && path === '/mini/api/approval') {
|
|
813
1249
|
const body = await readBody(req)
|
|
814
|
-
|
|
815
|
-
|
|
1250
|
+
// 手机页交上来的说法是 allow / deny,换成清单里的结论值;飞书那边直接交结论。
|
|
1251
|
+
const decision = body?.decision === 'allow'
|
|
1252
|
+
? 'allowed-once'
|
|
1253
|
+
: (body?.decision === 'deny' ? 'rejected' : body?.decision)
|
|
1254
|
+
const out = decideApproval(body?.id, { ...body, decision }, 'phone')
|
|
1255
|
+
if (!out.ok && out.error === 'expired') {
|
|
816
1256
|
console.log('[dsh-mini-remote] 手机上拍晚了:这条审批已经交回电脑(' + String(body?.id) + '),没接')
|
|
817
1257
|
sendJson(res, 409, { ok: false, error: '这条审批已经结束了。' })
|
|
818
1258
|
return
|
|
819
1259
|
}
|
|
820
|
-
|
|
821
|
-
? 'allowed-once'
|
|
822
|
-
: (body?.decision === 'deny' ? 'rejected' : null)
|
|
823
|
-
if (!decision) {
|
|
1260
|
+
if (!out.ok) {
|
|
824
1261
|
sendJson(res, 400, { ok: false, error: '审批的结论只有同意或拒绝两种。' })
|
|
825
1262
|
return
|
|
826
1263
|
}
|
|
827
|
-
waiting.settle(decision)
|
|
828
1264
|
sendJson(res, 200, { ok: true })
|
|
829
1265
|
return
|
|
830
1266
|
}
|
|
@@ -953,6 +1389,30 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
953
1389
|
return
|
|
954
1390
|
}
|
|
955
1391
|
|
|
1392
|
+
if (path === '/mini/api/subagent/stop') {
|
|
1393
|
+
// 停一道子智能体**当前这一轮**。父会话 id 由服务端自己取,不信请求体里的——
|
|
1394
|
+
// 权限凭据是「谁的父会话」,让手机随便填就等于谁都能停别人的。
|
|
1395
|
+
const childId = typeof body.id === 'string' ? body.id : ''
|
|
1396
|
+
const mode = typeof body.mode === 'string' ? body.mode : ''
|
|
1397
|
+
const result = await nav.stopSubagent(childId, store.snapshot().boundSessionId, mode)
|
|
1398
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
1399
|
+
return
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (path === '/mini/api/subagent/ask') {
|
|
1403
|
+
// 跟一道子智能体接着说一句话。同样,父会话 id 服务端自己取。
|
|
1404
|
+
const childId = typeof body.id === 'string' ? body.id : ''
|
|
1405
|
+
const mode = typeof body.mode === 'string' ? body.mode : ''
|
|
1406
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
1407
|
+
if (!text) {
|
|
1408
|
+
sendJson(res, 400, { ok: false, error: '话是空的。' })
|
|
1409
|
+
return
|
|
1410
|
+
}
|
|
1411
|
+
const result = await nav.askSubagent(childId, store.snapshot().boundSessionId, mode, text)
|
|
1412
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
1413
|
+
return
|
|
1414
|
+
}
|
|
1415
|
+
|
|
956
1416
|
if (path === '/mini/api/unqueue') {
|
|
957
1417
|
// 撤掉一条还在排队的指令。撤的是「手机正在遥控的那个会话」队列里的那一条,
|
|
958
1418
|
// 和 send / stop 同一套口径。
|
|
@@ -966,6 +1426,26 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
966
1426
|
return
|
|
967
1427
|
}
|
|
968
1428
|
|
|
1429
|
+
if (path === '/mini/api/trajectory/subscribe') {
|
|
1430
|
+
// 开/关轨迹推送(见 trajectorySubscribers)。**只点到 clientId 名下那条连接**,
|
|
1431
|
+
// 别的连接一个字节都不受影响——单帧/聊天模式的手机不该为这份体积付钱。
|
|
1432
|
+
const clientId = typeof body.clientId === 'string' ? body.clientId.trim() : ''
|
|
1433
|
+
if (!clientId) {
|
|
1434
|
+
sendJson(res, 400, { ok: false, error: 'clientId 不能空——订阅是按连接记的。' })
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
const on = body.on !== false
|
|
1438
|
+
const hit = setTrajectorySubscription(clientId, on)
|
|
1439
|
+
if (!hit) {
|
|
1440
|
+
// 认不出这条连接:多半是那条流已经断了(或者连流时没报 clientId)。
|
|
1441
|
+
// **如实说没订上**,别让手机以为订上了却一条都收不到——那是最难查的一种静默失败。
|
|
1442
|
+
sendJson(res, 404, { ok: false, error: '这条连接已经不在了,重新连一次流。' })
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
sendJson(res, 200, { ok: true, clientId, subscribed: on, connections: hit, epoch: EPOCH })
|
|
1446
|
+
return
|
|
1447
|
+
}
|
|
1448
|
+
|
|
969
1449
|
// 在一个已有的工作区里新建会话,建完立刻把手机绑过去。
|
|
970
1450
|
//
|
|
971
1451
|
// 为什么排在这么靠后、而不是跟上面那条 GET 挨着:这两条都要读请求体,
|
|
@@ -975,7 +1455,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
975
1455
|
// 建完顺手 bind + 广播:手机发一次请求就完成了「建 + 切过去」,
|
|
976
1456
|
// 不用再补一次 bind,也就不会出现「建好了但没切过去」这种半截状态。
|
|
977
1457
|
if (req.method === 'POST' && wsSessions) {
|
|
978
|
-
|
|
1458
|
+
// agentPreset(2026-10-04,手机上也能选模式):选了才带,没选让 DSH 用默认。
|
|
1459
|
+
const preset = typeof body.agentPreset === 'string' && body.agentPreset.trim()
|
|
1460
|
+
? body.agentPreset.trim()
|
|
1461
|
+
: undefined
|
|
1462
|
+
const created = await nav.createSession(decodeURIComponent(wsSessions[1]), preset)
|
|
979
1463
|
if (!created.ok) {
|
|
980
1464
|
const known = {
|
|
981
1465
|
'no-controller': [503, '这台电脑上的 DSH 没提供新建会话的能力。'],
|
|
@@ -987,6 +1471,8 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
987
1471
|
return
|
|
988
1472
|
}
|
|
989
1473
|
store.bind(created.sessionId)
|
|
1474
|
+
// 新会话会改变工作区的数量;下次打开导航栏必须重新整理一次。
|
|
1475
|
+
invalidateNavigation()
|
|
990
1476
|
const snap = store.snapshot()
|
|
991
1477
|
broadcast('state', snap)
|
|
992
1478
|
sendJson(res, 200, {
|
|
@@ -1042,7 +1528,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1042
1528
|
}
|
|
1043
1529
|
// 登记完立刻把最新的工作区列表带回去:手机拿到就能直接画出来,
|
|
1044
1530
|
// 不用再补一趟请求,也就不会出现「建好了但列表里还没有」。
|
|
1045
|
-
const workspaces = await
|
|
1531
|
+
const workspaces = await loadWorkspaces(new Set(), true)
|
|
1046
1532
|
sendJson(res, 200, {
|
|
1047
1533
|
ok: true, created: made.created, workspace: made.workspace, workspaces,
|
|
1048
1534
|
})
|
|
@@ -1182,6 +1668,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1182
1668
|
/** 想绑但失败的地址,交给调用方决定怎么提示。 */
|
|
1183
1669
|
failed,
|
|
1184
1670
|
broadcast,
|
|
1671
|
+
/** 执行轨迹的增量帧:只发给声明了「完整」模式的那几条连接(见 broadcastTrajectory)。 */
|
|
1672
|
+
broadcastTrajectory,
|
|
1673
|
+
/** 轨迹的活片段帧:同一批连接,不占 seq/epoch(见 broadcastTrajectoryLive)。 */
|
|
1674
|
+
broadcastTrajectoryLive,
|
|
1675
|
+
invalidateNavigation,
|
|
1676
|
+
invalidateSubagents,
|
|
1185
1677
|
close: async () => {
|
|
1186
1678
|
for (const res of clients) {
|
|
1187
1679
|
try { res.end() } catch { /* 已经断了 */ }
|