dsh-armory 1.9.3 → 1.9.4

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
@@ -35,6 +35,28 @@ var __injectCss = function(text) {
35
35
  const React2 = React;
36
36
  const { useState, useEffect, useCallback, useMemo, useRef } = React2;
37
37
 
38
+ // ── OPT-11:i18n 骨架(gettext 模型,键 = 中文原文) ────────────────────────
39
+ // zh-CN 是源语言:字典为空时 t() 恒等返回原文,缺键永远不会让界面出现 key 名。
40
+ // 将来补 en-US:往 i18nMessages 合并 { '中文原文': 'English' } 即可,无需改调用点。
41
+ // 带插值的文案:t('{0} 个会话', [n]) —— {n} 占位符按 vars 数组下标替换;
42
+ // 翻译表里的目标语可自由重排占位符顺序。
43
+ // 约束(与 OPT-12 同源):ModuleLoader 只注入宿主模块,client.js 不能相对导入
44
+ // 字典文件,所以消息表必须内联在本文件里。
45
+ const i18nMessages = {};
46
+ const setI18nMessages = (table) => { Object.assign(i18nMessages, table || {}); };
47
+ // 补 en-US 时把字典表直接写进下面这次调用的实参:{ '设置': 'Settings', ... }。
48
+ // 留一个空调用不是占位符——它是唯一的写入入口,也提醒后来者「新语言要改这里」。
49
+ setI18nMessages({
50
+ // 例:'设置': 'Settings',
51
+ });
52
+ const t = (s, vars) => {
53
+ let text = i18nMessages[s] || s;
54
+ if (vars && vars.length) {
55
+ text = text.replace(/\{(\d+)\}/g, (raw, index) => (index in vars ? vars[+index] : raw));
56
+ }
57
+ return text;
58
+ };
59
+
38
60
  // ── 旧插件名 → 新插件名的 localStorage 键迁移 ──────────────────────────────
39
61
  // 1.9.0 起本插件由 dsh-skill-mcp-board 更名为 dsh-armory(package.json 与
40
62
  // cordis.patch.yml 的 id/name 同步改)。浏览器侧这几个键**不能跟着直接改名**:
@@ -69,6 +91,7 @@ function migrateLegacyKeys(storage) {
69
91
  }
70
92
  migrateLegacyKeys(typeof localStorage === 'undefined' ? null : localStorage);
71
93
 
94
+ // ── 样式表 MCP_CSS:主题令牌别名 + 全部面板规则(一次性注入 <style>)──────────────
72
95
  /* 主题令牌别名层。DSH 的 ui-theme 拥有全部颜色、几何与字体:插件只消费语义令牌,
73
96
  不写死色值、不写局部圆角数值(docs/web-styling.md:「Use --dsw-alias-* semantic
74
97
  tokens in feature components. Do not copy static palette values or write literal
@@ -139,6 +162,10 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
139
162
  .dsh-mcp-badge.s-err.dot::before{background:var(--dsw-alias-state-error-primary)}
140
163
  .dsh-mcp-badge.s-off{color:var(--mcp-mut);border-color:var(--mcp-line)}
141
164
  .dsh-mcp-badge.s-err{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger)}
165
+ .dsh-mcp-badge.s-warn{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-tertiary)}
166
+ .dsh-mcp-badge.clickable{cursor:pointer;user-select:none}
167
+ .dsh-mcp-badge.clickable:hover{border-color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover-accent)}
168
+ .dsh-mcp-badge.clickable:focus-visible{outline:2px solid var(--dsw-alias-state-warn-primary);outline-offset:1px}
142
169
  .dsh-mcp-badge.scope-ws{color:var(--mcp-accent);border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
143
170
  .dsh-mcp-detail{flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-2);padding:16px;overflow-y:auto}
144
171
  .dsh-mcp-d-empty{display:flex;flex:1;align-items:center;justify-content:center;color:var(--mcp-mut);font-size:13px;gap:8px}
@@ -234,6 +261,11 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
234
261
  .dsh-mcp-wb-modal-note{font-size:11.5px;line-height:1.6;color:var(--mcp-mut)}
235
262
  .dsh-mcp-wb-examples{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);background:var(--mcp-bg-2);border-radius:var(--mcp-r-sm);padding:8px 12px;margin:10px 0}
236
263
  .dsh-mcp-wb-examples ul{margin:4px 0 0;padding-left:18px}
264
+ .dsh-mcp-wb-roster{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:5px}
265
+ .dsh-mcp-wb-roster li{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;font-size:12px;line-height:1.5;padding:5px 9px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-2)}
266
+ .dsh-mcp-wb-roster b{font-weight:650;min-width:0;overflow-wrap:anywhere}
267
+ .dsh-mcp-wb-roster-role{font-size:11px;color:var(--dsw-alias-brand-text);flex:none}
268
+ .dsh-mcp-wb-roster-prof{font-size:11px;color:var(--mcp-mut);flex:none}
237
269
  .dsh-mcp-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;flex-wrap:wrap}
238
270
  .dsh-mcp-advanced{margin-top:14px}.dsh-mcp-check{display:flex!important;align-items:center;gap:8px;margin:9px 0!important;color:var(--dsw-alias-label-primary)!important}.dsh-mcp-check input{width:auto!important}.dsh-mcp-number-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dsh-mcp-number-grid .dsh-mcp-field{margin-bottom:0}.dsh-mcp-import-preview{font-size:12px;line-height:1.6;color:var(--mcp-mut);white-space:pre-wrap}.dsh-mcp-import-json{min-height:220px;resize:vertical;font-family:var(--mcp-mono)!important}
239
271
  .dsh-mcp-builtin-modal{box-sizing:border-box;width:700px;height:min(760px,84vh);display:flex;flex-direction:column;overflow:hidden}.dsh-mcp-builtin-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 10px;flex:none}.dsh-mcp-builtin-toolbar .dsh-mcp-check{margin:0!important;font-size:12px}.dsh-mcp-builtin-count{font-size:11.5px;color:var(--mcp-mut)}.dsh-mcp-builtin-list{display:flex;flex-direction:column;gap:8px;min-height:0;overflow-y:auto;padding-right:2px}.dsh-mcp-builtin-modal>.dsh-mcp-modal-actions{flex:none}.dsh-mcp-builtin-row{display:grid;grid-template-columns:18px minmax(0,1fr) minmax(74px,auto);gap:11px;align-items:start;padding:11px 12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:pointer;transition:border-color .12s,background .12s;flex:none}.dsh-mcp-builtin-row:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dsh-mcp-builtin-row.installed{cursor:default;background:var(--mcp-bg-2)}.dsh-mcp-builtin-row.installed:hover{border-color:var(--mcp-line);background:var(--mcp-bg-2)}.dsh-mcp-builtin-select{box-sizing:border-box;width:16px!important;height:16px!important;margin:2px 0 0!important;accent-color:var(--mcp-accent);cursor:pointer}.dsh-mcp-builtin-select:disabled{cursor:default}.dsh-mcp-builtin-main{min-width:0;display:flex;flex-direction:column;gap:5px}.dsh-mcp-builtin-name{font-size:13px;font-weight:650;overflow-wrap:anywhere}.dsh-mcp-builtin-summary{font-size:11.5px;color:var(--mcp-mut);line-height:1.45}.dsh-mcp-builtin-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.dsh-mcp-builtin-access{font-size:10.5px;color:var(--mcp-mut)}.dsh-mcp-builtin-config{font-family:var(--mcp-mono);font-size:10.5px;line-height:1.45;color:var(--dsw-alias-brand-text);overflow-wrap:anywhere;white-space:normal}.dsh-mcp-builtin-status{min-width:0;text-align:right;font-size:11px;color:var(--mcp-mut);line-height:1.45;overflow-wrap:anywhere}.dsh-mcp-builtin-status.ready{color:var(--dsw-alias-state-success-primary)}.dsh-mcp-builtin-loading{padding:30px 12px;text-align:center;color:var(--mcp-mut);font-size:12px}
@@ -471,16 +503,31 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
471
503
  .dsh-mcp-tailor-empty{color:var(--mcp-mut);font-size:13px;padding:18px 14px}
472
504
  .dsh-mcp-tailor-note{border:0.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-2);padding:10px 14px;font-size:12.5px;line-height:19px;color:var(--mcp-mut);flex:none}
473
505
  .dsh-mcp-tailor-note b{color:var(--dsw-alias-label-primary)}
506
+ /* 解码告警(OPT-02):正常运行时整块不渲染,所以这里可以给足视觉权重。 */
507
+ .dsh-mcp-tailor-warn{border:0.5px solid var(--mcp-warn);border-radius:var(--mcp-r-md);corner-shape:round;background:color-mix(in srgb,var(--mcp-warn) 8%,transparent);padding:10px 14px;font-size:12.5px;line-height:19px;color:var(--dsw-alias-label-primary);flex:none;display:flex;flex-direction:column;gap:4px}
508
+ .dsh-mcp-tailor-warn b{color:var(--mcp-warn)}
509
+ .dsh-mcp-tailor-details{margin-top:2px}
510
+ .dsh-mcp-tailor-details summary{cursor:pointer;color:var(--mcp-mut);font-size:12px;line-height:18px}
511
+ .dsh-mcp-tailor-details ul{margin:6px 0 0;padding-left:18px;color:var(--mcp-mut);font-size:11.5px;line-height:17px;max-height:160px;overflow-y:auto}
512
+ .dsh-mcp-tailor-details li{font-family:var(--mcp-mono);word-break:break-all}
513
+ /* 面板错误边界(OPT-07):只在某块渲染期抛错时出现,占满该分类的正文区。 */
514
+ .dsh-mcp-panel-error{border:0.5px solid var(--mcp-err);border-radius:var(--mcp-r-md);corner-shape:round;background:color-mix(in srgb,var(--mcp-err) 7%,transparent);padding:16px 18px;display:flex;flex-direction:column;gap:8px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
515
+ .dsh-mcp-panel-error b{color:var(--mcp-err);font-size:14px}
516
+ .dsh-mcp-panel-error p{margin:0;font-family:var(--mcp-mono);font-size:12px;line-height:18px;color:var(--mcp-mut);word-break:break-word}
517
+ .dsh-mcp-panel-error-act{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
518
+ .dsh-mcp-panel-error-act span{color:var(--mcp-mut);font-size:12px;line-height:18px}
474
519
 
475
520
  /* 响应式覆盖必须位于全部基础规则之后:同特异性下后出现者胜出。 */
476
521
  @media (max-width:760px){.dsh-mcp-panel-overlay{padding:10px}.dsh-mcp-panel{max-width:none;width:100%;height:calc(100dvh - 20px);border-radius:var(--mcp-r-panel)}.dsh-mcp-panel .dsh-mcp-wrap{padding:12px}.dsh-mcp-head{align-items:flex-start}.dsh-mcp-body{flex-direction:column;overflow-y:auto;overflow-x:hidden}.dsh-mcp-side,.dsh-mcp-ws-body{width:auto;min-width:0;max-height:none;flex:none}.dsh-mcp-side:not(.embedded) .dsh-mcp-list{max-height:38vh}.dsh-mcp-section.local,.dsh-mcp-section.global{flex:none;min-height:0;max-height:none}.dsh-mcp-section.local .dsh-mcp-list{max-height:30vh}.dsh-mcp-global-list{max-height:30vh}.dsh-mcp-detail{padding:12px;flex:none}.dsh-mcp-d-head{flex-direction:column}.dsh-mcp-kv-edit{grid-template-columns:minmax(90px,35%) minmax(0,1fr) auto}.dsh-mcp-page{padding:calc(var(--dsh-frame-top-clearance,0px) + 10px) 12px 20px}.dsh-mcp-page>.dsh-mcp-wrap{min-height:0;height:auto}.dsh-mcp-skill-body{flex-direction:column;overflow:visible}.dsh-mcp-skill-side{width:auto;flex:none}.dsh-mcp-skill-list{max-height:34vh}.dsh-mcp-skill-detail{flex:none;max-height:none}.dsh-mcp-skill-d-md{max-height:38vh}}
477
522
  @media (max-width:480px){.dsh-mcp-head{flex-direction:column}.dsh-mcp-head-actions{width:100%;justify-content:flex-start}.dsh-mcp-btn{padding:7px 9px}.dsh-mcp-radio-row,.dsh-mcp-number-grid{grid-template-columns:1fr}.dsh-mcp-modal{max-width:none;width:100%;padding:14px}.dsh-mcp-kv{grid-template-columns:1fr;gap:4px}.dsh-mcp-kv>b{margin-top:4px}.dsh-mcp-secret-row{grid-template-columns:1fr;gap:4px}.dsh-mcp-secret{width:100%}.dsh-mcp-kv-edit{grid-template-columns:1fr auto}.dsh-mcp-kv-edit input:first-child{grid-column:1 / -1}.dsh-mcp-tool{flex-direction:column}.dsh-mcp-tool-name{max-width:100%}.dsh-mcp-tool-desc{white-space:normal}.dsh-mcp-builtin-toolbar{align-items:flex-start;flex-direction:column}.dsh-mcp-builtin-row{grid-template-columns:18px minmax(0,1fr)}.dsh-mcp-builtin-status{grid-column:2;text-align:left}}
478
523
  `;
479
524
 
525
+ // ── DOM 基础件:createElement 绑定、值遮蔽、通用小控件与图标表 ──────────────────────
526
+ // 这一层必须排在所有组件之前 —— 下面 23 个返回元素的 React 组件全靠这里的 `h` 构造。
480
527
  const h = React2.createElement;
481
528
  const MAX_TIMER_DELAY_MS = 2147483647;
482
529
  const REDACTED_VALUE = '__DSH_MCP_REDACTED__';
483
- const PRESERVED_VALUE_LABEL = '••••••(保留原值)';
530
+ const PRESERVED_VALUE_LABEL = t('••••••(保留原值)');
484
531
  const MASKED_VALUE = '••••••';
485
532
  const toFormValue = (value) => value === REDACTED_VALUE ? PRESERVED_VALUE_LABEL : String(value || '');
486
533
  const fromFormValue = (value) => value === PRESERVED_VALUE_LABEL ? REDACTED_VALUE : value;
@@ -509,7 +556,8 @@ const Dot = ({ phase }) => {
509
556
  return h('span', { className: 'dsh-mcp-dot ' + cls });
510
557
  };
511
558
 
512
- const Badge = ({ cls, children }) => h('span', { className: 'dsh-mcp-badge ' + (cls || '') }, children);
559
+ // 徽章默认是纯展示 span;传 onClick 时可当按钮用(如「需更新」点一下直接重跑召唤)。
560
+ const Badge = ({ cls, children, ...rest }) => h('span', { ...rest, className: 'dsh-mcp-badge ' + (cls || '') }, children);
513
561
 
514
562
  const Icon = ({ d, size, className }) =>
515
563
  h('svg', { className, width: size || 13, height: size || 13, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }, h('path', { d }));
@@ -541,14 +589,16 @@ const ICONS = {
541
589
  // ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
542
590
  // 预设只填充表单,不直接写入;命令依赖本机 npx / uvx,保存前请确认可用。
543
591
  const MCP_PRESETS = [
544
- { id: 'filesystem', serverName: 'filesystem', label: 'Filesystem · 本地文件读写', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/allowed/dir'] },
545
- { id: 'memory', serverName: 'memory', label: 'Memory · 知识图谱记忆', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] },
546
- { id: 'sequential-thinking', serverName: 'sequential-thinking', label: 'Sequential Thinking · 分步推理', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] },
547
- { id: 'time', serverName: 'time', label: 'Time · 时区时间工具(需 uvx)', transport: 'stdio', command: 'uvx', args: ['mcp-server-time'] },
548
- { id: 'git', serverName: 'git', label: 'Git · 版本控制(需 uvx)', transport: 'stdio', command: 'uvx', args: ['mcp-server-git'] },
592
+ { id: 'filesystem', serverName: 'filesystem', label: t('Filesystem · 本地文件读写'), transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/allowed/dir'] },
593
+ { id: 'memory', serverName: 'memory', label: t('Memory · 知识图谱记忆'), transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] },
594
+ { id: 'sequential-thinking', serverName: 'sequential-thinking', label: t('Sequential Thinking · 分步推理'), transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] },
595
+ { id: 'time', serverName: 'time', label: t('Time · 时区时间工具(需 uvx)'), transport: 'stdio', command: 'uvx', args: ['mcp-server-time'] },
596
+ { id: 'git', serverName: 'git', label: t('Git · 版本控制(需 uvx)'), transport: 'stdio', command: 'uvx', args: ['mcp-server-git'] },
549
597
  ];
550
598
 
551
- // ── 工具参数 JSON Schema 的可读化渲染辅助 ────────────────────────────
599
+ // ── 表单与剪贴板基础件:遮蔽值的显隐状态、修订号去重、复制 ──────────────────────────
600
+ // (此前这一节的标注写成「工具参数 JSON Schema 的可读化渲染辅助」,但那三个函数在下面
601
+ // describeType 处。标注错位会让人以为 reveal 逻辑属于 schema 渲染,实际两者无关。)
552
602
  function clearRevealState(generation, setValues, setBusy) {
553
603
  generation.current += 1;
554
604
  setValues({});
@@ -580,6 +630,8 @@ async function copyText(text) {
580
630
  area?.remove();
581
631
  }
582
632
  }
633
+
634
+ // ── 工具参数 Schema 的可读化渲染:类型名与枚举文本 ─────────────────────────────
583
635
  function describeType(node) {
584
636
  if (!node || typeof node !== 'object') return 'any';
585
637
  if (Array.isArray(node.type)) return node.type.join(' | ');
@@ -604,6 +656,8 @@ function estimateTabWidth(label, hasCount) {
604
656
  const POPOVER_MIN_WIDTH = 170;
605
657
 
606
658
  // 作用域标签页:全局固定首位;项目超出宽度时折叠为「…N」弹出层。
659
+
660
+ // ── 作用域标签页:全局固定首位,超宽时折叠成「…N」弹出层 ──────────────────────────
607
661
  const ScopeTabs = ({ tabs, active, onSelect }) => {
608
662
  const rootRef = useRef(null);
609
663
  const barRef = useRef(null);
@@ -690,6 +744,8 @@ const ScopeTabs = ({ tabs, active, onSelect }) => {
690
744
  popover,
691
745
  );
692
746
  };
747
+
748
+ // ── 工具行:Schema 摊平成字段清单,可展开看原始 JSON ────────────────────────────
693
749
  function flattenParameters(schema) {
694
750
  if (!schema || typeof schema !== 'object') return { required: [], properties: [] };
695
751
  const required = Array.isArray(schema.required) ? schema.required : [];
@@ -721,7 +777,7 @@ const ToolRow = ({ tool }) => {
721
777
  role: 'button',
722
778
  tabIndex: 0,
723
779
  'aria-expanded': open,
724
- title: open ? '收起参数' : '查看参数',
780
+ title: open ? t('收起参数') : t('查看参数'),
725
781
  onClick: toggle,
726
782
  onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } },
727
783
  } : { className: 'dsh-mcp-tool-head' };
@@ -736,15 +792,15 @@ const ToolRow = ({ tool }) => {
736
792
  properties.map((p) => h('div', { key: p.name, className: 'dsh-mcp-param' },
737
793
  h('div', { className: 'dsh-mcp-param-head' },
738
794
  h('code', { className: 'dsh-mcp-param-name' }, p.name),
739
- p.required ? h('span', { className: 'dsh-mcp-req' }, '必填') : h('span', { className: 'dsh-mcp-opt' }, '可选'),
795
+ p.required ? h('span', { className: 'dsh-mcp-req' }, t('必填')) : h('span', { className: 'dsh-mcp-opt' }, t('可选')),
740
796
  h('span', { className: 'dsh-mcp-param-type' }, p.type),
741
797
  ),
742
798
  p.description ? h('div', { className: 'dsh-mcp-param-desc' }, p.description) : null,
743
- p.enumText ? h('div', { className: 'dsh-mcp-param-enum' }, '可选值:' + p.enumText) : null,
744
- p.hasDefault ? h('div', { className: 'dsh-mcp-param-enum' }, '默认值:' + JSON.stringify(p.default)) : null,
799
+ p.enumText ? h('div', { className: 'dsh-mcp-param-enum' }, t('可选值:') + p.enumText) : null,
800
+ p.hasDefault ? h('div', { className: 'dsh-mcp-param-enum' }, t('默认值:') + JSON.stringify(p.default)) : null,
745
801
  )),
746
802
  ) : null,
747
- h('button', { type: 'button', className: 'dsh-mcp-tool-rawbtn', onClick: () => setRaw((value) => !value) }, raw ? '收起原始 JSON' : '查看原始 JSON'),
803
+ h('button', { type: 'button', className: 'dsh-mcp-tool-rawbtn', onClick: () => setRaw((value) => !value) }, raw ? t('收起原始 JSON') : t('查看原始 JSON')),
748
804
  raw ? h('pre', { className: 'dsh-mcp-json' }, JSON.stringify(tool.parameters, null, 2)) : null,
749
805
  ) : null,
750
806
  );
@@ -766,20 +822,21 @@ const mountFailed = (s) => s.mountFailed === true;
766
822
  // Host 已在行上标好 scopeFailed 与具体原因,这里只负责不要把它显示成「连接失败」。
767
823
  const scopeFailed = (s) => s.scopeFailed === true;
768
824
  const wsSub = (s) => {
769
- if (mountFailed(s)) return '挂载失败';
770
- if (scopeFailed(s)) return '作用域隔离失败';
771
- if (s.status === 'disabled') return '已禁用';
772
- if (s.status === 'unknown') return '状态未知';
773
- if (s.status === 'loading') return '连接中…';
774
- if (s.status === 'failed') return '连接失败';
775
- if (s.status !== 'connected') return '尚未建立连接(新会话自动挂载)';
825
+ if (mountFailed(s)) return t('挂载失败');
826
+ if (scopeFailed(s)) return t('作用域隔离失败');
827
+ if (s.status === 'disabled') return t('已禁用');
828
+ if (s.status === 'unknown') return t('状态未知');
829
+ if (s.status === 'loading') return t('连接中…');
830
+ if (s.status === 'failed') return t('连接失败');
831
+ if (s.status !== 'connected') return t('尚未建立连接(新会话自动挂载)');
776
832
  const refs = typeof s.refs === 'number' ? s.refs : 0;
777
- const tools = typeof s.toolCount === 'number' ? ' · ' + s.toolCount + ' 工具' : '';
833
+ const tools = typeof s.toolCount === 'number' ? ' · ' + s.toolCount + t(' 工具') : '';
778
834
  return refs > 0
779
- ? '已连接 · ' + refs + ' 个会话在用' + tools
780
- : '已连接 · 当前无会话使用(连接保留,下次直接用)' + tools;
835
+ ? t('已连接 · ') + refs + t(' 个会话在用') + tools
836
+ : t('已连接 · 当前无会话使用(连接保留,下次直接用)') + tools;
781
837
  };
782
838
 
839
+ // ── 服务器列表:按传输 / 状态筛选,选中与启停 ──────────────────────────────────
783
840
  const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onToggle, busy, transportFilter, statusFilter, onTransportFilter, onStatusFilter, embedded, workspace, emptyHint }) => {
784
841
  const count = total === undefined ? servers.length : total;
785
842
  const showControls = !embedded || count > CONTROLS_THRESHOLD;
@@ -789,23 +846,23 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
789
846
  const dotPhase = (s) => (mountFailed(s) || scopeFailed(s) ? 'failed' : s.status || s.phase);
790
847
  const toggleLocked = (s) => busy || !s.managed || (s.scope !== 'workspace' && !s.enabled && s.phase !== 'stopped');
791
848
  return h('div', { className: 'dsh-mcp-side' + (embedded ? ' embedded' : '') },
792
- showControls ? h('div', { className: 'dsh-mcp-search' }, h(Icon, { d: ICONS.search }), h('input', { placeholder: '搜索 MCP…', value: query, onChange: (e) => onQuery(e.target.value) })) : null,
849
+ showControls ? h('div', { className: 'dsh-mcp-search' }, h(Icon, { d: ICONS.search }), h('input', { placeholder: t('搜索 MCP…'), value: query, onChange: (e) => onQuery(e.target.value) })) : null,
793
850
  showControls ? h('div', { className: 'dsh-mcp-filters' },
794
- h('select', { value: transportFilter, onChange: (e) => onTransportFilter(e.target.value), 'aria-label': '按传输过滤' },
795
- h('option', { value: 'all' }, '全部传输'),
851
+ h('select', { value: transportFilter, onChange: (e) => onTransportFilter(e.target.value), 'aria-label': t('按传输过滤') },
852
+ h('option', { value: 'all' }, t('全部传输')),
796
853
  h('option', { value: 'http' }, 'HTTP'),
797
854
  h('option', { value: 'stdio' }, 'stdio'),
798
855
  ),
799
- h('select', { value: statusFilter, onChange: (e) => onStatusFilter(e.target.value), 'aria-label': '按状态过滤' },
800
- h('option', { value: 'all' }, '全部状态'),
801
- workspace ? null : h('option', { value: 'connected' }, '已连接'),
802
- workspace ? null : h('option', { value: 'failed' }, '失败'),
803
- h('option', { value: 'disabled' }, '已禁用'),
856
+ h('select', { value: statusFilter, onChange: (e) => onStatusFilter(e.target.value), 'aria-label': t('按状态过滤') },
857
+ h('option', { value: 'all' }, t('全部状态')),
858
+ workspace ? null : h('option', { value: 'connected' }, t('已连接')),
859
+ workspace ? null : h('option', { value: 'failed' }, t('失败')),
860
+ h('option', { value: 'disabled' }, t('已禁用')),
804
861
  ),
805
862
  ) : null,
806
863
  h('div', { className: 'dsh-mcp-list' },
807
864
  servers.length === 0
808
- ? h('div', { className: 'dsh-mcp-empty-list' }, count === 0 && emptyHint ? emptyHint : '没有匹配的 MCP')
865
+ ? h('div', { className: 'dsh-mcp-empty-list' }, count === 0 && emptyHint ? emptyHint : t('没有匹配的 MCP'))
809
866
  : servers.map((s) =>
810
867
  h('div', { key: s.serverName, role: 'button', tabIndex: 0, 'aria-selected': selected === s.serverName, className: 'dsh-mcp-item' + (selected === s.serverName ? ' sel' : ''), onClick: () => onSelect(s.serverName), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(s.serverName); } } },
811
868
  h(Dot, { phase: dotPhase(s) }),
@@ -813,12 +870,12 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
813
870
  h('div', { className: 'dsh-mcp-item-name' }, s.serverName),
814
871
  h('div', { className: 'dsh-mcp-item-sub' },
815
872
  h(Badge, { cls: s.transport === 'http' ? 't-http' : 't-stdio' }, s.transport === 'http' ? 'HTTP' : 'stdio'),
816
- h('span', null, s.scope === 'workspace' ? wsSub(s) : s.toolCount + ' 工具'),
817
- mountFailed(s) ? h(Badge, { cls: 's-err' }, '挂载失败') : null,
818
- scopeFailed(s) ? h(Badge, { cls: 's-err' }, '作用域故障') : null,
873
+ h('span', null, s.scope === 'workspace' ? wsSub(s) : s.toolCount + t(' 工具')),
874
+ mountFailed(s) ? h(Badge, { cls: 's-err' }, t('挂载失败')) : null,
875
+ scopeFailed(s) ? h(Badge, { cls: 's-err' }, t('作用域故障')) : null,
819
876
  ),
820
877
  ),
821
- h(Toggle, { on: s.enabled, disabled: toggleLocked(s), label: s.serverName + (s.enabled ? ':已启用' : ':已禁用'), title: s.managed ? (s.enabled ? '禁用' : '启用') : '来自其他配置层,只读', onChange: () => onToggle(s) }),
878
+ h(Toggle, { on: s.enabled, disabled: toggleLocked(s), label: s.serverName + (s.enabled ? t(':已启用') : t(':已禁用')), title: s.managed ? (s.enabled ? t('禁用') : t('启用')) : t('来自其他配置层,只读'), onChange: () => onToggle(s) }),
822
879
  ),
823
880
  ),
824
881
  ),
@@ -832,8 +889,8 @@ const EyeToggle = ({ hidden, disabled, onChange, label }) =>
832
889
  className: 'dsh-mcp-eye' + (hidden ? ' off' : ''),
833
890
  disabled,
834
891
  'aria-pressed': !hidden,
835
- 'aria-label': (hidden ? '已屏蔽:' : '可见:') + label,
836
- title: hidden ? '已在此项目屏蔽,点击恢复可见' : '在此项目可见,点击屏蔽',
892
+ 'aria-label': (hidden ? t('已屏蔽:') : t('可见:')) + label,
893
+ title: hidden ? t('已在此项目屏蔽,点击恢复可见') : t('在此项目可见,点击屏蔽'),
837
894
  onClick: (e) => { e.stopPropagation(); onChange(!hidden); },
838
895
  }, h(Icon, { d: ICONS[hidden ? 'eyeOff' : 'eye'], size: 15 }));
839
896
 
@@ -845,12 +902,12 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
845
902
  const query = (q || '').toLowerCase();
846
903
  const list = query ? servers.filter((s) => s.serverName.toLowerCase().includes(query)) : servers;
847
904
  return h(React2.Fragment, null,
848
- servers.length > CONTROLS_THRESHOLD ? h('div', { className: 'dsh-mcp-global-search' }, h(Icon, { d: ICONS.search }), h('input', { placeholder: '搜索全局 MCP…', value: q, onChange: (e) => setQ(e.target.value) })) : null,
905
+ servers.length > CONTROLS_THRESHOLD ? h('div', { className: 'dsh-mcp-global-search' }, h(Icon, { d: ICONS.search }), h('input', { placeholder: t('搜索全局 MCP…'), value: q, onChange: (e) => setQ(e.target.value) })) : null,
849
906
  h('div', { className: 'dsh-mcp-global-list' },
850
907
  servers.length === 0
851
- ? h('div', { className: 'dsh-mcp-empty-list' }, '没有全局 MCP')
908
+ ? h('div', { className: 'dsh-mcp-empty-list' }, t('没有全局 MCP'))
852
909
  : list.length === 0
853
- ? h('div', { className: 'dsh-mcp-empty-list' }, '没有匹配的全局 MCP')
910
+ ? h('div', { className: 'dsh-mcp-empty-list' }, t('没有匹配的全局 MCP'))
854
911
  : list.map((s) => {
855
912
  const isHidden = hidden.has(s.serverName);
856
913
  return h('div', {
@@ -867,8 +924,8 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
867
924
  h('div', { className: 'dsh-mcp-item-name' }, s.serverName),
868
925
  h('div', { className: 'dsh-mcp-item-sub' },
869
926
  h(Badge, { cls: s.transport === 'http' ? 't-http' : 't-stdio' }, s.transport === 'http' ? 'HTTP' : 'stdio'),
870
- h('span', null, s.toolCount + ' 工具'),
871
- isHidden ? h(Badge, { cls: 's-off' }, '已屏蔽') : null,
927
+ h('span', null, s.toolCount + t(' 工具')),
928
+ isHidden ? h(Badge, { cls: 's-off' }, t('已屏蔽')) : null,
872
929
  ),
873
930
  ),
874
931
  // 眼睛 = 可见性,与项目 MCP 的启用滑块在语义与外观上都区分开。
@@ -879,9 +936,10 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
879
936
  );
880
937
  };
881
938
 
939
+ // ── 服务器详情:工具清单、重连、编辑、移除、掩码值显隐 ────────────────────────────
882
940
  const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove, busy, onReveal, revealed, onCopy, excluded, onToggleExclude }) => {
883
941
  if (!server)
884
- return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' }, '选择一个 MCP 查看详情'));
942
+ return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' }, t('选择一个 MCP 查看详情')));
885
943
  const env = server.env || {};
886
944
  const headers = server.headers || {};
887
945
  const envKeys = Object.keys(env);
@@ -906,18 +964,18 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
906
964
  const status = mountFailed ? 'failed' : server.status || server.phase || 'stopped';
907
965
  const statusCls = server.enabled ? (status === 'connected' ? 's-on' : status === 'failed' ? 's-err' : '') : 's-off';
908
966
  const statusText = !server.enabled
909
- ? '已禁用'
967
+ ? t('已禁用')
910
968
  : mountFailed
911
- ? '挂载失败'
969
+ ? t('挂载失败')
912
970
  : scopeFailed
913
- ? '作用域隔离失败'
971
+ ? t('作用域隔离失败')
914
972
  : isWorkspace
915
973
  // 项目行的 status 与全局同一个取值域(deriveMcpPhase),只是文案不同。
916
974
  // 这里只说**连接**那一半事实("在不在");"几个会话在用"由详情页的连接信息与列表行的
917
975
  // 副文案给出——两者刻意不合并,见 wsSub 的注释。
918
976
  // stopped 在项目语境里意为「这条共享连接尚未建立」(新会话挂载时才建)。
919
- ? (status === 'connected' ? '已连接' : status === 'failed' ? '连接失败' : status === 'unknown' ? '状态未知' : status === 'loading' ? '连接中…' : '尚未建立连接(新会话自动挂载)')
920
- : status === 'connected' ? '已连接' : status === 'failed' ? '连接失败' : status === 'unknown' ? '状态未知' : status === 'loading' || status === 'waiting' ? '连接中…' : '已停止';
977
+ ? (status === 'connected' ? t('已连接') : status === 'failed' ? t('连接失败') : status === 'unknown' ? t('状态未知') : status === 'loading' ? t('连接中…') : t('尚未建立连接(新会话自动挂载)'))
978
+ : status === 'connected' ? t('已连接') : status === 'failed' ? t('连接失败') : status === 'unknown' ? t('状态未知') : status === 'loading' || status === 'waiting' ? t('连接中…') : t('已停止');
921
979
  const mut = (v) => {
922
980
  const visible = displayValue(v);
923
981
  return typeof visible === 'string' && visible.length > 90 ? visible.slice(0, 88) + '…' : visible;
@@ -931,8 +989,8 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
931
989
  const revealedNow = state?.value !== undefined;
932
990
  return h('span', { className: 'dsh-mcp-secret' },
933
991
  h('span', { className: 'dsh-mcp-secret-value', title: shown }, shown),
934
- revealedNow ? h('button', { type: 'button', className: 'dsh-mcp-secret-btn', title: '复制 ' + label, 'aria-label': '复制 ' + label, onClick: () => onCopy(shown) }, h(Icon, { d: ICONS.copy })) : null,
935
- masked ? h('button', { type: 'button', className: 'dsh-mcp-secret-btn', disabled: state?.pending, title: revealedNow ? '隐藏 ' + label : '显示 ' + label, 'aria-label': '显示/隐藏 ' + label, onClick: () => onReveal(server, field, keyName) }, state?.pending ? '…' : h(Icon, { d: ICONS[revealedNow ? 'eyeOff' : 'eye'] })) : null,
992
+ revealedNow ? h('button', { type: 'button', className: 'dsh-mcp-secret-btn', title: t('复制 ') + label, 'aria-label': t('复制 ') + label, onClick: () => onCopy(shown) }, h(Icon, { d: ICONS.copy })) : null,
993
+ masked ? h('button', { type: 'button', className: 'dsh-mcp-secret-btn', disabled: state?.pending, title: revealedNow ? t('隐藏 ') + label : t('显示 ') + label, 'aria-label': t('显示/隐藏 ') + label, onClick: () => onReveal(server, field, keyName) }, state?.pending ? '…' : h(Icon, { d: ICONS[revealedNow ? 'eyeOff' : 'eye'] })) : null,
936
994
  );
937
995
  };
938
996
  const RevealRow = ({ field, keyName, label }) =>
@@ -948,60 +1006,61 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
948
1006
  h(Dot, { phase: status }),
949
1007
  h(Badge, { cls: statusCls + ' dot' }, statusText),
950
1008
  h(Badge, { cls: server.transport === 'http' ? 't-http' : 't-stdio' }, server.transport === 'http' ? 'HTTP' : 'stdio'),
951
- h(Badge, { cls: isWorkspace ? 'scope-ws' : '' }, isWorkspace ? '项目配置' : '全局配置'),
952
- isWorkspace ? null : h(Badge, null, server.toolCount + ' 个工具'),
953
- isWorkspace && server.refs > 0 ? h(Badge, null, server.refs + ' 个会话共用') : null,
954
- isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h(Badge, { cls: 's-err' }, 'serverName 同名冲突') : null,
1009
+ h(Badge, { cls: isWorkspace ? 'scope-ws' : '' }, isWorkspace ? t('项目配置') : t('全局配置')),
1010
+ isWorkspace ? null : h(Badge, null, server.toolCount + t(' 个工具')),
1011
+ isWorkspace && server.refs > 0 ? h(Badge, null, server.refs + t(' 个会话共用')) : null,
1012
+ isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h(Badge, { cls: 's-err' }, t('serverName 同名冲突')) : null,
955
1013
  ),
956
1014
  ),
957
1015
  h('div', { className: 'dsh-mcp-d-actions' },
958
1016
  isForeign
959
1017
  ? h(React2.Fragment, null,
960
- h('span', { className: 'dsh-mcp-d-scope-hint' }, excluded ? '已在此项目屏蔽' : '在此项目可见'),
1018
+ h('span', { className: 'dsh-mcp-d-scope-hint' }, excluded ? t('已在此项目屏蔽') : t('在此项目可见')),
961
1019
  h(EyeToggle, { hidden: excluded, disabled: busy, label: server.serverName, onChange: (next) => onToggleExclude(server.serverName, next) }),
962
1020
  )
963
1021
  : h(React2.Fragment, null,
964
- h(Toggle, { on: server.enabled, disabled: busy || !server.managed, label: server.serverName + (server.enabled ? ':已启用' : ':已禁用'), onChange: () => onToggle(server), title: server.managed ? (server.enabled ? '禁用' : '启用') : '来自其他配置层,只读' }),
965
- h(Btn, { icon: 'edit', onClick: () => onEdit(server), disabled: busy || !server.managed, children: '编辑' }),
966
- h(Btn, { icon: 'refresh', onClick: () => onReconnect(server), disabled: busy || !server.enabled || !server.managed, title: isWorkspace ? '重连本项目共享连接(拆掉旧连接并按当前配置重建,不影响其他会话)' : '重连', children: '重连' }),
967
- h(Btn, { cls: 'danger', icon: 'trash', onClick: () => onRemove(server), disabled: busy || !server.managed, children: '移除' }),
1022
+ h(Toggle, { on: server.enabled, disabled: busy || !server.managed, label: server.serverName + (server.enabled ? t(':已启用') : t(':已禁用')), onChange: () => onToggle(server), title: server.managed ? (server.enabled ? t('禁用') : t('启用')) : t('来自其他配置层,只读') }),
1023
+ h(Btn, { icon: 'edit', onClick: () => onEdit(server), disabled: busy || !server.managed, children: t('编辑') }),
1024
+ h(Btn, { icon: 'refresh', onClick: () => onReconnect(server), disabled: busy || !server.enabled || !server.managed, title: isWorkspace ? t('重连本项目共享连接(拆掉旧连接并按当前配置重建,不影响其他会话)') : t('重连'), children: t('重连') }),
1025
+ h(Btn, { cls: 'danger', icon: 'trash', onClick: () => onRemove(server), disabled: busy || !server.managed, children: t('移除') }),
968
1026
  ),
969
1027
  ),
970
1028
  ),
971
- isForeign ? h('div', { className: 'dsh-mcp-log warn' }, '此 MCP 由全局配置提供。要改它的连接配置,请切到「全局」标签页;在这里只能决定它对本项目是否可见。') : null,
1029
+ isForeign ? h('div', { className: 'dsh-mcp-log warn' }, t('此 MCP 由全局配置提供。要改它的连接配置,请切到「全局」标签页;在这里只能决定它对本项目是否可见。')) : null,
972
1030
  // 同名在 DSH 0.1.5 起是合法配置(官方按注册作用域隔离),所以只有这份连接真的失败时才
973
1031
  // 把它摆成原因;成功时不刷屏,更不把一条好连接说成冲突。
974
- isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h('div', { className: 'dsh-mcp-log warn' }, '以下项目也定义了同名 serverName:' + server.duplicateOwners.join('、') + '。DSH 0.1.5 起各注册作用域可独立使用同名;更早的版本按全进程唯一判定,同名会导致其中一份启动失败。') : null,
975
- server.lastError ? h('div', { className: 'dsh-mcp-log', title: '来自 mcp-client 最近日志' }, server.lastError) : null,
976
- server.toolCountAmbiguous ? h('div', { className: 'dsh-mcp-log warn' }, '工具命名与其他 serverName 前缀重叠,无法可靠判断归属;请避免在 serverName 中使用双下划线。') : null,
1032
+ isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h('div', { className: 'dsh-mcp-log warn' }, t('以下项目也定义了同名 serverName:') + server.duplicateOwners.join(t('、')) + t('。DSH 0.1.5 起各注册作用域可独立使用同名;更早的版本按全进程唯一判定,同名会导致其中一份启动失败。')) : null,
1033
+ server.lastError ? h('div', { className: 'dsh-mcp-log', title: t('来自 mcp-client 最近日志') }, server.lastError) : null,
1034
+ server.toolCountAmbiguous ? h('div', { className: 'dsh-mcp-log warn' }, t('工具命名与其他 serverName 前缀重叠,无法可靠判断归属;请避免在 serverName 中使用双下划线。')) : null,
977
1035
  h('div', { className: 'dsh-mcp-card' },
978
- h('h4', null, '连接信息'),
1036
+ h('h4', null, t('连接信息')),
979
1037
  h('div', { className: 'dsh-mcp-kv' },
980
- h('b', null, '传输'), h('span', null, server.transport === 'http' ? 'Streamable HTTP' : 'stdio'),
1038
+ h('b', null, t('传输')), h('span', null, server.transport === 'http' ? 'Streamable HTTP' : 'stdio'),
981
1039
  server.url ? h('b', null, 'URL') : null, server.url ? h(RevealBtn, { field: 'url', label: 'URL', fallback: server.url }) : null,
982
- server.command ? h('b', null, '命令') : null, server.command ? h('span', null, mut(server.command)) : null,
983
- server.args && server.args.length ? h('b', null, '参数') : null, server.args && server.args.length ? h(RevealBtn, { field: 'args', label: '参数', fallback: server.args.map(mut).join(' ') }) : null,
984
- server.cwd ? h('b', null, '工作目录') : null, server.cwd ? h('span', null, mut(server.cwd)) : null,
985
- h('b', null, '启用'), h('span', null, server.enabled ? '是' : '否(补丁中 disabled: true)'),
986
- h('b', null, '配置来源'), h('span', null, server.conflict ? '存在重复 serverName(冲突,只读)' : isForeign ? '全局配置(在「全局」标签页可编辑)' : isWorkspace ? '项目配置 .dsh/mcp.json(可编辑,本项目会话共享一份连接)' : server.managed ? '当前 Web profile(可编辑)' : '其他 bundle / Agent(只读)'),
987
- server.toolCallTimeoutMs ? h('b', null, '调用超时') : null, server.toolCallTimeoutMs ? h('span', null, server.toolCallTimeoutMs + ' ms') : null,
988
- h('b', null, '启动失败策略'), h('span', null, server.failOnStartupError ? '阻止插件启动' : '记录错误并继续'),
989
- server.reconnect ? h('b', null, '自动重连') : null, server.reconnect ? h('span', null, server.reconnect.enabled === false ? '关闭' : `${server.reconnect.initialDelayMs || 500}–${server.reconnect.maxDelayMs || 30000} ms,最多 ${server.reconnect.maxAttempts || 10} 次`) : null,
1040
+ server.command ? h('b', null, t('命令')) : null, server.command ? h('span', null, mut(server.command)) : null,
1041
+ server.args && server.args.length ? h('b', null, t('参数')) : null, server.args && server.args.length ? h(RevealBtn, { field: 'args', label: t('参数'), fallback: server.args.map(mut).join(' ') }) : null,
1042
+ server.cwd ? h('b', null, t('工作目录')) : null, server.cwd ? h('span', null, mut(server.cwd)) : null,
1043
+ h('b', null, t('启用')), h('span', null, server.enabled ? t('是') : t('否(补丁中 disabled: true)')),
1044
+ h('b', null, t('配置来源')), h('span', null, server.conflict ? t('存在重复 serverName(冲突,只读)') : isForeign ? t('全局配置(在「全局」标签页可编辑)') : isWorkspace ? t('项目配置 .dsh/mcp.json(可编辑,本项目会话共享一份连接)') : server.managed ? t('当前 Web profile(可编辑)') : t('其他 bundle / Agent(只读)')),
1045
+ server.toolCallTimeoutMs ? h('b', null, t('调用超时')) : null, server.toolCallTimeoutMs ? h('span', null, server.toolCallTimeoutMs + ' ms') : null,
1046
+ h('b', null, t('启动失败策略')), h('span', null, server.failOnStartupError ? t('阻止插件启动') : t('记录错误并继续')),
1047
+ server.reconnect ? h('b', null, t('自动重连')) : null, server.reconnect ? h('span', null, server.reconnect.enabled === false ? t('关闭') : t('{0}–{1} ms,最多 {2} 次', [server.reconnect.initialDelayMs || 500, server.reconnect.maxDelayMs || 30000, server.reconnect.maxAttempts || 10])) : null,
990
1048
  headerKeys.length ? h('b', null, 'Headers') : null, headerKeys.length ? h('div', { className: 'dsh-mcp-secret-list' }, headerKeys.map((k) => h(RevealRow, { key: k, field: 'headers', keyName: k, label: 'Header ' + k }))) : null,
991
- envKeys.length ? h('b', null, '环境变量') : null, envKeys.length ? h('div', { className: 'dsh-mcp-secret-list' }, envKeys.map((k) => h(RevealRow, { key: k, field: 'env', keyName: k, label: '环境变量 ' + k }))) : null,
1049
+ envKeys.length ? h('b', null, t('环境变量')) : null, envKeys.length ? h('div', { className: 'dsh-mcp-secret-list' }, envKeys.map((k) => h(RevealRow, { key: k, field: 'env', keyName: k, label: t('环境变量 ') + k }))) : null,
992
1050
  ),
993
1051
  ),
994
1052
  h('div', { className: 'dsh-mcp-card' },
995
- h('h4', null, '工具(' + tools.length + ')'),
1053
+ h('h4', null, t('工具(') + tools.length + t(')')),
996
1054
  h('div', { className: 'dsh-mcp-tools' },
997
1055
  tools.length === 0
998
- ? h('div', { className: 'dsh-mcp-empty-list' }, server.toolCountAmbiguous ? '工具归属存在命名歧义' : server.scope === 'workspace' ? '尚无会话持有该项目连接,或连接尚未就绪' : '暂无工具')
1056
+ ? h('div', { className: 'dsh-mcp-empty-list' }, server.toolCountAmbiguous ? t('工具归属存在命名歧义') : server.scope === 'workspace' ? t('尚无会话持有该项目连接,或连接尚未就绪') : t('暂无工具'))
999
1057
  : tools.map((t) => h(ToolRow, { key: t.name, tool: t })),
1000
1058
  ),
1001
1059
  ),
1002
1060
  );
1003
1061
  };
1004
1062
 
1063
+ // ── 新增 / 编辑服务器弹窗:表单校验与 secret 字段遮蔽 ────────────────────────────
1005
1064
  const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, revealEpoch, scopeName }) => {
1006
1065
  const dialogRef = useRef(null);
1007
1066
  const [name, setName] = useState('');
@@ -1113,7 +1172,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1113
1172
  };
1114
1173
  const submit = () => {
1115
1174
  const n = (name || '').trim();
1116
- if (!n) return setError('请填写 MCP 名称');
1175
+ if (!n) return setError(t('请填写 MCP 名称'));
1117
1176
  const spec = { name: n, transport };
1118
1177
  if (transport === 'http') {
1119
1178
  spec.url = fromFormValue(url.trim());
@@ -1133,9 +1192,9 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1133
1192
  const initialDelay = Number(initialDelayMs);
1134
1193
  const maxDelay = Number(maxDelayMs);
1135
1194
  const attempts = Number(maxAttempts);
1136
- if (!Number.isSafeInteger(timeout) || timeout <= 0 || !Number.isSafeInteger(attempts) || attempts <= 0) return setError('工具超时和最大尝试次数必须是正整数');
1137
- if (!Number.isFinite(initialDelay) || initialDelay <= 0 || initialDelay > MAX_TIMER_DELAY_MS || !Number.isFinite(maxDelay) || maxDelay <= 0 || maxDelay > MAX_TIMER_DELAY_MS) return setError('重连延迟必须是有效正数且不超过 2147483647 ms');
1138
- if (initialDelay > maxDelay) return setError('首次延迟不能大于最大延迟');
1195
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || !Number.isSafeInteger(attempts) || attempts <= 0) return setError(t('工具超时和最大尝试次数必须是正整数'));
1196
+ if (!Number.isFinite(initialDelay) || initialDelay <= 0 || initialDelay > MAX_TIMER_DELAY_MS || !Number.isFinite(maxDelay) || maxDelay <= 0 || maxDelay > MAX_TIMER_DELAY_MS) return setError(t('重连延迟必须是有效正数且不超过 2147483647 ms'));
1197
+ if (initialDelay > maxDelay) return setError(t('首次延迟不能大于最大延迟'));
1139
1198
  spec.toolCallTimeoutMs = timeout;
1140
1199
  spec.failOnStartupError = failOnStartupError;
1141
1200
  spec.reconnect = { enabled: reconnectEnabled, initialDelayMs: initialDelay, maxDelayMs: maxDelay, maxAttempts: attempts };
@@ -1152,27 +1211,27 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1152
1211
  const setKvRows = transport === 'http' ? setHeaders : setEnvs;
1153
1212
  const revealIcon = (id, label, onClick) => {
1154
1213
  const shown = Object.hasOwn(editRevealed, id);
1155
- return h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-inline-icon', disabled: revealBusy === id, title: (shown ? '隐藏原值:' : '显示原值:') + label, 'aria-label': (shown ? '隐藏原值:' : '显示原值:') + label, onClick }, revealBusy === id ? '…' : h(Icon, { d: ICONS[shown ? 'eyeOff' : 'eye'] }));
1214
+ return h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-inline-icon', disabled: revealBusy === id, title: (shown ? t('隐藏原值:') : t('显示原值:')) + label, 'aria-label': (shown ? t('隐藏原值:') : t('显示原值:')) + label, onClick }, revealBusy === id ? '…' : h(Icon, { d: ICONS[shown ? 'eyeOff' : 'eye'] }));
1156
1215
  };
1157
1216
  return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && onClose() },
1158
- h('div', { ref: dialogRef, className: 'dsh-mcp-modal', role: 'dialog', 'aria-modal': true, 'aria-label': editing ? '编辑 MCP' : '添加 MCP', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } } },
1159
- h('h3', null, (editing ? '编辑 MCP' : '添加 MCP') + (scopeName && scopeName !== '全局' ? '(' + scopeName + ')' : '')),
1217
+ h('div', { ref: dialogRef, className: 'dsh-mcp-modal', role: 'dialog', 'aria-modal': true, 'aria-label': editing ? t('编辑 MCP') : t('添加 MCP'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } } },
1218
+ h('h3', null, (editing ? t('编辑 MCP') : t('添加 MCP')) + (scopeName && scopeName !== t('全局') ? t('(') + scopeName + t(')') : '')),
1160
1219
  !editing ? h('div', { className: 'dsh-mcp-field' },
1161
- h('label', null, '从预设模板快速填充(可选,命令需本机 npx/uvx)'),
1162
- h('select', { value: '', onChange: (e) => applyPreset(e.target.value), 'aria-label': '选择预设模板' },
1163
- h('option', { value: '' }, '— 选择预设 —'),
1220
+ h('label', null, t('从预设模板快速填充(可选,命令需本机 npx/uvx)')),
1221
+ h('select', { value: '', onChange: (e) => applyPreset(e.target.value), 'aria-label': t('选择预设模板') },
1222
+ h('option', { value: '' }, t('— 选择预设 —')),
1164
1223
  MCP_PRESETS.map((p) => h('option', { key: p.id, value: p.id }, p.label)),
1165
1224
  ),
1166
1225
  ) : null,
1167
1226
  h('div', { className: 'dsh-mcp-field' },
1168
- h('label', null, 'MCP 名称(唯一标识,不可改)'),
1227
+ h('label', null, t('MCP 名称(唯一标识,不可改)')),
1169
1228
  h('input', { value: name, disabled: editing, onChange: (e) => setName(e.target.value), placeholder: 'my-server' }),
1170
1229
  ),
1171
1230
  h('div', { className: 'dsh-mcp-field' },
1172
- h('label', null, '传输类型'),
1231
+ h('label', null, t('传输类型')),
1173
1232
  h('div', { className: 'dsh-mcp-radio-row' },
1174
- h('label', { className: transport === 'http' ? 'sel' : '' }, h('input', { type: 'radio', checked: transport === 'http', onChange: () => setTransport('http') }), 'HTTP(远程)'),
1175
- h('label', { className: transport === 'stdio' ? 'sel' : '' }, h('input', { type: 'radio', checked: transport === 'stdio', onChange: () => setTransport('stdio') }), 'stdio(本地进程)'),
1233
+ h('label', { className: transport === 'http' ? 'sel' : '' }, h('input', { type: 'radio', checked: transport === 'http', onChange: () => setTransport('http') }), t('HTTP(远程)')),
1234
+ h('label', { className: transport === 'stdio' ? 'sel' : '' }, h('input', { type: 'radio', checked: transport === 'stdio', onChange: () => setTransport('stdio') }), t('stdio(本地进程)')),
1176
1235
  ),
1177
1236
  ),
1178
1237
  transport === 'http'
@@ -1184,68 +1243,60 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1184
1243
  ),
1185
1244
  )
1186
1245
  : h('div', null,
1187
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '命令'), h('input', { value: command, onChange: (e) => setCommand(e.target.value), placeholder: 'npx 或 C:\\path\\to\\bin.exe' })),
1246
+ h('div', { className: 'dsh-mcp-field' }, h('label', null, t('命令')), h('input', { value: command, onChange: (e) => setCommand(e.target.value), placeholder: t('npx 或 C:\\path\\to\\bin.exe') })),
1188
1247
  h('div', { className: 'dsh-mcp-field' },
1189
- h('label', null, '参数(每行一个,原样传给进程)'),
1248
+ h('label', null, t('参数(每行一个,原样传给进程)')),
1190
1249
  h('div', { className: 'dsh-mcp-input-action' },
1191
1250
  h('textarea', { rows: 4, value: visibleEditValue('args', undefined, args), onChange: (e) => { hideEditReveal('args'); setArgs(e.target.value); }, placeholder: '-y\nmcp-server@latest' }),
1192
- editing && args === PRESERVED_VALUE_LABEL ? revealIcon('args', '参数', () => toggleEditReveal('args')) : null,
1251
+ editing && args === PRESERVED_VALUE_LABEL ? revealIcon('args', t('参数'), () => toggleEditReveal('args')) : null,
1193
1252
  ),
1194
1253
  ),
1195
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '工作目录 cwd(可选)'), h('input', { value: cwd, onChange: (e) => setCwd(e.target.value), placeholder: 'C:\\path\\to\\workspace' })),
1254
+ 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' })),
1196
1255
  ),
1197
1256
  h('div', { className: 'dsh-mcp-field' },
1198
- h('label', null, transport === 'http' ? 'Headers(可选)' : '环境变量(可选,值写 !!js process.env.KEY 引用密钥)'),
1257
+ h('label', null, transport === 'http' ? t('Headers(可选)') : t('环境变量(可选,值写 !!js process.env.KEY 引用密钥)')),
1199
1258
  kvRows.map((row, i) => {
1200
1259
  const field = transport === 'http' ? 'headers' : 'env';
1201
1260
  const originalKey = row.originalKey || row.k;
1202
1261
  const id = editRevealId(field, originalKey);
1203
1262
  return h('div', { key: i, className: 'dsh-mcp-kv-edit' },
1204
1263
  h('input', { placeholder: transport === 'http' ? 'Authorization' : 'API_KEY', value: row.k, onChange: (e) => { hideEditReveal(field, originalKey); kv(kvRows, setKvRows, i, 'k', e.target.value); } }),
1205
- h('input', { placeholder: transport === 'http' ? '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); } }),
1264
+ 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); } }),
1206
1265
  h('div', { className: 'dsh-mcp-kv-row-actions' },
1207
1266
  editing && row.v === PRESERVED_VALUE_LABEL ? revealIcon(id, row.k, () => toggleEditReveal(field, originalKey)) : null,
1208
- h('button', { type: 'button', className: 'dsh-mcp-kv-del', onClick: () => delRow(kvRows, setKvRows, i), title: '删除此项', 'aria-label': '删除 ' + (row.k || '配置项') }, '✕'),
1267
+ h('button', { type: 'button', className: 'dsh-mcp-kv-del', onClick: () => delRow(kvRows, setKvRows, i), title: t('删除此项'), 'aria-label': t('删除 ') + (row.k || t('配置项')) }, '✕'),
1209
1268
  ),
1210
1269
  );
1211
1270
  }),
1212
- h('div', { className: 'dsh-mcp-kv-add' }, h(Btn, { onClick: () => addRow(kvRows, setKvRows), children: '+ 添加' })),
1271
+ h('div', { className: 'dsh-mcp-kv-add' }, h(Btn, { onClick: () => addRow(kvRows, setKvRows), children: t('+ 添加') })),
1213
1272
  ),
1214
1273
  h('div', { className: 'dsh-mcp-card dsh-mcp-advanced' },
1215
- h('h4', null, '运行策略'),
1216
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '工具调用超时(毫秒)'), h('input', { type: 'number', min: 1, value: toolCallTimeoutMs, onChange: (e) => setToolCallTimeoutMs(e.target.value) })),
1217
- h('label', { className: 'dsh-mcp-check' }, h('input', { type: 'checkbox', checked: failOnStartupError, onChange: (e) => setFailOnStartupError(e.target.checked) }), '首次连接失败时阻止插件启动'),
1218
- h('label', { className: 'dsh-mcp-check' }, h('input', { type: 'checkbox', checked: reconnectEnabled, onChange: (e) => setReconnectEnabled(e.target.checked) }), '断开后自动重连'),
1274
+ h('h4', null, t('运行策略')),
1275
+ h('div', { className: 'dsh-mcp-field' }, h('label', null, t('工具调用超时(毫秒)')), h('input', { type: 'number', min: 1, value: toolCallTimeoutMs, onChange: (e) => setToolCallTimeoutMs(e.target.value) })),
1276
+ h('label', { className: 'dsh-mcp-check' }, h('input', { type: 'checkbox', checked: failOnStartupError, onChange: (e) => setFailOnStartupError(e.target.checked) }), t('首次连接失败时阻止插件启动')),
1277
+ h('label', { className: 'dsh-mcp-check' }, h('input', { type: 'checkbox', checked: reconnectEnabled, onChange: (e) => setReconnectEnabled(e.target.checked) }), t('断开后自动重连')),
1219
1278
  h('div', { className: 'dsh-mcp-number-grid' },
1220
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '首次延迟 ms'), h('input', { type: 'number', min: 1, step: 'any', value: initialDelayMs, onChange: (e) => setInitialDelayMs(e.target.value) })),
1221
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '最大延迟 ms'), h('input', { type: 'number', min: 1, step: 'any', value: maxDelayMs, onChange: (e) => setMaxDelayMs(e.target.value) })),
1222
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '最大尝试次数'), h('input', { type: 'number', min: 1, value: maxAttempts, onChange: (e) => setMaxAttempts(e.target.value) })),
1279
+ h('div', { className: 'dsh-mcp-field' }, h('label', null, t('首次延迟 ms')), h('input', { type: 'number', min: 1, step: 'any', value: initialDelayMs, onChange: (e) => setInitialDelayMs(e.target.value) })),
1280
+ h('div', { className: 'dsh-mcp-field' }, h('label', null, t('最大延迟 ms')), h('input', { type: 'number', min: 1, step: 'any', value: maxDelayMs, onChange: (e) => setMaxDelayMs(e.target.value) })),
1281
+ h('div', { className: 'dsh-mcp-field' }, h('label', null, t('最大尝试次数')), h('input', { type: 'number', min: 1, value: maxAttempts, onChange: (e) => setMaxAttempts(e.target.value) })),
1223
1282
  ),
1224
1283
  ),
1225
1284
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1226
1285
  h('div', { className: 'dsh-mcp-modal-actions' },
1227
- h(Btn, { onClick: onClose, children: '取消' }),
1228
- h(Btn, { cls: 'primary', onClick: submit, disabled: busy, children: busy ? '保存中…' : (editing ? '保存' : '添加') }),
1286
+ h(Btn, { onClick: onClose, children: t('取消') }),
1287
+ h(Btn, { cls: 'primary', onClick: submit, disabled: busy, children: busy ? t('保存中…') : (editing ? t('保存') : t('添加')) }),
1229
1288
  ),
1230
1289
  ),
1231
1290
  );
1232
1291
  };
1233
1292
 
1234
- function builtinConfigurationText(item) {
1235
- const config = item?.configuration || {};
1236
- if (config.url) {
1237
- const headers = Object.entries(config.headers || {}).map(([key, value]) => `${key}: ${value}`);
1238
- return [config.url, ...headers].join(' · ');
1239
- }
1240
- return [config.command, ...(config.args || [])].filter(Boolean).join(' ');
1241
- }
1242
-
1243
1293
  const sourceImportNames = (row) => (row.entries || []).filter((entry) => !entry.error).map((entry) => entry.name);
1244
1294
  // 只在界面上提示「这条来源带了会被掩码的字段」。具体值一律留在 Host:Host 只给字段名。
1245
1295
  const sourceMaskedFields = (row) => [...new Set((row.entries || []).flatMap((entry) => entry.maskedFields || []))];
1246
1296
  // 环境变量名不算秘密(值才是),但缺值会让导入后的那个位置变成空字符串,得先说。
1247
1297
  const sourceMissingEnv = (row) => [...new Set((row.entries || []).flatMap((entry) => entry.missingEnv || []))];
1248
1298
 
1299
+ // ── JSON 导入弹窗:多来源选择、错误分组、掩码字段提示 ────────────────────────────
1249
1300
  const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope, scopeName }) => {
1250
1301
  const isWorkspace = scope !== 'global' && scope !== undefined;
1251
1302
  const dialogRef = useRef(null);
@@ -1267,7 +1318,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1267
1318
  setSourcesLoading(true); setSourcesError('');
1268
1319
  try {
1269
1320
  const res = await call('scanImportSources', isWorkspace ? { wsPath: scope } : {});
1270
- if (!res?.ok) throw new Error(res?.error || '读取本机客户端配置失败');
1321
+ if (!res?.ok) throw new Error(res?.error || t('读取本机客户端配置失败'));
1271
1322
  setSources(res.sources || []);
1272
1323
  } catch (e) { setSourcesError(String(e?.message || e)); }
1273
1324
  finally { setSourcesLoading(false); }
@@ -1284,11 +1335,11 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1284
1335
  // 选中来源后,来源导入优先于粘贴内容:两者不会同时在生效状态。
1285
1336
  const pickSource = async (row) => {
1286
1337
  const names = sourceImportNames(row);
1287
- if (row.error || !names.length) { setError(row.error || '这条配置里没有可导入的 MCP'); return; }
1338
+ if (row.error || !names.length) { setError(row.error || t('这条配置里没有可导入的 MCP')); return; }
1288
1339
  setBusy(true); setError(''); setPreview(null); clearSource();
1289
1340
  try {
1290
1341
  const res = await call('previewImportSource', { sourceId: row.sourceId, scope: row.scope, names, ...scopePayload });
1291
- if (!res?.ok) throw new Error(res?.error || '预览失败');
1342
+ if (!res?.ok) throw new Error(res?.error || t('预览失败'));
1292
1343
  setSource(row); setSourcePreview(res);
1293
1344
  } catch (e) { setError(String(e?.message || e)); }
1294
1345
  finally { setBusy(false); }
@@ -1299,7 +1350,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1299
1350
  const res = isWorkspace
1300
1351
  ? await call('previewWorkspaceImport', { wsPath: scope, json: text, mode })
1301
1352
  : await call('previewImport', { json: text, mode });
1302
- if (!res?.ok) throw new Error(res?.error || 'JSON 解析失败');
1353
+ if (!res?.ok) throw new Error(res?.error || t('JSON 解析失败'));
1303
1354
  setPreview(res);
1304
1355
  } catch (e) { setError(String(e?.message || e)); }
1305
1356
  finally { setBusy(false); }
@@ -1311,18 +1362,18 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1311
1362
  setBusy(true); setError('');
1312
1363
  try {
1313
1364
  const res = await run();
1314
- if (!res?.ok) throw new Error(res?.error || '导入失败');
1365
+ if (!res?.ok) throw new Error(res?.error || t('导入失败'));
1315
1366
  onImported(res);
1316
1367
  onClose();
1317
1368
  } catch (e) { setError(String(e?.message || e)); }
1318
1369
  finally { setBusy(false); }
1319
1370
  };
1320
- const replaceWarning = '替换会删除' + (isWorkspace ? '该项目' : '当前 Web profile');
1371
+ const replaceWarning = t('替换会删除') + (isWorkspace ? t('该项目') : t('当前 Web profile'));
1321
1372
  const applyImport = async () => {
1322
1373
  if (!preview) return runPreview();
1323
1374
  // 同名冲突不再让整批失败:Host 会跳过那几条并在预览的提示里写明,用户按需处理。
1324
1375
  return submitImport(
1325
- mode === 'replace' ? replaceWarning + '中未出现在 JSON 里的 MCP。确认继续?' : '',
1376
+ mode === 'replace' ? replaceWarning + t('中未出现在 JSON 里的 MCP。确认继续?') : '',
1326
1377
  () => (isWorkspace
1327
1378
  ? call('importWorkspaceJson', { wsPath: scope, json: text, mode })
1328
1379
  : call('importJson', { json: text, mode })),
@@ -1331,7 +1382,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1331
1382
  const applySourceImport = async () => {
1332
1383
  if (!source || !sourcePreview) return;
1333
1384
  return submitImport(
1334
- mode === 'replace' ? replaceWarning + '中未出现在这份来源里的 MCP。确认继续?' : '',
1385
+ mode === 'replace' ? replaceWarning + t('中未出现在这份来源里的 MCP。确认继续?') : '',
1335
1386
  // 送出的是来源标识 + 条目名 + 预览时的内容指纹;路径与凭据都留在 Host 重算。
1336
1387
  () => call('importSource', {
1337
1388
  sourceId: source.sourceId,
@@ -1345,25 +1396,25 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1345
1396
  };
1346
1397
  const activePreview = sourcePreview?.preview || preview;
1347
1398
  const summary = activePreview ? [
1348
- '新增:' + (activePreview.added?.join(', ') || '无'),
1349
- '更新:' + (activePreview.updated?.join(', ') || '无'),
1350
- mode === 'replace' ? '移除:' + (activePreview.removed?.join(', ') || '无') : null,
1399
+ t('新增:') + (activePreview.added?.join(', ') || t('无')),
1400
+ t('更新:') + (activePreview.updated?.join(', ') || t('无')),
1401
+ mode === 'replace' ? t('移除:') + (activePreview.removed?.join(', ') || t('无')) : null,
1351
1402
  // 被跳过的同名条目(其他配置层占用、或来源里重名)由 Host 放进 warnings,这里只渲染一处,
1352
1403
  // 免得同一件事既显示"冲突"又显示"提示"。
1353
- ...(activePreview.warnings || []).map((warning) => '提示:' + warning),
1404
+ ...(activePreview.warnings || []).map((warning) => t('提示:') + warning),
1354
1405
  ].filter(Boolean).join('\n') : '';
1355
1406
  // 来源列表已按当前层过滤(Host 侧保证):全局标签只有全局来源,项目标签只有该项目的来源,
1356
1407
  // 所以行上不需要「(项目)」后缀,也不再需要用户自己判断「这一行会导到哪儿去」。
1357
- const layerLabel = isWorkspace ? '项目级' : '全局';
1408
+ const layerLabel = isWorkspace ? t('项目级') : t('全局');
1358
1409
  const layerHint = isWorkspace
1359
- ? '没检测到这个项目的项目级 MCP 配置(.mcp.json / .codex/config.toml / .pi/mcp.json / opencode.json 等),可在下面手动粘贴'
1360
- : '没检测到本机客户端的全局 MCP 配置(~/.claude.json / ~/.codex/config.toml / ~/.config/opencode/opencode.json 等),可在下面手动粘贴';
1410
+ ? t('没检测到这个项目的项目级 MCP 配置(.mcp.json / .codex/config.toml / .pi/mcp.json / opencode.json 等),可在下面手动粘贴')
1411
+ : t('没检测到本机客户端的全局 MCP 配置(~/.claude.json / ~/.codex/config.toml / ~/.config/opencode/opencode.json 等),可在下面手动粘贴');
1361
1412
  return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && onClose() },
1362
- h('div', { ref: dialogRef, className: 'dsh-mcp-modal', role: 'dialog', 'aria-modal': true, 'aria-label': '导入 MCP', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } } },
1363
- h('h3', null, '导入 MCP' + (isWorkspace && scopeName && scopeName !== '全局' ? '(' + scopeName + ')' : '')),
1413
+ h('div', { ref: dialogRef, className: 'dsh-mcp-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('导入 MCP'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } } },
1414
+ h('h3', null, t('导入 MCP') + (isWorkspace && scopeName && scopeName !== t('全局') ? t('(') + scopeName + t(')') : '')),
1364
1415
  h('div', { className: 'dsh-mcp-field dsh-mcp-source-box' },
1365
- h('label', null, '从本机客户端导入' + layerLabel + '配置(只读,不修改它们的文件)'),
1366
- sourcesLoading ? h('div', { className: 'dsh-mcp-source-detail' }, '正在扫描本机客户端配置…') : null,
1416
+ h('label', null, t('从本机客户端导入') + layerLabel + t('配置(只读,不修改它们的文件)')),
1417
+ sourcesLoading ? h('div', { className: 'dsh-mcp-source-detail' }, t('正在扫描本机客户端配置…')) : null,
1367
1418
  sourcesError ? h('div', { className: 'dsh-mcp-d-err' }, sourcesError) : null,
1368
1419
  !sourcesLoading && !sourcesError && !sources.length
1369
1420
  ? h('div', { className: 'dsh-mcp-source-detail' }, layerHint)
@@ -1372,7 +1423,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1372
1423
  const names = sourceImportNames(row);
1373
1424
  const masked = sourceMaskedFields(row);
1374
1425
  const missingEnv = sourceMissingEnv(row);
1375
- const detail = row.error || (names.length ? names.length + ' 个 MCP:' + names.join(', ') : '没有可导入的 MCP');
1426
+ const detail = row.error || (names.length ? names.length + t(' 个 MCP:') + names.join(', ') : t('没有可导入的 MCP'));
1376
1427
  return h('button', {
1377
1428
  key: row.key,
1378
1429
  type: 'button',
@@ -1386,29 +1437,29 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1386
1437
  h('code', { className: 'dsh-mcp-source-path' }, row.displayPath),
1387
1438
  ),
1388
1439
  h('span', { className: 'dsh-mcp-source-detail' }, detail),
1389
- masked.length ? h('span', { className: 'dsh-mcp-source-note' }, '含需掩码字段:' + masked.join('、') + '(导入后在详情页可用眼睛查看)') : null,
1390
- missingEnv.length ? h('span', { className: 'dsh-mcp-source-warn' }, '环境变量未设置:' + missingEnv.join('、') + '(导入后该处会得到空值)') : null,
1391
- row.warnings?.length ? h('span', { className: 'dsh-mcp-source-warn' }, row.warnings.length + ' 条提示,导入时逐条列出') : null,
1440
+ masked.length ? h('span', { className: 'dsh-mcp-source-note' }, t('含需掩码字段:') + masked.join(t('、')) + t('(导入后在详情页可用眼睛查看)')) : null,
1441
+ missingEnv.length ? h('span', { className: 'dsh-mcp-source-warn' }, t('环境变量未设置:') + missingEnv.join(t('、')) + t('(导入后该处会得到空值)')) : null,
1442
+ row.warnings?.length ? h('span', { className: 'dsh-mcp-source-warn' }, row.warnings.length + t(' 条提示,导入时逐条列出')) : null,
1392
1443
  );
1393
1444
  })) : null,
1394
1445
  ),
1395
1446
  h('div', { className: 'dsh-mcp-field' },
1396
- h('label', null, '或粘贴 JSON:支持 Claude/Cursor/Cline/Roo 的 mcpServers、VS Code 的 servers,以及单个 MCP 对象'),
1447
+ h('label', null, t('或粘贴 JSON:支持 Claude/Cursor/Cline/Roo 的 mcpServers、VS Code 的 servers,以及单个 MCP 对象')),
1397
1448
  h('textarea', { className: 'dsh-mcp-import-json', value: text, onChange: (e) => { setText(e.target.value); setPreview(null); clearSource(); }, placeholder: '{\n "mcpServers": {\n "example": { "command": "npx", "args": ["-y", "example-mcp"] }\n }\n}' }),
1398
1449
  ),
1399
1450
  h('div', { className: 'dsh-mcp-radio-row' },
1400
- h('label', { className: mode === 'merge' ? 'sel' : '' }, h('input', { type: 'radio', checked: mode === 'merge', onChange: () => { setMode('merge'); setPreview(null); } }), '合并(同名更新)'),
1401
- h('label', { className: mode === 'replace' ? 'sel' : '' }, h('input', { type: 'radio', checked: mode === 'replace', onChange: () => { setMode('replace'); setPreview(null); } }), isWorkspace ? '替换该项目' : '替换当前 Profile'),
1451
+ h('label', { className: mode === 'merge' ? 'sel' : '' }, h('input', { type: 'radio', checked: mode === 'merge', onChange: () => { setMode('merge'); setPreview(null); } }), t('合并(同名更新)')),
1452
+ h('label', { className: mode === 'replace' ? 'sel' : '' }, h('input', { type: 'radio', checked: mode === 'replace', onChange: () => { setMode('replace'); setPreview(null); } }), isWorkspace ? t('替换该项目') : t('替换当前 Profile')),
1402
1453
  ),
1403
1454
  summary ? h('div', { className: 'dsh-mcp-card dsh-mcp-import-preview' }, summary) : null,
1404
1455
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1405
1456
  h('div', { className: 'dsh-mcp-modal-actions' },
1406
- h(Btn, { onClick: onClose, children: '取消' }),
1407
- source ? h(Btn, { onClick: () => { clearSource(); setError(''); }, disabled: busy, children: '取消选择来源' }) : null,
1457
+ h(Btn, { onClick: onClose, children: t('取消') }),
1458
+ source ? h(Btn, { onClick: () => { clearSource(); setError(''); }, disabled: busy, children: t('取消选择来源') }) : null,
1408
1459
  source
1409
- ? h(Btn, { cls: 'primary', onClick: applySourceImport, disabled: busy || !sourcePreview, children: busy ? '导入中…' : '确认从来源导入' })
1410
- : h(Btn, { onClick: runPreview, disabled: busy || !text.trim(), children: busy ? '解析中…' : '预览' }),
1411
- source ? null : h(Btn, { cls: 'primary', onClick: applyImport, disabled: busy || !text.trim(), children: preview ? '确认导入' : '解析并预览' }),
1460
+ ? h(Btn, { cls: 'primary', onClick: applySourceImport, disabled: busy || !sourcePreview, children: busy ? t('导入中…') : t('确认从来源导入') })
1461
+ : h(Btn, { onClick: runPreview, disabled: busy || !text.trim(), children: busy ? t('解析中…') : t('预览') }),
1462
+ source ? null : h(Btn, { cls: 'primary', onClick: applyImport, disabled: busy || !text.trim(), children: preview ? t('确认导入') : t('解析并预览') }),
1412
1463
  ),
1413
1464
  ),
1414
1465
  );
@@ -1418,9 +1469,20 @@ const POLL_INTERVAL_MS = 5000;
1418
1469
 
1419
1470
  // 面板靠轮询反映 MCP 连接状态。标签页不可见时没人看这份数据,却照样每 5s 打两个 RPC,
1420
1471
  // 因此隐藏期间跳过拉取,并在重新可见时立刻补一次,避免用户回来先看到一屏陈旧状态。
1472
+ //
1473
+ // `timer` 可缺省:有些调用点(输入框菜单、会话头徽章)拿不到面板的 timer 对象。缺省时退回
1474
+ // 裸 setInterval/clearInterval——生命周期仍由调用方的 effect cleanup(返回的 stop 函数)
1475
+ // 独占管理,所以只是少了一层「面板卸载时统一清理」的保护,不会泄漏。
1476
+ const fallbackTimer = {
1477
+ interval(fn, ms) {
1478
+ const id = setInterval(fn, ms);
1479
+ return () => clearInterval(id);
1480
+ },
1481
+ };
1421
1482
  function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
1422
1483
  const visible = () => !doc || doc.visibilityState !== 'hidden';
1423
- const disposeInterval = timer.interval(() => { if (visible()) run(); }, intervalMs);
1484
+ const host = timer && typeof timer.interval === 'function' ? timer : fallbackTimer;
1485
+ const disposeInterval = host.interval(() => { if (visible()) run(); }, intervalMs);
1424
1486
  if (!doc || typeof doc.addEventListener !== 'function') return disposeInterval;
1425
1487
  const onVisibilityChange = () => { if (visible()) run(); };
1426
1488
  doc.addEventListener('visibilitychange', onVisibilityChange);
@@ -1430,6 +1492,7 @@ function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
1430
1492
  };
1431
1493
  }
1432
1494
 
1495
+ // ── MCP 主面板(McpTab):服务器 + 工具 + 可见性感知轮询 ──────────────────────────
1433
1496
  const McpTab = (props) => {
1434
1497
  const { call, onClose, floating, timer, pluginManager } = props;
1435
1498
  const panelRef = useRef(null);
@@ -1502,7 +1565,7 @@ const McpTab = (props) => {
1502
1565
  revisionRef.current = res.revision || '';
1503
1566
  setServers(next);
1504
1567
  }
1505
- if (notify) flash('已刷新项目 MCP,共 ' + next.length + ' 个');
1568
+ if (notify) flash(t('已刷新项目 MCP,共 ') + next.length + t(' 个'));
1506
1569
  } else {
1507
1570
  const next = res.servers || [];
1508
1571
  if (consumeRevision(revealRevisionRef, res.revealRevision)) clearRevealed();
@@ -1512,10 +1575,10 @@ const McpTab = (props) => {
1512
1575
  setServers(next);
1513
1576
  }
1514
1577
  setError('');
1515
- if (notify) flash('已刷新,共 ' + next.length + ' 个 MCP');
1578
+ if (notify) flash(t('已刷新,共 ') + next.length + t(' 个 MCP'));
1516
1579
  }
1517
1580
  } else {
1518
- const message = (res && res.error) || '加载失败';
1581
+ const message = (res && res.error) || t('加载失败');
1519
1582
  setError(message);
1520
1583
  if (notify) flash(message, 'err');
1521
1584
  }
@@ -1618,9 +1681,9 @@ const McpTab = (props) => {
1618
1681
  try {
1619
1682
  const res = await call(method, args);
1620
1683
  if (res && res.ok) {
1621
- flash(successMsg || (res.note || method + ' 成功'));
1684
+ flash(successMsg || (res.note || method + t(' 成功')));
1622
1685
  } else {
1623
- flash((res && res.error) || method + ' 失败', 'err');
1686
+ flash((res && res.error) || method + t(' 失败'), 'err');
1624
1687
  }
1625
1688
  } catch (e) {
1626
1689
  flash(String((e && e.message) || e), 'err');
@@ -1639,27 +1702,27 @@ const McpTab = (props) => {
1639
1702
  revisionRef.current = '';
1640
1703
  setServers((prev) => prev.map((x) => (x.serverName === s.serverName ? { ...x, enabled: next } : x)));
1641
1704
  if (scope === 'global') {
1642
- return act(next ? 'enable' : 'disable', s.serverName, (next ? '已启用 ' : '已禁用 ') + s.serverName);
1705
+ return act(next ? 'enable' : 'disable', s.serverName, (next ? t('已启用 ') : t('已禁用 ')) + s.serverName);
1643
1706
  }
1644
1707
  // 项目层:写 disabled 字段到项目配置。
1645
1708
  const spec = { ...s, name: s.serverName, transport: s.transport === 'stdio' ? 'stdio' : 'streamable-http', disabled: !next };
1646
- return act('updateWorkspaceServer', { wsPath: scope, spec }, (next ? '已启用 ' : '已禁用 ') + s.serverName);
1709
+ return act('updateWorkspaceServer', { wsPath: scope, spec }, (next ? t('已启用 ') : t('已禁用 ')) + s.serverName);
1647
1710
  };
1648
1711
  const onReconnect = (s) => {
1649
1712
  // 项目连接也要有出口:mcp-client 的重连预算耗尽后会注销工具并停止,只能靠重载配置/重启宿主
1650
1713
  // 恢复(它的 README 明写),而项目 MCP 不是常驻服务 —— 所以「重连」在这里是必需的能力,
1651
1714
  // 不是可选装饰。这条路径 = 拆掉旧连接 + 按当前配置重建 + 对齐存活会话。
1652
1715
  if (s.scope === 'workspace') {
1653
- return act('reconnectWorkspaceServer', { wsPath: scope, name: s.serverName }, '已重连 ' + s.serverName);
1716
+ return act('reconnectWorkspaceServer', { wsPath: scope, name: s.serverName }, t('已重连 ') + s.serverName);
1654
1717
  }
1655
- act('reconnect', s.serverName, '已请求重连 ' + s.serverName);
1718
+ act('reconnect', s.serverName, t('已请求重连 ') + s.serverName);
1656
1719
  };
1657
1720
  const onRemove = (s) => {
1658
1721
  const isWorkspace = s.scope === 'workspace';
1659
- if (confirm('确认移除 MCP「' + s.serverName + '」?将从' + (isWorkspace ? '项目配置' : '当前 Web profile 配置') + '中删除。')) {
1722
+ if (confirm(t('确认移除 MCP「') + s.serverName + t('」?将从') + (isWorkspace ? t('项目配置') : t('当前 Web profile 配置')) + t('中删除。'))) {
1660
1723
  clearRevealed();
1661
- if (isWorkspace) act('removeWorkspaceServer', { wsPath: scope, name: s.serverName }, '已移除 ' + s.serverName);
1662
- else act('removeServer', s.serverName, '已移除 ' + s.serverName);
1724
+ if (isWorkspace) act('removeWorkspaceServer', { wsPath: scope, name: s.serverName }, t('已移除 ') + s.serverName);
1725
+ else act('removeServer', s.serverName, t('已移除 ') + s.serverName);
1663
1726
  }
1664
1727
  };
1665
1728
  const onToggleExclude = (serverName, hidden) => {
@@ -1669,19 +1732,19 @@ const McpTab = (props) => {
1669
1732
  setExclude((prev) => (hidden ? (prev.includes(serverName) ? prev : [...prev, serverName]) : prev.filter((name) => name !== serverName)));
1670
1733
  // 屏蔽同样是"写盘即对齐":Host 会立刻把新的 restrict 写进会话作用域,运行中的会话在**下一次
1671
1734
  // 模型请求**就不再见得到这些工具(工具清单是每步重新组装的,不是首轮定型)。
1672
- act('setWorkspaceExclude', { wsPath: scope, serverName, hidden }, hidden ? ('已屏蔽 ' + serverName + ',对运行中的会话立即生效') : ('已取消屏蔽 ' + serverName + ',对运行中的会话立即生效'));
1735
+ act('setWorkspaceExclude', { wsPath: scope, serverName, hidden }, hidden ? (t('已屏蔽 ') + serverName + t(',对运行中的会话立即生效')) : (t('已取消屏蔽 ') + serverName + t(',对运行中的会话立即生效')));
1673
1736
  };
1674
1737
  useEffect(() => { clearRevealed(); }, [selected, clearRevealed]);
1675
1738
  const revealValue = useCallback(async (server, field, key) => {
1676
- if (!server.managed) throw new Error('该 MCP 来自其他配置层,配置只读,无法查看具体值');
1739
+ if (!server.managed) throw new Error(t('该 MCP 来自其他配置层,配置只读,无法查看具体值'));
1677
1740
  const res = server.scope === 'workspace'
1678
1741
  ? await call('revealWorkspaceServer', { wsPath: scope, name: server.serverName, field, key })
1679
1742
  : await call('reveal', { name: server.serverName, field, key });
1680
- if (!res?.ok) throw new Error(res?.error || '读取配置失败');
1743
+ if (!res?.ok) throw new Error(res?.error || t('读取配置失败'));
1681
1744
  return res.value;
1682
1745
  }, [call, scope]);
1683
1746
  const onCopy = useCallback((text) => {
1684
- copyText(text).then((ok) => flash(ok ? '已复制到剪贴板' : '复制失败', ok ? 'ok' : 'err'));
1747
+ copyText(text).then((ok) => flash(ok ? t('已复制到剪贴板') : t('复制失败'), ok ? 'ok' : 'err'));
1685
1748
  }, [flash]);
1686
1749
  const onReveal = useCallback(async (server, field, key) => {
1687
1750
  const id = server.serverName + ':' + field + (key !== undefined ? ':' + key : '');
@@ -1710,12 +1773,12 @@ const McpTab = (props) => {
1710
1773
  : (editTarget ? await call('update', spec) : await call('add', spec));
1711
1774
  if (res && res.ok) {
1712
1775
  clearRevealed();
1713
- flash(editTarget ? '已保存 ' + spec.name : '已添加 ' + spec.name);
1776
+ flash(editTarget ? t('已保存 ') + spec.name : t('已添加 ') + spec.name);
1714
1777
  setAddOpen(false);
1715
1778
  setEditTarget(null);
1716
1779
  timer.timeout(load, 1500);
1717
1780
  } else {
1718
- setFormError((res && res.error) || (editTarget ? '保存失败' : '添加失败'));
1781
+ setFormError((res && res.error) || (editTarget ? t('保存失败') : t('添加失败')));
1719
1782
  }
1720
1783
  } catch (e) {
1721
1784
  setFormError(String((e && e.message) || e));
@@ -1727,7 +1790,7 @@ const McpTab = (props) => {
1727
1790
  const openEdit = (s) => { setEditTarget(s); setAddOpen(true); };
1728
1791
  const onImported = (res) => {
1729
1792
  clearRevealed();
1730
- flash(res.note || '已导入 MCP');
1793
+ flash(res.note || t('已导入 MCP'));
1731
1794
  timer.timeout(load, 1200);
1732
1795
  };
1733
1796
 
@@ -1755,7 +1818,7 @@ const McpTab = (props) => {
1755
1818
  // (client api: pluginManager/installBundle expected 2 argument(s), got 1)。
1756
1819
  const res = await pluginManager[UPGRADE_METHOD](targetSpec, {});
1757
1820
  if (!res || res.ok !== true) {
1758
- setUpgradeState((current) => ({ ...current, phase: 'failed', text: (res && res.error && (res.error.message || res.error.code)) || '调用失败' }));
1821
+ setUpgradeState((current) => ({ ...current, phase: 'failed', text: (res && res.error && (res.error.message || res.error.code)) || t('调用失败') }));
1759
1822
  return;
1760
1823
  }
1761
1824
  const r = res.value || {};
@@ -1792,13 +1855,13 @@ const McpTab = (props) => {
1792
1855
  }, [globalServers, exclude]);
1793
1856
 
1794
1857
  const scopeTabs = useMemo(() => {
1795
- const tabs = [{ key: 'global', label: '全局' }];
1858
+ const tabs = [{ key: 'global', label: t('全局') }];
1796
1859
  for (const ws of workspaces) {
1797
- tabs.push({ key: ws.path, label: ws.name, count: ws.serverCount, title: ws.path + (ws.error ? '(配置无效:' + ws.error + ')' : '') });
1860
+ tabs.push({ key: ws.path, label: ws.name, count: ws.serverCount, title: ws.path + (ws.error ? t('(配置无效:') + ws.error + t(')') : '') });
1798
1861
  }
1799
1862
  return tabs;
1800
1863
  }, [workspaces]);
1801
- const scopeName = scope === 'global' ? '全局' : (workspaces.find((ws) => ws.path === scope)?.name || scope);
1864
+ const scopeName = scope === 'global' ? t('全局') : (workspaces.find((ws) => ws.path === scope)?.name || scope);
1802
1865
  const switchScope = useCallback((next) => {
1803
1866
  if (next === scope) return;
1804
1867
  setScope(next);
@@ -1828,11 +1891,11 @@ const McpTab = (props) => {
1828
1891
  // 作用域由下方的 tab 高亮承载,按钮文案不再重复项目名:项目名长度不可控,
1829
1892
  // 塞进三个按钮会把工具栏挤到换行;完整作用域放在 title 里,宽度保持恒定。
1830
1893
  { className: 'dsh-mcp-head-actions' },
1831
- h(Btn, { icon: 'refresh', onClick: () => load(true), disabled: refreshing, children: refreshing ? '刷新中…' : '刷新' }),
1832
- h(Btn, { onClick: () => setImportOpen(true), title: '导入 MCP(' + scopeName + ')', children: '导入 MCP' }),
1833
- h(Btn, { cls: 'primary', icon: 'plus', onClick: openAdd, title: (scope === 'global' ? '添加 MCP' : '添加项目 MCP') + '(' + scopeName + ')', children: scope === 'global' ? '添加 MCP' : '添加项目 MCP' }),
1894
+ h(Btn, { icon: 'refresh', onClick: () => load(true), disabled: refreshing, children: refreshing ? t('刷新中…') : t('刷新') }),
1895
+ h(Btn, { onClick: () => setImportOpen(true), title: t('导入 MCP(') + scopeName + t(')'), children: t('导入 MCP') }),
1896
+ h(Btn, { cls: 'primary', icon: 'plus', onClick: openAdd, title: (scope === 'global' ? t('添加 MCP') : t('添加项目 MCP')) + t('(') + scopeName + t(')'), children: scope === 'global' ? t('添加 MCP') : t('添加项目 MCP') }),
1834
1897
  h(EntryMenu, null),
1835
- onClose ? h(Btn, { icon: 'x', onClick: onClose, children: '关闭' }) : null,
1898
+ onClose ? h(Btn, { icon: 'x', onClick: onClose, children: t('关闭') }) : null,
1836
1899
  ),
1837
1900
  ),
1838
1901
  h(ScopeTabs, { tabs: scopeTabs, active: scope, onSelect: switchScope }),
@@ -1842,37 +1905,37 @@ const McpTab = (props) => {
1842
1905
  // 文案随升级阶段走:升级中/已装完待重启/失败,都比「有新版本」更该让用户看到。
1843
1906
  // 注意 updateHint 在 done 分支已经是 null(宿主已认不出旧版),所以这里不读它的字段。
1844
1907
  h('span', null,
1845
- upgradeState.phase === 'running' ? '正在升级到 v' + upgradeState.version + '…'
1846
- : upgradeState.phase === 'done' ? '已升级到 v' + upgradeState.version + ',重启宿主后生效'
1847
- : upgradeState.phase === 'failed' ? '升级失败:' + upgradeState.text
1908
+ upgradeState.phase === 'running' ? t('正在升级到 v') + upgradeState.version + '…'
1909
+ : upgradeState.phase === 'done' ? t('已升级到 v') + upgradeState.version + t(',重启宿主后生效')
1910
+ : upgradeState.phase === 'failed' ? t('升级失败:') + upgradeState.text
1848
1911
  : upgradeState.phase === 'blocked' && upgradeState.reason === 'not-npm'
1849
- ? '本地安装(' + (upgradeState.source || 'file') + '),一键升级会把本地副本换成 npm 包,故只提示' + (updateHint?.current ? '(当前 v' + updateHint.current + ')' : '')
1912
+ ? t('本地安装(') + (upgradeState.source || 'file') + t('),一键升级会把本地副本换成 npm 包,故只提示') + (updateHint?.current ? t('(当前 v') + updateHint.current + t(')') : '')
1850
1913
  : upgradeState.phase === 'idle' && upgradeState.reason === 'not-npm'
1851
1914
  // 探测阶段就判定不可一键升:这是本地开发安装的常态(file:/link:),
1852
1915
  // 说清原因,免得用户以为按钮坏了。
1853
- ? '新版本 v' + updateHint.latest + ' 可用;本地安装(' + (upgradeState.source || 'file') + ')请手动更新后重启'
1854
- : '新版本 v' + updateHint.latest + ' 可用'),
1916
+ ? t('新版本 v') + updateHint.latest + t(' 可用;本地安装(') + (upgradeState.source || 'file') + t(')请手动更新后重启')
1917
+ : t('新版本 v') + updateHint.latest + t(' 可用')),
1855
1918
  // 可升级时给按钮,否则退回链接。按钮只在探测确认过 service + npm 来源后出现——
1856
1919
  // 探测失败时这一整条与改动前完全一致,不会出现点了没反应的按钮。
1857
1920
  upgradeState.phase === 'idle' && upgradeState.spec
1858
1921
  ? h('button', {
1859
1922
  type: 'button', className: 'dsh-mcp-upd-go',
1860
- title: '升级由官方 pluginManager 执行,装完需要重启宿主',
1923
+ title: t('升级由官方 pluginManager 执行,装完需要重启宿主'),
1861
1924
  onClick: runUpgrade,
1862
- }, '升级')
1925
+ }, t('升级'))
1863
1926
  : h('a', {
1864
1927
  href: (updateHint && updateHint.url) || 'https://github.com/2026heshao/dsh-armory/releases',
1865
1928
  target: '_blank', rel: 'noreferrer',
1866
- }, upgradeState.phase === 'done' ? '看这次改了什么' : '查看更新方式'),
1867
- h('button', { type: 'button', className: 'dsh-mcp-upd-close', 'aria-label': '关闭更新提示', title: '本版本内不再提示', onClick: dismissUpdateHint }, h(Icon, { d: ICONS.x })),
1929
+ }, upgradeState.phase === 'done' ? t('看这次改了什么') : t('查看更新方式')),
1930
+ h('button', { type: 'button', className: 'dsh-mcp-upd-close', 'aria-label': t('关闭更新提示'), title: t('本版本内不再提示'), onClick: dismissUpdateHint }, h(Icon, { d: ICONS.x })),
1868
1931
  ) : null,
1869
1932
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1870
- scope !== 'global' && mountErrors.length ? h('div', { className: 'dsh-mcp-log' }, mountErrors.length + ' 个项目 MCP 未能挂载到会话:' + mountErrors.map((m) => m.serverName).join('、') + '。点开对应条目查看具体错误。'
1933
+ scope !== 'global' && mountErrors.length ? h('div', { className: 'dsh-mcp-log' }, mountErrors.length + t(' 个项目 MCP 未能挂载到会话:') + mountErrors.map((m) => m.serverName).join(t('、')) + t('。点开对应条目查看具体错误。')
1871
1934
  // 项目 MCP 是「该项目所有会话共享一份连接」,同一项目内不会因并发会话而撞名;这里只
1872
1935
  // 如实给出并发会话数作为上下文(挂载失败的常见外部原因),不再把它说成 serverName 占用。
1873
- + (liveAgents > 1 ? ' 本项目当前有 ' + liveAgents + ' 个会话在运行。' : '')) : null,
1874
- scope !== 'global' && scopeErrors.length ? h('div', { className: 'dsh-mcp-log' }, scopeErrors.length + ' 个项目 MCP 的工具不在共享作用域层:' + scopeErrors.map((m) => m.serverName).join('、') + '。点开对应条目查看具体错误。') : null,
1875
- scope !== 'global' && restrictError ? h('div', { className: 'dsh-mcp-log' }, '屏蔽未能写入会话作用域:' + restrictError.error) : null,
1936
+ + (liveAgents > 1 ? t(' 本项目当前有 ') + liveAgents + t(' 个会话在运行。') : '')) : null,
1937
+ scope !== 'global' && scopeErrors.length ? h('div', { className: 'dsh-mcp-log' }, scopeErrors.length + t(' 个项目 MCP 的工具不在共享作用域层:') + scopeErrors.map((m) => m.serverName).join(t('、')) + t('。点开对应条目查看具体错误。')) : null,
1938
+ scope !== 'global' && restrictError ? h('div', { className: 'dsh-mcp-log' }, t('屏蔽未能写入会话作用域:') + restrictError.error) : null,
1876
1939
  scope === 'global'
1877
1940
  ? h(
1878
1941
  'div',
@@ -1897,7 +1960,7 @@ const McpTab = (props) => {
1897
1960
  h('div', { className: 'dsh-mcp-ws-body', 'data-ws-path': scope },
1898
1961
  h('div', { className: 'dsh-mcp-section local' },
1899
1962
  h('div', { className: 'dsh-mcp-section-title' },
1900
- '此项目的 MCP',
1963
+ t('此项目的 MCP'),
1901
1964
  h('span', { className: 'dsh-mcp-section-count' }, servers.length),
1902
1965
  ),
1903
1966
  h(ServerList, {
@@ -1915,7 +1978,7 @@ const McpTab = (props) => {
1915
1978
  onStatusFilter: setStatusFilter,
1916
1979
  embedded: true,
1917
1980
  workspace: true,
1918
- emptyHint: '此项目还没有 MCP。用右上角「添加项目 MCP」或「市场」写入 .dsh/mcp.json',
1981
+ emptyHint: t('此项目还没有 MCP。用右上角「添加项目 MCP」或「市场」写入 .dsh/mcp.json'),
1919
1982
  }),
1920
1983
  ),
1921
1984
  h('div', { className: 'dsh-mcp-section global' + (globalOpen ? '' : ' collapsed') },
@@ -1927,14 +1990,14 @@ const McpTab = (props) => {
1927
1990
  onClick: () => setGlobalOpen((value) => !value),
1928
1991
  },
1929
1992
  h('span', { className: 'dsh-mcp-caret', 'aria-hidden': true }, globalOpen ? '▾' : '▸'),
1930
- h('span', { className: 'dsh-mcp-section-toggle-label' }, '全局 MCP'),
1993
+ h('span', { className: 'dsh-mcp-section-toggle-label' }, t('全局 MCP')),
1931
1994
  h('span', { className: 'dsh-mcp-section-summary' },
1932
1995
  globalServers.length === 0
1933
- ? '无'
1934
- : visibleGlobalCount + '/' + globalServers.length + ' 对本项目可见'
1996
+ ? t('无')
1997
+ : visibleGlobalCount + '/' + globalServers.length + t(' 对本项目可见')
1935
1998
  // 工具清单是**每一步**重新组装的,所以改动(新增/删除/改配置/屏蔽)对运行中的
1936
1999
  // 会话在下一轮请求就生效;只有手工改 .dsh/mcp.json 才要等下一次会话挂载。
1937
- + ' · 改动即刻生效'),
2000
+ + t(' · 改动即刻生效')),
1938
2001
  ),
1939
2002
  globalOpen ? h(GlobalServerList, { servers: globalServers, exclude, onToggleExclude, onSelect: setSelected, selected, busy }) : null,
1940
2003
  ),
@@ -1967,7 +2030,7 @@ const McpTab = (props) => {
1967
2030
  return h(
1968
2031
  'div',
1969
2032
  { className: 'dsh-mcp-panel-overlay', onClick: (e) => e.target === e.currentTarget && onClose && onClose() },
1970
- h('div', { ref: panelRef, className: 'dsh-mcp-panel', role: 'dialog', 'aria-modal': true, 'aria-label': 'MCP 市场面板', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && onClose) onClose(); } }, inner),
2033
+ h('div', { ref: panelRef, className: 'dsh-mcp-panel', role: 'dialog', 'aria-modal': true, 'aria-label': t('MCP 市场面板'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && onClose) onClose(); } }, inner),
1971
2034
  );
1972
2035
  return inner;
1973
2036
  };
@@ -2027,40 +2090,12 @@ function saveFabPos(pos) {
2027
2090
  }
2028
2091
  }
2029
2092
 
2030
- // ── 面板开合状态:悬浮按钮与左侧栏入口共享同一份 ──────────────────────────────
2031
- // 两个入口分属不同的 slot 子树(shell.overlay / sidebar.footer.action),没有公共
2032
- // React 祖先,因此用模块级订阅而不是 context。单一数据源:两个入口看到的开关状态
2033
- // 永远一致,不会出现「一个说开着、一个说关着」。
2034
- function createPanelVisibility() {
2035
- let open = false;
2036
- const listeners = new Set();
2037
- return {
2038
- isOpen: () => open,
2039
- set(next) {
2040
- if (open === next) return;
2041
- open = next;
2042
- for (const listener of [...listeners]) listener();
2043
- },
2044
- subscribe(listener) {
2045
- listeners.add(listener);
2046
- return () => { listeners.delete(listener); };
2047
- },
2048
- };
2049
- }
2050
- const panelVisibility = createPanelVisibility();
2051
-
2052
- const usePanelOpen = () => {
2053
- const [open, setOpen] = useState(panelVisibility.isOpen());
2054
- useEffect(() => panelVisibility.subscribe(() => setOpen(panelVisibility.isOpen())), []);
2055
- return open;
2056
- };
2057
-
2058
- // ── 入口显示偏好:两个入口都可关,但不能都关 ─────────────────────────────────
2059
- // 面板只能从这两个入口打开(右下角悬浮按钮 / 左侧栏底部「MCP」)。所以「两个都关」是一
2060
- // 个必须**无法表达**的状态:真出现了,用户就再也打不开面板,只能手改 localStorage 回来。
2061
- // 这里因此不存两个 boolean,而是存一张「模式 → 入口可见性」表:非法组合在表里根本不存在,
2062
- // 不需要额外的守卫、禁用逻辑或「至少留一个」的提示文案。新增入口时,这张表的不变式
2063
- // (每一项至少一个入口可见)由 test/client-entry.test.mjs 直接对表断言。
2093
+ // ── 入口显示偏好:三个入口都可关,但不能都关 ─────────────────────────────────
2094
+ // 面板只能从这三个入口打开(左上角全局面板行 / 右下角悬浮按钮 / 左侧栏底部「MCP」)。所以
2095
+ // 「三个都关」是一个必须**无法表达**的状态:真出现了,用户就再也打不开面板,只能手改
2096
+ // localStorage 回来。这里因此不存三个 boolean,而是存一张「模式 → 入口可见性」表:非法组合
2097
+ // 在表里根本不存在,不需要额外的守卫、禁用逻辑或「至少留一个」的提示文案。新增入口时,这张
2098
+ // 表的不变式(每一项至少一个入口可见)由 test/client-entry.mjs 直接对表断言。
2064
2099
  const ENTRY_KEY = 'dsh-armory/entry';
2065
2100
  const ENTRY_VISIBILITY = {
2066
2101
  both: { panel: true, fab: true, sidebar: false },
@@ -2070,11 +2105,11 @@ const ENTRY_VISIBILITY = {
2070
2105
  all: { panel: true, fab: true, sidebar: true },
2071
2106
  };
2072
2107
  const ENTRY_LABELS = {
2073
- both: '全局面板 + 悬浮按钮(推荐)',
2074
- panel: '仅全局面板(左上角,"插件"旁)',
2075
- fab: '仅悬浮按钮',
2076
- sidebar: '仅侧栏底部入口',
2077
- all: '三个入口全开',
2108
+ both: t('全局面板 + 悬浮按钮(推荐)'),
2109
+ panel: t('仅全局面板(左上角,"插件"旁)'),
2110
+ fab: t('仅悬浮按钮'),
2111
+ sidebar: t('仅侧栏底部入口'),
2112
+ all: t('三个入口全开'),
2078
2113
  };
2079
2114
  const ENTRY_DEFAULT = 'both';
2080
2115
  const ENTRY_MODES = Object.keys(ENTRY_VISIBILITY);
@@ -2126,14 +2161,13 @@ const useEntryMode = () => {
2126
2161
  return mode;
2127
2162
  };
2128
2163
 
2129
- // shell.overlay 那个注册里到底渲染什么:面板挂载点与悬浮按钮是两件事——
2130
- // 面板:只要 open 就渲染。侧栏入口只改 panelVisibility,真正渲染面板的是这里,
2131
- // 所以把面板也用入口偏好门住 = 关掉悬浮按钮后侧栏入口点了没反应(把自己锁死)。
2132
- // 按钮:才看入口偏好,且只在面板没开着时占位。
2133
- // 抽成纯函数也是为了让这条不变式能脱离 React 被测:测试替身的 hooks 是空实现,组件本身
2134
- // 在 node 里渲染不起来(见 test/client-entry.test.mjs)。
2135
- function overlaySeats(mode, open) {
2136
- return { panel: false, fab: ENTRY_VISIBILITY[mode].fab };
2164
+ // shell.overlay 那个注册里现在只剩悬浮按钮一个席位:面板早已改由 sidebar.panellist + main
2165
+ // 渲染(见下面「全局面板入口」一节),从不由这里挂载。所以这里没有「面板席位」这个维度,
2166
+ // 只回答「按当前入口偏好,悬浮按钮要不要占位」。
2167
+ // 抽成纯函数是为了让入口可见性不变式能脱离 React 被测:测试替身的 hooks 是空实现,组件本身
2168
+ // 在 node 里渲染不起来(见 test/client-entry.mjs)。
2169
+ function overlaySeats(mode) {
2170
+ return { fab: ENTRY_VISIBILITY[mode].fab };
2137
2171
  }
2138
2172
 
2139
2173
  // ── 全局面板入口(sidebar.panellist + main,与「插件」同一套官方机制)──────
@@ -2147,20 +2181,20 @@ const PANEL_ID = 'mcp-manager';
2147
2181
  // 入口排序:插件管理器用 0,定时任务用 10;取 5 让本入口正好落在「插件」旁边。
2148
2182
  const PANEL_ORDER = 5;
2149
2183
  // 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
2150
- const PANEL_LABEL = '技能 · MCP · 专家';
2184
+ const PANEL_LABEL = t('技能 · MCP · 专家');
2151
2185
 
2152
2186
  // skill 的 source 取值域来自官方 dsh-skill-filesystem 的 roots()(rank 顺序即优先级),
2153
2187
  // 与 skill-registry 的 runtime。这里只做展示文案,不参与判定。
2154
2188
  const SKILL_SOURCE_LABELS = {
2155
- 'project-dsh': '项目 .dsh/skills',
2156
- 'project-agents': '项目 .agents/skills',
2157
- custom: '自定义目录',
2158
- 'user-dsh': '用户 ~/.dsh/skills',
2159
- 'user-agents': '用户 ~/.agents/skills',
2160
- bundled: '随 DSH 分发',
2161
- runtime: '运行时注册',
2189
+ 'project-dsh': t('项目 .dsh/skills'),
2190
+ 'project-agents': t('项目 .agents/skills'),
2191
+ custom: t('自定义目录'),
2192
+ 'user-dsh': t('用户 ~/.dsh/skills'),
2193
+ 'user-agents': t('用户 ~/.agents/skills'),
2194
+ bundled: t('随 DSH 分发'),
2195
+ runtime: t('运行时注册'),
2162
2196
  };
2163
- const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || '未知来源');
2197
+ const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
2164
2198
 
2165
2199
  // 「项目」语境下 cwd 决定项目根(.dsh/skills / .agents/skills 都从它往下找),
2166
2200
  // 全局语境传 undefined,让提供方只扫用户级与自定义目录——与官方 list() 语义一致。
@@ -2173,40 +2207,49 @@ const skillScopeArg = (scope) => (scope === 'global' ? {} : { cwd: scope });
2173
2207
  // 历史出现过的未注册工具(例如已卸载的 MCP)只展示统计。
2174
2208
  // 延迟统计暂缺(见 tool-ledger.js 尾注),健康度以错误率 + 调用次数呈现。
2175
2209
  const TAILOR_WINDOWS = [
2176
- { key: '7', label: '近 7 天', days: 7 },
2177
- { key: '30', label: '近 30 天', days: 30 },
2178
- { key: '90', label: '近 90 天', days: 90 },
2210
+ { key: '7', label: t('近 7 天'), days: 7 },
2211
+ { key: '30', label: t('近 30 天'), days: 30 },
2212
+ { key: '90', label: t('近 90 天'), days: 90 },
2179
2213
  ];
2180
2214
  const fmtInt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');
2181
2215
  const fmtPct = (n) => (typeof n === 'number' && n > 0 ? (n * 100).toFixed(1) + '%' : '0%');
2182
2216
  const fmtTax = (n) => (typeof n === 'number' && n > 0 ? '~' + n.toLocaleString('en-US') + ' tok' : '—');
2183
2217
  const fmtLast = (ms, now) => {
2184
- if (typeof ms !== 'number') return '从未';
2218
+ if (typeof ms !== 'number') return t('从未');
2185
2219
  const diff = Math.max(0, (typeof now === 'number' ? now : Date.now()) - ms);
2186
2220
  const min = Math.floor(diff / 60000);
2187
- if (min < 1) return '刚刚';
2188
- if (min < 60) return min + ' 分钟前';
2221
+ if (min < 1) return t('刚刚');
2222
+ if (min < 60) return min + t(' 分钟前');
2189
2223
  const h = Math.floor(min / 60);
2190
- if (h < 24) return h + ' 小时前';
2224
+ if (h < 24) return h + t(' 小时前');
2191
2225
  const d = Math.floor(h / 24);
2192
- return d + ' 天前';
2226
+ return d + t(' 天前');
2193
2227
  };
2194
- const kindLabel = (tool) => (tool.kind === 'mcp' ? 'MCP' : tool.kind === 'mcp-ambiguous' ? 'MCP?' : '内置');
2195
2228
  const fmtScanError = (message) =>
2196
2229
  /404|transport failure/i.test(String(message))
2197
- ? String(message) + ' ——宿主还没注册 toolTailor 接口:重启 DeepSeek Harness 让宿主侧生效后即可'
2230
+ ? String(message) + t(' ——宿主还没注册 toolTailor 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
2198
2231
  : String(message);
2199
2232
 
2200
- const ToolTailor = ({ call, timer }) => {
2233
+ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending }) => {
2201
2234
  const [windowKey, setWindowKey] = useState('30');
2202
- const [report, setReport] = useState(null);
2235
+ // 报告可以由父层上提(OPT-04):分类角标和页面卡片读同一份,进入该分类只扫一次。
2236
+ // 没有上提时(直接挂载组件)退化为组件自己持有。
2237
+ const [localReport, setLocalReport] = useState(null);
2238
+ const report = reportProp !== undefined ? reportProp : localReport;
2239
+ const commitReport = onReport || setLocalReport;
2203
2240
  const [error, setError] = useState('');
2204
2241
  const [busy, setBusy] = useState(false);
2205
2242
  const [actionError, setActionError] = useState('');
2206
2243
  const [flashMsg, setFlashMsg] = useState('');
2207
- const [onlyDead, setOnlyDead] = useState(true);
2244
+ // 默认展示全部工具。账本里同时存在两类行:窗口内有真实调用次数的,和注册表里
2245
+ // 零调用的候选项。旧版默认打开「只看零调用」,屏幕上必然整列 0,看上去像「检测
2246
+ // 全坏」——那是筛选口径而不是数据问题。默认关掉,筛选交给用户显式开启。
2247
+ const [onlyDead, setOnlyDead] = useState(false);
2208
2248
  const flashCancel = useRef(null);
2209
2249
  const days = Number(windowKey) || 30;
2250
+ // 父层先把 30 天窗口扫好了(角标要用)。窗口一致且已有报告时,首次挂载**不必**再扫;
2251
+ // 只有用户切窗口(7/90 天)或点「重扫」时才真的重拉。
2252
+ const firstResolveRef = useRef(false);
2210
2253
 
2211
2254
  const load = useCallback(async () => {
2212
2255
  setBusy(true);
@@ -2214,21 +2257,33 @@ const ToolTailor = ({ call, timer }) => {
2214
2257
  try {
2215
2258
  const res = await call('toolTailor', { days });
2216
2259
  if (res && res.ok) {
2217
- setReport(res);
2260
+ commitReport(res);
2218
2261
  setActionError('');
2219
2262
  } else {
2220
- setError((res && res.error) || 'toolTailor 调用失败');
2263
+ setError((res && res.error) || t('toolTailor 调用失败'));
2221
2264
  }
2222
2265
  } catch (e) {
2223
2266
  setError(String((e && e.message) || e));
2224
2267
  } finally {
2225
2268
  setBusy(false);
2226
2269
  }
2227
- }, [call, days]);
2270
+ }, [call, days, commitReport]);
2228
2271
 
2229
2272
  useEffect(() => {
2273
+ if (!firstResolveRef.current) {
2274
+ // 父层正在替我们扫同一窗口(角标要用)→ 等它的报告,绝不重复发一发。
2275
+ // 父层失败时 seedPending 会翻成 false 并重跑本 effect,这里再自己兜底扫一次。
2276
+ if (seedPending && days === 30) return;
2277
+ firstResolveRef.current = true;
2278
+ const seededDays = report && typeof report.windowDays === 'number' ? report.windowDays : null;
2279
+ if (seededDays !== null && seededDays === days) return;
2280
+ }
2230
2281
  load();
2231
- }, [load]);
2282
+ // report 刻意不入依赖:它每次拉取都会变,入依赖会变成自激循环。
2283
+ // 注:本仓库不装 eslint-plugin-react-hooks(没有 React 侧的 lint 链路),所以这里不挂
2284
+ // disable 指令——挂了也是引用一条不存在的规则,ESLint 9 会直接判 error。将来若接入
2285
+ // react-hooks,需要在这行补 disable 并写成已注册的规则名。
2286
+ }, [load, seedPending]);
2232
2287
 
2233
2288
  // 完成动作后账本要重扫(禁用会让工具从注册表里消失),但扫描要几百毫秒,
2234
2289
  // 先给行内反馈再刷新。
@@ -2246,7 +2301,7 @@ const ToolTailor = ({ call, timer }) => {
2246
2301
  try {
2247
2302
  const res = await call(method, args);
2248
2303
  if (res && res.ok) flash(msg);
2249
- else flash((res && res.error) || method + ' 失败', 'err');
2304
+ else flash((res && res.error) || method + t(' 失败'), 'err');
2250
2305
  } catch (e) {
2251
2306
  flash(String((e && e.message) || e), 'err');
2252
2307
  } finally {
@@ -2261,36 +2316,68 @@ const ToolTailor = ({ call, timer }) => {
2261
2316
  .sort((a, b) => (b.calls - a.calls) || ((b.schemaTokens || 0) - (a.schemaTokens || 0)) || String(a.name).localeCompare(String(b.name)));
2262
2317
  const deadMcp = tools.filter((tool) => tool.kind === 'mcp' && tool.calls === 0);
2263
2318
  const deadMcpActionable = deadMcp.filter((tool) => !tool.serverDisabled);
2319
+ // 口径回显:任何时刻都能看出「当前显示几条 / 账本共几条 / 被筛掉几条」,
2320
+ // 避免筛选态再次被读成「所有计数都是 0」。
2321
+ const deadToolCount = tools.filter((tool) => tool.calls === 0).length;
2322
+ const calledToolCount = tools.length - deadToolCount;
2323
+ const metaText = report
2324
+ ? t('扫描 {0} 个会话 · 读 {1} 个日志', [report.sessionsScanned, report.filesRead]) + (report.failedFiles ? t(' · {0} 个失败', [report.failedFiles]) : '') + t(' · 显示 {0}/{1} 个工具 · Schema 合计 ~{2} tok/请求', [fmtInt(shown.length), fmtInt(tools.length), fmtInt(report.taxTotalTokens)]) + (onlyDead && calledToolCount ? t(' · 已隐藏 {0} 个有调用的工具', [fmtInt(calledToolCount)]) : '')
2325
+ : (busy ? t('扫描中…') : '');
2326
+
2327
+ // OPT-02:把「解码失败」摆到脸上。此前 failedFrames / failedFileSamples 一路被算出来
2328
+ // 却从不显示,于是 zstd 不可用时的表现(扫到了会话、读到了日志、调用数整列 0)与
2329
+ // 「只看零调用」筛选完全无法区分。现在两者各有各的一句话。
2330
+ const decodeWarning = !report
2331
+ ? ''
2332
+ : report.zstdSupported === false
2333
+ ? t('zstd 解压不可用:当前 Node 不支持 zlib.zstdDecompressSync(需 >= 22.15),所有调用计数都会是 0')
2334
+ : report.failedFrames > 0
2335
+ ? t('{0} 个日志帧解码失败,调用计数可能偏低', [fmtInt(report.failedFrames)])
2336
+ : '';
2337
+ const scanFailures = report && Array.isArray(report.failedFileSamples) ? report.failedFileSamples : [];
2338
+ // 正常运行时这一段**完全不渲染**(无噪音),只有真出问题才占位。
2339
+ const decodeIssue = decodeWarning || scanFailures.length
2340
+ ? h('div', { className: 'dsh-mcp-tailor-warn' },
2341
+ decodeWarning ? h('div', null, h('b', null, t('⚠ 数据可能不完整:')), decodeWarning) : null,
2342
+ report && report.zstdSupported === false
2343
+ ? h('div', null, t('当前运行在 Node {0},请升级到 22.15 以上后重启宿主。', [report.nodeVersion || t('(未知版本)')]))
2344
+ : null,
2345
+ scanFailures.length
2346
+ ? h('details', { className: 'dsh-mcp-tailor-details' },
2347
+ h('summary', null, t('{0} 个日志文件未能读取(含超限跳过),展开看原因', [fmtInt(report.failedFiles)])),
2348
+ h('ul', null, scanFailures.map((sample, i) => h('li', { key: i }, sample))),
2349
+ )
2350
+ : null,
2351
+ )
2352
+ : null;
2264
2353
 
2265
2354
  return h('div', { className: 'dsh-mcp-tailor' },
2266
2355
  h('div', { className: 'dsh-mcp-tailor-cards' },
2267
- h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.totalCalls : null)), h('span', null, `窗口内工具调用总次数(${days} 天)`)),
2268
- h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.toolCount : null)), h('span', null, '出现过的工具(含已卸载 MCP 的历史调用)')),
2269
- h('div', { className: 'dsh-mcp-tailor-card dead' }, h('b', null, fmtInt(report ? report.deadMcpCount : null)), h('span', null, `零调用的 MCP 工具(每轮白缴 ${report ? fmtTax(report.deadMcpTaxTokens) : '—'} 上下文税)`)),
2270
- h('div', { className: 'dsh-mcp-tailor-card err' }, h('b', null, fmtPct(report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0)), h('span', null, `总体错误率(${fmtInt(report ? report.totalErrors : null)} 次失败)`)),
2356
+ h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.totalCalls : null)), h('span', null, t('窗口内工具调用总次数({0} 天)', [days]))),
2357
+ h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.toolCount : null)), h('span', null, t('出现过的工具(含已卸载 MCP 的历史调用)'))),
2358
+ h('div', { className: 'dsh-mcp-tailor-card dead' }, h('b', null, fmtInt(report ? report.deadMcpCount : null)), h('span', null, t('零调用的 MCP 工具(每轮白缴 {0} 上下文税)', [report ? fmtTax(report.deadMcpTaxTokens) : '—']))),
2359
+ h('div', { className: 'dsh-mcp-tailor-card err' }, h('b', null, fmtPct(report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0)), h('span', null, t('总体错误率({0} 次失败)', [fmtInt(report ? report.totalErrors : null)]))),
2271
2360
  ),
2272
2361
  h('div', { className: 'dsh-mcp-tailor-toolbar' },
2273
2362
  TAILOR_WINDOWS.map((w) =>
2274
2363
  h('button', { key: w.key, type: 'button', className: 'dsh-mcp-btn' + (windowKey === w.key ? ' primary' : ''), onClick: () => setWindowKey(w.key) }, w.label),
2275
2364
  ),
2276
- h('button', { type: 'button', className: 'dsh-mcp-btn' + (onlyDead ? ' primary' : ''), onClick: () => setOnlyDead((v) => !v), title: '只看窗口内零调用的工具' }, '只看死工具'),
2277
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, '重扫'),
2278
- h('span', { className: 'dsh-mcp-tailor-meta' },
2279
- report
2280
- ? `扫描 ${report.sessionsScanned} 个会话 · 读 ${report.filesRead} 个日志${report.failedFiles ? ` · ${report.failedFiles} 个失败` : ''} · 全部工具 Schema 合计 ~${fmtInt(report.taxTotalTokens)} tok/请求`
2281
- : (busy ? '扫描中…' : ''),
2282
- ),
2365
+ h('button', { type: 'button', className: 'dsh-mcp-btn' + (onlyDead ? ' primary' : ''), onClick: () => setOnlyDead((v) => !v), title: t('只看窗口内零调用的工具(默认关闭:默认显示全部工具与真实调用次数)') }, report ? t('只看零调用({0})', [fmtInt(deadToolCount)]) : t('只看零调用')),
2366
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, t('重扫')),
2367
+ h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
2283
2368
  ),
2284
- error ? h('div', { className: 'dsh-mcp-tailor-note' }, '账本加载失败:', fmtScanError(error)) : null,
2369
+ error ? h('div', { className: 'dsh-mcp-tailor-note' }, t('账本加载失败:'), fmtScanError(error)) : null,
2370
+ // 解码告警放在数据和表格**上方**:调用数整列 0 时,用户第一眼要能看到「为什么」。
2371
+ decodeIssue,
2285
2372
  actionError ? h('div', { className: 'dsh-mcp-tailor-note' }, actionError) : null,
2286
2373
  flashMsg ? h('div', { className: 'dsh-mcp-tailor-note' }, flashMsg) : null,
2287
2374
  h('div', { className: 'dsh-mcp-tailor-table' },
2288
2375
  h('div', { className: 'dsh-mcp-tailor-row head' },
2289
- h('span', null, '工具'), h('span', { className: 't-num' }, '调用'), h('span', { className: 't-num' }, '错误'),
2290
- h('span', { className: 't-num' }, '错误率'), h('span', { className: 't-tax' }, 'Schema 税'), h('span', null, '最后使用 / 操作'),
2376
+ h('span', null, t('工具')), h('span', { className: 't-num' }, t('调用')), h('span', { className: 't-num' }, t('错误')),
2377
+ h('span', { className: 't-num' }, t('错误率')), h('span', { className: 't-tax' }, t('Schema 税')), h('span', null, t('最后使用 / 操作')),
2291
2378
  ),
2292
2379
  shown.length === 0
2293
- ? h('div', { className: 'dsh-mcp-tailor-empty' }, onlyDead && report ? `窗口内没有零调用的工具(共 ${fmtInt(report.toolCount)} 个工具都有调用)` : '暂无数据')
2380
+ ? h('div', { className: 'dsh-mcp-tailor-empty' }, onlyDead && report ? t('窗口内没有零调用的工具(共 {0} 个工具都有调用,关掉「只看零调用」即可看全部)', [fmtInt(report.toolCount)]) : (busy ? t('扫描中…') : t('暂无数据')))
2294
2381
  : shown.map((tool) => {
2295
2382
  const actionable = tool.kind === 'mcp' && tool.server;
2296
2383
  const isDead = tool.calls === 0;
@@ -2299,8 +2386,8 @@ const ToolTailor = ({ call, timer }) => {
2299
2386
  h('span', { className: 't-name' },
2300
2387
  tool.server ? h('span', { className: 't-badge' }, tool.server) : null,
2301
2388
  tool.server ? tool.leaf : tool.name,
2302
- tool.serverDisabled ? h('span', { className: 't-badge warn' }, '已禁用') : null,
2303
- errRate >= 0.5 ? h('span', { className: 't-badge err' }, '高错误率') : null,
2389
+ tool.serverDisabled ? h('span', { className: 't-badge warn' }, t('已禁用')) : null,
2390
+ errRate >= 0.5 ? h('span', { className: 't-badge err' }, t('高错误率')) : null,
2304
2391
  ),
2305
2392
  h('span', { className: 't-num' }, fmtInt(tool.calls)),
2306
2393
  h('span', { className: 't-num' }, fmtInt(tool.errors)),
@@ -2310,9 +2397,9 @@ const ToolTailor = ({ call, timer }) => {
2310
2397
  fmtLast(tool.lastUsedMs, report ? report.generatedAt : undefined),
2311
2398
  actionable && !tool.serverDisabled
2312
2399
  ? h('span', { className: 't-act' },
2313
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: () => tailorAct('disable', tool.server, `已禁用 ${tool.server}`) }, '禁用服务器'),
2400
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: () => tailorAct('disable', tool.server, t('已禁用 {0}', [tool.server])) }, t('禁用服务器')),
2314
2401
  ' ',
2315
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: () => tailorAct('enable', tool.server, `已启用 ${tool.server}`) }, '启用'),
2402
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: () => tailorAct('enable', tool.server, t('已启用 {0}', [tool.server])) }, t('启用')),
2316
2403
  )
2317
2404
  : null,
2318
2405
  ),
@@ -2321,8 +2408,8 @@ const ToolTailor = ({ call, timer }) => {
2321
2408
  ),
2322
2409
  deadMcpActionable.length
2323
2410
  ? h('div', { className: 'dsh-mcp-tailor-note' },
2324
- h('b', null, '裁缝建议:'),
2325
- `${deadMcpActionable.length} 个 MCP 工具在 ${days} 天内零调用,合计每轮请求白缴 ~${fmtInt(report ? report.deadMcpTaxTokens : 0)} tok 上下文税。可到「MCP」页或在上方行内禁用对应服务器(随时可启用,禁用只作用于新请求)。`,
2411
+ h('b', null, t('裁缝建议:')),
2412
+ t('{0} 个 MCP 工具在 {1} 天内零调用,合计每轮请求白缴 ~{2} tok 上下文税。可到「MCP」页或在上方行内禁用对应服务器(随时可启用,禁用只作用于新请求)。', [deadMcpActionable.length, days, fmtInt(report ? report.deadMcpTaxTokens : 0)]),
2326
2413
  )
2327
2414
  : null,
2328
2415
  );
@@ -2331,16 +2418,16 @@ const ToolTailor = ({ call, timer }) => {
2331
2418
  const BOARD_CATEGORIES = [
2332
2419
  { key: 'mcp', label: 'MCP', icon: 'plug' },
2333
2420
  { key: 'skill', label: 'Skill', icon: 'skill' },
2334
- { key: 'expert', label: '专家', icon: 'expert' },
2335
- { key: 'tailor', label: '工具裁缝', icon: 'scissors' },
2336
- { key: 'wb', label: '市场', icon: 'store' },
2421
+ { key: 'expert', label: t('专家'), icon: 'expert' },
2422
+ { key: 'tailor', label: t('工具裁缝'), icon: 'scissors' },
2423
+ { key: 'wb', label: t('市场'), icon: 'store' },
2337
2424
  ];
2338
2425
  // 宿主侧接口不存在(HTTP 404 / transport failure)几乎总是「宿主还没重启」:
2339
2426
  // 客户端 bundle 从磁盘现读、改完刷新即生效,而宿主的 Remote 方法在启动时才注册。
2340
2427
  // 这里把生硬的传输错误翻译成可操作的提示,而不是让用户对着 404 猜。
2341
2428
  const skillTransportHint = (message) =>
2342
2429
  /404|transport failure/i.test(String(message))
2343
- ? String(message) + ' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可'
2430
+ ? String(message) + t(' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
2344
2431
  : String(message);
2345
2432
 
2346
2433
  // ── Skill 看板:列表 + 详情 ──────────────────────────────────────────
@@ -2370,10 +2457,10 @@ const SkillBoard = ({ call, scope, timer }) => {
2370
2457
  if (res && res.ok) {
2371
2458
  setSkills(res.skills || []);
2372
2459
  setAvailable(res.available !== false);
2373
- setError(res.available === false ? '宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用' : '');
2460
+ setError(res.available === false ? t('宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用') : '');
2374
2461
  } else {
2375
2462
  setAvailable(false);
2376
- setError(skillTransportHint((res && res.error) || '读取 skill 列表失败'));
2463
+ setError(skillTransportHint((res && res.error) || t('读取 skill 列表失败')));
2377
2464
  }
2378
2465
  } catch (e) {
2379
2466
  if (seq !== loadSeq.current) return;
@@ -2417,7 +2504,7 @@ const SkillBoard = ({ call, scope, timer }) => {
2417
2504
  const res = await call('skillDetail', { ...skillScopeArg(scope), name });
2418
2505
  if (seq !== detailSeq.current) return;
2419
2506
  if (res && res.ok && res.skill) setDetail(res.skill);
2420
- else setDetailError((res && res.error) || '读取 skill 内容失败');
2507
+ else setDetailError((res && res.error) || t('读取 skill 内容失败'));
2421
2508
  } catch (e) {
2422
2509
  if (seq !== detailSeq.current) return;
2423
2510
  setDetailError(String((e && e.message) || e));
@@ -2434,19 +2521,19 @@ const SkillBoard = ({ call, scope, timer }) => {
2434
2521
  h('div', { className: 'dsh-mcp-skill-side' },
2435
2522
  h('div', { className: 'dsh-mcp-search' },
2436
2523
  h(Icon, { d: ICONS.search }),
2437
- h('input', { placeholder: '搜索 skill…', value: query, onChange: (e) => setQuery(e.target.value), 'aria-label': '搜索 skill' }),
2524
+ h('input', { placeholder: t('搜索 skill…'), value: query, onChange: (e) => setQuery(e.target.value), 'aria-label': t('搜索 skill') }),
2438
2525
  ),
2439
2526
  h('div', { className: 'dsh-mcp-item-sub' },
2440
- h('span', null, (loading && !refreshing) ? '读取中…' : (skills.length + ' 个 skill')),
2441
- refreshing ? h('span', null, ' · 刷新中…') : null,
2527
+ h('span', null, (loading && !refreshing) ? t('读取中…') : (skills.length + t(' 个 skill'))),
2528
+ refreshing ? h('span', null, t(' · 刷新中…')) : null,
2442
2529
  ),
2443
- h('div', { className: 'dsh-mcp-skill-list', role: 'listbox', 'aria-label': 'skill 列表' },
2530
+ h('div', { className: 'dsh-mcp-skill-list', role: 'listbox', 'aria-label': t('skill 列表') },
2444
2531
  groups.length === 0 && !loading
2445
- ? h('div', { className: 'dsh-mcp-skill-empty' }, '这个作用域下没有发现 skill。skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现。')
2532
+ ? h('div', { className: 'dsh-mcp-skill-empty' }, t('这个作用域下没有发现 skill。skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现。'))
2446
2533
  : groups.map((g) => h(React2.Fragment, { key: g.source },
2447
2534
  h('div', { className: 'dsh-mcp-item-sub', style: { marginTop: '6px' } },
2448
2535
  h(Badge, { cls: 't-stdio' }, skillSourceLabel(g.source)),
2449
- h('span', null, g.rows.length + ' 个'),
2536
+ h('span', null, g.rows.length + t(' 个')),
2450
2537
  ),
2451
2538
  g.rows.map((s) => h('button', {
2452
2539
  key: s.name,
@@ -2459,8 +2546,8 @@ const SkillBoard = ({ call, scope, timer }) => {
2459
2546
  h('div', { className: 'dsh-mcp-skill-main' },
2460
2547
  h('div', { className: 'dsh-mcp-skill-name' }, s.name),
2461
2548
  h('div', { className: 'dsh-mcp-skill-sub' },
2462
- s.modelInvocable ? h(Badge, { cls: 't-http' }, '模型可调用') : null,
2463
- s.userInvocable ? h(Badge, { cls: 't-stdio' }, '用户可调用') : null,
2549
+ s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
2550
+ s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
2464
2551
  h('span', { className: 'dsh-mcp-skill-src' }, s.provider || ''),
2465
2552
  ),
2466
2553
  s.description ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description) : null,
@@ -2471,7 +2558,7 @@ const SkillBoard = ({ call, scope, timer }) => {
2471
2558
  ),
2472
2559
  h('div', { className: 'dsh-mcp-skill-detail' },
2473
2560
  !summary && !detailLoading
2474
- ? h('div', { className: 'dsh-mcp-skill-empty' }, '从左侧选择一个 skill 查看详情')
2561
+ ? h('div', { className: 'dsh-mcp-skill-empty' }, t('从左侧选择一个 skill 查看详情'))
2475
2562
  : null,
2476
2563
  summary ? h('div', { className: 'dsh-mcp-skill-d-head' },
2477
2564
  h('div', null,
@@ -2483,29 +2570,29 @@ const SkillBoard = ({ call, scope, timer }) => {
2483
2570
  summary.path ? h('button', {
2484
2571
  type: 'button',
2485
2572
  className: 'dsh-mcp-skill-d-path',
2486
- title: '把 skill 所在目录交给宿主在资源管理器中打开(只读定位)',
2573
+ title: t('把 skill 所在目录交给宿主在资源管理器中打开(只读定位)'),
2487
2574
  onClick: () => { call('revealSkill', { path: summary.path }); },
2488
- }, h(Icon, { d: ICONS.folder }), '打开目录') : null,
2575
+ }, h(Icon, { d: ICONS.folder }), t('打开目录')) : null,
2489
2576
  ) : null,
2490
2577
  summary ? h('div', { className: 'dsh-mcp-skill-d-body' },
2491
2578
  h('div', { className: 'dsh-mcp-skill-d-sec' },
2492
- h('b', null, '描述'),
2493
- h('p', { className: 'dsh-mcp-skill-d-desc' }, summary.description || '(无描述)'),
2579
+ h('b', null, t('描述')),
2580
+ h('p', { className: 'dsh-mcp-skill-d-desc' }, summary.description || t('(无描述)')),
2494
2581
  ),
2495
2582
  summary.whenToUse ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2496
- h('b', null, '何时使用'),
2583
+ h('b', null, t('何时使用')),
2497
2584
  h('p', { className: 'dsh-mcp-skill-d-desc' }, summary.whenToUse),
2498
2585
  ) : null,
2499
2586
  h('div', { className: 'dsh-mcp-skill-d-sec' },
2500
- h('b', null, '调用策略'),
2587
+ h('b', null, t('调用策略')),
2501
2588
  h('p', { className: 'dsh-mcp-skill-d-desc' },
2502
- '模型可调用:' + (summary.modelInvocable ? '是' : '否')
2503
- + ' 用户可调用:' + (summary.userInvocable ? '是' : '否')),
2589
+ t('模型可调用:') + (summary.modelInvocable ? t('是') : t('否'))
2590
+ + t(' 用户可调用:') + (summary.userInvocable ? t('是') : t('否'))),
2504
2591
  ),
2505
- detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, '读取 skill 内容…') : null,
2592
+ detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, t('读取 skill 内容…')) : null,
2506
2593
  detailError ? h('div', { className: 'dsh-mcp-d-err' }, detailError) : null,
2507
2594
  detail && detail.content !== undefined ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2508
- h('b', null, '指令内容(' + detail.content.length + ' 字符)'),
2595
+ h('b', null, t('指令内容(') + detail.content.length + t(' 字符)')),
2509
2596
  h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.content),
2510
2597
  ) : null,
2511
2598
  ) : null,
@@ -2521,11 +2608,12 @@ const SkillBoard = ({ call, scope, timer }) => {
2521
2608
  // 已安装标记:技能按 wbSkillInstalled 的目录记录——只在进入面板时取一次,装/卸后局部刷新。
2522
2609
  const WB_MARKET_CONNECTOR_PAGE_SIZE = 60;
2523
2610
  const WB_SKILLHUB_SORTS = [
2524
- { key: 'downloads', label: '最多下载' },
2525
- { key: 'installs', label: '最多安装' },
2526
- { key: 'stars', label: '最多星标' },
2527
- { key: 'score', label: '综合评分' },
2611
+ { key: 'downloads', label: t('最多下载') },
2612
+ { key: 'installs', label: t('最多安装') },
2613
+ { key: 'stars', label: t('最多星标') },
2614
+ { key: 'score', label: t('综合评分') },
2528
2615
  ];
2616
+
2529
2617
  // ── 专家(WorkBuddy 专家中心直连)────────────────────────────────────
2530
2618
  // 两个页面一个数据源:ExpertInstalledBoard = 看板「专家」分类(管理**已召唤**的专家:
2531
2619
  // 预设状态、捆绑技能、送还、打开看板入口说明);ExpertMarket = 市场「专家」子标签
@@ -2537,7 +2625,7 @@ let expertCatalogCache = { at: 0, data: null };
2537
2625
  const loadExpertCatalogShared = async (call, force = false) => {
2538
2626
  if (!force && expertCatalogCache.data && Date.now() - expertCatalogCache.at < EXPERT_CATALOG_TTL_MS) return expertCatalogCache.data;
2539
2627
  const res = await call('wbExpertCatalog');
2540
- if (!res?.ok) throw new Error(res?.error || '读取专家目录失败');
2628
+ if (!res?.ok) throw new Error(res?.error || t('读取专家目录失败'));
2541
2629
  expertCatalogCache = { at: Date.now(), data: { experts: res.experts || [], categories: res.categories || [], loadedFrom: res.loadedFrom || '' } };
2542
2630
  return expertCatalogCache.data;
2543
2631
  };
@@ -2552,13 +2640,12 @@ const ExpertInstalledBoard = ({ call }) => {
2552
2640
  const [presets, setPresets] = useState([]);
2553
2641
  const [skills, setSkills] = useState([]);
2554
2642
  const [detail, setDetail] = useState(null);
2555
- const [withSkills, setWithSkills] = useState(true);
2556
2643
 
2557
2644
  const load = useCallback(async () => {
2558
2645
  setLoading(true); setError(''); setNote('');
2559
2646
  try {
2560
2647
  const res = await call('wbExpertInstalled');
2561
- if (!res?.ok) throw new Error(res?.error || '读取已召唤专家失败');
2648
+ if (!res?.ok) throw new Error(res?.error || t('读取已召唤专家失败'));
2562
2649
  setPresets(res.presets || []);
2563
2650
  setSkills(res.skills || []);
2564
2651
  } catch (e) {
@@ -2571,11 +2658,11 @@ const ExpertInstalledBoard = ({ call }) => {
2571
2658
 
2572
2659
  // 送还:与市场页同一写链;送还后刷新本页。
2573
2660
  const dismiss = async (plugin, name) => {
2574
- setBusy(true); setBusyLabel(`正在送还 ${name || plugin}…`); setError('');
2661
+ setBusy(true); setBusyLabel(t('正在送还 {0}…', [name || plugin])); setError('');
2575
2662
  try {
2576
2663
  const res = await call('wbExpertDismiss', { plugin });
2577
- if (!res?.ok) throw new Error(res?.error || '送还失败');
2578
- setNote(res.note || `${name || plugin} 已送还`);
2664
+ if (!res?.ok) throw new Error(res?.error || t('送还失败'));
2665
+ setNote(res.note || t('{0} 已送还', [name || plugin]));
2579
2666
  setDetail(null);
2580
2667
  await load();
2581
2668
  } catch (e) {
@@ -2589,7 +2676,7 @@ const ExpertInstalledBoard = ({ call }) => {
2589
2676
  setNote('');
2590
2677
  try {
2591
2678
  const res = await call('wbExpertDetail', { plugin });
2592
- if (!res?.ok) throw new Error(res?.error || '读取详情失败');
2679
+ if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
2593
2680
  setDetail({ ...res, loading: false });
2594
2681
  } catch (e) {
2595
2682
  setDetail({ plugin, loading: false, detailError: String(e?.message || e) });
@@ -2603,12 +2690,12 @@ const ExpertInstalledBoard = ({ call }) => {
2603
2690
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
2604
2691
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
2605
2692
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
2606
- loading ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取已召唤专家…') : null,
2693
+ loading ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取已召唤专家…')) : null,
2607
2694
  !loading && !presets.length && !skills.length
2608
2695
  ? h('div', { className: 'dsh-mcp-wb-empty' },
2609
- '还没有召唤任何专家。到 ',
2610
- h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, '市场 → 专家'),
2611
- ' 挑选一位召唤;召唤后它会出现在这里,并作为独立 Agent 预设出现在新会话的预设选择器里。')
2696
+ t('还没有召唤任何专家。到 '),
2697
+ h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, t('市场 → 专家')),
2698
+ t(' 挑选一位召唤;召唤后它会出现在这里,并作为独立 Agent 预设出现在新会话的预设选择器里。'))
2612
2699
  : null,
2613
2700
  !loading && presets.length
2614
2701
  ? h('div', { className: 'dsh-mcp-wb-grid' },
@@ -2620,13 +2707,13 @@ const ExpertInstalledBoard = ({ call }) => {
2620
2707
  h('div', { className: 'dsh-mcp-card-name' }, row.plugin),
2621
2708
  h('div', { className: 'dsh-mcp-card-vendor' }, row.presetId),
2622
2709
  ),
2623
- h(Badge, { cls: 's-on' }, '已召唤'),
2710
+ h(Badge, { cls: 's-on' }, t('已召唤')),
2624
2711
  ),
2625
2712
  h('div', { className: 'dsh-mcp-card-summary' },
2626
- boundSkills.length ? `捆绑技能:${boundSkills.join('、')}` : '无捆绑技能(预设本体在 profile 补丁)'),
2713
+ boundSkills.length ? t('捆绑技能:{0}', [boundSkills.join(t('、'))]) : t('无捆绑技能(预设本体在 profile 补丁)')),
2627
2714
  h('div', { className: 'dsh-mcp-wb-card-actions' },
2628
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(row.plugin) }, '详情'),
2629
- h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => dismiss(row.plugin, row.plugin) }, '送还'),
2715
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(row.plugin) }, t('详情')),
2716
+ h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => dismiss(row.plugin, row.plugin) }, t('送还')),
2630
2717
  ),
2631
2718
  );
2632
2719
  }),
@@ -2635,10 +2722,10 @@ const ExpertInstalledBoard = ({ call }) => {
2635
2722
  // 详情弹层:与市场页共用结构(只读 + 送还)。
2636
2723
  detail
2637
2724
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2638
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': '已召唤专家详情', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
2639
- h('h3', null, detail.name ? `${detail.name}(${detail.profession || ''})` : detail.plugin),
2725
+ h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('已召唤专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
2726
+ h('h3', null, detail.name ? t('{0}({1})', [detail.name, detail.profession || '']) : detail.plugin),
2640
2727
  detail.loading
2641
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '正在拉取详情…')
2728
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取详情…'))
2642
2729
  : h('div', null,
2643
2730
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
2644
2731
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
@@ -2648,16 +2735,16 @@ const ExpertInstalledBoard = ({ call }) => {
2648
2735
  : null,
2649
2736
  detail.persona
2650
2737
  ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2651
- h('b', null, `人设正文(${detail.personaChars || detail.persona.length} 字符)`),
2652
- h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.persona.length > 4000 ? detail.persona.slice(0, 4000) + '\n…(预览截断)' : detail.persona),
2738
+ h('b', null, t('人设正文({0} 字符)', [detail.personaChars || detail.persona.length])),
2739
+ h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.persona.length > 4000 ? detail.persona.slice(0, 4000) + t('\n…(预览截断)') : detail.persona),
2653
2740
  )
2654
2741
  : null,
2655
2742
  h('p', { className: 'dsh-mcp-wb-modal-note' },
2656
- '该专家以独立 Agent 预设生效:新建会话时在预设选择器选择「WorkBuddy 专家 · …」即专家接管。送还会同时清掉它的捆绑技能。'),
2743
+ t('该专家以独立 Agent 预设生效:新建会话时在预设选择器选择「WorkBuddy 专家 · …」即专家接管。送还会同时清掉它的捆绑技能。')),
2657
2744
  ),
2658
2745
  h('div', { className: 'dsh-mcp-modal-actions' },
2659
- h(Btn, { onClick: () => setDetail(null), disabled: busy, children: '关闭' }),
2660
- h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail.plugin, detail.name), children: '送还' }),
2746
+ h(Btn, { onClick: () => setDetail(null), disabled: busy, children: t('关闭') }),
2747
+ h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail.plugin, detail.name), children: t('送还') }),
2661
2748
  ),
2662
2749
  ),
2663
2750
  )
@@ -2678,14 +2765,17 @@ const ExpertMarket = ({ call }) => {
2678
2765
  const [category, setCategory] = useState('all');
2679
2766
  const [page, setPage] = useState(1);
2680
2767
  const [detail, setDetail] = useState(null);
2681
- const [presets, setPresets] = useState(new Set());
2768
+ // 已召唤预设行(含 personaText):团队行需要人设正文做 stale 体检——
2769
+ // 旧版本召唤写入的团队预设没有桥接段,新会话选它会调不存在的 TeamCreate/Agent。
2770
+ const [presetRows, setPresetRows] = useState([]);
2771
+ const presets = useMemo(() => new Set(presetRows.map((row) => row.plugin)), [presetRows]);
2682
2772
  const [withSkills, setWithSkills] = useState(true);
2683
2773
  const [installedSkillPlugins, setInstalledSkillPlugins] = useState(new Set());
2684
2774
 
2685
2775
  const loadInstalled = useCallback(async () => {
2686
2776
  const res = await call('wbExpertInstalled');
2687
2777
  if (res?.ok) {
2688
- setPresets(new Set((res.presets || []).map((row) => row.plugin)));
2778
+ setPresetRows(res.presets || []);
2689
2779
  setInstalledSkillPlugins(new Set((res.skills || []).map((row) => row.plugin)));
2690
2780
  }
2691
2781
  }, [call]);
@@ -2711,18 +2801,31 @@ const ExpertMarket = ({ call }) => {
2711
2801
  setNote('');
2712
2802
  try {
2713
2803
  const res = await call('wbExpertDetail', { plugin: expert.plugin });
2714
- if (!res?.ok) throw new Error(res?.error || '读取详情失败');
2804
+ if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
2715
2805
  setDetail({ ...expert, ...res, loading: false });
2716
2806
  } catch (e) {
2717
2807
  setDetail({ ...expert, loading: false, detailError: String(e?.message || e) });
2718
2808
  }
2719
2809
  };
2720
2810
  const summon = async (expert) => {
2721
- setBusy(true); setBusyLabel(`正在召唤 ${expert.name}(写入召唤预设${withSkills ? ' + 捆绑技能' : ''})…`); setError('');
2811
+ const isTeam = expert.expertType === 'team';
2812
+ // 更新预设(stale 团队行):走同一条召唤链,后端 upsert 整行重写(人设刷新为最新版)。
2813
+ // 判定与卡片按钮同源(expertCardActions),避免两处各写一套再走偏。
2814
+ const updating = expertCardActions({
2815
+ summoned: presets.has(expert.plugin),
2816
+ expertType: expert.expertType,
2817
+ personaText: personaTextOf(presetRows, expert.plugin),
2818
+ }).update;
2819
+ const suffix = updating
2820
+ ? t('(刷新旧版预设行的人设)')
2821
+ : isTeam
2822
+ ? t('(写入召唤预设 + 落盘成员人设)')
2823
+ : (withSkills ? t(' + 捆绑技能') : '');
2824
+ setBusy(true); setBusyLabel(t('正在召唤 {0}{1}…', [expert.name, suffix])); setError('');
2722
2825
  try {
2723
- const res = await call('wbExpertSummon', { plugin: expert.plugin, withSkills });
2724
- if (!res?.ok) throw new Error(res?.error || '召唤失败');
2725
- setNote(res.note || `${expert.name} 已召唤`);
2826
+ const res = await call('wbExpertSummon', { plugin: expert.plugin, withSkills: updating ? true : withSkills });
2827
+ if (!res?.ok) throw new Error(res?.error || t('召唤失败'));
2828
+ setNote(res.note || t('{0} 已召唤', [expert.name]));
2726
2829
  setDetail(null);
2727
2830
  await loadInstalled();
2728
2831
  } catch (e) {
@@ -2732,11 +2835,11 @@ const ExpertMarket = ({ call }) => {
2732
2835
  }
2733
2836
  };
2734
2837
  const dismiss = async (expert) => {
2735
- setBusy(true); setBusyLabel(`正在送还 ${expert.name}…`); setError('');
2838
+ setBusy(true); setBusyLabel(t('正在送还 {0}…', [expert.name])); setError('');
2736
2839
  try {
2737
2840
  const res = await call('wbExpertDismiss', { plugin: expert.plugin });
2738
- if (!res?.ok) throw new Error(res?.error || '送还失败');
2739
- setNote(res.note || `${expert.name} 已送还`);
2841
+ if (!res?.ok) throw new Error(res?.error || t('送还失败'));
2842
+ setNote(res.note || t('{0} 已送还', [expert.name]));
2740
2843
  setDetail(null);
2741
2844
  await loadInstalled();
2742
2845
  } catch (e) {
@@ -2762,48 +2865,72 @@ const ExpertMarket = ({ call }) => {
2762
2865
  return h('div', null,
2763
2866
  h('div', { className: 'dsh-mcp-wb-toolbar' },
2764
2867
  h(WbSearchBox, {
2765
- value: query, placeholder: '搜索专家(花名 / 职业 / 描述 / 标签)', disabled: loading,
2868
+ value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), disabled: loading,
2766
2869
  onChange: (value) => { setQuery(value); setPage(1); },
2767
2870
  }),
2768
2871
  h('select', {
2769
2872
  className: 'dsh-mcp-input dsh-mcp-wb-sort', value: searching ? 'all' : category, disabled: loading || searching,
2770
- title: searching ? '搜索时自动在全库匹配(忽略分类)' : undefined,
2873
+ title: searching ? t('搜索时自动在全库匹配(忽略分类)') : undefined,
2771
2874
  onChange: (e) => { setCategory(e.target.value); setPage(1); },
2772
- }, h('option', { key: 'all', value: 'all' }, '全部专家(' + experts.length + ')'),
2773
- categories.map((cat) => h('option', { key: cat.id, value: cat.id }, `${cat.name}(${experts.filter((item) => item.categoryId === cat.id).length})`))),
2875
+ }, h('option', { key: 'all', value: 'all' }, t('全部专家(') + experts.length + t(')')),
2876
+ categories.map((cat) => h('option', { key: cat.id, value: cat.id }, t('{0}({1})', [cat.name, experts.filter((item) => item.categoryId === cat.id).length])))),
2774
2877
  h('span', { className: 'dsh-mcp-builtin-count' },
2775
- `显示 ${filtered.length} / ${experts.length}${searching ? ' · 全库搜索' : ''}${source === 'network' ? ' · 已更新' : source === 'disk' ? ' · 缓存' : ''}`),
2878
+ t('显示 {0} / {1}', [filtered.length, experts.length]) + (searching ? t(' · 全库搜索') : '') + (source === 'network' ? t(' · 已更新') : source === 'disk' ? t(' · 缓存') : '')),
2776
2879
  ),
2777
2880
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
2778
2881
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
2779
2882
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
2780
- loading && !experts.length ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取专家目录…') : null,
2781
- !loading && !filtered.length ? h('div', { className: 'dsh-mcp-wb-empty' }, '没有匹配的专家,换个关键词或分类试试') : null,
2883
+ loading && !experts.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取专家目录…')) : null,
2884
+ !loading && !filtered.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('没有匹配的专家,换个关键词或分类试试')) : null,
2782
2885
  visible.length
2783
2886
  ? h('div', { className: 'dsh-mcp-wb-grid' },
2784
2887
  visible.map((item) => {
2785
2888
  const summoned = presets.has(item.plugin);
2889
+ // 按钮判定走模块作用域纯函数(可被 node 测到,见 expertCardActions 注释)。
2890
+ const actions = expertCardActions({
2891
+ summoned,
2892
+ expertType: item.expertType,
2893
+ personaText: personaTextOf(presetRows, item.plugin),
2894
+ });
2895
+ const staleTeam = actions.update;
2786
2896
  return h('div', { key: item.plugin, className: 'dsh-mcp-card wb' + (summoned ? ' installed' : '') },
2787
2897
  h('div', { className: 'dsh-mcp-card-top' },
2788
2898
  h('div', null,
2789
- h('div', { className: 'dsh-mcp-card-name' }, `${item.name}(${item.profession})`),
2899
+ h('div', { className: 'dsh-mcp-card-name' }, t('{0}({1})', [item.name, item.profession])),
2790
2900
  h('div', { className: 'dsh-mcp-card-vendor' }, item.categoryName || item.categoryId),
2791
2901
  ),
2792
- summoned ? h(Badge, { cls: 's-on' }, '已召唤') : null,
2902
+ staleTeam
2903
+ ? h(Badge, {
2904
+ cls: 's-warn dot clickable',
2905
+ role: 'button',
2906
+ tabIndex: 0,
2907
+ title: t('这条预设是旧版本召唤写入的,人设缺 DSH 桥接段——用它的新会话开不出成员子智能体。点此直接更新。'),
2908
+ onClick: () => { if (!busy) summon(item); },
2909
+ // role=button + tabIndex=0 就必须能键盘触发,否则是「假按钮」。
2910
+ onKeyDown: (e) => { if (!busy && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); summon(item); } },
2911
+ }, t('需更新 · 点击更新'))
2912
+ : summoned ? h(Badge, { cls: 's-on' }, t('已召唤')) : null,
2793
2913
  ),
2794
2914
  h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.profession),
2795
2915
  h('div', { className: 'dsh-mcp-card-tags' },
2796
- h(Badge, null, item.expertType === 'team' ? '专家团' : '专家'),
2916
+ h(Badge, null, item.expertType === 'team' ? t('专家团') : t('专家')),
2797
2917
  item.tags.map((tag) => h(Badge, { key: tag }, tag)),
2798
- installedSkillPlugins.has(item.plugin) ? h(Badge, null, '含技能') : null,
2918
+ installedSkillPlugins.has(item.plugin) ? h(Badge, null, t('含技能')) : null,
2799
2919
  ),
2800
2920
  h('div', { className: 'dsh-mcp-wb-card-actions' },
2801
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(item) }, '详情'),
2802
- item.expertType === 'team'
2803
- ? h('span', { className: 'dsh-mcp-wb-blocked' }, '团队型暂只读')
2804
- : summoned
2805
- ? h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => dismiss(item) }, '送还')
2806
- : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => summon(item) }, '召唤'),
2921
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(item) }, t('详情')),
2922
+ // 团队型已走桥接:主理人接管会话,成员人设落盘后由主理人用 subagent 调度。
2923
+ // stale 团队行:「更新预设」与「送还」**并列**(后端 upsert 原地重写该行,
2924
+ // 用户也可能只想删掉旧行)。1.9.4 这里曾写成三元,导致更新按钮永不渲染。
2925
+ actions.update
2926
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => summon(item) }, t('更新预设'))
2927
+ : null,
2928
+ actions.dismiss
2929
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => dismiss(item) }, t('送还'))
2930
+ : null,
2931
+ actions.summon
2932
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => summon(item) }, t('召唤'))
2933
+ : null,
2807
2934
  ),
2808
2935
  );
2809
2936
  }),
@@ -2811,60 +2938,97 @@ const ExpertMarket = ({ call }) => {
2811
2938
  : null,
2812
2939
  pageCount > 1
2813
2940
  ? h('div', { className: 'dsh-mcp-wb-pager' },
2814
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safePage <= 1, onClick: () => setPage(safePage - 1) }, '上一页'),
2941
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safePage <= 1, onClick: () => setPage(safePage - 1) }, t('上一页')),
2815
2942
  h('span', { className: 'dsh-mcp-builtin-count' }, `${safePage} / ${pageCount}`),
2816
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safePage >= pageCount, onClick: () => setPage(safePage + 1) }, '下一页'),
2943
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safePage >= pageCount, onClick: () => setPage(safePage + 1) }, t('下一页')),
2817
2944
  )
2818
2945
  : null,
2819
2946
  // ── 专家详情弹层:召唤前看人设原文 + 捆绑技能 ──
2820
2947
  detail
2821
- ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2822
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': '专家详情', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
2823
- h('h3', null, `${detail.name}(${detail.profession})`),
2948
+ ? (() => {
2949
+ // 弹层里该给哪些按钮:与卡片按钮同源(expertCardActions),只是换到详情对象上算。
2950
+ const detailActions = expertCardActions({
2951
+ summoned: presets.has(detail.plugin),
2952
+ expertType: detail.expertType,
2953
+ personaText: personaTextOf(presetRows, detail.plugin),
2954
+ });
2955
+ return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2956
+ h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
2957
+ h('h3', null, t('{0}({1})', [detail.name, detail.profession])),
2824
2958
  h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
2825
2959
  detail.loading
2826
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '正在拉取人设与技能清单…')
2960
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取人设与技能清单…'))
2827
2961
  : h('div', null,
2828
2962
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
2829
2963
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
2830
2964
  detail.quickPrompts && detail.quickPrompts.length
2831
2965
  ? h('div', { className: 'dsh-mcp-wb-examples' },
2832
- h('b', null, '试试这样问'),
2966
+ h('b', null, t('试试这样问')),
2833
2967
  h('ul', null, detail.quickPrompts.slice(0, 3).map((prompt, index) => h('li', { key: index }, prompt))),
2834
2968
  )
2835
2969
  : null,
2836
2970
  Array.isArray(detail.skills)
2837
2971
  ? h('div', { className: 'dsh-mcp-card-tags' },
2838
2972
  detail.skills.length
2839
- ? detail.skills.map((skill) => h(Badge, { key: skill.name, cls: skill.local ? 's-on' : '' }, `${skill.name}${skill.local ? '(本机可完整安装)' : '(仅 SKILL.md)'}`))
2840
- : h(Badge, null, '无捆绑技能'),
2973
+ ? detail.skills.map((skill) => h(Badge, { key: skill.name, cls: skill.local ? 's-on' : '' }, t('{0}{1}', [skill.name, skill.local ? t('(本机可完整安装)') : t('(仅 SKILL.md)')])))
2974
+ : h(Badge, null, t('无捆绑技能')),
2841
2975
  )
2842
2976
  : null,
2843
2977
  detail.persona
2844
2978
  ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2845
- h('b', null, `人设正文(${detail.personaChars || detail.persona.length} 字符,召唤后成为会话系统提示)`),
2846
- h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.persona.length > 4000 ? detail.persona.slice(0, 4000) + '\n…(预览截断,召唤时写入完整正文)' : detail.persona),
2979
+ h('b', null, t('人设正文({0} 字符,召唤后成为会话系统提示)', [detail.personaChars || detail.persona.length])),
2980
+ h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.persona.length > 4000 ? detail.persona.slice(0, 4000) + t('\n…(预览截断,召唤时写入完整正文)') : detail.persona),
2847
2981
  )
2848
2982
  : null,
2983
+ // 团队型:先给人看编制(成员是谁、负责什么),再决定召唤。
2984
+ detail.expertType === 'team' && Array.isArray(detail.members) && detail.members.length
2985
+ ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2986
+ h('b', null, t('团队编制({0} 人 · 召唤后成员人设落盘,由主理人调度)', [detail.members.length])),
2987
+ h('ul', { className: 'dsh-mcp-wb-roster' },
2988
+ detail.members.map((member) => h('li', { key: member.id },
2989
+ h('b', null, member.name || member.id),
2990
+ h('span', { className: 'dsh-mcp-wb-roster-role' }, member.role === 'lead' ? t('主理人') : member.profession || t('成员')),
2991
+ member.profession && member.role === 'lead' ? h('span', { className: 'dsh-mcp-wb-roster-prof' }, t('({0})', [member.profession])) : null,
2992
+ )),
2993
+ ),
2994
+ )
2995
+ : null,
2996
+ detail.expertType === 'team'
2997
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' },
2998
+ t('团队型走 DSH 桥接:主理人人设接管会话,') +
2999
+ t('每位成员的人设落盘到 ~/.dsh/wb-market/experts/teams/ 下,主理人按 SOP 依次用 subagent 调度(人设正文注入 prompt),') +
3000
+ t('成员产出回传主理人汇总。WorkBuddy 的 TeamCreate 无需调用 —— 团队编制在召唤时已固定。'))
3001
+ : null,
2849
3002
  detail.expertType !== 'team'
2850
3003
  ? h('label', { className: 'dsh-mcp-check' },
2851
3004
  h('input', { type: 'checkbox', checked: withSkills, disabled: busy, onChange: (e) => setWithSkills(e.target.checked) }),
2852
- '一并安装捆绑技能(本机 WorkBuddy 缓存完整安装;无缓存时仅装 SKILL.md)')
3005
+ t('一并安装捆绑技能(本机 WorkBuddy 缓存完整安装;无缓存时仅装 SKILL.md)'))
2853
3006
  : null,
2854
3007
  h('p', { className: 'dsh-mcp-wb-modal-note' },
2855
- '召唤 = 往 profile 补丁写入一条独立 Agent 预设(人设全程接管该预设的会话,标准工具原样保留);' +
2856
- '新建会话时选择「WorkBuddy 专家 · ' + detail.name + '」即专家接管。送还 = 摘除该预设与已装捆绑技能(热加载,无需重启)。'),
3008
+ t('召唤 = 往 profile 补丁写入一条独立 Agent 预设(人设全程接管该预设的会话,标准工具原样保留);') +
3009
+ t('新建会话时选择「WorkBuddy 专家 · ') + detail.name + t('」即专家接管。送还 = 摘除该预设与已装捆绑技能(热加载,无需重启)。')),
3010
+ // stale 团队行:详情弹层里给出直白解释 + 更新入口(同一条召唤链,upsert 整行重写)。
3011
+ // 判定与卡片按钮同源(detailActions 在上方按 expertCardActions 算出)。
3012
+ detailActions.update
3013
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', style: 'color:var(--dsw-alias-state-warn-primary)' },
3014
+ t('⚠ 这条预设是旧版本召唤写入的:人设缺「DSH 运行时适配」桥接段,用它的新会话主理人只会调不存在的 TeamCreate/Agent,成员子智能体开不出来。点下方「更新预设」重跑召唤即可刷新。'))
3015
+ : null,
2857
3016
  ),
2858
3017
  h('div', { className: 'dsh-mcp-modal-actions' },
2859
- h(Btn, { onClick: () => setDetail(null), disabled: busy, children: '关闭' }),
2860
- presets.has(detail.plugin)
2861
- ? h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail), children: '送还' })
2862
- : detail.expertType === 'team'
2863
- ? h('span', { className: 'dsh-mcp-wb-blocked' }, '团队型暂只读')
2864
- : h(Btn, { primary: true, icon: 'plus', disabled: busy || detail.loading, onClick: () => summon(detail), children: busy ? '召唤中…' : '召唤' }),
3018
+ h(Btn, { onClick: () => setDetail(null), disabled: busy, children: t('关闭') }),
3019
+ detailActions.update
3020
+ ? h(Btn, { primary: true, icon: 'refresh', disabled: busy || detail.loading, onClick: () => summon(detail), children: busy ? t('更新中…') : t('更新预设') })
3021
+ : null,
3022
+ detailActions.dismiss
3023
+ ? h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail), children: t('送还') })
3024
+ : null,
3025
+ detailActions.summon
3026
+ ? h(Btn, { primary: true, icon: 'plus', disabled: busy || detail.loading, onClick: () => summon(detail), children: busy ? t('召唤中…') : detail.expertType === 'team' ? t('召唤专家团') : t('召唤') })
3027
+ : null,
2865
3028
  ),
2866
3029
  ),
2867
- )
3030
+ );
3031
+ })()
2868
3032
  : null,
2869
3033
  );
2870
3034
  };
@@ -2873,19 +3037,36 @@ const ExpertMarket = ({ call }) => {
2873
3037
  // 会话输入框工具行右侧(conversation.input.right)的一个小图标:点击弹三分类菜单
2874
3038
  // (技能 / MCP / 专家),悬停分类展开「最近使用 / 新装」子菜单,点击条目 =
2875
3039
  // 把一条引用写进输入框草稿(技能 /name slash、MCP 点名工具前缀),并上报最近使用。
2876
- // 专家条目特殊:点击 = 调宿主 agentPresets.select(sessionId, presetId) 把专家预设
2877
- // **挂到当前会话**(blank 会话立即生效=该会话全局专家接管),输入框下方出现专家
2878
- // chip(可移除)。数据源:本插件既有 Remote + 官方 agentPresets Remote。
3040
+ // 专家条目特殊:点击 = 调本插件 expertAttach 把专家人设**挂到当前会话**(随时挂/换/
3041
+ // 摘,不走 agentPresets.select 预设通道——那条路会把预设写进会话投影且开过口锁死),
3042
+ // 输入框下方出现专家 chip(可移除)。数据源:本插件既有 Remote + 官方 agentPresets Remote。
3043
+ // 团队型 stale 体检的判定标记:与后端 lib/expert-market.js 的 TEAM_BRIDGE_MARKER 保持一致。
3044
+ const TEAM_BRIDGE_MARKER = '## DSH 运行时适配(团队协作桥接)';
3045
+ // 专家卡片该给哪些按钮:**纯函数放模块作用域**(同 CONTROLS_THRESHOLD 那一带的理由)——
3046
+ // 这段判定原先内联在 JSX 三元里,node 测不到,于是"已召唤 + stale"时「更新预设」被写进了
3047
+ // summoned===false 的恒假分支,按钮根本不渲染、点了当然没反应(1.9.4 真机 bug)。
3048
+ // update —— 已召唤、团队型、人设缺桥接段:给「更新预设」(后端 upsert 原地重写该行);
3049
+ // dismiss —— 已召唤:给「送还」;
3050
+ // summon —— 未召唤:给「召唤」。
3051
+ // update 与 dismiss **并列**(不是二选一):用户可能只想删掉这条旧行,而不是更新它。
3052
+ const expertCardActions = ({ summoned, expertType, personaText }) => ({
3053
+ update: Boolean(summoned) && expertType === 'team' && !String(personaText || '').includes(TEAM_BRIDGE_MARKER),
3054
+ dismiss: Boolean(summoned),
3055
+ summon: !summoned,
3056
+ });
3057
+ // 面板里给某条预设行找人设正文;找不到返回 ''(当作"没有桥接段"——宁可多提示一次更新,
3058
+ // 也不要让一条读不出来的旧行悄悄用坏人设开新会话)。
3059
+ const personaTextOf = (rows, plugin) => (Array.isArray(rows) ? rows.find((row) => row?.plugin === plugin)?.personaText : '') || '';
2879
3060
  const QUICK_MENU_KINDS = [
2880
- { key: 'skill', label: '技能', icon: 'skill', hint: '技能按需触发:选择后以 /技能名 插入输入框' },
2881
- { key: 'mcp', label: 'MCP', icon: 'plug', hint: 'MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具' },
2882
- { key: 'expert', label: '专家', icon: 'expert', hint: '专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消' },
3061
+ { key: 'skill', label: t('技能'), icon: 'skill', hint: t('技能按需触发:选择后以 /技能名 插入输入框') },
3062
+ { key: 'mcp', label: 'MCP', icon: 'plug', hint: t('MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具') },
3063
+ { key: 'expert', label: t('专家'), icon: 'expert', hint: t('专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消') },
2883
3064
  ];
2884
3065
  // 子菜单列表每屏固定 6 行(6 × 28px),更多条目在 .dsh-mcp-quick-list 里纵向滚动;
2885
3066
  // 「最近」徽章只标最近池里的前 6 名(首屏可见的那批),避免整列都是徽章。
2886
3067
  // 专家预设显示名前缀:召唤时写死的 `WorkBuddy 专家 · <显示名>`(见 expert-market.js
2887
3068
  // buildExpertPresetRow),宿主的会话头预设标签原样显示它——用来识别「那条会过期的标签」。
2888
- const EXPERT_PRESET_LABEL_PREFIX = 'WorkBuddy 专家 · ';
3069
+ const EXPERT_PRESET_LABEL_PREFIX = t('WorkBuddy 专家 · ');
2889
3070
  // 专家挂载状态的最小事件总线:快速菜单挂/换/摘专家后立刻通知会话头徽章与菜单 chip,
2890
3071
  // 三处显示同一帧内对齐(不再各自等重挂载)。低频轮询只作兜底,覆盖「别的入口挂的专家」。
2891
3072
  const expertChangeListeners = new Set();
@@ -2904,15 +3085,15 @@ const onExpertChange = (listener) => {
2904
3085
  // 「@文件/会话」引用语义,技能名不是文件,模型读不到就报「文件不存在」。
2905
3086
  // mcp → 明确点名 mcp__<server>__ 工具前缀:MCP 工具没有 slash 语义,直接给模型
2906
3087
  // 可执行的指令比插一个会被当文件解析的 @token 更实在。
2907
- // expert → 预设说明句(专家靠新会话选预设生效,会话内只能提示性引用)。
3088
+ // expert → 不在这里出文本:专家条目点击 = expertAttach 直接挂到当前会话(onClick
3089
+ // 分支不走 pick),历史上那句「请以预设处理」是死代码——删除并留此说明。
2908
3090
  const quickMentionText = (kind, item) => {
2909
- if (kind === 'expert') return `请以「WorkBuddy 专家 · ${item.name}」预设处理接下来的对话`;
2910
3091
  if (kind === 'mcp') {
2911
3092
  const server = String(item.name || '');
2912
- return /^[\w-]+$/.test(server) ? `请使用 ${server} 的 MCP 工具(mcp__${server}__*)处理接下来的任务` : `${server}:`;
3093
+ return /^[\w-]+$/.test(server) ? t('请使用 {0} 的 MCP 工具(mcp__{1}__*)处理接下来的任务', [server, server]) : t('{0}:', [server]);
2913
3094
  }
2914
3095
  const token = String(item.name || '');
2915
- return /^[\w-]+$/.test(token) ? `/${token} ` : `${token}:`;
3096
+ return /^[\w-]+$/.test(token) ? `/${token} ` : t('{0}:', [token]);
2916
3097
  };
2917
3098
  const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2918
3099
  const [open, setOpen] = useState(false);
@@ -3027,8 +3208,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3027
3208
  // ③ 复制进剪贴板 + 按钮上闪现「已复制」(绝不跳走:跳页是上一版的行为缺陷)。
3028
3209
  const pick = async (kind, item, text) => {
3029
3210
  setOpen(false); setHoverKind(null);
3030
- // 上报最近使用(失败静默:纯增值数据)。
3031
- const row = kind === 'expert' ? { plugin: item.plugin, name: item.name || item.plugin } : kind === 'mcp' ? { serverName: item.name } : { name: item.name };
3211
+ // 上报最近使用(失败静默:纯增值数据)。expert 不经 pick(点击分支直接 attachExpert)。
3212
+ const row = kind === 'mcp' ? { serverName: item.name } : { name: item.name };
3032
3213
  call('wbExpertRecordUse', { kind, row }).catch(() => {});
3033
3214
  const insertion = typeof text === 'string' ? (text.endsWith(' ') ? text : text + ' ') : String(text);
3034
3215
  if (inputActions && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
@@ -3097,7 +3278,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3097
3278
  const sessionId = typeof sessionIdProp === 'string' && sessionIdProp !== '' ? sessionIdProp : null;
3098
3279
  const attachExpert = async (item, { quiet = false } = {}) => {
3099
3280
  setHoverKind(null);
3100
- if (!sessionId) { setAttachNote('没有活动会话:请先进入一个会话再挂专家'); return; }
3281
+ if (!sessionId) { setAttachNote(t('没有活动会话:请先进入一个会话再挂专家')); return; }
3101
3282
  setAttachBusy(true); setAttachNote('');
3102
3283
  try {
3103
3284
  // 只走会话级人设通道(expertAttach):随时挂 / 换 / 摘,会话开过口也不锁。
@@ -3107,15 +3288,20 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3107
3288
  const res = await call('expertAttach', { sessionId, plugin: item.plugin });
3108
3289
  if (res && res.ok) {
3109
3290
  setAttached({ plugin: item.plugin, name: res.name || item.name || item.plugin, mode: 'persona' });
3110
- setAttachNote('✓ 已挂到当前会话:' + (res.name || item.name || item.plugin) + (res.replaced ? '(已替换原专家)' : ''));
3291
+ // 团队型追加提示:人设通道不动工具目录,成员要靠 subagent 调度。若当前会话
3292
+ // 没有 subagent 工具(预设没带 delegation 组),告诉用户去召唤预设的新会话。
3293
+ const teamHint = res.expertType === 'team'
3294
+ ? t(';成员由主理人用 subagent 调度——若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设', [res.name || item.name || item.plugin])
3295
+ : '';
3296
+ setAttachNote(t('✓ 已挂到当前会话:') + (res.name || item.name || item.plugin) + (res.replaced ? t('(已替换原专家)') : '') + teamHint);
3111
3297
  call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
3112
3298
  // 立即广播:会话头徽章 + 陈旧预设标签的隐藏同步生效,三处显示同帧对齐。
3113
3299
  emitExpertChange();
3114
3300
  } else {
3115
- setAttachNote('挂载失败:' + ((res && res.error) || '未知错误'));
3301
+ setAttachNote(t('挂载失败:') + ((res && res.error) || t('未知错误')));
3116
3302
  }
3117
3303
  } catch (e) {
3118
- if (!quiet) setAttachNote('挂载异常:' + String((e && e.message) || e));
3304
+ if (!quiet) setAttachNote(t('挂载异常:') + String((e && e.message) || e));
3119
3305
  } finally {
3120
3306
  setAttachBusy(false);
3121
3307
  }
@@ -3128,11 +3314,11 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3128
3314
  // 的分支,也就无需回退默认预设——预设选择器保持用户自己的选择,不被插件触碰。
3129
3315
  const res = await call('expertDetach', { sessionId }).catch(() => null);
3130
3316
  setAttached(null);
3131
- setAttachNote(res && res.ok && res.removed ? '✓ 已取消本会话的专家' : '本会话没有挂着专家');
3317
+ setAttachNote(res && res.ok && res.removed ? t('✓ 已取消本会话的专家') : t('本会话没有挂着专家'));
3132
3318
  // 摘掉也要广播:会话头徽章退场、被隐身的预设标签复原。
3133
3319
  emitExpertChange();
3134
3320
  } catch (e) {
3135
- setAttachNote('取消失败:' + String((e && e.message) || e));
3321
+ setAttachNote(t('取消失败:') + String((e && e.message) || e));
3136
3322
  } finally {
3137
3323
  setAttachBusy(false);
3138
3324
  }
@@ -3152,8 +3338,11 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3152
3338
  };
3153
3339
  sync();
3154
3340
  const off = onExpertChange(sync);
3155
- const poll = setInterval(sync, 5000);
3156
- return () => { cancelled = true; off(); clearInterval(poll); };
3341
+ // OPT-05:走可见性感知轮询。标签页隐藏时没人看这份数据,却照样每 5s 打一个
3342
+ // expertActive;这里拿不到面板的 timer,startVisibilityAwarePolling 会退回裸
3343
+ // setInterval,生命周期仍由下面的 cleanup 独占。
3344
+ const stopPoll = startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, null, 5000, sync);
3345
+ return () => { cancelled = true; off(); stopPoll(); };
3157
3346
  }, [sessionId, call]);
3158
3347
 
3159
3348
  // ── 悬停开合(整树统一管理)───────────────────────────────────────────
@@ -3171,14 +3360,14 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3171
3360
  // 挂载的专家:图标左侧的内联小 chip(与 trigger 同一行,不撑宽不换行)。
3172
3361
  // attachNote 不再渲染独立气泡——错误/提示走 trigger 的 title(悬停可见)。
3173
3362
  attached
3174
- ? h('span', { className: 'dsh-mcp-quick-chip', title: '本会话由该专家人设接管;点 × 摘掉(不动预设选择器)' },
3363
+ ? h('span', { className: 'dsh-mcp-quick-chip', title: t('本会话由该专家人设接管;点 × 摘掉(不动预设选择器)') },
3175
3364
  h(Icon, { d: ICONS.expert, size: 11 }),
3176
3365
  h('span', { className: 'dsh-mcp-quick-chip-name' }, attached.name),
3177
3366
  h('button', {
3178
3367
  type: 'button',
3179
3368
  className: 'dsh-mcp-quick-chip-x',
3180
- title: '摘掉专家(切回默认预设)',
3181
- 'aria-label': '摘掉专家',
3369
+ title: t('摘掉专家(切回默认预设)'),
3370
+ 'aria-label': t('摘掉专家'),
3182
3371
  disabled: attachBusy,
3183
3372
  onClick: (e) => { e.stopPropagation(); detachExpert(); },
3184
3373
  }, h(Icon, { d: ICONS.x, size: 10 })),
@@ -3187,18 +3376,18 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3187
3376
  h('button', {
3188
3377
  type: 'button',
3189
3378
  className: 'dsh-mcp-quick-trigger' + (open ? ' sel' : ''),
3190
- title: (attachNote ? attachNote + (attached ? ' ' : '') : '') + (attached ? '已挂专家:' + attached.name + '(本会话全局生效) ' : '') + '技能 / MCP / 专家 快速调用',
3191
- 'aria-label': '打开技能、MCP、专家快速调用菜单',
3379
+ title: (attachNote ? attachNote + (attached ? t(' ') : '') : '') + (attached ? t('已挂专家:') + attached.name + t('(本会话全局生效) ') : '') + t('技能 / MCP / 专家 快速调用'),
3380
+ 'aria-label': t('打开技能、MCP、专家快速调用菜单'),
3192
3381
  'aria-expanded': open,
3193
3382
  onClick: (e) => { e.stopPropagation(); setOpen((value) => !value); setHoverKind(null); },
3194
3383
  }, h(Icon, { d: ICONS.board, size: 15 })),
3195
3384
  open
3196
- ? h('div', { className: 'dsh-mcp-quick-menu', role: 'menu', 'aria-label': '快速调用', ref: menuRef, style: menuPos },
3197
- copied ? h('div', { className: 'dsh-mcp-quick-note' }, '已复制,粘贴到输入框:' + copied) : null,
3198
- attachBusy ? h('div', { className: 'dsh-mcp-quick-note' }, '正在挂载专家…') : null,
3385
+ ? h('div', { className: 'dsh-mcp-quick-menu', role: 'menu', 'aria-label': t('快速调用'), ref: menuRef, style: menuPos },
3386
+ copied ? h('div', { className: 'dsh-mcp-quick-note' }, t('已复制,粘贴到输入框:') + copied) : null,
3387
+ attachBusy ? h('div', { className: 'dsh-mcp-quick-note' }, t('正在挂载专家…')) : null,
3199
3388
  attachNote ? h('div', { className: 'dsh-mcp-quick-note' + (/^✓/.test(attachNote) ? ' ok' : ' err'), role: 'alert' }, attachNote) : null,
3200
3389
  listError ? h('div', { className: 'dsh-mcp-quick-err' }, listError) : null,
3201
- !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, '正在读取…') : null,
3390
+ !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, t('正在读取…')) : null,
3202
3391
  QUICK_MENU_KINDS.map((kind) => {
3203
3392
  const items = subItems(kind.key);
3204
3393
  const expanded = hoverKind === kind.key;
@@ -3227,35 +3416,35 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3227
3416
  h('span', { className: 'dsh-mcp-quick-arrow', 'aria-hidden': true }, '›'),
3228
3417
  ),
3229
3418
  hoverKind === kind.key
3230
- ? h('div', { className: 'dsh-mcp-quick-sub' + (Array.isArray(items) && items.length ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + '可选项' },
3419
+ ? h('div', { className: 'dsh-mcp-quick-sub' + (Array.isArray(items) && items.length ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + t('可选项') },
3231
3420
  Array.isArray(items) && items.length
3232
- ? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + '列表(最多显示 6 行,滚动查看更多)' },
3421
+ ? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + t('列表(最多显示 6 行,滚动查看更多)') },
3233
3422
  items.map((item, index) => h('button', {
3234
3423
  key: item.name || item.plugin || index,
3235
3424
  type: 'button',
3236
3425
  role: 'menuitem',
3237
3426
  className: 'dsh-mcp-quick-item',
3238
- title: kind.key === 'expert' ? '挂到当前会话(可随时换或取消);再点别的专家 = 直接替换' : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
3427
+ title: kind.key === 'expert' ? t('挂到当前会话(可随时换或取消);再点别的专家 = 直接替换') : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
3239
3428
  onClick: (e) => { e.stopPropagation(); if (kind.key === 'expert') attachExpert(item); else pick(kind.key, item, quickMentionText(kind.key, item)); },
3240
3429
  },
3241
3430
  kind.key === 'expert' && attached && attached.plugin === item.plugin
3242
- ? h(Badge, { cls: 's-on' }, '已挂')
3431
+ ? h(Badge, { cls: 's-on' }, t('已挂'))
3243
3432
  : item.__badge
3244
- ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': '最近使用' }, '最近')
3433
+ ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': t('最近使用') }, t('最近'))
3245
3434
  : null,
3246
3435
  h('span', { className: 'dsh-mcp-quick-item-name' }, itemLabel(kind.key, item)),
3247
3436
  itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc' }, itemDesc(kind.key, item)) : null,
3248
3437
  )),
3249
3438
  )
3250
- : h('div', { className: 'dsh-mcp-quick-sub-note' }, '暂无可选项'),
3439
+ : h('div', { className: 'dsh-mcp-quick-sub-note' }, t('暂无可选项')),
3251
3440
  h('button', {
3252
3441
  type: 'button',
3253
3442
  role: 'menuitem',
3254
3443
  className: 'dsh-mcp-quick-manage',
3255
- title: '打开看板的「' + kind.label + '」分类管理',
3444
+ title: t('打开看板的「') + kind.label + t('」分类管理'),
3256
3445
  onClick: (e) => { e.stopPropagation(); openBoardCategory(kind.key === 'expert' ? 'expert' : kind.key); },
3257
3446
  },
3258
- h('span', { className: 'dsh-mcp-quick-item-name' }, '管理' + kind.label + '…'),
3447
+ h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理') + kind.label + '…'),
3259
3448
  ),
3260
3449
  )
3261
3450
  : null,
@@ -3265,6 +3454,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3265
3454
  : null,
3266
3455
  );
3267
3456
  };
3457
+
3268
3458
  // ── 会话头「当前生效专家」徽章(conversation.session.header.actions)──────
3269
3459
  // 宿主的 AgentPresetLabel 只读会话的 agentPreset 投影(会话开过口后不再变化),
3270
3460
  // 与本插件「随时换专家」的人设通道天然脱节:换专家后顶栏仍显示第一次的预设名。
@@ -3295,8 +3485,9 @@ const SessionExpertBadge = ({ call, sessionId }) => {
3295
3485
  setReady(false);
3296
3486
  sync();
3297
3487
  const off = onExpertChange(sync);
3298
- const poll = setInterval(sync, 5000);
3299
- return () => { cancelled = true; off(); clearInterval(poll); };
3488
+ // OPT-05:同上,徽章这一处也改走可见性感知轮询。
3489
+ const stopPoll = startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, null, 5000, sync);
3490
+ return () => { cancelled = true; off(); stopPoll(); };
3300
3491
  }, [sessionId, call]);
3301
3492
  // 挂上专家时,把同容器里那条**陈旧的预设标签**藏掉:它读的是会话创建时的
3302
3493
  // agentPreset 投影(宿主只读、换专家不动它),所以换到第二个专家后它仍显示第一个,
@@ -3312,16 +3503,22 @@ const SessionExpertBadge = ({ call, sessionId }) => {
3312
3503
  // 作用域 = 能同时容纳「本徽章」与「宿主播条目」的最近祖先(正常就是 headerActions
3313
3504
  // 容器);若父层只是个包装且里面没有目标条目,再上探一层(最多 3 层,防止飘到整页
3314
3505
  // 把看板/菜单里同名前缀的文本一起误藏)。
3506
+ // 体量保险丝:正常 headerActions 容器只有几个 span;候选容器一旦大到此量级,
3507
+ // 说明作用域已经飘到会话体(几百条 tool card),必须直接放弃对账——否则
3508
+ // subtree 监听会把整个会话纳入观察,流式输出时每帧都要跑一遍全量对账。
3509
+ const tooBig = (node) => !node || node.querySelectorAll('span').length > 80;
3315
3510
  const resolveScope = () => {
3316
3511
  const own = badgeRef.current;
3317
3512
  if (!own) return null;
3318
3513
  let node = own.parentElement;
3319
3514
  for (let i = 0; i < 3 && node; i++) {
3515
+ if (tooBig(node)) return null;
3320
3516
  const others = [...node.querySelectorAll('span')].filter((s) => s !== own && !s.contains(own));
3321
3517
  if (others.some(isStalePreset)) return node;
3322
3518
  node = node.parentElement;
3323
3519
  }
3324
- return own.parentElement;
3520
+ const fallback = own.parentElement;
3521
+ return tooBig(fallback) ? null : fallback;
3325
3522
  };
3326
3523
  const reconcile = () => {
3327
3524
  const scope = resolveScope();
@@ -3333,12 +3530,23 @@ const SessionExpertBadge = ({ call, sessionId }) => {
3333
3530
  else node.classList.add('dsh-mcp-hide-stale-preset');
3334
3531
  }
3335
3532
  };
3533
+ // 回调改为排队执行:绝不与 React 的提交同步拉锯。
3534
+ let scheduled = false;
3535
+ const schedule = () => {
3536
+ if (scheduled) return;
3537
+ scheduled = true;
3538
+ queueMicrotask(() => { scheduled = false; reconcile(); });
3539
+ };
3336
3540
  const raf = requestAnimationFrame(() => {
3337
3541
  reconcile();
3338
3542
  const scope = resolveScope();
3339
3543
  if (!scope) return;
3340
- stop = new MutationObserver(reconcile);
3341
- stop.observe(scope, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
3544
+ // ★ 关键修复:必须只观察结构变化。
3545
+ // characterData:true → 流式输出每吐一个 token 都触发一轮全量对账;
3546
+ // attributes/class → 我们写进去的 class 会被自己的观察器听见(自喂回路)。
3547
+ // 两者叠加 = 主线程被占死(整窗点不动)。宿主重建 span 的情况仍由 childList 覆盖。
3548
+ stop = new MutationObserver(schedule);
3549
+ stop.observe(scope, { childList: true, subtree: true });
3342
3550
  });
3343
3551
  return () => {
3344
3552
  cancelAnimationFrame(raf);
@@ -3353,7 +3561,7 @@ const SessionExpertBadge = ({ call, sessionId }) => {
3353
3561
  return h('span', {
3354
3562
  className: 'dsh-mcp-head-expert',
3355
3563
  ref: badgeRef,
3356
- title: '当前生效专家(人设接管本会话;可在输入框工具行的专家菜单随时换/摘)',
3564
+ title: t('当前生效专家(人设接管本会话;可在输入框工具行的专家菜单随时换/摘)'),
3357
3565
  },
3358
3566
  h(Icon, { d: ICONS.expert, size: 13, className: 'dsh-mcp-head-expert-icon' }),
3359
3567
  h('span', { className: 'dsh-mcp-head-expert-name' }, attached.name),
@@ -3369,6 +3577,7 @@ const wbFormatDate = (ms) => {
3369
3577
  if (!ms) return '';
3370
3578
  try { return new Date(Number(ms)).toISOString().slice(0, 10); } catch { return ''; }
3371
3579
  };
3580
+ // ── 市场面板:WorkBuddy 连接器 / 内置 MCP / SkillHub 技能三个子标签 ──────────────
3372
3581
  // 市场搜索框:左内置放大镜、圆角胶囊、聚焦高亮、右一键清空。
3373
3582
  // 三处复用(连接器 / 内置 MCP / 技能):连接器与内置是纯本地过滤,输入即过滤;
3374
3583
  // 技能走服务端搜索,onCommit 由防抖或回车触发(见 WorkBuddyMarket 内注释)。
@@ -3385,8 +3594,8 @@ const WbSearchBox = ({ value, onChange, placeholder, disabled, onCommit }) =>
3385
3594
  }),
3386
3595
  value && !disabled
3387
3596
  ? h('button', {
3388
- type: 'button', className: 'dsh-mcp-wb-search-clear', title: '清空',
3389
- 'aria-label': '清空搜索',
3597
+ type: 'button', className: 'dsh-mcp-wb-search-clear', title: t('清空'),
3598
+ 'aria-label': t('清空搜索'),
3390
3599
  onClick: () => { onChange(''); if (onCommit) onCommit(); },
3391
3600
  }, h(Icon, { d: ICONS.x, size: 12 }))
3392
3601
  : null,
@@ -3426,7 +3635,7 @@ const WorkBuddyMarket = ({ call }) => {
3426
3635
  setLoading(true); setError(''); setNote('');
3427
3636
  try {
3428
3637
  const res = await call('wbConnectorCatalog');
3429
- if (!res?.ok) throw new Error(res?.error || '读取连接器目录失败');
3638
+ if (!res?.ok) throw new Error(res?.error || t('读取连接器目录失败'));
3430
3639
  setConnectors(res.connectors || []);
3431
3640
  setConnectorSource(res.loadedFrom || '');
3432
3641
  } catch (e) {
@@ -3440,7 +3649,7 @@ const WorkBuddyMarket = ({ call }) => {
3440
3649
  setLoading(true); setError(''); setNote('');
3441
3650
  try {
3442
3651
  const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
3443
- if (!res?.ok) throw new Error(res?.error || '读取技能目录失败');
3652
+ if (!res?.ok) throw new Error(res?.error || t('读取技能目录失败'));
3444
3653
  setSkills(res.skills || []);
3445
3654
  setSkillTotal(res.total || 0);
3446
3655
  } catch (e) {
@@ -3486,11 +3695,11 @@ const WorkBuddyMarket = ({ call }) => {
3486
3695
  setNote('');
3487
3696
  };
3488
3697
  const installConnector = async (item) => {
3489
- setBusy(true); setBusyLabel(`正在安装 ${item.name}…`); setError('');
3698
+ setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.name])); setError('');
3490
3699
  try {
3491
3700
  const res = await call('wbConnectorInstall', { connectorId: item.id, mode: 'merge' });
3492
- if (!res?.ok) throw new Error(res?.error || '安装失败');
3493
- setNote(`${item.name}:${res.note || '已写入 MCP 配置'}`);
3701
+ if (!res?.ok) throw new Error(res?.error || t('安装失败'));
3702
+ setNote(t('{0}:{1}', [item.name, res.note || t('已写入 MCP 配置')]));
3494
3703
  setConnectorDetail(null);
3495
3704
  await loadConnectors();
3496
3705
  } catch (e) {
@@ -3500,11 +3709,11 @@ const WorkBuddyMarket = ({ call }) => {
3500
3709
  }
3501
3710
  };
3502
3711
  const installSkill = async (skill) => {
3503
- setBusy(true); setBusyLabel(`正在下载并校验 ${skill.name}…`); setError('');
3712
+ setBusy(true); setBusyLabel(t('正在下载并校验 {0}…', [skill.name])); setError('');
3504
3713
  try {
3505
3714
  const res = await call('wbSkillInstall', { slug: skill.slug, namespace: skill.namespace, name: skill.name });
3506
- if (!res?.ok) throw new Error(res?.error || '安装失败');
3507
- setNote(`${skill.name}:${res.note || '已安装'}`);
3715
+ if (!res?.ok) throw new Error(res?.error || t('安装失败'));
3716
+ setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
3508
3717
  setSkillDetail(null);
3509
3718
  await loadInstalled();
3510
3719
  } catch (e) {
@@ -3514,11 +3723,11 @@ const WorkBuddyMarket = ({ call }) => {
3514
3723
  }
3515
3724
  };
3516
3725
  const uninstallSkill = async (skill) => {
3517
- setBusy(true); setBusyLabel(`正在卸载 ${skill.name}…`); setError('');
3726
+ setBusy(true); setBusyLabel(t('正在卸载 {0}…', [skill.name])); setError('');
3518
3727
  try {
3519
3728
  const res = await call('wbSkillUninstall', { slug: skill.slug, namespace: skill.namespace });
3520
- if (!res?.ok) throw new Error(res?.error || '卸载失败');
3521
- setNote(`${skill.name}:${res.note || '已卸载'}`);
3729
+ if (!res?.ok) throw new Error(res?.error || t('卸载失败'));
3730
+ setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
3522
3731
  setSkillDetail(null);
3523
3732
  await loadInstalled();
3524
3733
  } catch (e) {
@@ -3532,7 +3741,7 @@ const WorkBuddyMarket = ({ call }) => {
3532
3741
  setNote('');
3533
3742
  try {
3534
3743
  const res = await call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace });
3535
- if (!res?.ok) throw new Error(res?.error || '读取详情失败');
3744
+ if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
3536
3745
  setSkillDetail({ ...skill, ...res, loading: false });
3537
3746
  } catch (e) {
3538
3747
  setSkillDetail({ ...skill, loading: false, detailError: String(e?.message || e) });
@@ -3550,7 +3759,7 @@ const WorkBuddyMarket = ({ call }) => {
3550
3759
  setBuiltinsLoading(true); setBuiltinError('');
3551
3760
  try {
3552
3761
  const res = await call('builtins');
3553
- if (!res?.ok) throw new Error(res?.error || '读取 MCP 目录失败');
3762
+ if (!res?.ok) throw new Error(res?.error || t('读取 MCP 目录失败'));
3554
3763
  setBuiltins(res.builtins || []);
3555
3764
  } catch (e) {
3556
3765
  setBuiltinError(String(e?.message || e));
@@ -3583,11 +3792,11 @@ const WorkBuddyMarket = ({ call }) => {
3583
3792
  return [item.label, item.id, item.name, item.summary, item.vendor, item.access].filter(Boolean).join(' ').toLowerCase().includes(builtinQueryKey);
3584
3793
  });
3585
3794
  const installBuiltin = async (item) => {
3586
- setBusy(true); setBusyLabel(`正在安装 ${item.label}…`); setError('');
3795
+ setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.label])); setError('');
3587
3796
  try {
3588
3797
  const res = await call('installBuiltins', { ids: [item.id] });
3589
- if (!res?.ok) throw new Error(res?.error || '安装失败');
3590
- setNote(`${item.label}:${res.note || '已安装'}`);
3798
+ if (!res?.ok) throw new Error(res?.error || t('安装失败'));
3799
+ setNote(t('{0}:{1}', [item.label, res.note || t('已安装')]));
3591
3800
  await loadBuiltins();
3592
3801
  } catch (e) {
3593
3802
  setError(String(e?.message || e));
@@ -3597,11 +3806,11 @@ const WorkBuddyMarket = ({ call }) => {
3597
3806
  };
3598
3807
 
3599
3808
  const connectorSearch = h(WbSearchBox, {
3600
- value: connectorQuery, placeholder: '搜索连接器(名称 / 说明 / ID)', disabled: loading,
3809
+ value: connectorQuery, placeholder: t('搜索连接器(名称 / 说明 / ID)'), disabled: loading,
3601
3810
  onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
3602
3811
  });
3603
3812
  const skillSearch = h(WbSearchBox, {
3604
- value: skillKeywordInput, placeholder: '搜索 SkillHub 技能(19 万+)', disabled: loading,
3813
+ value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: loading,
3605
3814
  onChange: (value) => setSkillKeywordInput(value),
3606
3815
  // 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
3607
3816
  onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
@@ -3609,47 +3818,47 @@ const WorkBuddyMarket = ({ call }) => {
3609
3818
 
3610
3819
  const skillPageCount = Math.max(1, Math.ceil(skillTotal / WB_PAGE_SIZE));
3611
3820
  const skillMeta = skillTotal
3612
- ? `第 ${skillPage} / ${skillPageCount} 页 · 共 ${wbFormatCount(skillTotal)} 个技能`
3821
+ ? t('第 {0} / {1} 页 · 共 {2} 个技能', [skillPage, skillPageCount, wbFormatCount(skillTotal)])
3613
3822
  : '';
3614
3823
 
3615
3824
  return h('div', { className: 'dsh-mcp-wb' },
3616
- h('div', { className: 'dsh-mcp-wb-tabs', role: 'tablist', 'aria-label': 'WorkBuddy 市场' },
3825
+ h('div', { className: 'dsh-mcp-wb-tabs', role: 'tablist', 'aria-label': t('WorkBuddy 市场') },
3617
3826
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'connector', className: 'dsh-mcp-wb-tab' + (tab === 'connector' ? ' sel' : ''), onClick: () => setTab('connector') },
3618
- '连接器(MCP)', h('span', { className: 'dsh-mcp-cat-count' }, connectors.length)),
3827
+ t('连接器(MCP)'), h('span', { className: 'dsh-mcp-cat-count' }, connectors.length)),
3619
3828
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'builtin', className: 'dsh-mcp-wb-tab' + (tab === 'builtin' ? ' sel' : ''), onClick: () => setTab('builtin') },
3620
- '内置 MCP', h('span', { className: 'dsh-mcp-cat-count' }, builtins.length)),
3829
+ t('内置 MCP'), h('span', { className: 'dsh-mcp-cat-count' }, builtins.length)),
3621
3830
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'skill', className: 'dsh-mcp-wb-tab' + (tab === 'skill' ? ' sel' : ''), onClick: () => setTab('skill') },
3622
- '技能(SkillHub)'),
3831
+ t('技能(SkillHub)')),
3623
3832
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'expert', className: 'dsh-mcp-wb-tab' + (tab === 'expert' ? ' sel' : ''), onClick: () => setTab('expert') },
3624
- '专家(召唤)'),
3833
+ t('专家(召唤)')),
3625
3834
  ),
3626
3835
  h('div', { className: 'dsh-mcp-wb-disclaim' },
3627
- '直连读取:连接器来自 WorkBuddy 官方分发(static.workbuddy.cn),技能来自 SkillHub 公开 API,专家来自 WorkBuddy 专家中心分发。本面板只是浏览与召唤入口,' +
3628
- '内容版权归原作者所有;第三方接口的字段可能随上游改版变化。'),
3836
+ t('直连读取:连接器来自 WorkBuddy 官方分发(static.workbuddy.cn),技能来自 SkillHub 公开 API,专家来自 WorkBuddy 专家中心分发。本面板只是浏览与召唤入口,') +
3837
+ t('内容版权归原作者所有;第三方接口的字段可能随上游改版变化。')),
3629
3838
  tab === 'connector'
3630
3839
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
3631
3840
  connectorSearch,
3632
3841
  h('label', { className: 'dsh-mcp-check' },
3633
3842
  h('input', { type: 'checkbox', checked: connectorOnlyReady, disabled: loading, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
3634
- '只看可直接安装'),
3843
+ t('只看可直接安装')),
3635
3844
  h('span', { className: 'dsh-mcp-builtin-count' },
3636
- `显示 ${visibleConnectors.length} / ${filteredConnectors.length}${connectorSource === 'network' ? ' · 已更新' : connectorSource === 'disk' ? ' · 缓存' : ''}`),
3845
+ t('显示 {0} / {1}', [visibleConnectors.length, filteredConnectors.length]) + (connectorSource === 'network' ? t(' · 已更新') : connectorSource === 'disk' ? t(' · 缓存') : '')),
3637
3846
  )
3638
3847
  : tab === 'builtin'
3639
3848
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
3640
3849
  h(WbSearchBox, {
3641
- value: builtinQuery, placeholder: '搜索服务、用途或提供方…',
3850
+ value: builtinQuery, placeholder: t('搜索服务、用途或提供方…'),
3642
3851
  onChange: (value) => setBuiltinQuery(value),
3643
3852
  }),
3644
3853
  h('label', { className: 'dsh-mcp-check' },
3645
3854
  h('input', { type: 'checkbox', checked: builtinOnlyReady, onChange: (e) => setBuiltinOnlyReady(e.target.checked) }),
3646
- '只要免密钥'),
3855
+ t('只要免密钥')),
3647
3856
  h('select', {
3648
3857
  className: 'dsh-mcp-input dsh-mcp-wb-sort', value: builtinCategory,
3649
3858
  onChange: (e) => setBuiltinCategory(e.target.value),
3650
- }, h('option', { key: 'all', value: 'all' }, '全部分类'),
3651
- builtinCategories.map((cat) => h('option', { key: cat.key, value: cat.key }, cat.label + '(' + cat.count + ')'))),
3652
- h('span', { className: 'dsh-mcp-builtin-count' }, `显示 ${filteredBuiltins.length} / ${builtins.length}`),
3859
+ }, h('option', { key: 'all', value: 'all' }, t('全部分类')),
3860
+ builtinCategories.map((cat) => h('option', { key: cat.key, value: cat.key }, cat.label + t('(') + cat.count + t(')')))),
3861
+ h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [filteredBuiltins.length, builtins.length])),
3653
3862
  )
3654
3863
  : tab === 'expert'
3655
3864
  ? null
@@ -3664,45 +3873,45 @@ const WorkBuddyMarket = ({ call }) => {
3664
3873
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
3665
3874
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
3666
3875
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
3667
- loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取连接器目录…') : null,
3668
- loading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取技能目录…') : null,
3669
- !loading && tab === 'connector' && !visibleConnectors.length ? h('div', { className: 'dsh-mcp-wb-empty' }, '没有匹配的连接器,换个关键词试试') : null,
3670
- !loading && tab === 'skill' && !skills.length ? h('div', { className: 'dsh-mcp-wb-empty' }, '没有匹配的技能') : null,
3876
+ loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
3877
+ loading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
3878
+ !loading && tab === 'connector' && !visibleConnectors.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('没有匹配的连接器,换个关键词试试')) : null,
3879
+ !loading && tab === 'skill' && !skills.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('没有匹配的技能')) : null,
3671
3880
  tab === 'connector' && visibleConnectors.length
3672
3881
  ? h('div', { className: 'dsh-mcp-wb-grid' },
3673
3882
  visibleConnectors.map((item) => h('div', { key: item.id, className: 'dsh-mcp-card wb' + (item.installable ? '' : ' off') },
3674
3883
  h('div', { className: 'dsh-mcp-card-top' },
3675
3884
  h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
3676
- h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? '可安装' : '仅展示'),
3885
+ h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
3677
3886
  ),
3678
3887
  h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.nameEn || item.id),
3679
3888
  h('div', { className: 'dsh-mcp-card-tags' },
3680
- item.skillCount ? h(Badge, null, `含 ${item.skillCount} 技能`) : null,
3889
+ item.skillCount ? h(Badge, null, t('含 {0} 技能', [item.skillCount])) : null,
3681
3890
  item.transport ? h(Badge, { cls: item.transport === 'streamable-http' ? 't-http' : 't-stdio' }, item.transport === 'streamable-http' ? 'HTTP' : 'stdio') : null,
3682
3891
  item.authMode ? h(Badge, null, item.authMode) : null,
3683
3892
  ),
3684
3893
  h('div', { className: 'dsh-mcp-wb-card-actions' },
3685
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, '详情'),
3894
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, t('详情')),
3686
3895
  item.installable
3687
- ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installConnector(item) }, '安装')
3688
- : h('span', { className: 'dsh-mcp-wb-blocked', title: item.blockedReason }, item.blockedReason ? '不兼容:' + item.blockedReason : ''),
3896
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installConnector(item) }, t('安装'))
3897
+ : h('span', { className: 'dsh-mcp-wb-blocked', title: item.blockedReason }, item.blockedReason ? t('不兼容:') + item.blockedReason : ''),
3689
3898
  ),
3690
3899
  )),
3691
3900
  )
3692
3901
  : null,
3693
3902
  tab === 'connector' && connectorPageCount > 1
3694
3903
  ? h('div', { className: 'dsh-mcp-wb-pager' },
3695
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safeConnectorPage <= 1, onClick: () => setConnectorPage(safeConnectorPage - 1) }, '上一页'),
3904
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safeConnectorPage <= 1, onClick: () => setConnectorPage(safeConnectorPage - 1) }, t('上一页')),
3696
3905
  h('span', { className: 'dsh-mcp-builtin-count' }, `${safeConnectorPage} / ${connectorPageCount}`),
3697
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safeConnectorPage >= connectorPageCount, onClick: () => setConnectorPage(safeConnectorPage + 1) }, '下一页'),
3906
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safeConnectorPage >= connectorPageCount, onClick: () => setConnectorPage(safeConnectorPage + 1) }, t('下一页')),
3698
3907
  )
3699
3908
  : null,
3700
3909
  tab === 'builtin' && builtinError
3701
3910
  ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, builtinError, ' ',
3702
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: loadBuiltins, disabled: builtinsLoading }, '重试'))
3911
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: loadBuiltins, disabled: builtinsLoading }, t('重试')))
3703
3912
  : null,
3704
3913
  tab === 'builtin' && !builtinError && !filteredBuiltins.length
3705
- ? h('div', { className: 'dsh-mcp-wb-empty' }, builtinsLoading ? '正在读取 MCP 目录…' : '没有匹配的服务,换个关键词或分类试试')
3914
+ ? h('div', { className: 'dsh-mcp-wb-empty' }, builtinsLoading ? t('正在读取 MCP 目录…') : t('没有匹配的服务,换个关键词或分类试试'))
3706
3915
  : null,
3707
3916
  tab === 'builtin' && filteredBuiltins.length
3708
3917
  ? h('div', { className: 'dsh-mcp-wb-grid' },
@@ -3714,7 +3923,7 @@ const WorkBuddyMarket = ({ call }) => {
3714
3923
  h('div', { className: 'dsh-mcp-card-name' }, item.label),
3715
3924
  item.vendor ? h('div', { className: 'dsh-mcp-card-vendor' }, item.vendor) : null,
3716
3925
  ),
3717
- item.installed ? h(Badge, { cls: 's-on' }, '已配置') : null,
3926
+ item.installed ? h(Badge, { cls: 's-on' }, t('已配置')) : null,
3718
3927
  ),
3719
3928
  h('div', { className: 'dsh-mcp-card-summary' }, item.summary),
3720
3929
  h('div', { className: 'dsh-mcp-card-tags' },
@@ -3723,25 +3932,25 @@ const WorkBuddyMarket = ({ call }) => {
3723
3932
  item.installed
3724
3933
  ? null
3725
3934
  : builtinNeedsKey(item)
3726
- ? h(Badge, null, '需密钥')
3935
+ ? h(Badge, null, t('需密钥'))
3727
3936
  : builtinNeedsConfig(item)
3728
- ? h(Badge, null, '需连接信息')
3729
- : h(Badge, { cls: 's-off' }, '免密钥'),
3730
- builtinNeedsArgs(item) ? h(Badge, { cls: 's-err' }, '需填参数') : null,
3937
+ ? h(Badge, null, t('需连接信息'))
3938
+ : h(Badge, { cls: 's-off' }, t('免密钥')),
3939
+ builtinNeedsArgs(item) ? h(Badge, { cls: 's-err' }, t('需填参数')) : null,
3731
3940
  ),
3732
3941
  h('div', { className: 'dsh-mcp-card-access' }, item.access),
3733
3942
  builtinNeedsKey(item) || builtinNeedsConfig(item)
3734
3943
  ? h('div', { className: 'dsh-mcp-card-vars' },
3735
- builtinNeedsKey(item) ? h('span', null, '密钥:' + item.envKeys.join('、')) : null,
3736
- builtinNeedsConfig(item) ? h('span', null, '连接:' + item.configKeys.join('、')) : null,
3944
+ builtinNeedsKey(item) ? h('span', null, t('密钥:') + item.envKeys.join(t('、'))) : null,
3945
+ builtinNeedsConfig(item) ? h('span', null, t('连接:') + item.configKeys.join(t('、'))) : null,
3737
3946
  )
3738
3947
  : null,
3739
3948
  h('div', { className: 'dsh-mcp-wb-card-actions' },
3740
3949
  item.installed
3741
3950
  ? null
3742
- : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installBuiltin(item) }, '安装'),
3951
+ : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installBuiltin(item) }, t('安装')),
3743
3952
  item.homepage
3744
- ? h('a', { className: 'dsh-mcp-btn', href: item.homepage, target: '_blank', rel: 'noreferrer', title: '打开官方文档' }, '文档')
3953
+ ? h('a', { className: 'dsh-mcp-btn', href: item.homepage, target: '_blank', rel: 'noreferrer', title: t('打开官方文档') }, t('文档'))
3745
3954
  : null,
3746
3955
  ),
3747
3956
  );
@@ -3758,22 +3967,22 @@ const WorkBuddyMarket = ({ call }) => {
3758
3967
  h('div', { className: 'dsh-mcp-card-name' }, skill.name),
3759
3968
  h('div', { className: 'dsh-mcp-card-vendor' }, skill.canonical || skill.namespace),
3760
3969
  ),
3761
- installed ? h(Badge, { cls: 's-on' }, '已安装') : null,
3970
+ installed ? h(Badge, { cls: 's-on' }, t('已安装')) : null,
3762
3971
  ),
3763
3972
  h('div', { className: 'dsh-mcp-card-summary' }, skill.description || skill.name),
3764
3973
  h('div', { className: 'dsh-mcp-card-tags' },
3765
3974
  h(Badge, null, `↓${wbFormatCount(skill.downloads)}`),
3766
- skill.installs ? h(Badge, null, `装 ${wbFormatCount(skill.installs)}`) : null,
3975
+ skill.installs ? h(Badge, null, t('装 {0}', [wbFormatCount(skill.installs)])) : null,
3767
3976
  skill.stars ? h(Badge, null, `★${wbFormatCount(skill.stars)}`) : null,
3768
- skill.verified ? h(Badge, { cls: 's-on' }, '认证') : null,
3977
+ skill.verified ? h(Badge, { cls: 's-on' }, t('认证')) : null,
3769
3978
  skill.version ? h(Badge, null, 'v' + skill.version) : null,
3770
3979
  wbFormatDate(skill.updatedAt) ? h(Badge, null, wbFormatDate(skill.updatedAt)) : null,
3771
3980
  ),
3772
3981
  h('div', { className: 'dsh-mcp-wb-card-actions' },
3773
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openSkillDetail(skill) }, '详情'),
3982
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openSkillDetail(skill) }, t('详情')),
3774
3983
  installed
3775
- ? h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => uninstallSkill(skill) }, '卸载')
3776
- : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installSkill(skill) }, '安装'),
3984
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => uninstallSkill(skill) }, t('卸载'))
3985
+ : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installSkill(skill) }, t('安装')),
3777
3986
  ),
3778
3987
  );
3779
3988
  }),
@@ -3781,32 +3990,32 @@ const WorkBuddyMarket = ({ call }) => {
3781
3990
  : null,
3782
3991
  tab === 'skill' && skillPageCount > 1
3783
3992
  ? h('div', { className: 'dsh-mcp-wb-pager' },
3784
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: skillPage <= 1 || loading, onClick: () => setSkillPage(skillPage - 1) }, '上一页'),
3993
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: skillPage <= 1 || loading, onClick: () => setSkillPage(skillPage - 1) }, t('上一页')),
3785
3994
  h('span', { className: 'dsh-mcp-builtin-count' }, `${skillPage} / ${skillPageCount}`),
3786
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: skillPage >= skillPageCount || loading, onClick: () => setSkillPage(skillPage + 1) }, '下一页'),
3995
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: skillPage >= skillPageCount || loading, onClick: () => setSkillPage(skillPage + 1) }, t('下一页')),
3787
3996
  )
3788
3997
  : null,
3789
3998
  tab === 'expert' ? h(ExpertMarket, { call }) : null,
3790
3999
  // ── 连接器详情弹层:安装前看真实 mcp.json ──
3791
4000
  connectorDetail
3792
4001
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setConnectorDetail(null) },
3793
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': '连接器详情', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setConnectorDetail(null); } } },
4002
+ h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('连接器详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setConnectorDetail(null); } } },
3794
4003
  h('h3', null, connectorDetail.name),
3795
4004
  connectorDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.description) : null,
3796
4005
  connectorDetail.examples && connectorDetail.examples.length
3797
4006
  ? h('div', { className: 'dsh-mcp-wb-examples' },
3798
- h('b', null, '用法示例'),
4007
+ h('b', null, t('用法示例')),
3799
4008
  h('ul', null, connectorDetail.examples.slice(0, 4).map((example, index) => h('li', { key: index }, example))),
3800
4009
  )
3801
4010
  : null,
3802
4011
  h('p', { className: 'dsh-mcp-wb-modal-note' },
3803
- `安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。` +
3804
- (connectorDetail.skillCount ? `另含 ${connectorDetail.skillCount} 个连接器技能,如需请从技能市场或手动安装。` : '')),
4012
+ t(`安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。`) +
4013
+ (connectorDetail.skillCount ? t('另含 {0} 个连接器技能,如需请从技能市场或手动安装。', [connectorDetail.skillCount]) : '')),
3805
4014
  h('div', { className: 'dsh-mcp-modal-actions' },
3806
- h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: '取消' }),
4015
+ h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: t('取消') }),
3807
4016
  connectorDetail.installable
3808
- ? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => installConnector(connectorDetail), children: busy ? '安装中…' : '安装到全局' })
3809
- : h('span', { className: 'dsh-mcp-wb-blocked' }, connectorDetail.blockedReason || '不可安装'),
4017
+ ? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => installConnector(connectorDetail), children: busy ? t('安装中…') : t('安装到全局') })
4018
+ : h('span', { className: 'dsh-mcp-wb-blocked' }, connectorDetail.blockedReason || t('不可安装')),
3810
4019
  ),
3811
4020
  ),
3812
4021
  )
@@ -3814,29 +4023,29 @@ const WorkBuddyMarket = ({ call }) => {
3814
4023
  // ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
3815
4024
  skillDetail
3816
4025
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setSkillDetail(null) },
3817
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': '技能详情', tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setSkillDetail(null); } } },
4026
+ h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('技能详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setSkillDetail(null); } } },
3818
4027
  h('h3', null, skillDetail.name),
3819
4028
  h('div', { className: 'dsh-mcp-card-vendor' }, skillDetail.canonical || skillDetail.namespace || ''),
3820
4029
  skillDetail.loading
3821
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '正在读取详情…')
4030
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在读取详情…'))
3822
4031
  : h('div', null,
3823
4032
  skillDetail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, skillDetail.detailError) : null,
3824
4033
  skillDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, skillDetail.description) : null,
3825
4034
  h('div', { className: 'dsh-mcp-card-tags' },
3826
4035
  skillDetail.version ? h(Badge, null, 'v' + skillDetail.version) : null,
3827
- skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? '(已认证)' : '')) : null,
4036
+ skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? t('(已认证)') : '')) : null,
3828
4037
  skillDetail.certifiedName ? h(Badge, { cls: 's-on' }, skillDetail.certifiedName) : null,
3829
- Array.isArray(skillDetail.security) ? skillDetail.security.map((status, index) => h(Badge, { key: index, cls: status === 'benign' ? 's-on' : '' }, '安全: ' + status)) : null,
4038
+ Array.isArray(skillDetail.security) ? skillDetail.security.map((status, index) => h(Badge, { key: index, cls: status === 'benign' ? 's-on' : '' }, t('安全: ') + status)) : null,
3830
4039
  ),
3831
- skillDetail.changelog ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '最新版本说明:' + skillDetail.changelog) : null,
4040
+ skillDetail.changelog ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('最新版本说明:') + skillDetail.changelog) : null,
3832
4041
  h('p', { className: 'dsh-mcp-wb-modal-note' },
3833
- '安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。'),
4042
+ t('安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。')),
3834
4043
  ),
3835
4044
  h('div', { className: 'dsh-mcp-modal-actions' },
3836
- h(Btn, { onClick: () => setSkillDetail(null), disabled: busy, children: '关闭' }),
4045
+ h(Btn, { onClick: () => setSkillDetail(null), disabled: busy, children: t('关闭') }),
3837
4046
  installedSkills.has(skillDetail.slug)
3838
- ? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: '卸载' })
3839
- : h(Btn, { primary: true, icon: 'plus', disabled: busy || skillDetail.loading, onClick: () => installSkill(skillDetail), children: busy ? '安装中…' : '安装' }),
4047
+ ? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: t('卸载') })
4048
+ : h(Btn, { primary: true, icon: 'plus', disabled: busy || skillDetail.loading, onClick: () => installSkill(skillDetail), children: busy ? t('安装中…') : t('安装') }),
3840
4049
  ),
3841
4050
  ),
3842
4051
  )
@@ -3845,6 +4054,7 @@ const WorkBuddyMarket = ({ call }) => {
3845
4054
  };
3846
4055
 
3847
4056
 
4057
+ // ── 面板头部入口菜单:切「悬浮按钮 / 侧栏入口」的显示偏好 ────────────────────────
3848
4058
  // 入口偏好的开关就放在面板头部:它必须能从面板里改回来(关掉一个入口后用户还在面板里),
3849
4059
  // 而面板本身只有这两个入口能打开——所以这个控件不能藏得更深。低频操作,用图标按钮 + 弹层,
3850
4060
  // 不占 Tab(作用域 tab 讲的是「管哪一份配置」,与显示偏好无关)。
@@ -3863,9 +4073,9 @@ const EntryMenu = () => {
3863
4073
  { className: 'dsh-mcp-entry', ref: boxRef },
3864
4074
  h(Btn, {
3865
4075
  icon: 'entry',
3866
- title: '入口显示:' + ENTRY_LABELS[mode],
4076
+ title: t('入口显示:') + ENTRY_LABELS[mode],
3867
4077
  onClick: (e) => { e.stopPropagation(); setOpen((value) => !value); },
3868
- children: '入口',
4078
+ children: t('入口'),
3869
4079
  }),
3870
4080
  // 行用原生 <button> 而不是 div[role=menuitemradio]:menu 语义要求方向键 / Home / Esc /
3871
4081
  // roving tabindex 一整套键盘行为,不实现就等于这个设置对键盘用户不存在,还多了一句假声明。
@@ -3884,6 +4094,7 @@ const EntryMenu = () => {
3884
4094
  );
3885
4095
  };
3886
4096
 
4097
+ // ── 侧栏入口与面板图标:跨 slot 共享的打开/切分类通道 ────────────────────────────
3887
4098
  // 左侧栏底部入口(sidebar.footer.action,DSH 0.1.5 起提供):官方标准的发现入口。
3888
4099
  // 点击打开的是同一个浮层面板——不切换主面板、不打断正在进行的会话。
3889
4100
  const McpFooterAction = ({ wide }) => {
@@ -3897,12 +4108,12 @@ const McpFooterAction = ({ wide }) => {
3897
4108
  {
3898
4109
  type: 'button',
3899
4110
  className: 'dsh-mcp-footer-action' + (wide ? '' : ' rail'),
3900
- title: '技能 · MCP · 专家看板',
3901
- 'aria-label': '打开技能 · MCP · 专家看板',
4111
+ title: t('技能 · MCP · 专家看板'),
4112
+ 'aria-label': t('打开技能 · MCP · 专家看板'),
3902
4113
  onClick: () => openBoardPanel(),
3903
4114
  },
3904
4115
  h(Icon, { d: ICONS.plug, size: wide ? 16 : 18 }),
3905
- wide ? h('span', { className: 'dsh-mcp-footer-name' }, '技能·专家') : null,
4116
+ wide ? h('span', { className: 'dsh-mcp-footer-name' }, t('技能·专家')) : null,
3906
4117
  );
3907
4118
  };
3908
4119
 
@@ -3931,6 +4142,43 @@ function openBoardPanel() {
3931
4142
  layout.selectPanel(active === PANEL_ID ? null : PANEL_ID);
3932
4143
  }
3933
4144
 
4145
+ // ── 面板错误边界(OPT-07)────────────────────────────────────────────
4146
+ // 面板里 23 个 React 组件此前全部裸挂在同一棵树上:任何一个在渲染期抛错,整块面板
4147
+ // 直接白屏,连分类标签都不剩,用户唯一能做的是刷新页面。可能触发抛错的输入面很宽,
4148
+ // 而且都不是本地代码能提前穷举的 —— MCP 市场返回的远程 JSON 结构异常、用户粘贴的
4149
+ // 导入内容(mcp-import-sources.js)、被手工改坏的 cordis.patch.yml 往返、专家市场
4150
+ // 439 条数据里的字段缺失。
4151
+ // 因此每个顶层分类各包一层:把「一块坏掉」限制成「这一块显示错误卡片,其余照常可用」。
4152
+ class ArmoryPanelBoundary extends React2.Component {
4153
+ constructor(props) {
4154
+ super(props);
4155
+ this.state = { error: null };
4156
+ this.retry = this.retry.bind(this);
4157
+ }
4158
+ static getDerivedStateFromError(error) {
4159
+ return { error };
4160
+ }
4161
+ componentDidCatch(error, info) {
4162
+ // 只记录、不吞掉:控制台保留完整堆栈,用户回报时才有东西可看。
4163
+ console.error('[dsh-armory] panel crashed:', error, info);
4164
+ }
4165
+ retry() {
4166
+ this.setState({ error: null });
4167
+ }
4168
+ render() {
4169
+ const { error } = this.state;
4170
+ if (!error) return this.props.children;
4171
+ return h('div', { className: 'dsh-mcp-panel-error', role: 'alert' },
4172
+ h('b', null, t('这个面板加载失败')),
4173
+ h('p', null, String((error && error.message) || error)),
4174
+ h('div', { className: 'dsh-mcp-panel-error-act' },
4175
+ h('button', { type: 'button', className: 'dsh-mcp-btn primary', onClick: this.retry }, t('重试')),
4176
+ h('span', null, this.props.hint || t('其余分类仍可正常使用;完整堆栈已打印到控制台。')),
4177
+ ),
4178
+ );
4179
+ }
4180
+ }
4181
+
3934
4182
  // 看板页(main slot,key = PANEL_ID):宿主中栏按 entryKey 渲染同 key 的条目。
3935
4183
  // 组件复用既有的 McpTab——它本来就支持非浮层渲染(floating=false 时直接返回 inner),
3936
4184
  // 全部 MCP 能力(列表/详情/编辑/导入/市场/升级/更新提示)因此原样保留,只是换了宿主位置。
@@ -3953,8 +4201,6 @@ const McpBoardPage = (props) => {
3953
4201
  const [skillCount, setSkillCount] = useState(null);
3954
4202
  // MCP 计数:与其他分类标签保持一致(此前只有 skill/tailor/expert 有数量,MCP 没有是遗漏)。
3955
4203
  const [mcpCount, setMcpCount] = useState(null);
3956
- // 工具裁缝计数:只在用户切到该分类时拉一次,避免打开页面就扫日志。
3957
- const [tailorCount, setTailorCount] = useState(null);
3958
4204
  useEffect(() => {
3959
4205
  if (category !== 'mcp') return;
3960
4206
  let alive = true;
@@ -3967,14 +4213,27 @@ const McpBoardPage = (props) => {
3967
4213
  }).catch(() => {});
3968
4214
  return () => { alive = false; };
3969
4215
  }, [call, category]);
4216
+ // OPT-04:报告上提到这里,分类角标与 ToolTailor 页面本体共用同一份,进入该分类只扫一次。
4217
+ // 用「已发起」标记而不是判断报告是否非空,是为了区分「还没开始」与「发起了但失败了」:
4218
+ // 后者不该被角标当成「0 个死工具」,也不该让组件误以为自己该再扫一遍。
4219
+ const [tailorReport, setTailorReport] = useState(null);
4220
+ // 这个 ref 只在 effect 里读写;渲染期要用到「父层是否已认领本次加载」,
4221
+ // 所以另存一个 settled 状态。注意不能用「请求中」的瞬时布尔态来表达:
4222
+ // 子组件的 effect 先于父组件运行,首帧它看到的必然是 setState 之前的值,
4223
+ // 于是父层还没开始就被判定为「不管了」,两边同时发请求——正是要避免的那次重复扫描。
4224
+ const [tailorSettled, setTailorSettled] = useState(false);
4225
+ const tailorRequested = useRef(false);
3970
4226
  useEffect(() => {
3971
- if (category !== 'tailor' || tailorCount !== null) return;
4227
+ if (category !== 'tailor' || tailorRequested.current) return;
4228
+ tailorRequested.current = true;
3972
4229
  let alive = true;
3973
4230
  call('toolTailor', { days: 30 }).then((res) => {
3974
- if (alive && res && res.ok && typeof res.deadMcpCount === 'number') setTailorCount(res.deadMcpCount);
3975
- }).catch(() => {});
4231
+ if (alive && res && res.ok) setTailorReport(res);
4232
+ }).catch(() => {}).finally(() => { if (alive) setTailorSettled(true); });
3976
4233
  return () => { alive = false; };
3977
- }, [call, category, tailorCount]);
4234
+ }, [call, category]);
4235
+ // 首次进入该分类时父层必定会发这一发请求,因此首帧就告诉子组件「等我的」。
4236
+ const tailorSeedPending = category === 'tailor' && !tailorSettled;
3978
4237
  // 专家分类计数:已召唤数(打在分类上的是「已召唤」,与 Skill 条数语义一致——都是「在手」而非「总量」)。
3979
4238
  const [expertCount, setExpertCount] = useState(null);
3980
4239
  useEffect(() => {
@@ -3988,7 +4247,7 @@ const McpBoardPage = (props) => {
3988
4247
 
3989
4248
  return h('div', { className: 'dsh-mcp-page' },
3990
4249
  h('div', { className: 'dsh-mcp-wrap' },
3991
- h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': '看板分类' },
4250
+ h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': t('看板分类') },
3992
4251
  BOARD_CATEGORIES.map((cat) =>
3993
4252
  h('button', {
3994
4253
  key: cat.key,
@@ -3997,26 +4256,28 @@ const McpBoardPage = (props) => {
3997
4256
  'aria-pressed': category === cat.key,
3998
4257
  'aria-selected': category === cat.key,
3999
4258
  className: 'dsh-mcp-cat',
4000
- title: cat.key === 'mcp' ? 'MCP 服务器(原有全部能力)' : cat.key === 'skill' ? 'Skill 看板(新增)' : cat.key === 'expert' ? '已召唤的专家:预设状态 / 捆绑技能 / 送还(召唤新专家去「市场」)' : cat.key === 'tailor' ? '工具裁缝:工具使用账本 + 上下文税单 + 死工具清单' : '市场:WorkBuddy 连接器 + 内置 MCP 目录 + SkillHub 技能 + 专家召唤',
4259
+ title: cat.key === 'mcp' ? t('MCP 服务器(原有全部能力)') : cat.key === 'skill' ? t('Skill 看板(新增)') : cat.key === 'expert' ? t('已召唤的专家:预设状态 / 捆绑技能 / 送还(召唤新专家去「市场」)') : cat.key === 'tailor' ? t('工具裁缝:工具使用账本 + 上下文税单 + 死工具清单') : t('市场:WorkBuddy 连接器 + 内置 MCP 目录 + SkillHub 技能 + 专家召唤'),
4001
4260
  onClick: () => setCategory(cat.key),
4002
4261
  },
4003
4262
  h(Icon, { d: ICONS[cat.icon], size: 14 }),
4004
4263
  cat.label,
4005
4264
  cat.key === 'mcp' && mcpCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mcpCount) : null,
4006
4265
  cat.key === 'skill' && skillCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, skillCount) : null,
4007
- cat.key === 'tailor' && tailorCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, tailorCount) : null,
4266
+ cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' ? h('span', { className: 'dsh-mcp-cat-count' }, tailorReport.deadMcpCount) : null,
4008
4267
  cat.key === 'expert' && expertCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, expertCount) : null,
4009
4268
  )),
4010
4269
  ),
4270
+ // OPT-07:每个分类各自包一层错误边界。key 用分类名,切走再切回会重建边界,
4271
+ // 顺带让「重试」之外还有一条自然恢复路径。
4011
4272
  category === 'mcp'
4012
- ? h(McpTab, { call, timer, pluginManager })
4273
+ ? h(ArmoryPanelBoundary, { key: 'mcp', hint: t('MCP 列表加载失败;Skill、专家、裁缝、市场仍可正常使用。') }, h(McpTab, { call, timer, pluginManager }))
4013
4274
  : category === 'tailor'
4014
- ? h(ToolTailor, { call, timer })
4275
+ ? h(ArmoryPanelBoundary, { key: 'tailor', hint: t('工具账本加载失败;其余分类仍可正常使用。') }, h(ToolTailor, { call, timer, report: tailorReport, onReport: setTailorReport, seedPending: tailorSeedPending }))
4015
4276
  : category === 'expert'
4016
- ? h(ExpertInstalledBoard, { call })
4277
+ ? h(ArmoryPanelBoundary, { key: 'expert', hint: t('已召唤专家列表加载失败;其余分类仍可正常使用。') }, h(ExpertInstalledBoard, { call }))
4017
4278
  : category === 'wb'
4018
- ? h(WorkBuddyMarket, { call })
4019
- : h(SkillBoard, { call, timer }),
4279
+ ? h(ArmoryPanelBoundary, { key: 'wb', hint: t('市场数据加载失败;其余分类仍可正常使用。') }, h(WorkBuddyMarket, { call }))
4280
+ : h(ArmoryPanelBoundary, { key: 'skill', hint: t('Skill 看板加载失败;其余分类仍可正常使用。') }, h(SkillBoard, { call, timer })),
4020
4281
  ),
4021
4282
  );
4022
4283
  };
@@ -4111,25 +4372,26 @@ function upgradePlan({ service, hint, bundle, spec = null, source = null }) {
4111
4372
  function upgradeFailureText(error, exitCode) {
4112
4373
  const code = error?.code;
4113
4374
  const known = {
4114
- 'incompatible-version': '新版本与当前 DSH 不兼容,未升级。',
4115
- 'not-bundle': '该包没有声明组合包,不能作为插件安装。',
4116
- 'invalid-spec': '版本号不合法,未升级。',
4117
- 'ambiguous-install': '无法确定要升级哪个包,未升级。',
4118
- 'unknown-plugin': '在 profile 里找不到这个插件,未升级。',
4119
- 'stop-profile': '宿主正在停止,未升级;请重启后再试。',
4120
- 'bundle-in-use': '该包正在被使用,未升级。',
4121
- 'stale-approval': '构建授权已过期,未升级;请重试。',
4122
- 'management-required': '当前 profile 由应用托管,不允许在这里改动。',
4123
- 'unaddressable': '这一项无法被单独改动(随 DSH 提供)。',
4375
+ 'incompatible-version': t('新版本与当前 DSH 不兼容,未升级。'),
4376
+ 'not-bundle': t('该包没有声明组合包,不能作为插件安装。'),
4377
+ 'invalid-spec': t('版本号不合法,未升级。'),
4378
+ 'ambiguous-install': t('无法确定要升级哪个包,未升级。'),
4379
+ 'unknown-plugin': t('在 profile 里找不到这个插件,未升级。'),
4380
+ 'stop-profile': t('宿主正在停止,未升级;请重启后再试。'),
4381
+ 'bundle-in-use': t('该包正在被使用,未升级。'),
4382
+ 'stale-approval': t('构建授权已过期,未升级;请重试。'),
4383
+ 'management-required': t('当前 profile 由应用托管,不允许在这里改动。'),
4384
+ 'unaddressable': t('这一项无法被单独改动(随 DSH 提供)。'),
4124
4385
  };
4125
4386
  if (known[code]) return known[code];
4126
4387
  // 未知码不吞掉:把码和 pnpm 的退出码如实带出来,否则用户和我们都无从查起。
4127
4388
  const bits = [];
4128
- if (code) bits.push('错误码 ' + code);
4129
- if (typeof exitCode === 'number' && exitCode !== 0) bits.push('pnpm 退出码 ' + exitCode);
4130
- return bits.length ? '升级失败(' + bits.join(',') + ')。' : '升级失败。';
4389
+ if (code) bits.push(t('错误码 ') + code);
4390
+ if (typeof exitCode === 'number' && exitCode !== 0) bits.push(t('pnpm 退出码 ') + exitCode);
4391
+ return bits.length ? t('升级失败(') + bits.join(t(',')) + t(')。') : t('升级失败。');
4131
4392
  }
4132
4393
 
4394
+ // ── 「这次变了什么」弹窗:把 lib/whats-new.js 的内容说一次 ──────────────────────
4133
4395
  function whatsNewFor(current, lastSeen, entries) {
4134
4396
  if (!current || !Array.isArray(entries) || entries.length === 0) return [];
4135
4397
  if (lastSeen === current) return [];
@@ -4206,12 +4468,12 @@ const WhatsNewModal = ({ call }) => {
4206
4468
  { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && dismiss() },
4207
4469
  h(
4208
4470
  'div',
4209
- { ref: boxRef, className: 'dsh-mcp-whatsnew', role: 'dialog', 'aria-modal': 'true', 'aria-label': '本次更新内容' },
4471
+ { ref: boxRef, className: 'dsh-mcp-whatsnew', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('本次更新内容') },
4210
4472
  h('div', { className: 'dsh-mcp-whatsnew-head' },
4211
4473
  // 版本中性:首次打开(没有任何记录)与从旧版本升上来走的是同一个分支,
4212
4474
  // 写「更新到」会把前者的文案说错。
4213
- h('b', null, 'v' + notice.current + ' 的更新'),
4214
- h('span', null, '这些变化你可能想知道'),
4475
+ h('b', null, 'v' + notice.current + t(' 的更新')),
4476
+ h('span', null, t('这些变化你可能想知道')),
4215
4477
  ),
4216
4478
  h('div', { className: 'dsh-mcp-whatsnew-body' },
4217
4479
  notice.entries.map((entry) =>
@@ -4231,16 +4493,16 @@ const WhatsNewModal = ({ call }) => {
4231
4493
  ),
4232
4494
  ),
4233
4495
  h('div', { className: 'dsh-mcp-whatsnew-foot' },
4234
- h('button', { ref: confirmRef, type: 'button', className: 'dsh-mcp-btn primary', onClick: dismiss }, '知道了'),
4496
+ h('button', { ref: confirmRef, type: 'button', className: 'dsh-mcp-btn primary', onClick: dismiss }, t('知道了')),
4235
4497
  ),
4236
4498
  ),
4237
4499
  );
4238
4500
  };
4239
4501
 
4240
- const McpOverlay = ({ call, timer, pluginManager }) => {
4241
- const open = usePanelOpen();
4502
+ // ── 悬浮按钮宿主(shell.overlay):只剩这一个席位 ────────────────────────────────
4503
+ const McpOverlay = () => {
4242
4504
  const mode = useEntryMode();
4243
- const seats = overlaySeats(mode, open);
4505
+ const seats = overlaySeats(mode);
4244
4506
  const [pos, setPos] = useState(() => readFabPos() || defaultFabPos());
4245
4507
  const fabRef = useRef(null);
4246
4508
  const dragRef = useRef(null);
@@ -4314,8 +4576,8 @@ const McpOverlay = ({ call, timer, pluginManager }) => {
4314
4576
  ref: fabRef,
4315
4577
  type: 'button',
4316
4578
  className: 'dsh-mcp-fab',
4317
- title: '技能 · MCP · 专家看板(可拖拽移动)',
4318
- 'aria-label': '打开技能 · MCP · 专家看板',
4579
+ title: t('技能 · MCP · 专家看板(可拖拽移动)'),
4580
+ 'aria-label': t('打开技能 · MCP · 专家看板'),
4319
4581
  style,
4320
4582
  onClick: handleClick,
4321
4583
  onPointerDown: handlePointerDown,
@@ -4328,6 +4590,8 @@ const McpOverlay = ({ call, timer, pluginManager }) => {
4328
4590
  );
4329
4591
  };
4330
4592
 
4593
+ // ── 定时器宿主:卸载时统一清空,避免组件残留 setInterval ────────────────────────
4594
+ // 宿主的 client 上下文提供 `timer` 时优先用宿主;这里只是 apply() 里给自身用的兜底实现。
4331
4595
  function createTimer() {
4332
4596
  const timeouts = new Set();
4333
4597
  const intervals = new Set();
@@ -4360,6 +4624,7 @@ function createTimer() {
4360
4624
  };
4361
4625
  }
4362
4626
 
4627
+ // ── RPC 契约的浏览器半:strict codec 与描述符表 MCP_CONTRIBUTION ──────────────────
4363
4628
  // strict codec 与 lib/typert.js 是同一份契约的两份产物:宿主 loader 校验 host 那份,
4364
4629
  // typert-registry 的**浏览器半**校验这份,两边规则一致而两个渠道查不同的键——
4365
4630
  // 0.1.5-rc.x(= 当时 latest/next,npx 默认装到的那条)查 `codec.schema.parse`;
@@ -4444,6 +4709,7 @@ const MCP_CONTRIBUTION = {
4444
4709
  { id: 'dsh-armory#mcpManager/expertActive', service: 'mcpManager', namespace: 'mcpManager', method: 'expertActive', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('ExpertActiveResult') }
4445
4710
  ].map((descriptor) => ({ ...descriptor, sourceLocation: { file: 'dsh-armory/lib/typert.js', line: 7, column: 1 } }))
4446
4711
  };
4712
+ // ── 插件入口:挂 RPC、注入样式、注册六个界面槽位 ──────────────────────────────
4447
4713
  async function apply(ctx) {
4448
4714
  const disposeMount = await ctx.remote.$mount(MCP_CONTRIBUTION);
4449
4715
  let disposeCss;
@@ -4470,10 +4736,10 @@ async function apply(ctx) {
4470
4736
  MODULE_CTX.ctx = ctx;
4471
4737
  const call = async (method, args) => {
4472
4738
  const svc = (typeof ctx.get === 'function' ? ctx.get('remote.mcpManager') : null) || null;
4473
- if (!svc || typeof svc[method] !== 'function') return { ok: false, error: 'MCP 市场服务未连接(面板仍可用)' };
4739
+ if (!svc || typeof svc[method] !== 'function') return { ok: false, error: t('MCP 市场服务未连接(面板仍可用)') };
4474
4740
  const r = args === undefined || args === null ? await svc[method]() : await svc[method](args);
4475
4741
  if (r && r.ok) return { ok: true, ...((r.value && typeof r.value === 'object') ? r.value : {}) };
4476
- if (r && !r.ok) return { ok: false, error: (r.error && (r.error.message || r.error.code)) || '调用失败' };
4742
+ if (r && !r.ok) return { ok: false, error: (r.error && (r.error.message || r.error.code)) || t('调用失败') };
4477
4743
  return r;
4478
4744
  };
4479
4745
  // 一键升级要用官方 pluginManager,而它只在 apply 的 ctx 上取得到(面板组件拿不到 ctx——