dsh-jiuling-remote-mobile 1.0.15 → 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.
Files changed (3) hide show
  1. package/lib/client.js +154 -2
  2. package/lib/index.js +547 -4
  3. 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
- var entries = entriesOf(state.listing).filter(isDir);
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 {
@@ -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
- const text = (request.content ?? [])
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
  () =>
@@ -929,6 +1380,9 @@ export function apply(ctx) {
929
1380
  follows,
930
1381
  mirrorStates,
931
1382
  persist: () => saveRemoteSessions(remoteSessions, ctx.logger),
1383
+ // 模型/预设的静默缓存:配对后预拉、刷新按钮回写(见 desktopCache)。
1384
+ desktopCache,
1385
+ prefetch: prefetchDesktop,
932
1386
  body,
933
1387
  });
934
1388
  reply(200, { ok: true, result });
@@ -980,6 +1434,11 @@ export function apply(ctx) {
980
1434
  if (remoteSessions.size > 0) {
981
1435
  ctx.logger?.info?.(`[dsh-remote-mobile] 已恢复 ${remoteSessions.size} 个远程会话的事件流`);
982
1436
  }
1437
+ // 已配对的话,启动时也把电脑的模型/预设静默拉一次 —— 否则重启 App 后
1438
+ // 用户直接打开模型菜单会看到空列表(缓存是内存态,重启即丢)。
1439
+ // ⚠️ 不 await:这是后台动作,别拖慢引擎启动。`.catch` 兜住最后一道,
1440
+ // 免得一个后台失败变成 unhandled rejection 打脏引擎日志。
1441
+ prefetchDesktop('启动').catch(() => {});
983
1442
  return () => {
984
1443
  for (const controller of follows.values()) controller.abort();
985
1444
  follows.clear();
@@ -1052,16 +1511,46 @@ function desktopTitle(desktopPath) {
1052
1511
  * @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
1053
1512
  * @returns 工作区实体(含 `.id` / `.path` / `.title`)。
1054
1513
  */
1055
- async function ensureDesktopWorkspace(ctx, desktopPath) {
1514
+ async function ensureDesktopWorkspace(ctx, desktopPath, workspaceMap) {
1056
1515
  const registry = ctx.get?.('workspaceRegistry');
1057
1516
  if (registry === undefined || typeof registry.create !== 'function') {
1058
1517
  throw new Error('workspaceRegistry 不可用,无法为电脑目录建工作区');
1059
1518
  }
1060
- const dir = join(STATE_DIR, 'workspaces', desktopSlug(desktopPath));
1519
+ const slug = desktopSlug(desktopPath);
1520
+ const dir = join(STATE_DIR, 'workspaces', slug);
1061
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
+ }
1062
1527
  return await registry.create(dir, desktopTitle(desktopPath));
1063
1528
  }
1064
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
+
1065
1554
  /**
1066
1555
  * 处理浏览器桥来的一条 RPC。
1067
1556
  *
@@ -1080,6 +1569,56 @@ async function handleBridge(ctx, config, io) {
1080
1569
  case 'listDesktop':
1081
1570
  return callRemote(config, 'listDirectory', { path: params.path });
1082
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
+
1083
1622
  /**
1084
1623
  * 在电脑的某个目录下开一个新会话。
1085
1624
  *
@@ -1101,7 +1640,7 @@ async function handleBridge(ctx, config, io) {
1101
1640
  // 这里给它一个**电脑目录专属的镜像工作区**(标题 = 电脑目录末段),
1102
1641
  // 而不是手机的「默认工作区」—— 否则所有远程会话都堆在「默认工作区」
1103
1642
  // 底下,看起来就像「建完又回到默认工作去了」(见 `ensureDesktopWorkspace`)。
1104
- const workspace = await ensureDesktopWorkspace(ctx, params.path);
1643
+ const workspace = await ensureDesktopWorkspace(ctx, params.path, workspaceMap);
1105
1644
  const cwd = workspace.path;
1106
1645
  // ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
1107
1646
  //
@@ -1176,6 +1715,10 @@ async function handleBridge(ctx, config, io) {
1176
1715
  }
1177
1716
  const saved = await claimPairing(params.code.trim());
1178
1717
  Object.assign(config, saved);
1718
+ // 配对成功后**静默**预拉电脑的模型与预设(用户需求原文:
1719
+ // 「用户配对成功之后 就可以静默的拉取一些数据了,比如模型列表和预设列表」)。
1720
+ // ⚠️ 不 await:这是后台动作,配对请求要立刻回给前端,别让用户等一轮往返。
1721
+ io.prefetch?.('配对成功')?.catch?.(() => {});
1179
1722
  return { endpoint: saved.endpoint, tokenSet: saved.token !== '' };
1180
1723
  }
1181
1724
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jiuling-remote-mobile",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",