dsh-armory 1.9.4 → 1.10.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
@@ -40,15 +40,753 @@ const { useState, useEffect, useCallback, useMemo, useRef } = React2;
40
40
  // 将来补 en-US:往 i18nMessages 合并 { '中文原文': 'English' } 即可,无需改调用点。
41
41
  // 带插值的文案:t('{0} 个会话', [n]) —— {n} 占位符按 vars 数组下标替换;
42
42
  // 翻译表里的目标语可自由重排占位符顺序。
43
- // 约束(与 OPT-12 同源):ModuleLoader 只注入宿主模块,client.js 不能相对导入
44
- // 字典文件,所以消息表必须内联在本文件里。
43
+ // 曾经的约束(与 OPT-12 同源):ModuleLoader 只注入宿主模块,判定 client.js 不能
44
+ // 相对导入字典文件。OPT-14 实测可行:chunk 产物 + require.async 能拿到宿主模块
45
+ // (dsh-cost-meter 先例)。本次 en-US 表仍内联在本文件里(677 键,避免构建期拼接),
46
+ // 将来加新语言可改走 require.async 外部字典文件。
45
47
  const i18nMessages = {};
46
48
  const setI18nMessages = (table) => { Object.assign(i18nMessages, table || {}); };
47
- // 补 en-US 时把字典表直接写进下面这次调用的实参:{ '设置': 'Settings', ... }。
48
- // 留一个空调用不是占位符——它是唯一的写入入口,也提醒后来者「新语言要改这里」。
49
- setI18nMessages({
50
- // 例:'设置': 'Settings',
51
- });
49
+ // OPT-14 · 1.1 locale 判定:localStorage 偏好 → navigator.language → zh-CN 回落。
50
+ // 所有失败路径(存储不可用、无 navigator)都静默回落,绝不抛错影响面板加载。
51
+ const I18N_LOCALE = (() => {
52
+ try {
53
+ const stored = localStorage['dsh-armory.locale'];
54
+ if (stored) return stored;
55
+ } catch { /* 存储不可用时跳过偏好,继续往下回落 */ }
56
+ return typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'zh-CN';
57
+ })();
58
+ const EN_US_MESSAGES = {
59
+ " + 捆绑技能": " + bundled skills",
60
+ " · {0} 个失败": " · {0} failed",
61
+ " · 全库搜索(忽略分类)": " · searching all libraries (ignores category)",
62
+ " · 刷新中…": " · refreshing…",
63
+ " · 已更新": " · updated",
64
+ " · 已隐藏 {0} 个有调用的工具": " · {0} called tools hidden",
65
+ " · 显示 {0}/{1} 个工具 · Schema 合计 ~{2} tok/请求": " · showing {0}/{1} tools · total schema ~{2} tok/request",
66
+ " · 缓存": " · cache",
67
+ " 个": "",
68
+ " 个 MCP": " MCPs",
69
+ " 个 MCP:": " MCPs:",
70
+ " 个会话共用": " sessions share it",
71
+ " 个会话在用": " sessions in use",
72
+ " 个会话在运行。": " sessions running.",
73
+ " 个工具": " tools",
74
+ " 个项目 MCP 未能挂载到会话:": " project MCPs failed to attach to the session:",
75
+ " 个项目 MCP 的工具不在共享作用域层:": " project MCPs have tools outside the shared scope layer:",
76
+ " 分钟前": " min ago",
77
+ " 可用": " available",
78
+ " 可用;本地安装(": " available; local install (",
79
+ " 天": " days",
80
+ " 天前": " days ago",
81
+ " 失败": " failed",
82
+ " 字符)": " chars)",
83
+ " 对本项目可见": " visible to this project",
84
+ " 小时前": " h ago",
85
+ " 工具": " tools",
86
+ " 成功": " succeeded",
87
+ " 条提示,导入时逐条列出": " prompts, listed one by one on import",
88
+ " 的更新": " update",
89
+ "+ 添加": "+ Add",
90
+ "Bearer … 或 !!js process.env.KEY": "Bearer … or !!js process.env.KEY",
91
+ "Filesystem · 本地文件读写": "Filesystem · local file read/write",
92
+ "Git · 版本控制(需 uvx)": "Git · version control (needs uvx)",
93
+ "HTTP(远程)": "HTTP (remote)",
94
+ "Headers(可选)": "Headers (optional)",
95
+ "JSON 解析失败": "JSON parse failed",
96
+ "MCP 列表加载失败;Skill、专家、裁缝、市场仍可正常使用。": "Failed to load the MCP list; Skills, Experts, Tailor and Market keep working.",
97
+ "MCP 名称(唯一标识,不可改)": "MCP name (unique identifier, immutable)",
98
+ "MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具": "MCP tools apply per server: picking this names mcp__server__* tools",
99
+ "MCP 市场服务未连接(面板仍可用)": "MCP market service not connected (panel still usable)",
100
+ "MCP 市场面板": "MCP Market panel",
101
+ "MCP 目录暂时是空的": "The MCP catalog is empty for now",
102
+ "Memory · 知识图谱记忆": "Memory · knowledge-graph memory",
103
+ "Schema 税": "Schema tax",
104
+ "Sequential Thinking · 分步推理": "Sequential Thinking · step-by-step reasoning",
105
+ "Skill 看板加载失败;其余分类仍可正常使用。": "Failed to load the Skill board; other categories keep working.",
106
+ "SkillHub 是远程分页,只能在当前 {0} 条里筛。翻页找找,或取消「只看已安装」。": "SkillHub pages remotely; you can only filter within the current {0} entries. Try another page, or turn off 'Installed only'.",
107
+ "SkillHub 的搜索按名称与描述匹配;换一个更短的词通常更容易命中。": "SkillHub search matches name and description; a shorter query usually hits.",
108
+ "Time · 时区时间工具(需 uvx)": "Time · timezone clock tool (needs uvx)",
109
+ "WorkBuddy 专家 · ": "WorkBuddy Expert · ",
110
+ "WorkBuddy 市场": "WorkBuddy Market",
111
+ "npx 或 C:\\path\\to\\bin.exe": "npx or C:\\path\\to\\bin.exe",
112
+ "pnpm 退出码 ": "pnpm exit code ",
113
+ "serverName 同名冲突": "serverName duplicate conflict",
114
+ "skill 列表": "skill list",
115
+ "skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现;放一个 SKILL.md 进去就会出现在这里。": "Skills are discovered by skill-filesystem from project .dsh/skills, user ~/.dsh/skills and similar dirs; drop a SKILL.md in and it shows up here.",
116
+ "stdio(本地进程)": "stdio (local process)",
117
+ "toolTailor 调用失败": "toolTailor call failed",
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
+ "{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
+ "{0} 个专家正挂在 {1} 个会话上": "{0} experts are attached to {1} sessions",
122
+ "{0} 个会话": "{0} sessions",
123
+ "{0} 个共用": "{0} shared",
124
+ "{0} 个参数": "{0} parameters",
125
+ "{0} 个工具,默认收起以免遮蔽上方连接信息": "{0} tools, collapsed by default to keep connection info visible above",
126
+ "{0} 个日志帧解码失败,调用计数可能偏低": "{0} log frames failed to decode; call counts may be low",
127
+ "{0} 个日志文件未能读取(含超限跳过),展开看原因": "{0} log files could not be read (oversize ones skipped); expand to see why",
128
+ "{0} 个零调用 MCP 工具的 Schema,占全部工具税 {1} 的 {2}": "{0} zero-call MCP tools' schema — {2} of the {1} total tool tax",
129
+ "{0} 工具": "{0} tools",
130
+ "{0} 已召唤": "{0} summoned",
131
+ "{0} 已召唤,新建会话时可在预设选择器里选它": "{0} summoned; pick it in the preset selector when creating a new session",
132
+ "{0} 已送还": "{0} returned",
133
+ "{0}/{1} 个匹配": "{0}/{1} matches",
134
+ "{0}–{1} ms,最多 {2} 次": "{0}–{1} ms, max {2} attempts",
135
+ "{0}({1})": "{0} ({1})",
136
+ "{0}:": "{0}: ",
137
+ "{0}:{1}": "{0}: {1}",
138
+ "— 选择预设 —": "— Pick a preset —",
139
+ "••••••(保留原值)": "•••••• (keep current value)",
140
+ "⚠ 数据可能不完整:": "⚠ Data may be incomplete:",
141
+ "⚠ 这条预设是旧版本召唤写入的:人设缺「DSH 运行时适配」桥接段,用它的新会话主理人只会调不存在的 TeamCreate/Agent,成员子智能体开不出来。点下方「更新预设」重跑召唤即可刷新。": "⚠ This preset was written by an older summon: its persona lacks the 'DSH runtime adaptation' bridge section, so new sessions using it will call the nonexistent TeamCreate/Agent and member subagents won't spawn. Click 'Update preset' below to re-run the summon and refresh.",
142
+ "✓ 已取消本会话的专家": "✓ Expert detached from this session",
143
+ " ": " ",
144
+ " · 改动即刻生效": " · Changes take effect immediately",
145
+ " ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可": " — the host hasn't registered the skill interface yet; restart DeepSeek Harness to activate it",
146
+ " ——宿主还没注册 toolTailor 接口:重启 DeepSeek Harness 让宿主侧生效后即可": " — the host hasn't registered the toolTailor interface yet; restart DeepSeek Harness to activate it",
147
+ " 本项目当前有 ": " This project currently has ",
148
+ " 用户可调用:": " User-invocable:",
149
+ "、": ", ",
150
+ "。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
+ "。点开对应条目查看具体错误。": ". Open the matching entry to see the exact error.",
152
+ "」分类管理": "\" category manager",
153
+ "」即专家接管。送还 = 摘除该预设与已装捆绑技能(热加载,无需重启)。": "\"; that preset takes over the session. Unsummon = remove the preset and its bundled skills (hot reload, no restart).",
154
+ "」?将从": "\"? This will remove it from ",
155
+ "一并安装捆绑技能(本机 WorkBuddy 缓存完整安装;无缓存时仅装 SKILL.md)": "Also install bundled skills (full install from local WorkBuddy cache; without cache, SKILL.md only)",
156
+ "三个入口全开": "All three entries",
157
+ "上一页": "Previous page",
158
+ "下一页": "Next page",
159
+ "不兼容": "Incompatible",
160
+ "不兼容:": "Incompatible: ",
161
+ "不可安装": "Not installable",
162
+ "专家": "Experts",
163
+ "专家团": "Expert team",
164
+ "专家详情": "Expert details",
165
+ "专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消": "Attach/switch/detach experts anytime: click one to take over this session, click another to switch, click a chip's × to detach",
166
+ "中删除。": ".",
167
+ "中未出现在 JSON 里的 MCP。确认继续?": " that are absent from the JSON. Confirm to continue?",
168
+ "中未出现在这份来源里的 MCP。确认继续?": " that are absent from this source. Confirm to continue?",
169
+ "主理人": "Lead",
170
+ "人设正文({0} 字符)": "Persona body ({0} chars)",
171
+ "人设正文({0} 字符,召唤后成为会话系统提示)": "Persona body ({0} chars; becomes the session system prompt after summoning)",
172
+ "仅侧栏底部入口": "Sidebar bottom entry only",
173
+ "仅全局面板(左上角,\"插件\"旁)": "Global panel only (top-left, next to \"Plugins\")",
174
+ "仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)": "Filters installed ones within this page's {0} entries only (SkillHub pages remotely; cross-page filtering isn't possible)",
175
+ "仅展示": "Display only",
176
+ "仅悬浮按钮": "Floating button only",
177
+ "从左侧列表点选一台服务器,可以查看它的连接信息、参数与工具清单。": "Pick a server from the list on the left to view its connection info, parameters and tool list.",
178
+ "从左侧选择一个 skill 查看详情": "Pick a skill on the left to see details",
179
+ "从未": "Never",
180
+ "从本机客户端导入": "Import from local clients",
181
+ "从预设模板快速填充(可选,命令需本机 npx/uvx)": "Quick-fill from preset template (optional; commands need local npx/uvx)",
182
+ "以下项目也定义了同名 serverName:": "These projects also define a serverName with the same name:",
183
+ "会话": "Sessions",
184
+ "会话 {0}": "Session {0}",
185
+ "传输": "Transport",
186
+ "传输类型": "Transport type",
187
+ "何时使用": "When to use",
188
+ "作用域": "Scope",
189
+ "作用域故障": "Scope failure",
190
+ "作用域隔离失败": "Scope isolation failed",
191
+ "保存": "Save",
192
+ "保存中…": "Saving…",
193
+ "保存失败": "Save failed",
194
+ "免密钥": "Keyless",
195
+ "入口": "Entries",
196
+ "入口显示:": "Entry display:",
197
+ "全局": "Global",
198
+ "全局 MCP": "Global MCPs",
199
+ "全局共享": "Globally shared",
200
+ "全局配置": "Global config",
201
+ "全局配置(在「全局」标签页可编辑)": "Global config (editable on the 'Global' tab)",
202
+ "全局面板 + 悬浮按钮(推荐)": "Global panel + floating button (recommended)",
203
+ "全部": "All",
204
+ "全部传输": "All transports",
205
+ "全部分类": "All categories",
206
+ "全部状态": "All states",
207
+ "共 {0} 个": "Total {0}",
208
+ "关闭": "Close",
209
+ "关闭更新提示": "Dismiss update notice",
210
+ "其他 bundle / Agent(只读)": "Other bundles / agents (read-only)",
211
+ "其余分类仍可正常使用;完整堆栈已打印到控制台。": "Other categories keep working; the full stack trace is printed to the console.",
212
+ "内置 MCP": "Built-in MCPs",
213
+ "再看看这些(目录前 {0} 位未召唤)": "Also consider these (first {0} in catalog, not yet summoned)",
214
+ "出现过的工具(含已卸载 MCP 的历史调用)": "Tools ever seen (including historical calls of uninstalled MCPs)",
215
+ "列表(最多显示 6 行,滚动查看更多)": "List (shows up to 6 rows; scroll for more)",
216
+ "刚刚": "just now",
217
+ "删除 ": "Delete ",
218
+ "删除此项": "Delete this entry",
219
+ "刷新": "Refresh",
220
+ "刷新 MCP 状态": "Refresh MCP status",
221
+ "刷新中…": "Refreshing…",
222
+ "加载失败": "Load failed",
223
+ "动作": "Actions",
224
+ "升级": "Upgrade",
225
+ "升级失败。": "Upgrade failed.",
226
+ "升级失败(": "Upgrade failed (",
227
+ "升级失败:": "Upgrade failed: ",
228
+ "升级由官方 pluginManager 执行,装完需要重启宿主": "Upgrades run through the official pluginManager; restart the host after installing",
229
+ "卸载": "Uninstall",
230
+ "卸载失败": "Uninstall failed",
231
+ "去市场找现成的": "Find ready-made ones in the Market",
232
+ "去市场挑专家": "Pick experts in the Market",
233
+ "去市场看全部": "See all in the Market",
234
+ "参数": "Parameters",
235
+ "参数(每行一个,原样传给进程)": "Arguments (one per line, passed to the process verbatim)",
236
+ "取消": "Cancel",
237
+ "取消「只看已安装」": "Turn off 'Installed only'",
238
+ "取消「只看零调用」": "Turn off 'Zero-call only'",
239
+ "取消失败:": "Cancel failed: ",
240
+ "取消选择来源": "Clear source selection",
241
+ "另含 {0} 个连接器技能,如需请从技能市场或手动安装。": "Also includes {0} connector skills; install from the skill market or manually if needed.",
242
+ "只显示模型可以主动调用的 skill": "Show only skills the model can invoke on its own",
243
+ "只显示用户可以用 /名字 触发的 skill": "Show only skills the user can trigger with /name",
244
+ "只看可直接安装": "Directly installable only",
245
+ "只看已安装": "Installed only",
246
+ "只看来自该来源的 skill": "Show only skills from this source",
247
+ "只看窗口内零调用的工具": "Show only zero-call tools in the window",
248
+ "只看窗口内零调用的工具(默认关闭:默认显示全部工具与真实调用次数)": "Show only zero-call tools in the window (off by default: all tools with real call counts are shown)",
249
+ "只看零调用": "Zero-call only",
250
+ "只看零调用({0})": "Zero-call only ({0})",
251
+ "只要免密钥": "Keyless only",
252
+ "召唤": "Summon",
253
+ "召唤 = 往 profile 补丁写入一条独立 Agent 预设(人设全程接管该预设的会话,标准工具原样保留);": "Summon = write an independent agent preset into the profile patch (the persona takes over that preset's sessions; standard tools are kept); ",
254
+ "召唤专家团": "Summon expert team",
255
+ "召唤中…": "Summoning…",
256
+ "召唤后它会作为独立 Agent 预设出现在新会话的预设选择器里;也可以直接从下面的推荐位挑一位。": "After summoning it appears as an independent agent preset in new sessions' preset selector; you can also pick one from the recommendations below.",
257
+ "召唤失败": "Summon failed",
258
+ "召唤的专家是「预设」:新建会话时在预设选择器里选它才真正接管。想立刻让某个专家接管当前会话,用输入框左侧的召唤菜单。": "Summoned experts are presets: they take over only when you pick them in the preset selector of a new session. To have an expert take over the current session right away, use the summon menu at the left of the input box.",
259
+ "可以从这几位开始(目录前 {0} 位)": "Start with these (first {0} in the catalog)",
260
+ "可安装": "Installable",
261
+ "可见:": "Visible: ",
262
+ "可选": "Optional",
263
+ "可选值:": "Allowed values: ",
264
+ "可选项": "Options",
265
+ "合并(同名更新)": "Merge (update same names)",
266
+ "否": "No",
267
+ "否(补丁中 disabled: true)": "No (disabled: true in patch)",
268
+ "含 {0} 技能": "{0} skills included",
269
+ "含技能": "With skills",
270
+ "含需掩码字段:": "Contains fields needing masking: ",
271
+ "启动失败策略": "Start-failure policy",
272
+ "启用": "Enable",
273
+ "启用服务器 {0}": "Enable server {0}",
274
+ "命令": "Command",
275
+ "命令面板": "Command palette",
276
+ "团队型走 DSH 桥接:主理人人设接管会话,": "Team-type experts bridge through DSH: the lead's persona takes over the session, ",
277
+ "团队编制({0} 人 · 召唤后成员人设落盘,由主理人调度)": "Team roster ({0} members · personas land on disk after summoning; dispatched by the lead)",
278
+ "在 Skill 页查看 {0}": "View {0} on the Skill page",
279
+ "在 profile 里找不到这个插件,未升级。": "Plugin not found in the profile; not upgraded.",
280
+ "在不离开会话的情况下查看 MCP": "Inspect MCPs without leaving the session",
281
+ "在场 · {0} 会话": "Present · {0} sessions",
282
+ "在此项目可见": "Visible in this project",
283
+ "在此项目可见,点击屏蔽": "Visible in this project; click to block",
284
+ "复制 ": "Copy ",
285
+ "复制失败": "Copy failed",
286
+ "复制工具名": "Copy tool name",
287
+ "复制错误详情": "Copy error details",
288
+ "大体量": "Heavyweight",
289
+ "失败": "Failed",
290
+ "存在重复 serverName(冲突,只读)": "Duplicate serverName exists (conflict, read-only)",
291
+ "安全: ": "Security: ",
292
+ "安装": "Install",
293
+ "安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。": "Install = download file by file with sha256 verification into ~/.agents/skills/; DSH picks it up immediately (no restart). Reinstalling an installed version updates it.",
294
+ "安装中…": "Installing…",
295
+ "安装到全局": "Install globally",
296
+ "安装失败": "Install failed",
297
+ "宿主正在停止,未升级;请重启后再试。": "Host is shutting down; not upgraded. Restart and try again.",
298
+ "宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),本看板不可用": "Host has no skill registry mounted (@deepseek-ai/dsh-skill); this board is unavailable",
299
+ "密钥:": "Key: ",
300
+ "导入 MCP": "Import MCPs",
301
+ "导入 MCP(": "Import MCPs (",
302
+ "导入中…": "Importing…",
303
+ "导入失败": "Import failed",
304
+ "尚无会话持有该项目连接,或连接尚未就绪": "No session holds the project connection yet, or it isn't ready",
305
+ "尚未建立": "Not established yet",
306
+ "尚未建立连接(新会话自动挂载)": "No connection yet (new sessions attach automatically)",
307
+ "屏蔽未能写入会话作用域:": "Failed to write block into the session scope: ",
308
+ "展开工具清单": "Expand tool list",
309
+ "工作目录": "Working directory",
310
+ "工作目录 cwd(可选)": "Working directory cwd (optional)",
311
+ "工具": "Tools",
312
+ "工具使用账本:谁在被调用、谁在白缴上下文税。": "Tool usage ledger: who gets called, who pays dead context tax.",
313
+ "工具命名与其他 serverName 前缀重叠,无法可靠判断归属;请避免在 serverName 中使用双下划线。": "Tool names overlap other serverName prefixes; ownership can't be determined reliably. Avoid double underscores in serverName.",
314
+ "工具归属存在命名歧义": "Tool ownership is ambiguous",
315
+ "工具数 ≥ {0}:进详情默认折叠工具清单": "Tools ≥ {0}: tool list starts collapsed in details",
316
+ "工具裁缝": "Tool Tailor",
317
+ "工具调用超时(毫秒)": "Tool call timeout (ms)",
318
+ "工具账本加载失败;其余分类仍可正常使用。": "Failed to load the tool ledger; other categories keep working.",
319
+ "工具超时和最大尝试次数必须是正整数": "Tool timeout and max attempts must be positive integers",
320
+ "工具(": "Tools (",
321
+ "已保存 ": "Saved ",
322
+ "已停止": "Stopped",
323
+ "已写入 MCP 配置": "MCP config written",
324
+ "已刷新项目 MCP,共 ": "Refreshed project MCPs: ",
325
+ "已刷新,共 ": "Refreshed: ",
326
+ "已升级到 v": "Upgraded to v",
327
+ "已卸载": "Uninstalled",
328
+ "已取消屏蔽 ": "Unblocked ",
329
+ "已召唤": "Summoned",
330
+ "已召唤 · 未接管": "Summoned · not attached",
331
+ "已召唤专家列表加载失败;其余分类仍可正常使用。": "Failed to load summoned experts; other categories keep working.",
332
+ "已召唤专家详情": "Summoned expert details",
333
+ "已召唤的专家:人设、捆绑技能、当前在场状态,可送还。": "Summoned experts: persona, bundled skills, current presence; can be returned.",
334
+ "已启用 ": "Enabled ",
335
+ "已启用 {0}": "Enabled {0}",
336
+ "已在此项目屏蔽": "Blocked in this project",
337
+ "已在此项目屏蔽,点击恢复可见": "Blocked in this project; click to unblock",
338
+ "已复制": "Copied",
339
+ "已复制「{0}」": "Copied \"{0}\"",
340
+ "已复制到剪贴板": "Copied to clipboard",
341
+ "已复制,粘贴到输入框": "Copied; paste into the input box",
342
+ "已安装": "Installed",
343
+ "已导入 MCP": "MCPs imported",
344
+ "已屏蔽": "Blocked",
345
+ "已屏蔽 ": "Blocked ",
346
+ "已屏蔽:": "Blocked: ",
347
+ "已挂": "Attached",
348
+ "已挂专家:": "Attached expert: ",
349
+ "已添加 ": "Added ",
350
+ "已禁用": "Disabled",
351
+ "已禁用 ": "Disabled ",
352
+ "已禁用 {0}": "Disabled {0}",
353
+ "已移除 ": "Removed ",
354
+ "已脱敏": "Masked",
355
+ "已请求重连 ": "Reconnect requested ",
356
+ "已连接": "Connected",
357
+ "已连接 · ": "Connected · ",
358
+ "已连接 · 当前无会话使用(连接保留,下次直接用)": "Connected · no session using it right now (connection kept for next time)",
359
+ "已配置": "Configured",
360
+ "已重连 ": "Reconnected ",
361
+ "已隐藏 {0} 个不兼容条目": "Hidden {0} incompatible entries",
362
+ "市场": "Market",
363
+ "市场数据加载失败;其余分类仍可正常使用。": "Failed to load market data; other categories keep working.",
364
+ "市场:连接器、内置 MCP、SkillHub 技能与专家召唤。": "Market: connectors, built-in MCPs, SkillHub skills and expert summoning.",
365
+ "归属存疑": "Ownership uncertain",
366
+ "当前": "Current",
367
+ "当前 Web profile": "Current web profile",
368
+ "当前 Web profile 配置": "Current web profile config",
369
+ "当前 Web profile(可编辑)": "Current web profile (editable)",
370
+ "当前 profile 由应用托管,不允许在这里改动。": "The current profile is app-managed and can't be edited here.",
371
+ "当前在场": "Currently present",
372
+ "当前搜索或过滤条件下没有结果;清空关键词或把状态切回「全部状态」试试。": "No results under the current search/filter; clear the query or set the state filter back to 'All'.",
373
+ "当前有 {0} 个 skill,但都被筛选条挡住了。": "{0} skills exist, but all are filtered out.",
374
+ "当前生效专家(人设接管本会话;可在输入框工具行的专家菜单随时换/摘)": "Active expert (persona runs this session; swap/detach anytime from the expert menu on the input toolbar)",
375
+ "当前筛选下没有可安装的连接器": "No installable connectors under the current filter",
376
+ "当前筛选下没有服务": "No servers under the current filter",
377
+ "当前运行在 Node {0},请升级到 22.15 以上后重启宿主。": "Running Node {0}; upgrade to 22.15+ and restart the host.",
378
+ "必填": "Required",
379
+ "快速调用": "Quick invoke",
380
+ "总体错误率({0} 次失败)": "Overall error rate ({0} failures)",
381
+ "成员": "Members",
382
+ "成员产出回传主理人汇总。WorkBuddy 的 TeamCreate 无需调用 —— 团队编制在召唤时已固定。": "member output flows back to the lead for aggregation. WorkBuddy's TeamCreate needs no call — the roster is fixed at summon time.",
383
+ "或粘贴 JSON:支持 Claude/Cursor/Cline/Roo 的 mcpServers、VS Code 的 servers,以及单个 MCP 对象": "or paste JSON: supports mcpServers from Claude/Cursor/Cline/Roo, servers from VS Code, and a single MCP object",
384
+ "手动添加": "Add manually",
385
+ "打开": "Open",
386
+ "打开官方文档": "Open official docs",
387
+ "打开悬浮管理面板": "Open the floating panel",
388
+ "打开技能 · MCP · 专家看板": "Open the Skills · MCPs · Experts board",
389
+ "打开技能、MCP、专家快速调用菜单": "Open the skills/MCPs/experts quick-invoke menu",
390
+ "打开目录": "Open directory",
391
+ "打开看板的「": "Open the board's \"",
392
+ "执行": "Execute",
393
+ "扫描 {0} 个会话 · 读 {1} 个日志": "Scanned {0} sessions · read {1} logs",
394
+ "扫描中…": "Scanning…",
395
+ "扫描后给出": "After the scan: ",
396
+ "技能": "Skills",
397
+ "技能 / MCP / 专家 快速调用": "Skills / MCPs / Experts quick invoke",
398
+ "技能 · MCP · 专家": "Skills · MCPs · Experts",
399
+ "技能 · MCP · 专家看板": "Skills · MCPs · Experts board",
400
+ "技能 · MCP · 专家看板(可拖拽移动)": "Skills · MCPs · Experts board (draggable)",
401
+ "技能·专家": "Skills·Experts",
402
+ "技能按需触发:选择后以 /技能名 插入输入框": "Skills trigger on demand: picking inserts /skill-name into the input box",
403
+ "技能来自 SkillHub 公开 API,可能还没同步下来。": "Skills come from the public SkillHub API and may not have synced yet.",
404
+ "技能目录暂时是空的": "The skill catalog is empty for now",
405
+ "技能详情": "Skill details",
406
+ "把 skill 所在目录交给宿主在资源管理器中打开(只读定位)": "Ask the host to open the skill's directory in the file explorer (read-only locate)",
407
+ "挂到当前会话(可随时换或取消);再点别的专家 = 直接替换": "Attach to the current session (swap or detach anytime); clicking another expert replaces it",
408
+ "挂载失败": "Attach failed",
409
+ "挂载失败:": "Attach failed: ",
410
+ "挂载异常:": "Attach error: ",
411
+ "指令内容(": "Instruction body (",
412
+ "按传输过滤": "Filter by transport",
413
+ "按服务器看工具清单、连接状态与参数;可增删、重连、导入。": "Per-server tool list, connection status and parameters; add, remove, reconnect, import.",
414
+ "按状态过滤": "Filter by status",
415
+ "换个关键词试试,或清空搜索回到完整目录。": "Try another keyword, or clear the search to see the full catalog.",
416
+ "描述": "Description",
417
+ "提示:": "Tip: ",
418
+ "搜索": "Search",
419
+ "搜索 MCP…": "Search MCPs…",
420
+ "搜索 MCP、技能、专家,或跳转分类…": "Search MCPs, skills, experts, or jump to a category…",
421
+ "搜索 SkillHub 技能(19 万+)": "Search SkillHub skills (190k+)",
422
+ "搜索 skill": "Search skills",
423
+ "搜索 skill…": "Search skills…",
424
+ "搜索专家(花名 / 职业 / 描述 / 标签)": "Search experts (alias / role / description / tags)",
425
+ "搜索全局 MCP…": "Search global MCPs…",
426
+ "搜索工具名或描述…": "Search tool name or description…",
427
+ "搜索时自动在全库匹配(忽略分类)": "Search matches across all libraries (ignores category)",
428
+ "搜索服务、用途或提供方…": "Search services, purpose or provider…",
429
+ "搜索连接器(名称 / 说明 / ID)": "Search connectors (name / description / ID)",
430
+ "摘掉专家": "Detach expert",
431
+ "摘掉专家(切回默认预设)": "Detach expert (back to default preset)",
432
+ "操作": "Operations",
433
+ "收起原始 JSON": "Collapse raw JSON",
434
+ "收起参数": "Collapse parameters",
435
+ "收起工具清单": "Collapse tool list",
436
+ "文档": "Docs",
437
+ "断开后自动重连": "Auto-reconnect after disconnect",
438
+ "新增:": "New: ",
439
+ "新建会话时选择「WorkBuddy 专家 · ": "When creating a session, pick \"WorkBuddy Expert · ",
440
+ "新版本 v": "New version v",
441
+ "新版本与当前 DSH 不兼容,未升级。": "New version is incompatible with the current DSH; not upgraded.",
442
+ "无": "None",
443
+ "无捆绑技能": "No bundled skills",
444
+ "无捆绑技能(预设本体在 profile 补丁)": "No bundled skills (preset body lives in the profile patch)",
445
+ "无法确定要升级哪个包,未升级。": "Can't determine which package to upgrade; not upgraded.",
446
+ "是": "Yes",
447
+ "显示 ": "Show ",
448
+ "显示 {0} / {1}": "Show {0} / {1}",
449
+ "显示/隐藏 ": "Show/hide ",
450
+ "显示全部条目": "Show all entries",
451
+ "显示原值:": "Reveal value: ",
452
+ "暂无可选项": "No options yet",
453
+ "暂无工具": "No tools yet",
454
+ "暂无数据": "No data yet",
455
+ "更多操作": "More actions",
456
+ "更新中…": "Updating…",
457
+ "更新预设": "Update preset",
458
+ "更新:": "Update: ",
459
+ "替换会删除": "Replacing will remove",
460
+ "替换当前 Profile": "Replace current profile",
461
+ "替换该项目": "Replace this project",
462
+ "最后使用": "Last used",
463
+ "最多下载": "Most downloads",
464
+ "最多安装": "Most installs",
465
+ "最多星标": "Most stars",
466
+ "最大尝试次数": "Max attempts",
467
+ "最大延迟 ms": "Max delay ms",
468
+ "最新版本说明:": "Latest release notes: ",
469
+ "最近": "Recent",
470
+ "最近使用": "Recently used",
471
+ "服务器": "Servers",
472
+ "未知来源": "Unknown source",
473
+ "未知错误": "Unknown error",
474
+ "本会话没有挂着专家": "No expert attached to this session",
475
+ "本会话由该专家人设接管;点 × 摘掉(不动预设选择器)": "This session is run by that expert's persona; click × to detach (preset selector untouched)",
476
+ "本地 Skill 清单:来源、是否启用、是否可被模型调用。": "Local skill inventory: source, enabled state, model-invocable.",
477
+ "本地安装(": "Local install (",
478
+ "本次更新内容": "What's new in this release",
479
+ "本版本内不再提示": "Don't show again in this version",
480
+ "本页已安装 {0} / {1}": "Installed on this page {0} / {1}",
481
+ "本页没有已安装的技能": "No installed skills on this page",
482
+ "来自 mcp-client 最近日志": "From recent mcp-client logs",
483
+ "来自其他配置层,只读": "From another config layer, read-only",
484
+ "构建授权已过期,未升级;请重试。": "Build authorization expired; not upgraded. Please retry.",
485
+ "查看": "View",
486
+ "查看原始 JSON": "View raw JSON",
487
+ "查看参数": "View parameters",
488
+ "查看更新方式": "See how to update",
489
+ "模型可调用": "Model-invocable",
490
+ "模型可调用:": "Model-invocable: ",
491
+ "正在下载并校验 {0}…": "Downloading and verifying {0}…",
492
+ "正在升级到 v": "Upgrading to v",
493
+ "正在卸载 {0}…": "Uninstalling {0}…",
494
+ "正在召唤 {0}{1}…": "Summoning {0}{1}…",
495
+ "正在召唤 {0}…": "Summoning {0}…",
496
+ "正在安装 {0}…": "Installing {0}…",
497
+ "正在扫描本机客户端配置…": "Scanning local client configs…",
498
+ "正在拉取人设与技能清单…": "Fetching persona and skill list…",
499
+ "正在拉取详情…": "Fetching details…",
500
+ "正在挂载专家…": "Attaching expert…",
501
+ "正在读取 MCP 目录…": "Reading MCP catalog…",
502
+ "正在读取…": "Reading…",
503
+ "正在读取专家目录…": "Reading expert catalog…",
504
+ "正在读取已召唤专家…": "Reading summoned experts…",
505
+ "正在读取技能目录…": "Reading skill catalog…",
506
+ "正在读取清单…": "Reading inventory…",
507
+ "正在读取详情…": "Reading details…",
508
+ "正在读取连接器目录…": "Reading connector catalog…",
509
+ "正在送还 {0}…": "Returning {0}…",
510
+ "此 MCP 由全局配置提供。要改它的连接配置,请切到「全局」标签页;在这里只能决定它对本项目是否可见。": "This MCP comes from the global config. To edit its connection, switch to the 'Global' tab; here you can only toggle its visibility to this project.",
511
+ "此参数含密钥,已脱敏;需要时可在「编辑」里显式查看": "This parameter holds a key and is masked; reveal it explicitly in 'Edit' if needed",
512
+ "此项目的 MCP": "This project's MCPs",
513
+ "此项目还没有 MCP。用右上角「添加项目 MCP」或「市场」写入 .dsh/mcp.json": "No MCPs in this project yet. Use 'Add project MCP' (top right) or the 'Market' to write .dsh/mcp.json",
514
+ "每位成员的人设落盘到 ~/.dsh/wb-market/experts/teams/ 下,主理人按 SOP 依次用 subagent 调度(人设正文注入 prompt),": "Each member's persona lands under ~/.dsh/wb-market/experts/teams/; the lead dispatches members in SOP order via subagent (persona body injected into the prompt), ",
515
+ "每轮请求白缴的上下文税": "Context tax paid per request",
516
+ "没有专家正在接管会话": "No expert is running the session",
517
+ "没有全局 MCP": "No global MCPs",
518
+ "没有匹配「{0}」的工具": "No tools matching \"{0}\"",
519
+ "没有匹配的 MCP": "No matching MCPs",
520
+ "没有匹配的专家,换个关键词或分类试试": "No matching experts; try another keyword or category",
521
+ "没有匹配的全局 MCP": "No matching global MCPs",
522
+ "没有匹配项": "No matches",
523
+ "没有可导入的 MCP": "No importable MCPs",
524
+ "没有搜到「{0}」相关的技能": "No skills found for \"{0}\"",
525
+ "没有活动会话:请先进入一个会话再挂专家": "No active session: enter a session before attaching an expert",
526
+ "没有符合筛选条件的 skill": "No skills match the filters",
527
+ "没检测到本机客户端的全局 MCP 配置(~/.claude.json / ~/.codex/config.toml / ~/.config/opencode/opencode.json 等),可在下面手动粘贴": "No global MCP config detected from local clients (~/.claude.json / ~/.codex/config.toml / ~/.config/opencode/opencode.json etc.); paste it manually below",
528
+ "没检测到这个项目的项目级 MCP 配置(.mcp.json / .codex/config.toml / .pi/mcp.json / opencode.json 等),可在下面手动粘贴": "No project-level MCP config detected for this project (.mcp.json / .codex/config.toml / .pi/mcp.json / opencode.json etc.); paste it manually below",
529
+ "添加": "Add",
530
+ "添加 MCP": "Add MCP",
531
+ "添加一个 HTTP 或 stdio 服务器,或在「市场」里挑选现成的连接器。": "Add an HTTP or stdio server, or pick a ready-made connector in the 'Market'.",
532
+ "添加失败": "Add failed",
533
+ "添加项目 MCP": "Add project MCP",
534
+ "清空": "Clear",
535
+ "清空搜索": "Clear search",
536
+ "清除全部筛选": "Clear all filters",
537
+ "清除来源筛选": "Clear source filter",
538
+ "点击切换到此分类": "Click to switch to this category",
539
+ "版本号不合法,未升级。": "Invalid version; not upgraded.",
540
+ "状态未知": "State unknown",
541
+ "环境变量": "Environment variables",
542
+ "环境变量 ": "Environment variable ",
543
+ "环境变量未设置:": "Environment variable not set: ",
544
+ "环境变量(可选,值写 !!js process.env.KEY 引用密钥)": "Environment variables (optional; use !!js process.env.KEY for secrets)",
545
+ "用户 ~/.agents/skills": "User ~/.agents/skills",
546
+ "用户 ~/.dsh/skills": "User ~/.dsh/skills",
547
+ "用户可调用": "User-invocable",
548
+ "用法示例": "Usage example",
549
+ "目录来自 WorkBuddy 官方分发,可能还没同步下来;重读一次通常就好。": "The catalog comes from official WorkBuddy distribution and may not have synced; re-reading usually fixes it.",
550
+ "目录由宿主侧维护,可能还没同步下来;重读一次通常就好。": "The catalog is maintained host-side and may not have synced; re-reading usually fixes it.",
551
+ "目录里有 {0} 个服务,都被关键词、分类或「只要免密钥」筛掉了。": "The catalog has {0} servers, all filtered out by keyword, category or 'Keyless only'.",
552
+ "目录里有 {0} 个条目,「只看可直接安装」把不具备传输类型的挡掉了。": "The catalog has {0} entries; 'Directly installable only' hides the ones without a transport type.",
553
+ "看板分类": "Board categories",
554
+ "看这次改了什么": "See what changed",
555
+ "知道了": "Got it",
556
+ "确认从来源导入": "Confirm import from source",
557
+ "确认导入": "Confirm import",
558
+ "确认移除 MCP「": "Confirm removal of MCP \"",
559
+ "确认送还": "Confirm return",
560
+ "禁用": "Disable",
561
+ "禁用服务器 {0}": "Disable server {0}",
562
+ "移除": "Remove",
563
+ "移除:": "Remove: ",
564
+ "窗口内没有零调用的工具(共 {0} 个工具都有调用,关掉「只看零调用」即可看全部)": "No zero-call tools in the window (all {0} tools have calls; turn off 'Zero-call only' to see everything)",
565
+ "窗口内调用": "Calls in window",
566
+ "第 {0} / {1} 页 · 共 {2} 个技能": "Page {0} / {1} · {2} skills",
567
+ "第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。": "Third-party API fields may change upstream; verify transport type and credential requirements in details before installing.",
568
+ "筛选": "Filters",
569
+ "管理": "Manage",
570
+ "综合评分": "Overall score",
571
+ "编辑": "Edit",
572
+ "编辑 MCP": "Edit MCP",
573
+ "自动重连": "Auto-reconnect",
574
+ "自定义目录": "Custom directory",
575
+ "裁缝建议:": "Tailor suggestions: ",
576
+ "装 {0}": "Install {0}",
577
+ "解析中…": "Parsing…",
578
+ "解析并预览": "Parse and preview",
579
+ "认证": "Authentication",
580
+ "记录错误并继续": "Log the error and continue",
581
+ "试试这样问": "Try asking like this",
582
+ "该 MCP 来自其他配置层,配置只读,无法查看具体值": "This MCP comes from another config layer and is read-only; exact values can't be shown",
583
+ "该专家以独立 Agent 预设生效:新建会话时在预设选择器选择「WorkBuddy 专家 · …」即专家接管。送还会同时清掉它的捆绑技能。": "This expert works as an independent agent preset: pick \"WorkBuddy Expert · …\" in a new session's preset selector to hand over. Returning also clears its bundled skills.",
584
+ "该包正在被使用,未升级。": "The package is in use; not upgraded.",
585
+ "该包没有声明组合包,不能作为插件安装。": "The package declares no bundle; it can't be installed as a plugin.",
586
+ "该项目": "This project",
587
+ "详情": "Details",
588
+ "请使用 {0} 的 MCP 工具(mcp__{1}__*)处理接下来的任务": "Use {0}'s MCP tools (mcp__{1}__*) for the tasks ahead",
589
+ "请填写 MCP 名称": "Please fill in the MCP name",
590
+ "读取 MCP 目录失败": "Failed to read the MCP catalog",
591
+ "读取 skill 内容…": "Reading skill content…",
592
+ "读取 skill 内容失败": "Failed to read skill content",
593
+ "读取 skill 列表失败": "Failed to read the skill list",
594
+ "读取专家目录失败": "Failed to read the expert catalog",
595
+ "读取中…": "Reading…",
596
+ "读取内置 MCP 目录": "Read the built-in MCP catalog",
597
+ "读取已召唤专家失败": "Failed to read summoned experts",
598
+ "读取技能目录失败": "Failed to read the skill catalog",
599
+ "读取本机客户端配置失败": "Failed to read local client configs",
600
+ "读取详情失败": "Failed to read details",
601
+ "读取连接器目录失败": "Failed to read the connector catalog",
602
+ "读取配置失败": "Failed to read config",
603
+ "调用": "Calls",
604
+ "调用 {0}": "Call {0}",
605
+ "调用 {0} · 错误 {1}": "Calls {0} · errors {1}",
606
+ "调用 {0} 次": "Called {0} times",
607
+ "调用失败": "Call failed",
608
+ "调用策略": "Invocation policy",
609
+ "调用超时": "Call timeout",
610
+ "账本加载失败:": "Ledger load failed: ",
611
+ "跳转": "Go",
612
+ "运行时注册": "Runtime-registered",
613
+ "运行策略": "Run policy",
614
+ "近 30 天": "Last 30 days",
615
+ "近 30 天调用 {0} 次": "Called {0} times in the last 30 days",
616
+ "近 30 天:调用 {0} 次,失败 {1} 次": "Last 30 days: {0} calls, {1} failures",
617
+ "近 7 天": "Last 7 days",
618
+ "近 90 天": "Last 90 days",
619
+ "近 {0} 天每日调用": "Daily calls over the last {0} days",
620
+ "近 {0} 天每日调用量(左旧右新)": "Daily call volume over the last {0} days (older on the left)",
621
+ "还有 {0} 个作用域": "{0} more scopes",
622
+ "还没有 MCP 服务器": "No MCP servers yet",
623
+ "还没有召唤任何专家": "No experts summoned yet",
624
+ "这一项无法被单独改动(随 DSH 提供)。": "This entry can't be changed on its own (ships with DSH).",
625
+ "这个作用域下没有发现 skill": "No skills found in this scope",
626
+ "这个面板加载失败": "This panel failed to load",
627
+ "这些变化你可能想知道": "Changes you may want to know about",
628
+ "这些条目缺少传输类型等必要信息,本面板无法生成配置": "These entries lack required info like transport type; this panel can't generate config for them",
629
+ "这条配置里没有可导入的 MCP": "This config has no importable MCPs",
630
+ "这条预设是旧版本召唤写入的,人设缺 DSH 桥接段——用它的新会话开不出成员子智能体。点此直接更新。": "This preset was written by an older summon and its persona lacks the DSH bridge section — new sessions using it can't spawn member subagents. Click to update now.",
631
+ "连接中…": "Connecting…",
632
+ "连接信息": "Connection info",
633
+ "连接器": "Connectors",
634
+ "连接器 / 技能 / 专家均为第三方分发,本面板只做浏览与安装入口;内容版权归原作者所有。": "Connectors / skills / experts are third-party distributions; this panel only browses and installs. Content belongs to its authors.",
635
+ "连接器来自 WorkBuddy 官方分发(static.workbuddy.cn);技能来自 SkillHub 公开 API;专家来自 WorkBuddy 专家中心分发。": "Connectors come from official WorkBuddy distribution (static.workbuddy.cn); skills from the public SkillHub API; experts from the WorkBuddy expert center.",
636
+ "连接器目录暂时是空的": "The connector catalog is empty for now",
637
+ "连接器详情": "Connector details",
638
+ "连接失败": "Connection failed",
639
+ "连接状态": "Connection status",
640
+ "连接:": "Connection: ",
641
+ "送还": "Return",
642
+ "送还后此人设从所有会话移除,确认送还?": "Returning removes this persona from all sessions. Confirm return?",
643
+ "送还失败": "Return failed",
644
+ "选择": "Select",
645
+ "选择一个 MCP 查看详情": "Pick an MCP to see details",
646
+ "选择预设模板": "Pick a preset template",
647
+ "配置来源": "Config source",
648
+ "配置项": "Config entries",
649
+ "配置(只读,不修改它们的文件)": "Config (read-only; their files are not modified)",
650
+ "重扫": "Rescan",
651
+ "重新扫描": "Rescan",
652
+ "重新读取服务器与工具清单": "Re-read servers and tool list",
653
+ "重新读取目录": "Re-read the catalog",
654
+ "重试": "Retry",
655
+ "重连": "Reconnect",
656
+ "重连延迟必须是有效正数且不超过 2147483647 ms": "Reconnect delay must be a positive number, at most 2147483647 ms",
657
+ "重连本项目共享连接(拆掉旧连接并按当前配置重建,不影响其他会话)": "Reconnect this project's shared connection (tears down the old one and rebuilds from current config; other sessions unaffected)",
658
+ "错误 (率)": "Errors (rate)",
659
+ "错误码 ": "Error code ",
660
+ "阻止插件启动": "Block plugin startup",
661
+ "随 DSH 分发": "Ships with DSH",
662
+ "隐藏 ": "Hide ",
663
+ "隐藏原值:": "Hide value: ",
664
+ "零调用": "Zero-call",
665
+ "零调用工具": "Zero-call tools",
666
+ "零调用的 MCP 工具 · 点击下钻": "Zero-call MCP tools · click to drill down",
667
+ "零调用的 MCP 工具(已筛选)": "Zero-call MCP tools (filtered)",
668
+ "需上游提供传输类型": "Needs upstream transport type",
669
+ "需填参数": "Needs arguments",
670
+ "需密钥": "Needs a key",
671
+ "需更新 · 点击更新": "Update available · click to update",
672
+ "需连接信息": "Needs connection info",
673
+ "项目 .agents/skills": "Project .agents/skills",
674
+ "项目 .dsh/skills": "Project .dsh/skills",
675
+ "项目级": "Project-level",
676
+ "项目配置": "Project config",
677
+ "项目配置 .dsh/mcp.json(可编辑,本项目会话共享一份连接)": "Project config .dsh/mcp.json (editable; this project's sessions share one connection set)",
678
+ "预览": "Preview",
679
+ "预览失败": "Preview failed",
680
+ "首次延迟 ms": "First delay ms",
681
+ "首次延迟不能大于最大延迟": "First delay can't exceed max delay",
682
+ "首次连接失败时阻止插件启动": "Block plugin startup on first-connect failure",
683
+ "高错误率": "High error rate",
684
+ "默认值:": "Default: ",
685
+ "默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目": "By default only directly installable entries show; uncheck to see the incompatible 'display only' ones",
686
+ "(": " (",
687
+ "({0})": " ({0})",
688
+ "(仅 SKILL.md)": " (SKILL.md only)",
689
+ "(共 {0},已筛选)": " ({0} total, filtered)",
690
+ "(写入召唤预设 + 落盘成员人设)": " (writes the summon preset + lands member personas)",
691
+ "(刷新旧版预设行的人设)": " (refreshes the persona of an outdated preset row)",
692
+ "(导入后在详情页可用眼睛查看)": " (view with the eye icon on the details page after import)",
693
+ "(导入后该处会得到空值)": " (that field will be empty after import)",
694
+ "(已认证)": " (authenticated)",
695
+ "(当前 v": " (current v",
696
+ "(无描述)": " (no description)",
697
+ "(未知版本)": " (unknown version)",
698
+ "(本会话全局生效)": " (applies globally in this session)",
699
+ "(本机可完整安装)": " (fully installable locally)",
700
+ "(配置无效:": " (invalid config: ",
701
+ ")": ")",
702
+ ")。": ").",
703
+ ")请手动更新后重启": ") update manually and restart",
704
+ "),一键升级会把本地副本换成 npm 包,故只提示": "); one-click upgrade would replace the local copy with the npm package, so notice only",
705
+ ",": ", ",
706
+ ",对运行中的会话立即生效": ", effective immediately for running sessions",
707
+ ",重启宿主后生效": ", effective after restarting the host",
708
+ ":已启用": ": enabled",
709
+ ":已禁用": ": disabled",
710
+ "成员由主理人用 subagent 调度;若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设": "Members are dispatched by the lead via subagent; if this session has no subagent tool, create a new session and pick the \"WorkBuddy Expert · {0}\" preset",
711
+ "今日": "Today",
712
+ "账本缓存": "Ledger cache",
713
+ "清空缓存": "Clear cache",
714
+ "正在清空缓存…": "Clearing cache…",
715
+ "已清空缓存,释放 {0}": "Cache cleared, {0} freed",
716
+ " 条": " items",
717
+ "": "",
718
+ "&": "&",
719
+ "@^": "@^",
720
+ "Skill": "Skill",
721
+ "conversation.input.right": "conversation.input.right",
722
+ "conversation.session.header.actions": "conversation.session.header.actions",
723
+ "disable": "disable",
724
+ "enable": "enable",
725
+ "http": "http",
726
+ "layout": "layout",
727
+ "main": "main",
728
+ "reconnect": "reconnect",
729
+ "reconnectWorkspaceServer": "reconnectWorkspaceServer",
730
+ "remote.mcpManager": "remote.mcpManager",
731
+ "removeServer": "removeServer",
732
+ "removeWorkspaceServer": "removeWorkspaceServer",
733
+ "setWorkspaceExclude": "setWorkspaceExclude",
734
+ "shell.overlay": "shell.overlay",
735
+ "sidebar.footer.action": "sidebar.footer.action",
736
+ "sidebar.panellist": "sidebar.panellist",
737
+ "stdio": "stdio",
738
+ "style": "style",
739
+ "textarea": "textarea",
740
+ "updateWorkspaceServer": "updateWorkspaceServer",
741
+ "{0}{1}": "{0}{1}",
742
+ "收起": "Collapse",
743
+ "展开全文({0} 字符)": "Expand full text ({0} characters)",
744
+ "显示全部 {0} 条": "Show all {0}",
745
+ "连接诊断": "Connection diagnostics",
746
+ "重新读取宿主的连接表": "Re-read the host connection table",
747
+ "宿主连接表里没有这台服务器的记录:它当前没有被任何会话建立连接,也没有最近的回收记录。": "No record of this server in the host connection table: no session currently holds a connection to it, and there is no recent teardown.",
748
+ "(会话槽 {0})": " (slot {0})",
749
+ "会话槽": "Session slot",
750
+ "{0} 个会话共用这条连接": "{0} sessions share this connection",
751
+ "{0} 个引用": "{0} references",
752
+ "空闲回收": "Idle reclaim",
753
+ "已空闲超过 {0} 秒,正在等待回收": "Idle for over {0}s, waiting to be reclaimed",
754
+ "空闲 {0} 秒后回收": "Reclaimed after {0}s idle",
755
+ "上次回收": "Last teardown",
756
+ "配置": "Config",
757
+ "配置文件已改动,下次建连才生效": "Config file changed; takes effect on next connect",
758
+ "同名项目": "Same-name projects",
759
+ "拆掉这条连接并按当前配置重建,不影响其他会话": "Tear down this connection and rebuild it from the current config; other sessions are unaffected",
760
+ "重连这台服务器": "Reconnect this server",
761
+ "已就绪": "Ready",
762
+ "建连中": "Connecting",
763
+ "释放中": "Disposing",
764
+ "上次回收 {0} ms": "Last teardown {0} ms",
765
+ "上次回收 {0} ms(超时)": "Last teardown {0} ms (timed out)",
766
+ "回收中 {0} ms": "Tearing down {0} ms",
767
+ "\n…(预览截断)": "\n…(preview truncated)",
768
+ "\n…(预览截断,召唤时写入完整正文)": "\n…(preview truncated; the full text is written when you summon)",
769
+ "安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。": "Install merges that connector's mcpServers into the current scope (same pipeline as Import JSON; names that already exist are skipped).",
770
+ "第 {0} / {1} 页": "Page {0} / {1}",
771
+ "跳到": "Go to",
772
+ "页": "page",
773
+ "跳转到指定页": "Jump to a specific page",
774
+ "共 {0} 条": "{0} total",
775
+ "免认证": "No auth",
776
+ "这个看板是只读的:宿主只提供 listSkills / skillDetail,没有启用/禁用或修改入口;要改内容请直接编辑 SKILL.md。": "This board is read-only: the host only exposes listSkills / skillDetail, with no enable/disable or edit endpoint. To change a skill, edit its SKILL.md directly.",
777
+
778
+ "这 {0} 个 MCP 都需要交互取值,无法直接导入": "These {0} MCP servers all require interactive input and cannot be imported directly",
779
+ "(另有 {0} 个需要交互取值)": " ({0} more require interactive input)",
780
+ "不支持:": "Unsupported: ",
781
+ "可导入:": "Importable: ",
782
+ "需要在这里填写的交互输入:": "Interactive inputs to fill in: ",
783
+ "这些条目引用了编辑器交互输入(${input:…}),值在导入时拿不到": "These entries reference editor interactive inputs (${input:…}); the values are unavailable at import time",
784
+
785
+ ":": ": ",
786
+ };
787
+ // OPT-14 · 1.1:仅当判定为英文 locale 时装载 en-US 表;zh-CN(源语言)保持空字典
788
+ // 恒等直出,其他语言缺键也原样回落,不出现 key 名。
789
+ if (I18N_LOCALE && I18N_LOCALE.toLowerCase().startsWith('en')) setI18nMessages(EN_US_MESSAGES);
52
790
  const t = (s, vars) => {
53
791
  let text = i18nMessages[s] || s;
54
792
  if (vars && vars.length) {
@@ -113,6 +851,13 @@ migrateLegacyKeys(typeof localStorage === 'undefined' ? null : localStorage);
113
851
  - 等宽字体是 --ds-font-family-code(ui-theme/src/styles/base.css 的 :root 里定义的就是
114
852
  它,官方 51 处用它)。别用 --dsw-font-mono:那个名字全仓没有定义,只是个约定俗成的
115
853
  引用目标,用上它等于永远命中 fallback,等宽字形跟宿主代码块对不上。 */
854
+ // OPT-22(6.4 FAB 尺寸单源):FAB 尺寸常量提升到 MCP_CSS 之前——CSS 里 .dsh-mcp-fab
855
+ // 的外框尺寸/右下边距由这两个常量模板插值生成,拖拽 clamp/defaultFabPos 也按它们计算。
856
+ // 单一定义点,CSS 与 JS 不可能漂移(历史教训:CSS 写死 50px + content-box 渲染出 52px,
857
+ // JS 按 50px 夹取,右/下边缘差 2px;border-box + 插值后两侧恒等)。
858
+ const FAB_SIZE = 50;
859
+ const FAB_EDGE = 22;
860
+
116
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)}
117
862
  .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)}
118
863
  .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}
@@ -155,8 +900,11 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
155
900
  .dsh-mcp-item-sub{font-size:10.5px;color:var(--mcp-mut);display:flex;gap:6px;align-items:center}
156
901
  .dsh-mcp-badge{display:inline-flex;align-items:center;padding:1.5px 7px;border-radius:999px;font-size:10.5px;font-weight:500;line-height:16px;border:1px solid var(--mcp-line);color:var(--mcp-mut);background:var(--mcp-bg-1);corner-shape:round}
157
902
  .dsh-mcp-badge.dot::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px;opacity:.85;corner-shape:round}
158
- .dsh-mcp-badge.t-http{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
159
- .dsh-mcp-badge.t-stdio{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-tertiary)}
903
+ /* §1.3 色语:橙色的名额只留给「需注意」(不兼容 / 缺参数 / 有死工具)。
904
+ 传输类型是描述值、不是状态,此前 stdio 独占橙底会让人误读成「这台有问题」。
905
+ 改成中性底 + 等宽字体:等宽本身就是「这是技术标识」的信号,与状态胶囊区分开,
906
+ 也把橙/绿/红三色彻底还给状态语义。 */
907
+ .dsh-mcp-badge.t-http,.dsh-mcp-badge.t-stdio{color:var(--mcp-mut);border-color:var(--mcp-line);background:var(--mcp-bg-3);font-family:var(--mcp-mono);font-size:10px;letter-spacing:.2px}
160
908
  .dsh-mcp-badge.s-on{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-tertiary)}
161
909
  .dsh-mcp-badge.s-on.dot::before{background:var(--dsw-alias-state-success-primary)}
162
910
  .dsh-mcp-badge.s-err.dot::before{background:var(--dsw-alias-state-error-primary)}
@@ -256,6 +1004,11 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
256
1004
  .dsh-mcp-wb-card-actions{display:flex;align-items:center;gap:8px;margin-top:auto;flex-wrap:wrap}
257
1005
  .dsh-mcp-wb-blocked{font-size:10.5px;color:var(--mcp-warn);line-height:1.4;min-width:0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
258
1006
  .dsh-mcp-wb-pager{display:flex;align-items:center;justify-content:center;gap:12px;padding:6px 0 2px}
1007
+ .dsh-mcp-page-jump{display:flex;align-items:center;gap:4px;font-size:11.5px;color:var(--mcp-mut)}
1008
+ .dsh-mcp-page-jump>input{width:48px;box-sizing:border-box;padding:2px 4px;font:inherit;text-align:center;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-3);color:inherit}
1009
+ .dsh-mcp-page-jump>input:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1010
+ .dsh-mcp-readonly-note{display:flex;align-items:flex-start;gap:5px;font-size:10.5px;line-height:1.5;color:var(--mcp-mut);padding:5px 7px;border-radius:var(--mcp-r-sm);background:var(--mcp-bg-3)}
1011
+ .dsh-mcp-readonly-note>svg{flex:none;margin-top:1px;opacity:.75}
259
1012
  .dsh-mcp-wb-modal{width:640px;max-width:92vw}
260
1013
  .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}
261
1014
  .dsh-mcp-wb-modal-note{font-size:11.5px;line-height:1.6;color:var(--mcp-mut)}
@@ -270,6 +1023,12 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
270
1023
  .dsh-mcp-advanced{margin-top:14px}.dsh-mcp-check{display:flex!important;align-items:center;gap:8px;margin:9px 0!important;color:var(--dsw-alias-label-primary)!important}.dsh-mcp-check input{width:auto!important}.dsh-mcp-number-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dsh-mcp-number-grid .dsh-mcp-field{margin-bottom:0}.dsh-mcp-import-preview{font-size:12px;line-height:1.6;color:var(--mcp-mut);white-space:pre-wrap}.dsh-mcp-import-json{min-height:220px;resize:vertical;font-family:var(--mcp-mono)!important}
271
1024
  .dsh-mcp-builtin-modal{box-sizing:border-box;width:700px;height:min(760px,84vh);display:flex;flex-direction:column;overflow:hidden}.dsh-mcp-builtin-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 10px;flex:none}.dsh-mcp-builtin-toolbar .dsh-mcp-check{margin:0!important;font-size:12px}.dsh-mcp-builtin-count{font-size:11.5px;color:var(--mcp-mut)}.dsh-mcp-builtin-list{display:flex;flex-direction:column;gap:8px;min-height:0;overflow-y:auto;padding-right:2px}.dsh-mcp-builtin-modal>.dsh-mcp-modal-actions{flex:none}.dsh-mcp-builtin-row{display:grid;grid-template-columns:18px minmax(0,1fr) minmax(74px,auto);gap:11px;align-items:start;padding:11px 12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:pointer;transition:border-color .12s,background .12s;flex:none}.dsh-mcp-builtin-row:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dsh-mcp-builtin-row.installed{cursor:default;background:var(--mcp-bg-2)}.dsh-mcp-builtin-row.installed:hover{border-color:var(--mcp-line);background:var(--mcp-bg-2)}.dsh-mcp-builtin-select{box-sizing:border-box;width:16px!important;height:16px!important;margin:2px 0 0!important;accent-color:var(--mcp-accent);cursor:pointer}.dsh-mcp-builtin-select:disabled{cursor:default}.dsh-mcp-builtin-main{min-width:0;display:flex;flex-direction:column;gap:5px}.dsh-mcp-builtin-name{font-size:13px;font-weight:650;overflow-wrap:anywhere}.dsh-mcp-builtin-summary{font-size:11.5px;color:var(--mcp-mut);line-height:1.45}.dsh-mcp-builtin-meta{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.dsh-mcp-builtin-access{font-size:10.5px;color:var(--mcp-mut)}.dsh-mcp-builtin-config{font-family:var(--mcp-mono);font-size:10.5px;line-height:1.45;color:var(--dsw-alias-brand-text);overflow-wrap:anywhere;white-space:normal}.dsh-mcp-builtin-status{min-width:0;text-align:right;font-size:11px;color:var(--mcp-mut);line-height:1.45;overflow-wrap:anywhere}.dsh-mcp-builtin-status.ready{color:var(--dsw-alias-state-success-primary)}.dsh-mcp-builtin-loading{padding:30px 12px;text-align:center;color:var(--mcp-mut);font-size:12px}
272
1025
  .dsh-mcp-source-box{margin:0 0 14px;flex:none}.dsh-mcp-source-list{display:flex;flex-direction:column;gap:6px;max-height:186px;overflow-y:auto;margin-top:7px}.dsh-mcp-source-row{box-sizing:border-box;display:flex;flex-direction:column;gap:3px;width:100%;padding:8px 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:1.45;text-align:left;cursor:pointer;transition:border-color .12s,background .12s}.dsh-mcp-source-row:hover:not(:disabled){border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dsh-mcp-source-row.sel{border-color:var(--mcp-accent);background:var(--mcp-bg-2)}.dsh-mcp-source-row:disabled{cursor:default;opacity:.7}.dsh-mcp-source-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.dsh-mcp-source-name{font-weight:600}.dsh-mcp-source-path{font-family:var(--mcp-mono);font-size:10.5px;color:var(--dsw-alias-brand-text);overflow-wrap:anywhere}.dsh-mcp-source-detail{font-size:11px;color:var(--mcp-mut);overflow-wrap:anywhere}.dsh-mcp-source-note{font-size:10.5px;color:var(--mcp-mut)}.dsh-mcp-source-warn{font-size:10.5px;color:var(--mcp-warn)}
1026
+ /* OPT-17 · 2.5:不支持分组的呈现(复用 source-row 的排版,只补两处色语与分组间距) */
1027
+ .dsh-mcp-source-unsupported{font-size:11px;line-height:1.5;color:var(--mcp-warn);overflow-wrap:anywhere}
1028
+ .dsh-mcp-import-group{display:flex;gap:6px;align-items:baseline;font-size:12px;line-height:1.6}
1029
+ .dsh-mcp-import-group>b{flex:0 0 auto;color:var(--mcp-mut)}
1030
+ .dsh-mcp-import-group>b.warn{color:var(--mcp-warn)}
1031
+ .dsh-mcp-import-group-note{font-size:11px;line-height:1.5;color:var(--mcp-mut);overflow-wrap:anywhere;margin-top:2px}
273
1032
  .dsh-mcp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--dsw-alias-toast-bg);backdrop-filter:blur(10px);border:0;color:var(--dsw-alias-label-primary);padding:10px 18px;border-radius:var(--mcp-r-lg);font-size:12.5px;z-index:300;box-shadow:var(--dsw-shadow-lv3);max-width:70vw;animation:mcpPop .16s ease-out}
274
1033
  .dsh-mcp-toast.err{border:1px solid var(--dsw-alias-state-error-primary)}
275
1034
  .dsh-mcp-toast.ok{border:1px solid var(--dsw-alias-state-success-primary)}
@@ -279,11 +1038,10 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
279
1038
  .dsh-mcp-toggle::after{content:"";position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:var(--dsw-alias-switch-thumb);transition:left .18s,background .18s;box-shadow:none;corner-shape:round}
280
1039
  .dsh-mcp-toggle[aria-checked='true']::after{left:17px;background:var(--dsw-alias-label-primary-foreground)}
281
1040
  .dsh-mcp-d-err{font-size:12.5px;color:var(--mcp-err);margin-top:8px;white-space:pre-wrap}
282
- /* 悬浮按钮:外框尺寸必须恰好是 JS 里 FAB_SIZE 的值(拖拽边界与默认位置按它算,
283
- client.js 的 clampFabPos/defaultFabPos)。border-box 让这个等式不受边框影响——
284
- 之前是 1px 边框 + content-box,渲染出 52px 而 JS 按 50px 夹取,右/下边缘会差 2px;
285
- 现在去掉边框(抬升表面改成 elevation 阴影)+ border-box,两边一致且不会再漂。 */
286
- .dsh-mcp-fab{position:fixed;right:22px;bottom:22px;box-sizing:border-box;width:50px;height:50px;border-radius:50%;border:0;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-inverted);cursor:grab;user-select:none;display:flex;align-items:center;justify-content:center;box-shadow:var(--dsw-elevation-panel);z-index:190;touch-action:none;transition:transform .16s,background .16s;pointer-events:auto;corner-shape:round}
1041
+ /* 悬浮按钮:外框尺寸由 JS 常量 FAB_SIZE/FAB_EDGE 模板插值生成(单源无漂移,
1042
+ 拖拽 clampFabPos/defaultFabPos 按同一常量计算,两侧恒等)。border-box 保留:
1043
+ 历史教训——1px 边框 + content-box 曾把 50px 渲染成 52px,与 JS 按 50px 夹取差 2px。 */
1044
+ .dsh-mcp-fab{position:fixed;right:${FAB_EDGE}px;bottom:${FAB_EDGE}px;box-sizing:border-box;width:${FAB_SIZE}px;height:${FAB_SIZE}px;border-radius:50%;border:0;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-inverted);cursor:grab;user-select:none;display:flex;align-items:center;justify-content:center;box-shadow:var(--dsw-elevation-panel);z-index:190;touch-action:none;transition:transform .16s,background .16s;pointer-events:auto;corner-shape:round}
287
1045
  .dsh-mcp-fab:hover{transform:scale(1.05);background:var(--dsw-alias-button-primary-hover)}
288
1046
  .dsh-mcp-fab:active{transform:scale(.97)}
289
1047
  .dsh-mcp-fab svg{width:22px;height:22px}
@@ -474,6 +1232,8 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
474
1232
  .dsh-mcp-skill-d-md{margin:0;border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-2);color:var(--dsw-alias-label-primary);font-family:var(--mcp-mono);font-size:12px;line-height:18px;padding:12px;white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:52vh}
475
1233
  .dsh-mcp-skill-d-path{display:inline-flex;align-items:center;gap:6px;border:0;border-radius:var(--mcp-r-sm);corner-shape:round;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:17px;font-family:var(--mcp-mono);padding:0;cursor:pointer;text-align:left;word-break:break-all}
476
1234
  .dsh-mcp-skill-d-path:hover{color:var(--dsw-alias-label-primary);text-decoration:underline}
1235
+ /* OPT-16/26 · 3.2/3.3:链接式按钮(展开全文/收起/显示全部),无底无边框、主题色小字 */
1236
+ .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}
477
1237
  .dsh-mcp-skill-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;padding:10px 2px}
478
1238
 
479
1239
  /* 工具裁缝(Tool Tailor):账本表格 + 税单卡 + 死工具清单。 */
@@ -503,6 +1263,10 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
503
1263
  .dsh-mcp-tailor-empty{color:var(--mcp-mut);font-size:13px;padding:18px 14px}
504
1264
  .dsh-mcp-tailor-note{border:0.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-2);padding:10px 14px;font-size:12.5px;line-height:19px;color:var(--mcp-mut);flex:none}
505
1265
  .dsh-mcp-tailor-note b{color:var(--dsw-alias-label-primary)}
1266
+ /* OPT-19 · 4.3:账本缓存占用行(小号弱化文本 + 行内清空按钮),贴在工具条下方。 */
1267
+ .dsh-mcp-tailor-cache{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11.5px;line-height:16px;color:var(--mcp-mut);padding:2px 2px 0;flex:none}
1268
+ .dsh-mcp-tailor-cache-label{color:var(--dsw-alias-label-tertiary)}
1269
+ .dsh-mcp-tailor-cache .dsh-mcp-tailor-cache-clear{height:22px;padding:0 10px;font-size:11px;line-height:20px;flex:none}
506
1270
  /* 解码告警(OPT-02):正常运行时整块不渲染,所以这里可以给足视觉权重。 */
507
1271
  .dsh-mcp-tailor-warn{border:0.5px solid var(--mcp-warn);border-radius:var(--mcp-r-md);corner-shape:round;background:color-mix(in srgb,var(--mcp-warn) 8%,transparent);padding:10px 14px;font-size:12.5px;line-height:19px;color:var(--dsw-alias-label-primary);flex:none;display:flex;flex-direction:column;gap:4px}
508
1272
  .dsh-mcp-tailor-warn b{color:var(--mcp-warn)}
@@ -517,6 +1281,317 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
517
1281
  .dsh-mcp-panel-error-act{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
518
1282
  .dsh-mcp-panel-error-act span{color:var(--mcp-mut);font-size:12px;line-height:18px}
519
1283
 
1284
+ /* ══ 优化项落地(armory-优化项.md 全量)═════════════════════════════════════
1285
+ 本节只做「信息层级 + 交互反馈 + 状态语义」三件事,视觉全部走宿主语义令牌:
1286
+ 实底 surface、1px 描边、--mcp-r-* 圆角、--dsw-elevation-* 阴影,不自造颜色与渐变。
1287
+ 四色状态语义(§1.3,全局唯一口径):
1288
+ 蓝 --mcp-accent = 交互/可选(不是"正常") 绿 --mcp-ok = 健康/已连接
1289
+ 橙 --mcp-warn = 注意/降级(含"不兼容") 红 --mcp-err = 破坏性/失败
1290
+ ─────────────────────────────────────────────────────────────────────────── */
1291
+
1292
+ /* ── §1.1 顶栏分类 chip:hover 摘要浮层 + 异常红点 ───────────────────────── */
1293
+ .dsh-mcp-cats{position:relative}
1294
+ .dsh-mcp-cat{position:relative;min-width:0;flex:none;white-space:nowrap}
1295
+ .dsh-mcp-cat:disabled{cursor:default;opacity:.6}
1296
+ /* 红点只给"需要注意"的分类,用绝对定位贴 chip 右上角,不参与布局、不影响宽度 */
1297
+ .dsh-mcp-cat-alert{position:absolute;top:3px;right:3px;box-sizing:border-box;width:6px;height:6px;border-radius:50%;background:var(--mcp-err);box-shadow:0 0 0 2px var(--dsw-alias-bg-base);corner-shape:round;pointer-events:none}
1298
+ .dsh-mcp-cat-alert.warn{background:var(--mcp-warn)}
1299
+ /* hover 摘要:贴住 chip 下方的实底白卡,标题+两三行事实清单,不抢正文焦点 */
1300
+ .dsh-mcp-cat-pop{position:absolute;top:calc(100% + 6px);left:0;z-index:70;box-sizing:border-box;min-width:216px;max-width:300px;padding:9px 11px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-panel);display:flex;flex-direction:column;gap:6px;pointer-events:none;animation:mcpFade .1s ease-out}
1301
+ .dsh-mcp-cat-pop> b{font-size:12px;font-weight:650;color:var(--dsw-alias-label-primary)}
1302
+ .dsh-mcp-cat-pop> span{font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-secondary)}
1303
+ .dsh-mcp-cat-pop-facts{display:flex;flex-direction:column;gap:3px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
1304
+ .dsh-mcp-cat-pop-facts i{font-style:normal;color:var(--dsw-alias-label-tertiary);margin-right:5px}
1305
+ .dsh-mcp-cat-pop-facts b{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums;font-weight:600}
1306
+ .dsh-mcp-cat-pop-foot{font-size:10.5px;color:var(--dsw-alias-label-tertiary);border-top:1px solid var(--mcp-line);padding-top:5px}
1307
+
1308
+ /* §1.2 作用域分段控件的样式只在下方声明一次(勿在此重复插入)。 */
1309
+
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
+ /* ── §1.4 空状态四件套:图标 + 一句结论 + 一句原因 + 一个主操作 ───────────────── */
1321
+ .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
+ .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}
1323
+ .dsh-mcp-empty-ico.warn{color:var(--mcp-warn);border-color:color-mix(in srgb,var(--mcp-warn) 40%,transparent);background:var(--dsw-alias-state-warn-tertiary)}
1324
+ .dsh-mcp-empty-ico.err{color:var(--mcp-err);border-color:color-mix(in srgb,var(--mcp-err) 40%,transparent);background:var(--dsw-alias-interactive-bg-hover-danger)}
1325
+ .dsh-mcp-empty>b{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);line-height:1.5}
1326
+ .dsh-mcp-empty>span{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary);max-width:min(440px,100%)}
1327
+ .dsh-mcp-empty-act{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center;margin-top:5px}
1328
+ .dsh-mcp-empty.tight{padding:18px 12px}
1329
+ .dsh-mcp-empty.tight .dsh-mcp-empty-ico{width:32px;height:32px}
1330
+
1331
+ /* ── §1.5 Ctrl+K 命令面板:一个输入框 + 分组结果 + ↑↓↵ 操作 ───────────────── */
1332
+ .dsh-mcp-cmdk-overlay{position:fixed;inset:0;z-index:320;background:var(--dsw-alias-bg-overlay);display:flex;align-items:flex-start;justify-content:center;padding:min(14vh,120px) 16px 16px;animation:mcpFade .12s ease-out}
1333
+ .dsh-mcp-cmdk{box-sizing:border-box;width:min(620px,96vw);max-height:min(60vh,520px);display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-panel);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-prominent);animation:mcpPop .14s cubic-bezier(.2,.9,.3,1.1)}
1334
+ .dsh-mcp-cmdk-head{display:flex;align-items:center;gap:9px;flex:none;padding:11px 14px;border-bottom:1px solid var(--mcp-line)}
1335
+ .dsh-mcp-cmdk-head svg{flex:none;opacity:.55}
1336
+ .dsh-mcp-cmdk-input{flex:1;min-width:0;box-sizing:border-box;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13.5px;outline:none;padding:0}
1337
+ .dsh-mcp-cmdk-input::placeholder{color:var(--dsw-alias-label-tertiary)}
1338
+ .dsh-mcp-cmdk-kbd{flex:none;font-family:var(--mcp-mono);font-size:10.5px;color:var(--dsw-alias-label-tertiary);border:1px solid var(--mcp-line);border-radius:var(--mcp-r-xs);padding:1px 5px;line-height:15px}
1339
+ .dsh-mcp-cmdk-list{flex:1 1 auto;min-height:0;overflow-y:auto;padding:5px}
1340
+ .dsh-mcp-cmdk-group{font-size:10.5px;font-weight:650;text-transform:uppercase;letter-spacing:.6px;color:var(--dsw-alias-label-tertiary);padding:7px 9px 4px}
1341
+ .dsh-mcp-cmdk-item{box-sizing:border-box;display:flex;align-items:center;gap:9px;width:100%;padding:7px 10px;border:1px solid transparent;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;line-height:18px;text-align:left;cursor:pointer;transition:background .1s}
1342
+ .dsh-mcp-cmdk-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
1343
+ .dsh-mcp-cmdk-item.on{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
1344
+ .dsh-mcp-cmdk-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
1345
+ .dsh-mcp-cmdk-item-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1346
+ .dsh-mcp-cmdk-item-desc{font-size:11px;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1347
+ .dsh-mcp-cmdk-item-hint{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
1348
+ .dsh-mcp-cmdk-foot{flex:none;display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:7px 14px;border-top:1px solid var(--mcp-line);background:var(--mcp-bg-2);font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
1349
+ .dsh-mcp-cmdk-foot b{font-family:var(--mcp-mono);font-weight:500;color:var(--dsw-alias-label-secondary)}
1350
+
1351
+ /* ── §2.1 列表行体量标记:让「170 个工具」和「20 个工具」不再视觉等重 ─────────── */
1352
+ /* 刻意做得比 Badge 更轻(无边框、灰底、小一号):它是量级注脚,不该和状态徽章抢注意力。 */
1353
+ .dsh-mcp-tag-heavy{flex:none;display:inline-flex;align-items:center;padding:0 5px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);font-size:9.5px;font-weight:600;line-height:14px;letter-spacing:.2px;white-space:nowrap}
1354
+
1355
+ /* ── §2.3 参数 chips:先看「几个」,再看「哪个被脱敏」 ──────────────────────── */
1356
+ .dsh-mcp-args{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
1357
+ .dsh-mcp-args-count{font-size:11px;color:var(--dsw-alias-label-secondary)}
1358
+ .dsh-mcp-args-note{font-size:10px;padding:0 5px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);line-height:15px}
1359
+ .dsh-mcp-arg-chip{font-family:var(--mcp-mono);font-size:11px;padding:1px 6px;border-radius:var(--mcp-r-xs);background:var(--mcp-bg-3);color:var(--dsw-alias-label-primary);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1360
+ /* 脱敏 chip 用虚线描边而不是换色:颜色已经归状态语义用,不再兼职表达「保密」。 */
1361
+ .dsh-mcp-arg-chip.masked{border:1px dashed var(--mcp-line);background:transparent;color:var(--dsw-alias-label-secondary);letter-spacing:1px}
1362
+
1363
+ /* ── §2.4 连接健康条:把四个最常被问到的结论提到最上层 ──────────────────────── */
1364
+ .dsh-mcp-health{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin-bottom:10px}
1365
+ .dsh-mcp-health-cell{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1)}
1366
+ .dsh-mcp-health-cell>span{font-size:10.5px;color:var(--dsw-alias-label-secondary);letter-spacing:.2px}
1367
+ .dsh-mcp-health-cell>b{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}
1368
+ .dsh-mcp-health-cell>b.ok{color:var(--mcp-ok)}
1369
+ .dsh-mcp-health-cell>b.warn{color:var(--mcp-warn)}
1370
+ .dsh-mcp-health-cell>b.err{color:var(--mcp-err)}
1371
+ .dsh-mcp-health-cell>b.mute{color:var(--dsw-alias-label-secondary);font-weight:500}
1372
+
1373
+ /* ── §2.2 工具区:搜索条 / 折叠摘要 / 热度数字 ──────────────────────────────── */
1374
+ .dsh-mcp-tools-bar{display:flex;align-items:center;gap:8px;margin-bottom:6px}
1375
+ .dsh-mcp-tools-bar .dsh-mcp-search{flex:1;margin:0}
1376
+ .dsh-mcp-tools-count{flex:none;font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
1377
+ .dsh-mcp-tools-collapsed{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px dashed var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-2);font-size:12px;color:var(--dsw-alias-label-secondary)}
1378
+ .dsh-mcp-tools-collapse-back{margin-top:8px}
1379
+ .dsh-mcp-tool-usage{flex:none;font-size:10.5px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);white-space:nowrap}
1380
+ .dsh-mcp-tool-usage.mute{opacity:.72}
1381
+ .dsh-mcp-tool-usage.bad{color:var(--mcp-err)}
1382
+
1383
+ /* ── §1.1 分类摘要浮层:白底描边,与宿主浮层同一套语言 ──────────────────────── */
1384
+ .dsh-mcp-cat-pop{position:absolute;top:calc(100% + 6px);z-index:60;width:250px;padding:10px 12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-shadow-popover,0 8px 24px rgba(16,32,48,.12));pointer-events:none;display:flex;flex-direction:column;gap:4px}
1385
+ .dsh-mcp-cat-pop>b{font-size:12.5px;color:var(--dsw-alias-label-primary)}
1386
+ .dsh-mcp-cat-pop>span{font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary)}
1387
+ .dsh-mcp-cat-pop-facts{display:flex;flex-wrap:wrap;gap:4px 12px;margin-top:2px}
1388
+ .dsh-mcp-cat-pop-facts>div{display:flex;align-items:baseline;gap:4px}
1389
+ .dsh-mcp-cat-pop-facts i{font-style:normal;font-size:10.5px;color:var(--dsw-alias-label-secondary)}
1390
+ .dsh-mcp-cat-pop-facts b{font-size:12.5px;color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}
1391
+ .dsh-mcp-cat-pop-foot{margin-top:2px;padding-top:6px;border-top:1px solid var(--mcp-line);font-size:10.5px;color:var(--dsw-alias-label-secondary)}
1392
+ /* 分类条需要成为浮层的定位参照,否则浮层会贴到最近的定位祖先上去。 */
1393
+ .dsh-mcp-cats{position:relative}
1394
+ /* 异常红点:绝对定位在 chip 右上角,不占据 flex 布局空间(否则切换有无异常时 chip 会抖动)。 */
1395
+ .dsh-mcp-cat{position:relative}
1396
+ .dsh-mcp-cat-alert{position:absolute;top:2px;right:2px;width:6px;height:6px;border-radius:999px;background:var(--mcp-err)}
1397
+ .dsh-mcp-cat-alert.warn{background:var(--mcp-warn)}
1398
+
1399
+ /* ── §1.2 作用域分段控件:连体分段,当前项实底反白 ──────────────────────────── */
1400
+ .dsh-mcp-seg{display:inline-flex;align-items:stretch;gap:2px;padding:2px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-2);overflow-x:auto;max-width:100%;scrollbar-width:none}
1401
+ .dsh-mcp-seg::-webkit-scrollbar{display:none}
1402
+ .dsh-mcp-seg-item{flex:none;white-space:nowrap;display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:0;border-radius:var(--mcp-r-md);background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;cursor:pointer;transition:background 120ms ease,color 120ms ease}
1403
+ .dsh-mcp-seg-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
1404
+ .dsh-mcp-seg-item[aria-selected="true"]{background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:0 1px 2px rgba(16,32,48,.08)}
1405
+ .dsh-mcp-seg-count{font-size:10px;padding:0 4px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);line-height:14px;font-variant-numeric:tabular-nums}
1406
+ .dsh-mcp-seg-item[aria-selected="true"] .dsh-mcp-seg-count{background:var(--mcp-accent);color:var(--dsw-alias-label-on-brand,#fff)}
1407
+ .dsh-mcp-seg-item:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
1408
+ @media (prefers-reduced-motion:reduce){.dsh-mcp-seg-item{transition:none}}
1409
+
1410
+ /* ── §2.2 工具清单:搜索 + 滚动容器 + 热度色条 ─────────────────────────────── */
1411
+ .dsh-mcp-tools-head{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:9px}
1412
+ .dsh-mcp-tools-head h4{margin:0!important;flex:0 0 auto}
1413
+ .dsh-mcp-tools-head h4::after{display:none!important}
1414
+ .dsh-mcp-tools-search{position:relative;flex:1 1 160px;min-width:130px;max-width:260px;margin-left:auto}
1415
+ .dsh-mcp-tools-search svg{position:absolute;left:8px;top:50%;transform:translateY(-50%);opacity:.45;pointer-events:none}
1416
+ .dsh-mcp-tools-search input{width:100%;box-sizing:border-box;padding:5px 9px 5px 26px;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:11.5px;outline:none;transition:border-color .13s}
1417
+ .dsh-mcp-tools-search input::placeholder{color:var(--dsw-alias-label-tertiary)}
1418
+ .dsh-mcp-tools-search input:focus{border-color:var(--mcp-accent)}
1419
+ .dsh-mcp-tools-count{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
1420
+ .dsh-mcp-tools-scroll{max-height:min(52vh,460px);overflow-y:auto;overscroll-behavior:contain}
1421
+ /* 热度色条:调用次数分四档(0/低/中/高),宽度按占比,0 档只留一条灰线 */
1422
+ .dsh-mcp-heat{flex:none;position:relative;width:34px;height:4px;border-radius:999px;background:var(--mcp-line);overflow:hidden;corner-shape:round}
1423
+ .dsh-mcp-heat>i{position:absolute;inset:0 auto 0 0;border-radius:999px;background:var(--dsw-alias-label-tertiary)}
1424
+ .dsh-mcp-heat.lv1>i{background:color-mix(in srgb,var(--mcp-accent) 45%,transparent)}
1425
+ .dsh-mcp-heat.lv2>i{background:var(--mcp-accent)}
1426
+ .dsh-mcp-heat.lv3>i{background:var(--mcp-accent)}
1427
+ .dsh-mcp-heat-num{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;min-width:26px;text-align:right}
1428
+
1429
+ /* ── §2.4 连接健康条:对/错一眼分明,不靠日志去猜 ───────────────────────────── */
1430
+ .dsh-mcp-health{display:flex;align-items:stretch;gap:0;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);overflow:hidden}
1431
+ .dsh-mcp-health-cell{flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:2px;padding:8px 11px;border-left:1px solid var(--mcp-line)}
1432
+ .dsh-mcp-health-cell:first-child{border-left:0}
1433
+ .dsh-mcp-health-cell>span{font-size:10.5px;color:var(--dsw-alias-label-tertiary);line-height:15px}
1434
+ .dsh-mcp-health-cell>b{font-size:12.5px;font-weight:600;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
1435
+ .dsh-mcp-health-cell>b.ok{color:var(--dsw-alias-state-success-primary)}
1436
+ .dsh-mcp-health-cell>b.err{color:var(--mcp-err)}
1437
+ .dsh-mcp-health-cell>b.warn{color:var(--dsw-alias-state-warn-primary)}
1438
+ .dsh-mcp-health-cell>b.mute{color:var(--dsw-alias-label-tertiary)}
1439
+ /* §2.1 连接诊断(OPT-28):诊断卡的标题行、读数着色与动作行。 */
1440
+ .dsh-mcp-diag-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
1441
+ .dsh-mcp-diag-head>h4{margin:0}
1442
+ .dsh-mcp-diag-refresh{font-size:11.5px;line-height:1;padding:4px 8px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
1443
+ .dsh-mcp-diag-refresh:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
1444
+ .dsh-mcp-diag-refresh:disabled{opacity:.55;cursor:default}
1445
+ .dsh-mcp-diag-note{font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
1446
+ .dsh-mcp-diag-bad{color:var(--mcp-err)}
1447
+ .dsh-mcp-diag-ok{color:var(--dsw-alias-state-success-primary)}
1448
+ .dsh-mcp-diag-acts{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}
1449
+ .dsh-mcp-diag-act{font-size:12px;padding:4px 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);cursor:pointer}
1450
+ .dsh-mcp-diag-act:hover:not(:disabled){border-color:var(--dsw-alias-label-secondary)}
1451
+ .dsh-mcp-diag-act:disabled{opacity:.55;cursor:default}
1452
+
1453
+ /* ── §5 工具裁缝:主指标 hero + 列重排 + sticky 首列 + 行尾 ⋯ 菜单 + sparkline ── */
1454
+ .dsh-mcp-tailor-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);gap:10px;flex:none;align-items:stretch}
1455
+ .dsh-mcp-tailor-main{display:flex;flex-direction:column;gap:2px;justify-content:center;padding:13px 16px;border:1px solid color-mix(in srgb,var(--mcp-accent) 30%,transparent);border-radius:var(--mcp-r-lg);background:var(--dsw-alias-interactive-bg-hover-accent)}
1456
+ .dsh-mcp-tailor-main>b{font-size:31px;font-weight:650;line-height:36px;letter-spacing:-.6px;color:var(--dsw-alias-brand-text);font-variant-numeric:tabular-nums;word-break:break-word}
1457
+ .dsh-mcp-tailor-main>b em{font-size:13px;font-weight:500;font-style:normal;letter-spacing:0;color:var(--dsw-alias-label-secondary);margin-left:5px}
1458
+ .dsh-mcp-tailor-main>span{font-size:11px;font-weight:600;letter-spacing:.4px;text-transform:uppercase;color:var(--dsw-alias-brand-text)}
1459
+ .dsh-mcp-tailor-main>span.dsh-mcp-tailor-main-sub{font-size:11.5px;font-weight:400;letter-spacing:0;text-transform:none;line-height:1.55;color:var(--dsw-alias-label-secondary)}
1460
+ .dsh-mcp-tailor-trend{display:flex;align-items:center;gap:7px;margin-top:6px;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
1461
+ .dsh-mcp-tailor-trend .dsh-mcp-spark{height:16px}
1462
+ .dsh-mcp-tailor-kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
1463
+ .dsh-mcp-tailor-kpi{box-sizing:border-box;display:flex;flex-direction:column;gap:2px;justify-content:center;padding:9px 12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-2);font:inherit;text-align:left}
1464
+ .dsh-mcp-tailor-kpi>b{font-size:17px;font-weight:600;line-height:21px;font-variant-numeric:tabular-nums}
1465
+ .dsh-mcp-tailor-kpi>span{font-size:10.5px;line-height:1.45;color:var(--dsw-alias-label-tertiary)}
1466
+ .dsh-mcp-tailor-kpi.dead>b{color:var(--dsw-alias-state-warn-primary)}
1467
+ .dsh-mcp-tailor-kpi.err>b{color:var(--mcp-err)}
1468
+ .dsh-mcp-tailor-kpi.ok>b{color:var(--dsw-alias-state-success-primary)}
1469
+ .dsh-mcp-tailor-kpi.clickable{cursor:pointer;transition:background .12s,border-color .12s}
1470
+ .dsh-mcp-tailor-kpi.clickable:hover{border-color:color-mix(in srgb,var(--mcp-warn) 55%,transparent);background:color-mix(in srgb,var(--mcp-warn) 8%,var(--mcp-bg-2))}
1471
+ .dsh-mcp-tailor-kpi.clickable[aria-pressed="true"]{border-color:var(--mcp-warn);background:color-mix(in srgb,var(--mcp-warn) 13%,var(--mcp-bg-2))}
1472
+ .dsh-mcp-tailor-kpi.clickable:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1473
+ @media (max-width:900px){.dsh-mcp-tailor-hero{grid-template-columns:minmax(0,1fr)}}
1474
+ /* 账本表格:首列 sticky 不随横向滚动跑掉;行尾 ⋯ 打开操作菜单 */
1475
+ .dsh-mcp-tailor-table{overflow-x:auto}
1476
+ .dsh-mcp-tailor-row{grid-template-columns:minmax(180px,1.6fr) 88px 118px 96px minmax(96px,.8fr) 30px;gap:9px;padding:7px 12px;align-items:center}
1477
+ .dsh-mcp-tailor-row.head{padding:8px 12px}
1478
+ .dsh-mcp-tailor-row .t-num{text-align:right;justify-self:end;font-variant-numeric:tabular-nums}
1479
+ .dsh-mcp-tailor-row .t-num.bad{color:var(--mcp-err)}
1480
+ .dsh-mcp-tailor-row .t-tax{text-align:right;justify-self:end;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}
1481
+ .dsh-mcp-tailor-row .t-op{justify-self:end;display:flex;justify-content:flex-end}
1482
+ .dsh-mcp-tailor-row .t-name{position:sticky;left:0;z-index:1;background:inherit;padding-right:6px}
1483
+ .dsh-mcp-tailor-row.head .t-name{background:var(--mcp-bg-2)}
1484
+ .dsh-mcp-tailor-row:not(.head):hover{background:var(--dsw-alias-interactive-bg-hover)}
1485
+ .dsh-mcp-tailor-row.dead:not(.head):hover{background:color-mix(in srgb,var(--mcp-warn) 12%,transparent)}
1486
+ /* sparkline:7 段柱状,高度按窗口内分布归一,纯装饰不可交互 */
1487
+ .dsh-mcp-spark{display:inline-flex;align-items:flex-end;gap:1.5px;height:14px;flex:none;vertical-align:-2px}
1488
+ .dsh-mcp-spark>i{display:block;width:2.5px;min-height:1.5px;border-radius:.5px;background:var(--dsw-alias-label-tertiary);opacity:.55}
1489
+ .dsh-mcp-spark>i.hot{background:var(--mcp-accent);opacity:1}
1490
+ /* 行尾 ⋯ 菜单:与 ScopeTabs 的 more 弹层同构,实底 + elevation */
1491
+ .dsh-mcp-rowmenu{position:relative;justify-self:end}
1492
+ .dsh-mcp-rowmenu-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:1px solid transparent;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1;cursor:pointer;transition:background .12s,color .12s,border-color .12s}
1493
+ .dsh-mcp-rowmenu-btn:hover,.dsh-mcp-rowmenu-btn[aria-expanded="true"]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);border-color:var(--mcp-line)}
1494
+ .dsh-mcp-rowmenu-btn:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
1495
+ .dsh-mcp-rowmenu-pop{position:absolute;top:calc(100% + 4px);right:0;z-index:60;box-sizing:border-box;min-width:150px;padding:4px;display:flex;flex-direction:column;gap:1px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-panel);animation:mcpFade .1s ease-out}
1496
+ .dsh-mcp-rowmenu-item{box-sizing:border-box;display:flex;align-items:center;gap:7px;width:100%;padding:6px 9px;border:0;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:17px;text-align:left;white-space:nowrap;cursor:pointer;transition:background .1s}
1497
+ .dsh-mcp-rowmenu-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
1498
+ .dsh-mcp-rowmenu-item.danger{color:var(--mcp-err)}
1499
+ .dsh-mcp-rowmenu-item.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}
1500
+ .dsh-mcp-rowmenu-item:disabled{opacity:.45;cursor:not-allowed}
1501
+ .dsh-mcp-rowmenu-sep{height:1px;background:var(--mcp-line);margin:3px 2px}
1502
+
1503
+ /* ── §4.1 专家「当前在场」区:绿顶条 + 更大的卡,与普通罗列拉开权重 ───────────── */
1504
+ .dsh-mcp-onsite{display:flex;flex-direction:column;gap:9px;flex:none;border:1px solid color-mix(in srgb,var(--mcp-ok) 34%,transparent);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-2);padding:12px 14px}
1505
+ .dsh-mcp-onsite-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1506
+ .dsh-mcp-onsite-head>b{font-size:11px;font-weight:650;letter-spacing:.5px;text-transform:uppercase;color:var(--dsw-alias-state-success-primary);display:inline-flex;align-items:center;gap:6px}
1507
+ .dsh-mcp-onsite-head>b::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--mcp-ok);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-ok) 22%,transparent);corner-shape:round}
1508
+ .dsh-mcp-onsite-head>span{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1509
+ .dsh-mcp-onsite-body{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
1510
+ .dsh-mcp-onsite-name{font-size:15px;font-weight:650;line-height:20px;min-width:0;overflow-wrap:anywhere}
1511
+ .dsh-mcp-onsite-role{font-size:11.5px;color:var(--dsw-alias-brand-text)}
1512
+ .dsh-mcp-onsite-chips{display:flex;gap:5px;flex-wrap:wrap;min-width:0}
1513
+ .dsh-mcp-onsite-act{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1514
+ .dsh-mcp-onsite-empty{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
1515
+ /* 卡内技能 chips:标签密度比独立徽章更小,且要明确「可点」(hover 抬边 + accent 文字)。 */
1516
+ .dsh-mcp-card-tags>button.dsh-mcp-chip-s{font-size:10px;line-height:15px;padding:0 6px}
1517
+ .dsh-mcp-card-tags>button.dsh-mcp-chip-s:hover{border-color:var(--mcp-accent);color:var(--dsw-alias-brand-text)}
1518
+ .dsh-mcp-onsite-chips>button.dsh-mcp-chip-s:hover{border-color:var(--mcp-accent);color:var(--dsw-alias-brand-text)}
1519
+ /* 推荐位与在场区之间留一口气,否则两块卡片网格会连成一片。 */
1520
+ .dsh-mcp-wb-grid+.dsh-mcp-item-sub{margin-top:14px}
1521
+ /* 送还内联二次确认:把「点一下就没了」改成「点一下先问一句」 */
1522
+ .dsh-mcp-confirm{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 8px;border:1px solid color-mix(in srgb,var(--mcp-err) 40%,transparent);border-radius:var(--mcp-r-sm);background:var(--dsw-alias-interactive-bg-hover-danger);font-size:11.5px;color:var(--dsw-alias-label-primary)}
1523
+ .dsh-mcp-confirm>span{color:var(--mcp-err);font-weight:600}
1524
+ .dsh-mcp-chips{display:inline-flex;align-items:center;gap:4px;flex-wrap:wrap;min-width:0}
1525
+ .dsh-mcp-chip-s{display:inline-flex;align-items:center;gap:4px;padding:1px 7px;border:1px solid var(--mcp-line);border-radius:999px;background:var(--mcp-bg-1);color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:16px;white-space:nowrap;corner-shape:round}
1526
+ /* §3.1 过滤 chip:作为按钮要 cursor / hover / 选中态;未选中时保持弱化,选中才吃 accent。 */
1527
+ button.dsh-mcp-chip-s{font:inherit;font-size:10.5px;cursor:pointer;transition:background .12s,border-color .12s,color .12s}
1528
+ button.dsh-mcp-chip-s:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
1529
+ button.dsh-mcp-chip-s.on{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-primary);font-weight:600}
1530
+ button.dsh-mcp-chip-s:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1531
+ .dsh-mcp-chip-s-count{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}
1532
+ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
1533
+ .dsh-mcp-chip-s.more{color:var(--dsw-alias-label-tertiary);border-style:dashed}
1534
+
1535
+ /* ── §3.1 Skill 看板:来源徽记 + 过滤条(只看已启用 / 只看模型可调用)─────────── */
1536
+ .dsh-mcp-skill-filters{display:flex;align-items:center;gap:7px;flex-wrap:wrap;flex:none}
1537
+ .dsh-mcp-skill-toggle{box-sizing:border-box;display:inline-flex;align-items:center;gap:6px;padding:4px 10px;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:17px;white-space:nowrap;cursor:pointer;corner-shape:round;transition:background .12s,border-color .12s,color .12s}
1538
+ .dsh-mcp-skill-toggle:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
1539
+ .dsh-mcp-skill-toggle[aria-pressed="true"]{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-label-primary);font-weight:600}
1540
+ .dsh-mcp-skill-toggle:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1541
+ .dsh-mcp-skill-toggle>i{font-style:normal;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}
1542
+ .dsh-mcp-skill-toggle[aria-pressed="true"]>i{color:var(--mcp-accent)}
1543
+ .dsh-mcp-skill-src-badge{display:inline-flex;align-items:center;padding:0 6px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-xs);background:var(--mcp-bg-2);color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:15px;font-family:var(--mcp-mono);white-space:nowrap}
1544
+
1545
+ /* ── §6 市场:面包屑 + 子页签(下划线式)+ 兼容性前置 + 密度 + 分类条 + 免责折叠 ── */
1546
+ .dsh-mcp-mk-crumb{display:flex;align-items:center;gap:6px;flex:none;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);min-height:17px}
1547
+ .dsh-mcp-mk-crumb-root{color:var(--dsw-alias-label-secondary)}
1548
+ .dsh-mcp-mk-crumb-sep{opacity:.55}
1549
+ .dsh-mcp-mk-crumb>b{font-weight:600;color:var(--dsw-alias-label-primary);font-size:11.5px}
1550
+ .dsh-mcp-mk-subtabs-idle{flex:none;padding:7px 10px 8px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;line-height:19px;white-space:nowrap;cursor:pointer;opacity:.8;transition:color .13s,opacity .13s}
1551
+ .dsh-mcp-mk-subtabs-idle:hover{opacity:1;color:var(--dsw-alias-label-secondary);text-decoration:underline;text-underline-offset:2px}
1552
+ .dsh-mcp-mk-subtabs-idle:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-2px}
1553
+ .dsh-mcp-mk-subtabs{display:flex;align-items:center;gap:2px;flex:none;overflow-x:auto;scrollbar-width:none;border-bottom:1px solid var(--mcp-line);margin-bottom:2px}
1554
+ .dsh-mcp-mk-subtabs::-webkit-scrollbar{display:none}
1555
+ .dsh-mcp-mk-subtab{box-sizing:border-box;display:inline-flex;align-items:center;gap:7px;flex:none;padding:7px 12px 8px;border:0;border-bottom:2px solid transparent;margin-bottom:-1px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;line-height:19px;white-space:nowrap;cursor:pointer;transition:color .13s,border-color .13s}
1556
+ .dsh-mcp-mk-subtab:hover{color:var(--dsw-alias-label-primary)}
1557
+ .dsh-mcp-mk-subtab[aria-selected="true"]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--mcp-accent);font-weight:600}
1558
+ .dsh-mcp-mk-subtab:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-2px}
1559
+ .dsh-mcp-mk-subtab>i{font-style:normal;font-size:10px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary);padding:0 5px;border-radius:999px;background:var(--mcp-line);line-height:15px;corner-shape:round}
1560
+ .dsh-mcp-mk-subtab[aria-selected="true"]>i{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--mcp-accent)}
1561
+ /* 「只看可直接安装」默认勾选——把它做成显眼的开关条而不是埋在工具栏里 */
1562
+ .dsh-mcp-mk-quickbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex:none}
1563
+ /* 搜索态的分类 chips:置灰但保留位置,用户知道分类还在、只是本次搜索没按它筛。 */
1564
+ .dsh-mcp-market-chips.muted{opacity:.5}
1565
+ .dsh-mcp-market-chips.muted .dsh-mcp-chip{cursor:not-allowed}
1566
+ .dsh-mcp-mk-quick{box-sizing:border-box;display:inline-flex;align-items:center;gap:7px;padding:4px 11px;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:17px;cursor:pointer;corner-shape:round;transition:background .12s,border-color .12s,color .12s}
1567
+ .dsh-mcp-mk-quick:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
1568
+ .dsh-mcp-mk-quick[aria-pressed="true"]{border-color:var(--mcp-ok);background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-label-primary);font-weight:600}
1569
+ .dsh-mcp-mk-quick:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
1570
+ .dsh-mcp-mk-quick>i{font-style:normal;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}
1571
+ .dsh-mcp-mk-quick[aria-pressed="true"]>i{color:var(--dsw-alias-state-success-primary)}
1572
+ /* 不兼容卡:虚线灰显(不是红——它只是暂时装不上,不是错误状态)+ 橙角标 */
1573
+ .dsh-mcp-card.wb.blocked{border-style:dashed;border-color:color-mix(in srgb,var(--mcp-warn) 45%,var(--mcp-line));background:var(--mcp-bg-2);opacity:.9}
1574
+ .dsh-mcp-card.wb.blocked:hover{opacity:1}
1575
+ .dsh-mcp-mk-corner{position:absolute;top:-1px;right:-1px;z-index:2;padding:1px 7px;border-radius:0 var(--mcp-r-md) 0 var(--mcp-r-md);background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary);font-size:10px;font-weight:600;line-height:16px;white-space:nowrap}
1576
+ .dsh-mcp-card.wb{position:relative}
1577
+ /* 免责声明:从占屏一段话收成一行可展开 */
1578
+ .dsh-mcp-mk-disclaim{flex:none;font-size:11px;line-height:1.55;color:var(--dsw-alias-label-tertiary)}
1579
+ .dsh-mcp-mk-disclaim summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-tertiary);list-style:none;display:inline-flex;align-items:center;gap:5px}
1580
+ .dsh-mcp-mk-disclaim summary::-webkit-details-marker{display:none}
1581
+ .dsh-mcp-mk-disclaim summary::before{content:"▸";font-size:9px;transition:transform .13s}
1582
+ .dsh-mcp-mk-disclaim[open] summary::before{transform:rotate(90deg)}
1583
+ .dsh-mcp-mk-disclaim p{margin:5px 0 0;color:var(--dsw-alias-label-tertiary)}
1584
+
1585
+ /* ── 骨架屏(OPT-25 · 6.6 三处并行初始加载的占位)──────────────────────
1586
+ 容器与条只用既有主题令牌 + 中性 rgba,暗/亮主题都不需要额外分支。
1587
+ shimmer 是「扫过高光」式:一条半透明白从左到右扫过灰条,opacity/位移双重缓动。
1588
+ prefers-reduced-motion 时整个停掉,只留静态灰条(信息量不打折,动效不打扰)。 */
1589
+ .dsh-mcp-skeleton{display:flex;flex-direction:column;gap:9px;padding:10px 11px}
1590
+ .dsh-mcp-skeleton-bar{height:13px;border-radius:var(--mcp-r-sm);background:color-mix(in srgb,var(--mcp-mut) 16%,transparent);position:relative;overflow:hidden}
1591
+ .dsh-mcp-skeleton-bar::after{content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,rgba(255,255,255,.28),transparent);animation:mcpSkeletonShimmer 1.4s ease-in-out infinite}
1592
+ @keyframes mcpSkeletonShimmer{100%{transform:translateX(100%)}}
1593
+ @media (prefers-reduced-motion: reduce){.dsh-mcp-skeleton-bar::after{animation:none;opacity:0}}
1594
+
520
1595
  /* 响应式覆盖必须位于全部基础规则之后:同特异性下后出现者胜出。 */
521
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}}
522
1597
  @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}}
@@ -525,6 +1600,78 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
525
1600
  // ── DOM 基础件:createElement 绑定、值遮蔽、通用小控件与图标表 ──────────────────────
526
1601
  // 这一层必须排在所有组件之前 —— 下面 23 个返回元素的 React 组件全靠这里的 `h` 构造。
527
1602
  const h = React2.createElement;
1603
+
1604
+ // ── 对话框焦点陷阱(OPT-21 · 6.2)────────────────────────────────────────────
1605
+ // 全文件 9 处 role="dialog" 此前只有 Escape 关闭:键盘用户按 Tab 会一路走到对话框
1606
+ // **后面**的页面元素上,焦点跑出弹层后既看不见高亮、也没法用 Esc 回来;关闭弹层后
1607
+ // 焦点还停在已被卸载的节点上(等于丢给 body)。这里用一个 hook 兜住可访问性下限:
1608
+ // 进弹层把焦点推进去、Tab/Shift+Tab 在弹层内循环、退出时还给原来的元素。
1609
+ // 刻意不接管 Escape —— 每个 dialog 自己的 onKeyDown 负责关闭,语义保持不变。
1610
+ const FOCUSABLE_SELECTOR = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
1611
+ const useFocusTrap = (ref, active) => {
1612
+ useEffect(() => {
1613
+ if (!active) return undefined;
1614
+ const el = ref && ref.current;
1615
+ // 容器尚未挂载(或调用方给的是空 ref):什么都不做,既不猜选择器也不抛错。
1616
+ if (!el) return undefined;
1617
+ // 还原目标:进弹层那一刻的焦点。可能随弹层一起被卸载,所以清理时要 isConnected 兜底。
1618
+ const restoreTarget = document.activeElement;
1619
+ const focusables = () => {
1620
+ const nodes = el.querySelectorAll(FOCUSABLE_SELECTOR);
1621
+ const out = [];
1622
+ for (const node of nodes) if (node && typeof node.focus === 'function' && !node.disabled) out.push(node);
1623
+ return out;
1624
+ };
1625
+ // 初焦点:容器里第一个可聚焦元素;一个都没有就聚焦容器本身(role="dialog" 均带
1626
+ // tabIndex: -1)。若调用方自己的 effect 已经把焦点放进了容器(各 dialog 原有的
1627
+ // `dialogRef.current?.focus()`),这里就不抢——避免两处 effect 互相顶。
1628
+ if (!el.contains(document.activeElement)) {
1629
+ const head = focusables()[0];
1630
+ try { (head || el).focus(); } catch { /* 元素不可聚焦:留给用户点,不因此抛错 */ }
1631
+ }
1632
+ const onKeyDown = (event) => {
1633
+ if (event.key !== 'Tab') return;
1634
+ // 内层弹层优先:不让外层陷阱再处理同一按键(嵌套弹层时两边都会算一次 first/last)。
1635
+ event.stopPropagation();
1636
+ const items = focusables();
1637
+ const first = items[0];
1638
+ const last = items[items.length - 1];
1639
+ if (!items.length) {
1640
+ event.preventDefault();
1641
+ try { el.focus(); } catch { /* 同上 */ }
1642
+ return;
1643
+ }
1644
+ if (!el.contains(document.activeElement)) {
1645
+ event.preventDefault();
1646
+ try { (event.shiftKey ? last : first).focus(); } catch { /* 同上 */ }
1647
+ return;
1648
+ }
1649
+ // 焦点落在容器自身上(tabIndex: -1 的 dialog 常被各组件自己的 effect 聚焦)时,
1650
+ // 浏览器的默认行为是**往容器外**走一步——Tab 会跳到对话框后面的页面元素上。
1651
+ // 这正是陷阱最容易漏的一格,所以容器也要当作「边界」处理。
1652
+ const onBoundary = document.activeElement === el;
1653
+ if (!event.shiftKey) {
1654
+ if (onBoundary || document.activeElement === last) {
1655
+ event.preventDefault();
1656
+ try { first.focus(); } catch { /* 同上 */ }
1657
+ }
1658
+ } else if (onBoundary || document.activeElement === first) {
1659
+ event.preventDefault();
1660
+ try { last.focus(); } catch { /* 同上 */ }
1661
+ }
1662
+ };
1663
+ el.addEventListener('keydown', onKeyDown);
1664
+ return () => {
1665
+ el.removeEventListener('keydown', onKeyDown);
1666
+ // 还原焦点:目标可能已随对话框一同卸载,只能尽力而为。
1667
+ try {
1668
+ if (restoreTarget && restoreTarget.isConnected && typeof restoreTarget.focus === 'function') restoreTarget.focus();
1669
+ } catch { /* 目标不可聚焦:放弃还原,不因此抛错 */ }
1670
+ };
1671
+ // ref 是稳定对象;active 决定陷阱开关。
1672
+ }, [active, ref]);
1673
+ };
1674
+
528
1675
  const MAX_TIMER_DELAY_MS = 2147483647;
529
1676
  const REDACTED_VALUE = '__DSH_MCP_REDACTED__';
530
1677
  const PRESERVED_VALUE_LABEL = t('••••••(保留原值)');
@@ -678,7 +1825,12 @@ const ScopeTabs = ({ tabs, active, onSelect }) => {
678
1825
  setMoreRight(Math.min(Math.max(0, gap), maxRight));
679
1826
  }, []);
680
1827
  useEffect(() => {
681
- const el = barRef.current;
1828
+ // RO 必须观察外层 wrap 而不是 seg 本身:seg 是 inline-flex 收缩包裹,
1829
+ // 一旦折叠成「…N」其内容宽就锁死在窄态,观察它自己永远等不到「变宽」事件,
1830
+ // 初始测量若赶上宿主布局未完成(clientWidth 偏小),就会卡在全折叠死锁。
1831
+ // wrap 由外层 flex 决定宽度,与内容无关,是唯一稳定的基准。
1832
+ const wrap = rootRef.current;
1833
+ const el = wrap || barRef.current;
682
1834
  if (!el) return;
683
1835
  if (typeof ResizeObserver === 'undefined') { setBarWidth(el.clientWidth); return; }
684
1836
  const update = () => setBarWidth(el.clientWidth);
@@ -714,7 +1866,9 @@ const ScopeTabs = ({ tabs, active, onSelect }) => {
714
1866
  count -= 1;
715
1867
  acc -= estimateTabWidth(tabs[count].label, tabs[count].count !== undefined) + 8;
716
1868
  }
717
- return count;
1869
+ // barWidth 异常偏小(宿主布局中途测量)也不能一个都不显示,
1870
+ // 否则「…N」独占一排、列表头无任何可点作用域。
1871
+ return Math.max(1, count);
718
1872
  }, [tabs, barWidth]);
719
1873
  const hidden = visibleCount < tabs.length ? tabs.slice(visibleCount) : [];
720
1874
  const visible = visibleCount < tabs.length ? tabs.slice(0, visibleCount) : tabs;
@@ -731,15 +1885,36 @@ const ScopeTabs = ({ tabs, active, onSelect }) => {
731
1885
  ),
732
1886
  )
733
1887
  : null;
1888
+ // §1.2 分段控件:全局 / 各工作区是「同一维度的互斥取值」,用一排等长 chip 会让人
1889
+ // 以为是多个独立开关。改成连体分段:底板统一着色,当前项实底反白,一眼是"选中之一"。
1890
+ // 溢出折叠逻辑完全保留(工作区可能十几个),只是外观与语义标签换成 radio 组。
734
1891
  return h('div', { className: 'dsh-mcp-tabs-wrap', ref: rootRef },
735
- h('div', { className: 'dsh-mcp-tabs', ref: barRef },
1892
+ h('div', { className: 'dsh-mcp-seg', ref: barRef, role: 'tablist', 'aria-label': t('作用域') },
736
1893
  visible.map((tab) =>
737
- h('div', { key: tab.key, role: 'tab', 'aria-selected': active === tab.key, className: 'dsh-mcp-tab' + (active === tab.key ? ' sel' : ''), onClick: () => onSelect(tab.key), title: tab.title || tab.label },
1894
+ h('button', {
1895
+ key: tab.key,
1896
+ type: 'button',
1897
+ role: 'tab',
1898
+ 'aria-selected': active === tab.key,
1899
+ className: 'dsh-mcp-seg-item',
1900
+ onClick: () => onSelect(tab.key),
1901
+ title: tab.title || tab.label,
1902
+ },
738
1903
  tab.label,
739
- tab.count !== undefined ? h('span', { className: 'dsh-mcp-tab-count' }, tab.count) : null,
1904
+ tab.count !== undefined ? h('span', { className: 'dsh-mcp-seg-count' }, tab.count) : null,
740
1905
  ),
741
1906
  ),
742
- hidden.length ? h('div', { key: '__more__', ref: moreRef, role: 'tab', 'aria-expanded': moreOpen, className: 'dsh-mcp-tab more' + (moreOpen ? ' open' : ''), onClick: (e) => { e.stopPropagation(); syncMoreAnchor(); setMoreOpen((value) => !value); } }, h('b', null, '…'), hidden.length) : null,
1907
+ hidden.length
1908
+ ? h('button', {
1909
+ key: '__more__',
1910
+ ref: moreRef,
1911
+ type: 'button',
1912
+ 'aria-expanded': moreOpen,
1913
+ className: 'dsh-mcp-seg-item',
1914
+ title: t('还有 {0} 个作用域', [hidden.length]),
1915
+ onClick: (e) => { e.stopPropagation(); syncMoreAnchor(); setMoreOpen((value) => !value); },
1916
+ }, '…', h('span', { className: 'dsh-mcp-seg-count' }, hidden.length))
1917
+ : null,
743
1918
  ),
744
1919
  popover,
745
1920
  );
@@ -766,7 +1941,10 @@ function flattenParameters(schema) {
766
1941
  });
767
1942
  return { required, properties };
768
1943
  }
769
- const ToolRow = ({ tool }) => {
1944
+ // §2.2 工具行:加「调用 12 · 错误 0」与热度色条。
1945
+ // usage 是可选入参(来自工具账本的同名工具记录):账本可能还没扫完、也可能没开过裁缝页,
1946
+ // 此时整块不渲染,而不是显示「调用 0」——0 是「确认没被调用」,未知不是 0。
1947
+ const ToolRow = ({ tool, usage, maxCalls }) => {
770
1948
  const [open, setOpen] = useState(false);
771
1949
  const [raw, setRaw] = useState(false);
772
1950
  const hasSchema = !!tool.parameters && typeof tool.parameters === 'object';
@@ -781,10 +1959,19 @@ const ToolRow = ({ tool }) => {
781
1959
  onClick: toggle,
782
1960
  onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } },
783
1961
  } : { className: 'dsh-mcp-tool-head' };
1962
+ const calls = usage && typeof usage.calls === 'number' ? usage.calls : null;
1963
+ const errors = usage && typeof usage.errors === 'number' ? usage.errors : 0;
784
1964
  return h('div', { className: 'dsh-mcp-tool' },
785
1965
  h('div', headProps,
1966
+ // 色条贴在工具名左侧:视线沿行首竖扫就能看出「哪些工具在被用」,
1967
+ // 不必逐行去读右侧数字。
1968
+ calls === null ? null : h(HeatBar, { calls, max: maxCalls, label: t('近 30 天调用 {0} 次', [fmtInt(calls)]) }),
786
1969
  h('span', { className: 'dsh-mcp-tool-name', title: tool.name }, tool.name),
787
1970
  h('span', { className: 'dsh-mcp-tool-desc', title: tool.description }, tool.description || ''),
1971
+ calls === null ? null : h('span', {
1972
+ className: 'dsh-mcp-tool-usage' + (errors > 0 ? ' bad' : calls === 0 ? ' mute' : ''),
1973
+ title: t('近 30 天:调用 {0} 次,失败 {1} 次', [fmtInt(calls), fmtInt(errors)]),
1974
+ }, errors > 0 ? t('调用 {0} · 错误 {1}', [fmtInt(calls), fmtInt(errors)]) : calls === 0 ? t('零调用') : t('调用 {0}', [fmtInt(calls)])),
788
1975
  hasSchema ? h('span', { className: 'dsh-mcp-tool-caret', 'aria-hidden': true }, open ? '▾' : '▸') : null,
789
1976
  ),
790
1977
  open && hasSchema ? h('div', { className: 'dsh-mcp-tool-body' },
@@ -809,6 +1996,190 @@ const ToolRow = ({ tool }) => {
809
1996
  const Btn = ({ cls, icon, onClick, disabled, title, children }) =>
810
1997
  h('button', { type: 'button', className: 'dsh-mcp-btn' + (cls ? ' ' + cls : ''), onClick, disabled, title }, icon ? h(Icon, { d: ICONS[icon] }) : null, children);
811
1998
 
1999
+ // §2.2 体量阈值:≥100 个工具就算「大体量」。
2000
+ // 为什么是 100:本机 rea 是 170 个、windows 约 50 个,100 正好把真正需要概览的挑出来,
2001
+ // 而不至于让二十来个工具的普通服务器也被折叠(折叠本身也有成本)。
2002
+ const HEAVY_TOOL_COUNT = 100;
2003
+
2004
+ // §2.2 工具清单:本地搜索 + 热度排序无关的稳定列表。
2005
+ // 不做虚拟滚动:详情面板本身是滚动容器,工具行高度固定但总数上限在数百量级,
2006
+ // 直接渲染的代价远小于引入虚拟列表所带来的一堆边界(展开行、滚动锚点、键盘导航)。
2007
+ // 真正的问题是「找不到某个工具」,那用搜索解决比用虚拟滚动解决更直接。
2008
+ const ToolList = ({ tools, usageByName, searchable, query, onQuery }) => {
2009
+ const usageOf = (name) => (usageByName ? usageByName.get(name) : undefined);
2010
+ // 调用数峰值用于热度色条分档:没有账本数据时 max 为 0,HeatBar 会自行退化为无色。
2011
+ let maxCalls = 0;
2012
+ if (usageByName) for (const tool of tools) { const u = usageOf(tool.name); if (u && u.calls > maxCalls) maxCalls = u.calls; }
2013
+ const q = (query || '').trim().toLowerCase();
2014
+ const shown = q
2015
+ ? tools.filter((tool) => (tool.name + ' ' + (tool.description || '')).toLowerCase().includes(q))
2016
+ : tools;
2017
+ return h('div', { className: 'dsh-mcp-tools' },
2018
+ searchable
2019
+ ? h('div', { className: 'dsh-mcp-tools-bar' },
2020
+ h('div', { className: 'dsh-mcp-search' }, h(Icon, { d: ICONS.search }), h('input', { placeholder: t('搜索工具名或描述…'), value: query, onChange: (e) => onQuery(e.target.value) })),
2021
+ h('span', { className: 'dsh-mcp-tools-count' }, q ? t('{0}/{1} 个匹配', [fmtInt(shown.length), fmtInt(tools.length)]) : t('共 {0} 个', [fmtInt(tools.length)])),
2022
+ )
2023
+ : null,
2024
+ shown.length === 0
2025
+ ? h('div', { className: 'dsh-mcp-empty-list' }, t('没有匹配「{0}」的工具', [query]))
2026
+ : shown.map((tool) => h(ToolRow, { key: tool.name, tool, usage: usageOf(tool.name), maxCalls })),
2027
+ );
2028
+ };
2029
+
2030
+ // ══ 原子组件层(优化项共用件)═════════════════════════════════════════════
2031
+ // 放在这里而不是各页签内部:§1.3 状态语义、§1.4 空状态、§2.2 热度、§5 行尾菜单
2032
+ // 在四个页签里都要用;复制四份必然走形,一处定义才能保证「同一种状态长得一样」。
2033
+
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
+ // ── §1.4 空状态四件套:图标 + 结论 + 原因 + 主操作 ───────────────────────────
2040
+ // 一个空列表如果不解释「为什么空」和「下一步点哪」,用户只会反复刷新。
2041
+ // 四个槽位刻意分开:结论用 <b>,原因用 <span>,操作在最下——扫读顺序与决策顺序一致。
2042
+ // actions 两种写法都收:现成的 React 元素,或 { label, icon, onClick, danger } 描述对象
2043
+ // (描述对象交给这里统一渲染成按钮,避免每个调用点各写一份按钮样式)。
2044
+ const EmptyState = ({ icon, tone, title, hint, actions, tight }) =>
2045
+ h('div', { className: 'dsh-mcp-empty' + (tight ? ' tight' : '') },
2046
+ icon ? h('span', { className: 'dsh-mcp-empty-ico' + (tone ? ' ' + tone : '') }, h(Icon, { d: ICONS[icon], size: 20 })) : null,
2047
+ title ? h('b', null, title) : null,
2048
+ hint ? h('span', null, hint) : null,
2049
+ actions
2050
+ ? h('div', { className: 'dsh-mcp-empty-act' },
2051
+ (Array.isArray(actions) ? actions : [actions]).map((item, index) =>
2052
+ item && typeof item === 'object' && item.label !== undefined
2053
+ ? h('button', {
2054
+ key: index,
2055
+ type: 'button',
2056
+ className: 'dsh-mcp-btn' + (item.danger ? ' danger' : '') + (index === 0 ? ' primary' : ''),
2057
+ onClick: item.onClick,
2058
+ disabled: item.disabled,
2059
+ title: item.title,
2060
+ }, item.icon ? h(Icon, { d: ICONS[item.icon], size: 12 }) : null, item.label)
2061
+ : item))
2062
+ : null,
2063
+ );
2064
+
2065
+ // ── 内联二次确认:把「点一下就执行」改成「点一下先问一句」 ─────────────────────
2066
+ // 只用于不可撤销或代价高的动作(送还专家、移除服务器)。破坏性动作用 err 色,
2067
+ // 确认与取消并排且「取消」在左——默读顺序先看到的是退出路径,降低误触代价。
2068
+ const InlineConfirm = ({ question, confirmLabel, onConfirm, onCancel, busy }) =>
2069
+ h('span', { className: 'dsh-mcp-confirm' },
2070
+ h('span', null, question),
2071
+ h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: onConfirm }, confirmLabel),
2072
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: onCancel }, t('取消')),
2073
+ );
2074
+
2075
+ // ── §2.2 热度色条:把「调用次数」这种绝对量转成可扫读的相对量 ───────────────────
2076
+ // 分档而不是线性:实际分布极端长尾(少数工具几千次、多数几次),线性宽度全挤成一条线。
2077
+ const heatLevel = (calls, max) => {
2078
+ if (!calls || calls <= 0) return 0;
2079
+ if (!max || max <= 0) return 1;
2080
+ const ratio = calls / max;
2081
+ if (ratio >= 0.5) return 3;
2082
+ if (ratio >= 0.12) return 2;
2083
+ return 1;
2084
+ };
2085
+ const HeatBar = ({ calls, max, label }) => {
2086
+ const lv = heatLevel(calls, max);
2087
+ const pct = lv === 0 ? 0 : lv === 1 ? 34 : lv === 2 ? 66 : 100;
2088
+ return h('span', { className: 'dsh-mcp-heat lv' + lv, title: label || '', 'aria-hidden': true }, h('i', { style: { width: pct + '%' } }));
2089
+ };
2090
+
2091
+ // ── §5.4 sparkline:7 段柱状,把「最近有没有在用」压成 22px 宽 ──────────────────
2092
+ // 数据缺失时整块不渲染(不画一排等高灰条去假装有数据)。
2093
+ const Spark = ({ series, title }) => {
2094
+ if (!Array.isArray(series) || !series.length) return null;
2095
+ const max = Math.max.apply(null, series.concat([1]));
2096
+ return h('span', { className: 'dsh-mcp-spark', title, 'aria-hidden': true },
2097
+ series.map((v, i) => h('i', { key: i, className: v > 0 && v >= max * 0.55 ? 'hot' : '', style: { height: Math.max(1.5, Math.round((v / max) * 14)) + 'px' } })),
2098
+ );
2099
+ };
2100
+
2101
+ // ── §5.3 行尾 ⋯ 菜单:把「禁用 / 启用」这类低频动作收进菜单,别直铺占满一行 ──────
2102
+ // 点击外部关闭 + Esc 关闭;菜单项用 onMouseDown 提前于 blur,避免点不到。
2103
+ const RowMenu = ({ items, label }) => {
2104
+ const [open, setOpen] = useState(false);
2105
+ const rootRef = useRef(null);
2106
+ useEffect(() => {
2107
+ if (!open) return;
2108
+ const close = (e) => { if (!rootRef.current || !rootRef.current.contains(e.target)) setOpen(false); };
2109
+ const esc = (e) => { if (e.key === 'Escape') setOpen(false); };
2110
+ document.addEventListener('mousedown', close);
2111
+ document.addEventListener('keydown', esc);
2112
+ return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', esc); };
2113
+ }, [open]);
2114
+ const list = (items || []).filter(Boolean);
2115
+ if (!list.length) return null;
2116
+ return h('span', { className: 'dsh-mcp-rowmenu', ref: rootRef },
2117
+ h('button', {
2118
+ type: 'button',
2119
+ className: 'dsh-mcp-rowmenu-btn',
2120
+ 'aria-expanded': open,
2121
+ 'aria-label': label || t('更多操作'),
2122
+ title: label || t('更多操作'),
2123
+ onClick: (e) => { e.stopPropagation(); setOpen((v) => !v); },
2124
+ }, '⋯'),
2125
+ open
2126
+ ? h('span', { className: 'dsh-mcp-rowmenu-pop', onClick: (e) => e.stopPropagation() },
2127
+ list.map((item, i) => (item.sep
2128
+ ? h('span', { key: 'sep' + i, className: 'dsh-mcp-rowmenu-sep' })
2129
+ : h('button', {
2130
+ key: item.label,
2131
+ type: 'button',
2132
+ className: 'dsh-mcp-rowmenu-item' + (item.danger ? ' danger' : ''),
2133
+ disabled: item.disabled,
2134
+ title: item.title,
2135
+ onMouseDown: (e) => e.preventDefault(),
2136
+ onClick: () => { setOpen(false); item.onClick && item.onClick(); },
2137
+ }, item.icon ? h(Icon, { d: ICONS[item.icon], size: 13 }) : null, item.label))),
2138
+ )
2139
+ : null,
2140
+ );
2141
+ };
2142
+
2143
+ // ── §1.2 URL 记忆:把作用域/分类写进 location.hash,刷新与分享都能回到原处 ─────────
2144
+ // 只在 http(s) 或已有的 file:// 下生效;宿主可能把面板挂在任何路径上,所以只改 hash,
2145
+ // 不动 path/search。写入用 replaceState 避免每次切换都堆一条历史记录。
2146
+ const readHashParam = (name) => {
2147
+ try {
2148
+ if (typeof location === 'undefined') return '';
2149
+ const raw = String(location.hash || '').replace(/^#/, '');
2150
+ if (!raw) return '';
2151
+ const parts = raw.split('&');
2152
+ for (const part of parts) {
2153
+ const eq = part.indexOf('=');
2154
+ if (eq < 0) continue;
2155
+ if (decodeURIComponent(part.slice(0, eq)) === name) return decodeURIComponent(part.slice(eq + 1));
2156
+ }
2157
+ return '';
2158
+ } catch { return ''; }
2159
+ };
2160
+ const writeHashParams = (patch) => {
2161
+ try {
2162
+ if (typeof location === 'undefined' || typeof history === 'undefined') return;
2163
+ const raw = String(location.hash || '').replace(/^#/, '');
2164
+ const map = {};
2165
+ if (raw) {
2166
+ for (const part of raw.split('&')) {
2167
+ const eq = part.indexOf('=');
2168
+ if (eq < 0) { if (part) map[decodeURIComponent(part)] = ''; continue; }
2169
+ map[decodeURIComponent(part.slice(0, eq))] = decodeURIComponent(part.slice(eq + 1));
2170
+ }
2171
+ }
2172
+ Object.keys(patch).forEach((k) => {
2173
+ const v = patch[k];
2174
+ if (v === '' || v === null || v === undefined) delete map[k];
2175
+ else map[k] = String(v);
2176
+ });
2177
+ const next = Object.keys(map).map((k) => encodeURIComponent(k) + (map[k] === '' ? '' : '=' + encodeURIComponent(map[k]))).join('&');
2178
+ const url = location.pathname + location.search + (next ? '#' + next : '');
2179
+ history.replaceState(null, '', url);
2180
+ } catch { /* hash 只是便利设施,任何失败都不影响面板功能 */ }
2181
+ };
2182
+
812
2183
  // 服务器列表。embedded=true 时作为项目 tab 的分区内容渲染:去掉自身卡片外框,
813
2184
  // 并在条目较少时隐藏搜索/过滤(否则控件会挤占本就有限的列表高度)。
814
2185
  const CONTROLS_THRESHOLD = 6;
@@ -836,8 +2207,59 @@ const wsSub = (s) => {
836
2207
  : t('已连接 · 当前无会话使用(连接保留,下次直接用)') + tools;
837
2208
  };
838
2209
 
2210
+ // ── 骨架屏组件(OPT-25 · 6.6):初次并行加载的占位条,aria-hidden 避免读屏噪音 ──
2211
+ const Skeleton = ({ rows = 3 }) => h('div', { className: 'dsh-mcp-skeleton', 'aria-hidden': 'true' }, Array.from({ length: rows }, (_, i) => h('div', { key: i, className: 'dsh-mcp-skeleton-bar', style: { width: i === rows - 1 ? '60%' : '100%' } })));
2212
+
2213
+ // ── 分页条(OPT-30 · 3.1):上一页 / 跳页 / 下一页 + 总数 ────────────────────────
2214
+ // 原来三处 pager 都只有「上一页 12 / 34 下一页」:翻到第 28 页要点 27 次,而且
2215
+ // **看不出总共有多少条**(12/34 是页码不是条数)。这里补三件事:
2216
+ // ① 跳页输入框——失焦或回车才提交,绝不逐字符提交:市场技能页的 setPage 会打
2217
+ // wbSkillCatalog 远程请求,边敲边跳等于连发请求。
2218
+ // ② 总数——走 fmtInt,跟其他数字一样受 locale 影响。
2219
+ // ③ 越界钳制——输入 999 落到最后一页、输入 0 落到第一页,非数字回退当前页。
2220
+ const Pager = ({ page, pageCount, total, onPage, disabled }) => {
2221
+ const [draft, setDraft] = useState(String(page));
2222
+ // page 被外部改(上一页/下一页/筛选变化把页码夹回)时同步输入框,否则会显示旧值。
2223
+ useEffect(() => { setDraft(String(page)); }, [page]);
2224
+ const commit = () => {
2225
+ const n = Number(draft);
2226
+ if (draft.trim() === '' || !Number.isFinite(n)) { setDraft(String(page)); return; }
2227
+ const next = Math.min(pageCount, Math.max(1, Math.trunc(n)));
2228
+ if (next !== page) onPage(next);
2229
+ setDraft(String(next));
2230
+ };
2231
+ return h('div', { className: 'dsh-mcp-wb-pager' },
2232
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: disabled || page <= 1, onClick: () => onPage(page - 1) }, t('上一页')),
2233
+ h('span', { className: 'dsh-mcp-builtin-count' }, t('第 {0} / {1} 页', [page, pageCount])),
2234
+ h('label', { className: 'dsh-mcp-page-jump' },
2235
+ h('span', null, t('跳到')),
2236
+ h('input', {
2237
+ type: 'number', min: 1, max: pageCount, value: draft, disabled,
2238
+ 'aria-label': t('跳转到指定页'),
2239
+ onChange: (e) => setDraft(e.target.value),
2240
+ onBlur: commit,
2241
+ onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } },
2242
+ }),
2243
+ h('span', null, t('页')),
2244
+ ),
2245
+ h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: disabled || page >= pageCount, onClick: () => onPage(page + 1) }, t('下一页')),
2246
+ total === undefined ? null : h('span', { className: 'dsh-mcp-builtin-count' }, t('共 {0} 条', [fmtInt(total)])),
2247
+ );
2248
+ };
2249
+
2250
+ // ── 内置目录的 auth 枚举(OPT-24 · 2.3)──────────────────────────────────────────
2251
+ // 宿主 lib/mcp-config.js 的 builtinMcpCatalog 透出 auth(枚举 none/apiKey/oauth/
2252
+ // oauth-or-pat)+ 有则挂 authNote。**这与市场连接器 tab 的 authMode 不是一回事**:
2253
+ // authMode 是上游 connectors 清单里的自由文本,只能原样显示;auth 是枚举,可以映射
2254
+ // 成人能读的标签。未知枚举值原样显示,绝不猜。
2255
+ const AUTH_KIND_LABELS = { none: t('免认证'), apiKey: 'API Key', oauth: 'OAuth', 'oauth-or-pat': 'OAuth / PAT' };
2256
+ const authKindLabel = (kind) => AUTH_KIND_LABELS[kind] || kind;
2257
+ // 色语沿用 §1.3:oauth 系装完还得去浏览器授权,属「还需注意」→ 橙;apiKey 有既有
2258
+ // 「需密钥」徽章兜住,这里只作信息性标注,用默认灰。
2259
+ const authBadgeCls = (kind) => (kind === 'oauth' || kind === 'oauth-or-pat' ? 's-warn' : '');
2260
+
839
2261
  // ── 服务器列表:按传输 / 状态筛选,选中与启停 ──────────────────────────────────
840
- const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onToggle, busy, transportFilter, statusFilter, onTransportFilter, onStatusFilter, embedded, workspace, emptyHint }) => {
2262
+ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onToggle, busy, transportFilter, statusFilter, onTransportFilter, onStatusFilter, embedded, workspace, emptyHint, loading }) => {
841
2263
  const count = total === undefined ? servers.length : total;
842
2264
  const showControls = !embedded || count > CONTROLS_THRESHOLD;
843
2265
  // mountFailed / scopeFailed / wsSub 现在在模块作用域(纯函数,可被测试直接调用)。
@@ -845,6 +2267,10 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
845
2267
  // stopped / disabled / unknown。状态点因此直接复用,不再需要为项目行特例。
846
2268
  const dotPhase = (s) => (mountFailed(s) || scopeFailed(s) ? 'failed' : s.status || s.phase);
847
2269
  const toggleLocked = (s) => busy || !s.managed || (s.scope !== 'workspace' && !s.enabled && s.phase !== 'stopped');
2270
+ // §2.1 体量阈值:170 个工具和 20 个工具在“20 工具”这个字符串里视觉等重,
2271
+ // 但进详情页后一个是滚两屏、一个是滚十几屏。100 是「要不要默认折叠」的分界:
2272
+ // 低于 100 摊开看反而快,高于 100 必须先给概览。
2273
+ const HEAVY_TOOL_COUNT = 100;
848
2274
  return h('div', { className: 'dsh-mcp-side' + (embedded ? ' embedded' : '') },
849
2275
  showControls ? h('div', { className: 'dsh-mcp-search' }, h(Icon, { d: ICONS.search }), h('input', { placeholder: t('搜索 MCP…'), value: query, onChange: (e) => onQuery(e.target.value) })) : null,
850
2276
  showControls ? h('div', { className: 'dsh-mcp-filters' },
@@ -861,23 +2287,38 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
861
2287
  ),
862
2288
  ) : null,
863
2289
  h('div', { className: 'dsh-mcp-list' },
864
- servers.length === 0
865
- ? h('div', { className: 'dsh-mcp-empty-list' }, count === 0 && emptyHint ? emptyHint : t('没有匹配的 MCP'))
866
- : servers.map((s) =>
867
- h('div', { key: s.serverName, role: 'button', tabIndex: 0, 'aria-selected': selected === s.serverName, className: 'dsh-mcp-item' + (selected === s.serverName ? ' sel' : ''), onClick: () => onSelect(s.serverName), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(s.serverName); } } },
2290
+ // OPT-25:初次并行加载(servers 还是起步空数组)时用骨架条占位;空态 EmptyState
2291
+ // 只在真正加载完成后出现,避免「骨架 → 空态」闪烁出「没有 MCP」的误导。
2292
+ loading && count === 0
2293
+ ? h(Skeleton, { rows: 6 })
2294
+ : servers.length === 0
2295
+ // §1.4 空状态:列表为空时分两种情况——一个都没有(该去添加)vs 过滤后没有(该改条件)。
2296
+ // 原文案把两者合并成「没有匹配的 MCP」,用户不知道该清搜索还是该加服务器。
2297
+ ? (count === 0
2298
+ ? (emptyHint || h(EmptyState, { icon: 'plug', title: t('还没有 MCP 服务器'), hint: t('添加一个 HTTP 或 stdio 服务器,或在「市场」里挑选现成的连接器。'), tight: true }))
2299
+ : h(EmptyState, { icon: 'search', title: t('没有匹配的 MCP'), hint: t('当前搜索或过滤条件下没有结果;清空关键词或把状态切回「全部状态」试试。'), tight: true }))
2300
+ : servers.map((s) => {
2301
+ const heavy = typeof s.toolCount === 'number' && s.toolCount >= HEAVY_TOOL_COUNT;
2302
+ const failed = mountFailed(s) || scopeFailed(s) || s.status === 'failed';
2303
+ return h('div', { key: s.serverName, role: 'button', tabIndex: 0, 'aria-selected': selected === s.serverName, className: 'dsh-mcp-item' + (selected === s.serverName ? ' sel' : ''), onClick: () => onSelect(s.serverName), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(s.serverName); } } },
868
2304
  h(Dot, { phase: dotPhase(s) }),
2305
+ // 三段式:第一段(名称)给出「是谁」,第二段(事实行)给出「什么状况、多大体量」,
2306
+ // 第三段(右侧开关)给出「能立刻做什么」。三段各自负责一个问题,扫读时不必来回跳。
869
2307
  h('div', { className: 'dsh-mcp-item-main' },
870
2308
  h('div', { className: 'dsh-mcp-item-name' }, s.serverName),
871
2309
  h('div', { className: 'dsh-mcp-item-sub' },
872
2310
  h(Badge, { cls: s.transport === 'http' ? 't-http' : 't-stdio' }, s.transport === 'http' ? 'HTTP' : 'stdio'),
873
2311
  h('span', null, s.scope === 'workspace' ? wsSub(s) : s.toolCount + t(' 工具')),
2312
+ heavy ? h('span', { className: 'dsh-mcp-tag-heavy', title: t('工具数 ≥ {0}:进详情默认折叠工具清单', [HEAVY_TOOL_COUNT]) }, t('大体量')) : null,
2313
+ // 失败原因就地给一个词,不逼用户点进去看日志才知道哪里坏了。
2314
+ failed && !mountFailed(s) && !scopeFailed(s) ? h(Badge, { cls: 's-err' }, t('连接失败')) : null,
874
2315
  mountFailed(s) ? h(Badge, { cls: 's-err' }, t('挂载失败')) : null,
875
2316
  scopeFailed(s) ? h(Badge, { cls: 's-err' }, t('作用域故障')) : null,
876
2317
  ),
877
2318
  ),
878
2319
  h(Toggle, { on: s.enabled, disabled: toggleLocked(s), label: s.serverName + (s.enabled ? t(':已启用') : t(':已禁用')), title: s.managed ? (s.enabled ? t('禁用') : t('启用')) : t('来自其他配置层,只读'), onChange: () => onToggle(s) }),
879
- ),
880
- ),
2320
+ );
2321
+ }),
881
2322
  ),
882
2323
  );
883
2324
  };
@@ -936,10 +2377,97 @@ const GlobalServerList = ({ servers, exclude, onToggleExclude, onSelect, selecte
936
2377
  );
937
2378
  };
938
2379
 
2380
+ // ── §2.1 连接诊断(OPT-28)的纯函数部分 ────────────────────────────────────────
2381
+ // 从 projectConnections 的行集合里挑出「当前这条服务器」对应的那一行。
2382
+ // 为什么不能只按 serverName 匹配:serverName 的全 app 唯一性只在保存路径被校验,
2383
+ // 手工编辑 .dsh/mcp.json 完全能造出两个项目同名(见 :3126 的同一段注释),所以有
2384
+ // wsPath 时必须先按 wsPath 收窄,收窄不了(老宿主不回 wsPath)才退回「按名取第一条
2385
+ // 非 ready 的」——非 ready 的那条才是用户此刻真正想看的异常连接。
2386
+ const pickProjectConnection = (rows, serverName, wsPath) => {
2387
+ const list = Array.isArray(rows) ? rows.filter((row) => row && row.serverName === serverName) : [];
2388
+ if (list.length === 0) return null;
2389
+ const want = wsPath ? String(wsPath) : '';
2390
+ if (want) {
2391
+ const exact = list.filter((row) => String(row.wsPath || '') === want);
2392
+ if (exact.length === 1) return exact[0];
2393
+ if (exact.length > 1) return exact.find((row) => row.state !== 'ready') || exact[0];
2394
+ }
2395
+ if (list.length === 1) return list[0];
2396
+ return list.find((row) => row.state !== 'ready') || list[0];
2397
+ };
2398
+
2399
+ // 状态值域来自宿主 projectConnectionsView:ready / connecting / disposing。
2400
+ const connectionStateText = (state) => (state === 'ready'
2401
+ ? t('已就绪')
2402
+ : state === 'connecting'
2403
+ ? t('建连中')
2404
+ : state === 'disposing'
2405
+ ? t('释放中')
2406
+ : (state ? String(state) : '—'));
2407
+
2408
+ // teardown 读数:落定的只回答「上次回收花了多久」,未落定的才说「回收中」。
2409
+ // 宿主已保证 settled 记录只保留 10 分钟内(TEARDOWN_RECORD_STALE_MS),这里不再判时限。
2410
+ const connectionTeardownText = (teardown) => {
2411
+ if (!teardown) return '';
2412
+ const ms = Math.max(0, Math.round(Number(teardown.elapsedMs) || 0));
2413
+ if (teardown.settled) return teardown.stuck ? t('上次回收 {0} ms(超时)', [ms]) : t('上次回收 {0} ms', [ms]);
2414
+ return t('回收中 {0} ms', [ms]);
2415
+ };
2416
+
939
2417
  // ── 服务器详情:工具清单、重连、编辑、移除、掩码值显隐 ────────────────────────────
940
- const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove, busy, onReveal, revealed, onCopy, excluded, onToggleExclude }) => {
2418
+ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove, busy, onReveal, revealed, onCopy, excluded, onToggleExclude, usageByName, onAdd, call, wsPath }) => {
2419
+ // §2.2 工具区状态:搜索词 + 「大体量默认折叠」。
2420
+ // 这两条 state 必须放在早退之前——hooks 数量不能因 server 有无而变,
2421
+ // 否则从「空态」切到「有详情」时 React 会报 hook 顺序错误。
2422
+ const [toolQuery, setToolQuery] = useState('');
2423
+ const [toolsExpanded, setToolsExpanded] = useState(false);
2424
+ // 切换服务器时重置工具区视图:上一条的搜索词对下一条无意义,
2425
+ // 而「已展开」状态若保留,用户会以为新服务器也默认展开了。
2426
+ const toolScopeKey = server ? server.serverName + ':' + (server.toolRevision || '') : '';
2427
+ useEffect(() => { setToolQuery(''); setToolsExpanded(false); }, [toolScopeKey]);
2428
+ // §2.1 连接诊断(OPT-28):契约 dsh-armory#mcpManager/projectConnections 很早就注册了,
2429
+ // 但面板从未调过它——项目作用域的连接健康度(会话槽 / 空闲回收 / 配置是否过期 /
2430
+ // 上一次 teardown 卡住多久)因此完全不可见。这里按需拉一次,不轮询:诊断是「想看才看」
2431
+ // 的排障信息,定时轮询只会在后台白白唤醒宿主读一遍连接表。
2432
+ const [connDiag, setConnDiag] = useState(null);
2433
+ const [connBusy, setConnBusy] = useState(false);
2434
+ // 只对项目作用域有意义:全局连接不经过 projectConnections(那张表按 wsPath 索引)。
2435
+ const diagEnabled = !!(server && (server.scope === 'workspace' || server.wsPath));
2436
+ const diagWsPath = wsPath || (server && server.wsPath) || '';
2437
+ const diagServerName = server ? server.serverName : '';
2438
+ const loadConnDiag = useCallback(async () => {
2439
+ if (!call || !diagEnabled) return;
2440
+ setConnBusy(true);
2441
+ try {
2442
+ const res = await call('projectConnections');
2443
+ if (res && res.ok) setConnDiag(Array.isArray(res.connections) ? res.connections : []);
2444
+ else setConnDiag([]);
2445
+ } catch {
2446
+ setConnDiag([]);
2447
+ } finally {
2448
+ setConnBusy(false);
2449
+ }
2450
+ }, [call, diagEnabled]);
2451
+ useEffect(() => {
2452
+ // 切换服务器时先清空:上一条的连接读数挂在新服务器名下,比「暂无数据」更误导。
2453
+ setConnDiag(null);
2454
+ if (diagEnabled) loadConnDiag();
2455
+ }, [diagEnabled, diagServerName, diagWsPath, loadConnDiag]);
941
2456
  if (!server)
942
- return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' }, t('选择一个 MCP 查看详情')));
2457
+ return h('div', { className: 'dsh-mcp-detail' },
2458
+ h(EmptyState, {
2459
+ icon: 'plug',
2460
+ title: t('选择一个 MCP 查看详情'),
2461
+ // §1.4 空状态要「可操作」,不能只是一句说明。这里给的三个动作覆盖了
2462
+ // 用户看到空白右栏时最可能的意图:不知道怎么配(去市场找现成的)、
2463
+ // 想自己写(填表)、手上已有 JSON(导入)。
2464
+ hint: t('从左侧列表点选一台服务器,可以查看它的连接信息、参数与工具清单。'),
2465
+ actions: [
2466
+ { label: t('去市场找现成的'), icon: 'store', onClick: () => openBoardCategory('wb') },
2467
+ { label: t('手动添加'), icon: 'plug', onClick: onAdd },
2468
+ ],
2469
+ }),
2470
+ );
943
2471
  const env = server.env || {};
944
2472
  const headers = server.headers || {};
945
2473
  const envKeys = Object.keys(env);
@@ -998,6 +2526,82 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
998
2526
  h('b', null, keyName),
999
2527
  h(RevealBtn, { field, keyName, label, fallback: String(env[keyName] ?? headers[keyName] ?? '') }),
1000
2528
  );
2529
+ // §2.1 连接诊断(OPT-28)的渲染层。三种状态要分开说,否则「没数据」会被误读成「一切正常」:
2530
+ // diagEnabled=false → 整块不渲染(全局连接没有这条记录,说「无诊断」是噪音);
2531
+ // 拉过且没找到这一行 → 说明这条服务器当前没有活连接(也没回收记录),这本身是结论;
2532
+ // 拿到行 → 列出会话槽/空闲回收/配置是否过期/上次 teardown。
2533
+ const diagRow = connDiag ? pickProjectConnection(connDiag, server.serverName, diagWsPath) : null;
2534
+ const connDiagCard = !diagEnabled
2535
+ ? null
2536
+ : h('div', { className: 'dsh-mcp-card' },
2537
+ h('div', { className: 'dsh-mcp-diag-head' },
2538
+ h('h4', null, t('连接诊断')),
2539
+ // 诊断是「想看才看」的信息,不轮询;给一个手动的重新读取入口就够。
2540
+ h('button', {
2541
+ type: 'button',
2542
+ className: 'dsh-mcp-diag-refresh',
2543
+ disabled: connBusy,
2544
+ title: t('重新读取宿主的连接表'),
2545
+ onClick: loadConnDiag,
2546
+ }, connBusy ? t('正在读取…') : t('刷新')),
2547
+ ),
2548
+ connDiag === null
2549
+ ? h('div', { className: 'dsh-mcp-diag-note' }, t('正在读取…'))
2550
+ : !diagRow
2551
+ ? h('div', { className: 'dsh-mcp-diag-note' },
2552
+ t('宿主连接表里没有这台服务器的记录:它当前没有被任何会话建立连接,也没有最近的回收记录。'))
2553
+ : h('div', { className: 'dsh-mcp-kv' },
2554
+ h('b', null, t('连接状态')),
2555
+ h('span', { className: diagRow.state === 'ready' ? 'dsh-mcp-diag-ok' : 'dsh-mcp-diag-bad' },
2556
+ connectionStateText(diagRow.state)
2557
+ + (diagRow.fiberState ? t('(会话槽 {0})', [String(diagRow.fiberState)]) : '')),
2558
+ h('b', null, t('会话槽')),
2559
+ h('span', null, Number.isFinite(diagRow.sessions)
2560
+ ? t('{0} 个会话共用这条连接', [fmtInt(diagRow.sessions)])
2561
+ : (Number.isFinite(diagRow.refs) ? t('{0} 个引用', [fmtInt(diagRow.refs)]) : '—')),
2562
+ Number.isFinite(diagRow.idleTimeoutMs) && diagRow.idleTimeoutMs > 0
2563
+ ? h('b', null, t('空闲回收'))
2564
+ : null,
2565
+ Number.isFinite(diagRow.idleTimeoutMs) && diagRow.idleTimeoutMs > 0
2566
+ ? h('span', { className: diagRow.idle ? 'dsh-mcp-diag-bad' : '' },
2567
+ diagRow.idle
2568
+ ? t('已空闲超过 {0} 秒,正在等待回收', [fmtInt(Math.round(diagRow.idleTimeoutMs / 1000))])
2569
+ : t('空闲 {0} 秒后回收', [fmtInt(Math.round(diagRow.idleTimeoutMs / 1000))]))
2570
+ : null,
2571
+ connectionTeardownText(diagRow.teardown) ? h('b', null, t('上次回收')) : null,
2572
+ connectionTeardownText(diagRow.teardown)
2573
+ ? h('span', { className: diagRow.teardown && diagRow.teardown.stuck ? 'dsh-mcp-diag-bad' : '' },
2574
+ connectionTeardownText(diagRow.teardown))
2575
+ : null,
2576
+ diagRow.configStale || diagRow.configError || diagRow.scopeError ? h('b', null, t('配置')) : null,
2577
+ diagRow.configStale || diagRow.configError || diagRow.scopeError
2578
+ ? h('span', { className: 'dsh-mcp-diag-bad' },
2579
+ diagRow.configError
2580
+ ? diagRow.configError
2581
+ : diagRow.scopeError
2582
+ ? diagRow.scopeError
2583
+ : t('配置文件已改动,下次建连才生效'))
2584
+ : null,
2585
+ Array.isArray(diagRow.duplicateOwners) && diagRow.duplicateOwners.length
2586
+ ? h('b', null, t('同名项目'))
2587
+ : null,
2588
+ Array.isArray(diagRow.duplicateOwners) && diagRow.duplicateOwners.length
2589
+ ? h('span', null, diagRow.duplicateOwners.join(t('、')))
2590
+ : null,
2591
+ ),
2592
+ // 连不上的时候,用户下一步几乎一定要重连;别让他滚回顶部去找那个按钮。
2593
+ diagRow && diagRow.state !== 'ready'
2594
+ ? h('div', { className: 'dsh-mcp-diag-acts' },
2595
+ h('button', {
2596
+ type: 'button',
2597
+ className: 'dsh-mcp-diag-act',
2598
+ disabled: busy || !server.enabled || !server.managed,
2599
+ title: t('拆掉这条连接并按当前配置重建,不影响其他会话'),
2600
+ onClick: () => onReconnect(server),
2601
+ }, t('重连这台服务器')),
2602
+ )
2603
+ : null,
2604
+ );
1001
2605
  return h('div', { className: 'dsh-mcp-detail' },
1002
2606
  h('div', { className: 'dsh-mcp-d-head' },
1003
2607
  h('div', null,
@@ -1032,13 +2636,45 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
1032
2636
  isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h('div', { className: 'dsh-mcp-log warn' }, t('以下项目也定义了同名 serverName:') + server.duplicateOwners.join(t('、')) + t('。DSH 0.1.5 起各注册作用域可独立使用同名;更早的版本按全进程唯一判定,同名会导致其中一份启动失败。')) : null,
1033
2637
  server.lastError ? h('div', { className: 'dsh-mcp-log', title: t('来自 mcp-client 最近日志') }, server.lastError) : null,
1034
2638
  server.toolCountAmbiguous ? h('div', { className: 'dsh-mcp-log warn' }, t('工具命名与其他 serverName 前缀重叠,无法可靠判断归属;请避免在 serverName 中使用双下划线。')) : null,
2639
+ // §2.4 连接健康条:原来「连没连上、几个工具、几个会话」埋在两行徽章里,
2640
+ // 而且要滚到「连接信息」才知道传输方式。这里把四个最常被问到的结论提到最上层:
2641
+ // 状态 / 工具数 / 会话数 / 传输。失败时补一格「原因」——不让人为了一个红点去翻日志。
2642
+ h('div', { className: 'dsh-mcp-health' },
2643
+ h('div', { className: 'dsh-mcp-health-cell' }, h('span', null, t('连接状态')), h('b', { className: status === 'connected' ? 'ok' : status === 'failed' || mountFailed || scopeFailed ? 'err' : status === 'loading' || status === 'waiting' ? 'warn' : 'mute' }, statusText)),
2644
+ h('div', { className: 'dsh-mcp-health-cell' }, h('span', null, t('工具')), h('b', { className: server.toolCount > 0 ? 'ok' : 'mute' }, server.toolCountAmbiguous ? t('归属存疑') : server.toolCount + t(' 个'))),
2645
+ h('div', { className: 'dsh-mcp-health-cell' }, h('span', null, t('会话')), h('b', { className: 'mute' }, isWorkspace ? (server.refs > 0 ? t('{0} 个共用', [server.refs]) : t('尚未建立')) : t('全局共享'))),
2646
+ h('div', { className: 'dsh-mcp-health-cell' }, h('span', null, t('传输')), h('b', { className: 'mute' }, server.transport === 'http' ? 'HTTP' : 'stdio')),
2647
+ ),
2648
+ // §2.1 连接诊断(OPT-28):上面的健康条只说「连接现在是什么状态」,而排障还要知道
2649
+ // 「谁占着这条连接、什么时候会被回收、配置是不是已经改脏了、上一次回收卡了多久」。
2650
+ // 这些读数宿主一直有(projectConnectionsView),但面板从没调过那条契约。
2651
+ // 只有项目作用域渲染:全局连接不进 projectConnections(那张表按 wsPath 索引)。
2652
+ connDiagCard,
1035
2653
  h('div', { className: 'dsh-mcp-card' },
1036
2654
  h('h4', null, t('连接信息')),
1037
2655
  h('div', { className: 'dsh-mcp-kv' },
1038
2656
  h('b', null, t('传输')), h('span', null, server.transport === 'http' ? 'Streamable HTTP' : 'stdio'),
1039
2657
  server.url ? h('b', null, 'URL') : null, server.url ? h(RevealBtn, { field: 'url', label: 'URL', fallback: server.url }) : null,
1040
2658
  server.command ? h('b', null, t('命令')) : null, server.command ? h('span', null, mut(server.command)) : null,
1041
- server.args && server.args.length ? h('b', null, t('参数')) : null, server.args && server.args.length ? h(RevealBtn, { field: 'args', label: t('参数'), fallback: server.args.map(mut).join(' ') }) : null,
2659
+ server.args && server.args.length ? h('b', null, t('参数')) : null,
2660
+ // §2.3 参数行:原来的做法是把 args 用空格拼起来再截断 88 字符,结果是
2661
+ // 「npx -y rea-mcp --root /Users/……」,既看不出几个参数、也分不清哪段是被脱敏的。
2662
+ // 现在先说清「有几个」,再用可展开的 chips 展示结构;仍不泄露原值(显示的是脱敏后的)。
2663
+ server.args && server.args.length
2664
+ ? h('span', { className: 'dsh-mcp-args' },
2665
+ h('span', { className: 'dsh-mcp-args-count' }, t('{0} 个参数', [server.args.length])),
2666
+ h('span', { className: 'dsh-mcp-args-note' }, t('已脱敏')),
2667
+ server.args.map((arg, i) => {
2668
+ const text = displayValue(arg);
2669
+ const masked = arg === REDACTED_VALUE || text === REDACTED_VALUE;
2670
+ return h('code', {
2671
+ key: i,
2672
+ className: 'dsh-mcp-arg-chip' + (masked ? ' masked' : ''),
2673
+ title: masked ? t('此参数含密钥,已脱敏;需要时可在「编辑」里显式查看') : String(text),
2674
+ }, masked ? '••••••' : mut(text));
2675
+ }),
2676
+ )
2677
+ : null,
1042
2678
  server.cwd ? h('b', null, t('工作目录')) : null, server.cwd ? h('span', null, mut(server.cwd)) : null,
1043
2679
  h('b', null, t('启用')), h('span', null, server.enabled ? t('是') : t('否(补丁中 disabled: true)')),
1044
2680
  h('b', null, t('配置来源')), h('span', null, server.conflict ? t('存在重复 serverName(冲突,只读)') : isForeign ? t('全局配置(在「全局」标签页可编辑)') : isWorkspace ? t('项目配置 .dsh/mcp.json(可编辑,本项目会话共享一份连接)') : server.managed ? t('当前 Web profile(可编辑)') : t('其他 bundle / Agent(只读)')),
@@ -1051,11 +2687,27 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
1051
2687
  ),
1052
2688
  h('div', { className: 'dsh-mcp-card' },
1053
2689
  h('h4', null, t('工具(') + tools.length + t(')')),
1054
- h('div', { className: 'dsh-mcp-tools' },
1055
- tools.length === 0
1056
- ? h('div', { className: 'dsh-mcp-empty-list' }, server.toolCountAmbiguous ? t('工具归属存在命名歧义') : server.scope === 'workspace' ? t('尚无会话持有该项目连接,或连接尚未就绪') : t('暂无工具'))
1057
- : tools.map((t) => h(ToolRow, { key: t.name, tool: t })),
1058
- ),
2690
+ // §2.2 大体量默认折叠:170 条平铺会把「连接信息」和「工具」之外的上下文全推出屏幕,
2691
+ // 而用户进详情页十次里有九次是想确认连接是否健康。因此 ≥100 工具时先只给一行摘要,
2692
+ // 想看再展开——默认状态服务最常见的目的,而不是服务最长的清单。
2693
+ tools.length >= HEAVY_TOOL_COUNT && !toolsExpanded
2694
+ ? h('div', { className: 'dsh-mcp-tools-collapsed' },
2695
+ h('span', null, t('{0} 个工具,默认收起以免遮蔽上方连接信息', [fmtInt(tools.length)])),
2696
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setToolsExpanded(true) }, t('展开工具清单')),
2697
+ )
2698
+ : h(React2.Fragment, null,
2699
+ tools.length === 0
2700
+ ? h('div', { className: 'dsh-mcp-empty-list' }, server.toolCountAmbiguous ? t('工具归属存在命名歧义') : server.scope === 'workspace' ? t('尚无会话持有该项目连接,或连接尚未就绪') : t('暂无工具'))
2701
+ : h(ToolList, {
2702
+ tools,
2703
+ usageByName,
2704
+ // 大清单才给搜索框:七八个工具时搜索框比清单本身还高,纯属负担。
2705
+ searchable: tools.length > 12,
2706
+ query: toolQuery,
2707
+ onQuery: setToolQuery,
2708
+ }),
2709
+ tools.length >= HEAVY_TOOL_COUNT ? h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-tools-collapse-back', onClick: () => setToolsExpanded(false) }, t('收起工具清单')) : null,
2710
+ ),
1059
2711
  ),
1060
2712
  );
1061
2713
  };
@@ -1131,6 +2783,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
1131
2783
  useEffect(() => {
1132
2784
  if (open) dialogRef.current?.focus();
1133
2785
  }, [open]);
2786
+ useFocusTrap(dialogRef, open); // OPT-21:Tab 循环 + 关闭后焦点还原
1134
2787
  if (!open) return null;
1135
2788
  const kv = (rows, setRows, i, field, v) => {
1136
2789
  const next = rows.slice();
@@ -1295,6 +2948,12 @@ const sourceImportNames = (row) => (row.entries || []).filter((entry) => !entry.
1295
2948
  const sourceMaskedFields = (row) => [...new Set((row.entries || []).flatMap((entry) => entry.maskedFields || []))];
1296
2949
  // 环境变量名不算秘密(值才是),但缺值会让导入后的那个位置变成空字符串,得先说。
1297
2950
  const sourceMissingEnv = (row) => [...new Set((row.entries || []).flatMap((entry) => entry.missingEnv || []))];
2951
+ // OPT-17 · 2.5:核心字段含 ${input:xxx} 的条目会被整条降级(去掉等于发明一个猜的值),
2952
+ // 它们不进 entries,只在 unsupportedEntries 里留 { name, transport, reason, names }。
2953
+ // 界面必须把这两类分开说,否则用户只看到「没有可导入的 MCP」,看不出是「有几条需要交互取值」。
2954
+ const sourceUnsupported = (row) => (row && row.unsupportedEntries) || [];
2955
+ const sourceUnsupportedNames = (row) =>
2956
+ [...new Set(sourceUnsupported(row).flatMap((entry) => entry.names || []))];
1298
2957
 
1299
2958
  // ── JSON 导入弹窗:多来源选择、错误分组、掩码字段提示 ────────────────────────────
1300
2959
  const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope, scopeName }) => {
@@ -1331,11 +2990,19 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1331
2990
  useEffect(() => {
1332
2991
  if (open) dialogRef.current?.focus();
1333
2992
  }, [open]);
2993
+ useFocusTrap(dialogRef, open); // OPT-21:Tab 循环 + 关闭后焦点还原
1334
2994
  if (!open) return null;
1335
2995
  // 选中来源后,来源导入优先于粘贴内容:两者不会同时在生效状态。
1336
2996
  const pickSource = async (row) => {
1337
2997
  const names = sourceImportNames(row);
1338
- if (row.error || !names.length) { setError(row.error || t('这条配置里没有可导入的 MCP')); return; }
2998
+ if (row.error || !names.length) {
2999
+ // 「一条都没有」有两种截然不同的原因,混成一句话会让用户以为文件里真的没东西。
3000
+ const blocked = sourceUnsupported(row).length;
3001
+ setError(row.error || (blocked
3002
+ ? t('这 {0} 个 MCP 都需要交互取值,无法直接导入', [String(blocked)])
3003
+ : t('这条配置里没有可导入的 MCP')));
3004
+ return;
3005
+ }
1339
3006
  setBusy(true); setError(''); setPreview(null); clearSource();
1340
3007
  try {
1341
3008
  const res = await call('previewImportSource', { sourceId: row.sourceId, scope: row.scope, names, ...scopePayload });
@@ -1423,7 +3090,14 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1423
3090
  const names = sourceImportNames(row);
1424
3091
  const masked = sourceMaskedFields(row);
1425
3092
  const missingEnv = sourceMissingEnv(row);
1426
- const detail = row.error || (names.length ? names.length + t(' 个 MCP:') + names.join(', ') : t('没有可导入的 MCP'));
3093
+ const unsupported = sourceUnsupported(row);
3094
+ // 两类同时存在时,摘要必须同时给出两个数——只说「N 个 MCP」会让用户以为少算了。
3095
+ const detail = row.error || (names.length
3096
+ ? names.length + t(' 个 MCP:') + names.join(', ')
3097
+ + (unsupported.length ? t('(另有 {0} 个需要交互取值)', [String(unsupported.length)]) : '')
3098
+ : (unsupported.length
3099
+ ? t('这 {0} 个 MCP 都需要交互取值,无法直接导入', [String(unsupported.length)])
3100
+ : t('没有可导入的 MCP')));
1427
3101
  return h('button', {
1428
3102
  key: row.key,
1429
3103
  type: 'button',
@@ -1440,6 +3114,17 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1440
3114
  masked.length ? h('span', { className: 'dsh-mcp-source-note' }, t('含需掩码字段:') + masked.join(t('、')) + t('(导入后在详情页可用眼睛查看)')) : null,
1441
3115
  missingEnv.length ? h('span', { className: 'dsh-mcp-source-warn' }, t('环境变量未设置:') + missingEnv.join(t('、')) + t('(导入后该处会得到空值)')) : null,
1442
3116
  row.warnings?.length ? h('span', { className: 'dsh-mcp-source-warn' }, row.warnings.length + t(' 条提示,导入时逐条列出')) : null,
3117
+ // 逐条列出「哪一条、为什么进不来、缺哪些交互输入」,而不是只给一个总数。
3118
+ unsupported.length
3119
+ ? h('span', { className: 'dsh-mcp-source-unsupported' },
3120
+ t('不支持:') + unsupported.map((entry) => entry.name).join(t('、')))
3121
+ : null,
3122
+ unsupported.length
3123
+ ? h('span', { className: 'dsh-mcp-source-note' },
3124
+ sourceUnsupportedNames(row).length
3125
+ ? t('需要在这里填写的交互输入:') + sourceUnsupportedNames(row).join(t('、'))
3126
+ : t('这些条目引用了编辑器交互输入(${input:…}),值在导入时拿不到'))
3127
+ : null,
1443
3128
  );
1444
3129
  })) : null,
1445
3130
  ),
@@ -1452,6 +3137,19 @@ const JsonImportModal = ({ open, onClose, call, busy, setBusy, onImported, scope
1452
3137
  h('label', { className: mode === 'replace' ? 'sel' : '' }, h('input', { type: 'radio', checked: mode === 'replace', onChange: () => { setMode('replace'); setPreview(null); } }), isWorkspace ? t('替换该项目') : t('替换当前 Profile')),
1453
3138
  ),
1454
3139
  summary ? h('div', { className: 'dsh-mcp-card dsh-mcp-import-preview' }, summary) : null,
3140
+ // OPT-17 · 2.5:来源导入预览按「可导入 / 不支持」两组分开列。
3141
+ // 混在一张成功列表里,用户会以为「不支持的也导进去了」。
3142
+ source && sourceUnsupported(source).length
3143
+ ? h('div', { className: 'dsh-mcp-card dsh-mcp-import-preview' },
3144
+ h('div', { className: 'dsh-mcp-import-group' },
3145
+ h('b', null, t('可导入:')),
3146
+ h('span', null, sourceImportNames(source).length ? sourceImportNames(source).join(t('、')) : t('无'))),
3147
+ h('div', { className: 'dsh-mcp-import-group' },
3148
+ h('b', { className: 'warn' }, t('不支持:')),
3149
+ h('span', null, sourceUnsupported(source).map((entry) => entry.name).join(t('、')))),
3150
+ ...sourceUnsupported(source).map((entry, index) => h('div', { key: 'unsup-' + index, className: 'dsh-mcp-import-group-note' },
3151
+ entry.name + t(':') + entry.reason)))
3152
+ : null,
1455
3153
  error ? h('div', { className: 'dsh-mcp-d-err' }, error) : null,
1456
3154
  h('div', { className: 'dsh-mcp-modal-actions' },
1457
3155
  h(Btn, { onClick: onClose, children: t('取消') }),
@@ -1492,11 +3190,45 @@ function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
1492
3190
  };
1493
3191
  }
1494
3192
 
3193
+ // 6.3 updateHint 状态化:已忽略版本改存 JSON 状态键 dsh-armory.updateHint.state
3194
+ // ({"dismissed":"<版本>"})。这是格式迁移不是键改名——旧裸键只做读回落,弹窗
3195
+ // 判定语义不变(dismissed !== latest 才提示),故不进 LEGACY_KEY_SUFFIXES(那是要
3196
+ // 改名的迁移表)。读写失败一律静默,不打扰面板。
3197
+ const UPDATE_HINT_STATE_KEY = 'dsh-armory.updateHint.state';
3198
+ const readUpdateHintDismissed = () => {
3199
+ try {
3200
+ const raw = localStorage.getItem(UPDATE_HINT_STATE_KEY);
3201
+ if (raw) {
3202
+ const state = JSON.parse(raw);
3203
+ if (state && typeof state.dismissed === 'string' && state.dismissed) return state.dismissed;
3204
+ }
3205
+ } catch { /* 新键缺失或 JSON 损坏:回落旧裸键(旧版格式迁移) */ }
3206
+ try { return localStorage.getItem('dsh-armory.updateHint.dismissed'); } catch { return null; /* 存储不可用时视为未关闭 */ }
3207
+ };
3208
+ const writeUpdateHintDismissed = (version) => {
3209
+ try { localStorage.setItem(UPDATE_HINT_STATE_KEY, JSON.stringify({ dismissed: version })); } catch { return; /* 忽略:下次打开会再提示 */ }
3210
+ try { localStorage.removeItem('dsh-armory.updateHint.dismissed'); } catch { /* 旧裸键清理失败不报错,读回落仍兼容 */ }
3211
+ };
3212
+
1495
3213
  // ── MCP 主面板(McpTab):服务器 + 工具 + 可见性感知轮询 ──────────────────────────
1496
3214
  const McpTab = (props) => {
1497
- const { call, onClose, floating, timer, pluginManager } = props;
3215
+ const { call, onClose, floating, timer, pluginManager, tailorReport } = props;
3216
+ // §2.2 工具调用热度来自裁缝页的账本。这里只在"账本已经就绪"时用它——
3217
+ // 不为了给工具行配一列调用数就在 MCP 页再扫一遍日志:那要读几十上百个会话文件,
3218
+ // 而用户打开 MCP 页最常做的事只是确认连接是否正常。
3219
+ // 账本没有时传 undefined,ToolRow 会整块不渲染,而不是显示虚假的「调用 0」。
3220
+ const usageByName = useMemo(() => {
3221
+ const rows = tailorReport && Array.isArray(tailorReport.tools) ? tailorReport.tools : null;
3222
+ if (!rows) return undefined;
3223
+ const map = new Map();
3224
+ for (const row of rows) if (row && typeof row.name === 'string') map.set(row.name, row);
3225
+ return map;
3226
+ }, [tailorReport]);
1498
3227
  const panelRef = useRef(null);
1499
3228
  const [servers, setServers] = useState([]);
3229
+ // OPT-25(6.6 骨架屏):首次 load 返回前保持 true,三个面板的 ServerList 用它渲染骨架;
3230
+ // 只有第一次完成才落 false,后续轮询/刷新不再触发骨架,避免列表闪动。
3231
+ const [initialLoad, setInitialLoad] = useState(true);
1500
3232
  const [error, setError] = useState('');
1501
3233
  const [selected, setSelected] = useState('');
1502
3234
  const [query, setQuery] = useState('');
@@ -1509,8 +3241,12 @@ const McpTab = (props) => {
1509
3241
  const [refreshing, setRefreshing] = useState(false);
1510
3242
  const [transportFilter, setTransportFilter] = useState('all');
1511
3243
  const [statusFilter, setStatusFilter] = useState('all');
1512
- // ---- workspace 项目层 ----
1513
- const [scope, setScope] = useState('global');
3244
+ // ---- §1.2 作用域 URL hash 记忆 ----
3245
+ // 刷新或切走再回来时,作用域选择应当还在。没有这条路,用户每次都要重新点一遍,
3246
+ // 而「我正在看某个项目的 MCP」是一件会持续好几分钟的状态。
3247
+ // 存 hash 而不是 storage:hash 天然跟当前页签绑定,不会把上一个页签的选择带到新页签,
3248
+ // 也天然可分享、可书签。键名 `mcp` 由 writeHashParams 统一序列化(#mcp=<path>)。
3249
+ const [scope, setScope] = useState(() => readHashParam('mcp') || 'global');
1514
3250
  const [workspaces, setWorkspaces] = useState([]);
1515
3251
  const [globalServers, setGlobalServers] = useState([]);
1516
3252
  const [exclude, setExclude] = useState([]);
@@ -1529,10 +3265,31 @@ const McpTab = (props) => {
1529
3265
  const clearRevealed = useCallback(() => {
1530
3266
  clearRevealState(revealGeneration, setRevealed);
1531
3267
  }, []);
3268
+ // ---- §1.5 命令面板联动 ----
3269
+ // 面板里选中某台 MCP:直接把它的名字灌进列表搜索框。这比「选中并滚动到它」
3270
+ // 更简单也更准确——搜索框本来就是这台服务器的过滤器,一次请求就把列表缩到一行,
3271
+ // 同时清掉作用域/传输/状态过滤器,否则被过滤掉的服务器会「搜不到」。
3272
+ // 同样的两段式通道:从面板跳过来时 McpTab 可能刚挂载,广播还没发完。
3273
+ useEffect(() => {
3274
+ const focus = (name) => {
3275
+ const target = String(name || '').trim();
3276
+ if (!target) return;
3277
+ pendingMcpQuery = '';
3278
+ setQuery(target);
3279
+ setTransportFilter('all');
3280
+ setStatusFilter('all');
3281
+ };
3282
+ const off = onMcpQuery(focus);
3283
+ if (pendingMcpQuery) focus(pendingMcpQuery);
3284
+ return off;
3285
+ }, []);
1532
3286
 
1533
3287
  useEffect(() => {
1534
3288
  if (floating) panelRef.current?.focus();
1535
3289
  }, [floating]);
3290
+ // OPT-21:只有浮层形态才有 role="dialog" 的 panel 节点;嵌入 tab 形态下 panelRef 为空,
3291
+ // 陷阱本就该关掉,故 active 精确取 floating 而不是「面板已挂载」。
3292
+ useFocusTrap(panelRef, !!floating);
1536
3293
 
1537
3294
  const flash = useCallback(
1538
3295
  (msg, kind) => {
@@ -1589,6 +3346,7 @@ const McpTab = (props) => {
1589
3346
  if (notify) flash(message, 'err');
1590
3347
  } finally {
1591
3348
  if (notify && manualRefreshSeq.current === seq) setRefreshing(false);
3349
+ setInitialLoad(false);
1592
3350
  }
1593
3351
  }, [call, clearRevealed, flash, scope]);
1594
3352
 
@@ -1597,13 +3355,17 @@ const McpTab = (props) => {
1597
3355
  return startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, timer, POLL_INTERVAL_MS, () => load(false));
1598
3356
  }, [load, timer]);
1599
3357
 
3358
+ // §1.5 命令面板的「刷新 MCP 状态」走这里:带 notify 才走手动刷新分支(转圈 + toast),
3359
+ // 与头部刷新按钮完全同一条链路,避免面板刷新与按钮刷新出现两套行为。
3360
+ useEffect(() => onMcpRefresh(() => { load(true); }), [load]);
3361
+
1600
3362
  // 更新提示:Host 每天最多查一次 GitHub Releases;纯增值信息,任何失败都静默,不打扰面板。
1601
3363
  useEffect(() => {
1602
3364
  let cancelled = false;
1603
3365
  call('updateHint').then((res) => {
1604
3366
  if (cancelled || !res?.ok || !res.updateAvailable) return;
1605
3367
  let dismissed = null;
1606
- try { dismissed = localStorage.getItem('dsh-armory.updateHint.dismissed'); } catch { /* 存储不可用时视为未关闭 */ }
3368
+ try { dismissed = readUpdateHintDismissed(); } catch { /* 存储不可用时视为未关闭 */ }
1607
3369
  if (dismissed !== res.latest) setUpdateHint(res);
1608
3370
  }).catch(() => {});
1609
3371
  return () => { cancelled = true; };
@@ -1795,7 +3557,7 @@ const McpTab = (props) => {
1795
3557
  };
1796
3558
 
1797
3559
  const dismissUpdateHint = () => {
1798
- if (updateHint?.latest) { try { localStorage.setItem('dsh-armory.updateHint.dismissed', updateHint.latest); } catch { /* 忽略:下次打开会再提示 */ } }
3560
+ if (updateHint?.latest) { try { writeUpdateHintDismissed(updateHint.latest); } catch { /* 忽略:下次打开会再提示 */ } }
1799
3561
  setUpdateHint(null);
1800
3562
  // 升级完成的横幅不挂在 updateHint 上(那时它已经是 null),所以要把 state 也收掉,
1801
3563
  // 否则「关闭」按钮按下去横幅还在。
@@ -1864,6 +3626,8 @@ const McpTab = (props) => {
1864
3626
  const scopeName = scope === 'global' ? t('全局') : (workspaces.find((ws) => ws.path === scope)?.name || scope);
1865
3627
  const switchScope = useCallback((next) => {
1866
3628
  if (next === scope) return;
3629
+ // 写入 hash:next 是 'global' 时清掉参数,不留一个无意义的 #mcp/global。
3630
+ writeHashParams({ mcp: next === 'global' ? null : next });
1867
3631
  setScope(next);
1868
3632
  setSelected('');
1869
3633
  setQuery('');
@@ -1940,18 +3704,25 @@ const McpTab = (props) => {
1940
3704
  ? h(
1941
3705
  'div',
1942
3706
  { className: 'dsh-mcp-body' },
1943
- h(ServerList, { servers: filtered, total: servers.length, selected, query, onQuery: setQuery, onSelect: setSelected, onToggle, busy, transportFilter, statusFilter, onTransportFilter: setTransportFilter, onStatusFilter: setStatusFilter }),
3707
+ h(ServerList, { servers: filtered, total: servers.length, selected, query, onQuery: setQuery, onSelect: setSelected, onToggle, busy, transportFilter, statusFilter, onTransportFilter: setTransportFilter, onStatusFilter: setStatusFilter, loading: initialLoad }),
1944
3708
  h(ServerDetail, {
1945
3709
  server: selectedServer,
1946
3710
  tools,
3711
+ usageByName,
1947
3712
  onToggle,
1948
3713
  onReconnect,
1949
3714
  onEdit: openEdit,
1950
3715
  onRemove,
3716
+ onAdd: openAdd,
1951
3717
  busy,
1952
3718
  onReveal,
1953
3719
  revealed,
1954
3720
  onCopy,
3721
+ // §2.1 连接诊断(OPT-28):诊断卡要自己拉 projectConnections,因此把调用通道
3722
+ // 与作用域一并传入。全局分支下 scope 恒为 'global',诊断卡自身会因
3723
+ // diagEnabled=false 整块不渲染,这里传值不会多打一次宿主。
3724
+ call,
3725
+ wsPath: scope,
1955
3726
  }),
1956
3727
  )
1957
3728
  : h(
@@ -1979,6 +3750,7 @@ const McpTab = (props) => {
1979
3750
  embedded: true,
1980
3751
  workspace: true,
1981
3752
  emptyHint: t('此项目还没有 MCP。用右上角「添加项目 MCP」或「市场」写入 .dsh/mcp.json'),
3753
+ loading: initialLoad,
1982
3754
  }),
1983
3755
  ),
1984
3756
  h('div', { className: 'dsh-mcp-section global' + (globalOpen ? '' : ' collapsed') },
@@ -2005,16 +3777,21 @@ const McpTab = (props) => {
2005
3777
  h(ServerDetail, {
2006
3778
  server: selectedServer,
2007
3779
  tools,
3780
+ usageByName,
2008
3781
  onToggle,
2009
3782
  onReconnect,
2010
3783
  onEdit: openEdit,
2011
3784
  onRemove,
3785
+ onAdd: openAdd,
2012
3786
  busy,
2013
3787
  onReveal,
2014
3788
  revealed,
2015
3789
  onCopy,
2016
3790
  excluded: exclude.includes(selected),
2017
3791
  onToggleExclude,
3792
+ // §2.1 连接诊断(OPT-28):项目分支才是诊断真正生效的地方。
3793
+ call,
3794
+ wsPath: scope,
2018
3795
  }),
2019
3796
  ),
2020
3797
  h(AddEditModal, { open: addOpen, onClose: () => { setAddOpen(false); setEditTarget(null); }, onSubmit, busy, initial: editTarget, onRevealValue: revealValue, revealEpoch: revealGeneration.current, scopeName }),
@@ -2044,11 +3821,9 @@ const McpTab = (props) => {
2044
3821
  // 会掉帧、不跟手;最终位置只在松手时提交一次。
2045
3822
  // - Pointer Events + setPointerCapture 统一鼠标/触摸/笔,快速拖动不丢事件。
2046
3823
  // - touch-action:none 是触摸下唯一可靠的滚动抑制手段(iOS Safari 尤其如此)。
2047
- // 本按钮是 position:fixed 的 50px 圆钮,脱离文档流,left/top 重绘成本可忽略,
2048
- // 故保留 left/top 定位以复用现有 :hover/:active 的 transform 缩放反馈。
3824
+ // 本按钮是 position:fixed 的圆钮(外框 ${FAB_SIZE}px 由 JS 单源常量插值),脱离文档流,
3825
+ // left/top 重绘成本可忽略,故保留 left/top 定位以复用现有 :hover/:active 的 transform 缩放反馈。
2049
3826
  const FAB_POS_KEY = 'dsh-armory/fab-pos';
2050
- const FAB_SIZE = 50;
2051
- const FAB_EDGE = 22;
2052
3827
  const DRAG_THRESHOLD = 5;
2053
3828
 
2054
3829
  function fabViewport() {
@@ -2206,14 +3981,31 @@ const skillScopeArg = (scope) => (scope === 'global' ? {} : { cwd: scope });
2206
3981
  // 既有的 disable/enable 写链 —— 因此只有「当前已注册的 MCP 工具」才有操作按钮,
2207
3982
  // 历史出现过的未注册工具(例如已卸载的 MCP)只展示统计。
2208
3983
  // 延迟统计暂缺(见 tool-ledger.js 尾注),健康度以错误率 + 调用次数呈现。
3984
+ // OPT-16 · 3.2:专家详情弹层 persona 预览截断阈值(展开全文后渲染完整原文)。
3985
+ const PERSONA_PREVIEW_MAX = 4000;
2209
3986
  const TAILOR_WINDOWS = [
3987
+ { key: '1', label: t('今日'), days: 1 }, // OPT-24b · 4.1:今日档,回答「刚配置完它到底被调了几次」
2210
3988
  { key: '7', label: t('近 7 天'), days: 7 },
2211
3989
  { key: '30', label: t('近 30 天'), days: 30 },
2212
3990
  { key: '90', label: t('近 90 天'), days: 90 },
2213
3991
  ];
2214
- const fmtInt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');
3992
+ const fmtInt = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—'); // OPT-14 · 1.2:跟随 locale 回落
3993
+ // OPT-18 · 4.2 零值显性化:zstd 不可用时所有调用计数恒为 0(解不出帧),这个 0 是「测不到」
3994
+ // 而不是「没人用」,直接上 0 会误导排查方向。核心计数统一改显示 '—',原因由 decodeWarning 区解释。
3995
+ const tailorNum = (n, rep) => (rep && rep.zstdSupported === false ? '—' : fmtInt(n));
3996
+ // OPT-19 · 4.3:字节数格式化(1024 进制,ASCII 单位 B/KB/MB/GB,一位小数;<1KB 不出小数)。
3997
+ const fmtBytes = (n) => {
3998
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '—';
3999
+ if (n < 1024) return Math.round(n) + ' B';
4000
+ let v = n;
4001
+ for (const unit of ['KB', 'MB', 'GB']) {
4002
+ v /= 1024;
4003
+ if (v < 1024 || unit === 'GB') return v.toFixed(1) + ' ' + unit;
4004
+ }
4005
+ return n + ' B';
4006
+ };
2215
4007
  const fmtPct = (n) => (typeof n === 'number' && n > 0 ? (n * 100).toFixed(1) + '%' : '0%');
2216
- const fmtTax = (n) => (typeof n === 'number' && n > 0 ? '~' + n.toLocaleString('en-US') + ' tok' : '—');
4008
+ const fmtTax = (n) => (typeof n === 'number' && n > 0 ? '~' + n.toLocaleString(I18N_LOCALE || 'zh-CN') + ' tok' : '—'); // OPT-14 · 1.2
2217
4009
  const fmtLast = (ms, now) => {
2218
4010
  if (typeof ms !== 'number') return t('从未');
2219
4011
  const diff = Math.max(0, (typeof now === 'number' ? now : Date.now()) - ms);
@@ -2245,6 +4037,8 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
2245
4037
  // 零调用的候选项。旧版默认打开「只看零调用」,屏幕上必然整列 0,看上去像「检测
2246
4038
  // 全坏」——那是筛选口径而不是数据问题。默认关掉,筛选交给用户显式开启。
2247
4039
  const [onlyDead, setOnlyDead] = useState(false);
4040
+ // OPT-19 · 4.3:清空缓存是独立动作,busy 态与全局扫描分开,避免清空期间误开「重扫」。
4041
+ const [clearing, setClearing] = useState(false);
2248
4042
  const flashCancel = useRef(null);
2249
4043
  const days = Number(windowKey) || 30;
2250
4044
  // 父层先把 30 天窗口扫好了(角标要用)。窗口一致且已有报告时,首次挂载**不必**再扫;
@@ -2310,6 +4104,47 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
2310
4104
  }
2311
4105
  };
2312
4106
 
4107
+ // OPT-19 · 4.3:解压文本缓存行 + 清空。宿主侧 `action: 'clearTextCache'` 是「先清后扫」
4108
+ // (lib/index.js toolTailor 分支),一次调用同时完成清空与新报告,无需再补一发 load。
4109
+ const clearCache = async () => {
4110
+ if (clearing || busy) return;
4111
+ setClearing(true);
4112
+ setActionError('');
4113
+ try {
4114
+ const res = await call('toolTailor', { action: 'clearTextCache', days });
4115
+ if (res && res.ok) {
4116
+ const freed = res.textCache && typeof res.textCache.clearedBytes === 'number' ? res.textCache.clearedBytes : null;
4117
+ commitReport(res);
4118
+ setActionError('');
4119
+ flash(freed !== null ? t('已清空缓存,释放 {0}', [fmtBytes(freed)]) : t('正在清空缓存…'));
4120
+ } else {
4121
+ setActionError((res && res.error) || t('toolTailor 调用失败'));
4122
+ }
4123
+ } catch (e) {
4124
+ setActionError(String((e && e.message) || e));
4125
+ } finally {
4126
+ setClearing(false);
4127
+ }
4128
+ };
4129
+
4130
+ // OPT-19:只有报告带了 textCache 快照(Wave 1-A 起宿主才有)才渲染整行;旧报告整行缺省。
4131
+ const cacheLine = report && report.textCache && typeof report.textCache.entries === 'number'
4132
+ ? (() => {
4133
+ const cache = report.textCache;
4134
+ const fmtI = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—');
4135
+ return h('div', { className: 'dsh-mcp-tailor-cache' },
4136
+ h('span', { className: 'dsh-mcp-tailor-cache-label' }, t('账本缓存')),
4137
+ h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条') + ' · ' + fmtPct(cache.utilization)),
4138
+ h('button', {
4139
+ type: 'button',
4140
+ className: 'dsh-mcp-btn dsh-mcp-tailor-cache-clear',
4141
+ onClick: clearCache,
4142
+ disabled: clearing || busy,
4143
+ }, clearing ? t('正在清空缓存…') : t('清空缓存')),
4144
+ );
4145
+ })()
4146
+ : null;
4147
+
2313
4148
  const tools = report && Array.isArray(report.tools) ? report.tools : [];
2314
4149
  const shown = tools
2315
4150
  .filter((tool) => (onlyDead ? tool.calls === 0 : true))
@@ -2332,7 +4167,7 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
2332
4167
  : report.zstdSupported === false
2333
4168
  ? t('zstd 解压不可用:当前 Node 不支持 zlib.zstdDecompressSync(需 >= 22.15),所有调用计数都会是 0')
2334
4169
  : report.failedFrames > 0
2335
- ? t('{0} 个日志帧解码失败,调用计数可能偏低', [fmtInt(report.failedFrames)])
4170
+ ? t('{0} 个日志帧解码失败,调用计数可能偏低', [tailorNum(report.failedFrames, report)])
2336
4171
  : '';
2337
4172
  const scanFailures = report && Array.isArray(report.failedFileSamples) ? report.failedFileSamples : [];
2338
4173
  // 正常运行时这一段**完全不渲染**(无噪音),只有真出问题才占位。
@@ -2344,28 +4179,67 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
2344
4179
  : null,
2345
4180
  scanFailures.length
2346
4181
  ? h('details', { className: 'dsh-mcp-tailor-details' },
2347
- h('summary', null, t('{0} 个日志文件未能读取(含超限跳过),展开看原因', [fmtInt(report.failedFiles)])),
4182
+ h('summary', null, t('{0} 个日志文件未能读取(含超限跳过),展开看原因', [tailorNum(report.failedFiles, report)])),
2348
4183
  h('ul', null, scanFailures.map((sample, i) => h('li', { key: i }, sample))),
2349
4184
  )
2350
4185
  : null,
2351
4186
  )
2352
4187
  : null;
2353
4188
 
2354
- return h('div', { className: 'dsh-mcp-tailor' },
2355
- h('div', { className: 'dsh-mcp-tailor-cards' },
2356
- h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.totalCalls : null)), h('span', null, t('窗口内工具调用总次数({0} 天)', [days]))),
2357
- h('div', { className: 'dsh-mcp-tailor-card' }, h('b', null, fmtInt(report ? report.toolCount : null)), h('span', null, t('出现过的工具(含已卸载 MCP 的历史调用)'))),
2358
- h('div', { className: 'dsh-mcp-tailor-card dead' }, h('b', null, fmtInt(report ? report.deadMcpCount : null)), h('span', null, t('零调用的 MCP 工具(每轮白缴 {0} 上下文税)', [report ? fmtTax(report.deadMcpTaxTokens) : '—']))),
2359
- h('div', { className: 'dsh-mcp-tailor-card err' }, h('b', null, fmtPct(report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0)), h('span', null, t('总体错误率({0} 次失败)', [fmtInt(report ? report.totalErrors : null)]))),
4189
+ // §5.2 头部指标分层:原来四张同权重卡把「上下文税」和「调用总数」摆成一样大,
4190
+ // 于是用户读完四个数字仍然不知道最该动什么。真正需要行动的是「每轮白缴多少税」,
4191
+ // 它才是本页存在的理由;调用数只是背景。因此税单独放大居首,其余三个降为次级 KPI 行。
4192
+ // §5.4 趋势:dailyCalls 来自账本逐日分桶。全 0 或字段缺失时整块不渲染——
4193
+ // 画一排等高灰条假装有数据,比不画更误导。
4194
+ const daily = report && Array.isArray(report.dailyCalls) ? report.dailyCalls : [];
4195
+ const hasTrend = daily.length > 0 && daily.some((v) => v > 0);
4196
+ const errRate = report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0;
4197
+ // 错误率阈值按文档口径:<5% 是常态(绿),≥5% 才值得红着显示。
4198
+ const errTone = errRate >= 0.05 ? 'err' : 'ok';
4199
+ const hero = h('div', { className: 'dsh-mcp-tailor-hero' },
4200
+ h('div', { className: 'dsh-mcp-tailor-main' },
4201
+ h('b', null, report ? fmtTax(report.deadMcpTaxTokens).replace(/^~/, '') : '—'),
4202
+ h('span', null, t('每轮请求白缴的上下文税')),
4203
+ h('span', { className: 'dsh-mcp-tailor-main-sub' },
4204
+ report
4205
+ ? t('{0} 个零调用 MCP 工具的 Schema,占全部工具税 {1} 的 {2}', [
4206
+ tailorNum(report.deadMcpCount, report), // OPT-18:zstd 不可用时 0 改 —
4207
+ fmtTax(report.taxTotalTokens),
4208
+ report.taxTotalTokens > 0 ? Math.round((report.deadMcpTaxTokens / report.taxTotalTokens) * 100) + '%' : '—',
4209
+ ])
4210
+ : t('扫描后给出'),
4211
+ ),
4212
+ hasTrend ? h('div', { className: 'dsh-mcp-tailor-trend' }, h(Spark, { series: daily, title: t('近 {0} 天每日调用量(左旧右新)', [days]) }), h('span', null, t('近 {0} 天每日调用', [days]))) : null,
4213
+ ),
4214
+ h('div', { className: 'dsh-mcp-tailor-kpis' },
4215
+ // OPT-18:四张 KPI 的数字都随报告来,zstd 不可用时 0 是「测不到」→ 统一 tailorNum。
4216
+ h('div', { className: 'dsh-mcp-tailor-kpi' }, h('b', null, tailorNum(report ? report.totalCalls : null, report)), h('span', null, t('调用 {0} 次', [days + t(' 天')]))),
4217
+ h('div', { className: 'dsh-mcp-tailor-kpi' }, h('b', null, tailorNum(report ? report.toolCount : null, report)), h('span', null, t('出现过的工具(含已卸载 MCP 的历史调用)'))),
4218
+ // 零调用卡可点:点击即切「只看零调用」。原来这个筛选按钮在下行工具栏,
4219
+ // 用户看到「183 零调用」后得自己去别处找入口,关联断了。
4220
+ h('button', {
4221
+ type: 'button',
4222
+ className: 'dsh-mcp-tailor-kpi dead clickable',
4223
+ 'aria-pressed': onlyDead,
4224
+ title: onlyDead ? t('取消「只看零调用」') : t('只看窗口内零调用的工具'),
4225
+ onClick: () => setOnlyDead((v) => !v),
4226
+ }, h('b', null, tailorNum(report ? report.deadMcpCount : null, report)), h('span', null, onlyDead ? t('零调用的 MCP 工具(已筛选)') : t('零调用的 MCP 工具 · 点击下钻'))),
4227
+ h('div', { className: 'dsh-mcp-tailor-kpi ' + errTone }, h('b', null, fmtPct(errRate)), h('span', null, t('总体错误率({0} 次失败)', [tailorNum(report ? report.totalErrors : null, report)]))),
2360
4228
  ),
4229
+ );
4230
+
4231
+ return h('div', { className: 'dsh-mcp-tailor' },
4232
+ hero,
2361
4233
  h('div', { className: 'dsh-mcp-tailor-toolbar' },
2362
4234
  TAILOR_WINDOWS.map((w) =>
2363
4235
  h('button', { key: w.key, type: 'button', className: 'dsh-mcp-btn' + (windowKey === w.key ? ' primary' : ''), onClick: () => setWindowKey(w.key) }, w.label),
2364
4236
  ),
2365
- h('button', { type: 'button', className: 'dsh-mcp-btn' + (onlyDead ? ' primary' : ''), onClick: () => setOnlyDead((v) => !v), title: t('只看窗口内零调用的工具(默认关闭:默认显示全部工具与真实调用次数)') }, report ? t('只看零调用({0})', [fmtInt(deadToolCount)]) : t('只看零调用')),
4237
+ h('button', { type: 'button', className: 'dsh-mcp-btn' + (onlyDead ? ' primary' : ''), onClick: () => setOnlyDead((v) => !v), title: t('只看窗口内零调用的工具(默认关闭:默认显示全部工具与真实调用次数)') }, report ? t('只看零调用({0})', [tailorNum(deadToolCount, report)]) : t('只看零调用')),
2366
4238
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, t('重扫')),
2367
4239
  h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
2368
4240
  ),
4241
+ // OPT-19 · 4.3:缓存占用 + 一键清空(先清后扫,一次调用拿新报告)。
4242
+ cacheLine,
2369
4243
  error ? h('div', { className: 'dsh-mcp-tailor-note' }, t('账本加载失败:'), fmtScanError(error)) : null,
2370
4244
  // 解码告警放在数据和表格**上方**:调用数整列 0 时,用户第一眼要能看到「为什么」。
2371
4245
  decodeIssue,
@@ -2373,8 +4247,14 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
2373
4247
  flashMsg ? h('div', { className: 'dsh-mcp-tailor-note' }, flashMsg) : null,
2374
4248
  h('div', { className: 'dsh-mcp-tailor-table' },
2375
4249
  h('div', { className: 'dsh-mcp-tailor-row head' },
2376
- h('span', null, t('工具')), h('span', { className: 't-num' }, t('调用')), h('span', { className: 't-num' }, t('错误')),
2377
- h('span', { className: 't-num' }, t('错误率')), h('span', { className: 't-tax' }, t('Schema 税')), h('span', null, t('最后使用 / 操作')),
4250
+ h('span', { className: 't-name' }, t('工具')),
4251
+ h('span', { className: 't-num' }, t('调用')),
4252
+ // §5.1 合并「错误 / 错误率」两列:这两列永远同源(率 = 错误/调用),
4253
+ // 分开列只会让同一份信息占两格宽度,还逼出了横向挤压。合并成「7 (0.7%)」。
4254
+ h('span', { className: 't-num' }, t('错误 (率)')),
4255
+ h('span', { className: 't-tax' }, t('Schema 税')),
4256
+ h('span', null, t('最后使用')),
4257
+ h('span', { className: 't-op', 'aria-label': t('操作') }),
2378
4258
  ),
2379
4259
  shown.length === 0
2380
4260
  ? h('div', { className: 'dsh-mcp-tailor-empty' }, onlyDead && report ? t('窗口内没有零调用的工具(共 {0} 个工具都有调用,关掉「只看零调用」即可看全部)', [fmtInt(report.toolCount)]) : (busy ? t('扫描中…') : t('暂无数据')))
@@ -2382,27 +4262,34 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
2382
4262
  const actionable = tool.kind === 'mcp' && tool.server;
2383
4263
  const isDead = tool.calls === 0;
2384
4264
  const errRate = tool.errorRate || 0;
4265
+ // §5.3 行尾 ⋯ 菜单:原来「禁用服务器 / 启用」两个按钮直铺在行尾,
4266
+ // 对非 MCP 行完全没有意义,却每行都占掉一截宽度。收进菜单后,
4267
+ // 默认行尾只剩一个 24px 图标,且能把「复制工具名」这类零散动作一并收进去。
4268
+ const menuItems = [
4269
+ ...(actionable
4270
+ ? [
4271
+ tool.serverDisabled
4272
+ ? { label: t('启用服务器 {0}', [tool.server]), onClick: () => tailorAct('enable', tool.server, t('已启用 {0}', [tool.server])), disabled: busy }
4273
+ : { label: t('禁用服务器 {0}', [tool.server]), onClick: () => tailorAct('disable', tool.server, t('已禁用 {0}', [tool.server])), disabled: busy, danger: true },
4274
+ ]
4275
+ : []),
4276
+ { label: t('复制工具名'), onClick: () => { try { navigator.clipboard?.writeText?.(tool.name); flash(t('已复制「{0}」', [tool.name])); } catch { flash(t('复制失败'), 'err'); } } },
4277
+ ];
2385
4278
  return h('div', { key: tool.name, className: 'dsh-mcp-tailor-row' + (isDead ? ' dead' : '') },
2386
- h('span', { className: 't-name' },
4279
+ h('span', { className: 't-name', title: tool.name },
2387
4280
  tool.server ? h('span', { className: 't-badge' }, tool.server) : null,
2388
4281
  tool.server ? tool.leaf : tool.name,
2389
4282
  tool.serverDisabled ? h('span', { className: 't-badge warn' }, t('已禁用')) : null,
2390
4283
  errRate >= 0.5 ? h('span', { className: 't-badge err' }, t('高错误率')) : null,
2391
4284
  ),
2392
4285
  h('span', { className: 't-num' }, fmtInt(tool.calls)),
2393
- h('span', { className: 't-num' }, fmtInt(tool.errors)),
2394
- h('span', { className: 't-num' }, fmtPct(errRate)),
2395
- h('span', { className: 't-tax' }, fmtTax(tool.schemaTokens)),
2396
- h('span', { className: 't-last' },
2397
- fmtLast(tool.lastUsedMs, report ? report.generatedAt : undefined),
2398
- actionable && !tool.serverDisabled
2399
- ? h('span', { className: 't-act' },
2400
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: () => tailorAct('disable', tool.server, t('已禁用 {0}', [tool.server])) }, t('禁用服务器')),
2401
- ' ',
2402
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: busy, onClick: () => tailorAct('enable', tool.server, t('已启用 {0}', [tool.server])) }, t('启用')),
2403
- )
2404
- : null,
2405
- ),
4286
+ // 合并列:错误数为 0 时只显示 0,不补一个没信息量的 (0%)。
4287
+ h('span', { className: 't-num' + (errRate >= 0.5 ? ' bad' : '') },
4288
+ tool.errors > 0 ? fmtInt(tool.errors) + ' (' + fmtPct(errRate) + ')' : fmtInt(tool.errors)),
4289
+ // §5.1 Schema 税为「—」时不留占位符,直接空着:一串横杠只会让列看起来像坏了。
4290
+ h('span', { className: 't-tax' }, tool.schemaTokens ? fmtTax(tool.schemaTokens) : ''),
4291
+ h('span', { className: 't-last' }, fmtLast(tool.lastUsedMs, report ? report.generatedAt : undefined)),
4292
+ h('span', { className: 't-op' }, h(RowMenu, { label: t('操作'), items: menuItems })),
2406
4293
  );
2407
4294
  }),
2408
4295
  ),
@@ -2422,6 +4309,41 @@ const BOARD_CATEGORIES = [
2422
4309
  { key: 'tailor', label: t('工具裁缝'), icon: 'scissors' },
2423
4310
  { key: 'wb', label: t('市场'), icon: 'store' },
2424
4311
  ];
4312
+ // 分类摘要文案表:把「这一页到底有什么」压成一句话 + 两三行事实。
4313
+ // 拆成纯数据而不是写在渲染里,是因为它同时被 hover 浮层和键盘焦点复用,
4314
+ // 且后面加分类时只要补一条,不必改渲染逻辑。
4315
+ const CATEGORY_BLURB = {
4316
+ mcp: t('按服务器看工具清单、连接状态与参数;可增删、重连、导入。'),
4317
+ skill: t('本地 Skill 清单:来源、是否启用、是否可被模型调用。'),
4318
+ expert: t('已召唤的专家:人设、捆绑技能、当前在场状态,可送还。'),
4319
+ tailor: t('工具使用账本:谁在被调用、谁在白缴上下文税。'),
4320
+ wb: t('市场:连接器、内置 MCP、SkillHub 技能与专家召唤。'),
4321
+ };
4322
+ // §1.1 分类 hover 摘要。定位由父层算好(left),这里只负责内容与外观。
4323
+ // 只用 pointer-events:none 的只读浮层:不承载交互,因此不需要焦点陷阱,
4324
+ // 也不会在鼠标移向浮层时闪烁(移向浮层必然先离开 chip,浮层直接收起)。
4325
+ const CatHintPopover = ({ hint, category, counts }) => {
4326
+ const cat = BOARD_CATEGORIES.find((c) => c.key === hint.key);
4327
+ if (!cat) return null;
4328
+ const facts = [];
4329
+ if (hint.key === 'mcp' && counts.mcp !== null) facts.push([t('服务器'), counts.mcp]);
4330
+ if (hint.key === 'skill' && counts.skill !== null) facts.push([t('Skill'), counts.skill]);
4331
+ if (hint.key === 'expert' && counts.expert !== null) facts.push([t('已召唤'), counts.expert]);
4332
+ // 市场计数是「本地缓存的连接器条数」:进过市场才有数(null 时不显示这行)。
4333
+ if (hint.key === 'wb' && Number.isFinite(counts.wb)) facts.push([t('连接器'), counts.wb]);
4334
+ const tailor = counts.tailor;
4335
+ if (hint.key === 'tailor' && tailor) {
4336
+ if (typeof tailor.deadMcpCount === 'number') facts.push([t('零调用工具'), tailor.deadMcpCount]);
4337
+ if (typeof tailor.totalCalls === 'number') facts.push([t('窗口内调用'), tailor.totalCalls]);
4338
+ }
4339
+ return h('div', { className: 'dsh-mcp-cat-pop', style: { left: hint.left + 'px' }, role: 'presentation' },
4340
+ h('b', null, cat.label),
4341
+ h('span', null, CATEGORY_BLURB[cat.key] || ''),
4342
+ facts.length ? h('div', { className: 'dsh-mcp-cat-pop-facts' }, facts.map(([label, value]) => h('div', { key: label }, h('i', null, label), h('b', null, fmtInt(value))))) : null,
4343
+ // 页脚只在「不是当前页」时提示点击——已选中的页再提示"点击打开"是噪音。
4344
+ category !== hint.key ? h('div', { className: 'dsh-mcp-cat-pop-foot' }, t('点击切换到此分类')) : null,
4345
+ );
4346
+ };
2425
4347
  // 宿主侧接口不存在(HTTP 404 / transport failure)几乎总是「宿主还没重启」:
2426
4348
  // 客户端 bundle 从磁盘现读、改完刷新即生效,而宿主的 Remote 方法在启动时才注册。
2427
4349
  // 这里把生硬的传输错误翻译成可操作的提示,而不是让用户对着 404 猜。
@@ -2440,6 +4362,10 @@ const SkillBoard = ({ call, scope, timer }) => {
2440
4362
  const [loading, setLoading] = useState(true);
2441
4363
  const [refreshing, setRefreshing] = useState(false);
2442
4364
  const [query, setQuery] = useState('');
4365
+ // §3.1 过滤条状态(能力位 + 来源),与搜索可叠加。
4366
+ const [onlyModel, setOnlyModel] = useState(false);
4367
+ const [onlyUser, setOnlyUser] = useState(false);
4368
+ const [sourceFilter, setSourceFilter] = useState('all');
2443
4369
  const [selected, setSelected] = useState('');
2444
4370
  const [detail, setDetail] = useState(null);
2445
4371
  const [detailLoading, setDetailLoading] = useState(false);
@@ -2480,9 +4406,26 @@ const SkillBoard = ({ call, scope, timer }) => {
2480
4406
  }, [load]);
2481
4407
 
2482
4408
  const queryKey = (query || '').toLowerCase();
2483
- const filtered = queryKey
2484
- ? skills.filter((s) => (s.name + ' ' + (s.description || '')).toLowerCase().includes(queryKey))
2485
- : skills;
4409
+ // §3.1 过滤条。注意:宿主 skill 注册表**没有**启用/禁用开关(listSkills 只给
4410
+ // modelInvocable / userInvocable 这类能力位,没有 enable/disable 写入接口),
4411
+ // 所以这里不伪造一个「启用开关」——那会是个点了不生效的假控件。
4412
+ // 真正可筛的是「谁能调用它」,以及来源;禁用态灰显同理不做(没有禁用概念)。
4413
+ const filtered = skills.filter((s) => {
4414
+ if (onlyModel && !s.modelInvocable) return false;
4415
+ if (onlyUser && !s.userInvocable) return false;
4416
+ if (sourceFilter !== 'all' && (s.source || 'unknown') !== sourceFilter) return false;
4417
+ if (queryKey && !(s.name + ' ' + (s.description || '')).toLowerCase().includes(queryKey)) return false;
4418
+ return true;
4419
+ });
4420
+ // 来源统计一次算完(不要在每个 chip 里各 filter 一遍)。
4421
+ const sourceCounts = useMemo(() => {
4422
+ const map = new Map();
4423
+ for (const s of skills) {
4424
+ const key = s.source || 'unknown';
4425
+ map.set(key, (map.get(key) || 0) + 1);
4426
+ }
4427
+ return map;
4428
+ }, [skills]);
2486
4429
  // 按来源分组展示(与官方 skill 子系统的发现优先级同序),组内保持注册表返回的名字排序。
2487
4430
  const groups = useMemo(() => {
2488
4431
  const bySource = new Map();
@@ -2513,6 +4456,21 @@ const SkillBoard = ({ call, scope, timer }) => {
2513
4456
  }
2514
4457
  }, [call, scope]);
2515
4458
 
4459
+ // §4.2 接受跨分类定位:专家页的技能 chip 跳过来时,直接打开该技能的详情。
4460
+ // 两个来源都要接——订阅(已挂载时即时生效)与挂载时自领(从别的页签跳过来时,
4461
+ // 广播早已发完,只能靠模块里留下的 pending 目标)。
4462
+ useEffect(() => {
4463
+ const focus = (name) => {
4464
+ const target = String(name || '').trim();
4465
+ if (!target) return;
4466
+ pendingSkillFocus = '';
4467
+ openDetail(target);
4468
+ };
4469
+ const off = onSkillFocus(focus);
4470
+ if (pendingSkillFocus) focus(pendingSkillFocus);
4471
+ return off;
4472
+ }, [openDetail]);
4473
+
2516
4474
  const summary = selected ? skills.find((s) => s.name === selected) : null;
2517
4475
 
2518
4476
  return h('div', { className: 'dsh-mcp-skill-view' },
@@ -2523,13 +4481,66 @@ const SkillBoard = ({ call, scope, timer }) => {
2523
4481
  h(Icon, { d: ICONS.search }),
2524
4482
  h('input', { placeholder: t('搜索 skill…'), value: query, onChange: (e) => setQuery(e.target.value), 'aria-label': t('搜索 skill') }),
2525
4483
  ),
4484
+ h('div', { className: 'dsh-mcp-skill-filters' },
4485
+ h('button', {
4486
+ type: 'button', className: 'dsh-mcp-chip-s' + (onlyModel ? ' on' : ''), 'aria-pressed': onlyModel,
4487
+ title: t('只显示模型可以主动调用的 skill'),
4488
+ onClick: () => setOnlyModel((v) => !v),
4489
+ }, t('模型可调用')),
4490
+ h('button', {
4491
+ type: 'button', className: 'dsh-mcp-chip-s' + (onlyUser ? ' on' : ''), 'aria-pressed': onlyUser,
4492
+ title: t('只显示用户可以用 /名字 触发的 skill'),
4493
+ onClick: () => setOnlyUser((v) => !v),
4494
+ }, t('用户可调用')),
4495
+ [...sourceCounts.entries()].map(([source, count]) => h('button', {
4496
+ key: source,
4497
+ type: 'button',
4498
+ className: 'dsh-mcp-chip-s' + (sourceFilter === source ? ' on' : ''),
4499
+ 'aria-pressed': sourceFilter === source,
4500
+ title: t('只看来自该来源的 skill'),
4501
+ onClick: () => setSourceFilter((prev) => (prev === source ? 'all' : source)),
4502
+ }, skillSourceLabel(source), h('span', { className: 'dsh-mcp-chip-s-count' }, count))),
4503
+ sourceFilter !== 'all'
4504
+ ? h('button', {
4505
+ type: 'button', className: 'dsh-mcp-chip-s more',
4506
+ onClick: () => setSourceFilter('all'),
4507
+ }, t('清除来源筛选'))
4508
+ : null,
4509
+ ),
2526
4510
  h('div', { className: 'dsh-mcp-item-sub' },
2527
- h('span', null, (loading && !refreshing) ? t('读取中…') : (skills.length + t(' 个 skill'))),
4511
+ h('span', null, (loading && !refreshing) ? t('读取中…') : t('{0} 个 skill', [filtered.length])
4512
+ + (filtered.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
2528
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
+ ),
2529
4522
  ),
2530
4523
  h('div', { className: 'dsh-mcp-skill-list', role: 'listbox', 'aria-label': t('skill 列表') },
2531
4524
  groups.length === 0 && !loading
2532
- ? h('div', { className: 'dsh-mcp-skill-empty' }, t('这个作用域下没有发现 skill。skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现。'))
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
+ }))
2533
4544
  : groups.map((g) => h(React2.Fragment, { key: g.source },
2534
4545
  h('div', { className: 'dsh-mcp-item-sub', style: { marginTop: '6px' } },
2535
4546
  h(Badge, { cls: 't-stdio' }, skillSourceLabel(g.source)),
@@ -2548,7 +4559,10 @@ const SkillBoard = ({ call, scope, timer }) => {
2548
4559
  h('div', { className: 'dsh-mcp-skill-sub' },
2549
4560
  s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
2550
4561
  s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
2551
- h('span', { className: 'dsh-mcp-skill-src' }, s.provider || ''),
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,
2552
4566
  ),
2553
4567
  s.description ? h('div', { className: 'dsh-mcp-skill-desc' }, s.description) : null,
2554
4568
  ),
@@ -2629,6 +4643,24 @@ const loadExpertCatalogShared = async (call, force = false) => {
2629
4643
  expertCatalogCache = { at: Date.now(), data: { experts: res.experts || [], categories: res.categories || [], loadedFrom: res.loadedFrom || '' } };
2630
4644
  return expertCatalogCache.data;
2631
4645
  };
4646
+ // §4.2 跨分类技能定位:专家卡上的捆绑技能 chip 点击 → 跳 Skill 页并打开该技能详情。
4647
+ // 两段式:页面可能还没挂载(当前在专家页),所以目标先记在模块里,SkillBoard 挂载后自领;
4648
+ // 已挂载时直接广播,省掉一次可见的中间态。
4649
+ let pendingSkillFocus = '';
4650
+ const skillFocusListeners = new Set();
4651
+ function openSkillFocus(name) {
4652
+ const target = String(name || '').trim();
4653
+ if (!target) return;
4654
+ pendingSkillFocus = target;
4655
+ for (const listener of [...skillFocusListeners]) {
4656
+ try { listener(target); } catch { /* 单个订阅者失败不影响跳转本身 */ }
4657
+ }
4658
+ openBoardCategory('skill');
4659
+ }
4660
+ const onSkillFocus = (listener) => {
4661
+ skillFocusListeners.add(listener);
4662
+ return () => { skillFocusListeners.delete(listener); };
4663
+ };
2632
4664
  // 已装专家行(看板「专家」分类):按 wbExpertInstalled 投影——预设行 + 捆绑技能。
2633
4665
  // 点击卡片 = 打开该专家的详情(从共享 catalog 补全展示字段);操作 = 送还 / 重新召唤。
2634
4666
  const ExpertInstalledBoard = ({ call }) => {
@@ -2640,6 +4672,19 @@ const ExpertInstalledBoard = ({ call }) => {
2640
4672
  const [presets, setPresets] = useState([]);
2641
4673
  const [skills, setSkills] = useState([]);
2642
4674
  const [detail, setDetail] = useState(null);
4675
+ // §4.1 在场:expertActive 是「谁真的挂在会话上」的唯一真源。没有它,「已召唤」
4676
+ // (预设已落盘,新会话才接管)和「在场」(人设真的在这轮对话里生效)就是同一个绿 tag,
4677
+ // 而这恰恰是这一页最该说清楚的区别。
4678
+ const [activeRows, setActiveRows] = useState([]);
4679
+ // §4.1 送还二次确认:内联确认,不弹窗(弹窗会打断「确认—执行」的因果关系)。
4680
+ const [confirmDismiss, setConfirmDismiss] = useState('');
4681
+ // OPT-16 · 3.2:persona 预览展开态(已召唤专家详情弹层局部)。
4682
+ const [personaExpanded, setPersonaExpanded] = useState(false);
4683
+ // §4.1 空白区填充:召唤位推荐(catalog 前若干位,排除已召唤)。
4684
+ const [catalog, setCatalog] = useState([]);
4685
+ // OPT-21:详情弹层此前没有 ref,焦点陷阱需要容器节点。
4686
+ const summonedRef = useRef(null);
4687
+ useFocusTrap(summonedRef, !!detail);
2643
4688
 
2644
4689
  const load = useCallback(async () => {
2645
4690
  setLoading(true); setError(''); setNote('');
@@ -2656,6 +4701,48 @@ const ExpertInstalledBoard = ({ call }) => {
2656
4701
  }, [call]);
2657
4702
  useEffect(() => { load(); }, [load]);
2658
4703
 
4704
+ // §4.1 在场快照:与 SessionExpertBadge 同源(expertActive),并订阅同一个变更总线,
4705
+ // 这样在会话头挂了/摘了专家,这一页不需要刷新就是对的。
4706
+ useEffect(() => {
4707
+ let alive = true;
4708
+ const sync = () => {
4709
+ call('expertActive').then((res) => {
4710
+ if (!alive) return;
4711
+ const rows = res && res.ok && Array.isArray(res.sessions) ? res.sessions : [];
4712
+ setActiveRows(rows);
4713
+ }).catch(() => {});
4714
+ };
4715
+ sync();
4716
+ const off = onExpertChange(sync);
4717
+ const stopPoll = startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, null, 5000, sync);
4718
+ return () => { alive = false; off(); stopPoll(); };
4719
+ }, [call]);
4720
+
4721
+ // §4.1 召唤位推荐:只在架上为空时取目录(有专家时不需要,省一次请求)。
4722
+ useEffect(() => {
4723
+ if (loading || presets.length) return undefined;
4724
+ let alive = true;
4725
+ loadExpertCatalogShared(call).then((data) => {
4726
+ if (alive) setCatalog(Array.isArray(data.experts) ? data.experts : []);
4727
+ }).catch(() => {});
4728
+ return () => { alive = false; };
4729
+ }, [call, loading, presets.length]);
4730
+
4731
+ // 召唤(推荐位点击):与市场页同一条写链,成功后刷新本页 + 广播给会话头徽章。
4732
+ const summon = async (expert) => {
4733
+ setBusy(true); setBusyLabel(t('正在召唤 {0}…', [expert.name || expert.plugin])); setError('');
4734
+ try {
4735
+ const res = await call('wbExpertSummon', { plugin: expert.plugin, withSkills: true });
4736
+ if (!res?.ok) throw new Error(res?.error || t('召唤失败'));
4737
+ setNote(t('{0} 已召唤,新建会话时可在预设选择器里选它', [expert.name || expert.plugin]));
4738
+ await load();
4739
+ } catch (e) {
4740
+ setError(String(e?.message || e));
4741
+ } finally {
4742
+ setBusy(false); setBusyLabel('');
4743
+ }
4744
+ };
4745
+
2659
4746
  // 送还:与市场页同一写链;送还后刷新本页。
2660
4747
  const dismiss = async (plugin, name) => {
2661
4748
  setBusy(true); setBusyLabel(t('正在送还 {0}…', [name || plugin])); setError('');
@@ -2686,43 +4773,144 @@ const ExpertInstalledBoard = ({ call }) => {
2686
4773
  // 技能按 plugin 分组:预设行是主行,捆绑技能是它的附件清单。
2687
4774
  const skillsByPlugin = {};
2688
4775
  for (const row of skills) (skillsByPlugin[row.plugin] ??= []).push(row.skill);
4776
+ // 在场行按 plugin 归并:同一专家可能同时挂在多个会话上(不同 sessionId),
4777
+ // 卡面只关心「这个 plugin 现在有几个会话在用」,逐个会话平铺会把同一张卡切成多份。
4778
+ const activeByPlugin = new Map();
4779
+ for (const row of activeRows) {
4780
+ if (!activeByPlugin.has(row.plugin)) activeByPlugin.set(row.plugin, []);
4781
+ activeByPlugin.get(row.plugin).push(row);
4782
+ }
4783
+ // 推荐位:目录里还没召唤的,按目录序取前 6 —— 不排序、不评分,避免自造推荐逻辑。
4784
+ const summonedPlugins = new Set(presets.map((row) => row.plugin));
4785
+ const recommend = catalog.filter((item) => item && item.plugin && !summonedPlugins.has(item.plugin)).slice(0, 6);
2689
4786
  return h('div', null,
2690
4787
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
2691
4788
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
2692
4789
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
2693
4790
  loading ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取已召唤专家…')) : null,
4791
+ // §4.1「当前在场」区:已召唤 = 预设已落盘(新会话才接管),在场 = 人设真的挂在
4792
+ // 运行中的会话上。这两件事原来共用一个绿 tag,是最容易误判的地方,所以单独成区、
4793
+ // 单独给色(在场用绿,已召唤但不在场用中性)。
4794
+ !loading
4795
+ ? h('div', { className: 'dsh-mcp-onsite' },
4796
+ h('div', { className: 'dsh-mcp-onsite-head' },
4797
+ h('b', null, t('当前在场')),
4798
+ h('span', null, activeRows.length
4799
+ ? t('{0} 个专家正挂在 {1} 个会话上', [activeByPlugin.size, activeRows.length])
4800
+ : t('没有专家正在接管会话')),
4801
+ ),
4802
+ activeRows.length === 0
4803
+ ? h('div', { className: 'dsh-mcp-onsite-empty' },
4804
+ t('召唤的专家是「预设」:新建会话时在预设选择器里选它才真正接管。想立刻让某个专家接管当前会话,用输入框左侧的召唤菜单。'))
4805
+ : [...activeByPlugin.entries()].map(([plugin, rows]) => {
4806
+ const preset = presets.find((p) => p.plugin === plugin);
4807
+ const bound = skillsByPlugin[plugin] || [];
4808
+ return h('div', { key: plugin, className: 'dsh-mcp-onsite-body' },
4809
+ h('div', { className: 'dsh-mcp-onsite-name' }, (rows[0] && rows[0].name) || (preset && preset.presetId) || plugin),
4810
+ preset && preset.presetId ? h('div', { className: 'dsh-mcp-onsite-role' }, preset.presetId) : null,
4811
+ bound.length
4812
+ ? h('div', { className: 'dsh-mcp-onsite-chips' },
4813
+ bound.slice(0, 6).map((skill) => h('button', {
4814
+ key: skill,
4815
+ type: 'button',
4816
+ className: 'dsh-mcp-chip-s',
4817
+ title: t('在 Skill 页查看 {0}', [skill]),
4818
+ onClick: () => openSkillFocus(skill),
4819
+ }, skill)),
4820
+ bound.length > 6 ? h('span', { className: 'dsh-mcp-chip-s more' }, '+' + (bound.length - 6)) : null,
4821
+ )
4822
+ : h('div', { className: 'dsh-mcp-onsite-chips' }, h('span', { className: 'dsh-mcp-onsite-role' }, t('无捆绑技能'))),
4823
+ h('div', { className: 'dsh-mcp-onsite-act' },
4824
+ h('span', { className: 'dsh-mcp-onsite-role', title: rows.map((r) => r.sessionId).join('\n') },
4825
+ rows.length > 1 ? t('{0} 个会话', [rows.length]) : t('会话 {0}', [String(rows[0].sessionId || '').slice(0, 8)])),
4826
+ ),
4827
+ );
4828
+ }),
4829
+ )
4830
+ : null,
4831
+ // 已召唤清单:这里才是「送还」的主场;送还走内联确认(见 §4.1)。
2694
4832
  !loading && !presets.length && !skills.length
2695
- ? h('div', { className: 'dsh-mcp-wb-empty' },
2696
- t('还没有召唤任何专家。到 '),
2697
- h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, t('市场 → 专家')),
2698
- t(' 挑选一位召唤;召唤后它会出现在这里,并作为独立 Agent 预设出现在新会话的预设选择器里。'))
4833
+ ? h(EmptyState, {
4834
+ icon: 'expert',
4835
+ title: t('还没有召唤任何专家'),
4836
+ hint: t('召唤后它会作为独立 Agent 预设出现在新会话的预设选择器里;也可以直接从下面的推荐位挑一位。'),
4837
+ actions: [{ label: t('去市场挑专家'), icon: 'store', onClick: () => openBoardCategory('wb') }],
4838
+ })
2699
4839
  : null,
2700
4840
  !loading && presets.length
2701
4841
  ? h('div', { className: 'dsh-mcp-wb-grid' },
2702
4842
  presets.map((row) => {
2703
4843
  const boundSkills = skillsByPlugin[row.plugin] || [];
4844
+ const activeList = activeByPlugin.get(row.plugin) || [];
4845
+ const confirming = confirmDismiss === row.plugin;
2704
4846
  return h('div', { key: row.presetId, className: 'dsh-mcp-card wb installed' },
2705
4847
  h('div', { className: 'dsh-mcp-card-top' },
2706
4848
  h('div', null,
2707
4849
  h('div', { className: 'dsh-mcp-card-name' }, row.plugin),
2708
4850
  h('div', { className: 'dsh-mcp-card-vendor' }, row.presetId),
2709
4851
  ),
2710
- h(Badge, { cls: 's-on' }, t('已召唤')),
4852
+ activeList.length
4853
+ ? h(Badge, { cls: 's-on' }, t('在场 · {0} 会话', [activeList.length]))
4854
+ : h(Badge, { cls: 's-off' }, t('已召唤 · 未接管')),
2711
4855
  ),
2712
- h('div', { className: 'dsh-mcp-card-summary' },
2713
- boundSkills.length ? t('捆绑技能:{0}', [boundSkills.join(t('、'))]) : t('无捆绑技能(预设本体在 profile 补丁)')),
4856
+ // §4.2 绑定技能 chips 化:纯文本看不出能不能点,点了以后去哪也不清楚。
4857
+ boundSkills.length
4858
+ ? h('div', { className: 'dsh-mcp-card-tags' },
4859
+ boundSkills.map((skill) => h('button', {
4860
+ key: skill,
4861
+ type: 'button',
4862
+ className: 'dsh-mcp-chip-s',
4863
+ title: t('在 Skill 页查看 {0}', [skill]),
4864
+ onClick: () => openSkillFocus(skill),
4865
+ }, skill)))
4866
+ : h('div', { className: 'dsh-mcp-card-summary' }, t('无捆绑技能(预设本体在 profile 补丁)')),
2714
4867
  h('div', { className: 'dsh-mcp-wb-card-actions' },
2715
4868
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(row.plugin) }, t('详情')),
2716
- h('button', { type: 'button', className: 'dsh-mcp-btn danger', disabled: busy, onClick: () => dismiss(row.plugin, row.plugin) }, t('送还')),
4869
+ confirming
4870
+ // 内联确认:不弹窗、不移位——原地换成「确认/取消」,误触成本从 0 提到 1 次点击。
4871
+ ? h(InlineConfirm, {
4872
+ question: t('送还后此人设从所有会话移除,确认送还?'),
4873
+ confirmLabel: t('确认送还'),
4874
+ busy,
4875
+ onConfirm: () => { setConfirmDismiss(''); dismiss(row.plugin, row.plugin); },
4876
+ onCancel: () => setConfirmDismiss(''),
4877
+ })
4878
+ : h('button', {
4879
+ type: 'button', className: 'dsh-mcp-btn danger', disabled: busy,
4880
+ onClick: () => setConfirmDismiss(row.plugin),
4881
+ }, t('送还')),
2717
4882
  ),
2718
4883
  );
2719
4884
  }),
2720
4885
  )
2721
4886
  : null,
4887
+ // §4.1 空白区:推荐位(原页面 80% 是空白,且没有任何「下一步」)。
4888
+ !loading && recommend.length
4889
+ ? h('div', null,
4890
+ h('div', { className: 'dsh-mcp-item-sub', style: { marginTop: '14px' } },
4891
+ h('span', null, presets.length ? t('再看看这些(目录前 {0} 位未召唤)', [recommend.length]) : t('可以从这几位开始(目录前 {0} 位)', [recommend.length])),
4892
+ h('a', { href: '#', onClick: (e) => { e.preventDefault(); openBoardCategory('wb'); } }, t('去市场看全部')),
4893
+ ),
4894
+ h('div', { className: 'dsh-mcp-wb-grid' },
4895
+ recommend.map((item) => h('div', { key: item.plugin, className: 'dsh-mcp-card wb' },
4896
+ h('div', { className: 'dsh-mcp-card-top' },
4897
+ h('div', null,
4898
+ h('div', { className: 'dsh-mcp-card-name' }, item.name || item.plugin),
4899
+ h('div', { className: 'dsh-mcp-card-vendor' }, item.profession || item.category || item.plugin),
4900
+ ),
4901
+ ),
4902
+ item.description ? h('div', { className: 'dsh-mcp-card-summary' }, item.description) : null,
4903
+ h('div', { className: 'dsh-mcp-wb-card-actions' },
4904
+ h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => summon(item) }, t('召唤')),
4905
+ ),
4906
+ )),
4907
+ ),
4908
+ )
4909
+ : null,
2722
4910
  // 详情弹层:与市场页共用结构(只读 + 送还)。
2723
4911
  detail
2724
4912
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2725
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('已召唤专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
4913
+ h('div', { ref: summonedRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('已召唤专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
2726
4914
  h('h3', null, detail.name ? t('{0}({1})', [detail.name, detail.profession || '']) : detail.plugin),
2727
4915
  detail.loading
2728
4916
  ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取详情…'))
@@ -2736,7 +4924,12 @@ const ExpertInstalledBoard = ({ call }) => {
2736
4924
  detail.persona
2737
4925
  ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2738
4926
  h('b', null, t('人设正文({0} 字符)', [detail.personaChars || detail.persona.length])),
2739
- h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.persona.length > 4000 ? detail.persona.slice(0, 4000) + t('\n…(预览截断)') : detail.persona),
4927
+ h('pre', { className: 'dsh-mcp-skill-d-md' }, personaExpanded || detail.persona.length <= PERSONA_PREVIEW_MAX ? detail.persona : detail.persona.slice(0, PERSONA_PREVIEW_MAX) + t('\n…(预览截断)')),
4928
+ personaExpanded
4929
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-link-btn', onClick: () => setPersonaExpanded(false) }, t('收起'))
4930
+ : detail.persona.length > PERSONA_PREVIEW_MAX
4931
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-link-btn', onClick: () => setPersonaExpanded(true) }, t('展开全文({0} 字符)', [fmtInt(detail.persona.length)]))
4932
+ : null,
2740
4933
  )
2741
4934
  : null,
2742
4935
  h('p', { className: 'dsh-mcp-wb-modal-note' },
@@ -2771,6 +4964,9 @@ const ExpertMarket = ({ call }) => {
2771
4964
  const presets = useMemo(() => new Set(presetRows.map((row) => row.plugin)), [presetRows]);
2772
4965
  const [withSkills, setWithSkills] = useState(true);
2773
4966
  const [installedSkillPlugins, setInstalledSkillPlugins] = useState(new Set());
4967
+ // OPT-21:市场专家详情弹层此前没有 ref。
4968
+ const expertDetailRef = useRef(null);
4969
+ useFocusTrap(expertDetailRef, !!detail);
2774
4970
 
2775
4971
  const loadInstalled = useCallback(async () => {
2776
4972
  const res = await call('wbExpertInstalled');
@@ -2862,20 +5058,33 @@ const ExpertMarket = ({ call }) => {
2862
5058
  const pageCount = Math.max(1, Math.ceil(filtered.length / WB_MARKET_CONNECTOR_PAGE_SIZE));
2863
5059
  const safePage = Math.min(page, pageCount);
2864
5060
  const visible = filtered.slice((safePage - 1) * WB_MARKET_CONNECTOR_PAGE_SIZE, safePage * WB_MARKET_CONNECTOR_PAGE_SIZE);
5061
+ // §6.4 分类 chips:原来只有一个分类下拉,429 位专家怎么分布完全看不见,
5062
+ // 用户点进去才知道某个分类只有两三个人。改成 chips 条后各类体量一眼可见。
5063
+ // 计数在这里一次算完(而不是每个 option 里各 filter 一遍——那是 O(n×m))。
5064
+ const categoryCounts = {};
5065
+ for (const item of experts) categoryCounts[item.categoryId] = (categoryCounts[item.categoryId] || 0) + 1;
5066
+ const categoryChips = [
5067
+ { id: 'all', name: t('全部'), count: experts.length },
5068
+ ...categories.map((cat) => ({ id: cat.id, name: cat.name, count: categoryCounts[cat.id] || 0 })),
5069
+ ].filter((row) => row.count > 0 || row.id === 'all');
2865
5070
  return h('div', null,
2866
5071
  h('div', { className: 'dsh-mcp-wb-toolbar' },
2867
5072
  h(WbSearchBox, {
2868
5073
  value: query, placeholder: t('搜索专家(花名 / 职业 / 描述 / 标签)'), disabled: loading,
2869
5074
  onChange: (value) => { setQuery(value); setPage(1); },
2870
5075
  }),
2871
- h('select', {
2872
- className: 'dsh-mcp-input dsh-mcp-wb-sort', value: searching ? 'all' : category, disabled: loading || searching,
2873
- title: searching ? t('搜索时自动在全库匹配(忽略分类)') : undefined,
2874
- onChange: (e) => { setCategory(e.target.value); setPage(1); },
2875
- }, h('option', { key: 'all', value: 'all' }, t('全部专家(') + experts.length + t(')')),
2876
- categories.map((cat) => h('option', { key: cat.id, value: cat.id }, t('{0}({1})', [cat.name, experts.filter((item) => item.categoryId === cat.id).length])))),
2877
5076
  h('span', { className: 'dsh-mcp-builtin-count' },
2878
- t('显示 {0} / {1}', [filtered.length, experts.length]) + (searching ? t(' · 全库搜索') : '') + (source === 'network' ? t(' · 已更新') : source === 'disk' ? t(' · 缓存') : '')),
5077
+ t('显示 {0} / {1}', [filtered.length, experts.length]) + (searching ? t(' · 全库搜索(忽略分类)') : '') + (source === 'network' ? t(' · 已更新') : source === 'disk' ? t(' · 缓存') : '')),
5078
+ ),
5079
+ // 搜索态置灰不隐藏:位置稳定,用户知道「分类还在,只是这次搜索没按它筛」。
5080
+ h('div', { className: 'dsh-mcp-market-chips' + (searching ? ' muted' : ''), title: searching ? t('搜索时自动在全库匹配(忽略分类)') : undefined },
5081
+ categoryChips.map((row) => h('button', {
5082
+ key: row.id, type: 'button',
5083
+ className: 'dsh-mcp-chip' + (!searching && category === row.id ? ' sel' : ''),
5084
+ 'aria-pressed': !searching && category === row.id,
5085
+ disabled: searching || loading,
5086
+ onClick: () => { setCategory(row.id); setPage(1); },
5087
+ }, row.name, h('span', { className: 'dsh-mcp-chip-count' }, row.count))),
2879
5088
  ),
2880
5089
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
2881
5090
  busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
@@ -2937,23 +5146,22 @@ const ExpertMarket = ({ call }) => {
2937
5146
  )
2938
5147
  : null,
2939
5148
  pageCount > 1
2940
- ? h('div', { className: 'dsh-mcp-wb-pager' },
2941
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safePage <= 1, onClick: () => setPage(safePage - 1) }, t('上一页')),
2942
- h('span', { className: 'dsh-mcp-builtin-count' }, `${safePage} / ${pageCount}`),
2943
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safePage >= pageCount, onClick: () => setPage(safePage + 1) }, t('下一页')),
2944
- )
5149
+ ? h(Pager, { page: safePage, pageCount, total: filtered.length, onPage: setPage })
2945
5150
  : null,
2946
5151
  // ── 专家详情弹层:召唤前看人设原文 + 捆绑技能 ──
2947
5152
  detail
2948
5153
  ? (() => {
2949
5154
  // 弹层里该给哪些按钮:与卡片按钮同源(expertCardActions),只是换到详情对象上算。
5155
+ // OPT-16/26 · 3.2/3.3:persona 展开态与 quickPrompts 展开态(专家详情弹层局部)。
5156
+ const [personaExpanded, setPersonaExpanded] = useState(false);
5157
+ const [showAllPrompts, setShowAllPrompts] = useState(false);
2950
5158
  const detailActions = expertCardActions({
2951
5159
  summoned: presets.has(detail.plugin),
2952
5160
  expertType: detail.expertType,
2953
5161
  personaText: personaTextOf(presetRows, detail.plugin),
2954
5162
  });
2955
5163
  return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
2956
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
5164
+ h('div', { ref: expertDetailRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
2957
5165
  h('h3', null, t('{0}({1})', [detail.name, detail.profession])),
2958
5166
  h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
2959
5167
  detail.loading
@@ -2962,11 +5170,19 @@ const ExpertMarket = ({ call }) => {
2962
5170
  detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
2963
5171
  detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
2964
5172
  detail.quickPrompts && detail.quickPrompts.length
2965
- ? h('div', { className: 'dsh-mcp-wb-examples' },
2966
- h('b', null, t('试试这样问')),
2967
- h('ul', null, detail.quickPrompts.slice(0, 3).map((prompt, index) => h('li', { key: index }, prompt))),
2968
- )
2969
- : null,
5173
+ ? (() => {
5174
+ // OPT-26 · 3.3:quickPrompts 超过 3 条时默认折叠,按钮切换展开/收起。
5175
+ const promptsShown = showAllPrompts ? detail.quickPrompts : detail.quickPrompts.slice(0, 3);
5176
+ return h('div', { className: 'dsh-mcp-wb-examples' },
5177
+ h('b', null, t('试试这样问')),
5178
+ h('ul', null, promptsShown.map((prompt, index) => h('li', { key: index }, prompt))),
5179
+ detail.quickPrompts.length > 3
5180
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-link-btn', onClick: () => setShowAllPrompts(!showAllPrompts) },
5181
+ showAllPrompts ? t('收起') : t('显示全部 {0} 条', [fmtInt(detail.quickPrompts.length)]))
5182
+ : null,
5183
+ );
5184
+ })()
5185
+ : null,
2970
5186
  Array.isArray(detail.skills)
2971
5187
  ? h('div', { className: 'dsh-mcp-card-tags' },
2972
5188
  detail.skills.length
@@ -2977,7 +5193,12 @@ const ExpertMarket = ({ call }) => {
2977
5193
  detail.persona
2978
5194
  ? h('div', { className: 'dsh-mcp-skill-d-sec' },
2979
5195
  h('b', null, t('人设正文({0} 字符,召唤后成为会话系统提示)', [detail.personaChars || detail.persona.length])),
2980
- h('pre', { className: 'dsh-mcp-skill-d-md' }, detail.persona.length > 4000 ? detail.persona.slice(0, 4000) + t('\n…(预览截断,召唤时写入完整正文)') : detail.persona),
5196
+ h('pre', { className: 'dsh-mcp-skill-d-md' }, personaExpanded || detail.persona.length <= PERSONA_PREVIEW_MAX ? detail.persona : detail.persona.slice(0, PERSONA_PREVIEW_MAX) + t('\n…(预览截断,召唤时写入完整正文)')),
5197
+ personaExpanded
5198
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-link-btn', onClick: () => setPersonaExpanded(false) }, t('收起'))
5199
+ : detail.persona.length > PERSONA_PREVIEW_MAX
5200
+ ? h('button', { type: 'button', className: 'dsh-mcp-btn dsh-mcp-link-btn', onClick: () => setPersonaExpanded(true) }, t('展开全文({0} 字符)', [fmtInt(detail.persona.length)]))
5201
+ : null,
2981
5202
  )
2982
5203
  : null,
2983
5204
  // 团队型:先给人看编制(成员是谁、负责什么),再决定召唤。
@@ -3275,6 +5496,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3275
5496
  const [attached, setAttached] = useState(null); // { plugin, name, mode: 'persona' }
3276
5497
  const [attachBusy, setAttachBusy] = useState(false);
3277
5498
  const [attachNote, setAttachNote] = useState('');
5499
+ // 团队型专家的长说明(60+ 字):只挂到 trigger 的 title,不进菜单气泡(否则撑高菜单)。
5500
+ const [teamHint, setTeamHint] = useState('');
3278
5501
  const sessionId = typeof sessionIdProp === 'string' && sessionIdProp !== '' ? sessionIdProp : null;
3279
5502
  const attachExpert = async (item, { quiet = false } = {}) => {
3280
5503
  setHoverKind(null);
@@ -3288,17 +5511,19 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3288
5511
  const res = await call('expertAttach', { sessionId, plugin: item.plugin });
3289
5512
  if (res && res.ok) {
3290
5513
  setAttached({ plugin: item.plugin, name: res.name || item.name || item.plugin, mode: 'persona' });
3291
- // 团队型追加提示:人设通道不动工具目录,成员要靠 subagent 调度。若当前会话
3292
- // 没有 subagent 工具(预设没带 delegation 组),告诉用户去召唤预设的新会话。
3293
- const teamHint = res.expertType === 'team'
3294
- ? t(';成员由主理人用 subagent 调度——若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设', [res.name || item.name || item.plugin])
3295
- : '';
3296
- setAttachNote(t('✓ 已挂到当前会话:') + (res.name || item.name || item.plugin) + (res.replaced ? t('(已替换原专家)') : '') + teamHint);
5514
+ // 团队型说明只在 trigger 的 title(悬停)里出现,不进菜单气泡:
5515
+ // 这句 60+ 字,菜单 min-width 只有 148px,塞进气泡会折成多行把菜单撑高。
5516
+ // 成功态本来就有两处可见反馈——专家 chip(名字 + ×)与菜单项的「已挂」徽章。
5517
+ setAttachNote('');
5518
+ setTeamHint(res.expertType === 'team'
5519
+ ? t('成员由主理人用 subagent 调度;若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设', [res.name || item.name || item.plugin])
5520
+ : '');
3297
5521
  call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
3298
5522
  // 立即广播:会话头徽章 + 陈旧预设标签的隐藏同步生效,三处显示同帧对齐。
3299
5523
  emitExpertChange();
3300
5524
  } else {
3301
- setAttachNote(t('挂载失败:') + ((res && res.error) || t('未知错误')));
5525
+ // 失败必须可见,但同样压成一行:只留错误首行,截到 80 字。
5526
+ setAttachNote(t('挂载失败:') + String((res && res.error) || t('未知错误')).split('\n')[0].slice(0, 80));
3302
5527
  }
3303
5528
  } catch (e) {
3304
5529
  if (!quiet) setAttachNote(t('挂载异常:') + String((e && e.message) || e));
@@ -3314,6 +5539,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3314
5539
  // 的分支,也就无需回退默认预设——预设选择器保持用户自己的选择,不被插件触碰。
3315
5540
  const res = await call('expertDetach', { sessionId }).catch(() => null);
3316
5541
  setAttached(null);
5542
+ setTeamHint('');
3317
5543
  setAttachNote(res && res.ok && res.removed ? t('✓ 已取消本会话的专家') : t('本会话没有挂着专家'));
3318
5544
  // 摘掉也要广播:会话头徽章退场、被隐身的预设标签复原。
3319
5545
  emitExpertChange();
@@ -3358,7 +5584,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3358
5584
 
3359
5585
  return h('div', { className: 'dsh-mcp-quick', ref: rootRef, ...rootHoverProps },
3360
5586
  // 挂载的专家:图标左侧的内联小 chip(与 trigger 同一行,不撑宽不换行)。
3361
- // attachNote 不再渲染独立气泡——错误/提示走 trigger 的 title(悬停可见)。
5587
+ // 成功提示不进菜单(chip + 菜单项「已挂」徽章已表达);失败才出一行气泡。
3362
5588
  attached
3363
5589
  ? h('span', { className: 'dsh-mcp-quick-chip', title: t('本会话由该专家人设接管;点 × 摘掉(不动预设选择器)') },
3364
5590
  h(Icon, { d: ICONS.expert, size: 11 }),
@@ -3376,18 +5602,22 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3376
5602
  h('button', {
3377
5603
  type: 'button',
3378
5604
  className: 'dsh-mcp-quick-trigger' + (open ? ' sel' : ''),
3379
- title: (attachNote ? attachNote + (attached ? t(' ') : '') : '') + (attached ? t('已挂专家:') + attached.name + t('(本会话全局生效) ') : '') + t('技能 / MCP / 专家 快速调用'),
5605
+ title: (attachNote ? attachNote + t(' ') : '') + (attached ? t('已挂专家:') + attached.name + t('(本会话全局生效)') + (teamHint ? t(' ') + teamHint : '') + t(' ') : '') + t('技能 / MCP / 专家 快速调用'),
3380
5606
  'aria-label': t('打开技能、MCP、专家快速调用菜单'),
3381
5607
  'aria-expanded': open,
3382
5608
  onClick: (e) => { e.stopPropagation(); setOpen((value) => !value); setHoverKind(null); },
3383
5609
  }, h(Icon, { d: ICONS.board, size: 15 })),
3384
5610
  open
3385
5611
  ? h('div', { className: 'dsh-mcp-quick-menu', role: 'menu', 'aria-label': t('快速调用'), ref: menuRef, style: menuPos },
3386
- copied ? h('div', { className: 'dsh-mcp-quick-note' }, t('已复制,粘贴到输入框:') + copied) : null,
5612
+ // 只报「已复制」,不复述内容:MCP 那条 mention 有 60+ 字,拼进来会折行撑高菜单
5613
+ // (OPT-32)。内容已在剪贴板,用户直接粘贴即可;完整文本留作 title 悬停可查。
5614
+ copied ? h('div', { className: 'dsh-mcp-quick-note', title: copied }, t('已复制,粘贴到输入框')) : null,
3387
5615
  attachBusy ? h('div', { className: 'dsh-mcp-quick-note' }, t('正在挂载专家…')) : null,
3388
- attachNote ? h('div', { className: 'dsh-mcp-quick-note' + (/^✓/.test(attachNote) ? ' ok' : ' err'), role: 'alert' }, attachNote) : null,
5616
+ // 只在失败时出气泡(一行短句)。成功态靠专家 chip + 菜单项「已挂」徽章表达,
5617
+ // 不再渲染 ✓ 提示行——那是把菜单撑高的直接原因(见 attachExpert 里的注释)。
5618
+ attachNote ? h('div', { className: 'dsh-mcp-quick-note err', role: 'alert', title: attachNote }, attachNote) : null,
3389
5619
  listError ? h('div', { className: 'dsh-mcp-quick-err' }, listError) : null,
3390
- !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, t('正在读取…')) : null,
5620
+ !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, h(Skeleton, { rows: 3 })) : null,
3391
5621
  QUICK_MENU_KINDS.map((kind) => {
3392
5622
  const items = subItems(kind.key);
3393
5623
  const expanded = hoverKind === kind.key;
@@ -3433,7 +5663,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3433
5663
  ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': t('最近使用') }, t('最近'))
3434
5664
  : null,
3435
5665
  h('span', { className: 'dsh-mcp-quick-item-name' }, itemLabel(kind.key, item)),
3436
- itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc' }, itemDesc(kind.key, item)) : null,
5666
+ itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc', title: itemDesc(kind.key, item) }, itemDesc(kind.key, item)) : null,
3437
5667
  )),
3438
5668
  )
3439
5669
  : h('div', { className: 'dsh-mcp-quick-sub-note' }, t('暂无可选项')),
@@ -3612,7 +5842,7 @@ const WorkBuddyMarket = ({ call }) => {
3612
5842
  const [connectorSource, setConnectorSource] = useState('');
3613
5843
  const [connectors, setConnectors] = useState([]);
3614
5844
  const [connectorQuery, setConnectorQuery] = useState('');
3615
- const [connectorOnlyReady, setConnectorOnlyReady] = useState(false);
5845
+ const [connectorOnlyReady, setConnectorOnlyReady] = useState(true);
3616
5846
  const [connectorPage, setConnectorPage] = useState(1);
3617
5847
  const [connectorDetail, setConnectorDetail] = useState(null);
3618
5848
  // ── 技能 ──
@@ -3624,6 +5854,14 @@ const WorkBuddyMarket = ({ call }) => {
3624
5854
  const [skillTotal, setSkillTotal] = useState(0);
3625
5855
  const [skillDetail, setSkillDetail] = useState(null);
3626
5856
  const [installedSkills, setInstalledSkills] = useState(new Set());
5857
+ // §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
5858
+ // 所以这层过滤只能作用于**当前页**——必须在标签上写清,否则会被误读成全库筛选。
5859
+ const [skillOnlyInstalled, setSkillOnlyInstalled] = useState(false);
5860
+ // OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
5861
+ const connectorDetailRef = useRef(null);
5862
+ const skillDetailRef = useRef(null);
5863
+ useFocusTrap(connectorDetailRef, !!connectorDetail);
5864
+ useFocusTrap(skillDetailRef, !!skillDetail);
3627
5865
  const WB_PAGE_SIZE = 20;
3628
5866
 
3629
5867
  const loadInstalled = useCallback(async () => {
@@ -3820,27 +6058,64 @@ const WorkBuddyMarket = ({ call }) => {
3820
6058
  const skillMeta = skillTotal
3821
6059
  ? t('第 {0} / {1} 页 · 共 {2} 个技能', [skillPage, skillPageCount, wbFormatCount(skillTotal)])
3822
6060
  : '';
3823
-
6061
+ // §6.3 当前页的「已安装」子集。SkillHub 是远程分页(每页 20 条),
6062
+ // 已安装状态只在本地知道,所以这层过滤只作用于本页——计数文案里写清楚,
6063
+ // 免得用户以为「只看已安装」能翻遍全库。
6064
+ const shownSkills = skillOnlyInstalled
6065
+ ? skills.filter((skill) => installedSkills.has(skill.slug))
6066
+ : skills;
6067
+ const installedOnPage = skills.filter((skill) => installedSkills.has(skill.slug)).length;
6068
+
6069
+ // §6.1 子页签:原来用与顶栏 `.dsh-mcp-cat` 同款的胶囊按钮,两排长得一样,
6070
+ // 用户分不清「换整页」还是「换市场内部子页」。改成下划线式页签 + 面包屑,
6071
+ // 视觉上与顶栏彻底分开;「内置 MCP」拉不到数据时(0)降级成灰字,不占页签位。
6072
+ const MARKET_TABS = [
6073
+ { key: 'connector', label: t('连接器'), count: connectors.length || null },
6074
+ { key: 'builtin', label: t('内置 MCP'), count: builtins.length || null },
6075
+ { key: 'skill', label: t('技能'), count: null },
6076
+ { key: 'expert', label: t('专家'), count: null },
6077
+ ];
6078
+ const marketTabLabel = (MARKET_TABS.find((row) => row.key === tab) || MARKET_TABS[0]).label;
3824
6079
  return h('div', { className: 'dsh-mcp-wb' },
3825
- h('div', { className: 'dsh-mcp-wb-tabs', role: 'tablist', 'aria-label': t('WorkBuddy 市场') },
3826
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'connector', className: 'dsh-mcp-wb-tab' + (tab === 'connector' ? ' sel' : ''), onClick: () => setTab('connector') },
3827
- t('连接器(MCP)'), h('span', { className: 'dsh-mcp-cat-count' }, connectors.length)),
3828
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'builtin', className: 'dsh-mcp-wb-tab' + (tab === 'builtin' ? ' sel' : ''), onClick: () => setTab('builtin') },
3829
- t('内置 MCP'), h('span', { className: 'dsh-mcp-cat-count' }, builtins.length)),
3830
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'skill', className: 'dsh-mcp-wb-tab' + (tab === 'skill' ? ' sel' : ''), onClick: () => setTab('skill') },
3831
- t('技能(SkillHub)')),
3832
- h('button', { type: 'button', role: 'tab', 'aria-selected': tab === 'expert', className: 'dsh-mcp-wb-tab' + (tab === 'expert' ? ' sel' : ''), onClick: () => setTab('expert') },
3833
- t('专家(召唤)')),
6080
+ h('div', { className: 'dsh-mcp-mk-crumb' },
6081
+ h('span', { className: 'dsh-mcp-mk-crumb-root' }, t('市场')),
6082
+ h('span', { className: 'dsh-mcp-mk-crumb-sep', 'aria-hidden': 'true' }, '/'),
6083
+ h('b', null, marketTabLabel),
6084
+ ),
6085
+ h('div', { className: 'dsh-mcp-mk-subtabs', role: 'tablist', 'aria-label': t('WorkBuddy 市场') },
6086
+ MARKET_TABS.map((row) => (row.key === 'builtin' && !row.count
6087
+ // 内置 MCP 目录首次进入才有数据,未加载时给一个可点的灰字入口而非空页签。
6088
+ ? h('button', {
6089
+ key: row.key, type: 'button', className: 'dsh-mcp-mk-subtabs-idle',
6090
+ onClick: () => setTab(row.key), title: t('读取内置 MCP 目录'),
6091
+ }, row.label)
6092
+ : h('button', {
6093
+ key: row.key, type: 'button', role: 'tab', 'aria-selected': tab === row.key,
6094
+ className: 'dsh-mcp-mk-subtab', onClick: () => setTab(row.key),
6095
+ }, row.label, row.count ? h('i', null, row.count) : null))),
6096
+ ),
6097
+ // §6.5 免责声明:原来整段灰字常驻在页签下方,每次进市场都吃掉两行高度。
6098
+ // 收敛为一行摘要 + details 展开全文,默认折叠。
6099
+ h('details', { className: 'dsh-mcp-mk-disclaim' },
6100
+ h('summary', null, t('连接器 / 技能 / 专家均为第三方分发,本面板只做浏览与安装入口;内容版权归原作者所有。')),
6101
+ h('p', null,
6102
+ t('连接器来自 WorkBuddy 官方分发(static.workbuddy.cn);技能来自 SkillHub 公开 API;专家来自 WorkBuddy 专家中心分发。') +
6103
+ t('第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。')),
3834
6104
  ),
3835
- h('div', { className: 'dsh-mcp-wb-disclaim' },
3836
- t('直连读取:连接器来自 WorkBuddy 官方分发(static.workbuddy.cn),技能来自 SkillHub 公开 API,专家来自 WorkBuddy 专家中心分发。本面板只是浏览与召唤入口,') +
3837
- t('内容版权归原作者所有;第三方接口的字段可能随上游改版变化。')),
3838
6105
  tab === 'connector'
3839
6106
  ? h('div', { className: 'dsh-mcp-wb-toolbar' },
3840
6107
  connectorSearch,
3841
- h('label', { className: 'dsh-mcp-check' },
6108
+ h('label', { className: 'dsh-mcp-check', title: t('默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目') },
3842
6109
  h('input', { type: 'checkbox', checked: connectorOnlyReady, disabled: loading, onChange: (e) => { setConnectorOnlyReady(e.target.checked); setConnectorPage(1); } }),
3843
6110
  t('只看可直接安装')),
6111
+ // 被过滤掉多少条要明说,否则用户会以为目录里就这么多。
6112
+ !connectorOnlyReady || loading ? null : (() => {
6113
+ const hidden = connectors.filter((row) => !row.installable).length;
6114
+ return hidden > 0
6115
+ ? h('span', { className: 'dsh-mcp-builtin-count', title: t('这些条目缺少传输类型等必要信息,本面板无法生成配置') },
6116
+ t('已隐藏 {0} 个不兼容条目', [hidden]))
6117
+ : null;
6118
+ })(),
3844
6119
  h('span', { className: 'dsh-mcp-builtin-count' },
3845
6120
  t('显示 {0} / {1}', [visibleConnectors.length, filteredConnectors.length]) + (connectorSource === 'network' ? t(' · 已更新') : connectorSource === 'disk' ? t(' · 缓存') : '')),
3846
6121
  )
@@ -3868,6 +6143,12 @@ const WorkBuddyMarket = ({ call }) => {
3868
6143
  className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: loading,
3869
6144
  onChange: (e) => { setSkillSort(e.target.value); setSkillPage(1); },
3870
6145
  }, WB_SKILLHUB_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
6146
+ h('label', { className: 'dsh-mcp-check', title: t('仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)', [skills.length]) },
6147
+ h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled: loading, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
6148
+ t('只看已安装')),
6149
+ skillOnlyInstalled
6150
+ ? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
6151
+ : null,
3871
6152
  h('span', { className: 'dsh-mcp-builtin-count' }, skillMeta),
3872
6153
  ),
3873
6154
  note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
@@ -3875,11 +6156,59 @@ const WorkBuddyMarket = ({ call }) => {
3875
6156
  error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
3876
6157
  loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
3877
6158
  loading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
3878
- !loading && tab === 'connector' && !visibleConnectors.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('没有匹配的连接器,换个关键词试试')) : null,
3879
- !loading && tab === 'skill' && !skills.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('没有匹配的技能')) : null,
6159
+ // §1.4 「只看可直接安装」把本页筛空了:这是本页最容易踩的坑(默认勾选),
6160
+ // 主操作就是取消它——一句话说清「这里本来有条目,是筛选挡掉了」。
6161
+ !loading && tab === 'connector' && !visibleConnectors.length
6162
+ ? (connectors.length
6163
+ ? h(EmptyState, {
6164
+ icon: 'store',
6165
+ title: t('当前筛选下没有可安装的连接器'),
6166
+ hint: connectorOnlyReady
6167
+ ? t('目录里有 {0} 个条目,「只看可直接安装」把不具备传输类型的挡掉了。', [connectors.length])
6168
+ : t('换个关键词试试,或清空搜索回到完整目录。'),
6169
+ actions: connectorOnlyReady
6170
+ ? [
6171
+ { label: t('显示全部条目'), icon: 'eye', onClick: () => { setConnectorOnlyReady(false); setConnectorPage(1); } },
6172
+ { label: t('清空搜索'), icon: 'x', onClick: () => { setConnectorQuery(''); setConnectorPage(1); } },
6173
+ ]
6174
+ : [{ label: t('清空搜索'), icon: 'x', onClick: () => { setConnectorQuery(''); setConnectorPage(1); } }],
6175
+ })
6176
+ : h(EmptyState, {
6177
+ icon: 'store',
6178
+ title: t('连接器目录暂时是空的'),
6179
+ hint: t('目录来自 WorkBuddy 官方分发,可能还没同步下来;重读一次通常就好。'),
6180
+ actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadConnectors(); } }],
6181
+ }))
6182
+ : null,
6183
+ // §1.4 技能:搜索与「只看已安装」是两种完全不同的空,说法必须分开——
6184
+ // 否则用户会以为 SkillHub 里根本没有这个词。
6185
+ !loading && tab === 'skill' && !skills.length
6186
+ ? h(EmptyState, {
6187
+ icon: 'skill',
6188
+ title: skillKeyword.trim() ? t('没有搜到「{0}」相关的技能', [skillKeyword.trim()]) : t('技能目录暂时是空的'),
6189
+ hint: skillKeyword.trim() ? t('SkillHub 的搜索按名称与描述匹配;换一个更短的词通常更容易命中。') : t('技能来自 SkillHub 公开 API,可能还没同步下来。'),
6190
+ actions: skillKeyword.trim()
6191
+ ? [{ label: t('清空搜索'), icon: 'x', onClick: () => { setSkillKeywordInput(''); setSkillKeyword(''); setSkillPage(1); } }]
6192
+ : [],
6193
+ })
6194
+ : null,
6195
+ // 「只看已安装」把本页筛空了:不能沿用「没有匹配的技能」——那会让人以为搜索没结果。
6196
+ !loading && tab === 'skill' && skills.length && !shownSkills.length
6197
+ ? h(EmptyState, {
6198
+ icon: 'skill',
6199
+ tight: true,
6200
+ title: t('本页没有已安装的技能'),
6201
+ hint: t('SkillHub 是远程分页,只能在当前 {0} 条里筛。翻页找找,或取消「只看已安装」。', [skills.length]),
6202
+ actions: [{ label: t('取消「只看已安装」'), icon: 'x', onClick: () => setSkillOnlyInstalled(false) }],
6203
+ })
6204
+ : null,
3880
6205
  tab === 'connector' && visibleConnectors.length
3881
6206
  ? h('div', { className: 'dsh-mcp-wb-grid' },
3882
- visibleConnectors.map((item) => h('div', { key: item.id, className: 'dsh-mcp-card wb' + (item.installable ? '' : ' off') },
6207
+ visibleConnectors.map((item) => h('div', { key: item.id, className: 'dsh-mcp-card wb' + (item.installable ? '' : ' blocked') },
6208
+ // §6.2 不兼容卡片:原来只在卡片底部一行 10.5px 橙字「不兼容:…」,
6209
+ // 用户要滚到卡底才知道装不了。改成右上角橙色角标,一眼可见;
6210
+ // 完整原因仍留在 blockedReason,hover 卡片或角标都能看到。
6211
+ item.installable ? null : h('span', { className: 'dsh-mcp-mk-corner', title: item.blockedReason || t('不兼容') }, t('不兼容')),
3883
6212
  h('div', { className: 'dsh-mcp-card-top' },
3884
6213
  h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
3885
6214
  h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
@@ -3894,24 +6223,40 @@ const WorkBuddyMarket = ({ call }) => {
3894
6223
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, t('详情')),
3895
6224
  item.installable
3896
6225
  ? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => installConnector(item) }, t('安装'))
3897
- : h('span', { className: 'dsh-mcp-wb-blocked', title: item.blockedReason }, item.blockedReason ? t('不兼容:') + item.blockedReason : ''),
6226
+ : h('span', {
6227
+ className: 'dsh-mcp-wb-blocked',
6228
+ title: item.blockedReason,
6229
+ }, item.blockedReason ? t('不兼容:') + item.blockedReason : t('需上游提供传输类型')),
3898
6230
  ),
3899
6231
  )),
3900
6232
  )
3901
6233
  : null,
3902
6234
  tab === 'connector' && connectorPageCount > 1
3903
- ? h('div', { className: 'dsh-mcp-wb-pager' },
3904
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safeConnectorPage <= 1, onClick: () => setConnectorPage(safeConnectorPage - 1) }, t('上一页')),
3905
- h('span', { className: 'dsh-mcp-builtin-count' }, `${safeConnectorPage} / ${connectorPageCount}`),
3906
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: safeConnectorPage >= connectorPageCount, onClick: () => setConnectorPage(safeConnectorPage + 1) }, t('下一页')),
3907
- )
6235
+ ? h(Pager, { page: safeConnectorPage, pageCount: connectorPageCount, total: filteredConnectors.length, onPage: setConnectorPage })
3908
6236
  : null,
3909
6237
  tab === 'builtin' && builtinError
3910
6238
  ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, builtinError, ' ',
3911
6239
  h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: loadBuiltins, disabled: builtinsLoading }, t('重试')))
3912
6240
  : null,
3913
6241
  tab === 'builtin' && !builtinError && !filteredBuiltins.length
3914
- ? h('div', { className: 'dsh-mcp-wb-empty' }, builtinsLoading ? t('正在读取 MCP 目录…') : t('没有匹配的服务,换个关键词或分类试试'))
6242
+ ? (builtinsLoading
6243
+ ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取 MCP 目录…'))
6244
+ // §1.4 空状态四件套。这里是最典型的「裸空白」:目录 0 条时原来只有一句
6245
+ // 「没有匹配的服务」,用户既不知道是筛选造成的还是目录真的空,也不知道点哪。
6246
+ // 主操作按成因分流——有筛选就清筛选(一次点击立刻有结果),没筛选才重读目录。
6247
+ : h(EmptyState, {
6248
+ icon: 'store',
6249
+ title: builtins.length ? t('当前筛选下没有服务') : t('MCP 目录暂时是空的'),
6250
+ hint: builtins.length
6251
+ ? t('目录里有 {0} 个服务,都被关键词、分类或「只要免密钥」筛掉了。', [builtins.length])
6252
+ : t('目录由宿主侧维护,可能还没同步下来;重读一次通常就好。'),
6253
+ actions: builtins.length
6254
+ ? [
6255
+ { label: t('清除全部筛选'), icon: 'x', onClick: () => { setBuiltinQuery(''); setBuiltinCategory('all'); setBuiltinOnlyReady(false); } },
6256
+ { label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadBuiltins(); } },
6257
+ ]
6258
+ : [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadBuiltins(); } }],
6259
+ }))
3915
6260
  : null,
3916
6261
  tab === 'builtin' && filteredBuiltins.length
3917
6262
  ? h('div', { className: 'dsh-mcp-wb-grid' },
@@ -3936,9 +6281,16 @@ const WorkBuddyMarket = ({ call }) => {
3936
6281
  : builtinNeedsConfig(item)
3937
6282
  ? h(Badge, null, t('需连接信息'))
3938
6283
  : h(Badge, { cls: 's-off' }, t('免密钥')),
3939
- builtinNeedsArgs(item) ? h(Badge, { cls: 's-err' }, t('需填参数')) : null,
6284
+ // §1.3 色语:缺参数是「还需注意」不是「已故障」,用橙不用红——
6285
+ // 红色的名额留给真正的失败与破坏性操作,否则满屏红之后就没人在意红了。
6286
+ builtinNeedsArgs(item) ? h(Badge, { cls: 's-warn' }, t('需填参数')) : null,
6287
+ // OPT-24 · 2.3:auth 是「装完之后还要做什么」的关键信息——oauth 条目装完
6288
+ // 并不等于能用,得去浏览器授权。none 不出徽章(「免密钥」已覆盖这层语义)。
6289
+ item.auth && item.auth !== 'none' ? h(Badge, { cls: authBadgeCls(item.auth) }, authKindLabel(item.auth)) : null,
3940
6290
  ),
3941
6291
  h('div', { className: 'dsh-mcp-card-access' }, item.access),
6292
+ // authNote 只在宿主给了才显示(oauth 类条目才有),说明授权流程怎么走。
6293
+ item.auth && item.auth !== 'none' && item.authNote ? h('div', { className: 'dsh-mcp-card-note' }, item.authNote) : null,
3942
6294
  builtinNeedsKey(item) || builtinNeedsConfig(item)
3943
6295
  ? h('div', { className: 'dsh-mcp-card-vars' },
3944
6296
  builtinNeedsKey(item) ? h('span', null, t('密钥:') + item.envKeys.join(t('、'))) : null,
@@ -3957,9 +6309,9 @@ const WorkBuddyMarket = ({ call }) => {
3957
6309
  }),
3958
6310
  )
3959
6311
  : null,
3960
- tab === 'skill' && skills.length
6312
+ tab === 'skill' && shownSkills.length
3961
6313
  ? h('div', { className: 'dsh-mcp-wb-grid' },
3962
- skills.map((skill) => {
6314
+ shownSkills.map((skill) => {
3963
6315
  const installed = installedSkills.has(skill.slug);
3964
6316
  return h('div', { key: skill.namespace + '/' + skill.slug, className: 'dsh-mcp-card wb' },
3965
6317
  h('div', { className: 'dsh-mcp-card-top' },
@@ -3989,17 +6341,13 @@ const WorkBuddyMarket = ({ call }) => {
3989
6341
  )
3990
6342
  : null,
3991
6343
  tab === 'skill' && skillPageCount > 1
3992
- ? h('div', { className: 'dsh-mcp-wb-pager' },
3993
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: skillPage <= 1 || loading, onClick: () => setSkillPage(skillPage - 1) }, t('上一页')),
3994
- h('span', { className: 'dsh-mcp-builtin-count' }, `${skillPage} / ${skillPageCount}`),
3995
- h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: skillPage >= skillPageCount || loading, onClick: () => setSkillPage(skillPage + 1) }, t('下一页')),
3996
- )
6344
+ ? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled: loading })
3997
6345
  : null,
3998
6346
  tab === 'expert' ? h(ExpertMarket, { call }) : null,
3999
6347
  // ── 连接器详情弹层:安装前看真实 mcp.json ──
4000
6348
  connectorDetail
4001
6349
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setConnectorDetail(null) },
4002
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('连接器详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setConnectorDetail(null); } } },
6350
+ 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); } } },
4003
6351
  h('h3', null, connectorDetail.name),
4004
6352
  connectorDetail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.description) : null,
4005
6353
  connectorDetail.examples && connectorDetail.examples.length
@@ -4023,7 +6371,7 @@ const WorkBuddyMarket = ({ call }) => {
4023
6371
  // ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
4024
6372
  skillDetail
4025
6373
  ? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setSkillDetail(null) },
4026
- h('div', { className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('技能详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setSkillDetail(null); } } },
6374
+ h('div', { ref: skillDetailRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('技能详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setSkillDetail(null); } } },
4027
6375
  h('h3', null, skillDetail.name),
4028
6376
  h('div', { className: 'dsh-mcp-card-vendor' }, skillDetail.canonical || skillDetail.namespace || ''),
4029
6377
  skillDetail.loading
@@ -4152,8 +6500,12 @@ function openBoardPanel() {
4152
6500
  class ArmoryPanelBoundary extends React2.Component {
4153
6501
  constructor(props) {
4154
6502
  super(props);
4155
- this.state = { error: null };
6503
+ // info:componentDidCatch 拿到的 React 组件栈,只有它能把「哪个子组件抛的」说清楚,
6504
+ // 而控制台里的 console.error 用户在崩溃现场是抓不到、也不想抓的。
6505
+ this.state = { error: null, info: null, copyState: null };
4156
6506
  this.retry = this.retry.bind(this);
6507
+ this.copyDetails = this.copyDetails.bind(this);
6508
+ this.copyTimer = null;
4157
6509
  }
4158
6510
  static getDerivedStateFromError(error) {
4159
6511
  return { error };
@@ -4161,18 +6513,63 @@ class ArmoryPanelBoundary extends React2.Component {
4161
6513
  componentDidCatch(error, info) {
4162
6514
  // 只记录、不吞掉:控制台保留完整堆栈,用户回报时才有东西可看。
4163
6515
  console.error('[dsh-armory] panel crashed:', error, info);
6516
+ // OPT-20 · 6.1:同时留一份在 state 里,供「复制错误详情」一键带走。
6517
+ this.setState({ info });
6518
+ }
6519
+ componentWillUnmount() {
6520
+ // 复制反馈的 1.5s 定时器必须在卸载时清掉:否则它会在组件没了之后 setState,
6521
+ // 在开发态刷一条无害但吵人的告警,测试里也会留下悬挂的计时器。
6522
+ if (this.copyTimer) { clearTimeout(this.copyTimer); this.copyTimer = null; }
4164
6523
  }
4165
6524
  retry() {
4166
- this.setState({ error: null });
6525
+ this.setState({ error: null, info: null, copyState: null });
6526
+ }
6527
+ // 复制成功/失败后的短暂反馈:1.5 秒后自动回到常态文案。
6528
+ finishCopy(state) {
6529
+ if (this.copyTimer) clearTimeout(this.copyTimer);
6530
+ this.setState({ copyState: state });
6531
+ this.copyTimer = setTimeout(() => {
6532
+ this.copyTimer = null;
6533
+ this.setState({ copyState: null });
6534
+ }, 1500);
6535
+ }
6536
+ // 拼诊断文本并写剪贴板。用户回报 bug 时最缺的三件事全在这里:什么时候、什么环境、
6537
+ // 哪一段栈。段落用方括号标出来,粘进 issue 里不需要额外解释。
6538
+ copyDetails() {
6539
+ const { error, info } = this.state;
6540
+ const text = [
6541
+ 'dsh-armory panel crashed',
6542
+ 'time: ' + new Date().toISOString(),
6543
+ 'locale: ' + I18N_LOCALE,
6544
+ '[error]',
6545
+ String((error && error.stack) || error),
6546
+ '[componentStack]',
6547
+ String((info && info.componentStack) || '(unavailable)'),
6548
+ ].join('\n');
6549
+ // 与文件里其它复制点同一 idiom:优先 navigator.clipboard.writeText,被拒或非安全
6550
+ // 上下文(file://、http://)时回落到 copyText 的 textarea + execCommand 降级路径。
6551
+ const viaClipboard = typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function'
6552
+ ? navigator.clipboard.writeText(text).then(() => true, () => false)
6553
+ : Promise.resolve(false);
6554
+ viaClipboard
6555
+ .then((ok) => (ok ? true : copyText(text)))
6556
+ .then((ok) => this.finishCopy(ok ? 'ok' : 'fail'))
6557
+ .catch(() => this.finishCopy('fail'));
4167
6558
  }
4168
6559
  render() {
4169
- const { error } = this.state;
6560
+ const { error, copyState } = this.state;
4170
6561
  if (!error) return this.props.children;
6562
+ const copyLabel = copyState === 'ok'
6563
+ ? t('已复制')
6564
+ : copyState === 'fail'
6565
+ ? t('复制失败')
6566
+ : t('复制错误详情');
4171
6567
  return h('div', { className: 'dsh-mcp-panel-error', role: 'alert' },
4172
6568
  h('b', null, t('这个面板加载失败')),
4173
6569
  h('p', null, String((error && error.message) || error)),
4174
6570
  h('div', { className: 'dsh-mcp-panel-error-act' },
4175
6571
  h('button', { type: 'button', className: 'dsh-mcp-btn primary', onClick: this.retry }, t('重试')),
6572
+ h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: this.copyDetails }, copyLabel),
4176
6573
  h('span', null, this.props.hint || t('其余分类仍可正常使用;完整堆栈已打印到控制台。')),
4177
6574
  ),
4178
6575
  );
@@ -4185,35 +6582,324 @@ class ArmoryPanelBoundary extends React2.Component {
4185
6582
  // 跨分类跳转(openBoardCategory):分类选择器收在 MODULE_CTX 的订阅列表里——
4186
6583
  // ExpertInstalledBoard 空状态的「市场 → 专家」链接与输入框召唤菜单的「管理已装」都走它。
4187
6584
  const boardCategoryListeners = new Set();
6585
+ // ── §1.5 命令面板(Ctrl+K)──────────────────────────────────────────────
6586
+ // 为什么不是「再做一个搜索框」:这个插件已经有四个搜索框(MCP 列表、Skill 列表、
6587
+ // 市场技能、市场连接器),每个只认自己那一页。用户记得「rea」这个名字,但不记得
6588
+ // 它是 MCP 还是 Skill、在哪个页签——这才是真正花时间的地方。命令面板唯一的价值
6589
+ // 就是跨页签按名字找东西,所以它必须能同时搜四类,并且选中即直达。
6590
+ //
6591
+ // 索引惰性加载:打开面板才拉清单(三发并发),关掉即释放。常驻内存没有意义——
6592
+ // 面板是低频入口,而清单会随会话变化,缓存反而给出过期结果。
6593
+ //
6594
+ // 动作类只放「一定能做成」的:刷新、打开面板、五个分类跳转。不放「召唤专家」
6595
+ // 这类需要上下文的操作——面板没有拿到当前会话,做出来只会是半个功能。
6596
+ const PALETTE_GROUP_LIMIT = 6;
6597
+ // 跨组件通道:命令面板选中某台 MCP / 要求刷新时,McpTab 可能还没挂载或正被
6598
+ // 其它分类替换。与 openSkillFocus 同一套两段式(模块里留 pending + 已挂载则广播)。
6599
+ let pendingMcpQuery = '';
6600
+ const mcpQueryListeners = new Set();
6601
+ function openMcpQuery(name) {
6602
+ const target = String(name || '').trim();
6603
+ if (!target) return;
6604
+ pendingMcpQuery = target;
6605
+ for (const listener of [...mcpQueryListeners]) {
6606
+ try { listener(target); } catch { /* 单个订阅者失败不影响跳转 */ }
6607
+ }
6608
+ openBoardCategory('mcp');
6609
+ }
6610
+ const onMcpQuery = (listener) => {
6611
+ mcpQueryListeners.add(listener);
6612
+ return () => { mcpQueryListeners.delete(listener); };
6613
+ };
6614
+ const mcpRefreshListeners = new Set();
6615
+ function emitMcpRefresh() {
6616
+ for (const listener of [...mcpRefreshListeners]) {
6617
+ try { listener(); } catch { /* 同上 */ }
6618
+ }
6619
+ }
6620
+ const onMcpRefresh = (listener) => {
6621
+ mcpRefreshListeners.add(listener);
6622
+ return () => { mcpRefreshListeners.delete(listener); };
6623
+ };
6624
+
6625
+ const CommandPalette = ({ open, onClose, call, category, onJump }) => {
6626
+ const [query, setQuery] = useState('');
6627
+ const [index, setIndex] = useState(null); // { mcp: [], skill: [], expert: [] } | null
6628
+ const [indexError, setIndexError] = useState('');
6629
+ const [cursor, setCursor] = useState(0);
6630
+ const inputRef = useRef(null);
6631
+ const listRef = useRef(null);
6632
+ // OPT-21:命令面板容器此前没有 ref。注意它的输入框 effect 已把焦点送进输入框,
6633
+ // 陷阱的初焦点逻辑检测到 activeElement 已在容器内就不会抢,两者不冲突。
6634
+ const cmdkRef = useRef(null);
6635
+ useFocusTrap(cmdkRef, open);
6636
+ // 每次打开重新拉:清单随会话变,缓存过期比多一次请求更麻烦。
6637
+ useEffect(() => {
6638
+ if (!open) { setQuery(''); setCursor(0); setIndexError(''); return; }
6639
+ let alive = true;
6640
+ setIndex(null);
6641
+ Promise.all([
6642
+ call('list').catch(() => null),
6643
+ call('listSkills', skillScopeArg('global')).catch(() => null),
6644
+ call('wbExpertInstalled').catch(() => null),
6645
+ ]).then(([mcpRes, skillsRes, expertRes]) => {
6646
+ if (!alive) return;
6647
+ const servers = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers : [];
6648
+ const skills = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills : [];
6649
+ const experts = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets : [];
6650
+ setIndex({
6651
+ mcp: servers.map((s) => ({
6652
+ name: s.serverName,
6653
+ desc: [s.transport, typeof s.toolCount === 'number' ? t('{0} 工具', [s.toolCount]) : '', s.disabled ? t('已禁用') : ''].filter(Boolean).join(' · '),
6654
+ disabled: s.disabled === true,
6655
+ })),
6656
+ skill: skills.map((s) => ({ name: s.name, desc: s.description || s.whenToUse || s.source || '' })),
6657
+ expert: experts.map((e) => ({ name: e.plugin, desc: e.presetId || '', raw: e })),
6658
+ });
6659
+ }).catch((e) => { if (alive) setIndexError(String(e?.message || e)); });
6660
+ return () => { alive = false; };
6661
+ }, [call, open]);
6662
+ // 打开后聚焦输入框:面板的唯一入口是键盘,落地就应该能打字。
6663
+ useEffect(() => {
6664
+ if (!open) return;
6665
+ const id = requestAnimationFrame(() => inputRef.current?.focus());
6666
+ return () => cancelAnimationFrame(id);
6667
+ }, [open]);
6668
+
6669
+ const q = query.trim().toLowerCase();
6670
+ const hit = (text) => !q || String(text || '').toLowerCase().includes(q);
6671
+
6672
+ // 行模型统一:group + name + desc + hint + run。跳转与动作各占一组,
6673
+ // 搜索命中按类别分组——分组不是装饰,它让「同名的 MCP 和技能」不再混淆。
6674
+ const rows = useMemo(() => {
6675
+ const out = [];
6676
+ // 无输入时先给动作与跳转(面板刚打开的默认视图就该是「能做什么」)。
6677
+ for (const cat of BOARD_CATEGORIES) {
6678
+ if (!hit(cat.label) && !hit(CATEGORY_BLURB[cat.key])) continue;
6679
+ out.push({
6680
+ key: 'go:' + cat.key,
6681
+ group: t('跳转'),
6682
+ name: cat.label,
6683
+ desc: CATEGORY_BLURB[cat.key] || '',
6684
+ hint: category === cat.key ? t('当前') : t('打开'),
6685
+ icon: cat.icon,
6686
+ run: () => { onJump(cat.key); onClose(); },
6687
+ });
6688
+ }
6689
+ const actions = [
6690
+ { key: 'act:refresh', name: t('刷新 MCP 状态'), desc: t('重新读取服务器与工具清单'), icon: 'refresh', run: () => { emitMcpRefresh(); onJump('mcp'); onClose(); } },
6691
+ { key: 'act:panel', name: t('打开悬浮管理面板'), desc: t('在不离开会话的情况下查看 MCP'), icon: 'board', run: () => { openBoardPanel(); onClose(); } },
6692
+ ];
6693
+ for (const act of actions) {
6694
+ if (!hit(act.name) && !hit(act.desc)) continue;
6695
+ out.push({ ...act, group: t('动作'), hint: '' });
6696
+ }
6697
+ if (index) {
6698
+ for (const s of index.mcp) {
6699
+ if (!hit(s.name) && !hit(s.desc)) continue;
6700
+ out.push({
6701
+ key: 'mcp:' + s.name,
6702
+ group: 'MCP',
6703
+ name: s.name,
6704
+ desc: s.desc,
6705
+ hint: s.disabled ? t('已禁用') : t('筛选'),
6706
+ icon: 'plug',
6707
+ run: () => { openMcpQuery(s.name); onClose(); },
6708
+ });
6709
+ }
6710
+ for (const s of index.skill) {
6711
+ if (!hit(s.name) && !hit(s.desc)) continue;
6712
+ out.push({
6713
+ key: 'skill:' + s.name,
6714
+ group: 'Skill',
6715
+ name: s.name,
6716
+ desc: s.desc,
6717
+ hint: t('查看'),
6718
+ icon: 'skill',
6719
+ run: () => { openSkillFocus(s.name); onClose(); },
6720
+ });
6721
+ }
6722
+ for (const e of index.expert) {
6723
+ if (!hit(e.name) && !hit(e.desc)) continue;
6724
+ out.push({
6725
+ key: 'expert:' + e.name,
6726
+ group: t('专家'),
6727
+ name: e.name,
6728
+ desc: e.desc,
6729
+ hint: t('打开'),
6730
+ icon: 'expert',
6731
+ run: () => { onJump('expert'); onClose(); },
6732
+ });
6733
+ }
6734
+ }
6735
+ // 分组内截断:跨四类全量铺开会有上百行,键盘走不动,扫描也失效。
6736
+ // 截断只影响展示,输入更多字符就能看到更精确的结果,比「加载更多」诚实。
6737
+ const seen = new Map();
6738
+ return out.filter((row) => {
6739
+ const n = (seen.get(row.group) || 0) + 1;
6740
+ seen.set(row.group, n);
6741
+ return n <= PALETTE_GROUP_LIMIT;
6742
+ });
6743
+ }, [index, q, category, onJump, onClose]);
6744
+
6745
+ // 结果集变化时游标归零:否则回车会落到一个已被过滤掉的位置上。
6746
+ useEffect(() => { setCursor(0); }, [q, index]);
6747
+ const clampedCursor = rows.length ? Math.min(cursor, rows.length - 1) : 0;
6748
+ // 选中行滚进视野:键盘上下移动时看不到高亮就等于没有高亮。
6749
+ useEffect(() => {
6750
+ const box = listRef.current;
6751
+ if (!box) return;
6752
+ const el = box.querySelector('[data-cursor="' + clampedCursor + '"]');
6753
+ if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
6754
+ }, [clampedCursor, rows.length]);
6755
+
6756
+ if (!open) return null;
6757
+ const onKeyDown = (e) => {
6758
+ if (e.key === 'Escape') { e.stopPropagation(); onClose(); return; }
6759
+ if (e.key === 'ArrowDown' || (e.key === 'n' && e.ctrlKey)) {
6760
+ e.preventDefault();
6761
+ setCursor((c) => (rows.length ? (Math.min(c, rows.length - 1) + 1) % rows.length : 0));
6762
+ return;
6763
+ }
6764
+ if (e.key === 'ArrowUp' || (e.key === 'p' && e.ctrlKey)) {
6765
+ e.preventDefault();
6766
+ setCursor((c) => (rows.length ? (Math.min(c, rows.length - 1) + rows.length - 1) % rows.length : 0));
6767
+ return;
6768
+ }
6769
+ if (e.key === 'Enter') {
6770
+ e.preventDefault();
6771
+ const row = rows[clampedCursor];
6772
+ if (row) row.run();
6773
+ }
6774
+ };
6775
+
6776
+ // 分组渲染:rows 已按组连续排列(跳转→动作→MCP→Skill→专家),顺序扫一遍即可,
6777
+ // 不必再建 Map 保持插入序。
6778
+ const groups = [];
6779
+ for (const row of rows) {
6780
+ const last = groups[groups.length - 1];
6781
+ if (last && last.name === row.group) last.rows.push(row);
6782
+ else groups.push({ name: row.group, rows: [row] });
6783
+ }
6784
+ let flat = -1;
6785
+ return h('div', { className: 'dsh-mcp-cmdk-overlay', onClick: (e) => { if (e.target === e.currentTarget) onClose(); } },
6786
+ h('div', { ref: cmdkRef, className: 'dsh-mcp-cmdk', role: 'dialog', 'aria-modal': true, 'aria-label': t('命令面板'), onKeyDown },
6787
+ h('div', { className: 'dsh-mcp-cmdk-head' },
6788
+ h(Icon, { d: ICONS.search, size: 14 }),
6789
+ h('input', {
6790
+ ref: inputRef,
6791
+ className: 'dsh-mcp-cmdk-input',
6792
+ value: query,
6793
+ placeholder: t('搜索 MCP、技能、专家,或跳转分类…'),
6794
+ onChange: (e) => setQuery(e.target.value),
6795
+ 'aria-label': t('搜索'),
6796
+ }),
6797
+ h('span', { className: 'dsh-mcp-cmdk-kbd' }, 'Esc'),
6798
+ ),
6799
+ h('div', { className: 'dsh-mcp-cmdk-list', ref: listRef },
6800
+ indexError ? h('div', { className: 'dsh-mcp-cmdk-group' }, indexError) : null,
6801
+ !index && !indexError ? h('div', { className: 'dsh-mcp-cmdk-group' }, t('正在读取清单…')) : null,
6802
+ groups.map((group) => h('div', { key: group.name },
6803
+ h('div', { className: 'dsh-mcp-cmdk-group' }, group.name),
6804
+ group.rows.map((row) => {
6805
+ flat += 1;
6806
+ const on = flat === clampedCursor;
6807
+ return h('button', {
6808
+ key: row.key,
6809
+ type: 'button',
6810
+ 'data-cursor': flat,
6811
+ className: 'dsh-mcp-cmdk-item' + (on ? ' on' : ''),
6812
+ onMouseEnter: () => setCursor(flat),
6813
+ onClick: row.run,
6814
+ },
6815
+ h(Icon, { d: ICONS[row.icon] || ICONS.plug, size: 13 }),
6816
+ h('span', { className: 'dsh-mcp-cmdk-item-main' },
6817
+ h('span', { className: 'dsh-mcp-cmdk-item-name' }, row.name),
6818
+ row.desc ? h('span', { className: 'dsh-mcp-cmdk-item-desc' }, row.desc) : null,
6819
+ ),
6820
+ row.hint ? h('span', { className: 'dsh-mcp-cmdk-item-hint' }, row.hint) : null,
6821
+ );
6822
+ }),
6823
+ )),
6824
+ index && !rows.length ? h('div', { className: 'dsh-mcp-cmdk-group' }, t('没有匹配项')) : null,
6825
+ ),
6826
+ h('div', { className: 'dsh-mcp-cmdk-foot' },
6827
+ h('span', null, h('b', null, '↑↓'), ' ', t('选择')),
6828
+ h('span', null, h('b', null, '↵'), ' ', t('执行')),
6829
+ h('span', null, h('b', null, 'Esc'), ' ', t('关闭')),
6830
+ ),
6831
+ ),
6832
+ );
6833
+ };
6834
+
4188
6835
  function openBoardCategory(key) {
4189
6836
  boardCategoryListeners.forEach((listener) => { try { listener(key); } catch { /* 单个订阅者失败不拖累其它 */ } });
4190
6837
  openBoardPanel();
4191
6838
  }
4192
6839
  const McpBoardPage = (props) => {
4193
6840
  const { call, timer, pluginManager } = props;
4194
- const [category, setCategory] = useState('mcp');
6841
+ // §1.2 URL 记忆:分类写进 hash,刷新 / 从别处退回 / 分享链接都能回到同一页。
6842
+ // 初值只在首帧读一次(useState 惰性初始化),之后由 effect 单向写出,
6843
+ // 不做 hash→state 的反向监听:宿主可能在任意时刻改 hash,双向同步必然互相打架。
6844
+ const [category, setCategoryState] = useState(() => {
6845
+ const fromHash = readHashParam('armory');
6846
+ return BOARD_CATEGORIES.some((cat) => cat.key === fromHash) ? fromHash : 'mcp';
6847
+ });
6848
+ const setCategory = useCallback((key) => {
6849
+ setCategoryState(key);
6850
+ writeHashParams({ armory: key });
6851
+ }, []);
4195
6852
  useEffect(() => {
4196
6853
  const listener = (key) => { if (BOARD_CATEGORIES.some((cat) => cat.key === key)) setCategory(key); };
4197
6854
  boardCategoryListeners.add(listener);
4198
6855
  return () => { boardCategoryListeners.delete(listener); };
6856
+ }, [setCategory]);
6857
+ // §1.1 hover 摘要:记录当前悬停的 chip 与它的左偏移,浮层直接挂在分类条容器上,
6858
+ // 避免给每个 chip 各写一份定位逻辑(也避免 chip 内部的 overflow 裁掉浮层)。
6859
+ const [catHint, setCatHint] = useState(null); // { key, left }
6860
+ const catsRef = useRef(null);
6861
+ const showCatHint = useCallback((key, e) => {
6862
+ const el = e && e.currentTarget;
6863
+ const parent = catsRef.current;
6864
+ if (!el || !parent) return;
6865
+ const width = parent.clientWidth || 0;
6866
+ // 浮层最宽 300;靠右的 chip 会被推到边界内,宁可轻微左移也不出屏。
6867
+ const left = Math.max(0, Math.min(el.offsetLeft, Math.max(0, width - 300)));
6868
+ setCatHint({ key, left });
4199
6869
  }, []);
4200
- // 顶层分类计数:Skill 条数单独取,避免两条加载互相等待。
6870
+ const hideCatHint = useCallback(() => setCatHint(null), []);
6871
+ // 顶层分类计数:全部是「本机在手」数据,打开看板就一次性取齐,
6872
+ // 让 chip 数字常驻——而不是「进过哪一页才出现哪个」,视觉上才协调。
6873
+ // 市场是唯一例外+特例:目录在远端(24MB ZIP),不能为个数字在打开看板时
6874
+ // 全量下载;这里调 wbConnectorCachedCount 只读磁盘缓存(绝不联网),
6875
+ // 用户没进过市场时返回 null(chip 暂不显示数字),进过一次后就常驻。
4201
6876
  const [skillCount, setSkillCount] = useState(null);
4202
- // MCP 计数:与其他分类标签保持一致(此前只有 skill/tailor/expert 有数量,MCP 没有是遗漏)。
4203
6877
  const [mcpCount, setMcpCount] = useState(null);
6878
+ const [expertCount, setExpertCount] = useState(null);
6879
+ const [mkCount, setMkCount] = useState(null);
6880
+ const countsLoaded = useRef(false);
4204
6881
  useEffect(() => {
4205
- if (category !== 'mcp') return;
6882
+ if (countsLoaded.current) return;
6883
+ countsLoaded.current = true;
4206
6884
  let alive = true;
4207
6885
  call('listSkills', skillScopeArg('global')).then((res) => {
4208
6886
  if (alive && res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
4209
6887
  }).catch(() => {});
4210
- // MCP 计数与 Skill 同批取(都在打开页面时取一次;global 层与 McpTab 的默认视图一致)。
4211
6888
  call('list').then((res) => {
4212
6889
  if (alive && res && res.ok && Array.isArray(res.servers)) setMcpCount(res.servers.length);
4213
6890
  }).catch(() => {});
6891
+ // 专家计数 = 已召唤预设数(本地文件,便宜);与 Skill 同语义是「在手」而非「总量」。
6892
+ call('wbExpertInstalled').then((res) => {
6893
+ if (alive && res && res.ok && Array.isArray(res.presets)) setExpertCount(res.presets.length);
6894
+ }).catch(() => {});
6895
+ // 市场计数 = 本地缓存的连接器条数(零网络);无缓存 → null,进过市场后才有。
6896
+ call('wbConnectorCachedCount').then((res) => {
6897
+ if (alive && res && res.ok && Number.isFinite(res.total)) setMkCount(res.total);
6898
+ }).catch(() => {});
4214
6899
  return () => { alive = false; };
4215
- }, [call, category]);
4216
- // OPT-04:报告上提到这里,分类角标与 ToolTailor 页面本体共用同一份,进入该分类只扫一次。
6900
+ }, [call]);
6901
+ // OPT-04:报告上提到这里,分类角标与 ToolTailor 页面本体共用同一份。
6902
+ // 扫描时机不再等进裁缝页——账本是本机文件,打开看板就扫,橙点角标才有数。
4217
6903
  // 用「已发起」标记而不是判断报告是否非空,是为了区分「还没开始」与「发起了但失败了」:
4218
6904
  // 后者不该被角标当成「0 个死工具」,也不该让组件误以为自己该再扫一遍。
4219
6905
  const [tailorReport, setTailorReport] = useState(null);
@@ -4224,30 +6910,47 @@ const McpBoardPage = (props) => {
4224
6910
  const [tailorSettled, setTailorSettled] = useState(false);
4225
6911
  const tailorRequested = useRef(false);
4226
6912
  useEffect(() => {
4227
- if (category !== 'tailor' || tailorRequested.current) return;
6913
+ if (tailorRequested.current) return;
4228
6914
  tailorRequested.current = true;
4229
6915
  let alive = true;
4230
6916
  call('toolTailor', { days: 30 }).then((res) => {
4231
6917
  if (alive && res && res.ok) setTailorReport(res);
4232
6918
  }).catch(() => {}).finally(() => { if (alive) setTailorSettled(true); });
4233
6919
  return () => { alive = false; };
4234
- }, [call, category]);
4235
- // 首次进入该分类时父层必定会发这一发请求,因此首帧就告诉子组件「等我的」。
6920
+ }, [call]);
6921
+ // 首次进入裁缝分类时若父层还在扫,就告诉子组件「等我的」(已扫完则为 false,子组件直接用 report prop)。
4236
6922
  const tailorSeedPending = category === 'tailor' && !tailorSettled;
4237
- // 专家分类计数:已召唤数(打在分类上的是「已召唤」,与 Skill 条数语义一致——都是「在手」而非「总量」)。
4238
- const [expertCount, setExpertCount] = useState(null);
6923
+
6924
+ // §1.5 Ctrl+K:面板只在看板页挂载,所以快捷键也挂在这里——离开看板时不该
6925
+ // 还有监听器在抢键。同时提供「/」聚焦当前页搜索(只在没有输入焦点时生效,
6926
+ // 否则在搜索框里打斜杠就会跳走)。
6927
+ const [paletteOpen, setPaletteOpen] = useState(false);
4239
6928
  useEffect(() => {
4240
- if (category !== 'expert' || expertCount !== null) return;
4241
- let alive = true;
4242
- call('wbExpertInstalled').then((res) => {
4243
- if (alive && res && res.ok && Array.isArray(res.presets)) setExpertCount(res.presets.length);
4244
- }).catch(() => {});
4245
- return () => { alive = false; };
4246
- }, [call, category, expertCount]);
6929
+ const isTyping = (el) => {
6930
+ if (!el) return false;
6931
+ const tag = String(el.tagName || '').toLowerCase();
6932
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true;
6933
+ };
6934
+ const onKey = (e) => {
6935
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'k' || e.key === 'K')) {
6936
+ e.preventDefault();
6937
+ setPaletteOpen((v) => !v);
6938
+ return;
6939
+ }
6940
+ // '/' 聚焦搜索框:宿主会话页也把 '/' 当命令入口,所以这里必须避开输入态,
6941
+ // 并且只在事件目标不在可编辑元素内时才接管。
6942
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) {
6943
+ const box = document.querySelector('.dsh-mcp-page .dsh-mcp-search input, .dsh-mcp-page .dsh-mcp-tools-search input, .dsh-mcp-page .dsh-mcp-wb-search input');
6944
+ if (box) { e.preventDefault(); box.focus(); box.select?.(); }
6945
+ }
6946
+ };
6947
+ document.addEventListener('keydown', onKey);
6948
+ return () => document.removeEventListener('keydown', onKey);
6949
+ }, []);
4247
6950
 
4248
6951
  return h('div', { className: 'dsh-mcp-page' },
4249
6952
  h('div', { className: 'dsh-mcp-wrap' },
4250
- h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': t('看板分类') },
6953
+ h('div', { className: 'dsh-mcp-cats', role: 'tablist', 'aria-label': t('看板分类'), ref: catsRef },
4251
6954
  BOARD_CATEGORIES.map((cat) =>
4252
6955
  h('button', {
4253
6956
  key: cat.key,
@@ -4256,8 +6959,13 @@ const McpBoardPage = (props) => {
4256
6959
  'aria-pressed': category === cat.key,
4257
6960
  'aria-selected': category === cat.key,
4258
6961
  className: 'dsh-mcp-cat',
4259
- title: cat.key === 'mcp' ? t('MCP 服务器(原有全部能力)') : cat.key === 'skill' ? t('Skill 看板(新增)') : cat.key === 'expert' ? t('已召唤的专家:预设状态 / 捆绑技能 / 送还(召唤新专家去「市场」)') : cat.key === 'tailor' ? t('工具裁缝:工具使用账本 + 上下文税单 + 死工具清单') : t('市场:WorkBuddy 连接器 + 内置 MCP 目录 + SkillHub 技能 + 专家召唤'),
4260
6962
  onClick: () => setCategory(cat.key),
6963
+ // §1.1 hover 摘要替代原来的 title 长文案:title 要等一秒、不可扫读、
6964
+ // 移动端根本没有。摘要用同一份数据渲染成两三行事实,鼠标停住就能读。
6965
+ onMouseEnter: (e) => showCatHint(cat.key, e),
6966
+ onMouseLeave: hideCatHint,
6967
+ onFocus: (e) => showCatHint(cat.key, e),
6968
+ onBlur: hideCatHint,
4261
6969
  },
4262
6970
  h(Icon, { d: ICONS[cat.icon], size: 14 }),
4263
6971
  cat.label,
@@ -4265,12 +6973,20 @@ const McpBoardPage = (props) => {
4265
6973
  cat.key === 'skill' && skillCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, skillCount) : null,
4266
6974
  cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' ? h('span', { className: 'dsh-mcp-cat-count' }, tailorReport.deadMcpCount) : null,
4267
6975
  cat.key === 'expert' && expertCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, expertCount) : null,
6976
+ cat.key === 'wb' && mkCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mkCount) : null,
6977
+ // 异常红点:只在「这一页里有需要处理的东西」时出现,不参与布局也不改宽度。
6978
+ // 裁缝页的死 MCP 用 warn(橙)——它只是白交税,不是错误;
6979
+ // 若无死工具则完全不点,避免"每个标签都带点"导致红点失效。
6980
+ cat.key === 'tailor' && tailorReport && typeof tailorReport.deadMcpCount === 'number' && tailorReport.deadMcpCount > 0
6981
+ ? h('span', { className: 'dsh-mcp-cat-alert warn', 'aria-hidden': true })
6982
+ : null,
4268
6983
  )),
6984
+ catHint ? h(CatHintPopover, { hint: catHint, category, counts: { mcp: mcpCount, skill: skillCount, expert: expertCount, tailor: tailorReport, wb: mkCount } }) : null,
4269
6985
  ),
4270
6986
  // OPT-07:每个分类各自包一层错误边界。key 用分类名,切走再切回会重建边界,
4271
6987
  // 顺带让「重试」之外还有一条自然恢复路径。
4272
6988
  category === 'mcp'
4273
- ? h(ArmoryPanelBoundary, { key: 'mcp', hint: t('MCP 列表加载失败;Skill、专家、裁缝、市场仍可正常使用。') }, h(McpTab, { call, timer, pluginManager }))
6989
+ ? h(ArmoryPanelBoundary, { key: 'mcp', hint: t('MCP 列表加载失败;Skill、专家、裁缝、市场仍可正常使用。') }, h(McpTab, { call, timer, pluginManager, tailorReport }))
4274
6990
  : category === 'tailor'
4275
6991
  ? h(ArmoryPanelBoundary, { key: 'tailor', hint: t('工具账本加载失败;其余分类仍可正常使用。') }, h(ToolTailor, { call, timer, report: tailorReport, onReport: setTailorReport, seedPending: tailorSeedPending }))
4276
6992
  : category === 'expert'
@@ -4278,6 +6994,9 @@ const McpBoardPage = (props) => {
4278
6994
  : category === 'wb'
4279
6995
  ? h(ArmoryPanelBoundary, { key: 'wb', hint: t('市场数据加载失败;其余分类仍可正常使用。') }, h(WorkBuddyMarket, { call }))
4280
6996
  : h(ArmoryPanelBoundary, { key: 'skill', hint: t('Skill 看板加载失败;其余分类仍可正常使用。') }, h(SkillBoard, { call, timer })),
6997
+ // 命令面板挂在最外层的 wrap 里:它自己是 fixed 浮层,位置与父元素无关,
6998
+ // 但放在这里可确保它和分类正文共享同一次渲染,不会因分类切换被重建。
6999
+ h(CommandPalette, { open: paletteOpen, onClose: () => setPaletteOpen(false), call, category, onJump: setCategory }),
4281
7000
  ),
4282
7001
  );
4283
7002
  };
@@ -4292,6 +7011,24 @@ const McpBoardPage = (props) => {
4292
7011
  // localStorage 里分不辨,而后者正是要覆盖的人,所以偏向「说一次」,代价只是新装
4293
7012
  // 用户看到一条本次更新。
4294
7013
  const WHATS_NEW_SEEN_KEY = 'dsh-armory.whatsNew.seen';
7014
+ // OPT-13 · 1.3 状态化:已读版本改存 JSON 状态键 dsh-armory.whatsNew.state
7015
+ // ({"seen":"<版本>"})。这是格式迁移不是键改名——旧裸键只做读回落,弹窗版本
7016
+ // 比较语义不变,故不进 LEGACY_KEY_SUFFIXES(那是要改名的迁移表)。
7017
+ const WHATS_NEW_STATE_KEY = 'dsh-armory.whatsNew.state';
7018
+ const readWhatsNewSeen = () => {
7019
+ try {
7020
+ const raw = localStorage.getItem(WHATS_NEW_STATE_KEY);
7021
+ if (raw) {
7022
+ const state = JSON.parse(raw);
7023
+ if (state && typeof state.seen === 'string' && state.seen) return state.seen;
7024
+ }
7025
+ } catch { /* 新键缺失或 JSON 损坏:回落旧裸键(旧版格式迁移) */ }
7026
+ try { return localStorage.getItem(WHATS_NEW_SEEN_KEY); } catch { return null; /* 存储不可用:视为未读过 */ }
7027
+ };
7028
+ const writeWhatsNewSeen = (version) => {
7029
+ try { localStorage.setItem(WHATS_NEW_STATE_KEY, JSON.stringify({ seen: version })); } catch { return; /* 存储不可用:下次打开面板会再提示一次 */ }
7030
+ try { localStorage.removeItem(WHATS_NEW_SEEN_KEY); } catch { /* 旧裸键清理失败不报错,读回落仍兼容 */ }
7031
+ };
4295
7032
 
4296
7033
  // ── 一键升级:借官方 pluginManager,不自己 spawn 包管理器 ──────────────
4297
7034
  // 为什么能这么做:`@deepseek-ai/dsh-plugin-manager` 随 dsh-base / dsh-web-app 提供
@@ -4415,7 +7152,7 @@ const WhatsNewModal = ({ call }) => {
4415
7152
  .then((res) => {
4416
7153
  if (!alive || !res || !res.ok || !Array.isArray(res.entries)) return;
4417
7154
  let seen = null;
4418
- try { seen = localStorage.getItem(WHATS_NEW_SEEN_KEY); } catch { /* 存储不可用:这次仍提示,只是下次还会提示 */ }
7155
+ try { seen = readWhatsNewSeen(); } catch { /* 存储不可用:这次仍提示,只是下次还会提示 */ }
4419
7156
  const unseen = whatsNewFor(res.current, seen, res.entries);
4420
7157
  if (unseen.length) setNotice({ current: res.current, entries: unseen });
4421
7158
  })
@@ -4424,7 +7161,7 @@ const WhatsNewModal = ({ call }) => {
4424
7161
  }, []);
4425
7162
 
4426
7163
  const dismiss = () => {
4427
- try { if (notice?.current) localStorage.setItem(WHATS_NEW_SEEN_KEY, notice.current); } catch { /* 存储不可用:本次关闭有效,下次打开面板会再提示一次 */ }
7164
+ try { if (notice?.current) writeWhatsNewSeen(notice.current); } catch { /* 存储不可用:本次关闭有效,下次打开面板会再提示一次 */ }
4428
7165
  setNotice(null);
4429
7166
  };
4430
7167
 
@@ -4461,6 +7198,8 @@ const WhatsNewModal = ({ call }) => {
4461
7198
  if (target && document.contains(target) && typeof target.focus === 'function') target.focus();
4462
7199
  };
4463
7200
  }, [notice]);
7201
+ // OPT-21:上面的 focusin 守卫解决「宿主抢焦点」,但 Tab 仍可走到弹层外;这里补上循环。
7202
+ useFocusTrap(boxRef, !!notice);
4464
7203
 
4465
7204
  if (!notice) return null;
4466
7205
  return h(
@@ -4687,6 +7426,8 @@ const MCP_CONTRIBUTION = {
4687
7426
  { id: 'dsh-armory#mcpManager/toolTailor', service: 'mcpManager', namespace: 'mcpManager', method: 'toolTailor', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('TailorReport') },
4688
7427
  // ── WorkBuddy 市场直连:连接器 MCP + SkillHub 技能(同一命名空间同一信封)──
4689
7428
  { id: 'dsh-armory#mcpManager/wbConnectorCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorCatalog', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbCatalogResult') },
7429
+ // 顶栏「市场」chip 常驻计数:只读磁盘缓存(零网络),无缓存返回 { total: null }。
7430
+ { id: 'dsh-armory#mcpManager/wbConnectorCachedCount', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorCachedCount', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbCachedCountResult') },
4690
7431
  { id: 'dsh-armory#mcpManager/wbConnectorPreview', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorPreview', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPreviewResult') },
4691
7432
  { id: 'dsh-armory#mcpManager/wbConnectorInstall', service: 'mcpManager', namespace: 'mcpManager', method: 'wbConnectorInstall', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
4692
7433
  { id: 'dsh-armory#mcpManager/wbSkillCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillListResult') },