dsh-armory 1.10.0 → 2.0.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,7 +117,6 @@ 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
- "{0} 个 skill": "{0} skills",
121
120
  "{0} 个专家正挂在 {1} 个会话上": "{0} experts are attached to {1} sessions",
122
121
  "{0} 个会话": "{0} sessions",
123
122
  "{0} 个共用": "{0} shared",
@@ -145,7 +144,6 @@ const EN_US_MESSAGES = {
145
144
  " ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可": " — the host hasn't registered the skill interface yet; restart DeepSeek Harness to activate it",
146
145
  " ——宿主还没注册 toolTailor 接口:重启 DeepSeek Harness 让宿主侧生效后即可": " — the host hasn't registered the toolTailor interface yet; restart DeepSeek Harness to activate it",
147
146
  " 本项目当前有 ": " This project currently has ",
148
- " 用户可调用:": " User-invocable:",
149
147
  "、": ", ",
150
148
  "。DSH 0.1.5 起各注册作用域可独立使用同名;更早的版本按全进程唯一判定,同名会导致其中一份启动失败。": ". Since DSH 0.1.5, registered scopes can reuse the same name independently; earlier versions treat names as process-wide unique, and duplicates make one of them fail to start.",
151
149
  "。点开对应条目查看具体错误。": ". Open the matching entry to see the exact error.",
@@ -263,9 +261,57 @@ const EN_US_MESSAGES = {
263
261
  "可选值:": "Allowed values: ",
264
262
  "可选项": "Options",
265
263
  "合并(同名更新)": "Merge (update same names)",
266
- "否": "No",
267
264
  "否(补丁中 disabled: true)": "No (disabled: true in patch)",
268
265
  "含 {0} 技能": "{0} skills included",
266
+ "注入规则 ×{0}": "injection rules ×{0}",
267
+ "适用环境:": "Works with: ",
268
+ "{0} 个连接器适用": "{0} connectors",
269
+ "清除标签筛选": "Clear tag filter",
270
+ " +{0}": " +{0}",
271
+ "存在 {0} 条 token 注入规则(依赖 WB 账号体系,DSH 不代注入)。": "{0} token injection rules exist (relies on the WB account system; DSH does not inject on your behalf).",
272
+ "存在 token 注入规则(依赖 WB 账号体系,DSH 不代注入)": "Token injection rules exist (relies on the WB account system; DSH does not inject on your behalf)",
273
+ "凭据: ": "Credential: ",
274
+ "插件": "Plugins",
275
+ "搜索插件(名称 / 描述 / 作者)": "Search plugins (name / description / author)",
276
+ "读取插件目录失败": "Failed to load the plugin catalog",
277
+ "目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。": "The cached catalog is stale (update sources unreachable; showing the local cache).",
278
+ "DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。": "The DSH community catalog has a newer version (local cache {0}). Click \"Reload\" to fetch the latest catalog.",
279
+ "本地还没有 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).",
281
+ "正在加载 DSH 社区目录…": "Loading the DSH community catalog…",
282
+ "DSH 社区目录暂时是空的": "The DSH community catalog is temporarily empty",
283
+ "目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。": "The catalog comes from the awesome-dsh-plugins community registry (jsdelivr CDN); retry if fetching failed.",
284
+ "没有匹配的社区插件": "No matching community plugins",
285
+ "换个关键词,或清空分类与标签筛选。": "Try another keyword, or clear the category and tag filters.",
286
+ "清空筛选": "Clear filters",
287
+ "该条目未提供 npm 包名,暂不支持安装": "This entry has no npm package name and cannot be installed yet",
288
+ "暂不支持安装": "Not installable yet",
289
+ "宿主组件": "Host component",
290
+ "DSH 宿主官方组件随宿主本体分发,无法也不需要从市场安装": "Official DSH host component — it ships with the DSH host itself and cannot (and need not) be installed from the market",
291
+ "官方": "Official",
292
+ "CodeBuddy 官方": "CodeBuddy Official",
293
+ "DSH 社区": "DSH Community",
294
+ "套件": "Bundles",
295
+ "最新收录": "Recently added",
296
+ "正在安装插件 {0}…": "Installing plugin {0}…",
297
+ "正在安装 {0} 个插件…": "Installing {0} plugin(s)…",
298
+ "宿主未提供插件管理器(pluginManager),无法安装插件": "The host does not expose a plugin manager (pluginManager); cannot install plugins",
299
+ "该插件未提供可推导的安装来源(repository),暂不能安装": "This plugin declares no derivable install source (repository); display only for now",
300
+ "插件 {0} 安装成功,需重启 DSH 宿主后生效。": "Plugin {0} installed. Restart the DSH host for it to take effect.",
301
+ "正在读取插件目录…": "Loading the plugin catalog…",
302
+ "插件目录暂时是空的": "The plugin catalog is empty for now",
303
+ "没有匹配的插件": "No plugins match",
304
+ "插件详情": "Plugin details",
305
+ "依赖 {0}": "{0} deps",
306
+ "依赖": "Dependencies",
307
+ "本插件声明了 {0} 个 LSP 服务,安装后由宿主按声明启动(此处仅展示)。": "This plugin declares {0} LSP services; the host starts them per declaration after install (display only here).",
308
+ "来源:": "Source: ",
309
+ "主页:": "Homepage: ",
310
+ "安装 = 通过宿主 pluginManager 拉取并落盘;安装成功后需重启 DSH 宿主才会生效。": "Install fetches and lands the plugin through the host's pluginManager; restart the DSH host afterwards for it to take effect.",
311
+ "知道了": "Got it",
312
+ "调用失败": "Call failed",
313
+ "({0})": " ({0})",
314
+ "目录来自 codebuddy 官方市场 ZIP 分发,可能还没同步下来;重读一次通常就好。": "The catalog comes from the official codebuddy marketplace ZIP; it may not have synced yet. Re-reading usually fixes it.",
269
315
  "含技能": "With skills",
270
316
  "含需掩码字段:": "Contains fields needing masking: ",
271
317
  "启动失败策略": "Start-failure policy",
@@ -294,6 +340,8 @@ const EN_US_MESSAGES = {
294
340
  "安装中…": "Installing…",
295
341
  "安装到全局": "Install globally",
296
342
  "安装失败": "Install failed",
343
+ "安装任务已结束": "Install finished",
344
+ "大肥鱼军械库": "Big Fish Armory",
297
345
  "宿主正在停止,未升级;请重启后再试。": "Host is shutting down; not upgraded. Restart and try again.",
298
346
  "宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用": "Host has no skill registry mounted (@deepseek-ai/dsh-skill); this board is unavailable",
299
347
  "密钥:": "Key: ",
@@ -340,6 +388,14 @@ const EN_US_MESSAGES = {
340
388
  "已复制到剪贴板": "Copied to clipboard",
341
389
  "已复制,粘贴到输入框": "Copied; paste into the input box",
342
390
  "已安装": "Installed",
391
+ "目录默认顺序": "Catalog order",
392
+ "技能数由多到少": "Most skills first",
393
+ "名称 A→Z": "Name A→Z",
394
+ "插件市场有新版本可更新(本地缓存 {0})。点「重新加载」可拉取最新目录。": "A newer plugin market catalog is available (local cache {0}). Click Reload to fetch the latest catalog.",
395
+ "本地还没有插件市场缓存,点「重新加载」可首次拉取目录。": "No local plugin market cache yet. Click Reload to fetch the catalog for the first time.",
396
+ "重新加载": "Reload",
397
+ "插件市场更新探测失败(不影响已安装插件)。": "Plugin market update check failed (installed plugins are unaffected).",
398
+ "重新探测": "Recheck",
343
399
  "已导入 MCP": "MCPs imported",
344
400
  "已屏蔽": "Blocked",
345
401
  "已屏蔽 ": "Blocked ",
@@ -487,7 +543,6 @@ const EN_US_MESSAGES = {
487
543
  "查看参数": "View parameters",
488
544
  "查看更新方式": "See how to update",
489
545
  "模型可调用": "Model-invocable",
490
- "模型可调用:": "Model-invocable: ",
491
546
  "正在下载并校验 {0}…": "Downloading and verifying {0}…",
492
547
  "正在升级到 v": "Upgrading to v",
493
548
  "正在卸载 {0}…": "Uninstalling {0}…",
@@ -552,7 +607,6 @@ const EN_US_MESSAGES = {
552
607
  "目录里有 {0} 个条目,「只看可直接安装」把不具备传输类型的挡掉了。": "The catalog has {0} entries; 'Directly installable only' hides the ones without a transport type.",
553
608
  "看板分类": "Board categories",
554
609
  "看这次改了什么": "See what changed",
555
- "知道了": "Got it",
556
610
  "确认从来源导入": "Confirm import from source",
557
611
  "确认导入": "Confirm import",
558
612
  "确认移除 MCP「": "Confirm removal of MCP \"",
@@ -604,8 +658,6 @@ const EN_US_MESSAGES = {
604
658
  "调用 {0}": "Call {0}",
605
659
  "调用 {0} · 错误 {1}": "Calls {0} · errors {1}",
606
660
  "调用 {0} 次": "Called {0} times",
607
- "调用失败": "Call failed",
608
- "调用策略": "Invocation policy",
609
661
  "调用超时": "Call timeout",
610
662
  "账本加载失败:": "Ledger load failed: ",
611
663
  "跳转": "Go",
@@ -684,7 +736,6 @@ const EN_US_MESSAGES = {
684
736
  "默认值:": "Default: ",
685
737
  "默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目": "By default only directly installable entries show; uncheck to see the incompatible 'display only' ones",
686
738
  "(": " (",
687
- "({0})": " ({0})",
688
739
  "(仅 SKILL.md)": " (SKILL.md only)",
689
740
  "(共 {0},已筛选)": " ({0} total, filtered)",
690
741
  "(写入召唤预设 + 落盘成员人设)": " (writes the summon preset + lands member personas)",
@@ -782,6 +833,61 @@ const EN_US_MESSAGES = {
782
833
  "需要在这里填写的交互输入:": "Interactive inputs to fill in: ",
783
834
  "这些条目引用了编辑器交互输入(${input:…}),值在导入时拿不到": "These entries reference editor interactive inputs (${input:…}); the values are unavailable at import time",
784
835
 
836
+ // T04/R8:安装状态账本(插件装完回写登记失败时的提示)。占位回退用的「未知错误」上表已有。
837
+ "插件已安装,但安装状态登记失败:{0}": "Plugin installed, but recording its install state failed: {0}",
838
+ "插件安装失败(宿主安装器返回失败)": "Plugin installation failed (host installer reported failure)",
839
+ "在全量 {0} 条目录里筛出已安装的连接器(跨页生效,与技能侧「仅本页」不同)": "Filter the full {0}-entry catalog to installed connectors (across pages, unlike the skills tab)",
840
+ "在全量 {0} 条插件里筛出已安装的(跨页生效)": "Filter the full {0} plugins to installed ones (across pages)",
841
+ "已安装 {0} / {1}": "Installed {0} / {1}",
842
+
843
+ // T04/R7:连接器令牌弹窗(占位符凭据引导)。「环境变量 」上表已有,这里只补新增的七条。
844
+ "请求头 ": "Header ",
845
+ "填写连接器凭据": "Enter connector credentials",
846
+ "填写 {0} 的凭据": "Enter credentials for {0}",
847
+ "该连接器的配置里有 {0} 个占位符,需要你提供对应凭据才能生效。没填的会保持原样,安装后仍可在 MCP 配置里手动补全。": "This connector's config contains {0} placeholder(s) that need your credentials to take effect. Anything left blank keeps its original value and can be filled in later from the MCP config.",
848
+ "留空表示稍后自行补全": "Leave blank to fill in later",
849
+ "该连接器没有可填写的占位符,可直接安装。": "This connector has no placeholders to fill in; it can be installed directly.",
850
+ "带凭据安装": "Install with credentials",
851
+ "另有 {0} 个占位符未填写,配置里保留原样,可用前请补全。": "{0} placeholder(s) were left blank and kept as-is in the config; fill them in before use.",
852
+
853
+ // R8:连接器 OAuth 授权(401 → 浏览器授权页,成功后自动完成安装)。
854
+ "授权 {0}": "Authorize {0}",
855
+ "该连接器要求在浏览器里完成一次登录授权。授权页会尝试自动打开;授权完成后这里会自动继续,无需手动刷新。": "This connector needs a one-time sign-in in your browser. The authorization page is opened automatically, and this dialog continues on its own once you finish — no need to refresh.",
856
+ "正在启动授权…": "Starting authorization…",
857
+ "无法启动授权流程": "Unable to start the authorization flow",
858
+ "没能自动唤起浏览器,请复制下方链接手动打开。": "The browser could not be opened automatically — copy the link below and open it manually.",
859
+ "授权链接": "Authorization link",
860
+ "重新打开浏览器": "Reopen browser",
861
+ "该服务器只接受自定义协议回调,浏览器跳转后页面会打不开。请把地址栏里的完整地址复制到下面。": "This server only accepts a custom-scheme callback, so the redirect page will not load. Copy the full address from the browser's address bar into the field below.",
862
+ "粘贴完整回调地址": "Paste the complete callback address",
863
+ "提交回调": "Submit callback",
864
+ "正在校验回调…": "Verifying callback…",
865
+ "回调地址无效,请重新授权。": "That callback address is not valid. Please authorize again.",
866
+ "授权失败": "Authorization failed",
867
+ "也可以直接粘贴访问令牌": "You can also paste an access token directly",
868
+ "粘贴访问令牌": "Paste an access token",
869
+ "用令牌安装": "Install with token",
870
+ "正在保存令牌…": "Saving token…",
871
+ "{0}:授权成功,正在完成安装…": "{0}: authorized, finishing installation…",
872
+
873
+ // Skill 看板分类升级(skillcat/skillview/skillpick):分类名与新增操作文案的英译。
874
+ "编程开发": "Programming & Dev",
875
+ "设计与创作": "Design & Creation",
876
+ "文档办公": "Documents & Office",
877
+ "效率与自动化": "Productivity & Automation",
878
+ "开发运维": "DevOps",
879
+ "未分类": "Uncategorized",
880
+ "网格视图": "Grid view",
881
+ "列表视图": "List view",
882
+ "视图切换": "Switch view",
883
+ "按分类过滤 skill": "Filter skills by category",
884
+ "显示 {0} / {1} 个 skill": "Showing {0} of {1} skills",
885
+ "所属分类": "Category",
886
+ "来源目录": "Source directory",
887
+ "文件位置": "File location",
888
+ "复制 SKILL.md 路径": "Copy SKILL.md path",
889
+ "复制 @会话引用": "Copy @session reference",
890
+
785
891
  ":": ": ",
786
892
  };
787
893
  // OPT-14 · 1.1:仅当判定为英文 locale 时装载 en-US 表;zh-CN(源语言)保持空字典
@@ -829,6 +935,154 @@ function migrateLegacyKeys(storage) {
829
935
  }
830
936
  migrateLegacyKeys(typeof localStorage === 'undefined' ? null : localStorage);
831
937
 
938
+ // ── 秒开快照:DSH 社区目录的浏览器侧镜像 ───────────────────────────────────
939
+ // 服务端已改成 stale-while-revalidate(命中缓存即返回),但「第一次进插件页」仍要
940
+ // 走一次 IPC + 反序列化 1.5-2MB 目录,首屏还是一段空白。这里把上一次成功渲染过的
941
+ // 目录在浏览器侧留一份镜像:下次进插件页先用它铺满首屏(0 网络、0 IPC),随后被
942
+ // 真实结果静默替换。快照只是加速手段——读写失败一律静默退回原来的空白→加载路径。
943
+ const AWESOME_SNAPSHOT_KEY = KEY_PREFIX + '.awesomeCatalog.v1';
944
+ const AWESOME_SNAPSHOT_MAX_AGE = 7 * 24 * 60 * 60 * 1000;
945
+ const AWESOME_SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
946
+ function readAwesomeSnapshot() {
947
+ try {
948
+ const raw = localStorage.getItem(AWESOME_SNAPSHOT_KEY);
949
+ if (!raw) return null;
950
+ const parsed = JSON.parse(raw);
951
+ if (!parsed || !Array.isArray(parsed.plugins) || !parsed.plugins.length) return null;
952
+ const savedAt = Number(parsed.savedAt || 0);
953
+ if (!savedAt || Date.now() - savedAt > AWESOME_SNAPSHOT_MAX_AGE) return null;
954
+ return parsed;
955
+ } catch { return null; }
956
+ }
957
+ function writeAwesomeSnapshot(payload) {
958
+ try {
959
+ if (!payload || !Array.isArray(payload.plugins) || !payload.plugins.length) return;
960
+ const text = JSON.stringify({
961
+ savedAt: Date.now(),
962
+ marketplace: payload.marketplace || 'awesome-dsh-plugins',
963
+ fetchedAt: payload.fetchedAt || 0,
964
+ plugins: payload.plugins,
965
+ });
966
+ // 超出配额安全线就不写:留着旧快照比写坏一次更划算。
967
+ if (text.length > AWESOME_SNAPSHOT_MAX_BYTES) return;
968
+ localStorage.setItem(AWESOME_SNAPSHOT_KEY, text);
969
+ } catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
970
+ }
971
+
972
+ // ── 安装轻提示:挂在 document.body 上的常驻浮层 ─────────────────────────────
973
+ // 安装进度此前只存在组件本地 state(busy / busyLabel):用户一切到别的面板或窗口,
974
+ // 组件卸载 → 进度消失 → 看起来像「安装被取消了」。这个浮层直接挂 document.body,
975
+ // 不随面板卸载,切走再切回、甚至切到别的标签页都还看得见。
976
+ // 约束:纯 DOM 实现(本文件没有 react-dom,拿不到 createPortal);只用 textContent
977
+ // 写插件名,不拼 innerHTML。
978
+ const INSTALL_TOAST_ID = KEY_PREFIX + '-install-toast';
979
+ const INSTALL_TOAST_CSS_ID = KEY_PREFIX + '-install-toast-css';
980
+ // name → { name, phase: 'running' | 'ok' | 'fail', at }
981
+ const installTasks = new Map();
982
+ const installTimers = new Map();
983
+ let installToastNode = null;
984
+ function installToastCss() {
985
+ if (typeof document === 'undefined' || document.getElementById(INSTALL_TOAST_CSS_ID)) return;
986
+ const style = document.createElement('style');
987
+ style.id = INSTALL_TOAST_CSS_ID;
988
+ style.textContent = [
989
+ '.dsh-mcp-install-toast{position:fixed;top:64px;left:50%;transform:translate(-50%,-14px);z-index:2147483000;',
990
+ 'display:none;flex-direction:column;gap:6px;min-width:220px;max-width:min(420px,calc(100vw - 32px));',
991
+ 'padding:10px 14px 11px;border-radius:12px;pointer-events:none;',
992
+ 'background:var(--dsw-alias-bg-elevated,rgba(32,33,36,.92));color:var(--dsw-alias-text-primary,#f3f4f6);',
993
+ 'border:1px solid var(--dsw-alias-border-subtle,rgba(255,255,255,.12));',
994
+ 'box-shadow:0 10px 30px rgba(0,0,0,.28);backdrop-filter:blur(14px) saturate(160%);',
995
+ 'font-size:12px;line-height:1.45;opacity:0;transition:opacity .18s ease,transform .18s ease}',
996
+ '.dsh-mcp-install-toast.on{display:flex;opacity:1;transform:translate(-50%,0)}',
997
+ '.dsh-mcp-install-toast-title{font-weight:600;font-size:12px;letter-spacing:.01em}',
998
+ '.dsh-mcp-install-toast-row{display:flex;align-items:center;gap:8px;min-width:0}',
999
+ '.dsh-mcp-install-toast-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-accent-primary,#4d9fff);animation:dsh-mcp-install-pulse 1.1s ease-in-out infinite}',
1000
+ '.dsh-mcp-install-toast-row[data-phase="ok"] .dsh-mcp-install-toast-dot{animation:none;background:var(--dsw-alias-status-success,#35c46a)}',
1001
+ '.dsh-mcp-install-toast-row[data-phase="fail"] .dsh-mcp-install-toast-dot{animation:none;background:var(--dsw-alias-status-danger,#f0616b)}',
1002
+ '.dsh-mcp-install-toast-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
1003
+ '.dsh-mcp-install-toast-status{flex:0 0 auto;opacity:.72;font-variant-numeric:tabular-nums}',
1004
+ '@keyframes dsh-mcp-install-pulse{0%,100%{opacity:.35;transform:scale(.85)}50%{opacity:1;transform:scale(1)}}',
1005
+ '@media (prefers-reduced-motion:reduce){.dsh-mcp-install-toast,.dsh-mcp-install-toast-dot{transition:none;animation:none}}',
1006
+ ].join('');
1007
+ document.head.appendChild(style);
1008
+ }
1009
+ function ensureInstallToast() {
1010
+ if (typeof document === 'undefined' || !document.body) return null;
1011
+ if (installToastNode && installToastNode.isConnected) return installToastNode;
1012
+ installToastCss();
1013
+ const node = document.getElementById(INSTALL_TOAST_ID) || document.createElement('div');
1014
+ if (!node.id) {
1015
+ node.id = INSTALL_TOAST_ID;
1016
+ node.className = 'dsh-mcp-install-toast';
1017
+ node.setAttribute('role', 'status');
1018
+ node.setAttribute('aria-live', 'polite');
1019
+ }
1020
+ if (!node.isConnected) document.body.appendChild(node);
1021
+ installToastNode = node;
1022
+ return node;
1023
+ }
1024
+ function renderInstallToast() {
1025
+ const node = ensureInstallToast();
1026
+ if (!node) return;
1027
+ const tasks = [...installTasks.values()];
1028
+ node.replaceChildren();
1029
+ if (!tasks.length) { node.classList.remove('on'); return; }
1030
+ const running = tasks.filter((task) => task.phase === 'running').length;
1031
+ const title = document.createElement('div');
1032
+ title.className = 'dsh-mcp-install-toast-title';
1033
+ title.textContent = running ? t('正在安装 {0} 个插件…', [String(running)]) : t('安装任务已结束');
1034
+ node.appendChild(title);
1035
+ for (const task of tasks.slice(0, 3)) {
1036
+ const row = document.createElement('div');
1037
+ row.className = 'dsh-mcp-install-toast-row';
1038
+ row.setAttribute('data-phase', task.phase);
1039
+ const dot = document.createElement('span');
1040
+ dot.className = 'dsh-mcp-install-toast-dot';
1041
+ const name = document.createElement('span');
1042
+ name.className = 'dsh-mcp-install-toast-name';
1043
+ name.textContent = task.name;
1044
+ const status = document.createElement('span');
1045
+ status.className = 'dsh-mcp-install-toast-status';
1046
+ status.textContent = task.phase === 'running' ? t('安装中…') : task.phase === 'ok' ? t('已安装') : t('安装失败');
1047
+ row.appendChild(dot); row.appendChild(name); row.appendChild(status);
1048
+ node.appendChild(row);
1049
+ }
1050
+ node.classList.add('on');
1051
+ }
1052
+ function installToastStart(name) {
1053
+ const key = String(name || '');
1054
+ if (!key) return;
1055
+ const pending = installTimers.get(key);
1056
+ if (pending) { clearTimeout(pending); installTimers.delete(key); }
1057
+ installTasks.set(key, { name: key, phase: 'running', at: Date.now() });
1058
+ renderInstallToast();
1059
+ }
1060
+ function installToastFinish(name, phase) {
1061
+ const key = String(name || '');
1062
+ const task = installTasks.get(key);
1063
+ if (!task) return;
1064
+ task.phase = phase;
1065
+ task.at = Date.now();
1066
+ renderInstallToast();
1067
+ // 结果态留 4.2s 让用户看清「装完了 / 失败了」,再淡出;期间同一插件重新开装会取消这个定时器。
1068
+ const timer = setTimeout(() => {
1069
+ installTimers.delete(key);
1070
+ const current = installTasks.get(key);
1071
+ if (current && current.phase !== 'running' && Date.now() - current.at >= 4000) {
1072
+ installTasks.delete(key);
1073
+ renderInstallToast();
1074
+ }
1075
+ }, 4200);
1076
+ installTimers.set(key, timer);
1077
+ }
1078
+
1079
+ // 面板卸载后组件的 busy 会归零,但模块级的 installTasks 还在跑:按钮禁用要同时看它,
1080
+ // 否则切个面板回来还能再点一次「安装」,同一插件被并发装两遍。
1081
+ function isInstallRunning(name) {
1082
+ const task = installTasks.get(String(name || ''));
1083
+ return Boolean(task && task.phase === 'running');
1084
+ }
1085
+
832
1086
  // ── 样式表 MCP_CSS:主题令牌别名 + 全部面板规则(一次性注入 <style>)──────────────
833
1087
  /* 主题令牌别名层。DSH 的 ui-theme 拥有全部颜色、几何与字体:插件只消费语义令牌,
834
1088
  不写死色值、不写局部圆角数值(docs/web-styling.md:「Use --dsw-alias-* semantic
@@ -858,7 +1112,11 @@ migrateLegacyKeys(typeof localStorage === 'undefined' ? null : localStorage);
858
1112
  const FAB_SIZE = 50;
859
1113
  const FAB_EDGE = 22;
860
1114
 
861
- const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(--dsw-alias-state-success-primary);--mcp-err:var(--dsw-alias-state-error-primary);--mcp-warn:var(--dsw-alias-state-warn-primary);--mcp-mut:var(--dsw-alias-label-secondary);--mcp-bg-1:var(--dsw-alias-bg-layer-1);--mcp-bg-2:var(--dsw-alias-bg-layer-2);--mcp-bg-3:var(--dsw-alias-bg-layer-3);--mcp-line:var(--dsw-alias-border-l2);--mcp-mono:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);--mcp-r-xs:var(--dsw-radius-xs);--mcp-r-sm:var(--dsw-radius-sm);--mcp-r-md:var(--dsw-radius-md);--mcp-r-lg:var(--dsw-radius-lg);--mcp-r-xl:var(--dsw-radius-xl);--mcp-r-panel:var(--dsw-radius-panel)}
1115
+ const MCP_CSS = `body{--mcp-cat-all:var(--dsw-alias-label-primary);--mcp-cat-uncat:var(--dsw-alias-label-primary);--mcp-cat-dev:var(--dsw-static-deepseek-500);--mcp-cat-design:var(--dsw-static-blue-400);--mcp-cat-office:var(--dsw-static-green-500);--mcp-cat-auto:var(--dsw-static-amber-500);--mcp-cat-ops:var(--dsw-static-neutral-bluish-500);--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(--dsw-alias-state-success-primary);--mcp-err:var(--dsw-alias-state-error-primary);--mcp-warn:var(--dsw-alias-state-warn-primary);--mcp-mut:var(--dsw-alias-label-secondary);--mcp-bg-1:var(--dsw-alias-bg-layer-1);--mcp-bg-2:var(--dsw-alias-bg-layer-2);--mcp-bg-3:var(--dsw-alias-bg-layer-3);--mcp-line:var(--dsw-alias-border-l2);--mcp-mono:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);--mcp-r-xs:var(--dsw-radius-xs);--mcp-r-sm:var(--dsw-radius-sm);--mcp-r-md:var(--dsw-radius-md);--mcp-r-lg:var(--dsw-radius-lg);--mcp-r-xl:var(--dsw-radius-xl);--mcp-r-panel:var(--dsw-radius-panel)}
1116
+ /* Skill 看板升级 · 分类色(决策 10:只从宿主既有色相取,只用于色点与徽标)。
1117
+ 暗色套与宿主 ui-theme 同构(body[data-ds-dark-theme],取证见进度文档 §七);
1118
+ 分类色永远与文字标签成对出现,不单独用颜色传达信息。 */
1119
+ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-design:var(--dsw-static-blue-300);--mcp-cat-office:var(--dsw-static-green-400);--mcp-cat-auto:var(--dsw-static-amber-400);--mcp-cat-ops:var(--dsw-static-neutral-bluish-400)}
862
1120
  .dsh-mcp-wrap,.dsh-mcp-panel-overlay,.dsh-mcp-overlay,.dsh-mcp-fab,.dsh-mcp-footer-action,.dsh-mcp-toast{color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}
863
1121
  .dsh-mcp-wrap{box-sizing:border-box;display:flex;flex-direction:column;height:100%;min-height:0;gap:14px;padding:4px 2px;font-size:13px;-webkit-font-smoothing:antialiased}
864
1122
  .dsh-mcp-wrap ::-webkit-scrollbar{width:8px;height:8px}
@@ -980,6 +1238,14 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
980
1238
  .dsh-mcp-wb-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
981
1239
  .dsh-mcp-wb-toolbar .dsh-mcp-check{margin:0!important;font-size:12px}
982
1240
  .dsh-mcp-wb-toolbar input[type='search']{flex:1 1 220px;min-width:160px;box-sizing:border-box}
1241
+ /* T02/R2:visible_in 标签筛选 chips。选中态用 accent 边框 + 主色文字,与 wb-tab.sel 同语;clear 是弱化动作 */
1242
+ .dsh-mcp-tag-chip{border:1px solid var(--mcp-line);border-radius:999px;background:var(--mcp-bg-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;line-height:18px;padding:3px 10px;cursor:pointer;transition:border-color .12s,color .12s}
1243
+ .dsh-mcp-tag-chip:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
1244
+ .dsh-mcp-tag-chip.on{border-color:var(--mcp-accent);color:var(--mcp-accent);font-weight:600}
1245
+ .dsh-mcp-tag-chip.clear{border-style:dashed;color:var(--mcp-mut)}
1246
+ .dsh-mcp-tag-chip.clear:hover{color:var(--dsw-alias-label-primary)}
1247
+ /* T03/R5:重启提示条——与普通 note 同框但带 accent 左边条,右侧「知道了」弱化动作 */
1248
+ .dsh-mcp-wb-note.restart{border-left:3px solid var(--mcp-accent);display:flex;align-items:center;gap:10px}
983
1249
  /* ── 市场搜索框(WbSearchBox):胶囊造型 + 内嵌放大镜 + 一键清空 ── */
984
1250
  .dsh-mcp-wb-search{position:relative;display:flex;align-items:center;flex:1 1 260px;min-width:200px;max-width:520px}
985
1251
  .dsh-mcp-wb-search-ico{position:absolute;left:11px;display:inline-flex;color:var(--mcp-mut);pointer-events:none;transition:color .15s}
@@ -1013,6 +1279,12 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
1013
1279
  .dsh-mcp-wb-modal-desc{font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere}
1014
1280
  .dsh-mcp-wb-modal-note{font-size:11.5px;line-height:1.6;color:var(--mcp-mut)}
1015
1281
  .dsh-mcp-wb-examples{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);background:var(--mcp-bg-2);border-radius:var(--mcp-r-sm);padding:8px 12px;margin:10px 0}
1282
+ .dsh-mcp-wb-tokenform{display:flex;flex-direction:column;gap:10px;margin:12px 0 4px}
1283
+ .dsh-mcp-wb-tokenfield{display:flex;flex-direction:column;gap:5px;min-width:0}
1284
+ .dsh-mcp-wb-tokenlabel{font-size:11.5px;line-height:1.45;color:var(--mcp-mut);overflow-wrap:anywhere}
1285
+ .dsh-mcp-wb-tokenfield input{box-sizing:border-box;width:100%;padding:7px 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:inherit;font-size:12px;outline:none;transition:border-color .15s}
1286
+ .dsh-mcp-wb-tokenfield input:focus{border-color:var(--mcp-accent)}
1287
+ .dsh-mcp-wb-tokenfield input::placeholder{color:var(--mcp-mut)}
1016
1288
  .dsh-mcp-wb-examples ul{margin:4px 0 0;padding-left:18px}
1017
1289
  .dsh-mcp-wb-roster{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:5px}
1018
1290
  .dsh-mcp-wb-roster li{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;font-size:12px;line-height:1.5;padding:5px 9px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-2)}
@@ -1190,10 +1462,21 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
1190
1462
  .dsh-mcp-quick-note.ok{color:var(--mcp-ok)}
1191
1463
  .dsh-mcp-quick-err{color:var(--mcp-err)}
1192
1464
  /* ── 主面板页(main slot,和「插件」同级的入口页)────────────────────────
1193
- 入口型页面要用 --dsh-frame-top-clearance 让出窗口顶带(官方 ui-layout 的约定:
1194
- "entry pages in the main panel pad their top by it"),对话面板除外。 */
1195
- .dsh-mcp-page{box-sizing:border-box;height:100%;min-height:0;overflow:auto;padding:calc(var(--dsh-frame-top-clearance,0px) + 16px) 20px 24px;background:var(--dsw-alias-bg-base)}
1196
- .dsh-mcp-page>.dsh-mcp-wrap{height:calc(100dvh - var(--dsh-frame-top-clearance,0px) - 40px);min-height:520px;max-width:1180px;margin:0 auto}
1465
+ 顶带让位必须**按平台分开**,这是 Windows 上「首次进入整体偏下、要下滚 40px」的根因:
1466
+ · macOS:框架不补顶带,页面自己让(--dsh-frame-top-clearance 由宿主设为 48px);
1467
+ · Windows:框架的 [data-windows-titlebar] .BynINW_frame 已经 padding-top 过一次
1468
+ 标题栏高度,页面再让自己的 clearance 就是把同一段留白算两遍。
1469
+ 官方 ui-plugin-manager 的 PluginManagerPage 就是这个写法(基础 padding-top 28px,
1470
+ 仅 [data-platform=darwin] 叠加 clearance),照抄可保证与官方页面在同一窗口下等高。
1471
+ 高度不走 100dvh 反算:视口高≠中栏可用高(框架已扣过标题栏),一旦反算必然多出
1472
+ 一段正好等于标题栏高度的溢出。改成 flex 填充父容器可用高度,从结构上消除这道算术。 */
1473
+ .dsh-mcp-page{box-sizing:border-box;height:100%;min-height:0;overflow:auto;display:flex;flex-direction:column;padding:16px 20px 24px;background:var(--dsw-alias-bg-base)}
1474
+ [data-platform=darwin] .dsh-mcp-page{padding-top:calc(var(--dsh-frame-top-clearance,0px) + 16px)}
1475
+ /* flex:1 1 auto 让内容吃满剩余高度(no scrollbar);min-height:520px 是窗口过矮时的
1476
+ 兜底——撑破容器后由外层 overflow:auto 接管整体滚动,而不是把内部列表压扁。
1477
+ width:100% 必须显式给:改成 flex 容器后,auto 外边距(居中)会让交叉轴尺寸回落到
1478
+ fit-content,去掉它会比原来的块级铺满莫名其妙地窄一圈。 */
1479
+ .dsh-mcp-page>.dsh-mcp-wrap{flex:1 1 auto;width:100%;min-height:520px;max-width:1180px;margin:0 auto}
1197
1480
  .dsh-mcp-page .dsh-mcp-body{flex:1 1 auto;min-height:0}
1198
1481
  /* 主面板页里没有「关闭」按钮(返回对话由侧栏入口/会话列表承担),顶栏右侧留白更自然。 */
1199
1482
  .dsh-mcp-page .dsh-mcp-head{padding-right:2px}
@@ -1211,7 +1494,7 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
1211
1494
  .dsh-mcp-skill-view{display:flex;flex-direction:column;gap:12px;min-height:0}
1212
1495
  .dsh-mcp-skill-body{display:flex;gap:14px;min-height:0;align-items:stretch}
1213
1496
  .dsh-mcp-skill-side{width:340px;flex:none;display:flex;flex-direction:column;gap:10px;min-height:0}
1214
- .dsh-mcp-skill-list{overflow-y:auto;min-height:0;display:flex;flex-direction:column;gap:4px;padding-right:2px}
1497
+ .dsh-mcp-skill-list{overflow-y:auto;scrollbar-gutter:stable;min-height:0;display:flex;flex-direction:column;gap:4px;padding-right:2px}
1215
1498
  .dsh-mcp-skill{display:flex;align-items:flex-start;gap:9px;padding:9px 10px;border-radius:var(--mcp-r-md);corner-shape:round;cursor:pointer;border:0;background:0 0;color:inherit;font:inherit;text-align:left}
1216
1499
  .dsh-mcp-skill:hover{background:var(--dsw-alias-interactive-bg-hover)}
1217
1500
  .dsh-mcp-skill[aria-selected="true"]{background:var(--mcp-bg-3)}
@@ -1225,7 +1508,7 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
1225
1508
  .dsh-mcp-skill-d-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:14px 16px 10px}
1226
1509
  .dsh-mcp-skill-d-title{margin:0;font-size:15px;font-weight:500;color:var(--dsw-alias-label-primary);line-height:20px;word-break:break-word}
1227
1510
  .dsh-mcp-skill-d-sub{margin:4px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px;font-family:var(--mcp-mono);word-break:break-all}
1228
- .dsh-mcp-skill-d-body{padding:4px 16px 16px;overflow-y:auto;min-height:0;display:flex;flex-direction:column;gap:12px}
1511
+ .dsh-mcp-skill-d-body{padding:4px 16px 16px;overflow-y:auto;scrollbar-gutter:stable;min-height:0;display:flex;flex-direction:column;gap:12px}
1229
1512
  .dsh-mcp-skill-d-sec{margin:0}
1230
1513
  .dsh-mcp-skill-d-sec>b{display:block;font-size:11px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:var(--dsw-alias-label-tertiary);margin-bottom:5px}
1231
1514
  .dsh-mcp-skill-d-desc{margin:0;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;white-space:pre-wrap}
@@ -1236,6 +1519,81 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
1236
1519
  .dsh-mcp-link-btn{border:0;background:0 0;color:var(--mcp-accent);font:inherit;font-size:12px;line-height:17px;padding:0;cursor:pointer}
1237
1520
  .dsh-mcp-skill-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;padding:10px 2px}
1238
1521
 
1522
+ /* Skill 看板升级:分类轨道 + 网格/列表双视图 + 详情操作区。
1523
+ 全部走宿主主题令牌(决策 2/10),分类色只出现在色点与徽标,且永远伴随文字标签。 */
1524
+ .dsh-mcp-skill-toolbar{display:flex;align-items:center;gap:10px;flex:none}
1525
+ .dsh-mcp-skill-search{flex:1 1 auto;min-width:0}
1526
+ .dsh-mcp-skill-seg{display:inline-flex;flex:none;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-2);padding:2px;gap:2px}
1527
+ .dsh-mcp-skill-seg-btn{display:inline-flex;align-items:center;gap:5px;border:0;border-radius:var(--mcp-r-sm);corner-shape:round;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;line-height:20px;padding:2px 9px;cursor:pointer;transition:background .12s,color .12s}
1528
+ .dsh-mcp-skill-seg-btn:hover{color:var(--dsw-alias-label-primary)}
1529
+ .dsh-mcp-skill-seg-btn[aria-pressed="true"]{background:var(--dsw-alias-button-elevated-fill);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.08)}
1530
+ .dsh-mcp-skill-seg-btn:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1531
+ .dsh-mcp-skill-rail{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:none}
1532
+ .dsh-mcp-skill-cat{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid var(--mcp-line);border-radius:999px;corner-shape:round;background:var(--mcp-bg-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;line-height:18px;white-space:nowrap;cursor:pointer;transition:background .12s,border-color .12s,color .12s}
1533
+ .dsh-mcp-skill-cat:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
1534
+ .dsh-mcp-skill-cat.on{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-primary);font-weight:600}
1535
+ .dsh-mcp-skill-cat:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1536
+ .dsh-mcp-skill-cat-count{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}
1537
+ .dsh-mcp-skill-cat.on .dsh-mcp-skill-cat-count{color:var(--dsw-alias-label-primary)}
1538
+ .dsh-mcp-skill-cat-dot{display:inline-block;width:7px;height:7px;border-radius:50%;flex:none}
1539
+ .dsh-mcp-skill-result{flex:none}
1540
+ .dsh-mcp-skill-content{flex:1 1 auto;min-width:0;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding-right:2px}
1541
+ .dsh-mcp-skill-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(258px,1fr));gap:10px;align-content:start}
1542
+ .dsh-mcp-skill-cluster{margin-top:4px}
1543
+ .dsh-mcp-skill-card{position:relative;display:flex;flex-direction:column;text-align:left;border:0;padding:0;background:0 0;color:inherit;font:inherit;cursor:pointer;border-radius:var(--mcp-r-lg);corner-shape:round}
1544
+ .dsh-mcp-skill-card-inner{display:flex;flex-direction:column;gap:8px;box-sizing:border-box;height:100%;padding:12px 13px;border:0.5px solid var(--mcp-line);border-radius:var(--mcp-r-lg);corner-shape:round;background:var(--mcp-bg-1);transition:background .12s,border-color .12s}
1545
+ .dsh-mcp-skill-card:hover .dsh-mcp-skill-card-inner{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3)}
1546
+ .dsh-mcp-skill-card.on .dsh-mcp-skill-card-inner{border-color:var(--mcp-accent)}
1547
+ .dsh-mcp-skill-card:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}
1548
+ .dsh-mcp-skill-card-top{display:flex;align-items:center;gap:8px;min-width:0}
1549
+ .dsh-mcp-skill-cat-tile{display:inline-block;width:10px;height:10px;border-radius:3px;flex:none}
1550
+ .dsh-mcp-skill-card-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:13px;line-height:18px;color:var(--dsw-alias-label-primary)}
1551
+ .dsh-mcp-skill-desc-none{color:var(--dsw-alias-label-tertiary)}
1552
+ .dsh-mcp-skill-cat-tag{color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:16px;white-space:nowrap}
1553
+ .dsh-mcp-skill.on{box-shadow:inset 3px 0 0 var(--mcp-accent)}
1554
+ .dsh-mcp-skill-d-badges{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:4px 0 0}
1555
+ .dsh-mcp-skill-d-src{margin:3px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px;font-family:var(--mcp-mono);word-break:break-all}
1556
+ .dsh-mcp-skill-d-meta{display:grid;grid-template-columns:auto minmax(0,1fr);gap:5px 14px;font-size:12px;word-break:break-all}
1557
+ .dsh-mcp-skill-d-meta>span{color:var(--dsw-alias-label-tertiary)}
1558
+ .dsh-mcp-skill-d-meta>b{font-weight:400;color:var(--dsw-alias-label-primary);min-width:0}
1559
+ .dsh-mcp-skill-d-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:2px}
1560
+ .dsh-mcp-skill-d-flash{align-self:flex-start;padding:5px 12px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-state-success-bg,var(--mcp-bg-2));color:var(--dsw-alias-state-success-fg,var(--mcp-ok));font-size:12px;line-height:17px}
1561
+ .dsh-mcp-skill-d-flash.err{background:var(--dsw-alias-state-danger-bg,var(--mcp-bg-2));color:var(--dsw-alias-state-danger-fg,var(--mcp-err))}
1562
+ /* 主列画布:替代旧 340px 侧栏(旧 .dsh-mcp-skill-side 仍被 760px 媒体查询引用,保留无害)。 */
1563
+ .dsh-mcp-skill-canvas{flex:1 1 auto;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px}
1564
+ .dsh-mcp-skill-card-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0}
1565
+ .dsh-mcp-skill-card-foot .dsh-mcp-skill-src{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1566
+ /* 详情栏 sticky(决策 3):宿主页面是滚动容器,sticky 生效;被 760px 以下布局覆盖时自动失效。 */
1567
+ .dsh-mcp-skill-detail{align-self:flex-start;position:sticky;top:16px;max-height:calc(100vh - 48px)}
1568
+ /* 列表视图:整块只渲染一个列表面板,分类之间用分割线隔开(不再按来源拆成多个列表)。 */
1569
+ .dsh-mcp-skill-divider{display:flex;align-items:center;gap:8px;padding:12px 2px 4px;font-size:11px;line-height:16px;letter-spacing:.04em;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1570
+ .dsh-mcp-skill-divider:first-child{padding-top:2px}
1571
+ .dsh-mcp-skill-divider-line{flex:1 1 auto;height:1px;min-width:12px;background:var(--mcp-line)}
1572
+ .dsh-mcp-skill-divider-count{font-variant-numeric:tabular-nums}
1573
+ /* 列表视图下详情栏加宽,并做成「选中前后尺寸不变」的固定框。
1574
+ 抖动有三个来源,全部在这里掐掉:
1575
+ ① 原来写成 392→452 的二段宽度,选中瞬间整块横向重排一次;
1576
+ ② 高度由内容撑开(描述/元信息/指令正文字数各不相同),每次切换都收缩再展开;
1577
+ ③ position:sticky + top 在高度变化时反复重算吸附状态。
1578
+ 现在宽度一次到位 452px,高度跟随左侧列表所在的 flex 行(align-self:stretch),
1579
+ 内容只在自己内部滚动,外框不再参与重排。 */
1580
+ @media (min-width:761px){
1581
+ .dsh-mcp-skill-body.list-mode .dsh-mcp-skill-detail{flex:0 0 452px;align-self:stretch;position:static;height:auto;max-height:calc(100vh - 48px)}
1582
+ }
1583
+ /* 网格视图不再占右侧详情栏:详情形内容整体浮成居中弹窗(与市场卡片详情同一套观感)。
1584
+ 网格模式下未选中时那份「从左侧选择一个 skill」空态栏直接不占位,避免右侧留一条死白。 */
1585
+ .dsh-mcp-skill-body:not(.list-mode) .dsh-mcp-skill-detail:not(.is-modal){display:none}
1586
+ .dsh-mcp-skill-modal-hit{position:fixed;inset:0;background:var(--dsw-alias-bg-overlay);z-index:200;animation:mcpFade .14s ease-out}
1587
+ .dsh-mcp-skill-detail.is-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:min(760px,92vw);max-height:min(84vh,860px);overflow-y:auto;z-index:201;box-sizing:border-box;background:var(--mcp-bg-2);border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-panel);box-shadow:var(--dsw-elevation-prominent);padding:20px;align-self:auto;animation:skillModalIn .16s ease-out}
1588
+ /* 不能用现成的 mcpPop:它的关键帧自带 scale/translateY,会把居中的 translate(-50%,-50%) 覆盖掉,
1589
+ 动画期间弹窗会先跑到右下再跳回来。这里把居中的位移写进关键帧,缩放入场才不会破位。 */
1590
+ @keyframes skillModalIn{from{opacity:0;transform:translate(-50%,-50%) scale(.965)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}
1591
+ .dsh-mcp-skill-detail.is-modal .dsh-mcp-skill-d-title{overflow-wrap:anywhere}
1592
+ .dsh-mcp-skill-detail.dsh-mcp-skill-detail.is-modal .dsh-mcp-skill-d-head{position:static;top:auto}
1593
+ .dsh-mcp-skill-d-close{position:absolute;top:12px;right:12px;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;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}
1594
+ .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
+ @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
+
1239
1597
  /* 工具裁缝(Tool Tailor):账本表格 + 税单卡 + 死工具清单。 */
1240
1598
  .dsh-mcp-tailor{display:flex;flex-direction:column;gap:12px;min-height:0}
1241
1599
  .dsh-mcp-tailor-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;flex:none}
@@ -1307,16 +1665,6 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
1307
1665
 
1308
1666
  /* §1.2 作用域分段控件的样式只在下方声明一次(勿在此重复插入)。 */
1309
1667
 
1310
- /* ── §1.3 状态语义标签(四色唯一口径;"不兼容"降到橙,不用红)─────────────── */
1311
- .dsh-mcp-sev{display:inline-flex;align-items:center;gap:5px;padding:1.5px 7px;border-radius:999px;border:1px solid var(--mcp-line);background:var(--mcp-bg-1);color:var(--dsw-alias-label-secondary);font-size:10.5px;font-weight:500;line-height:16px;white-space:nowrap;corner-shape:round}
1312
- .dsh-mcp-sev::before{content:"";flex:none;width:5px;height:5px;border-radius:50%;background:currentColor;corner-shape:round}
1313
- .dsh-mcp-sev.ok{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--mcp-ok) 45%,transparent);background:var(--dsw-alias-state-success-tertiary)}
1314
- .dsh-mcp-sev.warn{color:var(--dsw-alias-state-warn-primary);border-color:color-mix(in srgb,var(--mcp-warn) 45%,transparent);background:var(--dsw-alias-state-warn-tertiary)}
1315
- .dsh-mcp-sev.err{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--mcp-err) 45%,transparent);background:var(--dsw-alias-interactive-bg-hover-danger)}
1316
- .dsh-mcp-sev.info{color:var(--dsw-alias-brand-text);border-color:color-mix(in srgb,var(--mcp-accent) 40%,transparent);background:var(--dsw-alias-interactive-bg-hover-accent)}
1317
- .dsh-mcp-sev.mute{color:var(--dsw-alias-label-tertiary)}
1318
- .dsh-mcp-sev.mute::before{background:var(--dsw-alias-label-tertiary)}
1319
-
1320
1668
  /* ── §1.4 空状态四件套:图标 + 一句结论 + 一句原因 + 一个主操作 ───────────────── */
1321
1669
  .dsh-mcp-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:34px 20px;text-align:center;color:var(--dsw-alias-label-tertiary)}
1322
1670
  .dsh-mcp-empty-ico{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:40px;height:40px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-2);color:var(--dsw-alias-label-tertiary);margin-bottom:2px}
@@ -1593,7 +1941,7 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
1593
1941
  @media (prefers-reduced-motion: reduce){.dsh-mcp-skeleton-bar::after{animation:none;opacity:0}}
1594
1942
 
1595
1943
  /* 响应式覆盖必须位于全部基础规则之后:同特异性下后出现者胜出。 */
1596
- @media (max-width:760px){.dsh-mcp-panel-overlay{padding:10px}.dsh-mcp-panel{max-width:none;width:100%;height:calc(100dvh - 20px);border-radius:var(--mcp-r-panel)}.dsh-mcp-panel .dsh-mcp-wrap{padding:12px}.dsh-mcp-head{align-items:flex-start}.dsh-mcp-body{flex-direction:column;overflow-y:auto;overflow-x:hidden}.dsh-mcp-side,.dsh-mcp-ws-body{width:auto;min-width:0;max-height:none;flex:none}.dsh-mcp-side:not(.embedded) .dsh-mcp-list{max-height:38vh}.dsh-mcp-section.local,.dsh-mcp-section.global{flex:none;min-height:0;max-height:none}.dsh-mcp-section.local .dsh-mcp-list{max-height:30vh}.dsh-mcp-global-list{max-height:30vh}.dsh-mcp-detail{padding:12px;flex:none}.dsh-mcp-d-head{flex-direction:column}.dsh-mcp-kv-edit{grid-template-columns:minmax(90px,35%) minmax(0,1fr) auto}.dsh-mcp-page{padding:calc(var(--dsh-frame-top-clearance,0px) + 10px) 12px 20px}.dsh-mcp-page>.dsh-mcp-wrap{min-height:0;height:auto}.dsh-mcp-skill-body{flex-direction:column;overflow:visible}.dsh-mcp-skill-side{width:auto;flex:none}.dsh-mcp-skill-list{max-height:34vh}.dsh-mcp-skill-detail{flex:none;max-height:none}.dsh-mcp-skill-d-md{max-height:38vh}}
1944
+ @media (max-width:760px){.dsh-mcp-panel-overlay{padding:10px}.dsh-mcp-panel{max-width:none;width:100%;height:calc(100dvh - 20px);border-radius:var(--mcp-r-panel)}.dsh-mcp-panel .dsh-mcp-wrap{padding:12px}.dsh-mcp-head{align-items:flex-start}.dsh-mcp-body{flex-direction:column;overflow-y:auto;overflow-x:hidden}.dsh-mcp-side,.dsh-mcp-ws-body{width:auto;min-width:0;max-height:none;flex:none}.dsh-mcp-side:not(.embedded) .dsh-mcp-list{max-height:38vh}.dsh-mcp-section.local,.dsh-mcp-section.global{flex:none;min-height:0;max-height:none}.dsh-mcp-section.local .dsh-mcp-list{max-height:30vh}.dsh-mcp-global-list{max-height:30vh}.dsh-mcp-detail{padding:12px;flex:none}.dsh-mcp-d-head{flex-direction:column}.dsh-mcp-kv-edit{grid-template-columns:minmax(90px,35%) minmax(0,1fr) auto}.dsh-mcp-page{padding:10px 12px 20px}[data-platform=darwin] .dsh-mcp-page{padding-top:calc(var(--dsh-frame-top-clearance,0px) + 10px)}.dsh-mcp-page>.dsh-mcp-wrap{flex:0 0 auto;width:100%;min-height:0;height:auto}.dsh-mcp-skill-body{flex-direction:column;overflow:visible}.dsh-mcp-skill-side{width:auto;flex:none}.dsh-mcp-skill-list{max-height:34vh}.dsh-mcp-skill-detail{flex:none;max-height:none}.dsh-mcp-skill-d-md{max-height:38vh}}
1597
1945
  @media (max-width:480px){.dsh-mcp-head{flex-direction:column}.dsh-mcp-head-actions{width:100%;justify-content:flex-start}.dsh-mcp-btn{padding:7px 9px}.dsh-mcp-radio-row,.dsh-mcp-number-grid{grid-template-columns:1fr}.dsh-mcp-modal{max-width:none;width:100%;padding:14px}.dsh-mcp-kv{grid-template-columns:1fr;gap:4px}.dsh-mcp-kv>b{margin-top:4px}.dsh-mcp-secret-row{grid-template-columns:1fr;gap:4px}.dsh-mcp-secret{width:100%}.dsh-mcp-kv-edit{grid-template-columns:1fr auto}.dsh-mcp-kv-edit input:first-child{grid-column:1 / -1}.dsh-mcp-tool{flex-direction:column}.dsh-mcp-tool-name{max-width:100%}.dsh-mcp-tool-desc{white-space:normal}.dsh-mcp-builtin-toolbar{align-items:flex-start;flex-direction:column}.dsh-mcp-builtin-row{grid-template-columns:18px minmax(0,1fr)}.dsh-mcp-builtin-status{grid-column:2;text-align:left}}
1598
1946
  `;
1599
1947
 
@@ -1731,6 +2079,9 @@ const ICONS = {
1731
2079
  store: 'M2.5 6.5L4 2.5h8l1.5 4M2.5 6.5h11M2.5 6.5V13.5h11V6.5M6 13.5v-4h4v4',
1732
2080
  // expert:专家(人物半身像:圆头 + 双肩),看板「专家」分类用
1733
2081
  expert: 'M8 2.5a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 0 1 0-4.4zM3.2 13.5c.4-2.7 2.3-4.2 4.8-4.2s4.4 1.5 4.8 4.2',
2082
+ // grid:网格视图(2×2 方块);list:列表视图(三行点线),Skill 看板视图切换用
2083
+ grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
2084
+ list: 'M2 3.5h2M6.5 3.5h7.5M2 8h2M6.5 8h7.5M2 12.5h2M6.5 12.5h7.5',
1734
2085
  };
1735
2086
 
1736
2087
  // ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
@@ -2028,14 +2379,9 @@ const ToolList = ({ tools, usageByName, searchable, query, onQuery }) => {
2028
2379
  };
2029
2380
 
2030
2381
  // ══ 原子组件层(优化项共用件)═════════════════════════════════════════════
2031
- // 放在这里而不是各页签内部:§1.3 状态语义、§1.4 空状态、§2.2 热度、§5 行尾菜单
2382
+ // 放在这里而不是各页签内部:§1.4 空状态、§2.2 热度、§5 行尾菜单
2032
2383
  // 在四个页签里都要用;复制四份必然走形,一处定义才能保证「同一种状态长得一样」。
2033
2384
 
2034
- // ── §1.3 状态语义标签:四色唯一口径(蓝=交互 / 绿=健康 / 橙=注意 / 红=破坏性)──
2035
- // tone: ok | warn | err | info | mute
2036
- const Sev = ({ tone, children, className, title }) =>
2037
- h('span', { className: 'dsh-mcp-sev' + (tone ? ' ' + tone : '') + (className ? ' ' + className : ''), title }, children);
2038
-
2039
2385
  // ── §1.4 空状态四件套:图标 + 结论 + 原因 + 主操作 ───────────────────────────
2040
2386
  // 一个空列表如果不解释「为什么空」和「下一步点哪」,用户只会反复刷新。
2041
2387
  // 四个槽位刻意分开:结论用 <b>,原因用 <span>,操作在最下——扫读顺序与决策顺序一致。
@@ -3957,6 +4303,10 @@ const PANEL_ID = 'mcp-manager';
3957
4303
  const PANEL_ORDER = 5;
3958
4304
  // 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
3959
4305
  const PANEL_LABEL = t('技能 · MCP · 专家');
4306
+ // 左上角侧栏入口(sidebar.panellist)单独命名,与 PANEL_LABEL 解耦:
4307
+ // 该行的 label 同时是可见文字、无障碍名称和折叠提示,改动不牵动输入框快捷菜单、
4308
+ // 会话头专家徽章、悬浮按钮和侧栏底部入口的文案。
4309
+ const SIDEBAR_ENTRY_LABEL = t('大肥鱼军械库');
3960
4310
 
3961
4311
  // skill 的 source 取值域来自官方 dsh-skill-filesystem 的 roots()(rank 顺序即优先级),
3962
4312
  // 与 skill-registry 的 runtime。这里只做展示文案,不参与判定。
@@ -3971,6 +4321,48 @@ const SKILL_SOURCE_LABELS = {
3971
4321
  };
3972
4322
  const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
3973
4323
 
4324
+ // ── Skill 分类升级(决策 6/10):6 类 + 未分类兜底,两段式强/弱特征词匹配 ─────────
4325
+ // 为什么两段式:中英混排描述里 code/image/frontend 这类词跨类污染严重,单层表会把
4326
+ // imagegen-frontend-* 误判成「编程开发」。先按 SKILL_CAT_MATCH_ORDER 整体扫强特征词
4327
+ // (跨类污染最小者优先),未命中再扫弱特征词,仍未命中落「未分类」——保证不丢条目。
4328
+ // 匹配对象 = name + ' ' + description 的小写串;首个命中即定类。关键词表允许实现方微调。
4329
+ const SKILL_CATS = [
4330
+ { key: 'all', name: t('全部'), color: 'var(--mcp-cat-all)' },
4331
+ { key: 'dev', name: t('编程开发'), color: 'var(--mcp-cat-dev)',
4332
+ strong: ['skill p8', 'arkts', 'arkui', '微信小程序', '小程序', '后端', '数据库', 'mysql', 'mariadb', 'api 设计', 'api设计', 'bug 诊断', 'bug诊断', '代码审查', '代码生成', '代码可读', '重构', '测试用例', '全栈', 'karpathy', 'spec 驱动', 'image-to-code', 'mcp server', 'sdk', 'monorepo', '源码'],
4333
+ weak: ['code', 'coding', 'developer', 'typescript', 'javascript', 'python', 'rust', 'sql', '编程', '开发'] },
4334
+ { key: 'design', name: t('设计与创作'), color: 'var(--mcp-cat-design)',
4335
+ strong: ['imagegen', '品牌', 'brandkit', 'logo', '海报', 'stitch', 'mockup', '图标', '插画', '视频生成', '视频编辑', '视频翻译', '素材分析', '内容生产', '内容合成', '设计稿', '设计系统', 'landing page', '配图', '设计'],
4336
+ weak: ['design', '创作', 'image', 'video', '视觉', '前端', 'frontend', '页面'] },
4337
+ { key: 'office', name: t('文档办公'), color: 'var(--mcp-cat-office)',
4338
+ strong: ['docx', 'xlsx', 'pptx', 'pdf', 'word 文档', 'excel', 'powerpoint', '文档', '公文', '报告', '表格', '幻灯', '公式缓存', 'libreoffice'],
4339
+ weak: ['office', '写作', 'markdown', '笔记'] },
4340
+ { key: 'auto', name: t('效率与自动化'), color: 'var(--mcp-cat-auto)',
4341
+ strong: ['browser', '浏览器', '定时', 'schedule', 'workflow', '工作流', '自动化', '批量', '表单填写', '数据提取', '提醒'],
4342
+ weak: ['自动', '效率', '抓取', '搜索'] },
4343
+ { key: 'ops', name: t('开发运维'), color: 'var(--mcp-cat-ops)',
4344
+ strong: ['sandbox', '沙箱', 'acl', '运维', '部署', 'deploy', 'release', '发布', '排查', '诊断', '环境', '崩溃', '代理下载', '签名', '供应链'],
4345
+ weak: ['debug', 'windows', '安装', '升级', '更新'] },
4346
+ ];
4347
+ const SKILL_UNCAT = { key: 'uncat', name: t('未分类'), color: 'var(--mcp-cat-uncat)' };
4348
+ // skillcat hash 值合法性校验用。
4349
+ const SKILL_CAT_KEYS = new Set(SKILL_CATS.map((c) => c.key));
4350
+ const skillCatByKey = (key) => SKILL_CATS.find((c) => c.key === key) || null;
4351
+ // 匹配顺序(强/弱两轮共用):设计与创作 → 文档办公 → 开发运维 → 效率与自动化 → 编程开发。
4352
+ const SKILL_CAT_MATCH_ORDER = ['design', 'office', 'ops', 'auto', 'dev'];
4353
+ const skillCategoryOf = (s) => {
4354
+ const hay = ((s.name || '') + ' ' + (s.description || '')).toLowerCase();
4355
+ for (const key of SKILL_CAT_MATCH_ORDER) {
4356
+ const cat = skillCatByKey(key);
4357
+ if (cat.strong.some((w) => hay.includes(w))) return cat;
4358
+ }
4359
+ for (const key of SKILL_CAT_MATCH_ORDER) {
4360
+ const cat = skillCatByKey(key);
4361
+ if (cat.weak.some((w) => hay.includes(w))) return cat;
4362
+ }
4363
+ return SKILL_UNCAT;
4364
+ };
4365
+
3974
4366
  // 「项目」语境下 cwd 决定项目根(.dsh/skills / .agents/skills 都从它往下找),
3975
4367
  // 全局语境传 undefined,让提供方只扫用户级与自定义目录——与官方 list() 语义一致。
3976
4368
  const skillScopeArg = (scope) => (scope === 'global' ? {} : { cwd: scope });
@@ -4370,8 +4762,17 @@ const SkillBoard = ({ call, scope, timer }) => {
4370
4762
  const [detail, setDetail] = useState(null);
4371
4763
  const [detailLoading, setDetailLoading] = useState(false);
4372
4764
  const [detailError, setDetailError] = useState('');
4765
+ // 分类升级(决策 3/4/7):分类单选 + 网格/列表双视图,全部记忆到 URL hash
4766
+ // (skillcat/skillview/skillpick),刷新与分享都能回到原处;'' = 默认态不写键。
4767
+ const [cat, setCat] = useState('');
4768
+ const [view, setView] = useState('');
4769
+ const [toast, setToast] = useState(null); // { msg, ok }
4373
4770
  const loadSeq = useRef(0);
4374
4771
  const detailSeq = useRef(0);
4772
+ const toastCancel = useRef(null);
4773
+ // 作用域切换检测(skillpick 只在真换作用域时清)+ skillpick 恢复只做一次的哨兵。
4774
+ const mountedScope = useRef(null);
4775
+ const seededPick = useRef(false);
4375
4776
 
4376
4777
  const load = useCallback(async (notify = false) => {
4377
4778
  const seq = ++loadSeq.current;
@@ -4398,6 +4799,15 @@ const SkillBoard = ({ call, scope, timer }) => {
4398
4799
  }, [call, scope]);
4399
4800
 
4400
4801
  useEffect(() => {
4802
+ // cat/view 从 hash 恢复(作用域切换时 hash 仍是唯一真值源,直接重读)。
4803
+ const seededCat = readHashParam('skillcat');
4804
+ setCat(SKILL_CAT_KEYS.has(seededCat) && seededCat !== 'all' ? seededCat : '');
4805
+ setView(readHashParam('skillview') === 'list' ? 'list' : '');
4806
+ // skillpick 只在真正换作用域时清(首次挂载不清——否则会把待恢复的选中态擦掉,
4807
+ // 挂载后的 seed 副作用就读不到了)。
4808
+ if (mountedScope.current !== null && mountedScope.current !== scope) writeHashParams({ skillpick: '' });
4809
+ mountedScope.current = scope;
4810
+ seededPick.current = false;
4401
4811
  setSelected('');
4402
4812
  setDetail(null);
4403
4813
  setDetailError('');
@@ -4406,6 +4816,21 @@ const SkillBoard = ({ call, scope, timer }) => {
4406
4816
  }, [load]);
4407
4817
 
4408
4818
  const queryKey = (query || '').toLowerCase();
4819
+ // 分类判定只依赖 name+description、与筛选无关 → 一次 memo 全量映射;
4820
+ // 分类轨道计数固定全量(决策 9),不随来源/权限/搜索联动。
4821
+ const catOf = useMemo(() => {
4822
+ const map = new Map();
4823
+ for (const s of skills) map.set(s.name, skillCategoryOf(s));
4824
+ return map;
4825
+ }, [skills]);
4826
+ const catCounts = useMemo(() => {
4827
+ const map = new Map([['all', skills.length]]);
4828
+ for (const s of skills) {
4829
+ const key = skillCategoryOf(s).key;
4830
+ map.set(key, (map.get(key) || 0) + 1);
4831
+ }
4832
+ return map;
4833
+ }, [skills]);
4409
4834
  // §3.1 过滤条。注意:宿主 skill 注册表**没有**启用/禁用开关(listSkills 只给
4410
4835
  // modelInvocable / userInvocable 这类能力位,没有 enable/disable 写入接口),
4411
4836
  // 所以这里不伪造一个「启用开关」——那会是个点了不生效的假控件。
@@ -4414,9 +4839,28 @@ const SkillBoard = ({ call, scope, timer }) => {
4414
4839
  if (onlyModel && !s.modelInvocable) return false;
4415
4840
  if (onlyUser && !s.userInvocable) return false;
4416
4841
  if (sourceFilter !== 'all' && (s.source || 'unknown') !== sourceFilter) return false;
4842
+ if (cat && (catOf.get(s.name) || SKILL_UNCAT).key !== cat) return false;
4417
4843
  if (queryKey && !(s.name + ' ' + (s.description || '')).toLowerCase().includes(queryKey)) return false;
4418
4844
  return true;
4419
4845
  });
4846
+ const activeCat = cat ? skillCatByKey(cat) : null;
4847
+ // 分类聚簇:网格与列表共用同一套簇序(决策 7:不按来源分组,改按分类聚簇,
4848
+ // 簇序 = SKILL_CATS 声明序,未分类垫底,簇内保持注册表顺序)。网格渲染成卡片簇,
4849
+ // 列表渲染成一个列表面板 + 分类分割线。选中具体分类时退化为单簇(无聚簇意义)。
4850
+ const clustered = useMemo(() => {
4851
+ // 选中具体分类时退化为单簇(网格无聚簇意义,列表也无需一条分割线)。
4852
+ if (activeCat) return [{ key: activeCat.key, cat: activeCat, rows: filtered }];
4853
+ const order = [...SKILL_CATS.filter((c) => c.key !== 'all').map((c) => c.key), SKILL_UNCAT.key];
4854
+ const buckets = new Map(order.map((k) => [k, []]));
4855
+ for (const s of filtered) {
4856
+ const key = (catOf.get(s.name) || SKILL_UNCAT).key;
4857
+ if (!buckets.has(key)) buckets.set(key, []);
4858
+ buckets.get(key).push(s);
4859
+ }
4860
+ return order
4861
+ .filter((k) => buckets.get(k).length)
4862
+ .map((k) => ({ key: k, cat: k === SKILL_UNCAT.key ? SKILL_UNCAT : skillCatByKey(k), rows: buckets.get(k) }));
4863
+ }, [filtered, catOf, view, activeCat]);
4420
4864
  // 来源统计一次算完(不要在每个 chip 里各 filter 一遍)。
4421
4865
  const sourceCounts = useMemo(() => {
4422
4866
  const map = new Map();
@@ -4426,19 +4870,12 @@ const SkillBoard = ({ call, scope, timer }) => {
4426
4870
  }
4427
4871
  return map;
4428
4872
  }, [skills]);
4429
- // 按来源分组展示(与官方 skill 子系统的发现优先级同序),组内保持注册表返回的名字排序。
4430
- const groups = useMemo(() => {
4431
- const bySource = new Map();
4432
- for (const s of filtered) {
4433
- const key = s.source || 'unknown';
4434
- if (!bySource.has(key)) bySource.set(key, []);
4435
- bySource.get(key).push(s);
4436
- }
4437
- return [...bySource.entries()].map(([source, rows]) => ({ source, rows }));
4438
- }, [filtered]);
4873
+
4439
4874
 
4440
4875
  const openDetail = useCallback(async (name) => {
4441
4876
  setSelected(name);
4877
+ // 选中态记忆到 hash(决策 4),刷新/分享可回到同一技能详情。
4878
+ writeHashParams({ skillpick: name });
4442
4879
  const seq = ++detailSeq.current;
4443
4880
  setDetail(null);
4444
4881
  setDetailError('');
@@ -4456,6 +4893,24 @@ const SkillBoard = ({ call, scope, timer }) => {
4456
4893
  }
4457
4894
  }, [call, scope]);
4458
4895
 
4896
+ // 关掉网格视图的详情弹窗:选中态、正文、报错、toast 一起清,并把 hash 里的 skillpick 撤回。
4897
+ // 只清 UI 不清 hash 的话,刷新/分享链接会把弹窗又弹回来,看起来像「关不掉」。
4898
+ const closeDetail = useCallback(() => {
4899
+ detailSeq.current += 1; // 作废在途的 skillDetail 回包,避免关掉后又被填回内容
4900
+ setSelected('');
4901
+ setDetail(null);
4902
+ setDetailError('');
4903
+ setDetailLoading(false);
4904
+ setToast(null);
4905
+ writeHashParams({ skillpick: '' });
4906
+ }, []);
4907
+
4908
+ // 弹窗要能被 Escape 关掉,前提是焦点在它里面。ref 回调在节点挂载时触发,
4909
+ // 比再挂一个 useEffect 更贴近「出现即聚焦」,也省掉一轮渲染。
4910
+ const focusModal = useCallback((el) => {
4911
+ if (el) el.focus();
4912
+ }, []);
4913
+
4459
4914
  // §4.2 接受跨分类定位:专家页的技能 chip 跳过来时,直接打开该技能的详情。
4460
4915
  // 两个来源都要接——订阅(已挂载时即时生效)与挂载时自领(从别的页签跳过来时,
4461
4916
  // 广播早已发完,只能靠模块里留下的 pending 目标)。
@@ -4471,15 +4926,123 @@ const SkillBoard = ({ call, scope, timer }) => {
4471
4926
  return off;
4472
4927
  }, [openDetail]);
4473
4928
 
4929
+ // skillpick 恢复(决策 4):列表就绪后按 hash 打开详情,只做一次;scope 切换时哨兵复位。
4930
+ useEffect(() => {
4931
+ if (seededPick.current || loading || !skills.length) return;
4932
+ seededPick.current = true;
4933
+ const pick = readHashParam('skillpick');
4934
+ if (pick && skills.some((s) => s.name === pick)) openDetail(pick);
4935
+ }, [loading, skills, openDetail]);
4936
+
4474
4937
  const summary = selected ? skills.find((s) => s.name === selected) : null;
4938
+ const summaryCat = summary ? (catOf.get(summary.name) || SKILL_UNCAT) : null;
4939
+ // 网格视图不再占右侧详情栏:有选中就浮成居中弹窗;列表视图仍走右侧栏(决策见 §12)。
4940
+ // 注意 view 的取值是 'list' | ''(网格是空串,见 setView(readHashParam('skillview') === 'list' ...)),
4941
+ // 所以判据必须是 !== 'list',写成 === 'grid' 会让弹窗永远不出现(卡片点了像没反应)。
4942
+ const isModal = view !== 'list' && !!summary;
4943
+
4944
+ // ── 状态记忆(决策 4):分类/视图/选中三态写 URL hash(skillcat/skillview/skillpick)──
4945
+ // 复用 readHashParam / writeHashParams,只改 hash,不动 path/search。
4946
+ const setCatHash = (key) => {
4947
+ const next = !key || key === 'all' ? '' : key;
4948
+ setCat(next);
4949
+ writeHashParams({ skillcat: next });
4950
+ };
4951
+ const setViewHash = (v) => {
4952
+ const next = v === 'list' ? 'list' : '';
4953
+ setView(next);
4954
+ writeHashParams({ skillview: next });
4955
+ };
4956
+
4957
+ // ── 详情操作(决策 4/11):复制走 copyText,反馈走局部 toast(约 2.6s 自动消失)────
4958
+ const showToast = useCallback((msg, ok) => {
4959
+ setToast({ msg, ok });
4960
+ if (toastCancel.current) toastCancel.current();
4961
+ // timer.timeout 的返回值是「取消函数」而不是 id(见 createTimer),直接持有它。
4962
+ toastCancel.current = timer.timeout(() => setToast(null), 2600);
4963
+ }, [timer]);
4964
+ useEffect(() => () => { if (toastCancel.current) toastCancel.current(); }, []);
4965
+ const copySkillMdPath = () => {
4966
+ const dir = String((summary && summary.path) || '').replace(/[\\/]+$/, '');
4967
+ if (!dir) return;
4968
+ copyText(dir + '/SKILL.md').then((ok) => showToast(ok ? t('已复制到剪贴板') : t('复制失败'), ok));
4969
+ };
4970
+ const copySessionRef = () => {
4971
+ if (!summary) return;
4972
+ copyText('@' + summary.name).then((ok) => showToast(ok ? t('已复制到剪贴板') : t('复制失败'), ok));
4973
+ };
4974
+
4975
+ // §1.4 空状态四件套:两种空况共用一份 JSX;「清除全部筛选」现在连同分类轨道一起清。
4976
+ const emptyBoard = skills.length === 0
4977
+ ? h(EmptyState, {
4978
+ icon: 'skill', title: t('这个作用域下没有发现 skill'),
4979
+ hint: t('skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现;放一个 SKILL.md 进去就会出现在这里。'),
4980
+ actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => load(true) }],
4981
+ tight: true,
4982
+ })
4983
+ : h(EmptyState, {
4984
+ icon: 'search', title: t('没有符合筛选条件的 skill'),
4985
+ hint: t('当前有 {0} 个 skill,但都被筛选条挡住了。', [skills.length]),
4986
+ actions: [{
4987
+ label: t('清除全部筛选'),
4988
+ icon: 'x',
4989
+ onClick: () => { setOnlyModel(false); setOnlyUser(false); setSourceFilter('all'); setCatHash(''); setQuery(''); },
4990
+ }],
4991
+ tight: true,
4992
+ });
4475
4993
 
4476
4994
  return h('div', { className: 'dsh-mcp-skill-view' },
4477
4995
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
4478
- available && !error ? h('div', { className: 'dsh-mcp-skill-body' },
4479
- h('div', { className: 'dsh-mcp-skill-side' },
4480
- h('div', { className: 'dsh-mcp-search' },
4481
- h(Icon, { d: ICONS.search }),
4482
- h('input', { placeholder: t('搜索 skill…'), value: query, onChange: (e) => setQuery(e.target.value), 'aria-label': t('搜索 skill') }),
4996
+ // list-mode / has-detail 只用于详情栏宽度分层:列表视图更宽,选中后最宽。
4997
+ available && !error ? h('div', {
4998
+ className: 'dsh-mcp-skill-body' + (view === 'list' ? ' list-mode' : '') + (summary ? ' has-detail' : ''),
4999
+ },
5000
+ h('div', { className: 'dsh-mcp-skill-content' },
5001
+ // 顶部工具条:搜索 + 网格/列表分段控件(决策 5:不加 Hero,直奔工作区)。
5002
+ h('div', { className: 'dsh-mcp-skill-toolbar' },
5003
+ h('div', { className: 'dsh-mcp-skill-search' },
5004
+ h(Icon, { d: ICONS.search }),
5005
+ h('input', { placeholder: t('搜索 skill…'), value: query, onChange: (e) => setQuery(e.target.value), 'aria-label': t('搜索 skill') }),
5006
+ ),
5007
+ h('div', { className: 'dsh-mcp-skill-seg', role: 'group', 'aria-label': t('视图切换') },
5008
+ h('button', {
5009
+ type: 'button', className: 'dsh-mcp-skill-seg-btn', 'aria-pressed': view !== 'list',
5010
+ title: t('网格视图'), onClick: () => setViewHash('grid'),
5011
+ }, h(Icon, { d: ICONS.grid, size: 13 }), t('网格视图')),
5012
+ h('button', {
5013
+ type: 'button', className: 'dsh-mcp-skill-seg-btn', 'aria-pressed': view === 'list',
5014
+ title: t('列表视图'), onClick: () => setViewHash('list'),
5015
+ }, h(Icon, { d: ICONS.list, size: 13 }), t('列表视图')),
5016
+ ),
5017
+ ),
5018
+ // 分类轨道(决策 5:轨道在搜索下、chips 上;计数固定全量,决策 9)。
5019
+ // 分类与来源/权限是正交维度——分类筛「它是什么」,来源/权限筛「它从哪来/谁能调用」。
5020
+ h('div', { className: 'dsh-mcp-skill-rail', role: 'tablist', 'aria-label': t('按分类过滤 skill') },
5021
+ SKILL_CATS.map((c) => h('button', {
5022
+ key: c.key,
5023
+ type: 'button',
5024
+ role: 'tab',
5025
+ 'aria-selected': (cat || 'all') === c.key,
5026
+ className: 'dsh-mcp-skill-cat' + ((cat || 'all') === c.key ? ' on' : ''),
5027
+ onClick: () => setCatHash(c.key),
5028
+ },
5029
+ h('span', { className: 'dsh-mcp-skill-cat-dot', style: { background: c.color } }),
5030
+ c.name,
5031
+ h('span', { className: 'dsh-mcp-skill-cat-count' }, catCounts.get(c.key) || 0),
5032
+ ))),
5033
+ // 结果行 + 权限/来源 chips + 只读提示:位置保持原样(决策 8)。
5034
+ h('div', { className: 'dsh-mcp-skill-result dsh-mcp-item-sub' },
5035
+ h('span', null, (loading && !refreshing) ? t('读取中…') : t('显示 {0} / {1} 个 skill', [filtered.length, skills.length])
5036
+ + (filtered.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
5037
+ refreshing ? h('span', null, t(' · 刷新中…')) : null,
5038
+ // OPT-31 · 5.1:把「只读」从代码注释提到界面上。宿主 skill 注册表只有
5039
+ // listSkills / skillDetail 两个读接口,没有启用/禁用/写入——这层语义以前只写在
5040
+ // 源码注释里,用户会反复找不到开关。明示之后「没有开关」变成预期而非缺陷。
5041
+ // 位置:筛选条之后、列表之前——读列表前先知道能做什么,比滚到底才发现划算。
5042
+ h('div', { className: 'dsh-mcp-readonly-note' },
5043
+ h(Icon, { d: ICONS.eye, size: 12 }),
5044
+ h('span', null, t('这个看板是只读的:宿主只提供 listSkills / skillDetail,没有启用/禁用或修改入口;要改内容请直接编辑 SKILL.md。')),
5045
+ ),
4483
5046
  ),
4484
5047
  h('div', { className: 'dsh-mcp-skill-filters' },
4485
5048
  h('button', {
@@ -4507,79 +5070,124 @@ const SkillBoard = ({ call, scope, timer }) => {
4507
5070
  }, t('清除来源筛选'))
4508
5071
  : null,
4509
5072
  ),
4510
- h('div', { className: 'dsh-mcp-item-sub' },
4511
- h('span', null, (loading && !refreshing) ? t('读取中…') : t('{0} 个 skill', [filtered.length])
4512
- + (filtered.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
4513
- refreshing ? h('span', null, t(' · 刷新中…')) : null,
4514
- // OPT-31 · 5.1:把「只读」从代码注释提到界面上。宿主 skill 注册表只有
4515
- // listSkills / skillDetail 两个读接口,没有启用/禁用/写入——这层语义以前只写在
4516
- // 源码注释里,用户会反复找不到开关。明示之后「没有开关」变成预期而非缺陷。
4517
- // 位置:筛选条之后、列表之前——读列表前先知道能做什么,比滚到底才发现划算。
4518
- h('div', { className: 'dsh-mcp-readonly-note' },
4519
- h(Icon, { d: ICONS.eye, size: 12 }),
4520
- h('span', null, t('这个看板是只读的:宿主只提供 listSkills / skillDetail,没有启用/禁用或修改入口;要改内容请直接编辑 SKILL.md。')),
4521
- ),
4522
- ),
4523
- h('div', { className: 'dsh-mcp-skill-list', role: 'listbox', 'aria-label': t('skill 列表') },
4524
- groups.length === 0 && !loading
4525
- // §1.4 空状态四件套:图标 + 一句发生了什么 + 一句怎么改 + 出口动作。
4526
- // 原来只有一段说明,用户不知道下一步该点哪里。
4527
- ? (skills.length === 0
4528
- ? h(EmptyState, {
4529
- icon: 'skill', title: t('这个作用域下没有发现 skill'),
4530
- hint: t('skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现;放一个 SKILL.md 进去就会出现在这里。'),
4531
- actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => load(true) }],
4532
- tight: true,
4533
- })
4534
- : h(EmptyState, {
4535
- icon: 'search', title: t('没有符合筛选条件的 skill'),
4536
- hint: t('当前有 {0} 个 skill,但都被筛选条挡住了。', [skills.length]),
4537
- actions: [{
4538
- label: t('清除全部筛选'),
4539
- icon: 'x',
4540
- onClick: () => { setOnlyModel(false); setOnlyUser(false); setSourceFilter('all'); setQuery(''); },
4541
- }],
4542
- tight: true,
4543
- }))
4544
- : groups.map((g) => h(React2.Fragment, { key: g.source },
4545
- h('div', { className: 'dsh-mcp-item-sub', style: { marginTop: '6px' } },
4546
- h(Badge, { cls: 't-stdio' }, skillSourceLabel(g.source)),
4547
- h('span', null, g.rows.length + t(' 个')),
4548
- ),
4549
- g.rows.map((s) => h('button', {
4550
- key: s.name,
4551
- type: 'button',
4552
- role: 'option',
4553
- 'aria-selected': selected === s.name,
4554
- className: 'dsh-mcp-skill',
4555
- onClick: () => openDetail(s.name),
4556
- },
4557
- h('div', { className: 'dsh-mcp-skill-main' },
4558
- h('div', { className: 'dsh-mcp-skill-name' }, s.name),
4559
- h('div', { className: 'dsh-mcp-skill-sub' },
4560
- s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
4561
- s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
4562
- // §3.1 来源固定在行内:原先只有分组头有来源,滚动到中途就不知道
4563
- // 当前这批 skill 来自哪里(尤其项目 / 用户 / 内置三个来源混排时)。
4564
- s.source ? h('span', { className: 'dsh-mcp-skill-src-badge', title: skillSourceLabel(s.source) }, skillSourceLabel(s.source)) : null,
4565
- s.provider ? h('span', { className: 'dsh-mcp-skill-src' }, s.provider) : null,
5073
+ // 主区画布:网格 = 分类聚簇双壳卡片;列表 = 一个列表面板 + 分类分割线。
5074
+ h('div', { className: 'dsh-mcp-skill-canvas' },
5075
+ filtered.length === 0 && !loading
5076
+ ? emptyBoard
5077
+ : (view === 'list'
5078
+ ? h('div', { className: 'dsh-mcp-skill-list', role: 'listbox', 'aria-label': t('skill 列表') },
5079
+ clustered.flatMap((cluster) => [
5080
+ // 分割线是普通行元素,不是独立列表容器:整块始终只有一个 listbox。
5081
+ !activeCat ? h('div', { key: cluster.key + ':head', className: 'dsh-mcp-skill-divider' },
5082
+ h('span', { className: 'dsh-mcp-skill-cat-tile', style: { background: cluster.cat.color } }),
5083
+ cluster.cat.name,
5084
+ h('span', { className: 'dsh-mcp-skill-divider-line' }),
5085
+ h('span', { className: 'dsh-mcp-skill-divider-count' }, cluster.rows.length),
5086
+ ) : null,
5087
+ ...cluster.rows.map((s) => h('button', {
5088
+ key: s.name,
5089
+ type: 'button',
5090
+ role: 'option',
5091
+ 'aria-selected': selected === s.name,
5092
+ className: 'dsh-mcp-skill' + (selected === s.name ? ' on' : ''),
5093
+ onClick: () => openDetail(s.name),
5094
+ },
5095
+ h('div', { className: 'dsh-mcp-skill-main' },
5096
+ h('div', { className: 'dsh-mcp-skill-name' }, s.name),
5097
+ h('div', { className: 'dsh-mcp-skill-sub' },
5098
+ s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
5099
+ s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
5100
+ // §3.1 来源固定在行内:不再有来源分组头,滚动到中途也要知道这条来自哪里。
5101
+ s.source ? h('span', { className: 'dsh-mcp-skill-src-badge', title: skillSourceLabel(s.source) }, skillSourceLabel(s.source)) : null,
5102
+ s.provider ? h('span', { className: 'dsh-mcp-skill-src' }, s.provider) : null,
5103
+ ),
5104
+ s.description ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description) : null,
5105
+ ),
5106
+ )),
5107
+ ].filter(Boolean)),
5108
+ )
5109
+ : clustered.map((cluster) => h('div', { key: cluster.key, className: 'dsh-mcp-skill-cluster' },
5110
+ !activeCat ? h('div', { className: 'dsh-mcp-item-sub' },
5111
+ h('span', { className: 'dsh-mcp-skill-cat-tile', style: { background: cluster.cat.color } }),
5112
+ cluster.cat.name,
5113
+ h('span', null, cluster.rows.length + t(' 个')),
5114
+ ) : null,
5115
+ h('div', { className: 'dsh-mcp-skill-grid' },
5116
+ cluster.rows.map((s) => {
5117
+ const sCat = catOf.get(s.name) || SKILL_UNCAT;
5118
+ return h('button', {
5119
+ key: s.name,
5120
+ type: 'button',
5121
+ 'aria-pressed': selected === s.name,
5122
+ className: 'dsh-mcp-skill-card' + (selected === s.name ? ' on' : ''),
5123
+ onClick: () => openDetail(s.name),
5124
+ },
5125
+ h('div', { className: 'dsh-mcp-skill-card-inner' },
5126
+ h('div', { className: 'dsh-mcp-skill-card-top' },
5127
+ h('span', { className: 'dsh-mcp-skill-cat-tile', style: { background: sCat.color }, title: sCat.name }),
5128
+ h('span', { className: 'dsh-mcp-skill-card-name' }, s.name),
5129
+ ),
5130
+ s.description
5131
+ ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description)
5132
+ : h('div', { className: 'dsh-mcp-skill-desc dsh-mcp-skill-desc-none' }, t('(无描述)')),
5133
+ h('div', { className: 'dsh-mcp-skill-card-foot' },
5134
+ h('span', { className: 'dsh-mcp-skill-cat-tag' }, sCat.name),
5135
+ h('span', { className: 'dsh-mcp-skill-sub' },
5136
+ s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
5137
+ s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
5138
+ ),
5139
+ ),
5140
+ ),
5141
+ );
5142
+ }),
4566
5143
  ),
4567
- s.description ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description) : null,
4568
- ),
4569
- )),
4570
- )),
5144
+ ))),
4571
5145
  ),
4572
5146
  ),
4573
- h('div', { className: 'dsh-mcp-skill-detail' },
5147
+ // 网格视图的详情以弹窗呈现(与市场卡片详情同一套观感),列表视图仍走右侧栏。
5148
+ // 遮罩单独做一层兄弟节点而不是包住弹窗:点遮罩即关,点弹窗内部不会冒泡到它。
5149
+ isModal
5150
+ ? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick: closeDetail })
5151
+ : null,
5152
+ h('div', {
5153
+ className: 'dsh-mcp-skill-detail' + (isModal ? ' is-modal' : ''),
5154
+ role: isModal ? 'dialog' : undefined,
5155
+ 'aria-modal': isModal ? true : undefined,
5156
+ 'aria-label': isModal ? t('技能详情') : undefined,
5157
+ tabIndex: isModal ? -1 : undefined,
5158
+ ref: isModal ? focusModal : undefined,
5159
+ onKeyDown: isModal
5160
+ ? (e) => {
5161
+ if (e.key === 'Escape') {
5162
+ e.stopPropagation();
5163
+ closeDetail();
5164
+ }
5165
+ }
5166
+ : undefined,
5167
+ },
5168
+ // 弹窗要有看得见的出口:市场那边的详情弹窗都带关闭按钮,这里保持一致。
5169
+ isModal
5170
+ ? h('button', {
5171
+ type: 'button',
5172
+ className: 'dsh-mcp-skill-d-close',
5173
+ title: t('关闭'),
5174
+ 'aria-label': t('关闭'),
5175
+ onClick: closeDetail,
5176
+ }, h(Icon, { d: ICONS.x, size: 14 }))
5177
+ : null,
4574
5178
  !summary && !detailLoading
4575
5179
  ? h('div', { className: 'dsh-mcp-skill-empty' }, t('从左侧选择一个 skill 查看详情'))
4576
5180
  : null,
4577
5181
  summary ? h('div', { className: 'dsh-mcp-skill-d-head' },
4578
5182
  h('div', null,
4579
5183
  h('h3', { className: 'dsh-mcp-skill-d-title' }, summary.name),
4580
- h('p', { className: 'dsh-mcp-skill-d-sub' },
4581
- skillSourceLabel(summary.source) + (summary.provider ? ' · ' + summary.provider : '')
4582
- + (summary.path ? ' · ' + summary.path : '')),
5184
+ h('div', { className: 'dsh-mcp-skill-d-badges' },
5185
+ summaryCat ? h(Badge, { cls: 't-stdio' }, summaryCat.name) : null,
5186
+ summary.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
5187
+ summary.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
5188
+ ),
5189
+ h('p', { className: 'dsh-mcp-skill-d-src' },
5190
+ skillSourceLabel(summary.source) + (summary.provider ? ' · ' + summary.provider : '')),
4583
5191
  ),
4584
5192
  summary.path ? h('button', {
4585
5193
  type: 'button',
@@ -4598,10 +5206,22 @@ const SkillBoard = ({ call, scope, timer }) => {
4598
5206
  h('p', { className: 'dsh-mcp-skill-d-desc' }, summary.whenToUse),
4599
5207
  ) : null,
4600
5208
  h('div', { className: 'dsh-mcp-skill-d-sec' },
4601
- h('b', null, t('调用策略')),
4602
- h('p', { className: 'dsh-mcp-skill-d-desc' },
4603
- t('模型可调用:') + (summary.modelInvocable ? t('是') : t('否'))
4604
- + t(' 用户可调用:') + (summary.userInvocable ? t('是') : t('否'))),
5209
+ h('b', null, t('详情')),
5210
+ h('div', { className: 'dsh-mcp-skill-d-meta' },
5211
+ h('span', null, t('所属分类')),
5212
+ h('b', null, summaryCat ? summaryCat.name : '—'),
5213
+ h('span', null, t('来源目录')),
5214
+ h('b', null, skillSourceLabel(summary.source) + (summary.provider ? ' · ' + summary.provider : '')),
5215
+ summary.path ? [h('span', { key: 'pl' }, t('文件位置')), h('b', { key: 'pv' }, summary.path)] : null,
5216
+ ),
5217
+ ),
5218
+ h('div', { className: 'dsh-mcp-skill-d-actions' },
5219
+ summary.path ? h('button', {
5220
+ type: 'button', className: 'dsh-mcp-btn', onClick: copySkillMdPath,
5221
+ }, h(Icon, { d: ICONS.copy, size: 13 }), t('复制 SKILL.md 路径')) : null,
5222
+ h('button', {
5223
+ type: 'button', className: 'dsh-mcp-btn', onClick: copySessionRef,
5224
+ }, h(Icon, { d: ICONS.copy, size: 13 }), t('复制 @会话引用')),
4605
5225
  ),
4606
5226
  detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, t('读取 skill 内容…')) : null,
4607
5227
  detailError ? h('div', { className: 'dsh-mcp-d-err' }, detailError) : null,
@@ -4609,6 +5229,7 @@ const SkillBoard = ({ call, scope, timer }) => {
4609
5229
  h('b', null, t('指令内容(') + detail.content.length + t(' 字符)')),
4610
5230
  h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.content),
4611
5231
  ) : null,
5232
+ toast ? h('div', { className: 'dsh-mcp-skill-d-flash' + (toast.ok ? '' : ' err') }, toast.msg) : null,
4612
5233
  ) : null,
4613
5234
  ),
4614
5235
  ) : null,
@@ -4621,12 +5242,44 @@ const SkillBoard = ({ call, scope, timer }) => {
4621
5242
  // wb* RPC(浏览器不直连腾讯/SkillHub),失败文案保留 Host 给出的原因。
4622
5243
  // 已安装标记:技能按 wbSkillInstalled 的目录记录——只在进入面板时取一次,装/卸后局部刷新。
4623
5244
  const WB_MARKET_CONNECTOR_PAGE_SIZE = 60;
5245
+ // T02/R2:visible_in 标签筛选语义与 lib/wb-market.js 的 matchesVisibleIn 一致——
5246
+ // selected 为空 = 全过;行未标 = 不受该维度过滤;命中为「任一命中」(OR,UI 直观)。
5247
+ // 放这里而不是组件内:纯数据判断,避免每次渲染重建闭包。
5248
+ const connectorMatchesVisibleIn = (rowVisibleTags, selectedTags) => {
5249
+ if (!Array.isArray(selectedTags) || !selectedTags.length) return true;
5250
+ if (!Array.isArray(rowVisibleTags) || !rowVisibleTags.length) return true;
5251
+ return rowVisibleTags.some((tag) => selectedTags.includes(tag));
5252
+ };
4624
5253
  const WB_SKILLHUB_SORTS = [
4625
5254
  { key: 'downloads', label: t('最多下载') },
4626
5255
  { key: 'installs', label: t('最多安装') },
4627
5256
  { key: 'stars', label: t('最多星标') },
4628
5257
  { key: 'score', label: t('综合评分') },
4629
5258
  ];
5259
+ // T05/R10:连接器排序(全量本地排序,不受分页影响)。
5260
+ // 目录自带 skillCount(捆绑技能数)与 visibleIn(适用环境标签数),够做有意义的排序;
5261
+ // upstream 无下载量/评分字段,不臆造。
5262
+ const WB_CONNECTOR_SORTS = [
5263
+ { key: 'default', label: t('目录默认顺序') },
5264
+ { key: 'skills', label: t('技能数由多到少') },
5265
+ { key: 'name', label: t('名称 A→Z') },
5266
+ ];
5267
+ // T06/Awesome:DSH 社区目录排序(全量本地排序,不受分页影响)。
5268
+ // upstream 自带 stars 与 lastVerified,无下载量/评分字段,不臆造。
5269
+ const AWESOME_SORTS = [
5270
+ { key: 'stars', label: t('最多星标') },
5271
+ { key: 'recent', label: t('最新收录') },
5272
+ { key: 'name', label: t('名称 A→Z') },
5273
+ ];
5274
+ const AWESOME_CATEGORIES = [
5275
+ { key: 'all', label: t('全部') },
5276
+ { key: 'plugin', label: t('插件') },
5277
+ { key: 'skill', label: t('技能') },
5278
+ { key: 'bundle', label: t('套件') },
5279
+ { key: 'tool', label: t('工具') },
5280
+ ];
5281
+ const AWESOME_TAGS = ['ui', 'terminal', 'agents', 'memory', 'models', 'vision', 'capabilities', 'channels', 'notifications', 'usage', 'observability', 'marketplace', 'devtools', 'safety', 'knowledge', 'interop', 'fun'];
5282
+ const AWESOME_PAGE_SIZE = 50;
4630
5283
 
4631
5284
  // ── 专家(WorkBuddy 专家中心直连)────────────────────────────────────
4632
5285
  // 两个页面一个数据源:ExpertInstalledBoard = 看板「专家」分类(管理**已召唤**的专家:
@@ -5830,7 +6483,7 @@ const WbSearchBox = ({ value, onChange, placeholder, disabled, onCommit }) =>
5830
6483
  }, h(Icon, { d: ICONS.x, size: 12 }))
5831
6484
  : null,
5832
6485
  );
5833
- const WorkBuddyMarket = ({ call }) => {
6486
+ const WorkBuddyMarket = ({ call, pluginManager }) => {
5834
6487
  // ── 共享状态 ──
5835
6488
  const [tab, setTab] = useState('connector'); // connector | skill
5836
6489
  const [loading, setLoading] = useState(true);
@@ -5843,6 +6496,10 @@ const WorkBuddyMarket = ({ call }) => {
5843
6496
  const [connectors, setConnectors] = useState([]);
5844
6497
  const [connectorQuery, setConnectorQuery] = useState('');
5845
6498
  const [connectorOnlyReady, setConnectorOnlyReady] = useState(true);
6499
+ // T04/R8:已安装筛选(全量本地筛选,不受分页影响——与技能侧「仅本页」不同)。
6500
+ const [connectorOnlyInstalled, setConnectorOnlyInstalled] = useState(false);
6501
+ // T05/R10:连接器排序键(全量本地排序)。
6502
+ const [connectorSort, setConnectorSort] = useState('default');
5846
6503
  const [connectorPage, setConnectorPage] = useState(1);
5847
6504
  const [connectorDetail, setConnectorDetail] = useState(null);
5848
6505
  // ── 技能 ──
@@ -5856,6 +6513,19 @@ const WorkBuddyMarket = ({ call }) => {
5856
6513
  const [installedSkills, setInstalledSkills] = useState(new Set());
5857
6514
  // §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
5858
6515
  // 所以这层过滤只能作用于**当前页**——必须在标签上写清,否则会被误读成全库筛选。
6516
+ // T04/R7:令牌弹窗。连接器 mcp.json 里带 ${...} 占位符时要先让用户填值——
6517
+ // 面板只做「用户明确输入」的透传,绝不代填、绝不猜值(X9 边界)。
6518
+ // tokenDialog = { item, values: Record<占位符名, string> } | null
6519
+ const [tokenDialog, setTokenDialog] = useState(null);
6520
+ // R8:OAuth 授权对话框。连接器探测到 401 不再报错,改为弹这里引导浏览器授权。
6521
+ // authDialog = { item, serverName, sessionId, authorizeUrl, mode, redirectUri, hint,
6522
+ // status, error, callbackUrl, manualToken } | null
6523
+ const [authDialog, setAuthDialog] = useState(null);
6524
+ const authDialogRef = useRef(null);
6525
+ authDialogRef.current = authDialog;
6526
+ const authPollRef = useRef(null);
6527
+ const authDialogRefNode = useRef(null);
6528
+ useFocusTrap(authDialogRefNode, !!authDialog);
5859
6529
  const [skillOnlyInstalled, setSkillOnlyInstalled] = useState(false);
5860
6530
  // OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
5861
6531
  const connectorDetailRef = useRef(null);
@@ -5869,6 +6539,22 @@ const WorkBuddyMarket = ({ call }) => {
5869
6539
  if (res?.ok) setInstalledSkills(new Set((res.installed || []).map((row) => row.slug)));
5870
6540
  }, [call]);
5871
6541
 
6542
+ // T04/R8:安装状态账本(三类安装的统一登记,宿主 ~/.dsh/armory/install-state.json)。
6543
+ // 与技能侧的 installedSkills 不同:这是**全量**集合,不受分页影响。
6544
+ const [installRecords, setInstallRecords] = useState([]);
6545
+ const loadInstallState = useCallback(async () => {
6546
+ try {
6547
+ const res = await call('wbInstallState');
6548
+ if (res?.ok) setInstallRecords(Array.isArray(res.items) ? res.items : []);
6549
+ } catch {
6550
+ // 安装状态是辅助信息,读失败不打断市场面板主流程。
6551
+ setInstallRecords([]);
6552
+ }
6553
+ }, [call]);
6554
+ const installedKeySet = (kind) => new Set(
6555
+ installRecords.filter((row) => row && row.kind === kind).map((row) => String(row.key || '')),
6556
+ );
6557
+
5872
6558
  const loadConnectors = useCallback(async () => {
5873
6559
  setLoading(true); setError(''); setNote('');
5874
6560
  try {
@@ -5900,7 +6586,8 @@ const WorkBuddyMarket = ({ call }) => {
5900
6586
  useEffect(() => {
5901
6587
  loadConnectors();
5902
6588
  loadInstalled();
5903
- }, [loadConnectors, loadInstalled]);
6589
+ loadInstallState();
6590
+ }, [loadConnectors, loadInstalled, loadInstallState]);
5904
6591
  useEffect(() => {
5905
6592
  loadSkills(skillPage, skillSort, skillKeyword);
5906
6593
  }, [loadSkills, skillPage, skillSort, skillKeyword]);
@@ -5919,33 +6606,206 @@ const WorkBuddyMarket = ({ call }) => {
5919
6606
 
5920
6607
  // ── 连接器:过滤 / 分页 ──
5921
6608
  const connectorQueryKey = connectorQuery.trim().toLowerCase();
6609
+ // T02/R2:visible_in 标签本地筛选。全集取自目录实际出现的标签(不发明筛选维度);
6610
+ // 上游没标 visible_in 的行不受标签筛选(matchesVisibleIn 的空集语义)。
6611
+ const [visibleTagFilter, setVisibleTagFilter] = useState([]);
6612
+ const connectorVisibleTagUniverse = useMemo(() => {
6613
+ const counts = new Map();
6614
+ for (const item of connectors) {
6615
+ for (const tag of item.visibleIn || []) counts.set(tag, (counts.get(tag) || 0) + 1);
6616
+ }
6617
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([tag, count]) => ({ tag, count }));
6618
+ }, [connectors]);
6619
+ // T04/R8:已安装集合按 kind 取(全量,不受分页影响)。
6620
+ const installedConnectorKeys = installedKeySet('connector');
6621
+ const installedPluginKeys = installedKeySet('plugin');
5922
6622
  const filteredConnectors = connectors.filter((item) => {
6623
+ // T04/R8:只看已安装——用 item.id 匹配账本 key(安装时 key 就是 connectorId)。
6624
+ if (connectorOnlyInstalled && !installedConnectorKeys.has(String(item.id || ''))) return false;
5923
6625
  if (connectorOnlyReady && !item.installable) return false;
6626
+ if (!connectorMatchesVisibleIn(item.visibleIn, visibleTagFilter)) return false;
5924
6627
  if (!connectorQueryKey) return true;
5925
6628
  return [item.name, item.nameEn, item.id, item.description].filter(Boolean).join(' ').toLowerCase().includes(connectorQueryKey);
5926
6629
  });
5927
- const connectorPageCount = Math.max(1, Math.ceil(filteredConnectors.length / WB_MARKET_CONNECTOR_PAGE_SIZE));
6630
+ // T05/R10:排序在过滤之后、分页之前做——保证全量排序,翻页不会打乱顺序。
6631
+ const sortedConnectors = connectorSort === 'default' ? filteredConnectors : (() => {
6632
+ const rows = [...filteredConnectors];
6633
+ if (connectorSort === 'skills') {
6634
+ rows.sort((a, b) => (b.skillCount || 0) - (a.skillCount || 0) || String(a.name || '').localeCompare(String(b.name || '')));
6635
+ } else if (connectorSort === 'name') {
6636
+ rows.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
6637
+ }
6638
+ return rows;
6639
+ })();
6640
+ const connectorPageCount = Math.max(1, Math.ceil(sortedConnectors.length / WB_MARKET_CONNECTOR_PAGE_SIZE));
5928
6641
  const safeConnectorPage = Math.min(connectorPage, connectorPageCount);
5929
- const visibleConnectors = filteredConnectors.slice((safeConnectorPage - 1) * WB_MARKET_CONNECTOR_PAGE_SIZE, safeConnectorPage * WB_MARKET_CONNECTOR_PAGE_SIZE);
6642
+ const visibleConnectors = sortedConnectors.slice((safeConnectorPage - 1) * WB_MARKET_CONNECTOR_PAGE_SIZE, safeConnectorPage * WB_MARKET_CONNECTOR_PAGE_SIZE);
5930
6643
 
5931
6644
  const openConnectorDetail = async (item) => {
5932
6645
  setConnectorDetail(item);
5933
6646
  setNote('');
5934
6647
  };
5935
- const installConnector = async (item) => {
6648
+ // ── R8:连接器 OAuth 授权 ────────────────────────────────────────────────
6649
+ // 宿主探测到 401 会回传 needsAuth,这里把它变成「打开浏览器 → 等回调 → 自动重装」。
6650
+ // 服务器只认自定义 scheme 回调(如 workbuddy://)时浏览器跳不过去,就走
6651
+ // manual-callback:让用户把地址栏里那条 URL 粘回来,等价于把 code 交给宿主。
6652
+ const stopAuthPoll = () => {
6653
+ if (authPollRef.current) {
6654
+ clearTimeout(authPollRef.current);
6655
+ authPollRef.current = null;
6656
+ }
6657
+ };
6658
+ const closeAuthDialog = async () => {
6659
+ stopAuthPoll();
6660
+ const sessionId = authDialogRef.current?.sessionId;
6661
+ setAuthDialog(null);
6662
+ if (sessionId) {
6663
+ try { await call('wbOAuthCancel', { sessionId }); } catch { /* 会话已结束,忽略 */ }
6664
+ }
6665
+ };
6666
+ const finishConnectorAuth = async (item) => {
6667
+ stopAuthPoll();
6668
+ setAuthDialog(null);
6669
+ if (!item) return;
6670
+ // 授权成功后立刻重装:宿主会把已存储的 token 注进 headers 再探测,这一步不会再 401。
6671
+ setNote(t('{0}:授权成功,正在完成安装…', [item.name]));
6672
+ await installConnector(item);
6673
+ };
6674
+ const pollAuthOnce = async (sessionId) => {
6675
+ let res;
6676
+ try {
6677
+ res = await call('wbOAuthPoll', { sessionId });
6678
+ } catch {
6679
+ res = null;
6680
+ }
6681
+ // 用户中途关掉弹窗/换了会话,就别再把状态写回界面。
6682
+ if (authDialogRef.current?.sessionId !== sessionId) return;
6683
+ if (res?.status === 'authorized') {
6684
+ await finishConnectorAuth(authDialogRef.current?.item);
6685
+ return;
6686
+ }
6687
+ if (res?.status === 'failed') {
6688
+ stopAuthPoll();
6689
+ setAuthDialog((cur) => (cur && cur.sessionId === sessionId
6690
+ ? { ...cur, status: 'failed', error: res.error || t('授权失败') }
6691
+ : cur));
6692
+ return;
6693
+ }
6694
+ authPollRef.current = setTimeout(() => { pollAuthOnce(sessionId); }, 1500);
6695
+ };
6696
+ const beginConnectorAuth = async (item, serverName) => {
6697
+ setError('');
6698
+ setBusy(true); setBusyLabel(t('正在启动授权…'));
6699
+ try {
6700
+ const res = await call('wbOAuthBegin', { connectorId: item.id, serverName });
6701
+ if (!res?.ok) throw new Error(res?.error || t('无法启动授权流程'));
6702
+ setAuthDialog({
6703
+ item,
6704
+ serverName: res.serverName || serverName || item.name,
6705
+ sessionId: res.sessionId,
6706
+ authorizeUrl: res.authorizeUrl,
6707
+ mode: res.mode,
6708
+ redirectUri: res.redirectUri,
6709
+ hint: res.hint || '',
6710
+ status: 'pending',
6711
+ error: res.opened ? '' : t('没能自动唤起浏览器,请复制下方链接手动打开。'),
6712
+ callbackUrl: '',
6713
+ manualToken: '',
6714
+ });
6715
+ if (res.sessionId) {
6716
+ stopAuthPoll();
6717
+ authPollRef.current = setTimeout(() => { pollAuthOnce(res.sessionId); }, 1200);
6718
+ }
6719
+ } catch (e) {
6720
+ setError(String(e?.message || e));
6721
+ } finally {
6722
+ setBusy(false); setBusyLabel('');
6723
+ }
6724
+ };
6725
+ const submitAuthCallback = async () => {
6726
+ const current = authDialogRef.current;
6727
+ if (!current) return;
6728
+ const url = String(current.callbackUrl || '').trim();
6729
+ if (!url) return;
6730
+ setBusy(true); setBusyLabel(t('正在校验回调…'));
6731
+ try {
6732
+ const res = await call('wbOAuthPoll', { sessionId: current.sessionId, callbackUrl: url });
6733
+ if (res?.status === 'authorized') {
6734
+ await finishConnectorAuth(current.item);
6735
+ return;
6736
+ }
6737
+ stopAuthPoll();
6738
+ setAuthDialog((cur) => (cur ? { ...cur, status: 'failed', error: res?.error || t('回调地址无效,请重新授权。') } : cur));
6739
+ } catch (e) {
6740
+ setAuthDialog((cur) => (cur ? { ...cur, status: 'failed', error: String(e?.message || e) } : cur));
6741
+ } finally {
6742
+ setBusy(false); setBusyLabel('');
6743
+ }
6744
+ };
6745
+ const submitAuthToken = async () => {
6746
+ const current = authDialogRef.current;
6747
+ if (!current) return;
6748
+ const token = String(current.manualToken || '').trim();
6749
+ if (!token) return;
6750
+ setBusy(true); setBusyLabel(t('正在保存令牌…'));
6751
+ try {
6752
+ const res = await call('wbOAuthToken', { connectorId: current.item.id, serverName: current.serverName, token });
6753
+ if (!res?.ok) throw new Error(res?.error || t('保存失败'));
6754
+ await finishConnectorAuth(current.item);
6755
+ } catch (e) {
6756
+ setAuthDialog((cur) => (cur ? { ...cur, error: String(e?.message || e) } : cur));
6757
+ } finally {
6758
+ setBusy(false); setBusyLabel('');
6759
+ }
6760
+ };
6761
+ useEffect(() => () => { stopAuthPoll(); }, []);
6762
+ const installConnector = async (item, tokens = null) => {
5936
6763
  setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.name])); setError('');
5937
6764
  try {
5938
- const res = await call('wbConnectorInstall', { connectorId: item.id, mode: 'merge' });
5939
- if (!res?.ok) throw new Error(res?.error || t('安装失败'));
5940
- setNote(t('{0}:{1}', [item.name, res.note || t('已写入 MCP 配置')]));
6765
+ // T04/R7:只把用户明确填写的槽位透传给宿主,未填的槽位由宿主保留原占位符
6766
+ // 并通过 missingSlots 回传——面板负责提示,绝不代填。
6767
+ const payload = { connectorId: item.id, mode: 'merge' };
6768
+ if (tokens && typeof tokens === 'object' && Object.keys(tokens).length) payload.tokens = tokens;
6769
+ const res = await call('wbConnectorInstall', payload);
6770
+ if (!res?.ok) {
6771
+ // R8:401 不再当作安装失败。宿主回传 needsAuth 时弹授权框,授权完成后
6772
+ // finishConnectorAuth 会重新调用本函数,此时 token 已落盘可径直装上。
6773
+ const needsAuth = Array.isArray(res?.needsAuth) ? res.needsAuth : [];
6774
+ if (needsAuth.length) {
6775
+ setTokenDialog(null);
6776
+ stopAuthPoll();
6777
+ await beginConnectorAuth(item, needsAuth[0]?.serverName);
6778
+ return;
6779
+ }
6780
+ throw new Error(res?.error || t('安装失败'));
6781
+ }
6782
+ const missing = Array.isArray(res?.missingSlots) ? res.missingSlots : [];
6783
+ const baseNote = t('{0}:{1}', [item.name, res.note || t('已写入 MCP 配置')]);
6784
+ setNote(missing.length
6785
+ ? baseNote + ' ' + t('另有 {0} 个占位符未填写,配置里保留原样,可用前请补全。', [missing.length])
6786
+ : baseNote);
6787
+ setTokenDialog(null);
5941
6788
  setConnectorDetail(null);
5942
6789
  await loadConnectors();
6790
+ await loadInstallState();
5943
6791
  } catch (e) {
5944
6792
  setError(String(e?.message || e));
5945
6793
  } finally {
5946
6794
  setBusy(false); setBusyLabel('');
5947
6795
  }
5948
6796
  };
6797
+ // T04/R7:安装入口。目录条目声明了 needsTokens 就先弹窗收令牌,否则直接装。
6798
+ // R8:needsOAuth 的连接器也走同一条路——宿主已存 token 就直接装,没存会回传
6799
+ // needsAuth 并把流程转到授权弹窗,所以这里不需要额外分支。
6800
+ const requestInstallConnector = (item) => {
6801
+ if (!item) return;
6802
+ if (item.needsTokens) {
6803
+ setError('');
6804
+ setTokenDialog({ item, values: {} });
6805
+ return;
6806
+ }
6807
+ installConnector(item);
6808
+ };
5949
6809
  const installSkill = async (skill) => {
5950
6810
  setBusy(true); setBusyLabel(t('正在下载并校验 {0}…', [skill.name])); setError('');
5951
6811
  try {
@@ -5954,6 +6814,7 @@ const WorkBuddyMarket = ({ call }) => {
5954
6814
  setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
5955
6815
  setSkillDetail(null);
5956
6816
  await loadInstalled();
6817
+ await loadInstallState();
5957
6818
  } catch (e) {
5958
6819
  setError(String(e?.message || e));
5959
6820
  } finally {
@@ -5968,6 +6829,7 @@ const WorkBuddyMarket = ({ call }) => {
5968
6829
  setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
5969
6830
  setSkillDetail(null);
5970
6831
  await loadInstalled();
6832
+ await loadInstallState();
5971
6833
  } catch (e) {
5972
6834
  setError(String(e?.message || e));
5973
6835
  } finally {
@@ -6023,6 +6885,214 @@ const WorkBuddyMarket = ({ call }) => {
6023
6885
  return [...counts.entries()].map(([key, entry]) => ({ key, ...entry }));
6024
6886
  }, [builtins]);
6025
6887
  const builtinQueryKey = builtinQuery.trim().toLowerCase();
6888
+ // ── T03/R5:codebuddy 插件市场 ──
6889
+ // 目录一次全量(官方 zip 里的 external_plugins 清单,无分页概念),本地过滤。
6890
+ // 安装走官方 pluginManager.installBundle(spec, {}) 严格双参(少传第二个会被 wire 拒绝,
6891
+ // 见本文件 runUpgrade 的踩坑注释);装完宿主进程不热载新包,必须重启 DSH 才生效——
6892
+ // 提示条 state 常驻(不写 localStorage,本次会话内不消失)。
6893
+ const [plugins, setPlugins] = useState([]);
6894
+ const [pluginsLoading, setPluginsLoading] = useState(false);
6895
+ const [pluginsError, setPluginsError] = useState('');
6896
+ const [pluginQuery, setPluginQuery] = useState('');
6897
+ // T04/R8:插件「只看已安装」(全量本地筛选)。
6898
+ const [pluginOnlyInstalled, setPluginOnlyInstalled] = useState(false);
6899
+ const [pluginDetail, setPluginDetail] = useState(null);
6900
+ const [restartNotice, setRestartNotice] = useState('');
6901
+ // T05/R9:插件市场更新提示。**状态名不能叫 updateHint**——那个名字已被本文件
6902
+ // L3288 的 dsh-armory 自身版本更新提示占用。只按 reason 分支,不读 error 正文。
6903
+ const [pluginUpdateHint, setPluginUpdateHint] = useState(null);
6904
+ const pluginDetailRef = useRef(null);
6905
+ useFocusTrap(pluginDetailRef, !!pluginDetail);
6906
+ const loadPlugins = useCallback(async () => {
6907
+ setPluginsLoading(true); setPluginsError('');
6908
+ try {
6909
+ const res = await call('wbPluginCatalog');
6910
+ if (!res?.ok) throw new Error(res?.error || t('读取插件目录失败'));
6911
+ setPlugins(res.plugins || []);
6912
+ } catch (e) {
6913
+ setPluginsError(String(e?.message || e));
6914
+ } finally {
6915
+ setPluginsLoading(false);
6916
+ }
6917
+ }, [call]);
6918
+ // T05/R9:插件市场更新探测——**懒触发、只跑一次**(X4 边界:绝不常驻定时器)。
6919
+ // 只在「已进过 plugin tab 且目录已加载」时探一次;reason 分支见下方提示条。
6920
+ const checkPluginUpdate = useCallback(async () => {
6921
+ try {
6922
+ const res = await call('wbPluginUpdateCheck');
6923
+ setPluginUpdateHint(res || null);
6924
+ } catch (e) {
6925
+ // 探测失败不打扰用户(辅助信息),保留 null 即不渲染提示条。
6926
+ setPluginUpdateHint(null);
6927
+ }
6928
+ }, [call]);
6929
+ // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)状态机与数据接入。
6930
+ // pluginSource 持久化 localStorage(先例 UPDATE_HINT_STATE_KEY);默认 awesome(用户拍板 2026-10-20:插件页初始显示 DSH 社区;显式切过来源的用户按其持久化值)。
6931
+ // 来源切换控件在插件工具行(select 两态),写入 localStorage 持久化。
6932
+ // 注意:必须在下方两个依赖数组含 pluginSource 的 effect 之前声明——依赖数组在渲染期求值,
6933
+ // const 声明若在其后,渲染即抛 ReferenceError: Cannot access 'pluginSource' before initialization(TDZ)。
6934
+ const [pluginSource, setPluginSource] = useState(() => {
6935
+ try { return localStorage.getItem('dsh-armory.pluginSource') === 'codebuddy' ? 'codebuddy' : 'awesome'; } catch { return 'awesome'; }
6936
+ });
6937
+ const pluginUpdateChecked = useRef(false);
6938
+ useEffect(() => {
6939
+ if (tab === 'plugin' && pluginSource === 'codebuddy' && !plugins.length && !pluginsLoading) loadPlugins();
6940
+ }, [tab, pluginSource, plugins.length, pluginsLoading, loadPlugins]);
6941
+ useEffect(() => {
6942
+ // 只在目录加载完成后探一次;pluginUpdateChecked 保证组件存续期内不重复。
6943
+ if (tab !== 'plugin' || pluginSource !== 'codebuddy' || pluginsLoading || !plugins.length || pluginUpdateChecked.current) return;
6944
+ pluginUpdateChecked.current = true;
6945
+ checkPluginUpdate();
6946
+ }, [tab, pluginSource, pluginsLoading, plugins.length, checkPluginUpdate]);
6947
+ // 首屏秒开:首次挂载就把上次的快照铺上,用户进插件页立刻看到目录而不是一句「正在加载」。
6948
+ // 快照只在挂载时读一次(JSON.parse 一两 MB 不能每次 render 都干);真实目录随后覆盖。
6949
+ const awesomeSnapshotRef = useRef(undefined);
6950
+ if (awesomeSnapshotRef.current === undefined) awesomeSnapshotRef.current = readAwesomeSnapshot();
6951
+ const [awesomePlugins, setAwesomePlugins] = useState(() => {
6952
+ const snapshot = awesomeSnapshotRef.current;
6953
+ return snapshot ? snapshot.plugins.map((item) => ({ ...item, marketplace: snapshot.marketplace || 'awesome-dsh-plugins' })) : [];
6954
+ });
6955
+ const [awesomeLoading, setAwesomeLoading] = useState(false);
6956
+ const [awesomeError, setAwesomeError] = useState('');
6957
+ const [awesomeUpdateHint, setAwesomeUpdateHint] = useState(null);
6958
+ const [awesomeStale, setAwesomeStale] = useState(false);
6959
+ const [awesomeQuery, setAwesomeQuery] = useState('');
6960
+ const [awesomeCategory, setAwesomeCategory] = useState('all');
6961
+ const [awesomeTags, setAwesomeTags] = useState([]);
6962
+ const [awesomeSort, setAwesomeSort] = useState('stars');
6963
+ const [awesomePage, setAwesomePage] = useState(1);
6964
+ const [awesomeOnlyInstalled, setAwesomeOnlyInstalled] = useState(false);
6965
+ const loadAwesomeCatalog = useCallback(async (refresh = false) => {
6966
+ setAwesomeLoading(true); setAwesomeError('');
6967
+ try {
6968
+ const res = await call('wbAwesomeCatalog', refresh ? { refresh: true } : {});
6969
+ if (!res?.ok) throw new Error(res?.error || t('读取插件目录失败'));
6970
+ setAwesomeStale(Boolean(res.stale));
6971
+ // 响应级 marketplace 标记统一贴到每条记录上(「只看已安装」按 source.marketplace 归账)。
6972
+ const marketplace = res.marketplace || 'awesome-dsh-plugins';
6973
+ const catalog = (res.plugins || []).map((item) => ({ ...item, marketplace }));
6974
+ setAwesomePlugins(catalog);
6975
+ // 顺手把这次的目录留成下次的首屏快照(写失败静默,不影响本次渲染)。
6976
+ writeAwesomeSnapshot({ plugins: catalog, marketplace, fetchedAt: res.fetchedAt });
6977
+ } catch (e) {
6978
+ setAwesomeError(String(e?.message || e));
6979
+ } finally {
6980
+ setAwesomeLoading(false);
6981
+ }
6982
+ }, [call]);
6983
+ const checkAwesomeUpdate = useCallback(async () => {
6984
+ try {
6985
+ const res = await call('wbAwesomeUpdateCheck');
6986
+ setAwesomeUpdateHint(res || null);
6987
+ } catch {
6988
+ setAwesomeUpdateHint(null);
6989
+ }
6990
+ }, [call]);
6991
+ const awesomeUpdateChecked = useRef(false);
6992
+ // 自动加载改成「每次挂载只发一次」:旧条件是 `awesomeLoading || awesomePlugins.length` 就跳过,
6993
+ // 一旦首屏已经有快照,真实目录就永远不会再拉——快照便成了死数据,必须去掉那个门槛。
6994
+ const awesomeAutoLoaded = useRef(false);
6995
+ useEffect(() => {
6996
+ if (tab !== 'plugin' || pluginSource !== 'awesome' || awesomeAutoLoaded.current) return;
6997
+ awesomeAutoLoaded.current = true;
6998
+ loadAwesomeCatalog();
6999
+ }, [tab, pluginSource, loadAwesomeCatalog]);
7000
+ useEffect(() => {
7001
+ // 目录加载完成后探一次;awesomeUpdateChecked 保证组件存续期内不重复;已有提示不再探。
7002
+ if (tab !== 'plugin' || pluginSource !== 'awesome' || awesomeLoading || !awesomePlugins.length || awesomeError || awesomeUpdateHint) return;
7003
+ if (awesomeUpdateChecked.current) return;
7004
+ awesomeUpdateChecked.current = true;
7005
+ checkAwesomeUpdate();
7006
+ }, [tab, pluginSource, awesomeLoading, awesomePlugins.length, awesomeError, awesomeUpdateHint, checkAwesomeUpdate]);
7007
+ useEffect(() => { setAwesomePage(1); }, [awesomeQuery, awesomeCategory, awesomeTags, awesomeSort]);
7008
+ // UNCLEAR#1 的取值策略:plugin.json 的 repository 是安装 spec 的唯一可推导来源——
7009
+ // 1. git/https 的 GitHub 仓库 URL → `github:<owner>/<repo>#<ref>`(官方 installBundle
7010
+ // 的 pnpm add 语义支持该 spec);
7011
+ // 2. 其余字符串(裸 npm 名、含版本号等)原样当作 spec 交给宿主;
7012
+ // 3. 没有 repository → null = 不可推导 → UI 只展示、不渲染安装按钮(绝不猜名字)。
7013
+ const pluginInstallSpec = (plugin) => {
7014
+ // T05/R12:DSH 社区条目(awesome-dsh-plugins)的主安装路径是 npm 包名。
7015
+ // installBundle 直接受 npm:<name> 形态的 spec;没有 npm 的社区条目在 UI 层灰置,走不到这里。
7016
+ if (plugin?.npm) return `npm:${String(plugin.npm).trim()}`;
7017
+ const repo = String(plugin?.repository || '').trim();
7018
+ if (!repo) return null;
7019
+ const gitHub = repo.match(/^https?:\/\/github\.com\/([^/]+)\/([^/#?]+)(?:[/#?]|\b)/i);
7020
+ if (gitHub) {
7021
+ const ref = (repo.split('#')[1] || '').trim();
7022
+ return `github:${gitHub[1]}/${gitHub[2].replace(/\.git$/, '')}${ref ? '#' + ref : ''}`;
7023
+ }
7024
+ return repo;
7025
+ };
7026
+ const installPlugin = async (plugin) => {
7027
+ if (!pluginManager || typeof pluginManager[UPGRADE_METHOD] !== 'function') {
7028
+ setError(t('宿主未提供插件管理器(pluginManager),无法安装插件'));
7029
+ return;
7030
+ }
7031
+ const spec = pluginInstallSpec(plugin);
7032
+ if (!spec) { setError(t('该插件未提供可推导的安装来源(repository),暂不能安装')); return; }
7033
+ setBusy(true); setBusyLabel(t('正在安装插件 {0}…', [plugin.name])); setError('');
7034
+ // 顶部浮层与组件 state 并行记账:面板被卸载(切标签 / 切窗口)后浮层仍在,用户不会误以为被取消。
7035
+ installToastStart(plugin.name);
7036
+ try {
7037
+ // 严格双参:installBundle(spec, {})。少传第二个会被 wire 直接拒绝。
7038
+ const res = await pluginManager[UPGRADE_METHOD](spec, {});
7039
+ if (!res || res.ok !== true) throw new Error((res?.error && (res.error.message || res.error.code)) || t('调用失败'));
7040
+ const r = res.value || {};
7041
+ if (r.application === 'failed' || r.application === 'cancelled') {
7042
+ throw new Error((r.error && (r.error.message || r.error.code)) || r.error || t('插件安装失败(宿主安装器返回失败)'));
7043
+ }
7044
+ // T04/R8:插件由客户端回写登记(宿主不知道 pluginManager 装了什么)。
7045
+ // 登记失败不影响安装结果——只提示,不抛。
7046
+ try {
7047
+ const rec = await call('wbInstallStateRecord', {
7048
+ kind: 'plugin',
7049
+ key: plugin.name,
7050
+ name: plugin.name,
7051
+ version: plugin.version || '',
7052
+ source: { kind: 'marketplace', marketplace: plugin.marketplace || 'codebuddy-plugins-official' },
7053
+ });
7054
+ if (!rec?.ok) setNote(t('插件已安装,但安装状态登记失败:{0}', [rec?.error || t('未知错误')]));
7055
+ } catch (e) {
7056
+ setNote(t('插件已安装,但安装状态登记失败:{0}', [String(e?.message || e)]));
7057
+ }
7058
+ await loadInstallState();
7059
+ // 快照里若还有这条旧目录数据,标记已安装由 installedPluginKeys 负责,无需改动。
7060
+ setRestartNotice(t('插件 {0} 安装成功,需重启 DSH 宿主后生效。', [plugin.name]));
7061
+ setPluginDetail(null);
7062
+ installToastFinish(plugin.name, 'ok');
7063
+ } catch (e) {
7064
+ setError(String(e?.message || e));
7065
+ installToastFinish(plugin.name, 'fail');
7066
+ } finally {
7067
+ setBusy(false); setBusyLabel('');
7068
+ }
7069
+ };
7070
+ const pluginQueryKey = pluginQuery.trim().toLowerCase();
7071
+ const filteredPlugins = plugins.filter((item) => {
7072
+ // T04/R8:只看已安装——插件账本 key 是 plugin.name。
7073
+ if (pluginOnlyInstalled && !installedPluginKeys.has(String(item.name || ''))) return false;
7074
+ if (!pluginQueryKey) return true;
7075
+ return [item.name, item.description, item.author].filter(Boolean).join(' ').toLowerCase().includes(pluginQueryKey);
7076
+ });
7077
+ // T06/Awesome:社区目录筛选 / 排序 / 分页(全量本地,不受分页影响;tags 多选为「或」语义)。
7078
+ const awesomeQueryKey = awesomeQuery.trim().toLowerCase();
7079
+ const filteredAwesome = awesomePlugins.filter((item) => {
7080
+ if (awesomeOnlyInstalled && !installedPluginKeys.has(String(item.name || ''))) return false;
7081
+ if (awesomeCategory !== 'all' && (item.category || 'plugin') !== awesomeCategory) return false;
7082
+ if (awesomeTags.length && !(item.tags || []).some((tag) => awesomeTags.includes(tag))) return false;
7083
+ if (!awesomeQueryKey) return true;
7084
+ return [item.name, item.description, item.repo, item.npm].filter(Boolean).join(' ').toLowerCase().includes(awesomeQueryKey);
7085
+ });
7086
+ const sortedAwesome = (() => {
7087
+ const rows = [...filteredAwesome];
7088
+ if (awesomeSort === 'stars') rows.sort((a, b) => (Number(b.stars) || 0) - (Number(a.stars) || 0));
7089
+ else if (awesomeSort === 'recent') rows.sort((a, b) => String(b.lastVerified || '').localeCompare(String(a.lastVerified || '')));
7090
+ else if (awesomeSort === 'name') rows.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
7091
+ return rows;
7092
+ })();
7093
+ const awesomePageCount = Math.max(1, Math.ceil(sortedAwesome.length / AWESOME_PAGE_SIZE));
7094
+ const safeAwesomePage = Math.min(awesomePage, awesomePageCount);
7095
+ const visibleAwesome = sortedAwesome.slice((safeAwesomePage - 1) * AWESOME_PAGE_SIZE, safeAwesomePage * AWESOME_PAGE_SIZE);
6026
7096
  const filteredBuiltins = builtins.filter((item) => {
6027
7097
  if (builtinCategory !== 'all' && (item.category || 'other') !== builtinCategory) return false;
6028
7098
  if (builtinOnlyReady && !builtinReady(item)) return false;
@@ -6073,6 +7143,8 @@ const WorkBuddyMarket = ({ call }) => {
6073
7143
  { key: 'connector', label: t('连接器'), count: connectors.length || null },
6074
7144
  { key: 'builtin', label: t('内置 MCP'), count: builtins.length || null },
6075
7145
  { key: 'skill', label: t('技能'), count: null },
7146
+ // T03/R5:codebuddy 插件市场子页签。count 在目录加载后才有(未载时灰字入口,同内置 MCP)。
7147
+ { key: 'plugin', label: t('插件'), count: plugins.length || null },
6076
7148
  { key: 'expert', label: t('专家'), count: null },
6077
7149
  ];
6078
7150
  const marketTabLabel = (MARKET_TABS.find((row) => row.key === tab) || MARKET_TABS[0]).label;
@@ -6108,6 +7180,18 @@ const WorkBuddyMarket = ({ call }) => {
6108
7180
  h('label', { className: 'dsh-mcp-check', title: t('默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目') },
6109
7181
  h('input', { type: 'checkbox', checked: connectorOnlyReady, disabled: loading, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
6110
7182
  t('只看可直接安装')),
7183
+ // T04/R8:已安装筛选是全量本地筛选(跨页生效),不随分页变化。
7184
+ h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条目录里筛出已安装的连接器(跨页生效,与技能侧「仅本页」不同)', [connectors.length]) },
7185
+ h('input', { type: 'checkbox', checked: connectorOnlyInstalled, disabled: loading, onChange: (e) => { setConnectorOnlyInstalled(e.target.checked); setConnectorPage(1); } }),
7186
+ t('只看已安装')),
7187
+ // T05/R10:排序下拉(全量本地排序,翻页不打乱)。
7188
+ h('select', {
7189
+ className: 'dsh-mcp-input dsh-mcp-wb-sort', value: connectorSort, disabled: loading,
7190
+ onChange: (e) => { setConnectorSort(e.target.value); setConnectorPage(1); },
7191
+ }, WB_CONNECTOR_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
7192
+ connectorOnlyInstalled
7193
+ ? h('span', { className: 'dsh-mcp-builtin-count' }, t('已安装 {0} / {1}', [installedConnectorKeys.size, connectors.length]))
7194
+ : null,
6111
7195
  // 被过滤掉多少条要明说,否则用户会以为目录里就这么多。
6112
7196
  !connectorOnlyReady || loading ? null : (() => {
6113
7197
  const hidden = connectors.filter((row) => !row.installable).length;
@@ -6119,7 +7203,29 @@ const WorkBuddyMarket = ({ call }) => {
6119
7203
  h('span', { className: 'dsh-mcp-builtin-count' },
6120
7204
  t('显示 {0} / {1}', [visibleConnectors.length, filteredConnectors.length]) + (connectorSource === 'network' ? t(' · 已更新') : connectorSource === 'disk' ? t(' · 缓存') : '')),
6121
7205
  )
6122
- : tab === 'builtin'
7206
+ : null,
7207
+ // T02/R2:visible_in 标签筛选 chips(只在连接器 tab 且目录里确实出现标签时显示)。
7208
+ // 语义:未选任何标签 = 不过滤;选中后任一命中即保留(OR);没标 visible_in 的行不受影响。
7209
+ tab === 'connector' && connectorVisibleTagUniverse.length
7210
+ ? h('div', { className: 'dsh-mcp-wb-toolbar dsh-mcp-wb-tagbar' },
7211
+ h('span', { className: 'dsh-mcp-wb-tagbar-label' }, t('适用环境:')),
7212
+ connectorVisibleTagUniverse.map(({ tag, count }) => h('button', {
7213
+ key: tag,
7214
+ type: 'button',
7215
+ className: 'dsh-mcp-tag-chip' + (visibleTagFilter.includes(tag) ? ' on' : ''),
7216
+ 'aria-pressed': visibleTagFilter.includes(tag),
7217
+ title: t('{0} 个连接器适用', [count]),
7218
+ onClick: () => {
7219
+ setVisibleTagFilter((current) => (current.includes(tag) ? current.filter((row) => row !== tag) : [...current, tag]));
7220
+ setConnectorPage(1);
7221
+ },
7222
+ }, tag)),
7223
+ visibleTagFilter.length
7224
+ ? h('button', { type: 'button', className: 'dsh-mcp-tag-chip clear', onClick: () => { setVisibleTagFilter([]); setConnectorPage(1); } }, t('清除标签筛选'))
7225
+ : null,
7226
+ )
7227
+ : null,
7228
+ tab === 'builtin'
6123
7229
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
6124
7230
  h(WbSearchBox, {
6125
7231
  value: builtinQuery, placeholder: t('搜索服务、用途或提供方…'),
@@ -6135,9 +7241,39 @@ const WorkBuddyMarket = ({ call }) => {
6135
7241
  builtinCategories.map((cat) => h('option', { key: cat.key, value: cat.key }, cat.label + t('(') + cat.count + t(')')))),
6136
7242
  h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [filteredBuiltins.length, builtins.length])),
6137
7243
  )
7244
+ : tab === 'plugin'
7245
+ ? h('div', { className: 'dsh-mcp-wb-toolbar' },
7246
+ h('select', { className: 'dsh-mcp-wb-sort', value: pluginSource, onChange: (e) => { const next = e.target.value === 'awesome' ? 'awesome' : 'codebuddy'; setPluginSource(next); try { localStorage.setItem('dsh-armory.pluginSource', next); } catch { /* localStorage 不可用时仅本会话生效 */ } } },
7247
+ h('option', { value: 'codebuddy' }, t('CodeBuddy 官方')),
7248
+ h('option', { value: 'awesome' }, t('DSH 社区')),
7249
+ ),
7250
+ ...(pluginSource === 'codebuddy' ? [
7251
+ h(WbSearchBox, {
7252
+ value: pluginQuery, placeholder: t('搜索插件(名称 / 描述 / 作者)'),
7253
+ onChange: (value) => setPluginQuery(value),
7254
+ }),
7255
+ // T04/R8:插件的已安装筛选同样是全量本地筛选。
7256
+ h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条插件里筛出已安装的(跨页生效)', [plugins.length]) },
7257
+ h('input', { type: 'checkbox', checked: pluginOnlyInstalled, disabled: pluginsLoading, onChange: (e) => setPluginOnlyInstalled(e.target.checked) }),
7258
+ t('只看已安装')),
7259
+ pluginOnlyInstalled
7260
+ ? h('span', { className: 'dsh-mcp-builtin-count' }, t('已安装 {0} / {1}', [installedPluginKeys.size, plugins.length]))
7261
+ : null,
7262
+ h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [filteredPlugins.length, plugins.length])),
7263
+ ] : []),
7264
+ ...(pluginSource === 'awesome' ? [
7265
+ h(WbSearchBox, { value: awesomeQuery, placeholder: t('搜索插件(名称 / 描述 / 作者)'), onChange: (value) => setAwesomeQuery(value) }),
7266
+ ...AWESOME_CATEGORIES.map((cat) => h('button', { key: cat.key, type: 'button', className: 'dsh-mcp-tag-chip' + (awesomeCategory === cat.key ? ' on' : ''), 'aria-pressed': awesomeCategory === cat.key, onClick: () => setAwesomeCategory(cat.key) }, cat.label)),
7267
+ ...AWESOME_TAGS.map((tag) => h('button', { key: tag, type: 'button', className: 'dsh-mcp-tag-chip' + (awesomeTags.includes(tag) ? ' on' : ''), 'aria-pressed': awesomeTags.includes(tag), onClick: () => setAwesomeTags(awesomeTags.includes(tag) ? awesomeTags.filter((row) => row !== tag) : [...awesomeTags, tag]) }, tag)),
7268
+ h('select', { className: 'dsh-mcp-wb-sort', value: awesomeSort, onChange: (e) => setAwesomeSort(e.target.value) }, AWESOME_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
7269
+ h('label', { className: 'dsh-mcp-check', title: t('在全量 {0} 条插件里筛出已安装的(跨页生效)', [awesomePlugins.length]) }, h('input', { type: 'checkbox', checked: awesomeOnlyInstalled, onChange: (e) => setAwesomeOnlyInstalled(e.target.checked) }), t('只看已安装')),
7270
+ h('span', { className: 'dsh-mcp-builtin-count' }, t('显示 {0} / {1}', [visibleAwesome.length, filteredAwesome.length])),
7271
+ ] : []),
7272
+ )
6138
7273
  : tab === 'expert'
6139
7274
  ? null
6140
- : h('div', { className: 'dsh-mcp-wb-toolbar' },
7275
+ : tab === 'skill'
7276
+ ? h('div', { className: 'dsh-mcp-wb-toolbar' },
6141
7277
  skillSearch,
6142
7278
  h('select', {
6143
7279
  className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: loading,
@@ -6150,7 +7286,8 @@ const WorkBuddyMarket = ({ call }) => {
6150
7286
  ? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
6151
7287
  : null,
6152
7288
  h('span', { className: 'dsh-mcp-builtin-count' }, skillMeta),
6153
- ),
7289
+ )
7290
+ : null,
6154
7291
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
6155
7292
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
6156
7293
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
@@ -6211,18 +7348,32 @@ const WorkBuddyMarket = ({ call }) => {
6211
7348
  item.installable ? null : h('span', { className: 'dsh-mcp-mk-corner', title: item.blockedReason || t('不兼容') }, t('不兼容')),
6212
7349
  h('div', { className: 'dsh-mcp-card-top' },
6213
7350
  h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
6214
- h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
7351
+ // T04/R8:已安装的条目优先显示「已安装」,不再提示可安装。
7352
+ installedConnectorKeys.has(String(item.id || ''))
7353
+ ? h(Badge, { cls: 's-on' }, t('已安装'))
7354
+ : h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
6215
7355
  ),
6216
- h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.nameEn || item.id),
7356
+ h('div', { className: 'dsh-mcp-card-summary' },
7357
+ // T02/R3:examples 优先(displayBody 由 host 侧 displayCopyOf 产出),
7358
+ // description 只作兜底;都无时回落 nameEn / id(与旧行为一致)。
7359
+ item.displayBody || item.description || item.nameEn || item.id),
6217
7360
  h('div', { className: 'dsh-mcp-card-tags' },
6218
7361
  item.skillCount ? h(Badge, null, t('含 {0} 技能', [item.skillCount])) : null,
6219
7362
  item.transport ? h(Badge, { cls: item.transport === 'streamable-http' ? 't-http' : 't-stdio' }, item.transport === 'streamable-http' ? 'HTTP' : 'stdio') : null,
6220
7363
  item.authMode ? h(Badge, null, item.authMode) : null,
7364
+ // T02/R1:token 注入规则标注(只展示,DSH 不代注入)。
7365
+ item.injectionRuleCount ? h(Badge, { cls: 's-warn', title: t('存在 token 注入规则(依赖 WB 账号体系,DSH 不代注入)') }, t('注入规则 ×{0}', [item.injectionRuleCount])) : null,
7366
+ // T02/R2:visible_in 标签 chips(最多展示 3 个,多出的折叠进 +N)。
7367
+ (item.visibleIn || []).length
7368
+ ? h(Badge, null,
7369
+ item.visibleIn.slice(0, 3).join(' / ')
7370
+ + ((item.visibleIn.length > 3) ? t(' +{0}', [item.visibleIn.length - 3]) : ''))
7371
+ : null,
6221
7372
  ),
6222
7373
  h('div', { className: 'dsh-mcp-wb-card-actions' },
6223
7374
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, t('详情')),
6224
7375
  item.installable
6225
- ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installConnector(item) }, t('安装'))
7376
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => requestInstallConnector(item) }, t('安装'))
6226
7377
  : h('span', {
6227
7378
  className: 'dsh-mcp-wb-blocked',
6228
7379
  title: item.blockedReason,
@@ -6232,7 +7383,7 @@ const WorkBuddyMarket = ({ call }) => {
6232
7383
  )
6233
7384
  : null,
6234
7385
  tab === 'connector' && connectorPageCount > 1
6235
- ? h(Pager, { page: safeConnectorPage, pageCount: connectorPageCount, total: filteredConnectors.length, onPage: setConnectorPage })
7386
+ ? h(Pager, { page: safeConnectorPage, pageCount: connectorPageCount, total: sortedConnectors.length, onPage: setConnectorPage })
6236
7387
  : null,
6237
7388
  tab === 'builtin' && builtinError
6238
7389
  ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, builtinError, ' ',
@@ -6343,13 +7494,256 @@ const WorkBuddyMarket = ({ call }) => {
6343
7494
  tab === 'skill' && skillPageCount > 1
6344
7495
  ? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled: loading })
6345
7496
  : null,
7497
+ // ── T03/R5:codebuddy 插件市场 ──
7498
+ // 重启提示条:常驻本 tab 顶部(组件卸载即消失,不写 localStorage)——
7499
+ // installBundle 装完包,宿主进程不热载,不重启就以为装失败了。
7500
+ tab === 'plugin' && restartNotice
7501
+ ? h('div', { className: 'dsh-mcp-wb-note restart', role: 'status' },
7502
+ restartNotice,
7503
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setRestartNotice('') }, t('知道了')),
7504
+ )
7505
+ : null,
7506
+ // T05/R9:插件市场更新提示条。只按 reason 分支(设计文档:UI 只看 reason,不看 error)。
7507
+ // no-source / probe-failed / up-to-date → 不提示;no-cache / update-available → 提示。
7508
+ // probe-failed 不自动重试(X4),用户可手动点「重新探测」。
7509
+ tab === 'plugin' && pluginSource === 'codebuddy' && pluginUpdateHint && (pluginUpdateHint.reason === 'update-available' || pluginUpdateHint.reason === 'no-cache')
7510
+ ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' },
7511
+ pluginUpdateHint.reason === 'update-available'
7512
+ ? t('插件市场有新版本可更新(本地缓存 {0})。点「重新加载」可拉取最新目录。', [pluginUpdateHint.localEtag || t('未知错误')])
7513
+ : t('本地还没有插件市场缓存,点「重新加载」可首次拉取目录。'),
7514
+ h('button', {
7515
+ type: 'button', className: 'dsh-mcp-btn', disabled: pluginsLoading,
7516
+ onClick: async () => { setPluginUpdateHint(null); await loadPlugins(); },
7517
+ }, t('重新加载')),
7518
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setPluginUpdateHint(null) }, t('知道了')),
7519
+ )
7520
+ : null,
7521
+ tab === 'plugin' && pluginSource === 'codebuddy' && pluginUpdateHint && pluginUpdateHint.reason === 'probe-failed'
7522
+ ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' },
7523
+ t('插件市场更新探测失败(不影响已安装插件)。'),
7524
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: pluginsLoading, onClick: () => { pluginUpdateChecked.current = false; checkPluginUpdate(); } }, t('重新探测')),
7525
+ )
7526
+ : null,
7527
+ tab === 'plugin' && pluginSource === 'codebuddy' && pluginsError
7528
+ ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, pluginsError, ' ',
7529
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: loadPlugins, disabled: pluginsLoading }, t('重试')))
7530
+ : null,
7531
+ tab === 'plugin' && pluginSource === 'codebuddy' && pluginsLoading && !plugins.length
7532
+ ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取插件目录…'))
7533
+ : null,
7534
+ tab === 'plugin' && pluginSource === 'codebuddy' && !pluginsError && !pluginsLoading && !plugins.length
7535
+ ? h(EmptyState, {
7536
+ icon: 'store',
7537
+ title: t('插件目录暂时是空的'),
7538
+ hint: t('目录来自 codebuddy 官方市场 ZIP 分发,可能还没同步下来;重读一次通常就好。'),
7539
+ actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: loadPlugins }],
7540
+ })
7541
+ : null,
7542
+ tab === 'plugin' && pluginSource === 'codebuddy' && plugins.length && !filteredPlugins.length
7543
+ ? h(EmptyState, {
7544
+ icon: 'store',
7545
+ tight: true,
7546
+ title: t('没有匹配的插件'),
7547
+ hint: t('换个关键词试试,或清空搜索回到完整目录。'),
7548
+ actions: [{ label: t('清空搜索'), icon: 'x', onClick: () => setPluginQuery('') }],
7549
+ })
7550
+ : null,
7551
+ tab === 'plugin' && pluginSource === 'codebuddy' && filteredPlugins.length
7552
+ ? h('div', { className: 'dsh-mcp-wb-grid' },
7553
+ filteredPlugins.map((item) => {
7554
+ const spec = pluginInstallSpec(item);
7555
+ return h('div', { key: item.name, className: 'dsh-mcp-card wb' },
7556
+ h('div', { className: 'dsh-mcp-card-top' },
7557
+ h('div', null,
7558
+ h('div', { className: 'dsh-mcp-card-name' }, item.name),
7559
+ item.author ? h('div', { className: 'dsh-mcp-card-vendor' }, item.author) : null,
7560
+ ),
7561
+ // T04/R8:已安装徽章(账本 key 是 plugin.name)。
7562
+ installedPluginKeys.has(String(item.name || ''))
7563
+ ? h(Badge, { cls: 's-on' }, t('已安装'))
7564
+ : null,
7565
+ item.version ? h(Badge, null, 'v' + item.version) : null,
7566
+ ),
7567
+ h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.name),
7568
+ h('div', { className: 'dsh-mcp-card-tags' },
7569
+ item.license ? h(Badge, null, item.license) : null,
7570
+ // R6:dependencies 只展示(缺 = 无依赖不出徽章),绝不自动安装。
7571
+ (item.dependencies || []).length ? h(Badge, { title: item.dependencies.map((d) => d.name).join(', ') }, t('依赖 {0}', [item.dependencies.length])) : null,
7572
+ ),
7573
+ h('div', { className: 'dsh-mcp-wb-card-actions' },
7574
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: async () => {
7575
+ setPluginDetail({ ...item, loading: true });
7576
+ try {
7577
+ const res = await call('wbPluginDetail', { name: item.name });
7578
+ setPluginDetail(res?.ok ? { ...res.plugin, loading: false } : { ...item, loading: false, detailError: res?.error || t('读取详情失败') });
7579
+ } catch (e) {
7580
+ setPluginDetail({ ...item, loading: false, detailError: String(e?.message || e) });
7581
+ }
7582
+ } }, t('详情')),
7583
+ // UNCLEAR#1:repository 可推导才渲染安装按钮;不可推导 → 只展示。
7584
+ spec
7585
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || isInstallRunning(item.name), onClick: () => installPlugin(item) }, t('安装'))
7586
+ : h('span', { className: 'dsh-mcp-wb-blocked', title: t('该插件未提供可推导的安装来源(repository),暂不能安装') }, t('仅展示')),
7587
+ ),
7588
+ );
7589
+ }),
7590
+ )
7591
+ : null,
7592
+ // ── 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,
7596
+ 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
+ tab === 'plugin' && pluginSource === 'awesome' && awesomeLoading && !awesomePlugins.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在加载 DSH 社区目录…')) : null,
7598
+ 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
+ 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' },
7601
+ h('div', { className: 'dsh-mcp-card-top' },
7602
+ h('div', null,
7603
+ h('div', { className: 'dsh-mcp-card-name' }, item.name),
7604
+ item.repo ? h('div', { className: 'dsh-mcp-card-vendor', title: item.repo }, String(item.repo).replace(/^https?:\/\/github\.com\//i, '')) : null),
7605
+ installedPluginKeys.has(String(item.name || '')) ? h(Badge, { cls: 's-on' }, t('已安装')) : null,
7606
+ item.official ? h(Badge, { cls: 's-on' }, t('官方')) : null,
7607
+ item.verifiedAgainst ? h(Badge, { title: item.lastVerified ? String(item.lastVerified) : undefined }, '✓ verified (dsh ' + item.verifiedAgainst + ')') : null),
7608
+ h('div', { className: 'dsh-mcp-card-summary' }, item.description || item.name),
7609
+ h('div', { className: 'dsh-mcp-card-tags' },
7610
+ item.npm ? h(Badge, { title: item.npm }, 'npm') : null,
7611
+ item.stars ? h(Badge, null, '★ ' + item.stars) : null,
7612
+ item.category && item.category !== 'plugin' ? h(Badge, null, item.category) : null,
7613
+ (item.tags || []).map((tag) => h('span', { key: tag, className: 'dsh-mcp-tag-chip on' }, tag))),
7614
+ h('div', { className: 'dsh-mcp-wb-card-actions' },
7615
+ 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
+ tab === 'plugin' && pluginSource === 'awesome' && awesomePageCount > 1 ? h(Pager, { page: safeAwesomePage, pageCount: awesomePageCount, total: sortedAwesome.length, onPage: setAwesomePage }) : null,
7617
+ // ── T04/R7:令牌弹窗(连接器 mcp.json 里的 ${...} 占位符)──
7618
+ // 设计约束:只收集用户输入并原样透传;槽位来自目录条目的 tokenSlots,
7619
+ // 同一占位符可能出现多次(同 server 的 env 与 header),按 placeholder 去重。
7620
+ tokenDialog
7621
+ ? (() => {
7622
+ const slots = [];
7623
+ const seen = new Set();
7624
+ for (const slot of tokenDialog.item.tokenSlots || []) {
7625
+ if (!slot || !slot.placeholder || seen.has(slot.placeholder)) continue;
7626
+ seen.add(slot.placeholder);
7627
+ slots.push(slot);
7628
+ }
7629
+ return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setTokenDialog(null) },
7630
+ h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('填写连接器凭据'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setTokenDialog(null); } } },
7631
+ h('h3', null, t('填写 {0} 的凭据', [tokenDialog.item.name])),
7632
+ h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
7633
+ t('该连接器的配置里有 {0} 个占位符,需要你提供对应凭据才能生效。没填的会保持原样,安装后仍可在 MCP 配置里手动补全。', [slots.length])),
7634
+ slots.length
7635
+ ? h('div', { className: 'dsh-mcp-wb-tokenform' }, slots.map((slot) => h('label', { key: slot.placeholder, className: 'dsh-mcp-wb-tokenfield' },
7636
+ h('span', { className: 'dsh-mcp-wb-tokenlabel' }, slot.placeholder + t('({0})', [slot.container === 'header' ? t('请求头 ') + slot.key : t('环境变量 ') + slot.key])),
7637
+ h('input', {
7638
+ type: 'password',
7639
+ autoComplete: 'off',
7640
+ disabled: busy,
7641
+ value: tokenDialog.values[slot.placeholder] || '',
7642
+ placeholder: t('留空表示稍后自行补全'),
7643
+ onChange: (e) => {
7644
+ const next = e.target.value;
7645
+ setTokenDialog((current) => (current ? { ...current, values: { ...current.values, [slot.placeholder]: next } } : current));
7646
+ },
7647
+ }),
7648
+ )))
7649
+ : h('p', { className: 'dsh-mcp-wb-modal-note' }, t('该连接器没有可填写的占位符,可直接安装。')),
7650
+ h('div', { className: 'dsh-mcp-modal-actions' },
7651
+ h(Btn, { onClick: () => setTokenDialog(null), disabled: busy, children: t('取消') }),
7652
+ h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => installConnector(tokenDialog.item, tokenDialog.values), children: busy ? t('安装中…') : t('带凭据安装') }),
7653
+ ),
7654
+ ),
7655
+ );
7656
+ })()
7657
+ : null,
6346
7658
  tab === 'expert' ? h(ExpertMarket, { call }) : null,
7659
+ // ── R8:OAuth 授权弹层(401 → 浏览器授权 → 自动重装)──
7660
+ authDialog
7661
+ ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && closeAuthDialog() },
7662
+ h('div', { ref: authDialogRefNode, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('授权 {0}', [authDialog.serverName]), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); closeAuthDialog(); } } },
7663
+ h('h3', null, t('授权 {0}', [authDialog.serverName])),
7664
+ h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
7665
+ authDialog.status === 'authorized'
7666
+ ? t('{0}:授权成功,正在完成安装…', [authDialog.item?.name || authDialog.serverName])
7667
+ : t('该连接器要求在浏览器里完成一次登录授权。授权页会尝试自动打开;授权完成后这里会自动继续,无需手动刷新。')),
7668
+ authDialog.error
7669
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'alert', style: 'color:var(--dsw-alias-state-warn-primary)' }, authDialog.error)
7670
+ : null,
7671
+ authDialog.authorizeUrl
7672
+ ? h('label', { className: 'dsh-mcp-wb-tokenfield' },
7673
+ h('span', { className: 'dsh-mcp-wb-tokenlabel' }, t('授权链接')),
7674
+ h('input', {
7675
+ type: 'text',
7676
+ readOnly: true,
7677
+ value: authDialog.authorizeUrl,
7678
+ onFocus: (e) => e.target.select(),
7679
+ }),
7680
+ h(Btn, { icon: 'plus', disabled: busy, onClick: () => { call('wbOAuthOpen', { url: authDialog.authorizeUrl }); }, children: t('重新打开浏览器') }),
7681
+ )
7682
+ : null,
7683
+ authDialog.mode === 'manual-callback'
7684
+ ? h('div', { className: 'dsh-mcp-wb-tokenform' },
7685
+ h('p', { className: 'dsh-mcp-wb-modal-note' },
7686
+ t('该服务器只接受自定义协议回调,浏览器跳转后页面会打不开。请把地址栏里的完整地址复制到下面。')),
7687
+ h('label', { className: 'dsh-mcp-wb-tokenfield' },
7688
+ h('span', { className: 'dsh-mcp-wb-tokenlabel' }, t('粘贴完整回调地址')),
7689
+ h('input', {
7690
+ type: 'text',
7691
+ autoComplete: 'off',
7692
+ disabled: busy,
7693
+ value: authDialog.callbackUrl || '',
7694
+ placeholder: authDialog.redirectUri || '',
7695
+ onChange: (e) => {
7696
+ const next = e.target.value;
7697
+ setAuthDialog((current) => (current ? { ...current, callbackUrl: next } : current));
7698
+ },
7699
+ }),
7700
+ h(Btn, { primary: true, disabled: busy || !String(authDialog.callbackUrl || '').trim(), onClick: () => { submitAuthCallback(); }, children: t('提交回调') }),
7701
+ ),
7702
+ )
7703
+ : null,
7704
+ h('div', { className: 'dsh-mcp-wb-tokenform' },
7705
+ h('label', { className: 'dsh-mcp-wb-tokenfield' },
7706
+ h('span', { className: 'dsh-mcp-wb-tokenlabel' }, t('也可以直接粘贴访问令牌')),
7707
+ h('input', {
7708
+ type: 'password',
7709
+ autoComplete: 'off',
7710
+ disabled: busy,
7711
+ value: authDialog.manualToken || '',
7712
+ placeholder: t('粘贴访问令牌'),
7713
+ onChange: (e) => {
7714
+ const next = e.target.value;
7715
+ setAuthDialog((current) => (current ? { ...current, manualToken: next } : current));
7716
+ },
7717
+ }),
7718
+ h(Btn, { disabled: busy || !String(authDialog.manualToken || '').trim(), onClick: () => { submitAuthToken(); }, children: t('用令牌安装') }),
7719
+ ),
7720
+ ),
7721
+ h('div', { className: 'dsh-mcp-modal-actions' },
7722
+ h(Btn, { disabled: busy, onClick: () => closeAuthDialog(), children: t('取消') }),
7723
+ ),
7724
+ ),
7725
+ )
7726
+ : null,
6347
7727
  // ── 连接器详情弹层:安装前看真实 mcp.json ──
6348
7728
  connectorDetail
6349
7729
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setConnectorDetail(null) },
6350
7730
  h('div', { ref: connectorDetailRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('连接器详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setConnectorDetail(null); } } },
6351
7731
  h('h3', null, connectorDetail.name),
6352
- connectorDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.description) : null,
7732
+ // T02/R3:详情正文同样 examples 优先,description 兜底。
7733
+ connectorDetail.displayBody || connectorDetail.description
7734
+ ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.displayBody || connectorDetail.description) : null,
7735
+ // T02/R1:token 注入规则标注——只展示条数,不注入、不展示规则内容(X9 边界)。
7736
+ connectorDetail.injectionRuleCount
7737
+ ? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
7738
+ t('存在 {0} 条 token 注入规则(依赖 WB 账号体系,DSH 不代注入)。', [connectorDetail.injectionRuleCount]))
7739
+ : null,
7740
+ // T02/R3:基础元数据照常透出。
7741
+ connectorDetail.version || connectorDetail.authMode
7742
+ ? h('div', { className: 'dsh-mcp-card-tags' },
7743
+ connectorDetail.version ? h(Badge, null, 'v' + connectorDetail.version) : null,
7744
+ connectorDetail.authMode ? h(Badge, null, t('凭据: ') + connectorDetail.authMode) : null,
7745
+ )
7746
+ : null,
6353
7747
  connectorDetail.examples && connectorDetail.examples.length
6354
7748
  ? h('div', { className: 'dsh-mcp-wb-examples' },
6355
7749
  h('b', null, t('用法示例')),
@@ -6362,12 +7756,50 @@ const WorkBuddyMarket = ({ call }) => {
6362
7756
  h('div', { className: 'dsh-mcp-modal-actions' },
6363
7757
  h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: t('取消') }),
6364
7758
  connectorDetail.installable
6365
- ? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => installConnector(connectorDetail), children: busy ? t('安装中…') : t('安装到全局') })
7759
+ ? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => requestInstallConnector(connectorDetail), children: busy ? t('安装中…') : t('安装到全局') })
6366
7760
  : h('span', { className: 'dsh-mcp-wb-blocked' }, connectorDetail.blockedReason || t('不可安装')),
6367
7761
  ),
6368
7762
  ),
6369
7763
  )
6370
7764
  : null,
7765
+ // ── T03/R5:插件详情弹层(repository/homepage/lspServers 全量透出;X7 lspServers 只展示)──
7766
+ pluginDetail
7767
+ ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setPluginDetail(null) },
7768
+ h('div', { ref: pluginDetailRef, 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(); setPluginDetail(null); } } },
7769
+ h('h3', null, pluginDetail.name),
7770
+ pluginDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, pluginDetail.description) : null,
7771
+ pluginDetail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, pluginDetail.detailError) : null,
7772
+ h('div', { className: 'dsh-mcp-card-tags' },
7773
+ pluginDetail.version ? h(Badge, null, 'v' + pluginDetail.version) : null,
7774
+ pluginDetail.author ? h(Badge, null, pluginDetail.author) : null,
7775
+ pluginDetail.license ? h(Badge, null, pluginDetail.license) : null,
7776
+ ),
7777
+ (pluginDetail.dependencies || []).length
7778
+ ? h('div', { className: 'dsh-mcp-wb-examples' },
7779
+ h('b', null, t('依赖')),
7780
+ h('ul', null, pluginDetail.dependencies.map((dep) => h('li', { key: dep.name }, dep.name + (dep.version ? t('({0})', [dep.version]) : '')))),
7781
+ )
7782
+ : null,
7783
+ pluginDetail.lspServers
7784
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' },
7785
+ t('本插件声明了 {0} 个 LSP 服务,安装后由宿主按声明启动(此处仅展示)。', [Object.keys(pluginDetail.lspServers).length]))
7786
+ : null,
7787
+ pluginDetail.repository || pluginDetail.homepage
7788
+ ? h('p', { className: 'dsh-mcp-wb-modal-note' },
7789
+ pluginDetail.repository ? t('来源:') + pluginDetail.repository + ' ' : '',
7790
+ pluginDetail.homepage ? t('主页:') + pluginDetail.homepage : '')
7791
+ : null,
7792
+ h('p', { className: 'dsh-mcp-wb-modal-note' },
7793
+ t('安装 = 通过宿主 pluginManager 拉取并落盘;安装成功后需重启 DSH 宿主才会生效。')),
7794
+ h('div', { className: 'dsh-mcp-modal-actions' },
7795
+ h(Btn, { onClick: () => setPluginDetail(null), disabled: busy, children: t('关闭') }),
7796
+ pluginInstallSpec(pluginDetail)
7797
+ ? h(Btn, { primary: true, icon: 'plus', disabled: busy || pluginDetail.loading || isInstallRunning(pluginDetail.name), onClick: () => installPlugin(pluginDetail), children: busy || isInstallRunning(pluginDetail.name) ? t('安装中…') : t('安装') })
7798
+ : h('span', { className: 'dsh-mcp-wb-blocked' }, t('该插件未提供可推导的安装来源(repository),暂不能安装')),
7799
+ ),
7800
+ ),
7801
+ )
7802
+ : null,
6371
7803
  // ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
6372
7804
  skillDetail
6373
7805
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setSkillDetail(null) },
@@ -6832,9 +8264,34 @@ const CommandPalette = ({ open, onClose, call, category, onJump }) => {
6832
8264
  );
6833
8265
  };
6834
8266
 
8267
+ // 跨分类跳转的 pending:与 pendingSkillFocus / pendingMcpQuery 同一套两段式。
8268
+ // 为什么必须有它:boardCategoryListeners 只通知**当前已挂载**的订阅者,而跨分类跳转最
8269
+ // 常见的触发场景恰恰是看板页还没挂载(从会话界面 / 浮层里点按钮)。广播无人接收就等于
8270
+ // 目标分类永久丢失,挂载后只能回落到 hash 记忆或默认分类。
8271
+ let pendingBoardCategory = '';
8272
+
8273
+ // 强制选中看板面板(不 toggle)。openBoardCategory 是「带目标的跳转」,语义上必须落到
8274
+ // 看板页;而 openBoardPanel 的开关语义是给**入口按钮**用的(再点一次回对话),两者混用
8275
+ // 就会出现「点『去市场找现成的』却被踢回会话」——分类广播刚发完,紧接着 openBoardPanel()
8276
+ // 看到已在看板页,反手 selectPanel(null)。入口按钮的 toggle 行为是有意设计,不能动,
8277
+ // 所以这里另开一条只进不退的通道。
8278
+ function selectBoardPanel() {
8279
+ const ctx = MODULE_CTX.ctx;
8280
+ const layout = ctx && typeof ctx.get === 'function' ? ctx.get('layout') : null;
8281
+ if (!layout || typeof layout.selectPanel !== 'function') return;
8282
+ try {
8283
+ layout.selectPanel(PANEL_ID);
8284
+ } catch { /* main slot 里还没注册同 key 条目时宿主会抛:入口没响应比抛错好 */ }
8285
+ }
8286
+
6835
8287
  function openBoardCategory(key) {
8288
+ // 只认 BOARD_CATEGORIES 里的 key:命令面板的分类行、召唤菜单的「管理」按钮传的是各自
8289
+ // kind,拼写漂移或新增 kind 未同步时宁可什么都不做,也不要选中一个不存在的分类。
8290
+ if (!BOARD_CATEGORIES.some((cat) => cat.key === key)) return;
8291
+ pendingBoardCategory = key;
6836
8292
  boardCategoryListeners.forEach((listener) => { try { listener(key); } catch { /* 单个订阅者失败不拖累其它 */ } });
6837
- openBoardPanel();
8293
+ // 走强制选中而不是 openBoardPanel():后者是开关,已在看板页时会把用户送回会话。
8294
+ selectBoardPanel();
6838
8295
  }
6839
8296
  const McpBoardPage = (props) => {
6840
8297
  const { call, timer, pluginManager } = props;
@@ -6842,6 +8299,11 @@ const McpBoardPage = (props) => {
6842
8299
  // 初值只在首帧读一次(useState 惰性初始化),之后由 effect 单向写出,
6843
8300
  // 不做 hash→state 的反向监听:宿主可能在任意时刻改 hash,双向同步必然互相打架。
6844
8301
  const [category, setCategoryState] = useState(() => {
8302
+ // pending 优先于 hash:用户刚刚点下的意图 > 上次留下的 URL 记忆。两者都不可用时
8303
+ // 才回落到默认分类。pending 在这里读、在下面的 effect 里清——惰性初始化只在首帧
8304
+ // 跑一次,不能承担副作用,所以只读取不消费。
8305
+ const focus = pendingBoardCategory;
8306
+ if (BOARD_CATEGORIES.some((cat) => cat.key === focus)) return focus;
6845
8307
  const fromHash = readHashParam('armory');
6846
8308
  return BOARD_CATEGORIES.some((cat) => cat.key === fromHash) ? fromHash : 'mcp';
6847
8309
  });
@@ -6850,8 +8312,19 @@ const McpBoardPage = (props) => {
6850
8312
  writeHashParams({ armory: key });
6851
8313
  }, []);
6852
8314
  useEffect(() => {
6853
- const listener = (key) => { if (BOARD_CATEGORIES.some((cat) => cat.key === key)) setCategory(key); };
8315
+ // 已挂载时的广播:领到就清 pending,与挂载自领走同一份状态,不会留下过期目标。
8316
+ const listener = (key) => {
8317
+ if (!BOARD_CATEGORIES.some((cat) => cat.key === key)) return;
8318
+ pendingBoardCategory = '';
8319
+ setCategory(key);
8320
+ };
6854
8321
  boardCategoryListeners.add(listener);
8322
+ // 挂载时自领 pending:从会话界面 / 浮层跳过来时广播早已发完(当时本组件还没挂载),
8323
+ // 目标分类只剩模块里这一份。与 pendingSkillFocus / pendingMcpQuery 完全同一套语义:
8324
+ // 领到就清空,避免下一次挂载被一条早已过期的意图劫持。
8325
+ const pending = pendingBoardCategory;
8326
+ pendingBoardCategory = '';
8327
+ if (pending && BOARD_CATEGORIES.some((cat) => cat.key === pending)) setCategory(pending);
6855
8328
  return () => { boardCategoryListeners.delete(listener); };
6856
8329
  }, [setCategory]);
6857
8330
  // §1.1 hover 摘要:记录当前悬停的 chip 与它的左偏移,浮层直接挂在分类条容器上,
@@ -6992,7 +8465,7 @@ const McpBoardPage = (props) => {
6992
8465
  : category === 'expert'
6993
8466
  ? h(ArmoryPanelBoundary, { key: 'expert', hint: t('已召唤专家列表加载失败;其余分类仍可正常使用。') }, h(ExpertInstalledBoard, { call }))
6994
8467
  : category === 'wb'
6995
- ? h(ArmoryPanelBoundary, { key: 'wb', hint: t('市场数据加载失败;其余分类仍可正常使用。') }, h(WorkBuddyMarket, { call }))
8468
+ ? h(ArmoryPanelBoundary, { key: 'wb', hint: t('市场数据加载失败;其余分类仍可正常使用。') }, h(WorkBuddyMarket, { call, pluginManager }))
6996
8469
  : h(ArmoryPanelBoundary, { key: 'skill', hint: t('Skill 看板加载失败;其余分类仍可正常使用。') }, h(SkillBoard, { call, timer })),
6997
8470
  // 命令面板挂在最外层的 wrap 里:它自己是 fixed 浮层,位置与父元素无关,
6998
8471
  // 但放在这里可确保它和分类正文共享同一次渲染,不会因分类切换被重建。
@@ -7430,11 +8903,33 @@ const MCP_CONTRIBUTION = {
7430
8903
  { id: 'dsh-armory#mcpManager/wbConnectorCachedCount', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorCachedCount', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbCachedCountResult') },
7431
8904
  { id: 'dsh-armory#mcpManager/wbConnectorPreview', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorPreview', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPreviewResult') },
7432
8905
  { id: 'dsh-armory#mcpManager/wbConnectorInstall', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorInstall', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8906
+ { id: 'dsh-armory#mcpManager/wbOAuthBegin', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthBegin', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8907
+ { id: 'dsh-armory#mcpManager/wbOAuthPoll', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthPoll', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8908
+ { id: 'dsh-armory#mcpManager/wbOAuthCancel', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthCancel', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8909
+ { id: 'dsh-armory#mcpManager/wbOAuthToken', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthToken', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8910
+ { id: 'dsh-armory#mcpManager/wbOAuthOverview', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthOverview', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8911
+ { id: 'dsh-armory#mcpManager/wbOAuthOpen', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthOpen', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
7433
8912
  { id: 'dsh-armory#mcpManager/wbSkillCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillListResult') },
7434
8913
  { id: 'dsh-armory#mcpManager/wbSkillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillDetailResult') },
7435
8914
  { id: 'dsh-armory#mcpManager/wbSkillInstall', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillInstall', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
7436
8915
  { id: 'dsh-armory#mcpManager/wbSkillUninstall', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillUninstall', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
7437
8916
  { id: 'dsh-armory#mcpManager/wbSkillInstalled', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillInstalled', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbInstalledResult') },
8917
+ // T03/R4:多市场注册表(读 / 增改)。与宿主 method、typert.js 三处必须同步。
8918
+ { id: 'dsh-armory#mcpManager/wbMarketRegistry', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMarketRegistry', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbMarketRegistryResult') },
8919
+ { id: 'dsh-armory#mcpManager/wbMarketRegistryUpsert', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMarketRegistryUpsert', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMarketRegistryResult') },
8920
+ // T03/R5:codebuddy 插件市场(列表 / 详情)。与宿主 method、typert.js 三处必须同步。
8921
+ { id: 'dsh-armory#mcpManager/wbPluginCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginCatalog', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbPluginCatalogResult') },
8922
+ // T04/R8:安装状态账本(读全量 / 登记 / 忘记)。与宿主 method、typert.js 三处必须同步。
8923
+ { id: 'dsh-armory#mcpManager/wbInstallState', service: 'mcpManager', namespace: 'mcpManager', method: 'wbInstallState', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('InstallStateResult') },
8924
+ { id: 'dsh-armory#mcpManager/wbInstallStateRecord', service: 'mcpManager', namespace: 'mcpManager', method: 'wbInstallStateRecord', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8925
+ { id: 'dsh-armory#mcpManager/wbInstallStateForget', service: 'mcpManager', namespace: 'mcpManager', method: 'wbInstallStateForget', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
8926
+ // T05/R9:插件市场更新探测(只读 ETag,绝不落新包)。与宿主 method、typert.js 三处必须同步。
8927
+ { id: 'dsh-armory#mcpManager/wbPluginUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
8928
+ // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)。与宿主 method、typert.js 三处必须同步。
8929
+ { id: 'dsh-armory#mcpManager/wbAwesomeCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeCatalogResult') },
8930
+ { id: 'dsh-armory#mcpManager/wbAwesomeDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeDetailResult') },
8931
+ { id: 'dsh-armory#mcpManager/wbAwesomeUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
8932
+ { id: 'dsh-armory#mcpManager/wbPluginDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPluginDetailResult') },
7438
8933
  // ── WorkBuddy 专家直连:召唤预设 + 捆绑技能(同一命名空间同一信封)──
7439
8934
  { id: 'dsh-armory#mcpManager/wbExpertCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertCatalog', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbExpertCatalogResult') },
7440
8935
  { id: 'dsh-armory#mcpManager/wbExpertDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbExpertDetailResult') },
@@ -7517,7 +9012,7 @@ async function apply(ctx) {
7517
9012
  const syncPanelEntry = () => {
7518
9013
  const want = ENTRY_VISIBILITY[entryMode.get()].panel;
7519
9014
  if (want && !disposePanelEntry) disposePanelEntry = ctx.slots.register(
7520
- { name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: PANEL_LABEL }, McpPanelIcon,
9015
+ { name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: SIDEBAR_ENTRY_LABEL }, McpPanelIcon,
7521
9016
  );
7522
9017
  if (!want && disposePanelEntry) { disposePanelEntry(); disposePanelEntry = null; }
7523
9018
  };