dsh-jiuling-remote-mobile 1.0.2 → 1.0.3
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/client.js +36 -0
- package/lib/index.js +666 -566
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -88,6 +88,42 @@ window.__ModuleLoader__.load({
|
|
|
88
88
|
err: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-state-error-primary,#ff6b6b);word-break:break-all;',
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* 把 CSS 文本解析成 React 的 style **对象**。
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ **这一步不能省**:React 的 `style` prop 只接受对象,给字符串会当场抛
|
|
95
|
+
* `Minified React error #62`(React 18 的
|
|
96
|
+
* `if (style != null && typeof style != "object") throw Error(62)`),
|
|
97
|
+
* 组件随即被 slot 系统 `abdicate` 退役、渲染回落到官方对话框 ——
|
|
98
|
+
* 表现就是「电脑」页签根本不出现,而控制台里只有一条难懂的 #62。
|
|
99
|
+
*
|
|
100
|
+
* 键名同时做 kebab → camelCase(`min-width` → `minWidth`),否则 React
|
|
101
|
+
* 会逐条告警「Unsupported style property」。
|
|
102
|
+
*
|
|
103
|
+
* @param text - 形如 `'a:1;b:2;'` 的 CSS 声明串。
|
|
104
|
+
* @returns React style 对象。
|
|
105
|
+
*/
|
|
106
|
+
function toStyle(text) {
|
|
107
|
+
var out = {};
|
|
108
|
+
var parts = String(text).split(';');
|
|
109
|
+
for (var index = 0; index < parts.length; index += 1) {
|
|
110
|
+
var part = parts[index];
|
|
111
|
+
if (part === '') continue;
|
|
112
|
+
var colon = part.indexOf(':');
|
|
113
|
+
if (colon < 0) continue;
|
|
114
|
+
var name = part.slice(0, colon).trim();
|
|
115
|
+
var value = part.slice(colon + 1).trim();
|
|
116
|
+
if (name === '' || value === '') continue;
|
|
117
|
+
out[name.replace(/-([a-z])/g, function (match, letter) {
|
|
118
|
+
return letter.toUpperCase();
|
|
119
|
+
})] = value;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 就地转成对象:下游一律写 `style: CSS.xxx`,不会再碰到字符串形式。
|
|
125
|
+
for (var styleKey in CSS) CSS[styleKey] = toStyle(CSS[styleKey]);
|
|
126
|
+
|
|
91
127
|
/**
|
|
92
128
|
* 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
|
|
93
129
|
*
|
package/lib/index.js
CHANGED
|
@@ -1,566 +1,666 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-remote-mobile —— 手机端(host half,跑在手机的 DSH 引擎里)。
|
|
3
|
-
*
|
|
4
|
-
* ## 职责
|
|
5
|
-
*
|
|
6
|
-
* 手机是这个方案的「瘦客户端 + 搬运工」:
|
|
7
|
-
* - 手机本地跑**完整官方前端**(零远程 UI 加载,这是本方案相对「映射桌面」的核心优势);
|
|
8
|
-
* - 手机不跑 LLM。用户在「电脑文件夹」里发的消息,转成 RPC 打到电脑;
|
|
9
|
-
* - 电脑回的 durable 事件写进**手机本地**同名 sessionId 的会话日志。
|
|
10
|
-
*
|
|
11
|
-
* 于是会话列表、历史、工具卡片、流式渲染全部是官方现成组件在工作,
|
|
12
|
-
* 手机端不需要写任何聊天 UI(只写那个「电脑文件夹」入口对话框)。
|
|
13
|
-
*
|
|
14
|
-
* ## ⚠️ cwd 绝不跨端传(实测硬约束)
|
|
15
|
-
*
|
|
16
|
-
* Android 是 posix 语义,`path.posix.isAbsolute('C:/Users/...') === false`,
|
|
17
|
-
* 而 `validateSessionHeader` 要求 cwd 是绝对路径;更危险的是
|
|
18
|
-
* `SessionCommandController.createOrAdopt()` 里有 `await mkdir(cwd, {recursive:true})`。
|
|
19
|
-
* 所以**手机端会话用自己的本地 cwd**,电脑路径只作展示文字,两端靠 sessionId 映射。
|
|
20
|
-
*
|
|
21
|
-
* ## 为什么手机端不装 LLM 也能「发消息」
|
|
22
|
-
*
|
|
23
|
-
* 关键:手机端会话**不要有 live agent**。一旦 `ctx.agents.get(sessionId)` 有值,
|
|
24
|
-
* 官方 `sessionController.prompt` 就会把消息交给那个 agent(真跑手机本地模型)。
|
|
25
|
-
* 本插件**拦截 `sessionController.prompt`**:远程会话的消息转发电脑,
|
|
26
|
-
* 同时用 `Session.append()` 在手机本地落一条 user/message,前端立刻显示用户气泡。
|
|
27
|
-
*
|
|
28
|
-
* 注意 `follow` / `page` 这类历史 RPC 的第三个参数是 `false`(不激活 Agent),
|
|
29
|
-
* 所以手机端打开一个远程会话**不会**触发本地模型,这是本方案能成立的前提。
|
|
30
|
-
*
|
|
31
|
-
* ## 事件搬运(durable 通道)
|
|
32
|
-
*
|
|
33
|
-
* 电脑的 durable 事件按顺序写进手机本地会话:
|
|
34
|
-
* - 直接 `session.append(type, data, opts)`,与电脑端同构,前端零改动;
|
|
35
|
-
* - **跳过 `session/end-seed`** —— 手机端 `Session` 构造时已自动插过一条,
|
|
36
|
-
* 重放时再写会让 seq 错位一位(实测踩过这个坑)。
|
|
37
|
-
*
|
|
38
|
-
* ## 流式通道(best-effort,全局一条)
|
|
39
|
-
*
|
|
40
|
-
* 电脑的流式帧经**一条全局 SSE** 转给浏览器,浏览器侧注入 `assistant/live-chunk`
|
|
41
|
-
* transient(不落盘)。用全局而不是按会话开多条,是因为手机 WebView 对
|
|
42
|
-
* 并发连接有上限,且重开 App 后一条连接就能恢复所有会话的动画。
|
|
43
|
-
*
|
|
44
|
-
* 丢帧是安全的:电脑端帧来自进程内事件永不丢,重连后 durable settlement
|
|
45
|
-
* 会把完整内容补上(实测结论),最坏只是少了逐字动画。
|
|
46
|
-
*
|
|
47
|
-
* @module dsh-remote-mobile
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
import { randomUUID } from 'node:crypto';
|
|
51
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
52
|
-
import { homedir } from 'node:os';
|
|
53
|
-
import { join } from 'node:path';
|
|
54
|
-
|
|
55
|
-
export const name = 'dsh-jiuling-remote-mobile';
|
|
56
|
-
|
|
57
|
-
/** `sessions` 建会话/追加事件,`webServer` 给浏览器挂桥,`sessionController` 拦截 prompt。 */
|
|
58
|
-
export const inject = ['sessions', 'sessionController', 'webServer'];
|
|
59
|
-
|
|
60
|
-
/** 浏览器访问桥的路由。 */
|
|
61
|
-
const ROUTE_RPC = '/api/dsh-remote/rpc';
|
|
62
|
-
const ROUTE_STREAM = '/api/dsh-remote/stream';
|
|
63
|
-
|
|
64
|
-
/** 状态目录(手机端)。 */
|
|
65
|
-
const STATE_DIR = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'dsh-remote');
|
|
66
|
-
|
|
67
|
-
/** 连接配置:电脑隧道地址 + 访问令牌。 */
|
|
68
|
-
const CONFIG_PATH = join(STATE_DIR, 'mobile.json');
|
|
69
|
-
|
|
70
|
-
/** 远程会话清单(持久化,重开 App 后能接着搬)。 */
|
|
71
|
-
const SESSIONS_PATH = join(STATE_DIR, 'sessions.json');
|
|
72
|
-
|
|
73
|
-
/** 默认隧道地址。 */
|
|
74
|
-
const DEFAULT_ENDPOINT = 'https://dsh-remote.jiulingssh.xyz';
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 内置访问令牌 —— **必须与 `dsh-jiuling-remote-desktop` 里的内置值逐字相同**。
|
|
78
|
-
*
|
|
79
|
-
* 两端同值 ⇒ 装完即可连通,用户不用手工同步任何东西。想隔离就改这个文件里的
|
|
80
|
-
* `token` 字段,并把电脑端 `<DSH_HOME>/dsh-remote/token.txt` 改成一样的值。
|
|
81
|
-
*/
|
|
82
|
-
const DEFAULT_TOKEN = 'f6721b0cf82414709df172056193c78a6c374e9a920e51145dd7883923cf16e7';
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 读配置。首次运行写一份模板,用户直接改文件即可。
|
|
86
|
-
*
|
|
87
|
-
* `token` 为空时退回内置值(与电脑端同值),所以**装完就能连**。
|
|
88
|
-
*
|
|
89
|
-
* @returns `{ endpoint, token }`。
|
|
90
|
-
*/
|
|
91
|
-
function loadConfig() {
|
|
92
|
-
try {
|
|
93
|
-
const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
94
|
-
const token = typeof parsed.token === 'string' && parsed.token !== '' ? parsed.token : DEFAULT_TOKEN;
|
|
95
|
-
return {
|
|
96
|
-
endpoint:
|
|
97
|
-
typeof parsed.endpoint === 'string' && parsed.endpoint !== ''
|
|
98
|
-
? parsed.endpoint.replace(/\/+$/, '')
|
|
99
|
-
: DEFAULT_ENDPOINT,
|
|
100
|
-
token,
|
|
101
|
-
};
|
|
102
|
-
} catch {
|
|
103
|
-
try {
|
|
104
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
105
|
-
writeFileSync(
|
|
106
|
-
CONFIG_PATH,
|
|
107
|
-
JSON.stringify({ endpoint: DEFAULT_ENDPOINT, token: DEFAULT_TOKEN }, null, 2),
|
|
108
|
-
{ mode: 0o600 },
|
|
109
|
-
);
|
|
110
|
-
} catch {
|
|
111
|
-
/* 只读环境:用默认值继续 */
|
|
112
|
-
}
|
|
113
|
-
return { endpoint: DEFAULT_ENDPOINT, token: DEFAULT_TOKEN };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** 读远程会话清单。 */
|
|
118
|
-
function loadRemoteSessions() {
|
|
119
|
-
try {
|
|
120
|
-
const parsed = JSON.parse(readFileSync(SESSIONS_PATH, 'utf8'));
|
|
121
|
-
return new Set(Array.isArray(parsed.sessions) ? parsed.sessions.filter((x) => typeof x === 'string') : []);
|
|
122
|
-
} catch {
|
|
123
|
-
return new Set();
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** 写远程会话清单(失败不致命,只影响重开后的自动恢复)。 */
|
|
128
|
-
function saveRemoteSessions(set, logger) {
|
|
129
|
-
try {
|
|
130
|
-
mkdirSync(STATE_DIR, { recursive: true });
|
|
131
|
-
writeFileSync(SESSIONS_PATH, JSON.stringify({ sessions: [...set] }, null, 2), { mode: 0o600 });
|
|
132
|
-
} catch (error) {
|
|
133
|
-
logger?.warn?.(`[dsh-remote-mobile] 保存远程会话清单失败: ${String(error?.message ?? error)}`);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* @param
|
|
186
|
-
* @
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (state ===
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
() =>
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-mobile —— 手机端(host half,跑在手机的 DSH 引擎里)。
|
|
3
|
+
*
|
|
4
|
+
* ## 职责
|
|
5
|
+
*
|
|
6
|
+
* 手机是这个方案的「瘦客户端 + 搬运工」:
|
|
7
|
+
* - 手机本地跑**完整官方前端**(零远程 UI 加载,这是本方案相对「映射桌面」的核心优势);
|
|
8
|
+
* - 手机不跑 LLM。用户在「电脑文件夹」里发的消息,转成 RPC 打到电脑;
|
|
9
|
+
* - 电脑回的 durable 事件写进**手机本地**同名 sessionId 的会话日志。
|
|
10
|
+
*
|
|
11
|
+
* 于是会话列表、历史、工具卡片、流式渲染全部是官方现成组件在工作,
|
|
12
|
+
* 手机端不需要写任何聊天 UI(只写那个「电脑文件夹」入口对话框)。
|
|
13
|
+
*
|
|
14
|
+
* ## ⚠️ cwd 绝不跨端传(实测硬约束)
|
|
15
|
+
*
|
|
16
|
+
* Android 是 posix 语义,`path.posix.isAbsolute('C:/Users/...') === false`,
|
|
17
|
+
* 而 `validateSessionHeader` 要求 cwd 是绝对路径;更危险的是
|
|
18
|
+
* `SessionCommandController.createOrAdopt()` 里有 `await mkdir(cwd, {recursive:true})`。
|
|
19
|
+
* 所以**手机端会话用自己的本地 cwd**,电脑路径只作展示文字,两端靠 sessionId 映射。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么手机端不装 LLM 也能「发消息」
|
|
22
|
+
*
|
|
23
|
+
* 关键:手机端会话**不要有 live agent**。一旦 `ctx.agents.get(sessionId)` 有值,
|
|
24
|
+
* 官方 `sessionController.prompt` 就会把消息交给那个 agent(真跑手机本地模型)。
|
|
25
|
+
* 本插件**拦截 `sessionController.prompt`**:远程会话的消息转发电脑,
|
|
26
|
+
* 同时用 `Session.append()` 在手机本地落一条 user/message,前端立刻显示用户气泡。
|
|
27
|
+
*
|
|
28
|
+
* 注意 `follow` / `page` 这类历史 RPC 的第三个参数是 `false`(不激活 Agent),
|
|
29
|
+
* 所以手机端打开一个远程会话**不会**触发本地模型,这是本方案能成立的前提。
|
|
30
|
+
*
|
|
31
|
+
* ## 事件搬运(durable 通道)
|
|
32
|
+
*
|
|
33
|
+
* 电脑的 durable 事件按顺序写进手机本地会话:
|
|
34
|
+
* - 直接 `session.append(type, data, opts)`,与电脑端同构,前端零改动;
|
|
35
|
+
* - **跳过 `session/end-seed`** —— 手机端 `Session` 构造时已自动插过一条,
|
|
36
|
+
* 重放时再写会让 seq 错位一位(实测踩过这个坑)。
|
|
37
|
+
*
|
|
38
|
+
* ## 流式通道(best-effort,全局一条)
|
|
39
|
+
*
|
|
40
|
+
* 电脑的流式帧经**一条全局 SSE** 转给浏览器,浏览器侧注入 `assistant/live-chunk`
|
|
41
|
+
* transient(不落盘)。用全局而不是按会话开多条,是因为手机 WebView 对
|
|
42
|
+
* 并发连接有上限,且重开 App 后一条连接就能恢复所有会话的动画。
|
|
43
|
+
*
|
|
44
|
+
* 丢帧是安全的:电脑端帧来自进程内事件永不丢,重连后 durable settlement
|
|
45
|
+
* 会把完整内容补上(实测结论),最坏只是少了逐字动画。
|
|
46
|
+
*
|
|
47
|
+
* @module dsh-remote-mobile
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { randomUUID } from 'node:crypto';
|
|
51
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
52
|
+
import { homedir } from 'node:os';
|
|
53
|
+
import { join } from 'node:path';
|
|
54
|
+
|
|
55
|
+
export const name = 'dsh-jiuling-remote-mobile';
|
|
56
|
+
|
|
57
|
+
/** `sessions` 建会话/追加事件,`webServer` 给浏览器挂桥,`sessionController` 拦截 prompt。 */
|
|
58
|
+
export const inject = ['sessions', 'sessionController', 'webServer', 'workspaceRegistry'];
|
|
59
|
+
|
|
60
|
+
/** 浏览器访问桥的路由。 */
|
|
61
|
+
const ROUTE_RPC = '/api/dsh-remote/rpc';
|
|
62
|
+
const ROUTE_STREAM = '/api/dsh-remote/stream';
|
|
63
|
+
|
|
64
|
+
/** 状态目录(手机端)。 */
|
|
65
|
+
const STATE_DIR = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'dsh-remote');
|
|
66
|
+
|
|
67
|
+
/** 连接配置:电脑隧道地址 + 访问令牌。 */
|
|
68
|
+
const CONFIG_PATH = join(STATE_DIR, 'mobile.json');
|
|
69
|
+
|
|
70
|
+
/** 远程会话清单(持久化,重开 App 后能接着搬)。 */
|
|
71
|
+
const SESSIONS_PATH = join(STATE_DIR, 'sessions.json');
|
|
72
|
+
|
|
73
|
+
/** 默认隧道地址。 */
|
|
74
|
+
const DEFAULT_ENDPOINT = 'https://dsh-remote.jiulingssh.xyz';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 内置访问令牌 —— **必须与 `dsh-jiuling-remote-desktop` 里的内置值逐字相同**。
|
|
78
|
+
*
|
|
79
|
+
* 两端同值 ⇒ 装完即可连通,用户不用手工同步任何东西。想隔离就改这个文件里的
|
|
80
|
+
* `token` 字段,并把电脑端 `<DSH_HOME>/dsh-remote/token.txt` 改成一样的值。
|
|
81
|
+
*/
|
|
82
|
+
const DEFAULT_TOKEN = 'f6721b0cf82414709df172056193c78a6c374e9a920e51145dd7883923cf16e7';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 读配置。首次运行写一份模板,用户直接改文件即可。
|
|
86
|
+
*
|
|
87
|
+
* `token` 为空时退回内置值(与电脑端同值),所以**装完就能连**。
|
|
88
|
+
*
|
|
89
|
+
* @returns `{ endpoint, token }`。
|
|
90
|
+
*/
|
|
91
|
+
function loadConfig() {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
94
|
+
const token = typeof parsed.token === 'string' && parsed.token !== '' ? parsed.token : DEFAULT_TOKEN;
|
|
95
|
+
return {
|
|
96
|
+
endpoint:
|
|
97
|
+
typeof parsed.endpoint === 'string' && parsed.endpoint !== ''
|
|
98
|
+
? parsed.endpoint.replace(/\/+$/, '')
|
|
99
|
+
: DEFAULT_ENDPOINT,
|
|
100
|
+
token,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
try {
|
|
104
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
105
|
+
writeFileSync(
|
|
106
|
+
CONFIG_PATH,
|
|
107
|
+
JSON.stringify({ endpoint: DEFAULT_ENDPOINT, token: DEFAULT_TOKEN }, null, 2),
|
|
108
|
+
{ mode: 0o600 },
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
/* 只读环境:用默认值继续 */
|
|
112
|
+
}
|
|
113
|
+
return { endpoint: DEFAULT_ENDPOINT, token: DEFAULT_TOKEN };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 读远程会话清单。 */
|
|
118
|
+
function loadRemoteSessions() {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(readFileSync(SESSIONS_PATH, 'utf8'));
|
|
121
|
+
return new Set(Array.isArray(parsed.sessions) ? parsed.sessions.filter((x) => typeof x === 'string') : []);
|
|
122
|
+
} catch {
|
|
123
|
+
return new Set();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 写远程会话清单(失败不致命,只影响重开后的自动恢复)。 */
|
|
128
|
+
function saveRemoteSessions(set, logger) {
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
131
|
+
writeFileSync(SESSIONS_PATH, JSON.stringify({ sessions: [...set] }, null, 2), { mode: 0o600 });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger?.warn?.(`[dsh-remote-mobile] 保存远程会话清单失败: ${String(error?.message ?? error)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 跨端请求专用的 undici 连接池。
|
|
139
|
+
*
|
|
140
|
+
* ## ⚠️ 为什么不能用全局 `fetch`(真机实测的硬坑)
|
|
141
|
+
*
|
|
142
|
+
* 在 Android 上,引擎进程里**全局 undici dispatcher 连不通公网**:同一个进程、
|
|
143
|
+
* 同一个 URL、同一时刻的对照实验(`__diag` 探针)——
|
|
144
|
+
*
|
|
145
|
+
* | 通道 | 结果 |
|
|
146
|
+
* |---|---|
|
|
147
|
+
* | 全局 `fetch()`(默认 dispatcher) | **12018 ms 超时** ❌ |
|
|
148
|
+
* | `new Agent({ autoSelectFamily: false })` | **2658 ms 200** ✅ |
|
|
149
|
+
* | `new Agent({ connect: { family: 4 } })` | 5283 ms 200 ✅ |
|
|
150
|
+
* | `node:https.request()` | 2125 ms 200 ✅ |
|
|
151
|
+
*
|
|
152
|
+
* DNS 正常(10 ms 解析出 2 个 IPv6 + 2 个 IPv4),所以不是解析问题;是全局
|
|
153
|
+
* dispatcher 的 **Happy Eyeballs / autoSelectFamily 在 Android 上卡住**
|
|
154
|
+
* (IPv6 地址拿到却连不通,又不及时回退到 IPv4)。
|
|
155
|
+
*
|
|
156
|
+
* 症状具有极强的误导性:**纯本地的桥方法(如 `isRemote`)几十毫秒就返回**,
|
|
157
|
+
* 而任何真正出网的桥方法(`ping` / `listDesktop`)永久挂起 —— 看起来像
|
|
158
|
+
* 「插件没装上」或「隧道不通」,实际是本机引擎的连接策略问题。
|
|
159
|
+
*
|
|
160
|
+
* 所以本插件**不复用全局 dispatcher**,自己建一个关掉 `autoSelectFamily` 的池。
|
|
161
|
+
* 只影响本插件发出的请求,不碰进程全局状态。
|
|
162
|
+
*
|
|
163
|
+
* @returns `{ dispatcher, fetch }`;拿不到 undici 时 `dispatcher` 为 `undefined`。
|
|
164
|
+
*/
|
|
165
|
+
let remoteAgent;
|
|
166
|
+
async function remoteFetch(url, init) {
|
|
167
|
+
if (remoteAgent === undefined) {
|
|
168
|
+
try {
|
|
169
|
+
const undici = await import('undici');
|
|
170
|
+
remoteAgent = new undici.Agent({ connect: { autoSelectFamily: false } });
|
|
171
|
+
} catch {
|
|
172
|
+
remoteAgent = null; // 没有 undici:退回全局 fetch
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (remoteAgent === null || remoteAgent === undefined) return fetch(url, init);
|
|
176
|
+
const undici = await import('undici');
|
|
177
|
+
return undici.fetch(url, { ...init, dispatcher: remoteAgent });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 调电脑端的一条 RPC。
|
|
182
|
+
*
|
|
183
|
+
* @param config - `{ endpoint, token }`。
|
|
184
|
+
* @param method - 方法名。
|
|
185
|
+
* @param params - 参数。
|
|
186
|
+
* @returns 电脑端的 `result`。
|
|
187
|
+
*/
|
|
188
|
+
async function callRemote(config, method, params = {}) {
|
|
189
|
+
if (config.token === '') {
|
|
190
|
+
throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token(电脑端启动日志会打印它)`);
|
|
191
|
+
}
|
|
192
|
+
const res = await remoteFetch(`${config.endpoint}/api/dsh-remote/rpc`, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: {
|
|
195
|
+
'content-type': 'application/json',
|
|
196
|
+
'x-dsh-remote-token': config.token,
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({ id: randomUUID(), method, params }),
|
|
199
|
+
});
|
|
200
|
+
if (!res.ok) throw new Error(`远程请求失败: HTTP ${res.status}`);
|
|
201
|
+
const payload = await res.json();
|
|
202
|
+
if (payload.ok !== true) throw new Error(payload.error ?? '远程请求失败');
|
|
203
|
+
return payload.result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 把一个电脑事件写进手机本地会话。
|
|
208
|
+
*
|
|
209
|
+
* ## seq 对齐规则(这是搬运正确性的核心)
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ 两端的 seq **起点不一样**,这是实测确认的:
|
|
212
|
+
*
|
|
213
|
+
* - 电脑端走 `sessionController.create` → agent factory → `agents.create({seed: ...})`,
|
|
214
|
+
* `seed !== undefined` ⇒ `Session` 构造时**自动插一条 `session/end-seed`**(seq=0),
|
|
215
|
+
* 真实事件从 seq=1 开始(`dsh-session` L1351-1352)。
|
|
216
|
+
* - 手机端走 `sessions.create(id, {meta})`,**不带 seed** ⇒ 那条自动插入不触发
|
|
217
|
+
* (L1351-1352 的条件是 `seed !== void 0`),真实事件从 seq=0 开始。
|
|
218
|
+
*
|
|
219
|
+
* 所以映射关系是 `手机 seq = 电脑 seq - 已跳过的 end-seed 条数`。
|
|
220
|
+
* `end-seed` 只会出现在日志最前面,跳过它之后两端就逐条对齐了。
|
|
221
|
+
*
|
|
222
|
+
* 据此判断:
|
|
223
|
+
* - 折算后的 seq < 本地 seq ⇒ 已写过(SSE 重连时电脑会补发历史),**跳过**;
|
|
224
|
+
* - 折算后 === 本地 seq ⇒ 正是下一条,写入;
|
|
225
|
+
* - 折算后 > 本地 seq ⇒ 中间缺事件。此时**不能硬写** —— 官方 `Session` 的 seq
|
|
226
|
+
* 必须连续,硬写会造成空洞。抛错交给调用方记日志,等重连补齐。
|
|
227
|
+
*
|
|
228
|
+
* @param session - 手机本地会话。
|
|
229
|
+
* @param event - 电脑端事件(`{type, seq, time, data, surfaceOp?, sourceEventSeqs?}`)。
|
|
230
|
+
* @param state - 该会话的搬运状态 `{ skippedSeeds }`。
|
|
231
|
+
* @returns 是否写入了。
|
|
232
|
+
*/
|
|
233
|
+
function mirrorEvent(session, event, state) {
|
|
234
|
+
// ⚠️ `session/end-seed` 必须跳过:手机端没有对应的一条,写进去会让后续全部错位。
|
|
235
|
+
// 它只会出现在电脑日志的最开头,所以偏移量只认一次 —— 否则 SSE 重连补发
|
|
236
|
+
// 历史时会被反复累加,后续事件的折算 seq 越算越小。
|
|
237
|
+
if (event.type === 'session/end-seed') {
|
|
238
|
+
if (state.skippedSeeds === 0) state.skippedSeeds = 1;
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
if (typeof event.seq === 'number') {
|
|
242
|
+
const expected = event.seq - state.skippedSeeds;
|
|
243
|
+
if (expected < session.seq) return false; // 重连补发的历史,已写过
|
|
244
|
+
if (expected > session.seq) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`事件流有缺口:电脑 seq=${event.seq}(折算 ${expected}),手机本地 seq=${session.seq}(等重连补齐)`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const opts = {};
|
|
251
|
+
if (event.surfaceOp !== undefined) opts.surfaceOp = event.surfaceOp;
|
|
252
|
+
if (event.sourceEventSeqs !== undefined) opts.sourceEventSeqs = event.sourceEventSeqs;
|
|
253
|
+
session.append(event.type, event.data, opts);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 插件入口。
|
|
259
|
+
*
|
|
260
|
+
* @param ctx - Cordis 上下文。
|
|
261
|
+
*/
|
|
262
|
+
export function apply(ctx) {
|
|
263
|
+
const config = loadConfig();
|
|
264
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] 远端: ${config.endpoint}`);
|
|
265
|
+
if (config.token === '') {
|
|
266
|
+
ctx.logger?.warn?.(`[dsh-remote-mobile] 未配置令牌,请在 ${CONFIG_PATH} 里填 token`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 远程会话 id 集合:这些会话的 prompt 转发电脑,不在手机本地跑模型。 */
|
|
270
|
+
const remoteSessions = loadRemoteSessions();
|
|
271
|
+
|
|
272
|
+
/** 每个远程会话正在跟随的电脑事件流。 */
|
|
273
|
+
const follows = new Map();
|
|
274
|
+
|
|
275
|
+
/** 每个远程会话的搬运状态(已跳过几条 `session/end-seed`)。 */
|
|
276
|
+
const mirrorStates = new Map();
|
|
277
|
+
|
|
278
|
+
/** 取(或建)某会话的搬运状态。 */
|
|
279
|
+
const mirrorStateOf = (sessionId) => {
|
|
280
|
+
let state = mirrorStates.get(sessionId);
|
|
281
|
+
if (state === undefined) {
|
|
282
|
+
state = { skippedSeeds: 0 };
|
|
283
|
+
mirrorStates.set(sessionId, state);
|
|
284
|
+
}
|
|
285
|
+
return state;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/** 浏览器侧全局 SSE 订阅者。 */
|
|
289
|
+
const watchers = new Set();
|
|
290
|
+
|
|
291
|
+
/** 往所有浏览器订阅者推一条 SSE。 */
|
|
292
|
+
const broadcast = (eventName, payload) => {
|
|
293
|
+
if (watchers.size === 0) return;
|
|
294
|
+
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
295
|
+
for (const res of watchers) {
|
|
296
|
+
try {
|
|
297
|
+
res.write(chunk);
|
|
298
|
+
} catch {
|
|
299
|
+
watchers.delete(res);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 跟随电脑的一个会话:durable 事件写手机本地,流式帧广播给浏览器。
|
|
306
|
+
*
|
|
307
|
+
* @param sessionId - 两端同名的会话 id。
|
|
308
|
+
*/
|
|
309
|
+
const follow = (sessionId) => {
|
|
310
|
+
if (follows.has(sessionId)) return;
|
|
311
|
+
const controller = new AbortController();
|
|
312
|
+
follows.set(sessionId, controller);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 连一次电脑的 SSE 并消费到断开。
|
|
316
|
+
*
|
|
317
|
+
* 断线是**常态**(Cloudflare 隧道会掐空闲连接、手机休眠会断网),所以
|
|
318
|
+
* 外面套一层退避重连:重连后电脑会补发全部历史,`mirrorEvent` 靠 seq
|
|
319
|
+
* 折算把已写过的跳过,不会重复。
|
|
320
|
+
*/
|
|
321
|
+
const once = async () => {
|
|
322
|
+
const url =
|
|
323
|
+
`${config.endpoint}/api/dsh-remote/stream` +
|
|
324
|
+
`?session=${encodeURIComponent(sessionId)}&token=${encodeURIComponent(config.token)}`;
|
|
325
|
+
// ⚠️ 必须走 `remoteFetch`(自带 dispatcher):全局 fetch 的 dispatcher
|
|
326
|
+
// 在 Android 上连不通公网,见 `remoteFetch` 的注释。
|
|
327
|
+
const res = await remoteFetch(url, {
|
|
328
|
+
headers: { accept: 'text/event-stream' },
|
|
329
|
+
signal: controller.signal,
|
|
330
|
+
});
|
|
331
|
+
if (!res.ok || res.body === null) throw new Error(`HTTP ${res.status}`);
|
|
332
|
+
|
|
333
|
+
const decoder = new TextDecoder();
|
|
334
|
+
let buffer = '';
|
|
335
|
+
for await (const chunk of res.body) {
|
|
336
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
337
|
+
// SSE 以空行分隔消息;末段可能不完整,留在 buffer 里。
|
|
338
|
+
let split;
|
|
339
|
+
while ((split = buffer.indexOf('\n\n')) >= 0) {
|
|
340
|
+
const raw = buffer.slice(0, split);
|
|
341
|
+
buffer = buffer.slice(split + 2);
|
|
342
|
+
let eventName = 'message';
|
|
343
|
+
let dataLine = '';
|
|
344
|
+
for (const line of raw.split('\n')) {
|
|
345
|
+
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
|
346
|
+
else if (line.startsWith('data:')) dataLine += line.slice(5).trim();
|
|
347
|
+
}
|
|
348
|
+
if (dataLine === '') continue; // 心跳/注释行
|
|
349
|
+
let payload;
|
|
350
|
+
try {
|
|
351
|
+
payload = JSON.parse(dataLine);
|
|
352
|
+
} catch {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (eventName === 'event') {
|
|
356
|
+
const session = ctx.sessions.get(sessionId);
|
|
357
|
+
if (session === undefined) continue;
|
|
358
|
+
let wrote = false;
|
|
359
|
+
try {
|
|
360
|
+
wrote = mirrorEvent(session, payload, mirrorStateOf(sessionId));
|
|
361
|
+
} catch (error) {
|
|
362
|
+
ctx.logger?.warn?.(
|
|
363
|
+
`[dsh-remote-mobile] 写入事件失败 (${payload?.type}): ${String(error?.message ?? error)}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
// ⚠️ 必须告诉浏览器「这一轮的 durable 结算到了」。
|
|
367
|
+
//
|
|
368
|
+
// 浏览器侧的逐字动画是我们自己注入的 transient 行,而官方的
|
|
369
|
+
// 清理点(`MutableSessionEventSource.settleAssistant`)只挂在
|
|
370
|
+
// `ClientAssistantStream.acceptEventChange` 上 —— 那条链是**远程
|
|
371
|
+
// 事件流**专用的,手机本地的 durable 是同步 append 进会话的,
|
|
372
|
+
// 走不到它。没人清的话,逐字残片会永久留在窗口里,和随后到达的
|
|
373
|
+
// 完整消息叠成两份。
|
|
374
|
+
if (wrote && (payload?.type === 'assistant/message' || payload?.type === 'assistant/attempt')) {
|
|
375
|
+
broadcast('settled', {
|
|
376
|
+
sessionId,
|
|
377
|
+
turn: payload?.data?.turn,
|
|
378
|
+
step: payload?.data?.step,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
} else if (eventName === 'frame') {
|
|
382
|
+
// 带上 sessionId:浏览器侧一条连接服务所有会话。
|
|
383
|
+
broadcast('frame', { sessionId, frame: payload });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const run = async () => {
|
|
390
|
+
let wait = 1000;
|
|
391
|
+
while (!controller.signal.aborted) {
|
|
392
|
+
try {
|
|
393
|
+
await once();
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (controller.signal.aborted) return;
|
|
396
|
+
ctx.logger?.warn?.(
|
|
397
|
+
`[dsh-remote-mobile] 事件流中断(${wait}ms 后重连): ${String(error?.message ?? error)}`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (controller.signal.aborted) return;
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
402
|
+
wait = Math.min(wait * 2, 15000); // 退避到 15s 为止,一直重试
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
run().finally(() => {
|
|
407
|
+
if (follows.get(sessionId) === controller) follows.delete(sessionId);
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// ---- 拦截官方 prompt:远程会话转发电脑,本地会话走原逻辑 ----
|
|
412
|
+
ctx.effect(() => {
|
|
413
|
+
const controller = ctx.sessionController;
|
|
414
|
+
if (controller === undefined) return;
|
|
415
|
+
const originalPrompt = controller.prompt.bind(controller);
|
|
416
|
+
controller.prompt = async (request) => {
|
|
417
|
+
if (!remoteSessions.has(request.sessionId)) return originalPrompt(request);
|
|
418
|
+
const text = (request.content ?? [])
|
|
419
|
+
.filter((part) => part.type === 'text')
|
|
420
|
+
.map((part) => part.text)
|
|
421
|
+
.join('\n');
|
|
422
|
+
if (ctx.sessions.get(request.sessionId) === undefined) {
|
|
423
|
+
throw new Error(`远程会话 "${request.sessionId}" 不在手机本地`);
|
|
424
|
+
}
|
|
425
|
+
// ⚠️ 手机端**不**自己写 user/message。
|
|
426
|
+
//
|
|
427
|
+
// 手机本地日志是电脑日志的镜像,seq 必须逐条对齐;本地多写一条会让
|
|
428
|
+
// 后续所有事件的 seq 错位,事件搬运随即失效。而电脑端 prompt 后
|
|
429
|
+
// 本来就会 append 一条 user/message 并流回来 —— 那才是唯一真源。
|
|
430
|
+
//
|
|
431
|
+
// 把前端的 requestId 原样带过去:电脑端会把它写进
|
|
432
|
+
// `source.rpcId`,前端凭这个结算「提交中」状态并显示用户气泡。
|
|
433
|
+
await callRemote(config, 'prompt', {
|
|
434
|
+
sessionId: request.sessionId,
|
|
435
|
+
text,
|
|
436
|
+
...(request.requestId === undefined ? {} : { requestId: request.requestId }),
|
|
437
|
+
});
|
|
438
|
+
// 确保事件流在线(断线时重连,补齐这段时间的事件)。
|
|
439
|
+
follow(request.sessionId);
|
|
440
|
+
return { accepted: true };
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// 「停止生成」同样要转发到电脑。
|
|
444
|
+
// ⚠️ 不拦的话,官方 `cancel` 会 `resolveAgent()` → 在手机上 resume 一个 agent,
|
|
445
|
+
// 那会真去建目录、真跑手机本地模型。
|
|
446
|
+
const originalCancel = controller.cancel.bind(controller);
|
|
447
|
+
controller.cancel = async (request) => {
|
|
448
|
+
if (!remoteSessions.has(request.sessionId)) return originalCancel(request);
|
|
449
|
+
await callRemote(config, 'cancel', { sessionId: request.sessionId });
|
|
450
|
+
return { accepted: true };
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
return () => {
|
|
454
|
+
controller.prompt = originalPrompt;
|
|
455
|
+
controller.cancel = originalCancel;
|
|
456
|
+
};
|
|
457
|
+
}, 'dsh-remote-mobile: prompt/cancel 拦截');
|
|
458
|
+
|
|
459
|
+
// ---- 浏览器桥:目录浏览 / 建会话 ----
|
|
460
|
+
ctx.effect(
|
|
461
|
+
() =>
|
|
462
|
+
ctx.webServer.register({
|
|
463
|
+
kind: 'exact',
|
|
464
|
+
path: ROUTE_RPC,
|
|
465
|
+
handler: (req, res) => {
|
|
466
|
+
const reply = (code, payload) => {
|
|
467
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
468
|
+
res.end(JSON.stringify(payload));
|
|
469
|
+
};
|
|
470
|
+
const chunks = [];
|
|
471
|
+
req.on('data', (c) => chunks.push(c));
|
|
472
|
+
req.on('end', async () => {
|
|
473
|
+
let body;
|
|
474
|
+
try {
|
|
475
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
476
|
+
} catch (error) {
|
|
477
|
+
reply(400, { error: `bad json: ${String(error?.message ?? error)}` });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const result = await handleBridge(ctx, config, {
|
|
482
|
+
remoteSessions,
|
|
483
|
+
follow,
|
|
484
|
+
persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
|
|
485
|
+
body,
|
|
486
|
+
});
|
|
487
|
+
reply(200, { ok: true, result });
|
|
488
|
+
} catch (error) {
|
|
489
|
+
reply(200, { ok: false, error: String(error?.message ?? error) });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
},
|
|
493
|
+
}),
|
|
494
|
+
'dsh-remote-mobile: browser rpc route',
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// ---- 浏览器流式订阅(全局一条) ----
|
|
498
|
+
ctx.effect(
|
|
499
|
+
() =>
|
|
500
|
+
ctx.webServer.register({
|
|
501
|
+
kind: 'exact',
|
|
502
|
+
path: ROUTE_STREAM,
|
|
503
|
+
handler: (req, res) => {
|
|
504
|
+
res.writeHead(200, {
|
|
505
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
506
|
+
'cache-control': 'no-store, no-transform',
|
|
507
|
+
connection: 'keep-alive',
|
|
508
|
+
'x-accel-buffering': 'no',
|
|
509
|
+
});
|
|
510
|
+
res.write(': connected\n\n');
|
|
511
|
+
watchers.add(res);
|
|
512
|
+
const heartbeat = setInterval(() => {
|
|
513
|
+
try {
|
|
514
|
+
res.write(': ping\n\n');
|
|
515
|
+
} catch {
|
|
516
|
+
clearInterval(heartbeat);
|
|
517
|
+
}
|
|
518
|
+
}, 15000);
|
|
519
|
+
const cleanup = () => {
|
|
520
|
+
clearInterval(heartbeat);
|
|
521
|
+
watchers.delete(res);
|
|
522
|
+
};
|
|
523
|
+
req.on('close', cleanup);
|
|
524
|
+
req.on('error', cleanup);
|
|
525
|
+
},
|
|
526
|
+
}),
|
|
527
|
+
'dsh-remote-mobile: browser stream route',
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// ---- 启动时恢复:把持久化的远程会话重新接上电脑 ----
|
|
531
|
+
ctx.effect(() => {
|
|
532
|
+
for (const sessionId of remoteSessions) follow(sessionId);
|
|
533
|
+
if (remoteSessions.size > 0) {
|
|
534
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] 已恢复 ${remoteSessions.size} 个远程会话的事件流`);
|
|
535
|
+
}
|
|
536
|
+
return () => {
|
|
537
|
+
for (const controller of follows.values()) controller.abort();
|
|
538
|
+
follows.clear();
|
|
539
|
+
for (const res of watchers) {
|
|
540
|
+
try {
|
|
541
|
+
res.end();
|
|
542
|
+
} catch {
|
|
543
|
+
/* 已断开 */
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
watchers.clear();
|
|
547
|
+
};
|
|
548
|
+
}, 'dsh-remote-mobile: restore + teardown');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 挑一个**手机本地**的 cwd 给新建的远程会话用。
|
|
553
|
+
*
|
|
554
|
+
* ## ⚠️ 为什么不能直接用 `process.cwd()`
|
|
555
|
+
*
|
|
556
|
+
* 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
|
|
557
|
+
* (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter:
|
|
558
|
+
* `record.sessionIds.filter(id => host.sessionPath(id) === record.path)`,
|
|
559
|
+
* 而 `sessionPath` 是 header.cwd 的 `realpathNormalize`)。
|
|
560
|
+
*
|
|
561
|
+
* 所以 cwd 落在任何已有工作区之外时,这个会话**不属于任何工作区**:
|
|
562
|
+
* 侧边栏没有它、composer 也被判成「没选工作区」而禁用 —— 界面表现是停在
|
|
563
|
+
* hero 页的「选择一个工作区开始」,看着像新会话没建成功,其实会话已经建好了
|
|
564
|
+
* (磁盘上有日志、两端 sessionId 一致)。
|
|
565
|
+
*
|
|
566
|
+
* 引擎的 `process.cwd()` 是 `files/workspace`,而手机默认工作区是
|
|
567
|
+
* `files/workspace/deepseek-harness/default-workspace`(**更深一层**),
|
|
568
|
+
* 两者不相等,正好踩中这个坑。
|
|
569
|
+
*
|
|
570
|
+
* 优先级:默认工作区 → 任意已有工作区 → 引擎 cwd。
|
|
571
|
+
* 前两个都拿不到时回退 `process.cwd()`(新建的工作区会按 cwd 反向认领它,
|
|
572
|
+
* 见 `dsh-workspace` 的 `bootstrap`/`groupsByPath`,不算死路)。
|
|
573
|
+
*
|
|
574
|
+
* @param ctx - 宿主 Cordis 上下文。
|
|
575
|
+
* @returns 手机本地的绝对路径。
|
|
576
|
+
*/
|
|
577
|
+
function localCwdFor(ctx) {
|
|
578
|
+
try {
|
|
579
|
+
const registry = ctx.get?.('workspaceRegistry');
|
|
580
|
+
const all = registry?.list?.() ?? [];
|
|
581
|
+
if (all.length === 0) return process.cwd();
|
|
582
|
+
const preferredId = registry?.state?.defaultWorkspaceId;
|
|
583
|
+
const chosen = all.find((entity) => entity.id === preferredId) ?? all[0];
|
|
584
|
+
const path = chosen?.path;
|
|
585
|
+
return typeof path === 'string' && path !== '' ? path : process.cwd();
|
|
586
|
+
} catch {
|
|
587
|
+
return process.cwd();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* 处理浏览器桥来的一条 RPC。
|
|
593
|
+
*
|
|
594
|
+
* @param ctx - Cordis 上下文。
|
|
595
|
+
* @param config - 远程配置。
|
|
596
|
+
* @param io - `{ remoteSessions, follow, persist, body }`。
|
|
597
|
+
*/
|
|
598
|
+
async function handleBridge(ctx, config, io) {
|
|
599
|
+
const params = io.body?.params ?? {};
|
|
600
|
+
switch (io.body?.method) {
|
|
601
|
+
/** 连接自检:手机 → 电脑。 */
|
|
602
|
+
case 'ping':
|
|
603
|
+
return callRemote(config, 'ping', {});
|
|
604
|
+
|
|
605
|
+
/** 列电脑上的目录。 */
|
|
606
|
+
case 'listDesktop':
|
|
607
|
+
return callRemote(config, 'listDirectory', { path: params.path });
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* 在电脑的某个目录下开一个新会话。
|
|
611
|
+
*
|
|
612
|
+
* 两端用**同一个 sessionId**:手机本地建会话(用手机 cwd,见文件头),
|
|
613
|
+
* 电脑端在它自己的 cwd 下建同名会话。之后事件就能直接互搬。
|
|
614
|
+
*/
|
|
615
|
+
case 'createSession': {
|
|
616
|
+
if (typeof params.path !== 'string' || params.path === '') {
|
|
617
|
+
throw new Error('请先在电脑页签里选一个目录');
|
|
618
|
+
}
|
|
619
|
+
if (config.token === '') {
|
|
620
|
+
throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token`);
|
|
621
|
+
}
|
|
622
|
+
const sessionId = `session-${randomUUID()}`;
|
|
623
|
+
// ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
|
|
624
|
+
// (Android 是 posix 语义,`path.posix.isAbsolute('C:/...') === false`,
|
|
625
|
+
// 且官方 createOrAdopt 会 `mkdir(cwd, {recursive:true})` 真去建目录)。
|
|
626
|
+
const cwd = localCwdFor(ctx);
|
|
627
|
+
// ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
|
|
628
|
+
//
|
|
629
|
+
// `ctx.sessions.create()` 只把会话塞进内存 store:持久化层既没
|
|
630
|
+
// `registerCreated()` 也没 `adopt()` 一个写句柄,于是日志**永远不落盘**,
|
|
631
|
+
// 而前端会话列表读的是 `sessionQuery.listSessions()`(磁盘 + pending),
|
|
632
|
+
// 因此列表里看不到这个会话、`openSession()` 也就跳不过去 —— 表现是
|
|
633
|
+
// 「电脑端建了会话,手机界面却停在『选择一个工作区开始』」。
|
|
634
|
+
//
|
|
635
|
+
// 官方链路 `sessionController.create` → `agents.ensureSession` →
|
|
636
|
+
// `agents.create` → `sessionPersistence.create` 才会真正建写句柄并落盘。
|
|
637
|
+
//
|
|
638
|
+
// ⚠️ 这**不会**让手机跑本地模型:远程会话的 `prompt` 被我们拦下来转发电脑
|
|
639
|
+
// (见上面的 prompt/cancel 拦截),这里的 agent 只是个空壳。
|
|
640
|
+
if (typeof ctx.sessionController?.create !== 'function') {
|
|
641
|
+
throw new Error('sessionController.create 不可用,无法在手机本地建会话');
|
|
642
|
+
}
|
|
643
|
+
await ctx.sessionController.create({ sessionId, cwd });
|
|
644
|
+
try {
|
|
645
|
+
await callRemote(config, 'createSession', { sessionId, cwd: params.path });
|
|
646
|
+
} catch (error) {
|
|
647
|
+
// 电脑没建成功就别留在手机本地,免得出现一个永远发不出消息的会话。
|
|
648
|
+
io.remoteSessions.delete(sessionId);
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
io.remoteSessions.add(sessionId);
|
|
652
|
+
io.persist();
|
|
653
|
+
io.follow(sessionId);
|
|
654
|
+
return { sessionId, cwd, desktopPath: params.path };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** 手机本地会话(供前端判断某个会话是不是远程会话)。 */
|
|
658
|
+
case 'isRemote':
|
|
659
|
+
return { remote: io.remoteSessions.has(params.sessionId) };
|
|
660
|
+
|
|
661
|
+
default:
|
|
662
|
+
throw new Error(`unknown method "${String(io.body?.method)}"`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH };
|