dsh-armory 2.2.0 → 2.3.0

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 CHANGED
@@ -46,15 +46,54 @@ const { useState, useEffect, useCallback, useMemo, useRef } = React2;
46
46
  // 将来加新语言可改走 require.async 外部字典文件。
47
47
  const i18nMessages = {};
48
48
  const setI18nMessages = (table) => { Object.assign(i18nMessages, table || {}); };
49
- // OPT-14 · 1.1 locale 判定:localStorage 偏好 → navigator.language → zh-CN 回落。
50
- // 所有失败路径(存储不可用、无 navigator)都静默回落,绝不抛错影响面板加载。
51
- const I18N_LOCALE = (() => {
49
+ const resetI18nMessages = () => { for (const key of Object.keys(i18nMessages)) delete i18nMessages[key]; };
50
+ // OPT-14 · 1.1 / OPT-25 locale 判定(首个命中即返回):
51
+ // ① localStorage['dsh-armory.locale'] —— 显式覆盖(测试与高级用户)
52
+ // ② DSH 宿主 locale.active —— 设置 → 通用里的语言,用户真正看到的 UI 语言
53
+ // ③ navigator.language —— 宿主 locale 不可用时的浏览器回落
54
+ // ④ zh-CN —— 源语言兜底
55
+ // 只读 navigator 是 2.2.0 及以前的做法:中文系统 + DSH 设英文时 navigator 仍是
56
+ // zh-CN,英文表永远不装载(issue #1 的根因)。宿主服务用 ctx.get('locale') 取,
57
+ // **不写进 inject**——老版本 DSH 没有该服务时仍能照常加载。
58
+ // 所有失败路径(存储不可用、无 navigator、宿主快照抛错)都静默回落,绝不抛错。
59
+ const readStoredI18nLocale = () => {
52
60
  try {
53
- const stored = localStorage['dsh-armory.locale'];
54
- if (stored) return stored;
55
- } catch { /* 存储不可用时跳过偏好,继续往下回落 */ }
56
- return typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'zh-CN';
57
- })();
61
+ return localStorage['dsh-armory.locale'] || null;
62
+ } catch { return null; }
63
+ };
64
+ const hostActiveLocale = (localeService) => {
65
+ try {
66
+ const snapshot = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null;
67
+ return snapshot && typeof snapshot.active === 'string' && snapshot.active ? snapshot.active : null;
68
+ } catch { return null; }
69
+ };
70
+ const initialI18nLocale = readStoredI18nLocale()
71
+ || (typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'zh-CN');
72
+ let I18N_LOCALE = initialI18nLocale;
73
+ const resolveI18nLocale = (localeService) => readStoredI18nLocale()
74
+ || hostActiveLocale(localeService)
75
+ || initialI18nLocale
76
+ || 'zh-CN';
77
+ // 切换语言时必须先清空旧字典:中文(源语言)要求空表才能恒等直出,
78
+ // 否则从 en 切回 zh 会残留英文译文。
79
+ // P1:源语言是中文、唯一译文是英文,所以按「zh* → 中文,其余 → 英文」判定。
80
+ // 非 zh 的非英文语言(ja / de / fr…)宁可给英文,也不把中文丢给看不懂的用户;
81
+ // en* 之外的显式覆盖同理。新增第三种语言时才需要把这里改成精确查表。
82
+ const isChineseLocale = (locale = I18N_LOCALE) => String(locale || '').toLowerCase().startsWith('zh');
83
+ const usesEnglish = (locale = I18N_LOCALE) => !isChineseLocale(locale);
84
+ const applyI18nLocale = (locale) => {
85
+ I18N_LOCALE = locale || initialI18nLocale || 'zh-CN';
86
+ resetI18nMessages();
87
+ if (usesEnglish()) setI18nMessages(EN_US_MESSAGES);
88
+ return I18N_LOCALE;
89
+ };
90
+ // 宿主 locale 订阅:包一层 try/catch,服务没实现 subscribe 或抛错都不影响面板。
91
+ const subscribeI18nLocale = (localeService, listener) => {
92
+ try {
93
+ if (localeService && typeof localeService.subscribe === 'function') return localeService.subscribe(listener);
94
+ } catch { /* 订阅失败:保留启动时判定的语言 */ }
95
+ return null;
96
+ };
58
97
  const EN_US_MESSAGES = {
59
98
  " + 捆绑技能": " + bundled skills",
60
99
  " · {0} 个失败": " · {0} failed",
@@ -461,6 +500,12 @@ const EN_US_MESSAGES = {
461
500
  "扫描中…": "Scanning…",
462
501
  "扫描后给出": "After the scan: ",
463
502
  "技能": "Skills",
503
+ "技能(skill 工具)": "Skills (skill tool)",
504
+ "技能账本:窗口内共 {0} 次 skill 调用;注册表 {1} 个技能中 {2} 个一次没被调用": "Skill ledger: {0} skill calls in the window; {2} of the {1} registered skills were never called",
505
+ "技能账本:窗口内共 {0} 次 skill 调用;注册表不可用,只列出日志里出现过的 {1} 个": "Skill ledger: {0} skill calls in the window; the registry is unavailable, so only the {1} seen in logs are listed",
506
+ "一次没被调用": "never called",
507
+ "有 {0} 次 skill 调用的参数里没有技能名,未计入上表": "{0} skill calls carried no skill name in their arguments and are not counted above",
508
+ "暂无技能调用": "No skill calls yet",
464
509
  "技能 / MCP / 专家 快速调用": "Skills / MCPs / Experts quick invoke",
465
510
  "技能 · MCP · 专家": "Skills · MCPs · Experts",
466
511
  "技能 · MCP · 专家看板": "Skills · MCPs · Experts board",
@@ -967,9 +1012,10 @@ const EN_US_MESSAGES = {
967
1012
 
968
1013
  ":": ": ",
969
1014
  };
970
- // OPT-14 · 1.1:仅当判定为英文 locale 时装载 en-US 表;zh-CN(源语言)保持空字典
971
- // 恒等直出,其他语言缺键也原样回落,不出现 key 名。
972
- if (I18N_LOCALE && I18N_LOCALE.toLowerCase().startsWith('en')) setI18nMessages(EN_US_MESSAGES);
1015
+ // 模块加载时先用 ①/③ 判定的 locale 装载一次;apply() 拿到宿主 locale 后会再按
1016
+ // ② 重算(见 applyI18nLocale)。zh-CN(源语言)保持空字典恒等直出,其他语言
1017
+ // 缺键也原样回落,不出现 key 名。
1018
+ applyI18nLocale(I18N_LOCALE);
973
1019
  const t = (s, vars) => {
974
1020
  let text = i18nMessages[s] || s;
975
1021
  if (vars && vars.length) {
@@ -977,6 +1023,59 @@ const t = (s, vars) => {
977
1023
  }
978
1024
  return text;
979
1025
  };
1026
+ // OPT-25:宿主切换语言后通知已挂载的界面重渲染。模块级文案表已改为 getter /
1027
+ // 函数(见 ENTRY_LABELS / SKILL_CATS 等),重渲染时就会读到新字典,不需要重载页面。
1028
+ // 订阅者失败隔离:一个组件抛错不影响其它组件更新。
1029
+ const i18nChangeListeners = new Set();
1030
+ const emitI18nChange = () => {
1031
+ for (const listener of [...i18nChangeListeners]) {
1032
+ try { listener(); } catch { /* 单个订阅者失败不影响其它订阅者 */ }
1033
+ }
1034
+ };
1035
+ const onI18nChange = (listener) => {
1036
+ i18nChangeListeners.add(listener);
1037
+ return () => { i18nChangeListeners.delete(listener); };
1038
+ };
1039
+ // 顶层槽位组件调用一次即可:locale 变化时强制自身重渲染。
1040
+ const useI18nRevision = () => {
1041
+ const [, setRevision] = useState(0);
1042
+ useEffect(() => onI18nChange(() => setRevision((value) => value + 1)), []);
1043
+ };
1044
+
1045
+ // ── P1:数据型英文覆盖(更新日志 / 内置 MCP 目录)───────────────────────────
1046
+ // 这两类文案不是 UI 字面量,而是宿主随 RPC 送来的数据:更新日志在 whats-new.js,
1047
+ // 目录在生成的 mcp-catalog.js。它们不能走 t() 字典(键集合由那两个文件决定,硬抄
1048
+ // 进字典会让 i18n-coverage 的「死键」断言永久变红),所以由宿主同时送中文与英文
1049
+ // 字段,客户端按当前 locale 选择。英文缺失时逐条回落中文,绝不渲染 undefined。
1050
+ //
1051
+ // 两个 helper 的 `english` 参数只用于测试时显式指定;运行时用当前 locale 判定。
1052
+ const localizeWhatsNewEntries = (entries, english = usesEnglish()) => {
1053
+ if (!english || !Array.isArray(entries)) return entries;
1054
+ return entries.map((entry) => ({
1055
+ ...entry,
1056
+ items: Array.isArray(entry.items)
1057
+ ? entry.items.map((item) => ({
1058
+ ...item,
1059
+ text: item.textEn || item.text,
1060
+ ...(item.whereEn ? { where: item.whereEn } : {}),
1061
+ }))
1062
+ : [],
1063
+ }));
1064
+ };
1065
+ const localizeBuiltinItem = (item, english = usesEnglish()) => {
1066
+ if (!english || !item) return item;
1067
+ const authNote = item.authNoteEn || item.authNote;
1068
+ // 只在英文字段真的存在时才覆盖:缺译文时保留原键(连 undefined 键都不新增),
1069
+ // 面板据此区分「没有说明」与「说明是空的」。
1070
+ return {
1071
+ ...item,
1072
+ ...(item.labelEn ? { label: item.labelEn } : {}),
1073
+ ...(item.summaryEn ? { summary: item.summaryEn } : {}),
1074
+ ...(item.accessEn ? { access: item.accessEn } : {}),
1075
+ ...(authNote ? { authNote } : {}),
1076
+ ...(item.categoryLabelEn ? { categoryLabel: item.categoryLabelEn } : {}),
1077
+ };
1078
+ };
980
1079
 
981
1080
  // ── 旧插件名 → 新插件名的 localStorage 键迁移 ──────────────────────────────
982
1081
  // 1.9.0 起本插件由 dsh-skill-mcp-board 更名为 dsh-armory(package.json 与
@@ -2661,10 +2760,16 @@ const useFocusTrap = (ref, active) => {
2661
2760
 
2662
2761
  const MAX_TIMER_DELAY_MS = 2147483647;
2663
2762
  const REDACTED_VALUE = '__DSH_MCP_REDACTED__';
2664
- const PRESERVED_VALUE_LABEL = t('••••••(保留原值)');
2763
+ // 屏蔽值在表单里需要**稳定哨兵**:locale 切换后译文会变,但已填进 state 的旧
2764
+ // 字符串不能随译文一起变,否则 toFormValue/fromFormValue 的相等判定会失配。
2765
+ // 因此内部只存 ASCII 哨兵,展示层再映射成当前语言的文案。
2766
+ const PRESERVED_VALUE_TOKEN = '__DSH_MCP_PRESERVED__';
2767
+ const preservedValueLabel = () => t('••••••(保留原值)');
2665
2768
  const MASKED_VALUE = '••••••';
2666
- const toFormValue = (value) => value === REDACTED_VALUE ? PRESERVED_VALUE_LABEL : String(value || '');
2667
- const fromFormValue = (value) => value === PRESERVED_VALUE_LABEL ? REDACTED_VALUE : value;
2769
+ const toFormValue = (value) => value === REDACTED_VALUE ? PRESERVED_VALUE_TOKEN : String(value || '');
2770
+ const fromFormValue = (value) => value === PRESERVED_VALUE_TOKEN ? REDACTED_VALUE : value;
2771
+ // args 是「每行一个」的多行串,哨兵可能夹在中间,所以按 token 全量替换而不是只比整串。
2772
+ const displayFormValue = (value) => String(value ?? '').split(PRESERVED_VALUE_TOKEN).join(preservedValueLabel());
2668
2773
  const displayValue = (value) => value === REDACTED_VALUE ? MASKED_VALUE : value;
2669
2774
  function setOwn(target, key, value) {
2670
2775
  Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true });
@@ -3242,8 +3347,9 @@ const Pager = ({ page, pageCount, total, onPage, disabled }) => {
3242
3347
  // oauth-or-pat)+ 有则挂 authNote。**这与市场连接器 tab 的 authMode 不是一回事**:
3243
3348
  // authMode 是上游 connectors 清单里的自由文本,只能原样显示;auth 是枚举,可以映射
3244
3349
  // 成人能读的标签。未知枚举值原样显示,绝不猜。
3245
- const AUTH_KIND_LABELS = { none: t('免认证'), apiKey: 'API Key', oauth: 'OAuth', 'oauth-or-pat': 'OAuth / PAT' };
3246
- const authKindLabel = (kind) => AUTH_KIND_LABELS[kind] || kind;
3350
+ const AUTH_KIND_LABELS = { apiKey: 'API Key', oauth: 'OAuth', 'oauth-or-pat': 'OAuth / PAT' };
3351
+ // 惰性求值:模块加载时宿主 locale 还没接进来,只有 none 需要翻译。
3352
+ const authKindLabel = (kind) => (kind === 'none' ? t('免认证') : AUTH_KIND_LABELS[kind] || kind);
3247
3353
  // 色语沿用 §1.3:oauth 系装完还得去浏览器授权,属「还需注意」→ 橙;apiKey 有既有
3248
3354
  // 「需密钥」徽章兜住,这里只作信息性标注,用默认灰。
3249
3355
  const authBadgeCls = (kind) => (kind === 'oauth' || kind === 'oauth-or-pat' ? 's-warn' : '');
@@ -3778,7 +3884,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
3778
3884
  const kv = (rows, setRows, i, field, v) => {
3779
3885
  const next = rows.slice();
3780
3886
  const row = { ...next[i], [field]: v };
3781
- if (field === 'k' && row.v === PRESERVED_VALUE_LABEL && v !== row.originalKey) row.v = '';
3887
+ if (field === 'k' && row.v === PRESERVED_VALUE_TOKEN && v !== row.originalKey) row.v = '';
3782
3888
  next[i] = row;
3783
3889
  setRows(next);
3784
3890
  };
@@ -3790,7 +3896,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
3790
3896
  : value && typeof value === 'object'
3791
3897
  ? Object.entries(value).map(([key, item]) => `${key}=${typeof item === 'object' ? JSON.stringify(item) : String(item)}`).join('\n')
3792
3898
  : String(value ?? '');
3793
- const visibleEditValue = (field, key, fallback) => Object.hasOwn(editRevealed, editRevealId(field, key)) ? editRevealed[editRevealId(field, key)] : fallback;
3899
+ const visibleEditValue = (field, key, fallback) => Object.hasOwn(editRevealed, editRevealId(field, key)) ? editRevealed[editRevealId(field, key)] : displayFormValue(fallback);
3794
3900
  const hideEditReveal = (field, key) => setEditRevealed((prev) => {
3795
3901
  const next = { ...prev };
3796
3902
  delete next[editRevealId(field, key)];
@@ -3882,7 +3988,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
3882
3988
  h('label', null, 'URL'),
3883
3989
  h('div', { className: 'dsh-mcp-input-action' },
3884
3990
  h('input', { value: visibleEditValue('url', undefined, url), onChange: (e) => { hideEditReveal('url'); setUrl(e.target.value); }, placeholder: 'https://example.com/mcp' }),
3885
- editing && url === PRESERVED_VALUE_LABEL ? revealIcon('url', 'URL', () => toggleEditReveal('url')) : null,
3991
+ editing && url === PRESERVED_VALUE_TOKEN ? revealIcon('url', 'URL', () => toggleEditReveal('url')) : null,
3886
3992
  ),
3887
3993
  )
3888
3994
  : h('div', null,
@@ -3891,7 +3997,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
3891
3997
  h('label', null, t('参数(每行一个,原样传给进程)')),
3892
3998
  h('div', { className: 'dsh-mcp-input-action' },
3893
3999
  h('textarea', { rows: 4, value: visibleEditValue('args', undefined, args), onChange: (e) => { hideEditReveal('args'); setArgs(e.target.value); }, placeholder: '-y\nmcp-server@latest' }),
3894
- editing && args === PRESERVED_VALUE_LABEL ? revealIcon('args', t('参数'), () => toggleEditReveal('args')) : null,
4000
+ editing && args === PRESERVED_VALUE_TOKEN ? revealIcon('args', t('参数'), () => toggleEditReveal('args')) : null,
3895
4001
  ),
3896
4002
  ),
3897
4003
  h('div', { className: 'dsh-mcp-field' }, h('label', null, t('工作目录 cwd(可选)')), h('input', { value: cwd, onChange: (e) => setCwd(e.target.value), placeholder: 'C:\\path\\to\\workspace' })),
@@ -3906,7 +4012,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
3906
4012
  h('input', { placeholder: transport === 'http' ? 'Authorization' : 'API_KEY', value: row.k, onChange: (e) => { hideEditReveal(field, originalKey); kv(kvRows, setKvRows, i, 'k', e.target.value); } }),
3907
4013
  h('input', { placeholder: transport === 'http' ? t('Bearer … 或 !!js process.env.KEY') : 'value', value: visibleEditValue(field, originalKey, row.v), onChange: (e) => { hideEditReveal(field, originalKey); kv(kvRows, setKvRows, i, 'v', e.target.value); } }),
3908
4014
  h('div', { className: 'dsh-mcp-kv-row-actions' },
3909
- editing && row.v === PRESERVED_VALUE_LABEL ? revealIcon(id, row.k, () => toggleEditReveal(field, originalKey)) : null,
4015
+ editing && row.v === PRESERVED_VALUE_TOKEN ? revealIcon(id, row.k, () => toggleEditReveal(field, originalKey)) : null,
3910
4016
  h('button', { type: 'button', className: 'dsh-mcp-kv-del', onClick: () => delRow(kvRows, setKvRows, i), title: t('删除此项'), 'aria-label': t('删除 ') + (row.k || t('配置项')) }, '✕'),
3911
4017
  ),
3912
4018
  );
@@ -4869,12 +4975,14 @@ const ENTRY_VISIBILITY = {
4869
4975
  sidebar: { panel: false, fab: false, sidebar: true },
4870
4976
  all: { panel: true, fab: true, sidebar: true },
4871
4977
  };
4978
+ // getter 而不是模块加载时求值:apply() 拿到宿主 locale 后文案才正确,
4979
+ // 且切语言后重渲染能直接读到新译文。
4872
4980
  const ENTRY_LABELS = {
4873
- both: t('全局面板 + 悬浮按钮(推荐)'),
4874
- panel: t('仅全局面板(左上角,"插件"旁)'),
4875
- fab: t('仅悬浮按钮'),
4876
- sidebar: t('仅侧栏底部入口'),
4877
- all: t('三个入口全开'),
4981
+ get both() { return t('全局面板 + 悬浮按钮(推荐)'); },
4982
+ get panel() { return t('仅全局面板(左上角,"插件"旁)'); },
4983
+ get fab() { return t('仅悬浮按钮'); },
4984
+ get sidebar() { return t('仅侧栏底部入口'); },
4985
+ get all() { return t('三个入口全开'); },
4878
4986
  };
4879
4987
  const ENTRY_DEFAULT = 'both';
4880
4988
  const ENTRY_MODES = Object.keys(ENTRY_VISIBILITY);
@@ -4946,22 +5054,24 @@ const PANEL_ID = 'mcp-manager';
4946
5054
  // 入口排序:插件管理器用 0,定时任务用 10;取 5 让本入口正好落在「插件」旁边。
4947
5055
  const PANEL_ORDER = 5;
4948
5056
  // 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
4949
- const PANEL_LABEL = t('技能 · MCP · 专家');
4950
- // 左上角侧栏入口(sidebar.panellist)单独命名,与 PANEL_LABEL 解耦:
5057
+ // 函数而非常量:槽位注册发生在 apply() 里,那时才拿到宿主 locale。
5058
+ const panelLabel = () => t('技能 · MCP · 专家');
5059
+ // 左上角侧栏入口(sidebar.panellist)单独命名,与 panelLabel() 解耦:
4951
5060
  // 该行的 label 同时是可见文字、无障碍名称和折叠提示,改动不牵动输入框快捷菜单、
4952
5061
  // 会话头专家徽章、悬浮按钮和侧栏底部入口的文案。
4953
- const SIDEBAR_ENTRY_LABEL = t('大肥鱼军械库');
5062
+ const sidebarEntryLabel = () => t('大肥鱼军械库');
4954
5063
 
4955
5064
  // skill 的 source 取值域来自官方 dsh-skill-filesystem 的 roots()(rank 顺序即优先级),
4956
5065
  // 与 skill-registry 的 runtime。这里只做展示文案,不参与判定。
5066
+ // getter 保证每次读取都按当前 locale 翻译(切语言后重渲染即生效)。
4957
5067
  const SKILL_SOURCE_LABELS = {
4958
- 'project-dsh': t('项目 .dsh/skills'),
4959
- 'project-agents': t('项目 .agents/skills'),
4960
- custom: t('自定义目录'),
4961
- 'user-dsh': t('用户 ~/.dsh/skills'),
4962
- 'user-agents': t('用户 ~/.agents/skills'),
4963
- bundled: t('随 DSH 分发'),
4964
- runtime: t('运行时注册'),
5068
+ get 'project-dsh'() { return t('项目 .dsh/skills'); },
5069
+ get 'project-agents'() { return t('项目 .agents/skills'); },
5070
+ get custom() { return t('自定义目录'); },
5071
+ get 'user-dsh'() { return t('用户 ~/.dsh/skills'); },
5072
+ get 'user-agents'() { return t('用户 ~/.agents/skills'); },
5073
+ get bundled() { return t('随 DSH 分发'); },
5074
+ get runtime() { return t('运行时注册'); },
4965
5075
  };
4966
5076
  const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
4967
5077
 
@@ -4977,24 +5087,24 @@ const skillRemovable = (source) => SKILL_REMOVABLE_SOURCES.has(String(source ||
4977
5087
  // (跨类污染最小者优先),未命中再扫弱特征词,仍未命中落「未分类」——保证不丢条目。
4978
5088
  // 匹配对象 = name + ' ' + description 的小写串;首个命中即定类。关键词表允许实现方微调。
4979
5089
  const SKILL_CATS = [
4980
- { key: 'all', name: t('全部'), color: 'var(--mcp-cat-all)' },
4981
- { key: 'dev', name: t('编程开发'), color: 'var(--mcp-cat-dev)',
5090
+ { key: 'all', get name() { return t('全部'); }, color: 'var(--mcp-cat-all)' },
5091
+ { key: 'dev', get name() { return t('编程开发'); }, color: 'var(--mcp-cat-dev)',
4982
5092
  strong: ['skill p8', 'arkts', 'arkui', '微信小程序', '小程序', '后端', '数据库', 'mysql', 'mariadb', 'api 设计', 'api设计', 'bug 诊断', 'bug诊断', '代码审查', '代码生成', '代码可读', '重构', '测试用例', '全栈', 'karpathy', 'spec 驱动', 'image-to-code', 'mcp server', 'sdk', 'monorepo', '源码'],
4983
5093
  weak: ['code', 'coding', 'developer', 'typescript', 'javascript', 'python', 'rust', 'sql', '编程', '开发'] },
4984
- { key: 'design', name: t('设计与创作'), color: 'var(--mcp-cat-design)',
5094
+ { key: 'design', get name() { return t('设计与创作'); }, color: 'var(--mcp-cat-design)',
4985
5095
  strong: ['imagegen', '品牌', 'brandkit', 'logo', '海报', 'stitch', 'mockup', '图标', '插画', '视频生成', '视频编辑', '视频翻译', '素材分析', '内容生产', '内容合成', '设计稿', '设计系统', 'landing page', '配图', '设计'],
4986
5096
  weak: ['design', '创作', 'image', 'video', '视觉', '前端', 'frontend', '页面'] },
4987
- { key: 'office', name: t('文档办公'), color: 'var(--mcp-cat-office)',
5097
+ { key: 'office', get name() { return t('文档办公'); }, color: 'var(--mcp-cat-office)',
4988
5098
  strong: ['docx', 'xlsx', 'pptx', 'pdf', 'word 文档', 'excel', 'powerpoint', '文档', '公文', '报告', '表格', '幻灯', '公式缓存', 'libreoffice'],
4989
5099
  weak: ['office', '写作', 'markdown', '笔记'] },
4990
- { key: 'auto', name: t('效率与自动化'), color: 'var(--mcp-cat-auto)',
5100
+ { key: 'auto', get name() { return t('效率与自动化'); }, color: 'var(--mcp-cat-auto)',
4991
5101
  strong: ['browser', '浏览器', '定时', 'schedule', 'workflow', '工作流', '自动化', '批量', '表单填写', '数据提取', '提醒'],
4992
5102
  weak: ['自动', '效率', '抓取', '搜索'] },
4993
- { key: 'ops', name: t('开发运维'), color: 'var(--mcp-cat-ops)',
5103
+ { key: 'ops', get name() { return t('开发运维'); }, color: 'var(--mcp-cat-ops)',
4994
5104
  strong: ['sandbox', '沙箱', 'acl', '运维', '部署', 'deploy', 'release', '发布', '排查', '诊断', '环境', '崩溃', '代理下载', '签名', '供应链'],
4995
5105
  weak: ['debug', 'windows', '安装', '升级', '更新'] },
4996
5106
  ];
4997
- const SKILL_UNCAT = { key: 'uncat', name: t('未分类'), color: 'var(--mcp-cat-uncat)' };
5107
+ const SKILL_UNCAT = { key: 'uncat', get name() { return t('未分类'); }, color: 'var(--mcp-cat-uncat)' };
4998
5108
  // skillcat hash 值合法性校验用。
4999
5109
  const SKILL_CAT_KEYS = new Set(SKILL_CATS.map((c) => c.key));
5000
5110
  const skillCatByKey = (key) => SKILL_CATS.find((c) => c.key === key) || null;
@@ -5026,10 +5136,10 @@ const skillScopeArg = (scope) => (scope === 'global' ? {} : { cwd: scope });
5026
5136
  // OPT-16 · 3.2:专家详情弹层 persona 预览截断阈值(展开全文后渲染完整原文)。
5027
5137
  const PERSONA_PREVIEW_MAX = 4000;
5028
5138
  const TAILOR_WINDOWS = [
5029
- { key: '1', label: t('今日'), days: 1 }, // OPT-24b · 4.1:今日档,回答「刚配置完它到底被调了几次」
5030
- { key: '7', label: t('近 7 天'), days: 7 },
5031
- { key: '30', label: t('近 30 天'), days: 30 },
5032
- { key: '90', label: t('近 90 天'), days: 90 },
5139
+ { key: '1', get label() { return t('今日'); }, days: 1 }, // OPT-24b · 4.1:今日档,回答「刚配置完它到底被调了几次」
5140
+ { key: '7', get label() { return t('近 7 天'); }, days: 7 },
5141
+ { key: '30', get label() { return t('近 30 天'); }, days: 30 },
5142
+ { key: '90', get label() { return t('近 90 天'); }, days: 90 },
5033
5143
  ];
5034
5144
  const fmtInt = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—'); // OPT-14 · 1.2:跟随 locale 回落
5035
5145
  // OPT-18 · 4.2 零值显性化:zstd 不可用时所有调用计数恒为 0(解不出帧),这个 0 是「测不到」
@@ -5166,6 +5276,54 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5166
5276
  : null;
5167
5277
 
5168
5278
  const tools = report && Array.isArray(report.tools) ? report.tools : [];
5279
+ // §6.4 技能维度:与工具表同一份账本、同一次扫描,只是把 skill 的参数里的名字拆出来。
5280
+ // 这一节回答的是工具表回答不了的问题——「注册了却一次没被调用」的是哪些技能。
5281
+ const skillRows = report && Array.isArray(report.skills) ? report.skills : [];
5282
+ const skillSection = (() => {
5283
+ // 没有报告时不留空壳;有报告但一条技能调用都没有、也没有解不出的调用时同样不占版面。
5284
+ if (!report) return null;
5285
+ const unparsed = typeof report.skillCallsUnparsed === 'number' ? report.skillCallsUnparsed : 0;
5286
+ if (skillRows.length === 0 && unparsed === 0) return null;
5287
+ const counted = typeof report.skillCalls === 'number' ? report.skillCalls : skillRows.reduce((sum, row) => sum + (row.calls || 0), 0);
5288
+ const summary = report.skillsRegistryAvailable === true
5289
+ ? t('技能账本:窗口内共 {0} 次 skill 调用;注册表 {1} 个技能中 {2} 个一次没被调用', [fmtInt(counted), fmtInt(report.skillRegisteredCount), fmtInt(report.skillNeverUsedCount)])
5290
+ : t('技能账本:窗口内共 {0} 次 skill 调用;注册表不可用,只列出日志里出现过的 {1} 个', [fmtInt(counted), fmtInt(skillRows.length)]);
5291
+ return h('div', null,
5292
+ h('div', { className: 'dsh-mcp-tailor-note' }, h('b', null, t('技能(skill 工具)')), summary),
5293
+ h('div', { className: 'dsh-mcp-tailor-table' },
5294
+ h('div', { className: 'dsh-mcp-tailor-row head' },
5295
+ h('span', { className: 't-name' }, t('技能')),
5296
+ h('span', { className: 't-num' }, t('调用')),
5297
+ h('span', { className: 't-num' }, t('错误 (率)')),
5298
+ h('span', { className: 't-tax' }),
5299
+ h('span', null, t('最后使用')),
5300
+ h('span', { className: 't-op' }),
5301
+ ),
5302
+ skillRows.length === 0
5303
+ ? h('div', { className: 'dsh-mcp-tailor-empty' }, t('暂无技能调用'))
5304
+ : skillRows.map((skill) => {
5305
+ const isDead = skill.calls === 0;
5306
+ const errRate = skill.errorRate || 0;
5307
+ return h('div', { key: skill.name, className: 'dsh-mcp-tailor-row' + (isDead ? ' dead' : '') },
5308
+ h('span', { className: 't-name', title: skill.name },
5309
+ skill.name,
5310
+ isDead ? h('span', { className: 't-badge' }, t('一次没被调用')) : null,
5311
+ errRate >= 0.5 ? h('span', { className: 't-badge err' }, t('高错误率')) : null,
5312
+ ),
5313
+ h('span', { className: 't-num' }, tailorNum(skill.calls, report)),
5314
+ h('span', { className: 't-num' + (errRate >= 0.5 ? ' bad' : '') },
5315
+ skill.errors > 0 ? fmtInt(skill.errors) + ' (' + fmtPct(errRate) + ')' : fmtInt(skill.errors)),
5316
+ h('span', { className: 't-tax' }),
5317
+ h('span', { className: 't-last' }, fmtLast(skill.lastUsedMs, report.generatedAt)),
5318
+ h('span', { className: 't-op' }),
5319
+ );
5320
+ }),
5321
+ ),
5322
+ unparsed > 0
5323
+ ? h('div', { className: 'dsh-mcp-tailor-note' }, t('有 {0} 次 skill 调用的参数里没有技能名,未计入上表', [fmtInt(unparsed)]))
5324
+ : null,
5325
+ );
5326
+ })();
5169
5327
  const shown = tools
5170
5328
  .filter((tool) => (onlyDead ? tool.calls === 0 : true))
5171
5329
  .sort((a, b) => (b.calls - a.calls) || ((b.schemaTokens || 0) - (a.schemaTokens || 0)) || String(a.name).localeCompare(String(b.name)));
@@ -5368,6 +5526,9 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5368
5526
  );
5369
5527
  }),
5370
5528
  ),
5529
+ // §6.4 技能账本。放在工具表**下方**而不是另一页:读者是同一个问题——「谁在干活、谁在占位」
5530
+ // 的第二个切面。零调用的技能行沿用工具表 `.dead` 的底色约定,读法不必重新学一遍。
5531
+ skillSection,
5371
5532
  deadMcpActionable.length
5372
5533
  ? h('div', { className: 'dsh-mcp-tailor-note' },
5373
5534
  h('b', null, t('裁缝建议:')),
@@ -5380,19 +5541,19 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5380
5541
  const BOARD_CATEGORIES = [
5381
5542
  { key: 'mcp', label: 'MCP', icon: 'plug' },
5382
5543
  { key: 'skill', label: 'Skill', icon: 'skill' },
5383
- { key: 'expert', label: t('专家'), icon: 'expert' },
5384
- { key: 'tailor', label: t('工具裁缝'), icon: 'scissors' },
5385
- { key: 'wb', label: t('市场'), icon: 'store' },
5544
+ { key: 'expert', get label() { return t('专家'); }, icon: 'expert' },
5545
+ { key: 'tailor', get label() { return t('工具裁缝'); }, icon: 'scissors' },
5546
+ { key: 'wb', get label() { return t('市场'); }, icon: 'store' },
5386
5547
  ];
5387
5548
  // 分类摘要文案表:把「这一页到底有什么」压成一句话 + 两三行事实。
5388
5549
  // 拆成纯数据而不是写在渲染里,是因为它同时被 hover 浮层和键盘焦点复用,
5389
5550
  // 且后面加分类时只要补一条,不必改渲染逻辑。
5390
5551
  const CATEGORY_BLURB = {
5391
- mcp: t('按服务器看工具清单、连接状态与参数;可增删、重连、导入。'),
5392
- skill: t('本地 Skill 清单:来源、是否启用、是否可被模型调用。'),
5393
- expert: t('已召唤的专家:人设、捆绑技能、当前在场状态,可送还。'),
5394
- tailor: t('工具使用账本:谁在被调用、谁在白缴上下文税。'),
5395
- wb: t('市场:连接器、内置 MCP、SkillHub 技能与专家召唤。'),
5552
+ get mcp() { return t('按服务器看工具清单、连接状态与参数;可增删、重连、导入。'); },
5553
+ get skill() { return t('本地 Skill 清单:来源、是否启用、是否可被模型调用。'); },
5554
+ get expert() { return t('已召唤的专家:人设、捆绑技能、当前在场状态,可送还。'); },
5555
+ get tailor() { return t('工具使用账本:谁在被调用、谁在白缴上下文税。'); },
5556
+ get wb() { return t('市场:连接器、内置 MCP、SkillHub 技能与专家召唤。'); },
5396
5557
  };
5397
5558
  // §1.1 分类 hover 摘要。定位由父层算好(left),这里只负责内容与外观。
5398
5559
  // 只用 pointer-events:none 的只读浮层:不承载交互,因此不需要焦点陷阱,
@@ -6587,32 +6748,32 @@ const connectorMatchesVisibleIn = (rowVisibleTags, selectedTags) => {
6587
6748
  return rowVisibleTags.some((tag) => selectedTags.includes(tag));
6588
6749
  };
6589
6750
  const WB_SKILLHUB_SORTS = [
6590
- { key: 'downloads', label: t('下载量(本页排序)') },
6591
- { key: 'installs', label: t('最多安装') },
6592
- { key: 'stars', label: t('最多星标') },
6593
- { key: 'score', label: t('综合评分') },
6751
+ { key: 'downloads', get label() { return t('下载量(本页排序)'); } },
6752
+ { key: 'installs', get label() { return t('最多安装'); } },
6753
+ { key: 'stars', get label() { return t('最多星标'); } },
6754
+ { key: 'score', get label() { return t('综合评分'); } },
6594
6755
  ];
6595
6756
  // T05/R10:连接器排序(全量本地排序,不受分页影响)。
6596
6757
  // 目录自带 skillCount(捆绑技能数)与 visibleIn(适用环境标签数),够做有意义的排序;
6597
6758
  // upstream 无下载量/评分字段,不臆造。
6598
6759
  const WB_CONNECTOR_SORTS = [
6599
- { key: 'default', label: t('目录默认顺序') },
6600
- { key: 'skills', label: t('技能数由多到少') },
6601
- { key: 'name', label: t('名称 A→Z') },
6760
+ { key: 'default', get label() { return t('目录默认顺序'); } },
6761
+ { key: 'skills', get label() { return t('技能数由多到少'); } },
6762
+ { key: 'name', get label() { return t('名称 A→Z'); } },
6602
6763
  ];
6603
6764
  // T06/Awesome:DSH 社区目录排序(全量本地排序,不受分页影响)。
6604
6765
  // upstream 自带 stars 与 lastVerified,无下载量/评分字段,不臆造。
6605
6766
  const AWESOME_SORTS = [
6606
- { key: 'stars', label: t('最多星标') },
6607
- { key: 'recent', label: t('最新收录') },
6608
- { key: 'name', label: t('名称 A→Z') },
6767
+ { key: 'stars', get label() { return t('最多星标'); } },
6768
+ { key: 'recent', get label() { return t('最新收录'); } },
6769
+ { key: 'name', get label() { return t('名称 A→Z'); } },
6609
6770
  ];
6610
6771
  const AWESOME_CATEGORIES = [
6611
- { key: 'all', label: t('全部') },
6612
- { key: 'plugin', label: t('插件') },
6613
- { key: 'skill', label: t('技能') },
6614
- { key: 'bundle', label: t('套件') },
6615
- { key: 'tool', label: t('工具') },
6772
+ { key: 'all', get label() { return t('全部'); } },
6773
+ { key: 'plugin', get label() { return t('插件'); } },
6774
+ { key: 'skill', get label() { return t('技能'); } },
6775
+ { key: 'bundle', get label() { return t('套件'); } },
6776
+ { key: 'tool', get label() { return t('工具'); } },
6616
6777
  ];
6617
6778
  const AWESOME_TAGS = ['ui', 'terminal', 'agents', 'memory', 'models', 'vision', 'capabilities', 'channels', 'notifications', 'usage', 'observability', 'marketplace', 'devtools', 'safety', 'knowledge', 'interop', 'fun'];
6618
6779
  const AWESOME_PAGE_SIZE = 50;
@@ -7295,15 +7456,18 @@ const expertCardActions = ({ summoned, expertType, personaText }) => ({
7295
7456
  // 也不要让一条读不出来的旧行悄悄用坏人设开新会话)。
7296
7457
  const personaTextOf = (rows, plugin) => (Array.isArray(rows) ? rows.find((row) => row?.plugin === plugin)?.personaText : '') || '';
7297
7458
  const QUICK_MENU_KINDS = [
7298
- { key: 'skill', label: t('技能'), icon: 'skill', hint: t('技能按需触发:选择后以 /技能名 插入输入框') },
7299
- { key: 'mcp', label: 'MCP', icon: 'plug', hint: t('MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具') },
7300
- { key: 'expert', label: t('专家'), icon: 'expert', hint: t('专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消') },
7459
+ { key: 'skill', get label() { return t('技能'); }, icon: 'skill', get hint() { return t('技能按需触发:选择后以 /技能名 插入输入框'); } },
7460
+ { key: 'mcp', label: 'MCP', icon: 'plug', get hint() { return t('MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具'); } },
7461
+ { key: 'expert', get label() { return t('专家'); }, icon: 'expert', get hint() { return t('专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消'); } },
7301
7462
  ];
7302
7463
  // 子菜单列表每屏固定 6 行(6 × 28px),更多条目在 .dsh-mcp-quick-list 里纵向滚动;
7303
7464
  // 「最近」徽章只标最近池里的前 6 名(首屏可见的那批),避免整列都是徽章。
7304
7465
  // 专家预设显示名前缀:召唤时写死的 `WorkBuddy 专家 · <显示名>`(见 expert-market.js
7305
7466
  // buildExpertPresetRow),宿主的会话头预设标签原样显示它——用来识别「那条会过期的标签」。
7306
- const EXPERT_PRESET_LABEL_PREFIX = t('WorkBuddy 专家 · ');
7467
+ // 这是**匹配用**前缀,不是展示文案:宿主 expert-market.js 写入预设定名时永远用
7468
+ // 中文 `WorkBuddy 专家 · `(见 buildExpertPresetRow),这里必须逐字相同。
7469
+ // 不能包 t()——英文环境下翻译成 "WorkBuddy Expert · " 会永远匹配不上宿主标签。
7470
+ const EXPERT_PRESET_LABEL_PREFIX = 'WorkBuddy 专家 · ';
7307
7471
  // 专家挂载状态的最小事件总线:快速菜单挂/换/摘专家后立刻通知会话头徽章与菜单 chip,
7308
7472
  // 三处显示同一帧内对齐(不再各自等重挂载)。低频轮询只作兜底,覆盖「别的入口挂的专家」。
7309
7473
  const expertChangeListeners = new Set();
@@ -7333,6 +7497,7 @@ const quickMentionText = (kind, item) => {
7333
7497
  return /^[\w-]+$/.test(token) ? `/${token} ` : t('{0}:', [token]);
7334
7498
  };
7335
7499
  const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7500
+ useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
7336
7501
  const [open, setOpen] = useState(false);
7337
7502
  const [hoverKind, setHoverKind] = useState(null);
7338
7503
  const [recent, setRecent] = useState(null); // { skill: [], mcp: [], expert: [] }
@@ -7784,6 +7949,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
7784
7949
  // 宿主标签在 540px 以下隐藏,本徽章跟随同一断点(container query 无法跨包复用,
7785
7950
  // 用媒体查询近似窄窗行为)。
7786
7951
  const SessionExpertBadge = ({ call, sessionId }) => {
7952
+ useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
7787
7953
  const [attached, setAttached] = useState(null);
7788
7954
  const [ready, setReady] = useState(false);
7789
7955
  const badgeRef = useRef(null);
@@ -8325,16 +8491,19 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8325
8491
  const builtinNeedsConfig = (item) => Array.isArray(item.configKeys) && item.configKeys.length > 0;
8326
8492
  const builtinNeedsArgs = (item) => Array.isArray(item.placeholders) && item.placeholders.length > 0;
8327
8493
  const builtinReady = (item) => !builtinNeedsKey(item) && !builtinNeedsConfig(item) && !builtinNeedsArgs(item);
8328
- const builtinCategories = useMemo(() => {
8494
+ // P1:目录随 RPC 同时送中英字段,这里按当前 locale 投影一次。只在 47 条上跑,
8495
+ // 不做 useMemo——locale 变化时 useI18nRevision 已强制重渲染,缓存反而会留下旧语言。
8496
+ const localizedBuiltins = builtins.map((item) => localizeBuiltinItem(item));
8497
+ const builtinCategories = (() => {
8329
8498
  const counts = new Map();
8330
- for (const item of builtins) {
8499
+ for (const item of localizedBuiltins) {
8331
8500
  const key = item.category || 'other';
8332
8501
  const entry = counts.get(key) || { label: item.categoryLabel || key, count: 0 };
8333
8502
  entry.count += 1;
8334
8503
  counts.set(key, entry);
8335
8504
  }
8336
8505
  return [...counts.entries()].map(([key, entry]) => ({ key, ...entry }));
8337
- }, [builtins]);
8506
+ })();
8338
8507
  const builtinQueryKey = builtinQuery.trim().toLowerCase();
8339
8508
  // ── T03/R5:codebuddy 插件市场 ──
8340
8509
  // 目录一次全量(官方 zip 里的 external_plugins 清单,无分页概念),本地过滤。
@@ -8588,7 +8757,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
8588
8757
  const awesomePageCount = Math.max(1, Math.ceil(sortedAwesome.length / AWESOME_PAGE_SIZE));
8589
8758
  const safeAwesomePage = Math.min(awesomePage, awesomePageCount);
8590
8759
  const visibleAwesome = sortedAwesome.slice((safeAwesomePage - 1) * AWESOME_PAGE_SIZE, safeAwesomePage * AWESOME_PAGE_SIZE);
8591
- const filteredBuiltins = builtins.filter((item) => {
8760
+ const filteredBuiltins = localizedBuiltins.filter((item) => {
8592
8761
  if (builtinCategory !== 'all' && (item.category || 'other') !== builtinCategory) return false;
8593
8762
  if (builtinOnlyReady && !builtinReady(item)) return false;
8594
8763
  if (!builtinQueryKey) return true;
@@ -9403,6 +9572,7 @@ const EntryMenu = () => {
9403
9572
  // 左侧栏底部入口(sidebar.footer.action,DSH 0.1.5 起提供):官方标准的发现入口。
9404
9573
  // 点击打开的是同一个浮层面板——不切换主面板、不打断正在进行的会话。
9405
9574
  const McpFooterAction = ({ wide }) => {
9575
+ useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
9406
9576
  const mode = useEntryMode();
9407
9577
  // 「仅悬浮按钮」模式:返回 null 就够了,不需要注销注册。list 型 slot 的每个条目直接渲染
9408
9578
  // 进侧栏的 .footerActions(display:flex、无 gap/padding),外层 outlet 锚点又是
@@ -9820,6 +9990,7 @@ function openBoardCategory(key) {
9820
9990
  }
9821
9991
  const McpBoardPage = (props) => {
9822
9992
  const { call, timer, pluginManager } = props;
9993
+ useI18nRevision(); // 宿主切语言后整块看板重渲染,模块级 getter 文案即时更新
9823
9994
  // §1.2 URL 记忆:分类写进 hash,刷新 / 从别处退回 / 分享链接都能回到同一页。
9824
9995
  // 初值只在首帧读一次(useState 惰性初始化),之后由 effect 单向写出,
9825
9996
  // 不做 hash→state 的反向监听:宿主可能在任意时刻改 hash,双向同步必然互相打架。
@@ -10151,6 +10322,7 @@ function whatsNewFor(current, lastSeen, entries) {
10151
10322
  // 自带 RPC 与状态:面板只管「在这儿渲染一个」。它挂在面板里,就自然随面板挂载/卸载——
10152
10323
  // 这正是「升级后第一次打开面板时弹一次」的触发点,不需要另外判开合。
10153
10324
  const WhatsNewModal = ({ call }) => {
10325
+ useI18nRevision(); // P1:宿主切语言后条目按新 locale 重新选择
10154
10326
  const [notice, setNotice] = useState(null);
10155
10327
  const boxRef = useRef(null);
10156
10328
  const confirmRef = useRef(null);
@@ -10214,6 +10386,8 @@ const WhatsNewModal = ({ call }) => {
10214
10386
  useFocusTrap(boxRef, !!notice);
10215
10387
 
10216
10388
  if (!notice) return null;
10389
+ // 在渲染期按当前 locale 选择,而不是在 RPC 回包时固化:弹窗开着时切语言也能即时变。
10390
+ const localizedEntries = localizeWhatsNewEntries(notice.entries);
10217
10391
  return h(
10218
10392
  'div',
10219
10393
  { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && dismiss() },
@@ -10227,10 +10401,10 @@ const WhatsNewModal = ({ call }) => {
10227
10401
  h('span', null, t('这些变化你可能想知道')),
10228
10402
  ),
10229
10403
  h('div', { className: 'dsh-mcp-whatsnew-body' },
10230
- notice.entries.map((entry) =>
10404
+ localizedEntries.map((entry) =>
10231
10405
  h('div', { key: entry.version, className: 'dsh-mcp-whatsnew-entry' },
10232
10406
  // 一次跳多个版本时每条自带版本号;只报一条时标题里的版本号就够了。
10233
- notice.entries.length > 1 ? h('div', { className: 'dsh-mcp-whatsnew-ver' }, 'v' + entry.version) : null,
10407
+ localizedEntries.length > 1 ? h('div', { className: 'dsh-mcp-whatsnew-ver' }, 'v' + entry.version) : null,
10234
10408
  entry.items.map((item, position) =>
10235
10409
  h('div', { key: position, className: 'dsh-mcp-whatsnew-item' },
10236
10410
  h('span', { className: 'dsh-mcp-whatsnew-dot', 'aria-hidden': true }, '·'),
@@ -10252,6 +10426,7 @@ const WhatsNewModal = ({ call }) => {
10252
10426
 
10253
10427
  // ── 悬浮按钮宿主(shell.overlay):只剩这一个席位 ────────────────────────────────
10254
10428
  const McpOverlay = () => {
10429
+ useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
10255
10430
  const mode = useEntryMode();
10256
10431
  const seats = overlaySeats(mode);
10257
10432
  const [pos, setPos] = useState(() => readFabPos() || defaultFabPos());
@@ -10492,19 +10667,34 @@ const MCP_CONTRIBUTION = {
10492
10667
  };
10493
10668
  // ── 插件入口:挂 RPC、注入样式、注册六个界面槽位 ──────────────────────────────
10494
10669
  async function apply(ctx) {
10670
+ // OPT-25:先接宿主 locale(设置 → 通用里的语言),再注册槽位——槽位 label 是
10671
+ // 注册时求值的字符串,必须等这里算完。ctx.get 是可选读取,不写进 inject:
10672
+ // 老版本 DSH 没有 locale 服务时返回 undefined,自动回落 navigator/zh-CN。
10673
+ const readHostLocale = () => {
10674
+ try { return typeof ctx.get === 'function' ? ctx.get('locale') : null; } catch { return null; }
10675
+ };
10676
+ let hostLocaleService = readHostLocale();
10677
+ applyI18nLocale(resolveI18nLocale(hostLocaleService));
10495
10678
  const disposeMount = await ctx.remote.$mount(MCP_CONTRIBUTION);
10496
10679
  let disposeCss;
10497
10680
  let timer;
10498
10681
  let cleanupPromise;
10682
+ let disposeLocaleSub = null;
10683
+ let disposeServiceWatch = null;
10499
10684
  const cleanup = () => {
10500
10685
  cleanupPromise ??= (async () => {
10501
10686
  try {
10502
- timer?.dispose();
10687
+ try { disposeLocaleSub?.(); } catch { /* 宿主订阅已失效 */ }
10688
+ try { disposeServiceWatch?.(); } catch { /* 宿主事件监听已失效 */ }
10503
10689
  } finally {
10504
10690
  try {
10505
- disposeCss?.();
10691
+ timer?.dispose();
10506
10692
  } finally {
10507
- await disposeMount();
10693
+ try {
10694
+ disposeCss?.();
10695
+ } finally {
10696
+ await disposeMount();
10697
+ }
10508
10698
  }
10509
10699
  }
10510
10700
  })();
@@ -10533,7 +10723,7 @@ async function apply(ctx) {
10533
10723
  // 直接 register:它声明感知,会等 ui-layout 把 main slot 声明出来(插件加载顺序不保证
10534
10724
  // 晚于布局)。
10535
10725
  ctx.slots.inject('main', () =>
10536
- ctx.slots.register({ name: 'main', key: PANEL_ID, label: PANEL_LABEL, inject: () => ({ call, timer, pluginManager }) }, McpBoardPage),
10726
+ ctx.slots.register({ name: 'main', key: PANEL_ID, label: panelLabel(), inject: () => ({ call, timer, pluginManager }) }, McpBoardPage),
10537
10727
  );
10538
10728
  // 输入框召唤菜单(conversation.input.right,list 型 slot):权限/模型座位右侧的小图标,
10539
10729
  // 点开 = 技能/MCP/专家三分类 + 悬停最近使用子菜单。声明感知注入(会话 slot 由 conversation
@@ -10541,13 +10731,13 @@ async function apply(ctx) {
10541
10731
  // sessionId 由 slot 渲染作为 prop 显式传入(session 座位约定:id 只走 props,不走 summary 投影),
10542
10732
  // 「挂专家」的人设通道 RPC 用它寻址当前会话。
10543
10733
  ctx.slots.inject('conversation.input.right', () =>
10544
- ctx.slots.register({ name: 'conversation.input.right', id: PANEL_ID + '-quick', order: 40, label: PANEL_LABEL, inject: (sessionId) => ({ call, sessionId }) }, SummonQuickMenu),
10734
+ ctx.slots.register({ name: 'conversation.input.right', id: PANEL_ID + '-quick', order: 40, label: panelLabel(), inject: (sessionId) => ({ call, sessionId }) }, SummonQuickMenu),
10545
10735
  );
10546
10736
  // 会话头「当前生效专家」徽章(conversation.session.header.actions,list 型 slot):
10547
10737
  // 与宿主 AgentPresetLabel 同列——但显示的是人设通道真正生效的专家(expertActive),
10548
10738
  // 修复「换专家后顶栏仍显示第一次预设名」的显示/实效分叉。
10549
10739
  ctx.slots.inject('conversation.session.header.actions', () =>
10550
- ctx.slots.register({ name: 'conversation.session.header.actions', id: PANEL_ID + '-head-expert', order: -5, label: PANEL_LABEL, inject: (sessionId) => ({ call, sessionId }) }, SessionExpertBadge),
10740
+ ctx.slots.register({ name: 'conversation.session.header.actions', id: PANEL_ID + '-head-expert', order: -5, label: panelLabel(), inject: (sessionId) => ({ call, sessionId }) }, SessionExpertBadge),
10551
10741
  );
10552
10742
  // 左上角全局面板行(sidebar.panellist):「插件」旁边,主入口。行点击由宿主承担——
10553
10743
  // layout.selectPanel(PANEL_ID) 要求 main slot 里存在同 key 条目,上面的注册保证了这点。
@@ -10557,20 +10747,52 @@ async function apply(ctx) {
10557
10747
  const syncPanelEntry = () => {
10558
10748
  const want = ENTRY_VISIBILITY[entryMode.get()].panel;
10559
10749
  if (want && !disposePanelEntry) disposePanelEntry = ctx.slots.register(
10560
- { name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: SIDEBAR_ENTRY_LABEL }, McpPanelIcon,
10750
+ { name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: sidebarEntryLabel() }, McpPanelIcon,
10561
10751
  );
10562
10752
  if (!want && disposePanelEntry) { disposePanelEntry(); disposePanelEntry = null; }
10563
10753
  };
10564
10754
  ctx.slots.inject('sidebar.panellist', () => { syncPanelEntry(); });
10565
10755
  entryMode.subscribe(syncPanelEntry);
10756
+ // OPT-25:宿主切换语言后重算字典并刷新槽位文案。
10757
+ // sidebar.panellist 的 label 来自注册参数而不是组件,只能 dispose + 重新注册;
10758
+ // 面板/菜单等内容组件通过 useI18nRevision 强制重渲染,getter 文案即时生效。
10759
+ const applyHostLocale = () => {
10760
+ const next = resolveI18nLocale(hostLocaleService);
10761
+ if (next === I18N_LOCALE) return;
10762
+ applyI18nLocale(next);
10763
+ // 只在入口已经注册过时重注册:否则可能在 sidebar.panellist 声明前直接
10764
+ // 调用 register(inject 的声明感知就白用了)。没注册过就没有旧文案可刷新,
10765
+ // 之后的 syncPanelEntry 会用新 locale 取 label。
10766
+ if (disposePanelEntry) {
10767
+ disposePanelEntry();
10768
+ disposePanelEntry = null;
10769
+ syncPanelEntry();
10770
+ }
10771
+ emitI18nChange();
10772
+ };
10773
+ const bindHostLocale = () => {
10774
+ const service = readHostLocale();
10775
+ if (!service) return;
10776
+ hostLocaleService = service;
10777
+ applyHostLocale();
10778
+ if (!disposeLocaleSub) disposeLocaleSub = subscribeI18nLocale(service, applyHostLocale);
10779
+ };
10780
+ bindHostLocale();
10781
+ // locale 插件可能晚于本插件完成 apply:服务注册时再绑一次(Cordis 内置事件)。
10782
+ // 老宿主没有该事件也无妨,启动时的 ctx.get 已经覆盖绝大多数加载顺序。
10783
+ if (typeof ctx.on === 'function') {
10784
+ disposeServiceWatch = ctx.on('internal/service', (name) => {
10785
+ if (name === 'locale') bindHostLocale();
10786
+ });
10787
+ }
10566
10788
  // 右下角悬浮按钮:保留既有能力,点击同样切到看板页。
10567
10789
  ctx.slots.inject('shell.overlay', () =>
10568
- ctx.slots.register({ name: 'shell.overlay', id: 'mcp-fab', order: 1000, label: PANEL_LABEL, inject: () => ({ call, timer, pluginManager }) }, McpOverlay),
10790
+ ctx.slots.register({ name: 'shell.overlay', id: 'mcp-fab', order: 1000, label: panelLabel(), inject: () => ({ call, timer, pluginManager }) }, McpOverlay),
10569
10791
  );
10570
10792
  // 左侧栏底部入口(sidebar.footer.action,0.1.5 起声明):兼容入口,行为与面板行一致。
10571
10793
  // 用 inject 而不是直接 register:它声明感知,会等侧边栏把 slot 声明出来。
10572
10794
  ctx.slots.inject('sidebar.footer.action', () =>
10573
- ctx.slots.register({ name: 'sidebar.footer.action', id: 'mcp-manager', order: 100, label: PANEL_LABEL }, McpFooterAction),
10795
+ ctx.slots.register({ name: 'sidebar.footer.action', id: 'mcp-manager', order: 100, label: panelLabel() }, McpFooterAction),
10574
10796
  );
10575
10797
  return cleanup;
10576
10798
  } catch (error) {