dsh-mini-remote 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -10
- package/README.en.md +33 -1
- package/README.md +33 -1
- package/client/client.js +272 -1
- package/lib/commands.js +6 -0
- package/lib/events.js +36 -5
- package/lib/history.js +21 -3
- package/lib/index.js +1323 -19
- package/lib/lark.js +1154 -0
- package/lib/page.html +2658 -136
- package/lib/page.js +17 -3
- package/lib/pairing.js +32 -3
- package/lib/permissions.js +1 -0
- package/lib/server.js +522 -30
- package/lib/session-meta-cache.js +100 -0
- package/lib/store.js +253 -2
- package/lib/subagent-cache.js +63 -0
- package/lib/trajectory.js +570 -0
- package/lib/tree.js +107 -15
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -30,6 +30,19 @@ 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,
|
|
@@ -37,6 +50,7 @@ import {
|
|
|
37
50
|
} from './commands.js'
|
|
38
51
|
import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
|
|
39
52
|
import { sendExternalNotification } from './notify.js'
|
|
53
|
+
import { createFeishuChannel, feishuQuestionText, approvalText, FEISHU_TEXT_LIMIT } from './lark.js'
|
|
40
54
|
import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
|
|
41
55
|
import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
|
|
42
56
|
import { serveState, serveEnable, serveDisable } from './serve.js'
|
|
@@ -44,6 +58,8 @@ import { ensureCloudflared } from './cloudflared.js'
|
|
|
44
58
|
import { startTunnel, TUNNEL_HINT } from './tunnel.js'
|
|
45
59
|
import { listWorkspaces, listSessionsOf, createSessionIn, createWorkspaceAt } from './tree.js'
|
|
46
60
|
import { createTitleCache } from './title-cache.js'
|
|
61
|
+
import { createSessionMetaCache } from './session-meta-cache.js'
|
|
62
|
+
import { createSubagentCache } from './subagent-cache.js'
|
|
47
63
|
import { listRoots, listDirectory, makeDirectory } from './browse.js'
|
|
48
64
|
import { listPresets, setPreset, available as presetsAvailable } from './permissions.js'
|
|
49
65
|
import { buildId } from './build.js'
|
|
@@ -68,6 +84,15 @@ const TUNNEL_ATTEMPTS = 3
|
|
|
68
84
|
*/
|
|
69
85
|
const MAX_PENDING_UPLOADS = 20
|
|
70
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 飞书里发 `/会话` 最多列几个会话。
|
|
89
|
+
*
|
|
90
|
+
* 10 是「一屏能看完」定的:手机页是把工作区一层层展开看的,飞书里只有一条消息,
|
|
91
|
+
* 列太长要划半天,而这条命令要的只是「切到那一个」。超出的部分如实说还剩多少条
|
|
92
|
+
* (见 lib/lark.js 的 sessionPickText)。
|
|
93
|
+
*/
|
|
94
|
+
const FEISHU_PICK_MAX = 10
|
|
95
|
+
|
|
71
96
|
/**
|
|
72
97
|
* 这份代码的构建指纹,加载时算一次。
|
|
73
98
|
* 用来回答「现在应答我的进程,跑的是不是我改的这份代码」——重启期间旧进程还在
|
|
@@ -112,6 +137,22 @@ const DEFAULTS = {
|
|
|
112
137
|
maxLength: 200,
|
|
113
138
|
sound: 'default',
|
|
114
139
|
},
|
|
140
|
+
/**
|
|
141
|
+
* 飞书通路(长连接)。默认关。
|
|
142
|
+
*
|
|
143
|
+
* 为什么默认关:开了之后这台电脑就多了一扇「从飞书进来的门」,该由用户自己决定。
|
|
144
|
+
* 而且**两个名单都空 = 谁都不认**(判据在 lib/lark.js 的 admitSource)——门开着但
|
|
145
|
+
* 名单不填,等于开着没钥匙,这是刻意的默认值。
|
|
146
|
+
*/
|
|
147
|
+
feishu: {
|
|
148
|
+
enabled: false,
|
|
149
|
+
appId: '',
|
|
150
|
+
appSecret: '',
|
|
151
|
+
/** 允许的 open_id,一行一个(逗号、分号也行)。空 = 这份名单不设限。 */
|
|
152
|
+
openIds: '',
|
|
153
|
+
/** 允许的 chat_id,同上。两个都空 = 任何来源都不处理。 */
|
|
154
|
+
chatIds: '',
|
|
155
|
+
},
|
|
115
156
|
}
|
|
116
157
|
|
|
117
158
|
/**
|
|
@@ -166,6 +207,23 @@ export const Config = z.object({
|
|
|
166
207
|
maxLength: z.number(),
|
|
167
208
|
sound: z.string(),
|
|
168
209
|
})).description('外部推送(Bark / ntfy / Telegram)。改了立即生效,不用重载。'),
|
|
210
|
+
/**
|
|
211
|
+
* 飞书这一块**没有**标 volatile(口径见上面那段注释)。
|
|
212
|
+
*
|
|
213
|
+
* 这不是省事:飞书的长连接是用 appId/appSecret 建起来的,改了凭据就得把那条连接
|
|
214
|
+
* 整个换掉——那一步不是「就地改一个值」能兑现的。标了 volatile 等于向 DSH 承诺
|
|
215
|
+
* 「改它不用重载」,而承诺得在代码里兑现。**宁可少标,不能乱标。**
|
|
216
|
+
*
|
|
217
|
+
* 用户实际不会因此多做什么:设置页那一页有自己的保存按钮,存完插件自己就会把连接
|
|
218
|
+
* 换掉(见 restartFeishu)。只有手改 settings.json 的人需要重载一次插件。
|
|
219
|
+
*/
|
|
220
|
+
feishu: z.object({
|
|
221
|
+
enabled: z.boolean(),
|
|
222
|
+
appId: z.string(),
|
|
223
|
+
appSecret: z.string().role('secret'),
|
|
224
|
+
openIds: z.string(),
|
|
225
|
+
chatIds: z.string(),
|
|
226
|
+
}).description('飞书(长连接):机器人的两个凭据与两个允许名单。改了要重载插件(凭据变了得重建连接)。'),
|
|
169
227
|
})
|
|
170
228
|
|
|
171
229
|
function deepMerge(base, ...overrides) {
|
|
@@ -291,7 +349,18 @@ export function apply(ctx, config = {}) {
|
|
|
291
349
|
* 而这件事**每重启一次就得重付一遍**——标题却是定下来基本不变的东西。
|
|
292
350
|
*/
|
|
293
351
|
const titles = createTitleCache({ file: join(dir, 'titles.json') })
|
|
352
|
+
const sessionMeta = createSessionMetaCache({ file: join(dir, 'session-meta.json') })
|
|
353
|
+
const subagentCache = createSubagentCache({ file: join(dir, 'subagents.json') })
|
|
294
354
|
const tracker = createTurnTracker()
|
|
355
|
+
/**
|
|
356
|
+
* 执行轨迹的累计器(「完整」模式要的过程区)。
|
|
357
|
+
*
|
|
358
|
+
* **和上面的 tracker 是两个人**:上面那个只认「用户说的话」和「最终回答」,轨迹那个
|
|
359
|
+
* 认的是思考、工具调用、工具结果。分开的理由是它们服务的是**两种模式**——聊天模式的
|
|
360
|
+
* 回答区里一个字的过程都不该有(2026-09-22 用户亲自否掉的),而完整模式要的就是过程。
|
|
361
|
+
* 两条路共用的是同一批原始事件,不是同一份产出。
|
|
362
|
+
*/
|
|
363
|
+
const trajectory = createTrajectoryTracker()
|
|
295
364
|
// 队列的真相在 agent 的收件箱里,store 拿不到 ctx,所以给它一个回调去问。
|
|
296
365
|
// 不缓存、每次取快照现读:没有第二份状态,也就不会和电脑上不一致。
|
|
297
366
|
store.setQueueSource(queuedOf)
|
|
@@ -660,6 +729,185 @@ export function apply(ctx, config = {}) {
|
|
|
660
729
|
})
|
|
661
730
|
}
|
|
662
731
|
|
|
732
|
+
// ------------------------------------------------------------------
|
|
733
|
+
// 飞书通路(长连接)
|
|
734
|
+
// ------------------------------------------------------------------
|
|
735
|
+
/**
|
|
736
|
+
* 飞书那条路:入站走官方 SDK 的 WebSocket 长连接(不需要公网地址、不需要域名),
|
|
737
|
+
* 出站用「回复某条消息」接口挂回原消息下。实现全在 lib/lark.js,这里只管它的生死。
|
|
738
|
+
*/
|
|
739
|
+
/** @type {Awaited<ReturnType<typeof createFeishuChannel>> | null} */
|
|
740
|
+
let feishu = null
|
|
741
|
+
/** 正在建连接;用来挡住「连点两次开关起出两条」。 */
|
|
742
|
+
let feishuStarting = false
|
|
743
|
+
/** 上一次没起来的原因;null = 没出错。和 serverError 一样,用来把「还没连上」和「起不来」分开说。 */
|
|
744
|
+
let feishuError = null
|
|
745
|
+
|
|
746
|
+
async function bringFeishuUp() {
|
|
747
|
+
if (feishu || feishuStarting) return
|
|
748
|
+
const cfg = settings.feishu ?? {}
|
|
749
|
+
if (!cfg.enabled) return
|
|
750
|
+
feishuStarting = true
|
|
751
|
+
feishuError = null
|
|
752
|
+
try {
|
|
753
|
+
feishu = await createFeishuChannel({
|
|
754
|
+
config: cfg,
|
|
755
|
+
log,
|
|
756
|
+
onInstruction,
|
|
757
|
+
// 飞书里回了「同意/1」这类话,交回电脑。走的是手机页同一份清单、同一个 settle
|
|
758
|
+
// (见 lib/server.js 的 answerQuestion / decideApproval),谁先答谁生效。
|
|
759
|
+
onAnswer: answerFromFeishu,
|
|
760
|
+
// `/会话` 要的两件事:列可选会话(标题是手机页同一份缓存)、切到某一个
|
|
761
|
+
// (切的是手机页同一个全局绑定,切完手机页跟着变)。文案全在 lib/lark.js。
|
|
762
|
+
listSessions: feishuSessionList,
|
|
763
|
+
bindSession: bindSessionForFeishu,
|
|
764
|
+
})
|
|
765
|
+
log.info('飞书:通路挂上了,等消息。')
|
|
766
|
+
} catch (err) {
|
|
767
|
+
feishu = null
|
|
768
|
+
feishuError = err?.message ?? String(err)
|
|
769
|
+
log.warn(`飞书没起来:${feishuError}`)
|
|
770
|
+
} finally {
|
|
771
|
+
feishuStarting = false
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function bringFeishuDown() {
|
|
776
|
+
feishu?.close()
|
|
777
|
+
feishu = null
|
|
778
|
+
feishuError = null
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* 换配置就地重建。
|
|
783
|
+
*
|
|
784
|
+
* 长连接是绑在凭据上的,改一处就得整条换掉——所以不是「改个值」而是「关了重开」。
|
|
785
|
+
* 关这一步必须真的断干净(`close()` 里连定时器一起收),否则会留下一条看不见的
|
|
786
|
+
* 旧连接,用旧凭据继续收消息。
|
|
787
|
+
*/
|
|
788
|
+
async function restartFeishu() {
|
|
789
|
+
bringFeishuDown()
|
|
790
|
+
await bringFeishuUp()
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 这条会话叫什么。给飞书那行「会话:…」标记用(见 lib/lark.js 的 sessionTagLine)。
|
|
795
|
+
*
|
|
796
|
+
* 来源就是**手机页显示的那个名字**:lib/title-cache.js 那份落盘缓存。取不到就回空串,
|
|
797
|
+
* 标记那边会写成「未命名会话」——**不把会话 id 露出去**,手机上从不显示 id,
|
|
798
|
+
* 用户拿着它没有地方可以对。
|
|
799
|
+
*/
|
|
800
|
+
function titleOfSession(sessionId) {
|
|
801
|
+
if (!sessionId) return ''
|
|
802
|
+
const known = titles.get(sessionId)
|
|
803
|
+
return typeof known === 'string' ? known : ''
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* 这一轮落定的回答,回给在飞书里发指令的那个人。
|
|
808
|
+
*
|
|
809
|
+
* **归属靠 sessionId**(见 lib/lark.js 的 takeTarget / markRoundStarted):哪条会话
|
|
810
|
+
* 这一轮是从飞书起跑的,回答就回那条飞书消息。`meta.userText` 是这一轮**用户的原话**,
|
|
811
|
+
* 只做兜底比对——它会被同一轮里后来的一句人话顶掉,所以不能当主凭据。
|
|
812
|
+
* 手机发起的轮次在这里天然落空——对不上号就不发,不会把手机上的回答倒进飞书。
|
|
813
|
+
*
|
|
814
|
+
* 顺带把这条会话的名字一起交出去:飞书那边要在回答末尾补一行「会话:…」。
|
|
815
|
+
* 只有飞书这条路拿得到它——手机页那份文本一个字都不变。
|
|
816
|
+
*/
|
|
817
|
+
function onReply(text, meta) {
|
|
818
|
+
if (!feishu) return
|
|
819
|
+
// reply() 自己把失败写进日志并返回 { ok:false },不会抛——这里不用再兜一层。
|
|
820
|
+
feishu.reply(text, { ...meta, sessionTitle: titleOfSession(meta?.sessionId) })
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* 飞书那边拍完板,把结果交回电脑。
|
|
825
|
+
*
|
|
826
|
+
* **这里不判「这道题还在不在」**:那份清单和 settle 都在 lib/server.js,交回去的时候
|
|
827
|
+
* 它还在不在,由它如实回答(`expired` = 手机先拍了板)。这边只是把飞书的说法翻译成
|
|
828
|
+
* 那个入口收的形状。
|
|
829
|
+
*
|
|
830
|
+
* 末尾那个 `'feishu'` 是**说出来源**,只用来让手机页那张还挂着的卡片知道该收起来、
|
|
831
|
+
* 该写「已在飞书答过」(见 lib/server.js 的 notifySettled)。不是身份判据。
|
|
832
|
+
*
|
|
833
|
+
* @param {{kind: string, id: string, cancel?: boolean, answers?: Array, decision?: string}} reply
|
|
834
|
+
* @returns {{ok: boolean, error?: string}}
|
|
835
|
+
*/
|
|
836
|
+
function answerFromFeishu(reply) {
|
|
837
|
+
const take = reply?.kind === 'approval' ? miniControl.decideApproval : miniControl.answerQuestion
|
|
838
|
+
if (typeof take !== 'function') return { ok: false, error: 'no-channel' }
|
|
839
|
+
const input = reply?.cancel === true
|
|
840
|
+
? { cancel: true }
|
|
841
|
+
: (reply?.kind === 'approval' ? { decision: reply?.decision } : { answers: reply?.answers })
|
|
842
|
+
return take(reply?.id, input, 'feishu')
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* `/会话` 要列的那些:这台电脑上的会话,新的在前。
|
|
847
|
+
*
|
|
848
|
+
* 标题走的是**手机页同一份东西**:`nav.listSessionsOf` 拿的正是手机页展开工作区时那份
|
|
849
|
+
* (knownTitles + 落盘缓存 lib/title-cache.js),取不到就是空串,飞书那边写「未命名会话」。
|
|
850
|
+
*
|
|
851
|
+
* 上限是给「一屏能看完」定的,不是给性能定的:工作区一多,全列出来在手机上要划半天。
|
|
852
|
+
* `total` 一起交出去,好在超出时如实说「只列了最近 N 条」。
|
|
853
|
+
*/
|
|
854
|
+
async function feishuSessionList() {
|
|
855
|
+
const workspaces = await nav.listWorkspaces(new Set(), false, false)
|
|
856
|
+
const rows = []
|
|
857
|
+
let total = 0
|
|
858
|
+
for (const ws of workspaces) {
|
|
859
|
+
const got = await nav.listSessionsOf(ws.id, FEISHU_PICK_MAX, false)
|
|
860
|
+
if (!got) continue
|
|
861
|
+
total += Number(got.total) || 0
|
|
862
|
+
for (const one of got.sessions ?? []) {
|
|
863
|
+
rows.push({ id: one.id, title: one.title, at: Number(one.createdAt) || 0 })
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// 新的在前——和手机页展开一个工作区时的顺序同一条口径(那边也是按 createdAt 倒序)。
|
|
867
|
+
rows.sort((a, b) => b.at - a.at)
|
|
868
|
+
return {
|
|
869
|
+
rows: rows.slice(0, FEISHU_PICK_MAX).map((one) => ({ id: one.id, title: one.title })),
|
|
870
|
+
total,
|
|
871
|
+
bound: store.snapshot().boundSessionId ?? '',
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* 切到那一个会话。
|
|
877
|
+
*
|
|
878
|
+
* **切的是手机页同一个全局绑定**(和 `/mini/api/bind` 那条路逐条一样:bind → 读历史 →
|
|
879
|
+
* 广播一份快照),所以飞书里切完,手机页跟着变。这不是顺手:两边各绑各的会让用户
|
|
880
|
+
* 以为在跟同一个会话说话,而实际上没有。
|
|
881
|
+
*/
|
|
882
|
+
function bindSessionForFeishu(sessionId) {
|
|
883
|
+
store.bind(sessionId)
|
|
884
|
+
// 和 /mini/api/bind 那条逐句一样:读这个会话的历史(异步补,读完自己会广播一份快照),
|
|
885
|
+
// 再广播一次当前快照——手机页正开着的话,它当场就跟着切过去了。
|
|
886
|
+
ensureHistory(sessionId)
|
|
887
|
+
server?.broadcast('state', store.snapshot())
|
|
888
|
+
return titleOfSession(sessionId)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** 飞书那一块给设置页看的现状。**绝不包含 appSecret**,只回答「有没有凭据」。 */
|
|
892
|
+
function feishuStatus() {
|
|
893
|
+
const cfg = settings.feishu ?? {}
|
|
894
|
+
const live = feishu?.status?.() ?? null
|
|
895
|
+
return {
|
|
896
|
+
enabled: Boolean(cfg.enabled),
|
|
897
|
+
appId: String(cfg.appId ?? ''),
|
|
898
|
+
openIds: String(cfg.openIds ?? ''),
|
|
899
|
+
chatIds: String(cfg.chatIds ?? ''),
|
|
900
|
+
hasSecret: String(cfg.appSecret ?? '').trim() !== '',
|
|
901
|
+
starting: feishuStarting,
|
|
902
|
+
running: Boolean(feishu),
|
|
903
|
+
connected: Boolean(live?.connected),
|
|
904
|
+
// 上一次被白名单挡住的来源(含 open_id / chat_id 原值)。设置页要把这一行印出来:
|
|
905
|
+
// 名单默认是空的,用户不可能凭空知道自己的 id 长什么样,全靠这一行抄。
|
|
906
|
+
rejected: live?.rejected ?? null,
|
|
907
|
+
error: feishuError,
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
663
911
|
// ------------------------------------------------------------------
|
|
664
912
|
// 手机发来的指令 → 注入 DSH
|
|
665
913
|
// ------------------------------------------------------------------
|
|
@@ -963,11 +1211,100 @@ export function apply(ctx, config = {}) {
|
|
|
963
1211
|
return win
|
|
964
1212
|
}
|
|
965
1213
|
|
|
1214
|
+
/**
|
|
1215
|
+
* 喂一口执行轨迹,并把这一批的变化推给**正在看这个会话、而且要「完整」模式**的手机。
|
|
1216
|
+
*
|
|
1217
|
+
* 三个决定,都有理由:
|
|
1218
|
+
* ① **落在 store 的内存缓冲里**,不落盘(体积是回答正文的 90.7 倍,落盘那份是每
|
|
1219
|
+
* 500 毫秒重写整份 JSON;理由见 lib/store.js 的 trajectoryBySession)。
|
|
1220
|
+
* ② **只推当前绑定的那个会话**(和 reply 那条同一个口径):手机遥控的是这一个,
|
|
1221
|
+
* 别的会话在想什么跟它无关;那些会话照旧攒在缓冲里,切过去时用接口拉一份。
|
|
1222
|
+
* ③ **推的是增量帧**,不是整份快照(见 lib/server.js 的 broadcastTrajectory)。
|
|
1223
|
+
*/
|
|
1224
|
+
function feedTrajectory(sessionId, event) {
|
|
1225
|
+
const change = trajectory.feed(sessionId, event)
|
|
1226
|
+
if (!change) return
|
|
1227
|
+
const frame = store.applyTrajectory(sessionId, change)
|
|
1228
|
+
if (!frame) return
|
|
1229
|
+
// 真条目落盘了:活片段的使命结束(它只是「还没落定」的影子,见 feedTrajectoryLive)。
|
|
1230
|
+
if (change.entries.length || change.state) resetLiveThink(sessionId)
|
|
1231
|
+
if (sessionId !== targetSessionId()) return
|
|
1232
|
+
server?.broadcastTrajectory({ sessionId, ...frame })
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* 轨迹的活片段(2026-10-04 用户解禁「流式不做」,仅完整模式消费,别的模式零影响):
|
|
1237
|
+
* 模型正在想的那一句,从 `agent/assistant-stream` 的 reasoning-delta 里顺出来。
|
|
1238
|
+
*
|
|
1239
|
+
* 边界三条:
|
|
1240
|
+
* ① **只吃思考**(reasoning-delta);回答正文(text-delta)一概不用——回答照旧等
|
|
1241
|
+
* 落定才显示,「流式不做」那条裁决只对轨迹的思考片段解禁;
|
|
1242
|
+
* ② **只推当前绑定的那个会话**,子 Agent 的流不过来(和轨迹增量帧同一个口径);
|
|
1243
|
+
* ③ **落盘即清、打断即清**:真条目一到就让位。它是进程本地的,不重放——手机刷新
|
|
1244
|
+
* 后没了就没了,等下一步的片段接着来,不补假的。
|
|
1245
|
+
*
|
|
1246
|
+
* 节流:模型一秒能吐几十个字,250 毫秒一发、尾随必发;第一段立刻发——
|
|
1247
|
+
* 「有没有动静」这件事最要紧的就是第一眼。
|
|
1248
|
+
*/
|
|
1249
|
+
const liveThink = new Map()
|
|
1250
|
+
const LIVE_THINK_MS = 250
|
|
1251
|
+
|
|
1252
|
+
/** 「当前这一段」的尾句:压成一行、限 160 字——给人瞄一眼的,不是给人读的。 */
|
|
1253
|
+
function liveThinkDetail(text) {
|
|
1254
|
+
const paragraphs = String(text).split(/\r?\n[\t ]*\r?\n/)
|
|
1255
|
+
const last = paragraphs[paragraphs.length - 1].replace(/\s+/g, ' ').trim()
|
|
1256
|
+
return last.length > 160 ? `${last.slice(0, 159)}…` : last
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function resetLiveThink(sessionId) {
|
|
1260
|
+
const slot = liveThink.get(sessionId)
|
|
1261
|
+
if (!slot) return
|
|
1262
|
+
if (slot.timer) clearTimeout(slot.timer)
|
|
1263
|
+
liveThink.delete(sessionId)
|
|
1264
|
+
// 只在真露过字之后才喊「清空」——不然每来一次工具调用都白发一帧。
|
|
1265
|
+
if (slot.sent) server?.broadcastTrajectoryLive({ sessionId, detail: '' })
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function feedTrajectoryLive(sessionId, frame) {
|
|
1269
|
+
if (sessionId !== targetSessionId()) return
|
|
1270
|
+
if (!frame || frame.type !== 'chunk') { resetLiveThink(sessionId); return }
|
|
1271
|
+
const chunk = frame.chunk ?? {}
|
|
1272
|
+
if (chunk.type === 'reasoning-delta' && chunk.text) {
|
|
1273
|
+
let slot = liveThink.get(sessionId)
|
|
1274
|
+
if (!slot) {
|
|
1275
|
+
slot = { text: '', sent: false, at: 0, timer: null }
|
|
1276
|
+
liveThink.set(sessionId, slot)
|
|
1277
|
+
}
|
|
1278
|
+
slot.text += chunk.text
|
|
1279
|
+
const detail = liveThinkDetail(slot.text)
|
|
1280
|
+
const flush = () => {
|
|
1281
|
+
slot.timer = null
|
|
1282
|
+
slot.at = Date.now()
|
|
1283
|
+
slot.sent = true
|
|
1284
|
+
server?.broadcastTrajectoryLive({ sessionId, detail })
|
|
1285
|
+
}
|
|
1286
|
+
if (!slot.sent || Date.now() - slot.at >= LIVE_THINK_MS) {
|
|
1287
|
+
if (slot.timer) { clearTimeout(slot.timer); slot.timer = null }
|
|
1288
|
+
flush()
|
|
1289
|
+
} else if (!slot.timer) {
|
|
1290
|
+
slot.timer = setTimeout(flush, LIVE_THINK_MS - (Date.now() - slot.at))
|
|
1291
|
+
if (slot.timer.unref) slot.timer.unref()
|
|
1292
|
+
}
|
|
1293
|
+
return
|
|
1294
|
+
}
|
|
1295
|
+
// 出现工具调用 = 这一步走到工具了,思考片段到此为止;text-delta 是回答正文,一概不用。
|
|
1296
|
+
if (chunk.type === 'tool-call-delta') resetLiveThink(sessionId)
|
|
1297
|
+
}
|
|
1298
|
+
|
|
966
1299
|
ctx.on('session/event', (session, event) => {
|
|
967
1300
|
// 子 agent 的会话不进手机——用户遥控的是自己的主会话,不是它派出去的小弟。
|
|
968
1301
|
if (session.header?.origin === 'subagent') return
|
|
969
1302
|
const sessionId = session.id
|
|
970
1303
|
|
|
1304
|
+
// 执行轨迹:**在所有早退之前**喂一口。下面 `tool/call` 那条分支取完命令原文就 return
|
|
1305
|
+
// 了(审批卡要用),而工具调用正是轨迹的主干——放在这一句之前,它才不会漏掉。
|
|
1306
|
+
feedTrajectory(sessionId, event)
|
|
1307
|
+
|
|
971
1308
|
// 手机正在看的这个会话:顺手让它的完整历史保持新鲜(一分钟最多重读一次)。
|
|
972
1309
|
// 只对「当前绑定的那一个」做——手机上没在看的会话,读了也没人看,白花 CPU。
|
|
973
1310
|
if (sessionId === targetSessionId()) ensureHistory(sessionId)
|
|
@@ -1105,6 +1442,22 @@ export function apply(ctx, config = {}) {
|
|
|
1105
1442
|
// 状态必须是同一套口径,否则刷新一下就看到别人的结果了。
|
|
1106
1443
|
if (sessionId !== targetSessionId()) return
|
|
1107
1444
|
|
|
1445
|
+
if (action.kind === 'notice') {
|
|
1446
|
+
// 子智能体完工的通知。记进聊天记录,**但不震动、不弹通知**——它是「你派出去的
|
|
1447
|
+
// 那件事有结果了」,和「模型答了你一句」不是同一件事。可它确实是这个会话里
|
|
1448
|
+
// 发生的一件事,聊天记录里不能缺(原来就是被 user/message 那道过滤一起挡掉的)。
|
|
1449
|
+
store.pushNotice({
|
|
1450
|
+
text: action.text,
|
|
1451
|
+
summary: action.summary,
|
|
1452
|
+
senderSessionId: action.senderSessionId,
|
|
1453
|
+
sessionId,
|
|
1454
|
+
turn: action.turn,
|
|
1455
|
+
timestamp: event.time,
|
|
1456
|
+
})
|
|
1457
|
+
server?.broadcast('state', store.snapshot())
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1108
1461
|
if (action.kind === 'user') {
|
|
1109
1462
|
// 手机自己发的那条:onInstruction 里已经记过一笔了,这里跳过。
|
|
1110
1463
|
// 不跳的话聊天模式里同一条指令会显示两次(用户实机报过)。
|
|
@@ -1121,7 +1474,7 @@ export function apply(ctx, config = {}) {
|
|
|
1121
1474
|
// 带上 id:那条也可能是**排队**排进去的,手机上要靠它对上号、标出「排队中」。
|
|
1122
1475
|
// 时间戳用事件自己的 `time`:硬盘重放出来的那份也是这个 `time`,两边同钟,
|
|
1123
1476
|
// 合成时才认得出「这条记录里已经有了」,不会接成两条(见 store.pushUser)。
|
|
1124
|
-
store.pushUser({ text: action.text, sessionId, id: action.id, timestamp: event.time })
|
|
1477
|
+
store.pushUser({ text: action.text, sessionId, id: action.id, turn: action.turn, timestamp: event.time })
|
|
1125
1478
|
server?.broadcast('state', store.snapshot())
|
|
1126
1479
|
return
|
|
1127
1480
|
}
|
|
@@ -1129,6 +1482,7 @@ export function apply(ctx, config = {}) {
|
|
|
1129
1482
|
// action.kind === 'reply'
|
|
1130
1483
|
store.pushReply({
|
|
1131
1484
|
text: action.text, sessionId, reason: action.reason, interrupted: action.interrupted,
|
|
1485
|
+
turn: action.turn,
|
|
1132
1486
|
// 同上:和硬盘那份用同一个钟(事件的 `time`),否则同一条回复会显示两遍。
|
|
1133
1487
|
timestamp: event.time,
|
|
1134
1488
|
})
|
|
@@ -1141,13 +1495,28 @@ export function apply(ctx, config = {}) {
|
|
|
1141
1495
|
timestamp: Date.now(),
|
|
1142
1496
|
})
|
|
1143
1497
|
maybeNotify(action.text)
|
|
1498
|
+
// 飞书那条路:这一轮落定的回答,回给在飞书里发指令的那个人(见 onReply)。
|
|
1499
|
+
// `sessionId` 既是**归属**(飞书那边靠它认出发起这一轮的那条消息),也要拿去查会话名;
|
|
1500
|
+
// `userText` 是 events.js 取好的「这一轮用户的原话」,只做兜底比对。
|
|
1501
|
+
onReply(action.text, { userText: action.userText, sessionId })
|
|
1144
1502
|
})
|
|
1145
1503
|
|
|
1146
1504
|
ctx.on('agent/status', ({ agent, status }) => {
|
|
1147
1505
|
const sessionId = agent?.session?.id
|
|
1148
1506
|
if (!sessionId) return
|
|
1149
|
-
if (agent.session.header?.origin === 'subagent')
|
|
1507
|
+
if (agent.session.header?.origin === 'subagent') {
|
|
1508
|
+
const parent = agent.session.header?.parentSession
|
|
1509
|
+
if (parent) {
|
|
1510
|
+
subagentCache.delete(parent)
|
|
1511
|
+
store.setSubagentRunning(parent, sessionId, status === 'running')
|
|
1512
|
+
server?.invalidateSubagents?.(parent)
|
|
1513
|
+
server?.broadcast('state', store.snapshot())
|
|
1514
|
+
}
|
|
1515
|
+
return
|
|
1516
|
+
}
|
|
1150
1517
|
store.setRunning(sessionId, status === 'running')
|
|
1518
|
+
server?.invalidateNavigation?.()
|
|
1519
|
+
server?.broadcast('navigation-refresh', { reason: 'session-status', sessionId })
|
|
1151
1520
|
broadcastStatus()
|
|
1152
1521
|
})
|
|
1153
1522
|
|
|
@@ -1164,15 +1533,20 @@ export function apply(ctx, config = {}) {
|
|
|
1164
1533
|
* 子 Agent 的流不往手机上推。
|
|
1165
1534
|
*/
|
|
1166
1535
|
ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
1167
|
-
// 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
|
|
1168
|
-
// 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
|
|
1169
|
-
if (!STREAMING_ENABLED) return
|
|
1170
|
-
|
|
1171
1536
|
const session = agent?.session
|
|
1172
1537
|
const sessionId = session?.id
|
|
1173
1538
|
if (!sessionId) return
|
|
1174
1539
|
if (session.header?.origin === 'subagent') return
|
|
1175
1540
|
|
|
1541
|
+
// 轨迹的活片段:完整模式要「正在想的片段一点点顺出来」(2026-10-04 用户解禁,
|
|
1542
|
+
// 只解禁这一处)。它跑在下面那道闸门**之前**——回答的流式照旧关着,两者互不相干:
|
|
1543
|
+
// 这里吃的是 reasoning-delta,text-delta 一个字都不碰。
|
|
1544
|
+
feedTrajectoryLive(sessionId, frame)
|
|
1545
|
+
|
|
1546
|
+
// 流式关着:这里直接掉头。手机上只认落定的那条回答,中途一个字都不推。
|
|
1547
|
+
// 为什么关、为什么留着代码,见 STREAMING_ENABLED 的注释。
|
|
1548
|
+
if (!STREAMING_ENABLED) return
|
|
1549
|
+
|
|
1176
1550
|
// 只报一次:这条日志回答的是「事件到底有没有送到插件」。一条都没有,就说明
|
|
1177
1551
|
// 问题在订阅那一层,不用再往下查。
|
|
1178
1552
|
if (!sawStreamEvent) {
|
|
@@ -1242,17 +1616,45 @@ export function apply(ctx, config = {}) {
|
|
|
1242
1616
|
// 会话一建出来就登记,这样手机上的会话选择器不用等到有活动才出现。
|
|
1243
1617
|
ctx.on('agent/created', ({ agent }) => {
|
|
1244
1618
|
const sessionId = agent?.session?.id
|
|
1245
|
-
if (!sessionId
|
|
1619
|
+
if (!sessionId) return
|
|
1620
|
+
if (agent.session.header?.origin === 'subagent') {
|
|
1621
|
+
const parent = agent.session.header?.parentSession
|
|
1622
|
+
if (parent) {
|
|
1623
|
+
subagentCache.delete(parent)
|
|
1624
|
+
store.setSubagentRunning(parent, sessionId, agent.status === 'running')
|
|
1625
|
+
server?.invalidateSubagents?.(parent)
|
|
1626
|
+
server?.broadcast('state', store.snapshot())
|
|
1627
|
+
}
|
|
1628
|
+
return
|
|
1629
|
+
}
|
|
1630
|
+
sessionMeta.upsert({ header: agent.session.header, live: true, persisted: false })
|
|
1246
1631
|
store.touchSession(sessionId, {
|
|
1247
1632
|
running: agent.status === 'running',
|
|
1248
1633
|
})
|
|
1634
|
+
server?.invalidateNavigation?.()
|
|
1635
|
+
server?.broadcast('navigation-refresh', { reason: 'session-created' })
|
|
1249
1636
|
server?.broadcast('state', store.snapshot())
|
|
1250
1637
|
})
|
|
1251
1638
|
|
|
1252
1639
|
ctx.on('agent/disposed', ({ agent }) => {
|
|
1253
1640
|
const sessionId = agent?.session?.id
|
|
1254
1641
|
if (!sessionId) return
|
|
1642
|
+
if (agent.session.header?.origin === 'subagent') {
|
|
1643
|
+
const parent = agent.session.header?.parentSession
|
|
1644
|
+
if (parent) {
|
|
1645
|
+
subagentCache.delete(parent)
|
|
1646
|
+
store.setSubagentRunning(parent, sessionId, false)
|
|
1647
|
+
server?.invalidateSubagents?.(parent)
|
|
1648
|
+
server?.broadcast('state', store.snapshot())
|
|
1649
|
+
}
|
|
1650
|
+
return
|
|
1651
|
+
}
|
|
1652
|
+
sessionMeta.upsert({ header: agent.session.header, live: false, persisted: true })
|
|
1255
1653
|
store.forgetSession(sessionId)
|
|
1654
|
+
// 轨迹那个累计器也清掉(它有自己的一份 per-session 状态)。
|
|
1655
|
+
trajectory.forget(sessionId)
|
|
1656
|
+
server?.invalidateNavigation?.()
|
|
1657
|
+
server?.broadcast('navigation-refresh', { reason: 'session-disposed' })
|
|
1256
1658
|
server?.broadcast('state', store.snapshot())
|
|
1257
1659
|
})
|
|
1258
1660
|
|
|
@@ -1335,6 +1737,8 @@ export function apply(ctx, config = {}) {
|
|
|
1335
1737
|
const events = Array.isArray(snap?.events) ? snap.events : []
|
|
1336
1738
|
const entries = replayHistory(events)
|
|
1337
1739
|
const capped = capEntries(entries)
|
|
1740
|
+
// 轨迹和聊天记录是**同一批事件的两面**,顺手一起重放出来;不这样就得为它再读一遍盘。
|
|
1741
|
+
const traj = capTrajectory(replayTrajectory(events))
|
|
1338
1742
|
return {
|
|
1339
1743
|
entries: capped.entries,
|
|
1340
1744
|
latest: lastReply(capped.entries, sessionId),
|
|
@@ -1344,6 +1748,10 @@ export function apply(ctx, config = {}) {
|
|
|
1344
1748
|
// 于是手机上真看到的条数可能比这里多一两条。数字对不上就是假话,
|
|
1345
1749
|
// 所以这里只声称**读取的上限**,不声称列表长度。
|
|
1346
1750
|
note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
|
|
1751
|
+
// 轨迹在**插件这一层就按自己的上限截好**再进缓存:不截的话,20 个会话的缓存会
|
|
1752
|
+
// 各压着几兆(见 lib/store.js 的 setReplaySource 那段说明)。
|
|
1753
|
+
trajectory: traj.turns,
|
|
1754
|
+
trajectoryDropped: traj.dropped,
|
|
1347
1755
|
}
|
|
1348
1756
|
} catch (err) {
|
|
1349
1757
|
// 老日志格式对不上、文件坏了,都会走到这儿。退回「手里那份」,**不编**。
|
|
@@ -1382,6 +1790,7 @@ export function apply(ctx, config = {}) {
|
|
|
1382
1790
|
const entries = replayHistory(tail.events)
|
|
1383
1791
|
const capped = capEntries(entries)
|
|
1384
1792
|
if (!capped.entries.length) return null
|
|
1793
|
+
const traj = capTrajectory(replayTrajectory(tail.events))
|
|
1385
1794
|
const windowMb = (HISTORY_TAIL_WINDOW_BYTES / 1048576).toFixed(0)
|
|
1386
1795
|
// 「不是全部」有两处会砍:readTailEvents 那层(窗口 / 事件上限)和这里 capEntries 那层
|
|
1387
1796
|
// (聊天记录条数上限)。**两层都要算进去**——小会话的窗口盖得住整份日志,唯一会砍它的
|
|
@@ -1398,6 +1807,10 @@ export function apply(ctx, config = {}) {
|
|
|
1398
1807
|
? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)`
|
|
1399
1808
|
: `这个会话很大,只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条 / ${windowMb}MB 窗口)`)
|
|
1400
1809
|
: null,
|
|
1810
|
+
// 轨迹那一份的「不是全部」由 store 自己说(它知道轨迹的上限),这里只把
|
|
1811
|
+
// 「被砍了多少条」交出去——读取窗口那一层的截断在上面 note 里已经说过一次了。
|
|
1812
|
+
trajectory: traj.turns,
|
|
1813
|
+
trajectoryDropped: traj.dropped,
|
|
1401
1814
|
}
|
|
1402
1815
|
}
|
|
1403
1816
|
|
|
@@ -1446,6 +1859,8 @@ export function apply(ctx, config = {}) {
|
|
|
1446
1859
|
latest: got?.latest ?? null,
|
|
1447
1860
|
truncated: got?.truncated === true,
|
|
1448
1861
|
note: got?.note ?? null,
|
|
1862
|
+
trajectory: got?.trajectory ?? null,
|
|
1863
|
+
trajectoryDropped: got?.trajectoryDropped ?? 0,
|
|
1449
1864
|
})
|
|
1450
1865
|
historyPending.delete(sessionId)
|
|
1451
1866
|
server?.broadcast('state', store.snapshot())
|
|
@@ -1464,12 +1879,310 @@ export function apply(ctx, config = {}) {
|
|
|
1464
1879
|
// 这个会话只有这么点内容(读历史有上限,见 HISTORY_MAX_* 那组常量)。
|
|
1465
1880
|
truncated: hit.truncated === true,
|
|
1466
1881
|
note: hit.note ?? null,
|
|
1882
|
+
// 同一份记录里重放出来的执行轨迹(「完整」模式要的过程区)。
|
|
1883
|
+
// store 会把它和内存里那份合成一张表,见 store.trajectoryOf。
|
|
1884
|
+
trajectory: hit.trajectory ?? null,
|
|
1885
|
+
trajectoryDropped: hit.trajectoryDropped ?? 0,
|
|
1467
1886
|
}
|
|
1468
1887
|
}
|
|
1469
1888
|
if (historyPending.has(sessionId)) return { entries: null, latest: null, pending: true }
|
|
1470
1889
|
return null
|
|
1471
1890
|
})
|
|
1472
1891
|
|
|
1892
|
+
// ------------------------------------------------------------------
|
|
1893
|
+
// 子智能体(2026-10-03 用户要求:把电脑端「子智能体」搬到手机)
|
|
1894
|
+
//
|
|
1895
|
+
// 和会话树同一套拿法:走 ctx.inject,服务不在就退回「没这个能力」。**不写进
|
|
1896
|
+
// `inject` 数组**——写进去,headless 组合下这个服务永远不来,插件就整个不加载了
|
|
1897
|
+
// (这个坑上面刚踩过,见 inject 那行的注释)。
|
|
1898
|
+
//
|
|
1899
|
+
// 三条路都**在进程内直接调 DSH 的服务**,不经过任何 HTTP / WebSocket:
|
|
1900
|
+
// `subagents` 子代理编排服务(列表、停止、继续)
|
|
1901
|
+
// `sessionProjections` 投影注册表(耗时、用量)
|
|
1902
|
+
// `sessions` 会话存储(不读日志就能拿到 Session 对象)
|
|
1903
|
+
//
|
|
1904
|
+
// 插件和它们同在一个进程里,这是它比桌面前端占便宜的地方:桌面前端只能隔着
|
|
1905
|
+
// 一条 WebSocket 问,而那条线上只开了 `prompt` / `interruptByParent` 两个口子;
|
|
1906
|
+
// 列表和运行记录它反而拿不到,得靠投影另推一份。
|
|
1907
|
+
// ------------------------------------------------------------------
|
|
1908
|
+
const subagentServices = { runtime: null, projections: null, sessions: null }
|
|
1909
|
+
ctx.inject(['subagents'], (subCtx) => {
|
|
1910
|
+
subagentServices.runtime = subCtx.subagents ?? null
|
|
1911
|
+
// 和会话树那句同一个道理:配置里有不等于运行时真的提供了,这一句是唯一的事实来源。
|
|
1912
|
+
console.log('[dsh-mini-remote] 子智能体服务已就绪:' + Boolean(subagentServices.runtime))
|
|
1913
|
+
})
|
|
1914
|
+
ctx.inject(['sessionProjections'], (projCtx) => {
|
|
1915
|
+
subagentServices.projections = projCtx.sessionProjections ?? null
|
|
1916
|
+
})
|
|
1917
|
+
ctx.inject(['sessions'], (sessCtx) => {
|
|
1918
|
+
subagentServices.sessions = sessCtx.sessions ?? null
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* DSH 那几个失败代号 → 一句人话。说不上来的照原样带出去,**不吞也不编**。
|
|
1923
|
+
*
|
|
1924
|
+
* 代号取自 dsh-subagent 的 `SubagentError.code`。头三句是手机上真点得到的三种:
|
|
1925
|
+
* 父会话不在线、同时跑的数量满了、这道子智能体接不了话。
|
|
1926
|
+
*/
|
|
1927
|
+
const SUBAGENT_FAILURES = {
|
|
1928
|
+
PARENT_UNAVAILABLE: '它在的那个会话不在线了,这条发不过去。',
|
|
1929
|
+
ACTIVATION_LIMIT_REACHED: '同时能跑的子智能体已经到上限了,等一个跑完再试。',
|
|
1930
|
+
NOT_RESUMABLE: '这道子智能体没有对话可以接,只能看它跑过什么。',
|
|
1931
|
+
UNAUTHORIZED: '这道子智能体不在当前会话名下。',
|
|
1932
|
+
DRAINING: '它正在收尾,这会儿不接受新消息。',
|
|
1933
|
+
ACTIVATION_CLOSING: '它正在停下,等停稳了再试。',
|
|
1934
|
+
CONTINUATION_UNAVAILABLE: '这台电脑上的 DSH 没开「继续子智能体对话」这条路。',
|
|
1935
|
+
PERSISTENCE_UNAVAILABLE: '这台电脑上的 DSH 没开会话记录,接不了话。',
|
|
1936
|
+
CANCELLED: '这条请求已经取消了。',
|
|
1937
|
+
}
|
|
1938
|
+
function subagentFailure(err, what) {
|
|
1939
|
+
const code = err?.code
|
|
1940
|
+
if (typeof code === 'string' && SUBAGENT_FAILURES[code]) return SUBAGENT_FAILURES[code]
|
|
1941
|
+
return `${what}:${err?.message ?? err}`
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* 每一行要的那两个数:耗时、用量。
|
|
1946
|
+
*
|
|
1947
|
+
* 都从 DSH 自己的投影里读(`subagentTiming` / `tokenUsage`),**不自己拿事件累加**——
|
|
1948
|
+
* 自己算出来的数和电脑上那一栏对不上,用户一眼就看得出来。读不到就给 null,
|
|
1949
|
+
* 页面上显示「—」:给 0 会被读成「真的一次都没跑过」,那是另一句话。
|
|
1950
|
+
*
|
|
1951
|
+
* 会话对象从 `sessions.get` 拿:内存里现成的,**不读日志**。老早跑完、进程里
|
|
1952
|
+
* 已经不在的那些拿不到——那就如实空着,不为数字好看去读它整份日志。
|
|
1953
|
+
*/
|
|
1954
|
+
function subagentMetrics(childId) {
|
|
1955
|
+
const session = subagentServices.sessions?.get?.(childId)
|
|
1956
|
+
const projections = subagentServices.projections
|
|
1957
|
+
if (!session || typeof projections?.snapshot !== 'function') {
|
|
1958
|
+
return { tokens: null, durationMs: null, lastTurnCompleted: null }
|
|
1959
|
+
}
|
|
1960
|
+
let values
|
|
1961
|
+
try {
|
|
1962
|
+
values = projections.snapshot(session, ['subagentTiming', 'tokenUsage'])?.values
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
return { tokens: null, durationMs: null, lastTurnCompleted: null }
|
|
1965
|
+
}
|
|
1966
|
+
let durationMs = null
|
|
1967
|
+
// 「最近跑完的那一轮是正常收尾的」——页面上拿它区分绿点(真的干完了)和灰点(
|
|
1968
|
+
// 没在跑,但不知道最后是怎么结束的)。读不到就给 null,别猜成「成功」。
|
|
1969
|
+
let lastTurnCompleted = null
|
|
1970
|
+
const timing = values?.subagentTiming
|
|
1971
|
+
if (timing) {
|
|
1972
|
+
// 跑完的轮次累加在 settledMs;还开着的那一轮按投影记的切面算,**不用 Date.now()**
|
|
1973
|
+
// ——那会读出一个投影没盖到的时间,和电脑上对不上。
|
|
1974
|
+
const settled = Number(timing.settledMs) || 0
|
|
1975
|
+
const active = timing.active
|
|
1976
|
+
? Math.max(0, (Number(timing.active.through) || 0) - (Number(timing.active.since) || 0))
|
|
1977
|
+
: 0
|
|
1978
|
+
durationMs = settled + active
|
|
1979
|
+
if (typeof timing.lastTurnCompleted === 'boolean') lastTurnCompleted = timing.lastTurnCompleted
|
|
1980
|
+
}
|
|
1981
|
+
let tokens = null
|
|
1982
|
+
const usage = values?.tokenUsage
|
|
1983
|
+
if (usage) {
|
|
1984
|
+
// 四个桶互不重叠(dsh-token-meter 的注释:思考 token 已算在 output 里,不再加一次)。
|
|
1985
|
+
tokens = (Number(usage.uncachedInputTokens) || 0) + (Number(usage.outputTokens) || 0)
|
|
1986
|
+
+ (Number(usage.cacheReadTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
|
|
1987
|
+
}
|
|
1988
|
+
return { tokens, durationMs, lastTurnCompleted }
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* 当前绑定会话派出的子智能体清单(含后代,按目录顺序)。
|
|
1993
|
+
*
|
|
1994
|
+
* `listDescendants` 读的是父会话自己的目录**事实**,不加载也不唤醒任何子代理——
|
|
1995
|
+
* 所以列一下很便宜,也不会把睡着的子代理弄醒。
|
|
1996
|
+
*/
|
|
1997
|
+
const subagentMetricInflight = new Map()
|
|
1998
|
+
|
|
1999
|
+
async function fillSubagentMetrics(parentSessionId, rows) {
|
|
2000
|
+
if (subagentMetricInflight.has(parentSessionId)) return
|
|
2001
|
+
const task = (async () => {
|
|
2002
|
+
for (const row of rows) {
|
|
2003
|
+
if (!row || row.diagnostic) continue
|
|
2004
|
+
const metrics = subagentMetrics(row.id)
|
|
2005
|
+
server?.broadcast('subagent-metrics', {
|
|
2006
|
+
parentSessionId,
|
|
2007
|
+
childId: row.id,
|
|
2008
|
+
...metrics,
|
|
2009
|
+
})
|
|
2010
|
+
}
|
|
2011
|
+
})().finally(() => subagentMetricInflight.delete(parentSessionId))
|
|
2012
|
+
subagentMetricInflight.set(parentSessionId, task)
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
async function readSubagentRows(parentSessionId) {
|
|
2016
|
+
const runtime = subagentServices.runtime
|
|
2017
|
+
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
2018
|
+
if (typeof runtime?.listDescendants !== 'function') {
|
|
2019
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }
|
|
2020
|
+
}
|
|
2021
|
+
let rows
|
|
2022
|
+
try {
|
|
2023
|
+
rows = await runtime.listDescendants(parentSessionId)
|
|
2024
|
+
} catch (err) {
|
|
2025
|
+
return { ok: false, error: subagentFailure(err, '读不到子智能体') }
|
|
2026
|
+
}
|
|
2027
|
+
const out = []
|
|
2028
|
+
for (const row of rows ?? []) {
|
|
2029
|
+
if (!row) continue
|
|
2030
|
+
// 有一支目录读不动时,DSH 拿一行「诊断」顶上来(corrupt / unsupported / unavailable)。
|
|
2031
|
+
// 如实转达,别当空的吞掉——吞了的表现是「明明有子智能体,却一个都不显示」。
|
|
2032
|
+
if (row.kind === 'diagnostic') {
|
|
2033
|
+
out.push({ id: String(row.id), diagnostic: String(row.reason ?? 'unavailable') })
|
|
2034
|
+
continue
|
|
2035
|
+
}
|
|
2036
|
+
const metrics = { tokens: null, durationMs: null, lastTurnCompleted: null }
|
|
2037
|
+
out.push({
|
|
2038
|
+
id: String(row.id),
|
|
2039
|
+
parentId: String(row.parentId ?? ''),
|
|
2040
|
+
depth: Number(row.depth) || 1,
|
|
2041
|
+
// 只有「可续接」谈得上继续聊;两个值之外的都按一次性处理——未知模式不许接话。
|
|
2042
|
+
mode: row.mode === 'continuable' ? 'continuable' : 'one-shot',
|
|
2043
|
+
label: typeof row.label === 'string' ? row.label : '',
|
|
2044
|
+
running: row.activity === 'running',
|
|
2045
|
+
tokens: metrics.tokens,
|
|
2046
|
+
durationMs: metrics.durationMs,
|
|
2047
|
+
lastTurnCompleted: metrics.lastTurnCompleted,
|
|
2048
|
+
})
|
|
2049
|
+
}
|
|
2050
|
+
return { ok: true, subagents: out, boundSessionId: parentSessionId }
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
async function listSubagents(parentSessionId, { includeMetrics = true, scheduleMetrics = false } = {}) {
|
|
2054
|
+
if (!parentSessionId) return readSubagentRows(parentSessionId)
|
|
2055
|
+
const cached = !includeMetrics ? subagentCache.get(parentSessionId) : null
|
|
2056
|
+
const rows = cached
|
|
2057
|
+
? { ok: true, subagents: cached, boundSessionId: parentSessionId }
|
|
2058
|
+
: await readSubagentRows(parentSessionId)
|
|
2059
|
+
if (!rows.ok) return rows
|
|
2060
|
+
if (includeMetrics) {
|
|
2061
|
+
rows.subagents = rows.subagents.map((row) => row.diagnostic
|
|
2062
|
+
? row
|
|
2063
|
+
: { ...row, ...subagentMetrics(row.id) })
|
|
2064
|
+
}
|
|
2065
|
+
if (!cached) subagentCache.set(parentSessionId, rows.subagents)
|
|
2066
|
+
if (scheduleMetrics && !includeMetrics) {
|
|
2067
|
+
setTimeout(() => { fillSubagentMetrics(parentSessionId, rows.subagents) }, 0).unref?.()
|
|
2068
|
+
}
|
|
2069
|
+
return rows
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* 某个子智能体**自己的运行过程**。
|
|
2074
|
+
*
|
|
2075
|
+
* 数据来源和主会话**完全同一条**:`sessionQuery.readSession(childId)` 拿原始事件,
|
|
2076
|
+
* `replayHistory` 重放成聊天记录(和实时那条路共用同一个提取器)。电脑端「点进去
|
|
2077
|
+
* 看它跑了什么」看的也是它自己的会话记录,两边因此长得一样——各写一套迟早会走散。
|
|
2078
|
+
*
|
|
2079
|
+
* 地址是手机传上来的,**不能拿它当权限**:先确认它真的挂在当前绑定会话名下。
|
|
2080
|
+
*/
|
|
2081
|
+
async function subagentTranscript(childId, parentSessionId) {
|
|
2082
|
+
if (!childId) return { ok: false, error: '没说要读哪一道子智能体。' }
|
|
2083
|
+
const listed = await listSubagents(parentSessionId, { includeMetrics: false, scheduleMetrics: false })
|
|
2084
|
+
if (!listed.ok) return listed
|
|
2085
|
+
const row = listed.subagents.find((s) => s.id === childId)
|
|
2086
|
+
if (!row) return { ok: false, error: '这道子智能体不在当前会话名下。' }
|
|
2087
|
+
const q = treeServices.query
|
|
2088
|
+
if (typeof q?.readSession !== 'function') {
|
|
2089
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供读取会话记录的能力。' }
|
|
2090
|
+
}
|
|
2091
|
+
let snap
|
|
2092
|
+
try {
|
|
2093
|
+
snap = await q.readSession(childId)
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
return { ok: false, error: subagentFailure(err, '读不到它的记录') }
|
|
2096
|
+
}
|
|
2097
|
+
const events = Array.isArray(snap?.events) ? snap.events : []
|
|
2098
|
+
if (!events.length) {
|
|
2099
|
+
// 一次性子智能体跑完、记录已被回收时就是这里。**如实说没有记录**,不拿
|
|
2100
|
+
// 「读不到」冒充「它没干过活」——这两件事对用户含义不同。
|
|
2101
|
+
return {
|
|
2102
|
+
ok: true, mode: row.mode, running: row.running,
|
|
2103
|
+
entries: [], reclaimed: true, truncated: false, note: null,
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
const capped = capEntries(replayHistory(events))
|
|
2107
|
+
return {
|
|
2108
|
+
ok: true,
|
|
2109
|
+
mode: row.mode,
|
|
2110
|
+
running: row.running,
|
|
2111
|
+
entries: capped.entries,
|
|
2112
|
+
reclaimed: false,
|
|
2113
|
+
truncated: capped.dropped > 0,
|
|
2114
|
+
note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/**
|
|
2119
|
+
* 停:只停**当前这一轮**,不销毁这道子智能体(排队里的消息、它的后代都留着)。
|
|
2120
|
+
*
|
|
2121
|
+
* 权限走「人」这条:`interruptByParent(childId, parentSessionId, mode)` 拿这三个当凭据,
|
|
2122
|
+
* 它**不做父 Agent 在线检查**——这正是「父会话不在线,也能停下正在跑的它」的原因。
|
|
2123
|
+
* 一次性的没有可停的轮次,这里就挡掉,不往 DSH 递。
|
|
2124
|
+
*/
|
|
2125
|
+
async function stopSubagent(childId, parentSessionId, mode) {
|
|
2126
|
+
const runtime = subagentServices.runtime
|
|
2127
|
+
if (!childId) return { ok: false, error: '没说要停哪一道子智能体。' }
|
|
2128
|
+
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
2129
|
+
if (mode !== 'continuable') {
|
|
2130
|
+
return { ok: false, error: '这是一次性子智能体,跑完就结束,没有可停的轮次。' }
|
|
2131
|
+
}
|
|
2132
|
+
if (typeof runtime?.interruptByParent !== 'function') {
|
|
2133
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供停止子智能体的能力。' }
|
|
2134
|
+
}
|
|
2135
|
+
try {
|
|
2136
|
+
await runtime.interruptByParent(childId, parentSessionId, 'continuable')
|
|
2137
|
+
} catch (err) {
|
|
2138
|
+
return { ok: false, error: subagentFailure(err, '停不下来') }
|
|
2139
|
+
}
|
|
2140
|
+
// 和主会话那条同一个口径:递进去不等于停稳,界面上得等它的状态翻过来。
|
|
2141
|
+
return { ok: true }
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/**
|
|
2145
|
+
* 继续:往这道子智能体里递一句**人**的话。
|
|
2146
|
+
*
|
|
2147
|
+
* `prompt` 把这条记成 `source.kind === 'user'`(带 rpcId),也就是「你在它旁边说了
|
|
2148
|
+
* 一句」,而不是「别的 agent 转达的」。delivery 选 queue(排到它当前这轮之后)——
|
|
2149
|
+
* 和手机自己对主会话的做法一致:主会话正在跑时,手机上发的指令也是排队,不打断。
|
|
2150
|
+
*
|
|
2151
|
+
* requestId 是这条消息的身份,**每次都得新铸一个**:复用同一个会被判成重复投递。
|
|
2152
|
+
*/
|
|
2153
|
+
async function askSubagent(childId, parentSessionId, mode, text) {
|
|
2154
|
+
const runtime = subagentServices.runtime
|
|
2155
|
+
if (!childId) return { ok: false, error: '没说要跟哪一道子智能体说话。' }
|
|
2156
|
+
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
2157
|
+
if (!text) return { ok: false, error: '话是空的。' }
|
|
2158
|
+
if (mode !== 'continuable') {
|
|
2159
|
+
return { ok: false, error: '这是一次性子智能体,跑完就结束了,接不了话。' }
|
|
2160
|
+
}
|
|
2161
|
+
if (typeof runtime?.prompt !== 'function') {
|
|
2162
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供继续子智能体对话的能力。' }
|
|
2163
|
+
}
|
|
2164
|
+
let requestId
|
|
2165
|
+
try {
|
|
2166
|
+
requestId = randomUUID()
|
|
2167
|
+
} catch (err) {
|
|
2168
|
+
// 兜底:uuid 拿不到也不该让这句话发不出去。
|
|
2169
|
+
requestId = 'mini-' + Date.now() + '-' + randomBytes(8).toString('hex')
|
|
2170
|
+
}
|
|
2171
|
+
try {
|
|
2172
|
+
const receipt = await runtime.prompt({
|
|
2173
|
+
requestId,
|
|
2174
|
+
parentSessionId,
|
|
2175
|
+
childSessionId: childId,
|
|
2176
|
+
mode: 'continuable',
|
|
2177
|
+
delivery: 'queue',
|
|
2178
|
+
content: [{ type: 'text', text }],
|
|
2179
|
+
})
|
|
2180
|
+
return { ok: true, messageId: receipt?.messageId ? String(receipt.messageId) : null }
|
|
2181
|
+
} catch (err) {
|
|
2182
|
+
return { ok: false, error: subagentFailure(err, '这句话没送出去') }
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
|
|
1473
2186
|
// ------------------------------------------------------------------
|
|
1474
2187
|
// 手机上答题:把电脑这边的提问推到手机,等它答,再把答案交还给电脑。
|
|
1475
2188
|
//
|
|
@@ -1507,6 +2220,23 @@ export function apply(ctx, config = {}) {
|
|
|
1507
2220
|
// 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
|
|
1508
2221
|
// **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
|
|
1509
2222
|
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
2223
|
+
/**
|
|
2224
|
+
* **飞书那条路此刻能不能接这道题。**
|
|
2225
|
+
*
|
|
2226
|
+
* 三个条件,缺一不接:
|
|
2227
|
+
* ① 通路在、而且**有一轮从飞书发起的指令还没落定**(见 lib/lark.js 的 serving)——
|
|
2228
|
+
* 手机上发起的轮次不该因此抢掉电脑上的弹窗;
|
|
2229
|
+
* ② 这道题在飞书里写得成一段纯文本(`feishuQuestionText` 写不成时给空串:
|
|
2230
|
+
* 两道以上的题里混着一道没有选项的题,数字协议表达不了);
|
|
2231
|
+
* ③ 那段话不超过一条消息的上限。
|
|
2232
|
+
* 接不了就当飞书那边不存在,电脑和手机照旧——**一个字都不变**。
|
|
2233
|
+
*/
|
|
2234
|
+
const askText = feishuQuestionText(questions)
|
|
2235
|
+
const feishuReady = Boolean(feishu)
|
|
2236
|
+
&& askText !== ''
|
|
2237
|
+
&& askText.length <= FEISHU_TEXT_LIMIT
|
|
2238
|
+
&& typeof feishu.serving === 'function'
|
|
2239
|
+
&& feishu.serving()
|
|
1510
2240
|
/**
|
|
1511
2241
|
* **让路的那三种情况,每一种都留一行。**
|
|
1512
2242
|
*
|
|
@@ -1520,8 +2250,8 @@ export function apply(ctx, config = {}) {
|
|
|
1520
2250
|
console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
|
|
1521
2251
|
return next()
|
|
1522
2252
|
}
|
|
1523
|
-
if (!phoneReady) {
|
|
1524
|
-
console.log('[dsh-mini-remote]
|
|
2253
|
+
if (!phoneReady && !feishuReady) {
|
|
2254
|
+
console.log('[dsh-mini-remote] 提问让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
|
|
1525
2255
|
return next()
|
|
1526
2256
|
}
|
|
1527
2257
|
if (typeof miniControl.askPhone !== 'function') {
|
|
@@ -1529,11 +2259,28 @@ export function apply(ctx, config = {}) {
|
|
|
1529
2259
|
return next()
|
|
1530
2260
|
}
|
|
1531
2261
|
// ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
|
|
1532
|
-
console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`
|
|
1533
|
-
|
|
2262
|
+
console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`
|
|
2263
|
+
+ (feishuReady ? '(飞书那边一起问)' : ''))
|
|
2264
|
+
/**
|
|
2265
|
+
* `askId` 是这道题在**手机页那份清单**里的编号,由 `onPending` 当场交出来。
|
|
2266
|
+
* 它只用来问一件事:「飞书那边还挂着这道题吗」(见 keepAlive)——
|
|
2267
|
+
* 题目的胜负、谁先答谁生效,全在 lib/server.js 那份清单里,这里不另判。
|
|
2268
|
+
*/
|
|
2269
|
+
let askId = null
|
|
2270
|
+
return miniControl.askPhone(questions, agentId, {
|
|
2271
|
+
onPending: (id) => {
|
|
2272
|
+
askId = id
|
|
2273
|
+
// 飞书那边写成一段普通文字问出来。它没接(写不成、或发不出去就当场撤回)时,
|
|
2274
|
+
// keepAlive 下一次为 false,题照旧还给电脑。
|
|
2275
|
+
feishu?.openAsk?.({ id, kind: 'question', payload: questions })
|
|
2276
|
+
},
|
|
2277
|
+
// 「手机全断了要不要收摊」的第二个判据:飞书那边还挂着这道题就别收。
|
|
2278
|
+
keepAlive: () => Boolean(feishu && askId && feishu.isWaiting?.(askId)),
|
|
2279
|
+
}).then((answer) => {
|
|
1534
2280
|
// 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
|
|
1535
2281
|
// 这一句日志是「手机上答了、电脑上没动静」那类现象唯一能落下来的痕迹:
|
|
1536
2282
|
// 有它才知道手机那份答案没接上(断线、或者答晚了一步),而不是插件没在跑。
|
|
2283
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1537
2284
|
if (!answer) {
|
|
1538
2285
|
console.log('[dsh-mini-remote] 手机上没答成(连接断了、或者答晚了),这题还给电脑')
|
|
1539
2286
|
return next()
|
|
@@ -1542,6 +2289,7 @@ export function apply(ctx, config = {}) {
|
|
|
1542
2289
|
return answer
|
|
1543
2290
|
}).catch((err) => {
|
|
1544
2291
|
// 我这边出任何问题都不能把提问吞掉,一律让回电脑。
|
|
2292
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1545
2293
|
console.log('[dsh-mini-remote] 接管提问出错,让回电脑:' + String(err))
|
|
1546
2294
|
return next()
|
|
1547
2295
|
})
|
|
@@ -1562,20 +2310,40 @@ export function apply(ctx, config = {}) {
|
|
|
1562
2310
|
// 这次到底要跑什么:审批请求只带调用编号,参数在会话事件里记着的那份。
|
|
1563
2311
|
const command = request?.callId ? (toolCalls.get(String(request.callId)) ?? '') : ''
|
|
1564
2312
|
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
2313
|
+
// 飞书那条路此刻能不能接:判据和上面那条提问链**一模一样**(可能接、写得成、不超长)。
|
|
2314
|
+
const view = {
|
|
2315
|
+
toolName: typeof toolName === 'string' && toolName ? toolName : '某个工具',
|
|
2316
|
+
reason: typeof request?.reason === 'string' ? request.reason : '',
|
|
2317
|
+
command: typeof command === 'string' ? command : '',
|
|
2318
|
+
}
|
|
2319
|
+
const approvalAskText = approvalText(view)
|
|
2320
|
+
const feishuReady = Boolean(feishu)
|
|
2321
|
+
&& approvalAskText.length <= FEISHU_TEXT_LIMIT
|
|
2322
|
+
&& typeof feishu.serving === 'function'
|
|
2323
|
+
&& feishu.serving()
|
|
1565
2324
|
if (!agentId || agentId !== bound) {
|
|
1566
2325
|
console.log(`[dsh-mini-remote] 审批让路:批的=${agentId ?? '无'},手机绑的=${bound ?? '无'}`)
|
|
1567
2326
|
return next()
|
|
1568
2327
|
}
|
|
1569
|
-
if (!phoneReady) {
|
|
1570
|
-
console.log('[dsh-mini-remote]
|
|
2328
|
+
if (!phoneReady && !feishuReady) {
|
|
2329
|
+
console.log('[dsh-mini-remote] 审批让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
|
|
1571
2330
|
return next()
|
|
1572
2331
|
}
|
|
1573
2332
|
if (typeof miniControl.askApproval !== 'function') {
|
|
1574
2333
|
console.log('[dsh-mini-remote] 审批让路:拿不到 askApproval(手机服务还没起来),交给电脑')
|
|
1575
2334
|
return next()
|
|
1576
2335
|
}
|
|
1577
|
-
console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`
|
|
1578
|
-
|
|
2336
|
+
console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`
|
|
2337
|
+
+ (feishuReady ? '(飞书那边一起问)' : ''))
|
|
2338
|
+
let askId = null
|
|
2339
|
+
return miniControl.askApproval(request, agentId, command, {
|
|
2340
|
+
onPending: (id) => {
|
|
2341
|
+
askId = id
|
|
2342
|
+
feishu?.openAsk?.({ id, kind: 'approval', payload: view })
|
|
2343
|
+
},
|
|
2344
|
+
keepAlive: () => Boolean(feishu && askId && feishu.isWaiting?.(askId)),
|
|
2345
|
+
}).then((outcome) => {
|
|
2346
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1579
2347
|
if (!outcome) {
|
|
1580
2348
|
console.log('[dsh-mini-remote] 手机上没拍成(连接断了、或者拍晚了),这条还给电脑')
|
|
1581
2349
|
return next()
|
|
@@ -1583,6 +2351,7 @@ export function apply(ctx, config = {}) {
|
|
|
1583
2351
|
console.log('[dsh-mini-remote] 手机上拍板了:' + outcome)
|
|
1584
2352
|
return outcome
|
|
1585
2353
|
}).catch((err) => {
|
|
2354
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1586
2355
|
console.log('[dsh-mini-remote] 接管审批出错,让回电脑:' + String(err))
|
|
1587
2356
|
return next()
|
|
1588
2357
|
})
|
|
@@ -1608,6 +2377,84 @@ export function apply(ctx, config = {}) {
|
|
|
1608
2377
|
+ Boolean(canCreate.controller) + ' create=' + typeof canCreate.controller?.create)
|
|
1609
2378
|
})
|
|
1610
2379
|
|
|
2380
|
+
// Agent 模式注册表(2026-10-04 用户要求:新建会话时手机上也要能选模式,PC 一直
|
|
2381
|
+
// 可以)。**单独 inject**(理由同上:缺一个服务不该连别的都没了)。没有它的时候
|
|
2382
|
+
// 手机直接建 DSH 的默认模式,如实说「没有可选的」——不摆一个没得选的选择题。
|
|
2383
|
+
const canPresets = { registry: null }
|
|
2384
|
+
ctx.inject(['agentPresets'], (presetsCtx) => {
|
|
2385
|
+
canPresets.registry = presetsCtx.agentPresets
|
|
2386
|
+
console.log('[dsh-mini-remote] Agent 模式清单已就绪:agentPresets=' + Boolean(canPresets.registry))
|
|
2387
|
+
})
|
|
2388
|
+
|
|
2389
|
+
// 插件管理服务(手机端「插件」页面用)。
|
|
2390
|
+
const pluginServices = { manager: null }
|
|
2391
|
+
// DSH 版本号问到一次就记着(版本不会中途变),省得每 30 秒问一趟。
|
|
2392
|
+
let cachedDshVersion = ''
|
|
2393
|
+
ctx.inject(['pluginManager'], (pCtx) => {
|
|
2394
|
+
pluginServices.manager = pCtx.pluginManager
|
|
2395
|
+
console.log('[dsh-mini-remote] 插件管理服务已就绪:pluginManager=' + Boolean(pluginServices.manager))
|
|
2396
|
+
})
|
|
2397
|
+
|
|
2398
|
+
/**
|
|
2399
|
+
* 「内置插件」要用的两样东西(2026-10-04 用户指出:内置插件只显示 6 个是不对的)。
|
|
2400
|
+
*
|
|
2401
|
+
* DSH 的理念是「一切皆插件、无特权核心」——一整套系统是几百个插件模块拼起来的,
|
|
2402
|
+
* 所以「内置插件」的真相不在 npm 包清单里(那是「插件市场」的粒度),
|
|
2403
|
+
* 而在 **Cordis Loader 的实时条目表**里。PC 的「设置 → 内置插件」页读的正是它,
|
|
2404
|
+
* 分成两块:全局插件(系统与所有会话共用)+ 会话插件(按 Agent 预设组合而成)。
|
|
2405
|
+
*
|
|
2406
|
+
* 两样都走 ctx.inject(软注入):拿不到就只少这一块,不整个倒下。
|
|
2407
|
+
* `pluginPackages` 只提供显示用的本地化标题/说明,没有它照样能列出来(退成模块名)。
|
|
2408
|
+
*/
|
|
2409
|
+
const invServices = { loader: null, packages: null, settings: null }
|
|
2410
|
+
ctx.inject(['loader'], (lCtx) => {
|
|
2411
|
+
invServices.loader = lCtx.loader
|
|
2412
|
+
console.log('[dsh-mini-remote] Cordis Loader 已就绪(内置插件清单要用):loader='
|
|
2413
|
+
+ Boolean(invServices.loader))
|
|
2414
|
+
})
|
|
2415
|
+
ctx.inject(['pluginPackages'], (pkCtx) => {
|
|
2416
|
+
invServices.packages = pkCtx.pluginPackages
|
|
2417
|
+
console.log('[dsh-mini-remote] 插件包元数据已就绪:pluginPackages='
|
|
2418
|
+
+ Boolean(invServices.packages))
|
|
2419
|
+
})
|
|
2420
|
+
// 设置文档:问「官方插件里注册了设置卡片的那几个,这台部署是不是提供了」。
|
|
2421
|
+
ctx.inject(['settings'], (sCtx) => {
|
|
2422
|
+
invServices.settings = sCtx.settings
|
|
2423
|
+
console.log('[dsh-mini-remote] 设置文档已就绪(官方插件卡片要用):settings='
|
|
2424
|
+
+ Boolean(invServices.settings))
|
|
2425
|
+
})
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* 官方插件里「注册了一张设置卡片」的那几个。
|
|
2429
|
+
*
|
|
2430
|
+
* **为什么只能列在这里**:这几张卡片是**浏览器端**的插件在运行时注册进 Plugins 页的
|
|
2431
|
+
* `plugins.item` 插槽的(dsh-client-ui-plugin-manager 读的是 `ctx.slots`),
|
|
2432
|
+
* 宿主进程看不见浏览器里的插槽注册表——没有哪条宿主接口能问出来。
|
|
2433
|
+
* 所以包 identity、卡片 id、开关注册所用的命名空间、中文文案,
|
|
2434
|
+
* 只能照 DSH 0.2.0-rc.2 的客户端源码抄一份(4 个包各注册一张卡):
|
|
2435
|
+
*
|
|
2436
|
+
* dsh-client-ui-settings-shell id=shell 等 bash-sandbox / pwsh-sandbox
|
|
2437
|
+
* dsh-client-ui-settings-agent-loop id=agent-loop 等 agent-loop
|
|
2438
|
+
* dsh-client-ui-settings-subagent id=subagent 等 subagent / subagent-model-selection-settings
|
|
2439
|
+
* dsh-client-ui-settings-web-search id=web-search 等 web-search-deepseek
|
|
2440
|
+
*
|
|
2441
|
+
* 文案逐字取自那几个包 `lib/client.js` 里自己的 zh 词典(不自己编)。
|
|
2442
|
+
*
|
|
2443
|
+
* **出现条件照 DSH 同一条规矩**:宿主的设置文档里 served 了它那几个命名空间才出现
|
|
2444
|
+
* (DSH 那边是 `configForms.whileServed(namespaces, …)`)——所以「这台部署没有终端设置」
|
|
2445
|
+
* 时,手机上也不会凭空多出一张终端卡。DSH 以后再加官方卡片,这张表要跟着补。
|
|
2446
|
+
*/
|
|
2447
|
+
const OFFICIAL_CARDS = [
|
|
2448
|
+
{ id: 'shell', namespaces: ['bash-sandbox', 'pwsh-sandbox'], title: '终端',
|
|
2449
|
+
description: '限制每条命令最多能跑多久、最多输出多少内容。' },
|
|
2450
|
+
{ id: 'agent-loop', namespaces: ['agent-loop'], title: 'Agent 循环',
|
|
2451
|
+
description: '控制 Agent 派发工具调用的方式。' },
|
|
2452
|
+
{ id: 'subagent', namespaces: ['subagent', 'subagent-model-selection-settings'], title: '子智能体',
|
|
2453
|
+
description: '设置子智能体的递归层级、数量和模型。' },
|
|
2454
|
+
{ id: 'web-search', namespaces: ['web-search-deepseek'], title: '网页搜索',
|
|
2455
|
+
description: '设置 DeepSeek 的搜索提供方。' },
|
|
2456
|
+
]
|
|
2457
|
+
|
|
1611
2458
|
// ------------------------------------------------------------------
|
|
1612
2459
|
// 手机上传文件要用的附件服务
|
|
1613
2460
|
// ------------------------------------------------------------------
|
|
@@ -1779,10 +2626,55 @@ export function apply(ctx, config = {}) {
|
|
|
1779
2626
|
}
|
|
1780
2627
|
}
|
|
1781
2628
|
|
|
2629
|
+
/**
|
|
2630
|
+
* 取一份「本地化文案」里的中文。
|
|
2631
|
+
*
|
|
2632
|
+
* 形状是 LocalizedText:一个字符串,或 `{ en, 'zh-CN', … }` 这种按语言分份的对象。
|
|
2633
|
+
* 插件的标题和说明都是这个形状——不挑语言直接读,官方插件在手机上就全是英文。
|
|
2634
|
+
* 没有中文退英文,再没有就取第一个非空串;都没有返回空串(调用方自己决定怎么兜)。
|
|
2635
|
+
*/
|
|
2636
|
+
function localizedText(v) {
|
|
2637
|
+
if (typeof v === 'string') return v
|
|
2638
|
+
if (!v || typeof v !== 'object') return ''
|
|
2639
|
+
for (const k of ['zh-CN', 'zh-Hans', 'zh', 'en']) {
|
|
2640
|
+
if (typeof v[k] === 'string' && v[k]) return v[k]
|
|
2641
|
+
}
|
|
2642
|
+
for (const val of Object.values(v)) if (typeof val === 'string' && val) return val
|
|
2643
|
+
return ''
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/**
|
|
2647
|
+
* Loader 条目的 Fiber 状态码 → 词。照 dsh-host-plugin-inventory 里那张表:
|
|
2648
|
+
* 0 待定 / 1 加载中 / 2 运行中 / 3 失败 / 4 已销毁(报 null)/ 5 卸载中。
|
|
2649
|
+
*/
|
|
2650
|
+
const FIBER_PHASE = ['pending', 'loading', 'active', 'failed', null, 'unloading']
|
|
2651
|
+
|
|
2652
|
+
/**
|
|
2653
|
+
* 一个 Agent 模式(预设)拿给用户看的名字与说明。
|
|
2654
|
+
*
|
|
2655
|
+
* 政策照 PC:**声明里自带名字的(自建模式)用它自己的**,没名字的(内置那几档)
|
|
2656
|
+
* 才落 AGENT_MODE_COPY;认不出又没名字的落 null——如实,不瞎起名。
|
|
2657
|
+
*
|
|
2658
|
+
* 抽出来是因为两处要用:手机上「选模式」的清单,以及插件页「会话插件」按预设分组。
|
|
2659
|
+
* (2026-10-05 实测抓到:分组那儿漏了这一步,页面上露出 standard / ptc / cordis
|
|
2660
|
+
* 这种英文代号——组合清单里的 name 是预设声明的原名,不是给用户看的名字。)
|
|
2661
|
+
*/
|
|
2662
|
+
function presetCopyOf(p) {
|
|
2663
|
+
const copy = AGENT_MODE_COPY[p?.id]
|
|
2664
|
+
const hasName = typeof p?.name === 'string' && p.name
|
|
2665
|
+
const hasDesc = typeof p?.description === 'string' && p.description
|
|
2666
|
+
return {
|
|
2667
|
+
id: p?.id,
|
|
2668
|
+
name: hasName ? p.name : (copy ? copy[0] : null),
|
|
2669
|
+
description: hasDesc ? p.description : (copy ? copy[1] : null),
|
|
2670
|
+
broken: typeof p?.broken === 'string' ? p.broken : null,
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
1782
2674
|
const nav = {
|
|
1783
2675
|
listWorkspaces: (runningIds, force, includeEmpty) =>
|
|
1784
|
-
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
|
|
1785
|
-
listSessionsOf: (workspaceId, limit, force) =>
|
|
2676
|
+
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty, meta: sessionMeta }),
|
|
2677
|
+
listSessionsOf: (workspaceId, limit, force, onTitle) =>
|
|
1786
2678
|
listSessionsOf({
|
|
1787
2679
|
registry: treeServices.registry,
|
|
1788
2680
|
query: treeServices.query,
|
|
@@ -1792,15 +2684,39 @@ export function apply(ctx, config = {}) {
|
|
|
1792
2684
|
workspaceId,
|
|
1793
2685
|
limit,
|
|
1794
2686
|
force,
|
|
2687
|
+
onTitle,
|
|
2688
|
+
meta: sessionMeta,
|
|
1795
2689
|
}),
|
|
1796
2690
|
// 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
|
|
1797
2691
|
// 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
|
|
1798
|
-
createSession: (workspaceId) =>
|
|
2692
|
+
createSession: (workspaceId, agentPreset) =>
|
|
1799
2693
|
createSessionIn({
|
|
1800
2694
|
controller: canCreate.controller ?? ctx.get('sessionController'),
|
|
1801
2695
|
registry: treeServices.registry,
|
|
1802
2696
|
workspaceId,
|
|
2697
|
+
agentPreset,
|
|
1803
2698
|
}),
|
|
2699
|
+
// Agent 模式清单(2026-10-04):手机新建会话时挑模式用。坏档也如实给(broken),
|
|
2700
|
+
// 拿不到能力就说 unavailable——不摆一个没得选的选择题。默认哪一档由
|
|
2701
|
+
// registry 的 defaultId 说(那是 DSH 自己的默认,不猜)。
|
|
2702
|
+
//
|
|
2703
|
+
// 名字照抄 PC 的词条表(见 AGENT_MODE_COPY,用户 2026-10-04 点名「模式选择应该
|
|
2704
|
+
// 为中文」)。政策也照 PC:**声明里自带名字的(自建模式)用它自己的**,没名字的
|
|
2705
|
+
// (内置档)才落词条表;认不出又没名字的才落 id——如实,不瞎起名。
|
|
2706
|
+
agentPresets: async () => {
|
|
2707
|
+
const svc = canPresets.registry ?? ctx.get('agentPresets')
|
|
2708
|
+
if (!svc || typeof svc.list !== 'function') return { ok: false, reason: 'unavailable' }
|
|
2709
|
+
try {
|
|
2710
|
+
const list = await svc.list()
|
|
2711
|
+
return {
|
|
2712
|
+
ok: true,
|
|
2713
|
+
defaultId: typeof svc.defaultId === 'string' ? svc.defaultId : null,
|
|
2714
|
+
presets: (Array.isArray(list) ? list : []).map(presetCopyOf),
|
|
2715
|
+
}
|
|
2716
|
+
} catch (err) {
|
|
2717
|
+
return { ok: false, reason: 'failed', error: String(err?.message ?? err) }
|
|
2718
|
+
}
|
|
2719
|
+
},
|
|
1804
2720
|
// 「这一刻到底能不能建会话」。给 /mini/api/version 用:那是个不用动手、
|
|
1805
2721
|
// 一条命令就能问的运行时事实,比翻日志快。和 createSession 同一条判据,
|
|
1806
2722
|
// 免得出现「探测说能、真按了却不行」。
|
|
@@ -1817,6 +2733,308 @@ export function apply(ctx, config = {}) {
|
|
|
1817
2733
|
setPermission: (sessionId, name) =>
|
|
1818
2734
|
setPreset({ service: presets.service, session: sessionFor(sessionId), name }),
|
|
1819
2735
|
|
|
2736
|
+
// 子智能体(2026-10-03)。四条都取**当前绑定会话**那一份,和上面 send / stop
|
|
2737
|
+
// 同一套口径:手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
|
|
2738
|
+
subagents: (sessionId, options) => listSubagents(sessionId, options),
|
|
2739
|
+
|
|
2740
|
+
/**
|
|
2741
|
+
* 插件清单(手机端「插件」页面用,2026-10-04)。
|
|
2742
|
+
*
|
|
2743
|
+
* 字段照 DSH 的 `BundleInfo`(pluginManager 的契约,逐字段对过):
|
|
2744
|
+
* name / version / installed / optional / enabled / description
|
|
2745
|
+
* meta: { title?, description? } —— **本地化文案**,中文说明在这一份里;
|
|
2746
|
+
* error: { code, diagnostic? } —— 出问题的那一笔。
|
|
2747
|
+
*
|
|
2748
|
+
* 两处不照抄会难看:① 只取 `description`(包的英文说明),
|
|
2749
|
+
* PC 上那句中文「启用团队协作、团队工具……」来自 `meta.description`,得优先用它;
|
|
2750
|
+
* ② `error` 是个对象,直接递出去页面上会显示 `[object Object]`。
|
|
2751
|
+
*/
|
|
2752
|
+
/**
|
|
2753
|
+
* 正在跑的这只 DSH 是哪个版本(设置页底部那行用,2026-10-04)。
|
|
2754
|
+
*
|
|
2755
|
+
* **为什么不读文件**:早先这里去翻 `node_modules/@deepseek-ai/dsh/package.json`,
|
|
2756
|
+
* 两个毛病都踩了——① 那是「命令行那份 dsh」的版本,未必等于正在跑的这只 Host
|
|
2757
|
+
* (实测桌面端跑的是 0.2.0-rc.2,而命令行那份写着 0.2.0-rc.1);
|
|
2758
|
+
* ② 桌面端的工作目录里压根没有那个文件,于是手机上一直显示 `unknown`。
|
|
2759
|
+
*
|
|
2760
|
+
* 改问正在跑的这只 Host:它自己报的运行时版本才作数。问到一次就记住。
|
|
2761
|
+
*/
|
|
2762
|
+
dshVersion: async () => {
|
|
2763
|
+
if (cachedDshVersion) return cachedDshVersion
|
|
2764
|
+
const mgr = pluginServices.manager ?? ctx.get('pluginManager')
|
|
2765
|
+
try {
|
|
2766
|
+
// 这个接口本意是列「版本豁免」,但它捎带把运行时版本报出来了
|
|
2767
|
+
// ——宿主唯一一处直接说「我是哪个版本」的地方。
|
|
2768
|
+
const got = await mgr?.listVersionExemptions?.()
|
|
2769
|
+
if (typeof got?.runtimeVersion === 'string' && got.runtimeVersion) {
|
|
2770
|
+
cachedDshVersion = got.runtimeVersion
|
|
2771
|
+
return cachedDshVersion
|
|
2772
|
+
}
|
|
2773
|
+
} catch (err) {
|
|
2774
|
+
// 问不到就往下一步退,不把设置页那行弄崩。
|
|
2775
|
+
}
|
|
2776
|
+
try {
|
|
2777
|
+
// 退一步:核心包自己的版本就是 DSH 的版本(整个仓库一起发版)。
|
|
2778
|
+
const bundles = await mgr?.listBundles?.()
|
|
2779
|
+
const base = (bundles || []).find((b) => b?.name === '@deepseek-ai/dsh-base')
|
|
2780
|
+
if (typeof base?.version === 'string' && base.version) {
|
|
2781
|
+
cachedDshVersion = base.version
|
|
2782
|
+
return cachedDshVersion
|
|
2783
|
+
}
|
|
2784
|
+
} catch (err) {
|
|
2785
|
+
// 都没有就如实说不知道(页面据此整行不显示,不拿 unknown 糊一行)。
|
|
2786
|
+
}
|
|
2787
|
+
return 'unknown'
|
|
2788
|
+
},
|
|
2789
|
+
|
|
2790
|
+
pluginList: async () => {
|
|
2791
|
+
const mgr = pluginServices.manager ?? ctx.get('pluginManager')
|
|
2792
|
+
if (!mgr) return { ok: false, reason: 'unavailable' }
|
|
2793
|
+
try {
|
|
2794
|
+
const bundles = await mgr.listBundles()
|
|
2795
|
+
return {
|
|
2796
|
+
ok: true,
|
|
2797
|
+
bundles: (bundles || []).map((b) => ({
|
|
2798
|
+
name: b?.name ?? '',
|
|
2799
|
+
// 显示名优先用本地化标题(官方插件在 PC 上显示的是「智能体团队」而不是包名),
|
|
2800
|
+
// 没有就退回包名——第三方插件多半没有标题。
|
|
2801
|
+
title: localizedText(b?.meta?.title) || b?.name || '',
|
|
2802
|
+
description: localizedText(b?.meta?.description) || b?.description || '',
|
|
2803
|
+
version: typeof b?.version === 'string' ? b.version : '',
|
|
2804
|
+
installed: b?.installed === true,
|
|
2805
|
+
optional: b?.optional === true,
|
|
2806
|
+
enabled: b?.enabled !== false,
|
|
2807
|
+
// 出问题的那一笔:有原话就说原话,没有就报错误码。没它就别硬编。
|
|
2808
|
+
problem: b?.error ? (b.error.diagnostic || b.error.code || '有问题') : '',
|
|
2809
|
+
})),
|
|
2810
|
+
}
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
2813
|
+
}
|
|
2814
|
+
},
|
|
2815
|
+
|
|
2816
|
+
/**
|
|
2817
|
+
* 内置插件清单(手机端「插件 → 已安装 → 内置」用,2026-10-04 补)。
|
|
2818
|
+
*
|
|
2819
|
+
* **为什么不是 listBundles**:那是 npm 包的粒度(十几个),答不了「内置插件有多少」。
|
|
2820
|
+
* DSH 是「一切皆插件、无特权核心」,一整套系统由几百个插件模块拼成,真相在
|
|
2821
|
+
* Cordis Loader 的实时条目表里。PC 的「设置 → 内置插件」页就是读这里,分两块:
|
|
2822
|
+
* 全局插件 = Loader 自己的条目(系统与所有会话共用);
|
|
2823
|
+
* 会话插件 = 各 Agent 预设的组合行(预设挂载后才在会话里跑)。
|
|
2824
|
+
*
|
|
2825
|
+
* 读取逻辑照 `dsh-host-plugin-inventory`(那是只读投影,我们直接读同几个源:
|
|
2826
|
+
* loader.entries() / agentPresets.compositionInventory() / pluginPackages.metaOf()),
|
|
2827
|
+
* 因为它在宿主服务目录里查不到,只能按它的实现来。
|
|
2828
|
+
*/
|
|
2829
|
+
pluginInventory: async () => {
|
|
2830
|
+
const loader = invServices.loader ?? ctx.get('loader')
|
|
2831
|
+
if (!loader || typeof loader.entries !== 'function') {
|
|
2832
|
+
return { ok: false, reason: 'unavailable' }
|
|
2833
|
+
}
|
|
2834
|
+
const packages = invServices.packages ?? ctx.get('pluginPackages')
|
|
2835
|
+
// 显示用的本地化元数据。取不到就是没有——退成模块名,不影响列出来。
|
|
2836
|
+
const metaOf = (moduleName, base) => {
|
|
2837
|
+
if (!packages || typeof packages.metaOf !== 'function' || base === undefined) return null
|
|
2838
|
+
try {
|
|
2839
|
+
return packages.metaOf(moduleName, base) ?? null
|
|
2840
|
+
} catch (err) {
|
|
2841
|
+
return null
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* 模块名压成短名。照 PC 的「内置插件」面板(dsh-client-ui-settings-plugin-inventory
|
|
2846
|
+
* 里那个 moduleShortName):`@deepseek-ai/dsh-tool-fs` → `tool-fs`。
|
|
2847
|
+
* 手机上不压的话每一行都是 `@deepseek-ai/dsh-xxx`,一屏放不下几个。
|
|
2848
|
+
*/
|
|
2849
|
+
const shortName = (name) => String(name ?? '')
|
|
2850
|
+
.replace(/^@[^/]+\//, '')
|
|
2851
|
+
.replace(/^cordis:/, '')
|
|
2852
|
+
.replace(/^cordis-plugin-/, '')
|
|
2853
|
+
.replace(/^dsh-(?:host-|client-)?/, '')
|
|
2854
|
+
/**
|
|
2855
|
+
* 显示名。PC 的规矩:`meta.title` 是本地化对象就取文案,否则把那一份
|
|
2856
|
+
* (字符串标题,或干脆是模块名)压成短名。没有标题就退到模块名的短名。
|
|
2857
|
+
*/
|
|
2858
|
+
const titleOf = (meta, moduleName) => {
|
|
2859
|
+
const t = meta?.title
|
|
2860
|
+
if (t && typeof t === 'object') return localizedText(t) || shortName(moduleName)
|
|
2861
|
+
return shortName(t || moduleName)
|
|
2862
|
+
}
|
|
2863
|
+
const rowOf = (moduleName, base, extra) => {
|
|
2864
|
+
const meta = metaOf(moduleName, base)
|
|
2865
|
+
return {
|
|
2866
|
+
entryId: extra.entryId,
|
|
2867
|
+
moduleName,
|
|
2868
|
+
title: titleOf(meta, moduleName),
|
|
2869
|
+
description: localizedText(meta?.description),
|
|
2870
|
+
enabled: extra.enabled,
|
|
2871
|
+
// 预设组合行可能是 'conditional'(带 !!js 条件、要挂载后才定得下来)。
|
|
2872
|
+
conditional: extra.conditional === true,
|
|
2873
|
+
condition: extra.condition,
|
|
2874
|
+
phase: extra.phase,
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
const entries = []
|
|
2879
|
+
try {
|
|
2880
|
+
for (const entry of loader.entries()) {
|
|
2881
|
+
// 分组只是树上的容器,不是插件(PC 的内置插件页同样跳过它们)。
|
|
2882
|
+
if (entry?.options?.group) continue
|
|
2883
|
+
const moduleName = String(entry?.options?.name ?? '')
|
|
2884
|
+
entries.push(rowOf(moduleName, entry?.parent?.tree?.ctx?.baseUrl, {
|
|
2885
|
+
entryId: String(entry?.id ?? ''),
|
|
2886
|
+
enabled: entry?.disabled !== true,
|
|
2887
|
+
condition: '',
|
|
2888
|
+
phase: entry?.fiber === undefined ? null : (FIBER_PHASE[entry.fiber.state] ?? null),
|
|
2889
|
+
}))
|
|
2890
|
+
}
|
|
2891
|
+
} catch (err) {
|
|
2892
|
+
return { ok: false, reason: '读 Loader 条目失败:' + String(err?.message || err) }
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// 会话插件:预设的组合行。拿不到就空着——那说明这个部署没挂预设名册,
|
|
2896
|
+
// 不是「一个都没有」,所以页面那一块整块不画。
|
|
2897
|
+
const presets = []
|
|
2898
|
+
try {
|
|
2899
|
+
const registry = canPresets.registry ?? ctx.get('agentPresets')
|
|
2900
|
+
// 组合清单里的 `name` 是预设自己声明的**原名**(内置那几档就是 standard / ptc
|
|
2901
|
+
// 这种代号),不是给用户看的。给用户看的名字在名册那边——走和「选模式」同一套
|
|
2902
|
+
// 政策取(见 presetCopyOf)。名册读不到就退到原名,不硬编一张对照表。
|
|
2903
|
+
const shownName = new Map()
|
|
2904
|
+
try {
|
|
2905
|
+
const roster = await registry?.list?.()
|
|
2906
|
+
for (const p of Array.isArray(roster) ? roster : []) {
|
|
2907
|
+
const copy = presetCopyOf(p)
|
|
2908
|
+
if (copy.id !== undefined && copy.name) shownName.set(String(copy.id), copy.name)
|
|
2909
|
+
}
|
|
2910
|
+
} catch (err) {
|
|
2911
|
+
// 名册问不到不影响列出组合(只是名字会退回代号)。
|
|
2912
|
+
}
|
|
2913
|
+
const list = await registry?.compositionInventory?.()
|
|
2914
|
+
for (const one of list || []) {
|
|
2915
|
+
const id = String(one?.id ?? '')
|
|
2916
|
+
presets.push({
|
|
2917
|
+
id,
|
|
2918
|
+
name: shownName.get(id) || localizedText(one?.name) || id,
|
|
2919
|
+
isDefault: one?.isDefault === true,
|
|
2920
|
+
broken: one?.broken ? String(one.broken) : '',
|
|
2921
|
+
rows: (one?.rows || []).map((r) => rowOf(String(r?.moduleName ?? ''), ctx.baseUrl, {
|
|
2922
|
+
entryId: r?.entryId === null || r?.entryId === undefined ? '' : String(r.entryId),
|
|
2923
|
+
enabled: r?.enabled === true,
|
|
2924
|
+
conditional: r?.enabled === 'conditional',
|
|
2925
|
+
condition: r?.condition ? String(r.condition) : '',
|
|
2926
|
+
phase: r?.fiberState === undefined ? null : (FIBER_PHASE[r.fiberState] ?? null),
|
|
2927
|
+
})),
|
|
2928
|
+
})
|
|
2929
|
+
}
|
|
2930
|
+
} catch (err) {
|
|
2931
|
+
// 预设读不动不该把全局那一半也拖没:两块各报各的。
|
|
2932
|
+
console.log('[dsh-mini-remote] 预设组合读不到(内置插件的会话那一块会空着):'
|
|
2933
|
+
+ String(err?.message || err))
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
return { ok: true, entries, presets, hasRoster: canPresets.registry !== null }
|
|
2937
|
+
},
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* 官方插件里那几张「设置卡片」(2026-10-04 用户确认:官方就是这 8 个,手机上也要看得到)。
|
|
2941
|
+
*
|
|
2942
|
+
* 官方那一栏其实是两样东西拼的,PC 上也是:
|
|
2943
|
+
* ① 可开关的官方插件**包**(optional && 未装)——走 pluginList,4 个;
|
|
2944
|
+
* ② 注册了设置卡片的官方插件——就是这里,4 个(终端 / Agent 循环 / 子智能体 / 网页搜索)。
|
|
2945
|
+
* 两张合起来正好是 PC 上那 8 个。
|
|
2946
|
+
*
|
|
2947
|
+
* 判据照 DSH:宿主的设置文档 served 了卡片那几个命名空间才出现。问不到设置文档时
|
|
2948
|
+
* **整块不出现**(宁可少画,不凭空多画)——这和 DSH 自己「不 served 就不注册」一致。
|
|
2949
|
+
*/
|
|
2950
|
+
officialPlugins: async () => {
|
|
2951
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
2952
|
+
if (!settings || typeof settings.describe !== 'function') return { ok: true, cards: [] }
|
|
2953
|
+
let served = null
|
|
2954
|
+
try {
|
|
2955
|
+
served = new Set((settings.describe() || []).map((d) => String(d?.ns ?? '')))
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
2958
|
+
}
|
|
2959
|
+
return {
|
|
2960
|
+
ok: true,
|
|
2961
|
+
cards: OFFICIAL_CARDS
|
|
2962
|
+
.filter((c) => c.namespaces.some((n) => served.has(n)))
|
|
2963
|
+
.map((c) => ({ kind: 'card', id: c.id, title: c.title, description: c.description })),
|
|
2964
|
+
}
|
|
2965
|
+
},
|
|
2966
|
+
/**
|
|
2967
|
+
* 「繁忙时的发送行为」(2026-10-05 用户要求搬进手机设置)。
|
|
2968
|
+
*
|
|
2969
|
+
* **这是什么**:智能体正在跑的时候,你在手机上进的那句话到底怎么交给它——
|
|
2970
|
+
* 排队发送(queue):排到队尾,等当前这一轮跑完再跑;
|
|
2971
|
+
* 插话发送(steer):直接插进去,它当场看到并调整方向。
|
|
2972
|
+
*
|
|
2973
|
+
* **PC 上是同一个东西**:设置 → 通用里的「繁忙时的发送行为」。
|
|
2974
|
+
* 真相在 `dsh-client-ui-conversation` 里:
|
|
2975
|
+
* 命名空间 ui-conversation、字段 busyEnter、取值 queue|steer、默认 queue。
|
|
2976
|
+
* 它的注释原话是「Busy-Enter preference shared by the composer bar inject face
|
|
2977
|
+
* and its Settings row: one live store … backed by the Host user-settings document」。
|
|
2978
|
+
*
|
|
2979
|
+
* **两端共用一个值**(用户 2026-10-05 拍板「都 ok」):宿主只有一份用户设置文档,
|
|
2980
|
+
* 手机上改它,电脑上也是这个值。这样才不会出现「手机上选了插话、电脑上还是排队」。
|
|
2981
|
+
*
|
|
2982
|
+
* **拿不到就整条不显示**,不摆一个假的开关——和插件页那套「宁可少画」同一口径。
|
|
2983
|
+
*/
|
|
2984
|
+
busyEnterSetting: async () => {
|
|
2985
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
2986
|
+
if (!settings || typeof settings.describe !== 'function') return { ok: false, reason: 'unavailable' }
|
|
2987
|
+
let doc = null
|
|
2988
|
+
try {
|
|
2989
|
+
doc = (settings.describe() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation') ?? null
|
|
2990
|
+
} catch (err) {
|
|
2991
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
2992
|
+
}
|
|
2993
|
+
// 这个命名空间得真的被 served 才有得改——没有它就没有这一条。
|
|
2994
|
+
if (!doc) return { ok: false, reason: 'unavailable' }
|
|
2995
|
+
const value = doc?.value && typeof doc.value === 'object' ? doc.value : {}
|
|
2996
|
+
const current = value.busyEnter === 'steer' ? 'steer' : 'queue'
|
|
2997
|
+
return {
|
|
2998
|
+
ok: true,
|
|
2999
|
+
current,
|
|
3000
|
+
// 带上 revision:写的时候要它做乐观锁,免得和电脑上同时改打架。
|
|
3001
|
+
revision: typeof doc.revision === 'number' ? doc.revision : undefined,
|
|
3002
|
+
}
|
|
3003
|
+
},
|
|
3004
|
+
|
|
3005
|
+
/**
|
|
3006
|
+
* 改「繁忙时的发送行为」。值只有两个,别的一律拒绝——**不替用户猜一个**。
|
|
3007
|
+
*
|
|
3008
|
+
* 走 `settings.update(ns, patch, revision)`(官方契约里的「把一个 entry 的可编辑
|
|
3009
|
+
* 字段合并进去」)。带 revision 是有意的:万一电脑上刚改过,这次写入会被挡下来
|
|
3010
|
+
* 而不是把对方的改动盖掉——那种覆盖用户看不见,最难查。
|
|
3011
|
+
*/
|
|
3012
|
+
setBusyEnter: async (next) => {
|
|
3013
|
+
if (next !== 'queue' && next !== 'steer') {
|
|
3014
|
+
return { ok: false, reason: 'invalid' }
|
|
3015
|
+
}
|
|
3016
|
+
const settings = invServices.settings ?? ctx.get('settings')
|
|
3017
|
+
if (!settings || typeof settings.update !== 'function') return { ok: false, reason: 'unavailable' }
|
|
3018
|
+
let revision
|
|
3019
|
+
try {
|
|
3020
|
+
const doc = (settings.describe() || []).find((d) => String(d?.ns ?? '') === 'ui-conversation')
|
|
3021
|
+
if (!doc) return { ok: false, reason: 'unavailable' }
|
|
3022
|
+
revision = typeof doc.revision === 'number' ? doc.revision : undefined
|
|
3023
|
+
} catch (err) {
|
|
3024
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
3025
|
+
}
|
|
3026
|
+
try {
|
|
3027
|
+
await settings.update('ui-conversation', { busyEnter: next }, revision)
|
|
3028
|
+
} catch (err) {
|
|
3029
|
+
return { ok: false, reason: String(err?.message || err) }
|
|
3030
|
+
}
|
|
3031
|
+
return { ok: true, current: next }
|
|
3032
|
+
},
|
|
3033
|
+
|
|
3034
|
+
subagentTranscript: (childId, sessionId) => subagentTranscript(childId, sessionId),
|
|
3035
|
+
stopSubagent: (childId, sessionId, mode) => stopSubagent(childId, sessionId, mode),
|
|
3036
|
+
askSubagent: (childId, sessionId, mode, text) => askSubagent(childId, sessionId, mode, text),
|
|
3037
|
+
|
|
1820
3038
|
// 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
|
|
1821
3039
|
// `canCreate.controller` 就是 `ctx.sessionController`——和上面 createSession 同一个东西。
|
|
1822
3040
|
//
|
|
@@ -1982,6 +3200,12 @@ export function apply(ctx, config = {}) {
|
|
|
1982
3200
|
// 手机点开会话(或刷新页面)时,把那条会话的完整历史读出来。
|
|
1983
3201
|
onSessionOpened: (sessionId) => ensureHistory(sessionId),
|
|
1984
3202
|
tree: nav,
|
|
3203
|
+
// 正文里写相对路径的图(``)相对的是**那段对话的工作目录**,
|
|
3204
|
+
// 而不一定是插件根。所以把当前已注册工作区的目录一并递过去当额外候选根。
|
|
3205
|
+
// `treeServices.registry` 就是 DSH 的 workspaceRegistry(注入见上面那段),
|
|
3206
|
+
// 服务这边拿不到 ctx,只能由插件给——传函数不传快照,理由和 treeServices 一样:
|
|
3207
|
+
// 服务每次请求现读,工作区是随时会变的。
|
|
3208
|
+
imageRoots: () => (treeServices.registry?.list?.() ?? []).map((w) => w?.path),
|
|
1985
3209
|
// 目录浏览自己读盘(理由见 lib/browse.js 开头:DSH 的目录选择器在
|
|
1986
3210
|
// 「Windows + 只绑回环」时会判成 native,那套动词是直接拒绝的,
|
|
1987
3211
|
// 会在电脑屏幕上弹框——而用户正在外面用手机)。
|
|
@@ -2030,6 +3254,29 @@ export function apply(ctx, config = {}) {
|
|
|
2030
3254
|
}
|
|
2031
3255
|
})
|
|
2032
3256
|
|
|
3257
|
+
// ------------------------------------------------------------------
|
|
3258
|
+
// 飞书那条路自己的生死
|
|
3259
|
+
// ------------------------------------------------------------------
|
|
3260
|
+
/**
|
|
3261
|
+
* 单独一个 effect,不和手机服务那一个搅在一起:这两样各有各的失败方式,
|
|
3262
|
+
* 一个起不来不该把另一个拖下水。
|
|
3263
|
+
*
|
|
3264
|
+
* `closed` 那一道和手机服务同一个道理——建连接是异步的,而插件随时可能被卸载。
|
|
3265
|
+
* 建好时如果已经卸了,当场把它关掉,不能留下一条没人管的连接和它自己的定时器。
|
|
3266
|
+
*/
|
|
3267
|
+
ctx.effect(() => {
|
|
3268
|
+
let closed = false
|
|
3269
|
+
const start = async () => {
|
|
3270
|
+
await bringFeishuUp()
|
|
3271
|
+
if (closed) bringFeishuDown()
|
|
3272
|
+
}
|
|
3273
|
+
start()
|
|
3274
|
+
return () => {
|
|
3275
|
+
closed = true
|
|
3276
|
+
bringFeishuDown()
|
|
3277
|
+
}
|
|
3278
|
+
})
|
|
3279
|
+
|
|
2033
3280
|
// ------------------------------------------------------------------
|
|
2034
3281
|
// DSH 设置页的「手机遥控」标签从这里取数据
|
|
2035
3282
|
// ------------------------------------------------------------------
|
|
@@ -2069,6 +3316,9 @@ export function apply(ctx, config = {}) {
|
|
|
2069
3316
|
error: serverError
|
|
2070
3317
|
? `手机服务起不来:${serverError}\n端口可以在设置文件里改(settings.json 里的 port)。`
|
|
2071
3318
|
: '手机服务还在启动,过几秒刷新一下。',
|
|
3319
|
+
// 手机服务没起来,不影响飞书那一块——它和手机服务没有关系,谁用谁的。
|
|
3320
|
+
// 所以这一条早退路径上也带着 feishu,用户至少改得动它。
|
|
3321
|
+
feishu: feishuStatus(),
|
|
2072
3322
|
},
|
|
2073
3323
|
}
|
|
2074
3324
|
}
|
|
@@ -2096,6 +3346,8 @@ export function apply(ctx, config = {}) {
|
|
|
2096
3346
|
* 端口被占时服务会自己往后让,用配置值会指错。
|
|
2097
3347
|
*/
|
|
2098
3348
|
serve: await serveState(server.port),
|
|
3349
|
+
// 飞书那一块的现状(**永远不含 appSecret**,只回答「有没有凭据」)。
|
|
3350
|
+
feishu: feishuStatus(),
|
|
2099
3351
|
})
|
|
2100
3352
|
return { status: payload.ok ? 200 : 500, body: payload }
|
|
2101
3353
|
}
|
|
@@ -2264,6 +3516,57 @@ export function apply(ctx, config = {}) {
|
|
|
2264
3516
|
},
|
|
2265
3517
|
})
|
|
2266
3518
|
|
|
3519
|
+
/**
|
|
3520
|
+
* 飞书那一块的配置:开关、两个凭据、两个允许名单。
|
|
3521
|
+
*
|
|
3522
|
+
* 和公网开关、改密码一样**只允许本机调用**:那是一对凭据,改它等于改这个插件
|
|
3523
|
+
* 在飞书那边的身份。同一个 Wi-Fi 下的别人不该替你做主。
|
|
3524
|
+
*
|
|
3525
|
+
* 两条不成文但必须守的规矩:
|
|
3526
|
+
* 1. **appSecret 从不回显**(见 feishuStatus)。所以界面上那个框天生是空的,
|
|
3527
|
+
* 于是「留空」只能解释成「不改动原来那份」——不然用户每点一次保存都得重新
|
|
3528
|
+
* 贴一遍密码,贴错一次就是一段查不出原因的鉴权失败。
|
|
3529
|
+
* 2. 存完**就地重建连接**。飞书的长连接是绑在凭据上的,不重建的话用户会看到
|
|
3530
|
+
* 「填好了、开关也开着,就是不回消息」,而屏幕上没有任何东西提示他原因。
|
|
3531
|
+
*/
|
|
3532
|
+
const offFeishu = webServer.register({
|
|
3533
|
+
kind: 'exact',
|
|
3534
|
+
path: '/mini-remote/feishu',
|
|
3535
|
+
handler: async (req, res) => {
|
|
3536
|
+
if (!localOnly(req, res)) return
|
|
3537
|
+
if (req.method !== 'POST') {
|
|
3538
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
3539
|
+
return
|
|
3540
|
+
}
|
|
3541
|
+
let want
|
|
3542
|
+
try {
|
|
3543
|
+
want = await readBody(req)
|
|
3544
|
+
} catch (err) {
|
|
3545
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
3546
|
+
return
|
|
3547
|
+
}
|
|
3548
|
+
const next = {
|
|
3549
|
+
enabled: Boolean(want?.enabled),
|
|
3550
|
+
appId: typeof want?.appId === 'string' ? want.appId.trim() : '',
|
|
3551
|
+
appSecret: typeof want?.appSecret === 'string' ? want.appSecret.trim() : '',
|
|
3552
|
+
openIds: typeof want?.openIds === 'string' ? want.openIds.trim() : '',
|
|
3553
|
+
chatIds: typeof want?.chatIds === 'string' ? want.chatIds.trim() : '',
|
|
3554
|
+
}
|
|
3555
|
+
// 留空 = 保留原来那份(理由见上面第 1 条)。
|
|
3556
|
+
if (!next.appSecret) next.appSecret = String(settings.feishu?.appSecret ?? '')
|
|
3557
|
+
settings.feishu = next
|
|
3558
|
+
persistSettings()
|
|
3559
|
+
if (next.enabled) await restartFeishu()
|
|
3560
|
+
else bringFeishuDown()
|
|
3561
|
+
try {
|
|
3562
|
+
const { status, body } = await pairingPayload()
|
|
3563
|
+
sendJson(res, status, body)
|
|
3564
|
+
} catch (err) {
|
|
3565
|
+
sendJson(res, 500, { ok: false, error: `改完之后读不到状态:${err?.message ?? err}` })
|
|
3566
|
+
}
|
|
3567
|
+
},
|
|
3568
|
+
})
|
|
3569
|
+
|
|
2267
3570
|
/**
|
|
2268
3571
|
* 卸载时把注册过的路由全部注销。
|
|
2269
3572
|
*
|
|
@@ -2271,13 +3574,14 @@ export function apply(ctx, config = {}) {
|
|
|
2271
3574
|
* 还挂在服务器上,指向一个已经死掉的闭包——再有人打过来就是莫名其妙的报错。
|
|
2272
3575
|
*
|
|
2273
3576
|
* 2026-09-24 补:`offToken` 一直是漏的(改密码那条路由注册了却没注销),
|
|
2274
|
-
* 加 HTTPS
|
|
3577
|
+
* 加 HTTPS 这条时对照着数了一遍才发现。五条注册,五条注销,现在对得上。
|
|
2275
3578
|
*/
|
|
2276
3579
|
return () => {
|
|
2277
3580
|
offGet?.()
|
|
2278
3581
|
offTunnel?.()
|
|
2279
3582
|
offServe?.()
|
|
2280
3583
|
offToken?.()
|
|
3584
|
+
offFeishu?.()
|
|
2281
3585
|
}
|
|
2282
3586
|
})
|
|
2283
3587
|
}
|