dsh-config-manager 0.1.73 → 0.1.76

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 (49) hide show
  1. package/lib/client.d.ts +8 -4
  2. package/lib/client.js +814 -682
  3. package/lib/core/rollback-ledger.d.ts +29 -3
  4. package/lib/core/rollback-ledger.js +125 -7
  5. package/lib/core/rollback-point.d.ts +221 -3
  6. package/lib/core/rollback-point.js +790 -153
  7. package/lib/index.d.ts +1 -1
  8. package/lib/index.js +1 -1
  9. package/lib/sync/messages.d.ts +1 -0
  10. package/lib/sync/messages.js +3 -0
  11. package/lib/sync/sync-engine.js +56 -7
  12. package/lib/ui/i18n.d.ts +1 -1
  13. package/lib/ui/i18n.js +2 -2
  14. package/lib/utils/bundle-scan.d.ts +5 -3
  15. package/lib/utils/bundle-scan.js +5 -3
  16. package/lib/utils/line-diff.d.ts +7 -1
  17. package/lib/utils/line-diff.js +8 -2
  18. package/package.json +1 -1
  19. package/src/client/chat/ChatEntryView.tsx +38 -9
  20. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  21. package/src/client/chat/chat-entry-view.test.ts +164 -1
  22. package/src/client/chat/chat-entry-view.ts +127 -10
  23. package/src/client/chat/chat-node-registration.ts +40 -6
  24. package/src/client/common/DiffTable.tsx +12 -23
  25. package/src/client/common/host-primitives.ts +78 -0
  26. package/src/client/config-manager.module.css +33 -12
  27. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  28. package/src/client/index.ts +7 -1
  29. package/src/client/library/RestorePlanView.tsx +0 -1
  30. package/src/client/locales.ts +0 -2
  31. package/src/client/recovery/RollbackDiffPane.tsx +13 -6
  32. package/src/client/recovery/recovery-locales.ts +2 -4
  33. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  34. package/src/client/sync/PushPreviewCard.tsx +57 -0
  35. package/src/client/sync/SyncPanel.tsx +2 -37
  36. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  37. package/src/client/sync/sync-push-preview.test.ts +14 -0
  38. package/src/client/sync/sync-view.ts +9 -1
  39. package/src/core/rollback-ledger.ts +123 -7
  40. package/src/core/rollback-point.test.ts +592 -17
  41. package/src/core/rollback-point.ts +861 -155
  42. package/src/index.ts +1 -1
  43. package/src/sync/messages.ts +3 -0
  44. package/src/sync/sync-engine.ts +55 -7
  45. package/src/sync/sync-preview-push.test.ts +158 -0
  46. package/src/ui/i18n.ts +2 -2
  47. package/src/utils/bundle-scan.ts +5 -3
  48. package/src/utils/line-diff.test.ts +12 -0
  49. package/src/utils/line-diff.ts +9 -3
package/lib/index.d.ts CHANGED
@@ -78,7 +78,7 @@ export declare const name = "config-manager";
78
78
  /** Services required before the engine can mount (present in every profile). */
79
79
  export declare const inject: string[];
80
80
  /** Plugin version, kept in sync with package.json ("version"). */
81
- export declare const PLUGIN_VERSION = "0.1.73";
81
+ export declare const PLUGIN_VERSION = "0.1.76";
82
82
  /**
83
83
  * Star 引导弹窗指向的 GitHub 仓库(用户引导点 Star 的目标)。
84
84
  * 与 package.json 的 repository 字段保持一致;界面不可改(硬编码,参照
package/lib/index.js CHANGED
@@ -136,7 +136,7 @@ export const name = 'config-manager';
136
136
  /** Services required before the engine can mount (present in every profile). */
137
137
  export const inject = ['settings', 'credentials'];
138
138
  /** Plugin version, kept in sync with package.json ("version"). */
139
- export const PLUGIN_VERSION = '0.1.73';
139
+ export const PLUGIN_VERSION = '0.1.76';
140
140
  /** Plugin own package name — excluded from its own exported plugins list. */
141
141
  const PLUGIN_NAME = 'dsh-config-manager';
142
142
  /**
@@ -57,6 +57,7 @@ export declare const zh: {
57
57
  readonly 'sync.backupHistoryDone': '{trigger}完成:{zip}';
58
58
  readonly 'sync.backupHistorySkipped': '{trigger}跳过:{reason}';
59
59
  readonly 'sync.backupHistoryFailed': '{trigger}失败';
60
+ readonly 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示';
60
61
  };
61
62
  export declare const en: Record<keyof typeof zh, string>;
62
63
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -83,6 +83,8 @@ export const zh = {
83
83
  'sync.backupHistoryDone': '{trigger}完成:{zip}',
84
84
  'sync.backupHistorySkipped': '{trigger}跳过:{reason}',
85
85
  'sync.backupHistoryFailed': '{trigger}失败',
86
+ // ---------- 推送前只读预览(issue #91) ----------
87
+ 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示',
86
88
  };
87
89
  export const en = {
88
90
  'sync.ancestorSnapshotIdRequired': 'loadAncestor: snapshotId must not be empty',
@@ -142,6 +144,7 @@ export const en = {
142
144
  'sync.backupHistoryDone': '{trigger} finished: {zip}',
143
145
  'sync.backupHistorySkipped': '{trigger} skipped: {reason}',
144
146
  'sync.backupHistoryFailed': '{trigger} failed',
147
+ 'sync.pushPreviewUnpackedLocalSources': 'Local-source plugins (link:/file:) must be packed to compare changes exactly; section(s) {sections} are shown as changed',
145
148
  };
146
149
  registerMessageCatalog({ id: 'sync-domain', zh, en });
147
150
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -22,6 +22,8 @@ import path from 'node:path';
22
22
  import { createAdapters } from '../adapters/index.js';
23
23
  import { defaultSecretScanner } from '../core/exporter.js';
24
24
  import { Importer } from '../core/importer.js';
25
+ // issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
26
+ import { isLocalPluginSpec } from '../core/local-plugin-pack.js';
25
27
  import { isFileSection, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.js';
26
28
  import { buildManifest, CHECKSUMS_FILE, MANIFEST_FILE } from '../schema/manifest.js';
27
29
  import { CURRENT_SCHEMA_VERSION } from '../schema/versions.js';
@@ -83,6 +85,24 @@ const FORBIDDEN_SECTIONS = ['credentialsStatus', 'secrets'];
83
85
  * 保留导出是为了让既有调用方/测试仍能引用「缺省保留份数」这一事实,不再作为唯一判据。
84
86
  */
85
87
  export const MAX_REMOTE_SNAPSHOTS = DEFAULT_RETENTION_POLICY.keepLast;
88
+ /**
89
+ * issue #91:走只读 `preview()` 拿到的产物,能否直接与该分区**基线 hash**(push 时按 export() 产物记录)比对。
90
+ *
91
+ * 为什么需要这个判定:`plugins` 适配器的 `preview()` 走 `collect(…, pack=false)` —— 刻意**不做**
92
+ * 本地源(`link:` / `file:`)插件的 `npm pack`。于是当 profile 里存在本地源插件时,预览产物既没有
93
+ * `localTarballs`、插件 spec 也保持原样(export() 会改写成 `file:<tarball>`),与基线**不同口径**:
94
+ * 此时既不能断言「无变化」(本地插件源码改了体积/内容,预览看不见),也不该为了比对去打包
95
+ * (那正是本 issue 的缺陷本身)。
96
+ *
97
+ * 返回 true = 该分区本趟**不可比** ⇒ 调用方只能保守报「有变化」并如实说明原因。
98
+ * 其余分区的 preview() 产物与 export() 逐字节相同(文件类分区不走 preview(),见 previewPush)。
99
+ */
100
+ function previewCaliberDiffersFromExport(sectionId, data) {
101
+ if (sectionId !== 'plugins')
102
+ return false;
103
+ const plugins = data.plugins;
104
+ return Array.isArray(plugins) && plugins.some((p) => isLocalPluginSpec(p?.spec));
105
+ }
86
106
  export class SyncEngine {
87
107
  ctx;
88
108
  transport;
@@ -516,14 +536,29 @@ export class SyncEngine {
516
536
  const targets = this.pushTargets(opts.sections, warnings);
517
537
  const plainSections = {};
518
538
  const counts = {};
539
+ /** issue #91:本趟只读预览省掉了本地源打包 ⇒ 与基线不可比的分区(不可断言「无变化」) */
540
+ const unpackedSections = new Set();
519
541
  for (const adapter of targets) {
542
+ const exportOptions = {
543
+ includeSecrets,
544
+ // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
545
+ ...this.sessionExportOptions(adapter.id, opts.sessions),
546
+ };
520
547
  let section;
521
548
  try {
522
- section = await adapter.export(this.ctx, {
523
- includeSecrets,
524
- // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
525
- ...this.sessionExportOptions(adapter.id, opts.sessions),
526
- });
549
+ // issue #91:**非文件类**分区优先走 adapter.preview()。只读预览必须零进程副作用,而
550
+ // plugins 的 export() 会对每个 `link:`/`file:` 插件真的跑一次 `npm pack`
551
+ // (真机 2 GB 本地源插件 = 297 s;这段时间全局写锁被该请求占满 ⇒ 界面上所有写操作静默 423)。
552
+ // 为什么文件类分区**不能**走 preview():其预览产物是「无内容」形态(条目 data 为空、
553
+ // contentHash 为空串),既不能与基线 hash 比对,也不能进 assertNoForbiddenSections
554
+ // —— 那会让预览谎报「每个文件分区都变了」。
555
+ const summary = !isFileSection(adapter.id) && adapter.preview !== undefined
556
+ ? await adapter.preview(this.ctx, exportOptions)
557
+ : undefined;
558
+ section = summary?.section ?? await adapter.export(this.ctx, exportOptions);
559
+ if (summary !== undefined && previewCaliberDiffersFromExport(adapter.id, section.data)) {
560
+ unpackedSections.add(adapter.id);
561
+ }
527
562
  }
528
563
  catch (err) {
529
564
  warnings.push(this.msg('sync.sectionFailed', { adapter: adapter.id, reason: err instanceof Error ? err.message : String(err) }));
@@ -544,15 +579,29 @@ export class SyncEngine {
544
579
  if (!encrypted) {
545
580
  try {
546
581
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
547
- baselineHashes = state.sections;
582
+ // 基线条目是 SyncSectionState({hash, updatedAt}),**不是**裸 hash 字符串。
583
+ // 这里曾把整个对象 as Record<string,string> 直接比对 ⇒ 对象 !== 字符串 恒真
584
+ // ⇒ 预览对**每一个**分区都报「有变化」,「N 个有变化」这句永远是「全都有变化」。
585
+ for (const [sid, rec] of Object.entries(state.sections)) {
586
+ if (rec !== undefined && typeof rec.hash === 'string')
587
+ baselineHashes[sid] = rec.hash;
588
+ }
548
589
  }
549
590
  catch {
550
591
  baselineHashes = {};
551
592
  }
552
593
  }
594
+ if (unpackedSections.size > 0) {
595
+ // 如实说明:本次预览没有为本地源插件执行 npm pack,故这些分区的基线比对不可得
596
+ warnings.push(this.msg('sync.pushPreviewUnpackedLocalSources', { sections: [...unpackedSections].join(', ') }));
597
+ }
553
598
  const sections = Object.keys(plainSections).map((sid) => {
554
599
  const id = sid;
555
- const changed = encrypted || baselineHashes[id] === undefined || baselineHashes[id] !== hashSection(plainSections[id]);
600
+ const changed = encrypted
601
+ // issue #91:只读预览省掉了本地源打包 ⇒ 产物与基线不同口径,**绝不**据此断言「无变化」
602
+ || unpackedSections.has(id)
603
+ || baselineHashes[id] === undefined
604
+ || baselineHashes[id] !== hashSection(plainSections[id]);
556
605
  return { section: id, count: counts[id] ?? 0, changed };
557
606
  });
558
607
  let remoteSnapshotCount = 0;
package/lib/ui/i18n.d.ts CHANGED
@@ -92,7 +92,7 @@ export declare const uiZh: {
92
92
  readonly 'error.downloadFailed': '下载失败:HTTP {status}';
93
93
  readonly 'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态';
94
94
  readonly 'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试';
95
- readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态';
95
+ readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试';
96
96
  readonly 'error.settingsConflict.title': '配置已被并发修改';
97
97
  readonly 'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。';
98
98
  readonly 'error.notConfirmed.title': '导入未确认';
package/lib/ui/i18n.js CHANGED
@@ -99,7 +99,7 @@ export const uiZh = {
99
99
  'error.downloadFailed': '下载失败:HTTP {status}',
100
100
  'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态',
101
101
  'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试',
102
- 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态',
102
+ 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试',
103
103
  'error.settingsConflict.title': '配置已被并发修改',
104
104
  'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。',
105
105
  'error.notConfirmed.title': '导入未确认',
@@ -662,7 +662,7 @@ export const uiEn = {
662
662
  'error.downloadFailed': 'Download failed: HTTP {status}',
663
663
  'error.exportTimeout': 'Export timed out ({minutes} min): the export did not complete, please retry; if it keeps timing out, check the DSH state',
664
664
  'error.syncTimeout': 'Sync request timed out ({minutes} min): check network and repository reachability, then retry',
665
- 'error.recoveryTimeout': 'Recovery request timed out ({minutes} min): the recovery did not complete, please retry; if it keeps timing out, check the DSH state',
665
+ 'error.recoveryTimeout': 'Recovery request timed out ({minutes} min): it may still be running in the background — refresh the preview to confirm, then retry if it did not finish',
666
666
  'error.settingsConflict.title': 'Configuration changed concurrently',
667
667
  'error.settingsConflict.action': 'This configuration on the target DSH was modified by another process during import. To avoid overwriting it, re-export or review it manually, then retry.',
668
668
  'error.notConfirmed.title': 'Import not confirmed',
@@ -31,9 +31,11 @@
31
31
  * DSH shell 把 UI 基础库作为 **static module seed** 预置在模块表里(`dsh-web-frontend` 的
32
32
  * `staticModules`:react / react-dom / cordis / client-store / ui-slots / ui-primitives / dockkit),
33
33
  * 插件 bundle 工厂拿到的 `require` 命中 seed 即可用 —— 第三方插件 dsh-better-sidebar 同样如此。
34
- * 本插件只用它画**文件类型图标**(DSH 文件树同一颗 `FileTypeIcon`),且**不是顶层 require**:
35
- * 调用点在 `src/client/chat/RollbackFileIcon.tsx` 的 `dshFileTypeIcon()` 里,外裹 try/catch ——
36
- * 老宿主没有这条 seed 时回落本地极简图标,绝不会因「一颗图标」打挂整个浏览器半。
34
+ * 本插件用它做两件事:**文件类型图标**(DSH 文件树同一颗 `FileTypeIcon`)与**用户正文投影**
35
+ * (`projectUserText` —— 会话引用/技能渲染成 chip,G-39 差距①②,2026-10-15)。
36
+ * **都不是顶层 require**:唯一入口是 `src/client/common/host-primitives.ts` 的帧内惰取门
37
+ * (三态缓存 + try/catch,每个进程只试一次)—— 老宿主没有这条 seed 时回落本地实现
38
+ * (极简图标 / 纯文本投影 / 可见的底色兜底),绝不会因一颗图标或一段正文打挂整个浏览器半。
37
39
  */
38
40
  export declare const CLIENT_BUNDLE_RUNTIME_WHITELIST: readonly string[];
39
41
  /**
@@ -31,9 +31,11 @@
31
31
  * DSH shell 把 UI 基础库作为 **static module seed** 预置在模块表里(`dsh-web-frontend` 的
32
32
  * `staticModules`:react / react-dom / cordis / client-store / ui-slots / ui-primitives / dockkit),
33
33
  * 插件 bundle 工厂拿到的 `require` 命中 seed 即可用 —— 第三方插件 dsh-better-sidebar 同样如此。
34
- * 本插件只用它画**文件类型图标**(DSH 文件树同一颗 `FileTypeIcon`),且**不是顶层 require**:
35
- * 调用点在 `src/client/chat/RollbackFileIcon.tsx` 的 `dshFileTypeIcon()` 里,外裹 try/catch ——
36
- * 老宿主没有这条 seed 时回落本地极简图标,绝不会因「一颗图标」打挂整个浏览器半。
34
+ * 本插件用它做两件事:**文件类型图标**(DSH 文件树同一颗 `FileTypeIcon`)与**用户正文投影**
35
+ * (`projectUserText` —— 会话引用/技能渲染成 chip,G-39 差距①②,2026-10-15)。
36
+ * **都不是顶层 require**:唯一入口是 `src/client/common/host-primitives.ts` 的帧内惰取门
37
+ * (三态缓存 + try/catch,每个进程只试一次)—— 老宿主没有这条 seed 时回落本地实现
38
+ * (极简图标 / 纯文本投影 / 可见的底色兜底),绝不会因一颗图标或一段正文打挂整个浏览器半。
37
39
  */
38
40
  export const CLIENT_BUNDLE_RUNTIME_WHITELIST = [
39
41
  'react',
@@ -11,6 +11,12 @@
11
11
  * (maxLines / maxD)时**降级为整块替换**(全删 + 全增),保证任何输入都有确定
12
12
  * 耗时的输出 —— 大文件不会卡死宿主。
13
13
  *
14
+ * 预算怎么定(2026-10-15 真机实测,本仓 rollback-point.ts 的一对版本):
15
+ * · 时间是 (N+M)·D 量级:D=4000、两侧合计 ~7500 行时 ≈ 70 ms(D=8000 ≈ 115 ms);
16
+ * · 内存是 O(D²)·4B(回溯要逐轮留 V 快照):D=2000 时 RSS +~30 MB、D=4000 +~100 MB、D=8000 +~175 MB。
17
+ * ⇒ 缺省取 2000(覆盖「大文件只改了几处」这一大类);单文件按需查看的调用方可以往上调,
18
+ * 到 4000 仍是一次点击可接受的开销;再大就该换线性空间算法,而不是继续加预算。
19
+ *
14
20
  * 行为约定:
15
21
  * - 行尾 \r\n 归一为 \n;文本末尾的换行不产生"空行"(行数 = 换行符语义)。
16
22
  * - 仅空白差异同样是差异(不做 ignoreWhitespace)。
@@ -48,7 +54,7 @@ export interface DiffOptions {
48
54
  context?: number;
49
55
  /** 参与 Myers 的变更区行数上限(两侧合计;超出 → 降级整块替换) */
50
56
  maxLines?: number;
51
- /** Myers 最大编辑距离 D(超出 → 降级整块替换) */
57
+ /** Myers 最大编辑距离 D(超出 → 降级整块替换);越大越接近最小编辑脚本,内存随 D² 增长(见文件头) */
52
58
  maxD?: number;
53
59
  }
54
60
  /** 文本 → 行数组(CRLF 归一、尾换行不产生空行)。 */
@@ -11,14 +11,20 @@
11
11
  * (maxLines / maxD)时**降级为整块替换**(全删 + 全增),保证任何输入都有确定
12
12
  * 耗时的输出 —— 大文件不会卡死宿主。
13
13
  *
14
+ * 预算怎么定(2026-10-15 真机实测,本仓 rollback-point.ts 的一对版本):
15
+ * · 时间是 (N+M)·D 量级:D=4000、两侧合计 ~7500 行时 ≈ 70 ms(D=8000 ≈ 115 ms);
16
+ * · 内存是 O(D²)·4B(回溯要逐轮留 V 快照):D=2000 时 RSS +~30 MB、D=4000 +~100 MB、D=8000 +~175 MB。
17
+ * ⇒ 缺省取 2000(覆盖「大文件只改了几处」这一大类);单文件按需查看的调用方可以往上调,
18
+ * 到 4000 仍是一次点击可接受的开销;再大就该换线性空间算法,而不是继续加预算。
19
+ *
14
20
  * 行为约定:
15
21
  * - 行尾 \r\n 归一为 \n;文本末尾的换行不产生"空行"(行数 = 换行符语义)。
16
22
  * - 仅空白差异同样是差异(不做 ignoreWhitespace)。
17
23
  * - 完全一致时 hunks 为空、identical=true。
18
24
  */
19
25
  const DEFAULT_CONTEXT = 3;
20
- const DEFAULT_MAX_LINES = 6000;
21
- const DEFAULT_MAX_D = 400;
26
+ const DEFAULT_MAX_LINES = 20000;
27
+ const DEFAULT_MAX_D = 2000;
22
28
  /** 文本 → 行数组(CRLF 归一、尾换行不产生空行)。 */
23
29
  export function splitLines(text) {
24
30
  if (text === '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-config-manager",
3
- "version": "0.1.73",
3
+ "version": "0.1.76",
4
4
  "license": "MIT",
5
5
  "description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
6
6
  "icon": "./icon.svg",
@@ -30,6 +30,7 @@ import {
30
30
  buildChatEntrySafeRow,
31
31
  buildChatEntryView,
32
32
  chatEntryButtonState,
33
+ projectChatEntryTextWithHost,
33
34
  chatEntryTurnPhase,
34
35
  planChatEntryAttachments,
35
36
  lookupPointForTurn,
@@ -41,6 +42,7 @@ import {
41
42
  type ChatPointLookup,
42
43
  } from './chat-entry-view.ts'
43
44
  import { ChatPointWindowController, type ChatPointWindowState } from './chat-point-window.ts'
45
+ import { hostFileTypeIcon, hostUserTextProjector } from '../common/host-primitives.ts'
44
46
  import type { ArchiveOutcome } from './rollback-entry-view.ts'
45
47
  import type { ChildOpenState } from './rollback-entry-view.ts'
46
48
  import type { RollbackFileIconRenderer } from './RollbackFileIcon.tsx'
@@ -93,11 +95,21 @@ export interface ChatEntryViewInjected {
93
95
  */
94
96
  export type ChatEntryViewProps = PropsRuntime<'conversation.chat.node', 'user'> & PropsLocale<'config-manager-chat'> & ChatEntryViewInjected
95
97
 
96
- /** 文件卡:文件名 + 扩展名徽标 + 体积(扩展名徽标 = 类型图标的诚实兜底,见能力对照表)。 */
98
+ /**
99
+ * 文件卡:类型图标 + 文件名 + 体积。
100
+ *
101
+ * 类型图标**优先用宿主自己的** `FileTypeIcon`(ui-primitives,帧内惰取 —— 见 common/host-primitives.ts);
102
+ * 拿不到(老宿主)才回落我们自己的**扩展名徽标**(口径与宿主 fileExtension 一致,见 fileExtensionOf)。
103
+ * 2026-10-15:此前一律走徽标,是因为「ui-primitives 包私有不可导入」只对**构建期 import** 成立 ——
104
+ * 运行期 require 是生态通行做法(dsh-reedit / dsh-better-sidebar / 本仓 RollbackFileIcon 都这么做)。
105
+ */
97
106
  function FileCard({ file, size }: { file: ChatEntryFile; size: string | null }) {
107
+ const DshIcon = hostFileTypeIcon()
98
108
  return (
99
109
  <span className={css.chatFileCard} title={file.name !== '' ? file.name : undefined}>
100
- {file.ext !== null && <span className={css.chatFileExt}>{file.ext}</span>}
110
+ {DshIcon !== null && file.name !== ''
111
+ ? <DshIcon path={file.name} size={14} />
112
+ : file.ext !== null && <span className={css.chatFileExt}>{file.ext}</span>}
101
113
  <span className={css.chatFileName}>{file.name !== '' ? file.name : '?'}</span>
102
114
  {size !== null && <span className={css.chatFileMeta}>{size}</span>}
103
115
  </span>
@@ -145,6 +157,9 @@ function ChatEntryCopyAction({ text, t }: { text: string; t: TranslateNS<'config
145
157
  interface ChatEntryBodyProps extends ChatEntryViewInjected {
146
158
  node: unknown
147
159
  sessionId: string
160
+ /** 宿主的文件 / 技能打开器(给宿主正文投影的 chip 用;形态不符时投影自己会当没有)。 */
161
+ openFile?: unknown
162
+ openSkill?: unknown
148
163
  /** 宿主图片渲染位(SlotMap 的 keyProps 把返回值声明成 unknown ⇒ 用它当 `MessageImagesRenderer` 必须过一层窄化)。 */
149
164
  renderMessageImages?: unknown
150
165
  t: TranslateNS<'config-manager-chat'>
@@ -159,11 +174,12 @@ interface ChatEntryBodyProps extends ChatEntryViewInjected {
159
174
  * (`projectChatEntryText`),且 `data.referenceLabels` 非空时渲染**宿主的引用汇总行**
160
175
  * (见下方 `css.chatEntryReference` + `t('chat.entry.referenceSummary')` 那段,文案与宿主
161
176
  * `引用会话 · {labels}` 逐字同义)。**仍有的差距**只有 chip 的**外观与可点开**(已追认)。
162
- * · **技能(skillNames)**:**仍不渲染** —— 宿主交给 ui-primitives 的 `projectUserText` 在正文内部
163
- * 把它渲染成 chip(包私有、不可导入)⇒ 我们不为技能产出任何界面元素(不编造内容)。
164
- * · 文件类型图标:宿主用 ui-primitives 的 FileTypeIcon(包私有)⇒ 我们用**扩展名徽标**兜底;
165
- * · 气泡底色:宿主直接用 `--dsw-specific-bubble`,本仓库 token 层没有这一档 ⇒ 用 `--cm-surface-2`
166
- * (亮/暗主题下都是「比底更强的面」),不改 DESIGN 体系、不新增 token。
177
+ * · **技能(skillNames)与引用 chip**:2026-10-15 起正文**优先交给宿主自己的** `projectUserText`
178
+ * (ui-primitives 帧内惰取,见 common/host-primitives.ts)——技能与引用渲染成 chip,`openFile` /
179
+ * `openSkill` 可用时还能点开;拿不到原语(老宿主)就回落我们的纯文本投影,**不编造内容、不留空白**。
180
+ * · 文件类型图标:同样优先宿主的 `FileTypeIcon`,拿不到才回落到**扩展名徽标**兜底;
181
+ * · 气泡底色:`--cm-chat-bubble`(= 宿主 `--dsw-specific-bubble`,2026-10-15 之前误用 `--cm-surface-2`
182
+ * ⇒ 浅色主题下与页面同色、气泡看不见;详见 DESIGN.md §15b 与 css 里那两条定义行的注释)。
167
183
  */
168
184
  /**
169
185
  * 订阅入口可见性信号(issue #88)。
@@ -177,7 +193,7 @@ function useRollbackEntryEnabled(): boolean {
177
193
  return value
178
194
  }
179
195
 
180
- function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
196
+ function ChatEntryBody({ node, sessionId, openFile, openSkill, renderMessageImages, t, rt, port, openChildSession, archiveParentSession, startFreshSession, openDiff, fileIcon }: ChatEntryBodyProps) {
181
197
  const view = useMemo(() => buildChatEntryView(node), [node])
182
198
  const [lookup, setLookup] = useState<ChatPointLookup | null>(null)
183
199
  /** 有界重试窗口是否还开着(**状态诚实**的唯一开关,R1)—— 由状态机的 `onState` 驱动。 */
@@ -254,6 +270,17 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
254
270
  : null)
255
271
  : <FileCard key={'file:' + String(item.index)} file={item.file} size={item.size} />)
256
272
  const hasAttachments = attachmentNodes.some((node) => node !== null)
273
+ /**
274
+ * 正文:**优先宿主自己的** `projectUserText`(会话引用 / 技能渲染成 chip,给了打开器就能点开),
275
+ * 拿不到就回落我们自己的纯文本投影(`bubble.text`,线格式也已换成 label)。
276
+ * 四条回落判据在纯层(`projectChatEntryTextWithHost`);投影器**帧内惰取**(老宿主没有原语包 → null)。
277
+ */
278
+ const hostText = projectChatEntryTextWithHost(hostUserTextProjector(), bubble.rawText, {
279
+ sessionLabels: view.referenceLabels,
280
+ skillNames: view.skillNames,
281
+ openFile,
282
+ openSkill,
283
+ })
257
284
  const reasonKey = button.kind === 'disabled' ? button.key : null
258
285
 
259
286
  return (
@@ -267,7 +294,9 @@ function ChatEntryBody({ node, sessionId, renderMessageImages, t, rt, port, open
267
294
  )}
268
295
  {bubble.showBubble && (
269
296
  <div className={css.chatBubble}>
270
- {bubble.text !== '' && <span className={css.chatBubbleText}>{bubble.text}</span>}
297
+ {bubble.text !== '' && (
298
+ <span className={css.chatBubbleText}>{hostText === null ? bubble.text : hostText}</span>
299
+ )}
271
300
  {bubble.raw.map((block) => (
272
301
  <RawBlock
273
302
  key={'raw-' + block.id}
@@ -9,56 +9,18 @@
9
9
  * ② **DSH 自带**:`@deepseek-ai/dsh-client-ui-primitives` 的 `FileTypeIcon` —— 这正是 DSH 左侧文件树
10
10
  * (`dsh-client-ui-sidebar-files` 的 FilesBody)逐字使用的那颗组件(同一份按类型着色的画集)。
11
11
  *
12
- * ## 为什么这里是「惰取 + try/catch」而不是顶层 import(**关键**)
13
- * 该包由 DSH shell 的模块表作为 **static module seed** 预置(`dsh-web-frontend` 的 `staticModules`
14
- * 里就有它),插件 bundle 的 `require` 能直接命中 —— 第三方插件 dsh-better-sidebar 走的就是这条路。
15
- * 但那是**较新宿主**才有的模块表:老宿主加载本插件时 `require` 会抛(missed the module table)。
16
- * 顶层 `import` 会被打包器改写成**模块体顶层**的 `require`,那一刻抛错会让**整个浏览器半加载失败**
17
- * (设置页一起没)—— 为了「一颗图标」赔掉整插件,绝不。所以这里:
18
- * · 只在**真要画 icon 时**才 require(帧内),
19
- * · 拿不到 / 形态不符 / 抛错一律回落一颗**极简的本地 SVG 文件图标**(`fallbackFileGlyph`),
20
- * 既不打挂任何东西,也不空着。
21
- * 该包因此不进 bundle:源码里只有 `require` 调用(bundle 白名单在 src/utils/bundle-scan.ts 登记)。
12
+ * ## 惰取门在 `common/host-primitives.ts`(2026-10-15 抽出)
13
+ * 那颗「帧内惰取 + try/catch + 三态缓存」的门改为全仓唯一出口(同一份门现在也供聊天气泡的
14
+ * 正文投影使用)——本文件只保留**两级回落**:外部来源(better-sidebar)→ 宿主 FileTypeIcon
15
+ * → 本地极简 SVG。为什么不能顶层 import、为什么必须帧内取,见那个文件的头注释。
22
16
  */
23
- import type { ComponentType, ReactNode } from 'react'
17
+ import type { ReactNode } from 'react'
24
18
  import { Icon } from '../common/Icon.tsx'
19
+ import { hostFileTypeIcon } from '../common/host-primitives.ts'
25
20
 
26
21
  /** 装配点注入的「外部图标来源」(dsh-better-sidebar 的 `fileIcon`);返回 null = 它不认这一条。 */
27
22
  export type RollbackFileIconRenderer = (path: string) => ReactNode | null
28
23
 
29
- /** `FileTypeIcon` 我们真的用到的入参面(只声明消费的字段,不复制第三方类型)。 */
30
- interface DshFileTypeIconProps {
31
- path: string
32
- size?: number
33
- className?: string
34
- }
35
-
36
- /** 惰取结果的三态缓存(undefined = 还没试过;null = 试过了,这台宿主没有)。 */
37
- let cachedDshFileTypeIcon: ComponentType<DshFileTypeIconProps> | null | undefined
38
-
39
- /**
40
- * 惰取 DSH 自带的 `FileTypeIcon`(**每次进程只试一次**,失败就记 null 不再重试)。
41
- *
42
- * 为什么不用 `import()`:动态 import 在 cjs 产物里同样要走模块表,且会把整个包拖进 chunk;
43
- * 这里的 `require` 是 DSH loader 交给插件工厂的那一个(banner 里 `factory: (require) => {...}`)。
44
- */
45
- function dshFileTypeIcon(): ComponentType<DshFileTypeIconProps> | null {
46
- if (cachedDshFileTypeIcon !== undefined) return cachedDshFileTypeIcon
47
- cachedDshFileTypeIcon = null
48
- try {
49
- const loaded = (typeof require === 'function' ? require('@deepseek-ai/dsh-client-ui-primitives') : null) as
50
- | { FileTypeIcon?: unknown }
51
- | null
52
- const icon = loaded?.FileTypeIcon
53
- if (typeof icon === 'function' || typeof icon === 'object') {
54
- cachedDshFileTypeIcon = icon as ComponentType<DshFileTypeIconProps>
55
- }
56
- } catch {
57
- cachedDshFileTypeIcon = null
58
- }
59
- return cachedDshFileTypeIcon
60
- }
61
-
62
24
  /**
63
25
  * 最后的兜底:共享图标原语的通用「文件」图标(`common/Icon.tsx` 的 `name="file"`)。
64
26
  * 只有「没有 better-sidebar + 宿主模块表里没有 ui-primitives」两条同时成立时才会出现 ——
@@ -90,7 +52,7 @@ export function RollbackFileIcon({ path, size = 14, render }: RollbackFileIconPr
90
52
  // null / undefined / false = 这一条外部来源接不住 ⇒ 回落 DSH 自带画法(不猜、不空着)
91
53
  if (node !== null && node !== undefined && node !== false) return <>{node}</>
92
54
  }
93
- const Dsh = dshFileTypeIcon()
55
+ const Dsh = hostFileTypeIcon()
94
56
  if (Dsh !== null) return <Dsh path={path} size={size} />
95
57
  return fallbackFileGlyph(size)
96
58
  }