dsh-jiuling-remote-mobile 1.0.15 → 1.0.17
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 +211 -2
- package/lib/index.js +553 -6
- 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
|
/**
|
|
@@ -220,6 +230,37 @@ window.__ModuleLoader__.load({
|
|
|
220
230
|
document.head.appendChild(styleTag);
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 电脑目录的**访问缓存**:`电脑路径 → listing`(模块级,跨对话框开关存活)。
|
|
235
|
+
*
|
|
236
|
+
* ## 为什么必须有(用户报障原文)
|
|
237
|
+
*
|
|
238
|
+
* 「为什么我点击添加工作区的时候,每次点击电脑都要重新加载一遍目录,
|
|
239
|
+
* 这就算了,我加载好之后,随便点击一个文件夹,文件夹里面的内容还要重新加载
|
|
240
|
+
* 一遍」—— 同一个电脑目录,每次进「电脑」页签、每次点进同一个文件夹,
|
|
241
|
+
* 都要重走一趟「手机 → Cloudflare 隧道 → 电脑」,一个来回 1~3 秒。
|
|
242
|
+
* 用户要的是「加载一次,后续直接从本地读」。
|
|
243
|
+
*
|
|
244
|
+
* ## 为什么放模块级而不是组件内 ref
|
|
245
|
+
*
|
|
246
|
+
* 对话框每次打开都是**新挂载的组件**,组件内的 ref 一关就没了 —— 那样
|
|
247
|
+
* 「关掉再打开又转一遍圈」,正是用户抱怨的第一个现象。放模块级则一次加载
|
|
248
|
+
* 整条会话内都命中。
|
|
249
|
+
*
|
|
250
|
+
* ⚠️ 键是**电脑路径**原样(`C:\Users\...`),`''` 表示根目录 —— 与
|
|
251
|
+
* `bridge('listDesktop')` 的入参完全一致,不做任何改写。
|
|
252
|
+
*
|
|
253
|
+
* ⚠️ **失效只有两条路**:用户点「刷新」(见 `refreshAll`),
|
|
254
|
+
* 或者那一次加载失败(见下面的 catch)。不做 TTL 轮询 —— 用户明确要的是
|
|
255
|
+
* 「加载一次就直接从本地读」。
|
|
256
|
+
*/
|
|
257
|
+
var desktopDirCache = {};
|
|
258
|
+
|
|
259
|
+
/** 清掉访问缓存(配对 / 解绑 / 切页签时调用,避免跨电脑串味)。 */
|
|
260
|
+
function clearDesktopDirCache() {
|
|
261
|
+
desktopDirCache = {};
|
|
262
|
+
}
|
|
263
|
+
|
|
223
264
|
/**
|
|
224
265
|
* 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
|
|
225
266
|
*
|
|
@@ -270,6 +311,21 @@ window.__ModuleLoader__.load({
|
|
|
270
311
|
return true;
|
|
271
312
|
}
|
|
272
313
|
|
|
314
|
+
/**
|
|
315
|
+
* 是否是隐藏条目(名字以 `.` 开头)。
|
|
316
|
+
*
|
|
317
|
+
* ⚠️ **只按名字判断,不要去看属性位**:电脑端返回的条目只有
|
|
318
|
+
* `{name, path, isDirectory, enterable}`,没有 Windows 的「隐藏」属性,
|
|
319
|
+
* 而用户说的「前面带 `.` 的文件夹比如 `.dsh`」正是这一种。
|
|
320
|
+
*
|
|
321
|
+
* @param entry - 目录条目。
|
|
322
|
+
* @returns 是否应归入「默认不显示」。
|
|
323
|
+
*/
|
|
324
|
+
function isHidden(entry) {
|
|
325
|
+
var name = nameOf(entry);
|
|
326
|
+
return name.length > 0 && name.charAt(0) === '.';
|
|
327
|
+
}
|
|
328
|
+
|
|
273
329
|
/** 取列表的当前路径与上级路径。 */
|
|
274
330
|
function locationOf(listing) {
|
|
275
331
|
var path = (listing && listing.path) || '';
|
|
@@ -311,6 +367,9 @@ window.__ModuleLoader__.load({
|
|
|
311
367
|
loading: false,
|
|
312
368
|
error: null,
|
|
313
369
|
creating: false,
|
|
370
|
+
// 是否显示 `.` 开头的隐藏文件夹。默认关 —— 电脑的项目根目录里
|
|
371
|
+
// `.dsh`、`.git`、`.vscode` 一堆,全列出来会把真正的项目目录淹掉。
|
|
372
|
+
showHidden: false,
|
|
314
373
|
});
|
|
315
374
|
var state = statePair[0];
|
|
316
375
|
var setState = statePair[1];
|
|
@@ -329,6 +388,9 @@ window.__ModuleLoader__.load({
|
|
|
329
388
|
// 「重新配对」的两步确认:`true` 时才显示「确定解绑 / 取消」。
|
|
330
389
|
confirming: false,
|
|
331
390
|
message: null,
|
|
391
|
+
// 一键刷新(模型 / 预设 / 目录树)的状态与回执。
|
|
392
|
+
refreshing: false,
|
|
393
|
+
refreshNote: null,
|
|
332
394
|
});
|
|
333
395
|
var pairState = pairPair[0];
|
|
334
396
|
var setPairState = pairPair[1];
|
|
@@ -393,6 +455,16 @@ window.__ModuleLoader__.load({
|
|
|
393
455
|
patch({ loading: false, listing: null, error: null });
|
|
394
456
|
return undefined;
|
|
395
457
|
}
|
|
458
|
+
// ⚠️ **电脑目录命中缓存就直接铺回去,不打桥**(用户报障:来回点文件夹
|
|
459
|
+
// 每次都重新加载)。缓存键用 `''` 表示根,与 `listDesktop` 的入参一致。
|
|
460
|
+
if (tab === 'desktop') {
|
|
461
|
+
var key = path === null ? '' : path;
|
|
462
|
+
var hit = desktopDirCache[key];
|
|
463
|
+
if (hit !== undefined) {
|
|
464
|
+
patch({ loading: false, listing: hit, error: null });
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
396
468
|
patch({ loading: true, error: null });
|
|
397
469
|
var load =
|
|
398
470
|
tab === 'desktop'
|
|
@@ -401,10 +473,16 @@ window.__ModuleLoader__.load({
|
|
|
401
473
|
load.then(
|
|
402
474
|
function (listing) {
|
|
403
475
|
if (!aliveRef.current) return;
|
|
476
|
+
if (tab === 'desktop') {
|
|
477
|
+
desktopDirCache[path === null ? '' : path] = listing;
|
|
478
|
+
}
|
|
404
479
|
patch({ loading: false, listing: listing });
|
|
405
480
|
},
|
|
406
481
|
function (reason) {
|
|
407
482
|
if (!aliveRef.current) return;
|
|
483
|
+
// ⚠️ 失败**要清掉这一格缓存**:留着的话用户再点进来会看到
|
|
484
|
+
// 上一次的旧内容,而以为自己看的是电脑现状。
|
|
485
|
+
if (tab === 'desktop') delete desktopDirCache[path === null ? '' : path];
|
|
408
486
|
patch({
|
|
409
487
|
loading: false,
|
|
410
488
|
listing: null,
|
|
@@ -420,7 +498,10 @@ window.__ModuleLoader__.load({
|
|
|
420
498
|
if (!open) return null;
|
|
421
499
|
|
|
422
500
|
var loc = locationOf(state.listing);
|
|
423
|
-
|
|
501
|
+
// `.` 开头的默认不显示(见 `isHidden`);开关在面包屑那一行。
|
|
502
|
+
var allDirs = entriesOf(state.listing).filter(isDir);
|
|
503
|
+
var hiddenCount = allDirs.filter(isHidden).length;
|
|
504
|
+
var entries = state.showHidden === true ? allDirs : allDirs.filter(function (e) { return !isHidden(e); });
|
|
424
505
|
|
|
425
506
|
var switchTab = function (next) {
|
|
426
507
|
if (next === state.tab) return;
|
|
@@ -454,6 +535,9 @@ window.__ModuleLoader__.load({
|
|
|
454
535
|
code: '',
|
|
455
536
|
message: { kind: 'ok', text: '配对成功,正在连接电脑…' },
|
|
456
537
|
});
|
|
538
|
+
// ⚠️ 换了一台电脑,旧缓存里的路径完全无效(甚至同名不同机)——
|
|
539
|
+
// 必须作废,否则用户会看到上一台电脑的目录。
|
|
540
|
+
clearDesktopDirCache();
|
|
457
541
|
patch({ path: null, listing: null });
|
|
458
542
|
},
|
|
459
543
|
function (reason) {
|
|
@@ -486,6 +570,8 @@ window.__ModuleLoader__.load({
|
|
|
486
570
|
code: '',
|
|
487
571
|
message: { kind: 'ok', text: '已解绑,请到电脑上生成新的配对码' },
|
|
488
572
|
});
|
|
573
|
+
// 解绑后旧凭据作废,缓存里的目录不该再露出来。
|
|
574
|
+
clearDesktopDirCache();
|
|
489
575
|
patch({ path: null, listing: null });
|
|
490
576
|
},
|
|
491
577
|
function (reason) {
|
|
@@ -555,6 +641,72 @@ window.__ModuleLoader__.load({
|
|
|
555
641
|
);
|
|
556
642
|
};
|
|
557
643
|
|
|
644
|
+
/**
|
|
645
|
+
* 一键刷新:把「模型 / 预设 / 目录树」三样一起重新拉一遍。
|
|
646
|
+
*
|
|
647
|
+
* 用户需求原文:「你只需要添加一个手动刷新…这三个地方的数据都可以在
|
|
648
|
+
* 选择工作区的时候,那个刷新按钮直接一键刷新这三个位置。」
|
|
649
|
+
*
|
|
650
|
+
* ⚠️ 光调桥拉数据**不够**:官方前端的模型目录是**带缓存**的
|
|
651
|
+
* (`ModelCatalogDirectory.load()` 在 `status === 'ready'` 时直接返回旧值,
|
|
652
|
+
* 见 `dsh-client-ui-model-selection/lib/client.js:100`)。所以拉完还要
|
|
653
|
+
* 让它失效重载,否则用户看到的是「点了刷新,列表没变」。
|
|
654
|
+
*
|
|
655
|
+
* `modelDirectories` 是官方那个服务的名字(`super(ctx, "modelDirectories")`),
|
|
656
|
+
* 它的 `.catalog.refresh()` 正是官方在 `llm/adapters-updated` 时走的那条路 ——
|
|
657
|
+
* 走服务实例的公开方法,不碰前端 bundle 内部类。
|
|
658
|
+
*
|
|
659
|
+
* 预设名单没有等价的服务实例(官方预设前端不是 Service),它每次
|
|
660
|
+
* 打开会话/设置面板都会重读,所以这里只更新宿主缓存即可。
|
|
661
|
+
*/
|
|
662
|
+
var refreshAll = function () {
|
|
663
|
+
if (pairState.refreshing) return;
|
|
664
|
+
patchPair({ refreshing: true, refreshNote: null });
|
|
665
|
+
var target = state.tab === 'desktop' ? loc.path : '';
|
|
666
|
+
// ⚠️ **「刷新」必须清掉访问缓存**,否则新列表铺回 `listing` 之后,
|
|
667
|
+
// 用户一离开再回来又命中旧的那一格,看起来就是「刷新没生效」。
|
|
668
|
+
// 连同整棵已缓存的树一起作废:电脑上的目录可能已经变了。
|
|
669
|
+
clearDesktopDirCache();
|
|
670
|
+
bridge('refreshAll', target === '' ? {} : { path: target }).then(
|
|
671
|
+
function (result) {
|
|
672
|
+
if (!aliveRef.current) return;
|
|
673
|
+
// 目录树:直接把新列表铺回去(正在看的这一层),并写回缓存。
|
|
674
|
+
if (result && result.directory && result.directory.ok) {
|
|
675
|
+
desktopDirCache[target === '' ? '' : target] = result.directory.value;
|
|
676
|
+
patch({ listing: result.directory.value });
|
|
677
|
+
}
|
|
678
|
+
// 模型:让官方目录失效重载,下一次打开菜单就是电脑的。
|
|
679
|
+
if (result && result.models && result.models.ok) {
|
|
680
|
+
try {
|
|
681
|
+
var models = props.modelDirectories;
|
|
682
|
+
if (models && models.catalog && typeof models.catalog.refresh === 'function') {
|
|
683
|
+
models.catalog.refresh();
|
|
684
|
+
}
|
|
685
|
+
} catch (error) {
|
|
686
|
+
/* 服务结构变了就只更新宿主缓存,不打扰用户 */
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
var failed = [];
|
|
690
|
+
if (result && result.models && !result.models.ok) failed.push('模型');
|
|
691
|
+
if (result && result.presets && !result.presets.ok) failed.push('预设');
|
|
692
|
+
if (result && result.directory && !result.directory.ok) failed.push('目录');
|
|
693
|
+
patchPair({
|
|
694
|
+
refreshing: false,
|
|
695
|
+
refreshNote: failed.length > 0
|
|
696
|
+
? { kind: 'error', text: failed.join('、') + '刷新失败,其余已更新' }
|
|
697
|
+
: { kind: 'ok', text: '模型、预设、目录已刷新' },
|
|
698
|
+
});
|
|
699
|
+
},
|
|
700
|
+
function (reason) {
|
|
701
|
+
if (!aliveRef.current) return;
|
|
702
|
+
patchPair({
|
|
703
|
+
refreshing: false,
|
|
704
|
+
refreshNote: { kind: 'error', text: String((reason && reason.message) || reason) },
|
|
705
|
+
});
|
|
706
|
+
},
|
|
707
|
+
);
|
|
708
|
+
};
|
|
709
|
+
|
|
558
710
|
/**
|
|
559
711
|
* 「电脑」页签顶部的配对条。
|
|
560
712
|
*
|
|
@@ -763,6 +915,41 @@ window.__ModuleLoader__.load({
|
|
|
763
915
|
'上一级',
|
|
764
916
|
),
|
|
765
917
|
h('span', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
|
|
918
|
+
// 有隐藏文件夹时才显示开关 —— 一个目录里没有 `.` 开头的东西
|
|
919
|
+
// 却摆个开关,只会让人以为漏了什么。
|
|
920
|
+
hiddenCount > 0 || state.showHidden === true
|
|
921
|
+
? h(
|
|
922
|
+
'button',
|
|
923
|
+
{
|
|
924
|
+
type: 'button',
|
|
925
|
+
style: state.showHidden === true
|
|
926
|
+
? Object.assign({}, CSS.crumbToggle, CSS.crumbToggleOn)
|
|
927
|
+
: CSS.crumbToggle,
|
|
928
|
+
title: state.showHidden === true
|
|
929
|
+
? '隐藏以 . 开头的文件夹'
|
|
930
|
+
: '显示 ' + hiddenCount + ' 个以 . 开头的隐藏文件夹',
|
|
931
|
+
onClick: function () {
|
|
932
|
+
patch({ showHidden: state.showHidden !== true });
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
state.showHidden === true ? '隐藏 . 文件夹' : '显示 . 文件夹 (' + hiddenCount + ')',
|
|
936
|
+
)
|
|
937
|
+
: null,
|
|
938
|
+
// 一键刷新:模型 / 预设 / 目录树。只在「电脑」页签下有意义
|
|
939
|
+
//(手机页签的数据本来就来自本机,没什么可刷的)。
|
|
940
|
+
state.tab === 'desktop' && pairState.paired === true
|
|
941
|
+
? h(
|
|
942
|
+
'button',
|
|
943
|
+
{
|
|
944
|
+
type: 'button',
|
|
945
|
+
style: CSS.crumbToggle,
|
|
946
|
+
title: '重新拉取电脑的模型列表、预设列表与当前目录',
|
|
947
|
+
disabled: pairState.refreshing === true,
|
|
948
|
+
onClick: refreshAll,
|
|
949
|
+
},
|
|
950
|
+
pairState.refreshing === true ? '刷新中…' : '刷新',
|
|
951
|
+
)
|
|
952
|
+
: null,
|
|
766
953
|
),
|
|
767
954
|
),
|
|
768
955
|
// —— 主体:目录列表 ——
|
|
@@ -772,6 +959,18 @@ window.__ModuleLoader__.load({
|
|
|
772
959
|
// 配对条只在「电脑」页签下出现 —— 「手机」页签跟配对无关。
|
|
773
960
|
state.tab === 'desktop' ? pairBar : null,
|
|
774
961
|
state.tab === 'desktop' ? pairMsg : null,
|
|
962
|
+
state.tab === 'desktop' && pairState.refreshNote !== null
|
|
963
|
+
? h(
|
|
964
|
+
'div',
|
|
965
|
+
{
|
|
966
|
+
style: pairState.refreshNote.kind === 'ok'
|
|
967
|
+
? Object.assign({}, CSS.refreshNote, CSS.refreshOk)
|
|
968
|
+
: Object.assign({}, CSS.refreshNote, CSS.error),
|
|
969
|
+
role: pairState.refreshNote.kind === 'ok' ? undefined : 'alert',
|
|
970
|
+
},
|
|
971
|
+
pairState.refreshNote.text,
|
|
972
|
+
)
|
|
973
|
+
: null,
|
|
775
974
|
state.loading
|
|
776
975
|
? h('div', { style: CSS.status, role: 'status' }, '加载中…')
|
|
777
976
|
: h(
|
|
@@ -784,7 +983,9 @@ window.__ModuleLoader__.load({
|
|
|
784
983
|
{ style: CSS.status },
|
|
785
984
|
state.tab === 'desktop' && pairState.paired !== true
|
|
786
985
|
? '配对后这里会显示电脑上的文件夹'
|
|
787
|
-
:
|
|
986
|
+
: hiddenCount > 0
|
|
987
|
+
? '这里只有 ' + hiddenCount + ' 个以 . 开头的隐藏文件夹'
|
|
988
|
+
: '这里没有子文件夹',
|
|
788
989
|
),
|
|
789
990
|
),
|
|
790
991
|
state.error !== null ? h('div', { style: CSS.error, role: 'alert' }, state.error) : null,
|
|
@@ -1058,6 +1259,14 @@ window.__ModuleLoader__.load({
|
|
|
1058
1259
|
},
|
|
1059
1260
|
/** 兜底:拉一次全量会话列表。 */
|
|
1060
1261
|
refreshSessions: () => ctx.sessions.refresh(),
|
|
1262
|
+
/**
|
|
1263
|
+
* 官方的模型目录服务(`super(ctx, "modelDirectories")`)。
|
|
1264
|
+
*
|
|
1265
|
+
* 「刷新」按钮要让它失效重载 —— 官方目录**带缓存**,光把宿主那边的
|
|
1266
|
+
* 数据换掉,前端仍然显示旧值(见 `refreshAll` 的注释)。
|
|
1267
|
+
* `ctx.get` 拿不到就返回 `undefined`,组件里会跳过这一步。
|
|
1268
|
+
*/
|
|
1269
|
+
modelDirectories: ctx.get === undefined ? undefined : ctx.get('modelDirectories'),
|
|
1061
1270
|
});
|
|
1062
1271
|
|
|
1063
1272
|
// 负 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 {
|
|
@@ -608,6 +655,9 @@ export function apply(ctx) {
|
|
|
608
655
|
/** 远程会话 id 集合:这些会话的 prompt 转发电脑,不在手机本地跑模型。 */
|
|
609
656
|
const remoteSessions = loadRemoteSessions();
|
|
610
657
|
|
|
658
|
+
/** 镜像工作区登记表:`<slug> → <电脑路径>`(见 `WORKSPACES_PATH` 的注释)。 */
|
|
659
|
+
const workspaceMap = loadWorkspaceMap();
|
|
660
|
+
|
|
611
661
|
/** 每个远程会话正在跟随的电脑事件流。 */
|
|
612
662
|
const follows = new Map();
|
|
613
663
|
|
|
@@ -627,6 +677,104 @@ export function apply(ctx) {
|
|
|
627
677
|
/** 浏览器侧全局 SSE 订阅者。 */
|
|
628
678
|
const watchers = new Set();
|
|
629
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
|
+
|
|
630
778
|
/** 往所有浏览器订阅者推一条 SSE。 */
|
|
631
779
|
const broadcast = (eventName, payload) => {
|
|
632
780
|
if (watchers.size === 0) return;
|
|
@@ -747,6 +895,69 @@ export function apply(ctx) {
|
|
|
747
895
|
});
|
|
748
896
|
};
|
|
749
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
|
+
|
|
750
961
|
// ---- 拦截官方 prompt:远程会话转发电脑,本地会话走原逻辑 ----
|
|
751
962
|
ctx.effect(() => {
|
|
752
963
|
const controller = ctx.sessionController;
|
|
@@ -763,7 +974,17 @@ export function apply(ctx) {
|
|
|
763
974
|
// 症状是**消息根本发不出去**,而报错文案完全看不出是参数漏传。
|
|
764
975
|
controller.prompt = async (request, signal) => {
|
|
765
976
|
if (!remoteSessions.has(request.sessionId)) return originalPrompt(request, signal);
|
|
766
|
-
|
|
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
|
|
767
988
|
.filter((part) => part.type === 'text')
|
|
768
989
|
.map((part) => part.text)
|
|
769
990
|
.join('\n');
|
|
@@ -781,6 +1002,8 @@ export function apply(ctx) {
|
|
|
781
1002
|
await callRemote(config, 'prompt', {
|
|
782
1003
|
sessionId: request.sessionId,
|
|
783
1004
|
text,
|
|
1005
|
+
// 附件形态一并带上(没有附件时给空数组,电脑端会退回纯文本)。
|
|
1006
|
+
content,
|
|
784
1007
|
...(request.requestId === undefined ? {} : { requestId: request.requestId }),
|
|
785
1008
|
});
|
|
786
1009
|
// 确保事件流在线(断线时重连,补齐这段时间的事件)。
|
|
@@ -900,6 +1123,234 @@ export function apply(ctx) {
|
|
|
900
1123
|
};
|
|
901
1124
|
}, 'dsh-remote-mobile: 屏蔽远程会话的本地标题生成');
|
|
902
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
|
+
|
|
903
1354
|
// ---- 浏览器桥:目录浏览 / 建会话 ----
|
|
904
1355
|
ctx.effect(
|
|
905
1356
|
() =>
|
|
@@ -928,7 +1379,14 @@ export function apply(ctx) {
|
|
|
928
1379
|
// 解绑要能停掉全部旧连接、清掉全部搬运状态(见 `unpair`)。
|
|
929
1380
|
follows,
|
|
930
1381
|
mirrorStates,
|
|
1382
|
+
// ⚠️ **必须显式传进来**:`handleBridge` 是模块级函数,作用域里
|
|
1383
|
+
// 没有 `apply()` 的闭包变量;漏传就是 `workspaceMap is not defined`,
|
|
1384
|
+
// 用户点「在此新建对话」时必然失败(1.0.16 的真实报障)。
|
|
1385
|
+
workspaceMap,
|
|
931
1386
|
persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
|
|
1387
|
+
// 模型/预设的静默缓存:配对后预拉、刷新按钮回写(见 desktopCache)。
|
|
1388
|
+
desktopCache,
|
|
1389
|
+
prefetch: prefetchDesktop,
|
|
932
1390
|
body,
|
|
933
1391
|
});
|
|
934
1392
|
reply(200, { ok: true, result });
|
|
@@ -980,6 +1438,11 @@ export function apply(ctx) {
|
|
|
980
1438
|
if (remoteSessions.size > 0) {
|
|
981
1439
|
ctx.logger?.info?.(`[dsh-remote-mobile] 已恢复 ${remoteSessions.size} 个远程会话的事件流`);
|
|
982
1440
|
}
|
|
1441
|
+
// 已配对的话,启动时也把电脑的模型/预设静默拉一次 —— 否则重启 App 后
|
|
1442
|
+
// 用户直接打开模型菜单会看到空列表(缓存是内存态,重启即丢)。
|
|
1443
|
+
// ⚠️ 不 await:这是后台动作,别拖慢引擎启动。`.catch` 兜住最后一道,
|
|
1444
|
+
// 免得一个后台失败变成 unhandled rejection 打脏引擎日志。
|
|
1445
|
+
prefetchDesktop('启动').catch(() => {});
|
|
983
1446
|
return () => {
|
|
984
1447
|
for (const controller of follows.values()) controller.abort();
|
|
985
1448
|
follows.clear();
|
|
@@ -1052,22 +1515,52 @@ function desktopTitle(desktopPath) {
|
|
|
1052
1515
|
* @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
|
|
1053
1516
|
* @returns 工作区实体(含 `.id` / `.path` / `.title`)。
|
|
1054
1517
|
*/
|
|
1055
|
-
async function ensureDesktopWorkspace(ctx, desktopPath) {
|
|
1518
|
+
async function ensureDesktopWorkspace(ctx, desktopPath, workspaceMap) {
|
|
1056
1519
|
const registry = ctx.get?.('workspaceRegistry');
|
|
1057
1520
|
if (registry === undefined || typeof registry.create !== 'function') {
|
|
1058
1521
|
throw new Error('workspaceRegistry 不可用,无法为电脑目录建工作区');
|
|
1059
1522
|
}
|
|
1060
|
-
const
|
|
1523
|
+
const slug = desktopSlug(desktopPath);
|
|
1524
|
+
const dir = join(STATE_DIR, 'workspaces', slug);
|
|
1061
1525
|
mkdirSync(dir, { recursive: true });
|
|
1526
|
+
// 登记 `slug → 电脑路径`,供「备选区直接选镜像工作区」那条路反查(见 WORKSPACES_PATH)。
|
|
1527
|
+
if (workspaceMap !== undefined && workspaceMap[slug] !== desktopPath) {
|
|
1528
|
+
workspaceMap[slug] = desktopPath;
|
|
1529
|
+
saveWorkspaceMap(workspaceMap, ctx.logger);
|
|
1530
|
+
}
|
|
1062
1531
|
return await registry.create(dir, desktopTitle(desktopPath));
|
|
1063
1532
|
}
|
|
1064
1533
|
|
|
1534
|
+
/**
|
|
1535
|
+
* 从镜像目录反查电脑路径。
|
|
1536
|
+
*
|
|
1537
|
+
* @param workspaceMap - `slug → 电脑路径`。
|
|
1538
|
+
* @param dir - 会话的 cwd(应当落在 `STATE_DIR/workspaces/<slug>` 下)。
|
|
1539
|
+
* @returns 电脑路径;不是镜像目录时 `undefined`。
|
|
1540
|
+
*/
|
|
1541
|
+
function desktopPathOfMirror(workspaceMap, dir) {
|
|
1542
|
+
if (typeof dir !== 'string' || dir === '') return undefined;
|
|
1543
|
+
// ⚠️ 用 `/workspaces/` 定位而不是拿 `join(STATE_DIR,'workspaces')` 做前缀比对:
|
|
1544
|
+
// `workspace.path` 是 **realpath**(官方 `createOrAdopt` 会 realpath 解析),
|
|
1545
|
+
// 在真机上可能多出/少掉一层符号链接(`/data/user/0` vs `/data/data`),
|
|
1546
|
+
// 前缀一比就永远不命中 —— 症状是「修了但没生效」,极难查。
|
|
1547
|
+
//
|
|
1548
|
+
// 分隔符两种都认:真机是 posix(`join` 给 `/`),但把本插件装到 Windows 上
|
|
1549
|
+
// 调试时 `join` 给的是 `\`,只认一种就会在那边静默失效。
|
|
1550
|
+
const at = dir.search(/[\\/]workspaces[\\/]/);
|
|
1551
|
+
if (at < 0) return undefined;
|
|
1552
|
+
const slug = dir.slice(at).split(/[\\/]/)[2];
|
|
1553
|
+
if (slug === undefined || slug === '') return undefined;
|
|
1554
|
+
const path = workspaceMap[slug];
|
|
1555
|
+
return typeof path === 'string' && path !== '' ? path : undefined;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1065
1558
|
/**
|
|
1066
1559
|
* 处理浏览器桥来的一条 RPC。
|
|
1067
1560
|
*
|
|
1068
1561
|
* @param ctx - Cordis 上下文。
|
|
1069
1562
|
* @param config - 远程配置。
|
|
1070
|
-
* @param io - `{ remoteSessions, follow, follows, mirrorStates, persist, body }`。
|
|
1563
|
+
* @param io - `{ remoteSessions, follow, follows, mirrorStates, persist, workspaceMap, body }`。
|
|
1071
1564
|
*/
|
|
1072
1565
|
async function handleBridge(ctx, config, io) {
|
|
1073
1566
|
const params = io.body?.params ?? {};
|
|
@@ -1080,6 +1573,56 @@ async function handleBridge(ctx, config, io) {
|
|
|
1080
1573
|
case 'listDesktop':
|
|
1081
1574
|
return callRemote(config, 'listDirectory', { path: params.path });
|
|
1082
1575
|
|
|
1576
|
+
/**
|
|
1577
|
+
* 电脑上的模型目录。
|
|
1578
|
+
*
|
|
1579
|
+
* 用户要的是「选了电脑目录之后,输入框右下方的模型列表就刷新为电脑的」。
|
|
1580
|
+
* 手机本地那份模型列表来自手机自己的 LLM 配置,跟电脑上能跑什么没关系,
|
|
1581
|
+
* 拿它去发消息会选到一个电脑上不存在的模型。
|
|
1582
|
+
*/
|
|
1583
|
+
case 'modelCatalog':
|
|
1584
|
+
return callRemote(config, 'modelCatalog', {});
|
|
1585
|
+
|
|
1586
|
+
/** 电脑上的预设列表。 */
|
|
1587
|
+
case 'presetList':
|
|
1588
|
+
return callRemote(config, 'presetList', {});
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* 一键刷新:把「模型 / 预设 / 目录树」三样一起重新拉一遍。
|
|
1592
|
+
*
|
|
1593
|
+
* ⚠️ 三样**必须并发**且**互不牵连**:目录树拉不到(比如电脑上那个目录被删了)
|
|
1594
|
+
* 不该让模型列表也刷不出来。所以逐个 catch,谁失败谁自己带 `error`。
|
|
1595
|
+
*
|
|
1596
|
+
* @returns `{ models, presets, directory }`,每项是 `{ok, value|error}`。
|
|
1597
|
+
*/
|
|
1598
|
+
case 'refreshAll': {
|
|
1599
|
+
const wantDirectory = typeof params.path === 'string' && params.path !== '';
|
|
1600
|
+
const jobs = [
|
|
1601
|
+
['models', () => callRemote(config, 'modelCatalog', {})],
|
|
1602
|
+
['presets', () => callRemote(config, 'presetList', {})],
|
|
1603
|
+
...(wantDirectory ? [['directory', () => callRemote(config, 'listDirectory', { path: params.path })]] : []),
|
|
1604
|
+
];
|
|
1605
|
+
const settled = await Promise.all(
|
|
1606
|
+
jobs.map(async ([key, run]) => {
|
|
1607
|
+
try {
|
|
1608
|
+
return [key, { ok: true, value: await run() }];
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
return [key, { ok: false, error: String(error?.message ?? error) }];
|
|
1611
|
+
}
|
|
1612
|
+
}),
|
|
1613
|
+
);
|
|
1614
|
+
const result = Object.fromEntries(settled);
|
|
1615
|
+
// ⚠️ 刷新到的模型/预设要**写回缓存**,否则「刷新按钮」只更新了它自己
|
|
1616
|
+
// 那份响应,前端模型菜单读的是宿主拦截器里的缓存 —— 用户会看到
|
|
1617
|
+
// 「点了刷新,模型列表没变」。
|
|
1618
|
+
if (io.desktopCache !== undefined) {
|
|
1619
|
+
if (result.models?.ok === true) io.desktopCache.catalog = result.models.value;
|
|
1620
|
+
if (result.presets?.ok === true) io.desktopCache.presets = result.presets.value;
|
|
1621
|
+
io.desktopCache.at = Date.now();
|
|
1622
|
+
}
|
|
1623
|
+
return result;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1083
1626
|
/**
|
|
1084
1627
|
* 在电脑的某个目录下开一个新会话。
|
|
1085
1628
|
*
|
|
@@ -1101,7 +1644,7 @@ async function handleBridge(ctx, config, io) {
|
|
|
1101
1644
|
// 这里给它一个**电脑目录专属的镜像工作区**(标题 = 电脑目录末段),
|
|
1102
1645
|
// 而不是手机的「默认工作区」—— 否则所有远程会话都堆在「默认工作区」
|
|
1103
1646
|
// 底下,看起来就像「建完又回到默认工作去了」(见 `ensureDesktopWorkspace`)。
|
|
1104
|
-
const workspace = await ensureDesktopWorkspace(ctx, params.path);
|
|
1647
|
+
const workspace = await ensureDesktopWorkspace(ctx, params.path, io.workspaceMap);
|
|
1105
1648
|
const cwd = workspace.path;
|
|
1106
1649
|
// ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
|
|
1107
1650
|
//
|
|
@@ -1176,6 +1719,10 @@ async function handleBridge(ctx, config, io) {
|
|
|
1176
1719
|
}
|
|
1177
1720
|
const saved = await claimPairing(params.code.trim());
|
|
1178
1721
|
Object.assign(config, saved);
|
|
1722
|
+
// 配对成功后**静默**预拉电脑的模型与预设(用户需求原文:
|
|
1723
|
+
// 「用户配对成功之后 就可以静默的拉取一些数据了,比如模型列表和预设列表」)。
|
|
1724
|
+
// ⚠️ 不 await:这是后台动作,配对请求要立刻回给前端,别让用户等一轮往返。
|
|
1725
|
+
io.prefetch?.('配对成功')?.catch?.(() => {});
|
|
1179
1726
|
return { endpoint: saved.endpoint, tokenSet: saved.token !== '' };
|
|
1180
1727
|
}
|
|
1181
1728
|
|
|
@@ -1227,4 +1774,4 @@ async function handleBridge(ctx, config, io) {
|
|
|
1227
1774
|
}
|
|
1228
1775
|
}
|
|
1229
1776
|
|
|
1230
|
-
export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH, PAIR_ENDPOINT, claimPairing };
|
|
1777
|
+
export { callRemote, mirrorEvent, loadConfig, DEFAULT_ENDPOINT, CONFIG_PATH, PAIR_ENDPOINT, claimPairing, handleBridge };
|