dsh-jiuling-remote-mobile 1.0.26 → 1.0.28
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 +160 -22
- package/lib/index.js +151 -7
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -862,14 +862,68 @@ window.__ModuleLoader__.load({
|
|
|
862
862
|
if (presets === undefined || presets === null) return function () {};
|
|
863
863
|
if (typeof presets.list !== 'function') return function () {};
|
|
864
864
|
|
|
865
|
+
// ⚠️⚠️ **必须在「原始实例」上覆盖 `list`,不能在 `ctx.remote.agentPresets`
|
|
866
|
+
// 拿到的那个对象上覆盖 —— 这是 1.0.25 / 1.0.26 / 1.0.27 三次真机都
|
|
867
|
+
// 「改了却完全没效果」的根因。**
|
|
868
|
+
//
|
|
869
|
+
// 与 `patchModelDirectories` 同一个陷阱(见那里的长注释):cordis 的
|
|
870
|
+
// `ctx.remote` 是 `createTraceable()` 造出来的 **Proxy**,它的 `get` trap
|
|
871
|
+
// 会把方法包成 `createShadowMethod(...)`,并把对象属性也裹一层。
|
|
872
|
+
// 往 Proxy 上赋一个新属性,写进的是 Proxy 的 shadow 层,
|
|
873
|
+
// **官方 `readRoster` 读的是它自己那份 `ctx.remote.agentPresets.list`** ——
|
|
874
|
+
// 两者不是同一个函数位,所以我那次赋值官方永远看不到。
|
|
875
|
+
//
|
|
876
|
+
// 真机症状(用户三次反馈):
|
|
877
|
+
// 1.0.25「模型列表能够快速切换回来了,但是预设列表没有切换」
|
|
878
|
+
// 1.0.26/1.0.27「不管是应用的私有默认工作区,还是我自己选择的
|
|
879
|
+
// 手机目录工作区,都无法自动切换回手机的预设列表」
|
|
880
|
+
// —— 注意「模型能切、预设不能切」,正是因为模型走
|
|
881
|
+
// `patchModelDirectories`(**正确解包了**),而预设没有。
|
|
882
|
+
//
|
|
883
|
+
// 正解:用 `Symbol.for('cordis.original')` 取出 Proxy 背后的原始实例
|
|
884
|
+
// (`createTraceable` 的 get trap 第一条就是 `if (prop === symbols.original) return target`),
|
|
885
|
+
// 在**它**身上覆盖。这样官方经 Proxy 读到的就是这个被包过的 `list`。
|
|
886
|
+
var ORIGINAL = Symbol.for('cordis.original');
|
|
887
|
+
var raw = presets[ORIGINAL] === undefined ? presets : presets[ORIGINAL];
|
|
888
|
+
if (!raw || typeof raw.list !== 'function') return function () {};
|
|
889
|
+
|
|
865
890
|
/**
|
|
866
|
-
* 「当前主视图会话」的 id
|
|
867
|
-
*
|
|
868
|
-
* `
|
|
891
|
+
* 「当前主视图会话」的 id。
|
|
892
|
+
*
|
|
893
|
+
* ⚠️⚠️ **必须优先读 `uiWorkspace.selection`,不能只靠 `retainedBy`。**
|
|
894
|
+
*
|
|
895
|
+
* 1.0.25 第一版用的是「遍历 `sessions.byId`,找 `retainedBy.mainView > 0`
|
|
896
|
+
* 的第一行」—— 那是官方 `installWorkspaceShortcuts` 里给快捷键用的
|
|
897
|
+
* `current()`(`dsh-client-ui-workspace/lib/client.js:106`),它只保证
|
|
898
|
+
* 「**有一个**会话被主视图持有」,**不保证唯一、也不保证是最新的那个**。
|
|
899
|
+
* 电脑工作区与手机工作区各有一个被持有的会话时,遍历顺序先命中谁就是谁,
|
|
900
|
+
* 于是用户切回手机目录后,判定仍然可能落在电脑那个会话上。
|
|
901
|
+
*
|
|
902
|
+
* 真机症状(用户截图):切回手机工作区,预设列表仍是电脑那份,
|
|
903
|
+
* 选中后报 `Unknown agent preset: pojia` —— 手机宿主不认识电脑的预设 id。
|
|
904
|
+
*
|
|
905
|
+
* 正解是官方自己的权威来源:`uiWorkspace.selection`
|
|
906
|
+
* (`createSnapshotStore({})`,见其 L761;`replaceMain()` 在切换主视图时
|
|
907
|
+
* 用 `this.selection.set({sessionId: reference.sessionId})` 写入,
|
|
908
|
+
* 见其 L982-985)。它就是「现在主视图上打开的是哪个会话」。
|
|
909
|
+
*
|
|
910
|
+
* `retainedBy` 只作为拿不到 `selection` 时的退路。
|
|
869
911
|
*
|
|
870
912
|
* @returns 会话 id;找不到时 `undefined`。
|
|
871
913
|
*/
|
|
872
914
|
var currentSessionId = function () {
|
|
915
|
+
// ① 权威来源:`uiWorkspace.selection`。
|
|
916
|
+
var uiWorkspace = ctx.uiWorkspace;
|
|
917
|
+
if (uiWorkspace !== undefined && uiWorkspace !== null) {
|
|
918
|
+
var selection = uiWorkspace.selection;
|
|
919
|
+
if (selection !== undefined && selection !== null && typeof selection.getSnapshot === 'function') {
|
|
920
|
+
var chosen = selection.getSnapshot();
|
|
921
|
+
if (chosen && typeof chosen.sessionId === 'string' && chosen.sessionId !== '') {
|
|
922
|
+
return chosen.sessionId;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// ② 退路:`retainedBy.mainView`(官方快捷键用的那条,不唯一)。
|
|
873
927
|
var sessions = ctx.sessions;
|
|
874
928
|
if (sessions === undefined || sessions === null) return undefined;
|
|
875
929
|
var list = sessions.list;
|
|
@@ -887,27 +941,111 @@ window.__ModuleLoader__.load({
|
|
|
887
941
|
return undefined;
|
|
888
942
|
};
|
|
889
943
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
944
|
+
/**
|
|
945
|
+
* 当前主视图所在的**工作区路径** —— 「选工作区就是开关」的那把开关本身。
|
|
946
|
+
*
|
|
947
|
+
* 用户原话:「就是选工作区的时候就作为一个开关……检测到是手机的,你就给我
|
|
948
|
+
* 换回手机那一套呗,检测到是电脑的就换回电脑那一套呗」。
|
|
949
|
+
*
|
|
950
|
+
* 所以判据应当落在**工作区**上,而不是绕着会话去猜。官方自己在
|
|
951
|
+
* `dsh-client-ui-workspace/lib/client.js:932` 就是这么定位当前工作区的:
|
|
952
|
+
*
|
|
953
|
+
* ```js
|
|
954
|
+
* const workspace = workspaces.items.find(
|
|
955
|
+
* (item) => item.sessionIds.includes(sessionId));
|
|
956
|
+
* ```
|
|
957
|
+
*
|
|
958
|
+
* `item.path` 即该工作区的目录 —— 电脑镜像工作区的路径里带
|
|
959
|
+
* `.../workspaces/<slug>/...`,`mirrorSlugOf()` 能认出 slug。
|
|
960
|
+
*
|
|
961
|
+
* @param sessionId - 当前主视图会话 id。
|
|
962
|
+
* @returns 工作区路径;查不到时 `undefined`。
|
|
963
|
+
*/
|
|
964
|
+
var workspacePathOfCurrent = function (sessionId) {
|
|
965
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
966
|
+
var workspaceService = ctx.workspaces;
|
|
967
|
+
if (workspaceService === undefined || workspaceService === null) return undefined;
|
|
968
|
+
var list = workspaceService.list;
|
|
969
|
+
if (list === undefined || list === null || typeof list.getSnapshot !== 'function') return undefined;
|
|
970
|
+
var snapshot = list.getSnapshot();
|
|
971
|
+
if (snapshot === undefined || snapshot === null) return undefined;
|
|
972
|
+
var items = snapshot.items;
|
|
973
|
+
if (!Array.isArray(items)) return undefined;
|
|
974
|
+
for (var at = 0; at < items.length; at += 1) {
|
|
975
|
+
var item = items[at];
|
|
976
|
+
if (!item || !Array.isArray(item.sessionIds)) continue;
|
|
977
|
+
if (item.sessionIds.indexOf(sessionId) === -1) continue;
|
|
978
|
+
if (typeof item.path === 'string' && item.path !== '') return item.path;
|
|
979
|
+
return undefined;
|
|
980
|
+
}
|
|
981
|
+
return undefined;
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* 这一屏该用电脑的预设还是手机的预设 —— **一个布尔开关**。
|
|
986
|
+
*
|
|
987
|
+
* 判据顺序(前面的赢):
|
|
988
|
+
*
|
|
989
|
+
* 1. **当前工作区的路径**是不是电脑镜像目录 —— 用户要的那个开关;
|
|
990
|
+
* 2. 当前会话自己的 `cwd` —— 与模型菜单完全同一条(`isRemoteSession`);
|
|
991
|
+
* 3. 都没有 ⇒ 当成手机(本地)。
|
|
992
|
+
*
|
|
993
|
+
* ⚠️ 第 1 条必须放最前:切工作区时 `selection` 与列表行可能晚一两帧才一致,
|
|
994
|
+
* 而工作区行是**已经切过去**的那一个,它最能代表用户此刻的选择。
|
|
995
|
+
*
|
|
996
|
+
* @returns 是不是电脑工作区。
|
|
997
|
+
*/
|
|
998
|
+
var currentIsRemote = function () {
|
|
999
|
+
var sessionId = currentSessionId();
|
|
1000
|
+
var path = workspacePathOfCurrent(sessionId);
|
|
1001
|
+
if (typeof path === 'string' && path !== '') {
|
|
1002
|
+
var slug = mirrorSlugOf(path);
|
|
1003
|
+
if (slug !== undefined && mirrorSlugs[slug] === true) return true;
|
|
1004
|
+
// 路径读到了、且不是镜像目录 ⇒ 就是手机本地工作区,直接定论。
|
|
1005
|
+
return false;
|
|
1006
|
+
}
|
|
1007
|
+
if (sessionId === undefined) return false;
|
|
1008
|
+
return isRemoteSession(sessionId, cwdOf);
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* 覆盖一个对象上的 `list`,返回撤销函数。
|
|
1013
|
+
*
|
|
1014
|
+
* 抽出来是为了**同时覆盖 Proxy 与原始实例两个位置**:只要有一处没覆盖到,
|
|
1015
|
+
* 官方读到旧函数,整个筛选就是静默失效(1.0.25→1.0.27 三次都是这个形态)。
|
|
1016
|
+
* 两处都覆盖,无论官方经哪条路径读都命中。
|
|
1017
|
+
*
|
|
1018
|
+
* @param host - 要覆盖的对象(Proxy 或原始实例)。
|
|
1019
|
+
* @returns 撤销函数。
|
|
1020
|
+
*/
|
|
1021
|
+
var overrideList = function (host) {
|
|
1022
|
+
if (!host || typeof host.list !== 'function') return function () {};
|
|
1023
|
+
var hadOwn = Object.prototype.hasOwnProperty.call(host, 'list');
|
|
1024
|
+
var previous = host.list;
|
|
1025
|
+
var call = previous.bind(host);
|
|
1026
|
+
host.list = function () {
|
|
1027
|
+
return call.apply(null, arguments).then(function (result) {
|
|
1028
|
+
// ⚠️ 失败回执原样透传:`readRoster` 靠 `result.ok` 判断,
|
|
1029
|
+
// 也靠 `error.code === 'gateway/invocation-unavailable'` 兜底,
|
|
1030
|
+
// 动它会让预设菜单变成错误态。
|
|
1031
|
+
if (!result || result.ok !== true || !result.value) return result;
|
|
1032
|
+
var picked = pickWorkspacePresets(result.value, currentIsRemote());
|
|
1033
|
+
if (picked === result.value) return result;
|
|
1034
|
+
return Object.assign({}, result, { value: picked });
|
|
1035
|
+
});
|
|
1036
|
+
};
|
|
1037
|
+
return function () {
|
|
1038
|
+
if (hadOwn) host.list = previous;
|
|
1039
|
+
else delete host.list;
|
|
1040
|
+
};
|
|
907
1041
|
};
|
|
1042
|
+
|
|
1043
|
+
// 两处都覆盖:Proxy(`presets`)与原始实例(`raw`)。
|
|
1044
|
+
var undoProxy = overrideList(presets);
|
|
1045
|
+
var undoRaw = raw === presets ? function () {} : overrideList(raw);
|
|
908
1046
|
return function () {
|
|
909
|
-
|
|
910
|
-
|
|
1047
|
+
undoRaw();
|
|
1048
|
+
undoProxy();
|
|
911
1049
|
};
|
|
912
1050
|
}
|
|
913
1051
|
|
package/lib/index.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
50
|
import { createHash, randomUUID } from 'node:crypto';
|
|
51
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
51
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
52
52
|
import { request as httpRequest } from 'node:http';
|
|
53
53
|
import { request as httpsRequest } from 'node:https';
|
|
54
54
|
import { homedir } from 'node:os';
|
|
@@ -72,6 +72,80 @@ const CONFIG_PATH = join(STATE_DIR, 'mobile.json');
|
|
|
72
72
|
/** 远程会话清单(持久化,重开 App 后能接着搬)。 */
|
|
73
73
|
const SESSIONS_PATH = join(STATE_DIR, 'sessions.json');
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* 电脑配置的本地快照 —— 模型目录、预设名单、根目录列表。
|
|
77
|
+
*
|
|
78
|
+
* ## 为什么要落盘(用户明确要求,2026-10-17)
|
|
79
|
+
*
|
|
80
|
+
* 用户原话:「我不想要你每次都往电脑上面拉取数据……用户匹配成功之后,他就加载
|
|
81
|
+
* 一次文件目录,还有预设以及模型列表就可以了,后续你就不用再拉取了。……电脑的
|
|
82
|
+
* 配置很少会有改变的,有改变的话,用户他自己在那个工作区那里点一下刷新来重新
|
|
83
|
+
* 拉取就行了。平时呢你就把上一次拉到的结果,直接给我存死在本地,后续就从本地
|
|
84
|
+
* 读取就行了,这样速度快体验优。」
|
|
85
|
+
*
|
|
86
|
+
* 对应两个真机症状:
|
|
87
|
+
*
|
|
88
|
+
* 1. **切模型转圈**:`modelCatalog` / `presetList` 这两个 RPC 原来是**每次都联网**
|
|
89
|
+
* (见 `case 'modelCatalog'`),前端每展开一次模型菜单或切一个模型,就要等一趟
|
|
90
|
+
* 公网往返。本地已有数据时这完全是白等。
|
|
91
|
+
* 2. **电脑下线后预设还是旧的**:`desktopCache` 只在**成功**时写入、从不因失败清空,
|
|
92
|
+
* 所以电脑隧道断了以后,内存里那份旧预设仍被 `mergePresets` 并进去,
|
|
93
|
+
* 用户切回手机工作区也看到电脑的预设(`pojia`),选中就报
|
|
94
|
+
* `Unknown agent preset: pojia`。
|
|
95
|
+
*
|
|
96
|
+
* 落盘之后,读路径**完全不碰网络**:有本地快照就直接给,没有才拉一次。
|
|
97
|
+
* 重新拉取只由两条路触发 —— 配对成功、以及用户自己点「刷新」。
|
|
98
|
+
*/
|
|
99
|
+
const DESKTOP_CACHE_PATH = join(STATE_DIR, 'desktop-cache.json');
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 读本地快照。
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ 任何异常都只返回 `null`,绝不让它挡住插件启动 —— 这个文件是**加速用的**,
|
|
105
|
+
* 坏了最多退回「联网拉一次」,不该让整个远程功能起不来。
|
|
106
|
+
*
|
|
107
|
+
* @returns `{catalog, presets, directory, at}` 或 `null`。
|
|
108
|
+
*/
|
|
109
|
+
function loadDesktopCache() {
|
|
110
|
+
try {
|
|
111
|
+
if (!existsSync(DESKTOP_CACHE_PATH)) return null;
|
|
112
|
+
const raw = readFileSync(DESKTOP_CACHE_PATH, 'utf8');
|
|
113
|
+
if (typeof raw !== 'string' || raw === '') return null;
|
|
114
|
+
const parsed = JSON.parse(raw);
|
|
115
|
+
if (parsed === null || typeof parsed !== 'object') return null;
|
|
116
|
+
return {
|
|
117
|
+
catalog: parsed.catalog ?? null,
|
|
118
|
+
presets: parsed.presets ?? null,
|
|
119
|
+
directory: parsed.directory ?? null,
|
|
120
|
+
at: typeof parsed.at === 'number' ? parsed.at : 0,
|
|
121
|
+
};
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 写本地快照(临时文件 + rename,与 `saveConfig` 同一个理由)。
|
|
129
|
+
*
|
|
130
|
+
* @param cache - `{catalog, presets, directory, at}`。
|
|
131
|
+
*/
|
|
132
|
+
function saveDesktopCache(cache) {
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
135
|
+
const record = {
|
|
136
|
+
catalog: cache.catalog ?? null,
|
|
137
|
+
presets: cache.presets ?? null,
|
|
138
|
+
directory: cache.directory ?? null,
|
|
139
|
+
at: cache.at ?? Date.now(),
|
|
140
|
+
};
|
|
141
|
+
const tmp = `${DESKTOP_CACHE_PATH}.${process.pid}.tmp`;
|
|
142
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
143
|
+
renameSync(tmp, DESKTOP_CACHE_PATH);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
/* 写盘失败不影响本次会话的使用,下次成功时会再写 */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
75
149
|
/** 默认隧道地址。 */
|
|
76
150
|
const DEFAULT_ENDPOINT = 'https://dsh-remote.jiulingssh.xyz';
|
|
77
151
|
|
|
@@ -1003,9 +1077,33 @@ export function apply(ctx) {
|
|
|
1003
1077
|
* **整棵目录树不进缓存**:电脑上几万个目录,全拉一遍既慢又没意义,而且
|
|
1004
1078
|
* 会大面积显示过期内容。所以 `dirs` 只装「用户实际看过的那几层的下一层」,
|
|
1005
1079
|
* 上限见 `DESKTOP_DIRS_MAX`。过期窗口由「刷新」按钮关闭。
|
|
1080
|
+
*
|
|
1081
|
+
* ⚠️ **`catalog` / `presets` / `directory` 三项同时落盘**(见
|
|
1082
|
+
* `DESKTOP_CACHE_PATH`):重开 App、甚至电脑隧道断了,这三样也从本地读,
|
|
1083
|
+
* 读路径**完全不碰网络**。`dirs` **不落盘** —— 它是按层预取的临时加速,
|
|
1084
|
+
* 数量会膨胀到几百条,存下来既无必要也让文件变大。
|
|
1006
1085
|
*/
|
|
1007
1086
|
const desktopCache = { catalog: null, presets: null, directory: null, dirs: emptyDirCache(), at: 0 };
|
|
1008
1087
|
|
|
1088
|
+
// 启动即从磁盘恢复上次拉到的电脑配置 —— 这是「配对后只拉一次」的落地点。
|
|
1089
|
+
const restoredCache = loadDesktopCache();
|
|
1090
|
+
if (restoredCache !== null) {
|
|
1091
|
+
desktopCache.catalog = restoredCache.catalog;
|
|
1092
|
+
desktopCache.presets = restoredCache.presets;
|
|
1093
|
+
desktopCache.directory = restoredCache.directory;
|
|
1094
|
+
desktopCache.at = restoredCache.at;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** 把当前内存里的三项落盘(`dirs` 不存,见 `desktopCache` 的注释)。 */
|
|
1098
|
+
const persistDesktopCache = () => {
|
|
1099
|
+
saveDesktopCache({
|
|
1100
|
+
catalog: desktopCache.catalog,
|
|
1101
|
+
presets: desktopCache.presets,
|
|
1102
|
+
directory: desktopCache.directory,
|
|
1103
|
+
at: desktopCache.at,
|
|
1104
|
+
});
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1009
1107
|
/** `desktopCache.dirs` 的容量上限(按插入顺序淘汰最旧的那些)。 */
|
|
1010
1108
|
const DESKTOP_DIRS_MAX = 200;
|
|
1011
1109
|
|
|
@@ -1119,6 +1217,9 @@ export function apply(ctx) {
|
|
|
1119
1217
|
if (presets !== null) desktopCache.presets = presets;
|
|
1120
1218
|
if (directory !== null) desktopCache.directory = directory;
|
|
1121
1219
|
desktopCache.at = Date.now();
|
|
1220
|
+
// ⚠️ 只有**真的拉到东西**才落盘。全失败时保留上次的本地快照,
|
|
1221
|
+
// 而不是把缓存写成空 —— 用户要的是「上次拉到的结果存死在本地」。
|
|
1222
|
+
if (catalog !== null || presets !== null || directory !== null) persistDesktopCache();
|
|
1122
1223
|
const models = catalog?.groups?.reduce((n, g) => n + (g.models?.length ?? 0), 0) ?? 0;
|
|
1123
1224
|
const dirs = directory?.entries?.length ?? directory?.items?.length ?? 0;
|
|
1124
1225
|
ctx.logger?.info?.(
|
|
@@ -1142,6 +1243,9 @@ export function apply(ctx) {
|
|
|
1142
1243
|
if (desktopCache.catalog !== null) return desktopCache.catalog;
|
|
1143
1244
|
try {
|
|
1144
1245
|
desktopCache.catalog = await callRemote(config, 'modelCatalog', {});
|
|
1246
|
+
// 第一次拉到就落盘:此后重开 App / 电脑下线都从本地读,不再联网。
|
|
1247
|
+
desktopCache.at = Date.now();
|
|
1248
|
+
persistDesktopCache();
|
|
1145
1249
|
return desktopCache.catalog;
|
|
1146
1250
|
} catch (error) {
|
|
1147
1251
|
ctx.logger?.warn?.(
|
|
@@ -1918,6 +2022,9 @@ export function apply(ctx) {
|
|
|
1918
2022
|
persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
|
|
1919
2023
|
// 模型/预设的静默缓存:配对后预拉、刷新按钮回写(见 desktopCache)。
|
|
1920
2024
|
desktopCache,
|
|
2025
|
+
// ⚠️ 也要显式传:`refreshAll` 是模块级 `handleBridge` 里的
|
|
2026
|
+
// case 分支,同样看不见 `apply()` 的闭包变量。
|
|
2027
|
+
persistDesktopCache,
|
|
1921
2028
|
prefetch: prefetchDesktop,
|
|
1922
2029
|
// ⚠️ 同 `workspaceMap`:`handleBridge` 是模块级函数,闭包里的
|
|
1923
2030
|
// `prefetchDirs` / `rememberDir` 它看不见,必须显式传。
|
|
@@ -2154,18 +2261,51 @@ async function handleBridge(ctx, config, io) {
|
|
|
2154
2261
|
}
|
|
2155
2262
|
|
|
2156
2263
|
/**
|
|
2157
|
-
*
|
|
2264
|
+
* 电脑上的模型目录 —— **只读本地缓存,不联网**。
|
|
2158
2265
|
*
|
|
2159
2266
|
* 用户要的是「选了电脑目录之后,输入框右下方的模型列表就刷新为电脑的」。
|
|
2160
2267
|
* 手机本地那份模型列表来自手机自己的 LLM 配置,跟电脑上能跑什么没关系,
|
|
2161
2268
|
* 拿它去发消息会选到一个电脑上不存在的模型。
|
|
2269
|
+
*
|
|
2270
|
+
* ⚠️⚠️ **这里从前是 `return callRemote(config, 'modelCatalog', {})`,
|
|
2271
|
+
* 每次前端读一次就过一趟公网隧道。** 用户原话:「我选择了电脑工作区之后,
|
|
2272
|
+
* 虽然说模型列表已经显示的是电脑的了,但是我如果展开列表,然后切换一个
|
|
2273
|
+
* 模型的话,此时会转圈圈的。如果是本地读取的话,完全没必要转圈圈」。
|
|
2274
|
+
*
|
|
2275
|
+
* 现在改为:**有缓存直接给,没有才拉一次并落盘**。电脑的模型配置极少变,
|
|
2276
|
+
* 要更新由用户自己点「刷新」(走 `refreshAll`,那条路仍然联网)。
|
|
2162
2277
|
*/
|
|
2163
|
-
case 'modelCatalog':
|
|
2164
|
-
|
|
2278
|
+
case 'modelCatalog': {
|
|
2279
|
+
if (desktopCache.catalog !== null) return desktopCache.catalog;
|
|
2280
|
+
const fetched = await desktopCatalog();
|
|
2281
|
+
if (fetched !== null) persistDesktopCache();
|
|
2282
|
+
return fetched;
|
|
2283
|
+
}
|
|
2165
2284
|
|
|
2166
|
-
/**
|
|
2167
|
-
|
|
2168
|
-
|
|
2285
|
+
/**
|
|
2286
|
+
* 电脑上的预设列表 —— **只读本地缓存,不联网**(理由同 `modelCatalog`)。
|
|
2287
|
+
*
|
|
2288
|
+
* 没有缓存的第一次仍会拉一次,之后一直用本地的。电脑下线后也不会再
|
|
2289
|
+
* 反复打失败的请求;用户切回手机工作区,读到的仍是这份本地快照,
|
|
2290
|
+
* 由前端按工作区筛选(见 `pickWorkspacePresets`)。
|
|
2291
|
+
*/
|
|
2292
|
+
case 'presetList': {
|
|
2293
|
+
if (desktopCache.presets !== null) return desktopCache.presets;
|
|
2294
|
+
try {
|
|
2295
|
+
const fetched = await callRemote(config, 'presetList', {});
|
|
2296
|
+
if (fetched !== null && fetched !== undefined) {
|
|
2297
|
+
desktopCache.presets = fetched;
|
|
2298
|
+
desktopCache.at = Date.now();
|
|
2299
|
+
persistDesktopCache();
|
|
2300
|
+
}
|
|
2301
|
+
return fetched;
|
|
2302
|
+
} catch (error) {
|
|
2303
|
+
ctx.logger?.warn?.(
|
|
2304
|
+
`[dsh-remote-mobile] 取电脑预设列表失败: ${String(error?.message ?? error)}`,
|
|
2305
|
+
);
|
|
2306
|
+
return null;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2169
2309
|
|
|
2170
2310
|
/**
|
|
2171
2311
|
* 一键刷新:把「模型 / 预设 / 目录树」三样一起重新拉一遍。
|
|
@@ -2217,6 +2357,10 @@ async function handleBridge(ctx, config, io) {
|
|
|
2217
2357
|
io.desktopCache.directory = result.directory.value;
|
|
2218
2358
|
}
|
|
2219
2359
|
io.desktopCache.at = Date.now();
|
|
2360
|
+
// ⚠️ 手动刷新是**唯一**会把新数据写进本地快照的路径 —— 平时读的都是
|
|
2361
|
+
// 这份快照(见 `modelCatalog` / `presetList` 两个 case)。不落盘的话,
|
|
2362
|
+
// 刷新只对本次运行有效,重开 App 又回到旧数据。
|
|
2363
|
+
io.persistDesktopCache?.();
|
|
2220
2364
|
// 刷新完顺手把这一层的下一层预取回来,用户点进去就不用等了。
|
|
2221
2365
|
if (result.directory?.ok === true) io.prefetchDirs?.(result.directory.value);
|
|
2222
2366
|
}
|