dsh-jiuling-remote-mobile 0.0.0-stage → 1.0.1
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 +636 -0
- package/lib/index.js +566 -0
- package/package.json +32 -4
- package/README.md +0 -3
package/lib/client.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-mobile —— 手机端(client half,跑在 WebView 的页面世界里)。
|
|
3
|
+
*
|
|
4
|
+
* ## 这个文件做两件事
|
|
5
|
+
*
|
|
6
|
+
* 1. **「电脑文件夹」入口** —— 注册进 `directoryFlow` slot,让选文件夹的对话框里
|
|
7
|
+
* 多出一个「电脑」页签:浏览电脑目录、在电脑上直接开一个会话。
|
|
8
|
+
* 2. **电脑实时消息流** —— 订阅宿主桥的全局 SSE,把电脑的流式帧转成
|
|
9
|
+
* `assistant/live-chunk` transient 注入本地会话,于是手机上有逐字动画。
|
|
10
|
+
*
|
|
11
|
+
* ## ⚠️ 为什么是「取代」官方对话框,而不是「并列」
|
|
12
|
+
*
|
|
13
|
+
* `directoryFlow` 是 `kind: "single"` 的 slot,渲染时取 `entriesOfSlot(name)[0]`,
|
|
14
|
+
* 而 entries 按 priority **升序**排(见 dsh-client-ui-renderer L1148-1151)。
|
|
15
|
+
* 也就是说这个 slot 同时只能渲染**一个** occupant,无法「并排加一项」。
|
|
16
|
+
*
|
|
17
|
+
* 所以本插件用 **priority -1** 占据首位,自己实现一个双页签对话框:
|
|
18
|
+
* 「手机」页签复刻官方浏览行为(走 `uiWorkspace.listDirectory`,与官方 browse
|
|
19
|
+
* 后端是同一个 API),「电脑」页签走宿主桥。对用户的观感就是
|
|
20
|
+
* 「选文件夹的时候多了一个电脑入口」。
|
|
21
|
+
*
|
|
22
|
+
* ## ⚠️ 语法约束(旧内核)
|
|
23
|
+
*
|
|
24
|
+
* 本 App 有按 chrome73 降级编译的前端 bundle(`webview-compat.zip`),
|
|
25
|
+
* 所以这里**不用**可选链 `?.`、空值合并 `??`、`Object.fromEntries`、`Array.flat`
|
|
26
|
+
* 这些 chrome80+ 的语法。
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-jiuling-remote-mobile/client
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
window.__ModuleLoader__.load({
|
|
32
|
+
id: 'dsh-jiuling-remote-mobile',
|
|
33
|
+
factory: (require) => {
|
|
34
|
+
var module = { exports: {} };
|
|
35
|
+
var exports = module.exports;
|
|
36
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
37
|
+
|
|
38
|
+
var react = require('react');
|
|
39
|
+
var h = react.createElement;
|
|
40
|
+
|
|
41
|
+
/** 宿主桥的路由(同源,直接相对路径)。 */
|
|
42
|
+
var ROUTE_RPC = '/api/dsh-remote/rpc';
|
|
43
|
+
var ROUTE_STREAM = '/api/dsh-remote/stream';
|
|
44
|
+
|
|
45
|
+
/** 配色取自官方设计令牌,拿不到时回退到中性值,避免与官方 UI 撞色。 */
|
|
46
|
+
var CSS = {
|
|
47
|
+
overlay:
|
|
48
|
+
'position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;' +
|
|
49
|
+
'background:rgba(0,0,0,.42);padding:16px;',
|
|
50
|
+
panel:
|
|
51
|
+
'width:100%;max-width:520px;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;' +
|
|
52
|
+
'border-radius:14px;background:var(--dsw-alias-bg-primary,#1b1b1f);' +
|
|
53
|
+
'color:var(--dsw-alias-label-primary,#e8e8ea);' +
|
|
54
|
+
'box-shadow:0 18px 48px rgba(0,0,0,.38);' +
|
|
55
|
+
'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.1));',
|
|
56
|
+
head: 'display:flex;align-items:center;gap:10px;padding:14px 16px 10px;flex:none;',
|
|
57
|
+
title: 'font-size:15px;font-weight:600;letter-spacing:.01em;flex:1;min-width:0;',
|
|
58
|
+
tabs: 'display:flex;gap:6px;padding:0 16px 10px;flex:none;',
|
|
59
|
+
tab:
|
|
60
|
+
'flex:1;height:32px;border-radius:8px;border:1px solid transparent;cursor:pointer;' +
|
|
61
|
+
'font-size:13px;background:transparent;color:var(--dsw-alias-label-secondary,#9a9aa2);',
|
|
62
|
+
tabOn:
|
|
63
|
+
'flex:1;height:32px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
|
|
64
|
+
'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.14));' +
|
|
65
|
+
'background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07));' +
|
|
66
|
+
'color:var(--dsw-alias-label-primary,#e8e8ea);',
|
|
67
|
+
path:
|
|
68
|
+
'display:flex;align-items:center;gap:8px;padding:8px 16px;flex:none;font-size:12px;' +
|
|
69
|
+
'color:var(--dsw-alias-label-tertiary,#7d7d85);' +
|
|
70
|
+
'border-top:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));' +
|
|
71
|
+
'border-bottom:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));',
|
|
72
|
+
pathText:
|
|
73
|
+
'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
|
|
74
|
+
list: 'flex:1;min-height:120px;overflow:auto;padding:6px 8px;',
|
|
75
|
+
row:
|
|
76
|
+
'display:flex;align-items:center;gap:10px;width:100%;text-align:left;padding:9px 10px;' +
|
|
77
|
+
'border-radius:8px;border:none;background:transparent;cursor:pointer;font-size:14px;' +
|
|
78
|
+
'color:var(--dsw-alias-label-primary,#e8e8ea);',
|
|
79
|
+
foot: 'display:flex;gap:8px;padding:12px 16px;flex:none;justify-content:flex-end;',
|
|
80
|
+
btn:
|
|
81
|
+
'height:34px;padding:0 16px;border-radius:8px;cursor:pointer;font-size:13px;' +
|
|
82
|
+
'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.16));' +
|
|
83
|
+
'background:transparent;color:var(--dsw-alias-label-primary,#e8e8ea);',
|
|
84
|
+
btnPrimary:
|
|
85
|
+
'height:34px;padding:0 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
|
|
86
|
+
'border:none;background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;',
|
|
87
|
+
note: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-label-tertiary,#7d7d85);',
|
|
88
|
+
err: 'padding:10px 16px;font-size:12px;color:var(--dsw-alias-state-error-primary,#ff6b6b);word-break:break-all;',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
|
|
93
|
+
*
|
|
94
|
+
* @param method - 方法名。
|
|
95
|
+
* @param params - 参数。
|
|
96
|
+
* @returns 结果的 Promise。
|
|
97
|
+
*/
|
|
98
|
+
function bridge(method, params) {
|
|
99
|
+
return fetch(ROUTE_RPC, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'content-type': 'application/json' },
|
|
102
|
+
body: JSON.stringify({ method: method, params: params || {} }),
|
|
103
|
+
}).then(
|
|
104
|
+
(res) => res.json(),
|
|
105
|
+
(error) => {
|
|
106
|
+
throw new Error('无法连接本地桥: ' + String((error && error.message) || error));
|
|
107
|
+
},
|
|
108
|
+
).then((payload) => {
|
|
109
|
+
if (!payload || payload.ok !== true) {
|
|
110
|
+
throw new Error((payload && payload.error) || '本地桥调用失败');
|
|
111
|
+
}
|
|
112
|
+
return payload.result;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 从任意形状的目录列表里取条目数组(宿主/官方后端的字段名略有差异)。 */
|
|
117
|
+
function entriesOf(listing) {
|
|
118
|
+
if (!listing || typeof listing !== 'object') return [];
|
|
119
|
+
if (Array.isArray(listing.entries)) return listing.entries;
|
|
120
|
+
if (Array.isArray(listing.items)) return listing.items;
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 取条目的显示名。 */
|
|
125
|
+
function nameOf(entry) {
|
|
126
|
+
return entry.name || entry.title || entry.displayName || String(entry.path || '');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 取条目的完整路径。 */
|
|
130
|
+
function pathOf(entry) {
|
|
131
|
+
return entry.path || entry.fullPath || entry.name || '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 是否是可进入的文件夹。 */
|
|
135
|
+
function isDir(entry) {
|
|
136
|
+
if (entry.enterable === false) return false;
|
|
137
|
+
if (entry.isDirectory === false) return false;
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 取列表的当前路径与上级路径。 */
|
|
142
|
+
function locationOf(listing) {
|
|
143
|
+
var path = (listing && listing.path) || '';
|
|
144
|
+
var parent = (listing && listing.parent) || '';
|
|
145
|
+
// 后端明确给空 parent = 已经到顶了(Linux 的 `/`、Windows 的 `C:\`),
|
|
146
|
+
// 这时**不能**再自己兜底推算:切 `C:\` 会得到 `C:`,而它在前端眼里
|
|
147
|
+
// 也是绝对路径、按钮会被点亮,点下去后端直接报错。
|
|
148
|
+
var atRoot = path === '' || path === '/' || /^[a-zA-Z]:[\\/]?$/.test(path);
|
|
149
|
+
if (parent === '' && !atRoot) {
|
|
150
|
+
var trimmed = path.replace(/[\\/]+$/, '');
|
|
151
|
+
var slash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
152
|
+
if (slash > 0) parent = trimmed.slice(0, slash);
|
|
153
|
+
}
|
|
154
|
+
return { path: path, parent: parent, home: (listing && listing.home) || '' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 「选择工作区目录」对话框:手机 / 电脑 双页签。
|
|
159
|
+
*
|
|
160
|
+
* props 是 slot 契约的 `{open, busy, onPicked, onCancel, onError}`,
|
|
161
|
+
* 外加 `inject` 送进来的浏览能力与宿主桥。
|
|
162
|
+
*/
|
|
163
|
+
function RemoteDirectoryFlow(props) {
|
|
164
|
+
var open = props.open;
|
|
165
|
+
var onPicked = props.onPicked;
|
|
166
|
+
var onCancel = props.onCancel;
|
|
167
|
+
var listDirectory = props.listDirectory;
|
|
168
|
+
var createDirectory = props.createDirectory;
|
|
169
|
+
var openSession = props.openSession;
|
|
170
|
+
|
|
171
|
+
var statePair = react.useState({
|
|
172
|
+
tab: 'local',
|
|
173
|
+
path: null,
|
|
174
|
+
listing: null,
|
|
175
|
+
loading: false,
|
|
176
|
+
error: null,
|
|
177
|
+
creating: false,
|
|
178
|
+
});
|
|
179
|
+
var state = statePair[0];
|
|
180
|
+
var setState = statePair[1];
|
|
181
|
+
|
|
182
|
+
var patch = react.useCallback(function (next) {
|
|
183
|
+
setState(function (prev) {
|
|
184
|
+
return Object.assign({}, prev, next);
|
|
185
|
+
});
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
var aliveRef = react.useRef(true);
|
|
189
|
+
react.useEffect(function () {
|
|
190
|
+
aliveRef.current = true;
|
|
191
|
+
return function () {
|
|
192
|
+
aliveRef.current = false;
|
|
193
|
+
};
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
// 每次打开都回到「手机」页签,避免上次停在电脑目录造成困惑。
|
|
197
|
+
react.useEffect(
|
|
198
|
+
function () {
|
|
199
|
+
if (open) patch({ tab: 'local', path: null, error: null });
|
|
200
|
+
},
|
|
201
|
+
[open, patch],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// 加载当前页签、当前路径的目录列表。
|
|
205
|
+
var tab = state.tab;
|
|
206
|
+
var path = state.path;
|
|
207
|
+
react.useEffect(
|
|
208
|
+
function () {
|
|
209
|
+
if (!open) return undefined;
|
|
210
|
+
patch({ loading: true, error: null });
|
|
211
|
+
var load =
|
|
212
|
+
tab === 'desktop'
|
|
213
|
+
? bridge('listDesktop', path === null ? {} : { path: path })
|
|
214
|
+
: Promise.resolve(listDirectory(path === null ? undefined : path));
|
|
215
|
+
load.then(
|
|
216
|
+
function (listing) {
|
|
217
|
+
if (!aliveRef.current) return;
|
|
218
|
+
patch({ loading: false, listing: listing });
|
|
219
|
+
},
|
|
220
|
+
function (reason) {
|
|
221
|
+
if (!aliveRef.current) return;
|
|
222
|
+
patch({
|
|
223
|
+
loading: false,
|
|
224
|
+
listing: null,
|
|
225
|
+
error: String((reason && reason.message) || reason),
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
return undefined;
|
|
230
|
+
},
|
|
231
|
+
[open, tab, path, listDirectory, patch],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (!open) return null;
|
|
235
|
+
|
|
236
|
+
var loc = locationOf(state.listing);
|
|
237
|
+
var entries = entriesOf(state.listing).filter(isDir);
|
|
238
|
+
|
|
239
|
+
var switchTab = function (next) {
|
|
240
|
+
if (next === state.tab) return;
|
|
241
|
+
patch({ tab: next, path: null, listing: null, error: null });
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** 「手机」页签:采纳为本地工作区。 */
|
|
245
|
+
var pickLocal = function () {
|
|
246
|
+
if (loc.path === '') return;
|
|
247
|
+
onPicked(loc.path);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** 「电脑」页签:在电脑的这个目录上开一个新会话,并跳到它。 */
|
|
251
|
+
var openDesktop = function () {
|
|
252
|
+
if (loc.path === '' || state.creating) return;
|
|
253
|
+
patch({ creating: true, error: null });
|
|
254
|
+
bridge('createSession', { path: loc.path }).then(
|
|
255
|
+
function (result) {
|
|
256
|
+
if (!aliveRef.current) return;
|
|
257
|
+
patch({ creating: false });
|
|
258
|
+
onCancel();
|
|
259
|
+
if (openSession !== undefined && result && result.sessionId) {
|
|
260
|
+
openSession(result.sessionId);
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
function (reason) {
|
|
264
|
+
if (!aliveRef.current) return;
|
|
265
|
+
patch({ creating: false, error: String((reason && reason.message) || reason) });
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
var makeFolder = function () {
|
|
271
|
+
if (loc.path === '' || createDirectory === undefined) return;
|
|
272
|
+
var name = window.prompt('文件夹名称', '新建文件夹');
|
|
273
|
+
if (name === null || name === '') return;
|
|
274
|
+
Promise.resolve(createDirectory(loc.path, name)).then(
|
|
275
|
+
function () {
|
|
276
|
+
if (aliveRef.current) patch({ path: loc.path });
|
|
277
|
+
},
|
|
278
|
+
function (reason) {
|
|
279
|
+
if (aliveRef.current) {
|
|
280
|
+
patch({ error: String((reason && reason.message) || reason) });
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
var rows = entries.map(function (entry) {
|
|
287
|
+
var full = pathOf(entry);
|
|
288
|
+
return h(
|
|
289
|
+
'button',
|
|
290
|
+
{
|
|
291
|
+
key: full,
|
|
292
|
+
type: 'button',
|
|
293
|
+
style: CSS.row,
|
|
294
|
+
onMouseEnter: function (event) {
|
|
295
|
+
event.currentTarget.style.background =
|
|
296
|
+
'var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07))';
|
|
297
|
+
},
|
|
298
|
+
onMouseLeave: function (event) {
|
|
299
|
+
event.currentTarget.style.background = 'transparent';
|
|
300
|
+
},
|
|
301
|
+
onClick: function () {
|
|
302
|
+
patch({ path: full });
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
h('span', { style: { opacity: 0.55, flex: 'none' } }, '\u25B8'),
|
|
306
|
+
h(
|
|
307
|
+
'span',
|
|
308
|
+
{
|
|
309
|
+
style: {
|
|
310
|
+
flex: '1',
|
|
311
|
+
minWidth: 0,
|
|
312
|
+
overflow: 'hidden',
|
|
313
|
+
textOverflow: 'ellipsis',
|
|
314
|
+
whiteSpace: 'nowrap',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
nameOf(entry),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return h(
|
|
323
|
+
'div',
|
|
324
|
+
{
|
|
325
|
+
style: CSS.overlay,
|
|
326
|
+
onClick: function (event) {
|
|
327
|
+
if (event.target === event.currentTarget) onCancel();
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
h(
|
|
331
|
+
'div',
|
|
332
|
+
{ style: CSS.panel, role: 'dialog', 'aria-label': '选择工作区目录' },
|
|
333
|
+
h(
|
|
334
|
+
'div',
|
|
335
|
+
{ style: CSS.head },
|
|
336
|
+
h('div', { style: CSS.title }, '选择工作区目录'),
|
|
337
|
+
h(
|
|
338
|
+
'button',
|
|
339
|
+
{ type: 'button', 'aria-label': '关闭', style: CSS.btn, onClick: onCancel },
|
|
340
|
+
'关闭',
|
|
341
|
+
),
|
|
342
|
+
),
|
|
343
|
+
h(
|
|
344
|
+
'div',
|
|
345
|
+
{ style: CSS.tabs },
|
|
346
|
+
h(
|
|
347
|
+
'button',
|
|
348
|
+
{
|
|
349
|
+
type: 'button',
|
|
350
|
+
style: state.tab === 'local' ? CSS.tabOn : CSS.tab,
|
|
351
|
+
onClick: function () {
|
|
352
|
+
switchTab('local');
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
'手机',
|
|
356
|
+
),
|
|
357
|
+
h(
|
|
358
|
+
'button',
|
|
359
|
+
{
|
|
360
|
+
type: 'button',
|
|
361
|
+
style: state.tab === 'desktop' ? CSS.tabOn : CSS.tab,
|
|
362
|
+
onClick: function () {
|
|
363
|
+
switchTab('desktop');
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
'电脑',
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
h(
|
|
370
|
+
'div',
|
|
371
|
+
{ style: CSS.path },
|
|
372
|
+
h(
|
|
373
|
+
'button',
|
|
374
|
+
{
|
|
375
|
+
type: 'button',
|
|
376
|
+
style: CSS.btn,
|
|
377
|
+
disabled: loc.parent === '',
|
|
378
|
+
onClick: function () {
|
|
379
|
+
patch({ path: loc.parent === '' ? null : loc.parent });
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
'上一级',
|
|
383
|
+
),
|
|
384
|
+
h('div', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
|
|
385
|
+
),
|
|
386
|
+
state.error !== null ? h('div', { style: CSS.err, role: 'alert' }, state.error) : null,
|
|
387
|
+
state.loading
|
|
388
|
+
? h('div', { style: CSS.note }, '加载中…')
|
|
389
|
+
: h(
|
|
390
|
+
'div',
|
|
391
|
+
{ style: CSS.list },
|
|
392
|
+
rows.length > 0 ? rows : h('div', { style: CSS.note }, '这里没有子文件夹'),
|
|
393
|
+
),
|
|
394
|
+
state.tab === 'desktop'
|
|
395
|
+
? h(
|
|
396
|
+
'div',
|
|
397
|
+
{ style: CSS.note },
|
|
398
|
+
'在电脑的这个目录上开一个新对话,手机上会同步显示它的全部过程。',
|
|
399
|
+
)
|
|
400
|
+
: null,
|
|
401
|
+
h(
|
|
402
|
+
'div',
|
|
403
|
+
{ style: CSS.foot },
|
|
404
|
+
state.tab === 'local' && createDirectory !== undefined
|
|
405
|
+
? h('button', { type: 'button', style: CSS.btn, onClick: makeFolder }, '新建文件夹')
|
|
406
|
+
: null,
|
|
407
|
+
state.tab === 'local'
|
|
408
|
+
? h(
|
|
409
|
+
'button',
|
|
410
|
+
{
|
|
411
|
+
type: 'button',
|
|
412
|
+
style: CSS.btnPrimary,
|
|
413
|
+
disabled: loc.path === '' || props.busy,
|
|
414
|
+
onClick: pickLocal,
|
|
415
|
+
},
|
|
416
|
+
props.busy ? '处理中…' : '选择此文件夹',
|
|
417
|
+
)
|
|
418
|
+
: h(
|
|
419
|
+
'button',
|
|
420
|
+
{
|
|
421
|
+
type: 'button',
|
|
422
|
+
style: CSS.btnPrimary,
|
|
423
|
+
disabled: loc.path === '' || state.creating,
|
|
424
|
+
onClick: openDesktop,
|
|
425
|
+
},
|
|
426
|
+
state.creating ? '正在电脑上新建…' : '在此新建对话',
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
//#region 电脑流式帧 → 本地 transient
|
|
434
|
+
/**
|
|
435
|
+
* 每个会话的流式折叠状态。
|
|
436
|
+
*
|
|
437
|
+
* 与官方 `ClientAssistantStream` 同构:`start` 建立 attempt、`chunk` 逐帧递增
|
|
438
|
+
* `nextIndex`、`end` 收尾。**序号不连续时直接放弃这一轮流式动画**
|
|
439
|
+
* (不抛错)——durable 事件随后会把完整内容补上,用户看到的是「没有逐字动画」
|
|
440
|
+
* 而不是「内容缺失」。这与官方 `rebaseline` 的兜底方向一致。
|
|
441
|
+
*/
|
|
442
|
+
var attempts = new Map();
|
|
443
|
+
|
|
444
|
+
/** 取某会话的绑定(`eventSource` 就是浏览器侧的会话事件源)。 */
|
|
445
|
+
function bindingOf(ctx, sessionId) {
|
|
446
|
+
try {
|
|
447
|
+
return ctx.sessions.binding(sessionId);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 退役某会话的逐字残片。
|
|
455
|
+
*
|
|
456
|
+
* ⚠️ **必须由我们来做**:官方的清理点是
|
|
457
|
+
* `MutableSessionEventSource.settleAssistant(attemptId)`,它只被
|
|
458
|
+
* `ClientAssistantStream.acceptEventChange` 调用 —— 而那条链服务于**远程
|
|
459
|
+
* 事件流**。手机本地的 durable 事件是宿主直接 `session.append()` 进会话的,
|
|
460
|
+
* 走不到它。不清理的话,逐字残片会永久留在窗口里,与随后到达的完整消息
|
|
461
|
+
* 叠成两份。
|
|
462
|
+
*
|
|
463
|
+
* 不传 `entry`:durable 的完整消息会由会话事件流自己送进窗口,这里只负责
|
|
464
|
+
* 把残片摘掉。
|
|
465
|
+
*
|
|
466
|
+
* @param ctx - client 根上下文。
|
|
467
|
+
* @param sessionId - 会话 id。
|
|
468
|
+
* @param attemptId - 要退役的 attempt。
|
|
469
|
+
*/
|
|
470
|
+
function retireTransient(ctx, sessionId, attemptId) {
|
|
471
|
+
if (typeof attemptId !== 'string') return;
|
|
472
|
+
var binding = bindingOf(ctx, sessionId);
|
|
473
|
+
if (binding === undefined || binding.eventSource === undefined) return;
|
|
474
|
+
try {
|
|
475
|
+
binding.eventSource.settleAssistant(attemptId);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
/* 窗口已被替换:无需清理 */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 把一帧电脑流式数据折进手机本地会话的 transient 行。
|
|
483
|
+
*
|
|
484
|
+
* @param ctx - client 根上下文。
|
|
485
|
+
* @param sessionId - 两端同名的会话 id。
|
|
486
|
+
* @param frame - 电脑端流式帧。
|
|
487
|
+
*/
|
|
488
|
+
function injectFrame(ctx, sessionId, frame) {
|
|
489
|
+
if (frame.type === 'start') {
|
|
490
|
+
// 上一轮若还挂着(start 帧没等到 end 帧),先把它退役,免得起重。
|
|
491
|
+
var stale = attempts.get(sessionId);
|
|
492
|
+
if (stale !== undefined) {
|
|
493
|
+
attempts.delete(sessionId);
|
|
494
|
+
retireTransient(ctx, sessionId, stale.attemptId);
|
|
495
|
+
}
|
|
496
|
+
attempts.set(sessionId, {
|
|
497
|
+
attemptId: frame.attemptId,
|
|
498
|
+
turn: frame.turn,
|
|
499
|
+
step: frame.step,
|
|
500
|
+
nextIndex: 0,
|
|
501
|
+
gap: 0,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
var attempt = attempts.get(sessionId);
|
|
506
|
+
if (attempt === undefined || attempt.attemptId !== frame.attemptId) return;
|
|
507
|
+
if (frame.type === 'end') {
|
|
508
|
+
attempts.delete(sessionId);
|
|
509
|
+
// `abandoned` 不会有 durable 结算,全靠这里把残片摘掉。
|
|
510
|
+
retireTransient(ctx, sessionId, attempt.attemptId);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (frame.type !== 'chunk') return;
|
|
514
|
+
if (frame.index !== attempt.nextIndex) {
|
|
515
|
+
// 序号跳了:这一轮流式不可信,交给 durable 通道兜底。
|
|
516
|
+
attempts.delete(sessionId);
|
|
517
|
+
retireTransient(ctx, sessionId, attempt.attemptId);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
attempt.nextIndex += 1;
|
|
521
|
+
attempt.gap += 1;
|
|
522
|
+
|
|
523
|
+
var binding = bindingOf(ctx, sessionId);
|
|
524
|
+
if (binding === undefined) return;
|
|
525
|
+
|
|
526
|
+
// transient 的 seq 要插在已提交事件之间:官方用 durableCursor + 1 - 1/(n+1)。
|
|
527
|
+
var cursor = -1;
|
|
528
|
+
var snapshot = binding.eventSource.getSnapshot();
|
|
529
|
+
var entries = (snapshot && snapshot.entries) || [];
|
|
530
|
+
for (var index = 0; index < entries.length; index += 1) {
|
|
531
|
+
var entry = entries[index];
|
|
532
|
+
if (entry.type !== 'transient' && entry.event && typeof entry.event.seq === 'number') {
|
|
533
|
+
if (entry.event.seq > cursor) cursor = entry.event.seq;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
binding.eventSource.append({
|
|
538
|
+
type: 'transient',
|
|
539
|
+
event: {
|
|
540
|
+
type: 'assistant/live-chunk',
|
|
541
|
+
seq: cursor + 1 - 1 / (attempt.gap + 1),
|
|
542
|
+
time: typeof frame.time === 'number' ? frame.time : Date.now(),
|
|
543
|
+
data: {
|
|
544
|
+
attemptId: attempt.attemptId,
|
|
545
|
+
turn: attempt.turn,
|
|
546
|
+
step: attempt.step,
|
|
547
|
+
chunk: frame.chunk,
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
} catch (error) {
|
|
552
|
+
attempts.delete(sessionId);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 订阅宿主桥的全局 SSE。
|
|
558
|
+
*
|
|
559
|
+
* 全局一条:手机 WebView 对并发连接有上限,且一条连接就能服务所有会话。
|
|
560
|
+
* EventSource 自带断线重连,所以这里不做退避。
|
|
561
|
+
*
|
|
562
|
+
* @param ctx - client 根上下文。
|
|
563
|
+
*/
|
|
564
|
+
function subscribe(ctx) {
|
|
565
|
+
var source = new EventSource(ROUTE_STREAM);
|
|
566
|
+
source.addEventListener('frame', function (message) {
|
|
567
|
+
var payload;
|
|
568
|
+
try {
|
|
569
|
+
payload = JSON.parse(message.data);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (!payload || typeof payload.sessionId !== 'string') return;
|
|
574
|
+
injectFrame(ctx, payload.sessionId, payload.frame);
|
|
575
|
+
});
|
|
576
|
+
// durable 结算兜底:万一 `end` 帧丢了,残片也要被摘掉。
|
|
577
|
+
source.addEventListener('settled', function (message) {
|
|
578
|
+
var payload;
|
|
579
|
+
try {
|
|
580
|
+
payload = JSON.parse(message.data);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (!payload || typeof payload.sessionId !== 'string') return;
|
|
585
|
+
var attempt = attempts.get(payload.sessionId);
|
|
586
|
+
if (attempt === undefined) return;
|
|
587
|
+
attempts.delete(payload.sessionId);
|
|
588
|
+
retireTransient(ctx, payload.sessionId, attempt.attemptId);
|
|
589
|
+
});
|
|
590
|
+
return function () {
|
|
591
|
+
source.close();
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
|
|
596
|
+
/** 需要宿主提供的服务:slot 注册表、工作区浏览与导航、会话数据。 */
|
|
597
|
+
const inject = ['slots', 'uiWorkspace', 'sessions'];
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Client 插件主体:注册「手机 / 电脑」双页签的目录选择对话框 + 订阅流式帧。
|
|
601
|
+
*
|
|
602
|
+
* @param ctx - client 根上下文。
|
|
603
|
+
*/
|
|
604
|
+
function apply(ctx) {
|
|
605
|
+
const injected = () => ({
|
|
606
|
+
listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal),
|
|
607
|
+
createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name),
|
|
608
|
+
openSession: (sessionId) => ctx.uiWorkspace.openSession(sessionId),
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// 负 priority:single slot 取 entries[0],entries 按 priority 升序 ⇒ 最先渲染。
|
|
612
|
+
const register = (name) =>
|
|
613
|
+
ctx.slots.register(
|
|
614
|
+
{
|
|
615
|
+
name: name,
|
|
616
|
+
priority: -1,
|
|
617
|
+
inject: injected,
|
|
618
|
+
},
|
|
619
|
+
RemoteDirectoryFlow,
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
ctx.slots.inject('conversation.hero.workspace.directoryFlow', () =>
|
|
623
|
+
ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
|
624
|
+
yield register('conversation.hero.workspace.directoryFlow');
|
|
625
|
+
yield register('sidebar.workspaces.directoryFlow');
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
ctx.effect(() => subscribe(ctx), 'dsh-remote-mobile: assistant stream bridge');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
exports.apply = apply;
|
|
633
|
+
exports.inject = inject;
|
|
634
|
+
return module.exports;
|
|
635
|
+
},
|
|
636
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,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'];
|
|
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
|
+
* 调电脑端的一条 RPC。
|
|
139
|
+
*
|
|
140
|
+
* @param config - `{ endpoint, token }`。
|
|
141
|
+
* @param method - 方法名。
|
|
142
|
+
* @param params - 参数。
|
|
143
|
+
* @returns 电脑端的 `result`。
|
|
144
|
+
*/
|
|
145
|
+
async function callRemote(config, method, params = {}) {
|
|
146
|
+
if (config.token === '') {
|
|
147
|
+
throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token(电脑端启动日志会打印它)`);
|
|
148
|
+
}
|
|
149
|
+
const res = await fetch(`${config.endpoint}/api/dsh-remote/rpc`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: {
|
|
152
|
+
'content-type': 'application/json',
|
|
153
|
+
'x-dsh-remote-token': config.token,
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({ id: randomUUID(), method, params }),
|
|
156
|
+
});
|
|
157
|
+
if (!res.ok) throw new Error(`远程请求失败: HTTP ${res.status}`);
|
|
158
|
+
const payload = await res.json();
|
|
159
|
+
if (payload.ok !== true) throw new Error(payload.error ?? '远程请求失败');
|
|
160
|
+
return payload.result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 把一个电脑事件写进手机本地会话。
|
|
165
|
+
*
|
|
166
|
+
* ## seq 对齐规则(这是搬运正确性的核心)
|
|
167
|
+
*
|
|
168
|
+
* ⚠️ 两端的 seq **起点不一样**,这是实测确认的:
|
|
169
|
+
*
|
|
170
|
+
* - 电脑端走 `sessionController.create` → agent factory → `agents.create({seed: ...})`,
|
|
171
|
+
* `seed !== undefined` ⇒ `Session` 构造时**自动插一条 `session/end-seed`**(seq=0),
|
|
172
|
+
* 真实事件从 seq=1 开始(`dsh-session` L1351-1352)。
|
|
173
|
+
* - 手机端走 `sessions.create(id, {meta})`,**不带 seed** ⇒ 那条自动插入不触发
|
|
174
|
+
* (L1351-1352 的条件是 `seed !== void 0`),真实事件从 seq=0 开始。
|
|
175
|
+
*
|
|
176
|
+
* 所以映射关系是 `手机 seq = 电脑 seq - 已跳过的 end-seed 条数`。
|
|
177
|
+
* `end-seed` 只会出现在日志最前面,跳过它之后两端就逐条对齐了。
|
|
178
|
+
*
|
|
179
|
+
* 据此判断:
|
|
180
|
+
* - 折算后的 seq < 本地 seq ⇒ 已写过(SSE 重连时电脑会补发历史),**跳过**;
|
|
181
|
+
* - 折算后 === 本地 seq ⇒ 正是下一条,写入;
|
|
182
|
+
* - 折算后 > 本地 seq ⇒ 中间缺事件。此时**不能硬写** —— 官方 `Session` 的 seq
|
|
183
|
+
* 必须连续,硬写会造成空洞。抛错交给调用方记日志,等重连补齐。
|
|
184
|
+
*
|
|
185
|
+
* @param session - 手机本地会话。
|
|
186
|
+
* @param event - 电脑端事件(`{type, seq, time, data, surfaceOp?, sourceEventSeqs?}`)。
|
|
187
|
+
* @param state - 该会话的搬运状态 `{ skippedSeeds }`。
|
|
188
|
+
* @returns 是否写入了。
|
|
189
|
+
*/
|
|
190
|
+
function mirrorEvent(session, event, state) {
|
|
191
|
+
// ⚠️ `session/end-seed` 必须跳过:手机端没有对应的一条,写进去会让后续全部错位。
|
|
192
|
+
// 它只会出现在电脑日志的最开头,所以偏移量只认一次 —— 否则 SSE 重连补发
|
|
193
|
+
// 历史时会被反复累加,后续事件的折算 seq 越算越小。
|
|
194
|
+
if (event.type === 'session/end-seed') {
|
|
195
|
+
if (state.skippedSeeds === 0) state.skippedSeeds = 1;
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (typeof event.seq === 'number') {
|
|
199
|
+
const expected = event.seq - state.skippedSeeds;
|
|
200
|
+
if (expected < session.seq) return false; // 重连补发的历史,已写过
|
|
201
|
+
if (expected > session.seq) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`事件流有缺口:电脑 seq=${event.seq}(折算 ${expected}),手机本地 seq=${session.seq}(等重连补齐)`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const opts = {};
|
|
208
|
+
if (event.surfaceOp !== undefined) opts.surfaceOp = event.surfaceOp;
|
|
209
|
+
if (event.sourceEventSeqs !== undefined) opts.sourceEventSeqs = event.sourceEventSeqs;
|
|
210
|
+
session.append(event.type, event.data, opts);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 插件入口。
|
|
216
|
+
*
|
|
217
|
+
* @param ctx - Cordis 上下文。
|
|
218
|
+
*/
|
|
219
|
+
export function apply(ctx) {
|
|
220
|
+
const config = loadConfig();
|
|
221
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] 远端: ${config.endpoint}`);
|
|
222
|
+
if (config.token === '') {
|
|
223
|
+
ctx.logger?.warn?.(`[dsh-remote-mobile] 未配置令牌,请在 ${CONFIG_PATH} 里填 token`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 远程会话 id 集合:这些会话的 prompt 转发电脑,不在手机本地跑模型。 */
|
|
227
|
+
const remoteSessions = loadRemoteSessions();
|
|
228
|
+
|
|
229
|
+
/** 每个远程会话正在跟随的电脑事件流。 */
|
|
230
|
+
const follows = new Map();
|
|
231
|
+
|
|
232
|
+
/** 每个远程会话的搬运状态(已跳过几条 `session/end-seed`)。 */
|
|
233
|
+
const mirrorStates = new Map();
|
|
234
|
+
|
|
235
|
+
/** 取(或建)某会话的搬运状态。 */
|
|
236
|
+
const mirrorStateOf = (sessionId) => {
|
|
237
|
+
let state = mirrorStates.get(sessionId);
|
|
238
|
+
if (state === undefined) {
|
|
239
|
+
state = { skippedSeeds: 0 };
|
|
240
|
+
mirrorStates.set(sessionId, state);
|
|
241
|
+
}
|
|
242
|
+
return state;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** 浏览器侧全局 SSE 订阅者。 */
|
|
246
|
+
const watchers = new Set();
|
|
247
|
+
|
|
248
|
+
/** 往所有浏览器订阅者推一条 SSE。 */
|
|
249
|
+
const broadcast = (eventName, payload) => {
|
|
250
|
+
if (watchers.size === 0) return;
|
|
251
|
+
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
252
|
+
for (const res of watchers) {
|
|
253
|
+
try {
|
|
254
|
+
res.write(chunk);
|
|
255
|
+
} catch {
|
|
256
|
+
watchers.delete(res);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 跟随电脑的一个会话:durable 事件写手机本地,流式帧广播给浏览器。
|
|
263
|
+
*
|
|
264
|
+
* @param sessionId - 两端同名的会话 id。
|
|
265
|
+
*/
|
|
266
|
+
const follow = (sessionId) => {
|
|
267
|
+
if (follows.has(sessionId)) return;
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
follows.set(sessionId, controller);
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 连一次电脑的 SSE 并消费到断开。
|
|
273
|
+
*
|
|
274
|
+
* 断线是**常态**(Cloudflare 隧道会掐空闲连接、手机休眠会断网),所以
|
|
275
|
+
* 外面套一层退避重连:重连后电脑会补发全部历史,`mirrorEvent` 靠 seq
|
|
276
|
+
* 折算把已写过的跳过,不会重复。
|
|
277
|
+
*/
|
|
278
|
+
const once = async () => {
|
|
279
|
+
const url =
|
|
280
|
+
`${config.endpoint}/api/dsh-remote/stream` +
|
|
281
|
+
`?session=${encodeURIComponent(sessionId)}&token=${encodeURIComponent(config.token)}`;
|
|
282
|
+
const res = await fetch(url, {
|
|
283
|
+
headers: { accept: 'text/event-stream' },
|
|
284
|
+
signal: controller.signal,
|
|
285
|
+
});
|
|
286
|
+
if (!res.ok || res.body === null) throw new Error(`HTTP ${res.status}`);
|
|
287
|
+
|
|
288
|
+
const decoder = new TextDecoder();
|
|
289
|
+
let buffer = '';
|
|
290
|
+
for await (const chunk of res.body) {
|
|
291
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
292
|
+
// SSE 以空行分隔消息;末段可能不完整,留在 buffer 里。
|
|
293
|
+
let split;
|
|
294
|
+
while ((split = buffer.indexOf('\n\n')) >= 0) {
|
|
295
|
+
const raw = buffer.slice(0, split);
|
|
296
|
+
buffer = buffer.slice(split + 2);
|
|
297
|
+
let eventName = 'message';
|
|
298
|
+
let dataLine = '';
|
|
299
|
+
for (const line of raw.split('\n')) {
|
|
300
|
+
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
|
301
|
+
else if (line.startsWith('data:')) dataLine += line.slice(5).trim();
|
|
302
|
+
}
|
|
303
|
+
if (dataLine === '') continue; // 心跳/注释行
|
|
304
|
+
let payload;
|
|
305
|
+
try {
|
|
306
|
+
payload = JSON.parse(dataLine);
|
|
307
|
+
} catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (eventName === 'event') {
|
|
311
|
+
const session = ctx.sessions.get(sessionId);
|
|
312
|
+
if (session === undefined) continue;
|
|
313
|
+
let wrote = false;
|
|
314
|
+
try {
|
|
315
|
+
wrote = mirrorEvent(session, payload, mirrorStateOf(sessionId));
|
|
316
|
+
} catch (error) {
|
|
317
|
+
ctx.logger?.warn?.(
|
|
318
|
+
`[dsh-remote-mobile] 写入事件失败 (${payload?.type}): ${String(error?.message ?? error)}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
// ⚠️ 必须告诉浏览器「这一轮的 durable 结算到了」。
|
|
322
|
+
//
|
|
323
|
+
// 浏览器侧的逐字动画是我们自己注入的 transient 行,而官方的
|
|
324
|
+
// 清理点(`MutableSessionEventSource.settleAssistant`)只挂在
|
|
325
|
+
// `ClientAssistantStream.acceptEventChange` 上 —— 那条链是**远程
|
|
326
|
+
// 事件流**专用的,手机本地的 durable 是同步 append 进会话的,
|
|
327
|
+
// 走不到它。没人清的话,逐字残片会永久留在窗口里,和随后到达的
|
|
328
|
+
// 完整消息叠成两份。
|
|
329
|
+
if (wrote && (payload?.type === 'assistant/message' || payload?.type === 'assistant/attempt')) {
|
|
330
|
+
broadcast('settled', {
|
|
331
|
+
sessionId,
|
|
332
|
+
turn: payload?.data?.turn,
|
|
333
|
+
step: payload?.data?.step,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} else if (eventName === 'frame') {
|
|
337
|
+
// 带上 sessionId:浏览器侧一条连接服务所有会话。
|
|
338
|
+
broadcast('frame', { sessionId, frame: payload });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const run = async () => {
|
|
345
|
+
let wait = 1000;
|
|
346
|
+
while (!controller.signal.aborted) {
|
|
347
|
+
try {
|
|
348
|
+
await once();
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (controller.signal.aborted) return;
|
|
351
|
+
ctx.logger?.warn?.(
|
|
352
|
+
`[dsh-remote-mobile] 事件流中断(${wait}ms 后重连): ${String(error?.message ?? error)}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (controller.signal.aborted) return;
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
357
|
+
wait = Math.min(wait * 2, 15000); // 退避到 15s 为止,一直重试
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
run().finally(() => {
|
|
362
|
+
if (follows.get(sessionId) === controller) follows.delete(sessionId);
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// ---- 拦截官方 prompt:远程会话转发电脑,本地会话走原逻辑 ----
|
|
367
|
+
ctx.effect(() => {
|
|
368
|
+
const controller = ctx.sessionController;
|
|
369
|
+
if (controller === undefined) return;
|
|
370
|
+
const originalPrompt = controller.prompt.bind(controller);
|
|
371
|
+
controller.prompt = async (request) => {
|
|
372
|
+
if (!remoteSessions.has(request.sessionId)) return originalPrompt(request);
|
|
373
|
+
const text = (request.content ?? [])
|
|
374
|
+
.filter((part) => part.type === 'text')
|
|
375
|
+
.map((part) => part.text)
|
|
376
|
+
.join('\n');
|
|
377
|
+
if (ctx.sessions.get(request.sessionId) === undefined) {
|
|
378
|
+
throw new Error(`远程会话 "${request.sessionId}" 不在手机本地`);
|
|
379
|
+
}
|
|
380
|
+
// ⚠️ 手机端**不**自己写 user/message。
|
|
381
|
+
//
|
|
382
|
+
// 手机本地日志是电脑日志的镜像,seq 必须逐条对齐;本地多写一条会让
|
|
383
|
+
// 后续所有事件的 seq 错位,事件搬运随即失效。而电脑端 prompt 后
|
|
384
|
+
// 本来就会 append 一条 user/message 并流回来 —— 那才是唯一真源。
|
|
385
|
+
//
|
|
386
|
+
// 把前端的 requestId 原样带过去:电脑端会把它写进
|
|
387
|
+
// `source.rpcId`,前端凭这个结算「提交中」状态并显示用户气泡。
|
|
388
|
+
await callRemote(config, 'prompt', {
|
|
389
|
+
sessionId: request.sessionId,
|
|
390
|
+
text,
|
|
391
|
+
...(request.requestId === undefined ? {} : { requestId: request.requestId }),
|
|
392
|
+
});
|
|
393
|
+
// 确保事件流在线(断线时重连,补齐这段时间的事件)。
|
|
394
|
+
follow(request.sessionId);
|
|
395
|
+
return { accepted: true };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// 「停止生成」同样要转发到电脑。
|
|
399
|
+
// ⚠️ 不拦的话,官方 `cancel` 会 `resolveAgent()` → 在手机上 resume 一个 agent,
|
|
400
|
+
// 那会真去建目录、真跑手机本地模型。
|
|
401
|
+
const originalCancel = controller.cancel.bind(controller);
|
|
402
|
+
controller.cancel = async (request) => {
|
|
403
|
+
if (!remoteSessions.has(request.sessionId)) return originalCancel(request);
|
|
404
|
+
await callRemote(config, 'cancel', { sessionId: request.sessionId });
|
|
405
|
+
return { accepted: true };
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
return () => {
|
|
409
|
+
controller.prompt = originalPrompt;
|
|
410
|
+
controller.cancel = originalCancel;
|
|
411
|
+
};
|
|
412
|
+
}, 'dsh-remote-mobile: prompt/cancel 拦截');
|
|
413
|
+
|
|
414
|
+
// ---- 浏览器桥:目录浏览 / 建会话 ----
|
|
415
|
+
ctx.effect(
|
|
416
|
+
() =>
|
|
417
|
+
ctx.webServer.register({
|
|
418
|
+
kind: 'exact',
|
|
419
|
+
path: ROUTE_RPC,
|
|
420
|
+
handler: (req, res) => {
|
|
421
|
+
const reply = (code, payload) => {
|
|
422
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
423
|
+
res.end(JSON.stringify(payload));
|
|
424
|
+
};
|
|
425
|
+
const chunks = [];
|
|
426
|
+
req.on('data', (c) => chunks.push(c));
|
|
427
|
+
req.on('end', async () => {
|
|
428
|
+
let body;
|
|
429
|
+
try {
|
|
430
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
431
|
+
} catch (error) {
|
|
432
|
+
reply(400, { error: `bad json: ${String(error?.message ?? error)}` });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const result = await handleBridge(ctx, config, {
|
|
437
|
+
remoteSessions,
|
|
438
|
+
follow,
|
|
439
|
+
persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
|
|
440
|
+
body,
|
|
441
|
+
});
|
|
442
|
+
reply(200, { ok: true, result });
|
|
443
|
+
} catch (error) {
|
|
444
|
+
reply(200, { ok: false, error: String(error?.message ?? error) });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
'dsh-remote-mobile: browser rpc route',
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// ---- 浏览器流式订阅(全局一条) ----
|
|
453
|
+
ctx.effect(
|
|
454
|
+
() =>
|
|
455
|
+
ctx.webServer.register({
|
|
456
|
+
kind: 'exact',
|
|
457
|
+
path: ROUTE_STREAM,
|
|
458
|
+
handler: (req, res) => {
|
|
459
|
+
res.writeHead(200, {
|
|
460
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
461
|
+
'cache-control': 'no-store, no-transform',
|
|
462
|
+
connection: 'keep-alive',
|
|
463
|
+
'x-accel-buffering': 'no',
|
|
464
|
+
});
|
|
465
|
+
res.write(': connected\n\n');
|
|
466
|
+
watchers.add(res);
|
|
467
|
+
const heartbeat = setInterval(() => {
|
|
468
|
+
try {
|
|
469
|
+
res.write(': ping\n\n');
|
|
470
|
+
} catch {
|
|
471
|
+
clearInterval(heartbeat);
|
|
472
|
+
}
|
|
473
|
+
}, 15000);
|
|
474
|
+
const cleanup = () => {
|
|
475
|
+
clearInterval(heartbeat);
|
|
476
|
+
watchers.delete(res);
|
|
477
|
+
};
|
|
478
|
+
req.on('close', cleanup);
|
|
479
|
+
req.on('error', cleanup);
|
|
480
|
+
},
|
|
481
|
+
}),
|
|
482
|
+
'dsh-remote-mobile: browser stream route',
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// ---- 启动时恢复:把持久化的远程会话重新接上电脑 ----
|
|
486
|
+
ctx.effect(() => {
|
|
487
|
+
for (const sessionId of remoteSessions) follow(sessionId);
|
|
488
|
+
if (remoteSessions.size > 0) {
|
|
489
|
+
ctx.logger?.info?.(`[dsh-remote-mobile] 已恢复 ${remoteSessions.size} 个远程会话的事件流`);
|
|
490
|
+
}
|
|
491
|
+
return () => {
|
|
492
|
+
for (const controller of follows.values()) controller.abort();
|
|
493
|
+
follows.clear();
|
|
494
|
+
for (const res of watchers) {
|
|
495
|
+
try {
|
|
496
|
+
res.end();
|
|
497
|
+
} catch {
|
|
498
|
+
/* 已断开 */
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
watchers.clear();
|
|
502
|
+
};
|
|
503
|
+
}, 'dsh-remote-mobile: restore + teardown');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 处理浏览器桥来的一条 RPC。
|
|
508
|
+
*
|
|
509
|
+
* @param ctx - Cordis 上下文。
|
|
510
|
+
* @param config - 远程配置。
|
|
511
|
+
* @param io - `{ remoteSessions, follow, persist, body }`。
|
|
512
|
+
*/
|
|
513
|
+
async function handleBridge(ctx, config, io) {
|
|
514
|
+
const params = io.body?.params ?? {};
|
|
515
|
+
switch (io.body?.method) {
|
|
516
|
+
/** 连接自检:手机 → 电脑。 */
|
|
517
|
+
case 'ping':
|
|
518
|
+
return callRemote(config, 'ping', {});
|
|
519
|
+
|
|
520
|
+
/** 列电脑上的目录。 */
|
|
521
|
+
case 'listDesktop':
|
|
522
|
+
return callRemote(config, 'listDirectory', { path: params.path });
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 在电脑的某个目录下开一个新会话。
|
|
526
|
+
*
|
|
527
|
+
* 两端用**同一个 sessionId**:手机本地建会话(用手机 cwd,见文件头),
|
|
528
|
+
* 电脑端在它自己的 cwd 下建同名会话。之后事件就能直接互搬。
|
|
529
|
+
*/
|
|
530
|
+
case 'createSession': {
|
|
531
|
+
if (typeof params.path !== 'string' || params.path === '') {
|
|
532
|
+
throw new Error('请先在电脑页签里选一个目录');
|
|
533
|
+
}
|
|
534
|
+
if (config.token === '') {
|
|
535
|
+
throw new Error(`未配置访问令牌:请在 ${CONFIG_PATH} 里填 token`);
|
|
536
|
+
}
|
|
537
|
+
const sessionId = `session-${randomUUID()}`;
|
|
538
|
+
// ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
|
|
539
|
+
// (Android 是 posix 语义,`path.posix.isAbsolute('C:/...') === false`,
|
|
540
|
+
// 且官方 createOrAdopt 会 `mkdir(cwd, {recursive:true})` 真去建目录)。
|
|
541
|
+
// `process.cwd()` 是引擎的工作目录,在手机上就是那个绝对路径。
|
|
542
|
+
const cwd = process.cwd();
|
|
543
|
+
ctx.sessions.create(sessionId, { meta: { cwd } });
|
|
544
|
+
try {
|
|
545
|
+
await callRemote(config, 'createSession', { sessionId, cwd: params.path });
|
|
546
|
+
} catch (error) {
|
|
547
|
+
// 电脑没建成功就别留在手机本地,免得出现一个永远发不出消息的会话。
|
|
548
|
+
io.remoteSessions.delete(sessionId);
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
io.remoteSessions.add(sessionId);
|
|
552
|
+
io.persist();
|
|
553
|
+
io.follow(sessionId);
|
|
554
|
+
return { sessionId, cwd, desktopPath: params.path };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 手机本地会话(供前端判断某个会话是不是远程会话)。 */
|
|
558
|
+
case 'isRemote':
|
|
559
|
+
return { remote: io.remoteSessions.has(params.sessionId) };
|
|
560
|
+
|
|
561
|
+
default:
|
|
562
|
+
throw new Error(`unknown method "${String(io.body?.method)}"`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH };
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-jiuling-remote-mobile",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"dshapp-plugin",
|
|
14
|
+
"dsh",
|
|
15
|
+
"dsh-plugin",
|
|
16
|
+
"deepseek-harness",
|
|
17
|
+
"remote",
|
|
18
|
+
"android",
|
|
19
|
+
"cloudflare-tunnel"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"lib"
|
|
23
|
+
],
|
|
24
|
+
"dsh": {
|
|
25
|
+
"client": {
|
|
26
|
+
"inject": [],
|
|
27
|
+
"platform": "web"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|
package/README.md
DELETED