dsh-mini-remote 0.1.8 → 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 +36 -0
- package/lib/commands.js +6 -0
- package/lib/events.js +8 -3
- package/lib/history.js +7 -2
- package/lib/index.js +669 -13
- package/lib/page.html +2151 -103
- package/lib/page.js +3 -1
- package/lib/permissions.js +1 -0
- package/lib/server.js +268 -6
- package/lib/session-meta-cache.js +100 -0
- package/lib/store.js +232 -3
- package/lib/subagent-cache.js +63 -0
- package/lib/trajectory.js +570 -0
- package/lib/tree.js +30 -8
- package/package.json +1 -1
package/lib/page.js
CHANGED
|
@@ -19,7 +19,9 @@ let template = null
|
|
|
19
19
|
export function renderPage({ defaultMode = 'minimal', needsToken = false, build = '', maxUpload = 0 } = {}) {
|
|
20
20
|
if (template === null) template = readFileSync(PAGE_PATH, 'utf8')
|
|
21
21
|
return template
|
|
22
|
-
|
|
22
|
+
// 三种显示模式都认(单帧/聊天/完整);不认识的值一律回落单帧——回落要卡在这一处,
|
|
23
|
+
// 各处自己挑认哪几个,迟早出现「存得进去、刷新就变回去」的对不上。
|
|
24
|
+
.replace('__DEFAULT_MODE__', ['chat', 'full'].includes(defaultMode) ? defaultMode : 'minimal')
|
|
23
25
|
.replace('__NEEDS_TOKEN__', needsToken ? 'true' : 'false')
|
|
24
26
|
// 立绘地址带上构建指纹(页面自己拼 ?v=__BUILD__),换了图浏览器就不会一直
|
|
25
27
|
// 拿缓存里那张。只留字母数字,别让它有机会往 HTML 里塞东西。
|
package/lib/permissions.js
CHANGED
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'
|
|
@@ -170,17 +170,73 @@ export const miniControl = {
|
|
|
170
170
|
* @param {(id:string)=>{ok:boolean, error?:string, sessionId?:string}} opts.onUnqueue
|
|
171
171
|
* @param {() => (string|undefined)[]} [opts.imageRoots] 正文里相对路径图的额外候选根
|
|
172
172
|
* (由插件递进「当前已注册工作区的目录」,见 extraImageRoots 那段)
|
|
173
|
-
* @returns {Promise<{port:number, addresses:string[], failed:{address:string,message:string}[], close:()=>Promise<void>, broadcast:(event:string, data:unknown)=>void}>}
|
|
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}>}
|
|
174
174
|
*/
|
|
175
175
|
export async function createMiniServer({ store, config, token, bindAddresses, tunnelHost, onInstruction, onCommands, onCommand, onStop, onUnqueue, onUpload, onSessionOpened, log, tree, browse, build, imageRoots }) {
|
|
176
176
|
/** @type {Set<import('node:http').ServerResponse>} */
|
|
177
177
|
const clients = new Set()
|
|
178
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
|
+
|
|
179
209
|
// 导航栏的数据比普通状态更适合做 stale-while-revalidate:工作区和会话不会因为
|
|
180
210
|
// 用户打开一次菜单就必须重新清点。第一次没有缓存时仍然等待真实结果;之后先回上次
|
|
181
211
|
// 整理好的列表,再在后台刷新。手动点刷新时由请求方显式 force,保持原来的语义。
|
|
182
212
|
const NAV_CACHE_TTL_MS = 30_000
|
|
183
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
|
+
}
|
|
184
240
|
|
|
185
241
|
async function loadWorkspaces(runningIds, force = false) {
|
|
186
242
|
const now = Date.now()
|
|
@@ -280,6 +336,58 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
280
336
|
}
|
|
281
337
|
}
|
|
282
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
|
+
|
|
283
391
|
// ---- 手机上答题 ------------------------------------------------------
|
|
284
392
|
//
|
|
285
393
|
// **这两样必须放在这一层**(和 `clients`、`broadcast` 同一层,全程只建一次)。
|
|
@@ -737,6 +845,15 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
737
845
|
})
|
|
738
846
|
res.write(': connected\n\n')
|
|
739
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)
|
|
740
857
|
// 留痕:手机连没连上、一共有几台,就看这一句。2026-09-27 测自言自语的这天
|
|
741
858
|
// 正是卡在这儿——「她怎么没说话」和「手机压根没连上」当时根本分不开。
|
|
742
859
|
// 还要看**是谁**连的、**活了多久**:2026-09-27 日志里一分钟几十次成对出现,
|
|
@@ -773,6 +890,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
773
890
|
said = true
|
|
774
891
|
clearInterval(beat)
|
|
775
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
|
+
}
|
|
776
899
|
console.log(`[dsh-mini-remote] 手机断开了,还剩 ${clients.size} 台`
|
|
777
900
|
+ `(${who},活了 ${Date.now() - openedAt} ms)`)
|
|
778
901
|
}
|
|
@@ -789,6 +912,58 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
789
912
|
return
|
|
790
913
|
}
|
|
791
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
|
+
|
|
792
967
|
// 构建指纹。重启期间旧进程还在应答,这个接口用来确认「跑的是不是新代码」。
|
|
793
968
|
//
|
|
794
969
|
// `canCreateSession` 是**运行时**事实,不是配置推断:它问的是「这一刻 DSH 真的
|
|
@@ -803,7 +978,38 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
803
978
|
// 探测本身不该把接口带崩:问不到就当没有。
|
|
804
979
|
canCreateSession = false
|
|
805
980
|
}
|
|
806
|
-
|
|
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
|
+
})
|
|
807
1013
|
return
|
|
808
1014
|
}
|
|
809
1015
|
|
|
@@ -824,7 +1030,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
824
1030
|
// 失败一律 200 + ok:false:这是"这道能力现在没有"或"这台电脑没开这条路",
|
|
825
1031
|
// 不是请求本身有错——用 4xx 会让页面上的重试逻辑把它当网络故障。
|
|
826
1032
|
if (req.method === 'GET' && path === '/mini/api/subagents') {
|
|
827
|
-
const result = await
|
|
1033
|
+
const result = await loadSubagents(store.snapshot().boundSessionId)
|
|
828
1034
|
sendJson(res, 200, result)
|
|
829
1035
|
return
|
|
830
1036
|
}
|
|
@@ -837,6 +1043,25 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
837
1043
|
return
|
|
838
1044
|
}
|
|
839
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
|
+
|
|
840
1065
|
// ---- 斜杠指令列表 ----------------------------------------------------
|
|
841
1066
|
// 手机在输入框里打 `/` 时要显示的那一份。**名单来自 DSH 自己的指令账本**,
|
|
842
1067
|
// 所以电脑上新装一个带指令的插件,手机上刷新就有了,不用改这个插件。
|
|
@@ -869,6 +1094,13 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
869
1094
|
return
|
|
870
1095
|
}
|
|
871
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
|
+
|
|
872
1104
|
const wsSessions = /^\/mini\/api\/workspaces\/([^/]+)\/sessions$/.exec(path)
|
|
873
1105
|
if (req.method === 'GET' && wsSessions) {
|
|
874
1106
|
const asked = Number(url.searchParams.get('limit'))
|
|
@@ -1194,6 +1426,26 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1194
1426
|
return
|
|
1195
1427
|
}
|
|
1196
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
|
+
|
|
1197
1449
|
// 在一个已有的工作区里新建会话,建完立刻把手机绑过去。
|
|
1198
1450
|
//
|
|
1199
1451
|
// 为什么排在这么靠后、而不是跟上面那条 GET 挨着:这两条都要读请求体,
|
|
@@ -1203,7 +1455,11 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1203
1455
|
// 建完顺手 bind + 广播:手机发一次请求就完成了「建 + 切过去」,
|
|
1204
1456
|
// 不用再补一次 bind,也就不会出现「建好了但没切过去」这种半截状态。
|
|
1205
1457
|
if (req.method === 'POST' && wsSessions) {
|
|
1206
|
-
|
|
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)
|
|
1207
1463
|
if (!created.ok) {
|
|
1208
1464
|
const known = {
|
|
1209
1465
|
'no-controller': [503, '这台电脑上的 DSH 没提供新建会话的能力。'],
|
|
@@ -1216,7 +1472,7 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1216
1472
|
}
|
|
1217
1473
|
store.bind(created.sessionId)
|
|
1218
1474
|
// 新会话会改变工作区的数量;下次打开导航栏必须重新整理一次。
|
|
1219
|
-
|
|
1475
|
+
invalidateNavigation()
|
|
1220
1476
|
const snap = store.snapshot()
|
|
1221
1477
|
broadcast('state', snap)
|
|
1222
1478
|
sendJson(res, 200, {
|
|
@@ -1412,6 +1668,12 @@ export async function createMiniServer({ store, config, token, bindAddresses, tu
|
|
|
1412
1668
|
/** 想绑但失败的地址,交给调用方决定怎么提示。 */
|
|
1413
1669
|
failed,
|
|
1414
1670
|
broadcast,
|
|
1671
|
+
/** 执行轨迹的增量帧:只发给声明了「完整」模式的那几条连接(见 broadcastTrajectory)。 */
|
|
1672
|
+
broadcastTrajectory,
|
|
1673
|
+
/** 轨迹的活片段帧:同一批连接,不占 seq/epoch(见 broadcastTrajectoryLive)。 */
|
|
1674
|
+
broadcastTrajectoryLive,
|
|
1675
|
+
invalidateNavigation,
|
|
1676
|
+
invalidateSubagents,
|
|
1415
1677
|
close: async () => {
|
|
1416
1678
|
for (const res of clients) {
|
|
1417
1679
|
try { res.end() } catch { /* 已经断了 */ }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导航用的会话元数据缓存。
|
|
3
|
+
*
|
|
4
|
+
* 这里只保存 sessionQuery.listSessions() 返回记录中的 header 和两个可用性标志,
|
|
5
|
+
* 不保存会话正文。用途是让 DSH 重启后导航可以先显示上次已知的工作区关系,
|
|
6
|
+
* 再由后台完整扫描校正。
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { dirname } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
12
|
+
const DEFAULT_MAX = 10_000
|
|
13
|
+
|
|
14
|
+
function validRecord(row) {
|
|
15
|
+
return Boolean(
|
|
16
|
+
row && typeof row === 'object'
|
|
17
|
+
&& row.header && typeof row.header.id === 'string' && row.header.id
|
|
18
|
+
&& Number.isFinite(row.header.createdAt),
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSessionMetaCache({
|
|
23
|
+
file, ttl = DEFAULT_TTL_MS, max = DEFAULT_MAX, debounceMs = 500, now = Date.now,
|
|
24
|
+
}) {
|
|
25
|
+
const rows = new Map()
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
28
|
+
const at = now()
|
|
29
|
+
for (const row of Array.isArray(raw) ? raw : Object.values(raw ?? {})) {
|
|
30
|
+
if (!validRecord(row) || typeof row.at !== 'number' || at - row.at > ttl) continue
|
|
31
|
+
rows.set(row.header.id, { ...row, at: row.at })
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// 第一次运行或缓存损坏都从空缓存开始,不能影响插件启动。
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let timer = null
|
|
38
|
+
let dirty = false
|
|
39
|
+
function save() {
|
|
40
|
+
timer = null
|
|
41
|
+
if (!dirty) return
|
|
42
|
+
dirty = false
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
45
|
+
const tmp = `${file}.tmp`
|
|
46
|
+
writeFileSync(tmp, JSON.stringify([...rows.values()]))
|
|
47
|
+
renameSync(tmp, file)
|
|
48
|
+
} catch {
|
|
49
|
+
// 缓存写失败只影响下次冷启动,不影响当前导航。
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function schedule() {
|
|
53
|
+
dirty = true
|
|
54
|
+
if (timer) return
|
|
55
|
+
timer = setTimeout(save, debounceMs)
|
|
56
|
+
timer.unref?.()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
all() {
|
|
61
|
+
const at = now()
|
|
62
|
+
return [...rows.values()]
|
|
63
|
+
.filter((row) => at - row.at <= ttl)
|
|
64
|
+
.map(({ at: _at, ...row }) => row)
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
replace(records) {
|
|
68
|
+
rows.clear()
|
|
69
|
+
const at = now()
|
|
70
|
+
for (const record of records ?? []) {
|
|
71
|
+
if (!validRecord(record)) continue
|
|
72
|
+
rows.set(record.header.id, {
|
|
73
|
+
header: record.header,
|
|
74
|
+
live: Boolean(record.live),
|
|
75
|
+
persisted: Boolean(record.persisted),
|
|
76
|
+
at,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
while (rows.size > max) rows.delete(rows.keys().next().value)
|
|
80
|
+
schedule()
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
upsert(record) {
|
|
84
|
+
if (!validRecord(record)) return
|
|
85
|
+
rows.set(record.header.id, {
|
|
86
|
+
header: record.header,
|
|
87
|
+
live: Boolean(record.live),
|
|
88
|
+
persisted: Boolean(record.persisted),
|
|
89
|
+
at: now(),
|
|
90
|
+
})
|
|
91
|
+
while (rows.size > max) rows.delete(rows.keys().next().value)
|
|
92
|
+
schedule()
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
clear() {
|
|
96
|
+
rows.clear()
|
|
97
|
+
schedule()
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|