dsh-mini-remote 0.1.8 → 0.2.0
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 +62 -0
- package/README.en.md +13 -7
- package/README.md +13 -7
- package/lib/commands.js +6 -0
- package/lib/events.js +8 -3
- package/lib/history.js +7 -2
- package/lib/index.js +818 -18
- package/lib/mirror.js +1422 -0
- package/lib/page.html +2818 -152
- package/lib/page.js +3 -1
- package/lib/permissions.js +1 -0
- package/lib/server.js +641 -7
- package/lib/session-meta-cache.js +100 -0
- package/lib/store.js +273 -3
- package/lib/subagent-cache.js +63 -0
- package/lib/trajectory.js +570 -0
- package/lib/tree.js +30 -8
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -30,12 +30,26 @@ import z from '@deepseek-ai/schemastery'
|
|
|
30
30
|
import { createStore } from './store.js'
|
|
31
31
|
import { createTurnTracker, visibleText, stepSelfTalk } from './events.js'
|
|
32
32
|
import { replayHistory, lastReply } from './history.js'
|
|
33
|
+
import { createTrajectoryTracker, replayTrajectory, capTrajectory } from './trajectory.js'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Agent 模式(内置四档)的中文词条。**照抄 PC 的词条表**(dsh-client-ui-agent-preset
|
|
37
|
+
* 里的 presetStandardName / presetStandardDescription 等八个键),一个字不改。
|
|
38
|
+
* 自建模式不进这张表——PC 的政策原话是「不翻译用户自造的名字」。
|
|
39
|
+
*/
|
|
40
|
+
const AGENT_MODE_COPY = {
|
|
41
|
+
standard: ['标准模式', '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。'],
|
|
42
|
+
ptc: ['PTC 模式', '包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。'],
|
|
43
|
+
minimal: ['极简模式', 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。'],
|
|
44
|
+
cordis: ['创造模式', '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。'],
|
|
45
|
+
}
|
|
33
46
|
import { readTailEvents, findSessionLog } from './log-tail.js'
|
|
34
47
|
import {
|
|
35
48
|
parseSlashLine, describeCommands, commandRow, commandPatch,
|
|
36
49
|
invalidCommandMessage, unknownCommandMessage,
|
|
37
50
|
} from './commands.js'
|
|
38
51
|
import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
|
|
52
|
+
import { createMirror } from './mirror.js'
|
|
39
53
|
import { sendExternalNotification } from './notify.js'
|
|
40
54
|
import { createFeishuChannel, feishuQuestionText, approvalText, FEISHU_TEXT_LIMIT } from './lark.js'
|
|
41
55
|
import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
|
|
@@ -45,6 +59,8 @@ import { ensureCloudflared } from './cloudflared.js'
|
|
|
45
59
|
import { startTunnel, TUNNEL_HINT } from './tunnel.js'
|
|
46
60
|
import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt } from './tree.js'
|
|
47
61
|
import { createTitleCache } from './title-cache.js'
|
|
62
|
+
import { createSessionMetaCache } from './session-meta-cache.js'
|
|
63
|
+
import { createSubagentCache } from './subagent-cache.js'
|
|
48
64
|
import { listRoots, listDirectory, makeDirectory } from './browse.js'
|
|
49
65
|
import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
|
|
50
66
|
import { buildId } from './build.js'
|
|
@@ -334,7 +350,18 @@ export function apply(ctx, config = {}) {
|
|
|
334
350
|
* 而这件事**每重启一次就得重付一遍**——标题却是定下来基本不变的东西。
|
|
335
351
|
*/
|
|
336
352
|
const titles = createTitleCache({ file: join(dir, 'titles.json') })
|
|
353
|
+
const sessionMeta = createSessionMetaCache({ file: join(dir, 'session-meta.json') })
|
|
354
|
+
const subagentCache = createSubagentCache({ file: join(dir, 'subagents.json') })
|
|
337
355
|
const tracker = createTurnTracker()
|
|
356
|
+
/**
|
|
357
|
+
* 执行轨迹的累计器(「完整」模式要的过程区)。
|
|
358
|
+
*
|
|
359
|
+
* **和上面的 tracker 是两个人**:上面那个只认「用户说的话」和「最终回答」,轨迹那个
|
|
360
|
+
* 认的是思考、工具调用、工具结果。分开的理由是它们服务的是**两种模式**——聊天模式的
|
|
361
|
+
* 回答区里一个字的过程都不该有(2026-09-22 用户亲自否掉的),而完整模式要的就是过程。
|
|
362
|
+
* 两条路共用的是同一批原始事件,不是同一份产出。
|
|
363
|
+
*/
|
|
364
|
+
const trajectory = createTrajectoryTracker()
|
|
338
365
|
// 队列的真相在 agent 的收件箱里,store 拿不到 ctx,所以给它一个回调去问。
|
|
339
366
|
// 不缓存、每次取快照现读:没有第二份状态,也就不会和电脑上不一致。
|
|
340
367
|
store.setQueueSource(queuedOf)
|
|
@@ -961,6 +988,27 @@ export function apply(ctx, config = {}) {
|
|
|
961
988
|
return '这个会话现在叫不醒(电脑那边没答应),等一会儿再试。'
|
|
962
989
|
}
|
|
963
990
|
|
|
991
|
+
/**
|
|
992
|
+
* 「繁忙时的发送行为」当前是哪个值——**发送那一刻现读,不缓存**。
|
|
993
|
+
*
|
|
994
|
+
* 为什么现读:这个值是**电脑端和手机端共用**的(就是 PC「设置 → 通用」里那一条),
|
|
995
|
+
* 用户在电脑上改了我们这边不一定知道。发送是低频动作,读一次 describe() 不值几个钱,
|
|
996
|
+
* 而缓存一份就会有「设置改了但发送还用老值」这种最难查的不一致。
|
|
997
|
+
*
|
|
998
|
+
* 读不到一律当 `queue`(排队)——那是 DSH 自己的默认值,也是最保守的那个:
|
|
999
|
+
* 宁可多等一轮,也不要莫名其妙打断正在跑的一轮。
|
|
1000
|
+
*/
|
|
1001
|
+
function busyEnterMode() {
|
|
1002
|
+
try {
|
|
1003
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
1004
|
+
const doc = (settings?.describe?.() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation')
|
|
1005
|
+
const value = doc?.value && typeof doc.value === 'object' ? doc.value : {}
|
|
1006
|
+
return value.busyEnter === 'steer' ? 'steer' : 'queue'
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
return 'queue'
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
964
1012
|
async function onInstruction(text, uploadIds) {
|
|
965
1013
|
const sessionId = targetSessionId()
|
|
966
1014
|
if (!sessionId) {
|
|
@@ -986,7 +1034,7 @@ export function apply(ctx, config = {}) {
|
|
|
986
1034
|
}
|
|
987
1035
|
|
|
988
1036
|
/**
|
|
989
|
-
*
|
|
1037
|
+
* 跑着的时候**不再拒绝**,改成「排队」或「插话」——按设置里那一条走。
|
|
990
1038
|
*
|
|
991
1039
|
* 原来这里有一条 `agent.status === 'running'` 的挡板,回一句「上一个任务还在跑,
|
|
992
1040
|
* 等它完成再发」。那条挡板是多余的,而且**比 DSH 自己还严**:`followup()` 的
|
|
@@ -994,9 +1042,26 @@ export function apply(ctx, config = {}) {
|
|
|
994
1042
|
* "Queue an ordinary follow-up turn and wake the driver"),跑着的时候照样收,
|
|
995
1043
|
* 排进 inbox.nextTurn,等当前这一轮结束再跑。DSH 桌面端同一时刻就能接着排。
|
|
996
1044
|
*
|
|
997
|
-
*
|
|
1045
|
+
* **2026-10-06 补上真正的「插话」。** 原来这里只会 `followup()`——那是**永远排队**。
|
|
1046
|
+
* 手机设置里那条「繁忙时的发送行为」当时只做了开关、没接上线,所以选了插话也照样排队
|
|
1047
|
+
* (用户实机发现:「插话发送似乎没实现,依然是排队发生」)。
|
|
1048
|
+
*
|
|
1049
|
+
* 现在按设置分岔,两条都是 DSH 自己的接口(`dsh-agent` 的类型注释原文):
|
|
1050
|
+
* · `followup(message)` —— "Queue an ordinary follow-up turn and wake the driver",
|
|
1051
|
+
* 进 inbox.nextTurn,等这一轮跑完;
|
|
1052
|
+
* · `steer(message)` —— "Submit steering for the nearest step",进 inbox.nextStep,
|
|
1053
|
+
* 跑着的驱动**在下一个步骤边界**就取走它(一个工具调用就是一个步骤,所以是秒级)。
|
|
1054
|
+
*
|
|
1055
|
+
* 两处刻意的保守:
|
|
1056
|
+
* ① 只有**真在跑**(queued)才插话。不跑的时候没什么可插的,走正常发送;
|
|
1057
|
+
* ② 老版本 DSH 上 `agent.steer` 可能不存在(它是后加的),所以要探一下类型——
|
|
1058
|
+
* 没有就退回排队,不能让一条指令发不出去。
|
|
1059
|
+
* PC 源码那边还留了一句定心的话:"Direct `steer` is intentionally best-effort:
|
|
1060
|
+
* AgentLoop turns a closed-window submission into the next waking Queue item."
|
|
1061
|
+
* ——**最坏情况它会自己退回排队**,不会丢。
|
|
998
1062
|
*/
|
|
999
1063
|
const queued = agent.status === 'running'
|
|
1064
|
+
const steered = queued && busyEnterMode() === 'steer' && typeof agent.steer === 'function'
|
|
1000
1065
|
|
|
1001
1066
|
const message = userMessage(text, files)
|
|
1002
1067
|
// 必须在 followup 之前登记。
|
|
@@ -1012,7 +1077,10 @@ export function apply(ctx, config = {}) {
|
|
|
1012
1077
|
// 正好记一次、也只在那时候出现。
|
|
1013
1078
|
if (!queued) rememberInjected(message.id)
|
|
1014
1079
|
try {
|
|
1015
|
-
|
|
1080
|
+
// 跑着的时候按设置分岔:插话进 nextStep(下一个步骤边界就取走),
|
|
1081
|
+
// 排队进 nextTurn(等这一轮跑完)。两条都是 DSH 自己的接口,见上面那段注释。
|
|
1082
|
+
if (steered) agent.steer(message)
|
|
1083
|
+
else agent.followup(message)
|
|
1016
1084
|
} catch (err) {
|
|
1017
1085
|
return { ok: false, error: `注入失败:${err?.message ?? err}` }
|
|
1018
1086
|
}
|
|
@@ -1028,8 +1096,11 @@ export function apply(ctx, config = {}) {
|
|
|
1028
1096
|
// 排队的那条也要推快照:队列列表是从 agent 收件箱读的,不推手机上就看不到
|
|
1029
1097
|
// 它排在哪儿。区别只在于**不往 history 里记**,所以它不会长成一条气泡。
|
|
1030
1098
|
server?.broadcast('state', store.snapshot())
|
|
1031
|
-
// queued
|
|
1032
|
-
|
|
1099
|
+
// queued / steered 只是给界面上那句提示用的:
|
|
1100
|
+
// queued —— 现在就在跑,所以这条得等下一轮;
|
|
1101
|
+
// steered —— 现在就在跑,但这条会在**下一个步骤边界**被取走(通常几秒)。
|
|
1102
|
+
// 两个不会同时为真。
|
|
1103
|
+
return { ok: true, sessionId, queued: queued && !steered, steered }
|
|
1033
1104
|
}
|
|
1034
1105
|
|
|
1035
1106
|
/**
|
|
@@ -1185,11 +1256,100 @@ export function apply(ctx, config = {}) {
|
|
|
1185
1256
|
return win
|
|
1186
1257
|
}
|
|
1187
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* 喂一口执行轨迹,并把这一批的变化推给**正在看这个会话、而且要「完整」模式**的手机。
|
|
1261
|
+
*
|
|
1262
|
+
* 三个决定,都有理由:
|
|
1263
|
+
* ① **落在 store 的内存缓冲里**,不落盘(体积是回答正文的 90.7 倍,落盘那份是每
|
|
1264
|
+
* 500 毫秒重写整份 JSON;理由见 lib/store.js 的 trajectoryBySession)。
|
|
1265
|
+
* ② **只推当前绑定的那个会话**(和 reply 那条同一个口径):手机遥控的是这一个,
|
|
1266
|
+
* 别的会话在想什么跟它无关;那些会话照旧攒在缓冲里,切过去时用接口拉一份。
|
|
1267
|
+
* ③ **推的是增量帧**,不是整份快照(见 lib/server.js 的 broadcastTrajectory)。
|
|
1268
|
+
*/
|
|
1269
|
+
function feedTrajectory(sessionId, event) {
|
|
1270
|
+
const change = trajectory.feed(sessionId, event)
|
|
1271
|
+
if (!change) return
|
|
1272
|
+
const frame = store.applyTrajectory(sessionId, change)
|
|
1273
|
+
if (!frame) return
|
|
1274
|
+
// 真条目落盘了:活片段的使命结束(它只是「还没落定」的影子,见 feedTrajectoryLive)。
|
|
1275
|
+
if (change.entries.length || change.state) resetLiveThink(sessionId)
|
|
1276
|
+
if (sessionId !== targetSessionId()) return
|
|
1277
|
+
server?.broadcastTrajectory({ sessionId, ...frame })
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* 轨迹的活片段(2026-10-04 用户解禁「流式不做」,仅完整模式消费,别的模式零影响):
|
|
1282
|
+
* 模型正在想的那一句,从 `agent/assistant-stream` 的 reasoning-delta 里顺出来。
|
|
1283
|
+
*
|
|
1284
|
+
* 边界三条:
|
|
1285
|
+
* ① **只吃思考**(reasoning-delta);回答正文(text-delta)一概不用——回答照旧等
|
|
1286
|
+
* 落定才显示,「流式不做」那条裁决只对轨迹的思考片段解禁;
|
|
1287
|
+
* ② **只推当前绑定的那个会话**,子 Agent 的流不过来(和轨迹增量帧同一个口径);
|
|
1288
|
+
* ③ **落盘即清、打断即清**:真条目一到就让位。它是进程本地的,不重放——手机刷新
|
|
1289
|
+
* 后没了就没了,等下一步的片段接着来,不补假的。
|
|
1290
|
+
*
|
|
1291
|
+
* 节流:模型一秒能吐几十个字,250 毫秒一发、尾随必发;第一段立刻发——
|
|
1292
|
+
* 「有没有动静」这件事最要紧的就是第一眼。
|
|
1293
|
+
*/
|
|
1294
|
+
const liveThink = new Map()
|
|
1295
|
+
const LIVE_THINK_MS = 250
|
|
1296
|
+
|
|
1297
|
+
/** 「当前这一段」的尾句:压成一行、限 160 字——给人瞄一眼的,不是给人读的。 */
|
|
1298
|
+
function liveThinkDetail(text) {
|
|
1299
|
+
const paragraphs = String(text).split(/\r?\n[\t ]*\r?\n/)
|
|
1300
|
+
const last = paragraphs[paragraphs.length - 1].replace(/\s+/g, ' ').trim()
|
|
1301
|
+
return last.length > 160 ? `${last.slice(0, 159)}…` : last
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function resetLiveThink(sessionId) {
|
|
1305
|
+
const slot = liveThink.get(sessionId)
|
|
1306
|
+
if (!slot) return
|
|
1307
|
+
if (slot.timer) clearTimeout(slot.timer)
|
|
1308
|
+
liveThink.delete(sessionId)
|
|
1309
|
+
// 只在真露过字之后才喊「清空」——不然每来一次工具调用都白发一帧。
|
|
1310
|
+
if (slot.sent) server?.broadcastTrajectoryLive({ sessionId, detail: '' })
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function feedTrajectoryLive(sessionId, frame) {
|
|
1314
|
+
if (sessionId !== targetSessionId()) return
|
|
1315
|
+
if (!frame || frame.type !== 'chunk') { resetLiveThink(sessionId); return }
|
|
1316
|
+
const chunk = frame.chunk ?? {}
|
|
1317
|
+
if (chunk.type === 'reasoning-delta' && chunk.text) {
|
|
1318
|
+
let slot = liveThink.get(sessionId)
|
|
1319
|
+
if (!slot) {
|
|
1320
|
+
slot = { text: '', sent: false, at: 0, timer: null }
|
|
1321
|
+
liveThink.set(sessionId, slot)
|
|
1322
|
+
}
|
|
1323
|
+
slot.text += chunk.text
|
|
1324
|
+
const detail = liveThinkDetail(slot.text)
|
|
1325
|
+
const flush = () => {
|
|
1326
|
+
slot.timer = null
|
|
1327
|
+
slot.at = Date.now()
|
|
1328
|
+
slot.sent = true
|
|
1329
|
+
server?.broadcastTrajectoryLive({ sessionId, detail })
|
|
1330
|
+
}
|
|
1331
|
+
if (!slot.sent || Date.now() - slot.at >= LIVE_THINK_MS) {
|
|
1332
|
+
if (slot.timer) { clearTimeout(slot.timer); slot.timer = null }
|
|
1333
|
+
flush()
|
|
1334
|
+
} else if (!slot.timer) {
|
|
1335
|
+
slot.timer = setTimeout(flush, LIVE_THINK_MS - (Date.now() - slot.at))
|
|
1336
|
+
if (slot.timer.unref) slot.timer.unref()
|
|
1337
|
+
}
|
|
1338
|
+
return
|
|
1339
|
+
}
|
|
1340
|
+
// 出现工具调用 = 这一步走到工具了,思考片段到此为止;text-delta 是回答正文,一概不用。
|
|
1341
|
+
if (chunk.type === 'tool-call-delta') resetLiveThink(sessionId)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1188
1344
|
ctx.on('session/event', (session, event) => {
|
|
1189
1345
|
// 子 agent 的会话不进手机——用户遥控的是自己的主会话,不是它派出去的小弟。
|
|
1190
1346
|
if (session.header?.origin === 'subagent') return
|
|
1191
1347
|
const sessionId = session.id
|
|
1192
1348
|
|
|
1349
|
+
// 执行轨迹:**在所有早退之前**喂一口。下面 `tool/call` 那条分支取完命令原文就 return
|
|
1350
|
+
// 了(审批卡要用),而工具调用正是轨迹的主干——放在这一句之前,它才不会漏掉。
|
|
1351
|
+
feedTrajectory(sessionId, event)
|
|
1352
|
+
|
|
1193
1353
|
// 手机正在看的这个会话:顺手让它的完整历史保持新鲜(一分钟最多重读一次)。
|
|
1194
1354
|
// 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
|
|
1195
1355
|
if (sessionId === targetSessionId()) ensureHistory(sessionId)
|
|
@@ -1336,6 +1496,7 @@ export function apply(ctx, config = {}) {
|
|
|
1336
1496
|
summary: action.summary,
|
|
1337
1497
|
senderSessionId: action.senderSessionId,
|
|
1338
1498
|
sessionId,
|
|
1499
|
+
turn: action.turn,
|
|
1339
1500
|
timestamp: event.time,
|
|
1340
1501
|
})
|
|
1341
1502
|
server?.broadcast('state', store.snapshot())
|
|
@@ -1358,7 +1519,7 @@ export function apply(ctx, config = {}) {
|
|
|
1358
1519
|
// 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
|
|
1359
1520
|
// 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
|
|
1360
1521
|
// 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
|
|
1361
|
-
store.pushUser({ text: action.text, sessionId, id: action.id, timestamp: event.time })
|
|
1522
|
+
store.pushUser({ text: action.text, sessionId, id: action.id, turn: action.turn, timestamp: event.time })
|
|
1362
1523
|
server?.broadcast('state', store.snapshot())
|
|
1363
1524
|
return
|
|
1364
1525
|
}
|
|
@@ -1366,6 +1527,7 @@ export function apply(ctx, config = {}) {
|
|
|
1366
1527
|
// action.kind === 'reply'
|
|
1367
1528
|
store.pushReply({
|
|
1368
1529
|
text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
|
|
1530
|
+
turn: action.turn,
|
|
1369
1531
|
// 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
|
|
1370
1532
|
timestamp: event.time,
|
|
1371
1533
|
})
|
|
@@ -1387,8 +1549,19 @@ export function apply(ctx, config = {}) {
|
|
|
1387
1549
|
ctx.on('agent/status', ({ agent, status }) => {
|
|
1388
1550
|
const sessionId = agent?.session?.id
|
|
1389
1551
|
if (!sessionId) return
|
|
1390
|
-
if (agent.session.header?.origin === 'subagent')
|
|
1552
|
+
if (agent.session.header?.origin === 'subagent') {
|
|
1553
|
+
const parent = agent.session.header?.parentSession
|
|
1554
|
+
if (parent) {
|
|
1555
|
+
subagentCache.delete(parent)
|
|
1556
|
+
store.setSubagentRunning(parent, sessionId, status === 'running')
|
|
1557
|
+
server?.invalidateSubagents?.(parent)
|
|
1558
|
+
server?.broadcast('state', store.snapshot())
|
|
1559
|
+
}
|
|
1560
|
+
return
|
|
1561
|
+
}
|
|
1391
1562
|
store.setRunning(sessionId, status === 'running')
|
|
1563
|
+
server?.invalidateNavigation?.()
|
|
1564
|
+
server?.broadcast('navigation-refresh', { reason: 'session-status', sessionId })
|
|
1392
1565
|
broadcastStatus()
|
|
1393
1566
|
})
|
|
1394
1567
|
|
|
@@ -1405,15 +1578,20 @@ export function apply(ctx, config = {}) {
|
|
|
1405
1578
|
* 子 Agent 的流不往手机上推。
|
|
1406
1579
|
*/
|
|
1407
1580
|
ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
1408
|
-
// 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
|
|
1409
|
-
// 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
|
|
1410
|
-
if (!STREAMING_ENABLED) return
|
|
1411
|
-
|
|
1412
1581
|
const session = agent?.session
|
|
1413
1582
|
const sessionId = session?.id
|
|
1414
1583
|
if (!sessionId) return
|
|
1415
1584
|
if (session.header?.origin === 'subagent') return
|
|
1416
1585
|
|
|
1586
|
+
// 轨迹的活片段:完整模式要「正在想的片段一点点顺出来」(2026-10-04 用户解禁,
|
|
1587
|
+
// 只解禁这一处)。它跑在下面那道闸门**之前**——回答的流式照旧关着,两者互不相干:
|
|
1588
|
+
// 这里吃的是 reasoning-delta,text-delta 一个字都不碰。
|
|
1589
|
+
feedTrajectoryLive(sessionId, frame)
|
|
1590
|
+
|
|
1591
|
+
// 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
|
|
1592
|
+
// 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
|
|
1593
|
+
if (!STREAMING_ENABLED) return
|
|
1594
|
+
|
|
1417
1595
|
// 只报一次:这条日志回答的是「事件到底有没有送到插件」。一条都没有,就说明
|
|
1418
1596
|
// 问题在订阅那一层,不用再往下查。
|
|
1419
1597
|
if (!sawStreamEvent) {
|
|
@@ -1483,17 +1661,45 @@ export function apply(ctx, config = {}) {
|
|
|
1483
1661
|
// 会话一建出来就登记,这样手机上的会话选择器不用等到有活动才出现。
|
|
1484
1662
|
ctx.on('agent/created', ({ agent }) => {
|
|
1485
1663
|
const sessionId = agent?.session?.id
|
|
1486
|
-
if (!sessionId
|
|
1664
|
+
if (!sessionId) return
|
|
1665
|
+
if (agent.session.header?.origin === 'subagent') {
|
|
1666
|
+
const parent = agent.session.header?.parentSession
|
|
1667
|
+
if (parent) {
|
|
1668
|
+
subagentCache.delete(parent)
|
|
1669
|
+
store.setSubagentRunning(parent, sessionId, agent.status === 'running')
|
|
1670
|
+
server?.invalidateSubagents?.(parent)
|
|
1671
|
+
server?.broadcast('state', store.snapshot())
|
|
1672
|
+
}
|
|
1673
|
+
return
|
|
1674
|
+
}
|
|
1675
|
+
sessionMeta.upsert({ header: agent.session.header, live: true, persisted: false })
|
|
1487
1676
|
store.touchSession(sessionId, {
|
|
1488
1677
|
running: agent.status === 'running',
|
|
1489
1678
|
})
|
|
1679
|
+
server?.invalidateNavigation?.()
|
|
1680
|
+
server?.broadcast('navigation-refresh', { reason: 'session-created' })
|
|
1490
1681
|
server?.broadcast('state', store.snapshot())
|
|
1491
1682
|
})
|
|
1492
1683
|
|
|
1493
1684
|
ctx.on('agent/disposed', ({ agent }) => {
|
|
1494
1685
|
const sessionId = agent?.session?.id
|
|
1495
1686
|
if (!sessionId) return
|
|
1687
|
+
if (agent.session.header?.origin === 'subagent') {
|
|
1688
|
+
const parent = agent.session.header?.parentSession
|
|
1689
|
+
if (parent) {
|
|
1690
|
+
subagentCache.delete(parent)
|
|
1691
|
+
store.setSubagentRunning(parent, sessionId, false)
|
|
1692
|
+
server?.invalidateSubagents?.(parent)
|
|
1693
|
+
server?.broadcast('state', store.snapshot())
|
|
1694
|
+
}
|
|
1695
|
+
return
|
|
1696
|
+
}
|
|
1697
|
+
sessionMeta.upsert({ header: agent.session.header, live: false, persisted: true })
|
|
1496
1698
|
store.forgetSession(sessionId)
|
|
1699
|
+
// 轨迹那个累计器也清掉(它有自己的一份 per-session 状态)。
|
|
1700
|
+
trajectory.forget(sessionId)
|
|
1701
|
+
server?.invalidateNavigation?.()
|
|
1702
|
+
server?.broadcast('navigation-refresh', { reason: 'session-disposed' })
|
|
1497
1703
|
server?.broadcast('state', store.snapshot())
|
|
1498
1704
|
})
|
|
1499
1705
|
|
|
@@ -1576,6 +1782,8 @@ export function apply(ctx, config = {}) {
|
|
|
1576
1782
|
const events = Array.isArray(snap?.events) ? snap.events : []
|
|
1577
1783
|
const entries = replayHistory(events)
|
|
1578
1784
|
const capped = capEntries(entries)
|
|
1785
|
+
// 轨迹和聊天记录是**同一批事件的两面**,顺手一起重放出来;不这样就得为它再读一遍盘。
|
|
1786
|
+
const traj = capTrajectory(replayTrajectory(events))
|
|
1579
1787
|
return {
|
|
1580
1788
|
entries: capped.entries,
|
|
1581
1789
|
latest: lastReply(capped.entries, sessionId),
|
|
@@ -1585,6 +1793,10 @@ export function apply(ctx, config = {}) {
|
|
|
1585
1793
|
// 于是手机上真看到的条数可能比这里多一两条。数字对不上就是假话,
|
|
1586
1794
|
// 所以这里只声称**读取的上限**,不声称列表长度。
|
|
1587
1795
|
note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
|
|
1796
|
+
// 轨迹在**插件这一层就按自己的上限截好**再进缓存:不截的话,20 个会话的缓存会
|
|
1797
|
+
// 各压着几兆(见 lib/store.js 的 setReplaySource 那段说明)。
|
|
1798
|
+
trajectory: traj.turns,
|
|
1799
|
+
trajectoryDropped: traj.dropped,
|
|
1588
1800
|
}
|
|
1589
1801
|
} catch (err) {
|
|
1590
1802
|
// 老日志格式对不上、文件坏了,都会走到这儿。退回「手里那份」,**不编**。
|
|
@@ -1623,6 +1835,7 @@ export function apply(ctx, config = {}) {
|
|
|
1623
1835
|
const entries = replayHistory(tail.events)
|
|
1624
1836
|
const capped = capEntries(entries)
|
|
1625
1837
|
if (!capped.entries.length) return null
|
|
1838
|
+
const traj = capTrajectory(replayTrajectory(tail.events))
|
|
1626
1839
|
const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
|
|
1627
1840
|
// 「不是全部」有两处会砍:readTailEvents 那层(窗口 / 事件上限)和这里 capEntries 那层
|
|
1628
1841
|
// (聊天记录条数上限)。**两层都要算进去**——小会话的窗口盖得住整份日志,唯一会砍它的
|
|
@@ -1639,6 +1852,10 @@ export function apply(ctx, config = {}) {
|
|
|
1639
1852
|
? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)`
|
|
1640
1853
|
: `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`)
|
|
1641
1854
|
: null,
|
|
1855
|
+
// 轨迹那一份的「不是全部」由 store 自己说(它知道轨迹的上限),这里只把
|
|
1856
|
+
// 「被砍了多少条」交出去——读取窗口那一层的截断在上面 note 里已经说过一次了。
|
|
1857
|
+
trajectory: traj.turns,
|
|
1858
|
+
trajectoryDropped: traj.dropped,
|
|
1642
1859
|
}
|
|
1643
1860
|
}
|
|
1644
1861
|
|
|
@@ -1687,6 +1904,8 @@ export function apply(ctx, config = {}) {
|
|
|
1687
1904
|
latest: got?.latest ?? null,
|
|
1688
1905
|
truncated: got?.truncated === true,
|
|
1689
1906
|
note: got?.note ?? null,
|
|
1907
|
+
trajectory: got?.trajectory ?? null,
|
|
1908
|
+
trajectoryDropped: got?.trajectoryDropped ?? 0,
|
|
1690
1909
|
})
|
|
1691
1910
|
historyPending.delete(sessionId)
|
|
1692
1911
|
server?.broadcast('state', store.snapshot())
|
|
@@ -1705,6 +1924,10 @@ export function apply(ctx, config = {}) {
|
|
|
1705
1924
|
// 这个会话只有这么点内容(读历史有上限,见 HISTORY_MAX_* 那组常量)。
|
|
1706
1925
|
truncated: hit.truncated === true,
|
|
1707
1926
|
note: hit.note ?? null,
|
|
1927
|
+
// 同一份记录里重放出来的执行轨迹(「完整」模式要的过程区)。
|
|
1928
|
+
// store 会把它和内存里那份合成一张表,见 store.trajectoryOf。
|
|
1929
|
+
trajectory: hit.trajectory ?? null,
|
|
1930
|
+
trajectoryDropped: hit.trajectoryDropped ?? 0,
|
|
1708
1931
|
}
|
|
1709
1932
|
}
|
|
1710
1933
|
if (historyPending.has(sessionId)) return { entries: null, latest: null, pending: true }
|
|
@@ -1816,7 +2039,25 @@ export function apply(ctx, config = {}) {
|
|
|
1816
2039
|
* `listDescendants` 读的是父会话自己的目录**事实**,不加载也不唤醒任何子代理——
|
|
1817
2040
|
* 所以列一下很便宜,也不会把睡着的子代理弄醒。
|
|
1818
2041
|
*/
|
|
1819
|
-
|
|
2042
|
+
const subagentMetricInflight = new Map()
|
|
2043
|
+
|
|
2044
|
+
async function fillSubagentMetrics(parentSessionId, rows) {
|
|
2045
|
+
if (subagentMetricInflight.has(parentSessionId)) return
|
|
2046
|
+
const task = (async () => {
|
|
2047
|
+
for (const row of rows) {
|
|
2048
|
+
if (!row || row.diagnostic) continue
|
|
2049
|
+
const metrics = subagentMetrics(row.id)
|
|
2050
|
+
server?.broadcast('subagent-metrics', {
|
|
2051
|
+
parentSessionId,
|
|
2052
|
+
childId: row.id,
|
|
2053
|
+
...metrics,
|
|
2054
|
+
})
|
|
2055
|
+
}
|
|
2056
|
+
})().finally(() => subagentMetricInflight.delete(parentSessionId))
|
|
2057
|
+
subagentMetricInflight.set(parentSessionId, task)
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
async function readSubagentRows(parentSessionId) {
|
|
1820
2061
|
const runtime = subagentServices.runtime
|
|
1821
2062
|
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
1822
2063
|
if (typeof runtime?.listDescendants !== 'function') {
|
|
@@ -1837,7 +2078,7 @@ export function apply(ctx, config = {}) {
|
|
|
1837
2078
|
out.push({ id: String(row.id), diagnostic: String(row.reason ?? 'unavailable') })
|
|
1838
2079
|
continue
|
|
1839
2080
|
}
|
|
1840
|
-
const metrics =
|
|
2081
|
+
const metrics = { tokens: null, durationMs: null, lastTurnCompleted: null }
|
|
1841
2082
|
out.push({
|
|
1842
2083
|
id: String(row.id),
|
|
1843
2084
|
parentId: String(row.parentId ?? ''),
|
|
@@ -1854,6 +2095,25 @@ export function apply(ctx, config = {}) {
|
|
|
1854
2095
|
return { ok: true, subagents: out, boundSessionId: parentSessionId }
|
|
1855
2096
|
}
|
|
1856
2097
|
|
|
2098
|
+
async function listSubagents(parentSessionId, { includeMetrics = true, scheduleMetrics = false } = {}) {
|
|
2099
|
+
if (!parentSessionId) return readSubagentRows(parentSessionId)
|
|
2100
|
+
const cached = !includeMetrics ? subagentCache.get(parentSessionId) : null
|
|
2101
|
+
const rows = cached
|
|
2102
|
+
? { ok: true, subagents: cached, boundSessionId: parentSessionId }
|
|
2103
|
+
: await readSubagentRows(parentSessionId)
|
|
2104
|
+
if (!rows.ok) return rows
|
|
2105
|
+
if (includeMetrics) {
|
|
2106
|
+
rows.subagents = rows.subagents.map((row) => row.diagnostic
|
|
2107
|
+
? row
|
|
2108
|
+
: { ...row, ...subagentMetrics(row.id) })
|
|
2109
|
+
}
|
|
2110
|
+
if (!cached) subagentCache.set(parentSessionId, rows.subagents)
|
|
2111
|
+
if (scheduleMetrics && !includeMetrics) {
|
|
2112
|
+
setTimeout(() => { fillSubagentMetrics(parentSessionId, rows.subagents) }, 0).unref?.()
|
|
2113
|
+
}
|
|
2114
|
+
return rows
|
|
2115
|
+
}
|
|
2116
|
+
|
|
1857
2117
|
/**
|
|
1858
2118
|
* 某个子智能体**自己的运行过程**。
|
|
1859
2119
|
*
|
|
@@ -1865,7 +2125,7 @@ export function apply(ctx, config = {}) {
|
|
|
1865
2125
|
*/
|
|
1866
2126
|
async function subagentTranscript(childId, parentSessionId) {
|
|
1867
2127
|
if (!childId) return { ok: false, error: '没说要读哪一道子智能体。' }
|
|
1868
|
-
const listed = await listSubagents(parentSessionId)
|
|
2128
|
+
const listed = await listSubagents(parentSessionId, { includeMetrics: false, scheduleMetrics: false })
|
|
1869
2129
|
if (!listed.ok) return listed
|
|
1870
2130
|
const row = listed.subagents.find((s) => s.id === childId)
|
|
1871
2131
|
if (!row) return { ok: false, error: '这道子智能体不在当前会话名下。' }
|
|
@@ -2162,6 +2422,84 @@ export function apply(ctx, config = {}) {
|
|
|
2162
2422
|
+ Boolean(canCreate.controller) + ' create=' + typeof canCreate.controller?.create)
|
|
2163
2423
|
})
|
|
2164
2424
|
|
|
2425
|
+
// Agent 模式注册表(2026-10-04 用户要求:新建会话时手机上也要能选模式,PC 一直
|
|
2426
|
+
// 可以)。**单独 inject**(理由同上:缺一个服务不该连别的都没了)。没有它的时候
|
|
2427
|
+
// 手机直接建 DSH 的默认模式,如实说「没有可选的」——不摆一个没得选的选择题。
|
|
2428
|
+
const canPresets = { registry: null }
|
|
2429
|
+
ctx.inject(['agentPresets'], (presetsCtx) => {
|
|
2430
|
+
canPresets.registry = presetsCtx.agentPresets
|
|
2431
|
+
console.log('[dsh-mini-remote] Agent 模式清单已就绪:agentPresets=' + Boolean(canPresets.registry))
|
|
2432
|
+
})
|
|
2433
|
+
|
|
2434
|
+
// 插件管理服务(手机端「插件」页面用)。
|
|
2435
|
+
const pluginServices = { manager: null }
|
|
2436
|
+
// DSH 版本号问到一次就记着(版本不会中途变),省得每 30 秒问一趟。
|
|
2437
|
+
let cachedDshVersion = ''
|
|
2438
|
+
ctx.inject(['pluginManager'], (pCtx) => {
|
|
2439
|
+
pluginServices.manager = pCtx.pluginManager
|
|
2440
|
+
console.log('[dsh-mini-remote] 插件管理服务已就绪:pluginManager=' + Boolean(pluginServices.manager))
|
|
2441
|
+
})
|
|
2442
|
+
|
|
2443
|
+
/**
|
|
2444
|
+
* 「内置插件」要用的两样东西(2026-10-04 用户指出:内置插件只显示 6 个是不对的)。
|
|
2445
|
+
*
|
|
2446
|
+
* DSH 的理念是「一切皆插件、无特权核心」——一整套系统是几百个插件模块拼起来的,
|
|
2447
|
+
* 所以「内置插件」的真相不在 npm 包清单里(那是「插件市场」的粒度),
|
|
2448
|
+
* 而在 **Cordis Loader 的实时条目表**里。PC 的「设置 → 内置插件」页读的正是它,
|
|
2449
|
+
* 分成两块:全局插件(系统与所有会话共用)+ 会话插件(按 Agent 预设组合而成)。
|
|
2450
|
+
*
|
|
2451
|
+
* 两样都走 ctx.inject(软注入):拿不到就只少这一块,不整个倒下。
|
|
2452
|
+
* `pluginPackages` 只提供显示用的本地化标题/说明,没有它照样能列出来(退成模块名)。
|
|
2453
|
+
*/
|
|
2454
|
+
const invServices = { loader: null, packages: null, settings: null }
|
|
2455
|
+
ctx.inject(['loader'], (lCtx) => {
|
|
2456
|
+
invServices.loader = lCtx.loader
|
|
2457
|
+
console.log('[dsh-mini-remote] Cordis Loader 已就绪(内置插件清单要用):loader='
|
|
2458
|
+
+ Boolean(invServices.loader))
|
|
2459
|
+
})
|
|
2460
|
+
ctx.inject(['pluginPackages'], (pkCtx) => {
|
|
2461
|
+
invServices.packages = pkCtx.pluginPackages
|
|
2462
|
+
console.log('[dsh-mini-remote] 插件包元数据已就绪:pluginPackages='
|
|
2463
|
+
+ Boolean(invServices.packages))
|
|
2464
|
+
})
|
|
2465
|
+
// 设置文档:问「官方插件里注册了设置卡片的那几个,这台部署是不是提供了」。
|
|
2466
|
+
ctx.inject(['settings'], (sCtx) => {
|
|
2467
|
+
invServices.settings = sCtx.settings
|
|
2468
|
+
console.log('[dsh-mini-remote] 设置文档已就绪(官方插件卡片要用):settings='
|
|
2469
|
+
+ Boolean(invServices.settings))
|
|
2470
|
+
})
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* 官方插件里「注册了一张设置卡片」的那几个。
|
|
2474
|
+
*
|
|
2475
|
+
* **为什么只能列在这里**:这几张卡片是**浏览器端**的插件在运行时注册进 Plugins 页的
|
|
2476
|
+
* `plugins.item` 插槽的(dsh-client-ui-plugin-manager 读的是 `ctx.slots`),
|
|
2477
|
+
* 宿主进程看不见浏览器里的插槽注册表——没有哪条宿主接口能问出来。
|
|
2478
|
+
* 所以包 identity、卡片 id、开关注册所用的命名空间、中文文案,
|
|
2479
|
+
* 只能照 DSH 0.2.0-rc.2 的客户端源码抄一份(4 个包各注册一张卡):
|
|
2480
|
+
*
|
|
2481
|
+
* dsh-client-ui-settings-shell id=shell 等 bash-sandbox / pwsh-sandbox
|
|
2482
|
+
* dsh-client-ui-settings-agent-loop id=agent-loop 等 agent-loop
|
|
2483
|
+
* dsh-client-ui-settings-subagent id=subagent 等 subagent / subagent-model-selection-settings
|
|
2484
|
+
* dsh-client-ui-settings-web-search id=web-search 等 web-search-deepseek
|
|
2485
|
+
*
|
|
2486
|
+
* 文案逐字取自那几个包 `lib/client.js` 里自己的 zh 词典(不自己编)。
|
|
2487
|
+
*
|
|
2488
|
+
* **出现条件照 DSH 同一条规矩**:宿主的设置文档里 served 了它那几个命名空间才出现
|
|
2489
|
+
* (DSH 那边是 `configForms.whileServed(namespaces, …)`)——所以「这台部署没有终端设置」
|
|
2490
|
+
* 时,手机上也不会凭空多出一张终端卡。DSH 以后再加官方卡片,这张表要跟着补。
|
|
2491
|
+
*/
|
|
2492
|
+
const OFFICIAL_CARDS = [
|
|
2493
|
+
{ id: 'shell', namespaces: ['bash-sandbox', 'pwsh-sandbox'], title: '终端',
|
|
2494
|
+
description: '限制每条命令最多能跑多久、最多输出多少内容。' },
|
|
2495
|
+
{ id: 'agent-loop', namespaces: ['agent-loop'], title: 'Agent 循环',
|
|
2496
|
+
description: '控制 Agent 派发工具调用的方式。' },
|
|
2497
|
+
{ id: 'subagent', namespaces: ['subagent', 'subagent-model-selection-settings'], title: '子智能体',
|
|
2498
|
+
description: '设置子智能体的递归层级、数量和模型。' },
|
|
2499
|
+
{ id: 'web-search', namespaces: ['web-search-deepseek'], title: '网页搜索',
|
|
2500
|
+
description: '设置 DeepSeek 的搜索提供方。' },
|
|
2501
|
+
]
|
|
2502
|
+
|
|
2165
2503
|
// ------------------------------------------------------------------
|
|
2166
2504
|
// 手机上传文件要用的附件服务
|
|
2167
2505
|
// ------------------------------------------------------------------
|
|
@@ -2333,9 +2671,54 @@ export function apply(ctx, config = {}) {
|
|
|
2333
2671
|
}
|
|
2334
2672
|
}
|
|
2335
2673
|
|
|
2674
|
+
/**
|
|
2675
|
+
* 取一份「本地化文案」里的中文。
|
|
2676
|
+
*
|
|
2677
|
+
* 形状是 LocalizedText:一个字符串,或 `{ en, 'zh-CN', … }` 这种按语言分份的对象。
|
|
2678
|
+
* 插件的标题和说明都是这个形状——不挑语言直接读,官方插件在手机上就全是英文。
|
|
2679
|
+
* 没有中文退英文,再没有就取第一个非空串;都没有返回空串(调用方自己决定怎么兜)。
|
|
2680
|
+
*/
|
|
2681
|
+
function localizedText(v) {
|
|
2682
|
+
if (typeof v === 'string') return v
|
|
2683
|
+
if (!v || typeof v !== 'object') return ''
|
|
2684
|
+
for (const k of ['zh-CN', 'zh-Hans', 'zh', 'en']) {
|
|
2685
|
+
if (typeof v[k] === 'string' && v[k]) return v[k]
|
|
2686
|
+
}
|
|
2687
|
+
for (const val of Object.values(v)) if (typeof val === 'string' && val) return val
|
|
2688
|
+
return ''
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/**
|
|
2692
|
+
* Loader 条目的 Fiber 状态码 → 词。照 dsh-host-plugin-inventory 里那张表:
|
|
2693
|
+
* 0 待定 / 1 加载中 / 2 运行中 / 3 失败 / 4 已销毁(报 null)/ 5 卸载中。
|
|
2694
|
+
*/
|
|
2695
|
+
const FIBER_PHASE = ['pending', 'loading', 'active', 'failed', null, 'unloading']
|
|
2696
|
+
|
|
2697
|
+
/**
|
|
2698
|
+
* 一个 Agent 模式(预设)拿给用户看的名字与说明。
|
|
2699
|
+
*
|
|
2700
|
+
* 政策照 PC:**声明里自带名字的(自建模式)用它自己的**,没名字的(内置那几档)
|
|
2701
|
+
* 才落 AGENT_MODE_COPY;认不出又没名字的落 null——如实,不瞎起名。
|
|
2702
|
+
*
|
|
2703
|
+
* 抽出来是因为两处要用:手机上「选模式」的清单,以及插件页「会话插件」按预设分组。
|
|
2704
|
+
* (2026-10-05 实测抓到:分组那儿漏了这一步,页面上露出 standard / ptc / cordis
|
|
2705
|
+
* 这种英文代号——组合清单里的 name 是预设声明的原名,不是给用户看的名字。)
|
|
2706
|
+
*/
|
|
2707
|
+
function presetCopyOf(p) {
|
|
2708
|
+
const copy = AGENT_MODE_COPY[p?.id]
|
|
2709
|
+
const hasName = typeof p?.name === 'string' && p.name
|
|
2710
|
+
const hasDesc = typeof p?.description === 'string' && p.description
|
|
2711
|
+
return {
|
|
2712
|
+
id: p?.id,
|
|
2713
|
+
name: hasName ? p.name : (copy ? copy[0] : null),
|
|
2714
|
+
description: hasDesc ? p.description : (copy ? copy[1] : null),
|
|
2715
|
+
broken: typeof p?.broken === 'string' ? p.broken : null,
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2336
2719
|
const nav = {
|
|
2337
2720
|
listWorkspaces: (runningIds, force, includeEmpty) =>
|
|
2338
|
-
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
|
|
2721
|
+
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty, meta: sessionMeta }),
|
|
2339
2722
|
listSessionsOf: (workspaceId, limit, force, onTitle) =>
|
|
2340
2723
|
listSessionsOf({
|
|
2341
2724
|
registry: treeServices.registry,
|
|
@@ -2347,15 +2730,38 @@ export function apply(ctx, config = {}) {
|
|
|
2347
2730
|
limit,
|
|
2348
2731
|
force,
|
|
2349
2732
|
onTitle,
|
|
2733
|
+
meta: sessionMeta,
|
|
2350
2734
|
}),
|
|
2351
2735
|
// 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
|
|
2352
2736
|
// 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
|
|
2353
|
-
createSession: (workspaceId) =>
|
|
2737
|
+
createSession: (workspaceId, agentPreset) =>
|
|
2354
2738
|
createSessionIn({
|
|
2355
2739
|
controller: canCreate.controller ?? ctx.get('sessionController'),
|
|
2356
2740
|
registry: treeServices.registry,
|
|
2357
2741
|
workspaceId,
|
|
2742
|
+
agentPreset,
|
|
2358
2743
|
}),
|
|
2744
|
+
// Agent 模式清单(2026-10-04):手机新建会话时挑模式用。坏档也如实给(broken),
|
|
2745
|
+
// 拿不到能力就说 unavailable——不摆一个没得选的选择题。默认哪一档由
|
|
2746
|
+
// registry 的 defaultId 说(那是 DSH 自己的默认,不猜)。
|
|
2747
|
+
//
|
|
2748
|
+
// 名字照抄 PC 的词条表(见 AGENT_MODE_COPY,用户 2026-10-04 点名「模式选择应该
|
|
2749
|
+
// 为中文」)。政策也照 PC:**声明里自带名字的(自建模式)用它自己的**,没名字的
|
|
2750
|
+
// (内置档)才落词条表;认不出又没名字的才落 id——如实,不瞎起名。
|
|
2751
|
+
agentPresets: async () => {
|
|
2752
|
+
const svc = canPresets.registry ?? ctx.get('agentPresets')
|
|
2753
|
+
if (!svc || typeof svc.list !== 'function') return { ok: false, reason: 'unavailable' }
|
|
2754
|
+
try {
|
|
2755
|
+
const list = await svc.list()
|
|
2756
|
+
return {
|
|
2757
|
+
ok: true,
|
|
2758
|
+
defaultId: typeof svc.defaultId === 'string' ? svc.defaultId : null,
|
|
2759
|
+
presets: (Array.isArray(list) ? list : []).map(presetCopyOf),
|
|
2760
|
+
}
|
|
2761
|
+
} catch (err) {
|
|
2762
|
+
return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
|
|
2763
|
+
}
|
|
2764
|
+
},
|
|
2359
2765
|
// 「这一刻到底能不能建会话」。给 /mini/api/version 用:那是个不用动手、
|
|
2360
2766
|
// 一条命令就能问的运行时事实,比翻日志快。和 createSession 同一条判据,
|
|
2361
2767
|
// 免得出现「探测说能、真按了却不行」。
|
|
@@ -2374,7 +2780,332 @@ export function apply(ctx, config = {}) {
|
|
|
2374
2780
|
|
|
2375
2781
|
// 子智能体(2026-10-03)。四条都取**当前绑定会话**那一份,和上面 send / stop
|
|
2376
2782
|
// 同一套口径:手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
|
|
2377
|
-
subagents: (sessionId) => listSubagents(sessionId),
|
|
2783
|
+
subagents: (sessionId, options) => listSubagents(sessionId, options),
|
|
2784
|
+
|
|
2785
|
+
/**
|
|
2786
|
+
* 插件清单(手机端「插件」页面用,2026-10-04)。
|
|
2787
|
+
*
|
|
2788
|
+
* 字段照 DSH 的 `BundleInfo`(pluginManager 的契约,逐字段对过):
|
|
2789
|
+
* name / version / installed / optional / enabled / description
|
|
2790
|
+
* meta: { title?, description? } —— **本地化文案**,中文说明在这一份里;
|
|
2791
|
+
* error: { code, diagnostic? } —— 出问题的那一笔。
|
|
2792
|
+
*
|
|
2793
|
+
* 两处不照抄会难看:① 只取 `description`(包的英文说明),
|
|
2794
|
+
* PC 上那句中文「启用团队协作、团队工具……」来自 `meta.description`,得优先用它;
|
|
2795
|
+
* ② `error` 是个对象,直接递出去页面上会显示 `[object Object]`。
|
|
2796
|
+
*/
|
|
2797
|
+
/**
|
|
2798
|
+
* 正在跑的这只 DSH 是哪个版本(设置页底部那行用,2026-10-04)。
|
|
2799
|
+
*
|
|
2800
|
+
* **为什么不读文件**:早先这里去翻 `node_modules/@deepseek-ai/dsh/package.json`,
|
|
2801
|
+
* 两个毛病都踩了——① 那是「命令行那份 dsh」的版本,未必等于正在跑的这只 Host
|
|
2802
|
+
* (实测桌面端跑的是 0.2.0-rc.2,而命令行那份写着 0.2.0-rc.1);
|
|
2803
|
+
* ② 桌面端的工作目录里压根没有那个文件,于是手机上一直显示 `unknown`。
|
|
2804
|
+
*
|
|
2805
|
+
* 改问正在跑的这只 Host:它自己报的运行时版本才作数。问到一次就记住。
|
|
2806
|
+
*/
|
|
2807
|
+
dshVersion: async () => {
|
|
2808
|
+
if (cachedDshVersion) return cachedDshVersion
|
|
2809
|
+
const mgr = pluginServices.manager ?? ctx.get('pluginManager')
|
|
2810
|
+
try {
|
|
2811
|
+
// 这个接口本意是列「版本豁免」,但它捎带把运行时版本报出来了
|
|
2812
|
+
// ——宿主唯一一处直接说「我是哪个版本」的地方。
|
|
2813
|
+
const got = await mgr?.listVersionExemptions?.()
|
|
2814
|
+
if (typeof got?.runtimeVersion === 'string' && got.runtimeVersion) {
|
|
2815
|
+
cachedDshVersion = got.runtimeVersion
|
|
2816
|
+
return cachedDshVersion
|
|
2817
|
+
}
|
|
2818
|
+
} catch (err) {
|
|
2819
|
+
// 问不到就往下一步退,不把设置页那行弄崩。
|
|
2820
|
+
}
|
|
2821
|
+
try {
|
|
2822
|
+
// 退一步:核心包自己的版本就是 DSH 的版本(整个仓库一起发版)。
|
|
2823
|
+
const bundles = await mgr?.listBundles?.()
|
|
2824
|
+
const base = (bundles || []).find((b) => b?.name === '@deepseek-ai/dsh-base')
|
|
2825
|
+
if (typeof base?.version === 'string' && base.version) {
|
|
2826
|
+
cachedDshVersion = base.version
|
|
2827
|
+
return cachedDshVersion
|
|
2828
|
+
}
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
// 都没有就如实说不知道(页面据此整行不显示,不拿 unknown 糊一行)。
|
|
2831
|
+
}
|
|
2832
|
+
return 'unknown'
|
|
2833
|
+
},
|
|
2834
|
+
|
|
2835
|
+
pluginList: async () => {
|
|
2836
|
+
const mgr = pluginServices.manager ?? ctx.get('pluginManager')
|
|
2837
|
+
if (!mgr) return { ok: false, reason: 'unavailable' }
|
|
2838
|
+
try {
|
|
2839
|
+
const bundles = await mgr.listBundles()
|
|
2840
|
+
return {
|
|
2841
|
+
ok: true,
|
|
2842
|
+
bundles: (bundles || []).map((b) => ({
|
|
2843
|
+
name: b?.name ?? '',
|
|
2844
|
+
// 显示名优先用本地化标题(官方插件在 PC 上显示的是「智能体团队」而不是包名),
|
|
2845
|
+
// 没有就退回包名——第三方插件多半没有标题。
|
|
2846
|
+
title: localizedText(b?.meta?.title) || b?.name || '',
|
|
2847
|
+
description: localizedText(b?.meta?.description) || b?.description || '',
|
|
2848
|
+
version: typeof b?.version === 'string' ? b.version : '',
|
|
2849
|
+
installed: b?.installed === true,
|
|
2850
|
+
optional: b?.optional === true,
|
|
2851
|
+
enabled: b?.enabled !== false,
|
|
2852
|
+
// 出问题的那一笔:有原话就说原话,没有就报错误码。没它就别硬编。
|
|
2853
|
+
problem: b?.error ? (b.error.diagnostic || b.error.code || '有问题') : '',
|
|
2854
|
+
})),
|
|
2855
|
+
}
|
|
2856
|
+
} catch (err) {
|
|
2857
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
2858
|
+
}
|
|
2859
|
+
},
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* 内置插件清单(手机端「插件 → 已安装 → 内置」用,2026-10-04 补)。
|
|
2863
|
+
*
|
|
2864
|
+
* **为什么不是 listBundles**:那是 npm 包的粒度(十几个),答不了「内置插件有多少」。
|
|
2865
|
+
* DSH 是「一切皆插件、无特权核心」,一整套系统由几百个插件模块拼成,真相在
|
|
2866
|
+
* Cordis Loader 的实时条目表里。PC 的「设置 → 内置插件」页就是读这里,分两块:
|
|
2867
|
+
* 全局插件 = Loader 自己的条目(系统与所有会话共用);
|
|
2868
|
+
* 会话插件 = 各 Agent 预设的组合行(预设挂载后才在会话里跑)。
|
|
2869
|
+
*
|
|
2870
|
+
* 读取逻辑照 `dsh-host-plugin-inventory`(那是只读投影,我们直接读同几个源:
|
|
2871
|
+
* loader.entries() / agentPresets.compositionInventory() / pluginPackages.metaOf()),
|
|
2872
|
+
* 因为它在宿主服务目录里查不到,只能按它的实现来。
|
|
2873
|
+
*/
|
|
2874
|
+
pluginInventory: async () => {
|
|
2875
|
+
const loader = invServices.loader ?? ctx.get('loader')
|
|
2876
|
+
if (!loader || typeof loader.entries !== 'function') {
|
|
2877
|
+
return { ok: false, reason: 'unavailable' }
|
|
2878
|
+
}
|
|
2879
|
+
const packages = invServices.packages ?? ctx.get('pluginPackages')
|
|
2880
|
+
// 显示用的本地化元数据。取不到就是没有——退成模块名,不影响列出来。
|
|
2881
|
+
const metaOf = (moduleName, base) => {
|
|
2882
|
+
if (!packages || typeof packages.metaOf !== 'function' || base === undefined) return null
|
|
2883
|
+
try {
|
|
2884
|
+
return packages.metaOf(moduleName, base) ?? null
|
|
2885
|
+
} catch (err) {
|
|
2886
|
+
return null
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* 模块名压成短名。照 PC 的「内置插件」面板(dsh-client-ui-settings-plugin-inventory
|
|
2891
|
+
* 里那个 moduleShortName):`@deepseek-ai/dsh-tool-fs` → `tool-fs`。
|
|
2892
|
+
* 手机上不压的话每一行都是 `@deepseek-ai/dsh-xxx`,一屏放不下几个。
|
|
2893
|
+
*/
|
|
2894
|
+
const shortName = (name) => String(name ?? '')
|
|
2895
|
+
.replace(/^@[^/]+\//, '')
|
|
2896
|
+
.replace(/^cordis:/, '')
|
|
2897
|
+
.replace(/^cordis-plugin-/, '')
|
|
2898
|
+
.replace(/^dsh-(?:host-|client-)?/, '')
|
|
2899
|
+
/**
|
|
2900
|
+
* 显示名。PC 的规矩:`meta.title` 是本地化对象就取文案,否则把那一份
|
|
2901
|
+
* (字符串标题,或干脆是模块名)压成短名。没有标题就退到模块名的短名。
|
|
2902
|
+
*/
|
|
2903
|
+
const titleOf = (meta, moduleName) => {
|
|
2904
|
+
const t = meta?.title
|
|
2905
|
+
if (t && typeof t === 'object') return localizedText(t) || shortName(moduleName)
|
|
2906
|
+
return shortName(t || moduleName)
|
|
2907
|
+
}
|
|
2908
|
+
const rowOf = (moduleName, base, extra) => {
|
|
2909
|
+
const meta = metaOf(moduleName, base)
|
|
2910
|
+
return {
|
|
2911
|
+
entryId: extra.entryId,
|
|
2912
|
+
moduleName,
|
|
2913
|
+
title: titleOf(meta, moduleName),
|
|
2914
|
+
description: localizedText(meta?.description),
|
|
2915
|
+
enabled: extra.enabled,
|
|
2916
|
+
// 预设组合行可能是 'conditional'(带 !!js 条件、要挂载后才定得下来)。
|
|
2917
|
+
conditional: extra.conditional === true,
|
|
2918
|
+
condition: extra.condition,
|
|
2919
|
+
phase: extra.phase,
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
const entries = []
|
|
2924
|
+
try {
|
|
2925
|
+
for (const entry of loader.entries()) {
|
|
2926
|
+
// 分组只是树上的容器,不是插件(PC 的内置插件页同样跳过它们)。
|
|
2927
|
+
if (entry?.options?.group) continue
|
|
2928
|
+
const moduleName = String(entry?.options?.name ?? '')
|
|
2929
|
+
entries.push(rowOf(moduleName, entry?.parent?.tree?.ctx?.baseUrl, {
|
|
2930
|
+
entryId: String(entry?.id ?? ''),
|
|
2931
|
+
enabled: entry?.disabled !== true,
|
|
2932
|
+
condition: '',
|
|
2933
|
+
phase: entry?.fiber === undefined ? null : (FIBER_PHASE[entry.fiber.state] ?? null),
|
|
2934
|
+
}))
|
|
2935
|
+
}
|
|
2936
|
+
} catch (err) {
|
|
2937
|
+
return { ok: false, reason: '读 Loader 条目失败:' + String(err?.message || err) }
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// 会话插件:预设的组合行。拿不到就空着——那说明这个部署没挂预设名册,
|
|
2941
|
+
// 不是「一个都没有」,所以页面那一块整块不画。
|
|
2942
|
+
const presets = []
|
|
2943
|
+
try {
|
|
2944
|
+
const registry = canPresets.registry ?? ctx.get('agentPresets')
|
|
2945
|
+
// 组合清单里的 `name` 是预设自己声明的**原名**(内置那几档就是 standard / ptc
|
|
2946
|
+
// 这种代号),不是给用户看的。给用户看的名字在名册那边——走和「选模式」同一套
|
|
2947
|
+
// 政策取(见 presetCopyOf)。名册读不到就退到原名,不硬编一张对照表。
|
|
2948
|
+
const shownName = new Map()
|
|
2949
|
+
try {
|
|
2950
|
+
const roster = await registry?.list?.()
|
|
2951
|
+
for (const p of Array.isArray(roster) ? roster : []) {
|
|
2952
|
+
const copy = presetCopyOf(p)
|
|
2953
|
+
if (copy.id !== undefined && copy.name) shownName.set(String(copy.id), copy.name)
|
|
2954
|
+
}
|
|
2955
|
+
} catch (err) {
|
|
2956
|
+
// 名册问不到不影响列出组合(只是名字会退回代号)。
|
|
2957
|
+
}
|
|
2958
|
+
const list = await registry?.compositionInventory?.()
|
|
2959
|
+
for (const one of list || []) {
|
|
2960
|
+
const id = String(one?.id ?? '')
|
|
2961
|
+
presets.push({
|
|
2962
|
+
id,
|
|
2963
|
+
name: shownName.get(id) || localizedText(one?.name) || id,
|
|
2964
|
+
isDefault: one?.isDefault === true,
|
|
2965
|
+
broken: one?.broken ? String(one.broken) : '',
|
|
2966
|
+
rows: (one?.rows || []).map((r) => rowOf(String(r?.moduleName ?? ''), ctx.baseUrl, {
|
|
2967
|
+
entryId: r?.entryId === null || r?.entryId === undefined ? '' : String(r.entryId),
|
|
2968
|
+
enabled: r?.enabled === true,
|
|
2969
|
+
conditional: r?.enabled === 'conditional',
|
|
2970
|
+
condition: r?.condition ? String(r.condition) : '',
|
|
2971
|
+
phase: r?.fiberState === undefined ? null : (FIBER_PHASE[r.fiberState] ?? null),
|
|
2972
|
+
})),
|
|
2973
|
+
})
|
|
2974
|
+
}
|
|
2975
|
+
} catch (err) {
|
|
2976
|
+
// 预设读不动不该把全局那一半也拖没:两块各报各的。
|
|
2977
|
+
console.log('[dsh-mini-remote] 预设组合读不到(内置插件的会话那一块会空着):'
|
|
2978
|
+
+ String(err?.message || err))
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
return { ok: true, entries, presets, hasRoster: canPresets.registry !== null }
|
|
2982
|
+
},
|
|
2983
|
+
|
|
2984
|
+
/**
|
|
2985
|
+
* 官方插件里那几张「设置卡片」(2026-10-04 用户确认:官方就是这 8 个,手机上也要看得到)。
|
|
2986
|
+
*
|
|
2987
|
+
* 官方那一栏其实是两样东西拼的,PC 上也是:
|
|
2988
|
+
* ① 可开关的官方插件**包**(optional && 未装)——走 pluginList,4 个;
|
|
2989
|
+
* ② 注册了设置卡片的官方插件——就是这里,4 个(终端 / Agent 循环 / 子智能体 / 网页搜索)。
|
|
2990
|
+
* 两张合起来正好是 PC 上那 8 个。
|
|
2991
|
+
*
|
|
2992
|
+
* 判据照 DSH:宿主的设置文档 served 了卡片那几个命名空间才出现。问不到设置文档时
|
|
2993
|
+
* **整块不出现**(宁可少画,不凭空多画)——这和 DSH 自己「不 served 就不注册」一致。
|
|
2994
|
+
*/
|
|
2995
|
+
officialPlugins: async () => {
|
|
2996
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
2997
|
+
if (!settings || typeof settings.describe !== 'function') return { ok: true, cards: [] }
|
|
2998
|
+
let served = null
|
|
2999
|
+
try {
|
|
3000
|
+
served = new Set((settings.describe() || []).map((d) => String(d?.ns ?? '')))
|
|
3001
|
+
} catch (err) {
|
|
3002
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
3003
|
+
}
|
|
3004
|
+
return {
|
|
3005
|
+
ok: true,
|
|
3006
|
+
cards: OFFICIAL_CARDS
|
|
3007
|
+
.filter((c) => c.namespaces.some((n) => served.has(n)))
|
|
3008
|
+
.map((c) => ({ kind: 'card', id: c.id, title: c.title, description: c.description })),
|
|
3009
|
+
}
|
|
3010
|
+
},
|
|
3011
|
+
/**
|
|
3012
|
+
* 「繁忙时的发送行为」(2026-10-05 用户要求搬进手机设置)。
|
|
3013
|
+
*
|
|
3014
|
+
* **这是什么**:智能体正在跑的时候,你在手机上进的那句话到底怎么交给它——
|
|
3015
|
+
* 排队发送(queue):排到队尾,等当前这一轮跑完再跑;
|
|
3016
|
+
* 插话发送(steer):直接插进去,它当场看到并调整方向。
|
|
3017
|
+
*
|
|
3018
|
+
* **PC 上是同一个东西**:设置 → 通用里的「繁忙时的发送行为」。
|
|
3019
|
+
* 真相在 `dsh-client-ui-conversation` 里:
|
|
3020
|
+
* 命名空间 ui-conversation、字段 busyEnter、取值 queue|steer、默认 queue。
|
|
3021
|
+
* 它的注释原话是「Busy-Enter preference shared by the composer bar inject face
|
|
3022
|
+
* and its Settings row: one live store … backed by the Host user-settings document」。
|
|
3023
|
+
*
|
|
3024
|
+
* **两端共用一个值**(用户 2026-10-05 拍板「都 ok」):宿主只有一份用户设置文档,
|
|
3025
|
+
* 手机上改它,电脑上也是这个值。这样才不会出现「手机上选了插话、电脑上还是排队」。
|
|
3026
|
+
*
|
|
3027
|
+
* **拿不到就整条不显示**,不摆一个假的开关——和插件页那套「宁可少画」同一口径。
|
|
3028
|
+
*/
|
|
3029
|
+
busyEnterSetting: async () => {
|
|
3030
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
3031
|
+
if (!settings || typeof settings.describe !== 'function') return { ok: false, reason: 'unavailable' }
|
|
3032
|
+
let doc = null
|
|
3033
|
+
try {
|
|
3034
|
+
doc = (settings.describe() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation') ?? null
|
|
3035
|
+
} catch (err) {
|
|
3036
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
3037
|
+
}
|
|
3038
|
+
// 这个命名空间得真的被 served 才有得改——没有它就没有这一条。
|
|
3039
|
+
if (!doc) return { ok: false, reason: 'unavailable' }
|
|
3040
|
+
const value = doc?.value && typeof doc.value === 'object' ? doc.value : {}
|
|
3041
|
+
const current = value.busyEnter === 'steer' ? 'steer' : 'queue'
|
|
3042
|
+
return {
|
|
3043
|
+
ok: true,
|
|
3044
|
+
current,
|
|
3045
|
+
// 带上 revision:写的时候要它做乐观锁,免得和电脑上同时改打架。
|
|
3046
|
+
revision: typeof doc.revision === 'number' ? doc.revision : undefined,
|
|
3047
|
+
}
|
|
3048
|
+
},
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* 改「繁忙时的发送行为」。值只有两个,别的一律拒绝——**不替用户猜一个**。
|
|
3052
|
+
*
|
|
3053
|
+
* 走 `settings.update(ns, patch, revision)`(官方契约里的「把一个 entry 的可编辑
|
|
3054
|
+
* 字段合并进去」)。带 revision 是有意的:万一电脑上刚改过,这次写入会被挡下来
|
|
3055
|
+
* 而不是把对方的改动盖掉——那种覆盖用户看不见,最难查。
|
|
3056
|
+
*/
|
|
3057
|
+
setBusyEnter: async (next) => {
|
|
3058
|
+
if (next !== 'queue' && next !== 'steer') {
|
|
3059
|
+
return { ok: false, reason: 'invalid' }
|
|
3060
|
+
}
|
|
3061
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
3062
|
+
if (!settings || typeof settings.update !== 'function') return { ok: false, reason: 'unavailable' }
|
|
3063
|
+
let revision
|
|
3064
|
+
try {
|
|
3065
|
+
const doc = (settings.describe() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation')
|
|
3066
|
+
if (!doc) return { ok: false, reason: 'unavailable' }
|
|
3067
|
+
revision = typeof doc.revision === 'number' ? doc.revision : undefined
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
3070
|
+
}
|
|
3071
|
+
try {
|
|
3072
|
+
await settings.update('ui-conversation', { busyEnter: next }, revision)
|
|
3073
|
+
} catch (err) {
|
|
3074
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
3075
|
+
}
|
|
3076
|
+
return { ok: true, current: next }
|
|
3077
|
+
},
|
|
3078
|
+
|
|
3079
|
+
/**
|
|
3080
|
+
* 「进阶设置(电脑端界面)」的当前状态。
|
|
3081
|
+
*
|
|
3082
|
+
* `available` 和 `enabled` 是**两件事**,界面要分开说:
|
|
3083
|
+
* available=false —— 这台机器上就没有这个能力(没拿到 DSH_WEB_URL),整条不显示;
|
|
3084
|
+
* available=true、enabled=false —— 有能力,但用户没开(默认就是这个)。
|
|
3085
|
+
*/
|
|
3086
|
+
mirrorSetting: () => ({
|
|
3087
|
+
ok: true,
|
|
3088
|
+
// `available` 问的是**此刻**能不能用(上游地址解析得出来),不是「建没建出这个对象」——
|
|
3089
|
+
// webServer 就绪得晚,插件刚加载那一刻问不到端口,那时候要如实说不可用。
|
|
3090
|
+
available: mirror.available(),
|
|
3091
|
+
enabled: store.state.mirrorEnabled === true,
|
|
3092
|
+
}),
|
|
3093
|
+
|
|
3094
|
+
/**
|
|
3095
|
+
* 开/关「进阶设置」。**这是权限开关,不是普通偏好**——见 store 里那段说明。
|
|
3096
|
+
*
|
|
3097
|
+
* 只认布尔值,别的一律拒绝(和 busyEnter 同一条口径:不替用户猜一个)。
|
|
3098
|
+
* 关掉时不做任何「清理」:那条路由是每请求现读开关的,关掉就等于不存在了,
|
|
3099
|
+
* 不需要再去撤销什么。
|
|
3100
|
+
*/
|
|
3101
|
+
setMirror: (enabled) => {
|
|
3102
|
+
if (typeof enabled !== 'boolean') return { ok: false, reason: 'invalid' }
|
|
3103
|
+
if (!mirror.available()) return { ok: false, reason: 'unavailable' }
|
|
3104
|
+
const value = store.setMirrorEnabled(enabled)
|
|
3105
|
+
log.info?.(value ? '手机开了「进阶设置」:现在可以打开电脑端界面。' : '手机关掉了「进阶设置」。')
|
|
3106
|
+
return { ok: true, enabled: value }
|
|
3107
|
+
},
|
|
3108
|
+
|
|
2378
3109
|
subagentTranscript: (childId, sessionId) => subagentTranscript(childId, sessionId),
|
|
2379
3110
|
stopSubagent: (childId, sessionId, mode) => stopSubagent(childId, sessionId, mode),
|
|
2380
3111
|
askSubagent: (childId, sessionId, mode, text) => askSubagent(childId, sessionId, mode, text),
|
|
@@ -2523,6 +3254,62 @@ export function apply(ctx, config = {}) {
|
|
|
2523
3254
|
},
|
|
2524
3255
|
}
|
|
2525
3256
|
|
|
3257
|
+
// ------------------------------------------------------------------
|
|
3258
|
+
// 「进阶设置」:电脑端界面的镜像(2026-10-06)
|
|
3259
|
+
//
|
|
3260
|
+
// 手机上那张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
3261
|
+
// 这一块是退路:**要改那些设置时,把电脑端界面整个搬过来看**——不迁移页面、
|
|
3262
|
+
// 不逐个适配、同一份界面。原委和认证怎么走,都写在 lib/mirror.js 开头。
|
|
3263
|
+
//
|
|
3264
|
+
// **地址从 webServer 服务问,不从环境变量猜。** 这一条是实测撞出来的:
|
|
3265
|
+
// 第一版读 `process.env.DSH_WEB_URL`,结果 `available=false`——那个变量是
|
|
3266
|
+
// **命令行工具那一侧**才有的(DSH 给子进程注入的),**宿主进程里根本没有**。
|
|
3267
|
+
// 宿主自己报地址的地方就是 webServer(它有 `port` 和 `host`),而且**两端都成立**:
|
|
3268
|
+
// 桌面端它指向那个只绑回环的界面,`dsh web` 那边它就是界面本身。
|
|
3269
|
+
//
|
|
3270
|
+
// 而且**必须惰性**:boot 期各插件并行加载,apply 这一刻 webServer 常常还没 provide。
|
|
3271
|
+
// 所以端口在下面那个 `ctx.inject(['webServer'])` 回调里才填上,
|
|
3272
|
+
// 在那之前镜像会如实说自己「还没法用」,界面那一行也就不显示。
|
|
3273
|
+
// ------------------------------------------------------------------
|
|
3274
|
+
let mirrorAppPort = null
|
|
3275
|
+
|
|
3276
|
+
function mirrorUpstream() {
|
|
3277
|
+
// webServer 到位了就用它——**回环字面量写在这里**:上游必须是本机那一个,
|
|
3278
|
+
// 而 webServer 的 host 可能是 0.0.0.0(那是「绑哪张网卡」,不是「连哪个地址」)。
|
|
3279
|
+
//
|
|
3280
|
+
// 没有 webServer 就没有可镜像的界面(纯 headless 组合),如实回 null,
|
|
3281
|
+
// 界面那一行不显示。**这里刻意不留 `DSH_WEB_URL` 的退路**:实测那个变量只在
|
|
3282
|
+
// 命令行工具那一侧存在,宿主里没有;而且留着它会让测试在开发机上意外指到
|
|
3283
|
+
// 用户真实运行的界面上——测试不该有这种机会。
|
|
3284
|
+
if (typeof mirrorAppPort === 'number' && mirrorAppPort > 0) {
|
|
3285
|
+
return `http://127.0.0.1:${mirrorAppPort}`
|
|
3286
|
+
}
|
|
3287
|
+
return null
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
const mirror = createMirror({
|
|
3291
|
+
upstream: mirrorUpstream,
|
|
3292
|
+
/**
|
|
3293
|
+
* 取「带官方令牌的地址」。**惰性求值**:这一刻 connection 服务可能还没 provide,
|
|
3294
|
+
* 但转发是真发生请求时才问,那时候它一定在。
|
|
3295
|
+
*/
|
|
3296
|
+
tokenUrl: () => {
|
|
3297
|
+
const connection = ctx.get('connection')
|
|
3298
|
+
if (!connection || typeof connection.authenticatedUrl !== 'function') return null
|
|
3299
|
+
const base = mirrorUpstream()
|
|
3300
|
+
if (!base) return null
|
|
3301
|
+
try {
|
|
3302
|
+
return connection.authenticatedUrl(`${base}/`)
|
|
3303
|
+
} catch (err) {
|
|
3304
|
+
log.warn?.('取电脑端界面的访问凭据失败:' + (err?.message ?? err))
|
|
3305
|
+
return null
|
|
3306
|
+
}
|
|
3307
|
+
},
|
|
3308
|
+
log: (msg) => log.warn?.('[镜像] ' + msg),
|
|
3309
|
+
})
|
|
3310
|
+
|
|
3311
|
+
log.info?.('「进阶设置(电脑端界面)」已就绪,默认关;等 webServer 报出端口后可用。')
|
|
3312
|
+
|
|
2526
3313
|
// ------------------------------------------------------------------
|
|
2527
3314
|
// 起服务
|
|
2528
3315
|
// ------------------------------------------------------------------
|
|
@@ -2535,6 +3322,11 @@ export function apply(ctx, config = {}) {
|
|
|
2535
3322
|
bindAddresses: resolveBindAddresses(settings.bindAddress),
|
|
2536
3323
|
tunnelHost,
|
|
2537
3324
|
log,
|
|
3325
|
+
// 镜像那一路:实例 + **每个请求现读的开关**(关着时那条路由根本不存在)。
|
|
3326
|
+
// 上游地址没解析出来时也当关着——宁可回 404(查无此路),也不要回一个
|
|
3327
|
+
// 「有这条路但它坏了」的 502:前者是实话,后者会让用户以为功能坏了。
|
|
3328
|
+
mirror,
|
|
3329
|
+
mirrorEnabled: () => store.state.mirrorEnabled === true && mirror.available(),
|
|
2538
3330
|
onInstruction,
|
|
2539
3331
|
onCommands,
|
|
2540
3332
|
onCommand,
|
|
@@ -2640,6 +3432,14 @@ export function apply(ctx, config = {}) {
|
|
|
2640
3432
|
return
|
|
2641
3433
|
}
|
|
2642
3434
|
|
|
3435
|
+
// 「进阶设置」的镜像要往**这台服务器**转发——它就是电脑端界面本身
|
|
3436
|
+
// (桌面端那个只绑回环的 19387;`dsh web` 那边就是 3080)。
|
|
3437
|
+
// 端口到这里才知道,所以镜像那边是惰性求值的(见上面 mirrorUpstream)。
|
|
3438
|
+
if (typeof webServer.port === 'number' && webServer.port > 0) {
|
|
3439
|
+
mirrorAppPort = webServer.port
|
|
3440
|
+
log.info?.(`「进阶设置」的上游定为这台界面:127.0.0.1:${webServer.port}`)
|
|
3441
|
+
}
|
|
3442
|
+
|
|
2643
3443
|
// 面板和开关都带着 token,所以两者都只允许从这台电脑自己访问。否则同一个
|
|
2644
3444
|
// Wi-Fi 下谁都能把密码取走,token 就白设了。
|
|
2645
3445
|
function localOnly(req, res) {
|