dsh-jiuling-remote-mobile 1.0.14 → 1.0.16
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 +154 -2
- package/lib/index.js +589 -24
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -92,6 +92,11 @@ window.__ModuleLoader__.load({
|
|
|
92
92
|
'display:flex;align-items:flex-end;min-height:28px;margin:0;' +
|
|
93
93
|
'font-size:16px;font-weight:510;line-height:24px;color:var(--dsw-alias-label-primary);',
|
|
94
94
|
crumbBar: 'display:flex;align-items:center;gap:8px;min-height:24px;',
|
|
95
|
+
// 「显示隐藏文件夹」开关:与「上一级」同排,靠右。
|
|
96
|
+
crumbToggle:
|
|
97
|
+
'display:flex;flex:none;align-items:center;gap:4px;cursor:pointer;' +
|
|
98
|
+
'color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;white-space:nowrap;',
|
|
99
|
+
crumbToggleOn: 'color:var(--dsw-alias-label-secondary);',
|
|
95
100
|
pathText:
|
|
96
101
|
'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
|
|
97
102
|
'color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;',
|
|
@@ -143,6 +148,11 @@ window.__ModuleLoader__.load({
|
|
|
143
148
|
pairConfirmText: 'flex:1 1 100%;',
|
|
144
149
|
pairMsg: 'padding:0 4px 8px;font-size:12px;line-height:18px;',
|
|
145
150
|
pairOk: 'color:var(--dsw-alias-state-success-primary);',
|
|
151
|
+
// 「刷新」那一行(模型 / 预设 / 目录树三样一起刷)的回执文字。
|
|
152
|
+
refreshNote:
|
|
153
|
+
'padding:0 4px 8px;font-size:12px;line-height:18px;' +
|
|
154
|
+
'color:var(--dsw-alias-label-tertiary);',
|
|
155
|
+
refreshOk: 'color:var(--dsw-alias-state-success-primary);',
|
|
146
156
|
};
|
|
147
157
|
|
|
148
158
|
/**
|
|
@@ -270,6 +280,21 @@ window.__ModuleLoader__.load({
|
|
|
270
280
|
return true;
|
|
271
281
|
}
|
|
272
282
|
|
|
283
|
+
/**
|
|
284
|
+
* 是否是隐藏条目(名字以 `.` 开头)。
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ **只按名字判断,不要去看属性位**:电脑端返回的条目只有
|
|
287
|
+
* `{name, path, isDirectory, enterable}`,没有 Windows 的「隐藏」属性,
|
|
288
|
+
* 而用户说的「前面带 `.` 的文件夹比如 `.dsh`」正是这一种。
|
|
289
|
+
*
|
|
290
|
+
* @param entry - 目录条目。
|
|
291
|
+
* @returns 是否应归入「默认不显示」。
|
|
292
|
+
*/
|
|
293
|
+
function isHidden(entry) {
|
|
294
|
+
var name = nameOf(entry);
|
|
295
|
+
return name.length > 0 && name.charAt(0) === '.';
|
|
296
|
+
}
|
|
297
|
+
|
|
273
298
|
/** 取列表的当前路径与上级路径。 */
|
|
274
299
|
function locationOf(listing) {
|
|
275
300
|
var path = (listing && listing.path) || '';
|
|
@@ -311,6 +336,9 @@ window.__ModuleLoader__.load({
|
|
|
311
336
|
loading: false,
|
|
312
337
|
error: null,
|
|
313
338
|
creating: false,
|
|
339
|
+
// 是否显示 `.` 开头的隐藏文件夹。默认关 —— 电脑的项目根目录里
|
|
340
|
+
// `.dsh`、`.git`、`.vscode` 一堆,全列出来会把真正的项目目录淹掉。
|
|
341
|
+
showHidden: false,
|
|
314
342
|
});
|
|
315
343
|
var state = statePair[0];
|
|
316
344
|
var setState = statePair[1];
|
|
@@ -329,6 +357,9 @@ window.__ModuleLoader__.load({
|
|
|
329
357
|
// 「重新配对」的两步确认:`true` 时才显示「确定解绑 / 取消」。
|
|
330
358
|
confirming: false,
|
|
331
359
|
message: null,
|
|
360
|
+
// 一键刷新(模型 / 预设 / 目录树)的状态与回执。
|
|
361
|
+
refreshing: false,
|
|
362
|
+
refreshNote: null,
|
|
332
363
|
});
|
|
333
364
|
var pairState = pairPair[0];
|
|
334
365
|
var setPairState = pairPair[1];
|
|
@@ -420,7 +451,10 @@ window.__ModuleLoader__.load({
|
|
|
420
451
|
if (!open) return null;
|
|
421
452
|
|
|
422
453
|
var loc = locationOf(state.listing);
|
|
423
|
-
|
|
454
|
+
// `.` 开头的默认不显示(见 `isHidden`);开关在面包屑那一行。
|
|
455
|
+
var allDirs = entriesOf(state.listing).filter(isDir);
|
|
456
|
+
var hiddenCount = allDirs.filter(isHidden).length;
|
|
457
|
+
var entries = state.showHidden === true ? allDirs : allDirs.filter(function (e) { return !isHidden(e); });
|
|
424
458
|
|
|
425
459
|
var switchTab = function (next) {
|
|
426
460
|
if (next === state.tab) return;
|
|
@@ -555,6 +589,67 @@ window.__ModuleLoader__.load({
|
|
|
555
589
|
);
|
|
556
590
|
};
|
|
557
591
|
|
|
592
|
+
/**
|
|
593
|
+
* 一键刷新:把「模型 / 预设 / 目录树」三样一起重新拉一遍。
|
|
594
|
+
*
|
|
595
|
+
* 用户需求原文:「你只需要添加一个手动刷新…这三个地方的数据都可以在
|
|
596
|
+
* 选择工作区的时候,那个刷新按钮直接一键刷新这三个位置。」
|
|
597
|
+
*
|
|
598
|
+
* ⚠️ 光调桥拉数据**不够**:官方前端的模型目录是**带缓存**的
|
|
599
|
+
* (`ModelCatalogDirectory.load()` 在 `status === 'ready'` 时直接返回旧值,
|
|
600
|
+
* 见 `dsh-client-ui-model-selection/lib/client.js:100`)。所以拉完还要
|
|
601
|
+
* 让它失效重载,否则用户看到的是「点了刷新,列表没变」。
|
|
602
|
+
*
|
|
603
|
+
* `modelDirectories` 是官方那个服务的名字(`super(ctx, "modelDirectories")`),
|
|
604
|
+
* 它的 `.catalog.refresh()` 正是官方在 `llm/adapters-updated` 时走的那条路 ——
|
|
605
|
+
* 走服务实例的公开方法,不碰前端 bundle 内部类。
|
|
606
|
+
*
|
|
607
|
+
* 预设名单没有等价的服务实例(官方预设前端不是 Service),它每次
|
|
608
|
+
* 打开会话/设置面板都会重读,所以这里只更新宿主缓存即可。
|
|
609
|
+
*/
|
|
610
|
+
var refreshAll = function () {
|
|
611
|
+
if (pairState.refreshing) return;
|
|
612
|
+
patchPair({ refreshing: true, refreshNote: null });
|
|
613
|
+
var target = state.tab === 'desktop' ? loc.path : '';
|
|
614
|
+
bridge('refreshAll', target === '' ? {} : { path: target }).then(
|
|
615
|
+
function (result) {
|
|
616
|
+
if (!aliveRef.current) return;
|
|
617
|
+
// 目录树:直接把新列表铺回去(正在看的这一层)。
|
|
618
|
+
if (result && result.directory && result.directory.ok) {
|
|
619
|
+
patch({ listing: result.directory.value });
|
|
620
|
+
}
|
|
621
|
+
// 模型:让官方目录失效重载,下一次打开菜单就是电脑的。
|
|
622
|
+
if (result && result.models && result.models.ok) {
|
|
623
|
+
try {
|
|
624
|
+
var models = props.modelDirectories;
|
|
625
|
+
if (models && models.catalog && typeof models.catalog.refresh === 'function') {
|
|
626
|
+
models.catalog.refresh();
|
|
627
|
+
}
|
|
628
|
+
} catch (error) {
|
|
629
|
+
/* 服务结构变了就只更新宿主缓存,不打扰用户 */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
var failed = [];
|
|
633
|
+
if (result && result.models && !result.models.ok) failed.push('模型');
|
|
634
|
+
if (result && result.presets && !result.presets.ok) failed.push('预设');
|
|
635
|
+
if (result && result.directory && !result.directory.ok) failed.push('目录');
|
|
636
|
+
patchPair({
|
|
637
|
+
refreshing: false,
|
|
638
|
+
refreshNote: failed.length > 0
|
|
639
|
+
? { kind: 'error', text: failed.join('、') + '刷新失败,其余已更新' }
|
|
640
|
+
: { kind: 'ok', text: '模型、预设、目录已刷新' },
|
|
641
|
+
});
|
|
642
|
+
},
|
|
643
|
+
function (reason) {
|
|
644
|
+
if (!aliveRef.current) return;
|
|
645
|
+
patchPair({
|
|
646
|
+
refreshing: false,
|
|
647
|
+
refreshNote: { kind: 'error', text: String((reason && reason.message) || reason) },
|
|
648
|
+
});
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
};
|
|
652
|
+
|
|
558
653
|
/**
|
|
559
654
|
* 「电脑」页签顶部的配对条。
|
|
560
655
|
*
|
|
@@ -763,6 +858,41 @@ window.__ModuleLoader__.load({
|
|
|
763
858
|
'上一级',
|
|
764
859
|
),
|
|
765
860
|
h('span', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
|
|
861
|
+
// 有隐藏文件夹时才显示开关 —— 一个目录里没有 `.` 开头的东西
|
|
862
|
+
// 却摆个开关,只会让人以为漏了什么。
|
|
863
|
+
hiddenCount > 0 || state.showHidden === true
|
|
864
|
+
? h(
|
|
865
|
+
'button',
|
|
866
|
+
{
|
|
867
|
+
type: 'button',
|
|
868
|
+
style: state.showHidden === true
|
|
869
|
+
? Object.assign({}, CSS.crumbToggle, CSS.crumbToggleOn)
|
|
870
|
+
: CSS.crumbToggle,
|
|
871
|
+
title: state.showHidden === true
|
|
872
|
+
? '隐藏以 . 开头的文件夹'
|
|
873
|
+
: '显示 ' + hiddenCount + ' 个以 . 开头的隐藏文件夹',
|
|
874
|
+
onClick: function () {
|
|
875
|
+
patch({ showHidden: state.showHidden !== true });
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
state.showHidden === true ? '隐藏 . 文件夹' : '显示 . 文件夹 (' + hiddenCount + ')',
|
|
879
|
+
)
|
|
880
|
+
: null,
|
|
881
|
+
// 一键刷新:模型 / 预设 / 目录树。只在「电脑」页签下有意义
|
|
882
|
+
//(手机页签的数据本来就来自本机,没什么可刷的)。
|
|
883
|
+
state.tab === 'desktop' && pairState.paired === true
|
|
884
|
+
? h(
|
|
885
|
+
'button',
|
|
886
|
+
{
|
|
887
|
+
type: 'button',
|
|
888
|
+
style: CSS.crumbToggle,
|
|
889
|
+
title: '重新拉取电脑的模型列表、预设列表与当前目录',
|
|
890
|
+
disabled: pairState.refreshing === true,
|
|
891
|
+
onClick: refreshAll,
|
|
892
|
+
},
|
|
893
|
+
pairState.refreshing === true ? '刷新中…' : '刷新',
|
|
894
|
+
)
|
|
895
|
+
: null,
|
|
766
896
|
),
|
|
767
897
|
),
|
|
768
898
|
// —— 主体:目录列表 ——
|
|
@@ -772,6 +902,18 @@ window.__ModuleLoader__.load({
|
|
|
772
902
|
// 配对条只在「电脑」页签下出现 —— 「手机」页签跟配对无关。
|
|
773
903
|
state.tab === 'desktop' ? pairBar : null,
|
|
774
904
|
state.tab === 'desktop' ? pairMsg : null,
|
|
905
|
+
state.tab === 'desktop' && pairState.refreshNote !== null
|
|
906
|
+
? h(
|
|
907
|
+
'div',
|
|
908
|
+
{
|
|
909
|
+
style: pairState.refreshNote.kind === 'ok'
|
|
910
|
+
? Object.assign({}, CSS.refreshNote, CSS.refreshOk)
|
|
911
|
+
: Object.assign({}, CSS.refreshNote, CSS.error),
|
|
912
|
+
role: pairState.refreshNote.kind === 'ok' ? undefined : 'alert',
|
|
913
|
+
},
|
|
914
|
+
pairState.refreshNote.text,
|
|
915
|
+
)
|
|
916
|
+
: null,
|
|
775
917
|
state.loading
|
|
776
918
|
? h('div', { style: CSS.status, role: 'status' }, '加载中…')
|
|
777
919
|
: h(
|
|
@@ -784,7 +926,9 @@ window.__ModuleLoader__.load({
|
|
|
784
926
|
{ style: CSS.status },
|
|
785
927
|
state.tab === 'desktop' && pairState.paired !== true
|
|
786
928
|
? '配对后这里会显示电脑上的文件夹'
|
|
787
|
-
:
|
|
929
|
+
: hiddenCount > 0
|
|
930
|
+
? '这里只有 ' + hiddenCount + ' 个以 . 开头的隐藏文件夹'
|
|
931
|
+
: '这里没有子文件夹',
|
|
788
932
|
),
|
|
789
933
|
),
|
|
790
934
|
state.error !== null ? h('div', { style: CSS.error, role: 'alert' }, state.error) : null,
|
|
@@ -1058,6 +1202,14 @@ window.__ModuleLoader__.load({
|
|
|
1058
1202
|
},
|
|
1059
1203
|
/** 兜底:拉一次全量会话列表。 */
|
|
1060
1204
|
refreshSessions: () => ctx.sessions.refresh(),
|
|
1205
|
+
/**
|
|
1206
|
+
* 官方的模型目录服务(`super(ctx, "modelDirectories")`)。
|
|
1207
|
+
*
|
|
1208
|
+
* 「刷新」按钮要让它失效重载 —— 官方目录**带缓存**,光把宿主那边的
|
|
1209
|
+
* 数据换掉,前端仍然显示旧值(见 `refreshAll` 的注释)。
|
|
1210
|
+
* `ctx.get` 拿不到就返回 `undefined`,组件里会跳过这一步。
|
|
1211
|
+
*/
|
|
1212
|
+
modelDirectories: ctx.get === undefined ? undefined : ctx.get('modelDirectories'),
|
|
1061
1213
|
});
|
|
1062
1214
|
|
|
1063
1215
|
// 负 priority:single slot 取 entries[0],entries 按 priority 升序 ⇒ 最先渲染。
|
package/lib/index.js
CHANGED
|
@@ -164,6 +164,53 @@ function loadConfig() {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* 镜像工作区登记表:`<slug> → <电脑路径>`。
|
|
169
|
+
*
|
|
170
|
+
* ## ⚠️ 为什么必须有这张表(否则「备选区选电脑目录」会静默退化成本地会话)
|
|
171
|
+
*
|
|
172
|
+
* 电脑目录在手机上的落点是**镜像目录** `STATE_DIR/workspaces/<slug>`,而 slug 是
|
|
173
|
+
* 电脑路径的**单向哈希**(`desktopSlug`,末 8 位是 sha256 摘要)——**推不回原路径**。
|
|
174
|
+
*
|
|
175
|
+
* 而用户第二次开对话时走的是**另一条路**:他不再进「电脑文件夹」对话框,而是直接在
|
|
176
|
+
* 新建对话首页的**备选区**里点上次那个工作区。那条路是官方前端自己
|
|
177
|
+
* `sessionController.create({workspaceId})`,**根本不经过本插件的 `createSession` 桥**
|
|
178
|
+
* ⇒ `remoteSessions` 里没有这个 sessionId ⇒ prompt 拦截不命中 ⇒ 消息走手机本地模型。
|
|
179
|
+
*
|
|
180
|
+
* 真机症状(用户原话):「我问他你的工作目录是什么,他回答的就是我安卓的目录,
|
|
181
|
+
* 模型也没加载到电脑的」。第一次手动建时一切正常,**第二次起就变本地**。
|
|
182
|
+
*
|
|
183
|
+
* 所以要能从「镜像目录」反查出「电脑路径」,才能在那个路口把会话重新绑成远程。
|
|
184
|
+
*/
|
|
185
|
+
const WORKSPACES_PATH = join(STATE_DIR, 'workspaces.json');
|
|
186
|
+
|
|
187
|
+
/** 读镜像工作区登记表。 */
|
|
188
|
+
function loadWorkspaceMap() {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(readFileSync(WORKSPACES_PATH, 'utf8'));
|
|
191
|
+
const map = parsed?.workspaces;
|
|
192
|
+
if (map === null || typeof map !== 'object') return {};
|
|
193
|
+
/** @type {Record<string, string>} */
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const key of Object.keys(map)) {
|
|
196
|
+
if (typeof map[key] === 'string' && map[key] !== '') out[key] = map[key];
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
} catch {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 写镜像工作区登记表(失败不致命,只影响重开后的自动绑定)。 */
|
|
205
|
+
function saveWorkspaceMap(map, logger) {
|
|
206
|
+
try {
|
|
207
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
208
|
+
writeFileSync(WORKSPACES_PATH, JSON.stringify({ workspaces: map }, null, 2), { mode: 0o600 });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
logger?.warn?.(`[dsh-remote-mobile] 保存镜像工作区登记表失败: ${String(error?.message ?? error)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
167
214
|
/** 读远程会话清单。 */
|
|
168
215
|
function loadRemoteSessions() {
|
|
169
216
|
try {
|
|
@@ -216,28 +263,50 @@ function saveRemoteSessions(set, logger) {
|
|
|
216
263
|
*/
|
|
217
264
|
async function remoteFetch(url, init) {
|
|
218
265
|
const pool = await remotePool();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
266
|
+
// ① 首选:undici 专用池(关掉 autoSelectFamily,Android 上最稳)。
|
|
267
|
+
// ② 备用:`node:https`(强制 IPv4,不依赖 undici 解析)。
|
|
268
|
+
//
|
|
269
|
+
// ⚠️ **两条通道必须互为备份,不能只做「解析失败才兜底」**。线上实测过:
|
|
270
|
+
// 同一个版本、同一个账号,配对服务器日志里既有 `POST /pair/claim 400`
|
|
271
|
+
// (请求到了)又有界面上的 `fetch failed`(请求没到)—— 说明是**间歇性**
|
|
272
|
+
// 建连失败。只修「undici 没解析到」那一种情形,另一种照样挂。
|
|
273
|
+
//
|
|
274
|
+
// 重试是安全的:`fetch()` 抛错意味着**建连或响应头阶段**就失败了,
|
|
275
|
+
// 此时响应体一个字节都还没消费,重发不会产生重复副作用。
|
|
276
|
+
// (流中途断开不会走到这里 —— 那是 `for await` 里的事,不在本函数内。)
|
|
277
|
+
const attempts = [];
|
|
278
|
+
if (pool !== null) {
|
|
279
|
+
try {
|
|
280
|
+
return await pool.undici.fetch(url, { ...init, dispatcher: pool.agent });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (isAbort(error)) throw error;
|
|
283
|
+
attempts.push(describeFetchError(error, '走的是专用 undici 池'));
|
|
232
284
|
}
|
|
233
|
-
|
|
285
|
+
} else if (!remoteFallbackWarned) {
|
|
286
|
+
remoteFallbackWarned = true;
|
|
287
|
+
console.warn('[dsh-remote-mobile] 拿不到 undici,改用 node:https 通道(强制 IPv4)。');
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
return await nodeHttpsFetch(url, init);
|
|
234
291
|
} catch (error) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
throw new Error(
|
|
292
|
+
if (isAbort(error)) throw error;
|
|
293
|
+
attempts.push(describeFetchError(error, '走的是 node:https 通道(强制 IPv4)'));
|
|
294
|
+
throw new Error(`两条通道都失败:\n ① ${attempts[0]}\n ② ${attempts[1]}`);
|
|
238
295
|
}
|
|
239
296
|
}
|
|
240
297
|
|
|
298
|
+
/**
|
|
299
|
+
* 是否是**主动中止**(不是网络故障)。
|
|
300
|
+
*
|
|
301
|
+
* ⚠️ SSE 断开重连靠 `AbortController`,把它当成网络故障包装掉,重连逻辑就失效了。
|
|
302
|
+
*
|
|
303
|
+
* @param error - 抛出的错误。
|
|
304
|
+
* @returns 是否应当原样上抛。
|
|
305
|
+
*/
|
|
306
|
+
function isAbort(error) {
|
|
307
|
+
return error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
|
308
|
+
}
|
|
309
|
+
|
|
241
310
|
/**
|
|
242
311
|
* 不依赖 undici 的 HTTPS 兜底通道(只用 Node 内置的 `node:https`)。
|
|
243
312
|
*
|
|
@@ -341,10 +410,10 @@ function nodeHttpsFetch(url, init = {}) {
|
|
|
341
410
|
* | `CERT_*` | 证书校验失败(手机时间不对也会这样) | 校时 |
|
|
342
411
|
*
|
|
343
412
|
* @param error - `fetch` 抛出的错误。
|
|
344
|
-
* @param
|
|
413
|
+
* @param channel - 这条错误来自哪条通道(`'undici'` / `'https'`)。
|
|
345
414
|
* @returns 形如 `fetch failed ← ENOTFOUND getaddrinfo ENOTFOUND pair… ← 走的是专用 undici 池`。
|
|
346
415
|
*/
|
|
347
|
-
function describeFetchError(error,
|
|
416
|
+
function describeFetchError(error, channel) {
|
|
348
417
|
const parts = [];
|
|
349
418
|
if (error?.message) parts.push(String(error.message));
|
|
350
419
|
let cause = error?.cause;
|
|
@@ -363,7 +432,7 @@ function describeFetchError(error, usedGlobalFetch) {
|
|
|
363
432
|
.join(' | '),
|
|
364
433
|
);
|
|
365
434
|
}
|
|
366
|
-
parts.push(
|
|
435
|
+
parts.push(channel);
|
|
367
436
|
return parts.join(' ← ');
|
|
368
437
|
}
|
|
369
438
|
|
|
@@ -586,6 +655,9 @@ export function apply(ctx) {
|
|
|
586
655
|
/** 远程会话 id 集合:这些会话的 prompt 转发电脑,不在手机本地跑模型。 */
|
|
587
656
|
const remoteSessions = loadRemoteSessions();
|
|
588
657
|
|
|
658
|
+
/** 镜像工作区登记表:`<slug> → <电脑路径>`(见 `WORKSPACES_PATH` 的注释)。 */
|
|
659
|
+
const workspaceMap = loadWorkspaceMap();
|
|
660
|
+
|
|
589
661
|
/** 每个远程会话正在跟随的电脑事件流。 */
|
|
590
662
|
const follows = new Map();
|
|
591
663
|
|
|
@@ -605,6 +677,104 @@ export function apply(ctx) {
|
|
|
605
677
|
/** 浏览器侧全局 SSE 订阅者。 */
|
|
606
678
|
const watchers = new Set();
|
|
607
679
|
|
|
680
|
+
/**
|
|
681
|
+
* 电脑侧的静默缓存:`{ catalog, presets, at }`。
|
|
682
|
+
*
|
|
683
|
+
* ## 为什么要缓存(用户需求原文)
|
|
684
|
+
*
|
|
685
|
+
* 「用户配对成功之后 就可以静默的拉取一些数据了,比如模型列表和预设列表,
|
|
686
|
+
* 以及目录树这样体验会好很多……因为一般项目目录是不会变的,可以提前加载」。
|
|
687
|
+
*
|
|
688
|
+
* 配对后立刻拉一次,之后前端一打开模型菜单就有内容,不用等一轮往返。
|
|
689
|
+
* 目录树**不进这个缓存** —— 它按路径变化,缓存了反而会显示过期内容,
|
|
690
|
+
* 由前端每次进目录时实时拉。
|
|
691
|
+
*/
|
|
692
|
+
const desktopCache = { catalog: null, presets: null, at: 0 };
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* 静默预拉电脑的模型目录与预设列表。
|
|
696
|
+
*
|
|
697
|
+
* ⚠️ **失败必须完全静默**:这是配对后的后台动作,用户没点任何东西。
|
|
698
|
+
* 弹错误只会让人以为配对坏了 —— 而实际上配对是成功的,只是这台电脑的
|
|
699
|
+
* 某个 provider 没配好(官方 `modelCatalog` 会把这种失败放进 `failures`,
|
|
700
|
+
* 根本不会抛)。
|
|
701
|
+
*
|
|
702
|
+
* @param why - 日志里标明是谁触发的(配对 / 手动刷新)。
|
|
703
|
+
*/
|
|
704
|
+
const prefetchDesktop = async (why) => {
|
|
705
|
+
if (config.token === EMPTY_TOKEN) return;
|
|
706
|
+
const [catalog, presets] = await Promise.all([
|
|
707
|
+
callRemote(config, 'modelCatalog', {}).catch((error) => {
|
|
708
|
+
ctx.logger?.warn?.(`[dsh-remote-mobile] 预拉模型列表失败(${why}): ${String(error?.message ?? error)}`);
|
|
709
|
+
return null;
|
|
710
|
+
}),
|
|
711
|
+
callRemote(config, 'presetList', {}).catch((error) => {
|
|
712
|
+
ctx.logger?.warn?.(`[dsh-remote-mobile] 预拉预设列表失败(${why}): ${String(error?.message ?? error)}`);
|
|
713
|
+
return null;
|
|
714
|
+
}),
|
|
715
|
+
]);
|
|
716
|
+
if (catalog !== null) desktopCache.catalog = catalog;
|
|
717
|
+
if (presets !== null) desktopCache.presets = presets;
|
|
718
|
+
desktopCache.at = Date.now();
|
|
719
|
+
const models = catalog?.groups?.reduce((n, g) => n + (g.models?.length ?? 0), 0) ?? 0;
|
|
720
|
+
ctx.logger?.info?.(
|
|
721
|
+
`[dsh-remote-mobile] 已预拉电脑数据(${why}):${models} 个模型、${presets?.presets?.length ?? 0} 个预设`,
|
|
722
|
+
);
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* 把电脑的模型目录并进手机本地的目录。
|
|
727
|
+
*
|
|
728
|
+
* ## ⚠️ 为什么是「并」而不是「换」
|
|
729
|
+
*
|
|
730
|
+
* 官方前端的模型目录是**全局唯一一份**(`ModelCatalogDirectory` 由
|
|
731
|
+
* `ModelDirectoryResolver` 持有,所有会话共享,见
|
|
732
|
+
* `dsh-client-ui-model-selection/lib/client.js:340`),请求本身**不带 sessionId**。
|
|
733
|
+
* 所以「远程会话看电脑的、本地会话看手机的」这种按会话切换,在官方结构里
|
|
734
|
+
* **表达不出来** —— 硬要做得去改前端内部类,脆弱且升级即碎。
|
|
735
|
+
*
|
|
736
|
+
* 折中:**电脑的排前面**(用户要的就是「刷新为电脑的」,电脑模型一眼可见),
|
|
737
|
+
* 手机自己那几组留在后面。这样远程会话能选到电脑的模型,本地会话也不会
|
|
738
|
+
* 因为列表里只剩电脑模型而彻底没法用。
|
|
739
|
+
*
|
|
740
|
+
* 同 id 的 provider 以**电脑的为准**(电脑上那份才是干活用的)。
|
|
741
|
+
*
|
|
742
|
+
* @param local - 手机本地的模型目录。
|
|
743
|
+
* @param desktop - 电脑的模型目录(`buildModelCatalog` 的产物)。
|
|
744
|
+
* @returns 合并后的目录。
|
|
745
|
+
*/
|
|
746
|
+
function mergeCatalog(local, desktop) {
|
|
747
|
+
const groups = [...(desktop?.groups ?? [])];
|
|
748
|
+
const seen = new Set(groups.map((group) => group.id));
|
|
749
|
+
for (const group of local?.groups ?? []) {
|
|
750
|
+
if (!seen.has(group.id)) groups.push(group);
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
...local,
|
|
754
|
+
groups,
|
|
755
|
+
failures: [...(desktop?.failures ?? []), ...(local?.failures ?? [])],
|
|
756
|
+
routableProviders: [
|
|
757
|
+
...new Set([...(local?.routableProviders ?? []), ...(desktop?.routableProviders ?? [])]),
|
|
758
|
+
],
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 把电脑的预设并进手机本地的预设名单(同 id 以电脑为准)。
|
|
764
|
+
*
|
|
765
|
+
* @param local - 手机本地的 `{presets}`。
|
|
766
|
+
* @param desktop - 电脑的 `{presets}`。
|
|
767
|
+
* @returns 合并后的名单。
|
|
768
|
+
*/
|
|
769
|
+
function mergePresets(local, desktop) {
|
|
770
|
+
const presets = [...(desktop?.presets ?? [])];
|
|
771
|
+
const seen = new Set(presets.map((preset) => preset.id));
|
|
772
|
+
for (const preset of local?.presets ?? []) {
|
|
773
|
+
if (!seen.has(preset.id)) presets.push(preset);
|
|
774
|
+
}
|
|
775
|
+
return { ...local, presets };
|
|
776
|
+
}
|
|
777
|
+
|
|
608
778
|
/** 往所有浏览器订阅者推一条 SSE。 */
|
|
609
779
|
const broadcast = (eventName, payload) => {
|
|
610
780
|
if (watchers.size === 0) return;
|
|
@@ -725,6 +895,69 @@ export function apply(ctx) {
|
|
|
725
895
|
});
|
|
726
896
|
};
|
|
727
897
|
|
|
898
|
+
// ---- 拦截官方 create:备选区选中「电脑目录」时自动绑成远程会话 ----
|
|
899
|
+
//
|
|
900
|
+
// ⚠️ **这是「第二次起就变本地」的根因修复,不是锦上添花。**
|
|
901
|
+
//
|
|
902
|
+
// 用户第一次是在「电脑文件夹」对话框里选的目录,走 `createSession` 桥 ——
|
|
903
|
+
// 那条路会把 sessionId 记进 `remoteSessions`,prompt 因此被转发电脑。
|
|
904
|
+
//
|
|
905
|
+
// 但第二次他是**在新建对话首页的备选区里**点上次那个工作区。官方前端此时
|
|
906
|
+
// 直接 `sessionController.create({workspaceId})`,**完全不经过本插件**,
|
|
907
|
+
// 于是 `remoteSessions` 里没有这个 sessionId ⇒ prompt 拦截不命中 ⇒
|
|
908
|
+
// 消息交给手机本地 agent(真跑手机模型、cwd 是手机路径)。
|
|
909
|
+
// 用户看到的就是「问他工作目录,答的是安卓目录」。
|
|
910
|
+
//
|
|
911
|
+
// 这里在同一个路口把它认出来并补上绑定:镜像目录 → 电脑路径 → 让电脑建同名会话。
|
|
912
|
+
ctx.effect(() => {
|
|
913
|
+
const controller = ctx.sessionController;
|
|
914
|
+
if (controller === undefined || typeof controller.create !== 'function') return;
|
|
915
|
+
const originalCreate = controller.create.bind(controller);
|
|
916
|
+
controller.create = async (request) => {
|
|
917
|
+
const result = await originalCreate(request);
|
|
918
|
+
const sessionId = result?.sessionId;
|
|
919
|
+
if (typeof sessionId !== 'string' || sessionId === '') return result;
|
|
920
|
+
// 已经绑过的(走对话框那条路)不用再绑。
|
|
921
|
+
if (remoteSessions.has(sessionId)) return result;
|
|
922
|
+
// 解析这个会话的 cwd:优先 `workspaceId`(备选区走的就是这条),
|
|
923
|
+
// 退回请求里的 `cwd`(直接给路径的情形)。
|
|
924
|
+
let dir = request?.cwd;
|
|
925
|
+
if (request?.workspaceId !== undefined) {
|
|
926
|
+
const workspace = ctx.get?.('workspaceRegistry')?.get?.(request.workspaceId);
|
|
927
|
+
if (workspace?.path !== undefined) dir = workspace.path;
|
|
928
|
+
}
|
|
929
|
+
const desktopPath = desktopPathOfMirror(workspaceMap, dir);
|
|
930
|
+
if (desktopPath === undefined) return result;
|
|
931
|
+
// 没配对时**不能**绑:绑了之后 prompt 会去打一个不存在的电脑,
|
|
932
|
+
// 用户看到的是「发消息就报错」,比退回本地会话更难理解。
|
|
933
|
+
if (config.token === EMPTY_TOKEN) {
|
|
934
|
+
ctx.logger?.warn?.(
|
|
935
|
+
`[dsh-remote-mobile] 会话 ${sessionId} 选的是电脑目录「${desktopPath}」,但还没配对,先当本地会话`,
|
|
936
|
+
);
|
|
937
|
+
return result;
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
await callRemote(config, 'createSession', { sessionId, cwd: desktopPath });
|
|
941
|
+
} catch (error) {
|
|
942
|
+
// 电脑没建成功就别绑 —— 否则这个会话的每条消息都会失败。
|
|
943
|
+
ctx.logger?.warn?.(
|
|
944
|
+
`[dsh-remote-mobile] 电脑建会话失败,${sessionId} 退回本地会话: ${String(error?.message ?? error)}`,
|
|
945
|
+
);
|
|
946
|
+
return result;
|
|
947
|
+
}
|
|
948
|
+
remoteSessions.add(sessionId);
|
|
949
|
+
saveRemoteSessions(remoteSessions, ctx.logger);
|
|
950
|
+
follow(sessionId);
|
|
951
|
+
ctx.logger?.info?.(
|
|
952
|
+
`[dsh-remote-mobile] 备选区选中电脑目录「${desktopPath}」,会话 ${sessionId} 已绑为远程会话`,
|
|
953
|
+
);
|
|
954
|
+
return result;
|
|
955
|
+
};
|
|
956
|
+
return () => {
|
|
957
|
+
controller.create = originalCreate;
|
|
958
|
+
};
|
|
959
|
+
}, 'dsh-remote-mobile: create 拦截(备选区选电脑目录)');
|
|
960
|
+
|
|
728
961
|
// ---- 拦截官方 prompt:远程会话转发电脑,本地会话走原逻辑 ----
|
|
729
962
|
ctx.effect(() => {
|
|
730
963
|
const controller = ctx.sessionController;
|
|
@@ -741,7 +974,17 @@ export function apply(ctx) {
|
|
|
741
974
|
// 症状是**消息根本发不出去**,而报错文案完全看不出是参数漏传。
|
|
742
975
|
controller.prompt = async (request, signal) => {
|
|
743
976
|
if (!remoteSessions.has(request.sessionId)) return originalPrompt(request, signal);
|
|
744
|
-
|
|
977
|
+
// ⚠️ **必须把附件一起转过去**,不能只挑 `text` part。
|
|
978
|
+
//
|
|
979
|
+
// 用户报的就是这个:「照片就会被吞了 AI 只能看到我的文字」。前端的
|
|
980
|
+
// `serializeAttachments()`(`dsh-client-ui-conversation/lib/client.js:3299`)
|
|
981
|
+
// 给图片发的是 `{type:'image', mediaType, data(base64)}` —— **字节是内联的**,
|
|
982
|
+
// 直接透传,电脑会用自己的 `attachments.admitPromptContent` 落盘。
|
|
983
|
+
//
|
|
984
|
+
// `{type:'file', receiptId}` 不转(receipt 属于手机的 `fileUploads`,
|
|
985
|
+
// 电脑解不开),由电脑端降级成一句说明(见其 `prompt` 分支的注释)。
|
|
986
|
+
const content = Array.isArray(request.content) ? request.content : [];
|
|
987
|
+
const text = content
|
|
745
988
|
.filter((part) => part.type === 'text')
|
|
746
989
|
.map((part) => part.text)
|
|
747
990
|
.join('\n');
|
|
@@ -759,6 +1002,8 @@ export function apply(ctx) {
|
|
|
759
1002
|
await callRemote(config, 'prompt', {
|
|
760
1003
|
sessionId: request.sessionId,
|
|
761
1004
|
text,
|
|
1005
|
+
// 附件形态一并带上(没有附件时给空数组,电脑端会退回纯文本)。
|
|
1006
|
+
content,
|
|
762
1007
|
...(request.requestId === undefined ? {} : { requestId: request.requestId }),
|
|
763
1008
|
});
|
|
764
1009
|
// 确保事件流在线(断线时重连,补齐这段时间的事件)。
|
|
@@ -878,6 +1123,234 @@ export function apply(ctx) {
|
|
|
878
1123
|
};
|
|
879
1124
|
}, 'dsh-remote-mobile: 屏蔽远程会话的本地标题生成');
|
|
880
1125
|
|
|
1126
|
+
// ---- 把电脑的模型目录与预设名单接进官方前端 ----
|
|
1127
|
+
//
|
|
1128
|
+
// ## 为什么拦在宿主侧,而不是改前端
|
|
1129
|
+
//
|
|
1130
|
+
// 官方前端的模型目录来自 `ctx.remote.session.modelCatalog()`(宿主 RPC),
|
|
1131
|
+
// 预设来自 `ctx.remote.agentPresets.list()`(宿主 RPC)。两个 RPC 的**宿主实现**
|
|
1132
|
+
// 就是 `sessionController.modelCatalog` 与 `agentPresets.remoteExportList`
|
|
1133
|
+
// (装饰器 `Remote("modelCatalog")` / `Remote("list")`,见
|
|
1134
|
+
// `dsh-api-session-controller/lib/index.js:2586`、
|
|
1135
|
+
// `dsh-agent-preset-registry/lib/index.js:426`)。
|
|
1136
|
+
//
|
|
1137
|
+
// 这两处都是 `Remote(...)` 装饰的方法,**经实例动态派发**(与 `create`/`prompt`
|
|
1138
|
+
// 同一机制),所以替换实例上的方法就能同时拦到直接调用与 RPC 调用 —— 不必碰
|
|
1139
|
+
// 前端 bundle,升级也不会碎。
|
|
1140
|
+
//
|
|
1141
|
+
// 用户要的是「选了电脑目录之后,模型列表刷新为电脑的」。前端拿到的目录里
|
|
1142
|
+
// **电脑的排前面**,手机自己那几组留在后面(原因见 `mergeCatalog` 的注释)。
|
|
1143
|
+
ctx.effect(() => {
|
|
1144
|
+
const undo = [];
|
|
1145
|
+
|
|
1146
|
+
/** 已配对才值得把电脑的东西并进来;没配对时保持手机本地原样。 */
|
|
1147
|
+
const paired = () => config.token !== EMPTY_TOKEN;
|
|
1148
|
+
|
|
1149
|
+
// ① 模型目录
|
|
1150
|
+
const controller = ctx.sessionController;
|
|
1151
|
+
if (controller !== undefined && typeof controller.modelCatalog === 'function') {
|
|
1152
|
+
const originalCatalog = controller.modelCatalog.bind(controller);
|
|
1153
|
+
controller.modelCatalog = async (...args) => {
|
|
1154
|
+
const local = await originalCatalog(...args);
|
|
1155
|
+
if (!paired() || desktopCache.catalog === null) return local;
|
|
1156
|
+
return mergeCatalog(local, desktopCache.catalog);
|
|
1157
|
+
};
|
|
1158
|
+
undo.push(() => {
|
|
1159
|
+
controller.modelCatalog = originalCatalog;
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
// ② 选模型:远程会话的选择必须落到**电脑**上。
|
|
1163
|
+
//
|
|
1164
|
+
// ⚠️ 不转发的话,用户在前端选的模型只写进手机本地会话的投影,
|
|
1165
|
+
// 电脑那边仍用它自己的默认模型 —— 表现是「选了也不生效」。
|
|
1166
|
+
if (typeof controller.selectModel === 'function') {
|
|
1167
|
+
const originalSelect = controller.selectModel.bind(controller);
|
|
1168
|
+
controller.selectModel = async (request) => {
|
|
1169
|
+
if (!remoteSessions.has(request?.sessionId)) return originalSelect(request);
|
|
1170
|
+
const selected = await callRemote(config, 'selectModel', {
|
|
1171
|
+
sessionId: request.sessionId,
|
|
1172
|
+
provider: request.provider,
|
|
1173
|
+
model: request.model,
|
|
1174
|
+
...(request.reasoningEffort === undefined
|
|
1175
|
+
? {}
|
|
1176
|
+
: { reasoningEffort: request.reasoningEffort }),
|
|
1177
|
+
});
|
|
1178
|
+
return selected;
|
|
1179
|
+
};
|
|
1180
|
+
undo.push(() => {
|
|
1181
|
+
controller.selectModel = originalSelect;
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// ③ 预设名单
|
|
1187
|
+
const patchPresets = (presets) => {
|
|
1188
|
+
if (presets === undefined || typeof presets.remoteExportList !== 'function') return false;
|
|
1189
|
+
const originalList = presets.remoteExportList.bind(presets);
|
|
1190
|
+
presets.remoteExportList = async (...args) => {
|
|
1191
|
+
const local = await originalList(...args);
|
|
1192
|
+
if (!paired() || desktopCache.presets === null) return local;
|
|
1193
|
+
return mergePresets(local, desktopCache.presets);
|
|
1194
|
+
};
|
|
1195
|
+
undo.push(() => {
|
|
1196
|
+
presets.remoteExportList = originalList;
|
|
1197
|
+
});
|
|
1198
|
+
return true;
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
// 与标题守卫同构:先同步试一次,没就绪再交给依赖注入兜底。
|
|
1202
|
+
// 两条路分开 try,否则 `ctx.get` 一抛错就把 `ctx.inject` 那条兜底也跳过了。
|
|
1203
|
+
const warn = (error) =>
|
|
1204
|
+
ctx.logger?.warn?.(
|
|
1205
|
+
`[dsh-remote-mobile] 未能接上电脑的模型/预设列表: ${String(error?.message ?? error)}`,
|
|
1206
|
+
);
|
|
1207
|
+
let patched = false;
|
|
1208
|
+
try {
|
|
1209
|
+
patched = patchPresets(ctx.get('agentPresets'));
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
warn(error);
|
|
1212
|
+
}
|
|
1213
|
+
if (!patched) {
|
|
1214
|
+
try {
|
|
1215
|
+
ctx.inject(['agentPresets'], (scoped) => {
|
|
1216
|
+
patchPresets(scoped.agentPresets);
|
|
1217
|
+
});
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
warn(error);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return () => {
|
|
1224
|
+
for (const fn of undo) fn();
|
|
1225
|
+
};
|
|
1226
|
+
}, 'dsh-remote-mobile: 电脑模型目录 / 预设名单接入');
|
|
1227
|
+
|
|
1228
|
+
// ---- 把远程会话的「文件树」接到电脑上(侧边栏 + 文件预览) ----
|
|
1229
|
+
//
|
|
1230
|
+
// 官方侧边栏的文件面板(`dsh-client-ui-sidebar-files`)以及内容预览
|
|
1231
|
+
// (`dsh-client-ui-document-preview`)都只认 host 的 `workspaceFiles` 服务:
|
|
1232
|
+
//
|
|
1233
|
+
// list(scope, path) → 一层目录(含文件)
|
|
1234
|
+
// read(scope, path, {offset}) → 文本分页
|
|
1235
|
+
// stat(scope, path) → 版本/大小
|
|
1236
|
+
// readBytes(scope, path, opts) → 二进制分页
|
|
1237
|
+
//
|
|
1238
|
+
// scope = {sessionId, workspaceRoot},由官方按 sessionId 从会话 header 现算。
|
|
1239
|
+
//
|
|
1240
|
+
// ⚠️ **远程会话不能走本地的 `workspaceFiles`**:它的 `workspaceRoot` 是本插件
|
|
1241
|
+
// 在手机上的**镜像空壳目录**(只为了让「备选区」里有个能点的项,见
|
|
1242
|
+
// `ensureDesktopWorkspace`)。照本地那条路走,侧边栏里永远是**空的** ——
|
|
1243
|
+
// 而用户要的是看到电脑上的目录树。所以这里把这几条调用转给电脑。
|
|
1244
|
+
//
|
|
1245
|
+
// ⚠️ **`changes`(自动刷新)刻意不转发**:官方那份是流式 RPC,本插件的中继
|
|
1246
|
+
// 是一问一答,跨端桥一条长连的代价远大于收益。本地的 watcher 落在镜像空壳上,
|
|
1247
|
+
// 会正常回 `ready`、只是永远不报变化 —— 侧边栏不会因此报错,用户用手动
|
|
1248
|
+
// 「刷新」按钮看最新内容(产品上也是这么定的)。
|
|
1249
|
+
ctx.effect(() => {
|
|
1250
|
+
const undo = [];
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* 判定会话是不是远程会话,并把路径换算成「相对于工作区」的一段。
|
|
1254
|
+
*
|
|
1255
|
+
* @returns 相对路径(根目录是空串);不是远程会话时 `undefined`。
|
|
1256
|
+
*/
|
|
1257
|
+
const mapPath = (scope, path) => {
|
|
1258
|
+
const desktopRoot = desktopPathOfMirror(workspaceMap, scope?.workspaceRoot);
|
|
1259
|
+
if (desktopRoot === undefined) return undefined;
|
|
1260
|
+
if (typeof path !== 'string' || path === '') return undefined;
|
|
1261
|
+
// 侧边栏给的路径以会话 cwd 开头(官方 `childPath` 逐层拼出来),而
|
|
1262
|
+
// `scope.workspaceRoot` 与它同源(都是会话 header 的 cwd)⇒ 前缀直接剥掉。
|
|
1263
|
+
//
|
|
1264
|
+
// ⚠️ **只传相对路径,绝不在这里拼电脑的绝对路径**:两端盘符/分隔符不同,
|
|
1265
|
+
// 拼接是电脑那一端的事(见文件头「cwd 绝不跨端传」)。
|
|
1266
|
+
const a = path.replace(/\\/g, '/');
|
|
1267
|
+
const b = String(scope.workspaceRoot).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
1268
|
+
if (a === b) return { path: '' };
|
|
1269
|
+
if (a.startsWith(`${b}/`)) return { path: a.slice(b.length + 1) };
|
|
1270
|
+
// 不在工作区内的路径:原样交给电脑去解析(它自己会判越界)。
|
|
1271
|
+
return { path: a };
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* 只处理远程会话;本地会话原样交给官方。
|
|
1276
|
+
*
|
|
1277
|
+
* @param name - `workspaceFiles` 上的方法名。
|
|
1278
|
+
* @param original - 该方法**本来的**实现(可能没有)。
|
|
1279
|
+
*/
|
|
1280
|
+
const forward = (name, original) => async (scope, path, ...rest) => {
|
|
1281
|
+
const mapped = remoteSessions.has(scope?.sessionId) ? mapPath(scope, path) : undefined;
|
|
1282
|
+
if (mapped === undefined) {
|
|
1283
|
+
if (original === undefined) throw new Error(`workspaceFiles.${name} 不可用`);
|
|
1284
|
+
return await original(scope, path, ...rest);
|
|
1285
|
+
}
|
|
1286
|
+
if (name === 'list') {
|
|
1287
|
+
return await callRemote(config, 'fileList', { sessionId: scope.sessionId, path: mapped.path });
|
|
1288
|
+
}
|
|
1289
|
+
if (name === 'read') {
|
|
1290
|
+
const range = rest[0] ?? {};
|
|
1291
|
+
return await callRemote(config, 'fileRead', {
|
|
1292
|
+
sessionId: scope.sessionId,
|
|
1293
|
+
path: mapped.path,
|
|
1294
|
+
...(range.offset === undefined ? {} : { offset: range.offset }),
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
if (name === 'stat') {
|
|
1298
|
+
return await callRemote(config, 'fileStat', { sessionId: scope.sessionId, path: mapped.path });
|
|
1299
|
+
}
|
|
1300
|
+
// readBytes:字节没法直接进 JSON,两端用 base64 过一道。
|
|
1301
|
+
//
|
|
1302
|
+
// ⚠️ **丢掉 `baseFile`**:官方那份是「相对 baseFile 所在目录」的语义,
|
|
1303
|
+
// 而这里的路径已经换算成电脑上的绝对路径了,再带上一个手机路径的
|
|
1304
|
+
// baseFile 只会让电脑去解析一个不存在的相对路径。
|
|
1305
|
+
const result = await callRemote(config, 'fileReadBytes', {
|
|
1306
|
+
sessionId: scope.sessionId,
|
|
1307
|
+
path: mapped.path,
|
|
1308
|
+
...(rest[0]?.range === undefined ? {} : { range: rest[0].range }),
|
|
1309
|
+
});
|
|
1310
|
+
const { data, ...meta } = result ?? {};
|
|
1311
|
+
return { ...meta, data: new Uint8Array(Buffer.from(String(data ?? ''), 'base64')) };
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
const patch = (files) => {
|
|
1315
|
+
if (files === undefined || typeof files.list !== 'function') return false;
|
|
1316
|
+
let count = 0;
|
|
1317
|
+
for (const name of ['list', 'read', 'stat', 'readBytes']) {
|
|
1318
|
+
const fn = files[name];
|
|
1319
|
+
if (typeof fn !== 'function') continue;
|
|
1320
|
+
files[name] = forward(name, fn.bind(files));
|
|
1321
|
+
undo.push(() => {
|
|
1322
|
+
files[name] = fn;
|
|
1323
|
+
});
|
|
1324
|
+
count += 1;
|
|
1325
|
+
}
|
|
1326
|
+
return count > 0;
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
const warn = (error) =>
|
|
1330
|
+
ctx.logger?.warn?.(
|
|
1331
|
+
`[dsh-remote-mobile] 未能接上电脑的文件树: ${String(error?.message ?? error)}`,
|
|
1332
|
+
);
|
|
1333
|
+
let patched = false;
|
|
1334
|
+
try {
|
|
1335
|
+
patched = patch(ctx.get('workspaceFiles'));
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
warn(error);
|
|
1338
|
+
}
|
|
1339
|
+
if (!patched) {
|
|
1340
|
+
try {
|
|
1341
|
+
ctx.inject(['workspaceFiles'], (scoped) => {
|
|
1342
|
+
patch(scoped.workspaceFiles);
|
|
1343
|
+
});
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
warn(error);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
return () => {
|
|
1350
|
+
for (const fn of undo) fn();
|
|
1351
|
+
};
|
|
1352
|
+
}, 'dsh-remote-mobile: 电脑文件树接入(侧边栏 / 预览)');
|
|
1353
|
+
|
|
881
1354
|
// ---- 浏览器桥:目录浏览 / 建会话 ----
|
|
882
1355
|
ctx.effect(
|
|
883
1356
|
() =>
|
|
@@ -907,6 +1380,9 @@ export function apply(ctx) {
|
|
|
907
1380
|
follows,
|
|
908
1381
|
mirrorStates,
|
|
909
1382
|
persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
|
|
1383
|
+
// 模型/预设的静默缓存:配对后预拉、刷新按钮回写(见 desktopCache)。
|
|
1384
|
+
desktopCache,
|
|
1385
|
+
prefetch: prefetchDesktop,
|
|
910
1386
|
body,
|
|
911
1387
|
});
|
|
912
1388
|
reply(200, { ok: true, result });
|
|
@@ -958,6 +1434,11 @@ export function apply(ctx) {
|
|
|
958
1434
|
if (remoteSessions.size > 0) {
|
|
959
1435
|
ctx.logger?.info?.(`[dsh-remote-mobile] 已恢复 ${remoteSessions.size} 个远程会话的事件流`);
|
|
960
1436
|
}
|
|
1437
|
+
// 已配对的话,启动时也把电脑的模型/预设静默拉一次 —— 否则重启 App 后
|
|
1438
|
+
// 用户直接打开模型菜单会看到空列表(缓存是内存态,重启即丢)。
|
|
1439
|
+
// ⚠️ 不 await:这是后台动作,别拖慢引擎启动。`.catch` 兜住最后一道,
|
|
1440
|
+
// 免得一个后台失败变成 unhandled rejection 打脏引擎日志。
|
|
1441
|
+
prefetchDesktop('启动').catch(() => {});
|
|
961
1442
|
return () => {
|
|
962
1443
|
for (const controller of follows.values()) controller.abort();
|
|
963
1444
|
follows.clear();
|
|
@@ -1030,16 +1511,46 @@ function desktopTitle(desktopPath) {
|
|
|
1030
1511
|
* @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
|
|
1031
1512
|
* @returns 工作区实体(含 `.id` / `.path` / `.title`)。
|
|
1032
1513
|
*/
|
|
1033
|
-
async function ensureDesktopWorkspace(ctx, desktopPath) {
|
|
1514
|
+
async function ensureDesktopWorkspace(ctx, desktopPath, workspaceMap) {
|
|
1034
1515
|
const registry = ctx.get?.('workspaceRegistry');
|
|
1035
1516
|
if (registry === undefined || typeof registry.create !== 'function') {
|
|
1036
1517
|
throw new Error('workspaceRegistry 不可用,无法为电脑目录建工作区');
|
|
1037
1518
|
}
|
|
1038
|
-
const
|
|
1519
|
+
const slug = desktopSlug(desktopPath);
|
|
1520
|
+
const dir = join(STATE_DIR, 'workspaces', slug);
|
|
1039
1521
|
mkdirSync(dir, { recursive: true });
|
|
1522
|
+
// 登记 `slug → 电脑路径`,供「备选区直接选镜像工作区」那条路反查(见 WORKSPACES_PATH)。
|
|
1523
|
+
if (workspaceMap !== undefined && workspaceMap[slug] !== desktopPath) {
|
|
1524
|
+
workspaceMap[slug] = desktopPath;
|
|
1525
|
+
saveWorkspaceMap(workspaceMap, ctx.logger);
|
|
1526
|
+
}
|
|
1040
1527
|
return await registry.create(dir, desktopTitle(desktopPath));
|
|
1041
1528
|
}
|
|
1042
1529
|
|
|
1530
|
+
/**
|
|
1531
|
+
* 从镜像目录反查电脑路径。
|
|
1532
|
+
*
|
|
1533
|
+
* @param workspaceMap - `slug → 电脑路径`。
|
|
1534
|
+
* @param dir - 会话的 cwd(应当落在 `STATE_DIR/workspaces/<slug>` 下)。
|
|
1535
|
+
* @returns 电脑路径;不是镜像目录时 `undefined`。
|
|
1536
|
+
*/
|
|
1537
|
+
function desktopPathOfMirror(workspaceMap, dir) {
|
|
1538
|
+
if (typeof dir !== 'string' || dir === '') return undefined;
|
|
1539
|
+
// ⚠️ 用 `/workspaces/` 定位而不是拿 `join(STATE_DIR,'workspaces')` 做前缀比对:
|
|
1540
|
+
// `workspace.path` 是 **realpath**(官方 `createOrAdopt` 会 realpath 解析),
|
|
1541
|
+
// 在真机上可能多出/少掉一层符号链接(`/data/user/0` vs `/data/data`),
|
|
1542
|
+
// 前缀一比就永远不命中 —— 症状是「修了但没生效」,极难查。
|
|
1543
|
+
//
|
|
1544
|
+
// 分隔符两种都认:真机是 posix(`join` 给 `/`),但把本插件装到 Windows 上
|
|
1545
|
+
// 调试时 `join` 给的是 `\`,只认一种就会在那边静默失效。
|
|
1546
|
+
const at = dir.search(/[\\/]workspaces[\\/]/);
|
|
1547
|
+
if (at < 0) return undefined;
|
|
1548
|
+
const slug = dir.slice(at).split(/[\\/]/)[2];
|
|
1549
|
+
if (slug === undefined || slug === '') return undefined;
|
|
1550
|
+
const path = workspaceMap[slug];
|
|
1551
|
+
return typeof path === 'string' && path !== '' ? path : undefined;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1043
1554
|
/**
|
|
1044
1555
|
* 处理浏览器桥来的一条 RPC。
|
|
1045
1556
|
*
|
|
@@ -1058,6 +1569,56 @@ async function handleBridge(ctx, config, io) {
|
|
|
1058
1569
|
case 'listDesktop':
|
|
1059
1570
|
return callRemote(config, 'listDirectory', { path: params.path });
|
|
1060
1571
|
|
|
1572
|
+
/**
|
|
1573
|
+
* 电脑上的模型目录。
|
|
1574
|
+
*
|
|
1575
|
+
* 用户要的是「选了电脑目录之后,输入框右下方的模型列表就刷新为电脑的」。
|
|
1576
|
+
* 手机本地那份模型列表来自手机自己的 LLM 配置,跟电脑上能跑什么没关系,
|
|
1577
|
+
* 拿它去发消息会选到一个电脑上不存在的模型。
|
|
1578
|
+
*/
|
|
1579
|
+
case 'modelCatalog':
|
|
1580
|
+
return callRemote(config, 'modelCatalog', {});
|
|
1581
|
+
|
|
1582
|
+
/** 电脑上的预设列表。 */
|
|
1583
|
+
case 'presetList':
|
|
1584
|
+
return callRemote(config, 'presetList', {});
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* 一键刷新:把「模型 / 预设 / 目录树」三样一起重新拉一遍。
|
|
1588
|
+
*
|
|
1589
|
+
* ⚠️ 三样**必须并发**且**互不牵连**:目录树拉不到(比如电脑上那个目录被删了)
|
|
1590
|
+
* 不该让模型列表也刷不出来。所以逐个 catch,谁失败谁自己带 `error`。
|
|
1591
|
+
*
|
|
1592
|
+
* @returns `{ models, presets, directory }`,每项是 `{ok, value|error}`。
|
|
1593
|
+
*/
|
|
1594
|
+
case 'refreshAll': {
|
|
1595
|
+
const wantDirectory = typeof params.path === 'string' && params.path !== '';
|
|
1596
|
+
const jobs = [
|
|
1597
|
+
['models', () => callRemote(config, 'modelCatalog', {})],
|
|
1598
|
+
['presets', () => callRemote(config, 'presetList', {})],
|
|
1599
|
+
...(wantDirectory ? [['directory', () => callRemote(config, 'listDirectory', { path: params.path })]] : []),
|
|
1600
|
+
];
|
|
1601
|
+
const settled = await Promise.all(
|
|
1602
|
+
jobs.map(async ([key, run]) => {
|
|
1603
|
+
try {
|
|
1604
|
+
return [key, { ok: true, value: await run() }];
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
return [key, { ok: false, error: String(error?.message ?? error) }];
|
|
1607
|
+
}
|
|
1608
|
+
}),
|
|
1609
|
+
);
|
|
1610
|
+
const result = Object.fromEntries(settled);
|
|
1611
|
+
// ⚠️ 刷新到的模型/预设要**写回缓存**,否则「刷新按钮」只更新了它自己
|
|
1612
|
+
// 那份响应,前端模型菜单读的是宿主拦截器里的缓存 —— 用户会看到
|
|
1613
|
+
// 「点了刷新,模型列表没变」。
|
|
1614
|
+
if (io.desktopCache !== undefined) {
|
|
1615
|
+
if (result.models?.ok === true) io.desktopCache.catalog = result.models.value;
|
|
1616
|
+
if (result.presets?.ok === true) io.desktopCache.presets = result.presets.value;
|
|
1617
|
+
io.desktopCache.at = Date.now();
|
|
1618
|
+
}
|
|
1619
|
+
return result;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1061
1622
|
/**
|
|
1062
1623
|
* 在电脑的某个目录下开一个新会话。
|
|
1063
1624
|
*
|
|
@@ -1079,7 +1640,7 @@ async function handleBridge(ctx, config, io) {
|
|
|
1079
1640
|
// 这里给它一个**电脑目录专属的镜像工作区**(标题 = 电脑目录末段),
|
|
1080
1641
|
// 而不是手机的「默认工作区」—— 否则所有远程会话都堆在「默认工作区」
|
|
1081
1642
|
// 底下,看起来就像「建完又回到默认工作去了」(见 `ensureDesktopWorkspace`)。
|
|
1082
|
-
const workspace = await ensureDesktopWorkspace(ctx, params.path);
|
|
1643
|
+
const workspace = await ensureDesktopWorkspace(ctx, params.path, workspaceMap);
|
|
1083
1644
|
const cwd = workspace.path;
|
|
1084
1645
|
// ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
|
|
1085
1646
|
//
|
|
@@ -1154,6 +1715,10 @@ async function handleBridge(ctx, config, io) {
|
|
|
1154
1715
|
}
|
|
1155
1716
|
const saved = await claimPairing(params.code.trim());
|
|
1156
1717
|
Object.assign(config, saved);
|
|
1718
|
+
// 配对成功后**静默**预拉电脑的模型与预设(用户需求原文:
|
|
1719
|
+
// 「用户配对成功之后 就可以静默的拉取一些数据了,比如模型列表和预设列表」)。
|
|
1720
|
+
// ⚠️ 不 await:这是后台动作,配对请求要立刻回给前端,别让用户等一轮往返。
|
|
1721
|
+
io.prefetch?.('配对成功')?.catch?.(() => {});
|
|
1157
1722
|
return { endpoint: saved.endpoint, tokenSet: saved.token !== '' };
|
|
1158
1723
|
}
|
|
1159
1724
|
|