dsh-mobile-plugin 0.1.90 → 0.1.91
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/lib/impl.js +8 -1
- package/lib/mobile/approvals.js +350 -0
- package/lib/mobile/questions.js +267 -45
- package/lib/mobile/session-send.js +3 -3
- package/lib/mobile/sessions.js +9 -3
- package/lib/mobile/stream.js +29 -0
- package/lib/routes/mobile-api.js +2 -1
- package/package.json +1 -1
package/lib/impl.js
CHANGED
|
@@ -32,6 +32,8 @@ const { createPreviewService } = require('./relay/preview.js')
|
|
|
32
32
|
// 入口按两条盘上布局分别找(嵌在插件包内 / 与插件包同级),见 shared/credentials-entry.js。
|
|
33
33
|
const credentials = require('./shared/credentials-entry.js').credentialsEntry()
|
|
34
34
|
const { createApiProxy } = require('./mobile/host-bridge.js')
|
|
35
|
+
const { installApprovalBridge } = require('./mobile/approvals.js')
|
|
36
|
+
const { installQuestionBridge } = require('./mobile/questions.js')
|
|
35
37
|
// 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
|
|
36
38
|
const activity = require('./mobile/activity.cjs')
|
|
37
39
|
const { installAutoArchive } = require('./auto-archive.cjs')
|
|
@@ -225,7 +227,10 @@ function apply(ctx) {
|
|
|
225
227
|
// —— host-bridge.js 是翻译到 0.2.0 的 `ctx.get('typertGateway')` 的。所以按运行时
|
|
226
228
|
// 有没有这个包二选一:有就用宿主那份真 apiProxy,没有才装兼容服务。
|
|
227
229
|
// (不能靠 `ctx.get('apiProxy')` 判断:宿主是后于本插件装载的,那时它还不在。)
|
|
228
|
-
|
|
230
|
+
// 这两条二选一,判据同源:0.1.x 宿主自带真 apiProxy(审批帧与回答都还走得通),
|
|
231
|
+
// 0.2.x 才需要兼容桥,也才需要审批桥(见 mobile/approvals.js 的文件头)。
|
|
232
|
+
const hasHostApiProxy = runtimeProvidesApiProxy()
|
|
233
|
+
if (!hasHostApiProxy) ctx.reflect.provide('apiProxy', createApiProxy(ctx))
|
|
229
234
|
|
|
230
235
|
const state = core.loadState()
|
|
231
236
|
// 0.2.0 之前是 `ctx.settings.register(REMOTE_CONTROL_SETTINGS_NAMESPACE,
|
|
@@ -296,6 +301,8 @@ function apply(ctx) {
|
|
|
296
301
|
|
|
297
302
|
// Keep stale project conversations out of the host's session browser.
|
|
298
303
|
// This is deliberately Host-side so it remains active even with no operator present.
|
|
304
|
+
if (!hasHostApiProxy) ctx.effect(() => installApprovalBridge(ctx), `${name}: host approval bridge`)
|
|
305
|
+
if (!hasHostApiProxy) ctx.effect(() => installQuestionBridge(ctx), `${name}: host question bridge`)
|
|
299
306
|
ctx.effect(() => installAutoArchive(ctx), `${name}: automatic session archive`)
|
|
300
307
|
ctx.effect(() => {
|
|
301
308
|
// Profiles without the optional tools injection still support core remote control.
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机端审批桥(DSH 0.2.x)。
|
|
5
|
+
*
|
|
6
|
+
* ≤0.1.x 的宿主把审批当成 apiProxy 事件流上的 server-request:
|
|
7
|
+
* `{rpcId, payload:{type:'approval/requested', approvalId, toolName, reason}}`,手机凭**帧级
|
|
8
|
+
* rpcId** 回答(`/approval/respond` 的 rpcId 就是宿主 `pendingApprovals` 表的键)。0.2.x
|
|
9
|
+
* 把 apiProxy 整个删了:一次审批 = 会话日志里的 `approval/asked` / `approval/decided` 事件
|
|
10
|
+
* 对 + 一个**进程内** waterfall 钩子 `approval/request`(`dsh-user-approval` 发起,
|
|
11
|
+
* `dsh-api-remotes` 把它转给桌面窗口那类 owner 连接,`dsh-acp` 也接同一个钩子)。
|
|
12
|
+
* 帧级 rpcId 就此消失,而 App 的审批面板**刻意**只认带 rpcId 的帧(不给一个按了必然 409
|
|
13
|
+
* 的按钮),于是「桌面有卡、手机上一片空白,连一条 409 都没有」。
|
|
14
|
+
*
|
|
15
|
+
* 这里按 0.2.x 的形状把那半条通道补起来,**手机端契约一行不改**(App 仍然收
|
|
16
|
+
* `approval/requested` + 帧级 rpcId,仍然 POST `/approval/respond`):
|
|
17
|
+
*
|
|
18
|
+
* 1. 听 `session/event` 的 `approval/asked`:宿主在发起审批前**必写**这条事件(决定走
|
|
19
|
+
* 哪条回答通道都不影响它),本桥据此铸一个自己的 rpcId 并把卡推给正在看这个会话的
|
|
20
|
+
* 手机。approvalId 直接用宿主自己的 id —— App 靠它把 `approval/decided` 对上、自动
|
|
21
|
+
* 收掉那张卡。
|
|
22
|
+
* 2. 听 `approval/request` 钩子(**prepend**,见下):把这台手机挂成候选人并和下游竞速。
|
|
23
|
+
* 下游(桌面窗口 / ACP / 宿主默认的 fail-closed)照旧拿到请求、卡片照旧出现,
|
|
24
|
+
* 手机答得早就用手机的,桌面答得早就用桌面的,输的那张卡由结算帧收掉。
|
|
25
|
+
* 3. `/approval/respond` 拿 rpcId 找到挂起项,把结论交回钩子 —— 这才是真正让 agent
|
|
26
|
+
* 继续往下跑的那一步。
|
|
27
|
+
*
|
|
28
|
+
* ⚠️ 钩子上**没有**宿主 id(既没有 approvalId 也没有 rpcId),而且它和 `approval/asked`
|
|
29
|
+
* 谁先到取决于宿主的派发顺序(事件在 `request()` 里先 append、钩子随后派发,但两者都走
|
|
30
|
+
* 微任务)。所以两个来源共用一张挂起项表:先到的建项,后到的补字段(事件补真实
|
|
31
|
+
* approvalId,钩子补 displayReason 与 signal)。卡片在任一路径上都不等对方。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ 为什么必须 prepend:宿主自己的转发器(`dsh-api-remotes`)在桌面窗口开着时会一直等
|
|
34
|
+
* 桌面回答、**不调用 next()**,而且那些核心包必然先于本插件装载。老实按注册顺序排在后面
|
|
35
|
+
* 等于「桌面开着时手机永远收不到」,正是这次要修的现象。prepend 是本桥能生效的前提,
|
|
36
|
+
* 不是优化。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const { randomUUID } = require('node:crypto');
|
|
40
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
41
|
+
|
|
42
|
+
/** `/approval/respond` 只认这两种结论(与 App 的两个按钮一一对应)。 */
|
|
43
|
+
const ANSWER_OUTCOMES = new Set(['allowed-once', 'rejected']);
|
|
44
|
+
/** 宿主 `approval/decided` 的结论词表(`dsh-user-approval` 的 OUTCOMES)。 */
|
|
45
|
+
const DECISIONS = new Set(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
|
|
46
|
+
|
|
47
|
+
/** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
|
|
48
|
+
const watchers = new Map();
|
|
49
|
+
/** rpcId → entry:回答时按它定位。 */
|
|
50
|
+
const entries = new Map();
|
|
51
|
+
/** sessionId → entry[](保序):把钩子上的请求和事件里的审批对上。 */
|
|
52
|
+
const bySession = new Map();
|
|
53
|
+
|
|
54
|
+
function watcherCount(sessionId) {
|
|
55
|
+
const set = watchers.get(sessionId);
|
|
56
|
+
return set ? set.size : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function openEntries(sessionId) {
|
|
60
|
+
const list = bySession.get(sessionId);
|
|
61
|
+
return list ? list.filter((entry) => !entry.settled) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 卡片帧。形状严格照 App `utils/approvalFrames.ts` 认的那一种:**帧级 rpcId** + payload 上的
|
|
66
|
+
* approvalId;reason 为空时 App 会自己落到「工具 X 请求越权执行」,与桌面同一句兜底文案。
|
|
67
|
+
*/
|
|
68
|
+
function cardFrame(entry) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'approval/requested',
|
|
71
|
+
sessionId: entry.sessionId,
|
|
72
|
+
rpcId: entry.rpcId,
|
|
73
|
+
approvalId: entry.approvalId,
|
|
74
|
+
...(entry.toolName ? { toolName: entry.toolName } : {}),
|
|
75
|
+
...(entry.reason ? { reason: entry.reason } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 结算帧。App 按 approvalId 比对(`approvalResolved`),所以这条让手机立刻收卡,不必等
|
|
81
|
+
* 宿主的 `approval/decided` 绕一圈 —— 尤其是手机自己答的那一条,桌面那条路已经不需要了。
|
|
82
|
+
*/
|
|
83
|
+
function resolvedFrame(entry, outcome) {
|
|
84
|
+
return { type: 'approval/resolved', sessionId: entry.sessionId, approvalId: entry.approvalId, outcome };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function broadcast(sessionId, frame) {
|
|
88
|
+
const set = watchers.get(sessionId);
|
|
89
|
+
if (!set) return;
|
|
90
|
+
for (const sink of set) {
|
|
91
|
+
// 单条连接写失败(客户端正好走了)不影响别的手机拿到这张卡。
|
|
92
|
+
try { sink(frame); } catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pushCard(entry) {
|
|
97
|
+
if (entry.settled || watcherCount(entry.sessionId) === 0) return;
|
|
98
|
+
broadcast(entry.sessionId, cardFrame(entry));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 取(或建)一条挂起项。两个来源都走这里:按 callId → toolName 配对,配对不上的就新建。
|
|
103
|
+
*
|
|
104
|
+
* `unboundOnly` 只给钩子用:钩子绑上一条就已经接管了它(回答由那条钩子的 promise 决定),
|
|
105
|
+
* 第二条钩子请求绝不能共用同一条挂起项。反过来,事件那一侧必须能认领**已经绑上**的挂起项 ——
|
|
106
|
+
* 「钩子先到、`approval/asked` 后到」时就是靠这一步把真实的 approvalId 补进去的。
|
|
107
|
+
*/
|
|
108
|
+
function entryFor(sessionId, ask, { unboundOnly = false } = {}) {
|
|
109
|
+
const open = openEntries(sessionId).filter((entry) => !unboundOnly || !entry.bound);
|
|
110
|
+
// 两个来源拿到的是宿主**同一个** request 对象(`dsh-user-approval.request()` 把 req 原样
|
|
111
|
+
// 交给钩子,`approval/asked` 也照它记 toolName/callId),所以按 callId → toolName 配对是
|
|
112
|
+
// 精确的;配对不上就新建(宁可多一张卡,也不要两条审批共用一张卡)。
|
|
113
|
+
const match = open.find((entry) => ask.callId && entry.callId === ask.callId)
|
|
114
|
+
|| open.find((entry) => ask.toolName && entry.toolName === ask.toolName);
|
|
115
|
+
if (match) return match;
|
|
116
|
+
const entry = {
|
|
117
|
+
// rpcId 是本桥自己铸的:0.2.x 的宿主不再提供任何可作答的帧级 id,而 App 只认带 rpcId 的帧。
|
|
118
|
+
rpcId: `m-${randomUUID()}`,
|
|
119
|
+
sessionId,
|
|
120
|
+
// 钩子先到时还不知道宿主的 approvalId,先铸一个占位(只用于挂起项身份,回答不看它);
|
|
121
|
+
// `approval/asked` 一到就换成真 id,好让 App 能把 approval/decided 对上。
|
|
122
|
+
approvalId: ask.approvalId || `m-${randomUUID()}`,
|
|
123
|
+
toolName: ask.toolName,
|
|
124
|
+
callId: ask.callId,
|
|
125
|
+
reason: ask.reason,
|
|
126
|
+
bound: false,
|
|
127
|
+
settled: false,
|
|
128
|
+
wait: null,
|
|
129
|
+
release: null,
|
|
130
|
+
};
|
|
131
|
+
entry.wait = new Promise((resolve) => { entry.release = resolve; });
|
|
132
|
+
entries.set(entry.rpcId, entry);
|
|
133
|
+
let list = bySession.get(sessionId);
|
|
134
|
+
if (!list) { list = []; bySession.set(sessionId, list); }
|
|
135
|
+
list.push(entry);
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 结算一条挂起项:唤醒等待中的钩子、撤下挂起项、让手机收卡。重复调用无副作用。 */
|
|
140
|
+
function settle(entry, outcome) {
|
|
141
|
+
if (entry.settled) return false;
|
|
142
|
+
entry.settled = true;
|
|
143
|
+
entries.delete(entry.rpcId);
|
|
144
|
+
const list = bySession.get(entry.sessionId);
|
|
145
|
+
if (list) {
|
|
146
|
+
const index = list.indexOf(entry);
|
|
147
|
+
if (index >= 0) list.splice(index, 1);
|
|
148
|
+
if (list.length === 0) bySession.delete(entry.sessionId);
|
|
149
|
+
}
|
|
150
|
+
entry.release({ outcome });
|
|
151
|
+
broadcast(entry.sessionId, resolvedFrame(entry, outcome));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 手机接上/离开某条会话流。接入时补发仍然可作答的卡;断开只摘掉这一个 sink —— 挂起项本身
|
|
157
|
+
* 继续等(用户可能马上就回来),由宿主的 signal 中止或别人答掉来收尾。
|
|
158
|
+
*/
|
|
159
|
+
function attachSession(sessionId, sink) {
|
|
160
|
+
let set = watchers.get(sessionId);
|
|
161
|
+
if (!set) { set = new Set(); watchers.set(sessionId, set); }
|
|
162
|
+
set.add(sink);
|
|
163
|
+
// 补发:审批可能在手机还没看这个会话时就挂起了(用户在列表页点了进来),
|
|
164
|
+
// 而钩子上的请求还在等 —— 那时候这张卡仍然答得动。
|
|
165
|
+
for (const entry of openEntries(sessionId)) {
|
|
166
|
+
if (entry.bound) { try { sink(cardFrame(entry)); } catch { /* ignore */ } }
|
|
167
|
+
}
|
|
168
|
+
return () => {
|
|
169
|
+
set.delete(sink);
|
|
170
|
+
if (set.size === 0) watchers.delete(sessionId);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 会话摘要里的待处理标记(0.2.x 的宿主不会再下发这个字段,见 sessions.js 的用法)。 */
|
|
175
|
+
function hasPending(sessionId) {
|
|
176
|
+
return openEntries(sessionId).length > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 测试用:清空全部进程内状态(真实运行时由插件卸载时的 disposer 收尾)。 */
|
|
180
|
+
function resetForTest() {
|
|
181
|
+
watchers.clear();
|
|
182
|
+
entries.clear();
|
|
183
|
+
bySession.clear();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sessionIdOf(session) {
|
|
187
|
+
if (!session || typeof session !== 'object') return null;
|
|
188
|
+
if (typeof session.id === 'string' && session.id) return session.id;
|
|
189
|
+
// 宿主自己的两处写法不一致(`dsh-api-session-controller` 用 `session.id`,
|
|
190
|
+
// `dsh-acp` 用 `session.header.id`);认不出会话 id 就等于这条审批没人能答,所以两种都认。
|
|
191
|
+
const header = session.header;
|
|
192
|
+
return header && typeof header.id === 'string' && header.id ? header.id : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sessionIdOfRequest(request) {
|
|
196
|
+
const agent = request && request.agent;
|
|
197
|
+
const session = agent && agent.session;
|
|
198
|
+
return sessionIdOf(session);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 升级卡片的文案:宿主给 `displayReason`(tz 化文案)时优先用它,与桌面同一句。 */
|
|
202
|
+
function displayReasonOf(request) {
|
|
203
|
+
const display = request && request.displayReason;
|
|
204
|
+
if (!display || typeof display !== 'object') return undefined;
|
|
205
|
+
const zh = display.zh;
|
|
206
|
+
if (typeof zh === 'string' && zh) return zh;
|
|
207
|
+
return Object.values(display).find((value) => typeof value === 'string' && value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `approval/asked`:宿主记下这次审批。details 见 `dsh-user-approval` 的 `request()`。 */
|
|
211
|
+
function noteAsk(sessionId, data) {
|
|
212
|
+
const approvalId = typeof data.id === 'string' && data.id ? data.id : null;
|
|
213
|
+
if (!approvalId) return;
|
|
214
|
+
const entry = entryFor(sessionId, {
|
|
215
|
+
approvalId,
|
|
216
|
+
toolName: typeof data.toolName === 'string' ? data.toolName : undefined,
|
|
217
|
+
callId: typeof data.callId === 'string' ? data.callId : undefined,
|
|
218
|
+
reason: typeof data.reason === 'string' ? data.reason : undefined,
|
|
219
|
+
});
|
|
220
|
+
if (entry.approvalId === approvalId) { pushCard(entry); return; }
|
|
221
|
+
// 钩子先到、铸了占位 id:换成宿主的真 id 并重发一次卡(同 rpcId,App 只是换一份内容)。
|
|
222
|
+
entry.approvalId = approvalId;
|
|
223
|
+
pushCard(entry);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `approval/decided`:这条审批已经有结论了(桌面答的、或被取消),把卡和挂起项一起收掉。 */
|
|
227
|
+
function noteDecided(sessionId, data) {
|
|
228
|
+
const approvalId = typeof data.id === 'string' && data.id ? data.id : null;
|
|
229
|
+
if (!approvalId) return;
|
|
230
|
+
const entry = openEntries(sessionId).find((candidate) => candidate.approvalId === approvalId);
|
|
231
|
+
if (!entry) return;
|
|
232
|
+
settle(entry, DECISIONS.has(data.outcome) ? data.outcome : 'unavailable');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 把一条钩子请求绑到挂起项上。
|
|
237
|
+
*
|
|
238
|
+
* 绑上才算「答得动」:`/approval/respond` 只结算 bound 的挂起项,否则手机点了按钮而 agent
|
|
239
|
+
* 那头没人接(比如本桥没装上、请求走的是别的回答者),如实回 409 比假成功好。
|
|
240
|
+
*/
|
|
241
|
+
function bindRequest(entry, request) {
|
|
242
|
+
entry.bound = true;
|
|
243
|
+
entry.toolName = entry.toolName || (typeof request.toolName === 'string' ? request.toolName : undefined);
|
|
244
|
+
entry.callId = entry.callId || (typeof request.callId === 'string' ? request.callId : undefined);
|
|
245
|
+
const nicer = displayReasonOf(request) || (typeof request.reason === 'string' ? request.reason : undefined);
|
|
246
|
+
if (nicer) entry.reason = nicer;
|
|
247
|
+
// 绑上再推一次:`approval/asked` 那条路已经推过一张(可能是占位 id、也可能是没 tz 化的
|
|
248
|
+
// 原始 reason),这里换成最终那份内容。同一个 rpcId,App 只是替换面板内容。
|
|
249
|
+
pushCard(entry);
|
|
250
|
+
const signal = request.signal;
|
|
251
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
252
|
+
const onAbort = () => { settle(entry, 'cancelled'); };
|
|
253
|
+
if (signal.aborted) onAbort();
|
|
254
|
+
else {
|
|
255
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
256
|
+
// 结算时把监听撤掉:这条 signal 归调用方的 turn 所有,挂着的监听会跟到 turn 结束。
|
|
257
|
+
const release = entry.release;
|
|
258
|
+
entry.release = (result) => { signal.removeEventListener('abort', onAbort); release(result); };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 挂起这条请求直到有人给结论。
|
|
265
|
+
*
|
|
266
|
+
* 下游**先叫**:`next()` 一调,桌面窗口那张卡照旧出现(这条路径的行为一字不改)。手机答得
|
|
267
|
+
* 早就用手机的;下游答得早就用下游的 —— 谁先给结论谁算数,输的那张卡由结算帧收掉。
|
|
268
|
+
* 两边都没人答时这里会一直挂着,和今天宿主自己的行为一致(审批本来就等人工)。
|
|
269
|
+
*/
|
|
270
|
+
function holdForPhone(entry, next) {
|
|
271
|
+
const downstream = Promise.resolve().then(() => next()).then((outcome) => {
|
|
272
|
+
const value = DECISIONS.has(outcome) ? outcome : 'unavailable';
|
|
273
|
+
settle(entry, value);
|
|
274
|
+
return value;
|
|
275
|
+
});
|
|
276
|
+
return Promise.race([entry.wait.then((result) => result.outcome), downstream]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function onApprovalRequest(request, next) {
|
|
280
|
+
const sessionId = sessionIdOfRequest(request);
|
|
281
|
+
if (!sessionId) return next();
|
|
282
|
+
const entry = entryFor(sessionId, {
|
|
283
|
+
approvalId: undefined,
|
|
284
|
+
toolName: typeof request.toolName === 'string' ? request.toolName : undefined,
|
|
285
|
+
callId: typeof request.callId === 'string' ? request.callId : undefined,
|
|
286
|
+
reason: typeof request.reason === 'string' ? request.reason : undefined,
|
|
287
|
+
}, { unboundOnly: true });
|
|
288
|
+
bindRequest(entry, request);
|
|
289
|
+
return holdForPhone(entry, next);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 装上审批桥(只在 0.2.x 那一侧装:0.1.x 宿主自带 apiProxy,帧和回答都还走得通,装上去
|
|
294
|
+
* 只会多推一张卡)。
|
|
295
|
+
*/
|
|
296
|
+
function installApprovalBridge(ctx) {
|
|
297
|
+
const disposers = [
|
|
298
|
+
ctx.on('session/event', (session, event) => {
|
|
299
|
+
const sessionId = sessionIdOf(session);
|
|
300
|
+
if (!sessionId || !event || typeof event !== 'object') return;
|
|
301
|
+
if (event.type === 'approval/asked') noteAsk(sessionId, event.data || {});
|
|
302
|
+
else if (event.type === 'approval/decided') noteDecided(sessionId, event.data || {});
|
|
303
|
+
}, { global: true }),
|
|
304
|
+
// prepend 是前提不是优化,理由见文件头。
|
|
305
|
+
ctx.on('approval/request', onApprovalRequest, { prepend: true, global: true }),
|
|
306
|
+
];
|
|
307
|
+
return () => {
|
|
308
|
+
for (const dispose of disposers) dispose();
|
|
309
|
+
for (const entry of [...entries.values()]) settle(entry, 'cancelled');
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* POST /api/mobile/v1/approval/respond —— `{rpcId, sessionId, approvalId, outcome}`。
|
|
315
|
+
*
|
|
316
|
+
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
|
|
317
|
+
* 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
|
|
318
|
+
*/
|
|
319
|
+
async function handleApprovalRespond(ctx, req, res) {
|
|
320
|
+
const body = await readJsonBody(req);
|
|
321
|
+
if (!body || typeof body.rpcId !== 'string' || !body.rpcId
|
|
322
|
+
|| typeof body.sessionId !== 'string' || !body.sessionId
|
|
323
|
+
|| typeof body.approvalId !== 'string' || !body.approvalId
|
|
324
|
+
|| !ANSWER_OUTCOMES.has(body.outcome)) {
|
|
325
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { rpcId, sessionId, approvalId, outcome }' } });
|
|
326
|
+
}
|
|
327
|
+
const entry = entries.get(body.rpcId);
|
|
328
|
+
if (entry && !entry.settled && entry.bound
|
|
329
|
+
&& entry.sessionId === body.sessionId && entry.approvalId === body.approvalId) {
|
|
330
|
+
settle(entry, body.outcome);
|
|
331
|
+
return sendJson(res, 200, { ok: true, accepted: true });
|
|
332
|
+
}
|
|
333
|
+
const receipt = await ctx.apiProxy.respond({
|
|
334
|
+
type: 'client-response',
|
|
335
|
+
rpcId: body.rpcId,
|
|
336
|
+
result: { ok: true, value: { sessionId: body.sessionId, approvalId: body.approvalId, outcome: body.outcome } },
|
|
337
|
+
}).catch(() => null);
|
|
338
|
+
if (!receipt || receipt.accepted !== true) {
|
|
339
|
+
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该审批已完成或不可回答' } });
|
|
340
|
+
}
|
|
341
|
+
sendJson(res, 200, { ok: true, accepted: true });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
installApprovalBridge,
|
|
346
|
+
handleApprovalRespond,
|
|
347
|
+
attachSession,
|
|
348
|
+
hasPending,
|
|
349
|
+
resetForTest,
|
|
350
|
+
};
|
package/lib/mobile/questions.js
CHANGED
|
@@ -1,71 +1,293 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 手机端提问桥(DSH 0.2.x):`ask_user_question` 与「计划评审」。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* ≤0.1.x 的宿主把提问当成 apiProxy 事件流上的 server-request:
|
|
7
|
+
* `{rpcId, payload:{type:'question/requested', sessionId, questions}}`,手机凭**帧级 rpcId**
|
|
8
|
+
* 回答(`/question/respond` 把 rpcId 回给宿主那张 pending 表)。0.2.x 把 apiProxy 删了,
|
|
9
|
+
* 提问只剩一个**进程内** waterfall 钩子 `user-questions/request`(`dsh-user-questions`
|
|
10
|
+
* 发起,`dsh-tool-ask-user` 与 `dsh-plan-mode` 是它的两个调用方),帧级 rpcId 随之消失,
|
|
11
|
+
* 于是手机既收不到卡片、也没有能回传的 id。
|
|
12
|
+
*
|
|
13
|
+
* 这里按 0.2.x 的形状把这条通道补起来,**手机端契约一行不改**(App 仍然收
|
|
14
|
+
* `question/requested` + 帧级 rpcId,仍然 POST `/question/respond`):
|
|
15
|
+
*
|
|
16
|
+
* 1. 听 `user-questions/request` 钩子(**prepend**,理由与审批桥同一条:宿主的转发器
|
|
17
|
+
* `dsh-api-remotes` 在桌面窗口开着时会一直等桌面回答、不调用 `next()`)。铸一个自己的
|
|
18
|
+
* rpcId,把卡片推给正在看这个会话的手机,然后把请求挂住。
|
|
19
|
+
* 2. 下游照旧拿到请求(桌面那张卡照旧出现,`dsh-plan-mode` 也一样),两边竞速:
|
|
20
|
+
* 手机答得早就用手机的,桌面答得早就用桌面的。
|
|
21
|
+
* 3. `/question/respond` 拿 rpcId 找到挂起项,把回答交回钩子 —— 这才是真正让 agent
|
|
22
|
+
* 继续往下跑的那一步。落定后广播 `question/resolved`,另一边那张卡被收掉。
|
|
23
|
+
*
|
|
24
|
+
* 「计划评审」不走另一条通道:`dsh-plan-mode` 的 `exit_plan_mode` 就是拿
|
|
25
|
+
* `ctx.userQuestions.ask()` 问的(问题带 `intent.kind='plan-review'`、`detail` 是计划正文、
|
|
26
|
+
* 选项里有一个「批准」标签),所以这道桥同时修好它。App 的问题面板本来就渲染
|
|
27
|
+
* `detail` 与 `options`,计划正文直接显示在详情里。
|
|
8
28
|
*/
|
|
9
29
|
|
|
30
|
+
const { randomUUID } = require('node:crypto');
|
|
10
31
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
11
32
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
33
|
+
/** sessionId → Set<sink>:正在看这个会话的手机连接(每条 SSE 一个 sink)。 */
|
|
34
|
+
const watchers = new Map();
|
|
35
|
+
/** rpcId → entry:回答时按它定位。 */
|
|
36
|
+
const entries = new Map();
|
|
37
|
+
/** sessionId → entry[](保序):接入时补发、摘要里数待处理。 */
|
|
38
|
+
const bySession = new Map();
|
|
39
|
+
|
|
40
|
+
function watcherCount(sessionId) {
|
|
41
|
+
const set = watchers.get(sessionId);
|
|
42
|
+
return set ? set.size : 0;
|
|
16
43
|
}
|
|
17
44
|
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
45
|
+
function openEntries(sessionId) {
|
|
46
|
+
const list = bySession.get(sessionId);
|
|
47
|
+
return list ? list.filter((entry) => !entry.settled) : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 卡片帧。形状严格照 App `streamFrame.ts` 认的那一种:**帧级 rpcId** + 顶层
|
|
52
|
+
* `sessionId` + `questions`(每题至少 `id` 与 `question` 两个字符串)。
|
|
53
|
+
* 只转发 App 认识的字段,宿主的其它内部键(如 `intent` 里的 callId)不外泄。
|
|
54
|
+
*/
|
|
55
|
+
function cardFrame(entry) {
|
|
56
|
+
return {
|
|
57
|
+
type: 'question/requested',
|
|
58
|
+
sessionId: entry.sessionId,
|
|
59
|
+
rpcId: entry.rpcId,
|
|
60
|
+
questions: entry.questions.map((question) => ({
|
|
61
|
+
id: question.id,
|
|
62
|
+
question: question.question,
|
|
63
|
+
...(question.header ? { header: question.header } : {}),
|
|
64
|
+
...(question.detail ? { detail: question.detail } : {}),
|
|
65
|
+
...(question.options ? { options: question.options } : {}),
|
|
66
|
+
...(question.multiSelect ? { multiSelect: question.multiSelect } : {}),
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 结算帧。App 按 `questionRpcId` 比对后清掉面板(`streamFrame.ts` 的
|
|
73
|
+
* `question/resolved` 分支),所以这里必须带上本桥铸的那个 rpcId。
|
|
74
|
+
*/
|
|
75
|
+
function resolvedFrame(entry) {
|
|
76
|
+
return { type: 'question/resolved', sessionId: entry.sessionId, questionRpcId: entry.rpcId };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function broadcast(sessionId, frame) {
|
|
80
|
+
const set = watchers.get(sessionId);
|
|
81
|
+
if (!set) return;
|
|
82
|
+
for (const sink of set) {
|
|
83
|
+
// 单条连接写失败(客户端正好走了)不影响别的手机拿到这张卡。
|
|
84
|
+
try { sink(frame); } catch { /* ignore */ }
|
|
22
85
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pushCard(entry) {
|
|
89
|
+
if (entry.settled || watcherCount(entry.sessionId) === 0) return;
|
|
90
|
+
broadcast(entry.sessionId, cardFrame(entry));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function entryFor(sessionId, request) {
|
|
94
|
+
const entry = {
|
|
95
|
+
// rpcId 是本桥自己铸的:0.2.x 的宿主不再提供任何可作答的帧级 id,而 App 只认带 rpcId 的帧。
|
|
96
|
+
rpcId: `m-${randomUUID()}`,
|
|
97
|
+
sessionId,
|
|
98
|
+
callId: request && request.wait && typeof request.wait.callId === 'string' ? request.wait.callId : undefined,
|
|
99
|
+
questions: Array.isArray(request && request.questions) ? request.questions : [],
|
|
100
|
+
bound: false,
|
|
101
|
+
settled: false,
|
|
102
|
+
signalReason: null,
|
|
103
|
+
wait: null,
|
|
104
|
+
release: null,
|
|
105
|
+
};
|
|
106
|
+
entry.wait = new Promise((resolve) => { entry.release = resolve; });
|
|
107
|
+
entries.set(entry.rpcId, entry);
|
|
108
|
+
let list = bySession.get(sessionId);
|
|
109
|
+
if (!list) { list = []; bySession.set(sessionId, list); }
|
|
110
|
+
list.push(entry);
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 结算一条挂起项:撤下挂起项、让手机收卡、唤醒等待中的钩子。
|
|
116
|
+
* `answers` 有值 = 手机给了答案;null = 手机这条路没结论(被取消 / 别人先答了)。
|
|
117
|
+
*/
|
|
118
|
+
function settle(entry, answers) {
|
|
119
|
+
if (entry.settled) return false;
|
|
120
|
+
entry.settled = true;
|
|
121
|
+
entries.delete(entry.rpcId);
|
|
122
|
+
const list = bySession.get(entry.sessionId);
|
|
123
|
+
if (list) {
|
|
124
|
+
const index = list.indexOf(entry);
|
|
125
|
+
if (index >= 0) list.splice(index, 1);
|
|
126
|
+
if (list.length === 0) bySession.delete(entry.sessionId);
|
|
26
127
|
}
|
|
128
|
+
entry.release({ answers });
|
|
129
|
+
broadcast(entry.sessionId, resolvedFrame(entry));
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
27
132
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
selected: answer.selected,
|
|
39
|
-
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
40
|
-
})),
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
if (!receipt || receipt.accepted !== true) {
|
|
46
|
-
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该问题已完成或不可回答' } });
|
|
133
|
+
/**
|
|
134
|
+
* 手机接上/离开某条会话流。接入时补发仍然可作答的卡;断开只摘掉这一个 sink —— 挂起项本身
|
|
135
|
+
* 继续等(用户可能马上就回来),由宿主的 signal 中止或别人答掉来收尾。
|
|
136
|
+
*/
|
|
137
|
+
function attachSession(sessionId, sink) {
|
|
138
|
+
let set = watchers.get(sessionId);
|
|
139
|
+
if (!set) { set = new Set(); watchers.set(sessionId, set); }
|
|
140
|
+
set.add(sink);
|
|
141
|
+
for (const entry of openEntries(sessionId)) {
|
|
142
|
+
if (entry.bound) { try { sink(cardFrame(entry)); } catch { /* ignore */ } }
|
|
47
143
|
}
|
|
48
|
-
|
|
144
|
+
return () => {
|
|
145
|
+
set.delete(sink);
|
|
146
|
+
if (set.size === 0) watchers.delete(sessionId);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 会话摘要里的待处理标记(0.2.x 的宿主不会再下发这个字段,见 sessions.js 的用法)。 */
|
|
151
|
+
function pendingKind(sessionId) {
|
|
152
|
+
const open = openEntries(sessionId);
|
|
153
|
+
if (open.length === 0) return undefined;
|
|
154
|
+
const planReview = open.some((entry) => entry.questions.some((question) => {
|
|
155
|
+
const intent = question && question.intent;
|
|
156
|
+
return intent && typeof intent === 'object' && intent.kind === 'plan-review';
|
|
157
|
+
}));
|
|
158
|
+
return planReview ? 'plan-review' : 'question';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 测试用:清空全部进程内状态(真实运行时由插件卸载时的 disposer 收尾)。 */
|
|
162
|
+
function resetForTest() {
|
|
163
|
+
watchers.clear();
|
|
164
|
+
entries.clear();
|
|
165
|
+
bySession.clear();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sessionIdOf(session) {
|
|
169
|
+
if (!session || typeof session !== 'object') return null;
|
|
170
|
+
if (typeof session.id === 'string' && session.id) return session.id;
|
|
171
|
+
const header = session.header;
|
|
172
|
+
return header && typeof header.id === 'string' && header.id ? header.id : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sessionIdOfRequest(request) {
|
|
176
|
+
const agent = request && request.agent;
|
|
177
|
+
const session = agent && agent.session;
|
|
178
|
+
return sessionIdOf(session);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 把回答批次校验成宿主 `AskUserQuestionAnswer` 的形状:每题恰好一条、标题合法。 */
|
|
182
|
+
function normalizeAnswers(questions, answers) {
|
|
183
|
+
if (!Array.isArray(answers) || answers.length !== questions.length) return null;
|
|
184
|
+
const byId = new Map();
|
|
185
|
+
for (const answer of answers) {
|
|
186
|
+
if (!answer || typeof answer.id !== 'string' || byId.has(answer.id)) return null;
|
|
187
|
+
if (!Array.isArray(answer.selected) || !answer.selected.every((label) => typeof label === 'string')) return null;
|
|
188
|
+
if (answer.custom !== undefined && typeof answer.custom !== 'string') return null;
|
|
189
|
+
byId.set(answer.id, {
|
|
190
|
+
id: answer.id,
|
|
191
|
+
selected: [...answer.selected],
|
|
192
|
+
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (!questions.every((question) => byId.has(question.id))) return null;
|
|
196
|
+
return questions.map((question) => byId.get(question.id));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 绑上钩子请求:从此这条挂起项「答得动」,并接上宿主的取消信号。 */
|
|
200
|
+
function bindRequest(entry, request) {
|
|
201
|
+
entry.bound = true;
|
|
202
|
+
pushCard(entry);
|
|
203
|
+
const signal = request.signal;
|
|
204
|
+
if (!signal || typeof signal.addEventListener !== 'function') return;
|
|
205
|
+
const onAbort = () => {
|
|
206
|
+
entry.signalReason = signal.reason;
|
|
207
|
+
settle(entry, null);
|
|
208
|
+
};
|
|
209
|
+
if (signal.aborted) onAbort();
|
|
210
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
211
|
+
const release = entry.release;
|
|
212
|
+
entry.release = (result) => {
|
|
213
|
+
signal.removeEventListener('abort', onAbort);
|
|
214
|
+
release(result);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 挂起这条请求直到有人回答。
|
|
220
|
+
*
|
|
221
|
+
* 下游**先叫**:`next()` 一调,桌面窗口那张卡照旧出现(这条路径的行为一字不改)。手机答得
|
|
222
|
+
* 早就用手机的;下游答得早就用下游的。手机上那张卡由结算帧收掉。
|
|
223
|
+
*/
|
|
224
|
+
function holdForPhone(entry, next) {
|
|
225
|
+
const downstream = Promise.resolve().then(() => next()).then(
|
|
226
|
+
(answer) => { settle(entry, null); return answer; },
|
|
227
|
+
(error) => { settle(entry, null); throw error; },
|
|
228
|
+
);
|
|
229
|
+
const fromPhone = entry.wait.then((result) => {
|
|
230
|
+
if (result.answers) return { answers: result.answers };
|
|
231
|
+
// 手机这条路没给出答案(被取消):按宿主的 abort 语义把原因抛回去,
|
|
232
|
+
// 别假装成了一个空回答。
|
|
233
|
+
const reason = entry.signalReason;
|
|
234
|
+
throw reason instanceof Error ? reason : new Error('ask_user_question aborted');
|
|
235
|
+
});
|
|
236
|
+
return Promise.race([fromPhone, downstream]);
|
|
49
237
|
}
|
|
50
238
|
|
|
51
|
-
|
|
239
|
+
function onQuestionRequest(request, next) {
|
|
240
|
+
const sessionId = sessionIdOfRequest(request);
|
|
241
|
+
if (!sessionId) return next();
|
|
242
|
+
const entry = entryFor(sessionId, request);
|
|
243
|
+
bindRequest(entry, request);
|
|
244
|
+
return holdForPhone(entry, next);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 装上提问桥(只在 0.2.x 那一侧装,理由与审批桥相同)。 */
|
|
248
|
+
function installQuestionBridge(ctx) {
|
|
249
|
+
const dispose = ctx.on('user-questions/request', onQuestionRequest, { prepend: true, global: true });
|
|
250
|
+
return () => {
|
|
251
|
+
dispose();
|
|
252
|
+
for (const entry of [...entries.values()]) settle(entry, null);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* POST /api/mobile/v1/question/respond —— `{rpcId, sessionId, answer:{answers}}`。
|
|
258
|
+
*
|
|
259
|
+
* 先查本桥的挂起项;查不到就交回 `ctx.apiProxy.respond`(0.1.x 宿主那份真 apiProxy 才是
|
|
260
|
+
* 它的归宿;0.2.x 的兼容桥会如实回 `host-unsupported`,于是落到下面那条 409)。
|
|
261
|
+
*/
|
|
262
|
+
async function handleQuestionRespond(ctx, req, res) {
|
|
52
263
|
const body = await readJsonBody(req);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
264
|
+
if (!body || typeof body.rpcId !== 'string' || !body.rpcId || typeof body.sessionId !== 'string' || !body.sessionId) {
|
|
265
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { rpcId, sessionId, answer }' } });
|
|
266
|
+
}
|
|
267
|
+
const entry = entries.get(body.rpcId);
|
|
268
|
+
if (entry && !entry.settled && entry.bound && entry.sessionId === body.sessionId) {
|
|
269
|
+
const answers = normalizeAnswers(entry.questions, body.answer && body.answer.answers);
|
|
270
|
+
if (!answers) {
|
|
271
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'answer.answers 必须每题恰好一条、含合法 selected' } });
|
|
272
|
+
}
|
|
273
|
+
settle(entry, answers);
|
|
274
|
+
return sendJson(res, 200, { ok: true, accepted: true });
|
|
59
275
|
}
|
|
60
276
|
const receipt = await ctx.apiProxy.respond({
|
|
61
277
|
type: 'client-response',
|
|
62
278
|
rpcId: body.rpcId,
|
|
63
|
-
result: { ok: true, value: { sessionId: body.sessionId,
|
|
64
|
-
});
|
|
279
|
+
result: { ok: true, value: { sessionId: body.sessionId, answer: { answers: (body.answer && body.answer.answers) || [] } } },
|
|
280
|
+
}).catch(() => null);
|
|
65
281
|
if (!receipt || receipt.accepted !== true) {
|
|
66
|
-
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '
|
|
282
|
+
return sendJson(res, 409, { ok: false, error: { code: 'not-pending', message: '该问题已完成或不可回答' } });
|
|
67
283
|
}
|
|
68
284
|
sendJson(res, 200, { ok: true, accepted: true });
|
|
69
285
|
}
|
|
70
286
|
|
|
71
|
-
module.exports = {
|
|
287
|
+
module.exports = {
|
|
288
|
+
installQuestionBridge,
|
|
289
|
+
handleQuestionRespond,
|
|
290
|
+
attachSession,
|
|
291
|
+
pendingKind,
|
|
292
|
+
resetForTest,
|
|
293
|
+
};
|
|
@@ -98,8 +98,8 @@ async function handleSend(ctx, req, res) {
|
|
|
98
98
|
if (!body || typeof body.sessionId !== 'string' || !body.sessionId) {
|
|
99
99
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, content | text }' } });
|
|
100
100
|
}
|
|
101
|
-
const mode = body.mode
|
|
102
|
-
if (mode !== 'queue' && mode !== 'steer') {
|
|
101
|
+
const mode = body.mode;
|
|
102
|
+
if (mode !== undefined && mode !== 'queue' && mode !== 'steer') {
|
|
103
103
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: "mode 必须为 'queue' 或 'steer'" } });
|
|
104
104
|
}
|
|
105
105
|
let content;
|
|
@@ -150,7 +150,7 @@ async function handleSend(ctx, req, res) {
|
|
|
150
150
|
try {
|
|
151
151
|
promptResponse = await promptOnce(ctx, typeof body.clientMessageId === 'string' ? body.clientMessageId : '', {
|
|
152
152
|
sessionId: body.sessionId,
|
|
153
|
-
mode,
|
|
153
|
+
...(mode !== undefined ? { mode } : {}),
|
|
154
154
|
content,
|
|
155
155
|
});
|
|
156
156
|
} catch (error) {
|
package/lib/mobile/sessions.js
CHANGED
|
@@ -49,6 +49,8 @@ const { promptOnce, handleSend, handleCommands, handleSessionRead, handleSession
|
|
|
49
49
|
const { handleSessionSearch } = require('./session-search.js');
|
|
50
50
|
const { handleAgentPresets } = require('./session-presets.js');
|
|
51
51
|
const { handleGoalAction } = require('./session-goal.js');
|
|
52
|
+
const approvals = require('./approvals.js');
|
|
53
|
+
const questions = require('./questions.js');
|
|
52
54
|
|
|
53
55
|
const HISTORY_FOR_ENRICH = 10; // 补标题/预览时拉取的消息条数(低到足够取最后一条预览,避免每次重活)
|
|
54
56
|
const SESSIONS_PREVIEW_LIMIT = 20; // 仅为最近/运行中的会话补最后消息预览
|
|
@@ -236,13 +238,17 @@ async function buildSessionsResponse(ctx, deviceKey = 'legacy', generation = com
|
|
|
236
238
|
// 该项是廉价字段;history 仅用于补「最后消息预览」,属于重活,见下方预览子集。
|
|
237
239
|
const rows = allSummaries.map((s) => {
|
|
238
240
|
const completionState = completionStatesForContext.get(s.sessionId);
|
|
241
|
+
// 待处理标记:宿主摘要里带就用它的(0.1.x / 桌面口径);0.2.x 的宿主摘要**不带**这个
|
|
242
|
+
// 字段(桌面上那个标记是桌面客户端自己算的),这时用两道桥的账 —— 少了这一条,手机在
|
|
243
|
+
// 列表页连黄点都不会亮。审批优先(一次 turn 里审批总是挡在提问前面)。
|
|
244
|
+
const pendingInteraction = (s.pendingInteraction === 'approval' || s.pendingInteraction === 'plan-review' || s.pendingInteraction === 'question')
|
|
245
|
+
? s.pendingInteraction
|
|
246
|
+
: (approvals.hasPending(s.sessionId) ? 'approval' : questions.pendingKind(s.sessionId));
|
|
239
247
|
const base = {
|
|
240
248
|
sessionId: s.sessionId,
|
|
241
249
|
title: listTitle(s),
|
|
242
250
|
running: !!s.running,
|
|
243
|
-
...(
|
|
244
|
-
? { pendingInteraction: s.pendingInteraction }
|
|
245
|
-
: {}),
|
|
251
|
+
...(pendingInteraction ? { pendingInteraction } : {}),
|
|
246
252
|
completed: !!completionState?.completed,
|
|
247
253
|
// 上一轮以错误收尾(由 completion.js 从 history 的 turn/end reason.kind 折出)。
|
|
248
254
|
// 侧栏据此画红点:completed 只说「跑完了、待阅读」,说不了「跑挂了」。字段只在
|
package/lib/mobile/stream.js
CHANGED
|
@@ -22,11 +22,14 @@ const {
|
|
|
22
22
|
writeSse: sharedWriteSse,
|
|
23
23
|
waitForDrain,
|
|
24
24
|
ssePadding,
|
|
25
|
+
shedWithoutSeq,
|
|
25
26
|
} = require('../shared/sse-write.js');
|
|
26
27
|
const { resolveEventAttachments } = require('./sessions.js');
|
|
27
28
|
const { callSessions } = require('./rpc.js');
|
|
28
29
|
const { unwrap } = require('./envelope.js');
|
|
29
30
|
const { normalizeJobs } = require('./jobs.js');
|
|
31
|
+
const approvals = require('./approvals.js');
|
|
32
|
+
const questions = require('./questions.js');
|
|
30
33
|
|
|
31
34
|
// Android React Native's XHR may buffer tiny chunked responses until its
|
|
32
35
|
// internal progress threshold. Pad each SSE record so onprogress exposes it
|
|
@@ -74,9 +77,23 @@ function writeSse(res, payload, frame) {
|
|
|
74
77
|
return sharedWriteSse(res, payload, {
|
|
75
78
|
frame,
|
|
76
79
|
padding: ssePadding(SSE_FLUSH_PADDING_BYTES),
|
|
80
|
+
// 审批帧没有 seq,按默认口径会在软水位上被丢掉(见上面那条策略)。但它是「人工决策
|
|
81
|
+
// 的入口」不是草稿:丢一张,手机就再也看不到那张卡(软水位只在客户端跟不上时到,
|
|
82
|
+
// 回放也不覆盖这一段)。它一次审批也就一两帧,不是撑爆写队列的那类帧。
|
|
83
|
+
shedWhenBuffered: (value) => !isApprovalPayload(value) && shedWithoutSeq(value),
|
|
77
84
|
});
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 审批卡与它的结算帧,外加提问卡与它的结算帧:都是「人工决策的入口」,一次就一两帧,
|
|
89
|
+
* 不是撑爆写队列的那类增量,按软水位丢掉就再也补不回来(回放不覆盖这一段)。
|
|
90
|
+
*/
|
|
91
|
+
function isApprovalPayload(payload) {
|
|
92
|
+
const type = payload && payload.type;
|
|
93
|
+
return type === 'approval/requested' || type === 'approval/resolved'
|
|
94
|
+
|| type === 'question/requested' || type === 'question/resolved';
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
/**
|
|
81
98
|
* 解析断线续传位置:
|
|
82
99
|
* - `Last-Event-ID: <sessionId>:<seq>`(标准 SSE 头);
|
|
@@ -142,6 +159,16 @@ async function handleStream(ctx, req, res) {
|
|
|
142
159
|
// shutdown 就是这么做的)。测试里的 setInterval 桩没有 unref,所以先判类型。
|
|
143
160
|
if (keepAlive && typeof keepAlive.unref === 'function') keepAlive.unref();
|
|
144
161
|
|
|
162
|
+
// 审批卡由审批桥在帧循环之外推进这条连接:宿主那半条通道(`approval/request` 钩子)
|
|
163
|
+
// 跟 mux 帧流没关系,见 mobile/approvals.js。接入时它会把仍然可作答的卡补发一遍。
|
|
164
|
+
const detachApprovals = approvals.attachSession(sessionId, (frame) => {
|
|
165
|
+
writeSse(res, frame, { payload: frame });
|
|
166
|
+
});
|
|
167
|
+
// 提问卡同理(见 mobile/questions.js):`user-questions/request` 钩子与 mux 帧流无关。
|
|
168
|
+
const detachQuestions = questions.attachSession(sessionId, (frame) => {
|
|
169
|
+
writeSse(res, frame, { payload: frame });
|
|
170
|
+
});
|
|
171
|
+
|
|
145
172
|
const resumeSeq = parseResumeSeq(req, sessionId);
|
|
146
173
|
// 已交付的 seq 水位:从续传位置开始,重连时先回放、后接入实时流,并用它去重。
|
|
147
174
|
let lastDeliveredSeq = resumeSeq != null ? resumeSeq : 0;
|
|
@@ -224,6 +251,8 @@ async function handleStream(ctx, req, res) {
|
|
|
224
251
|
logStream(sessionId, 'mux loop threw', error);
|
|
225
252
|
}
|
|
226
253
|
clearInterval(keepAlive);
|
|
254
|
+
detachApprovals();
|
|
255
|
+
detachQuestions();
|
|
227
256
|
try {
|
|
228
257
|
if (!res.writableEnded) {
|
|
229
258
|
// 明确告诉客户端「流到此为止,请带 lastSeq 重连」:它会走已验证可用的 replay
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -27,6 +27,7 @@ const agentPresets = require('../mobile/agent-presets.js');
|
|
|
27
27
|
const stream = require('../mobile/stream.js');
|
|
28
28
|
const events = require('../mobile/events.js');
|
|
29
29
|
const questions = require('../mobile/questions.js');
|
|
30
|
+
const approvals = require('../mobile/approvals.js');
|
|
30
31
|
const workspaces = require('../mobile/workspaces.js');
|
|
31
32
|
const archiveClean = require('../mobile/archive-clean.js');
|
|
32
33
|
const sessionLog = require('../mobile/session-log.js');
|
|
@@ -176,7 +177,7 @@ function registerMobileRoutes(deps) {
|
|
|
176
177
|
route('/api/mobile/v1/session/stream', stream.handleStream)
|
|
177
178
|
route('/api/mobile/v1/events/stream', events.handleDeviceStream)
|
|
178
179
|
route('/api/mobile/v1/question/respond', questions.handleQuestionRespond)
|
|
179
|
-
route('/api/mobile/v1/approval/respond',
|
|
180
|
+
route('/api/mobile/v1/approval/respond', approvals.handleApprovalRespond)
|
|
180
181
|
route('/api/mobile/v1/session/goal', sessions.handleGoalAction)
|
|
181
182
|
route('/api/mobile/v1/session/queue/update', sessions.handleQueueUpdate)
|
|
182
183
|
route('/api/mobile/v1/workspaces', workspaces.handleWorkspaces)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.91",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|