dsh-jiuling-remote-mobile 1.0.18 → 1.0.20
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 +530 -0
- package/lib/index.js +197 -21
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -261,6 +261,478 @@ window.__ModuleLoader__.load({
|
|
|
261
261
|
desktopDirCache = {};
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* 哪些会话是「跑在电脑上的」—— 宿主 `remoteSessions` 的本地镜像。
|
|
266
|
+
*
|
|
267
|
+
* ## 为什么需要(用户需求原文)
|
|
268
|
+
*
|
|
269
|
+
* 「用户如果当前选择的是 **app 本地文件夹**,那么**就不要出现电脑的模型**,
|
|
270
|
+
* 以这个文件夹为隔离条件,**互不干预**」。
|
|
271
|
+
*
|
|
272
|
+
* 宿主把电脑的模型目录与手机本地那份**并**在一起交给前端(原因见宿主
|
|
273
|
+
* `mergeCatalog` 的注释:官方 `modelCatalog` 是全局唯一一份、不带 sessionId)。
|
|
274
|
+
* 所以要按工作区隔离,只能在**前端按会话**再筛一次 —— 而筛之前必须先知道
|
|
275
|
+
* 「这个会话是不是电脑的」。
|
|
276
|
+
*
|
|
277
|
+
* ⚠️ **判据必须与宿主 `prompt` 拦截同源**(宿主读的也是 `remoteSessions`)。
|
|
278
|
+
* 用 cwd 猜镜像目录是另一套判据,一旦两者不一致,就会出现「菜单里显示的是
|
|
279
|
+
* 电脑模型、消息却发到手机本地」这种最坏情况。
|
|
280
|
+
*
|
|
281
|
+
* ⚠️ **不能每次渲染打一次桥**:模型菜单每帧都读目录快照,那是同步 API。
|
|
282
|
+
* 所以 `apply` 时拉一次全量,之后建会话 / 解绑时自己增量维护,
|
|
283
|
+
* `getSnapshot` 纯查内存(见宿主 `case 'remoteSessions'` 的注释)。
|
|
284
|
+
*/
|
|
285
|
+
var remoteSessionIds = {};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 电脑镜像工作区的 slug 集合 —— 隔离的**真正判据**。
|
|
289
|
+
*
|
|
290
|
+
* ⚠️⚠️ **为什么不用 `remoteSessionIds`(那是第一代,已被用户报障推翻)。**
|
|
291
|
+
*
|
|
292
|
+
* 第一代按 **sessionId** 判定,只在「用户从电脑文件夹对话框里建会话」
|
|
293
|
+
* 或「宿主拦截器绑过」时才知道。而用户在**侧边栏点「新建会话」**时,
|
|
294
|
+
* 官方前端直接 `sessionController.create({workspaceId})`,**不经过本插件**;
|
|
295
|
+
* 宿主的 create 拦截器认得出镜像目录(消息发得对),但前端不知道
|
|
296
|
+
* ⇒ 模型菜单仍按「本地」筛,显示手机模型。
|
|
297
|
+
*
|
|
298
|
+
* 用户原话:「只有我手动添加工作区的时候,才会触发模型和预设的刷新」——
|
|
299
|
+
* 说的就是判据挂在了「走了哪条路」上,而不是「会话的工作区在哪」。
|
|
300
|
+
*
|
|
301
|
+
* 正解:**按 cwd 判定**,与宿主 `desktopPathOfMirror` 同一个轴。
|
|
302
|
+
* 前端从 `sessions.list` 里同步读到会话的 `cwd`,看它是不是
|
|
303
|
+
* `…/workspaces/<slug>` 且 slug 属于电脑镜像。这条路与「谁建的会话」
|
|
304
|
+
* 完全无关,所以**任何入口进来都一致,不需要任何刷新触发**。
|
|
305
|
+
*/
|
|
306
|
+
var mirrorSlugs = {};
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 从 cwd 取出镜像工作区的 slug(与宿主 `desktopPathOfMirror` 同构)。
|
|
310
|
+
*
|
|
311
|
+
* ⚠️ 用 `/workspaces/` 定位而不是前缀比对:`workspace.path` 是 realpath,
|
|
312
|
+
* 真机上 `/data/user/0` 与 `/data/data` 会差一层,前缀一比就永远不命中
|
|
313
|
+
* (宿主那边踩过这个坑,注释见 `desktopPathOfMirror`)。
|
|
314
|
+
* 两种分隔符都认:手机是 posix,但在 Windows 上调试时是 `\`。
|
|
315
|
+
*
|
|
316
|
+
* @param cwd - 会话的 cwd。
|
|
317
|
+
* @returns 镜像工作区的 slug;不是镜像目录时 `undefined`。
|
|
318
|
+
*/
|
|
319
|
+
function mirrorSlugOf(cwd) {
|
|
320
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined;
|
|
321
|
+
var at = cwd.search(/[\\/]workspaces[\\/]/);
|
|
322
|
+
if (at < 0) return undefined;
|
|
323
|
+
var slug = cwd.slice(at).split(/[\\/]/)[2];
|
|
324
|
+
return slug === undefined || slug === '' ? undefined : slug;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 这个会话是不是跑在电脑上 —— **同步**判定,任何入口都用同一条。
|
|
329
|
+
*
|
|
330
|
+
* ⚠️ **cwd 读得到时,它以 cwd 为唯一判据**(不看 `remoteSessionIds`):
|
|
331
|
+
* 工作区就是隔离条件本身。解绑后 `mirrorSlugs` 清空 ⇒ 全部回到本地,
|
|
332
|
+
* 不需要再去清每个会话的旧标记。
|
|
333
|
+
*
|
|
334
|
+
* 只有列表行还没带 cwd 的那一两帧(会话刚建好)才退回 `remoteSessionIds`。
|
|
335
|
+
*
|
|
336
|
+
* @param sessionId - 会话 id。
|
|
337
|
+
* @param cwdOf - `sessionId → cwd` 的同步读取函数。
|
|
338
|
+
* @returns 是不是电脑工作区的会话。
|
|
339
|
+
*/
|
|
340
|
+
function isRemoteSession(sessionId, cwdOf) {
|
|
341
|
+
var cwd = cwdOf === undefined ? undefined : cwdOf(sessionId);
|
|
342
|
+
if (typeof cwd === 'string' && cwd !== '') {
|
|
343
|
+
var slug = mirrorSlugOf(cwd);
|
|
344
|
+
return slug !== undefined && mirrorSlugs[slug] === true;
|
|
345
|
+
}
|
|
346
|
+
return remoteSessionIds[sessionId] === true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 从宿主拉一次全量远程会话清单(失败就按「全是本地」处理)。 */
|
|
350
|
+
function loadRemoteSessionIds() {
|
|
351
|
+
return bridge('remoteSessions', {}).then(
|
|
352
|
+
function (result) {
|
|
353
|
+
var ids = result && result.ids;
|
|
354
|
+
if (!Array.isArray(ids)) return;
|
|
355
|
+
var next = {};
|
|
356
|
+
for (var at = 0; at < ids.length; at += 1) next[ids[at]] = true;
|
|
357
|
+
remoteSessionIds = next;
|
|
358
|
+
},
|
|
359
|
+
function () {
|
|
360
|
+
/* 拉不到就保持原样:最坏是本地工作区多看到电脑模型,不会误发 */
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 从宿主拉一次「电脑镜像工作区的 slug 集合」(隔离的真正判据)。
|
|
367
|
+
*
|
|
368
|
+
* 拉不到就保持原样(空集合 = 全部按本地处理):最坏是本地工作区多看到
|
|
369
|
+
* 电脑模型,**不会误发** —— 消息该发给谁由宿主 `prompt` 拦截器按
|
|
370
|
+
* `remoteSessions` 决定,与这里无关。
|
|
371
|
+
*/
|
|
372
|
+
function loadMirrorSlugs() {
|
|
373
|
+
return bridge('mirrorSlugs', {}).then(
|
|
374
|
+
function (result) {
|
|
375
|
+
var slugs = result && result.slugs;
|
|
376
|
+
if (!Array.isArray(slugs)) return;
|
|
377
|
+
var next = {};
|
|
378
|
+
for (var at = 0; at < slugs.length; at += 1) next[slugs[at]] = true;
|
|
379
|
+
mirrorSlugs = next;
|
|
380
|
+
},
|
|
381
|
+
function () {
|
|
382
|
+
/* 同上:最坏是多显示几项,不会误发 */
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 按「这个会话是不是电脑的」筛出该显示的组与失败项。
|
|
389
|
+
*
|
|
390
|
+
* ## 双向隔离
|
|
391
|
+
*
|
|
392
|
+
* - **本地工作区**:只留手机自己的组,并把被电脑同 id provider 覆盖掉的那组
|
|
393
|
+
* **还原**回来(`localGroups`,宿主 `mergeCatalog` 额外带上的)。
|
|
394
|
+
* - **电脑工作区**:只留电脑的组。这不是「顺手加活」—— 电脑会话的 prompt 会
|
|
395
|
+
* 转发到电脑,选一个手机本地才有的模型(比如 `agnes-3.0-flash`)发过去,
|
|
396
|
+
* 电脑上根本不存在,必然报错。用户说的「互不干预」正是这个意思。
|
|
397
|
+
*
|
|
398
|
+
* ⚠️ 没配对时目录里根本没有 `desktop` 标记,两个分支都会原样返回。
|
|
399
|
+
*
|
|
400
|
+
* ⚠️ **`extraLocal` 是必需的**:官方的 `syncInputs` 往 `directory.store`
|
|
401
|
+
* 里只拷了 `groups` / `failures`(见
|
|
402
|
+
* `dsh-client-ui-model-selection/lib/client.js:325`),**`localGroups` 不在
|
|
403
|
+
* 那一层**。所以筛 `directory.store` 时得由调用方把 `localGroups`
|
|
404
|
+
* 从 catalog 原值里递进来,否则本地工作区永远还原不出被覆盖的那组。
|
|
405
|
+
*
|
|
406
|
+
* @param value - 目录值(`{groups, failures, localGroups?, localFailures?}`)。
|
|
407
|
+
* @param isRemote - 这个会话是不是跑在电脑上。
|
|
408
|
+
* @param extraLocal - 备用本地组(`value.localGroups` 缺失时用)。
|
|
409
|
+
* @returns `{groups, failures}`(`value` 不合法时原样返回空数组)。
|
|
410
|
+
*/
|
|
411
|
+
function pickWorkspaceGroups(value, isRemote, extraLocal) {
|
|
412
|
+
if (!value || !Array.isArray(value.groups)) {
|
|
413
|
+
return { groups: [], failures: [] };
|
|
414
|
+
}
|
|
415
|
+
const groups = value.groups;
|
|
416
|
+
const failures = Array.isArray(value.failures) ? value.failures : [];
|
|
417
|
+
if (isRemote === true) {
|
|
418
|
+
return {
|
|
419
|
+
groups: groups.filter(function (group) { return group.desktop === true; }),
|
|
420
|
+
failures: failures.filter(function (failure) { return failure.desktop === true; }),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const kept = groups.filter(function (group) { return group.desktop !== true; });
|
|
424
|
+
// 还原被电脑同 id 覆盖掉的本地组(见宿主 `mergeCatalog`)。
|
|
425
|
+
const local = Array.isArray(value.localGroups)
|
|
426
|
+
? value.localGroups
|
|
427
|
+
: (Array.isArray(extraLocal) ? extraLocal : []);
|
|
428
|
+
if (local.length > 0) {
|
|
429
|
+
const have = {};
|
|
430
|
+
for (let at = 0; at < kept.length; at += 1) have[kept[at].id] = true;
|
|
431
|
+
for (let lt = 0; lt < local.length; lt += 1) {
|
|
432
|
+
if (have[local[lt].id] !== true) kept.push(local[lt]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
groups: kept,
|
|
437
|
+
failures: failures.filter(function (failure) { return failure.desktop !== true; }),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 从共享的 catalog 原值里取「手机本地的组」(宿主 `mergeCatalog` 带的)。
|
|
443
|
+
*
|
|
444
|
+
* @param directory - 官方的 `ModelDirectory`(含 `.catalog`)。
|
|
445
|
+
* @returns 本地组数组;拿不到就空数组。
|
|
446
|
+
*/
|
|
447
|
+
function localGroupsOf(directory) {
|
|
448
|
+
try {
|
|
449
|
+
// ⚠️ 不用可选链:本文件受 chrome73 语法约束(见文件头)。
|
|
450
|
+
if (!directory || !directory.catalog || !directory.catalog.store) return [];
|
|
451
|
+
const store = directory.catalog.store;
|
|
452
|
+
if (typeof store.getSnapshot !== 'function') return [];
|
|
453
|
+
const value = store.getSnapshot().value;
|
|
454
|
+
return value && Array.isArray(value.localGroups) ? value.localGroups : [];
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 某个 `provider/model` 在给定的组里存不存在。 */
|
|
461
|
+
function isSelectable(groups, selection) { if (!selection) return false;
|
|
462
|
+
for (let at = 0; at < groups.length; at += 1) {
|
|
463
|
+
if (groups[at].id !== selection.provider) continue;
|
|
464
|
+
const models = Array.isArray(groups[at].models) ? groups[at].models : [];
|
|
465
|
+
for (let mt = 0; mt < models.length; mt += 1) {
|
|
466
|
+
if (models[mt].id === selection.model) return true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 筛**目录值**(`catalog.store` 里 `{value}` 那一份)。
|
|
474
|
+
*
|
|
475
|
+
* ⚠️ **`default` 必须跟着换**:官方的 `syncInputs` 用
|
|
476
|
+
* `projected.next ?? catalog.value.default` 定 `current`,如果 `default`
|
|
477
|
+
* 指向一个已被筛掉的 provider,界面就会显示一个不在列表里的模型名。
|
|
478
|
+
* 所以筛完若 `default` 不在剩下的组里,就退到第一个组的第一个模型。
|
|
479
|
+
*
|
|
480
|
+
* ⚠️ **一组都没剩时原样返回**(例如电脑那边所有 provider 都挂了):
|
|
481
|
+
* 宁可多显示几个,也别把菜单变成空的 —— 空菜单用户连「刷新」都点不到。
|
|
482
|
+
*
|
|
483
|
+
* @param value - 目录值。
|
|
484
|
+
* @param isRemote - 这个会话是不是跑在电脑上。
|
|
485
|
+
* @returns 筛过的目录值。
|
|
486
|
+
*/
|
|
487
|
+
function filterCatalogValue(value, isRemote) {
|
|
488
|
+
if (!value || !Array.isArray(value.groups)) return value;
|
|
489
|
+
const picked = pickWorkspaceGroups(value, isRemote);
|
|
490
|
+
if (picked.groups.length === 0) return value;
|
|
491
|
+
const next = Object.assign({}, value, {
|
|
492
|
+
groups: picked.groups,
|
|
493
|
+
failures: picked.failures,
|
|
494
|
+
});
|
|
495
|
+
if (!isSelectable(picked.groups, value.default)) {
|
|
496
|
+
const first = picked.groups[0];
|
|
497
|
+
const firstModel = Array.isArray(first.models) ? first.models[0] : undefined;
|
|
498
|
+
if (firstModel !== undefined) next.default = { provider: first.id, model: firstModel.id };
|
|
499
|
+
}
|
|
500
|
+
return next;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 给官方那个**每会话共享**的 `ModelDirectory` 套一层「按工作区筛」的读路径。
|
|
505
|
+
*
|
|
506
|
+
* ## 为什么包装 `directoryFor` 而不是改前端 bundle
|
|
507
|
+
*
|
|
508
|
+
* `conversation.input.model` 这个 slot 的 `inject(sessionId)` 会调
|
|
509
|
+
* `modelDirectories.directoryFor(sessionId)`(见
|
|
510
|
+
* `dsh-client-ui-model-selection/lib/client.js:1189`),菜单和 `/model`
|
|
511
|
+
* 命令都从它拿数据。`directoryFor` 是**服务实例上的普通方法**,包它
|
|
512
|
+
* 就等于包住了这两个入口 —— 不用碰前端内部类,升级也不会碎。
|
|
513
|
+
*
|
|
514
|
+
* ## 为什么两层的 store 都要包
|
|
515
|
+
*
|
|
516
|
+
* 官方有两级快照,形状**不一样**,两个都要筛:
|
|
517
|
+
*
|
|
518
|
+
* | 来源 | 形状 | 谁读它 |
|
|
519
|
+
* |---|---|---|
|
|
520
|
+
* | `catalog.store` | `{value:{groups,default,…}, status}` | `syncInputs()` 算 `current`/`routable` |
|
|
521
|
+
* | `directory.store` | `{groups, failures, current, status, …}` | `ModelSelect` 直接渲染 |
|
|
522
|
+
*
|
|
523
|
+
* 只筛 `directory.store` 的话,`syncInputs` 仍会用**没筛过**的 `default`
|
|
524
|
+
* 把 `current` 算成被筛掉的那个模型 —— 菜单里没有它、触发按钮却显示它,
|
|
525
|
+
* 而且 `routable` 会被算成 `false`,官方界面会提示「该 provider 的目录加载失败」。
|
|
526
|
+
*
|
|
527
|
+
* ## 为什么要「稳定」而不是每次新建
|
|
528
|
+
*
|
|
529
|
+
* `inject` 每渲染一帧都调一次 `directoryFor`,返回值里的 `store` 直接喂给
|
|
530
|
+
* `useSyncExternalStore` 的 `subscribe`。每次返回新对象会让 React 每帧
|
|
531
|
+
* 重订阅。所以包装缓存在原 `directory.__dshRemoteScoped` 上,生命周期跟着
|
|
532
|
+
* 官方的会话 scope 一起走 —— 官方在 scope dispose 时删掉原实例,
|
|
533
|
+
* 包装随之被 GC,不会累积。
|
|
534
|
+
*
|
|
535
|
+
* ⚠️ 用 `Object.create(原型) + Object.assign` 而不是 `{...directory}`:
|
|
536
|
+
* `ModelDirectory` 的方法(`select` / `reasoningFor` / `syncInputs`)
|
|
537
|
+
* 在**原型**上,展开运算符只复制自有属性、会把方法全丢掉 —— 症状是
|
|
538
|
+
* 「点模型没反应」。
|
|
539
|
+
*
|
|
540
|
+
* ⚠️ `select` 走原型链、以 `this = wrapper` 调用,于是它读到的
|
|
541
|
+
* `this.store.getSnapshot()` 是**筛过**的(`current` 正确),写回
|
|
542
|
+
* `this.store.update()` 仍转发给原 store。读写都落在正确的那一层。
|
|
543
|
+
*
|
|
544
|
+
* @param directory - 官方的 `ModelDirectory` 实例。
|
|
545
|
+
* @param sessionId - 它归属的会话。
|
|
546
|
+
* @param cwdOf - `sessionId → cwd` 的**同步**读取函数(隔离判据要用它)。
|
|
547
|
+
* @returns 读路径被筛过的同构对象。
|
|
548
|
+
*/
|
|
549
|
+
function scopeDirectoryToWorkspace(directory, sessionId, cwdOf) {
|
|
550
|
+
if (!directory || !directory.store || typeof directory.store.getSnapshot !== 'function') {
|
|
551
|
+
return directory;
|
|
552
|
+
}
|
|
553
|
+
// ⚠️ 判据是**每次读时现算**的(不是建包装时算一次):会话的 cwd 可能
|
|
554
|
+
// 在列表行到达后才出现,而且解绑/换工作区都会改结论。
|
|
555
|
+
// `isRemoteSession` 见其注释 —— 它看的 cwd,跟宿主 `desktopPathOfMirror` 同一个轴。
|
|
556
|
+
const isRemote = function () { return isRemoteSession(sessionId, cwdOf); };
|
|
557
|
+
const wrapper = Object.create(Object.getPrototypeOf(directory));
|
|
558
|
+
Object.assign(wrapper, directory);
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 包一层 store:读时筛,通知与写回照旧转发。
|
|
562
|
+
*
|
|
563
|
+
* ⚠️⚠️ **`getSnapshot()` 必须身份稳定**,否则 React 直接炸。这条是
|
|
564
|
+
* 1.0.19 第二次真机启动实测到的(第一次是上面的 `remote.session`):
|
|
565
|
+
*
|
|
566
|
+
* [web] slot entry crashed in 'conversation.input.model':
|
|
567
|
+
* Error: Minified React error #185
|
|
568
|
+
*
|
|
569
|
+
* #185 就是「Maximum update depth exceeded」(无限重渲染)。原因:
|
|
570
|
+
* 官方把这个 store 的 `getSnapshot` 直接喂给 `useSyncExternalStore`,
|
|
571
|
+
* 而 React 在渲染期用 **`Object.is`** 比较前后两次快照,不同就再渲染一次。
|
|
572
|
+
* 而 `pickWorkspaceGroups` 每次都 `filter` 出新数组、外面又 `Object.assign`
|
|
573
|
+
* 出新对象 —— **同一个源快照也每次返回新对象** ⇒ 每一帧都"变了" ⇒ 死循环。
|
|
574
|
+
*
|
|
575
|
+
* 所以按「源快照身份 + 影响筛选结果的外部开关」缓存上一次的投影结果。
|
|
576
|
+
*/
|
|
577
|
+
const wrapStore = function (store, project, volatileToken) {
|
|
578
|
+
let cachedSource;
|
|
579
|
+
let cachedToken;
|
|
580
|
+
let cachedProjected;
|
|
581
|
+
return {
|
|
582
|
+
getSnapshot: function () {
|
|
583
|
+
const source = store.getSnapshot();
|
|
584
|
+
const token = volatileToken === undefined ? '' : volatileToken();
|
|
585
|
+
// 源快照换了、或开关变了才重算;否则复用同一个对象(身份稳定)。
|
|
586
|
+
if (source === cachedSource && token === cachedToken) return cachedProjected;
|
|
587
|
+
cachedSource = source;
|
|
588
|
+
cachedToken = token;
|
|
589
|
+
cachedProjected = project(source);
|
|
590
|
+
return cachedProjected;
|
|
591
|
+
},
|
|
592
|
+
subscribe: function (fn) { return store.subscribe(fn); },
|
|
593
|
+
update: function (mutator) { return store.update(mutator); },
|
|
594
|
+
set: function (value) { return store.set(value); },
|
|
595
|
+
};
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const store = directory.store;
|
|
599
|
+
wrapper.store = wrapStore(store, function (snapshot) {
|
|
600
|
+
// ⚠️ `directory.store` 那一层没有 `localGroups`(官方 `syncInputs`
|
|
601
|
+
// 只拷了 groups/failures),所以从 catalog 的原值里现取一份递进去。
|
|
602
|
+
const picked = pickWorkspaceGroups(snapshot, isRemote(), localGroupsOf(directory));
|
|
603
|
+
if (picked.groups.length === 0) return snapshot;
|
|
604
|
+
const next = Object.assign({}, snapshot, picked);
|
|
605
|
+
// `current` 指向被筛掉的模型时置空 —— 官方界面会显示「选择模型」,
|
|
606
|
+
// 比显示一个列表里根本没有的名字好。
|
|
607
|
+
if (!isSelectable(picked.groups, snapshot.current)) next.current = null;
|
|
608
|
+
return next;
|
|
609
|
+
// ⚠️ 缓存键必须带上「这个会话算不算远程」:同一个源快照在「切成远程会话」
|
|
610
|
+
// 前后要投影出**不同**结果,只看源快照身份会永远返回旧的那一份。
|
|
611
|
+
}, function () { return isRemote() === true ? 'remote:1' : 'remote:0'; });
|
|
612
|
+
|
|
613
|
+
// `load()` 返回的就是快照,`/model` 命令直接拿它渲染,所以也要筛。
|
|
614
|
+
wrapper.load = function () {
|
|
615
|
+
return Promise.resolve(directory.load()).then(function () {
|
|
616
|
+
return wrapper.store.getSnapshot();
|
|
617
|
+
});
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const catalog = directory.catalog;
|
|
621
|
+
if (catalog && catalog.store && typeof catalog.store.getSnapshot === 'function') {
|
|
622
|
+
const catalogStore = catalog.store;
|
|
623
|
+
const catalogWrapper = Object.create(Object.getPrototypeOf(catalog));
|
|
624
|
+
Object.assign(catalogWrapper, catalog);
|
|
625
|
+
catalogWrapper.store = wrapStore(catalogStore, function (snapshot) {
|
|
626
|
+
return Object.assign({}, snapshot, {
|
|
627
|
+
value: filterCatalogValue(snapshot.value, isRemote()),
|
|
628
|
+
});
|
|
629
|
+
}, function () { return isRemote() === true ? 'remote:1' : 'remote:0'; });
|
|
630
|
+
wrapper.catalog = catalogWrapper;
|
|
631
|
+
}
|
|
632
|
+
return wrapper;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 把「按工作区筛模型目录」接到官方服务上。
|
|
637
|
+
*
|
|
638
|
+
* `ctx.get` 拿不到服务就静默跳过(最坏是回到「两个工作区看到同一份列表」的
|
|
639
|
+
* 旧行为,不会报错)。返回一个撤销函数。
|
|
640
|
+
*
|
|
641
|
+
* @param ctx - client 根上下文。
|
|
642
|
+
* @param cwdOf - `sessionId → cwd` 的**同步**读取函数(隔离判据要用它)。
|
|
643
|
+
* @returns 撤销函数。
|
|
644
|
+
*/
|
|
645
|
+
function patchModelDirectories(ctx, cwdOf) {
|
|
646
|
+
var models = ctx.get === undefined ? undefined : ctx.get('modelDirectories');
|
|
647
|
+
if (!models || typeof models.directoryFor !== 'function') return function () {};
|
|
648
|
+
|
|
649
|
+
// ⚠️⚠️ **必须在「原始实例」上覆盖,不能在 `ctx.get()` 拿到的那个对象上覆盖。**
|
|
650
|
+
//
|
|
651
|
+
// `modelDirectories` 是 cordis 的 Service,`ctx.get()` 返回的是
|
|
652
|
+
// `createTraceable()` 造出来的 **Proxy**(见 cordis `lib/index.js` 的
|
|
653
|
+
// `getTraceable`/`createTraceable`)。那个 Proxy 的 `get` 会把方法包成
|
|
654
|
+
// `createShadowMethod(ctx, fn, receiver, shadow)`,其 apply 里有一句:
|
|
655
|
+
//
|
|
656
|
+
// if (thisArg === outer) thisArg = shadow;
|
|
657
|
+
//
|
|
658
|
+
// `outer` 就是取方法时的那个 receiver,而 `shadow` 是
|
|
659
|
+
// `withProp(receiver, property, ctx.extend({[shadow]: origin}))` ——
|
|
660
|
+
// 这个 `ctx` 是**谁调 `ctx.get()` 就用谁的上下文**。
|
|
661
|
+
//
|
|
662
|
+
// 于是 `models.directoryFor.bind(models)` 把 this 钉在了 Proxy 上,
|
|
663
|
+
// 每次调用都命中 `thisArg === outer` ⇒ this 被换成**我的插件上下文**
|
|
664
|
+
// 扩展出来的 shadow。而官方 `ModelDirectoryResolver.directoryFor` 内部要读
|
|
665
|
+
// `this.ctx.remote.session`(`dsh-client-ui-model-selection/lib/client.js:388`),
|
|
666
|
+
// 我的插件没有 inject 过 `remote.session` ⇒ 抛
|
|
667
|
+
//
|
|
668
|
+
// cannot get property "remote.session" without inject
|
|
669
|
+
//
|
|
670
|
+
// 真机症状:`conversation.input.model` 这个 slot 整体 crash,
|
|
671
|
+
// **输入框上的模型按钮直接消失**。1.0.19 首次真机启动实测到的。
|
|
672
|
+
//
|
|
673
|
+
// 正解是用 `Symbol.for('cordis.original')` 取出 Proxy 背后的原始实例
|
|
674
|
+
// (`createTraceable` 的 get trap 里第一条就是 `if (prop === symbols.original) return target`),
|
|
675
|
+
// 在**它**身上覆盖。这样 `this.ctx` 始终是 `ModelDirectoryResolver`
|
|
676
|
+
// 构造时 `super(ctx, …)` 存下的那个 ctx,服务解析照旧。
|
|
677
|
+
var ORIGINAL = Symbol.for('cordis.original');
|
|
678
|
+
var raw = models[ORIGINAL] === undefined ? models : models[ORIGINAL];
|
|
679
|
+
if (!raw || typeof raw.directoryFor !== 'function') return function () {};
|
|
680
|
+
|
|
681
|
+
var hadOwn = Object.prototype.hasOwnProperty.call(raw, 'directoryFor');
|
|
682
|
+
var previous = raw.directoryFor;
|
|
683
|
+
var call = previous.bind(raw);
|
|
684
|
+
raw.directoryFor = function (sessionId) {
|
|
685
|
+
var directory = call(sessionId);
|
|
686
|
+
if (directory.__dshRemoteScoped !== undefined) return directory.__dshRemoteScoped;
|
|
687
|
+
var scoped = scopeDirectoryToWorkspace(directory, sessionId, cwdOf);
|
|
688
|
+
try {
|
|
689
|
+
directory.__dshRemoteScoped = scoped;
|
|
690
|
+
} catch (error) {
|
|
691
|
+
/* 冻结就退回「每次新建」,功能不受影响 */
|
|
692
|
+
}
|
|
693
|
+
return scoped;
|
|
694
|
+
};
|
|
695
|
+
return function () {
|
|
696
|
+
if (hadOwn) raw.directoryFor = previous;
|
|
697
|
+
else delete raw.directoryFor;
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 造一个 `sessionId → cwd` 的**同步**读取函数。
|
|
703
|
+
*
|
|
704
|
+
* 会话的 cwd 在客户端是**同步可读**的:`sessions` 服务的 `list` 是
|
|
705
|
+
* `createSnapshotStore({ids, byId, …})`,`projectList()` 给每一行算出了
|
|
706
|
+
* `cwd`(`dsh-api-session-controller/lib/client.js` 的 `byId[id] = {id,
|
|
707
|
+
* displayTitle, running, …, cwd?, …}`)。
|
|
708
|
+
*
|
|
709
|
+
* ⚠️ 之所以要「同步」:模型菜单每渲染一帧都会读一次目录快照
|
|
710
|
+
* (`getSnapshot`),那里**不能**是异步的。
|
|
711
|
+
*
|
|
712
|
+
* 全用显式 `if` 判空 —— chrome73 语法约束,不能用 `?.`。
|
|
713
|
+
*
|
|
714
|
+
* @param ctx - client 根上下文。
|
|
715
|
+
* @returns 读取函数;读不到时返回 `undefined`。
|
|
716
|
+
*/
|
|
717
|
+
function makeCwdOf(ctx) {
|
|
718
|
+
return function (sessionId) {
|
|
719
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
720
|
+
var sessions = ctx.sessions;
|
|
721
|
+
if (sessions === undefined || sessions === null) return undefined;
|
|
722
|
+
var list = sessions.list;
|
|
723
|
+
if (list === undefined || list === null) return undefined;
|
|
724
|
+
if (typeof list.getSnapshot !== 'function') return undefined;
|
|
725
|
+
var snapshot = list.getSnapshot();
|
|
726
|
+
if (snapshot === undefined || snapshot === null) return undefined;
|
|
727
|
+
var byId = snapshot.byId;
|
|
728
|
+
if (byId === undefined || byId === null) return undefined;
|
|
729
|
+
var row = byId[sessionId];
|
|
730
|
+
if (row === undefined || row === null) return undefined;
|
|
731
|
+
var cwd = row.cwd;
|
|
732
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : undefined;
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
264
736
|
/**
|
|
265
737
|
* 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
|
|
266
738
|
*
|
|
@@ -359,6 +831,12 @@ window.__ModuleLoader__.load({
|
|
|
359
831
|
var registerLocal = props.registerLocal;
|
|
360
832
|
/** 兜底:拉一次全量会话列表(登记失败时用)。 */
|
|
361
833
|
var refreshSessions = props.refreshSessions;
|
|
834
|
+
/** 把会话标记成「电脑的」(cwd 还没进列表行时兜底,见 `isRemoteSession`)。 */
|
|
835
|
+
var markRemote = props.markRemote;
|
|
836
|
+
/** 重拉一次镜像工作区 slug 集合(新建镜像工作区之后必须调)。 */
|
|
837
|
+
var refreshMirrorSlugs = props.refreshMirrorSlugs;
|
|
838
|
+
/** 解绑后把所有会话退回「本地」。 */
|
|
839
|
+
var clearRemote = props.clearRemote;
|
|
362
840
|
|
|
363
841
|
var statePair = react.useState({
|
|
364
842
|
tab: 'local',
|
|
@@ -575,6 +1053,10 @@ window.__ModuleLoader__.load({
|
|
|
575
1053
|
// 不作废的话用户接下来看到的仍然是配对前那份手机模型列表 ——
|
|
576
1054
|
// 就是用户报的「模型列表还是没同步」。
|
|
577
1055
|
reloadModelCatalog();
|
|
1056
|
+
// ⚠️ **镜像工作区 slug 集合也要重拉**:换一台电脑后 `workspaceMap`
|
|
1057
|
+
// 是另一份(slug 由电脑路径哈希得来,同名不同机 slug 不同)。
|
|
1058
|
+
// 不重拉的话,新电脑的镜像工作区会被判成「本地」—— 又是那个 bug。
|
|
1059
|
+
if (refreshMirrorSlugs !== undefined) refreshMirrorSlugs();
|
|
578
1060
|
},
|
|
579
1061
|
function (reason) {
|
|
580
1062
|
if (!aliveRef.current) return;
|
|
@@ -608,6 +1090,11 @@ window.__ModuleLoader__.load({
|
|
|
608
1090
|
});
|
|
609
1091
|
// 解绑后旧凭据作废,缓存里的目录不该再露出来。
|
|
610
1092
|
clearDesktopDirCache();
|
|
1093
|
+
// ⚠️ **同时清掉「电脑会话」标记**:解绑后电脑已经不在了,
|
|
1094
|
+
// 那些会话退回普通本地会话(宿主 `unpair` 也清了
|
|
1095
|
+
// `remoteSessions`)。留着标记的话,模型菜单会一直显示
|
|
1096
|
+
// 电脑的模型,而消息其实发给了手机本地。
|
|
1097
|
+
if (clearRemote !== undefined) clearRemote();
|
|
611
1098
|
patch({ path: null, listing: null });
|
|
612
1099
|
},
|
|
613
1100
|
function (reason) {
|
|
@@ -872,6 +1359,13 @@ window.__ModuleLoader__.load({
|
|
|
872
1359
|
*/
|
|
873
1360
|
var settleLocal = function (result) {
|
|
874
1361
|
if (!result || typeof result.sessionId !== 'string') return Promise.resolve();
|
|
1362
|
+
// ⚠️ **先标记「这是电脑的」再登记**:列表行的 `cwd` 要等宿主那一行
|
|
1363
|
+
// 回来才带上,之前那几帧判据读不到 cwd,靠这个标记兜住。
|
|
1364
|
+
if (markRemote !== undefined) markRemote(result.sessionId);
|
|
1365
|
+
// ⚠️ **重拉镜像工作区 slug 集合**:宿主刚为这个电脑目录建好(或复用)
|
|
1366
|
+
// 镜像工作区,`workspaceMap` 可能**刚多出一条**。前端那份集合是快照,
|
|
1367
|
+
// 不重拉的话这个工作区在侧边栏里点进去仍被判成「本地」。
|
|
1368
|
+
if (refreshMirrorSlugs !== undefined) refreshMirrorSlugs();
|
|
875
1369
|
var local = registerLocal === undefined
|
|
876
1370
|
? Promise.resolve()
|
|
877
1371
|
: Promise.resolve(registerLocal(result.sessionId, result.cwd)).then(null, function () {
|
|
@@ -1303,8 +1797,44 @@ window.__ModuleLoader__.load({
|
|
|
1303
1797
|
* `ctx.get` 拿不到就返回 `undefined`,组件里会跳过这一步。
|
|
1304
1798
|
*/
|
|
1305
1799
|
modelDirectories: ctx.get === undefined ? undefined : ctx.get('modelDirectories'),
|
|
1800
|
+
/**
|
|
1801
|
+
* 把一个会话标记成「跑在电脑上」。
|
|
1802
|
+
*
|
|
1803
|
+
* 建完远程会话**当场**登记,用户不必等下一次全量拉取就能看到
|
|
1804
|
+
* 正确的模型列表(见 `remoteSessionIds` 的注释)。
|
|
1805
|
+
*
|
|
1806
|
+
* @param sessionId - 会话 id。
|
|
1807
|
+
*/
|
|
1808
|
+
markRemote: (sessionId) => {
|
|
1809
|
+
if (typeof sessionId === 'string' && sessionId !== '') remoteSessionIds[sessionId] = true;
|
|
1810
|
+
},
|
|
1811
|
+
/**
|
|
1812
|
+
* 重拉一次「电脑镜像工作区 slug 集合」。
|
|
1813
|
+
*
|
|
1814
|
+
* ⚠️ **必须在「刚给某个电脑目录建好工作区」之后调**:宿主
|
|
1815
|
+
* `ensureDesktopWorkspace()` 每碰到一个新的电脑目录就往 `workspaceMap`
|
|
1816
|
+
* 里加一条 slug,而前端这份集合是**快照**。不重拉的话,新加的镜像工作区
|
|
1817
|
+
* 在侧边栏里点进去仍会被判成「本地」—— 正是用户报的那个 bug。
|
|
1818
|
+
*/
|
|
1819
|
+
refreshMirrorSlugs: () => loadMirrorSlugs(),
|
|
1820
|
+
/** 解绑后把所有会话退回「本地」(电脑已经不在了)。 */
|
|
1821
|
+
clearRemote: () => {
|
|
1822
|
+
remoteSessionIds = {};
|
|
1823
|
+
mirrorSlugs = {};
|
|
1824
|
+
},
|
|
1306
1825
|
});
|
|
1307
1826
|
|
|
1827
|
+
// 拉一次全量远程会话清单(重启 App 后已有会话也要能正确识别)。
|
|
1828
|
+
loadRemoteSessionIds();
|
|
1829
|
+
// 拉一次镜像工作区 slug 集合(隔离的**真正判据**,见 `mirrorSlugs`)。
|
|
1830
|
+
loadMirrorSlugs();
|
|
1831
|
+
|
|
1832
|
+
// 把「按工作区筛模型目录」接到官方服务上。
|
|
1833
|
+
ctx.effect(
|
|
1834
|
+
() => patchModelDirectories(ctx, makeCwdOf(ctx)),
|
|
1835
|
+
'dsh-remote-mobile: workspace-scoped model catalog',
|
|
1836
|
+
);
|
|
1837
|
+
|
|
1308
1838
|
// 负 priority:single slot 取 entries[0],entries 按 priority 升序 ⇒ 最先渲染。
|
|
1309
1839
|
const register = (name) =>
|
|
1310
1840
|
ctx.slots.register(
|
package/lib/index.js
CHANGED
|
@@ -687,16 +687,100 @@ export function apply(ctx) {
|
|
|
687
687
|
*
|
|
688
688
|
* 配对后立刻拉一次,之后前端一打开模型菜单就有内容,不用等一轮往返。
|
|
689
689
|
*
|
|
690
|
-
* ⚠️ **`directory`
|
|
690
|
+
* ⚠️ **`directory` 缓存「根」那一层**(`listDirectory` 不给 path 时返回的
|
|
691
691
|
* home),因为「点开电脑页签」是每次都逃不掉的第一步,而它现在必然要过一轮
|
|
692
692
|
* 隧道往返 —— 用户看到的就是那个转圈(原话:「还是有一个加载过程,
|
|
693
693
|
* 不能预加载吗」)。
|
|
694
694
|
*
|
|
695
|
+
* ⚠️ **`dirs` 是「用户正在看的那一层」的下一层**(`电脑路径 → listing`)。
|
|
696
|
+
* 只缓存根层还不够:用户报障原文「你现在好像只缓存了顶级目录,打开某个
|
|
697
|
+
* 文件夹还是要加载」—— 点进任何一个子目录仍要过一次隧道往返(实测
|
|
698
|
+
* 700~1200ms)。而用户看到列表到点击之间的那一两秒,完全够把这一层的
|
|
699
|
+
* 子目录拉回来(见 `prefetchDirs`)。
|
|
700
|
+
*
|
|
695
701
|
* **整棵目录树不进缓存**:电脑上几万个目录,全拉一遍既慢又没意义,而且
|
|
696
|
-
*
|
|
697
|
-
*
|
|
702
|
+
* 会大面积显示过期内容。所以 `dirs` 只装「用户实际看过的那几层的下一层」,
|
|
703
|
+
* 上限见 `DESKTOP_DIRS_MAX`。过期窗口由「刷新」按钮关闭。
|
|
704
|
+
*/
|
|
705
|
+
const desktopCache = { catalog: null, presets: null, directory: null, dirs: {}, at: 0 };
|
|
706
|
+
|
|
707
|
+
/** `desktopCache.dirs` 的容量上限(按插入顺序淘汰最旧的那些)。 */
|
|
708
|
+
const DESKTOP_DIRS_MAX = 200;
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* 把用户**正在看的这一层**的子目录拉回手机本地。
|
|
712
|
+
*
|
|
713
|
+
* ## 为什么要有这个(用户报障原文)
|
|
714
|
+
*
|
|
715
|
+
* 「你现在好像只缓存了顶级目录,打开某个文件夹还是要加载」—— 根层命中缓存后
|
|
716
|
+
* 不再打隧道了,可点进任何一层子目录仍然要过一趟 Cloudflare,实测
|
|
717
|
+
* 700~1200ms,用户看到的就是那个转圈。
|
|
718
|
+
*
|
|
719
|
+
* 而**用户看列表、再决定点哪个文件夹,中间至少有一两秒**。这段时间完全够
|
|
720
|
+
* 把这一层的子目录并发拉回来 —— 用户点下去时就已经在本地了。
|
|
721
|
+
*
|
|
722
|
+
* ## 只预取「一层」而不是整棵树
|
|
723
|
+
*
|
|
724
|
+
* 电脑上一个项目根目录动辄几十上百个子目录,整棵树递归下去是几千次往返,
|
|
725
|
+
* 既慢又会大面积显示过期内容。**只拉下一层**:用户真正会点的就是这一层里
|
|
726
|
+
* 的那几个,而下下一层等他进了这一层再预取(`listDesktop` 每次都会触发)。
|
|
727
|
+
*
|
|
728
|
+
* ## 并发上限
|
|
729
|
+
*
|
|
730
|
+
* 隧道是单个 Cloudflare 连接,一次打几十个并发没有意义还会互相挤。所以
|
|
731
|
+
* 分批(每批 `DESKTOP_PREFETCH_BATCH` 个)顺序推,**不 await**:这是后台
|
|
732
|
+
* 动作,调用方(`listDesktop`)要立刻把当前层回给前端。
|
|
733
|
+
*
|
|
734
|
+
* @param listing - 用户当前看到的那一层(`listDirectory` 的产物)。
|
|
698
735
|
*/
|
|
699
|
-
const
|
|
736
|
+
const prefetchDirs = (listing) => {
|
|
737
|
+
if (config.token === EMPTY_TOKEN) return;
|
|
738
|
+
const dirs = listing?.entries?.filter?.((entry) => entry?.isDirectory === true) ?? [];
|
|
739
|
+
if (dirs.length === 0) return;
|
|
740
|
+
// ⚠️ **已经缓存过的不再拉**:来回翻页时不能把同一批目录重复请求一遍。
|
|
741
|
+
const todo = dirs
|
|
742
|
+
.map((entry) => entry.path)
|
|
743
|
+
.filter((path) => typeof path === 'string' && path !== '' && desktopCache.dirs[path] === undefined);
|
|
744
|
+
if (todo.length === 0) return;
|
|
745
|
+
void (async () => {
|
|
746
|
+
for (let at = 0; at < todo.length; at += DESKTOP_PREFETCH_BATCH) {
|
|
747
|
+
const batch = todo.slice(at, at + DESKTOP_PREFETCH_BATCH);
|
|
748
|
+
await Promise.all(
|
|
749
|
+
batch.map(async (path) => {
|
|
750
|
+
// 期间用户可能已经点「刷新」清过缓存,那就别再写回去了。
|
|
751
|
+
if (desktopCache.dirs[path] !== undefined) return;
|
|
752
|
+
try {
|
|
753
|
+
const child = await callRemote(config, 'listDirectory', { path });
|
|
754
|
+
rememberDir(path, child);
|
|
755
|
+
} catch {
|
|
756
|
+
/* 单个目录拉不到(被删 / 无权限)不影响其它目录,静默跳过 */
|
|
757
|
+
}
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
})();
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
/** 每批并发数(见 `prefetchDirs` 的注释)。 */
|
|
765
|
+
const DESKTOP_PREFETCH_BATCH = 6;
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* 写一格目录缓存,超出上限就按插入顺序淘汰最旧的。
|
|
769
|
+
*
|
|
770
|
+
* ⚠️ 淘汰必须用 `Object.keys` 的**插入顺序**:JS 对象的字符串键就是这个顺序,
|
|
771
|
+
* 而 `desktopCache.dirs` 正好是按用户浏览顺序写进去的 —— 最旧的键就是最早
|
|
772
|
+
* 看过的那些层,淘汰它们对用户体感最没影响。
|
|
773
|
+
*
|
|
774
|
+
* @param path - 电脑上的绝对路径。
|
|
775
|
+
* @param listing - 该层的列表结果。
|
|
776
|
+
*/
|
|
777
|
+
function rememberDir(path, listing) {
|
|
778
|
+
desktopCache.dirs[path] = listing;
|
|
779
|
+
const keys = Object.keys(desktopCache.dirs);
|
|
780
|
+
for (let at = 0; at + DESKTOP_DIRS_MAX < keys.length; at += 1) {
|
|
781
|
+
delete desktopCache.dirs[keys[at]];
|
|
782
|
+
}
|
|
783
|
+
}
|
|
700
784
|
|
|
701
785
|
/**
|
|
702
786
|
* 静默预拉电脑的模型目录与预设列表。
|
|
@@ -770,21 +854,32 @@ export function apply(ctx) {
|
|
|
770
854
|
* 官方前端的模型目录是**全局唯一一份**(`ModelCatalogDirectory` 由
|
|
771
855
|
* `ModelDirectoryResolver` 持有,所有会话共享,见
|
|
772
856
|
* `dsh-client-ui-model-selection/lib/client.js:340`),请求本身**不带 sessionId**。
|
|
773
|
-
*
|
|
774
|
-
*
|
|
857
|
+
* 所以「远程会话看电脑的、本地会话看手机的」这种按会话切换,在**这一层**
|
|
858
|
+
* 表达不出来。
|
|
775
859
|
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
860
|
+
* 折中:这里把两份**并起来**(电脑的排前面,同 id 的 provider 以电脑为准),
|
|
861
|
+
* 由前端按「当前会话的工作区是不是电脑镜像目录」再筛一次
|
|
862
|
+
* (见 client.js 的 `filterCatalogForWorkspace`)。合并时必须给电脑那几组
|
|
863
|
+
* 打上 `desktop: true`,否则前端分不出哪些是电脑的 —— 用户需求原文:
|
|
864
|
+
* 「用户如果当前选择的是 app 本地文件夹,那么就不要出现电脑的模型,
|
|
865
|
+
* 以这个文件夹为隔离条件,互不干预」。
|
|
779
866
|
*
|
|
780
|
-
*
|
|
867
|
+
* ⚠️ **`localGroups` / `localFailures` 必须一起带上**。因为同 id 的 provider
|
|
868
|
+
* 以电脑为准 ⇒ 本地那一组被**丢掉**了;而本地会话的 `current` 很可能正指向
|
|
869
|
+
* 它(手机本地的 `deepseek-official` 与电脑的同名)。前端要「本地工作区只显示
|
|
870
|
+
* 本地模型」,就得能把被覆盖的那组**还原**回来 —— 只靠 `groups` 做不到,
|
|
871
|
+
* 那组已经不在里面了。这两个字段是**本插件加的**,官方 `syncInputs` 只读
|
|
872
|
+
* `groups` / `failures` / `default`,多带两个不会影响渲染。
|
|
873
|
+
*
|
|
874
|
+
* ⚠️ `desktop` 同理是**我们加的非官方字段**。前端渲染只读 `id` / `name` /
|
|
875
|
+
* `models`(见 `optionsOf`),多一个字段无害。
|
|
781
876
|
*
|
|
782
877
|
* @param local - 手机本地的模型目录。
|
|
783
878
|
* @param desktop - 电脑的模型目录(`buildModelCatalog` 的产物)。
|
|
784
|
-
* @returns
|
|
879
|
+
* @returns 合并后的目录(额外带 `localGroups` / `localFailures`)。
|
|
785
880
|
*/
|
|
786
881
|
function mergeCatalog(local, desktop) {
|
|
787
|
-
const groups =
|
|
882
|
+
const groups = (desktop?.groups ?? []).map((group) => ({ ...group, desktop: true }));
|
|
788
883
|
const seen = new Set(groups.map((group) => group.id));
|
|
789
884
|
for (const group of local?.groups ?? []) {
|
|
790
885
|
if (!seen.has(group.id)) groups.push(group);
|
|
@@ -792,7 +887,14 @@ export function apply(ctx) {
|
|
|
792
887
|
return {
|
|
793
888
|
...local,
|
|
794
889
|
groups,
|
|
795
|
-
|
|
890
|
+
// failures 同样打标记:本地工作区里不该看到电脑 provider 的加载失败。
|
|
891
|
+
failures: [
|
|
892
|
+
...(desktop?.failures ?? []).map((failure) => ({ ...failure, desktop: true })),
|
|
893
|
+
...(local?.failures ?? []),
|
|
894
|
+
],
|
|
895
|
+
// ⚠️ 前端「本地工作区只显示本地模型」的还原依据(见上面注释)。
|
|
896
|
+
localGroups: local?.groups ?? [],
|
|
897
|
+
localFailures: local?.failures ?? [],
|
|
796
898
|
routableProviders: [
|
|
797
899
|
...new Set([...(local?.routableProviders ?? []), ...(desktop?.routableProviders ?? [])]),
|
|
798
900
|
],
|
|
@@ -1429,6 +1531,10 @@ export function apply(ctx) {
|
|
|
1429
1531
|
// 模型/预设的静默缓存:配对后预拉、刷新按钮回写(见 desktopCache)。
|
|
1430
1532
|
desktopCache,
|
|
1431
1533
|
prefetch: prefetchDesktop,
|
|
1534
|
+
// ⚠️ 同 `workspaceMap`:`handleBridge` 是模块级函数,闭包里的
|
|
1535
|
+
// `prefetchDirs` / `rememberDir` 它看不见,必须显式传。
|
|
1536
|
+
prefetchDirs,
|
|
1537
|
+
rememberDir,
|
|
1432
1538
|
body,
|
|
1433
1539
|
});
|
|
1434
1540
|
reply(200, { ok: true, result });
|
|
@@ -1614,19 +1720,34 @@ async function handleBridge(ctx, config, io) {
|
|
|
1614
1720
|
/**
|
|
1615
1721
|
* 列电脑上的目录。
|
|
1616
1722
|
*
|
|
1617
|
-
* ⚠️
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1723
|
+
* ⚠️ **命中缓存就直接回,不打隧道**。两级缓存:
|
|
1724
|
+
*
|
|
1725
|
+
* ① 根层(`desktopCache.directory`)—— 用户点开「电脑」页签时看的正是
|
|
1726
|
+
* 这一层,每一轮往返都要穿过 Cloudflare 隧道,那就是用户抱怨的
|
|
1727
|
+
* 「还是有一个加载过程」(原话:「目录树都提前存档手机本地了,
|
|
1728
|
+
* 为什么还是还加载呢,不能预加载吗」)。
|
|
1729
|
+
* ② 子层(`desktopCache.dirs`)—— 用户报障原文「你现在好像只缓存了
|
|
1730
|
+
* 顶级目录,打开某个文件夹还是要加载」。
|
|
1621
1731
|
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1732
|
+
* 两级都只在**拉成功之后**写回,并且**每次返回前都顺手预取「下一层」**
|
|
1733
|
+
* (见 `prefetchDirs`):用户看列表、决定点哪个,这一两秒足够把下一层
|
|
1734
|
+
* 拉回来,点下去时就已经在本地了。
|
|
1624
1735
|
*/
|
|
1625
1736
|
case 'listDesktop': {
|
|
1626
1737
|
const wantRoot = params.path === undefined || params.path === null || params.path === '';
|
|
1627
|
-
|
|
1738
|
+
const path = wantRoot ? '' : params.path;
|
|
1739
|
+
const cached = wantRoot ? io.desktopCache?.directory : io.desktopCache?.dirs?.[path];
|
|
1740
|
+
if (cached != null) {
|
|
1741
|
+
// 命中也要预取下一层:用户还没离开这一屏。
|
|
1742
|
+
io.prefetchDirs?.(cached);
|
|
1743
|
+
return cached;
|
|
1744
|
+
}
|
|
1628
1745
|
const listing = await callRemote(config, 'listDirectory', { path: params.path });
|
|
1629
|
-
if (
|
|
1746
|
+
if (io.desktopCache !== undefined) {
|
|
1747
|
+
if (wantRoot) io.desktopCache.directory = listing;
|
|
1748
|
+
else if (typeof path === 'string' && path !== '') io.rememberDir?.(path, listing);
|
|
1749
|
+
}
|
|
1750
|
+
io.prefetchDirs?.(listing);
|
|
1630
1751
|
return listing;
|
|
1631
1752
|
}
|
|
1632
1753
|
|
|
@@ -1683,6 +1804,10 @@ async function handleBridge(ctx, config, io) {
|
|
|
1683
1804
|
if (io.desktopCache !== undefined) {
|
|
1684
1805
|
if (result.models?.ok === true) io.desktopCache.catalog = result.models.value;
|
|
1685
1806
|
if (result.presets?.ok === true) io.desktopCache.presets = result.presets.value;
|
|
1807
|
+
// ⚠️ **刷新要把子层缓存整棵作废**:用户点刷新就是要看电脑现状,
|
|
1808
|
+
// 留着旧的子层,点进去看到的还是刷新前的内容(根层作废了、
|
|
1809
|
+
// 子层没作废,比两处都不作废更难解释)。
|
|
1810
|
+
io.desktopCache.dirs = {};
|
|
1686
1811
|
if (
|
|
1687
1812
|
result.directory?.ok === true &&
|
|
1688
1813
|
io.desktopCache.directory?.path === result.directory.value?.path
|
|
@@ -1690,6 +1815,8 @@ async function handleBridge(ctx, config, io) {
|
|
|
1690
1815
|
io.desktopCache.directory = result.directory.value;
|
|
1691
1816
|
}
|
|
1692
1817
|
io.desktopCache.at = Date.now();
|
|
1818
|
+
// 刷新完顺手把这一层的下一层预取回来,用户点进去就不用等了。
|
|
1819
|
+
if (result.directory?.ok === true) io.prefetchDirs?.(result.directory.value);
|
|
1693
1820
|
}
|
|
1694
1821
|
return result;
|
|
1695
1822
|
}
|
|
@@ -1775,6 +1902,47 @@ async function handleBridge(ctx, config, io) {
|
|
|
1775
1902
|
case 'isRemote':
|
|
1776
1903
|
return { remote: io.remoteSessions.has(params.sessionId) };
|
|
1777
1904
|
|
|
1905
|
+
/**
|
|
1906
|
+
* 全量远程会话清单(一次性给前端,前端拿它在**本地**做同步判定)。
|
|
1907
|
+
*
|
|
1908
|
+
* ⚠️ **为什么需要一个「全量」的桥**:前端要把模型菜单按「当前会话是不是
|
|
1909
|
+
* 电脑镜像工作区」隔离(用户需求原文:「用户如果当前选择的是 app 本地
|
|
1910
|
+
* 文件夹,那么就不要出现电脑的模型,以这个文件夹为隔离条件,互不干预」)。
|
|
1911
|
+
* 而模型菜单每渲染一帧都会读一次目录快照,**不可能每帧打一次桥** ——
|
|
1912
|
+
* 那是同步 API。所以前端在 `apply` 时拉一次全量,之后建会话/解绑时
|
|
1913
|
+
* 自己增量维护,`getSnapshot` 就纯查内存。
|
|
1914
|
+
*
|
|
1915
|
+
* 重启 App 后已有会话也能正确识别,正是靠这一条:宿主从
|
|
1916
|
+
* `sessions.json` 恢复出 `remoteSessions`,前端一次性拿走。
|
|
1917
|
+
*/
|
|
1918
|
+
case 'remoteSessions':
|
|
1919
|
+
return { ids: [...io.remoteSessions] };
|
|
1920
|
+
|
|
1921
|
+
/**
|
|
1922
|
+
* 电脑镜像工作区的 slug 集合(`workspaceMap` 的 keys)。
|
|
1923
|
+
*
|
|
1924
|
+
* ⚠️⚠️ **这是「模型列表按工作区隔离」的第二代判据,用户 1.0.19 报障后的正解。**
|
|
1925
|
+
*
|
|
1926
|
+
* 上一代用 `remoteSessions`(按 **sessionId**)当判据,漏掉了这条真实路径:
|
|
1927
|
+
* 用户在侧边栏点「新建会话」时,官方前端直接
|
|
1928
|
+
* `sessionController.create({workspaceId})` —— **不经过本插件的桥**。
|
|
1929
|
+
* 宿主的 create 拦截器认得出镜像目录(所以消息发得对),但**前端不知道**,
|
|
1930
|
+
* 于是模型菜单仍按「本地」筛,显示手机模型。
|
|
1931
|
+
*
|
|
1932
|
+
* 用户原话:「只有我手动添加工作区的时候,才会触发模型和预设的刷新」——
|
|
1933
|
+
* 一针见血:判据挂在了「走了哪条路」上,而不是「会话的工作区在哪」。
|
|
1934
|
+
*
|
|
1935
|
+
* 正解是**按 cwd 判定**,与宿主 `desktopPathOfMirror` 同一个轴:
|
|
1936
|
+
* 前端从 `sessions.list` 的会话行里**同步**读到 cwd,看它是不是
|
|
1937
|
+
* `<STATE_DIR>/workspaces/<slug>` 且 `slug` 在这里面。这条路
|
|
1938
|
+
* **与「谁建的会话」完全无关**,所以任何入口进来都一致。
|
|
1939
|
+
*
|
|
1940
|
+
* 只回 slug 列表而不是整张 `workspaceMap`:前端不需要电脑路径,
|
|
1941
|
+
* 少传一份数据也少一处可能不一致的地方。
|
|
1942
|
+
*/
|
|
1943
|
+
case 'mirrorSlugs':
|
|
1944
|
+
return { slugs: Object.keys(io.workspaceMap ?? {}) };
|
|
1945
|
+
|
|
1778
1946
|
/**
|
|
1779
1947
|
* 配对:用电脑上生成的 6 位码换回这台电脑的隧道地址与令牌。
|
|
1780
1948
|
*
|
|
@@ -1835,6 +2003,14 @@ async function handleBridge(ctx, config, io) {
|
|
|
1835
2003
|
io.follows.clear();
|
|
1836
2004
|
io.mirrorStates.clear();
|
|
1837
2005
|
io.remoteSessions.clear();
|
|
2006
|
+
// ⚠️ **子层目录缓存必须一起清**:`catalog` / `presets` / 根层目录在下次
|
|
2007
|
+
// 配对成功时会被 `prefetchDesktop` **覆盖**,不会串味;而 `dirs` 是
|
|
2008
|
+
// **累加**的(用户每浏览一层写一格),不清的话换一台电脑配对后,
|
|
2009
|
+
// 旧电脑那些子目录还会命中缓存 —— 用户点进去看到的是上一台电脑的内容。
|
|
2010
|
+
if (io.desktopCache !== undefined) {
|
|
2011
|
+
io.desktopCache.dirs = {};
|
|
2012
|
+
io.desktopCache.directory = null;
|
|
2013
|
+
}
|
|
1838
2014
|
io.persist();
|
|
1839
2015
|
ctx.logger?.info?.('[dsh-remote-mobile] 已解除配对,本地凭据与远程会话清单已清空');
|
|
1840
2016
|
return { endpoint: config.endpoint, paired: false };
|