dsh-mini-remote 0.1.6 → 0.1.8
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 +28 -12
- package/README.en.md +33 -1
- package/README.md +33 -1
- package/client/client.js +272 -1
- package/lib/events.js +30 -4
- package/lib/history.js +15 -2
- package/lib/index.js +658 -10
- package/lib/lark.js +1154 -0
- package/lib/page.html +477 -3
- package/lib/page.js +14 -2
- package/lib/pairing.js +32 -3
- package/lib/server.js +256 -26
- package/lib/store.js +22 -0
- package/lib/tree.js +78 -8
- package/package.json +2 -1
- package/lib/art/out-4legs-fix.gif +0 -0
- package/lib/art/out-anim-all.gif +0 -0
- package/lib/art/out-anim-carousel.gif +0 -0
- package/lib/art/out-anim-run-now-real.gif +0 -0
- package/lib/art/out-anim-run-real.gif +0 -0
- package/lib/art/out-anim-run-slow.gif +0 -0
- package/lib/art/out-anim-run.gif +0 -0
- package/lib/art/out-anim-sprint-real.gif +0 -0
- package/lib/art/out-anim-sprint-slow.gif +0 -0
- package/lib/art/out-anim-sprint.gif +0 -0
- package/lib/art/out-sway-sprint.gif +0 -0
package/lib/pairing.js
CHANGED
|
@@ -140,8 +140,9 @@ async function qr(text) {
|
|
|
140
140
|
* @param {string[]} [opts.bound] 真正绑定成功的地址;不传则用探测结果
|
|
141
141
|
* @param {{enabled?: boolean, url?: string|null, starting?: boolean, error?: string|null}} [opts.tunnel] 公网隧道状态
|
|
142
142
|
* @param {{installed?: boolean, url?: string|null, urlOfOtherPort?: string|null, error?: string|null}} [opts.serve] Tailscale HTTPS 状态
|
|
143
|
+
* @param {{enabled?: boolean, appId?: string, openIds?: string, chatIds?: string, hasSecret?: boolean, starting?: boolean, running?: boolean, connected?: boolean, rejected?: {reason: string, at: number}|null, error?: string|null}} [opts.feishu] 飞书那一块的现状(**不含 appSecret**)
|
|
143
144
|
*/
|
|
144
|
-
export async function buildPairing({ port, token, bound, tunnel, serve }) {
|
|
145
|
+
export async function buildPairing({ port, token, bound, tunnel, serve, feishu }) {
|
|
145
146
|
const detected = reachableAddresses()
|
|
146
147
|
const usable = bound ? detected.filter((a) => bound.includes(a.address)) : detected
|
|
147
148
|
|
|
@@ -200,6 +201,32 @@ export async function buildPairing({ port, token, bound, tunnel, serve }) {
|
|
|
200
201
|
error: tunnel?.error ?? null,
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* 飞书那一块的现状,**四种返回路径都要带上**(和 `serve` 同一个道理,见下面)。
|
|
206
|
+
*
|
|
207
|
+
* 这里**只回答「有没有凭据」**,绝不把 appSecret 本身送出去——这个字段会进浏览器。
|
|
208
|
+
* `openIds` / `chatIds` 是名单,可以回显:用户得看得见自己填的是什么。
|
|
209
|
+
*/
|
|
210
|
+
const feishuStatus = {
|
|
211
|
+
enabled: Boolean(feishu?.enabled),
|
|
212
|
+
appId: feishu?.appId ?? '',
|
|
213
|
+
openIds: feishu?.openIds ?? '',
|
|
214
|
+
chatIds: feishu?.chatIds ?? '',
|
|
215
|
+
hasSecret: Boolean(feishu?.hasSecret),
|
|
216
|
+
starting: Boolean(feishu?.starting),
|
|
217
|
+
running: Boolean(feishu?.running),
|
|
218
|
+
connected: Boolean(feishu?.connected),
|
|
219
|
+
/**
|
|
220
|
+
* 上一次被白名单挡住的来源,原样交给界面。
|
|
221
|
+
*
|
|
222
|
+
* 这是**新用户唯一走得通的那条路**:名单默认空着(谁都不认),他不可能凭空知道
|
|
223
|
+
* 自己的 open_id 长什么样。第一次发消息被挡下时,把「来源是谁」当场显示出来,
|
|
224
|
+
* 抄进名单再发一次就通了——少了这一步,用户会卡死在「开了但没反应」。
|
|
225
|
+
*/
|
|
226
|
+
rejected: feishu?.rejected ?? null,
|
|
227
|
+
error: feishu?.error ?? null,
|
|
228
|
+
}
|
|
229
|
+
|
|
203
230
|
/**
|
|
204
231
|
* Tailscale HTTPS 的现状,**四种返回路径都要带上**。
|
|
205
232
|
*
|
|
@@ -243,7 +270,7 @@ export async function buildPairing({ port, token, bound, tunnel, serve }) {
|
|
|
243
270
|
// 一条能用的都没有。三种成因要分开说,否则用户不知道该去改配置、去查网络,
|
|
244
271
|
// 还是去查隧道——这三件事的下一步动作完全不同。
|
|
245
272
|
if (status.enabled && status.error) {
|
|
246
|
-
return { ok: false, error: `公网访问没起来:${status.error}`, tunnel: status, tailscale, serve: httpsStatus }
|
|
273
|
+
return { ok: false, error: `公网访问没起来:${status.error}`, tunnel: status, tailscale, serve: httpsStatus, feishu: feishuStatus }
|
|
247
274
|
}
|
|
248
275
|
if (detected.length) {
|
|
249
276
|
return {
|
|
@@ -253,6 +280,7 @@ export async function buildPairing({ port, token, bound, tunnel, serve }) {
|
|
|
253
280
|
tunnel: status,
|
|
254
281
|
tailscale,
|
|
255
282
|
serve: httpsStatus,
|
|
283
|
+
feishu: feishuStatus,
|
|
256
284
|
}
|
|
257
285
|
}
|
|
258
286
|
return {
|
|
@@ -261,8 +289,9 @@ export async function buildPairing({ port, token, bound, tunnel, serve }) {
|
|
|
261
289
|
tunnel: status,
|
|
262
290
|
tailscale,
|
|
263
291
|
serve: httpsStatus,
|
|
292
|
+
feishu: feishuStatus,
|
|
264
293
|
}
|
|
265
294
|
}
|
|
266
295
|
|
|
267
|
-
return { ok: true, port, token, entries, tunnel: status, tailscale, serve: httpsStatus }
|
|
296
|
+
return { ok: true, port, token, entries, tunnel: status, tailscale, serve: httpsStatus, feishu: feishuStatus }
|
|
268
297
|
}
|
package/lib/server.js
CHANGED
|
@@ -168,12 +168,45 @@ 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
|
+
* @param {() => (string|undefined)[]} [opts.imageRoots] 正文里相对路径图的额外候选根
|
|
172
|
+
* (由插件递进「当前已注册工作区的目录」,见 extraImageRoots 那段)
|
|
171
173
|
* @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>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
|
+
// 导航栏的数据比普通状态更适合做 stale-while-revalidate:工作区和会话不会因为
|
|
180
|
+
// 用户打开一次菜单就必须重新清点。第一次没有缓存时仍然等待真实结果;之后先回上次
|
|
181
|
+
// 整理好的列表,再在后台刷新。手动点刷新时由请求方显式 force,保持原来的语义。
|
|
182
|
+
const NAV_CACHE_TTL_MS = 30_000
|
|
183
|
+
let navWorkspaceCache = { value: null, at: 0, inflight: null }
|
|
184
|
+
|
|
185
|
+
async function loadWorkspaces(runningIds, force = false) {
|
|
186
|
+
const now = Date.now()
|
|
187
|
+
const fresh = navWorkspaceCache.value && now - navWorkspaceCache.at < NAV_CACHE_TTL_MS
|
|
188
|
+
if (!force && navWorkspaceCache.value) {
|
|
189
|
+
if (!fresh && !navWorkspaceCache.inflight) {
|
|
190
|
+
navWorkspaceCache.inflight = refreshWorkspaces(runningIds, false)
|
|
191
|
+
.finally(() => { navWorkspaceCache.inflight = null })
|
|
192
|
+
}
|
|
193
|
+
return navWorkspaceCache.value
|
|
194
|
+
}
|
|
195
|
+
if (navWorkspaceCache.inflight) return navWorkspaceCache.inflight
|
|
196
|
+
navWorkspaceCache.inflight = refreshWorkspaces(runningIds, force)
|
|
197
|
+
.finally(() => { navWorkspaceCache.inflight = null })
|
|
198
|
+
return navWorkspaceCache.inflight
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function refreshWorkspaces(runningIds, force) {
|
|
202
|
+
const started = performance.now()
|
|
203
|
+
const value = await nav.listWorkspaces(runningIds, force, true)
|
|
204
|
+
navWorkspaceCache = { value, at: Date.now(), inflight: null }
|
|
205
|
+
const elapsed = Math.round(performance.now() - started)
|
|
206
|
+
log?.info?.(`导航工作区读取 ${elapsed}ms(${value.length} 个)`)
|
|
207
|
+
return value
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
// 没传 tree(测试里就是这样,headless 组合里也拿不到那两个服务)时给个空壳,
|
|
178
211
|
// 导航栏显示「取不到」,遥控本身照常。
|
|
179
212
|
// `createSession` 的空壳回 `no-controller`,跟真服务缺位时走同一条错误路径——
|
|
@@ -188,6 +221,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
188
221
|
// 模型那两个的**空壳**:测试里没有真的 DSH,照上面的样式如实说"没这个能力"。
|
|
189
222
|
modelCatalog: async () => ({ ok: false, reason: 'unavailable' }),
|
|
190
223
|
selectModel: async () => ({ ok: false, reason: 'unavailable' }),
|
|
224
|
+
// 子智能体那四个的空壳,同样如实说"没这个能力",不假装有一份空列表。
|
|
225
|
+
subagents: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
226
|
+
subagentTranscript: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
227
|
+
stopSubagent: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
228
|
+
askSubagent: async () => ({ ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }),
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
// 目录浏览的空壳。它**只依赖 node:fs**,不像 tree 那样依赖 DSH 的服务,
|
|
@@ -198,6 +236,39 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
198
236
|
makeDirectory: async () => ({ ok: false, reason: 'unavailable' }),
|
|
199
237
|
}
|
|
200
238
|
|
|
239
|
+
/**
|
|
240
|
+
* 正文里写相对路径的图(``)要多试的几个根:**当前已注册的工作区目录**。
|
|
241
|
+
*
|
|
242
|
+
* 为什么需要它(2026-09-28 实测确诊):正文里的相对路径相对的是**那段对话的工作目录**,
|
|
243
|
+
* 而它可能是别的项目——那次是 `I:\DSH\dsh-jev-ultrafast`,正文写的是
|
|
244
|
+
* `scratch/preview-0.2.3-settings.png`。插件原先只按自己的两个根(仓库根、lib/art)找,
|
|
245
|
+
* 于是 404,手机上就是破图;图就在盘上,接口本身也没毛病。
|
|
246
|
+
*
|
|
247
|
+
* **只放宽「在哪找」。** 仍然要令牌、仍然只发图片扩展名、仍然卡体积、相对写法里的 `..`
|
|
248
|
+
* 仍然被拒(后两条在 page.js 的 readImage 里,这一版一个字没动)。
|
|
249
|
+
* 额外根拿不到(服务还没起来)时退回空列表,也就是原来的行为。
|
|
250
|
+
*
|
|
251
|
+
* 缓存 30 秒:列一遍注册表要遍历所有工作区,而这张表几乎不变;晚半分钟知道新建的工作区
|
|
252
|
+
* 没有影响——图的地址是用户在正文里写的,不会正好卡在那半分钟上。
|
|
253
|
+
*/
|
|
254
|
+
const IMAGE_ROOTS_TTL_MS = 30_000
|
|
255
|
+
let imageRootsCache = { at: 0, list: [] }
|
|
256
|
+
function extraImageRoots() {
|
|
257
|
+
const now = Date.now()
|
|
258
|
+
if (now - imageRootsCache.at < IMAGE_ROOTS_TTL_MS) return imageRootsCache.list
|
|
259
|
+
let list = []
|
|
260
|
+
try {
|
|
261
|
+
const got = imageRoots?.()
|
|
262
|
+
// 去重:工作区里本来就可能包含插件根那个目录,重复只换来几次白跑的系统调用。
|
|
263
|
+
if (Array.isArray(got)) list = [...new Set(got.filter((r) => typeof r === 'string' && r))]
|
|
264
|
+
} catch {
|
|
265
|
+
// `workspaceRegistry.list()` 在服务还没就绪时会抛。少认几张图,不该拖垮整条取图的路。
|
|
266
|
+
list = []
|
|
267
|
+
}
|
|
268
|
+
imageRootsCache = { at: now, list }
|
|
269
|
+
return list
|
|
270
|
+
}
|
|
271
|
+
|
|
201
272
|
function broadcast(event, data) {
|
|
202
273
|
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
203
274
|
for (const res of clients) {
|
|
@@ -237,27 +308,81 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
237
308
|
return clients.size > 0 || (phoneSeenAt > 0 && Date.now() - phoneSeenAt < PHONE_GRACE_MS)
|
|
238
309
|
}
|
|
239
310
|
|
|
311
|
+
/**
|
|
312
|
+
* 「除了手机,还有别人在等这道题吗」——**收摊判据的第二个来源**。
|
|
313
|
+
*
|
|
314
|
+
* 手机全断开就立刻把问题还给电脑(上面那条,防的是永远卡住);可飞书那条路
|
|
315
|
+
* (见 lib/lark.js)也能答同一道题,手机上没人不等于没人答。所以由调用方传一个
|
|
316
|
+
* `keepAlive()` 进来,它说「还挂着」就不收摊。
|
|
317
|
+
*
|
|
318
|
+
* **绝不因为这一句把题卡死**:没传、或者它抛了,一律当「没人在等」——宁可还给电脑,
|
|
319
|
+
* 也不能因为另一条路的内部错误让问题悬在半空。
|
|
320
|
+
*/
|
|
321
|
+
function stillWaited(opts) {
|
|
322
|
+
try {
|
|
323
|
+
return opts?.keepAlive?.() === true
|
|
324
|
+
} catch {
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 一道题/一条审批刚刚有结果了——**主动告诉手机页**。
|
|
331
|
+
*
|
|
332
|
+
* 为什么必须有这一条:手机和飞书可能同时看着同一道题(用户就在两个界面之间切)。
|
|
333
|
+
* 谁先答谁生效的语义本来就有(后到的那边拿到 `expired`),但那只在**后到的人自己动手**
|
|
334
|
+
* 时才说话。手机页那张卡片是推一次就留在屏幕上的,用户在飞书里答完,手机上那张还挂在
|
|
335
|
+
* 原地——他没法知道这道题已经有人答了,只能盯着它发愣(2026-10-02 真机就是这个现象)。
|
|
336
|
+
*
|
|
337
|
+
* 所以这里用**现成的 SSE 通道**补一条状态:手机页收到就把卡片收起来、换一行说明。
|
|
338
|
+
* 只说「哪一道、谁答的」,不重复推题目本身——两边都不重复打扰。
|
|
339
|
+
*/
|
|
340
|
+
function notifySettled(kind, id, source) {
|
|
341
|
+
broadcast(`${kind}-done`, { id, source: source ?? '' })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 题挂上之后当场告诉调用方它的编号。
|
|
346
|
+
*
|
|
347
|
+
* **飞书那条路就靠这个号对号入座**(用户回的那句话是对哪一道题的答复),交出去的是
|
|
348
|
+
* 这一层内部用的编号(`q1`/`a1` 这种),不是界面上的顺序号。回调抛错只写一行日志,
|
|
349
|
+
* 不影响这道题本身——推不出去的只是「另一个界面也来问」这件事。
|
|
350
|
+
*/
|
|
351
|
+
function notifyPending(opts, id) {
|
|
352
|
+
try {
|
|
353
|
+
opts?.onPending?.(id)
|
|
354
|
+
} catch (err) {
|
|
355
|
+
log?.warn?.(`遥控服务:通知另一条通路失败(${err?.message ?? err})`)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
240
359
|
/**
|
|
241
360
|
* 把这个提问推到手机上,等它答。
|
|
361
|
+
* @param {{keepAlive?: () => boolean, onPending?: (id: string) => void}} [opts]
|
|
362
|
+
* 见上面 stillWaited / notifyPending 两段说明。
|
|
242
363
|
* @returns 答案对象;手机没了就返回 null,调用方据此**把问题还给电脑**。
|
|
243
364
|
*/
|
|
244
|
-
function askPhone(questions, agentId) {
|
|
365
|
+
function askPhone(questions, agentId, opts) {
|
|
245
366
|
const id = 'q' + (++questionSeq)
|
|
246
367
|
return new Promise((resolve) => {
|
|
247
368
|
let watch = null
|
|
248
|
-
const settle = (value) => {
|
|
369
|
+
const settle = (value, from) => {
|
|
249
370
|
if (!waitingQuestions.has(id)) return
|
|
250
371
|
waitingQuestions.delete(id)
|
|
251
372
|
if (watch) clearInterval(watch)
|
|
373
|
+
// 先通知、再落定:谁先答谁生效的语义一个字没动,这里只是**把结果说出去**,
|
|
374
|
+
// 让另一侧那张还挂着的卡片自己收起来(见 notifySettled)。
|
|
375
|
+
notifySettled('question', id, from ?? opts?.source)
|
|
252
376
|
resolve(value)
|
|
253
377
|
}
|
|
254
378
|
// 题目本身也存进来(不只是 settle):手机中途重连上来时要能补发给它,见 replayQuestions。
|
|
255
379
|
waitingQuestions.set(id, { settle, agentId, questions })
|
|
380
|
+
notifyPending(opts, id)
|
|
256
381
|
broadcast('question', { id, agentId, questions })
|
|
257
382
|
// 用户选的是「一直等,不还回电脑」——但那不等于「手机都没了还等」。
|
|
258
383
|
// 手机一旦全断开(锁屏、关了页面、网断),立刻收摊还给电脑,否则会永远卡住。
|
|
259
384
|
watch = setInterval(() => {
|
|
260
|
-
if (!hasPhone()) settle(null)
|
|
385
|
+
if (!hasPhone() && !stillWaited(opts)) settle(null)
|
|
261
386
|
}, 2000)
|
|
262
387
|
})
|
|
263
388
|
}
|
|
@@ -294,16 +419,20 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
294
419
|
|
|
295
420
|
/**
|
|
296
421
|
* 把一条命令确认推到手机上,等它拍板。
|
|
422
|
+
* @param {{keepAlive?: () => boolean, onPending?: (id: string) => void}} [opts]
|
|
423
|
+
* 和提问同一条:飞书那边也能拍这一板(见 lib/lark.js)。
|
|
297
424
|
* @returns 'allowed-once' 或 'rejected';手机没了返回 null,调用方据此还给电脑。
|
|
298
425
|
*/
|
|
299
|
-
function askApproval(req, agentId, command) {
|
|
426
|
+
function askApproval(req, agentId, command, opts) {
|
|
300
427
|
const id = 'a' + (++approvalSeq)
|
|
301
428
|
return new Promise((resolve) => {
|
|
302
429
|
let watch = null
|
|
303
|
-
const settle = (value) => {
|
|
430
|
+
const settle = (value, from) => {
|
|
304
431
|
if (!waitingApprovals.has(id)) return
|
|
305
432
|
waitingApprovals.delete(id)
|
|
306
433
|
if (watch) clearInterval(watch)
|
|
434
|
+
// 和提问那条同一句:通知另一侧那张卡片自己收起来(见 notifySettled)。
|
|
435
|
+
notifySettled('approval', id, from ?? opts?.source)
|
|
307
436
|
resolve(value)
|
|
308
437
|
}
|
|
309
438
|
// 推给手机的就是这几样:工具名 + 为什么来问我。**不带命令正文**——
|
|
@@ -317,10 +446,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
317
446
|
command: typeof command === 'string' ? command : '',
|
|
318
447
|
}
|
|
319
448
|
waitingApprovals.set(id, { settle, agentId, view })
|
|
449
|
+
notifyPending(opts, id)
|
|
320
450
|
broadcast('approval', view)
|
|
321
|
-
//
|
|
451
|
+
// 和提问同一条判据:**不倒计时,看手机还在不在**。全断开了就立刻收摊还给电脑
|
|
452
|
+
// (飞书那边还挂着就除外,见 stillWaited)。
|
|
322
453
|
watch = setInterval(() => {
|
|
323
|
-
if (!hasPhone()) settle(null)
|
|
454
|
+
if (!hasPhone() && !stillWaited(opts)) settle(null)
|
|
324
455
|
}, 2000)
|
|
325
456
|
})
|
|
326
457
|
}
|
|
@@ -335,11 +466,62 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
335
466
|
}
|
|
336
467
|
}
|
|
337
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* 把一道题的答案交回去。**这是答题的唯一入口**,两个界面都走它:
|
|
471
|
+
* 手机页:POST /mini/api/answer
|
|
472
|
+
* 飞书: miniControl.answerQuestion(见 lib/lark.js 的 onAnswer)
|
|
473
|
+
*
|
|
474
|
+
* 为什么收成一处:手机和飞书可能**同时**看着同一道题(用户就在两个人机界面之间切换),
|
|
475
|
+
* 「谁先答谁生效、另一个自动失效」不该写成两遍——这里只有一个 settle,第二个来的人
|
|
476
|
+
* 自然拿到 `expired`。这是现成的语义,不是为飞书新加的一套。
|
|
477
|
+
*
|
|
478
|
+
* @param {string} id 题号(waitingQuestions 里的那个)
|
|
479
|
+
* @param {{answers?: Array, cancel?: boolean}} input `cancel:true` = 不答:
|
|
480
|
+
* 按 settle(null) 处理,也就是和「手机全断了」**同一条路**——
|
|
481
|
+
* 调用方据此把问题还给电脑(见 lib/index.js 的 .then(answer => !answer → next()))。
|
|
482
|
+
* @param {'phone'|'feishu'} [from] 谁答的。**只用来通知另一侧**(见 notifySettled):
|
|
483
|
+
* 「已在飞书答过」这句话要有出处,猜不得。
|
|
484
|
+
* @returns {{ok: boolean, error?: 'expired'|'bad'}}
|
|
485
|
+
*/
|
|
486
|
+
function answerQuestion(id, input, from) {
|
|
487
|
+
const entry = waitingQuestions.get(id)
|
|
488
|
+
if (!entry) return { ok: false, error: 'expired' }
|
|
489
|
+
if (input?.cancel === true) {
|
|
490
|
+
entry.settle(null, from)
|
|
491
|
+
return { ok: true }
|
|
492
|
+
}
|
|
493
|
+
if (!Array.isArray(input?.answers)) return { ok: false, error: 'bad' }
|
|
494
|
+
entry.settle({ answers: input.answers }, from)
|
|
495
|
+
return { ok: true }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 把一条审批的结论交回去。形状、理由和 answerQuestion 一模一样(见上面那段)——
|
|
500
|
+
* 只是交回去的东西是结论,且**只认那两个值**:一个没见过的结论宁可说 bad,
|
|
501
|
+
* 也不能让电脑收到一个它看不懂的答复。
|
|
502
|
+
* @param {'phone'|'feishu'} [from] 谁拍的板。只用来通知另一侧(同 answerQuestion)。
|
|
503
|
+
*/
|
|
504
|
+
function decideApproval(id, input, from) {
|
|
505
|
+
const entry = waitingApprovals.get(id)
|
|
506
|
+
if (!entry) return { ok: false, error: 'expired' }
|
|
507
|
+
if (input?.cancel === true) {
|
|
508
|
+
entry.settle(null, from)
|
|
509
|
+
return { ok: true }
|
|
510
|
+
}
|
|
511
|
+
if (input?.decision !== 'allowed-once' && input?.decision !== 'rejected') {
|
|
512
|
+
return { ok: false, error: 'bad' }
|
|
513
|
+
}
|
|
514
|
+
entry.settle(input.decision, from)
|
|
515
|
+
return { ok: true }
|
|
516
|
+
}
|
|
517
|
+
|
|
338
518
|
// 把这两个能力放到插件够得着的固定位置(见文件上方 `miniControl` 的说明)。
|
|
339
519
|
// 放在这里是因为它俩都已定义(函数声明本来也会提升),且不受下面那堆路由逻辑影响。
|
|
340
520
|
miniControl.hasPhone = hasPhone
|
|
341
521
|
miniControl.askPhone = askPhone
|
|
342
522
|
miniControl.askApproval = askApproval
|
|
523
|
+
miniControl.answerQuestion = answerQuestion
|
|
524
|
+
miniControl.decideApproval = decideApproval
|
|
343
525
|
|
|
344
526
|
/**
|
|
345
527
|
* 把 UA 和来源地址压成一句人看的:设备类型 + 地址。
|
|
@@ -468,10 +650,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
468
650
|
// 取图有两条路,都挂在这个前缀下:
|
|
469
651
|
// ① /mini/art/<名字>.webp —— 立绘。名字受白名单限制,地址里带构建指纹,可以长缓存。
|
|
470
652
|
// ② /mini/art/?p=<路径> —— 正文里直接写了一张图在电脑上的位置(绝对路径,或相对
|
|
471
|
-
//
|
|
653
|
+
// 项目根/lib/art/已注册的工作区目录)。只发图片类型,地址里没有指纹,所以不
|
|
654
|
+
// 让浏览器缓存。
|
|
472
655
|
const wanted = url.searchParams.get('p')
|
|
473
656
|
if (wanted) {
|
|
474
|
-
const hit = readImage(wanted)
|
|
657
|
+
const hit = readImage(wanted, extraImageRoots())
|
|
475
658
|
if (!hit) {
|
|
476
659
|
sendJson(res, 404, { error: 'not found' })
|
|
477
660
|
return
|
|
@@ -636,6 +819,24 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
636
819
|
return
|
|
637
820
|
}
|
|
638
821
|
|
|
822
|
+
// ---- 子智能体 ------------------------------------------------------
|
|
823
|
+
// 列表取**当前绑定会话**名下那一份(和 send / stop 同一套口径)。
|
|
824
|
+
// 失败一律 200 + ok:false:这是"这道能力现在没有"或"这台电脑没开这条路",
|
|
825
|
+
// 不是请求本身有错——用 4xx 会让页面上的重试逻辑把它当网络故障。
|
|
826
|
+
if (req.method === 'GET' && path === '/mini/api/subagents') {
|
|
827
|
+
const result = await nav.subagents(store.snapshot().boundSessionId)
|
|
828
|
+
sendJson(res, 200, result)
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// 某一道子智能体**自己的运行过程**(它自己的会话记录重放出来的聊天记录)。
|
|
833
|
+
if (req.method === 'GET' && path === '/mini/api/subagent') {
|
|
834
|
+
const childId = url.searchParams.get('id') ?? ''
|
|
835
|
+
const result = await nav.subagentTranscript(childId, store.snapshot().boundSessionId)
|
|
836
|
+
sendJson(res, 200, result)
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
|
|
639
840
|
// ---- 斜杠指令列表 ----------------------------------------------------
|
|
640
841
|
// 手机在输入框里打 `/` 时要显示的那一份。**名单来自 DSH 自己的指令账本**,
|
|
641
842
|
// 所以电脑上新装一个带指令的插件,手机上刷新就有了,不用改这个插件。
|
|
@@ -661,8 +862,8 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
661
862
|
)
|
|
662
863
|
// includeEmpty:手机能自己新建工作区了,刚建好的那个本来就是空的。
|
|
663
864
|
// 不列出来的话,建完它当场消失,用户只会以为没建成。
|
|
664
|
-
const workspaces = await
|
|
665
|
-
runningIds, url.searchParams.get('refresh') === '1',
|
|
865
|
+
const workspaces = await loadWorkspaces(
|
|
866
|
+
runningIds, url.searchParams.get('refresh') === '1',
|
|
666
867
|
)
|
|
667
868
|
sendJson(res, 200, { ok: true, workspaces })
|
|
668
869
|
return
|
|
@@ -672,11 +873,15 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
672
873
|
if (req.method === 'GET' && wsSessions) {
|
|
673
874
|
const asked = Number(url.searchParams.get('limit'))
|
|
674
875
|
const limit = Number.isFinite(asked) && asked > 0 ? Math.min(asked, 100) : undefined
|
|
876
|
+
const workspaceId = decodeURIComponent(wsSessions[1])
|
|
877
|
+
const started = performance.now()
|
|
675
878
|
const result = await nav.listSessionsOf(
|
|
676
|
-
|
|
879
|
+
workspaceId,
|
|
677
880
|
limit,
|
|
678
881
|
url.searchParams.get('refresh') === '1',
|
|
882
|
+
(title) => broadcast('navigation-title', title),
|
|
679
883
|
)
|
|
884
|
+
log?.info?.(`导航会话读取 ${Math.round(performance.now() - started)}ms(工作区 ${workspaceId})`)
|
|
680
885
|
if (!result) {
|
|
681
886
|
sendJson(res, 404, { ok: false, error: '没有这个工作区。' })
|
|
682
887
|
return
|
|
@@ -787,8 +992,9 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
787
992
|
|
|
788
993
|
if (req.method === 'POST' && path === '/mini/api/answer') {
|
|
789
994
|
const body = await readBody(req)
|
|
790
|
-
|
|
791
|
-
|
|
995
|
+
// 判据、错误文案都收在 answerQuestion 里——和飞书那条路共用同一处(见上面那段说明)。
|
|
996
|
+
const out = answerQuestion(body?.id, body, 'phone')
|
|
997
|
+
if (!out.ok && out.error === 'expired') {
|
|
792
998
|
// 409 而不是 200:多半是这个提问早就结束了,如实说,别装作收下了。
|
|
793
999
|
// 落一句痕:这是「手机答了、电脑没动静」唯一能查到的线索——手机那边原来
|
|
794
1000
|
// 把失败咽掉了,插件这边再不留痕,下次还是只能靠猜(2026-09-27)。
|
|
@@ -796,12 +1002,10 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
796
1002
|
sendJson(res, 409, { ok: false, error: '这个提问已经结束了。' })
|
|
797
1003
|
return
|
|
798
1004
|
}
|
|
799
|
-
|
|
800
|
-
if (!answers) {
|
|
1005
|
+
if (!out.ok) {
|
|
801
1006
|
sendJson(res, 400, { ok: false, error: '答案格式不对。' })
|
|
802
1007
|
return
|
|
803
1008
|
}
|
|
804
|
-
waiting.settle({ answers })
|
|
805
1009
|
sendJson(res, 200, { ok: true })
|
|
806
1010
|
return
|
|
807
1011
|
}
|
|
@@ -811,20 +1015,20 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
811
1015
|
// 过期了如实说、格式不对就说格式不对,别装作收下了。
|
|
812
1016
|
if (req.method === 'POST' && path === '/mini/api/approval') {
|
|
813
1017
|
const body = await readBody(req)
|
|
814
|
-
|
|
815
|
-
|
|
1018
|
+
// 手机页交上来的说法是 allow / deny,换成清单里的结论值;飞书那边直接交结论。
|
|
1019
|
+
const decision = body?.decision === 'allow'
|
|
1020
|
+
? 'allowed-once'
|
|
1021
|
+
: (body?.decision === 'deny' ? 'rejected' : body?.decision)
|
|
1022
|
+
const out = decideApproval(body?.id, { ...body, decision }, 'phone')
|
|
1023
|
+
if (!out.ok && out.error === 'expired') {
|
|
816
1024
|
console.log('[dsh-mini-remote] 手机上拍晚了:这条审批已经交回电脑(' + String(body?.id) + '),没接')
|
|
817
1025
|
sendJson(res, 409, { ok: false, error: '这条审批已经结束了。' })
|
|
818
1026
|
return
|
|
819
1027
|
}
|
|
820
|
-
|
|
821
|
-
? 'allowed-once'
|
|
822
|
-
: (body?.decision === 'deny' ? 'rejected' : null)
|
|
823
|
-
if (!decision) {
|
|
1028
|
+
if (!out.ok) {
|
|
824
1029
|
sendJson(res, 400, { ok: false, error: '审批的结论只有同意或拒绝两种。' })
|
|
825
1030
|
return
|
|
826
1031
|
}
|
|
827
|
-
waiting.settle(decision)
|
|
828
1032
|
sendJson(res, 200, { ok: true })
|
|
829
1033
|
return
|
|
830
1034
|
}
|
|
@@ -953,6 +1157,30 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
953
1157
|
return
|
|
954
1158
|
}
|
|
955
1159
|
|
|
1160
|
+
if (path === '/mini/api/subagent/stop') {
|
|
1161
|
+
// 停一道子智能体**当前这一轮**。父会话 id 由服务端自己取,不信请求体里的——
|
|
1162
|
+
// 权限凭据是「谁的父会话」,让手机随便填就等于谁都能停别人的。
|
|
1163
|
+
const childId = typeof body.id === 'string' ? body.id : ''
|
|
1164
|
+
const mode = typeof body.mode === 'string' ? body.mode : ''
|
|
1165
|
+
const result = await nav.stopSubagent(childId, store.snapshot().boundSessionId, mode)
|
|
1166
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (path === '/mini/api/subagent/ask') {
|
|
1171
|
+
// 跟一道子智能体接着说一句话。同样,父会话 id 服务端自己取。
|
|
1172
|
+
const childId = typeof body.id === 'string' ? body.id : ''
|
|
1173
|
+
const mode = typeof body.mode === 'string' ? body.mode : ''
|
|
1174
|
+
const text = typeof body.text === 'string' ? body.text.trim() : ''
|
|
1175
|
+
if (!text) {
|
|
1176
|
+
sendJson(res, 400, { ok: false, error: '话是空的。' })
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
const result = await nav.askSubagent(childId, store.snapshot().boundSessionId, mode, text)
|
|
1180
|
+
sendJson(res, result.ok ? 200 : 409, result)
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
|
|
956
1184
|
if (path === '/mini/api/unqueue') {
|
|
957
1185
|
// 撤掉一条还在排队的指令。撤的是「手机正在遥控的那个会话」队列里的那一条,
|
|
958
1186
|
// 和 send / stop 同一套口径。
|
|
@@ -987,6 +1215,8 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
987
1215
|
return
|
|
988
1216
|
}
|
|
989
1217
|
store.bind(created.sessionId)
|
|
1218
|
+
// 新会话会改变工作区的数量;下次打开导航栏必须重新整理一次。
|
|
1219
|
+
navWorkspaceCache = { value: null, at: 0, inflight: null }
|
|
990
1220
|
const snap = store.snapshot()
|
|
991
1221
|
broadcast('state', snap)
|
|
992
1222
|
sendJson(res, 200, {
|
|
@@ -1042,7 +1272,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1042
1272
|
}
|
|
1043
1273
|
// 登记完立刻把最新的工作区列表带回去:手机拿到就能直接画出来,
|
|
1044
1274
|
// 不用再补一趟请求,也就不会出现「建好了但列表里还没有」。
|
|
1045
|
-
const workspaces = await
|
|
1275
|
+
const workspaces = await loadWorkspaces(new Set(), true)
|
|
1046
1276
|
sendJson(res, 200, {
|
|
1047
1277
|
ok: true, created: made.created, workspace: made.workspace, workspaces,
|
|
1048
1278
|
})
|
package/lib/store.js
CHANGED
|
@@ -348,6 +348,28 @@ export function createStore({ file, maxHistory = 50, maxSessions = 12 }) {
|
|
|
348
348
|
persist()
|
|
349
349
|
},
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* 子智能体完工的通知(`subagent-settled`)。
|
|
353
|
+
*
|
|
354
|
+
* 第三种东西,和指令行同类:既不是用户说的话,也不是模型的回答,而是**这个会话
|
|
355
|
+
* 派出去的一件事有结果了**。所以单独一个 role,界面上按一张可展开的卡片画——
|
|
356
|
+
* 混进用户气泡就变成「我说过这句话」,混进回答又会被读成模型的话,两种都是假的。
|
|
357
|
+
*
|
|
358
|
+
* 时间戳同样用事件自己的 `time`:硬盘重放出来那份(lib/history.js 里的 notice)
|
|
359
|
+
* 用的也是它,两边同一个钟才合成得成一条,而不是显示两遍。
|
|
360
|
+
*/
|
|
361
|
+
pushNotice({ sessionId, text, summary, senderSessionId, timestamp }) {
|
|
362
|
+
pushHistory(sessionId, {
|
|
363
|
+
role: 'notice',
|
|
364
|
+
text,
|
|
365
|
+
summary: summary ?? '',
|
|
366
|
+
senderSessionId: senderSessionId ?? '',
|
|
367
|
+
timestamp: timestamp ?? Date.now(),
|
|
368
|
+
sessionId,
|
|
369
|
+
})
|
|
370
|
+
persist()
|
|
371
|
+
},
|
|
372
|
+
|
|
351
373
|
/**
|
|
352
374
|
* 一条斜杠指令开跑了(`command/run`)。
|
|
353
375
|
*
|