dsh-mini-remote 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -12
- package/README.en.md +33 -1
- package/README.md +33 -1
- package/client/client.js +272 -1
- package/lib/events.js +30 -4
- package/lib/history.js +15 -2
- package/lib/index.js +658 -10
- package/lib/lark.js +1154 -0
- package/lib/page.html +477 -3
- package/lib/page.js +14 -2
- package/lib/pairing.js +32 -3
- package/lib/server.js +256 -26
- package/lib/store.js +22 -0
- package/lib/tree.js +78 -8
- package/package.json +2 -1
package/lib/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './commands.js'
|
|
38
38
|
import { createMiniServer, miniControl, sendJson, readBody } from './server.js'
|
|
39
39
|
import { sendExternalNotification } from './notify.js'
|
|
40
|
+
import { createFeishuChannel, feishuQuestionText, approvalText, FEISHU_TEXT_LIMIT } from './lark.js'
|
|
40
41
|
import { reachableAddresses, resolveBindAddresses, isLocalRequest } from './net.js'
|
|
41
42
|
import { buildPairing, tunnelUrlFor, tunnelProbeVerdict } from './pairing.js'
|
|
42
43
|
import { serveState, serveEnable, serveDisable } from './serve.js'
|
|
@@ -68,6 +69,15 @@ const TUNNEL_ATTEMPTS = 3
|
|
|
68
69
|
*/
|
|
69
70
|
const MAX_PENDING_UPLOADS = 20
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* 飞书里发 `/会话` 最多列几个会话。
|
|
74
|
+
*
|
|
75
|
+
* 10 是「一屏能看完」定的:手机页是把工作区一层层展开看的,飞书里只有一条消息,
|
|
76
|
+
* 列太长要划半天,而这条命令要的只是「切到那一个」。超出的部分如实说还剩多少条
|
|
77
|
+
* (见 lib/lark.js 的 sessionPickText)。
|
|
78
|
+
*/
|
|
79
|
+
const FEISHU_PICK_MAX = 10
|
|
80
|
+
|
|
71
81
|
/**
|
|
72
82
|
* 这份代码的构建指纹,加载时算一次。
|
|
73
83
|
* 用来回答「现在应答我的进程,跑的是不是我改的这份代码」——重启期间旧进程还在
|
|
@@ -112,6 +122,22 @@ const DEFAULTS = {
|
|
|
112
122
|
maxLength: 200,
|
|
113
123
|
sound: 'default',
|
|
114
124
|
},
|
|
125
|
+
/**
|
|
126
|
+
* 飞书通路(长连接)。默认关。
|
|
127
|
+
*
|
|
128
|
+
* 为什么默认关:开了之后这台电脑就多了一扇「从飞书进来的门」,该由用户自己决定。
|
|
129
|
+
* 而且**两个名单都空 = 谁都不认**(判据在 lib/lark.js 的 admitSource)——门开着但
|
|
130
|
+
* 名单不填,等于开着没钥匙,这是刻意的默认值。
|
|
131
|
+
*/
|
|
132
|
+
feishu: {
|
|
133
|
+
enabled: false,
|
|
134
|
+
appId: '',
|
|
135
|
+
appSecret: '',
|
|
136
|
+
/** 允许的 open_id,一行一个(逗号、分号也行)。空 = 这份名单不设限。 */
|
|
137
|
+
openIds: '',
|
|
138
|
+
/** 允许的 chat_id,同上。两个都空 = 任何来源都不处理。 */
|
|
139
|
+
chatIds: '',
|
|
140
|
+
},
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
/**
|
|
@@ -166,6 +192,23 @@ export const Config = z.object({
|
|
|
166
192
|
maxLength: z.number(),
|
|
167
193
|
sound: z.string(),
|
|
168
194
|
})).description('外部推送(Bark / ntfy / Telegram)。改了立即生效,不用重载。'),
|
|
195
|
+
/**
|
|
196
|
+
* 飞书这一块**没有**标 volatile(口径见上面那段注释)。
|
|
197
|
+
*
|
|
198
|
+
* 这不是省事:飞书的长连接是用 appId/appSecret 建起来的,改了凭据就得把那条连接
|
|
199
|
+
* 整个换掉——那一步不是「就地改一个值」能兑现的。标了 volatile 等于向 DSH 承诺
|
|
200
|
+
* 「改它不用重载」,而承诺得在代码里兑现。**宁可少标,不能乱标。**
|
|
201
|
+
*
|
|
202
|
+
* 用户实际不会因此多做什么:设置页那一页有自己的保存按钮,存完插件自己就会把连接
|
|
203
|
+
* 换掉(见 restartFeishu)。只有手改 settings.json 的人需要重载一次插件。
|
|
204
|
+
*/
|
|
205
|
+
feishu: z.object({
|
|
206
|
+
enabled: z.boolean(),
|
|
207
|
+
appId: z.string(),
|
|
208
|
+
appSecret: z.string().role('secret'),
|
|
209
|
+
openIds: z.string(),
|
|
210
|
+
chatIds: z.string(),
|
|
211
|
+
}).description('飞书(长连接):机器人的两个凭据与两个允许名单。改了要重载插件(凭据变了得重建连接)。'),
|
|
169
212
|
})
|
|
170
213
|
|
|
171
214
|
function deepMerge(base, ...overrides) {
|
|
@@ -660,6 +703,185 @@ export function apply(ctx, config = {}) {
|
|
|
660
703
|
})
|
|
661
704
|
}
|
|
662
705
|
|
|
706
|
+
// ------------------------------------------------------------------
|
|
707
|
+
// 飞书通路(长连接)
|
|
708
|
+
// ------------------------------------------------------------------
|
|
709
|
+
/**
|
|
710
|
+
* 飞书那条路:入站走官方 SDK 的 WebSocket 长连接(不需要公网地址、不需要域名),
|
|
711
|
+
* 出站用「回复某条消息」接口挂回原消息下。实现全在 lib/lark.js,这里只管它的生死。
|
|
712
|
+
*/
|
|
713
|
+
/** @type {Awaited<ReturnType<typeof createFeishuChannel>> | null} */
|
|
714
|
+
let feishu = null
|
|
715
|
+
/** 正在建连接;用来挡住「连点两次开关起出两条」。 */
|
|
716
|
+
let feishuStarting = false
|
|
717
|
+
/** 上一次没起来的原因;null = 没出错。和 serverError 一样,用来把「还没连上」和「起不来」分开说。 */
|
|
718
|
+
let feishuError = null
|
|
719
|
+
|
|
720
|
+
async function bringFeishuUp() {
|
|
721
|
+
if (feishu || feishuStarting) return
|
|
722
|
+
const cfg = settings.feishu ?? {}
|
|
723
|
+
if (!cfg.enabled) return
|
|
724
|
+
feishuStarting = true
|
|
725
|
+
feishuError = null
|
|
726
|
+
try {
|
|
727
|
+
feishu = await createFeishuChannel({
|
|
728
|
+
config: cfg,
|
|
729
|
+
log,
|
|
730
|
+
onInstruction,
|
|
731
|
+
// 飞书里回了「同意/1」这类话,交回电脑。走的是手机页同一份清单、同一个 settle
|
|
732
|
+
// (见 lib/server.js 的 answerQuestion / decideApproval),谁先答谁生效。
|
|
733
|
+
onAnswer: answerFromFeishu,
|
|
734
|
+
// `/会话` 要的两件事:列可选会话(标题是手机页同一份缓存)、切到某一个
|
|
735
|
+
// (切的是手机页同一个全局绑定,切完手机页跟着变)。文案全在 lib/lark.js。
|
|
736
|
+
listSessions: feishuSessionList,
|
|
737
|
+
bindSession: bindSessionForFeishu,
|
|
738
|
+
})
|
|
739
|
+
log.info('飞书:通路挂上了,等消息。')
|
|
740
|
+
} catch (err) {
|
|
741
|
+
feishu = null
|
|
742
|
+
feishuError = err?.message ?? String(err)
|
|
743
|
+
log.warn(`飞书没起来:${feishuError}`)
|
|
744
|
+
} finally {
|
|
745
|
+
feishuStarting = false
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function bringFeishuDown() {
|
|
750
|
+
feishu?.close()
|
|
751
|
+
feishu = null
|
|
752
|
+
feishuError = null
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 换配置就地重建。
|
|
757
|
+
*
|
|
758
|
+
* 长连接是绑在凭据上的,改一处就得整条换掉——所以不是「改个值」而是「关了重开」。
|
|
759
|
+
* 关这一步必须真的断干净(`close()` 里连定时器一起收),否则会留下一条看不见的
|
|
760
|
+
* 旧连接,用旧凭据继续收消息。
|
|
761
|
+
*/
|
|
762
|
+
async function restartFeishu() {
|
|
763
|
+
bringFeishuDown()
|
|
764
|
+
await bringFeishuUp()
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* 这条会话叫什么。给飞书那行「会话:…」标记用(见 lib/lark.js 的 sessionTagLine)。
|
|
769
|
+
*
|
|
770
|
+
* 来源就是**手机页显示的那个名字**:lib/title-cache.js 那份落盘缓存。取不到就回空串,
|
|
771
|
+
* 标记那边会写成「未命名会话」——**不把会话 id 露出去**,手机上从不显示 id,
|
|
772
|
+
* 用户拿着它没有地方可以对。
|
|
773
|
+
*/
|
|
774
|
+
function titleOfSession(sessionId) {
|
|
775
|
+
if (!sessionId) return ''
|
|
776
|
+
const known = titles.get(sessionId)
|
|
777
|
+
return typeof known === 'string' ? known : ''
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* 这一轮落定的回答,回给在飞书里发指令的那个人。
|
|
782
|
+
*
|
|
783
|
+
* **归属靠 sessionId**(见 lib/lark.js 的 takeTarget / markRoundStarted):哪条会话
|
|
784
|
+
* 这一轮是从飞书起跑的,回答就回那条飞书消息。`meta.userText` 是这一轮**用户的原话**,
|
|
785
|
+
* 只做兜底比对——它会被同一轮里后来的一句人话顶掉,所以不能当主凭据。
|
|
786
|
+
* 手机发起的轮次在这里天然落空——对不上号就不发,不会把手机上的回答倒进飞书。
|
|
787
|
+
*
|
|
788
|
+
* 顺带把这条会话的名字一起交出去:飞书那边要在回答末尾补一行「会话:…」。
|
|
789
|
+
* 只有飞书这条路拿得到它——手机页那份文本一个字都不变。
|
|
790
|
+
*/
|
|
791
|
+
function onReply(text, meta) {
|
|
792
|
+
if (!feishu) return
|
|
793
|
+
// reply() 自己把失败写进日志并返回 { ok:false },不会抛——这里不用再兜一层。
|
|
794
|
+
feishu.reply(text, { ...meta, sessionTitle: titleOfSession(meta?.sessionId) })
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 飞书那边拍完板,把结果交回电脑。
|
|
799
|
+
*
|
|
800
|
+
* **这里不判「这道题还在不在」**:那份清单和 settle 都在 lib/server.js,交回去的时候
|
|
801
|
+
* 它还在不在,由它如实回答(`expired` = 手机先拍了板)。这边只是把飞书的说法翻译成
|
|
802
|
+
* 那个入口收的形状。
|
|
803
|
+
*
|
|
804
|
+
* 末尾那个 `'feishu'` 是**说出来源**,只用来让手机页那张还挂着的卡片知道该收起来、
|
|
805
|
+
* 该写「已在飞书答过」(见 lib/server.js 的 notifySettled)。不是身份判据。
|
|
806
|
+
*
|
|
807
|
+
* @param {{kind: string, id: string, cancel?: boolean, answers?: Array, decision?: string}} reply
|
|
808
|
+
* @returns {{ok: boolean, error?: string}}
|
|
809
|
+
*/
|
|
810
|
+
function answerFromFeishu(reply) {
|
|
811
|
+
const take = reply?.kind === 'approval' ? miniControl.decideApproval : miniControl.answerQuestion
|
|
812
|
+
if (typeof take !== 'function') return { ok: false, error: 'no-channel' }
|
|
813
|
+
const input = reply?.cancel === true
|
|
814
|
+
? { cancel: true }
|
|
815
|
+
: (reply?.kind === 'approval' ? { decision: reply?.decision } : { answers: reply?.answers })
|
|
816
|
+
return take(reply?.id, input, 'feishu')
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* `/会话` 要列的那些:这台电脑上的会话,新的在前。
|
|
821
|
+
*
|
|
822
|
+
* 标题走的是**手机页同一份东西**:`nav.listSessionsOf` 拿的正是手机页展开工作区时那份
|
|
823
|
+
* (knownTitles + 落盘缓存 lib/title-cache.js),取不到就是空串,飞书那边写「未命名会话」。
|
|
824
|
+
*
|
|
825
|
+
* 上限是给「一屏能看完」定的,不是给性能定的:工作区一多,全列出来在手机上要划半天。
|
|
826
|
+
* `total` 一起交出去,好在超出时如实说「只列了最近 N 条」。
|
|
827
|
+
*/
|
|
828
|
+
async function feishuSessionList() {
|
|
829
|
+
const workspaces = await nav.listWorkspaces(new Set(), false, false)
|
|
830
|
+
const rows = []
|
|
831
|
+
let total = 0
|
|
832
|
+
for (const ws of workspaces) {
|
|
833
|
+
const got = await nav.listSessionsOf(ws.id, FEISHU_PICK_MAX, false)
|
|
834
|
+
if (!got) continue
|
|
835
|
+
total += Number(got.total) || 0
|
|
836
|
+
for (const one of got.sessions ?? []) {
|
|
837
|
+
rows.push({ id: one.id, title: one.title, at: Number(one.createdAt) || 0 })
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// 新的在前——和手机页展开一个工作区时的顺序同一条口径(那边也是按 createdAt 倒序)。
|
|
841
|
+
rows.sort((a, b) => b.at - a.at)
|
|
842
|
+
return {
|
|
843
|
+
rows: rows.slice(0, FEISHU_PICK_MAX).map((one) => ({ id: one.id, title: one.title })),
|
|
844
|
+
total,
|
|
845
|
+
bound: store.snapshot().boundSessionId ?? '',
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* 切到那一个会话。
|
|
851
|
+
*
|
|
852
|
+
* **切的是手机页同一个全局绑定**(和 `/mini/api/bind` 那条路逐条一样:bind → 读历史 →
|
|
853
|
+
* 广播一份快照),所以飞书里切完,手机页跟着变。这不是顺手:两边各绑各的会让用户
|
|
854
|
+
* 以为在跟同一个会话说话,而实际上没有。
|
|
855
|
+
*/
|
|
856
|
+
function bindSessionForFeishu(sessionId) {
|
|
857
|
+
store.bind(sessionId)
|
|
858
|
+
// 和 /mini/api/bind 那条逐句一样:读这个会话的历史(异步补,读完自己会广播一份快照),
|
|
859
|
+
// 再广播一次当前快照——手机页正开着的话,它当场就跟着切过去了。
|
|
860
|
+
ensureHistory(sessionId)
|
|
861
|
+
server?.broadcast('state', store.snapshot())
|
|
862
|
+
return titleOfSession(sessionId)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** 飞书那一块给设置页看的现状。**绝不包含 appSecret**,只回答「有没有凭据」。 */
|
|
866
|
+
function feishuStatus() {
|
|
867
|
+
const cfg = settings.feishu ?? {}
|
|
868
|
+
const live = feishu?.status?.() ?? null
|
|
869
|
+
return {
|
|
870
|
+
enabled: Boolean(cfg.enabled),
|
|
871
|
+
appId: String(cfg.appId ?? ''),
|
|
872
|
+
openIds: String(cfg.openIds ?? ''),
|
|
873
|
+
chatIds: String(cfg.chatIds ?? ''),
|
|
874
|
+
hasSecret: String(cfg.appSecret ?? '').trim() !== '',
|
|
875
|
+
starting: feishuStarting,
|
|
876
|
+
running: Boolean(feishu),
|
|
877
|
+
connected: Boolean(live?.connected),
|
|
878
|
+
// 上一次被白名单挡住的来源(含 open_id / chat_id 原值)。设置页要把这一行印出来:
|
|
879
|
+
// 名单默认是空的,用户不可能凭空知道自己的 id 长什么样,全靠这一行抄。
|
|
880
|
+
rejected: live?.rejected ?? null,
|
|
881
|
+
error: feishuError,
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
663
885
|
// ------------------------------------------------------------------
|
|
664
886
|
// 手机发来的指令 → 注入 DSH
|
|
665
887
|
// ------------------------------------------------------------------
|
|
@@ -1105,6 +1327,21 @@ export function apply(ctx, config = {}) {
|
|
|
1105
1327
|
// 状态必须是同一套口径,否则刷新一下就看到别人的结果了。
|
|
1106
1328
|
if (sessionId !== targetSessionId()) return
|
|
1107
1329
|
|
|
1330
|
+
if (action.kind === 'notice') {
|
|
1331
|
+
// 子智能体完工的通知。记进聊天记录,**但不震动、不弹通知**——它是「你派出去的
|
|
1332
|
+
// 那件事有结果了」,和「模型答了你一句」不是同一件事。可它确实是这个会话里
|
|
1333
|
+
// 发生的一件事,聊天记录里不能缺(原来就是被 user/message 那道过滤一起挡掉的)。
|
|
1334
|
+
store.pushNotice({
|
|
1335
|
+
text: action.text,
|
|
1336
|
+
summary: action.summary,
|
|
1337
|
+
senderSessionId: action.senderSessionId,
|
|
1338
|
+
sessionId,
|
|
1339
|
+
timestamp: event.time,
|
|
1340
|
+
})
|
|
1341
|
+
server?.broadcast('state', store.snapshot())
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1108
1345
|
if (action.kind === 'user') {
|
|
1109
1346
|
// 手机自己发的那条:onInstruction 里已经记过一笔了,这里跳过。
|
|
1110
1347
|
// 不跳的话聊天模式里同一条指令会显示两次(用户实机报过)。
|
|
@@ -1141,6 +1378,10 @@ export function apply(ctx, config = {}) {
|
|
|
1141
1378
|
timestamp: Date.now(),
|
|
1142
1379
|
})
|
|
1143
1380
|
maybeNotify(action.text)
|
|
1381
|
+
// 飞书那条路:这一轮落定的回答,回给在飞书里发指令的那个人(见 onReply)。
|
|
1382
|
+
// `sessionId` 既是**归属**(飞书那边靠它认出发起这一轮的那条消息),也要拿去查会话名;
|
|
1383
|
+
// `userText` 是 events.js 取好的「这一轮用户的原话」,只做兜底比对。
|
|
1384
|
+
onReply(action.text, { userText: action.userText, sessionId })
|
|
1144
1385
|
})
|
|
1145
1386
|
|
|
1146
1387
|
ctx.on('agent/status', ({ agent, status }) => {
|
|
@@ -1470,6 +1711,263 @@ export function apply(ctx, config = {}) {
|
|
|
1470
1711
|
return null
|
|
1471
1712
|
})
|
|
1472
1713
|
|
|
1714
|
+
// ------------------------------------------------------------------
|
|
1715
|
+
// 子智能体(2026-10-03 用户要求:把电脑端「子智能体」搬到手机)
|
|
1716
|
+
//
|
|
1717
|
+
// 和会话树同一套拿法:走 ctx.inject,服务不在就退回「没这个能力」。**不写进
|
|
1718
|
+
// `inject` 数组**——写进去,headless 组合下这个服务永远不来,插件就整个不加载了
|
|
1719
|
+
// (这个坑上面刚踩过,见 inject 那行的注释)。
|
|
1720
|
+
//
|
|
1721
|
+
// 三条路都**在进程内直接调 DSH 的服务**,不经过任何 HTTP / WebSocket:
|
|
1722
|
+
// `subagents` 子代理编排服务(列表、停止、继续)
|
|
1723
|
+
// `sessionProjections` 投影注册表(耗时、用量)
|
|
1724
|
+
// `sessions` 会话存储(不读日志就能拿到 Session 对象)
|
|
1725
|
+
//
|
|
1726
|
+
// 插件和它们同在一个进程里,这是它比桌面前端占便宜的地方:桌面前端只能隔着
|
|
1727
|
+
// 一条 WebSocket 问,而那条线上只开了 `prompt` / `interruptByParent` 两个口子;
|
|
1728
|
+
// 列表和运行记录它反而拿不到,得靠投影另推一份。
|
|
1729
|
+
// ------------------------------------------------------------------
|
|
1730
|
+
const subagentServices = { runtime: null, projections: null, sessions: null }
|
|
1731
|
+
ctx.inject(['subagents'], (subCtx) => {
|
|
1732
|
+
subagentServices.runtime = subCtx.subagents ?? null
|
|
1733
|
+
// 和会话树那句同一个道理:配置里有不等于运行时真的提供了,这一句是唯一的事实来源。
|
|
1734
|
+
console.log('[dsh-mini-remote] 子智能体服务已就绪:' + Boolean(subagentServices.runtime))
|
|
1735
|
+
})
|
|
1736
|
+
ctx.inject(['sessionProjections'], (projCtx) => {
|
|
1737
|
+
subagentServices.projections = projCtx.sessionProjections ?? null
|
|
1738
|
+
})
|
|
1739
|
+
ctx.inject(['sessions'], (sessCtx) => {
|
|
1740
|
+
subagentServices.sessions = sessCtx.sessions ?? null
|
|
1741
|
+
})
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* DSH 那几个失败代号 → 一句人话。说不上来的照原样带出去,**不吞也不编**。
|
|
1745
|
+
*
|
|
1746
|
+
* 代号取自 dsh-subagent 的 `SubagentError.code`。头三句是手机上真点得到的三种:
|
|
1747
|
+
* 父会话不在线、同时跑的数量满了、这道子智能体接不了话。
|
|
1748
|
+
*/
|
|
1749
|
+
const SUBAGENT_FAILURES = {
|
|
1750
|
+
PARENT_UNAVAILABLE: '它在的那个会话不在线了,这条发不过去。',
|
|
1751
|
+
ACTIVATION_LIMIT_REACHED: '同时能跑的子智能体已经到上限了,等一个跑完再试。',
|
|
1752
|
+
NOT_RESUMABLE: '这道子智能体没有对话可以接,只能看它跑过什么。',
|
|
1753
|
+
UNAUTHORIZED: '这道子智能体不在当前会话名下。',
|
|
1754
|
+
DRAINING: '它正在收尾,这会儿不接受新消息。',
|
|
1755
|
+
ACTIVATION_CLOSING: '它正在停下,等停稳了再试。',
|
|
1756
|
+
CONTINUATION_UNAVAILABLE: '这台电脑上的 DSH 没开「继续子智能体对话」这条路。',
|
|
1757
|
+
PERSISTENCE_UNAVAILABLE: '这台电脑上的 DSH 没开会话记录,接不了话。',
|
|
1758
|
+
CANCELLED: '这条请求已经取消了。',
|
|
1759
|
+
}
|
|
1760
|
+
function subagentFailure(err, what) {
|
|
1761
|
+
const code = err?.code
|
|
1762
|
+
if (typeof code === 'string' && SUBAGENT_FAILURES[code]) return SUBAGENT_FAILURES[code]
|
|
1763
|
+
return `${what}:${err?.message ?? err}`
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* 每一行要的那两个数:耗时、用量。
|
|
1768
|
+
*
|
|
1769
|
+
* 都从 DSH 自己的投影里读(`subagentTiming` / `tokenUsage`),**不自己拿事件累加**——
|
|
1770
|
+
* 自己算出来的数和电脑上那一栏对不上,用户一眼就看得出来。读不到就给 null,
|
|
1771
|
+
* 页面上显示「—」:给 0 会被读成「真的一次都没跑过」,那是另一句话。
|
|
1772
|
+
*
|
|
1773
|
+
* 会话对象从 `sessions.get` 拿:内存里现成的,**不读日志**。老早跑完、进程里
|
|
1774
|
+
* 已经不在的那些拿不到——那就如实空着,不为数字好看去读它整份日志。
|
|
1775
|
+
*/
|
|
1776
|
+
function subagentMetrics(childId) {
|
|
1777
|
+
const session = subagentServices.sessions?.get?.(childId)
|
|
1778
|
+
const projections = subagentServices.projections
|
|
1779
|
+
if (!session || typeof projections?.snapshot !== 'function') {
|
|
1780
|
+
return { tokens: null, durationMs: null, lastTurnCompleted: null }
|
|
1781
|
+
}
|
|
1782
|
+
let values
|
|
1783
|
+
try {
|
|
1784
|
+
values = projections.snapshot(session, ['subagentTiming', 'tokenUsage'])?.values
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
return { tokens: null, durationMs: null, lastTurnCompleted: null }
|
|
1787
|
+
}
|
|
1788
|
+
let durationMs = null
|
|
1789
|
+
// 「最近跑完的那一轮是正常收尾的」——页面上拿它区分绿点(真的干完了)和灰点(
|
|
1790
|
+
// 没在跑,但不知道最后是怎么结束的)。读不到就给 null,别猜成「成功」。
|
|
1791
|
+
let lastTurnCompleted = null
|
|
1792
|
+
const timing = values?.subagentTiming
|
|
1793
|
+
if (timing) {
|
|
1794
|
+
// 跑完的轮次累加在 settledMs;还开着的那一轮按投影记的切面算,**不用 Date.now()**
|
|
1795
|
+
// ——那会读出一个投影没盖到的时间,和电脑上对不上。
|
|
1796
|
+
const settled = Number(timing.settledMs) || 0
|
|
1797
|
+
const active = timing.active
|
|
1798
|
+
? Math.max(0, (Number(timing.active.through) || 0) - (Number(timing.active.since) || 0))
|
|
1799
|
+
: 0
|
|
1800
|
+
durationMs = settled + active
|
|
1801
|
+
if (typeof timing.lastTurnCompleted === 'boolean') lastTurnCompleted = timing.lastTurnCompleted
|
|
1802
|
+
}
|
|
1803
|
+
let tokens = null
|
|
1804
|
+
const usage = values?.tokenUsage
|
|
1805
|
+
if (usage) {
|
|
1806
|
+
// 四个桶互不重叠(dsh-token-meter 的注释:思考 token 已算在 output 里,不再加一次)。
|
|
1807
|
+
tokens = (Number(usage.uncachedInputTokens) || 0) + (Number(usage.outputTokens) || 0)
|
|
1808
|
+
+ (Number(usage.cacheReadTokens) || 0) + (Number(usage.cacheWriteTokens) || 0)
|
|
1809
|
+
}
|
|
1810
|
+
return { tokens, durationMs, lastTurnCompleted }
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* 当前绑定会话派出的子智能体清单(含后代,按目录顺序)。
|
|
1815
|
+
*
|
|
1816
|
+
* `listDescendants` 读的是父会话自己的目录**事实**,不加载也不唤醒任何子代理——
|
|
1817
|
+
* 所以列一下很便宜,也不会把睡着的子代理弄醒。
|
|
1818
|
+
*/
|
|
1819
|
+
async function listSubagents(parentSessionId) {
|
|
1820
|
+
const runtime = subagentServices.runtime
|
|
1821
|
+
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
1822
|
+
if (typeof runtime?.listDescendants !== 'function') {
|
|
1823
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供子智能体。' }
|
|
1824
|
+
}
|
|
1825
|
+
let rows
|
|
1826
|
+
try {
|
|
1827
|
+
rows = await runtime.listDescendants(parentSessionId)
|
|
1828
|
+
} catch (err) {
|
|
1829
|
+
return { ok: false, error: subagentFailure(err, '读不到子智能体') }
|
|
1830
|
+
}
|
|
1831
|
+
const out = []
|
|
1832
|
+
for (const row of rows ?? []) {
|
|
1833
|
+
if (!row) continue
|
|
1834
|
+
// 有一支目录读不动时,DSH 拿一行「诊断」顶上来(corrupt / unsupported / unavailable)。
|
|
1835
|
+
// 如实转达,别当空的吞掉——吞了的表现是「明明有子智能体,却一个都不显示」。
|
|
1836
|
+
if (row.kind === 'diagnostic') {
|
|
1837
|
+
out.push({ id: String(row.id), diagnostic: String(row.reason ?? 'unavailable') })
|
|
1838
|
+
continue
|
|
1839
|
+
}
|
|
1840
|
+
const metrics = subagentMetrics(row.id)
|
|
1841
|
+
out.push({
|
|
1842
|
+
id: String(row.id),
|
|
1843
|
+
parentId: String(row.parentId ?? ''),
|
|
1844
|
+
depth: Number(row.depth) || 1,
|
|
1845
|
+
// 只有「可续接」谈得上继续聊;两个值之外的都按一次性处理——未知模式不许接话。
|
|
1846
|
+
mode: row.mode === 'continuable' ? 'continuable' : 'one-shot',
|
|
1847
|
+
label: typeof row.label === 'string' ? row.label : '',
|
|
1848
|
+
running: row.activity === 'running',
|
|
1849
|
+
tokens: metrics.tokens,
|
|
1850
|
+
durationMs: metrics.durationMs,
|
|
1851
|
+
lastTurnCompleted: metrics.lastTurnCompleted,
|
|
1852
|
+
})
|
|
1853
|
+
}
|
|
1854
|
+
return { ok: true, subagents: out, boundSessionId: parentSessionId }
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* 某个子智能体**自己的运行过程**。
|
|
1859
|
+
*
|
|
1860
|
+
* 数据来源和主会话**完全同一条**:`sessionQuery.readSession(childId)` 拿原始事件,
|
|
1861
|
+
* `replayHistory` 重放成聊天记录(和实时那条路共用同一个提取器)。电脑端「点进去
|
|
1862
|
+
* 看它跑了什么」看的也是它自己的会话记录,两边因此长得一样——各写一套迟早会走散。
|
|
1863
|
+
*
|
|
1864
|
+
* 地址是手机传上来的,**不能拿它当权限**:先确认它真的挂在当前绑定会话名下。
|
|
1865
|
+
*/
|
|
1866
|
+
async function subagentTranscript(childId, parentSessionId) {
|
|
1867
|
+
if (!childId) return { ok: false, error: '没说要读哪一道子智能体。' }
|
|
1868
|
+
const listed = await listSubagents(parentSessionId)
|
|
1869
|
+
if (!listed.ok) return listed
|
|
1870
|
+
const row = listed.subagents.find((s) => s.id === childId)
|
|
1871
|
+
if (!row) return { ok: false, error: '这道子智能体不在当前会话名下。' }
|
|
1872
|
+
const q = treeServices.query
|
|
1873
|
+
if (typeof q?.readSession !== 'function') {
|
|
1874
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供读取会话记录的能力。' }
|
|
1875
|
+
}
|
|
1876
|
+
let snap
|
|
1877
|
+
try {
|
|
1878
|
+
snap = await q.readSession(childId)
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
return { ok: false, error: subagentFailure(err, '读不到它的记录') }
|
|
1881
|
+
}
|
|
1882
|
+
const events = Array.isArray(snap?.events) ? snap.events : []
|
|
1883
|
+
if (!events.length) {
|
|
1884
|
+
// 一次性子智能体跑完、记录已被回收时就是这里。**如实说没有记录**,不拿
|
|
1885
|
+
// 「读不到」冒充「它没干过活」——这两件事对用户含义不同。
|
|
1886
|
+
return {
|
|
1887
|
+
ok: true, mode: row.mode, running: row.running,
|
|
1888
|
+
entries: [], reclaimed: true, truncated: false, note: null,
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
const capped = capEntries(replayHistory(events))
|
|
1892
|
+
return {
|
|
1893
|
+
ok: true,
|
|
1894
|
+
mode: row.mode,
|
|
1895
|
+
running: row.running,
|
|
1896
|
+
entries: capped.entries,
|
|
1897
|
+
reclaimed: false,
|
|
1898
|
+
truncated: capped.dropped > 0,
|
|
1899
|
+
note: capped.dropped > 0 ? `只显示最近一段(读取上限:${HISTORY_MAX_ENTRIES} 条)` : null,
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* 停:只停**当前这一轮**,不销毁这道子智能体(排队里的消息、它的后代都留着)。
|
|
1905
|
+
*
|
|
1906
|
+
* 权限走「人」这条:`interruptByParent(childId, parentSessionId, mode)` 拿这三个当凭据,
|
|
1907
|
+
* 它**不做父 Agent 在线检查**——这正是「父会话不在线,也能停下正在跑的它」的原因。
|
|
1908
|
+
* 一次性的没有可停的轮次,这里就挡掉,不往 DSH 递。
|
|
1909
|
+
*/
|
|
1910
|
+
async function stopSubagent(childId, parentSessionId, mode) {
|
|
1911
|
+
const runtime = subagentServices.runtime
|
|
1912
|
+
if (!childId) return { ok: false, error: '没说要停哪一道子智能体。' }
|
|
1913
|
+
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
1914
|
+
if (mode !== 'continuable') {
|
|
1915
|
+
return { ok: false, error: '这是一次性子智能体,跑完就结束,没有可停的轮次。' }
|
|
1916
|
+
}
|
|
1917
|
+
if (typeof runtime?.interruptByParent !== 'function') {
|
|
1918
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供停止子智能体的能力。' }
|
|
1919
|
+
}
|
|
1920
|
+
try {
|
|
1921
|
+
await runtime.interruptByParent(childId, parentSessionId, 'continuable')
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
return { ok: false, error: subagentFailure(err, '停不下来') }
|
|
1924
|
+
}
|
|
1925
|
+
// 和主会话那条同一个口径:递进去不等于停稳,界面上得等它的状态翻过来。
|
|
1926
|
+
return { ok: true }
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* 继续:往这道子智能体里递一句**人**的话。
|
|
1931
|
+
*
|
|
1932
|
+
* `prompt` 把这条记成 `source.kind === 'user'`(带 rpcId),也就是「你在它旁边说了
|
|
1933
|
+
* 一句」,而不是「别的 agent 转达的」。delivery 选 queue(排到它当前这轮之后)——
|
|
1934
|
+
* 和手机自己对主会话的做法一致:主会话正在跑时,手机上发的指令也是排队,不打断。
|
|
1935
|
+
*
|
|
1936
|
+
* requestId 是这条消息的身份,**每次都得新铸一个**:复用同一个会被判成重复投递。
|
|
1937
|
+
*/
|
|
1938
|
+
async function askSubagent(childId, parentSessionId, mode, text) {
|
|
1939
|
+
const runtime = subagentServices.runtime
|
|
1940
|
+
if (!childId) return { ok: false, error: '没说要跟哪一道子智能体说话。' }
|
|
1941
|
+
if (!parentSessionId) return { ok: false, error: '手机上还没有绑定会话。' }
|
|
1942
|
+
if (!text) return { ok: false, error: '话是空的。' }
|
|
1943
|
+
if (mode !== 'continuable') {
|
|
1944
|
+
return { ok: false, error: '这是一次性子智能体,跑完就结束了,接不了话。' }
|
|
1945
|
+
}
|
|
1946
|
+
if (typeof runtime?.prompt !== 'function') {
|
|
1947
|
+
return { ok: false, error: '这台电脑上的 DSH 没提供继续子智能体对话的能力。' }
|
|
1948
|
+
}
|
|
1949
|
+
let requestId
|
|
1950
|
+
try {
|
|
1951
|
+
requestId = randomUUID()
|
|
1952
|
+
} catch (err) {
|
|
1953
|
+
// 兜底:uuid 拿不到也不该让这句话发不出去。
|
|
1954
|
+
requestId = 'mini-' + Date.now() + '-' + randomBytes(8).toString('hex')
|
|
1955
|
+
}
|
|
1956
|
+
try {
|
|
1957
|
+
const receipt = await runtime.prompt({
|
|
1958
|
+
requestId,
|
|
1959
|
+
parentSessionId,
|
|
1960
|
+
childSessionId: childId,
|
|
1961
|
+
mode: 'continuable',
|
|
1962
|
+
delivery: 'queue',
|
|
1963
|
+
content: [{ type: 'text', text }],
|
|
1964
|
+
})
|
|
1965
|
+
return { ok: true, messageId: receipt?.messageId ? String(receipt.messageId) : null }
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
return { ok: false, error: subagentFailure(err, '这句话没送出去') }
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1473
1971
|
// ------------------------------------------------------------------
|
|
1474
1972
|
// 手机上答题:把电脑这边的提问推到手机,等它答,再把答案交还给电脑。
|
|
1475
1973
|
//
|
|
@@ -1507,6 +2005,23 @@ export function apply(ctx, config = {}) {
|
|
|
1507
2005
|
// 能力从 `miniControl` 这个固定取用点拿(服务启动时主动放进去的),
|
|
1508
2006
|
// **不再经过 createMiniServer 的回执**——实测那个回执兑现出来是 undefined。
|
|
1509
2007
|
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
2008
|
+
/**
|
|
2009
|
+
* **飞书那条路此刻能不能接这道题。**
|
|
2010
|
+
*
|
|
2011
|
+
* 三个条件,缺一不接:
|
|
2012
|
+
* ① 通路在、而且**有一轮从飞书发起的指令还没落定**(见 lib/lark.js 的 serving)——
|
|
2013
|
+
* 手机上发起的轮次不该因此抢掉电脑上的弹窗;
|
|
2014
|
+
* ② 这道题在飞书里写得成一段纯文本(`feishuQuestionText` 写不成时给空串:
|
|
2015
|
+
* 两道以上的题里混着一道没有选项的题,数字协议表达不了);
|
|
2016
|
+
* ③ 那段话不超过一条消息的上限。
|
|
2017
|
+
* 接不了就当飞书那边不存在,电脑和手机照旧——**一个字都不变**。
|
|
2018
|
+
*/
|
|
2019
|
+
const askText = feishuQuestionText(questions)
|
|
2020
|
+
const feishuReady = Boolean(feishu)
|
|
2021
|
+
&& askText !== ''
|
|
2022
|
+
&& askText.length <= FEISHU_TEXT_LIMIT
|
|
2023
|
+
&& typeof feishu.serving === 'function'
|
|
2024
|
+
&& feishu.serving()
|
|
1510
2025
|
/**
|
|
1511
2026
|
* **让路的那三种情况,每一种都留一行。**
|
|
1512
2027
|
*
|
|
@@ -1520,8 +2035,8 @@ export function apply(ctx, config = {}) {
|
|
|
1520
2035
|
console.log(`[dsh-mini-remote] 提问让路:问的=${agentId ?? '无'},手机绑的=${bound ?? '无'},题目数=${questions.length}`)
|
|
1521
2036
|
return next()
|
|
1522
2037
|
}
|
|
1523
|
-
if (!phoneReady) {
|
|
1524
|
-
console.log('[dsh-mini-remote]
|
|
2038
|
+
if (!phoneReady && !feishuReady) {
|
|
2039
|
+
console.log('[dsh-mini-remote] 提问让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
|
|
1525
2040
|
return next()
|
|
1526
2041
|
}
|
|
1527
2042
|
if (typeof miniControl.askPhone !== 'function') {
|
|
@@ -1529,11 +2044,28 @@ export function apply(ctx, config = {}) {
|
|
|
1529
2044
|
return next()
|
|
1530
2045
|
}
|
|
1531
2046
|
// ——— 同步交出"将来会有答案"的东西;下面的等待都在这里,不再有同步判断 ———
|
|
1532
|
-
console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`
|
|
1533
|
-
|
|
2047
|
+
console.log(`[dsh-mini-remote] 提问推给手机:${questions.length} 题,等它答`
|
|
2048
|
+
+ (feishuReady ? '(飞书那边一起问)' : ''))
|
|
2049
|
+
/**
|
|
2050
|
+
* `askId` 是这道题在**手机页那份清单**里的编号,由 `onPending` 当场交出来。
|
|
2051
|
+
* 它只用来问一件事:「飞书那边还挂着这道题吗」(见 keepAlive)——
|
|
2052
|
+
* 题目的胜负、谁先答谁生效,全在 lib/server.js 那份清单里,这里不另判。
|
|
2053
|
+
*/
|
|
2054
|
+
let askId = null
|
|
2055
|
+
return miniControl.askPhone(questions, agentId, {
|
|
2056
|
+
onPending: (id) => {
|
|
2057
|
+
askId = id
|
|
2058
|
+
// 飞书那边写成一段普通文字问出来。它没接(写不成、或发不出去就当场撤回)时,
|
|
2059
|
+
// keepAlive 下一次为 false,题照旧还给电脑。
|
|
2060
|
+
feishu?.openAsk?.({ id, kind: 'question', payload: questions })
|
|
2061
|
+
},
|
|
2062
|
+
// 「手机全断了要不要收摊」的第二个判据:飞书那边还挂着这道题就别收。
|
|
2063
|
+
keepAlive: () => Boolean(feishu && askId && feishu.isWaiting?.(askId)),
|
|
2064
|
+
}).then((answer) => {
|
|
1534
2065
|
// 手机中途没了:把问题还给电脑,那边照常弹窗——不会两头都答不上。
|
|
1535
2066
|
// 这一句日志是「手机上答了、电脑上没动静」那类现象唯一能落下来的痕迹:
|
|
1536
2067
|
// 有它才知道手机那份答案没接上(断线、或者答晚了一步),而不是插件没在跑。
|
|
2068
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1537
2069
|
if (!answer) {
|
|
1538
2070
|
console.log('[dsh-mini-remote] 手机上没答成(连接断了、或者答晚了),这题还给电脑')
|
|
1539
2071
|
return next()
|
|
@@ -1542,6 +2074,7 @@ export function apply(ctx, config = {}) {
|
|
|
1542
2074
|
return answer
|
|
1543
2075
|
}).catch((err) => {
|
|
1544
2076
|
// 我这边出任何问题都不能把提问吞掉,一律让回电脑。
|
|
2077
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1545
2078
|
console.log('[dsh-mini-remote] 接管提问出错,让回电脑:' + String(err))
|
|
1546
2079
|
return next()
|
|
1547
2080
|
})
|
|
@@ -1562,20 +2095,40 @@ export function apply(ctx, config = {}) {
|
|
|
1562
2095
|
// 这次到底要跑什么:审批请求只带调用编号,参数在会话事件里记着的那份。
|
|
1563
2096
|
const command = request?.callId ? (toolCalls.get(String(request.callId)) ?? '') : ''
|
|
1564
2097
|
const phoneReady = typeof miniControl.hasPhone === 'function' ? miniControl.hasPhone() : false
|
|
2098
|
+
// 飞书那条路此刻能不能接:判据和上面那条提问链**一模一样**(可能接、写得成、不超长)。
|
|
2099
|
+
const view = {
|
|
2100
|
+
toolName: typeof toolName === 'string' && toolName ? toolName : '某个工具',
|
|
2101
|
+
reason: typeof request?.reason === 'string' ? request.reason : '',
|
|
2102
|
+
command: typeof command === 'string' ? command : '',
|
|
2103
|
+
}
|
|
2104
|
+
const approvalAskText = approvalText(view)
|
|
2105
|
+
const feishuReady = Boolean(feishu)
|
|
2106
|
+
&& approvalAskText.length <= FEISHU_TEXT_LIMIT
|
|
2107
|
+
&& typeof feishu.serving === 'function'
|
|
2108
|
+
&& feishu.serving()
|
|
1565
2109
|
if (!agentId || agentId !== bound) {
|
|
1566
2110
|
console.log(`[dsh-mini-remote] 审批让路:批的=${agentId ?? '无'},手机绑的=${bound ?? '无'}`)
|
|
1567
2111
|
return next()
|
|
1568
2112
|
}
|
|
1569
|
-
if (!phoneReady) {
|
|
1570
|
-
console.log('[dsh-mini-remote]
|
|
2113
|
+
if (!phoneReady && !feishuReady) {
|
|
2114
|
+
console.log('[dsh-mini-remote] 审批让路:手机和飞书此刻都接不上(页面没开、锁屏或断网,飞书那头没有在等的轮次),交给电脑')
|
|
1571
2115
|
return next()
|
|
1572
2116
|
}
|
|
1573
2117
|
if (typeof miniControl.askApproval !== 'function') {
|
|
1574
2118
|
console.log('[dsh-mini-remote] 审批让路:拿不到 askApproval(手机服务还没起来),交给电脑')
|
|
1575
2119
|
return next()
|
|
1576
2120
|
}
|
|
1577
|
-
console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`
|
|
1578
|
-
|
|
2121
|
+
console.log(`[dsh-mini-remote] 审批推给手机:${toolName},等它拍板`
|
|
2122
|
+
+ (feishuReady ? '(飞书那边一起问)' : ''))
|
|
2123
|
+
let askId = null
|
|
2124
|
+
return miniControl.askApproval(request, agentId, command, {
|
|
2125
|
+
onPending: (id) => {
|
|
2126
|
+
askId = id
|
|
2127
|
+
feishu?.openAsk?.({ id, kind: 'approval', payload: view })
|
|
2128
|
+
},
|
|
2129
|
+
keepAlive: () => Boolean(feishu && askId && feishu.isWaiting?.(askId)),
|
|
2130
|
+
}).then((outcome) => {
|
|
2131
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1579
2132
|
if (!outcome) {
|
|
1580
2133
|
console.log('[dsh-mini-remote] 手机上没拍成(连接断了、或者拍晚了),这条还给电脑')
|
|
1581
2134
|
return next()
|
|
@@ -1583,6 +2136,7 @@ export function apply(ctx, config = {}) {
|
|
|
1583
2136
|
console.log('[dsh-mini-remote] 手机上拍板了:' + outcome)
|
|
1584
2137
|
return outcome
|
|
1585
2138
|
}).catch((err) => {
|
|
2139
|
+
if (askId) feishu?.expireAsk?.(askId)
|
|
1586
2140
|
console.log('[dsh-mini-remote] 接管审批出错,让回电脑:' + String(err))
|
|
1587
2141
|
return next()
|
|
1588
2142
|
})
|
|
@@ -1782,7 +2336,7 @@ export function apply(ctx, config = {}) {
|
|
|
1782
2336
|
const nav = {
|
|
1783
2337
|
listWorkspaces: (runningIds, force, includeEmpty) =>
|
|
1784
2338
|
listWorkspaces({ registry: treeServices.registry, query: treeServices.query, runningIds, force, includeEmpty }),
|
|
1785
|
-
listSessionsOf: (workspaceId, limit, force) =>
|
|
2339
|
+
listSessionsOf: (workspaceId, limit, force, onTitle) =>
|
|
1786
2340
|
listSessionsOf({
|
|
1787
2341
|
registry: treeServices.registry,
|
|
1788
2342
|
query: treeServices.query,
|
|
@@ -1792,6 +2346,7 @@ export function apply(ctx, config = {}) {
|
|
|
1792
2346
|
workspaceId,
|
|
1793
2347
|
limit,
|
|
1794
2348
|
force,
|
|
2349
|
+
onTitle,
|
|
1795
2350
|
}),
|
|
1796
2351
|
// 建会话:先用 inject 拿到的那个,拿不到就现读一次——`ctx.get` 对没写进 inject
|
|
1797
2352
|
// 的服务名是**返回 undefined**(不是抛错),所以这么写是安全的。
|
|
@@ -1817,6 +2372,13 @@ export function apply(ctx, config = {}) {
|
|
|
1817
2372
|
setPermission: (sessionId, name) =>
|
|
1818
2373
|
setPreset({ service: presets.service, session: sessionFor(sessionId), name }),
|
|
1819
2374
|
|
|
2375
|
+
// 子智能体(2026-10-03)。四条都取**当前绑定会话**那一份,和上面 send / stop
|
|
2376
|
+
// 同一套口径:手机上遥控的是你正看着的那个会话,不该牵动你在电脑上另开的那个。
|
|
2377
|
+
subagents: (sessionId) => listSubagents(sessionId),
|
|
2378
|
+
subagentTranscript: (childId, sessionId) => subagentTranscript(childId, sessionId),
|
|
2379
|
+
stopSubagent: (childId, sessionId, mode) => stopSubagent(childId, sessionId, mode),
|
|
2380
|
+
askSubagent: (childId, sessionId, mode, text) => askSubagent(childId, sessionId, mode, text),
|
|
2381
|
+
|
|
1820
2382
|
// 模型与思考强度(2026-09-25 用户要求:手机上要能改每个会话的模型和强度)。
|
|
1821
2383
|
// `canCreate.controller` 就是 `ctx.sessionController`——和上面 createSession 同一个东西。
|
|
1822
2384
|
//
|
|
@@ -1982,6 +2544,12 @@ export function apply(ctx, config = {}) {
|
|
|
1982
2544
|
// 手机点开会话(或刷新页面)时,把那条会话的完整历史读出来。
|
|
1983
2545
|
onSessionOpened: (sessionId) => ensureHistory(sessionId),
|
|
1984
2546
|
tree: nav,
|
|
2547
|
+
// 正文里写相对路径的图(``)相对的是**那段对话的工作目录**,
|
|
2548
|
+
// 而不一定是插件根。所以把当前已注册工作区的目录一并递过去当额外候选根。
|
|
2549
|
+
// `treeServices.registry` 就是 DSH 的 workspaceRegistry(注入见上面那段),
|
|
2550
|
+
// 服务这边拿不到 ctx,只能由插件给——传函数不传快照,理由和 treeServices 一样:
|
|
2551
|
+
// 服务每次请求现读,工作区是随时会变的。
|
|
2552
|
+
imageRoots: () => (treeServices.registry?.list?.() ?? []).map((w) => w?.path),
|
|
1985
2553
|
// 目录浏览自己读盘(理由见 lib/browse.js 开头:DSH 的目录选择器在
|
|
1986
2554
|
// 「Windows + 只绑回环」时会判成 native,那套动词是直接拒绝的,
|
|
1987
2555
|
// 会在电脑屏幕上弹框——而用户正在外面用手机)。
|
|
@@ -2030,6 +2598,29 @@ export function apply(ctx, config = {}) {
|
|
|
2030
2598
|
}
|
|
2031
2599
|
})
|
|
2032
2600
|
|
|
2601
|
+
// ------------------------------------------------------------------
|
|
2602
|
+
// 飞书那条路自己的生死
|
|
2603
|
+
// ------------------------------------------------------------------
|
|
2604
|
+
/**
|
|
2605
|
+
* 单独一个 effect,不和手机服务那一个搅在一起:这两样各有各的失败方式,
|
|
2606
|
+
* 一个起不来不该把另一个拖下水。
|
|
2607
|
+
*
|
|
2608
|
+
* `closed` 那一道和手机服务同一个道理——建连接是异步的,而插件随时可能被卸载。
|
|
2609
|
+
* 建好时如果已经卸了,当场把它关掉,不能留下一条没人管的连接和它自己的定时器。
|
|
2610
|
+
*/
|
|
2611
|
+
ctx.effect(() => {
|
|
2612
|
+
let closed = false
|
|
2613
|
+
const start = async () => {
|
|
2614
|
+
await bringFeishuUp()
|
|
2615
|
+
if (closed) bringFeishuDown()
|
|
2616
|
+
}
|
|
2617
|
+
start()
|
|
2618
|
+
return () => {
|
|
2619
|
+
closed = true
|
|
2620
|
+
bringFeishuDown()
|
|
2621
|
+
}
|
|
2622
|
+
})
|
|
2623
|
+
|
|
2033
2624
|
// ------------------------------------------------------------------
|
|
2034
2625
|
// DSH 设置页的「手机遥控」标签从这里取数据
|
|
2035
2626
|
// ------------------------------------------------------------------
|
|
@@ -2069,6 +2660,9 @@ export function apply(ctx, config = {}) {
|
|
|
2069
2660
|
error: serverError
|
|
2070
2661
|
? `手机服务起不来:${serverError}\n端口可以在设置文件里改(settings.json 里的 port)。`
|
|
2071
2662
|
: '手机服务还在启动,过几秒刷新一下。',
|
|
2663
|
+
// 手机服务没起来,不影响飞书那一块——它和手机服务没有关系,谁用谁的。
|
|
2664
|
+
// 所以这一条早退路径上也带着 feishu,用户至少改得动它。
|
|
2665
|
+
feishu: feishuStatus(),
|
|
2072
2666
|
},
|
|
2073
2667
|
}
|
|
2074
2668
|
}
|
|
@@ -2096,6 +2690,8 @@ export function apply(ctx, config = {}) {
|
|
|
2096
2690
|
* 端口被占时服务会自己往后让,用配置值会指错。
|
|
2097
2691
|
*/
|
|
2098
2692
|
serve: await serveState(server.port),
|
|
2693
|
+
// 飞书那一块的现状(**永远不含 appSecret**,只回答「有没有凭据」)。
|
|
2694
|
+
feishu: feishuStatus(),
|
|
2099
2695
|
})
|
|
2100
2696
|
return { status: payload.ok ? 200 : 500, body: payload }
|
|
2101
2697
|
}
|
|
@@ -2264,6 +2860,57 @@ export function apply(ctx, config = {}) {
|
|
|
2264
2860
|
},
|
|
2265
2861
|
})
|
|
2266
2862
|
|
|
2863
|
+
/**
|
|
2864
|
+
* 飞书那一块的配置:开关、两个凭据、两个允许名单。
|
|
2865
|
+
*
|
|
2866
|
+
* 和公网开关、改密码一样**只允许本机调用**:那是一对凭据,改它等于改这个插件
|
|
2867
|
+
* 在飞书那边的身份。同一个 Wi-Fi 下的别人不该替你做主。
|
|
2868
|
+
*
|
|
2869
|
+
* 两条不成文但必须守的规矩:
|
|
2870
|
+
* 1. **appSecret 从不回显**(见 feishuStatus)。所以界面上那个框天生是空的,
|
|
2871
|
+
* 于是「留空」只能解释成「不改动原来那份」——不然用户每点一次保存都得重新
|
|
2872
|
+
* 贴一遍密码,贴错一次就是一段查不出原因的鉴权失败。
|
|
2873
|
+
* 2. 存完**就地重建连接**。飞书的长连接是绑在凭据上的,不重建的话用户会看到
|
|
2874
|
+
* 「填好了、开关也开着,就是不回消息」,而屏幕上没有任何东西提示他原因。
|
|
2875
|
+
*/
|
|
2876
|
+
const offFeishu = webServer.register({
|
|
2877
|
+
kind: 'exact',
|
|
2878
|
+
path: '/mini-remote/feishu',
|
|
2879
|
+
handler: async (req, res) => {
|
|
2880
|
+
if (!localOnly(req, res)) return
|
|
2881
|
+
if (req.method !== 'POST') {
|
|
2882
|
+
sendJson(res, 405, { ok: false, error: '这个地址只接受 POST。' })
|
|
2883
|
+
return
|
|
2884
|
+
}
|
|
2885
|
+
let want
|
|
2886
|
+
try {
|
|
2887
|
+
want = await readBody(req)
|
|
2888
|
+
} catch (err) {
|
|
2889
|
+
sendJson(res, 400, { ok: false, error: `请求体读不了:${err?.message ?? err}` })
|
|
2890
|
+
return
|
|
2891
|
+
}
|
|
2892
|
+
const next = {
|
|
2893
|
+
enabled: Boolean(want?.enabled),
|
|
2894
|
+
appId: typeof want?.appId === 'string' ? want.appId.trim() : '',
|
|
2895
|
+
appSecret: typeof want?.appSecret === 'string' ? want.appSecret.trim() : '',
|
|
2896
|
+
openIds: typeof want?.openIds === 'string' ? want.openIds.trim() : '',
|
|
2897
|
+
chatIds: typeof want?.chatIds === 'string' ? want.chatIds.trim() : '',
|
|
2898
|
+
}
|
|
2899
|
+
// 留空 = 保留原来那份(理由见上面第 1 条)。
|
|
2900
|
+
if (!next.appSecret) next.appSecret = String(settings.feishu?.appSecret ?? '')
|
|
2901
|
+
settings.feishu = next
|
|
2902
|
+
persistSettings()
|
|
2903
|
+
if (next.enabled) await restartFeishu()
|
|
2904
|
+
else bringFeishuDown()
|
|
2905
|
+
try {
|
|
2906
|
+
const { status, body } = await pairingPayload()
|
|
2907
|
+
sendJson(res, status, body)
|
|
2908
|
+
} catch (err) {
|
|
2909
|
+
sendJson(res, 500, { ok: false, error: `改完之后读不到状态:${err?.message ?? err}` })
|
|
2910
|
+
}
|
|
2911
|
+
},
|
|
2912
|
+
})
|
|
2913
|
+
|
|
2267
2914
|
/**
|
|
2268
2915
|
* 卸载时把注册过的路由全部注销。
|
|
2269
2916
|
*
|
|
@@ -2271,13 +2918,14 @@ export function apply(ctx, config = {}) {
|
|
|
2271
2918
|
* 还挂在服务器上,指向一个已经死掉的闭包——再有人打过来就是莫名其妙的报错。
|
|
2272
2919
|
*
|
|
2273
2920
|
* 2026-09-24 补:`offToken` 一直是漏的(改密码那条路由注册了却没注销),
|
|
2274
|
-
* 加 HTTPS
|
|
2921
|
+
* 加 HTTPS 这条时对照着数了一遍才发现。五条注册,五条注销,现在对得上。
|
|
2275
2922
|
*/
|
|
2276
2923
|
return () => {
|
|
2277
2924
|
offGet?.()
|
|
2278
2925
|
offTunnel?.()
|
|
2279
2926
|
offServe?.()
|
|
2280
2927
|
offToken?.()
|
|
2928
|
+
offFeishu?.()
|
|
2281
2929
|
}
|
|
2282
2930
|
})
|
|
2283
2931
|
}
|