dsh-armory 2.0.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",
@@ -277,7 +288,7 @@ const EN_US_MESSAGES = {
277
288
  "目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。": "The cached catalog is stale (update sources unreachable; showing the local cache).",
278
289
  "DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。": "The DSH community catalog has a newer version (local cache {0}). Click \"Reload\" to fetch the latest catalog.",
279
290
  "本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。": "No local DSH community catalog cache yet. Click \"Reload\" to fetch it for the first time.",
280
- "DSH 社区目录更新探测失败(不影响已安装插件)。": "DSH community catalog update probe failed (installed plugins are not affected).",
291
+
281
292
  "正在加载 DSH 社区目录…": "Loading the DSH community catalog…",
282
293
  "DSH 社区目录暂时是空的": "The DSH community catalog is temporarily empty",
283
294
  "目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。": "The catalog comes from the awesome-dsh-plugins community registry (jsdelivr CDN); retry if fetching failed.",
@@ -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",
@@ -550,8 +562,6 @@ const EN_US_MESSAGES = {
550
562
  "正在召唤 {0}…": "Summoning {0}…",
551
563
  "正在安装 {0}…": "Installing {0}…",
552
564
  "正在扫描本机客户端配置…": "Scanning local client configs…",
553
- "正在拉取人设与技能清单…": "Fetching persona and skill list…",
554
- "正在拉取详情…": "Fetching details…",
555
565
  "正在挂载专家…": "Attaching expert…",
556
566
  "正在读取 MCP 目录…": "Reading MCP catalog…",
557
567
  "正在读取…": "Reading…",
@@ -761,9 +771,6 @@ const EN_US_MESSAGES = {
761
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",
762
772
  "今日": "Today",
763
773
  "账本缓存": "Ledger cache",
764
- "清空缓存": "Clear cache",
765
- "正在清空缓存…": "Clearing cache…",
766
- "已清空缓存,释放 {0}": "Cache cleared, {0} freed",
767
774
  " 条": " items",
768
775
  "": "",
769
776
  "&": "&",
@@ -824,7 +831,68 @@ const EN_US_MESSAGES = {
824
831
  "跳转到指定页": "Jump to a specific page",
825
832
  "共 {0} 条": "{0} total",
826
833
  "免认证": "No auth",
827
- "这个看板是只读的:宿主只提供 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",
828
896
 
829
897
  "这 {0} 个 MCP 都需要交互取值,无法直接导入": "These {0} MCP servers all require interactive input and cannot be imported directly",
830
898
  "(另有 {0} 个需要交互取值)": " ({0} more require interactive input)",
@@ -888,6 +956,15 @@ const EN_US_MESSAGES = {
888
956
  "复制 SKILL.md 路径": "Copy SKILL.md path",
889
957
  "复制 @会话引用": "Copy @session reference",
890
958
 
959
+ // DSH 社区插件 README 预览弹层(点卡片打开)。
960
+ "查看 README 介绍与插图": "View README intro & images",
961
+ "{0} README 预览": "{0} · README preview",
962
+ "正在读取 README…": "Loading README…",
963
+ "README 过长,已截断显示。": "README is long; showing a truncated version.",
964
+ "重新读取": "Reload",
965
+ "读取 README 失败:未知错误": "Failed to read README: unknown error",
966
+ "预览服务尚未加载,请完全退出 DSH 后重新打开再试": "The preview service is not loaded yet. Fully quit DSH, reopen it, then try again.",
967
+
891
968
  ":": ": ",
892
969
  };
893
970
  // OPT-14 · 1.1:仅当判定为英文 locale 时装载 en-US 表;zh-CN(源语言)保持空字典
@@ -969,6 +1046,18 @@ function writeAwesomeSnapshot(payload) {
969
1046
  } catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
970
1047
  }
971
1048
 
1049
+ // ── 更新探测节流:把「每次进面板都 HEAD 一发」压到 6 小时一次 ────────────────
1050
+ // 目录本身已有服务端 stale-while-revalidate 在后台自动换新,这个 ETag 探测只是
1051
+ // 锦上添花;没必要每开一次面板就多打一次网络、多一次误报机会。
1052
+ const AWESOME_PROBE_AT_KEY = KEY_PREFIX + '.awesomeProbeAt.v1';
1053
+ const AWESOME_PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000;
1054
+ function readAwesomeProbeAt() {
1055
+ try { return Number(localStorage.getItem(AWESOME_PROBE_AT_KEY)) || 0; } catch { return 0; }
1056
+ }
1057
+ function writeAwesomeProbeAt(value) {
1058
+ try { localStorage.setItem(AWESOME_PROBE_AT_KEY, String(value)); } catch { /* 隐私模式:退化为不节流 */ }
1059
+ }
1060
+
972
1061
  // ── 安装轻提示:挂在 document.body 上的常驻浮层 ─────────────────────────────
973
1062
  // 安装进度此前只存在组件本地 state(busy / busyLabel):用户一切到别的面板或窗口,
974
1063
  // 组件卸载 → 进度消失 → 看起来像「安装被取消了」。这个浮层直接挂 document.body,
@@ -1083,6 +1172,420 @@ function isInstallRunning(name) {
1083
1172
  return Boolean(task && task.phase === 'running');
1084
1173
  }
1085
1174
 
1175
+ // ── README 预览:极简 markdown 渲染 + 插图还原 ────────────────────────────────
1176
+ //
1177
+ // 为什么自己写:本文件没有 react-dom,也没有任何 markdown 依赖;README 又是公网
1178
+ // 数据(不可信)。于是这里只做一件事——把 markdown 扫成块,逐块构造 React 元素,
1179
+ // 全程不拼字符串、不用 innerHTML,链接一律过 URL 白名单。
1180
+ //
1181
+ // 覆盖 README 真正会用到的那部分语法:标题 / 段落 / 行内代码 / 围栏代码 / 无序与
1182
+ // 有序列表 / 引用 / 分隔线 / 表格 / 粗体 / 斜体 / 删除线 / 链接 / 图片,外加裸 HTML
1183
+ // 里的 <img>、<a>、<br>(插件展示页几乎都用 <p align="center"><img …> 摆横幅)。
1184
+
1185
+ const README_STYLE_ID = KEY_PREFIX + '.readme-style';
1186
+ let readmeStyleInjected = false;
1187
+
1188
+ function ensureReadmeStyle() {
1189
+ if (readmeStyleInjected || typeof document === 'undefined') return;
1190
+ readmeStyleInjected = true;
1191
+ if (document.getElementById(README_STYLE_ID)) return;
1192
+ const style = document.createElement('style');
1193
+ style.id = README_STYLE_ID;
1194
+ style.textContent = [
1195
+ '.dsh-mcp-readme-modal{width:min(880px,94vw);max-width:94vw;max-height:86vh}',
1196
+ '.dsh-mcp-readme-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:12px;opacity:.75}',
1197
+ '.dsh-mcp-readme-body{margin-top:12px;overflow-wrap:anywhere}',
1198
+ '.dsh-mcp-md-h{margin:20px 0 8px;font-weight:600;line-height:1.35;letter-spacing:0}',
1199
+ '.dsh-mcp-md-h:first-child{margin-top:0}',
1200
+ '.dsh-mcp-md-p{margin:8px 0;line-height:1.72}',
1201
+ '.dsh-mcp-md-ul,.dsh-mcp-md-ol{margin:8px 0;padding-inline-start:22px}',
1202
+ '.dsh-mcp-md-li{margin:3px 0;line-height:1.7}',
1203
+ '.dsh-mcp-md-quote{margin:10px 0;padding:4px 12px;border-inline-start:3px solid var(--dsw-alias-border-strong,rgba(127,127,127,.4));opacity:.9}',
1204
+ '.dsh-mcp-md-code{padding:1px 5px;border-radius:4px;background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.14));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em}',
1205
+ '.dsh-mcp-md-pre{margin:10px 0;padding:10px 12px;border-radius:8px;overflow:auto;background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.12));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.88em;line-height:1.6}',
1206
+ // 插图尺寸:README 里的截图常是 1600~3000px 的巨图,只限宽会把弹层撑成一根滚动条。
1207
+ // 这里同时限宽与限高(40vh:小窗口自动更小,一屏必能看到整张),等比缩放不裁切;
1208
+ // width:auto 保证小徽章不被放大糊成一片。真正的硬约束写在 img 的 inline style 上,
1209
+ // 这两条只是同值的样式兜底,避免「样式没注入成功就失控」。
1210
+ '.dsh-mcp-md-img{display:block;box-sizing:border-box;width:auto;max-width:100%;max-height:min(40vh,420px);height:auto;margin:10px 0;border-radius:8px;object-fit:contain;background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.08))}',
1211
+ '.dsh-mcp-md-img-inline{display:inline-block;max-height:22px;max-width:min(100%,320px);width:auto;height:auto;margin:0 2px;vertical-align:middle;border-radius:4px;object-fit:contain}',
1212
+ // 兜底:任何来源塞进正文的 img(含裸 HTML 块里未被抽出的)都不许超出弹层宽度。
1213
+ '.dsh-mcp-readme-body img{max-width:100%}',
1214
+ '.dsh-mcp-readme-body table img{max-height:min(24vh,220px)}',
1215
+ '.dsh-mcp-md-table{width:100%;margin:10px 0;border-collapse:collapse;font-size:.92em}',
1216
+ '.dsh-mcp-md-table th,.dsh-mcp-md-table td{padding:6px 10px;border:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.25));text-align:start;vertical-align:top}',
1217
+ '.dsh-mcp-md-table th{background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.10));font-weight:600}',
1218
+ '.dsh-mcp-md-a{color:var(--dsw-alias-accent,#3b82f6);text-decoration:none}',
1219
+ '.dsh-mcp-md-a:hover{text-decoration:underline}',
1220
+ '.dsh-mcp-md-hr{margin:16px 0;border:0;border-top:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.25))}',
1221
+ ].join('\n');
1222
+ document.head.appendChild(style);
1223
+ }
1224
+
1225
+ /** 链接/图片地址白名单:只放行 http(s) 与 data:image,其余(javascript: 等)一律丢弃。 */
1226
+ function readmeSafeUrl(raw, base) {
1227
+ const text = String(raw == null ? '' : raw).trim().replace(/^<|>$/g, '');
1228
+ if (!text) return '';
1229
+ if (/^data:image\//i.test(text)) return text;
1230
+ if (/^mailto:/i.test(text)) return text;
1231
+ if (/^\/\//.test(text)) return 'https:' + text;
1232
+ if (/^https?:\/\//i.test(text)) return text;
1233
+ if (text.startsWith('#')) return text;
1234
+ if (!base) return '';
1235
+ const rel = text.replace(/^\.\//, '');
1236
+ if (rel.startsWith('/')) {
1237
+ // 站内绝对路径:GitHub raw 的 base 形如 .../<owner>/<repo>/HEAD/,回到仓库根。
1238
+ const root = /^(https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/[^/]+)\//.exec(base);
1239
+ return root ? root[1] + '/' + rel.slice(1) : '';
1240
+ }
1241
+ try {
1242
+ const resolved = new URL(rel, base);
1243
+ // 关键:new URL('javascript:alert(1)', base) 解析成功且协议是 javascript:,
1244
+ // 只靠 base 拼相对路径会把伪协议放行成可点击 href,这里必须再校验一次协议。
1245
+ if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') return '';
1246
+ return resolved.toString();
1247
+ } catch {
1248
+ return '';
1249
+ }
1250
+ }
1251
+
1252
+ /** 解析 `![alt](url "title")` / `[text](url "title")`(url 里允许成对括号)。 */
1253
+ function readmeSplitLink(token) {
1254
+ const match = /^(!?)\[([^\]\n]*)\]\(\s*([^()\s]*(?:\([^()]*\)[^()\s]*)*)(?:\s+"([^"\n]*)")?\s*\)$/.exec(String(token || ''));
1255
+ if (!match) return null;
1256
+ return { image: match[1] === '!', label: match[2], url: match[3], title: match[4] || '' };
1257
+ }
1258
+
1259
+ /* README 插图缩图代理。
1260
+ * 直连 GitHub 原图有两个硬伤:①README 截图常见 1600~3000px、单张几百 KB 到几 MB,
1261
+ * 跨国拉取常只有几十 KB/s,浏览器自上而下逐行解码,看起来就是「图只显示了一部分」;
1262
+ * ②原图字节多,弹层一打开就并发十几张,把带宽抢光,整个弹层都卡。
1263
+ * 所以 GitHub 系图片先走 wsrv.nl 缩到 880 宽(webp q72,体积通常降到 1/20),
1264
+ * 代理不可用时 onError 回退原图一次,再失败才隐藏——不牺牲可用性。 */
1265
+ const README_IMG_PROXY = 'https://wsrv.nl/?output=webp&q=72&w=880&url=';
1266
+ const README_IMG_PROXY_HOSTS = [
1267
+ 'raw.githubusercontent.com',
1268
+ 'user-images.githubusercontent.com',
1269
+ 'private-user-images.githubusercontent.com',
1270
+ 'objects.githubusercontent.com',
1271
+ 'camo.githubusercontent.com',
1272
+ 'github.com',
1273
+ ];
1274
+
1275
+ /** 只给 GitHub 系图源生成代理地址;其他源(如 npm registry、shields.io)原样返回 ''。 */
1276
+ function readmeProxiedSrc(src) {
1277
+ try {
1278
+ const host = new URL(src).hostname.toLowerCase();
1279
+ if (README_IMG_PROXY_HOSTS.indexOf(host) < 0) return '';
1280
+ return README_IMG_PROXY + encodeURIComponent(src);
1281
+ } catch {
1282
+ return '';
1283
+ }
1284
+ }
1285
+
1286
+ /** `![alt](url)` → img;相对路径按 base 还原成绝对 URL,还原不了就跳过(不产生坏图)。
1287
+ * 尺寸硬约束写在 inline style 上:不依赖 <style> 是否注入成功,也不怕被别处的 img 规则盖掉。 */
1288
+ function readmeImageNode(src, alt, makeKey, base, opts) {
1289
+ const resolved = readmeSafeUrl(src, base);
1290
+ if (!resolved) return null;
1291
+ const inline = Boolean(opts && opts.inline);
1292
+ const proxied = inline ? '' : readmeProxiedSrc(resolved);
1293
+ const props = {
1294
+ key: makeKey('img'),
1295
+ className: inline ? 'dsh-mcp-md-img-inline' : 'dsh-mcp-md-img',
1296
+ src: proxied || resolved,
1297
+ alt: String(alt || ''),
1298
+ loading: 'lazy',
1299
+ decoding: 'async',
1300
+ referrerPolicy: 'no-referrer',
1301
+ style: inline
1302
+ // 行内小图标:压到行高附近,最多占 320px 宽,不把整段文字顶开。
1303
+ ? { display: 'inline-block', width: 'auto', height: 'auto', maxWidth: 'min(100%,320px)', maxHeight: '22px', objectFit: 'contain', verticalAlign: 'middle', margin: '0 2px' }
1304
+ // 块级插图:同时限宽限高,等比缩放不裁切;40vh 保证小窗口也能整张看到。
1305
+ : { display: 'block', boxSizing: 'border-box', width: 'auto', height: 'auto', maxWidth: '100%', maxHeight: 'min(40vh,420px)', objectFit: 'contain', margin: '10px 0', borderRadius: '8px', background: 'var(--dsw-alias-bg-subtle, rgba(127,127,127,.06))' },
1306
+ onError: (event) => {
1307
+ const node = event && event.currentTarget;
1308
+ if (!node) return;
1309
+ // 代理挂了退回原图一次;原图也挂了才隐藏(徽章 404 很常见,别留一排破图标)。
1310
+ const origin = proxied ? node.getAttribute('data-readme-origin') : '';
1311
+ if (origin) {
1312
+ node.setAttribute('src', origin);
1313
+ node.removeAttribute('data-readme-origin');
1314
+ return;
1315
+ }
1316
+ if (node.style) node.style.display = 'none';
1317
+ },
1318
+ };
1319
+ if (proxied) props['data-readme-origin'] = resolved;
1320
+ return h('img', props);
1321
+ }
1322
+
1323
+ const README_INLINE_RE = /(`[^`\n]+`)|(!\[[^\]\n]*\]\([^()\s]*(?:\([^()]*\)[^()\s]*)*(?:\s+"[^"\n]*")?\s*\))|(\[[^\]\n]+\]\([^()\s]*(?:\([^()]*\)[^()\s]*)*(?:\s+"[^"\n]*")?\s*\))|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|(_[^_\n]+_)|(<https?:\/\/[^>\s]+>)|(<img\b[^>]*>)|(<br\s*\/?>)/i;
1324
+
1325
+ /** 行内语法 → React 节点数组。纯扫描,不做任何 HTML 解析。 */
1326
+ function renderReadmeInline(text, makeKey, base) {
1327
+ const out = [];
1328
+ const source = String(text || '');
1329
+ // 每次调用都用独立的正则实例:renderReadmeInline 会递归(粗体/斜体/链接文字),
1330
+ // 共享 /g 实例的 lastIndex 会被递归内层归零,外层游标因此回退到起点 → 死循环(曾整页 OOM)。
1331
+ const re = new RegExp(README_INLINE_RE.source, 'gi');
1332
+ let cursor = 0;
1333
+ let match = re.exec(source);
1334
+ const pushText = (value) => {
1335
+ if (!value) return;
1336
+ out.push(value);
1337
+ };
1338
+ while (match) {
1339
+ pushText(source.slice(cursor, match.index));
1340
+ const token = match[0];
1341
+ if (token.startsWith('`')) {
1342
+ out.push(h('code', { key: makeKey('code'), className: 'dsh-mcp-md-code' }, token.slice(1, -1)));
1343
+ } else if (token.startsWith('![') || token.startsWith('[')) {
1344
+ const link = readmeSplitLink(token);
1345
+ if (!link) {
1346
+ pushText(token);
1347
+ } else if (link.image) {
1348
+ const node = readmeImageNode(link.url, link.label, makeKey, base, { inline: out.length > 0 });
1349
+ if (node) out.push(node);
1350
+ } else {
1351
+ const href = readmeSafeUrl(link.url, base);
1352
+ out.push(
1353
+ href
1354
+ ? h('a', { key: makeKey('a'), className: 'dsh-mcp-md-a', href, title: link.title || undefined, target: '_blank', rel: 'noreferrer noopener' }, renderReadmeInline(link.label, makeKey, base))
1355
+ : h('span', { key: makeKey('span') }, renderReadmeInline(link.label, makeKey, base)),
1356
+ );
1357
+ }
1358
+ } else if (token.startsWith('**') || token.startsWith('__')) {
1359
+ out.push(h('strong', { key: makeKey('strong') }, renderReadmeInline(token.slice(2, -2), makeKey, base)));
1360
+ } else if (token.startsWith('~~')) {
1361
+ out.push(h('del', { key: makeKey('del') }, renderReadmeInline(token.slice(2, -2), makeKey, base)));
1362
+ } else if (token.startsWith('*') || token.startsWith('_')) {
1363
+ out.push(h('em', { key: makeKey('em') }, renderReadmeInline(token.slice(1, -1), makeKey, base)));
1364
+ } else if (token.startsWith('<http')) {
1365
+ const href = readmeSafeUrl(token.slice(1, -1), base);
1366
+ out.push(
1367
+ href
1368
+ ? h('a', { key: makeKey('a'), className: 'dsh-mcp-md-a', href, target: '_blank', rel: 'noreferrer noopener' }, token.slice(1, -1))
1369
+ : token,
1370
+ );
1371
+ } else if (/^<img/i.test(token)) {
1372
+ const srcMatch = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(token);
1373
+ const altMatch = /\balt\s*=\s*["']([^"']*)["']/i.exec(token);
1374
+ const node = srcMatch ? readmeImageNode(srcMatch[1], altMatch ? altMatch[1] : '', makeKey, base, { inline: true }) : null;
1375
+ if (node) out.push(node);
1376
+ } else if (/^<br/i.test(token)) {
1377
+ out.push(h('br', { key: makeKey('br') }));
1378
+ }
1379
+ cursor = match.index + token.length;
1380
+ if (!token) break;
1381
+ match = re.exec(source);
1382
+ }
1383
+ pushText(source.slice(cursor));
1384
+ return out;
1385
+ }
1386
+
1387
+ /** 从一段裸 HTML 里薅出 <img src>:README 的横幅几乎都藏在这种块里。 */
1388
+ function readmeHtmlImages(text) {
1389
+ const out = [];
1390
+ const re = /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
1391
+ let match = re.exec(String(text || ''));
1392
+ while (match) {
1393
+ out.push(match[1]);
1394
+ match = re.exec(String(text || ''));
1395
+ }
1396
+ return out;
1397
+ }
1398
+
1399
+ /** 裸 HTML 块 → 文本(去标签、留文字),用于既没图也没链接的说明性 HTML。 */
1400
+ function readmeHtmlText(text) {
1401
+ return String(text || '')
1402
+ .replace(/<br\s*\/?>/gi, '\n')
1403
+ .replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n')
1404
+ .replace(/<[^>]+>/g, '')
1405
+ .replace(/&nbsp;/gi, ' ')
1406
+ .replace(/&amp;/gi, '&')
1407
+ .replace(/&lt;/gi, '<')
1408
+ .replace(/&gt;/gi, '>')
1409
+ .replace(/&quot;/gi, '"')
1410
+ .replace(/[ \t]+\n/g, '\n')
1411
+ .trim();
1412
+ }
1413
+
1414
+ /**
1415
+ * markdown → React 节点数组。`base` 用来还原相对图片/链接。
1416
+ * 解析器只认行首块级语法,行内交给 renderReadmeInline;认不出的行按段落处理,
1417
+ * 所以畸形 README 最差也只是排版朴素,不会崩、不会吞内容。
1418
+ */
1419
+ function renderReadmeBlocks(markdown, base) {
1420
+ const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
1421
+ const blocks = [];
1422
+ let seq = 0;
1423
+ const makeKey = (tag) => `md-${tag}-${seq++}`;
1424
+ let index = 0;
1425
+ let paragraph = [];
1426
+
1427
+ const flushParagraph = () => {
1428
+ if (!paragraph.length) return;
1429
+ const text = paragraph.join(' ').replace(/\s+/g, ' ').trim();
1430
+ paragraph = [];
1431
+ if (text) blocks.push(h('p', { key: makeKey('p'), className: 'dsh-mcp-md-p' }, renderReadmeInline(text, makeKey, base)));
1432
+ };
1433
+
1434
+ while (index < lines.length) {
1435
+ const line = lines[index];
1436
+
1437
+ // 围栏代码块:整段原样保留,不再做行内解析。
1438
+ const fence = /^\s*(```+|~~~+)\s*([\w+#.-]*)\s*$/.exec(line);
1439
+ if (fence) {
1440
+ flushParagraph();
1441
+ const marker = fence[1][0];
1442
+ const body = [];
1443
+ index += 1;
1444
+ while (index < lines.length && !new RegExp('^\\s*' + marker + '{3,}\\s*$').test(lines[index])) {
1445
+ body.push(lines[index]);
1446
+ index += 1;
1447
+ }
1448
+ index += 1;
1449
+ blocks.push(h('pre', { key: makeKey('pre'), className: 'dsh-mcp-md-pre' }, h('code', null, body.join('\n'))));
1450
+ continue;
1451
+ }
1452
+
1453
+ if (!line.trim()) {
1454
+ flushParagraph();
1455
+ index += 1;
1456
+ continue;
1457
+ }
1458
+
1459
+ // 分隔线(--- / *** / ___,允许 3 个以上)
1460
+ if (/^\s*([-*_])\s*(?:\1\s*){2,}$/.test(line)) {
1461
+ flushParagraph();
1462
+ blocks.push(h('hr', { key: makeKey('hr'), className: 'dsh-mcp-md-hr' }));
1463
+ index += 1;
1464
+ continue;
1465
+ }
1466
+
1467
+ // 标题:h1~h6 统一渲染成带 aria-level 的 div,别和弹窗自己的 h3 打架。
1468
+ const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
1469
+ if (heading) {
1470
+ flushParagraph();
1471
+ const level = heading[1].length;
1472
+ blocks.push(
1473
+ h(
1474
+ 'div',
1475
+ {
1476
+ key: makeKey('h'),
1477
+ className: 'dsh-mcp-md-h',
1478
+ role: 'heading',
1479
+ 'aria-level': String(Math.min(6, level + 2)),
1480
+ style: { fontSize: level <= 1 ? '18px' : level === 2 ? '16px' : level === 3 ? '15px' : '14px' },
1481
+ },
1482
+ renderReadmeInline(heading[2], makeKey, base),
1483
+ ),
1484
+ );
1485
+ index += 1;
1486
+ continue;
1487
+ }
1488
+
1489
+ // 表格:当前行含 | 且下一行是 |---|---| 分隔行
1490
+ if (line.includes('|') && index + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[index + 1]) && lines[index + 1].includes('-')) {
1491
+ flushParagraph();
1492
+ const splitRow = (row) =>
1493
+ String(row)
1494
+ .replace(/^\s*\|/, '')
1495
+ .replace(/\|\s*$/, '')
1496
+ .split('|')
1497
+ .map((cell) => cell.trim());
1498
+ const header = splitRow(line);
1499
+ index += 2;
1500
+ const rows = [];
1501
+ while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
1502
+ rows.push(splitRow(lines[index]));
1503
+ index += 1;
1504
+ }
1505
+ blocks.push(
1506
+ h(
1507
+ 'table',
1508
+ { key: makeKey('table'), className: 'dsh-mcp-md-table' },
1509
+ h('thead', null, h('tr', null, header.map((cell, cellIndex) => h('th', { key: cellIndex }, renderReadmeInline(cell, makeKey, base))))),
1510
+ h(
1511
+ 'tbody',
1512
+ null,
1513
+ rows.map((row, rowIndex) =>
1514
+ h('tr', { key: rowIndex }, header.map((_, cellIndex) => h('td', { key: cellIndex }, renderReadmeInline(row[cellIndex] || '', makeKey, base)))),
1515
+ ),
1516
+ ),
1517
+ ),
1518
+ );
1519
+ continue;
1520
+ }
1521
+
1522
+ // 引用:连续 > 行合成一段
1523
+ if (/^\s*>/.test(line)) {
1524
+ flushParagraph();
1525
+ const quote = [];
1526
+ while (index < lines.length && /^\s*>/.test(lines[index])) {
1527
+ quote.push(lines[index].replace(/^\s*>\s?/, ''));
1528
+ index += 1;
1529
+ }
1530
+ blocks.push(h('blockquote', { key: makeKey('quote'), className: 'dsh-mcp-md-quote' }, h('p', { className: 'dsh-mcp-md-p' }, renderReadmeInline(quote.join(' '), makeKey, base))));
1531
+ continue;
1532
+ }
1533
+
1534
+ // 裸 HTML 块:先薅图片,再留文字。
1535
+ if (/^\s*</.test(line) && !/^\s*<https?:/i.test(line)) {
1536
+ flushParagraph();
1537
+ const chunk = [];
1538
+ while (index < lines.length && /^\s*</.test(lines[index]) && !/^\s*<https?:/i.test(lines[index])) {
1539
+ chunk.push(lines[index]);
1540
+ index += 1;
1541
+ }
1542
+ const rawHtml = chunk.join('\n');
1543
+ const srcs = readmeHtmlImages(rawHtml);
1544
+ const nodes = [];
1545
+ srcs.forEach((src) => {
1546
+ const node = readmeImageNode(src, '', makeKey, base, { inline: false });
1547
+ if (node) nodes.push(node);
1548
+ });
1549
+ const leftover = readmeHtmlText(rawHtml);
1550
+ if (leftover) {
1551
+ nodes.push(h('p', { key: makeKey('htmltext'), className: 'dsh-mcp-md-p' }, renderReadmeInline(leftover.replace(/\n+/g, ' '), makeKey, base)));
1552
+ }
1553
+ if (nodes.length) blocks.push(h('div', { key: makeKey('html'), className: 'dsh-mcp-md-htmlblock' }, nodes));
1554
+ continue;
1555
+ }
1556
+
1557
+ // 列表项:按缩进算层级,深度用 padding 表达(README 的嵌套一般不超过 3 层)
1558
+ const listItem = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
1559
+ if (listItem) {
1560
+ flushParagraph();
1561
+ const ordered = /\d/.test(listItem[2]);
1562
+ const items = [];
1563
+ while (index < lines.length) {
1564
+ const current = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[index]);
1565
+ if (!current) break;
1566
+ const depth = Math.min(3, Math.floor(current[1].replace(/\t/g, ' ').length / 2));
1567
+ items.push({ depth, text: current[3] });
1568
+ index += 1;
1569
+ }
1570
+ blocks.push(
1571
+ h(
1572
+ ordered ? 'ol' : 'ul',
1573
+ { key: makeKey('list'), className: ordered ? 'dsh-mcp-md-ol' : 'dsh-mcp-md-ul' },
1574
+ items.map((item, itemIndex) =>
1575
+ h('li', { key: itemIndex, className: 'dsh-mcp-md-li', style: item.depth ? { marginInlineStart: item.depth * 16 + 'px' } : undefined }, renderReadmeInline(item.text, makeKey, base)),
1576
+ ),
1577
+ ),
1578
+ );
1579
+ continue;
1580
+ }
1581
+
1582
+ paragraph.push(line.trim());
1583
+ index += 1;
1584
+ }
1585
+ flushParagraph();
1586
+ return blocks;
1587
+ }
1588
+
1086
1589
  // ── 样式表 MCP_CSS:主题令牌别名 + 全部面板规则(一次性注入 <style>)──────────────
1087
1590
  /* 主题令牌别名层。DSH 的 ui-theme 拥有全部颜色、几何与字体:插件只消费语义令牌,
1088
1591
  不写死色值、不写局部圆角数值(docs/web-styling.md:「Use --dsw-alias-* semantic
@@ -1594,6 +2097,142 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1594
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)}
1595
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}}
1596
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
+
1597
2236
  /* 工具裁缝(Tool Tailor):账本表格 + 税单卡 + 死工具清单。 */
1598
2237
  .dsh-mcp-tailor{display:flex;flex-direction:column;gap:12px;min-height:0}
1599
2238
  .dsh-mcp-tailor-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;flex:none}
@@ -1621,10 +2260,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
1621
2260
  .dsh-mcp-tailor-empty{color:var(--mcp-mut);font-size:13px;padding:18px 14px}
1622
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}
1623
2262
  .dsh-mcp-tailor-note b{color:var(--dsw-alias-label-primary)}
1624
- /* OPT-19 · 4.3:账本缓存占用行(小号弱化文本 + 行内清空按钮),贴在工具条下方。 */
2263
+ /* 账本缓存占用行(只读读数,贴在工具条下方)。行内清空按钮已于 2026-10-23 删除,见
2264
+ lib/client.js 里 cacheLine 的注释;这条只剩文本,所以配套的 .cache-clear 规则一并删掉。 */
1625
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}
1626
2266
  .dsh-mcp-tailor-cache-label{color:var(--dsw-alias-label-tertiary)}
1627
- .dsh-mcp-tailor-cache .dsh-mcp-tailor-cache-clear{height:22px;padding:0 10px;font-size:11px;line-height:20px;flex:none}
1628
2267
  /* 解码告警(OPT-02):正常运行时整块不渲染,所以这里可以给足视觉权重。 */
1629
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}
1630
2269
  .dsh-mcp-tailor-warn b{color:var(--mcp-warn)}
@@ -2082,6 +2721,11 @@ const ICONS = {
2082
2721
  // grid:网格视图(2×2 方块);list:列表视图(三行点线),Skill 看板视图切换用
2083
2722
  grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
2084
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',
2085
2729
  };
2086
2730
 
2087
2731
  // ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
@@ -4321,6 +4965,12 @@ const SKILL_SOURCE_LABELS = {
4321
4965
  };
4322
4966
  const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
4323
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
+
4324
4974
  // ── Skill 分类升级(决策 6/10):6 类 + 未分类兜底,两段式强/弱特征词匹配 ─────────
4325
4975
  // 为什么两段式:中英混排描述里 code/image/frontend 这类词跨类污染严重,单层表会把
4326
4976
  // imagegen-frontend-* 误判成「编程开发」。先按 SKILL_CAT_MATCH_ORDER 整体扫强特征词
@@ -4429,8 +5079,6 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4429
5079
  // 零调用的候选项。旧版默认打开「只看零调用」,屏幕上必然整列 0,看上去像「检测
4430
5080
  // 全坏」——那是筛选口径而不是数据问题。默认关掉,筛选交给用户显式开启。
4431
5081
  const [onlyDead, setOnlyDead] = useState(false);
4432
- // OPT-19 · 4.3:清空缓存是独立动作,busy 态与全局扫描分开,避免清空期间误开「重扫」。
4433
- const [clearing, setClearing] = useState(false);
4434
5082
  const flashCancel = useRef(null);
4435
5083
  const days = Number(windowKey) || 30;
4436
5084
  // 父层先把 30 天窗口扫好了(角标要用)。窗口一致且已有报告时,首次挂载**不必**再扫;
@@ -4496,43 +5144,23 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4496
5144
  }
4497
5145
  };
4498
5146
 
4499
- // OPT-19 · 4.3:解压文本缓存行 + 清空。宿主侧 `action: 'clearTextCache'` 是「先清后扫」
4500
- // (lib/index.js toolTailor 分支),一次调用同时完成清空与新报告,无需再补一发 load。
4501
- const clearCache = async () => {
4502
- if (clearing || busy) return;
4503
- setClearing(true);
4504
- setActionError('');
4505
- try {
4506
- const res = await call('toolTailor', { action: 'clearTextCache', days });
4507
- if (res && res.ok) {
4508
- const freed = res.textCache && typeof res.textCache.clearedBytes === 'number' ? res.textCache.clearedBytes : null;
4509
- commitReport(res);
4510
- setActionError('');
4511
- flash(freed !== null ? t('已清空缓存,释放 {0}', [fmtBytes(freed)]) : t('正在清空缓存…'));
4512
- } else {
4513
- setActionError((res && res.error) || t('toolTailor 调用失败'));
4514
- }
4515
- } catch (e) {
4516
- setActionError(String((e && e.message) || e));
4517
- } finally {
4518
- setClearing(false);
4519
- }
4520
- };
4521
-
4522
- // 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%)一并从这行去掉 —— 它原本就是用来把用户推向这个已经不存在的按钮。
4523
5157
  const cacheLine = report && report.textCache && typeof report.textCache.entries === 'number'
4524
5158
  ? (() => {
4525
5159
  const cache = report.textCache;
4526
5160
  const fmtI = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—');
4527
5161
  return h('div', { className: 'dsh-mcp-tailor-cache' },
4528
5162
  h('span', { className: 'dsh-mcp-tailor-cache-label' }, t('账本缓存')),
4529
- h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条') + ' · ' + fmtPct(cache.utilization)),
4530
- h('button', {
4531
- type: 'button',
4532
- className: 'dsh-mcp-btn dsh-mcp-tailor-cache-clear',
4533
- onClick: clearCache,
4534
- disabled: clearing || busy,
4535
- }, clearing ? t('正在清空缓存…') : t('清空缓存')),
5163
+ h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条')),
4536
5164
  );
4537
5165
  })()
4538
5166
  : null;
@@ -4562,13 +5190,24 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4562
5190
  ? t('{0} 个日志帧解码失败,调用计数可能偏低', [tailorNum(report.failedFrames, report)])
4563
5191
  : '';
4564
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
+ : '';
4565
5202
  // 正常运行时这一段**完全不渲染**(无噪音),只有真出问题才占位。
4566
- const decodeIssue = decodeWarning || scanFailures.length
5203
+ const decodeIssue = decodeWarning || scanFailures.length || undatedWarning || truncatedWarning
4567
5204
  ? h('div', { className: 'dsh-mcp-tailor-warn' },
4568
5205
  decodeWarning ? h('div', null, h('b', null, t('⚠ 数据可能不完整:')), decodeWarning) : null,
4569
5206
  report && report.zstdSupported === false
4570
5207
  ? h('div', null, t('当前运行在 Node {0},请升级到 22.15 以上后重启宿主。', [report.nodeVersion || t('(未知版本)')]))
4571
5208
  : null,
5209
+ undatedWarning ? h('div', null, undatedWarning) : null,
5210
+ truncatedWarning ? h('div', null, truncatedWarning) : null,
4572
5211
  scanFailures.length
4573
5212
  ? h('details', { className: 'dsh-mcp-tailor-details' },
4574
5213
  h('summary', null, t('{0} 个日志文件未能读取(含超限跳过),展开看原因', [tailorNum(report.failedFiles, report)])),
@@ -4578,6 +5217,31 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4578
5217
  )
4579
5218
  : null;
4580
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
+
4581
5245
  // §5.2 头部指标分层:原来四张同权重卡把「上下文税」和「调用总数」摆成一样大,
4582
5246
  // 于是用户读完四个数字仍然不知道最该动什么。真正需要行动的是「每轮白缴多少税」,
4583
5247
  // 它才是本页存在的理由;调用数只是背景。因此税单独放大居首,其余三个降为次级 KPI 行。
@@ -4585,6 +5249,15 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4585
5249
  // 画一排等高灰条假装有数据,比不画更误导。
4586
5250
  const daily = report && Array.isArray(report.dailyCalls) ? report.dailyCalls : [];
4587
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
+
4588
5261
  const errRate = report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0;
4589
5262
  // 错误率阈值按文档口径:<5% 是常态(绿),≥5% 才值得红着显示。
4590
5263
  const errTone = errRate >= 0.05 ? 'err' : 'ok';
@@ -4602,6 +5275,14 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4602
5275
  : t('扫描后给出'),
4603
5276
  ),
4604
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
+
4605
5286
  ),
4606
5287
  h('div', { className: 'dsh-mcp-tailor-kpis' },
4607
5288
  // OPT-18:四张 KPI 的数字都随报告来,zstd 不可用时 0 是「测不到」→ 统一 tailorNum。
@@ -4630,11 +5311,13 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
4630
5311
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, t('重扫')),
4631
5312
  h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
4632
5313
  ),
4633
- // OPT-19 · 4.3:缓存占用 + 一键清空(先清后扫,一次调用拿新报告)。
5314
+ // 账本缓存读数(只读,动作已删;见 cacheLine 上方注释)。
4634
5315
  cacheLine,
4635
5316
  error ? h('div', { className: 'dsh-mcp-tailor-note' }, t('账本加载失败:'), fmtScanError(error)) : null,
4636
5317
  // 解码告警放在数据和表格**上方**:调用数整列 0 时,用户第一眼要能看到「为什么」。
4637
5318
  decodeIssue,
5319
+ attribution,
5320
+
4638
5321
  actionError ? h('div', { className: 'dsh-mcp-tailor-note' }, actionError) : null,
4639
5322
  flashMsg ? h('div', { className: 'dsh-mcp-tailor-note' }, flashMsg) : null,
4640
5323
  h('div', { className: 'dsh-mcp-tailor-table' },
@@ -4736,6 +5419,503 @@ const CatHintPopover = ({ hint, category, counts }) => {
4736
5419
  category !== hint.key ? h('div', { className: 'dsh-mcp-cat-pop-foot' }, t('点击切换到此分类')) : null,
4737
5420
  );
4738
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
+
4739
5919
  // 宿主侧接口不存在(HTTP 404 / transport failure)几乎总是「宿主还没重启」:
4740
5920
  // 客户端 bundle 从磁盘现读、改完刷新即生效,而宿主的 Remote 方法在启动时才注册。
4741
5921
  // 这里把生硬的传输错误翻译成可操作的提示,而不是让用户对着 404 猜。
@@ -4744,6 +5924,22 @@ const skillTransportHint = (message) =>
4744
5924
  ? String(message) + t(' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
4745
5925
  : String(message);
4746
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
+
4747
5943
  // ── Skill 看板:列表 + 详情 ──────────────────────────────────────────
4748
5944
  // 数据来自宿主新增的 listSkills / skillDetail(走同一个 mcpManager Remote 命名空间,
4749
5945
  // 复用既有的 call 信封 {ok, ...}),不引入第二条 Remote 通道。
@@ -4762,14 +5958,26 @@ const SkillBoard = ({ call, scope, timer }) => {
4762
5958
  const [detail, setDetail] = useState(null);
4763
5959
  const [detailLoading, setDetailLoading] = useState(false);
4764
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('');
4765
5966
  // 分类升级(决策 3/4/7):分类单选 + 网格/列表双视图,全部记忆到 URL hash
4766
5967
  // (skillcat/skillview/skillpick),刷新与分享都能回到原处;'' = 默认态不写键。
4767
5968
  const [cat, setCat] = useState('');
4768
5969
  const [view, setView] = useState('');
4769
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 }
4770
5977
  const loadSeq = useRef(0);
4771
5978
  const detailSeq = useRef(0);
4772
5979
  const toastCancel = useRef(null);
5980
+ const noticeCancel = useRef(null);
4773
5981
  // 作用域切换检测(skillpick 只在真换作用域时清)+ skillpick 恢复只做一次的哨兵。
4774
5982
  const mountedScope = useRef(null);
4775
5983
  const seededPick = useRef(false);
@@ -4798,6 +6006,17 @@ const SkillBoard = ({ call, scope, timer }) => {
4798
6006
  }
4799
6007
  }, [call, scope]);
4800
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
+
4801
6020
  useEffect(() => {
4802
6021
  // cat/view 从 hash 恢复(作用域切换时 hash 仍是唯一真值源,直接重读)。
4803
6022
  const seededCat = readHashParam('skillcat');
@@ -4813,7 +6032,12 @@ const SkillBoard = ({ call, scope, timer }) => {
4813
6032
  setDetailError('');
4814
6033
  setQuery('');
4815
6034
  load(false);
4816
- }, [load]);
6035
+ loadSuites();
6036
+ }, [load, loadSuites]);
6037
+
6038
+ // 本看板挂着时收到变更(目前主要是自己卸载成功,以及将来其它入口)立即重读真值。
6039
+ // 不挂着时不用补——上面的挂载 effect 每次都会 load(false),切回本页天然是新数据。
6040
+ useEffect(() => onSkillChange(() => { load(true); }), [load]);
4817
6041
 
4818
6042
  const queryKey = (query || '').toLowerCase();
4819
6043
  // 分类判定只依赖 name+description、与筛选无关 → 一次 memo 全量映射;
@@ -4873,7 +6097,10 @@ const SkillBoard = ({ call, scope, timer }) => {
4873
6097
 
4874
6098
 
4875
6099
  const openDetail = useCallback(async (name) => {
6100
+ if (removing) return; // 卸载在途时不切换选中,避免成功回调关掉另一条详情
4876
6101
  setSelected(name);
6102
+ setConfirmRemove(false);
6103
+ setRemoveError('');
4877
6104
  // 选中态记忆到 hash(决策 4),刷新/分享可回到同一技能详情。
4878
6105
  writeHashParams({ skillpick: name });
4879
6106
  const seq = ++detailSeq.current;
@@ -4891,7 +6118,7 @@ const SkillBoard = ({ call, scope, timer }) => {
4891
6118
  } finally {
4892
6119
  if (seq === detailSeq.current) setDetailLoading(false);
4893
6120
  }
4894
- }, [call, scope]);
6121
+ }, [call, scope, removing]);
4895
6122
 
4896
6123
  // 关掉网格视图的详情弹窗:选中态、正文、报错、toast 一起清,并把 hash 里的 skillpick 撤回。
4897
6124
  // 只清 UI 不清 hash 的话,刷新/分享链接会把弹窗又弹回来,看起来像「关不掉」。
@@ -4901,10 +6128,19 @@ const SkillBoard = ({ call, scope, timer }) => {
4901
6128
  setDetail(null);
4902
6129
  setDetailError('');
4903
6130
  setDetailLoading(false);
6131
+ setConfirmRemove(false);
6132
+ setRemoveError('');
4904
6133
  setToast(null);
4905
6134
  writeHashParams({ skillpick: '' });
4906
6135
  }, []);
4907
6136
 
6137
+ // 卸载在途时拒绝关闭详情:请求不可取消,关掉后失败原因就无处可显示。
6138
+ // 所有「用户主动关」的入口都走这里;removeSelected 成功后用 closeDetail 强关。
6139
+ const requestCloseDetail = useCallback(() => {
6140
+ if (removing) return;
6141
+ closeDetail();
6142
+ }, [removing, closeDetail]);
6143
+
4908
6144
  // 弹窗要能被 Escape 关掉,前提是焦点在它里面。ref 回调在节点挂载时触发,
4909
6145
  // 比再挂一个 useEffect 更贴近「出现即聚焦」,也省掉一轮渲染。
4910
6146
  const focusModal = useCallback((el) => {
@@ -4926,6 +6162,19 @@ const SkillBoard = ({ call, scope, timer }) => {
4926
6162
  return off;
4927
6163
  }, [openDetail]);
4928
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
+
4929
6178
  // skillpick 恢复(决策 4):列表就绪后按 hash 打开详情,只做一次;scope 切换时哨兵复位。
4930
6179
  useEffect(() => {
4931
6180
  if (seededPick.current || loading || !skills.length) return;
@@ -4936,6 +6185,7 @@ const SkillBoard = ({ call, scope, timer }) => {
4936
6185
 
4937
6186
  const summary = selected ? skills.find((s) => s.name === selected) : null;
4938
6187
  const summaryCat = summary ? (catOf.get(summary.name) || SKILL_UNCAT) : null;
6188
+
4939
6189
  // 网格视图不再占右侧详情栏:有选中就浮成居中弹窗;列表视图仍走右侧栏(决策见 §12)。
4940
6190
  // 注意 view 的取值是 'list' | ''(网格是空串,见 setView(readHashParam('skillview') === 'list' ...)),
4941
6191
  // 所以判据必须是 !== 'list',写成 === 'grid' 会让弹窗永远不出现(卡片点了像没反应)。
@@ -4962,6 +6212,14 @@ const SkillBoard = ({ call, scope, timer }) => {
4962
6212
  toastCancel.current = timer.timeout(() => setToast(null), 2600);
4963
6213
  }, [timer]);
4964
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(); }, []);
4965
6223
  const copySkillMdPath = () => {
4966
6224
  const dir = String((summary && summary.path) || '').replace(/[\\/]+$/, '');
4967
6225
  if (!dir) return;
@@ -4972,12 +6230,36 @@ const SkillBoard = ({ call, scope, timer }) => {
4972
6230
  copyText('@' + summary.name).then((ok) => showToast(ok ? t('已复制到剪贴板') : t('复制失败'), ok));
4973
6231
  };
4974
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
+
4975
6257
  // §1.4 空状态四件套:两种空况共用一份 JSX;「清除全部筛选」现在连同分类轨道一起清。
4976
6258
  const emptyBoard = skills.length === 0
4977
6259
  ? h(EmptyState, {
4978
6260
  icon: 'skill', title: t('这个作用域下没有发现 skill'),
4979
6261
  hint: t('skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现;放一个 SKILL.md 进去就会出现在这里。'),
4980
- actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => load(true) }],
6262
+ actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => { load(true); loadSuites(); } }],
4981
6263
  tight: true,
4982
6264
  })
4983
6265
  : h(EmptyState, {
@@ -5014,6 +6296,18 @@ const SkillBoard = ({ call, scope, timer }) => {
5014
6296
  title: t('列表视图'), onClick: () => setViewHash('list'),
5015
6297
  }, h(Icon, { d: ICONS.list, size: 13 }), t('列表视图')),
5016
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
+ ),
5017
6311
  ),
5018
6312
  // 分类轨道(决策 5:轨道在搜索下、chips 上;计数固定全量,决策 9)。
5019
6313
  // 分类与来源/权限是正交维度——分类筛「它是什么」,来源/权限筛「它从哪来/谁能调用」。
@@ -5035,13 +6329,14 @@ const SkillBoard = ({ call, scope, timer }) => {
5035
6329
  h('span', null, (loading && !refreshing) ? t('读取中…') : t('显示 {0} / {1} 个 skill', [filtered.length, skills.length])
5036
6330
  + (filtered.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
5037
6331
  refreshing ? h('span', null, t(' · 刷新中…')) : null,
5038
- // OPT-31 · 5.1:把「只读」从代码注释提到界面上。宿主 skill 注册表只有
5039
- // listSkills / skillDetail 两个读接口,没有启用/禁用/写入——这层语义以前只写在
5040
- // 源码注释里,用户会反复找不到开关。明示之后「没有开关」变成预期而非缺陷。
5041
- // 位置:筛选条之后、列表之前——读列表前先知道能做什么,比滚到底才发现划算。
6332
+ // OPT-31 · 5.1:把「能做什么、不能做什么」从代码注释提到界面上。
6333
+ // 宿主注册表本身只有 listSkills / skillDetail 两个读接口;卸载是本插件补的
6334
+ // removeSkill(仅限用户级/项目级目录)。这层边界不写明,用户会把「随包分发 /
6335
+ // 运行时注册不可卸载」当成缺陷反复找开关。
6336
+ // 位置:筛选条之后、列表之前——读列表前先知道能力边界,比滚到底才发现划算。
5042
6337
  h('div', { className: 'dsh-mcp-readonly-note' },
5043
6338
  h(Icon, { d: ICONS.eye, size: 12 }),
5044
- h('span', null, t('这个看板是只读的:宿主只提供 listSkills / skillDetail,没有启用/禁用或修改入口;要改内容请直接编辑 SKILL.md。')),
6339
+ h('span', null, t('宿主只提供 skill 的读取接口;这里可以卸载用户级/项目级目录里的 skill(永久删除,不可撤销)。随 DSH 分发、运行时注册、自定义目录的 skill 不可卸载——要改内容请直接编辑 SKILL.md。')),
5045
6340
  ),
5046
6341
  ),
5047
6342
  h('div', { className: 'dsh-mcp-skill-filters' },
@@ -5147,7 +6442,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5147
6442
  // 网格视图的详情以弹窗呈现(与市场卡片详情同一套观感),列表视图仍走右侧栏。
5148
6443
  // 遮罩单独做一层兄弟节点而不是包住弹窗:点遮罩即关,点弹窗内部不会冒泡到它。
5149
6444
  isModal
5150
- ? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick: closeDetail })
6445
+ ? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick: requestCloseDetail })
5151
6446
  : null,
5152
6447
  h('div', {
5153
6448
  className: 'dsh-mcp-skill-detail' + (isModal ? ' is-modal' : ''),
@@ -5160,7 +6455,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5160
6455
  ? (e) => {
5161
6456
  if (e.key === 'Escape') {
5162
6457
  e.stopPropagation();
5163
- closeDetail();
6458
+ requestCloseDetail();
5164
6459
  }
5165
6460
  }
5166
6461
  : undefined,
@@ -5172,7 +6467,7 @@ const SkillBoard = ({ call, scope, timer }) => {
5172
6467
  className: 'dsh-mcp-skill-d-close',
5173
6468
  title: t('关闭'),
5174
6469
  'aria-label': t('关闭'),
5175
- onClick: closeDetail,
6470
+ onClick: requestCloseDetail,
5176
6471
  }, h(Icon, { d: ICONS.x, size: 14 }))
5177
6472
  : null,
5178
6473
  !summary && !detailLoading
@@ -5222,7 +6517,31 @@ const SkillBoard = ({ call, scope, timer }) => {
5222
6517
  h('button', {
5223
6518
  type: 'button', className: 'dsh-mcp-btn', onClick: copySessionRef,
5224
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,
5225
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,
5226
6545
  detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, t('读取 skill 内容…')) : null,
5227
6546
  detailError ? h('div', { className: 'dsh-mcp-d-err' }, detailError) : null,
5228
6547
  detail && detail.content !== undefined ? h('div', { className: 'dsh-mcp-skill-d-sec' },
@@ -5233,6 +6552,23 @@ const SkillBoard = ({ call, scope, timer }) => {
5233
6552
  ) : null,
5234
6553
  ),
5235
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,
5236
6572
  );
5237
6573
  };
5238
6574
 
@@ -5251,7 +6587,7 @@ const connectorMatchesVisibleIn = (rowVisibleTags, selectedTags) => {
5251
6587
  return rowVisibleTags.some((tag) => selectedTags.includes(tag));
5252
6588
  };
5253
6589
  const WB_SKILLHUB_SORTS = [
5254
- { key: 'downloads', label: t('最多下载') },
6590
+ { key: 'downloads', label: t('下载量(本页排序)') },
5255
6591
  { key: 'installs', label: t('最多安装') },
5256
6592
  { key: 'stars', label: t('最多星标') },
5257
6593
  { key: 'score', label: t('综合评分') },
@@ -5325,6 +6661,10 @@ const ExpertInstalledBoard = ({ call }) => {
5325
6661
  const [presets, setPresets] = useState([]);
5326
6662
  const [skills, setSkills] = useState([]);
5327
6663
  const [detail, setDetail] = useState(null);
6664
+ // 正在拉详情的那张卡(plugin 名):加载期间只把这一个卡片的「详情」按钮标成「读取中…」,
6665
+ // 弹层等数据到手才挂载——中途先 setDetail 一个只有标题的矮盒子会「先闪小窗、再跳大窗」
6666
+ // (.dsh-mcp-modal 高度随内容自适应)。与市场页 ExpertMarket 同一套做法。
6667
+ const [openingPlugin, setOpeningPlugin] = useState('');
5328
6668
  // §4.1 在场:expertActive 是「谁真的挂在会话上」的唯一真源。没有它,「已召唤」
5329
6669
  // (预设已落盘,新会话才接管)和「在场」(人设真的在这轮对话里生效)就是同一个绿 tag,
5330
6670
  // 而这恰恰是这一页最该说清楚的区别。
@@ -5412,14 +6752,19 @@ const ExpertInstalledBoard = ({ call }) => {
5412
6752
  }
5413
6753
  };
5414
6754
  const openDetail = async (plugin) => {
5415
- setDetail({ plugin, loading: true });
6755
+ // 弹层只在详情数据到手后挂载一次(理由同市场页:中途挂矮盒子会闪一下再变大)。
6756
+ if (openingPlugin) return; // 串行化:一条没回来就别再拉第二条。
6757
+ setOpeningPlugin(plugin);
5416
6758
  setNote('');
5417
6759
  try {
5418
6760
  const res = await call('wbExpertDetail', { plugin });
5419
6761
  if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
5420
- setDetail({ ...res, loading: false });
6762
+ setDetail({ ...res });
5421
6763
  } catch (e) {
5422
- setDetail({ plugin, loading: false, detailError: String(e?.message || e) });
6764
+ // 失败也开弹层:里面有 detailError 红字,比「点了没反应」好排查。
6765
+ setDetail({ plugin, detailError: String(e?.message || e) });
6766
+ } finally {
6767
+ setOpeningPlugin('');
5423
6768
  }
5424
6769
  };
5425
6770
 
@@ -5518,7 +6863,7 @@ const ExpertInstalledBoard = ({ call }) => {
5518
6863
  }, skill)))
5519
6864
  : h('div', { className: 'dsh-mcp-card-summary' }, t('无捆绑技能(预设本体在 profile 补丁)')),
5520
6865
  h('div', { className: 'dsh-mcp-wb-card-actions' },
5521
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(row.plugin) }, t('详情')),
6866
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: openingPlugin === row.plugin, 'aria-busy': openingPlugin === row.plugin || undefined, onClick: () => openDetail(row.plugin) }, openingPlugin === row.plugin ? t('读取中…') : t('详情')),
5522
6867
  confirming
5523
6868
  // 内联确认:不弹窗、不移位——原地换成「确认/取消」,误触成本从 0 提到 1 次点击。
5524
6869
  ? h(InlineConfirm, {
@@ -5565,9 +6910,7 @@ const ExpertInstalledBoard = ({ call }) => {
5565
6910
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
5566
6911
  h('div', { ref: summonedRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('已召唤专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
5567
6912
  h('h3', null, detail.name ? t('{0}({1})', [detail.name, detail.profession || '']) : detail.plugin),
5568
- detail.loading
5569
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取详情…'))
5570
- : h('div', null,
6913
+ h('div', null,
5571
6914
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
5572
6915
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
5573
6916
  Array.isArray(detail.skills) && detail.skills.length
@@ -5590,7 +6933,7 @@ const ExpertInstalledBoard = ({ call }) => {
5590
6933
  ),
5591
6934
  h('div', { className: 'dsh-mcp-modal-actions' },
5592
6935
  h(Btn, { onClick: () => setDetail(null), disabled: busy, children: t('关闭') }),
5593
- h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail.plugin, detail.name), children: t('送还') }),
6936
+ h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => dismiss(detail.plugin, detail.name), children: t('送还') }),
5594
6937
  ),
5595
6938
  ),
5596
6939
  )
@@ -5620,6 +6963,19 @@ const ExpertMarket = ({ call }) => {
5620
6963
  // OPT-21:市场专家详情弹层此前没有 ref。
5621
6964
  const expertDetailRef = useRef(null);
5622
6965
  useFocusTrap(expertDetailRef, !!detail);
6966
+ // OPT-16/26 · 3.2/3.3:详情弹层里「人设全文展开」「quickPrompts 全部展开」两个局部开关。
6967
+ // 必须声明在组件顶层、**每次渲染都调用**:这两个 hook 原先写在
6968
+ // `detail ? (() => { … })() : null` 这个条件 IIFE 里,首帧 detail 为 null 时一次都不调用,
6969
+ // 点卡片「详情」后同一组件突然多调用两个 hook —— React #310
6970
+ // 「Rendered more hooks than during the previous render」直接把整块面板打进错误边界
6971
+ // (用户看到的就是市场专家页点详情即白屏报 #310)。开关只在打开详情时重置,
6972
+ // 保证换一个专家进来是折叠态,而不是沿用上一条的展开状态。
6973
+ const [personaExpanded, setPersonaExpanded] = useState(false);
6974
+ const [showAllPrompts, setShowAllPrompts] = useState(false);
6975
+ // 正在拉详情的那张卡片(plugin 名):加载期间只把这一个卡片的「详情」按钮标成「读取中…」,
6976
+ // 详情弹层等数据到手才挂载。**不要**在加载中途先 setDetail 一个只有标题的矮盒子——.dsh-mcp-modal
6977
+ // 高度随内容自适应,那会让用户看到「先闪一个小窗、再跳成大窗」。
6978
+ const [openingPlugin, setOpeningPlugin] = useState('');
5623
6979
 
5624
6980
  const loadInstalled = useCallback(async () => {
5625
6981
  const res = await call('wbExpertInstalled');
@@ -5646,14 +7002,24 @@ const ExpertMarket = ({ call }) => {
5646
7002
  useEffect(() => { load(); loadInstalled(); }, [load, loadInstalled]);
5647
7003
 
5648
7004
  const openDetail = async (expert) => {
5649
- setDetail({ ...expert, loading: true });
7005
+ // 弹层**只在详情数据到手后挂载一次**。此前这里是「先 setDetail({…, loading:true}) 挂个矮盒子,
7006
+ // 拿到人设与技能清单后再 setDetail 一次」:因为 .dsh-mcp-modal 高度随内容自适应,用户看到的就是
7007
+ // 先闪一个小窗、再跳成大窗。现在加载期间只在卡片按钮上显示「读取中…」,弹层一次到位。
7008
+ if (openingPlugin) return; // 串行化:一条还没回来就别再拉第二条,避免后到的覆盖先到的。
7009
+ setOpeningPlugin(expert.plugin);
5650
7010
  setNote('');
7011
+ // 展开态跟着「这一次打开」重置,别让上一条专家留下的展开状态带进来。
7012
+ setPersonaExpanded(false);
7013
+ setShowAllPrompts(false);
5651
7014
  try {
5652
7015
  const res = await call('wbExpertDetail', { plugin: expert.plugin });
5653
7016
  if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
5654
- setDetail({ ...expert, ...res, loading: false });
7017
+ setDetail({ ...expert, ...res });
5655
7018
  } catch (e) {
5656
- setDetail({ ...expert, loading: false, detailError: String(e?.message || e) });
7019
+ // 失败也要把弹层开出来:里面有 detailError 的红字,比「点了没反应」好排查。
7020
+ setDetail({ ...expert, detailError: String(e?.message || e) });
7021
+ } finally {
7022
+ setOpeningPlugin('');
5657
7023
  }
5658
7024
  };
5659
7025
  const summon = async (expert) => {
@@ -5780,7 +7146,7 @@ const ExpertMarket = ({ call }) => {
5780
7146
  installedSkillPlugins.has(item.plugin) ? h(Badge, null, t('含技能')) : null,
5781
7147
  ),
5782
7148
  h('div', { className: 'dsh-mcp-wb-card-actions' },
5783
- h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(item) }, t('详情')),
7149
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: openingPlugin === item.plugin, 'aria-busy': openingPlugin === item.plugin || undefined, onClick: () => openDetail(item) }, openingPlugin === item.plugin ? t('读取中…') : t('详情')),
5784
7150
  // 团队型已走桥接:主理人接管会话,成员人设落盘后由主理人用 subagent 调度。
5785
7151
  // stale 团队行:「更新预设」与「送还」**并列**(后端 upsert 原地重写该行,
5786
7152
  // 用户也可能只想删掉旧行)。1.9.4 这里曾写成三元,导致更新按钮永不渲染。
@@ -5805,9 +7171,8 @@ const ExpertMarket = ({ call }) => {
5805
7171
  detail
5806
7172
  ? (() => {
5807
7173
  // 弹层里该给哪些按钮:与卡片按钮同源(expertCardActions),只是换到详情对象上算。
5808
- // OPT-16/26 · 3.2/3.3:persona 展开态与 quickPrompts 展开态(专家详情弹层局部)。
5809
- const [personaExpanded, setPersonaExpanded] = useState(false);
5810
- const [showAllPrompts, setShowAllPrompts] = useState(false);
7174
+ // 注意:这个 IIFE 里**不允许再出现任何 hook**(personaExpanded / showAllPrompts 已上提到
7175
+ // 组件顶层)——它本身就在 `detail ?` 条件分支里,一旦在此调用 hook 就是 React #310。
5811
7176
  const detailActions = expertCardActions({
5812
7177
  summoned: presets.has(detail.plugin),
5813
7178
  expertType: detail.expertType,
@@ -5817,9 +7182,7 @@ const ExpertMarket = ({ call }) => {
5817
7182
  h('div', { ref: expertDetailRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
5818
7183
  h('h3', null, t('{0}({1})', [detail.name, detail.profession])),
5819
7184
  h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
5820
- detail.loading
5821
- ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取人设与技能清单…'))
5822
- : h('div', null,
7185
+ h('div', null,
5823
7186
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
5824
7187
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
5825
7188
  detail.quickPrompts && detail.quickPrompts.length
@@ -5891,13 +7254,13 @@ const ExpertMarket = ({ call }) => {
5891
7254
  h('div', { className: 'dsh-mcp-modal-actions' },
5892
7255
  h(Btn, { onClick: () => setDetail(null), disabled: busy, children: t('关闭') }),
5893
7256
  detailActions.update
5894
- ? h(Btn, { primary: true, icon: 'refresh', disabled: busy || detail.loading, onClick: () => summon(detail), children: busy ? t('更新中…') : t('更新预设') })
7257
+ ? h(Btn, { primary: true, icon: 'refresh', disabled: busy, onClick: () => summon(detail), children: busy ? t('更新中…') : t('更新预设') })
5895
7258
  : null,
5896
7259
  detailActions.dismiss
5897
- ? h(Btn, { danger: true, icon: 'trash', disabled: busy || detail.loading, onClick: () => dismiss(detail), children: t('送还') })
7260
+ ? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => dismiss(detail), children: t('送还') })
5898
7261
  : null,
5899
7262
  detailActions.summon
5900
- ? h(Btn, { primary: true, icon: 'plus', disabled: busy || detail.loading, onClick: () => summon(detail), children: busy ? t('召唤中…') : detail.expertType === 'team' ? t('召唤专家团') : t('召唤') })
7263
+ ? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => summon(detail), children: busy ? t('召唤中…') : detail.expertType === 'team' ? t('召唤专家团') : t('召唤') })
5901
7264
  : null,
5902
7265
  ),
5903
7266
  ),
@@ -5989,20 +7352,34 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
5989
7352
  // MCP 行字段是 serverName(gateway list() 的行形状,与面板列表同一来源);禁用的不进菜单。
5990
7353
  const loadRecent = useCallback(async () => {
5991
7354
  setListError('');
5992
- const [recentRes, skillsRes, mcpRes, expertRes] = await Promise.all([
7355
+ const [recentRes, skillsRes, mcpRes, expertRes, suitesRes] = await Promise.all([
5993
7356
  call('wbExpertRecent'),
5994
7357
  call('listSkills', skillScopeArg('global')),
5995
7358
  call('list'),
5996
7359
  call('wbExpertInstalled'),
7360
+ // 套件读本插件自己的数据层(不是宿主接口),失败不该连累整个菜单——Promise.all 是
7361
+ // 「一败全败」,所以单独兜住:宿主还没重启(接口 404)时菜单照常出技能/MCP/专家。
7362
+ call('listSkillSuites').catch(() => ({ ok: false })),
5997
7363
  ]);
5998
7364
  const kinds = recentRes?.ok && recentRes.kinds ? recentRes.kinds : {};
5999
7365
  const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills : [];
6000
7366
  const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName) : [];
6001
7367
  const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets : [];
7368
+ const suiteRows = suitesRes?.ok && Array.isArray(suitesRes.suites) ? suitesRes.suites : [];
6002
7369
  setRecent({
6003
7370
  skill: skillRows.map((row) => ({ name: row.name, description: row.description || row.whenToUse || '' })),
6004
7371
  mcp: mcpRows.map((row) => ({ name: row.serverName, description: row.url || row.command || row.transport || '' })),
6005
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
+ })),
6006
7383
  recentSkill: Array.isArray(kinds.skill) ? kinds.skill : [],
6007
7384
  recentMcp: Array.isArray(kinds.mcp) ? kinds.mcp : [],
6008
7385
  recentExpert: Array.isArray(kinds.expert) ? kinds.expert : [],
@@ -6074,17 +7451,15 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6074
7451
  return () => { document.removeEventListener('click', onDocClick); document.removeEventListener('keydown', onKey); };
6075
7452
  }, [open]);
6076
7453
 
6077
- // 选用动作:把引用插进输入框光标处 + 上报最近使用。
7454
+ // 插入动作(技能 / MCP / 套件共用):把引用插进输入框光标处。
6078
7455
  // 写通道(按优先级):
6079
7456
  // ① inputActions.insertText(text, captureInsertion()) —— composer 的 standard provide channel
6080
7457
  // (InputBar 同款 useInput/inputActions),在光标处插入并保留现有草稿,最贴近 WorkBuddy;
6081
7458
  // ② inputActions.setDraft(现草稿 + 引用) —— insertText 不可用时的整草稿兜底;
6082
7459
  // ③ 复制进剪贴板 + 按钮上闪现「已复制」(绝不跳走:跳页是上一版的行为缺陷)。
6083
- const pick = async (kind, item, text) => {
6084
- setOpen(false); setHoverKind(null);
6085
- // 上报最近使用(失败静默:纯增值数据)。expert 不经 pick(点击分支直接 attachExpert)。
6086
- const row = kind === 'mcp' ? { serverName: item.name } : { name: item.name };
6087
- call('wbExpertRecordUse', { kind, row }).catch(() => {});
7460
+ // 套件展开出来是一整串 `/a /b /c`,走的正是同一条通道——所以「引用套件」不需要
7461
+ // 宿主认识套件这个概念,宿主看到的仍然只是普通草稿文本。
7462
+ const insertRef = async (text) => {
6088
7463
  const insertion = typeof text === 'string' ? (text.endsWith(' ') ? text : text + ' ') : String(text);
6089
7464
  if (inputActions && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
6090
7465
  try {
@@ -6106,6 +7481,25 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6106
7481
  setTimeout(() => setCopied(''), 1600);
6107
7482
  } catch { /* 剪贴板不可用:静默 */ }
6108
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
+ };
6109
7503
 
6110
7504
  // 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记,只标首屏 6 个),
6111
7505
  // 未命中最近记录的排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
@@ -6274,6 +7668,13 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6274
7668
  QUICK_MENU_KINDS.map((kind) => {
6275
7669
  const items = subItems(kind.key);
6276
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;
6277
7678
  return h('div', {
6278
7679
  key: kind.key,
6279
7680
  className: 'dsh-mcp-quick-row' + (expanded && flipKinds.has(kind.key) ? ' flip' : ''),
@@ -6295,14 +7696,35 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6295
7696
  },
6296
7697
  h(Icon, { d: ICONS[kind.icon], size: 13 }),
6297
7698
  h('span', null, kind.label),
6298
- 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,
6299
7701
  h('span', { className: 'dsh-mcp-quick-arrow', 'aria-hidden': true }, '›'),
6300
7702
  ),
6301
7703
  hoverKind === kind.key
6302
- ? h('div', { className: 'dsh-mcp-quick-sub' + (Array.isArray(items) && items.length ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + t('可选项') },
6303
- Array.isArray(items) && items.length
7704
+ ? h('div', { className: 'dsh-mcp-quick-sub' + (hasAny ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + t('可选项') },
7705
+ hasAny
6304
7706
  ? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + t('列表(最多显示 6 行,滚动查看更多)') },
6305
- 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', {
6306
7728
  key: item.name || item.plugin || index,
6307
7729
  type: 'button',
6308
7730
  role: 'menuitem',
@@ -6329,6 +7751,21 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
6329
7751
  },
6330
7752
  h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理') + kind.label + '…'),
6331
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,
6332
7769
  )
6333
7770
  : null,
6334
7771
  );
@@ -6509,6 +7946,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
6509
7946
  const [skillKeywordInput, setSkillKeywordInput] = useState('');
6510
7947
  const [skills, setSkills] = useState([]);
6511
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('');
6512
7955
  const [skillDetail, setSkillDetail] = useState(null);
6513
7956
  const [installedSkills, setInstalledSkills] = useState(new Set());
6514
7957
  // §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
@@ -6569,17 +8012,21 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
6569
8012
  }
6570
8013
  }, [call]);
6571
8014
 
8015
+ // 技能列表请求序号:搜索防抖与翻页会连发多个请求,慢的那个回来时不能盖掉新的结果。
8016
+ const skillSeq = useRef(0);
6572
8017
  const loadSkills = useCallback(async (page, sort, keyword) => {
6573
- setLoading(true); setError(''); setNote('');
8018
+ const seq = ++skillSeq.current;
8019
+ setSkillLoading(true); setSkillError('');
6574
8020
  try {
6575
8021
  const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
8022
+ if (seq !== skillSeq.current) return; // 过期响应,直接丢弃
6576
8023
  if (!res?.ok) throw new Error(res?.error || t('读取技能目录失败'));
6577
8024
  setSkills(res.skills || []);
6578
8025
  setSkillTotal(res.total || 0);
6579
8026
  } catch (e) {
6580
- setError(String(e?.message || e));
8027
+ if (seq === skillSeq.current) setSkillError(String(e?.message || e));
6581
8028
  } finally {
6582
- setLoading(false);
8029
+ if (seq === skillSeq.current) setSkillLoading(false);
6583
8030
  }
6584
8031
  }, [call]);
6585
8032
 
@@ -6813,6 +8260,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
6813
8260
  if (!res?.ok) throw new Error(res?.error || t('安装失败'));
6814
8261
  setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
6815
8262
  setSkillDetail(null);
8263
+ // 磁盘上的 skill 集合已经变了:广播出去,顶部分类计数与(切回时的)Skill 看板
8264
+ // 立即按新集合重算,不再等用户离开看板/重挂载。市场自己的列表照旧局部刷新。
8265
+ emitSkillChange('market-install');
6816
8266
  await loadInstalled();
6817
8267
  await loadInstallState();
6818
8268
  } catch (e) {
@@ -6828,6 +8278,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
6828
8278
  if (!res?.ok) throw new Error(res?.error || t('卸载失败'));
6829
8279
  setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
6830
8280
  setSkillDetail(null);
8281
+ emitSkillChange('market-uninstall');
6831
8282
  await loadInstalled();
6832
8283
  await loadInstallState();
6833
8284
  } catch (e) {
@@ -6980,15 +8431,59 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
6980
8431
  setAwesomeLoading(false);
6981
8432
  }
6982
8433
  }, [call]);
6983
- const checkAwesomeUpdate = useCallback(async () => {
8434
+ // 探测节流 6 小时;force=true(用户点「重新加载」)跳过节流立即重探。
8435
+ // 注意:时间戳只在真的发起探测时写,否则每次挂载都会把闸门往后推、探测永不再跑。
8436
+ const checkAwesomeUpdate = useCallback(async (force = false) => {
8437
+ if (!force && Date.now() - readAwesomeProbeAt() < AWESOME_PROBE_INTERVAL_MS) return;
8438
+ writeAwesomeProbeAt(Date.now());
6984
8439
  try {
6985
8440
  const res = await call('wbAwesomeUpdateCheck');
6986
- setAwesomeUpdateHint(res || null);
8441
+ // probe-failed 是环境性的瞬时失败(源不可达 / 超时 / HEAD 被拒),一律静默:
8442
+ // 真正「更新源不可达」由 awesomeStale 那条中性提示负责,不重复喊狼来了。
8443
+ setAwesomeUpdateHint(res && res.reason !== 'probe-failed' ? res : null);
6987
8444
  } catch {
6988
8445
  setAwesomeUpdateHint(null);
6989
8446
  }
6990
8447
  }, [call]);
6991
8448
  const awesomeUpdateChecked = useRef(false);
8449
+ // ── README 预览弹层 ──────────────────────────────────────────────────────
8450
+ // 点卡片时才按需拉一次(服务端 npm registry / GitHub raw + 7 天落盘缓存);
8451
+ // 目录本身仍是 13 字段瘦身版,不因预览而变重。
8452
+ const [readmeView, setReadmeView] = useState(null);
8453
+ const readmeSeq = useRef(0);
8454
+ const readmeNodeRef = useRef(null);
8455
+ useFocusTrap(readmeNodeRef, !!readmeView);
8456
+ const closeReadme = () => {
8457
+ readmeSeq.current += 1; // 作废在途请求,避免关掉之后又被写回来
8458
+ setReadmeView(null);
8459
+ };
8460
+ const loadAwesomeReadme = useCallback(async (item, refresh = false) => {
8461
+ if (!item) return;
8462
+ // 弹层的排版样式随首次打开注入。放在事件回调里而不是组件顶层,是为了不引入
8463
+ // 任何新的 Hook —— 这也正是 no-conditional-hooks 测试要防的那类崩溃。
8464
+ ensureReadmeStyle();
8465
+ const seq = ++readmeSeq.current;
8466
+ const npm = typeof item.npm === 'string' ? item.npm : (item.npm && item.npm.name) || '';
8467
+ setReadmeView({ item, loading: true, ok: false, markdown: '', base: '', error: '', source: '', reason: '', truncated: false });
8468
+ let res = null;
8469
+ try {
8470
+ res = await call('wbAwesomeReadme', { name: item.name, npm, repo: item.repo || '', refresh: refresh === true });
8471
+ } catch (error) {
8472
+ res = { ok: false, error: error && error.message ? error.message : String(error) };
8473
+ }
8474
+ if (seq !== readmeSeq.current) return;
8475
+ if (!res || res.ok !== true) {
8476
+ const raw = (res && res.error) || '';
8477
+ // 宿主进程若仍是旧版(新 Remote 未注册),这里会收到 404 transport failure:
8478
+ // 换成一句可操作的话,别让用户以为功能坏了。
8479
+ const message = /404|not\s*found/i.test(raw)
8480
+ ? t('预览服务尚未加载,请完全退出 DSH 后重新打开再试')
8481
+ : (raw || t('读取 README 失败:未知错误'));
8482
+ setReadmeView({ item, loading: false, ok: false, markdown: '', base: '', error: message, source: '', reason: (res && res.reason) || '', truncated: false });
8483
+ return;
8484
+ }
8485
+ setReadmeView({ item, loading: false, ok: true, markdown: res.markdown || '', base: res.base || '', error: '', source: res.source || '', reason: res.reason || '', truncated: res.truncated === true });
8486
+ }, [call]);
6992
8487
  // 自动加载改成「每次挂载只发一次」:旧条件是 `awesomeLoading || awesomePlugins.length` 就跳过,
6993
8488
  // 一旦首屏已经有快照,真实目录就永远不会再拉——快照便成了死数据,必须去掉那个门槛。
6994
8489
  const awesomeAutoLoaded = useRef(false);
@@ -7118,7 +8613,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7118
8613
  onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
7119
8614
  });
7120
8615
  const skillSearch = h(WbSearchBox, {
7121
- value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: loading,
8616
+ value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: skillLoading,
7122
8617
  onChange: (value) => setSkillKeywordInput(value),
7123
8618
  // 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
7124
8619
  onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
@@ -7276,11 +8771,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7276
8771
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
7277
8772
  skillSearch,
7278
8773
  h('select', {
7279
- 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 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。'),
7280
8776
  onChange: (e) => { setSkillSort(e.target.value); setSkillPage(1); },
7281
8777
  }, WB_SKILLHUB_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
7282
8778
  h('label', { className: 'dsh-mcp-check', title: t('仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)', [skills.length]) },
7283
- 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) }),
7284
8780
  t('只看已安装')),
7285
8781
  skillOnlyInstalled
7286
8782
  ? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
@@ -7291,8 +8787,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7291
8787
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
7292
8788
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
7293
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,
7294
8799
  loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
7295
- 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,
7296
8801
  // §1.4 「只看可直接安装」把本页筛空了:这是本页最容易踩的坑(默认勾选),
7297
8802
  // 主操作就是取消它——一句话说清「这里本来有条目,是筛选挡掉了」。
7298
8803
  !loading && tab === 'connector' && !visibleConnectors.length
@@ -7319,7 +8824,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7319
8824
  : null,
7320
8825
  // §1.4 技能:搜索与「只看已安装」是两种完全不同的空,说法必须分开——
7321
8826
  // 否则用户会以为 SkillHub 里根本没有这个词。
7322
- !loading && tab === 'skill' && !skills.length
8827
+ !skillLoading && tab === 'skill' && !skills.length
7323
8828
  ? h(EmptyState, {
7324
8829
  icon: 'skill',
7325
8830
  title: skillKeyword.trim() ? t('没有搜到「{0}」相关的技能', [skillKeyword.trim()]) : t('技能目录暂时是空的'),
@@ -7330,7 +8835,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7330
8835
  })
7331
8836
  : null,
7332
8837
  // 「只看已安装」把本页筛空了:不能沿用「没有匹配的技能」——那会让人以为搜索没结果。
7333
- !loading && tab === 'skill' && skills.length && !shownSkills.length
8838
+ !skillLoading && tab === 'skill' && skills.length && !shownSkills.length
7334
8839
  ? h(EmptyState, {
7335
8840
  icon: 'skill',
7336
8841
  tight: true,
@@ -7492,7 +8997,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7492
8997
  )
7493
8998
  : null,
7494
8999
  tab === 'skill' && skillPageCount > 1
7495
- ? 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 })
7496
9001
  : null,
7497
9002
  // ── T03/R5:codebuddy 插件市场 ──
7498
9003
  // 重启提示条:常驻本 tab 顶部(组件卸载即消失,不写 localStorage)——
@@ -7590,14 +9095,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7590
9095
  )
7591
9096
  : null,
7592
9097
  // ── T06/Awesome:DSH 社区(awesome-dsh-plugins)目录 ──
7593
- tab === 'plugin' && pluginSource === 'awesome' && awesomeStale ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, t('目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => loadAwesomeCatalog(true) }, t('重新加载'))) : null,
7594
- tab === 'plugin' && pluginSource === 'awesome' && awesomeUpdateHint && (awesomeUpdateHint.reason === 'update-available' || awesomeUpdateHint.reason === 'no-cache') ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, awesomeUpdateHint.reason === 'update-available' ? t('DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。', [awesomeUpdateHint.localEtag || t('未知错误')]) : t('本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: async () => { setAwesomeUpdateHint(null); await loadAwesomeCatalog(true); } }, t('重新加载')), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setAwesomeUpdateHint(null) }, t('知道了'))) : null,
7595
- tab === 'plugin' && pluginSource === 'awesome' && awesomeUpdateHint && awesomeUpdateHint.reason === 'probe-failed' ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, t('DSH 社区目录更新探测失败(不影响已安装插件)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => { awesomeUpdateChecked.current = false; checkAwesomeUpdate(); } }, t('重新探测'))) : null,
9098
+ tab === 'plugin' && pluginSource === 'awesome' && awesomeStale ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, t('目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => { writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; loadAwesomeCatalog(true); } }, t('重新加载'))) : null,
9099
+ tab === 'plugin' && pluginSource === 'awesome' && awesomeUpdateHint && (awesomeUpdateHint.reason === 'update-available' || awesomeUpdateHint.reason === 'no-cache') ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, awesomeUpdateHint.reason === 'update-available' ? t('DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。', [awesomeUpdateHint.localEtag || t('未知错误')]) : t('本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: async () => { setAwesomeUpdateHint(null); writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; await loadAwesomeCatalog(true); } }, t('重新加载')), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setAwesomeUpdateHint(null) }, t('知道了'))) : null,
9100
+
7596
9101
  tab === 'plugin' && pluginSource === 'awesome' && awesomeError ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, awesomeError, h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => loadAwesomeCatalog() }, t('重试'))) : null,
7597
9102
  tab === 'plugin' && pluginSource === 'awesome' && awesomeLoading && !awesomePlugins.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在加载 DSH 社区目录…')) : null,
7598
9103
  tab === 'plugin' && pluginSource === 'awesome' && !awesomeError && !awesomeLoading && !awesomePlugins.length ? h(EmptyState, { icon: 'store', title: t('DSH 社区目录暂时是空的'), hint: t('目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。'), actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => loadAwesomeCatalog() }] }) : null,
7599
9104
  tab === 'plugin' && pluginSource === 'awesome' && awesomePlugins.length && !visibleAwesome.length ? h(EmptyState, { tight: true, title: t('没有匹配的社区插件'), hint: t('换个关键词,或清空分类与标签筛选。'), actions: [{ label: t('清空筛选'), icon: 'x', onClick: () => { setAwesomeQuery(''); setAwesomeCategory('all'); setAwesomeTags([]); } }] }) : null,
7600
- tab === 'plugin' && pluginSource === 'awesome' && visibleAwesome.length ? h('div', { className: 'dsh-mcp-wb-grid' }, visibleAwesome.map((item) => h('div', { key: item.name, className: 'dsh-mcp-card wb' },
9105
+ tab === 'plugin' && pluginSource === 'awesome' && visibleAwesome.length ? h('div', { className: 'dsh-mcp-wb-grid' }, visibleAwesome.map((item) => h('div', { key: item.name, className: 'dsh-mcp-card wb dsh-mcp-card-clickable', role: 'button', tabIndex: 0, title: t('查看 README 介绍与插图'), onClick: () => loadAwesomeReadme(item), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadAwesomeReadme(item); } } },
7601
9106
  h('div', { className: 'dsh-mcp-card-top' },
7602
9107
  h('div', null,
7603
9108
  h('div', { className: 'dsh-mcp-card-name' }, item.name),
@@ -7611,9 +9116,29 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
7611
9116
  item.stars ? h(Badge, null, '★ ' + item.stars) : null,
7612
9117
  item.category && item.category !== 'plugin' ? h(Badge, null, item.category) : null,
7613
9118
  (item.tags || []).map((tag) => h('span', { key: tag, className: 'dsh-mcp-tag-chip on' }, tag))),
7614
- h('div', { className: 'dsh-mcp-wb-card-actions' },
9119
+ h('div', { className: 'dsh-mcp-wb-card-actions', onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation() },
7615
9120
  item.npm ? (String(item.npm).startsWith('@deepseek-ai/') ? h('span', { className: 'dsh-mcp-wb-blocked', title: t('DSH 宿主官方组件随宿主本体分发,无法也不需要从市场安装') }, t('宿主组件')) : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || isInstallRunning(item.name), onClick: () => installPlugin(item) }, t('安装'))) : h('span', { className: 'dsh-mcp-wb-blocked', title: t('该条目未提供 npm 包名,暂不支持安装') }, t('暂不支持安装')))))) : null,
7616
9121
  tab === 'plugin' && pluginSource === 'awesome' && awesomePageCount > 1 ? h(Pager, { page: safeAwesomePage, pageCount: awesomePageCount, total: sortedAwesome.length, onPage: setAwesomePage }) : null,
9122
+ // ── DSH 社区插件 README 预览(点卡片打开;插图来自 README 自身的相对/绝对地址)──
9123
+ readmeView
9124
+ ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => { if (e.target === e.currentTarget) closeReadme(); } },
9125
+ h('div', { ref: readmeNodeRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal dsh-mcp-readme-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('{0} README 预览', [readmeView.item && readmeView.item.name ? readmeView.item.name : '']), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeReadme(); } } },
9126
+ h('h3', null, readmeView.item && readmeView.item.name ? readmeView.item.name : ''),
9127
+ h('div', { className: 'dsh-mcp-readme-meta' },
9128
+ readmeView.item && readmeView.item.repo ? h(Badge, { title: readmeView.item.repo }, String(readmeView.item.repo).replace(/^https?:\/\/github\.com\//i, '')) : null,
9129
+ readmeView.item && readmeView.item.npm ? h(Badge, { title: readmeView.item.npm }, 'npm') : null,
9130
+ readmeView.source ? h(Badge, null, readmeView.source === 'npm' ? 'npm registry' : 'GitHub') : null),
9131
+ readmeView.loading
9132
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'status' }, t('正在读取 README…'))
9133
+ : readmeView.ok
9134
+ ? h('div', { className: 'dsh-mcp-readme-body' }, renderReadmeBlocks(readmeView.markdown, readmeView.base))
9135
+ : h('p', { className: 'dsh-mcp-wb-error', role: 'alert' }, readmeView.error),
9136
+ readmeView.ok && readmeView.truncated ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('README 过长,已截断显示。')) : null,
9137
+ h('div', { className: 'dsh-mcp-modal-actions' },
9138
+ h(Btn, { onClick: closeReadme, children: t('关闭') }),
9139
+ readmeView.item && readmeView.item.npm ? h(Btn, { primary: true, icon: 'plus', disabled: busy || isInstallRunning(readmeView.item.name), onClick: () => { const target = readmeView.item; closeReadme(); installPlugin(target); } }, t('安装')) : null,
9140
+ h(Btn, { onClick: () => loadAwesomeReadme(readmeView.item, true), disabled: readmeView.loading, children: t('重新读取') }))))
9141
+ : null,
7617
9142
  // ── T04/R7:令牌弹窗(连接器 mcp.json 里的 ${...} 占位符)──
7618
9143
  // 设计约束:只收集用户输入并原样透传;槽位来自目录条目的 tokenSlots,
7619
9144
  // 同一占位符可能出现多次(同 server 的 env 与 header),按 placeholder 去重。
@@ -8341,8 +9866,10 @@ const McpBoardPage = (props) => {
8341
9866
  setCatHint({ key, left });
8342
9867
  }, []);
8343
9868
  const hideCatHint = useCallback(() => setCatHint(null), []);
8344
- // 顶层分类计数:全部是「本机在手」数据,打开看板就一次性取齐,
9869
+ // 顶层分类计数:全部是「本机在手」数据,打开看板就先取齐,
8345
9870
  // 让 chip 数字常驻——而不是「进过哪一页才出现哪个」,视觉上才协调。
9871
+ // 四类里只有 Skill 会随用户操作变化(市场装/卸、看板卸载),所以它下面额外接了
9872
+ // 变更总线重取;其余三类仍是一次性读取。
8346
9873
  // 市场是唯一例外+特例:目录在远端(24MB ZIP),不能为个数字在打开看板时
8347
9874
  // 全量下载;这里调 wbConnectorCachedCount 只读磁盘缓存(绝不联网),
8348
9875
  // 用户没进过市场时返回 null(chip 暂不显示数字),进过一次后就常驻。
@@ -8351,13 +9878,22 @@ const McpBoardPage = (props) => {
8351
9878
  const [expertCount, setExpertCount] = useState(null);
8352
9879
  const [mkCount, setMkCount] = useState(null);
8353
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]);
8354
9892
  useEffect(() => {
8355
9893
  if (countsLoaded.current) return;
8356
9894
  countsLoaded.current = true;
8357
9895
  let alive = true;
8358
- call('listSkills', skillScopeArg('global')).then((res) => {
8359
- if (alive && res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
8360
- }).catch(() => {});
9896
+ refreshSkillCount();
8361
9897
  call('list').then((res) => {
8362
9898
  if (alive && res && res.ok && Array.isArray(res.servers)) setMcpCount(res.servers.length);
8363
9899
  }).catch(() => {});
@@ -8370,7 +9906,10 @@ const McpBoardPage = (props) => {
8370
9906
  if (alive && res && res.ok && Number.isFinite(res.total)) setMkCount(res.total);
8371
9907
  }).catch(() => {});
8372
9908
  return () => { alive = false; };
8373
- }, [call]);
9909
+ }, [call, refreshSkillCount]);
9910
+ // 关键:角标不再只在首次挂载读一次。市场装完就广播,这里直接重取数量并重渲染;
9911
+ // Skill 看板挂着时它自己的订阅负责刷新列表,没挂着时靠其挂载 effect 重读,路径不同但都不漏。
9912
+ useEffect(() => onSkillChange(() => { refreshSkillCount(); }), [refreshSkillCount]);
8374
9913
  // OPT-04:报告上提到这里,分类角标与 ToolTailor 页面本体共用同一份。
8375
9914
  // 扫描时机不再等进裁缝页——账本是本机文件,打开看板就扫,橙点角标才有数。
8376
9915
  // 用「已发起」标记而不是判断报告是否非空,是为了区分「还没开始」与「发起了但失败了」:
@@ -8895,6 +10434,11 @@ const MCP_CONTRIBUTION = {
8895
10434
  { id: 'dsh-armory#mcpManager/listSkills', service: 'mcpManager', namespace: 'mcpManager', method: 'listSkills', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillListResult') },
8896
10435
  { id: 'dsh-armory#mcpManager/skillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'skillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillDetailResult') },
8897
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') },
8898
10442
  // ── 工具裁缝:与 revealSkill 同批加入看板,但两端契约此前双双缺失(客户端永远 404)──
8899
10443
  { id: 'dsh-armory#mcpManager/toolTailor', service: 'mcpManager', namespace: 'mcpManager', method: 'toolTailor', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('TailorReport') },
8900
10444
  // ── WorkBuddy 市场直连:连接器 MCP + SkillHub 技能(同一命名空间同一信封)──
@@ -8928,6 +10472,7 @@ const MCP_CONTRIBUTION = {
8928
10472
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)。与宿主 method、typert.js 三处必须同步。
8929
10473
  { id: 'dsh-armory#mcpManager/wbAwesomeCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeCatalogResult') },
8930
10474
  { id: 'dsh-armory#mcpManager/wbAwesomeDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeDetailResult') },
10475
+ { id: 'dsh-armory#mcpManager/wbAwesomeReadme', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeReadme', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeReadmeResult') },
8931
10476
  { id: 'dsh-armory#mcpManager/wbAwesomeUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
8932
10477
  { id: 'dsh-armory#mcpManager/wbPluginDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPluginDetailResult') },
8933
10478
  // ── WorkBuddy 专家直连:召唤预设 + 捆绑技能(同一命名空间同一信封)──