dsh-armory 1.9.2 → 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}
@@ -352,26 +384,49 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
352
384
  .dsh-mcp-quick-count{margin-left:auto;color:var(--mcp-accent);font-size:11px;font-variant-numeric:tabular-nums}
353
385
  .dsh-mcp-quick-arrow{margin-left:5px;color:var(--mcp-mut);font-size:11px}
354
386
  /* 子菜单:与锚点行垂直居中(top:50% + translateY(-50%)),比顶对齐更贴近 WorkBuddy;
355
- 出场动画只动 opacity/transform(动效克制),替代上一版「整块突现」的残影来源。 */
356
- .dsh-mcp-quick-sub{position:absolute;top:50%;left:calc(100% + 6px);z-index:951;width:216px;max-height:min(264px,40vh);overflow:auto;padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform:translateY(-50%);transform-origin:left center;animation:dshMcpQuickIn .13s ease-out}
387
+ 出场动画只动 opacity/transform(动效克制),替代上一版「整块突现」的残影来源。
388
+ 面板本体 max-height 由「6 行列表 + 底部固定管理按钮」精确算出(6×28 + 5×2 padding
389
+ + 30 按钮 + 4 间距 + 边框),列表自身滚动,管理按钮永远贴底可见。 */
390
+ .dsh-mcp-quick-sub{position:absolute;top:50%;left:calc(100% + 6px);z-index:951;display:flex;flex-direction:column;width:232px;max-height:min(216px,40vh);padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform:translateY(-50%);transform-origin:left center;animation:dshMcpQuickIn .13s ease-out}
391
+ /* 可滚动列表:最多 6 行(6 × 28px),溢出即滚动;隐藏水平溢出防止拖宽面板 */
392
+ .dsh-mcp-quick-list{min-height:0;max-height:168px;overflow-y:auto;overflow-x:hidden;border-radius:var(--mcp-r-sm)}
393
+ /* 细滚动条(跟随主题 token,悬停加深;hover 之外 6px 宽半透明,几乎不占视觉) */
394
+ .dsh-mcp-quick-list{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--mcp-line) 90%,transparent) transparent}
395
+ .dsh-mcp-quick-list::-webkit-scrollbar{width:6px}
396
+ .dsh-mcp-quick-list::-webkit-scrollbar-track{background:transparent}
397
+ .dsh-mcp-quick-list::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--mcp-line) 90%,transparent);border-radius:3px}
398
+ .dsh-mcp-quick-list:hover::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 45%,transparent)}
399
+ .dsh-mcp-quick-list:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
357
400
  /* 视口下缘不够时 JS 加 flip:子菜单改为向锚点行上方展开 */
358
401
  .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{top:auto;bottom:50%;transform:translateY(50%)}
359
402
  .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{animation-name:dshMcpQuickInFlip}
360
403
  @keyframes dshMcpQuickIn{from{opacity:0;transform:translateY(-50%) translateX(-4px)}to{opacity:1;transform:translateY(-50%)}}
361
404
  @keyframes dshMcpQuickInFlip{from{opacity:0;transform:translateY(50%) translateX(-4px)}to{opacity:1;transform:translateY(50%)}}
362
405
  @media (prefers-reduced-motion: reduce){.dsh-mcp-quick-sub{animation:none}}
363
- .dsh-mcp-quick-sub.empty{padding:9px 11px;color:var(--mcp-mut);font-size:12px}
364
- .dsh-mcp-quick-sub-note{padding:6px 9px;color:var(--mcp-mut);font-size:11px}
365
- .dsh-mcp-quick-manage{display:flex;align-items:center;width:100%;margin-top:4px;padding:6px 9px;border:none;border-top:1px solid var(--mcp-line);border-radius:0 0 var(--mcp-r-sm) var(--mcp-r-sm);background:transparent;color:var(--mcp-accent);font-family:inherit;font-size:11px;text-align:left;cursor:pointer;transition:background .12s}
406
+ .dsh-mcp-quick-sub.empty{display:block;padding:9px 11px;color:var(--mcp-mut);font-size:12px}
407
+ .dsh-mcp-quick-sub-note{padding:6px 9px;color:var(--mcp-mut);font-size:11px;white-space:normal}
408
+ .dsh-mcp-quick-manage{display:flex;align-items:center;flex:none;width:100%;margin-top:4px;padding:6px 9px;border:none;border-top:1px solid var(--mcp-line);border-radius:0 0 var(--mcp-r-sm) var(--mcp-r-sm);background:transparent;color:var(--mcp-accent);font-family:inherit;font-size:11px;text-align:left;cursor:pointer;transition:background .12s}
366
409
  .dsh-mcp-quick-manage:hover{background:var(--dsw-alias-interactive-bg-hover)}
367
410
  .dsh-mcp-quick-manage:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
368
- .dsh-mcp-quick-item{display:flex;align-items:baseline;gap:7px;width:100%;padding:6px 9px;border:none;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:16px;text-align:left;cursor:pointer;transition:background .12s}
411
+ .dsh-mcp-quick-item{display:flex;align-items:baseline;gap:7px;flex:none;width:100%;height:28px;padding:6px 9px;border:none;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:16px;text-align:left;cursor:pointer;transition:background .12s}
369
412
  .dsh-mcp-quick-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
370
413
  .dsh-mcp-quick-item:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
371
414
  .dsh-mcp-quick-item-name{flex:none;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
372
415
  .dsh-mcp-quick-item-badge{flex:none;padding:1px 5px;border-radius:var(--mcp-r-xs);background:color-mix(in srgb,var(--mcp-accent) 14%,transparent);color:var(--mcp-accent);font-size:10px;line-height:12px}
373
416
  .dsh-mcp-quick-item-desc{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mcp-mut);font-size:11px}
374
417
  .dsh-mcp-quick-loading,.dsh-mcp-quick-err{padding:9px 11px;color:var(--mcp-mut);font-size:12px}
418
+ /* ── 会话头「当前生效专家」徽章(SessionExpertBadge)─────────────────────
419
+ 尺寸/配色对齐宿主 AgentPresetLabel(.b06baG_label:22px 高、12px 字号、
420
+ tertiary 色、180px 上限),并排显示不突兀;窄窗(≤540px 容器)跟随宿主隐藏。
421
+ 宿主用 container query,这里没有对应容器名,用媒体查询近似同一断点。 */
422
+ .dsh-mcp-head-expert{display:inline-flex;align-items:center;gap:4px;max-width:180px;height:22px;padding:0 2px 0 0;border-radius:var(--dsw-radius-xs,var(--mcp-r-sm));background:var(--dsw-alias-fill-tsp-secondary,transparent);color:var(--dsw-alias-label-tertiary,var(--mcp-mut));font-size:12px;line-height:22px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
423
+ .dsh-mcp-head-expert-icon{flex:none;opacity:.7}
424
+ .dsh-mcp-head-expert-name{min-width:0;overflow:hidden;text-overflow:ellipsis}
425
+ /* 陈旧预设标签:挂上专家后由 SessionExpertBadge 动态加此 class(只命中带
426
+ WorkBuddy 专家 前缀的那条宿主标签)。不删节点、不改 DOM 结构,只视觉隐身——
427
+ 摘掉专家时 class 被移除,标签原样回来(React 始终是这些节点的所有者)。 */
428
+ .dsh-mcp-hide-stale-preset{display:none!important}
429
+ @media (max-width:560px){.dsh-mcp-head-expert{display:none}}
375
430
  .dsh-mcp-quick-note{padding:7px 11px;margin-bottom:4px;border-radius:var(--mcp-r-sm);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-size:11px;line-height:15px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;white-space:normal;word-break:break-word}
376
431
  .dsh-mcp-quick-note.err{color:var(--mcp-err)}
377
432
  .dsh-mcp-quick-note.ok{color:var(--mcp-ok)}
@@ -448,16 +503,31 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
448
503
  .dsh-mcp-tailor-empty{color:var(--mcp-mut);font-size:13px;padding:18px 14px}
449
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}
450
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}
451
519
 
452
520
  /* 响应式覆盖必须位于全部基础规则之后:同特异性下后出现者胜出。 */
453
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}}
454
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}}
455
523
  `;
456
524
 
525
+ // ── DOM 基础件:createElement 绑定、值遮蔽、通用小控件与图标表 ──────────────────────
526
+ // 这一层必须排在所有组件之前 —— 下面 23 个返回元素的 React 组件全靠这里的 `h` 构造。
457
527
  const h = React2.createElement;
458
528
  const MAX_TIMER_DELAY_MS = 2147483647;
459
529
  const REDACTED_VALUE = '__DSH_MCP_REDACTED__';
460
- const PRESERVED_VALUE_LABEL = '••••••(保留原值)';
530
+ const PRESERVED_VALUE_LABEL = t('••••••(保留原值)');
461
531
  const MASKED_VALUE = '••••••';
462
532
  const toFormValue = (value) => value === REDACTED_VALUE ? PRESERVED_VALUE_LABEL : String(value || '');
463
533
  const fromFormValue = (value) => value === PRESERVED_VALUE_LABEL ? REDACTED_VALUE : value;
@@ -486,7 +556,8 @@ const Dot = ({ phase }) => {
486
556
  return h('span', { className: 'dsh-mcp-dot ' + cls });
487
557
  };
488
558
 
489
- 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);
490
561
 
491
562
  const Icon = ({ d, size, className }) =>
492
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 }));
@@ -518,14 +589,16 @@ const ICONS = {
518
589
  // ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
519
590
  // 预设只填充表单,不直接写入;命令依赖本机 npx / uvx,保存前请确认可用。
520
591
  const MCP_PRESETS = [
521
- { id: 'filesystem', serverName: 'filesystem', label: 'Filesystem · 本地文件读写', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/path/to/allowed/dir'] },
522
- { id: 'memory', serverName: 'memory', label: 'Memory · 知识图谱记忆', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] },
523
- { id: 'sequential-thinking', serverName: 'sequential-thinking', label: 'Sequential Thinking · 分步推理', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] },
524
- { id: 'time', serverName: 'time', label: 'Time · 时区时间工具(需 uvx)', transport: 'stdio', command: 'uvx', args: ['mcp-server-time'] },
525
- { 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'] },
526
597
  ];
527
598
 
528
- // ── 工具参数 JSON Schema 的可读化渲染辅助 ────────────────────────────
599
+ // ── 表单与剪贴板基础件:遮蔽值的显隐状态、修订号去重、复制 ──────────────────────────
600
+ // (此前这一节的标注写成「工具参数 JSON Schema 的可读化渲染辅助」,但那三个函数在下面
601
+ // describeType 处。标注错位会让人以为 reveal 逻辑属于 schema 渲染,实际两者无关。)
529
602
  function clearRevealState(generation, setValues, setBusy) {
530
603
  generation.current += 1;
531
604
  setValues({});
@@ -557,6 +630,8 @@ async function copyText(text) {
557
630
  area?.remove();
558
631
  }
559
632
  }
633
+
634
+ // ── 工具参数 Schema 的可读化渲染:类型名与枚举文本 ─────────────────────────────
560
635
  function describeType(node) {
561
636
  if (!node || typeof node !== 'object') return 'any';
562
637
  if (Array.isArray(node.type)) return node.type.join(' | ');
@@ -581,6 +656,8 @@ function estimateTabWidth(label, hasCount) {
581
656
  const POPOVER_MIN_WIDTH = 170;
582
657
 
583
658
  // 作用域标签页:全局固定首位;项目超出宽度时折叠为「…N」弹出层。
659
+
660
+ // ── 作用域标签页:全局固定首位,超宽时折叠成「…N」弹出层 ──────────────────────────
584
661
  const ScopeTabs = ({ tabs, active, onSelect }) => {
585
662
  const rootRef = useRef(null);
586
663
  const barRef = useRef(null);
@@ -667,6 +744,8 @@ const ScopeTabs = ({ tabs, active, onSelect }) => {
667
744
  popover,
668
745
  );
669
746
  };
747
+
748
+ // ── 工具行:Schema 摊平成字段清单,可展开看原始 JSON ────────────────────────────
670
749
  function flattenParameters(schema) {
671
750
  if (!schema || typeof schema !== 'object') return { required: [], properties: [] };
672
751
  const required = Array.isArray(schema.required) ? schema.required : [];
@@ -698,7 +777,7 @@ const ToolRow = ({ tool }) => {
698
777
  role: 'button',
699
778
  tabIndex: 0,
700
779
  'aria-expanded': open,
701
- title: open ? '收起参数' : '查看参数',
780
+ title: open ? t('收起参数') : t('查看参数'),
702
781
  onClick: toggle,
703
782
  onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } },
704
783
  } : { className: 'dsh-mcp-tool-head' };
@@ -713,15 +792,15 @@ const ToolRow = ({ tool }) => {
713
792
  properties.map((p) => h('div', { key: p.name, className: 'dsh-mcp-param' },
714
793
  h('div', { className: 'dsh-mcp-param-head' },
715
794
  h('code', { className: 'dsh-mcp-param-name' }, p.name),
716
- 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('可选')),
717
796
  h('span', { className: 'dsh-mcp-param-type' }, p.type),
718
797
  ),
719
798
  p.description ? h('div', { className: 'dsh-mcp-param-desc' }, p.description) : null,
720
- p.enumText ? h('div', { className: 'dsh-mcp-param-enum' }, '可选值:' + p.enumText) : null,
721
- 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,
722
801
  )),
723
802
  ) : null,
724
- 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')),
725
804
  raw ? h('pre', { className: 'dsh-mcp-json' }, JSON.stringify(tool.parameters, null, 2)) : null,
726
805
  ) : null,
727
806
  );
@@ -743,20 +822,21 @@ const mountFailed = (s) => s.mountFailed === true;
743
822
  // Host 已在行上标好 scopeFailed 与具体原因,这里只负责不要把它显示成「连接失败」。
744
823
  const scopeFailed = (s) => s.scopeFailed === true;
745
824
  const wsSub = (s) => {
746
- if (mountFailed(s)) return '挂载失败';
747
- if (scopeFailed(s)) return '作用域隔离失败';
748
- if (s.status === 'disabled') return '已禁用';
749
- if (s.status === 'unknown') return '状态未知';
750
- if (s.status === 'loading') return '连接中…';
751
- if (s.status === 'failed') return '连接失败';
752
- 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('尚未建立连接(新会话自动挂载)');
753
832
  const refs = typeof s.refs === 'number' ? s.refs : 0;
754
- const tools = typeof s.toolCount === 'number' ? ' · ' + s.toolCount + ' 工具' : '';
833
+ const tools = typeof s.toolCount === 'number' ? ' · ' + s.toolCount + t(' 工具') : '';
755
834
  return refs > 0
756
- ? '已连接 · ' + refs + ' 个会话在用' + tools
757
- : '已连接 · 当前无会话使用(连接保留,下次直接用)' + tools;
835
+ ? t('已连接 · ') + refs + t(' 个会话在用') + tools
836
+ : t('已连接 · 当前无会话使用(连接保留,下次直接用)') + tools;
758
837
  };
759
838
 
839
+ // ── 服务器列表:按传输 / 状态筛选,选中与启停 ──────────────────────────────────
760
840
  const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onToggle, busy, transportFilter, statusFilter, onTransportFilter, onStatusFilter, embedded, workspace, emptyHint }) => {
761
841
  const count = total === undefined ? servers.length : total;
762
842
  const showControls = !embedded || count > CONTROLS_THRESHOLD;
@@ -766,23 +846,23 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
766
846
  const dotPhase = (s) => (mountFailed(s) || scopeFailed(s) ? 'failed' : s.status || s.phase);
767
847
  const toggleLocked = (s) => busy || !s.managed || (s.scope !== 'workspace' && !s.enabled && s.phase !== 'stopped');
768
848
  return h('div', { className: 'dsh-mcp-side' + (embedded ? ' embedded' : '') },
769
- 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,
770
850
  showControls ? h('div', { className: 'dsh-mcp-filters' },
771
- h('select', { value: transportFilter, onChange: (e) => onTransportFilter(e.target.value), 'aria-label': '按传输过滤' },
772
- h('option', { value: 'all' }, '全部传输'),
851
+ h('select', { value: transportFilter, onChange: (e) => onTransportFilter(e.target.value), 'aria-label': t('按传输过滤') },
852
+ h('option', { value: 'all' }, t('全部传输')),
773
853
  h('option', { value: 'http' }, 'HTTP'),
774
854
  h('option', { value: 'stdio' }, 'stdio'),
775
855
  ),
776
- h('select', { value: statusFilter, onChange: (e) => onStatusFilter(e.target.value), 'aria-label': '按状态过滤' },
777
- h('option', { value: 'all' }, '全部状态'),
778
- workspace ? null : h('option', { value: 'connected' }, '已连接'),
779
- workspace ? null : h('option', { value: 'failed' }, '失败'),
780
- 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('已禁用')),
781
861
  ),
782
862
  ) : null,
783
863
  h('div', { className: 'dsh-mcp-list' },
784
864
  servers.length === 0
785
- ? 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'))
786
866
  : servers.map((s) =>
787
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); } } },
788
868
  h(Dot, { phase: dotPhase(s) }),
@@ -790,12 +870,12 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
790
870
  h('div', { className: 'dsh-mcp-item-name' }, s.serverName),
791
871
  h('div', { className: 'dsh-mcp-item-sub' },
792
872
  h(Badge, { cls: s.transport === 'http' ? 't-http' : 't-stdio' }, s.transport === 'http' ? 'HTTP' : 'stdio'),
793
- h('span', null, s.scope === 'workspace' ? wsSub(s) : s.toolCount + ' 工具'),
794
- mountFailed(s) ? h(Badge, { cls: 's-err' }, '挂载失败') : null,
795
- 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,
796
876
  ),
797
877
  ),
798
- 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) }),
799
879
  ),
800
880
  ),
801
881
  ),
@@ -809,8 +889,8 @@ const EyeToggle = ({ hidden, disabled, onChange, label }) =>
809
889
  className: 'dsh-mcp-eye' + (hidden ? ' off' : ''),
810
890
  disabled,
811
891
  'aria-pressed': !hidden,
812
- 'aria-label': (hidden ? '已屏蔽:' : '可见:') + label,
813
- title: hidden ? '已在此项目屏蔽,点击恢复可见' : '在此项目可见,点击屏蔽',
892
+ 'aria-label': (hidden ? t('已屏蔽:') : t('可见:')) + label,
893
+ title: hidden ? t('已在此项目屏蔽,点击恢复可见') : t('在此项目可见,点击屏蔽'),
814
894
  onClick: (e) => { e.stopPropagation(); onChange(!hidden); },
815
895
  }, h(Icon, { d: ICONS[hidden ? 'eyeOff' : 'eye'], size: 15 }));
816
896
 
@@ -822,12 +902,12 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
822
902
  const query = (q || '').toLowerCase();
823
903
  const list = query ? servers.filter((s) => s.serverName.toLowerCase().includes(query)) : servers;
824
904
  return h(React2.Fragment, null,
825
- 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,
826
906
  h('div', { className: 'dsh-mcp-global-list' },
827
907
  servers.length === 0
828
- ? h('div', { className: 'dsh-mcp-empty-list' }, '没有全局 MCP')
908
+ ? h('div', { className: 'dsh-mcp-empty-list' }, t('没有全局 MCP'))
829
909
  : list.length === 0
830
- ? h('div', { className: 'dsh-mcp-empty-list' }, '没有匹配的全局 MCP')
910
+ ? h('div', { className: 'dsh-mcp-empty-list' }, t('没有匹配的全局 MCP'))
831
911
  : list.map((s) => {
832
912
  const isHidden = hidden.has(s.serverName);
833
913
  return h('div', {
@@ -844,8 +924,8 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
844
924
  h('div', { className: 'dsh-mcp-item-name' }, s.serverName),
845
925
  h('div', { className: 'dsh-mcp-item-sub' },
846
926
  h(Badge, { cls: s.transport === 'http' ? 't-http' : 't-stdio' }, s.transport === 'http' ? 'HTTP' : 'stdio'),
847
- h('span', null, s.toolCount + ' 工具'),
848
- isHidden ? h(Badge, { cls: 's-off' }, '已屏蔽') : null,
927
+ h('span', null, s.toolCount + t(' 工具')),
928
+ isHidden ? h(Badge, { cls: 's-off' }, t('已屏蔽')) : null,
849
929
  ),
850
930
  ),
851
931
  // 眼睛 = 可见性,与项目 MCP 的启用滑块在语义与外观上都区分开。
@@ -856,9 +936,10 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
856
936
  );
857
937
  };
858
938
 
939
+ // ── 服务器详情:工具清单、重连、编辑、移除、掩码值显隐 ────────────────────────────
859
940
  const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove, busy, onReveal, revealed, onCopy, excluded, onToggleExclude }) => {
860
941
  if (!server)
861
- 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 查看详情')));
862
943
  const env = server.env || {};
863
944
  const headers = server.headers || {};
864
945
  const envKeys = Object.keys(env);
@@ -883,18 +964,18 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
883
964
  const status = mountFailed ? 'failed' : server.status || server.phase || 'stopped';
884
965
  const statusCls = server.enabled ? (status === 'connected' ? 's-on' : status === 'failed' ? 's-err' : '') : 's-off';
885
966
  const statusText = !server.enabled
886
- ? '已禁用'
967
+ ? t('已禁用')
887
968
  : mountFailed
888
- ? '挂载失败'
969
+ ? t('挂载失败')
889
970
  : scopeFailed
890
- ? '作用域隔离失败'
971
+ ? t('作用域隔离失败')
891
972
  : isWorkspace
892
973
  // 项目行的 status 与全局同一个取值域(deriveMcpPhase),只是文案不同。
893
974
  // 这里只说**连接**那一半事实("在不在");"几个会话在用"由详情页的连接信息与列表行的
894
975
  // 副文案给出——两者刻意不合并,见 wsSub 的注释。
895
976
  // stopped 在项目语境里意为「这条共享连接尚未建立」(新会话挂载时才建)。
896
- ? (status === 'connected' ? '已连接' : status === 'failed' ? '连接失败' : status === 'unknown' ? '状态未知' : status === 'loading' ? '连接中…' : '尚未建立连接(新会话自动挂载)')
897
- : 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('已停止');
898
979
  const mut = (v) => {
899
980
  const visible = displayValue(v);
900
981
  return typeof visible === 'string' && visible.length > 90 ? visible.slice(0, 88) + '…' : visible;
@@ -908,8 +989,8 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
908
989
  const revealedNow = state?.value !== undefined;
909
990
  return h('span', { className: 'dsh-mcp-secret' },
910
991
  h('span', { className: 'dsh-mcp-secret-value', title: shown }, shown),
911
- revealedNow ? h('button', { type: 'button', className: 'dsh-mcp-secret-btn', title: '复制 ' + label, 'aria-label': '复制 ' + label, onClick: () => onCopy(shown) }, h(Icon, { d: ICONS.copy })) : null,
912
- 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,
913
994
  );
914
995
  };
915
996
  const RevealRow = ({ field, keyName, label }) =>
@@ -925,60 +1006,61 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
925
1006
  h(Dot, { phase: status }),
926
1007
  h(Badge, { cls: statusCls + ' dot' }, statusText),
927
1008
  h(Badge, { cls: server.transport === 'http' ? 't-http' : 't-stdio' }, server.transport === 'http' ? 'HTTP' : 'stdio'),
928
- h(Badge, { cls: isWorkspace ? 'scope-ws' : '' }, isWorkspace ? '项目配置' : '全局配置'),
929
- isWorkspace ? null : h(Badge, null, server.toolCount + ' 个工具'),
930
- isWorkspace && server.refs > 0 ? h(Badge, null, server.refs + ' 个会话共用') : null,
931
- 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,
932
1013
  ),
933
1014
  ),
934
1015
  h('div', { className: 'dsh-mcp-d-actions' },
935
1016
  isForeign
936
1017
  ? h(React2.Fragment, null,
937
- h('span', { className: 'dsh-mcp-d-scope-hint' }, excluded ? '已在此项目屏蔽' : '在此项目可见'),
1018
+ h('span', { className: 'dsh-mcp-d-scope-hint' }, excluded ? t('已在此项目屏蔽') : t('在此项目可见')),
938
1019
  h(EyeToggle, { hidden: excluded, disabled: busy, label: server.serverName, onChange: (next) => onToggleExclude(server.serverName, next) }),
939
1020
  )
940
1021
  : h(React2.Fragment, null,
941
- h(Toggle, { on: server.enabled, disabled: busy || !server.managed, label: server.serverName + (server.enabled ? ':已启用' : ':已禁用'), onChange: () => onToggle(server), title: server.managed ? (server.enabled ? '禁用' : '启用') : '来自其他配置层,只读' }),
942
- h(Btn, { icon: 'edit', onClick: () => onEdit(server), disabled: busy || !server.managed, children: '编辑' }),
943
- h(Btn, { icon: 'refresh', onClick: () => onReconnect(server), disabled: busy || !server.enabled || !server.managed, title: isWorkspace ? '重连本项目共享连接(拆掉旧连接并按当前配置重建,不影响其他会话)' : '重连', children: '重连' }),
944
- 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('移除') }),
945
1026
  ),
946
1027
  ),
947
1028
  ),
948
- isForeign ? h('div', { className: 'dsh-mcp-log warn' }, '此 MCP 由全局配置提供。要改它的连接配置,请切到「全局」标签页;在这里只能决定它对本项目是否可见。') : null,
1029
+ isForeign ? h('div', { className: 'dsh-mcp-log warn' }, t('此 MCP 由全局配置提供。要改它的连接配置,请切到「全局」标签页;在这里只能决定它对本项目是否可见。')) : null,
949
1030
  // 同名在 DSH 0.1.5 起是合法配置(官方按注册作用域隔离),所以只有这份连接真的失败时才
950
1031
  // 把它摆成原因;成功时不刷屏,更不把一条好连接说成冲突。
951
- isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h('div', { className: 'dsh-mcp-log warn' }, '以下项目也定义了同名 serverName:' + server.duplicateOwners.join('、') + '。DSH 0.1.5 起各注册作用域可独立使用同名;更早的版本按全进程唯一判定,同名会导致其中一份启动失败。') : null,
952
- server.lastError ? h('div', { className: 'dsh-mcp-log', title: '来自 mcp-client 最近日志' }, server.lastError) : null,
953
- 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,
954
1035
  h('div', { className: 'dsh-mcp-card' },
955
- h('h4', null, '连接信息'),
1036
+ h('h4', null, t('连接信息')),
956
1037
  h('div', { className: 'dsh-mcp-kv' },
957
- 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'),
958
1039
  server.url ? h('b', null, 'URL') : null, server.url ? h(RevealBtn, { field: 'url', label: 'URL', fallback: server.url }) : null,
959
- server.command ? h('b', null, '命令') : null, server.command ? h('span', null, mut(server.command)) : null,
960
- 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,
961
- server.cwd ? h('b', null, '工作目录') : null, server.cwd ? h('span', null, mut(server.cwd)) : null,
962
- h('b', null, '启用'), h('span', null, server.enabled ? '是' : '否(补丁中 disabled: true)'),
963
- h('b', null, '配置来源'), h('span', null, server.conflict ? '存在重复 serverName(冲突,只读)' : isForeign ? '全局配置(在「全局」标签页可编辑)' : isWorkspace ? '项目配置 .dsh/mcp.json(可编辑,本项目会话共享一份连接)' : server.managed ? '当前 Web profile(可编辑)' : '其他 bundle / Agent(只读)'),
964
- server.toolCallTimeoutMs ? h('b', null, '调用超时') : null, server.toolCallTimeoutMs ? h('span', null, server.toolCallTimeoutMs + ' ms') : null,
965
- h('b', null, '启动失败策略'), h('span', null, server.failOnStartupError ? '阻止插件启动' : '记录错误并继续'),
966
- 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,
967
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,
968
- 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,
969
1050
  ),
970
1051
  ),
971
1052
  h('div', { className: 'dsh-mcp-card' },
972
- h('h4', null, '工具(' + tools.length + ')'),
1053
+ h('h4', null, t('工具(') + tools.length + t(')')),
973
1054
  h('div', { className: 'dsh-mcp-tools' },
974
1055
  tools.length === 0
975
- ? 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('暂无工具'))
976
1057
  : tools.map((t) => h(ToolRow, { key: t.name, tool: t })),
977
1058
  ),
978
1059
  ),
979
1060
  );
980
1061
  };
981
1062
 
1063
+ // ── 新增 / 编辑服务器弹窗:表单校验与 secret 字段遮蔽 ────────────────────────────
982
1064
  const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, revealEpoch, scopeName }) => {
983
1065
  const dialogRef = useRef(null);
984
1066
  const [name, setName] = useState('');
@@ -1090,7 +1172,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1090
1172
  };
1091
1173
  const submit = () => {
1092
1174
  const n = (name || '').trim();
1093
- if (!n) return setError('请填写 MCP 名称');
1175
+ if (!n) return setError(t('请填写 MCP 名称'));
1094
1176
  const spec = { name: n, transport };
1095
1177
  if (transport === 'http') {
1096
1178
  spec.url = fromFormValue(url.trim());
@@ -1110,9 +1192,9 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1110
1192
  const initialDelay = Number(initialDelayMs);
1111
1193
  const maxDelay = Number(maxDelayMs);
1112
1194
  const attempts = Number(maxAttempts);
1113
- if (!Number.isSafeInteger(timeout) || timeout <= 0 || !Number.isSafeInteger(attempts) || attempts <= 0) return setError('工具超时和最大尝试次数必须是正整数');
1114
- 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');
1115
- 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('首次延迟不能大于最大延迟'));
1116
1198
  spec.toolCallTimeoutMs = timeout;
1117
1199
  spec.failOnStartupError = failOnStartupError;
1118
1200
  spec.reconnect = { enabled: reconnectEnabled, initialDelayMs: initialDelay, maxDelayMs: maxDelay, maxAttempts: attempts };
@@ -1129,27 +1211,27 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1129
1211
  const setKvRows = transport === 'http' ? setHeaders : setEnvs;
1130
1212
  const revealIcon = (id, label, onClick) => {
1131
1213
  const shown = Object.hasOwn(editRevealed, id);
1132
- 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'] }));
1133
1215
  };
1134
1216
  return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && onClose() },
1135
- 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(); } } },
1136
- 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(')') : '')),
1137
1219
  !editing ? h('div', { className: 'dsh-mcp-field' },
1138
- h('label', null, '从预设模板快速填充(可选,命令需本机 npx/uvx)'),
1139
- h('select', { value: '', onChange: (e) => applyPreset(e.target.value), 'aria-label': '选择预设模板' },
1140
- 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('— 选择预设 —')),
1141
1223
  MCP_PRESETS.map((p) => h('option', { key: p.id, value: p.id }, p.label)),
1142
1224
  ),
1143
1225
  ) : null,
1144
1226
  h('div', { className: 'dsh-mcp-field' },
1145
- h('label', null, 'MCP 名称(唯一标识,不可改)'),
1227
+ h('label', null, t('MCP 名称(唯一标识,不可改)')),
1146
1228
  h('input', { value: name, disabled: editing, onChange: (e) => setName(e.target.value), placeholder: 'my-server' }),
1147
1229
  ),
1148
1230
  h('div', { className: 'dsh-mcp-field' },
1149
- h('label', null, '传输类型'),
1231
+ h('label', null, t('传输类型')),
1150
1232
  h('div', { className: 'dsh-mcp-radio-row' },
1151
- h('label', { className: transport === 'http' ? 'sel' : '' }, h('input', { type: 'radio', checked: transport === 'http', onChange: () => setTransport('http') }), 'HTTP(远程)'),
1152
- 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(本地进程)')),
1153
1235
  ),
1154
1236
  ),
1155
1237
  transport === 'http'
@@ -1161,68 +1243,60 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1161
1243
  ),
1162
1244
  )
1163
1245
  : h('div', null,
1164
- 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') })),
1165
1247
  h('div', { className: 'dsh-mcp-field' },
1166
- h('label', null, '参数(每行一个,原样传给进程)'),
1248
+ h('label', null, t('参数(每行一个,原样传给进程)')),
1167
1249
  h('div', { className: 'dsh-mcp-input-action' },
1168
1250
  h('textarea', { rows: 4, value: visibleEditValue('args', undefined, args), onChange: (e) => { hideEditReveal('args'); setArgs(e.target.value); }, placeholder: '-y\nmcp-server@latest' }),
1169
- editing && args === PRESERVED_VALUE_LABEL ? revealIcon('args', '参数', () => toggleEditReveal('args')) : null,
1251
+ editing && args === PRESERVED_VALUE_LABEL ? revealIcon('args', t('参数'), () => toggleEditReveal('args')) : null,
1170
1252
  ),
1171
1253
  ),
1172
- 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' })),
1173
1255
  ),
1174
1256
  h('div', { className: 'dsh-mcp-field' },
1175
- h('label', null, transport === 'http' ? 'Headers(可选)' : '环境变量(可选,值写 !!js process.env.KEY 引用密钥)'),
1257
+ h('label', null, transport === 'http' ? t('Headers(可选)') : t('环境变量(可选,值写 !!js process.env.KEY 引用密钥)')),
1176
1258
  kvRows.map((row, i) => {
1177
1259
  const field = transport === 'http' ? 'headers' : 'env';
1178
1260
  const originalKey = row.originalKey || row.k;
1179
1261
  const id = editRevealId(field, originalKey);
1180
1262
  return h('div', { key: i, className: 'dsh-mcp-kv-edit' },
1181
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); } }),
1182
- 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); } }),
1183
1265
  h('div', { className: 'dsh-mcp-kv-row-actions' },
1184
1266
  editing && row.v === PRESERVED_VALUE_LABEL ? revealIcon(id, row.k, () => toggleEditReveal(field, originalKey)) : null,
1185
- 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('配置项')) }, '✕'),
1186
1268
  ),
1187
1269
  );
1188
1270
  }),
1189
- 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('+ 添加') })),
1190
1272
  ),
1191
1273
  h('div', { className: 'dsh-mcp-card dsh-mcp-advanced' },
1192
- h('h4', null, '运行策略'),
1193
- h('div', { className: 'dsh-mcp-field' }, h('label', null, '工具调用超时(毫秒)'), h('input', { type: 'number', min: 1, value: toolCallTimeoutMs, onChange: (e) => setToolCallTimeoutMs(e.target.value) })),
1194
- h('label', { className: 'dsh-mcp-check' }, h('input', { type: 'checkbox', checked: failOnStartupError, onChange: (e) => setFailOnStartupError(e.target.checked) }), '首次连接失败时阻止插件启动'),
1195
- 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('断开后自动重连')),
1196
1278
  h('div', { className: 'dsh-mcp-number-grid' },
1197
- 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) })),
1198
- 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) })),
1199
- 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) })),
1200
1282
  ),
1201
1283
  ),
1202
1284
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1203
1285
  h('div', { className: 'dsh-mcp-modal-actions' },
1204
- h(Btn, { onClick: onClose, children: '取消' }),
1205
- 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('添加')) }),
1206
1288
  ),
1207
1289
  ),
1208
1290
  );
1209
1291
  };
1210
1292
 
1211
- function builtinConfigurationText(item) {
1212
- const config = item?.configuration || {};
1213
- if (config.url) {
1214
- const headers = Object.entries(config.headers || {}).map(([key, value]) => `${key}: ${value}`);
1215
- return [config.url, ...headers].join(' · ');
1216
- }
1217
- return [config.command, ...(config.args || [])].filter(Boolean).join(' ');
1218
- }
1219
-
1220
1293
  const sourceImportNames = (row) => (row.entries || []).filter((entry) => !entry.error).map((entry) => entry.name);
1221
1294
  // 只在界面上提示「这条来源带了会被掩码的字段」。具体值一律留在 Host:Host 只给字段名。
1222
1295
  const sourceMaskedFields = (row) => [...new Set((row.entries || []).flatMap((entry) => entry.maskedFields || []))];
1223
1296
  // 环境变量名不算秘密(值才是),但缺值会让导入后的那个位置变成空字符串,得先说。
1224
1297
  const sourceMissingEnv = (row) => [...new Set((row.entries || []).flatMap((entry) => entry.missingEnv || []))];
1225
1298
 
1299
+ // ── JSON 导入弹窗:多来源选择、错误分组、掩码字段提示 ────────────────────────────
1226
1300
  const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope, scopeName }) => {
1227
1301
  const isWorkspace = scope !== 'global' && scope !== undefined;
1228
1302
  const dialogRef = useRef(null);
@@ -1244,7 +1318,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1244
1318
  setSourcesLoading(true); setSourcesError('');
1245
1319
  try {
1246
1320
  const res = await call('scanImportSources', isWorkspace ? { wsPath: scope } : {});
1247
- if (!res?.ok) throw new Error(res?.error || '读取本机客户端配置失败');
1321
+ if (!res?.ok) throw new Error(res?.error || t('读取本机客户端配置失败'));
1248
1322
  setSources(res.sources || []);
1249
1323
  } catch (e) { setSourcesError(String(e?.message || e)); }
1250
1324
  finally { setSourcesLoading(false); }
@@ -1261,11 +1335,11 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1261
1335
  // 选中来源后,来源导入优先于粘贴内容:两者不会同时在生效状态。
1262
1336
  const pickSource = async (row) => {
1263
1337
  const names = sourceImportNames(row);
1264
- if (row.error || !names.length) { setError(row.error || '这条配置里没有可导入的 MCP'); return; }
1338
+ if (row.error || !names.length) { setError(row.error || t('这条配置里没有可导入的 MCP')); return; }
1265
1339
  setBusy(true); setError(''); setPreview(null); clearSource();
1266
1340
  try {
1267
1341
  const res = await call('previewImportSource', { sourceId: row.sourceId, scope: row.scope, names, ...scopePayload });
1268
- if (!res?.ok) throw new Error(res?.error || '预览失败');
1342
+ if (!res?.ok) throw new Error(res?.error || t('预览失败'));
1269
1343
  setSource(row); setSourcePreview(res);
1270
1344
  } catch (e) { setError(String(e?.message || e)); }
1271
1345
  finally { setBusy(false); }
@@ -1276,7 +1350,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1276
1350
  const res = isWorkspace
1277
1351
  ? await call('previewWorkspaceImport', { wsPath: scope, json: text, mode })
1278
1352
  : await call('previewImport', { json: text, mode });
1279
- if (!res?.ok) throw new Error(res?.error || 'JSON 解析失败');
1353
+ if (!res?.ok) throw new Error(res?.error || t('JSON 解析失败'));
1280
1354
  setPreview(res);
1281
1355
  } catch (e) { setError(String(e?.message || e)); }
1282
1356
  finally { setBusy(false); }
@@ -1288,18 +1362,18 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1288
1362
  setBusy(true); setError('');
1289
1363
  try {
1290
1364
  const res = await run();
1291
- if (!res?.ok) throw new Error(res?.error || '导入失败');
1365
+ if (!res?.ok) throw new Error(res?.error || t('导入失败'));
1292
1366
  onImported(res);
1293
1367
  onClose();
1294
1368
  } catch (e) { setError(String(e?.message || e)); }
1295
1369
  finally { setBusy(false); }
1296
1370
  };
1297
- const replaceWarning = '替换会删除' + (isWorkspace ? '该项目' : '当前 Web profile');
1371
+ const replaceWarning = t('替换会删除') + (isWorkspace ? t('该项目') : t('当前 Web profile'));
1298
1372
  const applyImport = async () => {
1299
1373
  if (!preview) return runPreview();
1300
1374
  // 同名冲突不再让整批失败:Host 会跳过那几条并在预览的提示里写明,用户按需处理。
1301
1375
  return submitImport(
1302
- mode === 'replace' ? replaceWarning + '中未出现在 JSON 里的 MCP。确认继续?' : '',
1376
+ mode === 'replace' ? replaceWarning + t('中未出现在 JSON 里的 MCP。确认继续?') : '',
1303
1377
  () => (isWorkspace
1304
1378
  ? call('importWorkspaceJson', { wsPath: scope, json: text, mode })
1305
1379
  : call('importJson', { json: text, mode })),
@@ -1308,7 +1382,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1308
1382
  const applySourceImport = async () => {
1309
1383
  if (!source || !sourcePreview) return;
1310
1384
  return submitImport(
1311
- mode === 'replace' ? replaceWarning + '中未出现在这份来源里的 MCP。确认继续?' : '',
1385
+ mode === 'replace' ? replaceWarning + t('中未出现在这份来源里的 MCP。确认继续?') : '',
1312
1386
  // 送出的是来源标识 + 条目名 + 预览时的内容指纹;路径与凭据都留在 Host 重算。
1313
1387
  () => call('importSource', {
1314
1388
  sourceId: source.sourceId,
@@ -1322,25 +1396,25 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1322
1396
  };
1323
1397
  const activePreview = sourcePreview?.preview || preview;
1324
1398
  const summary = activePreview ? [
1325
- '新增:' + (activePreview.added?.join(', ') || '无'),
1326
- '更新:' + (activePreview.updated?.join(', ') || '无'),
1327
- 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,
1328
1402
  // 被跳过的同名条目(其他配置层占用、或来源里重名)由 Host 放进 warnings,这里只渲染一处,
1329
1403
  // 免得同一件事既显示"冲突"又显示"提示"。
1330
- ...(activePreview.warnings || []).map((warning) => '提示:' + warning),
1404
+ ...(activePreview.warnings || []).map((warning) => t('提示:') + warning),
1331
1405
  ].filter(Boolean).join('\n') : '';
1332
1406
  // 来源列表已按当前层过滤(Host 侧保证):全局标签只有全局来源,项目标签只有该项目的来源,
1333
1407
  // 所以行上不需要「(项目)」后缀,也不再需要用户自己判断「这一行会导到哪儿去」。
1334
- const layerLabel = isWorkspace ? '项目级' : '全局';
1408
+ const layerLabel = isWorkspace ? t('项目级') : t('全局');
1335
1409
  const layerHint = isWorkspace
1336
- ? '没检测到这个项目的项目级 MCP 配置(.mcp.json / .codex/config.toml / .pi/mcp.json / opencode.json 等),可在下面手动粘贴'
1337
- : '没检测到本机客户端的全局 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 等),可在下面手动粘贴');
1338
1412
  return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && onClose() },
1339
- 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(); } } },
1340
- 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(')') : '')),
1341
1415
  h('div', { className: 'dsh-mcp-field dsh-mcp-source-box' },
1342
- h('label', null, '从本机客户端导入' + layerLabel + '配置(只读,不修改它们的文件)'),
1343
- 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,
1344
1418
  sourcesError ? h('div', { className: 'dsh-mcp-d-err' }, sourcesError) : null,
1345
1419
  !sourcesLoading && !sourcesError && !sources.length
1346
1420
  ? h('div', { className: 'dsh-mcp-source-detail' }, layerHint)
@@ -1349,7 +1423,7 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1349
1423
  const names = sourceImportNames(row);
1350
1424
  const masked = sourceMaskedFields(row);
1351
1425
  const missingEnv = sourceMissingEnv(row);
1352
- 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'));
1353
1427
  return h('button', {
1354
1428
  key: row.key,
1355
1429
  type: 'button',
@@ -1363,29 +1437,29 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1363
1437
  h('code', { className: 'dsh-mcp-source-path' }, row.displayPath),
1364
1438
  ),
1365
1439
  h('span', { className: 'dsh-mcp-source-detail' }, detail),
1366
- masked.length ? h('span', { className: 'dsh-mcp-source-note' }, '含需掩码字段:' + masked.join('、') + '(导入后在详情页可用眼睛查看)') : null,
1367
- missingEnv.length ? h('span', { className: 'dsh-mcp-source-warn' }, '环境变量未设置:' + missingEnv.join('、') + '(导入后该处会得到空值)') : null,
1368
- 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,
1369
1443
  );
1370
1444
  })) : null,
1371
1445
  ),
1372
1446
  h('div', { className: 'dsh-mcp-field' },
1373
- 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 对象')),
1374
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}' }),
1375
1449
  ),
1376
1450
  h('div', { className: 'dsh-mcp-radio-row' },
1377
- h('label', { className: mode === 'merge' ? 'sel' : '' }, h('input', { type: 'radio', checked: mode === 'merge', onChange: () => { setMode('merge'); setPreview(null); } }), '合并(同名更新)'),
1378
- 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')),
1379
1453
  ),
1380
1454
  summary ? h('div', { className: 'dsh-mcp-card dsh-mcp-import-preview' }, summary) : null,
1381
1455
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1382
1456
  h('div', { className: 'dsh-mcp-modal-actions' },
1383
- h(Btn, { onClick: onClose, children: '取消' }),
1384
- 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,
1385
1459
  source
1386
- ? h(Btn, { cls: 'primary', onClick: applySourceImport, disabled: busy || !sourcePreview, children: busy ? '导入中…' : '确认从来源导入' })
1387
- : h(Btn, { onClick: runPreview, disabled: busy || !text.trim(), children: busy ? '解析中…' : '预览' }),
1388
- 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('解析并预览') }),
1389
1463
  ),
1390
1464
  ),
1391
1465
  );
@@ -1395,9 +1469,20 @@ const POLL_INTERVAL_MS = 5000;
1395
1469
 
1396
1470
  // 面板靠轮询反映 MCP 连接状态。标签页不可见时没人看这份数据,却照样每 5s 打两个 RPC,
1397
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
+ };
1398
1482
  function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
1399
1483
  const visible = () => !doc || doc.visibilityState !== 'hidden';
1400
- 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);
1401
1486
  if (!doc || typeof doc.addEventListener !== 'function') return disposeInterval;
1402
1487
  const onVisibilityChange = () => { if (visible()) run(); };
1403
1488
  doc.addEventListener('visibilitychange', onVisibilityChange);
@@ -1407,6 +1492,7 @@ function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
1407
1492
  };
1408
1493
  }
1409
1494
 
1495
+ // ── MCP 主面板(McpTab):服务器 + 工具 + 可见性感知轮询 ──────────────────────────
1410
1496
  const McpTab = (props) => {
1411
1497
  const { call, onClose, floating, timer, pluginManager } = props;
1412
1498
  const panelRef = useRef(null);
@@ -1479,7 +1565,7 @@ const McpTab = (props) => {
1479
1565
  revisionRef.current = res.revision || '';
1480
1566
  setServers(next);
1481
1567
  }
1482
- if (notify) flash('已刷新项目 MCP,共 ' + next.length + ' 个');
1568
+ if (notify) flash(t('已刷新项目 MCP,共 ') + next.length + t(' 个'));
1483
1569
  } else {
1484
1570
  const next = res.servers || [];
1485
1571
  if (consumeRevision(revealRevisionRef, res.revealRevision)) clearRevealed();
@@ -1489,10 +1575,10 @@ const McpTab = (props) => {
1489
1575
  setServers(next);
1490
1576
  }
1491
1577
  setError('');
1492
- if (notify) flash('已刷新,共 ' + next.length + ' 个 MCP');
1578
+ if (notify) flash(t('已刷新,共 ') + next.length + t(' 个 MCP'));
1493
1579
  }
1494
1580
  } else {
1495
- const message = (res && res.error) || '加载失败';
1581
+ const message = (res && res.error) || t('加载失败');
1496
1582
  setError(message);
1497
1583
  if (notify) flash(message, 'err');
1498
1584
  }
@@ -1595,9 +1681,9 @@ const McpTab = (props) => {
1595
1681
  try {
1596
1682
  const res = await call(method, args);
1597
1683
  if (res && res.ok) {
1598
- flash(successMsg || (res.note || method + ' 成功'));
1684
+ flash(successMsg || (res.note || method + t(' 成功')));
1599
1685
  } else {
1600
- flash((res && res.error) || method + ' 失败', 'err');
1686
+ flash((res && res.error) || method + t(' 失败'), 'err');
1601
1687
  }
1602
1688
  } catch (e) {
1603
1689
  flash(String((e && e.message) || e), 'err');
@@ -1616,27 +1702,27 @@ const McpTab = (props) => {
1616
1702
  revisionRef.current = '';
1617
1703
  setServers((prev) => prev.map((x) => (x.serverName === s.serverName ? { ...x, enabled: next } : x)));
1618
1704
  if (scope === 'global') {
1619
- return act(next ? 'enable' : 'disable', s.serverName, (next ? '已启用 ' : '已禁用 ') + s.serverName);
1705
+ return act(next ? 'enable' : 'disable', s.serverName, (next ? t('已启用 ') : t('已禁用 ')) + s.serverName);
1620
1706
  }
1621
1707
  // 项目层:写 disabled 字段到项目配置。
1622
1708
  const spec = { ...s, name: s.serverName, transport: s.transport === 'stdio' ? 'stdio' : 'streamable-http', disabled: !next };
1623
- return act('updateWorkspaceServer', { wsPath: scope, spec }, (next ? '已启用 ' : '已禁用 ') + s.serverName);
1709
+ return act('updateWorkspaceServer', { wsPath: scope, spec }, (next ? t('已启用 ') : t('已禁用 ')) + s.serverName);
1624
1710
  };
1625
1711
  const onReconnect = (s) => {
1626
1712
  // 项目连接也要有出口:mcp-client 的重连预算耗尽后会注销工具并停止,只能靠重载配置/重启宿主
1627
1713
  // 恢复(它的 README 明写),而项目 MCP 不是常驻服务 —— 所以「重连」在这里是必需的能力,
1628
1714
  // 不是可选装饰。这条路径 = 拆掉旧连接 + 按当前配置重建 + 对齐存活会话。
1629
1715
  if (s.scope === 'workspace') {
1630
- return act('reconnectWorkspaceServer', { wsPath: scope, name: s.serverName }, '已重连 ' + s.serverName);
1716
+ return act('reconnectWorkspaceServer', { wsPath: scope, name: s.serverName }, t('已重连 ') + s.serverName);
1631
1717
  }
1632
- act('reconnect', s.serverName, '已请求重连 ' + s.serverName);
1718
+ act('reconnect', s.serverName, t('已请求重连 ') + s.serverName);
1633
1719
  };
1634
1720
  const onRemove = (s) => {
1635
1721
  const isWorkspace = s.scope === 'workspace';
1636
- if (confirm('确认移除 MCP「' + s.serverName + '」?将从' + (isWorkspace ? '项目配置' : '当前 Web profile 配置') + '中删除。')) {
1722
+ if (confirm(t('确认移除 MCP「') + s.serverName + t('」?将从') + (isWorkspace ? t('项目配置') : t('当前 Web profile 配置')) + t('中删除。'))) {
1637
1723
  clearRevealed();
1638
- if (isWorkspace) act('removeWorkspaceServer', { wsPath: scope, name: s.serverName }, '已移除 ' + s.serverName);
1639
- 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);
1640
1726
  }
1641
1727
  };
1642
1728
  const onToggleExclude = (serverName, hidden) => {
@@ -1646,19 +1732,19 @@ const McpTab = (props) => {
1646
1732
  setExclude((prev) => (hidden ? (prev.includes(serverName) ? prev : [...prev, serverName]) : prev.filter((name) => name !== serverName)));
1647
1733
  // 屏蔽同样是"写盘即对齐":Host 会立刻把新的 restrict 写进会话作用域,运行中的会话在**下一次
1648
1734
  // 模型请求**就不再见得到这些工具(工具清单是每步重新组装的,不是首轮定型)。
1649
- act('setWorkspaceExclude', { wsPath: scope, serverName, hidden }, hidden ? ('已屏蔽 ' + serverName + ',对运行中的会话立即生效') : ('已取消屏蔽 ' + serverName + ',对运行中的会话立即生效'));
1735
+ act('setWorkspaceExclude', { wsPath: scope, serverName, hidden }, hidden ? (t('已屏蔽 ') + serverName + t(',对运行中的会话立即生效')) : (t('已取消屏蔽 ') + serverName + t(',对运行中的会话立即生效')));
1650
1736
  };
1651
1737
  useEffect(() => { clearRevealed(); }, [selected, clearRevealed]);
1652
1738
  const revealValue = useCallback(async (server, field, key) => {
1653
- if (!server.managed) throw new Error('该 MCP 来自其他配置层,配置只读,无法查看具体值');
1739
+ if (!server.managed) throw new Error(t('该 MCP 来自其他配置层,配置只读,无法查看具体值'));
1654
1740
  const res = server.scope === 'workspace'
1655
1741
  ? await call('revealWorkspaceServer', { wsPath: scope, name: server.serverName, field, key })
1656
1742
  : await call('reveal', { name: server.serverName, field, key });
1657
- if (!res?.ok) throw new Error(res?.error || '读取配置失败');
1743
+ if (!res?.ok) throw new Error(res?.error || t('读取配置失败'));
1658
1744
  return res.value;
1659
1745
  }, [call, scope]);
1660
1746
  const onCopy = useCallback((text) => {
1661
- copyText(text).then((ok) => flash(ok ? '已复制到剪贴板' : '复制失败', ok ? 'ok' : 'err'));
1747
+ copyText(text).then((ok) => flash(ok ? t('已复制到剪贴板') : t('复制失败'), ok ? 'ok' : 'err'));
1662
1748
  }, [flash]);
1663
1749
  const onReveal = useCallback(async (server, field, key) => {
1664
1750
  const id = server.serverName + ':' + field + (key !== undefined ? ':' + key : '');
@@ -1687,12 +1773,12 @@ const McpTab = (props) => {
1687
1773
  : (editTarget ? await call('update', spec) : await call('add', spec));
1688
1774
  if (res && res.ok) {
1689
1775
  clearRevealed();
1690
- flash(editTarget ? '已保存 ' + spec.name : '已添加 ' + spec.name);
1776
+ flash(editTarget ? t('已保存 ') + spec.name : t('已添加 ') + spec.name);
1691
1777
  setAddOpen(false);
1692
1778
  setEditTarget(null);
1693
1779
  timer.timeout(load, 1500);
1694
1780
  } else {
1695
- setFormError((res && res.error) || (editTarget ? '保存失败' : '添加失败'));
1781
+ setFormError((res && res.error) || (editTarget ? t('保存失败') : t('添加失败')));
1696
1782
  }
1697
1783
  } catch (e) {
1698
1784
  setFormError(String((e && e.message) || e));
@@ -1704,7 +1790,7 @@ const McpTab = (props) => {
1704
1790
  const openEdit = (s) => { setEditTarget(s); setAddOpen(true); };
1705
1791
  const onImported = (res) => {
1706
1792
  clearRevealed();
1707
- flash(res.note || '已导入 MCP');
1793
+ flash(res.note || t('已导入 MCP'));
1708
1794
  timer.timeout(load, 1200);
1709
1795
  };
1710
1796
 
@@ -1732,7 +1818,7 @@ const McpTab = (props) => {
1732
1818
  // (client api: pluginManager/installBundle expected 2 argument(s), got 1)。
1733
1819
  const res = await pluginManager[UPGRADE_METHOD](targetSpec, {});
1734
1820
  if (!res || res.ok !== true) {
1735
- 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('调用失败') }));
1736
1822
  return;
1737
1823
  }
1738
1824
  const r = res.value || {};
@@ -1769,13 +1855,13 @@ const McpTab = (props) => {
1769
1855
  }, [globalServers, exclude]);
1770
1856
 
1771
1857
  const scopeTabs = useMemo(() => {
1772
- const tabs = [{ key: 'global', label: '全局' }];
1858
+ const tabs = [{ key: 'global', label: t('全局') }];
1773
1859
  for (const ws of workspaces) {
1774
- 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(')') : '') });
1775
1861
  }
1776
1862
  return tabs;
1777
1863
  }, [workspaces]);
1778
- 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);
1779
1865
  const switchScope = useCallback((next) => {
1780
1866
  if (next === scope) return;
1781
1867
  setScope(next);
@@ -1805,11 +1891,11 @@ const McpTab = (props) => {
1805
1891
  // 作用域由下方的 tab 高亮承载,按钮文案不再重复项目名:项目名长度不可控,
1806
1892
  // 塞进三个按钮会把工具栏挤到换行;完整作用域放在 title 里,宽度保持恒定。
1807
1893
  { className: 'dsh-mcp-head-actions' },
1808
- h(Btn, { icon: 'refresh', onClick: () => load(true), disabled: refreshing, children: refreshing ? '刷新中…' : '刷新' }),
1809
- h(Btn, { onClick: () => setImportOpen(true), title: '导入 MCP(' + scopeName + ')', children: '导入 MCP' }),
1810
- 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') }),
1811
1897
  h(EntryMenu, null),
1812
- onClose ? h(Btn, { icon: 'x', onClick: onClose, children: '关闭' }) : null,
1898
+ onClose ? h(Btn, { icon: 'x', onClick: onClose, children: t('关闭') }) : null,
1813
1899
  ),
1814
1900
  ),
1815
1901
  h(ScopeTabs, { tabs: scopeTabs, active: scope, onSelect: switchScope }),
@@ -1819,37 +1905,37 @@ const McpTab = (props) => {
1819
1905
  // 文案随升级阶段走:升级中/已装完待重启/失败,都比「有新版本」更该让用户看到。
1820
1906
  // 注意 updateHint 在 done 分支已经是 null(宿主已认不出旧版),所以这里不读它的字段。
1821
1907
  h('span', null,
1822
- upgradeState.phase === 'running' ? '正在升级到 v' + upgradeState.version + '…'
1823
- : upgradeState.phase === 'done' ? '已升级到 v' + upgradeState.version + ',重启宿主后生效'
1824
- : 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
1825
1911
  : upgradeState.phase === 'blocked' && upgradeState.reason === 'not-npm'
1826
- ? '本地安装(' + (upgradeState.source || 'file') + '),一键升级会把本地副本换成 npm 包,故只提示' + (updateHint?.current ? '(当前 v' + updateHint.current + ')' : '')
1912
+ ? t('本地安装(') + (upgradeState.source || 'file') + t('),一键升级会把本地副本换成 npm 包,故只提示') + (updateHint?.current ? t('(当前 v') + updateHint.current + t(')') : '')
1827
1913
  : upgradeState.phase === 'idle' && upgradeState.reason === 'not-npm'
1828
1914
  // 探测阶段就判定不可一键升:这是本地开发安装的常态(file:/link:),
1829
1915
  // 说清原因,免得用户以为按钮坏了。
1830
- ? '新版本 v' + updateHint.latest + ' 可用;本地安装(' + (upgradeState.source || 'file') + ')请手动更新后重启'
1831
- : '新版本 v' + updateHint.latest + ' 可用'),
1916
+ ? t('新版本 v') + updateHint.latest + t(' 可用;本地安装(') + (upgradeState.source || 'file') + t(')请手动更新后重启')
1917
+ : t('新版本 v') + updateHint.latest + t(' 可用')),
1832
1918
  // 可升级时给按钮,否则退回链接。按钮只在探测确认过 service + npm 来源后出现——
1833
1919
  // 探测失败时这一整条与改动前完全一致,不会出现点了没反应的按钮。
1834
1920
  upgradeState.phase === 'idle' && upgradeState.spec
1835
1921
  ? h('button', {
1836
1922
  type: 'button', className: 'dsh-mcp-upd-go',
1837
- title: '升级由官方 pluginManager 执行,装完需要重启宿主',
1923
+ title: t('升级由官方 pluginManager 执行,装完需要重启宿主'),
1838
1924
  onClick: runUpgrade,
1839
- }, '升级')
1925
+ }, t('升级'))
1840
1926
  : h('a', {
1841
1927
  href: (updateHint && updateHint.url) || 'https://github.com/2026heshao/dsh-armory/releases',
1842
1928
  target: '_blank', rel: 'noreferrer',
1843
- }, upgradeState.phase === 'done' ? '看这次改了什么' : '查看更新方式'),
1844
- 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 })),
1845
1931
  ) : null,
1846
1932
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1847
- 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('。点开对应条目查看具体错误。')
1848
1934
  // 项目 MCP 是「该项目所有会话共享一份连接」,同一项目内不会因并发会话而撞名;这里只
1849
1935
  // 如实给出并发会话数作为上下文(挂载失败的常见外部原因),不再把它说成 serverName 占用。
1850
- + (liveAgents > 1 ? ' 本项目当前有 ' + liveAgents + ' 个会话在运行。' : '')) : null,
1851
- scope !== 'global' && scopeErrors.length ? h('div', { className: 'dsh-mcp-log' }, scopeErrors.length + ' 个项目 MCP 的工具不在共享作用域层:' + scopeErrors.map((m) => m.serverName).join('、') + '。点开对应条目查看具体错误。') : null,
1852
- 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,
1853
1939
  scope === 'global'
1854
1940
  ? h(
1855
1941
  'div',
@@ -1874,7 +1960,7 @@ const McpTab = (props) => {
1874
1960
  h('div', { className: 'dsh-mcp-ws-body', 'data-ws-path': scope },
1875
1961
  h('div', { className: 'dsh-mcp-section local' },
1876
1962
  h('div', { className: 'dsh-mcp-section-title' },
1877
- '此项目的 MCP',
1963
+ t('此项目的 MCP'),
1878
1964
  h('span', { className: 'dsh-mcp-section-count' }, servers.length),
1879
1965
  ),
1880
1966
  h(ServerList, {
@@ -1892,7 +1978,7 @@ const McpTab = (props) => {
1892
1978
  onStatusFilter: setStatusFilter,
1893
1979
  embedded: true,
1894
1980
  workspace: true,
1895
- emptyHint: '此项目还没有 MCP。用右上角「添加项目 MCP」或「市场」写入 .dsh/mcp.json',
1981
+ emptyHint: t('此项目还没有 MCP。用右上角「添加项目 MCP」或「市场」写入 .dsh/mcp.json'),
1896
1982
  }),
1897
1983
  ),
1898
1984
  h('div', { className: 'dsh-mcp-section global' + (globalOpen ? '' : ' collapsed') },
@@ -1904,14 +1990,14 @@ const McpTab = (props) => {
1904
1990
  onClick: () => setGlobalOpen((value) => !value),
1905
1991
  },
1906
1992
  h('span', { className: 'dsh-mcp-caret', 'aria-hidden': true }, globalOpen ? '▾' : '▸'),
1907
- h('span', { className: 'dsh-mcp-section-toggle-label' }, '全局 MCP'),
1993
+ h('span', { className: 'dsh-mcp-section-toggle-label' }, t('全局 MCP')),
1908
1994
  h('span', { className: 'dsh-mcp-section-summary' },
1909
1995
  globalServers.length === 0
1910
- ? '无'
1911
- : visibleGlobalCount + '/' + globalServers.length + ' 对本项目可见'
1996
+ ? t('无')
1997
+ : visibleGlobalCount + '/' + globalServers.length + t(' 对本项目可见')
1912
1998
  // 工具清单是**每一步**重新组装的,所以改动(新增/删除/改配置/屏蔽)对运行中的
1913
1999
  // 会话在下一轮请求就生效;只有手工改 .dsh/mcp.json 才要等下一次会话挂载。
1914
- + ' · 改动即刻生效'),
2000
+ + t(' · 改动即刻生效')),
1915
2001
  ),
1916
2002
  globalOpen ? h(GlobalServerList, { servers: globalServers, exclude, onToggleExclude, onSelect: setSelected, selected, busy }) : null,
1917
2003
  ),
@@ -1944,7 +2030,7 @@ const McpTab = (props) => {
1944
2030
  return h(
1945
2031
  'div',
1946
2032
  { className: 'dsh-mcp-panel-overlay', onClick: (e) => e.target === e.currentTarget && onClose && onClose() },
1947
- 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),
1948
2034
  );
1949
2035
  return inner;
1950
2036
  };
@@ -2004,40 +2090,12 @@ function saveFabPos(pos) {
2004
2090
  }
2005
2091
  }
2006
2092
 
2007
- // ── 面板开合状态:悬浮按钮与左侧栏入口共享同一份 ──────────────────────────────
2008
- // 两个入口分属不同的 slot 子树(shell.overlay / sidebar.footer.action),没有公共
2009
- // React 祖先,因此用模块级订阅而不是 context。单一数据源:两个入口看到的开关状态
2010
- // 永远一致,不会出现「一个说开着、一个说关着」。
2011
- function createPanelVisibility() {
2012
- let open = false;
2013
- const listeners = new Set();
2014
- return {
2015
- isOpen: () => open,
2016
- set(next) {
2017
- if (open === next) return;
2018
- open = next;
2019
- for (const listener of [...listeners]) listener();
2020
- },
2021
- subscribe(listener) {
2022
- listeners.add(listener);
2023
- return () => { listeners.delete(listener); };
2024
- },
2025
- };
2026
- }
2027
- const panelVisibility = createPanelVisibility();
2028
-
2029
- const usePanelOpen = () => {
2030
- const [open, setOpen] = useState(panelVisibility.isOpen());
2031
- useEffect(() => panelVisibility.subscribe(() => setOpen(panelVisibility.isOpen())), []);
2032
- return open;
2033
- };
2034
-
2035
- // ── 入口显示偏好:两个入口都可关,但不能都关 ─────────────────────────────────
2036
- // 面板只能从这两个入口打开(右下角悬浮按钮 / 左侧栏底部「MCP」)。所以「两个都关」是一
2037
- // 个必须**无法表达**的状态:真出现了,用户就再也打不开面板,只能手改 localStorage 回来。
2038
- // 这里因此不存两个 boolean,而是存一张「模式 → 入口可见性」表:非法组合在表里根本不存在,
2039
- // 不需要额外的守卫、禁用逻辑或「至少留一个」的提示文案。新增入口时,这张表的不变式
2040
- // (每一项至少一个入口可见)由 test/client-entry.test.mjs 直接对表断言。
2093
+ // ── 入口显示偏好:三个入口都可关,但不能都关 ─────────────────────────────────
2094
+ // 面板只能从这三个入口打开(左上角全局面板行 / 右下角悬浮按钮 / 左侧栏底部「MCP」)。所以
2095
+ // 「三个都关」是一个必须**无法表达**的状态:真出现了,用户就再也打不开面板,只能手改
2096
+ // localStorage 回来。这里因此不存三个 boolean,而是存一张「模式 → 入口可见性」表:非法组合
2097
+ // 在表里根本不存在,不需要额外的守卫、禁用逻辑或「至少留一个」的提示文案。新增入口时,这张
2098
+ // 表的不变式(每一项至少一个入口可见)由 test/client-entry.mjs 直接对表断言。
2041
2099
  const ENTRY_KEY = 'dsh-armory/entry';
2042
2100
  const ENTRY_VISIBILITY = {
2043
2101
  both: { panel: true, fab: true, sidebar: false },
@@ -2047,11 +2105,11 @@ const ENTRY_VISIBILITY = {
2047
2105
  all: { panel: true, fab: true, sidebar: true },
2048
2106
  };
2049
2107
  const ENTRY_LABELS = {
2050
- both: '全局面板 + 悬浮按钮(推荐)',
2051
- panel: '仅全局面板(左上角,"插件"旁)',
2052
- fab: '仅悬浮按钮',
2053
- sidebar: '仅侧栏底部入口',
2054
- all: '三个入口全开',
2108
+ both: t('全局面板 + 悬浮按钮(推荐)'),
2109
+ panel: t('仅全局面板(左上角,"插件"旁)'),
2110
+ fab: t('仅悬浮按钮'),
2111
+ sidebar: t('仅侧栏底部入口'),
2112
+ all: t('三个入口全开'),
2055
2113
  };
2056
2114
  const ENTRY_DEFAULT = 'both';
2057
2115
  const ENTRY_MODES = Object.keys(ENTRY_VISIBILITY);
@@ -2103,14 +2161,13 @@ const useEntryMode = () => {
2103
2161
  return mode;
2104
2162
  };
2105
2163
 
2106
- // shell.overlay 那个注册里到底渲染什么:面板挂载点与悬浮按钮是两件事——
2107
- // 面板:只要 open 就渲染。侧栏入口只改 panelVisibility,真正渲染面板的是这里,
2108
- // 所以把面板也用入口偏好门住 = 关掉悬浮按钮后侧栏入口点了没反应(把自己锁死)。
2109
- // 按钮:才看入口偏好,且只在面板没开着时占位。
2110
- // 抽成纯函数也是为了让这条不变式能脱离 React 被测:测试替身的 hooks 是空实现,组件本身
2111
- // 在 node 里渲染不起来(见 test/client-entry.test.mjs)。
2112
- function overlaySeats(mode, open) {
2113
- 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 };
2114
2171
  }
2115
2172
 
2116
2173
  // ── 全局面板入口(sidebar.panellist + main,与「插件」同一套官方机制)──────
@@ -2124,20 +2181,20 @@ const PANEL_ID = 'mcp-manager';
2124
2181
  // 入口排序:插件管理器用 0,定时任务用 10;取 5 让本入口正好落在「插件」旁边。
2125
2182
  const PANEL_ORDER = 5;
2126
2183
  // 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
2127
- const PANEL_LABEL = '技能 · MCP · 专家';
2184
+ const PANEL_LABEL = t('技能 · MCP · 专家');
2128
2185
 
2129
2186
  // skill 的 source 取值域来自官方 dsh-skill-filesystem 的 roots()(rank 顺序即优先级),
2130
2187
  // 与 skill-registry 的 runtime。这里只做展示文案,不参与判定。
2131
2188
  const SKILL_SOURCE_LABELS = {
2132
- 'project-dsh': '项目 .dsh/skills',
2133
- 'project-agents': '项目 .agents/skills',
2134
- custom: '自定义目录',
2135
- 'user-dsh': '用户 ~/.dsh/skills',
2136
- 'user-agents': '用户 ~/.agents/skills',
2137
- bundled: '随 DSH 分发',
2138
- 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('运行时注册'),
2139
2196
  };
2140
- const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || '未知来源');
2197
+ const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
2141
2198
 
2142
2199
  // 「项目」语境下 cwd 决定项目根(.dsh/skills / .agents/skills 都从它往下找),
2143
2200
  // 全局语境传 undefined,让提供方只扫用户级与自定义目录——与官方 list() 语义一致。
@@ -2150,40 +2207,49 @@ const skillScopeArg = (scope) => (scope === 'global' ? {} : { cwd: scope });
2150
2207
  // 历史出现过的未注册工具(例如已卸载的 MCP)只展示统计。
2151
2208
  // 延迟统计暂缺(见 tool-ledger.js 尾注),健康度以错误率 + 调用次数呈现。
2152
2209
  const TAILOR_WINDOWS = [
2153
- { key: '7', label: '近 7 天', days: 7 },
2154
- { key: '30', label: '近 30 天', days: 30 },
2155
- { 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 },
2156
2213
  ];
2157
2214
  const fmtInt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');
2158
2215
  const fmtPct = (n) => (typeof n === 'number' && n > 0 ? (n * 100).toFixed(1) + '%' : '0%');
2159
2216
  const fmtTax = (n) => (typeof n === 'number' && n > 0 ? '~' + n.toLocaleString('en-US') + ' tok' : '—');
2160
2217
  const fmtLast = (ms, now) => {
2161
- if (typeof ms !== 'number') return '从未';
2218
+ if (typeof ms !== 'number') return t('从未');
2162
2219
  const diff = Math.max(0, (typeof now === 'number' ? now : Date.now()) - ms);
2163
2220
  const min = Math.floor(diff / 60000);
2164
- if (min < 1) return '刚刚';
2165
- if (min < 60) return min + ' 分钟前';
2221
+ if (min < 1) return t('刚刚');
2222
+ if (min < 60) return min + t(' 分钟前');
2166
2223
  const h = Math.floor(min / 60);
2167
- if (h < 24) return h + ' 小时前';
2224
+ if (h < 24) return h + t(' 小时前');
2168
2225
  const d = Math.floor(h / 24);
2169
- return d + ' 天前';
2226
+ return d + t(' 天前');
2170
2227
  };
2171
- const kindLabel = (tool) => (tool.kind === 'mcp' ? 'MCP' : tool.kind === 'mcp-ambiguous' ? 'MCP?' : '内置');
2172
2228
  const fmtScanError = (message) =>
2173
2229
  /404|transport failure/i.test(String(message))
2174
- ? String(message) + ' ——宿主还没注册 toolTailor 接口:重启 DeepSeek Harness 让宿主侧生效后即可'
2230
+ ? String(message) + t(' ——宿主还没注册 toolTailor 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
2175
2231
  : String(message);
2176
2232
 
2177
- const ToolTailor = ({ call, timer }) => {
2233
+ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending }) => {
2178
2234
  const [windowKey, setWindowKey] = useState('30');
2179
- 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;
2180
2240
  const [error, setError] = useState('');
2181
2241
  const [busy, setBusy] = useState(false);
2182
2242
  const [actionError, setActionError] = useState('');
2183
2243
  const [flashMsg, setFlashMsg] = useState('');
2184
- const [onlyDead, setOnlyDead] = useState(true);
2244
+ // 默认展示全部工具。账本里同时存在两类行:窗口内有真实调用次数的,和注册表里
2245
+ // 零调用的候选项。旧版默认打开「只看零调用」,屏幕上必然整列 0,看上去像「检测
2246
+ // 全坏」——那是筛选口径而不是数据问题。默认关掉,筛选交给用户显式开启。
2247
+ const [onlyDead, setOnlyDead] = useState(false);
2185
2248
  const flashCancel = useRef(null);
2186
2249
  const days = Number(windowKey) || 30;
2250
+ // 父层先把 30 天窗口扫好了(角标要用)。窗口一致且已有报告时,首次挂载**不必**再扫;
2251
+ // 只有用户切窗口(7/90 天)或点「重扫」时才真的重拉。
2252
+ const firstResolveRef = useRef(false);
2187
2253
 
2188
2254
  const load = useCallback(async () => {
2189
2255
  setBusy(true);
@@ -2191,21 +2257,33 @@ const ToolTailor = ({ call, timer }) => {
2191
2257
  try {
2192
2258
  const res = await call('toolTailor', { days });
2193
2259
  if (res && res.ok) {
2194
- setReport(res);
2260
+ commitReport(res);
2195
2261
  setActionError('');
2196
2262
  } else {
2197
- setError((res && res.error) || 'toolTailor 调用失败');
2263
+ setError((res && res.error) || t('toolTailor 调用失败'));
2198
2264
  }
2199
2265
  } catch (e) {
2200
2266
  setError(String((e && e.message) || e));
2201
2267
  } finally {
2202
2268
  setBusy(false);
2203
2269
  }
2204
- }, [call, days]);
2270
+ }, [call, days, commitReport]);
2205
2271
 
2206
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
+ }
2207
2281
  load();
2208
- }, [load]);
2282
+ // report 刻意不入依赖:它每次拉取都会变,入依赖会变成自激循环。
2283
+ // 注:本仓库不装 eslint-plugin-react-hooks(没有 React 侧的 lint 链路),所以这里不挂
2284
+ // disable 指令——挂了也是引用一条不存在的规则,ESLint 9 会直接判 error。将来若接入
2285
+ // react-hooks,需要在这行补 disable 并写成已注册的规则名。
2286
+ }, [load, seedPending]);
2209
2287
 
2210
2288
  // 完成动作后账本要重扫(禁用会让工具从注册表里消失),但扫描要几百毫秒,
2211
2289
  // 先给行内反馈再刷新。
@@ -2223,7 +2301,7 @@ const ToolTailor = ({ call, timer }) => {
2223
2301
  try {
2224
2302
  const res = await call(method, args);
2225
2303
  if (res && res.ok) flash(msg);
2226
- else flash((res && res.error) || method + ' 失败', 'err');
2304
+ else flash((res && res.error) || method + t(' 失败'), 'err');
2227
2305
  } catch (e) {
2228
2306
  flash(String((e && e.message) || e), 'err');
2229
2307
  } finally {
@@ -2238,36 +2316,68 @@ const ToolTailor = ({ call, timer }) => {
2238
2316
  .sort((a, b) => (b.calls - a.calls) || ((b.schemaTokens || 0) - (a.schemaTokens || 0)) || String(a.name).localeCompare(String(b.name)));
2239
2317
  const deadMcp = tools.filter((tool) => tool.kind === 'mcp' && tool.calls === 0);
2240
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;
2241
2353
 
2242
2354
  return h('div', { className: 'dsh-mcp-tailor' },
2243
2355
  h('div', { className: 'dsh-mcp-tailor-cards' },
2244
- h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.totalCalls : null)), h('span', null, `窗口内工具调用总次数(${days} 天)`)),
2245
- h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.toolCount : null)), h('span', null, '出现过的工具(含已卸载 MCP 的历史调用)')),
2246
- h('div', { className: 'dsh-mcp-tailor-card dead' }, h('b', null, fmtInt(report ? report.deadMcpCount : null)), h('span', null, `零调用的 MCP 工具(每轮白缴 ${report ? fmtTax(report.deadMcpTaxTokens) : '—'} 上下文税)`)),
2247
- 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)]))),
2248
2360
  ),
2249
2361
  h('div', { className: 'dsh-mcp-tailor-toolbar' },
2250
2362
  TAILOR_WINDOWS.map((w) =>
2251
2363
  h('button', { key: w.key, type: 'button', className: 'dsh-mcp-btn' + (windowKey === w.key ? ' primary' : ''), onClick: () => setWindowKey(w.key) }, w.label),
2252
2364
  ),
2253
- h('button', { type: 'button', className: 'dsh-mcp-btn' + (onlyDead ? ' primary' : ''), onClick: () => setOnlyDead((v) => !v), title: '只看窗口内零调用的工具' }, '只看死工具'),
2254
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, '重扫'),
2255
- h('span', { className: 'dsh-mcp-tailor-meta' },
2256
- report
2257
- ? `扫描 ${report.sessionsScanned} 个会话 · 读 ${report.filesRead} 个日志${report.failedFiles ? ` · ${report.failedFiles} 个失败` : ''} · 全部工具 Schema 合计 ~${fmtInt(report.taxTotalTokens)} tok/请求`
2258
- : (busy ? '扫描中…' : ''),
2259
- ),
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),
2260
2368
  ),
2261
- 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,
2262
2372
  actionError ? h('div', { className: 'dsh-mcp-tailor-note' }, actionError) : null,
2263
2373
  flashMsg ? h('div', { className: 'dsh-mcp-tailor-note' }, flashMsg) : null,
2264
2374
  h('div', { className: 'dsh-mcp-tailor-table' },
2265
2375
  h('div', { className: 'dsh-mcp-tailor-row head' },
2266
- h('span', null, '工具'), h('span', { className: 't-num' }, '调用'), h('span', { className: 't-num' }, '错误'),
2267
- 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('最后使用 / 操作')),
2268
2378
  ),
2269
2379
  shown.length === 0
2270
- ? 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('暂无数据')))
2271
2381
  : shown.map((tool) => {
2272
2382
  const actionable = tool.kind === 'mcp' && tool.server;
2273
2383
  const isDead = tool.calls === 0;
@@ -2276,8 +2386,8 @@ const ToolTailor = ({ call, timer }) => {
2276
2386
  h('span', { className: 't-name' },
2277
2387
  tool.server ? h('span', { className: 't-badge' }, tool.server) : null,
2278
2388
  tool.server ? tool.leaf : tool.name,
2279
- tool.serverDisabled ? h('span', { className: 't-badge warn' }, '已禁用') : null,
2280
- 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,
2281
2391
  ),
2282
2392
  h('span', { className: 't-num' }, fmtInt(tool.calls)),
2283
2393
  h('span', { className: 't-num' }, fmtInt(tool.errors)),
@@ -2287,9 +2397,9 @@ const ToolTailor = ({ call, timer }) => {
2287
2397
  fmtLast(tool.lastUsedMs, report ? report.generatedAt : undefined),
2288
2398
  actionable && !tool.serverDisabled
2289
2399
  ? h('span', { className: 't-act' },
2290
- 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('禁用服务器')),
2291
2401
  ' ',
2292
- 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('启用')),
2293
2403
  )
2294
2404
  : null,
2295
2405
  ),
@@ -2298,8 +2408,8 @@ const ToolTailor = ({ call, timer }) => {
2298
2408
  ),
2299
2409
  deadMcpActionable.length
2300
2410
  ? h('div', { className: 'dsh-mcp-tailor-note' },
2301
- h('b', null, '裁缝建议:'),
2302
- `${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)]),
2303
2413
  )
2304
2414
  : null,
2305
2415
  );
@@ -2308,16 +2418,16 @@ const ToolTailor = ({ call, timer }) => {
2308
2418
  const BOARD_CATEGORIES = [
2309
2419
  { key: 'mcp', label: 'MCP', icon: 'plug' },
2310
2420
  { key: 'skill', label: 'Skill', icon: 'skill' },
2311
- { key: 'expert', label: '专家', icon: 'expert' },
2312
- { key: 'tailor', label: '工具裁缝', icon: 'scissors' },
2313
- { 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' },
2314
2424
  ];
2315
2425
  // 宿主侧接口不存在(HTTP 404 / transport failure)几乎总是「宿主还没重启」:
2316
2426
  // 客户端 bundle 从磁盘现读、改完刷新即生效,而宿主的 Remote 方法在启动时才注册。
2317
2427
  // 这里把生硬的传输错误翻译成可操作的提示,而不是让用户对着 404 猜。
2318
2428
  const skillTransportHint = (message) =>
2319
2429
  /404|transport failure/i.test(String(message))
2320
- ? String(message) + ' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可'
2430
+ ? String(message) + t(' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
2321
2431
  : String(message);
2322
2432
 
2323
2433
  // ── Skill 看板:列表 + 详情 ──────────────────────────────────────────
@@ -2347,10 +2457,10 @@ const SkillBoard = ({ call, scope, timer }) => {
2347
2457
  if (res && res.ok) {
2348
2458
  setSkills(res.skills || []);
2349
2459
  setAvailable(res.available !== false);
2350
- setError(res.available === false ? '宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用' : '');
2460
+ setError(res.available === false ? t('宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用') : '');
2351
2461
  } else {
2352
2462
  setAvailable(false);
2353
- setError(skillTransportHint((res && res.error) || '读取 skill 列表失败'));
2463
+ setError(skillTransportHint((res && res.error) || t('读取 skill 列表失败')));
2354
2464
  }
2355
2465
  } catch (e) {
2356
2466
  if (seq !== loadSeq.current) return;
@@ -2394,7 +2504,7 @@ const SkillBoard = ({ call, scope, timer }) => {
2394
2504
  const res = await call('skillDetail', { ...skillScopeArg(scope), name });
2395
2505
  if (seq !== detailSeq.current) return;
2396
2506
  if (res && res.ok && res.skill) setDetail(res.skill);
2397
- else setDetailError((res && res.error) || '读取 skill 内容失败');
2507
+ else setDetailError((res && res.error) || t('读取 skill 内容失败'));
2398
2508
  } catch (e) {
2399
2509
  if (seq !== detailSeq.current) return;
2400
2510
  setDetailError(String((e && e.message) || e));
@@ -2411,19 +2521,19 @@ const SkillBoard = ({ call, scope, timer }) => {
2411
2521
  h('div', { className: 'dsh-mcp-skill-side' },
2412
2522
  h('div', { className: 'dsh-mcp-search' },
2413
2523
  h(Icon, { d: ICONS.search }),
2414
- 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') }),
2415
2525
  ),
2416
2526
  h('div', { className: 'dsh-mcp-item-sub' },
2417
- h('span', null, (loading && !refreshing) ? '读取中…' : (skills.length + ' 个 skill')),
2418
- refreshing ? h('span', null, ' · 刷新中…') : null,
2527
+ h('span', null, (loading && !refreshing) ? t('读取中…') : (skills.length + t(' 个 skill'))),
2528
+ refreshing ? h('span', null, t(' · 刷新中…')) : null,
2419
2529
  ),
2420
- 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 列表') },
2421
2531
  groups.length === 0 && !loading
2422
- ? 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 等目录发现。'))
2423
2533
  : groups.map((g) => h(React2.Fragment, { key: g.source },
2424
2534
  h('div', { className: 'dsh-mcp-item-sub', style: { marginTop: '6px' } },
2425
2535
  h(Badge, { cls: 't-stdio' }, skillSourceLabel(g.source)),
2426
- h('span', null, g.rows.length + ' 个'),
2536
+ h('span', null, g.rows.length + t(' 个')),
2427
2537
  ),
2428
2538
  g.rows.map((s) => h('button', {
2429
2539
  key: s.name,
@@ -2436,8 +2546,8 @@ const SkillBoard = ({ call, scope, timer }) => {
2436
2546
  h('div', { className: 'dsh-mcp-skill-main' },
2437
2547
  h('div', { className: 'dsh-mcp-skill-name' }, s.name),
2438
2548
  h('div', { className: 'dsh-mcp-skill-sub' },
2439
- s.modelInvocable ? h(Badge, { cls: 't-http' }, '模型可调用') : null,
2440
- 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,
2441
2551
  h('span', { className: 'dsh-mcp-skill-src' }, s.provider || ''),
2442
2552
  ),
2443
2553
  s.description ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description) : null,
@@ -2448,7 +2558,7 @@ const SkillBoard = ({ call, scope, timer }) => {
2448
2558
  ),
2449
2559
  h('div', { className: 'dsh-mcp-skill-detail' },
2450
2560
  !summary && !detailLoading
2451
- ? h('div', { className: 'dsh-mcp-skill-empty' }, '从左侧选择一个 skill 查看详情')
2561
+ ? h('div', { className: 'dsh-mcp-skill-empty' }, t('从左侧选择一个 skill 查看详情'))
2452
2562
  : null,
2453
2563
  summary ? h('div', { className: 'dsh-mcp-skill-d-head' },
2454
2564
  h('div', null,
@@ -2460,29 +2570,29 @@ const SkillBoard = ({ call, scope, timer }) => {
2460
2570
  summary.path ? h('button', {
2461
2571
  type: 'button',
2462
2572
  className: 'dsh-mcp-skill-d-path',
2463
- title: '把 skill 所在目录交给宿主在资源管理器中打开(只读定位)',
2573
+ title: t('把 skill 所在目录交给宿主在资源管理器中打开(只读定位)'),
2464
2574
  onClick: () => { call('revealSkill', { path: summary.path }); },
2465
- }, h(Icon, { d: ICONS.folder }), '打开目录') : null,
2575
+ }, h(Icon, { d: ICONS.folder }), t('打开目录')) : null,
2466
2576
  ) : null,
2467
2577
  summary ? h('div', { className: 'dsh-mcp-skill-d-body' },
2468
2578
  h('div', { className: 'dsh-mcp-skill-d-sec' },
2469
- h('b', null, '描述'),
2470
- 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('(无描述)')),
2471
2581
  ),
2472
2582
  summary.whenToUse ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2473
- h('b', null, '何时使用'),
2583
+ h('b', null, t('何时使用')),
2474
2584
  h('p', { className: 'dsh-mcp-skill-d-desc' }, summary.whenToUse),
2475
2585
  ) : null,
2476
2586
  h('div', { className: 'dsh-mcp-skill-d-sec' },
2477
- h('b', null, '调用策略'),
2587
+ h('b', null, t('调用策略')),
2478
2588
  h('p', { className: 'dsh-mcp-skill-d-desc' },
2479
- '模型可调用:' + (summary.modelInvocable ? '是' : '否')
2480
- + ' 用户可调用:' + (summary.userInvocable ? '是' : '否')),
2589
+ t('模型可调用:') + (summary.modelInvocable ? t('是') : t('否'))
2590
+ + t(' 用户可调用:') + (summary.userInvocable ? t('是') : t('否'))),
2481
2591
  ),
2482
- detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, '读取 skill 内容…') : null,
2592
+ detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, t('读取 skill 内容…')) : null,
2483
2593
  detailError ? h('div', { className: 'dsh-mcp-d-err' }, detailError) : null,
2484
2594
  detail && detail.content !== undefined ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2485
- h('b', null, '指令内容(' + detail.content.length + ' 字符)'),
2595
+ h('b', null, t('指令内容(') + detail.content.length + t(' 字符)')),
2486
2596
  h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.content),
2487
2597
  ) : null,
2488
2598
  ) : null,
@@ -2498,11 +2608,12 @@ const SkillBoard = ({ call, scope, timer }) => {
2498
2608
  // 已安装标记:技能按 wbSkillInstalled 的目录记录——只在进入面板时取一次,装/卸后局部刷新。
2499
2609
  const WB_MARKET_CONNECTOR_PAGE_SIZE = 60;
2500
2610
  const WB_SKILLHUB_SORTS = [
2501
- { key: 'downloads', label: '最多下载' },
2502
- { key: 'installs', label: '最多安装' },
2503
- { key: 'stars', label: '最多星标' },
2504
- { key: 'score', label: '综合评分' },
2611
+ { key: 'downloads', label: t('最多下载') },
2612
+ { key: 'installs', label: t('最多安装') },
2613
+ { key: 'stars', label: t('最多星标') },
2614
+ { key: 'score', label: t('综合评分') },
2505
2615
  ];
2616
+
2506
2617
  // ── 专家(WorkBuddy 专家中心直连)────────────────────────────────────
2507
2618
  // 两个页面一个数据源:ExpertInstalledBoard = 看板「专家」分类(管理**已召唤**的专家:
2508
2619
  // 预设状态、捆绑技能、送还、打开看板入口说明);ExpertMarket = 市场「专家」子标签
@@ -2514,7 +2625,7 @@ let expertCatalogCache = { at: 0, data: null };
2514
2625
  const loadExpertCatalogShared = async (call, force = false) => {
2515
2626
  if (!force && expertCatalogCache.data && Date.now() - expertCatalogCache.at < EXPERT_CATALOG_TTL_MS) return expertCatalogCache.data;
2516
2627
  const res = await call('wbExpertCatalog');
2517
- if (!res?.ok) throw new Error(res?.error || '读取专家目录失败');
2628
+ if (!res?.ok) throw new Error(res?.error || t('读取专家目录失败'));
2518
2629
  expertCatalogCache = { at: Date.now(), data: { experts: res.experts || [], categories: res.categories || [], loadedFrom: res.loadedFrom || '' } };
2519
2630
  return expertCatalogCache.data;
2520
2631
  };
@@ -2529,13 +2640,12 @@ const ExpertInstalledBoard = ({ call }) => {
2529
2640
  const [presets, setPresets] = useState([]);
2530
2641
  const [skills, setSkills] = useState([]);
2531
2642
  const [detail, setDetail] = useState(null);
2532
- const [withSkills, setWithSkills] = useState(true);
2533
2643
 
2534
2644
  const load = useCallback(async () => {
2535
2645
  setLoading(true); setError(''); setNote('');
2536
2646
  try {
2537
2647
  const res = await call('wbExpertInstalled');
2538
- if (!res?.ok) throw new Error(res?.error || '读取已召唤专家失败');
2648
+ if (!res?.ok) throw new Error(res?.error || t('读取已召唤专家失败'));
2539
2649
  setPresets(res.presets || []);
2540
2650
  setSkills(res.skills || []);
2541
2651
  } catch (e) {
@@ -2548,11 +2658,11 @@ const ExpertInstalledBoard = ({ call }) => {
2548
2658
 
2549
2659
  // 送还:与市场页同一写链;送还后刷新本页。
2550
2660
  const dismiss = async (plugin, name) => {
2551
- setBusy(true); setBusyLabel(`正在送还 ${name || plugin}…`); setError('');
2661
+ setBusy(true); setBusyLabel(t('正在送还 {0}…', [name || plugin])); setError('');
2552
2662
  try {
2553
2663
  const res = await call('wbExpertDismiss', { plugin });
2554
- if (!res?.ok) throw new Error(res?.error || '送还失败');
2555
- setNote(res.note || `${name || plugin} 已送还`);
2664
+ if (!res?.ok) throw new Error(res?.error || t('送还失败'));
2665
+ setNote(res.note || t('{0} 已送还', [name || plugin]));
2556
2666
  setDetail(null);
2557
2667
  await load();
2558
2668
  } catch (e) {
@@ -2566,7 +2676,7 @@ const ExpertInstalledBoard = ({ call }) => {
2566
2676
  setNote('');
2567
2677
  try {
2568
2678
  const res = await call('wbExpertDetail', { plugin });
2569
- if (!res?.ok) throw new Error(res?.error || '读取详情失败');
2679
+ if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
2570
2680
  setDetail({ ...res, loading: false });
2571
2681
  } catch (e) {
2572
2682
  setDetail({ plugin, loading: false, detailError: String(e?.message || e) });
@@ -2580,12 +2690,12 @@ const ExpertInstalledBoard = ({ call }) => {
2580
2690
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
2581
2691
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
2582
2692
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
2583
- loading ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取已召唤专家…') : null,
2693
+ loading ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取已召唤专家…')) : null,
2584
2694
  !loading && !presets.length && !skills.length
2585
2695
  ? h('div', { className: 'dsh-mcp-wb-empty' },
2586
- '还没有召唤任何专家。到 ',
2587
- h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, '市场 → 专家'),
2588
- ' 挑选一位召唤;召唤后它会出现在这里,并作为独立 Agent 预设出现在新会话的预设选择器里。')
2696
+ t('还没有召唤任何专家。到 '),
2697
+ h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, t('市场 → 专家')),
2698
+ t(' 挑选一位召唤;召唤后它会出现在这里,并作为独立 Agent 预设出现在新会话的预设选择器里。'))
2589
2699
  : null,
2590
2700
  !loading && presets.length
2591
2701
  ? h('div', { className: 'dsh-mcp-wb-grid' },
@@ -2597,13 +2707,13 @@ const ExpertInstalledBoard = ({ call }) => {
2597
2707
  h('div', { className: 'dsh-mcp-card-name' }, row.plugin),
2598
2708
  h('div', { className: 'dsh-mcp-card-vendor' }, row.presetId),
2599
2709
  ),
2600
- h(Badge, { cls: 's-on' }, '已召唤'),
2710
+ h(Badge, { cls: 's-on' }, t('已召唤')),
2601
2711
  ),
2602
2712
  h('div', { className: 'dsh-mcp-card-summary' },
2603
- boundSkills.length ? `捆绑技能:${boundSkills.join('、')}` : '无捆绑技能(预设本体在 profile 补丁)'),
2713
+ boundSkills.length ? t('捆绑技能:{0}', [boundSkills.join(t('、'))]) : t('无捆绑技能(预设本体在 profile 补丁)')),
2604
2714
  h('div', { className: 'dsh-mcp-wb-card-actions' },
2605
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(row.plugin) }, '详情'),
2606
- 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('送还')),
2607
2717
  ),
2608
2718
  );
2609
2719
  }),
@@ -2612,10 +2722,10 @@ const ExpertInstalledBoard = ({ call }) => {
2612
2722
  // 详情弹层:与市场页共用结构(只读 + 送还)。
2613
2723
  detail
2614
2724
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2615
- 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); } } },
2616
- 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),
2617
2727
  detail.loading
2618
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '正在拉取详情…')
2728
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取详情…'))
2619
2729
  : h('div', null,
2620
2730
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
2621
2731
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
@@ -2625,16 +2735,16 @@ const ExpertInstalledBoard = ({ call }) => {
2625
2735
  : null,
2626
2736
  detail.persona
2627
2737
  ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2628
- h('b', null, `人设正文(${detail.personaChars || detail.persona.length} 字符)`),
2629
- 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),
2630
2740
  )
2631
2741
  : null,
2632
2742
  h('p', { className: 'dsh-mcp-wb-modal-note' },
2633
- '该专家以独立 Agent 预设生效:新建会话时在预设选择器选择「WorkBuddy 专家 · …」即专家接管。送还会同时清掉它的捆绑技能。'),
2743
+ t('该专家以独立 Agent 预设生效:新建会话时在预设选择器选择「WorkBuddy 专家 · …」即专家接管。送还会同时清掉它的捆绑技能。')),
2634
2744
  ),
2635
2745
  h('div', { className: 'dsh-mcp-modal-actions' },
2636
- h(Btn, { onClick: () => setDetail(null), disabled: busy, children: '关闭' }),
2637
- 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('送还') }),
2638
2748
  ),
2639
2749
  ),
2640
2750
  )
@@ -2655,14 +2765,17 @@ const ExpertMarket = ({ call }) => {
2655
2765
  const [category, setCategory] = useState('all');
2656
2766
  const [page, setPage] = useState(1);
2657
2767
  const [detail, setDetail] = useState(null);
2658
- 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]);
2659
2772
  const [withSkills, setWithSkills] = useState(true);
2660
2773
  const [installedSkillPlugins, setInstalledSkillPlugins] = useState(new Set());
2661
2774
 
2662
2775
  const loadInstalled = useCallback(async () => {
2663
2776
  const res = await call('wbExpertInstalled');
2664
2777
  if (res?.ok) {
2665
- setPresets(new Set((res.presets || []).map((row) => row.plugin)));
2778
+ setPresetRows(res.presets || []);
2666
2779
  setInstalledSkillPlugins(new Set((res.skills || []).map((row) => row.plugin)));
2667
2780
  }
2668
2781
  }, [call]);
@@ -2688,18 +2801,31 @@ const ExpertMarket = ({ call }) => {
2688
2801
  setNote('');
2689
2802
  try {
2690
2803
  const res = await call('wbExpertDetail', { plugin: expert.plugin });
2691
- if (!res?.ok) throw new Error(res?.error || '读取详情失败');
2804
+ if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
2692
2805
  setDetail({ ...expert, ...res, loading: false });
2693
2806
  } catch (e) {
2694
2807
  setDetail({ ...expert, loading: false, detailError: String(e?.message || e) });
2695
2808
  }
2696
2809
  };
2697
2810
  const summon = async (expert) => {
2698
- 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('');
2699
2825
  try {
2700
- const res = await call('wbExpertSummon', { plugin: expert.plugin, withSkills });
2701
- if (!res?.ok) throw new Error(res?.error || '召唤失败');
2702
- 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]));
2703
2829
  setDetail(null);
2704
2830
  await loadInstalled();
2705
2831
  } catch (e) {
@@ -2709,11 +2835,11 @@ const ExpertMarket = ({ call }) => {
2709
2835
  }
2710
2836
  };
2711
2837
  const dismiss = async (expert) => {
2712
- setBusy(true); setBusyLabel(`正在送还 ${expert.name}…`); setError('');
2838
+ setBusy(true); setBusyLabel(t('正在送还 {0}…', [expert.name])); setError('');
2713
2839
  try {
2714
2840
  const res = await call('wbExpertDismiss', { plugin: expert.plugin });
2715
- if (!res?.ok) throw new Error(res?.error || '送还失败');
2716
- setNote(res.note || `${expert.name} 已送还`);
2841
+ if (!res?.ok) throw new Error(res?.error || t('送还失败'));
2842
+ setNote(res.note || t('{0} 已送还', [expert.name]));
2717
2843
  setDetail(null);
2718
2844
  await loadInstalled();
2719
2845
  } catch (e) {
@@ -2739,48 +2865,72 @@ const ExpertMarket = ({ call }) => {
2739
2865
  return h('div', null,
2740
2866
  h('div', { className: 'dsh-mcp-wb-toolbar' },
2741
2867
  h(WbSearchBox, {
2742
- value: query, placeholder: '搜索专家(花名 / 职业 / 描述 / 标签)', disabled: loading,
2868
+ value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), disabled: loading,
2743
2869
  onChange: (value) => { setQuery(value); setPage(1); },
2744
2870
  }),
2745
2871
  h('select', {
2746
2872
  className: 'dsh-mcp-input dsh-mcp-wb-sort', value: searching ? 'all' : category, disabled: loading || searching,
2747
- title: searching ? '搜索时自动在全库匹配(忽略分类)' : undefined,
2873
+ title: searching ? t('搜索时自动在全库匹配(忽略分类)') : undefined,
2748
2874
  onChange: (e) => { setCategory(e.target.value); setPage(1); },
2749
- }, h('option', { key: 'all', value: 'all' }, '全部专家(' + experts.length + ')'),
2750
- 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])))),
2751
2877
  h('span', { className: 'dsh-mcp-builtin-count' },
2752
- `显示 ${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(' · 缓存') : '')),
2753
2879
  ),
2754
2880
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
2755
2881
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
2756
2882
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
2757
- loading && !experts.length ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取专家目录…') : null,
2758
- !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,
2759
2885
  visible.length
2760
2886
  ? h('div', { className: 'dsh-mcp-wb-grid' },
2761
2887
  visible.map((item) => {
2762
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;
2763
2896
  return h('div', { key: item.plugin, className: 'dsh-mcp-card wb' + (summoned ? ' installed' : '') },
2764
2897
  h('div', { className: 'dsh-mcp-card-top' },
2765
2898
  h('div', null,
2766
- 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])),
2767
2900
  h('div', { className: 'dsh-mcp-card-vendor' }, item.categoryName || item.categoryId),
2768
2901
  ),
2769
- 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,
2770
2913
  ),
2771
2914
  h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.profession),
2772
2915
  h('div', { className: 'dsh-mcp-card-tags' },
2773
- h(Badge, null, item.expertType === 'team' ? '专家团' : '专家'),
2916
+ h(Badge, null, item.expertType === 'team' ? t('专家团') : t('专家')),
2774
2917
  item.tags.map((tag) => h(Badge, { key: tag }, tag)),
2775
- installedSkillPlugins.has(item.plugin) ? h(Badge, null, '含技能') : null,
2918
+ installedSkillPlugins.has(item.plugin) ? h(Badge, null, t('含技能')) : null,
2776
2919
  ),
2777
2920
  h('div', { className: 'dsh-mcp-wb-card-actions' },
2778
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(item) }, '详情'),
2779
- item.expertType === 'team'
2780
- ? h('span', { className: 'dsh-mcp-wb-blocked' }, '团队型暂只读')
2781
- : summoned
2782
- ? h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => dismiss(item) }, '送还')
2783
- : 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,
2784
2934
  ),
2785
2935
  );
2786
2936
  }),
@@ -2788,60 +2938,97 @@ const ExpertMarket = ({ call }) => {
2788
2938
  : null,
2789
2939
  pageCount > 1
2790
2940
  ? h('div', { className: 'dsh-mcp-wb-pager' },
2791
- 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('上一页')),
2792
2942
  h('span', { className: 'dsh-mcp-builtin-count' }, `${safePage} / ${pageCount}`),
2793
- 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('下一页')),
2794
2944
  )
2795
2945
  : null,
2796
2946
  // ── 专家详情弹层:召唤前看人设原文 + 捆绑技能 ──
2797
2947
  detail
2798
- ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2799
- 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); } } },
2800
- 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])),
2801
2958
  h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
2802
2959
  detail.loading
2803
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '正在拉取人设与技能清单…')
2960
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取人设与技能清单…'))
2804
2961
  : h('div', null,
2805
2962
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
2806
2963
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
2807
2964
  detail.quickPrompts && detail.quickPrompts.length
2808
2965
  ? h('div', { className: 'dsh-mcp-wb-examples' },
2809
- h('b', null, '试试这样问'),
2966
+ h('b', null, t('试试这样问')),
2810
2967
  h('ul', null, detail.quickPrompts.slice(0, 3).map((prompt, index) => h('li', { key: index }, prompt))),
2811
2968
  )
2812
2969
  : null,
2813
2970
  Array.isArray(detail.skills)
2814
2971
  ? h('div', { className: 'dsh-mcp-card-tags' },
2815
2972
  detail.skills.length
2816
- ? detail.skills.map((skill) => h(Badge, { key: skill.name, cls: skill.local ? 's-on' : '' }, `${skill.name}${skill.local ? '(本机可完整安装)' : '(仅 SKILL.md)'}`))
2817
- : 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('无捆绑技能')),
2818
2975
  )
2819
2976
  : null,
2820
2977
  detail.persona
2821
2978
  ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2822
- h('b', null, `人设正文(${detail.personaChars || detail.persona.length} 字符,召唤后成为会话系统提示)`),
2823
- 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),
2824
2981
  )
2825
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,
2826
3002
  detail.expertType !== 'team'
2827
3003
  ? h('label', { className: 'dsh-mcp-check' },
2828
3004
  h('input', { type: 'checkbox', checked: withSkills, disabled: busy, onChange: (e) => setWithSkills(e.target.checked) }),
2829
- '一并安装捆绑技能(本机 WorkBuddy 缓存完整安装;无缓存时仅装 SKILL.md)')
3005
+ t('一并安装捆绑技能(本机 WorkBuddy 缓存完整安装;无缓存时仅装 SKILL.md)'))
2830
3006
  : null,
2831
3007
  h('p', { className: 'dsh-mcp-wb-modal-note' },
2832
- '召唤 = 往 profile 补丁写入一条独立 Agent 预设(人设全程接管该预设的会话,标准工具原样保留);' +
2833
- '新建会话时选择「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,
2834
3016
  ),
2835
3017
  h('div', { className: 'dsh-mcp-modal-actions' },
2836
- h(Btn, { onClick: () => setDetail(null), disabled: busy, children: '关闭' }),
2837
- presets.has(detail.plugin)
2838
- ? h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail), children: '送还' })
2839
- : detail.expertType === 'team'
2840
- ? h('span', { className: 'dsh-mcp-wb-blocked' }, '团队型暂只读')
2841
- : 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,
2842
3028
  ),
2843
3029
  ),
2844
- )
3030
+ );
3031
+ })()
2845
3032
  : null,
2846
3033
  );
2847
3034
  };
@@ -2850,30 +3037,63 @@ const ExpertMarket = ({ call }) => {
2850
3037
  // 会话输入框工具行右侧(conversation.input.right)的一个小图标:点击弹三分类菜单
2851
3038
  // (技能 / MCP / 专家),悬停分类展开「最近使用 / 新装」子菜单,点击条目 =
2852
3039
  // 把一条引用写进输入框草稿(技能 /name slash、MCP 点名工具前缀),并上报最近使用。
2853
- // 专家条目特殊:点击 = 调宿主 agentPresets.select(sessionId, presetId) 把专家预设
2854
- // **挂到当前会话**(blank 会话立即生效=该会话全局专家接管),输入框下方出现专家
2855
- // 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 : '') || '';
2856
3060
  const QUICK_MENU_KINDS = [
2857
- { key: 'skill', label: '技能', icon: 'skill', hint: '技能按需触发:选择后以 /技能名 插入输入框' },
2858
- { key: 'mcp', label: 'MCP', icon: 'plug', hint: 'MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具' },
2859
- { 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 的 × 取消') },
2860
3064
  ];
2861
- const QUICK_RECENT_LIMIT = 6;
3065
+ // 子菜单列表每屏固定 6 行(6 × 28px),更多条目在 .dsh-mcp-quick-list 里纵向滚动;
3066
+ // 「最近」徽章只标最近池里的前 6 名(首屏可见的那批),避免整列都是徽章。
3067
+ // 专家预设显示名前缀:召唤时写死的 `WorkBuddy 专家 · <显示名>`(见 expert-market.js
3068
+ // buildExpertPresetRow),宿主的会话头预设标签原样显示它——用来识别「那条会过期的标签」。
3069
+ const EXPERT_PRESET_LABEL_PREFIX = t('WorkBuddy 专家 · ');
3070
+ // 专家挂载状态的最小事件总线:快速菜单挂/换/摘专家后立刻通知会话头徽章与菜单 chip,
3071
+ // 三处显示同一帧内对齐(不再各自等重挂载)。低频轮询只作兜底,覆盖「别的入口挂的专家」。
3072
+ const expertChangeListeners = new Set();
3073
+ const emitExpertChange = () => {
3074
+ for (const listener of [...expertChangeListeners]) {
3075
+ try { listener(); } catch { /* 单个订阅者失败不影响其它订阅者 */ }
3076
+ }
3077
+ };
3078
+ const onExpertChange = (listener) => {
3079
+ expertChangeListeners.add(listener);
3080
+ return () => { expertChangeListeners.delete(listener); };
3081
+ };
2862
3082
  // 引用文本格式(对齐宿主真实语义,避免「同名空文档」):
2863
3083
  // skill → /name:slash token 命中技能源 lexicon(dsh-client-ui-skill 的 skill source,
2864
3084
  // trigger "/"),发送后走官方 skill gesture,模型真正调起技能——@name 会落进
2865
3085
  // 「@文件/会话」引用语义,技能名不是文件,模型读不到就报「文件不存在」。
2866
3086
  // mcp → 明确点名 mcp__<server>__ 工具前缀:MCP 工具没有 slash 语义,直接给模型
2867
3087
  // 可执行的指令比插一个会被当文件解析的 @token 更实在。
2868
- // expert → 预设说明句(专家靠新会话选预设生效,会话内只能提示性引用)。
3088
+ // expert → 不在这里出文本:专家条目点击 = expertAttach 直接挂到当前会话(onClick
3089
+ // 分支不走 pick),历史上那句「请以预设处理」是死代码——删除并留此说明。
2869
3090
  const quickMentionText = (kind, item) => {
2870
- if (kind === 'expert') return `请以「WorkBuddy 专家 · ${item.name}」预设处理接下来的对话`;
2871
3091
  if (kind === 'mcp') {
2872
3092
  const server = String(item.name || '');
2873
- return /^[\w-]+$/.test(server) ? `请使用 ${server} 的 MCP 工具(mcp__${server}__*)处理接下来的任务` : `${server}:`;
3093
+ return /^[\w-]+$/.test(server) ? t('请使用 {0} 的 MCP 工具(mcp__{1}__*)处理接下来的任务', [server, server]) : t('{0}:', [server]);
2874
3094
  }
2875
3095
  const token = String(item.name || '');
2876
- return /^[\w-]+$/.test(token) ? `/${token} ` : `${token}:`;
3096
+ return /^[\w-]+$/.test(token) ? `/${token} ` : t('{0}:', [token]);
2877
3097
  };
2878
3098
  const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2879
3099
  const [open, setOpen] = useState(false);
@@ -2890,7 +3110,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2890
3110
  const cancelClose = () => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } };
2891
3111
  // scheduleClose 在 JSX 前定义(rootHoverProps 用它);此处不重复声明。
2892
3112
 
2893
- // 菜单打开时拉一次各类清单(每类 ≤6)。三类并行取,任何一类失败不影响其它类。
3113
+ // 菜单打开时拉一次各类清单。清单取全量(不做 6 条截断),交给子菜单自己
3114
+ // 「最近使用置顶 + 每屏 6 行滚动」展示;三类并行取,任何一类失败不影响其它类。
2894
3115
  // MCP 行字段是 serverName(gateway list() 的行形状,与面板列表同一来源);禁用的不进菜单。
2895
3116
  const loadRecent = useCallback(async () => {
2896
3117
  setListError('');
@@ -2901,9 +3122,9 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2901
3122
  call('wbExpertInstalled'),
2902
3123
  ]);
2903
3124
  const kinds = recentRes?.ok && recentRes.kinds ? recentRes.kinds : {};
2904
- const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills.slice(0, QUICK_RECENT_LIMIT) : [];
2905
- const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName).slice(0, QUICK_RECENT_LIMIT) : [];
2906
- const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets.slice(0, QUICK_RECENT_LIMIT) : [];
3125
+ const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills : [];
3126
+ const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName) : [];
3127
+ const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets : [];
2907
3128
  setRecent({
2908
3129
  skill: skillRows.map((row) => ({ name: row.name, description: row.description || row.whenToUse || '' })),
2909
3130
  mcp: mcpRows.map((row) => ({ name: row.serverName, description: row.url || row.command || row.transport || '' })),
@@ -2987,8 +3208,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2987
3208
  // ③ 复制进剪贴板 + 按钮上闪现「已复制」(绝不跳走:跳页是上一版的行为缺陷)。
2988
3209
  const pick = async (kind, item, text) => {
2989
3210
  setOpen(false); setHoverKind(null);
2990
- // 上报最近使用(失败静默:纯增值数据)。
2991
- 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 };
2992
3213
  call('wbExpertRecordUse', { kind, row }).catch(() => {});
2993
3214
  const insertion = typeof text === 'string' ? (text.endsWith(' ') ? text : text + ' ') : String(text);
2994
3215
  if (inputActions && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
@@ -3012,8 +3233,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3012
3233
  } catch { /* 剪贴板不可用:静默 */ }
3013
3234
  };
3014
3235
 
3015
- // 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记),未命中最近记录的
3016
- // 排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
3236
+ // 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记,只标首屏 6 个),
3237
+ // 未命中最近记录的排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
3017
3238
  const recentListFor = (kind) => (kind === 'skill' ? recent.recentSkill : kind === 'mcp' ? recent.recentMcp : recent.recentExpert);
3018
3239
  const itemKeyOf = (kind, item) => String(kind === 'expert' ? (item.plugin || item.name || '') : (item.name || ''));
3019
3240
  const orderSubItems = (kind, items) => {
@@ -3027,7 +3248,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3027
3248
  return { item, index, rank: rank === undefined ? Number.MAX_SAFE_INTEGER : rank, usedAt: rank === undefined ? 0 : recentRows[rank].usedAt || 0 };
3028
3249
  })
3029
3250
  .sort((a, b) => a.rank - b.rank || (b.usedAt - a.usedAt) || a.index - b.index)
3030
- .map((entry) => ({ ...entry.item, __recentRank: entry.rank }));
3251
+ .map((entry) => ({ ...entry.item, __recentRank: entry.rank, __badge: entry.rank < 6 }));
3031
3252
  };
3032
3253
 
3033
3254
  const subItems = (kind) => {
@@ -3046,46 +3267,41 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3046
3267
  // - 不受 agent-preset/locked 限制 —— 会话已开过口也能挂、能换、能摘;
3047
3268
  // - 只影响这一个会话,不动工具目录、不写任何持久化配置;
3048
3269
  // - 反复点不同专家 = 直接替换(Host 先摘旧的再装新的),不再是一次性。
3049
- // 预设通道(agentPresets.select)作为 blank 会话的增强:它连工具目录一起接管,语义更
3050
- // 彻底;只在「该专家已被召唤 + 会话还是空白」时成功,失败就自动退回人设通道——
3051
- // 两条路都不通的情况不存在,所以「换专家」永远有出路。
3270
+ // 挂载只走人设通道(预设通道 agentPresets.select 会在会话投影里留下永不更新的
3271
+ // 专家名,造成「顶栏显示 A、实际生效 B」的分叉——见 SessionExpertBadge 注释)。
3052
3272
  // sessionId 来源:conversation.input.right 是 session 座位,slot 渲染时把 sessionId
3053
3273
  // 作为 prop 显式传入(apply() 注册处 inject: (sessionId) => ({ call, sessionId }));
3054
3274
  // 会话 summary 行本身不带 id 字段(id 只走 props),所以不能从 useSession 投影里取。
3055
- const MODULE = MODULE_CTX.ctx;
3056
- const [attached, setAttached] = useState(null); // { plugin, name, mode: 'persona'|'preset' }
3275
+ const [attached, setAttached] = useState(null); // { plugin, name, mode: 'persona' }
3057
3276
  const [attachBusy, setAttachBusy] = useState(false);
3058
3277
  const [attachNote, setAttachNote] = useState('');
3059
3278
  const sessionId = typeof sessionIdProp === 'string' && sessionIdProp !== '' ? sessionIdProp : null;
3060
- const expertPresetIdOf = (item) => 'wbexpert-' + String(item.plugin || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
3061
3279
  const attachExpert = async (item, { quiet = false } = {}) => {
3062
3280
  setHoverKind(null);
3063
- if (!sessionId) { setAttachNote('没有活动会话:请先进入一个会话再挂专家'); return; }
3064
- const presetId = expertPresetIdOf(item);
3281
+ if (!sessionId) { setAttachNote(t('没有活动会话:请先进入一个会话再挂专家')); return; }
3065
3282
  setAttachBusy(true); setAttachNote('');
3066
3283
  try {
3067
- // ① blank 会话 + 已召唤 → 先用预设通道(连工具目录一起接管,最彻底)。
3068
- const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
3069
- if (remote && typeof remote.select === 'function') {
3070
- const result = await remote.select(sessionId, presetId).catch(() => null);
3071
- if (result && result.ok) {
3072
- setAttached({ plugin: item.plugin, name: item.name || item.plugin, mode: 'preset' });
3073
- setAttachNote('✓ 已挂到当前会话:' + (item.name || item.plugin));
3074
- call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
3075
- return;
3076
- }
3077
- }
3078
- // ② 其余情况(会话已开始 / 未召唤 / 无 agentPresets)→ 会话级人设通道,必定可用。
3284
+ // 只走会话级人设通道(expertAttach):随时挂 / 换 / 摘,会话开过口也不锁。
3285
+ // 不再先试 agentPresets.select 预设通道——它成功时会把专家预设写进会话投影,
3286
+ // 会话顶栏从此固定显示那个专家,且会话一旦开过口 select 永久锁死,换专家
3287
+ // 只换人设不动投影 → 「顶栏显示 A、实际生效 B」的假象。人设通道两边一致。
3079
3288
  const res = await call('expertAttach', { sessionId, plugin: item.plugin });
3080
3289
  if (res && res.ok) {
3081
3290
  setAttached({ plugin: item.plugin, name: res.name || item.name || item.plugin, mode: 'persona' });
3082
- 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);
3083
3297
  call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
3298
+ // 立即广播:会话头徽章 + 陈旧预设标签的隐藏同步生效,三处显示同帧对齐。
3299
+ emitExpertChange();
3084
3300
  } else {
3085
- setAttachNote('挂载失败:' + ((res && res.error) || '未知错误'));
3301
+ setAttachNote(t('挂载失败:') + ((res && res.error) || t('未知错误')));
3086
3302
  }
3087
3303
  } catch (e) {
3088
- if (!quiet) setAttachNote('挂载异常:' + String((e && e.message) || e));
3304
+ if (!quiet) setAttachNote(t('挂载异常:') + String((e && e.message) || e));
3089
3305
  } finally {
3090
3306
  setAttachBusy(false);
3091
3307
  }
@@ -3094,36 +3310,39 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3094
3310
  if (!sessionId) return;
3095
3311
  setAttachBusy(true); setAttachNote('');
3096
3312
  try {
3097
- // 会话级人设一定清(不管当初走的是哪条路);预设通道只在当初走预设时回退默认。
3313
+ // 只清会话级人设。挂载只走人设通道(见 attachExpert),不存在「当初走了预设」
3314
+ // 的分支,也就无需回退默认预设——预设选择器保持用户自己的选择,不被插件触碰。
3098
3315
  const res = await call('expertDetach', { sessionId }).catch(() => null);
3099
- const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
3100
- if (attached?.mode === 'preset' && remote && typeof remote.list === 'function' && typeof remote.select === 'function') {
3101
- try {
3102
- const list = await remote.list();
3103
- const options = list && list.ok && Array.isArray(list.presets) ? list.presets : [];
3104
- const fallback = (options.find((row) => row.isDefault) || options[0] || {}).id;
3105
- if (fallback) await remote.select(sessionId, fallback);
3106
- } catch { /* 预设回退失败不影响「已摘掉」这一事实 */ }
3107
- }
3108
3316
  setAttached(null);
3109
- setAttachNote(res && res.ok && res.removed ? '✓ 已取消本会话的专家' : '本会话没有挂着专家');
3317
+ setAttachNote(res && res.ok && res.removed ? t('✓ 已取消本会话的专家') : t('本会话没有挂着专家'));
3318
+ // 摘掉也要广播:会话头徽章退场、被隐身的预设标签复原。
3319
+ emitExpertChange();
3110
3320
  } catch (e) {
3111
- setAttachNote('取消失败:' + String((e && e.message) || e));
3321
+ setAttachNote(t('取消失败:') + String((e && e.message) || e));
3112
3322
  } finally {
3113
3323
  setAttachBusy(false);
3114
3324
  }
3115
3325
  };
3116
3326
  // 会话切换 / 组件重挂时对齐真实状态:宿主是唯一真源(专家可能由别的入口挂上,
3117
- // 或会话被重启),不能只信组件自己的 state。
3327
+ // 或会话被重启),不能只信组件自己的 state。挂/换/摘后由总线立即触发;
3328
+ // 5s 低频轮询兜底外部入口的变更(代价极小:expertActive 只读内存表)。
3118
3329
  useEffect(() => {
3119
3330
  if (!sessionId) return undefined;
3120
3331
  let cancelled = false;
3121
- call('expertActive').then((res) => {
3122
- if (cancelled || !res || !res.ok || !Array.isArray(res.sessions)) return;
3123
- const mine = res.sessions.find((row) => row.sessionId === sessionId);
3124
- setAttached(mine ? { plugin: mine.plugin, name: mine.name, mode: 'persona' } : null);
3125
- }).catch(() => { /* 对齐失败保持现状:不影响使用 */ });
3126
- return () => { cancelled = true; };
3332
+ const sync = () => {
3333
+ call('expertActive').then((res) => {
3334
+ if (cancelled || !res || !res.ok || !Array.isArray(res.sessions)) return;
3335
+ const mine = res.sessions.find((row) => row.sessionId === sessionId);
3336
+ setAttached(mine ? { plugin: mine.plugin, name: mine.name, mode: 'persona' } : null);
3337
+ }).catch(() => { /* 对齐失败保持现状:不影响使用 */ });
3338
+ };
3339
+ sync();
3340
+ const off = onExpertChange(sync);
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(); };
3127
3346
  }, [sessionId, call]);
3128
3347
 
3129
3348
  // ── 悬停开合(整树统一管理)───────────────────────────────────────────
@@ -3141,14 +3360,14 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3141
3360
  // 挂载的专家:图标左侧的内联小 chip(与 trigger 同一行,不撑宽不换行)。
3142
3361
  // attachNote 不再渲染独立气泡——错误/提示走 trigger 的 title(悬停可见)。
3143
3362
  attached
3144
- ? h('span', { className: 'dsh-mcp-quick-chip', title: '本会话由该专家人设接管;点 × 摘掉切回默认预设' },
3363
+ ? h('span', { className: 'dsh-mcp-quick-chip', title: t('本会话由该专家人设接管;点 × 摘掉(不动预设选择器)') },
3145
3364
  h(Icon, { d: ICONS.expert, size: 11 }),
3146
3365
  h('span', { className: 'dsh-mcp-quick-chip-name' }, attached.name),
3147
3366
  h('button', {
3148
3367
  type: 'button',
3149
3368
  className: 'dsh-mcp-quick-chip-x',
3150
- title: '摘掉专家(切回默认预设)',
3151
- 'aria-label': '摘掉专家',
3369
+ title: t('摘掉专家(切回默认预设)'),
3370
+ 'aria-label': t('摘掉专家'),
3152
3371
  disabled: attachBusy,
3153
3372
  onClick: (e) => { e.stopPropagation(); detachExpert(); },
3154
3373
  }, h(Icon, { d: ICONS.x, size: 10 })),
@@ -3157,18 +3376,18 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3157
3376
  h('button', {
3158
3377
  type: 'button',
3159
3378
  className: 'dsh-mcp-quick-trigger' + (open ? ' sel' : ''),
3160
- title: (attachNote ? attachNote + (attached ? ' ' : '') : '') + (attached ? '已挂专家:' + attached.name + '(本会话全局生效) ' : '') + '技能 / MCP / 专家 快速调用',
3161
- 'aria-label': '打开技能、MCP、专家快速调用菜单',
3379
+ title: (attachNote ? attachNote + (attached ? t(' ') : '') : '') + (attached ? t('已挂专家:') + attached.name + t('(本会话全局生效) ') : '') + t('技能 / MCP / 专家 快速调用'),
3380
+ 'aria-label': t('打开技能、MCP、专家快速调用菜单'),
3162
3381
  'aria-expanded': open,
3163
3382
  onClick: (e) => { e.stopPropagation(); setOpen((value) => !value); setHoverKind(null); },
3164
3383
  }, h(Icon, { d: ICONS.board, size: 15 })),
3165
3384
  open
3166
- ? h('div', { className: 'dsh-mcp-quick-menu', role: 'menu', 'aria-label': '快速调用', ref: menuRef, style: menuPos },
3167
- copied ? h('div', { className: 'dsh-mcp-quick-note' }, '已复制,粘贴到输入框:' + copied) : null,
3168
- 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,
3169
3388
  attachNote ? h('div', { className: 'dsh-mcp-quick-note' + (/^✓/.test(attachNote) ? ' ok' : ' err'), role: 'alert' }, attachNote) : null,
3170
3389
  listError ? h('div', { className: 'dsh-mcp-quick-err' }, listError) : null,
3171
- !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, '正在读取…') : null,
3390
+ !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, t('正在读取…')) : null,
3172
3391
  QUICK_MENU_KINDS.map((kind) => {
3173
3392
  const items = subItems(kind.key);
3174
3393
  const expanded = hoverKind === kind.key;
@@ -3197,33 +3416,35 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3197
3416
  h('span', { className: 'dsh-mcp-quick-arrow', 'aria-hidden': true }, '›'),
3198
3417
  ),
3199
3418
  hoverKind === kind.key
3200
- ? 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('可选项') },
3201
3420
  Array.isArray(items) && items.length
3202
- ? items.map((item, index) => h('button', {
3203
- key: item.name || item.plugin || index,
3204
- type: 'button',
3205
- role: 'menuitem',
3206
- className: 'dsh-mcp-quick-item',
3207
- title: kind.key === 'expert' ? '挂到当前会话(可随时换或取消);再点别的专家 = 直接替换' : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
3208
- onClick: (e) => { e.stopPropagation(); if (kind.key === 'expert') attachExpert(item); else pick(kind.key, item, quickMentionText(kind.key, item)); },
3209
- },
3210
- kind.key === 'expert' && attached && attached.plugin === item.plugin
3211
- ? h(Badge, { cls: 's-on' }, '已挂')
3212
- : typeof item.__recentRank === 'number' && item.__recentRank !== Number.MAX_SAFE_INTEGER
3213
- ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': '最近使用' }, '最近')
3214
- : null,
3215
- h('span', { className: 'dsh-mcp-quick-item-name' }, itemLabel(kind.key, item)),
3216
- itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc' }, itemDesc(kind.key, item)) : null,
3217
- ))
3218
- : h('div', { className: 'dsh-mcp-quick-sub-note' }, '暂无可选项'),
3421
+ ? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + t('列表(最多显示 6 行,滚动查看更多)') },
3422
+ items.map((item, index) => h('button', {
3423
+ key: item.name || item.plugin || index,
3424
+ type: 'button',
3425
+ role: 'menuitem',
3426
+ className: 'dsh-mcp-quick-item',
3427
+ title: kind.key === 'expert' ? t('挂到当前会话(可随时换或取消);再点别的专家 = 直接替换') : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
3428
+ onClick: (e) => { e.stopPropagation(); if (kind.key === 'expert') attachExpert(item); else pick(kind.key, item, quickMentionText(kind.key, item)); },
3429
+ },
3430
+ kind.key === 'expert' && attached && attached.plugin === item.plugin
3431
+ ? h(Badge, { cls: 's-on' }, t('已挂'))
3432
+ : item.__badge
3433
+ ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': t('最近使用') }, t('最近'))
3434
+ : null,
3435
+ h('span', { className: 'dsh-mcp-quick-item-name' }, itemLabel(kind.key, item)),
3436
+ itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc' }, itemDesc(kind.key, item)) : null,
3437
+ )),
3438
+ )
3439
+ : h('div', { className: 'dsh-mcp-quick-sub-note' }, t('暂无可选项')),
3219
3440
  h('button', {
3220
3441
  type: 'button',
3221
3442
  role: 'menuitem',
3222
3443
  className: 'dsh-mcp-quick-manage',
3223
- title: '打开看板的「' + kind.label + '」分类管理',
3444
+ title: t('打开看板的「') + kind.label + t('」分类管理'),
3224
3445
  onClick: (e) => { e.stopPropagation(); openBoardCategory(kind.key === 'expert' ? 'expert' : kind.key); },
3225
3446
  },
3226
- h('span', { className: 'dsh-mcp-quick-item-name' }, '管理' + kind.label + '…'),
3447
+ h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理') + kind.label + '…'),
3227
3448
  ),
3228
3449
  )
3229
3450
  : null,
@@ -3233,6 +3454,119 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3233
3454
  : null,
3234
3455
  );
3235
3456
  };
3457
+
3458
+ // ── 会话头「当前生效专家」徽章(conversation.session.header.actions)──────
3459
+ // 宿主的 AgentPresetLabel 只读会话的 agentPreset 投影(会话开过口后不再变化),
3460
+ // 与本插件「随时换专家」的人设通道天然脱节:换专家后顶栏仍显示第一次的预设名。
3461
+ // 这个 slot 条目显示的是真正生效中的专家(expertActive 对账),与输入框 chip 同源
3462
+ // 同步——哪个专家在管这个会话,顶栏一眼可见;没挂专家时不渲染任何东西。
3463
+ // 样式对齐宿主 AgentPresetLabel(同高 22px、同 token),与预设标签并排不突兀;
3464
+ // 宿主标签在 540px 以下隐藏,本徽章跟随同一断点(container query 无法跨包复用,
3465
+ // 用媒体查询近似窄窗行为)。
3466
+ const SessionExpertBadge = ({ call, sessionId }) => {
3467
+ const [attached, setAttached] = useState(null);
3468
+ const [ready, setReady] = useState(false);
3469
+ const badgeRef = useRef(null);
3470
+ // 同步真源:宿主 expertActive 是唯一真源(别的入口挂的专家也要认)。挂/换/摘之后
3471
+ // 由总线立即触发一次,5s 低频轮询只兜底外部变化;组件重挂载也走同一条路径。
3472
+ useEffect(() => {
3473
+ if (!sessionId) { setAttached(null); setReady(true); return undefined; }
3474
+ let cancelled = false;
3475
+ const sync = () => {
3476
+ call('expertActive').then((res) => {
3477
+ if (cancelled) return;
3478
+ const mine = res && res.ok && Array.isArray(res.sessions)
3479
+ ? res.sessions.find((row) => row.sessionId === sessionId)
3480
+ : null;
3481
+ setAttached(mine ? { plugin: mine.plugin, name: mine.name } : null);
3482
+ setReady(true);
3483
+ }).catch(() => { if (!cancelled) setReady(true); });
3484
+ };
3485
+ setReady(false);
3486
+ sync();
3487
+ const off = onExpertChange(sync);
3488
+ // OPT-05:同上,徽章这一处也改走可见性感知轮询。
3489
+ const stopPoll = startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, null, 5000, sync);
3490
+ return () => { cancelled = true; off(); stopPoll(); };
3491
+ }, [sessionId, call]);
3492
+ // 挂上专家时,把同容器里那条**陈旧的预设标签**藏掉:它读的是会话创建时的
3493
+ // agentPreset 投影(宿主只读、换专家不动它),所以换到第二个专家后它仍显示第一个,
3494
+ // 与真正生效的人设相反——三处显示里唯一会「说谎」的一处。只藏带本插件专家预设
3495
+ // 前缀(WorkBuddy 专家)的那条,默认预设标签与其他插件条目一律不碰;摘掉专家后
3496
+ // 立即恢复(原生 <span> 一直被 React 管着,我们只临时加 class,不移动/不删除节点)。
3497
+ // 对账幂等:每轮把「现在不该藏」的还原回显;观察 class 变化是为了宿主重渲染覆盖
3498
+ // className 后能补回(React 只在 props 变化时才写,稳态不会抖)。
3499
+ useEffect(() => {
3500
+ if (!attached || typeof document === 'undefined') return undefined;
3501
+ let stop = null;
3502
+ const isStalePreset = (node) => String(node.textContent || '').trim().startsWith(EXPERT_PRESET_LABEL_PREFIX);
3503
+ // 作用域 = 能同时容纳「本徽章」与「宿主播条目」的最近祖先(正常就是 headerActions
3504
+ // 容器);若父层只是个包装且里面没有目标条目,再上探一层(最多 3 层,防止飘到整页
3505
+ // 把看板/菜单里同名前缀的文本一起误藏)。
3506
+ // 体量保险丝:正常 headerActions 容器只有几个 span;候选容器一旦大到此量级,
3507
+ // 说明作用域已经飘到会话体(几百条 tool card),必须直接放弃对账——否则
3508
+ // subtree 监听会把整个会话纳入观察,流式输出时每帧都要跑一遍全量对账。
3509
+ const tooBig = (node) => !node || node.querySelectorAll('span').length > 80;
3510
+ const resolveScope = () => {
3511
+ const own = badgeRef.current;
3512
+ if (!own) return null;
3513
+ let node = own.parentElement;
3514
+ for (let i = 0; i < 3 && node; i++) {
3515
+ if (tooBig(node)) return null;
3516
+ const others = [...node.querySelectorAll('span')].filter((s) => s !== own && !s.contains(own));
3517
+ if (others.some(isStalePreset)) return node;
3518
+ node = node.parentElement;
3519
+ }
3520
+ const fallback = own.parentElement;
3521
+ return tooBig(fallback) ? null : fallback;
3522
+ };
3523
+ const reconcile = () => {
3524
+ const scope = resolveScope();
3525
+ const own = badgeRef.current;
3526
+ if (!scope || !own) return;
3527
+ for (const node of scope.querySelectorAll('span')) {
3528
+ const keep = node === own || node.contains(own) || !isStalePreset(node);
3529
+ if (keep) node.classList.remove('dsh-mcp-hide-stale-preset');
3530
+ else node.classList.add('dsh-mcp-hide-stale-preset');
3531
+ }
3532
+ };
3533
+ // 回调改为排队执行:绝不与 React 的提交同步拉锯。
3534
+ let scheduled = false;
3535
+ const schedule = () => {
3536
+ if (scheduled) return;
3537
+ scheduled = true;
3538
+ queueMicrotask(() => { scheduled = false; reconcile(); });
3539
+ };
3540
+ const raf = requestAnimationFrame(() => {
3541
+ reconcile();
3542
+ const scope = resolveScope();
3543
+ if (!scope) return;
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 });
3550
+ });
3551
+ return () => {
3552
+ cancelAnimationFrame(raf);
3553
+ stop?.disconnect();
3554
+ // 摘掉专家 / 组件卸载:全部复原(只摘我们加的那个 class)。
3555
+ for (const node of document.querySelectorAll('.dsh-mcp-hide-stale-preset')) {
3556
+ node.classList.remove('dsh-mcp-hide-stale-preset');
3557
+ }
3558
+ };
3559
+ }, [attached]);
3560
+ if (!ready || !attached) return null;
3561
+ return h('span', {
3562
+ className: 'dsh-mcp-head-expert',
3563
+ ref: badgeRef,
3564
+ title: t('当前生效专家(人设接管本会话;可在输入框工具行的专家菜单随时换/摘)'),
3565
+ },
3566
+ h(Icon, { d: ICONS.expert, size: 13, className: 'dsh-mcp-head-expert-icon' }),
3567
+ h('span', { className: 'dsh-mcp-head-expert-name' }, attached.name),
3568
+ );
3569
+ };
3236
3570
  const wbFormatCount = (value) => {
3237
3571
  const n = Number(value) || 0;
3238
3572
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + 'M';
@@ -3243,6 +3577,7 @@ const wbFormatDate = (ms) => {
3243
3577
  if (!ms) return '';
3244
3578
  try { return new Date(Number(ms)).toISOString().slice(0, 10); } catch { return ''; }
3245
3579
  };
3580
+ // ── 市场面板:WorkBuddy 连接器 / 内置 MCP / SkillHub 技能三个子标签 ──────────────
3246
3581
  // 市场搜索框:左内置放大镜、圆角胶囊、聚焦高亮、右一键清空。
3247
3582
  // 三处复用(连接器 / 内置 MCP / 技能):连接器与内置是纯本地过滤,输入即过滤;
3248
3583
  // 技能走服务端搜索,onCommit 由防抖或回车触发(见 WorkBuddyMarket 内注释)。
@@ -3259,8 +3594,8 @@ const WbSearchBox = ({ value, onChange, placeholder, disabled, onCommit }) =>
3259
3594
  }),
3260
3595
  value && !disabled
3261
3596
  ? h('button', {
3262
- type: 'button', className: 'dsh-mcp-wb-search-clear', title: '清空',
3263
- 'aria-label': '清空搜索',
3597
+ type: 'button', className: 'dsh-mcp-wb-search-clear', title: t('清空'),
3598
+ 'aria-label': t('清空搜索'),
3264
3599
  onClick: () => { onChange(''); if (onCommit) onCommit(); },
3265
3600
  }, h(Icon, { d: ICONS.x, size: 12 }))
3266
3601
  : null,
@@ -3300,7 +3635,7 @@ const WorkBuddyMarket = ({ call }) => {
3300
3635
  setLoading(true); setError(''); setNote('');
3301
3636
  try {
3302
3637
  const res = await call('wbConnectorCatalog');
3303
- if (!res?.ok) throw new Error(res?.error || '读取连接器目录失败');
3638
+ if (!res?.ok) throw new Error(res?.error || t('读取连接器目录失败'));
3304
3639
  setConnectors(res.connectors || []);
3305
3640
  setConnectorSource(res.loadedFrom || '');
3306
3641
  } catch (e) {
@@ -3314,7 +3649,7 @@ const WorkBuddyMarket = ({ call }) => {
3314
3649
  setLoading(true); setError(''); setNote('');
3315
3650
  try {
3316
3651
  const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
3317
- if (!res?.ok) throw new Error(res?.error || '读取技能目录失败');
3652
+ if (!res?.ok) throw new Error(res?.error || t('读取技能目录失败'));
3318
3653
  setSkills(res.skills || []);
3319
3654
  setSkillTotal(res.total || 0);
3320
3655
  } catch (e) {
@@ -3360,11 +3695,11 @@ const WorkBuddyMarket = ({ call }) => {
3360
3695
  setNote('');
3361
3696
  };
3362
3697
  const installConnector = async (item) => {
3363
- setBusy(true); setBusyLabel(`正在安装 ${item.name}…`); setError('');
3698
+ setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.name])); setError('');
3364
3699
  try {
3365
3700
  const res = await call('wbConnectorInstall', { connectorId: item.id, mode: 'merge' });
3366
- if (!res?.ok) throw new Error(res?.error || '安装失败');
3367
- 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 配置')]));
3368
3703
  setConnectorDetail(null);
3369
3704
  await loadConnectors();
3370
3705
  } catch (e) {
@@ -3374,11 +3709,11 @@ const WorkBuddyMarket = ({ call }) => {
3374
3709
  }
3375
3710
  };
3376
3711
  const installSkill = async (skill) => {
3377
- setBusy(true); setBusyLabel(`正在下载并校验 ${skill.name}…`); setError('');
3712
+ setBusy(true); setBusyLabel(t('正在下载并校验 {0}…', [skill.name])); setError('');
3378
3713
  try {
3379
3714
  const res = await call('wbSkillInstall', { slug: skill.slug, namespace: skill.namespace, name: skill.name });
3380
- if (!res?.ok) throw new Error(res?.error || '安装失败');
3381
- 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('已安装')]));
3382
3717
  setSkillDetail(null);
3383
3718
  await loadInstalled();
3384
3719
  } catch (e) {
@@ -3388,11 +3723,11 @@ const WorkBuddyMarket = ({ call }) => {
3388
3723
  }
3389
3724
  };
3390
3725
  const uninstallSkill = async (skill) => {
3391
- setBusy(true); setBusyLabel(`正在卸载 ${skill.name}…`); setError('');
3726
+ setBusy(true); setBusyLabel(t('正在卸载 {0}…', [skill.name])); setError('');
3392
3727
  try {
3393
3728
  const res = await call('wbSkillUninstall', { slug: skill.slug, namespace: skill.namespace });
3394
- if (!res?.ok) throw new Error(res?.error || '卸载失败');
3395
- 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('已卸载')]));
3396
3731
  setSkillDetail(null);
3397
3732
  await loadInstalled();
3398
3733
  } catch (e) {
@@ -3406,7 +3741,7 @@ const WorkBuddyMarket = ({ call }) => {
3406
3741
  setNote('');
3407
3742
  try {
3408
3743
  const res = await call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace });
3409
- if (!res?.ok) throw new Error(res?.error || '读取详情失败');
3744
+ if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
3410
3745
  setSkillDetail({ ...skill, ...res, loading: false });
3411
3746
  } catch (e) {
3412
3747
  setSkillDetail({ ...skill, loading: false, detailError: String(e?.message || e) });
@@ -3424,7 +3759,7 @@ const WorkBuddyMarket = ({ call }) => {
3424
3759
  setBuiltinsLoading(true); setBuiltinError('');
3425
3760
  try {
3426
3761
  const res = await call('builtins');
3427
- if (!res?.ok) throw new Error(res?.error || '读取 MCP 目录失败');
3762
+ if (!res?.ok) throw new Error(res?.error || t('读取 MCP 目录失败'));
3428
3763
  setBuiltins(res.builtins || []);
3429
3764
  } catch (e) {
3430
3765
  setBuiltinError(String(e?.message || e));
@@ -3457,11 +3792,11 @@ const WorkBuddyMarket = ({ call }) => {
3457
3792
  return [item.label, item.id, item.name, item.summary, item.vendor, item.access].filter(Boolean).join(' ').toLowerCase().includes(builtinQueryKey);
3458
3793
  });
3459
3794
  const installBuiltin = async (item) => {
3460
- setBusy(true); setBusyLabel(`正在安装 ${item.label}…`); setError('');
3795
+ setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.label])); setError('');
3461
3796
  try {
3462
3797
  const res = await call('installBuiltins', { ids: [item.id] });
3463
- if (!res?.ok) throw new Error(res?.error || '安装失败');
3464
- 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('已安装')]));
3465
3800
  await loadBuiltins();
3466
3801
  } catch (e) {
3467
3802
  setError(String(e?.message || e));
@@ -3471,11 +3806,11 @@ const WorkBuddyMarket = ({ call }) => {
3471
3806
  };
3472
3807
 
3473
3808
  const connectorSearch = h(WbSearchBox, {
3474
- value: connectorQuery, placeholder: '搜索连接器(名称 / 说明 / ID)', disabled: loading,
3809
+ value: connectorQuery, placeholder: t('搜索连接器(名称 / 说明 / ID)'), disabled: loading,
3475
3810
  onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
3476
3811
  });
3477
3812
  const skillSearch = h(WbSearchBox, {
3478
- value: skillKeywordInput, placeholder: '搜索 SkillHub 技能(19 万+)', disabled: loading,
3813
+ value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: loading,
3479
3814
  onChange: (value) => setSkillKeywordInput(value),
3480
3815
  // 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
3481
3816
  onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
@@ -3483,47 +3818,47 @@ const WorkBuddyMarket = ({ call }) => {
3483
3818
 
3484
3819
  const skillPageCount = Math.max(1, Math.ceil(skillTotal / WB_PAGE_SIZE));
3485
3820
  const skillMeta = skillTotal
3486
- ? `第 ${skillPage} / ${skillPageCount} 页 · 共 ${wbFormatCount(skillTotal)} 个技能`
3821
+ ? t('第 {0} / {1} 页 · 共 {2} 个技能', [skillPage, skillPageCount, wbFormatCount(skillTotal)])
3487
3822
  : '';
3488
3823
 
3489
3824
  return h('div', { className: 'dsh-mcp-wb' },
3490
- 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 市场') },
3491
3826
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'connector', className: 'dsh-mcp-wb-tab' + (tab === 'connector' ? ' sel' : ''), onClick: () => setTab('connector') },
3492
- '连接器(MCP)', h('span', { className: 'dsh-mcp-cat-count' }, connectors.length)),
3827
+ t('连接器(MCP)'), h('span', { className: 'dsh-mcp-cat-count' }, connectors.length)),
3493
3828
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'builtin', className: 'dsh-mcp-wb-tab' + (tab === 'builtin' ? ' sel' : ''), onClick: () => setTab('builtin') },
3494
- '内置 MCP', h('span', { className: 'dsh-mcp-cat-count' }, builtins.length)),
3829
+ t('内置 MCP'), h('span', { className: 'dsh-mcp-cat-count' }, builtins.length)),
3495
3830
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'skill', className: 'dsh-mcp-wb-tab' + (tab === 'skill' ? ' sel' : ''), onClick: () => setTab('skill') },
3496
- '技能(SkillHub)'),
3831
+ t('技能(SkillHub)')),
3497
3832
  h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'expert', className: 'dsh-mcp-wb-tab' + (tab === 'expert' ? ' sel' : ''), onClick: () => setTab('expert') },
3498
- '专家(召唤)'),
3833
+ t('专家(召唤)')),
3499
3834
  ),
3500
3835
  h('div', { className: 'dsh-mcp-wb-disclaim' },
3501
- '直连读取:连接器来自 WorkBuddy 官方分发(static.workbuddy.cn),技能来自 SkillHub 公开 API,专家来自 WorkBuddy 专家中心分发。本面板只是浏览与召唤入口,' +
3502
- '内容版权归原作者所有;第三方接口的字段可能随上游改版变化。'),
3836
+ t('直连读取:连接器来自 WorkBuddy 官方分发(static.workbuddy.cn),技能来自 SkillHub 公开 API,专家来自 WorkBuddy 专家中心分发。本面板只是浏览与召唤入口,') +
3837
+ t('内容版权归原作者所有;第三方接口的字段可能随上游改版变化。')),
3503
3838
  tab === 'connector'
3504
3839
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
3505
3840
  connectorSearch,
3506
3841
  h('label', { className: 'dsh-mcp-check' },
3507
3842
  h('input', { type: 'checkbox', checked: connectorOnlyReady, disabled: loading, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
3508
- '只看可直接安装'),
3843
+ t('只看可直接安装')),
3509
3844
  h('span', { className: 'dsh-mcp-builtin-count' },
3510
- `显示 ${visibleConnectors.length} / ${filteredConnectors.length}${connectorSource === 'network' ? ' · 已更新' : connectorSource === 'disk' ? ' · 缓存' : ''}`),
3845
+ t('显示 {0} / {1}', [visibleConnectors.length, filteredConnectors.length]) + (connectorSource === 'network' ? t(' · 已更新') : connectorSource === 'disk' ? t(' · 缓存') : '')),
3511
3846
  )
3512
3847
  : tab === 'builtin'
3513
3848
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
3514
3849
  h(WbSearchBox, {
3515
- value: builtinQuery, placeholder: '搜索服务、用途或提供方…',
3850
+ value: builtinQuery, placeholder: t('搜索服务、用途或提供方…'),
3516
3851
  onChange: (value) => setBuiltinQuery(value),
3517
3852
  }),
3518
3853
  h('label', { className: 'dsh-mcp-check' },
3519
3854
  h('input', { type: 'checkbox', checked: builtinOnlyReady, onChange: (e) => setBuiltinOnlyReady(e.target.checked) }),
3520
- '只要免密钥'),
3855
+ t('只要免密钥')),
3521
3856
  h('select', {
3522
3857
  className: 'dsh-mcp-input dsh-mcp-wb-sort', value: builtinCategory,
3523
3858
  onChange: (e) => setBuiltinCategory(e.target.value),
3524
- }, h('option', { key: 'all', value: 'all' }, '全部分类'),
3525
- builtinCategories.map((cat) => h('option', { key: cat.key, value: cat.key }, cat.label + '(' + cat.count + ')'))),
3526
- 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])),
3527
3862
  )
3528
3863
  : tab === 'expert'
3529
3864
  ? null
@@ -3538,45 +3873,45 @@ const WorkBuddyMarket = ({ call }) => {
3538
3873
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
3539
3874
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
3540
3875
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
3541
- loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取连接器目录…') : null,
3542
- loading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, '正在读取技能目录…') : null,
3543
- !loading && tab === 'connector' && !visibleConnectors.length ? h('div', { className: 'dsh-mcp-wb-empty' }, '没有匹配的连接器,换个关键词试试') : null,
3544
- !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,
3545
3880
  tab === 'connector' && visibleConnectors.length
3546
3881
  ? h('div', { className: 'dsh-mcp-wb-grid' },
3547
3882
  visibleConnectors.map((item) => h('div', { key: item.id, className: 'dsh-mcp-card wb' + (item.installable ? '' : ' off') },
3548
3883
  h('div', { className: 'dsh-mcp-card-top' },
3549
3884
  h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
3550
- h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? '可安装' : '仅展示'),
3885
+ h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
3551
3886
  ),
3552
3887
  h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.nameEn || item.id),
3553
3888
  h('div', { className: 'dsh-mcp-card-tags' },
3554
- item.skillCount ? h(Badge, null, `含 ${item.skillCount} 技能`) : null,
3889
+ item.skillCount ? h(Badge, null, t('含 {0} 技能', [item.skillCount])) : null,
3555
3890
  item.transport ? h(Badge, { cls: item.transport === 'streamable-http' ? 't-http' : 't-stdio' }, item.transport === 'streamable-http' ? 'HTTP' : 'stdio') : null,
3556
3891
  item.authMode ? h(Badge, null, item.authMode) : null,
3557
3892
  ),
3558
3893
  h('div', { className: 'dsh-mcp-wb-card-actions' },
3559
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, '详情'),
3894
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, t('详情')),
3560
3895
  item.installable
3561
- ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installConnector(item) }, '安装')
3562
- : 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 : ''),
3563
3898
  ),
3564
3899
  )),
3565
3900
  )
3566
3901
  : null,
3567
3902
  tab === 'connector' && connectorPageCount > 1
3568
3903
  ? h('div', { className: 'dsh-mcp-wb-pager' },
3569
- 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('上一页')),
3570
3905
  h('span', { className: 'dsh-mcp-builtin-count' }, `${safeConnectorPage} / ${connectorPageCount}`),
3571
- 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('下一页')),
3572
3907
  )
3573
3908
  : null,
3574
3909
  tab === 'builtin' && builtinError
3575
3910
  ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, builtinError, ' ',
3576
- 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('重试')))
3577
3912
  : null,
3578
3913
  tab === 'builtin' && !builtinError && !filteredBuiltins.length
3579
- ? h('div', { className: 'dsh-mcp-wb-empty' }, builtinsLoading ? '正在读取 MCP 目录…' : '没有匹配的服务,换个关键词或分类试试')
3914
+ ? h('div', { className: 'dsh-mcp-wb-empty' }, builtinsLoading ? t('正在读取 MCP 目录…') : t('没有匹配的服务,换个关键词或分类试试'))
3580
3915
  : null,
3581
3916
  tab === 'builtin' && filteredBuiltins.length
3582
3917
  ? h('div', { className: 'dsh-mcp-wb-grid' },
@@ -3588,7 +3923,7 @@ const WorkBuddyMarket = ({ call }) => {
3588
3923
  h('div', { className: 'dsh-mcp-card-name' }, item.label),
3589
3924
  item.vendor ? h('div', { className: 'dsh-mcp-card-vendor' }, item.vendor) : null,
3590
3925
  ),
3591
- item.installed ? h(Badge, { cls: 's-on' }, '已配置') : null,
3926
+ item.installed ? h(Badge, { cls: 's-on' }, t('已配置')) : null,
3592
3927
  ),
3593
3928
  h('div', { className: 'dsh-mcp-card-summary' }, item.summary),
3594
3929
  h('div', { className: 'dsh-mcp-card-tags' },
@@ -3597,25 +3932,25 @@ const WorkBuddyMarket = ({ call }) => {
3597
3932
  item.installed
3598
3933
  ? null
3599
3934
  : builtinNeedsKey(item)
3600
- ? h(Badge, null, '需密钥')
3935
+ ? h(Badge, null, t('需密钥'))
3601
3936
  : builtinNeedsConfig(item)
3602
- ? h(Badge, null, '需连接信息')
3603
- : h(Badge, { cls: 's-off' }, '免密钥'),
3604
- 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,
3605
3940
  ),
3606
3941
  h('div', { className: 'dsh-mcp-card-access' }, item.access),
3607
3942
  builtinNeedsKey(item) || builtinNeedsConfig(item)
3608
3943
  ? h('div', { className: 'dsh-mcp-card-vars' },
3609
- builtinNeedsKey(item) ? h('span', null, '密钥:' + item.envKeys.join('、')) : null,
3610
- 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,
3611
3946
  )
3612
3947
  : null,
3613
3948
  h('div', { className: 'dsh-mcp-wb-card-actions' },
3614
3949
  item.installed
3615
3950
  ? null
3616
- : 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('安装')),
3617
3952
  item.homepage
3618
- ? 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('文档'))
3619
3954
  : null,
3620
3955
  ),
3621
3956
  );
@@ -3632,22 +3967,22 @@ const WorkBuddyMarket = ({ call }) => {
3632
3967
  h('div', { className: 'dsh-mcp-card-name' }, skill.name),
3633
3968
  h('div', { className: 'dsh-mcp-card-vendor' }, skill.canonical || skill.namespace),
3634
3969
  ),
3635
- installed ? h(Badge, { cls: 's-on' }, '已安装') : null,
3970
+ installed ? h(Badge, { cls: 's-on' }, t('已安装')) : null,
3636
3971
  ),
3637
3972
  h('div', { className: 'dsh-mcp-card-summary' }, skill.description || skill.name),
3638
3973
  h('div', { className: 'dsh-mcp-card-tags' },
3639
3974
  h(Badge, null, `↓${wbFormatCount(skill.downloads)}`),
3640
- skill.installs ? h(Badge, null, `装 ${wbFormatCount(skill.installs)}`) : null,
3975
+ skill.installs ? h(Badge, null, t('装 {0}', [wbFormatCount(skill.installs)])) : null,
3641
3976
  skill.stars ? h(Badge, null, `★${wbFormatCount(skill.stars)}`) : null,
3642
- skill.verified ? h(Badge, { cls: 's-on' }, '认证') : null,
3977
+ skill.verified ? h(Badge, { cls: 's-on' }, t('认证')) : null,
3643
3978
  skill.version ? h(Badge, null, 'v' + skill.version) : null,
3644
3979
  wbFormatDate(skill.updatedAt) ? h(Badge, null, wbFormatDate(skill.updatedAt)) : null,
3645
3980
  ),
3646
3981
  h('div', { className: 'dsh-mcp-wb-card-actions' },
3647
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openSkillDetail(skill) }, '详情'),
3982
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openSkillDetail(skill) }, t('详情')),
3648
3983
  installed
3649
- ? h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => uninstallSkill(skill) }, '卸载')
3650
- : 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('安装')),
3651
3986
  ),
3652
3987
  );
3653
3988
  }),
@@ -3655,32 +3990,32 @@ const WorkBuddyMarket = ({ call }) => {
3655
3990
  : null,
3656
3991
  tab === 'skill' && skillPageCount > 1
3657
3992
  ? h('div', { className: 'dsh-mcp-wb-pager' },
3658
- 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('上一页')),
3659
3994
  h('span', { className: 'dsh-mcp-builtin-count' }, `${skillPage} / ${skillPageCount}`),
3660
- 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('下一页')),
3661
3996
  )
3662
3997
  : null,
3663
3998
  tab === 'expert' ? h(ExpertMarket, { call }) : null,
3664
3999
  // ── 连接器详情弹层:安装前看真实 mcp.json ──
3665
4000
  connectorDetail
3666
4001
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setConnectorDetail(null) },
3667
- 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); } } },
3668
4003
  h('h3', null, connectorDetail.name),
3669
4004
  connectorDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.description) : null,
3670
4005
  connectorDetail.examples && connectorDetail.examples.length
3671
4006
  ? h('div', { className: 'dsh-mcp-wb-examples' },
3672
- h('b', null, '用法示例'),
4007
+ h('b', null, t('用法示例')),
3673
4008
  h('ul', null, connectorDetail.examples.slice(0, 4).map((example, index) => h('li', { key: index }, example))),
3674
4009
  )
3675
4010
  : null,
3676
4011
  h('p', { className: 'dsh-mcp-wb-modal-note' },
3677
- `安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。` +
3678
- (connectorDetail.skillCount ? `另含 ${connectorDetail.skillCount} 个连接器技能,如需请从技能市场或手动安装。` : '')),
4012
+ t(`安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。`) +
4013
+ (connectorDetail.skillCount ? t('另含 {0} 个连接器技能,如需请从技能市场或手动安装。', [connectorDetail.skillCount]) : '')),
3679
4014
  h('div', { className: 'dsh-mcp-modal-actions' },
3680
- h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: '取消' }),
4015
+ h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: t('取消') }),
3681
4016
  connectorDetail.installable
3682
- ? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => installConnector(connectorDetail), children: busy ? '安装中…' : '安装到全局' })
3683
- : 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('不可安装')),
3684
4019
  ),
3685
4020
  ),
3686
4021
  )
@@ -3688,29 +4023,29 @@ const WorkBuddyMarket = ({ call }) => {
3688
4023
  // ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
3689
4024
  skillDetail
3690
4025
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setSkillDetail(null) },
3691
- 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); } } },
3692
4027
  h('h3', null, skillDetail.name),
3693
4028
  h('div', { className: 'dsh-mcp-card-vendor' }, skillDetail.canonical || skillDetail.namespace || ''),
3694
4029
  skillDetail.loading
3695
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, '正在读取详情…')
4030
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在读取详情…'))
3696
4031
  : h('div', null,
3697
4032
  skillDetail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, skillDetail.detailError) : null,
3698
4033
  skillDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, skillDetail.description) : null,
3699
4034
  h('div', { className: 'dsh-mcp-card-tags' },
3700
4035
  skillDetail.version ? h(Badge, null, 'v' + skillDetail.version) : null,
3701
- skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? '(已认证)' : '')) : null,
4036
+ skillDetail.publisher ? h(Badge, null, skillDetail.publisher + (skillDetail.verifiedPublisher ? t('(已认证)') : '')) : null,
3702
4037
  skillDetail.certifiedName ? h(Badge, { cls: 's-on' }, skillDetail.certifiedName) : null,
3703
- 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,
3704
4039
  ),
3705
- 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,
3706
4041
  h('p', { className: 'dsh-mcp-wb-modal-note' },
3707
- '安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。'),
4042
+ t('安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。')),
3708
4043
  ),
3709
4044
  h('div', { className: 'dsh-mcp-modal-actions' },
3710
- h(Btn, { onClick: () => setSkillDetail(null), disabled: busy, children: '关闭' }),
4045
+ h(Btn, { onClick: () => setSkillDetail(null), disabled: busy, children: t('关闭') }),
3711
4046
  installedSkills.has(skillDetail.slug)
3712
- ? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: '卸载' })
3713
- : 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('安装') }),
3714
4049
  ),
3715
4050
  ),
3716
4051
  )
@@ -3719,6 +4054,7 @@ const WorkBuddyMarket = ({ call }) => {
3719
4054
  };
3720
4055
 
3721
4056
 
4057
+ // ── 面板头部入口菜单:切「悬浮按钮 / 侧栏入口」的显示偏好 ────────────────────────
3722
4058
  // 入口偏好的开关就放在面板头部:它必须能从面板里改回来(关掉一个入口后用户还在面板里),
3723
4059
  // 而面板本身只有这两个入口能打开——所以这个控件不能藏得更深。低频操作,用图标按钮 + 弹层,
3724
4060
  // 不占 Tab(作用域 tab 讲的是「管哪一份配置」,与显示偏好无关)。
@@ -3737,9 +4073,9 @@ const EntryMenu = () => {
3737
4073
  { className: 'dsh-mcp-entry', ref: boxRef },
3738
4074
  h(Btn, {
3739
4075
  icon: 'entry',
3740
- title: '入口显示:' + ENTRY_LABELS[mode],
4076
+ title: t('入口显示:') + ENTRY_LABELS[mode],
3741
4077
  onClick: (e) => { e.stopPropagation(); setOpen((value) => !value); },
3742
- children: '入口',
4078
+ children: t('入口'),
3743
4079
  }),
3744
4080
  // 行用原生 <button> 而不是 div[role=menuitemradio]:menu 语义要求方向键 / Home / Esc /
3745
4081
  // roving tabindex 一整套键盘行为,不实现就等于这个设置对键盘用户不存在,还多了一句假声明。
@@ -3758,6 +4094,7 @@ const EntryMenu = () => {
3758
4094
  );
3759
4095
  };
3760
4096
 
4097
+ // ── 侧栏入口与面板图标:跨 slot 共享的打开/切分类通道 ────────────────────────────
3761
4098
  // 左侧栏底部入口(sidebar.footer.action,DSH 0.1.5 起提供):官方标准的发现入口。
3762
4099
  // 点击打开的是同一个浮层面板——不切换主面板、不打断正在进行的会话。
3763
4100
  const McpFooterAction = ({ wide }) => {
@@ -3771,12 +4108,12 @@ const McpFooterAction = ({ wide }) => {
3771
4108
  {
3772
4109
  type: 'button',
3773
4110
  className: 'dsh-mcp-footer-action' + (wide ? '' : ' rail'),
3774
- title: '技能 · MCP · 专家看板',
3775
- 'aria-label': '打开技能 · MCP · 专家看板',
4111
+ title: t('技能 · MCP · 专家看板'),
4112
+ 'aria-label': t('打开技能 · MCP · 专家看板'),
3776
4113
  onClick: () => openBoardPanel(),
3777
4114
  },
3778
4115
  h(Icon, { d: ICONS.plug, size: wide ? 16 : 18 }),
3779
- wide ? h('span', { className: 'dsh-mcp-footer-name' }, '技能·专家') : null,
4116
+ wide ? h('span', { className: 'dsh-mcp-footer-name' }, t('技能·专家')) : null,
3780
4117
  );
3781
4118
  };
3782
4119
 
@@ -3805,6 +4142,43 @@ function openBoardPanel() {
3805
4142
  layout.selectPanel(active === PANEL_ID ? null : PANEL_ID);
3806
4143
  }
3807
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
+
3808
4182
  // 看板页(main slot,key = PANEL_ID):宿主中栏按 entryKey 渲染同 key 的条目。
3809
4183
  // 组件复用既有的 McpTab——它本来就支持非浮层渲染(floating=false 时直接返回 inner),
3810
4184
  // 全部 MCP 能力(列表/详情/编辑/导入/市场/升级/更新提示)因此原样保留,只是换了宿主位置。
@@ -3825,24 +4199,41 @@ const McpBoardPage = (props) => {
3825
4199
  }, []);
3826
4200
  // 顶层分类计数:Skill 条数单独取,避免两条加载互相等待。
3827
4201
  const [skillCount, setSkillCount] = useState(null);
3828
- // 工具裁缝计数:只在用户切到该分类时拉一次,避免打开页面就扫日志。
3829
- const [tailorCount, setTailorCount] = useState(null);
4202
+ // MCP 计数:与其他分类标签保持一致(此前只有 skill/tailor/expert 有数量,MCP 没有是遗漏)。
4203
+ const [mcpCount, setMcpCount] = useState(null);
3830
4204
  useEffect(() => {
3831
4205
  if (category !== 'mcp') return;
3832
4206
  let alive = true;
3833
4207
  call('listSkills', skillScopeArg('global')).then((res) => {
3834
4208
  if (alive && res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
3835
4209
  }).catch(() => {});
4210
+ // MCP 计数与 Skill 同批取(都在打开页面时取一次;global 层与 McpTab 的默认视图一致)。
4211
+ call('list').then((res) => {
4212
+ if (alive && res && res.ok && Array.isArray(res.servers)) setMcpCount(res.servers.length);
4213
+ }).catch(() => {});
3836
4214
  return () => { alive = false; };
3837
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);
3838
4226
  useEffect(() => {
3839
- if (category !== 'tailor' || tailorCount !== null) return;
4227
+ if (category !== 'tailor' || tailorRequested.current) return;
4228
+ tailorRequested.current = true;
3840
4229
  let alive = true;
3841
4230
  call('toolTailor', { days: 30 }).then((res) => {
3842
- if (alive && res && res.ok && typeof res.deadMcpCount === 'number') setTailorCount(res.deadMcpCount);
3843
- }).catch(() => {});
4231
+ if (alive && res && res.ok) setTailorReport(res);
4232
+ }).catch(() => {}).finally(() => { if (alive) setTailorSettled(true); });
3844
4233
  return () => { alive = false; };
3845
- }, [call, category, tailorCount]);
4234
+ }, [call, category]);
4235
+ // 首次进入该分类时父层必定会发这一发请求,因此首帧就告诉子组件「等我的」。
4236
+ const tailorSeedPending = category === 'tailor' && !tailorSettled;
3846
4237
  // 专家分类计数:已召唤数(打在分类上的是「已召唤」,与 Skill 条数语义一致——都是「在手」而非「总量」)。
3847
4238
  const [expertCount, setExpertCount] = useState(null);
3848
4239
  useEffect(() => {
@@ -3856,7 +4247,7 @@ const McpBoardPage = (props) => {
3856
4247
 
3857
4248
  return h('div', { className: 'dsh-mcp-page' },
3858
4249
  h('div', { className: 'dsh-mcp-wrap' },
3859
- h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': '看板分类' },
4250
+ h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': t('看板分类') },
3860
4251
  BOARD_CATEGORIES.map((cat) =>
3861
4252
  h('button', {
3862
4253
  key: cat.key,
@@ -3865,25 +4256,28 @@ const McpBoardPage = (props) => {
3865
4256
  'aria-pressed': category === cat.key,
3866
4257
  'aria-selected': category === cat.key,
3867
4258
  className: 'dsh-mcp-cat',
3868
- 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 技能 + 专家召唤'),
3869
4260
  onClick: () => setCategory(cat.key),
3870
4261
  },
3871
4262
  h(Icon, { d: ICONS[cat.icon], size: 14 }),
3872
4263
  cat.label,
4264
+ cat.key === 'mcp' && mcpCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mcpCount) : null,
3873
4265
  cat.key === 'skill' && skillCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, skillCount) : null,
3874
- 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,
3875
4267
  cat.key === 'expert' && expertCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, expertCount) : null,
3876
4268
  )),
3877
4269
  ),
4270
+ // OPT-07:每个分类各自包一层错误边界。key 用分类名,切走再切回会重建边界,
4271
+ // 顺带让「重试」之外还有一条自然恢复路径。
3878
4272
  category === 'mcp'
3879
- ? h(McpTab, { call, timer, pluginManager })
4273
+ ? h(ArmoryPanelBoundary, { key: 'mcp', hint: t('MCP 列表加载失败;Skill、专家、裁缝、市场仍可正常使用。') }, h(McpTab, { call, timer, pluginManager }))
3880
4274
  : category === 'tailor'
3881
- ? h(ToolTailor, { call, timer })
4275
+ ? h(ArmoryPanelBoundary, { key: 'tailor', hint: t('工具账本加载失败;其余分类仍可正常使用。') }, h(ToolTailor, { call, timer, report: tailorReport, onReport: setTailorReport, seedPending: tailorSeedPending }))
3882
4276
  : category === 'expert'
3883
- ? h(ExpertInstalledBoard, { call })
4277
+ ? h(ArmoryPanelBoundary, { key: 'expert', hint: t('已召唤专家列表加载失败;其余分类仍可正常使用。') }, h(ExpertInstalledBoard, { call }))
3884
4278
  : category === 'wb'
3885
- ? h(WorkBuddyMarket, { call })
3886
- : 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 })),
3887
4281
  ),
3888
4282
  );
3889
4283
  };
@@ -3978,25 +4372,26 @@ function upgradePlan({ service, hint, bundle, spec = null, source = null }) {
3978
4372
  function upgradeFailureText(error, exitCode) {
3979
4373
  const code = error?.code;
3980
4374
  const known = {
3981
- 'incompatible-version': '新版本与当前 DSH 不兼容,未升级。',
3982
- 'not-bundle': '该包没有声明组合包,不能作为插件安装。',
3983
- 'invalid-spec': '版本号不合法,未升级。',
3984
- 'ambiguous-install': '无法确定要升级哪个包,未升级。',
3985
- 'unknown-plugin': '在 profile 里找不到这个插件,未升级。',
3986
- 'stop-profile': '宿主正在停止,未升级;请重启后再试。',
3987
- 'bundle-in-use': '该包正在被使用,未升级。',
3988
- 'stale-approval': '构建授权已过期,未升级;请重试。',
3989
- 'management-required': '当前 profile 由应用托管,不允许在这里改动。',
3990
- '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 提供)。'),
3991
4385
  };
3992
4386
  if (known[code]) return known[code];
3993
4387
  // 未知码不吞掉:把码和 pnpm 的退出码如实带出来,否则用户和我们都无从查起。
3994
4388
  const bits = [];
3995
- if (code) bits.push('错误码 ' + code);
3996
- if (typeof exitCode === 'number' && exitCode !== 0) bits.push('pnpm 退出码 ' + exitCode);
3997
- 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('升级失败。');
3998
4392
  }
3999
4393
 
4394
+ // ── 「这次变了什么」弹窗:把 lib/whats-new.js 的内容说一次 ──────────────────────
4000
4395
  function whatsNewFor(current, lastSeen, entries) {
4001
4396
  if (!current || !Array.isArray(entries) || entries.length === 0) return [];
4002
4397
  if (lastSeen === current) return [];
@@ -4073,12 +4468,12 @@ const WhatsNewModal = ({ call }) => {
4073
4468
  { className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && dismiss() },
4074
4469
  h(
4075
4470
  'div',
4076
- { 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('本次更新内容') },
4077
4472
  h('div', { className: 'dsh-mcp-whatsnew-head' },
4078
4473
  // 版本中性:首次打开(没有任何记录)与从旧版本升上来走的是同一个分支,
4079
4474
  // 写「更新到」会把前者的文案说错。
4080
- h('b', null, 'v' + notice.current + ' 的更新'),
4081
- h('span', null, '这些变化你可能想知道'),
4475
+ h('b', null, 'v' + notice.current + t(' 的更新')),
4476
+ h('span', null, t('这些变化你可能想知道')),
4082
4477
  ),
4083
4478
  h('div', { className: 'dsh-mcp-whatsnew-body' },
4084
4479
  notice.entries.map((entry) =>
@@ -4098,16 +4493,16 @@ const WhatsNewModal = ({ call }) => {
4098
4493
  ),
4099
4494
  ),
4100
4495
  h('div', { className: 'dsh-mcp-whatsnew-foot' },
4101
- 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('知道了')),
4102
4497
  ),
4103
4498
  ),
4104
4499
  );
4105
4500
  };
4106
4501
 
4107
- const McpOverlay = ({ call, timer, pluginManager }) => {
4108
- const open = usePanelOpen();
4502
+ // ── 悬浮按钮宿主(shell.overlay):只剩这一个席位 ────────────────────────────────
4503
+ const McpOverlay = () => {
4109
4504
  const mode = useEntryMode();
4110
- const seats = overlaySeats(mode, open);
4505
+ const seats = overlaySeats(mode);
4111
4506
  const [pos, setPos] = useState(() => readFabPos() || defaultFabPos());
4112
4507
  const fabRef = useRef(null);
4113
4508
  const dragRef = useRef(null);
@@ -4181,8 +4576,8 @@ const McpOverlay = ({ call, timer, pluginManager }) => {
4181
4576
  ref: fabRef,
4182
4577
  type: 'button',
4183
4578
  className: 'dsh-mcp-fab',
4184
- title: '技能 · MCP · 专家看板(可拖拽移动)',
4185
- 'aria-label': '打开技能 · MCP · 专家看板',
4579
+ title: t('技能 · MCP · 专家看板(可拖拽移动)'),
4580
+ 'aria-label': t('打开技能 · MCP · 专家看板'),
4186
4581
  style,
4187
4582
  onClick: handleClick,
4188
4583
  onPointerDown: handlePointerDown,
@@ -4195,6 +4590,8 @@ const McpOverlay = ({ call, timer, pluginManager }) => {
4195
4590
  );
4196
4591
  };
4197
4592
 
4593
+ // ── 定时器宿主:卸载时统一清空,避免组件残留 setInterval ────────────────────────
4594
+ // 宿主的 client 上下文提供 `timer` 时优先用宿主;这里只是 apply() 里给自身用的兜底实现。
4198
4595
  function createTimer() {
4199
4596
  const timeouts = new Set();
4200
4597
  const intervals = new Set();
@@ -4227,6 +4624,7 @@ function createTimer() {
4227
4624
  };
4228
4625
  }
4229
4626
 
4627
+ // ── RPC 契约的浏览器半:strict codec 与描述符表 MCP_CONTRIBUTION ──────────────────
4230
4628
  // strict codec 与 lib/typert.js 是同一份契约的两份产物:宿主 loader 校验 host 那份,
4231
4629
  // typert-registry 的**浏览器半**校验这份,两边规则一致而两个渠道查不同的键——
4232
4630
  // 0.1.5-rc.x(= 当时 latest/next,npx 默认装到的那条)查 `codec.schema.parse`;
@@ -4311,6 +4709,7 @@ const MCP_CONTRIBUTION = {
4311
4709
  { id: 'dsh-armory#mcpManager/expertActive', service: 'mcpManager', namespace: 'mcpManager', method: 'expertActive', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('ExpertActiveResult') }
4312
4710
  ].map((descriptor) => ({ ...descriptor, sourceLocation: { file: 'dsh-armory/lib/typert.js', line: 7, column: 1 } }))
4313
4711
  };
4712
+ // ── 插件入口:挂 RPC、注入样式、注册六个界面槽位 ──────────────────────────────
4314
4713
  async function apply(ctx) {
4315
4714
  const disposeMount = await ctx.remote.$mount(MCP_CONTRIBUTION);
4316
4715
  let disposeCss;
@@ -4337,10 +4736,10 @@ async function apply(ctx) {
4337
4736
  MODULE_CTX.ctx = ctx;
4338
4737
  const call = async (method, args) => {
4339
4738
  const svc = (typeof ctx.get === 'function' ? ctx.get('remote.mcpManager') : null) || null;
4340
- if (!svc || typeof svc[method] !== 'function') return { ok: false, error: 'MCP 市场服务未连接(面板仍可用)' };
4739
+ if (!svc || typeof svc[method] !== 'function') return { ok: false, error: t('MCP 市场服务未连接(面板仍可用)') };
4341
4740
  const r = args === undefined || args === null ? await svc[method]() : await svc[method](args);
4342
4741
  if (r && r.ok) return { ok: true, ...((r.value && typeof r.value === 'object') ? r.value : {}) };
4343
- 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('调用失败') };
4344
4743
  return r;
4345
4744
  };
4346
4745
  // 一键升级要用官方 pluginManager,而它只在 apply 的 ctx 上取得到(面板组件拿不到 ctx——
@@ -4359,10 +4758,16 @@ async function apply(ctx) {
4359
4758
  // 点开 = 技能/MCP/专家三分类 + 悬停最近使用子菜单。声明感知注入(会话 slot 由 conversation
4360
4759
  // 包声明,加载顺序不保证);菜单组件自管开关与外部点击关闭。
4361
4760
  // sessionId 由 slot 渲染作为 prop 显式传入(session 座位约定:id 只走 props,不走 summary 投影),
4362
- // 「挂专家」的 agentPresets.select 用它寻址当前会话。
4761
+ // 「挂专家」的人设通道 RPC 用它寻址当前会话。
4363
4762
  ctx.slots.inject('conversation.input.right', () =>
4364
4763
  ctx.slots.register({ name: 'conversation.input.right', id: PANEL_ID + '-quick', order: 40, label: PANEL_LABEL, inject: (sessionId) => ({ call, sessionId }) }, SummonQuickMenu),
4365
4764
  );
4765
+ // 会话头「当前生效专家」徽章(conversation.session.header.actions,list 型 slot):
4766
+ // 与宿主 AgentPresetLabel 同列——但显示的是人设通道真正生效的专家(expertActive),
4767
+ // 修复「换专家后顶栏仍显示第一次预设名」的显示/实效分叉。
4768
+ ctx.slots.inject('conversation.session.header.actions', () =>
4769
+ ctx.slots.register({ name: 'conversation.session.header.actions', id: PANEL_ID + '-head-expert', order: -5, label: PANEL_LABEL, inject: (sessionId) => ({ call, sessionId }) }, SessionExpertBadge),
4770
+ );
4366
4771
  // 左上角全局面板行(sidebar.panellist):「插件」旁边,主入口。行点击由宿主承担——
4367
4772
  // layout.selectPanel(PANEL_ID) 要求 main slot 里存在同 key 条目,上面的注册保证了这点。
4368
4773
  // 面板行不能用「渲染 null」隐藏(label 来自注册 options,不来自组件),所以按偏好