dsh-armory 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -117,6 +117,17 @@ const EN_US_MESSAGES = {
117
117
  "toolTailor 调用失败": "toolTailor call failed",
118
118
  "zstd 解压不可用:当前 Node 不支持 zlib.zstdDecompressSync(需 >= 22.15),所有调用计数都会是 0": "zstd decompression unavailable: this Node lacks zlib.zstdDecompressSync (needs >= 22.15); all call counts will read 0",
119
119
  "{0} 个 MCP 工具在 {1} 天内零调用,合计每轮请求白缴 ~{2} tok 上下文税。可到「MCP」页或在上方行内禁用对应服务器(随时可启用,禁用只作用于新请求)。": "{0} MCP tools had zero calls in {1} days, wasting ~{2} tok of context tax per request. Disable the servers on the 'MCP' page or inline above (re-enable anytime; disabling only affects new requests).",
120
+ // §6.1 / §6.2 / §6.4 工具裁缝「税的时间维度与归因」。
121
+ "近 {0} 天每轮真实下发的工具税(左旧右新)": "Real per-request tool tax over the last {0} days (older left, newer right)",
122
+ "每轮下发清单在 {0} 天内一次没变": "The per-request tool list did not change once in {0} days",
123
+ "每轮下发清单变化 {0} 次": "The per-request tool list changed {0} times",
124
+ "最近一次 {0} 个工具 ~{1} tok({2})": "Latest request: {0} tools, ~{1} tok ({2})",
125
+ "{0} 条调用事件没有可用的 time 字段,无法判定落在哪个窗口,已全部计入当前窗口(切换时间窗对这部分不生效)。": "{0} call events had no usable time field and could not be placed in any window; all were counted into the current one (switching the window does not affect them).",
126
+ "窗口内真实下发清单的种类数超过上限,税趋势线只覆盖前若干种。": "The window held more distinct tool lists than the cap allows; the tax trend covers only the first few.",
127
+ "窗口 {0} 天,但读到的调用记录只跨 {1} 天(最早一条是 {2});更早的部分本机没有日志,加长窗口不会改变任何数字。": "The window is {0} days, but the call records read span only {1} days (earliest {2}). There are no local logs before that, so widening the window cannot change any number.",
128
+ "注册表快照比最近一次真实下发多 ~{0} tok:多半是改完配置还没重启宿主,或项目级工具还没在当前会话注册。": "The registry snapshot is ~{0} tok larger than the latest real request: you likely edited the config without restarting the host, or project-level tools are not registered in this session yet.",
129
+ "最近一次真实下发比注册表快照多 ~{0} tok:当前会话的工具集与面板读到的全局注册表不一致(可能来自项目级或子 Agent 作用域)。": "The latest real request carries ~{0} tok more than the registry snapshot: this session's tool set differs from the global registry the panel reads (likely project-level or sub-agent scope).",
130
+
120
131
  "{0} 个专家正挂在 {1} 个会话上": "{0} experts are attached to {1} sessions",
121
132
  "{0} 个会话": "{0} sessions",
122
133
  "{0} 个共用": "{0} shared",
@@ -516,7 +527,8 @@ const EN_US_MESSAGES = {
516
527
  "替换当前 Profile": "Replace current profile",
517
528
  "替换该项目": "Replace this project",
518
529
  "最后使用": "Last used",
519
- "最多下载": "Most downloads",
530
+ "下载量(本页排序)": "Downloads (this page)",
531
+ "SkillHub 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。": "SkillHub no longer offers a global download ranking; this option sorts by downloads within the current page only.",
520
532
  "最多安装": "Most installs",
521
533
  "最多星标": "Most stars",
522
534
  "最大尝试次数": "Max attempts",
@@ -759,9 +771,6 @@ const EN_US_MESSAGES = {
759
771
  "成员由主理人用 subagent 调度;若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设": "Members are dispatched by the lead via subagent; if this session has no subagent tool, create a new session and pick the \"WorkBuddy Expert · {0}\" preset",
760
772
  "今日": "Today",
761
773
  "账本缓存": "Ledger cache",
762
- "清空缓存": "Clear cache",
763
- "正在清空缓存…": "Clearing cache…",
764
- "已清空缓存,释放 {0}": "Cache cleared, {0} freed",
765
774
  " 条": " items",
766
775
  "": "",
767
776
  "&": "&",
@@ -822,7 +831,68 @@ const EN_US_MESSAGES = {
822
831
  "跳转到指定页": "Jump to a specific page",
823
832
  "共 {0} 条": "{0} total",
824
833
  "免认证": "No auth",
825
- "这个看板是只读的:宿主只提供 listSkills / skillDetail,没有启用/禁用或修改入口;要改内容请直接编辑 SKILL.md。": "This board is read-only: the host only exposes listSkills / skillDetail, with no enable/disable or edit endpoint. To change a skill, edit its SKILL.md directly.",
834
+ "宿主只提供 skill 的读取接口;这里可以卸载用户级/项目级目录里的 skill(永久删除,不可撤销)。随 DSH 分发、运行时注册、自定义目录的 skill 不可卸载——要改内容请直接编辑 SKILL.md。": "The host only exposes read endpoints for skills. Skills in user-level/project-level directories can be uninstalled here (permanent deletion, no undo). Skills shipped with DSH, registered at runtime, or living in custom directories cannot be uninstalled — edit SKILL.md directly to change them.",
835
+ "将从磁盘永久删除 {0}(不可撤销),确认卸载?": "This permanently deletes {0} from disk (no undo). Uninstall?",
836
+ "确认卸载": "Uninstall",
837
+ "卸载中…": "Uninstalling…",
838
+ "永久删除这个 skill 所在目录(不可撤销)": "Permanently delete this skill's directory (no undo)",
839
+ "已卸载 {0}": "Uninstalled {0}",
840
+ "这个 skill 来自「{0}」,不在可卸载范围内:随 DSH 分发、运行时注册与自定义目录的 skill 只能查看。要停用它,请编辑所属预设或 SKILL.md。": "This skill comes from \"{0}\" and cannot be uninstalled: skills shipped with DSH, registered at runtime, or living in custom directories are read-only. To disable it, edit its preset or SKILL.md instead.",
841
+
842
+ // 自定义套件(Skill 看板的套件管理弹窗 + 召唤菜单技能子菜单里的套件分组)。
843
+ // 「套件」本身、保存中…、取消、关闭、搜索 skill… 等复用既有键,不在此重复登记。
844
+ "套件管理": "Suite manager",
845
+ "把多个相关 skill 打包成套件;在输入框的引用菜单里点一次套件,等于同时引用全部成员。": "Bundle related skills into a suite; one click in the composer's reference menu inserts every member at once.",
846
+ "管理自定义套件": "Manage custom suites",
847
+ "管理自定义套件:把多个相关 skill 打包,引用一次全部生效": "Manage custom suites: bundle related skills so one reference applies them all",
848
+ "管理套件…": "Manage suites…",
849
+ "新建或修改套件(把多个相关 skill 打包,引用一次全部生效)": "Create or edit suites (bundle related skills so one reference applies them all)",
850
+ "引用套件:展开为 {0} 个 /name": "Reference this suite: expands into {0} /name tokens",
851
+ "{0} 技能": "{0} skills",
852
+ "我的套件": "My suites",
853
+ "新建套件": "New suite",
854
+ "新建一个空套件": "Create an empty suite",
855
+ "还没有套件:点右上「新建套件」开始": "No suites yet: click \"New suite\" at the top right to start",
856
+ "未命名套件": "Untitled suite",
857
+ "套件名称": "Suite name",
858
+ "例如:演示视频流水线": "e.g. Demo video pipeline",
859
+ "说明(可选,显示在引用菜单条目右侧)": "Description (optional, shown beside the entry in the reference menu)",
860
+ "一句话说明这套技能干什么": "One line on what this bundle of skills does",
861
+ "成员技能": "Member skills",
862
+ "{0} 个 · 拖拽可排序": "{0} · drag to reorder",
863
+ "把左侧卡片拖到这里": "Drag a card from the left in here",
864
+ "拖不动时,悬停卡片点 + 也能加进来": "If dragging is awkward, hover a card and click + to add it",
865
+ "选择右上列表里的一个套件,或点「新建套件」": "Select a suite from the list at the top right, or click \"New suite\"",
866
+ "先在右侧选择一个套件,或点「新建套件」": "Select a suite on the right first, or click \"New suite\"",
867
+ "套件 = 一组相关 skill 的引用打包": "A suite = one reference that bundles related skills",
868
+ "拖左侧卡片到成员区即可添加;成员可上下拖拽排序,+ / × 是无拖拽的替代路径。": "Drag a card from the left into the member area to add it; drag members up or down to reorder. + / × are the no-drag alternatives.",
869
+ "看板没有读到 skill 列表": "The board did not return a skill list",
870
+ "宿主未挂载 skill 注册表(@deepseek-ai/dsh-skill)时这里就是空的;套件仍能保存,但成员名要自己保证正确。": "This is empty when the host mounts no skill registry (@deepseek-ai/dsh-skill). Suites can still be saved, but you have to get the member names right yourself.",
871
+ "没有符合筛选的 skill": "No skill matches the filter",
872
+ "已在当前套件中": "Already in the current suite",
873
+ "已在套件中": "In suite",
874
+ "拖到右侧成员区,或点 + 添加": "Drag to the member area on the right, or click + to add",
875
+ "添加到当前套件": "Add to the current suite",
876
+ "已加入「{0}」": "Added \"{0}\"",
877
+ "「{0}」已在这个套件里": "\"{0}\" is already in this suite",
878
+ "成员已达上限 {0} 个": "Member limit reached ({0})",
879
+ "套件数量已达上限 {0} 个": "Suite limit reached ({0})",
880
+ "宿主当前找不到这个 skill(可能已移动或删除),引用时会失效": "The host can no longer find this skill (it may have been moved or deleted); referencing it will fail",
881
+ "已失效": "Missing",
882
+ "移除 {0}": "Remove {0}",
883
+ "删除套件": "Delete suite",
884
+ "再点一次确认删除": "Click again to confirm deletion",
885
+ "确认?": "Sure?",
886
+ "删除": "Delete",
887
+ "未保存": "Unsaved",
888
+ "整集合覆盖保存:右侧列表就是全部套件": "Saving overwrites the whole set: the list on the right is all your suites",
889
+ "保存套件": "Save suites",
890
+ "套件已保存({0} 个)": "Suites saved ({0})",
891
+ "保存失败:宿主没有回包": "Save failed: no response from the host",
892
+ "有套件还没起名": "A suite still has no name",
893
+ "套件「{0}」还没有成员": "Suite \"{0}\" has no members yet",
894
+ "已删除套件(保存后生效)": "Suite deleted (takes effect on save)",
895
+ "已放弃未保存的修改": "Unsaved changes discarded",
826
896
 
827
897
  "这 {0} 个 MCP 都需要交互取值,无法直接导入": "These {0} MCP servers all require interactive input and cannot be imported directly",
828
898
  "(另有 {0} 个需要交互取值)": " ({0} more require interactive input)",
@@ -2027,6 +2097,142 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2027
2097
  .dsh-mcp-skill-d-close:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
2028
2098
  @media (max-width:760px){.dsh-mcp-skill-content{overflow:visible;max-height:none}.dsh-mcp-skill-toolbar{flex-wrap:wrap}.dsh-mcp-skill-search{flex:1 1 160px}.dsh-mcp-skill-detail{position:static;max-height:none;align-self:stretch}}
2029
2099
 
2100
+ /* ══ 自定义套件(看板工具条入口 + 召唤菜单套件分组 + 套件管理弹窗)═══════════════════
2101
+ 度量取自 docs/design-suites-mockup.html(局部 A/B/C),类名沿用 mockup 的 suite-* 命名,
2102
+ 只有两处刻意改名:mockup 的 .pick-card → .suite-pick-card(看板里 pick 另有含义),
2103
+ mockup 的 header/.x/.sub → .suite-head/.suite-x/.suite-head-text(弹窗里要能定位到类,
2104
+ 元素选择器太宽,会把宿主注入的任何 header/h3 一起带走)。
2105
+ 只用宿主 --dsw-* 与本插件已有的 --mcp-* 令牌,不新增自定义属性;阴影一律带回落值
2106
+ (--dsw-elevation-card 在本仓库从未用过,不能假定宿主一定提供)。
2107
+ 短类名(.nm/.cat/.tile/.grip/.idx/.rm…)一律带父级前缀,避免日后与别的组件撞名。 */
2108
+ .dsh-mcp-btn.suite-entry{flex:none}
2109
+ .dsh-mcp-btn.suite-entry .cnt{padding:0 5px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-text);font-size:10.5px;line-height:15px;font-variant-numeric:tabular-nums}
2110
+ .dsh-mcp-btn.suite-entry:hover .cnt{background:var(--mcp-accent);color:var(--dsw-alias-label-primary-inverted)}
2111
+ /* 召唤菜单:套件分组头 + 分隔线 + 条目前的 accent 图标(只有套件条目带图标——
2112
+ 技能/MCP/专家条目保持原样,避免一次改动三个子菜单的既有观感)。 */
2113
+ .dsh-mcp-quick-group{display:flex;align-items:center;gap:6px;flex:none;padding:5px 9px 3px;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:14px;letter-spacing:.05em}
2114
+ .dsh-mcp-quick-group svg{flex:none;color:var(--mcp-accent)}
2115
+ .dsh-mcp-quick-sep{height:1px;flex:none;margin:4px 6px;background:var(--mcp-line)}
2116
+ /* 条目行是 align-items:baseline(文字与徽章按基线对齐),图标不参与基线:
2117
+ alignSelf:center 让它在 28px 固定行高里正中,不会被 svg 的下沿顶偏。 */
2118
+ .dsh-mcp-quick-item .ic{flex:none;align-self:center;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary)}
2119
+ .dsh-mcp-quick-item.suite .ic{color:var(--mcp-accent)}
2120
+ .dsh-mcp-quick-sub .dsh-mcp-quick-manage{gap:6px}
2121
+ .dsh-mcp-quick-manage .ic{flex:none;display:inline-flex;align-items:center}
2122
+ /* 两条页脚按钮叠着时(管理技能… / 管理套件…),第二条不再自带 4px 上边距:
2123
+ 否则分割线会浮在空隙中间,看着像渲染错位。 */
2124
+ .dsh-mcp-quick-manage+.dsh-mcp-quick-manage{margin-top:0}
2125
+ /* 弹窗本体:把基类 .dsh-mcp-modal 的 560px/20px 内边距/纵向滚动整体换掉——
2126
+ 套件弹窗是「左右两栏各自滚动」,外层必须 overflow:hidden 才不会出现双滚动条。 */
2127
+ .dsh-mcp-modal.suites{display:flex;flex-direction:column;width:min(980px,94vw);max-width:94vw;height:min(640px,88vh);max-height:88vh;padding:0;overflow:hidden}
2128
+ .suite-head{display:flex;align-items:flex-start;gap:10px;flex:none;padding:16px 18px 12px;border-bottom:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
2129
+ .suite-head-text{min-width:0}
2130
+ .suite-head h3{display:flex;align-items:center;gap:8px;margin:0;font-size:15px;font-weight:650;line-height:20px;color:var(--dsw-alias-label-primary)}
2131
+ .suite-head h3::before{content:"";flex:none;width:4px;height:14px;border-radius:2px;background:var(--mcp-accent)}
2132
+ .suite-head .sub{margin:5px 0 0 12px;font-size:11.5px;line-height:17px;color:var(--mcp-mut)}
2133
+ /* 关闭按钮不用 .dsh-mcp-skill-d-close:那个是 position:absolute(贴详情面板的角),
2134
+ 放进 flex 头部会脱离文档流、盖住副标题。 */
2135
+ .suite-x{display:inline-flex;align-items:center;justify-content:center;flex:none;width:26px;height:26px;margin-left:auto;padding:0;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--dsw-alias-button-elevated-fill);color:var(--mcp-mut);cursor:pointer;transition:background .15s,border-color .15s,color .15s}
2136
+ .suite-x:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
2137
+ .suite-x:disabled{opacity:.5;cursor:default}
2138
+ .suite-x:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
2139
+ .suite-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(320px,44%) 1fr}
2140
+ /* ── 左:技能库选择器 ──────────────────────────────────────────────── */
2141
+ .suite-left{display:flex;flex-direction:column;gap:9px;min-height:0;padding:13px 14px;border-right:1px solid var(--mcp-line);background:var(--mcp-bg-2)}
2142
+ /* 搜索框不复用 .dsh-mcp-skill-search:那条在本仓库只有 flex 尺寸、没有图标定位,
2143
+ 看板里的放大镜其实是裸排的(既有小瑕疵,不在本次范围)。这里自带一份完整的。 */
2144
+ .suite-search{position:relative;display:flex;align-items:center;flex:none;height:30px}
2145
+ .suite-search svg{position:absolute;top:50%;left:9px;transform:translateY(-50%);pointer-events:none;color:var(--mcp-mut)}
2146
+ .suite-search input{box-sizing:border-box;width:100%;height:30px;padding:0 10px 0 28px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;outline:0;transition:border-color .15s,box-shadow .15s}
2147
+ .suite-search input::placeholder{color:var(--mcp-mut)}
2148
+ .suite-search input:focus{border-color:var(--mcp-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-accent) 14%,transparent)}
2149
+ /* 分类轨道在弹窗里更窄,chips 收一号才放得下 6 个。 */
2150
+ .suite-left .dsh-mcp-skill-rail{gap:5px}
2151
+ .suite-left .dsh-mcp-skill-cat{padding:2px 9px;font-size:11px;line-height:17px}
2152
+ .suite-pick-list{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:7px;padding:2px 2px 6px;scrollbar-width:thin}
2153
+ .suite-pick-card{position:relative;display:flex;align-items:flex-start;gap:9px;padding:9px 26px 9px 10px;border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:grab;transition:border-color .12s,background .12s,box-shadow .14s,opacity .12s}
2154
+ .suite-pick-card:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover);box-shadow:var(--dsw-elevation-overlay,0 1px 3px rgba(0,0,0,.1))}
2155
+ .suite-pick-card:active{cursor:grabbing}
2156
+ .suite-pick-card.dragging{opacity:.45;border-style:dashed}
2157
+ .suite-pick-card.in-suite{border-color:color-mix(in srgb,var(--mcp-accent) 45%,var(--mcp-line));background:var(--dsw-alias-interactive-bg-hover-accent)}
2158
+ .suite-pick-card .tile{flex:none;width:10px;height:10px;margin-top:3px;border-radius:3px}
2159
+ .suite-pick-card .body{min-width:0;flex:1}
2160
+ .suite-pick-card .nm{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:550;line-height:18px;color:var(--dsw-alias-label-primary)}
2161
+ .suite-pick-card .nm .cat{font-size:10.5px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
2162
+ .suite-pick-card .ds{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;margin:2px 0 0;font-size:11px;line-height:1.5;color:var(--mcp-mut)}
2163
+ .suite-pick-card .grip{flex:none;margin-top:2px;color:var(--dsw-alias-label-tertiary);opacity:.55}
2164
+ .suite-pick-card:hover .grip{opacity:1}
2165
+ /* + 是无拖拽的替代路径:平时藏着(hover / 键盘聚焦才现形),已在套件里则常亮成 ✓。 */
2166
+ .suite-pick-card .add{position:absolute;top:7px;right:7px;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:1px solid var(--mcp-line);border-radius:7px;background:var(--mcp-bg-1);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s,background .12s,border-color .12s,color .12s}
2167
+ .suite-pick-card:hover .add,.suite-pick-card:focus-within .add{opacity:1}
2168
+ .suite-pick-card .add:hover{background:var(--mcp-accent);border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary-inverted)}
2169
+ .suite-pick-card .add:disabled{cursor:default}
2170
+ .suite-pick-card.in-suite .add{opacity:1;border-color:color-mix(in srgb,var(--mcp-accent) 40%,var(--mcp-line));color:var(--mcp-accent)}
2171
+ /* ── 右:套件列表 + 编辑器 ─────────────────────────────────────────── */
2172
+ .suite-right{display:flex;flex-direction:column;min-height:0;background:var(--mcp-bg-1)}
2173
+ .suite-list-bar{display:flex;align-items:center;gap:8px;flex:none;padding:11px 14px 9px;border-bottom:1px solid var(--mcp-line)}
2174
+ .suite-list-bar .t{font-size:12px;font-weight:650;color:var(--dsw-alias-label-secondary)}
2175
+ .suite-list-bar .sp{flex:1}
2176
+ .dsh-mcp-btn.suite-new{padding:4px 10px;font-size:11.5px}
2177
+ .suite-list{display:flex;flex-direction:column;gap:4px;flex:none;max-height:132px;overflow-y:auto;padding:8px 10px;border-bottom:1px solid var(--mcp-line);background:var(--mcp-bg-2);scrollbar-width:thin}
2178
+ /* div[role=button] 而不是 <button>:行内还要放「删除」按钮,button 套 button 是无效 HTML。
2179
+ 所以这里得把 button 的默认外观一条条抹平(font/border/background/text-align)。 */
2180
+ .suite-row{display:flex;align-items:center;gap:8px;width:100%;padding:7px 9px;border:1px solid transparent;border-radius:var(--mcp-r-md);background:transparent;font-family:inherit;font-size:12.5px;text-align:left;cursor:pointer;transition:background .12s,border-color .12s}
2181
+ .suite-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
2182
+ .suite-row.on{border-color:color-mix(in srgb,var(--mcp-accent) 40%,transparent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2183
+ .suite-row:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
2184
+ .suite-row .ic{flex:none;display:inline-flex;align-items:center;color:var(--mcp-accent)}
2185
+ .suite-row .nm{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;font-weight:550;color:var(--dsw-alias-label-primary)}
2186
+ /* 成员色点叠成一摞:一眼看出这套件里都是哪几类技能(最多 4 个,见 slice(0,4))。 */
2187
+ .suite-row .stack{display:inline-flex;flex:none}
2188
+ .suite-row .stack i{width:9px;height:9px;margin-left:-3px;border:1.5px solid var(--mcp-bg-2);border-radius:3px}
2189
+ .suite-row .stack i:first-child{margin-left:0}
2190
+ .suite-row .ct{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
2191
+ /* 删除是两步确认(点一次变红写「确认?」,2.6s 内再点才真删):平时藏起来,
2192
+ hover 或已武装时常显;武装态用实底红,让「再点一次就没了」这件事不可能被忽略。 */
2193
+ .suite-row .del{display:inline-flex;align-items:center;justify-content:center;flex:none;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:10.5px;cursor:pointer;opacity:0;transition:opacity .12s,background .12s,color .12s}
2194
+ .suite-row:hover .del,.suite-row .del:focus-visible,.suite-row .del.arm{opacity:1}
2195
+ .suite-row .del:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--mcp-err)}
2196
+ .suite-row .del.arm{width:auto;padding:0 7px;background:var(--mcp-err);color:var(--dsw-alias-label-primary-inverted);font-weight:600}
2197
+ .suite-editor{display:flex;flex-direction:column;gap:10px;flex:1;min-height:0;padding:12px 14px 14px}
2198
+ .suite-editor .fields{display:grid;grid-template-columns:1fr;gap:8px;flex:none}
2199
+ .field label{display:block;margin-bottom:4px;font-size:10.5px;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary)}
2200
+ .field input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12.5px;outline:0;transition:border-color .15s,box-shadow .15s}
2201
+ .field input::placeholder{color:var(--mcp-mut)}
2202
+ .field input:focus{border-color:var(--mcp-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-accent) 14%,transparent)}
2203
+ /* 成员区:虚线框表达「往这里放东西」,拖拽悬停时整块变 accent 底。 */
2204
+ .member-zone{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;border:1.5px dashed var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-2);transition:border-color .14s,background .14s}
2205
+ .member-zone.over{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
2206
+ .member-zone .zh{display:flex;align-items:center;gap:7px;flex:none;padding:8px 11px;border-bottom:1px dashed var(--mcp-line);font-size:11px;color:var(--dsw-alias-label-tertiary)}
2207
+ .member-zone .zh svg{flex:none;color:var(--mcp-accent)}
2208
+ .member-zone .zh b{font-weight:600;color:var(--dsw-alias-label-secondary)}
2209
+ .member-list{display:flex;flex-direction:column;gap:5px;flex:1;min-height:0;overflow-y:auto;padding:7px;scrollbar-width:thin}
2210
+ .member-row{display:flex;align-items:center;gap:8px;padding:7px 9px;border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:grab;transition:border-color .12s,box-shadow .12s,opacity .12s}
2211
+ .member-row:hover{border-color:var(--dsw-alias-border-l3);box-shadow:var(--dsw-elevation-overlay,0 1px 3px rgba(0,0,0,.1))}
2212
+ .member-row.dragging{opacity:.4}
2213
+ .member-row .idx{flex:none;width:16px;text-align:center;font-size:10px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
2214
+ .member-row .grip{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary);opacity:.6}
2215
+ .member-row .tile{flex:none;width:9px;height:9px;border-radius:3px}
2216
+ .member-row .nm{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:550;color:var(--dsw-alias-label-primary)}
2217
+ .member-row .cat{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary)}
2218
+ /* 宿主注册表里查不到的成员:不删(用户可能只是切了作用域),但如实标成警示色。 */
2219
+ .member-row .cat.gone{color:var(--mcp-warn)}
2220
+ .member-row .rm{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;transition:background .12s,color .12s}
2221
+ .member-row .rm:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--mcp-err)}
2222
+ /* 拖拽插入指示线:普通行元素而不是独立浮层,成员区因此始终只有一个滚动容器。 */
2223
+ .drop-line{flex:none;height:2px;margin:1px 4px;border-radius:1px;background:var(--mcp-accent)}
2224
+ .member-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:1;padding:22px;color:var(--dsw-alias-label-tertiary);font-size:11.5px;line-height:17px;text-align:center}
2225
+ .member-empty .big{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:14px;background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--mcp-accent)}
2226
+ /* ── 弹窗底栏 ─────────────────────────────────────────────────────── */
2227
+ .suite-foot{display:flex;align-items:center;gap:10px;flex:none;padding:11px 16px;border-top:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
2228
+ .suite-foot .hint{display:flex;align-items:center;gap:6px;min-width:0;flex:1;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
2229
+ .suite-foot .hint svg{flex:none}
2230
+ /* 保存失败与「未保存」互斥显示:失败是阻断信息(红),未保存只是状态提示(橙)。 */
2231
+ .suite-foot .err{flex:none;max-width:44%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--mcp-err)}
2232
+ .suite-foot .dirty{display:inline-flex;align-items:center;gap:5px;flex:none;font-size:11px;color:var(--mcp-warn)}
2233
+ .suite-foot .dirty i{width:6px;height:6px;border-radius:50%;background:var(--mcp-warn)}
2234
+ @media (max-width:900px){.suite-body{grid-template-columns:1fr}.suite-left{max-height:260px;border-right:0;border-bottom:1px solid var(--mcp-line)}.suite-foot .hint{display:none}}
2235
+
2030
2236
  /* 工具裁缝(Tool Tailor):账本表格 + 税单卡 + 死工具清单。 */
2031
2237
  .dsh-mcp-tailor{display:flex;flex-direction:column;gap:12px;min-height:0}
2032
2238
  .dsh-mcp-tailor-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;flex:none}
@@ -2054,10 +2260,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2054
2260
  .dsh-mcp-tailor-empty{color:var(--mcp-mut);font-size:13px;padding:18px 14px}
2055
2261
  .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}
2056
2262
  .dsh-mcp-tailor-note b{color:var(--dsw-alias-label-primary)}
2057
- /* OPT-19 · 4.3:账本缓存占用行(小号弱化文本 + 行内清空按钮),贴在工具条下方。 */
2263
+ /* 账本缓存占用行(只读读数,贴在工具条下方)。行内清空按钮已于 2026-10-23 删除,见
2264
+ lib/client.js 里 cacheLine 的注释;这条只剩文本,所以配套的 .cache-clear 规则一并删掉。 */
2058
2265
  .dsh-mcp-tailor-cache{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11.5px;line-height:16px;color:var(--mcp-mut);padding:2px 2px 0;flex:none}
2059
2266
  .dsh-mcp-tailor-cache-label{color:var(--dsw-alias-label-tertiary)}
2060
- .dsh-mcp-tailor-cache .dsh-mcp-tailor-cache-clear{height:22px;padding:0 10px;font-size:11px;line-height:20px;flex:none}
2061
2267
  /* 解码告警(OPT-02):正常运行时整块不渲染,所以这里可以给足视觉权重。 */
2062
2268
  .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}
2063
2269
  .dsh-mcp-tailor-warn b{color:var(--mcp-warn)}
@@ -2515,6 +2721,11 @@ const ICONS = {
2515
2721
  // grid:网格视图(2×2 方块);list:列表视图(三行点线),Skill 看板视图切换用
2516
2722
  grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
2517
2723
  list: 'M2 3.5h2M6.5 3.5h7.5M2 8h2M6.5 8h7.5M2 12.5h2M6.5 12.5h7.5',
2724
+ // layers:三层堆叠 = 自定义套件(看板入口按钮、召唤菜单套件分组、弹窗空态共用);
2725
+ // grip:六点拖拽手柄,套件弹窗里「这张卡 / 这一行可以拖」的可见线索(16×16 网格,
2726
+ // 用 .2 长的短线段而不是零长路径——零长路径在部分渲染器里画不出圆点)。
2727
+ layers: 'M8 2l6 3.3-6 3.4-6-3.4zM2.4 8.4L8 11.5l5.6-3.1M2.4 11.1L8 14.2l5.6-3.1',
2728
+ grip: 'M6 3.4v.2M10 3.4v.2M6 7.9v.2M10 7.9v.2M6 12.4v.2M10 12.4v.2',
2518
2729
  };
2519
2730
 
2520
2731
  // ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
@@ -4754,6 +4965,12 @@ const SKILL_SOURCE_LABELS = {
4754
4965
  };
4755
4966
  const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
4756
4967
 
4968
+ // 可卸载来源白名单:与宿主 lib/skill-remove.js 的 REMOVABLE_SKILL_SOURCES 一一对应,
4969
+ // 改一处必须改两处。bundled / runtime / custom 不在这里——前三者的去处不可信或不该由
4970
+ // 面板删(宿主侧仍会二次校验,客户端判定只决定「按不按钮」)。
4971
+ const SKILL_REMOVABLE_SOURCES = new Set(['user-agents', 'user-dsh', 'project-agents', 'project-dsh']);
4972
+ const skillRemovable = (source) => SKILL_REMOVABLE_SOURCES.has(String(source || ''));
4973
+
4757
4974
  // ── Skill 分类升级(决策 6/10):6 类 + 未分类兜底,两段式强/弱特征词匹配 ─────────
4758
4975
  // 为什么两段式:中英混排描述里 code/image/frontend 这类词跨类污染严重,单层表会把
4759
4976
  // imagegen-frontend-* 误判成「编程开发」。先按 SKILL_CAT_MATCH_ORDER 整体扫强特征词
@@ -4862,8 +5079,6 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4862
5079
  // 零调用的候选项。旧版默认打开「只看零调用」,屏幕上必然整列 0,看上去像「检测
4863
5080
  // 全坏」——那是筛选口径而不是数据问题。默认关掉,筛选交给用户显式开启。
4864
5081
  const [onlyDead, setOnlyDead] = useState(false);
4865
- // OPT-19 · 4.3:清空缓存是独立动作,busy 态与全局扫描分开,避免清空期间误开「重扫」。
4866
- const [clearing, setClearing] = useState(false);
4867
5082
  const flashCancel = useRef(null);
4868
5083
  const days = Number(windowKey) || 30;
4869
5084
  // 父层先把 30 天窗口扫好了(角标要用)。窗口一致且已有报告时,首次挂载**不必**再扫;
@@ -4929,43 +5144,23 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4929
5144
  }
4930
5145
  };
4931
5146
 
4932
- // OPT-19 · 4.3:解压文本缓存行 + 清空。宿主侧 `action: 'clearTextCache'` 是「先清后扫」
4933
- // (lib/index.js toolTailor 分支),一次调用同时完成清空与新报告,无需再补一发 load。
4934
- const clearCache = async () => {
4935
- if (clearing || busy) return;
4936
- setClearing(true);
4937
- setActionError('');
4938
- try {
4939
- const res = await call('toolTailor', { action: 'clearTextCache', days });
4940
- if (res && res.ok) {
4941
- const freed = res.textCache && typeof res.textCache.clearedBytes === 'number' ? res.textCache.clearedBytes : null;
4942
- commitReport(res);
4943
- setActionError('');
4944
- flash(freed !== null ? t('已清空缓存,释放 {0}', [fmtBytes(freed)]) : t('正在清空缓存…'));
4945
- } else {
4946
- setActionError((res && res.error) || t('toolTailor 调用失败'));
4947
- }
4948
- } catch (e) {
4949
- setActionError(String((e && e.message) || e));
4950
- } finally {
4951
- setClearing(false);
4952
- }
4953
- };
4954
-
4955
- // OPT-19:只有报告带了 textCache 快照(Wave 1-A 起宿主才有)才渲染整行;旧报告整行缺省。
5147
+ // 解压文本缓存只留读数,「清空缓存」按钮与它的整条动作链已删除(2026-10-23 实测):
5148
+ // · 反馈永远错:宿主 toolTailor 分支把 clearTextCache() 的返回值丢了,报告里的
5149
+ // textCache 来自 textCacheStats(),根本没有 clearedBytes 这个键 → 面板成功分支只能落到
5150
+ // 兜底文案,点完之后浮层恒显示「正在清空缓存…」,看着像还在忙;
5151
+ // · 占用不会降:那是「先清后扫」,快照取在扫描**之后**,同一发调用里 92 个日志重新解压
5152
+ // 回填,实测按下去前后都是 32 条 / 65.8 MB;
5153
+ // · 而且这台机器上缓存本来就没在起作用:槽位 32、日志 92 个,淘汰按插入序而每轮按同序
5154
+ // 遍历全量,命中率 = max(0, 2×槽位 − 文件数) ÷ 文件数 = 0%(冷扫 1797ms / 热扫 1881ms)。
5155
+ // 结论是这个动作既不回收内存也不修任何东西,删掉。合成分 utilization(取字节与条数占比的
5156
+ // 较大者,本机恒 100%)一并从这行去掉 —— 它原本就是用来把用户推向这个已经不存在的按钮。
4956
5157
  const cacheLine = report && report.textCache && typeof report.textCache.entries === 'number'
4957
5158
  ? (() => {
4958
5159
  const cache = report.textCache;
4959
5160
  const fmtI = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—');
4960
5161
  return h('div', { className: 'dsh-mcp-tailor-cache' },
4961
5162
  h('span', { className: 'dsh-mcp-tailor-cache-label' }, t('账本缓存')),
4962
- h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条') + ' · ' + fmtPct(cache.utilization)),
4963
- h('button', {
4964
- type: 'button',
4965
- className: 'dsh-mcp-btn dsh-mcp-tailor-cache-clear',
4966
- onClick: clearCache,
4967
- disabled: clearing || busy,
4968
- }, clearing ? t('正在清空缓存…') : t('清空缓存')),
5163
+ h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条')),
4969
5164
  );
4970
5165
  })()
4971
5166
  : null;
@@ -4995,13 +5190,24 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4995
5190
  ? t('{0} 个日志帧解码失败,调用计数可能偏低', [tailorNum(report.failedFrames, report)])
4996
5191
  : '';
4997
5192
  const scanFailures = report && Array.isArray(report.failedFileSamples) ? report.failedFileSamples : [];
5193
+ // §6.2 无 time 的调用事件:账本对这类事件的口径是「计进当前窗口」(见 tool-ledger.js 头注)。
5194
+ // 现在恒为 0,所以这行**不渲染**;等宿主日志格式一改、它变成非 0,界面必须当场说清楚
5195
+ // 「切窗口对这部分不生效」,否则症状又会退化成这次这种「数字看着不动但没人知道为什么」。
5196
+ const undatedWarning = report && report.undatedCalls > 0
5197
+ ? t('{0} 条调用事件没有可用的 time 字段,无法判定落在哪个窗口,已全部计入当前窗口(切换时间窗对这部分不生效)。', [fmtInt(report.undatedCalls)])
5198
+ : '';
5199
+ const truncatedWarning = report && report.wireTaxTruncated === true
5200
+ ? t('窗口内真实下发清单的种类数超过上限,税趋势线只覆盖前若干种。')
5201
+ : '';
4998
5202
  // 正常运行时这一段**完全不渲染**(无噪音),只有真出问题才占位。
4999
- const decodeIssue = decodeWarning || scanFailures.length
5203
+ const decodeIssue = decodeWarning || scanFailures.length || undatedWarning || truncatedWarning
5000
5204
  ? h('div', { className: 'dsh-mcp-tailor-warn' },
5001
5205
  decodeWarning ? h('div', null, h('b', null, t('⚠ 数据可能不完整:')), decodeWarning) : null,
5002
5206
  report && report.zstdSupported === false
5003
5207
  ? h('div', null, t('当前运行在 Node {0},请升级到 22.15 以上后重启宿主。', [report.nodeVersion || t('(未知版本)')]))
5004
5208
  : null,
5209
+ undatedWarning ? h('div', null, undatedWarning) : null,
5210
+ truncatedWarning ? h('div', null, truncatedWarning) : null,
5005
5211
  scanFailures.length
5006
5212
  ? h('details', { className: 'dsh-mcp-tailor-details' },
5007
5213
  h('summary', null, t('{0} 个日志文件未能读取(含超限跳过),展开看原因', [tailorNum(report.failedFiles, report)])),
@@ -5011,6 +5217,31 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5011
5217
  )
5012
5218
  : null;
5013
5219
 
5220
+ // §6.4 两条「为什么这个数字不动」的归因,合成一条 note(无话可说时整块不渲染):
5221
+ // ① 窗口比本机日志还长 → 加长窗口必然无变化(实测这台机器只有 14 天日志,30 天与 90 天
5222
+ // 输出一字不差,而界面此前对此毫无提示);
5223
+ // ② 注册表快照与最近一次真实下发差得多 → 改了配置没生效 / 工具挂在别的作用域里读不到。
5224
+ // 阈值取「>500 tok 且 >5%」:Schema 长度的口径差有几十 tok 的正常噪声,压在阈值里就不会
5225
+ // 变成一条常驻假告警(假告警会把真告警教成背景噪音)。
5226
+ const attribution = (() => {
5227
+ if (!report) return null;
5228
+ const lines = [];
5229
+ if (typeof report.scannedSpanDays === 'number' && report.scannedSpanDays > 0 && report.windowDays > report.scannedSpanDays) {
5230
+ lines.push(t('窗口 {0} 天,但读到的调用记录只跨 {1} 天(最早一条是 {2});更早的部分本机没有日志,加长窗口不会改变任何数字。', [
5231
+ fmtInt(report.windowDays), fmtInt(report.scannedSpanDays), fmtLast(report.scannedEventsOldestMs, report.generatedAt),
5232
+ ]));
5233
+ }
5234
+ const divergence = report.wireTaxDivergenceTokens;
5235
+ if (typeof divergence === 'number' && report.taxTotalTokens > 0 && Math.abs(divergence) > Math.max(500, report.taxTotalTokens * 0.05)) {
5236
+ lines.push(divergence > 0
5237
+ ? t('注册表快照比最近一次真实下发多 ~{0} tok:多半是改完配置还没重启宿主,或项目级工具还没在当前会话注册。', [fmtInt(divergence)])
5238
+ : t('最近一次真实下发比注册表快照多 ~{0} tok:当前会话的工具集与面板读到的全局注册表不一致(可能来自项目级或子 Agent 作用域)。', [fmtInt(-divergence)]));
5239
+ }
5240
+ if (!lines.length) return null;
5241
+ return h('div', { className: 'dsh-mcp-tailor-note' }, lines.map((line, i) => h('div', { key: i }, line)));
5242
+ })();
5243
+
5244
+
5014
5245
  // §5.2 头部指标分层:原来四张同权重卡把「上下文税」和「调用总数」摆成一样大,
5015
5246
  // 于是用户读完四个数字仍然不知道最该动什么。真正需要行动的是「每轮白缴多少税」,
5016
5247
  // 它才是本页存在的理由;调用数只是背景。因此税单独放大居首,其余三个降为次级 KPI 行。
@@ -5018,6 +5249,15 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5018
5249
  // 画一排等高灰条假装有数据,比不画更误导。
5019
5250
  const daily = report && Array.isArray(report.dailyCalls) ? report.dailyCalls : [];
5020
5251
  const hasTrend = daily.length > 0 && daily.some((v) => v > 0);
5252
+ // §6.1 真实下发税。上面那个主数字(上下文税)是「当前注册表 Schema 长度」的纯函数,与时间
5253
+ // 无关 —— 于是「连着几天一模一样」既可能是真的没改过,也可能是界面读不出变化,两者在屏幕上
5254
+ // 长得完全一样(这正是本次排查的起因)。日志里每次请求都落了一份真实下发的工具清单,按天
5255
+ // 采样后税值第一次有了时间形状:变化 0 次 = 数字不动是事实,不是裁缝坏了。
5256
+ const wireDaily = report && Array.isArray(report.wireTaxDaily) ? report.wireTaxDaily : [];
5257
+ const hasWireTax = wireDaily.length > 0 && wireDaily.some((v) => v > 0);
5258
+ const wireLatest = report ? report.wireTaxLatest : null;
5259
+ const wireChanges = report && typeof report.wireTaxChangeCount === 'number' ? report.wireTaxChangeCount : null;
5260
+
5021
5261
  const errRate = report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0;
5022
5262
  // 错误率阈值按文档口径:<5% 是常态(绿),≥5% 才值得红着显示。
5023
5263
  const errTone = errRate >= 0.05 ? 'err' : 'ok';
@@ -5035,6 +5275,14 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5035
5275
  : t('扫描后给出'),
5036
5276
  ),
5037
5277
  hasTrend ? h('div', { className: 'dsh-mcp-tailor-trend' }, h(Spark, { series: daily, title: t('近 {0} 天每日调用量(左旧右新)', [days]) }), h('span', null, t('近 {0} 天每日调用', [days]))) : null,
5278
+ // §6.1 第二行趋势:真实下发税 + 清单变化次数 + 最近一次下发的规模。三件事合成一行,
5279
+ // 免得「每日调用」和「每轮税」两张 sparkline 各自带一句标签,把 hero 卡撑散。
5280
+ hasWireTax ? h('div', { className: 'dsh-mcp-tailor-trend' },
5281
+ h(Spark, { series: wireDaily, title: t('近 {0} 天每轮真实下发的工具税(左旧右新)', [days]) }),
5282
+ h('span', null, wireChanges === 1 ? t('每轮下发清单在 {0} 天内一次没变', [days]) : t('每轮下发清单变化 {0} 次', [wireChanges])),
5283
+ wireLatest ? h('span', null, t('最近一次 {0} 个工具 ~{1} tok({2})', [fmtInt(wireLatest.toolCount), fmtInt(wireLatest.tokens), fmtLast(wireLatest.atMs, report.generatedAt)])) : null,
5284
+ ) : null,
5285
+
5038
5286
  ),
5039
5287
  h('div', { className: 'dsh-mcp-tailor-kpis' },
5040
5288
  // OPT-18:四张 KPI 的数字都随报告来,zstd 不可用时 0 是「测不到」→ 统一 tailorNum。
@@ -5063,11 +5311,13 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
5063
5311
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, t('重扫')),
5064
5312
  h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
5065
5313
  ),
5066
- // OPT-19 · 4.3:缓存占用 + 一键清空(先清后扫,一次调用拿新报告)。
5314
+ // 账本缓存读数(只读,动作已删;见 cacheLine 上方注释)。
5067
5315
  cacheLine,
5068
5316
  error ? h('div', { className: 'dsh-mcp-tailor-note' }, t('账本加载失败:'), fmtScanError(error)) : null,
5069
5317
  // 解码告警放在数据和表格**上方**:调用数整列 0 时,用户第一眼要能看到「为什么」。
5070
5318
  decodeIssue,
5319
+ attribution,
5320
+
5071
5321
  actionError ? h('div', { className: 'dsh-mcp-tailor-note' }, actionError) : null,
5072
5322
  flashMsg ? h('div', { className: 'dsh-mcp-tailor-note' }, flashMsg) : null,
5073
5323
  h('div', { className: 'dsh-mcp-tailor-table' },
@@ -5169,6 +5419,503 @@ const CatHintPopover = ({ hint, category, counts }) => {
5169
5419
  category !== hint.key ? h('div', { className: 'dsh-mcp-cat-pop-foot' }, t('点击切换到此分类')) : null,
5170
5420
  );
5171
5421
  };
5422
+ // ── 自定义套件(Skill 看板):管理弹窗 + 跨组件打开总线 ─────────────────────
5423
+ // 一个套件 = 一组相关 skill 的**有序**命名组合;在输入框的引用菜单里点一次套件,等于把
5424
+ // 成员的 /name 逐个插进草稿(纯插件侧展开,宿主零改动)。数据层 lib/suites-registry.js
5425
+ // (~/.dsh/armory/suites.json),通道 listSkillSuites / saveSkillSuites。
5426
+ // 两条必须遵守的分工:
5427
+ // ① 保存是**整集合覆盖**:右侧列表就是全部真相,删除 = 从列表里拿掉再保存;
5428
+ // ② id 由**数据层**分配:新建条目在本地只有 `local-N` 临时 id(只为 React key 与选中态
5429
+ // 服务),提交前置空,保存成功后以返回值刷新看板副本——拿旧副本再存一次,同一套件
5430
+ // 会分裂成两条。
5431
+ // save 的传输错误翻译复用紧随其后的 skillTransportHint(404 = 宿主还没重启)。
5432
+ const SUITE_LOCAL_ID_PREFIX = 'local-';
5433
+ // 与 lib/suites-registry.js 的同名常量同值:写侧对超长是「放行 + 截断」,这里在输入时就
5434
+ // 限住,免得用户写满两百字、保存后才发现被截断。改一处请同时改另一处。
5435
+ const SUITE_NAME_MAX = 40;
5436
+ const SUITE_DESC_MAX = 120;
5437
+ const SUITE_MEMBERS_MAX = 40;
5438
+ const SUITES_MAX = 100;
5439
+
5440
+ // 打开弹窗的两段式总线(与 pendingSkillFocus / pendingBoardCategory 完全同一套语义):
5441
+ // 召唤菜单的「管理套件…」也要能开这个弹窗,而弹窗挂在 SkillBoard 里。看板已挂载 → 直接
5442
+ // 广播;没挂载(用户在会话界面点的)→ 意图先记在模块里,SkillBoard 挂载后自领,随后
5443
+ // openBoardCategory('skill') 把看板切到 Skill 分类(它自己会 selectPanel,不必再开一次)。
5444
+ let pendingSuiteOpen = false;
5445
+ const suiteOpenListeners = new Set();
5446
+ function openSuiteManager() {
5447
+ pendingSuiteOpen = true;
5448
+ for (const listener of [...suiteOpenListeners]) {
5449
+ try { listener(); } catch { /* 单个订阅者失败不影响弹窗本身 */ }
5450
+ }
5451
+ openBoardCategory('skill');
5452
+ }
5453
+ const onSuiteOpenRequest = (listener) => {
5454
+ suiteOpenListeners.add(listener);
5455
+ return () => { suiteOpenListeners.delete(listener); };
5456
+ };
5457
+
5458
+ // 提交给数据层的形状:本地临时 id 置空(写侧分配真 id),已有的 id / 时间戳原样带上
5459
+ // (updatedAt 由调用方决定 → 同一 payload 重放得到同一结果,写侧幂等)。
5460
+ const suitePayloadOf = (rows) => (Array.isArray(rows) ? rows : []).map((suite) => ({
5461
+ id: String(suite.id || '').startsWith(SUITE_LOCAL_ID_PREFIX) ? '' : String(suite.id || ''),
5462
+ name: String(suite.name || ''),
5463
+ desc: String(suite.desc || ''),
5464
+ members: Array.isArray(suite.members) ? [...suite.members] : [],
5465
+ ...(suite.createdAt ? { createdAt: suite.createdAt } : {}),
5466
+ ...(suite.updatedAt ? { updatedAt: suite.updatedAt } : {}),
5467
+ }));
5468
+ // 工作副本的一条:字段全部拉成确定形状,编辑时不必再逐处判空。
5469
+ const cloneSuiteRow = (suite) => ({
5470
+ id: String(suite?.id || ''),
5471
+ name: String(suite?.name || ''),
5472
+ desc: String(suite?.desc || ''),
5473
+ members: Array.isArray(suite?.members) ? suite.members.map((name) => String(name)) : [],
5474
+ createdAt: Number(suite?.createdAt) || 0,
5475
+ updatedAt: Number(suite?.updatedAt) || 0,
5476
+ });
5477
+
5478
+ const SuiteManager = ({ call, skills, catOf, suites, onClose, onSaved, notify }) => {
5479
+ const skillRows = Array.isArray(skills) ? skills : [];
5480
+ // 弹窗内所有编辑都落在 draft 上,「保存」才整集合提交;取消 = 直接丢弃 draft。
5481
+ // 只在挂载时从 props 拷一次:弹窗打开期间唯一的写入者就是它自己,不需要反向同步。
5482
+ const [draft, setDraft] = useState(() => (Array.isArray(suites) ? suites.map(cloneSuiteRow) : []));
5483
+ const [selId, setSelId] = useState(() => (Array.isArray(suites) && suites.length ? String(suites[0].id || '') : ''));
5484
+ const [pickCat, setPickCat] = useState('all');
5485
+ const [pickQuery, setPickQuery] = useState('');
5486
+ const [dirty, setDirty] = useState(false);
5487
+ const [saving, setSaving] = useState(false);
5488
+ const [saveError, setSaveError] = useState('');
5489
+ const [flashMsg, setFlashMsg] = useState(null); // { msg, ok }:弹窗内的小反馈
5490
+ const [armDel, setArmDel] = useState(''); // 两段式删除:已 arm 的套件 id
5491
+ const [dropIndex, setDropIndex] = useState(null); // 拖拽插入位(null = 不画指示线)
5492
+ const [zoneOver, setZoneOver] = useState(false);
5493
+ const [dragging, setDragging] = useState(''); // 拖动中的来源标记(半透明视觉)
5494
+ const dragRef = useRef(null); // { kind: 'skill', name } | { kind: 'member', index }
5495
+ const localSeq = useRef(0);
5496
+ const flashTimer = useRef(null);
5497
+ const armTimer = useRef(null);
5498
+ const nameRef = useRef(null);
5499
+ const modalRef = useRef(null);
5500
+ useFocusTrap(modalRef, true);
5501
+ useEffect(() => () => {
5502
+ if (flashTimer.current) clearTimeout(flashTimer.current);
5503
+ if (armTimer.current) clearTimeout(armTimer.current);
5504
+ }, []);
5505
+
5506
+ const flash = (msg, ok = true) => {
5507
+ setFlashMsg({ msg, ok });
5508
+ if (flashTimer.current) clearTimeout(flashTimer.current);
5509
+ flashTimer.current = setTimeout(() => setFlashMsg(null), 2400);
5510
+ };
5511
+
5512
+ // 分类:优先用看板已经算好的 catOf(同一份映射,两处判定不会漂移),缺席时现算。
5513
+ const skillByName = useMemo(() => {
5514
+ const map = new Map();
5515
+ for (const s of skillRows) map.set(s.name, s);
5516
+ return map;
5517
+ }, [skills]);
5518
+ const catFor = (skill) => (skill ? ((catOf && catOf.get(skill.name)) || skillCategoryOf(skill)) : SKILL_UNCAT);
5519
+ const catOfName = (name) => catFor(skillByName.get(name));
5520
+
5521
+ // 左栏:分类轨道计数固定全量(与看板决策 9 同口径,不随搜索联动),列表随搜索/分类筛。
5522
+ const pickCounts = useMemo(() => {
5523
+ const list = Array.isArray(skills) ? skills : [];
5524
+ const map = new Map([['all', list.length]]);
5525
+ for (const s of list) {
5526
+ const cat = (catOf && catOf.get(s.name)) || skillCategoryOf(s);
5527
+ map.set(cat.key, (map.get(cat.key) || 0) + 1);
5528
+ }
5529
+ return map;
5530
+ }, [skills, catOf]);
5531
+ const pickList = useMemo(() => {
5532
+ const list = Array.isArray(skills) ? skills : [];
5533
+ const q = pickQuery.trim().toLowerCase();
5534
+ return list.filter((s) => {
5535
+ if (pickCat !== 'all') {
5536
+ const cat = (catOf && catOf.get(s.name)) || skillCategoryOf(s);
5537
+ if (cat.key !== pickCat) return false;
5538
+ }
5539
+ if (q && !(String(s.name || '') + ' ' + String(s.description || '')).toLowerCase().includes(q)) return false;
5540
+ return true;
5541
+ });
5542
+ }, [skills, catOf, pickCat, pickQuery]);
5543
+
5544
+ const cur = draft.find((s) => s.id === selId) || null;
5545
+ const curMembers = cur && Array.isArray(cur.members) ? cur.members : [];
5546
+
5547
+ const patchCur = (patch) => {
5548
+ if (!cur) return;
5549
+ setDraft((prev) => prev.map((s) => (s.id === cur.id ? { ...s, ...patch } : s)));
5550
+ setDirty(true);
5551
+ };
5552
+
5553
+ // 无拖拽替代路径(也是唯一的键盘路径):点卡片上的 + 把 skill 加进当前套件。
5554
+ const addMember = (name, at) => {
5555
+ if (!cur) { flash(t('先在右侧选择一个套件,或点「新建套件」'), false); return; }
5556
+ if (curMembers.includes(name)) { flash(t('「{0}」已在这个套件里', [name]), false); return; }
5557
+ if (curMembers.length >= SUITE_MEMBERS_MAX) { flash(t('成员已达上限 {0} 个', [SUITE_MEMBERS_MAX]), false); return; }
5558
+ const members = [...curMembers];
5559
+ members.splice(typeof at === 'number' && at >= 0 && at <= members.length ? at : members.length, 0, name);
5560
+ patchCur({ members });
5561
+ flash(t('已加入「{0}」', [name]));
5562
+ };
5563
+ const removeMember = (index) => {
5564
+ if (!cur) return;
5565
+ patchCur({ members: curMembers.filter((_, i) => i !== index) });
5566
+ };
5567
+ const moveMember = (from, to) => {
5568
+ if (!cur || from < 0 || from >= curMembers.length) return;
5569
+ const members = [...curMembers];
5570
+ const [moved] = members.splice(from, 1);
5571
+ // 指示线下标是按**移动前**的数组算的:来源在目标之前时,抽出来源后目标整体前移一位。
5572
+ const target = Math.max(0, Math.min(from < to ? to - 1 : to, members.length));
5573
+ members.splice(target, 0, moved);
5574
+ patchCur({ members });
5575
+ };
5576
+
5577
+ // ── 拖拽(HTML5 DnD):载荷真源是 dragRef,dataTransfer 只作跨容器兜底 ─────────
5578
+ const startSkillDrag = (e, name) => {
5579
+ dragRef.current = { kind: 'skill', name };
5580
+ setDragging('skill:' + name);
5581
+ try {
5582
+ if (e.dataTransfer) { e.dataTransfer.setData('text/plain', name); e.dataTransfer.effectAllowed = 'copy'; }
5583
+ } catch { /* 个别浏览器限制 setData 时机;本组件不依赖它 */ }
5584
+ };
5585
+ const startMemberDrag = (e, index) => {
5586
+ dragRef.current = { kind: 'member', index };
5587
+ setDragging('member:' + index);
5588
+ try {
5589
+ if (e.dataTransfer) { e.dataTransfer.setData('text/plain', String(index)); e.dataTransfer.effectAllowed = 'move'; }
5590
+ } catch { /* 同上:dragRef 是真源 */ }
5591
+ };
5592
+ const endDrag = () => { dragRef.current = null; setDragging(''); setDropIndex(null); setZoneOver(false); };
5593
+ const onRowDragOver = (e, index) => {
5594
+ e.preventDefault();
5595
+ const rect = e.currentTarget.getBoundingClientRect();
5596
+ setZoneOver(true);
5597
+ setDropIndex(e.clientY < rect.top + rect.height / 2 ? index : index + 1);
5598
+ };
5599
+ const onZoneDrop = (e) => {
5600
+ e.preventDefault();
5601
+ const payload = dragRef.current;
5602
+ const at = dropIndex === null ? curMembers.length : dropIndex;
5603
+ endDrag();
5604
+ if (!payload || !cur) return;
5605
+ if (payload.kind === 'skill') addMember(payload.name, at);
5606
+ else moveMember(payload.index, at);
5607
+ };
5608
+
5609
+ const newSuite = () => {
5610
+ if (draft.length >= SUITES_MAX) { flash(t('套件数量已达上限 {0} 个', [SUITES_MAX]), false); return; }
5611
+ localSeq.current += 1;
5612
+ const id = SUITE_LOCAL_ID_PREFIX + localSeq.current;
5613
+ setDraft((prev) => [...prev, { id, name: '', desc: '', members: [], createdAt: 0, updatedAt: 0 }]);
5614
+ setSelId(id);
5615
+ setDirty(true);
5616
+ setArmDel('');
5617
+ setSaveError('');
5618
+ // 名称框要等这一帧渲染完才存在(编辑器随 selId 重渲染),聚焦放到下一帧。
5619
+ requestAnimationFrame(() => { if (nameRef.current) nameRef.current.focus(); });
5620
+ };
5621
+ // 两段式删除:第一次点变「确认?」(2.6s 后自动收回),第二次点才真删。删除只改 draft,
5622
+ // 落盘要等「保存」——所以文案是「保存后生效」,误删可以在关窗前反悔(取消即回滚)。
5623
+ const armDelete = (id) => {
5624
+ if (armDel !== id) {
5625
+ setArmDel(id);
5626
+ if (armTimer.current) clearTimeout(armTimer.current);
5627
+ armTimer.current = setTimeout(() => setArmDel(''), 2600);
5628
+ return;
5629
+ }
5630
+ if (armTimer.current) clearTimeout(armTimer.current);
5631
+ setArmDel('');
5632
+ const next = draft.filter((s) => s.id !== id);
5633
+ setDraft(next);
5634
+ if (selId === id) setSelId(next.length ? next[0].id : '');
5635
+ setDirty(true);
5636
+ flash(t('已删除套件(保存后生效)'), false);
5637
+ };
5638
+
5639
+ const attemptClose = () => {
5640
+ if (saving) return;
5641
+ // 「已放弃未保存的修改」必须由看板来说:弹窗一关自己就卸载了,反馈留在弹窗里等于没有。
5642
+ if (dirty && typeof notify === 'function') notify(t('已放弃未保存的修改'), false);
5643
+ onClose();
5644
+ };
5645
+
5646
+ const save = async () => {
5647
+ // 前置校验与写侧同口径(无名 / 空成员都属「拒绝」档),先在界面上挡住并跳到那一条:
5648
+ // 写侧的错误消息里只有序号,用户还得自己数是第几条。
5649
+ const unnamed = draft.find((s) => !String(s.name || '').trim());
5650
+ if (unnamed) {
5651
+ setSelId(unnamed.id);
5652
+ flash(t('有套件还没起名'), false);
5653
+ requestAnimationFrame(() => { if (nameRef.current) nameRef.current.focus(); });
5654
+ return;
5655
+ }
5656
+ const hollow = draft.find((s) => !Array.isArray(s.members) || !s.members.length);
5657
+ if (hollow) {
5658
+ setSelId(hollow.id);
5659
+ flash(t('套件「{0}」还没有成员', [hollow.name]), false);
5660
+ return;
5661
+ }
5662
+ setSaving(true);
5663
+ setSaveError('');
5664
+ let res = null;
5665
+ try {
5666
+ res = await call('saveSkillSuites', { suites: suitePayloadOf(draft) });
5667
+ } catch (e) {
5668
+ setSaving(false);
5669
+ setSaveError(skillTransportHint(String((e && e.message) || e)));
5670
+ return;
5671
+ }
5672
+ if (res && res.ok) {
5673
+ const saved = Array.isArray(res.suites) ? res.suites : [];
5674
+ setSaving(false);
5675
+ setDirty(false);
5676
+ // 先把返回值交出去再关窗:新分配的 id 只在这里出现,看板用它刷新副本与计数徽标。
5677
+ if (typeof onSaved === 'function') onSaved(saved);
5678
+ if (typeof notify === 'function') notify(t('套件已保存({0} 个)', [saved.length]), true);
5679
+ onClose();
5680
+ return;
5681
+ }
5682
+ setSaving(false);
5683
+ setSaveError(skillTransportHint((res && res.error) || t('保存失败:宿主没有回包')));
5684
+ };
5685
+
5686
+ // 成员行 + 拖拽插入指示线:指示线是普通行元素(不是独立容器),成员区始终只有一个滚动区。
5687
+ const memberCells = [];
5688
+ curMembers.forEach((name, index) => {
5689
+ if (dropIndex === index) memberCells.push(h('div', { key: '__dl__' + index, className: 'drop-line' }));
5690
+ const cat = catOfName(name);
5691
+ // 注册表里查不到 = 该 skill 已被移动 / 删除 / 不在当前作用域。成员保留(用户可能只是
5692
+ // 暂时切了作用域),但如实标「已失效」——引用时宿主插进去的 /name 命中不了任何技能。
5693
+ const missing = !skillByName.has(name);
5694
+ memberCells.push(h('div', {
5695
+ key: name,
5696
+ className: 'member-row' + (dragging === 'member:' + index ? ' dragging' : ''),
5697
+ draggable: true,
5698
+ title: missing ? t('宿主当前找不到这个 skill(可能已移动或删除),引用时会失效') : name,
5699
+ onDragStart: (e) => startMemberDrag(e, index),
5700
+ onDragEnd: endDrag,
5701
+ onDragOver: (e) => onRowDragOver(e, index),
5702
+ },
5703
+ h('span', { className: 'idx' }, index + 1),
5704
+ h('span', { className: 'grip', 'aria-hidden': true }, h(Icon, { d: ICONS.grip, size: 11 })),
5705
+ h('span', { className: 'tile', style: { background: cat.color } }),
5706
+ h('span', { className: 'nm' }, name),
5707
+ h('span', { className: 'cat' + (missing ? ' gone' : '') }, missing ? t('已失效') : cat.name),
5708
+ h('button', {
5709
+ type: 'button', className: 'rm', title: t('移除'), 'aria-label': t('移除 {0}', [name]),
5710
+ onClick: () => removeMember(index),
5711
+ }, h(Icon, { d: ICONS.x, size: 10 })),
5712
+ ));
5713
+ });
5714
+ if (cur && dropIndex === curMembers.length) memberCells.push(h('div', { key: '__dl__end', className: 'drop-line' }));
5715
+
5716
+ return h('div', {
5717
+ className: 'dsh-mcp-overlay',
5718
+ onMouseDown: (e) => { if (e.target === e.currentTarget) attemptClose(); },
5719
+ },
5720
+ h('div', {
5721
+ ref: modalRef,
5722
+ className: 'dsh-mcp-modal suites',
5723
+ role: 'dialog',
5724
+ 'aria-modal': true,
5725
+ 'aria-label': t('套件管理'),
5726
+ tabIndex: -1,
5727
+ onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); attemptClose(); } },
5728
+ },
5729
+ h('header', { className: 'suite-head' },
5730
+ h('div', { className: 'suite-head-text' },
5731
+ h('h3', null, t('套件管理')),
5732
+ h('p', { className: 'sub' }, t('把多个相关 skill 打包成套件;在输入框的引用菜单里点一次套件,等于同时引用全部成员。')),
5733
+ ),
5734
+ h('button', {
5735
+ type: 'button', className: 'suite-x', title: t('关闭'), 'aria-label': t('关闭'),
5736
+ disabled: saving, onClick: attemptClose,
5737
+ }, h(Icon, { d: ICONS.x, size: 14 })),
5738
+ ),
5739
+ h('div', { className: 'suite-body' },
5740
+ // ── 左:技能库选择器(分类轨道 + 搜索 + 可拖卡片)──
5741
+ h('div', { className: 'suite-left' },
5742
+ h('label', { className: 'suite-search' },
5743
+ h(Icon, { d: ICONS.search, size: 13 }),
5744
+ h('input', {
5745
+ value: pickQuery,
5746
+ placeholder: t('搜索 skill…'),
5747
+ 'aria-label': t('搜索 skill'),
5748
+ onChange: (e) => setPickQuery(e.target.value),
5749
+ }),
5750
+ ),
5751
+ h('div', { className: 'dsh-mcp-skill-rail', role: 'tablist', 'aria-label': t('按分类过滤 skill') },
5752
+ SKILL_CATS.map((c) => h('button', {
5753
+ key: c.key,
5754
+ type: 'button',
5755
+ role: 'tab',
5756
+ 'aria-selected': pickCat === c.key,
5757
+ className: 'dsh-mcp-skill-cat' + (pickCat === c.key ? ' on' : ''),
5758
+ onClick: () => setPickCat(c.key),
5759
+ },
5760
+ h('span', { className: 'dsh-mcp-skill-cat-dot', style: { background: c.color } }),
5761
+ c.name,
5762
+ h('span', { className: 'dsh-mcp-skill-cat-count' }, pickCounts.get(c.key) || 0),
5763
+ ))),
5764
+ skillRows.length === 0
5765
+ ? h('div', { className: 'member-empty' },
5766
+ h('span', { className: 'big' }, h(Icon, { d: ICONS.skill, size: 20 })),
5767
+ t('看板没有读到 skill 列表'),
5768
+ t('宿主未挂载 skill 注册表(@deepseek-ai/dsh-skill)时这里就是空的;套件仍能保存,但成员名要自己保证正确。'))
5769
+ : h('div', { className: 'suite-pick-list' },
5770
+ pickList.length === 0
5771
+ ? h('div', { className: 'member-empty' }, t('没有符合筛选的 skill'))
5772
+ : pickList.map((s) => {
5773
+ const cat = catFor(s);
5774
+ const inSuite = curMembers.includes(s.name);
5775
+ return h('div', {
5776
+ key: s.name,
5777
+ className: 'suite-pick-card' + (inSuite ? ' in-suite' : '') + (dragging === 'skill:' + s.name ? ' dragging' : ''),
5778
+ draggable: !!cur,
5779
+ title: inSuite ? t('已在当前套件中') : t('拖到右侧成员区,或点 + 添加'),
5780
+ onDragStart: (e) => startSkillDrag(e, s.name),
5781
+ onDragEnd: endDrag,
5782
+ },
5783
+ h('span', { className: 'tile', style: { background: cat.color } }),
5784
+ h('span', { className: 'body' },
5785
+ h('span', { className: 'nm' }, s.name, h('span', { className: 'cat' }, cat.name)),
5786
+ h('span', { className: 'ds' }, s.description || t('(无描述)')),
5787
+ ),
5788
+ h('span', { className: 'grip', 'aria-hidden': true }, h(Icon, { d: ICONS.grip, size: 12 })),
5789
+ h('button', {
5790
+ type: 'button',
5791
+ className: 'add',
5792
+ disabled: !cur,
5793
+ title: inSuite ? t('已在套件中') : t('添加到当前套件'),
5794
+ 'aria-label': t('添加到当前套件'),
5795
+ onClick: (e) => { e.stopPropagation(); addMember(s.name); },
5796
+ }, inSuite ? '✓' : h(Icon, { d: ICONS.plus, size: 11 })),
5797
+ );
5798
+ })),
5799
+ ),
5800
+ // ── 右:套件列表 + 编辑器 ──
5801
+ h('div', { className: 'suite-right' },
5802
+ h('div', { className: 'suite-list-bar' },
5803
+ h('span', { className: 't' }, t('我的套件')),
5804
+ h('span', { className: 'sp' }),
5805
+ h(Btn, {
5806
+ cls: 'suite-new', icon: 'plus', disabled: saving, onClick: newSuite,
5807
+ title: t('新建一个空套件'), children: t('新建套件'),
5808
+ }),
5809
+ ),
5810
+ h('div', { className: 'suite-list' },
5811
+ draft.length === 0
5812
+ ? h('div', { className: 'member-empty' }, t('还没有套件:点右上「新建套件」开始'))
5813
+ : draft.map((s) => h('div', {
5814
+ key: s.id,
5815
+ className: 'suite-row' + (s.id === selId ? ' on' : ''),
5816
+ // 行本身是 div[role=button] 而不是 <button>:里面还要放「删除」按钮,
5817
+ // button 套 button 是无效 HTML(浏览器会拆节点,React 的树跟着错位)。
5818
+ role: 'button',
5819
+ tabIndex: 0,
5820
+ 'aria-pressed': s.id === selId,
5821
+ onClick: () => { setSelId(s.id); setArmDel(''); },
5822
+ onKeyDown: (e) => {
5823
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelId(s.id); setArmDel(''); }
5824
+ },
5825
+ },
5826
+ h('span', { className: 'ic' }, h(Icon, { d: ICONS.layers, size: 13 })),
5827
+ h('span', { className: 'nm' }, s.name || t('未命名套件')),
5828
+ h('span', { className: 'stack', 'aria-hidden': true },
5829
+ s.members.slice(0, 4).map((name) => h('i', { key: name, style: { background: catOfName(name).color } }))),
5830
+ h('span', { className: 'ct' }, s.members.length),
5831
+ h('button', {
5832
+ type: 'button',
5833
+ className: 'del' + (armDel === s.id ? ' arm' : ''),
5834
+ disabled: saving,
5835
+ title: armDel === s.id ? t('再点一次确认删除') : t('删除套件'),
5836
+ onClick: (e) => { e.stopPropagation(); armDelete(s.id); },
5837
+ }, armDel === s.id ? t('确认?') : t('删除')),
5838
+ ))),
5839
+ cur
5840
+ ? h('div', { className: 'suite-editor' },
5841
+ h('div', { className: 'fields' },
5842
+ h('div', { className: 'field' },
5843
+ h('label', { htmlFor: 'dsh-suite-name' }, t('套件名称')),
5844
+ h('input', {
5845
+ id: 'dsh-suite-name',
5846
+ ref: nameRef,
5847
+ value: cur.name,
5848
+ maxLength: SUITE_NAME_MAX,
5849
+ placeholder: t('例如:演示视频流水线'),
5850
+ onChange: (e) => patchCur({ name: e.target.value }),
5851
+ }),
5852
+ ),
5853
+ h('div', { className: 'field' },
5854
+ h('label', { htmlFor: 'dsh-suite-desc' }, t('说明(可选,显示在引用菜单条目右侧)')),
5855
+ h('input', {
5856
+ id: 'dsh-suite-desc',
5857
+ value: cur.desc,
5858
+ maxLength: SUITE_DESC_MAX,
5859
+ placeholder: t('一句话说明这套技能干什么'),
5860
+ onChange: (e) => patchCur({ desc: e.target.value }),
5861
+ }),
5862
+ ),
5863
+ ),
5864
+ h('div', {
5865
+ className: 'member-zone' + (zoneOver ? ' over' : ''),
5866
+ onDragOver: (e) => {
5867
+ e.preventDefault();
5868
+ setZoneOver(true);
5869
+ // 空成员区没有行可测几何:整个区域就是一个放置点(追加到末尾)。
5870
+ if (!curMembers.length) setDropIndex(0);
5871
+ },
5872
+ onDragLeave: (e) => {
5873
+ if (!e.currentTarget.contains(e.relatedTarget)) { setZoneOver(false); setDropIndex(null); }
5874
+ },
5875
+ onDrop: onZoneDrop,
5876
+ },
5877
+ h('div', { className: 'zh' },
5878
+ h(Icon, { d: ICONS.layers, size: 12 }),
5879
+ h('b', null, t('成员技能')),
5880
+ h('span', null, t('{0} 个 · 拖拽可排序', [curMembers.length])),
5881
+ ),
5882
+ h('div', { className: 'member-list' },
5883
+ curMembers.length === 0
5884
+ ? h('div', { className: 'member-empty' },
5885
+ h('span', { className: 'big' }, h(Icon, { d: ICONS.grip, size: 20 })),
5886
+ t('把左侧卡片拖到这里'),
5887
+ t('拖不动时,悬停卡片点 + 也能加进来'))
5888
+ : memberCells),
5889
+ ),
5890
+ )
5891
+ : h('div', { className: 'suite-editor' },
5892
+ h('div', { className: 'member-empty' },
5893
+ h('span', { className: 'big' }, h(Icon, { d: ICONS.layers, size: 20 })),
5894
+ t('选择右上列表里的一个套件,或点「新建套件」'),
5895
+ t('套件 = 一组相关 skill 的引用打包')),
5896
+ ),
5897
+ ),
5898
+ ),
5899
+ h('div', { className: 'suite-foot' },
5900
+ h('span', { className: 'hint' },
5901
+ h(Icon, { d: ICONS.grip, size: 12 }),
5902
+ h('span', null, t('拖左侧卡片到成员区即可添加;成员可上下拖拽排序,+ / × 是无拖拽的替代路径。')),
5903
+ ),
5904
+ saveError
5905
+ ? h('span', { className: 'err', role: 'alert', title: saveError }, saveError)
5906
+ : (dirty ? h('span', { className: 'dirty' }, h('i', null), t('未保存')) : null),
5907
+ h(Btn, { onClick: attemptClose, disabled: saving, children: t('取消') }),
5908
+ h(Btn, {
5909
+ cls: 'primary', onClick: save, disabled: saving,
5910
+ title: t('整集合覆盖保存:右侧列表就是全部套件'),
5911
+ children: saving ? t('保存中…') : t('保存套件'),
5912
+ }),
5913
+ ),
5914
+ flashMsg ? h('div', { className: 'dsh-mcp-toast ' + (flashMsg.ok ? 'ok' : 'err') }, flashMsg.msg) : null,
5915
+ ),
5916
+ );
5917
+ };
5918
+
5172
5919
  // 宿主侧接口不存在(HTTP 404 / transport failure)几乎总是「宿主还没重启」:
5173
5920
  // 客户端 bundle 从磁盘现读、改完刷新即生效,而宿主的 Remote 方法在启动时才注册。
5174
5921
  // 这里把生硬的传输错误翻译成可操作的提示,而不是让用户对着 404 猜。
@@ -5177,6 +5924,22 @@ const skillTransportHint = (message) =>
5177
5924
  ? String(message) + t(' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
5178
5925
  : String(message);
5179
5926
 
5927
+ // Skill 集合变更总线:市场安装/卸载、看板卸载都会改磁盘上的 skill 集合,但这些入口
5928
+ // 与消费方不在一棵子树里——顶部数量角标在 McpBoardPage,列表在 SkillBoard,二者还
5929
+ // 不同时挂载(分类是单选渲染)。没有总线时角标只在 McpBoardPage 首次挂载读一次,
5930
+ // 装完必须离开整个看板再回来才更新。与 expertChangeListeners 同构:模块级 Set、
5931
+ // 逐个 try(单个订阅者失败不影响其它)、返回退订函数;reason 只用于排查日志/测试。
5932
+ const skillChangeListeners = new Set();
5933
+ const emitSkillChange = (reason = '') => {
5934
+ for (const listener of [...skillChangeListeners]) {
5935
+ try { listener(reason); } catch { /* 单个订阅者失败不影响其它订阅者 */ }
5936
+ }
5937
+ };
5938
+ const onSkillChange = (listener) => {
5939
+ skillChangeListeners.add(listener);
5940
+ return () => { skillChangeListeners.delete(listener); };
5941
+ };
5942
+
5180
5943
  // ── Skill 看板:列表 + 详情 ──────────────────────────────────────────
5181
5944
  // 数据来自宿主新增的 listSkills / skillDetail(走同一个 mcpManager Remote 命名空间,
5182
5945
  // 复用既有的 call 信封 {ok, ...}),不引入第二条 Remote 通道。
@@ -5195,14 +5958,26 @@ const SkillBoard = ({ call, scope, timer }) => {
5195
5958
  const [detail, setDetail] = useState(null);
5196
5959
  const [detailLoading, setDetailLoading] = useState(false);
5197
5960
  const [detailError, setDetailError] = useState('');
5961
+ // 卸载三态:confirmRemove = 详情里已展开二次确认;removing = 请求在途;
5962
+ // removeError = 宿主拒绝或网络失败的原因(成功即关详情,不在详情里留错误)。
5963
+ const [confirmRemove, setConfirmRemove] = useState(false);
5964
+ const [removing, setRemoving] = useState(false);
5965
+ const [removeError, setRemoveError] = useState('');
5198
5966
  // 分类升级(决策 3/4/7):分类单选 + 网格/列表双视图,全部记忆到 URL hash
5199
5967
  // (skillcat/skillview/skillpick),刷新与分享都能回到原处;'' = 默认态不写键。
5200
5968
  const [cat, setCat] = useState('');
5201
5969
  const [view, setView] = useState('');
5202
5970
  const [toast, setToast] = useState(null); // { msg, ok }
5971
+ // 自定义套件三态:suites = 宿主 ~/.dsh/armory/suites.json 的本地镜像,suiteOpen = 管理弹窗开关,
5972
+ // notice = 看板根级浮层提示。notice 必须与 toast 分开——toast 渲染在详情体内(dsh-mcp-skill-d-flash),
5973
+ // 而「套件已保存 / 已放弃未保存的修改」恰恰发生在弹窗关闭的一刻,挂在详情里根本看不见。
5974
+ const [suites, setSuites] = useState([]);
5975
+ const [suiteOpen, setSuiteOpen] = useState(false);
5976
+ const [notice, setNotice] = useState(null); // { msg, ok }
5203
5977
  const loadSeq = useRef(0);
5204
5978
  const detailSeq = useRef(0);
5205
5979
  const toastCancel = useRef(null);
5980
+ const noticeCancel = useRef(null);
5206
5981
  // 作用域切换检测(skillpick 只在真换作用域时清)+ skillpick 恢复只做一次的哨兵。
5207
5982
  const mountedScope = useRef(null);
5208
5983
  const seededPick = useRef(false);
@@ -5231,6 +6006,17 @@ const SkillBoard = ({ call, scope, timer }) => {
5231
6006
  }
5232
6007
  }, [call, scope]);
5233
6008
 
6009
+ // 套件集合读取。与 listSkills 不同:它读的是本插件自己的数据层(不依赖宿主 skill 注册表),
6010
+ // 失败也不该把整个看板拖进错误态——读不到就当「还没有套件」,与 wb-registry 读侧同一口径。
6011
+ const loadSuites = useCallback(async () => {
6012
+ try {
6013
+ const res = await call('listSkillSuites');
6014
+ if (res && res.ok && Array.isArray(res.suites)) setSuites(res.suites);
6015
+ } catch {
6016
+ // 宿主未重启 / 接口缺失:静默降级为空集合,看板与召唤菜单都还能用。
6017
+ }
6018
+ }, [call]);
6019
+
5234
6020
  useEffect(() => {
5235
6021
  // cat/view 从 hash 恢复(作用域切换时 hash 仍是唯一真值源,直接重读)。
5236
6022
  const seededCat = readHashParam('skillcat');
@@ -5246,7 +6032,12 @@ const SkillBoard = ({ call, scope, timer }) => {
5246
6032
  setDetailError('');
5247
6033
  setQuery('');
5248
6034
  load(false);
5249
- }, [load]);
6035
+ loadSuites();
6036
+ }, [load, loadSuites]);
6037
+
6038
+ // 本看板挂着时收到变更(目前主要是自己卸载成功,以及将来其它入口)立即重读真值。
6039
+ // 不挂着时不用补——上面的挂载 effect 每次都会 load(false),切回本页天然是新数据。
6040
+ useEffect(() => onSkillChange(() => { load(true); }), [load]);
5250
6041
 
5251
6042
  const queryKey = (query || '').toLowerCase();
5252
6043
  // 分类判定只依赖 name+description、与筛选无关 → 一次 memo 全量映射;
@@ -5306,7 +6097,10 @@ const SkillBoard = ({ call, scope, timer }) => {
5306
6097
 
5307
6098
 
5308
6099
  const openDetail = useCallback(async (name) => {
6100
+ if (removing) return; // 卸载在途时不切换选中,避免成功回调关掉另一条详情
5309
6101
  setSelected(name);
6102
+ setConfirmRemove(false);
6103
+ setRemoveError('');
5310
6104
  // 选中态记忆到 hash(决策 4),刷新/分享可回到同一技能详情。
5311
6105
  writeHashParams({ skillpick: name });
5312
6106
  const seq = ++detailSeq.current;
@@ -5324,7 +6118,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5324
6118
  } finally {
5325
6119
  if (seq === detailSeq.current) setDetailLoading(false);
5326
6120
  }
5327
- }, [call, scope]);
6121
+ }, [call, scope, removing]);
5328
6122
 
5329
6123
  // 关掉网格视图的详情弹窗:选中态、正文、报错、toast 一起清,并把 hash 里的 skillpick 撤回。
5330
6124
  // 只清 UI 不清 hash 的话,刷新/分享链接会把弹窗又弹回来,看起来像「关不掉」。
@@ -5334,10 +6128,19 @@ const SkillBoard = ({ call, scope, timer }) => {
5334
6128
  setDetail(null);
5335
6129
  setDetailError('');
5336
6130
  setDetailLoading(false);
6131
+ setConfirmRemove(false);
6132
+ setRemoveError('');
5337
6133
  setToast(null);
5338
6134
  writeHashParams({ skillpick: '' });
5339
6135
  }, []);
5340
6136
 
6137
+ // 卸载在途时拒绝关闭详情:请求不可取消,关掉后失败原因就无处可显示。
6138
+ // 所有「用户主动关」的入口都走这里;removeSelected 成功后用 closeDetail 强关。
6139
+ const requestCloseDetail = useCallback(() => {
6140
+ if (removing) return;
6141
+ closeDetail();
6142
+ }, [removing, closeDetail]);
6143
+
5341
6144
  // 弹窗要能被 Escape 关掉,前提是焦点在它里面。ref 回调在节点挂载时触发,
5342
6145
  // 比再挂一个 useEffect 更贴近「出现即聚焦」,也省掉一轮渲染。
5343
6146
  const focusModal = useCallback((el) => {
@@ -5359,6 +6162,19 @@ const SkillBoard = ({ call, scope, timer }) => {
5359
6162
  return off;
5360
6163
  }, [openDetail]);
5361
6164
 
6165
+ // 套件弹窗的跨组件入口:召唤菜单底部「管理套件…」发广播 → 这里领走并打开弹窗。
6166
+ // 与 pendingSkillFocus 同构:订阅覆盖「看板已挂载」,自领 pending 覆盖「从别的页签跳过来时
6167
+ // 广播早已发完」;领走即清标志,否则下次再进看板会莫名其妙自己弹一次。
6168
+ useEffect(() => {
6169
+ const open = () => {
6170
+ pendingSuiteOpen = false;
6171
+ setSuiteOpen(true);
6172
+ };
6173
+ const off = onSuiteOpenRequest(open);
6174
+ if (pendingSuiteOpen) open();
6175
+ return off;
6176
+ }, []);
6177
+
5362
6178
  // skillpick 恢复(决策 4):列表就绪后按 hash 打开详情,只做一次;scope 切换时哨兵复位。
5363
6179
  useEffect(() => {
5364
6180
  if (seededPick.current || loading || !skills.length) return;
@@ -5369,6 +6185,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5369
6185
 
5370
6186
  const summary = selected ? skills.find((s) => s.name === selected) : null;
5371
6187
  const summaryCat = summary ? (catOf.get(summary.name) || SKILL_UNCAT) : null;
6188
+
5372
6189
  // 网格视图不再占右侧详情栏:有选中就浮成居中弹窗;列表视图仍走右侧栏(决策见 §12)。
5373
6190
  // 注意 view 的取值是 'list' | ''(网格是空串,见 setView(readHashParam('skillview') === 'list' ...)),
5374
6191
  // 所以判据必须是 !== 'list',写成 === 'grid' 会让弹窗永远不出现(卡片点了像没反应)。
@@ -5395,6 +6212,14 @@ const SkillBoard = ({ call, scope, timer }) => {
5395
6212
  toastCancel.current = timer.timeout(() => setToast(null), 2600);
5396
6213
  }, [timer]);
5397
6214
  useEffect(() => () => { if (toastCancel.current) toastCancel.current(); }, []);
6215
+ // 看板根级提示(约 2.6s 自动消失):套件弹窗一关就卸载,自己的 toast 活不过那一帧,
6216
+ // 所以「已保存 / 已放弃修改」这类消息一律交给这里,挂在看板根节点上。
6217
+ const showNotice = useCallback((msg, ok) => {
6218
+ setNotice({ msg, ok });
6219
+ if (noticeCancel.current) noticeCancel.current();
6220
+ noticeCancel.current = timer.timeout(() => setNotice(null), 2600);
6221
+ }, [timer]);
6222
+ useEffect(() => () => { if (noticeCancel.current) noticeCancel.current(); }, []);
5398
6223
  const copySkillMdPath = () => {
5399
6224
  const dir = String((summary && summary.path) || '').replace(/[\\/]+$/, '');
5400
6225
  if (!dir) return;
@@ -5405,12 +6230,36 @@ const SkillBoard = ({ call, scope, timer }) => {
5405
6230
  copyText('@' + summary.name).then((ok) => showToast(ok ? t('已复制到剪贴板') : t('复制失败'), ok));
5406
6231
  };
5407
6232
 
6233
+ // 卸载:宿主按注册表记录的真路径永久删除。成功后先本地收敛列表(注册表可能有扫描缓存,
6234
+ // 等 listSkills 回来才消失会让用户以为没删掉),再广播变更——本看板的订阅者会 load(true)
6235
+ // 让真值覆盖,顶部数量角标也同帧重算;刷新失败也不回滚——文件已经没了,回滚只会制造假象。
6236
+ // 注意定义位置:必须排在 showNotice 之后(依赖数组在渲染期求值,放前面会 TDZ)。
6237
+ const removeSelected = useCallback(async () => {
6238
+ if (!summary || !summary.path || removing) return;
6239
+ const name = summary.name;
6240
+ setConfirmRemove(false);
6241
+ setRemoving(true);
6242
+ setRemoveError('');
6243
+ try {
6244
+ const res = await call('removeSkill', { ...skillScopeArg(scope), name });
6245
+ if (!res || !res.ok) throw new Error((res && res.error) || t('卸载失败'));
6246
+ setSkills((rows) => rows.filter((s) => s.name !== name));
6247
+ closeDetail();
6248
+ showNotice(t('已卸载 {0}', [name]), true);
6249
+ emitSkillChange('skill-board-remove');
6250
+ } catch (e) {
6251
+ setRemoveError(skillTransportHint(String((e && e.message) || e)));
6252
+ } finally {
6253
+ setRemoving(false);
6254
+ }
6255
+ }, [summary, removing, call, scope, closeDetail, showNotice]);
6256
+
5408
6257
  // §1.4 空状态四件套:两种空况共用一份 JSX;「清除全部筛选」现在连同分类轨道一起清。
5409
6258
  const emptyBoard = skills.length === 0
5410
6259
  ? h(EmptyState, {
5411
6260
  icon: 'skill', title: t('这个作用域下没有发现 skill'),
5412
6261
  hint: t('skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现;放一个 SKILL.md 进去就会出现在这里。'),
5413
- actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => load(true) }],
6262
+ actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => { load(true); loadSuites(); } }],
5414
6263
  tight: true,
5415
6264
  })
5416
6265
  : h(EmptyState, {
@@ -5447,6 +6296,18 @@ const SkillBoard = ({ call, scope, timer }) => {
5447
6296
  title: t('列表视图'), onClick: () => setViewHash('list'),
5448
6297
  }, h(Icon, { d: ICONS.list, size: 13 }), t('列表视图')),
5449
6298
  ),
6299
+ // 自定义套件入口(决策:按钮放在 skill 看板工具条上,与视图分段控件同一行)。
6300
+ // 徽章显示已保存的套件数——0 时不渲染,避免空看板挂一个「0」误导成有东西可点。
6301
+ h('button', {
6302
+ type: 'button', className: 'dsh-mcp-btn suite-entry',
6303
+ title: t('管理自定义套件:把多个相关 skill 打包,引用一次全部生效'),
6304
+ onClick: () => setSuiteOpen(true),
6305
+ 'aria-label': t('管理自定义套件'),
6306
+ },
6307
+ h(Icon, { d: ICONS.layers, size: 13 }),
6308
+ t('套件'),
6309
+ suites.length ? h('span', { className: 'cnt' }, suites.length) : null,
6310
+ ),
5450
6311
  ),
5451
6312
  // 分类轨道(决策 5:轨道在搜索下、chips 上;计数固定全量,决策 9)。
5452
6313
  // 分类与来源/权限是正交维度——分类筛「它是什么」,来源/权限筛「它从哪来/谁能调用」。
@@ -5468,13 +6329,14 @@ const SkillBoard = ({ call, scope, timer }) => {
5468
6329
  h('span', null, (loading && !refreshing) ? t('读取中…') : t('显示 {0} / {1} 个 skill', [filtered.length, skills.length])
5469
6330
  + (filtered.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
5470
6331
  refreshing ? h('span', null, t(' · 刷新中…')) : null,
5471
- // OPT-31 · 5.1:把「只读」从代码注释提到界面上。宿主 skill 注册表只有
5472
- // listSkills / skillDetail 两个读接口,没有启用/禁用/写入——这层语义以前只写在
5473
- // 源码注释里,用户会反复找不到开关。明示之后「没有开关」变成预期而非缺陷。
5474
- // 位置:筛选条之后、列表之前——读列表前先知道能做什么,比滚到底才发现划算。
6332
+ // OPT-31 · 5.1:把「能做什么、不能做什么」从代码注释提到界面上。
6333
+ // 宿主注册表本身只有 listSkills / skillDetail 两个读接口;卸载是本插件补的
6334
+ // removeSkill(仅限用户级/项目级目录)。这层边界不写明,用户会把「随包分发 /
6335
+ // 运行时注册不可卸载」当成缺陷反复找开关。
6336
+ // 位置:筛选条之后、列表之前——读列表前先知道能力边界,比滚到底才发现划算。
5475
6337
  h('div', { className: 'dsh-mcp-readonly-note' },
5476
6338
  h(Icon, { d: ICONS.eye, size: 12 }),
5477
- h('span', null, t('这个看板是只读的:宿主只提供 listSkills / skillDetail,没有启用/禁用或修改入口;要改内容请直接编辑 SKILL.md。')),
6339
+ h('span', null, t('宿主只提供 skill 的读取接口;这里可以卸载用户级/项目级目录里的 skill(永久删除,不可撤销)。随 DSH 分发、运行时注册、自定义目录的 skill 不可卸载——要改内容请直接编辑 SKILL.md。')),
5478
6340
  ),
5479
6341
  ),
5480
6342
  h('div', { className: 'dsh-mcp-skill-filters' },
@@ -5580,7 +6442,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5580
6442
  // 网格视图的详情以弹窗呈现(与市场卡片详情同一套观感),列表视图仍走右侧栏。
5581
6443
  // 遮罩单独做一层兄弟节点而不是包住弹窗:点遮罩即关,点弹窗内部不会冒泡到它。
5582
6444
  isModal
5583
- ? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick: closeDetail })
6445
+ ? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick: requestCloseDetail })
5584
6446
  : null,
5585
6447
  h('div', {
5586
6448
  className: 'dsh-mcp-skill-detail' + (isModal ? ' is-modal' : ''),
@@ -5593,7 +6455,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5593
6455
  ? (e) => {
5594
6456
  if (e.key === 'Escape') {
5595
6457
  e.stopPropagation();
5596
- closeDetail();
6458
+ requestCloseDetail();
5597
6459
  }
5598
6460
  }
5599
6461
  : undefined,
@@ -5605,7 +6467,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5605
6467
  className: 'dsh-mcp-skill-d-close',
5606
6468
  title: t('关闭'),
5607
6469
  'aria-label': t('关闭'),
5608
- onClick: closeDetail,
6470
+ onClick: requestCloseDetail,
5609
6471
  }, h(Icon, { d: ICONS.x, size: 14 }))
5610
6472
  : null,
5611
6473
  !summary && !detailLoading
@@ -5655,7 +6517,31 @@ const SkillBoard = ({ call, scope, timer }) => {
5655
6517
  h('button', {
5656
6518
  type: 'button', className: 'dsh-mcp-btn', onClick: copySessionRef,
5657
6519
  }, h(Icon, { d: ICONS.copy, size: 13 }), t('复制 @会话引用')),
6520
+ // 卸载入口只在「来源可定位到用户级/项目级 skills 根」且确有磁盘路径时出现。
6521
+ // 二次确认就地展开(不弹窗、不移位),确认按钮为危险色,取消在左侧。
6522
+ skillRemovable(summary.source) && summary.path
6523
+ ? (confirmRemove
6524
+ ? h(InlineConfirm, {
6525
+ question: t('将从磁盘永久删除 {0}(不可撤销),确认卸载?', [summary.name]),
6526
+ confirmLabel: removing ? t('卸载中…') : t('确认卸载'),
6527
+ busy: removing,
6528
+ onConfirm: removeSelected,
6529
+ onCancel: () => setConfirmRemove(false),
6530
+ })
6531
+ : h('button', {
6532
+ type: 'button', className: 'dsh-mcp-btn danger', disabled: removing,
6533
+ title: t('永久删除这个 skill 所在目录(不可撤销)'),
6534
+ onClick: () => { setRemoveError(''); setConfirmRemove(true); },
6535
+ }, h(Icon, { d: ICONS.trash, size: 13 }), t('卸载')))
6536
+ : null,
5658
6537
  ),
6538
+ !skillRemovable(summary.source)
6539
+ ? h('div', { className: 'dsh-mcp-readonly-note' },
6540
+ h(Icon, { d: ICONS.eye, size: 12 }),
6541
+ h('span', null, t('这个 skill 来自「{0}」,不在可卸载范围内:随 DSH 分发、运行时注册与自定义目录的 skill 只能查看。要停用它,请编辑所属预设或 SKILL.md。', [skillSourceLabel(summary.source)])),
6542
+ )
6543
+ : null,
6544
+ removeError ? h('div', { className: 'dsh-mcp-d-err' }, removeError) : null,
5659
6545
  detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, t('读取 skill 内容…')) : null,
5660
6546
  detailError ? h('div', { className: 'dsh-mcp-d-err' }, detailError) : null,
5661
6547
  detail && detail.content !== undefined ? h('div', { className: 'dsh-mcp-skill-d-sec' },
@@ -5666,6 +6552,23 @@ const SkillBoard = ({ call, scope, timer }) => {
5666
6552
  ) : null,
5667
6553
  ),
5668
6554
  ) : null,
6555
+ // 套件管理弹窗:挂在「看板可用」判据**之外**——它读的是本插件自己的数据层,
6556
+ // 就算宿主没挂载 skill 注册表(error 态、工具条不渲染),召唤菜单里的「管理套件…」
6557
+ // 也仍应能把它打开,只是左侧候选列表是空的。
6558
+ suiteOpen
6559
+ ? h(SuiteManager, {
6560
+ call,
6561
+ skills,
6562
+ catOf,
6563
+ suites,
6564
+ onClose: () => setSuiteOpen(false),
6565
+ // 保存成功后一律以宿主返回的集合为真值:新分配的 id 只出现在返回值里,
6566
+ // 拿本地副本回填会让同一条套件在下次保存时被当成「新建」再存一份。
6567
+ onSaved: setSuites,
6568
+ notify: showNotice,
6569
+ })
6570
+ : null,
6571
+ notice ? h('div', { className: 'dsh-mcp-toast ' + (notice.ok ? 'ok' : 'err'), role: 'status' }, notice.msg) : null,
5669
6572
  );
5670
6573
  };
5671
6574
 
@@ -5684,7 +6587,7 @@ const connectorMatchesVisibleIn = (rowVisibleTags, selectedTags) => {
5684
6587
  return rowVisibleTags.some((tag) => selectedTags.includes(tag));
5685
6588
  };
5686
6589
  const WB_SKILLHUB_SORTS = [
5687
- { key: 'downloads', label: t('最多下载') },
6590
+ { key: 'downloads', label: t('下载量(本页排序)') },
5688
6591
  { key: 'installs', label: t('最多安装') },
5689
6592
  { key: 'stars', label: t('最多星标') },
5690
6593
  { key: 'score', label: t('综合评分') },
@@ -6449,20 +7352,34 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6449
7352
  // MCP 行字段是 serverName(gateway list() 的行形状,与面板列表同一来源);禁用的不进菜单。
6450
7353
  const loadRecent = useCallback(async () => {
6451
7354
  setListError('');
6452
- const [recentRes, skillsRes, mcpRes, expertRes] = await Promise.all([
7355
+ const [recentRes, skillsRes, mcpRes, expertRes, suitesRes] = await Promise.all([
6453
7356
  call('wbExpertRecent'),
6454
7357
  call('listSkills', skillScopeArg('global')),
6455
7358
  call('list'),
6456
7359
  call('wbExpertInstalled'),
7360
+ // 套件读本插件自己的数据层(不是宿主接口),失败不该连累整个菜单——Promise.all 是
7361
+ // 「一败全败」,所以单独兜住:宿主还没重启(接口 404)时菜单照常出技能/MCP/专家。
7362
+ call('listSkillSuites').catch(() => ({ ok: false })),
6457
7363
  ]);
6458
7364
  const kinds = recentRes?.ok && recentRes.kinds ? recentRes.kinds : {};
6459
7365
  const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills : [];
6460
7366
  const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName) : [];
6461
7367
  const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets : [];
7368
+ const suiteRows = suitesRes?.ok && Array.isArray(suitesRes.suites) ? suitesRes.suites : [];
6462
7369
  setRecent({
6463
7370
  skill: skillRows.map((row) => ({ name: row.name, description: row.description || row.whenToUse || '' })),
6464
7371
  mcp: mcpRows.map((row) => ({ name: row.serverName, description: row.url || row.command || row.transport || '' })),
6465
7372
  expert: expertRows.map((row) => ({ plugin: row.plugin, name: row.plugin, presetId: row.presetId })),
7373
+ // 数据层读侧已经滤掉「无名/无成员」的残缺条目,这里再兜一次手改文件的形状:
7374
+ // 空套件展开后什么都插不进草稿,留在菜单里只是个点了没反应的死条目。
7375
+ suite: suiteRows
7376
+ .filter((row) => row && row.name && Array.isArray(row.members) && row.members.length)
7377
+ .map((row) => ({
7378
+ id: String(row.id || ''),
7379
+ name: String(row.name),
7380
+ desc: String(row.desc || ''),
7381
+ members: row.members.map((name) => String(name)),
7382
+ })),
6466
7383
  recentSkill: Array.isArray(kinds.skill) ? kinds.skill : [],
6467
7384
  recentMcp: Array.isArray(kinds.mcp) ? kinds.mcp : [],
6468
7385
  recentExpert: Array.isArray(kinds.expert) ? kinds.expert : [],
@@ -6534,17 +7451,15 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6534
7451
  return () => { document.removeEventListener('click', onDocClick); document.removeEventListener('keydown', onKey); };
6535
7452
  }, [open]);
6536
7453
 
6537
- // 选用动作:把引用插进输入框光标处 + 上报最近使用。
7454
+ // 插入动作(技能 / MCP / 套件共用):把引用插进输入框光标处。
6538
7455
  // 写通道(按优先级):
6539
7456
  // ① inputActions.insertText(text, captureInsertion()) —— composer 的 standard provide channel
6540
7457
  // (InputBar 同款 useInput/inputActions),在光标处插入并保留现有草稿,最贴近 WorkBuddy;
6541
7458
  // ② inputActions.setDraft(现草稿 + 引用) —— insertText 不可用时的整草稿兜底;
6542
7459
  // ③ 复制进剪贴板 + 按钮上闪现「已复制」(绝不跳走:跳页是上一版的行为缺陷)。
6543
- const pick = async (kind, item, text) => {
6544
- setOpen(false); setHoverKind(null);
6545
- // 上报最近使用(失败静默:纯增值数据)。expert 不经 pick(点击分支直接 attachExpert)。
6546
- const row = kind === 'mcp' ? { serverName: item.name } : { name: item.name };
6547
- call('wbExpertRecordUse', { kind, row }).catch(() => {});
7460
+ // 套件展开出来是一整串 `/a /b /c`,走的正是同一条通道——所以「引用套件」不需要
7461
+ // 宿主认识套件这个概念,宿主看到的仍然只是普通草稿文本。
7462
+ const insertRef = async (text) => {
6548
7463
  const insertion = typeof text === 'string' ? (text.endsWith(' ') ? text : text + ' ') : String(text);
6549
7464
  if (inputActions && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
6550
7465
  try {
@@ -6566,6 +7481,25 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6566
7481
  setTimeout(() => setCopied(''), 1600);
6567
7482
  } catch { /* 剪贴板不可用:静默 */ }
6568
7483
  };
7484
+ const pick = async (kind, item, text) => {
7485
+ setOpen(false); setHoverKind(null);
7486
+ // 上报最近使用(失败静默:纯增值数据)。expert 不经 pick(点击分支直接 attachExpert)。
7487
+ const row = kind === 'mcp' ? { serverName: item.name } : { name: item.name };
7488
+ call('wbExpertRecordUse', { kind, row }).catch(() => {});
7489
+ await insertRef(text);
7490
+ };
7491
+ // 引用一个套件 = 把成员逐个展开成引用文本拼成一段插入草稿(纯插件侧展开,宿主零改动)。
7492
+ // 文本用 quickMentionText('skill', …) 逐成员生成,与单个技能的引用格式完全同源:
7493
+ // 合法标识符走 `/name`,中文名等走 `名字:`——两处各写一套正则必然漂移。
7494
+ // 最近使用按**成员**上报而不是按套件:技能最近池里只该出现真实的 skill 名,
7495
+ // 记套件名会污染池子(子菜单的「最近」徽章拿套件名去匹技能,永远匹配不上)。
7496
+ const pickSuite = async (suite) => {
7497
+ setOpen(false); setHoverKind(null);
7498
+ const members = (Array.isArray(suite?.members) ? suite.members : []).map((name) => String(name).trim()).filter(Boolean);
7499
+ if (!members.length) return;
7500
+ for (const name of members) call('wbExpertRecordUse', { kind: 'skill', row: { name } }).catch(() => {});
7501
+ await insertRef(members.map((name) => quickMentionText('skill', { name }).trim()).join(' ') + ' ');
7502
+ };
6569
7503
 
6570
7504
  // 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记,只标首屏 6 个),
6571
7505
  // 未命中最近记录的排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
@@ -6734,6 +7668,13 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6734
7668
  QUICK_MENU_KINDS.map((kind) => {
6735
7669
  const items = subItems(kind.key);
6736
7670
  const expanded = hoverKind === kind.key;
7671
+ // 套件只在「技能」子菜单里出现,且作为独立分组置顶(决策:与单个技能同级但分组,
7672
+ // 让用户一眼看出「这一条点下去是同时引用好几个 skill」)。
7673
+ const suites = kind.key === 'skill' && recent && Array.isArray(recent.suite) ? recent.suite : [];
7674
+ const skillRows = Array.isArray(items) ? items : [];
7675
+ // 子菜单的空态判据要把套件算进来:只有套件、一个技能都没有时也是「有内容」,
7676
+ // 否则 .empty 会把分组头和条目一起按空态样式压掉。
7677
+ const hasAny = skillRows.length > 0 || suites.length > 0;
6737
7678
  return h('div', {
6738
7679
  key: kind.key,
6739
7680
  className: 'dsh-mcp-quick-row' + (expanded && flipKinds.has(kind.key) ? ' flip' : ''),
@@ -6755,14 +7696,35 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6755
7696
  },
6756
7697
  h(Icon, { d: ICONS[kind.icon], size: 13 }),
6757
7698
  h('span', null, kind.label),
6758
- Array.isArray(items) && items.length ? h('span', { className: 'dsh-mcp-quick-count' }, items.length) : null,
7699
+ // 计数只报技能本身(与主菜单「这一类有多少条」的语义一致),不把套件数混进去。
7700
+ skillRows.length ? h('span', { className: 'dsh-mcp-quick-count' }, skillRows.length) : null,
6759
7701
  h('span', { className: 'dsh-mcp-quick-arrow', 'aria-hidden': true }, '›'),
6760
7702
  ),
6761
7703
  hoverKind === kind.key
6762
- ? h('div', { className: 'dsh-mcp-quick-sub' + (Array.isArray(items) && items.length ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + t('可选项') },
6763
- Array.isArray(items) && items.length
7704
+ ? h('div', { className: 'dsh-mcp-quick-sub' + (hasAny ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + t('可选项') },
7705
+ hasAny
6764
7706
  ? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + t('列表(最多显示 6 行,滚动查看更多)') },
6765
- items.map((item, index) => h('button', {
7707
+ // ── 套件分组(仅技能子菜单)─────────────────────────────
7708
+ suites.length ? h('div', { className: 'dsh-mcp-quick-group', 'aria-hidden': true },
7709
+ h(Icon, { d: ICONS.layers, size: 11 }),
7710
+ t('套件'),
7711
+ ) : null,
7712
+ suites.map((suite, index) => h('button', {
7713
+ key: suite.id || suite.name || index,
7714
+ type: 'button',
7715
+ role: 'menuitem',
7716
+ className: 'dsh-mcp-quick-item suite',
7717
+ title: t('引用套件:展开为 {0} 个 /name', [suite.members.length]),
7718
+ onClick: (e) => { e.stopPropagation(); pickSuite(suite); },
7719
+ },
7720
+ h('span', { className: 'ic', 'aria-hidden': true }, h(Icon, { d: ICONS.layers, size: 12 })),
7721
+ h('span', { className: 'dsh-mcp-quick-item-name' }, suite.name),
7722
+ h('span', { className: 'dsh-mcp-quick-item-badge' }, t('{0} 技能', [suite.members.length])),
7723
+ suite.desc ? h('span', { className: 'dsh-mcp-quick-item-desc', title: suite.desc }, suite.desc) : null,
7724
+ )),
7725
+ // 分隔线只在两段都有内容时画:只有一段时它是条多余的尾巴。
7726
+ suites.length && skillRows.length ? h('div', { className: 'dsh-mcp-quick-sep', role: 'separator' }) : null,
7727
+ skillRows.map((item, index) => h('button', {
6766
7728
  key: item.name || item.plugin || index,
6767
7729
  type: 'button',
6768
7730
  role: 'menuitem',
@@ -6789,6 +7751,21 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6789
7751
  },
6790
7752
  h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理') + kind.label + '…'),
6791
7753
  ),
7754
+ // 套件管理入口只在技能子菜单里给(套件就是技能的组合,放在别处会找不到语境)。
7755
+ // 走 openSuiteManager():它广播 + 切到 skill 看板,弹窗由 SkillBoard 那边挂出来——
7756
+ // 菜单自己是输入框旁的临时浮层,不适合承载一个模态对话框。
7757
+ kind.key === 'skill'
7758
+ ? h('button', {
7759
+ type: 'button',
7760
+ role: 'menuitem',
7761
+ className: 'dsh-mcp-quick-manage',
7762
+ title: t('新建或修改套件(把多个相关 skill 打包,引用一次全部生效)'),
7763
+ onClick: (e) => { e.stopPropagation(); setOpen(false); setHoverKind(null); openSuiteManager(); },
7764
+ },
7765
+ h('span', { className: 'ic', 'aria-hidden': true }, h(Icon, { d: ICONS.layers, size: 11 })),
7766
+ h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理套件…')),
7767
+ )
7768
+ : null,
6792
7769
  )
6793
7770
  : null,
6794
7771
  );
@@ -6969,6 +7946,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
6969
7946
  const [skillKeywordInput, setSkillKeywordInput] = useState('');
6970
7947
  const [skills, setSkills] = useState([]);
6971
7948
  const [skillTotal, setSkillTotal] = useState(0);
7949
+ // 技能侧自己的加载/错误态:此前与连接器共用 `loading`/`error`,两种请求谁先回来谁就
7950
+ // 把对方的态改掉——连接器 1 秒读完就把 loading 置 false,技能还在飞,于是技能页先画
7951
+ // 「技能目录暂时是空的 / 可能还没同步下来」;等技能真的超时,又把整页 banner 改成报错。
7952
+ // 拆开之后,技能慢只影响技能页自己的 loading/error。
7953
+ const [skillLoading, setSkillLoading] = useState(true);
7954
+ const [skillError, setSkillError] = useState('');
6972
7955
  const [skillDetail, setSkillDetail] = useState(null);
6973
7956
  const [installedSkills, setInstalledSkills] = useState(new Set());
6974
7957
  // §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
@@ -7029,17 +8012,21 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7029
8012
  }
7030
8013
  }, [call]);
7031
8014
 
8015
+ // 技能列表请求序号:搜索防抖与翻页会连发多个请求,慢的那个回来时不能盖掉新的结果。
8016
+ const skillSeq = useRef(0);
7032
8017
  const loadSkills = useCallback(async (page, sort, keyword) => {
7033
- setLoading(true); setError(''); setNote('');
8018
+ const seq = ++skillSeq.current;
8019
+ setSkillLoading(true); setSkillError('');
7034
8020
  try {
7035
8021
  const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
8022
+ if (seq !== skillSeq.current) return; // 过期响应,直接丢弃
7036
8023
  if (!res?.ok) throw new Error(res?.error || t('读取技能目录失败'));
7037
8024
  setSkills(res.skills || []);
7038
8025
  setSkillTotal(res.total || 0);
7039
8026
  } catch (e) {
7040
- setError(String(e?.message || e));
8027
+ if (seq === skillSeq.current) setSkillError(String(e?.message || e));
7041
8028
  } finally {
7042
- setLoading(false);
8029
+ if (seq === skillSeq.current) setSkillLoading(false);
7043
8030
  }
7044
8031
  }, [call]);
7045
8032
 
@@ -7273,6 +8260,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7273
8260
  if (!res?.ok) throw new Error(res?.error || t('安装失败'));
7274
8261
  setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
7275
8262
  setSkillDetail(null);
8263
+ // 磁盘上的 skill 集合已经变了:广播出去,顶部分类计数与(切回时的)Skill 看板
8264
+ // 立即按新集合重算,不再等用户离开看板/重挂载。市场自己的列表照旧局部刷新。
8265
+ emitSkillChange('market-install');
7276
8266
  await loadInstalled();
7277
8267
  await loadInstallState();
7278
8268
  } catch (e) {
@@ -7288,6 +8278,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7288
8278
  if (!res?.ok) throw new Error(res?.error || t('卸载失败'));
7289
8279
  setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
7290
8280
  setSkillDetail(null);
8281
+ emitSkillChange('market-uninstall');
7291
8282
  await loadInstalled();
7292
8283
  await loadInstallState();
7293
8284
  } catch (e) {
@@ -7622,7 +8613,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7622
8613
  onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
7623
8614
  });
7624
8615
  const skillSearch = h(WbSearchBox, {
7625
- value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: loading,
8616
+ value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: skillLoading,
7626
8617
  onChange: (value) => setSkillKeywordInput(value),
7627
8618
  // 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
7628
8619
  onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
@@ -7780,11 +8771,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7780
8771
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
7781
8772
  skillSearch,
7782
8773
  h('select', {
7783
- className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: loading,
8774
+ className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: skillLoading,
8775
+ title: t('SkillHub 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。'),
7784
8776
  onChange: (e) => { setSkillSort(e.target.value); setSkillPage(1); },
7785
8777
  }, WB_SKILLHUB_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
7786
8778
  h('label', { className: 'dsh-mcp-check', title: t('仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)', [skills.length]) },
7787
- h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled: loading, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
8779
+ h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled: skillLoading, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
7788
8780
  t('只看已安装')),
7789
8781
  skillOnlyInstalled
7790
8782
  ? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
@@ -7795,8 +8787,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7795
8787
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
7796
8788
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
7797
8789
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
8790
+ // 技能加载失败只落在这一行(带重试),不再借用整页 banner:一个上游接口挂了
8791
+ // 不该让「连接器 / 内置 / 插件 / 专家」页签看起来都坏了。
8792
+ tab === 'skill' && skillError
8793
+ ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, skillError, ' ',
8794
+ h('button', {
8795
+ type: 'button', className: 'dsh-mcp-btn', disabled: skillLoading,
8796
+ onClick: () => loadSkills(skillPage, skillSort, skillKeyword),
8797
+ }, t('重试')))
8798
+ : null,
7798
8799
  loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
7799
- loading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
8800
+ skillLoading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
7800
8801
  // §1.4 「只看可直接安装」把本页筛空了:这是本页最容易踩的坑(默认勾选),
7801
8802
  // 主操作就是取消它——一句话说清「这里本来有条目,是筛选挡掉了」。
7802
8803
  !loading && tab === 'connector' && !visibleConnectors.length
@@ -7823,7 +8824,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7823
8824
  : null,
7824
8825
  // §1.4 技能:搜索与「只看已安装」是两种完全不同的空,说法必须分开——
7825
8826
  // 否则用户会以为 SkillHub 里根本没有这个词。
7826
- !loading && tab === 'skill' && !skills.length
8827
+ !skillLoading && tab === 'skill' && !skills.length
7827
8828
  ? h(EmptyState, {
7828
8829
  icon: 'skill',
7829
8830
  title: skillKeyword.trim() ? t('没有搜到「{0}」相关的技能', [skillKeyword.trim()]) : t('技能目录暂时是空的'),
@@ -7834,7 +8835,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7834
8835
  })
7835
8836
  : null,
7836
8837
  // 「只看已安装」把本页筛空了:不能沿用「没有匹配的技能」——那会让人以为搜索没结果。
7837
- !loading && tab === 'skill' && skills.length && !shownSkills.length
8838
+ !skillLoading && tab === 'skill' && skills.length && !shownSkills.length
7838
8839
  ? h(EmptyState, {
7839
8840
  icon: 'skill',
7840
8841
  tight: true,
@@ -7996,7 +8997,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7996
8997
  )
7997
8998
  : null,
7998
8999
  tab === 'skill' && skillPageCount > 1
7999
- ? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled: loading })
9000
+ ? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled: skillLoading })
8000
9001
  : null,
8001
9002
  // ── T03/R5:codebuddy 插件市场 ──
8002
9003
  // 重启提示条:常驻本 tab 顶部(组件卸载即消失,不写 localStorage)——
@@ -8865,8 +9866,10 @@ const McpBoardPage = (props) => {
8865
9866
  setCatHint({ key, left });
8866
9867
  }, []);
8867
9868
  const hideCatHint = useCallback(() => setCatHint(null), []);
8868
- // 顶层分类计数:全部是「本机在手」数据,打开看板就一次性取齐,
9869
+ // 顶层分类计数:全部是「本机在手」数据,打开看板就先取齐,
8869
9870
  // 让 chip 数字常驻——而不是「进过哪一页才出现哪个」,视觉上才协调。
9871
+ // 四类里只有 Skill 会随用户操作变化(市场装/卸、看板卸载),所以它下面额外接了
9872
+ // 变更总线重取;其余三类仍是一次性读取。
8870
9873
  // 市场是唯一例外+特例:目录在远端(24MB ZIP),不能为个数字在打开看板时
8871
9874
  // 全量下载;这里调 wbConnectorCachedCount 只读磁盘缓存(绝不联网),
8872
9875
  // 用户没进过市场时返回 null(chip 暂不显示数字),进过一次后就常驻。
@@ -8875,13 +9878,22 @@ const McpBoardPage = (props) => {
8875
9878
  const [expertCount, setExpertCount] = useState(null);
8876
9879
  const [mkCount, setMkCount] = useState(null);
8877
9880
  const countsLoaded = useRef(false);
9881
+ // Skill 角标会在挂载后被反复重取(市场装/卸、Skill 看板卸载都会广播变更),用序号挡住
9882
+ // 乱序回包;读失败保留旧值——角标读不到不该把数字改成 0,也不该拖垮其它分类的计数。
9883
+ const skillCountSeq = useRef(0);
9884
+ const refreshSkillCount = useCallback(async () => {
9885
+ const seq = ++skillCountSeq.current;
9886
+ try {
9887
+ const res = await call('listSkills', skillScopeArg('global'));
9888
+ if (seq !== skillCountSeq.current) return;
9889
+ if (res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
9890
+ } catch { /* 保留旧值 */ }
9891
+ }, [call]);
8878
9892
  useEffect(() => {
8879
9893
  if (countsLoaded.current) return;
8880
9894
  countsLoaded.current = true;
8881
9895
  let alive = true;
8882
- call('listSkills', skillScopeArg('global')).then((res) => {
8883
- if (alive && res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
8884
- }).catch(() => {});
9896
+ refreshSkillCount();
8885
9897
  call('list').then((res) => {
8886
9898
  if (alive && res && res.ok && Array.isArray(res.servers)) setMcpCount(res.servers.length);
8887
9899
  }).catch(() => {});
@@ -8894,7 +9906,10 @@ const McpBoardPage = (props) => {
8894
9906
  if (alive && res && res.ok && Number.isFinite(res.total)) setMkCount(res.total);
8895
9907
  }).catch(() => {});
8896
9908
  return () => { alive = false; };
8897
- }, [call]);
9909
+ }, [call, refreshSkillCount]);
9910
+ // 关键:角标不再只在首次挂载读一次。市场装完就广播,这里直接重取数量并重渲染;
9911
+ // Skill 看板挂着时它自己的订阅负责刷新列表,没挂着时靠其挂载 effect 重读,路径不同但都不漏。
9912
+ useEffect(() => onSkillChange(() => { refreshSkillCount(); }), [refreshSkillCount]);
8898
9913
  // OPT-04:报告上提到这里,分类角标与 ToolTailor 页面本体共用同一份。
8899
9914
  // 扫描时机不再等进裁缝页——账本是本机文件,打开看板就扫,橙点角标才有数。
8900
9915
  // 用「已发起」标记而不是判断报告是否非空,是为了区分「还没开始」与「发起了但失败了」:
@@ -9419,6 +10434,11 @@ const MCP_CONTRIBUTION = {
9419
10434
  { id: 'dsh-armory#mcpManager/listSkills', service: 'mcpManager', namespace: 'mcpManager', method: 'listSkills', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillListResult') },
9420
10435
  { id: 'dsh-armory#mcpManager/skillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'skillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillDetailResult') },
9421
10436
  { id: 'dsh-armory#mcpManager/revealSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'revealSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10437
+ { id: 'dsh-armory#mcpManager/removeSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'removeSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
10438
+ // 自定义套件:读全量集合 / 整集合覆盖保存(与 typert.js、lib/index.js 三处同步,
10439
+ // 漏一处这条通道恒 404 —— toolTailor、installInfo 各踩过一次)。
10440
+ { id: 'dsh-armory#mcpManager/listSkillSuites', service: 'mcpManager', namespace: 'mcpManager', method: 'listSkillSuites', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('SkillSuiteListResult') },
10441
+ { id: 'dsh-armory#mcpManager/saveSkillSuites', service: 'mcpManager', namespace: 'mcpManager', method: 'saveSkillSuites', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillSuiteSaveResult') },
9422
10442
  // ── 工具裁缝:与 revealSkill 同批加入看板,但两端契约此前双双缺失(客户端永远 404)──
9423
10443
  { id: 'dsh-armory#mcpManager/toolTailor', service: 'mcpManager', namespace: 'mcpManager', method: 'toolTailor', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('TailorReport') },
9424
10444
  // ── WorkBuddy 市场直连:连接器 MCP + SkillHub 技能(同一命名空间同一信封)──